Skip to content

Main sync: C01-8/9 generations, C09+X02 fixture+R01 receipts, R02 docs - #21

Merged
im-tyler merged 14 commits into
mainfrom
sync/main-20260924a
Sep 24, 2026
Merged

im-tyler merged 14 commits into
mainfrom
sync/main-20260924a

Conversation

@im-tyler

Copy link
Copy Markdown
Contributor

Mirror sync from Forgejo. Same tree.

im-tyler and others added 14 commits September 23, 2026 18:14
…(X02 S2 tail)

Closes the last 'pending S2 tail' artifact row. Fixtures are
encoder-derived: generated from the REAL collectServerStatus via a mock
SSH executor (contracts_golden_test.go, TEPLOY_UPDATE_CONTRACTS) with
synthetic values; the wire shape was verified against a live
`teploy server status --json` capture before pinning.

- valid/full.json: complete healthy observation (uptime, load, memory,
  two disks, docker inventory with container+image, caddy routes, no
  errors)
- valid/partial-caddy-unavailable.json: the class a real target without
  a caddy container produces — caddy.routes error entry, empty inventory
- legacy/pre-mi.json: machine_interface absent (42243e2-era shape)

Schema defect fixed in the same commit: server-status-envelope had
copied the appStatus root since its S2 draft (08cfb1b's own message
admits the copy) and never described the actual serverStatusDTO wire
format. Rewritten to the real root (server/host/uptime/load/memory/
disks/docker/caddy/observed_at/errors) with required-key coverage
matching the DTO's no-omitempty fields; $defs for every nested type.
Consumers see this as additive — the old schema matched nothing on the
wire. Corpus rev 5 (MANIFEST); no MI bump, no token change.
The C09 acceptance wants scripted automation to distinguish rejected,
failed, uncertain, canceled and successful outcomes. The machinery
produced all of these but the taxonomy classified an interrupted deploy
(SIGINT, automation timeout) as plain internal — a canceled deploy and a
failed one were indistinguishable on the wire.

- classifyMachineError: context.Canceled/DeadlineExceeded (however
  deeply wrapped) -> uncertain-outcome, message names reconciliation.
  The C01 crash-window contract: an interrupted window is INSPECT, never
  a rendered failure. Exit codes unchanged (0/1/2, D10).
- wrapDeployOutcomeError: the human path for a failed deploy names the
  recovery action (re-run is safe; inspect with teploy status) instead
  of a bare 'context canceled'.
- help consistency: deploy --help now states outcome/exit-code semantics
  and the --json error-envelope codes; version --help documents the
  --json machine handshake (MI number + capability registry).

No deploy/state internals touched; classification stays CLI-side.
…/recovery guide, migration recipes; README superlative sweep

- docs/supported-workloads.md: what deploys (single-image apps, multi-process,
  static, Compose subset, templates, previews, accessories) vs what is refused
  (multi-image stacks, ambiguous web candidates, per-field Compose refusals)
  with the refusal behavior named; ingress guarantees per mode; declared
  operational limits.
- docs/first-success.md: install -> server add -> deploy -> verify -> rollback,
  executed against a scratch SSH+Docker host (colima fixture); each step's
  failure mode and remedy (admission errors, rsync, health gate, doctor).
- docs/failure-and-recovery.md: exit codes + --json error envelope taxonomy
  (wired vs reserved codes marked), doctor check table, crash-recovery
  dispositions (RETRY/INSPECT/COMPENSATE/MANUAL) and repair debt, self-heal,
  DR bundle family (dr create/list/show/restore verified on the fixture;
  cutover documented).
- docs/migration.md: Dokploy/Coolify -> teploy via the C05 Compose import
  (converts/refuses tables, concept mapping, reversible adoption).
- README: replace unsupported superlatives with scoped verified statements
  (zero-downtime scoped to Caddy blue/green; rollback no longer 'instantly';
  drop 'No dependencies'); add Docs index; honest Requirements.
…C09)

'make quickstart' (examples/quickstart/run.sh) closes the C09 acceptance
gap that stayed open after doctor/MI/exit-code work: the new-user path
was documented prose with nothing executable behind it.

