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
+50
View File
@@ -0,0 +1,50 @@
# Environment lock and draft
`plan-environment` accepts strict JSON `{ "lock": <Lock> }`, collects local
preflight observations itself, and emits a non-executable draft. It does not
accept caller-supplied host observations, download anything, run apt, configure
sources or start services.
Lock fields, all required:
- protocolVersion: 1.
- repository: exactly `https://download.docker.com/linux/ubuntu`.
- suite: jammy, noble or resolute; architecture: amd64 or arm64.
- releaseDigest: `sha256:` plus 64 lowercase hex digits, supplied by the caller.
- packages: exactly docker-ce, docker-ce-cli, containerd.io,
docker-buildx-plugin and docker-compose-plugin, once each.
- Each package has name, version, filename, digest and size. Version is explicit
digit-leading Debian-style syntax (optional numeric epoch), at most 128 bytes;
digest uses the above SHA-256 format; size is 1 through 512 MiB.
- Filename must equal
`dists/<suite>/pool/stable/<architecture>/<name>_<version-without-epoch>_<architecture>.deb`.
Encoded paths, absolute paths, alternate domains, query strings and traversal
are not accepted. docker-ce and docker-ce-cli must use the same version.
This is a deliberately limited Docker lock format, not a complete Debian version
parser. A Linux host's observed distribution/suite and architecture must match;
unknown observations remain blockers. On a non-Linux machine a syntactically
valid lock can be reviewed, but unsupported-platform blockers remain.
## Digests and remaining trust boundary
lockDigest binds Go JSON encoding of the validated Lock in struct/array order.
observationDigest binds Go JSON encoding of the collected Report. These are
content hashes, not signatures, stable host IDs, freshness tokens or authorization.
The local observation is not atomic and changes (including free disk space) can
change its hash. No writing consumer may treat it as an approved plan.
The draft returns requestedPackages, not a complete APT dependency transaction.
It never proposes automatic removal or upgrade of existing installations.
RepositoryAuthenticated and executable are always false. There is no signature
verification, Release-to-Packages-to-deb digest chain verification, metadata
freshness policy, artifact download, package dependency resolution, or installation
executor yet. Matching a URL allowlist and a caller-provided hash proves none of
those. No actual versions are recommended or locked from live metadata in this batch.
Blockers explicitly retain these gaps along with host/network/runtime checks.
Potential APT database changes, dependency changes, service starts and network
rule effects are reported. Exit 0 only means a draft was produced.
Reference for package names and repository layout:
[Docker Ubuntu installation](https://docs.docker.com/engine/install/ubuntu/).
+68
View File
@@ -0,0 +1,68 @@
package installplan
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"regexp"
"strings"
)
type Lock struct {
ProtocolVersion int `json:"protocolVersion"`
Repository string `json:"repository"`
Suite string `json:"suite"`
Architecture string `json:"architecture"`
ReleaseDigest string `json:"releaseDigest"`
Packages []Package `json:"packages"`
}
type Package struct {
Name string `json:"name"`
Version string `json:"version"`
Filename string `json:"filename"`
Digest string `json:"digest"`
Size uint64 `json:"size"`
}
var versionPattern = regexp.MustCompile(`^(?:[0-9]+:)?[0-9][0-9A-Za-z.+~-]*$`)
var digestPattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`)
var required = []string{"docker-ce", "docker-ce-cli", "containerd.io", "docker-buildx-plugin", "docker-compose-plugin"}
// Validate checks a caller-supplied lock against a fixed source policy. This is
// NOT repository signature validation or evidence these artifacts exist.
func Validate(lock Lock, suite, architecture string) (string, error) {
reject := errors.New("invalid Docker package lock")
if lock.ProtocolVersion != 1 || lock.Repository != "https://download.docker.com/linux/ubuntu" || lock.Suite != suite || lock.Architecture != architecture {
return "", reject
}
if (suite != "jammy" && suite != "noble" && suite != "resolute") || (architecture != "amd64" && architecture != "arm64") || !digestPattern.MatchString(lock.ReleaseDigest) || len(lock.Packages) != len(required) {
return "", reject
}
versions := map[string]string{}
for _, p := range lock.Packages {
allowed := false
for _, name := range required {
if p.Name == name {
allowed = true
}
}
if !allowed || versions[p.Name] != "" || len(p.Version) > 128 || !versionPattern.MatchString(p.Version) || !digestPattern.MatchString(p.Digest) || p.Size == 0 || p.Size > 512<<20 {
return "", reject
}
fileVersion := p.Version
if _, after, ok := strings.Cut(fileVersion, ":"); ok {
fileVersion = after
}
if p.Filename != "dists/"+suite+"/pool/stable/"+architecture+"/"+p.Name+"_"+fileVersion+"_"+architecture+".deb" {
return "", reject
}
versions[p.Name] = p.Version
}
if versions["docker-ce"] != versions["docker-ce-cli"] {
return "", reject
}
raw, _ := json.Marshal(lock)
sum := sha256.Sum256(raw)
return "sha256:" + hex.EncodeToString(sum[:]), nil
}
+79
View File
@@ -0,0 +1,79 @@
package installplan
import (
"strings"
"testing"
)
func lockFixture() Lock {
l := Lock{ProtocolVersion: 1, Repository: "https://download.docker.com/linux/ubuntu", Suite: "resolute", Architecture: "amd64", ReleaseDigest: "sha256:" + strings.Repeat("b", 64), Packages: []Package{}}
for _, name := range []string{"docker-ce", "docker-ce-cli", "containerd.io", "docker-buildx-plugin", "docker-compose-plugin"} {
l.Packages = append(l.Packages, Package{Name: name, Version: "1.2.3-1", Filename: "dists/resolute/pool/stable/amd64/" + name + "_1.2.3-1_amd64.deb", Digest: "sha256:" + strings.Repeat("a", 64), Size: 123})
}
return l
}
func TestValidLockAndStableDigest(t *testing.T) {
l := lockFixture()
digest, err := Validate(l, "resolute", "amd64")
if err != nil || !strings.HasPrefix(digest, "sha256:") {
t.Fatal("valid lock rejected", err)
}
l.Packages[0].Size++
changed, err := Validate(l, "resolute", "amd64")
if err != nil || changed == digest {
t.Fatal("lock digest does not bind size")
}
}
func TestRejectUnsafeLock(t *testing.T) {
for name, mutate := range map[string]func(*Lock){
"protocol": func(l *Lock) { l.ProtocolVersion = 2 },
"repository": func(l *Lock) { l.Repository = "https://attacker.example/linux/ubuntu" },
"credentials": func(l *Lock) { l.Repository = "https://user:secret@download.docker.com/linux/ubuntu" },
"suite": func(l *Lock) { l.Suite = "noble" },
"architecture": func(l *Lock) { l.Architecture = "arm64" },
"release": func(l *Lock) { l.ReleaseDigest = "" },
"missing": func(l *Lock) { l.Packages = l.Packages[:4] },
"extra": func(l *Lock) { l.Packages = append(l.Packages, l.Packages[0]) },
"duplicate": func(l *Lock) { l.Packages[1] = l.Packages[0] },
"latest": func(l *Lock) { l.Packages[0].Version = "latest" },
"shell": func(l *Lock) { l.Packages[0].Version = "1;reboot" },
"wrong file": func(l *Lock) { l.Packages[0].Filename = "dists/resolute/pool/stable/amd64/other.deb" },
"traversal": func(l *Lock) { l.Packages[0].Filename = "../docker.deb" },
"encoded path": func(l *Lock) { l.Packages[0].Filename = "dists/resolute/pool/stable/amd64/%2e%2e.deb" },
"digest": func(l *Lock) { l.Packages[0].Digest = "bad" },
"size": func(l *Lock) { l.Packages[0].Size = 0 },
"engine mismatch": func(l *Lock) {
l.Packages[1].Version = "2.3.4-1"
l.Packages[1].Filename = "dists/resolute/pool/stable/amd64/docker-ce-cli_2.3.4-1_amd64.deb"
},
} {
t.Run(name, func(t *testing.T) {
l := lockFixture()
mutate(&l)
if _, err := Validate(l, "resolute", "amd64"); err == nil {
t.Fatal("unsafe lock accepted")
}
})
}
}
func TestDockerEpochAndTargetArchitecture(t *testing.T) {
l := lockFixture()
for i := range l.Packages {
p := &l.Packages[i]
if p.Name == "docker-ce" || p.Name == "docker-ce-cli" {
p.Version = "5:29.1.0-1~ubuntu.26.04~resolute"
p.Filename = "dists/resolute/pool/stable/amd64/" + p.Name + "_29.1.0-1~ubuntu.26.04~resolute_amd64.deb"
}
}
if _, err := Validate(l, "resolute", "amd64"); err != nil {
t.Fatal("epoch version rejected", err)
}
if _, err := Validate(l, "resolute", "arm64"); err == nil {
t.Fatal("architecture mismatch accepted")
}
l.Packages[0].Filename = strings.Replace(l.Packages[0].Filename, "_29.", "_5:29.", 1)
if _, err := Validate(l, "resolute", "amd64"); err == nil {
t.Fatal("epoch in repository filename accepted")
}
}
+42
View File
@@ -0,0 +1,42 @@
package installplan
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"server-deploy/internal/preflight"
)
type Draft struct {
Executable bool `json:"executable"`
RepositoryAuthenticated bool `json:"repositoryAuthenticated"`
LockDigest string `json:"lockDigest"`
ObservationDigest string `json:"observationDigest"`
Blockers []string `json:"blockers"`
RequestedPackages []Package `json:"requestedPackages"`
Impacts []string `json:"impacts"`
}
// Build binds requested package pins to a local observation for review only.
// Neither digest is a signature, host identity, freshness token or approval.
func Build(report preflight.Report, lock Lock) (Draft, error) {
digest, err := Validate(lock, lock.Suite, lock.Architecture)
if err != nil {
return Draft{}, err
}
if report.Runtime.OS == "linux" && (report.Runtime.Architecture != lock.Architecture || (report.Distribution.State == "observed" && (report.Distribution.ID != "ubuntu" || report.Distribution.Codename != lock.Suite))) {
return Draft{}, errors.New("lock does not match local platform")
}
proposal := preflight.Plan(report)
blockers := []string{}
for _, b := range proposal.Blockers {
if b != "package_versions_unresolved" {
blockers = append(blockers, b)
}
}
blockers = append(blockers, "dependency_transaction_unresolved", "artifact_bytes_unverified", "repository_metadata_freshness_unverified")
raw, _ := json.Marshal(report)
sum := sha256.Sum256(raw)
return Draft{LockDigest: digest, ObservationDigest: "sha256:" + hex.EncodeToString(sum[:]), Blockers: blockers, RequestedPackages: append([]Package{}, lock.Packages...), Impacts: []string{"package_database_and_repository_changes", "services_may_start_during_package_install", "host_network_rules_may_change", "additional_dependencies_not_yet_resolved"}}, nil
}
+39
View File
@@ -0,0 +1,39 @@
package installplan
import (
"strings"
"testing"
"server-deploy/internal/inspect"
"server-deploy/internal/preflight"
)
func TestDraftPreservesSafetyBlockersAndBindsInputs(t *testing.T) {
r := preflight.Report{Runtime: inspect.Report{OS: "linux", Architecture: "amd64"}, Distribution: preflight.Distribution{State: "observed", ID: "ubuntu", Version: "26.04", Codename: "resolute"}}
d, err := Build(r, lockFixture())
if err != nil || d.Executable || d.RepositoryAuthenticated || len(d.Blockers) == 0 || !strings.HasPrefix(d.LockDigest, "sha256:") || !strings.HasPrefix(d.ObservationDigest, "sha256:") {
t.Fatalf("unsafe draft %+v %v", d, err)
}
for _, want := range []string{"repository_trust_unverified", "dependency_transaction_unresolved", "artifact_bytes_unverified"} {
found := false
for _, b := range d.Blockers {
if b == want {
found = true
}
}
if !found {
t.Fatalf("missing blocker %s", want)
}
}
r.Privilege = "non_root"
d2, _ := Build(r, lockFixture())
if d2.ObservationDigest == d.ObservationDigest {
t.Fatal("report change not bound")
}
l := lockFixture()
r.Distribution.Version = "24.04"
r.Distribution.Codename = "noble"
if _, err := Build(r, l); err == nil {
t.Fatal("host and lock mismatch accepted")
}
}