feat(push): auto-exchange token for registry auth in remote mode
When $GORDON_TOKEN or --token is available during gordon push --remote, the CLI now automatically exchanges the long-lived token for a short-lived (5 min) registry access token via /auth/token. This eliminates the need for a separate docker login step in CI/CD pipelines. Changes: - registrypush: add WithAuth option for explicit auth headers - remote: add ExchangeRegistryToken for short-lived token exchange - push_image_ops: wire VerifyAuth + ExchangeRegistryToken into Push flow
- commit
- 98678d94766b9368999f2731aef694525591151f
- author
- bnema · · 2026-03-18 21:25 UTC
- parent
- a28bceb
- signature
- unsigned
6 files · +378 -10
M internal/adapters/in/cli/push_image_ops.go +51 -7
| @@ -30,9 +30,10 @@ type pushImageOps interface { | |||
| 30 | 30 | ||
| 31 | 31 | // dockerImageOps implements pushImageOps using the Docker SDK and registrypush. | |
| 32 | 32 | type dockerImageOps struct { | |
| 33 | - | cli client.APIClient | |
| 34 | - | insecureTLS bool | |
| 35 | - | progress io.Writer | |
| 33 | + | cli client.APIClient | |
| 34 | + | insecureTLS bool | |
| 35 | + | progress io.Writer | |
| 36 | + | remoteClient *remote.Client // nil when local | |
| 36 | 37 | } | |
| 37 | 38 | ||
| 38 | 39 | // newDockerImageOps creates a dockerImageOps from the Docker environment. | |
| @@ -50,8 +51,20 @@ func newDockerImageOps(insecureTLS bool, progress io.Writer) (*dockerImageOps, e | |||
| 50 | 51 | ||
| 51 | 52 | // newImageOpsFromFlags resolves TLS settings from CLI flags/config and creates ops. | |
| 52 | 53 | func newImageOpsFromFlags() (pushImageOps, error) { | |
| 53 | - | _, _, insecureTLS, _ := remote.ResolveRemote(remoteFlag, tokenFlag, insecureTLSFlag) | |
| 54 | - | return newDockerImageOps(insecureTLS, os.Stderr) | |
| 54 | + | remoteURL, token, insecureTLS, _ := remote.ResolveRemote(remoteFlag, tokenFlag, insecureTLSFlag) | |
| 55 | + | ops, err := newDockerImageOps(insecureTLS, os.Stderr) | |
| 56 | + | if err != nil { | |
| 57 | + | return nil, err | |
| 58 | + | } | |
| 59 | + | if remoteURL != "" && token != "" { | |
| 60 | + | var clientOpts []remote.ClientOption | |
| 61 | + | clientOpts = append(clientOpts, remote.WithToken(token)) | |
| 62 | + | if insecureTLS { | |
| 63 | + | clientOpts = append(clientOpts, remote.WithInsecureTLS(true)) | |
| 64 | + | } | |
| 65 | + | ops.remoteClient = remote.NewClient(remoteURL, clientOpts...) | |
| 66 | + | } | |
| 67 | + | return ops, nil | |
| 55 | 68 | } | |
| 56 | 69 | ||
| 57 | 70 | func (d *dockerImageOps) Tag(ctx context.Context, sourceRef, targetRef string) error { | |
| @@ -62,13 +75,44 @@ func (d *dockerImageOps) Tag(ctx context.Context, sourceRef, targetRef string) e | |||
| 62 | 75 | } | |
| 63 | 76 | ||
| 64 | 77 | func (d *dockerImageOps) Push(ctx context.Context, ref string) error { | |
| 65 | - | pusher := registrypush.New( | |
| 78 | + | opts := []registrypush.Option{ | |
| 66 | 79 | registrypush.WithProgress(d.progress), | |
| 67 | 80 | registrypush.WithInsecureTLS(d.insecureTLS), | |
| 68 | - | ) | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | if d.remoteClient != nil { | |
| 84 | + | authHeader, err := d.exchangeRegistryAuth(ctx) | |
| 85 | + | if err != nil { | |
| 86 | + | return fmt.Errorf("failed to obtain registry credentials: %w", err) | |
| 87 | + | } | |
| 88 | + | opts = append(opts, registrypush.WithAuth(authHeader)) | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | pusher := registrypush.New(opts...) | |
| 69 | 92 | return pusher.Push(ctx, ref) | |
| 70 | 93 | } | |
| 71 | 94 | ||
| 95 | + | // exchangeRegistryAuth exchanges the long-lived Gordon token for a short-lived | |
| 96 | + | // registry access token. Returns the Authorization header value (e.g. "Bearer <token>"). | |
| 97 | + | func (d *dockerImageOps) exchangeRegistryAuth(ctx context.Context) (string, error) { | |
| 98 | + | // Step 1: Get subject from server (validates token is still active) | |
| 99 | + | verifyResp, err := d.remoteClient.VerifyAuth(ctx) | |
| 100 | + | if err != nil { | |
| 101 | + | return "", fmt.Errorf("failed to verify token: %w", err) | |
| 102 | + | } | |
| 103 | + | if !verifyResp.Valid { | |
| 104 | + | return "", fmt.Errorf("token is not valid") | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | // Step 2: Exchange long-lived token for short-lived registry token | |
| 108 | + | shortToken, err := d.remoteClient.ExchangeRegistryToken(ctx, verifyResp.Subject) | |
| 109 | + | if err != nil { | |
| 110 | + | return "", fmt.Errorf("failed to exchange registry token: %w", err) | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | return "Bearer " + shortToken, nil | |
| 114 | + | } | |
| 115 | + | ||
| 72 | 116 | func (d *dockerImageOps) Exists(ctx context.Context, imageRef string) (bool, error) { | |
| 73 | 117 | _, err := d.cli.ImageInspect(ctx, imageRef) | |
| 74 | 118 | if err != nil { | |
| @@ -30,9 +30,10 @@ type pushImageOps interface { | |||||
| 30 | 30 | ||||
| 31 | // dockerImageOps implements pushImageOps using the Docker SDK and registrypush. | 31 | // dockerImageOps implements pushImageOps using the Docker SDK and registrypush. | ||
| 32 | type dockerImageOps struct { | 32 | type dockerImageOps struct { | ||
| 33 | - | cli client.APIClient | 33 | + | cli client.APIClient |
| 34 | - | insecureTLS bool | 34 | + | insecureTLS bool |
| 35 | - | progress io.Writer | 35 | + | progress io.Writer |
| 36 | + | remoteClient *remote.Client // nil when local | |||
| 36 | } | 37 | } | ||
| 37 | 38 | ||||
| 38 | // newDockerImageOps creates a dockerImageOps from the Docker environment. | 39 | // newDockerImageOps creates a dockerImageOps from the Docker environment. | ||
| @@ -50,8 +51,20 @@ func newDockerImageOps(insecureTLS bool, progress io.Writer) (*dockerImageOps, e | |||||
| 50 | 51 | ||||
| 51 | // newImageOpsFromFlags resolves TLS settings from CLI flags/config and creates ops. | 52 | // newImageOpsFromFlags resolves TLS settings from CLI flags/config and creates ops. | ||
| 52 | func newImageOpsFromFlags() (pushImageOps, error) { | 53 | func newImageOpsFromFlags() (pushImageOps, error) { | ||
| 53 | - | _, _, insecureTLS, _ := remote.ResolveRemote(remoteFlag, tokenFlag, insecureTLSFlag) | 54 | + | remoteURL, token, insecureTLS, _ := remote.ResolveRemote(remoteFlag, tokenFlag, insecureTLSFlag) |
| 54 | - | return newDockerImageOps(insecureTLS, os.Stderr) | 55 | + | ops, err := newDockerImageOps(insecureTLS, os.Stderr) |
| 56 | + | if err != nil { | |||
| 57 | + | return nil, err | |||
| 58 | + | } | |||
| 59 | + | if remoteURL != "" && token != "" { | |||
| 60 | + | var clientOpts []remote.ClientOption | |||
| 61 | + | clientOpts = append(clientOpts, remote.WithToken(token)) | |||
| 62 | + | if insecureTLS { | |||
| 63 | + | clientOpts = append(clientOpts, remote.WithInsecureTLS(true)) | |||
| 64 | + | } | |||
| 65 | + | ops.remoteClient = remote.NewClient(remoteURL, clientOpts...) | |||
| 66 | + | } | |||
| 67 | + | return ops, nil | |||
| 55 | } | 68 | } | ||
| 56 | 69 | ||||
| 57 | func (d *dockerImageOps) Tag(ctx context.Context, sourceRef, targetRef string) error { | 70 | func (d *dockerImageOps) Tag(ctx context.Context, sourceRef, targetRef string) error { | ||
| @@ -62,13 +75,44 @@ func (d *dockerImageOps) Tag(ctx context.Context, sourceRef, targetRef string) e | |||||
| 62 | } | 75 | } | ||
| 63 | 76 | ||||
| 64 | func (d *dockerImageOps) Push(ctx context.Context, ref string) error { | 77 | func (d *dockerImageOps) Push(ctx context.Context, ref string) error { | ||
| 65 | - | pusher := registrypush.New( | 78 | + | opts := []registrypush.Option{ |
| 66 | registrypush.WithProgress(d.progress), | 79 | registrypush.WithProgress(d.progress), | ||
| 67 | registrypush.WithInsecureTLS(d.insecureTLS), | 80 | registrypush.WithInsecureTLS(d.insecureTLS), | ||
| 68 | - | ) | 81 | + | } |
| 82 | + | ||||
| 83 | + | if d.remoteClient != nil { | |||
| 84 | + | authHeader, err := d.exchangeRegistryAuth(ctx) | |||
| 85 | + | if err != nil { | |||
| 86 | + | return fmt.Errorf("failed to obtain registry credentials: %w", err) | |||
| 87 | + | } | |||
| 88 | + | opts = append(opts, registrypush.WithAuth(authHeader)) | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | pusher := registrypush.New(opts...) | |||
| 69 | return pusher.Push(ctx, ref) | 92 | return pusher.Push(ctx, ref) | ||
| 70 | } | 93 | } | ||
| 71 | 94 | ||||
| 95 | + | // exchangeRegistryAuth exchanges the long-lived Gordon token for a short-lived | |||
| 96 | + | // registry access token. Returns the Authorization header value (e.g. "Bearer <token>"). | |||
| 97 | + | func (d *dockerImageOps) exchangeRegistryAuth(ctx context.Context) (string, error) { | |||
| 98 | + | // Step 1: Get subject from server (validates token is still active) | |||
| 99 | + | verifyResp, err := d.remoteClient.VerifyAuth(ctx) | |||
| 100 | + | if err != nil { | |||
| 101 | + | return "", fmt.Errorf("failed to verify token: %w", err) | |||
| 102 | + | } | |||
| 103 | + | if !verifyResp.Valid { | |||
| 104 | + | return "", fmt.Errorf("token is not valid") | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | // Step 2: Exchange long-lived token for short-lived registry token | |||
| 108 | + | shortToken, err := d.remoteClient.ExchangeRegistryToken(ctx, verifyResp.Subject) | |||
| 109 | + | if err != nil { | |||
| 110 | + | return "", fmt.Errorf("failed to exchange registry token: %w", err) | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | return "Bearer " + shortToken, nil | |||
| 114 | + | } | |||
| 115 | + | ||||
| 72 | func (d *dockerImageOps) Exists(ctx context.Context, imageRef string) (bool, error) { | 116 | func (d *dockerImageOps) Exists(ctx context.Context, imageRef string) (bool, error) { | ||
| 73 | _, err := d.cli.ImageInspect(ctx, imageRef) | 117 | _, err := d.cli.ImageInspect(ctx, imageRef) | ||
| 74 | if err != nil { | 118 | if err != nil { | ||
A internal/adapters/in/cli/push_image_ops_test.go +114 -0
| @@ -0,0 +1,114 @@ | |||
| 1 | + | package cli | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "encoding/json" | |
| 6 | + | "net/http" | |
| 7 | + | "net/http/httptest" | |
| 8 | + | "testing" | |
| 9 | + | ||
| 10 | + | "github.com/stretchr/testify/assert" | |
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/gordon/internal/adapters/dto" | |
| 14 | + | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // TestExchangeRegistryAuth_Success verifies the happy path: | |
| 18 | + | // VerifyAuth is called, then ExchangeRegistryToken is called with the | |
| 19 | + | // subject from VerifyAuth, and the result is "Bearer <short-lived-token>". | |
| 20 | + | func TestExchangeRegistryAuth_Success(t *testing.T) { | |
| 21 | + | const subject = "ci-bot" | |
| 22 | + | const shortToken = "short-lived-token-abc123" | |
| 23 | + | ||
| 24 | + | verifyHandled := false | |
| 25 | + | tokenHandled := false | |
| 26 | + | ||
| 27 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 28 | + | switch r.URL.Path { | |
| 29 | + | case "/admin/auth/verify": | |
| 30 | + | verifyHandled = true | |
| 31 | + | w.Header().Set("Content-Type", "application/json") | |
| 32 | + | resp := dto.AuthVerifyResponse{ | |
| 33 | + | Valid: true, | |
| 34 | + | Subject: subject, | |
| 35 | + | Scopes: []string{"push", "pull"}, | |
| 36 | + | } | |
| 37 | + | json.NewEncoder(w).Encode(resp) //nolint:errcheck | |
| 38 | + | case "/auth/token": | |
| 39 | + | tokenHandled = true | |
| 40 | + | // Verify basic auth was set with subject as username | |
| 41 | + | u, p, ok := r.BasicAuth() | |
| 42 | + | assert.True(t, ok, "expected basic auth") | |
| 43 | + | assert.Equal(t, subject, u, "expected subject as username") | |
| 44 | + | assert.NotEmpty(t, p, "expected token as password") | |
| 45 | + | ||
| 46 | + | w.Header().Set("Content-Type", "application/json") | |
| 47 | + | resp := dto.TokenResponse{Token: shortToken} | |
| 48 | + | json.NewEncoder(w).Encode(resp) //nolint:errcheck | |
| 49 | + | default: | |
| 50 | + | http.NotFound(w, r) | |
| 51 | + | } | |
| 52 | + | })) | |
| 53 | + | defer srv.Close() | |
| 54 | + | ||
| 55 | + | client := remote.NewClient(srv.URL, remote.WithToken("long-lived-token")) | |
| 56 | + | ops := &dockerImageOps{ | |
| 57 | + | remoteClient: client, | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | got, err := ops.exchangeRegistryAuth(context.Background()) | |
| 61 | + | ||
| 62 | + | require.NoError(t, err) | |
| 63 | + | assert.Equal(t, "Bearer "+shortToken, got) | |
| 64 | + | assert.True(t, verifyHandled, "expected /admin/auth/verify to be called") | |
| 65 | + | assert.True(t, tokenHandled, "expected /auth/token to be called") | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // TestExchangeRegistryAuth_VerifyAuthFails verifies that when VerifyAuth fails, | |
| 69 | + | // exchangeRegistryAuth returns an error. | |
| 70 | + | func TestExchangeRegistryAuth_VerifyAuthFails(t *testing.T) { | |
| 71 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 72 | + | if r.URL.Path == "/admin/auth/verify" { | |
| 73 | + | http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) | |
| 74 | + | return | |
| 75 | + | } | |
| 76 | + | http.NotFound(w, r) | |
| 77 | + | })) | |
| 78 | + | defer srv.Close() | |
| 79 | + | ||
| 80 | + | client := remote.NewClient(srv.URL, remote.WithToken("bad-token")) | |
| 81 | + | ops := &dockerImageOps{ | |
| 82 | + | remoteClient: client, | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | _, err := ops.exchangeRegistryAuth(context.Background()) | |
| 86 | + | ||
| 87 | + | require.Error(t, err) | |
| 88 | + | assert.Contains(t, err.Error(), "failed to verify token") | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | // TestExchangeRegistryAuth_TokenNotValid verifies that when VerifyAuth returns | |
| 92 | + | // Valid=false, exchangeRegistryAuth returns an error. | |
| 93 | + | func TestExchangeRegistryAuth_TokenNotValid(t *testing.T) { | |
| 94 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 95 | + | if r.URL.Path == "/admin/auth/verify" { | |
| 96 | + | w.Header().Set("Content-Type", "application/json") | |
| 97 | + | resp := dto.AuthVerifyResponse{Valid: false} | |
| 98 | + | json.NewEncoder(w).Encode(resp) //nolint:errcheck | |
| 99 | + | return | |
| 100 | + | } | |
| 101 | + | http.NotFound(w, r) | |
| 102 | + | })) | |
| 103 | + | defer srv.Close() | |
| 104 | + | ||
| 105 | + | client := remote.NewClient(srv.URL, remote.WithToken("expired-token")) | |
| 106 | + | ops := &dockerImageOps{ | |
| 107 | + | remoteClient: client, | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | _, err := ops.exchangeRegistryAuth(context.Background()) | |
| 111 | + | ||
| 112 | + | require.Error(t, err) | |
| 113 | + | assert.Contains(t, err.Error(), "token is not valid") | |
| 114 | + | } | |
| @@ -0,0 +1,114 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "net/http" | |||
| 7 | + | "net/http/httptest" | |||
| 8 | + | "testing" | |||
| 9 | + | ||||
| 10 | + | "github.com/stretchr/testify/assert" | |||
| 11 | + | "github.com/stretchr/testify/require" | |||
| 12 | + | ||||
| 13 | + | "github.com/bnema/gordon/internal/adapters/dto" | |||
| 14 | + | "github.com/bnema/gordon/internal/adapters/in/cli/remote" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // TestExchangeRegistryAuth_Success verifies the happy path: | |||
| 18 | + | // VerifyAuth is called, then ExchangeRegistryToken is called with the | |||
| 19 | + | // subject from VerifyAuth, and the result is "Bearer <short-lived-token>". | |||
| 20 | + | func TestExchangeRegistryAuth_Success(t *testing.T) { | |||
| 21 | + | const subject = "ci-bot" | |||
| 22 | + | const shortToken = "short-lived-token-abc123" | |||
| 23 | + | ||||
| 24 | + | verifyHandled := false | |||
| 25 | + | tokenHandled := false | |||
| 26 | + | ||||
| 27 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 28 | + | switch r.URL.Path { | |||
| 29 | + | case "/admin/auth/verify": | |||
| 30 | + | verifyHandled = true | |||
| 31 | + | w.Header().Set("Content-Type", "application/json") | |||
| 32 | + | resp := dto.AuthVerifyResponse{ | |||
| 33 | + | Valid: true, | |||
| 34 | + | Subject: subject, | |||
| 35 | + | Scopes: []string{"push", "pull"}, | |||
| 36 | + | } | |||
| 37 | + | json.NewEncoder(w).Encode(resp) //nolint:errcheck | |||
| 38 | + | case "/auth/token": | |||
| 39 | + | tokenHandled = true | |||
| 40 | + | // Verify basic auth was set with subject as username | |||
| 41 | + | u, p, ok := r.BasicAuth() | |||
| 42 | + | assert.True(t, ok, "expected basic auth") | |||
| 43 | + | assert.Equal(t, subject, u, "expected subject as username") | |||
| 44 | + | assert.NotEmpty(t, p, "expected token as password") | |||
| 45 | + | ||||
| 46 | + | w.Header().Set("Content-Type", "application/json") | |||
| 47 | + | resp := dto.TokenResponse{Token: shortToken} | |||
| 48 | + | json.NewEncoder(w).Encode(resp) //nolint:errcheck | |||
| 49 | + | default: | |||
| 50 | + | http.NotFound(w, r) | |||
| 51 | + | } | |||
| 52 | + | })) | |||
| 53 | + | defer srv.Close() | |||
| 54 | + | ||||
| 55 | + | client := remote.NewClient(srv.URL, remote.WithToken("long-lived-token")) | |||
| 56 | + | ops := &dockerImageOps{ | |||
| 57 | + | remoteClient: client, | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | got, err := ops.exchangeRegistryAuth(context.Background()) | |||
| 61 | + | ||||
| 62 | + | require.NoError(t, err) | |||
| 63 | + | assert.Equal(t, "Bearer "+shortToken, got) | |||
| 64 | + | assert.True(t, verifyHandled, "expected /admin/auth/verify to be called") | |||
| 65 | + | assert.True(t, tokenHandled, "expected /auth/token to be called") | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | // TestExchangeRegistryAuth_VerifyAuthFails verifies that when VerifyAuth fails, | |||
| 69 | + | // exchangeRegistryAuth returns an error. | |||
| 70 | + | func TestExchangeRegistryAuth_VerifyAuthFails(t *testing.T) { | |||
| 71 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 72 | + | if r.URL.Path == "/admin/auth/verify" { | |||
| 73 | + | http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) | |||
| 74 | + | return | |||
| 75 | + | } | |||
| 76 | + | http.NotFound(w, r) | |||
| 77 | + | })) | |||
| 78 | + | defer srv.Close() | |||
| 79 | + | ||||
| 80 | + | client := remote.NewClient(srv.URL, remote.WithToken("bad-token")) | |||
| 81 | + | ops := &dockerImageOps{ | |||
| 82 | + | remoteClient: client, | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | _, err := ops.exchangeRegistryAuth(context.Background()) | |||
| 86 | + | ||||
| 87 | + | require.Error(t, err) | |||
| 88 | + | assert.Contains(t, err.Error(), "failed to verify token") | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | // TestExchangeRegistryAuth_TokenNotValid verifies that when VerifyAuth returns | |||
| 92 | + | // Valid=false, exchangeRegistryAuth returns an error. | |||
| 93 | + | func TestExchangeRegistryAuth_TokenNotValid(t *testing.T) { | |||
| 94 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 95 | + | if r.URL.Path == "/admin/auth/verify" { | |||
| 96 | + | w.Header().Set("Content-Type", "application/json") | |||
| 97 | + | resp := dto.AuthVerifyResponse{Valid: false} | |||
| 98 | + | json.NewEncoder(w).Encode(resp) //nolint:errcheck | |||
| 99 | + | return | |||
| 100 | + | } | |||
| 101 | + | http.NotFound(w, r) | |||
| 102 | + | })) | |||
| 103 | + | defer srv.Close() | |||
| 104 | + | ||||
| 105 | + | client := remote.NewClient(srv.URL, remote.WithToken("expired-token")) | |||
| 106 | + | ops := &dockerImageOps{ | |||
| 107 | + | remoteClient: client, | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | _, err := ops.exchangeRegistryAuth(context.Background()) | |||
| 111 | + | ||||
| 112 | + | require.Error(t, err) | |||
| 113 | + | assert.Contains(t, err.Error(), "token is not valid") | |||
| 114 | + | } | |||
M internal/adapters/in/cli/remote/auth.go +50 -0
| @@ -8,6 +8,8 @@ import ( | |||
| 8 | 8 | "io" | |
| 9 | 9 | "net/http" | |
| 10 | 10 | "os" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/gordon/internal/adapters/dto" | |
| 11 | 13 | ) | |
| 12 | 14 | ||
| 13 | 15 | // PasswordRequest represents the request body for POST /auth/password. | |
| @@ -71,6 +73,54 @@ func (c *Client) Authenticate(ctx context.Context, username, password string) (* | |||
| 71 | 73 | return &result, nil | |
| 72 | 74 | } | |
| 73 | 75 | ||
| 76 | + | // ExchangeRegistryToken exchanges the client's long-lived Gordon token for a | |
| 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). | |
| 79 | + | // Returns the short-lived Bearer token string. | |
| 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" | |
| 82 | + | ||
| 83 | + | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) | |
| 84 | + | if err != nil { | |
| 85 | + | return "", fmt.Errorf("failed to create request: %w", err) | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | req.SetBasicAuth(subject, c.token) | |
| 89 | + | req.Header.Set("Accept", "application/json") | |
| 90 | + | ||
| 91 | + | resp, err := c.httpClient.Do(req) | |
| 92 | + | if err != nil { | |
| 93 | + | return "", fmt.Errorf("failed to connect: %w", err) | |
| 94 | + | } | |
| 95 | + | defer resp.Body.Close() | |
| 96 | + | ||
| 97 | + | if resp.StatusCode >= 400 { | |
| 98 | + | body, _ := io.ReadAll(resp.Body) | |
| 99 | + | var errResp struct { | |
| 100 | + | Error string `json:"error"` | |
| 101 | + | } | |
| 102 | + | if err := json.Unmarshal(body, &errResp); err == nil && errResp.Error != "" { | |
| 103 | + | return "", fmt.Errorf("%s: %s", resp.Status, errResp.Error) | |
| 104 | + | } | |
| 105 | + | return "", fmt.Errorf("%s: %s", resp.Status, string(body)) | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | var result dto.TokenResponse | |
| 109 | + | if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { | |
| 110 | + | return "", fmt.Errorf("failed to decode response: %w", err) | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | token := result.Token | |
| 114 | + | if token == "" { | |
| 115 | + | token = result.AccessToken | |
| 116 | + | } | |
| 117 | + | if token == "" { | |
| 118 | + | return "", fmt.Errorf("registry token exchange returned empty token") | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | return token, nil | |
| 122 | + | } | |
| 123 | + | ||
| 74 | 124 | // UpdateRemoteToken updates the token for a named remote. | |
| 75 | 125 | func UpdateRemoteToken(name, token string) error { | |
| 76 | 126 | // Try pass first | |
| @@ -8,6 +8,8 @@ import ( | |||||
| 8 | "io" | 8 | "io" | ||
| 9 | "net/http" | 9 | "net/http" | ||
| 10 | "os" | 10 | "os" | ||
| 11 | + | ||||
| 12 | + | "github.com/bnema/gordon/internal/adapters/dto" | |||
| 11 | ) | 13 | ) | ||
| 12 | 14 | ||||
| 13 | // PasswordRequest represents the request body for POST /auth/password. | 15 | // PasswordRequest represents the request body for POST /auth/password. | ||
| @@ -71,6 +73,54 @@ func (c *Client) Authenticate(ctx context.Context, username, password string) (* | |||||
| 71 | return &result, nil | 73 | return &result, nil | ||
| 72 | } | 74 | } | ||
| 73 | 75 | ||||
| 76 | + | // ExchangeRegistryToken exchanges the client's long-lived Gordon token for a | |||
| 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). | |||
| 79 | + | // Returns the short-lived Bearer token string. | |||
| 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" | |||
| 82 | + | ||||
| 83 | + | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) | |||
| 84 | + | if err != nil { | |||
| 85 | + | return "", fmt.Errorf("failed to create request: %w", err) | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | req.SetBasicAuth(subject, c.token) | |||
| 89 | + | req.Header.Set("Accept", "application/json") | |||
| 90 | + | ||||
| 91 | + | resp, err := c.httpClient.Do(req) | |||
| 92 | + | if err != nil { | |||
| 93 | + | return "", fmt.Errorf("failed to connect: %w", err) | |||
| 94 | + | } | |||
| 95 | + | defer resp.Body.Close() | |||
| 96 | + | ||||
| 97 | + | if resp.StatusCode >= 400 { | |||
| 98 | + | body, _ := io.ReadAll(resp.Body) | |||
| 99 | + | var errResp struct { | |||
| 100 | + | Error string `json:"error"` | |||
| 101 | + | } | |||
| 102 | + | if err := json.Unmarshal(body, &errResp); err == nil && errResp.Error != "" { | |||
| 103 | + | return "", fmt.Errorf("%s: %s", resp.Status, errResp.Error) | |||
| 104 | + | } | |||
| 105 | + | return "", fmt.Errorf("%s: %s", resp.Status, string(body)) | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | var result dto.TokenResponse | |||
| 109 | + | if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { | |||
| 110 | + | return "", fmt.Errorf("failed to decode response: %w", err) | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | token := result.Token | |||
| 114 | + | if token == "" { | |||
| 115 | + | token = result.AccessToken | |||
| 116 | + | } | |||
| 117 | + | if token == "" { | |||
| 118 | + | return "", fmt.Errorf("registry token exchange returned empty token") | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | return token, nil | |||
| 122 | + | } | |||
| 123 | + | ||||
| 74 | // UpdateRemoteToken updates the token for a named remote. | 124 | // UpdateRemoteToken updates the token for a named remote. | ||
| 75 | func UpdateRemoteToken(name, token string) error { | 125 | func UpdateRemoteToken(name, token string) error { | ||
| 76 | // Try pass first | 126 | // Try pass first | ||
M internal/adapters/in/cli/remote/client_test.go +70 -0
| @@ -250,3 +250,73 @@ func TestRequestWithRetry_CapsErrorBodySize(t *testing.T) { | |||
| 250 | 250 | require.Error(t, err) | |
| 251 | 251 | assert.Less(t, len(err.Error()), 2*1024, "retry error body should be capped to ~1KB") | |
| 252 | 252 | } | |
| 253 | + | ||
| 254 | + | func TestExchangeRegistryToken_Success(t *testing.T) { | |
| 255 | + | const subject = "testuser" | |
| 256 | + | const gordonToken = "gordon-jwt-token" | |
| 257 | + | const shortToken = "short-lived-123" | |
| 258 | + | ||
| 259 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 260 | + | require.Equal(t, "/auth/token", r.URL.Path) | |
| 261 | + | require.Equal(t, http.MethodGet, r.Method) | |
| 262 | + | require.Equal(t, "repository:*:push,pull", r.URL.Query().Get("scope")) | |
| 263 | + | require.Equal(t, "gordon-registry", r.URL.Query().Get("service")) | |
| 264 | + | ||
| 265 | + | u, p, ok := r.BasicAuth() | |
| 266 | + | require.True(t, ok, "expected Basic Auth") | |
| 267 | + | assert.Equal(t, subject, u) | |
| 268 | + | assert.Equal(t, gordonToken, p) | |
| 269 | + | ||
| 270 | + | w.Header().Set("Content-Type", "application/json") | |
| 271 | + | _, _ = w.Write([]byte(`{"token":"short-lived-123","expires_in":300}`)) | |
| 272 | + | })) | |
| 273 | + | defer srv.Close() | |
| 274 | + | ||
| 275 | + | client := NewClient(srv.URL, WithToken(gordonToken)) | |
| 276 | + | token, err := client.ExchangeRegistryToken(context.Background(), subject) | |
| 277 | + | require.NoError(t, err) | |
| 278 | + | assert.Equal(t, shortToken, token) | |
| 279 | + | } | |
| 280 | + | ||
| 281 | + | func TestExchangeRegistryToken_Unauthorized(t *testing.T) { | |
| 282 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 283 | + | w.WriteHeader(http.StatusUnauthorized) | |
| 284 | + | _, _ = w.Write([]byte(`{"error":"invalid credentials"}`)) | |
| 285 | + | })) | |
| 286 | + | defer srv.Close() | |
| 287 | + | ||
| 288 | + | client := NewClient(srv.URL, WithToken("bad-token")) | |
| 289 | + | _, err := client.ExchangeRegistryToken(context.Background(), "user") | |
| 290 | + | require.Error(t, err) | |
| 291 | + | assert.Contains(t, err.Error(), "401") | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | func TestExchangeRegistryToken_EmptyToken(t *testing.T) { | |
| 295 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 296 | + | w.Header().Set("Content-Type", "application/json") | |
| 297 | + | _, _ = w.Write([]byte(`{"token":"","expires_in":300}`)) | |
| 298 | + | })) | |
| 299 | + | defer srv.Close() | |
| 300 | + | ||
| 301 | + | client := NewClient(srv.URL, WithToken("some-token")) | |
| 302 | + | _, err := client.ExchangeRegistryToken(context.Background(), "user") | |
| 303 | + | require.Error(t, err) | |
| 304 | + | assert.Contains(t, err.Error(), "empty") | |
| 305 | + | } | |
| 306 | + | ||
| 307 | + | func TestExchangeRegistryToken_AccessTokenFallback(t *testing.T) { | |
| 308 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 309 | + | w.Header().Set("Content-Type", "application/json") | |
| 310 | + | // Token field empty, but access_token has a value | |
| 311 | + | json.NewEncoder(w).Encode(map[string]any{ | |
| 312 | + | "access_token": "fallback-token-456", | |
| 313 | + | "expires_in": 300, | |
| 314 | + | }) | |
| 315 | + | })) | |
| 316 | + | defer srv.Close() | |
| 317 | + | ||
| 318 | + | client := NewClient(srv.URL, WithToken("long-lived")) | |
| 319 | + | token, err := client.ExchangeRegistryToken(context.Background(), "ci-bot") | |
| 320 | + | require.NoError(t, err) | |
| 321 | + | assert.Equal(t, "fallback-token-456", token) | |
| 322 | + | } | |
| @@ -250,3 +250,73 @@ func TestRequestWithRetry_CapsErrorBodySize(t *testing.T) { | |||||
| 250 | require.Error(t, err) | 250 | require.Error(t, err) | ||
| 251 | assert.Less(t, len(err.Error()), 2*1024, "retry error body should be capped to ~1KB") | 251 | assert.Less(t, len(err.Error()), 2*1024, "retry error body should be capped to ~1KB") | ||
| 252 | } | 252 | } | ||
| 253 | + | ||||
| 254 | + | func TestExchangeRegistryToken_Success(t *testing.T) { | |||
| 255 | + | const subject = "testuser" | |||
| 256 | + | const gordonToken = "gordon-jwt-token" | |||
| 257 | + | const shortToken = "short-lived-123" | |||
| 258 | + | ||||
| 259 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 260 | + | require.Equal(t, "/auth/token", r.URL.Path) | |||
| 261 | + | require.Equal(t, http.MethodGet, r.Method) | |||
| 262 | + | require.Equal(t, "repository:*:push,pull", r.URL.Query().Get("scope")) | |||
| 263 | + | require.Equal(t, "gordon-registry", r.URL.Query().Get("service")) | |||
| 264 | + | ||||
| 265 | + | u, p, ok := r.BasicAuth() | |||
| 266 | + | require.True(t, ok, "expected Basic Auth") | |||
| 267 | + | assert.Equal(t, subject, u) | |||
| 268 | + | assert.Equal(t, gordonToken, p) | |||
| 269 | + | ||||
| 270 | + | w.Header().Set("Content-Type", "application/json") | |||
| 271 | + | _, _ = w.Write([]byte(`{"token":"short-lived-123","expires_in":300}`)) | |||
| 272 | + | })) | |||
| 273 | + | defer srv.Close() | |||
| 274 | + | ||||
| 275 | + | client := NewClient(srv.URL, WithToken(gordonToken)) | |||
| 276 | + | token, err := client.ExchangeRegistryToken(context.Background(), subject) | |||
| 277 | + | require.NoError(t, err) | |||
| 278 | + | assert.Equal(t, shortToken, token) | |||
| 279 | + | } | |||
| 280 | + | ||||
| 281 | + | func TestExchangeRegistryToken_Unauthorized(t *testing.T) { | |||
| 282 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 283 | + | w.WriteHeader(http.StatusUnauthorized) | |||
| 284 | + | _, _ = w.Write([]byte(`{"error":"invalid credentials"}`)) | |||
| 285 | + | })) | |||
| 286 | + | defer srv.Close() | |||
| 287 | + | ||||
| 288 | + | client := NewClient(srv.URL, WithToken("bad-token")) | |||
| 289 | + | _, err := client.ExchangeRegistryToken(context.Background(), "user") | |||
| 290 | + | require.Error(t, err) | |||
| 291 | + | assert.Contains(t, err.Error(), "401") | |||
| 292 | + | } | |||
| 293 | + | ||||
| 294 | + | func TestExchangeRegistryToken_EmptyToken(t *testing.T) { | |||
| 295 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 296 | + | w.Header().Set("Content-Type", "application/json") | |||
| 297 | + | _, _ = w.Write([]byte(`{"token":"","expires_in":300}`)) | |||
| 298 | + | })) | |||
| 299 | + | defer srv.Close() | |||
| 300 | + | ||||
| 301 | + | client := NewClient(srv.URL, WithToken("some-token")) | |||
| 302 | + | _, err := client.ExchangeRegistryToken(context.Background(), "user") | |||
| 303 | + | require.Error(t, err) | |||
| 304 | + | assert.Contains(t, err.Error(), "empty") | |||
| 305 | + | } | |||
| 306 | + | ||||
| 307 | + | func TestExchangeRegistryToken_AccessTokenFallback(t *testing.T) { | |||
| 308 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 309 | + | w.Header().Set("Content-Type", "application/json") | |||
| 310 | + | // Token field empty, but access_token has a value | |||
| 311 | + | json.NewEncoder(w).Encode(map[string]any{ | |||
| 312 | + | "access_token": "fallback-token-456", | |||
| 313 | + | "expires_in": 300, | |||
| 314 | + | }) | |||
| 315 | + | })) | |||
| 316 | + | defer srv.Close() | |||
| 317 | + | ||||
| 318 | + | client := NewClient(srv.URL, WithToken("long-lived")) | |||
| 319 | + | token, err := client.ExchangeRegistryToken(context.Background(), "ci-bot") | |||
| 320 | + | require.NoError(t, err) | |||
| 321 | + | assert.Equal(t, "fallback-token-456", token) | |||
| 322 | + | } | |||
M pkg/registrypush/push.go +18 -3
| @@ -42,6 +42,7 @@ type Pusher struct { | |||
| 42 | 42 | imageSource func(ctx context.Context, ref string) (v1.Image, func(), error) | |
| 43 | 43 | cleanupFn func() | |
| 44 | 44 | progress io.Writer | |
| 45 | + | authHeader string | |
| 45 | 46 | } | |
| 46 | 47 | ||
| 47 | 48 | // Option configures a Pusher. | |
| @@ -89,6 +90,14 @@ func WithProgress(w io.Writer) Option { | |||
| 89 | 90 | return func(p *Pusher) { p.progress = w } | |
| 90 | 91 | } | |
| 91 | 92 | ||
| 93 | + | // WithAuth sets an explicit Authorization header value (e.g. "Bearer <token>") | |
| 94 | + | // to use for all registry requests. When set, the Docker credential keychain | |
| 95 | + | // is not consulted. This is useful when a pre-computed token is available | |
| 96 | + | // without requiring a local docker login. | |
| 97 | + | func WithAuth(header string) Option { | |
| 98 | + | return func(p *Pusher) { p.authHeader = header } | |
| 99 | + | } | |
| 100 | + | ||
| 92 | 101 | // New creates a Pusher with the given options. | |
| 93 | 102 | func New(opts ...Option) *Pusher { | |
| 94 | 103 | p := &Pusher{chunkSize: DefaultChunkSize, timeout: defaultHTTPTimeout} | |
| @@ -125,9 +134,15 @@ func (p *Pusher) Push(ctx context.Context, ref string) error { | |||
| 125 | 134 | return fmt.Errorf("image ref %s must include a tag", ref) | |
| 126 | 135 | } | |
| 127 | 136 | ||
| 128 | - | authHeader, err := resolveAuthHeader(parsedRef) | |
| 129 | - | if err != nil { | |
| 130 | - | return err | |
| 137 | + | var authHeader string | |
| 138 | + | if p.authHeader != "" { | |
| 139 | + | authHeader = p.authHeader | |
| 140 | + | } else { | |
| 141 | + | var resolveErr error | |
| 142 | + | authHeader, resolveErr = resolveAuthHeader(parsedRef) | |
| 143 | + | if resolveErr != nil { | |
| 144 | + | return resolveErr | |
| 145 | + | } | |
| 131 | 146 | } | |
| 132 | 147 | ||
| 133 | 148 | img, sourceCleanup, err := p.imageSource(ctx, ref) | |
| @@ -42,6 +42,7 @@ type Pusher struct { | |||||
| 42 | imageSource func(ctx context.Context, ref string) (v1.Image, func(), error) | 42 | imageSource func(ctx context.Context, ref string) (v1.Image, func(), error) | ||
| 43 | cleanupFn func() | 43 | cleanupFn func() | ||
| 44 | progress io.Writer | 44 | progress io.Writer | ||
| 45 | + | authHeader string | |||
| 45 | } | 46 | } | ||
| 46 | 47 | ||||
| 47 | // Option configures a Pusher. | 48 | // Option configures a Pusher. | ||
| @@ -89,6 +90,14 @@ func WithProgress(w io.Writer) Option { | |||||
| 89 | return func(p *Pusher) { p.progress = w } | 90 | return func(p *Pusher) { p.progress = w } | ||
| 90 | } | 91 | } | ||
| 91 | 92 | ||||
| 93 | + | // WithAuth sets an explicit Authorization header value (e.g. "Bearer <token>") | |||
| 94 | + | // to use for all registry requests. When set, the Docker credential keychain | |||
| 95 | + | // is not consulted. This is useful when a pre-computed token is available | |||
| 96 | + | // without requiring a local docker login. | |||
| 97 | + | func WithAuth(header string) Option { | |||
| 98 | + | return func(p *Pusher) { p.authHeader = header } | |||
| 99 | + | } | |||
| 100 | + | ||||
| 92 | // New creates a Pusher with the given options. | 101 | // New creates a Pusher with the given options. | ||
| 93 | func New(opts ...Option) *Pusher { | 102 | func New(opts ...Option) *Pusher { | ||
| 94 | p := &Pusher{chunkSize: DefaultChunkSize, timeout: defaultHTTPTimeout} | 103 | p := &Pusher{chunkSize: DefaultChunkSize, timeout: defaultHTTPTimeout} | ||
| @@ -125,9 +134,15 @@ func (p *Pusher) Push(ctx context.Context, ref string) error { | |||||
| 125 | return fmt.Errorf("image ref %s must include a tag", ref) | 134 | return fmt.Errorf("image ref %s must include a tag", ref) | ||
| 126 | } | 135 | } | ||
| 127 | 136 | ||||
| 128 | - | authHeader, err := resolveAuthHeader(parsedRef) | 137 | + | var authHeader string |
| 129 | - | if err != nil { | 138 | + | if p.authHeader != "" { |
| 130 | - | return err | 139 | + | authHeader = p.authHeader |
| 140 | + | } else { | |||
| 141 | + | var resolveErr error | |||
| 142 | + | authHeader, resolveErr = resolveAuthHeader(parsedRef) | |||
| 143 | + | if resolveErr != nil { | |||
| 144 | + | return resolveErr | |||
| 145 | + | } | |||
| 131 | } | 146 | } | ||
| 132 | 147 | ||||
| 133 | img, sourceCleanup, err := p.imageSource(ctx, ref) | 148 | img, sourceCleanup, err := p.imageSource(ctx, ref) | ||
M pkg/registrypush/push_test.go +75 -0
| @@ -660,3 +660,78 @@ func pathSegmentBetween(value, start, end string) string { | |||
| 660 | 660 | func typesOCIManifestSchema1() string { | |
| 661 | 661 | return "application/vnd.oci.image.manifest.v1+json" | |
| 662 | 662 | } | |
| 663 | + | ||
| 664 | + | // TestPusher_WithAuth verifies that when WithAuth is provided, its header is | |
| 665 | + | // used for all registry requests instead of the Docker keychain, and that | |
| 666 | + | // omitting WithAuth leaves existing behavior unchanged. | |
| 667 | + | func TestPusher_WithAuth(t *testing.T) { | |
| 668 | + | t.Run("WithAuth header is sent on every registry request", func(t *testing.T) { | |
| 669 | + | img, err := random.Image(1024, 1) | |
| 670 | + | require.NoError(t, err) | |
| 671 | + | ||
| 672 | + | const explicitToken = "Bearer my-token" | |
| 673 | + | ||
| 674 | + | // authAssertingRegistry fails the test if any request lacks the expected | |
| 675 | + | // Authorization header. | |
| 676 | + | var requestCount int | |
| 677 | + | registry := httptest.NewServer(newFakePushRegistry(t, img, map[string]bool{}).handlerWithAuthCheck(t, explicitToken, &requestCount)) | |
| 678 | + | defer registry.Close() | |
| 679 | + | ||
| 680 | + | serverURL, err := url.Parse(registry.URL) | |
| 681 | + | require.NoError(t, err) | |
| 682 | + | ref := serverURL.Host + "/demo/app:v1" | |
| 683 | + | ||
| 684 | + | p := registrypush.New( | |
| 685 | + | registrypush.WithTransport(registry.Client().Transport), | |
| 686 | + | registrypush.WithImageSource(func(context.Context, string) (v1.Image, error) { | |
| 687 | + | return img, nil | |
| 688 | + | }), | |
| 689 | + | registrypush.WithAuth(explicitToken), | |
| 690 | + | ) | |
| 691 | + | ||
| 692 | + | err = p.Push(context.Background(), ref) | |
| 693 | + | require.NoError(t, err) | |
| 694 | + | assert.Positive(t, requestCount, "at least one request should have been made") | |
| 695 | + | }) | |
| 696 | + | ||
| 697 | + | t.Run("without WithAuth, existing push behavior is unchanged", func(t *testing.T) { | |
| 698 | + | img, err := random.Image(1024, 1) | |
| 699 | + | require.NoError(t, err) | |
| 700 | + | ||
| 701 | + | serverState := newFakePushRegistry(t, img, map[string]bool{}) | |
| 702 | + | registry := httptest.NewServer(serverState.handler()) | |
| 703 | + | defer registry.Close() | |
| 704 | + | ||
| 705 | + | serverURL, err := url.Parse(registry.URL) | |
| 706 | + | require.NoError(t, err) | |
| 707 | + | ref := serverURL.Host + "/demo/app:v1" | |
| 708 | + | ||
| 709 | + | // No WithAuth — relies on Docker keychain (returns empty string for localhost). | |
| 710 | + | p := registrypush.New( | |
| 711 | + | registrypush.WithTransport(registry.Client().Transport), | |
| 712 | + | registrypush.WithImageSource(func(context.Context, string) (v1.Image, error) { | |
| 713 | + | return img, nil | |
| 714 | + | }), | |
| 715 | + | ) | |
| 716 | + | ||
| 717 | + | err = p.Push(context.Background(), ref) | |
| 718 | + | require.NoError(t, err) | |
| 719 | + | ||
| 720 | + | // Manifest should have been pushed — verifies the push completed normally. | |
| 721 | + | _, ok := serverState.manifestPuts[serverState.expectedRepo+":"+serverState.expectedTag] | |
| 722 | + | assert.True(t, ok, "manifest should have been uploaded") | |
| 723 | + | }) | |
| 724 | + | } | |
| 725 | + | ||
| 726 | + | // handlerWithAuthCheck wraps the fakePushRegistry handler and asserts that | |
| 727 | + | // every request carries the expected Authorization header. | |
| 728 | + | func (f *fakePushRegistry) handlerWithAuthCheck(t *testing.T, expectedAuth string, requestCount *int) http.Handler { | |
| 729 | + | t.Helper() | |
| 730 | + | inner := f.handler() | |
| 731 | + | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 732 | + | *requestCount++ | |
| 733 | + | assert.Equal(t, expectedAuth, r.Header.Get("Authorization"), | |
| 734 | + | "expected Authorization header on %s %s", r.Method, r.URL.Path) | |
| 735 | + | inner.ServeHTTP(w, r) | |
| 736 | + | }) | |
| 737 | + | } | |
| @@ -660,3 +660,78 @@ func pathSegmentBetween(value, start, end string) string { | |||||
| 660 | func typesOCIManifestSchema1() string { | 660 | func typesOCIManifestSchema1() string { | ||
| 661 | return "application/vnd.oci.image.manifest.v1+json" | 661 | return "application/vnd.oci.image.manifest.v1+json" | ||
| 662 | } | 662 | } | ||
| 663 | + | ||||
| 664 | + | // TestPusher_WithAuth verifies that when WithAuth is provided, its header is | |||
| 665 | + | // used for all registry requests instead of the Docker keychain, and that | |||
| 666 | + | // omitting WithAuth leaves existing behavior unchanged. | |||
| 667 | + | func TestPusher_WithAuth(t *testing.T) { | |||
| 668 | + | t.Run("WithAuth header is sent on every registry request", func(t *testing.T) { | |||
| 669 | + | img, err := random.Image(1024, 1) | |||
| 670 | + | require.NoError(t, err) | |||
| 671 | + | ||||
| 672 | + | const explicitToken = "Bearer my-token" | |||
| 673 | + | ||||
| 674 | + | // authAssertingRegistry fails the test if any request lacks the expected | |||
| 675 | + | // Authorization header. | |||
| 676 | + | var requestCount int | |||
| 677 | + | registry := httptest.NewServer(newFakePushRegistry(t, img, map[string]bool{}).handlerWithAuthCheck(t, explicitToken, &requestCount)) | |||
| 678 | + | defer registry.Close() | |||
| 679 | + | ||||
| 680 | + | serverURL, err := url.Parse(registry.URL) | |||
| 681 | + | require.NoError(t, err) | |||
| 682 | + | ref := serverURL.Host + "/demo/app:v1" | |||
| 683 | + | ||||
| 684 | + | p := registrypush.New( | |||
| 685 | + | registrypush.WithTransport(registry.Client().Transport), | |||
| 686 | + | registrypush.WithImageSource(func(context.Context, string) (v1.Image, error) { | |||
| 687 | + | return img, nil | |||
| 688 | + | }), | |||
| 689 | + | registrypush.WithAuth(explicitToken), | |||
| 690 | + | ) | |||
| 691 | + | ||||
| 692 | + | err = p.Push(context.Background(), ref) | |||
| 693 | + | require.NoError(t, err) | |||
| 694 | + | assert.Positive(t, requestCount, "at least one request should have been made") | |||
| 695 | + | }) | |||
| 696 | + | ||||
| 697 | + | t.Run("without WithAuth, existing push behavior is unchanged", func(t *testing.T) { | |||
| 698 | + | img, err := random.Image(1024, 1) | |||
| 699 | + | require.NoError(t, err) | |||
| 700 | + | ||||
| 701 | + | serverState := newFakePushRegistry(t, img, map[string]bool{}) | |||
| 702 | + | registry := httptest.NewServer(serverState.handler()) | |||
| 703 | + | defer registry.Close() | |||
| 704 | + | ||||
| 705 | + | serverURL, err := url.Parse(registry.URL) | |||
| 706 | + | require.NoError(t, err) | |||
| 707 | + | ref := serverURL.Host + "/demo/app:v1" | |||
| 708 | + | ||||
| 709 | + | // No WithAuth — relies on Docker keychain (returns empty string for localhost). | |||
| 710 | + | p := registrypush.New( | |||
| 711 | + | registrypush.WithTransport(registry.Client().Transport), | |||
| 712 | + | registrypush.WithImageSource(func(context.Context, string) (v1.Image, error) { | |||
| 713 | + | return img, nil | |||
| 714 | + | }), | |||
| 715 | + | ) | |||
| 716 | + | ||||
| 717 | + | err = p.Push(context.Background(), ref) | |||
| 718 | + | require.NoError(t, err) | |||
| 719 | + | ||||
| 720 | + | // Manifest should have been pushed — verifies the push completed normally. | |||
| 721 | + | _, ok := serverState.manifestPuts[serverState.expectedRepo+":"+serverState.expectedTag] | |||
| 722 | + | assert.True(t, ok, "manifest should have been uploaded") | |||
| 723 | + | }) | |||
| 724 | + | } | |||
| 725 | + | ||||
| 726 | + | // handlerWithAuthCheck wraps the fakePushRegistry handler and asserts that | |||
| 727 | + | // every request carries the expected Authorization header. | |||
| 728 | + | func (f *fakePushRegistry) handlerWithAuthCheck(t *testing.T, expectedAuth string, requestCount *int) http.Handler { | |||
| 729 | + | t.Helper() | |||
| 730 | + | inner := f.handler() | |||
| 731 | + | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 732 | + | *requestCount++ | |||
| 733 | + | assert.Equal(t, expectedAuth, r.Header.Get("Authorization"), | |||
| 734 | + | "expected Authorization header on %s %s", r.Method, r.URL.Path) | |||
| 735 | + | inner.ServeHTTP(w, r) | |||
| 736 | + | }) | |||
| 737 | + | } | |||