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`