clonetool/deploy.go

110 lines
3.8 KiB
Go

package main
import (
"bytes"
"errors"
"fmt"
"os"
"os/exec"
"runtime"
"strings"
)
// deployedRemoteBin is where clonetool copies itself on a remote host that
// doesn't already have a runnable one — a plain file under the user's home,
// no install, no root.
const deployedRemoteBin = ".clonetool/bin/clonetool"
// resolveRemoteBin returns the path to a runnable clonetool on spec's host.
// It checks the configured --remote-bin first, then a copy deployed by an
// earlier run, and finally streams this binary over and verifies it runs.
func resolveRemoteBin(cfg *SyncConfig, spec Spec, tag string) (string, error) {
if _, code, err := runRemote(cfg, spec, cfg.RemoteBin, "version"); err != nil {
return "", fmt.Errorf("%s: cannot reach %s over ssh: %w", tag, spec.Host, err)
} else if code == 0 {
return cfg.RemoteBin, nil
}
if !cfg.Deploy {
return "", fmt.Errorf("%s: %q is not runnable on %s and --deploy=false; "+
"install clonetool there or pass --remote-bin", tag, cfg.RemoteBin, spec.Host)
}
if _, code, err := runRemote(cfg, spec, deployedRemoteBin, "version"); err == nil && code == 0 {
return deployedRemoteBin, nil
}
fmt.Fprintf(os.Stderr, "%s: no runnable clonetool on %s; copying this binary there ...\n", tag, spec.Host)
if err := deployBinary(cfg, spec); err != nil {
return "", fmt.Errorf("%s: copy clonetool to %s: %w", tag, spec.Host, err)
}
out, code, err := runRemote(cfg, spec, deployedRemoteBin, "version")
if err != nil {
return "", fmt.Errorf("%s: verify clonetool on %s: %w", tag, spec.Host, err)
}
if code != 0 {
return "", fmt.Errorf(
"%s: copied clonetool to %s but it will not run there (%s). This binary is %s/%s; "+
"build one for the remote's architecture (e.g. CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build) "+
"and put it on PATH there or point --remote-bin at it",
tag, spec.Host, firstLine(out), runtime.GOOS, runtime.GOARCH)
}
fmt.Fprintf(os.Stderr, "%s: deployed to %s:~/%s (%s)\n", tag, spec.Host, deployedRemoteBin, strings.TrimSpace(out))
return deployedRemoteBin, nil
}
// runRemote runs `bin args...` on spec's host over ssh and returns its
// combined output and process exit code. A non-nil error means ssh itself
// could not run (connection/auth failure); a remote command that merely
// exits non-zero returns (output, code, nil).
func runRemote(cfg *SyncConfig, spec Spec, bin string, args ...string) (string, int, error) {
remoteArgs := append([]string{bin}, args...)
cmd := sshCommand(cfg.SSHBin, cfg.SSHOpts, false, cfg.ConnectTimeoutSec, spec.User, spec.Host, remoteArgs)
var buf bytes.Buffer
cmd.Stdout = &buf
cmd.Stderr = &buf
err := cmd.Run()
if err == nil {
return buf.String(), 0, nil
}
var ee *exec.ExitError
if errors.As(err, &ee) {
return buf.String(), ee.ExitCode(), nil
}
return buf.String(), -1, err
}
// deployBinary streams this executable to ~/.clonetool/bin/clonetool on
// spec's host, writing to a temp name and renaming into place so a
// concurrent run never sees a half-written file.
func deployBinary(cfg *SyncConfig, spec Spec) error {
f, err := os.Open(selfExe())
if err != nil {
return err
}
defer f.Close()
const script = `set -e; d="$HOME/.clonetool/bin"; mkdir -p "$d"; ` +
`cat > "$d/clonetool.new"; chmod 755 "$d/clonetool.new"; mv "$d/clonetool.new" "$d/clonetool"`
cmd := sshCommand(cfg.SSHBin, cfg.SSHOpts, false, cfg.ConnectTimeoutSec, spec.User, spec.Host, []string{"sh", "-c", script})
cmd.Stdin = f
var errb bytes.Buffer
cmd.Stderr = &errb
if err := cmd.Run(); err != nil {
if msg := strings.TrimSpace(errb.String()); msg != "" {
return fmt.Errorf("%w: %s", err, firstLine(msg))
}
return err
}
return nil
}
func firstLine(s string) string {
s = strings.TrimSpace(s)
if i := strings.IndexByte(s, '\n'); i >= 0 {
return s[:i]
}
return s
}