diff --git a/Makefile b/Makefile index a96c0ae..4760efc 100644 --- a/Makefile +++ b/Makefile @@ -4,17 +4,22 @@ COMMIT ?= $(shell git rev-parse --short=12 HEAD 2>/dev/null || printf unknown) BUILD_DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ) LDFLAGS := -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.buildDate=$(BUILD_DATE) -.PHONY: build test check image clean +.PHONY: build test test-ssh check image clean build: mkdir -p bin CGO_ENABLED=0 go build -trimpath -ldflags="$(LDFLAGS)" -o $(BINARY) ./cmd/rsmon-worker test: - RSMON_ENV=test CWD=$(CURDIR) go test \ + # RSMON_TEST_DOCKER=0 pins this target to the non-Docker unit run even + # if a developer has the opt-in flag exported in their environment; + # only the explicit `make test-ssh` target starts containers. + RSMON_ENV=test CWD=$(CURDIR) RSMON_TEST_DOCKER=0 go test \ ./cmd/rsmon-worker \ ./internal/distworker \ ./internal/installer \ + ./internal/installer/harness \ + ./internal/sshinstall \ ./internal/webapp \ ./internal/compose \ ./internal/workercluster \ @@ -33,6 +38,14 @@ test: ./checks/cwhois \ ./checks/llmhttp +# test-ssh runs the Docker/OpenSSH source-install harness tests against +# real Alpine, Ubuntu, and Arch fixtures. Requires a working Docker +# daemon; the fixtures pull/start containers, so this is opt-in and is +# never part of the default `make test` run. +test-ssh: + RSMON_ENV=test CWD=$(CURDIR) RSMON_TEST_DOCKER=1 go test \ + -v -timeout 30m -count=1 ./internal/installer/harness + check: go mod tidy git diff --exit-code -- go.mod go.sum diff --git a/README.md b/README.md index 8557007..7684802 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,12 @@ SSH host keys are checked against `~/.ssh/known_hosts` by default. Use `--insecure-host-key` option disables host authentication and should only be used in a trusted disposable environment. +A Go SSH source installer (remote package/toolchain/source build) is planned; +the pure detection/planning layer and the Docker/OpenSSH test harness that will +accept it are implemented. See +[`docs/source-installation.md`](docs/source-installation.md); run the live +fixture matrix with `make test-ssh`. + ## Configuration | Variable | Required | Default | Purpose | diff --git a/docs/README.md b/docs/README.md index 2c8f9dc..def7c08 100644 --- a/docs/README.md +++ b/docs/README.md @@ -28,7 +28,7 @@ disagree, update both in the same change or mark the discrepancy explicitly. | [network-diagnostics.md](network-diagnostics.md) | Confirmation checks and dedicated diagnostic tasks | Partial | | [critical-check-cluster.md](critical-check-cluster.md) | Raft-backed dispatchless critical checks | Scaffold only | | [public-endpoint-and-identity.md](public-endpoint-and-identity.md) | One HTTPS origin, peer status, CA/mTLS, managed Raft topology | Planned; peer HTTPS partial | -| [source-installation.md](source-installation.md) | Go SSH source installer and Docker/OpenSSH test matrix | Planned | +| [source-installation.md](source-installation.md) | Go SSH source installer and Docker/OpenSSH test matrix | Partial: harness + planning landed, remote build pending | | [implementation-roadmap.md](implementation-roadmap.md) | Ordered repository work packages and release gates | Active | | [source-plan-migration.md](source-plan-migration.md) | Source-to-target conversion ledger and resolved conflicts | Complete mapping | diff --git a/docs/changelog.md b/docs/changelog.md index 3f5ebb1..f9df91f 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,6 +2,42 @@ ## 2026-08-12 +### Source-install harness and planning foundations (work packages 1-2) + +- Added `internal/installer/harness`: a reusable Docker/OpenSSH test harness + that builds real OpenSSH containers for Alpine, Ubuntu, and Arch, waits for + real network SSH readiness, captures the server host key into a temp + `known_hosts` file, and tears the container, network, per-instance fixture + image tag, and temp dir down reliably. It uses the `golang.org/x/crypto/ssh` + library and known_hosts verification semantics the installer's `deploy` path + relies on (the harness owns its connection code rather than reusing the + installer functions) and never mocks SSH. A fresh known_hosts file trusts + the first key (TOFU); the host-key mismatch test proves a different key is + rejected before any command runs. +- Added distro fixtures under `internal/installer/harness/testdata/fixtures`. + Alpine defaults to the `reg.rsxx.ru/library/alpine:3` mirror; Ubuntu and Arch + fall back to Docker Hub refs overridable via `RSMON_TEST_IMAGE_`. + Each fixture starts clean (no Go, no worker source) and authenticates with a + bundled test key; password auth is disabled. The test key is strictly + test-only - it grants root only to the disposable fixture containers - and + must never be used outside the harness. +- Added opt-in integration controls: the Docker tests run only with + `RSMON_TEST_DOCKER=1` (`make test-ssh`); default `make test` and `go test + ./...` skip them and never pull or start containers. `make test` also pins + `RSMON_TEST_DOCKER=0` so an exported opt-in flag cannot leak into the unit + run. +- Integration tests assert real SSH round trips, clean target state, distro / + package-manager / init detection per fixture, a full source plan including + the pinned Go toolchain, host-key mismatch rejection, host-key stability, + failed-start cleanup, and complete teardown (container, network, fixture + image tag, and temp dir gone). +- Added `internal/sshinstall`: pure, unit-tested detection and planning for the + source installer - os-release parsing, distro/package-manager/init + resolution, `uname -m` to Go archive mapping, pinned Go 1.26 toolchain with + published SHA-256, and a reviewable ordered plan. No remote execution yet. +- `make test` now includes the new packages; `make test-ssh` runs the live + fixture matrix. + ### Public endpoint configuration (milestone 1 of public-endpoint-and-identity) - `PUBLIC_URL` is now the canonical advertised public origin; the legacy diff --git a/docs/implementation-roadmap.md b/docs/implementation-roadmap.md index 80a8397..af1c074 100644 --- a/docs/implementation-roadmap.md +++ b/docs/implementation-roadmap.md @@ -19,6 +19,17 @@ Worker repository: `main`; - document immutable SHA and release tags as production defaults. +Source-install foundations landed: + +- [x] reusable Docker/OpenSSH harness and distro fixtures for Alpine, Ubuntu, + and Arch (`internal/installer/harness`) with real SSH readiness and + reliable teardown, gated behind `RSMON_TEST_DOCKER` (`make test-ssh`); +- [x] pure distro/toolchain/source-install planning (`internal/sshinstall`): + os-release detection, package-manager/init resolution, pinned Go 1.26 + toolchain with published SHA-256, and a plan the executor will run; +- [ ] remote package install, Go download, clone, and build execution through + the SSH transport (source-install work package 3); + Gate: a push publishes `sha-<12>` and `latest` manifests for both platforms, and a container remains healthy when the control plane is unavailable. diff --git a/docs/source-installation.md b/docs/source-installation.md index 9d2c3dd..872e775 100644 --- a/docs/source-installation.md +++ b/docs/source-installation.md @@ -2,10 +2,28 @@ ## Status -Planned. The current Go installer can upload a binary or deploy an immutable -Docker image over SSH. It does not yet install build prerequisites, download Go, -clone the public repository, or build remotely. Existing tests are unit tests; -there is no live OpenSSH-container installation test. +In progress. Work package 1 (reusable Docker/OpenSSH harness and distro +fixtures) and the pure detection/planning foundation (work package 2 +core) are implemented: + +- `internal/installer/harness` builds and runs real OpenSSH containers + for Alpine, Ubuntu, and Arch, waits for real network readiness, captures + the server host key into a temp `known_hosts` file, and tears the + container, network, per-instance fixture image tag, and temp dir down + reliably. It never mocks SSH and connects with the + `golang.org/x/crypto/ssh` library and known_hosts verification + semantics the installer's `deploy` path relies on. +- `internal/sshinstall` resolves a remote host's distro, package manager, + and init system from `/etc/os-release`, plans the pinned Go 1.26 + toolchain (published SHA-256) for the remote architecture, and produces + a pure source-install plan. It executes nothing. + +The current Go installer can still only upload a binary or deploy an +immutable Docker image over SSH. Remote package/toolchain/source build +execution (work packages 3 and 4) is not implemented yet; the acceptance +test currently stops after detection, clean-state, and planning +assertions. Existing tests are unit tests; the harness tests run against +live OpenSSH containers when explicitly enabled. ## Initial Platform Scope @@ -58,10 +76,93 @@ Each clean target begins without Go or the worker source. The test asserts package installation, verified Go version, clone branch/resolved commit, build, atomic config permissions, running service where supported, and HTTP liveness. +### Implementation (`internal/installer/harness`) + +The harness starts one container per distro fixture +(`testdata/fixtures/{alpine,ubuntu,arch}/Dockerfile`), waits for a real +TCP + SSH handshake, captures the server host key into a temp +`known_hosts` file, and connects with the `golang.org/x/crypto/ssh` +library and known_hosts verification semantics the installer's `deploy` +uses. The harness owns its connection code rather than calling into the +installer package, so the tests stay independent; only the library and +the verification semantics are shared. + +Host-key handling is trust-on-first-use (TOFU): the fresh temp +`known_hosts` file accepts the first key the server presents. What the +harness proves is that a known_hosts entry carrying a *different* key is +rejected before any command runs (the `TestHarnessHostKeyMismatch` test), +not that a fingerprint is pinned. + +Teardown (`docker rm -f` + `docker network rm` + per-instance +`docker image rm` + temp-dir removal) is idempotent, runs on every +`Start` error path, and is verified by a dedicated test. Each harness +instance builds its own uniquely-tagged fixture image +(`rsmon-worker-test/-:local`), so removing one never +deletes a shared base image or another instance's image. + +Base images, mirror-first: + +| Fixture | Default image | Note | +| --- | --- | --- | +| alpine | `reg.rsxx.ru/library/alpine:3` | reg.rsxx.ru mirror exists | +| ubuntu | `ubuntu:24.04` | no mirror yet; override `RSMON_TEST_IMAGE_UBUNTU` | +| arch | `archlinux:latest` | no mirror yet; override `RSMON_TEST_IMAGE_ARCH` | + +Any `RSMON_TEST_IMAGE_` environment variable overrides the fixture +image, so a mirror or local cache can be used when available. + +> **Mutable test images.** The fixture base tags above are deliberately +> mutable (a major-tag mirror ref and Docker Hub rolling tags) so the +> fixtures track current distro releases. Fixture builds are therefore +> not byte-reproducible; the worker's *source-install* production output +> pins immutable artifacts (a Go toolchain SHA-256, a branch's resolved +> commit) and this harness's image override is the escape hatch for +> reproducing a specific distro snapshot. + +The fixtures authenticate with the bundled test key +(`testdata/keys/rsmon_test_ed25519`); password auth is disabled and +`PermitRootLogin` is `prohibit-password`. `openrc` is installed in the +Alpine fixture so init detection has a stable marker; Ubuntu and Arch +carry systemd markers. + +> **Test-only key.** The bundled keypair is strictly a test fixture: it +> grants root SSH access only to the disposable containers that bake its +> public key. It must never be used for real hosts, added to production +> images, or treated as a credential outside the harness. + +### Opt-in integration test controls + +Ordinary unit runs never pull or start Docker. The Docker/OpenSSH tests +are gated behind the `RSMON_TEST_DOCKER` environment variable: + +- `make test` (default CI unit run) pins `RSMON_TEST_DOCKER=0` and skips + every harness Docker test, even when the flag is exported in the + developer's environment. +- `make test-ssh` sets `RSMON_TEST_DOCKER=1` and runs the full fixture + matrix (`TestHarnessFixtures` for alpine/ubuntu/arch, host-key + mismatch, stable host key, failed-start cleanup, and teardown + assertions). +- The harness `Start` itself refuses to run without the opt-in flag. + +Run them locally with: + +```bash +make test-ssh +``` + +or, equivalently: + +```bash +RSMON_TEST_DOCKER=1 go test -v -count=1 -timeout 30m ./internal/installer/harness +``` + ## Idempotency And Security - A second run updates/fetches safely and leaves one active service. -- Wrong host fingerprints fail before remote mutation. +- Wrong host fingerprints fail before remote mutation. The harness's fresh + 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 + dial fails before any command runs. - Tokens/passwords come from files or stdin-safe channels and never appear in command arguments, logs, source checkout, or shell history. - Remote temporary files are removed on success and failure. @@ -71,19 +172,33 @@ atomic config permissions, running service where supported, and HTTP liveness. ## Implementation Work Packages -1. Add reusable Docker/OpenSSH harness and distro fixtures. -2. Add pure distro/toolchain/source-install script planning and unit tests. -3. Execute source installation through the existing SSH transport. -4. Add atomic build/install, idempotency, and failure rollback. -5. Add Alpine, Ubuntu, and Arch network E2E tests to CI. -6. Add CentOS-family support. -7. Plan native Windows service and macOS launchd installers separately. +- [x] 1. Add reusable Docker/OpenSSH harness and distro fixtures. +- [x] 2. Add pure distro/toolchain/source-install script planning and unit tests + (detection + planning foundation; remote execution is work package 3). +- [ ] 3. Execute source installation through the existing SSH transport. +- [ ] 4. Add atomic build/install, idempotency, and failure rollback. +- [ ] 5. Add Alpine, Ubuntu, and Arch network E2E tests to CI. +- [ ] 6. Add CentOS-family support. +- [ ] 7. Plan native Windows service and macOS launchd installers separately. ## Acceptance Gates -- All three initial Linux images install from a clean state through OpenSSH. -- The built worker reports the expected version/commit and serves `/healthz`. -- Re-running the installer succeeds without duplicate services or leaked files. -- Host-key, checksum, clone, build, and service-start failure tests preserve the - previous installation. -- CI uses approved registry mirrors and cleans every test container/network. +- [ ] All three initial Linux images install from a clean state through OpenSSH. +- [ ] The built worker reports the expected version/commit and serves `/healthz`. +- [ ] Re-running the installer succeeds without duplicate services or leaked files. +- [ ] Host-key, checksum, clone, build, and service-start failure tests preserve the + previous installation. +- [ ] CI uses approved registry mirrors and cleans every test container/network. + +## Verified Test Evidence (work packages 1 and 2) + +Recorded 2026-08-12 from `make test-ssh` (Docker Engine 29.7.1): + +- Alpine `3.24.1` (mirror `reg.rsxx.ru/library/alpine:3`): distro alpine, + pkg apk, init openrc. +- Ubuntu `24.04` (`VERSION_ID=24.04`): distro ubuntu, pkg apt, init systemd. +- Arch rolling image `archlinux:latest` (`VERSION_ID=20260809.0.570793`): + distro arch, pkg pacman, init systemd. +- Host-key mismatch, host-key stability, failed-start cleanup, and complete + teardown (container, network, fixture image tag, and temp dir gone) tests + pass; no test container, network, or image tag is left behind. diff --git a/internal/installer/harness/harness.go b/internal/installer/harness/harness.go new file mode 100644 index 0000000..51b09b8 --- /dev/null +++ b/internal/installer/harness/harness.go @@ -0,0 +1,599 @@ +// Package harness starts real OpenSSH containers for the source-install +// tests. It is the reusable Docker/OpenSSH test harness from work +// package 1 of docs/source-installation.md. +// +// The harness never mocks SSH: it builds a distro fixture image, runs an +// OpenSSH server in a container, waits for real network readiness, and +// connects with the golang.org/x/crypto/ssh library and known_hosts +// verification semantics the worker installer uses. Tests opt in with +// RSMON_TEST_DOCKER=1 so ordinary unit runs never pull or start Docker. +package harness + +import ( + "bytes" + "context" + "errors" + "fmt" + "net" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "sync" + "time" + + "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/knownhosts" + + "rocketgit.ru/rsmon/worker/internal/sshinstall" +) + +// Fixture describes one distro OpenSSH fixture the harness can start. +type Fixture struct { + Name string // "alpine", "ubuntu", "arch" + Distro sshinstall.Distro // expected detected distro + Pkg sshinstall.PackageManager + Init sshinstall.InitSystem + Image string // explicit image override; empty uses the default + User string // SSH user to authenticate as; default "root" +} + +// Fixtures returns the supported source-install distro fixtures. +func Fixtures() []Fixture { + return []Fixture{ + {Name: "alpine", Distro: sshinstall.DistroAlpine, Pkg: sshinstall.PkgApk, Init: sshinstall.InitOpenRC}, + {Name: "ubuntu", Distro: sshinstall.DistroUbuntu, Pkg: sshinstall.PkgApt, Init: sshinstall.InitSystemd}, + {Name: "arch", Distro: sshinstall.DistroArch, Pkg: sshinstall.PkgPacman, Init: sshinstall.InitSystemd}, + } +} + +// defaultImages maps fixture name to its base image. reg.rsxx.ru mirror +// refs are used where the mirror caches the distro; Docker Hub refs are +// the fallback for distros the mirror does not carry and are overridable +// per fixture via RSMON_TEST_IMAGE_. +var defaultImages = map[string]string{ + "alpine": "reg.rsxx.ru/library/alpine:3", + "ubuntu": "ubuntu:24.04", + "arch": "archlinux:latest", +} + +// ImageRef resolves the base image reference for a fixture. Precedence: +// RSMON_TEST_IMAGE_ env > Fixture.Image > mirror-aware default. +func (f Fixture) ImageRef() string { + envKey := "RSMON_TEST_IMAGE_" + strings.ToUpper(strings.ReplaceAll(f.Name, "-", "_")) + if v := strings.TrimSpace(os.Getenv(envKey)); v != "" { + return v + } + if f.Image != "" { + return f.Image + } + return defaultImages[f.Name] +} + +// UserOrDefault returns the SSH user, defaulting to root. +func (f Fixture) UserOrDefault() string { + if f.User != "" { + return f.User + } + return "root" +} + +// DockerfilePath is the fixture's Dockerfile under the package testdata. +func (f Fixture) DockerfilePath() string { + return filepath.Join("testdata", "fixtures", f.Name, "Dockerfile") +} + +// BuildContext is the Docker build context that carries the shared keys. +func (f Fixture) BuildContext() string { + return filepath.Join("testdata") +} + +// Harness manages the lifecycle of one OpenSSH test container: build, +// run, wait for readiness, capture the host key, and tear down reliably. +// Every resource it creates - fixture image tag, container, network, and +// the temp known_hosts directory - is unique to this instance and is +// removed by Stop. +type Harness struct { + Name string + Fixture Fixture + + mu sync.Mutex + started bool + suffix string + containerID string + container string + network string + imageTag string + port int + user string + keyPath string + knownHosts string +} + +// New creates a harness for the named fixture. The name must be a safe, +// short identifier; a random suffix makes the container, network, and +// fixture-image tag unique to this instance so teardown never touches +// another harness's resources. +func New(name string, f Fixture) (*Harness, error) { + if err := validateName(name); err != nil { + return nil, err + } + suffix := strconv.FormatInt(time.Now().UnixNano(), 36) + return &Harness{ + Name: name, + Fixture: f, + suffix: suffix, + container: "rsmon-worker-test-" + sanitize(name) + "-" + suffix, + network: "rsmon-worker-test-" + sanitize(name) + "-" + suffix, + imageTag: "rsmon-worker-test/" + sanitize(f.Name) + "-" + suffix + ":local", + user: f.UserOrDefault(), + }, nil +} + +// ContainerID returns the running container id (after Start). +func (h *Harness) ContainerID() string { + h.mu.Lock() + defer h.mu.Unlock() + return h.containerID +} + +// ContainerName returns the Docker container name. +func (h *Harness) ContainerName() string { return h.container } + +// NetworkName returns the Docker network name. +func (h *Harness) NetworkName() string { return h.network } + +// ImageTag returns the fixture image tag this harness instance builds +// and removes on Stop. Tags are unique per instance, so removing one +// never deletes a shared base image or another harness's image. +func (h *Harness) ImageTag() string { return h.imageTag } + +// KnownHostsPath returns the temp known_hosts file created by Start, or +// "" before Start. The surrounding directory is removed by Stop. +func (h *Harness) KnownHostsPath() string { + h.mu.Lock() + defer h.mu.Unlock() + return h.knownHosts +} + +// Addr returns the dialable host:port of the published SSH listener. +// Before Start it is empty. +func (h *Harness) Addr() string { + h.mu.Lock() + defer h.mu.Unlock() + if h.port == 0 { + return "" + } + return net.JoinHostPort("127.0.0.1", strconv.Itoa(h.port)) +} + +// Start builds the fixture image, starts the container, waits for real +// SSH readiness, captures the server host key into a temp known_hosts +// file, and records the published port. Every error path cleans up the +// container, network, image tag, and temp dir created so far; on +// success the caller owns teardown via t.Cleanup(h.Stop) or a defer. +func (h *Harness) Start(ctx context.Context) error { + if !Enabled() { + return errors.New("harness integration is disabled; set RSMON_TEST_DOCKER=1 to run Docker/OpenSSH tests") + } + image := h.Fixture.ImageRef() + if image == "" { + return fmt.Errorf("fixture %q has no base image", h.Fixture.Name) + } + + // Any error after the first resource is created must release what + // was already allocated. Stop is idempotent and tolerates resources + // that were never created. + success := false + defer func() { + if !success { + _ = h.Stop() + } + }() + + if _, err := dockerCmd(ctx, "build", "-q", "-t", h.imageTag, "-f", h.Fixture.DockerfilePath(), h.Fixture.BuildContext()); err != nil { + return fmt.Errorf("build %s fixture image: %w", h.Fixture.Name, err) + } + if _, err := dockerCmd(ctx, "network", "create", h.network); err != nil { + return fmt.Errorf("create test network: %w", err) + } + // docker run -d prints the container id directly, so no lookup is + // needed; the container name is the stable handle for later docker + // calls and the id is captured for diagnostics and assertions. + out, err := dockerCmd( + ctx, "run", "-d", + "--name", h.container, + "--network", h.network, + "-p", "127.0.0.1::22", + h.imageTag, + ) + if err != nil { + return fmt.Errorf("start %s fixture container: %w", h.Fixture.Name, err) + } + h.containerID = strings.TrimSpace(out) + + port, err := h.publishedPort(ctx) + if err != nil { + return err + } + h.port = port + + key, err := h.waitForSSH(ctx, h.Addr()) + if err != nil { + return fmt.Errorf("wait for ssh readiness on %s fixture: %w", h.Fixture.Name, err) + } + // Register the temp dir before writing so a write failure still + // leaves it known to the cleanup defer. + dir, err := os.MkdirTemp("", "rsmon-worker-harness-") + if err != nil { + return err + } + h.knownHosts = filepath.Join(dir, "known_hosts") + hostsFile, err := writeKnownHosts(dir, h.Addr(), key) + if err != nil { + return err + } + h.keyPath = testKeyPath() + h.knownHosts = hostsFile + + h.mu.Lock() + h.started = true + h.mu.Unlock() + success = true + return nil +} + +// Stop releases every resource the harness created: the container, its +// dedicated network, the per-instance fixture image tag (never a shared +// base image), and the temp known_hosts directory. It is idempotent and +// tolerates already-removed resources so teardown never fails the test +// twice. +func (h *Harness) Stop() error { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + var errs []error + if h.container != "" { + if _, err := dockerCmd(ctx, "rm", "-f", h.container); err != nil && !isNotExist(err) { + errs = append(errs, err) + } + } + if h.network != "" { + if _, err := dockerCmd(ctx, "network", "rm", h.network); err != nil && !isNotExist(err) { + errs = append(errs, err) + } + } + if h.imageTag != "" { + if _, err := dockerCmd(ctx, "image", "rm", h.imageTag); err != nil && !isNotExist(err) && !isInUse(err) { + errs = append(errs, err) + } + } + if h.knownHosts != "" { + if err := os.RemoveAll(filepath.Dir(h.knownHosts)); err != nil { + errs = append(errs, fmt.Errorf("remove harness temp dir: %w", err)) + } + } + h.mu.Lock() + h.started = false + h.containerID = "" + h.port = 0 + h.keyPath = "" + h.knownHosts = "" + h.mu.Unlock() + return errors.Join(errs...) +} + +// Dial connects using the same SSH library (golang.org/x/crypto/ssh) and +// the same known_hosts host-key verification the installer's deploy path +// relies on. The harness owns this code rather than calling into the +// installer package so tests stay independent; only the library and the +// verification semantics are shared. +func (h *Harness) Dial() (*ssh.Client, error) { + h.mu.Lock() + if !h.started || h.knownHosts == "" || h.keyPath == "" { + h.mu.Unlock() + return nil, errors.New("harness is not started") + } + addr := net.JoinHostPort("127.0.0.1", strconv.Itoa(h.port)) + knownHostsFile, keyPath, user := h.knownHosts, h.keyPath, h.user + h.mu.Unlock() + + keyBytes, err := os.ReadFile(keyPath) + if err != nil { + return nil, err + } + signer, err := ssh.ParsePrivateKey(keyBytes) + if err != nil { + return nil, err + } + hostKeyCallback, err := knownhosts.New(knownHostsFile) + if err != nil { + return nil, err + } + config := &ssh.ClientConfig{ + User: user, + Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)}, + HostKeyCallback: hostKeyCallback, + Timeout: 15 * time.Second, + } + return ssh.Dial("tcp", addr, config) +} + +// RunCommand executes a command over an existing SSH client and returns +// its stdout. Stderr is folded into the returned error message on +// failure so test failures show what went wrong. +func RunCommand(client *ssh.Client, command string) ([]byte, error) { + session, err := client.NewSession() + if err != nil { + return nil, err + } + defer session.Close() //nolint:errcheck + var stdout, stderr bytes.Buffer + session.Stdout = &stdout + session.Stderr = &stderr + if err := session.Run(command); err != nil { + if msg := strings.TrimSpace(stderr.String()); msg != "" { + return stdout.Bytes(), fmt.Errorf("%w: %s", err, msg) + } + return stdout.Bytes(), err + } + return stdout.Bytes(), nil +} + +// DockerCmd runs the Docker CLI with the given arguments, returning +// stdout. Exposed so integration tests can assert teardown state. +func (h *Harness) DockerCmd(ctx context.Context, args ...string) (string, error) { + return dockerCmd(ctx, args...) +} + +// Enabled reports whether Docker-backed integration tests may run. The +// opt-in env var keeps ordinary `go test` and CI unit runs from pulling +// or starting any container. +func Enabled() bool { + switch strings.ToLower(strings.TrimSpace(os.Getenv("RSMON_TEST_DOCKER"))) { + case "1", "true", "yes", "on": + return true + } + return false +} + +// SkipUnlessEnabled skips the test with a helpful message unless the +// Docker integration opt-in is set. +func SkipUnlessEnabled(t interface { + Helper() + Skipf(format string, args ...interface{}) +}, +) { + t.Helper() + if !Enabled() { + t.Skipf("Docker/OpenSSH harness tests are opt-in; set RSMON_TEST_DOCKER=1 to run them") + } +} + +// dockerBin is the Docker CLI binary. Overridable in tests via +// SetDockerBin so unit tests can stub the harness's docker calls. +var dockerBin = "docker" + +// SetDockerBin overrides the Docker CLI binary used by the harness. +// Pass an empty value to restore the default ("docker"). +func SetDockerBin(name string) { + if name == "" { + dockerBin = "docker" + return + } + dockerBin = name +} + +func dockerCmd(ctx context.Context, args ...string) (string, error) { + cmd := exec.CommandContext(ctx, dockerBin, args...) + out, err := cmd.Output() + if err != nil { + if ee, ok := err.(*exec.ExitError); ok && len(ee.Stderr) > 0 { + return "", fmt.Errorf("docker %s: %s", strings.Join(args, " "), strings.TrimSpace(string(ee.Stderr))) + } + return "", fmt.Errorf("docker %s: %w", strings.Join(args, " "), err) + } + return string(out), nil +} + +// isNotExist reports whether the docker error is a missing resource +// (container/network/image already removed or never created). Docker +// reports missing networks and images as "... not found" and missing +// containers as "No such container: ...". +func isNotExist(err error) bool { + if err == nil { + return false + } + lower := strings.ToLower(err.Error()) + for _, marker := range []string{"no such container", "no such network", "no such image", "is not running", "not found"} { + if strings.Contains(lower, marker) { + return true + } + } + return false +} + +// isInUse reports whether a docker image removal failed because another +// container or tag still references the image. Teardown must not treat +// that as a leak error: the per-instance tags make this unlikely, but +// tolerating it keeps Stop deterministic under concurrent harnesses. +func isInUse(err error) bool { + if err == nil { + return false + } + lower := strings.ToLower(err.Error()) + for _, marker := range []string{"image is being used", "image is in use", "image is referenced"} { + if strings.Contains(lower, marker) { + return true + } + } + return false +} + +// publishedPort queries the host port docker published for the +// container's port 22. +func (h *Harness) publishedPort(ctx context.Context) (int, error) { + out, err := dockerCmd(ctx, "port", h.container, "22/tcp") + if err != nil { + return 0, err + } + return parsePublishedPort(out) +} + +// parsePublishedPort extracts the host port from `docker port` output +// like "127.0.0.1:49153", "0.0.0.0:49153", or "::1:49153". +func parsePublishedPort(out string) (int, error) { + for _, line := range strings.Split(out, "\n") { + line = strings.TrimSpace(line) + if line == "" { + continue + } + sep := strings.LastIndex(line, ":") + if sep < 0 { + continue + } + port, err := strconv.Atoi(strings.TrimSpace(line[sep+1:])) + if err == nil && port > 0 && port <= 65535 { + return port, nil + } + } + return 0, fmt.Errorf("could not parse published port from %q", out) +} + +// waitForSSH waits for the container's SSH listener to accept a real +// handshake and returns the server host key. TCP readiness alone is not +// enough; the ssh.Dial must succeed and the exec channel must answer. +func (h *Harness) waitForSSH(ctx context.Context, addr string) (ssh.PublicKey, error) { + if err := waitForPort(ctx, addr, 60*time.Second); err != nil { + return nil, err + } + deadline := time.Now().Add(60 * time.Second) + var lastErr error + for { + if ctx.Err() != nil { + return nil, fmt.Errorf("ssh readiness: %w (last: %v)", ctx.Err(), lastErr) + } + if time.Now().After(deadline) { + return nil, fmt.Errorf("ssh readiness timed out (last: %v)", lastErr) + } + key, err := h.handshake(addr) + if err == nil { + return key, nil + } + lastErr = err + time.Sleep(500 * time.Millisecond) + } +} + +// handshake performs one full SSH exchange and captures the server host +// key via the callback so the harness can write a known_hosts entry. +// A fresh known_hosts file trusts the first key it sees (trust-on-first- +// use); the host-key-mismatch integration test proves that a known_hosts +// entry carrying a *different* key is rejected before any command runs. +func (h *Harness) handshake(addr string) (ssh.PublicKey, error) { + keyBytes, err := os.ReadFile(testKeyPath()) + if err != nil { + return nil, err + } + signer, err := ssh.ParsePrivateKey(keyBytes) + if err != nil { + return nil, err + } + var got ssh.PublicKey + config := &ssh.ClientConfig{ + User: h.user, + Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)}, + HostKeyCallback: func(_ string, _ net.Addr, key ssh.PublicKey) error { + got = key + return nil + }, + Timeout: 5 * time.Second, + } + client, err := ssh.Dial("tcp", addr, config) + if err != nil { + return nil, err + } + defer client.Close() //nolint:errcheck + session, err := client.NewSession() + if err != nil { + return nil, err + } + defer session.Close() //nolint:errcheck + if _, err := session.Output("true"); err != nil { + return nil, err + } + if got == nil { + return nil, errors.New("no host key returned by handshake") + } + return got, nil +} + +// waitForPort polls a TCP endpoint until it accepts a connection. It is +// a plain network probe; SSH readiness is separately verified. +func waitForPort(ctx context.Context, addr string, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + for { + if ctx.Err() != nil { + return ctx.Err() + } + if time.Now().After(deadline) { + return fmt.Errorf("port %s not open within %s", addr, timeout) + } + conn, err := net.DialTimeout("tcp", addr, 2*time.Second) + if err == nil { + _ = conn.Close() + return nil + } + time.Sleep(500 * time.Millisecond) + } +} + +// writeKnownHosts writes a known_hosts entry for the harness address +// inside an existing temp directory, using the captured server host key. +func writeKnownHosts(dir, addr string, key ssh.PublicKey) (string, error) { + path := filepath.Join(dir, "known_hosts") + line := knownhosts.Line([]string{addr}, key) + if err := os.WriteFile(path, []byte(line+"\n"), 0o600); err != nil { + return "", err + } + return path, nil +} + +// testKeyPath returns the shared test private key bundled with the +// package. The fixtures bake the matching public key into authorized_keys. +// +// This keypair is strictly test-only: it grants root SSH access only to +// the disposable fixture containers that bake its public key. It must +// never be used for real hosts, copied into production images, or +// treated as a credential anywhere outside the harness. +func testKeyPath() string { + return filepath.Join("testdata", "keys", "rsmon_test_ed25519") +} + +// validateName rejects harness names that could inject shell or docker +// metacharacters into container/network names. +func validateName(name string) error { + if name == "" || len(name) > 64 { + return errors.New("harness name must be 1-64 characters") + } + for _, r := range name { + lower := r >= 'a' && r <= 'z' + digit := r >= '0' && r <= '9' + if !lower && !digit && r != '-' { + return fmt.Errorf("harness name %q must be lowercase alphanumeric and hyphens", name) + } + } + return nil +} + +func sanitize(name string) string { + var b strings.Builder + for _, r := range name { + if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' { + b.WriteRune(r) + } else { + b.WriteByte('-') + } + } + return b.String() +} diff --git a/internal/installer/harness/harness_test.go b/internal/installer/harness/harness_test.go new file mode 100644 index 0000000..8108022 --- /dev/null +++ b/internal/installer/harness/harness_test.go @@ -0,0 +1,402 @@ +package harness + +import ( + "context" + "net" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "golang.org/x/crypto/ssh" +) + +func TestEnabled(t *testing.T) { + // os.Unsetenv would leak into later tests in the same process; + // t.Setenv restores the original value after this test. + t.Setenv("RSMON_TEST_DOCKER", "") + if Enabled() { + t.Fatal("Enabled() true without RSMON_TEST_DOCKER") + } + for _, v := range []string{"1", "true", "TRUE", "yes", "on"} { + t.Setenv("RSMON_TEST_DOCKER", v) + if !Enabled() { + t.Fatalf("Enabled() false for RSMON_TEST_DOCKER=%q", v) + } + } + for _, v := range []string{"0", "false", "no", "off", " "} { + t.Setenv("RSMON_TEST_DOCKER", v) + if Enabled() { + t.Fatalf("Enabled() true for RSMON_TEST_DOCKER=%q", v) + } + } +} + +func TestFixtureDefaults(t *testing.T) { + fixtures := Fixtures() + if len(fixtures) != 3 { + t.Fatalf("Fixtures() = %d, want 3", len(fixtures)) + } + byName := map[string]Fixture{} + for _, f := range fixtures { + byName[f.Name] = f + } + if f := byName["alpine"]; f.ImageRef() != "reg.rsxx.ru/library/alpine:3" || f.Distro != "alpine" { + t.Fatalf("alpine fixture wrong: %+v", f) + } + if f := byName["ubuntu"]; f.ImageRef() != "ubuntu:24.04" || f.Distro != "ubuntu" { + t.Fatalf("ubuntu fixture wrong: %+v", f) + } + if f := byName["arch"]; f.ImageRef() != "archlinux:latest" || f.Distro != "arch" { + t.Fatalf("arch fixture wrong: %+v", f) + } + if f := byName["alpine"]; f.UserOrDefault() != "root" { + t.Fatalf("default user = %q, want root", f.UserOrDefault()) + } +} + +func TestFixtureImageEnvOverride(t *testing.T) { + f := Fixture{Name: "ubuntu"} + t.Setenv("RSMON_TEST_IMAGE_UBUNTU", "reg.rsxx.ru/library/ubuntu:24.04") + if got := f.ImageRef(); got != "reg.rsxx.ru/library/ubuntu:24.04" { + t.Fatalf("env override not applied: %q", got) + } + t.Setenv("RSMON_TEST_IMAGE_UBUNTU", "") + if got := f.ImageRef(); got != "ubuntu:24.04" { + t.Fatalf("default image changed after env cleared: %q", got) + } + + override := Fixture{Name: "arch", Image: "archlinux:2026.01.01"} + if got := override.ImageRef(); got != "archlinux:2026.01.01" { + t.Fatalf("fixture image override not applied: %q", got) + } +} + +func TestFixturesHaveDockerfiles(t *testing.T) { + for _, f := range Fixtures() { + path := f.DockerfilePath() + if _, err := os.Stat(path); err != nil { + t.Fatalf("fixture %s missing Dockerfile at %s: %v", f.Name, path, err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), "FROM ") { + t.Fatalf("fixture %s Dockerfile has no FROM", f.Name) + } + } +} + +func TestNewValidatesName(t *testing.T) { + for _, bad := range []string{"", "with space", "UPPER", "semi;colon", "back`tick", strings.Repeat("a", 65)} { + if _, err := New(bad, Fixtures()[0]); err == nil { + t.Fatalf("New(%q) succeeded", bad) + } + } + h, err := New("fixture-alpine", Fixtures()[0]) + if err != nil { + t.Fatal(err) + } + if h.ContainerName() == "" || h.NetworkName() == "" { + t.Fatalf("empty container/network names: %+v", h) + } + if h.Addr() != "" { + t.Fatalf("Addr() = %q before Start, want empty", h.Addr()) + } +} + +func TestStartDisabled(t *testing.T) { + t.Setenv("RSMON_TEST_DOCKER", "") + h, err := New("unit-start", Fixtures()[0]) + if err != nil { + t.Fatal(err) + } + if err := h.Start(context.Background()); err == nil { + t.Fatal("Start succeeded with integration disabled") + } else if !strings.Contains(err.Error(), "RSMON_TEST_DOCKER") { + t.Fatalf("disabled Start error = %v, want opt-in hint", err) + } +} + +func TestDialBeforeStart(t *testing.T) { + h, err := New("unit-dial", Fixtures()[0]) + if err != nil { + t.Fatal(err) + } + if _, err := h.Dial(); err == nil { + t.Fatal("Dial succeeded before Start") + } +} + +func TestParsePublishedPort(t *testing.T) { + for _, tc := range []struct { + in string + want int + }{ + {in: "127.0.0.1:49153\n", want: 49153}, + {in: "0.0.0.0:2222", want: 2222}, + {in: "127.0.0.1:0\n", want: 0}, + {in: "::1:32768\n", want: 32768}, + {in: "127.0.0.1:notaport\n", want: 0}, + {in: "", want: 0}, + } { + got, err := parsePublishedPort(tc.in) + if tc.want == 0 { + if err == nil { + t.Fatalf("parsePublishedPort(%q) succeeded with %d", tc.in, got) + } + continue + } + if err != nil || got != tc.want { + t.Fatalf("parsePublishedPort(%q) = %d, %v; want %d", tc.in, got, err, tc.want) + } + } +} + +// TestWaitForPort exercises the readiness probe against a real local +// listener so the polling loop is covered without Docker. +func TestWaitForPort(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer ln.Close() //nolint:errcheck + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := waitForPort(ctx, ln.Addr().String(), 2*time.Second); err != nil { + t.Fatalf("waitForPort on open listener: %v", err) + } + + // A port that never opens must time out. + closed, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + addr := closed.Addr().String() + closed.Close() //nolint:errcheck + + short, shortCancel := context.WithTimeout(context.Background(), 3*time.Second) + defer shortCancel() + if err := waitForPort(short, addr, 1500*time.Millisecond); err == nil { + t.Fatal("waitForPort on closed port succeeded") + } +} + +func TestWriteKnownHostsRoundTrip(t *testing.T) { + // Parsing a known_hosts entry requires a real key; use the bundled + // public key so the format is exercised. + raw, err := os.ReadFile(filepath.Join("testdata", "keys", "rsmon_test_ed25519.pub")) + if err != nil { + t.Fatal(err) + } + key, _, _, _, err := ssh.ParseAuthorizedKey(raw) + if err != nil { + t.Fatal(err) + } + path, err := writeKnownHosts(t.TempDir(), "127.0.0.1:49153", key) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(data), "[127.0.0.1]:49153") { + t.Fatalf("known_hosts entry %q missing bracketed address", data) + } +} + +func TestValidateName(t *testing.T) { + for _, ok := range []string{"alpine", "fixture-1", "a", strings.Repeat("x", 64)} { + if err := validateName(ok); err != nil { + t.Fatalf("validateName(%q): %v", ok, err) + } + } + for _, bad := range []string{"", "A", "a b", "a/b", strings.Repeat("x", 65)} { + if err := validateName(bad); err == nil { + t.Fatalf("validateName(%q) succeeded", bad) + } + } +} + +func TestSetDockerBin(t *testing.T) { + SetDockerBin("docker") + SetDockerBin("") + if dockerBin != "docker" { + t.Fatalf("dockerBin = %q after reset", dockerBin) + } + SetDockerBin("/stub/docker") + if dockerBin != "/stub/docker" { + t.Fatalf("dockerBin = %q after set", dockerBin) + } + SetDockerBin("") +} + +// writeStubDocker installs a fake docker binary that records its argv to +// logPath and returns the recorded path. The stub succeeds for build, +// network, run, and teardown calls; `port` fails so Start fails after +// the container and network exist. Setting STUB_DOCKER_FAIL_BUILD=1 makes +// the build step fail instead. Real Docker is never touched. +func writeStubDocker(t *testing.T, logPath string) string { + t.Helper() + dir := t.TempDir() + stub := filepath.Join(dir, "docker") + script := `#!/bin/sh +echo "$*" >> "$STUB_DOCKER_LOG" +case "$1" in + build) + if [ "${STUB_DOCKER_FAIL_BUILD:-0}" = "1" ]; then + echo "stub build failure" >&2 + exit 1 + fi + echo "sha256:stub-image-id" + ;; + network) echo "stub-network-id" ;; + run) echo "stub-container-id" ;; + port) echo "stub port failure" >&2; exit 1 ;; + rm|-r|image|ps) exit 0 ;; + *) exit 0 ;; +esac +` + if err := os.WriteFile(stub, []byte(script), 0o700); err != nil { + t.Fatal(err) + } + t.Setenv("STUB_DOCKER_LOG", logPath) + SetDockerBin(stub) + t.Cleanup(func() { SetDockerBin("") }) + return stub +} + +func readDockerCalls(t *testing.T, logPath string) string { + t.Helper() + calls, err := os.ReadFile(logPath) + if err != nil { + t.Fatal(err) + } + return string(calls) +} + +// TestStartFailureCleansUpResources drives Start to failure after the +// container and network were created (the stub `docker port` fails) and +// asserts the failure path removed every resource: container, network, +// fixture image, and no temp known_hosts dir leaked. +func TestStartFailureCleansUpResources(t *testing.T) { + t.Setenv("RSMON_TEST_DOCKER", "1") + logPath := filepath.Join(t.TempDir(), "docker-calls.log") + writeStubDocker(t, logPath) + + h, err := New("fail-cleanup", Fixtures()[0]) + if err != nil { + t.Fatal(err) + } + if err := h.Start(context.Background()); err == nil { + t.Fatal("Start succeeded despite stub docker port failure") + } + calls := readDockerCalls(t, logPath) + for _, want := range []string{"build", "network create", "run -d", "rm -f", "network rm", "image rm"} { + if !strings.Contains(calls, want) { + t.Fatalf("failed-start cleanup missing docker call %q; calls:\n%s", want, calls) + } + } + if h.KnownHostsPath() != "" { + t.Fatalf("known_hosts path set after failed start: %q", h.KnownHostsPath()) + } +} + +// TestStartBuildFailureIsSafe drives Start to fail at the very first +// step (build) and asserts teardown stays idempotent and harmless: no +// container or network was ever created, and the failure path leaves +// nothing behind. +func TestStartBuildFailureIsSafe(t *testing.T) { + t.Setenv("RSMON_TEST_DOCKER", "1") + t.Setenv("STUB_DOCKER_FAIL_BUILD", "1") + logPath := filepath.Join(t.TempDir(), "docker-calls.log") + writeStubDocker(t, logPath) + + h, err := New("fail-build", Fixtures()[0]) + if err != nil { + t.Fatal(err) + } + if err := h.Start(context.Background()); err == nil { + t.Fatal("Start succeeded despite stub docker build failure") + } + if h.ContainerID() != "" { + t.Fatalf("container id set after build failure: %q", h.ContainerID()) + } + if h.KnownHostsPath() != "" { + t.Fatalf("known_hosts path set after build failure: %q", h.KnownHostsPath()) + } + if err := h.Stop(); err != nil { + t.Fatalf("Stop after failed build: %v", err) + } +} + +// TestStopRemovesKnownHostsDir verifies Stop deletes the temp known_hosts +// directory even when no container was ever started (the failed-start +// path registers the dir before the final write). +func TestStopRemovesKnownHostsDir(t *testing.T) { + logPath := filepath.Join(t.TempDir(), "docker-calls.log") + writeStubDocker(t, logPath) + + h, err := New("stop-dir", Fixtures()[0]) + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + h.knownHosts = filepath.Join(dir, "known_hosts") + if err := os.WriteFile(h.knownHosts, []byte("placeholder"), 0o600); err != nil { + t.Fatal(err) + } + if err := h.Stop(); err != nil { + t.Fatalf("Stop: %v", err) + } + if _, err := os.Stat(dir); !os.IsNotExist(err) { + t.Fatalf("known_hosts temp dir still exists after Stop: %v", err) + } + if h.KnownHostsPath() != "" { + t.Fatalf("KnownHostsPath = %q after Stop, want empty", h.KnownHostsPath()) + } +} + +// TestStopIdempotent verifies repeated Stop calls do not error. +func TestStopIdempotent(t *testing.T) { + logPath := filepath.Join(t.TempDir(), "docker-calls.log") + writeStubDocker(t, logPath) + + h, err := New("stop-again", Fixtures()[0]) + if err != nil { + t.Fatal(err) + } + h.container = "rsmon-worker-test-none" + h.network = "rsmon-worker-test-none" + h.imageTag = "rsmon-worker-test/none:local" + h.knownHosts = filepath.Join(t.TempDir(), "known_hosts") + if err := h.Stop(); err != nil { + t.Fatalf("first Stop: %v", err) + } + if err := h.Stop(); err != nil { + t.Fatalf("second Stop: %v", err) + } +} + +// TestImageTagUnique verifies every harness instance gets its own image +// tag so teardown can never delete another instance's image. +func TestImageTagUnique(t *testing.T) { + a, err := New("img-a", Fixtures()[0]) + if err != nil { + t.Fatal(err) + } + b, err := New("img-b", Fixtures()[0]) + if err != nil { + t.Fatal(err) + } + if a.ImageTag() == b.ImageTag() { + t.Fatalf("image tags collide: %q", a.ImageTag()) + } + if !strings.HasPrefix(a.ImageTag(), "rsmon-worker-test/alpine-") { + t.Fatalf("unexpected image tag: %q", a.ImageTag()) + } +} diff --git a/internal/installer/harness/integration_test.go b/internal/installer/harness/integration_test.go new file mode 100644 index 0000000..06c7ef1 --- /dev/null +++ b/internal/installer/harness/integration_test.go @@ -0,0 +1,293 @@ +package harness + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/knownhosts" + + "rocketgit.ru/rsmon/worker/internal/sshinstall" +) + +// TestHarnessFixtures is the work-package-1 acceptance test: each distro +// fixture starts a real OpenSSH container, the harness waits for real +// network readiness, and the installer's Go SSH client connects, runs +// commands, and tears the environment down. +// +// Opt-in: set RSMON_TEST_DOCKER=1 (see make test-ssh). +func TestHarnessFixtures(t *testing.T) { + SkipUnlessEnabled(t) + + for _, f := range Fixtures() { + f := f + t.Run(f.Name, func(t *testing.T) { + h, err := New("fixture-"+f.Name, f) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 20*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) + } + }) + + client, err := h.Dial() + if err != nil { + t.Fatalf("dial %s fixture: %v", f.Name, err) + } + defer client.Close() //nolint:errcheck + + // 1. SSH command execution is real: round-trip a nonce. + nonce := fmt.Sprintf("RSMON_SSH_OK_%d", time.Now().UnixNano()) + out, err := RunCommand(client, "printf '%s' "+shellQuote(nonce)) + if err != nil { + t.Fatalf("ssh round trip: %v", err) + } + if strings.TrimSpace(string(out)) != nonce { + t.Fatalf("ssh round trip = %q, want %q", out, nonce) + } + + // 2. The clean target has no Go toolchain and no worker source. + out, err = RunCommand(client, "command -v go || true; test ! -e /usr/local/go && echo NO_GO; test ! -e /opt/rsmon-worker-src && echo NO_SOURCE") + if err != nil { + t.Fatalf("clean-state probe: %v", err) + } + clean := string(out) + if strings.Contains(clean, "/go") && !strings.Contains(clean, "NO_GO") { + t.Fatalf("fixture unexpectedly has Go installed: %q", clean) + } + if !strings.Contains(clean, "NO_SOURCE") { + t.Fatalf("fixture unexpectedly has worker source: %q", clean) + } + + // 3. Distro detection over the real session matches the fixture. + out, err = RunCommand(client, "cat /etc/os-release") + if err != nil { + t.Fatalf("read os-release: %v", err) + } + d := sshinstall.Detect(string(out), makeProber(client)) + if d.Distro != f.Distro { + t.Fatalf("detected distro = %q, want %q (%s)", d.Distro, f.Distro, d.Summarize()) + } + if d.PackageManager != f.Pkg { + t.Fatalf("detected package manager = %q, want %q", d.PackageManager, f.Pkg) + } + if d.InitSystem != f.Init { + t.Fatalf("detected init = %q, want %q", d.InitSystem, f.Init) + } + t.Logf("%s: %s", f.Name, d.Summarize()) + + // 4. A full source plan resolves for the detected host, + // including the pinned Go toolchain for its real arch. + out, err = RunCommand(client, "uname -m") + if err != nil { + t.Fatalf("uname -m: %v", err) + } + goarch, err := sshinstall.GoArch(strings.TrimSpace(string(out))) + if err != nil { + t.Fatalf("GoArch(%q): %v", out, err) + } + plan, err := sshinstall.PlanSource(d, sshinstall.SourceOptions{UnameM: strings.TrimSpace(string(out))}) + if err != nil { + t.Fatalf("PlanSource: %v", err) + } + if plan.Toolchain.Arch != "linux-"+goarch { + t.Fatalf("plan toolchain %q does not match detected arch %q", plan.Toolchain.Arch, goarch) + } + if len(plan.Packages) == 0 || plan.Repo == "" { + t.Fatalf("incomplete plan: %+v", plan) + } + steps := plan.Steps() + if len(steps) != 6 { + t.Fatalf("plan steps = %d, want 6", len(steps)) + } + }) + } +} + +// TestHarnessHostKeyMismatch verifies the security gate: a dial against +// a known_hosts entry carrying a different host key must fail before any +// command can run. +func TestHarnessHostKeyMismatch(t *testing.T) { + SkipUnlessEnabled(t) + + f := Fixtures()[0] // alpine is the smallest fixture + h, err := New("hostkey-"+f.Name, f) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute) + defer cancel() + if err := h.Start(ctx); err != nil { + t.Fatalf("start fixture: %v", err) + } + t.Cleanup(func() { + if err := h.Stop(); err != nil { + t.Errorf("stop fixture: %v", err) + } + }) + + // Build a known_hosts entry with a different (freshly generated) key. + _, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + signer, err := ssh.NewSignerFromKey(priv) + if err != nil { + t.Fatal(err) + } + wrongFile := filepath.Join(t.TempDir(), "known_hosts") + line := knownhosts.Line([]string{h.Addr()}, signer.PublicKey()) + if err := os.WriteFile(wrongFile, []byte(line+"\n"), 0o600); err != nil { + t.Fatal(err) + } + + keyBytes, err := os.ReadFile(testKeyPath()) + if err != nil { + t.Fatal(err) + } + keySigner, err := ssh.ParsePrivateKey(keyBytes) + if err != nil { + t.Fatal(err) + } + callback, err := knownhosts.New(wrongFile) + if err != nil { + t.Fatal(err) + } + config := &ssh.ClientConfig{ + User: f.UserOrDefault(), + Auth: []ssh.AuthMethod{ssh.PublicKeys(keySigner)}, + HostKeyCallback: callback, + Timeout: 15 * time.Second, + } + client, err := ssh.Dial("tcp", h.Addr(), config) + if err == nil { + client.Close() //nolint:errcheck + t.Fatal("dial with a mismatched host key succeeded") + } + if !strings.Contains(err.Error(), "knownhosts") && !strings.Contains(err.Error(), "key") { + t.Fatalf("host-key mismatch error = %v, want a key/host verification failure", err) + } +} + +// TestHarnessTeardown verifies Stop reliably removes the container, the +// dedicated network, the per-instance fixture image tag (never a shared +// base image), and the temp known_hosts directory. +func TestHarnessTeardown(t *testing.T) { + SkipUnlessEnabled(t) + + f := Fixtures()[0] + h, err := New("teardown-"+f.Name, f) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute) + defer cancel() + if err := h.Start(ctx); err != nil { + t.Fatalf("start fixture: %v", err) + } + if h.ContainerID() == "" { + t.Fatal("container id empty after start") + } + if h.KnownHostsPath() == "" { + t.Fatal("known_hosts not created after start") + } + knownHostsDir := filepath.Dir(h.KnownHostsPath()) + if err := h.Stop(); err != nil { + t.Fatalf("stop: %v", err) + } + if _, err := h.DockerCmd(ctx, "inspect", h.ContainerName()); err == nil { + t.Fatal("container still present after Stop") + } + if _, err := h.DockerCmd(ctx, "network", "inspect", h.NetworkName()); err == nil { + t.Fatal("network still present after Stop") + } + if _, err := h.DockerCmd(ctx, "image", "inspect", h.ImageTag()); err == nil { + t.Fatalf("fixture image tag %q still present after Stop", h.ImageTag()) + } + if _, err := os.Stat(knownHostsDir); !os.IsNotExist(err) { + t.Fatalf("known_hosts temp dir %q still present after Stop: %v", knownHostsDir, err) + } + if h.KnownHostsPath() != "" { + t.Fatalf("KnownHostsPath = %q after Stop, want empty", h.KnownHostsPath()) + } + // Stop is idempotent. + if err := h.Stop(); err != nil { + t.Fatalf("second stop: %v", err) + } +} + +// TestHarnessHostKeyStableAcrossDial ensures the host key captured at +// readiness is the one verified on every later dial, so a successful +// Dial is proof of verified, real SSH transport. +func TestHarnessHostKeyStable(t *testing.T) { + SkipUnlessEnabled(t) + + f := Fixtures()[0] + h, err := New("key-"+f.Name, f) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute) + defer cancel() + if err := h.Start(ctx); err != nil { + t.Fatalf("start fixture: %v", err) + } + t.Cleanup(func() { + if err := h.Stop(); err != nil { + t.Errorf("stop fixture: %v", err) + } + }) + client, err := h.Dial() + if err != nil { + t.Fatalf("dial: %v", err) + } + client.Close() //nolint:errcheck + client, err = h.Dial() + if err != nil { + t.Fatalf("second dial: %v", err) + } + defer client.Close() //nolint:errcheck + if out, err := RunCommand(client, "echo VERIFIED"); err != nil || strings.TrimSpace(string(out)) != "VERIFIED" { + t.Fatalf("verified session command = %q, %v", out, err) + } +} + +// makeProber builds an sshinstall.FileProber over a live SSH session. +func makeProber(client *ssh.Client) sshinstall.FileProber { + return func(paths ...string) map[string]bool { + // The trailing `; true` keeps the shell exit status 0: the + // last `[ -e "$p" ]` in the loop would otherwise set exit 1 + // when the final path is absent (as on Arch), which is not an + // error for a probe. + expr := "for p in " + strings.Join(paths, " ") + "; do [ -e \"$p\" ] && printf '%s\\n' \"$p\"; done; true" + out, err := RunCommand(client, 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 + } +} + +func shellQuote(value string) string { + return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'" +} diff --git a/internal/installer/harness/testdata/fixtures/alpine/Dockerfile b/internal/installer/harness/testdata/fixtures/alpine/Dockerfile new file mode 100644 index 0000000..cdeffad --- /dev/null +++ b/internal/installer/harness/testdata/fixtures/alpine/Dockerfile @@ -0,0 +1,21 @@ +# Alpine OpenSSH fixture for the source-install harness. +# +# Base image is the reg.rsxx.ru mirror (alpine:3). openrc is installed +# explicitly so init-system detection has a stable marker; openssh is the +# minimal sshd. Password auth is disabled; the fixture authenticates with +# the shared test key. +FROM reg.rsxx.ru/library/alpine:3 + +RUN apk add --no-cache openssh openrc \ + && mkdir -p /run/sshd /root/.ssh \ + && chmod 700 /root/.ssh \ + && ssh-keygen -A \ + && sed -i 's/^#\?PermitRootLogin.*/PermitRootLogin prohibit-password/' /etc/ssh/sshd_config \ + && sed -i 's/^#\?PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config \ + && sed -i 's/^#\?PubkeyAuthentication.*/PubkeyAuthentication yes/' /etc/ssh/sshd_config + +COPY keys/rsmon_test_ed25519.pub /root/.ssh/authorized_keys +RUN chmod 600 /root/.ssh/authorized_keys + +EXPOSE 22 +CMD ["/usr/sbin/sshd", "-D", "-e"] diff --git a/internal/installer/harness/testdata/fixtures/arch/Dockerfile b/internal/installer/harness/testdata/fixtures/arch/Dockerfile new file mode 100644 index 0000000..53e1b23 --- /dev/null +++ b/internal/installer/harness/testdata/fixtures/arch/Dockerfile @@ -0,0 +1,19 @@ +# Arch Linux OpenSSH fixture for the source-install harness. +# +# No reg.rsxx.ru mirror exists for Arch yet, so the default is the +# Docker Hub archlinux:latest image. Override with RSMON_TEST_IMAGE_ARCH. +FROM archlinux:latest + +RUN pacman -Sy --noconfirm --needed openssh \ + && mkdir -p /run/sshd /root/.ssh \ + && chmod 700 /root/.ssh \ + && ssh-keygen -A \ + && sed -i 's/^#\?PermitRootLogin.*/PermitRootLogin prohibit-password/' /etc/ssh/sshd_config \ + && sed -i 's/^#\?PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config \ + && sed -i 's/^#\?PubkeyAuthentication.*/PubkeyAuthentication yes/' /etc/ssh/sshd_config + +COPY keys/rsmon_test_ed25519.pub /root/.ssh/authorized_keys +RUN chmod 600 /root/.ssh/authorized_keys + +EXPOSE 22 +CMD ["/usr/sbin/sshd", "-D", "-e"] diff --git a/internal/installer/harness/testdata/fixtures/ubuntu/Dockerfile b/internal/installer/harness/testdata/fixtures/ubuntu/Dockerfile new file mode 100644 index 0000000..f0d552d --- /dev/null +++ b/internal/installer/harness/testdata/fixtures/ubuntu/Dockerfile @@ -0,0 +1,20 @@ +# Ubuntu OpenSSH fixture for the source-install harness. +# +# No reg.rsxx.ru mirror exists for Ubuntu yet, so the default is the +# Docker Hub ubuntu:24.04 image. Override with RSMON_TEST_IMAGE_UBUNTU. +FROM ubuntu:24.04 + +RUN apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends openssh-server \ + && rm -rf /var/lib/apt/lists/* \ + && mkdir -p /run/sshd /root/.ssh \ + && chmod 700 /root/.ssh \ + && sed -i 's/^#\?PermitRootLogin.*/PermitRootLogin prohibit-password/' /etc/ssh/sshd_config \ + && sed -i 's/^#\?PasswordAuthentication.*/PasswordAuthentication no/' /etc/ssh/sshd_config \ + && sed -i 's/^#\?PubkeyAuthentication.*/PubkeyAuthentication yes/' /etc/ssh/sshd_config + +COPY keys/rsmon_test_ed25519.pub /root/.ssh/authorized_keys +RUN chmod 600 /root/.ssh/authorized_keys + +EXPOSE 22 +CMD ["/usr/sbin/sshd", "-D", "-e"] diff --git a/internal/installer/harness/testdata/keys/rsmon_test_ed25519 b/internal/installer/harness/testdata/keys/rsmon_test_ed25519 new file mode 100644 index 0000000..205c261 --- /dev/null +++ b/internal/installer/harness/testdata/keys/rsmon_test_ed25519 @@ -0,0 +1,8 @@ +-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACCz7h3HFwJrC+DIrE3W+9yI+hRAcCVesyEQmCicmPrqAwAAAKgIO12YCDtd +mAAAAAtzc2gtZWQyNTUxOQAAACCz7h3HFwJrC+DIrE3W+9yI+hRAcCVesyEQmCicmPrqAw +AAAEDqbSMcuhF56miNJOZKUOuA/9I6yVga06nirb7pns41lLPuHccXAmsL4MisTdb73Ij6 +FEBwJV6zIRCYKJyY+uoDAAAAIHJzbW9uLXdvcmtlciBzb3VyY2UtaW5zdGFsbCB0ZXN0AQ +IDBAU= +-----END OPENSSH PRIVATE KEY----- diff --git a/internal/installer/harness/testdata/keys/rsmon_test_ed25519.pub b/internal/installer/harness/testdata/keys/rsmon_test_ed25519.pub new file mode 100644 index 0000000..10ead9a --- /dev/null +++ b/internal/installer/harness/testdata/keys/rsmon_test_ed25519.pub @@ -0,0 +1 @@ +ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILPuHccXAmsL4MisTdb73Ij6FEBwJV6zIRCYKJyY+uoD rsmon-worker source-install test diff --git a/internal/sshinstall/detect.go b/internal/sshinstall/detect.go new file mode 100644 index 0000000..946a29a --- /dev/null +++ b/internal/sshinstall/detect.go @@ -0,0 +1,221 @@ +// Package sshinstall contains the pure detection and planning layer for +// installing the worker from source over SSH. It never touches the +// network or a remote host; the executor that turns a plan into remote +// commands is a later work package (docs/source-installation.md). +// +// The Docker/OpenSSH harness that exercises detection against real +// distro containers lives in internal/installer/harness. +package sshinstall + +import ( + "fmt" + "strings" +) + +// Distro is a supported remote Linux distribution family. +type Distro string + +const ( + DistroUnknown Distro = "unknown" + DistroAlpine Distro = "alpine" + DistroDebian Distro = "debian" + DistroUbuntu Distro = "ubuntu" + DistroArch Distro = "arch" + DistroCentOS Distro = "centos" + DistroFedora Distro = "fedora" + DistroRocky Distro = "rocky" + DistroAlma Distro = "alma" + DistroRHEL Distro = "rhel" +) + +// PackageManager is the remote package manager used to install build +// prerequisites. +type PackageManager string + +const ( + PkgUnknown PackageManager = "" + PkgApk PackageManager = "apk" + PkgApt PackageManager = "apt" + PkgPacman PackageManager = "pacman" + PkgDnf PackageManager = "dnf" + PkgYum PackageManager = "yum" +) + +// InitSystem is the remote init system. The source installer must know +// it before it can install a service definition; Alpine's OpenRC and the +// systemd distros take different paths. +type InitSystem string + +const ( + InitUnknown InitSystem = "" + InitSystemd InitSystem = "systemd" + InitOpenRC InitSystem = "openrc" +) + +// Detection is the resolved identity of a remote Linux host. +type Detection struct { + ID string // os-release ID (lowercase, e.g. "alpine") + Name string // os-release NAME (pretty, may be empty) + VersionID string // os-release VERSION_ID (may be empty) + Distro Distro + PackageManager PackageManager + InitSystem InitSystem +} + +// FileProber reports which of the given absolute paths exist on the +// remote host. Detection uses it to distinguish init systems and +// packaging hints without parsing the full command surface. A nil +// prober behaves as if nothing exists. +type FileProber func(paths ...string) map[string]bool + +// Detect resolves the distro, package manager, and init system of a +// remote host from its /etc/os-release contents and a path prober. +// Pure and deterministic: no command execution happens here. +func Detect(osRelease string, probe FileProber) Detection { + d := parseOSRelease(osRelease) + d.PackageManager = packageManagerFor(d.ID) + d.InitSystem = initFor(d.ID, probe) + return d +} + +// parseOSRelease extracts the fields the installer cares about from the +// /etc/os-release text. Unknown keys are ignored. +func parseOSRelease(data string) Detection { + var d Detection + for _, line := range strings.Split(data, "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + key, value, ok := strings.Cut(line, "=") + if !ok { + continue + } + // os-release requires KEY=VALUE with no whitespace around the + // equals sign. A spaced assignment is a malformed line and must + // not be interpreted. + if strings.ContainsAny(key, " \t") { + continue + } + value = unquoteOSValue(value) + switch strings.TrimSpace(key) { + case "ID": + d.ID = strings.ToLower(strings.TrimSpace(value)) + case "NAME": + d.Name = strings.TrimSpace(value) + case "VERSION_ID": + d.VersionID = strings.TrimSpace(value) + } + } + d.Distro = distroForID(d.ID) + return d +} + +// unquoteOSValue strips the surrounding quotes os-release permits and +// unescapes the two escapes the spec defines. Unquoted values pass +// through unchanged. +func unquoteOSValue(value string) string { + value = strings.TrimSpace(value) + if len(value) >= 2 && (value[0] == '"' || value[0] == '\'') && value[len(value)-1] == value[0] { + value = value[1 : len(value)-1] + } + value = strings.ReplaceAll(value, `\"`, `"`) + value = strings.ReplaceAll(value, `\'`, `'`) + value = strings.ReplaceAll(value, `\\`, `\`) + value = strings.ReplaceAll(value, `\$`, `$`) + value = strings.ReplaceAll(value, "`", "") + return value +} + +// distroForID maps an os-release ID to a supported distro family. +func distroForID(id string) Distro { + switch id { + case "alpine": + return DistroAlpine + case "debian": + return DistroDebian + case "ubuntu", "linuxmint", "elementary": + return DistroUbuntu + case "arch", "archarm", "manjaro", "endeavouros": + return DistroArch + case "centos": + return DistroCentOS + case "fedora": + return DistroFedora + case "rocky": + return DistroRocky + case "almalinux": + return DistroAlma + case "rhel", "redhat": + return DistroRHEL + default: + return DistroUnknown + } +} + +// packageManagerFor maps an os-release ID to its package manager. +// CentOS-family versions differ (dnf on 8+, yum on 7); the planner +// defaults to dnf and the CentOS work package will refine it. +func packageManagerFor(id string) PackageManager { + switch distroForID(id) { + case DistroAlpine: + return PkgApk + case DistroDebian, DistroUbuntu: + return PkgApt + case DistroArch: + return PkgPacman + case DistroCentOS, DistroFedora, DistroRocky, DistroAlma, DistroRHEL: + return PkgDnf + default: + return PkgUnknown + } +} + +// initFor infers the init system from well-known filesystem markers. In +// a container the PID 1 command is not a reliable signal (sshd or the +// runtime command runs first), so the installer uses the packaging +// markers systemd and OpenRC leave behind. +func initFor(_ string, probe FileProber) InitSystem { + if probe == nil { + return InitUnknown + } + present := probe( + "/run/systemd/system", + "/usr/lib/systemd/system", + "/etc/systemd/system", + "/sbin/openrc", + "/etc/init.d", + ) + if present["/run/systemd/system"] || present["/usr/lib/systemd/system"] || present["/etc/systemd/system"] { + return InitSystemd + } + if present["/sbin/openrc"] || present["/etc/init.d"] { + return InitOpenRC + } + return InitUnknown +} + +// Summarize returns a single-line, operator-readable description of the +// detection result. +func (d Detection) Summarize() string { + return fmt.Sprintf("%s (id=%s version=%s, pkg=%s, init=%s)", + displayName(d), d.ID, displayValue(d.VersionID), + displayValue(string(d.PackageManager)), displayValue(string(d.InitSystem))) +} + +func displayName(d Detection) string { + if d.Name != "" { + return d.Name + } + if d.ID != "" { + return d.ID + } + return string(d.Distro) +} + +func displayValue(v string) string { + if v == "" { + return "unknown" + } + return v +} diff --git a/internal/sshinstall/detect_test.go b/internal/sshinstall/detect_test.go new file mode 100644 index 0000000..1183a01 --- /dev/null +++ b/internal/sshinstall/detect_test.go @@ -0,0 +1,120 @@ +package sshinstall + +import ( + "strings" + "testing" +) + +// prober returns a FileProber backed by a fixed path set. +func prober(exist ...string) FileProber { + want := make(map[string]bool, len(exist)) + for _, p := range exist { + want[p] = true + } + return func(paths ...string) map[string]bool { + out := make(map[string]bool, len(paths)) + for _, p := range paths { + out[p] = want[p] + } + return out + } +} + +func TestParseOSReleaseQuoting(t *testing.T) { + d := parseOSRelease(`NAME="Ubuntu 24.04 LTS" +VERSION_ID="24.04" +ID=ubuntu +ID_LIKE=debian +PRETTY_NAME="Ubuntu 24.04 LTS" +`) + if d.ID != "ubuntu" || d.Name != "Ubuntu 24.04 LTS" || d.VersionID != "24.04" { + t.Fatalf("parseOSRelease = %+v", d) + } + if d.Distro != DistroUbuntu { + t.Fatalf("distro = %q, want ubuntu", d.Distro) + } +} + +func TestParseOSReleaseSingleQuotedAndEmpty(t *testing.T) { + d := parseOSRelease("# comment\nID='alpine'\nNAME=Alpine\n\n") + if d.ID != "alpine" || d.Name != "Alpine" { + t.Fatalf("parseOSRelease = %+v", d) + } + if d.Distro != DistroAlpine { + t.Fatalf("distro = %q, want alpine", d.Distro) + } +} + +func TestParseOSReleaseGarbage(t *testing.T) { + d := parseOSRelease("not an os-release file\nID = spaced\nFOO=bar\n") + if d.ID != "" { + t.Fatalf("ID = %q, want empty", d.ID) + } + if d.Distro != DistroUnknown { + t.Fatalf("distro = %q, want unknown", d.Distro) + } +} + +func TestPackageManagerFor(t *testing.T) { + cases := map[string]PackageManager{ + "alpine": PkgApk, + "debian": PkgApt, + "ubuntu": PkgApt, + "arch": PkgPacman, + "centos": PkgDnf, + "fedora": PkgDnf, + "rocky": PkgDnf, + "almalinux": PkgDnf, + "rhel": PkgDnf, + "nonsense": PkgUnknown, + "": PkgUnknown, + } + for id, want := range cases { + if got := packageManagerFor(id); got != want { + t.Fatalf("packageManagerFor(%q) = %q, want %q", id, got, want) + } + } +} + +func TestDetectInitMarkers(t *testing.T) { + osRelease := "ID=arch\nNAME=Arch Linux\n" + + if got := Detect(osRelease, prober("/usr/lib/systemd/system")); got.InitSystem != InitSystemd { + t.Fatalf("arch systemd markers not detected: %+v", got) + } + if got := Detect(osRelease, nil).InitSystem; got != InitUnknown { + t.Fatalf("nil prober should yield unknown init, got %q", got) + } + + alpine := Detect("ID=alpine\n", prober("/sbin/openrc", "/etc/init.d")) + if alpine.InitSystem != InitOpenRC { + t.Fatalf("alpine openrc markers not detected: %+v", alpine) + } + if alpine.PackageManager != PkgApk { + t.Fatalf("alpine pkg = %q, want apk", alpine.PackageManager) + } + + ubuntu := Detect("ID=ubuntu\n", prober("/run/systemd/system")) + if ubuntu.InitSystem != InitSystemd || ubuntu.PackageManager != PkgApt { + t.Fatalf("ubuntu detection = %+v", ubuntu) + } +} + +func TestDetectSystemdWinsOverOpenRC(t *testing.T) { + // A host that carries both markers must be reported as systemd so a + // systemd distro running a containerized openrc stub is not misplanned. + d := Detect("ID=ubuntu\n", prober("/usr/lib/systemd/system", "/sbin/openrc")) + if d.InitSystem != InitSystemd { + t.Fatalf("init = %q, want systemd", d.InitSystem) + } +} + +func TestDetectionSummarize(t *testing.T) { + d := Detect("ID=alpine\nNAME=Alpine Linux\n", prober("/sbin/openrc")) + s := d.Summarize() + for _, want := range []string{"Alpine Linux", "id=alpine", "pkg=apk", "init=openrc"} { + if !strings.Contains(s, want) { + t.Fatalf("Summarize() = %q missing %q", s, want) + } + } +} diff --git a/internal/sshinstall/plan.go b/internal/sshinstall/plan.go new file mode 100644 index 0000000..577831a --- /dev/null +++ b/internal/sshinstall/plan.go @@ -0,0 +1,259 @@ +package sshinstall + +import ( + "fmt" + "net/url" + "strings" +) + +// DefaultGoVersion is the pinned Go toolchain version the source +// installer plans by default. It is overridable through install options +// when a host needs a different toolchain. +const DefaultGoVersion = "1.26.0" + +// DefaultRepo is the publicly readable worker repository. It requires no +// source credential and is the plan's default clone URL. +const DefaultRepo = "https://rocketgit.ru/rsmon/worker.git" + +// DefaultBranch is the branch the source installer checks out and +// builds. +const DefaultBranch = "main" + +// Toolchain is a pinned, checksum-verified Go toolchain download for a +// remote Linux architecture. The SHA-256 is baked for the default +// toolchain version so planning never needs the network. +type Toolchain struct { + Version string // e.g. "1.26.0" + Arch string // download archive suffix, e.g. "linux-amd64" + URL string // direct download URL + SHA256 string // published SHA-256 (64 lowercase hex) +} + +// toolchainSHA pins the official go.dev SHA-256 checksums for the +// default Go version per Linux archive. Sources: https://go.dev/dl/ +// (?mode=json) published sums for DefaultGoVersion. Keep this in sync +// with DefaultGoVersion. +var toolchainSHA = map[string]string{ + "linux-386": "35e2ec7a7ae6905a1fae5459197b70e3fcbc5e0a786a7d6ba8e49bcd38ad2e26", + "linux-amd64": "aac1b08a0fb0c4e0a7c1555beb7b59180b05dfc5a3d62e40e9de90cd42f88235", + "linux-arm64": "bd03b743eb6eb4193ea3c3fd3956546bf0e3ca5b7076c8226334afe6b75704cd", + "linux-armv6l": "3f6b48d96f0d8dff77e4625aa179e0449f6bbe79b6986bfa711c2cfc1257ebd8", + "linux-loong64": "33947cd7686f1cd5f097d2a5a30427a4ade114ea00b7570c85a2abf1af3d0507", + "linux-mips": "a4ece61d4bac43b6983fde2c6b9cfc1af7f0d5d6a073219583d4e93b11559c25", + "linux-mips64": "197c2e97fa9ec1ad05998e0982d1a1ae761980df154424e5f29f3912e9ea4e5e", + "linux-mips64le": "61c52b4ab0dceae29f10df29045483596c3f06810c9b511e8336a97428a95a1b", + "linux-mipsle": "b3a13cc5a5f9250b02cf4ba19914c90c7034e68a5ccb9affa5198aadbcedac9a", + "linux-ppc64": "ef7232a49101d163a93bac34d03bfbc4fb18f75d7526d77ac307e16d9d83c300", + "linux-ppc64le": "3066b2284b554da76cf664d217490792ba6f292ec0fc20bf9615e173cc0d2800", + "linux-riscv64": "ab9226ecddda0f682365c949114b653a66c2e9330e7b8d3edea80858437d2ff2", + "linux-s390x": "d62137f11530b97f3503453ad7d9e570af070770599fb8054f4e8cd0e905a453", +} + +// GoArch maps a remote `uname -m` value to the Go download archive +// suffix used by go.dev. Every suffix it can return must have a pinned +// checksum in toolchainSHA; the table-consistency test enforces that. +// Unknown values error. +func GoArch(unameM string) (string, error) { + switch strings.ToLower(strings.TrimSpace(unameM)) { + case "x86_64", "amd64": + return "amd64", nil + case "aarch64", "arm64": + return "arm64", nil + case "armv6l", "armv7l": + return "armv6l", nil + case "i386", "i486", "i586", "i686", "386": + return "386", nil + case "loongarch64": + return "loong64", nil + case "mips": + return "mips", nil + case "mipsel": + return "mipsle", nil + case "mips64": + return "mips64", nil + case "mips64el": + return "mips64le", nil + case "ppc64": + return "ppc64", nil + case "ppc64le": + return "ppc64le", nil + case "riscv64": + return "riscv64", nil + case "s390x": + return "s390x", nil + default: + return "", fmt.Errorf("unsupported machine architecture %q", unameM) + } +} + +// ToolchainFor returns the pinned, checksum-verified Go toolchain for a +// remote Linux architecture. A non-default version has no baked +// checksum yet and must be resolved through a checksum source by the +// executor work package. +func ToolchainFor(goarch, version string) (Toolchain, error) { + if version == "" { + version = DefaultGoVersion + } + suffix := "linux-" + goarch + t := Toolchain{ + Version: version, + Arch: suffix, + URL: "https://go.dev/dl/go" + version + "." + suffix + ".tar.gz", + } + if version != DefaultGoVersion { + return t, fmt.Errorf("no baked checksum for Go %s; only %s is pinned (resolve %s via the checksum source)", + version, DefaultGoVersion, suffix) + } + sha, ok := toolchainSHA[suffix] + if !ok { + return Toolchain{}, fmt.Errorf("no pinned Go %s toolchain for %s", version, suffix) + } + t.SHA256 = sha + return t, nil +} + +// SourceOptions are the operator-configurable knobs that the source +// plan is resolved against. Every field is optional; empty values fall +// back to the pinned defaults. +type SourceOptions struct { + Repo string // clone URL; default DefaultRepo + Branch string // default DefaultBranch + GoVersion string // default DefaultGoVersion + GoArch string // go archive suffix; when empty, derived from UnameM + UnameM string // remote `uname -m` output; required unless GoArch set + BuildDir string // remote clone/build directory + GoModuleProxy string // GOPROXY override; empty keeps the Go default +} + +// SourcePlan is the pure, resolved source-install plan. Producing it +// never touches the network or the remote host; the executor work +// package turns it into remote commands. +type SourcePlan struct { + Repo string + Branch string + BuildDir string + GoModuleProxy string + Toolchain Toolchain + Packages []string // packages to install via the package manager + InitSystem InitSystem +} + +// PlanSource resolves a source-install plan for a detected host. It +// returns an error before any remote mutation could happen when the +// target cannot be planned (unknown distro, unsupported architecture, +// malformed repository). +func PlanSource(d Detection, opts SourceOptions) (SourcePlan, error) { + if d.PackageManager == PkgUnknown { + return SourcePlan{}, fmt.Errorf("unsupported distro %q: no package manager", d.ID) + } + goarch := strings.TrimSpace(opts.GoArch) + if goarch == "" { + var err error + goarch, err = GoArch(opts.UnameM) + if err != nil { + return SourcePlan{}, err + } + } + toolchain, err := ToolchainFor(goarch, opts.GoVersion) + if err != nil { + return SourcePlan{}, err + } + repo := strings.TrimSpace(opts.Repo) + if repo == "" { + repo = DefaultRepo + } + if err := validateRepoURL(repo); err != nil { + return SourcePlan{}, err + } + branch := strings.TrimSpace(opts.Branch) + if branch == "" { + branch = DefaultBranch + } + buildDir := strings.TrimSpace(opts.BuildDir) + if buildDir == "" { + buildDir = "/opt/rsmon-worker-src" + } + if !strings.HasPrefix(buildDir, "/") { + return SourcePlan{}, fmt.Errorf("build directory must be absolute, got %q", buildDir) + } + return SourcePlan{ + Repo: repo, + Branch: branch, + BuildDir: buildDir, + GoModuleProxy: strings.TrimSpace(opts.GoModuleProxy), + Toolchain: toolchain, + Packages: packagePrereqs(d.PackageManager), + InitSystem: d.InitSystem, + }, nil +} + +// packagePrereqs returns the minimal package set the source installer +// plans to install for a package manager: git, CA certificates, and +// download/archive tools. It never plans a C compiler or build-essential +// because the worker builds with CGO disabled. +func packagePrereqs(pkg PackageManager) []string { + switch pkg { + case PkgApk: + return []string{"git", "ca-certificates", "curl", "tar", "gzip"} + case PkgApt: + return []string{"git", "ca-certificates", "curl", "tar", "gzip"} + case PkgPacman: + return []string{"git", "ca-certificates", "curl", "tar", "gzip"} + case PkgDnf, PkgYum: + return []string{"git", "ca-certificates", "curl", "tar", "gzip"} + default: + return nil + } +} + +// validateRepoURL rejects repository references that could smuggle a +// command or a non-remote scheme into the clone step. Only http(s) and +// the git protocol are accepted; the default repository is https. +func validateRepoURL(repo string) error { + if strings.ContainsAny(repo, "\r\n\t ") { + return fmt.Errorf("repository URL %q contains whitespace", repo) + } + u, err := url.Parse(repo) + if err != nil || u.Host == "" { + return fmt.Errorf("repository URL %q is not an absolute clone URL", repo) + } + switch u.Scheme { + case "https", "http", "git": + return nil + default: + return fmt.Errorf("repository URL %q uses unsupported scheme %q", repo, u.Scheme) + } +} + +// StepKind identifies one ordered remote step the executor will run. +type StepKind string + +const ( + StepInstallPackages StepKind = "install-packages" + StepInstallToolchain StepKind = "install-toolchain" + StepCloneSource StepKind = "clone-source" + StepCheckoutBranch StepKind = "checkout-branch" + StepBuildWorker StepKind = "build-worker" + StepInstallService StepKind = "install-service" +) + +// Step is one ordered, pure planning step. The executor maps each step +// to remote commands; planning does not execute anything. +type Step struct { + Kind StepKind + Detail string + Packages []string // only for StepInstallPackages +} + +// Steps returns the ordered source-install plan as stable, reviewable +// steps. It is the contract the executor work package implements. +func (p SourcePlan) Steps() []Step { + return []Step{ + {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: StepCloneSource, Detail: "clone " + p.Repo + " into " + p.BuildDir}, + {Kind: StepCheckoutBranch, Detail: "check out branch " + p.Branch + " and record the resolved commit"}, + {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"}, + } +} diff --git a/internal/sshinstall/plan_test.go b/internal/sshinstall/plan_test.go new file mode 100644 index 0000000..04ae7b5 --- /dev/null +++ b/internal/sshinstall/plan_test.go @@ -0,0 +1,243 @@ +package sshinstall + +import ( + "strings" + "testing" +) + +func TestGoArch(t *testing.T) { + cases := map[string]string{ + "x86_64": "amd64", + "X86_64": "amd64", + "amd64": "amd64", + "aarch64": "arm64", + "arm64": "arm64", + "armv7l": "armv6l", + "armv6l": "armv6l", + "i686": "386", + "i386": "386", + "loongarch64": "loong64", + "mips": "mips", + "mipsel": "mipsle", + "mips64": "mips64", + "mips64el": "mips64le", + "ppc64": "ppc64", + "ppc64le": "ppc64le", + "riscv64": "riscv64", + "s390x": "s390x", + } + for in, want := range cases { + got, err := GoArch(in) + if err != nil || got != want { + t.Fatalf("GoArch(%q) = %q, %v; want %q", in, got, err, want) + } + } + for _, bad := range []string{"", "sparc", "x86", "mips64el-le"} { + if _, err := GoArch(bad); err == nil { + t.Fatalf("GoArch(%q) succeeded", bad) + } + } +} + +// TestToolchainTableConsistentWithGoArch keeps GoArch and toolchainSHA in +// sync: every `uname -m` mapping must resolve to a pinned checksum, and +// every pinned checksum must be reachable through some `uname -m` value. +func TestToolchainTableConsistentWithGoArch(t *testing.T) { + // unameExamples maps a Go archive suffix to a real `uname -m` value + // that GoArch accepts for it. + unameExamples := map[string]string{ + "amd64": "x86_64", + "arm64": "aarch64", + "armv6l": "armv7l", + "386": "i686", + "loong64": "loongarch64", + "mips": "mips", + "mipsle": "mipsel", + "mips64": "mips64", + "mips64le": "mips64el", + "ppc64": "ppc64", + "ppc64le": "ppc64le", + "riscv64": "riscv64", + "s390x": "s390x", + } + for archSuffix, unameValue := range unameExamples { + tc, err := ToolchainFor(archSuffix, DefaultGoVersion) + if err != nil { + t.Fatalf("GoArch(%q) = %q has no pinned checksum: %v", unameValue, archSuffix, err) + } + if tc.SHA256 == "" || tc.Arch != "linux-"+archSuffix { + t.Fatalf("ToolchainFor(%q) = %+v", archSuffix, tc) + } + } + for suffix := range toolchainSHA { + arch := strings.TrimPrefix(suffix, "linux-") + if _, ok := unameExamples[arch]; !ok { + t.Fatalf("checksum %q is not reachable through any GoArch `uname -m` mapping", suffix) + } + } +} + +func TestToolchainFor(t *testing.T) { + tc, err := ToolchainFor("amd64", "") + if err != nil { + t.Fatal(err) + } + if tc.Version != DefaultGoVersion { + t.Fatalf("version = %q, want %q", tc.Version, DefaultGoVersion) + } + if tc.Arch != "linux-amd64" { + t.Fatalf("arch = %q, want linux-amd64", tc.Arch) + } + if tc.URL != "https://go.dev/dl/go1.26.0.linux-amd64.tar.gz" { + t.Fatalf("url = %q", tc.URL) + } + if len(tc.SHA256) != 64 { + t.Fatalf("sha = %q, want 64 hex chars", tc.SHA256) + } + for _, c := range tc.SHA256 { + hexDigit := c >= '0' && c <= '9' || c >= 'a' && c <= 'f' + if !hexDigit { + t.Fatalf("sha %q contains non-lowercase-hex char", tc.SHA256) + } + } +} + +func TestToolchainForArm64Pinned(t *testing.T) { + tc, err := ToolchainFor("arm64", DefaultGoVersion) + if err != nil { + t.Fatal(err) + } + if tc.SHA256 == "" || tc.Arch != "linux-arm64" { + t.Fatalf("arm64 toolchain not pinned: %+v", tc) + } +} + +func TestToolchainForRejectsUnpinnedVersion(t *testing.T) { + if _, err := ToolchainFor("amd64", "1.27.0"); err == nil { + t.Fatal("unpinned Go version accepted") + } + if _, err := ToolchainFor("sparc", ""); err == nil { + t.Fatal("unknown architecture accepted") + } +} + +func TestPlanSourceAlpine(t *testing.T) { + d := Detect("ID=alpine\n", prober("/sbin/openrc")) + p, err := PlanSource(d, SourceOptions{UnameM: "x86_64"}) + if err != nil { + t.Fatal(err) + } + if p.Repo != DefaultRepo || p.Branch != DefaultBranch { + t.Fatalf("plan defaults wrong: %+v", p) + } + if p.Toolchain.Arch != "linux-amd64" { + t.Fatalf("toolchain = %+v", p.Toolchain) + } + if len(p.Packages) != 5 || p.Packages[0] != "git" { + t.Fatalf("alpine packages = %v", p.Packages) + } + if p.InitSystem != InitOpenRC { + t.Fatalf("init = %q, want openrc", p.InitSystem) + } + steps := p.Steps() + if len(steps) != 6 { + t.Fatalf("steps = %d, want 6", len(steps)) + } + if steps[0].Kind != StepInstallPackages || steps[1].Kind != StepInstallToolchain { + t.Fatalf("step order wrong: %+v", steps) + } + if !strings.Contains(steps[1].Detail, "Go 1.26.0") || !strings.Contains(steps[1].Detail, "linux-amd64") { + t.Fatalf("toolchain step detail = %q", steps[1].Detail) + } + if !strings.Contains(steps[5].Detail, "openrc") { + t.Fatalf("install step detail = %q", steps[5].Detail) + } +} + +func TestPlanSourceUbuntuArch(t *testing.T) { + ubuntu := Detect("ID=ubuntu\n", prober("/usr/lib/systemd/system")) + p, err := PlanSource(ubuntu, SourceOptions{UnameM: "aarch64"}) + if err != nil { + t.Fatal(err) + } + if p.Toolchain.Arch != "linux-arm64" || p.InitSystem != InitSystemd { + t.Fatalf("ubuntu plan = %+v", p) + } + + arch := Detect("ID=arch\n", prober("/usr/lib/systemd/system")) + p, err = PlanSource(arch, SourceOptions{UnameM: "x86_64"}) + if err != nil { + t.Fatal(err) + } + if p.Packages[0] != "git" || p.InitSystem != InitSystemd { + t.Fatalf("arch plan = %+v", p) + } +} + +func TestPlanSourceExplicitOverrides(t *testing.T) { + d := Detect("ID=ubuntu\n", nil) + p, err := PlanSource(d, SourceOptions{ + Repo: "https://example.test/worker.git", + Branch: "release-1.0", + GoArch: "arm64", + BuildDir: "/srv/rsmon", + GoModuleProxy: "https://proxy.golang.org,direct", + }) + if err != nil { + t.Fatal(err) + } + if p.Repo != "https://example.test/worker.git" || p.Branch != "release-1.0" || + p.BuildDir != "/srv/rsmon" || p.GoModuleProxy != "https://proxy.golang.org,direct" { + t.Fatalf("overrides not applied: %+v", p) + } + if p.Toolchain.Arch != "linux-arm64" { + t.Fatalf("toolchain = %+v", p.Toolchain) + } +} + +func TestPlanSourceErrors(t *testing.T) { + unknown := Detect("ID=weirdos\n", nil) + if _, err := PlanSource(unknown, SourceOptions{UnameM: "x86_64"}); err == nil { + t.Fatal("unknown distro planned") + } + + ubuntu := Detect("ID=ubuntu\n", nil) + if _, err := PlanSource(ubuntu, SourceOptions{}); err == nil { + t.Fatal("missing architecture planned") + } + for _, repo := range []string{"file:///tmp/worker.git", "not-a-url", "https://ex ample/x"} { + if _, err := PlanSource(ubuntu, SourceOptions{UnameM: "x86_64", Repo: repo}); err == nil { + t.Fatalf("invalid repo %q planned", repo) + } + } + if _, err := PlanSource(ubuntu, SourceOptions{UnameM: "x86_64", BuildDir: "relative"}); err == nil { + t.Fatal("relative build dir planned") + } +} + +func TestPackagePrereqsNeverIncludeCompiler(t *testing.T) { + for _, pkg := range []PackageManager{PkgApk, PkgApt, PkgPacman, PkgDnf} { + for _, name := range packagePrereqs(pkg) { + switch name { + case "build-essential", "gcc", "g++", "base-devel", "gcc-c++", "make": + t.Fatalf("plan includes compiler package %q", name) + } + } + } + if got := packagePrereqs(PkgUnknown); len(got) != 0 { + t.Fatalf("unknown package manager planned packages %v", got) + } +} + +func TestValidateRepoURLSchemes(t *testing.T) { + for _, ok := range []string{"https://rocketgit.ru/rsmon/worker.git", "http://x/y", "git://example.test/r"} { + if err := validateRepoURL(ok); err != nil { + t.Fatalf("validateRepoURL(%q): %v", ok, err) + } + } + for _, bad := range []string{"ssh://h@x/r", "s3://bucket/key", "x y", ""} { + if err := validateRepoURL(bad); err == nil { + t.Fatalf("validateRepoURL(%q) succeeded", bad) + } + } +}