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)
}
Comment on lines +165 to +167

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Writing directly to config.yaml using os.WriteFile can leave the file truncated or corrupted if the write operation is interrupted or fails (e.g., due to a full disk or process termination).

To ensure robustness and prevent file corruption, perform an atomic write by writing the updated data to a temporary file in the same directory first, and then renaming it to the target path using os.Rename.

		tmpConfigPath := configPath + ".tmp"
		if err := os.WriteFile(tmpConfigPath, updatedData, 0644); err != nil {
			return fmt.Errorf("failed to write temporary config.yaml: %w", err)
		}
		if err := os.Rename(tmpConfigPath, configPath); err != nil {
			_ = os.Remove(tmpConfigPath)
			return fmt.Errorf("failed to update config.yaml atomically: %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")
}
10 changes: 10 additions & 0 deletions cmd/cli_mode.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,16 @@ var agentAllowed = map[string]bool{
"template.push": true,
"template.pull": true,
"template.status": true,
"harness-config": true,
"harness-config.list": true,
"harness-config.show": true,
"harness-config.install": true,
"harness-config.sync": true,
"harness-config.push": true,
"harness-config.pull": true,
"harness-config.delete": true,
"harness-config.reset": true,
"harness-config.upgrade": true,
}

// resolveMode determines the active CLI mode from environment and settings.
Expand Down
7 changes: 5 additions & 2 deletions cmd/cli_mode_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,7 @@ func TestApplyModeRestrictions_Agent(t *testing.T) {
// These commands should be present in agent mode
expected := []string{
"create", "delete",
"harness-config", "harness-config.install", "harness-config.list",
"help",
"list", "logs", "look",
"message",
Expand Down Expand Up @@ -304,7 +305,7 @@ func TestApplyModeRestrictions_Agent(t *testing.T) {
// These should be removed
absent := []string{
"attach", "broker", "cdw", "clean", "completion", "config", "doctor",
"grove", "harness-config", "hub",
"grove", "hub",
"init", "messages", "restore", "server", "sync",
}
for _, cmd := range absent {
Expand Down Expand Up @@ -423,6 +424,9 @@ func TestAgentAllowedList(t *testing.T) {
"template", "template.list", "template.show", "template.clone",
"template.delete", "template.import", "template.sync",
"template.push", "template.pull", "template.status",
"harness-config", "harness-config.list", "harness-config.show", "harness-config.install",
"harness-config.sync", "harness-config.push", "harness-config.pull",
"harness-config.delete", "harness-config.reset", "harness-config.upgrade",
}
for _, path := range expectedAllowed {
assert.True(t, agentAllowed[path], "agentAllowed should contain %s", path)
Expand All @@ -432,7 +436,6 @@ func TestAgentAllowedList(t *testing.T) {
"attach", "restore", "sync", "clean", "cdw", "init",
"completion", "config", "doctor", "hub", "messages",
"server", "broker", "grove",
"harness-config",
"config.set", "config.validate", "config.migrate",
"config.list", "config.get", "config.dir", "config.schema",
"hub.enable", "hub.disable", "hub.link", "hub.unlink",
Expand Down
69 changes: 61 additions & 8 deletions cmd/server_foreground.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ import (
"github.com/GoogleCloudPlatform/scion/pkg/harness"
"github.com/GoogleCloudPlatform/scion/pkg/hub"
"github.com/GoogleCloudPlatform/scion/pkg/observability/dbmetrics"
"github.com/GoogleCloudPlatform/scion/pkg/observability/dispatchmetrics"
"github.com/GoogleCloudPlatform/scion/pkg/observability/hubmetrics"
scionplugin "github.com/GoogleCloudPlatform/scion/pkg/plugin"
"github.com/GoogleCloudPlatform/scion/pkg/runtime"
"github.com/GoogleCloudPlatform/scion/pkg/runtimebroker"
Expand Down Expand Up @@ -211,6 +213,7 @@ func runServerStart(cmd *cobra.Command, args []string) error {
// 11. Start Hub
var hubSrv *hub.Server
var secretBackend secret.SecretBackend
var hubDBRec dbmetrics.Recorder
if enableHub {
// Initialize secret backend early so signing keys can be loaded from it
// during hub server creation. This prevents the previous bug where
Expand All @@ -232,13 +235,62 @@ func runServerStart(cmd *cobra.Command, args []string) error {
log.Fatalf("Hub server failed to start: %v", hubInitErr)
}

// Wire hub OTel metrics export to Cloud Monitoring.
if cfg.Hub.GCPProjectID != "" {
mp, mpErr := hubmetrics.NewMeterProvider(ctx, cfg.Hub.GCPProjectID,
hubmetrics.WithHubID(hubSrv.HubID()),
)
if mpErr != nil {
log.Printf("WARNING: hub metrics export disabled: %v", mpErr)
} else {
defer func() {
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = mp.Shutdown(shutdownCtx)
}()

dbRec, dbErr := dbmetrics.New(mp)
if dbErr != nil {
log.Printf("WARNING: hub db metrics disabled: %v", dbErr)
} else {
hubDBRec = dbRec
hubSrv.SetDBMetrics(dbRec)
}

dispRec, dispErr := dispatchmetrics.New(mp)
if dispErr != nil {
log.Printf("WARNING: hub dispatch metrics disabled: %v", dispErr)
} else {
hubSrv.SetDispatchMetrics(dispRec)
}

if hubSrv.GetBrokerAuthService() != nil {
otelMetrics, otelAuthErr := hub.NewOTelMetricsRecorder(mp)
if otelAuthErr != nil {
log.Printf("WARNING: hub auth metrics OTel export disabled: %v", otelAuthErr)
} else {
hubSrv.SetMetrics(otelMetrics)
}
}

otelGCP, otelGCPErr := hub.NewOTelGCPTokenMetrics(mp)
if otelGCPErr != nil {
log.Printf("WARNING: hub GCP token metrics OTel export disabled: %v", otelGCPErr)
} else {
hubSrv.SetGCPTokenMetrics(otelGCP)
}

log.Printf("Hub OTel metrics export enabled (project: %s)", cfg.Hub.GCPProjectID)
}
}

// Wire command bus for cross-node dispatch (B2-4).
cmdBus := newCommandBus(ctx, cfg, hubSrv)
hubSrv.SetCommandBus(cmdBus)

if !enableWeb {
// Hub runs its own HTTP server (standalone mode).
eventPub := newEventPublisher(ctx, cfg)
eventPub := newEventPublisher(ctx, cfg, hubDBRec)
hubSrv.SetEventPublisher(eventPub)

log.Printf("Starting Hub API server on %s:%d", cfg.Hub.Host, cfg.Hub.Port)
Expand Down Expand Up @@ -266,7 +318,7 @@ func runServerStart(cmd *cobra.Command, args []string) error {
// 12. Start Web
var webSrv *hub.WebServer
if enableWeb {
webSrv = initWebServer(ctx, cfg, hubSrv, devAuthToken, adminEmailList, adminMode, maintenanceMessage, requestLogger)
webSrv = initWebServer(ctx, cfg, hubSrv, devAuthToken, adminEmailList, adminMode, maintenanceMessage, requestLogger, hubDBRec)

// In combined mode, start Hub background services now that the
// ChannelEventPublisher has been wired by initWebServer.
Expand Down Expand Up @@ -1167,11 +1219,12 @@ func initHubStorage(ctx context.Context, hubSrv *hub.Server, cfg *config.GlobalC
// ChannelEventPublisher. If the Postgres publisher cannot be started it falls
// back to the in-process publisher so a single instance still functions, logging
// a prominent warning since cross-replica SSE delivery will be unavailable.
func newEventPublisher(ctx context.Context, cfg *config.GlobalConfig) hub.EventPublisher {
func newEventPublisher(ctx context.Context, cfg *config.GlobalConfig, dbRec dbmetrics.Recorder) hub.EventPublisher {
if strings.EqualFold(cfg.Database.Driver, "postgres") {
// Metrics export is wired separately (see pkg/observability/dbmetrics);
// use a disabled recorder until a MeterProvider is configured.
pub, err := hub.NewPostgresEventPublisher(ctx, cfg.Database.URL, dbmetrics.NewDisabled(), logging.Subsystem("hub.events"))
if dbRec == nil {
dbRec = dbmetrics.NewDisabled()
}
pub, err := hub.NewPostgresEventPublisher(ctx, cfg.Database.URL, dbRec, logging.Subsystem("hub.events"))
if err != nil {
log.Printf("WARNING: failed to start Postgres event publisher (%v); falling back to in-process events. Cross-replica SSE will not work.", err)
return hub.NewChannelEventPublisher()
Expand Down Expand Up @@ -1208,7 +1261,7 @@ func newCommandBus(ctx context.Context, cfg *config.GlobalConfig, hubSrv *hub.Se
// initWebServer creates and configures the Web server. The provided context is
// threaded to the event publisher so that the Postgres LISTEN/NOTIFY goroutine
// is cancelled cleanly on shutdown, preventing connection leaks.
func initWebServer(ctx context.Context, cfg *config.GlobalConfig, hubSrv *hub.Server, devAuthToken string, adminEmailList []string, adminMode bool, maintenanceMessage string, requestLogger *slog.Logger) *hub.WebServer {
func initWebServer(ctx context.Context, cfg *config.GlobalConfig, hubSrv *hub.Server, devAuthToken string, adminEmailList []string, adminMode bool, maintenanceMessage string, requestLogger *slog.Logger, dbRec dbmetrics.Recorder) *hub.WebServer {
webHost := cfg.Hub.Host
if webHost == "" {
webHost = "0.0.0.0"
Expand Down Expand Up @@ -1264,7 +1317,7 @@ func initWebServer(ctx context.Context, cfg *config.GlobalConfig, hubSrv *hub.Se
webSrv.SetRequestLogger(requestLogger)

// Create shared event publisher for real-time SSE
eventPub := newEventPublisher(ctx, cfg)
eventPub := newEventPublisher(ctx, cfg, dbRec)
webSrv.SetEventPublisher(eventPub)

// Wire Hub services into WebServer if Hub is enabled
Expand Down
Loading
Loading