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 удалений

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

@@ -39,12 +39,15 @@ test:
./checks/llmhttp ./checks/llmhttp
# test-ssh runs the Docker/OpenSSH source-install harness tests against # test-ssh runs the Docker/OpenSSH source-install harness tests against
# real Alpine, Ubuntu, and Arch fixtures. Requires a working Docker # real Alpine, Ubuntu, and Arch fixtures, including the full work
# daemon; the fixtures pull/start containers, so this is opt-in and is # package-3 source install (Go toolchain download, clone, and build) on
# never part of the default `make test` run. # each. Requires a working Docker daemon; the fixtures pull/start
# containers and build inside them, so this is opt-in and is never part
# of the default `make test` run. The 60m timeout covers three real
# toolchain downloads and worker builds plus the harness lifecycle tests.
test-ssh: test-ssh:
RSMON_ENV=test CWD=$(CURDIR) RSMON_TEST_DOCKER=1 go test \ RSMON_ENV=test CWD=$(CURDIR) RSMON_TEST_DOCKER=1 go test \
-v -timeout 30m -count=1 ./internal/installer/harness -v -timeout 60m -count=1 ./internal/installer/harness
check: check:
go mod tidy go mod tidy

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

@@ -168,21 +168,52 @@ are rejected before connecting to the remote host.
The default SSH port is 22 and the default RSMon URL is `https://rsmon.ru`. The default SSH port is 22 and the default RSMon URL is `https://rsmon.ru`.
Encrypted keys use `--key-passphrase-file`; password authentication uses Encrypted keys use `--key-passphrase-file`; password authentication uses
`--password-file`; password-protected sudo uses `--sudo-password-file`. Direct `--password-file`; password-protected sudo uses `--sudo-password-file`. Strongly
secret flags are supported for interactive convenience but file options are prefer the file options for automation: a secret supplied through a direct flag
safer for automation. is visible in the process list and shell history, while a file option never
exposes it through argv. The direct flags remain available for interactive
convenience.
SSH host keys are checked against `~/.ssh/known_hosts` by default. Use SSH host keys are checked against `~/.ssh/known_hosts` by default. Use
`--known-hosts PATH` or pin `--host-key-fingerprint SHA256:...`. The explicit `--known-hosts PATH` or pin `--host-key-fingerprint SHA256:...`. The explicit
`--insecure-host-key` option disables host authentication and should only be `--insecure-host-key` option disables host authentication and should only be
used in a trusted disposable environment. used in a trusted disposable environment.
A Go SSH source installer (remote package/toolchain/source build) is planned; A Go SSH source installer is implemented as the `source-install` subcommand: it
the pure detection/planning layer and the Docker/OpenSSH test harness that will detects the remote distro and architecture over the existing SSH transport,
accept it are implemented. See installs the minimal build prerequisites, downloads and SHA-256-verifies the
pinned Go 1.26 toolchain, clones/updates the public repository, records the
resolved branch and commit, and builds the worker to a staging path. It
deliberately stops before touching the running service or its configuration
(atomic activation and rollback are the next milestone). The pure
detection/planning layer and the Docker/OpenSSH test harness that accepts it
are implemented; see
[`docs/source-installation.md`](docs/source-installation.md); run the live [`docs/source-installation.md`](docs/source-installation.md); run the live
fixture matrix with `make test-ssh`. fixture matrix with `make test-ssh`.
```bash
./bin/rsmon-worker source-install \
--host worker.example.com \
--user deploy \
--identity-file ~/.ssh/id_ed25519
```
By default the installer builds the remote's default branch and records what it
resolves to (the public repository currently publishes `master`). Pass
`--branch <name>` to pin an explicit branch; it must exist on the remote or the
install fails before building. The repository must be an `https://` URL without
userinfo.
The built binary is left at `/opt/rsmon-worker-src/rsmon-worker` (override with
`--build-dir` / `--stage-binary`), the toolchain at `/usr/local/go` (replaced
atomically: download, verify, stage, swap with rollback), and the resolved
branch and commit in `/opt/rsmon-worker-src/rsmon-worker.commit` (written only
after a successful build). The same SSH auth, secret-file, and host-key options
as `deploy` apply; no worker token is sent because the source install does not
configure a service. Prefer `--key-passphrase-file`, `--password-file`, and
`--sudo-password-file` over their direct-flag equivalents: file options keep
secrets out of the process list and shell history.
## Configuration ## Configuration
| Variable | Required | Default | Purpose | | Variable | Required | Default | Purpose |

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

