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
181 changes: 181 additions & 0 deletions cmd/build.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
// 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"
"strings"

"github.com/GoogleCloudPlatform/scion/pkg/config"
"github.com/GoogleCloudPlatform/scion/pkg/runtime"
"github.com/spf13/cobra"
"gopkg.in/yaml.v3"
)

var (
buildTag string
buildBaseImage string
buildPush bool
buildPlatform string
buildDryRun bool
)

var buildCmd = &cobra.Command{
Use: "build <harness-config-name>",
Short: "Build a container image from a harness-config Dockerfile",
Long: `Build a container image from a Dockerfile bundled inside a harness-config directory.

The base image is resolved from the image_registry setting unless --base-image
is provided. After a successful build the harness-config's config.yaml image
field is updated to reference the built image.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
harnessConfigName := args[0]

hcDir, err := config.FindHarnessConfigDir(harnessConfigName, projectPath)
if err != nil {
return fmt.Errorf("harness-config %q not found: %w", harnessConfigName, err)
}
if hcDir.Path == "" {
return fmt.Errorf("harness-config %q does not have a local directory path", harnessConfigName)
}

dockerfilePath := filepath.Join(hcDir.Path, "Dockerfile")
if _, err := os.Stat(dockerfilePath); err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("harness-config %q does not contain a Dockerfile", harnessConfigName)
}
return fmt.Errorf("cannot access Dockerfile in harness-config %q: %w", harnessConfigName, err)
}

tag := buildTag

var settings *config.VersionedSettings
if buildBaseImage == "" || buildPush {
settings, _, err = config.LoadEffectiveSettings(projectPath)
if err != nil {
return fmt.Errorf("failed to load settings: %w", err)
}
}

baseImage := buildBaseImage
if baseImage == "" {
imageRegistry := ""
if settings != nil {
imageRegistry = settings.ResolveImageRegistry(profile)
}
baseImage = "scion-base:" + tag
if imageRegistry != "" {
baseImage = imageRegistry + "/scion-base:" + tag
}
}

runtimeBin := runtime.DetectContainerRuntime()
if runtimeBin == "" {
return fmt.Errorf("no container runtime found (tried docker, podman)")
}

outputImage := harnessConfigName + ":" + tag
if buildPush {
imageRegistry := ""
if settings != nil {
imageRegistry = settings.ResolveImageRegistry(profile)
}
if imageRegistry == "" {
return fmt.Errorf("--push requires image_registry to be configured")
}
outputImage = imageRegistry + "/" + harnessConfigName + ":" + tag
}

buildArgs := []string{"build",
"--build-arg", "BASE_IMAGE=" + baseImage,
"-t", outputImage,
}
if buildPlatform != "" {
buildArgs = append(buildArgs, "--platform", buildPlatform)
}
buildArgs = append(buildArgs, hcDir.Path)

if buildDryRun {
fmt.Println(runtimeBin + " " + strings.Join(buildArgs, " "))
return nil
}

buildExec := exec.CommandContext(cmd.Context(), runtimeBin, buildArgs...)
buildExec.Stdout = os.Stdout
buildExec.Stderr = os.Stderr
if err := buildExec.Run(); err != nil {
return fmt.Errorf("build failed: %w", err)
}

if buildPush {
pushExec := exec.CommandContext(cmd.Context(), runtimeBin, "push", outputImage)
pushExec.Stdout = os.Stdout
pushExec.Stderr = os.Stderr
if err := pushExec.Run(); err != nil {
return fmt.Errorf("push failed: %w", err)
}
}

configPath := filepath.Join(hcDir.Path, "config.yaml")
configData, err := os.ReadFile(configPath)
if err != nil {
return fmt.Errorf("failed to read config.yaml for update: %w", err)
}
var doc yaml.Node
if err := yaml.Unmarshal(configData, &doc); err != nil {
return fmt.Errorf("failed to parse config.yaml: %w", err)
}
if len(doc.Content) > 0 && doc.Content[0].Kind == yaml.MappingNode {
mapping := doc.Content[0]
found := false
for i := 0; i < len(mapping.Content)-1; i += 2 {
if mapping.Content[i].Value == "image" {
mapping.Content[i+1].Value = outputImage
found = true
break
}
}
if !found {
mapping.Content = append(mapping.Content,
&yaml.Node{Kind: yaml.ScalarNode, Value: "image"},
&yaml.Node{Kind: yaml.ScalarNode, Value: outputImage},
)
}
}
updatedData, err := yaml.Marshal(&doc)
if err != nil {
return fmt.Errorf("failed to marshal updated config.yaml: %w", err)
}
if err := os.WriteFile(configPath, updatedData, 0644); err != nil {
return fmt.Errorf("failed to write updated config.yaml: %w", err)
}
fmt.Printf("Updated %s image to %s\n", configPath, outputImage)

return nil
},
}

func init() {
rootCmd.AddCommand(buildCmd)
buildCmd.Flags().StringVar(&buildTag, "tag", "latest", "Image tag")
buildCmd.Flags().StringVar(&buildBaseImage, "base-image", "", "Override the base image (skips image_registry resolution)")
buildCmd.Flags().BoolVar(&buildPush, "push", false, "Push built image to image_registry after building")
buildCmd.Flags().StringVar(&buildPlatform, "platform", "", "Target platform (default: current architecture)")
buildCmd.Flags().BoolVar(&buildDryRun, "dry-run", false, "Show the docker build command without executing")
}
1 change: 0 additions & 1 deletion pkg/config/harness_config.go
Original file line number Diff line number Diff line change
Expand Up @@ -346,7 +346,6 @@ func ComputeHarnessConfigRevision(dirPath string) string {
}
var hashes []fileHash
skipBasenames := map[string]bool{
"Dockerfile": true,
"cloudbuild.yaml": true,
"README.md": true,
".gitkeep": true,
Expand Down
10 changes: 9 additions & 1 deletion pkg/config/harness_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -510,7 +510,7 @@ func TestComputeHarnessConfigRevision_SkipsNonRuntimeFiles(t *testing.T) {
t.Fatal("expected non-empty revision")
}

for _, skip := range []string{"Dockerfile", "cloudbuild.yaml", "README.md", ".gitkeep"} {
for _, skip := range []string{"cloudbuild.yaml", "README.md", ".gitkeep"} {
if err := os.WriteFile(filepath.Join(dir, skip), []byte("should be ignored"), 0644); err != nil {
t.Fatal(err)
}
Expand All @@ -520,6 +520,14 @@ func TestComputeHarnessConfigRevision_SkipsNonRuntimeFiles(t *testing.T) {
t.Errorf("adding non-runtime files changed revision: %s -> %s", baseRev, afterSkipped)
}

if err := os.WriteFile(filepath.Join(dir, "Dockerfile"), []byte("FROM scratch"), 0644); err != nil {
t.Fatal(err)
}
afterDockerfile := ComputeHarnessConfigRevision(dir)
if afterDockerfile == afterSkipped {
t.Error("adding Dockerfile should change revision")
}

if err := os.WriteFile(filepath.Join(dir, "config.yaml"), []byte("harness: opencode\nimage: new\n"), 0644); err != nil {
t.Fatal(err)
}
Expand Down
13 changes: 2 additions & 11 deletions pkg/hub/maintenance_executors.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import (
"runtime"
"strings"

scionruntime "github.com/GoogleCloudPlatform/scion/pkg/runtime"
"github.com/GoogleCloudPlatform/scion/pkg/secret"
"github.com/GoogleCloudPlatform/scion/pkg/store"
"github.com/GoogleCloudPlatform/scion/pkg/util/logging"
Expand Down Expand Up @@ -172,7 +173,7 @@ func (e *PullImagesExecutor) Run(ctx context.Context, logger io.Writer, params m

runtimeBin := e.runtimeBin
if runtimeBin == "" {
runtimeBin = detectContainerRuntime()
runtimeBin = scionruntime.DetectContainerRuntime()
}
if runtimeBin == "" {
return fmt.Errorf("no container runtime found (tried docker, podman)")
Expand Down Expand Up @@ -220,16 +221,6 @@ func (e *PullImagesExecutor) Run(ctx context.Context, logger io.Writer, params m
return nil
}

// detectContainerRuntime finds an available container CLI on the system.
func detectContainerRuntime() string {
for _, bin := range []string{"docker", "podman"} {
if p, err := exec.LookPath(bin); err == nil && p != "" {
return bin
}
}
return ""
}

// RebuildServerExecutor rebuilds the server binary from git and restarts via systemd.
type RebuildServerExecutor struct {
repoPath string // path to scion source checkout
Expand Down
28 changes: 28 additions & 0 deletions pkg/runtime/container.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
// 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 runtime

import "os/exec"

// DetectContainerRuntime finds an available container CLI (docker or podman).
// Returns the binary name, or "" if neither is found.
func DetectContainerRuntime() string {
for _, bin := range []string{"docker", "podman"} {
if p, err := exec.LookPath(bin); err == nil && p != "" {
return bin
}
}
return ""
}
Loading