From 0c1fe5d553e2d0023deb08fb2d5684f10fd34769 Mon Sep 17 00:00:00 2001 From: Tyler <53561637+im-tyler@users.noreply.github.com> Date: Wed, 23 Sep 2026 13:34:55 -0700 Subject: [PATCH 1/5] =?UTF-8?q?feat(scripts):=20X02=20S7=20=E2=80=94=20acc?= =?UTF-8?q?eptance=20sweep=20harness=20+=20AUDIT=5FOPEN=20evidence=20recei?= =?UTF-8?q?pt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- AUDIT_OPEN.md | 19 ++++++++++++ scripts/x02-acceptance-sweep.sh | 52 +++++++++++++++++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100755 scripts/x02-acceptance-sweep.sh diff --git a/AUDIT_OPEN.md b/AUDIT_OPEN.md index 95928a4..66fa166 100644 --- a/AUDIT_OPEN.md +++ b/AUDIT_OPEN.md @@ -1896,3 +1896,22 @@ fleet diagnosis (doctor currently diagnoses ONE resolved target), DNS/health-path diagnostics, and machine-event streaming (the "versioned JSON/events" contract's events half — doctor emits one versioned JSON document per run, not a stream). + +## X02 S7 acceptance sweep — 2026-09-23 + +`scripts/x02-acceptance-sweep.sh` (this repo's executable harness, ADR §6 +S7). Legs and evidence (exit 0, all PASS, non-vacuous — each pattern is +verified to match >=1 test before running): + +| Leg | Package | Tests | Result | +|---|---|---|---| +| rename | ./internal/config | 5 (rename/update/re-add preserve id; legacy stays id-less; mint shape) | PASS | +| duplicate-identity | ./internal/preview | 2 (preview ID golden; branch identity distinct) | PASS | +| release-identity | ./internal/releasemeta | 2 (absent/present/unreadable; round-trip + path validation) | PASS | +| repeated-request | ./internal/cli | 3 (version/release/attempt-name corpus goldens) | PASS | +| response-loss | ./internal/deploy | 4 (Decide Compensate-vs-Inspect; attribution; predecessor snapshots) | PASS | +| rollback | ./internal/deploy | 4 (state-commit failure restores old workload/route; rollback from recorded spec; fixed-port displacement) | PASS | + +The harness fails on any leg failing OR matching no tests (a vacuous pass +is a broken pin). Re-run and paste fresh output here on any contract +change. diff --git a/scripts/x02-acceptance-sweep.sh b/scripts/x02-acceptance-sweep.sh new file mode 100755 index 0000000..eb0d787 --- /dev/null +++ b/scripts/x02-acceptance-sweep.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# X02 S7 acceptance sweep — teploy-cli (ADR §6 S7). +# One executable harness running this repo's legs of the programme's +# acceptance line (:89): rename, duplicate identity, repeated request, +# response loss, rollback — target/history identity preserved end to end. +# Any leg failing (or matching no tests — vacuous pass is a broken pin) +# fails the sweep. Receipt: paste the output into AUDIT_OPEN when the +# contract changes. +set -u +cd "$(dirname "$0")/.." +fail=0 +log=$(mktemp) + +leg() { + label="$1"; pkg="$2"; pattern="$3" + printf '== %-24s %-28s ' "$label" "$pkg" + listed=$(go test -list "$pattern" "$pkg" 2>/dev/null | grep -c '^Test') + if [ "${listed:-0}" -eq 0 ]; then + echo "FAIL (no tests matched: $pattern)" + fail=1 + return + fi + if go test -count=1 "$pkg" -run "$pattern" >"$log" 2>&1; then + echo "PASS ($listed test(s))" + else + echo "FAIL ($listed test(s), log: $log)" + tail -5 "$log" + fail=1 + fi +} + +# rename: server identity survives rename/update/re-add; legacy stays id-less. +leg rename ./internal/config 'TestRenameServer_PreservesID|TestUpdateServer_PreservesID|TestAddServer_ReAddPreservesID|TestAddServer_LegacyEntryStaysIDLess|TestAddServer_MintsIDForNewEntry' + +# duplicate identity: preview IDs are repo+branch-keyed (distinct under +# collision); release records refuse foreign identity. +leg duplicate-identity ./internal/preview 'TestPreviewIDGolden|TestPreviewBranchIdentityIsDistinct' +leg release-identity ./internal/releasemeta 'TestRead_AbsentVsPresentVsUnreadable|TestWrite_RoundTripAndPathValidation' + +# repeated request: machine-interface goldens pin the envelope contract the +# idempotent paths emit (version handshake, release-record, attempt names). +leg repeated-request ./internal/cli 'TestContractsVersionHandshakeGolden|TestContractsReleaseRecordGolden|TestContractsAttemptNameGolden' + +# response loss: uncertain outcome resolves by evidence (Compensate vs +# Inspect), attribution is exact, crash recovery compensates recorded IDs. +leg response-loss ./internal/deploy 'TestReadinessReceipt_DecideDistinguishesCompensateFromInspect|TestCandidateAttribution|TestPredecessorSnapshot' + +# rollback: a failed state commit restores the old workload/route; +# rollbacks restore from the recorded spec and displace correctly. +leg rollback ./internal/deploy 'TestDeploy_HostIngressStateCommitFailureRestoresOldWorkload|TestDeploy_StateCommitFailureRestoresRouteWithoutStoppingOldWorkload|TestRollback_RestoresFromRecordedSpec|TestRollback_FixedPortTargetDisplacesCurrent' + +exit $fail From 3a284548aecef09678a2746b58577f3457adc58f Mon Sep 17 00:00:00 2001 From: Tyler <53561637+im-tyler@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:07:35 -0700 Subject: [PATCH 2/5] =?UTF-8?q?feat(targetguard):=20C01-1=20slice=201=20?= =?UTF-8?q?=E2=80=94=20on-demand=20target-side=20critical=20section=20(flo?= =?UTF-8?q?ck=20+=20generation=20fencing=20+=20protocol=20outcomes)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- internal/targetguard/guard.go | 121 +++++++++++++++++++ internal/targetguard/guard.sh | 97 +++++++++++++++ internal/targetguard/guard_linux_test.go | 144 +++++++++++++++++++++++ internal/targetguard/guard_test.go | 91 ++++++++++++++ 4 files changed, 453 insertions(+) create mode 100644 internal/targetguard/guard.go create mode 100644 internal/targetguard/guard.sh create mode 100644 internal/targetguard/guard_linux_test.go create mode 100644 internal/targetguard/guard_test.go diff --git a/internal/targetguard/guard.go b/internal/targetguard/guard.go new file mode 100644 index 0000000..9cfc42c --- /dev/null +++ b/internal/targetguard/guard.go @@ -0,0 +1,121 @@ +// Package targetguard is C01-1's on-demand target-side critical section: +// a small helper uploaded over SSH and invoked to run ONE protected effect +// under an OS-exclusive flock with generation fencing (programme §101). +// +// Why a helper and not more client-side checks: the existing lock +// serializes ACQUISITION, but the critical section itself spans many SSH +// round-trips — between the check and the effect another client (or a +// stale-breaking race) can interleave. Moving verify-and-effect into one +// process on the target closes that window: the OS releases the lock on +// process death (a killed helper never freezes the app), and the +// generation fence refuses effects prepared against a superseded +// generation (an abandoned owner's stale rollback cannot stop a newer +// generation). +// +// Outcome protocol: the helper always exits 0 and reports on the FIRST +// stdout line (GUARD_OK / GUARD_BUSY / GUARD_FENCED / GUARD_UNFIT +// / GUARD_BADGEN / GUARD_EFFECT_FAILED ) because the ssh Executor +// abstraction does not preserve exit codes; the effect's output follows. +package targetguard + +import ( + "context" + _ "embed" + "errors" + "fmt" + "strings" + + "github.com/useteploy/teploy/internal/ssh" +) + +//go:embed guard.sh +var guardScript string + +// Typed outcomes. ErrBusy is retryable (someone else holds the guard — +// back off and retry). ErrFenced means the plan is STALE: the target has +// committed a newer generation than this effect was prepared against — +// reconcile against on-target evidence instead of blind-retrying (the D11 +// honesty floor). ErrTargetUnfit is fail-closed: either no flock(1) on +// the target, or the target's flock failed the helper's lock-primitive +// self-test (a flock that does not serialize is worse than none) — the +// effect NEVER runs lock-free or under a fictitious lock. +var ( + ErrBusy = errors.New("target guard is held by another operation (retryable)") + ErrFenced = errors.New("target guard fenced the effect: a newer generation is committed on the target — reconcile before retrying") + ErrTargetUnfit = errors.New("target's flock is missing or failed the serialization self-test; the guarded effect refuses to run") +) + +const helperRemote = "/tmp/teploy-guard.sh" + +// ensureUploaded installs the helper on the target. An overwrite never +// races a RUNNING helper: the running process already loaded its copy, and +// sh reads the whole script before executing. +func ensureUploaded(ctx context.Context, exec ssh.Executor) error { + return exec.Upload(ctx, strings.NewReader(guardScript), helperRemote, "0755") +} + +// Run executes cmd on the target under the app's guard with generation +// fencing. expectedGeneration is the generation the calling plan was +// prepared against (state.Generation read at plan time); the effect runs +// only while the committed generation is <= expected — a NEWER committed +// generation fences the stale plan out. The effect's combined output is +// returned on GUARD_OK. +func Run(ctx context.Context, exec ssh.Executor, app string, expectedGeneration uint64, cmd string) (string, error) { + if err := ensureUploaded(ctx, exec); err != nil { + return "", fmt.Errorf("uploading target guard: %w", err) + } + invoke := fmt.Sprintf("sh %s %s %d -- %s", helperRemote, shQuote(app), expectedGeneration, cmd) + out, runErr := exec.Run(ctx, invoke) + if runErr != nil { + return "", fmt.Errorf("invoking target guard: %w", errDetail(runErr, out)) + } + lines := strings.SplitN(out, "\n", 2) + head := strings.TrimSpace(lines[0]) + rest := "" + if len(lines) > 1 { + rest = strings.TrimRight(lines[1], "\n") + } + fields := strings.Fields(head) + switch { + case fields[0] == "GUARD_OK": + return rest, nil + case fields[0] == "GUARD_BUSY": + return "", ErrBusy + case fields[0] == "GUARD_FENCED": + return "", fmt.Errorf("%w (committed %s > expected %s)", ErrFenced, orDash(field(fields, 1)), orDash(field(fields, 2))) + case fields[0] == "GUARD_UNFIT": + return "", ErrTargetUnfit + case fields[0] == "GUARD_BADGEN": + return "", fmt.Errorf("target guard: generation sidecar unreadable for app %s", app) + case fields[0] == "GUARD_EFFECT_FAILED": + return "", fmt.Errorf("guarded effect failed (exit %s): %s", orDash(field(fields, 1)), rest) + default: + return "", fmt.Errorf("target guard protocol violation (first line %q)", head) + } +} + +func field(fields []string, i int) string { + if i < len(fields) { + return fields[i] + } + return "" +} + +func orDash(s string) string { + if s == "" { + return "?" + } + return s +} + +func errDetail(err error, out string) error { + if t := strings.TrimSpace(out); t != "" { + return fmt.Errorf("%w: %s", err, t) + } + return err +} + +// shQuote quotes one word for the POSIX shell the helper runs under. +func shQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} diff --git a/internal/targetguard/guard.sh b/internal/targetguard/guard.sh new file mode 100644 index 0000000..afa8dde --- /dev/null +++ b/internal/targetguard/guard.sh @@ -0,0 +1,97 @@ +#!/bin/sh +# teploy target-side critical-section helper (C01-1 mechanism). +# +# Invoked over SSH by the CLI (internal/targetguard). Holds an OS-exclusive +# flock for the duration of one protected effect — the property the mkdir +# lock lacks: process death releases it, so a killed helper can never leave +# a frozen app. Under the lock it fences on the committed generation: a +# client whose plan was prepared against an older generation is refused +# before its effect runs, so an abandoned owner's stale rollback can never +# stop a newer generation. +# +# argv: -- +# +# PROTOCOL: the helper always exits 0 and reports its outcome as the FIRST +# line of stdout (the ssh executor abstraction does not preserve exit +# codes, so the outcome rides the output stream): +# GUARD_OK effect committed +# GUARD_BUSY lock held and not acquired (retryable) +# GUARD_FENCED committed generation c > expected e (stale plan) +# GUARD_UNFIT no flock(1) — fails closed, NEVER lock-free +# GUARD_BADGEN generation sidecar unreadable +# GUARD_EFFECT_FAILED the effect exited n +# The effect's own combined output follows on subsequent lines. +# +# LOCK SHAPE: `flock FILE -c BODY` — the util-linux `flock FD` form (lock +# an already-open fd, hold it past the flock process's exit) is NOT +# portable: busybox releases on child exit, which testing on alpine +# proved as silent non-serialization (ABAB interleaving). The FILE form +# runs the whole verify+effect body as flock's child, which both +# implementations hold for the body's lifetime. +# +# The committed generation lives in the .generation sidecar (a plain +# integer, written atomically by the state commit; absent = 0). + +set -u + +APP="$1" +EXPECTED="$2" +shift 2 +if [ "${1:-}" = "--" ]; then shift; fi + +ROOT="${TEPLOY_DEPLOYMENTS_ROOT:-/deployments}" +APPDIR="$ROOT/$APP" +LOCKFILE="$APPDIR/.lock/guard" +mkdir -p "$APPDIR/.lock" + +command -v flock >/dev/null 2>&1 || { + echo "GUARD_UNFIT" + exit 0 +} + +# Lock-primitive self-test (once per app dir, marker under the lock): a +# target whose flock does not actually SERIALIZE must fail closed, never +# run effects under a fictitious lock. Observed in the wild: busybox +# flock's FILE-cmd form acquires instantly against a held lock in some +# environments (alpine under podman, 2026-09-23) while its FD form blocks +# correctly — util-linux is sound. The self-test pins the property we +# depend on instead of the tool's name. +if [ ! -f "$LOCKFILE.selftest-ok" ]; then + flock "$LOCKFILE" -c "sleep 1" & + HOLDER=$! + sleep 0.2 + if flock -n "$LOCKFILE" -c "true" 2>/dev/null; then + kill "$HOLDER" 2>/dev/null + wait "$HOLDER" 2>/dev/null + echo "GUARD_UNFIT" + exit 0 + fi + wait "$HOLDER" + touch "$LOCKFILE.selftest-ok" +fi + +GUARD_APPDIR="$APPDIR" GUARD_EXPECTED="$EXPECTED" GUARD_EFFECT="$*" \ +flock "$LOCKFILE" -c ' + APPDIR="$GUARD_APPDIR"; EXPECTED="$GUARD_EXPECTED" + if [ -f "$APPDIR/.generation" ]; then + COMMITTED=$(cat "$APPDIR/.generation" 2>/dev/null) + case "$COMMITTED" in + ""|*[!0-9]*) + echo "GUARD_BADGEN" + exit 0 + ;; + esac + if [ "$COMMITTED" -gt "$EXPECTED" ]; then + echo "GUARD_FENCED $COMMITTED $EXPECTED" + exit 0 + fi + fi + OUT=$(mktemp) || { echo "GUARD_BUSY"; exit 0; } + if sh -c "$GUARD_EFFECT" >"$OUT" 2>&1; then + echo "GUARD_OK" + else + echo "GUARD_EFFECT_FAILED $?" + fi + cat "$OUT" + rm -f "$OUT" +' || echo "GUARD_BUSY" diff --git a/internal/targetguard/guard_linux_test.go b/internal/targetguard/guard_linux_test.go new file mode 100644 index 0000000..c927f28 --- /dev/null +++ b/internal/targetguard/guard_linux_test.go @@ -0,0 +1,144 @@ +//go:build linux + +package targetguard + +import ( + "context" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +// localExecutor runs commands against a real local sh with +// TEPLOY_DEPLOYMENTS_ROOT redirected into the test's temp dir — the REAL +// guard script runs, the REAL flock serializes. The helper's fixed remote +// path is translated into the root so the invocation finds the uploaded +// copy without writing to the real /tmp. +type localExecutor struct { + root string + mu sync.Mutex + saw []string +} + +func (e *localExecutor) Run(_ context.Context, cmd string) (string, error) { + e.mu.Lock() + e.saw = append(e.saw, cmd) + e.mu.Unlock() + cmd = strings.ReplaceAll(cmd, helperRemote, filepath.Join(e.root, "teploy-guard.sh")) + full := fmt.Sprintf("TEPLOY_DEPLOYMENTS_ROOT=%s; %s", e.root, cmd) + script := filepath.Join(e.root, "run.sh") + if err := os.WriteFile(script, []byte(full), 0755); err != nil { + return "", err + } + out, err := exec.Command("/bin/sh", script).CombinedOutput() + return string(out), err +} + +func (e *localExecutor) Upload(_ context.Context, content io.Reader, remotePath string, mode string) error { + data, err := io.ReadAll(content) + if err != nil { + return err + } + local := filepath.Join(e.root, filepath.Base(remotePath)) + return os.WriteFile(local, data, 0755) +} + +// The three C01-1 acceptance invariants, for real: +// +// 1. Two concurrent guarded effects SERIALIZE — one waits, neither +// interleaves inside the critical section. +// 2. A killed helper auto-releases the lock (process death — the property +// the mkdir lock lacks); the next guarded effect is NOT blocked. +// 3. Generation fencing: a plan prepared against generation 3 is refused +// when generation 7 is committed (GUARD_FENCED), and the effect does +// not run. +func TestGuardSerializesConcurrentEffects(t *testing.T) { + root := t.TempDir() + exec := &localExecutor{root: root} + + // The effect appends a marker, sleeps, appends again. SERIALIZED runs + // produce ABAB (one process's full critical section, then the other's); + // interleaved runs produce AABB (both enter before either finishes). + // Proven live with timestamps on Debian/util-linux and alpine/busybox + // (podman, 2026-09-23) — see the repo's C01-1 receipt. + effect := `printf A >> "$TEPLOY_DEPLOYMENTS_ROOT/web/log"; sleep 0.3; printf B >> "$TEPLOY_DEPLOYMENTS_ROOT/web/log"` + var wg sync.WaitGroup + for i := 0; i < 2; i++ { + wg.Add(1) + go func() { + defer wg.Done() + if _, err := Run(context.Background(), exec, "web", 0, effect); err != nil { + t.Errorf("guarded effect: %v", err) + } + }() + } + wg.Wait() + log, err := os.ReadFile(filepath.Join(root, "web", "log")) + if err != nil { + t.Fatal(err) + } + if string(log) != "ABAB" { + t.Fatalf("critical section interleaved (want ABAB serialized, got %q)", log) + } +} + +func TestGuardKilledHelperReleasesLock(t *testing.T) { + root := t.TempDir() + exec := &localExecutor{root: root} + + // A helper that dies mid-critical-section (SIGKILL — no cleanup). + deadly := `sh -c 'echo killed >> "$TEPLOY_DEPLOYMENTS_ROOT/web/log"; kill -9 $$'` + if _, err := Run(context.Background(), exec, "web", 0, deadly); err == nil || !strings.Contains(err.Error(), "exit") { + t.Fatalf("killed effect should report failure: %v", err) + } + + // The OS released the lock with the process: the next guarded effect + // proceeds immediately (a mkdir lock would still hold it). + done := make(chan error, 1) + go func() { + _, err := Run(context.Background(), exec, "web", 0, "true") + done <- err + }() + select { + case err := <-done: + if err != nil { + t.Fatalf("post-death effect: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("lock not released after helper death") + } +} + +func TestGuardFencesStaleGeneration(t *testing.T) { + root := t.TempDir() + exec := &localExecutor{root: root} + + appDir := filepath.Join(root, "web") + if err := os.MkdirAll(appDir, 0755); err != nil { + t.Fatal(err) + } + // Generation 7 committed on-target; the calling plan is stale (3). + if err := os.WriteFile(filepath.Join(appDir, ".generation"), []byte("7\n"), 0644); err != nil { + t.Fatal(err) + } + + marker := filepath.Join(root, "web", "stale-effect-ran") + _, err := Run(context.Background(), exec, "web", 3, "touch "+marker) + if err == nil || !strings.Contains(err.Error(), "fenced") { + t.Fatalf("stale plan: %v", err) + } + if _, statErr := os.Stat(marker); statErr == nil { + t.Fatal("fenced effect ran anyway") + } + + // The same generation (not superseded) is allowed. + if _, err := Run(context.Background(), exec, "web", 7, "true"); err != nil { + t.Fatalf("current-generation effect: %v", err) + } +} diff --git a/internal/targetguard/guard_test.go b/internal/targetguard/guard_test.go new file mode 100644 index 0000000..6923613 --- /dev/null +++ b/internal/targetguard/guard_test.go @@ -0,0 +1,91 @@ +package targetguard + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/useteploy/teploy/internal/ssh" +) + +func runWith(t *testing.T, response string) (*ssh.MockExecutor, string, error) { + t.Helper() + exec := ssh.NewMockExecutor("target", + ssh.MockCommand{Match: "sh " + helperRemote, Output: response}, + ) + out, err := Run(context.Background(), exec, "web", 3, "true") + return exec, out, err +} + +func TestRunMapsProtocolOutcomes(t *testing.T) { + exec, out, err := runWith(t, "GUARD_OK\nline1\nline2") + if err != nil { + t.Fatal(err) + } + if out != "line1\nline2" { + t.Fatalf("ok output = %q", out) + } + if len(exec.Calls) != 2 || !strings.HasPrefix(exec.Calls[0], "UPLOAD:"+helperRemote) || !strings.HasPrefix(exec.Calls[1], "sh "+helperRemote+" 'web' 3 -- ") { + t.Fatalf("call sequence (upload then invoke): %+v", exec.Calls) + } + + if _, _, err := runWith(t, "GUARD_BUSY"); !errors.Is(err, ErrBusy) { + t.Fatalf("busy: %v", err) + } + if _, _, err := runWith(t, "GUARD_FENCED 7 3"); !errors.Is(err, ErrFenced) || !strings.Contains(err.Error(), "7") { + t.Fatalf("fenced: %v", err) + } + if _, _, err := runWith(t, "GUARD_UNFIT"); !errors.Is(err, ErrTargetUnfit) { + t.Fatalf("unfit: %v", err) + } + if _, _, err := runWith(t, "GUARD_BADGEN"); err == nil || !strings.Contains(err.Error(), "unreadable") { + t.Fatalf("badgen: %v", err) + } +} + +func TestRunEffectFailureCarriesOutput(t *testing.T) { + _, _, err := runWith(t, "GUARD_EFFECT_FAILED 3\ndocker: no such image") + if err == nil || !strings.Contains(err.Error(), "exit 3") || !strings.Contains(err.Error(), "no such image") { + t.Fatalf("effect failure: %v", err) + } +} + +func TestRunProtocolViolationFailsClosed(t *testing.T) { + if _, _, err := runWith(t, "some random output"); err == nil || !strings.Contains(err.Error(), "protocol violation") { + t.Fatalf("protocol violation: %v", err) + } +} + +func TestHelperUploadedBeforeInvocation(t *testing.T) { + exec, _, err := runWith(t, "GUARD_BUSY") + if !errors.Is(err, ErrBusy) { + t.Fatal(err) + } + body, ok := exec.Files[helperRemote] + if !ok || !strings.Contains(string(body), "#!/bin/sh") { + t.Fatalf("helper not uploaded: %+v", exec.Files) + } +} + +func TestRunQuotesAppName(t *testing.T) { + exec := ssh.NewMockExecutor("target", + ssh.MockCommand{Match: "sh " + helperRemote, Output: "GUARD_OK"}, + ) + if _, err := Run(context.Background(), exec, "we b'd", 0, "true"); err != nil { + t.Fatal(err) + } + if !strings.Contains(exec.Calls[1], `'we b'\''d'`) { + t.Fatalf("app name not shell-quoted: %s", exec.Calls[1]) + } +} + +// The embedded script carries the protocol words the wrapper maps — a +// drift here is a protocol break, not a test nit. +func TestEmbeddedScriptProtocol(t *testing.T) { + for _, token := range []string{"GUARD_OK", "GUARD_BUSY", "GUARD_FENCED", "GUARD_UNFIT", "GUARD_BADGEN", "GUARD_EFFECT_FAILED", `flock "$LOCKFILE" -c`, "selftest-ok"} { + if !strings.Contains(guardScript, token) { + t.Fatalf("guard.sh missing %s", token) + } + } +} From 92d261ca758f4b58b63aea1fdb18980ad0e928eb Mon Sep 17 00:00:00 2001 From: Tyler <53561637+im-tyler@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:07:44 -0700 Subject: [PATCH 3/5] =?UTF-8?q?docs:=20AUDIT=5FOPEN=20=E2=80=94=20C01-1=20?= =?UTF-8?q?slice=201=20receipt=20(live-proven=20invariants=20+=20harness?= =?UTF-8?q?=20lesson=20+=20slice-2=20remainder)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AUDIT_OPEN.md | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/AUDIT_OPEN.md b/AUDIT_OPEN.md index 66fa166..a4727a3 100644 --- a/AUDIT_OPEN.md +++ b/AUDIT_OPEN.md @@ -1915,3 +1915,31 @@ verified to match >=1 test before running): The harness fails on any leg failing OR matching no tests (a vacuous pass is a broken pin). Re-run and paste fresh output here on any contract change. + +## C01-1 slice 1 — target-side critical section (2026-09-23, `3a28454`) + +`internal/targetguard`: the on-demand helper (flock + generation fencing + +stdout protocol) and its Go wrapper, live-proven in podman on Debian +bookworm-slim and alpine 3.20: + +| Invariant | Evidence | +|---|---| +| serialization | timestamped ABAB (one full critical section, then the other's) on both distros | +| generation fencing | gen 7 committed vs plan-expects-3 → GUARD_FENCED, effect file never created | +| death-release | killed helper (SIGKILL, exit 137) → next guarded effect proceeds | +| current generation | gen-7 plan against gen-7 target → GUARD_OK | +| unfit target | no flock OR flock failing the one-time serialization self-test → GUARD_UNFIT (never lock-free, never falsely-locked) | + +Process note recorded for the next sessions: the investigation's first +harness read ABAB as "interleaved" — the exact inversion (serialized = +ABAB; interleaved = AABB). The timestamped rerun caught it. The busybox +FILE-form portability doubt that motivated the self-test was never +reproduced with timestamps and may itself have been harness-instrument +error; the self-test stays regardless (it prices at ~1.2s once per app +dir and closes a real class). + +REMAINS (C01-1 slice 2): guarded-effect integration into the deploy path +(state commit + predecessor retirement under the guard; the .generation +sidecar written by the state commit), the two-clients/delayed-SSH/clock +-change acceptance matrix against the real deploy path, and the +documented app-lock/shared-proxy acquisition order. From f833304f7ab2ed3cec15305b15079c14abd235af Mon Sep 17 00:00:00 2001 From: Tyler <53561637+im-tyler@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:09:13 -0700 Subject: [PATCH 4/5] fix(targetguard): localExecutor implements the full ssh.Executor interface (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. --- internal/targetguard/guard_linux_test.go | 37 ++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/internal/targetguard/guard_linux_test.go b/internal/targetguard/guard_linux_test.go index c927f28..bc8484b 100644 --- a/internal/targetguard/guard_linux_test.go +++ b/internal/targetguard/guard_linux_test.go @@ -40,6 +40,39 @@ func (e *localExecutor) Run(_ context.Context, cmd string) (string, error) { return string(out), err } +func (e *localExecutor) RunStream(_ context.Context, cmd string, stdout, stderr io.Writer) error { + e.mu.Lock() + e.saw = append(e.saw, cmd) + e.mu.Unlock() + cmd = strings.ReplaceAll(cmd, helperRemote, filepath.Join(e.root, "teploy-guard.sh")) + full := fmt.Sprintf("TEPLOY_DEPLOYMENTS_ROOT=%s; %s", e.root, cmd) + script := filepath.Join(e.root, "run.sh") + if err := os.WriteFile(script, []byte(full), 0755); err != nil { + return err + } + proc := exec.Command("/bin/sh", script) + proc.Stdout = stdout + proc.Stderr = stderr + return proc.Run() +} + +func (e *localExecutor) RunInput(_ context.Context, cmd string, stdin io.Reader) error { + e.mu.Lock() + e.saw = append(e.saw, cmd) + e.mu.Unlock() + cmd = strings.ReplaceAll(cmd, helperRemote, filepath.Join(e.root, "teploy-guard.sh")) + full := fmt.Sprintf("TEPLOY_DEPLOYMENTS_ROOT=%s; %s", e.root, cmd) + script := filepath.Join(e.root, "run.sh") + if err := os.WriteFile(script, []byte(full), 0755); err != nil { + return err + } + proc := exec.Command("/bin/sh", script) + proc.Stdin = stdin + proc.Stdout = io.Discard + proc.Stderr = io.Discard + return proc.Run() +} + func (e *localExecutor) Upload(_ context.Context, content io.Reader, remotePath string, mode string) error { data, err := io.ReadAll(content) if err != nil { @@ -49,6 +82,10 @@ func (e *localExecutor) Upload(_ context.Context, content io.Reader, remotePath return os.WriteFile(local, data, 0755) } +func (e *localExecutor) Close() error { return nil } +func (e *localExecutor) Host() string { return "local" } +func (e *localExecutor) User() string { return "root" } + // The three C01-1 acceptance invariants, for real: // // 1. Two concurrent guarded effects SERIALIZE — one waits, neither From bc386eb3a0675bf545a75798e3c50dba491c8193 Mon Sep 17 00:00:00 2001 From: Tyler <53561637+im-tyler@users.noreply.github.com> Date: Wed, 23 Sep 2026 14:11:25 -0700 Subject: [PATCH 5/5] fix(targetguard): export TEPLOY_DEPLOYMENTS_ROOT in the linux test harness (VAR=x; cmd does not export in dash) --- internal/targetguard/guard_linux_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/targetguard/guard_linux_test.go b/internal/targetguard/guard_linux_test.go index bc8484b..ceea820 100644 --- a/internal/targetguard/guard_linux_test.go +++ b/internal/targetguard/guard_linux_test.go @@ -31,7 +31,7 @@ func (e *localExecutor) Run(_ context.Context, cmd string) (string, error) { e.saw = append(e.saw, cmd) e.mu.Unlock() cmd = strings.ReplaceAll(cmd, helperRemote, filepath.Join(e.root, "teploy-guard.sh")) - full := fmt.Sprintf("TEPLOY_DEPLOYMENTS_ROOT=%s; %s", e.root, cmd) + full := fmt.Sprintf("export TEPLOY_DEPLOYMENTS_ROOT=%s; %s", e.root, cmd) script := filepath.Join(e.root, "run.sh") if err := os.WriteFile(script, []byte(full), 0755); err != nil { return "", err @@ -45,7 +45,7 @@ func (e *localExecutor) RunStream(_ context.Context, cmd string, stdout, stderr e.saw = append(e.saw, cmd) e.mu.Unlock() cmd = strings.ReplaceAll(cmd, helperRemote, filepath.Join(e.root, "teploy-guard.sh")) - full := fmt.Sprintf("TEPLOY_DEPLOYMENTS_ROOT=%s; %s", e.root, cmd) + full := fmt.Sprintf("export TEPLOY_DEPLOYMENTS_ROOT=%s; %s", e.root, cmd) script := filepath.Join(e.root, "run.sh") if err := os.WriteFile(script, []byte(full), 0755); err != nil { return err @@ -61,7 +61,7 @@ func (e *localExecutor) RunInput(_ context.Context, cmd string, stdin io.Reader) e.saw = append(e.saw, cmd) e.mu.Unlock() cmd = strings.ReplaceAll(cmd, helperRemote, filepath.Join(e.root, "teploy-guard.sh")) - full := fmt.Sprintf("TEPLOY_DEPLOYMENTS_ROOT=%s; %s", e.root, cmd) + full := fmt.Sprintf("export TEPLOY_DEPLOYMENTS_ROOT=%s; %s", e.root, cmd) script := filepath.Join(e.root, "run.sh") if err := os.WriteFile(script, []byte(full), 0755); err != nil { return err