209 lines
6.9 KiB
Go
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"
|
|
}
|