feat: add deployment foundation and cross-device handoff
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
# Read-only local environment proposal
|
||||
|
||||
`deployctl preflight` takes no stdin request. It collects facts from the local
|
||||
machine and emits protocolVersion, mode=`local-environment-proposal`, observedAt,
|
||||
report and proposal. It never connects to SSH/Docker, runs package managers,
|
||||
sources shell files, writes configuration or starts/restarts services.
|
||||
|
||||
## Observations
|
||||
|
||||
- Reuses `inspect` for OS, architecture and systemd/Docker-client file presence.
|
||||
- Reads only ID, VERSION_ID and VERSION_CODENAME from `/etc/os-release`, at most
|
||||
64 KiB. Handles plain and simply quoted values, not a shell grammar. Ambiguous,
|
||||
duplicated, missing or unsupported value syntax fails closed. Other keys are
|
||||
ignored, not evaluated or returned. Trusted host files/ancestors are assumed.
|
||||
- Linux effective UID is classified root/non_root/unknown.
|
||||
- Linux statfs reports available bytes on the filesystem containing `/var/lib`.
|
||||
This does not measure another configured data root, quotas, or inode capacity.
|
||||
Unknown disk observations cannot authorize a fresh-install candidate.
|
||||
- Resource checks inspect directory entries for `/var/lib/docker`,
|
||||
`/var/lib/containerd`, `/etc/docker`, `/var/lib/server-deploy`, and the Docker
|
||||
`.sources`/`.list` paths under `/etc/apt/sources.list.d`. Contents are not read.
|
||||
Any link or non-directory intermediate component is treated as existing;
|
||||
access failures become unknown, not absent. Checks are conservative hints,
|
||||
not a complete scan of runtime installations or APT sources.
|
||||
- Non-Linux hosts do not read Linux paths or report Linux disk availability.
|
||||
- Reads a bounded local dpkg status snapshot and requires an empty update journal.
|
||||
Reports only the Docker/runtime-related package records and the complete status
|
||||
file digest. Missing/malformed/journal-busy input is unknown, not an empty host.
|
||||
Installed, held, partial and residual relevant records all require manual review.
|
||||
It does not audit unrelated dependency health or non-dpkg installations.
|
||||
|
||||
## Candidate policy
|
||||
|
||||
Linux amd64/arm64; Ubuntu version/codename pairs 22.04/jammy, 24.04/noble,
|
||||
26.04/resolute; root; systemd path present; at least 5 GiB available on /var/lib;
|
||||
Docker client absent; all listed resource paths observed absent. The 5 GiB floor
|
||||
is only a bootstrap screening threshold, not a calculated application/image/backup
|
||||
capacity requirement. Docker official Ubuntu support was checked at implementation:
|
||||
[Docker installation requirements](https://docs.docker.com/engine/install/ubuntu/).
|
||||
This project has not certified these distributions with actual installation tests.
|
||||
|
||||
If all these observations pass, proposal lists candidate source configuration,
|
||||
version-locked package installation and engine/Compose verification steps, plus
|
||||
APT/disk/service-start/firewall impacts. It does not produce shell commands or
|
||||
claim those steps can yet execute. Existing resources cause manual-review blockers;
|
||||
there is no automatic removal, adoption, migration or package conflict cleanup.
|
||||
|
||||
Every proposal remains `executable=false`, with blockers for unverified host
|
||||
identity, unknown package inventory, unresolved versions, repository trust, network/firewall
|
||||
and the unimplemented installer. Empty candidate steps mean preliminary host
|
||||
observations also failed. Nonempty steps are NOT an approved install transaction.
|
||||
|
||||
Reports describe this process's environment (possibly a container/WSL instance),
|
||||
not necessarily the intended cloud server. No snapshot hash, SSH identity binding,
|
||||
freshness token or lock-based revalidation exists yet. These must be implemented
|
||||
before any future write path consumes observations. Exit 0 means a report was
|
||||
produced; inspect `proposal.blockers`, never exit status alone, for readiness.
|
||||
@@ -0,0 +1,22 @@
|
||||
//go:build linux
|
||||
|
||||
package preflight
|
||||
|
||||
import (
|
||||
"os"
|
||||
"syscall"
|
||||
|
||||
"server-deploy/internal/inspect"
|
||||
)
|
||||
|
||||
func Collect() Report {
|
||||
return Probe(inspect.Collect(), os.DirFS("/"), os.Geteuid(), diskAvailable("/var/lib"))
|
||||
}
|
||||
|
||||
func diskAvailable(path string) Disk {
|
||||
var stat syscall.Statfs_t
|
||||
if syscall.Statfs(path, &stat) != nil || stat.Bsize <= 0 || stat.Bavail > ^uint64(0)/uint64(stat.Bsize) {
|
||||
return Disk{State: "unknown"}
|
||||
}
|
||||
return Disk{State: "observed", AvailableBytes: stat.Bavail * uint64(stat.Bsize)}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
//go:build linux
|
||||
|
||||
package preflight
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRealDiskObservation(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if diskAvailable(dir).State != "observed" {
|
||||
t.Fatal("existing filesystem not observed")
|
||||
}
|
||||
if diskAvailable(filepath.Join(dir, "missing")).State != "unknown" {
|
||||
t.Fatal("missing target reported capacity")
|
||||
}
|
||||
}
|
||||
func TestDanglingResourceLinkRequiresReview(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(dir, "var/lib"), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink("missing", filepath.Join(dir, "var/lib/docker")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resourceState(os.DirFS(dir), "var/lib/docker") != "present" {
|
||||
t.Fatal("dangling link treated as absent")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build !linux
|
||||
|
||||
package preflight
|
||||
|
||||
import "server-deploy/internal/inspect"
|
||||
|
||||
func Collect() Report { return Probe(inspect.Collect(), nil, -1, Disk{State: "not_checked"}) }
|
||||
@@ -0,0 +1,208 @@
|
||||
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"
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package preflight
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"server-deploy/internal/inspect"
|
||||
)
|
||||
|
||||
func hostFiles() fstest.MapFS {
|
||||
return fstest.MapFS{
|
||||
"etc/os-release": {Data: []byte("ID=ubuntu\nVERSION_ID=\"26.04\"\nVERSION_CODENAME=resolute\n")},
|
||||
"var/lib/dpkg/status": {Data: []byte("Package: base-files\nStatus: install ok installed\nArchitecture: amd64\nVersion: 1.0\n")},
|
||||
"var/lib/dpkg/updates": {Mode: fs.ModeDir | 0700},
|
||||
}
|
||||
}
|
||||
|
||||
func TestPackageStateBlocksFreshInstallCandidates(t *testing.T) {
|
||||
for _, state := range []string{"install ok installed", "deinstall ok config-files", "install reinstreq half-installed"} {
|
||||
files := hostFiles()
|
||||
files["var/lib/dpkg/status"].Data = []byte("Package: containerd\nStatus: " + state + "\nArchitecture: amd64\nVersion: 1.2.3\n")
|
||||
p := Plan(Probe(baseline(), files, 0, Disk{State: "observed", AvailableBytes: 20 << 30}))
|
||||
if len(p.ProposedChanges) != 0 || !contains(p.Blockers, "existing_packages_require_review") {
|
||||
t.Errorf("existing package state %s overlooked", state)
|
||||
}
|
||||
}
|
||||
files := hostFiles()
|
||||
delete(files, "var/lib/dpkg/status")
|
||||
if p := Plan(Probe(baseline(), files, 0, Disk{State: "observed", AvailableBytes: 20 << 30})); len(p.ProposedChanges) != 0 {
|
||||
t.Fatal("unknown inventory treated as empty")
|
||||
}
|
||||
}
|
||||
func baseline() inspect.Report {
|
||||
return inspect.Report{ProtocolVersion: 1, OS: "linux", Architecture: "amd64", SystemdRuntime: "present", DockerClient: "missing"}
|
||||
}
|
||||
func TestHostFactsAndNonExecutableProposal(t *testing.T) {
|
||||
r := Probe(baseline(), hostFiles(), 0, Disk{State: "observed", AvailableBytes: 20 << 30})
|
||||
if r.Distribution.ID != "ubuntu" || r.Distribution.Version != "26.04" || r.Distribution.State != "observed" || r.Privilege != "root" {
|
||||
t.Fatalf("incorrect host facts: %+v", r)
|
||||
}
|
||||
p := Plan(r)
|
||||
if p.Executable || len(p.ProposedChanges) == 0 || !contains(p.Blockers, "package_versions_unresolved") || !contains(p.Blockers, "host_identity_unverified") {
|
||||
t.Fatalf("unsafe proposal: %+v", p)
|
||||
}
|
||||
}
|
||||
func TestExistingResourcesNeverProposeFreshInstall(t *testing.T) {
|
||||
for _, path := range []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"} {
|
||||
files := hostFiles()
|
||||
files[path] = &fstest.MapFile{Mode: fs.ModeDir | 0700}
|
||||
p := Plan(Probe(baseline(), files, 0, Disk{State: "observed", AvailableBytes: 20 << 30}))
|
||||
if len(p.ProposedChanges) != 0 || !contains(p.Blockers, "existing_resources_require_review") {
|
||||
t.Errorf("fresh install proposed over %s", path)
|
||||
}
|
||||
}
|
||||
runtime := baseline()
|
||||
runtime.DockerClient = "present"
|
||||
if p := Plan(Probe(runtime, hostFiles(), 0, Disk{State: "observed", AvailableBytes: 20 << 30})); len(p.ProposedChanges) != 0 {
|
||||
t.Fatal("existing Docker overlooked")
|
||||
}
|
||||
}
|
||||
func TestUnknownAndInsufficientHostFailsClosed(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
runtime inspect.Report
|
||||
uid int
|
||||
disk Disk
|
||||
blocker string
|
||||
}{
|
||||
{"nonroot", baseline(), 1000, Disk{State: "observed", AvailableBytes: 20 << 30}, "root_required"},
|
||||
{"disk unknown", baseline(), 0, Disk{State: "unknown"}, "disk_unverified"},
|
||||
{"disk low", baseline(), 0, Disk{State: "observed", AvailableBytes: 1}, "disk_below_bootstrap_floor"},
|
||||
{"unsupported", inspect.Report{OS: "windows", Architecture: "amd64"}, 0, Disk{}, "unsupported_platform"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
p := Plan(Probe(tc.runtime, hostFiles(), tc.uid, tc.disk))
|
||||
if !contains(p.Blockers, tc.blocker) || p.Executable || len(p.ProposedChanges) != 0 {
|
||||
t.Fatalf("unsafe proposal: %+v", p)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestOSReleaseRejectsAmbiguityAndNeverEvaluatesShell(t *testing.T) {
|
||||
for _, content := range []string{"ID=ubuntu\nID=debian\nVERSION_ID=26.04\nVERSION_CODENAME=resolute", "ID=$(touch secret)\nVERSION_ID=26.04\nVERSION_CODENAME=resolute", "ID=ubuntu\nVERSION_ID=\"26.04\nVERSION_CODENAME=resolute", strings.Repeat("#", 65537)} {
|
||||
files := hostFiles()
|
||||
files["etc/os-release"].Data = []byte(content)
|
||||
p := Plan(Probe(baseline(), files, 0, Disk{State: "observed", AvailableBytes: 20 << 30}))
|
||||
if len(p.ProposedChanges) != 0 || !contains(p.Blockers, "distribution_unverified") {
|
||||
t.Fatal("ambiguous distribution accepted")
|
||||
}
|
||||
}
|
||||
files := hostFiles()
|
||||
files["etc/os-release"].Data = []byte("ID=ubuntu\nVERSION_ID=26.04\nVERSION_CODENAME=noble\n")
|
||||
if p := Plan(Probe(baseline(), files, 0, Disk{State: "observed", AvailableBytes: 20 << 30})); !contains(p.Blockers, "unsupported_distribution") {
|
||||
t.Fatal("mismatched suite accepted")
|
||||
}
|
||||
}
|
||||
|
||||
type deniedFS struct{}
|
||||
|
||||
func (deniedFS) Open(string) (fs.File, error) { return nil, fs.ErrPermission }
|
||||
func TestAccessFailuresAreNotAbsence(t *testing.T) {
|
||||
r := Probe(baseline(), deniedFS{}, 0, Disk{State: "observed", AvailableBytes: 20 << 30})
|
||||
if r.Distribution.State != "unknown" || r.Resources[0].State != "unknown" || len(Plan(r).ProposedChanges) != 0 {
|
||||
t.Fatal("permission failure treated as clean host")
|
||||
}
|
||||
}
|
||||
func contains(values []string, want string) bool {
|
||||
for _, v := range values {
|
||||
if v == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user