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