// 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 }