diff --git a/Makefile b/Makefile index d5838eaf..b20d5ea9 100644 --- a/Makefile +++ b/Makefile @@ -346,7 +346,7 @@ catalog-update: ## Update catalog configuration with current bundle image .PHONY: build-caib build-caib: ## Build the caib tool - go build -tags containers_image_openpgp -ldflags "-X main.version=$(VERSION)" -o bin/caib cmd/caib/main.go + go build -tags containers_image_openpgp -ldflags "-X main.version=$(VERSION)" -o bin/caib ./cmd/caib .PHONY: build-api-server build-api-server: ## Build the api server diff --git a/cmd/caib/README.md b/cmd/caib/README.md index 9d2575c6..7f3eaf87 100644 --- a/cmd/caib/README.md +++ b/cmd/caib/README.md @@ -25,10 +25,9 @@ export CAIB_SERVER=https://your-build-api.example Build a bootc container and push it to a registry: ```bash -bin/caib build manifest.aib.yml \ +bin/caib image build manifest.aib.yml \ --arch arm64 \ - --push quay.io/myorg/automotive-os:latest \ - --follow + --push quay.io/myorg/automotive-os:latest ``` Systems running bootc can then switch to this image: @@ -42,13 +41,12 @@ bootc switch quay.io/myorg/automotive-os:latest Build a bootc container and also create a disk image from it: ```bash -bin/caib build manifest.aib.yml \ +bin/caib image build manifest.aib.yml \ --arch arm64 \ --push quay.io/myorg/automotive-os:latest \ --disk \ --push-disk quay.io/myorg/automotive-disk:latest \ - -o ./output/disk.qcow2 \ - --follow + -o ./output/disk.qcow2 ``` ### Build a Development (Non-Bootc) Image @@ -56,22 +54,23 @@ bin/caib build manifest.aib.yml \ Build an ostree-based or package-based disk image for development: ```bash -bin/caib build-dev manifest.aib.yml \ +bin/caib image build-dev manifest.aib.yml \ --arch arm64 \ --mode image \ --format qcow2 \ - -o ./output/disk.qcow2 \ - --follow + -o ./output/disk.qcow2 ``` ## Commands -### build +All image workflow commands live under `caib image`. + +### image build Builds a bootc container image with optional disk image creation. This is the recommended approach for production. ```bash -bin/caib build [flags] +bin/caib image build [flags] ``` **Required flags:** @@ -108,56 +107,50 @@ bin/caib build [flags] ```bash # Build and push bootc container only -bin/caib build my-manifest.aib.yml \ +bin/caib image build my-manifest.aib.yml \ --arch arm64 \ - --push quay.io/myorg/automotive:v1.0 \ - --follow + --push quay.io/myorg/automotive:v1.0 # Build bootc container + qcow2 disk image, download locally -bin/caib build my-manifest.aib.yml \ +bin/caib image build my-manifest.aib.yml \ --arch arm64 \ --push quay.io/myorg/automotive:v1.0 \ --disk \ --format qcow2 \ --push-disk quay.io/myorg/automotive-disk:v1.0 \ - -o ./my-image.qcow2 \ - --follow + -o ./my-image.qcow2 # Push to OpenShift internal registry (no credentials required) -bin/caib build my-manifest.aib.yml \ +bin/caib image build my-manifest.aib.yml \ --arch arm64 \ - --internal-registry \ - --follow + --internal-registry # Internal registry with custom image name and tag -bin/caib build my-manifest.aib.yml \ +bin/caib image build my-manifest.aib.yml \ --arch arm64 \ --internal-registry \ --image-name my-automotive-os \ - --image-tag v1.0 \ - --follow + --image-tag v1.0 # Internal registry with disk image -bin/caib build my-manifest.aib.yml \ +bin/caib image build my-manifest.aib.yml \ --arch arm64 \ --internal-registry \ - --disk \ - --follow + --disk # Use custom builder image -bin/caib build my-manifest.aib.yml \ +bin/caib image build my-manifest.aib.yml \ --arch amd64 \ --builder-image quay.io/myorg/my-aib-build:latest \ - --push quay.io/myorg/result:latest \ - --follow + --push quay.io/myorg/result:latest ``` -### disk +### image disk Creates a disk image from an existing bootc container in a registry. ```bash -bin/caib disk [flags] +bin/caib image disk [flags] ``` **Optional flags:** @@ -183,23 +176,21 @@ bin/caib disk [flags] ```bash # Create disk image from container, download locally -bin/caib disk quay.io/myorg/my-os:v1 \ +bin/caib image disk quay.io/myorg/my-os:v1 \ -o ./disk.qcow2 \ - --format qcow2 \ - --wait + --format qcow2 # Push disk as OCI artifact instead of downloading -bin/caib disk quay.io/myorg/my-os:v1 \ - --push quay.io/myorg/my-disk:v1 \ - --follow +bin/caib image disk quay.io/myorg/my-os:v1 \ + --push quay.io/myorg/my-disk:v1 ``` -### build-dev +### image build-dev Builds a disk image (ostree or package-based) for development workflows. Creates standalone disk images without bootc container integration. ```bash -bin/caib build-dev [flags] +bin/caib image build-dev [flags] ``` **Required flags:** @@ -231,28 +222,101 @@ bin/caib build-dev [flags] ```bash # Build ostree-based image and download -bin/caib build-dev my-manifest.aib.yml \ +bin/caib image build-dev my-manifest.aib.yml \ --arch arm64 \ --mode image \ --format qcow2 \ - -o ./disk.qcow2 \ - --follow + -o ./disk.qcow2 # Build and push to OCI registry (requires REGISTRY_USERNAME/REGISTRY_PASSWORD env vars) -bin/caib build-dev my-manifest.aib.yml \ +bin/caib image build-dev my-manifest.aib.yml \ --arch arm64 \ --mode image \ --format qcow2 \ - --push quay.io/myorg/disk-image:v1.0 \ - --follow + --push quay.io/myorg/disk-image:v1.0 +``` + +### image reseal / prepare-reseal / extract-for-signing / inject-signed + +Sealed operations manage TPM-based image sealing for secure boot workflows. All sealed commands share a common set of flags. + +**Shared flags:** +| Flag | Default | Description | +|------|---------|-------------| +| `--server` | `$CAIB_SERVER` | Build API server URL | +| `--token` | `$CAIB_TOKEN` | Bearer token | +| `--input` | | Input/source container ref (alternative to positional) | +| `--output` | | Output container ref (alternative to positional) | +| `--aib-image` | `quay.io/.../automotive-image-builder:latest` | AIB container image | +| `--builder-image` | | Builder container image (overrides `--arch` default) | +| `--arch` | (auto-detected) | Target architecture (`amd64`, `arm64`) | +| `--key` | | Path to local PEM key file | +| `--passwd` | | Password for encrypted key file | +| `--key-secret` | | Name of cluster secret containing sealing key | +| `--key-password-secret` | | Name of cluster secret containing key password | +| `--registry-auth-file` | | Path to Docker/Podman auth file for registry authentication | +| `--extra-args` | | Extra arguments to pass to AIB (repeatable) | +| `--timeout` | `120` | Timeout in minutes | +| `-w`, `--wait` | `false` | Wait for completion | +| `-f`, `--follow` | `true` | Stream task logs | + +#### reseal + +Reseal a bootc container image with a new TPM key. If no key is provided, an ephemeral key is generated. + +```bash +bin/caib image reseal [flags] ``` -### download +**Examples:** + +```bash +# Reseal with ephemeral key +bin/caib image reseal quay.io/myorg/my-os:v1 quay.io/myorg/my-os:resealed + +# Reseal with explicit key +bin/caib image reseal quay.io/myorg/my-os:v1 quay.io/myorg/my-os:resealed \ + --key ./seal-key.pem + +# Using --input/--output flags instead of positionals +bin/caib image reseal --input quay.io/myorg/my-os:v1 --output quay.io/myorg/my-os:resealed +``` + +#### prepare-reseal + +Prepare a bootc container image for resealing (first step in a two-step seal workflow). + +```bash +bin/caib image prepare-reseal [flags] +``` + +#### extract-for-signing + +Extract components from a container image for external signing (e.g. secure boot). + +```bash +bin/caib image extract-for-signing [flags] +``` + +#### inject-signed + +Inject externally signed components back into a container image. + +```bash +bin/caib image inject-signed [flags] +``` + +Additional flag: +| Flag | Description | +|------|-------------| +| `--signed` | Signed artifact ref (alternative to positional) | + +### image download Downloads artifacts from a completed build. ```bash -bin/caib download [flags] +bin/caib image download [flags] ``` | Flag | Default | Description | @@ -261,12 +325,12 @@ bin/caib download [flags] | `--token` | `$CAIB_TOKEN` | Bearer token | | `-o`, `--output` | (required) | Destination file or directory for downloaded artifact | -### list +### image list Lists existing builds. ```bash -bin/caib list [flags] +bin/caib image list [flags] ``` | Flag | Default | Description | @@ -274,12 +338,12 @@ bin/caib list [flags] | `--server` | `$CAIB_SERVER` | Build API server URL | | `--token` | `$CAIB_TOKEN` | Bearer token | -### show +### image show Shows detailed information for a single build, including current status and resolved build parameters. ```bash -bin/caib show [flags] +bin/caib image show [flags] ``` | Flag | Default | Description | @@ -292,11 +356,11 @@ bin/caib show [flags] ```bash # Human-friendly detail view -bin/caib show my-build +bin/caib image show my-build # Machine-readable output -bin/caib show my-build -o json -bin/caib show my-build -o yaml +bin/caib image show my-build -o json +bin/caib image show my-build -o yaml ``` ## Bootc vs Dev Builds @@ -317,10 +381,15 @@ The CLI automatically detects authentication in this order: 3. Bearer token from kubeconfig (OpenShift `oc login`, exec plugins) 4. `oc whoami -t` command (if `oc` is available) -For registry authentication (`--push`, `--push-disk`): +For registry authentication (`--push`, `--push-disk`, sealed operations): -1. `REGISTRY_USERNAME` / `REGISTRY_PASSWORD` environment variables -2. Docker/Podman auth files (`~/.docker/config.json`, `~/.config/containers/auth.json`) +1. `--registry-auth-file` flag — explicit path to a Docker/Podman auth file (highest priority) +2. `REGISTRY_USERNAME` / `REGISTRY_PASSWORD` environment variables +3. Auto-discovery of auth files from standard locations: + - `$REGISTRY_AUTH_FILE` environment variable + - `$XDG_RUNTIME_DIR/containers/auth.json` + - `/run/containers//auth.json` + - `~/.config/containers/auth.json` For the OpenShift internal registry (`--internal-registry`): @@ -349,6 +418,7 @@ Supported locations: | `CAIB_TOKEN` | Bearer token (equivalent to `--token`) | | `REGISTRY_USERNAME` | Registry username for push operations | | `REGISTRY_PASSWORD` | Registry password for push operations | +| `REGISTRY_AUTH_FILE` | Path to Docker/Podman auth file (auto-discovery candidate) | ## Timeouts and Retries @@ -370,7 +440,7 @@ Supported locations: | HTTP 503/504 during log follow | Build pod starting | CLI retries automatically | | Build fails after upload | PVC transition timing | Increase `--timeout`, check operator logs | | "no bearer token found" | Not logged in | Run `oc login` or set `CAIB_TOKEN` | -| Registry auth failure | Missing credentials | Set `REGISTRY_USERNAME/REGISTRY_PASSWORD` env vars or login via `podman login` | +| Registry auth failure | Missing credentials | Run `podman login`, set `REGISTRY_USERNAME/REGISTRY_PASSWORD` env vars, or use `--registry-auth-file` | ## Version diff --git a/cmd/caib/build_validation.go b/cmd/caib/build_validation.go new file mode 100644 index 00000000..63209e3f --- /dev/null +++ b/cmd/caib/build_validation.go @@ -0,0 +1,10 @@ +package main + +import ( + common "github.com/centos-automotive-suite/automotive-dev-operator/cmd/caib/common" +) + +// sanitizeBuildName is kept in main package for existing tests. +func sanitizeBuildName(name string) string { + return common.SanitizeBuildName(name) +} diff --git a/cmd/caib/buildcmd/build.go b/cmd/caib/buildcmd/build.go new file mode 100644 index 00000000..ae8a3d1b --- /dev/null +++ b/cmd/caib/buildcmd/build.go @@ -0,0 +1,773 @@ +// Package buildcmd provides handlers for image build workflows. +package buildcmd + +import ( + "context" + "encoding/base64" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + common "github.com/centos-automotive-suite/automotive-dev-operator/cmd/caib/common" + "github.com/centos-automotive-suite/automotive-dev-operator/cmd/caib/registryauth" + buildapitypes "github.com/centos-automotive-suite/automotive-dev-operator/internal/buildapi" + buildapiclient "github.com/centos-automotive-suite/automotive-dev-operator/internal/buildapi/client" + "github.com/fatih/color" + "github.com/spf13/cobra" +) + +const ( + phaseCompleted = "Completed" + phaseFailed = "Failed" + phaseFlashing = "Flashing" + phasePending = "Pending" + phaseRunning = "Running" + phaseUploading = "Uploading" + + errPrefixBuild = "build" + errPrefixFlash = "flash" + errPrefixPush = "push" +) + +// Options wires build handlers to caller-owned state and helper functions. +type Options struct { + ServerURL *string + Manifest *string + BuildName *string + Distro *string + Target *string + Architecture *string + ExportFormat *string + Mode *string + AutomotiveImageBuilder *string + StorageClass *string + OutputDir *string + Timeout *int + WaitForBuild *bool + CustomDefs *[]string + AIBExtraArgs *[]string + FollowLogs *bool + CompressionAlgo *string + AuthToken *string + ContainerPush *string + BuildDiskImage *bool + DiskFormat *string + ExportOCI *string + BuilderImage *string + RegistryAuthFile *string + ContainerRef *string + RebuildBuilder *bool + FlashAfterBuild *bool + JumpstarterClient *string + LeaseDuration *string + + UseInternalRegistry *bool + InternalRegistryImageName *string + InternalRegistryTag *string + + InsecureSkipTLS *bool + + HandleError func(error) +} + +// Handler implements image build command run functions. +type Handler struct { + opts Options +} + +// NewHandler creates a build workflow handler. +func NewHandler(opts Options) *Handler { + return &Handler{opts: opts} +} + +func (h *Handler) handleError(err error) { + if h.opts.HandleError != nil { + h.opts.HandleError(err) + return + } + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) +} + +func (h *Handler) supportsColorOutput() bool { + return common.SupportsColorOutput() +} + +func (h *Handler) applyWaitFollowDefaults(cmd *cobra.Command, defaultWait, defaultFollow bool) { + if cmd == nil { + return + } + if !cmd.Flags().Changed("wait") { + *h.opts.WaitForBuild = defaultWait + } + if !cmd.Flags().Changed("follow") { + *h.opts.FollowLogs = defaultFollow + } +} + +// validateBootcBuildFlags validates flag combinations for the build command. +func (h *Handler) validateBootcBuildFlags() error { + if strings.TrimSpace(*h.opts.ServerURL) == "" { + return fmt.Errorf("--server is required (or set CAIB_SERVER, or run 'caib login ')") + } + + if *h.opts.UseInternalRegistry && *h.opts.ExportOCI != "" { + return fmt.Errorf("--internal-registry cannot be used with --push-disk") + } + + if *h.opts.OutputDir != "" && !*h.opts.BuildDiskImage { + *h.opts.BuildDiskImage = true + } + if *h.opts.ExportOCI != "" && !*h.opts.BuildDiskImage { + *h.opts.BuildDiskImage = true + } + if *h.opts.FlashAfterBuild && !*h.opts.BuildDiskImage { + *h.opts.BuildDiskImage = true + } + if !*h.opts.UseInternalRegistry { + if err := common.ValidateOutputRequiresPush(*h.opts.OutputDir, *h.opts.ExportOCI, "--push-disk"); err != nil { + return err + } + } + + if *h.opts.ContainerPush == "" && !*h.opts.BuildDiskImage && !*h.opts.UseInternalRegistry { + return fmt.Errorf( + "--push is required when not building a disk image " + + "(use --disk or --output to create a disk image without pushing the container)", + ) + } + + return nil +} + +// applyRegistryCredentialsToRequest sets registry credentials on the build request. +// When --internal-registry is combined with --push, both are configured so the +// container is pushed externally while the disk image uses the internal registry. +func (h *Handler) applyRegistryCredentialsToRequest(req *buildapitypes.BuildRequest) error { + if *h.opts.UseInternalRegistry { + req.UseInternalRegistry = true + req.InternalRegistryImageName = *h.opts.InternalRegistryImageName + req.InternalRegistryTag = *h.opts.InternalRegistryTag + if *h.opts.ContainerPush == "" { + return nil + } + // Hybrid: fall through to also set external registry credentials + // for the container push. + } + + effectiveRegistryURL, registryUsername, registryPassword := registryauth.ExtractRegistryCredentials(*h.opts.ContainerPush, *h.opts.ExportOCI) + registryCreds, err := registryauth.ResolveRegistryCredentials( + effectiveRegistryURL, + registryUsername, + registryPassword, + *h.opts.RegistryAuthFile, + ) + if err != nil { + return err + } + req.RegistryCredentials = registryCreds + return nil +} + +// fetchTargetDefaults fetches the operator config once and returns it. +// If flash is enabled, it also validates that the target has a Jumpstarter mapping. +func (h *Handler) fetchTargetDefaults( + ctx context.Context, + api *buildapiclient.Client, + target string, + validateFlash bool, +) (*buildapitypes.OperatorConfigResponse, error) { + 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, nil + } + return nil, fmt.Errorf("failed to get operator configuration for Jumpstarter validation: %w", err) + } + + if validateFlash { + if len(config.JumpstarterTargets) == 0 { + return nil, 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) + } + return nil, fmt.Errorf( + "flash enabled but no Jumpstarter target mapping found for target %q. Available targets: %v", + target, + availableTargets, + ) + } + } + + return config, nil +} + +// ApplyTargetDefaults applies architecture and extra-args defaults from the operator +// target defaults. CLI flags override defaults when explicitly set. +func ApplyTargetDefaults(cmd *cobra.Command, config *buildapitypes.OperatorConfigResponse, req *buildapitypes.BuildRequest) { + if config == nil || len(config.TargetDefaults) == 0 { + return + } + + defaults, exists := config.TargetDefaults[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 defaults for %q\n", defaults.Architecture, req.Target) + } + + if len(defaults.ExtraArgs) > 0 { + // Default args come first, user args appended. + req.AIBExtraArgs = append(defaults.ExtraArgs, req.AIBExtraArgs...) + fmt.Printf("Prepending extra args %v from target defaults for %q\n", defaults.ExtraArgs, req.Target) + } +} + +// displayBuildResults shows push locations after build completion. +func (h *Handler) displayBuildResults(ctx context.Context, api *buildapiclient.Client, buildName string) { + labelColor := func(a ...any) string { return fmt.Sprint(a...) } + valueColor := func(a ...any) string { return fmt.Sprint(a...) } + if h.supportsColorOutput() { + labelColor = color.New(color.FgHiWhite, color.Bold).SprintFunc() + valueColor = color.New(color.FgHiGreen).SprintFunc() + } + + if *h.opts.UseInternalRegistry { + st, err := api.GetBuild(ctx, buildName) + if err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to get build results for %s: %v\n", buildName, err) + return + } + if st.ContainerImage != "" { + fmt.Printf("%s %s\n", labelColor("Container image:"), valueColor(st.ContainerImage)) + } + if st.DiskImage != "" { + fmt.Printf("%s %s\n", labelColor("Disk image:"), valueColor(st.DiskImage)) + } + if st.RegistryToken != "" { + if *h.opts.OutputDir != "" && st.DiskImage != "" { + if err := common.PullOCIArtifact( + st.DiskImage, + *h.opts.OutputDir, + "serviceaccount", + st.RegistryToken, + *h.opts.InsecureSkipTLS, + ); err != nil { + h.handleError(fmt.Errorf("failed to download OCI artifact: %w", err)) + return + } + } else { + credsFile, credsErr := common.WriteRegistryCredentialsFile(st.RegistryToken) + if credsErr != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to write registry credentials file: %v\n", credsErr) + fmt.Printf("\n%s\n", labelColor("Registry credentials (valid ~4 hours):")) + fmt.Printf(" %s %s\n", labelColor("Username:"), valueColor("serviceaccount")) + fmt.Printf(" %s %s\n", labelColor("Token:"), valueColor(st.RegistryToken)) + } else { + fmt.Printf("\n%s %s (valid ~4 hours)\n", + labelColor("Registry credentials written to:"), + valueColor(credsFile), + ) + } + } + } + return + } + + if *h.opts.ContainerPush != "" { + fmt.Printf("%s %s\n", labelColor("Container image pushed to:"), valueColor(*h.opts.ContainerPush)) + } + if *h.opts.ExportOCI != "" { + fmt.Printf("%s %s\n", labelColor("Disk image pushed to:"), valueColor(*h.opts.ExportOCI)) + } + if *h.opts.OutputDir != "" { + _, registryUsername, registryPassword := registryauth.ExtractRegistryCredentials(*h.opts.ContainerPush, *h.opts.ExportOCI) + if err := common.PullOCIArtifact( + *h.opts.ExportOCI, + *h.opts.OutputDir, + registryUsername, + registryPassword, + *h.opts.InsecureSkipTLS, + ); err != nil { + h.handleError(fmt.Errorf("failed to download OCI artifact: %w", err)) + return + } + } +} + +func (h *Handler) displayBuildLogsCommand(buildName string) { + labelColor := func(a ...any) string { return fmt.Sprint(a...) } + commandColor := func(a ...any) string { return fmt.Sprint(a...) } + if h.supportsColorOutput() { + labelColor = color.New(color.FgHiWhite, color.Bold).SprintFunc() + commandColor = color.New(color.FgHiYellow, color.Bold).SprintFunc() + } + + fmt.Printf("\n%s\n %s\n\n", labelColor("View build logs:"), commandColor("caib image logs "+buildName)) +} + +// RunBuild handles the main `caib image build` command. +func (h *Handler) RunBuild(cmd *cobra.Command, args []string) { + h.applyWaitFollowDefaults(cmd, true, false) + + ctx := context.Background() + manifestPath := args[0] + *h.opts.Manifest = manifestPath + + if err := common.ValidateManifestSuffix(manifestPath); err != nil { + h.handleError(err) + return + } + if err := h.validateBootcBuildFlags(); err != nil { + h.handleError(err) + return + } + + if *h.opts.BuildName == "" { + base := filepath.Base(manifestPath) + base = strings.TrimSuffix(base, ".aib.yml") + base = strings.TrimSuffix(base, ".mpp.yml") + sanitized := common.SanitizeBuildName(base) + *h.opts.BuildName = fmt.Sprintf("%s-%s", sanitized, time.Now().Format("20060102-150405")) + fmt.Printf("Auto-generated build name: %s\n", *h.opts.BuildName) + } else if err := common.ValidateBuildName(*h.opts.BuildName); err != nil { + h.handleError(err) + return + } + + api, err := common.CreateBuildAPIClient(*h.opts.ServerURL, h.opts.AuthToken, *h.opts.InsecureSkipTLS) + if err != nil { + h.handleError(err) + return + } + + manifestBytes, err := os.ReadFile(manifestPath) + if err != nil { + h.handleError(fmt.Errorf("error reading manifest: %w", err)) + return + } + + req := buildapitypes.BuildRequest{ + Name: *h.opts.BuildName, + Manifest: string(manifestBytes), + ManifestFileName: filepath.Base(manifestPath), + Distro: buildapitypes.Distro(*h.opts.Distro), + Target: buildapitypes.Target(*h.opts.Target), + Architecture: buildapitypes.Architecture(*h.opts.Architecture), + ExportFormat: buildapitypes.ExportFormat(*h.opts.DiskFormat), + Mode: buildapitypes.ModeBootc, + AutomotiveImageBuilder: *h.opts.AutomotiveImageBuilder, + StorageClass: *h.opts.StorageClass, + CustomDefs: *h.opts.CustomDefs, + AIBExtraArgs: *h.opts.AIBExtraArgs, + Compression: *h.opts.CompressionAlgo, + ContainerPush: *h.opts.ContainerPush, + BuildDiskImage: *h.opts.BuildDiskImage, + ExportOCI: *h.opts.ExportOCI, + BuilderImage: *h.opts.BuilderImage, + RebuildBuilder: *h.opts.RebuildBuilder, + } + + if err := h.applyRegistryCredentialsToRequest(&req); err != nil { + h.handleError(err) + return + } + + operatorConfig, cfgErr := h.fetchTargetDefaults(ctx, api, *h.opts.Target, *h.opts.FlashAfterBuild) + if cfgErr != nil { + h.handleError(cfgErr) + return + } + ApplyTargetDefaults(cmd, operatorConfig, &req) + + if *h.opts.FlashAfterBuild { + if *h.opts.ExportOCI == "" && !*h.opts.UseInternalRegistry { + h.handleError(fmt.Errorf("cannot enable --flash without exporting a disk image (--push-disk)")) + return + } + if *h.opts.JumpstarterClient == "" { + h.handleError(fmt.Errorf("--flash requires --client to specify Jumpstarter client config file")) + return + } + clientConfigBytes, clientErr := os.ReadFile(*h.opts.JumpstarterClient) + if clientErr != nil { + h.handleError(fmt.Errorf("failed to read Jumpstarter client config: %w", clientErr)) + return + } + req.FlashEnabled = true + req.FlashClientConfig = base64.StdEncoding.EncodeToString(clientConfigBytes) + req.FlashLeaseDuration = *h.opts.LeaseDuration + } + + resp, err := api.CreateBuild(ctx, req) + if err != nil { + h.handleError(err) + return + } + fmt.Printf("Build %s accepted: %s - %s\n", resp.Name, resp.Phase, resp.Message) + h.displayBuildLogsCommand(resp.Name) + + localRefs, refsErr := common.FindLocalFileReferences(string(manifestBytes)) + if refsErr != nil { + h.handleError(fmt.Errorf("manifest file reference error: %w", refsErr)) + return + } + if len(localRefs) > 0 { + if err := h.handleFileUploads(ctx, api, resp.Name, localRefs); err != nil { + h.handleError(err) + return + } + } + + if *h.opts.WaitForBuild || *h.opts.FollowLogs || *h.opts.OutputDir != "" || *h.opts.FlashAfterBuild { + if err := h.waitForBuildCompletion(ctx, api, resp.Name); err != nil { + return + } + } + + h.displayBuildResults(ctx, api, resp.Name) +} + +// RunDisk handles `caib image disk`. +func (h *Handler) RunDisk(cmd *cobra.Command, args []string) { + h.applyWaitFollowDefaults(cmd, false, false) + + ctx := context.Background() + containerRef := args[0] + *h.opts.ContainerRef = containerRef + + if strings.TrimSpace(*h.opts.ServerURL) == "" { + h.handleError(fmt.Errorf("--server is required (or set CAIB_SERVER, or run 'caib login ')")) + return + } + + if *h.opts.UseInternalRegistry { + if *h.opts.ExportOCI != "" { + h.handleError(fmt.Errorf("--internal-registry cannot be used with --push")) + return + } + } else { + if *h.opts.OutputDir == "" && *h.opts.ExportOCI == "" { + h.handleError(fmt.Errorf("either --output or --push is required")) + return + } + if err := common.ValidateOutputRequiresPush(*h.opts.OutputDir, *h.opts.ExportOCI, "--push"); err != nil { + h.handleError(err) + return + } + } + + if *h.opts.BuildName == "" { + parts := strings.Split(containerRef, "/") + imagePart := parts[len(parts)-1] + imagePart = strings.Split(imagePart, ":")[0] + sanitized := common.SanitizeBuildName(imagePart) + *h.opts.BuildName = fmt.Sprintf("disk-%s-%s", sanitized, time.Now().Format("20060102-150405")) + fmt.Printf("Auto-generated build name: %s\n", *h.opts.BuildName) + } else if err := common.ValidateBuildName(*h.opts.BuildName); err != nil { + h.handleError(err) + return + } + + api, err := common.CreateBuildAPIClient(*h.opts.ServerURL, h.opts.AuthToken, *h.opts.InsecureSkipTLS) + if err != nil { + h.handleError(err) + return + } + + req := buildapitypes.BuildRequest{ + Name: *h.opts.BuildName, + ContainerRef: containerRef, + Distro: buildapitypes.Distro(*h.opts.Distro), + Target: buildapitypes.Target(*h.opts.Target), + Architecture: buildapitypes.Architecture(*h.opts.Architecture), + ExportFormat: buildapitypes.ExportFormat(*h.opts.DiskFormat), + Mode: buildapitypes.ModeDisk, + AutomotiveImageBuilder: *h.opts.AutomotiveImageBuilder, + StorageClass: *h.opts.StorageClass, + AIBExtraArgs: *h.opts.AIBExtraArgs, + Compression: *h.opts.CompressionAlgo, + ExportOCI: *h.opts.ExportOCI, + } + + if err := h.applyRegistryCredentialsToRequest(&req); err != nil { + h.handleError(err) + return + } + + operatorConfig, cfgErr := h.fetchTargetDefaults(ctx, api, *h.opts.Target, *h.opts.FlashAfterBuild) + if cfgErr != nil { + h.handleError(cfgErr) + return + } + ApplyTargetDefaults(cmd, operatorConfig, &req) + + if *h.opts.FlashAfterBuild { + if *h.opts.ExportOCI == "" && !*h.opts.UseInternalRegistry { + h.handleError(fmt.Errorf("cannot enable --flash without exporting a disk image (--push)")) + return + } + if *h.opts.JumpstarterClient == "" { + h.handleError(fmt.Errorf("--flash requires --client to specify Jumpstarter client config file")) + return + } + clientConfigBytes, clientErr := os.ReadFile(*h.opts.JumpstarterClient) + if clientErr != nil { + h.handleError(fmt.Errorf("failed to read Jumpstarter client config: %w", clientErr)) + return + } + req.FlashEnabled = true + req.FlashClientConfig = base64.StdEncoding.EncodeToString(clientConfigBytes) + req.FlashLeaseDuration = *h.opts.LeaseDuration + } + + resp, err := api.CreateBuild(ctx, req) + if err != nil { + h.handleError(err) + return + } + fmt.Printf("Build %s accepted: %s - %s\n", resp.Name, resp.Phase, resp.Message) + h.displayBuildLogsCommand(resp.Name) + + if *h.opts.WaitForBuild || *h.opts.FollowLogs || *h.opts.OutputDir != "" || *h.opts.FlashAfterBuild { + if err := h.waitForBuildCompletion(ctx, api, resp.Name); err != nil { + return + } + } + + h.displayBuildResults(ctx, api, resp.Name) +} + +// RunBuildDev handles `caib image build-dev` (traditional ostree/package builds). +func (h *Handler) RunBuildDev(cmd *cobra.Command, args []string) { + h.applyWaitFollowDefaults(cmd, false, false) + + ctx := context.Background() + manifestPath := args[0] + *h.opts.Manifest = manifestPath + + if err := common.ValidateManifestSuffix(manifestPath); err != nil { + h.handleError(err) + return + } + + if strings.TrimSpace(*h.opts.ServerURL) == "" { + h.handleError(fmt.Errorf("--server is required (or set CAIB_SERVER, or run 'caib login ')")) + return + } + + if *h.opts.UseInternalRegistry { + if *h.opts.ExportOCI != "" { + h.handleError(fmt.Errorf("--internal-registry cannot be used with --push")) + return + } + } else if err := common.ValidateOutputRequiresPush(*h.opts.OutputDir, *h.opts.ExportOCI, "--push"); err != nil { + h.handleError(err) + return + } + + if *h.opts.BuildName == "" { + base := filepath.Base(manifestPath) + base = strings.TrimSuffix(base, ".aib.yml") + base = strings.TrimSuffix(base, ".mpp.yml") + sanitized := common.SanitizeBuildName(base) + *h.opts.BuildName = fmt.Sprintf("%s-%s", sanitized, time.Now().Format("20060102-150405")) + fmt.Printf("Auto-generated build name: %s\n", *h.opts.BuildName) + } else if err := common.ValidateBuildName(*h.opts.BuildName); err != nil { + h.handleError(err) + return + } + + api, err := common.CreateBuildAPIClient(*h.opts.ServerURL, h.opts.AuthToken, *h.opts.InsecureSkipTLS) + if err != nil { + h.handleError(err) + return + } + + manifestBytes, err := os.ReadFile(manifestPath) + if err != nil { + h.handleError(fmt.Errorf("error reading manifest: %w", err)) + return + } + + var parsedMode buildapitypes.Mode + switch *h.opts.Mode { + case "image": + parsedMode = buildapitypes.ModeImage + case "package": + parsedMode = buildapitypes.ModePackage + default: + h.handleError(fmt.Errorf( + "invalid --mode %q (expected: %q or %q)", + *h.opts.Mode, + buildapitypes.ModeImage, + buildapitypes.ModePackage, + )) + return + } + + req := buildapitypes.BuildRequest{ + Name: *h.opts.BuildName, + Manifest: string(manifestBytes), + ManifestFileName: filepath.Base(manifestPath), + Distro: buildapitypes.Distro(*h.opts.Distro), + Target: buildapitypes.Target(*h.opts.Target), + Architecture: buildapitypes.Architecture(*h.opts.Architecture), + ExportFormat: buildapitypes.ExportFormat(*h.opts.ExportFormat), + Mode: parsedMode, + AutomotiveImageBuilder: *h.opts.AutomotiveImageBuilder, + StorageClass: *h.opts.StorageClass, + CustomDefs: *h.opts.CustomDefs, + AIBExtraArgs: *h.opts.AIBExtraArgs, + Compression: *h.opts.CompressionAlgo, + ExportOCI: *h.opts.ExportOCI, + } + + if err := h.applyRegistryCredentialsToRequest(&req); err != nil { + h.handleError(err) + return + } + + operatorConfig, cfgErr := h.fetchTargetDefaults(ctx, api, *h.opts.Target, *h.opts.FlashAfterBuild) + if cfgErr != nil { + h.handleError(cfgErr) + return + } + ApplyTargetDefaults(cmd, operatorConfig, &req) + + if *h.opts.FlashAfterBuild { + if *h.opts.ExportOCI == "" && !*h.opts.UseInternalRegistry { + h.handleError(fmt.Errorf("cannot enable --flash without exporting a disk image (--push)")) + return + } + if *h.opts.JumpstarterClient == "" { + h.handleError(fmt.Errorf("--flash requires --client to specify Jumpstarter client config file")) + return + } + + clientConfigBytes, clientErr := os.ReadFile(*h.opts.JumpstarterClient) + if clientErr != nil { + h.handleError(fmt.Errorf("failed to read Jumpstarter client config: %w", clientErr)) + return + } + req.FlashEnabled = true + req.FlashClientConfig = base64.StdEncoding.EncodeToString(clientConfigBytes) + req.FlashLeaseDuration = *h.opts.LeaseDuration + } + + resp, err := api.CreateBuild(ctx, req) + if err != nil { + h.handleError(err) + return + } + fmt.Printf("Build %s accepted: %s - %s\n", resp.Name, resp.Phase, resp.Message) + h.displayBuildLogsCommand(resp.Name) + + localRefs, refsErr := common.FindLocalFileReferences(string(manifestBytes)) + if refsErr != nil { + h.handleError(fmt.Errorf("manifest file reference error: %w", refsErr)) + return + } + if len(localRefs) > 0 { + if err := h.handleFileUploads(ctx, api, resp.Name, localRefs); err != nil { + h.handleError(err) + return + } + } + + if *h.opts.WaitForBuild || *h.opts.FollowLogs || *h.opts.OutputDir != "" || *h.opts.FlashAfterBuild { + if err := h.waitForBuildCompletion(ctx, api, resp.Name); err != nil { + return + } + } + + h.displayBuildResults(ctx, api, resp.Name) +} + +func (h *Handler) handleFileUploads( + ctx context.Context, + api *buildapiclient.Client, + buildName string, + localRefs []map[string]string, +) error { + for _, ref := range localRefs { + if _, err := os.Stat(ref["source_path"]); err != nil { + return fmt.Errorf("referenced file %s does not exist: %w", ref["source_path"], err) + } + } + + fmt.Println("Waiting for upload server to be ready...") + readyCtx, cancel := context.WithTimeout(ctx, 10*time.Minute) + defer cancel() + for { + if err := readyCtx.Err(); err != nil { + return fmt.Errorf("timed out waiting for upload server to be ready") + } + reqCtx, reqCancel := context.WithTimeout(readyCtx, 15*time.Second) + st, err := api.GetBuild(reqCtx, buildName) + reqCancel() + if err == nil { + if st.Phase == phaseUploading { + break + } + if st.Phase == phaseFailed { + return fmt.Errorf("build failed while waiting for upload server: %s", st.Message) + } + } + time.Sleep(3 * time.Second) + } + + uploads := make([]buildapiclient.Upload, 0, len(localRefs)) + for _, ref := range localRefs { + uploads = append(uploads, buildapiclient.Upload{ + SourcePath: ref["source_path"], + DestPath: ref["path"], + }) + } + + uploadDeadline := time.Now().Add(10 * time.Minute) + const perAttemptTimeout = 30 * time.Second + for { + remaining := time.Until(uploadDeadline) + if remaining <= 0 { + return fmt.Errorf("upload files failed: timed out after 10m") + } + attemptTimeout := perAttemptTimeout + if remaining < attemptTimeout { + attemptTimeout = remaining + } + + attemptCtx, attemptCancel := context.WithTimeout(ctx, attemptTimeout) + err := api.UploadFiles(attemptCtx, buildName, uploads) + attemptCancel() + if err != nil { + lower := strings.ToLower(err.Error()) + if time.Now().After(uploadDeadline) { + return fmt.Errorf("upload files failed: %w", err) + } + isServiceUnavailable := strings.Contains(lower, "503") || + strings.Contains(lower, "service unavailable") || + strings.Contains(lower, "upload pod not ready") + if isServiceUnavailable { + fmt.Println("Upload server not ready yet. Retrying...") + time.Sleep(5 * time.Second) + continue + } + return fmt.Errorf("upload files failed: %w", err) + } + break + } + fmt.Println("Local files uploaded. Build will proceed.") + return nil +} diff --git a/cmd/caib/buildcmd/flash_feedback.go b/cmd/caib/buildcmd/flash_feedback.go new file mode 100644 index 00000000..ac8e218a --- /dev/null +++ b/cmd/caib/buildcmd/flash_feedback.go @@ -0,0 +1,116 @@ +package buildcmd + +import ( + "fmt" + "strings" + + buildapitypes "github.com/centos-automotive-suite/automotive-dev-operator/internal/buildapi" + "github.com/fatih/color" +) + +func replaceFlashImagePlaceholders(cmd, imageURI string) string { + cmd = strings.ReplaceAll(cmd, "{image_uri}", imageURI) + cmd = strings.ReplaceAll(cmd, "{artifact_url}", imageURI) + cmd = strings.ReplaceAll(cmd, "${IMAGE}", imageURI) + cmd = strings.ReplaceAll(cmd, "${IMAGE_REF}", imageURI) + return cmd +} + +func hasUnresolvedFlashImagePlaceholder(cmd string) bool { + placeholders := []string{ + "{image_uri}", + "{artifact_url}", + "${IMAGE}", + "${IMAGE_REF}", + } + for _, placeholder := range placeholders { + if strings.Contains(cmd, placeholder) { + return true + } + } + return false +} + +// displayFlashInstructions shows flash instructions when flash is not executed or fails. +func (h *Handler) displayFlashInstructions(st *buildapitypes.BuildResponse, isFailure bool) { + if st.Jumpstarter == nil || !st.Jumpstarter.Available { + return + } + if st.Jumpstarter.ExporterSelector == "" && st.Jumpstarter.FlashCmd == "" { + return + } + // Don't show jumpstarter instructions if user requested a local download. + if *h.opts.OutputDir != "" { + return + } + + colorsSupported := h.supportsColorOutput() + var headerColor, commandColor, infoColor func(...any) string + var headerPrefix, commandPrefix string + + if isFailure { + if colorsSupported { + headerColor = color.New(color.FgHiRed, color.Bold).SprintFunc() + commandColor = color.New(color.FgHiYellow, color.Bold).SprintFunc() + infoColor = color.New(color.FgHiWhite).SprintFunc() + } else { + headerColor = func(a ...any) string { return fmt.Sprint(a...) } + commandColor = func(a ...any) string { return fmt.Sprint(a...) } + infoColor = func(a ...any) string { return fmt.Sprint(a...) } + headerPrefix = "[!] " + commandPrefix = ">> " + } + } else { + if colorsSupported { + headerColor = color.New(color.FgHiWhite, color.Bold).SprintFunc() + commandColor = color.New(color.FgHiGreen, color.Bold).SprintFunc() + infoColor = color.New(color.FgHiYellow).SprintFunc() + } else { + headerColor = func(a ...any) string { return fmt.Sprint(a...) } + commandColor = func(a ...any) string { return fmt.Sprint(a...) } + infoColor = func(a ...any) string { return fmt.Sprint(a...) } + headerPrefix = "[*] " + commandPrefix = ">> " + } + } + + if isFailure { + fmt.Printf("\n%s%s\n", headerPrefix, headerColor("Manual Flash Required")) + fmt.Printf("%s\n", infoColor("Flash failed, but you can flash manually using Jumpstarter:")) + } else { + fmt.Printf("%s\n", infoColor("Jumpstarter is available for flashing:")) + } + + if st.Jumpstarter.ExporterSelector != "" { + fmt.Printf(" %s %s\n", infoColor("Exporter selector:"), st.Jumpstarter.ExporterSelector) + } + + if st.Jumpstarter.FlashCmd != "" { + flashCmd := st.Jumpstarter.FlashCmd + imageURI := st.DiskImage + if imageURI == "" { + imageURI = st.ContainerImage + } + if imageURI != "" { + flashCmd = replaceFlashImagePlaceholders(flashCmd, imageURI) + } + + if hasUnresolvedFlashImagePlaceholder(flashCmd) { + fmt.Printf(" %s\n", infoColor("Flash command template:")) + fmt.Printf(" %s%s\n", commandPrefix, commandColor(replaceFlashImagePlaceholders(flashCmd, ""))) + fmt.Printf(" %s\n", infoColor("No pushed disk image URI is available for this build.")) + fmt.Printf(" %s\n", infoColor("Use --push-disk or --internal-registry to produce a flashable URI.")) + return + } + + fmt.Printf(" %s\n", infoColor("Flash command:")) + fmt.Printf(" %s%s\n", commandPrefix, commandColor(flashCmd)) + } +} + +func (h *Handler) handleFlashError(err error, st *buildapitypes.BuildResponse) { + if *h.opts.FlashAfterBuild && st != nil { + h.displayFlashInstructions(st, true) + } + h.handleError(err) +} diff --git a/cmd/caib/buildcmd/logs.go b/cmd/caib/buildcmd/logs.go new file mode 100644 index 00000000..3ef6a705 --- /dev/null +++ b/cmd/caib/buildcmd/logs.go @@ -0,0 +1,306 @@ +package buildcmd + +import ( + "context" + "crypto/tls" + "fmt" + "net/http" + "net/url" + "os" + "strings" + "time" + + common "github.com/centos-automotive-suite/automotive-dev-operator/cmd/caib/common" + "github.com/centos-automotive-suite/automotive-dev-operator/cmd/caib/logstream" + "github.com/centos-automotive-suite/automotive-dev-operator/cmd/caib/ui" + buildapitypes "github.com/centos-automotive-suite/automotive-dev-operator/internal/buildapi" + buildapiclient "github.com/centos-automotive-suite/automotive-dev-operator/internal/buildapi/client" + "github.com/fatih/color" + "github.com/spf13/cobra" +) + +//nolint:gocyclo // Complex state machine for build progress tracking with log streaming. +func (h *Handler) waitForBuildCompletion(ctx context.Context, api *buildapiclient.Client, name string) error { + fmt.Println("Waiting for build to complete...") + timeoutCtx, cancel := context.WithTimeout(ctx, time.Duration(*h.opts.Timeout)*time.Minute) + defer cancel() + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + userFollowRequested := *h.opts.FollowLogs + var lastPhase, lastMessage string + pendingWarningShown := false + retryLimitWarningShown := false + + logTransport := &http.Transport{ + ResponseHeaderTimeout: 30 * time.Second, + IdleConnTimeout: 2 * time.Minute, + } + if *h.opts.InsecureSkipTLS { + logTransport.TLSClientConfig = &tls.Config{ + InsecureSkipVerify: true, + MinVersion: tls.VersionTLS12, + } + } + // No hard Timeout on the client: log streams can run for the entire + // build duration (often >10 min). The build's context timeout (timeoutCtx) + // already governs cancellation via the request context. + logClient := &http.Client{ + Transport: logTransport, + } + streamState := &logstream.State{} + pb := ui.NewProgressBar() + + for { + select { + case <-timeoutCtx.Done(): + pb.Clear() + timeoutErr := fmt.Errorf("timed out waiting for build") + h.handleError(timeoutErr) + return timeoutErr + case <-ticker.C: + reqCtx, cancelReq := context.WithTimeout(timeoutCtx, 2*time.Minute) + st, err := api.GetBuild(reqCtx, name) + cancelReq() + if err != nil { + fmt.Printf("status check failed: %v\n", err) + continue + } + + if !*h.opts.FollowLogs && !streamState.Active { + progressCtx, progressCancel := context.WithTimeout(timeoutCtx, 10*time.Second) + progress, _ := api.GetBuildProgress(progressCtx, name) + progressCancel() + + displayPhase := st.Phase + var step *buildapitypes.BuildStep + if progress != nil { + step = progress.Step + if progress.Phase != "" { + displayPhase = progress.Phase + } + } + pb.Render(displayPhase, step) + } else if !streamState.Active && (!userFollowRequested || !streamState.CanRetry(maxLogRetries)) { + if st.Phase != lastPhase || st.Message != lastMessage { + fmt.Printf("status: %s - %s\n", st.Phase, st.Message) + lastPhase = st.Phase + lastMessage = st.Message + } + } + + if st.Phase == phaseCompleted { + pb.Clear() + flashWasExecuted := strings.Contains(strings.ToLower(st.Message), "flash") + if flashWasExecuted { + bannerColor := func(a ...any) string { return fmt.Sprint(a...) } + infoColor := func(a ...any) string { return fmt.Sprint(a...) } + commandColor := func(a ...any) string { return fmt.Sprint(a...) } + if h.supportsColorOutput() { + bannerColor = color.New(color.FgHiGreen, color.Bold).SprintFunc() + infoColor = color.New(color.FgHiWhite).SprintFunc() + commandColor = color.New(color.FgHiYellow, color.Bold).SprintFunc() + } + + divider := strings.Repeat("=", 50) + fmt.Println("\n" + bannerColor(divider)) + fmt.Println(bannerColor("Build and flash completed successfully!")) + fmt.Println(bannerColor(divider)) + fmt.Println("\n" + infoColor("The device has been flashed and a lease has been acquired.")) + + leaseID := "" + if st.Jumpstarter != nil && st.Jumpstarter.LeaseID != "" { + leaseID = st.Jumpstarter.LeaseID + } else if streamState.LeaseID != "" { + leaseID = streamState.LeaseID + } + if leaseID != "" { + fmt.Printf("\n%s %s\n", infoColor("Lease ID:"), commandColor(leaseID)) + fmt.Printf("\n%s\n", infoColor("To access the device:")) + fmt.Printf(" %s\n", commandColor(fmt.Sprintf("jmp shell --lease %s", leaseID))) + fmt.Printf("\n%s\n", infoColor("To release the lease when done:")) + fmt.Printf(" %s\n", commandColor(fmt.Sprintf("jmp delete leases %s", leaseID))) + } else { + fmt.Println(infoColor("Check the logs above for lease details, or use:")) + fmt.Printf(" %s\n", commandColor("jmp list leases")) + fmt.Printf("\n%s\n", infoColor("To access the device:")) + fmt.Printf(" %s\n", commandColor("jmp shell --lease ")) + fmt.Printf("\n%s\n", infoColor("To release the lease when done:")) + fmt.Printf(" %s\n", commandColor("jmp delete leases ")) + } + } else { + fmt.Println("Build completed successfully!") + if *h.opts.FlashAfterBuild { + fmt.Println("\nWarning: --flash was requested but flash was not executed.") + fmt.Println("This may be because no Jumpstarter target mapping exists for this target.") + fmt.Println("Check OperatorConfig for JumpstarterTargetMappings configuration.") + } + h.displayFlashInstructions(st, false) + } + return nil + } + if st.Phase == phaseFailed { + pb.Clear() + errPrefix := errPrefixBuild + isFlashFailure := false + + if strings.Contains(strings.ToLower(st.Message), errPrefixFlash) { + errPrefix = errPrefixFlash + isFlashFailure = true + } else if strings.Contains(strings.ToLower(st.Message), errPrefixPush) { + errPrefix = errPrefixPush + } else if lastPhase == phaseFlashing { + errPrefix = errPrefixFlash + isFlashFailure = true + } else if lastPhase == "Pushing" { + errPrefix = errPrefixPush + } else if *h.opts.FlashAfterBuild && + (lastPhase == phaseFlashing || strings.Contains(strings.ToLower(st.Message), errPrefixFlash)) { + errPrefix = errPrefixFlash + isFlashFailure = true + } + + handleErr := fmt.Errorf("%s failed: %s", errPrefix, st.Message) + if isFlashFailure { + h.handleFlashError(handleErr, st) + } else { + h.handleError(handleErr) + } + return handleErr + } + + if !*h.opts.FollowLogs || streamState.Active { + continue + } + + if streamState.Completed && isBuildActive(st.Phase) { + streamState.Completed = false + streamState.RetryCount = 0 + } + + if !streamState.CanRetry(maxLogRetries) { + continue + } + + if st.Phase == phasePending { + streamState.Reset() + if userFollowRequested && !pendingWarningShown { + fmt.Println("Waiting for build to start before streaming logs...") + pendingWarningShown = true + } + continue + } + + if isBuildActive(st.Phase) { + if streamState.RetryCount == 0 { + fmt.Println("Build is active. Attempting to stream logs...") + pendingWarningShown = false + } + + if err := h.tryLogStreaming(timeoutCtx, logClient, name, streamState); err != nil { + streamState.RetryCount++ + if !streamState.CanRetry(maxLogRetries) && !retryLimitWarningShown { + msg := "Log streaming failed after %d attempts (~2 minutes). Falling back to status updates only.\n" + fmt.Printf(msg, maxLogRetries) + retryLimitWarningShown = true + } + } else { + *h.opts.FollowLogs = userFollowRequested + } + } + } + } +} + +const maxLogRetries = 24 + +func isBuildActive(phase string) bool { + return phase == "Building" || phase == phaseRunning || phase == phaseUploading || phase == phaseFlashing +} + +func (h *Handler) tryLogStreaming(ctx context.Context, logClient *http.Client, name string, state *logstream.State) error { + logURL := h.buildLogURL(name, state.StartTime) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, logURL, nil) + if err != nil { + return fmt.Errorf("failed to create log request: %w", err) + } + if authToken := strings.TrimSpace(*h.opts.AuthToken); authToken != "" { + req.Header.Set("Authorization", "Bearer "+authToken) + } + + resp, err := logClient.Do(req) + if err != nil { + return fmt.Errorf("log request failed: %w", err) + } + + if resp.StatusCode == http.StatusOK { + defer func() { + if closeErr := resp.Body.Close(); closeErr != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to close response body: %v\n", closeErr) + } + }() + return logstream.StreamLogsToStdout(resp.Body, state, true) + } + + return logstream.HandleLogStreamError(resp, state, maxLogRetries) +} + +func (h *Handler) buildLogURL(buildName string, startTime time.Time) string { + logURL := strings.TrimRight(*h.opts.ServerURL, "/") + "/v1/builds/" + url.PathEscape(buildName) + "/logs?follow=1" + if !startTime.IsZero() { + logURL += "&since=" + url.QueryEscape(startTime.Format(time.RFC3339)) + } + return logURL +} + +// RunLogs handles `caib image logs`. +func (h *Handler) RunLogs(_ *cobra.Command, args []string) { + ctx := context.Background() + name := args[0] + + if strings.TrimSpace(*h.opts.ServerURL) == "" { + h.handleError(fmt.Errorf("--server is required (or set CAIB_SERVER, or run 'caib login ')")) + return + } + + api, err := common.CreateBuildAPIClient(*h.opts.ServerURL, h.opts.AuthToken, *h.opts.InsecureSkipTLS) + if err != nil { + h.handleError(err) + return + } + + st, err := api.GetBuild(ctx, name) + if err != nil { + h.handleError(fmt.Errorf("failed to get build: %w", err)) + return + } + fmt.Printf("Build %s: %s - %s\n", name, st.Phase, st.Message) + + if st.Phase == phaseCompleted || st.Phase == phaseFailed { + logTransport := &http.Transport{ + ResponseHeaderTimeout: 30 * time.Second, + } + if *h.opts.InsecureSkipTLS { + logTransport.TLSClientConfig = &tls.Config{ + InsecureSkipVerify: true, + MinVersion: tls.VersionTLS12, + } + } + logClient := &http.Client{ + Timeout: 2 * time.Minute, + Transport: logTransport, + } + streamState := &logstream.State{} + if err := h.tryLogStreaming(ctx, logClient, name, streamState); err != nil { + fmt.Printf("Could not retrieve logs (pods may have been cleaned up). Use 'caib image show %s' for details.\n", name) + } + return + } + + *h.opts.FollowLogs = true + if err := h.waitForBuildCompletion(ctx, api, name); err != nil { + return + } + h.displayBuildResults(ctx, api, name) +} diff --git a/cmd/caib/cli_helpers.go b/cmd/caib/cli_helpers.go new file mode 100644 index 00000000..94ad1100 --- /dev/null +++ b/cmd/caib/cli_helpers.go @@ -0,0 +1,40 @@ +package main + +import ( + "fmt" + "os" + "runtime" + "strconv" + "strings" +) + +// getDefaultArch returns the current system architecture in caib format. +func getDefaultArch() string { + switch runtime.GOARCH { + case archAMD64: + return archAMD64 + case archARM64: + return archARM64 + default: + fmt.Fprintf(os.Stderr, "Warning: unrecognized host architecture %q, using raw GOARCH value\n", runtime.GOARCH) + return runtime.GOARCH + } +} + +// envBool parses a boolean from environment variable. +func envBool(key string) bool { + v := strings.TrimSpace(os.Getenv(key)) + if v == "" { + return false + } + b, err := strconv.ParseBool(v) + if err != nil { + return false + } + return b +} + +func handleError(err error) { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) +} diff --git a/cmd/caib/common/api_client.go b/cmd/caib/common/api_client.go new file mode 100644 index 00000000..3f362456 --- /dev/null +++ b/cmd/caib/common/api_client.go @@ -0,0 +1,199 @@ +// Package caibcommon provides shared caib helpers. +package caibcommon + +import ( + "context" + "fmt" + "os" + "os/exec" + "strings" + "time" + + "github.com/centos-automotive-suite/automotive-dev-operator/cmd/caib/auth" + buildapiclient "github.com/centos-automotive-suite/automotive-dev-operator/internal/buildapi/client" + "k8s.io/client-go/tools/clientcmd" +) + +// CreateBuildAPIClient creates a build API client with auth token from flags/env/kubeconfig. +func CreateBuildAPIClient(serverURL string, authToken *string, insecureSkipTLS bool) (*buildapiclient.Client, error) { + ctx := context.Background() + + tokenValue := "" + if authToken != nil { + tokenValue = strings.TrimSpace(*authToken) + } + setToken := func(token string) { + tokenValue = strings.TrimSpace(token) + if authToken != nil { + *authToken = tokenValue + } + } + + envToken := strings.TrimSpace(os.Getenv("CAIB_TOKEN")) + explicitToken := tokenValue != "" || envToken != "" + + if !explicitToken { + token, didAuth, err := auth.GetTokenWithReauth(ctx, serverURL, "", insecureSkipTLS) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: OIDC authentication failed: %v\n", err) + fmt.Fprintln(os.Stderr, "Attempting kubeconfig fallback (this may use a different identity)") + if tok, loadErr := LoadTokenFromKubeconfig(); loadErr == nil && strings.TrimSpace(tok) != "" { + setToken(tok) + } else { + return nil, fmt.Errorf("OIDC authentication failed and no kubeconfig token available: %w", err) + } + } else if token != "" { + setToken(token) + if didAuth { + fmt.Fprintln(os.Stderr, "OIDC authentication successful") + } + } else if tok, loadErr := LoadTokenFromKubeconfig(); loadErr == nil && strings.TrimSpace(tok) != "" { + setToken(tok) + } + } else if tokenValue == "" { + if envToken != "" { + setToken(envToken) + } else if tok, loadErr := LoadTokenFromKubeconfig(); loadErr == nil && strings.TrimSpace(tok) != "" { + setToken(tok) + } + } + + var opts []buildapiclient.Option + if tokenValue != "" { + opts = append(opts, buildapiclient.WithAuthToken(tokenValue)) + } + + if insecureSkipTLS { + opts = append(opts, buildapiclient.WithInsecureTLS()) + } + if caCertFile := os.Getenv("SSL_CERT_FILE"); caCertFile != "" { + opts = append(opts, buildapiclient.WithCACertificate(caCertFile)) + } else if caCertFile := os.Getenv("REQUESTS_CA_BUNDLE"); caCertFile != "" { + opts = append(opts, buildapiclient.WithCACertificate(caCertFile)) + } + + return buildapiclient.New(serverURL, opts...) +} + +// ExecuteWithReauth executes an API call and retries once after re-auth on auth errors. +func ExecuteWithReauth( + serverURL string, + authToken *string, + insecureSkipTLS bool, + fn func(*buildapiclient.Client) error, +) error { + ctx := context.Background() + currentToken := "" + if authToken != nil { + currentToken = strings.TrimSpace(*authToken) + } + setToken := func(token string) { + currentToken = strings.TrimSpace(token) + if authToken != nil { + *authToken = currentToken + } + } + + runWithFreshClient := func() error { + client, err := CreateBuildAPIClient(serverURL, authToken, insecureSkipTLS) + if err != nil { + return err + } + if authToken != nil { + currentToken = strings.TrimSpace(*authToken) + } + return fn(client) + } + + err := runWithFreshClient() + if err == nil { + return nil + } + if !auth.IsAuthError(err) { + return err + } + + fmt.Fprintln(os.Stderr, "Authentication failed (401), re-authenticating...") + newToken, _, err := auth.GetTokenWithReauth(ctx, serverURL, currentToken, insecureSkipTLS) + if err != nil { + return fmt.Errorf("re-authentication failed: %w", err) + } + setToken(newToken) + + fmt.Fprintln(os.Stderr, "Retrying request...") + err = runWithFreshClient() + if err == nil { + return nil + } + if !auth.IsAuthError(err) { + return err + } + + if tok, loadErr := LoadTokenFromKubeconfig(); loadErr == nil && strings.TrimSpace(tok) != "" { + setToken(tok) + fmt.Fprintln(os.Stderr, "Attempting kubeconfig fallback...") + return runWithFreshClient() + } + + return err +} + +// LoadTokenFromKubeconfig loads a bearer token from kubeconfig. +func LoadTokenFromKubeconfig() (string, error) { + loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() + deferred := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, &clientcmd.ConfigOverrides{}) + if restCfg, err := deferred.ClientConfig(); err == nil && restCfg != nil { + if t := strings.TrimSpace(restCfg.BearerToken); t != "" { + return t, nil + } + if f := strings.TrimSpace(restCfg.BearerTokenFile); f != "" { + if b, readErr := os.ReadFile(f); readErr == nil { + if t := strings.TrimSpace(string(b)); t != "" { + return t, nil + } + } + } + } + + rawCfg, err := loadingRules.Load() + if err != nil || rawCfg == nil { + return "", fmt.Errorf("cannot load kubeconfig: %w", err) + } + ctxName := rawCfg.CurrentContext + if strings.TrimSpace(ctxName) == "" { + return "", fmt.Errorf("no current kube context") + } + ctx := rawCfg.Contexts[ctxName] + if ctx == nil { + return "", fmt.Errorf("missing context %s", ctxName) + } + ai := rawCfg.AuthInfos[ctx.AuthInfo] + if ai == nil { + return "", fmt.Errorf("missing auth info for context %s", ctxName) + } + if strings.TrimSpace(ai.Token) != "" { + return strings.TrimSpace(ai.Token), nil + } + if ai.AuthProvider != nil && ai.AuthProvider.Config != nil { + if t := strings.TrimSpace(ai.AuthProvider.Config["access-token"]); t != "" { + return t, nil + } + if t := strings.TrimSpace(ai.AuthProvider.Config["id-token"]); t != "" { + return t, nil + } + if t := strings.TrimSpace(ai.AuthProvider.Config["token"]); t != "" { + return t, nil + } + } + if path, lookErr := exec.LookPath("oc"); lookErr == nil && path != "" { + cmdCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + out, cmdErr := exec.CommandContext(cmdCtx, path, "whoami", "-t").Output() + if cmdErr == nil { + if t := strings.TrimSpace(string(out)); t != "" { + return t, nil + } + } + } + return "", fmt.Errorf("no bearer token found in kubeconfig") +} diff --git a/cmd/caib/common/build_validation.go b/cmd/caib/common/build_validation.go new file mode 100644 index 00000000..c1fdf19b --- /dev/null +++ b/cmd/caib/common/build_validation.go @@ -0,0 +1,58 @@ +package caibcommon + +import ( + "fmt" + "path/filepath" + "regexp" + "strings" +) + +var ( + multiHyphenRe = regexp.MustCompile(`-{2,}`) + validManifestSuffix = []string{".aib.yml", ".mpp.yml"} +) + +const maxBuildNameLen = 63 + +// SanitizeBuildName converts a string into a valid RFC1123-style name. +func SanitizeBuildName(name string) string { + name = strings.ToLower(name) + var b strings.Builder + for _, r := range name { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' { + b.WriteRune(r) + } else { + b.WriteRune('-') + } + } + result := multiHyphenRe.ReplaceAllString(b.String(), "-") + return strings.Trim(result, "-") +} + +// ValidateBuildName checks user-provided build name validity. +func ValidateBuildName(name string) error { + sanitized := SanitizeBuildName(name) + if sanitized == "" { + return fmt.Errorf("build name %q contains only invalid characters", name) + } + if len(sanitized) > maxBuildNameLen { + return fmt.Errorf( + "sanitized build name %q is too long: got %d, max %d", + sanitized, + len(sanitized), + maxBuildNameLen, + ) + } + return nil +} + +// ValidateManifestSuffix validates the manifest file extension. +func ValidateManifestSuffix(filename string) error { + for _, suffix := range validManifestSuffix { + if strings.HasSuffix(filename, suffix) { + return nil + } + } + return fmt.Errorf("manifest file %q must have one of the following extensions: %s", + filepath.Base(filename), strings.Join(validManifestSuffix, ", ")) +} diff --git a/cmd/caib/common/cli_helpers.go b/cmd/caib/common/cli_helpers.go new file mode 100644 index 00000000..ef92248e --- /dev/null +++ b/cmd/caib/common/cli_helpers.go @@ -0,0 +1,73 @@ +package caibcommon + +import ( + "encoding/json" + "fmt" + "os" + "strings" +) + +// SupportsColorOutput returns true when terminal supports color output. +func SupportsColorOutput() bool { + if os.Getenv("NO_COLOR") != "" { + return false + } + term := strings.ToLower(strings.TrimSpace(os.Getenv("TERM"))) + if term == "" || term == "dumb" { + return false + } + ci := strings.TrimSpace(os.Getenv("CI")) != "" + if ci { + if os.Getenv("GITHUB_ACTIONS") == "" && + os.Getenv("GITLAB_CI") == "" && + os.Getenv("CIRCLECI") == "" && + os.Getenv("TRAVIS") == "" && + os.Getenv("BUILDKITE") == "" { + return false + } + } + return true +} + +// WriteRegistryCredentialsFile writes registry credentials to a mode-0600 temp file. +func WriteRegistryCredentialsFile(token string) (string, error) { + creds, err := json.Marshal(map[string]string{ + "username": "serviceaccount", + "token": token, + }) + if err != nil { + return "", err + } + + f, err := os.CreateTemp("", "caib-registry-creds-*.json") + if err != nil { + return "", err + } + name := f.Name() + + if _, err := f.Write(creds); err != nil { + _ = f.Close() + _ = os.Remove(name) + return "", err + } + if err := f.Close(); err != nil { + _ = os.Remove(name) + return "", err + } + if err := os.Chmod(name, 0600); err != nil { + _ = os.Remove(name) + return "", err + } + return name, nil +} + +// ValidateOutputRequiresPush validates --output usage with a push flag. +func ValidateOutputRequiresPush(output, pushRef, flagName string) error { + if output == "" { + return nil + } + if pushRef == "" { + return fmt.Errorf("--output requires %s to download from registry", flagName) + } + return nil +} diff --git a/cmd/caib/common/manifest_artifact_helpers.go b/cmd/caib/common/manifest_artifact_helpers.go new file mode 100644 index 00000000..4835ce89 --- /dev/null +++ b/cmd/caib/common/manifest_artifact_helpers.go @@ -0,0 +1,201 @@ +package caibcommon + +import ( + "compress/gzip" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" +) + +// FindLocalFileReferences extracts manifest add_files source_path references. +func FindLocalFileReferences(manifestContent string) ([]map[string]string, error) { + var manifestData map[string]any + var localFiles []map[string]string + + if err := yaml.Unmarshal([]byte(manifestContent), &manifestData); err != nil { + return nil, fmt.Errorf("failed to parse manifest YAML: %w", err) + } + + isPathSafe := func(path string) error { + if path == "" || path == "/" { + return fmt.Errorf("empty or root path is not allowed") + } + + normalizedPath := filepath.ToSlash(path) + for _, segment := range strings.Split(normalizedPath, "/") { + if segment == ".." { + return fmt.Errorf("directory traversal detected in path: %s", path) + } + } + + if filepath.IsAbs(path) { + safeDirectories := configuredSafeDirectories() + if len(safeDirectories) > 0 { + cleanedPath := filepath.Clean(path) + isInSafeDir := false + for _, dir := range safeDirectories { + if dir == "" { + continue + } + cleanedDir := filepath.Clean(dir) + if cleanedPath == cleanedDir || + strings.HasPrefix(cleanedPath, cleanedDir+string(os.PathSeparator)) { + isInSafeDir = true + break + } + } + if !isInSafeDir { + return fmt.Errorf( + "absolute path outside configured safe directories: %s (set CAIB_SAFE_DIRECTORIES)", + path, + ) + } + } + } + return nil + } + + processAddFiles := func(addFiles []any) error { + for _, file := range addFiles { + if fileMap, ok := file.(map[string]any); ok { + path, hasPath := fileMap["path"].(string) + sourcePath, hasSourcePath := fileMap["source_path"].(string) + if hasPath && hasSourcePath { + if err := isPathSafe(sourcePath); err != nil { + return err + } + localFiles = append(localFiles, map[string]string{ + "path": path, + "source_path": sourcePath, + }) + } + } + } + return nil + } + + if content, ok := manifestData["content"].(map[string]any); ok { + if addFiles, ok := content["add_files"].([]any); ok { + if err := processAddFiles(addFiles); err != nil { + return nil, err + } + } + } + if qm, ok := manifestData["qm"].(map[string]any); ok { + if qmContent, ok := qm["content"].(map[string]any); ok { + if addFiles, ok := qmContent["add_files"].([]any); ok { + if err := processAddFiles(addFiles); err != nil { + return nil, err + } + } + } + } + + return localFiles, nil +} + +func configuredSafeDirectories() []string { + raw := strings.TrimSpace(os.Getenv("CAIB_SAFE_DIRECTORIES")) + if raw == "" { + // Default policy: allow absolute paths when no safe directories are configured. + return nil + } + + parts := strings.Split(raw, string(os.PathListSeparator)) + dirs := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + dirs = append(dirs, filepath.Clean(part)) + } + return dirs +} + +// compressionExtension returns the filename extension for a compression algorithm. +func compressionExtension(algo string) string { + switch algo { + case "tar.gz": + return ".tar.gz" + case "gzip": + return ".gz" + case "lz4": + return ".lz4" + case "xz": + return ".xz" + default: + return "" + } +} + +// hasCompressionExtension checks if filename already has a compression extension. +func hasCompressionExtension(filename string) bool { + lower := strings.ToLower(filename) + return strings.HasSuffix(lower, ".tar.gz") || + strings.HasSuffix(lower, ".gz") || + strings.HasSuffix(lower, ".lz4") || + strings.HasSuffix(lower, ".xz") +} + +// detectFileCompression examines magic bytes and returns the compression algorithm. +func detectFileCompression(filePath string) string { + file, err := os.Open(filePath) + if err != nil { + return "" + } + defer func() { + if err := file.Close(); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to close file: %v\n", err) + } + }() + + header := make([]byte, 10) + n, err := file.Read(header) + if err != nil || n < 3 { + return "" + } + + if n >= 2 && header[0] == 0x1f && header[1] == 0x8b { + if isTarInsideGzip(filePath) { + return "tar.gz" + } + return "gzip" + } + if n >= 4 && header[0] == 0x04 && header[1] == 0x22 && header[2] == 0x4d && header[3] == 0x18 { + return "lz4" + } + if n >= 6 && header[0] == 0xfd && header[1] == 0x37 && header[2] == 0x7a && + header[3] == 0x58 && header[4] == 0x5a && header[5] == 0x00 { + return "xz" + } + + return "" +} + +// isTarInsideGzip checks whether a gzip file contains a tar archive. +func isTarInsideGzip(filePath string) bool { + file, err := os.Open(filePath) + if err != nil { + return false + } + defer func() { _ = file.Close() }() + + gzReader, err := gzip.NewReader(file) + if err != nil { + return false + } + defer func() { _ = gzReader.Close() }() + + header := make([]byte, 512) + n, err := io.ReadFull(gzReader, header) + if err != nil && n < 262 { + return false + } + + return n >= 262 && string(header[257:262]) == "ustar" +} diff --git a/cmd/caib/common/oci_artifact.go b/cmd/caib/common/oci_artifact.go new file mode 100644 index 00000000..748e7cae --- /dev/null +++ b/cmd/caib/common/oci_artifact.go @@ -0,0 +1,292 @@ +package caibcommon + +import ( + "context" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/containers/image/v5/copy" + "github.com/containers/image/v5/docker" + "github.com/containers/image/v5/oci/layout" + "github.com/containers/image/v5/signature" + "github.com/containers/image/v5/types" +) + +// PullOCIArtifact pulls and extracts an OCI artifact to local destination. +func PullOCIArtifact(ociRef, destPath, username, password string, insecureSkipTLS bool) error { + fmt.Printf("Pulling OCI artifact %s to %s\n", ociRef, destPath) + + destDir := filepath.Dir(destPath) + if destDir != "" && destDir != "." { + if err := os.MkdirAll(destDir, 0755); err != nil { + return fmt.Errorf("create output dir: %w", err) + } + } + + ctx := context.Background() + systemCtx := &types.SystemContext{} + if username != "" && password != "" { + fmt.Printf("Using provided username/password credentials\n") + systemCtx.DockerAuthConfig = &types.DockerAuthConfig{ + Username: username, + Password: password, + } + } else { + fmt.Printf("No explicit credentials provided, will use local container auth files if available\n") + } + + if insecureSkipTLS { + systemCtx.OCIInsecureSkipTLSVerify = insecureSkipTLS + systemCtx.DockerInsecureSkipTLSVerify = types.OptionalBoolTrue + } + + verifySignatures, err := signatureVerificationEnabled() + if err != nil { + return err + } + var policy *signature.Policy + if verifySignatures { + policy, err = signature.DefaultPolicy(systemCtx) + if err != nil { + return fmt.Errorf("load default signature policy: %w", err) + } + } else { + policy = &signature.Policy{ + Default: []signature.PolicyRequirement{signature.NewPRInsecureAcceptAnything()}, + } + } + + policyCtx, err := signature.NewPolicyContext(policy) + if err != nil { + return fmt.Errorf("create policy context: %w", err) + } + + srcRef, err := docker.ParseReference("//" + ociRef) + if err != nil { + return fmt.Errorf("parse source reference: %w", err) + } + + tempDir, err := os.MkdirTemp("", "oci-pull-*") + if err != nil { + return fmt.Errorf("create temp dir: %w", err) + } + defer func() { + if err := os.RemoveAll(tempDir); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to remove temp directory: %v\n", err) + } + }() + + destRef, err := layout.ParseReference(tempDir + ":latest") + if err != nil { + return fmt.Errorf("parse destination reference: %w", err) + } + + fmt.Printf("Downloading OCI artifact...") + _, err = copy.Image(ctx, policyCtx, destRef, srcRef, ©.Options{ + ReportWriter: os.Stdout, + SourceCtx: systemCtx, + DestinationCtx: systemCtx, + }) + if err != nil { + return fmt.Errorf("copy image: %w", err) + } + + fmt.Printf("\nExtracting artifact to %s\n", destPath) + if err := extractOCIArtifactBlob(tempDir, destPath); err != nil { + return fmt.Errorf("extract artifact: %w", err) + } + + info, err := os.Stat(destPath) + if err != nil { + return fmt.Errorf("stat destination: %w", err) + } + + if info.IsDir() { + fmt.Printf("Downloaded multi-layer artifact to %s/\n", destPath) + return nil + } + + finalPath := destPath + compression := detectFileCompression(destPath) + if compression != "" && !hasCompressionExtension(destPath) { + ext := compressionExtension(compression) + if ext != "" { + newPath := destPath + ext + fmt.Printf("Adding compression extension: %s -> %s\n", filepath.Base(destPath), filepath.Base(newPath)) + if err := os.Rename(destPath, newPath); err != nil { + return fmt.Errorf("rename file with compression extension: %w", err) + } + finalPath = newPath + } + } + fmt.Printf("Downloaded to %s\n", finalPath) + return nil +} + +func signatureVerificationEnabled() (bool, error) { + raw := strings.TrimSpace(os.Getenv("SIGNATURE_VERIFY")) + if raw == "" { + return false, nil + } + enabled, err := strconv.ParseBool(raw) + if err != nil { + return false, fmt.Errorf("invalid SIGNATURE_VERIFY value %q: %w", raw, err) + } + return enabled, nil +} + +func extractOCIArtifactBlob(ociLayoutPath, destPath string) error { + indexPath := filepath.Join(ociLayoutPath, "index.json") + indexData, err := os.ReadFile(indexPath) + if err != nil { + return fmt.Errorf("read index.json: %w", err) + } + + var index struct { + Manifests []struct { + Digest string `json:"digest"` + } `json:"manifests"` + } + if err := json.Unmarshal(indexData, &index); err != nil { + return fmt.Errorf("parse index.json: %w", err) + } + if len(index.Manifests) == 0 { + return fmt.Errorf("no manifests found in index") + } + + manifestDigest := strings.TrimPrefix(index.Manifests[0].Digest, "sha256:") + manifestPath := filepath.Join(ociLayoutPath, "blobs", "sha256", manifestDigest) + manifestData, err := os.ReadFile(manifestPath) + if err != nil { + return fmt.Errorf("read manifest: %w", err) + } + + var manifest struct { + Annotations map[string]string `json:"annotations"` + Layers []struct { + Digest string `json:"digest"` + Annotations map[string]string `json:"annotations"` + } `json:"layers"` + } + if err := json.Unmarshal(manifestData, &manifest); err != nil { + return fmt.Errorf("parse manifest: %w", err) + } + if len(manifest.Layers) == 0 { + return fmt.Errorf("no layers found in manifest") + } + + annotationMultiLayer := manifest.Annotations["automotive.sdv.cloud.redhat.com/multi-layer"] == "true" + isMultiLayer := annotationMultiLayer || len(manifest.Layers) > 1 + if isMultiLayer { + if !annotationMultiLayer && len(manifest.Layers) > 1 { + fmt.Printf("Warning: manifest has %d layers without multi-layer annotation; extracting all layers\n", len(manifest.Layers)) + } + fmt.Printf("Multi-layer artifact detected (%d layers)\n", len(manifest.Layers)) + if err := os.MkdirAll(destPath, 0755); err != nil { + return fmt.Errorf("create destination directory: %w", err) + } + + seenFilenames := make(map[string]struct { + layerIndex int + digest string + title string + }) + for i, layer := range manifest.Layers { + layerDigest := strings.TrimPrefix(layer.Digest, "sha256:") + layerPath := filepath.Join(ociLayoutPath, "blobs", "sha256", layerDigest) + + originalTitle := layer.Annotations["org.opencontainers.image.title"] + filename := sanitizeFilename(originalTitle, i) + + if prev, exists := seenFilenames[filename]; exists { + return fmt.Errorf( + "duplicate sanitized filename '%s' for layer %d (digest: %s, title: %s) conflicts with layer %d (digest: %s, title: %s)", + filename, i, layer.Digest, originalTitle, prev.layerIndex, prev.digest, prev.title) + } + seenFilenames[filename] = struct { + layerIndex int + digest string + title string + }{ + layerIndex: i, + digest: layer.Digest, + title: originalTitle, + } + + destFile := filepath.Join(destPath, filename) + fmt.Printf(" Extracting layer %d: %s\n", i+1, filename) + if err := copyFile(layerPath, destFile); err != nil { + return fmt.Errorf("extract layer %s: %w", filename, err) + } + } + + fmt.Printf("Extracted %d files to %s\n", len(manifest.Layers), destPath) + return nil + } + + layerDigest := strings.TrimPrefix(manifest.Layers[0].Digest, "sha256:") + layerPath := filepath.Join(ociLayoutPath, "blobs", "sha256", layerDigest) + return copyFile(layerPath, destPath) +} + +func sanitizeFilename(filename string, layerIndex int) string { + fallback := fmt.Sprintf("layer-%d.bin", layerIndex) + if filename == "" { + return fallback + } + if strings.ContainsRune(filename, 0) { + fmt.Fprintf(os.Stderr, "Warning: layer %d filename contains null bytes, using fallback\n", layerIndex) + return fallback + } + if filepath.IsAbs(filename) { + fmt.Fprintf(os.Stderr, "Warning: layer %d filename is absolute path, using fallback\n", layerIndex) + return fallback + } + if strings.Contains(filename, "..") { + fmt.Fprintf(os.Stderr, "Warning: layer %d filename contains '..', using fallback\n", layerIndex) + return fallback + } + + base := filepath.Base(filename) + if base != filename { + fmt.Fprintf(os.Stderr, "Warning: layer %d filename contains path separators, using basename: %s\n", layerIndex, base) + filename = base + } + if filename == "" || filename == "." || filename == ".." { + return fallback + } + return filename +} + +func copyFile(srcPath, dstPath string) error { + src, err := os.Open(srcPath) + if err != nil { + return fmt.Errorf("open source: %w", err) + } + defer func() { + if err := src.Close(); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to close source file: %v\n", err) + } + }() + + dst, err := os.Create(dstPath) + if err != nil { + return fmt.Errorf("create destination: %w", err) + } + defer func() { + if err := dst.Close(); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to close destination file: %v\n", err) + } + }() + + if _, err := io.Copy(dst, src); err != nil { + return fmt.Errorf("copy data: %w", err) + } + + return nil +} diff --git a/cmd/caib/downloadcmd/download.go b/cmd/caib/downloadcmd/download.go new file mode 100644 index 00000000..dfb74dd1 --- /dev/null +++ b/cmd/caib/downloadcmd/download.go @@ -0,0 +1,113 @@ +// Package downloadcmd provides the image artifact download handler. +package downloadcmd + +import ( + "context" + "fmt" + "strings" + + common "github.com/centos-automotive-suite/automotive-dev-operator/cmd/caib/common" + "github.com/centos-automotive-suite/automotive-dev-operator/cmd/caib/registryauth" + buildapitypes "github.com/centos-automotive-suite/automotive-dev-operator/internal/buildapi" + buildapiclient "github.com/centos-automotive-suite/automotive-dev-operator/internal/buildapi/client" + "github.com/spf13/cobra" +) + +const phaseCompleted = "Completed" + +// Options wires download handler dependencies. +type Options struct { + ServerURL *string + AuthToken *string + OutputDir *string + InsecureSkipTLS *bool + + HandleError func(error) +} + +// Handler implements the download command run function. +type Handler struct { + opts Options +} + +// NewHandler creates a download handler. +func NewHandler(opts Options) *Handler { + return &Handler{opts: opts} +} + +func (h *Handler) handleError(err error) { + if h.opts.HandleError != nil { + h.opts.HandleError(err) + return + } + panic(err) +} + +// RunDownload handles `caib image download`. +func (h *Handler) RunDownload(_ *cobra.Command, args []string) { + ctx := context.Background() + downloadBuildName := args[0] + + if h.opts.ServerURL == nil || strings.TrimSpace(*h.opts.ServerURL) == "" { + h.handleError(fmt.Errorf("--server is required (or set CAIB_SERVER, or run 'caib login ')")) + return + } + if h.opts.OutputDir == nil || strings.TrimSpace(*h.opts.OutputDir) == "" { + h.handleError(fmt.Errorf("--output / -o is required")) + return + } + if h.opts.InsecureSkipTLS == nil { + h.handleError(fmt.Errorf("internal error: --insecure option is not configured")) + return + } + + serverURL := strings.TrimSpace(*h.opts.ServerURL) + outputDir := strings.TrimSpace(*h.opts.OutputDir) + insecureSkipTLS := *h.opts.InsecureSkipTLS + + var st *buildapitypes.BuildResponse + err := common.ExecuteWithReauth(serverURL, h.opts.AuthToken, insecureSkipTLS, func(api *buildapiclient.Client) error { + var getErr error + st, getErr = api.GetBuild(ctx, downloadBuildName) + return getErr + }) + if err != nil { + h.handleError(fmt.Errorf("error getting build %s: %w", downloadBuildName, err)) + return + } + + if st.Phase != phaseCompleted { + h.handleError(fmt.Errorf("build %s is not completed (phase: %s), cannot download artifacts", downloadBuildName, st.Phase)) + return + } + + ociRef := st.DiskImage + if ociRef == "" { + h.handleError(fmt.Errorf( + "build %s has no disk image artifact to download (no OCI export was configured)", + downloadBuildName, + )) + return + } + + registryUsername := "" + registryPassword := "" + if st.RegistryToken != "" { + registryUsername = "serviceaccount" + registryPassword = st.RegistryToken + } else { + effectiveRegistryURL, extractedUser, extractedPassword := registryauth.ExtractRegistryCredentials(ociRef, "") + registryUsername = extractedUser + registryPassword = extractedPassword + if err := registryauth.ValidateRegistryCredentials(effectiveRegistryURL, registryUsername, registryPassword); err != nil { + h.handleError(err) + return + } + } + + fmt.Printf("Downloading disk image from %s\n", ociRef) + if err := common.PullOCIArtifact(ociRef, outputDir, registryUsername, registryPassword, insecureSkipTLS); err != nil { + h.handleError(fmt.Errorf("download failed: %w", err)) + return + } +} diff --git a/cmd/caib/flashcmd/flash.go b/cmd/caib/flashcmd/flash.go new file mode 100644 index 00000000..53c571e9 --- /dev/null +++ b/cmd/caib/flashcmd/flash.go @@ -0,0 +1,279 @@ +// Package flashcmd provides the `caib image flash` command handler. +package flashcmd + +import ( + "context" + "crypto/tls" + "encoding/base64" + "fmt" + "net/http" + "net/url" + "os" + "strconv" + "strings" + "time" + + common "github.com/centos-automotive-suite/automotive-dev-operator/cmd/caib/common" + "github.com/centos-automotive-suite/automotive-dev-operator/cmd/caib/logstream" + buildapitypes "github.com/centos-automotive-suite/automotive-dev-operator/internal/buildapi" + buildapiclient "github.com/centos-automotive-suite/automotive-dev-operator/internal/buildapi/client" + "github.com/spf13/cobra" +) + +const ( + phaseCompleted = "Completed" + phaseFailed = "Failed" + phasePending = "Pending" + phaseRunning = "Running" + maxLogRetries = 24 +) + +// Options wires flash command handlers to caller-owned state and dependencies. +type Options struct { + ServerURL *string + AuthToken *string + JumpstarterClient *string + FlashName *string + Target *string + ExporterSelector *string + LeaseDuration *string + WaitForBuild *bool + FollowLogs *bool + InsecureSkipTLS *bool + + HandleError func(error) +} + +// Handler implements flash-related Cobra run functions. +type Handler struct { + opts Options +} + +// NewHandler creates a flash command handler. +func NewHandler(opts Options) *Handler { + return &Handler{opts: opts} +} + +func (h *Handler) handleError(err error) { + if h != nil && h.opts.HandleError != nil { + h.opts.HandleError(err) + return + } + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) +} + +func (h *Handler) applyWaitFollowDefaults(cmd *cobra.Command, defaultWait, defaultFollow bool) { + if !cmd.Flags().Changed("wait") { + *h.opts.WaitForBuild = defaultWait + } + if !cmd.Flags().Changed("follow") { + *h.opts.FollowLogs = defaultFollow + } +} + +// RunFlash handles the standalone `caib image flash` command. +func (h *Handler) RunFlash(cmd *cobra.Command, args []string) { + h.applyWaitFollowDefaults(cmd, true, false) + + ctx := context.Background() + imageRef := args[0] + server := strings.TrimSpace(*h.opts.ServerURL) + + if server == "" { + h.handleError(fmt.Errorf("--server is required (or set CAIB_SERVER, or run 'caib login ')")) + return + } + + if strings.TrimSpace(*h.opts.JumpstarterClient) == "" { + h.handleError(fmt.Errorf("--client is required")) + return + } + + // Validate that either target or exporter is specified. + if strings.TrimSpace(*h.opts.Target) == "" && strings.TrimSpace(*h.opts.ExporterSelector) == "" { + h.handleError(fmt.Errorf("either --target or --exporter is required")) + return + } + + api, err := common.CreateBuildAPIClient(server, h.opts.AuthToken, *h.opts.InsecureSkipTLS) + if err != nil { + h.handleError(err) + return + } + + clientConfigBytes, err := os.ReadFile(*h.opts.JumpstarterClient) + if err != nil { + h.handleError(fmt.Errorf("failed to read client config file: %w", err)) + return + } + clientConfigB64 := base64.StdEncoding.EncodeToString(clientConfigBytes) + + req := buildapitypes.FlashRequest{ + Name: *h.opts.FlashName, + ImageRef: imageRef, + Target: *h.opts.Target, + ExporterSelector: *h.opts.ExporterSelector, + ClientConfig: clientConfigB64, + LeaseDuration: *h.opts.LeaseDuration, + } + + resp, err := api.CreateFlash(ctx, req) + if err != nil { + h.handleError(err) + return + } + fmt.Printf("Flash job %s accepted: %s - %s\n", resp.Name, resp.Phase, resp.Message) + + if *h.opts.WaitForBuild || *h.opts.FollowLogs { + h.waitForFlashCompletion(ctx, api, resp.Name) + } +} + +// parseLeaseDuration converts HH:MM:SS format to time.Duration. +func parseLeaseDuration(duration string) (time.Duration, error) { + parts := strings.Split(duration, ":") + if len(parts) != 3 { + return 0, fmt.Errorf("invalid lease duration %q: expected HH:MM:SS", duration) + } + + hours, err := strconv.Atoi(parts[0]) + if err != nil { + return 0, fmt.Errorf("invalid lease duration hours %q", parts[0]) + } + mins, err := strconv.Atoi(parts[1]) + if err != nil { + return 0, fmt.Errorf("invalid lease duration minutes %q", parts[1]) + } + secs, err := strconv.Atoi(parts[2]) + if err != nil { + return 0, fmt.Errorf("invalid lease duration seconds %q", parts[2]) + } + if hours < 0 || hours > 8760 || mins < 0 || mins >= 60 || secs < 0 || secs >= 60 { + return 0, fmt.Errorf("invalid lease duration values %q", duration) + } + + return time.Duration(hours)*time.Hour + time.Duration(mins)*time.Minute + time.Duration(secs)*time.Second, nil +} + +// waitForFlashCompletion waits for a flash job to complete, optionally streaming logs. +func (h *Handler) waitForFlashCompletion(ctx context.Context, _ *buildapiclient.Client, name string) { + fmt.Println("Waiting for flash to complete...") + + leaseDuration, err := parseLeaseDuration(*h.opts.LeaseDuration) + if err != nil { + h.handleError(fmt.Errorf("invalid lease duration: %w", err)) + return + } + + timeoutDuration := leaseDuration + 10*time.Minute + timeoutCtx, cancel := context.WithTimeout(ctx, timeoutDuration) + defer cancel() + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + var lastPhase, lastMessage string + pendingWarningShown := false + + flashLogTransport := &http.Transport{ + ResponseHeaderTimeout: 30 * time.Second, + IdleConnTimeout: 2 * time.Minute, + } + if *h.opts.InsecureSkipTLS { + flashLogTransport.TLSClientConfig = &tls.Config{ + InsecureSkipVerify: true, + MinVersion: tls.VersionTLS12, + } + } + logClient := &http.Client{Transport: flashLogTransport} + streamState := &logstream.State{} + + for { + select { + case <-timeoutCtx.Done(): + h.handleError(fmt.Errorf("timed out waiting for flash")) + return + case <-ticker.C: + reqCtx, cancelReq := context.WithTimeout(timeoutCtx, 2*time.Minute) + var st *buildapitypes.FlashResponse + err := common.ExecuteWithReauth(*h.opts.ServerURL, h.opts.AuthToken, *h.opts.InsecureSkipTLS, func(api *buildapiclient.Client) error { + var getErr error + st, getErr = api.GetFlash(reqCtx, name) + return getErr + }) + cancelReq() + if err != nil { + fmt.Printf("status check failed: %v\n", err) + continue + } + + if !streamState.Active && (st.Phase != lastPhase || st.Message != lastMessage) { + fmt.Printf("status: %s - %s\n", st.Phase, st.Message) + lastPhase = st.Phase + lastMessage = st.Message + } + + if st.Phase == phaseCompleted { + fmt.Println("Flash completed successfully!") + return + } + if st.Phase == phaseFailed { + h.handleError(fmt.Errorf("flash failed: %s", st.Message)) + return + } + + if !*h.opts.FollowLogs || streamState.Active || !streamState.CanRetry(maxLogRetries) { + continue + } + + if st.Phase == phasePending { + streamState.Reset() + if !pendingWarningShown { + fmt.Println("Waiting for flash to start before streaming logs...") + pendingWarningShown = true + } + continue + } + + if st.Phase == phaseRunning { + if streamState.RetryCount == 0 { + fmt.Println("Flash is running. Attempting to stream logs...") + pendingWarningShown = false + } + if err := h.tryFlashLogStreaming(timeoutCtx, logClient, name, streamState); err != nil { + streamState.RetryCount++ + } + } + } + } +} + +func (h *Handler) tryFlashLogStreaming(ctx context.Context, logClient *http.Client, name string, state *logstream.State) error { + logURL := strings.TrimRight(strings.TrimSpace(*h.opts.ServerURL), "/") + "/v1/flash/" + url.PathEscape(name) + "/logs?follow=1" + if !state.StartTime.IsZero() { + logURL += "&since=" + url.QueryEscape(state.StartTime.Format(time.RFC3339)) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, logURL, nil) + if err != nil { + return fmt.Errorf("failed to create log request: %w", err) + } + if t := strings.TrimSpace(*h.opts.AuthToken); t != "" { + req.Header.Set("Authorization", "Bearer "+t) + } + + resp, err := logClient.Do(req) + if err != nil { + return fmt.Errorf("log request failed: %w", err) + } + + if resp.StatusCode == http.StatusOK { + defer func() { + if closeErr := resp.Body.Close(); closeErr != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to close response body: %v\n", closeErr) + } + }() + return logstream.StreamLogsToStdout(resp.Body, state, false) + } + return logstream.HandleLogStreamError(resp, state, maxLogRetries) +} diff --git a/cmd/caib/image/image.go b/cmd/caib/image/image.go new file mode 100644 index 00000000..b174f9b3 --- /dev/null +++ b/cmd/caib/image/image.go @@ -0,0 +1,508 @@ +// Package image defines the `caib image` command tree. +package image + +import ( + "os" + + "github.com/centos-automotive-suite/automotive-dev-operator/cmd/caib/config" + "github.com/spf13/cobra" +) + +// Options wires the image command tree to caller-owned state and handlers. +type Options struct { + RunBuild func(*cobra.Command, []string) + RunDisk func(*cobra.Command, []string) + RunBuildDev func(*cobra.Command, []string) + RunList func(*cobra.Command, []string) + RunShow func(*cobra.Command, []string) + RunDownload func(*cobra.Command, []string) + RunLogs func(*cobra.Command, []string) + RunFlash func(*cobra.Command, []string) + RunPrepareReseal func(*cobra.Command, []string) + RunReseal func(*cobra.Command, []string) + RunExtractForSigning func(*cobra.Command, []string) + RunInjectSigned func(*cobra.Command, []string) + + GetDefaultArch func() string + + ServerURL *string + AuthToken *string + BuildName *string + ShowOutputFormat *string + Distro *string + Target *string + Architecture *string + ExportFormat *string + Mode *string + AutomotiveImageBuilder *string + StorageClass *string + OutputDir *string + Timeout *int + WaitForBuild *bool + CustomDefs *[]string + AIBExtraArgs *[]string + FollowLogs *bool + CompressionAlgo *string + ContainerPush *string + BuildDiskImage *bool + DiskFormat *string + ExportOCI *string + BuilderImage *string + RegistryAuthFile *string + RebuildBuilder *bool + + FlashAfterBuild *bool + JumpstarterClient *string + FlashName *string + ExporterSelector *string + LeaseDuration *string + + UseInternalRegistry *bool + InternalRegistryImageName *string + InternalRegistryTag *string + + SealedBuilderImage *string + SealedArchitecture *string + SealedKeySecret *string + SealedKeyPasswordSecret *string + SealedKeyFile *string + SealedKeyPassword *string + SealedInputRef *string + SealedOutputRef *string + SealedSignedRef *string +} + +// NewImageCmd creates the top-level `caib image` command with all image workflow subcommands. +func NewImageCmd(opts Options) *cobra.Command { + cmd := &cobra.Command{ + Use: "image", + Short: "Build and manage image workflows", + Long: `Commands for creating, managing, and inspecting image builds.`, + } + + buildCmd := newBuildCmd(opts) + diskCmd := newDiskCmd(opts) + buildDevCmd := newBuildDevCmd(opts) + listCmd := newListCmd(opts) + showCmd := newShowCmd(opts) + downloadCmd := newDownloadCmd(opts) + logsCmd := newLogsCmd(opts) + flashCmd := newFlashCmd(opts) + + prepareResealCmd := newPrepareResealCmd(opts) + resealCmd := newResealCmd(opts) + extractForSigningCmd := newExtractForSigningCmd(opts) + injectSignedCmd := newInjectSignedCmd(opts) + + // build command flags (bootc - the default) + buildCmd.Flags().StringVar(opts.ServerURL, "server", config.DefaultServer(), "REST API server base URL") + buildCmd.Flags().StringVar(opts.AuthToken, "token", os.Getenv("CAIB_TOKEN"), "Bearer token for authentication") + buildCmd.Flags().StringVarP(opts.BuildName, "name", "n", "", "name for the ImageBuild (auto-generated if omitted)") + buildCmd.Flags().StringVarP(opts.Distro, "distro", "d", "autosd", "distribution to build") + buildCmd.Flags().StringVarP(opts.Target, "target", "t", "qemu", "target platform") + buildCmd.Flags().StringVarP(opts.Architecture, "arch", "a", opts.GetDefaultArch(), "architecture (amd64, arm64)") + buildCmd.Flags().StringVar(opts.ContainerPush, "push", "", "push bootc container to registry (optional if --disk is used)") + buildCmd.Flags().BoolVar(opts.BuildDiskImage, "disk", false, "also build disk image from container") + buildCmd.Flags().StringVarP(opts.OutputDir, "output", "o", "", "download disk image to file from registry (implies --disk; requires --push-disk or --internal-registry)") + buildCmd.Flags().StringVar( + opts.DiskFormat, "format", "", "disk image format (qcow2, raw, simg); inferred from output filename if not set", + ) + buildCmd.Flags().StringVar(opts.CompressionAlgo, "compress", "gzip", "compression algorithm (gzip, lz4, xz)") + buildCmd.Flags().StringVar(opts.ExportOCI, "push-disk", "", "push disk image as OCI artifact to registry (implies --disk)") + buildCmd.Flags().StringVar( + opts.RegistryAuthFile, + "registry-auth-file", + "", + "path to Docker/Podman auth file for push authentication (takes precedence over env vars and auto-discovery)", + ) + buildCmd.Flags().StringVar( + opts.AutomotiveImageBuilder, "aib-image", + "quay.io/centos-sig-automotive/automotive-image-builder:latest", "AIB container image", + ) + buildCmd.Flags().StringVar(opts.BuilderImage, "builder-image", "", "custom builder container") + buildCmd.Flags().BoolVar(opts.RebuildBuilder, "rebuild-builder", false, "force rebuild of the bootc builder image") + buildCmd.Flags().StringVar(opts.StorageClass, "storage-class", "", "Kubernetes storage class for build workspace") + buildCmd.Flags().StringArrayVarP(opts.CustomDefs, "define", "D", []string{}, "custom definition KEY=VALUE") + buildCmd.Flags().StringArrayVar(opts.AIBExtraArgs, "extra-args", []string{}, "extra arguments to pass to AIB (can be repeated)") + buildCmd.Flags().IntVar(opts.Timeout, "timeout", 60, "timeout in minutes") + buildCmd.Flags().BoolVarP(opts.WaitForBuild, "wait", "w", true, "wait for build to complete") + buildCmd.Flags().BoolVarP(opts.FollowLogs, "follow", "f", false, "follow build logs (shows full log output instead of progress bar)") + // Note: --push is optional when --disk is used (disk image becomes the output) + // Jumpstarter flash options + buildCmd.Flags().BoolVar(opts.FlashAfterBuild, "flash", false, "flash the image to device after build completes") + buildCmd.Flags().StringVar(opts.JumpstarterClient, "client", "", "path to Jumpstarter client config file (required for --flash)") + buildCmd.Flags().StringVar(opts.LeaseDuration, "lease", "03:00:00", "device lease duration for flash (HH:MM:SS)") + // Internal registry options + buildCmd.Flags().BoolVar(opts.UseInternalRegistry, "internal-registry", false, "push to OpenShift internal registry") + buildCmd.Flags().StringVar(opts.InternalRegistryImageName, "image-name", "", "override image name for internal registry (default: build name)") + buildCmd.Flags().StringVar(opts.InternalRegistryTag, "image-tag", "", "tag for internal registry image (default: build name)") + + listCmd.Flags().StringVar( + opts.ServerURL, "server", config.DefaultServer(), "REST API server base URL (e.g. https://api.example)", + ) + listCmd.Flags().StringVar( + opts.AuthToken, "token", os.Getenv("CAIB_TOKEN"), + "Bearer token for authentication (e.g., OpenShift access token)", + ) + showCmd.Flags().StringVar( + opts.ServerURL, "server", config.DefaultServer(), "REST API server base URL (e.g. https://api.example)", + ) + showCmd.Flags().StringVar( + opts.AuthToken, "token", os.Getenv("CAIB_TOKEN"), + "Bearer token for authentication (e.g., OpenShift access token)", + ) + showCmd.Flags().StringVarP( + opts.ShowOutputFormat, "output", "o", "table", "Output format (table, json, yaml)", + ) + + // disk command flags (create disk from existing container) + diskCmd.Flags().StringVar(opts.ServerURL, "server", config.DefaultServer(), "REST API server base URL") + diskCmd.Flags().StringVar(opts.AuthToken, "token", os.Getenv("CAIB_TOKEN"), "Bearer token for authentication") + diskCmd.Flags().StringVarP(opts.BuildName, "name", "n", "", "name for the build job (auto-generated if omitted)") + diskCmd.Flags().StringVarP(opts.OutputDir, "output", "o", "", "download disk image to file from registry (requires --push)") + diskCmd.Flags().StringVar( + opts.DiskFormat, "format", "", "disk image format (qcow2, raw, simg); inferred from output filename if not set", + ) + diskCmd.Flags().StringVar(opts.CompressionAlgo, "compress", "gzip", "compression algorithm (gzip, lz4, xz)") + diskCmd.Flags().StringVar(opts.ExportOCI, "push", "", "push disk image as OCI artifact to registry") + diskCmd.Flags().StringVar( + opts.RegistryAuthFile, + "registry-auth-file", + "", + "path to Docker/Podman auth file for push authentication (takes precedence over env vars and auto-discovery)", + ) + diskCmd.Flags().StringVarP(opts.Distro, "distro", "d", "autosd", "distribution") + diskCmd.Flags().StringVarP(opts.Target, "target", "t", "qemu", "target platform") + diskCmd.Flags().StringVarP(opts.Architecture, "arch", "a", opts.GetDefaultArch(), "architecture (amd64, arm64)") + diskCmd.Flags().StringVar( + opts.AutomotiveImageBuilder, "aib-image", + "quay.io/centos-sig-automotive/automotive-image-builder:latest", "AIB container image", + ) + diskCmd.Flags().StringVar(opts.StorageClass, "storage-class", "", "Kubernetes storage class") + diskCmd.Flags().StringArrayVar(opts.AIBExtraArgs, "extra-args", []string{}, "extra arguments to pass to AIB (can be repeated)") + diskCmd.Flags().IntVar(opts.Timeout, "timeout", 60, "timeout in minutes") + diskCmd.Flags().BoolVarP(opts.WaitForBuild, "wait", "w", false, "wait for build to complete") + diskCmd.Flags().BoolVarP(opts.FollowLogs, "follow", "f", false, "follow build logs (shows full log output instead of progress bar)") + // Jumpstarter flash options + diskCmd.Flags().BoolVar(opts.FlashAfterBuild, "flash", false, "flash the image to device after build completes") + diskCmd.Flags().StringVar(opts.JumpstarterClient, "client", "", "path to Jumpstarter client config file (required for --flash)") + diskCmd.Flags().StringVar(opts.LeaseDuration, "lease", "03:00:00", "device lease duration for flash (HH:MM:SS)") + // Internal registry options + diskCmd.Flags().BoolVar(opts.UseInternalRegistry, "internal-registry", false, "push to OpenShift internal registry") + diskCmd.Flags().StringVar(opts.InternalRegistryImageName, "image-name", "", "override image name for internal registry (default: build name)") + diskCmd.Flags().StringVar(opts.InternalRegistryTag, "image-tag", "", "tag for internal registry image (default: build name)") + + // build-dev command flags (traditional ostree/package builds) + buildDevCmd.Flags().StringVar(opts.ServerURL, "server", config.DefaultServer(), "REST API server base URL") + buildDevCmd.Flags().StringVar(opts.AuthToken, "token", os.Getenv("CAIB_TOKEN"), "Bearer token for authentication") + buildDevCmd.Flags().StringVarP(opts.BuildName, "name", "n", "", "name for the ImageBuild") + buildDevCmd.Flags().StringVarP(opts.Distro, "distro", "d", "autosd", "distribution to build") + buildDevCmd.Flags().StringVarP(opts.Target, "target", "t", "qemu", "target platform") + buildDevCmd.Flags().StringVarP(opts.Architecture, "arch", "a", opts.GetDefaultArch(), "architecture (amd64, arm64)") + buildDevCmd.Flags().StringVar(opts.Mode, "mode", "package", "build mode: image (ostree) or package (package-based)") + buildDevCmd.Flags().StringVar(opts.ExportFormat, "format", "", "export format: qcow2, raw, simg, etc.") + buildDevCmd.Flags().StringVarP(opts.OutputDir, "output", "o", "", "download artifact to file from registry (requires --push)") + buildDevCmd.Flags().StringVar(opts.CompressionAlgo, "compress", "gzip", "compression algorithm (gzip, lz4, xz)") + buildDevCmd.Flags().StringVar(opts.ExportOCI, "push", "", "push disk image as OCI artifact to registry") + buildDevCmd.Flags().StringVar( + opts.RegistryAuthFile, + "registry-auth-file", + "", + "path to Docker/Podman auth file for push authentication (takes precedence over env vars and auto-discovery)", + ) + buildDevCmd.Flags().StringVar( + opts.AutomotiveImageBuilder, "aib-image", + "quay.io/centos-sig-automotive/automotive-image-builder:latest", "AIB container image", + ) + buildDevCmd.Flags().StringVar(opts.StorageClass, "storage-class", "", "Kubernetes storage class") + buildDevCmd.Flags().StringArrayVarP(opts.CustomDefs, "define", "D", []string{}, "custom definition KEY=VALUE") + buildDevCmd.Flags().StringArrayVar(opts.AIBExtraArgs, "extra-args", []string{}, "extra arguments to pass to AIB (can be repeated)") + buildDevCmd.Flags().IntVar(opts.Timeout, "timeout", 60, "timeout in minutes") + buildDevCmd.Flags().BoolVarP(opts.WaitForBuild, "wait", "w", false, "wait for build to complete") + buildDevCmd.Flags().BoolVarP(opts.FollowLogs, "follow", "f", false, "follow build logs (shows full log output instead of progress bar)") + // Jumpstarter flash options + buildDevCmd.Flags().BoolVar(opts.FlashAfterBuild, "flash", false, "flash the image to device after build completes") + buildDevCmd.Flags().StringVar(opts.JumpstarterClient, "client", "", "path to Jumpstarter client config file (required for --flash)") + buildDevCmd.Flags().StringVar(opts.LeaseDuration, "lease", "03:00:00", "device lease duration for flash (HH:MM:SS)") + // Internal registry options + buildDevCmd.Flags().BoolVar(opts.UseInternalRegistry, "internal-registry", false, "push to OpenShift internal registry") + buildDevCmd.Flags().StringVar(opts.InternalRegistryImageName, "image-name", "", "override image name for internal registry (default: build name)") + buildDevCmd.Flags().StringVar(opts.InternalRegistryTag, "image-tag", "", "tag for internal registry image (default: build name)") + + // logs command flags + logsCmd.Flags().StringVar(opts.ServerURL, "server", config.DefaultServer(), "REST API server base URL") + logsCmd.Flags().StringVar(opts.AuthToken, "token", os.Getenv("CAIB_TOKEN"), "Bearer token for authentication") + logsCmd.Flags().IntVar(opts.Timeout, "timeout", 60, "timeout in minutes") + + // download command flags + downloadCmd.Flags().StringVar(opts.ServerURL, "server", config.DefaultServer(), "REST API server base URL") + downloadCmd.Flags().StringVar(opts.AuthToken, "token", os.Getenv("CAIB_TOKEN"), "Bearer token for authentication") + downloadCmd.Flags().StringVarP(opts.OutputDir, "output", "o", "", "destination file or directory for the artifact") + + // flash command flags + flashCmd.Flags().StringVar(opts.ServerURL, "server", config.DefaultServer(), "REST API server base URL") + flashCmd.Flags().StringVar(opts.AuthToken, "token", os.Getenv("CAIB_TOKEN"), "Bearer token for authentication") + flashCmd.Flags().StringVar(opts.JumpstarterClient, "client", "", "path to Jumpstarter client config file (required)") + flashCmd.Flags().StringVarP(opts.FlashName, "name", "n", "", "name for the flash job (auto-generated if omitted)") + flashCmd.Flags().StringVarP(opts.Target, "target", "t", "", "target platform for exporter lookup") + flashCmd.Flags().StringVar(opts.ExporterSelector, "exporter", "", "direct exporter selector (alternative to --target)") + flashCmd.Flags().StringVar(opts.LeaseDuration, "lease", "03:00:00", "device lease duration (HH:MM:SS)") + flashCmd.Flags().BoolVarP(opts.FollowLogs, "follow", "f", false, "follow flash logs (shows full log output instead of progress bar)") + flashCmd.Flags().BoolVarP(opts.WaitForBuild, "wait", "w", true, "wait for flash to complete") + _ = flashCmd.MarkFlagRequired("client") + + // Sealed operation shared flags + addSealedFlags(prepareResealCmd, opts) + addSealedFlags(resealCmd, opts) + addSealedFlags(extractForSigningCmd, opts) + addSealedFlags(injectSignedCmd, opts) + injectSignedCmd.Flags().StringVar(opts.SealedSignedRef, "signed", "", "Signed artifact ref for inject-signed") + + cmd.AddCommand( + buildCmd, + diskCmd, + buildDevCmd, + listCmd, + showCmd, + downloadCmd, + logsCmd, + flashCmd, + prepareResealCmd, + resealCmd, + extractForSigningCmd, + injectSignedCmd, + ) + + return cmd +} + +func newBuildCmd(opts Options) *cobra.Command { + return &cobra.Command{ + Use: "build ", + Short: "Build bootc container image with optional disk image", + Long: `Build creates a bootc container image from an AIB manifest. + +Bootc images are immutable, atomically updatable OS images based on +container technology. This is the recommended approach for production. + +Examples: + # Build and push container to registry + caib image build manifest.aib.yml --push quay.io/org/my-os:v1 + + # Build container + create disk image + caib image build manifest.aib.yml --push quay.io/org/my-os:v1 --disk -o disk.qcow2`, + Args: cobra.ExactArgs(1), + Run: opts.RunBuild, + } +} + +func newDiskCmd(opts Options) *cobra.Command { + return &cobra.Command{ + Use: "disk ", + Short: "Create disk image from existing bootc container", + Long: `Create a disk image from an existing bootc container in a registry. + +This uses 'aib to-disk-image' to convert a bootc container to a disk +image that can be flashed onto hardware. + +Examples: + # Create disk image from container + caib image disk quay.io/org/my-os:v1 -o disk.qcow2 --format qcow2 + + # Push disk as OCI artifact instead of downloading + caib image disk quay.io/org/my-os:v1 --push quay.io/org/my-disk:v1`, + Args: cobra.ExactArgs(1), + Run: opts.RunDisk, + } +} + +func newBuildDevCmd(opts Options) *cobra.Command { + return &cobra.Command{ + Use: "build-dev ", + Short: "Build disk image for development (ostree or package-based)", + Long: `Build a disk image using ostree or package-based mode for development workflows. + +This creates standalone disk images without bootc container integration. + +Examples: + # Ostree-based image + caib image build-dev manifest.aib.yml --mode image --format qcow2 -o disk.qcow2 + + # Package-based image + caib image build-dev manifest.aib.yml --mode package --format raw -o disk.raw`, + Args: cobra.ExactArgs(1), + Run: opts.RunBuildDev, + } +} + +func newFlashCmd(opts Options) *cobra.Command { + return &cobra.Command{ + Use: "flash ", + Short: "Flash a disk image to hardware via Jumpstarter", + Long: `Flash a disk image from an OCI registry to a hardware device using Jumpstarter. + +This command connects to a Jumpstarter exporter to flash the specified disk image +onto physical hardware. Requires a Jumpstarter client configuration file. + +Examples: + # Flash using target platform lookup + caib image flash quay.io/org/disk:v1 --client ~/.jumpstarter/client.yaml --target j784s4evm + + # Flash with explicit exporter selector + caib image flash quay.io/org/disk:v1 --client ~/.jumpstarter/client.yaml --exporter "board-type=j784s4evm"`, + Args: cobra.ExactArgs(1), + Run: opts.RunFlash, + } +} + +func newListCmd(opts Options) *cobra.Command { + return &cobra.Command{ + Use: "list", + Short: "List existing ImageBuilds", + Run: opts.RunList, + } +} + +func newShowCmd(opts Options) *cobra.Command { + return &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 image show my-build + + # Show details as JSON + caib image show my-build -o json`, + Args: cobra.ExactArgs(1), + Run: opts.RunShow, + } +} + +func newDownloadCmd(opts Options) *cobra.Command { + return &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 image download my-build -o ./disk.qcow2 + + # Download to a directory (multi-layer artifacts extract here) + caib image download my-build -o ./output/`, + Args: cobra.ExactArgs(1), + Run: opts.RunDownload, + } +} + +func newLogsCmd(opts Options) *cobra.Command { + return &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 image logs my-build-20250101-120000 + + # List builds first, then follow one + caib image list + caib image logs `, + Args: cobra.ExactArgs(1), + Run: opts.RunLogs, + } +} + +func newPrepareResealCmd(opts Options) *cobra.Command { + return &cobra.Command{ + Use: "prepare-reseal [source-container] [output-container]", + Short: "Prepare a bootc container image for resealing", + Long: `Prepare a bootc container image for resealing. With --server, runs on +the cluster via the Build API; otherwise runs locally using the AIB container. + +Input and output can be given as positionals or via --input and --output (any order). + +Examples: + + # Run locally + caib image prepare-reseal ./input.qcow2 ./output.qcow2 --workspace ./work`, + Args: cobra.RangeArgs(0, 2), + Run: opts.RunPrepareReseal, + } +} + +func newResealCmd(opts Options) *cobra.Command { + return &cobra.Command{ + Use: "reseal [source-container] [output-container]", + Short: "Reseal a prepared bootc container image with a new key", + Long: `Reseal a bootc container image that was prepared with prepare-reseal. +With --server, runs on the cluster via the Build API; otherwise runs locally. + +Input and output can be given as positionals or via --input and --output (any order). +If no seal key is provided, an ephemeral key is generated for one-time use.`, + Args: cobra.RangeArgs(0, 2), + Run: opts.RunReseal, + } +} + +func newExtractForSigningCmd(opts Options) *cobra.Command { + return &cobra.Command{ + Use: "extract-for-signing [source-container] [output-artifact]", + Short: "Extract components from a container image for external signing", + Long: `Extract components that need to be signed (e.g. for secure boot) from a +container image. Sign the extracted contents externally, then use inject-signed. + +Input and output can be given as positionals or via --input and --output (any order).`, + Args: cobra.RangeArgs(0, 2), + Run: opts.RunExtractForSigning, + } +} + +func newInjectSignedCmd(opts Options) *cobra.Command { + return &cobra.Command{ + Use: "inject-signed [source-container] [signed-artifact] [output-container]", + Short: "Inject signed components back into a container image", + Long: `Inject externally signed components (from extract-for-signing) back into the +container image. Optionally reseals in the same step with --key. + +Input, signed artifact, and output can be given as positionals or via --input, --signed, --output (any order).`, + Args: cobra.RangeArgs(0, 3), + Run: opts.RunInjectSigned, + } +} + +func addSealedFlags(cmd *cobra.Command, opts Options) { + cmd.Flags().StringVar(opts.ServerURL, "server", config.DefaultServer(), "Build API server URL") + cmd.Flags().StringVar(opts.AuthToken, "token", os.Getenv("CAIB_TOKEN"), "Bearer token for authentication") + cmd.Flags().StringVar(opts.SealedInputRef, "input", "", "Input/source container or artifact ref") + cmd.Flags().StringVar(opts.SealedOutputRef, "output", "", "Output container or artifact ref") + cmd.Flags().StringVar( + opts.RegistryAuthFile, + "registry-auth-file", + "", + "path to Docker/Podman auth file for registry authentication (takes precedence over env vars and auto-discovery)", + ) + cmd.Flags().StringVar( + opts.AutomotiveImageBuilder, "aib-image", + "quay.io/centos-sig-automotive/automotive-image-builder:latest", "AIB container image", + ) + cmd.Flags().StringVar(opts.SealedBuilderImage, "builder-image", "", "Builder container image (overrides --arch default)") + cmd.Flags().StringVar(opts.SealedArchitecture, "arch", "", "Target architecture for default builder image (amd64, arm64); auto-detected if not set") + cmd.Flags().StringArrayVar(opts.AIBExtraArgs, "extra-args", nil, "Extra arguments to pass to AIB (repeatable)") + cmd.Flags().BoolVarP(opts.WaitForBuild, "wait", "w", false, "Wait for completion") + cmd.Flags().BoolVarP(opts.FollowLogs, "follow", "f", true, "Stream task logs") + cmd.Flags().StringVar(opts.SealedKeySecret, "key-secret", "", "Name of existing cluster secret containing sealing key (data key 'private-key')") + cmd.Flags().StringVar(opts.SealedKeyPasswordSecret, "key-password-secret", "", "Name of existing cluster secret containing key password (data key 'password')") + cmd.Flags().StringVar(opts.SealedKeyFile, "key", "", "Path to local PEM key file (uploaded to cluster automatically)") + cmd.Flags().StringVar(opts.SealedKeyPassword, "passwd", "", "Password for encrypted key file (used with --key)") + cmd.Flags().IntVar(opts.Timeout, "timeout", 120, "Timeout in minutes") +} diff --git a/cmd/caib/image/image_test.go b/cmd/caib/image/image_test.go new file mode 100644 index 00000000..f9a626c1 --- /dev/null +++ b/cmd/caib/image/image_test.go @@ -0,0 +1,48 @@ +package image + +import ( + "strings" + "testing" + + "github.com/spf13/cobra" +) + +func TestShowCommandRejectsMissingBuildName(t *testing.T) { + called := false + cmd := newShowCmd(Options{ + RunShow: func(_ *cobra.Command, _ []string) { + called = true + }, + }) + + cmd.SetArgs([]string{}) + + err := cmd.Execute() + if err == nil { + t.Fatalf("expected an error when build name argument is missing") + } + if called { + t.Fatalf("expected RunShow not to be called when args are invalid") + } + if !strings.Contains(err.Error(), "accepts 1 arg(s), received 0") { + t.Fatalf("unexpected error for missing build name: %v", err) + } +} + +func TestShowCommandInvokesHandlerWithBuildName(t *testing.T) { + var gotArgs []string + cmd := newShowCmd(Options{ + RunShow: func(_ *cobra.Command, args []string) { + gotArgs = append([]string{}, args...) + }, + }) + + cmd.SetArgs([]string{"my-build"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("expected command to execute successfully: %v", err) + } + if len(gotArgs) != 1 || gotArgs[0] != "my-build" { + t.Fatalf("expected RunShow to receive [my-build], got %v", gotArgs) + } +} diff --git a/cmd/caib/login.go b/cmd/caib/login.go new file mode 100644 index 00000000..87509a25 --- /dev/null +++ b/cmd/caib/login.go @@ -0,0 +1,70 @@ +package main + +import ( + "context" + "fmt" + "net/url" + "strings" + + "github.com/centos-automotive-suite/automotive-dev-operator/cmd/caib/auth" + "github.com/centos-automotive-suite/automotive-dev-operator/cmd/caib/config" + "github.com/spf13/cobra" +) + +// runLogin saves the server URL and optionally performs OIDC authentication. +func runLogin(_ *cobra.Command, args []string) { + raw := strings.TrimSpace(args[0]) + if raw == "" { + handleError(fmt.Errorf("server URL is required")) + return + } + server := raw + if !strings.HasPrefix(server, "http://") && !strings.HasPrefix(server, "https://") { + server = "https://" + server + } + + parsedURL, err := url.Parse(server) + if err != nil || parsedURL.Scheme == "" || parsedURL.Host == "" { + handleError(fmt.Errorf("invalid server URL %q", server)) + return + } + if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { + handleError(fmt.Errorf("invalid server URL %q: scheme must be http or https", server)) + return + } + if parsedURL.User != nil { + handleError(fmt.Errorf("server URL must not include credentials")) + return + } + if parsedURL.RawQuery != "" { + handleError(fmt.Errorf("server URL must not include query parameters")) + return + } + if parsedURL.Fragment != "" { + handleError(fmt.Errorf("server URL must not include fragments")) + return + } + if parsedURL.Path != "" && parsedURL.Path != "/" { + handleError(fmt.Errorf("server URL must not include a non-root path")) + return + } + server = parsedURL.Scheme + "://" + parsedURL.Host + + if err := config.SaveServerURL(server); err != nil { + handleError(fmt.Errorf("failed to save server URL: %w", err)) + return + } + fmt.Printf("Server saved: %s\n", server) + + ctx := context.Background() + token, didAuth, err := auth.GetTokenWithReauth(ctx, server, "", insecureSkipTLS) + if err != nil { + fmt.Printf("Warning: authentication failed (you may need --token or kubeconfig for API calls): %v\n", err) + return + } + if token != "" && didAuth { + fmt.Println("OIDC authentication successful. Token cached for subsequent commands.") + } else if token != "" { + fmt.Println("Using existing or kubeconfig token. You can run build/list/disk commands without --server.") + } +} diff --git a/cmd/caib/logstream/logstream.go b/cmd/caib/logstream/logstream.go new file mode 100644 index 00000000..200471d4 --- /dev/null +++ b/cmd/caib/logstream/logstream.go @@ -0,0 +1,124 @@ +// Package logstream provides shared log streaming helpers for CLI commands. +package logstream + +import ( + "bufio" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" +) + +// State stores reconnect and stream progress state. +type State struct { + Active bool + RetryCount int + WarningShown bool + StartTime time.Time + Completed bool + LeaseID string +} + +// CanRetry reports whether another reconnect attempt should be made. +func (s *State) CanRetry(maxRetries int) bool { + if s == nil { + return false + } + return s.RetryCount < maxRetries && !s.Completed +} + +// Reset clears retry counters for a fresh streaming attempt. +func (s *State) Reset() { + if s == nil { + return + } + s.RetryCount = 0 + s.WarningShown = false +} + +// StreamLogsToStdout streams response body line-by-line to stdout. +func StreamLogsToStdout(body io.Reader, state *State, captureLeaseID bool) error { + if state == nil { + return fmt.Errorf("stream state is required") + } + + firstStream := state.StartTime.IsZero() + if firstStream { + state.StartTime = time.Now() + fmt.Println("Streaming logs...") + } + state.Active = true + state.Reset() + + scanner := bufio.NewScanner(body) + scanner.Buffer(make([]byte, 64*1024), 1024*1024) + for scanner.Scan() { + line := scanner.Text() + fmt.Println(line) + state.StartTime = time.Now() + + if !captureLeaseID { + continue + } + + if strings.Contains(line, "jmp shell --lease ") { + parts := strings.Split(line, "jmp shell --lease ") + if len(parts) > 1 { + tokens := strings.Fields(parts[1]) + if len(tokens) > 0 { + state.LeaseID = tokens[0] + } + } + } else if strings.Contains(line, "Lease acquired: ") { + parts := strings.Split(line, "Lease acquired: ") + if len(parts) > 1 { + tokens := strings.Fields(parts[1]) + if len(tokens) > 0 { + state.LeaseID = tokens[0] + } + } + } + } + state.Active = false + + if err := scanner.Err(); err != nil { + return fmt.Errorf("log stream interrupted: %w", err) + } + + return nil +} + +// HandleLogStreamError handles common stream endpoint failures. +func HandleLogStreamError(resp *http.Response, state *State, maxRetries int) error { + if resp == nil || resp.Body == nil { + return fmt.Errorf("log stream failed: empty response") + } + + body, readErr := io.ReadAll(resp.Body) + closeErr := resp.Body.Close() + if readErr != nil { + return fmt.Errorf("failed to read log stream error body: %w", readErr) + } + if closeErr != nil { + return fmt.Errorf("failed to close log stream error body: %w", closeErr) + } + msg := strings.TrimSpace(string(body)) + + if resp.StatusCode == http.StatusServiceUnavailable || resp.StatusCode == http.StatusGatewayTimeout { + if state != nil && !state.WarningShown { + fmt.Fprintf(os.Stderr, "log stream not ready (HTTP %d). Retrying... (attempt %d/%d)\n", + resp.StatusCode, state.RetryCount+1, maxRetries) + state.WarningShown = true + } + return fmt.Errorf("log endpoint not ready (HTTP %d)", resp.StatusCode) + } + + if msg != "" { + fmt.Fprintf(os.Stderr, "log stream error (%d): %s\n", resp.StatusCode, msg) + } else { + fmt.Fprintf(os.Stderr, "log stream error: HTTP %d\n", resp.StatusCode) + } + return fmt.Errorf("log stream failed with HTTP %d", resp.StatusCode) +} diff --git a/cmd/caib/main.go b/cmd/caib/main.go index f3b66f6b..9bdd616d 100644 --- a/cmd/caib/main.go +++ b/cmd/caib/main.go @@ -2,79 +2,15 @@ package main import ( - "bufio" - "compress/gzip" - "context" - "crypto/tls" - "encoding/base64" - "encoding/json" "fmt" - "io" - "net/http" - "net/url" "os" - "os/exec" - "path/filepath" - "regexp" - "runtime" - "strconv" - "strings" - "text/tabwriter" - "time" - - "github.com/containers/image/v5/copy" - "github.com/containers/image/v5/docker" - "github.com/containers/image/v5/oci/layout" - "github.com/containers/image/v5/signature" - "github.com/containers/image/v5/types" - "gopkg.in/yaml.v3" - - "github.com/centos-automotive-suite/automotive-dev-operator/cmd/caib/auth" - "github.com/centos-automotive-suite/automotive-dev-operator/cmd/caib/authcmd" - "github.com/centos-automotive-suite/automotive-dev-operator/cmd/caib/catalog" - "github.com/centos-automotive-suite/automotive-dev-operator/cmd/caib/config" - "github.com/centos-automotive-suite/automotive-dev-operator/cmd/caib/container" - "github.com/centos-automotive-suite/automotive-dev-operator/cmd/caib/registryauth" - "github.com/centos-automotive-suite/automotive-dev-operator/cmd/caib/ui" - buildapitypes "github.com/centos-automotive-suite/automotive-dev-operator/internal/buildapi" - buildapiclient "github.com/centos-automotive-suite/automotive-dev-operator/internal/buildapi/client" - "github.com/fatih/color" - "github.com/spf13/cobra" - "golang.org/x/term" - "k8s.io/client-go/tools/clientcmd" ) const ( - archAMD64 = "amd64" - archARM64 = "arm64" - phaseCompleted = "Completed" - phaseFailed = "Failed" - phaseFlashing = "Flashing" - phasePending = "Pending" - phaseUploading = "Uploading" - phaseRunning = "Running" - errPrefixBuild = "build" - errPrefixFlash = "flash" - errPrefixPush = "push" - defaultRegistry = "docker.io" -) - -var ( - multiHyphenRe = regexp.MustCompile(`-{2,}`) + archAMD64 = "amd64" + archARM64 = "arm64" ) -// getDefaultArch returns the current system architecture in caib format -func getDefaultArch() string { - switch runtime.GOARCH { - case archAMD64: - return archAMD64 - case archARM64: - return archARM64 - default: - return archAMD64 - } -} - var ( serverURL string manifest string @@ -134,3021 +70,10 @@ var ( sealedSignedRef string ) -// envBool parses a boolean from environment variable -func envBool(key string) bool { - v := strings.TrimSpace(os.Getenv(key)) - if v == "" { - return false - } - b, err := strconv.ParseBool(v) - if err != nil { - return false - } - return b -} - -func supportsColorOutput() bool { - if os.Getenv("NO_COLOR") != "" { - return false - } - - if !term.IsTerminal(int(os.Stdout.Fd())) { - return false - } - - termType := os.Getenv("TERM") - if termType == "dumb" { - return false - } - - shell := os.Getenv("SHELL") - - isSupportedShell := strings.Contains(shell, "bash") || - strings.Contains(shell, "fish") || - strings.Contains(shell, "zsh") - - hasColorTerm := termType != "" && - !strings.Contains(termType, "mono") - - return !color.NoColor || isSupportedShell || hasColorTerm -} - -// createBuildAPIClient creates a build API client with authentication token from flags or kubeconfig -// It will attempt OIDC re-authentication if token is missing or expired -func createBuildAPIClient(serverURL string, authToken *string) (*buildapiclient.Client, error) { - ctx := context.Background() - - explicitToken := strings.TrimSpace(*authToken) != "" || os.Getenv("CAIB_TOKEN") != "" - - // If no explicit token, try OIDC if config is available - if !explicitToken { - token, didAuth, err := auth.GetTokenWithReauth(ctx, serverURL, "", insecureSkipTLS) - if err != nil { - // OIDC is configured but failed - don't silently fall back to kubeconfig - // This indicates a real authentication failure that should be reported - // Falling back could authenticate with an unexpected identity - fmt.Printf("Error: OIDC authentication failed: %v\n", err) - // Only try kubeconfig as last resort, but warn the user - fmt.Println("Attempting kubeconfig fallback (this may use a different identity)") - if tok, err := loadTokenFromKubeconfig(); err == nil && strings.TrimSpace(tok) != "" { - *authToken = tok - } else { - // No kubeconfig available either - return error - return nil, fmt.Errorf("OIDC authentication failed and no kubeconfig token available: %w", err) - } - } else if token != "" { - // OIDC succeeded - *authToken = token - if didAuth { - fmt.Println("OIDC authentication successful") - } - } else { - // OIDC not configured in OperatorConfig - if tok, err := loadTokenFromKubeconfig(); err == nil && strings.TrimSpace(tok) != "" { - *authToken = tok - } - } - } else { - // Token was explicitly provided, use it (but still try kubeconfig if empty) - if strings.TrimSpace(*authToken) == "" { - if tok, err := loadTokenFromKubeconfig(); err == nil && strings.TrimSpace(tok) != "" { - *authToken = tok - } - } - } - - var opts []buildapiclient.Option - if strings.TrimSpace(*authToken) != "" { - opts = append(opts, buildapiclient.WithAuthToken(strings.TrimSpace(*authToken))) - } - - // Configure TLS - if insecureSkipTLS { - opts = append(opts, buildapiclient.WithInsecureTLS()) - } - // Check for custom CA certificate - if caCertFile := os.Getenv("SSL_CERT_FILE"); caCertFile != "" { - opts = append(opts, buildapiclient.WithCACertificate(caCertFile)) - } else if caCertFile := os.Getenv("REQUESTS_CA_BUNDLE"); caCertFile != "" { - opts = append(opts, buildapiclient.WithCACertificate(caCertFile)) - } - - return buildapiclient.New(serverURL, opts...) -} - -// executeWithReauth executes an API call and automatically retries with re-authentication on auth errors. -func executeWithReauth(serverURL string, authToken *string, fn func(*buildapiclient.Client) error) error { - ctx := context.Background() - - client, err := createBuildAPIClient(serverURL, authToken) - if err != nil { - return err - } - - err = fn(client) - if err == nil { - return nil - } - - if !auth.IsAuthError(err) { - return err - } - - // Auth error (401) - try re-authentication; token may be rejected, not necessarily expired - fmt.Println("Authentication failed (401), re-authenticating...") - - newToken, _, err := auth.GetTokenWithReauth(ctx, serverURL, *authToken, insecureSkipTLS) - if err != nil { - return fmt.Errorf("re-authentication failed: %w", err) - } - - *authToken = newToken - // If re-auth returned no token (API says OIDC not configured), try kubeconfig before retrying - if strings.TrimSpace(*authToken) == "" { - if tok, kerr := loadTokenFromKubeconfig(); kerr == nil && strings.TrimSpace(tok) != "" { - *authToken = tok - client, err = createBuildAPIClient(serverURL, authToken) - if err != nil { - return err - } - fmt.Println("Using kubeconfig token, retrying...") - return fn(client) - } - } - - client, err = createBuildAPIClient(serverURL, authToken) - if err != nil { - return err - } - - fmt.Println("Retrying request...") - err = fn(client) - if err == nil { - return nil - } - - // Still 401 after OIDC re-auth (e.g. server OIDC broken, or wrong client/audience) - try kubeconfig fallback - if !auth.IsAuthError(err) { - return err - } - if tok, kerr := loadTokenFromKubeconfig(); kerr == nil && strings.TrimSpace(tok) != "" { - *authToken = tok - client, err = createBuildAPIClient(serverURL, authToken) - if err != nil { - return err - } - fmt.Println("Attempting kubeconfig fallback...") - return fn(client) - } - - return err -} - -// writeRegistryCredentialsFile writes registry credentials to a mode-0600 temp file and returns its path. -func writeRegistryCredentialsFile(token string) (string, error) { - creds, err := json.Marshal(map[string]string{ - "username": "serviceaccount", - "token": token, - }) - if err != nil { - return "", err - } - - f, err := os.CreateTemp("", "caib-registry-creds-*.json") - if err != nil { - return "", err - } - name := f.Name() - - if _, err := f.Write(creds); err != nil { - _ = f.Close() - _ = os.Remove(name) - return "", err - } - if err := f.Close(); err != nil { - _ = os.Remove(name) - return "", err - } - if err := os.Chmod(name, 0600); err != nil { - _ = os.Remove(name) - return "", err - } - return name, nil -} - -func validateOutputRequiresPush(output, pushRef, flagName string) { - if output == "" { - return - } - if pushRef == "" { - handleError(fmt.Errorf("--output requires %s to download from registry", flagName)) - } -} - -func downloadOCIArtifactIfRequested(output, exportOCI, registryUsername, registryPassword string, insecureSkipTLS bool) { - if output == "" { - return - } - if err := pullOCIArtifact(exportOCI, output, registryUsername, registryPassword, insecureSkipTLS); err != nil { - handleError(fmt.Errorf("failed to download OCI artifact: %w", err)) - } -} func main() { - rootCmd := &cobra.Command{ - Use: "caib", - Short: "Cloud Automotive Image Builder", - Version: version, - } - - rootCmd.InitDefaultVersionFlag() - rootCmd.SetVersionTemplate("caib version: {{.Version}}\n") - - // Global flags - rootCmd.PersistentFlags().BoolVar( - &insecureSkipTLS, - "insecure", - envBool("CAIB_INSECURE"), - "skip TLS certificate verification (insecure, for testing only; env: CAIB_INSECURE)", - ) - - // Main build command (bootc - the default, future-focused approach) - buildCmd := &cobra.Command{ - Use: "build ", - Short: "Build bootc container image with optional disk image", - Long: `Build creates a bootc container image from an AIB manifest. - -Bootc images are immutable, atomically updatable OS images based on -container technology. This is the recommended approach for production. - -Examples: - # Build and push container to registry - caib build manifest.aib.yml --push quay.io/org/my-os:v1 - - # Build container + create disk image - caib build manifest.aib.yml --push quay.io/org/my-os:v1 --disk -o disk.qcow2`, - Args: cobra.ExactArgs(1), - Run: runBuild, - } - - // Disk command - create disk from existing container - diskCmd := &cobra.Command{ - Use: "disk ", - Short: "Create disk image from existing bootc container", - Long: `Create a disk image from an existing bootc container in a registry. - -This uses 'aib to-disk-image' to convert a bootc container to a disk -image that can be flashed onto hardware. - -Examples: - # Create disk image from container - caib disk quay.io/org/my-os:v1 -o disk.qcow2 --format qcow2 - - # Push disk as OCI artifact instead of downloading - caib disk quay.io/org/my-os:v1 --push quay.io/org/my-disk:v1`, - Args: cobra.ExactArgs(1), - Run: runDisk, - } - - // Dev build command (traditional ostree/package-based) - buildDevCmd := &cobra.Command{ - Use: "build-dev ", - Short: "Build disk image for development (ostree or package-based)", - Long: `Build a disk image using ostree or package-based mode for development workflows. - -This creates standalone disk images without bootc container integration. - -Examples: - # Ostree-based image - caib build-dev manifest.aib.yml --mode image --format qcow2 -o disk.qcow2 - - # Package-based image - caib build-dev manifest.aib.yml --mode package --format raw -o disk.raw`, - Args: cobra.ExactArgs(1), - Run: runBuildDev, - } - - // Flash command - flash a disk image to hardware via Jumpstarter - flashCmd := &cobra.Command{ - Use: "flash ", - Short: "Flash a disk image to hardware via Jumpstarter", - Long: `Flash a disk image from an OCI registry to a hardware device using Jumpstarter. - -This command connects to a Jumpstarter exporter to flash the specified disk image -onto physical hardware. Requires a Jumpstarter client configuration file. - -Examples: - # Flash using target platform lookup - caib flash quay.io/org/disk:v1 --client ~/.jumpstarter/client.yaml --target j784s4evm - - # Flash with explicit exporter selector - caib flash quay.io/org/disk:v1 --client ~/.jumpstarter/client.yaml --exporter "board-type=j784s4evm"`, - Args: cobra.ExactArgs(1), - Run: runFlash, - } - - listCmd := &cobra.Command{ - Use: "list", - Short: "List existing ImageBuilds", - 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", - 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, - } - - 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", - Long: `Login saves the Build API server URL locally (~/.caib/cli.json) so you do not need -to pass --server or set CAIB_SERVER for later commands. If the server uses OIDC, -this command also performs authentication and caches the token. - -Example: - caib login https://build-api.my-cluster.example.com`, - Args: cobra.ExactArgs(1), - Run: runLogin, - } - - // build command flags (bootc - the default) - buildCmd.Flags().StringVar(&serverURL, "server", config.DefaultServer(), "REST API server base URL") - buildCmd.Flags().StringVar(&authToken, "token", os.Getenv("CAIB_TOKEN"), "Bearer token for authentication") - buildCmd.Flags().StringVarP(&buildName, "name", "n", "", "name for the ImageBuild (auto-generated if omitted)") - buildCmd.Flags().StringVarP(&distro, "distro", "d", "autosd", "distribution to build") - buildCmd.Flags().StringVarP(&target, "target", "t", "qemu", "target platform") - buildCmd.Flags().StringVarP(&architecture, "arch", "a", getDefaultArch(), "architecture (amd64, arm64)") - buildCmd.Flags().StringVar(&containerPush, "push", "", "push bootc container to registry (optional if --disk is used)") - buildCmd.Flags().BoolVar(&buildDiskImage, "disk", false, "also build disk image from container") - buildCmd.Flags().StringVarP(&outputDir, "output", "o", "", "download disk image to file from registry (implies --disk; requires --push-disk or --internal-registry)") - buildCmd.Flags().StringVar( - &diskFormat, "format", "", "disk image format (qcow2, raw, simg); inferred from output filename if not set", - ) - buildCmd.Flags().StringVar(&compressionAlgo, "compress", "gzip", "compression algorithm (gzip, lz4, xz)") - buildCmd.Flags().StringVar(&exportOCI, "push-disk", "", "push disk image as OCI artifact to registry (implies --disk)") - buildCmd.Flags().StringVar( - ®istryAuthFile, - "registry-auth-file", - "", - "path to Docker/Podman auth file for push authentication (takes precedence over env vars and auto-discovery)", - ) - buildCmd.Flags().StringVar( - &automotiveImageBuilder, "aib-image", - "quay.io/centos-sig-automotive/automotive-image-builder:latest", "AIB container image", - ) - buildCmd.Flags().StringVar(&builderImage, "builder-image", "", "custom builder container") - buildCmd.Flags().BoolVar(&rebuildBuilder, "rebuild-builder", false, "force rebuild of the bootc builder image") - buildCmd.Flags().StringVar(&storageClass, "storage-class", "", "Kubernetes storage class for build workspace") - buildCmd.Flags().StringArrayVarP(&customDefs, "define", "D", []string{}, "custom definition KEY=VALUE") - buildCmd.Flags().StringArrayVar(&aibExtraArgs, "extra-args", []string{}, "extra arguments to pass to AIB (can be repeated)") - buildCmd.Flags().IntVar(&timeout, "timeout", 60, "timeout in minutes") - buildCmd.Flags().BoolVarP(&waitForBuild, "wait", "w", true, "wait for build to complete") - buildCmd.Flags().BoolVarP(&followLogs, "follow", "f", false, "follow build logs (shows full log output instead of progress bar)") - // Note: --push is optional when --disk is used (disk image becomes the output) - // Jumpstarter flash options - buildCmd.Flags().BoolVar(&flashAfterBuild, "flash", false, "flash the image to device after build completes") - buildCmd.Flags().StringVar(&jumpstarterClient, "client", "", "path to Jumpstarter client config file (required for --flash)") - buildCmd.Flags().StringVar(&leaseDuration, "lease", "03:00:00", "device lease duration for flash (HH:MM:SS)") - // Internal registry options - buildCmd.Flags().BoolVar(&useInternalRegistry, "internal-registry", false, "push to OpenShift internal registry") - buildCmd.Flags().StringVar(&internalRegistryImageName, "image-name", "", "override image name for internal registry (default: build name)") - buildCmd.Flags().StringVar(&internalRegistryTag, "image-tag", "", "tag for internal registry image (default: build name)") - - listCmd.Flags().StringVar( - &serverURL, "server", config.DefaultServer(), "REST API server base URL (e.g. https://api.example)", - ) - listCmd.Flags().StringVar( - &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") - diskCmd.Flags().StringVar(&authToken, "token", os.Getenv("CAIB_TOKEN"), "Bearer token for authentication") - diskCmd.Flags().StringVarP(&buildName, "name", "n", "", "name for the build job (auto-generated if omitted)") - diskCmd.Flags().StringVarP(&outputDir, "output", "o", "", "download disk image to file from registry (requires --push)") - diskCmd.Flags().StringVar( - &diskFormat, "format", "", "disk image format (qcow2, raw, simg); inferred from output filename if not set", - ) - diskCmd.Flags().StringVar(&compressionAlgo, "compress", "gzip", "compression algorithm (gzip, lz4, xz)") - diskCmd.Flags().StringVar(&exportOCI, "push", "", "push disk image as OCI artifact to registry") - diskCmd.Flags().StringVar( - ®istryAuthFile, - "registry-auth-file", - "", - "path to Docker/Podman auth file for push authentication (takes precedence over env vars and auto-discovery)", - ) - diskCmd.Flags().StringVarP(&distro, "distro", "d", "autosd", "distribution") - diskCmd.Flags().StringVarP(&target, "target", "t", "qemu", "target platform") - diskCmd.Flags().StringVarP(&architecture, "arch", "a", getDefaultArch(), "architecture (amd64, arm64)") - diskCmd.Flags().StringVar( - &automotiveImageBuilder, "aib-image", - "quay.io/centos-sig-automotive/automotive-image-builder:latest", "AIB container image", - ) - diskCmd.Flags().StringVar(&storageClass, "storage-class", "", "Kubernetes storage class") - diskCmd.Flags().StringArrayVar(&aibExtraArgs, "extra-args", []string{}, "extra arguments to pass to AIB (can be repeated)") - diskCmd.Flags().IntVar(&timeout, "timeout", 60, "timeout in minutes") - diskCmd.Flags().BoolVarP(&waitForBuild, "wait", "w", false, "wait for build to complete") - diskCmd.Flags().BoolVarP(&followLogs, "follow", "f", false, "follow build logs (shows full log output instead of progress bar)") - // Jumpstarter flash options - diskCmd.Flags().BoolVar(&flashAfterBuild, "flash", false, "flash the image to device after build completes") - diskCmd.Flags().StringVar(&jumpstarterClient, "client", "", "path to Jumpstarter client config file (required for --flash)") - diskCmd.Flags().StringVar(&leaseDuration, "lease", "03:00:00", "device lease duration for flash (HH:MM:SS)") - // Internal registry options - diskCmd.Flags().BoolVar(&useInternalRegistry, "internal-registry", false, "push to OpenShift internal registry") - diskCmd.Flags().StringVar(&internalRegistryImageName, "image-name", "", "override image name for internal registry (default: build name)") - diskCmd.Flags().StringVar(&internalRegistryTag, "image-tag", "", "tag for internal registry image (default: build name)") - - // build-dev command flags (traditional ostree/package builds) - buildDevCmd.Flags().StringVar(&serverURL, "server", config.DefaultServer(), "REST API server base URL") - buildDevCmd.Flags().StringVar(&authToken, "token", os.Getenv("CAIB_TOKEN"), "Bearer token for authentication") - buildDevCmd.Flags().StringVarP(&buildName, "name", "n", "", "name for the ImageBuild") - buildDevCmd.Flags().StringVarP(&distro, "distro", "d", "autosd", "distribution to build") - buildDevCmd.Flags().StringVarP(&target, "target", "t", "qemu", "target platform") - buildDevCmd.Flags().StringVarP(&architecture, "arch", "a", getDefaultArch(), "architecture (amd64, arm64)") - buildDevCmd.Flags().StringVar(&mode, "mode", "package", "build mode: image (ostree) or package (package-based)") - buildDevCmd.Flags().StringVar(&exportFormat, "format", "", "export format: qcow2, raw, simg, etc.") - buildDevCmd.Flags().StringVarP(&outputDir, "output", "o", "", "download artifact to file from registry (requires --push)") - buildDevCmd.Flags().StringVar(&compressionAlgo, "compress", "gzip", "compression algorithm (gzip, lz4, xz)") - buildDevCmd.Flags().StringVar(&exportOCI, "push", "", "push disk image as OCI artifact to registry") - buildDevCmd.Flags().StringVar( - ®istryAuthFile, - "registry-auth-file", - "", - "path to Docker/Podman auth file for push authentication (takes precedence over env vars and auto-discovery)", - ) - buildDevCmd.Flags().StringVar( - &automotiveImageBuilder, "aib-image", - "quay.io/centos-sig-automotive/automotive-image-builder:latest", "AIB container image", - ) - buildDevCmd.Flags().StringVar(&storageClass, "storage-class", "", "Kubernetes storage class") - buildDevCmd.Flags().StringArrayVarP(&customDefs, "define", "D", []string{}, "custom definition KEY=VALUE") - buildDevCmd.Flags().StringArrayVar(&aibExtraArgs, "extra-args", []string{}, "extra arguments to pass to AIB (can be repeated)") - buildDevCmd.Flags().IntVar(&timeout, "timeout", 60, "timeout in minutes") - buildDevCmd.Flags().BoolVarP(&waitForBuild, "wait", "w", false, "wait for build to complete") - buildDevCmd.Flags().BoolVarP(&followLogs, "follow", "f", false, "follow build logs (shows full log output instead of progress bar)") - // Jumpstarter flash options - buildDevCmd.Flags().BoolVar(&flashAfterBuild, "flash", false, "flash the image to device after build completes") - 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)") - // Internal registry options - buildDevCmd.Flags().BoolVar(&useInternalRegistry, "internal-registry", false, "push to OpenShift internal registry") - 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") - 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") - flashCmd.Flags().StringVar(&jumpstarterClient, "client", "", "path to Jumpstarter client config file (required)") - flashCmd.Flags().StringVarP(&flashName, "name", "n", "", "name for the flash job (auto-generated if omitted)") - flashCmd.Flags().StringVarP(&target, "target", "t", "", "target platform for exporter lookup") - flashCmd.Flags().StringVar(&exporterSelector, "exporter", "", "direct exporter selector (alternative to --target)") - flashCmd.Flags().StringVar(&leaseDuration, "lease", "03:00:00", "device lease duration (HH:MM:SS)") - flashCmd.Flags().BoolVarP(&followLogs, "follow", "f", false, "follow flash logs (shows full log output instead of progress bar)") - flashCmd.Flags().BoolVarP(&waitForBuild, "wait", "w", true, "wait for flash to complete") - _ = flashCmd.MarkFlagRequired("client") - - // build-container command (Shipwright-based container builds) - containerCmd := container.NewContainerCmd() - - // Sealed operations - top-level commands matching AIB CLI structure - - prepareResealCmd := &cobra.Command{ - Use: "prepare-reseal [source-container] [output-container]", - Short: "Prepare a bootc container image for resealing", - Long: `Prepare a bootc container image for resealing. With --server, runs on -the cluster via the Build API; otherwise runs locally using the AIB container. - -Input and output can be given as positionals or via --input and --output (any order). - -Examples: - - # Run locally - caib prepare-reseal ./input.qcow2 ./output.qcow2 --workspace ./work`, - Args: cobra.RangeArgs(0, 2), - Run: runPrepareReseal, - } - - resealCmd := &cobra.Command{ - Use: "reseal [source-container] [output-container]", - Short: "Reseal a prepared bootc container image with a new key", - Long: `Reseal a bootc container image that was prepared with prepare-reseal. -With --server, runs on the cluster via the Build API; otherwise runs locally. - -Input and output can be given as positionals or via --input and --output (any order). -If no seal key is provided, an ephemeral key is generated for one-time use.`, - Args: cobra.RangeArgs(0, 2), - Run: runReseal, - } - - extractForSigningCmd := &cobra.Command{ - Use: "extract-for-signing [source-container] [output-artifact]", - Short: "Extract components from a container image for external signing", - Long: `Extract components that need to be signed (e.g. for secure boot) from a -container image. Sign the extracted contents externally, then use inject-signed. - -Input and output can be given as positionals or via --input and --output (any order).`, - Args: cobra.RangeArgs(0, 2), - Run: runExtractForSigning, - } - - injectSignedCmd := &cobra.Command{ - Use: "inject-signed [source-container] [signed-artifact] [output-container]", - Short: "Inject signed components back into a container image", - Long: `Inject externally signed components (from extract-for-signing) back into the -container image. Optionally reseals in the same step with --key. - -Input, signed artifact, and output can be given as positionals or via --input, --signed, --output (any order).`, - Args: cobra.RangeArgs(0, 3), - Run: runInjectSigned, - } - - // Sealed operation shared flags helper - addSealedFlags := func(cmd *cobra.Command) { - cmd.Flags().StringVar(&serverURL, "server", config.DefaultServer(), "Build API server URL") - cmd.Flags().StringVar(&authToken, "token", os.Getenv("CAIB_TOKEN"), "Bearer token for authentication") - cmd.Flags().StringVar(&sealedInputRef, "input", "", "Input/source container or artifact ref") - cmd.Flags().StringVar(&sealedOutputRef, "output", "", "Output container or artifact ref") - cmd.Flags().StringVar( - &automotiveImageBuilder, "aib-image", - "quay.io/centos-sig-automotive/automotive-image-builder:latest", "AIB container image", - ) - cmd.Flags().StringVar(&sealedBuilderImage, "builder-image", "", "Builder container image (overrides --arch default)") - cmd.Flags().StringVar(&sealedArchitecture, "arch", "", "Target architecture for default builder image (amd64, arm64); auto-detected if not set") - cmd.Flags().StringArrayVar(&aibExtraArgs, "extra-args", nil, "Extra arguments to pass to AIB (repeatable)") - cmd.Flags().BoolVarP(&waitForBuild, "wait", "w", false, "Wait for completion") - cmd.Flags().BoolVarP(&followLogs, "follow", "f", true, "Stream task logs") - cmd.Flags().StringVar(&sealedKeySecret, "key-secret", "", "Name of existing cluster secret containing sealing key (data key 'private-key')") - cmd.Flags().StringVar(&sealedKeyPasswordSecret, "key-password-secret", "", "Name of existing cluster secret containing key password (data key 'password')") - cmd.Flags().StringVar(&sealedKeyFile, "key", "", "Path to local PEM key file (uploaded to cluster automatically)") - cmd.Flags().StringVar(&sealedKeyPassword, "passwd", "", "Password for encrypted key file (used with --key)") - cmd.Flags().IntVar(&timeout, "timeout", 120, "Timeout in minutes") - } - addSealedFlags(prepareResealCmd) - addSealedFlags(resealCmd) - addSealedFlags(extractForSigningCmd) - addSealedFlags(injectSignedCmd) - injectSignedCmd.Flags().StringVar(&sealedSignedRef, "signed", "", "Signed artifact ref for inject-signed") - - // Add all commands - rootCmd.AddCommand(buildCmd, diskCmd, buildDevCmd, listCmd, showCmd, downloadCmd, flashCmd, logsCmd, loginCmd, - containerCmd, prepareResealCmd, resealCmd, extractForSigningCmd, injectSignedCmd, - catalog.NewCatalogCmd(), authcmd.NewAuthCmd()) - + rootCmd := newRootCmd() if err := rootCmd.Execute(); err != nil { fmt.Println(err) os.Exit(1) } } - -// runLogin saves the server URL and optionally performs OIDC authentication. -func runLogin(_ *cobra.Command, args []string) { - raw := strings.TrimSpace(args[0]) - if raw == "" { - handleError(fmt.Errorf("server URL is required")) - } - server := raw - if !strings.HasPrefix(server, "http://") && !strings.HasPrefix(server, "https://") { - server = "https://" + server - } - if err := config.SaveServerURL(server); err != nil { - handleError(fmt.Errorf("failed to save server URL: %w", err)) - } - fmt.Printf("Server saved: %s\n", server) - - ctx := context.Background() - token, didAuth, err := auth.GetTokenWithReauth(ctx, server, "", insecureSkipTLS) - if err != nil { - fmt.Printf("Warning: authentication failed (you may need --token or kubeconfig for API calls): %v\n", err) - return - } - if token != "" && didAuth { - fmt.Println("OIDC authentication successful. Token cached for subsequent commands.") - } else if token != "" { - fmt.Println("Using existing or kubeconfig token. You can run build/list/disk commands without --server.") - } -} - -// validateBootcBuildFlags validates flag combinations for the build command -func validateBootcBuildFlags() { - if serverURL == "" { - handleError(fmt.Errorf("--server is required (or set CAIB_SERVER, or run 'caib login ')")) - } - - if useInternalRegistry { - if exportOCI != "" { - handleError(fmt.Errorf("--internal-registry cannot be used with --push-disk")) - } - } - - if outputDir != "" && !buildDiskImage { - buildDiskImage = true - } - if exportOCI != "" && !buildDiskImage { - buildDiskImage = true - } - if flashAfterBuild && !buildDiskImage { - buildDiskImage = true - } - if !useInternalRegistry { - validateOutputRequiresPush(outputDir, exportOCI, "--push-disk") - } - - if containerPush == "" && !buildDiskImage && !useInternalRegistry { - handleError(fmt.Errorf( - "--push is required when not building a disk image " + - "(use --disk or --output to create a disk image without pushing the container)", - )) - } -} - -// applyRegistryCredentialsToRequest sets registry credentials on the build request. -// When --internal-registry is combined with --push, both are configured so the -// container is pushed externally while the disk image uses the internal registry. -func applyRegistryCredentialsToRequest(req *buildapitypes.BuildRequest) { - if useInternalRegistry { - req.UseInternalRegistry = true - req.InternalRegistryImageName = internalRegistryImageName - req.InternalRegistryTag = internalRegistryTag - if containerPush == "" { - return - } - // Hybrid: fall through to also set external registry credentials - // for the container push. - } - - effectiveRegistryURL, registryUsername, registryPassword := registryauth.ExtractRegistryCredentials(containerPush, exportOCI) - registryCreds, err := registryauth.ResolveRegistryCredentials(effectiveRegistryURL, registryUsername, registryPassword, registryAuthFile) - if err != nil { - handleError(err) - } - req.RegistryCredentials = registryCreds -} - -// 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 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) - } - 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 defaults (ConfigMap). CLI flags override defaults when explicitly set. -func applyTargetDefaults(cmd *cobra.Command, config *buildapitypes.OperatorConfigResponse, req *buildapitypes.BuildRequest) { - if config == nil || len(config.TargetDefaults) == 0 { - return - } - - defaults, exists := config.TargetDefaults[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 defaults for %q\n", defaults.Architecture, req.Target) - } - - if len(defaults.ExtraArgs) > 0 { - // Default args come first, user args appended - req.AIBExtraArgs = append(defaults.ExtraArgs, req.AIBExtraArgs...) - fmt.Printf("Prepending extra args %v from target defaults for %q\n", defaults.ExtraArgs, req.Target) - } -} - -// displayBuildResults shows push locations after build completion -func displayBuildResults(ctx context.Context, api *buildapiclient.Client, buildName string) { - labelColor := func(a ...any) string { return fmt.Sprint(a...) } - valueColor := func(a ...any) string { return fmt.Sprint(a...) } - if supportsColorOutput() { - labelColor = color.New(color.FgHiWhite, color.Bold).SprintFunc() - valueColor = color.New(color.FgHiGreen).SprintFunc() - } - - if useInternalRegistry { - st, err := api.GetBuild(ctx, buildName) - if err != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to get build results for %s: %v\n", buildName, err) - return - } - if st.ContainerImage != "" { - fmt.Printf("%s %s\n", labelColor("Container image:"), valueColor(st.ContainerImage)) - } - if st.DiskImage != "" { - fmt.Printf("%s %s\n", labelColor("Disk image:"), valueColor(st.DiskImage)) - } - if st.RegistryToken != "" { - if outputDir != "" && st.DiskImage != "" { - downloadOCIArtifactIfRequested(outputDir, st.DiskImage, "serviceaccount", st.RegistryToken, insecureSkipTLS) - } else { - credsFile, err := writeRegistryCredentialsFile(st.RegistryToken) - if err != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to write registry credentials file: %v\n", err) - fmt.Printf("\n%s\n", labelColor("Registry credentials (valid ~4 hours):")) - fmt.Printf(" %s %s\n", labelColor("Username:"), valueColor("serviceaccount")) - fmt.Printf(" %s %s\n", labelColor("Token:"), valueColor(st.RegistryToken)) - } else { - fmt.Printf("\n%s %s (valid ~4 hours)\n", - labelColor("Registry credentials written to:"), - valueColor(credsFile)) - } - } - } - } else { - if containerPush != "" { - fmt.Printf("%s %s\n", labelColor("Container image pushed to:"), valueColor(containerPush)) - } - if exportOCI != "" { - fmt.Printf("%s %s\n", labelColor("Disk image pushed to:"), valueColor(exportOCI)) - } - if outputDir != "" { - _, registryUsername, registryPassword := registryauth.ExtractRegistryCredentials(containerPush, exportOCI) - downloadOCIArtifactIfRequested(outputDir, exportOCI, registryUsername, registryPassword, insecureSkipTLS) - } - } -} - -func displayBuildLogsCommand(buildName string) { - labelColor := func(a ...any) string { return fmt.Sprint(a...) } - commandColor := func(a ...any) string { return fmt.Sprint(a...) } - if supportsColorOutput() { - labelColor = color.New(color.FgHiWhite, color.Bold).SprintFunc() - commandColor = color.New(color.FgHiYellow, color.Bold).SprintFunc() - } - - fmt.Printf("\n%s\n %s\n\n", labelColor("View build logs:"), commandColor("caib logs "+buildName)) -} - -func applyWaitFollowDefaults(cmd *cobra.Command, defaultWait, defaultFollow bool) { - if cmd == nil { - return - } - if !cmd.Flags().Changed("wait") { - waitForBuild = defaultWait - } - if !cmd.Flags().Changed("follow") { - followLogs = defaultFollow - } -} - -// runBuild handles the main 'build' command (bootc builds) -func runBuild(cmd *cobra.Command, args []string) { - applyWaitFollowDefaults(cmd, true, false) - - ctx := context.Background() - manifest = args[0] - - validateManifestSuffix(manifest) - validateBootcBuildFlags() - - if buildName == "" { - base := filepath.Base(manifest) - for _, suffix := range validManifestSuffixes { - base = strings.TrimSuffix(base, suffix) - } - buildName = fmt.Sprintf("%s-%s", sanitizeBuildName(base), time.Now().Format("20060102-150405")) - fmt.Printf("Auto-generated build name: %s\n", buildName) - } else { - validateBuildName(buildName) - } - - api, err := createBuildAPIClient(serverURL, &authToken) - if err != nil { - handleError(err) - } - - manifestBytes, err := os.ReadFile(manifest) - if err != nil { - handleError(fmt.Errorf("error reading manifest: %w", err)) - } - - req := buildapitypes.BuildRequest{ - Name: buildName, - Manifest: string(manifestBytes), - ManifestFileName: filepath.Base(manifest), - Distro: buildapitypes.Distro(distro), - Target: buildapitypes.Target(target), - Architecture: buildapitypes.Architecture(architecture), - ExportFormat: buildapitypes.ExportFormat(diskFormat), - Mode: buildapitypes.ModeBootc, - AutomotiveImageBuilder: automotiveImageBuilder, - StorageClass: storageClass, - CustomDefs: customDefs, - AIBExtraArgs: aibExtraArgs, - Compression: compressionAlgo, - ContainerPush: containerPush, - BuildDiskImage: buildDiskImage, - ExportOCI: exportOCI, - BuilderImage: builderImage, - RebuildBuilder: rebuildBuilder, - } - - 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 { - handleError(fmt.Errorf("cannot enable --flash without exporting a disk image (--push-disk)")) - } - if jumpstarterClient == "" { - handleError(fmt.Errorf("--flash requires --client to specify Jumpstarter client config file")) - } - clientConfigBytes, err := os.ReadFile(jumpstarterClient) - if err != nil { - handleError(fmt.Errorf("failed to read Jumpstarter client config: %w", err)) - } - req.FlashEnabled = true - req.FlashClientConfig = base64.StdEncoding.EncodeToString(clientConfigBytes) - req.FlashLeaseDuration = leaseDuration - } - - resp, err := api.CreateBuild(ctx, req) - if err != nil { - handleError(err) - } - fmt.Printf("Build %s accepted: %s - %s\n", resp.Name, resp.Phase, resp.Message) - displayBuildLogsCommand(resp.Name) - - // Handle local file uploads if needed - localRefs, err := findLocalFileReferences(string(manifestBytes)) - if err != nil { - handleError(fmt.Errorf("manifest file reference error: %w", err)) - } - if len(localRefs) > 0 { - handleFileUploads(ctx, api, resp.Name, localRefs) - } - - if waitForBuild || followLogs || outputDir != "" || flashAfterBuild { - waitForBuildCompletion(ctx, api, resp.Name) - } - - displayBuildResults(ctx, api, resp.Name) -} - -func runDisk(cmd *cobra.Command, args []string) { - applyWaitFollowDefaults(cmd, false, false) - - ctx := context.Background() - containerRef = args[0] - - if serverURL == "" { - handleError(fmt.Errorf("--server is required (or set CAIB_SERVER, or run 'caib login ')")) - } - - if useInternalRegistry { - if exportOCI != "" { - handleError(fmt.Errorf("--internal-registry cannot be used with --push")) - } - } else { - // Validate: need either --output or --push - if outputDir == "" && exportOCI == "" { - handleError(fmt.Errorf("either --output or --push is required")) - } - validateOutputRequiresPush(outputDir, exportOCI, "--push") - } - - // Auto-generate build name if not provided - if buildName == "" { - parts := strings.Split(containerRef, "/") - imagePart := parts[len(parts)-1] - imagePart = strings.Split(imagePart, ":")[0] // remove tag - buildName = fmt.Sprintf("disk-%s-%s", sanitizeBuildName(imagePart), time.Now().Format("20060102-150405")) - fmt.Printf("Auto-generated build name: %s\n", buildName) - } else { - validateBuildName(buildName) - } - - api, err := createBuildAPIClient(serverURL, &authToken) - if err != nil { - handleError(err) - } - - req := buildapitypes.BuildRequest{ - Name: buildName, - ContainerRef: containerRef, - Distro: buildapitypes.Distro(distro), - Target: buildapitypes.Target(target), - Architecture: buildapitypes.Architecture(architecture), - ExportFormat: buildapitypes.ExportFormat(diskFormat), - Mode: buildapitypes.ModeDisk, - AutomotiveImageBuilder: automotiveImageBuilder, - StorageClass: storageClass, - AIBExtraArgs: aibExtraArgs, - Compression: compressionAlgo, - ExportOCI: exportOCI, - } - - 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 { - handleError(fmt.Errorf("cannot enable --flash without exporting a disk image (--push)")) - } - if jumpstarterClient == "" { - handleError(fmt.Errorf("--flash requires --client to specify Jumpstarter client config file")) - } - clientConfigBytes, err := os.ReadFile(jumpstarterClient) - if err != nil { - handleError(fmt.Errorf("failed to read Jumpstarter client config: %w", err)) - } - req.FlashEnabled = true - req.FlashClientConfig = base64.StdEncoding.EncodeToString(clientConfigBytes) - req.FlashLeaseDuration = leaseDuration - } - - resp, err := api.CreateBuild(ctx, req) - if err != nil { - handleError(err) - } - fmt.Printf("Build %s accepted: %s - %s\n", resp.Name, resp.Phase, resp.Message) - displayBuildLogsCommand(resp.Name) - - if waitForBuild || followLogs || outputDir != "" || flashAfterBuild { - waitForBuildCompletion(ctx, api, resp.Name) - } - - displayBuildResults(ctx, api, resp.Name) -} - -func pullOCIArtifact(ociRef, destPath, username, password string, insecureSkipTLS bool) error { - fmt.Printf("Pulling OCI artifact %s to %s\n", ociRef, destPath) - - // Ensure output directory exists - destDir := filepath.Dir(destPath) - if destDir != "" && destDir != "." { - if err := os.MkdirAll(destDir, 0755); err != nil { - return fmt.Errorf("create output dir: %w", err) - } - } - - ctx := context.Background() - - // Set up system context with authentication - systemCtx := &types.SystemContext{} - if username != "" && password != "" { - fmt.Printf("Using provided username/password credentials\n") - systemCtx.DockerAuthConfig = &types.DockerAuthConfig{ - Username: username, - Password: password, - } - } else { - fmt.Printf("No explicit credentials provided, will use local container auth files if available\n") - } - - // Configure TLS verification - if insecureSkipTLS { - systemCtx.OCIInsecureSkipTLSVerify = insecureSkipTLS - systemCtx.DockerInsecureSkipTLSVerify = types.OptionalBoolTrue - } - - // Set up policy context (allow all) - policy := &signature.Policy{ - Default: []signature.PolicyRequirement{signature.NewPRInsecureAcceptAnything()}, - } - policyCtx, err := signature.NewPolicyContext(policy) - if err != nil { - return fmt.Errorf("create policy context: %w", err) - } - - // Source: docker registry reference - srcRef, err := docker.ParseReference("//" + ociRef) - if err != nil { - return fmt.Errorf("parse source reference: %w", err) - } - - // Create temporary directory for OCI layout - tempDir, err := os.MkdirTemp("", "oci-pull-*") - if err != nil { - return fmt.Errorf("create temp dir: %w", err) - } - defer func() { - if err := os.RemoveAll(tempDir); err != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to remove temp directory: %v\n", err) - } - }() - - // Destination: local OCI layout - destRef, err := layout.ParseReference(tempDir + ":latest") - if err != nil { - return fmt.Errorf("parse destination reference: %w", err) - } - - // Copy the image from registry to local OCI layout - fmt.Printf("Downloading OCI artifact...") - _, err = copy.Image(ctx, policyCtx, destRef, srcRef, ©.Options{ - ReportWriter: os.Stdout, - SourceCtx: systemCtx, - DestinationCtx: systemCtx, - }) - if err != nil { - return fmt.Errorf("copy image: %w", err) - } - - fmt.Printf("\nExtracting artifact to %s\n", destPath) - - // Extract the artifact blob(s) to the destination - if err := extractOCIArtifactBlob(tempDir, destPath); err != nil { - return fmt.Errorf("extract artifact: %w", err) - } - - // Check if destPath is a directory (multi-layer) or file (single-layer) - info, err := os.Stat(destPath) - if err != nil { - return fmt.Errorf("stat destination: %w", err) - } - - if info.IsDir() { - // Multi-layer: files already extracted with correct names - fmt.Printf("Downloaded multi-layer artifact to %s/\n", destPath) - } else { - // Single-layer: check if file is compressed and add appropriate extension if needed - finalPath := destPath - compression := detectFileCompression(destPath) - if compression != "" && !hasCompressionExtension(destPath) { - ext := compressionExtension(compression) - if ext != "" { - newPath := destPath + ext - fmt.Printf("Adding compression extension: %s -> %s\n", filepath.Base(destPath), filepath.Base(newPath)) - if err := os.Rename(destPath, newPath); err != nil { - return fmt.Errorf("rename file with compression extension: %w", err) - } - finalPath = newPath - } - } - fmt.Printf("Downloaded to %s\n", finalPath) - } - - return nil -} - -func extractOCIArtifactBlob(ociLayoutPath, destPath string) error { - // Read the index.json to find the manifest - indexPath := filepath.Join(ociLayoutPath, "index.json") - indexData, err := os.ReadFile(indexPath) - if err != nil { - return fmt.Errorf("read index.json: %w", err) - } - - var index struct { - Manifests []struct { - Digest string `json:"digest"` - } `json:"manifests"` - } - if err := json.Unmarshal(indexData, &index); err != nil { - return fmt.Errorf("parse index.json: %w", err) - } - - if len(index.Manifests) == 0 { - return fmt.Errorf("no manifests found in index") - } - - // Get the manifest digest and read the manifest - manifestDigest := strings.TrimPrefix(index.Manifests[0].Digest, "sha256:") - manifestPath := filepath.Join(ociLayoutPath, "blobs", "sha256", manifestDigest) - manifestData, err := os.ReadFile(manifestPath) - if err != nil { - return fmt.Errorf("read manifest: %w", err) - } - - var manifest struct { - Annotations map[string]string `json:"annotations"` - Layers []struct { - Digest string `json:"digest"` - Annotations map[string]string `json:"annotations"` - } `json:"layers"` - } - if err := json.Unmarshal(manifestData, &manifest); err != nil { - return fmt.Errorf("parse manifest: %w", err) - } - - if len(manifest.Layers) == 0 { - return fmt.Errorf("no layers found in manifest") - } - - // Check if this is a multi-layer artifact - isMultiLayer := manifest.Annotations["automotive.sdv.cloud.redhat.com/multi-layer"] == "true" - - if isMultiLayer { - // Multi-layer: extract all layers to destPath directory - fmt.Printf("Multi-layer artifact detected (%d layers)\n", len(manifest.Layers)) - - // Create destination directory - if err := os.MkdirAll(destPath, 0755); err != nil { - return fmt.Errorf("create destination directory: %w", err) - } - - // Track sanitized filenames to prevent silent overwrites - seenFilenames := make(map[string]struct { - layerIndex int - digest string - title string - }) - - for i, layer := range manifest.Layers { - layerDigest := strings.TrimPrefix(layer.Digest, "sha256:") - layerPath := filepath.Join(ociLayoutPath, "blobs", "sha256", layerDigest) - - // Get filename from annotation, fallback to layer index - originalTitle := layer.Annotations["org.opencontainers.image.title"] - - // Sanitize filename to prevent path traversal attacks - filename := sanitizeFilename(originalTitle, i) - - // Check for duplicate sanitized filenames - if prev, exists := seenFilenames[filename]; exists { - return fmt.Errorf("duplicate sanitized filename '%s' for layer %d (digest: %s, title: %s) conflicts with layer %d (digest: %s, title: %s)", - filename, i, layer.Digest, originalTitle, prev.layerIndex, prev.digest, prev.title) - } - - // Record this filename as seen - seenFilenames[filename] = struct { - layerIndex int - digest string - title string - }{ - layerIndex: i, - digest: layer.Digest, - title: originalTitle, - } - - destFile := filepath.Join(destPath, filename) - fmt.Printf(" Extracting layer %d: %s\n", i+1, filename) - - if err := copyFile(layerPath, destFile); err != nil { - return fmt.Errorf("extract layer %s: %w", filename, err) - } - } - - fmt.Printf("Extracted %d files to %s\n", len(manifest.Layers), destPath) - return nil - } - - // Single-layer: extract to destPath file (original behavior) - layerDigest := strings.TrimPrefix(manifest.Layers[0].Digest, "sha256:") - layerPath := filepath.Join(ociLayoutPath, "blobs", "sha256", layerDigest) - - return copyFile(layerPath, destPath) -} - -// sanitizeBuildName converts a string into a valid RFC 1123 subdomain name -// suitable for use as a Kubernetes resource name. It lowercases the input, -// replaces invalid characters (underscores, dots, etc.) with hyphens, -// collapses consecutive hyphens, and trims leading/trailing hyphens. -func sanitizeBuildName(name string) string { - name = strings.ToLower(name) - var b strings.Builder - for _, r := range name { - if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' { - b.WriteRune(r) - } else { - b.WriteRune('-') - } - } - // Collapse consecutive hyphens - result := multiHyphenRe.ReplaceAllString(b.String(), "-") - return strings.Trim(result, "-") -} - -// validateBuildName checks a user-provided build name and exits if it -// contains only invalid characters after sanitization. -var validManifestSuffixes = []string{".aib.yml", ".mpp.yml"} - -func validateManifestSuffix(filename string) { - for _, suffix := range validManifestSuffixes { - if strings.HasSuffix(filename, suffix) { - return - } - } - handleError(fmt.Errorf("manifest file %q must have one of the following extensions: %s", - filepath.Base(filename), strings.Join(validManifestSuffixes, ", "))) -} - -func validateBuildName(name string) { - if sanitizeBuildName(name) == "" { - fmt.Printf("Error: build name '%s' contains only invalid characters\n", name) - fmt.Println("Build names must contain at least one letter or number") - os.Exit(1) - } -} - -// sanitizeFilename validates and sanitizes a filename from OCI layer annotations. -// Returns a safe filename, falling back to "layer-N.bin" if the input is invalid. -// This prevents path traversal attacks by rejecting: -// - Empty filenames -// - Absolute paths -// - Paths containing ".." components -// - Paths containing null bytes -// - Filenames that differ from their base name (contain path separators) -func sanitizeFilename(filename string, layerIndex int) string { - fallback := fmt.Sprintf("layer-%d.bin", layerIndex) - - // Reject empty filenames - if filename == "" { - return fallback - } - - // Reject filenames containing null bytes - if strings.ContainsRune(filename, 0) { - fmt.Fprintf(os.Stderr, "Warning: layer %d filename contains null bytes, using fallback\n", layerIndex) - return fallback - } - - // Reject absolute paths - if filepath.IsAbs(filename) { - fmt.Fprintf(os.Stderr, "Warning: layer %d filename is absolute path, using fallback\n", layerIndex) - return fallback - } - - // Reject paths containing ".." - if strings.Contains(filename, "..") { - fmt.Fprintf(os.Stderr, "Warning: layer %d filename contains '..', using fallback\n", layerIndex) - return fallback - } - - // Extract base name and reject if it differs (contains path separators) - base := filepath.Base(filename) - if base != filename { - fmt.Fprintf(os.Stderr, "Warning: layer %d filename contains path separators, using basename: %s\n", layerIndex, base) - filename = base - } - - // Final safety check: base should not be empty, ".", or ".." - if filename == "" || filename == "." || filename == ".." { - return fallback - } - - return filename -} - -// copyFile copies a file from src to dst -func copyFile(srcPath, dstPath string) error { - src, err := os.Open(srcPath) - if err != nil { - return fmt.Errorf("open source: %w", err) - } - defer func() { - if err := src.Close(); err != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to close source file: %v\n", err) - } - }() - - dst, err := os.Create(dstPath) - if err != nil { - return fmt.Errorf("create destination: %w", err) - } - defer func() { - if err := dst.Close(); err != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to close destination file: %v\n", err) - } - }() - - if _, err := io.Copy(dst, src); err != nil { - return fmt.Errorf("copy data: %w", err) - } - - return nil -} - -// runBuildDev handles the 'build-dev' command (traditional ostree/package builds) -func runBuildDev(cmd *cobra.Command, args []string) { - applyWaitFollowDefaults(cmd, false, false) - - ctx := context.Background() - manifest = args[0] - - validateManifestSuffix(manifest) - - if serverURL == "" { - handleError(fmt.Errorf("--server is required (or set CAIB_SERVER, or run 'caib login ')")) - } - - if useInternalRegistry { - if exportOCI != "" { - handleError(fmt.Errorf("--internal-registry cannot be used with --push")) - } - } else { - validateOutputRequiresPush(outputDir, exportOCI, "--push") - } - - // Auto-generate build name if not provided - if buildName == "" { - base := filepath.Base(manifest) - for _, suffix := range validManifestSuffixes { - base = strings.TrimSuffix(base, suffix) - } - buildName = fmt.Sprintf("%s-%s", sanitizeBuildName(base), time.Now().Format("20060102-150405")) - fmt.Printf("Auto-generated build name: %s\n", buildName) - } else { - validateBuildName(buildName) - } - - api, err := createBuildAPIClient(serverURL, &authToken) - if err != nil { - handleError(err) - } - - manifestBytes, err := os.ReadFile(manifest) - if err != nil { - handleError(fmt.Errorf("error reading manifest: %w", err)) - } - - // Validate mode - var parsedMode buildapitypes.Mode - switch mode { - case "image": - parsedMode = buildapitypes.ModeImage - case "package": - parsedMode = buildapitypes.ModePackage - default: - handleError(fmt.Errorf("invalid --mode %q (expected: %q or %q)", mode, buildapitypes.ModeImage, buildapitypes.ModePackage)) - } - - req := buildapitypes.BuildRequest{ - Name: buildName, - Manifest: string(manifestBytes), - ManifestFileName: filepath.Base(manifest), - Distro: buildapitypes.Distro(distro), - Target: buildapitypes.Target(target), - Architecture: buildapitypes.Architecture(architecture), - ExportFormat: buildapitypes.ExportFormat(exportFormat), - Mode: parsedMode, - AutomotiveImageBuilder: automotiveImageBuilder, - StorageClass: storageClass, - CustomDefs: customDefs, - AIBExtraArgs: aibExtraArgs, - Compression: compressionAlgo, - ExportOCI: exportOCI, - } - - 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 { - handleError(fmt.Errorf("cannot enable --flash without exporting a disk image (--push)")) - } - if jumpstarterClient == "" { - handleError(fmt.Errorf("--flash requires --client to specify Jumpstarter client config file")) - } - - clientConfigBytes, err := os.ReadFile(jumpstarterClient) - if err != nil { - handleError(fmt.Errorf("failed to read Jumpstarter client config: %w", err)) - } - req.FlashEnabled = true - req.FlashClientConfig = base64.StdEncoding.EncodeToString(clientConfigBytes) - req.FlashLeaseDuration = leaseDuration - } - - resp, err := api.CreateBuild(ctx, req) - if err != nil { - handleError(err) - } - fmt.Printf("Build %s accepted: %s - %s\n", resp.Name, resp.Phase, resp.Message) - displayBuildLogsCommand(resp.Name) - - // Handle local file uploads if needed - localRefs, err := findLocalFileReferences(string(manifestBytes)) - if err != nil { - handleError(fmt.Errorf("manifest file reference error: %w", err)) - } - if len(localRefs) > 0 { - handleFileUploads(ctx, api, resp.Name, localRefs) - } - - if waitForBuild || followLogs || outputDir != "" || flashAfterBuild { - waitForBuildCompletion(ctx, api, resp.Name) - } - - displayBuildResults(ctx, api, resp.Name) -} - -func handleFileUploads( - ctx context.Context, - api *buildapiclient.Client, - buildName string, - localRefs []map[string]string, -) { - for _, ref := range localRefs { - if _, err := os.Stat(ref["source_path"]); err != nil { - handleError(fmt.Errorf("referenced file %s does not exist: %w", ref["source_path"], err)) - } - } - - fmt.Println("Waiting for upload server to be ready...") - readyCtx, cancel := context.WithTimeout(ctx, 10*time.Minute) - defer cancel() - for { - if err := readyCtx.Err(); err != nil { - handleError(fmt.Errorf("timed out waiting for upload server to be ready")) - } - reqCtx, c := context.WithTimeout(ctx, 15*time.Second) - st, err := api.GetBuild(reqCtx, buildName) - c() - if err == nil { - if st.Phase == "Uploading" { - break - } - if st.Phase == phaseFailed { - handleError(fmt.Errorf("build failed while waiting for upload server: %s", st.Message)) - } - } - time.Sleep(3 * time.Second) - } - - uploads := make([]buildapiclient.Upload, 0, len(localRefs)) - for _, ref := range localRefs { - uploads = append(uploads, buildapiclient.Upload{SourcePath: ref["source_path"], DestPath: ref["source_path"]}) - } - - uploadDeadline := time.Now().Add(10 * time.Minute) - for { - if err := api.UploadFiles(ctx, buildName, uploads); err != nil { - lower := strings.ToLower(err.Error()) - if time.Now().After(uploadDeadline) { - handleError(fmt.Errorf("upload files failed: %w", err)) - } - isServiceUnavailable := strings.Contains(lower, "503") || - strings.Contains(lower, "service unavailable") || - strings.Contains(lower, "upload pod not ready") - if isServiceUnavailable { - fmt.Println("Upload server not ready yet. Retrying...") - time.Sleep(5 * time.Second) - continue - } - handleError(fmt.Errorf("upload files failed: %w", err)) - } - break - } - fmt.Println("Local files uploaded. Build will proceed.") -} - -//nolint:gocyclo // Complex state machine for build progress tracking with log streaming -func waitForBuildCompletion(ctx context.Context, api *buildapiclient.Client, name string) { - fmt.Println("Waiting for build to complete...") - timeoutCtx, cancel := context.WithTimeout(ctx, time.Duration(timeout)*time.Minute) - defer cancel() - ticker := time.NewTicker(5 * time.Second) - defer ticker.Stop() - - userFollowRequested := followLogs - var lastPhase, lastMessage string - pendingWarningShown := false - retryLimitWarningShown := false - - logTransport := &http.Transport{ - ResponseHeaderTimeout: 30 * time.Second, - IdleConnTimeout: 2 * time.Minute, - } - if insecureSkipTLS { - logTransport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} - } - // No hard Timeout on the client: log streams can run for the entire - // build duration (often >10 min). The build's context timeout - // (timeoutCtx) already governs cancellation via the request context. - logClient := &http.Client{ - Transport: logTransport, - } - streamState := &logStreamState{} - pb := ui.NewProgressBar() - - for { - select { - case <-timeoutCtx.Done(): - pb.Clear() - handleError(fmt.Errorf("timed out waiting for build")) - case <-ticker.C: - reqCtx, cancelReq := context.WithTimeout(ctx, 2*time.Minute) - st, err := api.GetBuild(reqCtx, name) - cancelReq() - if err != nil { - fmt.Printf("status check failed: %v\n", err) - continue - } - - // Progress bar mode: when not following logs, poll progress endpoint - if !followLogs && !streamState.active { - progressCtx, progressCancel := context.WithTimeout(ctx, 10*time.Second) - progress, _ := api.GetBuildProgress(progressCtx, name) - progressCancel() - // Use phase from progress response (fresher than GetBuild) - displayPhase := st.Phase - var step *buildapitypes.BuildStep - if progress != nil { - step = progress.Step - if progress.Phase != "" { - displayPhase = progress.Phase - } - } - pb.Render(displayPhase, step) - } else if !streamState.active && (!userFollowRequested || !streamState.canRetry()) { - // Fallback: text status when streaming is not active - if st.Phase != lastPhase || st.Message != lastMessage { - fmt.Printf("status: %s - %s\n", st.Phase, st.Message) - lastPhase = st.Phase - lastMessage = st.Message - } - } - - // Handle terminal build states - if st.Phase == phaseCompleted { - pb.Clear() - flashWasExecuted := strings.Contains(st.Message, "flash") - if flashWasExecuted { - bannerColor := func(a ...any) string { return fmt.Sprint(a...) } - infoColor := func(a ...any) string { return fmt.Sprint(a...) } - commandColor := func(a ...any) string { return fmt.Sprint(a...) } - if supportsColorOutput() { - bannerColor = color.New(color.FgHiGreen, color.Bold).SprintFunc() - infoColor = color.New(color.FgHiWhite).SprintFunc() - commandColor = color.New(color.FgHiYellow, color.Bold).SprintFunc() - } - - divider := strings.Repeat("=", 50) - fmt.Println("\n" + bannerColor(divider)) - fmt.Println(bannerColor("Build and flash completed successfully!")) - fmt.Println(bannerColor(divider)) - fmt.Println("\n" + infoColor("The device has been flashed and a lease has been acquired.")) - // Get lease ID from API response (preferred) or fall back to log parsing - leaseID := "" - if st.Jumpstarter != nil && st.Jumpstarter.LeaseID != "" { - leaseID = st.Jumpstarter.LeaseID - } else if streamState.leaseID != "" { - leaseID = streamState.leaseID - } - if leaseID != "" { - fmt.Printf("\n%s %s\n", infoColor("Lease ID:"), commandColor(leaseID)) - fmt.Printf("\n%s\n", infoColor("To access the device:")) - fmt.Printf(" %s\n", commandColor(fmt.Sprintf("jmp shell --lease %s", leaseID))) - fmt.Printf("\n%s\n", infoColor("To release the lease when done:")) - fmt.Printf(" %s\n", commandColor(fmt.Sprintf("jmp delete leases %s", leaseID))) - } else { - fmt.Println(infoColor("Check the logs above for lease details, or use:")) - fmt.Printf(" %s\n", commandColor("jmp list leases")) - fmt.Printf("\n%s\n", infoColor("To access the device:")) - fmt.Printf(" %s\n", commandColor("jmp shell --lease ")) - fmt.Printf("\n%s\n", infoColor("To release the lease when done:")) - fmt.Printf(" %s\n", commandColor("jmp delete leases ")) - } - } else { - fmt.Println("Build completed successfully!") - if flashAfterBuild { - fmt.Println("\nWarning: --flash was requested but flash was not executed.") - fmt.Println("This may be because no Jumpstarter target mapping exists for this target.") - fmt.Println("Check OperatorConfig for JumpstarterTargetMappings configuration.") - } - // Show flash instructions with colors - displayFlashInstructions(st, false) - } - return - } - if st.Phase == phaseFailed { - pb.Clear() - // Provide phase-specific error messages - errPrefix := errPrefixBuild - isFlashFailure := false - - if strings.Contains(strings.ToLower(st.Message), errPrefixFlash) { - errPrefix = errPrefixFlash - isFlashFailure = true - } else if strings.Contains(strings.ToLower(st.Message), errPrefixPush) { - errPrefix = errPrefixPush - } else if lastPhase == phaseFlashing { - errPrefix = errPrefixFlash - isFlashFailure = true - } else if lastPhase == "Pushing" { - errPrefix = errPrefixPush - } else if flashAfterBuild && (lastPhase == phaseFlashing || strings.Contains(strings.ToLower(st.Message), errPrefixFlash)) { - // Only treat as flash failure if we actually reached a flash-related phase - // or the error message explicitly indicates a flash error - errPrefix = errPrefixFlash - isFlashFailure = true - } - - err := fmt.Errorf("%s failed: %s", errPrefix, st.Message) - if isFlashFailure { - handleFlashError(err, st) - } else { - handleError(err) - } - } - - // Attempt log streaming for active builds - if !followLogs || streamState.active { - continue - } - - // If the stream ended cleanly but the build is still active - // (e.g. stream covered build tasks but flash pod hadn't appeared yet), - // allow reconnection so we pick up remaining task logs. - if streamState.completed && isBuildActive(st.Phase) { - streamState.completed = false - streamState.retryCount = 0 - } - - if !streamState.canRetry() { - continue - } - - if st.Phase == phasePending { - streamState.reset() - if userFollowRequested && !pendingWarningShown { - fmt.Println("Waiting for build to start before streaming logs...") - pendingWarningShown = true - } - continue - } - - if isBuildActive(st.Phase) { - if streamState.retryCount == 0 { - fmt.Println("Build is active. Attempting to stream logs...") - pendingWarningShown = false - } - - if err := tryLogStreaming(ctx, logClient, name, streamState); err != nil { - streamState.retryCount++ - if !streamState.canRetry() && !retryLimitWarningShown { - msg := "Log streaming failed after %d attempts (~2 minutes). " + - "Falling back to status updates only.\n" - fmt.Printf(msg, maxLogRetries) - retryLimitWarningShown = true - } - } else { - followLogs = userFollowRequested - } - } - } - } -} - -// logStreamState encapsulates state for log streaming with automatic reconnection -type logStreamState struct { - active bool - retryCount int - warningShown bool - startTime time.Time - completed bool // Set when stream ends normally, prevents reconnection - leaseID string // Captured lease ID from flash logs -} - -const maxLogRetries = 24 // ~2 minutes at 5s intervals - -func (s *logStreamState) canRetry() bool { - return s.retryCount <= maxLogRetries && !s.completed -} - -func (s *logStreamState) reset() { - s.retryCount = 0 - s.warningShown = false -} - -func isBuildActive(phase string) bool { - return phase == "Building" || phase == phaseRunning || phase == "Uploading" || phase == phaseFlashing -} - -// tryLogStreaming attempts to stream logs and returns error if it fails -func tryLogStreaming(ctx context.Context, logClient *http.Client, name string, state *logStreamState) error { - logURL := buildLogURL(name, state.startTime) - - req, _ := http.NewRequestWithContext(ctx, http.MethodGet, logURL, nil) - if authToken := strings.TrimSpace(authToken); authToken != "" { - req.Header.Set("Authorization", "Bearer "+authToken) - } - - resp, err := logClient.Do(req) - if err != nil { - return fmt.Errorf("log request failed: %w", err) - } - defer func() { - if err := resp.Body.Close(); err != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to close response body: %v\n", err) - } - }() - - if resp.StatusCode == http.StatusOK { - return streamLogsToStdout(resp.Body, state) - } - - return handleLogStreamError(resp, state) -} - -func buildLogURL(buildName string, startTime time.Time) string { - logURL := strings.TrimRight(serverURL, "/") + "/v1/builds/" + url.PathEscape(buildName) + "/logs?follow=1" - if !startTime.IsZero() { - logURL += "&since=" + url.QueryEscape(startTime.Format(time.RFC3339)) - } - return logURL -} - -func streamLogsToStdout(body io.Reader, state *logStreamState) error { - firstStream := state.startTime.IsZero() - if firstStream { - state.startTime = time.Now() - } - - if firstStream { - fmt.Println("Streaming logs...") - } - state.active = true - state.reset() - - // Use line-by-line streaming for real-time output - scanner := bufio.NewScanner(body) - scanner.Buffer(make([]byte, 64*1024), 1024*1024) // Handle long lines - 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: " - // Extract only the first token after the marker to avoid trailing flags/text - if strings.Contains(line, "jmp shell --lease ") { - parts := strings.Split(line, "jmp shell --lease ") - if len(parts) > 1 { - tokens := strings.Fields(parts[1]) - if len(tokens) > 0 { - state.leaseID = tokens[0] - } - } - } else if strings.Contains(line, "Lease acquired: ") { - parts := strings.Split(line, "Lease acquired: ") - if len(parts) > 1 { - tokens := strings.Fields(parts[1]) - if len(tokens) > 0 { - state.leaseID = tokens[0] - } - } - } - } - state.active = false - - if err := scanner.Err(); err != nil { - return fmt.Errorf("log stream interrupted: %w", err) - } - - // Stream ended normally (server closed connection after sending all logs) - // Mark as completed to prevent reconnection attempts - state.completed = true - return nil -} - -func handleLogStreamError(resp *http.Response, state *logStreamState) error { - body, _ := io.ReadAll(resp.Body) - msg := strings.TrimSpace(string(body)) - - if resp.StatusCode == http.StatusServiceUnavailable || resp.StatusCode == http.StatusGatewayTimeout { - if !state.warningShown { - fmt.Printf("log stream not ready (HTTP %d). Retrying... (attempt %d/%d)\n", - resp.StatusCode, state.retryCount+1, maxLogRetries) - state.warningShown = true - } - return fmt.Errorf("log endpoint not ready (HTTP %d)", resp.StatusCode) - } - - if msg != "" { - fmt.Printf("log stream error (%d): %s\n", resp.StatusCode, msg) - } else { - fmt.Printf("log stream error: HTTP %d\n", resp.StatusCode) - } - return fmt.Errorf("log stream failed with HTTP %d", resp.StatusCode) -} - -func handleError(err error) { - fmt.Printf("Error: %v\n", err) - os.Exit(1) -} - -func replaceFlashImagePlaceholders(cmd, imageURI string) string { - cmd = strings.ReplaceAll(cmd, "{image_uri}", imageURI) - cmd = strings.ReplaceAll(cmd, "{artifact_url}", imageURI) - cmd = strings.ReplaceAll(cmd, "${IMAGE}", imageURI) - cmd = strings.ReplaceAll(cmd, "${IMAGE_REF}", imageURI) - return cmd -} - -func hasUnresolvedFlashImagePlaceholder(cmd string) bool { - placeholders := []string{ - "{image_uri}", - "{artifact_url}", - "${IMAGE}", - "${IMAGE_REF}", - } - for _, placeholder := range placeholders { - if strings.Contains(cmd, placeholder) { - return true - } - } - return false -} - -// displayFlashInstructions shows colorful flashing instructions when flash is not executed or fails -func displayFlashInstructions(st *buildapitypes.BuildResponse, isFailure bool) { - if st.Jumpstarter == nil || !st.Jumpstarter.Available { - return - } - - // Only show instructions if this target actually has a mapping - // (i.e., there's a selector or flash command configured for it) - if st.Jumpstarter.ExporterSelector == "" && st.Jumpstarter.FlashCmd == "" { - return - } - - // Don't show jumpstarter instructions if user requested a download - they have the artifact locally - if outputDir != "" { - return - } - - colorsSupported := supportsColorOutput() - - var headerColor, commandColor, infoColor func(...any) string - var headerPrefix, commandPrefix string - - if isFailure { - if colorsSupported { - headerColor = color.New(color.FgHiRed, color.Bold).SprintFunc() - commandColor = color.New(color.FgHiYellow, color.Bold).SprintFunc() - infoColor = color.New(color.FgHiWhite).SprintFunc() - } else { - headerColor = func(a ...any) string { return fmt.Sprint(a...) } - commandColor = func(a ...any) string { return fmt.Sprint(a...) } - infoColor = func(a ...any) string { return fmt.Sprint(a...) } - headerPrefix = "[!] " - commandPrefix = ">> " - } - } else { - if colorsSupported { - // Success mode: use high-contrast, readable colors - headerColor = color.New(color.FgHiWhite, color.Bold).SprintFunc() - commandColor = color.New(color.FgHiGreen, color.Bold).SprintFunc() - infoColor = color.New(color.FgHiYellow).SprintFunc() - } else { - // Fallback with symbols for no-color terminals - headerColor = func(a ...any) string { return fmt.Sprint(a...) } - commandColor = func(a ...any) string { return fmt.Sprint(a...) } - infoColor = func(a ...any) string { return fmt.Sprint(a...) } - headerPrefix = "[*] " - commandPrefix = ">> " - } - } - - if isFailure { - fmt.Printf("\n%s%s\n", headerPrefix, headerColor("Manual Flash Required")) - fmt.Printf("%s\n", infoColor("Flash failed, but you can flash manually using Jumpstarter:")) - } else { - fmt.Printf("%s\n", infoColor("Jumpstarter is available for flashing:")) - } - - if st.Jumpstarter.ExporterSelector != "" { - fmt.Printf(" %s %s\n", infoColor("Exporter selector:"), st.Jumpstarter.ExporterSelector) - } - - if st.Jumpstarter.FlashCmd != "" { - flashCmd := st.Jumpstarter.FlashCmd - imageURI := st.DiskImage - if imageURI == "" { - imageURI = st.ContainerImage - } - if imageURI != "" { - flashCmd = replaceFlashImagePlaceholders(flashCmd, imageURI) - } - - if hasUnresolvedFlashImagePlaceholder(flashCmd) { - fmt.Printf(" %s\n", infoColor("Flash command template:")) - fmt.Printf(" %s%s\n", commandPrefix, commandColor(replaceFlashImagePlaceholders(flashCmd, ""))) - fmt.Printf(" %s\n", infoColor("No pushed disk image URI is available for this build.")) - fmt.Printf(" %s\n", infoColor("Use --push-disk or --internal-registry to produce a flashable URI.")) - return - } - - fmt.Printf(" %s\n", infoColor("Flash command:")) - fmt.Printf(" %s%s\n", commandPrefix, commandColor(flashCmd)) - } -} - -func handleFlashError(err error, st *buildapitypes.BuildResponse) { - fmt.Printf("Error: %v\n", err) - - // Show flash instructions to help user flash manually after failure - if flashAfterBuild && st != nil { - displayFlashInstructions(st, true) - } - - os.Exit(1) -} - -func findLocalFileReferences(manifestContent string) ([]map[string]string, error) { - var manifestData map[string]any - var localFiles []map[string]string - - if err := yaml.Unmarshal([]byte(manifestContent), &manifestData); err != nil { - return nil, fmt.Errorf("failed to parse manifest YAML: %w", err) - } - - isPathSafe := func(path string) error { - if path == "" || path == "/" { - return fmt.Errorf("empty or root path is not allowed") - } - - if strings.Contains(path, "..") { - return fmt.Errorf("directory traversal detected in path: %s", path) - } - - if filepath.IsAbs(path) { - // TODO add safe dirs flag - safeDirectories := []string{} - isInSafeDir := false - for _, dir := range safeDirectories { - if strings.HasPrefix(path, dir+"/") { - isInSafeDir = true - break - } - } - if !isInSafeDir { - return fmt.Errorf("absolute path outside safe directories: %s", path) - } - } - - return nil - } - - processAddFiles := func(addFiles []any) error { - for _, file := range addFiles { - if fileMap, ok := file.(map[string]any); ok { - path, hasPath := fileMap["path"].(string) - sourcePath, hasSourcePath := fileMap["source_path"].(string) - if hasPath && hasSourcePath { - if err := isPathSafe(sourcePath); err != nil { - return err - } - localFiles = append(localFiles, map[string]string{ - "path": path, - "source_path": sourcePath, - }) - } - } - } - return nil - } - - if content, ok := manifestData["content"].(map[string]any); ok { - if addFiles, ok := content["add_files"].([]any); ok { - if err := processAddFiles(addFiles); err != nil { - return nil, err - } - } - } - - if qm, ok := manifestData["qm"].(map[string]any); ok { - if qmContent, ok := qm["content"].(map[string]any); ok { - if addFiles, ok := qmContent["add_files"].([]any); ok { - if err := processAddFiles(addFiles); err != nil { - return nil, err - } - } - } - } - - return localFiles, nil -} - -// compressionExtension returns the file extension for a compression algorithm -func compressionExtension(algo string) string { - switch algo { - case "tar.gz": - return ".tar.gz" - case "gzip": - return ".gz" - case "lz4": - return ".lz4" - case "xz": - return ".xz" - default: - return "" - } -} - -// hasCompressionExtension checks if a filename already has a compression extension -func hasCompressionExtension(filename string) bool { - lower := strings.ToLower(filename) - return strings.HasSuffix(lower, ".tar.gz") || - strings.HasSuffix(lower, ".gz") || - strings.HasSuffix(lower, ".lz4") || - strings.HasSuffix(lower, ".xz") -} - -// detectFileCompression examines file magic bytes to determine compression type -func detectFileCompression(filePath string) string { - file, err := os.Open(filePath) - if err != nil { - return "" - } - defer func() { - if err := file.Close(); err != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to close file: %v\n", err) - } - }() - - // Read first few bytes to check magic numbers - header := make([]byte, 10) - n, err := file.Read(header) - if err != nil || n < 3 { - return "" - } - - // Check for gzip magic number - if n >= 2 && header[0] == 0x1f && header[1] == 0x8b { - // Check if it's a gzipped tar by decompressing and looking for tar magic - if isTarInsideGzip(filePath) { - return "tar.gz" - } - return "gzip" - } - - // Check for lz4 magic number - if n >= 4 && header[0] == 0x04 && header[1] == 0x22 && header[2] == 0x4d && header[3] == 0x18 { - return "lz4" - } - - // Check for xz magic number - if n >= 6 && header[0] == 0xfd && header[1] == 0x37 && header[2] == 0x7a && - header[3] == 0x58 && header[4] == 0x5a && header[5] == 0x00 { - return "xz" - } - - return "" -} - -// isTarInsideGzip checks if a gzip file contains a tar archive -func isTarInsideGzip(filePath string) bool { - file, err := os.Open(filePath) - if err != nil { - return false - } - defer func() { _ = file.Close() }() - - gzReader, err := gzip.NewReader(file) - if err != nil { - return false - } - defer func() { _ = gzReader.Close() }() - - // Read enough bytes to check for tar magic at offset 257 ("ustar") - header := make([]byte, 512) - n, err := io.ReadFull(gzReader, header) - if err != nil && n < 262 { - return false - } - - // Tar magic "ustar" is at offset 257 - return n >= 262 && string(header[257:262]) == "ustar" -} - -func runList(_ *cobra.Command, _ []string) { - ctx := context.Background() - if strings.TrimSpace(serverURL) == "" { - fmt.Println("Error: --server is required (or set CAIB_SERVER, or run 'caib login ')") - os.Exit(1) - } - - var items []buildapitypes.BuildListItem - err := executeWithReauth(serverURL, &authToken, func(api *buildapiclient.Client) error { - var err error - items, err = api.ListBuilds(ctx) - return err - }) - if err != nil { - fmt.Printf("Error listing ImageBuilds: %v\n", err) - os.Exit(1) - } - if len(items) == 0 { - fmt.Println("No ImageBuilds found") - return - } - - 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) - } - }() - - if _, err := fmt.Fprintln(w, "NAME\tSTATUS\tAGE\tREQUESTED BY\tARTIFACT"); err != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to write header: %v\n", err) - return - } - for _, it := range items { - artifact := it.DiskImage - if artifact == "" { - artifact = it.ContainerImage - } - if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\t%s\n", it.Name, it.Phase, formatAge(it.CreatedAt), it.RequestedBy, artifact); err != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to write row: %v\n", err) - } - } -} - -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 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 { - // 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 - } - - followLogs = true - waitForBuildCompletion(ctx, api, name) - displayBuildResults(ctx, api, name) -} - -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 { - return rfcTime - } - d := time.Since(t) - switch { - case d < time.Minute: - return fmt.Sprintf("%ds", int(d.Seconds())) - case d < time.Hour: - return fmt.Sprintf("%dm", int(d.Minutes())) - case d < 24*time.Hour: - return fmt.Sprintf("%dh", int(d.Hours())) - default: - return fmt.Sprintf("%dd", int(d.Hours()/24)) - } -} - -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)) - } - - // Use API-minted token if available (internal registry builds), - // otherwise fall back to environment credentials. - registryUsername := "" - registryPassword := "" - if st.RegistryToken != "" { - registryUsername = "serviceaccount" - registryPassword = st.RegistryToken - } else { - var effectiveRegistryURL string - effectiveRegistryURL, registryUsername, registryPassword = registryauth.ExtractRegistryCredentials(ociRef, "") - if err := registryauth.ValidateRegistryCredentials(effectiveRegistryURL, registryUsername, registryPassword); err != nil { - handleError(err) - } - } - - fmt.Printf("Downloading disk image from %s\n", ociRef) - if err := pullOCIArtifact(ociRef, outputDir, registryUsername, registryPassword, insecureSkipTLS); 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 - // (e.g., OpenShift login) and populate a usable BearerToken. - deferred := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, &clientcmd.ConfigOverrides{}) - if restCfg, err := deferred.ClientConfig(); err == nil && restCfg != nil { - if t := strings.TrimSpace(restCfg.BearerToken); t != "" { - return t, nil - } - if f := strings.TrimSpace(restCfg.BearerTokenFile); f != "" { - if b, rerr := os.ReadFile(f); rerr == nil { - if t := strings.TrimSpace(string(b)); t != "" { - return t, nil - } - } - } - } - - // Fallback to parsing raw kubeconfig for legacy token fields - rawCfg, err := loadingRules.Load() - if err != nil || rawCfg == nil { - return "", fmt.Errorf("cannot load kubeconfig: %w", err) - } - ctxName := rawCfg.CurrentContext - if strings.TrimSpace(ctxName) == "" { - return "", fmt.Errorf("no current kube context") - } - ctx := rawCfg.Contexts[ctxName] - if ctx == nil { - return "", fmt.Errorf("missing context %s", ctxName) - } - ai := rawCfg.AuthInfos[ctx.AuthInfo] - if ai == nil { - return "", fmt.Errorf("missing auth info for context %s", ctxName) - } - if strings.TrimSpace(ai.Token) != "" { - return strings.TrimSpace(ai.Token), nil - } - if ai.AuthProvider != nil && ai.AuthProvider.Config != nil { - if t := strings.TrimSpace(ai.AuthProvider.Config["access-token"]); t != "" { - return t, nil - } - if t := strings.TrimSpace(ai.AuthProvider.Config["id-token"]); t != "" { - return t, nil - } - if t := strings.TrimSpace(ai.AuthProvider.Config["token"]); t != "" { - return t, nil - } - } - if path, err := exec.LookPath("oc"); err == nil && path != "" { - out, err := exec.Command(path, "whoami", "-t").Output() - if err == nil { - if t := strings.TrimSpace(string(out)); t != "" { - return t, nil - } - } - } - return "", fmt.Errorf("no bearer token found in kubeconfig") -} - -// parseLeaseDuration converts HH:MM:SS format to time.Duration -func parseLeaseDuration(duration string) time.Duration { - parts := strings.Split(duration, ":") - if len(parts) != 3 { - return time.Hour // Default 1 hour - } - var hours, mins, secs int - - // Validate each part can be parsed as an integer - if n, err := fmt.Sscanf(parts[0], "%d", &hours); n != 1 || err != nil { - return time.Hour // Default 1 hour if hours is invalid - } - if n, err := fmt.Sscanf(parts[1], "%d", &mins); n != 1 || err != nil { - return time.Hour // Default 1 hour if minutes is invalid - } - if n, err := fmt.Sscanf(parts[2], "%d", &secs); n != 1 || err != nil { - return time.Hour // Default 1 hour if seconds is invalid - } - - // Validate ranges to prevent negative or extremely large values - if hours < 0 || hours > 8760 || mins < 0 || mins >= 60 || secs < 0 || secs >= 60 { - return time.Hour // Default 1 hour if values are out of reasonable range - } - - return time.Duration(hours)*time.Hour + time.Duration(mins)*time.Minute + time.Duration(secs)*time.Second -} - -// runFlash handles the standalone 'flash' command -func runFlash(cmd *cobra.Command, args []string) { - applyWaitFollowDefaults(cmd, true, false) - - ctx := context.Background() - imageRef := args[0] - - if serverURL == "" { - handleError(fmt.Errorf("--server is required (or set CAIB_SERVER, or run 'caib login ')")) - } - - if jumpstarterClient == "" { - handleError(fmt.Errorf("--client is required")) - } - - // Validate that either target or exporter is specified - if target == "" && exporterSelector == "" { - handleError(fmt.Errorf("either --target or --exporter is required")) - } - - api, err := createBuildAPIClient(serverURL, &authToken) - if err != nil { - handleError(err) - } - - // Read and encode client config - clientConfigBytes, err := os.ReadFile(jumpstarterClient) - if err != nil { - handleError(fmt.Errorf("failed to read client config file: %w", err)) - } - clientConfigB64 := base64.StdEncoding.EncodeToString(clientConfigBytes) - - req := buildapitypes.FlashRequest{ - Name: flashName, - ImageRef: imageRef, - Target: target, - ExporterSelector: exporterSelector, - ClientConfig: clientConfigB64, - LeaseDuration: leaseDuration, - } - - resp, err := api.CreateFlash(ctx, req) - if err != nil { - handleError(err) - } - fmt.Printf("Flash job %s accepted: %s - %s\n", resp.Name, resp.Phase, resp.Message) - - if waitForBuild || followLogs { - waitForFlashCompletion(ctx, api, resp.Name) - } -} - -// waitForFlashCompletion waits for a flash job to complete, optionally streaming logs -func waitForFlashCompletion(ctx context.Context, api *buildapiclient.Client, name string) { - fmt.Println("Waiting for flash to complete...") - // Parse lease duration and add buffer for wait timeout - timeoutDuration := parseLeaseDuration(leaseDuration) + 10*time.Minute - timeoutCtx, cancel := context.WithTimeout(ctx, timeoutDuration) - defer cancel() - ticker := time.NewTicker(5 * time.Second) - defer ticker.Stop() - - var lastPhase, lastMessage string - pendingWarningShown := false - - flashLogTransport := &http.Transport{ - ResponseHeaderTimeout: 30 * time.Second, - IdleConnTimeout: 2 * time.Minute, - } - if insecureSkipTLS { - flashLogTransport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} - } - // No hard Timeout on the client: flash operations can stream logs for - // the entire lease duration (default 3 hours, user-configurable). The - // flash's context timeout (timeoutCtx) already governs cancellation. - logClient := &http.Client{ - Transport: flashLogTransport, - } - streamState := &logStreamState{} - - for { - select { - case <-timeoutCtx.Done(): - handleError(fmt.Errorf("timed out waiting for flash")) - case <-ticker.C: - reqCtx, cancelReq := context.WithTimeout(ctx, 2*time.Minute) - st, err := api.GetFlash(reqCtx, name) - cancelReq() - if err != nil { - fmt.Printf("status check failed: %v\n", err) - continue - } - - // Update status display when not streaming - if !streamState.active { - if st.Phase != lastPhase || st.Message != lastMessage { - fmt.Printf("status: %s - %s\n", st.Phase, st.Message) - lastPhase = st.Phase - lastMessage = st.Message - } - } - - // Handle terminal states - if st.Phase == phaseCompleted { - fmt.Println("Flash completed successfully!") - return - } - if st.Phase == phaseFailed { - handleError(fmt.Errorf("flash failed: %s", st.Message)) - } - - // Attempt log streaming for active flash jobs - if !followLogs || streamState.active || !streamState.canRetry() { - continue - } - - if st.Phase == phasePending { - streamState.reset() - if !pendingWarningShown { - fmt.Println("Waiting for flash to start before streaming logs...") - pendingWarningShown = true - } - continue - } - - if st.Phase == phaseRunning { - if streamState.retryCount == 0 { - fmt.Println("Flash is running. Attempting to stream logs...") - pendingWarningShown = false - } - - if err := tryFlashLogStreaming(ctx, logClient, name, streamState); err != nil { - streamState.retryCount++ - } - } - } - } -} - -// tryFlashLogStreaming attempts to stream flash logs -func tryFlashLogStreaming(ctx context.Context, logClient *http.Client, name string, state *logStreamState) error { - logURL := strings.TrimRight(serverURL, "/") + "/v1/flash/" + url.PathEscape(name) + "/logs?follow=1" - if !state.startTime.IsZero() { - logURL += "&since=" + url.QueryEscape(state.startTime.Format(time.RFC3339)) - } - - req, _ := http.NewRequestWithContext(ctx, http.MethodGet, logURL, nil) - if authToken := strings.TrimSpace(authToken); authToken != "" { - req.Header.Set("Authorization", "Bearer "+authToken) - } - - resp, err := logClient.Do(req) - if err != nil { - return fmt.Errorf("log request failed: %w", err) - } - defer func() { - if err := resp.Body.Close(); err != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to close response body: %v\n", err) - } - }() - - if resp.StatusCode == http.StatusOK { - return streamLogsToStdout(resp.Body, state) - } - - return handleLogStreamError(resp, state) -} - -// ── Sealed operations ── - -func sealedRegistryCredentials(refs ...string) (registryURL, username, password string) { - username = strings.TrimSpace(os.Getenv("REGISTRY_USERNAME")) - password = strings.TrimSpace(os.Getenv("REGISTRY_PASSWORD")) - if username == "" || password == "" { - return "", "", "" - } - for _, ref := range refs { - ref = strings.TrimSpace(ref) - if ref == "" { - continue - } - parts := strings.SplitN(ref, "/", 2) - if len(parts) < 2 { - return defaultRegistry, username, password - } - first := parts[0] - if strings.Contains(first, ".") || strings.Contains(first, ":") || first == "localhost" { - return first, username, password - } - return defaultRegistry, username, password - } - return "", "", "" -} - -// sealedBuildRequest builds a SealedRequest from CLI flags -func sealedBuildRequest(op buildapitypes.SealedOperation, inputRef, outputRef, signedRef string) (buildapitypes.SealedRequest, error) { - req := buildapitypes.SealedRequest{ - Operation: op, - InputRef: inputRef, - OutputRef: outputRef, - SignedRef: signedRef, - AIBImage: automotiveImageBuilder, - BuilderImage: sealedBuilderImage, - Architecture: sealedArchitecture, - AIBExtraArgs: aibExtraArgs, - } - if regURL, user, pass := sealedRegistryCredentials(inputRef, outputRef, signedRef); regURL != "" { - req.RegistryCredentials = &buildapitypes.RegistryCredentials{ - Enabled: true, - AuthType: "username-password", - RegistryURL: regURL, - Username: user, - Password: pass, - } - } - if strings.TrimSpace(sealedKeyFile) != "" { - keyData, err := os.ReadFile(strings.TrimSpace(sealedKeyFile)) - if err != nil { - return req, fmt.Errorf("failed to read key file %s: %w", sealedKeyFile, err) - } - req.KeyContent = string(keyData) - if strings.TrimSpace(sealedKeyPassword) != "" { - req.KeyPassword = strings.TrimSpace(sealedKeyPassword) - } - } else if strings.TrimSpace(sealedKeySecret) != "" { - req.KeySecretRef = strings.TrimSpace(sealedKeySecret) - if strings.TrimSpace(sealedKeyPasswordSecret) != "" { - req.KeyPasswordSecretRef = strings.TrimSpace(sealedKeyPasswordSecret) - } - } - return req, nil -} - -// sealedRunViaAPI creates a sealed job via the Build API and optionally waits/streams logs -func sealedRunViaAPI(op buildapitypes.SealedOperation, inputRef, outputRef, signedRef string) { - api, err := createBuildAPIClient(serverURL, &authToken) - if err != nil { - handleError(err) - } - ctx := context.Background() - req, err := sealedBuildRequest(op, inputRef, outputRef, signedRef) - if err != nil { - handleError(err) - } - resp, err := api.CreateSealed(ctx, req) - if err != nil { - handleError(err) - } - fmt.Printf("Job %s accepted: %s - %s\n", resp.Name, resp.Phase, resp.Message) - if waitForBuild || followLogs { - sealedWaitForCompletion(ctx, api, op, resp.Name) - } -} - -const maxSealedLogRetries = 24 - -func sealedWaitForCompletion(ctx context.Context, api *buildapiclient.Client, op buildapitypes.SealedOperation, name string) { - fmt.Println("Waiting for job to complete...") - ticker := time.NewTicker(5 * time.Second) - defer ticker.Stop() - sealedTimeout := time.Duration(timeout) * time.Minute - deadline := time.Now().Add(sealedTimeout) - var lastPhase string - logRetries := 0 - logStreaming := false - logRetryWarningShown := false - for time.Now().Before(deadline) { - st, err := api.GetSealed(ctx, op, name) - if err != nil { - fmt.Printf("status check failed: %v\n", err) - <-ticker.C - continue - } - if st.Phase != lastPhase { - fmt.Printf("status: %s - %s\n", st.Phase, st.Message) - lastPhase = st.Phase - } - if st.Phase == phaseCompleted { - fmt.Println("Job completed successfully.") - if st.OutputRef != "" { - fmt.Printf("Output: %s\n", st.OutputRef) - } - return - } - if st.Phase == phaseFailed { - fmt.Printf("Error: job failed: %s\n", st.Message) - os.Exit(1) - } - if followLogs && !logStreaming && (st.Phase == phaseRunning || st.Phase == phasePending) { - if logRetries < maxSealedLogRetries { - sErr := sealedStreamLogs(op, name) - if sErr != nil { - logRetries++ - if !logRetryWarningShown { - fmt.Printf("Waiting for logs... (attempt %d/%d)\n", logRetries, maxSealedLogRetries) - logRetryWarningShown = true - } - } else { - logStreaming = true - } - } else if !logRetryWarningShown { - fmt.Printf("Log streaming failed after %d attempts. Falling back to status updates.\n", maxSealedLogRetries) - logRetryWarningShown = true - followLogs = false - } - } - <-ticker.C - } - fmt.Printf("Error: timed out after %v\n", sealedTimeout) - os.Exit(1) -} - -func sealedStreamLogs(op buildapitypes.SealedOperation, name string) error { - logURL := strings.TrimRight(serverURL, "/") + buildapitypes.SealedOperationAPIPath(op) + "/" + url.PathEscape(name) + "/logs?follow=1" - req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, logURL, nil) - if t := strings.TrimSpace(authToken); t != "" { - req.Header.Set("Authorization", "Bearer "+t) - } - httpClient := &http.Client{Timeout: 10 * time.Minute} - resp, err := httpClient.Do(req) - if err != nil { - return fmt.Errorf("log stream failed: %w", err) - } - defer func() { - if cErr := resp.Body.Close(); cErr != nil { - fmt.Fprintf(os.Stderr, "Warning: failed to close response body: %v\n", cErr) - } - }() - if resp.StatusCode == http.StatusServiceUnavailable || resp.StatusCode == http.StatusGatewayTimeout { - return fmt.Errorf("log endpoint not ready (HTTP %d)", resp.StatusCode) - } - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("log stream error: HTTP %d", resp.StatusCode) - } - fmt.Println("Streaming logs...") - scanner := bufio.NewScanner(resp.Body) - scanner.Buffer(make([]byte, 64*1024), 1024*1024) - for scanner.Scan() { - fmt.Println(scanner.Text()) - } - _ = scanner.Err() - return nil -} - -// ── Sealed command runners ── - -// resolveSealedTwoRefs returns input and output refs from --input/--output flags or positionals (any order). -func resolveSealedTwoRefs(args []string) (inputRef, outputRef string, err error) { - in := strings.TrimSpace(sealedInputRef) - out := strings.TrimSpace(sealedOutputRef) - if in != "" && out != "" { - return in, out, nil - } - if in != "" && len(args) >= 1 { - return in, strings.TrimSpace(args[0]), nil - } - if out != "" && len(args) >= 1 { - return strings.TrimSpace(args[0]), out, nil - } - if len(args) >= 2 { - return strings.TrimSpace(args[0]), strings.TrimSpace(args[1]), nil - } - return "", "", fmt.Errorf("need two refs: use positionals (source output) or --input and --output in any order") -} - -// resolveSealedThreeRefs returns input, signed, and output refs from --input/--signed/--output flags or positionals (any order). -func resolveSealedThreeRefs(args []string) (inputRef, signedRef, outputRef string, err error) { - in := strings.TrimSpace(sealedInputRef) - signed := strings.TrimSpace(sealedSignedRef) - out := strings.TrimSpace(sealedOutputRef) - if in != "" && signed != "" && out != "" { - return in, signed, out, nil - } - // Count how many from flags; remaining from args in order: input, signed, output - fromFlags := 0 - if in != "" { - fromFlags++ - } - if signed != "" { - fromFlags++ - } - if out != "" { - fromFlags++ - } - need := 3 - fromFlags - if len(args) < need { - return "", "", "", fmt.Errorf("need three refs (source, signed-artifact, output): use positionals or --input, --signed, --output in any order") - } - idx := 0 - if in == "" { - in = strings.TrimSpace(args[idx]) - idx++ - } - if signed == "" { - signed = strings.TrimSpace(args[idx]) - idx++ - } - if out == "" { - out = strings.TrimSpace(args[idx]) - } - return in, signed, out, nil -} - -func runPrepareReseal(cmd *cobra.Command, args []string) { - applyWaitFollowDefaults(cmd, false, true) - - inputRef, outputRef, err := resolveSealedTwoRefs(args) - if err != nil { - handleError(err) - } - sealedRunViaAPI(buildapitypes.SealedPrepareReseal, inputRef, outputRef, "") -} - -func runReseal(cmd *cobra.Command, args []string) { - applyWaitFollowDefaults(cmd, false, true) - - inputRef, outputRef, err := resolveSealedTwoRefs(args) - if err != nil { - handleError(err) - } - sealedRunViaAPI(buildapitypes.SealedReseal, inputRef, outputRef, "") -} - -func runExtractForSigning(cmd *cobra.Command, args []string) { - applyWaitFollowDefaults(cmd, false, true) - - inputRef, outputRef, err := resolveSealedTwoRefs(args) - if err != nil { - handleError(err) - } - sealedRunViaAPI(buildapitypes.SealedExtractForSigning, inputRef, outputRef, "") -} - -func runInjectSigned(cmd *cobra.Command, args []string) { - applyWaitFollowDefaults(cmd, false, true) - - inputRef, signedRef, outputRef, err := resolveSealedThreeRefs(args) - if err != nil { - handleError(err) - } - sealedRunViaAPI(buildapitypes.SealedInjectSigned, inputRef, outputRef, signedRef) -} diff --git a/cmd/caib/querycmd/query.go b/cmd/caib/querycmd/query.go new file mode 100644 index 00000000..768ee21f --- /dev/null +++ b/cmd/caib/querycmd/query.go @@ -0,0 +1,300 @@ +// Package querycmd provides handlers for image list/show commands. +package querycmd + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + "text/tabwriter" + "time" + + common "github.com/centos-automotive-suite/automotive-dev-operator/cmd/caib/common" + buildapitypes "github.com/centos-automotive-suite/automotive-dev-operator/internal/buildapi" + buildapiclient "github.com/centos-automotive-suite/automotive-dev-operator/internal/buildapi/client" + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" +) + +// Options wires query handlers to caller-owned state and helper callbacks. +type Options struct { + ServerURL *string + AuthToken *string + ShowOutputFormat *string + InsecureSkipTLS *bool + + HandleError func(error) +} + +// Handler implements list/show command run functions. +type Handler struct { + opts Options +} + +// NewHandler creates a query handler. +func NewHandler(opts Options) *Handler { + return &Handler{opts: opts} +} + +func (h *Handler) handleError(err error) { + if h.opts.HandleError != nil { + h.opts.HandleError(err) + return + } + panic(err) +} + +// RunList handles `caib image list`. +func (h *Handler) RunList(_ *cobra.Command, _ []string) { + ctx := context.Background() + if h.opts.ServerURL == nil || strings.TrimSpace(*h.opts.ServerURL) == "" { + h.handleError(fmt.Errorf("--server is required (or set CAIB_SERVER, or run 'caib login ')")) + return + } + if h.opts.InsecureSkipTLS == nil { + h.handleError(fmt.Errorf("internal error: --insecure option is not configured")) + return + } + + serverURL := strings.TrimSpace(*h.opts.ServerURL) + insecureSkipTLS := *h.opts.InsecureSkipTLS + + var items []buildapitypes.BuildListItem + err := common.ExecuteWithReauth(serverURL, h.opts.AuthToken, insecureSkipTLS, func(api *buildapiclient.Client) error { + var listErr error + items, listErr = api.ListBuilds(ctx) + return listErr + }) + if err != nil { + h.handleError(fmt.Errorf("error listing ImageBuilds: %w", err)) + return + } + if len(items) == 0 { + fmt.Println("No ImageBuilds found") + return + } + + w := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + defer func() { + if flushErr := w.Flush(); flushErr != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to flush output: %v\n", flushErr) + } + }() + + if _, err := fmt.Fprintln(w, "NAME\tSTATUS\tAGE\tREQUESTED BY\tARTIFACT"); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to write header: %v\n", err) + return + } + for _, it := range items { + artifact := it.DiskImage + if artifact == "" { + artifact = it.ContainerImage + } + if _, err := fmt.Fprintf( + w, + "%s\t%s\t%s\t%s\t%s\n", + it.Name, + it.Phase, + formatAge(it.CreatedAt), + it.RequestedBy, + artifact, + ); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to write row: %v\n", err) + } + } +} + +// RunShow handles `caib image show`. +func (h *Handler) RunShow(_ *cobra.Command, args []string) { + ctx := context.Background() + showBuildName := args[0] + + if h.opts.ServerURL == nil || strings.TrimSpace(*h.opts.ServerURL) == "" { + h.handleError(fmt.Errorf("--server is required (or set CAIB_SERVER, or run 'caib login ')")) + return + } + if h.opts.InsecureSkipTLS == nil { + h.handleError(fmt.Errorf("internal error: --insecure option is not configured")) + return + } + if h.opts.ShowOutputFormat == nil { + h.handleError(fmt.Errorf("internal error: output format option is not configured")) + return + } + + serverURL := strings.TrimSpace(*h.opts.ServerURL) + insecureSkipTLS := *h.opts.InsecureSkipTLS + + var st *buildapitypes.BuildResponse + err := common.ExecuteWithReauth(serverURL, h.opts.AuthToken, insecureSkipTLS, func(api *buildapiclient.Client) error { + var getErr error + st, getErr = api.GetBuild(ctx, showBuildName) + return getErr + }) + if err != nil { + h.handleError(fmt.Errorf("error getting ImageBuild %s: %w", showBuildName, err)) + return + } + + // Backward-compatible fallback for older API servers that do not yet include response parameters. + if st.Parameters == nil { + fallbackErr := common.ExecuteWithReauth(serverURL, h.opts.AuthToken, insecureSkipTLS, func(api *buildapiclient.Client) error { + tpl, tplErr := api.GetBuildTemplate(ctx, showBuildName) + if tplErr != nil { + return tplErr + } + st.Parameters = buildParametersFromTemplate(tpl) + return nil + }) + if fallbackErr != nil { + fmt.Fprintf( + os.Stderr, + "Warning: failed to fetch build template for %s from %s: %v\n", + showBuildName, + serverURL, + fallbackErr, + ) + } + } + + switch strings.ToLower(*h.opts.ShowOutputFormat) { + case "json": + out, marshalErr := json.MarshalIndent(st, "", " ") + if marshalErr != nil { + h.handleError(fmt.Errorf("error rendering JSON output: %w", marshalErr)) + return + } + fmt.Println(string(out)) + case "yaml", "yml": + out, marshalErr := yaml.Marshal(st) + if marshalErr != nil { + h.handleError(fmt.Errorf("error rendering YAML output: %w", marshalErr)) + return + } + fmt.Print(string(out)) + case "table": + printBuildDetails(st) + default: + h.handleError(fmt.Errorf("invalid output format %q (supported: table, json, yaml)", *h.opts.ShowOutputFormat)) + return + } +} + +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 { + return rfcTime + } + d := time.Since(t) + switch { + case d < time.Minute: + return fmt.Sprintf("%ds", int(d.Seconds())) + case d < time.Hour: + return fmt.Sprintf("%dm", int(d.Minutes())) + case d < 24*time.Hour: + return fmt.Sprintf("%dh", int(d.Hours())) + default: + return fmt.Sprintf("%dd", int(d.Hours()/24)) + } +} diff --git a/cmd/caib/root.go b/cmd/caib/root.go new file mode 100644 index 00000000..c49e94ad --- /dev/null +++ b/cmd/caib/root.go @@ -0,0 +1,54 @@ +package main + +import ( + "github.com/centos-automotive-suite/automotive-dev-operator/cmd/caib/authcmd" + "github.com/centos-automotive-suite/automotive-dev-operator/cmd/caib/catalog" + "github.com/centos-automotive-suite/automotive-dev-operator/cmd/caib/container" + "github.com/centos-automotive-suite/automotive-dev-operator/cmd/caib/image" + "github.com/spf13/cobra" +) + +func newRootCmd() *cobra.Command { + rootCmd := &cobra.Command{ + Use: "caib", + Short: "Cloud Automotive Image Builder", + Version: version, + } + + rootCmd.InitDefaultVersionFlag() + rootCmd.SetVersionTemplate("caib version: {{.Version}}\n") + + rootCmd.PersistentFlags().BoolVar( + &insecureSkipTLS, + "insecure", + envBool("CAIB_INSECURE"), + "skip TLS certificate verification (insecure, for testing only; env: CAIB_INSECURE)", + ) + state := newRuntimeState() + handlers := state.newHandlers() + + rootCmd.AddCommand( + image.NewImageCmd(state.imageOptions(handlers)), + newLoginCmd(), + container.NewContainerCmd(), + catalog.NewCatalogCmd(), + authcmd.NewAuthCmd(), + ) + + return rootCmd +} + +func newLoginCmd() *cobra.Command { + return &cobra.Command{ + Use: "login [server-url]", + Short: "Save server endpoint and authenticate for subsequent commands", + Long: `Login saves the Build API server URL locally (~/.caib/cli.json) so you do not need +to pass --server or set CAIB_SERVER for later commands. If the server uses OIDC, +this command also performs authentication and caches the token. + +Example: + caib login https://build-api.my-cluster.example.com`, + Args: cobra.ExactArgs(1), + Run: runLogin, + } +} diff --git a/cmd/caib/runtime_wiring.go b/cmd/caib/runtime_wiring.go new file mode 100644 index 00000000..8b58ca13 --- /dev/null +++ b/cmd/caib/runtime_wiring.go @@ -0,0 +1,279 @@ +package main + +import ( + "github.com/centos-automotive-suite/automotive-dev-operator/cmd/caib/buildcmd" + "github.com/centos-automotive-suite/automotive-dev-operator/cmd/caib/downloadcmd" + "github.com/centos-automotive-suite/automotive-dev-operator/cmd/caib/flashcmd" + "github.com/centos-automotive-suite/automotive-dev-operator/cmd/caib/image" + "github.com/centos-automotive-suite/automotive-dev-operator/cmd/caib/querycmd" + "github.com/centos-automotive-suite/automotive-dev-operator/cmd/caib/sealedcmd" +) + +type runtimeState struct { + ServerURL *string + Manifest *string + BuildName *string + ShowOutputFormat *string + Distro *string + Target *string + Architecture *string + ExportFormat *string + Mode *string + AutomotiveImageBuilder *string + StorageClass *string + OutputDir *string + Timeout *int + WaitForBuild *bool + CustomDefs *[]string + AIBExtraArgs *[]string + FollowLogs *bool + CompressionAlgo *string + AuthToken *string + + ContainerPush *string + BuildDiskImage *bool + DiskFormat *string + ExportOCI *string + BuilderImage *string + RegistryAuthFile *string + ContainerRef *string + RebuildBuilder *bool + + FlashAfterBuild *bool + JumpstarterClient *string + FlashName *string + ExporterSelector *string + LeaseDuration *string + + UseInternalRegistry *bool + InternalRegistryImageName *string + InternalRegistryTag *string + + InsecureSkipTLS *bool + + SealedBuilderImage *string + SealedArchitecture *string + SealedKeySecret *string + SealedKeyPasswordSecret *string + SealedKeyFile *string + SealedKeyPassword *string + SealedInputRef *string + SealedOutputRef *string + SealedSignedRef *string +} + +func newRuntimeState() runtimeState { + return runtimeState{ + ServerURL: &serverURL, + Manifest: &manifest, + BuildName: &buildName, + ShowOutputFormat: &showOutputFormat, + Distro: &distro, + Target: &target, + Architecture: &architecture, + ExportFormat: &exportFormat, + Mode: &mode, + AutomotiveImageBuilder: &automotiveImageBuilder, + StorageClass: &storageClass, + OutputDir: &outputDir, + Timeout: &timeout, + WaitForBuild: &waitForBuild, + CustomDefs: &customDefs, + AIBExtraArgs: &aibExtraArgs, + FollowLogs: &followLogs, + CompressionAlgo: &compressionAlgo, + AuthToken: &authToken, + + ContainerPush: &containerPush, + BuildDiskImage: &buildDiskImage, + DiskFormat: &diskFormat, + ExportOCI: &exportOCI, + BuilderImage: &builderImage, + RegistryAuthFile: ®istryAuthFile, + ContainerRef: &containerRef, + RebuildBuilder: &rebuildBuilder, + + FlashAfterBuild: &flashAfterBuild, + JumpstarterClient: &jumpstarterClient, + FlashName: &flashName, + ExporterSelector: &exporterSelector, + LeaseDuration: &leaseDuration, + + UseInternalRegistry: &useInternalRegistry, + InternalRegistryImageName: &internalRegistryImageName, + InternalRegistryTag: &internalRegistryTag, + + InsecureSkipTLS: &insecureSkipTLS, + + SealedBuilderImage: &sealedBuilderImage, + SealedArchitecture: &sealedArchitecture, + SealedKeySecret: &sealedKeySecret, + SealedKeyPasswordSecret: &sealedKeyPasswordSecret, + SealedKeyFile: &sealedKeyFile, + SealedKeyPassword: &sealedKeyPassword, + SealedInputRef: &sealedInputRef, + SealedOutputRef: &sealedOutputRef, + SealedSignedRef: &sealedSignedRef, + } +} + +type handlerSet struct { + build *buildcmd.Handler + query *querycmd.Handler + download *downloadcmd.Handler + flash *flashcmd.Handler + sealed *sealedcmd.Handler +} + +func (s runtimeState) newHandlers() handlerSet { + return handlerSet{ + build: buildcmd.NewHandler(buildcmd.Options{ + ServerURL: s.ServerURL, + Manifest: s.Manifest, + BuildName: s.BuildName, + Distro: s.Distro, + Target: s.Target, + Architecture: s.Architecture, + ExportFormat: s.ExportFormat, + Mode: s.Mode, + AutomotiveImageBuilder: s.AutomotiveImageBuilder, + StorageClass: s.StorageClass, + OutputDir: s.OutputDir, + Timeout: s.Timeout, + WaitForBuild: s.WaitForBuild, + CustomDefs: s.CustomDefs, + AIBExtraArgs: s.AIBExtraArgs, + FollowLogs: s.FollowLogs, + CompressionAlgo: s.CompressionAlgo, + AuthToken: s.AuthToken, + ContainerPush: s.ContainerPush, + BuildDiskImage: s.BuildDiskImage, + DiskFormat: s.DiskFormat, + ExportOCI: s.ExportOCI, + BuilderImage: s.BuilderImage, + RegistryAuthFile: s.RegistryAuthFile, + ContainerRef: s.ContainerRef, + RebuildBuilder: s.RebuildBuilder, + FlashAfterBuild: s.FlashAfterBuild, + JumpstarterClient: s.JumpstarterClient, + LeaseDuration: s.LeaseDuration, + UseInternalRegistry: s.UseInternalRegistry, + InternalRegistryImageName: s.InternalRegistryImageName, + InternalRegistryTag: s.InternalRegistryTag, + InsecureSkipTLS: s.InsecureSkipTLS, + HandleError: handleError, + }), + query: querycmd.NewHandler(querycmd.Options{ + ServerURL: s.ServerURL, + AuthToken: s.AuthToken, + ShowOutputFormat: s.ShowOutputFormat, + InsecureSkipTLS: s.InsecureSkipTLS, + HandleError: handleError, + }), + download: downloadcmd.NewHandler(downloadcmd.Options{ + ServerURL: s.ServerURL, + AuthToken: s.AuthToken, + OutputDir: s.OutputDir, + InsecureSkipTLS: s.InsecureSkipTLS, + HandleError: handleError, + }), + flash: flashcmd.NewHandler(flashcmd.Options{ + ServerURL: s.ServerURL, + AuthToken: s.AuthToken, + JumpstarterClient: s.JumpstarterClient, + FlashName: s.FlashName, + Target: s.Target, + ExporterSelector: s.ExporterSelector, + LeaseDuration: s.LeaseDuration, + WaitForBuild: s.WaitForBuild, + FollowLogs: s.FollowLogs, + InsecureSkipTLS: s.InsecureSkipTLS, + HandleError: handleError, + }), + sealed: sealedcmd.NewHandler(sealedcmd.Options{ + ServerURL: s.ServerURL, + AuthToken: s.AuthToken, + AutomotiveImageBuilder: s.AutomotiveImageBuilder, + SealedBuilderImage: s.SealedBuilderImage, + SealedArchitecture: s.SealedArchitecture, + AIBExtraArgs: s.AIBExtraArgs, + WaitForBuild: s.WaitForBuild, + FollowLogs: s.FollowLogs, + Timeout: s.Timeout, + SealedKeySecret: s.SealedKeySecret, + SealedKeyPasswordSecret: s.SealedKeyPasswordSecret, + SealedKeyFile: s.SealedKeyFile, + SealedKeyPassword: s.SealedKeyPassword, + SealedInputRef: s.SealedInputRef, + SealedOutputRef: s.SealedOutputRef, + SealedSignedRef: s.SealedSignedRef, + RegistryAuthFile: s.RegistryAuthFile, + InsecureSkipTLS: s.InsecureSkipTLS, + HandleError: handleError, + }), + } +} + +func (s runtimeState) imageOptions(h handlerSet) image.Options { + return image.Options{ + RunBuild: h.build.RunBuild, + RunDisk: h.build.RunDisk, + RunBuildDev: h.build.RunBuildDev, + RunList: h.query.RunList, + RunShow: h.query.RunShow, + RunDownload: h.download.RunDownload, + RunLogs: h.build.RunLogs, + RunFlash: h.flash.RunFlash, + RunPrepareReseal: h.sealed.RunPrepareReseal, + RunReseal: h.sealed.RunReseal, + RunExtractForSigning: h.sealed.RunExtractForSigning, + RunInjectSigned: h.sealed.RunInjectSigned, + GetDefaultArch: getDefaultArch, + + ServerURL: s.ServerURL, + AuthToken: s.AuthToken, + BuildName: s.BuildName, + ShowOutputFormat: s.ShowOutputFormat, + Distro: s.Distro, + Target: s.Target, + Architecture: s.Architecture, + ExportFormat: s.ExportFormat, + Mode: s.Mode, + AutomotiveImageBuilder: s.AutomotiveImageBuilder, + StorageClass: s.StorageClass, + OutputDir: s.OutputDir, + Timeout: s.Timeout, + WaitForBuild: s.WaitForBuild, + CustomDefs: s.CustomDefs, + AIBExtraArgs: s.AIBExtraArgs, + FollowLogs: s.FollowLogs, + CompressionAlgo: s.CompressionAlgo, + ContainerPush: s.ContainerPush, + BuildDiskImage: s.BuildDiskImage, + DiskFormat: s.DiskFormat, + ExportOCI: s.ExportOCI, + BuilderImage: s.BuilderImage, + RegistryAuthFile: s.RegistryAuthFile, + RebuildBuilder: s.RebuildBuilder, + + FlashAfterBuild: s.FlashAfterBuild, + JumpstarterClient: s.JumpstarterClient, + FlashName: s.FlashName, + ExporterSelector: s.ExporterSelector, + LeaseDuration: s.LeaseDuration, + + UseInternalRegistry: s.UseInternalRegistry, + InternalRegistryImageName: s.InternalRegistryImageName, + InternalRegistryTag: s.InternalRegistryTag, + + SealedBuilderImage: s.SealedBuilderImage, + SealedArchitecture: s.SealedArchitecture, + SealedKeySecret: s.SealedKeySecret, + SealedKeyPasswordSecret: s.SealedKeyPasswordSecret, + SealedKeyFile: s.SealedKeyFile, + SealedKeyPassword: s.SealedKeyPassword, + SealedInputRef: s.SealedInputRef, + SealedOutputRef: s.SealedOutputRef, + SealedSignedRef: s.SealedSignedRef, + } +} diff --git a/cmd/caib/sealedcmd/sealed.go b/cmd/caib/sealedcmd/sealed.go new file mode 100644 index 00000000..07df6252 --- /dev/null +++ b/cmd/caib/sealedcmd/sealed.go @@ -0,0 +1,387 @@ +// Package sealedcmd provides sealed image operation handlers. +package sealedcmd + +import ( + "bufio" + "context" + "crypto/tls" + "fmt" + "net/http" + "net/url" + "os" + "strings" + "time" + + common "github.com/centos-automotive-suite/automotive-dev-operator/cmd/caib/common" + "github.com/centos-automotive-suite/automotive-dev-operator/cmd/caib/registryauth" + buildapitypes "github.com/centos-automotive-suite/automotive-dev-operator/internal/buildapi" + buildapiclient "github.com/centos-automotive-suite/automotive-dev-operator/internal/buildapi/client" + "github.com/spf13/cobra" +) + +const ( + phaseCompleted = "Completed" + phaseFailed = "Failed" + phasePending = "Pending" + phaseRunning = "Running" + maxSealedLogRetries = 24 +) + +// Options wires sealed command handlers to caller-owned state and dependencies. +type Options struct { + ServerURL *string + AuthToken *string + AutomotiveImageBuilder *string + SealedBuilderImage *string + SealedArchitecture *string + AIBExtraArgs *[]string + WaitForBuild *bool + FollowLogs *bool + Timeout *int + + SealedKeySecret *string + SealedKeyPasswordSecret *string + SealedKeyFile *string + SealedKeyPassword *string + SealedInputRef *string + SealedOutputRef *string + SealedSignedRef *string + + RegistryAuthFile *string + InsecureSkipTLS *bool + HandleError func(error) +} + +// Handler implements sealed command run functions. +type Handler struct { + opts Options +} + +// NewHandler creates a sealed operations handler. +func NewHandler(opts Options) *Handler { + return &Handler{opts: opts} +} + +func (h *Handler) handleError(err error) { + if h != nil && h.opts.HandleError != nil { + h.opts.HandleError(err) + return + } + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) +} + +func (h *Handler) applyWaitFollowDefaults(cmd *cobra.Command) { + if !cmd.Flags().Changed("wait") { + *h.opts.WaitForBuild = false + } + if !cmd.Flags().Changed("follow") { + *h.opts.FollowLogs = true + } +} + +// RunPrepareReseal handles `caib image prepare-reseal`. +func (h *Handler) RunPrepareReseal(cmd *cobra.Command, args []string) { + h.applyWaitFollowDefaults(cmd) + inputRef, outputRef, err := h.resolveSealedTwoRefs(args) + if err != nil { + h.handleError(err) + return + } + h.sealedRunViaAPI(buildapitypes.SealedPrepareReseal, inputRef, outputRef, "") +} + +// RunReseal handles `caib image reseal`. +func (h *Handler) RunReseal(cmd *cobra.Command, args []string) { + h.applyWaitFollowDefaults(cmd) + inputRef, outputRef, err := h.resolveSealedTwoRefs(args) + if err != nil { + h.handleError(err) + return + } + h.sealedRunViaAPI(buildapitypes.SealedReseal, inputRef, outputRef, "") +} + +// RunExtractForSigning handles `caib image extract-for-signing`. +func (h *Handler) RunExtractForSigning(cmd *cobra.Command, args []string) { + h.applyWaitFollowDefaults(cmd) + inputRef, outputRef, err := h.resolveSealedTwoRefs(args) + if err != nil { + h.handleError(err) + return + } + h.sealedRunViaAPI(buildapitypes.SealedExtractForSigning, inputRef, outputRef, "") +} + +// RunInjectSigned handles `caib image inject-signed`. +func (h *Handler) RunInjectSigned(cmd *cobra.Command, args []string) { + h.applyWaitFollowDefaults(cmd) + inputRef, signedRef, outputRef, err := h.resolveSealedThreeRefs(args) + if err != nil { + h.handleError(err) + return + } + h.sealedRunViaAPI(buildapitypes.SealedInjectSigned, inputRef, outputRef, signedRef) +} + +func (h *Handler) sealedBuildRequest( + op buildapitypes.SealedOperation, + inputRef, outputRef, signedRef string, +) (buildapitypes.SealedRequest, error) { + req := buildapitypes.SealedRequest{ + Operation: op, + InputRef: inputRef, + OutputRef: outputRef, + SignedRef: signedRef, + AIBImage: *h.opts.AutomotiveImageBuilder, + BuilderImage: *h.opts.SealedBuilderImage, + Architecture: *h.opts.SealedArchitecture, + AIBExtraArgs: *h.opts.AIBExtraArgs, + } + + registryURL, username, password := registryauth.ExtractRegistryCredentials(inputRef, outputRef) + registryCreds, err := registryauth.ResolveRegistryCredentials( + registryURL, + username, + password, + *h.opts.RegistryAuthFile, + ) + if err != nil { + return req, err + } + req.RegistryCredentials = registryCreds + + if keyFile := strings.TrimSpace(*h.opts.SealedKeyFile); keyFile != "" { + keyData, err := os.ReadFile(keyFile) + if err != nil { + return req, fmt.Errorf("failed to read key file %s: %w", keyFile, err) + } + req.KeyContent = string(keyData) + if keyPassword := strings.TrimSpace(*h.opts.SealedKeyPassword); keyPassword != "" { + req.KeyPassword = keyPassword + } + } else if keySecret := strings.TrimSpace(*h.opts.SealedKeySecret); keySecret != "" { + req.KeySecretRef = keySecret + if keyPassSecret := strings.TrimSpace(*h.opts.SealedKeyPasswordSecret); keyPassSecret != "" { + req.KeyPasswordSecretRef = keyPassSecret + } + } + + return req, nil +} + +func (h *Handler) sealedRunViaAPI(op buildapitypes.SealedOperation, inputRef, outputRef, signedRef string) { + api, err := common.CreateBuildAPIClient(*h.opts.ServerURL, h.opts.AuthToken, *h.opts.InsecureSkipTLS) + if err != nil { + h.handleError(err) + return + } + + ctx := context.Background() + req, err := h.sealedBuildRequest(op, inputRef, outputRef, signedRef) + if err != nil { + h.handleError(err) + return + } + + resp, err := api.CreateSealed(ctx, req) + if err != nil { + h.handleError(err) + return + } + fmt.Printf("Job %s accepted: %s - %s\n", resp.Name, resp.Phase, resp.Message) + + if *h.opts.WaitForBuild || *h.opts.FollowLogs { + h.sealedWaitForCompletion(ctx, api, op, resp.Name) + } +} + +func (h *Handler) sealedWaitForCompletion( + ctx context.Context, + api *buildapiclient.Client, + op buildapitypes.SealedOperation, + name string, +) { + fmt.Println("Waiting for job to complete...") + sealedTimeout := time.Duration(*h.opts.Timeout) * time.Minute + waitCtx, cancel := context.WithTimeout(ctx, sealedTimeout) + defer cancel() + + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + var lastPhase string + logRetries := 0 + logStreaming := false + logRetryWarningShown := false + const requestTimeout = 2 * time.Minute + + for { + select { + case <-waitCtx.Done(): + h.handleError(fmt.Errorf("timed out after %v", sealedTimeout)) + return + case <-ticker.C: + reqCtx, reqCancel := context.WithTimeout(waitCtx, requestTimeout) + st, err := api.GetSealed(reqCtx, op, name) + reqCancel() + if err != nil { + if waitCtx.Err() != nil { + h.handleError(fmt.Errorf("timed out after %v", sealedTimeout)) + return + } + fmt.Printf("status check failed: %v\n", err) + continue + } + + if st.Phase != lastPhase { + fmt.Printf("status: %s - %s\n", st.Phase, st.Message) + lastPhase = st.Phase + } + + if st.Phase == phaseCompleted { + fmt.Println("Job completed successfully.") + if st.OutputRef != "" { + fmt.Printf("Output: %s\n", st.OutputRef) + } + return + } + if st.Phase == phaseFailed { + h.handleError(fmt.Errorf("job failed: %s", st.Message)) + return + } + + if *h.opts.FollowLogs && !logStreaming && (st.Phase == phaseRunning || st.Phase == phasePending) { + if logRetries < maxSealedLogRetries { + logCtx, logCancel := context.WithTimeout(waitCtx, requestTimeout) + streamErr := h.sealedStreamLogs(logCtx, op, name) + logCancel() + if streamErr != nil { + logRetries++ + if logRetries == 1 { + fmt.Printf("Waiting for logs... (attempt %d/%d)\n", logRetries, maxSealedLogRetries) + } + } else { + logStreaming = true + } + } else if !logRetryWarningShown { + fmt.Printf("Log streaming failed after %d attempts. Falling back to status updates.\n", maxSealedLogRetries) + logRetryWarningShown = true + logStreaming = false + *h.opts.FollowLogs = false + } + } + } + } +} + +func (h *Handler) sealedStreamLogs(ctx context.Context, op buildapitypes.SealedOperation, name string) error { + logURL := strings.TrimRight(*h.opts.ServerURL, "/") + + buildapitypes.SealedOperationAPIPath(op) + "/" + url.PathEscape(name) + "/logs?follow=1" + req, err := http.NewRequestWithContext(ctx, http.MethodGet, logURL, nil) + if err != nil { + return fmt.Errorf("failed to create log request: %w", err) + } + if t := strings.TrimSpace(*h.opts.AuthToken); t != "" { + req.Header.Set("Authorization", "Bearer "+t) + } + + transport := &http.Transport{} + if *h.opts.InsecureSkipTLS { + transport.TLSClientConfig = &tls.Config{ + InsecureSkipVerify: true, + MinVersion: tls.VersionTLS12, + } + } + httpClient := &http.Client{ + Timeout: 10 * time.Minute, + Transport: transport, + } + resp, err := httpClient.Do(req) + if err != nil { + return fmt.Errorf("log stream failed: %w", err) + } + defer func() { + if closeErr := resp.Body.Close(); closeErr != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to close response body: %v\n", closeErr) + } + }() + + if resp.StatusCode == http.StatusServiceUnavailable || resp.StatusCode == http.StatusGatewayTimeout { + return fmt.Errorf("log endpoint not ready (HTTP %d)", resp.StatusCode) + } + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("log stream error: HTTP %d", resp.StatusCode) + } + + fmt.Println("Streaming logs...") + scanner := bufio.NewScanner(resp.Body) + scanner.Buffer(make([]byte, 64*1024), 1024*1024) + for scanner.Scan() { + fmt.Println(scanner.Text()) + } + if err := scanner.Err(); err != nil { + return fmt.Errorf("log stream interrupted: %w", err) + } + return nil +} + +// resolveSealedTwoRefs returns input and output refs from --input/--output flags or positionals (any order). +func (h *Handler) resolveSealedTwoRefs(args []string) (inputRef, outputRef string, err error) { + in := strings.TrimSpace(*h.opts.SealedInputRef) + out := strings.TrimSpace(*h.opts.SealedOutputRef) + if in != "" && out != "" { + return in, out, nil + } + if in != "" && len(args) >= 1 { + return in, strings.TrimSpace(args[0]), nil + } + if out != "" && len(args) >= 1 { + return strings.TrimSpace(args[0]), out, nil + } + if len(args) >= 2 { + return strings.TrimSpace(args[0]), strings.TrimSpace(args[1]), nil + } + return "", "", fmt.Errorf("need two refs: use positionals (source output) or --input and --output in any order") +} + +// resolveSealedThreeRefs returns input, signed, and output refs from --input/--signed/--output flags or positionals (any order). +func (h *Handler) resolveSealedThreeRefs(args []string) (inputRef, signedRef, outputRef string, err error) { + in := strings.TrimSpace(*h.opts.SealedInputRef) + signed := strings.TrimSpace(*h.opts.SealedSignedRef) + out := strings.TrimSpace(*h.opts.SealedOutputRef) + if in != "" && signed != "" && out != "" { + return in, signed, out, nil + } + + fromFlags := 0 + if in != "" { + fromFlags++ + } + if signed != "" { + fromFlags++ + } + if out != "" { + fromFlags++ + } + + need := 3 - fromFlags + if len(args) < need { + return "", "", "", fmt.Errorf( + "need three refs (source, signed-artifact, output): use positionals or --input, --signed, --output in any order") + } + + idx := 0 + if in == "" { + in = strings.TrimSpace(args[idx]) + idx++ + } + if signed == "" { + signed = strings.TrimSpace(args[idx]) + idx++ + } + if out == "" { + out = strings.TrimSpace(args[idx]) + } + return in, signed, out, nil +} diff --git a/cmd/caib/target_defaults_compat.go b/cmd/caib/target_defaults_compat.go new file mode 100644 index 00000000..cd8de8f2 --- /dev/null +++ b/cmd/caib/target_defaults_compat.go @@ -0,0 +1,12 @@ +package main + +import ( + "github.com/centos-automotive-suite/automotive-dev-operator/cmd/caib/buildcmd" + buildapitypes "github.com/centos-automotive-suite/automotive-dev-operator/internal/buildapi" + "github.com/spf13/cobra" +) + +// applyTargetDefaults is kept in main package for existing tests. +func applyTargetDefaults(cmd *cobra.Command, config *buildapitypes.OperatorConfigResponse, req *buildapitypes.BuildRequest) { + buildcmd.ApplyTargetDefaults(cmd, config, req) +} diff --git a/internal/common/tasks/scripts/sealed_operation.sh b/internal/common/tasks/scripts/sealed_operation.sh index 744de6da..2185a7ad 100644 --- a/internal/common/tasks/scripts/sealed_operation.sh +++ b/internal/common/tasks/scripts/sealed_operation.sh @@ -78,18 +78,31 @@ REGISTRY_AUTH_DIR="${REGISTRY_AUTH_PATH:-/workspace/registry-auth}" REGISTRY_URL="" REGISTRY_USERNAME="" REGISTRY_PASSWORD="" -if [ -f "$REGISTRY_AUTH_DIR/REGISTRY_URL" ]; then - REGISTRY_URL=$(cat "$REGISTRY_AUTH_DIR/REGISTRY_URL") -fi -if [ -f "$REGISTRY_AUTH_DIR/REGISTRY_USERNAME" ]; then - REGISTRY_USERNAME=$(cat "$REGISTRY_AUTH_DIR/REGISTRY_USERNAME") -fi -if [ -f "$REGISTRY_AUTH_DIR/REGISTRY_PASSWORD" ]; then - REGISTRY_PASSWORD=$(cat "$REGISTRY_AUTH_DIR/REGISTRY_PASSWORD") -fi +REGISTRY_AUTH_FILE_CONTENT="" +read_registry_creds "$REGISTRY_AUTH_DIR" ORAS_REGISTRY_CONFIG="" -if [ -n "$REGISTRY_USERNAME" ] && [ -n "$REGISTRY_PASSWORD" ] && [ -n "$REGISTRY_URL" ]; then +if [ -n "$REGISTRY_AUTH_FILE_CONTENT" ]; then + echo "Using provided registry auth file content" + echo "$REGISTRY_AUTH_FILE_CONTENT" > "$HOME/.custom_authjson" + # Merge SA token for internal registry access if available + if [ -n "$TOKEN" ]; then + SA_AUTH_B64=$(echo -n "serviceaccount:$TOKEN" | base64 -w0) + python3 -c " +import json, sys +with open(sys.argv[1]) as f: + cfg = json.load(f) +cfg.setdefault('auths', {})[sys.argv[2]] = {'auth': sys.argv[3]} +with open(sys.argv[1], 'w') as f: + json.dump(cfg, f) +" "$HOME/.custom_authjson" "$REGISTRY" "$SA_AUTH_B64" 2>/dev/null || true + fi + chmod 600 "$HOME/.custom_authjson" + export REGISTRY_AUTH_FILE="$HOME/.custom_authjson" + ORAS_REGISTRY_CONFIG="$WORKSPACE/.oras-auth.json" + cp "$REGISTRY_AUTH_FILE" "$ORAS_REGISTRY_CONFIG" + chmod 600 "$ORAS_REGISTRY_CONFIG" +elif [ -n "$REGISTRY_USERNAME" ] && [ -n "$REGISTRY_PASSWORD" ] && [ -n "$REGISTRY_URL" ]; then echo "Creating registry auth from username/password for $REGISTRY_URL" AUTH_STRING=$(echo -n "$REGISTRY_USERNAME:$REGISTRY_PASSWORD" | base64 -w0) SA_AUTH=""