feat: add deployment foundation and cross-device handoff
This commit is contained in:
@@ -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/).
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user