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
Этот коммит содержится в:
221
internal/sshinstall/detect.go
Обычный файл
221
internal/sshinstall/detect.go
Обычный файл
@@ -0,0 +1,221 @@
|
||||
// Package sshinstall contains the pure detection and planning layer for
|
||||
// installing the worker from source over SSH. It never touches the
|
||||
// network or a remote host; the executor that turns a plan into remote
|
||||
// commands is a later work package (docs/source-installation.md).
|
||||
//
|
||||
// The Docker/OpenSSH harness that exercises detection against real
|
||||
// distro containers lives in internal/installer/harness.
|
||||
package sshinstall
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Distro is a supported remote Linux distribution family.
|
||||
type Distro string
|
||||
|
||||
const (
|
||||
DistroUnknown Distro = "unknown"
|
||||
DistroAlpine Distro = "alpine"
|
||||
DistroDebian Distro = "debian"
|
||||
DistroUbuntu Distro = "ubuntu"
|
||||
DistroArch Distro = "arch"
|
||||
DistroCentOS Distro = "centos"
|
||||
DistroFedora Distro = "fedora"
|
||||
DistroRocky Distro = "rocky"
|
||||
DistroAlma Distro = "alma"
|
||||
DistroRHEL Distro = "rhel"
|
||||
)
|
||||
|
||||
// PackageManager is the remote package manager used to install build
|
||||
// prerequisites.
|
||||
type PackageManager string
|
||||
|
||||
const (
|
||||
PkgUnknown PackageManager = ""
|
||||
PkgApk PackageManager = "apk"
|
||||
PkgApt PackageManager = "apt"
|
||||
PkgPacman PackageManager = "pacman"
|
||||
PkgDnf PackageManager = "dnf"
|
||||
PkgYum PackageManager = "yum"
|
||||
)
|
||||
|
||||
// InitSystem is the remote init system. The source installer must know
|
||||
// it before it can install a service definition; Alpine's OpenRC and the
|
||||
// systemd distros take different paths.
|
||||
type InitSystem string
|
||||
|
||||
const (
|
||||
InitUnknown InitSystem = ""
|
||||
InitSystemd InitSystem = "systemd"
|
||||
InitOpenRC InitSystem = "openrc"
|
||||
)
|
||||
|
||||
// Detection is the resolved identity of a remote Linux host.
|
||||
type Detection struct {
|
||||
ID string // os-release ID (lowercase, e.g. "alpine")
|
||||
Name string // os-release NAME (pretty, may be empty)
|
||||
VersionID string // os-release VERSION_ID (may be empty)
|
||||
Distro Distro
|
||||
PackageManager PackageManager
|
||||
InitSystem InitSystem
|
||||
}
|
||||
|
||||
// FileProber reports which of the given absolute paths exist on the
|
||||
// remote host. Detection uses it to distinguish init systems and
|
||||
// packaging hints without parsing the full command surface. A nil
|
||||
// prober behaves as if nothing exists.
|
||||
type FileProber func(paths ...string) map[string]bool
|
||||
|
||||
// Detect resolves the distro, package manager, and init system of a
|
||||
// remote host from its /etc/os-release contents and a path prober.
|
||||
// Pure and deterministic: no command execution happens here.
|
||||
func Detect(osRelease string, probe FileProber) Detection {
|
||||
d := parseOSRelease(osRelease)
|
||||
d.PackageManager = packageManagerFor(d.ID)
|
||||
d.InitSystem = initFor(d.ID, probe)
|
||||
return d
|
||||
}
|
||||
|
||||
// parseOSRelease extracts the fields the installer cares about from the
|
||||
// /etc/os-release text. Unknown keys are ignored.
|
||||
func parseOSRelease(data string) Detection {
|
||||
var d Detection
|
||||
for _, line := range strings.Split(data, "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
key, value, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// os-release requires KEY=VALUE with no whitespace around the
|
||||
// equals sign. A spaced assignment is a malformed line and must
|
||||
// not be interpreted.
|
||||
if strings.ContainsAny(key, " \t") {
|
||||
continue
|
||||
}
|
||||
value = unquoteOSValue(value)
|
||||
switch strings.TrimSpace(key) {
|
||||
case "ID":
|
||||
d.ID = strings.ToLower(strings.TrimSpace(value))
|
||||
case "NAME":
|
||||
d.Name = strings.TrimSpace(value)
|
||||
case "VERSION_ID":
|
||||
d.VersionID = strings.TrimSpace(value)
|
||||
}
|
||||
}
|
||||
d.Distro = distroForID(d.ID)
|
||||
return d
|
||||
}
|
||||
|
||||
// unquoteOSValue strips the surrounding quotes os-release permits and
|
||||
// unescapes the two escapes the spec defines. Unquoted values pass
|
||||
// through unchanged.
|
||||
func unquoteOSValue(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) >= 2 && (value[0] == '"' || value[0] == '\'') && value[len(value)-1] == value[0] {
|
||||
value = value[1 : len(value)-1]
|
||||
}
|
||||
value = strings.ReplaceAll(value, `\"`, `"`)
|
||||
value = strings.ReplaceAll(value, `\'`, `'`)
|
||||
value = strings.ReplaceAll(value, `\\`, `\`)
|
||||
value = strings.ReplaceAll(value, `\$`, `$`)
|
||||
value = strings.ReplaceAll(value, "`", "")
|
||||
return value
|
||||
}
|
||||
|
||||
// distroForID maps an os-release ID to a supported distro family.
|
||||
func distroForID(id string) Distro {
|
||||
switch id {
|
||||
case "alpine":
|
||||
return DistroAlpine
|
||||
case "debian":
|
||||
return DistroDebian
|
||||
case "ubuntu", "linuxmint", "elementary":
|
||||
return DistroUbuntu
|
||||
case "arch", "archarm", "manjaro", "endeavouros":
|
||||
return DistroArch
|
||||
case "centos":
|
||||
return DistroCentOS
|
||||
case "fedora":
|
||||
return DistroFedora
|
||||
case "rocky":
|
||||
return DistroRocky
|
||||
case "almalinux":
|
||||
return DistroAlma
|
||||
case "rhel", "redhat":
|
||||
return DistroRHEL
|
||||
default:
|
||||
return DistroUnknown
|
||||
}
|
||||
}
|
||||
|
||||
// packageManagerFor maps an os-release ID to its package manager.
|
||||
// CentOS-family versions differ (dnf on 8+, yum on 7); the planner
|
||||
// defaults to dnf and the CentOS work package will refine it.
|
||||
func packageManagerFor(id string) PackageManager {
|
||||
switch distroForID(id) {
|
||||
case DistroAlpine:
|
||||
return PkgApk
|
||||
case DistroDebian, DistroUbuntu:
|
||||
return PkgApt
|
||||
case DistroArch:
|
||||
return PkgPacman
|
||||
case DistroCentOS, DistroFedora, DistroRocky, DistroAlma, DistroRHEL:
|
||||
return PkgDnf
|
||||
default:
|
||||
return PkgUnknown
|
||||
}
|
||||
}
|
||||
|
||||
// initFor infers the init system from well-known filesystem markers. In
|
||||
// a container the PID 1 command is not a reliable signal (sshd or the
|
||||
// runtime command runs first), so the installer uses the packaging
|
||||
// markers systemd and OpenRC leave behind.
|
||||
func initFor(_ string, probe FileProber) InitSystem {
|
||||
if probe == nil {
|
||||
return InitUnknown
|
||||
}
|
||||
present := probe(
|
||||
"/run/systemd/system",
|
||||
"/usr/lib/systemd/system",
|
||||
"/etc/systemd/system",
|
||||
"/sbin/openrc",
|
||||
"/etc/init.d",
|
||||
)
|
||||
if present["/run/systemd/system"] || present["/usr/lib/systemd/system"] || present["/etc/systemd/system"] {
|
||||
return InitSystemd
|
||||
}
|
||||
if present["/sbin/openrc"] || present["/etc/init.d"] {
|
||||
return InitOpenRC
|
||||
}
|
||||
return InitUnknown
|
||||
}
|
||||
|
||||
// Summarize returns a single-line, operator-readable description of the
|
||||
// detection result.
|
||||
func (d Detection) Summarize() string {
|
||||
return fmt.Sprintf("%s (id=%s version=%s, pkg=%s, init=%s)",
|
||||
displayName(d), d.ID, displayValue(d.VersionID),
|
||||
displayValue(string(d.PackageManager)), displayValue(string(d.InitSystem)))
|
||||
}
|
||||
|
||||
func displayName(d Detection) string {
|
||||
if d.Name != "" {
|
||||
return d.Name
|
||||
}
|
||||
if d.ID != "" {
|
||||
return d.ID
|
||||
}
|
||||
return string(d.Distro)
|
||||
}
|
||||
|
||||
func displayValue(v string) string {
|
||||
if v == "" {
|
||||
return "unknown"
|
||||
}
|
||||
return v
|
||||
}
|
||||
120
internal/sshinstall/detect_test.go
Обычный файл
120
internal/sshinstall/detect_test.go
Обычный файл
@@ -0,0 +1,120 @@
|
||||
package sshinstall
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// prober returns a FileProber backed by a fixed path set.
|
||||
func prober(exist ...string) FileProber {
|
||||
want := make(map[string]bool, len(exist))
|
||||
for _, p := range exist {
|
||||
want[p] = true
|
||||
}
|
||||
return func(paths ...string) map[string]bool {
|
||||
out := make(map[string]bool, len(paths))
|
||||
for _, p := range paths {
|
||||
out[p] = want[p]
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseOSReleaseQuoting(t *testing.T) {
|
||||
d := parseOSRelease(`NAME="Ubuntu 24.04 LTS"
|
||||
VERSION_ID="24.04"
|
||||
ID=ubuntu
|
||||
ID_LIKE=debian
|
||||
PRETTY_NAME="Ubuntu 24.04 LTS"
|
||||
`)
|
||||
if d.ID != "ubuntu" || d.Name != "Ubuntu 24.04 LTS" || d.VersionID != "24.04" {
|
||||
t.Fatalf("parseOSRelease = %+v", d)
|
||||
}
|
||||
if d.Distro != DistroUbuntu {
|
||||
t.Fatalf("distro = %q, want ubuntu", d.Distro)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseOSReleaseSingleQuotedAndEmpty(t *testing.T) {
|
||||
d := parseOSRelease("# comment\nID='alpine'\nNAME=Alpine\n\n")
|
||||
if d.ID != "alpine" || d.Name != "Alpine" {
|
||||
t.Fatalf("parseOSRelease = %+v", d)
|
||||
}
|
||||
if d.Distro != DistroAlpine {
|
||||
t.Fatalf("distro = %q, want alpine", d.Distro)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseOSReleaseGarbage(t *testing.T) {
|
||||
d := parseOSRelease("not an os-release file\nID = spaced\nFOO=bar\n")
|
||||
if d.ID != "" {
|
||||
t.Fatalf("ID = %q, want empty", d.ID)
|
||||
}
|
||||
if d.Distro != DistroUnknown {
|
||||
t.Fatalf("distro = %q, want unknown", d.Distro)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackageManagerFor(t *testing.T) {
|
||||
cases := map[string]PackageManager{
|
||||
"alpine": PkgApk,
|
||||
"debian": PkgApt,
|
||||
"ubuntu": PkgApt,
|
||||
"arch": PkgPacman,
|
||||
"centos": PkgDnf,
|
||||
"fedora": PkgDnf,
|
||||
"rocky": PkgDnf,
|
||||
"almalinux": PkgDnf,
|
||||
"rhel": PkgDnf,
|
||||
"nonsense": PkgUnknown,
|
||||
"": PkgUnknown,
|
||||
}
|
||||
for id, want := range cases {
|
||||
if got := packageManagerFor(id); got != want {
|
||||
t.Fatalf("packageManagerFor(%q) = %q, want %q", id, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectInitMarkers(t *testing.T) {
|
||||
osRelease := "ID=arch\nNAME=Arch Linux\n"
|
||||
|
||||
if got := Detect(osRelease, prober("/usr/lib/systemd/system")); got.InitSystem != InitSystemd {
|
||||
t.Fatalf("arch systemd markers not detected: %+v", got)
|
||||
}
|
||||
if got := Detect(osRelease, nil).InitSystem; got != InitUnknown {
|
||||
t.Fatalf("nil prober should yield unknown init, got %q", got)
|
||||
}
|
||||
|
||||
alpine := Detect("ID=alpine\n", prober("/sbin/openrc", "/etc/init.d"))
|
||||
if alpine.InitSystem != InitOpenRC {
|
||||
t.Fatalf("alpine openrc markers not detected: %+v", alpine)
|
||||
}
|
||||
if alpine.PackageManager != PkgApk {
|
||||
t.Fatalf("alpine pkg = %q, want apk", alpine.PackageManager)
|
||||
}
|
||||
|
||||
ubuntu := Detect("ID=ubuntu\n", prober("/run/systemd/system"))
|
||||
if ubuntu.InitSystem != InitSystemd || ubuntu.PackageManager != PkgApt {
|
||||
t.Fatalf("ubuntu detection = %+v", ubuntu)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectSystemdWinsOverOpenRC(t *testing.T) {
|
||||
// A host that carries both markers must be reported as systemd so a
|
||||
// systemd distro running a containerized openrc stub is not misplanned.
|
||||
d := Detect("ID=ubuntu\n", prober("/usr/lib/systemd/system", "/sbin/openrc"))
|
||||
if d.InitSystem != InitSystemd {
|
||||
t.Fatalf("init = %q, want systemd", d.InitSystem)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectionSummarize(t *testing.T) {
|
||||
d := Detect("ID=alpine\nNAME=Alpine Linux\n", prober("/sbin/openrc"))
|
||||
s := d.Summarize()
|
||||
for _, want := range []string{"Alpine Linux", "id=alpine", "pkg=apk", "init=openrc"} {
|
||||
if !strings.Contains(s, want) {
|
||||
t.Fatalf("Summarize() = %q missing %q", s, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
259
internal/sshinstall/plan.go
Обычный файл
259
internal/sshinstall/plan.go
Обычный файл
@@ -0,0 +1,259 @@
|
||||
package sshinstall
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DefaultGoVersion is the pinned Go toolchain version the source
|
||||
// installer plans by default. It is overridable through install options
|
||||
// when a host needs a different toolchain.
|
||||
const DefaultGoVersion = "1.26.0"
|
||||
|
||||
// DefaultRepo is the publicly readable worker repository. It requires no
|
||||
// source credential and is the plan's default clone URL.
|
||||
const DefaultRepo = "https://rocketgit.ru/rsmon/worker.git"
|
||||
|
||||
// DefaultBranch is the branch the source installer checks out and
|
||||
// builds.
|
||||
const DefaultBranch = "main"
|
||||
|
||||
// Toolchain is a pinned, checksum-verified Go toolchain download for a
|
||||
// remote Linux architecture. The SHA-256 is baked for the default
|
||||
// toolchain version so planning never needs the network.
|
||||
type Toolchain struct {
|
||||
Version string // e.g. "1.26.0"
|
||||
Arch string // download archive suffix, e.g. "linux-amd64"
|
||||
URL string // direct download URL
|
||||
SHA256 string // published SHA-256 (64 lowercase hex)
|
||||
}
|
||||
|
||||
// toolchainSHA pins the official go.dev SHA-256 checksums for the
|
||||
// default Go version per Linux archive. Sources: https://go.dev/dl/
|
||||
// (?mode=json) published sums for DefaultGoVersion. Keep this in sync
|
||||
// with DefaultGoVersion.
|
||||
var toolchainSHA = map[string]string{
|
||||
"linux-386": "35e2ec7a7ae6905a1fae5459197b70e3fcbc5e0a786a7d6ba8e49bcd38ad2e26",
|
||||
"linux-amd64": "aac1b08a0fb0c4e0a7c1555beb7b59180b05dfc5a3d62e40e9de90cd42f88235",
|
||||
"linux-arm64": "bd03b743eb6eb4193ea3c3fd3956546bf0e3ca5b7076c8226334afe6b75704cd",
|
||||
"linux-armv6l": "3f6b48d96f0d8dff77e4625aa179e0449f6bbe79b6986bfa711c2cfc1257ebd8",
|
||||
"linux-loong64": "33947cd7686f1cd5f097d2a5a30427a4ade114ea00b7570c85a2abf1af3d0507",
|
||||
"linux-mips": "a4ece61d4bac43b6983fde2c6b9cfc1af7f0d5d6a073219583d4e93b11559c25",
|
||||
"linux-mips64": "197c2e97fa9ec1ad05998e0982d1a1ae761980df154424e5f29f3912e9ea4e5e",
|
||||
"linux-mips64le": "61c52b4ab0dceae29f10df29045483596c3f06810c9b511e8336a97428a95a1b",
|
||||
"linux-mipsle": "b3a13cc5a5f9250b02cf4ba19914c90c7034e68a5ccb9affa5198aadbcedac9a",
|
||||
"linux-ppc64": "ef7232a49101d163a93bac34d03bfbc4fb18f75d7526d77ac307e16d9d83c300",
|
||||
"linux-ppc64le": "3066b2284b554da76cf664d217490792ba6f292ec0fc20bf9615e173cc0d2800",
|
||||
"linux-riscv64": "ab9226ecddda0f682365c949114b653a66c2e9330e7b8d3edea80858437d2ff2",
|
||||
"linux-s390x": "d62137f11530b97f3503453ad7d9e570af070770599fb8054f4e8cd0e905a453",
|
||||
}
|
||||
|
||||
// GoArch maps a remote `uname -m` value to the Go download archive
|
||||
// suffix used by go.dev. Every suffix it can return must have a pinned
|
||||
// checksum in toolchainSHA; the table-consistency test enforces that.
|
||||
// Unknown values error.
|
||||
func GoArch(unameM string) (string, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(unameM)) {
|
||||
case "x86_64", "amd64":
|
||||
return "amd64", nil
|
||||
case "aarch64", "arm64":
|
||||
return "arm64", nil
|
||||
case "armv6l", "armv7l":
|
||||
return "armv6l", nil
|
||||
case "i386", "i486", "i586", "i686", "386":
|
||||
return "386", nil
|
||||
case "loongarch64":
|
||||
return "loong64", nil
|
||||
case "mips":
|
||||
return "mips", nil
|
||||
case "mipsel":
|
||||
return "mipsle", nil
|
||||
case "mips64":
|
||||
return "mips64", nil
|
||||
case "mips64el":
|
||||
return "mips64le", nil
|
||||
case "ppc64":
|
||||
return "ppc64", nil
|
||||
case "ppc64le":
|
||||
return "ppc64le", nil
|
||||
case "riscv64":
|
||||
return "riscv64", nil
|
||||
case "s390x":
|
||||
return "s390x", nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported machine architecture %q", unameM)
|
||||
}
|
||||
}
|
||||
|
||||
// ToolchainFor returns the pinned, checksum-verified Go toolchain for a
|
||||
// remote Linux architecture. A non-default version has no baked
|
||||
// checksum yet and must be resolved through a checksum source by the
|
||||
// executor work package.
|
||||
func ToolchainFor(goarch, version string) (Toolchain, error) {
|
||||
if version == "" {
|
||||
version = DefaultGoVersion
|
||||
}
|
||||
suffix := "linux-" + goarch
|
||||
t := Toolchain{
|
||||
Version: version,
|
||||
Arch: suffix,
|
||||
URL: "https://go.dev/dl/go" + version + "." + suffix + ".tar.gz",
|
||||
}
|
||||
if version != DefaultGoVersion {
|
||||
return t, fmt.Errorf("no baked checksum for Go %s; only %s is pinned (resolve %s via the checksum source)",
|
||||
version, DefaultGoVersion, suffix)
|
||||
}
|
||||
sha, ok := toolchainSHA[suffix]
|
||||
if !ok {
|
||||
return Toolchain{}, fmt.Errorf("no pinned Go %s toolchain for %s", version, suffix)
|
||||
}
|
||||
t.SHA256 = sha
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// SourceOptions are the operator-configurable knobs that the source
|
||||
// plan is resolved against. Every field is optional; empty values fall
|
||||
// back to the pinned defaults.
|
||||
type SourceOptions struct {
|
||||
Repo string // clone URL; default DefaultRepo
|
||||
Branch string // default DefaultBranch
|
||||
GoVersion string // default DefaultGoVersion
|
||||
GoArch string // go archive suffix; when empty, derived from UnameM
|
||||
UnameM string // remote `uname -m` output; required unless GoArch set
|
||||
BuildDir string // remote clone/build directory
|
||||
GoModuleProxy string // GOPROXY override; empty keeps the Go default
|
||||
}
|
||||
|
||||
// SourcePlan is the pure, resolved source-install plan. Producing it
|
||||
// never touches the network or the remote host; the executor work
|
||||
// package turns it into remote commands.
|
||||
type SourcePlan struct {
|
||||
Repo string
|
||||
Branch string
|
||||
BuildDir string
|
||||
GoModuleProxy string
|
||||
Toolchain Toolchain
|
||||
Packages []string // packages to install via the package manager
|
||||
InitSystem InitSystem
|
||||
}
|
||||
|
||||
// PlanSource resolves a source-install plan for a detected host. It
|
||||
// returns an error before any remote mutation could happen when the
|
||||
// target cannot be planned (unknown distro, unsupported architecture,
|
||||
// malformed repository).
|
||||
func PlanSource(d Detection, opts SourceOptions) (SourcePlan, error) {
|
||||
if d.PackageManager == PkgUnknown {
|
||||
return SourcePlan{}, fmt.Errorf("unsupported distro %q: no package manager", d.ID)
|
||||
}
|
||||
goarch := strings.TrimSpace(opts.GoArch)
|
||||
if goarch == "" {
|
||||
var err error
|
||||
goarch, err = GoArch(opts.UnameM)
|
||||
if err != nil {
|
||||
return SourcePlan{}, err
|
||||
}
|
||||
}
|
||||
toolchain, err := ToolchainFor(goarch, opts.GoVersion)
|
||||
if err != nil {
|
||||
return SourcePlan{}, err
|
||||
}
|
||||
repo := strings.TrimSpace(opts.Repo)
|
||||
if repo == "" {
|
||||
repo = DefaultRepo
|
||||
}
|
||||
if err := validateRepoURL(repo); err != nil {
|
||||
return SourcePlan{}, err
|
||||
}
|
||||
branch := strings.TrimSpace(opts.Branch)
|
||||
if branch == "" {
|
||||
branch = DefaultBranch
|
||||
}
|
||||
buildDir := strings.TrimSpace(opts.BuildDir)
|
||||
if buildDir == "" {
|
||||
buildDir = "/opt/rsmon-worker-src"
|
||||
}
|
||||
if !strings.HasPrefix(buildDir, "/") {
|
||||
return SourcePlan{}, fmt.Errorf("build directory must be absolute, got %q", buildDir)
|
||||
}
|
||||
return SourcePlan{
|
||||
Repo: repo,
|
||||
Branch: branch,
|
||||
BuildDir: buildDir,
|
||||
GoModuleProxy: strings.TrimSpace(opts.GoModuleProxy),
|
||||
Toolchain: toolchain,
|
||||
Packages: packagePrereqs(d.PackageManager),
|
||||
InitSystem: d.InitSystem,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// packagePrereqs returns the minimal package set the source installer
|
||||
// plans to install for a package manager: git, CA certificates, and
|
||||
// download/archive tools. It never plans a C compiler or build-essential
|
||||
// because the worker builds with CGO disabled.
|
||||
func packagePrereqs(pkg PackageManager) []string {
|
||||
switch pkg {
|
||||
case PkgApk:
|
||||
return []string{"git", "ca-certificates", "curl", "tar", "gzip"}
|
||||
case PkgApt:
|
||||
return []string{"git", "ca-certificates", "curl", "tar", "gzip"}
|
||||
case PkgPacman:
|
||||
return []string{"git", "ca-certificates", "curl", "tar", "gzip"}
|
||||
case PkgDnf, PkgYum:
|
||||
return []string{"git", "ca-certificates", "curl", "tar", "gzip"}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// validateRepoURL rejects repository references that could smuggle a
|
||||
// command or a non-remote scheme into the clone step. Only http(s) and
|
||||
// the git protocol are accepted; the default repository is https.
|
||||
func validateRepoURL(repo string) error {
|
||||
if strings.ContainsAny(repo, "\r\n\t ") {
|
||||
return fmt.Errorf("repository URL %q contains whitespace", repo)
|
||||
}
|
||||
u, err := url.Parse(repo)
|
||||
if err != nil || u.Host == "" {
|
||||
return fmt.Errorf("repository URL %q is not an absolute clone URL", repo)
|
||||
}
|
||||
switch u.Scheme {
|
||||
case "https", "http", "git":
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("repository URL %q uses unsupported scheme %q", repo, u.Scheme)
|
||||
}
|
||||
}
|
||||
|
||||
// StepKind identifies one ordered remote step the executor will run.
|
||||
type StepKind string
|
||||
|
||||
const (
|
||||
StepInstallPackages StepKind = "install-packages"
|
||||
StepInstallToolchain StepKind = "install-toolchain"
|
||||
StepCloneSource StepKind = "clone-source"
|
||||
StepCheckoutBranch StepKind = "checkout-branch"
|
||||
StepBuildWorker StepKind = "build-worker"
|
||||
StepInstallService StepKind = "install-service"
|
||||
)
|
||||
|
||||
// Step is one ordered, pure planning step. The executor maps each step
|
||||
// to remote commands; planning does not execute anything.
|
||||
type Step struct {
|
||||
Kind StepKind
|
||||
Detail string
|
||||
Packages []string // only for StepInstallPackages
|
||||
}
|
||||
|
||||
// Steps returns the ordered source-install plan as stable, reviewable
|
||||
// steps. It is the contract the executor work package implements.
|
||||
func (p SourcePlan) Steps() []Step {
|
||||
return []Step{
|
||||
{Kind: StepInstallPackages, Detail: "install minimal build prerequisites", Packages: p.Packages},
|
||||
{Kind: StepInstallToolchain, Detail: "install pinned Go " + p.Toolchain.Version + " (" + p.Toolchain.Arch + ") and verify SHA-256"},
|
||||
{Kind: StepCloneSource, Detail: "clone " + p.Repo + " into " + p.BuildDir},
|
||||
{Kind: StepCheckoutBranch, Detail: "check out branch " + p.Branch + " and record the resolved commit"},
|
||||
{Kind: StepBuildWorker, Detail: "build the worker binary with CGO_ENABLED=0 and trimpath"},
|
||||
{Kind: StepInstallService, Detail: "atomically install the binary, env, data dir, and " + string(p.InitSystem) + " service definition"},
|
||||
}
|
||||
}
|
||||
243
internal/sshinstall/plan_test.go
Обычный файл
243
internal/sshinstall/plan_test.go
Обычный файл
@@ -0,0 +1,243 @@
|
||||
package sshinstall
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGoArch(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"x86_64": "amd64",
|
||||
"X86_64": "amd64",
|
||||
"amd64": "amd64",
|
||||
"aarch64": "arm64",
|
||||
"arm64": "arm64",
|
||||
"armv7l": "armv6l",
|
||||
"armv6l": "armv6l",
|
||||
"i686": "386",
|
||||
"i386": "386",
|
||||
"loongarch64": "loong64",
|
||||
"mips": "mips",
|
||||
"mipsel": "mipsle",
|
||||
"mips64": "mips64",
|
||||
"mips64el": "mips64le",
|
||||
"ppc64": "ppc64",
|
||||
"ppc64le": "ppc64le",
|
||||
"riscv64": "riscv64",
|
||||
"s390x": "s390x",
|
||||
}
|
||||
for in, want := range cases {
|
||||
got, err := GoArch(in)
|
||||
if err != nil || got != want {
|
||||
t.Fatalf("GoArch(%q) = %q, %v; want %q", in, got, err, want)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"", "sparc", "x86", "mips64el-le"} {
|
||||
if _, err := GoArch(bad); err == nil {
|
||||
t.Fatalf("GoArch(%q) succeeded", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestToolchainTableConsistentWithGoArch keeps GoArch and toolchainSHA in
|
||||
// sync: every `uname -m` mapping must resolve to a pinned checksum, and
|
||||
// every pinned checksum must be reachable through some `uname -m` value.
|
||||
func TestToolchainTableConsistentWithGoArch(t *testing.T) {
|
||||
// unameExamples maps a Go archive suffix to a real `uname -m` value
|
||||
// that GoArch accepts for it.
|
||||
unameExamples := map[string]string{
|
||||
"amd64": "x86_64",
|
||||
"arm64": "aarch64",
|
||||
"armv6l": "armv7l",
|
||||
"386": "i686",
|
||||
"loong64": "loongarch64",
|
||||
"mips": "mips",
|
||||
"mipsle": "mipsel",
|
||||
"mips64": "mips64",
|
||||
"mips64le": "mips64el",
|
||||
"ppc64": "ppc64",
|
||||
"ppc64le": "ppc64le",
|
||||
"riscv64": "riscv64",
|
||||
"s390x": "s390x",
|
||||
}
|
||||
for archSuffix, unameValue := range unameExamples {
|
||||
tc, err := ToolchainFor(archSuffix, DefaultGoVersion)
|
||||
if err != nil {
|
||||
t.Fatalf("GoArch(%q) = %q has no pinned checksum: %v", unameValue, archSuffix, err)
|
||||
}
|
||||
if tc.SHA256 == "" || tc.Arch != "linux-"+archSuffix {
|
||||
t.Fatalf("ToolchainFor(%q) = %+v", archSuffix, tc)
|
||||
}
|
||||
}
|
||||
for suffix := range toolchainSHA {
|
||||
arch := strings.TrimPrefix(suffix, "linux-")
|
||||
if _, ok := unameExamples[arch]; !ok {
|
||||
t.Fatalf("checksum %q is not reachable through any GoArch `uname -m` mapping", suffix)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolchainFor(t *testing.T) {
|
||||
tc, err := ToolchainFor("amd64", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tc.Version != DefaultGoVersion {
|
||||
t.Fatalf("version = %q, want %q", tc.Version, DefaultGoVersion)
|
||||
}
|
||||
if tc.Arch != "linux-amd64" {
|
||||
t.Fatalf("arch = %q, want linux-amd64", tc.Arch)
|
||||
}
|
||||
if tc.URL != "https://go.dev/dl/go1.26.0.linux-amd64.tar.gz" {
|
||||
t.Fatalf("url = %q", tc.URL)
|
||||
}
|
||||
if len(tc.SHA256) != 64 {
|
||||
t.Fatalf("sha = %q, want 64 hex chars", tc.SHA256)
|
||||
}
|
||||
for _, c := range tc.SHA256 {
|
||||
hexDigit := c >= '0' && c <= '9' || c >= 'a' && c <= 'f'
|
||||
if !hexDigit {
|
||||
t.Fatalf("sha %q contains non-lowercase-hex char", tc.SHA256)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolchainForArm64Pinned(t *testing.T) {
|
||||
tc, err := ToolchainFor("arm64", DefaultGoVersion)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tc.SHA256 == "" || tc.Arch != "linux-arm64" {
|
||||
t.Fatalf("arm64 toolchain not pinned: %+v", tc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToolchainForRejectsUnpinnedVersion(t *testing.T) {
|
||||
if _, err := ToolchainFor("amd64", "1.27.0"); err == nil {
|
||||
t.Fatal("unpinned Go version accepted")
|
||||
}
|
||||
if _, err := ToolchainFor("sparc", ""); err == nil {
|
||||
t.Fatal("unknown architecture accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanSourceAlpine(t *testing.T) {
|
||||
d := Detect("ID=alpine\n", prober("/sbin/openrc"))
|
||||
p, err := PlanSource(d, SourceOptions{UnameM: "x86_64"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.Repo != DefaultRepo || p.Branch != DefaultBranch {
|
||||
t.Fatalf("plan defaults wrong: %+v", p)
|
||||
}
|
||||
if p.Toolchain.Arch != "linux-amd64" {
|
||||
t.Fatalf("toolchain = %+v", p.Toolchain)
|
||||
}
|
||||
if len(p.Packages) != 5 || p.Packages[0] != "git" {
|
||||
t.Fatalf("alpine packages = %v", p.Packages)
|
||||
}
|
||||
if p.InitSystem != InitOpenRC {
|
||||
t.Fatalf("init = %q, want openrc", p.InitSystem)
|
||||
}
|
||||
steps := p.Steps()
|
||||
if len(steps) != 6 {
|
||||
t.Fatalf("steps = %d, want 6", len(steps))
|
||||
}
|
||||
if steps[0].Kind != StepInstallPackages || steps[1].Kind != StepInstallToolchain {
|
||||
t.Fatalf("step order wrong: %+v", steps)
|
||||
}
|
||||
if !strings.Contains(steps[1].Detail, "Go 1.26.0") || !strings.Contains(steps[1].Detail, "linux-amd64") {
|
||||
t.Fatalf("toolchain step detail = %q", steps[1].Detail)
|
||||
}
|
||||
if !strings.Contains(steps[5].Detail, "openrc") {
|
||||
t.Fatalf("install step detail = %q", steps[5].Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanSourceUbuntuArch(t *testing.T) {
|
||||
ubuntu := Detect("ID=ubuntu\n", prober("/usr/lib/systemd/system"))
|
||||
p, err := PlanSource(ubuntu, SourceOptions{UnameM: "aarch64"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.Toolchain.Arch != "linux-arm64" || p.InitSystem != InitSystemd {
|
||||
t.Fatalf("ubuntu plan = %+v", p)
|
||||
}
|
||||
|
||||
arch := Detect("ID=arch\n", prober("/usr/lib/systemd/system"))
|
||||
p, err = PlanSource(arch, SourceOptions{UnameM: "x86_64"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.Packages[0] != "git" || p.InitSystem != InitSystemd {
|
||||
t.Fatalf("arch plan = %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanSourceExplicitOverrides(t *testing.T) {
|
||||
d := Detect("ID=ubuntu\n", nil)
|
||||
p, err := PlanSource(d, SourceOptions{
|
||||
Repo: "https://example.test/worker.git",
|
||||
Branch: "release-1.0",
|
||||
GoArch: "arm64",
|
||||
BuildDir: "/srv/rsmon",
|
||||
GoModuleProxy: "https://proxy.golang.org,direct",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.Repo != "https://example.test/worker.git" || p.Branch != "release-1.0" ||
|
||||
p.BuildDir != "/srv/rsmon" || p.GoModuleProxy != "https://proxy.golang.org,direct" {
|
||||
t.Fatalf("overrides not applied: %+v", p)
|
||||
}
|
||||
if p.Toolchain.Arch != "linux-arm64" {
|
||||
t.Fatalf("toolchain = %+v", p.Toolchain)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanSourceErrors(t *testing.T) {
|
||||
unknown := Detect("ID=weirdos\n", nil)
|
||||
if _, err := PlanSource(unknown, SourceOptions{UnameM: "x86_64"}); err == nil {
|
||||
t.Fatal("unknown distro planned")
|
||||
}
|
||||
|
||||
ubuntu := Detect("ID=ubuntu\n", nil)
|
||||
if _, err := PlanSource(ubuntu, SourceOptions{}); err == nil {
|
||||
t.Fatal("missing architecture planned")
|
||||
}
|
||||
for _, repo := range []string{"file:///tmp/worker.git", "not-a-url", "https://ex ample/x"} {
|
||||
if _, err := PlanSource(ubuntu, SourceOptions{UnameM: "x86_64", Repo: repo}); err == nil {
|
||||
t.Fatalf("invalid repo %q planned", repo)
|
||||
}
|
||||
}
|
||||
if _, err := PlanSource(ubuntu, SourceOptions{UnameM: "x86_64", BuildDir: "relative"}); err == nil {
|
||||
t.Fatal("relative build dir planned")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackagePrereqsNeverIncludeCompiler(t *testing.T) {
|
||||
for _, pkg := range []PackageManager{PkgApk, PkgApt, PkgPacman, PkgDnf} {
|
||||
for _, name := range packagePrereqs(pkg) {
|
||||
switch name {
|
||||
case "build-essential", "gcc", "g++", "base-devel", "gcc-c++", "make":
|
||||
t.Fatalf("plan includes compiler package %q", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
if got := packagePrereqs(PkgUnknown); len(got) != 0 {
|
||||
t.Fatalf("unknown package manager planned packages %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRepoURLSchemes(t *testing.T) {
|
||||
for _, ok := range []string{"https://rocketgit.ru/rsmon/worker.git", "http://x/y", "git://example.test/r"} {
|
||||
if err := validateRepoURL(ok); err != nil {
|
||||
t.Fatalf("validateRepoURL(%q): %v", ok, err)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{"ssh://h@x/r", "s3://bucket/key", "x y", ""} {
|
||||
if err := validateRepoURL(bad); err == nil {
|
||||
t.Fatalf("validateRepoURL(%q) succeeded", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
Ссылка в новой задаче
Block a user