The maintained fixture (examples/quickstart/app: busybox httpd +
teploy.yml, host ingress :18080, tcp health) deploys to the LOCAL
colima VM's own SSH endpoint — no remote server, domain, or DNS needed,
so the loop runs on a laptop through the real deploy machinery (config
loader, build-on-target, health gate, commit, status, remove).

Verified green on this machine (colima/aarch64): deploy qs1 -> curl
serves v1 -> redeploy qs2 -> curl serves v2 -> status shows both
releases -> full cleanup (containers, images, /deployments/quickstart,
the run's own known_hosts lines). Honest gating: missing docker/colima/
ssh/curl or a stopped VM prints SKIP and exits 0.

One fixture detail worth recording: the app listens on the PORT env
teploy injects (httpd ${PORT:-80}) — a fixed-port listener silently
breaks host ingress because the publish and the health probe point at
the injected port, while docker-proxy still accepts TCP dials (a tcp
health gate can pass against a dead backend).
…I slice)

- scripts/release-verify.sh: builds the exact goreleaser matrix locally
  (CGO_ENABLED=0, -s -w -X main.version, linux/darwin/windows x
  amd64/arm64), checksums the raw binaries (the reproducible unit —
  goreleaser's published checksums.txt covers archives, which embed
  mtimes), records inputs (go toolchain, commit, describe, ldflags,
  date/host), and diffs rebuilds against the recorded expectation.
  Honest refusal over vacuous pass: toolchain skew or changed build
  inputs (cmd/ internal/ go.mod go.sum) exit 1 naming the difference;
  a source-identical descendant of the recorded commit compares with a
  note (the expectation file itself necessarily lands in a later
  commit). Expectations live in release/expectations/, keyed by
  (version label, go version), committed and reviewed like contracts.
- Dockerfile: scratch smoke vehicle COPY-ing the verified matrix
  binary — image provenance is always the checksummed build. Explicitly
  NOT a published artifact (goreleaser ships binaries/archives).
- release-smoke: builds linux/<docker-arch> image, runs 'version'
  (must exit 0) and 'doctor --json' (must emit the 9-check machine
  envelope; exit 1 = documented failing-diagnosis semantics in a bare
  container, not a crash). Skips (exit 0) without docker.
- release/RELEASE_RECEIPT.md: the pattern (template) + the lane's
  filled local-verification example, clearly marked NOT A RELEASE.
- Make targets: release-verify, release-record, release-smoke.

No tag, no publish — release cuts stay owner-controlled.
…cation)

Recorded at the R01 tooling commit with a clean tree: all 6 matrix
binaries verified REPRODUCIBLE on go1.26.6 (Darwin/arm64 host), and the
built-image smoke passed (scratch image, version exit 0, doctor emits
the 9-check machine envelope with documented exit semantics). Receipt
clearly marked LOCAL VERIFICATION, NOT A RELEASE — no tag, no publish.
…it (C01-8/9 foundation)

The generation identity C01-8/9 build on: every state commit now
publishes /deployments/<app>/.generation — a plain decimal integer, the
targetguard helper's contract (internal/targetguard/guard.sh), so one
sidecar serves both fences — and the fenced commit gained a
compare-and-swap on it.

- Write/WriteFenced publish the sidecar atomically right after
  state.json: authority first, fence second, never a trailing authority.
  In WriteFenced both renames run in the SAME guarded command, so a
  crash can only leave the sidecar one generation BEHIND (the
  permissive direction — nothing fences against a generation that never
  committed).
- GenerationCASPrefix renders the composable in-shell CAS (refuses
  exit 74 with TEPLOY_GENERATION_FENCED <committed> <expected>; a
  present-but-corrupt sidecar fails closed as TEPLOY_GENERATION_BADGEN;
  absent reads as 0, the targetguard rule). ErrGenerationFenced +
  GenerationFenced() classify refusals across packages.
- WriteFencedGeneration chains holdership guard + sidecar CAS + state
  rename + sidecar rename in one remote shell: an operation that
  resolved the world at generation E cannot commit over a successor's
  E+N even when its commands land inside the successor's window — the
  stale-rollback-at-the-authority-boundary refusal.
- ReadCommittedGeneration reads the sidecar (absent=0, corrupt=error).

