Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 129 additions & 0 deletions cmd/images.go
Original file line number Diff line number Diff line change
@@ -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 <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 <path> 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
}
175 changes: 175 additions & 0 deletions cmd/images_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
6 changes: 4 additions & 2 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -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
}

Expand Down
4 changes: 3 additions & 1 deletion docs-site/src/content/docs/advanced-local/custom-images.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path>`). 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:
Expand Down
7 changes: 7 additions & 0 deletions docs-site/src/content/docs/getting-started/install.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading