Некоторые проверки не удались
CI / test (push) Successful in 3m19s
Docker / Build and publish worker image (push) Successful in 17m20s
SSH Source-Install E2E / Alpine/Ubuntu/Arch source-install E2E (push) Failing after 2m48s
596 строки
34 KiB
Markdown
596 строки
34 KiB
Markdown
# SSH Source Installation Plan
|
|
|
|
## Status
|
|
|
|
In progress. Work packages 1-5 are implemented (work package 5 is the
|
|
Docker/OpenSSH E2E matrix wired into CI):
|
|
|
|
- `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.
|
|
- `installer.SourceInstall` (work package 3) executes the plan through
|
|
the same SSH transport, authentication, and host-key verification the
|
|
`deploy` command uses. It installs the minimal package prerequisites,
|
|
downloads and SHA-256-verifies the pinned Go toolchain before
|
|
extraction, clones/updates the public repository, checks out the
|
|
resolved branch, records the resolved branch and commit, and builds the
|
|
worker to a staging path.
|
|
- Work package 4 (`installer.SourceInstall` with `Activation` enabled)
|
|
atomically installs the staged binary, validated environment, data
|
|
directory, and the detected init's service definition; starts/restarts
|
|
the worker; verifies the process and `/healthz`; and rolls back to the
|
|
prior working install on any activation/start/health failure. Reruns
|
|
are idempotent with exactly one running worker and no leaked temp
|
|
files. The CLI runs the full flow by default and accepts
|
|
`--no-activate` (staging only) and `--no-start`.
|
|
|
|
The current Go installer can also upload a binary or deploy an immutable
|
|
Docker image over SSH (`deploy`). Source installs build remotely, then
|
|
activate the staged build atomically with rollback. Existing tests are
|
|
unit tests plus the harness tests that run against live OpenSSH
|
|
containers when explicitly enabled.
|
|
|
|
## Initial Platform Scope
|
|
|
|
The first source installer supports Linux and is validated on Alpine, Ubuntu,
|
|
and Arch Linux containers. CentOS-family support follows after its package and
|
|
service differences are implemented. Windows and macOS remain later platform
|
|
work despite the worker being written in Go.
|
|
|
|
Use `reg.rsxx.ru` image mirrors where available. Tests must not depend on Docker
|
|
Hub when a local mirror exists.
|
|
|
|
## Source Install Flow
|
|
|
|
The Go CLI connects through `golang.org/x/crypto/ssh` using existing key,
|
|
passphrase, password, sudo-password, known-hosts, and pinned-fingerprint support.
|
|
It then:
|
|
|
|
1. detects supported OS, architecture, package manager, init system, and
|
|
privilege path;
|
|
2. installs only required packages (`git`, CA certificates, download/archive
|
|
tools); it does not install `build-essential` or a C compiler unless a
|
|
detected dependency requires CGO;
|
|
3. downloads the pinned Go 1.26 toolchain for the detected architecture and
|
|
verifies the published SHA-256;
|
|
4. clones `https://rocketgit.ru/rsmon/worker.git` or updates an existing clone;
|
|
5. checks out the resolved branch (the pinned branch when one is configured,
|
|
otherwise the remote's default branch) and records the resolved commit;
|
|
6. builds a reproducible worker binary with the repository build flags;
|
|
7. atomically installs the binary, validated environment, data directory, and
|
|
service definition;
|
|
8. starts the service and verifies process status and `/healthz`.
|
|
Repository, branch, Go version, checksum source, build directory, and Go module
|
|
proxy may be configurable, but production output records their resolved values.
|
|
The default repository is publicly readable and requires no source credential.
|
|
|
|
## Work Package 3: Remote Execution To A Staging Path
|
|
|
|
Work package 3 is `installer.SourceInstall` in
|
|
`internal/installer/sourceinstall.go`. It reuses the `deploy` command's
|
|
`SSHOptions` (authentication, sudo password, known-hosts and fingerprint
|
|
verification) and runs every remote step with the same privilege path
|
|
(root, passwordless sudo, or `sudo -S -p ''` with the password delivered
|
|
only over stdin). Steps 1-6 of the flow above are implemented; step 7
|
|
(atomic install) and step 8 (start + verify) are work package 4.
|
|
|
|
Per step:
|
|
|
|
- **Prerequisite install.** `packageScript` renders the distro's
|
|
idempotent command (`apk add --no-cache`, `apt-get update` +
|
|
`apt-get install -y --no-install-recommends`, `pacman -Sy --noconfirm
|
|
--needed`, `dnf install -y`) for the minimal plan packages (`git`,
|
|
`ca-certificates`, `curl`, `tar`, `gzip`). No compiler is ever planned
|
|
or installed.
|
|
- **Toolchain.** `toolchainScript` downloads the pinned Go tarball into a
|
|
`mktemp` temp dir, verifies it with `sha256sum -c -` *before*
|
|
extraction, extracts into a staging dir on the same filesystem as
|
|
`ToolchainDir` (which must end in `/go`, default `/usr/local/go`),
|
|
verifies the staged toolchain reports the target version, and only then
|
|
swaps it into place. The prior toolchain is moved to a sibling
|
|
`.go-backup` and is restored if the swap fails, so a failed
|
|
download/verify/extract/swap always leaves the prior Go untouched. A
|
|
present toolchain that already reports the target version is reused, so
|
|
reruns do not re-download. Temp, staging, and backup directories are
|
|
removed on success and failure.
|
|
- **Clone/update.** `cloneUpdateScript` clones the repository when
|
|
`BuildDir` has no `.git` and otherwise fetches with `--prune`, so a
|
|
rerun updates in place. An existing checkout's `remote.origin.url` must
|
|
exactly match the configured repository before anything is fetched or
|
|
built, so the installer can never fetch or build an unconfigured
|
|
repository. The clone/fetch retries up to three times (2s apart)
|
|
because real repositories can be transiently unreachable (DNS, TLS, or
|
|
proxy hiccups); three bounded attempts keep a momentary outage from
|
|
failing a full source install.
|
|
- **Resolved branch.** The installer resolves the remote default branch
|
|
via `git remote set-head origin --auto` +
|
|
`git symbolic-ref --short refs/remotes/origin/HEAD`. When no branch is
|
|
pinned it builds the remote default (the public repo currently
|
|
publishes `master`); a pinned branch must exist remotely or the install
|
|
fails before the build. The resolved branch and the `git rev-parse
|
|
HEAD` commit (validated as 40 lowercase hex) are returned by
|
|
`SourceInstall`.
|
|
- **Staging build.** `buildScript` builds with `CGO_ENABLED=0`,
|
|
`-trimpath`, the repository's own `-ldflags` shape (version `dev`,
|
|
resolved commit short form, UTC build date), and both `GOCACHE` and
|
|
`GOMODCACHE` inside the build dir (so reruns reuse them), plus an
|
|
optional `GOPROXY`. The binary is built to a sibling `<stage>.new`,
|
|
verified with `<stage>.new --version`, and only then atomically swapped
|
|
over `<BuildDir>/rsmon-worker` (or `StageBinary`), so a failed build
|
|
never replaces the previous staging binary. It is not written to
|
|
`/usr/local/bin`.
|
|
- **Commit record.** The `rsmon-worker.commit` record (a
|
|
`branch=...` / `commit=...` format in the build dir) is written only
|
|
*after* a successful build, so the record and the staged binary always
|
|
correspond to the same commit.
|
|
|
|
Security properties of work package 3:
|
|
|
|
- Every interpolated value (repository, branch, build dir, URLs, SHA-256,
|
|
package names, paths) is single-quoted; repository, branch, commit, Go
|
|
version, and Go architecture values are additionally validated with
|
|
strict patterns. No worker token or control-plane credential is ever
|
|
sent: the install stages a binary and touches no service configuration.
|
|
- The repository must be an `https://` URL without userinfo, so source
|
|
credentials cannot reach the remote clone command or the clone's
|
|
config.
|
|
- Sudo passwords are delivered over the session's stdin only, never in a
|
|
command string (the same `sudoWrap` path the `deploy` command uses).
|
|
Direct `--password`/`--sudo-password`/`--key-passphrase` flags remain
|
|
available but expose the value through the process list and shell
|
|
history; the CLI docs strongly prefer the `-file` variants. The source
|
|
installer sends no worker token at all.
|
|
- Every step script fails closed: `set -eu` (or an explicit retry that
|
|
exits non-zero) is used, so a failed checkout or fetch can never be
|
|
masked by a stale subsequent command. The checkout step refuses before
|
|
the destructive `checkout -B` when the tracked working tree is dirty
|
|
(`git diff --quiet` / `--cached --quiet`), because `checkout -B` would
|
|
silently discard local changes; a dirty-tree rerun fails at checkout and
|
|
leaves the previous staging binary and commit record untouched.
|
|
- Remote errors are bounded: each step returns a step-labelled error,
|
|
stderr and captured stdout are size-bounded in `runRemoteOutput`, and
|
|
each remote command is capped by `--session-timeout` (default 30m).
|
|
- Toolchain temp, staging, and backup directories are removed on success
|
|
and failure, and the acceptance test asserts no `/tmp/rsmon-toolchain-*`
|
|
or `/usr/local/.go-staging-*`/`.go-backup` leaks after the rerun.
|
|
- Failed builds and failed checkouts leave the previous staging binary
|
|
untouched (the binary is only overwritten by an atomically-swapped
|
|
successful build, and a checkout failure aborts before the build).
|
|
|
|
## Work Package 4: Atomic Activation And Rollback
|
|
|
|
Work package 4 is the second half of `installer.SourceInstall`, driven by
|
|
`Activation` (`internal/installer/sourceactivate.go`). It runs over the
|
|
same SSH executor, privilege path, and bounded-error machinery as the
|
|
staging steps and installs the staged build atomically:
|
|
|
|
- **Layout.** The classic installer's on-disk layout is reused
|
|
(`resolvePaths`): binary `/usr/local/bin/rsmon-worker[-name]`, config
|
|
`/etc/rsmon-worker[-name]/worker.env` (mode 0600), data
|
|
`/var/lib/rsmon-worker[-name]`, and a service definition for the
|
|
detected init (systemd unit `/etc/systemd/system/rsmon-worker.service`
|
|
mode 0644, or an OpenRC script `/etc/init.d/rsmon-worker` mode 0755).
|
|
A named instance (`--name`) mirrors the classic multi-instance
|
|
convention with `-<name>` suffixes and its own unit and port.
|
|
- **Environment.** The env file is rendered by the classic installer's
|
|
`resolveInstallEnv` + `renderEnvFile` (so `PUBLIC_URL` canonicalization,
|
|
basic-auth XOR, required `RSMON_URL`/`RSMON_TOKEN`, and systemd-safe
|
|
value validation are identical to `install`). The env file is read
|
|
*exactly once* and validated from the in-memory bytes
|
|
(`parseEnvironmentContent`), and the rendered env is cached during
|
|
option normalization and reused at activation time, so a hostile local
|
|
writer cannot swap the file between the preflight and the remote
|
|
install (env-file TOCTOU). The rendered bytes are uploaded into a
|
|
server-created 0700 temp dir (`mktemp -d /tmp/rsmon-worker-act.XXXXXX`
|
|
under the SSH user) rather than a predictable `/tmp` path, eliminating
|
|
the symlink/TOCTOU attack surface on the upload while the token still
|
|
travels only as base64 over the session stdin and, later, only inside
|
|
the mode-0600 env file. The env is then installed with mode 0600 in the
|
|
config dir (mode 0750).
|
|
- **Atomic install.** The staged binary is validated (`<stage>
|
|
--version`) before any state is touched, then installed with a
|
|
same-directory temp file + rename. The env and unit files are installed
|
|
the same way. The data dir (and `webapp/` subdir) is created with mode
|
|
0755.
|
|
- **Supervisor.** `restart_svc` drives start/restart through whichever
|
|
init system is *actually running*: systemd (`systemctl`), OpenRC
|
|
(`rc-service`), or the embedded supervisor fallback when no init is
|
|
running (containers/chroots, the "no-service gate"). The health loop
|
|
verifies the service is active *through the supervisor* — `systemctl
|
|
is-active` for systemd, `rc-service status` for OpenRC, and a
|
|
zombie-aware pid check for the embedded supervisor — never through a
|
|
pid file on the init-managed paths. The embedded supervisor keeps the
|
|
worker as a single background process with a pid file under the data
|
|
dir, stops the previous instance before starting, refuses to kill a pid
|
|
whose executable is not the configured worker (`/proc/<pid>/exe`
|
|
checked before every `kill`), and answers `/healthz` via the worker's
|
|
own `liveness` subcommand with a clean `env -i` environment built from
|
|
the env file (so a rolled-back env can never leak a stale variable into
|
|
the restored process). The OpenRC init script exports the env-file
|
|
variables (`set -a` before sourcing) so the worker inherits them when
|
|
OpenRC starts it.
|
|
- **Rollback.** The prior binary, env, and unit (and the unit's enable
|
|
state) are snapshotted into the data dir before any mutation, and an
|
|
`EXIT`/`HUP`/`INT`/`TERM` trap restores them (preserving the prior
|
|
binary/env/unit metadata via `cp -p`) and restarts the prior service if
|
|
any later step fails or a signal interrupts the run. A corrupt staged
|
|
binary fails the pre-validation *before* the trap is armed, so the
|
|
prior install is never touched. A fresh-install failure disables the
|
|
newly-enabled unit (`systemctl disable` / `rc-update del`) and removes
|
|
it; a rerun failure restores the prior unit and re-applies its prior
|
|
enable state. Rollback also removes the backup dir, the `.new` temp
|
|
files, the uploaded env/unit temps, and the activation lock.
|
|
- **Lock, recovery, interruption.** A `mkdir`-based activation lock
|
|
(`.rsmon-activate.lock`, broken automatically when its recorded pid is
|
|
dead) prevents concurrent activations from racing the deployed-state
|
|
mutation. The snapshot is marked; a run killed mid-flight (SSH drop,
|
|
SIGKILL) leaves that marker, and the next activation restores the
|
|
leftover snapshot before proceeding, so the host never stays
|
|
half-activated.
|
|
- **Idempotency.** A rerun reuses the existing env (rewriting it
|
|
deterministically from the same knobs), swaps the binary atomically,
|
|
restarts exactly one worker, and leaves no `.rsmon-backup`, `.new`,
|
|
`/tmp/rsmon-worker-act.*`, or lock leftovers. `--no-start` installs the
|
|
full layout without starting anything and reruns stay idle.
|
|
|
|
The `source-install` CLI activates by default; pass `--no-activate` for
|
|
the staging-only behavior of work package 3. The worker token is required
|
|
for activation and is supplied with `--token`/`--token-file` or
|
|
`--env-file` (prefer the file options; direct flags expose the value
|
|
through the process list).
|
|
|
|
## Work Package 5: Alpine/Ubuntu/Arch E2E In CI
|
|
|
|
Work package 5 runs the full Docker/OpenSSH E2E matrix on pushes to
|
|
`master` and on manual `workflow_dispatch` runs. It is a separate Gitea
|
|
Actions workflow (`.github/workflows/test-ssh.yml`) so the ordinary unit
|
|
CI run stays Docker-free; the job executes `make test-ssh` through
|
|
`scripts/ci/test-ssh.sh`.
|
|
|
|
CI behavior:
|
|
|
|
- **Trust boundary and triggers.** The workflow runs *only* on pushes to
|
|
`master` (the default branch) and on manual `workflow_dispatch`. It
|
|
deliberately does **not** trigger on `pull_request`: the distro fixtures
|
|
execute the checked-out repository code inside privileged Docker
|
|
containers, so an untrusted PR must never reach the runner's Docker
|
|
surface automatically. Gitea's read-only token clamp for fork PRs does
|
|
not limit what containers can do on the runner host, so PR coverage is
|
|
left to the Docker-free unit CI (`ci.yml`) and to manual dispatch after
|
|
a human reviews the change.
|
|
- **Concurrency is scoped per ref.** `concurrency.group:
|
|
test-ssh-${{ gitea.ref }}` gives master pushes their own group (a newer
|
|
master push cancels a superseded in-flight master run instead of
|
|
stacking) and gives a manual dispatch on another branch its own group so
|
|
it never cancels the master run. Gitea Actions evaluates the expression,
|
|
and `gitea.ref` is the same documented context the repo's `docker.yml`
|
|
already uses.
|
|
- **Least privilege.** The workflow declares `permissions: contents: read`
|
|
(supported by Gitea Actions as the `GITEA_TOKEN` scope for
|
|
code/releases), so the job's token can only read the repository; the
|
|
workflow never writes, pushes, or publishes.
|
|
- **Action revisions.** The two actions this workflow uses are pinned to
|
|
immutable full commit SHAs (not moving tags): `actions/checkout@v4` ->
|
|
`11d5960a326750d5838078e36cf38b85af677262` and `actions/setup-go@v5` ->
|
|
`40f1582b2485089dde7abd97c1529aa768e1baff` (verified 2026-08-13 against
|
|
the GitHub API that each tag points to a commit object). The repo-wide
|
|
convention still leaves `ci.yml` and `docker.yml` on moving tags
|
|
(`@v4`, `@v5`, `@v3`, `@v6`); that is an accepted, documented risk: a
|
|
tag move can change behavior without a workflow diff. New workflows
|
|
should pin SHAs like this one; migrating the existing workflows is a
|
|
separate change.
|
|
- **Runner requirement.** The harness dials fixture SSH ports published on
|
|
the Docker daemon's `127.0.0.1`, so the job must share the daemon's
|
|
loopback (a host-mode runner or a job container with host networking).
|
|
`scripts/ci/test-ssh.sh` probes this with a tiny `docker run -p
|
|
127.0.0.1::22` round trip *before* the matrix and fails fast with a
|
|
clear, actionable message (including diagnostics and fix options)
|
|
instead of after a 60m go-test timeout.
|
|
- **Bounding and timeout.** The workflow sets `timeout-minutes: 90` and
|
|
the existing `make test-ssh` go-test `-timeout 60m` stays in place, so
|
|
the whole job is hard-bounded even under slow networks or downloads.
|
|
- **Cleanup.** The harness already tears down every container, network,
|
|
per-instance fixture image tag, and temp dir on success and failure.
|
|
`scripts/ci/test-ssh.sh` additionally traps `EXIT` to remove any
|
|
leftover `rsmon-worker-test-*` resource (container, network, or the
|
|
per-instance `rsmon-worker-test/<fixture>-<suffix>:local` image tag), and
|
|
the workflow adds an `if: always()` step that does the same even when the
|
|
script itself is killed. All filters are anchored to the harness's own
|
|
prefix and image repository (`name=^rsmon-worker-test-`,
|
|
`reference=rsmon-worker-test/*`), so cleanup never touches a shared base
|
|
image (`reg.rsxx.ru/library/alpine:3`, `ubuntu:24.04`, `archlinux:latest`)
|
|
or an unrelated resource.
|
|
- **Mirrors and overrides.** The Alpine fixture already uses the
|
|
`reg.rsxx.ru/library/alpine:3` mirror. Ubuntu and Arch have no mirror yet
|
|
and default to Docker Hub. The workflow wires the
|
|
`RSMON_TEST_IMAGE_ALPINE`, `RSMON_TEST_IMAGE_UBUNTU`, and
|
|
`RSMON_TEST_IMAGE_ARCH` repository variables (empty by default) so an
|
|
operator can pin a mirror or a specific distro snapshot per fixture, and
|
|
the `RSMON_TEST_DOCKER_DNS` repository variable (comma-separated
|
|
nameservers, applied as `docker run --dns ...`) to pin a resolver for
|
|
flaky CI DNS. External network (go.dev toolchain download, the public
|
|
rocketgit.ru source clone, distro package repos) is fetched live by
|
|
design.
|
|
- **Artifacts and secrets.** The workflow uploads no artifacts: the go-test
|
|
log stays in the runner's job log and nothing private (test key,
|
|
`known_hosts`, env files) is retained on the runner or published. The
|
|
fixture containers receive only the fixed `e2e-activation-test-token`
|
|
and the bundled test-only key, never a real worker token.
|
|
- **Caching.** `actions/setup-go` caches the Go module/build cache used to
|
|
compile the harness test binary (public dependencies only). Fixture and
|
|
toolchain downloads are not cached because they run inside disposable
|
|
distro containers; reruns rebuild them cleanly.
|
|
|
|
Work package 5 does not change how the source installer behaves. It only
|
|
adds a CI surface for the existing acceptance tests.
|
|
|
|
## Docker OpenSSH Test Harness
|
|
|
|
Adapt the real-network pattern from `/data/_swap/sshkeymanager`: start an
|
|
OpenSSH container, wait for SSH readiness, connect with the Go installer, and
|
|
tear the environment down reliably. Do not mock SSH command execution in the
|
|
acceptance test.
|
|
|
|
Provide images/fixtures for:
|
|
|
|
- Ubuntu with apt and systemd-compatible service testing where practical;
|
|
- Alpine with apk/OpenRC or a clearly separated no-service build/install gate;
|
|
- Arch with pacman and its service behavior.
|
|
|
|
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.
|
|
|
|
The fixtures install over the public internet, so environments with flaky
|
|
local resolvers can pin a reliable upstream via the comma-separated
|
|
`RSMON_TEST_DOCKER_DNS` variable (applied as `docker run --dns ...`); it
|
|
is empty by default, keeping Docker's embedded DNS.
|
|
|
|
Teardown (`docker rm -f` + `docker network rm` + per-instance
|
|
`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: the toolchain is reused when the version
|
|
matches, the clone's origin is verified against the configured repository and
|
|
then fetched in place, the resolved branch/commit record is rewritten after
|
|
the new build succeeds, and the staging build atomically swaps over the
|
|
previous staging binary. Activation (work package 4) makes the rerun
|
|
idempotent at the service level too: exactly one worker process exists, the
|
|
env file is rewritten deterministically from the same knobs, and no
|
|
`.rsmon-backup`, `.new`, `/tmp/rsmon-worker-act.*`, or lock files leak.
|
|
- 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 should come from files: `--key-passphrase-file`,
|
|
`--password-file`, `--sudo-password-file`, and the activation
|
|
`--token-file`/`--env-file` keep secrets out of argv and shell history,
|
|
while the equivalent direct flags expose them through the process list. The
|
|
worker token is written only to the mode-0600 env file, never echoed into a
|
|
command or log, and sudo passwords travel only over the session's stdin. The
|
|
env and unit uploads land in a server-created 0700 `mktemp -d` directory (not
|
|
a predictable `/tmp` path), so no local user can plant a symlink at the
|
|
upload target (TOCTOU), and the env file itself is read/rendered exactly once
|
|
so a local writer cannot swap it between validation and upload.
|
|
- Remote temporary files are removed on success and failure, including the
|
|
activation backup dir, the uploaded env/unit temps, and the activation lock.
|
|
The snapshot is written under a marker so a run killed mid-flight is
|
|
recovered (restored) by the next activation instead of leaving the host
|
|
half-activated.
|
|
- Failed builds, failed checkouts, and failed toolchain swaps do not replace a
|
|
working binary or service definition. Failed activations, failed starts, and
|
|
failed `/healthz` verifications restore the prior binary, env, and service
|
|
definition (preserving their metadata), re-apply the prior unit enable state
|
|
(or disable a freshly-enabled unit on a fresh failure), and bring the prior
|
|
worker back up (work package 4 rollback). The supervisor only ever kills a
|
|
process whose `/proc/<pid>/exe` matches the configured worker binary, so a
|
|
stale or recycled pid file can never kill an unrelated process.
|
|
- Package-manager and download failures return bounded actionable errors.
|
|
- The installer verifies Go tarball checksum before extraction.
|
|
|
|
## Implementation Work Packages
|
|
|
|
- [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).
|
|
- [x] 3. Execute source installation through the existing SSH transport
|
|
(prerequisites, verified Go toolchain, clone/update, resolved branch and
|
|
commit record, and a build to a staging path; no service activation).
|
|
- [x] 4. Add atomic build/install, idempotency, and failure rollback (binary,
|
|
env, data dir, and init service definition installed atomically; process
|
|
and `/healthz` verified; activation/start/health failures restore the
|
|
prior install; reruns keep exactly one service).
|
|
- [x] 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
|
|
|
|
- [x] All three initial Linux images install from a clean state through OpenSSH:
|
|
package install, verified Go 1.26, clone/update, resolved commit, staging
|
|
build, and (work package 4) atomic activation with a running verified
|
|
worker.
|
|
- [x] The built worker reports the expected version/commit and serves `/healthz`
|
|
(verified by the E2E fixture tests and by the activation health gate).
|
|
- [x] Re-running the installer succeeds without duplicate services or leaked
|
|
files (exactly one worker process and no backup/temp leftovers asserted by
|
|
the rerun test on all three fixtures).
|
|
- [x] Host-key, checksum, clone, build, and service-start failure tests preserve
|
|
the previous installation (build/checkout, activation, start, and health
|
|
failure reruns on the Alpine fixture all restore the prior binary, env,
|
|
and running service).
|
|
- [x] CI uses approved registry mirrors and cleans every test container/network.
|
|
|
|
## Verified Test Evidence (work packages 1-4)
|
|
|
|
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.
|
|
- `TestSourceInstallFixtures` runs the full work-package-3 flow on each
|
|
fixture from a clean state over the harness's real OpenSSH transport:
|
|
prerequisite install, verified Go 1.26 toolchain download/extraction, clone
|
|
of the public repository, resolution of its default branch, resolved-commit
|
|
record file, and a staging build that reports the resolved commit via
|
|
`--version`. A rerun succeeds, keeps the same branch, reuses the toolchain,
|
|
and leaves no `/tmp/rsmon-toolchain-*` temp dirs.
|
|
- `TestSourceInstallActivationFixtures` runs the full work-package-4 flow on
|
|
each fixture from a clean state: after the staging build the installer
|
|
atomically installs the binary (`/usr/local/bin/rsmon-worker`), env
|
|
(`/etc/rsmon-worker/worker.env`, mode 0600; config dir 0750), data dir
|
|
(`/var/lib/rsmon-worker`), and the detected init's service definition
|
|
(systemd unit on Ubuntu/Arch, OpenRC script on Alpine), then starts the
|
|
worker via the embedded supervisor (no init runs inside the fixtures) and
|
|
verifies exactly one worker process and a live `/healthz`. A rerun succeeds,
|
|
keeps a single process, rewrites the env deterministically, and leaves no
|
|
`.rsmon-backup`, `.new`, `/tmp/rsmon-worker-act.*`, or lock leftovers.
|
|
- `TestSourceInstallActivationFailureRollback` (Alpine) forces four rerun
|
|
failures and proves each restores the prior install byte-for-byte and
|
|
running: a dirty checkout (build failure), a sabotaged atomic swap
|
|
(activation failure), `WORKER_CLUSTER_ENABLED=true` without credentials (the
|
|
new process exits at boot; start failure), and `WORKER_HOST=255.255.255.255`
|
|
(the new process runs but `/healthz` is unreachable; health failure). In
|
|
every case the installed binary SHA-256, env, service definition, single
|
|
process, and liveness match the pre-failure state, and no backup, lock, or
|
|
temp files leak.
|
|
- `TestSourceInstallActivationNoStart` installs the full layout without
|
|
starting anything, and reruns stay idle.
|
|
- The targeted shell fixture tests (`TestActivateShell*`) execute the real
|
|
activation script against stub `systemctl`/`rc-service`/`rc-update`
|
|
implementations and cover the init-managed paths the Docker fixtures cannot
|
|
reach: systemd unit install/enable with a `systemctl is-active`-based health
|
|
loop (and no pid file), OpenRC install/enable with an `rc-service`-based
|
|
health loop, fresh-failure rollback disabling a newly-enabled unit, rerun
|
|
rollback restoring the prior unit and its enable state, stale-lock breaking,
|
|
and interrupted-run recovery from a leftover backup marker.
|
|
- The unit suite covers the per-step remote scripts, the activation script
|
|
(backup + recovery marker, activation lock, atomic install, rollback trap,
|
|
supervisor, pid/zombie handling, pid-belongs-to-binary checks, enable-state
|
|
restoration, secrets absent), OpenRC unit rendering (including env
|
|
export), option validation, branch/commit parsing, sudo wrapping (password
|
|
never in the command), and an in-process real-SSH orchestration flow with
|
|
failure paths for missing pinned branches, build failures, detection
|
|
failures, and activation/start/health failures.
|
|
- **Residual limitation (stated honestly):** live systemd and OpenRC cannot
|
|
run inside the Docker/OpenSSH harness (the container PID 1 is sshd), so the
|
|
init-managed supervisor paths are verified by the stub-based shell fixture
|
|
tests above rather than against a real init. The stub tools simulate unit
|
|
state and command flow, not real systemd/OpenRC unit semantics; a real
|
|
init-system smoke test on a booted host remains a follow-up.
|
|
|
|
Work package 5 (CI) evidence, recorded 2026-08-13:
|
|
|
|
- `.github/workflows/test-ssh.yml` runs `scripts/ci/test-ssh.sh` on pushes
|
|
to `master` and on manual `workflow_dispatch` only (never on
|
|
`pull_request`, because the fixtures execute the checked-out code inside
|
|
privileged Docker). Concurrency is scoped per ref
|
|
(`test-ssh-${{ gitea.ref }}`), the job token is `contents: read`, and
|
|
the script's loopback port-publishing probe passes on a host Docker
|
|
daemon while its `EXIT` cleanup leaves zero leftover containers,
|
|
networks, or fixture image tags.
|
|
- A full local `make test-ssh` run (the exact command the CI job executes)
|
|
passes the complete harness suite: `TestHarnessFixtures` (alpine/ubuntu/
|
|
arch), host-key mismatch and stability, teardown, `TestSourceInstallFixtures`
|
|
(work package 3 staging on all three distros), `TestSourceInstallActivationFixtures`
|
|
(work package 4 activation on all three), activation failure rollback,
|
|
`--no-start`, and the dirty-checkout preservation test. Pinned
|
|
`RSMON_TEST_DOCKER_DNS=1.1.1.1` was used because the local network's
|
|
default resolvers intermittently time out on `rocketgit.ru`.
|
|
- `TestSourceInstallDirtyCheckoutPreservesStaging` and the rollback test's
|
|
build-failure step now dirty the tracked tree deterministically (appending
|
|
a marker line to `Makefile`) instead of `git checkout master~1 -- Makefile`,
|
|
which depended on the last two commits differing and silently stopped
|
|
dirtying the tree once a commit did not touch that file.
|