From 0459fac5d31f3d70bf6b6cc65e290cf9758b99eb Mon Sep 17 00:00:00 2001 From: Julien Dessaux Date: Fri, 3 May 2024 00:24:21 +0200 Subject: feat(gonf): implement the gonf deploy cli command --- cmd/gonf/cmd_deploy.go | 57 +++++++++++++++++ cmd/gonf/main.go | 2 + cmd/gonf/ssh.go | 162 +++++++++++++++++++++++++++++++++++++++++++++++++ go.mod | 4 ++ go.sum | 6 ++ 5 files changed, 231 insertions(+) create mode 100644 cmd/gonf/cmd_deploy.go create mode 100644 cmd/gonf/ssh.go create mode 100644 go.sum diff --git a/cmd/gonf/cmd_deploy.go b/cmd/gonf/cmd_deploy.go new file mode 100644 index 0000000..c541a25 --- /dev/null +++ b/cmd/gonf/cmd_deploy.go @@ -0,0 +1,57 @@ +package main + +import ( + "context" + "flag" + "io" + "log/slog" + "path/filepath" +) + +func cmdDeploy(ctx context.Context, + f *flag.FlagSet, + args []string, + getenv func(string) string, + stdout, stderr io.Writer, +) error { + f.Init(`gonf deploy [-FLAG] +where FLAG can be one or more of`, flag.ContinueOnError) + hostFlag := addHostFlag(f) + f.SetOutput(stderr) + _ = f.Parse(args) + if helpMode { + f.SetOutput(stdout) + f.Usage() + } + hostDir, err := hostFlagToHostDir(f, hostFlag) + if err != nil { + f.Usage() + return err + } + _ = hostDir + return runDeploy(ctx, getenv, stdout, stderr, *hostFlag, hostDir) +} + +func runDeploy(ctx context.Context, + getenv func(string) string, + stdout, stderr io.Writer, + hostFlag string, + hostDir string, +) error { + sshc, err := newSSHClient(ctx, getenv, hostFlag+":22") + if err != nil { + slog.Error("deploy", "action", "newSshClient", "error", err) + return err + } + defer func() { + if e := sshc.Close(); err == nil { + err = e + } + }() + + if err = sshc.SendFile(ctx, stdout, stderr, filepath.Join(hostDir, hostFlag)); err != nil { + slog.Error("deploy", "action", "SendFile", "error", err) + } + + return err +} diff --git a/cmd/gonf/main.go b/cmd/gonf/main.go index fe6f065..99817c4 100644 --- a/cmd/gonf/main.go +++ b/cmd/gonf/main.go @@ -76,6 +76,8 @@ where FLAG can be one or more of`, flag.ContinueOnError) switch cmd { case "build": return cmdBuild(ctx, f, argsTail, getenv, stdout, stderr) + case "deploy": + return cmdDeploy(ctx, f, argsTail, getenv, stdout, stderr) default: f.Usage() return fmt.Errorf("invalid command: %s", cmd) diff --git a/cmd/gonf/ssh.go b/cmd/gonf/ssh.go new file mode 100644 index 0000000..47bf2c5 --- /dev/null +++ b/cmd/gonf/ssh.go @@ -0,0 +1,162 @@ +package main + +import ( + "context" + "fmt" + "io" + "net" + "os" + "path/filepath" + "sync" + "time" + + "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/agent" + "golang.org/x/crypto/ssh/knownhosts" +) + +type sshClient struct { + agentConn net.Conn + client *ssh.Client +} + +func newSSHClient(context context.Context, + getenv func(string) string, + destination string, +) (*sshClient, error) { + var sshc sshClient + var err error + + socket := getenv("SSH_AUTH_SOCK") + if sshc.agentConn, err = net.Dial("unix", socket); err != nil { + return nil, fmt.Errorf("failed to open SSH_AUTH_SOCK: %+v", err) + } + agentClient := agent.NewClient(sshc.agentConn) + + hostKeyCallback, err := knownhosts.New(filepath.Join(getenv("HOME"), ".ssh/known_hosts")) + if err != nil { + return nil, fmt.Errorf("could not create hostkeycallback function: %+v", err) + } + + config := &ssh.ClientConfig{ + User: "root", + Auth: []ssh.AuthMethod{ + ssh.PublicKeysCallback(agentClient.Signers), + }, + HostKeyCallback: hostKeyCallback, + } + sshc.client, err = ssh.Dial("tcp", destination, config) + if err != nil { + return nil, fmt.Errorf("failed to dial: %+v", err) + } + return &sshc, nil +} + +func (sshc *sshClient) Close() error { + if err := sshc.client.Close(); err != nil { + return err + } + if err := sshc.agentConn.Close(); err != nil { + return err + } + return nil +} + +func (sshc *sshClient) SendFile(ctx context.Context, + stdout, stderr io.Writer, + filename string, +) (err error) { + session, err := sshc.client.NewSession() + if err != nil { + return fmt.Errorf("sshClient failed to create session: %+v", err) + } + defer func() { + if e := session.Close(); err == nil && e != io.EOF { + err = e + } + }() + + file, err := os.Open(filename) + if err != nil { + return fmt.Errorf("sshClient failed to open file: %+v", err) + } + defer func() { + if e := file.Close(); err == nil { + err = e + } + }() + + fi, err := file.Stat() + if err != nil { + return fmt.Errorf("sshClient failed to stat file: %+v", err) + } + + w, err := session.StdinPipe() + if err != nil { + return fmt.Errorf("sshClient failed to open session stdin pipe: %+v", err) + } + + wg := sync.WaitGroup{} + wg.Add(2) + errCh := make(chan error, 2) + + session.Stdout = stdout + session.Stderr = stderr + if err = session.Start("scp -t /usr/local/bin/gonf-run"); err != nil { + return fmt.Errorf("sshClient failed to run scp: %+v", err) + } + go func() { + defer wg.Done() + if e := session.Wait(); e != nil { + errCh <- e + } + }() + + go func() { + defer wg.Done() + defer func() { + if e := w.Close(); e != nil { + errCh <- e + } + }() + // Write "C{mode} {size} {filename}\n" + if _, e := fmt.Fprintf(w, "C%#o %d %s\n", 0700, fi.Size(), "gonf-run"); e != nil { + errCh <- e + return + } + // Write the file's contents. + if _, e := io.Copy(w, file); e != nil { + errCh <- e + return + } + // End with a null byte. + if _, e := fmt.Fprint(w, "\x00"); e != nil { + errCh <- e + } + }() + + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, 60*time.Second) + defer cancel() + + // wait for all waitgroup.Done() or the timeout + c := make(chan struct{}) + go func() { + wg.Wait() + close(c) + }() + select { + case <-c: + case <-ctx.Done(): + return ctx.Err() + } + + close(errCh) + for err := range errCh { + if err != nil { + return err + } + } + + return nil +} diff --git a/go.mod b/go.mod index d220e11..5e4be7e 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,7 @@ module git.adyxax.org/adyxax/gonf/v2 go 1.22.1 + +require golang.org/x/crypto v0.22.0 + +require golang.org/x/sys v0.19.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..1e76b40 --- /dev/null +++ b/go.sum @@ -0,0 +1,6 @@ +golang.org/x/crypto v0.22.0 h1:g1v0xeRhjcugydODzvb3mEM9SQ0HGp9s/nh3COQ/C30= +golang.org/x/crypto v0.22.0/go.mod h1:vr6Su+7cTlO45qkww3VDJlzDn0ctJvRgYbC2NvXHt+M= +golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o= +golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.19.0 h1:+ThwsDv+tYfnJFhF4L8jITxu1tdTWRTZpdsWgEgjL6Q= +golang.org/x/term v0.19.0/go.mod h1:2CuTdWZ7KHSQwUzKva0cbMg6q2DMI3Mmxp+gKJbskEk= -- cgit v1.2.3