feat: add deployment foundation and cross-device handoff

This commit is contained in:
2026-09-25 08:49:19 +08:00
parent 8ccb8b7c15
commit e965b0943d
77 changed files with 8018 additions and 0 deletions
+57
View File
@@ -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.
+22
View File
@@ -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)}
}
+31
View File
@@ -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")
}
}
+7
View File
@@ -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"}) }
+208
View File
@@ -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"
}
+116
View File
@@ -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
}