Skip to content

Commit 80ff2ea

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-build-deps Signed-off-by: andrew <andrew@anyscale.com>
1 parent 5a582a1 commit 80ff2ea

File tree

4 files changed

+629
-72
lines changed

4 files changed

+629
-72
lines changed

wanda/deps.go

Lines changed: 45 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -77,9 +77,14 @@ func (g *depGraph) loadSpec(specPath string, isRoot bool) error {
7777
if err != nil {
7878
return fmt.Errorf("parse %s: %w", specPath, err)
7979
}
80+
81+
if err := spec.ValidateParams(g.lookup); err != nil {
82+
return fmt.Errorf("%s: %w", specPath, err)
83+
}
84+
8085
spec = spec.expandVar(g.lookup)
8186

82-
if err := checkUnexpandedVars(spec, specPath); err != nil {
87+
if err := checkUnexpandedVars(spec, specPath, spec.Params); err != nil {
8388
return err
8489
}
8590

@@ -252,69 +257,38 @@ func (g *depGraph) validateDeps() error {
252257
}
253258

254259
// checkUnexpandedVars checks if a spec has any unexpanded environment variables
255-
// and returns a helpful error message if so.
256-
func checkUnexpandedVars(spec *Spec, specPath string) error {
257-
var missing []string
258-
259-
if vars := findUnexpandedVars(spec.Name); len(vars) > 0 {
260-
missing = append(missing, vars...)
261-
}
262-
for _, s := range spec.Froms {
263-
if vars := findUnexpandedVars(s); len(vars) > 0 {
264-
missing = append(missing, vars...)
265-
}
266-
}
267-
268-
if len(missing) == 0 {
260+
// and returns a helpful error message. If params are declared for a missing var,
261+
// the valid values are included in the error message.
262+
func checkUnexpandedVars(spec *Spec, specPath string, params map[string][]string) error {
263+
vars := spec.UnexpandedVars()
264+
if len(vars) == 0 {
269265
return nil
270266
}
271267

268+
// Deduplicate
272269
seen := make(map[string]bool)
273270
var unique []string
274-
for _, v := range missing {
271+
for _, v := range vars {
275272
if !seen[v] {
276273
seen[v] = true
277274
unique = append(unique, v)
278275
}
279276
}
280277

281-
if len(unique) == 1 {
282-
return fmt.Errorf("%s: environment variable %s is not set", specPath, unique[0])
278+
// Build error message with param hints where available
279+
var parts []string
280+
for _, v := range unique {
281+
if allowed, ok := params[v]; ok && len(allowed) > 0 {
282+
parts = append(parts, fmt.Sprintf("$%s (valid values: %s)", v, strings.Join(allowed, ", ")))
283+
} else {
284+
parts = append(parts, "$"+v)
285+
}
283286
}
284-
return fmt.Errorf("%s: environment variables not set: %s", specPath, strings.Join(unique, ", "))
285-
}
286287

287-
// findUnexpandedVars finds $VAR patterns in a string that were not expanded.
288-
func findUnexpandedVars(s string) []string {
289-
var vars []string
290-
for i := 0; i < len(s); i++ {
291-
if s[i] == '$' && i+1 < len(s) {
292-
// Skip $$
293-
if s[i+1] == '$' {
294-
i++
295-
continue
296-
}
297-
// Find the variable name
298-
j := i + 1
299-
for j < len(s) {
300-
c := s[j]
301-
if c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c == '_' {
302-
j++
303-
continue
304-
}
305-
if c >= '0' && c <= '9' && j > i+1 {
306-
j++
307-
continue
308-
}
309-
break
310-
}
311-
if j > i+1 {
312-
vars = append(vars, s[i:j])
313-
}
314-
i = j - 1
315-
}
288+
if len(parts) == 1 {
289+
return fmt.Errorf("%s: environment variable %s is not set", specPath, parts[0])
316290
}
317-
return vars
291+
return fmt.Errorf("%s: environment variables not set: %s", specPath, strings.Join(parts, "; "))
318292
}
319293

320294
// readWandaSpecs reads the wandaSpecsFile.
@@ -345,7 +319,8 @@ func readWandaSpecs(wandaSpecsFile string) ([]string, error) {
345319
type specIndex map[string]string
346320

347321
// discoverSpecs scans searchRoot for *.wanda.yaml files and builds a name index.
348-
// Names are expanded using the provided lookup function.
322+
// Names are expanded using declared params first, then the lookup function.
323+
// Specs with params will have all param combinations indexed.
349324
// Returns an error if two specs expand to the same name.
350325
func discoverSpecs(searchRoot string, lookup lookupFunc) (specIndex, error) {
351326
index := make(specIndex)
@@ -368,30 +343,28 @@ func discoverSpecs(searchRoot string, lookup lookupFunc) (specIndex, error) {
368343
return nil // skip unparseable files
369344
}
370345

371-
// Expand the name using env lookup and index it.
372-
expanded := spec.expandVar(lookup)
373-
name := expanded.Name
374-
375-
// Skip specs with unexpanded variables.
376-
if strings.Contains(name, "$") {
377-
return nil
378-
}
379-
380-
if existing, exists := index[name]; exists && existing != path {
381-
// Record conflict.
382-
m := conflicts[name]
383-
if m == nil {
384-
m = make(map[string]struct{}, 2)
385-
conflicts[name] = m
346+
// Index under all expanded names (using params, then env lookup).
347+
for _, name := range spec.ExpandedNames() {
348+
expanded, ok := tryFullyExpand(name, lookup)
349+
if !ok {
350+
continue
386351
}
387-
m[existing] = struct{}{}
388-
m[path] = struct{}{}
389-
if minConflictName == "" || name < minConflictName {
390-
minConflictName = name
352+
if existing, exists := index[expanded]; exists && existing != path {
353+
// Record conflict.
354+
m := conflicts[expanded]
355+
if m == nil {
356+
m = make(map[string]struct{}, 2)
357+
conflicts[expanded] = m
358+
}
359+
m[existing] = struct{}{}
360+
m[path] = struct{}{}
361+
if minConflictName == "" || expanded < minConflictName {
362+
minConflictName = expanded
363+
}
364+
continue
391365
}
392-
return nil
366+
index[expanded] = path
393367
}
394-
index[name] = path
395368
return nil
396369
})
397370
if err != nil {

wanda/deps_test.go

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -458,6 +458,70 @@ func TestDiscoverSpecs_WithVariables(t *testing.T) {
458458
}
459459
}
460460

461+
func TestDiscoverSpecs_WithParams(t *testing.T) {
462+
tmpDir := t.TempDir()
463+
464+
// Spec with params - should be indexed under all expanded names
465+
writeSpec(t, tmpDir, "base.wanda.yaml", strings.Join([]string{
466+
"name: base$PY",
467+
"params:",
468+
" PY:",
469+
" - '3.10'",
470+
" - '3.11'",
471+
" - '3.12'",
472+
"dockerfile: Dockerfile",
473+
}, "\n"))
474+
475+
// No env vars needed - params provide the values
476+
index, err := discoverSpecs(tmpDir, noopLookup)
477+
if err != nil {
478+
t.Fatalf("discoverSpecs: %v", err)
479+
}
480+
481+
// All three expanded names should be indexed
482+
for _, name := range []string{"base3.10", "base3.11", "base3.12"} {
483+
if _, ok := index[name]; !ok {
484+
t.Errorf("index missing %q, got: %v", name, index)
485+
}
486+
}
487+
488+
// All should point to the same spec file
489+
path := index["base3.10"]
490+
if index["base3.11"] != path || index["base3.12"] != path {
491+
t.Errorf("all names should map to same path, got: %v", index)
492+
}
493+
}
494+
495+
func TestDiscoverSpecs_ParamsAndEnvFallback(t *testing.T) {
496+
tmpDir := t.TempDir()
497+
498+
// Spec with partial params - one var has params, one needs env
499+
writeSpec(t, tmpDir, "base.wanda.yaml", strings.Join([]string{
500+
"name: base$PY-$ARCH",
501+
"params:",
502+
" PY:",
503+
" - '3.10'",
504+
"dockerfile: Dockerfile",
505+
}, "\n"))
506+
507+
lookup := func(key string) (string, bool) {
508+
if key == "ARCH" {
509+
return "amd64", true
510+
}
511+
return "", false
512+
}
513+
514+
index, err := discoverSpecs(tmpDir, lookup)
515+
if err != nil {
516+
t.Fatalf("discoverSpecs: %v", err)
517+
}
518+
519+
// Should be indexed as base3.10-amd64
520+
if _, ok := index["base3.10-amd64"]; !ok {
521+
t.Errorf("index missing base3.10-amd64, got: %v", index)
522+
}
523+
}
524+
461525
func TestBuildDepGraph_Discovery(t *testing.T) {
462526
tmpDir := t.TempDir()
463527

@@ -585,3 +649,134 @@ func TestBuildDepGraph_TransitiveDeps(t *testing.T) {
585649
t.Error("expected c in graph (transitive dep)")
586650
}
587651
}
652+
653+
func TestBuildDepGraph_ParamsValidation(t *testing.T) {
654+
tmpDir := t.TempDir()
655+
656+
writeSpec(t, tmpDir, "spec.wanda.yaml", strings.Join([]string{
657+
"name: myimage$PY_VERSION",
658+
"params:",
659+
" PY_VERSION:",
660+
" - '3.10'",
661+
" - '3.11'",
662+
"dockerfile: Dockerfile",
663+
}, "\n"))
664+
665+
specsFile := filepath.Join(tmpDir, testWandaSpecsFile)
666+
667+
t.Run("valid param value", func(t *testing.T) {
668+
lookup := func(k string) (string, bool) {
669+
if k == "PY_VERSION" {
670+
return "3.10", true
671+
}
672+
return "", false
673+
}
674+
_, err := buildDepGraph(filepath.Join(tmpDir, "spec.wanda.yaml"), lookup, "", specsFile)
675+
if err != nil {
676+
t.Errorf("unexpected error with valid param: %v", err)
677+
}
678+
})
679+
680+
t.Run("invalid param value", func(t *testing.T) {
681+
lookup := func(k string) (string, bool) {
682+
if k == "PY_VERSION" {
683+
return "3.9", true
684+
}
685+
return "", false
686+
}
687+
_, err := buildDepGraph(filepath.Join(tmpDir, "spec.wanda.yaml"), lookup, "", specsFile)
688+
if err == nil {
689+
t.Error("expected error for invalid param value")
690+
}
691+
if !strings.Contains(err.Error(), "3.9") {
692+
t.Errorf("error should mention invalid value '3.9': %v", err)
693+
}
694+
})
695+
}
696+
697+
func TestBuildDepGraph_UnexpandedWithParamsHint(t *testing.T) {
698+
tmpDir := t.TempDir()
699+
700+
// Spec with params but env var not set
701+
writeSpec(t, tmpDir, "spec.wanda.yaml", strings.Join([]string{
702+
"name: myimage$PY_VERSION",
703+
"params:",
704+
" PY_VERSION:",
705+
" - '3.10'",
706+
" - '3.11'",
707+
"dockerfile: Dockerfile",
708+
}, "\n"))
709+
710+
specsFile := filepath.Join(tmpDir, testWandaSpecsFile)
711+
// No env var set - should get helpful error with valid values
712+
_, err := buildDepGraph(filepath.Join(tmpDir, "spec.wanda.yaml"), noopLookup, "", specsFile)
713+
if err == nil {
714+
t.Fatal("expected error for unexpanded env var")
715+
}
716+
717+
// Error should mention valid values from params
718+
errStr := err.Error()
719+
if !strings.Contains(errStr, "PY_VERSION") {
720+
t.Errorf("error should mention PY_VERSION: %v", err)
721+
}
722+
if !strings.Contains(errStr, "valid values") {
723+
t.Errorf("error should mention valid values: %v", err)
724+
}
725+
if !strings.Contains(errStr, "3.10") || !strings.Contains(errStr, "3.11") {
726+
t.Errorf("error should list valid values 3.10, 3.11: %v", err)
727+
}
728+
}
729+
730+
func TestBuildDepGraph_DiscoveryWithParams(t *testing.T) {
731+
tmpDir := t.TempDir()
732+
733+
specsFile := writeWandaSpecs(t, tmpDir, []string{"."})
734+
735+
// Base spec with params - discoverable via params, loadable with env var
736+
baseDir := filepath.Join(tmpDir, "base")
737+
if err := os.MkdirAll(baseDir, 0755); err != nil {
738+
t.Fatal(err)
739+
}
740+
writeSpec(t, baseDir, "base.wanda.yaml", strings.Join([]string{
741+
"name: base$PY",
742+
"params:",
743+
" PY:",
744+
" - '3.10'",
745+
" - '3.11'",
746+
"dockerfile: Dockerfile",
747+
}, "\n"))
748+
749+
// App spec depends on base3.10
750+
appDir := filepath.Join(tmpDir, "app")
751+
if err := os.MkdirAll(appDir, 0755); err != nil {
752+
t.Fatal(err)
753+
}
754+
writeSpec(t, appDir, "app.wanda.yaml", strings.Join([]string{
755+
"name: app",
756+
`froms: ["cr.ray.io/rayproject/base3.10"]`,
757+
"dockerfile: Dockerfile",
758+
}, "\n"))
759+
760+
// Discovery finds base3.10 via params (no env var needed for discovery).
761+
// Loading the spec requires env var to be set for expansion.
762+
lookup := func(key string) (string, bool) {
763+
if key == "PY" {
764+
return "3.10", true
765+
}
766+
return "", false
767+
}
768+
769+
graph, err := buildDepGraph(filepath.Join(appDir, "app.wanda.yaml"), lookup, testPrefix, specsFile)
770+
if err != nil {
771+
t.Fatalf("buildDepGraph: %v", err)
772+
}
773+
774+
// base3.10 was discovered via params and loaded with PY=3.10
775+
if graph.Specs["base3.10"] == nil {
776+
t.Error("expected base3.10 in graph")
777+
}
778+
779+
if len(graph.Order) != 2 {
780+
t.Errorf("Order has %d items, want 2", len(graph.Order))
781+
}
782+
}

0 commit comments

Comments
 (0)