Skip to content

Commit 07dcaf6

Browse files
authored
Merge pull request #90 from LAA-Software-Engineering/feat/apply-exit-code-3-78
feat(apply): exit code 3 for plan/apply deployment conflicts (#78)
2 parents 98a2fd0 + 01cc364 commit 07dcaf6

13 files changed

Lines changed: 252 additions & 5 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ Notes:
148148
| `-o` / `--output` | `table`, `json`, or `yaml` |
149149
| `--no-color` | ASCII-friendly validate output |
150150

151-
Exit codes are summarized in **section 11.2** of [`docs/DESIGN_DOC.md`](docs/DESIGN_DOC.md) (`0` success, `2` validation, `4` execution, `5` policy denial, …).
151+
Exit codes are summarized in **section 11.2** of [`docs/DESIGN_DOC.md`](docs/DESIGN_DOC.md) (`0` success, `2` validation, **`3` plan/apply conflict** when deployment state changed after `plan`, `4` execution, `5` policy denial, …).
152152

153153
---
154154

internal/apply/applier.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@ func (a *Applier) ApplyPlan(ctx context.Context, env string, g *spec.ProjectGrap
5050
at = at.UTC()
5151

5252
run := func(ctx context.Context, dep state.DeploymentStore) error {
53+
if err := assertDeploymentBaseline(ctx, dep, env, projectName, p); err != nil {
54+
return err
55+
}
5356
return executePlan(ctx, dep, env, p, at, projectName, projectVersion)
5457
}
5558
if tx, ok := a.Deploy.(state.TransactionalDeployment); ok {

internal/apply/applier_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,3 +127,47 @@ func TestApplyPlan_deleteRemovesRow(t *testing.T) {
127127
t.Fatalf("want project only, got %+v", list)
128128
}
129129
}
130+
131+
func TestApplyPlan_rejectsStaleDeploymentBaseline(t *testing.T) {
132+
ctx := context.Background()
133+
st, err := sqlite.Open(ctx, filepath.Join(t.TempDir(), "apply-stale.db"))
134+
if err != nil {
135+
t.Fatal(err)
136+
}
137+
t.Cleanup(func() { _ = st.Close() })
138+
139+
pl := plan.NewPlanner(st)
140+
ap := NewApplier(st)
141+
t0 := time.Date(2026, 4, 11, 10, 0, 0, 0, time.UTC)
142+
143+
gFull := graphWithAgent()
144+
pCreate, err := pl.ComputePlan(ctx, "dev", gFull)
145+
if err != nil {
146+
t.Fatal(err)
147+
}
148+
if len(pCreate.Operations) == 0 || pCreate.DeploymentBaseline == "" {
149+
t.Fatalf("want non-empty plan with baseline, got ops=%d baseline=%q", len(pCreate.Operations), pCreate.DeploymentBaseline)
150+
}
151+
if err := ap.ApplyPlan(ctx, "dev", gFull, pCreate, t0); err != nil {
152+
t.Fatal(err)
153+
}
154+
155+
gOnly := minimalGraph()
156+
pDelete, err := pl.ComputePlan(ctx, "dev", gOnly)
157+
if err != nil {
158+
t.Fatal(err)
159+
}
160+
if pDelete.DeploymentBaseline == "" {
161+
t.Fatal("missing baseline")
162+
}
163+
164+
// Simulate another writer: deployment no longer matches the fingerprint embedded in pDelete.
165+
if err := st.DeleteAppliedResource(ctx, "dev", spec.ResourceID{Kind: spec.KindAgent, Name: "rev"}); err != nil {
166+
t.Fatal(err)
167+
}
168+
169+
err = ap.ApplyPlan(ctx, "dev", gOnly, pDelete, t0.Add(time.Hour))
170+
if !errors.Is(err, ErrDeploymentStateChanged) {
171+
t.Fatalf("want ErrDeploymentStateChanged, got %v", err)
172+
}
173+
}

internal/apply/baseline.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package apply
2+
3+
import (
4+
"context"
5+
"errors"
6+
"strings"
7+
8+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/plan"
9+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/state"
10+
)
11+
12+
func assertDeploymentBaseline(ctx context.Context, dep state.DeploymentStore, env, projectName string, p *plan.Plan) error {
13+
if p == nil {
14+
return errors.New("apply: nil plan")
15+
}
16+
want := strings.TrimSpace(p.DeploymentBaseline)
17+
if want == "" {
18+
// Plans from [plan.Planner.ComputePlan] always set a baseline; empty skips the check for tests/synthetics.
19+
return nil
20+
}
21+
got, err := plan.DeploymentStateFingerprint(ctx, dep, env, projectName)
22+
if err != nil {
23+
return err
24+
}
25+
if got != want {
26+
return ErrDeploymentStateChanged
27+
}
28+
return nil
29+
}

internal/apply/errors.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package apply
2+
3+
import "errors"
4+
5+
// ErrDeploymentStateChanged means SQLite deployment state changed after the plan was computed.
6+
// Scripts should re-run plan, then apply. Surfaces as CLI exit code 3 (issue #78).
7+
var ErrDeploymentStateChanged = errors.New("deployment state changed since plan was computed; re-run plan")

internal/cli/apply.go

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package cli
33
import (
44
"bufio"
55
"context"
6+
"errors"
67
"fmt"
78
"io"
89
"os"
@@ -33,14 +34,18 @@ then persist changes unless you decline at the prompt.
3334
Use --auto-approve to skip confirmation, or set ` + EnvAutoApprove + `=1 for non-interactive runs
3435
(CI, scripts). When stdin is not a terminal and the plan is non-empty, one of those is required.
3536
37+
The plan is computed, then (after any prompt) applied in a single run. If another writer changes the
38+
same state database between those steps—e.g. a second terminal applying the same --state file while
39+
this process waits at the confirmation prompt—apply fails with exit code 3.
40+
3641
The state database defaults to .agentic/state.db under --project, or project.spec.state.dsn,
3742
unless overridden by global --state.
3843
3944
Exit codes (section 11.2):
4045
0 — success (including nothing to apply)
4146
1 — generic failure (e.g. cannot open SQLite, non-interactive without approval, cancelled)
4247
2 — validation failure (invalid project), or non-table output without approval when the plan is non-empty
43-
3 — plan/apply conflict (reserved for optimistic concurrency; not used in this MVP)`,
48+
3 — plan/apply conflict: deployment store changed after this plan was computed (re-run plan, then apply)`,
4449
RunE: func(cmd *cobra.Command, args []string) error {
4550
_ = args
4651
return runApply(cmd, autoApprove)
@@ -122,6 +127,9 @@ func runApply(cmd *cobra.Command, flagAutoApprove bool) error {
122127

123128
at := time.Now().UTC()
124129
if err := apply.NewApplier(st).ApplyPlan(ctx, env, graph, pl, at); err != nil {
130+
if errors.Is(err, apply.ErrDeploymentStateChanged) {
131+
return NewExitError(ExitPlanApplyConflict, err)
132+
}
125133
return fmt.Errorf("apply: %w", err)
126134
}
127135

internal/cli/apply_test.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"strings"
1010
"testing"
1111

12+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/apply"
1213
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/state/sqlite"
1314
)
1415

@@ -188,3 +189,10 @@ func TestApply_jsonAutoApprove_validJSON(t *testing.T) {
188189
t.Fatalf("operations: %v", m["operations"])
189190
}
190191
}
192+
193+
func TestApply_exitCode_planApplyConflict(t *testing.T) {
194+
err := NewExitError(ExitPlanApplyConflict, apply.ErrDeploymentStateChanged)
195+
if ExitCodeOf(err) != ExitPlanApplyConflict {
196+
t.Fatalf("exit code = %d want %d", ExitCodeOf(err), ExitPlanApplyConflict)
197+
}
198+
}

internal/cli/diff.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ func runDiff(cmd *cobra.Command, args []string) error {
150150
if op == nil {
151151
return writeDiffInSync(cmd, env, dsn, id, g)
152152
}
153-
pl = &plan.Plan{Operations: []plan.Operation{*op}, Risk: plan.RiskSummary{}}
153+
pl = &plan.Plan{Operations: []plan.Operation{*op}, Risk: plan.RiskSummary{}, DeploymentBaseline: pl.DeploymentBaseline}
154154
}
155155

156156
entries := buildDiffEntries(pl, appliedByKey)

internal/cli/plan.go

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ func planJSONModel(env, dsn string, p *plan.Plan) map[string]any {
121121
}
122122
ops = append(ops, entry)
123123
}
124-
return map[string]any{
124+
m := map[string]any{
125125
"environment": env,
126126
"statePath": dsn,
127127
"summary": map[string]any{
@@ -132,6 +132,10 @@ func planJSONModel(env, dsn string, p *plan.Plan) map[string]any {
132132
"operations": ops,
133133
"risk": riskStrings(p),
134134
}
135+
if p != nil && p.DeploymentBaseline != "" {
136+
m["deploymentBaseline"] = p.DeploymentBaseline
137+
}
138+
return m
135139
}
136140

137141
func riskStrings(p *plan.Plan) []string {

internal/plan/fingerprint.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package plan
2+
3+
import (
4+
"context"
5+
"crypto/sha256"
6+
"database/sql"
7+
"encoding/hex"
8+
"errors"
9+
"fmt"
10+
"sort"
11+
"strings"
12+
13+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/state"
14+
)
15+
16+
// DeploymentStateFingerprint returns a stable SHA-256 hex digest of deployment rows for env.
17+
// It covers every applied_resources row for env (kind, name, spec hash, normalized JSON) and
18+
// the applied_projects row for (env, projectName), or a sentinel when that row is missing.
19+
// Used for optimistic concurrency between plan and apply (issue #78).
20+
func DeploymentStateFingerprint(ctx context.Context, dep state.DeploymentStore, env, projectName string) (string, error) {
21+
if dep == nil {
22+
return "", errors.New("plan: nil deployment store")
23+
}
24+
env = strings.TrimSpace(env)
25+
projectName = strings.TrimSpace(projectName)
26+
if env == "" || projectName == "" {
27+
return "", errors.New("plan: empty env or project name")
28+
}
29+
list, err := dep.ListAppliedResourcesByEnv(ctx, env)
30+
if err != nil {
31+
return "", err
32+
}
33+
sort.Slice(list, func(i, j int) bool {
34+
if list[i].Kind != list[j].Kind {
35+
return list[i].Kind < list[j].Kind
36+
}
37+
return list[i].Name < list[j].Name
38+
})
39+
var b strings.Builder
40+
for _, r := range list {
41+
fmt.Fprintf(&b, "%s\x00%s\x00%s\x00%s\n", r.Kind, r.Name, r.SpecHash, r.NormalizedSpecJSON)
42+
}
43+
proj, err := dep.GetAppliedProject(ctx, env, projectName)
44+
switch {
45+
case err != nil && errors.Is(err, sql.ErrNoRows):
46+
b.WriteString("applied_projects\x00MISSING\n")
47+
case err != nil:
48+
return "", err
49+
default:
50+
if proj == nil {
51+
b.WriteString("applied_projects\x00MISSING\n")
52+
} else {
53+
fmt.Fprintf(&b, "applied_projects\x00%s\x00%s\x00%s\n", proj.ProjectName, proj.Env, proj.Version)
54+
}
55+
}
56+
sum := sha256.Sum256([]byte(b.String()))
57+
return hex.EncodeToString(sum[:]), nil
58+
}

0 commit comments

Comments
 (0)