127 lines
4.7 KiB
Go
127 lines
4.7 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 clonetool on spec's host that is
|
|
// both runnable and the same build as this binary (compared via the
|
|
// "build=" tag in `version` output, see versionString/remoteBuildTag — an
|
|
// architecture mismatch alone, expected when cross-compiled, doesn't count
|
|
// as a mismatch). 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. With --deploy=false it can't correct a stale or missing
|
|
// binary, so it uses --remote-bin anyway if merely out of date (only a
|
|
// non-runnable one is still an error).
|
|
func resolveRemoteBin(cfg *SyncConfig, spec Spec, tag string) (string, error) {
|
|
localTag := remoteBuildTag(versionString())
|
|
|
|
if out, 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 {
|
|
if remoteBuildTag(out) == localTag {
|
|
return cfg.RemoteBin, nil
|
|
}
|
|
if !cfg.Deploy {
|
|
fmt.Fprintf(os.Stderr, "%s: %s on %s reports %q, a different build than this one (%s); continuing anyway (--deploy=false)\n",
|
|
tag, cfg.RemoteBin, spec.Host, firstLine(out), versionString())
|
|
return cfg.RemoteBin, nil
|
|
}
|
|
fmt.Fprintf(os.Stderr, "%s: %s on %s reports %q, a different build than this one (%s); looking for an up-to-date copy ...\n",
|
|
tag, cfg.RemoteBin, spec.Host, firstLine(out), versionString())
|
|
}
|
|
|
|
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 out, code, err := runRemote(cfg, spec, deployedRemoteBin, "version"); err == nil && code == 0 && remoteBuildTag(out) == localTag {
|
|
return deployedRemoteBin, nil
|
|
}
|
|
|
|
fmt.Fprintf(os.Stderr, "%s: no up-to-date 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
|
|
}
|