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