Skip to content

Commit d7807b5

Browse files
feat(wanda): Add dependency resolution for wanda specs
Implement core dependency graph functionality to support automatic building of prerequisite images. This enables a more bazel-like UX where `wanda build spec.yaml` can discover and build dependencies. - Add `Deps` field to Spec for declaring dependency wanda files - Export ParseSpecFile for use by dependency resolver - Implement DepGraph with topological sort (Kahn's algorithm) - Add cycle detection and validation for @ref dependencies - Support transitive dependency discovery Topic: wanda-deps Labels: draft Generated with help from Claude Opus 4.5 Signed-off-by: andrew <andrew@anyscale.com>
1 parent 4f2948f commit d7807b5

File tree

9 files changed

+676
-6
lines changed

9 files changed

+676
-6
lines changed

wanda/deps.go

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
package wanda
2+
3+
import (
4+
"fmt"
5+
"path/filepath"
6+
"strings"
7+
)
8+
9+
// ResolvedSpec contains a parsed and expanded spec along with its file path.
10+
type ResolvedSpec struct {
11+
Spec *Spec
12+
Path string // original file path
13+
}
14+
15+
// DepGraph represents a dependency graph of wanda specs.
16+
type DepGraph struct {
17+
// specs maps expanded name to resolved spec
18+
specs map[string]*ResolvedSpec
19+
20+
// order is the topological build order (dependencies first)
21+
order []string
22+
23+
// root is the name of the root spec (the one requested to build)
24+
root string
25+
}
26+
27+
// Order returns the build order (dependencies first, root last).
28+
func (g *DepGraph) Order() []string {
29+
return g.order
30+
}
31+
32+
// Root returns the name of the root spec.
33+
func (g *DepGraph) Root() string {
34+
return g.root
35+
}
36+
37+
// Specs returns all resolved specs in the graph.
38+
func (g *DepGraph) Specs() map[string]*ResolvedSpec {
39+
return g.specs
40+
}
41+
42+
// Get returns the resolved spec for the given name.
43+
func (g *DepGraph) Get(name string) *ResolvedSpec {
44+
return g.specs[name]
45+
}
46+
47+
// BuildDepGraph parses a spec and all its dependencies, returning a dependency graph
48+
// with specs in topological build order.
49+
func BuildDepGraph(specPath string, lookup lookupFunc) (*DepGraph, error) {
50+
g := &DepGraph{
51+
specs: make(map[string]*ResolvedSpec),
52+
}
53+
54+
if err := g.loadSpec(specPath, lookup); err != nil {
55+
return nil, fmt.Errorf("load root spec: %w", err)
56+
}
57+
58+
absPath, err := filepath.Abs(specPath)
59+
if err != nil {
60+
return nil, fmt.Errorf("abs path for root: %w", err)
61+
}
62+
for name, rs := range g.specs {
63+
rsAbs, _ := filepath.Abs(rs.Path)
64+
if rsAbs == absPath {
65+
g.root = name
66+
break
67+
}
68+
}
69+
70+
if err := g.topoSort(); err != nil {
71+
return nil, fmt.Errorf("topological sort: %w", err)
72+
}
73+
74+
return g, nil
75+
}
76+
77+
func (g *DepGraph) loadSpec(specPath string, lookup lookupFunc) error {
78+
spec, err := ParseSpecFile(specPath)
79+
if err != nil {
80+
return fmt.Errorf("parse %s: %w", specPath, err)
81+
}
82+
spec = spec.expandVar(lookup)
83+
84+
if err := checkUnexpandedVars(spec, specPath); err != nil {
85+
return err
86+
}
87+
88+
if _, exists := g.specs[spec.Name]; exists {
89+
return nil
90+
}
91+
92+
g.specs[spec.Name] = &ResolvedSpec{
93+
Spec: spec,
94+
Path: specPath,
95+
}
96+
97+
specDir := filepath.Dir(specPath)
98+
for _, depPath := range spec.Deps {
99+
fullDepPath := depPath
100+
if !filepath.IsAbs(depPath) {
101+
fullDepPath = filepath.Join(specDir, depPath)
102+
}
103+
if err := g.loadSpec(fullDepPath, lookup); err != nil {
104+
return fmt.Errorf("load dep %s: %w", depPath, err)
105+
}
106+
}
107+
108+
return nil
109+
}
110+
111+
// localDeps extracts @-prefixed dependency names from a spec's Froms.
112+
func localDeps(spec *Spec) []string {
113+
var deps []string
114+
for _, from := range spec.Froms {
115+
if strings.HasPrefix(from, "@") {
116+
deps = append(deps, strings.TrimPrefix(from, "@"))
117+
}
118+
}
119+
return deps
120+
}
121+
122+
// topoSort performs topological sort using Kahn's algorithm.
123+
func (g *DepGraph) topoSort() error {
124+
inDegree := make(map[string]int)
125+
dependents := make(map[string][]string)
126+
127+
for name := range g.specs {
128+
inDegree[name] = 0
129+
}
130+
131+
for name, rs := range g.specs {
132+
for _, depName := range localDeps(rs.Spec) {
133+
if _, exists := g.specs[depName]; exists {
134+
inDegree[name]++
135+
dependents[depName] = append(dependents[depName], name)
136+
}
137+
}
138+
}
139+
140+
var queue []string
141+
for name, degree := range inDegree {
142+
if degree == 0 {
143+
queue = append(queue, name)
144+
}
145+
}
146+
147+
var order []string
148+
for len(queue) > 0 {
149+
current := queue[0]
150+
queue = queue[1:]
151+
order = append(order, current)
152+
153+
for _, dependent := range dependents[current] {
154+
inDegree[dependent]--
155+
if inDegree[dependent] == 0 {
156+
queue = append(queue, dependent)
157+
}
158+
}
159+
}
160+
161+
if len(order) != len(g.specs) {
162+
var cycleNodes []string
163+
for name, degree := range inDegree {
164+
if degree > 0 {
165+
cycleNodes = append(cycleNodes, name)
166+
}
167+
}
168+
return fmt.Errorf("dependency cycle detected involving: %v", cycleNodes)
169+
}
170+
171+
g.order = order
172+
return nil
173+
}
174+
175+
// ValidateDeps checks that all @-prefixed references in Froms have corresponding
176+
// entries in the deps list (i.e., they're in the graph).
177+
func (g *DepGraph) ValidateDeps() error {
178+
for name, rs := range g.specs {
179+
for _, depName := range localDeps(rs.Spec) {
180+
if _, exists := g.specs[depName]; !exists {
181+
return fmt.Errorf(
182+
"spec %q references @%s in froms, but no dep provides image %q",
183+
name, depName, depName,
184+
)
185+
}
186+
}
187+
}
188+
return nil
189+
}
190+
191+
// checkUnexpandedVars checks if a spec has any unexpanded environment variables
192+
// and returns a helpful error message if so.
193+
func checkUnexpandedVars(spec *Spec, specPath string) error {
194+
var missing []string
195+
196+
if vars := findUnexpandedVars(spec.Name); len(vars) > 0 {
197+
missing = append(missing, vars...)
198+
}
199+
for _, s := range spec.Froms {
200+
if vars := findUnexpandedVars(s); len(vars) > 0 {
201+
missing = append(missing, vars...)
202+
}
203+
}
204+
for _, s := range spec.Deps {
205+
if vars := findUnexpandedVars(s); len(vars) > 0 {
206+
missing = append(missing, vars...)
207+
}
208+
}
209+
210+
if len(missing) == 0 {
211+
return nil
212+
}
213+
214+
seen := make(map[string]bool)
215+
var unique []string
216+
for _, v := range missing {
217+
if !seen[v] {
218+
seen[v] = true
219+
unique = append(unique, v)
220+
}
221+
}
222+
223+
if len(unique) == 1 {
224+
return fmt.Errorf("%s: environment variable %s is not set", specPath, unique[0])
225+
}
226+
return fmt.Errorf("%s: environment variables not set: %s", specPath, strings.Join(unique, ", "))
227+
}
228+
229+
// findUnexpandedVars finds $VAR patterns in a string that were not expanded.
230+
func findUnexpandedVars(s string) []string {
231+
var vars []string
232+
for i := 0; i < len(s); i++ {
233+
if s[i] == '$' && i+1 < len(s) {
234+
// Skip $$
235+
if s[i+1] == '$' {
236+
i++
237+
continue
238+
}
239+
// Find the variable name
240+
j := i + 1
241+
for j < len(s) {
242+
c := s[j]
243+
if c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c == '_' {
244+
j++
245+
continue
246+
}
247+
if c >= '0' && c <= '9' && j > i+1 {
248+
j++
249+
continue
250+
}
251+
break
252+
}
253+
if j > i+1 {
254+
vars = append(vars, s[i:j])
255+
}
256+
i = j - 1
257+
}
258+
}
259+
return vars
260+
}

0 commit comments

Comments
 (0)