test(installer): add OpenSSH distro harness
Все проверки выполнены успешно
CI / test (push) Successful in 3m33s
Docker / Build and publish worker image (push) Successful in 18m37s
Все проверки выполнены успешно
CI / test (push) Successful in 3m33s
Docker / Build and publish worker image (push) Successful in 18m37s
Этот коммит содержится в:
17
Makefile
17
Makefile
@@ -4,17 +4,22 @@ COMMIT ?= $(shell git rev-parse --short=12 HEAD 2>/dev/null || printf unknown)
|
||||
BUILD_DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
LDFLAGS := -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.buildDate=$(BUILD_DATE)
|
||||
|
||||
.PHONY: build test check image clean
|
||||
.PHONY: build test test-ssh check image clean
|
||||
|
||||
build:
|
||||
mkdir -p bin
|
||||
CGO_ENABLED=0 go build -trimpath -ldflags="$(LDFLAGS)" -o $(BINARY) ./cmd/rsmon-worker
|
||||
|
||||
test:
|
||||
RSMON_ENV=test CWD=$(CURDIR) go test \
|
||||
# RSMON_TEST_DOCKER=0 pins this target to the non-Docker unit run even
|
||||
# if a developer has the opt-in flag exported in their environment;
|
||||
# only the explicit `make test-ssh` target starts containers.
|
||||
RSMON_ENV=test CWD=$(CURDIR) RSMON_TEST_DOCKER=0 go test \
|
||||
./cmd/rsmon-worker \
|
||||
./internal/distworker \
|
||||
./internal/installer \
|
||||
./internal/installer/harness \
|
||||
./internal/sshinstall \
|
||||
./internal/webapp \
|
||||
./internal/compose \
|
||||
./internal/workercluster \
|
||||
@@ -33,6 +38,14 @@ test:
|
||||
./checks/cwhois \
|
||||
./checks/llmhttp
|
||||
|
||||
# test-ssh runs the Docker/OpenSSH source-install harness tests against
|
||||
# real Alpine, Ubuntu, and Arch fixtures. Requires a working Docker
|
||||
# daemon; the fixtures pull/start containers, so this is opt-in and is
|
||||
# never part of the default `make test` run.
|
||||
test-ssh:
|
||||
RSMON_ENV=test CWD=$(CURDIR) RSMON_TEST_DOCKER=1 go test \
|
||||
-v -timeout 30m -count=1 ./internal/installer/harness
|
||||
|
||||
check:
|
||||
go mod tidy
|
||||
git diff --exit-code -- go.mod go.sum
|
||||
|
||||
@@ -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
Обычный файл
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
Обычный файл
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())
|
||||
}
|
||||
}
|
||||
293
internal/installer/harness/integration_test.go
Обычный файл
293
internal/installer/harness/integration_test.go
Обычный файл
@@ -0,0 +1,293 @@
|
||||
package harness
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
"golang.org/x/crypto/ssh/knownhosts"
|
||||
|
||||
"rocketgit.ru/rsmon/worker/internal/sshinstall"
|
||||
)
|
||||
|
||||
// TestHarnessFixtures is the work-package-1 acceptance test: each distro
|
||||
// fixture starts a real OpenSSH container, the harness waits for real
|
||||
// network readiness, and the installer's Go SSH client connects, runs
|
||||
// commands, and tears the environment down.
|
||||
//
|
||||
// Opt-in: set RSMON_TEST_DOCKER=1 (see make test-ssh).
|
||||
func TestHarnessFixtures(t *testing.T) {
|
||||
SkipUnlessEnabled(t)
|
||||
|
||||
for _, f := range Fixtures() {
|
||||
f := f
|
||||
t.Run(f.Name, func(t *testing.T) {
|
||||
h, err := New("fixture-"+f.Name, f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute)
|
||||
defer cancel()
|
||||
if err := h.Start(ctx); err != nil {
|
||||
t.Fatalf("start %s fixture: %v", f.Name, err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := h.Stop(); err != nil {
|
||||
t.Errorf("stop %s fixture: %v", f.Name, err)
|
||||
}
|
||||
})
|
||||
|
||||
client, err := h.Dial()
|
||||
if err != nil {
|
||||
t.Fatalf("dial %s fixture: %v", f.Name, err)
|
||||
}
|
||||
defer client.Close() //nolint:errcheck
|
||||
|
||||
// 1. SSH command execution is real: round-trip a nonce.
|
||||
nonce := fmt.Sprintf("RSMON_SSH_OK_%d", time.Now().UnixNano())
|
||||
out, err := RunCommand(client, "printf '%s' "+shellQuote(nonce))
|
||||
if err != nil {
|
||||
t.Fatalf("ssh round trip: %v", err)
|
||||
}
|
||||
if strings.TrimSpace(string(out)) != nonce {
|
||||
t.Fatalf("ssh round trip = %q, want %q", out, nonce)
|
||||
}
|
||||
|
||||
// 2. The clean target has no Go toolchain and no worker source.
|
||||
out, err = RunCommand(client, "command -v go || true; test ! -e /usr/local/go && echo NO_GO; test ! -e /opt/rsmon-worker-src && echo NO_SOURCE")
|
||||
if err != nil {
|
||||
t.Fatalf("clean-state probe: %v", err)
|
||||
}
|
||||
clean := string(out)
|
||||
if strings.Contains(clean, "/go") && !strings.Contains(clean, "NO_GO") {
|
||||
t.Fatalf("fixture unexpectedly has Go installed: %q", clean)
|
||||
}
|
||||
if !strings.Contains(clean, "NO_SOURCE") {
|
||||
t.Fatalf("fixture unexpectedly has worker source: %q", clean)
|
||||
}
|
||||
|
||||
// 3. Distro detection over the real session matches the fixture.
|
||||
out, err = RunCommand(client, "cat /etc/os-release")
|
||||
if err != nil {
|
||||
t.Fatalf("read os-release: %v", err)
|
||||
}
|
||||
d := sshinstall.Detect(string(out), makeProber(client))
|
||||
if d.Distro != f.Distro {
|
||||
t.Fatalf("detected distro = %q, want %q (%s)", d.Distro, f.Distro, d.Summarize())
|
||||
}
|
||||
if d.PackageManager != f.Pkg {
|
||||
t.Fatalf("detected package manager = %q, want %q", d.PackageManager, f.Pkg)
|
||||
}
|
||||
if d.InitSystem != f.Init {
|
||||
t.Fatalf("detected init = %q, want %q", d.InitSystem, f.Init)
|
||||
}
|
||||
t.Logf("%s: %s", f.Name, d.Summarize())
|
||||
|
||||
// 4. A full source plan resolves for the detected host,
|
||||
// including the pinned Go toolchain for its real arch.
|
||||
out, err = RunCommand(client, "uname -m")
|
||||
if err != nil {
|
||||
t.Fatalf("uname -m: %v", err)
|
||||
}
|
||||
goarch, err := sshinstall.GoArch(strings.TrimSpace(string(out)))
|
||||
if err != nil {
|
||||
t.Fatalf("GoArch(%q): %v", out, err)
|
||||
}
|
||||
plan, err := sshinstall.PlanSource(d, sshinstall.SourceOptions{UnameM: strings.TrimSpace(string(out))})
|
||||
if err != nil {
|
||||
t.Fatalf("PlanSource: %v", err)
|
||||
}
|
||||
if plan.Toolchain.Arch != "linux-"+goarch {
|
||||
t.Fatalf("plan toolchain %q does not match detected arch %q", plan.Toolchain.Arch, goarch)
|
||||
}
|
||||
if len(plan.Packages) == 0 || plan.Repo == "" {
|
||||
t.Fatalf("incomplete plan: %+v", plan)
|
||||
}
|
||||
steps := plan.Steps()
|
||||
if len(steps) != 6 {
|
||||
t.Fatalf("plan steps = %d, want 6", len(steps))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHarnessHostKeyMismatch verifies the security gate: a dial against
|
||||
// a known_hosts entry carrying a different host key must fail before any
|
||||
// command can run.
|
||||
func TestHarnessHostKeyMismatch(t *testing.T) {
|
||||
SkipUnlessEnabled(t)
|
||||
|
||||
f := Fixtures()[0] // alpine is the smallest fixture
|
||||
h, err := New("hostkey-"+f.Name, f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute)
|
||||
defer cancel()
|
||||
if err := h.Start(ctx); err != nil {
|
||||
t.Fatalf("start fixture: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := h.Stop(); err != nil {
|
||||
t.Errorf("stop fixture: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
// Build a known_hosts entry with a different (freshly generated) key.
|
||||
_, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
signer, err := ssh.NewSignerFromKey(priv)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wrongFile := filepath.Join(t.TempDir(), "known_hosts")
|
||||
line := knownhosts.Line([]string{h.Addr()}, signer.PublicKey())
|
||||
if err := os.WriteFile(wrongFile, []byte(line+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
keyBytes, err := os.ReadFile(testKeyPath())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keySigner, err := ssh.ParsePrivateKey(keyBytes)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
callback, err := knownhosts.New(wrongFile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config := &ssh.ClientConfig{
|
||||
User: f.UserOrDefault(),
|
||||
Auth: []ssh.AuthMethod{ssh.PublicKeys(keySigner)},
|
||||
HostKeyCallback: callback,
|
||||
Timeout: 15 * time.Second,
|
||||
}
|
||||
client, err := ssh.Dial("tcp", h.Addr(), config)
|
||||
if err == nil {
|
||||
client.Close() //nolint:errcheck
|
||||
t.Fatal("dial with a mismatched host key succeeded")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "knownhosts") && !strings.Contains(err.Error(), "key") {
|
||||
t.Fatalf("host-key mismatch error = %v, want a key/host verification failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHarnessTeardown verifies Stop reliably removes the container, the
|
||||
// dedicated network, the per-instance fixture image tag (never a shared
|
||||
// base image), and the temp known_hosts directory.
|
||||
func TestHarnessTeardown(t *testing.T) {
|
||||
SkipUnlessEnabled(t)
|
||||
|
||||
f := Fixtures()[0]
|
||||
h, err := New("teardown-"+f.Name, f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute)
|
||||
defer cancel()
|
||||
if err := h.Start(ctx); err != nil {
|
||||
t.Fatalf("start fixture: %v", err)
|
||||
}
|
||||
if h.ContainerID() == "" {
|
||||
t.Fatal("container id empty after start")
|
||||
}
|
||||
if h.KnownHostsPath() == "" {
|
||||
t.Fatal("known_hosts not created after start")
|
||||
}
|
||||
knownHostsDir := filepath.Dir(h.KnownHostsPath())
|
||||
if err := h.Stop(); err != nil {
|
||||
t.Fatalf("stop: %v", err)
|
||||
}
|
||||
if _, err := h.DockerCmd(ctx, "inspect", h.ContainerName()); err == nil {
|
||||
t.Fatal("container still present after Stop")
|
||||
}
|
||||
if _, err := h.DockerCmd(ctx, "network", "inspect", h.NetworkName()); err == nil {
|
||||
t.Fatal("network still present after Stop")
|
||||
}
|
||||
if _, err := h.DockerCmd(ctx, "image", "inspect", h.ImageTag()); err == nil {
|
||||
t.Fatalf("fixture image tag %q still present after Stop", h.ImageTag())
|
||||
}
|
||||
if _, err := os.Stat(knownHostsDir); !os.IsNotExist(err) {
|
||||
t.Fatalf("known_hosts temp dir %q still present after Stop: %v", knownHostsDir, err)
|
||||
}
|
||||
if h.KnownHostsPath() != "" {
|
||||
t.Fatalf("KnownHostsPath = %q after Stop, want empty", h.KnownHostsPath())
|
||||
}
|
||||
// Stop is idempotent.
|
||||
if err := h.Stop(); err != nil {
|
||||
t.Fatalf("second stop: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHarnessHostKeyStableAcrossDial ensures the host key captured at
|
||||
// readiness is the one verified on every later dial, so a successful
|
||||
// Dial is proof of verified, real SSH transport.
|
||||
func TestHarnessHostKeyStable(t *testing.T) {
|
||||
SkipUnlessEnabled(t)
|
||||
|
||||
f := Fixtures()[0]
|
||||
h, err := New("key-"+f.Name, f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Minute)
|
||||
defer cancel()
|
||||
if err := h.Start(ctx); err != nil {
|
||||
t.Fatalf("start fixture: %v", err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := h.Stop(); err != nil {
|
||||
t.Errorf("stop fixture: %v", err)
|
||||
}
|
||||
})
|
||||
client, err := h.Dial()
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
client.Close() //nolint:errcheck
|
||||
client, err = h.Dial()
|
||||
if err != nil {
|
||||
t.Fatalf("second dial: %v", err)
|
||||
}
|
||||
defer client.Close() //nolint:errcheck
|
||||
if out, err := RunCommand(client, "echo VERIFIED"); err != nil || strings.TrimSpace(string(out)) != "VERIFIED" {
|
||||
t.Fatalf("verified session command = %q, %v", out, err)
|
||||
}
|
||||
}
|
||||
|
||||
// makeProber builds an sshinstall.FileProber over a live SSH session.
|
||||
func makeProber(client *ssh.Client) sshinstall.FileProber {
|
||||
return func(paths ...string) map[string]bool {
|
||||
// The trailing `; true` keeps the shell exit status 0: the
|
||||
// last `[ -e "$p" ]` in the loop would otherwise set exit 1
|
||||
// when the final path is absent (as on Arch), which is not an
|
||||
// error for a probe.
|
||||
expr := "for p in " + strings.Join(paths, " ") + "; do [ -e \"$p\" ] && printf '%s\\n' \"$p\"; done; true"
|
||||
out, err := RunCommand(client, expr)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
present := make(map[string]bool, len(paths))
|
||||
for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
|
||||
if line = strings.TrimSpace(line); line != "" {
|
||||
present[line] = true
|
||||
}
|
||||
}
|
||||
return present
|
||||
}
|
||||
}
|
||||
|
||||
func shellQuote(value string) string {
|
||||
return "'" + strings.ReplaceAll(value, "'", "'\\''") + "'"
|
||||
}
|
||||
21
internal/installer/harness/testdata/fixtures/alpine/Dockerfile
поставляемый
Обычный файл
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
поставляемый
Обычный файл
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
поставляемый
Обычный файл
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
поставляемый
Обычный файл
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
поставляемый
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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
Обычный файл
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Ссылка в новой задаче
Block a user