From 04c50f24797833779541e60875f357fa7ba5436e Mon Sep 17 00:00:00 2001 From: Harrison Powers Date: Mon, 30 Mar 2026 11:55:09 -0400 Subject: [PATCH] feat: add scion images build command Wraps image-build/scripts/build-images.sh so users can build container images directly from the CLI instead of finding and invoking the script manually. Supports the same flags as the script (--registry, --target, --push, --platform, --tag) plus --source to specify the repo clone path. Exempts the images command group from grove and registry requirements in PersistentPreRunE since it is used to set up the registry before any grove or registry exists. --- cmd/images.go | 129 +++++++++++++ cmd/images_test.go | 175 ++++++++++++++++++ cmd/root.go | 6 +- .../docs/advanced-local/custom-images.md | 4 +- .../content/docs/getting-started/install.md | 7 + docs-site/src/content/docs/reference/cli.md | 39 ++++ 6 files changed, 357 insertions(+), 3 deletions(-) create mode 100644 cmd/images.go create mode 100644 cmd/images_test.go diff --git a/cmd/images.go b/cmd/images.go new file mode 100644 index 000000000..55b39c151 --- /dev/null +++ b/cmd/images.go @@ -0,0 +1,129 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + + "github.com/spf13/cobra" +) + +var ( + imagesBuildRegistry string + imagesBuildTarget string + imagesBuildPush bool + imagesBuildPlatform string + imagesBuildTag string + imagesBuildSource string +) + +var imagesCmd = &cobra.Command{ + Use: "images", + Short: "Manage Scion container images", + Long: `Commands for building and managing Scion container images.`, +} + +var imagesBuildCmd = &cobra.Command{ + Use: "build", + Short: "Build Scion container images using docker buildx", + Long: `Build Scion container images locally using docker buildx. + +Builds the Scion container image hierarchy: + + core-base System dependencies (Go, Node, Python) + └── scion-base Adds sciontool binary and scion user + ├── claude Claude Code harness + ├── gemini Gemini CLI harness + ├── opencode OpenCode harness + └── codex Codex harness + +The --source flag (or current directory) must point to a clone of the +Scion source repository, as the Dockerfiles are located there. + +After building, configure scion to use the registry: + scion config set image_registry `, + RunE: func(cmd *cobra.Command, args []string) error { + return runImagesBuild() + }, +} + +func init() { + rootCmd.AddCommand(imagesCmd) + imagesCmd.AddCommand(imagesBuildCmd) + + imagesBuildCmd.Flags().StringVar(&imagesBuildRegistry, "registry", "", "Target registry path, e.g. ghcr.io/myorg (required)") + _ = imagesBuildCmd.MarkFlagRequired("registry") + imagesBuildCmd.Flags().StringVar(&imagesBuildTarget, "target", "common", "Build target: common (scion-base + harnesses), all (full rebuild including core-base), core-base, harnesses") + imagesBuildCmd.Flags().BoolVar(&imagesBuildPush, "push", false, "Push images to the registry after building") + imagesBuildCmd.Flags().StringVar(&imagesBuildPlatform, "platform", "", `Target platform(s): "all" (linux/amd64,linux/arm64) or explicit e.g. "linux/amd64"`) + imagesBuildCmd.Flags().StringVar(&imagesBuildTag, "tag", "latest", "Image tag") + imagesBuildCmd.Flags().StringVar(&imagesBuildSource, "source", "", "Path to a Scion source repository clone (default: current directory)") +} + +func runImagesBuild() error { + sourceDir := imagesBuildSource + if sourceDir == "" { + var err error + sourceDir, err = os.Getwd() + if err != nil { + return fmt.Errorf("getting current directory: %w", err) + } + } + + scriptPath := filepath.Join(sourceDir, "image-build", "scripts", "build-images.sh") + if _, err := os.Stat(scriptPath); os.IsNotExist(err) { + return fmt.Errorf( + "build script not found at %s\n\n"+ + "'scion images build' requires the Scion source repository because\n"+ + "the Dockerfiles and build scripts are part of the repo.\n\n"+ + "Run this command from within a clone of the Scion repo, or use\n"+ + "--source to specify the repository path.\n\n"+ + "To clone: git clone https://github.com/GoogleCloudPlatform/scion", + scriptPath, + ) + } + + scriptArgs := []string{ + scriptPath, + "--registry", imagesBuildRegistry, + "--target", imagesBuildTarget, + "--tag", imagesBuildTag, + } + if imagesBuildPush { + scriptArgs = append(scriptArgs, "--push") + } + if imagesBuildPlatform != "" { + scriptArgs = append(scriptArgs, "--platform", imagesBuildPlatform) + } + + bashPath, err := exec.LookPath("bash") + if err != nil { + return fmt.Errorf("bash not found in PATH: %w", err) + } + + buildCmd := exec.Command(bashPath, scriptArgs...) + buildCmd.Dir = sourceDir + buildCmd.Stdout = os.Stdout + buildCmd.Stderr = os.Stderr + + if err := buildCmd.Run(); err != nil { + return fmt.Errorf("image build failed: %w", err) + } + + return nil +} diff --git a/cmd/images_test.go b/cmd/images_test.go new file mode 100644 index 000000000..0af48fe14 --- /dev/null +++ b/cmd/images_test.go @@ -0,0 +1,175 @@ +// Copyright 2026 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestImagesBuildCommandRegistered(t *testing.T) { + // Verify "scion images" is registered on root + found := false + for _, c := range rootCmd.Commands() { + if c.Name() == "images" { + found = true + break + } + } + assert.True(t, found, "images command should be registered on rootCmd") + + // Verify "scion images build" is registered under images + buildFound := false + for _, c := range imagesCmd.Commands() { + if c.Name() == "build" { + buildFound = true + break + } + } + assert.True(t, buildFound, "build subcommand should be registered under imagesCmd") +} + +func TestImagesBuildFlagsRegistered(t *testing.T) { + assert.NotNil(t, imagesBuildCmd.Flags().Lookup("registry")) + assert.NotNil(t, imagesBuildCmd.Flags().Lookup("target")) + assert.NotNil(t, imagesBuildCmd.Flags().Lookup("push")) + assert.NotNil(t, imagesBuildCmd.Flags().Lookup("platform")) + assert.NotNil(t, imagesBuildCmd.Flags().Lookup("tag")) + assert.NotNil(t, imagesBuildCmd.Flags().Lookup("source")) + + // --registry is required + ann := imagesBuildCmd.Flags().Lookup("registry").Annotations + _, required := ann[cobra_requiredAnnotation] + assert.True(t, required, "--registry should be marked as required") +} + +// cobra_requiredAnnotation is the annotation key cobra uses to mark required flags. +const cobra_requiredAnnotation = "cobra_annotation_bash_completion_one_required_flag" + +func TestImagesBuildMissingScript(t *testing.T) { + // Point source to a temp dir that has no image-build subdirectory + tmpDir := t.TempDir() + + orig := imagesBuildSource + defer func() { imagesBuildSource = orig }() + imagesBuildSource = tmpDir + + origRegistry := imagesBuildRegistry + defer func() { imagesBuildRegistry = origRegistry }() + imagesBuildRegistry = "ghcr.io/test" + + err := runImagesBuild() + require.Error(t, err) + assert.Contains(t, err.Error(), "build script not found") + assert.Contains(t, err.Error(), "image-build/scripts/build-images.sh") + assert.Contains(t, err.Error(), "--source") +} + +func TestImagesBuildInvokesScript(t *testing.T) { + // Create a fake source tree with a stub build-images.sh that records its args + tmpDir := t.TempDir() + scriptDir := filepath.Join(tmpDir, "image-build", "scripts") + require.NoError(t, os.MkdirAll(scriptDir, 0755)) + + // Write a stub script that writes its arguments to a file + argsFile := filepath.Join(tmpDir, "captured-args") + stubScript := "#!/bin/bash\necho \"$@\" > " + argsFile + "\n" + scriptPath := filepath.Join(scriptDir, "build-images.sh") + require.NoError(t, os.WriteFile(scriptPath, []byte(stubScript), 0755)) + + // Save and restore flag state + orig := struct { + source, registry, target, tag, platform string + push bool + }{imagesBuildSource, imagesBuildRegistry, imagesBuildTarget, imagesBuildTag, imagesBuildPlatform, imagesBuildPush} + defer func() { + imagesBuildSource = orig.source + imagesBuildRegistry = orig.registry + imagesBuildTarget = orig.target + imagesBuildTag = orig.tag + imagesBuildPlatform = orig.platform + imagesBuildPush = orig.push + }() + + imagesBuildSource = tmpDir + imagesBuildRegistry = "ghcr.io/myorg" + imagesBuildTarget = "common" + imagesBuildTag = "latest" + imagesBuildPlatform = "" + imagesBuildPush = false + + err := runImagesBuild() + require.NoError(t, err) + + // Verify the script was called with correct args + captured, readErr := os.ReadFile(argsFile) + require.NoError(t, readErr, "stub script should have written args file") + + args := strings.TrimSpace(string(captured)) + assert.Contains(t, args, "--registry ghcr.io/myorg") + assert.Contains(t, args, "--target common") + assert.Contains(t, args, "--tag latest") + assert.NotContains(t, args, "--push") + assert.NotContains(t, args, "--platform") +} + +func TestImagesBuildWithPushAndPlatform(t *testing.T) { + tmpDir := t.TempDir() + scriptDir := filepath.Join(tmpDir, "image-build", "scripts") + require.NoError(t, os.MkdirAll(scriptDir, 0755)) + + argsFile := filepath.Join(tmpDir, "captured-args") + stubScript := "#!/bin/bash\necho \"$@\" > " + argsFile + "\n" + scriptPath := filepath.Join(scriptDir, "build-images.sh") + require.NoError(t, os.WriteFile(scriptPath, []byte(stubScript), 0755)) + + orig := struct { + source, registry, target, tag, platform string + push bool + }{imagesBuildSource, imagesBuildRegistry, imagesBuildTarget, imagesBuildTag, imagesBuildPlatform, imagesBuildPush} + defer func() { + imagesBuildSource = orig.source + imagesBuildRegistry = orig.registry + imagesBuildTarget = orig.target + imagesBuildTag = orig.tag + imagesBuildPlatform = orig.platform + imagesBuildPush = orig.push + }() + + imagesBuildSource = tmpDir + imagesBuildRegistry = "us-docker.pkg.dev/myproject/scion" + imagesBuildTarget = "all" + imagesBuildTag = "v1.0" + imagesBuildPlatform = "all" + imagesBuildPush = true + + err := runImagesBuild() + require.NoError(t, err) + + captured, readErr := os.ReadFile(argsFile) + require.NoError(t, readErr) + + args := strings.TrimSpace(string(captured)) + assert.Contains(t, args, "--registry us-docker.pkg.dev/myproject/scion") + assert.Contains(t, args, "--target all") + assert.Contains(t, args, "--tag v1.0") + assert.Contains(t, args, "--push") + assert.Contains(t, args, "--platform all") +} diff --git a/cmd/root.go b/cmd/root.go index 4ad914ec3..cccb9d33e 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -76,6 +76,8 @@ return an error instead of blocking.`, // - help, version, completion (built-in or explicit) // - init, grove init (creates grove) // - server (runs hub server, doesn't need local grove) + // - doctor (diagnostic tool) + // - images (builds container images, used before grove/registry exist) cmdName := cmd.Name() parentName := "" if cmd.Parent() != nil { @@ -84,7 +86,7 @@ return an error instead of blocking.`, requiresGrove := true switch cmdName { - case "help", "version", "completion", "server", "doctor": + case "help", "version", "completion", "server", "doctor", "images": requiresGrove = false case "init": // Both top-level init and grove init don't require existing grove @@ -94,7 +96,7 @@ return an error instead of blocking.`, requiresGrove = false } // Grove subcommands operate on all groves, not just the current one - if parentName == "grove" { + if parentName == "grove" || parentName == "images" { requiresGrove = false } diff --git a/docs-site/src/content/docs/advanced-local/custom-images.md b/docs-site/src/content/docs/advanced-local/custom-images.md index b93f08ec6..e47147e16 100644 --- a/docs-site/src/content/docs/advanced-local/custom-images.md +++ b/docs-site/src/content/docs/advanced-local/custom-images.md @@ -34,12 +34,14 @@ Build images locally and push to your registry: ```bash # Build scion-base + all harness images, then push -image-build/scripts/build-images.sh --registry ghcr.io/myorg --push +scion images build --registry ghcr.io/myorg --push # Configure Scion to use them scion config set image_registry ghcr.io/myorg ``` +`scion images build` must be run from within a clone of the Scion source repository (or pass `--source `). It wraps `image-build/scripts/build-images.sh` and accepts the same flags — see [Build Script Reference](#build-script-reference) below. + ### Option 2: GitHub Actions (GHCR) If your project is hosted on GitHub: diff --git a/docs-site/src/content/docs/getting-started/install.md b/docs-site/src/content/docs/getting-started/install.md index 148a27a90..09cf3df83 100644 --- a/docs-site/src/content/docs/getting-started/install.md +++ b/docs-site/src/content/docs/getting-started/install.md @@ -86,6 +86,13 @@ The easiest way to get these images is to fork this repo, and then go to the "Ac You will then use your `ghcr.io/myorg` registry for the scion setting. +If you have the repository cloned locally, you can also build directly with the CLI: + +```bash +scion images build --registry ghcr.io/myorg --push +scion config set image_registry ghcr.io/myorg +``` + See [Building Containers](/scion/advanced-local/custom-images/) for more details ## Configuration diff --git a/docs-site/src/content/docs/reference/cli.md b/docs-site/src/content/docs/reference/cli.md index 473ab27c9..2f88a9242 100644 --- a/docs-site/src/content/docs/reference/cli.md +++ b/docs-site/src/content/docs/reference/cli.md @@ -247,6 +247,45 @@ Manages Scion server components (Hub and Broker). - `scion server start`: Start one or more server components. - Flags: `--enable-hub`, `--enable-runtime-broker`, `--port`, `--db`, `--dev-auth`. +## Image Management + +### `scion images` + +Commands for building and managing Scion container images. + +#### `scion images build` + +Builds Scion container images locally using `docker buildx`. Must be run from within a clone of the Scion source repository (or use `--source`). + +**Usage:** `scion images build --registry [flags]` + +- **Flags:** + - `--registry `: **(Required)** Target registry path (e.g., `ghcr.io/myorg`). + - `--target `: Build target — `common` (scion-base + harnesses), `all` (full rebuild including core-base), `core-base`, or `harnesses`. (default: `common`) + - `--push`: Push images to the registry after building. + - `--platform `: Target platform(s). Use `all` for `linux/amd64,linux/arm64`, or specify directly. + - `--tag `: Image tag. (default: `latest`) + - `--source `: Path to a Scion source repository clone. (default: current directory) + +**Example:** +```bash +# From within a clone of the scion repo +scion images build --registry ghcr.io/myorg --push + +# With all options +scion images build \ + --registry ghcr.io/myorg \ + --target all \ + --platform all \ + --tag v1.0 \ + --push +``` + +After building, configure Scion to use the registry: +```bash +scion config set image_registry ghcr.io/myorg +``` + ## Miscellaneous ### `scion version`