diff --git a/.claude/agents/aib-expert.md b/.claude/agents/aib-expert.md index dd8b30cc..10106a9e 100644 --- a/.claude/agents/aib-expert.md +++ b/.claude/agents/aib-expert.md @@ -81,7 +81,7 @@ Development-focused command for non-bootc builds: aib-dev build [OPTIONS] # Examples -aib-dev build --distro cs9 --format qcow2 manifest.aib.yml disk.qcow2 +aib-dev build --distro autosd --format qcow2 manifest.aib.yml disk.qcow2 aib-dev build --distro autosd --format raw --mode package manifest.aib.yml disk.raw ``` diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 47b8ee73..bb4ea284 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -61,6 +61,8 @@ jobs: kind create cluster --name automotive-dev-e2e --wait 5m kubectl cluster-info kubectl get nodes + # Label node for OperatorConfig nodeSelector + kubectl label nodes --all aib=true - name: Install Tekton Pipelines run: | diff --git a/api/v1alpha1/imagebuild_types.go b/api/v1alpha1/imagebuild_types.go index a6a1db27..0d64d0bd 100644 --- a/api/v1alpha1/imagebuild_types.go +++ b/api/v1alpha1/imagebuild_types.go @@ -49,6 +49,23 @@ type ImageBuildSpec struct { // Export contains configuration for exporting build artifacts Export *ExportSpec `json:"export,omitempty"` + + // Flash contains configuration for flashing the built image to hardware via Jumpstarter + Flash *FlashSpec `json:"flash,omitempty"` +} + +// FlashSpec defines configuration for flashing images to hardware via Jumpstarter +// The exporter selector and flash command are derived from OperatorConfig's JumpstarterTargetMappings +// based on the AIB target field +type FlashSpec struct { + // ClientConfigSecretRef is the name of the secret containing the Jumpstarter client config + // The secret should have a key "client.yaml" with the config contents + // If set, flash is enabled automatically + ClientConfigSecretRef string `json:"clientConfigSecretRef,omitempty"` + + // LeaseDuration is the duration for the device lease in HH:MM:SS format + // +kubebuilder:default="03:00:00" + LeaseDuration string `json:"leaseDuration,omitempty"` } // AIBSpec defines the automotive-image-builder configuration @@ -125,7 +142,7 @@ type ImageBuildStatus struct { ObservedGeneration int64 `json:"observedGeneration,omitempty"` // Phase represents the current phase of the build (Building, Completed, Failed) - // +kubebuilder:validation:Enum=Pending;Uploading;Building;Pushing;Completed;Failed + // +kubebuilder:validation:Enum=Pending;Uploading;Building;Pushing;Flashing;Completed;Failed Phase string `json:"phase,omitempty"` // StartTime is when the build started @@ -146,6 +163,9 @@ type ImageBuildStatus struct { // PushTaskRunName is the name of the TaskRun for pushing artifacts to registry PushTaskRunName string `json:"pushTaskRunName,omitempty"` + // FlashTaskRunName is the name of the TaskRun for flashing to hardware + FlashTaskRunName string `json:"flashTaskRunName,omitempty"` + // Conditions represent the latest available observations of the ImageBuild's state // +optional Conditions []metav1.Condition `json:"conditions,omitempty"` @@ -160,6 +180,10 @@ type ImageBuildStatus struct { // This is particularly useful for bootc builds where the builder may be auto-generated // +optional BuilderImageUsed string `json:"builderImageUsed,omitempty"` + + // LeaseID is the Jumpstarter lease ID acquired during flash + // +optional + LeaseID string `json:"leaseId,omitempty"` } // +kubebuilder:object:root=true @@ -333,3 +357,24 @@ func (s *ImageBuildSpec) GetLegacyExportURL() string { // Return empty string to force an error that guides them to update return "" } + +// IsFlashEnabled returns true if flash is configured +func (s *ImageBuildSpec) IsFlashEnabled() bool { + return s.Flash != nil && s.Flash.ClientConfigSecretRef != "" +} + +// GetFlashClientConfigSecretRef returns the flash client config secret reference +func (s *ImageBuildSpec) GetFlashClientConfigSecretRef() string { + if s.Flash != nil { + return s.Flash.ClientConfigSecretRef + } + return "" +} + +// GetFlashLeaseDuration returns the flash lease duration, or default +func (s *ImageBuildSpec) GetFlashLeaseDuration() string { + if s.Flash != nil && s.Flash.LeaseDuration != "" { + return s.Flash.LeaseDuration + } + return "03:00:00" +} diff --git a/api/v1alpha1/operatorconfig_types.go b/api/v1alpha1/operatorconfig_types.go index 7f73ea4b..f00547e0 100644 --- a/api/v1alpha1/operatorconfig_types.go +++ b/api/v1alpha1/operatorconfig_types.go @@ -54,13 +54,29 @@ type JumpstarterTargetMapping struct { FlashCmd string `json:"flashCmd,omitempty"` } +// DefaultJumpstarterImage is the default container image for Jumpstarter CLI operations +const DefaultJumpstarterImage = "quay.io/jumpstarter-dev/jumpstarter:latest" + // JumpstarterConfig defines configuration for Jumpstarter device flashing integration type JumpstarterConfig struct { + // Image is the container image for Jumpstarter CLI operations + // +kubebuilder:default="quay.io/jumpstarter-dev/jumpstarter:latest" + // +optional + Image string `json:"image,omitempty"` + // TargetMappings maps build targets to Jumpstarter exporter configurations // +optional TargetMappings map[string]JumpstarterTargetMapping `json:"targetMappings,omitempty"` } +// GetJumpstarterImage returns the Jumpstarter image to use, falling back to the default +func (c *JumpstarterConfig) GetJumpstarterImage() string { + if c != nil && c.Image != "" { + return c.Image + } + return DefaultJumpstarterImage +} + // BuildAPIConfig defines configuration for the Build API server type BuildAPIConfig struct { // MaxManifestSize is the maximum allowed manifest size in bytes diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 5217c5b1..91ce1bb8 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -285,6 +285,21 @@ func (in *ExportSpec) DeepCopy() *ExportSpec { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *FlashSpec) DeepCopyInto(out *FlashSpec) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new FlashSpec. +func (in *FlashSpec) DeepCopy() *FlashSpec { + if in == nil { + return nil + } + out := new(FlashSpec) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *HardwareTarget) DeepCopyInto(out *HardwareTarget) { *out = *in @@ -399,6 +414,11 @@ func (in *ImageBuildSpec) DeepCopyInto(out *ImageBuildSpec) { *out = new(ExportSpec) (*in).DeepCopyInto(*out) } + if in.Flash != nil { + in, out := &in.Flash, &out.Flash + *out = new(FlashSpec) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ImageBuildSpec. diff --git a/bundle/manifests/automotive.sdv.cloud.redhat.com_imagebuilds.yaml b/bundle/manifests/automotive.sdv.cloud.redhat.com_imagebuilds.yaml index 1df7a8b7..69d9d0bd 100644 --- a/bundle/manifests/automotive.sdv.cloud.redhat.com_imagebuilds.yaml +++ b/bundle/manifests/automotive.sdv.cloud.redhat.com_imagebuilds.yaml @@ -125,6 +125,22 @@ spec: raw, qcow2, simg, or any AIB-supported format) type: string type: object + flash: + description: Flash contains configuration for flashing the built image + to hardware via Jumpstarter + properties: + clientConfigSecretRef: + description: |- + ClientConfigSecretRef is the name of the secret containing the Jumpstarter client config + The secret should have a key "client.yaml" with the config contents + If set, flash is enabled automatically + type: string + leaseDuration: + default: "03:00:00" + description: LeaseDuration is the duration for the device lease + in HH:MM:SS format + type: string + type: object pushSecretRef: description: |- PushSecretRef is the name of the kubernetes.io/dockerconfigjson secret for pushing artifacts @@ -218,6 +234,13 @@ spec: - type type: object type: array + flashTaskRunName: + description: FlashTaskRunName is the name of the TaskRun for flashing + to hardware + type: string + leaseId: + description: LeaseID is the Jumpstarter lease ID acquired during flash + type: string message: description: Message provides more detail about the current phase type: string @@ -234,6 +257,7 @@ spec: - Uploading - Building - Pushing + - Flashing - Completed - Failed type: string diff --git a/bundle/manifests/automotive.sdv.cloud.redhat.com_operatorconfigs.yaml b/bundle/manifests/automotive.sdv.cloud.redhat.com_operatorconfigs.yaml index b866f4ea..7cbc946a 100644 --- a/bundle/manifests/automotive.sdv.cloud.redhat.com_operatorconfigs.yaml +++ b/bundle/manifests/automotive.sdv.cloud.redhat.com_operatorconfigs.yaml @@ -81,6 +81,11 @@ spec: description: Jumpstarter defines configuration for Jumpstarter device flashing integration properties: + image: + default: quay.io/jumpstarter-dev/jumpstarter:latest + description: Image is the container image for Jumpstarter CLI + operations + type: string targetMappings: additionalProperties: description: JumpstarterTargetMapping defines the Jumpstarter diff --git a/cmd/caib/main.go b/cmd/caib/main.go index 64f8e095..b405d3ef 100644 --- a/cmd/caib/main.go +++ b/cmd/caib/main.go @@ -5,6 +5,7 @@ import ( "bufio" "compress/gzip" "context" + "encoding/base64" "encoding/json" "fmt" "io" @@ -32,8 +33,9 @@ import ( ) const ( - archAMD64 = "amd64" - archARM64 = "arm64" + archAMD64 = "amd64" + archARM64 = "arm64" + phaseFailed = "Failed" ) // getDefaultArch returns the current system architecture in caib format @@ -76,6 +78,13 @@ var ( builderImage string containerRef string + + // Flash options + flashAfterBuild bool + jumpstarterClient string + flashName string + exporterSelector string + leaseDuration string ) // createBuildAPIClient creates a build API client with authentication token from flags or kubeconfig @@ -254,6 +263,25 @@ Examples: Hidden: true, } + // 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", @@ -287,6 +315,10 @@ Examples: buildCmd.Flags().BoolVarP(&waitForBuild, "wait", "w", false, "wait for build to complete") buildCmd.Flags().BoolVarP(&followLogs, "follow", "f", true, "follow build logs") // 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)") listCmd.Flags().StringVar( &serverURL, "server", os.Getenv("CAIB_SERVER"), "REST API server base URL (e.g. https://api.example)", @@ -318,6 +350,10 @@ Examples: 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", true, "follow build logs") + // 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)") // build-dev command flags (traditional ostree/package builds) buildDevCmd.Flags().StringVar(&serverURL, "server", os.Getenv("CAIB_SERVER"), "REST API server base URL") @@ -341,9 +377,25 @@ Examples: 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", true, "follow build logs") + // 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)") + + // flash command flags + flashCmd.Flags().StringVar(&serverURL, "server", os.Getenv("CAIB_SERVER"), "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", true, "follow flash logs") + flashCmd.Flags().BoolVarP(&waitForBuild, "wait", "w", true, "wait for flash to complete") + _ = flashCmd.MarkFlagRequired("client") // Add all commands - rootCmd.AddCommand(buildCmd, diskCmd, buildDevCmd, listCmd, catalog.NewCatalogCmd()) + rootCmd.AddCommand(buildCmd, diskCmd, buildDevCmd, listCmd, flashCmd, catalog.NewCatalogCmd()) // Add deprecated aliases for backwards compatibility rootCmd.AddCommand(buildBootcAliasCmd, buildLegacyAliasCmd, buildTraditionalAliasCmd) @@ -427,6 +479,24 @@ func runBuild(_ *cobra.Command, args []string) { BuilderImage: builderImage, } + // Add flash configuration if enabled + if flashAfterBuild { + // Flash requires a disk image pushed to a registry + if exportOCI == "" { + 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 + } + if effectiveRegistryURL != "" && registryUsername != "" && registryPassword != "" { req.RegistryCredentials = &buildapitypes.RegistryCredentials{ Enabled: true, @@ -452,7 +522,7 @@ func runBuild(_ *cobra.Command, args []string) { handleFileUploads(ctx, api, resp.Name, localRefs) } - if waitForBuild || followLogs || outputDir != "" { + if waitForBuild || followLogs || outputDir != "" || flashAfterBuild { waitForBuildCompletion(ctx, api, resp.Name) } @@ -465,6 +535,10 @@ func runBuild(_ *cobra.Command, args []string) { } downloadOCIArtifactIfRequested(outputDir, exportOCI, registryUsername, registryPassword) + + // Note: When flashAfterBuild is enabled, flash config is sent with the build request + // and the controller handles flashing after push. The waitForBuildCompletion above + // will wait until the full pipeline (including flash) completes. } func runDisk(_ *cobra.Command, args []string) { @@ -519,6 +593,24 @@ func runDisk(_ *cobra.Command, args []string) { ExportOCI: exportOCI, } + // Add flash configuration if enabled + if flashAfterBuild { + // Flash requires a disk image pushed to a registry + if exportOCI == "" { + 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 + } + if effectiveRegistryURL != "" && registryUsername != "" && registryPassword != "" { req.RegistryCredentials = &buildapitypes.RegistryCredentials{ Enabled: true, @@ -535,16 +627,20 @@ func runDisk(_ *cobra.Command, args []string) { } fmt.Printf("Build %s accepted: %s - %s\n", resp.Name, resp.Phase, resp.Message) - if waitForBuild || followLogs || outputDir != "" { + if waitForBuild || followLogs || outputDir != "" || flashAfterBuild { waitForBuildCompletion(ctx, api, resp.Name) } // Show push location after successful build completion if exportOCI != "" { - fmt.Printf("✓ Disk image pushed to: %s\n", exportOCI) + fmt.Printf("Disk image pushed to: %s\n", exportOCI) } downloadOCIArtifactIfRequested(outputDir, exportOCI, registryUsername, registryPassword) + + // Note: When flashAfterBuild is enabled, flash config is sent with the build request + // and the controller handles flashing after push. The waitForBuildCompletion above + // will wait until the full pipeline (including flash) completes. } func pullOCIArtifact(ociRef, destPath, username, password string) error { @@ -784,6 +880,24 @@ func runBuildDev(_ *cobra.Command, args []string) { ExportOCI: exportOCI, } + // Add flash configuration if enabled + if flashAfterBuild { + // Flash requires a disk image pushed to a registry + if exportOCI == "" { + 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 + } + if effectiveRegistryURL != "" && registryUsername != "" && registryPassword != "" { req.RegistryCredentials = &buildapitypes.RegistryCredentials{ Enabled: true, @@ -809,10 +923,14 @@ func runBuildDev(_ *cobra.Command, args []string) { handleFileUploads(ctx, api, resp.Name, localRefs) } - if waitForBuild || followLogs || outputDir != "" { + if waitForBuild || followLogs || outputDir != "" || flashAfterBuild { waitForBuildCompletion(ctx, api, resp.Name) } downloadOCIArtifactIfRequested(outputDir, exportOCI, registryUsername, registryPassword) + + // Note: When flashAfterBuild is enabled, flash config is sent with the build request + // and the controller handles flashing after push. The waitForBuildCompletion above + // will wait until the full pipeline (including flash) completes. } func handleFileUploads( @@ -841,7 +959,7 @@ func handleFileUploads( if st.Phase == "Uploading" { break } - if st.Phase == "Failed" { + if st.Phase == phaseFailed { handleError(fmt.Errorf("build failed while waiting for upload server: %s", st.Message)) } } @@ -875,6 +993,7 @@ func handleFileUploads( 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) @@ -920,20 +1039,66 @@ func waitForBuildCompletion(ctx context.Context, api *buildapiclient.Client, nam // Handle terminal build states if st.Phase == "Completed" { - fmt.Println("Build completed successfully!") - if st.Jumpstarter != nil && st.Jumpstarter.Available { - fmt.Println("\nJumpstarter is available for device flashing.") - if st.Jumpstarter.ExporterSelector != "" { - fmt.Printf(" Exporter selector: %s\n", st.Jumpstarter.ExporterSelector) + flashWasExecuted := strings.Contains(st.Message, "flash") + if flashWasExecuted { + fmt.Println("\n" + strings.Repeat("=", 50)) + fmt.Println("Build and flash completed successfully!") + fmt.Println(strings.Repeat("=", 50)) + fmt.Println("\nThe 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 st.Jumpstarter.FlashCmd != "" { - fmt.Printf(" Flash command: %s\n", st.Jumpstarter.FlashCmd) + if leaseID != "" { + fmt.Printf("\nLease ID: %s\n", leaseID) + fmt.Println("\nTo access the device:") + fmt.Printf(" jmp shell --lease %s\n", leaseID) + fmt.Println("\nTo release the lease when done:") + fmt.Printf(" jmp delete leases %s\n", leaseID) + } else { + fmt.Println("Check the logs above for lease details, or use:") + fmt.Println(" jmp list leases") + fmt.Println("\nTo access the device:") + fmt.Println(" jmp shell --lease ") + fmt.Println("\nTo release the lease when done:") + fmt.Println(" 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.") + } + if st.Jumpstarter != nil && st.Jumpstarter.Available { + fmt.Println("\nJumpstarter is available") + if st.Jumpstarter.ExporterSelector != "" { + fmt.Println("matching exporter(s) found") + fmt.Printf(" Exporter selector: %s\n", st.Jumpstarter.ExporterSelector) + } + if st.Jumpstarter.FlashCmd != "" { + fmt.Printf(" Flash command: %s\n", st.Jumpstarter.FlashCmd) + } } } return } - if st.Phase == "Failed" { - handleError(fmt.Errorf("build failed: %s", st.Message)) + if st.Phase == phaseFailed { + // Provide phase-specific error messages + errPrefix := "build" + if strings.Contains(strings.ToLower(st.Message), "flash") { + errPrefix = "flash" + } else if strings.Contains(strings.ToLower(st.Message), "push") { + errPrefix = "push" + } else if lastPhase == "Flashing" { + errPrefix = "flash" + } else if lastPhase == "Pushing" { + errPrefix = "push" + } + handleError(fmt.Errorf("%s failed: %s", errPrefix, st.Message)) } // Attempt log streaming for active builds @@ -978,7 +1143,8 @@ type logStreamState struct { retryCount int warningShown bool startTime time.Time - completed bool // Set when stream ends normally, prevents reconnection + 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 @@ -993,7 +1159,7 @@ func (s *logStreamState) reset() { } func isBuildActive(phase string) bool { - return phase == "Building" || phase == "Running" || phase == "Uploading" + return phase == "Building" || phase == "Running" || phase == "Uploading" || phase == "Flashing" } // tryLogStreaming attempts to stream logs and returns error if it fails @@ -1043,7 +1209,29 @@ func streamLogsToStdout(body io.Reader, state *logStreamState) error { scanner := bufio.NewScanner(body) scanner.Buffer(make([]byte, 64*1024), 1024*1024) // Handle long lines for scanner.Scan() { - fmt.Println(scanner.Text()) + line := scanner.Text() + fmt.Println(line) + + // 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 @@ -1335,3 +1523,176 @@ func loadTokenFromKubeconfig() (string, error) { } 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 + _, _ = fmt.Sscanf(parts[0], "%d", &hours) + _, _ = fmt.Sscanf(parts[1], "%d", &mins) + _, _ = fmt.Sscanf(parts[2], "%d", &secs) + return time.Duration(hours)*time.Hour + time.Duration(mins)*time.Minute + time.Duration(secs)*time.Second +} + +// runFlash handles the standalone 'flash' command +func runFlash(_ *cobra.Command, args []string) { + ctx := context.Background() + imageRef := args[0] + + if serverURL == "" { + handleError(fmt.Errorf("--server is required (or set CAIB_SERVER env)")) + } + + 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 + + logClient := &http.Client{ + Timeout: 10 * time.Minute, + Transport: &http.Transport{ + ResponseHeaderTimeout: 30 * time.Second, + IdleConnTimeout: 2 * time.Minute, + }, + } + 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 == "Completed" { + 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 == "Pending" { + streamState.reset() + if !pendingWarningShown { + fmt.Println("Waiting for flash to start before streaming logs...") + pendingWarningShown = true + } + continue + } + + if st.Phase == "Running" { + 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) +} diff --git a/config/crd/bases/automotive.sdv.cloud.redhat.com_imagebuilds.yaml b/config/crd/bases/automotive.sdv.cloud.redhat.com_imagebuilds.yaml index f56af034..76265eaf 100644 --- a/config/crd/bases/automotive.sdv.cloud.redhat.com_imagebuilds.yaml +++ b/config/crd/bases/automotive.sdv.cloud.redhat.com_imagebuilds.yaml @@ -125,6 +125,22 @@ spec: raw, qcow2, simg, or any AIB-supported format) type: string type: object + flash: + description: Flash contains configuration for flashing the built image + to hardware via Jumpstarter + properties: + clientConfigSecretRef: + description: |- + ClientConfigSecretRef is the name of the secret containing the Jumpstarter client config + The secret should have a key "client.yaml" with the config contents + If set, flash is enabled automatically + type: string + leaseDuration: + default: "03:00:00" + description: LeaseDuration is the duration for the device lease + in HH:MM:SS format + type: string + type: object pushSecretRef: description: |- PushSecretRef is the name of the kubernetes.io/dockerconfigjson secret for pushing artifacts @@ -218,6 +234,13 @@ spec: - type type: object type: array + flashTaskRunName: + description: FlashTaskRunName is the name of the TaskRun for flashing + to hardware + type: string + leaseId: + description: LeaseID is the Jumpstarter lease ID acquired during flash + type: string message: description: Message provides more detail about the current phase type: string @@ -234,6 +257,7 @@ spec: - Uploading - Building - Pushing + - Flashing - Completed - Failed type: string diff --git a/config/crd/bases/automotive.sdv.cloud.redhat.com_operatorconfigs.yaml b/config/crd/bases/automotive.sdv.cloud.redhat.com_operatorconfigs.yaml index 2efc85af..f49a7e30 100644 --- a/config/crd/bases/automotive.sdv.cloud.redhat.com_operatorconfigs.yaml +++ b/config/crd/bases/automotive.sdv.cloud.redhat.com_operatorconfigs.yaml @@ -81,6 +81,11 @@ spec: description: Jumpstarter defines configuration for Jumpstarter device flashing integration properties: + image: + default: quay.io/jumpstarter-dev/jumpstarter:latest + description: Image is the container image for Jumpstarter CLI + operations + type: string targetMappings: additionalProperties: description: JumpstarterTargetMapping defines the Jumpstarter diff --git a/config/samples/automotive_v1_image_http.yaml b/config/samples/automotive_v1_image_http.yaml index 9af23f24..4dbef4ea 100644 --- a/config/samples/automotive_v1_image_http.yaml +++ b/config/samples/automotive_v1_image_http.yaml @@ -43,7 +43,7 @@ metadata: app.kubernetes.io/managed-by: kustomize name: image-registry-sample-3 spec: - distro: "cs9" + distro: "autosd" target: "edge-commit" architecture: "x86_64" exportFormat: "image" diff --git a/config/samples/automotive_v1_operatorconfig.yaml b/config/samples/automotive_v1_operatorconfig.yaml index c8594c7d..32ec8fe1 100644 --- a/config/samples/automotive_v1_operatorconfig.yaml +++ b/config/samples/automotive_v1_operatorconfig.yaml @@ -26,13 +26,8 @@ spec: # Optional: Node selector for scheduling build pods to specific nodes # Build pods will only run on nodes with matching labels - # Common use cases: - # - Route builds to dedicated build nodes: {"workload": "builds"} - # - Use nodes with fast storage: {"disktype": "ssd"} - # - Target specific node pools: {"node-pool": "compute-intensive"} - # nodeSelector: - # dedicated: "builds" - # disktype: "ssd" + nodeSelector: + aib: "true" # Optional: Tolerations for exclusive access to tainted nodes # Enables scheduling on nodes tainted for dedicated automotive builds diff --git a/go.mod b/go.mod index e18b7627..19abb3b3 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,6 @@ require ( github.com/gin-gonic/gin v1.10.1 github.com/onsi/ginkgo/v2 v2.23.4 github.com/onsi/gomega v1.36.3 - github.com/schollz/progressbar/v3 v3.18.0 k8s.io/apimachinery v0.33.3 k8s.io/client-go v0.33.3 sigs.k8s.io/controller-runtime v0.19.1 @@ -60,7 +59,6 @@ require ( github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mattn/go-sqlite3 v1.14.28 // indirect github.com/miekg/pkcs11 v1.1.1 // indirect - github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect github.com/moby/spdystream v0.5.0 // indirect github.com/moby/sys/capability v0.4.0 // indirect github.com/moby/sys/mountinfo v0.7.2 // indirect @@ -133,7 +131,7 @@ require ( github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/openshift/api v0.0.0-20250725072657-92b1455121e1 github.com/pkg/errors v0.9.1 // indirect - github.com/prometheus/client_golang v1.22.0 // indirect + github.com/prometheus/client_golang v1.22.0 github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.65.0 // indirect github.com/prometheus/procfs v0.17.0 // indirect @@ -172,7 +170,7 @@ require ( gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 k8s.io/api v0.33.3 - k8s.io/apiextensions-apiserver v0.33.1 // indirect + k8s.io/apiextensions-apiserver v0.33.1 k8s.io/component-base v0.33.3 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kube-openapi v0.0.0-20250701173324-9bd5c66d9911 // indirect diff --git a/go.sum b/go.sum index 0c8d59a0..2f74630b 100644 --- a/go.sum +++ b/go.sum @@ -81,8 +81,6 @@ github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7mk9/PwM= -github.com/chengxilo/virtualterm v1.0.4/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0iwWCOK1q10rlY= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= @@ -335,8 +333,6 @@ github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxU github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/miekg/pkcs11 v1.1.1 h1:Ugu9pdy6vAYku5DEpVWVFPYnzV+bxB+iRdbuFSu7TvU= github.com/miekg/pkcs11 v1.1.1/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs= -github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ= -github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw= github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU= github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= github.com/moby/sys/capability v0.4.0 h1:4D4mI6KlNtWMCM1Z/K0i7RV1FkX+DBDHKVJpCndZoHk= @@ -427,8 +423,6 @@ github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWN github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= -github.com/schollz/progressbar/v3 v3.18.0 h1:uXdoHABRFmNIjUfte/Ex7WtuyVslrw2wVPQmCN62HpA= -github.com/schollz/progressbar/v3 v3.18.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec= github.com/secure-systems-lab/go-securesystemslib v0.9.0 h1:rf1HIbL64nUpEIZnjLZ3mcNEL9NBPB0iuVjyxvq3LZc= github.com/secure-systems-lab/go-securesystemslib v0.9.0/go.mod h1:DVHKMcZ+V4/woA/peqr+L0joiRXbPpQ042GgJckkFgw= github.com/sigstore/fulcio v1.6.6 h1:XaMYX6TNT+8n7Npe8D94nyZ7/ERjEsNGFC+REdi/wzw= diff --git a/hack/deploy-catalog.sh b/hack/deploy-catalog.sh index 4971ebf1..68c8f9dc 100755 --- a/hack/deploy-catalog.sh +++ b/hack/deploy-catalog.sh @@ -67,9 +67,8 @@ echo "Using OpenShift internal registry: ${INTERNAL_REGISTRY}" REGISTRY=${REGISTRY:-${INTERNAL_REGISTRY}} CATALOG_NAMESPACE=${CATALOG_NAMESPACE:-openshift-marketplace} -# Use git SHA for unique operator image tag to avoid node-level caching issues -GIT_SHA=$(git rev-parse --short HEAD 2>/dev/null || echo "dev") -OPERATOR_TAG="${GIT_SHA}-$(date +%s)" +# Use 'latest' tag - Kubernetes defaults to imagePullPolicy: Always for latest +OPERATOR_TAG="latest" OPERATOR_IMG="${REGISTRY}/${NAMESPACE}/automotive-dev-operator:${OPERATOR_TAG}" BUNDLE_IMG="${REGISTRY}/${CATALOG_NAMESPACE}/automotive-dev-operator-bundle:v${VERSION}" @@ -208,10 +207,13 @@ echo "Generating bundle..." make bundle IMG=${OPERATOR_IMG} VERSION=${VERSION} echo "" -echo "Fixing OPERATOR_IMAGE env var in bundle..." -# The bundle generator doesn't replace env var values, only container images -# We need to manually update the OPERATOR_IMAGE env var to use the internal registry with unique tag +echo "Fixing image references in bundle to use internal registry..." +# The bundle generator uses the external registry route for container images, +# but pods can't authenticate to the external route. Replace ALL image references +# (both container images and env var values) with the internal service URL. OPERATOR_IMG_INTERNAL="image-registry.openshift-image-registry.svc:5000/${NAMESPACE}/automotive-dev-operator:${OPERATOR_TAG}" +sed -i.bak "s|${REGISTRY}/${NAMESPACE}/automotive-dev-operator:${OPERATOR_TAG}|${OPERATOR_IMG_INTERNAL}|g" bundle/manifests/automotive-dev-operator.clusterserviceversion.yaml +# Also fix the legacy controller:latest pattern if it exists sed -i.bak "s|value: controller:latest|value: ${OPERATOR_IMG_INTERNAL}|g" bundle/manifests/automotive-dev-operator.clusterserviceversion.yaml rm -f bundle/manifests/automotive-dev-operator.clusterserviceversion.yaml.bak diff --git a/internal/buildapi/client/client.go b/internal/buildapi/client/client.go index 0e89748e..8ddbd175 100644 --- a/internal/buildapi/client/client.go +++ b/internal/buildapi/client/client.go @@ -53,6 +53,8 @@ func WithHTTPClient(h *http.Client) Option { return func(c *Client) { c.httpClie func WithAuthToken(t string) Option { return func(c *Client) { c.authToken = t } } // CreateBuild submits a new build request to the API server. +// +//nolint:dupl // Build and Flash methods are intentionally similar but work with different types func (c *Client) CreateBuild(ctx context.Context, req buildapi.BuildRequest) (*buildapi.BuildResponse, error) { body, err := json.Marshal(req) if err != nil { @@ -158,6 +160,103 @@ func (c *Client) resolve(p string) string { return u.String() } +// CreateFlash submits a new flash request to the API server. +// +//nolint:dupl // Build and Flash methods are intentionally similar but work with different types +func (c *Client) CreateFlash(ctx context.Context, req buildapi.FlashRequest) (*buildapi.FlashResponse, error) { + body, err := json.Marshal(req) + if err != nil { + return nil, err + } + endpoint := c.resolve("/v1/flash") + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return nil, err + } + httpReq.Header.Set("Content-Type", "application/json") + if c.authToken != "" { + httpReq.Header.Set("Authorization", "Bearer "+c.authToken) + } + resp, err := c.httpClient.Do(httpReq) + if err != nil { + return nil, 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.StatusAccepted && resp.StatusCode != http.StatusOK { + b, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return nil, fmt.Errorf("create flash failed: %s: %s", resp.Status, string(b)) + } + var out buildapi.FlashResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, err + } + return &out, nil +} + +// GetFlash retrieves the status of a specific flash job by name. +func (c *Client) GetFlash(ctx context.Context, name string) (*buildapi.FlashResponse, error) { + endpoint := c.resolve(path.Join("/v1/flash", url.PathEscape(name))) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + if c.authToken != "" { + req.Header.Set("Authorization", "Bearer "+c.authToken) + } + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, 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 { + b, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return nil, fmt.Errorf("get flash failed: %s: %s", resp.Status, string(b)) + } + var out buildapi.FlashResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, err + } + return &out, nil +} + +// ListFlash retrieves a list of all flash jobs from the API server. +func (c *Client) ListFlash(ctx context.Context) ([]buildapi.FlashListItem, error) { + endpoint := c.resolve("/v1/flash") + req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + if c.authToken != "" { + req.Header.Set("Authorization", "Bearer "+c.authToken) + } + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, 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 { + b, _ := io.ReadAll(io.LimitReader(resp.Body, 1024)) + return nil, fmt.Errorf("list flash failed: %s: %s", resp.Status, string(b)) + } + var out []buildapi.FlashListItem + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, err + } + return out, nil +} + // Upload represents a file to upload to the build API. type Upload struct { SourcePath string diff --git a/internal/buildapi/server.go b/internal/buildapi/server.go index ece236a2..e6a1230f 100644 --- a/internal/buildapi/server.go +++ b/internal/buildapi/server.go @@ -13,6 +13,7 @@ import ( "net/http" "os" "path" + "sort" "strings" "time" @@ -33,6 +34,8 @@ import ( automotivev1alpha1 "github.com/centos-automotive-suite/automotive-dev-operator/api/v1alpha1" "github.com/centos-automotive-suite/automotive-dev-operator/internal/buildapi/catalog" + "github.com/centos-automotive-suite/automotive-dev-operator/internal/common/tasks" + tektonv1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" authnv1 "k8s.io/api/authentication/v1" ) @@ -40,6 +43,8 @@ const ( // Build phase constants phaseCompleted = "Completed" phaseFailed = "Failed" + phasePending = "Pending" + phaseRunning = "Running" // Image format and compression constants formatImage = "image" @@ -50,6 +55,9 @@ const ( statusUnknown = "unknown" statusMissing = "MISSING" buildAPIName = "ado-build-api" + + // Flash TaskRun constants + flashTaskRunLabel = "automotive.sdv.cloud.redhat.com/flash-taskrun" ) // APILimits holds configurable limits for the API server @@ -223,6 +231,15 @@ func (a *APIServer) createRouter() *gin.Engine { buildsGroup.POST("/:name/uploads", a.handleUploadFiles) } + flashGroup := v1.Group("/flash") + flashGroup.Use(a.authMiddleware()) + { + flashGroup.POST("", a.handleCreateFlash) + flashGroup.GET("", a.handleListFlash) + flashGroup.GET("/:name", a.handleGetFlash) + flashGroup.GET("/:name/logs", a.handleFlashLogs) + } + // Register catalog routes with authentication catalogClient, err := a.getCatalogClient() if err != nil { @@ -504,6 +521,18 @@ func (a *APIServer) streamLogs(c *gin.Context, name string) { continue } + // Sort pods by start time so logs appear in execution order + sort.Slice(pods.Items, func(i, j int) bool { + // Pods without start time go last + if pods.Items[i].Status.StartTime == nil { + return false + } + if pods.Items[j].Status.StartTime == nil { + return true + } + return pods.Items[i].Status.StartTime.Before(pods.Items[j].Status.StartTime) + }) + allPodsComplete := true for _, pod := range pods.Items { if completedPods[pod.Name] { @@ -525,13 +554,12 @@ func (a *APIServer) streamLogs(c *gin.Context, name string) { } } - if err := k8sClient.Get(ctx, types.NamespacedName{Name: name, Namespace: namespace}, ib); err == nil { - if ib.Status.Phase == phaseCompleted || ib.Status.Phase == phaseFailed { - break - } + // Check if build is complete AND all pod logs have been streamed + if shouldExitLogStream(ctx, k8sClient, name, namespace, ib, allPodsComplete) { + break } - if !hadStream || allPodsComplete { + if !hadStream || !allPodsComplete { time.Sleep(2 * time.Second) } if !hadStream { @@ -542,6 +570,27 @@ func (a *APIServer) streamLogs(c *gin.Context, name string) { } } + writeLogStreamFooter(c, hadStream) +} + +// shouldExitLogStream checks if the log streaming loop should exit +func shouldExitLogStream( + ctx context.Context, + k8sClient client.Client, + name, namespace string, + ib *automotivev1alpha1.ImageBuild, + allPodsComplete bool, +) bool { + if err := k8sClient.Get(ctx, types.NamespacedName{Name: name, Namespace: namespace}, ib); err == nil { + if (ib.Status.Phase == phaseCompleted || ib.Status.Phase == phaseFailed) && allPodsComplete { + return true + } + } + return false +} + +// writeLogStreamFooter writes the final message after log streaming ends +func writeLogStreamFooter(c *gin.Context, hadStream bool) { if !hadStream { _, _ = c.Writer.Write([]byte("\n[No logs available]\n")) } else { @@ -967,6 +1016,24 @@ func (a *APIServer) createBuild(c *gin.Context) { return } + var flashSpec *automotivev1alpha1.FlashSpec + var flashSecretName string + if req.FlashEnabled { + if req.FlashClientConfig == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "flash enabled but client config is required"}) + return + } + flashSecretName = req.Name + "-jumpstarter-client" + if err := createFlashClientSecret(ctx, k8sClient, namespace, flashSecretName, req.FlashClientConfig); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("error creating flash client secret: %v", err)}) + return + } + flashSpec = &automotivev1alpha1.FlashSpec{ + ClientConfigSecretRef: flashSecretName, + LeaseDuration: req.FlashLeaseDuration, + } + } + imageBuild := &automotivev1alpha1.ImageBuild{ ObjectMeta: metav1.ObjectMeta{ Name: req.Name, @@ -983,6 +1050,7 @@ func (a *APIServer) createBuild(c *gin.Context) { PushSecretRef: pushSecretName, AIB: buildAIBSpec(&req, cfgName, needsUpload), Export: buildExportSpec(&req), + Flash: flashSpec, }, } if err := k8sClient.Create(ctx, imageBuild); err != nil { @@ -1018,6 +1086,16 @@ func (a *APIServer) createBuild(c *gin.Context) { } } + if flashSecretName != "" { + if err := setSecretOwnerRef(ctx, k8sClient, namespace, flashSecretName, imageBuild); err != nil { + log.Printf( + "WARNING: failed to set owner reference on flash client secret %s: %v "+ + "(cleanup may require manual intervention)", + flashSecretName, err, + ) + } + } + writeJSON(c, http.StatusAccepted, BuildResponse{ Name: req.Name, Phase: "Building", @@ -1090,6 +1168,10 @@ func getBuild(c *gin.Context, name string) { if err := k8sClient.Get(ctx, types.NamespacedName{Name: "config", Namespace: namespace}, operatorConfig); err == nil { if operatorConfig.Status.JumpstarterAvailable { jumpstarterInfo = &JumpstarterInfo{Available: true} + // Include lease ID if flash was executed + if build.Status.LeaseID != "" { + jumpstarterInfo.LeaseID = build.Status.LeaseID + } if operatorConfig.Spec.Jumpstarter != nil { if mapping, ok := operatorConfig.Spec.Jumpstarter.TargetMappings[build.Spec.GetTarget()]; ok { jumpstarterInfo.ExporterSelector = mapping.Selector @@ -1457,6 +1539,37 @@ func setSecretOwnerRef( return c.Update(ctx, secret) } +// createFlashClientSecret creates a secret containing the Jumpstarter client config +func createFlashClientSecret( + ctx context.Context, + c client.Client, + namespace, secretName, base64Config string, +) error { + // Decode base64 client config + configBytes, err := base64.StdEncoding.DecodeString(base64Config) + if err != nil { + return fmt.Errorf("failed to decode client config: %w", err) + } + + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: secretName, + Namespace: namespace, + Labels: map[string]string{ + "app.kubernetes.io/managed-by": "build-api", + "app.kubernetes.io/part-of": "automotive-dev", + "app.kubernetes.io/component": "jumpstarter-client", + }, + }, + Type: corev1.SecretTypeOpaque, + Data: map[string][]byte{ + "client.yaml": configBytes, + }, + } + + return c.Create(ctx, secret) +} + func writeJSON(c *gin.Context, status int, v any) { c.Header("Cache-Control", "no-store") c.IndentedJSON(status, v) @@ -1515,6 +1628,9 @@ func getClientFromRequest(c *gin.Context) (client.Client, error) { if err := corev1.AddToScheme(scheme); err != nil { return nil, fmt.Errorf("failed to add core scheme: %w", err) } + if err := tektonv1.AddToScheme(scheme); err != nil { + return nil, fmt.Errorf("failed to add tekton scheme: %w", err) + } k8sClient, err := client.New(cfg, client.Options{Scheme: scheme}) if err != nil { @@ -1578,3 +1694,424 @@ func resolveRequester(c *gin.Context) string { return res.Status.User.Username } + +// Flash API handlers + +func (a *APIServer) handleCreateFlash(c *gin.Context) { + a.log.Info("create flash", "reqID", c.GetString("reqID")) + a.createFlash(c) +} + +func (a *APIServer) handleListFlash(c *gin.Context) { + a.log.Info("list flash jobs", "reqID", c.GetString("reqID")) + a.listFlash(c) +} + +func (a *APIServer) handleGetFlash(c *gin.Context) { + name := c.Param("name") + a.log.Info("get flash", "flash", name, "reqID", c.GetString("reqID")) + a.getFlash(c, name) +} + +func (a *APIServer) handleFlashLogs(c *gin.Context) { + name := c.Param("name") + a.log.Info("flash logs requested", "flash", name, "reqID", c.GetString("reqID")) + a.streamFlashLogs(c, name) +} + +func (a *APIServer) createFlash(c *gin.Context) { + var req FlashRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid JSON request"}) + return + } + + // Validate required fields + if req.ImageRef == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "imageRef is required"}) + return + } + if req.ClientConfig == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "clientConfig is required"}) + return + } + + // Auto-generate name if not provided + if req.Name == "" { + req.Name = fmt.Sprintf("flash-%s", time.Now().Format("20060102-150405")) + } + + // Validate name + if err := validateBuildName(req.Name); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + k8sClient, err := getClientFromRequest(c) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("k8s client error: %v", err)}) + return + } + + restCfg, err := getRESTConfigFromRequest(c) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + clientset, err := kubernetes.NewForConfig(restCfg) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + ctx := c.Request.Context() + namespace := resolveNamespace() + requestedBy := resolveRequester(c) + + // Get exporter selector from OperatorConfig if target is specified + exporterSelector := req.ExporterSelector + flashCmd := req.FlashCmd + if req.Target != "" && exporterSelector == "" { + operatorConfig := &automotivev1alpha1.OperatorConfig{} + if err := k8sClient.Get(ctx, types.NamespacedName{Name: "config", Namespace: namespace}, operatorConfig); err == nil { + if operatorConfig.Spec.Jumpstarter != nil { + if mapping, ok := operatorConfig.Spec.Jumpstarter.TargetMappings[req.Target]; ok { + exporterSelector = mapping.Selector + if flashCmd == "" { + flashCmd = mapping.FlashCmd + } + } + } + } + } + + if exporterSelector == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "exporterSelector or valid target is required"}) + return + } + + // Replace placeholders in flash command + if flashCmd != "" { + flashCmd = strings.ReplaceAll(flashCmd, "{image_uri}", req.ImageRef) + flashCmd = strings.ReplaceAll(flashCmd, "{artifact_url}", req.ImageRef) + } + + // Decode client config to verify it's valid base64 + clientConfigBytes, err := base64.StdEncoding.DecodeString(req.ClientConfig) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "clientConfig must be base64 encoded"}) + return + } + + // Create secret for client config + secretName := fmt.Sprintf("%s-jumpstarter-client", req.Name) + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: secretName, + Namespace: namespace, + Labels: map[string]string{ + "app.kubernetes.io/managed-by": "build-api", + "app.kubernetes.io/part-of": "automotive-dev", + flashTaskRunLabel: req.Name, + "automotive.sdv.cloud.redhat.com/resource-type": "jumpstarter-client", + }, + }, + Type: corev1.SecretTypeOpaque, + Data: map[string][]byte{ + "client.yaml": clientConfigBytes, + }, + } + + createdSecret, err := clientset.CoreV1().Secrets(namespace).Create(ctx, secret, metav1.CreateOptions{}) + if err != nil { + if k8serrors.IsAlreadyExists(err) { + c.JSON(http.StatusConflict, gin.H{"error": fmt.Sprintf("flash %s already exists", req.Name)}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to create secret: %v", err)}) + return + } + + // Get the flash task spec + flashTask := tasks.GenerateFlashTask(namespace) + + // Lease duration + leaseDuration := req.LeaseDuration + if leaseDuration == "" { + leaseDuration = "03:00:00" // Default 3 hours + } + + // Create the flash TaskRun + taskRun := &tektonv1.TaskRun{ + ObjectMeta: metav1.ObjectMeta{ + Name: req.Name, + Namespace: namespace, + Labels: map[string]string{ + "app.kubernetes.io/managed-by": "build-api", + "app.kubernetes.io/part-of": "automotive-dev", + "app.kubernetes.io/name": "flash-taskrun", + flashTaskRunLabel: req.Name, + }, + Annotations: map[string]string{ + "automotive.sdv.cloud.redhat.com/requested-by": requestedBy, + "automotive.sdv.cloud.redhat.com/image-ref": req.ImageRef, + }, + }, + Spec: tektonv1.TaskRunSpec{ + TaskSpec: &flashTask.Spec, + Params: []tektonv1.Param{ + {Name: "image-ref", Value: tektonv1.ParamValue{Type: tektonv1.ParamTypeString, StringVal: req.ImageRef}}, + {Name: "exporter-selector", Value: tektonv1.ParamValue{Type: tektonv1.ParamTypeString, StringVal: exporterSelector}}, + {Name: "flash-cmd", Value: tektonv1.ParamValue{Type: tektonv1.ParamTypeString, StringVal: flashCmd}}, + {Name: "lease-duration", Value: tektonv1.ParamValue{Type: tektonv1.ParamTypeString, StringVal: leaseDuration}}, + }, + Workspaces: []tektonv1.WorkspaceBinding{ + { + Name: "jumpstarter-client", + Secret: &corev1.SecretVolumeSource{ + SecretName: secretName, + }, + }, + }, + }, + } + + if err := k8sClient.Create(ctx, taskRun); err != nil { + // Clean up the secret if TaskRun creation fails + _ = clientset.CoreV1().Secrets(namespace).Delete(ctx, secretName, metav1.DeleteOptions{}) + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to create flash TaskRun: %v", err)}) + return + } + + // Set owner reference on secret for automatic cleanup + createdSecret.OwnerReferences = []metav1.OwnerReference{ + { + APIVersion: "tekton.dev/v1", + Kind: "TaskRun", + Name: taskRun.Name, + UID: taskRun.UID, + }, + } + if _, err := clientset.CoreV1().Secrets(namespace).Update(ctx, createdSecret, metav1.UpdateOptions{}); err != nil { + log.Printf("WARNING: failed to set owner reference on secret %s: %v", secretName, err) + } + + writeJSON(c, http.StatusAccepted, FlashResponse{ + Name: req.Name, + Phase: phasePending, + Message: "Flash TaskRun created", + RequestedBy: requestedBy, + TaskRunName: taskRun.Name, + }) +} + +func (a *APIServer) listFlash(c *gin.Context) { + namespace := resolveNamespace() + + k8sClient, err := getClientFromRequest(c) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("k8s client error: %v", err)}) + return + } + + ctx := c.Request.Context() + + // List TaskRuns with flash label + taskRunList := &tektonv1.TaskRunList{} + if err := k8sClient.List(ctx, taskRunList, client.InNamespace(namespace), client.HasLabels{flashTaskRunLabel}); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to list flash TaskRuns: %v", err)}) + return + } + + resp := make([]FlashListItem, 0, len(taskRunList.Items)) + for _, tr := range taskRunList.Items { + phase, message := getTaskRunStatus(&tr) + var compStr string + if tr.Status.CompletionTime != nil { + compStr = tr.Status.CompletionTime.Format(time.RFC3339) + } + resp = append(resp, FlashListItem{ + Name: tr.Name, + Phase: phase, + Message: message, + RequestedBy: tr.Annotations["automotive.sdv.cloud.redhat.com/requested-by"], + CreatedAt: tr.CreationTimestamp.Format(time.RFC3339), + CompletionTime: compStr, + }) + } + writeJSON(c, http.StatusOK, resp) +} + +func (a *APIServer) getFlash(c *gin.Context, name string) { + namespace := resolveNamespace() + + k8sClient, err := getClientFromRequest(c) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("k8s client error: %v", err)}) + return + } + + ctx := c.Request.Context() + taskRun := &tektonv1.TaskRun{} + if err := k8sClient.Get(ctx, types.NamespacedName{Name: name, Namespace: namespace}, taskRun); err != nil { + if k8serrors.IsNotFound(err) { + c.JSON(http.StatusNotFound, gin.H{"error": "flash TaskRun not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to get flash TaskRun: %v", err)}) + return + } + + // Verify it's a flash TaskRun + if taskRun.Labels[flashTaskRunLabel] == "" { + c.JSON(http.StatusNotFound, gin.H{"error": "flash TaskRun not found"}) + return + } + + phase, message := getTaskRunStatus(taskRun) + var startStr, compStr string + if taskRun.Status.StartTime != nil { + startStr = taskRun.Status.StartTime.Format(time.RFC3339) + } + if taskRun.Status.CompletionTime != nil { + compStr = taskRun.Status.CompletionTime.Format(time.RFC3339) + } + + writeJSON(c, http.StatusOK, FlashResponse{ + Name: taskRun.Name, + Phase: phase, + Message: message, + RequestedBy: taskRun.Annotations["automotive.sdv.cloud.redhat.com/requested-by"], + StartTime: startStr, + CompletionTime: compStr, + TaskRunName: taskRun.Name, + }) +} + +func getTaskRunStatus(tr *tektonv1.TaskRun) (phase, message string) { + // Check if completed + if tr.Status.CompletionTime != nil { + // Check conditions for success/failure + for _, cond := range tr.Status.Conditions { + if cond.Type == "Succeeded" { + if cond.Status == corev1.ConditionTrue { + return phaseCompleted, "Flash completed successfully" + } + return phaseFailed, cond.Message + } + } + return phaseFailed, "Flash failed" + } + + // Check if running + if tr.Status.StartTime != nil { + return phaseRunning, "Flash in progress" + } + + return phasePending, "Waiting to start" +} + +func (a *APIServer) streamFlashLogs(c *gin.Context, name string) { + namespace := resolveNamespace() + + k8sClient, err := getClientFromRequest(c) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("k8s client error: %v", err)}) + return + } + + restCfg, err := getRESTConfigFromRequest(c) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + clientset, err := kubernetes.NewForConfig(restCfg) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + ctx := c.Request.Context() + + // Verify the TaskRun exists and is a flash TaskRun + taskRun := &tektonv1.TaskRun{} + if err := k8sClient.Get(ctx, types.NamespacedName{Name: name, Namespace: namespace}, taskRun); err != nil { + if k8serrors.IsNotFound(err) { + c.JSON(http.StatusNotFound, gin.H{"error": "flash TaskRun not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to get flash TaskRun: %v", err)}) + return + } + if taskRun.Labels[flashTaskRunLabel] == "" { + c.JSON(http.StatusNotFound, gin.H{"error": "flash TaskRun not found"}) + return + } + + sinceTime := parseSinceTime(c.Query("since")) + streamDuration := time.Duration(a.limits.MaxLogStreamDurationMinutes) * time.Minute + streamCtx, cancel := context.WithTimeout(ctx, streamDuration) + defer cancel() + + // Get the pod name from TaskRun status + podName := taskRun.Status.PodName + if podName == "" { + c.JSON(http.StatusServiceUnavailable, gin.H{"error": "flash pod not ready"}) + return + } + + setupLogStreamHeaders(c) + + // TaskRun pods use step containers with naming convention "step-" + containerName := "step-flash" + + // Stream logs + req := clientset.CoreV1().Pods(namespace).GetLogs(podName, &corev1.PodLogOptions{ + Container: containerName, + Follow: true, + SinceTime: sinceTime, + }) + stream, err := req.Stream(streamCtx) + if err != nil { + _, _ = fmt.Fprintf(c.Writer, "\n[Error streaming logs: %v]\n", err) + c.Writer.Flush() + return + } + defer func() { + if err := stream.Close(); err != nil { + fmt.Fprintf(os.Stderr, "Warning: failed to close stream: %v\n", err) + } + }() + + _, _ = c.Writer.Write([]byte("\n===== Flash TaskRun Logs =====\n\n")) + c.Writer.Flush() + + scanner := bufio.NewScanner(stream) + scanner.Buffer(make([]byte, 64*1024), 1024*1024) + + for scanner.Scan() { + select { + case <-streamCtx.Done(): + return + default: + } + line := scanner.Bytes() + if _, writeErr := c.Writer.Write(line); writeErr != nil { + return + } + if _, writeErr := c.Writer.Write([]byte("\n")); writeErr != nil { + return + } + c.Writer.Flush() + } + + if err := scanner.Err(); err != nil && err != io.EOF { + var errMsg []byte + errMsg = fmt.Appendf(errMsg, "\n[Stream error: %v]\n", err) + _, _ = c.Writer.Write(errMsg) + c.Writer.Flush() + } + + _, _ = c.Writer.Write([]byte("\n[Log streaming completed]\n")) + c.Writer.Flush() +} diff --git a/internal/buildapi/types.go b/internal/buildapi/types.go index 3040a1a5..a7e7a708 100644 --- a/internal/buildapi/types.go +++ b/internal/buildapi/types.go @@ -131,6 +131,11 @@ type BuildRequest struct { BuildDiskImage bool `json:"buildDiskImage,omitempty"` // Build disk image from bootc container ExportOCI string `json:"exportOci,omitempty"` // Registry URL to push disk as OCI artifact BuilderImage string `json:"builderImage,omitempty"` // Custom builder image + + // Flash configuration for Jumpstarter device flashing after build + FlashEnabled bool `json:"flashEnabled,omitempty"` // Enable flashing after build + FlashClientConfig string `json:"flashClientConfig,omitempty"` // Base64-encoded Jumpstarter client config + FlashLeaseDuration string `json:"flashLeaseDuration,omitempty"` // Lease duration in HH:MM:SS format } // RegistryCredentials contains authentication details for container registries. @@ -152,6 +157,47 @@ type JumpstarterInfo struct { ExporterSelector string `json:"exporterSelector,omitempty"` // FlashCmd is the command for flashing the device FlashCmd string `json:"flashCmd,omitempty"` + // LeaseID is the Jumpstarter lease ID acquired during flash + LeaseID string `json:"leaseId,omitempty"` +} + +// FlashRequest is the payload to flash an image via Jumpstarter +type FlashRequest struct { + // Name is the flash job name (auto-generated if omitted) + Name string `json:"name"` + // ImageRef is the OCI registry reference of the disk image to flash + ImageRef string `json:"imageRef"` + // Target is the target platform for exporter lookup from OperatorConfig + Target string `json:"target,omitempty"` + // ExporterSelector is the direct label selector for Jumpstarter exporters (alternative to Target) + ExporterSelector string `json:"exporterSelector,omitempty"` + // FlashCmd is the command template for flashing (optional, can come from OperatorConfig) + FlashCmd string `json:"flashCmd,omitempty"` + // ClientConfig is the base64-encoded Jumpstarter client config file content + ClientConfig string `json:"clientConfig"` + // LeaseDuration is the Jumpstarter lease duration in HH:MM:SS format (default: "01:00:00") + LeaseDuration string `json:"leaseDuration,omitempty"` +} + +// FlashResponse is returned by flash operations +type FlashResponse struct { + Name string `json:"name"` + Phase string `json:"phase"` + Message string `json:"message"` + RequestedBy string `json:"requestedBy,omitempty"` + StartTime string `json:"startTime,omitempty"` + CompletionTime string `json:"completionTime,omitempty"` + TaskRunName string `json:"taskRunName,omitempty"` +} + +// FlashListItem represents a flash TaskRun in the list API +type FlashListItem struct { + Name string `json:"name"` + Phase string `json:"phase"` + Message string `json:"message"` + RequestedBy string `json:"requestedBy,omitempty"` + CreatedAt string `json:"createdAt"` + CompletionTime string `json:"completionTime,omitempty"` } // BuildResponse is returned by POST and GET build operations diff --git a/internal/common/tasks/scripts.go b/internal/common/tasks/scripts.go index 1fd97fe2..16ab848e 100644 --- a/internal/common/tasks/scripts.go +++ b/internal/common/tasks/scripts.go @@ -24,3 +24,8 @@ var PushArtifactScript string // BuildBuilderScript contains the embedded shell script for building the builder image. var BuildBuilderScript string + +//go:embed scripts/flash_image.sh + +// FlashImageScript contains the embedded shell script for flashing images via Jumpstarter. +var FlashImageScript string diff --git a/internal/common/tasks/scripts/flash_image.sh b/internal/common/tasks/scripts/flash_image.sh new file mode 100644 index 00000000..17e7b991 --- /dev/null +++ b/internal/common/tasks/scripts/flash_image.sh @@ -0,0 +1,66 @@ +#!/bin/bash +set -euo pipefail + +echo "=== Jumpstarter Flash Operation ===" +echo "Image: ${IMAGE_REF}" +echo "Exporter Selector: ${EXPORTER_SELECTOR}" + +export JMP_CLIENT_CONFIG="${JMP_CLIENT_CONFIG:-/workspace/jumpstarter-client/client.yaml}" + +if [[ ! -f "${JMP_CLIENT_CONFIG}" ]]; then + echo "ERROR: Jumpstarter client config not found at ${JMP_CLIENT_CONFIG}" + exit 1 +fi + +echo "Using client config: ${JMP_CLIENT_CONFIG}" + +FLASH_CMD="${FLASH_CMD:-j storage flash \{image_uri\}}" +FLASH_CMD=$(echo "${FLASH_CMD}" | sed "s|{image_uri}|${IMAGE_REF}|g") + + +LEASE_DURATION="${LEASE_DURATION:-03:00:00}" + +echo "Flash command: ${FLASH_CMD}" +echo "Lease duration: ${LEASE_DURATION}" +echo "" + +echo "Creating lease on exporter matching: ${EXPORTER_SELECTOR}" + +LEASE_NAME=$(jmp create lease --client-config "${JMP_CLIENT_CONFIG}" -l "${EXPORTER_SELECTOR}" --duration "${LEASE_DURATION}" -o name) + +if [[ -z "${LEASE_NAME}" ]]; then + echo "ERROR: Failed to create lease" + exit 1 +fi + +echo "" +echo "Lease acquired: ${LEASE_NAME}" +echo "Duration: ${LEASE_DURATION}" +echo "" + +# Write lease ID to Tekton result +if [[ -n "${RESULTS_LEASE_ID_PATH:-}" ]]; then + echo -n "${LEASE_NAME}" > "${RESULTS_LEASE_ID_PATH}" +fi + +FLASH_SUCCESS=false + +cleanup() { + if [[ "${FLASH_SUCCESS}" != "true" ]]; then + echo "" + echo "Releasing lease ${LEASE_NAME} due to failure..." + jmp delete leases --client-config "${JMP_CLIENT_CONFIG}" "${LEASE_NAME}" || true + fi +} +trap cleanup EXIT + +echo "Starting flash operation..." +echo "Executing: ${FLASH_CMD}" + +if ! jmp shell --client-config "${JMP_CLIENT_CONFIG}" --lease "${LEASE_NAME}" -- ${FLASH_CMD}; then + echo "" + echo "ERROR: Flash command failed" + exit 1 +fi + +FLASH_SUCCESS=true \ No newline at end of file diff --git a/internal/common/tasks/tasks.go b/internal/common/tasks/tasks.go index 8d5b60c0..cb901cc9 100644 --- a/internal/common/tasks/tasks.go +++ b/internal/common/tasks/tasks.go @@ -4,6 +4,7 @@ import ( _ "embed" // Required for go:embed directives "time" + automotivev1alpha1 "github.com/centos-automotive-suite/automotive-dev-operator/api/v1alpha1" tektonv1 "github.com/tektoncd/pipeline/pkg/apis/pipeline/v1" corev1 "k8s.io/api/core/v1" "k8s.io/apimachinery/pkg/api/resource" @@ -538,11 +539,67 @@ func GenerateTektonPipeline(name, namespace string) *tektonv1.Pipeline { StringVal: "", }, }, + // Flash (Jumpstarter) parameters + { + Name: "flash-enabled", + Type: tektonv1.ParamTypeString, + Description: "Enable flashing the image to hardware via Jumpstarter (true/false)", + Default: &tektonv1.ParamValue{ + Type: tektonv1.ParamTypeString, + StringVal: "false", + }, + }, + { + Name: "flash-image-ref", + Type: tektonv1.ParamTypeString, + Description: "OCI image reference to flash to the device", + Default: &tektonv1.ParamValue{ + Type: tektonv1.ParamTypeString, + StringVal: "", + }, + }, + { + Name: "flash-exporter-selector", + Type: tektonv1.ParamTypeString, + Description: "Jumpstarter exporter selector label (e.g., 'board=j784s4evm')", + Default: &tektonv1.ParamValue{ + Type: tektonv1.ParamTypeString, + StringVal: "", + }, + }, + { + Name: "flash-cmd", + Type: tektonv1.ParamTypeString, + Description: "Custom flash command (default: j storage flash ${IMAGE_REF})", + Default: &tektonv1.ParamValue{ + Type: tektonv1.ParamTypeString, + StringVal: "", + }, + }, + { + Name: "flash-lease-duration", + Type: tektonv1.ParamTypeString, + Description: "Jumpstarter lease duration in HH:MM:SS format", + Default: &tektonv1.ParamValue{ + Type: tektonv1.ParamTypeString, + StringVal: "03:00:00", + }, + }, + { + Name: "jumpstarter-image", + Type: tektonv1.ParamTypeString, + Description: "Container image for Jumpstarter CLI operations", + Default: &tektonv1.ParamValue{ + Type: tektonv1.ParamTypeString, + StringVal: automotivev1alpha1.DefaultJumpstarterImage, + }, + }, }, Workspaces: []tektonv1.PipelineWorkspaceDeclaration{ {Name: "shared-workspace"}, {Name: "manifest-config-workspace"}, {Name: "registry-auth", Optional: true}, + {Name: "jumpstarter-client", Optional: true}, }, Results: []tektonv1.PipelineResult{ { @@ -550,6 +607,11 @@ func GenerateTektonPipeline(name, namespace string) *tektonv1.Pipeline { Description: "The final artifact filename produced by the build", Value: tektonv1.ParamValue{Type: tektonv1.ParamTypeString, StringVal: "$(tasks.build-image.results.artifact-filename)"}, }, + { + Name: "lease-id", + Description: "The Jumpstarter lease ID acquired during flash (empty if flash not enabled)", + Value: tektonv1.ParamValue{Type: tektonv1.ParamTypeString, StringVal: "$(tasks.flash-image.results.lease-id)"}, + }, }, Tasks: []tektonv1.PipelineTask{ { @@ -855,6 +917,92 @@ func GenerateTektonPipeline(name, namespace string) *tektonv1.Pipeline { }, }, }, + { + Name: "flash-image", + TaskRef: &tektonv1.TaskRef{ + ResolverRef: tektonv1.ResolverRef{ + Resolver: "cluster", + Params: []tektonv1.Param{ + { + Name: "kind", + Value: tektonv1.ParamValue{ + Type: tektonv1.ParamTypeString, + StringVal: "task", + }, + }, + { + Name: "name", + Value: tektonv1.ParamValue{ + Type: tektonv1.ParamTypeString, + StringVal: "flash-image", + }, + }, + { + Name: "namespace", + Value: tektonv1.ParamValue{ + Type: tektonv1.ParamTypeString, + StringVal: namespace, + }, + }, + }, + }, + }, + Params: []tektonv1.Param{ + { + Name: "image-ref", + Value: tektonv1.ParamValue{ + Type: tektonv1.ParamTypeString, + StringVal: "$(params.flash-image-ref)", + }, + }, + { + Name: "exporter-selector", + Value: tektonv1.ParamValue{ + Type: tektonv1.ParamTypeString, + StringVal: "$(params.flash-exporter-selector)", + }, + }, + { + Name: "flash-cmd", + Value: tektonv1.ParamValue{ + Type: tektonv1.ParamTypeString, + StringVal: "$(params.flash-cmd)", + }, + }, + { + Name: "lease-duration", + Value: tektonv1.ParamValue{ + Type: tektonv1.ParamTypeString, + StringVal: "$(params.flash-lease-duration)", + }, + }, + { + Name: "jumpstarter-image", + Value: tektonv1.ParamValue{ + Type: tektonv1.ParamTypeString, + StringVal: "$(params.jumpstarter-image)", + }, + }, + }, + Workspaces: []tektonv1.WorkspacePipelineTaskBinding{ + {Name: "jumpstarter-client", Workspace: "jumpstarter-client"}, + }, + // Flash runs after push-disk-artifact (if it ran) or build-image + RunAfter: []string{"push-disk-artifact"}, + When: []tektonv1.WhenExpression{ + { + Input: "$(params.flash-enabled)", + Operator: "in", + Values: []string{"true"}, + }, + { + Input: "$(params.flash-exporter-selector)", + Operator: "notin", + Values: []string{"", "null"}, + }, + }, + Timeout: &metav1.Duration{Duration: 4 * time.Hour}, + }, }, }, } @@ -1037,6 +1185,114 @@ func GeneratePrepareBuilderTask(namespace string) *tektonv1.Task { } } +// GenerateFlashTask creates a Tekton Task for flashing images to hardware via Jumpstarter +func GenerateFlashTask(namespace string) *tektonv1.Task { + return &tektonv1.Task{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "tekton.dev/v1", + Kind: "Task", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "flash-image", + Namespace: namespace, + Labels: map[string]string{ + "app.kubernetes.io/managed-by": "automotive-dev-operator", + "app.kubernetes.io/part-of": "automotive-dev", + }, + }, + Spec: tektonv1.TaskSpec{ + Params: []tektonv1.ParamSpec{ + { + Name: "image-ref", + Type: tektonv1.ParamTypeString, + Description: "OCI image reference to flash to the device", + }, + { + Name: "exporter-selector", + Type: tektonv1.ParamTypeString, + Description: "Jumpstarter exporter selector label (e.g., 'board=j784s4evm')", + }, + { + Name: "flash-cmd", + Type: tektonv1.ParamTypeString, + Description: "Command to run for flashing (default: j storage flash ${IMAGE_REF})", + Default: &tektonv1.ParamValue{ + Type: tektonv1.ParamTypeString, + StringVal: "", + }, + }, + { + Name: "lease-duration", + Type: tektonv1.ParamTypeString, + Description: "Lease duration in HH:MM:SS format", + Default: &tektonv1.ParamValue{ + Type: tektonv1.ParamTypeString, + StringVal: "03:00:00", + }, + }, + { + Name: "jumpstarter-image", + Type: tektonv1.ParamTypeString, + Description: "Container image for Jumpstarter CLI operations", + Default: &tektonv1.ParamValue{ + Type: tektonv1.ParamTypeString, + StringVal: automotivev1alpha1.DefaultJumpstarterImage, + }, + }, + }, + Results: []tektonv1.TaskResult{ + { + Name: "lease-id", + Type: tektonv1.ResultsTypeString, + Description: "The Jumpstarter lease ID acquired for the device", + }, + }, + Workspaces: []tektonv1.WorkspaceDeclaration{ + { + Name: "jumpstarter-client", + Description: "Workspace containing the Jumpstarter client config (client.yaml)", + MountPath: "/workspace/jumpstarter-client", + Optional: true, + }, + }, + Steps: []tektonv1.Step{ + { + Name: "flash", + Image: "$(params.jumpstarter-image)", + Env: []corev1.EnvVar{ + { + Name: "IMAGE_REF", + Value: "$(params.image-ref)", + }, + { + Name: "EXPORTER_SELECTOR", + Value: "$(params.exporter-selector)", + }, + { + Name: "FLASH_CMD", + Value: "$(params.flash-cmd)", + }, + { + Name: "LEASE_DURATION", + Value: "$(params.lease-duration)", + }, + { + Name: "JMP_CLIENT_CONFIG", + Value: "/workspace/jumpstarter-client/client.yaml", + }, + { + Name: "RESULTS_LEASE_ID_PATH", + Value: "$(results.lease-id.path)", + }, + }, + Script: FlashImageScript, + Timeout: &metav1.Duration{Duration: 4 * time.Hour}, + }, + }, + }, + } +} + // GenerateBuildBuilderJob creates a Job to build the aib-build helper container func GenerateBuildBuilderJob(namespace, distro, targetRegistry, aibImage string) *corev1.Pod { if aibImage == "" { diff --git a/internal/controller/imagebuild/controller.go b/internal/controller/imagebuild/controller.go index 1d04c30d..39cb0fe6 100644 --- a/internal/controller/imagebuild/controller.go +++ b/internal/controller/imagebuild/controller.go @@ -76,7 +76,12 @@ func (r *ImageBuildReconciler) Reconcile(ctx context.Context, req ctrl.Request) case "Building": return r.handleBuildingState(ctx, imageBuild) case "Pushing": + // Legacy phase - push is now part of the pipeline return r.handlePushingState(ctx, imageBuild) + case "Flashing": + // Legacy phase - flash is now part of the pipeline + // Handle gracefully for any in-progress builds from before this change + return r.handleFlashingState(ctx, imageBuild) case phaseCompleted: return r.handleCompletedState(ctx, imageBuild) case phaseFailed: @@ -242,33 +247,19 @@ func (r *ImageBuildReconciler) checkBuildProgress( fresh.Status.AIBImageUsed = aibImageUsed fresh.Status.BuilderImageUsed = builderImageUsed - // Check if push is configured - if imageBuild.Spec.HasDiskExport() { - // Extract artifact filename from build results - artifactFilename := extractArtifactFilename(pipelineRun) - // Start push task - if err := r.createPushTaskRun(ctx, imageBuild, artifactFilename); err != nil { - log.Error(err, "Failed to create push TaskRun") - fresh.Status.Phase = phaseFailed - fresh.Status.Message = fmt.Sprintf("Failed to start push: %v", err) - if patchErr := r.Status().Patch(ctx, fresh, patch); patchErr != nil { - log.Error(patchErr, "Failed to patch status after push TaskRun creation failure") - return ctrl.Result{}, patchErr - } - return ctrl.Result{}, nil - } - - fresh.Status.Phase = "Pushing" - fresh.Status.Message = "Pushing artifact to registry" - if err := r.Status().Patch(ctx, fresh, patch); err != nil { - log.Error(err, "Failed to patch status to Pushing") - return ctrl.Result{}, err - } - return ctrl.Result{RequeueAfter: time.Second * 10}, nil + // Extract lease ID if flash was enabled + if fresh.Spec.IsFlashEnabled() { + fresh.Status.LeaseID = extractLeaseID(pipelineRun) } + // Pipeline includes push-disk-artifact and flash-image tasks (when enabled) + // Pipeline completion means everything succeeded fresh.Status.Phase = phaseCompleted - fresh.Status.Message = "Build completed successfully" + if fresh.Spec.IsFlashEnabled() { + fresh.Status.Message = "Build and flash completed successfully" + } else { + fresh.Status.Message = "Build completed successfully" + } if fresh.Status.CompletionTime == nil { now := metav1.Now() fresh.Status.CompletionTime = &now @@ -299,26 +290,8 @@ func (r *ImageBuildReconciler) startNewBuild( ctx context.Context, imageBuild *automotivev1alpha1.ImageBuild, ) (ctrl.Result, error) { - pvcName, err := r.getOrCreateWorkspacePVC(ctx, imageBuild) - if err != nil { - return ctrl.Result{}, fmt.Errorf("failed to get or create workspace PVC: %w", err) - } - - if imageBuild.Status.PVCName != pvcName { - fresh := &automotivev1alpha1.ImageBuild{} - nsName := types.NamespacedName{Name: imageBuild.Name, Namespace: imageBuild.Namespace} - if err := r.Get(ctx, nsName, fresh); err != nil { - return ctrl.Result{}, fmt.Errorf("failed to get fresh ImageBuild: %w", err) - } - - fresh.Status.PVCName = pvcName - if err := r.Status().Update(ctx, fresh); err != nil { - return ctrl.Result{}, fmt.Errorf("failed to update ImageBuild status with PVC name: %w", err) - } - - imageBuild.Status.PVCName = pvcName - } - + // PVC is now created via VolumeClaimTemplate in createBuildTaskRun + // to ensure proper zone affinity with WaitForFirstConsumer if err := r.createBuildTaskRun(ctx, imageBuild); err != nil { return ctrl.Result{}, fmt.Errorf("failed to create build task run: %w", err) } @@ -326,6 +299,7 @@ func (r *ImageBuildReconciler) startNewBuild( return ctrl.Result{RequeueAfter: time.Second * 30}, nil } +//nolint:gocyclo // Complex PipelineRun builder with many optional fields based on build configuration func (r *ImageBuildReconciler) createBuildTaskRun( ctx context.Context, imageBuild *automotivev1alpha1.ImageBuild, @@ -351,29 +325,10 @@ func (r *ImageBuildReconciler) createBuildTaskRun( RuntimeClassName: operatorConfig.Spec.OSBuilds.RuntimeClassName, } } - _ = buildConfig // buildConfig used for PVC sizing if needed + _ = buildConfig // buildConfig used for RuntimeClassName if needed - if imageBuild.Status.PVCName == "" { - workspacePVCName, err := r.getOrCreateWorkspacePVC(ctx, imageBuild) - if err != nil { - return err - } - - fresh := &automotivev1alpha1.ImageBuild{} - nsName := types.NamespacedName{Name: imageBuild.Name, Namespace: imageBuild.Namespace} - if err := r.Get(ctx, nsName, fresh); err != nil { - return fmt.Errorf("failed to get fresh ImageBuild: %w", err) - } - - fresh.Status.PVCName = workspacePVCName - if err := r.Status().Update(ctx, fresh); err != nil { - return fmt.Errorf("failed to update ImageBuild status with PVC name: %w", err) - } - - imageBuild.Status.PVCName = workspacePVCName - } - - workspacePVCName := imageBuild.Status.PVCName + // PVC is created via VolumeClaimTemplate in the PipelineRun workspace binding + // to ensure proper zone affinity with WaitForFirstConsumer storage class params := []tektonv1.Param{ { @@ -497,11 +452,72 @@ func (r *ImageBuildReconciler) createBuildTaskRun( }) } + // Add flash params if flash is enabled + var flashExporterSelector, flashCmd string + if imageBuild.Spec.IsFlashEnabled() { + target := imageBuild.Spec.GetTarget() + if operatorConfig.Spec.Jumpstarter != nil { + if mapping, ok := operatorConfig.Spec.Jumpstarter.TargetMappings[target]; ok { + flashExporterSelector = mapping.Selector + flashCmd = mapping.FlashCmd + } + } + if flashExporterSelector == "" { + return fmt.Errorf("flash enabled but no Jumpstarter target mapping found for target %q; "+ + "configure OperatorConfig.spec.jumpstarter.targetMappings[%q] with selector and flashCmd", target, target) + } + params = append(params, + tektonv1.Param{ + Name: "flash-enabled", + Value: tektonv1.ParamValue{Type: tektonv1.ParamTypeString, StringVal: "true"}, + }, + tektonv1.Param{ + Name: "flash-image-ref", + Value: tektonv1.ParamValue{Type: tektonv1.ParamTypeString, StringVal: imageBuild.Spec.GetExportOCI()}, + }, + tektonv1.Param{ + Name: "flash-exporter-selector", + Value: tektonv1.ParamValue{Type: tektonv1.ParamTypeString, StringVal: flashExporterSelector}, + }, + tektonv1.Param{ + Name: "flash-cmd", + Value: tektonv1.ParamValue{Type: tektonv1.ParamTypeString, StringVal: flashCmd}, + }, + tektonv1.Param{ + Name: "flash-lease-duration", + Value: tektonv1.ParamValue{Type: tektonv1.ParamTypeString, StringVal: imageBuild.Spec.GetFlashLeaseDuration()}, + }, + tektonv1.Param{ + Name: "jumpstarter-image", + Value: tektonv1.ParamValue{Type: tektonv1.ParamTypeString, StringVal: operatorConfig.Spec.Jumpstarter.GetJumpstarterImage()}, + }, + ) + } + + // Use VolumeClaimTemplate instead of pre-created PVC to ensure the volume + // is provisioned in the correct zone based on pod scheduling constraints + storageSize := resource.MustParse("8Gi") + if operatorConfig.Spec.OSBuilds != nil && operatorConfig.Spec.OSBuilds.PVCSize != "" { + storageSize = resource.MustParse(operatorConfig.Spec.OSBuilds.PVCSize) + } + var storageClassName *string + if imageBuild.Spec.StorageClass != "" { + storageClassName = &imageBuild.Spec.StorageClass + } + pipelineWorkspaces := []tektonv1.WorkspaceBinding{ { Name: "shared-workspace", - PersistentVolumeClaim: &corev1.PersistentVolumeClaimVolumeSource{ - ClaimName: workspacePVCName, + VolumeClaimTemplate: &corev1.PersistentVolumeClaim{ + Spec: corev1.PersistentVolumeClaimSpec{ + AccessModes: []corev1.PersistentVolumeAccessMode{corev1.ReadWriteOnce}, + StorageClassName: storageClassName, + Resources: corev1.VolumeResourceRequirements{ + Requests: corev1.ResourceList{ + corev1.ResourceStorage: storageSize, + }, + }, + }, }, }, { @@ -523,6 +539,15 @@ func (r *ImageBuildReconciler) createBuildTaskRun( }) } + if imageBuild.Spec.IsFlashEnabled() { + pipelineWorkspaces = append(pipelineWorkspaces, tektonv1.WorkspaceBinding{ + Name: "jumpstarter-client", + Secret: &corev1.SecretVolumeSource{ + SecretName: imageBuild.Spec.GetFlashClientConfigSecretRef(), + }, + }) + } + nodeAffinity := &corev1.NodeAffinity{ RequiredDuringSchedulingIgnoredDuringExecution: &corev1.NodeSelector{ NodeSelectorTerms: []corev1.NodeSelectorTerm{ @@ -809,6 +834,17 @@ func (r *ImageBuildReconciler) handlePushingState( patch := client.MergeFrom(fresh.DeepCopy()) if isTaskRunSuccessful(taskRun) { + // Check if flash is enabled + if fresh.Spec.IsFlashEnabled() { + fresh.Status.Phase = "Flashing" + fresh.Status.Message = "Flashing image to device" + if err := r.Status().Patch(ctx, fresh, patch); err != nil { + log.Error(err, "Failed to patch status to Flashing") + return ctrl.Result{}, err + } + return ctrl.Result{Requeue: true}, nil + } + fresh.Status.Phase = phaseCompleted fresh.Status.Message = "Build and push completed successfully" } else { @@ -829,6 +865,200 @@ func (r *ImageBuildReconciler) handlePushingState( return ctrl.Result{}, nil } +func (r *ImageBuildReconciler) handleFlashingState( + ctx context.Context, + imageBuild *automotivev1alpha1.ImageBuild, +) (ctrl.Result, error) { + nsName := types.NamespacedName{Name: imageBuild.Name, Namespace: imageBuild.Namespace} + log := r.Log.WithValues("imagebuild", nsName) + + if imageBuild.Status.FlashTaskRunName == "" { + // No flash TaskRun yet, create one + if err := r.createFlashTaskRun(ctx, imageBuild); err != nil { + log.Error(err, "Failed to create flash TaskRun") + msg := fmt.Sprintf("Failed to create flash TaskRun: %v", err) + if statusErr := r.updateStatus(ctx, imageBuild, phaseFailed, msg); statusErr != nil { + log.Error(statusErr, "Failed to update status after flash TaskRun creation failure") + return ctrl.Result{}, statusErr + } + return ctrl.Result{}, nil + } + return ctrl.Result{RequeueAfter: time.Second * 10}, nil + } + + // Check flash TaskRun status + taskRun := &tektonv1.TaskRun{} + err := r.Get(ctx, types.NamespacedName{ + Name: imageBuild.Status.FlashTaskRunName, + Namespace: imageBuild.Namespace, + }, taskRun) + if err != nil { + if errors.IsNotFound(err) { + // TaskRun was deleted, try to recreate + imageBuild.Status.FlashTaskRunName = "" + if statusErr := r.Status().Update(ctx, imageBuild); statusErr != nil { + log.Error(statusErr, "Failed to clear FlashTaskRunName in status") + return ctrl.Result{}, statusErr + } + return ctrl.Result{Requeue: true}, nil + } + return ctrl.Result{}, err + } + + if !isTaskRunCompleted(taskRun) { + return ctrl.Result{RequeueAfter: time.Second * 30}, nil + } + + // Flash completed - cleanup and update status + r.cleanupTransientSecrets(ctx, imageBuild, log) + + fresh := &automotivev1alpha1.ImageBuild{} + if err := r.Get(ctx, types.NamespacedName{Name: imageBuild.Name, Namespace: imageBuild.Namespace}, fresh); err != nil { + return ctrl.Result{}, err + } + + patch := client.MergeFrom(fresh.DeepCopy()) + + if isTaskRunSuccessful(taskRun) { + fresh.Status.Phase = phaseCompleted + fresh.Status.Message = "Build, push, and flash completed successfully" + } else { + fresh.Status.Phase = phaseFailed + fresh.Status.Message = "Flash to device failed" + } + + if fresh.Status.CompletionTime == nil { + now := metav1.Now() + fresh.Status.CompletionTime = &now + } + + if err := r.Status().Patch(ctx, fresh, patch); err != nil { + log.Error(err, "Failed to patch status after flash completion") + return ctrl.Result{}, err + } + + return ctrl.Result{}, nil +} + +func (r *ImageBuildReconciler) createFlashTaskRun( + ctx context.Context, + imageBuild *automotivev1alpha1.ImageBuild, +) error { + log := r.Log.WithValues("imagebuild", types.NamespacedName{Name: imageBuild.Name, Namespace: imageBuild.Namespace}) + log.Info("Creating flash TaskRun for ImageBuild") + + if !imageBuild.Spec.IsFlashEnabled() { + return fmt.Errorf("flash is not enabled") + } + + // Get exporter selector from OperatorConfig based on target + operatorConfig := &automotivev1alpha1.OperatorConfig{} + err := r.Get(ctx, types.NamespacedName{Name: "config", Namespace: OperatorNamespace}, operatorConfig) + if err != nil { + return fmt.Errorf("failed to get OperatorConfig: %w", err) + } + + target := imageBuild.Spec.GetTarget() + var exporterSelector, flashCmd string + if operatorConfig.Spec.Jumpstarter != nil { + if mapping, ok := operatorConfig.Spec.Jumpstarter.TargetMappings[target]; ok { + exporterSelector = mapping.Selector + flashCmd = mapping.FlashCmd + } + } + + if exporterSelector == "" { + return fmt.Errorf("no Jumpstarter exporter mapping found for target %q in OperatorConfig", target) + } + + // Get the image reference to flash (from export.disk.oci) + imageRef := imageBuild.Spec.GetExportOCI() + if imageRef == "" { + return fmt.Errorf("no disk export OCI URL configured for flash") + } + + // Note: Flash command placeholders are handled in the flash script itself + + leaseDuration := imageBuild.Spec.GetFlashLeaseDuration() + clientConfigSecretRef := imageBuild.Spec.GetFlashClientConfigSecretRef() + if clientConfigSecretRef == "" { + return fmt.Errorf("flash client config secret reference is required but not set") + } + + flashTask := tasks.GenerateFlashTask(OperatorNamespace) + + params := []tektonv1.Param{ + { + Name: "image-ref", + Value: tektonv1.ParamValue{Type: tektonv1.ParamTypeString, StringVal: imageRef}, + }, + { + Name: "exporter-selector", + Value: tektonv1.ParamValue{Type: tektonv1.ParamTypeString, StringVal: exporterSelector}, + }, + { + Name: "flash-cmd", + Value: tektonv1.ParamValue{Type: tektonv1.ParamTypeString, StringVal: flashCmd}, + }, + { + Name: "lease-duration", + Value: tektonv1.ParamValue{Type: tektonv1.ParamTypeString, StringVal: leaseDuration}, + }, + } + + workspaces := []tektonv1.WorkspaceBinding{ + { + Name: "jumpstarter-client", + Secret: &corev1.SecretVolumeSource{ + SecretName: clientConfigSecretRef, + }, + }, + } + + taskRun := &tektonv1.TaskRun{ + ObjectMeta: metav1.ObjectMeta{ + GenerateName: fmt.Sprintf("%s-flash-", imageBuild.Name), + Namespace: imageBuild.Namespace, + Labels: map[string]string{ + tektonv1.ManagedByLabelKey: "automotive-dev-operator", + "automotive.sdv.cloud.redhat.com/imagebuild-name": imageBuild.Name, + "automotive.sdv.cloud.redhat.com/task-type": "flash", + }, + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: imageBuild.APIVersion, + Kind: imageBuild.Kind, + Name: imageBuild.Name, + UID: imageBuild.UID, + Controller: ptr.To(true), + }, + }, + }, + Spec: tektonv1.TaskRunSpec{ + TaskSpec: &flashTask.Spec, + Params: params, + Workspaces: workspaces, + }, + } + + if err := r.Create(ctx, taskRun); err != nil { + return fmt.Errorf("failed to create flash TaskRun: %w", err) + } + + fresh := &automotivev1alpha1.ImageBuild{} + if err := r.Get(ctx, types.NamespacedName{Name: imageBuild.Name, Namespace: imageBuild.Namespace}, fresh); err != nil { + return fmt.Errorf("failed to get fresh ImageBuild: %w", err) + } + + fresh.Status.FlashTaskRunName = taskRun.Name + if err := r.Status().Update(ctx, fresh); err != nil { + return fmt.Errorf("failed to update ImageBuild with flash TaskRun name: %w", err) + } + + log.Info("Successfully created flash TaskRun", "name", taskRun.Name) + return nil +} + // cleanupTransientSecrets deletes any transient secrets created for this build // Uses retry logic to handle transient API errors func (r *ImageBuildReconciler) cleanupTransientSecrets( @@ -844,6 +1074,10 @@ func (r *ImageBuildReconciler) cleanupTransientSecrets( if imageBuild.Spec.PushSecretRef != "" { r.deleteSecretWithRetry(ctx, imageBuild.Namespace, imageBuild.Spec.PushSecretRef, "push auth", log) } + // Cleanup flash client config secret + if flashSecretRef := imageBuild.Spec.GetFlashClientConfigSecretRef(); flashSecretRef != "" { + r.deleteSecretWithRetry(ctx, imageBuild.Namespace, flashSecretRef, "flash client config", log) + } } // deleteSecretWithRetry attempts to delete a secret with exponential backoff retry @@ -946,6 +1180,16 @@ func extractArtifactFilename(pipelineRun *tektonv1.PipelineRun) string { return "" } +// extractLeaseID extracts the Jumpstarter lease ID from PipelineRun results +func extractLeaseID(pipelineRun *tektonv1.PipelineRun) string { + for _, result := range pipelineRun.Status.Results { + if result.Name == "lease-id" { + return result.Value.StringVal + } + } + return "" +} + func isTaskRunSuccessful(taskRun *tektonv1.TaskRun) bool { conditions := taskRun.Status.Conditions if len(conditions) == 0 { diff --git a/internal/controller/operatorconfig/controller.go b/internal/controller/operatorconfig/controller.go index 9d19901f..cab06032 100644 --- a/internal/controller/operatorconfig/controller.go +++ b/internal/controller/operatorconfig/controller.go @@ -417,6 +417,7 @@ func (r *OperatorConfigReconciler) deployOSBuilds( tasks.GenerateBuildAutomotiveImageTask(operatorNamespace, buildConfig, ""), tasks.GeneratePushArtifactRegistryTask(operatorNamespace), tasks.GeneratePrepareBuilderTask(operatorNamespace), + tasks.GenerateFlashTask(operatorNamespace), } for _, task := range tektonTasks { @@ -455,7 +456,7 @@ func (r *OperatorConfigReconciler) cleanupOSBuilds(ctx context.Context) error { r.Log.Info("Cleaning up OSBuilds resources") // Delete Tekton tasks - taskNames := []string{"build-automotive-image", "push-artifact-registry"} + taskNames := []string{"build-automotive-image", "push-artifact-registry", "prepare-builder", "flash-image"} for _, taskName := range taskNames { task := &tektonv1.Task{} task.Name = taskName diff --git a/vendor/github.com/mitchellh/colorstring/.travis.yml b/vendor/github.com/mitchellh/colorstring/.travis.yml deleted file mode 100644 index 74e286ae..00000000 --- a/vendor/github.com/mitchellh/colorstring/.travis.yml +++ /dev/null @@ -1,15 +0,0 @@ -language: go - -go: - - 1.0 - - 1.1 - - 1.2 - - 1.3 - - tip - -script: - - go test - -matrix: - allow_failures: - - go: tip diff --git a/vendor/github.com/mitchellh/colorstring/LICENSE b/vendor/github.com/mitchellh/colorstring/LICENSE deleted file mode 100644 index 22985159..00000000 --- a/vendor/github.com/mitchellh/colorstring/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -The MIT License (MIT) - -Copyright (c) 2014 Mitchell Hashimoto - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. diff --git a/vendor/github.com/mitchellh/colorstring/README.md b/vendor/github.com/mitchellh/colorstring/README.md deleted file mode 100644 index 0654d454..00000000 --- a/vendor/github.com/mitchellh/colorstring/README.md +++ /dev/null @@ -1,30 +0,0 @@ -# colorstring [![Build Status](https://travis-ci.org/mitchellh/colorstring.svg)](https://travis-ci.org/mitchellh/colorstring) - -colorstring is a [Go](http://www.golang.org) library for outputting colored -strings to a console using a simple inline syntax in your string to specify -the color to print as. - -For example, the string `[blue]hello [red]world` would output the text -"hello world" in two colors. The API of colorstring allows for easily disabling -colors, adding aliases, etc. - -## Installation - -Standard `go get`: - -``` -$ go get github.com/mitchellh/colorstring -``` - -## Usage & Example - -For usage and examples see the [Godoc](http://godoc.org/github.com/mitchellh/colorstring). - -Usage is easy enough: - -```go -colorstring.Println("[blue]Hello [red]World!") -``` - -Additionally, the `Colorize` struct can be used to set options such as -custom colors, color disabling, etc. diff --git a/vendor/github.com/mitchellh/colorstring/colorstring.go b/vendor/github.com/mitchellh/colorstring/colorstring.go deleted file mode 100644 index 3de5b241..00000000 --- a/vendor/github.com/mitchellh/colorstring/colorstring.go +++ /dev/null @@ -1,244 +0,0 @@ -// colorstring provides functions for colorizing strings for terminal -// output. -package colorstring - -import ( - "bytes" - "fmt" - "io" - "regexp" - "strings" -) - -// Color colorizes your strings using the default settings. -// -// Strings given to Color should use the syntax `[color]` to specify the -// color for text following. For example: `[blue]Hello` will return "Hello" -// in blue. See DefaultColors for all the supported colors and attributes. -// -// If an unrecognized color is given, it is ignored and assumed to be part -// of the string. For example: `[hi]world` will result in "[hi]world". -// -// A color reset is appended to the end of every string. This will reset -// the color of following strings when you output this text to the same -// terminal session. -// -// If you want to customize any of this behavior, use the Colorize struct. -func Color(v string) string { - return def.Color(v) -} - -// ColorPrefix returns the color sequence that prefixes the given text. -// -// This is useful when wrapping text if you want to inherit the color -// of the wrapped text. For example, "[green]foo" will return "[green]". -// If there is no color sequence, then this will return "". -func ColorPrefix(v string) string { - return def.ColorPrefix(v) -} - -// Colorize colorizes your strings, giving you the ability to customize -// some of the colorization process. -// -// The options in Colorize can be set to customize colorization. If you're -// only interested in the defaults, just use the top Color function directly, -// which creates a default Colorize. -type Colorize struct { - // Colors maps a color string to the code for that color. The code - // is a string so that you can use more complex colors to set foreground, - // background, attributes, etc. For example, "boldblue" might be - // "1;34" - Colors map[string]string - - // If true, color attributes will be ignored. This is useful if you're - // outputting to a location that doesn't support colors and you just - // want the strings returned. - Disable bool - - // Reset, if true, will reset the color after each colorization by - // adding a reset code at the end. - Reset bool -} - -// Color colorizes a string according to the settings setup in the struct. -// -// For more details on the syntax, see the top-level Color function. -func (c *Colorize) Color(v string) string { - matches := parseRe.FindAllStringIndex(v, -1) - if len(matches) == 0 { - return v - } - - result := new(bytes.Buffer) - colored := false - m := []int{0, 0} - for _, nm := range matches { - // Write the text in between this match and the last - result.WriteString(v[m[1]:nm[0]]) - m = nm - - var replace string - if code, ok := c.Colors[v[m[0]+1:m[1]-1]]; ok { - colored = true - - if !c.Disable { - replace = fmt.Sprintf("\033[%sm", code) - } - } else { - replace = v[m[0]:m[1]] - } - - result.WriteString(replace) - } - result.WriteString(v[m[1]:]) - - if colored && c.Reset && !c.Disable { - // Write the clear byte at the end - result.WriteString("\033[0m") - } - - return result.String() -} - -// ColorPrefix returns the first color sequence that exists in this string. -// -// For example: "[green]foo" would return "[green]". If no color sequence -// exists, then "" is returned. This is especially useful when wrapping -// colored texts to inherit the color of the wrapped text. -func (c *Colorize) ColorPrefix(v string) string { - return prefixRe.FindString(strings.TrimSpace(v)) -} - -// DefaultColors are the default colors used when colorizing. -// -// If the color is surrounded in underscores, such as "_blue_", then that -// color will be used for the background color. -var DefaultColors map[string]string - -func init() { - DefaultColors = map[string]string{ - // Default foreground/background colors - "default": "39", - "_default_": "49", - - // Foreground colors - "black": "30", - "red": "31", - "green": "32", - "yellow": "33", - "blue": "34", - "magenta": "35", - "cyan": "36", - "light_gray": "37", - "dark_gray": "90", - "light_red": "91", - "light_green": "92", - "light_yellow": "93", - "light_blue": "94", - "light_magenta": "95", - "light_cyan": "96", - "white": "97", - - // Background colors - "_black_": "40", - "_red_": "41", - "_green_": "42", - "_yellow_": "43", - "_blue_": "44", - "_magenta_": "45", - "_cyan_": "46", - "_light_gray_": "47", - "_dark_gray_": "100", - "_light_red_": "101", - "_light_green_": "102", - "_light_yellow_": "103", - "_light_blue_": "104", - "_light_magenta_": "105", - "_light_cyan_": "106", - "_white_": "107", - - // Attributes - "bold": "1", - "dim": "2", - "underline": "4", - "blink_slow": "5", - "blink_fast": "6", - "invert": "7", - "hidden": "8", - - // Reset to reset everything to their defaults - "reset": "0", - "reset_bold": "21", - } - - def = Colorize{ - Colors: DefaultColors, - Reset: true, - } -} - -var def Colorize -var parseReRaw = `\[[a-z0-9_-]+\]` -var parseRe = regexp.MustCompile(`(?i)` + parseReRaw) -var prefixRe = regexp.MustCompile(`^(?i)(` + parseReRaw + `)+`) - -// Print is a convenience wrapper for fmt.Print with support for color codes. -// -// Print formats using the default formats for its operands and writes to -// standard output with support for color codes. Spaces are added between -// operands when neither is a string. It returns the number of bytes written -// and any write error encountered. -func Print(a string) (n int, err error) { - return fmt.Print(Color(a)) -} - -// Println is a convenience wrapper for fmt.Println with support for color -// codes. -// -// Println formats using the default formats for its operands and writes to -// standard output with support for color codes. Spaces are always added -// between operands and a newline is appended. It returns the number of bytes -// written and any write error encountered. -func Println(a string) (n int, err error) { - return fmt.Println(Color(a)) -} - -// Printf is a convenience wrapper for fmt.Printf with support for color codes. -// -// Printf formats according to a format specifier and writes to standard output -// with support for color codes. It returns the number of bytes written and any -// write error encountered. -func Printf(format string, a ...interface{}) (n int, err error) { - return fmt.Printf(Color(format), a...) -} - -// Fprint is a convenience wrapper for fmt.Fprint with support for color codes. -// -// Fprint formats using the default formats for its operands and writes to w -// with support for color codes. Spaces are added between operands when neither -// is a string. It returns the number of bytes written and any write error -// encountered. -func Fprint(w io.Writer, a string) (n int, err error) { - return fmt.Fprint(w, Color(a)) -} - -// Fprintln is a convenience wrapper for fmt.Fprintln with support for color -// codes. -// -// Fprintln formats using the default formats for its operands and writes to w -// with support for color codes. Spaces are always added between operands and a -// newline is appended. It returns the number of bytes written and any write -// error encountered. -func Fprintln(w io.Writer, a string) (n int, err error) { - return fmt.Fprintln(w, Color(a)) -} - -// Fprintf is a convenience wrapper for fmt.Fprintf with support for color -// codes. -// -// Fprintf formats according to a format specifier and writes to w with support -// for color codes. It returns the number of bytes written and any write error -// encountered. -func Fprintf(w io.Writer, format string, a ...interface{}) (n int, err error) { - return fmt.Fprintf(w, Color(format), a...) -} diff --git a/vendor/github.com/schollz/progressbar/v3/.gitignore b/vendor/github.com/schollz/progressbar/v3/.gitignore deleted file mode 100644 index 35c3bde5..00000000 --- a/vendor/github.com/schollz/progressbar/v3/.gitignore +++ /dev/null @@ -1,18 +0,0 @@ -# Binaries for programs and plugins -*.exe -*.exe~ -*.dll -*.so -*.dylib - -# Test binary, built with `go test -c` -*.test - -# Output of the go coverage tool, specifically when used with LiteIDE -*.out - -# Dependency directories (remove the comment below to include it) -# vendor/ - -.idea/ -*.tar.gz diff --git a/vendor/github.com/schollz/progressbar/v3/.golangci.yml b/vendor/github.com/schollz/progressbar/v3/.golangci.yml deleted file mode 100644 index 8c45095d..00000000 --- a/vendor/github.com/schollz/progressbar/v3/.golangci.yml +++ /dev/null @@ -1,21 +0,0 @@ -run: - timeout: 5m - exclude-dirs: - - vendor - - examples - -linters: - enable: - - errcheck - - gocyclo - - gofmt - - goimports - - gosimple - - govet - - ineffassign - - staticcheck - - unused - -linters-settings: - gocyclo: - min-complexity: 20 \ No newline at end of file diff --git a/vendor/github.com/schollz/progressbar/v3/.travis.yml b/vendor/github.com/schollz/progressbar/v3/.travis.yml deleted file mode 100644 index 1d233875..00000000 --- a/vendor/github.com/schollz/progressbar/v3/.travis.yml +++ /dev/null @@ -1,6 +0,0 @@ -language: go - -go: - - tip - -script: go test -v . diff --git a/vendor/github.com/schollz/progressbar/v3/LICENSE b/vendor/github.com/schollz/progressbar/v3/LICENSE deleted file mode 100644 index 0ca97652..00000000 --- a/vendor/github.com/schollz/progressbar/v3/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2017 Zack - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/vendor/github.com/schollz/progressbar/v3/README.md b/vendor/github.com/schollz/progressbar/v3/README.md deleted file mode 100644 index e94a1c12..00000000 --- a/vendor/github.com/schollz/progressbar/v3/README.md +++ /dev/null @@ -1,121 +0,0 @@ -# progressbar - -[![CI](https://github.com/schollz/progressbar/actions/workflows/ci.yml/badge.svg?branch=main&event=push)](https://github.com/schollz/progressbar/actions/workflows/ci.yml) -[![go report card](https://goreportcard.com/badge/github.com/schollz/progressbar)](https://goreportcard.com/report/github.com/schollz/progressbar) -[![coverage](https://img.shields.io/badge/coverage-84%25-brightgreen.svg)](https://gocover.io/github.com/schollz/progressbar) -[![godocs](https://godoc.org/github.com/schollz/progressbar?status.svg)](https://godoc.org/github.com/schollz/progressbar/v3) - -A very simple thread-safe progress bar which should work on every OS without problems. I needed a progressbar for [croc](https://github.com/schollz/croc) and everything I tried had problems, so I made another one. In order to be OS agnostic I do not plan to support [multi-line outputs](https://github.com/schollz/progressbar/issues/6). - - -## Install - -``` -go get -u github.com/schollz/progressbar/v3 -``` - -## Usage - -### Basic usage - -```golang -bar := progressbar.Default(100) -for i := 0; i < 100; i++ { - bar.Add(1) - time.Sleep(40 * time.Millisecond) -} -``` - -which looks like: - -![Example of basic bar](examples/basic/basic.gif) - - -### I/O operations - -The `progressbar` implements an `io.Writer` so it can automatically detect the number of bytes written to a stream, so you can use it as a progressbar for an `io.Reader`. - -```golang -req, _ := http.NewRequest("GET", "https://dl.google.com/go/go1.14.2.src.tar.gz", nil) -resp, _ := http.DefaultClient.Do(req) -defer resp.Body.Close() - -f, _ := os.OpenFile("go1.14.2.src.tar.gz", os.O_CREATE|os.O_WRONLY, 0644) -defer f.Close() - -bar := progressbar.DefaultBytes( - resp.ContentLength, - "downloading", -) -io.Copy(io.MultiWriter(f, bar), resp.Body) -``` - -which looks like: - -![Example of download bar](examples/download/download.gif) - - -### Progress bar with unknown length - -A progressbar with unknown length is a spinner. Any bar with -1 length will automatically convert it to a spinner with a customizable spinner type. For example, the above code can be run and set the `resp.ContentLength` to `-1`. - -which looks like: - -![Example of download bar with unknown length](examples/download-unknown/download-unknown.gif) - - -### Customization - -There is a lot of customization that you can do - change the writer, the color, the width, description, theme, etc. See [all the options](https://pkg.go.dev/github.com/schollz/progressbar/v3?tab=doc#Option). - -```golang -bar := progressbar.NewOptions(1000, - progressbar.OptionSetWriter(ansi.NewAnsiStdout()), //you should install "github.com/k0kubun/go-ansi" - progressbar.OptionEnableColorCodes(true), - progressbar.OptionShowBytes(true), - progressbar.OptionSetWidth(15), - progressbar.OptionSetDescription("[cyan][1/3][reset] Writing moshable file..."), - progressbar.OptionSetTheme(progressbar.Theme{ - Saucer: "[green]=[reset]", - SaucerHead: "[green]>[reset]", - SaucerPadding: " ", - BarStart: "[", - BarEnd: "]", - })) -for i := 0; i < 1000; i++ { - bar.Add(1) - time.Sleep(5 * time.Millisecond) -} -``` - -which looks like: - -![Example of customized bar](examples/customization/customization.gif) - - -## Contributing - -Pull requests are welcome. Feel free to... - -- Revise documentation -- Add new features -- Fix bugs -- Suggest improvements - -## Thanks - -Thanks [@Dynom](https://github.com/dynom) for massive improvements in version 2.0! - -Thanks [@CrushedPixel](https://github.com/CrushedPixel) for adding descriptions and color code support! - -Thanks [@MrMe42](https://github.com/MrMe42) for adding some minor features! - -Thanks [@tehstun](https://github.com/tehstun) for some great PRs! - -Thanks [@Benzammour](https://github.com/Benzammour) and [@haseth](https://github.com/haseth) for helping create v3! - -Thanks [@briandowns](https://github.com/briandowns) for compiling the list of spinners. - -## License - -MIT diff --git a/vendor/github.com/schollz/progressbar/v3/progressbar.go b/vendor/github.com/schollz/progressbar/v3/progressbar.go deleted file mode 100644 index 0ccec7d3..00000000 --- a/vendor/github.com/schollz/progressbar/v3/progressbar.go +++ /dev/null @@ -1,1490 +0,0 @@ -package progressbar - -import ( - "bytes" - "encoding/json" - "errors" - "fmt" - "io" - "log" - "math" - "net/http" - "os" - "regexp" - "strings" - "sync" - "time" - - "github.com/mitchellh/colorstring" - "github.com/rivo/uniseg" - "golang.org/x/term" -) - -// ProgressBar is a thread-safe, simple -// progress bar -type ProgressBar struct { - state state - config config - lock sync.Mutex -} - -// State is the basic properties of the bar -type State struct { - Max int64 - CurrentNum int64 - CurrentPercent float64 - CurrentBytes float64 - SecondsSince float64 - SecondsLeft float64 - KBsPerSecond float64 - Description string -} - -type state struct { - currentNum int64 - currentPercent int - lastPercent int - currentSaucerSize int - isAltSaucerHead bool - - lastShown time.Time - startTime time.Time // time when the progress bar start working - - counterTime time.Time - counterNumSinceLast int64 - counterLastTenRates []float64 - spinnerIdx int // the index of spinner - - maxLineWidth int - currentBytes float64 - finished bool - exit bool // Progress bar exit halfway - - details []string // details to show,only used when detail row is set to more than 0 - - rendered string -} - -type config struct { - max int64 // max number of the counter - maxHumanized string - maxHumanizedSuffix string - width int - writer io.Writer - theme Theme - renderWithBlankState bool - description string - iterationString string - ignoreLength bool // ignoreLength if max bytes not known - - // whether the output is expected to contain color codes - colorCodes bool - - // show rate of change in kB/sec or MB/sec - showBytes bool - // show the iterations per second - showIterationsPerSecond bool - showIterationsCount bool - - // whether the progress bar should show the total bytes (e.g. 23/24 or 23/-, vs. just 23). - showTotalBytes bool - - // whether the progress bar should show elapsed time. - // always enabled if predictTime is true. - elapsedTime bool - - showElapsedTimeOnFinish bool - - // whether the progress bar should attempt to predict the finishing - // time of the progress based on the start time and the average - // number of seconds between increments. - predictTime bool - - // minimum time to wait in between updates - throttleDuration time.Duration - - // clear bar once finished - clearOnFinish bool - - // spinnerType should be a number between 0-75 - spinnerType int - - // spinnerTypeOptionUsed remembers if the spinnerType was changed manually - spinnerTypeOptionUsed bool - - // spinnerChangeInterval the change interval of spinner - // if set this attribute to 0, the spinner only change when renderProgressBar was called - // for example, each time when Add() was called,which will call renderProgressBar function - spinnerChangeInterval time.Duration - - // spinner represents the spinner as a slice of string - spinner []string - - // fullWidth specifies whether to measure and set the bar to a specific width - fullWidth bool - - // invisible doesn't render the bar at all, useful for debugging - invisible bool - - onCompletion func() - - // whether the render function should make use of ANSI codes to reduce console I/O - useANSICodes bool - - // whether to use the IEC units (e.g. MiB) instead of the default SI units (e.g. MB) - useIECUnits bool - - // showDescriptionAtLineEnd specifies whether description should be written at line end instead of line start - showDescriptionAtLineEnd bool - - // specifies how many rows of details to show,default value is 0 and no details will be shown - maxDetailRow int - - stdBuffer bytes.Buffer -} - -// Theme defines the elements of the bar -type Theme struct { - Saucer string - AltSaucerHead string - SaucerHead string - SaucerPadding string - BarStart string - BarEnd string - - // BarStartFilled is used after the Bar starts filling, if set. Otherwise, it defaults to BarStart. - BarStartFilled string - - // BarEndFilled is used once the Bar finishes, if set. Otherwise, it defaults to BarEnd. - BarEndFilled string -} - -var ( - // ThemeDefault is given by default (if not changed with OptionSetTheme), and it looks like "|████ |". - ThemeDefault = Theme{Saucer: "█", SaucerPadding: " ", BarStart: "|", BarEnd: "|"} - - // ThemeASCII is a predefined Theme that uses ASCII symbols. It looks like "[===>...]". - // Configure it with OptionSetTheme(ThemeASCII). - ThemeASCII = Theme{ - Saucer: "=", - SaucerHead: ">", - SaucerPadding: ".", - BarStart: "[", - BarEnd: "]", - } - - // ThemeUnicode is a predefined Theme that uses Unicode characters, displaying a graphic bar. - // It looks like "" (rendering will depend on font being used). - // It requires special symbols usually found in "nerd fonts" [2], or in Fira Code [1], and other sources. - // Configure it with OptionSetTheme(ThemeUnicode). - // - // [1] https://github.com/tonsky/FiraCode - // [2] https://www.nerdfonts.com/ - ThemeUnicode = Theme{ - Saucer: "\uEE04", //  - SaucerHead: "\uEE04", //  - SaucerPadding: "\uEE01", //  - BarStart: "\uEE00", //  - BarStartFilled: "\uEE03", //  - BarEnd: "\uEE02", //  - BarEndFilled: "\uEE05", //  - } -) - -// Option is the type all options need to adhere to -type Option func(p *ProgressBar) - -// OptionSetWidth sets the width of the bar -func OptionSetWidth(s int) Option { - return func(p *ProgressBar) { - p.config.width = s - } -} - -// OptionSetSpinnerChangeInterval sets the spinner change interval -// the spinner will change according to this value. -// By default, this value is 100 * time.Millisecond -// If you don't want to let this progressbar update by specified time interval -// you can set this value to zero, then the spinner will change each time rendered, -// such as when Add() or Describe() was called -func OptionSetSpinnerChangeInterval(interval time.Duration) Option { - return func(p *ProgressBar) { - p.config.spinnerChangeInterval = interval - } -} - -// OptionSpinnerType sets the type of spinner used for indeterminate bars -func OptionSpinnerType(spinnerType int) Option { - return func(p *ProgressBar) { - p.config.spinnerTypeOptionUsed = true - p.config.spinnerType = spinnerType - } -} - -// OptionSpinnerCustom sets the spinner used for indeterminate bars to the passed -// slice of string -func OptionSpinnerCustom(spinner []string) Option { - return func(p *ProgressBar) { - p.config.spinner = spinner - } -} - -// OptionSetTheme sets the elements the bar is constructed with. -// There are two pre-defined themes you can use: ThemeASCII and ThemeUnicode. -func OptionSetTheme(t Theme) Option { - return func(p *ProgressBar) { - p.config.theme = t - } -} - -// OptionSetVisibility sets the visibility -func OptionSetVisibility(visibility bool) Option { - return func(p *ProgressBar) { - p.config.invisible = !visibility - } -} - -// OptionFullWidth sets the bar to be full width -func OptionFullWidth() Option { - return func(p *ProgressBar) { - p.config.fullWidth = true - } -} - -// OptionSetWriter sets the output writer (defaults to os.StdOut) -func OptionSetWriter(w io.Writer) Option { - return func(p *ProgressBar) { - p.config.writer = w - } -} - -// OptionSetRenderBlankState sets whether or not to render a 0% bar on construction -func OptionSetRenderBlankState(r bool) Option { - return func(p *ProgressBar) { - p.config.renderWithBlankState = r - } -} - -// OptionSetDescription sets the description of the bar to render in front of it -func OptionSetDescription(description string) Option { - return func(p *ProgressBar) { - p.config.description = description - } -} - -// OptionEnableColorCodes enables or disables support for color codes -// using mitchellh/colorstring -func OptionEnableColorCodes(colorCodes bool) Option { - return func(p *ProgressBar) { - p.config.colorCodes = colorCodes - } -} - -// OptionSetElapsedTime will enable elapsed time. Always enabled if OptionSetPredictTime is true. -func OptionSetElapsedTime(elapsedTime bool) Option { - return func(p *ProgressBar) { - p.config.elapsedTime = elapsedTime - } -} - -// OptionSetPredictTime will also attempt to predict the time remaining. -func OptionSetPredictTime(predictTime bool) Option { - return func(p *ProgressBar) { - p.config.predictTime = predictTime - } -} - -// OptionShowCount will also print current count out of total -func OptionShowCount() Option { - return func(p *ProgressBar) { - p.config.showIterationsCount = true - } -} - -// OptionShowIts will also print the iterations/second -func OptionShowIts() Option { - return func(p *ProgressBar) { - p.config.showIterationsPerSecond = true - } -} - -// OptionShowElapsedTimeOnFinish will keep the display of elapsed time on finish. -func OptionShowElapsedTimeOnFinish() Option { - return func(p *ProgressBar) { - p.config.showElapsedTimeOnFinish = true - } -} - -// OptionShowTotalBytes will keep the display of total bytes. -func OptionShowTotalBytes(flag bool) Option { - return func(p *ProgressBar) { - p.config.showTotalBytes = flag - } -} - -// OptionSetItsString sets what's displayed for iterations a second. The default is "it" which would display: "it/s" -func OptionSetItsString(iterationString string) Option { - return func(p *ProgressBar) { - p.config.iterationString = iterationString - } -} - -// OptionThrottle will wait the specified duration before updating again. The default -// duration is 0 seconds. -func OptionThrottle(duration time.Duration) Option { - return func(p *ProgressBar) { - p.config.throttleDuration = duration - } -} - -// OptionClearOnFinish will clear the bar once its finished. -func OptionClearOnFinish() Option { - return func(p *ProgressBar) { - p.config.clearOnFinish = true - } -} - -// OptionOnCompletion will invoke cmpl function once its finished -func OptionOnCompletion(cmpl func()) Option { - return func(p *ProgressBar) { - p.config.onCompletion = cmpl - } -} - -// OptionShowBytes will update the progress bar -// configuration settings to display/hide kBytes/Sec -func OptionShowBytes(val bool) Option { - return func(p *ProgressBar) { - p.config.showBytes = val - } -} - -// OptionUseANSICodes will use more optimized terminal i/o. -// -// Only useful in environments with support for ANSI escape sequences. -func OptionUseANSICodes(val bool) Option { - return func(p *ProgressBar) { - p.config.useANSICodes = val - } -} - -// OptionUseIECUnits will enable IEC units (e.g. MiB) instead of the default -// SI units (e.g. MB). -func OptionUseIECUnits(val bool) Option { - return func(p *ProgressBar) { - p.config.useIECUnits = val - } -} - -// OptionShowDescriptionAtLineEnd defines whether description should be written at line end instead of line start -func OptionShowDescriptionAtLineEnd() Option { - return func(p *ProgressBar) { - p.config.showDescriptionAtLineEnd = true - } -} - -// OptionSetMaxDetailRow sets the max row of details -// the row count should be less than the terminal height, otherwise it will not give you the output you want -func OptionSetMaxDetailRow(row int) Option { - return func(p *ProgressBar) { - p.config.maxDetailRow = row - } -} - -// NewOptions constructs a new instance of ProgressBar, with any options you specify -func NewOptions(max int, options ...Option) *ProgressBar { - return NewOptions64(int64(max), options...) -} - -// NewOptions64 constructs a new instance of ProgressBar, with any options you specify -func NewOptions64(max int64, options ...Option) *ProgressBar { - b := ProgressBar{ - state: state{ - startTime: time.Time{}, - lastShown: time.Time{}, - counterTime: time.Time{}, - }, - config: config{ - writer: os.Stdout, - theme: ThemeDefault, - iterationString: "it", - width: 40, - max: max, - throttleDuration: 0 * time.Nanosecond, - elapsedTime: max == -1, - predictTime: true, - spinnerType: 9, - invisible: false, - spinnerChangeInterval: 100 * time.Millisecond, - showTotalBytes: true, - }, - } - - for _, o := range options { - o(&b) - } - - if b.config.spinnerType < 0 || b.config.spinnerType > 75 { - panic("invalid spinner type, must be between 0 and 75") - } - - if b.config.maxDetailRow < 0 { - panic("invalid max detail row, must be greater than 0") - } - - // ignoreLength if max bytes not known - if b.config.max == -1 { - b.lengthUnknown() - } - - b.config.maxHumanized, b.config.maxHumanizedSuffix = humanizeBytes(float64(b.config.max), - b.config.useIECUnits) - - if b.config.renderWithBlankState { - b.RenderBlank() - } - - // if the render time interval attribute is set - if b.config.spinnerChangeInterval != 0 && !b.config.invisible && b.config.ignoreLength { - go func() { - ticker := time.NewTicker(b.config.spinnerChangeInterval) - defer ticker.Stop() - for { - select { - case <-ticker.C: - if b.IsFinished() { - return - } - if b.IsStarted() { - b.lock.Lock() - b.render() - b.lock.Unlock() - } - } - } - }() - } - - return &b -} - -func getBasicState() state { - now := time.Now() - return state{ - startTime: now, - lastShown: now, - counterTime: now, - } -} - -// New returns a new ProgressBar -// with the specified maximum -func New(max int) *ProgressBar { - return NewOptions(max) -} - -// DefaultBytes provides a progressbar to measure byte -// throughput with recommended defaults. -// Set maxBytes to -1 to use as a spinner. -func DefaultBytes(maxBytes int64, description ...string) *ProgressBar { - desc := "" - if len(description) > 0 { - desc = description[0] - } - return NewOptions64( - maxBytes, - OptionSetDescription(desc), - OptionSetWriter(os.Stderr), - OptionShowBytes(true), - OptionShowTotalBytes(true), - OptionSetWidth(10), - OptionThrottle(65*time.Millisecond), - OptionShowCount(), - OptionOnCompletion(func() { - fmt.Fprint(os.Stderr, "\n") - }), - OptionSpinnerType(14), - OptionFullWidth(), - OptionSetRenderBlankState(true), - ) -} - -// DefaultBytesSilent is the same as DefaultBytes, but does not output anywhere. -// String() can be used to get the output instead. -func DefaultBytesSilent(maxBytes int64, description ...string) *ProgressBar { - // Mostly the same bar as DefaultBytes - - desc := "" - if len(description) > 0 { - desc = description[0] - } - return NewOptions64( - maxBytes, - OptionSetDescription(desc), - OptionSetWriter(io.Discard), - OptionShowBytes(true), - OptionShowTotalBytes(true), - OptionSetWidth(10), - OptionThrottle(65*time.Millisecond), - OptionShowCount(), - OptionSpinnerType(14), - OptionFullWidth(), - ) -} - -// Default provides a progressbar with recommended defaults. -// Set max to -1 to use as a spinner. -func Default(max int64, description ...string) *ProgressBar { - desc := "" - if len(description) > 0 { - desc = description[0] - } - return NewOptions64( - max, - OptionSetDescription(desc), - OptionSetWriter(os.Stderr), - OptionSetWidth(10), - OptionShowTotalBytes(true), - OptionThrottle(65*time.Millisecond), - OptionShowCount(), - OptionShowIts(), - OptionOnCompletion(func() { - fmt.Fprint(os.Stderr, "\n") - }), - OptionSpinnerType(14), - OptionFullWidth(), - OptionSetRenderBlankState(true), - ) -} - -// DefaultSilent is the same as Default, but does not output anywhere. -// String() can be used to get the output instead. -func DefaultSilent(max int64, description ...string) *ProgressBar { - // Mostly the same bar as Default - - desc := "" - if len(description) > 0 { - desc = description[0] - } - return NewOptions64( - max, - OptionSetDescription(desc), - OptionSetWriter(io.Discard), - OptionSetWidth(10), - OptionShowTotalBytes(true), - OptionThrottle(65*time.Millisecond), - OptionShowCount(), - OptionShowIts(), - OptionSpinnerType(14), - OptionFullWidth(), - ) -} - -// String returns the current rendered version of the progress bar. -// It will never return an empty string while the progress bar is running. -func (p *ProgressBar) String() string { - return p.state.rendered -} - -// RenderBlank renders the current bar state, you can use this to render a 0% state -func (p *ProgressBar) RenderBlank() error { - p.lock.Lock() - defer p.lock.Unlock() - - if p.config.invisible { - return nil - } - if p.state.currentNum == 0 { - p.state.lastShown = time.Time{} - } - return p.render() -} - -// StartWithoutRender will start the progress bar without rendering it -// this method is created for the use case where you want to start the progress -// but don't want to render it immediately. -// If you want to start the progress and render it immediately, use RenderBlank instead, -// or maybe you can use Add to start it automatically, but it will make the time calculation less precise. -func (p *ProgressBar) StartWithoutRender() { - p.lock.Lock() - defer p.lock.Unlock() - - if p.IsStarted() { - return - } - - p.state.startTime = time.Now() - // the counterTime should be set to the current time - p.state.counterTime = time.Now() -} - -// Reset will reset the clock that is used -// to calculate current time and the time left. -func (p *ProgressBar) Reset() { - p.lock.Lock() - defer p.lock.Unlock() - - p.state = getBasicState() -} - -// Finish will fill the bar to full -func (p *ProgressBar) Finish() error { - p.lock.Lock() - p.state.currentNum = p.config.max - if !p.config.ignoreLength { - p.state.currentBytes = float64(p.config.max) - } - p.lock.Unlock() - return p.Add(0) -} - -// Exit will exit the bar to keep current state -func (p *ProgressBar) Exit() error { - p.lock.Lock() - defer p.lock.Unlock() - - p.state.exit = true - if p.config.onCompletion != nil { - p.config.onCompletion() - } - return nil -} - -// Add will add the specified amount to the progressbar -func (p *ProgressBar) Add(num int) error { - return p.Add64(int64(num)) -} - -// Set will set the bar to a current number -func (p *ProgressBar) Set(num int) error { - return p.Set64(int64(num)) -} - -// Set64 will set the bar to a current number -func (p *ProgressBar) Set64(num int64) error { - p.lock.Lock() - toAdd := num - int64(p.state.currentBytes) - p.lock.Unlock() - return p.Add64(toAdd) -} - -// Add64 will add the specified amount to the progressbar -func (p *ProgressBar) Add64(num int64) error { - if p.config.invisible { - return nil - } - p.lock.Lock() - defer p.lock.Unlock() - - if p.state.exit { - return nil - } - - // error out since OptionSpinnerCustom will always override a manually set spinnerType - if p.config.spinnerTypeOptionUsed && len(p.config.spinner) > 0 { - return errors.New("OptionSpinnerType and OptionSpinnerCustom cannot be used together") - } - - if p.config.max == 0 { - return errors.New("max must be greater than 0") - } - - if p.state.currentNum < p.config.max { - if p.config.ignoreLength { - p.state.currentNum = (p.state.currentNum + num) % p.config.max - } else { - p.state.currentNum += num - } - } - - p.state.currentBytes += float64(num) - - if p.state.counterTime.IsZero() { - p.state.counterTime = time.Now() - } - - // reset the countdown timer every second to take rolling average - p.state.counterNumSinceLast += num - if time.Since(p.state.counterTime).Seconds() > 0.5 { - p.state.counterLastTenRates = append(p.state.counterLastTenRates, float64(p.state.counterNumSinceLast)/time.Since(p.state.counterTime).Seconds()) - if len(p.state.counterLastTenRates) > 10 { - p.state.counterLastTenRates = p.state.counterLastTenRates[1:] - } - p.state.counterTime = time.Now() - p.state.counterNumSinceLast = 0 - } - - percent := float64(p.state.currentNum) / float64(p.config.max) - p.state.currentSaucerSize = int(percent * float64(p.config.width)) - p.state.currentPercent = int(percent * 100) - updateBar := p.state.currentPercent != p.state.lastPercent && p.state.currentPercent > 0 - - p.state.lastPercent = p.state.currentPercent - if p.state.currentNum > p.config.max { - return errors.New("current number exceeds max") - } - - // always update if show bytes/second or its/second - if updateBar || p.config.showIterationsPerSecond || p.config.showIterationsCount { - return p.render() - } - - return nil -} - -// AddDetail adds a detail to the progress bar. Only used when maxDetailRow is set to a value greater than 0 -func (p *ProgressBar) AddDetail(detail string) error { - if p.config.maxDetailRow == 0 { - return errors.New("maxDetailRow is set to 0, cannot add detail") - } - if p.IsFinished() { - return errors.New("cannot add detail to a finished progress bar") - } - - p.lock.Lock() - defer p.lock.Unlock() - if p.state.details == nil { - // if we add a detail before the first add, it will be weird that we have detail but don't have the progress bar in the top. - // so when we add the first detail, we will render the progress bar first. - if err := p.render(); err != nil { - return err - } - } - p.state.details = append(p.state.details, detail) - if len(p.state.details) > p.config.maxDetailRow { - p.state.details = p.state.details[1:] - } - if err := p.renderDetails(); err != nil { - return err - } - return nil -} - -// renderDetails renders the details of the progress bar -func (p *ProgressBar) renderDetails() error { - if p.config.invisible { - return nil - } - if p.state.finished { - return nil - } - if p.config.maxDetailRow == 0 { - return nil - } - - b := strings.Builder{} - b.WriteString("\n") - - // render the details row - for _, detail := range p.state.details { - b.WriteString(fmt.Sprintf("\u001B[K\r%s\n", detail)) - } - // add empty lines to fill the maxDetailRow - for i := len(p.state.details); i < p.config.maxDetailRow; i++ { - b.WriteString("\u001B[K\n") - } - - // move the cursor up to the start of the details row - b.WriteString(fmt.Sprintf("\u001B[%dF", p.config.maxDetailRow+1)) - - writeString(p.config, b.String()) - - return nil -} - -// Clear erases the progress bar from the current line -func (p *ProgressBar) Clear() error { - return clearProgressBar(p.config, p.state) -} - -// Describe will change the description shown before the progress, which -// can be changed on the fly (as for a slow running process). -func (p *ProgressBar) Describe(description string) { - p.lock.Lock() - defer p.lock.Unlock() - p.config.description = description - if p.config.invisible { - return - } - p.render() -} - -// New64 returns a new ProgressBar -// with the specified maximum -func New64(max int64) *ProgressBar { - return NewOptions64(max) -} - -// GetMax returns the max of a bar -func (p *ProgressBar) GetMax() int { - p.lock.Lock() - defer p.lock.Unlock() - - return int(p.config.max) -} - -// GetMax64 returns the current max -func (p *ProgressBar) GetMax64() int64 { - p.lock.Lock() - defer p.lock.Unlock() - - return p.config.max -} - -// ChangeMax takes in a int -// and changes the max value -// of the progress bar -func (p *ProgressBar) ChangeMax(newMax int) { - p.ChangeMax64(int64(newMax)) -} - -// ChangeMax64 is basically -// the same as ChangeMax, -// but takes in a int64 -// to avoid casting -func (p *ProgressBar) ChangeMax64(newMax int64) { - p.lock.Lock() - - p.config.max = newMax - - if p.config.showBytes { - p.config.maxHumanized, p.config.maxHumanizedSuffix = humanizeBytes(float64(p.config.max), - p.config.useIECUnits) - } - - if newMax == -1 { - p.lengthUnknown() - } else { - p.lengthKnown(newMax) - } - p.lock.Unlock() // so p.Add can lock - - p.Add(0) // re-render -} - -// AddMax takes in a int -// and adds it to the max -// value of the progress bar -func (p *ProgressBar) AddMax(added int) { - p.AddMax64(int64(added)) -} - -// AddMax64 is basically -// the same as AddMax, -// but takes in a int64 -// to avoid casting -func (p *ProgressBar) AddMax64(added int64) { - p.lock.Lock() - - p.config.max += added - - if p.config.showBytes { - p.config.maxHumanized, p.config.maxHumanizedSuffix = humanizeBytes(float64(p.config.max), - p.config.useIECUnits) - } - - if p.config.max == -1 { - p.lengthUnknown() - } else { - p.lengthKnown(p.config.max) - } - p.lock.Unlock() // so p.Add can lock - - p.Add(0) // re-render -} - -// IsFinished returns true if progress bar is completed -func (p *ProgressBar) IsFinished() bool { - p.lock.Lock() - defer p.lock.Unlock() - - return p.state.finished -} - -// IsStarted returns true if progress bar is started -func (p *ProgressBar) IsStarted() bool { - return !p.state.startTime.IsZero() -} - -// render renders the progress bar, updating the maximum -// rendered line width. this function is not thread-safe, -// so it must be called with an acquired lock. -func (p *ProgressBar) render() error { - // make sure that the rendering is not happening too quickly - // but always show if the currentNum reaches the max - if !p.IsStarted() { - p.state.startTime = time.Now() - } else if time.Since(p.state.lastShown).Nanoseconds() < p.config.throttleDuration.Nanoseconds() && - p.state.currentNum < p.config.max { - return nil - } - - if !p.config.useANSICodes { - // first, clear the existing progress bar, if not yet finished. - if !p.state.finished { - err := clearProgressBar(p.config, p.state) - if err != nil { - return err - } - } - } - - // check if the progress bar is finished - if !p.state.finished && p.state.currentNum >= p.config.max { - p.state.finished = true - if !p.config.clearOnFinish { - io.Copy(p.config.writer, &p.config.stdBuffer) - renderProgressBar(p.config, &p.state) - } - if p.config.maxDetailRow > 0 { - p.renderDetails() - // put the cursor back to the last line of the details - writeString(p.config, fmt.Sprintf("\u001B[%dB\r\u001B[%dC", p.config.maxDetailRow, len(p.state.details[len(p.state.details)-1]))) - } - if p.config.onCompletion != nil { - p.config.onCompletion() - } - } - if p.state.finished { - // when using ANSI codes we don't pre-clean the current line - if p.config.useANSICodes && p.config.clearOnFinish { - err := clearProgressBar(p.config, p.state) - if err != nil { - return err - } - } - return nil - } - - // then, re-render the current progress bar - io.Copy(p.config.writer, &p.config.stdBuffer) - w, err := renderProgressBar(p.config, &p.state) - if err != nil { - return err - } - - if w > p.state.maxLineWidth { - p.state.maxLineWidth = w - } - - p.state.lastShown = time.Now() - - return nil -} - -// lengthUnknown sets the progress bar to ignore the length -func (p *ProgressBar) lengthUnknown() { - p.config.ignoreLength = true - p.config.max = int64(p.config.width) - p.config.predictTime = false -} - -// lengthKnown sets the progress bar to do not ignore the length -func (p *ProgressBar) lengthKnown(max int64) { - p.config.ignoreLength = false - p.config.max = max - p.config.predictTime = true -} - -// State returns the current state -func (p *ProgressBar) State() State { - p.lock.Lock() - defer p.lock.Unlock() - s := State{} - s.CurrentNum = p.state.currentNum - s.Max = p.config.max - if p.config.ignoreLength { - s.Max = -1 - } - s.CurrentPercent = float64(p.state.currentNum) / float64(p.config.max) - s.CurrentBytes = p.state.currentBytes - if p.IsStarted() { - s.SecondsSince = time.Since(p.state.startTime).Seconds() - } else { - s.SecondsSince = 0 - } - - if p.state.currentNum > 0 { - s.SecondsLeft = s.SecondsSince / float64(p.state.currentNum) * (float64(p.config.max) - float64(p.state.currentNum)) - } - s.KBsPerSecond = float64(p.state.currentBytes) / 1024.0 / s.SecondsSince - s.Description = p.config.description - return s -} - -// StartHTTPServer starts an HTTP server dedicated to serving progress bar updates. This allows you to -// display the status in various UI elements, such as an OS status bar with an `xbar` extension. -// It is recommended to run this function in a separate goroutine to avoid blocking the main thread. -// -// hostPort specifies the address and port to bind the server to, for example, "0.0.0.0:19999". -func (p *ProgressBar) StartHTTPServer(hostPort string) { - // for advanced users, we can return the data as json - http.HandleFunc("/state", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/json") - // since the state is a simple struct, we can just ignore the error - bs, _ := json.Marshal(p.State()) - w.Write(bs) - }) - // for others, we just return the description in a plain text format - http.HandleFunc("/desc", func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "text/plain") - fmt.Fprintf(w, - "%d/%d, %.2f%%, %s left", - p.State().CurrentNum, p.State().Max, p.State().CurrentPercent*100, - (time.Second * time.Duration(p.State().SecondsLeft)).String(), - ) - }) - log.Fatal(http.ListenAndServe(hostPort, nil)) -} - -// regex matching ansi escape codes -var ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`) - -func getStringWidth(c config, str string, colorize bool) int { - if c.colorCodes { - // convert any color codes in the progress bar into the respective ANSI codes - str = colorstring.Color(str) - } - - // the width of the string, if printed to the console - // does not include the carriage return character - cleanString := strings.Replace(str, "\r", "", -1) - - if c.colorCodes { - // the ANSI codes for the colors do not take up space in the console output, - // so they do not count towards the output string width - cleanString = ansiRegex.ReplaceAllString(cleanString, "") - } - - // get the amount of runes in the string instead of the - // character count of the string, as some runes span multiple characters. - // see https://stackoverflow.com/a/12668840/2733724 - stringWidth := uniseg.StringWidth(cleanString) - return stringWidth -} - -func renderProgressBar(c config, s *state) (int, error) { - var sb strings.Builder - - averageRate := average(s.counterLastTenRates) - if len(s.counterLastTenRates) == 0 || s.finished { - // if no average samples, or if finished, - // then average rate should be the total rate - if t := time.Since(s.startTime).Seconds(); t > 0 { - averageRate = s.currentBytes / t - } else { - averageRate = 0 - } - } - - // show iteration count in "current/total" iterations format - if c.showIterationsCount { - if sb.Len() == 0 { - sb.WriteString("(") - } else { - sb.WriteString(", ") - } - if !c.ignoreLength { - if c.showBytes { - currentHumanize, currentSuffix := humanizeBytes(s.currentBytes, c.useIECUnits) - if currentSuffix == c.maxHumanizedSuffix { - if c.showTotalBytes { - sb.WriteString(fmt.Sprintf("%s/%s%s", - currentHumanize, c.maxHumanized, c.maxHumanizedSuffix)) - } else { - sb.WriteString(fmt.Sprintf("%s%s", - currentHumanize, c.maxHumanizedSuffix)) - } - } else if c.showTotalBytes { - sb.WriteString(fmt.Sprintf("%s%s/%s%s", - currentHumanize, currentSuffix, c.maxHumanized, c.maxHumanizedSuffix)) - } else { - sb.WriteString(fmt.Sprintf("%s%s", currentHumanize, currentSuffix)) - } - } else if c.showTotalBytes { - sb.WriteString(fmt.Sprintf("%.0f/%d", s.currentBytes, c.max)) - } else { - sb.WriteString(fmt.Sprintf("%.0f", s.currentBytes)) - } - } else { - if c.showBytes { - currentHumanize, currentSuffix := humanizeBytes(s.currentBytes, c.useIECUnits) - sb.WriteString(fmt.Sprintf("%s%s", currentHumanize, currentSuffix)) - } else if c.showTotalBytes { - sb.WriteString(fmt.Sprintf("%.0f/%s", s.currentBytes, "-")) - } else { - sb.WriteString(fmt.Sprintf("%.0f", s.currentBytes)) - } - } - } - - // show rolling average rate - if c.showBytes && averageRate > 0 && !math.IsInf(averageRate, 1) { - if sb.Len() == 0 { - sb.WriteString("(") - } else { - sb.WriteString(", ") - } - currentHumanize, currentSuffix := humanizeBytes(averageRate, c.useIECUnits) - sb.WriteString(fmt.Sprintf("%s%s/s", currentHumanize, currentSuffix)) - } - - // show iterations rate - if c.showIterationsPerSecond { - if sb.Len() == 0 { - sb.WriteString("(") - } else { - sb.WriteString(", ") - } - if averageRate > 1 { - sb.WriteString(fmt.Sprintf("%0.0f %s/s", averageRate, c.iterationString)) - } else if averageRate*60 > 1 { - sb.WriteString(fmt.Sprintf("%0.0f %s/min", 60*averageRate, c.iterationString)) - } else { - sb.WriteString(fmt.Sprintf("%0.0f %s/hr", 3600*averageRate, c.iterationString)) - } - } - if sb.Len() > 0 { - sb.WriteString(")") - } - - leftBrac, rightBrac, saucer, saucerHead := "", "", "", "" - barStart, barEnd := c.theme.BarStart, c.theme.BarEnd - if s.finished && c.theme.BarEndFilled != "" { - barEnd = c.theme.BarEndFilled - } - - // show time prediction in "current/total" seconds format - switch { - case c.predictTime: - rightBracNum := (time.Duration((1/averageRate)*(float64(c.max)-float64(s.currentNum))) * time.Second) - if rightBracNum.Seconds() < 0 { - rightBracNum = 0 * time.Second - } - rightBrac = rightBracNum.String() - fallthrough - case c.elapsedTime || c.showElapsedTimeOnFinish: - leftBrac = (time.Duration(time.Since(s.startTime).Seconds()) * time.Second).String() - } - - if c.fullWidth && !c.ignoreLength { - width, err := termWidth() - if err != nil { - width = 80 - } - - amend := 1 // an extra space at eol - switch { - case leftBrac != "" && rightBrac != "": - amend = 4 // space, square brackets and colon - case leftBrac != "" && rightBrac == "": - amend = 4 // space and square brackets and another space - case leftBrac == "" && rightBrac != "": - amend = 3 // space and square brackets - } - if c.showDescriptionAtLineEnd { - amend += 1 // another space - } - - c.width = width - getStringWidth(c, c.description, true) - 10 - amend - sb.Len() - len(leftBrac) - len(rightBrac) - s.currentSaucerSize = int(float64(s.currentPercent) / 100.0 * float64(c.width)) - } - if (s.currentSaucerSize > 0 || s.currentPercent > 0) && c.theme.BarStartFilled != "" { - barStart = c.theme.BarStartFilled - } - if s.currentSaucerSize > 0 { - if c.ignoreLength { - saucer = strings.Repeat(c.theme.SaucerPadding, s.currentSaucerSize-1) - } else { - saucer = strings.Repeat(c.theme.Saucer, s.currentSaucerSize-1) - } - - // Check if an alternate saucer head is set for animation - if c.theme.AltSaucerHead != "" && s.isAltSaucerHead { - saucerHead = c.theme.AltSaucerHead - s.isAltSaucerHead = false - } else if c.theme.SaucerHead == "" || s.currentSaucerSize == c.width { - // use the saucer for the saucer head if it hasn't been set - // to preserve backwards compatibility - saucerHead = c.theme.Saucer - } else { - saucerHead = c.theme.SaucerHead - s.isAltSaucerHead = true - } - } - - /* - Progress Bar format - Description % |------ | (kb/s) (iteration count) (iteration rate) (predict time) - - or if showDescriptionAtLineEnd is enabled - % |------ | (kb/s) (iteration count) (iteration rate) (predict time) Description - */ - - repeatAmount := c.width - s.currentSaucerSize - if repeatAmount < 0 { - repeatAmount = 0 - } - - str := "" - - if c.ignoreLength { - selectedSpinner := spinners[c.spinnerType] - if len(c.spinner) > 0 { - selectedSpinner = c.spinner - } - - var spinner string - if c.spinnerChangeInterval != 0 { - // if the spinner is changed according to an interval, calculate it - spinner = selectedSpinner[int(math.Round(math.Mod(float64(time.Since(s.startTime).Nanoseconds()/c.spinnerChangeInterval.Nanoseconds()), float64(len(selectedSpinner)))))] - } else { - // if the spinner is changed according to the number render was called - spinner = selectedSpinner[s.spinnerIdx] - s.spinnerIdx = (s.spinnerIdx + 1) % len(selectedSpinner) - } - if c.elapsedTime { - if c.showDescriptionAtLineEnd { - str = fmt.Sprintf("\r%s %s [%s] %s ", - spinner, - sb.String(), - leftBrac, - c.description) - } else { - str = fmt.Sprintf("\r%s %s %s [%s] ", - spinner, - c.description, - sb.String(), - leftBrac) - } - } else { - if c.showDescriptionAtLineEnd { - str = fmt.Sprintf("\r%s %s %s ", - spinner, - sb.String(), - c.description) - } else { - str = fmt.Sprintf("\r%s %s %s ", - spinner, - c.description, - sb.String()) - } - } - } else if rightBrac == "" { - str = fmt.Sprintf("%4d%% %s%s%s%s%s %s", - s.currentPercent, - barStart, - saucer, - saucerHead, - strings.Repeat(c.theme.SaucerPadding, repeatAmount), - barEnd, - sb.String()) - if (s.currentPercent == 100 && c.showElapsedTimeOnFinish) || c.elapsedTime { - str = fmt.Sprintf("%s [%s]", str, leftBrac) - } - - if c.showDescriptionAtLineEnd { - str = fmt.Sprintf("\r%s %s ", str, c.description) - } else { - str = fmt.Sprintf("\r%s%s ", c.description, str) - } - } else { - if s.currentPercent == 100 { - str = fmt.Sprintf("%4d%% %s%s%s%s%s %s", - s.currentPercent, - barStart, - saucer, - saucerHead, - strings.Repeat(c.theme.SaucerPadding, repeatAmount), - barEnd, - sb.String()) - - if c.showElapsedTimeOnFinish { - str = fmt.Sprintf("%s [%s]", str, leftBrac) - } - - if c.showDescriptionAtLineEnd { - str = fmt.Sprintf("\r%s %s", str, c.description) - } else { - str = fmt.Sprintf("\r%s%s", c.description, str) - } - } else { - str = fmt.Sprintf("%4d%% %s%s%s%s%s %s [%s:%s]", - s.currentPercent, - barStart, - saucer, - saucerHead, - strings.Repeat(c.theme.SaucerPadding, repeatAmount), - barEnd, - sb.String(), - leftBrac, - rightBrac) - - if c.showDescriptionAtLineEnd { - str = fmt.Sprintf("\r%s %s", str, c.description) - } else { - str = fmt.Sprintf("\r%s%s", c.description, str) - } - } - } - - if c.colorCodes { - // convert any color codes in the progress bar into the respective ANSI codes - str = colorstring.Color(str) - } - - s.rendered = str - - return getStringWidth(c, str, false), writeString(c, str) -} - -func clearProgressBar(c config, s state) error { - if s.maxLineWidth == 0 { - return nil - } - if c.useANSICodes { - // write the "clear current line" ANSI escape sequence - return writeString(c, "\033[2K\r") - } - // fill the empty content - // to overwrite the progress bar and jump - // back to the beginning of the line - str := fmt.Sprintf("\r%s\r", strings.Repeat(" ", s.maxLineWidth)) - return writeString(c, str) - // the following does not show correctly if the previous line is longer than subsequent line - // return writeString(c, "\r") -} - -func writeString(c config, str string) error { - if _, err := io.WriteString(c.writer, str); err != nil { - return err - } - - if f, ok := c.writer.(*os.File); ok { - // ignore any errors in Sync(), as stdout - // can't be synced on some operating systems - // like Debian 9 (Stretch) - f.Sync() - } - - return nil -} - -// Reader is the progressbar io.Reader struct -type Reader struct { - io.Reader - bar *ProgressBar -} - -// NewReader return a new Reader with a given progress bar. -func NewReader(r io.Reader, bar *ProgressBar) Reader { - return Reader{ - Reader: r, - bar: bar, - } -} - -// Read will read the data and add the number of bytes to the progressbar -func (r *Reader) Read(p []byte) (n int, err error) { - n, err = r.Reader.Read(p) - r.bar.Add(n) - return -} - -// Close the reader when it implements io.Closer -func (r *Reader) Close() (err error) { - if closer, ok := r.Reader.(io.Closer); ok { - return closer.Close() - } - r.bar.Finish() - return -} - -// Write implement io.Writer -func (p *ProgressBar) Write(b []byte) (n int, err error) { - n = len(b) - err = p.Add(n) - return -} - -// Read implement io.Reader -func (p *ProgressBar) Read(b []byte) (n int, err error) { - n = len(b) - err = p.Add(n) - return -} - -func (p *ProgressBar) Close() (err error) { - err = p.Finish() - return -} - -func average(xs []float64) float64 { - total := 0.0 - for _, v := range xs { - total += v - } - return total / float64(len(xs)) -} - -func humanizeBytes(s float64, iec bool) (string, string) { - sizes := []string{" B", " kB", " MB", " GB", " TB", " PB", " EB"} - base := 1000.0 - - if iec { - sizes = []string{" B", " KiB", " MiB", " GiB", " TiB", " PiB", " EiB"} - base = 1024.0 - } - - if s < 10 { - return fmt.Sprintf("%2.0f", s), sizes[0] - } - e := math.Floor(logn(float64(s), base)) - suffix := sizes[int(e)] - val := math.Floor(float64(s)/math.Pow(base, e)*10+0.5) / 10 - f := "%.0f" - if val < 10 { - f = "%.1f" - } - - return fmt.Sprintf(f, val), suffix -} - -func logn(n, b float64) float64 { - return math.Log(n) / math.Log(b) -} - -// termWidth function returns the visible width of the current terminal -// and can be redefined for testing -var termWidth = func() (width int, err error) { - width, _, err = term.GetSize(int(os.Stdout.Fd())) - if err == nil { - return width, nil - } - - return 0, err -} - -func shouldCacheOutput(pb *ProgressBar) bool { - return !pb.state.finished && !pb.state.exit && !pb.config.invisible -} - -func Bprintln(pb *ProgressBar, a ...interface{}) (int, error) { - pb.lock.Lock() - defer pb.lock.Unlock() - if !shouldCacheOutput(pb) { - return fmt.Fprintln(pb.config.writer, a...) - } else { - return fmt.Fprintln(&pb.config.stdBuffer, a...) - } -} - -func Bprintf(pb *ProgressBar, format string, a ...interface{}) (int, error) { - pb.lock.Lock() - defer pb.lock.Unlock() - if !shouldCacheOutput(pb) { - return fmt.Fprintf(pb.config.writer, format, a...) - } else { - return fmt.Fprintf(&pb.config.stdBuffer, format, a...) - } -} diff --git a/vendor/github.com/schollz/progressbar/v3/spinners.go b/vendor/github.com/schollz/progressbar/v3/spinners.go deleted file mode 100644 index c3ccd01f..00000000 --- a/vendor/github.com/schollz/progressbar/v3/spinners.go +++ /dev/null @@ -1,80 +0,0 @@ -package progressbar - -var spinners = map[int][]string{ - 0: {"←", "↖", "↑", "↗", "→", "↘", "↓", "↙"}, - 1: {"▁", "▃", "▄", "▅", "▆", "▇", "█", "▇", "▆", "▅", "▄", "▃", "▁"}, - 2: {"▖", "▘", "▝", "▗"}, - 3: {"┤", "┘", "┴", "└", "├", "┌", "┬", "┐"}, - 4: {"◢", "◣", "◤", "◥"}, - 5: {"◰", "◳", "◲", "◱"}, - 6: {"◴", "◷", "◶", "◵"}, - 7: {"◐", "◓", "◑", "◒"}, - 8: {".", "o", "O", "@", "*"}, - 9: {"|", "/", "-", "\\"}, - 10: {"◡◡", "⊙⊙", "◠◠"}, - 11: {"⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"}, - 12: {">))'>", " >))'>", " >))'>", " >))'>", " >))'>", " <'((<", " <'((<", " <'((<"}, - 13: {"⠁", "⠂", "⠄", "⡀", "⢀", "⠠", "⠐", "⠈"}, - 14: {"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}, - 15: {"a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z"}, - 16: {"▉", "▊", "▋", "▌", "▍", "▎", "▏", "▎", "▍", "▌", "▋", "▊", "▉"}, - 17: {"■", "□", "▪", "▫"}, - 18: {"←", "↑", "→", "↓"}, - 19: {"╫", "╪"}, - 20: {"⇐", "⇖", "⇑", "⇗", "⇒", "⇘", "⇓", "⇙"}, - 21: {"⠁", "⠁", "⠉", "⠙", "⠚", "⠒", "⠂", "⠂", "⠒", "⠲", "⠴", "⠤", "⠄", "⠄", "⠤", "⠠", "⠠", "⠤", "⠦", "⠖", "⠒", "⠐", "⠐", "⠒", "⠓", "⠋", "⠉", "⠈", "⠈"}, - 22: {"⠈", "⠉", "⠋", "⠓", "⠒", "⠐", "⠐", "⠒", "⠖", "⠦", "⠤", "⠠", "⠠", "⠤", "⠦", "⠖", "⠒", "⠐", "⠐", "⠒", "⠓", "⠋", "⠉", "⠈"}, - 23: {"⠁", "⠉", "⠙", "⠚", "⠒", "⠂", "⠂", "⠒", "⠲", "⠴", "⠤", "⠄", "⠄", "⠤", "⠴", "⠲", "⠒", "⠂", "⠂", "⠒", "⠚", "⠙", "⠉", "⠁"}, - 24: {"⠋", "⠙", "⠚", "⠒", "⠂", "⠂", "⠒", "⠲", "⠴", "⠦", "⠖", "⠒", "⠐", "⠐", "⠒", "⠓", "⠋"}, - 25: {"ヲ", "ァ", "ィ", "ゥ", "ェ", "ォ", "ャ", "ュ", "ョ", "ッ", "ア", "イ", "ウ", "エ", "オ", "カ", "キ", "ク", "ケ", "コ", "サ", "シ", "ス", "セ", "ソ", "タ", "チ", "ツ", "テ", "ト", "ナ", "ニ", "ヌ", "ネ", "ノ", "ハ", "ヒ", "フ", "ヘ", "ホ", "マ", "ミ", "ム", "メ", "モ", "ヤ", "ユ", "ヨ", "ラ", "リ", "ル", "レ", "ロ", "ワ", "ン"}, - 26: {".", "..", "..."}, - 27: {"▁", "▂", "▃", "▄", "▅", "▆", "▇", "█", "▉", "▊", "▋", "▌", "▍", "▎", "▏", "▏", "▎", "▍", "▌", "▋", "▊", "▉", "█", "▇", "▆", "▅", "▄", "▃", "▂", "▁"}, - 28: {".", "o", "O", "°", "O", "o", "."}, - 29: {"+", "x"}, - 30: {"v", "<", "^", ">"}, - 31: {">>--->", " >>--->", " >>--->", " >>--->", " >>--->", " <---<<", " <---<<", " <---<<", " <---<<", "<---<<"}, - 32: {"|", "||", "|||", "||||", "|||||", "|||||||", "||||||||", "|||||||", "||||||", "|||||", "||||", "|||", "||", "|"}, - 33: {"[ ]", "[= ]", "[== ]", "[=== ]", "[==== ]", "[===== ]", "[====== ]", "[======= ]", "[======== ]", "[========= ]", "[==========]"}, - 34: {"(*---------)", "(-*--------)", "(--*-------)", "(---*------)", "(----*-----)", "(-----*----)", "(------*---)", "(-------*--)", "(--------*-)", "(---------*)"}, - 35: {"█▒▒▒▒▒▒▒▒▒", "███▒▒▒▒▒▒▒", "█████▒▒▒▒▒", "███████▒▒▒", "██████████"}, - 36: {"[ ]", "[=> ]", "[===> ]", "[=====> ]", "[======> ]", "[========> ]", "[==========> ]", "[============> ]", "[==============> ]", "[================> ]", "[==================> ]", "[===================>]"}, - 37: {"ဝ", "၀"}, - 38: {"▌", "▀", "▐▄"}, - 39: {"🌍", "🌎", "🌏"}, - 40: {"◜", "◝", "◞", "◟"}, - 41: {"⬒", "⬔", "⬓", "⬕"}, - 42: {"⬖", "⬘", "⬗", "⬙"}, - 43: {"[>>> >]", "[]>>>> []", "[] >>>> []", "[] >>>> []", "[] >>>> []", "[] >>>>[]", "[>> >>]"}, - 44: {"♠", "♣", "♥", "♦"}, - 45: {"➞", "➟", "➠", "➡", "➠", "➟"}, - 46: {" | ", ` \ `, "_ ", ` \ `, " | ", " / ", " _", " / "}, - 47: {" . . . .", ". . . .", ". . . .", ". . . .", ". . . . ", ". . . . ."}, - 48: {" | ", " / ", " _ ", ` \ `, " | ", ` \ `, " _ ", " / "}, - 49: {"⎺", "⎻", "⎼", "⎽", "⎼", "⎻"}, - 50: {"▹▹▹▹▹", "▸▹▹▹▹", "▹▸▹▹▹", "▹▹▸▹▹", "▹▹▹▸▹", "▹▹▹▹▸"}, - 51: {"[ ]", "[ =]", "[ ==]", "[ ===]", "[====]", "[=== ]", "[== ]", "[= ]"}, - 52: {"( ● )", "( ● )", "( ● )", "( ● )", "( ●)", "( ● )", "( ● )", "( ● )", "( ● )"}, - 53: {"✶", "✸", "✹", "✺", "✹", "✷"}, - 54: {"▐|\\____________▌", "▐_|\\___________▌", "▐__|\\__________▌", "▐___|\\_________▌", "▐____|\\________▌", "▐_____|\\_______▌", "▐______|\\______▌", "▐_______|\\_____▌", "▐________|\\____▌", "▐_________|\\___▌", "▐__________|\\__▌", "▐___________|\\_▌", "▐____________|\\▌", "▐____________/|▌", "▐___________/|_▌", "▐__________/|__▌", "▐_________/|___▌", "▐________/|____▌", "▐_______/|_____▌", "▐______/|______▌", "▐_____/|_______▌", "▐____/|________▌", "▐___/|_________▌", "▐__/|__________▌", "▐_/|___________▌", "▐/|____________▌"}, - 55: {"▐⠂ ▌", "▐⠈ ▌", "▐ ⠂ ▌", "▐ ⠠ ▌", "▐ ⡀ ▌", "▐ ⠠ ▌", "▐ ⠂ ▌", "▐ ⠈ ▌", "▐ ⠂ ▌", "▐ ⠠ ▌", "▐ ⡀ ▌", "▐ ⠠ ▌", "▐ ⠂ ▌", "▐ ⠈ ▌", "▐ ⠂▌", "▐ ⠠▌", "▐ ⡀▌", "▐ ⠠ ▌", "▐ ⠂ ▌", "▐ ⠈ ▌", "▐ ⠂ ▌", "▐ ⠠ ▌", "▐ ⡀ ▌", "▐ ⠠ ▌", "▐ ⠂ ▌", "▐ ⠈ ▌", "▐ ⠂ ▌", "▐ ⠠ ▌", "▐ ⡀ ▌", "▐⠠ ▌"}, - 56: {"¿", "?"}, - 57: {"⢹", "⢺", "⢼", "⣸", "⣇", "⡧", "⡗", "⡏"}, - 58: {"⢄", "⢂", "⢁", "⡁", "⡈", "⡐", "⡠"}, - 59: {". ", ".. ", "...", " ..", " .", " "}, - 60: {".", "o", "O", "°", "O", "o", "."}, - 61: {"▓", "▒", "░"}, - 62: {"▌", "▀", "▐", "▄"}, - 63: {"⊶", "⊷"}, - 64: {"▪", "▫"}, - 65: {"□", "■"}, - 66: {"▮", "▯"}, - 67: {"-", "=", "≡"}, - 68: {"d", "q", "p", "b"}, - 69: {"∙∙∙", "●∙∙", "∙●∙", "∙∙●", "∙∙∙"}, - 70: {"🌑 ", "🌒 ", "🌓 ", "🌔 ", "🌕 ", "🌖 ", "🌗 ", "🌘 "}, - 71: {"☗", "☖"}, - 72: {"⧇", "⧆"}, - 73: {"◉", "◎"}, - 74: {"㊂", "㊀", "㊁"}, - 75: {"⦾", "⦿"}, -} diff --git a/vendor/modules.txt b/vendor/modules.txt index c05ae4ac..71e12961 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -426,9 +426,6 @@ github.com/mattn/go-sqlite3 # github.com/miekg/pkcs11 v1.1.1 ## explicit; go 1.12 github.com/miekg/pkcs11 -# github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db -## explicit -github.com/mitchellh/colorstring # github.com/moby/spdystream v0.5.0 ## explicit; go 1.13 github.com/moby/spdystream @@ -545,9 +542,6 @@ github.com/prometheus/statsd_exporter/pkg/mapper/fsm # github.com/rivo/uniseg v0.4.7 ## explicit; go 1.18 github.com/rivo/uniseg -# github.com/schollz/progressbar/v3 v3.18.0 -## explicit; go 1.22 -github.com/schollz/progressbar/v3 # github.com/secure-systems-lab/go-securesystemslib v0.9.0 ## explicit; go 1.20 github.com/secure-systems-lab/go-securesystemslib/encrypted