feat(installer): build worker source over SSH
Все проверки выполнены успешно
CI / test (push) Successful in 3m13s
Docker / Build and publish worker image (push) Successful in 10m35s

Этот коммит содержится в:
Gleb Tv
2026-08-13 00:07:43 +03:00
родитель 4651deb280
Коммит bd6070ee1f
18 изменённых файлов: 2194 добавлений и 96 удалений

Просмотреть файл

@@ -18,7 +18,14 @@ import (
"golang.org/x/crypto/ssh/knownhosts"
)
type DeployOptions struct {
// SSHOptions is the SSH connection and authentication surface shared by
// the deploy command and the source installer. Secrets (passwords,
// passphrases, sudo passwords) can be supplied through direct flags or
// file options. The CLI layer should strongly prefer file options: when
// read from a file they never appear in command arguments, logs, or
// shell history, while direct flags expose the value through the process
// list and shell history.
type SSHOptions struct {
Host string
Port int
User string
@@ -29,12 +36,16 @@ type DeployOptions struct {
KnownHostsFile string
HostKeyFingerprint string
InsecureHostKey bool
Binary string
Token string
URL string
Docker bool
Image string
NoStart bool
}
type DeployOptions struct {
SSHOptions
Binary string
Token string
URL string
Docker bool
Image string
NoStart bool
}
func Deploy(opts DeployOptions) error {
@@ -71,11 +82,11 @@ func Deploy(opts DeployOptions) error {
return err
}
}
auth, err := sshAuth(opts)
auth, err := sshAuth(opts.SSHOptions)
if err != nil {
return err
}
hostKey, err := hostKeyCallback(opts)
hostKey, err := hostKeyCallback(opts.SSHOptions)
if err != nil {
return err
}
@@ -111,16 +122,7 @@ func Deploy(opts DeployOptions) error {
if opts.NoStart {
args += " --no-start"
}
var command string
var stdin []byte
if opts.User == "root" {
command = args
} else if opts.SudoPassword != "" {
command = "sudo -S -p '' -- " + args
stdin = []byte(opts.SudoPassword + "\n")
} else {
command = "sudo -n -- " + args
}
command, stdin := sudoWrap(opts.User, opts.SudoPassword, args)
if err := runRemote(client, command, stdin); err != nil {
return fmt.Errorf("remote install: %w", err)
}
@@ -137,23 +139,30 @@ func deployDocker(client *ssh.Client, opts DeployOptions, remoteEnv, remoteUnit
if !opts.NoStart {
script += " && systemctl restart rsmon-worker.service && systemctl is-active --quiet rsmon-worker.service"
}
command := "sh -c " + shellQuote(script)
var stdin []byte
if opts.User != "root" {
if opts.SudoPassword != "" {
command = "sudo -S -p '' -- " + command
stdin = []byte(opts.SudoPassword + "\n")
} else {
command = "sudo -n -- " + command
}
}
command, stdin := sudoWrap(opts.User, opts.SudoPassword, "sh -c "+shellQuote(script))
if err := runRemote(client, command, stdin); err != nil {
return fmt.Errorf("remote Docker install: %w", err)
}
return nil
}
func sshAuth(opts DeployOptions) ([]ssh.AuthMethod, error) {
// sudoWrap prefixes a remote command with the privilege path required to
// run it as root: the plain command for a root user, `sudo -n` when the
// user has passwordless sudo, and `sudo -S` with an empty prompt when a
// sudo password is configured. The sudo password is delivered only over
// the session's stdin, never in the command string, so it cannot appear
// in process listings, logs, or shell history.
func sudoWrap(user, sudoPassword, command string) (string, []byte) {
if user == "root" {
return command, nil
}
if sudoPassword != "" {
return "sudo -S -p '' -- " + command, []byte(sudoPassword + "\n")
}
return "sudo -n -- " + command, nil
}
func sshAuth(opts SSHOptions) ([]ssh.AuthMethod, error) {
var methods []ssh.AuthMethod
if opts.IdentityFile != "" {
key, err := os.ReadFile(opts.IdentityFile)
@@ -180,7 +189,7 @@ func sshAuth(opts DeployOptions) ([]ssh.AuthMethod, error) {
return methods, nil
}
func hostKeyCallback(opts DeployOptions) (ssh.HostKeyCallback, error) {
func hostKeyCallback(opts SSHOptions) (ssh.HostKeyCallback, error) {
if opts.HostKeyFingerprint != "" {
want := opts.HostKeyFingerprint
return func(_ string, _ net.Addr, key ssh.PublicKey) error {
@@ -242,6 +251,79 @@ func uploadReader(client *ssh.Client, src io.Reader, remotePath string, mode os.
}
func runRemote(client *ssh.Client, command string, stdin []byte) error {
// Deploy commands are short and stream to the terminal; they keep the
// historical behavior with no timeout.
return runRemoteWithTimeout(client, command, stdin, os.Stdout, os.Stderr, 0)
}
// maxRemoteError bounds the stderr snippet folded into runRemoteOutput
// errors so a verbose remote failure cannot produce an unbounded error
// string.
const maxRemoteError = 4096
// maxRemoteOutput bounds the stdout captured by runRemoteOutput so a
// noisy remote command cannot exhaust memory.
const maxRemoteOutput = 1 << 20 // 1 MiB
// boundedBuffer is an io.Writer that silently discards everything past
// max bytes and remembers whether truncation happened.
type boundedBuffer struct {
buf bytes.Buffer
max int
truncated bool
}
func (b *boundedBuffer) Write(p []byte) (int, error) {
if len(p) == 0 {
return 0, nil
}
if b.buf.Len() >= b.max {
b.truncated = true
return len(p), nil
}
remaining := b.max - b.buf.Len()
if len(p) > remaining {
b.buf.Write(p[:remaining])
b.truncated = true
return len(p), nil
}
return b.buf.Write(p)
}
func (b *boundedBuffer) Bytes() []byte { return b.buf.Bytes() }
func (b *boundedBuffer) String() string { return b.buf.String() }
// runRemoteOutput executes a remote command and returns its captured,
// size-bounded stdout. Stderr is folded into the returned error on
// failure (bounded to maxRemoteError bytes) so operators see what went
// wrong without a bounded failure dumping unbounded output. The command
// is aborted if it outlives timeout (<= 0 disables the timeout).
func runRemoteOutput(client *ssh.Client, command string, stdin []byte, timeout time.Duration) ([]byte, error) {
var stdout, stderr boundedBuffer
stdout.max = maxRemoteOutput
stderr.max = maxRemoteError
if err := runRemoteWithTimeout(client, command, stdin, &stdout, &stderr, timeout); err != nil {
msg := strings.TrimSpace(stderr.String())
if msg != "" {
if stderr.truncated {
msg += "..."
}
return stdout.Bytes(), fmt.Errorf("%w: %s", err, msg)
}
return stdout.Bytes(), err
}
out := stdout.Bytes()
if stdout.truncated {
out = append(out, []byte("\n...[output truncated]")...)
}
return out, nil
}
// runRemoteWithTimeout runs a remote command, optionally aborting it
// when it outlives timeout (<= 0 disables the timeout). The session is
// closed and the blocked Run is unblocked when the timer fires.
func runRemoteWithTimeout(client *ssh.Client, command string, stdin []byte, stdout, stderr io.Writer, timeout time.Duration) error {
session, err := client.NewSession()
if err != nil {
return err
@@ -250,9 +332,24 @@ func runRemote(client *ssh.Client, command string, stdin []byte) error {
if stdin != nil {
session.Stdin = bytes.NewReader(stdin)
}
session.Stdout = os.Stdout
session.Stderr = os.Stderr
return session.Run(command)
session.Stdout = stdout
session.Stderr = stderr
if timeout <= 0 {
return session.Run(command)
}
done := make(chan error, 1)
go func() { done <- session.Run(command) }()
timer := time.NewTimer(timeout)
defer timer.Stop()
select {
case err := <-done:
return err
case <-timer.C:
_ = session.Close() // abort the remote command and free the session
<-done
return fmt.Errorf("remote command timed out after %s", timeout)
}
}
func shellQuote(value string) string {

Просмотреть файл

@@ -19,14 +19,14 @@ func TestFingerprintHostKeyCallback(t *testing.T) {
if err != nil {
t.Fatal(err)
}
callback, err := hostKeyCallback(DeployOptions{HostKeyFingerprint: ssh.FingerprintSHA256(publicKey)})
callback, err := hostKeyCallback(SSHOptions{HostKeyFingerprint: ssh.FingerprintSHA256(publicKey)})
if err != nil {
t.Fatal(err)
}
if err := callback("host", &net.TCPAddr{}, publicKey); err != nil {
t.Fatalf("matching fingerprint rejected: %v", err)
}
callback, err = hostKeyCallback(DeployOptions{HostKeyFingerprint: "SHA256:wrong"})
callback, err = hostKeyCallback(SSHOptions{HostKeyFingerprint: "SHA256:wrong"})
if err != nil {
t.Fatal(err)
}
@@ -36,15 +36,17 @@ func TestFingerprintHostKeyCallback(t *testing.T) {
}
func TestKnownHostsMissingFile(t *testing.T) {
if _, err := hostKeyCallback(DeployOptions{KnownHostsFile: t.TempDir() + "/missing"}); err == nil {
if _, err := hostKeyCallback(SSHOptions{KnownHostsFile: t.TempDir() + "/missing"}); err == nil {
t.Fatal("missing known_hosts file accepted")
}
}
func TestDeployRejectsMutableDockerImageBeforeConnecting(t *testing.T) {
err := Deploy(DeployOptions{
Host: "unreachable.example.test",
User: "deploy",
SSHOptions: SSHOptions{
Host: "unreachable.example.test",
User: "deploy",
},
Token: "token",
Docker: true,
Image: "reg.rsxx.ru/rsmon/rsmon-worker:latest",

Просмотреть файл

@@ -168,6 +168,14 @@ func (h *Harness) Addr() string {
return net.JoinHostPort("127.0.0.1", strconv.Itoa(h.port))
}
// Port returns the published host port of the container's SSH listener
// (the host is always 127.0.0.1). 0 before Start.
func (h *Harness) Port() int {
h.mu.Lock()
defer h.mu.Unlock()
return h.port
}
// Start builds the fixture image, starts the container, waits for real
// SSH readiness, captures the server host key into a temp known_hosts
// file, and records the published port. Every error path cleans up the
@@ -201,13 +209,10 @@ func (h *Harness) Start(ctx context.Context) error {
// docker run -d prints the container id directly, so no lookup is
// needed; the container name is the stable handle for later docker
// calls and the id is captured for diagnostics and assertions.
out, err := dockerCmd(
ctx, "run", "-d",
"--name", h.container,
"--network", h.network,
"-p", "127.0.0.1::22",
h.imageTag,
)
runArgs := []string{"run", "-d", "--name", h.container, "--network", h.network, "-p", "127.0.0.1::22"}
runArgs = append(runArgs, dockerDNS()...)
runArgs = append(runArgs, h.imageTag)
out, err := dockerCmd(ctx, runArgs...)
if err != nil {
return fmt.Errorf("start %s fixture container: %w", h.Fixture.Name, err)
}
@@ -244,6 +249,23 @@ func (h *Harness) Start(ctx context.Context) error {
return nil
}
// dockerDNS returns the `--dns` arguments to pin for fixture containers,
// parsed from the comma-separated RSMON_TEST_DOCKER_DNS environment
// variable. It is empty by default (Docker's embedded DNS). The override
// exists so environments with flaky local resolvers can pin a reliable
// upstream for the internet-facing installs (go.dev, rocketgit.ru,
// proxy.golang.org), which would otherwise fail intermittently on DNS
// timeouts.
func dockerDNS() []string {
var args []string
for _, ns := range strings.Split(os.Getenv("RSMON_TEST_DOCKER_DNS"), ",") {
if ns = strings.TrimSpace(ns); ns != "" {
args = append(args, "--dns", ns)
}
}
return args
}
// Stop releases every resource the harness created: the container, its
// dedicated network, the per-instance fixture image tag (never a shared
// base image), and the temp known_hosts directory. It is idempotent and

Просмотреть файл

@@ -235,6 +235,21 @@ func TestSetDockerBin(t *testing.T) {
SetDockerBin("")
}
func TestDockerDNSOverride(t *testing.T) {
t.Setenv("RSMON_TEST_DOCKER_DNS", "")
if got := dockerDNS(); len(got) != 0 {
t.Fatalf("dockerDNS() with empty env = %v, want none", got)
}
t.Setenv("RSMON_TEST_DOCKER_DNS", "8.8.8.8, 1.1.1.1")
if got := dockerDNS(); len(got) != 4 || got[0] != "--dns" || got[1] != "8.8.8.8" || got[3] != "1.1.1.1" {
t.Fatalf("dockerDNS() = %v", got)
}
t.Setenv("RSMON_TEST_DOCKER_DNS", " ,,")
if got := dockerDNS(); len(got) != 0 {
t.Fatalf("dockerDNS() with blank entries = %v", got)
}
}
// writeStubDocker installs a fake docker binary that records its argv to
// logPath and returns the recorded path. The stub succeeds for build,
// network, run, and teardown calls; `port` fails so Start fails after

Просмотреть файл

@@ -1,6 +1,7 @@
package harness
import (
"bytes"
"context"
"crypto/ed25519"
"crypto/rand"
@@ -14,6 +15,7 @@ import (
"golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/knownhosts"
"rocketgit.ru/rsmon/worker/internal/installer"
"rocketgit.ru/rsmon/worker/internal/sshinstall"
)
@@ -266,6 +268,236 @@ func TestHarnessHostKeyStable(t *testing.T) {
}
}
// TestSourceInstallFixtures is the work-package-3 acceptance test: each
// distro fixture starts clean (no Go, no worker source) and the real
// installer executes the full source flow over SSH - prerequisite
// install, verified Go toolchain download/extraction, clone/update of the
// public repository, resolved branch/commit record, and a build to a
// staging path. The running service and its config are deliberately not
// installed (that is work package 4). A rerun exercises idempotency.
//
// Opt-in: set RSMON_TEST_DOCKER=1 (see make test-ssh).
func TestSourceInstallFixtures(t *testing.T) {
SkipUnlessEnabled(t)
for _, f := range Fixtures() {
f := f
t.Run(f.Name, func(t *testing.T) {
h, err := New("source-"+f.Name, f)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
if err := h.Start(ctx); err != nil {
t.Fatalf("start %s fixture: %v", f.Name, err)
}
t.Cleanup(func() {
if err := h.Stop(); err != nil {
t.Errorf("stop %s fixture: %v", f.Name, err)
}
})
// Clean state: no Go toolchain, no source, no leftover
// toolchain temp dirs.
client, err := h.Dial()
if err != nil {
t.Fatalf("dial %s fixture: %v", f.Name, err)
}
probe, err := RunCommand(client, "command -v go || true; test ! -e /usr/local/go && echo NO_GO; test ! -e /opt/rsmon-worker-src && echo NO_SOURCE; ls /tmp | grep -q rsmon-toolchain && echo LEAK; ls /usr/local 2>/dev/null | grep -qE 'go-staging|go-backup' && echo LEAK; echo DONE")
if err != nil {
t.Fatalf("clean-state probe: %v", err)
}
clean := string(probe)
if !strings.Contains(clean, "NO_GO") || !strings.Contains(clean, "NO_SOURCE") {
t.Fatalf("fixture is not clean: %q", clean)
}
if strings.Contains(clean, "LEAK") {
t.Fatalf("fixture has leftover toolchain temp dirs: %q", clean)
}
client.Close() //nolint:errcheck
opts := installer.SourceInstallOptions{
SSHOptions: installer.SSHOptions{
Host: "127.0.0.1",
Port: h.Port(),
User: f.UserOrDefault(),
IdentityFile: testKeyPath(),
KnownHostsFile: h.KnownHostsPath(),
},
}
if repo := strings.TrimSpace(os.Getenv("RSMON_TEST_SOURCE_REPO")); repo != "" {
opts.Repo = repo
}
if branch := strings.TrimSpace(os.Getenv("RSMON_TEST_SOURCE_BRANCH")); branch != "" {
opts.Branch = branch
}
res, err := installer.SourceInstall(opts)
if err != nil {
t.Fatalf("source install on %s: %v", f.Name, err)
}
t.Logf("%s: %s -> branch=%s commit=%s staged=%s", f.Name, res.Detection.Summarize(),
res.ResolvedBranch, res.ResolvedCommit, res.StageBinary)
if res.Detection.Distro != f.Distro || res.Detection.PackageManager != f.Pkg || res.Detection.InitSystem != f.Init {
t.Fatalf("detection = %+v, want %s/%s/%s", res.Detection, f.Distro, f.Pkg, f.Init)
}
if res.GoArch != "linux-"+strings.TrimPrefix(res.Plan.Toolchain.Arch, "linux-") {
t.Fatalf("resolved arch = %q, want %q", res.GoArch, res.Plan.Toolchain.Arch)
}
if res.ResolvedBranch == "" || len(res.ResolvedCommit) != 40 {
t.Fatalf("resolved branch/commit incomplete: %s @ %s", res.ResolvedBranch, res.ResolvedCommit)
}
if res.StageBinary == "" || res.RecordFile == "" || res.ToolchainDir == "" {
t.Fatalf("result paths incomplete: %+v", res)
}
client, err = h.Dial()
if err != nil {
t.Fatalf("redial: %v", err)
}
defer client.Close() //nolint:errcheck
assertSourceInstallState(t, client, f.Name, res)
// Idempotent rerun: succeeds, resolves the same branch,
// reuses the toolchain, and leaks no temp files.
res2, err := installer.SourceInstall(opts)
if err != nil {
t.Fatalf("source install rerun on %s: %v", f.Name, err)
}
if res2.ResolvedBranch != res.ResolvedBranch || len(res2.ResolvedCommit) != 40 {
t.Fatalf("rerun resolved = %s @ %s, want branch %s", res2.ResolvedBranch, res2.ResolvedCommit, res.ResolvedBranch)
}
assertSourceInstallState(t, client, f.Name, res2)
if out, err := RunCommand(client, "leak=0; ls /tmp | grep -q rsmon-toolchain && leak=1; ls /usr/local 2>/dev/null | grep -qE 'go-staging|go-backup' && leak=1; [ \"$leak\" -eq 1 ] && echo LEAK || echo CLEAN"); err != nil || strings.TrimSpace(string(out)) != "CLEAN" {
t.Fatalf("toolchain temp dirs leaked after rerun: %q, %v", out, err)
}
})
}
}
// assertSourceInstallState verifies the remote side-effects of a source
// install: the toolchain reports the pinned version, the staging binary
// exists and reports the resolved commit, and the record file carries
// the resolved branch and commit.
func assertSourceInstallState(t *testing.T, client *ssh.Client, name string, res *installer.SourceInstallResult) {
t.Helper()
out, err := RunCommand(client, res.ToolchainDir+"/bin/go version")
if err != nil {
t.Fatalf("%s: toolchain missing: %v", name, err)
}
if !strings.Contains(string(out), "go"+res.Plan.Toolchain.Version) {
t.Fatalf("%s: toolchain version = %q, want go%s", name, out, res.Plan.Toolchain.Version)
}
out, err = RunCommand(client, "test -x "+shellQuote(res.StageBinary)+" && echo BUILT")
if err != nil || !strings.Contains(string(out), "BUILT") {
t.Fatalf("%s: staging binary not present at %s: %q, %v", name, res.StageBinary, out, err)
}
out, err = RunCommand(client, shellQuote(res.StageBinary)+" --version")
if err != nil {
t.Fatalf("%s: staging binary --version: %v", name, err)
}
if !strings.Contains(string(out), "commit="+res.ResolvedCommit[:12]) {
t.Fatalf("%s: staging binary reports commit %q, want short %s", name, out, res.ResolvedCommit[:12])
}
record, err := RunCommand(client, "cat "+shellQuote(res.RecordFile))
if err != nil {
t.Fatalf("%s: read record file: %v", name, err)
}
if !strings.Contains(string(record), "branch="+res.ResolvedBranch) || !strings.Contains(string(record), "commit="+res.ResolvedCommit) {
t.Fatalf("%s: record file = %q, want branch=%s commit=%s", name, record, res.ResolvedBranch, res.ResolvedCommit)
}
}
// TestSourceInstallDirtyCheckoutPreservesStaging is the work-package-3
// failure-atomicity test: after a successful install, dirtying the
// tracked working tree makes the next checkout fail closed. The rerun
// must report the checkout error without ever reaching the build step,
// leaving the previous staging binary and commit record byte-for-byte
// unchanged, and without leaking toolchain staging/backup directories.
func TestSourceInstallDirtyCheckoutPreservesStaging(t *testing.T) {
SkipUnlessEnabled(t)
f := Fixtures()[0] // alpine is the smallest fixture
h, err := New("dirty-"+f.Name, f)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
if err := h.Start(ctx); err != nil {
t.Fatalf("start %s fixture: %v", f.Name, err)
}
t.Cleanup(func() {
if err := h.Stop(); err != nil {
t.Errorf("stop %s fixture: %v", f.Name, err)
}
})
opts := installer.SourceInstallOptions{
SSHOptions: installer.SSHOptions{
Host: "127.0.0.1",
Port: h.Port(),
User: f.UserOrDefault(),
IdentityFile: testKeyPath(),
KnownHostsFile: h.KnownHostsPath(),
},
}
if repo := strings.TrimSpace(os.Getenv("RSMON_TEST_SOURCE_REPO")); repo != "" {
opts.Repo = repo
}
res, err := installer.SourceInstall(opts)
if err != nil {
t.Fatalf("initial source install: %v", err)
}
client, err := h.Dial()
if err != nil {
t.Fatalf("dial: %v", err)
}
defer client.Close() //nolint:errcheck
// Dirty a tracked file so the rerun's checkout refuses to proceed.
// Makefile differs between master and master~1 (unlike go.mod).
if _, err := RunCommand(client, "git -C "+shellQuote(res.Plan.BuildDir)+" checkout -q master~1 -- Makefile"); err != nil {
t.Fatalf("dirty the working tree: %v", err)
}
beforeBinary, err := RunCommand(client, "sha256sum "+shellQuote(res.StageBinary))
if err != nil {
t.Fatal(err)
}
beforeRecord, err := RunCommand(client, "cat "+shellQuote(res.RecordFile))
if err != nil {
t.Fatal(err)
}
if _, err := installer.SourceInstall(opts); err == nil || !strings.Contains(err.Error(), "check out branch") {
t.Fatalf("rerun err = %v, want checkout failure on dirty tree", err)
}
afterBinary, err := RunCommand(client, "sha256sum "+shellQuote(res.StageBinary))
if err != nil {
t.Fatal(err)
}
afterRecord, err := RunCommand(client, "cat "+shellQuote(res.RecordFile))
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(bytes.TrimSpace(beforeBinary), bytes.TrimSpace(afterBinary)) {
t.Fatalf("staging binary changed after failed rerun:\nbefore: %s\nafter: %s", beforeBinary, afterBinary)
}
if !bytes.Equal(bytes.TrimSpace(beforeRecord), bytes.TrimSpace(afterRecord)) {
t.Fatalf("commit record changed after failed rerun:\nbefore: %s\nafter: %s", beforeRecord, afterRecord)
}
if out, err := RunCommand(client, "leak=0; ls /tmp | grep -q rsmon-toolchain && leak=1; ls /usr/local 2>/dev/null | grep -qE 'go-staging|go-backup' && leak=1; [ \"$leak\" -eq 1 ] && echo LEAK || echo CLEAN"); err != nil || strings.TrimSpace(string(out)) != "CLEAN" {
t.Fatalf("toolchain staging leaked after failed rerun: %q, %v", out, err)
}
}
// makeProber builds an sshinstall.FileProber over a live SSH session.
func makeProber(client *ssh.Client) sshinstall.FileProber {
return func(paths ...string) map[string]bool {

558
internal/installer/sourceinstall.go Обычный файл
Просмотреть файл

@@ -0,0 +1,558 @@
package installer
import (
"errors"
"fmt"
"net"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"golang.org/x/crypto/ssh"
"rocketgit.ru/rsmon/worker/internal/sshinstall"
)
// SourceInstallOptions drives the remote source installation (work
// package 3 of docs/source-installation.md). It reuses the deploy
// command's SSHOptions for authentication and host-key verification and
// adds the source-build knobs. It never carries a worker token or
// control-plane credential: the built worker is staged, not configured
// or started, so no secret is ever sent to the remote host.
type SourceInstallOptions struct {
SSHOptions
// Repo is the public worker repository to clone or update. Empty
// uses the sshinstall default. Only https URLs without userinfo are
// accepted.
Repo string
// Branch pins the branch to build. Empty resolves the remote's
// default branch (the public repo currently publishes "master");
// the resolved branch and commit are recorded in the build dir.
Branch string
// GoVersion defaults to the pinned sshinstall toolchain (1.26.0).
// Non-default versions have no baked checksum yet and are rejected.
GoVersion string
// GoArch optionally pins the Go download archive suffix (e.g.
// "amd64"); empty derives it from the remote `uname -m`.
GoArch string
// BuildDir is the remote clone/build directory.
BuildDir string
// GoModuleProxy overrides GOPROXY for the remote build.
GoModuleProxy string
// ToolchainDir is where the verified Go toolchain is installed.
// It must be an absolute path ending in /go (default
// /usr/local/go). Replacement is atomic: the new toolchain is
// downloaded, verified, and staged before the prior one is moved
// aside, and the prior one is restored if the swap fails.
ToolchainDir string
// StageBinary is where the built worker binary is written. It must
// be absolute and defaults to <BuildDir>/rsmon-worker. The running
// service and its config are NOT touched by this work package.
StageBinary string
// SessionTimeout bounds each remote command. 0 uses the default
// (30 minutes); the build step can legitimately run for minutes.
SessionTimeout time.Duration
}
// SourceInstallResult is what a source installation resolved to. The
// resolved branch and commit are recorded on the remote host in
// RecordFile, and the built binary is left at StageBinary for the next
// (service-activation) work package to install atomically.
type SourceInstallResult struct {
Detection sshinstall.Detection
Plan sshinstall.SourcePlan
GoArch string // resolved archive suffix, e.g. "linux-amd64"
ToolchainDir string
ResolvedBranch string
ResolvedCommit string
RecordFile string
StageBinary string
}
// defaultToolchainDir is the standard Go installation prefix.
const defaultToolchainDir = "/usr/local/go"
// defaultSessionTimeout bounds each remote command when the operator
// does not configure one. The staging build and first-time module
// downloads can run for minutes, so this is generous.
const defaultSessionTimeout = 30 * time.Minute
// commitRecordName is the file (inside BuildDir) that records the
// resolved branch and commit the build was produced from.
const commitRecordName = "rsmon-worker.commit"
var (
commitPattern = regexp.MustCompile(`^[0-9a-f]{40}$`)
branchNamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]*$`)
)
// sourceExecutor bundles the SSH client, privilege options, and per
// command timeout used by every remote source-install step.
type sourceExecutor struct {
client *ssh.Client
ssh SSHOptions
timeout time.Duration
}
// runPrivileged executes a command through the SSHOptions privilege path
// (root, passwordless sudo, or sudo -S) and returns bounded stdout.
func (e *sourceExecutor) runPrivileged(command string) ([]byte, error) {
cmd, stdin := sudoWrap(e.ssh.User, e.ssh.SudoPassword, command)
return runRemoteOutput(e.client, cmd, stdin, e.timeout)
}
// runPlain executes a command as the SSH user and returns bounded
// stdout.
func (e *sourceExecutor) runPlain(command string) ([]byte, error) {
return runRemoteOutput(e.client, command, nil, e.timeout)
}
// fileProber builds the sshinstall.FileProber used for init-system
// detection over a live SSH session.
func (e *sourceExecutor) fileProber() sshinstall.FileProber {
return func(paths ...string) map[string]bool {
quoted := make([]string, len(paths))
for i, p := range paths {
quoted[i] = shellQuote(p)
}
expr := "for p in " + strings.Join(quoted, " ") + "; do [ -e \"$p\" ] && printf '%s\\n' \"$p\"; done; true"
out, err := e.runPlain(expr)
if err != nil {
return nil
}
present := make(map[string]bool, len(paths))
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
if line = strings.TrimSpace(line); line != "" {
present[line] = true
}
}
return present
}
}
// SourceInstall executes the source-install flow over SSH: it reuses the
// deploy command's SSH authentication and host-key verification, detects
// the remote host, plans the pinned toolchain and package prerequisites,
// then installs packages, downloads and verifies the Go toolchain,
// clones/updates the public repository (verifying an existing checkout's
// origin matches the configured repository), checks out the resolved
// branch, builds the worker to a staging path, and only then records the
// resolved branch and commit.
//
// The running service, its configuration, and its data directory are
// deliberately untouched: atomic activation and rollback are the next
// work package. Every remote step runs with the same privilege path as
// `deploy` (root, passwordless sudo, or sudo -S), every interpolated
// value is single-quoted, every step script fails closed (`set -eu` or
// explicit `&&`/retry), and every failure returns a bounded, actionable
// error. Each remote command is capped by SessionTimeout and its stdout
// is size-bounded.
func SourceInstall(opts SourceInstallOptions) (*SourceInstallResult, error) {
opts, err := normalizeSourceOptions(opts)
if err != nil {
return nil, err
}
auth, err := sshAuth(opts.SSHOptions)
if err != nil {
return nil, err
}
hostKey, err := hostKeyCallback(opts.SSHOptions)
if err != nil {
return nil, err
}
client, err := ssh.Dial("tcp", net.JoinHostPort(opts.Host, strconv.Itoa(opts.Port)), &ssh.ClientConfig{
User: opts.User,
Auth: auth,
HostKeyCallback: hostKey,
Timeout: 15 * time.Second,
})
if err != nil {
return nil, fmt.Errorf("connect to %s: %w", opts.Host, err)
}
defer client.Close() //nolint:errcheck
executor := &sourceExecutor{client: client, ssh: opts.SSHOptions, timeout: opts.SessionTimeout}
osRelease, err := executor.runPlain("cat /etc/os-release")
if err != nil {
return nil, fmt.Errorf("read remote /etc/os-release: %w", err)
}
uname, err := executor.runPlain("uname -m")
if err != nil {
return nil, fmt.Errorf("read remote machine architecture: %w", err)
}
detection := sshinstall.Detect(string(osRelease), executor.fileProber())
plan, err := sshinstall.PlanSource(detection, sshinstall.SourceOptions{
Repo: opts.Repo,
Branch: opts.Branch,
GoVersion: opts.GoVersion,
GoArch: opts.GoArch,
UnameM: strings.TrimSpace(string(uname)),
BuildDir: opts.BuildDir,
GoModuleProxy: opts.GoModuleProxy,
})
if err != nil {
return nil, err
}
stage := opts.StageBinary
if stage == "" {
stage = filepath.Join(plan.BuildDir, "rsmon-worker")
}
result := &SourceInstallResult{
Detection: detection,
Plan: plan,
GoArch: plan.Toolchain.Arch,
ToolchainDir: opts.ToolchainDir,
StageBinary: stage,
}
if len(plan.Packages) > 0 {
script := "sh -c " + shellQuote(packageScript(detection.PackageManager, plan.Packages))
if _, err := executor.runPrivileged(script); err != nil {
return nil, fmt.Errorf("install prerequisites via %s: %w", detection.PackageManager, err)
}
}
script := "sh -c " + shellQuote(toolchainScript(plan.Toolchain, opts.ToolchainDir))
if _, err := executor.runPrivileged(script); err != nil {
return nil, fmt.Errorf("install Go %s toolchain: %w", plan.Toolchain.Version, err)
}
// Clone or update. An existing checkout must point at the configured
// repository, or the install fails before fetching or building.
script = "sh -c " + shellQuote(cloneUpdateScript(plan.Repo, plan.BuildDir))
if _, err := executor.runPrivileged(script); err != nil {
return nil, fmt.Errorf("clone/update source repository: %w", err)
}
out, err := executor.runPrivileged("sh -c " + shellQuote(resolveBranchScript(plan.BuildDir)))
if err != nil {
return nil, fmt.Errorf("resolve remote default branch: %w", err)
}
branch, err := parseResolvedBranch(string(out))
if err != nil {
return nil, err
}
if plan.Branch != "" && plan.Branch != branch {
ref := "refs/remotes/origin/" + plan.Branch
if _, err := executor.runPrivileged("sh -c " + shellQuote(refExistsScript(plan.BuildDir, ref))); err != nil {
return nil, fmt.Errorf("branch %q does not exist on the remote repository: %w", plan.Branch, err)
}
branch = plan.Branch
}
result.ResolvedBranch = branch
// Checkout fails closed: a dirty tree or missing branch aborts before
// the build, so the previous staging binary is left untouched.
out, err = executor.runPrivileged("sh -c " + shellQuote(checkoutScript(plan.BuildDir, branch)))
if err != nil {
return nil, fmt.Errorf("check out branch %q: %w", branch, err)
}
commit, err := parseResolvedCommit(string(out))
if err != nil {
return nil, fmt.Errorf("resolve commit: %w", err)
}
result.ResolvedCommit = commit
// Build with the repository's own flags (same -ldflags shape the
// worker Makefile uses), resolving COMMIT from the checkout and
// BUILD_DATE at install time. The binary is built to a temp path,
// verified, and atomically swapped into the staging path; a failed
// build leaves the previous staging binary in place.
buildDate := time.Now().UTC().Format("2006-01-02T15:04:05Z")
ldflags := fmt.Sprintf("-s -w -X main.version=dev -X main.commit=%s -X main.buildDate=%s", commit[:12], buildDate)
script = "sh -c " + shellQuote(buildScript(opts.ToolchainDir, plan.BuildDir, plan.GoModuleProxy, stage, ldflags))
if _, err := executor.runPrivileged(script); err != nil {
return nil, fmt.Errorf("build worker binary: %w", err)
}
// The commit record is written only after a successful build, so the
// record and the staged binary always correspond to the same commit.
result.RecordFile = filepath.Join(plan.BuildDir, commitRecordName)
if _, err := executor.runPrivileged("sh -c " + shellQuote(commitRecordScript(plan.BuildDir, branch, commit))); err != nil {
return nil, fmt.Errorf("record resolved commit: %w", err)
}
return result, nil
}
// normalizeSourceOptions validates operator input before any remote
// connection or mutation. Every value that later reaches a remote shell
// is constrained here.
func normalizeSourceOptions(o SourceInstallOptions) (SourceInstallOptions, error) {
if o.Host == "" || o.User == "" {
return o, errors.New("--host and --user are required for source install")
}
if o.Port == 0 {
o.Port = 22
}
if o.Port < 1 || o.Port > 65535 {
return o, errors.New("SSH port must be between 1 and 65535")
}
if o.Branch != "" && !validBranchName(o.Branch) {
return o, fmt.Errorf("invalid branch %q: only A-Za-z0-9, dots, underscores, slashes, and hyphens are allowed", o.Branch)
}
if o.GoVersion != "" && !sshinstall.ValidGoVersion(o.GoVersion) {
return o, fmt.Errorf("invalid Go version %q: only digits, letters, dots, dashes, and underscores are allowed", o.GoVersion)
}
if o.GoArch != "" && !sshinstall.ValidGoArch(o.GoArch) {
return o, fmt.Errorf("invalid Go architecture %q: only letters, digits, dashes, and underscores are allowed", o.GoArch)
}
if err := sshinstall.ValidateRepoURL(o.Repo); err != nil {
return o, fmt.Errorf("invalid repository: %w", err)
}
if o.ToolchainDir == "" {
o.ToolchainDir = defaultToolchainDir
}
if !strings.HasPrefix(o.ToolchainDir, "/") || filepath.Base(o.ToolchainDir) != "go" {
return o, fmt.Errorf("toolchain directory must be an absolute path ending in /go, got %q", o.ToolchainDir)
}
if o.StageBinary != "" && !strings.HasPrefix(o.StageBinary, "/") {
return o, fmt.Errorf("staging binary path must be absolute, got %q", o.StageBinary)
}
if o.SessionTimeout <= 0 {
o.SessionTimeout = defaultSessionTimeout
}
return o, nil
}
// validBranchName reports whether a branch is a safe git branch name
// that can be interpolated into remote commands. The charset check is
// the command-injection boundary; the extra rules reject git-invalid or
// ambiguous refname patterns.
func validBranchName(branch string) bool {
if !branchNamePattern.MatchString(branch) {
return false
}
if strings.HasPrefix(branch, "-") || strings.HasPrefix(branch, "/") ||
strings.Contains(branch, "..") || strings.Contains(branch, "@{") ||
strings.Contains(branch, "//") || strings.HasSuffix(branch, ".") ||
strings.HasSuffix(branch, "/") {
return false
}
return true
}
// parseResolvedBranch turns the `git symbolic-ref` output
// ("origin/master\n") into the short branch name, validating it so
// remote-controlled output can never inject a command.
func parseResolvedBranch(raw string) (string, error) {
branch := strings.TrimSpace(raw)
branch = strings.TrimPrefix(branch, "origin/")
if !validBranchName(branch) {
return "", fmt.Errorf("remote reported an invalid default branch %q", strings.TrimSpace(raw))
}
return branch, nil
}
// parseResolvedCommit extracts the 40-hex commit from `git rev-parse
// HEAD` output, taking the last whitespace-separated token so unrelated
// stdout cannot satisfy the parse.
func parseResolvedCommit(raw string) (string, error) {
fields := strings.Fields(strings.TrimSpace(raw))
if len(fields) == 0 {
return "", errors.New("remote reported no commit")
}
commit := fields[len(fields)-1]
if !commitPattern.MatchString(commit) {
return "", fmt.Errorf("remote reported an invalid resolved commit %q", commit)
}
return commit, nil
}
// quoteList renders each item as a single shell-quoted word.
func quoteList(items []string) string {
quoted := make([]string, len(items))
for i, item := range items {
quoted[i] = shellQuote(item)
}
return strings.Join(quoted, " ")
}
// packageScript installs the minimal build prerequisites with the
// distro's package manager. It is idempotent on every supported manager
// and never installs a C compiler (the worker builds with CGO disabled).
func packageScript(pkg sshinstall.PackageManager, pkgs []string) string {
quoted := quoteList(pkgs)
switch pkg {
case sshinstall.PkgApk:
return "apk add --no-cache " + quoted
case sshinstall.PkgApt:
// Ubuntu/Debian need a fresh package index before installing.
return "export DEBIAN_FRONTEND=noninteractive\napt-get update\napt-get install -y --no-install-recommends " + quoted
case sshinstall.PkgPacman:
return "pacman -Sy --noconfirm --needed " + quoted
case sshinstall.PkgDnf:
return "dnf install -y --setopt=install_weak_deps=False " + quoted
case sshinstall.PkgYum:
return "yum install -y " + quoted
default:
return ""
}
}
// toolchainScript downloads the pinned Go toolchain, verifies its
// published SHA-256 before extraction, stages the extract on the same
// filesystem as the target, verifies the staged toolchain reports the
// target version, and only then swaps it into place. The prior toolchain
// (when present) is moved to a sibling backup first and is restored if
// the swap fails, so a failed download/verify/extract/swap always leaves
// the prior Go untouched. A present toolchain already reporting the
// target version is reused (idempotent rerun). Temp and staging
// directories are removed on success and failure.
func toolchainScript(tc sshinstall.Toolchain, toolchainDir string) string {
parent := filepath.Dir(toolchainDir)
want := "go" + tc.Version
var b strings.Builder
b.WriteString("set -eu\n")
b.WriteString("parent=" + shellQuote(parent) + "\n")
b.WriteString("toolchain=" + shellQuote(toolchainDir) + "\n")
b.WriteString("want=" + shellQuote(want) + "\n")
b.WriteString("if [ -x \"$toolchain/bin/go\" ]; then\n")
b.WriteString(" have=\"$($toolchain/bin/go version 2>/dev/null | awk '{print $3}')\"\n")
b.WriteString(" if [ \"$have\" = \"$want\" ]; then\n")
b.WriteString(" echo \"go toolchain already present: $have\"\n")
b.WriteString(" exit 0\n")
b.WriteString(" fi\n")
b.WriteString("fi\n")
b.WriteString("work=\"$(mktemp -d /tmp/rsmon-toolchain-XXXXXX)\"\n")
b.WriteString("staging=\"$(mktemp -d \"$parent/.go-staging-XXXXXX\")\"\n")
b.WriteString("trap 'rm -rf \"$work\" \"$staging\"' EXIT HUP INT TERM\n")
b.WriteString("archive=\"$work/go" + tc.Version + "." + tc.Arch + ".tar.gz\"\n")
b.WriteString("curl -fsSL --retry 3 --retry-delay 2 -o \"$archive\" " + shellQuote(tc.URL) + "\n")
b.WriteString("printf '%s %s\\n' " + shellQuote(tc.SHA256) + " \"$archive\" | sha256sum -c -\n")
b.WriteString("tar -C \"$staging\" -xzf \"$archive\"\n")
b.WriteString("staged=\"$($staging/go/bin/go version | awk '{print $3}')\"\n")
b.WriteString("if [ \"$staged\" != \"$want\" ]; then\n")
b.WriteString(" printf 'staged toolchain failed verification: %s\\n' \"$staged\" >&2\n")
b.WriteString(" exit 1\n")
b.WriteString("fi\n")
// Swap atomically on the same filesystem, preserving the prior
// toolchain in a sibling backup with rollback on failure.
b.WriteString("backup=\"\"\n")
b.WriteString("if [ -e \"$toolchain\" ]; then\n")
b.WriteString(" backup=\"$parent/.go-backup\"\n")
b.WriteString(" rm -rf \"$backup\"\n")
b.WriteString(" mv \"$toolchain\" \"$backup\"\n")
b.WriteString("fi\n")
b.WriteString("if ! mv \"$staging/go\" \"$toolchain\"; then\n")
b.WriteString(" if [ -n \"$backup\" ]; then\n")
b.WriteString(" mv \"$backup\" \"$toolchain\"\n")
b.WriteString(" fi\n")
b.WriteString(" exit 1\n")
b.WriteString("fi\n")
b.WriteString("if [ -n \"$backup\" ]; then\n")
b.WriteString(" rm -rf \"$backup\"\n")
b.WriteString("fi\n")
b.WriteString("\"$toolchain/bin/go\" version\n")
return b.String()
}
// cloneUpdateScript clones the repository when missing and otherwise
// fetches the latest refs, so a rerun updates in place. Before fetching
// an existing checkout, it verifies the configured repository matches
// the checkout's `remote.origin.url` exactly, so the installer can never
// fetch or build an unconfigured repository. The clone/fetch is retried
// up to three times (2s apart) because real repositories can be
// transiently unreachable (DNS, TLS, or proxy hiccups). The script fails
// closed: any exhausted retry exits non-zero.
func cloneUpdateScript(repo, buildDir string) string {
return "set -u\n" +
"repo=" + shellQuote(repo) + "\n" +
"dir=" + shellQuote(buildDir) + "\n" +
"if [ ! -d \"$dir/.git\" ]; then\n" +
" attempt=0\n" +
" while [ \"$attempt\" -lt 3 ]; do\n" +
" if git clone \"$repo\" \"$dir\"; then\n" +
" exit 0\n" +
" fi\n" +
" attempt=$((attempt + 1))\n" +
" sleep 2\n" +
" done\n" +
" exit 1\n" +
"fi\n" +
"origin=\"$(git -C \"$dir\" config --get remote.origin.url || true)\"\n" +
"if [ \"$origin\" != \"$repo\" ]; then\n" +
" printf 'existing checkout origin does not match configured repository\\nconfigured: %s\\nfound: %s\\n' \"$repo\" \"$origin\" >&2\n" +
" exit 1\n" +
"fi\n" +
"attempt=0\n" +
"while [ \"$attempt\" -lt 3 ]; do\n" +
" if git -C \"$dir\" fetch --prune origin; then\n" +
" exit 0\n" +
" fi\n" +
" attempt=$((attempt + 1))\n" +
" sleep 2\n" +
"done\n" +
"exit 1\n"
}
// resolveBranchScript prints the remote's default branch short name
// (with an "origin/" prefix) via origin/HEAD. It fails closed so a
// set-head failure aborts rather than resolving a stale default.
func resolveBranchScript(buildDir string) string {
return "set -eu\n" +
"git -C " + shellQuote(buildDir) + " remote set-head origin --auto >/dev/null\n" +
"git -C " + shellQuote(buildDir) + " symbolic-ref --short refs/remotes/origin/HEAD\n"
}
// refExistsScript verifies a remote-tracking ref exists (exit 0) without
// emitting output.
func refExistsScript(buildDir, ref string) string {
return "git -C " + shellQuote(buildDir) + " show-ref --verify --quiet " + shellQuote(ref)
}
// checkoutScript moves the local branch to the resolved remote branch
// and prints the resolved commit. It fails closed (`set -eu`): before the
// destructive `checkout -B` (which would silently discard local changes)
// it refuses when the tracked working tree is dirty, so a checkout
// failure can never be masked by a stale `rev-parse` from the previous
// checkout, and a failed checkout aborts before the build.
func checkoutScript(buildDir, branch string) string {
return "set -eu\n" +
"git -C " + shellQuote(buildDir) + " diff --quiet || { echo 'working tree has uncommitted changes; refusing to overwrite' >&2; exit 1; }\n" +
"git -C " + shellQuote(buildDir) + " diff --cached --quiet || { echo 'working tree has staged changes; refusing to overwrite' >&2; exit 1; }\n" +
"git -C " + shellQuote(buildDir) + " checkout -q -B " + shellQuote(branch) + " " + shellQuote("origin/"+branch) + "\n" +
"git -C " + shellQuote(buildDir) + " rev-parse HEAD\n"
}
// commitRecordScript writes the resolved branch and commit to the build
// dir record file with a bounded, greppable format. It runs only after a
// successful build, so the record always matches the staged binary.
func commitRecordScript(buildDir, branch, commit string) string {
path := shellQuote(filepath.Join(buildDir, commitRecordName))
format := shellQuote("branch=%s\\ncommit=%s\\n")
return "umask 022; printf " + format + " " + shellQuote(branch) + " " + shellQuote(commit) +
" > " + path + " && chmod 0644 " + path
}
// buildScript builds the worker with the repository's own flags into the
// staging path and verifies the resulting binary runs. CGO is disabled,
// trimpath keeps the build reproducible, and both caches (GOCACHE and
// GOMODCACHE) live inside the build dir so reruns reuse them. The binary
// is built to a sibling temp path, verified, and only then atomically
// swapped over the previous staging binary, so a failed build never
// replaces it.
func buildScript(toolchainDir, buildDir, goproxy, stage, ldflags string) string {
var b strings.Builder
b.WriteString("set -eu\n")
b.WriteString("cd " + shellQuote(buildDir) + "\n")
b.WriteString("export PATH=" + shellQuote(toolchainDir+"/bin") + ":$PATH\n")
b.WriteString("export GOCACHE=" + shellQuote(buildDir+"/.gocache") + "\n")
b.WriteString("export GOMODCACHE=" + shellQuote(buildDir+"/.gomodcache") + "\n")
if goproxy != "" {
b.WriteString("export GOPROXY=" + shellQuote(goproxy) + "\n")
}
b.WriteString("stage=" + shellQuote(stage) + "\n")
b.WriteString("tmp=\"$stage.new\"\n")
b.WriteString("trap 'rm -f \"$tmp\"' EXIT HUP INT TERM\n")
b.WriteString("CGO_ENABLED=0 ")
b.WriteString(shellQuote(toolchainDir + "/bin/go"))
b.WriteString(" build -trimpath -ldflags=" + shellQuote(ldflags) + " -o \"$tmp\" ./cmd/rsmon-worker\n")
b.WriteString("\"$tmp\" --version\n")
b.WriteString("mv -f \"$tmp\" \"$stage\"\n")
return b.String()
}

351
internal/installer/sourceinstall_ssh_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,351 @@
package installer
import (
"crypto/ed25519"
"crypto/rand"
"fmt"
"net"
"strings"
"sync"
"testing"
"time"
"golang.org/x/crypto/ssh"
"rocketgit.ru/rsmon/worker/internal/sshinstall"
)
const (
fakeSSHPassword = "fake-ssh-password"
fakeCommitHex = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"
fakeOsRelease = "ID=ubuntu\nNAME=\"Ubuntu\"\nVERSION_ID=24.04\n"
)
// fakeSSHServer is a minimal in-process SSH server that simulates a
// remote Linux host for the SourceInstall orchestration tests. It uses
// real golang.org/x/crypto/ssh transport (no mocked SSH library), so the
// executor's dial, session, exec, and stdout/stderr plumbing is
// exercised end to end, and it records every command it ran.
type fakeSSHServer struct {
addr string
onExec func(command string) (stdout, stderr string, code int)
mu sync.Mutex
commands []string
}
func startFakeSSHServer(t *testing.T, onExec func(command string) (string, string, int)) *fakeSSHServer {
t.Helper()
_, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
signer, err := ssh.NewSignerFromKey(priv)
if err != nil {
t.Fatal(err)
}
config := &ssh.ServerConfig{
PasswordCallback: func(_ ssh.ConnMetadata, pass []byte) (*ssh.Permissions, error) {
if string(pass) == fakeSSHPassword {
return nil, nil
}
return nil, fmt.Errorf("password rejected")
},
}
config.AddHostKey(signer)
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = ln.Close() })
srv := &fakeSSHServer{addr: ln.Addr().String(), onExec: onExec}
go func() {
for {
conn, err := ln.Accept()
if err != nil {
return
}
go srv.handleConn(conn, config)
}
}()
return srv
}
func (s *fakeSSHServer) Port() int {
_, port, err := net.SplitHostPort(s.addr)
if err != nil {
return 0
}
p := 0
fmt.Sscanf(port, "%d", &p)
return p
}
func (s *fakeSSHServer) Commands() []string {
s.mu.Lock()
defer s.mu.Unlock()
return append([]string(nil), s.commands...)
}
func (s *fakeSSHServer) handleConn(conn net.Conn, config *ssh.ServerConfig) {
sconn, chans, reqs, err := ssh.NewServerConn(conn, config)
if err != nil {
return
}
defer sconn.Close() //nolint:errcheck
go ssh.DiscardRequests(reqs)
for newChannel := range chans {
if newChannel.ChannelType() != "session" {
_ = newChannel.Reject(ssh.UnknownChannelType, "unknown channel type")
continue
}
channel, requests, err := newChannel.Accept()
if err != nil {
continue
}
go func() {
defer channel.Close()
s.handleSession(channel, requests)
}()
}
}
func (s *fakeSSHServer) handleSession(channel ssh.Channel, requests <-chan *ssh.Request) {
for req := range requests {
if req.Type != "exec" {
if req.WantReply {
_ = req.Reply(false, nil)
}
continue
}
var payload struct{ Command string }
if err := ssh.Unmarshal(req.Payload, &payload); err != nil {
if req.WantReply {
_ = req.Reply(false, nil)
}
continue
}
if req.WantReply {
_ = req.Reply(true, nil)
}
s.mu.Lock()
s.commands = append(s.commands, payload.Command)
s.mu.Unlock()
s.execCommand(channel, payload.Command)
return
}
}
func (s *fakeSSHServer) execCommand(channel ssh.Channel, command string) {
handler := s.onExec
if handler == nil {
handler = defaultFakeExec
}
stdout, stderr, code := handler(command)
_, _ = channel.Write([]byte(stdout))
_, _ = channel.Stderr().Write([]byte(stderr))
_, _ = channel.SendRequest("exit-status", false, ssh.Marshal(struct{ Status uint32 }{uint32(code)}))
_ = channel.CloseWrite()
}
// defaultFakeExec simulates a minimal Linux host: it answers os-release,
// uname, the init-marker file probe, origin URL, branch resolution, and
// rev-parse, and accepts every install step. A pinned-branch existence
// check (show-ref) fails by default so the missing-branch path is
// exercised without extra setup.
func defaultFakeExec(command string) (string, string, int) {
switch {
case strings.Contains(command, "cat /etc/os-release"):
return fakeOsRelease, "", 0
case strings.Contains(command, "uname -m"):
return "x86_64\n", "", 0
case strings.Contains(command, "[ -e"):
return "/usr/lib/systemd/system\n", "", 0
case strings.Contains(command, "remote.origin.url"):
return sshinstall.DefaultRepo + "\n", "", 0
case strings.Contains(command, "symbolic-ref"):
return "origin/master\n", "", 0
case strings.Contains(command, "rev-parse HEAD"):
return fakeCommitHex + "\n", "", 0
case strings.Contains(command, "show-ref"):
return "", "branch not found", 1
default:
return "", "", 0
}
}
func testSSHOptions(port int) SourceInstallOptions {
return SourceInstallOptions{
SSHOptions: SSHOptions{
Host: "127.0.0.1",
Port: port,
User: "root",
Password: fakeSSHPassword,
InsecureHostKey: true,
},
}
}
func TestSourceInstallSSHFlow(t *testing.T) {
srv := startFakeSSHServer(t, nil)
res, err := SourceInstall(testSSHOptions(srv.Port()))
if err != nil {
t.Fatal(err)
}
if res.Detection.Distro != sshinstall.DistroUbuntu || res.Detection.PackageManager != sshinstall.PkgApt {
t.Fatalf("detection = %+v", res.Detection)
}
if res.Detection.InitSystem != sshinstall.InitSystemd {
t.Fatalf("init detection = %q, want systemd", res.Detection.InitSystem)
}
if res.Plan.Toolchain.Arch != "linux-amd64" || res.Plan.Toolchain.Version != "1.26.0" {
t.Fatalf("toolchain = %+v", res.Plan.Toolchain)
}
if len(res.Plan.Packages) == 0 || res.Plan.Repo == "" {
t.Fatalf("plan = %+v", res.Plan)
}
if res.ResolvedBranch != "master" || res.ResolvedCommit != fakeCommitHex {
t.Fatalf("resolved = %s @ %s", res.ResolvedBranch, res.ResolvedCommit)
}
if res.ToolchainDir != "/usr/local/go" || res.StageBinary != "/opt/rsmon-worker-src/rsmon-worker" ||
res.RecordFile != "/opt/rsmon-worker-src/rsmon-worker.commit" {
t.Fatalf("paths = %+v", res)
}
commands := srv.Commands()
var joined strings.Builder
for _, c := range commands {
joined.WriteString(c)
joined.WriteString("\n")
}
// Marker substrings that survive the nested `sh -c '<script>'`
// quoting; exact quoting of each script is asserted by the unit
// tests (TestPackageScript, TestToolchainScript, ...).
for _, want := range []string{
"cat /etc/os-release",
"uname -m",
"apt-get update",
"apt-get install -y --no-install-recommends",
"sha256sum -c -",
"git clone",
"git -C",
"fetch --prune origin",
"symbolic-ref --short refs/remotes/origin/HEAD",
"checkout -q -B",
"rev-parse HEAD",
"branch=%s\\ncommit=%s\\n",
fakeCommitHex,
"CGO_ENABLED=0",
"build -trimpath",
"GOMODCACHE",
"mv -f",
} {
if !strings.Contains(joined.String(), want) {
t.Fatalf("recorded commands missing %q:\n%s", want, joined.String())
}
}
// Ordering: prerequisites before toolchain before source before build.
idx := func(sub string) int {
for i, c := range commands {
if strings.Contains(c, sub) {
return i
}
}
t.Fatalf("command %q not found in %v", sub, commands)
return -1
}
if !(idx("apt-get install") < idx("sha256sum") && idx("sha256sum") < idx("git clone") &&
idx("git clone") < idx("rev-parse") && idx("rev-parse") < idx("-trimpath") &&
idx("-trimpath") < idx("branch=%s")) {
t.Fatalf("step order wrong: %v", commands)
}
// Adaptive resolution means no pinned-branch existence check ran.
if strings.Contains(joined.String(), "show-ref") {
t.Fatalf("show-ref ran despite branch resolution:\n%s", joined.String())
}
if strings.Contains(joined.String(), fakeSSHPassword) {
t.Fatal("SSH password leaked into a remote command")
}
}
func TestSourceInstallSSHRejectsMissingPinnedBranch(t *testing.T) {
srv := startFakeSSHServer(t, nil)
opts := testSSHOptions(srv.Port())
opts.Branch = "main"
_, err := SourceInstall(opts)
if err == nil || !strings.Contains(err.Error(), `branch "main" does not exist`) {
t.Fatalf("err = %v, want missing-branch error", err)
}
if commands := srv.Commands(); !strings.Contains(strings.Join(commands, "\n"), "show-ref") {
t.Fatalf("pinned branch existence was not verified: %v", commands)
}
}
func TestSourceInstallSSHBuildFailure(t *testing.T) {
srv := startFakeSSHServer(t, func(command string) (string, string, int) {
if strings.Contains(command, "-trimpath") {
return "", "build exploded", 1
}
return defaultFakeExec(command)
})
_, err := SourceInstall(testSSHOptions(srv.Port()))
if err == nil || !strings.Contains(err.Error(), "build worker binary") || !strings.Contains(err.Error(), "build exploded") {
t.Fatalf("err = %v, want bounded build failure", err)
}
}
func TestSourceInstallSSHDetectionFailure(t *testing.T) {
srv := startFakeSSHServer(t, func(command string) (string, string, int) {
if strings.Contains(command, "os-release") {
return "", "os-release unreadable", 1
}
return defaultFakeExec(command)
})
_, err := SourceInstall(testSSHOptions(srv.Port()))
if err == nil || !strings.Contains(err.Error(), "/etc/os-release") {
t.Fatalf("err = %v, want detection failure", err)
}
}
// TestSourceInstallSSHCheckoutFailureNotMasked proves the fail-closed
// contract: a failed checkout (e.g. a dirty working tree) surfaces as an
// error and never reaches the build or commit-record steps, so the
// previous staging binary and record are preserved.
func TestSourceInstallSSHCheckoutFailureNotMasked(t *testing.T) {
srv := startFakeSSHServer(t, func(command string) (string, string, int) {
if strings.Contains(command, "checkout -q -B") {
return "", "your local changes to the following files would be overwritten by checkout", 1
}
return defaultFakeExec(command)
})
_, err := SourceInstall(testSSHOptions(srv.Port()))
if err == nil || !strings.Contains(err.Error(), "check out branch") {
t.Fatalf("err = %v, want checkout failure", err)
}
joined := strings.Join(srv.Commands(), "\n")
if strings.Contains(joined, "-trimpath") || strings.Contains(joined, "branch=%s") {
t.Fatalf("build or record ran after checkout failed:\n%s", joined)
}
}
// TestSourceInstallSSHCommandTimeout verifies each remote command is
// bounded by SessionTimeout and the run reports it.
func TestSourceInstallSSHCommandTimeout(t *testing.T) {
srv := startFakeSSHServer(t, func(command string) (string, string, int) {
if strings.Contains(command, "uname -m") {
time.Sleep(5 * time.Second)
return "x86_64\n", "", 0
}
return defaultFakeExec(command)
})
opts := testSSHOptions(srv.Port())
opts.SessionTimeout = 300 * time.Millisecond
_, err := SourceInstall(opts)
if err == nil || !strings.Contains(err.Error(), "timed out after") {
t.Fatalf("err = %v, want command timeout", err)
}
}

395
internal/installer/sourceinstall_test.go Обычный файл
Просмотреть файл

@@ -0,0 +1,395 @@
package installer
import (
"strings"
"testing"
"time"
"rocketgit.ru/rsmon/worker/internal/sshinstall"
)
// cannedSHA is a fixed 64-hex value used to exercise script rendering.
const cannedSHA = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
func TestPackageScript(t *testing.T) {
pkgs := []string{"git", "ca-certificates", "curl", "tar", "gzip"}
apk := packageScript(sshinstall.PkgApk, pkgs)
if !strings.HasPrefix(apk, "apk add --no-cache ") {
t.Fatalf("apk script = %q", apk)
}
for _, p := range pkgs {
if !strings.Contains(apk, shellQuote(p)) {
t.Fatalf("apk script missing quoted package %q: %q", p, apk)
}
}
apt := packageScript(sshinstall.PkgApt, pkgs)
for _, want := range []string{"export DEBIAN_FRONTEND=noninteractive", "apt-get update", "apt-get install -y --no-install-recommends"} {
if !strings.Contains(apt, want) {
t.Fatalf("apt script missing %q: %q", want, apt)
}
}
pacman := packageScript(sshinstall.PkgPacman, pkgs)
if !strings.Contains(pacman, "pacman -Sy --noconfirm --needed") {
t.Fatalf("pacman script = %q", pacman)
}
dnf := packageScript(sshinstall.PkgDnf, pkgs)
if !strings.Contains(dnf, "dnf install -y") {
t.Fatalf("dnf script = %q", dnf)
}
if got := packageScript(sshinstall.PkgUnknown, pkgs); got != "" {
t.Fatalf("unknown pkg script = %q, want empty", got)
}
}
func TestPackageScriptNeverIncludesCompiler(t *testing.T) {
pkgs := []string{"git", "ca-certificates", "curl", "tar", "gzip"}
for _, pkg := range []sshinstall.PackageManager{sshinstall.PkgApk, sshinstall.PkgApt, sshinstall.PkgPacman, sshinstall.PkgDnf} {
script := packageScript(pkg, pkgs)
for _, bad := range []string{"build-essential", "gcc", "g++", "base-devel", "make", "gcc-c++"} {
if strings.Contains(script, bad) {
t.Fatalf("script for %s includes compiler hint %q: %q", pkg, bad, script)
}
}
}
}
func TestToolchainScript(t *testing.T) {
tc := sshinstall.Toolchain{
Version: "1.26.0",
Arch: "linux-amd64",
URL: "https://go.dev/dl/go1.26.0.linux-amd64.tar.gz",
SHA256: cannedSHA,
}
script := toolchainScript(tc, "/usr/local/go")
for _, want := range []string{
"set -eu",
"mktemp -d /tmp/rsmon-toolchain-XXXXXX",
"mktemp -d \"$parent/.go-staging-XXXXXX\"",
"trap 'rm -rf \"$work\" \"$staging\"' EXIT HUP INT TERM",
"curl -fsSL --retry 3 --retry-delay 2 -o \"$archive\" 'https://go.dev/dl/go1.26.0.linux-amd64.tar.gz'",
"sha256sum -c -",
cannedSHA,
"tar -C \"$staging\" -xzf \"$archive\"",
"staged=\"$($staging/go/bin/go version | awk '{print $3}')\"",
"backup=\"$parent/.go-backup\"",
"mv \"$toolchain\" \"$backup\"",
"mv \"$staging/go\" \"$toolchain\"",
"\"$toolchain/bin/go\" version",
"go toolchain already present",
} {
if !strings.Contains(script, want) {
t.Fatalf("toolchain script missing %q:\n%s", want, script)
}
}
if !strings.Contains(script, shellQuote("go1.26.0")) {
t.Fatalf("toolchain script missing version guard:\n%s", script)
}
}
func TestToolchainScriptIdempotentSkipOnlyForMatchingVersion(t *testing.T) {
script := toolchainScript(sshinstall.Toolchain{Version: "1.26.0", Arch: "linux-amd64", URL: "u", SHA256: cannedSHA}, "/usr/local/go")
if strings.Count(script, "exit 0") != 1 {
t.Fatalf("toolchain script should skip only once:\n%s", script)
}
// Replacement must be atomic: the prior toolchain is moved aside and
// restored when the swap fails, never removed before staging is ready.
for _, want := range []string{
"if [ -e \"$toolchain\" ]; then",
"mv \"$toolchain\" \"$backup\"",
"if ! mv \"$staging/go\" \"$toolchain\"; then",
"mv \"$backup\" \"$toolchain\"",
} {
if !strings.Contains(script, want) {
t.Fatalf("toolchain script missing %q:\n%s", want, script)
}
}
if strings.Contains(script, "rm -rf \"$toolchain\"") {
t.Fatalf("toolchain script must not delete the live toolchain directly:\n%s", script)
}
}
func TestCloneUpdateScript(t *testing.T) {
script := cloneUpdateScript("https://example.test/worker.git", "/opt/rsmon-worker-src")
for _, want := range []string{
"set -u",
"[ ! -d \"$dir/.git\" ]",
"git clone \"$repo\" \"$dir\"",
"while [ \"$attempt\" -lt 3 ]",
"sleep 2",
"git -C \"$dir\" config --get remote.origin.url",
"existing checkout origin does not match configured repository",
"git -C \"$dir\" fetch --prune origin",
"exit 1",
} {
if !strings.Contains(script, want) {
t.Fatalf("clone/update script missing %q:\n%s", want, script)
}
}
}
func TestCheckoutScriptFailsClosed(t *testing.T) {
script := checkoutScript("/opt/rsmon-worker-src", "master")
if !strings.HasPrefix(script, "set -eu\n") {
t.Fatalf("checkout script must fail closed with set -eu:\n%s", script)
}
for _, want := range []string{
"git -C '/opt/rsmon-worker-src' diff --quiet ||",
"git -C '/opt/rsmon-worker-src' diff --cached --quiet ||",
"refusing to overwrite",
"checkout -q -B 'master' 'origin/master'",
"rev-parse HEAD",
} {
if !strings.Contains(script, want) {
t.Fatalf("checkout script missing %q:\n%s", want, script)
}
}
}
func TestResolveBranchScriptFailsClosed(t *testing.T) {
if !strings.HasPrefix(resolveBranchScript("/opt/rsmon-worker-src"), "set -eu\n") {
t.Fatalf("resolve-branch script must fail closed:\n%s", resolveBranchScript("/opt/rsmon-worker-src"))
}
}
func TestResolveBranchScript(t *testing.T) {
script := resolveBranchScript("/opt/rsmon-worker-src")
for _, want := range []string{
"remote set-head origin --auto",
"symbolic-ref --short refs/remotes/origin/HEAD",
} {
if !strings.Contains(script, want) {
t.Fatalf("resolve-branch script missing %q:\n%s", want, script)
}
}
}
func TestCheckoutScript(t *testing.T) {
script := checkoutScript("/opt/rsmon-worker-src", "master")
for _, want := range []string{
"checkout -q -B 'master' 'origin/master'",
"rev-parse HEAD",
} {
if !strings.Contains(script, want) {
t.Fatalf("checkout script missing %q:\n%s", want, script)
}
}
}
func TestRefExistsScript(t *testing.T) {
script := refExistsScript("/opt/rsmon-worker-src", "refs/remotes/origin/main")
if !strings.Contains(script, "show-ref --verify --quiet 'refs/remotes/origin/main'") {
t.Fatalf("ref-exists script = %q", script)
}
}
func TestCommitRecordScript(t *testing.T) {
script := commitRecordScript("/opt/rsmon-worker-src", "master", strings.Repeat("a", 40))
for _, want := range []string{
"branch=%s\\ncommit=%s\\n",
"'master'",
strings.Repeat("a", 40),
"'/opt/rsmon-worker-src/rsmon-worker.commit'",
"chmod 0644",
} {
if !strings.Contains(script, want) {
t.Fatalf("record script missing %q:\n%s", want, script)
}
}
}
func TestBuildScript(t *testing.T) {
script := buildScript("/usr/local/go", "/opt/rsmon-worker-src", "", "/opt/rsmon-worker-src/rsmon-worker",
`-s -w -X main.version=dev -X main.commit=abcdef012345 -X main.buildDate=2026-08-12T00:00:00Z`)
for _, want := range []string{
"set -eu",
"cd '/opt/rsmon-worker-src'",
"export PATH='/usr/local/go/bin':$PATH",
"export GOCACHE='/opt/rsmon-worker-src/.gocache'",
"export GOMODCACHE='/opt/rsmon-worker-src/.gomodcache'",
"CGO_ENABLED=0 '/usr/local/go/bin/go' build -trimpath",
"-X main.commit=abcdef012345",
"-o \"$tmp\" ./cmd/rsmon-worker",
"tmp=\"$stage.new\"",
"\"$tmp\" --version",
"mv -f \"$tmp\" \"$stage\"",
"trap 'rm -f \"$tmp\"' EXIT HUP INT TERM",
} {
if !strings.Contains(script, want) {
t.Fatalf("build script missing %q:\n%s", want, script)
}
}
if strings.Contains(script, "GOPROXY") {
t.Fatalf("empty GOPROXY must not be exported:\n%s", script)
}
withProxy := buildScript("/usr/local/go", "/opt/rsmon-worker-src", "https://proxy.golang.org,direct", "/opt/rsmon-worker-src/rsmon-worker", "-s -w")
if !strings.Contains(withProxy, "export GOPROXY='https://proxy.golang.org,direct'") {
t.Fatalf("GOPROXY override not rendered:\n%s", withProxy)
}
}
func TestBuildScriptVerifiesBeforeSwap(t *testing.T) {
script := buildScript("/usr/local/go", "/opt/rsmon-worker-src", "", "/opt/rsmon-worker-src/rsmon-worker", "-s -w")
verify := strings.Index(script, "\"$tmp\" --version")
swap := strings.Index(script, "mv -f \"$tmp\" \"$stage\"")
if verify < 0 || swap < 0 || verify > swap {
t.Fatalf("build script must verify the temp binary before swapping it in:\n%s", script)
}
}
func TestParseResolvedCommit(t *testing.T) {
commit := strings.Repeat("abcdef", 6) + "abcd" // 40 hex
if got, err := parseResolvedCommit(commit + "\n"); err != nil || got != commit {
t.Fatalf("parseResolvedCommit() = %q, %v", got, err)
}
if got, err := parseResolvedCommit("ignored\n" + commit + "\n"); err != nil || got != commit {
t.Fatalf("parseResolvedCommit() multi-line = %q, %v", got, err)
}
for _, bad := range []string{"", "abc", strings.Repeat("A", 40), strings.Repeat("a", 39), "x" + strings.Repeat("a", 39)} {
if _, err := parseResolvedCommit(bad); err == nil {
t.Fatalf("parseResolvedCommit(%q) succeeded", bad)
}
}
}
func TestParseResolvedBranch(t *testing.T) {
if got, err := parseResolvedBranch("origin/master\n"); err != nil || got != "master" {
t.Fatalf("parseResolvedBranch(origin/master) = %q, %v", got, err)
}
if got, err := parseResolvedBranch("master\n"); err != nil || got != "master" {
t.Fatalf("parseResolvedBranch(master) = %q, %v", got, err)
}
// A multi-component short name is legal git and stays safe because
// the value is validated and single-quoted everywhere it is used.
if got, err := parseResolvedBranch("origin/release/1.0\n"); err != nil || got != "release/1.0" {
t.Fatalf("parseResolvedBranch(nested) = %q, %v", got, err)
}
for _, bad := range []string{"origin/../evil\n", "origin/x y\n", "origin/x..y\n", "origin/x@{y\n", "origin/-x\n"} {
if _, err := parseResolvedBranch(bad); err == nil {
t.Fatalf("parseResolvedBranch(%q) succeeded", bad)
}
}
}
func TestValidBranchName(t *testing.T) {
for _, ok := range []string{"main", "master", "release-1.0", "feature/x", "a", "v1.2.3", "a_b", "release/1.0"} {
if !validBranchName(ok) {
t.Fatalf("validBranchName(%q) rejected", ok)
}
}
for _, bad := range []string{"", "-bad", "x..y", "x@{y", "x y", "/x", "x.", "x/", "x//y", "x\\y", "x;y", "$x", "`x`"} {
if validBranchName(bad) {
t.Fatalf("validBranchName(%q) accepted", bad)
}
}
}
func TestQuoteList(t *testing.T) {
if got, want := quoteList([]string{"git", "ca-certificates"}), "'git' 'ca-certificates'"; got != want {
t.Fatalf("quoteList() = %q, want %q", got, want)
}
if got := quoteList(nil); got != "" {
t.Fatalf("quoteList(nil) = %q, want empty", got)
}
}
func TestNormalizeSourceOptions(t *testing.T) {
o, err := normalizeSourceOptions(SourceInstallOptions{SSHOptions: SSHOptions{Host: "h", User: "u"}})
if err != nil {
t.Fatal(err)
}
if o.Port != 22 || o.ToolchainDir != "/usr/local/go" || o.SessionTimeout != defaultSessionTimeout {
t.Fatalf("defaults not applied: %+v", o)
}
if o, err := normalizeSourceOptions(SourceInstallOptions{SSHOptions: SSHOptions{Host: "h", User: "u"}, SessionTimeout: 7 * time.Minute}); err != nil || o.SessionTimeout != 7*time.Minute {
t.Fatalf("explicit session timeout not honored: %+v, %v", o, err)
}
for _, tc := range []struct {
name string
opts SourceInstallOptions
}{
{name: "missing host", opts: SourceInstallOptions{SSHOptions: SSHOptions{User: "u"}}},
{name: "missing user", opts: SourceInstallOptions{SSHOptions: SSHOptions{Host: "h"}}},
{name: "bad port", opts: SourceInstallOptions{SSHOptions: SSHOptions{Host: "h", User: "u", Port: 70000}}},
{name: "bad branch", opts: SourceInstallOptions{SSHOptions: SSHOptions{Host: "h", User: "u"}, Branch: "x y"}},
{name: "bad go version", opts: SourceInstallOptions{SSHOptions: SSHOptions{Host: "h", User: "u"}, GoVersion: "1.26;rm"}},
{name: "bad go arch", opts: SourceInstallOptions{SSHOptions: SSHOptions{Host: "h", User: "u"}, GoArch: "amd64;rm"}},
{name: "bad repo scheme", opts: SourceInstallOptions{SSHOptions: SSHOptions{Host: "h", User: "u"}, Repo: "http://x/y"}},
{name: "repo userinfo", opts: SourceInstallOptions{SSHOptions: SSHOptions{Host: "h", User: "u"}, Repo: "https://user:pass@x/y"}},
{name: "relative toolchain", opts: SourceInstallOptions{SSHOptions: SSHOptions{Host: "h", User: "u"}, ToolchainDir: "usr/local/go"}},
{name: "non-go toolchain basename", opts: SourceInstallOptions{SSHOptions: SSHOptions{Host: "h", User: "u"}, ToolchainDir: "/opt/golang"}},
{name: "relative stage", opts: SourceInstallOptions{SSHOptions: SSHOptions{Host: "h", User: "u"}, StageBinary: "bin/worker"}},
} {
t.Run(tc.name, func(t *testing.T) {
if _, err := normalizeSourceOptions(tc.opts); err == nil {
t.Fatalf("normalizeSourceOptions(%s) succeeded", tc.name)
}
})
}
}
func TestSudoWrap(t *testing.T) {
cmd, stdin := sudoWrap("root", "", "echo hi")
if cmd != "echo hi" || stdin != nil {
t.Fatalf("root wrap = %q, %q", cmd, stdin)
}
cmd, stdin = sudoWrap("deploy", "", "echo hi")
if cmd != "sudo -n -- echo hi" || stdin != nil {
t.Fatalf("passwordless sudo wrap = %q, %q", cmd, stdin)
}
cmd, stdin = sudoWrap("deploy", "supersecret", "echo hi")
if cmd != "sudo -S -p '' -- echo hi" || string(stdin) != "supersecret\n" {
t.Fatalf("sudo -S wrap = %q, %q", cmd, stdin)
}
if strings.Contains(cmd, "supersecret") {
t.Fatal("sudo password leaked into the command string")
}
}
// TestSourceScriptsNeverContainSecrets documents the "secrets absent"
// contract: none of the rendered remote scripts carry a credential.
func TestBoundedBuffer(t *testing.T) {
var b boundedBuffer
b.max = 8
if _, err := b.Write([]byte("12345")); err != nil {
t.Fatal(err)
}
if b.truncated {
t.Fatal("truncated before exceeding max")
}
if _, err := b.Write([]byte("6789abcdef")); err != nil {
t.Fatal(err)
}
if !b.truncated {
t.Fatal("overflow not flagged")
}
if got, want := b.String(), "12345678"; got != want {
t.Fatalf("boundedBuffer = %q, want %q", got, want)
}
}
func TestSourceScriptsNeverContainSecrets(t *testing.T) {
const secret = "super-secret-token-value"
scripts := []string{
packageScript(sshinstall.PkgApk, []string{"git", "ca-certificates", "curl", "tar", "gzip"}),
toolchainScript(sshinstall.Toolchain{Version: "1.26.0", Arch: "linux-amd64", URL: "https://go.dev/dl/go1.26.0.linux-amd64.tar.gz", SHA256: cannedSHA}, "/usr/local/go"),
cloneUpdateScript("https://example.test/worker.git", "/opt/rsmon-worker-src"),
resolveBranchScript("/opt/rsmon-worker-src"),
checkoutScript("/opt/rsmon-worker-src", "master"),
commitRecordScript("/opt/rsmon-worker-src", "master", strings.Repeat("a", 40)),
buildScript("/usr/local/go", "/opt/rsmon-worker-src", "", "/opt/rsmon-worker-src/rsmon-worker", "-s -w"),
}
for i, script := range scripts {
if strings.Contains(script, secret) {
t.Fatalf("script %d contains a secret", i)
}
}
}