From 4b88172c19fcf30f2d0a275a2707a399ee709259 Mon Sep 17 00:00:00 2001 From: Benny Zlotnik Date: Fri, 20 Mar 2026 11:22:08 +0200 Subject: [PATCH] add `caib image token` command for internal registry builds Allow users to request a fresh 4-hour registry token for completed builds that used --internal-registry. The token can be used with podman, skopeo, or any OCI tool to pull images externally. The endpoint verifies build ownership before minting a token. Signed-off-by: Benny Zlotnik Assisted-by: claude-opus-4.6 --- cmd/caib/image/image.go | 32 +++++++++ cmd/caib/runtime_wiring.go | 9 +++ cmd/caib/tokencmd/token.go | 78 ++++++++++++++++++++++ internal/buildapi/client/client.go | 34 ++++++++++ internal/buildapi/container_builds.go | 8 ++- internal/buildapi/server.go | 94 +++++++++++++++++++++++++-- internal/buildapi/types.go | 9 +++ 7 files changed, 255 insertions(+), 9 deletions(-) create mode 100644 cmd/caib/tokencmd/token.go diff --git a/cmd/caib/image/image.go b/cmd/caib/image/image.go index 9bc34346..a44550fc 100644 --- a/cmd/caib/image/image.go +++ b/cmd/caib/image/image.go @@ -23,6 +23,7 @@ type Options struct { RunReseal func(*cobra.Command, []string) RunExtractForSigning func(*cobra.Command, []string) RunInjectSigned func(*cobra.Command, []string) + RunToken func(*cobra.Command, []string) GetDefaultArch func() string @@ -99,6 +100,8 @@ func NewImageCmd(opts Options) *cobra.Command { logsCmd := newLogsCmd(opts) flashCmd := newFlashCmd(opts) + tokenCmd := newTokenCmd(opts) + prepareResealCmd := newPrepareResealCmd(opts) resealCmd := newResealCmd(opts) extractForSigningCmd := newExtractForSigningCmd(opts) @@ -258,6 +261,10 @@ func NewImageCmd(opts Options) *cobra.Command { downloadCmd.Flags().StringVar(opts.AuthToken, "token", os.Getenv("CAIB_TOKEN"), "Bearer token for authentication") downloadCmd.Flags().StringVarP(opts.OutputDir, "output", "o", "", "destination file or directory for the artifact") + // token command flags + tokenCmd.Flags().StringVar(opts.ServerURL, "server", defaultServer, "REST API server base URL") + tokenCmd.Flags().StringVar(opts.AuthToken, "token", os.Getenv("CAIB_TOKEN"), "Bearer token for authentication") + // flash command flags flashCmd.Flags().StringVar(opts.ServerURL, "server", defaultServer, "REST API server base URL") flashCmd.Flags().StringVar(opts.AuthToken, "token", os.Getenv("CAIB_TOKEN"), "Bearer token for authentication") @@ -291,6 +298,7 @@ func NewImageCmd(opts Options) *cobra.Command { showCmd, downloadCmd, logsCmd, + tokenCmd, flashCmd, prepareResealCmd, resealCmd, @@ -451,6 +459,30 @@ Examples: } } +func newTokenCmd(opts Options) *cobra.Command { + return &cobra.Command{ + Use: "token ", + Short: "Request a fresh registry token for an internal-registry build", + Long: `Request a fresh, short-lived registry token for a completed build that +used the internal OpenShift registry (--internal-registry). + +The token is valid for 4 hours and can be used with podman, skopeo, or +any OCI-compatible tool to pull images from the internal registry. + +Examples: + # Get a token for a completed build + caib image token my-build + + # Use the printed podman login command to authenticate + echo '' | podman login --username serviceaccount --password-stdin + + # Then pull the image + podman pull `, + Args: cobra.ExactArgs(1), + Run: opts.RunToken, + } +} + func newPrepareResealCmd(opts Options) *cobra.Command { return &cobra.Command{ Use: "prepare-reseal [source-container] [output-container]", diff --git a/cmd/caib/runtime_wiring.go b/cmd/caib/runtime_wiring.go index 6bb590fa..e8da58bc 100644 --- a/cmd/caib/runtime_wiring.go +++ b/cmd/caib/runtime_wiring.go @@ -7,6 +7,7 @@ import ( "github.com/centos-automotive-suite/automotive-dev-operator/cmd/caib/image" "github.com/centos-automotive-suite/automotive-dev-operator/cmd/caib/querycmd" "github.com/centos-automotive-suite/automotive-dev-operator/cmd/caib/sealedcmd" + "github.com/centos-automotive-suite/automotive-dev-operator/cmd/caib/tokencmd" ) type runtimeState struct { @@ -127,6 +128,7 @@ type handlerSet struct { download *downloadcmd.Handler flash *flashcmd.Handler sealed *sealedcmd.Handler + token *tokencmd.Handler } func (s runtimeState) newHandlers() handlerSet { @@ -221,6 +223,12 @@ func (s runtimeState) newHandlers() handlerSet { InsecureSkipTLS: s.InsecureSkipTLS, HandleError: handleError, }), + token: tokencmd.NewHandler(tokencmd.Options{ + ServerURL: s.ServerURL, + AuthToken: s.AuthToken, + InsecureSkipTLS: s.InsecureSkipTLS, + HandleError: handleError, + }), } } @@ -238,6 +246,7 @@ func (s runtimeState) imageOptions(h handlerSet) image.Options { RunReseal: h.sealed.RunReseal, RunExtractForSigning: h.sealed.RunExtractForSigning, RunInjectSigned: h.sealed.RunInjectSigned, + RunToken: h.token.RunToken, GetDefaultArch: getDefaultArch, ServerURL: s.ServerURL, diff --git a/cmd/caib/tokencmd/token.go b/cmd/caib/tokencmd/token.go new file mode 100644 index 00000000..557c08bb --- /dev/null +++ b/cmd/caib/tokencmd/token.go @@ -0,0 +1,78 @@ +// Package tokencmd provides the image registry token request handler. +package tokencmd + +import ( + "context" + "fmt" + "strings" + + common "github.com/centos-automotive-suite/automotive-dev-operator/cmd/caib/common" + buildapitypes "github.com/centos-automotive-suite/automotive-dev-operator/internal/buildapi" + buildapiclient "github.com/centos-automotive-suite/automotive-dev-operator/internal/buildapi/client" + "github.com/spf13/cobra" +) + +// Options wires token handler dependencies. +type Options struct { + ServerURL *string + AuthToken *string + InsecureSkipTLS *bool + + HandleError func(error) +} + +// Handler implements the token command run function. +type Handler struct { + opts Options +} + +// NewHandler creates a token handler. +func NewHandler(opts Options) *Handler { + return &Handler{opts: opts} +} + +func (h *Handler) handleError(err error) { + if h.opts.HandleError != nil { + h.opts.HandleError(err) + return + } + panic(err) +} + +// RunToken handles `caib image token`. +func (h *Handler) RunToken(_ *cobra.Command, args []string) { + ctx := context.Background() + buildName := args[0] + + if h.opts.ServerURL == nil || strings.TrimSpace(*h.opts.ServerURL) == "" { + h.handleError(fmt.Errorf("server URL required (use --server, CAIB_SERVER, run 'caib login ' or 'jmp login ')")) + return + } + if h.opts.InsecureSkipTLS == nil { + h.handleError(fmt.Errorf("internal error: --insecure option is not configured")) + return + } + + serverURL := strings.TrimSpace(*h.opts.ServerURL) + insecureSkipTLS := *h.opts.InsecureSkipTLS + + var tok *buildapitypes.TokenResponse + err := common.ExecuteWithReauth(serverURL, h.opts.AuthToken, insecureSkipTLS, func(api *buildapiclient.Client) error { + var tokenErr error + tok, tokenErr = api.CreateBuildToken(ctx, buildName) + return tokenErr + }) + if err != nil { + h.handleError(fmt.Errorf("error requesting token for build %s: %w", buildName, err)) + return + } + + fmt.Printf("Registry: %s\n", tok.Registry) + fmt.Printf("Image: %s\n", tok.Image) + fmt.Printf("Username: %s\n", tok.Username) + fmt.Printf("Token: %s\n", tok.Token) + fmt.Printf("Expires: %s\n", tok.ExpiresAt) + fmt.Println() + fmt.Println("To authenticate:") + fmt.Printf(" echo '%s' | podman login %s --username %s --password-stdin\n", tok.Token, tok.Registry, tok.Username) +} diff --git a/internal/buildapi/client/client.go b/internal/buildapi/client/client.go index 124dac5b..a2512ec4 100644 --- a/internal/buildapi/client/client.go +++ b/internal/buildapi/client/client.go @@ -167,6 +167,38 @@ func (c *Client) GetBuild(ctx context.Context, name string) (*buildapi.BuildResp return &out, nil } +// CreateBuildToken requests a fresh registry token for an internal-registry build. +// +//nolint:dupl // HTTP client methods share structural boilerplate by design +func (c *Client) CreateBuildToken(ctx context.Context, name string) (*buildapi.TokenResponse, error) { + endpoint := c.resolve(path.Join("/v1/builds", url.PathEscape(name), "token")) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, 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("create build token failed: %s: %s", resp.Status, string(b)) + } + var out buildapi.TokenResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return nil, err + } + return &out, nil +} + // GetBuildProgress retrieves the current progress of a build. // Returns nil, nil (no error) on 404 to handle older servers gracefully. func (c *Client) GetBuildProgress(ctx context.Context, name string) (*buildapi.BuildProgress, error) { @@ -202,6 +234,8 @@ func (c *Client) GetBuildProgress(ctx context.Context, name string) (*buildapi.B } // GetBuildTemplate retrieves a build template reconstructed from ImageBuild inputs. +// +//nolint:dupl // HTTP client methods share structural boilerplate by design func (c *Client) GetBuildTemplate(ctx context.Context, name string) (*buildapi.BuildTemplateResponse, error) { endpoint := c.resolve(path.Join("/v1/builds", url.PathEscape(name), "template")) req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) diff --git a/internal/buildapi/container_builds.go b/internal/buildapi/container_builds.go index 4f63c4d3..74777f1e 100644 --- a/internal/buildapi/container_builds.go +++ b/internal/buildapi/container_builds.go @@ -451,9 +451,13 @@ func (a *APIServer) getContainerBuild(c *gin.Context, name string) { } // Mint a fresh registry token for completed/failed internal registry builds - if cb.Spec.UseServiceAccountAuth && + // that belong to the requesting user + requester := a.resolveRequester(c) + buildOwner := cb.Annotations["automotive.sdv.cloud.redhat.com/requested-by"] + if requester == buildOwner && + cb.Spec.UseServiceAccountAuth && (cb.Status.Phase == phaseCompleted || cb.Status.Phase == phaseFailed) { - token, tokenErr := a.mintRegistryToken(ctx, c, namespace) + token, _, tokenErr := a.mintRegistryToken(ctx, c, namespace) if tokenErr != nil { a.log.Error(tokenErr, "failed to mint registry token for container build", "build", name) } else { diff --git a/internal/buildapi/server.go b/internal/buildapi/server.go index 84378737..bc101209 100644 --- a/internal/buildapi/server.go +++ b/internal/buildapi/server.go @@ -284,14 +284,14 @@ func deleteImageStream(ctx context.Context, k8sClient client.Client, namespace, // mintRegistryToken creates a fresh short-lived token for the pipeline SA // so the caller can pull images from the internal registry. -func (a *APIServer) mintRegistryToken(ctx context.Context, c *gin.Context, namespace string) (string, error) { +func (a *APIServer) mintRegistryToken(ctx context.Context, c *gin.Context, namespace string) (string, metav1.Time, error) { restCfg, err := getRESTConfigFromRequest(c) if err != nil { - return "", fmt.Errorf("error getting REST config for token mint: %w", err) + return "", metav1.Time{}, fmt.Errorf("error getting REST config for token mint: %w", err) } clientset, err := kubernetes.NewForConfig(restCfg) if err != nil { - return "", fmt.Errorf("error creating clientset for token mint: %w", err) + return "", metav1.Time{}, fmt.Errorf("error creating clientset for token mint: %w", err) } expSeconds := int64(4 * 3600) tokenReq := &authnv1.TokenRequest{ @@ -302,9 +302,9 @@ func (a *APIServer) mintRegistryToken(ctx context.Context, c *gin.Context, names tokenResp, err := clientset.CoreV1().ServiceAccounts(namespace). CreateToken(ctx, "pipeline", tokenReq, metav1.CreateOptions{}) if err != nil { - return "", fmt.Errorf("error creating token for SA pipeline in %s: %w", namespace, err) + return "", metav1.Time{}, fmt.Errorf("error creating token for SA pipeline in %s: %w", namespace, err) } - return tokenResp.Status.Token, nil + return tokenResp.Status.Token, tokenResp.Status.ExpirationTimestamp, nil } // APILimits holds configurable limits for the API server @@ -518,6 +518,7 @@ func (a *APIServer) createRouter() *gin.Engine { buildsGroup.GET("/:name/progress", a.handleGetProgress) buildsGroup.GET("/:name/template", a.handleGetBuildTemplate) buildsGroup.POST("/:name/uploads", a.handleUploadFiles) + buildsGroup.POST("/:name/token", a.handleCreateBuildToken) } flashGroup := v1.Group("/flash") @@ -652,6 +653,81 @@ func (a *APIServer) handleGetBuild(c *gin.Context) { a.getBuild(c, name) } +func (a *APIServer) handleCreateBuildToken(c *gin.Context) { + name := c.Param("name") + a.log.Info("token requested", "build", name, "reqID", c.GetString("reqID")) + + 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() + build := &automotivev1alpha1.ImageBuild{} + if err := k8sClient.Get(ctx, types.NamespacedName{Name: name, Namespace: namespace}, build); err != nil { + if k8serrors.IsNotFound(err) { + c.JSON(http.StatusNotFound, gin.H{"error": "build not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("error fetching build: %v", err)}) + return + } + + // Verify the requesting user owns this build + requester := a.resolveRequester(c) + owner := build.Annotations["automotive.sdv.cloud.redhat.com/requested-by"] + if owner != requester { + c.JSON(http.StatusForbidden, gin.H{"error": "you can only request tokens for your own builds"}) + return + } + + if !build.Spec.GetUseServiceAccountAuth() { + c.JSON(http.StatusBadRequest, gin.H{"error": "build does not use the internal registry"}) + return + } + + if build.Status.Phase != phaseCompleted { + c.JSON(http.StatusBadRequest, gin.H{"error": fmt.Sprintf("build is not completed (current: %s)", build.Status.Phase)}) + return + } + + // Determine the image ref first — only mint tokens if there's an internal image + imageRef := build.Spec.GetExportOCI() + if imageRef == "" { + imageRef = build.Spec.GetContainerPush() + } + if imageRef == "" || !strings.HasPrefix(imageRef, defaultInternalRegistryURL+"/") { + c.JSON(http.StatusBadRequest, gin.H{"error": "build has no image in the internal registry"}) + return + } + + token, expiresAt, err := a.mintRegistryToken(ctx, c, namespace) + if err != nil { + a.log.Error(err, "failed to mint registry token", "build", name) + c.JSON(http.StatusInternalServerError, gin.H{"error": fmt.Sprintf("failed to mint registry token: %v", err)}) + return + } + + registryHost := "" + externalRoute, routeErr := getExternalRegistryRoute(ctx, k8sClient, namespace) + if routeErr == nil && externalRoute != "" { + imageRef = translateToExternalURL(imageRef, externalRoute) + registryHost = externalRoute + } else { + registryHost = strings.SplitN(imageRef, "/", 2)[0] + } + + writeJSON(c, http.StatusOK, TokenResponse{ + Registry: registryHost, + Username: "serviceaccount", + Token: token, + ExpiresAt: expiresAt.UTC().Format(time.RFC3339), + Image: imageRef, + }) +} + func (a *APIServer) handleStreamLogs(c *gin.Context) { name := c.Param("name") a.log.Info("logs requested", "build", name, "reqID", c.GetString("reqID")) @@ -1779,11 +1855,15 @@ func (a *APIServer) getBuild(c *gin.Context, name string) { } // Mint a fresh registry token only for completed/failed internal registry builds + // that belong to the requesting user var registryToken string - if build.Spec.GetUseServiceAccountAuth() && + requester := a.resolveRequester(c) + buildOwner := build.Annotations["automotive.sdv.cloud.redhat.com/requested-by"] + if requester == buildOwner && + build.Spec.GetUseServiceAccountAuth() && (build.Status.Phase == phaseCompleted || build.Status.Phase == phaseFailed) { var tokenErr error - registryToken, tokenErr = a.mintRegistryToken(ctx, c, namespace) + registryToken, _, tokenErr = a.mintRegistryToken(ctx, c, namespace) if tokenErr != nil { a.log.Error(tokenErr, "failed to mint registry token", "build", name) tokenWarning := fmt.Sprintf("failed to mint registry token: %v", tokenErr) diff --git a/internal/buildapi/types.go b/internal/buildapi/types.go index 304533d0..196c2db0 100644 --- a/internal/buildapi/types.go +++ b/internal/buildapi/types.go @@ -248,6 +248,15 @@ type BuildParameters struct { UseServiceAccountAuth bool `json:"useServiceAccountAuth,omitempty"` } +// TokenResponse is returned by the token endpoint for internal registry builds +type TokenResponse struct { + Registry string `json:"registry"` + Username string `json:"username"` + Token string `json:"token"` + ExpiresAt string `json:"expiresAt"` + Image string `json:"image"` +} + // BuildListItem represents a build in the list API type BuildListItem struct { Name string `json:"name"`