diff --git a/cmd/caib/README.md b/cmd/caib/README.md index aecd5dcd..9d2575c6 100644 --- a/cmd/caib/README.md +++ b/cmd/caib/README.md @@ -99,7 +99,7 @@ bin/caib build [flags] | `-D`, `--define` | | Custom definition `KEY=VALUE` (repeatable) | | `--timeout` | `60` | Timeout in minutes | | `-w`, `--wait` | `false` | Wait for build to complete | -| `-f`, `--follow` | `false` | Follow build logs | +| `-f`, `--follow` | `true` | Follow build logs | | `--internal-registry` | `false` | Push to OpenShift internal registry (no credentials needed) | | `--image-name` | (build name) | Override image name in internal registry | | `--image-tag` | (build name) | Override tag in internal registry | @@ -177,7 +177,7 @@ bin/caib disk [flags] | `--storage-class` | | Kubernetes storage class | | `--timeout` | `60` | Timeout in minutes | | `-w`, `--wait` | `false` | Wait for build to complete | -| `-f`, `--follow` | `false` | Follow build logs | +| `-f`, `--follow` | `true` | Follow build logs | **Examples:** @@ -225,7 +225,7 @@ bin/caib build-dev [flags] | `-D`, `--define` | | Custom definition `KEY=VALUE` (repeatable) | | `--timeout` | `60` | Timeout in minutes | | `-w`, `--wait` | `false` | Wait for build to complete | -| `-f`, `--follow` | `false` | Follow build logs | +| `-f`, `--follow` | `true` | Follow build logs | **Examples:** @@ -252,16 +252,14 @@ bin/caib build-dev my-manifest.aib.yml \ Downloads artifacts from a completed build. ```bash -bin/caib download --name [flags] +bin/caib download [flags] ``` | Flag | Default | Description | |------|---------|-------------| -| `--name` | (required) | Build name | | `--server` | `$CAIB_SERVER` | Build API server URL | | `--token` | `$CAIB_TOKEN` | Bearer token | -| `--output-dir` | `./output` | Directory to save artifacts | -| `--compress` | `true` | Keep directory artifacts compressed | +| `-o`, `--output` | (required) | Destination file or directory for downloaded artifact | ### list @@ -276,6 +274,31 @@ bin/caib list [flags] | `--server` | `$CAIB_SERVER` | Build API server URL | | `--token` | `$CAIB_TOKEN` | Bearer token | +### show + +Shows detailed information for a single build, including current status and resolved build parameters. + +```bash +bin/caib show [flags] +``` + +| Flag | Default | Description | +|------|---------|-------------| +| `--server` | `$CAIB_SERVER` | Build API server URL | +| `--token` | `$CAIB_TOKEN` | Bearer token | +| `-o`, `--output` | `table` | Output format: `table`, `json`, `yaml` | + +**Examples:** + +```bash +# Human-friendly detail view +bin/caib show my-build + +# Machine-readable output +bin/caib show my-build -o json +bin/caib show my-build -o yaml +``` + ## Bootc vs Dev Builds | Aspect | `build` (bootc) | `build-dev` | diff --git a/cmd/caib/main.go b/cmd/caib/main.go index b1061887..d955d00c 100644 --- a/cmd/caib/main.go +++ b/cmd/caib/main.go @@ -60,6 +60,7 @@ var ( serverURL string manifest string buildName string + showOutputFormat string distro string target string architecture string @@ -469,6 +470,21 @@ Examples: Run: runList, } + showCmd := &cobra.Command{ + Use: "show ", + Short: "Show detailed information for an ImageBuild", + Long: `Show retrieves detailed status and output fields for a single ImageBuild. + +Examples: + # Show details in table format + caib show my-build + + # Show details as JSON + caib show my-build -o json`, + Args: cobra.ExactArgs(1), + Run: runShow, + } + downloadCmd := &cobra.Command{ Use: "download ", Short: "Download disk image artifact from a completed build", @@ -544,6 +560,16 @@ Example: &authToken, "token", os.Getenv("CAIB_TOKEN"), "Bearer token for authentication (e.g., OpenShift access token)", ) + showCmd.Flags().StringVar( + &serverURL, "server", config.DefaultServer(), "REST API server base URL (e.g. https://api.example)", + ) + showCmd.Flags().StringVar( + &authToken, "token", os.Getenv("CAIB_TOKEN"), + "Bearer token for authentication (e.g., OpenShift access token)", + ) + showCmd.Flags().StringVarP( + &showOutputFormat, "output", "o", "table", "Output format (table, json, yaml)", + ) // disk command flags (create disk from existing container) diskCmd.Flags().StringVar(&serverURL, "server", config.DefaultServer(), "REST API server base URL") @@ -625,7 +651,7 @@ Example: _ = flashCmd.MarkFlagRequired("client") // Add all commands - rootCmd.AddCommand(buildCmd, diskCmd, buildDevCmd, listCmd, downloadCmd, flashCmd, loginCmd, catalog.NewCatalogCmd()) + rootCmd.AddCommand(buildCmd, diskCmd, buildDevCmd, listCmd, showCmd, downloadCmd, flashCmd, loginCmd, catalog.NewCatalogCmd()) // Add deprecated aliases for backwards compatibility rootCmd.AddCommand(buildBootcAliasCmd, buildLegacyAliasCmd, buildTraditionalAliasCmd) @@ -1902,6 +1928,156 @@ func runList(_ *cobra.Command, _ []string) { } } +func runShow(_ *cobra.Command, args []string) { + ctx := context.Background() + showBuildName := args[0] + + if strings.TrimSpace(serverURL) == "" { + handleError(fmt.Errorf("--server is required (or set CAIB_SERVER, or run 'caib login ')")) + } + + var st *buildapitypes.BuildResponse + err := executeWithReauth(serverURL, &authToken, func(api *buildapiclient.Client) error { + var err error + st, err = api.GetBuild(ctx, showBuildName) + return err + }) + if err != nil { + handleError(fmt.Errorf("error getting ImageBuild %s: %w", showBuildName, err)) + } + + // Backward-compatible fallback for older API servers that do not yet include response parameters. + if st.Parameters == nil { + _ = executeWithReauth(serverURL, &authToken, func(api *buildapiclient.Client) error { + tpl, err := api.GetBuildTemplate(ctx, showBuildName) + if err != nil { + return err + } + st.Parameters = buildParametersFromTemplate(tpl) + return nil + }) + } + + switch strings.ToLower(showOutputFormat) { + case "json": + out, err := json.MarshalIndent(st, "", " ") + if err != nil { + handleError(fmt.Errorf("error rendering JSON output: %w", err)) + } + fmt.Println(string(out)) + case "yaml", "yml": + out, err := yaml.Marshal(st) + if err != nil { + handleError(fmt.Errorf("error rendering YAML output: %w", err)) + } + fmt.Print(string(out)) + case "table": + printBuildDetails(st) + default: + handleError(fmt.Errorf("invalid output format %q (supported: table, json, yaml)", showOutputFormat)) + } +} + +func buildParametersFromTemplate(tpl *buildapitypes.BuildTemplateResponse) *buildapitypes.BuildParameters { + if tpl == nil { + return nil + } + + params := &buildapitypes.BuildParameters{ + Architecture: string(tpl.Architecture), + Distro: string(tpl.Distro), + Target: string(tpl.Target), + Mode: string(tpl.Mode), + ExportFormat: string(tpl.ExportFormat), + Compression: tpl.Compression, + StorageClass: tpl.StorageClass, + AutomotiveImageBuilder: tpl.AutomotiveImageBuilder, + BuilderImage: tpl.BuilderImage, + ContainerRef: tpl.ContainerRef, + BuildDiskImage: tpl.BuildDiskImage, + FlashEnabled: tpl.FlashEnabled, + FlashLeaseDuration: tpl.FlashLeaseDuration, + UseServiceAccountAuth: tpl.UseInternalRegistry, + } + + if strings.TrimSpace(params.Architecture) == "" && + strings.TrimSpace(params.Distro) == "" && + strings.TrimSpace(params.Target) == "" && + strings.TrimSpace(params.Mode) == "" && + strings.TrimSpace(params.ExportFormat) == "" && + strings.TrimSpace(params.Compression) == "" && + strings.TrimSpace(params.StorageClass) == "" && + strings.TrimSpace(params.AutomotiveImageBuilder) == "" && + strings.TrimSpace(params.BuilderImage) == "" && + strings.TrimSpace(params.ContainerRef) == "" && + strings.TrimSpace(params.FlashLeaseDuration) == "" && + !params.BuildDiskImage && + !params.FlashEnabled && + !params.UseServiceAccountAuth { + return nil + } + + return params +} + +func printBuildDetails(st *buildapitypes.BuildResponse) { + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + defer func() { + if err := w.Flush(); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to flush output: %v\n", err) + } + }() + + rows := [][2]string{ + {"Name", st.Name}, + {"Phase", st.Phase}, + {"Message", st.Message}, + {"Requested By", valueOrDash(st.RequestedBy)}, + {"Start Time", valueOrDash(st.StartTime)}, + {"Completion Time", valueOrDash(st.CompletionTime)}, + {"Container Image", valueOrDash(st.ContainerImage)}, + {"Disk Image", valueOrDash(st.DiskImage)}, + {"Warning", valueOrDash(st.Warning)}, + } + + if st.Parameters != nil { + rows = append(rows, + [2]string{"Architecture", valueOrDash(st.Parameters.Architecture)}, + [2]string{"Distro", valueOrDash(st.Parameters.Distro)}, + [2]string{"Target", valueOrDash(st.Parameters.Target)}, + [2]string{"Mode", valueOrDash(st.Parameters.Mode)}, + [2]string{"Export Format", valueOrDash(st.Parameters.ExportFormat)}, + [2]string{"Compression", valueOrDash(st.Parameters.Compression)}, + [2]string{"Storage Class", valueOrDash(st.Parameters.StorageClass)}, + [2]string{"AIB Image", valueOrDash(st.Parameters.AutomotiveImageBuilder)}, + [2]string{"Builder Image", valueOrDash(st.Parameters.BuilderImage)}, + ) + } + + if st.Jumpstarter != nil { + rows = append(rows, + [2]string{"Jumpstarter Available", fmt.Sprintf("%t", st.Jumpstarter.Available)}, + [2]string{"Jumpstarter Exporter", valueOrDash(st.Jumpstarter.ExporterSelector)}, + [2]string{"Jumpstarter Flash Cmd", valueOrDash(st.Jumpstarter.FlashCmd)}, + [2]string{"Jumpstarter Lease ID", valueOrDash(st.Jumpstarter.LeaseID)}, + ) + } + + for _, row := range rows { + if _, err := fmt.Fprintf(w, "%s\t%s\n", row[0], row[1]); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to write output row: %v\n", err) + return + } + } +} + +func valueOrDash(v string) string { + if strings.TrimSpace(v) == "" { + return "-" + } + return v +} + func formatAge(rfcTime string) string { t, err := time.Parse(time.RFC3339, rfcTime) if err != nil { diff --git a/internal/buildapi/client/client.go b/internal/buildapi/client/client.go index 91563735..2eeda5bf 100644 --- a/internal/buildapi/client/client.go +++ b/internal/buildapi/client/client.go @@ -167,9 +167,9 @@ func (c *Client) GetBuild(ctx context.Context, name string) (*buildapi.BuildResp return &out, nil } -// ListBuilds retrieves a list of all builds from the API server. -func (c *Client) ListBuilds(ctx context.Context) ([]buildapi.BuildListItem, error) { - endpoint := c.resolve("/v1/builds") +// GetBuildTemplate retrieves a build template reconstructed from ImageBuild inputs. +func (c *Client) GetBuildTemplate(ctx context.Context, name string) (*buildapi.BuildTemplateResponse, error) { + endpoint := c.resolve(path.Join("/v1/builds", url.PathEscape(name), "template")) req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return nil, err @@ -188,12 +188,21 @@ func (c *Client) ListBuilds(ctx context.Context) ([]buildapi.BuildListItem, erro }() if resp.StatusCode != http.StatusOK { b, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) - return nil, fmt.Errorf("list builds failed: %s: %s", resp.Status, string(b)) + return nil, fmt.Errorf("get build template failed: %s: %s", resp.Status, string(b)) } - var out []buildapi.BuildListItem + var out buildapi.BuildTemplateResponse if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { return nil, err } + return &out, nil +} + +// ListBuilds retrieves a list of all builds from the API server. +func (c *Client) ListBuilds(ctx context.Context) ([]buildapi.BuildListItem, error) { + var out []buildapi.BuildListItem + if err := c.listJSON(ctx, c.resolve("/v1/builds"), "list builds", &out); err != nil { + return nil, err + } return out, nil } @@ -277,17 +286,25 @@ func (c *Client) GetFlash(ctx context.Context, name string) (*buildapi.FlashResp // ListFlash retrieves a list of all flash jobs from the API server. func (c *Client) ListFlash(ctx context.Context) ([]buildapi.FlashListItem, error) { - endpoint := c.resolve("/v1/flash") + var out []buildapi.FlashListItem + if err := c.listJSON(ctx, c.resolve("/v1/flash"), "list flash", &out); err != nil { + return nil, err + } + return out, nil +} + +// listJSON performs a list-style GET request and decodes a JSON array response into out. +func (c *Client) listJSON(ctx context.Context, endpoint, operation string, out any) error { req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { - return nil, err + return err } if c.authToken != "" { req.Header.Set("Authorization", "Bearer "+c.authToken) } resp, err := c.httpClient.Do(req) if err != nil { - return nil, err + return err } defer func() { if err := resp.Body.Close(); err != nil { @@ -296,13 +313,12 @@ func (c *Client) ListFlash(ctx context.Context) ([]buildapi.FlashListItem, error }() if resp.StatusCode != http.StatusOK { b, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) - return nil, fmt.Errorf("list flash failed: %s: %s", resp.Status, string(b)) + return fmt.Errorf("%s failed: %s: %s", operation, resp.Status, string(b)) } - var out []buildapi.FlashListItem - if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { - return nil, err + if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + return err } - return out, nil + return nil } // Upload represents a file to upload to the build API. diff --git a/internal/buildapi/server.go b/internal/buildapi/server.go index b5f1be7e..bf9fe168 100644 --- a/internal/buildapi/server.go +++ b/internal/buildapi/server.go @@ -1596,6 +1596,22 @@ func (a *APIServer) getBuild(c *gin.Context, name string) { RegistryToken: registryToken, Warning: warning, Jumpstarter: jumpstarterInfo, + Parameters: &BuildParameters{ + Architecture: build.Spec.Architecture, + Distro: build.Spec.GetDistro(), + Target: build.Spec.GetTarget(), + Mode: build.Spec.GetMode(), + ExportFormat: build.Spec.GetExportFormat(), + Compression: build.Spec.GetCompression(), + StorageClass: build.Spec.StorageClass, + AutomotiveImageBuilder: build.Spec.GetAIBImage(), + BuilderImage: build.Spec.GetBuilderImage(), + ContainerRef: build.Spec.GetContainerRef(), + BuildDiskImage: build.Spec.GetBuildDiskImage(), + FlashEnabled: build.Spec.IsFlashEnabled(), + FlashLeaseDuration: build.Spec.GetFlashLeaseDuration(), + UseServiceAccountAuth: build.Spec.GetUseServiceAccountAuth(), + }, }) } diff --git a/internal/buildapi/types.go b/internal/buildapi/types.go index 0bbcb7c1..829a6447 100644 --- a/internal/buildapi/types.go +++ b/internal/buildapi/types.go @@ -218,6 +218,25 @@ type BuildResponse struct { RegistryToken string `json:"registryToken,omitempty"` Warning string `json:"warning,omitempty"` Jumpstarter *JumpstarterInfo `json:"jumpstarter,omitempty"` + Parameters *BuildParameters `json:"parameters,omitempty"` +} + +// BuildParameters describes the key input parameters that produced an ImageBuild. +type BuildParameters struct { + Architecture string `json:"architecture,omitempty"` + Distro string `json:"distro,omitempty"` + Target string `json:"target,omitempty"` + Mode string `json:"mode,omitempty"` + ExportFormat string `json:"exportFormat,omitempty"` + Compression string `json:"compression,omitempty"` + StorageClass string `json:"storageClass,omitempty"` + AutomotiveImageBuilder string `json:"automotiveImageBuilder,omitempty"` + BuilderImage string `json:"builderImage,omitempty"` + ContainerRef string `json:"containerRef,omitempty"` + BuildDiskImage bool `json:"buildDiskImage,omitempty"` + FlashEnabled bool `json:"flashEnabled,omitempty"` + FlashLeaseDuration string `json:"flashLeaseDuration,omitempty"` + UseServiceAccountAuth bool `json:"useServiceAccountAuth,omitempty"` } // BuildListItem represents a build in the list API