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
+191
View File
@@ -0,0 +1,191 @@
# deployctl protocol v1 — read-only foundation
# Supported commands
The runner supports `version`, `plan`, `verify-plan`, `inspect`, `preflight`, `plan-environment`, `verify-repository`, `verify-artifacts`, `verify-package`
and `check-package`.
It does not connect to a remote server, invoke Docker, install software, change system
configuration, deploy applications, or restore data. `apply`, `upgrade` and `restore`
fail closed. `verify-repository` and `verify-artifacts` create private temporary snapshots and invoke
local Linux GnuPG; it does not modify APT or persistent keyrings.
## Build and try
For staged official Docker metadata authentication, see
[verify-repository contract](../internal/aptrepo/README.md). Its output is not
accepted as execution authorization by `plan-environment`.
Requires Go 1.26 or newer. No third-party Go dependencies.
```powershell
go test ./... -count=1
go vet ./...
go build -o dist/deployctl.exe ./cmd/deployctl
Get-Content -Raw protocol/examples/plan-request.json | ./dist/deployctl.exe plan
node --test tests/cli-smoke.test.mjs
```
```sh
go build -o dist/deployctl ./cmd/deployctl
./dist/deployctl plan < protocol/examples/plan-request.json
```
The example hashes are synthetic, not deployable package/image references.
## Input
`plan`, `verify-plan`, `verify-package`, `check-package`, `plan-environment`, `verify-repository` and `verify-artifacts` receive one UTF-8 JSON value through
stdin, maximum 1 MiB. `version`, `inspect` and `preflight` require no stdin request.
Unknown fields, case aliases, duplicate keys, missing fields, nulls, additional
JSON values, excessive nesting and malformed types are rejected.
Errors go to stderr without echoing input; exit status is nonzero. stdout only
contains a JSON response on success (an output transport failure can truncate it).
`plan` requires exactly the fields in `examples/plan-request.json`:
- protocolVersion: integer 1.
- hostId, instanceId, appId: lower-case ASCII identifiers, first character a-z,
remaining characters a-z, 0-9 or hyphen; 1–48 characters.
- packageDigest, imageDigest, observedStateDigest: `sha256:` and 64 lowercase hex characters.
- domain: explicit lowercase ASCII DNS name, at least two labels, not an IP,
wildcard, URL, port or trailing-dot name. IDNs must already be in ASCII form.
`imageDigest` is a preliminary single-image binding. The deployable package
contract will bind all component images (including private databases); this
foundation does not claim to represent a complete multi-container release.
## Plan and verification
`plan` returns protocolVersion, mode=`offline-preview`, executable=false and plan.
The plan contains intent, projectName, dataPath, createdAt, expiresAt and hash.
Times are UTC RFC3339 with second precision; the validity window is 15 minutes.
Project name is `sd-<instanceId>`, data path is
`/var/lib/server-deploy/instances/<instanceId>`. The path is only a preview;
symlink-safe filesystem access is a separate executor responsibility.
Hash is SHA-256 over Go JSON encoding of Plan with an empty hash field, in its
declared field order. The CLI is the canonical plan producer; clients transport
the returned plan without rebuilding its hash. This is content binding, NOT a
signature, authorization, approval token or package trust check.
`verify-plan` receives exactly `{ "plan": <returned plan>, "current": <intent> }`.
It rejects changed identities, state digests, derived resources, hashes, future
creation times and expiry (including the exact expiration timestamp).
Success returns protocolVersion, mode, valid=true and executable=false.
Host identity and state digest are caller-supplied offline inputs. Verification
does NOT prove anything about a real server. Future apply must independently
inspect the host under the host lock, resolve the entire trusted package and
revalidate it; offline preview is never permission to deploy.
## Local prerequisite inspection
`inspect` takes no stdin request and examines the machine where deployctl runs.
It reports OS/architecture, the presence of `/run/systemd/system`, and an executable
regular Docker client at `/usr/bin/docker` or `/usr/local/bin/docker`. It does not
run that executable or read Docker configuration/credentials. Access failures
appear as `unknown`, not `missing`. Non-Linux targets do not probe Linux paths.
`supportedPlatform` only describes the initial Linux amd64/arm64 runtime family,
not distro certification. `deploymentReady` is always false in this foundation.
Daemon connectivity, Compose version, host identity, distro acceptance, permissions,
disk space, ports, DNS, firewall and gateway validation are listed as unchecked.
This report is not the authoritative observed state required by a future apply.
### Environment proposal
`preflight` adds local Ubuntu release, effective privilege, Linux `/var/lib`
filesystem available bytes and conservative existing-resource observations.
Output includes protocolVersion=1, mode=`local-environment-proposal`, observedAt,
report and proposal. It does not accept caller-supplied observations over stdin.
The proposal reports blockers, candidate changes and impacts; executable is always
false. Existing/unknown resources are never silently adopted or removed. Candidate
steps, when present, are not an executable installation plan: package inventory,
version locks, repository trust, host identity and network checks remain unresolved.
Exit 0 means the report was produced, not that the host is ready. See the
[preflight contract](../internal/preflight/README.md) for exact boundaries.
### Version-locked environment draft
`plan-environment` receives `{ "lock": <Docker package lock> }`. See the
[lock contract](../internal/installplan/README.md) for all required fields.
Local observations are collected by the CLI, not supplied by the caller.
Output includes protocolVersion=1, mode=`local-environment-draft`, observedAt,
report and draft. The draft includes requestedPackages, lockDigest,
observationDigest, impacts, blockers, executable=false and
repositoryAuthenticated=false. No shell command, download or installation runs.
Preflight now includes a local dpkg inventory observation. Relevant existing or
residual packages prevent fresh-install candidates; unknown status/journal cannot
be treated as absence. The lock is only syntactically pinned to an allowed source:
repository signature, signed metadata chain, package bytes, freshness and dependency
resolution remain blockers. This is not a complete or authorized APT transaction.
## Local application package verification
`verify-package` reads `{ "directory": "<absolute package directory>",
"expectedDigest": "sha256:<64 lowercase hex>" }` from stdin.
The expected digest pins the exact manifest.json bytes; the manifest pins every
payload file and every component's image reference. Obtain the expected digest
through a trusted channel, not from the untrusted package being checked.
The output includes the verified manifest, manifest digest, payload file count,
verified=true, executable=false and publisherAuthenticated=false. File contents
are never echoed. Integrity is not publisher authentication, Compose-policy
validation, container-image availability, backup compatibility or deployment safety.
Packages must be immutable, trusted staging directories during verification.
The internal verifier returns the verified payload snapshot for future consumers;
execution must not reopen mutable source files after a check. No package extraction,
download, signature trust-store management or image pull is implemented yet.
### Manifest contract
Every field is required; unknown fields are rejected:
- `protocolVersion`: 1; `runtime`: `compose`.
- `appId`: the same lowercase identifier rules as the offline intent.
- `version`: three numeric components separated by dots (no prerelease suffix).
- `entrypoint`: the path of one declared payload file.
- `platforms`: a nonempty unique subset of `linux/amd64`, `linux/arm64`.
- `components`: 1–32 unique `{name, image}` entries. Names use identifier rules;
images use lowercase repository paths followed by `@sha256:<64 lowercase hex>`.
This initial lexical subset excludes tags and registry ports; it is not a
complete Docker reference parser or a check against the Compose services.
- `files`: 1–128 unique `{path, digest}` entries. Digests pin payload bytes;
`manifest.json` itself must not appear in this list.
Paths are lowercase portable relative slash paths, at most 240 bytes overall and
100 bytes per segment. Segments begin with ASCII alphanumeric characters and use
only ASCII alphanumeric, dot, underscore or hyphen. Dotfiles, traversal, backslashes,
colons, Windows device names and trailing dots are rejected. The directory must
contain only `manifest.json`, listed payload files and their required parents.
Limits are 1 MiB for the manifest, 4 MiB per payload and 16 MiB for total payloads.
Symlinks and special files are rejected. See `internal/appbundle/README.md` for
filesystem trust boundaries and Linux-specific rejection tests.
## Restricted Compose policy check
`check-package` accepts the same request as `verify-package`. It performs the
complete integrity check first, then validates the returned entrypoint bytes
against `isolated-compose-v1`. No files are reopened and no Docker command runs.
Success returns protocolVersion=1, policyPassed=true, profile, the manifest digest,
executable=false and publisherAuthenticated=false. Failure returns no JSON result
and a fixed redacted diagnostic. `verify-package` remains integrity-only; a package
may pass that command while failing `check-package`.
The initial profile accepts only strict JSON, pinned manifest-matching services,
explicit non-root identities, read-only roots, dropped capabilities, no new
privileges, a private internal backend network and plain project-local named
volumes. Every field in the profile is required; other fields are rejected.
See [exact shape and limitations](../internal/composepolicy/README.md).
This is not a general YAML validator or completed application adapter. Traefik
routes, environment/secrets, application-specific permissions, Docker version
compatibility, volume ownership and resource limits are not implemented here.
Passing never establishes that an application can start or its data can be safely
upgraded/restored. Future execution still requires locked host/resource checks,
trusted images, a fixed Compose invocation and exact snapshot binding.
+10
View File
@@ -0,0 +1,10 @@
{
"protocolVersion": 1,
"hostId": "example-host",
"instanceId": "git-one",
"appId": "gitea",
"packageDigest": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"imageDigest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"domain": "git.example.com",
"observedStateDigest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
}