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 }