detached 5498bc4

feat(auth): add token login and registry pull token

commit
5498bc4df94baae96ac2bd967a5effe780de0e01
author
bnema · · 2026-01-30 20:52 UTC
parent
6ee1d33
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
7 files · +237 -52
M docs/cli/auth.md +13 -3
@@ -19,7 +19,7 @@ Manage Gordon server authentication tokens and passwords.
1919
2020 ## gordon auth login
2121
22-Authenticate with a remote Gordon server using password authentication.
22+Authenticate with a remote Gordon server using password authentication or a pre-generated token.
2323
2424 ```bash
2525 gordon auth login [options]
@@ -31,14 +31,18 @@ gordon auth login [options]
3131 |--------|-------------|
3232 | `-r, --remote` | Remote to authenticate with (defaults to active remote) |
3333 | `-u, --username` | Username for authentication |
34+| `-p, --password` | Password for authentication |
35+| `-t, --token` | Authentication token to store for the remote |
3436
3537 ### Description
3638
3739 This command prompts for your username and password, authenticates with the remote server's `/auth/password` endpoint, and stores the returned token in your remotes configuration.
3840
41+If you provide `--token`, the token is stored directly and verified against `/admin/status` on a best-effort basis.
42+
3943 The token is a long-lived JWT (7 days) that's used for subsequent CLI operations against the remote.
4044
41-> **Note:** This command only works with servers that have password authentication configured (`username` + `password_hash`). For token-only servers, use `gordon remotes set-token` instead.
45+> **Note:** For token-only servers, you can pass `--token` here or use `gordon remotes set-token`.
4246
4347 ### Examples
4448
@@ -51,6 +55,9 @@ gordon auth login --remote prod
5155
5256 # Pre-fill username
5357 gordon auth login --username admin
58+
59+# Store a pre-generated token
60+gordon auth login --token <token>
5461 ```
5562
5663 ### Output
@@ -212,7 +219,9 @@ Display the auto-generated internal registry credentials.
212219 gordon auth internal
213220 ```
214221
215-Gordon generates temporary credentials for internal communication with its local registry. These credentials are useful for manual recovery when debugging deployment issues.
222+Gordon generates temporary credentials for loopback-only communication with its local registry. These credentials are useful for manual recovery when debugging deployment issues.
223+
224+When registry auth is enabled, Gordon also generates a separate service token for its own registry-domain pulls. That token is not exposed via the CLI.
216225
217226 ### Output
218227
@@ -231,6 +240,7 @@ Usage:
231240 - Credentials are regenerated each time Gordon starts
232241 - Only available while Gordon is running
233242 - Used for manual `docker pull` from the local registry during debugging
243+- Separate from the service token used by Gordon core for registry-domain pulls
234244
235245 ### Use Cases
236246
@@ -19,7 +19,7 @@ Manage Gordon server authentication tokens and passwords.
19 19
20 ## gordon auth login20 ## gordon auth login
21 21
22-Authenticate with a remote Gordon server using password authentication.22+Authenticate with a remote Gordon server using password authentication or a pre-generated token.
23 23
24 ```bash24 ```bash
25 gordon auth login [options]25 gordon auth login [options]
@@ -31,14 +31,18 @@ gordon auth login [options]
31 |--------|-------------|31 |--------|-------------|
32 | `-r, --remote` | Remote to authenticate with (defaults to active remote) |32 | `-r, --remote` | Remote to authenticate with (defaults to active remote) |
33 | `-u, --username` | Username for authentication |33 | `-u, --username` | Username for authentication |
34+| `-p, --password` | Password for authentication |
35+| `-t, --token` | Authentication token to store for the remote |
34 36
35 ### Description37 ### Description
36 38
37 This command prompts for your username and password, authenticates with the remote server's `/auth/password` endpoint, and stores the returned token in your remotes configuration.39 This command prompts for your username and password, authenticates with the remote server's `/auth/password` endpoint, and stores the returned token in your remotes configuration.
38 40
41+If you provide `--token`, the token is stored directly and verified against `/admin/status` on a best-effort basis.
42+
39 The token is a long-lived JWT (7 days) that's used for subsequent CLI operations against the remote.43 The token is a long-lived JWT (7 days) that's used for subsequent CLI operations against the remote.
40 44
41-> **Note:** This command only works with servers that have password authentication configured (`username` + `password_hash`). For token-only servers, use `gordon remotes set-token` instead.45+> **Note:** For token-only servers, you can pass `--token` here or use `gordon remotes set-token`.
42 46
43 ### Examples47 ### Examples
44 48
@@ -51,6 +55,9 @@ gordon auth login --remote prod
51 55
52 # Pre-fill username56 # Pre-fill username
53 gordon auth login --username admin57 gordon auth login --username admin
58+
59+# Store a pre-generated token
60+gordon auth login --token <token>
54 ```61 ```
55 62
56 ### Output63 ### Output
@@ -212,7 +219,9 @@ Display the auto-generated internal registry credentials.
212 gordon auth internal219 gordon auth internal
213 ```220 ```
214 221
215-Gordon generates temporary credentials for internal communication with its local registry. These credentials are useful for manual recovery when debugging deployment issues.222+Gordon generates temporary credentials for loopback-only communication with its local registry. These credentials are useful for manual recovery when debugging deployment issues.
223+
224+When registry auth is enabled, Gordon also generates a separate service token for its own registry-domain pulls. That token is not exposed via the CLI.
216 225
217 ### Output226 ### Output
218 227
@@ -231,6 +240,7 @@ Usage:
231 - Credentials are regenerated each time Gordon starts240 - Credentials are regenerated each time Gordon starts
232 - Only available while Gordon is running241 - Only available while Gordon is running
233 - Used for manual `docker pull` from the local registry during debugging242 - Used for manual `docker pull` from the local registry during debugging
243+- Separate from the service token used by Gordon core for registry-domain pulls
234 244
235 ### Use Cases245 ### Use Cases
236 246
M docs/config/auth.md +2 -0
@@ -313,6 +313,8 @@ token_secret = "gordon/auth/token_secret"
313313
314314 Gordon generates internal credentials automatically when auth is enabled. These are used for loopback pulls when deploying containers and are regenerated on each restart.
315315
316+Gordon also generates a separate service token for its own registry-domain pulls. This token is managed internally and is not exposed via configuration or CLI.
317+
316318 To view internal credentials (for troubleshooting):
317319 ```bash
318320 gordon auth internal
@@ -313,6 +313,8 @@ token_secret = "gordon/auth/token_secret"
313 313
314 Gordon generates internal credentials automatically when auth is enabled. These are used for loopback pulls when deploying containers and are regenerated on each restart.314 Gordon generates internal credentials automatically when auth is enabled. These are used for loopback pulls when deploying containers and are regenerated on each restart.
315 315
316+Gordon also generates a separate service token for its own registry-domain pulls. This token is managed internally and is not exposed via configuration or CLI.
317+
316 To view internal credentials (for troubleshooting):318 To view internal credentials (for troubleshooting):
317 ```bash319 ```bash
318 gordon auth internal320 gordon auth internal
M internal/adapters/in/cli/auth.go +89 -40
@@ -207,12 +207,14 @@ func newAuthLoginCmd() *cobra.Command {
207207 var (
208208 remoteName string
209209 username string
210+ token string
211+ password string
210212 )
211213
212214 cmd := &cobra.Command{
213215 Use: "login",
214216 Short: "Authenticate with a Gordon server",
215- Long: `Authenticate with a Gordon server using password authentication.
217+ Long: `Authenticate with a Gordon server using password authentication or a pre-generated token.
216218
217219 This command prompts for your username and password, authenticates with the
218220 remote server's /auth/password endpoint, and stores the returned token.
@@ -220,43 +222,35 @@ remote server's /auth/password endpoint, and stores the returned token.
220222 The token is stored in your remote configuration and used for subsequent
221223 CLI operations.
222224
223-Note: This command only works with servers configured for password authentication.
224-For servers using token-based auth, use 'gordon remote set-token' instead.
225+If --token is provided, the token is stored for the remote and verified
226+against /admin/status on a best-effort basis.
225227
226228 Examples:
227- gordon auth login Login using active remote
228- gordon auth login --remote prod Login to specific remote
229- gordon auth login --username admin Pre-fill username`,
229+ gordon auth login Login using active remote
230+ gordon auth login --remote prod Login to specific remote
231+ gordon auth login --username admin Pre-fill username
232+ gordon auth login --token <token> Store a token for token-only servers`,
230233 RunE: func(cmd *cobra.Command, args []string) error {
231- return runAuthLogin(remoteName, username)
234+ if token != "" {
235+ return runAuthLoginWithToken(remoteName, token)
236+ }
237+ return runAuthLogin(remoteName, username, password)
232238 },
233239 }
234240
235241 cmd.Flags().StringVarP(&remoteName, "remote", "r", "", "Remote to authenticate with (defaults to active remote)")
236242 cmd.Flags().StringVarP(&username, "username", "u", "", "Username for authentication")
243+ cmd.Flags().StringVarP(&password, "password", "p", "", "Password for authentication")
244+ cmd.Flags().StringVarP(&token, "token", "t", "", "Authentication token to store for the remote")
237245
238246 return cmd
239247 }
240248
241249 // runAuthLogin authenticates with a remote Gordon server.
242-func runAuthLogin(remoteName, username string) error {
243- // Load remotes config
244- config, err := remote.LoadRemotes("")
250+func runAuthLogin(remoteName, username, password string) error {
251+ resolvedName, remoteConfig, err := resolveRemoteEntry(remoteName)
245252 if err != nil {
246- return fmt.Errorf("failed to load remotes: %w", err)
247- }
248-
249- // Determine which remote to use
250- if remoteName == "" {
251- remoteName = config.Active
252- }
253- if remoteName == "" {
254- return fmt.Errorf("no remote specified and no active remote set. Use --remote or 'gordon remote use <name>'")
255- }
256-
257- remoteConfig, ok := config.Remotes[remoteName]
258- if !ok {
259- return fmt.Errorf("remote '%s' not found", remoteName)
253+ return err
260254 }
261255
262256 // Prompt for username if not provided
@@ -274,21 +268,25 @@ func runAuthLogin(remoteName, username string) error {
274268 return fmt.Errorf("username cannot be empty")
275269 }
276270
277- // Prompt for password (hidden input)
278- fmt.Print("Password: ")
279- passwordBytes, err := term.ReadPassword(int(os.Stdin.Fd()))
280- if err != nil {
281- // Fallback for non-terminal input
282- reader := bufio.NewReader(os.Stdin)
283- password, readErr := reader.ReadString('\n')
284- if readErr != nil {
285- return fmt.Errorf("failed to read password: %w", readErr)
271+ password = strings.TrimSpace(password)
272+ if password == "" {
273+ // Prompt for password (hidden input)
274+ fmt.Print("Password: ")
275+ passwordBytes, err := term.ReadPassword(int(os.Stdin.Fd()))
276+ if err != nil {
277+ // Fallback for non-terminal input
278+ reader := bufio.NewReader(os.Stdin)
279+ passwordInput, readErr := reader.ReadString('\n')
280+ if readErr != nil {
281+ return fmt.Errorf("failed to read password: %w", readErr)
282+ }
283+ passwordBytes = []byte(strings.TrimSpace(passwordInput))
286284 }
287- passwordBytes = []byte(strings.TrimSpace(password))
285+ fmt.Println()
286+ password = strings.TrimSpace(string(passwordBytes))
288287 }
289- fmt.Println() // newline after hidden password input
290288
291- if len(passwordBytes) == 0 {
289+ if password == "" {
292290 return fmt.Errorf("password cannot be empty")
293291 }
294292
@@ -297,26 +295,77 @@ func runAuthLogin(remoteName, username string) error {
297295
298296 // Authenticate
299297 ctx := context.Background()
300- fmt.Printf("Authenticating with %s...\n", remoteName)
298+ fmt.Printf("Authenticating with %s...\n", resolvedName)
301299
302- resp, err := client.Authenticate(ctx, username, string(passwordBytes))
300+ resp, err := client.Authenticate(ctx, username, password)
303301 if err != nil {
304302 return fmt.Errorf("authentication failed: %w", err)
305303 }
306304
307305 // Update remote config with new token
308- if err := remote.UpdateRemoteToken(remoteName, resp.Token); err != nil {
306+ if err := remote.UpdateRemoteToken(resolvedName, resp.Token); err != nil {
309307 return fmt.Errorf("failed to save token: %w", err)
310308 }
311309
312310 fmt.Println()
313311 fmt.Println(styles.RenderSuccess("Authentication successful!"))
314- fmt.Printf("Token stored for remote '%s'\n", remoteName)
312+ fmt.Printf("Token stored for remote '%s'\n", resolvedName)
315313 fmt.Printf("Expires in: %d seconds\n", resp.ExpiresIn)
316314
317315 return nil
318316 }
319317
318+func runAuthLoginWithToken(remoteName, token string) error {
319+ token = strings.TrimSpace(token)
320+ if token == "" {
321+ return fmt.Errorf("token cannot be empty")
322+ }
323+
324+ resolvedName, remoteConfig, err := resolveRemoteEntry(remoteName)
325+ if err != nil {
326+ return err
327+ }
328+
329+ if err := remote.UpdateRemoteToken(resolvedName, token); err != nil {
330+ return fmt.Errorf("failed to save token: %w", err)
331+ }
332+
333+ fmt.Println()
334+ fmt.Println(styles.RenderSuccess(fmt.Sprintf("Token stored for remote '%s'", resolvedName)))
335+
336+ client := remote.NewClient(remoteConfig.URL, remote.WithToken(token))
337+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
338+ defer cancel()
339+ if _, err := client.GetStatus(ctx); err != nil {
340+ fmt.Println(styles.RenderWarning(fmt.Sprintf("Token verification failed: %v", err)))
341+ return nil
342+ }
343+
344+ fmt.Println(styles.RenderSuccess("Token verified with remote status endpoint"))
345+ return nil
346+}
347+
348+func resolveRemoteEntry(remoteName string) (string, remote.RemoteEntry, error) {
349+ config, err := remote.LoadRemotes("")
350+ if err != nil {
351+ return "", remote.RemoteEntry{}, fmt.Errorf("failed to load remotes: %w", err)
352+ }
353+
354+ if remoteName == "" {
355+ remoteName = config.Active
356+ }
357+ if remoteName == "" {
358+ return "", remote.RemoteEntry{}, fmt.Errorf("no remote specified and no active remote set. Use --remote or 'gordon remote use <name>'")
359+ }
360+
361+ remoteConfig, ok := config.Remotes[remoteName]
362+ if !ok {
363+ return "", remote.RemoteEntry{}, fmt.Errorf("remote '%s' not found", remoteName)
364+ }
365+
366+ return remoteName, remoteConfig, nil
367+}
368+
320369 // runShowInternalAuth displays the internal registry credentials.
321370 func runShowInternalAuth() error {
322371 creds, err := app.GetInternalCredentials()
@@ -207,12 +207,14 @@ func newAuthLoginCmd() *cobra.Command {
207 var (207 var (
208 remoteName string208 remoteName string
209 username string209 username string
210+ token string
211+ password string
210 )212 )
211 213
212 cmd := &cobra.Command{214 cmd := &cobra.Command{
213 Use: "login",215 Use: "login",
214 Short: "Authenticate with a Gordon server",216 Short: "Authenticate with a Gordon server",
215- Long: `Authenticate with a Gordon server using password authentication.217+ Long: `Authenticate with a Gordon server using password authentication or a pre-generated token.
216 218
217 This command prompts for your username and password, authenticates with the219 This command prompts for your username and password, authenticates with the
218 remote server's /auth/password endpoint, and stores the returned token.220 remote server's /auth/password endpoint, and stores the returned token.
@@ -220,43 +222,35 @@ remote server's /auth/password endpoint, and stores the returned token.
220 The token is stored in your remote configuration and used for subsequent222 The token is stored in your remote configuration and used for subsequent
221 CLI operations.223 CLI operations.
222 224
223-Note: This command only works with servers configured for password authentication.225+If --token is provided, the token is stored for the remote and verified
224-For servers using token-based auth, use 'gordon remote set-token' instead.226+against /admin/status on a best-effort basis.
225 227
226 Examples:228 Examples:
227- gordon auth login Login using active remote229+ gordon auth login Login using active remote
228- gordon auth login --remote prod Login to specific remote230+ gordon auth login --remote prod Login to specific remote
229- gordon auth login --username admin Pre-fill username`,231+ gordon auth login --username admin Pre-fill username
232+ gordon auth login --token <token> Store a token for token-only servers`,
230 RunE: func(cmd *cobra.Command, args []string) error {233 RunE: func(cmd *cobra.Command, args []string) error {
231- return runAuthLogin(remoteName, username)234+ if token != "" {
235+ return runAuthLoginWithToken(remoteName, token)
236+ }
237+ return runAuthLogin(remoteName, username, password)
232 },238 },
233 }239 }
234 240
235 cmd.Flags().StringVarP(&remoteName, "remote", "r", "", "Remote to authenticate with (defaults to active remote)")241 cmd.Flags().StringVarP(&remoteName, "remote", "r", "", "Remote to authenticate with (defaults to active remote)")
236 cmd.Flags().StringVarP(&username, "username", "u", "", "Username for authentication")242 cmd.Flags().StringVarP(&username, "username", "u", "", "Username for authentication")
243+ cmd.Flags().StringVarP(&password, "password", "p", "", "Password for authentication")
244+ cmd.Flags().StringVarP(&token, "token", "t", "", "Authentication token to store for the remote")
237 245
238 return cmd246 return cmd
239 }247 }
240 248
241 // runAuthLogin authenticates with a remote Gordon server.249 // runAuthLogin authenticates with a remote Gordon server.
242-func runAuthLogin(remoteName, username string) error {250+func runAuthLogin(remoteName, username, password string) error {
243- // Load remotes config251+ resolvedName, remoteConfig, err := resolveRemoteEntry(remoteName)
244- config, err := remote.LoadRemotes("")
245 if err != nil {252 if err != nil {
246- return fmt.Errorf("failed to load remotes: %w", err)253+ return err
247- }
248-
249- // Determine which remote to use
250- if remoteName == "" {
251- remoteName = config.Active
252- }
253- if remoteName == "" {
254- return fmt.Errorf("no remote specified and no active remote set. Use --remote or 'gordon remote use <name>'")
255- }
256-
257- remoteConfig, ok := config.Remotes[remoteName]
258- if !ok {
259- return fmt.Errorf("remote '%s' not found", remoteName)
260 }254 }
261 255
262 // Prompt for username if not provided256 // Prompt for username if not provided
@@ -274,21 +268,25 @@ func runAuthLogin(remoteName, username string) error {
274 return fmt.Errorf("username cannot be empty")268 return fmt.Errorf("username cannot be empty")
275 }269 }
276 270
277- // Prompt for password (hidden input)271+ password = strings.TrimSpace(password)
278- fmt.Print("Password: ")272+ if password == "" {
279- passwordBytes, err := term.ReadPassword(int(os.Stdin.Fd()))273+ // Prompt for password (hidden input)
280- if err != nil {274+ fmt.Print("Password: ")
281- // Fallback for non-terminal input275+ passwordBytes, err := term.ReadPassword(int(os.Stdin.Fd()))
282- reader := bufio.NewReader(os.Stdin)276+ if err != nil {
283- password, readErr := reader.ReadString('\n')277+ // Fallback for non-terminal input
284- if readErr != nil {278+ reader := bufio.NewReader(os.Stdin)
285- return fmt.Errorf("failed to read password: %w", readErr)279+ passwordInput, readErr := reader.ReadString('\n')
280+ if readErr != nil {
281+ return fmt.Errorf("failed to read password: %w", readErr)
282+ }
283+ passwordBytes = []byte(strings.TrimSpace(passwordInput))
286 }284 }
287- passwordBytes = []byte(strings.TrimSpace(password))285+ fmt.Println()
286+ password = strings.TrimSpace(string(passwordBytes))
288 }287 }
289- fmt.Println() // newline after hidden password input
290 288
291- if len(passwordBytes) == 0 {289+ if password == "" {
292 return fmt.Errorf("password cannot be empty")290 return fmt.Errorf("password cannot be empty")
293 }291 }
294 292
@@ -297,26 +295,77 @@ func runAuthLogin(remoteName, username string) error {
297 295
298 // Authenticate296 // Authenticate
299 ctx := context.Background()297 ctx := context.Background()
300- fmt.Printf("Authenticating with %s...\n", remoteName)298+ fmt.Printf("Authenticating with %s...\n", resolvedName)
301 299
302- resp, err := client.Authenticate(ctx, username, string(passwordBytes))300+ resp, err := client.Authenticate(ctx, username, password)
303 if err != nil {301 if err != nil {
304 return fmt.Errorf("authentication failed: %w", err)302 return fmt.Errorf("authentication failed: %w", err)
305 }303 }
306 304
307 // Update remote config with new token305 // Update remote config with new token
308- if err := remote.UpdateRemoteToken(remoteName, resp.Token); err != nil {306+ if err := remote.UpdateRemoteToken(resolvedName, resp.Token); err != nil {
309 return fmt.Errorf("failed to save token: %w", err)307 return fmt.Errorf("failed to save token: %w", err)
310 }308 }
311 309
312 fmt.Println()310 fmt.Println()
313 fmt.Println(styles.RenderSuccess("Authentication successful!"))311 fmt.Println(styles.RenderSuccess("Authentication successful!"))
314- fmt.Printf("Token stored for remote '%s'\n", remoteName)312+ fmt.Printf("Token stored for remote '%s'\n", resolvedName)
315 fmt.Printf("Expires in: %d seconds\n", resp.ExpiresIn)313 fmt.Printf("Expires in: %d seconds\n", resp.ExpiresIn)
316 314
317 return nil315 return nil
318 }316 }
319 317
318+func runAuthLoginWithToken(remoteName, token string) error {
319+ token = strings.TrimSpace(token)
320+ if token == "" {
321+ return fmt.Errorf("token cannot be empty")
322+ }
323+
324+ resolvedName, remoteConfig, err := resolveRemoteEntry(remoteName)
325+ if err != nil {
326+ return err
327+ }
328+
329+ if err := remote.UpdateRemoteToken(resolvedName, token); err != nil {
330+ return fmt.Errorf("failed to save token: %w", err)
331+ }
332+
333+ fmt.Println()
334+ fmt.Println(styles.RenderSuccess(fmt.Sprintf("Token stored for remote '%s'", resolvedName)))
335+
336+ client := remote.NewClient(remoteConfig.URL, remote.WithToken(token))
337+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
338+ defer cancel()
339+ if _, err := client.GetStatus(ctx); err != nil {
340+ fmt.Println(styles.RenderWarning(fmt.Sprintf("Token verification failed: %v", err)))
341+ return nil
342+ }
343+
344+ fmt.Println(styles.RenderSuccess("Token verified with remote status endpoint"))
345+ return nil
346+}
347+
348+func resolveRemoteEntry(remoteName string) (string, remote.RemoteEntry, error) {
349+ config, err := remote.LoadRemotes("")
350+ if err != nil {
351+ return "", remote.RemoteEntry{}, fmt.Errorf("failed to load remotes: %w", err)
352+ }
353+
354+ if remoteName == "" {
355+ remoteName = config.Active
356+ }
357+ if remoteName == "" {
358+ return "", remote.RemoteEntry{}, fmt.Errorf("no remote specified and no active remote set. Use --remote or 'gordon remote use <name>'")
359+ }
360+
361+ remoteConfig, ok := config.Remotes[remoteName]
362+ if !ok {
363+ return "", remote.RemoteEntry{}, fmt.Errorf("remote '%s' not found", remoteName)
364+ }
365+
366+ return remoteName, remoteConfig, nil
367+}
368+
320 // runShowInternalAuth displays the internal registry credentials.369 // runShowInternalAuth displays the internal registry credentials.
321 func runShowInternalAuth() error {370 func runShowInternalAuth() error {
322 creds, err := app.GetInternalCredentials()371 creds, err := app.GetInternalCredentials()
A internal/adapters/in/cli/auth_test.go +73 -0
@@ -0,0 +1,73 @@
1+package cli
2+
3+import (
4+ "net/http"
5+ "net/http/httptest"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+
10+ "github.com/bnema/gordon/internal/adapters/in/cli/remote"
11+)
12+
13+func TestRunAuthLoginWithToken_VerifiesAndStores(t *testing.T) {
14+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
15+
16+ var receivedAuth string
17+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
18+ if r.URL.Path != "/admin/status" {
19+ w.WriteHeader(http.StatusNotFound)
20+ return
21+ }
22+ receivedAuth = r.Header.Get("Authorization")
23+ w.Header().Set("Content-Type", "application/json")
24+ w.WriteHeader(http.StatusOK)
25+ _, _ = w.Write([]byte(`{"routes":0,"registry_domain":"","registry_port":0,"server_port":0,"auto_route":false,"network_isolation":false,"container_status":{}}`))
26+ }))
27+ defer server.Close()
28+
29+ config := &remote.ClientConfig{
30+ Active: "prod",
31+ Remotes: map[string]remote.RemoteEntry{
32+ "prod": {URL: server.URL},
33+ },
34+ }
35+ assert.NoError(t, remote.SaveRemotes(remote.DefaultRemotesPath(), config))
36+
37+ err := runAuthLoginWithToken("", "token123")
38+ assert.NoError(t, err)
39+
40+ loaded, err := remote.LoadRemotes("")
41+ assert.NoError(t, err)
42+ assert.Equal(t, "token123", loaded.Remotes["prod"].Token)
43+ assert.Equal(t, "Bearer token123", receivedAuth)
44+}
45+
46+func TestRunAuthLoginWithToken_StoresTokenOnVerificationFailure(t *testing.T) {
47+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
48+
49+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
50+ if r.URL.Path != "/admin/status" {
51+ w.WriteHeader(http.StatusNotFound)
52+ return
53+ }
54+ w.WriteHeader(http.StatusUnauthorized)
55+ _, _ = w.Write([]byte(`{"error":"unauthorized"}`))
56+ }))
57+ defer server.Close()
58+
59+ config := &remote.ClientConfig{
60+ Active: "prod",
61+ Remotes: map[string]remote.RemoteEntry{
62+ "prod": {URL: server.URL},
63+ },
64+ }
65+ assert.NoError(t, remote.SaveRemotes(remote.DefaultRemotesPath(), config))
66+
67+ err := runAuthLoginWithToken("", "token123")
68+ assert.NoError(t, err)
69+
70+ loaded, err := remote.LoadRemotes("")
71+ assert.NoError(t, err)
72+ assert.Equal(t, "token123", loaded.Remotes["prod"].Token)
73+}
@@ -0,0 +1,73 @@
1+package cli
2+
3+import (
4+ "net/http"
5+ "net/http/httptest"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+
10+ "github.com/bnema/gordon/internal/adapters/in/cli/remote"
11+)
12+
13+func TestRunAuthLoginWithToken_VerifiesAndStores(t *testing.T) {
14+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
15+
16+ var receivedAuth string
17+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
18+ if r.URL.Path != "/admin/status" {
19+ w.WriteHeader(http.StatusNotFound)
20+ return
21+ }
22+ receivedAuth = r.Header.Get("Authorization")
23+ w.Header().Set("Content-Type", "application/json")
24+ w.WriteHeader(http.StatusOK)
25+ _, _ = w.Write([]byte(`{"routes":0,"registry_domain":"","registry_port":0,"server_port":0,"auto_route":false,"network_isolation":false,"container_status":{}}`))
26+ }))
27+ defer server.Close()
28+
29+ config := &remote.ClientConfig{
30+ Active: "prod",
31+ Remotes: map[string]remote.RemoteEntry{
32+ "prod": {URL: server.URL},
33+ },
34+ }
35+ assert.NoError(t, remote.SaveRemotes(remote.DefaultRemotesPath(), config))
36+
37+ err := runAuthLoginWithToken("", "token123")
38+ assert.NoError(t, err)
39+
40+ loaded, err := remote.LoadRemotes("")
41+ assert.NoError(t, err)
42+ assert.Equal(t, "token123", loaded.Remotes["prod"].Token)
43+ assert.Equal(t, "Bearer token123", receivedAuth)
44+}
45+
46+func TestRunAuthLoginWithToken_StoresTokenOnVerificationFailure(t *testing.T) {
47+ t.Setenv("XDG_CONFIG_HOME", t.TempDir())
48+
49+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
50+ if r.URL.Path != "/admin/status" {
51+ w.WriteHeader(http.StatusNotFound)
52+ return
53+ }
54+ w.WriteHeader(http.StatusUnauthorized)
55+ _, _ = w.Write([]byte(`{"error":"unauthorized"}`))
56+ }))
57+ defer server.Close()
58+
59+ config := &remote.ClientConfig{
60+ Active: "prod",
61+ Remotes: map[string]remote.RemoteEntry{
62+ "prod": {URL: server.URL},
63+ },
64+ }
65+ assert.NoError(t, remote.SaveRemotes(remote.DefaultRemotesPath(), config))
66+
67+ err := runAuthLoginWithToken("", "token123")
68+ assert.NoError(t, err)
69+
70+ loaded, err := remote.LoadRemotes("")
71+ assert.NoError(t, err)
72+ assert.Equal(t, "token123", loaded.Remotes["prod"].Token)
73+}
M internal/app/run.go +19 -6
@@ -317,7 +317,9 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
317317
318318 secretSvc := secretsSvc.NewService(domainSecretStore, log)
319319
320- svc.containerSvc = createContainerService(v, cfg, svc)
320+ if svc.containerSvc, err = createContainerService(ctx, v, cfg, svc, log); err != nil {
321+ return nil, err
322+ }
321323 svc.registrySvc = registrySvc.NewService(svc.blobStorage, svc.manifestStorage, svc.eventBus)
322324 svc.proxySvc = proxy.NewService(svc.runtime, svc.containerSvc, svc.configSvc, proxy.Config{
323325 RegistryDomain: cfg.Server.RegistryDomain,
@@ -461,7 +463,10 @@ func createLogWriter(cfg Config, log zerowrap.Logger) (*logwriter.LogWriter, err
461463 return writer, nil
462464 }
463465
464-const internalRegistryUsername = "gordon-internal"
466+const (
467+ internalRegistryUsername = "gordon-internal"
468+ serviceTokenSubject = "gordon-service"
469+)
465470
466471 func generateInternalRegistryAuth() (string, string, error) {
467472 password, err := randomTokenHex(32)
@@ -790,13 +795,11 @@ func loadSecret(ctx context.Context, backend domain.SecretsBackend, path, dataDi
790795 }
791796
792797 // createContainerService creates the container service with configuration.
793-func createContainerService(v *viper.Viper, cfg Config, svc *services) *container.Service {
798+func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc *services, log zerowrap.Logger) (*container.Service, error) {
794799 containerConfig := container.Config{
795800 RegistryAuthEnabled: cfg.Auth.Enabled,
796801 RegistryDomain: cfg.Server.RegistryDomain,
797802 RegistryPort: cfg.Server.RegistryPort,
798- RegistryUsername: cfg.Auth.Username,
799- RegistryPassword: cfg.Auth.Password,
800803 InternalRegistryUsername: svc.internalRegUser,
801804 InternalRegistryPassword: svc.internalRegPass,
802805 PullPolicy: v.GetString("deploy.pull_policy"),
@@ -809,7 +812,17 @@ func createContainerService(v *viper.Viper, cfg Config, svc *services) *containe
809812 NetworkGroups: svc.configSvc.GetNetworkGroups(),
810813 Attachments: svc.configSvc.GetAttachments(),
811814 }
812- return container.NewService(svc.runtime, svc.envLoader, svc.eventBus, svc.logWriter, containerConfig)
815+
816+ if cfg.Auth.Enabled && svc.authSvc != nil {
817+ serviceToken, err := svc.authSvc.GenerateToken(ctx, serviceTokenSubject, []string{"pull"}, 0)
818+ if err != nil {
819+ return nil, log.WrapErr(err, "failed to generate registry service token")
820+ }
821+ containerConfig.ServiceTokenUsername = serviceTokenSubject
822+ containerConfig.ServiceToken = serviceToken
823+ }
824+
825+ return container.NewService(svc.runtime, svc.envLoader, svc.eventBus, svc.logWriter, containerConfig), nil
813826 }
814827
815828 // registerEventHandlers registers all event handlers.
@@ -317,7 +317,9 @@ func createServices(ctx context.Context, v *viper.Viper, cfg Config, log zerowra
317 317
318 secretSvc := secretsSvc.NewService(domainSecretStore, log)318 secretSvc := secretsSvc.NewService(domainSecretStore, log)
319 319
320- svc.containerSvc = createContainerService(v, cfg, svc)320+ if svc.containerSvc, err = createContainerService(ctx, v, cfg, svc, log); err != nil {
321+ return nil, err
322+ }
321 svc.registrySvc = registrySvc.NewService(svc.blobStorage, svc.manifestStorage, svc.eventBus)323 svc.registrySvc = registrySvc.NewService(svc.blobStorage, svc.manifestStorage, svc.eventBus)
322 svc.proxySvc = proxy.NewService(svc.runtime, svc.containerSvc, svc.configSvc, proxy.Config{324 svc.proxySvc = proxy.NewService(svc.runtime, svc.containerSvc, svc.configSvc, proxy.Config{
323 RegistryDomain: cfg.Server.RegistryDomain,325 RegistryDomain: cfg.Server.RegistryDomain,
@@ -461,7 +463,10 @@ func createLogWriter(cfg Config, log zerowrap.Logger) (*logwriter.LogWriter, err
461 return writer, nil463 return writer, nil
462 }464 }
463 465
464-const internalRegistryUsername = "gordon-internal"466+const (
467+ internalRegistryUsername = "gordon-internal"
468+ serviceTokenSubject = "gordon-service"
469+)
465 470
466 func generateInternalRegistryAuth() (string, string, error) {471 func generateInternalRegistryAuth() (string, string, error) {
467 password, err := randomTokenHex(32)472 password, err := randomTokenHex(32)
@@ -790,13 +795,11 @@ func loadSecret(ctx context.Context, backend domain.SecretsBackend, path, dataDi
790 }795 }
791 796
792 // createContainerService creates the container service with configuration.797 // createContainerService creates the container service with configuration.
793-func createContainerService(v *viper.Viper, cfg Config, svc *services) *container.Service {798+func createContainerService(ctx context.Context, v *viper.Viper, cfg Config, svc *services, log zerowrap.Logger) (*container.Service, error) {
794 containerConfig := container.Config{799 containerConfig := container.Config{
795 RegistryAuthEnabled: cfg.Auth.Enabled,800 RegistryAuthEnabled: cfg.Auth.Enabled,
796 RegistryDomain: cfg.Server.RegistryDomain,801 RegistryDomain: cfg.Server.RegistryDomain,
797 RegistryPort: cfg.Server.RegistryPort,802 RegistryPort: cfg.Server.RegistryPort,
798- RegistryUsername: cfg.Auth.Username,
799- RegistryPassword: cfg.Auth.Password,
800 InternalRegistryUsername: svc.internalRegUser,803 InternalRegistryUsername: svc.internalRegUser,
801 InternalRegistryPassword: svc.internalRegPass,804 InternalRegistryPassword: svc.internalRegPass,
802 PullPolicy: v.GetString("deploy.pull_policy"),805 PullPolicy: v.GetString("deploy.pull_policy"),
@@ -809,7 +812,17 @@ func createContainerService(v *viper.Viper, cfg Config, svc *services) *containe
809 NetworkGroups: svc.configSvc.GetNetworkGroups(),812 NetworkGroups: svc.configSvc.GetNetworkGroups(),
810 Attachments: svc.configSvc.GetAttachments(),813 Attachments: svc.configSvc.GetAttachments(),
811 }814 }
812- return container.NewService(svc.runtime, svc.envLoader, svc.eventBus, svc.logWriter, containerConfig)815+
816+ if cfg.Auth.Enabled && svc.authSvc != nil {
817+ serviceToken, err := svc.authSvc.GenerateToken(ctx, serviceTokenSubject, []string{"pull"}, 0)
818+ if err != nil {
819+ return nil, log.WrapErr(err, "failed to generate registry service token")
820+ }
821+ containerConfig.ServiceTokenUsername = serviceTokenSubject
822+ containerConfig.ServiceToken = serviceToken
823+ }
824+
825+ return container.NewService(svc.runtime, svc.envLoader, svc.eventBus, svc.logWriter, containerConfig), nil
813 }826 }
814 827
815 // registerEventHandlers registers all event handlers.828 // registerEventHandlers registers all event handlers.
M internal/usecase/container/service.go +6 -3
@@ -21,8 +21,8 @@ type Config struct {
2121 RegistryAuthEnabled bool
2222 RegistryDomain string
2323 RegistryPort int
24- RegistryUsername string
25- RegistryPassword string
24+ ServiceTokenUsername string
25+ ServiceToken string
2626 InternalRegistryUsername string
2727 InternalRegistryPassword string
2828 PullPolicy string
@@ -754,7 +754,10 @@ func (s *Service) pullImage(ctx context.Context, pullRef string, isInternal bool
754754 return log.WrapErr(err, "failed to pull image")
755755 }
756756 case s.config.RegistryAuthEnabled:
757- if err := s.runtime.PullImageWithAuth(ctx, pullRef, s.config.RegistryUsername, s.config.RegistryPassword); err != nil {
757+ if s.config.ServiceTokenUsername == "" || s.config.ServiceToken == "" {
758+ return log.WrapErr(fmt.Errorf("registry service token not configured"), "failed to pull image for registry auth")
759+ }
760+ if err := s.runtime.PullImageWithAuth(ctx, pullRef, s.config.ServiceTokenUsername, s.config.ServiceToken); err != nil {
758761 return log.WrapErr(err, "failed to pull image with auth")
759762 }
760763 default:
@@ -21,8 +21,8 @@ type Config struct {
21 RegistryAuthEnabled bool21 RegistryAuthEnabled bool
22 RegistryDomain string22 RegistryDomain string
23 RegistryPort int23 RegistryPort int
24- RegistryUsername string24+ ServiceTokenUsername string
25- RegistryPassword string25+ ServiceToken string
26 InternalRegistryUsername string26 InternalRegistryUsername string
27 InternalRegistryPassword string27 InternalRegistryPassword string
28 PullPolicy string28 PullPolicy string
@@ -754,7 +754,10 @@ func (s *Service) pullImage(ctx context.Context, pullRef string, isInternal bool
754 return log.WrapErr(err, "failed to pull image")754 return log.WrapErr(err, "failed to pull image")
755 }755 }
756 case s.config.RegistryAuthEnabled:756 case s.config.RegistryAuthEnabled:
757- if err := s.runtime.PullImageWithAuth(ctx, pullRef, s.config.RegistryUsername, s.config.RegistryPassword); err != nil {757+ if s.config.ServiceTokenUsername == "" || s.config.ServiceToken == "" {
758+ return log.WrapErr(fmt.Errorf("registry service token not configured"), "failed to pull image for registry auth")
759+ }
760+ if err := s.runtime.PullImageWithAuth(ctx, pullRef, s.config.ServiceTokenUsername, s.config.ServiceToken); err != nil {
758 return log.WrapErr(err, "failed to pull image with auth")761 return log.WrapErr(err, "failed to pull image with auth")
759 }762 }
760 default:763 default:
M internal/usecase/container/service_test.go +35 -0
@@ -119,6 +119,41 @@ func TestService_Deploy_ImagePullFailure(t *testing.T) {
119119 assert.Contains(t, err.Error(), "failed to pull image")
120120 }
121121
122+func TestService_PullImage_UsesServiceTokenForExternalPull(t *testing.T) {
123+ runtime := mocks.NewMockContainerRuntime(t)
124+
125+ config := Config{
126+ RegistryAuthEnabled: true,
127+ ServiceTokenUsername: "gordon-service",
128+ ServiceToken: "service-token",
129+ }
130+
131+ svc := NewService(runtime, nil, nil, nil, config)
132+ ctx := testContext()
133+
134+ runtime.EXPECT().PullImageWithAuth(mock.Anything, "registry.example.com/myapp:latest", "gordon-service", "service-token").Return(nil)
135+
136+ err := svc.pullImage(ctx, "registry.example.com/myapp:latest", false)
137+
138+ assert.NoError(t, err)
139+}
140+
141+func TestService_PullImage_RequiresServiceTokenForExternalPull(t *testing.T) {
142+ runtime := mocks.NewMockContainerRuntime(t)
143+
144+ config := Config{
145+ RegistryAuthEnabled: true,
146+ }
147+
148+ svc := NewService(runtime, nil, nil, nil, config)
149+ ctx := testContext()
150+
151+ err := svc.pullImage(ctx, "registry.example.com/myapp:latest", false)
152+
153+ assert.Error(t, err)
154+ assert.Contains(t, err.Error(), "registry service token not configured")
155+}
156+
122157 func TestService_Deploy_ReplacesExistingContainer(t *testing.T) {
123158 runtime := mocks.NewMockContainerRuntime(t)
124159 envLoader := mocks.NewMockEnvLoader(t)
@@ -119,6 +119,41 @@ func TestService_Deploy_ImagePullFailure(t *testing.T) {
119 assert.Contains(t, err.Error(), "failed to pull image")119 assert.Contains(t, err.Error(), "failed to pull image")
120 }120 }
121 121
122+func TestService_PullImage_UsesServiceTokenForExternalPull(t *testing.T) {
123+ runtime := mocks.NewMockContainerRuntime(t)
124+
125+ config := Config{
126+ RegistryAuthEnabled: true,
127+ ServiceTokenUsername: "gordon-service",
128+ ServiceToken: "service-token",
129+ }
130+
131+ svc := NewService(runtime, nil, nil, nil, config)
132+ ctx := testContext()
133+
134+ runtime.EXPECT().PullImageWithAuth(mock.Anything, "registry.example.com/myapp:latest", "gordon-service", "service-token").Return(nil)
135+
136+ err := svc.pullImage(ctx, "registry.example.com/myapp:latest", false)
137+
138+ assert.NoError(t, err)
139+}
140+
141+func TestService_PullImage_RequiresServiceTokenForExternalPull(t *testing.T) {
142+ runtime := mocks.NewMockContainerRuntime(t)
143+
144+ config := Config{
145+ RegistryAuthEnabled: true,
146+ }
147+
148+ svc := NewService(runtime, nil, nil, nil, config)
149+ ctx := testContext()
150+
151+ err := svc.pullImage(ctx, "registry.example.com/myapp:latest", false)
152+
153+ assert.Error(t, err)
154+ assert.Contains(t, err.Error(), "registry service token not configured")
155+}
156+
122 func TestService_Deploy_ReplacesExistingContainer(t *testing.T) {157 func TestService_Deploy_ReplacesExistingContainer(t *testing.T) {
123 runtime := mocks.NewMockContainerRuntime(t)158 runtime := mocks.NewMockContainerRuntime(t)
124 envLoader := mocks.NewMockEnvLoader(t)159 envLoader := mocks.NewMockEnvLoader(t)

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