feat(auth): add token login and registry pull token
- commit
- 5498bc4df94baae96ac2bd967a5effe780de0e01
- author
- bnema · · 2026-01-30 20:52 UTC
- parent
- 6ee1d33
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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. | |||
| 19 | 19 | ||
| 20 | 20 | ## 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 | 24 | ```bash | |
| 25 | 25 | gordon auth login [options] | |
| @@ -31,14 +31,18 @@ gordon auth login [options] | |||
| 31 | 31 | |--------|-------------| | |
| 32 | 32 | | `-r, --remote` | Remote to authenticate with (defaults to active remote) | | |
| 33 | 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 | 37 | ### Description | |
| 36 | 38 | ||
| 37 | 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 | 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 | 47 | ### Examples | |
| 44 | 48 | ||
| @@ -51,6 +55,9 @@ gordon auth login --remote prod | |||
| 51 | 55 | ||
| 52 | 56 | # Pre-fill username | |
| 53 | 57 | gordon auth login --username admin | |
| 58 | + | ||
| 59 | + | # Store a pre-generated token | |
| 60 | + | gordon auth login --token <token> | |
| 54 | 61 | ``` | |
| 55 | 62 | ||
| 56 | 63 | ### Output | |
| @@ -212,7 +219,9 @@ Display the auto-generated internal registry credentials. | |||
| 212 | 219 | 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 | 226 | ### Output | |
| 218 | 227 | ||
| @@ -231,6 +240,7 @@ Usage: | |||
| 231 | 240 | - Credentials are regenerated each time Gordon starts | |
| 232 | 241 | - Only available while Gordon is running | |
| 233 | 242 | - 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 | 245 | ### Use Cases | |
| 236 | 246 | ||
| @@ -19,7 +19,7 @@ Manage Gordon server authentication tokens and passwords. | |||||
| 19 | 19 | ||||
| 20 | ## gordon auth login | 20 | ## 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 | ```bash | 24 | ```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 | ### Description | 37 | ### 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 | ### Examples | 47 | ### Examples | ||
| 44 | 48 | ||||
| @@ -51,6 +55,9 @@ gordon auth login --remote prod | |||||
| 51 | 55 | ||||
| 52 | # Pre-fill username | 56 | # Pre-fill username | ||
| 53 | gordon auth login --username admin | 57 | gordon auth login --username admin | ||
| 58 | + | ||||
| 59 | + | # Store a pre-generated token | |||
| 60 | + | gordon auth login --token <token> | |||
| 54 | ``` | 61 | ``` | ||
| 55 | 62 | ||||
| 56 | ### Output | 63 | ### Output | ||
| @@ -212,7 +219,9 @@ Display the auto-generated internal registry credentials. | |||||
| 212 | gordon auth internal | 219 | 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 | ### Output | 226 | ### Output | ||
| 218 | 227 | ||||
| @@ -231,6 +240,7 @@ Usage: | |||||
| 231 | - Credentials are regenerated each time Gordon starts | 240 | - Credentials are regenerated each time Gordon starts | ||
| 232 | - Only available while Gordon is running | 241 | - Only available while Gordon is running | ||
| 233 | - Used for manual `docker pull` from the local registry during debugging | 242 | - 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 Cases | 245 | ### Use Cases | ||
| 236 | 246 | ||||
M docs/config/auth.md +2 -0
| @@ -313,6 +313,8 @@ token_secret = "gordon/auth/token_secret" | |||
| 313 | 313 | ||
| 314 | 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 | 318 | To view internal credentials (for troubleshooting): | |
| 317 | 319 | ```bash | |
| 318 | 320 | 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 | ```bash | 319 | ```bash | ||
| 318 | gordon auth internal | 320 | gordon auth internal | ||
M internal/adapters/in/cli/auth.go +89 -40
| @@ -207,12 +207,14 @@ func newAuthLoginCmd() *cobra.Command { | |||
| 207 | 207 | var ( | |
| 208 | 208 | remoteName string | |
| 209 | 209 | username string | |
| 210 | + | token string | |
| 211 | + | password string | |
| 210 | 212 | ) | |
| 211 | 213 | ||
| 212 | 214 | cmd := &cobra.Command{ | |
| 213 | 215 | Use: "login", | |
| 214 | 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 | 219 | This command prompts for your username and password, authenticates with the | |
| 218 | 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 | 222 | The token is stored in your remote configuration and used for subsequent | |
| 221 | 223 | CLI operations. | |
| 222 | 224 | ||
| 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. | |
| 225 | 227 | ||
| 226 | 228 | 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`, | |
| 230 | 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 | 241 | cmd.Flags().StringVarP(&remoteName, "remote", "r", "", "Remote to authenticate with (defaults to active remote)") | |
| 236 | 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 | 246 | return cmd | |
| 239 | 247 | } | |
| 240 | 248 | ||
| 241 | 249 | // 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) | |
| 245 | 252 | 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 | |
| 260 | 254 | } | |
| 261 | 255 | ||
| 262 | 256 | // Prompt for username if not provided | |
| @@ -274,21 +268,25 @@ func runAuthLogin(remoteName, username string) error { | |||
| 274 | 268 | return fmt.Errorf("username cannot be empty") | |
| 275 | 269 | } | |
| 276 | 270 | ||
| 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)) | |
| 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 | 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 | 296 | // Authenticate | |
| 299 | 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 | 301 | if err != nil { | |
| 304 | 302 | return fmt.Errorf("authentication failed: %w", err) | |
| 305 | 303 | } | |
| 306 | 304 | ||
| 307 | 305 | // 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 | 307 | return fmt.Errorf("failed to save token: %w", err) | |
| 310 | 308 | } | |
| 311 | 309 | ||
| 312 | 310 | fmt.Println() | |
| 313 | 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 | 313 | fmt.Printf("Expires in: %d seconds\n", resp.ExpiresIn) | |
| 316 | 314 | ||
| 317 | 315 | 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 | 369 | // runShowInternalAuth displays the internal registry credentials. | |
| 321 | 370 | func runShowInternalAuth() error { | |
| 322 | 371 | creds, err := app.GetInternalCredentials() | |
| @@ -207,12 +207,14 @@ func newAuthLoginCmd() *cobra.Command { | |||||
| 207 | var ( | 207 | var ( | ||
| 208 | remoteName string | 208 | remoteName string | ||
| 209 | username string | 209 | 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 the | 219 | 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 subsequent | 222 | 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 remote | 229 | + | gordon auth login Login using active remote |
| 228 | - | gordon auth login --remote prod Login to specific remote | 230 | + | 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 cmd | 246 | 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 config | 251 | + | 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 provided | 256 | // 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 input | 275 | + | 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 | // Authenticate | 296 | // 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 token | 305 | // 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 nil | 315 | 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 | |||
| 317 | 317 | ||
| 318 | 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 | 323 | svc.registrySvc = registrySvc.NewService(svc.blobStorage, svc.manifestStorage, svc.eventBus) | |
| 322 | 324 | svc.proxySvc = proxy.NewService(svc.runtime, svc.containerSvc, svc.configSvc, proxy.Config{ | |
| 323 | 325 | RegistryDomain: cfg.Server.RegistryDomain, | |
| @@ -461,7 +463,10 @@ func createLogWriter(cfg Config, log zerowrap.Logger) (*logwriter.LogWriter, err | |||
| 461 | 463 | 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 | 471 | func generateInternalRegistryAuth() (string, string, error) { | |
| 467 | 472 | password, err := randomTokenHex(32) | |
| @@ -790,13 +795,11 @@ func loadSecret(ctx context.Context, backend domain.SecretsBackend, path, dataDi | |||
| 790 | 795 | } | |
| 791 | 796 | ||
| 792 | 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 | 799 | containerConfig := container.Config{ | |
| 795 | 800 | RegistryAuthEnabled: cfg.Auth.Enabled, | |
| 796 | 801 | RegistryDomain: cfg.Server.RegistryDomain, | |
| 797 | 802 | RegistryPort: cfg.Server.RegistryPort, | |
| 798 | - | RegistryUsername: cfg.Auth.Username, | |
| 799 | - | RegistryPassword: cfg.Auth.Password, | |
| 800 | 803 | InternalRegistryUsername: svc.internalRegUser, | |
| 801 | 804 | InternalRegistryPassword: svc.internalRegPass, | |
| 802 | 805 | PullPolicy: v.GetString("deploy.pull_policy"), | |
| @@ -809,7 +812,17 @@ func createContainerService(v *viper.Viper, cfg Config, svc *services) *containe | |||
| 809 | 812 | NetworkGroups: svc.configSvc.GetNetworkGroups(), | |
| 810 | 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 | 828 | // 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, nil | 463 | 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 { | |||
| 21 | 21 | RegistryAuthEnabled bool | |
| 22 | 22 | RegistryDomain string | |
| 23 | 23 | RegistryPort int | |
| 24 | - | RegistryUsername string | |
| 25 | - | RegistryPassword string | |
| 24 | + | ServiceTokenUsername string | |
| 25 | + | ServiceToken string | |
| 26 | 26 | InternalRegistryUsername string | |
| 27 | 27 | InternalRegistryPassword string | |
| 28 | 28 | PullPolicy string | |
| @@ -754,7 +754,10 @@ func (s *Service) pullImage(ctx context.Context, pullRef string, isInternal bool | |||
| 754 | 754 | return log.WrapErr(err, "failed to pull image") | |
| 755 | 755 | } | |
| 756 | 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 | 761 | return log.WrapErr(err, "failed to pull image with auth") | |
| 759 | 762 | } | |
| 760 | 763 | default: | |
| @@ -21,8 +21,8 @@ type Config struct { | |||||
| 21 | RegistryAuthEnabled bool | 21 | RegistryAuthEnabled bool | ||
| 22 | RegistryDomain string | 22 | RegistryDomain string | ||
| 23 | RegistryPort int | 23 | RegistryPort int | ||
| 24 | - | RegistryUsername string | 24 | + | ServiceTokenUsername string |
| 25 | - | RegistryPassword string | 25 | + | ServiceToken string |
| 26 | InternalRegistryUsername string | 26 | InternalRegistryUsername string | ||
| 27 | InternalRegistryPassword string | 27 | InternalRegistryPassword string | ||
| 28 | PullPolicy string | 28 | 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) { | |||
| 119 | 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 | 157 | func TestService_Deploy_ReplacesExistingContainer(t *testing.T) { | |
| 123 | 158 | runtime := mocks.NewMockContainerRuntime(t) | |
| 124 | 159 | 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) | ||