detached 3148fa0

fix: registry push token retry, empty subject guard, and docstring accuracy

- Add single-retry logic on HTTP 401 during push to handle token expiry
  mid-upload for large images (re-exchanges credentials and retries once)
- Guard against empty Subject after VerifyAuth returns Valid=true, surfacing
  a clear error instead of sending empty BasicAuth username
- Fix ExchangeRegistryToken docstring to clarify it returns the raw token
  string without a "Bearer " prefix
commit
3148fa0f3a645ffc2867b9b69912c05f26dd4bc1
author
brice · · 2026-03-19 04:12 UTC
parent
a9c1886
signature
unsigned
2 files · +32 -1
M internal/adapters/in/cli/push_image_ops.go +31 -0
@@ -6,6 +6,7 @@ import (
66 "io"
77 "os"
88 "os/exec"
9+ "strings"
910
1011 cerrdefs "github.com/containerd/errdefs"
1112 "github.com/docker/docker/client"
@@ -86,12 +87,39 @@ func (d *dockerImageOps) Push(ctx context.Context, ref string) error {
8687 return fmt.Errorf("failed to obtain registry credentials: %w", err)
8788 }
8889 opts = append(opts, registrypush.WithAuth(authHeader))
90+
91+ pusher := registrypush.New(opts...)
92+ err = pusher.Push(ctx, ref)
93+ if err != nil && isAuthError(err) {
94+ // Token may have expired mid-push; refresh once and retry.
95+ authHeader, refreshErr := d.exchangeRegistryAuth(ctx)
96+ if refreshErr != nil {
97+ return fmt.Errorf("failed to refresh registry credentials after auth error: %w (original: %v)", refreshErr, err)
98+ }
99+ retryOpts := []registrypush.Option{
100+ registrypush.WithProgress(d.progress),
101+ registrypush.WithInsecureTLS(d.insecureTLS),
102+ registrypush.WithAuth(authHeader),
103+ }
104+ pusher = registrypush.New(retryOpts...)
105+ return pusher.Push(ctx, ref)
106+ }
107+ return err
89108 }
90109
91110 pusher := registrypush.New(opts...)
92111 return pusher.Push(ctx, ref)
93112 }
94113
114+// isAuthError returns true if the error looks like an HTTP 401 authentication failure.
115+func isAuthError(err error) bool {
116+ if err == nil {
117+ return false
118+ }
119+ msg := err.Error()
120+ return strings.Contains(msg, "status 401") || strings.Contains(msg, "401 Unauthorized")
121+}
122+
95123 // exchangeRegistryAuth exchanges the long-lived Gordon token for a short-lived
96124 // registry access token. Returns the Authorization header value (e.g. "Bearer <token>").
97125 func (d *dockerImageOps) exchangeRegistryAuth(ctx context.Context) (string, error) {
@@ -103,6 +131,9 @@ func (d *dockerImageOps) exchangeRegistryAuth(ctx context.Context) (string, erro
103131 if !verifyResp.Valid {
104132 return "", fmt.Errorf("token is not valid")
105133 }
134+ if verifyResp.Subject == "" {
135+ return "", fmt.Errorf("empty subject in verified token")
136+ }
106137
107138 // Step 2: Exchange long-lived token for short-lived registry token
108139 shortToken, err := d.remoteClient.ExchangeRegistryToken(ctx, verifyResp.Subject)
@@ -6,6 +6,7 @@ import (
6 "io"6 "io"
7 "os"7 "os"
8 "os/exec"8 "os/exec"
9+ "strings"
9 10
10 cerrdefs "github.com/containerd/errdefs"11 cerrdefs "github.com/containerd/errdefs"
11 "github.com/docker/docker/client"12 "github.com/docker/docker/client"
@@ -86,12 +87,39 @@ func (d *dockerImageOps) Push(ctx context.Context, ref string) error {
86 return fmt.Errorf("failed to obtain registry credentials: %w", err)87 return fmt.Errorf("failed to obtain registry credentials: %w", err)
87 }88 }
88 opts = append(opts, registrypush.WithAuth(authHeader))89 opts = append(opts, registrypush.WithAuth(authHeader))
90+
91+ pusher := registrypush.New(opts...)
92+ err = pusher.Push(ctx, ref)
93+ if err != nil && isAuthError(err) {
94+ // Token may have expired mid-push; refresh once and retry.
95+ authHeader, refreshErr := d.exchangeRegistryAuth(ctx)
96+ if refreshErr != nil {
97+ return fmt.Errorf("failed to refresh registry credentials after auth error: %w (original: %v)", refreshErr, err)
98+ }
99+ retryOpts := []registrypush.Option{
100+ registrypush.WithProgress(d.progress),
101+ registrypush.WithInsecureTLS(d.insecureTLS),
102+ registrypush.WithAuth(authHeader),
103+ }
104+ pusher = registrypush.New(retryOpts...)
105+ return pusher.Push(ctx, ref)
106+ }
107+ return err
89 }108 }
90 109
91 pusher := registrypush.New(opts...)110 pusher := registrypush.New(opts...)
92 return pusher.Push(ctx, ref)111 return pusher.Push(ctx, ref)
93 }112 }
94 113
114+// isAuthError returns true if the error looks like an HTTP 401 authentication failure.
115+func isAuthError(err error) bool {
116+ if err == nil {
117+ return false
118+ }
119+ msg := err.Error()
120+ return strings.Contains(msg, "status 401") || strings.Contains(msg, "401 Unauthorized")
121+}
122+
95 // exchangeRegistryAuth exchanges the long-lived Gordon token for a short-lived123 // exchangeRegistryAuth exchanges the long-lived Gordon token for a short-lived
96 // registry access token. Returns the Authorization header value (e.g. "Bearer <token>").124 // registry access token. Returns the Authorization header value (e.g. "Bearer <token>").
97 func (d *dockerImageOps) exchangeRegistryAuth(ctx context.Context) (string, error) {125 func (d *dockerImageOps) exchangeRegistryAuth(ctx context.Context) (string, error) {
@@ -103,6 +131,9 @@ func (d *dockerImageOps) exchangeRegistryAuth(ctx context.Context) (string, erro
103 if !verifyResp.Valid {131 if !verifyResp.Valid {
104 return "", fmt.Errorf("token is not valid")132 return "", fmt.Errorf("token is not valid")
105 }133 }
134+ if verifyResp.Subject == "" {
135+ return "", fmt.Errorf("empty subject in verified token")
136+ }
106 137
107 // Step 2: Exchange long-lived token for short-lived registry token138 // Step 2: Exchange long-lived token for short-lived registry token
108 shortToken, err := d.remoteClient.ExchangeRegistryToken(ctx, verifyResp.Subject)139 shortToken, err := d.remoteClient.ExchangeRegistryToken(ctx, verifyResp.Subject)
M internal/adapters/in/cli/remote/auth.go +1 -1
@@ -76,7 +76,7 @@ func (c *Client) Authenticate(ctx context.Context, username, password string) (*
7676 // ExchangeRegistryToken exchanges the client's long-lived Gordon token for a
7777 // short-lived registry access token via the Docker v2 token endpoint.
7878 // The subject must match the token's sub claim (use VerifyAuth to obtain it).
79-// Returns the short-lived Bearer token string.
79+// Returns the raw short-lived token string (without a "Bearer " prefix).
8080 func (c *Client) ExchangeRegistryToken(ctx context.Context, subject string) (string, error) {
8181 url := c.baseURL + "/auth/token?scope=repository:*:push,pull&service=gordon-registry"
8282
@@ -76,7 +76,7 @@ func (c *Client) Authenticate(ctx context.Context, username, password string) (*
76 // ExchangeRegistryToken exchanges the client's long-lived Gordon token for a76 // ExchangeRegistryToken exchanges the client's long-lived Gordon token for a
77 // short-lived registry access token via the Docker v2 token endpoint.77 // short-lived registry access token via the Docker v2 token endpoint.
78 // The subject must match the token's sub claim (use VerifyAuth to obtain it).78 // The subject must match the token's sub claim (use VerifyAuth to obtain it).
79-// Returns the short-lived Bearer token string.79+// Returns the raw short-lived token string (without a "Bearer " prefix).
80 func (c *Client) ExchangeRegistryToken(ctx context.Context, subject string) (string, error) {80 func (c *Client) ExchangeRegistryToken(ctx context.Context, subject string) (string, error) {
81 url := c.baseURL + "/auth/token?scope=repository:*:push,pull&service=gordon-registry"81 url := c.baseURL + "/auth/token?scope=repository:*:push,pull&service=gordon-registry"
82 82

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close