test(installer): add OpenSSH distro harness
Все проверки выполнены успешно
CI / test (push) Successful in 3m33s
Docker / Build and publish worker image (push) Successful in 18m37s

Этот коммит содержится в:
Gleb Tv
2026-08-12 22:00:14 +03:00
родитель cb23f123ae
Коммит 4651deb280
18 изменённых файлов: 2408 добавлений и 21 удалений

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

@@ -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

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

@@ -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 |

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

@@ -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 |

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

@@ -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_<NAME>`.
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

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

@@ -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.

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

@@ -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/<fixture>-<suffix>: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_<NAME>` 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.

599
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_<NAME>.
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_<NAME> 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()
}

402
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())
}
}

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

@@ -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, "'", "'\\''") + "'"
}

21
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"]

19
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"]

20
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"]

8
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-----

1
internal/installer/harness/testdata/keys/rsmon_test_ed25519.pub поставляемый Обычный файл
Просмотреть файл

@@ -0,0 +1 @@
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILPuHccXAmsL4MisTdb73Ij6FEBwJV6zIRCYKJyY+uoD rsmon-worker source-install test

221
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
}

120
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)
}
}
}

259
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"},
}
}

243
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)
}
}
}