Skip to content

Commit f831135

Browse files
feat(wanda): Add params field for env var validation and discovery
Add `params` field to wanda spec for declaring allowed values of environment variables used in templated fields (name, froms). This enables: - Strict validation: reject builds where env var values don't match declared params - Dependency discovery: specs with params are indexed under all their expanded names (e.g., base$PY with params [3.10, 3.11] is indexed as both base3.10 and base3.11), enabling dependency resolution without requiring all env vars to be set Validation runs before variable expansion in buildDepGraph, so errors reference the original $VARNAME. Topic: wanda-params Relative: wanda-deps Signed-off-by: andrew <[email protected]>
1 parent 388c9c1 commit f831135

4 files changed

Lines changed: 553 additions & 5 deletions

File tree

wanda/deps.go

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,11 @@ func (g *depGraph) loadSpec(specPath string, isRoot bool) error {
6969
if err != nil {
7070
return fmt.Errorf("parse %s: %w", specPath, err)
7171
}
72+
73+
if err := spec.ValidateParams(g.lookup); err != nil {
74+
return fmt.Errorf("%s: %w", specPath, err)
75+
}
76+
7277
spec = spec.expandVar(g.lookup)
7378

7479
if err := checkUnexpandedVars(spec, specPath); err != nil {
@@ -331,7 +336,8 @@ type discovered struct {
331336
}
332337

333338
// discoverSpecs scans searchRoot for *.wanda.yaml files and builds a name index.
334-
// Names are expanded using the provided lookup function.
339+
// Names are expanded using declared params first, then the lookup function.
340+
// Specs with params will have all param combinations indexed.
335341
// Returns an error if two specs expand to the same name.
336342
func discoverSpecs(searchRoot string, lookup lookupFunc) (specIndex, error) {
337343
index := make(specIndex)
@@ -356,13 +362,25 @@ func discoverSpecs(searchRoot string, lookup lookupFunc) (specIndex, error) {
356362
outCh <- discovered{skipped: true}
357363
continue
358364
}
359-
spec = spec.expandVar(lookup)
360365

361-
if vars := findUnexpandedVars(spec.Name); len(vars) > 0 {
366+
// Use params to enumerate all possible names.
367+
// For vars without params, $VARNAME is preserved.
368+
names := spec.ExpandedNames()
369+
sentAny := false
370+
for _, name := range names {
371+
// If still has unexpanded vars, try env lookup
372+
if vars := findUnexpandedVars(name); len(vars) > 0 {
373+
name = expandVar(name, lookup)
374+
if vars := findUnexpandedVars(name); len(vars) > 0 {
375+
continue // Can't fully expand, skip this name
376+
}
377+
}
378+
outCh <- discovered{name: name, path: path}
379+
sentAny = true
380+
}
381+
if !sentAny {
362382
outCh <- discovered{skipped: true}
363-
continue
364383
}
365-
outCh <- discovered{name: spec.Name, path: path}
366384
}
367385
}()
368386
}

wanda/deps_test.go

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -515,6 +515,70 @@ func TestDiscoverSpecs_WithVariables(t *testing.T) {
515515
}
516516
}
517517

