Skip to content

Commit 3037573

Browse files
gustavobertoiclaude
andcommitted
feat(resource): Stage B config resources: block + up-saga resources phase (spec 27)
- internal/config: additive ResourceDecl on Project, cross-ref validated (uses targets a declared shared instance, kind supported by the engine, no (engine,kind,name) collision); forward-tolerant to unknown keys. - internal/orchestrate: new 'resources' saga phase provisions every declared resource via the engine Provisioner under the flock, records ledger + event, and REPORTS drift without auto-dropping. Wired into BuildUp after the implicit postgres provision phase; gated on --no-provision. Provision phase untouched. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 2b02c0f commit 3037573

6 files changed

Lines changed: 661 additions & 1 deletion

File tree

‎internal/config/model.go‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,24 @@ type Project struct {
103103
Kind string `yaml:"kind" validate:"required,eq=Project"`
104104
Name string `yaml:"name" validate:"required,dsname"`
105105
Services map[string]Service `yaml:"services" validate:"required,dive"`
106-
Hooks Hooks `yaml:"hooks"` // spec 11 — project-scope lifecycle hooks
106+
Hooks Hooks `yaml:"hooks"` // spec 11 — project-scope lifecycle hooks
107+
Resources []ResourceDecl `yaml:"resources" validate:"dive"` // spec 27 — declarative data-plane resources
108+
}
109+
110+
// ResourceDecl is one declarative data-plane resource a project needs INSIDE a
111+
// shared engine (spec 27): a database, bucket, lifecycle policy, queue, … `up`
112+
// provisions each idempotently. The block is additive and forward-tolerant
113+
// (unknown keys are ignored, never fatal); the cross-ref resolver checks `uses`
114+
// targets a declared shared instance, `kind` is one the engine supports, and no
115+
// two resources collide on (engine, name). Removing an entry NEVER auto-drops the
116+
// resource (Q-RESOURCE-DRIFT) — teardown is always explicit + confirmed.
117+
type ResourceDecl struct {
118+
Uses string `yaml:"uses" validate:"required"` // workspace.shared.<name>
119+
Kind string `yaml:"kind" validate:"required"` // database|user|bucket|lifecycle|queue|stream|topic
120+
Name string `yaml:"name"` // engine-level identifier (default: project name)
121+
Engine string `yaml:"engine"` // optional; inferred from the uses target's template
122+
Params map[string]any `yaml:"params"` // kind-specific knobs
123+
Credentials string `yaml:"credentials" validate:"omitempty,oneof=predictable generated"`
107124
}
108125

109126
// Service is one container in a project stack.

‎internal/config/resources_test.go‎

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
package config
2+
3+
import (
4+
"strings"
5+
"testing"
6+
)
7+
8+
const resWorkspace = `apiVersion: devstack/v1
9+
kind: Workspace
10+
name: acme
11+
shared:
12+
postgres: { template: postgres }
13+
minio: { template: minio }
14+
projects:
15+
- { name: api, path: api }
16+
`
17+
18+
func projectWithResources(body string) map[string]string {
19+
return map[string]string{
20+
"workspace.yaml": resWorkspace,
21+
"api/devstack.yaml": "apiVersion: devstack/v1\nkind: Project\nname: api\nservices:\n api: { template: t }\n" + body,
22+
}
23+
}
24+
25+
func TestResourcesValid(t *testing.T) {
26+
root := writeTree(t, projectWithResources(`resources:
27+
- { uses: workspace.shared.postgres, kind: database }
28+
- { uses: workspace.shared.minio, kind: bucket, name: api-uploads, credentials: generated }
29+
- { uses: workspace.shared.minio, kind: lifecycle, name: api-uploads, params: { expireDays: 7 } }
30+
`))
31+
m, err := LoadAt(root)
32+
if err != nil {
33+
t.Fatalf("LoadAt: %v", err)
34+
}
35+
rs := m.Projects["api"].Resources
36+
if len(rs) != 3 {
37+
t.Fatalf("resources = %d, want 3", len(rs))
38+
}
39+
if rs[0].Kind != "database" || rs[0].Uses != "workspace.shared.postgres" {
40+
t.Errorf("resource[0] = %+v", rs[0])
41+
}
42+
if rs[1].Credentials != "generated" || rs[1].Name != "api-uploads" {
43+
t.Errorf("resource[1] = %+v", rs[1])
44+
}
45+
}
46+
47+
func TestResourcesUnknownShared(t *testing.T) {
48+
root := writeTree(t, projectWithResources(`resources:
49+
- { uses: workspace.shared.ghost, kind: database }
50+
`))
51+
_, err := LoadAt(root)
52+
if err == nil || !strings.Contains(err.Error(), "ghost") {
53+
t.Fatalf("want an unknown-shared error naming ghost, got %v", err)
54+
}
55+
}
56+
57+
func TestResourcesBadUsesForm(t *testing.T) {
58+
root := writeTree(t, projectWithResources(`resources:
59+
- { uses: postgres, kind: database }
60+
`))
61+
_, err := LoadAt(root)
62+
if err == nil || !strings.Contains(err.Error(), "workspace.shared.<name>") {
63+
t.Fatalf("want a uses-form error, got %v", err)
64+
}
65+
}
66+
67+
func TestResourcesKindNotSupportedByEngine(t *testing.T) {
68+
// A bucket on postgres is rejected (postgres provisioner lists no bucket kind).
69+
root := writeTree(t, projectWithResources(`resources:
70+
- { uses: workspace.shared.postgres, kind: bucket }
71+
`))
72+
_, err := LoadAt(root)
73+
if err == nil || !strings.Contains(err.Error(), "not supported by engine") {
74+
t.Fatalf("want a kind/engine error, got %v", err)
75+
}
76+
}
77+
78+
func TestResourcesDuplicateCollision(t *testing.T) {
79+
// Two databases defaulting to the project name collide on (engine, name).
80+
root := writeTree(t, projectWithResources(`resources:
81+
- { uses: workspace.shared.postgres, kind: database }
82+
- { uses: workspace.shared.postgres, kind: database }
83+
`))
84+
_, err := LoadAt(root)
85+
if err == nil || !strings.Contains(err.Error(), "duplicate database resource") {
86+
t.Fatalf("want a duplicate-resource error, got %v", err)
87+
}
88+
}
89+
90+
func TestResourcesBadCredentials(t *testing.T) {
91+
root := writeTree(t, projectWithResources(`resources:
92+
- { uses: workspace.shared.postgres, kind: database, credentials: bogus }
93+
`))
94+
_, err := LoadAt(root)
95+
if err == nil || !strings.Contains(err.Error(), "predictable, generated") {
96+
t.Fatalf("want a credentials oneof error, got %v", err)
97+
}
98+
}
99+
100+
func TestResourcesForwardTolerantUnknownKeys(t *testing.T) {
101+
// Unknown keys inside a resource entry are ignored, never fatal (additive block).
102+
root := writeTree(t, projectWithResources(`resources:
103+
- { uses: workspace.shared.postgres, kind: database, futureField: whatever }
104+
`))
105+
if _, err := LoadAt(root); err != nil {
106+
t.Fatalf("unknown resource keys must be tolerated, got %v", err)
107+
}
108+
}
109+
110+
func TestResourcesExplicitEngineOverride(t *testing.T) {
111+
// An explicit engine that supports the kind is accepted even if it differs from
112+
// the inferred template (forward-tolerance for custom shared templates).
113+
root := writeTree(t, projectWithResources(`resources:
114+
- { uses: workspace.shared.postgres, kind: bucket, engine: minio, name: api-uploads }
115+
`))
116+
if _, err := LoadAt(root); err != nil {
117+
t.Fatalf("explicit valid engine override should pass, got %v", err)
118+
}
119+
}

