Все проверки выполнены успешно
CI / test (push) Successful in 3m13s
Docker / Build and publish worker image (push) Successful in 10m35s
352 строки
10 KiB
Go
352 строки
10 KiB
Go
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)
|
|
}
|
|
}
|