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
264 changes: 206 additions & 58 deletions cmd/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
buildPush bool
buildPlatform string
buildDryRun bool
buildBuilder string
)

var buildCmd = &cobra.Command{
Expand Down Expand Up @@ -66,25 +67,30 @@
tag := buildTag

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

imageRegistry := ""
if settings != nil {
imageRegistry = settings.ResolveImageRegistry(profile)
}

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

if buildBuilder == "cloud-build" {
return runCloudBuild(cmd, harnessConfigName, hcDir, tag, baseImage, imageRegistry, settings)
}

runtimeBin := runtime.DetectContainerRuntime()
if runtimeBin == "" {
return fmt.Errorf("no container runtime found (tried docker, podman)")
Expand All @@ -101,10 +107,6 @@

outputImage := imageBaseName + ":" + tag
if buildPush {
imageRegistry := ""
if settings != nil {
imageRegistry = settings.ResolveImageRegistry(profile)
}
if imageRegistry == "" {
return fmt.Errorf("--push requires image_registry to be configured")
}
Expand Down Expand Up @@ -141,63 +143,208 @@
}
}

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},
)
}
updateBuildConfigAndSync(harnessConfigName, hcDir, outputImage)

return nil
},
}

type cloudBuildConfig struct {
Steps []cloudBuildStep `yaml:"steps"`
Options cloudBuildOptions `yaml:"options"`
Timeout string `yaml:"timeout"`
}

type cloudBuildStep struct {
Name string `yaml:"name"`
ID string `yaml:"id"`
Args []string `yaml:"args"`
Env []string `yaml:"env,omitempty"`
}

type cloudBuildOptions struct {
DynamicSubstitutions bool `yaml:"dynamicSubstitutions"`
MachineType string `yaml:"machineType"`
}

