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
Этот коммит содержится в:
351
internal/installer/sourceinstall_ssh_test.go
Обычный файл
351
internal/installer/sourceinstall_ssh_test.go
Обычный файл
@@ -0,0 +1,351 @@
|
||||
package installer
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
|
||||
"rocketgit.ru/rsmon/worker/internal/sshinstall"
|
||||
)
|
||||
|
||||
const (
|
||||
fakeSSHPassword = "fake-ssh-password"
|
||||
fakeCommitHex = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef"
|
||||
fakeOsRelease = "ID=ubuntu\nNAME=\"Ubuntu\"\nVERSION_ID=24.04\n"
|
||||
)
|
||||
|
||||
// fakeSSHServer is a minimal in-process SSH server that simulates a
|
||||
// remote Linux host for the SourceInstall orchestration tests. It uses
|
||||
// real golang.org/x/crypto/ssh transport (no mocked SSH library), so the
|
||||
// executor's dial, session, exec, and stdout/stderr plumbing is
|
||||
// exercised end to end, and it records every command it ran.
|
||||
type fakeSSHServer struct {
|
||||
addr string
|
||||
onExec func(command string) (stdout, stderr string, code int)
|
||||
mu sync.Mutex
|
||||
commands []string
|
||||
}
|
||||
|
||||
func startFakeSSHServer(t *testing.T, onExec func(command string) (string, string, int)) *fakeSSHServer {
|
||||
t.Helper()
|
||||
_, priv, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
signer, err := ssh.NewSignerFromKey(priv)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
config := &ssh.ServerConfig{
|
||||
PasswordCallback: func(_ ssh.ConnMetadata, pass []byte) (*ssh.Permissions, error) {
|
||||
if string(pass) == fakeSSHPassword {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("password rejected")
|
||||
},
|
||||
}
|
||||
config.AddHostKey(signer)
|
||||
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = ln.Close() })
|
||||
|
||||
srv := &fakeSSHServer{addr: ln.Addr().String(), onExec: onExec}
|
||||
go func() {
|
||||
for {
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go srv.handleConn(conn, config)
|
||||
}
|
||||
}()
|
||||
return srv
|
||||
}
|
||||
|
||||
func (s *fakeSSHServer) Port() int {
|
||||
_, port, err := net.SplitHostPort(s.addr)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
p := 0
|
||||
fmt.Sscanf(port, "%d", &p)
|
||||
return p
|
||||
}
|
||||
|
||||
func (s *fakeSSHServer) Commands() []string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return append([]string(nil), s.commands...)
|
||||
}
|
||||
|
||||
func (s *fakeSSHServer) handleConn(conn net.Conn, config *ssh.ServerConfig) {
|
||||
sconn, chans, reqs, err := ssh.NewServerConn(conn, config)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer sconn.Close() //nolint:errcheck
|
||||
go ssh.DiscardRequests(reqs)
|
||||
for newChannel := range chans {
|
||||
if newChannel.ChannelType() != "session" {
|
||||
_ = newChannel.Reject(ssh.UnknownChannelType, "unknown channel type")
|
||||
continue
|
||||
}
|
||||
channel, requests, err := newChannel.Accept()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
go func() {
|
||||
defer channel.Close()
|
||||
s.handleSession(channel, requests)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *fakeSSHServer) handleSession(channel ssh.Channel, requests <-chan *ssh.Request) {
|
||||
for req := range requests {
|
||||
if req.Type != "exec" {
|
||||
if req.WantReply {
|
||||
_ = req.Reply(false, nil)
|
||||
}
|
||||
continue
|
||||
}
|
||||
var payload struct{ Command string }
|
||||
if err := ssh.Unmarshal(req.Payload, &payload); err != nil {
|
||||
if req.WantReply {
|
||||
_ = req.Reply(false, nil)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if req.WantReply {
|
||||
_ = req.Reply(true, nil)
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.commands = append(s.commands, payload.Command)
|
||||
s.mu.Unlock()
|
||||
s.execCommand(channel, payload.Command)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (s *fakeSSHServer) execCommand(channel ssh.Channel, command string) {
|
||||
handler := s.onExec
|
||||
if handler == nil {
|
||||
handler = defaultFakeExec
|
||||
}
|
||||
stdout, stderr, code := handler(command)
|
||||
_, _ = channel.Write([]byte(stdout))
|
||||
_, _ = channel.Stderr().Write([]byte(stderr))
|
||||
_, _ = channel.SendRequest("exit-status", false, ssh.Marshal(struct{ Status uint32 }{uint32(code)}))
|
||||
_ = channel.CloseWrite()
|
||||
}
|
||||
|
||||
// defaultFakeExec simulates a minimal Linux host: it answers os-release,
|
||||
// uname, the init-marker file probe, origin URL, branch resolution, and
|
||||
// rev-parse, and accepts every install step. A pinned-branch existence
|
||||
// check (show-ref) fails by default so the missing-branch path is
|
||||
// exercised without extra setup.
|
||||
func defaultFakeExec(command string) (string, string, int) {
|
||||
switch {
|
||||
case strings.Contains(command, "cat /etc/os-release"):
|
||||
return fakeOsRelease, "", 0
|
||||
case strings.Contains(command, "uname -m"):
|
||||
return "x86_64\n", "", 0
|
||||
case strings.Contains(command, "[ -e"):
|
||||
return "/usr/lib/systemd/system\n", "", 0
|
||||
case strings.Contains(command, "remote.origin.url"):
|
||||
return sshinstall.DefaultRepo + "\n", "", 0
|
||||
case strings.Contains(command, "symbolic-ref"):
|
||||
return "origin/master\n", "", 0
|
||||
case strings.Contains(command, "rev-parse HEAD"):
|
||||
return fakeCommitHex + "\n", "", 0
|
||||
case strings.Contains(command, "show-ref"):
|
||||
return "", "branch not found", 1
|
||||
default:
|
||||
return "", "", 0
|
||||
}
|
||||
}
|
||||
|
||||
func testSSHOptions(port int) SourceInstallOptions {
|
||||
return SourceInstallOptions{
|
||||
SSHOptions: SSHOptions{
|
||||
Host: "127.0.0.1",
|
||||
Port: port,
|
||||
User: "root",
|
||||
Password: fakeSSHPassword,
|
||||
InsecureHostKey: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceInstallSSHFlow(t *testing.T) {
|
||||
srv := startFakeSSHServer(t, nil)
|
||||
res, err := SourceInstall(testSSHOptions(srv.Port()))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if res.Detection.Distro != sshinstall.DistroUbuntu || res.Detection.PackageManager != sshinstall.PkgApt {
|
||||
t.Fatalf("detection = %+v", res.Detection)
|
||||
}
|
||||
if res.Detection.InitSystem != sshinstall.InitSystemd {
|
||||
t.Fatalf("init detection = %q, want systemd", res.Detection.InitSystem)
|
||||
}
|
||||
if res.Plan.Toolchain.Arch != "linux-amd64" || res.Plan.Toolchain.Version != "1.26.0" {
|
||||
t.Fatalf("toolchain = %+v", res.Plan.Toolchain)
|
||||
}
|
||||
if len(res.Plan.Packages) == 0 || res.Plan.Repo == "" {
|
||||
t.Fatalf("plan = %+v", res.Plan)
|
||||
}
|
||||
if res.ResolvedBranch != "master" || res.ResolvedCommit != fakeCommitHex {
|
||||
t.Fatalf("resolved = %s @ %s", res.ResolvedBranch, res.ResolvedCommit)
|
||||
}
|
||||
if res.ToolchainDir != "/usr/local/go" || res.StageBinary != "/opt/rsmon-worker-src/rsmon-worker" ||
|
||||
res.RecordFile != "/opt/rsmon-worker-src/rsmon-worker.commit" {
|
||||
t.Fatalf("paths = %+v", res)
|
||||
}
|
||||
|
||||
commands := srv.Commands()
|
||||
var joined strings.Builder
|
||||
for _, c := range commands {
|
||||
joined.WriteString(c)
|
||||
joined.WriteString("\n")
|
||||
}
|
||||
// Marker substrings that survive the nested `sh -c '<script>'`
|
||||
// quoting; exact quoting of each script is asserted by the unit
|
||||
// tests (TestPackageScript, TestToolchainScript, ...).
|
||||
for _, want := range []string{
|
||||
"cat /etc/os-release",
|
||||
"uname -m",
|
||||
"apt-get update",
|
||||
"apt-get install -y --no-install-recommends",
|
||||
"sha256sum -c -",
|
||||
"git clone",
|
||||
"git -C",
|
||||
"fetch --prune origin",
|
||||
"symbolic-ref --short refs/remotes/origin/HEAD",
|
||||
"checkout -q -B",
|
||||
"rev-parse HEAD",
|
||||
"branch=%s\\ncommit=%s\\n",
|
||||
fakeCommitHex,
|
||||
"CGO_ENABLED=0",
|
||||
"build -trimpath",
|
||||
"GOMODCACHE",
|
||||
"mv -f",
|
||||
} {
|
||||
if !strings.Contains(joined.String(), want) {
|
||||
t.Fatalf("recorded commands missing %q:\n%s", want, joined.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Ordering: prerequisites before toolchain before source before build.
|
||||
idx := func(sub string) int {
|
||||
for i, c := range commands {
|
||||
if strings.Contains(c, sub) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
t.Fatalf("command %q not found in %v", sub, commands)
|
||||
return -1
|
||||
}
|
||||
if !(idx("apt-get install") < idx("sha256sum") && idx("sha256sum") < idx("git clone") &&
|
||||
idx("git clone") < idx("rev-parse") && idx("rev-parse") < idx("-trimpath") &&
|
||||
idx("-trimpath") < idx("branch=%s")) {
|
||||
t.Fatalf("step order wrong: %v", commands)
|
||||
}
|
||||
|
||||
// Adaptive resolution means no pinned-branch existence check ran.
|
||||
if strings.Contains(joined.String(), "show-ref") {
|
||||
t.Fatalf("show-ref ran despite branch resolution:\n%s", joined.String())
|
||||
}
|
||||
if strings.Contains(joined.String(), fakeSSHPassword) {
|
||||
t.Fatal("SSH password leaked into a remote command")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceInstallSSHRejectsMissingPinnedBranch(t *testing.T) {
|
||||
srv := startFakeSSHServer(t, nil)
|
||||
opts := testSSHOptions(srv.Port())
|
||||
opts.Branch = "main"
|
||||
_, err := SourceInstall(opts)
|
||||
if err == nil || !strings.Contains(err.Error(), `branch "main" does not exist`) {
|
||||
t.Fatalf("err = %v, want missing-branch error", err)
|
||||
}
|
||||
if commands := srv.Commands(); !strings.Contains(strings.Join(commands, "\n"), "show-ref") {
|
||||
t.Fatalf("pinned branch existence was not verified: %v", commands)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceInstallSSHBuildFailure(t *testing.T) {
|
||||
srv := startFakeSSHServer(t, func(command string) (string, string, int) {
|
||||
if strings.Contains(command, "-trimpath") {
|
||||
return "", "build exploded", 1
|
||||
}
|
||||
return defaultFakeExec(command)
|
||||
})
|
||||
_, err := SourceInstall(testSSHOptions(srv.Port()))
|
||||
if err == nil || !strings.Contains(err.Error(), "build worker binary") || !strings.Contains(err.Error(), "build exploded") {
|
||||
t.Fatalf("err = %v, want bounded build failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceInstallSSHDetectionFailure(t *testing.T) {
|
||||
srv := startFakeSSHServer(t, func(command string) (string, string, int) {
|
||||
if strings.Contains(command, "os-release") {
|
||||
return "", "os-release unreadable", 1
|
||||
}
|
||||
return defaultFakeExec(command)
|
||||
})
|
||||
_, err := SourceInstall(testSSHOptions(srv.Port()))
|
||||
if err == nil || !strings.Contains(err.Error(), "/etc/os-release") {
|
||||
t.Fatalf("err = %v, want detection failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSourceInstallSSHCheckoutFailureNotMasked proves the fail-closed
|
||||
// contract: a failed checkout (e.g. a dirty working tree) surfaces as an
|
||||
// error and never reaches the build or commit-record steps, so the
|
||||
// previous staging binary and record are preserved.
|
||||
func TestSourceInstallSSHCheckoutFailureNotMasked(t *testing.T) {
|
||||
srv := startFakeSSHServer(t, func(command string) (string, string, int) {
|
||||
if strings.Contains(command, "checkout -q -B") {
|
||||
return "", "your local changes to the following files would be overwritten by checkout", 1
|
||||
}
|
||||
return defaultFakeExec(command)
|
||||
})
|
||||
_, err := SourceInstall(testSSHOptions(srv.Port()))
|
||||
if err == nil || !strings.Contains(err.Error(), "check out branch") {
|
||||
t.Fatalf("err = %v, want checkout failure", err)
|
||||
}
|
||||
joined := strings.Join(srv.Commands(), "\n")
|
||||
if strings.Contains(joined, "-trimpath") || strings.Contains(joined, "branch=%s") {
|
||||
t.Fatalf("build or record ran after checkout failed:\n%s", joined)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSourceInstallSSHCommandTimeout verifies each remote command is
|
||||
// bounded by SessionTimeout and the run reports it.
|
||||
func TestSourceInstallSSHCommandTimeout(t *testing.T) {
|
||||
srv := startFakeSSHServer(t, func(command string) (string, string, int) {
|
||||
if strings.Contains(command, "uname -m") {
|
||||
time.Sleep(5 * time.Second)
|
||||
return "x86_64\n", "", 0
|
||||
}
|
||||
return defaultFakeExec(command)
|
||||
})
|
||||
opts := testSSHOptions(srv.Port())
|
||||
opts.SessionTimeout = 300 * time.Millisecond
|
||||
_, err := SourceInstall(opts)
|
||||
if err == nil || !strings.Contains(err.Error(), "timed out after") {
|
||||
t.Fatalf("err = %v, want command timeout", err)
|
||||
}
|
||||
}
|
||||
Ссылка в новой задаче
Block a user