@@ -18,6 +18,15 @@ func TestDispatchManagementCommand(t *testing.T) {
if handled, code := dispatchManagementCommand([]string{"install", "--help"}); !handled || code != 0 { if handled, code := dispatchManagementCommand([]string{"install", "--help"}); !handled || code != 0 {
t.Fatalf("install help = handled %t code %d", handled, code) t.Fatalf("install help = handled %t code %d", handled, code)
} }
if handled, code := dispatchManagementCommand([]string{"deploy", "--help"}); !handled || code != 0 {
t.Fatalf("deploy help = handled %t code %d", handled, code)
}
if handled, code := dispatchManagementCommand([]string{"source-install", "--help"}); !handled || code != 0 {
t.Fatalf("source-install help = handled %t code %d", handled, code)
}
if handled, code := dispatchManagementCommand([]string{"source-install", "--identity-file", t.TempDir() + "/missing"}); !handled || code == 0 {
t.Fatalf("source-install without host/user = handled %t code %d", handled, code)
}
} }
func TestSecretValue(t *testing.T) { func TestSecretValue(t *testing.T) {

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

@@ -19,6 +19,8 @@ func dispatchManagementCommand(args []string) (bool, int) {
return true, installCommand(args[1:]) return true, installCommand(args[1:])
case "deploy": case "deploy":
return true, deployCommand(args[1:]) return true, deployCommand(args[1:])
case "source-install":
return true, sourceInstallCommand(args[1:])
default: default:
return false, 0 return false, 0
} }
@@ -153,3 +155,78 @@ func secretValue(direct, path string) (string, error) {
} }
return strings.TrimRight(string(b), "\r\n"), nil return strings.TrimRight(string(b), "\r\n"), nil
} }
// sourceInstallCommand drives the remote source installer (work package
// 3 of docs/source-installation.md): prerequisites, verified Go
// toolchain, clone/update, resolved branch/commit record, and a staging
// build. It reuses the deploy SSH options; it never touches the running
// service, configuration, or data directory on the remote host.
func sourceInstallCommand(args []string) int {
fs := flag.NewFlagSet("source-install", flag.ContinueOnError)
fs.SetOutput(os.Stderr)
var opts installer.SourceInstallOptions
var passphraseFile, passwordFile, sudoPasswordFile string
fs.StringVar(&opts.Host, "host", "", "SSH server hostname or address")
fs.IntVar(&opts.Port, "port", 22, "SSH server port")
fs.StringVar(&opts.User, "user", "", "SSH username")
fs.StringVar(&opts.IdentityFile, "identity-file", "", "SSH private key path")
fs.StringVar(&opts.KeyPassphrase, "key-passphrase", "", "SSH private key passphrase")
fs.StringVar(&passphraseFile, "key-passphrase-file", "", "file containing the private key passphrase")
fs.StringVar(&opts.Password, "password", "", "SSH login password")
fs.StringVar(&passwordFile, "password-file", "", "file containing the SSH login password")
fs.StringVar(&opts.SudoPassword, "sudo-password", "", "remote sudo password")
fs.StringVar(&sudoPasswordFile, "sudo-password-file", "", "file containing the remote sudo password")
fs.StringVar(&opts.KnownHostsFile, "known-hosts", "", "known_hosts path (default: ~/.ssh/known_hosts)")
fs.StringVar(&opts.HostKeyFingerprint, "host-key-fingerprint", "", "expected SHA256 SSH host-key fingerprint")
fs.BoolVar(&opts.InsecureHostKey, "insecure-host-key", false, "disable SSH host-key verification (unsafe)")
fs.StringVar(&opts.Repo, "repo", "", "worker repository to clone/update (default: public rocketgit.ru repo)")
fs.StringVar(&opts.Branch, "branch", "", "branch to build; empty resolves the remote default branch")
fs.StringVar(&opts.GoVersion, "go-version", "", "Go toolchain version (default: pinned 1.26.0)")
fs.StringVar(&opts.GoArch, "go-arch", "", "Go download archive suffix; empty derives it from the remote architecture")
fs.StringVar(&opts.BuildDir, "build-dir", "", "remote clone/build directory (default /opt/rsmon-worker-src)")
fs.StringVar(&opts.GoModuleProxy, "go-proxy", "", "GOPROXY for the remote build (default: Go default)")
fs.StringVar(&opts.ToolchainDir, "toolchain-dir", "", "remote Go install path ending in /go (default /usr/local/go)")
fs.StringVar(&opts.StageBinary, "stage-binary", "", "staging binary path (default <build-dir>/rsmon-worker)")
fs.DurationVar(&opts.SessionTimeout, "session-timeout", 0, "per-remote-command timeout (default 30m; 0 uses the default)")
fs.Usage = func() {
fmt.Fprintln(fs.Output(), "Usage: rsmon-worker source-install --host HOST --user USER [SSH options] [source options]")
fs.PrintDefaults()
}
if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return 0
}
return 2
}
if fs.NArg() != 0 {
fs.Usage()
return 2
}
var err error
if opts.KeyPassphrase, err = secretValue(opts.KeyPassphrase, passphraseFile); err != nil {
fmt.Fprintf(os.Stderr, "source-install failed: %v\n", err)
return 1
}
if opts.Password, err = secretValue(opts.Password, passwordFile); err != nil {
fmt.Fprintf(os.Stderr, "source-install failed: %v\n", err)
return 1
}
if opts.SudoPassword, err = secretValue(opts.SudoPassword, sudoPasswordFile); err != nil {
fmt.Fprintf(os.Stderr, "source-install failed: %v\n", err)
return 1
}
res, err := installer.SourceInstall(opts)
if err != nil {
fmt.Fprintf(os.Stderr, "source-install failed: %v\n", err)
return 1
}
fmt.Printf("source install complete\n")
fmt.Printf(" distro: %s\n", res.Detection.Summarize())
fmt.Printf(" toolchain: %s (%s) at %s\n", res.Plan.Toolchain.Version, res.GoArch, res.ToolchainDir)
fmt.Printf(" branch: %s\n", res.ResolvedBranch)
fmt.Printf(" commit: %s\n", res.ResolvedCommit)
fmt.Printf(" record file: %s\n", res.RecordFile)
fmt.Printf(" staged build: %s\n", res.StageBinary)
fmt.Println(" status: not installed as a service (activation is the next milestone)")
return 0
}

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

@@ -2,6 +2,88 @@
## 2026-08-12 ## 2026-08-12
### Source-install hardening review
- Fail-closed remote scripts: checkout, branch resolution, and build steps now
run under `set -eu` (and package/record steps chain with `&&`), so a failed
checkout or fetch can never be masked by a stale `rev-parse` or subsequent
command. The checkout step additionally refuses (`git diff --quiet` /
`--cached --quiet`) before the destructive `checkout -B`, because `-B`
silently discards local changes and would otherwise never fail on a dirty
tree. A dirty-tree checkout failure surfaces as a `check out branch` error
before the build runs; the new `TestSourceInstallSSHCheckoutFailureNotMasked`
unit test and `TestSourceInstallDirtyCheckoutPreservesStaging` Docker test
prove the previous staging binary and commit record are preserved
byte-for-byte.
- Atomic toolchain replacement: the Go toolchain is downloaded, SHA-256
verified, extracted into a same-filesystem staging dir, verified to report
the target version, and only then swapped into `ToolchainDir` with the prior
toolchain moved to a sibling `.go-backup` that is restored on swap failure.
A failed download/verify/extract/swap never destroys the prior Go.
- Record-after-build pairing: `rsmon-worker.commit` is written only after a
successful build, so the record and the staged binary always correspond to
the same commit. The build verifies `<stage>.new --version` before an atomic
`mv -f` over the previous staging binary; `GOMODCACHE` is now set alongside
`GOCACHE` inside the build dir so reruns reuse both caches.
- Origin verification: an existing checkout's `remote.origin.url` must exactly
match the configured repository before anything is fetched or built.
- Repository hardening: only `https://` clone URLs without userinfo are
accepted (`ValidateRepoURL`, enforced before dialing and again when
planning).
- Explicit charset validation for Go version and architecture overrides
(`sshinstall.ValidGoVersion` / `ValidGoArch`) before any remote mutation.
- Bounded remote execution: each remote command is capped by
`--session-timeout` (default 30m) and captured stdout is size-bounded
alongside the existing stderr bound; deploy's streaming `runRemote` keeps its
historical no-timeout behavior.
- The source installer now defaults to the remote's default branch (the public
repo publishes `master`) instead of the plan's stale `main` default, while
`--branch` still pins an explicit branch that must exist remotely. The
README quickstart no longer shows the incorrect `--branch main`.
- CLI secret flags keep their compatibility, but docs now explicitly state that
file options (`-password-file`, etc.) keep secrets out of argv and shell
history while direct flags expose them through the process list.
- The harness accepts `RSMON_TEST_DOCKER_DNS` (comma-separated) to pin
`docker run --dns` for fixture containers, so internet-facing installs are
not at the mercy of a flaky local resolver.
### Remote source-install execution (work package 3)
- Added `installer.SourceInstall` (`internal/installer/sourceinstall.go`):
executes the source-install flow through the existing SSH transport,
reusing the `deploy` command's `SSHOptions` (keys, passphrases,
passwords, sudo passwords, known-hosts, pinned fingerprints) and its
privilege path. Extracted the shared `SSHOptions` struct and a
`sudoWrap` helper so deploy and source install cannot diverge.
- Steps implemented: minimal package-prerequisite install per distro
(`apk`/`apt`/`pacman`/`dnf`, never a compiler), SHA-256-verified Go 1.26
toolchain download/extraction with an idempotent version-skip and temp-dir
cleanup, clone-or-update of the public repository (with a bounded 3-attempt
retry for transient DNS/TLS/proxy failures), resolution of the remote
default branch (a pinned branch must exist remotely), a resolved branch and
commit record at `<BuildDir>/rsmon-worker.commit`, and a staging build
(`CGO_ENABLED=0`, `-trimpath`, repository `-ldflags`) verified via
`--version`. The running service, config, and data directory are untouched
(work package 4 boundary).
- Security: every interpolated remote value is single-quoted; branch and
commit values are strictly validated; no worker token or control-plane
credential is sent; sudo passwords travel only over session stdin; remote
errors are bounded (stderr truncated in `runRemoteOutput`).
- Added unit tests for the remote scripts, option validation, branch/commit
parsing, sudo wrapping, the secrets-absent contract, and an in-process
real-SSH orchestration flow (with missing-pinned-branch, build-failure, and
detection-failure paths).
- Added `TestSourceInstallFixtures` to the Docker/OpenSSH harness: each of
Alpine, Ubuntu, and Arch installs from a clean state through the real
harness transport (prerequisite install, verified Go 1.26, clone, resolved
commit, staging build), then a rerun proves idempotency (same branch,
toolchain reuse, no temp leaks). All three resolved the public repo's
`master` at `4651deb2...` in the recorded run. `make test-ssh` timeout
raised to 60m.
- Documented the branch-resolution reality: the public repository currently
publishes `master`, and the installer records whatever the remote default
branch resolves to.
### Source-install harness and planning foundations (work packages 1-2) ### Source-install harness and planning foundations (work packages 1-2)
- Added `internal/installer/harness`: a reusable Docker/OpenSSH test harness - Added `internal/installer/harness`: a reusable Docker/OpenSSH test harness

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