// runCloudBuild executes the build via gcloud builds submit.
func runCloudBuild(cmd *cobra.Command, harnessConfigName string, hcDir *config.HarnessConfigDir, tag, baseImage, imageRegistry string, settings *config.VersionedSettings) error {
if _, err := exec.LookPath("gcloud"); err != nil {
return fmt.Errorf("--builder cloud-build requires gcloud CLI to be installed and in PATH")
}
if imageRegistry == "" {
return fmt.Errorf("--builder cloud-build requires image_registry to be configured")
}

gcpProject := resolveGCPProject(settings)
if gcpProject == "" {
return fmt.Errorf("no GCP project configured; set gcp_project_id in settings or run 'gcloud config set project <project>'")
}

imageBaseName := harnessConfigName
if hcDir.Config.Image != "" {
name := hcDir.Config.Image
if colonIdx := strings.LastIndex(name, ":"); colonIdx >= 0 {
name = name[:colonIdx]
}
updatedData, err := yaml.Marshal(&doc)
if err != nil {
return fmt.Errorf("failed to marshal updated config.yaml: %w", err)
imageBaseName = name
}
outputImage := imageRegistry + "/" + imageBaseName + ":" + tag

platform := buildPlatform
if platform == "" {
platform = "linux/amd64,linux/arm64"
}

// Build the cloudbuild.yaml via structured marshaling.
cbConfig := cloudBuildConfig{
Steps: []cloudBuildStep{
{
Name: "gcr.io/cloud-builders/docker",
ID: "setup-buildx",
Args: []string{"buildx", "create", "--name", "builder", "--use"},
Env: []string{"DOCKER_CLI_EXPERIMENTAL=enabled"},
},
{
Name: "gcr.io/cloud-builders/docker",
ID: "bootstrap-buildx",
Args: []string{"buildx", "inspect", "--bootstrap"},
Env: []string{"DOCKER_CLI_EXPERIMENTAL=enabled"},
},
{
Name: "gcr.io/cloud-builders/docker",
ID: "build-image",
Args: []string{
"buildx", "build",
"--platform", platform,
"--build-arg", "BASE_IMAGE=" + baseImage,
"-t", outputImage,
"-f", "Dockerfile",
"--push", ".",
},
Env: []string{"DOCKER_CLI_EXPERIMENTAL=enabled"},
},
},
Options: cloudBuildOptions{
DynamicSubstitutions: true,
MachineType: "E2_HIGHCPU_8",
},
Timeout: "1200s",
}

cbYAML, err := yaml.Marshal(&cbConfig)
if err != nil {
return fmt.Errorf("failed to marshal cloudbuild config: %w", err)
}

tmpFile, err := os.CreateTemp("", "scion-cloudbuild-*.yaml")
if err != nil {
return fmt.Errorf("failed to create temp cloudbuild config: %w", err)
}
defer os.Remove(tmpFile.Name())

Check failure on line 244 in cmd/build.go

View workflow job for this annotation

GitHub Actions / golangci-lint

Error return value of `os.Remove` is not checked (errcheck)

if _, err := tmpFile.Write(cbYAML); err != nil {
tmpFile.Close()

Check failure on line 247 in cmd/build.go

View workflow job for this annotation

GitHub Actions / golangci-lint

Error return value of `tmpFile.Close` is not checked (errcheck)
return fmt.Errorf("failed to write cloudbuild config: %w", err)
}
tmpFile.Close()

Check failure on line 250 in cmd/build.go

View workflow job for this annotation

GitHub Actions / golangci-lint

Error return value of `tmpFile.Close` is not checked (errcheck)

if buildDryRun {
fmt.Printf("gcloud builds submit --project %s --config %s %s\n", gcpProject, tmpFile.Name(), hcDir.Path)
fmt.Printf("\n# cloudbuild.yaml contents:\n%s", cbYAML)
return nil
}

fmt.Printf("Submitting Cloud Build in project %s...\n", gcpProject)
fmt.Printf("Output image: %s\n", outputImage)

gcloudCmd := exec.CommandContext(cmd.Context(), "gcloud", "builds", "submit",
"--project", gcpProject,
"--config", tmpFile.Name(),
hcDir.Path)
gcloudCmd.Stdout = os.Stdout
gcloudCmd.Stderr = os.Stderr
if err := gcloudCmd.Run(); err != nil {
return fmt.Errorf("Cloud Build failed: %w", err)

Check failure on line 268 in cmd/build.go

View workflow job for this annotation

GitHub Actions / golangci-lint

ST1005: error strings should not be capitalized (staticcheck)
}

updateBuildConfigAndSync(harnessConfigName, hcDir, outputImage)

return nil
}

// resolveGCPProject returns the GCP project from settings or gcloud config.
func resolveGCPProject(settings *config.VersionedSettings) string {
if settings != nil && settings.Server != nil && settings.Server.Secrets != nil && settings.Server.Secrets.GCPProjectID != "" {
return settings.Server.Secrets.GCPProjectID
}
out, err := exec.Command("gcloud", "config", "get-value", "project").Output()
if err != nil {
return ""
}
return strings.TrimSpace(string(out))
}

// updateBuildConfigAndSync updates the harness config's config.yaml with the
// new image reference and syncs to Hub.
func updateBuildConfigAndSync(harnessConfigName string, hcDir *config.HarnessConfigDir, outputImage string) {
configPath := filepath.Join(hcDir.Path, "config.yaml")
configData, err := os.ReadFile(configPath)
if err != nil {
fmt.Printf("Warning: failed to read config.yaml for update: %v\n", err)
return
}
var doc yaml.Node
if err := yaml.Unmarshal(configData, &doc); err != nil {
fmt.Printf("Warning: failed to parse config.yaml: %v\n", err)
return
}
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 err := os.WriteFile(configPath, updatedData, 0644); err != nil {
return fmt.Errorf("failed to write updated config.yaml: %w", err)
if !found {
mapping.Content = append(mapping.Content,
&yaml.Node{Kind: yaml.ScalarNode, Value: "image"},
&yaml.Node{Kind: yaml.ScalarNode, Value: outputImage},
)
}
fmt.Printf("Updated %s image to %s\n", configPath, outputImage)
}
updatedData, err := yaml.Marshal(&doc)
if err != nil {
fmt.Printf("Warning: failed to marshal updated config.yaml: %v\n", err)
return
}
if err := os.WriteFile(configPath, updatedData, 0644); err != nil {
fmt.Printf("Warning: failed to write updated config.yaml: %v\n", err)
return
}
fmt.Printf("Updated %s image to %s\n", configPath, outputImage)

// Sync updated config to Hub so agents pick up the new image.
var gp string
if projectPath != "" {
if resolved, err := config.GetResolvedProjectDir(projectPath); err == nil {
gp = resolved
}
} else if resolved, err := config.GetResolvedProjectDir(""); err == nil {
var gp string
if projectPath != "" {
if resolved, err := config.GetResolvedProjectDir(projectPath); err == nil {
gp = resolved
}
hubCtx, hubErr := CheckHubAvailabilityWithOptions(gp, true)
if hubErr != nil {
fmt.Printf("Warning: could not sync to Hub: %v\n", hubErr)
} else if resolved, err := config.GetResolvedProjectDir(""); err == nil {
gp = resolved
}
hubCtx, hubErr := CheckHubAvailabilityWithOptions(gp, true)
if hubErr != nil {
fmt.Printf("Warning: could not sync to Hub: %v\n", hubErr)
fmt.Println("Run 'scion harness-config push " + harnessConfigName + "' to sync manually.")
} else if hubCtx != nil {
if err := syncHarnessConfigToHub(hubCtx, harnessConfigName, hcDir.Path, "global", "", hcDir.Config.Harness); err != nil {
fmt.Printf("Warning: failed to sync to Hub: %v\n", err)
fmt.Println("Run 'scion harness-config push " + harnessConfigName + "' to sync manually.")
} else if hubCtx != nil {
if err := syncHarnessConfigToHub(hubCtx, harnessConfigName, hcDir.Path, "global", "", hcDir.Config.Harness); err != nil {
fmt.Printf("Warning: failed to sync to Hub: %v\n", err)
fmt.Println("Run 'scion harness-config push " + harnessConfigName + "' to sync manually.")
}
}

return nil
},
}
}

func init() {
Expand All @@ -207,4 +354,5 @@
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")
buildCmd.Flags().StringVar(&buildBuilder, "builder", "local", "Build backend: local or cloud-build")
}
Loading
Loading