Files
server-deploy/internal/preflight/preflight.go
T

209 lines
6.9 KiB
Go

package preflight
import (
"io"
"io/fs"
"regexp"
"server-deploy/internal/debian"
"server-deploy/internal/inspect"
"strings"
)
type Disk struct {
State string `json:"state"`
AvailableBytes uint64 `json:"availableBytes"`
}
type Distribution struct {
State string `json:"state"`
ID string `json:"id"`
Version string `json:"version"`
Codename string `json:"codename"`
}
type Resource struct {
Path string `json:"path"`
State string `json:"state"`
}
type Report struct {
Runtime inspect.Report `json:"runtime"`
Distribution Distribution `json:"distribution"`
Privilege string `json:"privilege"`
Disk Disk `json:"disk"`
Resources []Resource `json:"resources"`
Inventory debian.Snapshot `json:"inventory"`
}
type Proposal struct {
Executable bool `json:"executable"`
Blockers []string `json:"blockers"`
ProposedChanges []string `json:"proposedChanges"`
Impacts []string `json:"impacts"`
}
var resourcePaths = []string{"var/lib/docker", "var/lib/containerd", "etc/docker", "var/lib/server-deploy", "etc/apt/sources.list.d/docker.sources", "etc/apt/sources.list.d/docker.list"}
// Probe reads metadata and a bounded os-release file. It never sources shell
// files or invokes executables. Disk describes /var/lib, not an arbitrary target.
func Probe(runtime inspect.Report, files fs.FS, uid int, disk Disk) Report {
r := Report{Runtime: runtime, Distribution: Distribution{State: "not_checked"}, Privilege: "not_checked", Disk: Disk{State: "not_checked"}, Resources: []Resource{}, Inventory: debian.Snapshot{State: "not_checked", Packages: []debian.Installed{}}}
if runtime.OS != "linux" {
return r
}
r.Disk = disk
r.Privilege = "non_root"
if uid == 0 {
r.Privilege = "root"
} else if uid < 0 {
r.Privilege = "unknown"
}
r.Distribution = readDistribution(files)
r.Inventory = debian.Inventory(files)
for _, p := range resourcePaths {
r.Resources = append(r.Resources, Resource{Path: "/" + p, State: resourceState(files, p)})
}
return r
}
// Plan is a proposal, not an executable or approved installation plan. Missing
// package inventory/version locks and host identity always block execution.
func Plan(r Report) Proposal {
p := Proposal{Blockers: []string{}, ProposedChanges: []string{}, Impacts: []string{}}
if r.Runtime.OS != "linux" || (r.Runtime.Architecture != "amd64" && r.Runtime.Architecture != "arm64") {
p.Blockers = append(p.Blockers, "unsupported_platform")
}
if r.Distribution.State != "observed" {
p.Blockers = append(p.Blockers, "distribution_unverified")
} else if r.Distribution.ID != "ubuntu" || !supportedSuite(r.Distribution) {
p.Blockers = append(p.Blockers, "unsupported_distribution")
}
if r.Privilege != "root" {
p.Blockers = append(p.Blockers, "root_required")
}
if r.Runtime.SystemdRuntime != "present" {
p.Blockers = append(p.Blockers, "systemd_unverified")
}
if r.Disk.State != "observed" {
p.Blockers = append(p.Blockers, "disk_unverified")
} else if r.Disk.AvailableBytes < 5<<30 {
p.Blockers = append(p.Blockers, "disk_below_bootstrap_floor")
}
if r.Runtime.DockerClient != "missing" {
p.Blockers = append(p.Blockers, "existing_or_unknown_runtime_requires_review")
}
// Validate the complete path set as well: an incomplete report is not clean.
seen := make(map[string]bool)
resourcesClean := len(r.Resources) == len(resourcePaths)
for _, v := range r.Resources {
if v.State != "missing" || seen[v.Path] {
resourcesClean = false
}
seen[v.Path] = true
}
for _, path := range resourcePaths {
if !seen["/"+path] {
resourcesClean = false
}
}
if !resourcesClean {
p.Blockers = append(p.Blockers, "existing_resources_require_review")
}
if r.Inventory.State != "observed" {
p.Blockers = append(p.Blockers, "package_inventory_unverified")
} else if len(r.Inventory.Packages) > 0 {
p.Blockers = append(p.Blockers, "existing_packages_require_review")
}
if len(p.Blockers) == 0 {
p.ProposedChanges = []string{"configure_verified_docker_apt_source", "install_version_locked_docker_packages", "verify_local_engine_and_compose"}
p.Impacts = []string{"apt_configuration_and_package_database_changes", "docker_service_may_start_during_install", "docker_may_change_host_network_firewall_rules", "system_disk_usage_increases"}
}
p.Blockers = append(p.Blockers, "host_identity_unverified", "package_versions_unresolved", "repository_trust_unverified", "network_and_firewall_unverified", "installation_executor_unimplemented")
return p
}
func supportedSuite(d Distribution) bool {
return map[string]string{"22.04": "jammy", "24.04": "noble", "26.04": "resolute"}[d.Version] == d.Codename && d.Codename != ""
}
var releaseValue = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,63}$`)
func readDistribution(files fs.FS) Distribution {
initial, err := fs.Stat(files, "etc/os-release")
if err != nil {
return Distribution{State: "unknown"}
}
if !initial.Mode().IsRegular() || initial.Size() > 65536 {
return Distribution{State: "invalid"}
}
f, err := files.Open("etc/os-release")
if err != nil {
return Distribution{State: "unknown"}
}
defer f.Close()
info, err := f.Stat()
if err != nil {
return Distribution{State: "unknown"}
}
if !info.Mode().IsRegular() || info.Size() > 65536 {
return Distribution{State: "invalid"}
}
raw, err := io.ReadAll(io.LimitReader(f, 65537))
if err != nil {
return Distribution{State: "unknown"}
}
if len(raw) > 65536 {
return Distribution{State: "invalid"}
}
values := map[string]string{}
for _, line := range strings.Split(string(raw), "\n") {
key, value, ok := strings.Cut(strings.TrimSpace(line), "=")
if key != "ID" && key != "VERSION_ID" && key != "VERSION_CODENAME" {
continue
}
if !ok || values[key] != "" {
return Distribution{State: "invalid"}
}
if len(value) >= 2 && ((value[0] == '"' && value[len(value)-1] == '"') || (value[0] == '\'' && value[len(value)-1] == '\'')) {
value = value[1 : len(value)-1]
}
if !releaseValue.MatchString(value) {
return Distribution{State: "invalid"}
}
values[key] = value
}
if len(values) != 3 {
return Distribution{State: "invalid"}
}
return Distribution{State: "observed", ID: values["ID"], Version: values["VERSION_ID"], Codename: values["VERSION_CODENAME"]}
}
// Walk directory entries to observe dangling/intermediate links as existing
// resources instead of following them and misreporting a clean install target.
func resourceState(files fs.FS, path string) string {
parent := "."
parts := strings.Split(path, "/")
for i, part := range parts {
entries, err := fs.ReadDir(files, parent)
if err != nil {
return "unknown"
}
found := false
for _, entry := range entries {
if entry.Name() != part {
continue
}
found = true
if i == len(parts)-1 || entry.Type()&fs.ModeSymlink != 0 || !entry.IsDir() {
return "present"
}
if parent == "." {
parent = part
} else {
parent += "/" + part
}
break
}
if !found {
return "missing"
}
}
return "unknown"
}