chore(lint): reduce cyclomatic complexity
- commit
- b462a181615412501fd04218eb875e5851ec3186
- author
- bnema · · 2026-01-13 20:52 UTC
- parent
- 1a89de9
- signature
- unsigned
5 files · +237 -122
M internal/adapters/in/cli/auth.go +1 -1
| @@ -272,7 +272,7 @@ func runPasswordHash() error { | |||
| 272 | 272 | fmt.Print("Enter password: ") | |
| 273 | 273 | ||
| 274 | 274 | // Read password without echo | |
| 275 | - | passwordBytes, err := term.ReadPassword(int(syscall.Stdin)) | |
| 275 | + | passwordBytes, err := term.ReadPassword(syscall.Stdin) | |
| 276 | 276 | if err != nil { | |
| 277 | 277 | // Fallback for non-terminal input | |
| 278 | 278 | reader := bufio.NewReader(os.Stdin) | |
| @@ -272,7 +272,7 @@ func runPasswordHash() error { | |||||
| 272 | fmt.Print("Enter password: ") | 272 | fmt.Print("Enter password: ") | ||
| 273 | 273 | ||||
| 274 | // Read password without echo | 274 | // Read password without echo | ||
| 275 | - | passwordBytes, err := term.ReadPassword(int(syscall.Stdin)) | 275 | + | passwordBytes, err := term.ReadPassword(syscall.Stdin) |
| 276 | if err != nil { | 276 | if err != nil { | ||
| 277 | // Fallback for non-terminal input | 277 | // Fallback for non-terminal input | ||
| 278 | reader := bufio.NewReader(os.Stdin) | 278 | reader := bufio.NewReader(os.Stdin) | ||
M internal/adapters/out/tokenstore/pass.go +2 -0
| @@ -228,12 +228,14 @@ func (s *PassStore) DeleteToken(ctx context.Context, subject string) error { | |||
| 228 | 228 | metaPath := fmt.Sprintf("%s/%s.meta", passTokenPath, subject) | |
| 229 | 229 | ||
| 230 | 230 | // Remove JWT | |
| 231 | + | // #nosec G204 -- command and args are fixed; subject is treated as data only. | |
| 231 | 232 | cmd := exec.CommandContext(ctx, "pass", "rm", "-f", jwtPath) | |
| 232 | 233 | if err := cmd.Run(); err != nil { | |
| 233 | 234 | return fmt.Errorf("failed to delete token: %w", err) | |
| 234 | 235 | } | |
| 235 | 236 | ||
| 236 | 237 | // Remove metadata | |
| 238 | + | // #nosec G204 -- command and args are fixed; subject is treated as data only. | |
| 237 | 239 | cmd = exec.CommandContext(ctx, "pass", "rm", "-f", metaPath) | |
| 238 | 240 | if err := cmd.Run(); err != nil { | |
| 239 | 241 | s.log.Warn().Err(err).Msg("failed to delete token metadata") | |
| @@ -228,12 +228,14 @@ func (s *PassStore) DeleteToken(ctx context.Context, subject string) error { | |||||
| 228 | metaPath := fmt.Sprintf("%s/%s.meta", passTokenPath, subject) | 228 | metaPath := fmt.Sprintf("%s/%s.meta", passTokenPath, subject) | ||
| 229 | 229 | ||||
| 230 | // Remove JWT | 230 | // Remove JWT | ||
| 231 | + | // #nosec G204 -- command and args are fixed; subject is treated as data only. | |||
| 231 | cmd := exec.CommandContext(ctx, "pass", "rm", "-f", jwtPath) | 232 | cmd := exec.CommandContext(ctx, "pass", "rm", "-f", jwtPath) | ||
| 232 | if err := cmd.Run(); err != nil { | 233 | if err := cmd.Run(); err != nil { | ||
| 233 | return fmt.Errorf("failed to delete token: %w", err) | 234 | return fmt.Errorf("failed to delete token: %w", err) | ||
| 234 | } | 235 | } | ||
| 235 | 236 | ||||
| 236 | // Remove metadata | 237 | // Remove metadata | ||
| 238 | + | // #nosec G204 -- command and args are fixed; subject is treated as data only. | |||
| 237 | cmd = exec.CommandContext(ctx, "pass", "rm", "-f", metaPath) | 239 | cmd = exec.CommandContext(ctx, "pass", "rm", "-f", metaPath) | ||
| 238 | if err := cmd.Run(); err != nil { | 240 | if err := cmd.Run(); err != nil { | ||
| 239 | s.log.Warn().Err(err).Msg("failed to delete token metadata") | 241 | s.log.Warn().Err(err).Msg("failed to delete token metadata") | ||
M internal/app/run.go +118 -60
| @@ -395,92 +395,150 @@ func createAuthService(ctx context.Context, cfg Config, log zerowrap.Logger) (ou | |||
| 395 | 395 | return nil, nil, nil | |
| 396 | 396 | } | |
| 397 | 397 | ||
| 398 | - | // Determine auth type | |
| 399 | - | authType := domain.AuthTypePassword | |
| 400 | - | if cfg.RegistryAuth.Type == "token" { | |
| 401 | - | authType = domain.AuthTypeToken | |
| 398 | + | authType := resolveAuthType(cfg.RegistryAuth.Type) | |
| 399 | + | backend := resolveSecretsBackend(cfg.Secrets.Backend) | |
| 400 | + | dataDir := resolveDataDir(cfg.Server.DataDir) | |
| 401 | + | ||
| 402 | + | store, err := createTokenStore(authType, backend, dataDir, log) | |
| 403 | + | if err != nil { | |
| 404 | + | return nil, nil, err | |
| 405 | + | } | |
| 406 | + | ||
| 407 | + | authConfig, err := buildAuthConfig(ctx, cfg, authType, backend, dataDir, log) | |
| 408 | + | if err != nil { | |
| 409 | + | return nil, nil, err | |
| 410 | + | } | |
| 411 | + | ||
| 412 | + | authSvc := auth.NewService(authConfig, store, log) | |
| 413 | + | ||
| 414 | + | log.Info(). | |
| 415 | + | Str("type", string(authType)). | |
| 416 | + | Str("backend", string(backend)). | |
| 417 | + | Msg("registry authentication enabled") | |
| 418 | + | ||
| 419 | + | return store, authSvc, nil | |
| 420 | + | } | |
| 421 | + | ||
| 422 | + | func resolveAuthType(authType string) domain.AuthType { | |
| 423 | + | if authType == "token" { | |
| 424 | + | return domain.AuthTypeToken | |
| 402 | 425 | } | |
| 426 | + | return domain.AuthTypePassword | |
| 427 | + | } | |
| 403 | 428 | ||
| 404 | - | // Determine secrets backend | |
| 405 | - | backend := domain.SecretsBackendUnsafe // default | |
| 406 | - | switch cfg.Secrets.Backend { | |
| 429 | + | func resolveSecretsBackend(backend string) domain.SecretsBackend { | |
| 430 | + | switch backend { | |
| 407 | 431 | case "pass": | |
| 408 | - | backend = domain.SecretsBackendPass | |
| 432 | + | return domain.SecretsBackendPass | |
| 409 | 433 | case "sops": | |
| 410 | - | backend = domain.SecretsBackendSops | |
| 434 | + | return domain.SecretsBackendSops | |
| 411 | 435 | case "unsafe", "": | |
| 412 | - | backend = domain.SecretsBackendUnsafe | |
| 436 | + | return domain.SecretsBackendUnsafe | |
| 437 | + | default: | |
| 438 | + | return domain.SecretsBackendUnsafe | |
| 413 | 439 | } | |
| 440 | + | } | |
| 414 | 441 | ||
| 415 | - | // Get data directory | |
| 416 | - | dataDir := cfg.Server.DataDir | |
| 442 | + | func resolveDataDir(dataDir string) string { | |
| 417 | 443 | if dataDir == "" { | |
| 418 | - | dataDir = DefaultDataDir() | |
| 444 | + | return DefaultDataDir() | |
| 419 | 445 | } | |
| 446 | + | return dataDir | |
| 447 | + | } | |
| 420 | 448 | ||
| 421 | - | // Create token store (needed for token auth or to store revocations) | |
| 422 | - | var store out.TokenStore | |
| 423 | - | var err error | |
| 424 | - | if authType == domain.AuthTypeToken { | |
| 425 | - | store, err = tokenstore.NewStore(backend, dataDir, log) | |
| 426 | - | if err != nil { | |
| 427 | - | return nil, nil, log.WrapErr(err, "failed to create token store") | |
| 428 | - | } | |
| 449 | + | func createTokenStore(authType domain.AuthType, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (out.TokenStore, error) { | |
| 450 | + | if authType != domain.AuthTypeToken { | |
| 451 | + | return nil, nil | |
| 452 | + | } | |
| 453 | + | ||
| 454 | + | store, err := tokenstore.NewStore(backend, dataDir, log) | |
| 455 | + | if err != nil { | |
| 456 | + | return nil, log.WrapErr(err, "failed to create token store") | |
| 429 | 457 | } | |
| 458 | + | return store, nil | |
| 459 | + | } | |
| 430 | 460 | ||
| 431 | - | // Build auth config | |
| 461 | + | func buildAuthConfig(ctx context.Context, cfg Config, authType domain.AuthType, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (auth.Config, error) { | |
| 432 | 462 | authConfig := auth.Config{ | |
| 433 | 463 | Enabled: cfg.RegistryAuth.Enabled, | |
| 434 | 464 | AuthType: authType, | |
| 435 | 465 | Username: cfg.RegistryAuth.Username, | |
| 436 | 466 | } | |
| 437 | 467 | ||
| 438 | - | // Load secrets based on auth type | |
| 439 | - | if authType == domain.AuthTypePassword { | |
| 440 | - | // For password auth, we need the bcrypt hash | |
| 441 | - | if cfg.RegistryAuth.PasswordHash != "" { | |
| 442 | - | // Load from secrets backend | |
| 443 | - | hash, err := loadSecret(ctx, backend, cfg.RegistryAuth.PasswordHash, dataDir, log) | |
| 444 | - | if err != nil { | |
| 445 | - | return nil, nil, log.WrapErr(err, "failed to load password hash") | |
| 446 | - | } | |
| 447 | - | authConfig.PasswordHash = hash | |
| 448 | - | } else if cfg.RegistryAuth.Password != "" { | |
| 449 | - | // Backward compatibility: use plain password (will be hashed on validation) | |
| 450 | - | // This is less secure but maintains backward compatibility | |
| 451 | - | log.Warn().Msg("using plain password in config is deprecated, use password_hash with a secrets backend") | |
| 452 | - | authConfig.PasswordHash = cfg.RegistryAuth.Password | |
| 468 | + | switch authType { | |
| 469 | + | case domain.AuthTypePassword: | |
| 470 | + | hash, err := loadPasswordHash(ctx, cfg, backend, dataDir, log) | |
| 471 | + | if err != nil { | |
| 472 | + | return auth.Config{}, err | |
| 453 | 473 | } | |
| 454 | - | } else { | |
| 455 | - | // For token auth, we need the signing secret | |
| 456 | - | if cfg.RegistryAuth.TokenSecret != "" { | |
| 457 | - | secret, err := loadSecret(ctx, backend, cfg.RegistryAuth.TokenSecret, dataDir, log) | |
| 458 | - | if err != nil { | |
| 459 | - | return nil, nil, log.WrapErr(err, "failed to load token secret") | |
| 460 | - | } | |
| 461 | - | authConfig.TokenSecret = []byte(secret) | |
| 462 | - | } else { | |
| 463 | - | return nil, nil, fmt.Errorf("token_secret is required for token authentication") | |
| 474 | + | authConfig.PasswordHash = hash | |
| 475 | + | case domain.AuthTypeToken: | |
| 476 | + | secret, expiry, err := loadTokenConfig(ctx, cfg, backend, dataDir, log) | |
| 477 | + | if err != nil { | |
| 478 | + | return auth.Config{}, err | |
| 464 | 479 | } | |
| 480 | + | authConfig.TokenSecret = secret | |
| 481 | + | authConfig.TokenExpiry = expiry | |
| 482 | + | } | |
| 465 | 483 | ||
| 466 | - | // Parse token expiry | |
| 467 | - | if cfg.RegistryAuth.TokenExpiry != "" { | |
| 468 | - | expiry, err := time.ParseDuration(cfg.RegistryAuth.TokenExpiry) | |
| 469 | - | if err != nil { | |
| 470 | - | return nil, nil, fmt.Errorf("invalid token_expiry: %w", err) | |
| 471 | - | } | |
| 472 | - | authConfig.TokenExpiry = expiry | |
| 484 | + | return authConfig, nil | |
| 485 | + | } | |
| 486 | + | ||
| 487 | + | func loadPasswordHash(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (string, error) { | |
| 488 | + | if cfg.RegistryAuth.PasswordHash != "" { | |
| 489 | + | hash, err := loadSecret(ctx, backend, cfg.RegistryAuth.PasswordHash, dataDir, log) | |
| 490 | + | if err != nil { | |
| 491 | + | return "", log.WrapErr(err, "failed to load password hash") | |
| 473 | 492 | } | |
| 493 | + | return hash, nil | |
| 474 | 494 | } | |
| 475 | 495 | ||
| 476 | - | authSvc := auth.NewService(authConfig, store, log) | |
| 496 | + | if cfg.RegistryAuth.Password != "" { | |
| 497 | + | log.Warn().Msg("using plain password in config is deprecated, use password_hash with a secrets backend") | |
| 498 | + | return cfg.RegistryAuth.Password, nil | |
| 499 | + | } | |
| 477 | 500 | ||
| 478 | - | log.Info(). | |
| 479 | - | Str("type", string(authType)). | |
| 480 | - | Str("backend", string(backend)). | |
| 481 | - | Msg("registry authentication enabled") | |
| 501 | + | return "", nil | |
| 502 | + | } | |
| 482 | 503 | ||
| 483 | - | return store, authSvc, nil | |
| 504 | + | func loadTokenConfig(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) ([]byte, time.Duration, error) { | |
| 505 | + | secret, err := loadTokenSecret(ctx, cfg, backend, dataDir, log) | |
| 506 | + | if err != nil { | |
| 507 | + | return nil, 0, err | |
| 508 | + | } | |
| 509 | + | ||
| 510 | + | expiry, err := parseTokenExpiry(cfg.RegistryAuth.TokenExpiry) | |
| 511 | + | if err != nil { | |
| 512 | + | return nil, 0, err | |
| 513 | + | } | |
| 514 | + | ||
| 515 | + | return secret, expiry, nil | |
| 516 | + | } | |
| 517 | + | ||
| 518 | + | func loadTokenSecret(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) ([]byte, error) { | |
| 519 | + | if cfg.RegistryAuth.TokenSecret == "" { | |
| 520 | + | return nil, fmt.Errorf("token_secret is required for token authentication") | |
| 521 | + | } | |
| 522 | + | ||
| 523 | + | secret, err := loadSecret(ctx, backend, cfg.RegistryAuth.TokenSecret, dataDir, log) | |
| 524 | + | if err != nil { | |
| 525 | + | return nil, log.WrapErr(err, "failed to load token secret") | |
| 526 | + | } | |
| 527 | + | ||
| 528 | + | return []byte(secret), nil | |
| 529 | + | } | |
| 530 | + | ||
| 531 | + | func parseTokenExpiry(expiry string) (time.Duration, error) { | |
| 532 | + | if expiry == "" { | |
| 533 | + | return 0, nil | |
| 534 | + | } | |
| 535 | + | ||
| 536 | + | parsed, err := time.ParseDuration(expiry) | |
| 537 | + | if err != nil { | |
| 538 | + | return 0, fmt.Errorf("invalid token_expiry: %w", err) | |
| 539 | + | } | |
| 540 | + | ||
| 541 | + | return parsed, nil | |
| 484 | 542 | } | |
| 485 | 543 | ||
| 486 | 544 | // loadSecret loads a secret from the configured backend. | |
| @@ -395,92 +395,150 @@ func createAuthService(ctx context.Context, cfg Config, log zerowrap.Logger) (ou | |||||
| 395 | return nil, nil, nil | 395 | return nil, nil, nil | ||
| 396 | } | 396 | } | ||
| 397 | 397 | ||||
| 398 | - | // Determine auth type | 398 | + | authType := resolveAuthType(cfg.RegistryAuth.Type) |
| 399 | - | authType := domain.AuthTypePassword | 399 | + | backend := resolveSecretsBackend(cfg.Secrets.Backend) |
| 400 | - | if cfg.RegistryAuth.Type == "token" { | 400 | + | dataDir := resolveDataDir(cfg.Server.DataDir) |
| 401 | - | authType = domain.AuthTypeToken | 401 | + | |
| 402 | + | store, err := createTokenStore(authType, backend, dataDir, log) | |||
| 403 | + | if err != nil { | |||
| 404 | + | return nil, nil, err | |||
| 405 | + | } | |||
| 406 | + | ||||
| 407 | + | authConfig, err := buildAuthConfig(ctx, cfg, authType, backend, dataDir, log) | |||
| 408 | + | if err != nil { | |||
| 409 | + | return nil, nil, err | |||
| 410 | + | } | |||
| 411 | + | ||||
| 412 | + | authSvc := auth.NewService(authConfig, store, log) | |||
| 413 | + | ||||
| 414 | + | log.Info(). | |||
| 415 | + | Str("type", string(authType)). | |||
| 416 | + | Str("backend", string(backend)). | |||
| 417 | + | Msg("registry authentication enabled") | |||
| 418 | + | ||||
| 419 | + | return store, authSvc, nil | |||
| 420 | + | } | |||
| 421 | + | ||||
| 422 | + | func resolveAuthType(authType string) domain.AuthType { | |||
| 423 | + | if authType == "token" { | |||
| 424 | + | return domain.AuthTypeToken | |||
| 402 | } | 425 | } | ||
| 426 | + | return domain.AuthTypePassword | |||
| 427 | + | } | |||
| 403 | 428 | ||||
| 404 | - | // Determine secrets backend | 429 | + | func resolveSecretsBackend(backend string) domain.SecretsBackend { |
| 405 | - | backend := domain.SecretsBackendUnsafe // default | 430 | + | switch backend { |
| 406 | - | switch cfg.Secrets.Backend { | |||
| 407 | case "pass": | 431 | case "pass": | ||
| 408 | - | backend = domain.SecretsBackendPass | 432 | + | return domain.SecretsBackendPass |
| 409 | case "sops": | 433 | case "sops": | ||
| 410 | - | backend = domain.SecretsBackendSops | 434 | + | return domain.SecretsBackendSops |
| 411 | case "unsafe", "": | 435 | case "unsafe", "": | ||
| 412 | - | backend = domain.SecretsBackendUnsafe | 436 | + | return domain.SecretsBackendUnsafe |
| 437 | + | default: | |||
| 438 | + | return domain.SecretsBackendUnsafe | |||
| 413 | } | 439 | } | ||
| 440 | + | } | |||
| 414 | 441 | ||||
| 415 | - | // Get data directory | 442 | + | func resolveDataDir(dataDir string) string { |
| 416 | - | dataDir := cfg.Server.DataDir | |||
| 417 | if dataDir == "" { | 443 | if dataDir == "" { | ||
| 418 | - | dataDir = DefaultDataDir() | 444 | + | return DefaultDataDir() |
| 419 | } | 445 | } | ||
| 446 | + | return dataDir | |||
| 447 | + | } | |||
| 420 | 448 | ||||
| 421 | - | // Create token store (needed for token auth or to store revocations) | 449 | + | func createTokenStore(authType domain.AuthType, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (out.TokenStore, error) { |
| 422 | - | var store out.TokenStore | 450 | + | if authType != domain.AuthTypeToken { |
| 423 | - | var err error | 451 | + | return nil, nil |
| 424 | - | if authType == domain.AuthTypeToken { | 452 | + | } |
| 425 | - | store, err = tokenstore.NewStore(backend, dataDir, log) | 453 | + | |
| 426 | - | if err != nil { | 454 | + | store, err := tokenstore.NewStore(backend, dataDir, log) |
| 427 | - | return nil, nil, log.WrapErr(err, "failed to create token store") | 455 | + | if err != nil { |
| 428 | - | } | 456 | + | return nil, log.WrapErr(err, "failed to create token store") |
| 429 | } | 457 | } | ||
| 458 | + | return store, nil | |||
| 459 | + | } | |||
| 430 | 460 | ||||
| 431 | - | // Build auth config | 461 | + | func buildAuthConfig(ctx context.Context, cfg Config, authType domain.AuthType, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (auth.Config, error) { |
| 432 | authConfig := auth.Config{ | 462 | authConfig := auth.Config{ | ||
| 433 | Enabled: cfg.RegistryAuth.Enabled, | 463 | Enabled: cfg.RegistryAuth.Enabled, | ||
| 434 | AuthType: authType, | 464 | AuthType: authType, | ||
| 435 | Username: cfg.RegistryAuth.Username, | 465 | Username: cfg.RegistryAuth.Username, | ||
| 436 | } | 466 | } | ||
| 437 | 467 | ||||
| 438 | - | // Load secrets based on auth type | 468 | + | switch authType { |
| 439 | - | if authType == domain.AuthTypePassword { | 469 | + | case domain.AuthTypePassword: |
| 440 | - | // For password auth, we need the bcrypt hash | 470 | + | hash, err := loadPasswordHash(ctx, cfg, backend, dataDir, log) |
| 441 | - | if cfg.RegistryAuth.PasswordHash != "" { | 471 | + | if err != nil { |
| 442 | - | // Load from secrets backend | 472 | + | return auth.Config{}, err |
| 443 | - | hash, err := loadSecret(ctx, backend, cfg.RegistryAuth.PasswordHash, dataDir, log) | |||
| 444 | - | if err != nil { | |||
| 445 | - | return nil, nil, log.WrapErr(err, "failed to load password hash") | |||
| 446 | - | } | |||
| 447 | - | authConfig.PasswordHash = hash | |||
| 448 | - | } else if cfg.RegistryAuth.Password != "" { | |||
| 449 | - | // Backward compatibility: use plain password (will be hashed on validation) | |||
| 450 | - | // This is less secure but maintains backward compatibility | |||
| 451 | - | log.Warn().Msg("using plain password in config is deprecated, use password_hash with a secrets backend") | |||
| 452 | - | authConfig.PasswordHash = cfg.RegistryAuth.Password | |||
| 453 | } | 473 | } | ||
| 454 | - | } else { | 474 | + | authConfig.PasswordHash = hash |
| 455 | - | // For token auth, we need the signing secret | 475 | + | case domain.AuthTypeToken: |
| 456 | - | if cfg.RegistryAuth.TokenSecret != "" { | 476 | + | secret, expiry, err := loadTokenConfig(ctx, cfg, backend, dataDir, log) |
| 457 | - | secret, err := loadSecret(ctx, backend, cfg.RegistryAuth.TokenSecret, dataDir, log) | 477 | + | if err != nil { |
| 458 | - | if err != nil { | 478 | + | return auth.Config{}, err |
| 459 | - | return nil, nil, log.WrapErr(err, "failed to load token secret") | |||
| 460 | - | } | |||
| 461 | - | authConfig.TokenSecret = []byte(secret) | |||
| 462 | - | } else { | |||
| 463 | - | return nil, nil, fmt.Errorf("token_secret is required for token authentication") | |||
| 464 | } | 479 | } | ||
| 480 | + | authConfig.TokenSecret = secret | |||
| 481 | + | authConfig.TokenExpiry = expiry | |||
| 482 | + | } | |||
| 465 | 483 | ||||
| 466 | - | // Parse token expiry | 484 | + | return authConfig, nil |
| 467 | - | if cfg.RegistryAuth.TokenExpiry != "" { | 485 | + | } |
| 468 | - | expiry, err := time.ParseDuration(cfg.RegistryAuth.TokenExpiry) | 486 | + | |
| 469 | - | if err != nil { | 487 | + | func loadPasswordHash(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) (string, error) { |
| 470 | - | return nil, nil, fmt.Errorf("invalid token_expiry: %w", err) | 488 | + | if cfg.RegistryAuth.PasswordHash != "" { |
| 471 | - | } | 489 | + | hash, err := loadSecret(ctx, backend, cfg.RegistryAuth.PasswordHash, dataDir, log) |
| 472 | - | authConfig.TokenExpiry = expiry | 490 | + | if err != nil { |
| 491 | + | return "", log.WrapErr(err, "failed to load password hash") | |||
| 473 | } | 492 | } | ||
| 493 | + | return hash, nil | |||
| 474 | } | 494 | } | ||
| 475 | 495 | ||||
| 476 | - | authSvc := auth.NewService(authConfig, store, log) | 496 | + | if cfg.RegistryAuth.Password != "" { |
| 497 | + | log.Warn().Msg("using plain password in config is deprecated, use password_hash with a secrets backend") | |||
| 498 | + | return cfg.RegistryAuth.Password, nil | |||
| 499 | + | } | |||
| 477 | 500 | ||||
| 478 | - | log.Info(). | 501 | + | return "", nil |
| 479 | - | Str("type", string(authType)). | 502 | + | } |
| 480 | - | Str("backend", string(backend)). | |||
| 481 | - | Msg("registry authentication enabled") | |||
| 482 | 503 | ||||
| 483 | - | return store, authSvc, nil | 504 | + | func loadTokenConfig(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) ([]byte, time.Duration, error) { |
| 505 | + | secret, err := loadTokenSecret(ctx, cfg, backend, dataDir, log) | |||
| 506 | + | if err != nil { | |||
| 507 | + | return nil, 0, err | |||
| 508 | + | } | |||
| 509 | + | ||||
| 510 | + | expiry, err := parseTokenExpiry(cfg.RegistryAuth.TokenExpiry) | |||
| 511 | + | if err != nil { | |||
| 512 | + | return nil, 0, err | |||
| 513 | + | } | |||
| 514 | + | ||||
| 515 | + | return secret, expiry, nil | |||
| 516 | + | } | |||
| 517 | + | ||||
| 518 | + | func loadTokenSecret(ctx context.Context, cfg Config, backend domain.SecretsBackend, dataDir string, log zerowrap.Logger) ([]byte, error) { | |||
| 519 | + | if cfg.RegistryAuth.TokenSecret == "" { | |||
| 520 | + | return nil, fmt.Errorf("token_secret is required for token authentication") | |||
| 521 | + | } | |||
| 522 | + | ||||
| 523 | + | secret, err := loadSecret(ctx, backend, cfg.RegistryAuth.TokenSecret, dataDir, log) | |||
| 524 | + | if err != nil { | |||
| 525 | + | return nil, log.WrapErr(err, "failed to load token secret") | |||
| 526 | + | } | |||
| 527 | + | ||||
| 528 | + | return []byte(secret), nil | |||
| 529 | + | } | |||
| 530 | + | ||||
| 531 | + | func parseTokenExpiry(expiry string) (time.Duration, error) { | |||
| 532 | + | if expiry == "" { | |||
| 533 | + | return 0, nil | |||
| 534 | + | } | |||
| 535 | + | ||||
| 536 | + | parsed, err := time.ParseDuration(expiry) | |||
| 537 | + | if err != nil { | |||
| 538 | + | return 0, fmt.Errorf("invalid token_expiry: %w", err) | |||
| 539 | + | } | |||
| 540 | + | ||||
| 541 | + | return parsed, nil | |||
| 484 | } | 542 | } | ||
| 485 | 543 | ||||
| 486 | // loadSecret loads a secret from the configured backend. | 544 | // loadSecret loads a secret from the configured backend. | ||
M internal/usecase/auth/service.go +43 -19
| @@ -91,37 +91,54 @@ func (s *Service) ValidateToken(ctx context.Context, tokenString string) (*domai | |||
| 91 | 91 | }) | |
| 92 | 92 | log := zerowrap.FromCtx(ctx) | |
| 93 | 93 | ||
| 94 | - | // Parse the token | |
| 94 | + | claims, err := s.parseTokenClaims(tokenString) | |
| 95 | + | if err != nil { | |
| 96 | + | log.Debug().Err(err).Msg("failed to parse token") | |
| 97 | + | return nil, err | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | tokenClaims := buildTokenClaims(claims) | |
| 101 | + | ||
| 102 | + | if err := s.ensureTokenNotRevoked(ctx, tokenClaims, log); err != nil { | |
| 103 | + | return nil, err | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | if err := ensureTokenNotExpired(tokenClaims, log); err != nil { | |
| 107 | + | return nil, err | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | log.Debug().Str("subject", tokenClaims.Subject).Msg("token validation successful") | |
| 111 | + | return tokenClaims, nil | |
| 112 | + | } | |
| 113 | + | ||
| 114 | + | func (s *Service) parseTokenClaims(tokenString string) (jwt.MapClaims, error) { | |
| 95 | 115 | token, err := jwt.Parse(tokenString, func(token *jwt.Token) (any, error) { | |
| 96 | - | // Validate signing method | |
| 97 | 116 | if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { | |
| 98 | 117 | return nil, domain.ErrInvalidToken | |
| 99 | 118 | } | |
| 100 | 119 | return s.config.TokenSecret, nil | |
| 101 | 120 | }) | |
| 102 | 121 | if err != nil { | |
| 103 | - | log.Debug().Err(err).Msg("failed to parse token") | |
| 104 | 122 | return nil, domain.ErrInvalidToken | |
| 105 | 123 | } | |
| 106 | 124 | ||
| 107 | 125 | if !token.Valid { | |
| 108 | - | log.Debug().Msg("token is invalid") | |
| 109 | 126 | return nil, domain.ErrInvalidToken | |
| 110 | 127 | } | |
| 111 | 128 | ||
| 112 | - | // Extract claims | |
| 113 | 129 | claims, ok := token.Claims.(jwt.MapClaims) | |
| 114 | 130 | if !ok { | |
| 115 | - | log.Debug().Msg("failed to extract claims") | |
| 116 | 131 | return nil, domain.ErrInvalidToken | |
| 117 | 132 | } | |
| 118 | 133 | ||
| 119 | - | // Build TokenClaims from JWT claims | |
| 134 | + | return claims, nil | |
| 135 | + | } | |
| 136 | + | ||
| 137 | + | func buildTokenClaims(claims jwt.MapClaims) *domain.TokenClaims { | |
| 120 | 138 | tokenClaims := &domain.TokenClaims{ | |
| 121 | 139 | Issuer: getStringClaim(claims, "iss"), | |
| 122 | 140 | } | |
| 123 | 141 | ||
| 124 | - | // Get standard claims | |
| 125 | 142 | if jti, ok := claims["jti"].(string); ok { | |
| 126 | 143 | tokenClaims.ID = jti | |
| 127 | 144 | } | |
| @@ -142,27 +159,34 @@ func (s *Service) ValidateToken(ctx context.Context, tokenString string) (*domai | |||
| 142 | 159 | } | |
| 143 | 160 | } | |
| 144 | 161 | ||
| 145 | - | // Check if token is revoked | |
| 162 | + | return tokenClaims | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | func (s *Service) ensureTokenNotRevoked(ctx context.Context, tokenClaims *domain.TokenClaims, log zerowrap.Logger) error { | |
| 146 | 166 | revoked, err := s.tokenStore.IsRevoked(ctx, tokenClaims.ID) | |
| 147 | 167 | if err != nil { | |
| 148 | 168 | log.Warn().Err(err).Msg("failed to check token revocation") | |
| 149 | - | // Continue anyway - revocation check is best effort | |
| 169 | + | return nil | |
| 150 | 170 | } | |
| 151 | 171 | if revoked { | |
| 152 | 172 | log.Debug().Str("token_id", tokenClaims.ID).Msg("token has been revoked") | |
| 153 | - | return nil, domain.ErrRevokedToken | |
| 173 | + | return domain.ErrRevokedToken | |
| 154 | 174 | } | |
| 155 | 175 | ||
| 156 | - | // Check expiry (if set) | |
| 157 | - | if tokenClaims.ExpiresAt > 0 { | |
| 158 | - | if time.Now().Unix() > tokenClaims.ExpiresAt { | |
| 159 | - | log.Debug().Str("token_id", tokenClaims.ID).Msg("token has expired") | |
| 160 | - | return nil, domain.ErrExpiredToken | |
| 161 | - | } | |
| 176 | + | return nil | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | func ensureTokenNotExpired(tokenClaims *domain.TokenClaims, log zerowrap.Logger) error { | |
| 180 | + | if tokenClaims.ExpiresAt <= 0 { | |
| 181 | + | return nil | |
| 162 | 182 | } | |
| 163 | 183 | ||
| 164 | - | log.Debug().Str("subject", tokenClaims.Subject).Msg("token validation successful") | |
| 165 | - | return tokenClaims, nil | |
| 184 | + | if time.Now().Unix() > tokenClaims.ExpiresAt { | |
| 185 | + | log.Debug().Str("token_id", tokenClaims.ID).Msg("token has expired") | |
| 186 | + | return domain.ErrExpiredToken | |
| 187 | + | } | |
| 188 | + | ||
| 189 | + | return nil | |
| 166 | 190 | } | |
| 167 | 191 | ||
| 168 | 192 | // GenerateToken creates a new JWT token for the given subject. | |
| @@ -91,37 +91,54 @@ func (s *Service) ValidateToken(ctx context.Context, tokenString string) (*domai | |||||
| 91 | }) | 91 | }) | ||
| 92 | log := zerowrap.FromCtx(ctx) | 92 | log := zerowrap.FromCtx(ctx) | ||
| 93 | 93 | ||||
| 94 | - | // Parse the token | 94 | + | claims, err := s.parseTokenClaims(tokenString) |
| 95 | + | if err != nil { | |||
| 96 | + | log.Debug().Err(err).Msg("failed to parse token") | |||
| 97 | + | return nil, err | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | tokenClaims := buildTokenClaims(claims) | |||
| 101 | + | ||||
| 102 | + | if err := s.ensureTokenNotRevoked(ctx, tokenClaims, log); err != nil { | |||
| 103 | + | return nil, err | |||
| 104 | + | } | |||
| 105 | + | ||||
| 106 | + | if err := ensureTokenNotExpired(tokenClaims, log); err != nil { | |||
| 107 | + | return nil, err | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | log.Debug().Str("subject", tokenClaims.Subject).Msg("token validation successful") | |||
| 111 | + | return tokenClaims, nil | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | func (s *Service) parseTokenClaims(tokenString string) (jwt.MapClaims, error) { | |||
| 95 | token, err := jwt.Parse(tokenString, func(token *jwt.Token) (any, error) { | 115 | token, err := jwt.Parse(tokenString, func(token *jwt.Token) (any, error) { | ||
| 96 | - | // Validate signing method | |||
| 97 | if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { | 116 | if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { | ||
| 98 | return nil, domain.ErrInvalidToken | 117 | return nil, domain.ErrInvalidToken | ||
| 99 | } | 118 | } | ||
| 100 | return s.config.TokenSecret, nil | 119 | return s.config.TokenSecret, nil | ||
| 101 | }) | 120 | }) | ||
| 102 | if err != nil { | 121 | if err != nil { | ||
| 103 | - | log.Debug().Err(err).Msg("failed to parse token") | |||
| 104 | return nil, domain.ErrInvalidToken | 122 | return nil, domain.ErrInvalidToken | ||
| 105 | } | 123 | } | ||
| 106 | 124 | ||||
| 107 | if !token.Valid { | 125 | if !token.Valid { | ||
| 108 | - | log.Debug().Msg("token is invalid") | |||
| 109 | return nil, domain.ErrInvalidToken | 126 | return nil, domain.ErrInvalidToken | ||
| 110 | } | 127 | } | ||
| 111 | 128 | ||||
| 112 | - | // Extract claims | |||
| 113 | claims, ok := token.Claims.(jwt.MapClaims) | 129 | claims, ok := token.Claims.(jwt.MapClaims) | ||
| 114 | if !ok { | 130 | if !ok { | ||
| 115 | - | log.Debug().Msg("failed to extract claims") | |||
| 116 | return nil, domain.ErrInvalidToken | 131 | return nil, domain.ErrInvalidToken | ||
| 117 | } | 132 | } | ||
| 118 | 133 | ||||
| 119 | - | // Build TokenClaims from JWT claims | 134 | + | return claims, nil |
| 135 | + | } | |||
| 136 | + | ||||
| 137 | + | func buildTokenClaims(claims jwt.MapClaims) *domain.TokenClaims { | |||
| 120 | tokenClaims := &domain.TokenClaims{ | 138 | tokenClaims := &domain.TokenClaims{ | ||
| 121 | Issuer: getStringClaim(claims, "iss"), | 139 | Issuer: getStringClaim(claims, "iss"), | ||
| 122 | } | 140 | } | ||
| 123 | 141 | ||||
| 124 | - | // Get standard claims | |||
| 125 | if jti, ok := claims["jti"].(string); ok { | 142 | if jti, ok := claims["jti"].(string); ok { | ||
| 126 | tokenClaims.ID = jti | 143 | tokenClaims.ID = jti | ||
| 127 | } | 144 | } | ||
| @@ -142,27 +159,34 @@ func (s *Service) ValidateToken(ctx context.Context, tokenString string) (*domai | |||||
| 142 | } | 159 | } | ||
| 143 | } | 160 | } | ||
| 144 | 161 | ||||
| 145 | - | // Check if token is revoked | 162 | + | return tokenClaims |
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | func (s *Service) ensureTokenNotRevoked(ctx context.Context, tokenClaims *domain.TokenClaims, log zerowrap.Logger) error { | |||
| 146 | revoked, err := s.tokenStore.IsRevoked(ctx, tokenClaims.ID) | 166 | revoked, err := s.tokenStore.IsRevoked(ctx, tokenClaims.ID) | ||
| 147 | if err != nil { | 167 | if err != nil { | ||
| 148 | log.Warn().Err(err).Msg("failed to check token revocation") | 168 | log.Warn().Err(err).Msg("failed to check token revocation") | ||
| 149 | - | // Continue anyway - revocation check is best effort | 169 | + | return nil |
| 150 | } | 170 | } | ||
| 151 | if revoked { | 171 | if revoked { | ||
| 152 | log.Debug().Str("token_id", tokenClaims.ID).Msg("token has been revoked") | 172 | log.Debug().Str("token_id", tokenClaims.ID).Msg("token has been revoked") | ||
| 153 | - | return nil, domain.ErrRevokedToken | 173 | + | return domain.ErrRevokedToken |
| 154 | } | 174 | } | ||
| 155 | 175 | ||||
| 156 | - | // Check expiry (if set) | 176 | + | return nil |
| 157 | - | if tokenClaims.ExpiresAt > 0 { | 177 | + | } |
| 158 | - | if time.Now().Unix() > tokenClaims.ExpiresAt { | 178 | + | |
| 159 | - | log.Debug().Str("token_id", tokenClaims.ID).Msg("token has expired") | 179 | + | func ensureTokenNotExpired(tokenClaims *domain.TokenClaims, log zerowrap.Logger) error { |
| 160 | - | return nil, domain.ErrExpiredToken | 180 | + | if tokenClaims.ExpiresAt <= 0 { |
| 161 | - | } | 181 | + | return nil |
| 162 | } | 182 | } | ||
| 163 | 183 | ||||
| 164 | - | log.Debug().Str("subject", tokenClaims.Subject).Msg("token validation successful") | 184 | + | if time.Now().Unix() > tokenClaims.ExpiresAt { |
| 165 | - | return tokenClaims, nil | 185 | + | log.Debug().Str("token_id", tokenClaims.ID).Msg("token has expired") |
| 186 | + | return domain.ErrExpiredToken | |||
| 187 | + | } | |||
| 188 | + | ||||
| 189 | + | return nil | |||
| 166 | } | 190 | } | ||
| 167 | 191 | ||||
| 168 | // GenerateToken creates a new JWT token for the given subject. | 192 | // GenerateToken creates a new JWT token for the given subject. | ||
M internal/usecase/container/service.go +73 -42
| @@ -468,6 +468,13 @@ func (s *Service) buildImageRef(image string) string { | |||
| 468 | 468 | return fmt.Sprintf("%s/%s", s.config.RegistryDomain, image) | |
| 469 | 469 | } | |
| 470 | 470 | ||
| 471 | + | func (s *Service) pullRefForDeploy(ctx context.Context, imageRef string) (string, bool) { | |
| 472 | + | if !domain.IsInternalDeploy(ctx) { | |
| 473 | + | return imageRef, false | |
| 474 | + | } | |
| 475 | + | return rewriteToLocalRegistry(imageRef, s.config.RegistryDomain, s.config.RegistryPort), true | |
| 476 | + | } | |
| 477 | + | ||
| 471 | 478 | // ensureImage ensures the image is available locally, pulling if needed. | |
| 472 | 479 | // Returns the canonical image reference to use for container operations. | |
| 473 | 480 | func (s *Service) ensureImage(ctx context.Context, imageRef string) (string, error) { | |
| @@ -475,11 +482,7 @@ func (s *Service) ensureImage(ctx context.Context, imageRef string) (string, err | |||
| 475 | 482 | log := zerowrap.FromCtx(ctx) | |
| 476 | 483 | ||
| 477 | 484 | // Determine if this is an internal deploy and what reference to use for pulls. | |
| 478 | - | pullRef := imageRef | |
| 479 | - | isInternal := domain.IsInternalDeploy(ctx) | |
| 480 | - | if isInternal { | |
| 481 | - | pullRef = rewriteToLocalRegistry(imageRef, s.config.RegistryDomain, s.config.RegistryPort) | |
| 482 | - | } | |
| 485 | + | pullRef, isInternal := s.pullRefForDeploy(ctx, imageRef) | |
| 483 | 486 | if pullRef != imageRef { | |
| 484 | 487 | log.Info(). | |
| 485 | 488 | Str("original_ref", imageRef). | |
| @@ -487,64 +490,96 @@ func (s *Service) ensureImage(ctx context.Context, imageRef string) (string, err | |||
| 487 | 490 | Msg("internal deploy: using localhost registry for pull") | |
| 488 | 491 | } | |
| 489 | 492 | ||
| 490 | - | // Check local images using the canonical reference or pull reference. | |
| 493 | + | found, err := s.ensureLocalImage(ctx, imageRef, pullRef) | |
| 494 | + | if err != nil { | |
| 495 | + | return "", err | |
| 496 | + | } | |
| 497 | + | if found { | |
| 498 | + | return imageRef, nil | |
| 499 | + | } | |
| 500 | + | ||
| 501 | + | // Pull image | |
| 502 | + | log.Info().Msg("pulling image from registry") | |
| 503 | + | ||
| 504 | + | if err := s.pullImage(ctx, pullRef, isInternal); err != nil { | |
| 505 | + | return "", err | |
| 506 | + | } | |
| 507 | + | ||
| 508 | + | if err := s.tagImageIfNeeded(ctx, pullRef, imageRef); err != nil { | |
| 509 | + | return "", err | |
| 510 | + | } | |
| 511 | + | ||
| 512 | + | log.Info().Msg("image pulled successfully") | |
| 513 | + | return imageRef, nil | |
| 514 | + | } | |
| 515 | + | ||
| 516 | + | func (s *Service) ensureLocalImage(ctx context.Context, imageRef, pullRef string) (bool, error) { | |
| 517 | + | log := zerowrap.FromCtx(ctx) | |
| 518 | + | ||
| 491 | 519 | localImages, err := s.runtime.ListImages(ctx) | |
| 492 | 520 | if err != nil { | |
| 493 | 521 | log.WrapErr(err, "failed to list local images, will attempt pull") | |
| 494 | - | } else { | |
| 495 | - | normalizedRef := normalizeImageRef(imageRef) | |
| 496 | - | normalizedPullRef := normalizeImageRef(pullRef) | |
| 497 | - | for _, img := range localImages { | |
| 498 | - | normalizedImage := normalizeImageRef(img) | |
| 499 | - | if normalizedImage == normalizedRef { | |
| 500 | - | log.Info().Msg("image found locally, skipping pull") | |
| 501 | - | return imageRef, nil | |
| 502 | - | } | |
| 503 | - | if normalizedImage == normalizedPullRef { | |
| 504 | - | if pullRef != imageRef { | |
| 505 | - | if err := s.runtime.TagImage(ctx, pullRef, imageRef); err != nil { | |
| 506 | - | return "", log.WrapErr(err, "failed to tag image from pull reference") | |
| 507 | - | } | |
| 508 | - | } | |
| 509 | - | log.Info().Msg("image found locally, skipping pull") | |
| 510 | - | return imageRef, nil | |
| 522 | + | return false, nil | |
| 523 | + | } | |
| 524 | + | ||
| 525 | + | normalizedRef := normalizeImageRef(imageRef) | |
| 526 | + | normalizedPullRef := normalizeImageRef(pullRef) | |
| 527 | + | for _, img := range localImages { | |
| 528 | + | normalizedImage := normalizeImageRef(img) | |
| 529 | + | if normalizedImage == normalizedRef { | |
| 530 | + | log.Info().Msg("image found locally, skipping pull") | |
| 531 | + | return true, nil | |
| 532 | + | } | |
| 533 | + | if normalizedImage == normalizedPullRef { | |
| 534 | + | if err := s.tagImageIfNeeded(ctx, pullRef, imageRef); err != nil { | |
| 535 | + | return false, err | |
| 511 | 536 | } | |
| 537 | + | log.Info().Msg("image found locally, skipping pull") | |
| 538 | + | return true, nil | |
| 512 | 539 | } | |
| 513 | 540 | } | |
| 514 | 541 | ||
| 515 | - | // Pull image | |
| 516 | - | log.Info().Msg("pulling image from registry") | |
| 542 | + | return false, nil | |
| 543 | + | } | |
| 544 | + | ||
| 545 | + | func (s *Service) pullImage(ctx context.Context, pullRef string, isInternal bool) error { | |
| 546 | + | log := zerowrap.FromCtx(ctx) | |
| 517 | 547 | ||
| 518 | 548 | switch { | |
| 519 | 549 | case isInternal && s.config.RegistryAuthEnabled: | |
| 520 | 550 | if s.config.InternalRegistryUsername == "" || s.config.InternalRegistryPassword == "" { | |
| 521 | - | return "", log.WrapErr(fmt.Errorf("internal registry auth not configured"), "failed to pull image for internal deploy") | |
| 551 | + | return log.WrapErr(fmt.Errorf("internal registry auth not configured"), "failed to pull image for internal deploy") | |
| 522 | 552 | } | |
| 523 | 553 | if err := s.runtime.PullImageWithAuth(ctx, pullRef, s.config.InternalRegistryUsername, s.config.InternalRegistryPassword); err != nil { | |
| 524 | - | return "", log.WrapErr(err, "failed to pull image with internal auth") | |
| 554 | + | return log.WrapErr(err, "failed to pull image with internal auth") | |
| 525 | 555 | } | |
| 526 | 556 | case isInternal: | |
| 527 | 557 | if err := s.runtime.PullImage(ctx, pullRef); err != nil { | |
| 528 | - | return "", log.WrapErr(err, "failed to pull image") | |
| 558 | + | return log.WrapErr(err, "failed to pull image") | |
| 529 | 559 | } | |
| 530 | 560 | case s.config.RegistryAuthEnabled: | |
| 531 | 561 | if err := s.runtime.PullImageWithAuth(ctx, pullRef, s.config.RegistryUsername, s.config.RegistryPassword); err != nil { | |
| 532 | - | return "", log.WrapErr(err, "failed to pull image with auth") | |
| 562 | + | return log.WrapErr(err, "failed to pull image with auth") | |
| 533 | 563 | } | |
| 534 | 564 | default: | |
| 535 | 565 | if err := s.runtime.PullImage(ctx, pullRef); err != nil { | |
| 536 | - | return "", log.WrapErr(err, "failed to pull image") | |
| 566 | + | return log.WrapErr(err, "failed to pull image") | |
| 537 | 567 | } | |
| 538 | 568 | } | |
| 539 | 569 | ||
| 540 | - | if pullRef != imageRef { | |
| 541 | - | if err := s.runtime.TagImage(ctx, pullRef, imageRef); err != nil { | |
| 542 | - | return "", log.WrapErr(err, "failed to tag image from pull reference") | |
| 543 | - | } | |
| 570 | + | return nil | |
| 571 | + | } | |
| 572 | + | ||
| 573 | + | func (s *Service) tagImageIfNeeded(ctx context.Context, sourceRef, targetRef string) error { | |
| 574 | + | if sourceRef == targetRef { | |
| 575 | + | return nil | |
| 544 | 576 | } | |
| 545 | 577 | ||
| 546 | - | log.Info().Msg("image pulled successfully") | |
| 547 | - | return imageRef, nil | |
| 578 | + | log := zerowrap.FromCtx(ctx) | |
| 579 | + | if err := s.runtime.TagImage(ctx, sourceRef, targetRef); err != nil { | |
| 580 | + | return log.WrapErr(err, "failed to tag image from pull reference") | |
| 581 | + | } | |
| 582 | + | return nil | |
| 548 | 583 | } | |
| 549 | 584 | ||
| 550 | 585 | func (s *Service) loadEnvironment(ctx context.Context, domainName, imageRef string) ([]string, error) { | |
| @@ -1021,16 +1056,12 @@ func rewriteToLocalRegistry(imageRef, registryDomain string, registryPort int) s | |||
| 1021 | 1056 | ||
| 1022 | 1057 | localRegistry := fmt.Sprintf("localhost:%d", registryPort) | |
| 1023 | 1058 | localPrefix := localRegistry + "/" | |
| 1024 | - | if strings.HasPrefix(imageRef, localPrefix) { | |
| 1025 | - | return imageRef | |
| 1026 | - | } | |
| 1059 | + | imageRef = strings.TrimPrefix(imageRef, localPrefix) | |
| 1027 | 1060 | ||
| 1028 | 1061 | registryDomain = strings.TrimSuffix(registryDomain, "/") | |
| 1029 | 1062 | if registryDomain != "" { | |
| 1030 | 1063 | prefix := registryDomain + "/" | |
| 1031 | - | if strings.HasPrefix(imageRef, prefix) { | |
| 1032 | - | imageRef = strings.TrimPrefix(imageRef, prefix) | |
| 1033 | - | } | |
| 1064 | + | imageRef = strings.TrimPrefix(imageRef, prefix) | |
| 1034 | 1065 | } | |
| 1035 | 1066 | ||
| 1036 | 1067 | return localPrefix + imageRef | |
| @@ -468,6 +468,13 @@ func (s *Service) buildImageRef(image string) string { | |||||
| 468 | return fmt.Sprintf("%s/%s", s.config.RegistryDomain, image) | 468 | return fmt.Sprintf("%s/%s", s.config.RegistryDomain, image) | ||
| 469 | } | 469 | } | ||
| 470 | 470 | ||||
| 471 | + | func (s *Service) pullRefForDeploy(ctx context.Context, imageRef string) (string, bool) { | |||
| 472 | + | if !domain.IsInternalDeploy(ctx) { | |||
| 473 | + | return imageRef, false | |||
| 474 | + | } | |||
| 475 | + | return rewriteToLocalRegistry(imageRef, s.config.RegistryDomain, s.config.RegistryPort), true | |||
| 476 | + | } | |||
| 477 | + | ||||
| 471 | // ensureImage ensures the image is available locally, pulling if needed. | 478 | // ensureImage ensures the image is available locally, pulling if needed. | ||
| 472 | // Returns the canonical image reference to use for container operations. | 479 | // Returns the canonical image reference to use for container operations. | ||
| 473 | func (s *Service) ensureImage(ctx context.Context, imageRef string) (string, error) { | 480 | func (s *Service) ensureImage(ctx context.Context, imageRef string) (string, error) { | ||
| @@ -475,11 +482,7 @@ func (s *Service) ensureImage(ctx context.Context, imageRef string) (string, err | |||||
| 475 | log := zerowrap.FromCtx(ctx) | 482 | log := zerowrap.FromCtx(ctx) | ||
| 476 | 483 | ||||
| 477 | // Determine if this is an internal deploy and what reference to use for pulls. | 484 | // Determine if this is an internal deploy and what reference to use for pulls. | ||
| 478 | - | pullRef := imageRef | 485 | + | pullRef, isInternal := s.pullRefForDeploy(ctx, imageRef) |
| 479 | - | isInternal := domain.IsInternalDeploy(ctx) | |||
| 480 | - | if isInternal { | |||
| 481 | - | pullRef = rewriteToLocalRegistry(imageRef, s.config.RegistryDomain, s.config.RegistryPort) | |||
| 482 | - | } | |||
| 483 | if pullRef != imageRef { | 486 | if pullRef != imageRef { | ||
| 484 | log.Info(). | 487 | log.Info(). | ||
| 485 | Str("original_ref", imageRef). | 488 | Str("original_ref", imageRef). | ||
| @@ -487,64 +490,96 @@ func (s *Service) ensureImage(ctx context.Context, imageRef string) (string, err | |||||
| 487 | Msg("internal deploy: using localhost registry for pull") | 490 | Msg("internal deploy: using localhost registry for pull") | ||
| 488 | } | 491 | } | ||
| 489 | 492 | ||||
| 490 | - | // Check local images using the canonical reference or pull reference. | 493 | + | found, err := s.ensureLocalImage(ctx, imageRef, pullRef) |
| 494 | + | if err != nil { | |||
| 495 | + | return "", err | |||
| 496 | + | } | |||
| 497 | + | if found { | |||
| 498 | + | return imageRef, nil | |||
| 499 | + | } | |||
| 500 | + | ||||
| 501 | + | // Pull image | |||
| 502 | + | log.Info().Msg("pulling image from registry") | |||
| 503 | + | ||||
| 504 | + | if err := s.pullImage(ctx, pullRef, isInternal); err != nil { | |||
| 505 | + | return "", err | |||
| 506 | + | } | |||
| 507 | + | ||||
| 508 | + | if err := s.tagImageIfNeeded(ctx, pullRef, imageRef); err != nil { | |||
| 509 | + | return "", err | |||
| 510 | + | } | |||
| 511 | + | ||||
| 512 | + | log.Info().Msg("image pulled successfully") | |||
| 513 | + | return imageRef, nil | |||
| 514 | + | } | |||
| 515 | + | ||||
| 516 | + | func (s *Service) ensureLocalImage(ctx context.Context, imageRef, pullRef string) (bool, error) { | |||
| 517 | + | log := zerowrap.FromCtx(ctx) | |||
| 518 | + | ||||
| 491 | localImages, err := s.runtime.ListImages(ctx) | 519 | localImages, err := s.runtime.ListImages(ctx) | ||
| 492 | if err != nil { | 520 | if err != nil { | ||
| 493 | log.WrapErr(err, "failed to list local images, will attempt pull") | 521 | log.WrapErr(err, "failed to list local images, will attempt pull") | ||
| 494 | - | } else { | 522 | + | return false, nil |
| 495 | - | normalizedRef := normalizeImageRef(imageRef) | 523 | + | } |
| 496 | - | normalizedPullRef := normalizeImageRef(pullRef) | 524 | + | |
| 497 | - | for _, img := range localImages { | 525 | + | normalizedRef := normalizeImageRef(imageRef) |
| 498 | - | normalizedImage := normalizeImageRef(img) | 526 | + | normalizedPullRef := normalizeImageRef(pullRef) |
| 499 | - | if normalizedImage == normalizedRef { | 527 | + | for _, img := range localImages { |
| 500 | - | log.Info().Msg("image found locally, skipping pull") | 528 | + | normalizedImage := normalizeImageRef(img) |
| 501 | - | return imageRef, nil | 529 | + | if normalizedImage == normalizedRef { |
| 502 | - | } | 530 | + | log.Info().Msg("image found locally, skipping pull") |
| 503 | - | if normalizedImage == normalizedPullRef { | 531 | + | return true, nil |
| 504 | - | if pullRef != imageRef { | 532 | + | } |
| 505 | - | if err := s.runtime.TagImage(ctx, pullRef, imageRef); err != nil { | 533 | + | if normalizedImage == normalizedPullRef { |
| 506 | - | return "", log.WrapErr(err, "failed to tag image from pull reference") | 534 | + | if err := s.tagImageIfNeeded(ctx, pullRef, imageRef); err != nil { |
| 507 | - | } | 535 | + | return false, err |
| 508 | - | } | |||
| 509 | - | log.Info().Msg("image found locally, skipping pull") | |||
| 510 | - | return imageRef, nil | |||
| 511 | } | 536 | } | ||
| 537 | + | log.Info().Msg("image found locally, skipping pull") | |||
| 538 | + | return true, nil | |||
| 512 | } | 539 | } | ||
| 513 | } | 540 | } | ||
| 514 | 541 | ||||
| 515 | - | // Pull image | 542 | + | return false, nil |
| 516 | - | log.Info().Msg("pulling image from registry") | 543 | + | } |
| 544 | + | ||||
| 545 | + | func (s *Service) pullImage(ctx context.Context, pullRef string, isInternal bool) error { | |||
| 546 | + | log := zerowrap.FromCtx(ctx) | |||
| 517 | 547 | ||||
| 518 | switch { | 548 | switch { | ||
| 519 | case isInternal && s.config.RegistryAuthEnabled: | 549 | case isInternal && s.config.RegistryAuthEnabled: | ||
| 520 | if s.config.InternalRegistryUsername == "" || s.config.InternalRegistryPassword == "" { | 550 | if s.config.InternalRegistryUsername == "" || s.config.InternalRegistryPassword == "" { | ||
| 521 | - | return "", log.WrapErr(fmt.Errorf("internal registry auth not configured"), "failed to pull image for internal deploy") | 551 | + | return log.WrapErr(fmt.Errorf("internal registry auth not configured"), "failed to pull image for internal deploy") |
| 522 | } | 552 | } | ||
| 523 | if err := s.runtime.PullImageWithAuth(ctx, pullRef, s.config.InternalRegistryUsername, s.config.InternalRegistryPassword); err != nil { | 553 | if err := s.runtime.PullImageWithAuth(ctx, pullRef, s.config.InternalRegistryUsername, s.config.InternalRegistryPassword); err != nil { | ||
| 524 | - | return "", log.WrapErr(err, "failed to pull image with internal auth") | 554 | + | return log.WrapErr(err, "failed to pull image with internal auth") |
| 525 | } | 555 | } | ||
| 526 | case isInternal: | 556 | case isInternal: | ||
| 527 | if err := s.runtime.PullImage(ctx, pullRef); err != nil { | 557 | if err := s.runtime.PullImage(ctx, pullRef); err != nil { | ||
| 528 | - | return "", log.WrapErr(err, "failed to pull image") | 558 | + | return log.WrapErr(err, "failed to pull image") |
| 529 | } | 559 | } | ||
| 530 | case s.config.RegistryAuthEnabled: | 560 | case s.config.RegistryAuthEnabled: | ||
| 531 | if err := s.runtime.PullImageWithAuth(ctx, pullRef, s.config.RegistryUsername, s.config.RegistryPassword); err != nil { | 561 | if err := s.runtime.PullImageWithAuth(ctx, pullRef, s.config.RegistryUsername, s.config.RegistryPassword); err != nil { | ||
| 532 | - | return "", log.WrapErr(err, "failed to pull image with auth") | 562 | + | return log.WrapErr(err, "failed to pull image with auth") |
| 533 | } | 563 | } | ||
| 534 | default: | 564 | default: | ||
| 535 | if err := s.runtime.PullImage(ctx, pullRef); err != nil { | 565 | if err := s.runtime.PullImage(ctx, pullRef); err != nil { | ||
| 536 | - | return "", log.WrapErr(err, "failed to pull image") | 566 | + | return log.WrapErr(err, "failed to pull image") |
| 537 | } | 567 | } | ||
| 538 | } | 568 | } | ||
| 539 | 569 | ||||
| 540 | - | if pullRef != imageRef { | 570 | + | return nil |
| 541 | - | if err := s.runtime.TagImage(ctx, pullRef, imageRef); err != nil { | 571 | + | } |
| 542 | - | return "", log.WrapErr(err, "failed to tag image from pull reference") | 572 | + | |
| 543 | - | } | 573 | + | func (s *Service) tagImageIfNeeded(ctx context.Context, sourceRef, targetRef string) error { |
| 574 | + | if sourceRef == targetRef { | |||
| 575 | + | return nil | |||
| 544 | } | 576 | } | ||
| 545 | 577 | ||||
| 546 | - | log.Info().Msg("image pulled successfully") | 578 | + | log := zerowrap.FromCtx(ctx) |
| 547 | - | return imageRef, nil | 579 | + | if err := s.runtime.TagImage(ctx, sourceRef, targetRef); err != nil { |
| 580 | + | return log.WrapErr(err, "failed to tag image from pull reference") | |||
| 581 | + | } | |||
| 582 | + | return nil | |||
| 548 | } | 583 | } | ||
| 549 | 584 | ||||
| 550 | func (s *Service) loadEnvironment(ctx context.Context, domainName, imageRef string) ([]string, error) { | 585 | func (s *Service) loadEnvironment(ctx context.Context, domainName, imageRef string) ([]string, error) { | ||
| @@ -1021,16 +1056,12 @@ func rewriteToLocalRegistry(imageRef, registryDomain string, registryPort int) s | |||||
| 1021 | 1056 | ||||
| 1022 | localRegistry := fmt.Sprintf("localhost:%d", registryPort) | 1057 | localRegistry := fmt.Sprintf("localhost:%d", registryPort) | ||
| 1023 | localPrefix := localRegistry + "/" | 1058 | localPrefix := localRegistry + "/" | ||
| 1024 | - | if strings.HasPrefix(imageRef, localPrefix) { | 1059 | + | imageRef = strings.TrimPrefix(imageRef, localPrefix) |
| 1025 | - | return imageRef | |||
| 1026 | - | } | |||
| 1027 | 1060 | ||||
| 1028 | registryDomain = strings.TrimSuffix(registryDomain, "/") | 1061 | registryDomain = strings.TrimSuffix(registryDomain, "/") | ||
| 1029 | if registryDomain != "" { | 1062 | if registryDomain != "" { | ||
| 1030 | prefix := registryDomain + "/" | 1063 | prefix := registryDomain + "/" | ||
| 1031 | - | if strings.HasPrefix(imageRef, prefix) { | 1064 | + | imageRef = strings.TrimPrefix(imageRef, prefix) |
| 1032 | - | imageRef = strings.TrimPrefix(imageRef, prefix) | |||
| 1033 | - | } | |||
| 1034 | } | 1065 | } | ||
| 1035 | 1066 | ||||
| 1036 | return localPrefix + imageRef | 1067 | return localPrefix + imageRef | ||