detached 2c89614

fix: address CodeRabbit review — context propagation, sentinels, mutex, CLI patterns

commit
2c896149d4634e19110bca93f4a9f0a9baa80546
author
brice · · 2026-03-23 05:13 UTC
parent
7c8965b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +60 -39
M internal/adapters/in/cli/auth.go +15 -15
@@ -223,7 +223,7 @@ Examples:
223223 if token == "" {
224224 return fmt.Errorf("--token is required; generate a token on the server with: gordon auth token generate")
225225 }
226- return runAuthLoginWithToken(remoteName, token)
226+ return runAuthLoginWithToken(cmd.Context(), remoteName, token, cmd.OutOrStdout())
227227 },
228228 }
229229
@@ -233,7 +233,7 @@ Examples:
233233 return cmd
234234 }
235235
236-func runAuthLoginWithToken(remoteName, token string) error {
236+func runAuthLoginWithToken(ctx context.Context, remoteName, token string, out io.Writer) error {
237237 token = strings.TrimSpace(token)
238238 if token == "" {
239239 return fmt.Errorf("token cannot be empty")
@@ -246,11 +246,11 @@ func runAuthLoginWithToken(remoteName, token string) error {
246246
247247 insecureTLS := remote.ResolveInsecureTLSForRemote(insecureTLSFlag, resolvedName)
248248 client := remote.NewClient(remoteConfig.URL, remoteClientOptions(token, insecureTLS)...)
249- ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
249+ verifyCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
250250 defer cancel()
251251 verified := true
252- if _, err := client.GetStatus(ctx); err != nil {
253- fmt.Println(styles.RenderWarning(fmt.Sprintf("Token verification failed: %v", err)))
252+ if _, err := client.GetStatus(verifyCtx); err != nil {
253+ fmt.Fprintln(out, styles.RenderWarning(fmt.Sprintf("Token verification failed: %v", err)))
254254 verified = false
255255 }
256256
@@ -258,10 +258,10 @@ func runAuthLoginWithToken(remoteName, token string) error {
258258 return fmt.Errorf("failed to save token: %w", err)
259259 }
260260
261- fmt.Println()
262- fmt.Println(styles.RenderSuccess(fmt.Sprintf("Token stored for remote '%s'", resolvedName)))
261+ fmt.Fprintln(out)
262+ fmt.Fprintln(out, styles.RenderSuccess(fmt.Sprintf("Token stored for remote '%s'", resolvedName)))
263263 if verified {
264- fmt.Println(styles.RenderSuccess("Token verified with remote status endpoint"))
264+ fmt.Fprintln(out, styles.RenderSuccess("Token verified with remote status endpoint"))
265265 }
266266 return nil
267267 }
@@ -285,7 +285,7 @@ Examples:
285285 gordon auth show-token --remote prod Show token for specific remote
286286 gordon auth show-token | pbcopy Copy token to clipboard`,
287287 RunE: func(cmd *cobra.Command, args []string) error {
288- return runAuthShowToken(remoteName)
288+ return runAuthShowToken(remoteName, cmd.OutOrStdout())
289289 },
290290 }
291291
@@ -294,7 +294,7 @@ Examples:
294294 return cmd
295295 }
296296
297-func runAuthShowToken(remoteName string) error {
297+func runAuthShowToken(remoteName string, out io.Writer) error {
298298 resolvedName, entry, err := resolveRemoteEntry(remoteName)
299299 if err != nil {
300300 return err
@@ -305,7 +305,7 @@ func runAuthShowToken(remoteName string) error {
305305 return fmt.Errorf("no token found for remote '%s'", resolvedName)
306306 }
307307
308- fmt.Println(token)
308+ fmt.Fprintln(out, token)
309309 return nil
310310 }
311311
@@ -330,7 +330,7 @@ Examples:
330330 gordon auth logout --remote prod Logout from specific remote
331331 gordon auth logout --revoke Logout and revoke token on server`,
332332 RunE: func(cmd *cobra.Command, args []string) error {
333- return runAuthLogout(remoteName, revoke)
333+ return runAuthLogout(remoteName, revoke, cmd.OutOrStdout())
334334 },
335335 }
336336
@@ -340,21 +340,21 @@ Examples:
340340 return cmd
341341 }
342342
343-func runAuthLogout(remoteName string, revoke bool) error {
343+func runAuthLogout(remoteName string, revoke bool, out io.Writer) error {
344344 resolvedName, _, err := resolveRemoteEntry(remoteName)
345345 if err != nil {
346346 return err
347347 }
348348
349349 if revoke {
350- fmt.Println(styles.RenderWarning("Token revocation not yet implemented; clearing local credentials only"))
350+ fmt.Fprintln(out, styles.RenderWarning("Token revocation not yet implemented; clearing local credentials only"))
351351 }
352352
353353 if err := remote.ClearRemoteToken(resolvedName); err != nil {
354354 return fmt.Errorf("failed to clear token: %w", err)
355355 }
356356
357- fmt.Println(styles.RenderSuccess(fmt.Sprintf("Logged out from remote '%s'", resolvedName)))
357+ fmt.Fprintln(out, styles.RenderSuccess(fmt.Sprintf("Logged out from remote '%s'", resolvedName)))
358358 return nil
359359 }
360360
@@ -223,7 +223,7 @@ Examples:
223 if token == "" {223 if token == "" {
224 return fmt.Errorf("--token is required; generate a token on the server with: gordon auth token generate")224 return fmt.Errorf("--token is required; generate a token on the server with: gordon auth token generate")
225 }225 }
226- return runAuthLoginWithToken(remoteName, token)226+ return runAuthLoginWithToken(cmd.Context(), remoteName, token, cmd.OutOrStdout())
227 },227 },
228 }228 }
229 229
@@ -233,7 +233,7 @@ Examples:
233 return cmd233 return cmd
234 }234 }
235 235
236-func runAuthLoginWithToken(remoteName, token string) error {236+func runAuthLoginWithToken(ctx context.Context, remoteName, token string, out io.Writer) error {
237 token = strings.TrimSpace(token)237 token = strings.TrimSpace(token)
238 if token == "" {238 if token == "" {
239 return fmt.Errorf("token cannot be empty")239 return fmt.Errorf("token cannot be empty")
@@ -246,11 +246,11 @@ func runAuthLoginWithToken(remoteName, token string) error {
246 246
247 insecureTLS := remote.ResolveInsecureTLSForRemote(insecureTLSFlag, resolvedName)247 insecureTLS := remote.ResolveInsecureTLSForRemote(insecureTLSFlag, resolvedName)
248 client := remote.NewClient(remoteConfig.URL, remoteClientOptions(token, insecureTLS)...)248 client := remote.NewClient(remoteConfig.URL, remoteClientOptions(token, insecureTLS)...)
249- ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)249+ verifyCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
250 defer cancel()250 defer cancel()
251 verified := true251 verified := true
252- if _, err := client.GetStatus(ctx); err != nil {252+ if _, err := client.GetStatus(verifyCtx); err != nil {
253- fmt.Println(styles.RenderWarning(fmt.Sprintf("Token verification failed: %v", err)))253+ fmt.Fprintln(out, styles.RenderWarning(fmt.Sprintf("Token verification failed: %v", err)))
254 verified = false254 verified = false
255 }255 }
256 256
@@ -258,10 +258,10 @@ func runAuthLoginWithToken(remoteName, token string) error {
258 return fmt.Errorf("failed to save token: %w", err)258 return fmt.Errorf("failed to save token: %w", err)
259 }259 }
260 260
261- fmt.Println()261+ fmt.Fprintln(out)
262- fmt.Println(styles.RenderSuccess(fmt.Sprintf("Token stored for remote '%s'", resolvedName)))262+ fmt.Fprintln(out, styles.RenderSuccess(fmt.Sprintf("Token stored for remote '%s'", resolvedName)))
263 if verified {263 if verified {
264- fmt.Println(styles.RenderSuccess("Token verified with remote status endpoint"))264+ fmt.Fprintln(out, styles.RenderSuccess("Token verified with remote status endpoint"))
265 }265 }
266 return nil266 return nil
267 }267 }
@@ -285,7 +285,7 @@ Examples:
285 gordon auth show-token --remote prod Show token for specific remote285 gordon auth show-token --remote prod Show token for specific remote
286 gordon auth show-token | pbcopy Copy token to clipboard`,286 gordon auth show-token | pbcopy Copy token to clipboard`,
287 RunE: func(cmd *cobra.Command, args []string) error {287 RunE: func(cmd *cobra.Command, args []string) error {
288- return runAuthShowToken(remoteName)288+ return runAuthShowToken(remoteName, cmd.OutOrStdout())
289 },289 },
290 }290 }
291 291
@@ -294,7 +294,7 @@ Examples:
294 return cmd294 return cmd
295 }295 }
296 296
297-func runAuthShowToken(remoteName string) error {297+func runAuthShowToken(remoteName string, out io.Writer) error {
298 resolvedName, entry, err := resolveRemoteEntry(remoteName)298 resolvedName, entry, err := resolveRemoteEntry(remoteName)
299 if err != nil {299 if err != nil {
300 return err300 return err
@@ -305,7 +305,7 @@ func runAuthShowToken(remoteName string) error {
305 return fmt.Errorf("no token found for remote '%s'", resolvedName)305 return fmt.Errorf("no token found for remote '%s'", resolvedName)
306 }306 }
307 307
308- fmt.Println(token)308+ fmt.Fprintln(out, token)
309 return nil309 return nil
310 }310 }
311 311
@@ -330,7 +330,7 @@ Examples:
330 gordon auth logout --remote prod Logout from specific remote330 gordon auth logout --remote prod Logout from specific remote
331 gordon auth logout --revoke Logout and revoke token on server`,331 gordon auth logout --revoke Logout and revoke token on server`,
332 RunE: func(cmd *cobra.Command, args []string) error {332 RunE: func(cmd *cobra.Command, args []string) error {
333- return runAuthLogout(remoteName, revoke)333+ return runAuthLogout(remoteName, revoke, cmd.OutOrStdout())
334 },334 },
335 }335 }
336 336
@@ -340,21 +340,21 @@ Examples:
340 return cmd340 return cmd
341 }341 }
342 342
343-func runAuthLogout(remoteName string, revoke bool) error {343+func runAuthLogout(remoteName string, revoke bool, out io.Writer) error {
344 resolvedName, _, err := resolveRemoteEntry(remoteName)344 resolvedName, _, err := resolveRemoteEntry(remoteName)
345 if err != nil {345 if err != nil {
346 return err346 return err
347 }347 }
348 348
349 if revoke {349 if revoke {
350- fmt.Println(styles.RenderWarning("Token revocation not yet implemented; clearing local credentials only"))350+ fmt.Fprintln(out, styles.RenderWarning("Token revocation not yet implemented; clearing local credentials only"))
351 }351 }
352 352
353 if err := remote.ClearRemoteToken(resolvedName); err != nil {353 if err := remote.ClearRemoteToken(resolvedName); err != nil {
354 return fmt.Errorf("failed to clear token: %w", err)354 return fmt.Errorf("failed to clear token: %w", err)
355 }355 }
356 356
357- fmt.Println(styles.RenderSuccess(fmt.Sprintf("Logged out from remote '%s'", resolvedName)))357+ fmt.Fprintln(out, styles.RenderSuccess(fmt.Sprintf("Logged out from remote '%s'", resolvedName)))
358 return nil358 return nil
359 }359 }
360 360
M internal/adapters/in/cli/auth_test.go +6 -2
@@ -1,6 +1,8 @@
11 package cli
22
33 import (
4+ "bytes"
5+ "context"
46 "net/http"
57 "net/http/httptest"
68 "path/filepath"
@@ -36,7 +38,8 @@ func TestRunAuthLoginWithToken_VerifiesAndStores(t *testing.T) {
3638 }
3739 assert.NoError(t, remote.SaveRemotes(remote.DefaultRemotesPath(), config))
3840
39- err := runAuthLoginWithToken("", "token123")
41+ var out bytes.Buffer
42+ err := runAuthLoginWithToken(context.Background(), "", "token123", &out)
4043 assert.NoError(t, err)
4144
4245 loaded, err := remote.LoadRemotes("")
@@ -67,7 +70,8 @@ func TestRunAuthLoginWithToken_StoresTokenOnVerificationFailure(t *testing.T) {
6770 }
6871 assert.NoError(t, remote.SaveRemotes(remote.DefaultRemotesPath(), config))
6972
70- err := runAuthLoginWithToken("", "token123")
73+ var out bytes.Buffer
74+ err := runAuthLoginWithToken(context.Background(), "", "token123", &out)
7175 assert.NoError(t, err)
7276
7377 loaded, err := remote.LoadRemotes("")
@@ -1,6 +1,8 @@
1 package cli1 package cli
2 2
3 import (3 import (
4+ "bytes"
5+ "context"
4 "net/http"6 "net/http"
5 "net/http/httptest"7 "net/http/httptest"
6 "path/filepath"8 "path/filepath"
@@ -36,7 +38,8 @@ func TestRunAuthLoginWithToken_VerifiesAndStores(t *testing.T) {
36 }38 }
37 assert.NoError(t, remote.SaveRemotes(remote.DefaultRemotesPath(), config))39 assert.NoError(t, remote.SaveRemotes(remote.DefaultRemotesPath(), config))
38 40
39- err := runAuthLoginWithToken("", "token123")41+ var out bytes.Buffer
42+ err := runAuthLoginWithToken(context.Background(), "", "token123", &out)
40 assert.NoError(t, err)43 assert.NoError(t, err)
41 44
42 loaded, err := remote.LoadRemotes("")45 loaded, err := remote.LoadRemotes("")
@@ -67,7 +70,8 @@ func TestRunAuthLoginWithToken_StoresTokenOnVerificationFailure(t *testing.T) {
67 }70 }
68 assert.NoError(t, remote.SaveRemotes(remote.DefaultRemotesPath(), config))71 assert.NoError(t, remote.SaveRemotes(remote.DefaultRemotesPath(), config))
69 72
70- err := runAuthLoginWithToken("", "token123")73+ var out bytes.Buffer
74+ err := runAuthLoginWithToken(context.Background(), "", "token123", &out)
71 assert.NoError(t, err)75 assert.NoError(t, err)
72 76
73 loaded, err := remote.LoadRemotes("")77 loaded, err := remote.LoadRemotes("")
M internal/adapters/in/cli/remote/client.go +22 -10
@@ -13,6 +13,7 @@ import (
1313 "net/url"
1414 "os"
1515 "strings"
16+ "sync"
1617 "time"
1718
1819 "github.com/golang-jwt/jwt/v5"
@@ -29,9 +30,10 @@ type Client struct {
2930 insecureTLS bool
3031
3132 // Ephemeral admin token exchange fields.
32- subject string // JWT subject extracted from long-lived token
33- ephemeral string // cached ephemeral admin token
34- ephemeralExp time.Time // expiry of cached ephemeral token
33+ mu sync.Mutex // protects ephemeral and ephemeralExp
34+ subject string // JWT subject extracted from long-lived token
35+ ephemeral string // cached ephemeral admin token
36+ ephemeralExp time.Time // expiry of cached ephemeral token
3537 }
3638
3739 var (
@@ -125,10 +127,11 @@ func (c *Client) ephemeralValid() bool {
125127
126128 // exchangeToken exchanges the long-lived token for a short-lived ephemeral
127129 // admin token via /auth/token. Follows the same pattern as ExchangeRegistryToken.
128-func (c *Client) exchangeToken() error {
130+// The caller must hold c.mu.
131+func (c *Client) exchangeToken(ctx context.Context) error {
129132 url := c.baseURL + "/auth/token?scope=admin:*:*&service=gordon-registry"
130133
131- req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
134+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
132135 if err != nil {
133136 return fmt.Errorf("ephemeral token exchange: %w", err)
134137 }
@@ -166,6 +169,10 @@ func (c *Client) exchangeToken() error {
166169 return fmt.Errorf("ephemeral token exchange returned empty token")
167170 }
168171
172+ if result.ExpiresIn <= 0 {
173+ return fmt.Errorf("ephemeral token exchange: invalid expires_in value: %d", result.ExpiresIn)
174+ }
175+
169176 c.ephemeral = token
170177 c.ephemeralExp = time.Now().Add(time.Duration(result.ExpiresIn) * time.Second)
171178 return nil
@@ -174,7 +181,7 @@ func (c *Client) exchangeToken() error {
174181 // bearerToken returns the token to use in Authorization headers.
175182 // If the client has a subject (valid JWT), it exchanges for an ephemeral token.
176183 // Otherwise it falls back to sending the long-lived token directly.
177-func (c *Client) bearerToken() (string, error) {
184+func (c *Client) bearerToken(ctx context.Context) (string, error) {
178185 if c.token == "" {
179186 return "", nil
180187 }
@@ -182,8 +189,10 @@ func (c *Client) bearerToken() (string, error) {
182189 if c.subject == "" {
183190 return c.token, nil
184191 }
192+ c.mu.Lock()
193+ defer c.mu.Unlock()
185194 if !c.ephemeralValid() {
186- if err := c.exchangeToken(); err != nil {
195+ if err := c.exchangeToken(ctx); err != nil {
187196 return "", err
188197 }
189198 }
@@ -244,11 +253,14 @@ func (c *Client) request(ctx context.Context, method, path string, body any) (*h
244253 _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, maxErrorBodySize))
245254 resp.Body.Close()
246255
256+ c.mu.Lock()
247257 c.ephemeral = ""
248258 c.ephemeralExp = time.Time{}
249- if exErr := c.exchangeToken(); exErr != nil {
259+ if exErr := c.exchangeToken(ctx); exErr != nil {
260+ c.mu.Unlock()
250261 return nil, fmt.Errorf("token re-exchange after 401: %w", exErr)
251262 }
263+ c.mu.Unlock()
252264 return c.doRequest(ctx, method, path, jsonBody)
253265 }
254266
@@ -272,7 +284,7 @@ func (c *Client) doRequest(ctx context.Context, method, path string, jsonBody []
272284 req.Header.Set("Content-Type", "application/json")
273285 req.Header.Set("Accept", "application/json")
274286
275- bearer, err := c.bearerToken()
287+ bearer, err := c.bearerToken(ctx)
276288 if err != nil {
277289 return nil, err
278290 }
@@ -1112,7 +1124,7 @@ func (c *Client) openSSEStream(ctx context.Context, path string) (*http.Response
11121124 }
11131125
11141126 req.Header.Set("Accept", "text/event-stream")
1115- bearer, err := c.bearerToken()
1127+ bearer, err := c.bearerToken(ctx)
11161128 if err != nil {
11171129 return nil, err
11181130 }
@@ -13,6 +13,7 @@ import (
13 "net/url"13 "net/url"
14 "os"14 "os"
15 "strings"15 "strings"
16+ "sync"
16 "time"17 "time"
17 18
18 "github.com/golang-jwt/jwt/v5"19 "github.com/golang-jwt/jwt/v5"
@@ -29,9 +30,10 @@ type Client struct {
29 insecureTLS bool30 insecureTLS bool
30 31
31 // Ephemeral admin token exchange fields.32 // Ephemeral admin token exchange fields.
32- subject string // JWT subject extracted from long-lived token33+ mu sync.Mutex // protects ephemeral and ephemeralExp
33- ephemeral string // cached ephemeral admin token34+ subject string // JWT subject extracted from long-lived token
34- ephemeralExp time.Time // expiry of cached ephemeral token35+ ephemeral string // cached ephemeral admin token
36+ ephemeralExp time.Time // expiry of cached ephemeral token
35 }37 }
36 38
37 var (39 var (
@@ -125,10 +127,11 @@ func (c *Client) ephemeralValid() bool {
125 127
126 // exchangeToken exchanges the long-lived token for a short-lived ephemeral128 // exchangeToken exchanges the long-lived token for a short-lived ephemeral
127 // admin token via /auth/token. Follows the same pattern as ExchangeRegistryToken.129 // admin token via /auth/token. Follows the same pattern as ExchangeRegistryToken.
128-func (c *Client) exchangeToken() error {130+// The caller must hold c.mu.
131+func (c *Client) exchangeToken(ctx context.Context) error {
129 url := c.baseURL + "/auth/token?scope=admin:*:*&service=gordon-registry"132 url := c.baseURL + "/auth/token?scope=admin:*:*&service=gordon-registry"
130 133
131- req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)134+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
132 if err != nil {135 if err != nil {
133 return fmt.Errorf("ephemeral token exchange: %w", err)136 return fmt.Errorf("ephemeral token exchange: %w", err)
134 }137 }
@@ -166,6 +169,10 @@ func (c *Client) exchangeToken() error {
166 return fmt.Errorf("ephemeral token exchange returned empty token")169 return fmt.Errorf("ephemeral token exchange returned empty token")
167 }170 }
168 171
172+ if result.ExpiresIn <= 0 {
173+ return fmt.Errorf("ephemeral token exchange: invalid expires_in value: %d", result.ExpiresIn)
174+ }
175+
169 c.ephemeral = token176 c.ephemeral = token
170 c.ephemeralExp = time.Now().Add(time.Duration(result.ExpiresIn) * time.Second)177 c.ephemeralExp = time.Now().Add(time.Duration(result.ExpiresIn) * time.Second)
171 return nil178 return nil
@@ -174,7 +181,7 @@ func (c *Client) exchangeToken() error {
174 // bearerToken returns the token to use in Authorization headers.181 // bearerToken returns the token to use in Authorization headers.
175 // If the client has a subject (valid JWT), it exchanges for an ephemeral token.182 // If the client has a subject (valid JWT), it exchanges for an ephemeral token.
176 // Otherwise it falls back to sending the long-lived token directly.183 // Otherwise it falls back to sending the long-lived token directly.
177-func (c *Client) bearerToken() (string, error) {184+func (c *Client) bearerToken(ctx context.Context) (string, error) {
178 if c.token == "" {185 if c.token == "" {
179 return "", nil186 return "", nil
180 }187 }
@@ -182,8 +189,10 @@ func (c *Client) bearerToken() (string, error) {
182 if c.subject == "" {189 if c.subject == "" {
183 return c.token, nil190 return c.token, nil
184 }191 }
192+ c.mu.Lock()
193+ defer c.mu.Unlock()
185 if !c.ephemeralValid() {194 if !c.ephemeralValid() {
186- if err := c.exchangeToken(); err != nil {195+ if err := c.exchangeToken(ctx); err != nil {
187 return "", err196 return "", err
188 }197 }
189 }198 }
@@ -244,11 +253,14 @@ func (c *Client) request(ctx context.Context, method, path string, body any) (*h
244 _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, maxErrorBodySize))253 _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, maxErrorBodySize))
245 resp.Body.Close()254 resp.Body.Close()
246 255
256+ c.mu.Lock()
247 c.ephemeral = ""257 c.ephemeral = ""
248 c.ephemeralExp = time.Time{}258 c.ephemeralExp = time.Time{}
249- if exErr := c.exchangeToken(); exErr != nil {259+ if exErr := c.exchangeToken(ctx); exErr != nil {
260+ c.mu.Unlock()
250 return nil, fmt.Errorf("token re-exchange after 401: %w", exErr)261 return nil, fmt.Errorf("token re-exchange after 401: %w", exErr)
251 }262 }
263+ c.mu.Unlock()
252 return c.doRequest(ctx, method, path, jsonBody)264 return c.doRequest(ctx, method, path, jsonBody)
253 }265 }
254 266
@@ -272,7 +284,7 @@ func (c *Client) doRequest(ctx context.Context, method, path string, jsonBody []
272 req.Header.Set("Content-Type", "application/json")284 req.Header.Set("Content-Type", "application/json")
273 req.Header.Set("Accept", "application/json")285 req.Header.Set("Accept", "application/json")
274 286
275- bearer, err := c.bearerToken()287+ bearer, err := c.bearerToken(ctx)
276 if err != nil {288 if err != nil {
277 return nil, err289 return nil, err
278 }290 }
@@ -1112,7 +1124,7 @@ func (c *Client) openSSEStream(ctx context.Context, path string) (*http.Response
1112 }1124 }
1113 1125
1114 req.Header.Set("Accept", "text/event-stream")1126 req.Header.Set("Accept", "text/event-stream")
1115- bearer, err := c.bearerToken()1127+ bearer, err := c.bearerToken(ctx)
1116 if err != nil {1128 if err != nil {
1117 return nil, err1129 return nil, err
1118 }1130 }
M internal/adapters/in/cli/remote/config.go +4 -2
@@ -7,6 +7,8 @@ import (
77 "strconv"
88
99 "github.com/pelletier/go-toml/v2"
10+
11+ "github.com/bnema/gordon/internal/domain"
1012 )
1113
1214 // ClientConfig represents the client-mode configuration.
@@ -345,7 +347,7 @@ func SetActiveRemote(name string) error {
345347
346348 // Verify remote exists
347349 if _, ok := config.Remotes[name]; !ok {
348- return fmt.Errorf("remote '%s' not found", name)
350+ return fmt.Errorf("%w: %s", domain.ErrRemoteNotFound, name)
349351 }
350352
351353 config.Active = name
@@ -367,7 +369,7 @@ func ClearRemoteToken(name string) error {
367369
368370 entry, ok := config.Remotes[name]
369371 if !ok {
370- return fmt.Errorf("remote '%s' not found", name)
372+ return fmt.Errorf("%w: %s", domain.ErrRemoteNotFound, name)
371373 }
372374
373375 entry.Token = ""
@@ -7,6 +7,8 @@ import (
7 "strconv"7 "strconv"
8 8
9 "github.com/pelletier/go-toml/v2"9 "github.com/pelletier/go-toml/v2"
10+
11+ "github.com/bnema/gordon/internal/domain"
10 )12 )
11 13
12 // ClientConfig represents the client-mode configuration.14 // ClientConfig represents the client-mode configuration.
@@ -345,7 +347,7 @@ func SetActiveRemote(name string) error {
345 347
346 // Verify remote exists348 // Verify remote exists
347 if _, ok := config.Remotes[name]; !ok {349 if _, ok := config.Remotes[name]; !ok {
348- return fmt.Errorf("remote '%s' not found", name)350+ return fmt.Errorf("%w: %s", domain.ErrRemoteNotFound, name)
349 }351 }
350 352
351 config.Active = name353 config.Active = name
@@ -367,7 +369,7 @@ func ClearRemoteToken(name string) error {
367 369
368 entry, ok := config.Remotes[name]370 entry, ok := config.Remotes[name]
369 if !ok {371 if !ok {
370- return fmt.Errorf("remote '%s' not found", name)372+ return fmt.Errorf("%w: %s", domain.ErrRemoteNotFound, name)
371 }373 }
372 374
373 entry.Token = ""375 entry.Token = ""
M internal/adapters/in/cli/remote/pass.go +6 -1
@@ -104,7 +104,12 @@ func passDeleteToken(name string) error {
104104 }
105105
106106 cmd := exec.Command("pass", "rm", "-f", passTokenPath(name)) //nolint:gosec // pass binary name is constant; remote names are validated
107- return cmd.Run()
107+ var stderr bytes.Buffer
108+ cmd.Stderr = &stderr
109+ if err := cmd.Run(); err != nil {
110+ return fmt.Errorf("pass rm failed: %w: %s", err, stderr.String())
111+ }
112+ return nil
108113 }
109114
110115 func warnPassUnavailable() {
@@ -104,7 +104,12 @@ func passDeleteToken(name string) error {
104 }104 }
105 105
106 cmd := exec.Command("pass", "rm", "-f", passTokenPath(name)) //nolint:gosec // pass binary name is constant; remote names are validated106 cmd := exec.Command("pass", "rm", "-f", passTokenPath(name)) //nolint:gosec // pass binary name is constant; remote names are validated
107- return cmd.Run()107+ var stderr bytes.Buffer
108+ cmd.Stderr = &stderr
109+ if err := cmd.Run(); err != nil {
110+ return fmt.Errorf("pass rm failed: %w: %s", err, stderr.String())
111+ }
112+ return nil
108 }113 }
109 114
110 func warnPassUnavailable() {115 func warnPassUnavailable() {
M internal/adapters/in/http/middleware/auth.go +5 -9
@@ -16,10 +16,6 @@ import (
1616 "github.com/bnema/gordon/internal/domain"
1717 )
1818
19-// errLongLivedToken is returned when a long-lived (stored) token is used on an
20-// endpoint that requires an ephemeral access token from /auth/token.
21-var errLongLivedToken = errors.New("long-lived tokens must be exchanged via /auth/token")
22-
2319 // TokenClaimsKey is context key for storing token claims.
2420 // Using domain key for consistency across all auth flows.
2521 const TokenClaimsKey = domain.TokenClaimsKey
@@ -99,8 +95,8 @@ func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, l
9995 tokenClaims, authErr := authenticateToken(ctx, r, authSvc, log)
10096
10197 if authErr != nil {
102- if errors.Is(authErr, errLongLivedToken) {
103- sendUnauthorizedMsg(w, authSvc.GetAuthType(), r.Host, log, r, errLongLivedToken.Error())
98+ if errors.Is(authErr, domain.ErrLongLivedToken) {
99+ sendUnauthorizedMsg(w, authSvc.GetAuthType(), r.Host, log, r, domain.ErrLongLivedToken.Error())
104100 } else {
105101 sendUnauthorized(w, authSvc.GetAuthType(), r.Host, log, r)
106102 }
@@ -127,7 +123,7 @@ func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, l
127123 // authenticateToken handles token-based authentication.
128124 // It supports both Bearer token in Authorization header and token-as-password for CI.
129125 // Returns (tokenClaims, error). A nil error means authentication succeeded.
130-// Returns errLongLivedToken when a stored (non-ephemeral) token is used.
126+// Returns domain.ErrLongLivedToken when a stored (non-ephemeral) token is used.
131127 func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthService, log zerowrap.Logger) (*domain.TokenClaims, error) {
132128 // First, check for Bearer token
133129 authHeader := r.Header.Get("Authorization")
@@ -149,7 +145,7 @@ func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthServ
149145 Str(zerowrap.FieldMethod, r.Method).
150146 Str(zerowrap.FieldPath, r.URL.Path).
151147 Msg("long-lived token rejected on registry endpoint")
152- return nil, errLongLivedToken
148+ return nil, domain.ErrLongLivedToken
153149 }
154150
155151 log.Debug().
@@ -200,7 +196,7 @@ func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthServ
200196 Str(zerowrap.FieldMethod, r.Method).
201197 Str(zerowrap.FieldPath, r.URL.Path).
202198 Msg("long-lived token rejected on registry endpoint")
203- return nil, errLongLivedToken
199+ return nil, domain.ErrLongLivedToken
204200 }
205201
206202 log.Debug().
@@ -16,10 +16,6 @@ import (
16 "github.com/bnema/gordon/internal/domain"16 "github.com/bnema/gordon/internal/domain"
17 )17 )
18 18
19-// errLongLivedToken is returned when a long-lived (stored) token is used on an
20-// endpoint that requires an ephemeral access token from /auth/token.
21-var errLongLivedToken = errors.New("long-lived tokens must be exchanged via /auth/token")
22-
23 // TokenClaimsKey is context key for storing token claims.19 // TokenClaimsKey is context key for storing token claims.
24 // Using domain key for consistency across all auth flows.20 // Using domain key for consistency across all auth flows.
25 const TokenClaimsKey = domain.TokenClaimsKey21 const TokenClaimsKey = domain.TokenClaimsKey
@@ -99,8 +95,8 @@ func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, l
99 tokenClaims, authErr := authenticateToken(ctx, r, authSvc, log)95 tokenClaims, authErr := authenticateToken(ctx, r, authSvc, log)
100 96
101 if authErr != nil {97 if authErr != nil {
102- if errors.Is(authErr, errLongLivedToken) {98+ if errors.Is(authErr, domain.ErrLongLivedToken) {
103- sendUnauthorizedMsg(w, authSvc.GetAuthType(), r.Host, log, r, errLongLivedToken.Error())99+ sendUnauthorizedMsg(w, authSvc.GetAuthType(), r.Host, log, r, domain.ErrLongLivedToken.Error())
104 } else {100 } else {
105 sendUnauthorized(w, authSvc.GetAuthType(), r.Host, log, r)101 sendUnauthorized(w, authSvc.GetAuthType(), r.Host, log, r)
106 }102 }
@@ -127,7 +123,7 @@ func RegistryAuthV2(authSvc in.AuthService, internalAuth InternalRegistryAuth, l
127 // authenticateToken handles token-based authentication.123 // authenticateToken handles token-based authentication.
128 // It supports both Bearer token in Authorization header and token-as-password for CI.124 // It supports both Bearer token in Authorization header and token-as-password for CI.
129 // Returns (tokenClaims, error). A nil error means authentication succeeded.125 // Returns (tokenClaims, error). A nil error means authentication succeeded.
130-// Returns errLongLivedToken when a stored (non-ephemeral) token is used.126+// Returns domain.ErrLongLivedToken when a stored (non-ephemeral) token is used.
131 func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthService, log zerowrap.Logger) (*domain.TokenClaims, error) {127 func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthService, log zerowrap.Logger) (*domain.TokenClaims, error) {
132 // First, check for Bearer token128 // First, check for Bearer token
133 authHeader := r.Header.Get("Authorization")129 authHeader := r.Header.Get("Authorization")
@@ -149,7 +145,7 @@ func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthServ
149 Str(zerowrap.FieldMethod, r.Method).145 Str(zerowrap.FieldMethod, r.Method).
150 Str(zerowrap.FieldPath, r.URL.Path).146 Str(zerowrap.FieldPath, r.URL.Path).
151 Msg("long-lived token rejected on registry endpoint")147 Msg("long-lived token rejected on registry endpoint")
152- return nil, errLongLivedToken148+ return nil, domain.ErrLongLivedToken
153 }149 }
154 150
155 log.Debug().151 log.Debug().
@@ -200,7 +196,7 @@ func authenticateToken(ctx context.Context, r *http.Request, authSvc in.AuthServ
200 Str(zerowrap.FieldMethod, r.Method).196 Str(zerowrap.FieldMethod, r.Method).
201 Str(zerowrap.FieldPath, r.URL.Path).197 Str(zerowrap.FieldPath, r.URL.Path).
202 Msg("long-lived token rejected on registry endpoint")198 Msg("long-lived token rejected on registry endpoint")
203- return nil, errLongLivedToken199+ return nil, domain.ErrLongLivedToken
204 }200 }
205 201
206 log.Debug().202 log.Debug().
M internal/domain/errors.go +2 -0
@@ -69,12 +69,14 @@ var (
6969 ErrInvalidScope = errors.New("invalid scope format")
7070 ErrSSRFBlocked = errors.New("request to internal/blocked network not allowed")
7171 ErrInvalidEnvKey = errors.New("invalid environment variable key")
72+ ErrLongLivedToken = errors.New("long-lived tokens must be exchanged via /auth/token")
7273
7374 // Remote errors
7475 ErrEmptyRemoteName = errors.New("remote name must not be empty")
7576 ErrInvalidRemoteNameChar = errors.New("remote name contains invalid characters")
7677 ErrConsecutiveDots = errors.New("remote name must not contain consecutive dots")
7778 ErrPassUnavailable = errors.New("pass is not available")
79+ ErrRemoteNotFound = errors.New("remote not found")
7880
7981 // Attachment errors
8082 ErrAttachmentNotFound = errors.New("attachment not found")
@@ -69,12 +69,14 @@ var (
69 ErrInvalidScope = errors.New("invalid scope format")69 ErrInvalidScope = errors.New("invalid scope format")
70 ErrSSRFBlocked = errors.New("request to internal/blocked network not allowed")70 ErrSSRFBlocked = errors.New("request to internal/blocked network not allowed")
71 ErrInvalidEnvKey = errors.New("invalid environment variable key")71 ErrInvalidEnvKey = errors.New("invalid environment variable key")
72+ ErrLongLivedToken = errors.New("long-lived tokens must be exchanged via /auth/token")
72 73
73 // Remote errors74 // Remote errors
74 ErrEmptyRemoteName = errors.New("remote name must not be empty")75 ErrEmptyRemoteName = errors.New("remote name must not be empty")
75 ErrInvalidRemoteNameChar = errors.New("remote name contains invalid characters")76 ErrInvalidRemoteNameChar = errors.New("remote name contains invalid characters")
76 ErrConsecutiveDots = errors.New("remote name must not contain consecutive dots")77 ErrConsecutiveDots = errors.New("remote name must not contain consecutive dots")
77 ErrPassUnavailable = errors.New("pass is not available")78 ErrPassUnavailable = errors.New("pass is not available")
79+ ErrRemoteNotFound = errors.New("remote not found")
78 80
79 // Attachment errors81 // Attachment errors
80 ErrAttachmentNotFound = errors.New("attachment not found")82 ErrAttachmentNotFound = errors.New("attachment not found")

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