Main sync: C01 locking+drain, C05 plan/apply, corpus fixes (tree-identical) - #18
Merged
Merged
Conversation
…e receipt One executable harness (ADR §6 S7) running this repo's legs of the programme's acceptance line: rename, duplicate identity, repeated request, response loss, rollback — target/history identity preserved end to end. Vacuous-match guard: a -run pattern matching zero tests fails the leg. Receipt table recorded in AUDIT_OPEN.
… manifest C05 plan/apply needs to diff planned config against what a release actually deployed. The deployed shape already exists — the manifest NormalizeAndDigest writes into state (env as key presence, values redacted) — but nothing could read it back, so every consumer would have re-derived the JSON shape by hand. ParseAppliedManifest decodes that manifest into a typed view (container image/port/replicas/bind, env keys, env files, volumes, memory/cpu, publish, processes; accessories with the same fields). It sits next to the writer so the shape has one home. Malformed input is refused, never guessed from (empty, non-object root, mistyped sections); null container/accessories sections parse as absent so static releases and pre-manifest records stay representable. Round-trip tests pin writer/reader agreement, the no-secret-values invariant, static absence, and the refusal set.
…e/resources, image known-vs-unresolved, binding identity The C05 plan surface. `teploy plan` (existing command, extended — no fork) now computes the full known-effect set, not just containers: - routing: domain set (order/case normalized), ingress mode, the application port the Caddy route and health gate probe, extra publishes — diffed against deployed state (AppState identity) and the recorded applied manifest. - env: KEY presence diff (values are redacted by the manifest contract — only key and env-file-reference changes are plannable; a note states that file VALUES resolve at deploy). - storage: volume declarations (add/remove/mount-path change) with the managed host path named via plannedVolumeMounts — extracted from deployBuiltImageFenced so plan and deploy share ONE volume resolution (a plan can never describe a different host layout than the deploy creates). Behavior-preserving; deploy tests green. - resources: replicas, memory, cpu against the recorded manifest. - accessories: set membership + image changes. Image data is classified, not guessed: resolved-by-digest (pinned ref), resolved-by-image-id (mutable ref, content resolved at plan time via the C04 provenance path — reusing resolveDeployProvenance so plan and deploy cannot disagree about what the build inputs are), unresolved-mutable-tag, unresolved-awaiting-build. A build plan binds the build INPUTS (context fingerprint, Dockerfile identity) instead of an image that does not exist yet; a mutable tag is explicitly recorded as binding the reference, not the bytes. --out FILE writes a PlanRecord: schema-versioned, plan id = pure hash of the binding inputs (app/server/user/destination/version/ config digest/build-input identities/state generation+hash), atomic 0600 write, self-consistency-validated on load (tampered or future-schema plans are refused). New flags mirror deploy: --image, -d/--destination (overlay captured in the record; apply must resolve the same merged config). plan --json keeps every pre-existing key (app/server/target_version/version_known/ same_version/changes) with the record fields riding additively — no MachineInterface bump. Config digest is NormalizeAndDigest evaluated with the image the plan resolved (empty for builds): recomputable at apply-time BEFORE any execution, unlike the receipt digest which names the built image — the plan id stamped into the receipt is the tie between the two (next commit).
…keover Lock acquisition was treated as quiescence: acquireAutoLock broke a stale lock and the deploy proceeded with no observation of the dead holder's leftover world (ADR finding C01-1). A late docker run issued by the dead owner landed under version-keyed names and surfaced later as a generic docker error, not a reconciliation. - state.acquireAutoLock reports whether the acquisition broke a stale auto/heal lock; Lock.TookOver() exposes it (nil lock: false). Fresh acquisitions unchanged. - deploy.Observe (internal/deploy/reconcile.go) productionizes the fault harness's evidence collector: docker label inventory, state.json, the managed Caddyfile, the per-release record -> recovery.Observation, exact names; read failures map to Unknown (never-auto-decide). The decision stays the pure table's (recovery.Decide). - DeployFenced step 1c: on takeover, ReconcileAfterTakeover runs BEFORE the deploy's first effect. RETRY is the only proceed disposition (surfaced); INSPECT gets one bounded re-observation (transient reads) then refuses; COMPENSATE/MANUAL refuse immediately, every refusal carrying the observed evidence classes and inspect commands. Not an auto-compensator: that is the F04-keyed recovery-owner continuation; refusing with evidence is the safe subset the table permits. Tests: mock suite (foreign running workload -> MANUAL with zero effects; clean world proceeds; same-version disagreement INSPECT per R6; candidate-without-receipt INSPECT; traffic-on-uncommitted COMPENSATE; transient read recovers; Observe classification pins) plus a real-fixture integration test (colima, docker 29.5.2): the dead owner's nohup'd late candidate lands after the genuine stale-break acquisition and the production reconciler refuses MANUAL. Existing fault harness unchanged and passing on the same fixture.
…e deploy engine
C05's apply half. `teploy apply <plan-file>` executes a plan written
by `teploy plan --out` through the EXISTING engine — deployAppConfig,
the same function a direct `teploy deploy` runs (planID threaded
through deployBuiltImageFenced; every other caller passes "").
There is no side execution path.
Before anything runs, the plan's binding is re-derived and compared
(drift = refusal naming what moved, with the remedy):
- config: NormalizeAndDigest over the current teploy.yml with the
plan's recorded overlay (`applyResolveCurrent` shares
resolveDeployEnv — extracted from runDeploy so an applied plan
resolves env with the SAME semantics a direct deploy does) and the
plan's image reference. An overlay edit — including strict
presence-aware clears (env: {}) — moves the digest and refuses.
- target version: explicit --version binds as-is; a derived one
(git hash / image tag) must re-derive identically.
- build inputs: build plans re-resolve the C04 context fingerprint
and Dockerfile identity and compare — this catches dirty-tree
edits a version cannot see.
- identity: app + resolved server host.
- target state: the deployed generation. Any deploy/rollback/scale
in between increments it; a first-deploy plan against now-deployed
state (and vice versa) refuses.
Unverifiable plans refuse outright: a floating-tag version (a
deploy-time timestamp) can never be bound. Tampered plan files are
refused at load (plan id recomputation, schema version gate).
Receipt tie-back: releasemeta.Provenance gains plan_id (additive,
omitempty) stamped at deployBuiltImageFenced when a plan executes,
so the F14 release record names the reviewed plan it came from.
Provenance round-trip carries it; engine-level test runs the real
deployBuiltImageFenced over a mock executor and asserts both the
stamped provenance.json in the attempt namespace and the committed
release state.
Drift refusals classify as the error-envelope conflict code under
--json (the code existed in the taxonomy as UNMIGRATED; this wires
its first site): coherent request, world moved — distinct from
config-invalid and internal.
Static apps: apply verifies and executes them (runStaticDeploy via
deployAppConfig) but that path writes no provenance receipt, so no
plan-id stamp — recorded as an open tail in AUDIT_OPEN.
Candidate starts, worker starts and the route switch ran lk.Check as a command separate from the effect; only the state commit composed guard+effect (ADR finding C01-2). Between check and effect a takeover could let a broken holder's effects land inside the new owner's window. - state.Lock.GuardPrefix() exposes the composed guard prefix and state.FenceLost(err) matches refusals across packages; docker and caddy consume the prefix string, not the Lock type. - docker.RunGuarded composes the holdership guard with the docker run in one remote command; DeployFenced's web-candidate and worker starts use it, mapping refusals to ErrFenceLost into the existing recovery handlers. The separate pre-effect Checks at those sites are superseded by the composition. - caddy.Client.WithCommitGuard: mutate now stages the new Caddyfile to an inert random sibling and commits it with ONE guarded mv -fT (the traffic-switch instant). Reload and delivery verification remain separate (idempotent after a refused commit means nothing became authoritative); rollback restores stay deliberately unfenced. Wired through deploy step 11, rollback, and the three static SetStaticRoute sites. Tests: happy path proves guard+effect composition on both container starts and the Caddyfile commit; a mid-deploy takeover at the health gate refuses the route switch (no Caddyfile written, no reload, ErrFenceLost) and the worker start (no executed run); the pre-existing late-holder test asserts on executed (bare) docker runs -- a refused start now appears only as the composed command the guard rejected. Caddy-side refusal + legitimate-holder tests added; all pre-existing suites unchanged and green.
…tion (flock + generation fencing + protocol outcomes) internal/targetguard: a POSIX helper (guard.sh, embedded) uploaded over SSH and invoked to run ONE protected effect under an OS-exclusive flock with generation fencing, plus the Go wrapper mapping its first-line protocol to typed outcomes (ErrBusy retryable; ErrFenced = stale plan, reconcile never blind-retry per D11; ErrTargetUnfit fail-closed). The helper self-tests the lock primitive once per app dir (marker under the lock) and refuses (GUARD_UNFIT) if the target's flock does not serialize — a falsely-held lock is worse than none. Outcome rides stdout's first line because the ssh Executor abstraction does not preserve exit codes. LIVE PROOF (podman, 2026-09-23, debian bookworm-slim + alpine 3.20): serialization timestamped ABAB (one full critical section then the other's — the investigation initially read ABAB as interleaving; the inverted harness evaluation was caught and corrected, the timestamped runs are the evidence); fencing refuses gen-7-committed vs plan-expects-3 with the effect untouched; killed helper auto-releases (post-death effect OK); current generation passes. The busybox util-linux FILE-form non-portability observations motivated the self-test hardening. Linux CI tests (guard_linux_test.go, gated) pin the same invariants with the corrected serialized signature; wrapper semantics pinned on every platform. NEXT SLICE (recorded in _internal): integrate guarded effects into the deploy path (state commit + predecessor retirement under the guard; .generation sidecar written by the state commit) and the acquisition-order doc for the shared-proxy commit lock.
…rness lesson + slice-2 remainder)
…nd volumes
Found by the C05 plan conformance suite: mapCompose parsed
environment/volumes only in the ACCESSORY branches, so the WEB
service's 'environment:' and 'volumes:' were silently dropped — an
imported stack deployed without any of the web service's env or
storage while reporting a successful import. Exactly the class the
C05 preserve/translate/reject contract exists to prevent, missed
because the earlier field-classification pass inventoried REJECTED
fields and the accessory translators, not the web service's own
translate-paths.
Both now translate: environment -> teploy.yml env (values verbatim;
deploy-time $\{VAR\} expansion matches Compose's interpolation intent
for the literal common case), volumes -> teploy.yml volumes with a
new parseWebVolumes that preserves host-bind sources as binds
(an absolute source keeps its full path key — IsHostBindVolume's
contract). The accessory translator (parseServiceVolumes) still
basenames host-bind sources: pre-existing behavior, left untouched,
recorded in AUDIT_OPEN as a finding.
Regression: TestLoadCompose_WebServiceEnvAndVolumesPreserved; the
plan conformance test TestComposePlan_EffectSetSurvivesImport fails
without this fix (verified live — it ran red against the stashed
pre-fix importer).
…05 conformance and docs Machine interface: 'plan-apply' capability token added (additive — no MI bump): plan --out/apply with drift-invalidated binding and provenance.plan_id receipt tie-back. Registry golden updated (the addition is forced deliberate), version-handshake fixture regenerated, contracts corpus bumped to rev 3. Corpus: plan-record schema (binding identity, image resolution enum closed to resolved-by-digest | resolved-by-image-id | unresolved-mutable-tag | unresolved-awaiting-build, effect vocabularies, plan_id 16-hex pattern) + fixtures generated from the real record construction: build plan (unresolved-awaiting-build, build inputs bound), prebuilt digest-pinned plan (resolved-by-digest), and an invalid tampered-id record pinning the load-time refusal. C05 acceptance conformance (composeplan_test.go): compose fixtures survive import -> plan with the known-vs-unresolved classification asserted (build service plans as unresolved-awaiting-build WITH a bound context fingerprint; digest-pinned image plans resolved-by-digest carrying its digest; an unresolvable mutable tag degrades to unresolved-mutable-tag exactly as live); the translated surfaces plan as effects (accessory adds, env keys, web storage with the managed host path); refused shapes (distinct builds) never plan. README core commands + CHANGELOG Unreleased entry + AUDIT_OPEN slice record with design, evidence, mutation checks and the explicit C05 remainder (stack resource breadth, Compose breadth beyond the inventory, single-target apply, static provenance receipt, env-value binding outside the redaction contract, accessory volume basename finding).
…rface (Close/Host/User/RunStream/RunInput) The linux-gated test file is invisible to macOS go vet, so the interface miss only surfaced in CI (sync PR #11). GOOS=linux go vet/build now part of the local pre-push check for this package.
…flicting-route reconciliation The shared-proxy lock was a bare ownerless mkdir broken by directory mtime after 120s (ADR finding C01-3): a slow-but-alive orphaned editor could interleave its Caddyfile edit with the new owner's mutate, and the table's 'conflicting route evidence -> INSPECT' had no producer or consumer. - caddy.acquireLock writes an owner-tagged caddy-edit info file (owner token + RFC3339 ts, mirroring the app locks). Staleness is measured from the info timestamp (unparseable = stale); legacy no-info dirs keep the mtime fallback. No renewal -- edit sessions are seconds. TTL unchanged at 120s. - The Caddyfile commit chains the CADDY-LOCK guard after the app-fence guard: a stale-broken holder's late edit is refused in-shell and cannot interleave with the successor. Acquisition order documented and test-pinned: app guard first (long-held), caddy guard second (brief), never inverse, never cross-host. - releaseLock removes the lock only when its info still names the releaser (the app locks' A04 lesson) -- a stale holder's deferred release cannot delete the successor's lock. - deploy.Observe classifies route evidence exactly: a managed block naming neither the attempted candidates nor the predecessor's containers (the dead-holder late-route-edit shape) is CONFLICTING (Unknown), which Decide sends to INSPECT -- previously it collapsed into a false 'route to predecessor' that could yield a blind RETRY. No managed block at all is clean absence. Tests: stale break by info age; fresh holder never broken; broken holder's commit refused with nothing landed; release spares a successor's lock; guard order pinned on the deploy commit command; third-generation route -> Unknown -> INSPECT; mock executor and the stateful caddy fake evaluate chained guards. The C01 locking-protocol redesign (C01-1/2/3) is closed; residuals are the F04-keyed C01-8/9 and the A12/T05 rollback-route remainder.
…rness (VAR=x; cmd does not export in dash)
…s, once-only upload, GUARD_-scan protocol parse Two real CI findings: (1) concurrent goroutines raced the shared run.sh/ guard upload paths (unique per-invocation scripts + sync.Once upload); (2) bash job-control prints a 'Killed' notice to stdout before the protocol line when the effect is SIGKILLed — the wrapper now scans for the first GUARD_-prefixed line instead of trusting line 1.
…n distinction C03's ingress half: distinguish readiness (health mode + deadline), liveness (container HEALTHCHECK), graceful stop (stop_timeout's SIGTERM->SIGKILL ladder) and REQUEST DRAIN -- and add the missing drain: the window between the traffic switch and predecessor retirement during which the predecessor keeps serving in-flight requests on its existing connections. - drain_seconds (0..600, default 0 = historical stop-immediately compat) in teploy.yml, destination overlays, and the effective-config manifest (drift identity); mapped into deploy/rollback/scale configs at the single config->deploy seam. - Deploy: after the route switch + state commit, the window elapses before predecessor retirement -- caddy-routed blue/green only (external ingress is the operator's edge; recreate already stopped the fixed-port workload). Rollback: the same window before stopping the superseded generation. Cancellation cuts the window short. - The deploy output surfaces all four policies before the switch. - Honest scope: Caddy's config-level routing cannot count in-flight requests per upstream, so the drain policy IS the window plus the ladder -- documented in README, config comments, and the deploy output. Nothing promises request counting. Tests: mock wiring (reload -> window -> stop ordering, window elapses, policy lines, drain-0 compat, external-ingress skip, rollback window, config parse/bounds/overlay/manifest). Real fixture (colima docker 29.5.2 + caddy:2-alpine, the production caddy.Client switch path): 90 requests across a blue/green switch with a 3.5s request in flight, 5s drain, graceful stop -- ZERO failed requests, the long request completes end-to-end on blue inside the window, traffic serves green after. Negative control: stop -t 0 breaks the in-flight request (502), proving the fixture detects a broken promise. Integration tests close SSH sessions after t.Cleanup work (LIFO) -- a plain defer silently raced the fixture cleanups.
…s shell syntax executed at invocation level, outside the guard
…dated apply, plan-record corpus rev 3, web-env import fix # Conflicts: # AUDIT_OPEN.md
…(parallel lane) # Conflicts: # AUDIT_OPEN.md
…list [] fixtures
Found by dash's new X01 job-3 contracts CI lane (42f0d25), red-by-design
until this fix: (1) server-status-envelope.schema.json referenced
#/$defs/* since its generation copied the appStatus shape but not the
defs block - the schema never compiled; (2) the app-list goldens emitted
JSON null for containers/processes/errors where the real encoder
(collectAppList) emits [] - the fixtures failed their own schema and the
real dash decode path. Goldens regenerated from the corrected literals
([]machineError{}/[]processDTO{}), MANIFEST amended at rev 3.
…vious_release Second corpus defect the dash contracts-CI decode surfaced: the golden omitted previous_release entirely, but the real encoder initializes both release slots (Ports non-nil) and dash's per-app completeness rule correctly refuses a nil there. Golden now matches the encoder.
# Conflicts: # AUDIT_OPEN.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Mirror sync from Forgejo main; branch built by merging main into github/main so trees reconcile after the squash history. Tree-diff vs Forgejo main: empty.