From 5d0868b8b758d671528e3a5f277476e158a6787b Mon Sep 17 00:00:00 2001 From: Gustavo Bertoi Date: Mon, 29 Jun 2026 09:50:33 -0300 Subject: [PATCH] =?UTF-8?q?feat(hooks):=20C4=20=E2=80=94=20internal/hooks?= =?UTF-8?q?=20thin=20runner=20+=20hook=5Frun=20ledger=20CRUD=20(spec=2011)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Run declarative lifecycle hooks at saga phase boundaries: - internal/state hook_run CRUD: HookSatisfied (lock-free read), RecordHookRun (idempotent; success-only, under the flock), DeleteHookRuns (--force-hooks re-arm). The cardinal rule — a row reflects only success — makes hooks resumable for free. - internal/hooks: Execer (host via os/exec from the documented base dir; exec via `docker compose exec -T -w -e NAME=VALUE`, the only transport that takes per-run env values, DECISIONS D10) + OSExecer production impl. Runner applies per-hook timeout, retries (fixed backoff), and onFailure (abort/warn/continue). RunPhase orders a phase's hooks, skips ledger-satisfied idempotent/once hooks, and records success INSIDE the flock — the hook BODY runs OUTSIDE it so a long hook never serializes other invocations (spec 08 #1 rule). run:exec without a service is rejected (the transport rule config can't express). Thin runner: ${ref}/${env}/${self}/secret:// interpolation happens upstream in the saga and arrives via PhaseOpts.ExtraEnv (secrets last); the package never imports internal/state (Ledger/Locker interfaces) or resolves secrets. Full workspace-scope ordering is X3. Unit tests (fake Execer + fake Ledger): host/exec routing, retries→success, retries exhausted, timeout, abort/warn/continue, idempotent skip, record-on- success-not-failure under the lock, env ordering. A hermetic OSExecer host test (real os/exec) + a `//go:build integration` test exec'ing into a real busybox compose stack with inline -e env injection. Co-Authored-By: Claude Opus 4.8 (1M context) --- internal/hooks/hooks.go | 331 +++++++++++++++++++++++ internal/hooks/hooks_integration_test.go | 56 ++++ internal/hooks/hooks_test.go | 269 ++++++++++++++++++ internal/state/hooks.go | 63 +++++ internal/state/hooks_test.go | 57 ++++ 5 files changed, 776 insertions(+) create mode 100644 internal/hooks/hooks.go create mode 100644 internal/hooks/hooks_integration_test.go create mode 100644 internal/hooks/hooks_test.go create mode 100644 internal/state/hooks.go create mode 100644 internal/state/hooks_test.go diff --git a/internal/hooks/hooks.go b/internal/hooks/hooks.go new file mode 100644 index 0000000..13642d0 --- /dev/null +++ b/internal/hooks/hooks.go @@ -0,0 +1,331 @@ +// Package hooks runs the declarative lifecycle hooks (spec 11) at saga phase +// boundaries: user commands on the host (run: host) or inside a running service +// (run: exec via `compose exec -T`), with per-hook timeout, retries, and +// onFailure semantics. firstRun/postPull hooks are made idempotent by the +// `hook_run` ledger — recorded ONLY on success and ONLY inside the flock, so a +// failed or interrupted hook re-runs next time (the correct replacement for +// Postgres initdb.d, DECISIONS D8). +// +// The hook BODY runs OUTSIDE the global flock (a 10-minute `npm install` must not +// serialize every other invocation, spec 08 #1 rule); only the ledger record is +// taken under the lock. This is the thin runner — ${ref}/${env}/${self}/secret:// +// interpolation happens upstream in the saga (the resolved values arrive via +// PhaseOpts.ExtraEnv); the full workspace-scope ordering is X3. +package hooks + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "sort" + "time" + + "github.com/open-source-cloud/devstack/internal/config" +) + +// Defaults (spec 11). +const ( + DefaultTimeout = 120 * time.Second + defaultBackoff = 2 * time.Second +) + +// onFailure modes (spec 11). +const ( + OnAbort = "abort" + OnWarn = "warn" + OnContinue = "continue" +) + +// Result statuses for a single hook. +const ( + StatusRan = "ran" // executed successfully + StatusSkipped = "skipped" // ledger already satisfied (idempotent hook) + StatusWarned = "warned" // failed but onFailure was warn/continue + StatusFailed = "failed" // failed with onFailure=abort +) + +// Execer runs the two hook transports, returning combined stdout+stderr. It is +// injectable so the runner is testable without a daemon. Implementations MUST +// honor ctx cancellation (the runner enforces each hook's timeout via ctx). +type Execer interface { + // Host runs argv on the host. workdir is hook-relative (joined under the + // documented base dir); env is KEY=VALUE pairs appended to the process env. + Host(ctx context.Context, workdir string, env, argv []string) (string, error) + // Exec runs argv inside a RUNNING service via `compose exec -T`. workdir is the + // in-container -w path; env becomes repeated -e NAME=VALUE flags. + Exec(ctx context.Context, service, workdir string, env, argv []string) (string, error) +} + +// OSExecer is the production Execer: os/exec for host hooks, the `docker compose +// exec` CLI for service hooks (DECISIONS D5 — devstack owns compose CLI +// construction). -T disables TTY for deterministic non-interactive runs; combined +// output is captured for the saga checklist and the failure remediation. +type OSExecer struct { + BaseDir string // documented working dir for run:host (repo root / workspace root) + Project string // compose -p + File string // compose -f +} + +// Host runs argv on the host from the hook's working directory. +func (e OSExecer) Host(ctx context.Context, workdir string, env, argv []string) (string, error) { + if len(argv) == 0 { + return "", fmt.Errorf("empty command") + } + dir := e.BaseDir + if workdir != "" { + if filepath.IsAbs(workdir) { + dir = workdir + } else { + dir = filepath.Join(e.BaseDir, workdir) + } + } + cmd := exec.CommandContext(ctx, argv[0], argv[1:]...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), env...) + out, err := cmd.CombinedOutput() + return string(out), err +} + +// Exec shells argv into a running service. compose exec -e accepts NAME=VALUE +// (unlike `up`), so hook secrets are passed inline without ever touching a file. +func (e OSExecer) Exec(ctx context.Context, service, workdir string, env, argv []string) (string, error) { + if len(argv) == 0 { + return "", fmt.Errorf("empty command") + } + args := []string{"compose", "-p", e.Project, "-f", e.File, "exec", "-T"} + if workdir != "" { + args = append(args, "-w", workdir) + } + for _, kv := range env { + args = append(args, "-e", kv) + } + args = append(args, service) + args = append(args, argv...) + cmd := exec.CommandContext(ctx, "docker", args...) + cmd.Dir = e.BaseDir + out, err := cmd.CombinedOutput() + return string(out), err +} + +// Ledger is the idempotency store the runner needs (satisfied by *state.DB). Kept +// as an interface so internal/hooks does not import internal/state. +type Ledger interface { + HookSatisfied(project, hook, scopeKey string) (bool, error) + RecordHookRun(project, hook, scopeKey string) error +} + +// Locker runs fn while holding the machine-global flock (wraps lock.WithLock). +type Locker func(ctx context.Context, fn func() error) error + +// Runner executes lifecycle hooks. Execer is required; Ledger+Lock are needed +// only for idempotent phases; Backoff/Logf are optional. +type Runner struct { + Execer Execer + Ledger Ledger + Lock Locker + Backoff time.Duration // between retry attempts (0 → default 2s) + Logf func(format string, a ...any) // optional progress/warn sink +} + +// Result is the outcome of one hook (for the saga checklist / --json). +type Result struct { + Hook string + Status string + Attempts int + Output string + Err error +} + +// Run executes one hook with its timeout and retries, returning combined output, +// the attempt count, and the failure error (after retries) if any. It applies NO +// onFailure or idempotency semantics — RunPhase layers those on. extraEnv carries +// resolved ${ref}/secret values (saga-supplied), appended after the hook's own +// env so secrets land last. +func (r *Runner) Run(ctx context.Context, h config.Hook, extraEnv []string) (string, int, error) { + if err := validate(h); err != nil { + return "", 0, err + } + timeout := DefaultTimeout + if d, err := time.ParseDuration(h.Timeout); err == nil && h.Timeout != "" { + timeout = d + } + env := buildEnv(h.Env, extraEnv) + + var ( + out string + err error + attempts int + ) + for attempt := 0; attempt <= max(h.Retries, 0); attempt++ { + if attempt > 0 { + if e := sleep(ctx, r.backoff()); e != nil { + return out, attempts, e + } + } + attempts++ + actx, cancel := context.WithTimeout(ctx, timeout) + if h.Run == "exec" { + out, err = r.Execer.Exec(actx, h.Service, h.Workdir, env, h.Command) + } else { + out, err = r.Execer.Host(actx, h.Workdir, env, h.Command) + } + cancel() + if err == nil { + return out, attempts, nil + } + } + return out, attempts, fmt.Errorf("hook %q failed after %d attempt(s): %w", h.Name, attempts, err) +} + +// PhaseOpts configures a phase run. +type PhaseOpts struct { + Project string // ledger 'project' + Phase string // ledger 'hook' column, e.g. "firstRun" + Idempotent bool // guard every hook via the ledger (firstRun/postPull) + ScopeKey func(config.Hook) string // scope_key for idempotent / `once` hooks + DefaultOnFailure string // applied when a hook omits onFailure + ExtraEnv []string // resolved env/secret values for every hook +} + +// RunPhase runs the phase's hooks in order, applying idempotency and onFailure: +// - abort : stop immediately, return the error (phase fails). +// - warn : log and continue; the phase still succeeds. +// - continue: run remaining hooks, but the phase ultimately fails. +// +// A successful run of an idempotent (or `once`) hook is recorded in the ledger +// INSIDE the flock; nothing is recorded on failure (spec 11). +func (r *Runner) RunPhase(ctx context.Context, hookList []config.Hook, o PhaseOpts) ([]Result, error) { + var ( + results []Result + deferredErr error // from onFailure: continue + ) + for _, h := range hookList { + guarded := o.Idempotent || h.Once + var scope string + if guarded { + if o.ScopeKey == nil { + return results, fmt.Errorf("hook %q: idempotent phase %q has no scope_key function", h.Name, o.Phase) + } + scope = o.ScopeKey(h) + if r.Ledger != nil { + ok, err := r.Ledger.HookSatisfied(o.Project, o.Phase, scope) + if err != nil { + return results, err + } + if ok { + results = append(results, Result{Hook: h.Name, Status: StatusSkipped}) + continue + } + } + } + + out, attempts, err := r.Run(ctx, h, o.ExtraEnv) + res := Result{Hook: h.Name, Attempts: attempts, Output: out, Err: err} + if err == nil { + res.Status = StatusRan + results = append(results, res) + if guarded { + if e := r.record(ctx, o.Project, o.Phase, scope); e != nil { + return results, e + } + } + continue + } + + switch onFailure(h, o.DefaultOnFailure) { + case OnWarn: + res.Status = StatusWarned + r.warn("hook %q failed (onFailure=warn): %v", h.Name, err) + results = append(results, res) + case OnContinue: + res.Status = StatusWarned + deferredErr = err + r.warn("hook %q failed (onFailure=continue): %v", h.Name, err) + results = append(results, res) + default: // abort + res.Status = StatusFailed + results = append(results, res) + return results, err + } + } + return results, deferredErr +} + +func (r *Runner) record(ctx context.Context, project, phase, scope string) error { + if r.Ledger == nil { + return nil + } + rec := func() error { return r.Ledger.RecordHookRun(project, phase, scope) } + if r.Lock != nil { + return r.Lock(ctx, rec) + } + return rec() +} + +func (r *Runner) backoff() time.Duration { + if r.Backoff > 0 { + return r.Backoff + } + return defaultBackoff +} + +func (r *Runner) warn(format string, a ...any) { + if r.Logf != nil { + r.Logf(format, a...) + } +} + +// validate enforces the transport-specific rule config can't (spec 11): run:exec +// needs a target service. (Structural shape — name/run/command — is already +// validated at config load, C3a.) +func validate(h config.Hook) error { + if len(h.Command) == 0 { + return fmt.Errorf("hook %q: command is empty", h.Name) + } + if h.Run == "exec" && h.Service == "" { + return fmt.Errorf("hook %q: run: exec requires a `service`", h.Name) + } + return nil +} + +func onFailure(h config.Hook, def string) string { + if h.OnFailure != "" { + return h.OnFailure + } + if def != "" { + return def + } + return OnAbort +} + +// buildEnv renders the hook's env map (sorted for determinism) followed by the +// saga-supplied extra env (secrets last, spec 11). +func buildEnv(m map[string]string, extra []string) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + env := make([]string, 0, len(m)+len(extra)) + for _, k := range keys { + env = append(env, k+"="+m[k]) + } + return append(env, extra...) +} + +// sleep is the cancelable inter-retry wait, indirected for tests. +var sleep = func(ctx context.Context, d time.Duration) error { + if d <= 0 { + return ctx.Err() + } + t := time.NewTimer(d) + defer t.Stop() + select { + case <-ctx.Done(): + return ctx.Err() + case <-t.C: + return nil + } +} diff --git a/internal/hooks/hooks_integration_test.go b/internal/hooks/hooks_integration_test.go new file mode 100644 index 0000000..8cd3449 --- /dev/null +++ b/internal/hooks/hooks_integration_test.go @@ -0,0 +1,56 @@ +//go:build integration + +package hooks + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/open-source-cloud/devstack/internal/config" +) + +// TestOSExecer_RealCompose brings up a one-service busybox stack and runs a +// `run: exec` hook into it via the real `docker compose exec` transport, +// asserting combined output and inline `-e` env injection. Tagged `integration`. +func TestOSExecer_RealCompose(t *testing.T) { + ctx := context.Background() + if err := exec.CommandContext(ctx, "docker", "info").Run(); err != nil { + t.Skipf("no reachable Docker daemon: %v", err) + } + + dir := t.TempDir() + proj := "devstack-it-hooks-" + strconv.Itoa(os.Getpid()) + file := filepath.Join(dir, "docker-compose.yaml") + compose := "services:\n app:\n image: busybox\n command: [\"sh\",\"-c\",\"sleep 300\"]\n" + if err := os.WriteFile(file, []byte(compose), 0o644); err != nil { + t.Fatal(err) + } + + up := exec.CommandContext(ctx, "docker", "compose", "-p", proj, "-f", file, "up", "-d") + if out, err := up.CombinedOutput(); err != nil { + t.Fatalf("compose up: %v\n%s", err, out) + } + t.Cleanup(func() { + _ = exec.Command("docker", "compose", "-p", proj, "-f", file, "down", "-v").Run() + }) + + e := OSExecer{BaseDir: dir, Project: proj, File: file} + r := &Runner{Execer: e} + h := config.Hook{ + Name: "echo-env", Run: "exec", Service: "app", + Command: []string{"sh", "-c", "echo container-says:$INJECTED"}, + Env: map[string]string{"INJECTED": "hello"}, + } + out, attempts, err := r.Run(ctx, h, nil) + if err != nil { + t.Fatalf("Run exec: %v\n%s", err, out) + } + if attempts != 1 || !strings.Contains(out, "container-says:hello") { + t.Errorf("exec hook out=%q attempts=%d, want injected env echoed", out, attempts) + } +} diff --git a/internal/hooks/hooks_test.go b/internal/hooks/hooks_test.go new file mode 100644 index 0000000..07840a6 --- /dev/null +++ b/internal/hooks/hooks_test.go @@ -0,0 +1,269 @@ +package hooks + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/open-source-cloud/devstack/internal/config" +) + +type callResult struct { + out string + err error +} + +// fakeExecer consumes a scripted result sequence across both transports and +// records which transport each call used. +type fakeExecer struct { + seq []callResult + i int + block bool // block until ctx is done (to exercise timeout) + calls []string + lastEnv []string +} + +func (f *fakeExecer) run(ctx context.Context) (string, error) { + if f.block { + <-ctx.Done() + return "", ctx.Err() + } + idx := f.i + if idx >= len(f.seq) { + idx = len(f.seq) - 1 + } + f.i++ + if idx < 0 { + return "", nil + } + return f.seq[idx].out, f.seq[idx].err +} + +func (f *fakeExecer) Host(ctx context.Context, workdir string, env, _ []string) (string, error) { + f.calls = append(f.calls, "host:"+workdir) + f.lastEnv = env + return f.run(ctx) +} + +func (f *fakeExecer) Exec(ctx context.Context, service, _ string, env, _ []string) (string, error) { + f.calls = append(f.calls, "exec:"+service) + f.lastEnv = env + return f.run(ctx) +} + +type fakeLedger struct { + satisfied map[string]bool + recorded []string +} + +func lkey(p, h, s string) string { return p + "|" + h + "|" + s } + +func (l *fakeLedger) HookSatisfied(p, h, s string) (bool, error) { + return l.satisfied[lkey(p, h, s)], nil +} +func (l *fakeLedger) RecordHookRun(p, h, s string) error { + if l.satisfied == nil { + l.satisfied = map[string]bool{} + } + l.satisfied[lkey(p, h, s)] = true + l.recorded = append(l.recorded, lkey(p, h, s)) + return nil +} + +func hostHook(name string, onFail string) config.Hook { + return config.Hook{Name: name, Run: "host", Command: []string{"true"}, OnFailure: onFail} +} + +func TestRunHostSuccess(t *testing.T) { + fe := &fakeExecer{seq: []callResult{{out: "ok"}}} + r := &Runner{Execer: fe} + out, attempts, err := r.Run(context.Background(), hostHook("x", ""), nil) + if err != nil || out != "ok" || attempts != 1 { + t.Fatalf("Run = %q, %d, %v", out, attempts, err) + } + if len(fe.calls) != 1 || fe.calls[0] != "host:" { + t.Errorf("calls = %v, want one host call", fe.calls) + } +} + +func TestRunExecRequiresService(t *testing.T) { + r := &Runner{Execer: &fakeExecer{}} + _, _, err := r.Run(context.Background(), config.Hook{Name: "m", Run: "exec", Command: []string{"true"}}, nil) + if err == nil { + t.Fatal("run:exec without a service should error") + } +} + +func TestRunRetriesThenSucceeds(t *testing.T) { + fe := &fakeExecer{seq: []callResult{ + {err: errors.New("boom")}, {err: errors.New("boom")}, {out: "done"}, + }} + r := &Runner{Execer: fe, Backoff: time.Millisecond} + h := config.Hook{Name: "m", Run: "host", Command: []string{"true"}, Retries: 3} + out, attempts, err := r.Run(context.Background(), h, nil) + if err != nil || out != "done" || attempts != 3 { + t.Fatalf("Run = %q, %d, %v; want done,3,nil", out, attempts, err) + } +} + +func TestRunRetriesExhausted(t *testing.T) { + fe := &fakeExecer{seq: []callResult{{err: errors.New("boom")}}} + r := &Runner{Execer: fe, Backoff: time.Millisecond} + h := config.Hook{Name: "m", Run: "host", Command: []string{"true"}, Retries: 2} + _, attempts, err := r.Run(context.Background(), h, nil) + if err == nil || attempts != 3 { + t.Fatalf("Run attempts=%d err=%v; want 3 attempts + error", attempts, err) + } +} + +func TestRunTimeout(t *testing.T) { + fe := &fakeExecer{block: true} + r := &Runner{Execer: fe} + h := config.Hook{Name: "slow", Run: "host", Command: []string{"sleep"}, Timeout: "10ms"} + out, attempts, err := r.Run(context.Background(), h, nil) + if err == nil { + t.Fatal("a hook exceeding its timeout should fail") + } + if out != "" || attempts != 1 { + t.Errorf("out=%q attempts=%d, want empty + 1 attempt", out, attempts) + } +} + +func TestRunPhaseAbortStops(t *testing.T) { + fe := &fakeExecer{seq: []callResult{{err: errors.New("boom")}, {out: "second"}}} + r := &Runner{Execer: fe} + hooks := []config.Hook{hostHook("a", "abort"), hostHook("b", "")} + res, err := r.RunPhase(context.Background(), hooks, PhaseOpts{Phase: "postUp"}) + if err == nil { + t.Fatal("abort should fail the phase") + } + if len(res) != 1 || res[0].Status != StatusFailed { + t.Fatalf("results = %+v, want one failed", res) + } + if len(fe.calls) != 1 { + t.Errorf("second hook should not run after abort; calls=%v", fe.calls) + } +} + +func TestRunPhaseWarnContinues(t *testing.T) { + fe := &fakeExecer{seq: []callResult{{err: errors.New("boom")}, {out: "ok"}}} + r := &Runner{Execer: fe} + hooks := []config.Hook{hostHook("a", "warn"), hostHook("b", "")} + res, err := r.RunPhase(context.Background(), hooks, PhaseOpts{Phase: "postUp"}) + if err != nil { + t.Fatalf("warn should not fail the phase: %v", err) + } + if len(res) != 2 || res[0].Status != StatusWarned || res[1].Status != StatusRan { + t.Fatalf("results = %+v", res) + } +} + +func TestRunPhaseContinueFailsPhaseButRunsRest(t *testing.T) { + fe := &fakeExecer{seq: []callResult{{err: errors.New("boom")}, {out: "ok"}}} + r := &Runner{Execer: fe} + hooks := []config.Hook{hostHook("a", "continue"), hostHook("b", "")} + res, err := r.RunPhase(context.Background(), hooks, PhaseOpts{Phase: "postUp"}) + if err == nil { + t.Fatal("continue should still fail the phase overall") + } + if len(res) != 2 { + t.Fatalf("both hooks should run; results=%+v", res) + } +} + +func TestRunPhaseIdempotentSkip(t *testing.T) { + led := &fakeLedger{satisfied: map[string]bool{lkey("api", "firstRun", "vol-1"): true}} + fe := &fakeExecer{seq: []callResult{{out: "ran"}}} + r := &Runner{Execer: fe, Ledger: led} + hooks := []config.Hook{hostHook("migrate", "")} + res, err := r.RunPhase(context.Background(), hooks, PhaseOpts{ + Project: "api", Phase: "firstRun", Idempotent: true, + ScopeKey: func(config.Hook) string { return "vol-1" }, + }) + if err != nil { + t.Fatal(err) + } + if len(res) != 1 || res[0].Status != StatusSkipped { + t.Fatalf("results = %+v, want skipped", res) + } + if len(fe.calls) != 0 { + t.Errorf("a satisfied hook must not execute; calls=%v", fe.calls) + } +} + +func TestRunPhaseIdempotentRecordsOnSuccessNotFailure(t *testing.T) { + // Success path: records under the lock. + led := &fakeLedger{} + locked := 0 + fe := &fakeExecer{seq: []callResult{{out: "ok"}}} + r := &Runner{Execer: fe, Ledger: led, Lock: func(_ context.Context, fn func() error) error { + locked++ + return fn() + }} + scope := func(config.Hook) string { return "vol-1" } + if _, err := r.RunPhase(context.Background(), []config.Hook{hostHook("migrate", "")}, PhaseOpts{ + Project: "api", Phase: "firstRun", Idempotent: true, ScopeKey: scope, + }); err != nil { + t.Fatal(err) + } + if len(led.recorded) != 1 || led.recorded[0] != lkey("api", "firstRun", "vol-1") { + t.Fatalf("recorded = %v, want one row", led.recorded) + } + if locked != 1 { + t.Errorf("record should happen inside the lock exactly once, got %d", locked) + } + + // Failure path: nothing recorded. + led2 := &fakeLedger{} + feFail := &fakeExecer{seq: []callResult{{err: errors.New("boom")}}} + r2 := &Runner{Execer: feFail, Ledger: led2, Lock: func(_ context.Context, fn func() error) error { return fn() }} + _, _ = r2.RunPhase(context.Background(), []config.Hook{hostHook("migrate", "abort")}, PhaseOpts{ + Project: "api", Phase: "firstRun", Idempotent: true, ScopeKey: scope, + }) + if len(led2.recorded) != 0 { + t.Errorf("a failed firstRun must record nothing, got %v", led2.recorded) + } +} + +func TestBuildEnvSortedSecretsLast(t *testing.T) { + got := buildEnv(map[string]string{"B": "2", "A": "1"}, []string{"SECRET=x"}) + want := []string{"A=1", "B=2", "SECRET=x"} + if len(got) != 3 || got[0] != want[0] || got[1] != want[1] || got[2] != want[2] { + t.Errorf("buildEnv = %v, want %v", got, want) + } +} + +func TestRunExecTransportRouting(t *testing.T) { + fe := &fakeExecer{seq: []callResult{{out: "ok"}}} + r := &Runner{Execer: fe} + h := config.Hook{Name: "m", Run: "exec", Service: "api", Command: []string{"true"}, Env: map[string]string{"K": "V"}} + if _, _, err := r.Run(context.Background(), h, []string{"S=1"}); err != nil { + t.Fatal(err) + } + if len(fe.calls) != 1 || fe.calls[0] != "exec:api" { + t.Fatalf("calls = %v, want exec:api", fe.calls) + } + if len(fe.lastEnv) != 2 || fe.lastEnv[0] != "K=V" || fe.lastEnv[1] != "S=1" { + t.Errorf("env = %v, want [K=V S=1]", fe.lastEnv) + } +} + +// TestOSExecerHost exercises the real host transport (os/exec) hermetically: +// env injection + working directory. No daemon needed. +func TestOSExecerHost(t *testing.T) { + dir := t.TempDir() + e := OSExecer{BaseDir: dir} + out, err := e.Host(context.Background(), "", []string{"GREETING=hi"}, + []string{"sh", "-c", "echo $GREETING; pwd"}) + if err != nil { + t.Fatalf("Host: %v\n%s", err, out) + } + if !strings.HasPrefix(out, "hi\n") { + t.Errorf("output = %q, want it to start with \"hi\\n\"", out) + } + if !strings.Contains(out, dir) { + t.Errorf("output %q should contain the working dir %q", out, dir) + } +} diff --git a/internal/state/hooks.go b/internal/state/hooks.go new file mode 100644 index 0000000..3a44074 --- /dev/null +++ b/internal/state/hooks.go @@ -0,0 +1,63 @@ +package state + +import ( + "database/sql" + "fmt" +) + +// This file is the CRUD over the spec-08/spec-11 `hook_run` table: the +// idempotency ledger for firstRun/postPull hooks. A row exists IFF that hook is +// satisfied for that scope_key (e.g. the provisioned data-volume identity for +// firstRun, the resolved commit SHA for postPull). The cardinal rule (spec 11): +// a row is written ONLY on hook success, inside the flock — so the ledger always +// reflects success and a failed/interrupted hook re-runs next time. +// +// HookSatisfied is a lock-free read; RecordHookRun / DeleteHookRuns mutate and +// MUST be called while holding the machine-global flock (internal/lock). + +// HookSatisfied reports whether (project, hook, scopeKey) already has a recorded +// successful run for this Docker context. Lock-free. +func (db *DB) HookSatisfied(project, hook, scopeKey string) (bool, error) { + var one int + err := db.QueryRow(`SELECT 1 FROM hook_run + WHERE ctx=? AND project=? AND hook=? AND scope_key=?`, + db.Ctx, project, hook, scopeKey).Scan(&one) + if err == sql.ErrNoRows { + return false, nil + } + if err != nil { + return false, fmt.Errorf("hook satisfied check %s/%s: %w", project, hook, err) + } + return true, nil +} + +// RecordHookRun marks (project, hook, scopeKey) satisfied (idempotent — a repeat +// is a no-op). Call only on hook success, inside the flock. +func (db *DB) RecordHookRun(project, hook, scopeKey string) error { + _, err := db.Exec(`INSERT OR IGNORE INTO hook_run (ctx, project, hook, scope_key) + VALUES (?,?,?,?)`, db.Ctx, project, hook, scopeKey) + if err != nil { + return fmt.Errorf("record hook run %s/%s: %w", project, hook, err) + } + return nil +} + +// DeleteHookRuns clears recorded runs so the hooks re-arm (the `--force-hooks` +// path, spec 11). With hook=="" it clears every hook for the project; otherwise +// just the named hook. Returns the number of rows removed. Hold the flock. +func (db *DB) DeleteHookRuns(project, hook string) (int, error) { + var ( + res sql.Result + err error + ) + if hook == "" { + res, err = db.Exec(`DELETE FROM hook_run WHERE ctx=? AND project=?`, db.Ctx, project) + } else { + res, err = db.Exec(`DELETE FROM hook_run WHERE ctx=? AND project=? AND hook=?`, db.Ctx, project, hook) + } + if err != nil { + return 0, fmt.Errorf("delete hook runs %s/%s: %w", project, hook, err) + } + n, _ := res.RowsAffected() + return int(n), nil +} diff --git a/internal/state/hooks_test.go b/internal/state/hooks_test.go new file mode 100644 index 0000000..f459d40 --- /dev/null +++ b/internal/state/hooks_test.go @@ -0,0 +1,57 @@ +package state + +import "testing" + +func TestHookRunLedger(t *testing.T) { + db := openTestDB(t) + + // Unsatisfied before any run. + if ok, err := db.HookSatisfied("api", "firstRun", "vol-abc"); err != nil || ok { + t.Fatalf("HookSatisfied before run = %v, %v; want false, nil", ok, err) + } + + // Record success → satisfied. + if err := db.RecordHookRun("api", "firstRun", "vol-abc"); err != nil { + t.Fatal(err) + } + if ok, _ := db.HookSatisfied("api", "firstRun", "vol-abc"); !ok { + t.Error("HookSatisfied after record should be true") + } + // Idempotent record. + if err := db.RecordHookRun("api", "firstRun", "vol-abc"); err != nil { + t.Fatalf("re-record should be a no-op: %v", err) + } + + // A different scope_key (e.g. volume reset) re-arms. + if ok, _ := db.HookSatisfied("api", "firstRun", "vol-xyz"); ok { + t.Error("a different scope_key must be unsatisfied (re-armed)") + } + // A different hook name is independent. + if ok, _ := db.HookSatisfied("api", "postPull", "vol-abc"); ok { + t.Error("a different hook must be unsatisfied") + } + + // Force-rearm just this hook. + _ = db.RecordHookRun("api", "postPull", "sha-1") + n, err := db.DeleteHookRuns("api", "firstRun") + if err != nil { + t.Fatal(err) + } + if n != 1 { + t.Errorf("DeleteHookRuns(firstRun) removed %d, want 1", n) + } + if ok, _ := db.HookSatisfied("api", "firstRun", "vol-abc"); ok { + t.Error("firstRun should be re-armed after delete") + } + if ok, _ := db.HookSatisfied("api", "postPull", "sha-1"); !ok { + t.Error("postPull should survive a firstRun-only delete") + } + + // Delete all for the project. + if n, _ := db.DeleteHookRuns("api", ""); n != 1 { + t.Errorf("DeleteHookRuns(all) removed %d, want 1", n) + } + if ok, _ := db.HookSatisfied("api", "postPull", "sha-1"); ok { + t.Error("all hooks should be re-armed after project-wide delete") + } +}