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
Этот коммит содержится в:
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
|
||||
Ссылка в новой задаче
Block a user