ssh.MockExecutor models the new server-side fragments: the sidecar CAS
evaluates against recorded file state (plus the generation label check
and the exact-block route CAS evaluators the follow-up commits in this
series consume), and compound file ops (mv state && mv sidecar) apply
segment by segment — the same evaluation the target shell performs.

Tests: CAS semantics (absent/equal/older pass, newer refuses naming both
generations, corrupt fails closed), sidecar publication + in-command
ordering, the refused stale commit keeping the successor's state.json.
…es (C01-8/9)

The container half of the generation identity: containers carry the
generation that created them, and destructive effects read that identity
AT EXECUTION TIME in the same shell as the effect.

- RunConfig.Generation stamps the teploy.generation label (zero keeps
  the legacy unlabeled shape; Recreate preserves labels across a
  rollback's recreate, so the identity is stable for a container's
  lifetime while NAMES remain version-keyed).
- StopGenerationFenced composes holdership guard + generation label
  check + docker stop in ONE remote command, addressed by the exact
  container ID from the operation's own under-lock inventory. The label
  is the immutable discriminator: a name re-created by a NEWER
  generation (takeover + same-hash redeploy — C01-9's names-collide
  hazard) refuses with TEPLOY_GENERATION_FENCED <found> <expected>, no
  matter when the command lands. This closes the delayed-SSH-effect
  window holdership guards cannot: a guard refuses ISSUING, the label
  check refuses EXECUTING against an identity that never changes.
- RestartFenced/RecreateGuarded compose the same check into the
  recreate's force-remove — the destructive half of a rollback's target
  restart — so a stale rollback cannot destroy a successor's
  same-named container. Legacy unlabeled containers pass everywhere
  (compat rule).

Tests: label emission + legacy shape, the composed one-command stop,
execution-time refusal naming both generations with the stop never
executing, the guarded remove with label preservation on recreate.
…e CAS (A12/T05)

The route half of the generation identity, closing A12/T05's
compare-and-swap remainder on ParseSites/ExtractPolicy's home ground:
the managed block names its generation, and a route edit commits only
over the precise predecessor region it resolved.

- WithGeneration stamps the block with '# TEPLOY GENERATION <n>' as the
  first line inside the markers — a comment caddy ignores, inside the
  managed region so every rewrite replaces it, never leaking to
  operator config. managedBlockHosts skips it (the address line stays
  the first content line).
- ReadManagedBlock/extractManagedRegion return the marker-INCLUSIVE
  region — the exact bytes sed prints at commit time; ManagedRegionHash
  is the shared normalization (sha256 of region+newline, empty input
  for an absent region) both sides MUST use. RegionGeneration and
  RegionUpstreams parse the refusal evidence.
- WithRouteCAS makes the next managed-block commit a compare-and-swap:
  the commit command (already composed under the app-fence and
  caddy-lock guards, C01-2/3) chains a server-side extraction+hash of
  the live region and refuses exit 76 (TEPLOY_ROUTE_CAS_MISMATCH,
  found/expected generations on stderr) unless it hashes to one of the
  acceptable regions. The CAS is evaluated against the bytes being
  replaced ON the target — no client-side read in between — and is
  defense-in-depth independent of lock holdership: it closes the
  in-flight/takeover-interleave window AND makes the deliberately
  unfenced compensation restores (A07) identity-safe instead of
  last-writer-wins.
- ErrRouteCAS carries the evidence: expected generation, the live
  block's stamp (or 'unstamped (legacy or foreign)'), and its upstream
  dials — the refusal names BOTH generations and what the successor
  serves.

