Skip to content

Commit c1be080

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 308598c commit c1be080

File tree

4 files changed

+619
-55
lines changed

4 files changed

+619
-55
lines changed

wanda/deps.go

Lines changed: 36 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -70,9 +70,14 @@ func (g *depGraph) loadSpec(specPath string, isRoot bool) error {
7070
if err != nil {
7171
return fmt.Errorf("parse %s: %w", specPath, err)
7272
}
73+
74+
if err := spec.ValidateParams(g.lookup); err != nil {
75+
return fmt.Errorf("%s: %w", specPath, err)
76+
}
77+
7378
spec = spec.expandVar(g.lookup)
7479

75-
if err := checkUnexpandedVars(spec, specPath); err != nil {
80+
if err := checkUnexpandedVars(spec, specPath, spec.Params); err != nil {
7681
return err
7782
}
7883

@@ -240,69 +245,38 @@ func (g *depGraph) validateDeps() error {
240245
}
241246

242247
// checkUnexpandedVars checks if a spec has any unexpanded environment variables
243-
// and returns a helpful error message if so.
244-
func checkUnexpandedVars(spec *Spec, specPath string) error {
245-
var missing []string
246-
247-
if vars := findUnexpandedVars(spec.Name); len(vars) > 0 {
248-
missing = append(missing, vars...)
249-
}
250-
for _, s := range spec.Froms {
251-
if vars := findUnexpandedVars(s); len(vars) > 0 {
252-
missing = append(missing, vars...)
253-
}
254-
}
255-
256-
if len(missing) == 0 {
248+
// and returns a helpful error message. If params are declared for a missing var,
249+
// the valid values are included in the error message.
250+
func checkUnexpandedVars(spec *Spec, specPath string, params map[string][]string) error {
251+
vars := spec.UnexpandedVars()
252+
if len(vars) == 0 {
257253
return nil
258254
}
259255

256+
// Deduplicate
260257
seen := make(map[string]bool)
261258
var unique []string
262-
for _, v := range missing {
259+
for _, v := range vars {
263260
if !seen[v] {
264261
seen[v] = true
265262
unique = append(unique, v)
266263
}
267264
}
268265

269-
if len(unique) == 1 {
270-
return fmt.Errorf("%s: environment variable %s is not set", specPath, unique[0])
266+
// Build error message with param hints where available
267+
var parts []string
268+
for _, v := range unique {
269+
if allowed, ok := params[v]; ok && len(allowed) > 0 {
270+
parts = append(parts, fmt.Sprintf("$%s (valid values: %s)", v, strings.Join(allowed, ", ")))
271+
} else {
272+
parts = append(parts, "$"+v)
273+
}
271274
}
272-
return fmt.Errorf("%s: environment variables not set: %s", specPath, strings.Join(unique, ", "))
273-
}
274275

275-
// findUnexpandedVars finds $VAR patterns in a string that were not expanded.
276-
func findUnexpandedVars(s string) []string {
277-
var vars []string
278-
for i := 0; i < len(s); i++ {
279-
if s[i] == '$' && i+1 < len(s) {
280-
// Skip $$
281-
if s[i+1] == '$' {
282-
i++
283-
continue
284-
}
285-
// Find the variable name
286-
j := i + 1
287-
for j < len(s) {
288-
c := s[j]
289-
if c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c == '_' {
290-
j++
291-
continue
292-
}
293-
if c >= '0' && c <= '9' && j > i+1 {
294-
j++
295-
continue
296-
}
297-
break
298-
}
299-
if j > i+1 {
300-
vars = append(vars, s[i:j])
301-
}
302-
i = j - 1
303-
}
276+
if len(parts) == 1 {
277+
return fmt.Errorf("%s: environment variable %s is not set", specPath, parts[0])
304278
}
305-
return vars
279+
return fmt.Errorf("%s: environment variables not set: %s", specPath, strings.Join(parts, "; "))
306280
}
307281

308282
// findRepoRoot walks up from startDir looking for a .git directory.
@@ -334,7 +308,8 @@ type discovered struct {
334308
}
335309

336310
// discoverSpecs scans searchRoot for *.wanda.yaml files and builds a name index.
337-
// Names are expanded using the provided lookup function.
311+
// Names are expanded using declared params first, then the lookup function.
312+
// Specs with params will have all param combinations indexed.
338313
// Returns an error if two specs expand to the same name.
339314
func discoverSpecs(searchRoot string, lookup lookupFunc) (specIndex, error) {
340315
index := make(specIndex)
@@ -359,13 +334,19 @@ func discoverSpecs(searchRoot string, lookup lookupFunc) (specIndex, error) {
359334
outCh <- discovered{skipped: true}
360335
continue
361336
}
362-
spec = spec.expandVar(lookup)
363337

364-
if vars := findUnexpandedVars(spec.Name); len(vars) > 0 {
338+
// Use params to enumerate all possible names.
339+
// For vars without params, try env lookup.
340+
sentAny := false
341+
for _, name := range spec.ExpandedNames() {
342+
if expanded, ok := tryFullyExpand(name, lookup); ok {
343+
outCh <- discovered{name: expanded, path: path}
344+
sentAny = true
345+
}
346+
}
347+
if !sentAny {
365348
outCh <- discovered{skipped: true}
366-
continue
367349
}
368-
outCh <- discovered{name: spec.Name, path: path}
369350
}
370351
}()
371352
}

wanda/deps_test.go

Lines changed: 194 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

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

0 commit comments

Comments
 (0)