Skip to content

Commit c1c2a3d

Browse files
feat(wanda): Build dependencies in topological order
Extend Build() to automatically build all @name dependencies before building the root spec. In local mode, specs are discovered by scanning the repo for *.wanda.yaml files and built in topological order. In RayCI mode, only the root spec is built (deps built by prior pipeline steps). - Add CI mode @name resolution to work repo - Tag images with plain name for @name resolution in local mode - Add tests for dependency chain builds Topic: wanda-build-deps Relative: wanda-deps Signed-off-by: andrew <andrew@anyscale.com>
1 parent c1346cb commit c1c2a3d

File tree

3 files changed

+111
-14
lines changed

3 files changed

+111
-14
lines changed

wanda/forge.go

Lines changed: 51 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,25 +14,46 @@ import (
1414
"github.com/google/go-containerregistry/pkg/v1/remote"
1515
)
1616

17-
// Build builds a container image from the given specification file.
17+
// Build builds a container image from the given specification file, and builds
18+
// all its dependencies in topological order.
19+
// In RayCI mode, dependencies are assumed built by prior pipeline steps; only
20+
// the root is built.
1821
func Build(specFile string, config *ForgeConfig) error {
1922
if config == nil {
2023
config = &ForgeConfig{}
2124
}
2225

23-
spec, err := parseSpecFile(specFile)
26+
graph, err := buildDepGraph(specFile, os.LookupEnv)
2427
if err != nil {
25-
return fmt.Errorf("parse spec file: %w", err)
28+
return fmt.Errorf("build dep graph: %w", err)
2629
}
2730

28-
// Expand env variable.
29-
spec = spec.expandVar(os.LookupEnv)
31+
if err := graph.validateDeps(); err != nil {
32+
return fmt.Errorf("validate deps: %w", err)
33+
}
3034

3135
forge, err := NewForge(config)
3236
if err != nil {
3337
return fmt.Errorf("make forge: %w", err)
3438
}
35-
return forge.Build(spec)
39+
40+
// In RayCI mode, only build the root (deps built by prior pipeline steps).
41+
order := graph.Order
42+
if config.RayCI {
43+
order = []string{graph.Root}
44+
}
45+
46+
for _, name := range order {
47+
rs := graph.Specs[name]
48+
49+
log.Printf("building %s (from %s)", name, rs.Path)
50+
51+
if err := forge.Build(rs.Spec); err != nil {
52+
return fmt.Errorf("build %s: %w", name, err)
53+
}
54+
}
55+
56+
return nil
3657
}
3758

3859
// Forge is a forge to build container images.
@@ -95,13 +116,26 @@ func (f *Forge) resolveBases(froms []string) (map[string]*imageSource, error) {
95116
namePrefix := f.config.NamePrefix
96117

97118
for _, from := range froms {
98-
if strings.HasPrefix(from, "@") { // A local image.
119+
if strings.HasPrefix(from, "@") { // A sourceable image name.
99120
name := strings.TrimPrefix(from, "@")
100-
src, err := resolveDockerImage(f.docker, from, name)
101-
if err != nil {
102-
return nil, fmt.Errorf("resolve local image %s: %w", from, err)
121+
122+
if f.isRemote() {
123+
// CI mode: deps were built by prior pipeline steps and pushed
124+
// to the work repo. Resolve @name to {work_repo}:{build_id}-{name}.
125+
workTag := f.workTag(name)
126+
src, err := resolveRemoteImage(from, workTag, f.remoteOpts...)
127+
if err != nil {
128+
return nil, fmt.Errorf("resolve remote dep @%s: %w", name, err)
129+
}
130+
m[from] = src
131+
} else {
132+
// Local mode: resolve from local docker
133+
src, err := resolveDockerImage(f.docker, from, name)
134+
if err != nil {
135+
return nil, fmt.Errorf("resolve local dep @%s: %w", name, err)
136+
}
137+
m[from] = src
103138
}
104-
m[from] = src
105139
continue
106140
}
107141

@@ -188,13 +222,18 @@ func (f *Forge) Build(spec *Spec) error {
188222
in.addTag(cacheTag)
189223

190224
// When running on rayCI, we only need the workTag and the cacheTag.
191-
// Otherwise, add extra tags.
225+
// Otherwise, add extra tags for local use.
192226
if !f.config.RayCI {
193227
// Name tag is the tag we use to reference the image locally.
194228
// It is also what can be referenced by following steps.
195229
if f.config.NamePrefix != "" {
196230
nameTag := f.config.NamePrefix + spec.Name
197231
in.addTag(nameTag)
232+
} else {
233+
// Tag with plain name so @name references can resolve it.
234+
// e.g., if spec.Name is "dep-base", a dependent spec with
235+
// froms: ["@dep-base"] will look up the image by "dep-base".
236+
in.addTag(spec.Name)
198237
}
199238
for _, tag := range spec.Tags { // And add extra tags.
200239
in.addTag(tag)

wanda/forge_test.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,65 @@ func TestForgeWithRemoteWorkRepo(t *testing.T) {
455455
}
456456
}
457457

458+
func TestBuild_WithDeps(t *testing.T) {
459+
// Test: dep-top -> dep-middle -> dep-base
460+
// Build should build in order: dep-base, dep-middle, dep-top
461+
config := &ForgeConfig{WorkDir: "testdata"}
462+
463+
if err := Build("testdata/dep-top.wanda.yaml", config); err != nil {
464+
t.Fatalf("build with deps: %v", err)
465+
}
466+
467+
// Verify dep-top was built and can be read
468+
ref, err := name.ParseReference("dep-top")
469+
if err != nil {
470+
t.Fatalf("parse reference: %v", err)
471+
}
472+
473+
img, err := daemon.Image(ref)
474+
if err != nil {
475+
t.Fatalf("read dep-top image: %v", err)
476+
}
477+
478+
layers, err := img.Layers()
479+
if err != nil {
480+
t.Fatalf("read layers: %v", err)
481+
}
482+
483+
// Should have 3 layers: dep-base, dep-middle, dep-top
484+
if got, want := len(layers), 3; got != want {
485+
t.Errorf("got %d layers, want %d", got, want)
486+
}
487+
}
488+
489+
func TestBuild_NoDeps(t *testing.T) {
490+
// Test backward compatibility: a spec with no deps should work
491+
config := &ForgeConfig{WorkDir: "testdata"}
492+
493+
if err := Build("testdata/hello.wanda.yaml", config); err != nil {
494+
t.Fatalf("build with deps: %v", err)
495+
}
496+
497+
ref, err := name.ParseReference("hello")
498+
if err != nil {
499+
t.Fatalf("parse reference: %v", err)
500+
}
501+
502+
img, err := daemon.Image(ref)
503+
if err != nil {
504+
t.Fatalf("read hello image: %v", err)
505+
}
506+
507+
layers, err := img.Layers()
508+
if err != nil {
509+
t.Fatalf("read layers: %v", err)
510+
}
511+
512+
if got, want := len(layers), 1; got != want {
513+
t.Errorf("got %d layers, want %d", got, want)
514+
}
515+
}
516+
458517
func TestForgeLocal_withNamePrefix(t *testing.T) {
459518
if runtime.GOOS != "linux" {
460519
t.Skip("skipping test on non-linux")
Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,2 @@
1-
name: localbase
1+
name: mybase
22
dockerfile: Dockerfile.localbase
3-
tags: [ "mybase" ]

0 commit comments

Comments
 (0)