Tests: stamp rendering + region/hash round-trip, evidence parsers, the
refusal (nothing lands, no reload, both generations + upstreams named),
the pass on the exact predecessor, the restore set ({resolved,
switched} accepted; a successor's block refused), first-deploy absent
region, and the Go-hash / sed-sha256sum parity through the mock's
shell-mode evaluation.
… and restores (C01-8/9, A12/T05 remainder)

Rollback now carries an explicit target generation identity, and every
route effect — switch AND restore, deploy-side and rollback-side — runs
under the exact-block compare-and-swap.

Rollback (the operation C01-8's finding is about):
- Resolves fromGeneration (state.json under the lock) before any
  effect; everything it does is prepared against it and creates
  fromGeneration+1. Its route switch stamps that generation and CASes
  on the managed region resolved under the lock (a successor's block
  refuses with both generations named); its state commit runs
  WriteFencedGeneration (sidecar CAS); its retirement stops and
  fixed-port displacements stop by exact container ID under the
  composed holdership+label check; its target restarts guard the
  force-remove the same way. A stale rollback can no longer stop,
  remove, overwrite or become authority over a newer generation —
  including commands whose SSH execution lands post-takeover.
- restoreRollbackRoute (the failed-rollback compensation, A12/T05's
  last reconstruct-from-inspection site) now renders from the F14
  record of the version being rolled back FROM — deploy's
  restoreRouteFromReceipt shape — with live inspection only as the
  announced legacy fallback, and commits under the restore CAS set
  {region resolved, region switched to}: an unfenced compensation may
  undo its OWN route but never clobber a successor's.

Deploy:
- Resolves fromGeneration/nextGeneration at step 1; candidates and
  workers are labeled with the generation it creates; the route switch
  stamps and CASes on the region resolved at step 1 (refusing with
  ErrRouteCAS instead of landing over a successor's route); the commit
  runs WriteFencedGeneration; retirement stops are composed,
  ID-addressed, generation-fenced (a refusal is reported as degraded
  retirement, never a stop of the successor's container); cleanup and
  abortStateCommit stop by exact ID under the generation check, so a
  takeover + same-hash redeploy cannot have this holder's OWN cleanup
  destroy the successor's same-named containers.

Acceptance tests through the real Rollback/DeployFenced entry points
(the mock evaluates guards, sidecar CAS, label checks and route CAS
against recorded file state): deploy-vs-rollback and
rollback-vs-rollback switch refusals naming both generations with
nothing landing; the mid-flight generation bump refusing the commit
while the CAS-owned route is restored and the successor's authority
kept; the delayed retirement stop refused against the live label with
the newer container still running and the rollback completing degraded;
happy-path stamps (labels, block, sidecar) on both operations.

Existing fixtures updated for the new command shapes: retirement and
displacement stops assert the exact-ID composed form; the framed
Caddyfile read fixtures serve presence instead of poisoning the read
(the old flow never read the route authority pre-switch; refusing to
deploy over an unreadable one is the new, earlier, honest failure).
…te (C01-8/9)

Live evidence against the colima fixture (docker 29.5.2, real
caddy:2-alpine, the C01/C03 harness contract — skips cleanly when
TEPLOY_FAULT_* is unset):

- DelayedStopRefusesNewerGeneration: a generation-9-labeled container
  is not stoppable prepared-against-7 — including a stop whose
  EXECUTION is delayed 3s past issue (nohup): the label check reads the
  container's identity at execution time and refuses; the newer
  generation's container keeps running. Legacy unlabeled containers
  keep stopping. This is the acceptance's delayed-SSH-effect shape on
  real docker.
- CommitCASAndSidecar: WriteFencedGeneration commits state.json + the
  .generation sidecar together on the real FS; the stale
  prepared-against-7 commit refuses over a committed 8 and state.json
  keeps the successor's content.
- RouteCASAgainstRealCaddy: through the production caddy.Client (lock,
  adapt gate, guarded commit, reload, delivery verification) against a
  real Caddyfile: the stale switch replayed after a successor switched
  refuses with both generations and the successor's upstreams named,
  and the successor's block stays live.

The pre-existing drain (2/2) and reconcile (1/1) integration harnesses
pass unchanged against the same fixture — the production paths these
drive are not regressed by the new command shapes.

ADR (docs/C01_RECOVERY_STATE_TABLE.md): findings 8/9 record the landed
generation-identity sub-slices (labels, sidecar, fromGeneration
fencing, exact-block CAS, record-driven rollback route restore) and
what deliberately remains — the INSPECT-to-adopt continuation (A08) and
attempt-scoped container names (F04/A09, the breaking rename). The
lock-ordering rule documents that identity CASes chain AFTER both
holdership guards.
… (corpus rev 5); R01 release receipts (parallel lane)
@im-tyler
im-tyler merged commit b1370b9 into main Sep 24, 2026
1 check passed
@im-tyler
im-tyler deleted the sync/main-20260924a branch September 24, 2026 04:11
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