Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions cmd/caib/image/image.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -291,6 +298,7 @@ func NewImageCmd(opts Options) *cobra.Command {
showCmd,
downloadCmd,
logsCmd,
tokenCmd,
flashCmd,
prepareResealCmd,
resealCmd,
Expand Down Expand Up @@ -451,6 +459,30 @@ Examples:
}
}

func newTokenCmd(opts Options) *cobra.Command {
return &cobra.Command{
Use: "token <build-name>",
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 '<token>' | podman login <registry> --username serviceaccount --password-stdin

# Then pull the image
podman pull <image-ref>`,
Args: cobra.ExactArgs(1),
Run: opts.RunToken,
}
}

func newPrepareResealCmd(opts Options) *cobra.Command {
return &cobra.Command{
Use: "prepare-reseal [source-container] [output-container]",
Expand Down
9 changes: 9 additions & 0 deletions cmd/caib/runtime_wiring.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -127,6 +128,7 @@ type handlerSet struct {
download *downloadcmd.Handler
flash *flashcmd.Handler
sealed *sealedcmd.Handler
token *tokencmd.Handler
}

func (s runtimeState) newHandlers() handlerSet {
Expand Down Expand Up @@ -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,
}),
}
}

Expand All @@ -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,
Expand Down
78 changes: 78 additions & 0 deletions cmd/caib/tokencmd/token.go
Original file line number Diff line number Diff line change
@@ -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 <server-url>' or 'jmp login <endpoint>')"))
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)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
34 changes: 34 additions & 0 deletions internal/buildapi/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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)
Expand Down
8 changes: 6 additions & 2 deletions internal/buildapi/container_builds.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
94 changes: 87 additions & 7 deletions internal/buildapi/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand All @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

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]
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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"))
Expand Down Expand Up @@ -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)
Expand Down
Loading
Loading