Все проверки выполнены успешно
CI / test (push) Successful in 4m30s
Docker / Build and publish worker image (push) Successful in 17m26s
604 строки
25 KiB
Go
604 строки
25 KiB
Go
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 until Activation runs.
|
|
StageBinary string
|
|
// SessionTimeout bounds each remote command. 0 uses the default
|
|
// (30 minutes); the build step can legitimately run for minutes.
|
|
SessionTimeout time.Duration
|
|
// Activation drives work package 4: after the staging build, the
|
|
// staged binary, validated environment, data dir, and the detected
|
|
// init's service definition are installed atomically and the worker
|
|
// is started and verified (process + /healthz). Any failure rolls
|
|
// back to the prior working install. Empty keeps SourceInstall at the
|
|
// staging boundary and touches no service configuration.
|
|
Activation ActivationOptions
|
|
|
|
// activationEnv is the once-rendered activation environment, computed
|
|
// during option normalization so the env file is read and rendered
|
|
// exactly once per run (no TOCTOU between preflight and activation).
|
|
activationEnv []byte
|
|
}
|
|
|
|
// 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
|
|
// Activation is set when the staged binary was atomically installed
|
|
// and the worker started/verified. It records the installed layout
|
|
// and the supervisor used. Nil when Activation was not requested.
|
|
Activation *ActivationResult
|
|
}
|
|
|
|
// 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.
|
|
//
|
|
// When Activation.Activate is set, the staged binary is then atomically
|
|
// installed together with the validated environment, data directory, and
|
|
// the detected init's service definition, and the worker is started and
|
|
// verified (process + /healthz); any activation/start/health failure
|
|
// rolls back to the prior working install. Without activation the
|
|
// running service, its configuration, and its data directory are
|
|
// deliberately untouched (the staging boundary of work package 3).
|
|
//
|
|
// 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)
|
|
}
|
|
|
|
// Work package 4: atomically install the staged build and activate
|
|
// the worker. On any failure the remote script restores the previous
|
|
// working install and this step returns a bounded error.
|
|
if opts.Activation.Activate {
|
|
if err := executor.activateWorker(opts, result); err != nil {
|
|
return nil, 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
|
|
}
|
|
if o.Activation.Activate {
|
|
// Render (and validate) the activation environment exactly once
|
|
// here. The rendered bytes are reused at activation time, so the
|
|
// env file is read a single time and cannot change between the
|
|
// preflight and the remote install (env-file TOCTOU).
|
|
env, err := o.Activation.renderEnv()
|
|
if err != nil {
|
|
return o, fmt.Errorf("activation: %w", err)
|
|
}
|
|
if err := validateRenderedEnv(env); err != nil {
|
|
return o, fmt.Errorf("activation: %w", err)
|
|
}
|
|
o.activationEnv = env
|
|
}
|
|
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()
|
|
}
|