Skip to content

Commit 32e8cb9

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 7c9b96c commit 32e8cb9

File tree

4 files changed

+630
-72
lines changed

4 files changed

+630
-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
// findRepoRoot walks up from startDir looking for a .git directory or file.
@@ -362,7 +336,8 @@ func readWandaSpecs(dir string) ([]string, error) {
362336
type specIndex map[string]string
363337

364338
// discoverSpecs scans searchRoot for *.wanda.yaml files and builds a name index.
365-
// 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.
366341
// Returns an error if two specs expand to the same name.
367342
func discoverSpecs(searchRoot string, lookup lookupFunc) (specIndex, error) {
368343
index := make(specIndex)
@@ -385,30 +360,28 @@ func discoverSpecs(searchRoot string, lookup lookupFunc) (specIndex, error) {
385360
return nil // skip unparseable files
386361
}
387362

388-
// Expand the name using env lookup and index it.
389-
expanded := spec.expandVar(lookup)
390-
name := expanded.Name
391-
392-
// Skip specs with unexpanded variables.
393-
if strings.Contains(name, "$") {
394-
return nil
395-
}
396-
397-
if existing, exists := index[name]; exists && existing != path {
398-
// Record conflict.
399-
m := conflicts[name]
400-
if m == nil {
401-
m = make(map[string]struct{}, 2)
402-
conflicts[name] = m
363+
// Index under all expanded names (using params, then env lookup).
364+
for _, name := range spec.ExpandedNames() {
365+
expanded, ok := tryFullyExpand(name, lookup)
366+
if !ok {
367+
continue
403368
}
404-
m[existing] = struct{}{}
405-
m[path] = struct{}{}
406-
if minConflictName == "" || name < minConflictName {
407-
minConflictName = name
369+
if existing, exists := index[expanded]; exists && existing != path {
370+
// Record conflict.
371+
m := conflicts[expanded]
372+
if m == nil {
373+
m = make(map[string]struct{}, 2)
374+
conflicts[expanded] = m
375+
}
376+
m[existing] = struct{}{}
377+
m[path] = struct{}{}
378+
if minConflictName == "" || expanded < minConflictName {
379+
minConflictName = expanded
380+
}
381+
continue
408382
}
409-
return nil
383+
index[expanded] = path
410384
}
411-
index[name] = path
412385
return nil
413386
})
414387
if err != nil {

wanda/deps_test.go

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -531,6 +531,70 @@ func TestDiscoverSpecs_WithVariables(t *testing.T) {
531531
}
532532
}
533533

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

@@ -672,3 +736,135 @@ func TestBuildDepGraph_TransitiveDeps(t *testing.T) {
672736
t.Error("expected c in graph (transitive dep)")
673737
}
674738
}
739+
740+
func TestBuildDepGraph_ParamsValidation(t *testing.T) {
741+
tmpDir := t.TempDir()
742+
743+
writeSpec(t, tmpDir, "spec.wanda.yaml", strings.Join([]string{
744+
"name: myimage$PY_VERSION",
745+
"params:",
746+
" PY_VERSION:",
747+
" - '3.10'",
748+
" - '3.11'",
749+
"dockerfile: Dockerfile",
750+
}, "\n"))
751+
752+
t.Run("valid param value", func(t *testing.T) {
753+
lookup := func(k string) (string, bool) {
754+
if k == "PY_VERSION" {
755+
return "3.10", true
756+
}
757+
return "", false
758+
}
759+
_, err := buildDepGraph(filepath.Join(tmpDir, "spec.wanda.yaml"), lookup, "")
760+
if err != nil {
761+
t.Errorf("unexpected error with valid param: %v", err)
762+
}
763+
})
764+
765+
t.Run("invalid param value", func(t *testing.T) {
766+
lookup := func(k string) (string, bool) {
767+
if k == "PY_VERSION" {
768+
return "3.9", true
769+
}
770+
return "", false
771+
}
772+
_, err := buildDepGraph(filepath.Join(tmpDir, "spec.wanda.yaml"), lookup, "")
773+
if err == nil {
774+
t.Error("expected error for invalid param value")
775+
}
776+
if !strings.Contains(err.Error(), "3.9") {
777+
t.Errorf("error should mention invalid value '3.9': %v", err)
778+
}
779+
})
780+
}
781+
782+
func TestBuildDepGraph_UnexpandedWithParamsHint(t *testing.T) {
783+
tmpDir := t.TempDir()
784+
785+
// Spec with params but env var not set
786+
writeSpec(t, tmpDir, "spec.wanda.yaml", strings.Join([]string{
787+
"name: myimage$PY_VERSION",
788+
"params:",
789+
" PY_VERSION:",
790+
" - '3.10'",
791+
" - '3.11'",
792+
"dockerfile: Dockerfile",
793+
}, "\n"))
794+
795+
// No env var set - should get helpful error with valid values
796+
_, err := buildDepGraph(filepath.Join(tmpDir, "spec.wanda.yaml"), noopLookup, "")
797+
if err == nil {
798+
t.Fatal("expected error for unexpanded env var")
799+
}
800+
801+
// Error should mention valid values from params
802+
errStr := err.Error()
803+
if !strings.Contains(errStr, "PY_VERSION") {
804+
t.Errorf("error should mention PY_VERSION: %v", err)
805+
}
806+
if !strings.Contains(errStr, "valid values") {
807+
t.Errorf("error should mention valid values: %v", err)
808+
}
809+
if !strings.Contains(errStr, "3.10") || !strings.Contains(errStr, "3.11") {
810+
t.Errorf("error should list valid values 3.10, 3.11: %v", err)
811+
}
812+
}
813+
814+
func TestBuildDepGraph_DiscoveryWithParams(t *testing.T) {
815+
tmpDir := t.TempDir()
816+
817+
if err := os.Mkdir(filepath.Join(tmpDir, ".git"), 0755); err != nil {
818+
t.Fatal(err)
819+
}
820+
821+
writeWandaSpecs(t, tmpDir, []string{"."})
822+
823+
// Base spec with params - discoverable via params, loadable with env var
824+
baseDir := filepath.Join(tmpDir, "base")
825+
if err := os.MkdirAll(baseDir, 0755); err != nil {
826+
t.Fatal(err)
827+
}
828+
writeSpec(t, baseDir, "base.wanda.yaml", strings.Join([]string{
829+
"name: base$PY",
830+
"params:",
831+
" PY:",
832+
" - '3.10'",
833+
" - '3.11'",
834+
"dockerfile: Dockerfile",
835+
}, "\n"))
836+
837+
// App spec depends on base3.10
838+
appDir := filepath.Join(tmpDir, "app")
839+
if err := os.MkdirAll(appDir, 0755); err != nil {
840+
t.Fatal(err)
841+
}
842+
writeSpec(t, appDir, "app.wanda.yaml", strings.Join([]string{
843+
"name: app",
844+
`froms: ["cr.ray.io/rayproject/base3.10"]`,
845+
"dockerfile: Dockerfile",
846+
}, "\n"))
847+
848+
// Discovery finds base3.10 via params (no env var needed for discovery).
849+
// Loading the spec requires env var to be set for expansion.
850+
lookup := func(key string) (string, bool) {
851+
if key == "PY" {
852+
return "3.10", true
853+
}
854+
return "", false
855+
}
856+
857+
graph, err := buildDepGraph(filepath.Join(appDir, "app.wanda.yaml"), lookup, testPrefix)
858+
if err != nil {
859+
t.Fatalf("buildDepGraph: %v", err)
860+
}
861+
862+
// base3.10 was discovered via params and loaded with PY=3.10
863+
if graph.Specs["base3.10"] == nil {
864+
t.Error("expected base3.10 in graph")
865+
}
866+
867+
if len(graph.Order) != 2 {
868+
t.Errorf("Order has %d items, want 2", len(graph.Order))
869+
}
870+
}

0 commit comments

Comments
 (0)