From 2da9bed009735f45236afecc3508b43d9d9d8c2b Mon Sep 17 00:00:00 2001 From: Jonas Falck Date: Fri, 13 Dec 2024 16:03:47 +0100 Subject: [PATCH 1/2] WIP bootstrap --- cmd/gmc/main.go | 11 ++- go.mod | 5 +- go.sum | 8 +- pkg/admin/admin.go | 23 +---- pkg/admin/bootstrap.go | 216 +++++++++++++++++++++++++++++++++++++++++ pkg/agent/agent.go | 22 ++++- 6 files changed, 252 insertions(+), 33 deletions(-) create mode 100644 pkg/admin/bootstrap.go diff --git a/cmd/gmc/main.go b/cmd/gmc/main.go index 650db3d..27e640e 100644 --- a/cmd/gmc/main.go +++ b/cmd/gmc/main.go @@ -233,7 +233,7 @@ func app() *cli.App { Usage: "installs the agent on a new machine.", Action: func(c *cli.Context) error { admin := admin.NewAdminFromContext(c) - return admin.Bootstrap(c.Context) + return admin.Bootstrap(c.Context, c.Args().Slice()) }, Flags: []cli.Flag{ &cli.StringFlag{ @@ -244,11 +244,16 @@ func app() *cli.App { Usage: "config file location. contains info about the master urls", }, &cli.StringFlag{ - // this is the client for SREs so it needs to have a config somewhere. - Name: "location", + Name: "target-path", Value: "/usr/local/bin", Usage: "where to put the gmc binary when bootstrapping", }, + &cli.StringFlag{ + Name: "ssh-user", + Value: "", + Aliases: []string{"u"}, + Usage: "which user to ssh as", + }, &cli.BoolFlag{ Name: "dry", Value: false, diff --git a/go.mod b/go.mod index a161fb9..cf50d66 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,7 @@ require ( github.com/sirupsen/logrus v1.9.3 github.com/stretchr/testify v1.10.0 github.com/urfave/cli/v2 v2.27.5 + golang.org/x/crypto v0.29.0 gopkg.in/yaml.v3 v3.0.1 k8s.io/apimachinery v0.31.3 ) @@ -88,9 +89,9 @@ require ( github.com/xanzy/ssh-agent v0.3.3 // indirect github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect golang.org/x/arch v0.12.0 // indirect - golang.org/x/crypto v0.29.0 // indirect golang.org/x/net v0.31.0 // indirect - golang.org/x/sys v0.27.0 // indirect + golang.org/x/sys v0.28.0 // indirect + golang.org/x/term v0.27.0 // indirect golang.org/x/text v0.20.0 // indirect google.golang.org/protobuf v1.35.2 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect diff --git a/go.sum b/go.sum index 2af9da5..616e398 100644 --- a/go.sum +++ b/go.sum @@ -552,11 +552,11 @@ golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= -golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.26.0 h1:WEQa6V3Gja/BhNxg540hBip/kkaYtRg3cxg4oXSw4AU= -golang.org/x/term v0.26.0/go.mod h1:Si5m1o57C5nBNQo5z1iq+XDijt21BDBDp2bK0QI8e3E= +golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= diff --git a/pkg/admin/admin.go b/pkg/admin/admin.go index 2180ffc..2b8afb0 100644 --- a/pkg/admin/admin.go +++ b/pkg/admin/admin.go @@ -41,7 +41,8 @@ type Admin struct { dry bool // binary location when Bootstrap - location string + targetPath string + sshUser string } func NewAdminFromContext(c *cli.Context) *Admin { @@ -50,7 +51,8 @@ func NewAdminFromContext(c *cli.Context) *Admin { selector: c.String("selector"), regexp: c.String("regexp"), dry: c.Bool("dry"), - location: c.String("location"), + targetPath: c.String("target-path"), + sshUser: c.String("ssh-user"), } } @@ -276,23 +278,6 @@ func (a *Admin) Apply(ctx context.Context, args []string) error { return nil } -func (a *Admin) Bootstrap(ctx context.Context) error { - conf, err := a.config() - if err != nil { - return err - } - - // find where own binary is located - // SCP binary to target - // ssh to target and start binary with --one-shot - // (do we need to wait that it shows up in pending list?) - // call /api/machines/accept-v1 with {"host":hostname} body - - // it will configure it-self from git and then die and start itself with systemd. - - fmt.Println(conf) - return nil -} func (a *Admin) Proxy(ctx context.Context) error { conf, err := a.config() if err != nil { diff --git a/pkg/admin/bootstrap.go b/pkg/admin/bootstrap.go new file mode 100644 index 0000000..5120729 --- /dev/null +++ b/pkg/admin/bootstrap.go @@ -0,0 +1,216 @@ +package admin + +import ( + "bytes" + "context" + "fmt" + "io" + "net" + "os" + "os/exec" + "path/filepath" + + "github.com/sirupsen/logrus" + "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/agent" +) + +func (a *Admin) Bootstrap(ctx context.Context, hosts []string) error { + conf, err := a.config() + if err != nil { + return err + } + + binaryPath, err := getSelfLocation() + if err != nil { + return err + } + + socket := os.Getenv("SSH_AUTH_SOCK") + conn, err := net.Dial("unix", socket) + if err != nil { + return err + } + + master := conf.FindMasterForConnection(ctx, "", "") + + if master == "" { + return fmt.Errorf("found no alive master") + } + + agentClient := agent.NewClient(conn) + for _, host := range hosts { + + logrus.Infof("Copying %s to %s on %s", binaryPath, a.targetPath, host) + + // find where own binary is located + // SCP binary to target + // ssh to target and start binary with --one-shot + // (do we need to wait that it shows up in pending list?) + // call /api/machines/accept-v1 with {"host":hostname} body + + // it will configure it-self from git and then die and start itself with systemd. + config := &ssh.ClientConfig{ + User: a.sshUser, + Auth: []ssh.AuthMethod{ + ssh.PublicKeysCallback(agentClient.Signers), + }, + HostKeyCallback: ssh.InsecureIgnoreHostKey(), + } + err := sshCommands(ctx, config, host, binaryPath, a.targetPath, master) + if err != nil { + return err + } + + //TODO invoke gmc agent --one-shot --master + + } + return nil +} + +func sshCommands(ctx context.Context, config *ssh.ClientConfig, host, src, dstFolder, master string) error { + client, err := ssh.Dial("tcp", host+":22", config) + if err != nil { + return err + } + defer client.Close() + err = copyFileToServer(client, src, "/tmp") // always save in /tmp and mv to correct location + if err != nil { + return err + } + + // TODO if we are not allowd sudo wihout PW write it to stdin? + // hostIn, err := session.StdinPipe() + // if err != nil { + // return err + // } + // fmt.Fprint(os.Stderr, "Enter Password: ") + + // golangci-lint says unnecessary conversion (unconvert) on this line. This is not unnecessary. Only works on windows if its this ways. + // pw, err := term.ReadPassword(int(syscall.Stdin)) + // if err != nil { + // return err + // } + // fmt.Println() + // err = session.Start(cmd) + // if err != nil { + // return err + // } + + // _, err = hostIn.Write(pw) + // if err != nil { + // return err + // } + + // return nil + dst := filepath.Join(dstFolder, filepath.Base(src)) + err = runOverSSH(ctx, client, fmt.Sprintf("sudo mv %s %s", filepath.Join("/tmp", filepath.Base(src)), dst)) + if err != nil { + return err + } + + // TODO start goroutine here that checks for the server to appear in API and need accept! then accept + + err = runOverSSH(ctx, client, fmt.Sprintf("sudo %s agent --one-shot --master %s", dst, master)) + if err != nil { + return err + } + + return nil +} + +func runOverSSH(ctx context.Context, client *ssh.Client, cmd string) error { + logrus.Infof("ssh: %s", cmd) + session, err := client.NewSession() + if err != nil { + return err + } + // session.Stdout = os.Stdout + defer session.Close() + buf := &bytes.Buffer{} + buf2 := &bytes.Buffer{} + session.Stdout = buf + session.Stderr = buf2 + session.Stdin = os.Stdin + err = session.Start(cmd) + if err != nil { + return fmt.Errorf("%w stdout: %s stderr: %s", err, buf.String(), buf2.String()) + } + + exit := make(chan struct{}, 1) + defer close(exit) + + //TODO stream session.Stdout and session.Stderr and print in own goroutine? + + go func() { + select { + case <-ctx.Done(): + if ctx.Err() != nil { + fmt.Println("stdout", buf.String(), "stderr", buf2.String()) + session.Signal(ssh.SIGINT) + session.Close() + } + case <-exit: + } + }() + + err = session.Wait() + if err != nil { + return fmt.Errorf("%w stdout: %s stderr: %s", err, buf.String(), buf2.String()) + } + + return nil +} + +func copyFileToServer(client *ssh.Client, src, dst string) error { + session, err := client.NewSession() + if err != nil { + return err + } + + defer session.Close() + + file, err := os.Open(src) + if err != nil { + return err + } + + defer file.Close() + stat, err := file.Stat() + if err != nil { + return err + } + + stdoutBuf := bytes.Buffer{} + stderrBuf := bytes.Buffer{} + session.Stdout = &stdoutBuf + session.Stderr = &stderrBuf + + hostIn, err := session.StdinPipe() + if err != nil { + return err + } + + err = session.Start("/usr/bin/scp -t " + dst) + if err != nil { + return err + } + + fmt.Fprintf(hostIn, "C0755 %d %s\n", stat.Size(), filepath.Base(src)) + _, err = io.Copy(hostIn, file) + if err != nil { + return fmt.Errorf("%w stdout: %s stderr: %s", err, stdoutBuf.String(), stderrBuf.String()) + } + + fmt.Fprint(hostIn, "\x00") + hostIn.Close() + + return session.Wait() +} +func getSelfLocation() (string, error) { + fname, err := exec.LookPath(os.Args[0]) + if err != nil { + return "", err + } + return filepath.Abs(fname) +} diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go index 50feca3..1457947 100644 --- a/pkg/agent/agent.go +++ b/pkg/agent/agent.go @@ -25,7 +25,7 @@ type OnFunc func(*protocol.WebsocketMessage) error type Agent struct { Master string - OneShot bool + oneShot bool Dry bool Hostname string Zone string @@ -42,7 +42,7 @@ func NewAgentFromContext(c *cli.Context) *Agent { m := &Agent{ Master: c.String("master"), configFile: c.String("config"), - OneShot: c.Bool("one-shot"), + oneShot: c.Bool("one-shot"), Dry: c.Bool("dry"), Hostname: c.String("hostname"), Zone: c.String("zone"), @@ -247,8 +247,7 @@ func (a *Agent) onMachineUpdate(msg *protocol.WebsocketMessage) error { return err } - recon := reconciliation.NewMachineReconciler(a.commander, a.client) - return recon.Reconcile(machine) + return a.doRecon(machine) } else { if msg.Source == protocol.ManualSource && a.config.Ignore { a.config.Ignore = false @@ -259,8 +258,21 @@ func (a *Agent) onMachineUpdate(msg *protocol.WebsocketMessage) error { } } + return a.doRecon(machine) +} + +func (a *Agent) doRecon(machine *types.Machine) error { recon := reconciliation.NewMachineReconciler(a.commander, a.client) - return recon.Reconcile(machine) + err := recon.Reconcile(machine) + + if a.oneShot { + if err != nil { + logrus.Error(err) + } + os.Exit(0) // Exit here after first sync if --one-shot is true + } + + return err } func (a *Agent) reader(ctx context.Context) { From 99b30e1a48aeeebf9b6b56e116cf30dd164b7db2 Mon Sep 17 00:00:00 2001 From: Jonas Falck Date: Tue, 18 Mar 2025 12:52:11 +0100 Subject: [PATCH 2/2] Implement bootstrap --- Jenkinsfile | 39 ++++++++++++++++++++ Makefile | 13 +++++++ e2e/httpclient_test.go | 52 -------------------------- e2e/utils_test.go | 7 +++- pkg/admin/bootstrap.go | 2 - pkg/authedhttpclient/client.go | 67 ++++++++++++++++++++++++++++++++++ 6 files changed, 124 insertions(+), 56 deletions(-) create mode 100644 Jenkinsfile create mode 100644 pkg/authedhttpclient/client.go diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000..1b6f8f1 --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,39 @@ +properties( + [ + buildDiscarder( + logRotator( + numToKeepStr: '5' + ) + ) + ] +) + +node('go1.23') { + container('run'){ + def tag = '' + stage('Checkout') { + checkout scm + tag = sh(script: 'git tag -l --contains HEAD', returnStdout: true).trim() + } + + stage('Fetch dependencies') { + // using ID because: https://issues.jenkins-ci.org/browse/JENKINS-32101 + sshagent(credentials: ['18270936-0906-4c40-a90e-bcf6661f501d']) { + sh('go mod download') + } + } + + stage('Run test') { + sh('make test') + } + + if (env.BRANCH_NAME == 'main' && tag != '') { + stage('Generate and push docker image'){ + docker.withRegistry("https://quay.io", 'docker-registry') { + strippedTag = tag.replaceFirst('v', '') + sh("make push VERSION=${strippedTag}") + } + } + } + } +} diff --git a/Makefile b/Makefile index d08669c..95af4eb 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,19 @@ .PHONY: test imports SHELL := /bin/bash +VERSION?=0.0.1-local + +IMAGE = quay.io/fortnox/gitmachinecontroller + +build: + CGO_ENABLED=0 GOOS=linux go build + +docker: build + docker build --pull --rm -t $(IMAGE):$(VERSION) . + +push: docker + docker push $(IMAGE):$(VERSION) + test: imports go test -v ./... diff --git a/e2e/httpclient_test.go b/e2e/httpclient_test.go index bff11bf..96d1c32 100644 --- a/e2e/httpclient_test.go +++ b/e2e/httpclient_test.go @@ -1,64 +1,12 @@ package e2e_test import ( - "encoding/json" "io" - "net/http" - "strings" "testing" "github.com/stretchr/testify/assert" ) -type authedHttpClient struct { - Token string - baseURL string -} - -func NewAuthedHttpClient(t *testing.T, baseURL string) *authedHttpClient { - type tokenStruct struct { - Jwt string - } - - resp, err := http.Post(baseURL+"/api/admin-v1", "application/json", nil) - assert.NoError(t, err) - defer resp.Body.Close() - - token := &tokenStruct{} - err = json.NewDecoder(resp.Body).Decode(token) - assert.NoError(t, err) - - return &authedHttpClient{ - Token: token.Jwt, - baseURL: baseURL, - } -} - -func (ahc *authedHttpClient) Post(u string, body io.Reader) (resp *http.Response, err error) { - u = strings.TrimLeft(u, "/") - - req, err := http.NewRequest(http.MethodPost, ahc.baseURL+"/"+u, body) - if err != nil { - return nil, err - } - - req.Header.Add("Authorization", ahc.Token) - - return http.DefaultClient.Do(req) -} -func (ahc *authedHttpClient) Get(u string) (resp *http.Response, err error) { - u = strings.TrimLeft(u, "/") - - req, err := http.NewRequest(http.MethodGet, ahc.baseURL+"/"+u, nil) - if err != nil { - return nil, err - } - - req.Header.Add("Authorization", ahc.Token) - - return http.DefaultClient.Do(req) -} - func getBody(t *testing.T, b io.ReadCloser) string { d, err := io.ReadAll(b) diff --git a/e2e/utils_test.go b/e2e/utils_test.go index 4d0f8a3..7ac2f88 100644 --- a/e2e/utils_test.go +++ b/e2e/utils_test.go @@ -14,13 +14,14 @@ import ( "github.com/fortnoxab/gitmachinecontroller/mocks" "github.com/fortnoxab/gitmachinecontroller/pkg/agent" "github.com/fortnoxab/gitmachinecontroller/pkg/agent/config" + "github.com/fortnoxab/gitmachinecontroller/pkg/authedhttpclient" "github.com/fortnoxab/gitmachinecontroller/pkg/master" "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" ) type testWrapper struct { - client *authedHttpClient + client *authedhttpclient.Client master *master.Master agent *agent.Agent commander *mocks.MockCommander @@ -71,7 +72,9 @@ func initMasterAgent(t *testing.T, ctx context.Context) testWrapper { }() time.Sleep(400 * time.Millisecond) - client := NewAuthedHttpClient(t, "http://localhost:"+portStr) + client := authedhttpclient.New(t, "http://localhost:"+portStr) + err = client.AuthAsAdmin() + assert.NoError(t, err) t.Cleanup(func() { os.Remove("./agentConfig") diff --git a/pkg/admin/bootstrap.go b/pkg/admin/bootstrap.go index 5120729..58c1eb4 100644 --- a/pkg/admin/bootstrap.go +++ b/pkg/admin/bootstrap.go @@ -62,8 +62,6 @@ func (a *Admin) Bootstrap(ctx context.Context, hosts []string) error { return err } - //TODO invoke gmc agent --one-shot --master - } return nil } diff --git a/pkg/authedhttpclient/client.go b/pkg/authedhttpclient/client.go new file mode 100644 index 0000000..5b19a61 --- /dev/null +++ b/pkg/authedhttpclient/client.go @@ -0,0 +1,67 @@ +package authedhttpclient + +import ( + "encoding/json" + "io" + "net/http" + "strings" + "testing" +) + +type Client struct { + Token string + baseURL string +} + +func New(t *testing.T, baseURL string) *Client { + return &Client{ + baseURL: baseURL, + } +} + +func (ahc *Client) AuthAsAdmin() error { + type tokenStruct struct { + Jwt string + } + + resp, err := http.Post(ahc.baseURL+"/api/admin-v1", "application/json", nil) + if err != nil { + return err + } + + defer resp.Body.Close() + + token := &tokenStruct{} + err = json.NewDecoder(resp.Body).Decode(token) + if err != nil { + return err + } + + ahc.Token = token.Jwt + return nil +} + +func (ahc *Client) Post(u string, body io.Reader) (resp *http.Response, err error) { + u = strings.TrimLeft(u, "/") + + req, err := http.NewRequest(http.MethodPost, ahc.baseURL+"/"+u, body) + if err != nil { + return nil, err + } + + req.Header.Add("Authorization", ahc.Token) + + return http.DefaultClient.Do(req) +} +func (ahc *Client) Get(u string) (resp *http.Response, err error) { + u = strings.TrimLeft(u, "/") + + req, err := http.NewRequest(http.MethodGet, ahc.baseURL+"/"+u, nil) + if err != nil { + return nil, err + } + + req.Header.Add("Authorization", ahc.Token) + + return http.DefaultClient.Do(req) +}