Main sync: C01-8/9 generations, C09+X02 fixture+R01 receipts, R02 docs - #21
Merged
Merged
Conversation
…(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)
…igration guides (parallel lane)
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. Same tree.