From 9df492408b11934b141e71247e46b3d8bd02a4b4 Mon Sep 17 00:00:00 2001 From: Benny Zlotnik Date: Fri, 13 Feb 2026 20:29:27 +0200 Subject: [PATCH 1/6] caib: improve build flow Signed-off-by: Benny Zlotnik --- cmd/caib/main.go | 69 +++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 66 insertions(+), 3 deletions(-) diff --git a/cmd/caib/main.go b/cmd/caib/main.go index 501315b0..71a3a623 100644 --- a/cmd/caib/main.go +++ b/cmd/caib/main.go @@ -504,6 +504,25 @@ Examples: Run: runDownload, } + logsCmd := &cobra.Command{ + Use: "logs ", + Short: "Follow logs of an existing build", + Long: `Follow the log output of an active or completed build. + +This is useful when you kicked off a build and need to reconnect later +(e.g., after restarting your terminal or computer). + +Examples: + # Follow logs of an active build + caib logs my-build-20250101-120000 + + # List builds first, then follow one + caib list + caib logs `, + Args: cobra.ExactArgs(1), + Run: runLogs, + } + loginCmd := &cobra.Command{ Use: "login [server-url]", Short: "Save server endpoint and authenticate for subsequent commands", @@ -633,6 +652,11 @@ Example: buildDevCmd.Flags().StringVar(&internalRegistryImageName, "image-name", "", "override image name for internal registry (default: build name)") buildDevCmd.Flags().StringVar(&internalRegistryTag, "image-tag", "", "tag for internal registry image (default: build name)") + // logs command flags + logsCmd.Flags().StringVar(&serverURL, "server", config.DefaultServer(), "REST API server base URL") + logsCmd.Flags().StringVar(&authToken, "token", os.Getenv("CAIB_TOKEN"), "Bearer token for authentication") + logsCmd.Flags().IntVar(&timeout, "timeout", 60, "timeout in minutes") + // download command flags downloadCmd.Flags().StringVar(&serverURL, "server", config.DefaultServer(), "REST API server base URL") downloadCmd.Flags().StringVar(&authToken, "token", os.Getenv("CAIB_TOKEN"), "Bearer token for authentication") @@ -651,7 +675,7 @@ Example: _ = flashCmd.MarkFlagRequired("client") // Add all commands - rootCmd.AddCommand(buildCmd, diskCmd, buildDevCmd, listCmd, showCmd, downloadCmd, flashCmd, loginCmd, catalog.NewCatalogCmd()) + rootCmd.AddCommand(buildCmd, diskCmd, buildDevCmd, listCmd, showCmd, downloadCmd, flashCmd, logsCmd, loginCmd, catalog.NewCatalogCmd()) // Add deprecated aliases for backwards compatibility rootCmd.AddCommand(buildBootcAliasCmd, buildLegacyAliasCmd, buildTraditionalAliasCmd) @@ -885,6 +909,7 @@ func runBuild(_ *cobra.Command, args []string) { handleError(err) } fmt.Printf("Build %s accepted: %s - %s\n", resp.Name, resp.Phase, resp.Message) + fmt.Printf("To follow this build later: caib logs %s\n", resp.Name) // Handle local file uploads if needed localRefs, err := findLocalFileReferences(string(manifestBytes)) @@ -976,6 +1001,7 @@ func runDisk(_ *cobra.Command, args []string) { handleError(err) } fmt.Printf("Build %s accepted: %s - %s\n", resp.Name, resp.Phase, resp.Message) + fmt.Printf("To follow this build later: caib logs %s\n", resp.Name) if waitForBuild || followLogs || outputDir != "" || flashAfterBuild { waitForBuildCompletion(ctx, api, resp.Name) @@ -1376,6 +1402,7 @@ func runBuildDev(_ *cobra.Command, args []string) { handleError(err) } fmt.Printf("Build %s accepted: %s - %s\n", resp.Name, resp.Phase, resp.Message) + fmt.Printf("To follow this build later: caib logs %s\n", resp.Name) // Handle local file uploads if needed localRefs, err := findLocalFileReferences(string(manifestBytes)) @@ -1661,11 +1688,14 @@ func buildLogURL(buildName string, startTime time.Time) string { } func streamLogsToStdout(body io.Reader, state *logStreamState) error { - if state.startTime.IsZero() { + firstStream := state.startTime.IsZero() + if firstStream { state.startTime = time.Now() } - fmt.Println("Streaming logs...") + if firstStream { + fmt.Println("Streaming logs...") + } state.active = true state.reset() @@ -1675,6 +1705,8 @@ func streamLogsToStdout(body io.Reader, state *logStreamState) error { for scanner.Scan() { line := scanner.Text() fmt.Println(line) + // Advance startTime so reconnections only fetch new logs + state.startTime = time.Now() // Capture lease ID from flash logs // Format: "jmp shell --lease " or "Lease acquired: " @@ -2089,6 +2121,37 @@ func printBuildDetails(st *buildapitypes.BuildResponse) { } } +func runLogs(_ *cobra.Command, args []string) { + ctx := context.Background() + name := args[0] + + if strings.TrimSpace(serverURL) == "" { + fmt.Println("Error: --server is required (or set CAIB_SERVER, or run 'caib login ')") + os.Exit(1) + } + + api, err := createBuildAPIClient(serverURL, &authToken) + if err != nil { + handleError(err) + } + + // Verify the build exists and show current status + st, err := api.GetBuild(ctx, name) + if err != nil { + handleError(fmt.Errorf("failed to get build: %w", err)) + } + fmt.Printf("Build %s: %s - %s\n", name, st.Phase, st.Message) + + if st.Phase == phaseCompleted || st.Phase == phaseFailed { + fmt.Printf("Build already finished (%s). Use 'caib show %s' for details.\n", st.Phase, name) + return + } + + followLogs = true + waitForBuildCompletion(ctx, api, name) + displayBuildResults(ctx, api, name) +} + func valueOrDash(v string) string { if strings.TrimSpace(v) == "" { return "-" From c06bf2cb38649fbcca6f2d1a40c03187301238a2 Mon Sep 17 00:00:00 2001 From: Benny Zlotnik Date: Sat, 14 Feb 2026 08:48:24 +0200 Subject: [PATCH 2/6] show logs for flashing Signed-off-by: Benny Zlotnik --- internal/buildapi/server.go | 21 ++++++++------- internal/controller/imagebuild/controller.go | 27 +++++++++++++++++--- 2 files changed, 36 insertions(+), 12 deletions(-) diff --git a/internal/buildapi/server.go b/internal/buildapi/server.go index 936ce7b9..d2cfc3dd 100644 --- a/internal/buildapi/server.go +++ b/internal/buildapi/server.go @@ -618,17 +618,18 @@ func getStepContainerNames(pod corev1.Pod) []string { return stepNames } -// streamContainerLogs streams logs from a single container +// streamContainerLogs streams logs from a single container. +// Returns true if logs were successfully streamed, false if the stream could not be opened. func streamContainerLogs( ctx context.Context, c *gin.Context, cs *kubernetes.Clientset, namespace, podName, containerName, taskName string, sinceTime *metav1.Time, -) { +) bool { req := cs.CoreV1().Pods(namespace).GetLogs( podName, &corev1.PodLogOptions{Container: containerName, Follow: true, SinceTime: sinceTime}, ) stream, err := req.Stream(ctx) if err != nil { - return + return false } _, _ = c.Writer.Write([]byte( @@ -648,15 +649,15 @@ func streamContainerLogs( for scanner.Scan() { select { case <-ctx.Done(): - return + return true default: } line := scanner.Bytes() if _, writeErr := c.Writer.Write(line); writeErr != nil { - return + return true } if _, writeErr := c.Writer.Write([]byte("\n")); writeErr != nil { - return + return true } c.Writer.Flush() } @@ -667,6 +668,7 @@ func streamContainerLogs( _, _ = c.Writer.Write(errMsg) c.Writer.Flush() } + return true } // processPodLogs processes logs for all containers in a pod @@ -689,10 +691,11 @@ func processPodLogs( if !*hadStream { c.Writer.Flush() } - *hadStream = true - streamContainerLogs(ctx, c, cs, namespace, pod.Name, cName, taskName, sinceTime) - streamedContainers[cName] = true + if streamContainerLogs(ctx, c, cs, namespace, pod.Name, cName, taskName, sinceTime) { + *hadStream = true + streamedContainers[cName] = true + } } } diff --git a/internal/controller/imagebuild/controller.go b/internal/controller/imagebuild/controller.go index 17050145..4ab6e401 100644 --- a/internal/controller/imagebuild/controller.go +++ b/internal/controller/imagebuild/controller.go @@ -34,6 +34,9 @@ const ( // Phase constants for ImageBuild status phaseCompleted = "Completed" phaseFailed = "Failed" + + // Tekton condition type for completion status + conditionSucceeded = "Succeeded" ) // ImageBuildReconciler reconciles a ImageBuild object @@ -284,7 +287,7 @@ func (r *ImageBuildReconciler) checkBuildProgress( // Build failed - cleanup transient secrets r.cleanupTransientSecrets(ctx, imageBuild, r.Log) - if err := r.updateStatus(ctx, imageBuild, phaseFailed, "Build failed"); err != nil { + if err := r.updateStatus(ctx, imageBuild, phaseFailed, pipelineRunFailureMessage(pipelineRun)); err != nil { log.Error(err, "Failed to update status to Failed") return ctrl.Result{}, err } @@ -1075,7 +1078,7 @@ func (r *ImageBuildReconciler) handleFlashingState( fresh.Status.Message = "Build, push, and flash completed successfully" } else { fresh.Status.Phase = phaseFailed - fresh.Status.Message = "Flash to device failed" + fresh.Status.Message = taskRunFailureMessage(taskRun, "Flash to device failed") } if fresh.Status.CompletionTime == nil { @@ -1299,13 +1302,31 @@ func isPipelineRunSuccessful(pipelineRun *tektonv1.PipelineRun) bool { } for _, condition := range conditions { - if condition.Type == "Succeeded" { + if condition.Type == conditionSucceeded { return condition.Status == "True" } } return false } +func pipelineRunFailureMessage(pipelineRun *tektonv1.PipelineRun) string { + for _, condition := range pipelineRun.Status.Conditions { + if condition.Type == conditionSucceeded && condition.Status != "True" && condition.Message != "" { + return fmt.Sprintf("Build failed: %s", condition.Message) + } + } + return "Build failed" +} + +func taskRunFailureMessage(taskRun *tektonv1.TaskRun, fallback string) string { + for _, condition := range taskRun.Status.Conditions { + if condition.Type == conditionSucceeded && condition.Status != corev1.ConditionTrue && condition.Message != "" { + return fmt.Sprintf("%s: %s", fallback, condition.Message) + } + } + return fallback +} + // extractProvenance extracts build provenance information from PipelineRun results func extractProvenance(pipelineRun *tektonv1.PipelineRun, aibImage string) (aibImageUsed, builderImageUsed string) { aibImageUsed = aibImage // Always record the AIB image that was requested From 1acbfc2b32db89037c8a607c6a09aa21fe90dd87 Mon Sep 17 00:00:00 2001 From: Benny Zlotnik Date: Sat, 14 Feb 2026 09:43:03 +0200 Subject: [PATCH 3/6] add default args in mapping Signed-off-by: Benny Zlotnik --- api/v1alpha1/operatorconfig_types.go | 10 +++ api/v1alpha1/zz_generated.deepcopy.go | 7 +- cmd/caib/main.go | 90 ++++++++++++++----- ....sdv.cloud.redhat.com_operatorconfigs.yaml | 12 +++ internal/buildapi/server.go | 8 +- internal/buildapi/server_test.go | 14 ++- internal/buildapi/types.go | 11 ++- 7 files changed, 121 insertions(+), 31 deletions(-) diff --git a/api/v1alpha1/operatorconfig_types.go b/api/v1alpha1/operatorconfig_types.go index 6b552585..e89c7793 100644 --- a/api/v1alpha1/operatorconfig_types.go +++ b/api/v1alpha1/operatorconfig_types.go @@ -59,6 +59,16 @@ type JumpstarterTargetMapping struct { // Example: "j storage flash ${IMAGE}" // +optional FlashCmd string `json:"flashCmd,omitempty"` + + // Architecture is the default CPU architecture for builds targeting this device + // Example: "arm64" + // +optional + Architecture string `json:"architecture,omitempty"` + + // ExtraArgs are default extra arguments passed to AIB for builds targeting this device + // Example: ["--separate-partitions"] + // +optional + ExtraArgs []string `json:"extraArgs,omitempty"` } // DefaultJumpstarterImage is the default container image for Jumpstarter CLI operations diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index a1ab4ee9..168987d3 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -692,7 +692,7 @@ func (in *JumpstarterConfig) DeepCopyInto(out *JumpstarterConfig) { in, out := &in.TargetMappings, &out.TargetMappings *out = make(map[string]JumpstarterTargetMapping, len(*in)) for key, val := range *in { - (*out)[key] = val + (*out)[key] = *val.DeepCopy() } } } @@ -710,6 +710,11 @@ func (in *JumpstarterConfig) DeepCopy() *JumpstarterConfig { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *JumpstarterTargetMapping) DeepCopyInto(out *JumpstarterTargetMapping) { *out = *in + if in.ExtraArgs != nil { + in, out := &in.ExtraArgs, &out.ExtraArgs + *out = make([]string, len(*in)) + copy(*out, *in) + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new JumpstarterTargetMapping. diff --git a/cmd/caib/main.go b/cmd/caib/main.go index 71a3a623..d5f3ad8b 100644 --- a/cmd/caib/main.go +++ b/cmd/caib/main.go @@ -731,6 +731,9 @@ func validateBootcBuildFlags() { if exportOCI != "" && !buildDiskImage { buildDiskImage = true } + if flashAfterBuild && !buildDiskImage { + buildDiskImage = true + } if !useInternalRegistry { validateOutputRequiresPush(outputDir, exportOCI, "--push-disk") } @@ -773,29 +776,63 @@ func applyRegistryCredentialsToRequest(req *buildapitypes.BuildRequest) { } } -// validateFlashTargetMapping validates that the selected target has a Jumpstarter mapping. -func validateFlashTargetMapping(ctx context.Context, api *buildapiclient.Client, target string) { +// fetchTargetDefaults fetches the operator config once and returns it. +// If flash is enabled, it also validates that the target has a Jumpstarter mapping. +func fetchTargetDefaults(ctx context.Context, api *buildapiclient.Client, target string, validateFlash bool) *buildapitypes.OperatorConfigResponse { config, err := api.GetOperatorConfig(ctx) if err != nil { + // Non-fatal for defaults: if we can't reach the config endpoint, just skip defaults + if !validateFlash { + fmt.Fprintf(os.Stderr, "Warning: could not fetch operator config for target defaults: %v\n", err) + return nil + } handleError(fmt.Errorf("failed to get operator configuration for Jumpstarter validation: %w", err)) } - if len(config.JumpstarterTargets) == 0 { - handleError(fmt.Errorf("flash enabled but no Jumpstarter target mappings configured in operator")) - } + if validateFlash { + if len(config.JumpstarterTargets) == 0 { + handleError(fmt.Errorf("flash enabled but no Jumpstarter target mappings configured in operator")) + } - if _, exists := config.JumpstarterTargets[target]; !exists { - availableTargets := make([]string, 0, len(config.JumpstarterTargets)) - for t := range config.JumpstarterTargets { - availableTargets = append(availableTargets, t) + if _, exists := config.JumpstarterTargets[target]; !exists { + availableTargets := make([]string, 0, len(config.JumpstarterTargets)) + for t := range config.JumpstarterTargets { + availableTargets = append(availableTargets, t) + } + handleError( + fmt.Errorf( + "flash enabled but no Jumpstarter target mapping found for target %q. Available targets: %v", + target, + availableTargets, + ), + ) } - handleError( - fmt.Errorf( - "flash enabled but no Jumpstarter target mapping found for target %q. Available targets: %v", - target, - availableTargets, - ), - ) + } + + return config +} + +// applyTargetDefaults applies architecture and extra-args defaults from the operator config +// target mapping. CLI flags override mapping defaults when explicitly set. +func applyTargetDefaults(cmd *cobra.Command, config *buildapitypes.OperatorConfigResponse, req *buildapitypes.BuildRequest) { + if config == nil || len(config.JumpstarterTargets) == 0 { + return + } + + defaults, exists := config.JumpstarterTargets[string(req.Target)] + if !exists { + return + } + + if defaults.Architecture != "" && !cmd.Flags().Changed("arch") { + req.Architecture = buildapitypes.Architecture(defaults.Architecture) + fmt.Printf("Using architecture %q from target mapping for %q\n", defaults.Architecture, req.Target) + } + + if len(defaults.ExtraArgs) > 0 { + // Mapping args come first, user args appended + req.AIBExtraArgs = append(defaults.ExtraArgs, req.AIBExtraArgs...) + fmt.Printf("Prepending extra args %v from target mapping for %q\n", defaults.ExtraArgs, req.Target) } } @@ -841,7 +878,7 @@ func displayBuildResults(ctx context.Context, api *buildapiclient.Client, buildN } // runBuild handles the main 'build' command (bootc builds) -func runBuild(_ *cobra.Command, args []string) { +func runBuild(cmd *cobra.Command, args []string) { ctx := context.Background() manifest = args[0] @@ -886,6 +923,10 @@ func runBuild(_ *cobra.Command, args []string) { applyRegistryCredentialsToRequest(&req) + // Fetch target defaults and apply them to the request + operatorConfig := fetchTargetDefaults(ctx, api, target, flashAfterBuild) + applyTargetDefaults(cmd, operatorConfig, &req) + // Add flash configuration if enabled if flashAfterBuild { if exportOCI == "" && !useInternalRegistry { @@ -894,7 +935,6 @@ func runBuild(_ *cobra.Command, args []string) { if jumpstarterClient == "" { handleError(fmt.Errorf("--flash requires --client to specify Jumpstarter client config file")) } - validateFlashTargetMapping(ctx, api, target) clientConfigBytes, err := os.ReadFile(jumpstarterClient) if err != nil { handleError(fmt.Errorf("failed to read Jumpstarter client config: %w", err)) @@ -927,7 +967,7 @@ func runBuild(_ *cobra.Command, args []string) { displayBuildResults(ctx, api, resp.Name) } -func runDisk(_ *cobra.Command, args []string) { +func runDisk(cmd *cobra.Command, args []string) { ctx := context.Background() containerRef = args[0] @@ -978,6 +1018,10 @@ func runDisk(_ *cobra.Command, args []string) { applyRegistryCredentialsToRequest(&req) + // Fetch target defaults and apply them to the request + operatorConfig := fetchTargetDefaults(ctx, api, target, flashAfterBuild) + applyTargetDefaults(cmd, operatorConfig, &req) + // Add flash configuration if enabled if flashAfterBuild { if exportOCI == "" && !useInternalRegistry { @@ -986,7 +1030,6 @@ func runDisk(_ *cobra.Command, args []string) { if jumpstarterClient == "" { handleError(fmt.Errorf("--flash requires --client to specify Jumpstarter client config file")) } - validateFlashTargetMapping(ctx, api, target) clientConfigBytes, err := os.ReadFile(jumpstarterClient) if err != nil { handleError(fmt.Errorf("failed to read Jumpstarter client config: %w", err)) @@ -1314,7 +1357,7 @@ func copyFile(srcPath, dstPath string) error { } // runBuildDev handles the 'build-dev' command (traditional ostree/package builds) -func runBuildDev(_ *cobra.Command, args []string) { +func runBuildDev(cmd *cobra.Command, args []string) { ctx := context.Background() manifest = args[0] @@ -1378,6 +1421,10 @@ func runBuildDev(_ *cobra.Command, args []string) { applyRegistryCredentialsToRequest(&req) + // Fetch target defaults and apply them to the request + operatorConfig := fetchTargetDefaults(ctx, api, target, flashAfterBuild) + applyTargetDefaults(cmd, operatorConfig, &req) + // Add flash configuration if enabled if flashAfterBuild { if exportOCI == "" && !useInternalRegistry { @@ -1386,7 +1433,6 @@ func runBuildDev(_ *cobra.Command, args []string) { if jumpstarterClient == "" { handleError(fmt.Errorf("--flash requires --client to specify Jumpstarter client config file")) } - validateFlashTargetMapping(ctx, api, target) clientConfigBytes, err := os.ReadFile(jumpstarterClient) if err != nil { diff --git a/config/crd/bases/automotive.sdv.cloud.redhat.com_operatorconfigs.yaml b/config/crd/bases/automotive.sdv.cloud.redhat.com_operatorconfigs.yaml index e24667b2..0addc4e8 100644 --- a/config/crd/bases/automotive.sdv.cloud.redhat.com_operatorconfigs.yaml +++ b/config/crd/bases/automotive.sdv.cloud.redhat.com_operatorconfigs.yaml @@ -442,6 +442,18 @@ spec: description: JumpstarterTargetMapping defines the Jumpstarter configuration for a specific build target properties: + architecture: + description: |- + Architecture is the default CPU architecture for builds targeting this device + Example: "arm64" + type: string + extraArgs: + description: |- + ExtraArgs are default extra arguments passed to AIB for builds targeting this device + Example: ["--separate-partitions"] + items: + type: string + type: array flashCmd: description: |- FlashCmd is the command template for flashing the device diff --git a/internal/buildapi/server.go b/internal/buildapi/server.go index d2cfc3dd..0febdb62 100644 --- a/internal/buildapi/server.go +++ b/internal/buildapi/server.go @@ -2488,9 +2488,13 @@ func (a *APIServer) handleGetOperatorConfig(c *gin.Context) { response := OperatorConfigResponse{} if operatorConfig.Spec.Jumpstarter != nil && len(operatorConfig.Spec.Jumpstarter.TargetMappings) > 0 { - response.JumpstarterTargets = make(map[string]string) + response.JumpstarterTargets = make(map[string]TargetDefaults) for target, mapping := range operatorConfig.Spec.Jumpstarter.TargetMappings { - response.JumpstarterTargets[target] = mapping.Selector + response.JumpstarterTargets[target] = TargetDefaults{ + Selector: mapping.Selector, + Architecture: mapping.Architecture, + ExtraArgs: mapping.ExtraArgs, + } } } diff --git a/internal/buildapi/server_test.go b/internal/buildapi/server_test.go index a3a20320..4322a479 100644 --- a/internal/buildapi/server_test.go +++ b/internal/buildapi/server_test.go @@ -163,8 +163,10 @@ var _ = Describe("APIServer", func() { "qemu": { Selector: "board-type=qemu", }, - "j784s4evm": { - Selector: "board-type=j784s4evm", + "ebbr": { + Selector: "board-type=ebbr", + Architecture: "arm64", + ExtraArgs: []string{"--separate-partitions"}, }, }, }, @@ -191,8 +193,12 @@ var _ = Describe("APIServer", func() { var response OperatorConfigResponse Expect(json.Unmarshal(w.Body.Bytes(), &response)).To(Succeed()) Expect(response.JumpstarterTargets).To(HaveLen(2)) - Expect(response.JumpstarterTargets["qemu"]).To(Equal("board-type=qemu")) - Expect(response.JumpstarterTargets["j784s4evm"]).To(Equal("board-type=j784s4evm")) + Expect(response.JumpstarterTargets["qemu"]).To(Equal(TargetDefaults{Selector: "board-type=qemu"})) + Expect(response.JumpstarterTargets["ebbr"]).To(Equal(TargetDefaults{ + Selector: "board-type=ebbr", + Architecture: "arm64", + ExtraArgs: []string{"--separate-partitions"}, + })) }) }) diff --git a/internal/buildapi/types.go b/internal/buildapi/types.go index 829a6447..f6581129 100644 --- a/internal/buildapi/types.go +++ b/internal/buildapi/types.go @@ -252,10 +252,17 @@ type BuildListItem struct { DiskImage string `json:"diskImage,omitempty"` } +// TargetDefaults contains build defaults and Jumpstarter configuration for a target +type TargetDefaults struct { + Selector string `json:"selector"` + Architecture string `json:"architecture,omitempty"` + ExtraArgs []string `json:"extraArgs,omitempty"` +} + // OperatorConfigResponse returns relevant operator configuration for CLI validation type OperatorConfigResponse struct { - // JumpstarterTargets contains the target mappings for Jumpstarter flashing - JumpstarterTargets map[string]string `json:"jumpstarterTargets,omitempty"` + // JumpstarterTargets contains the target mappings with build defaults + JumpstarterTargets map[string]TargetDefaults `json:"jumpstarterTargets,omitempty"` } type ( From 951be6fc92895396330dc0e29beb2d2c59d35da2 Mon Sep 17 00:00:00 2001 From: Benny Zlotnik Date: Sat, 14 Feb 2026 10:18:13 +0200 Subject: [PATCH 4/6] add tests Signed-off-by: Benny Zlotnik --- .../controller/imagebuild/controller_test.go | 189 ++++++++++++++++++ 1 file changed, 189 insertions(+) create mode 100644 internal/controller/imagebuild/controller_test.go diff --git a/internal/controller/imagebuild/controller_test.go b/internal/controller/imagebuild/controller_test.go new file mode 100644 index 00000000..936d03d7 --- /dev/null +++ b/internal/controller/imagebuild/controller_test.go @@ -0,0 +1,189 @@ +package imagebuild + +import ( + "testing" + + tektonv1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" + corev1 "k8s.io/api/core/v1" + knativev1 "knative.dev/pkg/apis/duck/v1" +) + +func TestPipelineRunFailureMessage(t *testing.T) { + tests := []struct { + name string + pipelineRun *tektonv1.PipelineRun + want string + }{ + { + name: "returns condition message on failure", + pipelineRun: &tektonv1.PipelineRun{ + Status: tektonv1.PipelineRunStatus{ + Status: knativev1.Status{ + Conditions: knativev1.Conditions{ + { + Type: conditionSucceeded, + Status: corev1.ConditionFalse, + Message: "TaskRun build-step failed: container exited with code 1", + }, + }, + }, + }, + }, + want: "Build failed: TaskRun build-step failed: container exited with code 1", + }, + { + name: "returns fallback when no conditions", + pipelineRun: &tektonv1.PipelineRun{ + Status: tektonv1.PipelineRunStatus{}, + }, + want: "Build failed", + }, + { + name: "returns fallback when Succeeded condition has empty message", + pipelineRun: &tektonv1.PipelineRun{ + Status: tektonv1.PipelineRunStatus{ + Status: knativev1.Status{ + Conditions: knativev1.Conditions{ + { + Type: conditionSucceeded, + Status: corev1.ConditionFalse, + }, + }, + }, + }, + }, + want: "Build failed", + }, + { + name: "ignores non-Succeeded conditions", + pipelineRun: &tektonv1.PipelineRun{ + Status: tektonv1.PipelineRunStatus{ + Status: knativev1.Status{ + Conditions: knativev1.Conditions{ + { + Type: "Ready", + Status: corev1.ConditionFalse, + Message: "not ready", + }, + }, + }, + }, + }, + want: "Build failed", + }, + { + name: "ignores Succeeded=True condition", + pipelineRun: &tektonv1.PipelineRun{ + Status: tektonv1.PipelineRunStatus{ + Status: knativev1.Status{ + Conditions: knativev1.Conditions{ + { + Type: conditionSucceeded, + Status: corev1.ConditionTrue, + Message: "All Tasks have completed executing", + }, + }, + }, + }, + }, + want: "Build failed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := pipelineRunFailureMessage(tt.pipelineRun) + if got != tt.want { + t.Errorf("pipelineRunFailureMessage() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestTaskRunFailureMessage(t *testing.T) { + tests := []struct { + name string + taskRun *tektonv1.TaskRun + fallback string + want string + }{ + { + name: "returns condition message on failure", + taskRun: &tektonv1.TaskRun{ + Status: tektonv1.TaskRunStatus{ + Status: knativev1.Status{ + Conditions: knativev1.Conditions{ + { + Type: conditionSucceeded, + Status: corev1.ConditionFalse, + Message: "step flash failed: timeout waiting for device", + }, + }, + }, + }, + }, + fallback: "Flash to device failed", + want: "Flash to device failed: step flash failed: timeout waiting for device", + }, + { + name: "returns fallback when no conditions", + taskRun: &tektonv1.TaskRun{ + Status: tektonv1.TaskRunStatus{}, + }, + fallback: "Flash to device failed", + want: "Flash to device failed", + }, + { + name: "returns fallback when Succeeded condition has empty message", + taskRun: &tektonv1.TaskRun{ + Status: tektonv1.TaskRunStatus{ + Status: knativev1.Status{ + Conditions: knativev1.Conditions{ + { + Type: conditionSucceeded, + Status: corev1.ConditionFalse, + }, + }, + }, + }, + }, + fallback: "Flash to device failed", + want: "Flash to device failed", + }, + { + name: "ignores Succeeded=True condition", + taskRun: &tektonv1.TaskRun{ + Status: tektonv1.TaskRunStatus{ + Status: knativev1.Status{ + Conditions: knativev1.Conditions{ + { + Type: conditionSucceeded, + Status: corev1.ConditionTrue, + Message: "All steps completed", + }, + }, + }, + }, + }, + fallback: "Flash to device failed", + want: "Flash to device failed", + }, + { + name: "uses custom fallback message", + taskRun: &tektonv1.TaskRun{ + Status: tektonv1.TaskRunStatus{}, + }, + fallback: "Custom operation failed", + want: "Custom operation failed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := taskRunFailureMessage(tt.taskRun, tt.fallback) + if got != tt.want { + t.Errorf("taskRunFailureMessage() = %q, want %q", got, tt.want) + } + }) + } +} From 9e7aed75ceaf9f68e74ef85f85a45f1b51a08035 Mon Sep 17 00:00:00 2001 From: Benny Zlotnik Date: Sat, 14 Feb 2026 10:40:04 +0200 Subject: [PATCH 5/6] improve logs and sort list output Signed-off-by: Benny Zlotnik --- cmd/caib/main.go | 16 ++- cmd/caib/main_test.go | 248 ++++++++++++++++++++++++++++++++++++ internal/buildapi/server.go | 10 ++ 3 files changed, 273 insertions(+), 1 deletion(-) create mode 100644 cmd/caib/main_test.go diff --git a/cmd/caib/main.go b/cmd/caib/main.go index d5f3ad8b..73249a56 100644 --- a/cmd/caib/main.go +++ b/cmd/caib/main.go @@ -2189,7 +2189,21 @@ func runLogs(_ *cobra.Command, args []string) { fmt.Printf("Build %s: %s - %s\n", name, st.Phase, st.Message) if st.Phase == phaseCompleted || st.Phase == phaseFailed { - fmt.Printf("Build already finished (%s). Use 'caib show %s' for details.\n", st.Phase, name) + // Build is finished — attempt to fetch logs once (pods may have been GC'd) + logTransport := &http.Transport{ + ResponseHeaderTimeout: 30 * time.Second, + } + if insecureSkipTLS { + logTransport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} + } + logClient := &http.Client{ + Timeout: 2 * time.Minute, + Transport: logTransport, + } + streamState := &logStreamState{} + if err := tryLogStreaming(ctx, logClient, name, streamState); err != nil { + fmt.Printf("Could not retrieve logs (pods may have been cleaned up). Use 'caib show %s' for details.\n", name) + } return } diff --git a/cmd/caib/main_test.go b/cmd/caib/main_test.go new file mode 100644 index 00000000..6f40e447 --- /dev/null +++ b/cmd/caib/main_test.go @@ -0,0 +1,248 @@ +package main + +import ( + "testing" + + buildapitypes "github.com/centos-automotive-suite/automotive-dev-operator/internal/buildapi" + "github.com/spf13/cobra" +) + +func newCmdWithArchFlag(archValue string, changed bool) *cobra.Command { + cmd := &cobra.Command{Use: "test"} + cmd.Flags().StringP("arch", "a", archAMD64, "architecture") + if changed { + // Simulate the user explicitly setting the flag + if err := cmd.Flags().Set("arch", archValue); err != nil { + panic(err) + } + } + return cmd +} + +func TestApplyTargetDefaults_NilConfig(t *testing.T) { + cmd := newCmdWithArchFlag(archAMD64, false) + req := &buildapitypes.BuildRequest{ + Target: "ebbr", + Architecture: buildapitypes.Architecture(archAMD64), + } + + applyTargetDefaults(cmd, nil, req) + + if req.Architecture != buildapitypes.Architecture(archAMD64) { + t.Errorf("expected architecture to remain amd64, got %s", req.Architecture) + } +} + +func TestApplyTargetDefaults_EmptyTargets(t *testing.T) { + cmd := newCmdWithArchFlag(archAMD64, false) + config := &buildapitypes.OperatorConfigResponse{ + JumpstarterTargets: map[string]buildapitypes.TargetDefaults{}, + } + req := &buildapitypes.BuildRequest{ + Target: "ebbr", + Architecture: buildapitypes.Architecture(archAMD64), + } + + applyTargetDefaults(cmd, config, req) + + if req.Architecture != buildapitypes.Architecture(archAMD64) { + t.Errorf("expected architecture to remain amd64, got %s", req.Architecture) + } +} + +func TestApplyTargetDefaults_NoMatchingTarget(t *testing.T) { + cmd := newCmdWithArchFlag(archAMD64, false) + config := &buildapitypes.OperatorConfigResponse{ + JumpstarterTargets: map[string]buildapitypes.TargetDefaults{ + "qemu": {Selector: "board-type=qemu"}, + }, + } + req := &buildapitypes.BuildRequest{ + Target: "ebbr", + Architecture: buildapitypes.Architecture(archAMD64), + } + + applyTargetDefaults(cmd, config, req) + + if req.Architecture != buildapitypes.Architecture(archAMD64) { + t.Errorf("expected architecture to remain amd64, got %s", req.Architecture) + } +} + +func TestApplyTargetDefaults_AppliesArchFromMapping(t *testing.T) { + cmd := newCmdWithArchFlag(archAMD64, false) + config := &buildapitypes.OperatorConfigResponse{ + JumpstarterTargets: map[string]buildapitypes.TargetDefaults{ + "ebbr": { + Selector: "board-type=ebbr", + Architecture: archARM64, + }, + }, + } + req := &buildapitypes.BuildRequest{ + Target: "ebbr", + Architecture: buildapitypes.Architecture(archAMD64), + } + + applyTargetDefaults(cmd, config, req) + + if req.Architecture != buildapitypes.Architecture(archARM64) { + t.Errorf("expected architecture to be overridden to arm64, got %s", req.Architecture) + } +} + +func TestApplyTargetDefaults_ExplicitArchOverridesMapping(t *testing.T) { + cmd := newCmdWithArchFlag(archAMD64, true) // user explicitly set --arch amd64 + config := &buildapitypes.OperatorConfigResponse{ + JumpstarterTargets: map[string]buildapitypes.TargetDefaults{ + "ebbr": { + Selector: "board-type=ebbr", + Architecture: archARM64, + }, + }, + } + req := &buildapitypes.BuildRequest{ + Target: "ebbr", + Architecture: buildapitypes.Architecture(archAMD64), + } + + applyTargetDefaults(cmd, config, req) + + if req.Architecture != buildapitypes.Architecture(archAMD64) { + t.Errorf("expected explicit --arch to override mapping, got %s", req.Architecture) + } +} + +func TestApplyTargetDefaults_ExplicitArchArm64OverridesMapping(t *testing.T) { + cmd := newCmdWithArchFlag(archARM64, true) // user explicitly set --arch arm64 + config := &buildapitypes.OperatorConfigResponse{ + JumpstarterTargets: map[string]buildapitypes.TargetDefaults{ + "ebbr": { + Selector: "board-type=ebbr", + Architecture: archAMD64, // mapping says amd64 + }, + }, + } + req := &buildapitypes.BuildRequest{ + Target: "ebbr", + Architecture: buildapitypes.Architecture(archARM64), + } + + applyTargetDefaults(cmd, config, req) + + if req.Architecture != buildapitypes.Architecture(archARM64) { + t.Errorf("expected explicit --arch arm64 to override mapping amd64, got %s", req.Architecture) + } +} + +func TestApplyTargetDefaults_PrependsExtraArgs(t *testing.T) { + cmd := newCmdWithArchFlag(archAMD64, false) + config := &buildapitypes.OperatorConfigResponse{ + JumpstarterTargets: map[string]buildapitypes.TargetDefaults{ + "ride": { + Selector: "board-type=ride", + ExtraArgs: []string{"--separate-partitions"}, + }, + }, + } + req := &buildapitypes.BuildRequest{ + Target: "ride", + Architecture: buildapitypes.Architecture(archAMD64), + AIBExtraArgs: []string{"--user-arg"}, + } + + applyTargetDefaults(cmd, config, req) + + expected := []string{"--separate-partitions", "--user-arg"} + if len(req.AIBExtraArgs) != len(expected) { + t.Fatalf("expected %d extra args, got %d: %v", len(expected), len(req.AIBExtraArgs), req.AIBExtraArgs) + } + for i, arg := range expected { + if req.AIBExtraArgs[i] != arg { + t.Errorf("extra arg [%d]: expected %q, got %q", i, arg, req.AIBExtraArgs[i]) + } + } +} + +func TestApplyTargetDefaults_ExtraArgsWithNoUserArgs(t *testing.T) { + cmd := newCmdWithArchFlag(archAMD64, false) + config := &buildapitypes.OperatorConfigResponse{ + JumpstarterTargets: map[string]buildapitypes.TargetDefaults{ + "ride": { + Selector: "board-type=ride", + ExtraArgs: []string{"--separate-partitions", "--verbose"}, + }, + }, + } + req := &buildapitypes.BuildRequest{ + Target: "ride", + Architecture: buildapitypes.Architecture(archAMD64), + } + + applyTargetDefaults(cmd, config, req) + + expected := []string{"--separate-partitions", "--verbose"} + if len(req.AIBExtraArgs) != len(expected) { + t.Fatalf("expected %d extra args, got %d: %v", len(expected), len(req.AIBExtraArgs), req.AIBExtraArgs) + } + for i, arg := range expected { + if req.AIBExtraArgs[i] != arg { + t.Errorf("extra arg [%d]: expected %q, got %q", i, arg, req.AIBExtraArgs[i]) + } + } +} + +func TestApplyTargetDefaults_BothArchAndExtraArgs(t *testing.T) { + cmd := newCmdWithArchFlag(archAMD64, false) + config := &buildapitypes.OperatorConfigResponse{ + JumpstarterTargets: map[string]buildapitypes.TargetDefaults{ + "ride": { + Selector: "board-type=ride", + Architecture: archARM64, + ExtraArgs: []string{"--separate-partitions"}, + }, + }, + } + req := &buildapitypes.BuildRequest{ + Target: "ride", + Architecture: buildapitypes.Architecture(archAMD64), + AIBExtraArgs: []string{"--my-arg"}, + } + + applyTargetDefaults(cmd, config, req) + + if req.Architecture != buildapitypes.Architecture(archARM64) { + t.Errorf("expected architecture arm64, got %s", req.Architecture) + } + expected := []string{"--separate-partitions", "--my-arg"} + if len(req.AIBExtraArgs) != len(expected) { + t.Fatalf("expected %d extra args, got %d: %v", len(expected), len(req.AIBExtraArgs), req.AIBExtraArgs) + } + for i, arg := range expected { + if req.AIBExtraArgs[i] != arg { + t.Errorf("extra arg [%d]: expected %q, got %q", i, arg, req.AIBExtraArgs[i]) + } + } +} + +func TestApplyTargetDefaults_MappingWithEmptyArchDoesNotOverride(t *testing.T) { + cmd := newCmdWithArchFlag(archAMD64, false) + config := &buildapitypes.OperatorConfigResponse{ + JumpstarterTargets: map[string]buildapitypes.TargetDefaults{ + "qemu": { + Selector: "board-type=qemu", + // Architecture intentionally empty + }, + }, + } + req := &buildapitypes.BuildRequest{ + Target: "qemu", + Architecture: buildapitypes.Architecture(archAMD64), + } + + applyTargetDefaults(cmd, config, req) + + if req.Architecture != buildapitypes.Architecture(archAMD64) { + t.Errorf("expected architecture to remain amd64 when mapping has no arch, got %s", req.Architecture) + } +} diff --git a/internal/buildapi/server.go b/internal/buildapi/server.go index 0febdb62..96347af5 100644 --- a/internal/buildapi/server.go +++ b/internal/buildapi/server.go @@ -1489,6 +1489,11 @@ func listBuilds(c *gin.Context) { return } + // Sort by creation time, newest first + sort.Slice(list.Items, func(i, j int) bool { + return list.Items[j].CreationTimestamp.Before(&list.Items[i].CreationTimestamp) + }) + // Resolve external route once for translating internal registry URLs externalRoute, _ := getExternalRegistryRoute(ctx, k8sClient, namespace) @@ -2729,6 +2734,11 @@ func (a *APIServer) listFlash(c *gin.Context) { return } + // Sort by creation time, newest first + sort.Slice(taskRunList.Items, func(i, j int) bool { + return taskRunList.Items[j].CreationTimestamp.Before(&taskRunList.Items[i].CreationTimestamp) + }) + resp := make([]FlashListItem, 0, len(taskRunList.Items)) for _, tr := range taskRunList.Items { phase, message := getTaskRunStatus(&tr) From db055948ad002ec578de0697b10483d94e548320 Mon Sep 17 00:00:00 2001 From: Benny Zlotnik Date: Sat, 14 Feb 2026 10:50:48 +0200 Subject: [PATCH 6/6] limit upload to 30 minutes by default configurable Signed-off-by: Benny Zlotnik --- api/v1alpha1/operatorconfig_types.go | 5 ++++ ....sdv.cloud.redhat.com_operatorconfigs.yaml | 6 +++++ internal/controller/imagebuild/controller.go | 23 +++++++++++++++++++ 3 files changed, 34 insertions(+) diff --git a/api/v1alpha1/operatorconfig_types.go b/api/v1alpha1/operatorconfig_types.go index e89c7793..26142540 100644 --- a/api/v1alpha1/operatorconfig_types.go +++ b/api/v1alpha1/operatorconfig_types.go @@ -202,6 +202,11 @@ type OSBuildsConfig struct { // +optional ClusterRegistryRoute string `json:"clusterRegistryRoute,omitempty"` + // UploadTimeoutMinutes is the maximum time in minutes to wait for file uploads before failing the build + // Default: 30 + // +optional + UploadTimeoutMinutes int32 `json:"uploadTimeoutMinutes,omitempty"` + // NodeSelector specifies node labels that build pods must match for scheduling // These labels are added to the pod template used by Tekton PipelineRuns // Example: {"dedicated": "builds", "disktype": "ssd"} diff --git a/config/crd/bases/automotive.sdv.cloud.redhat.com_operatorconfigs.yaml b/config/crd/bases/automotive.sdv.cloud.redhat.com_operatorconfigs.yaml index 0addc4e8..b8a8a05a 100644 --- a/config/crd/bases/automotive.sdv.cloud.redhat.com_operatorconfigs.yaml +++ b/config/crd/bases/automotive.sdv.cloud.redhat.com_operatorconfigs.yaml @@ -552,6 +552,12 @@ spec: type: string type: object type: array + uploadTimeoutMinutes: + description: |- + UploadTimeoutMinutes is the maximum time in minutes to wait for file uploads before failing the build + Default: 30 + format: int32 + type: integer useMemoryContainerStorage: description: |- UseMemoryContainerStorage determines whether to use memory-backed volumes for container storage diff --git a/internal/controller/imagebuild/controller.go b/internal/controller/imagebuild/controller.go index 4ab6e401..32486f56 100644 --- a/internal/controller/imagebuild/controller.go +++ b/internal/controller/imagebuild/controller.go @@ -136,6 +136,29 @@ func (r *ImageBuildReconciler) handleUploadingState( types.NamespacedName{Name: imageBuild.Name, Namespace: imageBuild.Namespace}, ) + // Fail the build if uploads have not completed within the configured timeout + uploadTimeout := 30 * time.Minute // default + operatorConfig := &automotivev1alpha1.OperatorConfig{} + if err := r.Get(ctx, types.NamespacedName{Name: "config", Namespace: OperatorNamespace}, operatorConfig); err == nil { + if operatorConfig.Spec.OSBuilds != nil && operatorConfig.Spec.OSBuilds.UploadTimeoutMinutes > 0 { + uploadTimeout = time.Duration(operatorConfig.Spec.OSBuilds.UploadTimeoutMinutes) * time.Minute + } + } + if time.Since(imageBuild.CreationTimestamp.Time) > uploadTimeout { + log.Info("Upload timed out", "age", time.Since(imageBuild.CreationTimestamp.Time), "timeout", uploadTimeout) + r.cleanupTransientSecrets(ctx, imageBuild, r.Log) + if err := r.shutdownUploadPod(ctx, imageBuild); err != nil { + log.Error(err, "Failed to shutdown upload pod during timeout cleanup") + } + timeoutMinutes := int(uploadTimeout.Minutes()) + if err := r.updateStatus(ctx, imageBuild, phaseFailed, + fmt.Sprintf("Upload timed out: file uploads were not completed within %d minutes", timeoutMinutes)); err != nil { + log.Error(err, "Failed to update status to Failed") + return ctrl.Result{}, err + } + return ctrl.Result{}, nil + } + uploadsComplete := imageBuild.Annotations != nil && imageBuild.Annotations["automotive.sdv.cloud.redhat.com/uploads-complete"] == "true"