‎internal/config/validate.go‎

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

1111
"github.com/go-playground/validator/v10"
12+
13+
"github.com/open-source-cloud/devstack/internal/resource"
1214
)
1315

1416
// dsNameRE is the safe-identifier pattern for workspace/project/service/alias
@@ -64,9 +66,61 @@ func validateModel(m *Model, ws *source, projSrc map[string]*source) error {
6466
if err := validateProfiles(m, ws); err != nil {
6567
return err
6668
}
69+
if err := validateResources(m, projSrc); err != nil {
70+
return err
71+
}
6772
return detectCycles(m)
6873
}
6974

75+
// validateResources checks the spec-27 declarative `resources:` block on each
76+
// project: every `uses` targets a declared shared instance, `kind` is one the
77+
// target engine's provisioner supports (for known engines — unknown/custom
78+
// engines are forward-tolerant), and no two resources collide on (engine, name)
79+
// within a project (tenant-scoping: names are per-project). Positioned to the
80+
// project file.
81+
func validateResources(m *Model, projSrc map[string]*source) error {
82+
for _, pname := range sortedKeys(m.Projects) {
83+
p := m.Projects[pname]
84+
src := projSrc[pname]
85+
seen := map[string]bool{}
86+
for i, d := range p.Resources {
87+
r := parseRef(d.Uses)
88+
if r.kind != refShared || r.attr != "" {
89+
return src.errAt(fmt.Sprintf("$.resources[%d].uses", i),
90+
"resources: uses %q must be a shared service reference of the form workspace.shared.<name>", d.Uses)
91+
}
92+
svc, ok := m.Workspace.Shared[r.name]
93+
if !ok {
94+
return src.errAt(fmt.Sprintf("$.resources[%d].uses", i),
95+
"resources: shared service %q does not exist%s", r.name, suggest(r.name, m.SharedNames()))
96+
}
97+
engine := d.Engine
98+
if engine == "" {
99+
engine = svc.Template // inferred: the shared template name == its engine capability
100+
}
101+
if !resource.SupportsKind(engine, d.Kind) {
102+
return src.errAt(fmt.Sprintf("$.resources[%d].kind", i),
103+
"resources: kind %q is not supported by engine %q (supported: %s)",
104+
d.Kind, engine, strings.Join(resource.Kinds(engine), ", "))
105+
}
106+
name := d.Name
107+
if name == "" {
108+
name = pname // default: the project name
109+
}
110+
// Collide on (engine, kind, name): a bucket and its lifecycle may share a
111+
// name (they reference the same object), but two databases of the same
112+
// name may not.
113+
key := engine + "\x00" + d.Kind + "\x00" + name
114+
if seen[key] {
115+
return src.errAt(fmt.Sprintf("$.resources[%d].name", i),
116+
"resources: duplicate %s resource %q on engine %q (a project may declare it once)", d.Kind, name, engine)
117+
}
118+
seen[key] = true
119+
}
120+
}
121+
return nil
122+
}
123+
70124
// validateProfiles checks the spec-12 service-slice config: every group's
71125
// services reference a real service, and defaultProfile (if set) names a defined
72126
// group (or the reserved "all"). Positioned to the workspace file.

0 commit comments

Comments
 (0)