Skip to content

Commit 0d91698

Browse files
gustavobertoiclaude
andcommitted
feat(config): implement the M1 config loader (spec 01)
internal/config: the two-file model (workspace.yaml + per-repo devstack.yaml), walk-up workspace discovery, goccy/go-yaml positioned parse, the typed ${...} interpolation grammar (env/self/ref/profile/workspace.name + $$), and two-layer validation — validator/v10 structural rules plus a custom resolver that checks uses/import cross-references against the WORKSPACE graph and detects reference cycles, all reported as file:line:col. Adds goccy/go-yaml + go-playground/validator/v10. Unit-tested (valid fixture + dangling-ref, cycle, discovery, name/apiVersion, interpolation cases); make ci green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 7c5b1d4 commit 0d91698

13 files changed

Lines changed: 1101 additions & 10 deletions

File tree

‎go.mod‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ go 1.25.0
77
require (
88
charm.land/fang/v2 v2.0.1
99
github.com/adrg/xdg v0.5.3
10+
github.com/go-playground/validator/v10 v10.30.3
11+
github.com/goccy/go-yaml v1.19.2
1012
github.com/gofrs/flock v0.13.0
1113
github.com/moby/moby/client v0.4.1
1214
github.com/spf13/cobra v1.10.2
@@ -32,10 +34,14 @@ require (
3234
github.com/docker/go-units v0.5.0 // indirect
3335
github.com/dustin/go-humanize v1.0.1 // indirect
3436
github.com/felixge/httpsnoop v1.0.4 // indirect
37+
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
3538
github.com/go-logr/logr v1.4.2 // indirect
3639
github.com/go-logr/stdr v1.2.2 // indirect
40+
github.com/go-playground/locales v0.14.1 // indirect
41+
github.com/go-playground/universal-translator v0.18.1 // indirect
3742
github.com/google/uuid v1.6.0 // indirect
3843
github.com/inconshreveable/mousetrap v1.1.0 // indirect
44+
github.com/leodido/go-urn v1.4.0 // indirect
3945
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
4046
github.com/mattn/go-isatty v0.0.20 // indirect
4147
github.com/mattn/go-runewidth v0.0.20 // indirect
@@ -58,9 +64,10 @@ require (
5864
go.opentelemetry.io/otel v1.35.0 // indirect
5965
go.opentelemetry.io/otel/metric v1.35.0 // indirect
6066
go.opentelemetry.io/otel/trace v1.35.0 // indirect
67+
golang.org/x/crypto v0.52.0 // indirect
6168
golang.org/x/sync v0.20.0 // indirect
62-
golang.org/x/sys v0.42.0 // indirect
63-
golang.org/x/text v0.24.0 // indirect
69+
golang.org/x/sys v0.45.0 // indirect
70+
golang.org/x/text v0.37.0 // indirect
6471
modernc.org/libc v1.72.3 // indirect
6572
modernc.org/mathutil v1.7.1 // indirect
6673
modernc.org/memory v1.11.0 // indirect

‎go.sum‎

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -45,11 +45,23 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
4545
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
4646
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
4747
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
48+
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
49+
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
4850
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
4951
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
5052
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
5153
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
5254
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
55+
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
56+
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
57+
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
58+
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
59+
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
60+
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
61+
github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8=
62+
github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc=
63+
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
64+
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
5365
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
5466
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
5567
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
@@ -62,6 +74,8 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs
6274
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
6375
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
6476
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
77+
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
78+
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
6579
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
6680
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
6781
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@@ -120,19 +134,21 @@ go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstF
120134
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
121135
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
122136
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
137+
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
138+
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
123139
golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI=
124140
golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo=
125-
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
126-
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
141+
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
142+
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
127143
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
128144
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
129145
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
130-
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
131-
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
132-
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
133-
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
134-
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
135-
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
146+
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
147+
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
148+
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
149+
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
150+
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
151+
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
136152
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
137153
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
138154
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

‎internal/config/config_test.go‎

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,248 @@
1+
package config
2+
3+
import (
4+
"errors"
5+
"os"
6+
"path/filepath"
7+
"strings"
8+
"testing"
9+
)
10+
11+
func writeTree(t *testing.T, files map[string]string) string {
12+
t.Helper()
13+
root := t.TempDir()
14+
for rel, content := range files {
15+
p := filepath.Join(root, rel)
16+
if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil {
17+
t.Fatal(err)
18+
}
19+
if err := os.WriteFile(p, []byte(content), 0o644); err != nil {
20+
t.Fatal(err)
21+
}
22+
}
23+
return root
24+
}
25+
26+
func TestLoadValid(t *testing.T) {
27+
m, err := LoadAt(filepath.Join("testdata", "valid"))
28+
if err != nil {
29+
t.Fatalf("LoadAt: %v", err)
30+
}
31+
if m.Workspace.Name != "acme" {
32+
t.Errorf("workspace name = %q, want acme", m.Workspace.Name)
33+
}
34+
if got := len(m.Projects); got != 2 {
35+
t.Errorf("projects = %d, want 2", got)
36+
}
37+
if got := len(m.Workspace.Shared); got != 3 {
38+
t.Errorf("shared = %d, want 3", got)
39+
}
40+
api, ok := m.Projects["api"]
41+
if !ok {
42+
t.Fatal("project api missing")
43+
}
44+
if got := len(api.Services["api"].Uses); got != 2 {
45+
t.Errorf("api.uses = %d, want 2", got)
46+
}
47+
if dir := m.ProjectDir("api"); !strings.HasSuffix(dir, filepath.Join("services", "api")) {
48+
t.Errorf("ProjectDir(api) = %q, want suffix services/api", dir)
49+
}
50+
}
51+
52+
const sharedPGOnly = `apiVersion: devstack/v1
53+
kind: Workspace
54+
name: acme
55+
shared:
56+
postgres: { template: postgres }
57+
projects:
58+
- { name: api, path: api }
59+
`
60+
61+
func TestDanglingUses(t *testing.T) {
62+
root := writeTree(t, map[string]string{
63+
"workspace.yaml": sharedPGOnly,
64+
"api/devstack.yaml": `apiVersion: devstack/v1
65+
kind: Project
66+
name: api
67+
services:
68+
api:
69+
template: t
70+
uses:
71+
- workspace.shared.kafka
72+
`,
73+
})
74+
_, err := LoadAt(root)
75+
if err == nil {
76+
t.Fatal("expected error for dangling uses")
77+
}
78+
var pe *PosError
79+
if !errors.As(err, &pe) {
80+
t.Fatalf("want *PosError, got %T: %v", err, err)
81+
}
82+
if pe.Line == 0 {
83+
t.Errorf("want a positioned error (line>0), got %v", pe)
84+
}
85+
if !strings.Contains(pe.Msg, "kafka") {
86+
t.Errorf("error %q should name the missing service kafka", pe.Msg)
87+
}
88+
}
89+
90+
func TestReferenceCycle(t *testing.T) {
91+
root := writeTree(t, map[string]string{
92+
"workspace.yaml": `apiVersion: devstack/v1
93+
kind: Workspace
94+
name: acme
95+
projects:
96+
- { name: a, path: a }
97+
- { name: b, path: b }
98+
`,
99+
"a/devstack.yaml": `apiVersion: devstack/v1
100+
kind: Project
101+
name: a
102+
services:
103+
svc:
104+
template: t
105+
env:
106+
import:
107+
- { from: workspace.b.svc }
108+
`,
109+
"b/devstack.yaml": `apiVersion: devstack/v1
110+
kind: Project
111+
name: b
112+
services:
113+
svc:
114+
template: t
115+
env:
116+
import:
117+
- { from: workspace.a.svc }
118+
`,
119+
})
120+
_, err := LoadAt(root)
121+
if err == nil {
122+
t.Fatal("expected a cycle error")
123+
}
124+
if !strings.Contains(err.Error(), "cycle") {
125+
t.Errorf("error %q should report a cycle", err.Error())
126+
}
127+
}
128+
129+
func TestBadName(t *testing.T) {
130+
root := writeTree(t, map[string]string{
131+
"workspace.yaml": `apiVersion: devstack/v1
132+
kind: Workspace
133+
name: Acme_Bad!
134+
projects: []
135+
`,
136+
})
137+
_, err := LoadAt(root)
138+
if err == nil || !strings.Contains(err.Error(), "name") {
139+
t.Fatalf("want a name validation error, got %v", err)
140+
}
141+
}
142+
143+
func TestWrongAPIVersion(t *testing.T) {
144+
root := writeTree(t, map[string]string{
145+
"workspace.yaml": `apiVersion: devstack/v2
146+
kind: Workspace
147+
name: acme
148+
projects: []
149+
`,
150+
})
151+
if _, err := LoadAt(root); err == nil {
152+
t.Fatal("want an apiVersion mismatch error")
153+
}
154+
}
155+
156+
func TestDiscoverWalksUp(t *testing.T) {
157+
t.Setenv("DEVSTACK_WORKSPACE", "")
158+
root := writeTree(t, map[string]string{
159+
"workspace.yaml": "apiVersion: devstack/v1\nkind: Workspace\nname: acme\n",
160+
"a/b/c/.keep": "",
161+
})
162+
got, err := Discover(filepath.Join(root, "a", "b", "c"))
163+
if err != nil {
164+
t.Fatalf("Discover: %v", err)
165+
}
166+
if eval(t, got) != eval(t, root) {
167+
t.Errorf("Discover = %q, want %q", got, root)
168+
}
169+
}
170+
171+
func TestDiscoverNotFound(t *testing.T) {
172+
t.Setenv("DEVSTACK_WORKSPACE", "")
173+
if _, err := Discover(t.TempDir()); err == nil {
174+
t.Fatal("want a not-found error when no workspace.yaml exists")
175+
}
176+
}
177+
178+
func eval(t *testing.T, p string) string {
179+
t.Helper()
180+
r, err := filepath.EvalSymlinks(p)
181+
if err != nil {
182+
return p
183+
}
184+
return r
185+
}
186+
187+
// fakeResolver implements Resolver for interpolation tests.
188+
type fakeResolver struct{}
189+
190+
func (fakeResolver) Env(n string) (string, bool) {
191+
if n == "FOO" {
192+
return "E", true
193+
}
194+
return "", false
195+
}
196+
func (fakeResolver) Self(a string) (string, bool) {
197+
if a == "host" {
198+
return "h", true
199+
}
200+
return "", false
201+
}
202+
func (fakeResolver) Ref(path string) (string, error) { return "R:" + path, nil }
203+
func (fakeResolver) Profile() string { return "dev" }
204+
func (fakeResolver) WorkspaceName() string { return "acme" }
205+
206+
func TestInterpolate(t *testing.T) {
207+
r := fakeResolver{}
208+
ok := []struct{ in, want string }{
209+
{"${profile}", "dev"},
210+
{"${workspace.name}", "acme"},
211+
{"${env.FOO}", "E"},
212+
{"${self.host}", "h"},
213+
{"${ref:workspace.shared.postgres.host}", "R:workspace.shared.postgres.host"},
214+
{"a$$b", "a$b"},
215+
{"x${profile}y", "xdevy"},
216+
{"no interpolation", "no interpolation"},
217+
{"price is $5", "price is $5"},
218+
}
219+
for _, c := range ok {
220+
got, err := Interpolate(c.in, r)
221+
if err != nil {
222+
t.Errorf("Interpolate(%q) error: %v", c.in, err)
223+
continue
224+
}
225+
if got != c.want {
226+
t.Errorf("Interpolate(%q) = %q, want %q", c.in, got, c.want)
227+
}
228+
}
229+
bad := []string{"${env.MISSING}", "${self.nope}", "${bogus}", "${unterminated", "${}"}
230+
for _, in := range bad {
231+
if _, err := Interpolate(in, r); err == nil {
232+
t.Errorf("Interpolate(%q) want error, got nil", in)
233+
}
234+
}
235+
}
236+
237+
func TestInterpolationRefs(t *testing.T) {
238+
got := InterpolationRefs("a ${ref:workspace.shared.pg.host} b ${profile} ${ref:workspace.api.x.port}")
239+
want := []string{"workspace.shared.pg.host", "workspace.api.x.port"}
240+
if len(got) != len(want) {
241+
t.Fatalf("refs = %v, want %v", got, want)
242+
}
243+
for i := range want {
244+
if got[i] != want[i] {
245+
t.Errorf("ref[%d] = %q, want %q", i, got[i], want[i])
246+
}
247+
}
248+
}

0 commit comments

Comments
 (0)