@@ -15,8 +15,8 @@ Worker repository:
metadata; metadata;
- add a package/install smoke test for Docker and systemd artifacts; - add a package/install smoke test for Docker and systemd artifacts;
- add the Docker/OpenSSH source-install matrix for Alpine, Ubuntu, and Arch from - add the Docker/OpenSSH source-install matrix for Alpine, Ubuntu, and Arch from
[source-installation.md](source-installation.md), using Go 1.26 and branch [source-installation.md](source-installation.md), using Go 1.26 and the
`main`; remote default branch;
- document immutable SHA and release tags as production defaults. - document immutable SHA and release tags as production defaults.
Source-install foundations landed: Source-install foundations landed:
@@ -26,9 +26,16 @@ Source-install foundations landed:
reliable teardown, gated behind `RSMON_TEST_DOCKER` (`make test-ssh`); reliable teardown, gated behind `RSMON_TEST_DOCKER` (`make test-ssh`);
- [x] pure distro/toolchain/source-install planning (`internal/sshinstall`): - [x] pure distro/toolchain/source-install planning (`internal/sshinstall`):
os-release detection, package-manager/init resolution, pinned Go 1.26 os-release detection, package-manager/init resolution, pinned Go 1.26
toolchain with published SHA-256, and a plan the executor will run; toolchain with published SHA-256, and a plan the executor runs;
- [ ] remote package install, Go download, clone, and build execution through - [x] remote package install, Go download, clone, and build execution through
the SSH transport (source-install work package 3); the SSH transport (`installer.SourceInstall`): prerequisite install,
SHA-256-verified Go toolchain, clone/update of the public repo, resolved
branch/commit record, and a staging build. Running service/config is not
touched (source-install work package 3);
- [ ] atomic service activation, rollback, and failure-preservation tests over
SSH (source-install work package 4);
- [ ] run the full source-install E2E matrix in CI (source-install work
package 5).
Gate: a push publishes `sha-<12>` and `latest` manifests for both platforms, Gate: a push publishes `sha-<12>` and `latest` manifests for both platforms,
and a container remains healthy when the control plane is unavailable. and a container remains healthy when the control plane is unavailable.

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

@@ -5,8 +5,12 @@ Docker image) into a running, enabled systemd service on a Linux host. It is
the supported way to deploy the worker: it writes the configuration, the the supported way to deploy the worker: it writes the configuration, the
systemd unit, and the data directory, then starts the service. systemd unit, and the data directory, then starts the service.
> A Go SSH source installer is planned in > A Go SSH source installer (`rsmon-worker source-install`) builds the worker
> [source-installation.md](source-installation.md). Today > from source on a remote host over SSH - prerequisites, verified Go toolchain,
> clone/update of the public repository (resolved to the remote default branch
> unless pinned), resolved branch/commit record, and a staging build - without
> yet installing a service. See
> [source-installation.md](source-installation.md). Today the local
> `install` copies the binary you invoke it from (or pulls the `--image` > `install` copies the binary you invoke it from (or pulls the `--image`
> digest), so build first with `make build` and run the resulting > digest), so build first with `make build` and run the resulting
> `./bin/rsmon-worker`. > `./bin/rsmon-worker`.

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