518+
func TestDiscoverSpecs_WithParams(t *testing.T) {
519+
tmpDir := t.TempDir()
520+
521+
// Spec with params - should be indexed under all expanded names
522+
writeSpec(t, tmpDir, "base.wanda.yaml", strings.Join([]string{
523+
"name: base$PY",
524+
"params:",
525+
" PY:",
526+
" - '3.10'",
527+
" - '3.11'",
528+
" - '3.12'",
529+
"dockerfile: Dockerfile",
530+
}, "\n"))
531+
532+
// No env vars needed - params provide the values
533+
index, err := discoverSpecs(tmpDir, noopLookup)
534+
if err != nil {
535+
t.Fatalf("discoverSpecs: %v", err)
536+
}
537+
538+
// All three expanded names should be indexed
539+
for _, name := range []string{"base3.10", "base3.11", "base3.12"} {
540+
if _, ok := index[name]; !ok {
541+
t.Errorf("index missing %q, got: %v", name, index)
542+
}
543+
}
544+
545+
// All should point to the same spec file
546+
path := index["base3.10"]
547+
if index["base3.11"] != path || index["base3.12"] != path {
548+
t.Errorf("all names should map to same path, got: %v", index)
549+
}
550+
}
551+
552+
func TestDiscoverSpecs_ParamsAndEnvFallback(t *testing.T) {
553+
tmpDir := t.TempDir()
554+
555+
// Spec with partial params - one var has params, one needs env
556+
writeSpec(t, tmpDir, "base.wanda.yaml", strings.Join([]string{
557+
"name: base$PY-$ARCH",
558+
"params:",
559+
" PY:",
560+
" - '3.10'",
561+
"dockerfile: Dockerfile",
562+
}, "\n"))
563+
564+
lookup := func(key string) (string, bool) {
565+
if key == "ARCH" {
566+
return "amd64", true
567+
}
568+
return "", false
569+
}
570+
571+
index, err := discoverSpecs(tmpDir, lookup)
572+
if err != nil {
573+
t.Fatalf("discoverSpecs: %v", err)
574+
}
575+
576+
// Should be indexed as base3.10-amd64
577+
if _, ok := index["base3.10-amd64"]; !ok {
578+
t.Errorf("index missing base3.10-amd64, got: %v", index)
579+
}
580+
}
581+
518582
func TestBuildDepGraph_Discovery(t *testing.T) {
519583
tmpDir := t.TempDir()
520584

@@ -643,3 +707,101 @@ func TestBuildDepGraph_TransitiveDeps(t *testing.T) {
643707
t.Error("expected c in graph (transitive dep)")
644708
}
645709
}
710+
711+
func TestBuildDepGraph_ParamsValidation(t *testing.T) {
712+
tmpDir := t.TempDir()
713+
714+
writeSpec(t, tmpDir, "spec.wanda.yaml", strings.Join([]string{
715+
"name: myimage$PY_VERSION",
716+
"params:",
717+
" PY_VERSION:",
718+
" - '3.10'",
719+
" - '3.11'",
720+
"dockerfile: Dockerfile",
721+
}, "\n"))
722+
723+
t.Run("valid param value", func(t *testing.T) {
724+
lookup := func(k string) (string, bool) {
725+
if k == "PY_VERSION" {
726+
return "3.10", true
727+
}
728+
return "", false
729+
}
730+
_, err := buildDepGraph(filepath.Join(tmpDir, "spec.wanda.yaml"), lookup, "")
731+
if err != nil {
732+
t.Errorf("unexpected error with valid param: %v", err)
733+
}
734+
})
735+
736+
t.Run("invalid param value", func(t *testing.T) {
737+
lookup := func(k string) (string, bool) {
738+
if k == "PY_VERSION" {
739+
return "3.9", true
740+
}
741+
return "", false
742+
}
743+
_, err := buildDepGraph(filepath.Join(tmpDir, "spec.wanda.yaml"), lookup, "")
744+
if err == nil {
745+
t.Error("expected error for invalid param value")
746+
}
747+
if !strings.Contains(err.Error(), "3.9") {
748+
t.Errorf("error should mention invalid value '3.9': %v", err)
749+
}
750+
})
751+
}
752+
753+
func TestBuildDepGraph_DiscoveryWithParams(t *testing.T) {
754+
tmpDir := t.TempDir()
755+
756+
if err := os.Mkdir(filepath.Join(tmpDir, ".git"), 0755); err != nil {
757+
t.Fatal(err)
758+
}
759+
760+
// Base spec with params - discoverable via params, loadable with env var
761+
baseDir := filepath.Join(tmpDir, "base")
762+
if err := os.MkdirAll(baseDir, 0755); err != nil {
763+
t.Fatal(err)
764+
}
765+
writeSpec(t, baseDir, "base.wanda.yaml", strings.Join([]string{
766+
"name: base$PY",
767+
"params:",
768+
" PY:",
769+
" - '3.10'",
770+
" - '3.11'",
771+
"dockerfile: Dockerfile",
772+
}, "\n"))
773+
774+
// App spec depends on base3.10
775+
appDir := filepath.Join(tmpDir, "app")
776+
if err := os.MkdirAll(appDir, 0755); err != nil {
777+
t.Fatal(err)
778+
}
779+
writeSpec(t, appDir, "app.wanda.yaml", strings.Join([]string{
780+
"name: app",
781+
`froms: ["cr.ray.io/rayproject/base3.10"]`,
782+
"dockerfile: Dockerfile",
783+
}, "\n"))
784+
785+
// Discovery finds base3.10 via params (no env var needed for discovery).
786+
// Loading the spec requires env var to be set for expansion.
787+
lookup := func(key string) (string, bool) {
788+
if key == "PY" {
789+
return "3.10", true
790+
}
791+
return "", false
792+
}
793+
794+
graph, err := buildDepGraph(filepath.Join(appDir, "app.wanda.yaml"), lookup, testPrefix)
795+
if err != nil {
796+
t.Fatalf("buildDepGraph: %v", err)
797+
}
798+
799+
// base3.10 was discovered via params and loaded with PY=3.10
800+
if graph.Specs["base3.10"] == nil {
801+
t.Error("expected base3.10 in graph")
802+
}
803+
804+
if len(graph.Order) != 2 {
805+
t.Errorf("Order has %d items, want 2", len(graph.Order))
806+
}
807+
}

