diff --git a/cmd/build.go b/cmd/build.go new file mode 100644 index 000000000..267fbdfea --- /dev/null +++ b/cmd/build.go @@ -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 ", + 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") +} diff --git a/pkg/config/harness_config.go b/pkg/config/harness_config.go index 5745df0b5..9b1f21588 100644 --- a/pkg/config/harness_config.go +++ b/pkg/config/harness_config.go @@ -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, diff --git a/pkg/config/harness_config_test.go b/pkg/config/harness_config_test.go index fef66bbda..4db66725e 100644 --- a/pkg/config/harness_config_test.go +++ b/pkg/config/harness_config_test.go @@ -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) } @@ -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) } diff --git a/pkg/hub/maintenance_executors.go b/pkg/hub/maintenance_executors.go index 7ec561299..cb303e3f4 100644 --- a/pkg/hub/maintenance_executors.go +++ b/pkg/hub/maintenance_executors.go @@ -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" @@ -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)") @@ -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 diff --git a/pkg/runtime/container.go b/pkg/runtime/container.go new file mode 100644 index 000000000..c7ad32e65 --- /dev/null +++ b/pkg/runtime/container.go @@ -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 "" +}