@@ -3,8 +3,9 @@
## Status ## Status
In progress. Work package 1 (reusable Docker/OpenSSH harness and distro In progress. Work package 1 (reusable Docker/OpenSSH harness and distro
fixtures) and the pure detection/planning foundation (work package 2 fixtures), the pure detection/planning foundation (work package 2 core),
core) are implemented: and work package 3 (remote execution through the existing SSH transport)
are implemented:
- `internal/installer/harness` builds and runs real OpenSSH containers - `internal/installer/harness` builds and runs real OpenSSH containers
for Alpine, Ubuntu, and Arch, waits for real network readiness, captures for Alpine, Ubuntu, and Arch, waits for real network readiness, captures
@@ -17,13 +18,23 @@ core) are implemented:
and init system from `/etc/os-release`, plans the pinned Go 1.26 and init system from `/etc/os-release`, plans the pinned Go 1.26
toolchain (published SHA-256) for the remote architecture, and produces toolchain (published SHA-256) for the remote architecture, and produces
a pure source-install plan. It executes nothing. a pure source-install plan. It executes nothing.
- `installer.SourceInstall` (work package 3) executes the plan through
the same SSH transport, authentication, and host-key verification the
`deploy` command uses. It installs the minimal package prerequisites,
downloads and SHA-256-verifies the pinned Go toolchain before
extraction, clones/updates the public repository, checks out the
resolved branch, records the resolved branch and commit, and builds the
worker to a staging path. It deliberately does not install or replace
the running service, configuration, or data directory: atomic
activation and rollback are the next work package.
The current Go installer can still only upload a binary or deploy an The current Go installer can still only upload a binary or deploy an
immutable Docker image over SSH. Remote package/toolchain/source build immutable Docker image over SSH. Source installs now build remotely to a
execution (work packages 3 and 4) is not implemented yet; the acceptance staging path, but service activation over SSH (work package 4) is not
test currently stops after detection, clean-state, and planning implemented yet; the acceptance test stops after install-to-staging and
assertions. Existing tests are unit tests; the harness tests run against idempotent-rerun assertions. Existing tests are unit tests plus the
live OpenSSH containers when explicitly enabled. harness tests that run against live OpenSSH containers when explicitly
enabled.
## Initial Platform Scope ## Initial Platform Scope
@@ -49,7 +60,8 @@ It then:
3. downloads the pinned Go 1.26 toolchain for the detected architecture and 3. downloads the pinned Go 1.26 toolchain for the detected architecture and
verifies the published SHA-256; verifies the published SHA-256;
4. clones `https://rocketgit.ru/rsmon/worker.git` or updates an existing clone; 4. clones `https://rocketgit.ru/rsmon/worker.git` or updates an existing clone;
5. checks out branch `main` and records the resolved commit; 5. checks out the resolved branch (the pinned branch when one is configured,
otherwise the remote's default branch) and records the resolved commit;
6. builds a reproducible worker binary with the repository build flags; 6. builds a reproducible worker binary with the repository build flags;
7. atomically installs the binary, validated environment, data directory, and 7. atomically installs the binary, validated environment, data directory, and
service definition; service definition;
@@ -59,6 +71,100 @@ Repository, branch, Go version, checksum source, build directory, and Go module
proxy may be configurable, but production output records their resolved values. proxy may be configurable, but production output records their resolved values.
The default repository is publicly readable and requires no source credential. The default repository is publicly readable and requires no source credential.
## Work Package 3: Remote Execution To A Staging Path
Work package 3 is `installer.SourceInstall` in
`internal/installer/sourceinstall.go`. It reuses the `deploy` command's
`SSHOptions` (authentication, sudo password, known-hosts and fingerprint
verification) and runs every remote step with the same privilege path
(root, passwordless sudo, or `sudo -S -p ''` with the password delivered
only over stdin). Steps 1-6 of the flow above are implemented; step 7
(atomic install) is deliberately the next work package.
Per step:
- **Prerequisite install.** `packageScript` renders the distro's
idempotent command (`apk add --no-cache`, `apt-get update` +
`apt-get install -y --no-install-recommends`, `pacman -Sy --noconfirm
--needed`, `dnf install -y`) for the minimal plan packages (`git`,
`ca-certificates`, `curl`, `tar`, `gzip`). No compiler is ever planned
or installed.
- **Toolchain.** `toolchainScript` downloads the pinned Go tarball into a
`mktemp` temp dir, verifies it with `sha256sum -c -` *before*
extraction, extracts into a staging dir on the same filesystem as
`ToolchainDir` (which must end in `/go`, default `/usr/local/go`),
verifies the staged toolchain reports the target version, and only then
swaps it into place. The prior toolchain is moved to a sibling
`.go-backup` and is restored if the swap fails, so a failed
download/verify/extract/swap always leaves the prior Go untouched. A
present toolchain that already reports the target version is reused, so
reruns do not re-download. Temp, staging, and backup directories are
removed on success and failure.
- **Clone/update.** `cloneUpdateScript` clones the repository when
`BuildDir` has no `.git` and otherwise fetches with `--prune`, so a
rerun updates in place. An existing checkout's `remote.origin.url` must
exactly match the configured repository before anything is fetched or
built, so the installer can never fetch or build an unconfigured
repository. The clone/fetch retries up to three times (2s apart)
because real repositories can be transiently unreachable (DNS, TLS, or
proxy hiccups); three bounded attempts keep a momentary outage from
failing a full source install.
- **Resolved branch.** The installer resolves the remote default branch
via `git remote set-head origin --auto` +
`git symbolic-ref --short refs/remotes/origin/HEAD`. When no branch is
pinned it builds the remote default (the public repo currently
publishes `master`); a pinned branch must exist remotely or the install
fails before the build. The resolved branch and the `git rev-parse
HEAD` commit (validated as 40 lowercase hex) are returned by
`SourceInstall`.
- **Staging build.** `buildScript` builds with `CGO_ENABLED=0`,
`-trimpath`, the repository's own `-ldflags` shape (version `dev`,
resolved commit short form, UTC build date), and both `GOCACHE` and
`GOMODCACHE` inside the build dir (so reruns reuse them), plus an
optional `GOPROXY`. The binary is built to a sibling `<stage>.new`,
verified with `<stage>.new --version`, and only then atomically swapped
over `<BuildDir>/rsmon-worker` (or `StageBinary`), so a failed build
never replaces the previous staging binary. It is not written to
`/usr/local/bin`.
- **Commit record.** The `rsmon-worker.commit` record (a
`branch=...` / `commit=...` format in the build dir) is written only
*after* a successful build, so the record and the staged binary always
correspond to the same commit.
Security properties of work package 3:
- Every interpolated value (repository, branch, build dir, URLs, SHA-256,
package names, paths) is single-quoted; repository, branch, commit, Go
version, and Go architecture values are additionally validated with
strict patterns. No worker token or control-plane credential is ever
sent: the install stages a binary and touches no service configuration.
- The repository must be an `https://` URL without userinfo, so source
credentials cannot reach the remote clone command or the clone's
config.
- Sudo passwords are delivered over the session's stdin only, never in a
command string (the same `sudoWrap` path the `deploy` command uses).
Direct `--password`/`--sudo-password`/`--key-passphrase` flags remain
available but expose the value through the process list and shell
history; the CLI docs strongly prefer the `-file` variants. The source
installer sends no worker token at all.
- Every step script fails closed: `set -eu` (or an explicit retry that
exits non-zero) is used, so a failed checkout or fetch can never be
masked by a stale subsequent command. The checkout step refuses before
the destructive `checkout -B` when the tracked working tree is dirty
(`git diff --quiet` / `--cached --quiet`), because `checkout -B` would
silently discard local changes; a dirty-tree rerun fails at checkout and
leaves the previous staging binary and commit record untouched.
- Remote errors are bounded: each step returns a step-labelled error,
stderr and captured stdout are size-bounded in `runRemoteOutput`, and
each remote command is capped by `--session-timeout` (default 30m).
- Toolchain temp, staging, and backup directories are removed on success
and failure, and the acceptance test asserts no `/tmp/rsmon-toolchain-*`
or `/usr/local/.go-staging-*`/`.go-backup` leaks after the rerun.
- Failed builds and failed checkouts leave the previous staging binary
untouched (the binary is only overwritten by an atomically-swapped
successful build, and a checkout failure aborts before the build);
there is no running service or configuration to preserve yet.
## Docker OpenSSH Test Harness ## Docker OpenSSH Test Harness
Adapt the real-network pattern from `/data/_swap/sshkeymanager`: start an Adapt the real-network pattern from `/data/_swap/sshkeymanager`: start an
@@ -93,6 +199,11 @@ harness proves is that a known_hosts entry carrying a *different* key is
rejected before any command runs (the `TestHarnessHostKeyMismatch` test), rejected before any command runs (the `TestHarnessHostKeyMismatch` test),
not that a fingerprint is pinned. not that a fingerprint is pinned.
The fixtures install over the public internet, so environments with flaky
local resolvers can pin a reliable upstream via the comma-separated
`RSMON_TEST_DOCKER_DNS` variable (applied as `docker run --dns ...`); it
is empty by default, keeping Docker's embedded DNS.
Teardown (`docker rm -f` + `docker network rm` + per-instance Teardown (`docker rm -f` + `docker network rm` + per-instance
`docker image rm` + temp-dir removal) is idempotent, runs on every `docker image rm` + temp-dir removal) is idempotent, runs on every
`Start` error path, and is verified by a dedicated test. Each harness `Start` error path, and is verified by a dedicated test. Each harness
@@ -158,15 +269,26 @@ RSMON_TEST_DOCKER=1 go test -v -count=1 -timeout 30m ./internal/installer/harnes
## Idempotency And Security ## Idempotency And Security
- A second run updates/fetches safely and leaves one active service. - A second run updates/fetches safely: the toolchain is reused when the version
matches, the clone's origin is verified against the configured repository and
then fetched in place, the resolved branch/commit record is rewritten after
the new build succeeds, and the staging build atomically swaps over the
previous staging binary. The "one active service" guarantee is a work package
4 property (activation); work package 3 leaves no running service to
duplicate and no leaked toolchain temp files.
- Wrong host fingerprints fail before remote mutation. The harness's fresh - Wrong host fingerprints fail before remote mutation. The harness's fresh
known_hosts file is trust-on-first-use; its dedicated mismatch test dials known_hosts file is trust-on-first-use; its dedicated mismatch test dials
against a known_hosts entry carrying a different server key and proves the against a known_hosts entry carrying a different server key and proves the
dial fails before any command runs. dial fails before any command runs.
- Tokens/passwords come from files or stdin-safe channels and never appear in - Tokens/passwords should come from files: `--key-passphrase-file`,
command arguments, logs, source checkout, or shell history. `--password-file`, and `--sudo-password-file` keep secrets out of argv and
shell history, while the equivalent direct flags expose them through the
process list. The source install itself sends no worker token or
control-plane credential at all, and sudo passwords travel only over the
session's stdin.
- Remote temporary files are removed on success and failure. - Remote temporary files are removed on success and failure.
- Failed builds do not replace a working binary or service definition. - Failed builds, failed checkouts, and failed toolchain swaps do not replace a
working binary or service definition.
- Package-manager and download failures return bounded actionable errors. - Package-manager and download failures return bounded actionable errors.
- The installer verifies Go tarball checksum before extraction. - The installer verifies Go tarball checksum before extraction.
@@ -175,7 +297,9 @@ RSMON_TEST_DOCKER=1 go test -v -count=1 -timeout 30m ./internal/installer/harnes
- [x] 1. Add reusable Docker/OpenSSH harness and distro fixtures. - [x] 1. Add reusable Docker/OpenSSH harness and distro fixtures.
- [x] 2. Add pure distro/toolchain/source-install script planning and unit tests - [x] 2. Add pure distro/toolchain/source-install script planning and unit tests
(detection + planning foundation; remote execution is work package 3). (detection + planning foundation; remote execution is work package 3).
- [ ] 3. Execute source installation through the existing SSH transport. - [x] 3. Execute source installation through the existing SSH transport
(prerequisites, verified Go toolchain, clone/update, resolved branch and
commit record, and a build to a staging path; no service activation).
- [ ] 4. Add atomic build/install, idempotency, and failure rollback. - [ ] 4. Add atomic build/install, idempotency, and failure rollback.
- [ ] 5. Add Alpine, Ubuntu, and Arch network E2E tests to CI. - [ ] 5. Add Alpine, Ubuntu, and Arch network E2E tests to CI.
- [ ] 6. Add CentOS-family support. - [ ] 6. Add CentOS-family support.
@@ -184,13 +308,16 @@ RSMON_TEST_DOCKER=1 go test -v -count=1 -timeout 30m ./internal/installer/harnes
## Acceptance Gates ## Acceptance Gates
- [ ] All three initial Linux images install from a clean state through OpenSSH. - [ ] All three initial Linux images install from a clean state through OpenSSH.
Work package 3 covers the install-to-staging half (package install,
verified Go 1.26, clone/update, resolved commit, staging build); the
service-start half is the work package 4 gate.
- [ ] The built worker reports the expected version/commit and serves `/healthz`. - [ ] The built worker reports the expected version/commit and serves `/healthz`.
- [ ] Re-running the installer succeeds without duplicate services or leaked files. - [ ] Re-running the installer succeeds without duplicate services or leaked files.
- [ ] Host-key, checksum, clone, build, and service-start failure tests preserve the - [ ] Host-key, checksum, clone, build, and service-start failure tests preserve the
previous installation. previous installation.
- [ ] CI uses approved registry mirrors and cleans every test container/network. - [ ] CI uses approved registry mirrors and cleans every test container/network.
## Verified Test Evidence (work packages 1 and 2) ## Verified Test Evidence (work packages 1-3)
Recorded 2026-08-12 from `make test-ssh` (Docker Engine 29.7.1): Recorded 2026-08-12 from `make test-ssh` (Docker Engine 29.7.1):
@@ -202,3 +329,19 @@ Recorded 2026-08-12 from `make test-ssh` (Docker Engine 29.7.1):
- Host-key mismatch, host-key stability, failed-start cleanup, and complete - Host-key mismatch, host-key stability, failed-start cleanup, and complete
teardown (container, network, fixture image tag, and temp dir gone) tests teardown (container, network, fixture image tag, and temp dir gone) tests
pass; no test container, network, or image tag is left behind. pass; no test container, network, or image tag is left behind.
- `TestSourceInstallFixtures` runs the full work-package-3 flow on each
fixture from a clean state over the harness's real OpenSSH transport:
prerequisite install, verified Go 1.26 toolchain download/extraction, clone
of the public repository, resolution of its default branch, resolved-commit
record file, and a staging build that reports the resolved commit via
`--version`. The public repo's `HEAD` was `master` at `4651deb2...` during
the run, and all three fixtures resolved to that branch and commit (the
installer records whatever the remote publishes). A rerun succeeds, keeps
the same branch, reuses the toolchain, and leaves no
`/tmp/rsmon-toolchain-*` temp dirs. The running service and its
configuration are not touched (work package 4 boundary).
- The unit suite covers the per-step remote scripts, option validation,
branch/commit parsing, sudo wrapping (password never in the command), the
secrets-absent contract, and an in-process real-SSH orchestration flow with
failure paths for missing pinned branches, build failures, and detection
failures.

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

@@ -18,7 +18,14 @@ import (
"golang.org/x/crypto/ssh/knownhosts" "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 Host string
Port int Port int
User string User string
@@ -29,6 +36,10 @@ type DeployOptions struct {
KnownHostsFile string KnownHostsFile string
HostKeyFingerprint string HostKeyFingerprint string
InsecureHostKey bool InsecureHostKey bool
}
type DeployOptions struct {
SSHOptions
Binary string Binary string
Token string Token string
URL string URL string
@@ -71,11 +82,11 @@ func Deploy(opts DeployOptions) error {
return err return err
} }
} }
auth, err := sshAuth(opts) auth, err := sshAuth(opts.SSHOptions)
if err != nil { if err != nil {
return err return err
} }
hostKey, err := hostKeyCallback(opts) hostKey, err := hostKeyCallback(opts.SSHOptions)
if err != nil { if err != nil {
return err return err
} }
@@ -111,16 +122,7 @@ func Deploy(opts DeployOptions) error {
if opts.NoStart { if opts.NoStart {
args += " --no-start" args += " --no-start"
} }
var command string command, stdin := sudoWrap(opts.User, opts.SudoPassword, args)
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
}
if err := runRemote(client, command, stdin); err != nil { if err := runRemote(client, command, stdin); err != nil {
return fmt.Errorf("remote install: %w", err) return fmt.Errorf("remote install: %w", err)
} }
@@ -137,23 +139,30 @@ func deployDocker(client *ssh.Client, opts DeployOptions, remoteEnv, remoteUnit
if !opts.NoStart { if !opts.NoStart {
script += " && systemctl restart rsmon-worker.service && systemctl is-active --quiet rsmon-worker.service" script += " && systemctl restart rsmon-worker.service && systemctl is-active --quiet rsmon-worker.service"
} }
command := "sh -c " + shellQuote(script) command, stdin := sudoWrap(opts.User, opts.SudoPassword, "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
}
}
if err := runRemote(client, command, stdin); err != nil { if err := runRemote(client, command, stdin); err != nil {
return fmt.Errorf("remote Docker install: %w", err) return fmt.Errorf("remote Docker install: %w", err)
} }
return nil 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 var methods []ssh.AuthMethod
if opts.IdentityFile != "" { if opts.IdentityFile != "" {
key, err := os.ReadFile(opts.IdentityFile) key, err := os.ReadFile(opts.IdentityFile)
@@ -180,7 +189,7 @@ func sshAuth(opts DeployOptions) ([]ssh.AuthMethod, error) {
return methods, nil return methods, nil
} }
func hostKeyCallback(opts DeployOptions) (ssh.HostKeyCallback, error) { func hostKeyCallback(opts SSHOptions) (ssh.HostKeyCallback, error) {
if opts.HostKeyFingerprint != "" { if opts.HostKeyFingerprint != "" {
want := opts.HostKeyFingerprint want := opts.HostKeyFingerprint
return func(_ string, _ net.Addr, key ssh.PublicKey) error { 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 { 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() session, err := client.NewSession()
if err != nil { if err != nil {
return err return err
@@ -250,10 +332,25 @@ func runRemote(client *ssh.Client, command string, stdin []byte) error {
if stdin != nil { if stdin != nil {
session.Stdin = bytes.NewReader(stdin) session.Stdin = bytes.NewReader(stdin)
} }
session.Stdout = os.Stdout session.Stdout = stdout
session.Stderr = os.Stderr session.Stderr = stderr
if timeout <= 0 {
return session.Run(command) 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 { func shellQuote(value string) string {
return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'" return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'"

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

@@ -19,14 +19,14 @@ func TestFingerprintHostKeyCallback(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
callback, err := hostKeyCallback(DeployOptions{HostKeyFingerprint: ssh.FingerprintSHA256(publicKey)}) callback, err := hostKeyCallback(SSHOptions{HostKeyFingerprint: ssh.FingerprintSHA256(publicKey)})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if err := callback("host", &net.TCPAddr{}, publicKey); err != nil { if err := callback("host", &net.TCPAddr{}, publicKey); err != nil {
t.Fatalf("matching fingerprint rejected: %v", err) t.Fatalf("matching fingerprint rejected: %v", err)
} }
callback, err = hostKeyCallback(DeployOptions{HostKeyFingerprint: "SHA256:wrong"}) callback, err = hostKeyCallback(SSHOptions{HostKeyFingerprint: "SHA256:wrong"})
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -36,15 +36,17 @@ func TestFingerprintHostKeyCallback(t *testing.T) {
} }
func TestKnownHostsMissingFile(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") t.Fatal("missing known_hosts file accepted")
} }
} }
func TestDeployRejectsMutableDockerImageBeforeConnecting(t *testing.T) { func TestDeployRejectsMutableDockerImageBeforeConnecting(t *testing.T) {
err := Deploy(DeployOptions{ err := Deploy(DeployOptions{
SSHOptions: SSHOptions{
Host: "unreachable.example.test", Host: "unreachable.example.test",
User: "deploy", User: "deploy",
},
Token: "token", Token: "token",
Docker: true, Docker: true,
Image: "reg.rsxx.ru/rsmon/rsmon-worker:latest", 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)) 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 // Start builds the fixture image, starts the container, waits for real
// SSH readiness, captures the server host key into a temp known_hosts // SSH readiness, captures the server host key into a temp known_hosts
// file, and records the published port. Every error path cleans up the // 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 // docker run -d prints the container id directly, so no lookup is
// needed; the container name is the stable handle for later docker // needed; the container name is the stable handle for later docker
// calls and the id is captured for diagnostics and assertions. // calls and the id is captured for diagnostics and assertions.
out, err := dockerCmd( runArgs := []string{"run", "-d", "--name", h.container, "--network", h.network, "-p", "127.0.0.1::22"}
ctx, "run", "-d", runArgs = append(runArgs, dockerDNS()...)
"--name", h.container, runArgs = append(runArgs, h.imageTag)
"--network", h.network, out, err := dockerCmd(ctx, runArgs...)
"-p", "127.0.0.1::22",
h.imageTag,
)
if err != nil { if err != nil {
return fmt.Errorf("start %s fixture container: %w", h.Fixture.Name, err) 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 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 // Stop releases every resource the harness created: the container, its
// dedicated network, the per-instance fixture image tag (never a shared // dedicated network, the per-instance fixture image tag (never a shared
// base image), and the temp known_hosts directory. It is idempotent and // base image), and the temp known_hosts directory. It is idempotent and

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

@@ -235,6 +235,21 @@ func TestSetDockerBin(t *testing.T) {
SetDockerBin("") 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 // writeStubDocker installs a fake docker binary that records its argv to
// logPath and returns the recorded path. The stub succeeds for build, // logPath and returns the recorded path. The stub succeeds for build,
// network, run, and teardown calls; `port` fails so Start fails after // network, run, and teardown calls; `port` fails so Start fails after

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

@@ -1,6 +1,7 @@
package harness package harness
import ( import (
"bytes"
"context" "context"
"crypto/ed25519" "crypto/ed25519"
"crypto/rand" "crypto/rand"
@@ -14,6 +15,7 @@ import (
"golang.org/x/crypto/ssh" "golang.org/x/crypto/ssh"
"golang.org/x/crypto/ssh/knownhosts" "golang.org/x/crypto/ssh/knownhosts"
"rocketgit.ru/rsmon/worker/internal/installer"
"rocketgit.ru/rsmon/worker/internal/sshinstall" "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. // makeProber builds an sshinstall.FileProber over a live SSH session.
func makeProber(client *ssh.Client) sshinstall.FileProber { func makeProber(client *ssh.Client) sshinstall.FileProber {
return func(paths ...string) map[string]bool { 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)
}
}
}

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

@@ -3,6 +3,7 @@ package sshinstall
import ( import (
"fmt" "fmt"
"net/url" "net/url"
"regexp"
"strings" "strings"
) )
@@ -15,9 +16,25 @@ const DefaultGoVersion = "1.26.0"
// source credential and is the plan's default clone URL. // source credential and is the plan's default clone URL.
const DefaultRepo = "https://rocketgit.ru/rsmon/worker.git" const DefaultRepo = "https://rocketgit.ru/rsmon/worker.git"
// DefaultBranch is the branch the source installer checks out and var (
// builds. // goVersionPattern bounds Go toolchain version strings that are
const DefaultBranch = "main" // interpolated into remote shell commands and download URLs.
goVersionPattern = regexp.MustCompile(`^[0-9][0-9A-Za-z._-]*$`)
// goArchPattern bounds Go download archive suffixes (e.g. "amd64")
// that are interpolated into remote shell commands and URLs.
goArchPattern = regexp.MustCompile(`^[0-9A-Za-z][0-9A-Za-z_-]*$`)
)
// ValidGoVersion reports whether a Go toolchain version uses only safe
// characters (digits, letters, dots, dashes, underscores) and starts
// with a digit. Versions are embedded in remote shell commands and
// download URLs, so the charset is the injection boundary.
func ValidGoVersion(v string) bool { return goVersionPattern.MatchString(v) }
// ValidGoArch reports whether a Go download archive suffix uses only
// safe characters. Suffixes are embedded in remote shell commands and
// download URLs, so the charset is the injection boundary.
func ValidGoArch(a string) bool { return goArchPattern.MatchString(a) }
// Toolchain is a pinned, checksum-verified Go toolchain download for a // Toolchain is a pinned, checksum-verified Go toolchain download for a
// remote Linux architecture. The SHA-256 is baked for the default // remote Linux architecture. The SHA-256 is baked for the default
@@ -117,7 +134,7 @@ func ToolchainFor(goarch, version string) (Toolchain, error) {
// back to the pinned defaults. // back to the pinned defaults.
type SourceOptions struct { type SourceOptions struct {
Repo string // clone URL; default DefaultRepo Repo string // clone URL; default DefaultRepo
Branch string // default DefaultBranch Branch string // branch to build; empty means "the remote default branch"
GoVersion string // default DefaultGoVersion GoVersion string // default DefaultGoVersion
GoArch string // go archive suffix; when empty, derived from UnameM GoArch string // go archive suffix; when empty, derived from UnameM
UnameM string // remote `uname -m` output; required unless GoArch set UnameM string // remote `uname -m` output; required unless GoArch set
@@ -147,6 +164,9 @@ func PlanSource(d Detection, opts SourceOptions) (SourcePlan, error) {
return SourcePlan{}, fmt.Errorf("unsupported distro %q: no package manager", d.ID) return SourcePlan{}, fmt.Errorf("unsupported distro %q: no package manager", d.ID)
} }
goarch := strings.TrimSpace(opts.GoArch) goarch := strings.TrimSpace(opts.GoArch)
if goarch != "" && !ValidGoArch(goarch) {
return SourcePlan{}, fmt.Errorf("invalid Go architecture %q: only letters, digits, dashes, and underscores are allowed", goarch)
}
if goarch == "" { if goarch == "" {
var err error var err error
goarch, err = GoArch(opts.UnameM) goarch, err = GoArch(opts.UnameM)
@@ -154,6 +174,9 @@ func PlanSource(d Detection, opts SourceOptions) (SourcePlan, error) {
return SourcePlan{}, err return SourcePlan{}, err
} }
} }
if version := strings.TrimSpace(opts.GoVersion); version != "" && !ValidGoVersion(version) {
return SourcePlan{}, fmt.Errorf("invalid Go version %q: only digits, letters, dots, dashes, and underscores are allowed", version)
}
toolchain, err := ToolchainFor(goarch, opts.GoVersion) toolchain, err := ToolchainFor(goarch, opts.GoVersion)
if err != nil { if err != nil {
return SourcePlan{}, err return SourcePlan{}, err
@@ -166,9 +189,10 @@ func PlanSource(d Detection, opts SourceOptions) (SourcePlan, error) {
return SourcePlan{}, err return SourcePlan{}, err
} }
branch := strings.TrimSpace(opts.Branch) branch := strings.TrimSpace(opts.Branch)
if branch == "" { // An empty branch means "build the remote's default branch" (the
branch = DefaultBranch // public repo currently publishes master). The executor resolves and
} // records the remote default; a non-empty branch is pinned and must
// exist on the remote.
buildDir := strings.TrimSpace(opts.BuildDir) buildDir := strings.TrimSpace(opts.BuildDir)
if buildDir == "" { if buildDir == "" {
buildDir = "/opt/rsmon-worker-src" buildDir = "/opt/rsmon-worker-src"
@@ -206,9 +230,19 @@ func packagePrereqs(pkg PackageManager) []string {
} }
} }
// validateRepoURL rejects repository references that could smuggle a // ValidateRepoURL rejects repository references that could smuggle a
// command or a non-remote scheme into the clone step. Only http(s) and // command or a non-remote scheme into the clone step. Only https is
// the git protocol are accepted; the default repository is https. // accepted (the default repository is https), and userinfo such as
// `user:pass@host` is rejected so credentials can never reach the remote
// clone command or the clone's config. An empty value is accepted here
// (it falls back to the default repository when planning).
func ValidateRepoURL(repo string) error {
if repo == "" {
return nil
}
return validateRepoURL(repo)
}
func validateRepoURL(repo string) error { func validateRepoURL(repo string) error {
if strings.ContainsAny(repo, "\r\n\t ") { if strings.ContainsAny(repo, "\r\n\t ") {
return fmt.Errorf("repository URL %q contains whitespace", repo) return fmt.Errorf("repository URL %q contains whitespace", repo)
@@ -217,12 +251,13 @@ func validateRepoURL(repo string) error {
if err != nil || u.Host == "" { if err != nil || u.Host == "" {
return fmt.Errorf("repository URL %q is not an absolute clone URL", repo) return fmt.Errorf("repository URL %q is not an absolute clone URL", repo)
} }
switch u.Scheme { if u.User != nil {
case "https", "http", "git": return fmt.Errorf("repository URL %q must not contain userinfo", repo)
return nil
default:
return fmt.Errorf("repository URL %q uses unsupported scheme %q", repo, u.Scheme)
} }
if u.Scheme != "https" {
return fmt.Errorf("repository URL %q must use the https scheme", repo)
}
return nil
} }
// StepKind identifies one ordered remote step the executor will run. // StepKind identifies one ordered remote step the executor will run.
@@ -248,11 +283,15 @@ type Step struct {
// Steps returns the ordered source-install plan as stable, reviewable // Steps returns the ordered source-install plan as stable, reviewable
// steps. It is the contract the executor work package implements. // steps. It is the contract the executor work package implements.
func (p SourcePlan) Steps() []Step { func (p SourcePlan) Steps() []Step {
checkoutDetail := "check out branch " + p.Branch + " and record the resolved commit"
if p.Branch == "" {
checkoutDetail = "check out the remote default branch and record the resolved commit"
}
return []Step{ return []Step{
{Kind: StepInstallPackages, Detail: "install minimal build prerequisites", Packages: p.Packages}, {Kind: StepInstallPackages, Detail: "install minimal build prerequisites", Packages: p.Packages},
{Kind: StepInstallToolchain, Detail: "install pinned Go " + p.Toolchain.Version + " (" + p.Toolchain.Arch + ") and verify SHA-256"}, {Kind: StepInstallToolchain, Detail: "install pinned Go " + p.Toolchain.Version + " (" + p.Toolchain.Arch + ") and verify SHA-256"},
{Kind: StepCloneSource, Detail: "clone " + p.Repo + " into " + p.BuildDir}, {Kind: StepCloneSource, Detail: "clone " + p.Repo + " into " + p.BuildDir},
{Kind: StepCheckoutBranch, Detail: "check out branch " + p.Branch + " and record the resolved commit"}, {Kind: StepCheckoutBranch, Detail: checkoutDetail},
{Kind: StepBuildWorker, Detail: "build the worker binary with CGO_ENABLED=0 and trimpath"}, {Kind: StepBuildWorker, Detail: "build the worker binary with CGO_ENABLED=0 and trimpath"},
{Kind: StepInstallService, Detail: "atomically install the binary, env, data dir, and " + string(p.InitSystem) + " service definition"}, {Kind: StepInstallService, Detail: "atomically install the binary, env, data dir, and " + string(p.InitSystem) + " service definition"},
} }

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

@@ -127,7 +127,7 @@ func TestPlanSourceAlpine(t *testing.T) {
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
} }
if p.Repo != DefaultRepo || p.Branch != DefaultBranch { if p.Repo != DefaultRepo || p.Branch != "" {
t.Fatalf("plan defaults wrong: %+v", p) t.Fatalf("plan defaults wrong: %+v", p)
} }
if p.Toolchain.Arch != "linux-amd64" { if p.Toolchain.Arch != "linux-amd64" {
@@ -230,14 +230,45 @@ func TestPackagePrereqsNeverIncludeCompiler(t *testing.T) {
} }
func TestValidateRepoURLSchemes(t *testing.T) { func TestValidateRepoURLSchemes(t *testing.T) {
for _, ok := range []string{"https://rocketgit.ru/rsmon/worker.git", "http://x/y", "git://example.test/r"} { for _, ok := range []string{"https://rocketgit.ru/rsmon/worker.git", "https://example.test/r"} {
if err := validateRepoURL(ok); err != nil { if err := validateRepoURL(ok); err != nil {
t.Fatalf("validateRepoURL(%q): %v", ok, err) t.Fatalf("validateRepoURL(%q): %v", ok, err)
} }
} }
for _, bad := range []string{"ssh://h@x/r", "s3://bucket/key", "x y", ""} { for _, bad := range []string{
"ssh://h@x/r", "s3://bucket/key", "x y", "", "http://x/y", "git://example.test/r",
"https://user:pass@example.test/r", "https://token@example.test/r", "file:///tmp/r",
} {
if err := validateRepoURL(bad); err == nil { if err := validateRepoURL(bad); err == nil {
t.Fatalf("validateRepoURL(%q) succeeded", bad) t.Fatalf("validateRepoURL(%q) succeeded", bad)
} }
} }
} }
func TestPlanSourceRejectsUnsafeCharset(t *testing.T) {
ubuntu := Detect("ID=ubuntu\n", nil)
for _, goarch := range []string{"amd64;rm", "x;rm -rf", "$(id)", "..", "a b"} {
if _, err := PlanSource(ubuntu, SourceOptions{UnameM: "x86_64", GoArch: goarch}); err == nil {
t.Fatalf("unsafe GoArch %q planned", goarch)
}
}
for _, version := range []string{"1.26;rm", "$(id)", "1.26.0 x", "a/b"} {
if _, err := PlanSource(ubuntu, SourceOptions{UnameM: "x86_64", GoVersion: version}); err == nil {
t.Fatalf("unsafe GoVersion %q planned", version)
}
}
if !ValidGoVersion("1.26.0") || !ValidGoArch("amd64") {
t.Fatal("valid version/arch rejected")
}
}
func TestPlanSourceEmptyBranchSteps(t *testing.T) {
ubuntu := Detect("ID=ubuntu\n", nil)
p, err := PlanSource(ubuntu, SourceOptions{UnameM: "x86_64"})
if err != nil {
t.Fatal(err)
}
if !strings.Contains(p.Steps()[3].Detail, "remote default branch") {
t.Fatalf("checkout step detail for empty branch = %q", p.Steps()[3].Detail)
}
}