diff --git a/wanda/container_cmd.go b/wanda/container_cmd.go new file mode 100644 index 00000000..d840d050 --- /dev/null +++ b/wanda/container_cmd.go @@ -0,0 +1,177 @@ +package wanda + +import ( + "bytes" + "encoding/json" + "fmt" + "log" + "os" + "os/exec" + "sort" + "strings" +) + +// ContainerCmd is the interface for building container images across +// different container runtimes and builders. +type ContainerCmd interface { + // setWorkDir sets the working directory for commands. + setWorkDir(dir string) + + // run executes a command with the given arguments. + run(args ...string) error + + // pull pulls an image and optionally tags it. + pull(src, asTag string) error + + // inspectImage returns information about an image, or nil if not found. + inspectImage(tag string) (*imageInfo, error) + + // tag tags an image. + tag(src, asTag string) error + + // build builds an image from the given input. + build(in *buildInput, core *buildInputCore, hints *buildInputHints) error +} + +// imageInfo contains information about a container image. +type imageInfo struct { + ID string `json:"Id"` + RepoDigests []string + RepoTags []string +} + +// baseContainerCmd provides common functionality for container commands. +type baseContainerCmd struct { + bin string + workDir string + envs []string +} + +func (c *baseContainerCmd) setWorkDir(dir string) { c.workDir = dir } + +func (c *baseContainerCmd) cmd(args ...string) *exec.Cmd { + cmd := exec.Command(c.bin, args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Env = c.envs + if c.workDir != "" { + cmd.Dir = c.workDir + } + return cmd +} + +func (c *baseContainerCmd) run(args ...string) error { + return c.cmd(args...).Run() +} + +func (c *baseContainerCmd) pull(src, asTag string) error { + if err := c.run("pull", src); err != nil { + return fmt.Errorf("pull %s: %w", src, err) + } + if src != asTag { + if err := c.tag(src, asTag); err != nil { + return fmt.Errorf("tag %s %s: %w", src, asTag, err) + } + } + return nil +} + +func (c *baseContainerCmd) inspectImage(tag string) (*imageInfo, error) { + cmd := c.cmd("image", "inspect", tag) + buf := new(bytes.Buffer) + cmd.Stdout = buf + if err := cmd.Run(); err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + // Docker returns 1 + code := exitErr.ExitCode() + if code == 1 { + return nil, nil + } + } + return nil, err + } + var info []*imageInfo + if err := json.Unmarshal(buf.Bytes(), &info); err != nil { + return nil, fmt.Errorf("unmarshal image info: %w", err) + } + if len(info) != 1 { + return nil, fmt.Errorf("%d image(s) found, expect 1", len(info)) + } + return info[0], nil +} + +func (c *baseContainerCmd) tag(src, asTag string) error { + return c.run("tag", src, asTag) +} + +func (c *baseContainerCmd) build(in *buildInput, core *buildInputCore, hints *buildInputHints) error { + return c.doBuild(in, core, hints, nil) +} + +// doBuild is the common build implementation that accepts extra flags. +func (c *baseContainerCmd) doBuild(in *buildInput, core *buildInputCore, hints *buildInputHints, extraFlags []string) error { + if hints == nil { + hints = newBuildInputHints(nil) + } + + // Pull down the required images, and tag them properly. + var froms []string + for from := range core.Froms { + froms = append(froms, from) + } + sort.Strings(froms) + + for _, from := range froms { + src, ok := in.froms[from] + if !ok { + return fmt.Errorf("missing base image source for %q", from) + } + if src.local != "" { // local image, already ready. + continue + } + if err := c.pull(src.src, src.name); err != nil { + return fmt.Errorf("pull %s(%s): %w", src.name, src.src, err) + } + } + + // Build the image. + var args []string + args = append(args, "build") + args = append(args, extraFlags...) + args = append(args, "-f", core.Dockerfile) + + for _, t := range in.tagList() { + args = append(args, "-t", t) + } + + buildArgs := make(map[string]string) + for k, v := range hints.BuildArgs { + buildArgs[k] = v + } + // non-hint args can overwrite hint args + for k, v := range core.BuildArgs { + buildArgs[k] = v + } + + var buildArgKeys []string + for k := range buildArgs { + buildArgKeys = append(buildArgKeys, k) + } + sort.Strings(buildArgKeys) + for _, k := range buildArgKeys { + v := buildArgs[k] + args = append(args, "--build-arg", fmt.Sprintf("%s=%s", k, v)) + } + + // read context from stdin + args = append(args, "-") + + log.Printf("%s %s", c.bin, strings.Join(args, " ")) + + buildCmd := c.cmd(args...) + if in.context != nil { + buildCmd.Stdin = newWriterToReader(in.context) + } + + return buildCmd.Run() +} diff --git a/wanda/container_cmd_test.go b/wanda/container_cmd_test.go new file mode 100644 index 00000000..39cea592 --- /dev/null +++ b/wanda/container_cmd_test.go @@ -0,0 +1,511 @@ +package wanda + +import ( + "encoding/json" + "os" + "os/exec" + "strings" + "testing" + + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/v1/daemon" +) + +// imageTestInfo contains image information for testing. +type imageTestInfo struct { + LayerCount int + Env []string + Labels map[string]string +} + +// containerRuntimeTest represents a container runtime for testing. +type containerRuntimeTest struct { + name string + available func() bool + runtime ContainerRuntime + tagPrefix string + bin string +} + +func dockerAvailable() bool { + _, err := exec.LookPath("docker") + return err == nil +} + +func podmanAvailable() bool { + return false +} + +var containerRuntimes = []containerRuntimeTest{ + { + name: "docker", + available: dockerAvailable, + runtime: RuntimeDocker, + tagPrefix: "cr.ray.io/rayproject/", + bin: "docker", + }, + { + name: "podman", + available: podmanAvailable, + runtime: RuntimePodman, + tagPrefix: "localhost/rayproject/", + bin: "podman", + }, +} + +// newCmd creates a ContainerCmd for the given runtime. +func (rt *containerRuntimeTest) newCmd() ContainerCmd { + config := &ForgeConfig{ContainerRuntime: rt.runtime} + return config.newContainerCmd() +} + +// inspectLabel gets an image label using the runtime's inspect command. +func (rt *containerRuntimeTest) inspectLabel(tag, label string) (string, error) { + out, err := exec.Command(rt.bin, "inspect", "--format", "{{index .Config.Labels \""+label+"\"}}", tag).Output() + return strings.TrimSpace(string(out)), err +} + +// inspectImage gets detailed image information for testing. +func (rt *containerRuntimeTest) inspectImage(tag string) (*imageTestInfo, error) { + if rt.runtime == RuntimeDocker { + return rt.inspectImageDocker(tag) + } + return rt.inspectImagePodman(tag) +} + +func (rt *containerRuntimeTest) inspectImageDocker(tag string) (*imageTestInfo, error) { + ref, err := name.ParseReference(tag) + if err != nil { + return nil, err + } + + img, err := daemon.Image(ref) + if err != nil { + return nil, err + } + + layers, err := img.Layers() + if err != nil { + return nil, err + } + + config, err := img.ConfigFile() + if err != nil { + return nil, err + } + + return &imageTestInfo{ + LayerCount: len(layers), + Env: config.Config.Env, + Labels: config.Config.Labels, + }, nil +} + +func (rt *containerRuntimeTest) inspectImagePodman(tag string) (*imageTestInfo, error) { + // Use podman inspect to get image details as JSON. + out, err := exec.Command("podman", "inspect", tag).Output() + if err != nil { + return nil, err + } + + var inspectResult []struct { + RootFS struct { + Layers []string `json:"Layers"` + } `json:"RootFS"` + Config struct { + Env []string `json:"Env"` + Labels map[string]string `json:"Labels"` + } `json:"Config"` + } + + if err := json.Unmarshal(out, &inspectResult); err != nil { + return nil, err + } + + if len(inspectResult) == 0 { + return nil, nil + } + + return &imageTestInfo{ + LayerCount: len(inspectResult[0].RootFS.Layers), + Env: inspectResult[0].Config.Env, + Labels: inspectResult[0].Config.Labels, + }, nil +} + +func TestContainerCmd_Build(t *testing.T) { + for _, rt := range containerRuntimes { + t.Run(rt.name, func(t *testing.T) { + if !rt.available() { + t.Skipf("%s not available", rt.name) + } + + cmd := rt.newCmd() + + ts := newTarStream() + ts.addFile("Dockerfile.hello", nil, "testdata/Dockerfile.hello") + + tag := rt.tagPrefix + "wanda-build-test" + + buildArgs := []string{"MESSAGE=test message from " + rt.name} + input := newBuildInput(ts, buildArgs) + input.addTag(tag) + + core, err := input.makeCore("Dockerfile.hello") + if err != nil { + t.Fatalf("make build input core: %v", err) + } + + hints := newBuildInputHints([]string{ + "REMOTE_CACHE_URL=http://localhost:5000", + "MESSAGE=does not matter", // will be shadowed by the build args + }) + + if err := cmd.build(input, core, hints); err != nil { + t.Fatalf("build: %v", err) + } + + // Verify the image was built. + info, err := cmd.inspectImage(tag) + if err != nil { + t.Fatalf("inspect image: %v", err) + } + if info == nil { + t.Fatal("image not found after build") + } + + // Verify the build args were applied by checking the image labels. + label, err := rt.inspectLabel(tag, "io.ray.wanda.message") + if err != nil { + t.Fatalf("inspect label: %v", err) + } + + want := "test message from " + rt.name + if label != want { + t.Errorf("label got %q, want %q", label, want) + } + + // Clean up. + _ = cmd.run("rmi", tag) + }) + } +} + +// TestContainerCmd_Build_Full tests container build with full image verification +// including layers and env variables for both docker and podman. +func TestContainerCmd_Build_Full(t *testing.T) { + for _, rt := range containerRuntimes { + t.Run(rt.name, func(t *testing.T) { + if !rt.available() { + t.Skipf("%s not available", rt.name) + } + + cmd := rt.newCmd() + + ts := newTarStream() + ts.addFile("Dockerfile.hello", nil, "testdata/Dockerfile.hello") + + tag := rt.tagPrefix + "wanda-full-test" + + buildArgs := []string{"MESSAGE=test message"} + input := newBuildInput(ts, buildArgs) + input.addTag(tag) + + core, err := input.makeCore("Dockerfile.hello") + if err != nil { + t.Fatalf("make build input core: %v", err) + } + + hints := newBuildInputHints([]string{ + "REMOTE_CACHE_URL=http://localhost:5000", + "MESSAGE=does not matter", // will be shadowed by the build args + }) + + if err := cmd.build(input, core, hints); err != nil { + t.Fatalf("build: %v", err) + } + + // Get full image info. + imgInfo, err := rt.inspectImage(tag) + if err != nil { + t.Fatalf("inspect image: %v", err) + } + if imgInfo == nil { + t.Fatal("image not found after build") + } + + // Verify layer count. + if imgInfo.LayerCount != 1 { + t.Errorf("expected 1 layer, got %d", imgInfo.LayerCount) + } + + // Check message env value, this is set by the build args. + messageEnv := "" + t.Log(imgInfo.Env) + for _, env := range imgInfo.Env { + if strings.HasPrefix(env, "MESSAGE=") { + messageEnv = env + break + } + } + + if messageEnv != "MESSAGE=test message" { + t.Errorf("MESSAGE env got %q, want `MESSAGE=test message`", messageEnv) + } + + // Clean up. + _ = cmd.run("rmi", tag) + }) + } +} + +// TestContainerCmd_Build_Full_withHints tests container build with hints (no build args) +// with full image verification for both docker and podman. +func TestContainerCmd_Build_Full_withHints(t *testing.T) { + for _, rt := range containerRuntimes { + t.Run(rt.name, func(t *testing.T) { + if !rt.available() { + t.Skipf("%s not available", rt.name) + } + + cmd := rt.newCmd() + + ts := newTarStream() + ts.addFile("Dockerfile.hello", nil, "testdata/Dockerfile.hello") + + tag := rt.tagPrefix + "wanda-full-hints-test" + + input := newBuildInput(ts, nil) + input.addTag(tag) + + core, err := input.makeCore("Dockerfile.hello") + if err != nil { + t.Fatalf("make build input core: %v", err) + } + + hints := newBuildInputHints([]string{ + "REMOTE_CACHE_URL=http://localhost:5000", + "MESSAGE=hint message", + }) + + if err := cmd.build(input, core, hints); err != nil { + t.Fatalf("build: %v", err) + } + + // Get full image info. + imgInfo, err := rt.inspectImage(tag) + if err != nil { + t.Fatalf("inspect image: %v", err) + } + if imgInfo == nil { + t.Fatal("image not found after build") + } + + // Verify layer count. + if imgInfo.LayerCount != 1 { + t.Errorf("expected 1 layer, got %d", imgInfo.LayerCount) + } + + // Check message env value, this is set by the hint args. + messageEnv := "" + t.Log(imgInfo.Env) + for _, env := range imgInfo.Env { + if strings.HasPrefix(env, "MESSAGE=") { + messageEnv = env + break + } + } + + if messageEnv != "MESSAGE=hint message" { + t.Errorf("MESSAGE env got %q, want `MESSAGE=hint message`", messageEnv) + } + + // Clean up. + _ = cmd.run("rmi", tag) + }) + } +} + +func TestContainerCmd_Build_withHints(t *testing.T) { + for _, rt := range containerRuntimes { + t.Run(rt.name, func(t *testing.T) { + if !rt.available() { + t.Skipf("%s not available", rt.name) + } + + cmd := rt.newCmd() + + ts := newTarStream() + ts.addFile("Dockerfile.hello", nil, "testdata/Dockerfile.hello") + + tag := rt.tagPrefix + "wanda-hints-test" + + input := newBuildInput(ts, nil) // no build args, only hints + input.addTag(tag) + + core, err := input.makeCore("Dockerfile.hello") + if err != nil { + t.Fatalf("make build input core: %v", err) + } + + hints := newBuildInputHints([]string{ + "REMOTE_CACHE_URL=http://localhost:5000", + "MESSAGE=hint message for " + rt.name, + }) + + if err := cmd.build(input, core, hints); err != nil { + t.Fatalf("build: %v", err) + } + + // Verify the image was built. + info, err := cmd.inspectImage(tag) + if err != nil { + t.Fatalf("inspect image: %v", err) + } + if info == nil { + t.Fatal("image not found after build") + } + + // Verify the hint args were applied by checking the image labels. + label, err := rt.inspectLabel(tag, "io.ray.wanda.message") + if err != nil { + t.Fatalf("inspect label: %v", err) + } + + want := "hint message for " + rt.name + if label != want { + t.Errorf("label got %q, want %q", label, want) + } + + // Clean up. + _ = cmd.run("rmi", tag) + }) + } +} + +func TestForge_Build(t *testing.T) { + for _, rt := range containerRuntimes { + t.Run(rt.name, func(t *testing.T) { + if !rt.available() { + t.Skipf("%s not available", rt.name) + } + + tmpDir := t.TempDir() + + // Create a simple file. + if err := os.WriteFile(tmpDir+"/hello.txt", []byte("hello"), 0644); err != nil { + t.Fatalf("write file: %v", err) + } + + // Create a Dockerfile. + dockerfile := `FROM scratch +COPY hello.txt /hello.txt +CMD ["cat", "/hello.txt"] +` + if err := os.WriteFile(tmpDir+"/Dockerfile", []byte(dockerfile), 0644); err != nil { + t.Fatalf("write Dockerfile: %v", err) + } + + spec := &Spec{ + Name: "forge-build-test", + Dockerfile: "Dockerfile", + Srcs: []string{"hello.txt"}, + } + + config := &ForgeConfig{ + WorkDir: tmpDir, + NamePrefix: "localhost/test/", + ContainerRuntime: rt.runtime, + } + + forge, err := NewForge(config) + if err != nil { + t.Fatalf("create forge: %v", err) + } + + if err := forge.Build(spec); err != nil { + t.Fatalf("build: %v", err) + } + + // Verify the image was built. + tag := "localhost/test/forge-build-test" + cmd := rt.newCmd() + info, err := cmd.inspectImage(tag) + if err != nil { + t.Fatalf("inspect image: %v", err) + } + if info == nil { + t.Fatal("image not found after build") + } + + // Clean up. + _ = cmd.run("rmi", tag) + }) + } +} + +func TestForge_BuildWithSymlink(t *testing.T) { + for _, rt := range containerRuntimes { + t.Run(rt.name, func(t *testing.T) { + if !rt.available() { + t.Skipf("%s not available", rt.name) + } + + tmpDir := t.TempDir() + + // Create a target file. + if err := os.WriteFile(tmpDir+"/target.txt", []byte("target content"), 0644); err != nil { + t.Fatalf("write target: %v", err) + } + + // Create a symlink. + if err := os.Symlink("target.txt", tmpDir+"/link.txt"); err != nil { + t.Fatalf("create symlink: %v", err) + } + + // Create a Dockerfile. + dockerfile := `FROM scratch +COPY . /app/ +CMD ["cat", "/app/link.txt"] +` + if err := os.WriteFile(tmpDir+"/Dockerfile", []byte(dockerfile), 0644); err != nil { + t.Fatalf("write Dockerfile: %v", err) + } + + spec := &Spec{ + Name: "forge-symlink-test", + Dockerfile: "Dockerfile", + Srcs: []string{"target.txt", "link.txt"}, + } + + config := &ForgeConfig{ + WorkDir: tmpDir, + NamePrefix: "localhost/test/", + ContainerRuntime: rt.runtime, + } + + forge, err := NewForge(config) + if err != nil { + t.Fatalf("create forge: %v", err) + } + + if err := forge.Build(spec); err != nil { + t.Fatalf("build: %v", err) + } + + // Verify the image was built. + tag := "localhost/test/forge-symlink-test" + cmd := rt.newCmd() + info, err := cmd.inspectImage(tag) + if err != nil { + t.Fatalf("inspect image: %v", err) + } + if info == nil { + t.Fatal("image not found after build") + } + + // Clean up. + _ = cmd.run("rmi", tag) + }) + } +} diff --git a/wanda/docker_cmd.go b/wanda/docker_cmd.go index 82f29d2d..2a7c9dba 100644 --- a/wanda/docker_cmd.go +++ b/wanda/docker_cmd.go @@ -1,14 +1,8 @@ package wanda import ( - "bytes" - "encoding/json" "fmt" - "log" "os" - "os/exec" - "sort" - "strings" ) func dockerCmdEnvs() []string { @@ -28,29 +22,30 @@ func dockerCmdEnvs() []string { return envs } +// dockerCmd wraps the docker CLI for building container images. type dockerCmd struct { - bin string - workDir string - - envs []string + baseContainerCmd + // useLegacyEngine disables BuildKit. When false, uses --progress=plain. useLegacyEngine bool } -type dockerCmdConfig struct { - bin string +// DockerCmdConfig configures the docker command. +type DockerCmdConfig struct { + Bin string - useLegacyEngine bool + UseLegacyEngine bool } -func newDockerCmd(config *dockerCmdConfig) *dockerCmd { - bin := config.bin +// NewDockerCmd creates a new docker container command. +func NewDockerCmd(config *DockerCmdConfig) ContainerCmd { + bin := config.Bin if bin == "" { bin = "docker" } envs := dockerCmdEnvs() - if config.useLegacyEngine { + if config.UseLegacyEngine { envs = append(envs, "DOCKER_BUILDKIT=0") } else { // Default using buildkit. @@ -58,140 +53,19 @@ func newDockerCmd(config *dockerCmdConfig) *dockerCmd { } return &dockerCmd{ - bin: bin, - envs: envs, - useLegacyEngine: config.useLegacyEngine, - } -} - -func (c *dockerCmd) setWorkDir(dir string) { c.workDir = dir } - -func (c *dockerCmd) cmd(args ...string) *exec.Cmd { - cmd := exec.Command(c.bin, args...) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - cmd.Env = c.envs - if c.workDir != "" { - cmd.Dir = c.workDir - } - return cmd -} - -func (c *dockerCmd) run(args ...string) error { - cmd := c.cmd(args...) - return cmd.Run() -} - -func (c *dockerCmd) pull(src, asTag string) error { - if err := c.run("pull", src); err != nil { - return fmt.Errorf("pull %s: %w", src, err) - } - - if src != asTag { - if err := c.tag(src, asTag); err != nil { - return fmt.Errorf("tag %s %s: %w", src, asTag, err) - } - } - - return nil -} - -type dockerImageInfo struct { - ID string `json:"Id"` - RepoDigests []string - RepoTags []string -} - -func (c *dockerCmd) inspectImage(tag string) (*dockerImageInfo, error) { - cmd := c.cmd("image", "inspect", tag) - buf := new(bytes.Buffer) - cmd.Stdout = buf - if err := cmd.Run(); err != nil { - if exitErr, ok := err.(*exec.ExitError); ok && exitErr.ExitCode() == 1 { - return nil, nil - } - return nil, err + baseContainerCmd: baseContainerCmd{ + bin: bin, + envs: envs, + }, + useLegacyEngine: config.UseLegacyEngine, } - var info []*dockerImageInfo - if err := json.Unmarshal(buf.Bytes(), &info); err != nil { - return nil, fmt.Errorf("unmarshal image info: %w", err) - } - if len(info) != 1 { - return nil, fmt.Errorf("%d image(s) found, expect 1", len(info)) - } - return info[0], nil -} - -func (c *dockerCmd) tag(src, asTag string) error { - return c.run("tag", src, asTag) } +// build overrides baseContainerCmd.build to add --progress=plain for BuildKit. func (c *dockerCmd) build(in *buildInput, core *buildInputCore, hints *buildInputHints) error { - if hints == nil { - hints = newBuildInputHints(nil) - } - - // Pull down the required images, and tag them properly. - var froms []string - for from := range core.Froms { - froms = append(froms, from) - } - sort.Strings(froms) - - for _, from := range froms { - src, ok := in.froms[from] - if !ok { - return fmt.Errorf("missing base image source for %q", from) - } - if src.local != "" { // local image, already ready. - continue - } - if err := c.pull(src.src, src.name); err != nil { - return fmt.Errorf("pull %s(%s): %w", src.name, src.src, err) - } - } - // TODO(aslonnie): maybe recheck all the IDs of the from images? - - // Build the image. - var args []string - args = append(args, "build") + var extraFlags []string if !c.useLegacyEngine { - args = append(args, "--progress=plain") - } - args = append(args, "-f", core.Dockerfile) - - for _, t := range in.tagList() { - args = append(args, "-t", t) + extraFlags = append(extraFlags, "--progress=plain") } - - buildArgs := make(map[string]string) - for k, v := range hints.BuildArgs { - buildArgs[k] = v - } - // non-hint args can overwrite hint args - for k, v := range core.BuildArgs { - buildArgs[k] = v - } - - var buildArgKeys []string - for k := range buildArgs { - buildArgKeys = append(buildArgKeys, k) - } - sort.Strings(buildArgKeys) - for _, k := range buildArgKeys { - v := buildArgs[k] - args = append(args, "--build-arg", fmt.Sprintf("%s=%s", k, v)) - } - - // read context from stdin - args = append(args, "-") - - log.Printf("docker %s", strings.Join(args, " ")) - - buildCmd := c.cmd(args...) - if in.context != nil { - buildCmd.Stdin = newWriterToReader(in.context) - } - - return buildCmd.Run() + return c.doBuild(in, core, hints, extraFlags) } diff --git a/wanda/docker_cmd_test.go b/wanda/docker_cmd_test.go index 64b6e839..5045db40 100644 --- a/wanda/docker_cmd_test.go +++ b/wanda/docker_cmd_test.go @@ -2,136 +2,35 @@ package wanda import ( "testing" - - "strings" - - "github.com/google/go-containerregistry/pkg/name" - "github.com/google/go-containerregistry/pkg/v1/daemon" ) -func TestDockerCmdBuild(t *testing.T) { - cmd := newDockerCmd(&dockerCmdConfig{}) // uses real docker client - - ts := newTarStream() - ts.addFile("Dockerfile.hello", nil, "testdata/Dockerfile.hello") - - const tag = "cr.ray.io/rayproject/wanda-test" - - buildArgs := []string{"MESSAGE=test mesasge"} - input := newBuildInput(ts, buildArgs) - input.addTag(tag) - - core, err := input.makeCore("Dockerfile.hello") - if err != nil { - t.Fatalf("make build input core: %v", err) - } - - hints := newBuildInputHints([]string{ - "REMOTE_CACHE_URL=http://localhost:5000", - "MESSAGE=does not matter", // will be shadowed by the build args - }) - - if err := cmd.build(input, core, hints); err != nil { - t.Fatalf("build: %v", err) - } - - ref, err := name.ParseReference(tag) - if err != nil { - t.Fatalf("parse reference: %v", err) - } - - img, err := daemon.Image(ref) - if err != nil { - t.Fatalf("read image: %v", err) - } - - layers, err := img.Layers() - if err != nil { - t.Fatalf("read layers: %v", err) - } - if len(layers) != 1 { - t.Fatalf("expected 1 layer, got %d", len(layers)) - } - - config, err := img.ConfigFile() - if err != nil { - t.Fatalf("read config: %v", err) - } +func TestNewDockerCmd(t *testing.T) { + c := NewDockerCmd(&DockerCmdConfig{}) + cmd := c.(*dockerCmd) - // Check message env value, this is set by the build args. - messageEnv := "" - t.Log(config.Config.Env) - for _, env := range config.Config.Env { - if strings.HasPrefix(env, "MESSAGE=") { - messageEnv = env - break - } + if cmd.bin != "docker" { + t.Errorf("bin: got %q, want %q", cmd.bin, "docker") } - if messageEnv != "MESSAGE=test mesasge" { - t.Errorf("MESSAGE env got %q, want `MESSAGE=test mesasge`", messageEnv) + if cmd.useLegacyEngine { + t.Error("useLegacyEngine should be false by default") } } -func TestDockerCmdBuild_withHints(t *testing.T) { - cmd := newDockerCmd(&dockerCmdConfig{}) // uses real docker client - - ts := newTarStream() - ts.addFile("Dockerfile.hello", nil, "testdata/Dockerfile.hello") - - const tag = "cr.ray.io/rayproject/wanda-test" - - input := newBuildInput(ts, nil) - input.addTag(tag) - - core, err := input.makeCore("Dockerfile.hello") - if err != nil { - t.Fatalf("make build input core: %v", err) - } - - hints := newBuildInputHints([]string{ - "REMOTE_CACHE_URL=http://localhost:5000", - "MESSAGE=hint message", // will be shadowed by the build args - }) - - if err := cmd.build(input, core, hints); err != nil { - t.Fatalf("build: %v", err) - } - - ref, err := name.ParseReference(tag) - if err != nil { - t.Fatalf("parse reference: %v", err) - } +func TestNewDockerCmd_customBin(t *testing.T) { + c := NewDockerCmd(&DockerCmdConfig{Bin: "/usr/local/bin/docker"}) + cmd := c.(*dockerCmd) - img, err := daemon.Image(ref) - if err != nil { - t.Fatalf("read image: %v", err) - } - - layers, err := img.Layers() - if err != nil { - t.Fatalf("read layers: %v", err) - } - if len(layers) != 1 { - t.Fatalf("expected 1 layer, got %d", len(layers)) - } - - config, err := img.ConfigFile() - if err != nil { - t.Fatalf("read config: %v", err) + if cmd.bin != "/usr/local/bin/docker" { + t.Errorf("bin: got %q, want %q", cmd.bin, "/usr/local/bin/docker") } +} - // Check message env value, this is set by the build args. - messageEnv := "" - t.Log(config.Config.Env) - for _, env := range config.Config.Env { - if strings.HasPrefix(env, "MESSAGE=") { - messageEnv = env - break - } - } +func TestNewDockerCmd_legacyEngine(t *testing.T) { + c := NewDockerCmd(&DockerCmdConfig{UseLegacyEngine: true}) + cmd := c.(*dockerCmd) - if messageEnv != "MESSAGE=hint message" { - t.Errorf("MESSAGE env got %q, want `MESSAGE=hint message`", messageEnv) + if !cmd.useLegacyEngine { + t.Error("useLegacyEngine should be true when configured") } } diff --git a/wanda/forge.go b/wanda/forge.go index b4bfeae6..83e16fed 100644 --- a/wanda/forge.go +++ b/wanda/forge.go @@ -45,7 +45,7 @@ type Forge struct { cacheHitCount int - docker *dockerCmd + containerCmd ContainerCmd } // NewForge creates a new forge with the given configuration. @@ -56,8 +56,9 @@ func NewForge(config *ForgeConfig) (*Forge, error) { } f := &Forge{ - config: config, - workDir: absWorkDir, + config: config, + workDir: absWorkDir, + containerCmd: config.newContainerCmd(), remoteOpts: []remote.Option{ remote.WithAuthFromKeychain(authn.DefaultKeychain), remote.WithPlatform(crane.Platform{ @@ -66,7 +67,6 @@ func NewForge(config *ForgeConfig) (*Forge, error) { }), }, } - f.docker = f.newDockerCmd() return f, nil } @@ -83,13 +83,6 @@ func (f *Forge) cacheTag(digest string) string { return f.config.cacheTag(digest) } -func (f *Forge) newDockerCmd() *dockerCmd { - return newDockerCmd(&dockerCmdConfig{ - bin: f.config.DockerBin, - useLegacyEngine: runtime.GOOS == "windows", - }) -} - func (f *Forge) resolveBases(froms []string) (map[string]*imageSource, error) { m := make(map[string]*imageSource) namePrefix := f.config.NamePrefix @@ -97,7 +90,7 @@ func (f *Forge) resolveBases(froms []string) (map[string]*imageSource, error) { for _, from := range froms { if strings.HasPrefix(from, "@") { // A local image. name := strings.TrimPrefix(from, "@") - src, err := resolveDockerImage(f.docker, from, name) + src, err := resolveDockerImage(f.containerCmd, from, name) if err != nil { return nil, fmt.Errorf("resolve local image %s: %w", from, err) } @@ -108,7 +101,7 @@ func (f *Forge) resolveBases(froms []string) (map[string]*imageSource, error) { if namePrefix != "" && strings.HasPrefix(from, namePrefix) { if !f.isRemote() { // Treat it as a local image. - src, err := resolveDockerImage(f.docker, from, from) + src, err := resolveDockerImage(f.containerCmd, from, from) if err != nil { return nil, fmt.Errorf( "resolve prefixed local image %s: %w", from, err, @@ -227,7 +220,7 @@ func (f *Forge) Build(spec *Spec) error { return nil // and we are done. } } else { - info, err := f.docker.inspectImage(cacheTag) + info, err := f.containerCmd.inspectImage(cacheTag) if err != nil { return fmt.Errorf("check cache image: %w", err) } @@ -238,7 +231,7 @@ func (f *Forge) Build(spec *Spec) error { for _, tag := range in.tagList() { log.Printf("tag output as %s", tag) if tag != cacheTag { - if err := f.docker.tag(cacheTag, tag); err != nil { + if err := f.containerCmd.tag(cacheTag, tag); err != nil { return fmt.Errorf("tag cache image: %w", err) } } @@ -251,23 +244,28 @@ func (f *Forge) Build(spec *Spec) error { inputHints := newBuildInputHints(spec.BuildHintArgs) // Now we can build the image. - // Always use a new dockerCmd so that it can run in its own environment. - d := f.newDockerCmd() - d.setWorkDir(f.workDir) + var newCmd ContainerCmd + if f.config.ContainerRuntime == RuntimeDocker { + // Always use a new dockerCmd so that it can run in its own environment. + newCmd = f.config.newContainerCmd() + } else { + newCmd = f.containerCmd + } + newCmd.setWorkDir(f.workDir) - if err := d.build(in, inputCore, inputHints); err != nil { - return fmt.Errorf("build docker: %w", err) + if err := newCmd.build(in, inputCore, inputHints); err != nil { + return fmt.Errorf("build: %w", err) } // Push the image to the work repo with workTag and cacheTag if needed. if f.isRemote() { - if err := d.run("push", workTag); err != nil { - return fmt.Errorf("push docker: %w", err) + if err := newCmd.run("push", workTag); err != nil { + return fmt.Errorf("push: %w", err) } // Save cache result too. if caching && !f.config.ReadOnlyCache { - if err := d.run("push", cacheTag); err != nil { + if err := newCmd.run("push", cacheTag); err != nil { return fmt.Errorf("push cache: %w", err) } } diff --git a/wanda/forge_config.go b/wanda/forge_config.go index 07093367..74cb321c 100644 --- a/wanda/forge_config.go +++ b/wanda/forge_config.go @@ -2,13 +2,23 @@ package wanda import ( "fmt" + "runtime" "strings" ) +// ContainerRuntime specifies which container runtime to use. +type ContainerRuntime int + +const ( + // RuntimeDocker uses Docker as the container runtime. + RuntimeDocker ContainerRuntime = iota + // RuntimePodman uses Podman as the container runtime. + RuntimePodman +) + // ForgeConfig is a configuration for a forge to build container images. type ForgeConfig struct { WorkDir string - DockerBin string WorkRepo string NamePrefix string BuildID string @@ -18,6 +28,22 @@ type ForgeConfig struct { Rebuild bool ReadOnlyCache bool + + // ContainerRuntime specifies which container runtime to use. + // Defaults to RuntimeDocker. + ContainerRuntime ContainerRuntime + + // ContainerBin is the path to the container runtime binary. + // If empty, uses the default binary name ("docker" or "podman"). + ContainerBin string +} + +// newContainerCmd creates a ContainerCmd based on the config settings. +func (c *ForgeConfig) newContainerCmd() ContainerCmd { + return NewDockerCmd(&DockerCmdConfig{ + Bin: c.ContainerBin, + UseLegacyEngine: runtime.GOOS == "windows", + }) } func (c *ForgeConfig) isRemote() bool { return c.WorkRepo != "" } diff --git a/wanda/resolve_image.go b/wanda/resolve_image.go index f0377978..47d9480a 100644 --- a/wanda/resolve_image.go +++ b/wanda/resolve_image.go @@ -8,7 +8,7 @@ import ( "github.com/google/go-containerregistry/pkg/v1/remote" ) -func resolveDockerImage(d *dockerCmd, name, ref string) (*imageSource, error) { +func resolveDockerImage(d ContainerCmd, name, ref string) (*imageSource, error) { info, err := d.inspectImage(ref) if err != nil { return nil, fmt.Errorf("inspect image %s: %w", ref, err) diff --git a/wanda/resolve_image_test.go b/wanda/resolve_image_test.go index 79f6120e..7b41615c 100644 --- a/wanda/resolve_image_test.go +++ b/wanda/resolve_image_test.go @@ -51,7 +51,7 @@ func TestResolveLocalImage(t *testing.T) { t.Errorf("got image local %q, want %q", src.local, tagStr) } - dockerCmd := newDockerCmd(&dockerCmdConfig{}) + dockerCmd := NewDockerCmd(&DockerCmdConfig{}) if err := dockerCmd.run("image", "rm", tagStr); err != nil { t.Fatal("remove image: ", err) } diff --git a/wanda/wanda/main.go b/wanda/wanda/main.go index 041e8bd7..315bfca3 100644 --- a/wanda/wanda/main.go +++ b/wanda/wanda/main.go @@ -26,7 +26,7 @@ Flags: func main() { workDir := flag.String("work_dir", ".", "root directory for the build") - docker := flag.String("docker", "", "path to the docker client binary") + dockerBin := flag.String("docker", "", "path to the docker/podman binary") rayCI := flag.Bool( "rayci", false, "takes RAYCI_ env vars for input and run in remote mode", @@ -71,9 +71,11 @@ func main() { input = os.Getenv("RAYCI_WANDA_FILE") } + // Determine the container runtime. + runtime := wanda.RuntimeDocker + config := &wanda.ForgeConfig{ WorkDir: *workDir, - DockerBin: *docker, WorkRepo: *workRepo, NamePrefix: *namePrefix, BuildID: *buildID, @@ -83,6 +85,9 @@ func main() { Rebuild: *rebuild, ReadOnlyCache: *readOnly, + + ContainerRuntime: runtime, + ContainerBin: *dockerBin, } if err := wanda.Build(input, config); err != nil {