From 470c9694381d1805102424f47e84f1afa03cf5fc Mon Sep 17 00:00:00 2001 From: Benny Zlotnik Date: Fri, 6 Feb 2026 11:51:20 +0200 Subject: [PATCH] caib: restore caib download To download already built images Signed-off-by: Benny Zlotnik --- cmd/caib/main.go | 81 +++++++++++++++++-- internal/buildapi/server.go | 4 +- internal/buildapi/types.go | 2 + .../controller/operatorconfig/resources.go | 19 ++--- 4 files changed, 88 insertions(+), 18 deletions(-) diff --git a/cmd/caib/main.go b/cmd/caib/main.go index 675e6b73..3e960546 100644 --- a/cmd/caib/main.go +++ b/cmd/caib/main.go @@ -35,9 +35,10 @@ import ( ) const ( - archAMD64 = "amd64" - archARM64 = "arm64" - phaseFailed = "Failed" + archAMD64 = "amd64" + archARM64 = "arm64" + phaseCompleted = "Completed" + phaseFailed = "Failed" ) // getDefaultArch returns the current system architecture in caib format @@ -402,6 +403,25 @@ Examples: Run: runList, } + downloadCmd := &cobra.Command{ + Use: "download ", + Short: "Download disk image artifact from a completed build", + Long: `Download retrieves the disk image artifact from a completed build. + +The build must have pushed a disk image to an OCI registry (via --push-disk +or --push on disk/build-dev commands). The artifact is pulled from the +registry to a local file. + +Examples: + # Download disk image from a completed build + caib download my-build -o ./disk.qcow2 + + # Download to a directory (multi-layer artifacts extract here) + caib download my-build -o ./output/`, + Args: cobra.ExactArgs(1), + Run: runDownload, + } + loginCmd := &cobra.Command{ Use: "login [server-url]", Short: "Save server endpoint and authenticate for subsequent commands", @@ -509,6 +529,11 @@ Example: buildDevCmd.Flags().StringVar(&jumpstarterClient, "client", "", "path to Jumpstarter client config file (required for --flash)") buildDevCmd.Flags().StringVar(&leaseDuration, "lease", "03:00:00", "device lease duration for flash (HH:MM:SS)") + // 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") + downloadCmd.Flags().StringVarP(&outputDir, "output", "o", "", "destination file or directory for the artifact") + // flash command flags flashCmd.Flags().StringVar(&serverURL, "server", config.DefaultServer(), "REST API server base URL") flashCmd.Flags().StringVar(&authToken, "token", os.Getenv("CAIB_TOKEN"), "Bearer token for authentication") @@ -522,7 +547,7 @@ Example: _ = flashCmd.MarkFlagRequired("client") // Add all commands - rootCmd.AddCommand(buildCmd, diskCmd, buildDevCmd, listCmd, flashCmd, loginCmd, catalog.NewCatalogCmd()) + rootCmd.AddCommand(buildCmd, diskCmd, buildDevCmd, listCmd, downloadCmd, flashCmd, loginCmd, catalog.NewCatalogCmd()) // Add deprecated aliases for backwards compatibility rootCmd.AddCommand(buildBootcAliasCmd, buildLegacyAliasCmd, buildTraditionalAliasCmd) @@ -1317,7 +1342,7 @@ func waitForBuildCompletion(ctx context.Context, api *buildapiclient.Client, nam } // Handle terminal build states - if st.Phase == "Completed" { + if st.Phase == phaseCompleted { flashWasExecuted := strings.Contains(st.Message, "flash") if flashWasExecuted { fmt.Println("\n" + strings.Repeat("=", 50)) @@ -1744,6 +1769,50 @@ func runList(_ *cobra.Command, _ []string) { } } +func runDownload(_ *cobra.Command, args []string) { + ctx := context.Background() + downloadBuildName := args[0] + + if serverURL == "" { + handleError(fmt.Errorf("--server is required (or set CAIB_SERVER, or run 'caib login ')")) + } + + if outputDir == "" { + handleError(fmt.Errorf("--output / -o is required")) + } + + var st *buildapitypes.BuildResponse + err := executeWithReauth(serverURL, &authToken, func(api *buildapiclient.Client) error { + var err error + st, err = api.GetBuild(ctx, downloadBuildName) + return err + }) + if err != nil { + handleError(fmt.Errorf("error getting build %s: %w", downloadBuildName, err)) + } + + if st.Phase != phaseCompleted { + handleError(fmt.Errorf("build %s is not completed (phase: %s), cannot download artifacts", downloadBuildName, st.Phase)) + } + + ociRef := st.DiskImage + if ociRef == "" { + handleError(fmt.Errorf("build %s has no disk image artifact to download (no OCI export was configured)", downloadBuildName)) + } + + // Extract registry credentials from environment + effectiveRegistryURL, registryUsername, registryPassword := extractRegistryCredentials(ociRef, "") + + if err := validateRegistryCredentials(effectiveRegistryURL, registryUsername, registryPassword); err != nil { + handleError(err) + } + + fmt.Printf("Downloading disk image from %s\n", ociRef) + if err := pullOCIArtifact(ociRef, outputDir, registryUsername, registryPassword); err != nil { + handleError(fmt.Errorf("download failed: %w", err)) + } +} + func loadTokenFromKubeconfig() (string, error) { loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() // First, ask client-go to build a client config. This will execute any exec credential plugins @@ -1912,7 +1981,7 @@ func waitForFlashCompletion(ctx context.Context, api *buildapiclient.Client, nam } // Handle terminal states - if st.Phase == "Completed" { + if st.Phase == phaseCompleted { fmt.Println("Flash completed successfully!") return } diff --git a/internal/buildapi/server.go b/internal/buildapi/server.go index 546cb187..3721f5d0 100644 --- a/internal/buildapi/server.go +++ b/internal/buildapi/server.go @@ -1277,7 +1277,9 @@ func getBuild(c *gin.Context, name string) { } return "" }(), - Jumpstarter: jumpstarterInfo, + ContainerImage: build.Spec.GetContainerPush(), + DiskImage: build.Spec.GetExportOCI(), + Jumpstarter: jumpstarterInfo, }) } diff --git a/internal/buildapi/types.go b/internal/buildapi/types.go index a7e7a708..1d0846fc 100644 --- a/internal/buildapi/types.go +++ b/internal/buildapi/types.go @@ -208,6 +208,8 @@ type BuildResponse struct { RequestedBy string `json:"requestedBy,omitempty"` StartTime string `json:"startTime,omitempty"` CompletionTime string `json:"completionTime,omitempty"` + ContainerImage string `json:"containerImage,omitempty"` + DiskImage string `json:"diskImage,omitempty"` Jumpstarter *JumpstarterInfo `json:"jumpstarter,omitempty"` } diff --git a/internal/controller/operatorconfig/resources.go b/internal/controller/operatorconfig/resources.go index 20189644..2b744a75 100644 --- a/internal/controller/operatorconfig/resources.go +++ b/internal/controller/operatorconfig/resources.go @@ -76,10 +76,9 @@ func (r *OperatorConfigReconciler) buildBuildAPIContainers(isOpenShift bool) []c } containers := []corev1.Container{ { - Name: "build-api", - Image: getOperatorImage(), - ImagePullPolicy: corev1.PullIfNotPresent, - Command: []string{"/build-api"}, + Name: "build-api", + Image: getOperatorImage(), + Command: []string{"/build-api"}, Resources: corev1.ResourceRequirements{ Requests: corev1.ResourceList{ corev1.ResourceCPU: resource.MustParse("50m"), @@ -108,9 +107,8 @@ func (r *OperatorConfigReconciler) buildBuildAPIContainers(isOpenShift bool) []c // Only add oauth-proxy on OpenShift if isOpenShift { containers = append(containers, corev1.Container{ - Name: "oauth-proxy", - Image: "registry.redhat.io/openshift4/ose-oauth-proxy:latest", - ImagePullPolicy: corev1.PullIfNotPresent, + Name: "oauth-proxy", + Image: "registry.redhat.io/openshift4/ose-oauth-proxy:latest", Args: []string{ "--provider=openshift", "--https-address=", @@ -199,10 +197,9 @@ func (r *OperatorConfigReconciler) buildBuildAPIDeployment(isOpenShift bool) *ap ServiceAccountName: "ado-controller-manager", InitContainers: []corev1.Container{ { - Name: "init-secrets", - Image: getOperatorImage(), - ImagePullPolicy: corev1.PullIfNotPresent, - Command: []string{"/init-secrets"}, + Name: "init-secrets", + Image: getOperatorImage(), + Command: []string{"/init-secrets"}, Env: []corev1.EnvVar{ { Name: "POD_NAMESPACE",