Skip to content

Main sync: C01 locking+drain, C05 plan/apply, corpus fixes (tree-identical) - #18

Merged
im-tyler merged 21 commits into
mainfrom
sync/main-20260923e
Sep 23, 2026
Merged

im-tyler merged 21 commits into
mainfrom
sync/main-20260923e

Conversation

@im-tyler

Copy link
Copy Markdown
Contributor

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.

…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.
…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.
…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
@im-tyler
im-tyler merged commit 029713b into main Sep 23, 2026
1 check passed
@im-tyler
im-tyler deleted the sync/main-20260923e branch September 23, 2026 22:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant