Skip to content

Commit 3ab4138

Browse files
authored
Merge pull request #49 from LAA-Software-Engineering/issue/15-apply-applier
feat(apply): persist planned deployment state (issue #15)
2 parents 70f6917 + 9b41650 commit 3ab4138

17 files changed

Lines changed: 506 additions & 118 deletions

Makefile

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
11
.PHONY: test build
22

3+
fmt:
4+
go fmt ./...
5+
36
test:
47
go test ./... -race
58

69
test-coverage:
710
go test ./... -race -coverprofile=coverage.out
811

12+
vet:
13+
go vet ./...
14+
915
build:
1016
mkdir -p bin
1117
go build -o bin/agentctl ./cmd/agentctl

internal/apply/applier.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package apply
2+
3+
import (
4+
"context"
5+
"errors"
6+
"time"
7+
8+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/plan"
9+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/spec"
10+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/state"
11+
)
12+
13+
// Applier mutates deployment state from an apply operation (design doc §5.2, §12.2 D).
14+
type Applier struct {
15+
Deploy state.DeploymentStore
16+
}
17+
18+
// NewApplier returns an applier backed by dep.
19+
func NewApplier(dep state.DeploymentStore) *Applier {
20+
return &Applier{Deploy: dep}
21+
}
22+
23+
// RecordAppliedResource upserts one applied resource row.
24+
func (a *Applier) RecordAppliedResource(ctx context.Context, r state.AppliedResource) error {
25+
if a == nil || a.Deploy == nil {
26+
return errors.New("apply: nil applier or deployment store")
27+
}
28+
return a.Deploy.UpsertAppliedResource(ctx, r)
29+
}
30+
31+
// ApplyPlan persists all plan operations and updates applied_projects for env (issue #15).
32+
// When dep implements [state.TransactionalDeployment] (e.g. SQLite), the whole apply runs in one transaction.
33+
func (a *Applier) ApplyPlan(ctx context.Context, env string, g *spec.ProjectGraph, p *plan.Plan, at time.Time) error {
34+
if a == nil || a.Deploy == nil {
35+
return errors.New("apply: nil applier or deployment store")
36+
}
37+
if g == nil {
38+
return errors.New("apply: nil project graph")
39+
}
40+
if p == nil {
41+
return errors.New("apply: nil plan")
42+
}
43+
if env == "" {
44+
return errors.New("apply: empty env")
45+
}
46+
projectName, projectVersion, err := plan.ProjectDeploymentMeta(g)
47+
if err != nil {
48+
return err
49+
}
50+
at = at.UTC()
51+
52+
run := func(ctx context.Context, dep state.DeploymentStore) error {
53+
return executePlan(ctx, dep, env, p, at, projectName, projectVersion)
54+
}
55+
if tx, ok := a.Deploy.(state.TransactionalDeployment); ok {
56+
return tx.RunDeploymentTx(ctx, run)
57+
}
58+
return run(ctx, a.Deploy)
59+
}

internal/apply/applier_test.go

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
package apply
2+
3+
import (
4+
"context"
5+
"database/sql"
6+
"errors"
7+
"path/filepath"
8+
"testing"
9+
"time"
10+
11+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/plan"
12+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/spec"
13+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/state/sqlite"
14+
)
15+
16+
func minimalGraph() *spec.ProjectGraph {
17+
return &spec.ProjectGraph{
18+
Meta: spec.Metadata{Name: "acme"},
19+
Spec: spec.ProjectSpec{},
20+
Agents: map[string]*spec.AgentResource{},
21+
Tools: map[string]*spec.ToolResource{},
22+
Workflows: map[string]*spec.WorkflowResource{},
23+
Policies: map[string]*spec.PolicyResource{},
24+
Environments: map[string]*spec.EnvironmentResource{},
25+
}
26+
}
27+
28+
func graphWithAgent() *spec.ProjectGraph {
29+
g := minimalGraph()
30+
g.Agents["rev"] = &spec.AgentResource{
31+
APIVersion: spec.APIVersionV0,
32+
Kind: spec.KindAgent,
33+
Metadata: spec.Metadata{Name: "rev"},
34+
Spec: spec.AgentSpec{Model: "m", Policy: "default"},
35+
}
36+
return g
37+
}
38+
39+
func TestApplyPlan_thenListShowsResources(t *testing.T) {
40+
ctx := context.Background()
41+
st, err := sqlite.Open(ctx, filepath.Join(t.TempDir(), "apply.db"))
42+
if err != nil {
43+
t.Fatal(err)
44+
}
45+
t.Cleanup(func() { _ = st.Close() })
46+
47+
g := minimalGraph()
48+
pl := plan.NewPlanner(st)
49+
p, err := pl.ComputePlan(ctx, "dev", g)
50+
if err != nil {
51+
t.Fatal(err)
52+
}
53+
at := time.Date(2026, 4, 11, 12, 0, 0, 0, time.UTC)
54+
ap := NewApplier(st)
55+
if err := ap.ApplyPlan(ctx, "dev", g, p, at); err != nil {
56+
t.Fatal(err)
57+
}
58+
59+
list, err := st.ListAppliedResourcesByEnv(ctx, "dev")
60+
if err != nil {
61+
t.Fatal(err)
62+
}
63+
if len(list) != 1 {
64+
t.Fatalf("resources: %+v", list)
65+
}
66+
if list[0].Kind != spec.KindProject || list[0].Name != "acme" {
67+
t.Fatalf("got %+v", list[0])
68+
}
69+
got, err := st.GetAppliedResource(ctx, "dev", spec.ResourceID{Kind: spec.KindProject, Name: "acme"})
70+
if err != nil {
71+
t.Fatal(err)
72+
}
73+
if got.SpecHash == "" || got.NormalizedSpecJSON == "" {
74+
t.Fatalf("missing spec material: %+v", got)
75+
}
76+
77+
proj, err := st.GetAppliedProject(ctx, "dev", "acme")
78+
if err != nil {
79+
t.Fatal(err)
80+
}
81+
if proj.Version == "" {
82+
t.Fatalf("applied_projects.version empty: %+v", proj)
83+
}
84+
}
85+
86+
func TestApplyPlan_deleteRemovesRow(t *testing.T) {
87+
ctx := context.Background()
88+
st, err := sqlite.Open(ctx, filepath.Join(t.TempDir(), "apply-del.db"))
89+
if err != nil {
90+
t.Fatal(err)
91+
}
92+
t.Cleanup(func() { _ = st.Close() })
93+
94+
pl := plan.NewPlanner(st)
95+
ap := NewApplier(st)
96+
t0 := time.Date(2026, 4, 11, 10, 0, 0, 0, time.UTC)
97+
t1 := t0.Add(time.Hour)
98+
99+
gFull := graphWithAgent()
100+
p1, err := pl.ComputePlan(ctx, "dev", gFull)
101+
if err != nil {
102+
t.Fatal(err)
103+
}
104+
if err := ap.ApplyPlan(ctx, "dev", gFull, p1, t0); err != nil {
105+
t.Fatal(err)
106+
}
107+
108+
gOnly := minimalGraph()
109+
p2, err := pl.ComputePlan(ctx, "dev", gOnly)
110+
if err != nil {
111+
t.Fatal(err)
112+
}
113+
if err := ap.ApplyPlan(ctx, "dev", gOnly, p2, t1); err != nil {
114+
t.Fatal(err)
115+
}
116+
117+
_, err = st.GetAppliedResource(ctx, "dev", spec.ResourceID{Kind: spec.KindAgent, Name: "rev"})
118+
if !errors.Is(err, sql.ErrNoRows) {
119+
t.Fatalf("agent row should be gone: %v", err)
120+
}
121+
122+
list, err := st.ListAppliedResourcesByEnv(ctx, "dev")
123+
if err != nil {
124+
t.Fatal(err)
125+
}
126+
if len(list) != 1 || list[0].Kind != spec.KindProject {
127+
t.Fatalf("want project only, got %+v", list)
128+
}
129+
}

internal/apply/apply.go

Lines changed: 0 additions & 26 deletions
This file was deleted.

internal/apply/doc.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,5 @@
11
// Package apply applies plans to control-plane and runtime state.
2+
//
3+
// [Applier.ApplyPlan] writes applied_resources and applied_projects using [plan.Plan] operations.
4+
// SQLite uses a single transaction via [state.TransactionalDeployment] (issue #15).
25
package apply

internal/apply/executor.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package apply
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"strings"
7+
"time"
8+
9+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/plan"
10+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/state"
11+
)
12+
13+
func executePlan(
14+
ctx context.Context,
15+
dep state.DeploymentStore,
16+
env string,
17+
p *plan.Plan,
18+
at time.Time,
19+
projectName, projectVersion string,
20+
) error {
21+
for _, op := range p.Operations {
22+
switch op.Action {
23+
case plan.ActionCreate, plan.ActionUpdate:
24+
if strings.TrimSpace(op.SpecHash) == "" || strings.TrimSpace(op.NormalizedSpecJSON) == "" {
25+
return fmt.Errorf("apply: %s operation for %s missing spec hash or normalized JSON", op.Action, op.Target.String())
26+
}
27+
if err := dep.UpsertAppliedResource(ctx, state.AppliedResource{
28+
Kind: op.Target.Kind,
29+
Name: op.Target.Name,
30+
Env: env,
31+
SpecHash: op.SpecHash,
32+
NormalizedSpecJSON: op.NormalizedSpecJSON,
33+
AppliedAt: at,
34+
}); err != nil {
35+
return err
36+
}
37+
case plan.ActionDelete:
38+
if err := dep.DeleteAppliedResource(ctx, env, op.Target); err != nil {
39+
return err
40+
}
41+
default:
42+
return fmt.Errorf("apply: unknown operation action %q", op.Action)
43+
}
44+
}
45+
return dep.UpsertAppliedProject(ctx, state.AppliedProject{
46+
ProjectName: projectName,
47+
Env: env,
48+
Version: projectVersion,
49+
AppliedAt: at,
50+
})
51+
}

internal/plan/plan.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,13 @@ type Plan struct {
2222
}
2323

2424
// Operation is one create, update, or delete against a resource identity.
25+
// SpecHash and NormalizedSpecJSON are set for create and update (apply material); they are empty for delete.
2526
type Operation struct {
26-
Action string
27-
Target spec.ResourceID
28-
Diff []FieldChange
27+
Action string
28+
Target spec.ResourceID
29+
Diff []FieldChange
30+
SpecHash string
31+
NormalizedSpecJSON string
2932
}
3033

3134
// FieldChange is one normalized field-level delta for updates (§10.2 plan output).

internal/plan/plan_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ func (s *stubDeploy) GetAppliedProject(context.Context, string, string) (*state.
3535
return nil, errors.New("stub")
3636
}
3737

38+
func (s *stubDeploy) DeleteAppliedResource(context.Context, string, spec.ResourceID) error {
39+
return errors.New("stub")
40+
}
41+
3842
func TestPlanner_listAppliedResources_usesDeploymentStoreOnly(t *testing.T) {
3943
st := &stubDeploy{list: []state.AppliedResource{{Name: "agent-a", Env: "dev", Kind: "Agent"}}}
4044
p := plan.NewPlanner(st)

internal/plan/planner.go

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,13 @@ func (p *Planner) ComputePlan(ctx context.Context, env string, g *spec.ProjectGr
5454
key := resourceMapKey(d.id.Kind, d.id.Name)
5555
prev, ok := appliedByID[key]
5656
if !ok {
57-
ops = append(ops, Operation{Action: ActionCreate, Target: d.id, Diff: nil})
57+
ops = append(ops, Operation{
58+
Action: ActionCreate,
59+
Target: d.id,
60+
Diff: nil,
61+
SpecHash: d.hash,
62+
NormalizedSpecJSON: d.json,
63+
})
5864
continue
5965
}
6066
if prev.SpecHash == d.hash {
@@ -68,7 +74,13 @@ func (p *Planner) ComputePlan(ctx context.Context, env string, g *spec.ProjectGr
6874
if err != nil {
6975
return nil, err
7076
}
71-
ops = append(ops, Operation{Action: ActionUpdate, Target: d.id, Diff: diff})
77+
ops = append(ops, Operation{
78+
Action: ActionUpdate,
79+
Target: d.id,
80+
Diff: diff,
81+
SpecHash: d.hash,
82+
NormalizedSpecJSON: d.json,
83+
})
7284
}
7385

7486
for _, r := range applied {

internal/plan/planner_test.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,10 @@ func (f *fakeDeploy) GetAppliedProject(context.Context, string, string) (*state.
3434
return nil, nil
3535
}
3636

37+
func (f *fakeDeploy) DeleteAppliedResource(context.Context, string, spec.ResourceID) error {
38+
return nil
39+
}
40+
3741
func minimalGraph() *spec.ProjectGraph {
3842
return &spec.ProjectGraph{
3943
Meta: spec.Metadata{Name: "acme"},

0 commit comments

Comments
 (0)