diff --git a/api/v1alpha1/imagebuild_types.go b/api/v1alpha1/imagebuild_types.go index f4893923..b4aedef6 100644 --- a/api/v1alpha1/imagebuild_types.go +++ b/api/v1alpha1/imagebuild_types.go @@ -70,6 +70,11 @@ type FlashSpec struct { // FlashCmd overrides the flash command from OperatorConfig target mappings // +optional FlashCmd string `json:"flashCmd,omitempty"` + + // ExporterSelector overrides the exporter selector from OperatorConfig target mappings + // When set, the target-based lookup is skipped entirely + // +optional + ExporterSelector string `json:"exporterSelector,omitempty"` } // AIBSpec defines the automotive-image-builder configuration @@ -429,6 +434,14 @@ func (s *ImageBuildSpec) GetRebuildBuilder() bool { return false } +// GetFlashExporterSelector returns the user-specified exporter selector override, or empty string +func (s *ImageBuildSpec) GetFlashExporterSelector() string { + if s.Flash != nil { + return s.Flash.ExporterSelector + } + return "" +} + // GetFlashCmd returns the user-specified flash command override, or empty string func (s *ImageBuildSpec) GetFlashCmd() string { if s.Flash != nil { diff --git a/cmd/caib/buildcmd/build.go b/cmd/caib/buildcmd/build.go index d909bafb..3fecdbb8 100644 --- a/cmd/caib/buildcmd/build.go +++ b/cmd/caib/buildcmd/build.go @@ -63,6 +63,7 @@ type Options struct { JumpstarterClient *string LeaseDuration *string FlashCmd *string + ExporterSelector *string UseInternalRegistry *bool InternalRegistryImageName *string @@ -385,7 +386,8 @@ func (h *Handler) RunBuild(cmd *cobra.Command, args []string) { return } - operatorConfig, cfgErr := h.fetchTargetDefaults(ctx, api, *h.opts.Target, *h.opts.FlashAfterBuild) + validateFlash := *h.opts.FlashAfterBuild && *h.opts.ExporterSelector == "" + operatorConfig, cfgErr := h.fetchTargetDefaults(ctx, api, *h.opts.Target, validateFlash) if cfgErr != nil { h.handleError(cfgErr) return @@ -410,6 +412,7 @@ func (h *Handler) RunBuild(cmd *cobra.Command, args []string) { req.FlashClientConfig = base64.StdEncoding.EncodeToString(clientConfigBytes) req.FlashLeaseDuration = *h.opts.LeaseDuration req.FlashCmd = *h.opts.FlashCmd + req.FlashExporterSelector = *h.opts.ExporterSelector } resp, err := api.CreateBuild(ctx, req) @@ -502,7 +505,8 @@ func (h *Handler) RunDisk(cmd *cobra.Command, args []string) { return } - operatorConfig, cfgErr := h.fetchTargetDefaults(ctx, api, *h.opts.Target, *h.opts.FlashAfterBuild) + validateFlash := *h.opts.FlashAfterBuild && *h.opts.ExporterSelector == "" + operatorConfig, cfgErr := h.fetchTargetDefaults(ctx, api, *h.opts.Target, validateFlash) if cfgErr != nil { h.handleError(cfgErr) return @@ -527,6 +531,7 @@ func (h *Handler) RunDisk(cmd *cobra.Command, args []string) { req.FlashClientConfig = base64.StdEncoding.EncodeToString(clientConfigBytes) req.FlashLeaseDuration = *h.opts.LeaseDuration req.FlashCmd = *h.opts.FlashCmd + req.FlashExporterSelector = *h.opts.ExporterSelector } resp, err := api.CreateBuild(ctx, req) @@ -636,7 +641,8 @@ func (h *Handler) RunBuildDev(cmd *cobra.Command, args []string) { return } - operatorConfig, cfgErr := h.fetchTargetDefaults(ctx, api, *h.opts.Target, *h.opts.FlashAfterBuild) + validateFlash := *h.opts.FlashAfterBuild && *h.opts.ExporterSelector == "" + operatorConfig, cfgErr := h.fetchTargetDefaults(ctx, api, *h.opts.Target, validateFlash) if cfgErr != nil { h.handleError(cfgErr) return @@ -662,6 +668,7 @@ func (h *Handler) RunBuildDev(cmd *cobra.Command, args []string) { req.FlashClientConfig = base64.StdEncoding.EncodeToString(clientConfigBytes) req.FlashLeaseDuration = *h.opts.LeaseDuration req.FlashCmd = *h.opts.FlashCmd + req.FlashExporterSelector = *h.opts.ExporterSelector } resp, err := api.CreateBuild(ctx, req) diff --git a/cmd/caib/flashcmd/flash.go b/cmd/caib/flashcmd/flash.go index 13b159a4..49f893a5 100644 --- a/cmd/caib/flashcmd/flash.go +++ b/cmd/caib/flashcmd/flash.go @@ -15,6 +15,7 @@ import ( common "github.com/centos-automotive-suite/automotive-dev-operator/cmd/caib/common" "github.com/centos-automotive-suite/automotive-dev-operator/cmd/caib/logstream" + "github.com/centos-automotive-suite/automotive-dev-operator/cmd/caib/registryauth" buildapitypes "github.com/centos-automotive-suite/automotive-dev-operator/internal/buildapi" buildapiclient "github.com/centos-automotive-suite/automotive-dev-operator/internal/buildapi/client" "github.com/spf13/cobra" @@ -41,6 +42,7 @@ type Options struct { WaitForBuild *bool FollowLogs *bool InsecureSkipTLS *bool + RegistryAuthFile *string HandleError func(error) } @@ -120,6 +122,21 @@ func (h *Handler) RunFlash(cmd *cobra.Command, args []string) { FlashCmd: *h.opts.FlashCmd, } + // Resolve OCI registry credentials for the flash image + authFile := "" + if h.opts.RegistryAuthFile != nil { + authFile = *h.opts.RegistryAuthFile + } + registryURL, registryUsername, registryPassword := registryauth.ExtractRegistryCredentials(imageRef, "") + registryCreds, credErr := registryauth.ResolveRegistryCredentials( + registryURL, registryUsername, registryPassword, authFile, + ) + if credErr != nil { + h.handleError(fmt.Errorf("failed to resolve registry credentials: %w", credErr)) + return + } + req.RegistryCredentials = registryCreds + resp, err := api.CreateFlash(ctx, req) if err != nil { h.handleError(err) diff --git a/cmd/caib/image/image.go b/cmd/caib/image/image.go index a1faeee4..55b45167 100644 --- a/cmd/caib/image/image.go +++ b/cmd/caib/image/image.go @@ -142,6 +142,7 @@ func NewImageCmd(opts Options) *cobra.Command { buildCmd.Flags().StringVar(opts.JumpstarterClient, "client", "", "path to Jumpstarter client config file (required for --flash)") buildCmd.Flags().StringVar(opts.LeaseDuration, "lease", "03:00:00", "device lease duration for flash (HH:MM:SS)") buildCmd.Flags().StringVar(opts.FlashCmd, "flash-cmd", "", "override flash command (default: from OperatorConfig target mapping)") + buildCmd.Flags().StringVar(opts.ExporterSelector, "exporter", "", "direct exporter selector for flash (alternative to --target lookup)") // Internal registry options buildCmd.Flags().BoolVar(opts.UseInternalRegistry, "internal-registry", false, "push to OpenShift internal registry") buildCmd.Flags().StringVar(opts.InternalRegistryImageName, "image-name", "", "override image name for internal registry (default: build name)") @@ -198,6 +199,7 @@ func NewImageCmd(opts Options) *cobra.Command { diskCmd.Flags().StringVar(opts.JumpstarterClient, "client", "", "path to Jumpstarter client config file (required for --flash)") diskCmd.Flags().StringVar(opts.LeaseDuration, "lease", "03:00:00", "device lease duration for flash (HH:MM:SS)") diskCmd.Flags().StringVar(opts.FlashCmd, "flash-cmd", "", "override flash command (default: from OperatorConfig target mapping)") + diskCmd.Flags().StringVar(opts.ExporterSelector, "exporter", "", "direct exporter selector for flash (alternative to --target lookup)") // Internal registry options diskCmd.Flags().BoolVar(opts.UseInternalRegistry, "internal-registry", false, "push to OpenShift internal registry") diskCmd.Flags().StringVar(opts.InternalRegistryImageName, "image-name", "", "override image name for internal registry (default: build name)") @@ -236,6 +238,7 @@ func NewImageCmd(opts Options) *cobra.Command { buildDevCmd.Flags().StringVar(opts.JumpstarterClient, "client", "", "path to Jumpstarter client config file (required for --flash)") buildDevCmd.Flags().StringVar(opts.LeaseDuration, "lease", "03:00:00", "device lease duration for flash (HH:MM:SS)") buildDevCmd.Flags().StringVar(opts.FlashCmd, "flash-cmd", "", "override flash command (default: from OperatorConfig target mapping)") + buildDevCmd.Flags().StringVar(opts.ExporterSelector, "exporter", "", "direct exporter selector for flash (alternative to --target lookup)") // Internal registry options buildDevCmd.Flags().BoolVar(opts.UseInternalRegistry, "internal-registry", false, "push to OpenShift internal registry") buildDevCmd.Flags().StringVar(opts.InternalRegistryImageName, "image-name", "", "override image name for internal registry (default: build name)") @@ -260,6 +263,12 @@ func NewImageCmd(opts Options) *cobra.Command { flashCmd.Flags().StringVar(opts.ExporterSelector, "exporter", "", "direct exporter selector (alternative to --target)") flashCmd.Flags().StringVar(opts.LeaseDuration, "lease", "03:00:00", "device lease duration (HH:MM:SS)") flashCmd.Flags().StringVar(opts.FlashCmd, "flash-cmd", "", "override flash command (default: from OperatorConfig target mapping)") + flashCmd.Flags().StringVar( + opts.RegistryAuthFile, + "registry-auth-file", + "", + "path to Docker/Podman auth file for OCI image pull authentication (takes precedence over env vars and auto-discovery)", + ) flashCmd.Flags().BoolVarP(opts.FollowLogs, "follow", "f", false, "follow flash logs (shows full log output instead of progress bar)") flashCmd.Flags().BoolVarP(opts.WaitForBuild, "wait", "w", true, "wait for flash to complete") _ = flashCmd.MarkFlagRequired("client") diff --git a/cmd/caib/registryauth/loader.go b/cmd/caib/registryauth/loader.go index 8a5c81cd..52a0db6d 100644 --- a/cmd/caib/registryauth/loader.go +++ b/cmd/caib/registryauth/loader.go @@ -4,11 +4,12 @@ package registryauth import ( "encoding/json" "fmt" - "net/url" "os" "path/filepath" "strconv" "strings" + + "github.com/centos-automotive-suite/automotive-dev-operator/internal/common/registryutil" ) type authConfigFile struct { @@ -32,30 +33,8 @@ func authEntryHasCredentials(entry authConfigEntry) bool { return strings.TrimSpace(entry.Username) != "" && strings.TrimSpace(entry.Password) != "" } -func normalizeRegistryHost(raw string) string { - value := strings.TrimSpace(raw) - if value == "" { - return "" - } - if strings.Contains(value, "://") { - parsed, err := url.Parse(value) - if err == nil && parsed.Host != "" { - value = parsed.Host - } - } - value = strings.TrimPrefix(value, "//") - value = strings.SplitN(value, "/", 2)[0] - value = strings.TrimSuffix(value, "/") - return strings.ToLower(strings.TrimSpace(value)) -} - func registryAuthKeyMatches(authKey, registryURL string) bool { - keyHost := normalizeRegistryHost(authKey) - registryHost := normalizeRegistryHost(registryURL) - if keyHost == "" || registryHost == "" { - return false - } - return keyHost == registryHost + return registryutil.RegistryHostMatches(authKey, registryURL) } func authFileHasRegistryAuth(content []byte, registryURL string) (bool, error) { diff --git a/cmd/caib/runtime_wiring.go b/cmd/caib/runtime_wiring.go index b0eda719..2df47843 100644 --- a/cmd/caib/runtime_wiring.go +++ b/cmd/caib/runtime_wiring.go @@ -160,6 +160,7 @@ func (s runtimeState) newHandlers() handlerSet { JumpstarterClient: s.JumpstarterClient, LeaseDuration: s.LeaseDuration, FlashCmd: s.FlashCmd, + ExporterSelector: s.ExporterSelector, UseInternalRegistry: s.UseInternalRegistry, InternalRegistryImageName: s.InternalRegistryImageName, InternalRegistryTag: s.InternalRegistryTag, @@ -192,6 +193,7 @@ func (s runtimeState) newHandlers() handlerSet { WaitForBuild: s.WaitForBuild, FollowLogs: s.FollowLogs, InsecureSkipTLS: s.InsecureSkipTLS, + RegistryAuthFile: s.RegistryAuthFile, HandleError: handleError, }), sealed: sealedcmd.NewHandler(sealedcmd.Options{ 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 7abcf193..7cf3ac38 100644 --- a/config/crd/bases/automotive.sdv.cloud.redhat.com_imagebuilds.yaml +++ b/config/crd/bases/automotive.sdv.cloud.redhat.com_imagebuilds.yaml @@ -159,6 +159,11 @@ spec: The secret should have a key "client.yaml" with the config contents If set, flash is enabled automatically type: string + exporterSelector: + description: |- + ExporterSelector overrides the exporter selector from OperatorConfig target mappings + When set, the target-based lookup is skipped entirely + type: string flashCmd: description: FlashCmd overrides the flash command from OperatorConfig target mappings diff --git a/internal/buildapi/flash_helpers.go b/internal/buildapi/flash_helpers.go new file mode 100644 index 00000000..ac754fa0 --- /dev/null +++ b/internal/buildapi/flash_helpers.go @@ -0,0 +1,181 @@ +package buildapi + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "strings" + + automotivev1alpha1 "github.com/centos-automotive-suite/automotive-dev-operator/api/v1alpha1" + "github.com/centos-automotive-suite/automotive-dev-operator/internal/common/registryutil" + corev1 "k8s.io/api/core/v1" + k8serrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" +) + +type httpError struct { + code int + message string +} + +// resolveFlashTargetConfig resolves exporter selector and flash command from request and OperatorConfig. +func resolveFlashTargetConfig(req FlashRequest, operatorConfig *automotivev1alpha1.OperatorConfig) (string, string) { + exporterSelector := req.ExporterSelector + flashCmd := req.FlashCmd + if req.Target != "" && exporterSelector == "" && operatorConfig.Spec.Jumpstarter != nil { + if mapping, ok := operatorConfig.Spec.Jumpstarter.TargetMappings[req.Target]; ok { + exporterSelector = mapping.Selector + if flashCmd == "" { + flashCmd = mapping.FlashCmd + } + } + } + return exporterSelector, flashCmd +} + +// createFlashClientConfigSecret creates the Jumpstarter client config secret for a standalone flash job. +func createFlashClientConfigSecret( + ctx context.Context, clientset kubernetes.Interface, namespace string, req FlashRequest, +) (string, *corev1.Secret, *httpError) { + clientConfigBytes, err := base64.StdEncoding.DecodeString(req.ClientConfig) + if err != nil { + return "", nil, &httpError{code: http.StatusBadRequest, message: "clientConfig must be base64 encoded"} + } + 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, + }, + } + created, createErr := clientset.CoreV1().Secrets(namespace).Create(ctx, secret, metav1.CreateOptions{}) + if createErr != nil { + if k8serrors.IsAlreadyExists(createErr) { + return "", nil, &httpError{code: http.StatusConflict, message: fmt.Sprintf("flash %s already exists", req.Name)} + } + return "", nil, &httpError{code: http.StatusInternalServerError, message: fmt.Sprintf("failed to create secret: %v", createErr)} + } + return secretName, created, nil +} + +// createFlashOCIAuthSecret creates a Kubernetes secret with OCI credentials for flash image pull. +// Returns the secret name, the created secret (for owner ref setup), and an error if creation fails. +// Returns empty name and nil secret if no credentials are provided. +func createFlashOCIAuthSecret( + ctx context.Context, clientset kubernetes.Interface, namespace, flashName string, creds *RegistryCredentials, +) (string, *corev1.Secret, *httpError) { + if creds == nil || !creds.Enabled { + return "", nil, nil + } + ociUsername, ociPassword, err := extractOCICredentials(creds) + if err != nil { + return "", nil, &httpError{code: http.StatusBadRequest, message: fmt.Sprintf("invalid registry credentials: %v", err)} + } + if ociUsername == "" || ociPassword == "" { + return "", nil, &httpError{code: http.StatusBadRequest, message: "registry credentials enabled but missing username or password"} + } + secretName := fmt.Sprintf("%s-flash-oci-auth", flashName) + ociSecret := &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: flashName, + "automotive.sdv.cloud.redhat.com/transient": "true", + "automotive.sdv.cloud.redhat.com/resource-type": "flash-oci-auth", + }, + }, + Type: corev1.SecretTypeOpaque, + Data: map[string][]byte{ + "username": []byte(ociUsername), + "password": []byte(ociPassword), + }, + } + created, createErr := clientset.CoreV1().Secrets(namespace).Create(ctx, ociSecret, metav1.CreateOptions{}) + if createErr != nil { + if k8serrors.IsAlreadyExists(createErr) { + return "", nil, &httpError{code: http.StatusConflict, message: fmt.Sprintf("flash OCI auth secret %s already exists", secretName)} + } + return "", nil, &httpError{ + code: http.StatusInternalServerError, + message: fmt.Sprintf("failed to create flash OCI auth secret: %v", createErr), + } + } + return secretName, created, nil +} + +// extractOCICredentials extracts username/password from RegistryCredentials. +// For docker-config auth, it returns the entry matching RegistryURL. +func extractOCICredentials(creds *RegistryCredentials) (string, string, error) { + if creds == nil || !creds.Enabled { + return "", "", nil + } + switch creds.AuthType { + case authTypeUsernamePassword: + if creds.Username == "" || creds.Password == "" { + return "", "", fmt.Errorf("username-password auth enabled but missing username or password") + } + return creds.Username, creds.Password, nil + case authTypeDockerConfig: + if creds.DockerConfig == "" { + return "", "", fmt.Errorf("docker config is empty") + } + return decodeDockerConfigAuth(creds.DockerConfig, creds.RegistryURL) + default: + return "", "", fmt.Errorf("unsupported auth type for flash OCI credentials: %s", creds.AuthType) + } +} + +// decodeDockerConfigAuth parses a docker config JSON and extracts username/password +// for the entry matching registryURL. +func decodeDockerConfigAuth(dockerConfig, registryURL string) (string, string, error) { + var cfg struct { + Auths map[string]struct { + Auth string `json:"auth"` + } `json:"auths"` + } + if err := json.Unmarshal([]byte(dockerConfig), &cfg); err != nil { + return "", "", fmt.Errorf("failed to parse docker config: %w", err) + } + + for key, entry := range cfg.Auths { + if !registryutil.RegistryHostMatches(key, registryURL) { + continue + } + if user, pass, ok := decodeAuthField(entry.Auth); ok { + return user, pass, nil + } + } + return "", "", fmt.Errorf("no credentials found for registry %s", registryURL) +} + +// decodeAuthField decodes a base64-encoded "user:password" auth field. +func decodeAuthField(auth string) (string, string, bool) { + if auth == "" { + return "", "", false + } + decoded, err := base64.StdEncoding.DecodeString(auth) + if err != nil { + return "", "", false + } + parts := strings.SplitN(string(decoded), ":", 2) + if len(parts) != 2 { + return "", "", false + } + return parts[0], parts[1], true +} diff --git a/internal/buildapi/server.go b/internal/buildapi/server.go index b3f2eaf0..d836910a 100644 --- a/internal/buildapi/server.go +++ b/internal/buildapi/server.go @@ -1591,6 +1591,7 @@ func (a *APIServer) createBuild(c *gin.Context) { ClientConfigSecretRef: flashSecretName, LeaseDuration: req.FlashLeaseDuration, FlashCmd: req.FlashCmd, + ExporterSelector: req.FlashExporterSelector, } } @@ -2870,20 +2871,8 @@ func (a *APIServer) createFlash(c *gin.Context) { operatorConfig = &automotivev1alpha1.OperatorConfig{} } - // Get exporter selector from OperatorConfig if target is specified - exporterSelector := req.ExporterSelector - flashCmd := req.FlashCmd - if req.Target != "" && exporterSelector == "" { - if operatorConfig.Spec.Jumpstarter != nil { - if mapping, ok := operatorConfig.Spec.Jumpstarter.TargetMappings[req.Target]; ok { - exporterSelector = mapping.Selector - if flashCmd == "" { - flashCmd = mapping.FlashCmd - } - } - } - } - + // Resolve exporter selector and flash command from OperatorConfig + exporterSelector, flashCmd := resolveFlashTargetConfig(req, operatorConfig) if exporterSelector == "" { c.JSON(http.StatusBadRequest, gin.H{"error": "exporterSelector or valid target is required"}) return @@ -2895,39 +2884,18 @@ func (a *APIServer) createFlash(c *gin.Context) { 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"}) + // Create Jumpstarter client config secret + secretName, createdSecret, secretErr := createFlashClientConfigSecret(ctx, clientset, namespace, req) + if secretErr != nil { + c.JSON(secretErr.code, gin.H{"error": secretErr.message}) 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)}) + // Create OCI auth secret for flash image pull credentials + flashOCIAuthSecretName, createdOCIAuthSecret, ociErr := createFlashOCIAuthSecret(ctx, clientset, namespace, req.Name, req.RegistryCredentials) + if ociErr != nil { + _ = clientset.CoreV1().Secrets(namespace).Delete(ctx, secretName, metav1.DeleteOptions{}) + c.JSON(ociErr.code, gin.H{"error": ociErr.message}) return } @@ -2954,6 +2922,24 @@ func (a *APIServer) createFlash(c *gin.Context) { } } + // Build workspace bindings + workspaces := []tektonv1.WorkspaceBinding{ + { + Name: "jumpstarter-client", + Secret: &corev1.SecretVolumeSource{ + SecretName: secretName, + }, + }, + } + if flashOCIAuthSecretName != "" { + workspaces = append(workspaces, tektonv1.WorkspaceBinding{ + Name: "flash-oci-auth", + Secret: &corev1.SecretVolumeSource{ + SecretName: flashOCIAuthSecretName, + }, + }) + } + // Create the flash TaskRun taskRun := &tektonv1.TaskRun{ ObjectMeta: metav1.ObjectMeta{ @@ -2978,26 +2964,22 @@ func (a *APIServer) createFlash(c *gin.Context) { {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, - }, - }, - }, + Workspaces: workspaces, }, } if err := k8sClient.Create(ctx, taskRun); err != nil { - // Clean up the secret if TaskRun creation fails + // Clean up secrets if TaskRun creation fails _ = clientset.CoreV1().Secrets(namespace).Delete(ctx, secretName, metav1.DeleteOptions{}) + if flashOCIAuthSecretName != "" { + _ = clientset.CoreV1().Secrets(namespace).Delete(ctx, flashOCIAuthSecretName, 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{ + // Set owner reference on secrets for automatic cleanup + ownerRef := []metav1.OwnerReference{ { APIVersion: "tekton.dev/v1", Kind: "TaskRun", @@ -3005,9 +2987,16 @@ func (a *APIServer) createFlash(c *gin.Context) { UID: taskRun.UID, }, } + createdSecret.OwnerReferences = ownerRef 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) } + if createdOCIAuthSecret != nil { + createdOCIAuthSecret.OwnerReferences = ownerRef + if _, updErr := clientset.CoreV1().Secrets(namespace).Update(ctx, createdOCIAuthSecret, metav1.UpdateOptions{}); updErr != nil { + log.Printf("WARNING: failed to set owner reference on flash OCI auth secret %s: %v", flashOCIAuthSecretName, updErr) + } + } writeJSON(c, http.StatusAccepted, FlashResponse{ Name: req.Name, diff --git a/internal/buildapi/types.go b/internal/buildapi/types.go index 3fbd0394..cb277ee9 100644 --- a/internal/buildapi/types.go +++ b/internal/buildapi/types.go @@ -139,10 +139,11 @@ type BuildRequest struct { InternalRegistryTag string `json:"internalRegistryTag,omitempty"` // Tag for internal registry image (default: build name) // 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 - FlashCmd string `json:"flashCmd,omitempty"` // Override flash command from OperatorConfig + 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 + FlashCmd string `json:"flashCmd,omitempty"` // Override flash command from OperatorConfig + FlashExporterSelector string `json:"flashExporterSelector,omitempty"` // Override exporter selector from OperatorConfig } // RegistryCredentials contains authentication details for container registries. @@ -184,6 +185,8 @@ type FlashRequest struct { ClientConfig string `json:"clientConfig"` // LeaseDuration is the Jumpstarter lease duration in HH:MM:SS format (default: "01:00:00") LeaseDuration string `json:"leaseDuration,omitempty"` + // RegistryCredentials contains OCI registry auth for pulling the flash image on the exporter + RegistryCredentials *RegistryCredentials `json:"registryCredentials,omitempty"` } // FlashResponse is returned by flash operations diff --git a/internal/common/registryutil/registryutil.go b/internal/common/registryutil/registryutil.go new file mode 100644 index 00000000..9c05b549 --- /dev/null +++ b/internal/common/registryutil/registryutil.go @@ -0,0 +1,45 @@ +// Package registryutil provides shared utilities for registry URL normalization and matching. +package registryutil + +import ( + "net/url" + "strings" +) + +// NormalizeRegistryHost extracts and normalizes the host portion of a registry +// URL or auth key for comparison. It strips scheme, path, and trailing slashes, +// and lowercases the result. +// +// Examples: +// +// "https://quay.io/v1/" → "quay.io" +// "quay.io" → "quay.io" +// "//Docker.IO/" → "docker.io" +// "localhost:5000" → "localhost:5000" +func NormalizeRegistryHost(raw string) string { + value := strings.TrimSpace(raw) + if value == "" { + return "" + } + if strings.Contains(value, "://") { + parsed, err := url.Parse(value) + if err == nil && parsed.Host != "" { + value = parsed.Host + } + } + value = strings.TrimPrefix(value, "//") + value = strings.SplitN(value, "/", 2)[0] + value = strings.TrimSuffix(value, "/") + return strings.ToLower(strings.TrimSpace(value)) +} + +// RegistryHostMatches returns true if two registry references resolve to the +// same host after normalization. Returns false if either value is empty. +func RegistryHostMatches(a, b string) bool { + hostA := NormalizeRegistryHost(a) + hostB := NormalizeRegistryHost(b) + if hostA == "" || hostB == "" { + return false + } + return hostA == hostB +} diff --git a/internal/common/registryutil/registryutil_test.go b/internal/common/registryutil/registryutil_test.go new file mode 100644 index 00000000..767b7e5b --- /dev/null +++ b/internal/common/registryutil/registryutil_test.go @@ -0,0 +1,29 @@ +package registryutil + +import "testing" + +func TestRegistryHostMatches(t *testing.T) { + tests := []struct { + name string + a, b string + want bool + }{ + {"same host", "quay.io", "quay.io", true}, + {"scheme stripped", "https://quay.io", "quay.io", true}, + {"path stripped", "https://quay.io/v1/", "quay.io", true}, + {"case insensitive", "Quay.IO", "quay.io", true}, + {"port preserved", "localhost:5000", "localhost:5000", true}, + {"different hosts", "quay.io", "docker.io", false}, + {"subdomain not matched", "quay.io", "quay.io.evil.com", false}, + {"different ports", "localhost:5000", "localhost:5001", false}, + {"empty rejected", "", "quay.io", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := RegistryHostMatches(tt.a, tt.b); got != tt.want { + t.Errorf("RegistryHostMatches(%q, %q) = %v, want %v", tt.a, tt.b, got, tt.want) + } + }) + } +} diff --git a/internal/controller/imagebuild/controller.go b/internal/controller/imagebuild/controller.go index 0892823b..8f02abb8 100644 --- a/internal/controller/imagebuild/controller.go +++ b/internal/controller/imagebuild/controller.go @@ -4,11 +4,14 @@ package imagebuild import ( "context" "crypto/sha256" + "encoding/base64" + "encoding/json" "fmt" "strings" "time" automotivev1alpha1 "github.com/centos-automotive-suite/automotive-dev-operator/api/v1alpha1" + "github.com/centos-automotive-suite/automotive-dev-operator/internal/common/registryutil" "github.com/centos-automotive-suite/automotive-dev-operator/internal/common/tasks" "github.com/go-logr/logr" routev1 "github.com/openshift/api/route/v1" @@ -562,21 +565,26 @@ func (r *ImageBuildReconciler) createBuildTaskRun( // Add flash params if flash is enabled var flashExporterSelector, flashCmd, flashOCIAuthSecretName 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 + // User-specified exporter selector bypasses target lookup entirely + flashExporterSelector = imageBuild.Spec.GetFlashExporterSelector() + if flashExporterSelector == "" { + 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, "+ + "or set flash.exporterSelector directly", target, target) } } // User-specified flash command overrides OperatorConfig if userCmd := imageBuild.Spec.GetFlashCmd(); userCmd != "" { flashCmd = userCmd } - 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) - } // Internal registry references are cluster-internal and not reachable by the flash exporter. // Require an external route and fail fast if unavailable. if imageBuild.Spec.GetUseServiceAccountAuth() && clusterRegistryRoute == "" { @@ -634,8 +642,17 @@ func (r *ImageBuildReconciler) createBuildTaskRun( "password": []byte(tokenResp.Status.Token), }, } - if _, err := clientset.CoreV1().Secrets(imageBuild.Namespace).Create(ctx, ociSecret, metav1.CreateOptions{}); err != nil && !errors.IsAlreadyExists(err) { - return fmt.Errorf("failed to create flash OCI auth secret: %w", err) + _, err = clientset.CoreV1().Secrets(imageBuild.Namespace).Create(ctx, ociSecret, metav1.CreateOptions{}) + if errors.IsAlreadyExists(err) { + existing, getErr := clientset.CoreV1().Secrets(imageBuild.Namespace).Get(ctx, ociSecret.Name, metav1.GetOptions{}) + if getErr != nil { + return fmt.Errorf("failed to get existing flash OCI auth secret: %w", getErr) + } + existing.Data = ociSecret.Data + _, err = clientset.CoreV1().Secrets(imageBuild.Namespace).Update(ctx, existing, metav1.UpdateOptions{}) + } + if err != nil { + return fmt.Errorf("failed to create/update flash OCI auth secret: %w", err) } } else if imageBuild.Spec.SecretRef != "" && flashImageRef != "" { // External registry: read credentials from the registry-auth secret and @@ -648,11 +665,11 @@ func (r *ImageBuildReconciler) createBuildTaskRun( }, registrySecret); err != nil { return fmt.Errorf("failed to read registry secret %q for flash OCI credentials: %w", imageBuild.Spec.SecretRef, err) } - regUser := registrySecret.Data["REGISTRY_USERNAME"] - regPass := registrySecret.Data["REGISTRY_PASSWORD"] - hasUser := len(regUser) > 0 - hasPass := len(regPass) > 0 - if hasUser && hasPass { + regUser, regPass := extractFlashCredentials(registrySecret, flashImageRef, log) + if len(regUser) == 0 && len(regPass) == 0 { + log.Info("No usable credentials found in registry secret for flash OCI auth", + "secret", imageBuild.Spec.SecretRef) + } else if len(regUser) > 0 && len(regPass) > 0 { flashOCIAuthSecretName = imageBuild.Name + "-flash-oci-auth" ociSecret := &corev1.Secret{ ObjectMeta: metav1.ObjectMeta{ @@ -675,16 +692,20 @@ func (r *ImageBuildReconciler) createBuildTaskRun( "password": regPass, }, } - if err := r.Create(ctx, ociSecret); err != nil && !errors.IsAlreadyExists(err) { - return fmt.Errorf("failed to create flash OCI auth secret from registry credentials: %w", err) - } - } else if hasUser || hasPass { - missing := "REGISTRY_PASSWORD" - if !hasUser { - missing = "REGISTRY_USERNAME" + if err := r.Create(ctx, ociSecret); err != nil { + if errors.IsAlreadyExists(err) { + existing := &corev1.Secret{} + if err := r.Get(ctx, client.ObjectKey{Namespace: imageBuild.Namespace, Name: flashOCIAuthSecretName}, existing); err != nil { + return fmt.Errorf("failed to get existing flash OCI auth secret: %w", err) + } + existing.Data = ociSecret.Data + if err := r.Update(ctx, existing); err != nil { + return fmt.Errorf("failed to update flash OCI auth secret: %w", err) + } + } else { + return fmt.Errorf("failed to create flash OCI auth secret from registry credentials: %w", err) + } } - log.Info("Partial registry credentials in secret, skipping flash OCI auth", - "secret", imageBuild.Spec.SecretRef, "missing", missing) } } @@ -1406,6 +1427,8 @@ func (r *ImageBuildReconciler) cleanupTransientSecrets( if flashSecretRef := imageBuild.Spec.GetFlashClientConfigSecretRef(); flashSecretRef != "" { r.deleteSecretWithRetry(ctx, imageBuild.Namespace, flashSecretRef, "flash client config", log) } + // Cleanup flash OCI auth secret + r.deleteSecretWithRetry(ctx, imageBuild.Namespace, imageBuild.Name+"-flash-oci-auth", "flash OCI auth", log) } // deleteSecretWithRetry attempts to delete a secret with exponential backoff retry @@ -1952,3 +1975,62 @@ func (r *ImageBuildReconciler) shutdownUploadPod(ctx context.Context, imageBuild log.Info("Upload pod deleted") return nil } + +// extractFlashCredentials extracts username/password from a registry secret for flash OCI auth. +// It first checks for explicit REGISTRY_USERNAME/REGISTRY_PASSWORD keys, then falls back +// to parsing .dockerconfigjson or REGISTRY_AUTH_FILE_CONTENT to decode credentials. +func extractFlashCredentials(secret *corev1.Secret, registryURL string, log logr.Logger) ([]byte, []byte) { + regUser := secret.Data["REGISTRY_USERNAME"] + regPass := secret.Data["REGISTRY_PASSWORD"] + if len(regUser) > 0 && len(regPass) > 0 { + return regUser, regPass + } + + // Fall back to docker config JSON + dockerConfig := secret.Data[".dockerconfigjson"] + if len(dockerConfig) == 0 { + dockerConfig = secret.Data["REGISTRY_AUTH_FILE_CONTENT"] + } + if len(dockerConfig) == 0 { + log.Error(nil, "No docker config found in secret", "secret", secret.Name) + return nil, nil + } + + var cfg struct { + Auths map[string]struct { + Auth string `json:"auth"` + } `json:"auths"` + } + if err := json.Unmarshal(dockerConfig, &cfg); err != nil { + log.Error(err, "Failed to parse docker config JSON from secret", "secret", secret.Name) + return nil, nil + } + + for key, entry := range cfg.Auths { + if !registryutil.RegistryHostMatches(key, registryURL) { + continue + } + if user, pass := decodeAuthEntry(entry.Auth, log); user != nil { + return user, pass + } + } + log.Error(nil, "No matching credentials found in docker config", "secret", secret.Name, "registry", registryURL) + return nil, nil +} + +func decodeAuthEntry(auth string, log logr.Logger) ([]byte, []byte) { + if auth == "" { + return nil, nil + } + decoded, err := base64.StdEncoding.DecodeString(auth) + if err != nil { + log.Error(err, "Failed to base64-decode auth entry") + return nil, nil + } + parts := strings.SplitN(string(decoded), ":", 2) + if len(parts) == 2 { + return []byte(parts[0]), []byte(parts[1]) + } + log.Error(nil, "Auth entry missing ':' separator after decoding") + return nil, nil +}