# 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-`, data path is `/var/lib/server-deploy/instances/`. 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": , "current": }`. 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": }`. 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": "", "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.