feat(installer): build worker source over SSH
Все проверки выполнены успешно
CI / test (push) Successful in 3m13s
Docker / Build and publish worker image (push) Successful in 10m35s
Все проверки выполнены успешно
CI / test (push) Successful in 3m13s
Docker / Build and publish worker image (push) Successful in 10m35s
Этот коммит содержится в:
@@ -168,6 +168,14 @@ func (h *Harness) Addr() string {
|
||||
return net.JoinHostPort("127.0.0.1", strconv.Itoa(h.port))
|
||||
}
|
||||
|
||||
// Port returns the published host port of the container's SSH listener
|
||||
// (the host is always 127.0.0.1). 0 before Start.
|
||||
func (h *Harness) Port() int {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
return 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
|
||||
@@ -201,13 +209,10 @@ func (h *Harness) Start(ctx context.Context) error {
|
||||
// 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,
|
||||
)
|
||||
runArgs := []string{"run", "-d", "--name", h.container, "--network", h.network, "-p", "127.0.0.1::22"}
|
||||
runArgs = append(runArgs, dockerDNS()...)
|
||||
runArgs = append(runArgs, h.imageTag)
|
||||
out, err := dockerCmd(ctx, runArgs...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("start %s fixture container: %w", h.Fixture.Name, err)
|
||||
}
|
||||
@@ -244,6 +249,23 @@ func (h *Harness) Start(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// dockerDNS returns the `--dns` arguments to pin for fixture containers,
|
||||
// parsed from the comma-separated RSMON_TEST_DOCKER_DNS environment
|
||||
// variable. It is empty by default (Docker's embedded DNS). The override
|
||||
// exists so environments with flaky local resolvers can pin a reliable
|
||||
// upstream for the internet-facing installs (go.dev, rocketgit.ru,
|
||||
// proxy.golang.org), which would otherwise fail intermittently on DNS
|
||||
// timeouts.
|
||||
func dockerDNS() []string {
|
||||
var args []string
|
||||
for _, ns := range strings.Split(os.Getenv("RSMON_TEST_DOCKER_DNS"), ",") {
|
||||
if ns = strings.TrimSpace(ns); ns != "" {
|
||||
args = append(args, "--dns", ns)
|
||||
}
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -235,6 +235,21 @@ func TestSetDockerBin(t *testing.T) {
|
||||
SetDockerBin("")
|
||||
}
|
||||
|
||||
func TestDockerDNSOverride(t *testing.T) {
|
||||
t.Setenv("RSMON_TEST_DOCKER_DNS", "")
|
||||
if got := dockerDNS(); len(got) != 0 {
|
||||
t.Fatalf("dockerDNS() with empty env = %v, want none", got)
|
||||
}
|
||||
t.Setenv("RSMON_TEST_DOCKER_DNS", "8.8.8.8, 1.1.1.1")
|
||||
if got := dockerDNS(); len(got) != 4 || got[0] != "--dns" || got[1] != "8.8.8.8" || got[3] != "1.1.1.1" {
|
||||
t.Fatalf("dockerDNS() = %v", got)
|
||||
}
|
||||
t.Setenv("RSMON_TEST_DOCKER_DNS", " ,,")
|
||||
if got := dockerDNS(); len(got) != 0 {
|
||||
t.Fatalf("dockerDNS() with blank entries = %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package harness
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
"golang.org/x/crypto/ssh"
|
||||
"golang.org/x/crypto/ssh/knownhosts"
|
||||
|
||||
"rocketgit.ru/rsmon/worker/internal/installer"
|
||||
"rocketgit.ru/rsmon/worker/internal/sshinstall"
|
||||
)
|
||||
|
||||
@@ -266,6 +268,236 @@ func TestHarnessHostKeyStable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSourceInstallFixtures is the work-package-3 acceptance test: each
|
||||
// distro fixture starts clean (no Go, no worker source) and the real
|
||||
// installer executes the full source flow over SSH - prerequisite
|
||||
// install, verified Go toolchain download/extraction, clone/update of the
|
||||
// public repository, resolved branch/commit record, and a build to a
|
||||
// staging path. The running service and its config are deliberately not
|
||||
// installed (that is work package 4). A rerun exercises idempotency.
|
||||
//
|
||||
// Opt-in: set RSMON_TEST_DOCKER=1 (see make test-ssh).
|
||||
func TestSourceInstallFixtures(t *testing.T) {
|
||||
SkipUnlessEnabled(t)
|
||||
|
||||
for _, f := range Fixtures() {
|
||||
f := f
|
||||
t.Run(f.Name, func(t *testing.T) {
|
||||
h, err := New("source-"+f.Name, f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*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)
|
||||
}
|
||||
})
|
||||
|
||||
// Clean state: no Go toolchain, no source, no leftover
|
||||
// toolchain temp dirs.
|
||||
client, err := h.Dial()
|
||||
if err != nil {
|
||||
t.Fatalf("dial %s fixture: %v", f.Name, err)
|
||||
}
|
||||
probe, err := RunCommand(client, "command -v go || true; test ! -e /usr/local/go && echo NO_GO; test ! -e /opt/rsmon-worker-src && echo NO_SOURCE; ls /tmp | grep -q rsmon-toolchain && echo LEAK; ls /usr/local 2>/dev/null | grep -qE 'go-staging|go-backup' && echo LEAK; echo DONE")
|
||||
if err != nil {
|
||||
t.Fatalf("clean-state probe: %v", err)
|
||||
}
|
||||
clean := string(probe)
|
||||
if !strings.Contains(clean, "NO_GO") || !strings.Contains(clean, "NO_SOURCE") {
|
||||
t.Fatalf("fixture is not clean: %q", clean)
|
||||
}
|
||||
if strings.Contains(clean, "LEAK") {
|
||||
t.Fatalf("fixture has leftover toolchain temp dirs: %q", clean)
|
||||
}
|
||||
client.Close() //nolint:errcheck
|
||||
|
||||
opts := installer.SourceInstallOptions{
|
||||
SSHOptions: installer.SSHOptions{
|
||||
Host: "127.0.0.1",
|
||||
Port: h.Port(),
|
||||
User: f.UserOrDefault(),
|
||||
IdentityFile: testKeyPath(),
|
||||
KnownHostsFile: h.KnownHostsPath(),
|
||||
},
|
||||
}
|
||||
if repo := strings.TrimSpace(os.Getenv("RSMON_TEST_SOURCE_REPO")); repo != "" {
|
||||
opts.Repo = repo
|
||||
}
|
||||
if branch := strings.TrimSpace(os.Getenv("RSMON_TEST_SOURCE_BRANCH")); branch != "" {
|
||||
opts.Branch = branch
|
||||
}
|
||||
|
||||
res, err := installer.SourceInstall(opts)
|
||||
if err != nil {
|
||||
t.Fatalf("source install on %s: %v", f.Name, err)
|
||||
}
|
||||
t.Logf("%s: %s -> branch=%s commit=%s staged=%s", f.Name, res.Detection.Summarize(),
|
||||
res.ResolvedBranch, res.ResolvedCommit, res.StageBinary)
|
||||
|
||||
if res.Detection.Distro != f.Distro || res.Detection.PackageManager != f.Pkg || res.Detection.InitSystem != f.Init {
|
||||
t.Fatalf("detection = %+v, want %s/%s/%s", res.Detection, f.Distro, f.Pkg, f.Init)
|
||||
}
|
||||
if res.GoArch != "linux-"+strings.TrimPrefix(res.Plan.Toolchain.Arch, "linux-") {
|
||||
t.Fatalf("resolved arch = %q, want %q", res.GoArch, res.Plan.Toolchain.Arch)
|
||||
}
|
||||
if res.ResolvedBranch == "" || len(res.ResolvedCommit) != 40 {
|
||||
t.Fatalf("resolved branch/commit incomplete: %s @ %s", res.ResolvedBranch, res.ResolvedCommit)
|
||||
}
|
||||
if res.StageBinary == "" || res.RecordFile == "" || res.ToolchainDir == "" {
|
||||
t.Fatalf("result paths incomplete: %+v", res)
|
||||
}
|
||||
|
||||
client, err = h.Dial()
|
||||
if err != nil {
|
||||
t.Fatalf("redial: %v", err)
|
||||
}
|
||||
defer client.Close() //nolint:errcheck
|
||||
assertSourceInstallState(t, client, f.Name, res)
|
||||
|
||||
// Idempotent rerun: succeeds, resolves the same branch,
|
||||
// reuses the toolchain, and leaks no temp files.
|
||||
res2, err := installer.SourceInstall(opts)
|
||||
if err != nil {
|
||||
t.Fatalf("source install rerun on %s: %v", f.Name, err)
|
||||
}
|
||||
if res2.ResolvedBranch != res.ResolvedBranch || len(res2.ResolvedCommit) != 40 {
|
||||
t.Fatalf("rerun resolved = %s @ %s, want branch %s", res2.ResolvedBranch, res2.ResolvedCommit, res.ResolvedBranch)
|
||||
}
|
||||
assertSourceInstallState(t, client, f.Name, res2)
|
||||
if out, err := RunCommand(client, "leak=0; ls /tmp | grep -q rsmon-toolchain && leak=1; ls /usr/local 2>/dev/null | grep -qE 'go-staging|go-backup' && leak=1; [ \"$leak\" -eq 1 ] && echo LEAK || echo CLEAN"); err != nil || strings.TrimSpace(string(out)) != "CLEAN" {
|
||||
t.Fatalf("toolchain temp dirs leaked after rerun: %q, %v", out, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// assertSourceInstallState verifies the remote side-effects of a source
|
||||
// install: the toolchain reports the pinned version, the staging binary
|
||||
// exists and reports the resolved commit, and the record file carries
|
||||
// the resolved branch and commit.
|
||||
func assertSourceInstallState(t *testing.T, client *ssh.Client, name string, res *installer.SourceInstallResult) {
|
||||
t.Helper()
|
||||
|
||||
out, err := RunCommand(client, res.ToolchainDir+"/bin/go version")
|
||||
if err != nil {
|
||||
t.Fatalf("%s: toolchain missing: %v", name, err)
|
||||
}
|
||||
if !strings.Contains(string(out), "go"+res.Plan.Toolchain.Version) {
|
||||
t.Fatalf("%s: toolchain version = %q, want go%s", name, out, res.Plan.Toolchain.Version)
|
||||
}
|
||||
|
||||
out, err = RunCommand(client, "test -x "+shellQuote(res.StageBinary)+" && echo BUILT")
|
||||
if err != nil || !strings.Contains(string(out), "BUILT") {
|
||||
t.Fatalf("%s: staging binary not present at %s: %q, %v", name, res.StageBinary, out, err)
|
||||
}
|
||||
out, err = RunCommand(client, shellQuote(res.StageBinary)+" --version")
|
||||
if err != nil {
|
||||
t.Fatalf("%s: staging binary --version: %v", name, err)
|
||||
}
|
||||
if !strings.Contains(string(out), "commit="+res.ResolvedCommit[:12]) {
|
||||
t.Fatalf("%s: staging binary reports commit %q, want short %s", name, out, res.ResolvedCommit[:12])
|
||||
}
|
||||
|
||||
record, err := RunCommand(client, "cat "+shellQuote(res.RecordFile))
|
||||
if err != nil {
|
||||
t.Fatalf("%s: read record file: %v", name, err)
|
||||
}
|
||||
if !strings.Contains(string(record), "branch="+res.ResolvedBranch) || !strings.Contains(string(record), "commit="+res.ResolvedCommit) {
|
||||
t.Fatalf("%s: record file = %q, want branch=%s commit=%s", name, record, res.ResolvedBranch, res.ResolvedCommit)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSourceInstallDirtyCheckoutPreservesStaging is the work-package-3
|
||||
// failure-atomicity test: after a successful install, dirtying the
|
||||
// tracked working tree makes the next checkout fail closed. The rerun
|
||||
// must report the checkout error without ever reaching the build step,
|
||||
// leaving the previous staging binary and commit record byte-for-byte
|
||||
// unchanged, and without leaking toolchain staging/backup directories.
|
||||
func TestSourceInstallDirtyCheckoutPreservesStaging(t *testing.T) {
|
||||
SkipUnlessEnabled(t)
|
||||
|
||||
f := Fixtures()[0] // alpine is the smallest fixture
|
||||
h, err := New("dirty-"+f.Name, f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*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)
|
||||
}
|
||||
})
|
||||
|
||||
opts := installer.SourceInstallOptions{
|
||||
SSHOptions: installer.SSHOptions{
|
||||
Host: "127.0.0.1",
|
||||
Port: h.Port(),
|
||||
User: f.UserOrDefault(),
|
||||
IdentityFile: testKeyPath(),
|
||||
KnownHostsFile: h.KnownHostsPath(),
|
||||
},
|
||||
}
|
||||
if repo := strings.TrimSpace(os.Getenv("RSMON_TEST_SOURCE_REPO")); repo != "" {
|
||||
opts.Repo = repo
|
||||
}
|
||||
res, err := installer.SourceInstall(opts)
|
||||
if err != nil {
|
||||
t.Fatalf("initial source install: %v", err)
|
||||
}
|
||||
|
||||
client, err := h.Dial()
|
||||
if err != nil {
|
||||
t.Fatalf("dial: %v", err)
|
||||
}
|
||||
defer client.Close() //nolint:errcheck
|
||||
|
||||
// Dirty a tracked file so the rerun's checkout refuses to proceed.
|
||||
// Makefile differs between master and master~1 (unlike go.mod).
|
||||
if _, err := RunCommand(client, "git -C "+shellQuote(res.Plan.BuildDir)+" checkout -q master~1 -- Makefile"); err != nil {
|
||||
t.Fatalf("dirty the working tree: %v", err)
|
||||
}
|
||||
beforeBinary, err := RunCommand(client, "sha256sum "+shellQuote(res.StageBinary))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
beforeRecord, err := RunCommand(client, "cat "+shellQuote(res.RecordFile))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := installer.SourceInstall(opts); err == nil || !strings.Contains(err.Error(), "check out branch") {
|
||||
t.Fatalf("rerun err = %v, want checkout failure on dirty tree", err)
|
||||
}
|
||||
|
||||
afterBinary, err := RunCommand(client, "sha256sum "+shellQuote(res.StageBinary))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
afterRecord, err := RunCommand(client, "cat "+shellQuote(res.RecordFile))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !bytes.Equal(bytes.TrimSpace(beforeBinary), bytes.TrimSpace(afterBinary)) {
|
||||
t.Fatalf("staging binary changed after failed rerun:\nbefore: %s\nafter: %s", beforeBinary, afterBinary)
|
||||
}
|
||||
if !bytes.Equal(bytes.TrimSpace(beforeRecord), bytes.TrimSpace(afterRecord)) {
|
||||
t.Fatalf("commit record changed after failed rerun:\nbefore: %s\nafter: %s", beforeRecord, afterRecord)
|
||||
}
|
||||
if out, err := RunCommand(client, "leak=0; ls /tmp | grep -q rsmon-toolchain && leak=1; ls /usr/local 2>/dev/null | grep -qE 'go-staging|go-backup' && leak=1; [ \"$leak\" -eq 1 ] && echo LEAK || echo CLEAN"); err != nil || strings.TrimSpace(string(out)) != "CLEAN" {
|
||||
t.Fatalf("toolchain staging leaked after failed rerun: %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 {
|
||||
|
||||
Ссылка в новой задаче
Block a user