Skip to content

Fenced locks, attempt artifacts, Caddy adapt gate, strict-env opt-in - #3

Merged
im-tyler merged 5 commits into
mainfrom
audit/leases-adapt-strictenv
Sep 19, 2026
Merged

im-tyler merged 5 commits into
mainfrom
audit/leases-adapt-strictenv

Conversation

@im-tyler

Copy link
Copy Markdown
Contributor

F16 owner-token fenced locks with renewal; F08 attempt-scoped immutable artifacts; F48/F49 server-binary adapt gate + structured partial adoption (cross-checked against real caddy v2.10.2); F57/TCL-32 opt-in --strict-env with presence-aware overlays. Race suite green.

Every auto lock now carries a unique owner token (the fencing token) and
is renewed in the background every staleLockTTL/3; staleness is measured
from the last renewal, so a live-but-slow deploy is never falsely broken
(the stranding hazard the register warned about) while a dead holder's
lock still self-heals after the historical 30-minute window.

Effect sites verify the fence before mutating: deploy/rollback/static
check before every effectful phase, and the atomic state commit itself
(state.WriteFenced) renames under the guard — a broken holder's late
write is refused with ErrFenceLost instead of interleaving with the new
holder. Recovery paths (restoring displaced containers, route rollback)
are deliberately NOT fenced: refusing to clean up one's own partial
effects is how a fencing design strands an app mid-incident. The owner
token doubles as the fencing token; a separate monotonic counter adds
nothing in this topology (the .lock dir on the target is the single
authority — refusal is exactly 'does it still name us').

MockExecutor now evaluates the guard fragment against its recorded file
state, so fence tests prove a refused effect never executes. A nil *Lock
is the unfenced legacy shape (DeployLocked), kept honest rather than
silently faked.

TCL-05's containment is subsumed (release still detached/bounded); the
caddy file lock stays short-lived and unfenced by design (seconds-held,
no long-holder hazard).
…s under the deploy lease

The three artifacts a deploy attempt generates were shared per-app paths:
the build context (/deployments/<app>/build, rsynced before the app lock
was ever taken), the resolved env file (.deploy-env), and the TLS
cert/key (/deployments/caddy/tls/<app>.crt). Racing attempts interleaved
writes on all three, and the F14 record's env-file reference named a
path a later attempt would overwrite.

Every attempt now mints (app, hash, random id) and writes its artifacts
immutable, in the releasemeta namespace:

- build context: /deployments/<app>/meta/att/<hash>.<id>/build, with the
  previous attempt's build dir as rsync --link-dest so a fresh directory
  still transfers incrementally and hardlink-shares unchanged files
- env file: meta/att/<hash>.<id>/env — the record's EnvFiles now names
  bytes no later attempt can touch
- TLS: /deployments/caddy/tls/att/<hash>.<id>/ (container path
  /etc/caddy/tls/att/…), deliberately kept under the caddy tls dir — the
  one mount every custom-TLS server provably has; written atomically

The terminal deploy path acquires the fenced lease BEFORE artifact
generation (the autodeploy path already locked before fetch), so
attempts serialize at the source; attempt-scoped paths make the
immutability structural even without the lease (teploy build, lockless
by design, now builds into its own attempt dir and cannot interleave
with a deploy's rsync).

Retention: PruneAttempts runs after a committed deploy with the same
protection window as version pruning (current + previous + pinned
releases; unparsable entries kept, F78 parity). TLS nil-attempt callers
(rollback, LB upload) keep the legacy shared paths — pre-F14 records
still reference them and F14-recorded releases override from the record.
…e, parser-based adoption

New vendored structural parser (routes.go): top-level site blocks with
verbatim bodies, global-options blocks, snippets, comments, quoted
strings, multi-line backtick literals (the maintenance page's own
shape), and heredocs. It fails loudly — error, no edit — on unbalanced
braces and top-level import (imports resolve against things this parser
does not model; guessing is how another host's routes get deleted).

F49: foreign-block adoption is decided on the parsed structure. All
hosts requested -> whole-block adoption (the old rule, now structural);
PARTIAL overlap -> the adopted hosts are removed from the foreign
block's address line and the block survives for its remaining hosts
with its directives untouched — previously the leftover duplicate site
address failed at reload and took the whole edit down with it. Managed
(TEPLOY-marked) regions are never adopted.

F48: SetMaintenance extracts the current block's tls directive and
basic_auth/forward_auth spans (ExtractPolicy) and carries them into the
maintenance block — enabling maintenance no longer silently drops HTTPS
termination and auth for the duration. An unextractable policy fails
the toggle rather than guessing.

Adapt-API integration (adapt.go): the HARD pre-write gate runs the
SERVER's own binary (docker exec -i caddy caddy adapt, content over
stdin) so validation uses the exact caddy that will serve the config. A
LOCAL caddy in PATH is the advisory/debug surface and the cross-check
oracle for the parser — deliberately not a gate, because version/module
drift makes a local binary reject legitimate caddy_extra directives
from custom server builds (found live: rate_limit under stock caddy).

Tests: parser/adoption/policy behavioral tests; stub-binary tests for
the local adapt plumbing (no caddy exists in this environment — stated,
not faked); a PATH-gated cross-check against real caddy (v2.10.2 built
from source) proving the parser's host extraction agrees with adapt's
JSON and that F48/F49 outputs adapt cleanly — skipped where no binary
is installed (CI).
…rict-env)

One persistent flag, default off, two behaviors:

- TCL-32: expandEnvTemplates gains a strict mode that fails the deploy
  listing every unset ${VAR} referenced by teploy.yml's env: — a typo'd
  variable name currently deploys fine and breaks at runtime as an
  empty string. Available on the terminal deploy path and the resident
  autodeploy path (serve --strict-env, or TEPLOY_STRICT_ENV=1 in the
  already-installed systemd unit — the env var is the config surface
  that needs no setup changes).

- F57: LoadAppWithDestination gains OverlayOptions{Strict}. Under
  strict, a destination overlay that NAMES a map/list key with an empty
  value (env: null, env: {}, publish: [], TOML publish = []) explicitly
  CLEARS the base's field — the one thing the historical non-zero merge
  could not express, which is what made presence-aware semantics a
  schema decision rather than a bug. Non-empty overlays merge exactly
  as before (key-merge for maps, no replace semantics introduced);
  scalars are not clearable (a zero scalar already has no merge effect
  and port: 0 is more likely a mistake than a reset).

Default behavior is byte-identical: both strict paths are opt-in only,
preserving product compat per the register's owner decision.
…nnotations

Records the 2026-09-18 family: fenced locks with renewal (F16),
attempt-scoped immutable artifacts (F08 — whose F04 dependency turned
out not to hold), structured Caddy routes with server-side adapt gate
(F48/F49), and the opt-in strict-env mode (F57/TCL-32). F04's entry now
carries the dependency notes from this session's stay-out: the attempt
id is the artifact-side generation token, and DeployFenced's lock
handle is where a generation token rides when F04's design pass starts.
F45/TCL-39 annotations updated for F48's landing.
@im-tyler
im-tyler merged commit 0faf201 into main Sep 19, 2026
1 check passed
@im-tyler
im-tyler deleted the audit/leases-adapt-strictenv branch September 19, 2026 02:46
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