wanda/spec.go

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ import (
1212
type Spec struct {
1313
Name string `yaml:"name,omitempty"`
1414

15+
// Params declares allowed values for environment variables used in
16+
// templated fields, currently only `name` and `froms` are supported.
17+
// Keys are variable names (without $), values are lists of allowed values.
18+
Params map[string][]string `yaml:"params,omitempty"`
19+
1520
Tags []string `yaml:"tags"`
1621

1722
// Inputs
@@ -120,6 +125,7 @@ func (s *Spec) expandVar(lookup lookupFunc) *Spec {
120125
result := new(Spec)
121126

122127
result.Name = expandVar(s.Name, lookup)
128+
result.Params = s.Params // Params are expanded by expandVarWithParams
123129
result.Tags = stringsExpanVar(s.Tags, lookup)
124130
result.Froms = stringsExpanVar(s.Froms, lookup)
125131
result.Srcs = stringsExpanVar(s.Srcs, lookup)
@@ -129,3 +135,146 @@ func (s *Spec) expandVar(lookup lookupFunc) *Spec {
129135

130136
return result
131137
}
138+
139+
// extractVarNames extracts all variable names from a string.
140+
// Uses the same parsing rules as expandVar.
141+
func extractVarNames(s string) []string {
142+
var vars []string
143+
inName := false
144+
nameStart := 0
145+
146+
for i, r := range s {
147+
if !inName {
148+
if r == '$' {
149+
inName = true
150+
nameStart = i + 1
151+
}
152+
} else {
153+
if r == '$' {
154+
if nameStart == i {
155+
// $$ escape sequence
156+
inName = false
157+
continue
158+
}
159+
}
160+
if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r == '_' {
161+
continue
162+
}
163+
if r >= '0' && r <= '9' && i > nameStart {
164+
continue
165+
}
166+
167+
if i > nameStart {
168+
vars = append(vars, s[nameStart:i])
169+
}
170+
if r == '$' {
171+
nameStart = i + 1
172+
} else {
173+
inName = false
174+
}
175+
}
176+
}
177+
178+
if inName && len(s) > nameStart {
179+
vars = append(vars, s[nameStart:])
180+
}
181+
182+
return vars
183+
}
184+
185+
// expandVarWithParams returns all possible expanded values for a string
186+
// using declared params. Generates the cartesian product when multiple
187+
// variables are present. Variables without params are preserved as $VARNAME.
188+
func expandVarWithParams(s string, params map[string][]string) []string {
189+
vars := extractVarNames(s)
190+
if len(vars) == 0 {
191+
return []string{s}
192+
}
193+
194+
// Find which vars have params
195+
var paramVars []string
196+
for _, v := range vars {
197+
if _, ok := params[v]; ok {
198+
paramVars = append(paramVars, v)
199+
}
200+
}
201+
202+
if len(paramVars) == 0 {
203+
return []string{s}
204+
}
205+
206+
// Generate all combinations
207+
combinations := []map[string]string{{}}
208+
for _, v := range paramVars {
209+
var newCombinations []map[string]string
210+
for _, combo := range combinations {
211+
for _, val := range params[v] {
212+
newCombo := make(map[string]string, len(combo)+1)
213+
for k, v := range combo {
214+
newCombo[k] = v
215+
}
216+
newCombo[v] = val
217+
newCombinations = append(newCombinations, newCombo)
218+
}
219+
}
220+
combinations = newCombinations
221+
}
222+
223+
// Expand each combination
224+
var results []string
225+
for _, combo := range combinations {
226+
expanded := expandVar(s, func(k string) (string, bool) {
227+
v, ok := combo[k]
228+
return v, ok
229+
})
230+
results = append(results, expanded)
231+
}
232+
233+
return results
234+
}
235+
236+
// ExpandedNames returns all possible names based on declared params.
237+
func (s *Spec) ExpandedNames() []string {
238+
return expandVarWithParams(s.Name, s.Params)
239+
}
240+
241+
// ExpandedFroms returns all possible froms based on declared params.
242+
// Results are deduplicated.
243+
func (s *Spec) ExpandedFroms() []string {
244+
seen := make(map[string]bool)
245+
var results []string
246+
for _, from := range s.Froms {
247+
for _, expanded := range expandVarWithParams(from, s.Params) {
248+
if !seen[expanded] {
249+
seen[expanded] = true
250+
results = append(results, expanded)
251+
}
252+
}
253+
}
254+
return results
255+
}
256+
257+
// ValidateParams validates that environment variable values match declared params.
258+
func (s *Spec) ValidateParams(lookup lookupFunc) error {
259+
for varName, allowed := range s.Params {
260+
val, ok := lookup(varName)
261+
if !ok {
262+
continue // Unset vars are handled by expandVar
263+
}
264+
265+
valid := false
266+
for _, a := range allowed {
267+
if val == a {
268+
valid = true
269+
break
270+
}
271+
}
272+
if !valid {
273+
return fmt.Errorf(
274+
"env var %s has value %q, but params only allow: %v",
275+
varName, val, allowed,
276+
)
277+
}
278+
}
279+
return nil
280+
}

0 commit comments

Comments
 (0)