diff --git a/cmd/bob/build.go b/cmd/bob/build.go index 805b355..ffc400a 100644 --- a/cmd/bob/build.go +++ b/cmd/bob/build.go @@ -39,6 +39,7 @@ func newBuildCmd() *cobra.Command { var pvcName string var downloadDir string var skipVerify bool + var force bool cmd := &cobra.Command{ Use: "build [name]", @@ -50,13 +51,15 @@ func newBuildCmd() *cobra.Command { bob build body-ecu-nucleo # re-trigger existing BuildJob bob build body-ecu-mpu-hostlike --local # sync . and build bob build body-ecu-mpu-hostlike --local --source ~/code # sync specific dir - bob build body-ecu-nucleo -d ./out # build and download artifacts + bob build body-ecu-nucleo -d ./out # download artifacts (rebuild only if needed) + bob build body-ecu-nucleo -d ./out --force # force rebuild and download When --local is used, the BuildJob is temporarily switched to use your local source. The next build without --local automatically restores git source. -When -d is used, the CLI waits for the build to complete and automatically -downloads the resulting artifacts to the specified directory.`, +When -d is used, the CLI checks if the build already succeeded. If so, it +downloads the existing artifacts without retriggering. Use --force to rebuild +regardless.`, RunE: func(cmd *cobra.Command, args []string) error { ns := firstNonEmpty(bobNamespace, os.Getenv("BOB_NAMESPACE"), "bob-builds") if local { @@ -96,9 +99,17 @@ downloads the resulting artifacts to the specified directory.`, if len(args) == 0 { return fmt.Errorf("provide a BuildJob name or use -f ") } - if err := autoRestoreIfLocal(ns, args[0]); err != nil { + restored, err := autoRestoreIfLocal(ns, args[0]) + if err != nil { return fmt.Errorf("restoring git source: %w", err) } + if downloadDir != "" && !force && !restored { + if skipped, err := downloadIfUpToDate(cmd.Context(), args[0], downloadDir, skipVerify); err != nil { + return err + } else if skipped { + return nil + } + } if err := retrigger(cmd.Context(), args[0]); err != nil { return err } @@ -116,6 +127,7 @@ downloads the resulting artifacts to the specified directory.`, cmd.Flags().StringVar(&pvcName, "pvc", "source-code", "PVC name for local source upload") cmd.Flags().StringVarP(&downloadDir, "download", "d", "", "Wait for build to finish and download artifacts to this directory") cmd.Flags().BoolVar(&skipVerify, "skip-verify", false, "Skip cosign signature verification on download") + cmd.Flags().BoolVar(&force, "force", false, "Force a new build even if the last one succeeded") return cmd } @@ -183,17 +195,17 @@ func createFromFile(ctx context.Context, path string, branch string) (string, er return result.Name, nil } -func autoRestoreIfLocal(namespace, bjName string) error { +func autoRestoreIfLocal(namespace, bjName string) (restored bool, err error) { kubecli := detectKubeClient() if kubecli == "" { - return nil + return false, nil } out, _ := exec.Command(kubecli, "get", "buildjob", bjName, "-n", namespace, "-o", `jsonpath={.metadata.annotations.builder\.sdv\.cloud\.redhat\.com/original-source}`).CombinedOutput() if len(out) > 0 && string(out) != "" { - return restoreGitSource(kubecli, namespace, bjName) + return true, restoreGitSource(kubecli, namespace, bjName) } - return nil + return false, nil } func retrigger(ctx context.Context, name string) error { diff --git a/cmd/bob/wait.go b/cmd/bob/wait.go index e37d833..3367cd8 100644 --- a/cmd/bob/wait.go +++ b/cmd/bob/wait.go @@ -27,8 +27,39 @@ import ( const ( buildPollInterval = 5 * time.Second buildStartupTimeout = 2 * time.Minute + + phaseSucceeded = "Succeeded" + phaseFailed = "Failed" + phaseRunning = "Running" + phasePending = "Pending" ) +// downloadIfUpToDate checks if the build already succeeded and downloads +// artifacts without triggering a new run. Returns (true, nil) if artifacts +// were downloaded, (false, nil) if a new build is needed. +func downloadIfUpToDate(ctx context.Context, name, downloadDir string, skipVerify bool) (bool, error) { + c := newClient() + build, err := c.Get(ctx, name) + if err != nil { + return false, fmt.Errorf("getting build status: %w", err) + } + + if build.Phase != phaseSucceeded { + return false, nil + } + + fmt.Printf("Build %q already up-to-date, skipping rebuild.\n", name) + fmt.Printf(" Run: %s\n", build.PipelineRun) + if build.CommitSHA != "" { + fmt.Printf(" Commit: %s\n", build.CommitSHA) + } + if build.Image != "" { + fmt.Printf(" Image: %s\n", build.Image) + } + fmt.Println() + return true, downloadBuildArtifacts(ctx, c, build, downloadDir, skipVerify) +} + func waitAndDownload(ctx context.Context, name, downloadDir string, skipVerify bool) error { c := newClient() @@ -60,10 +91,10 @@ func waitAndDownload(ctx context.Context, name, downloadDir string, skipVerify b case build.PipelineRun != previousRun: buildStarted = true fmt.Printf(" New PipelineRun: %s\n", build.PipelineRun) - case build.Phase == "Running" || build.Phase == "Pending": + case build.Phase == phaseRunning || build.Phase == phasePending: buildStarted = true fmt.Printf(" PipelineRun: %s\n", build.PipelineRun) - case build.Phase == "Succeeded" || build.Phase == "Failed": + case build.Phase == phaseSucceeded || build.Phase == phaseFailed: if time.Since(start) > buildStartupTimeout { fmt.Printf(" Build already completed (run %s), downloading existing artifacts.\n", build.PipelineRun) buildStarted = true @@ -86,10 +117,10 @@ func waitAndDownload(ctx context.Context, name, downloadDir string, skipVerify b } switch build.Phase { - case "Succeeded": + case phaseSucceeded: fmt.Printf("\nBuild succeeded in %s\n", time.Since(start).Truncate(time.Second)) return downloadBuildArtifacts(ctx, c, build, downloadDir, skipVerify) - case "Failed": + case phaseFailed: fmt.Fprintf(os.Stderr, "\nBuild failed after %s\n", time.Since(start).Truncate(time.Second)) fmt.Fprintf(os.Stderr, "View logs with: bob logs %s\n", name) return fmt.Errorf("build %q failed", name) diff --git a/cmd/bob/wait_test.go b/cmd/bob/wait_test.go index 4fac02d..d1f9286 100644 --- a/cmd/bob/wait_test.go +++ b/cmd/bob/wait_test.go @@ -2,6 +2,7 @@ package main import ( "testing" + "time" ) func TestNewBuildCmd_HasDownloadFlag(t *testing.T) { @@ -21,8 +22,29 @@ func TestNewBuildCmd_HasDownloadFlag(t *testing.T) { } } +func TestNewBuildCmd_HasForceFlag(t *testing.T) { + cmd := newBuildCmd() + + f := cmd.Flags().Lookup("force") + if f == nil { + t.Fatal("expected --force flag") + } + if f.DefValue != "false" { + t.Errorf("expected default false, got %s", f.DefValue) + } +} + func TestBuildPollInterval(t *testing.T) { if buildPollInterval <= 0 { t.Fatal("poll interval should be positive") } } + +func TestBuildStartupTimeout(t *testing.T) { + if buildStartupTimeout < 1*time.Minute { + t.Fatal("startup timeout should be at least 1 minute") + } + if buildStartupTimeout > 5*time.Minute { + t.Fatal("startup timeout should not exceed 5 minutes") + } +}