fix(container): preserve image pull failure sentinel
- commit
- f8d5e7c4a8724739453d00db1ec091fae6bba5f3
- author
- brice · · 2026-04-08 18:48 UTC
- parent
- 7cfa412
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
3 files · +17 -6
M internal/adapters/out/docker/runtime.go +2 -2
| @@ -468,7 +468,7 @@ func (r *Runtime) PullImage(ctx context.Context, imageRef string) error { | |||
| 468 | 468 | ||
| 469 | 469 | reader, err := r.client.ImagePull(ctx, imageRef, image.PullOptions{}) | |
| 470 | 470 | if err != nil { | |
| 471 | - | return log.WrapErr(err, "failed to pull image") | |
| 471 | + | return log.WrapErr(fmt.Errorf("%w: %w", domain.ErrImagePullFailed, err), "failed to pull image") | |
| 472 | 472 | } | |
| 473 | 473 | defer reader.Close() | |
| 474 | 474 | ||
| @@ -528,7 +528,7 @@ func (r *Runtime) PullImageWithAuth(ctx context.Context, imageRef, username, pas | |||
| 528 | 528 | RegistryAuth: authStr, | |
| 529 | 529 | }) | |
| 530 | 530 | if err != nil { | |
| 531 | - | return log.WrapErr(err, "failed to pull image with auth") | |
| 531 | + | return log.WrapErr(fmt.Errorf("%w: %w", domain.ErrImagePullFailed, err), "failed to pull image with auth") | |
| 532 | 532 | } | |
| 533 | 533 | defer reader.Close() | |
| 534 | 534 | ||
| @@ -468,7 +468,7 @@ func (r *Runtime) PullImage(ctx context.Context, imageRef string) error { | |||||
| 468 | 468 | ||||
| 469 | reader, err := r.client.ImagePull(ctx, imageRef, image.PullOptions{}) | 469 | reader, err := r.client.ImagePull(ctx, imageRef, image.PullOptions{}) | ||
| 470 | if err != nil { | 470 | if err != nil { | ||
| 471 | - | return log.WrapErr(err, "failed to pull image") | 471 | + | return log.WrapErr(fmt.Errorf("%w: %w", domain.ErrImagePullFailed, err), "failed to pull image") |
| 472 | } | 472 | } | ||
| 473 | defer reader.Close() | 473 | defer reader.Close() | ||
| 474 | 474 | ||||
| @@ -528,7 +528,7 @@ func (r *Runtime) PullImageWithAuth(ctx context.Context, imageRef, username, pas | |||||
| 528 | RegistryAuth: authStr, | 528 | RegistryAuth: authStr, | ||
| 529 | }) | 529 | }) | ||
| 530 | if err != nil { | 530 | if err != nil { | ||
| 531 | - | return log.WrapErr(err, "failed to pull image with auth") | 531 | + | return log.WrapErr(fmt.Errorf("%w: %w", domain.ErrImagePullFailed, err), "failed to pull image with auth") |
| 532 | } | 532 | } | ||
| 533 | defer reader.Close() | 533 | defer reader.Close() | ||
| 534 | 534 | ||||
M internal/usecase/container/service.go +8 -4
| @@ -835,6 +835,10 @@ func isPullFailure(err error) bool { | |||
| 835 | 835 | return false | |
| 836 | 836 | } | |
| 837 | 837 | ||
| 838 | + | if errors.Is(err, domain.ErrImagePullFailed) { | |
| 839 | + | return true | |
| 840 | + | } | |
| 841 | + | ||
| 838 | 842 | msg := strings.ToLower(err.Error()) | |
| 839 | 843 | return strings.Contains(msg, "failed to pull image") || | |
| 840 | 844 | strings.Contains(msg, "pull access denied") | |
| @@ -1910,24 +1914,24 @@ func (s *Service) pullImage(ctx context.Context, pullRef string, isInternal bool | |||
| 1910 | 1914 | if err := s.pullImageWithRetry(ctx, func(ctx context.Context) error { | |
| 1911 | 1915 | return s.runtime.PullImageWithAuth(ctx, pullRef, cfg.InternalRegistryUsername, cfg.InternalRegistryPassword) | |
| 1912 | 1916 | }, isInternal); err != nil { | |
| 1913 | - | return log.WrapErr(err, "failed to pull image with internal auth") | |
| 1917 | + | return log.WrapErr(fmt.Errorf("%w: %w", domain.ErrImagePullFailed, err), "failed to pull image with internal auth") | |
| 1914 | 1918 | } | |
| 1915 | 1919 | case isInternal: | |
| 1916 | 1920 | if err := s.pullImageWithRetry(ctx, func(ctx context.Context) error { | |
| 1917 | 1921 | return s.runtime.PullImage(ctx, pullRef) | |
| 1918 | 1922 | }, isInternal); err != nil { | |
| 1919 | - | return log.WrapErr(err, "failed to pull image") | |
| 1923 | + | return log.WrapErr(fmt.Errorf("%w: %w", domain.ErrImagePullFailed, err), "failed to pull image") | |
| 1920 | 1924 | } | |
| 1921 | 1925 | case cfg.RegistryAuthEnabled: | |
| 1922 | 1926 | if cfg.ServiceTokenUsername == "" || cfg.ServiceToken == "" { | |
| 1923 | 1927 | return log.WrapErr(fmt.Errorf("registry service token not configured"), "failed to pull image for registry auth") | |
| 1924 | 1928 | } | |
| 1925 | 1929 | if err := s.runtime.PullImageWithAuth(ctx, pullRef, cfg.ServiceTokenUsername, cfg.ServiceToken); err != nil { | |
| 1926 | - | return log.WrapErr(err, "failed to pull image with auth") | |
| 1930 | + | return log.WrapErr(fmt.Errorf("%w: %w", domain.ErrImagePullFailed, err), "failed to pull image with auth") | |
| 1927 | 1931 | } | |
| 1928 | 1932 | default: | |
| 1929 | 1933 | if err := s.runtime.PullImage(ctx, pullRef); err != nil { | |
| 1930 | - | return log.WrapErr(err, "failed to pull image") | |
| 1934 | + | return log.WrapErr(fmt.Errorf("%w: %w", domain.ErrImagePullFailed, err), "failed to pull image") | |
| 1931 | 1935 | } | |
| 1932 | 1936 | } | |
| 1933 | 1937 | ||
| @@ -835,6 +835,10 @@ func isPullFailure(err error) bool { | |||||
| 835 | return false | 835 | return false | ||
| 836 | } | 836 | } | ||
| 837 | 837 | ||||
| 838 | + | if errors.Is(err, domain.ErrImagePullFailed) { | |||
| 839 | + | return true | |||
| 840 | + | } | |||
| 841 | + | ||||
| 838 | msg := strings.ToLower(err.Error()) | 842 | msg := strings.ToLower(err.Error()) | ||
| 839 | return strings.Contains(msg, "failed to pull image") || | 843 | return strings.Contains(msg, "failed to pull image") || | ||
| 840 | strings.Contains(msg, "pull access denied") | 844 | strings.Contains(msg, "pull access denied") | ||
| @@ -1910,24 +1914,24 @@ func (s *Service) pullImage(ctx context.Context, pullRef string, isInternal bool | |||||
| 1910 | if err := s.pullImageWithRetry(ctx, func(ctx context.Context) error { | 1914 | if err := s.pullImageWithRetry(ctx, func(ctx context.Context) error { | ||
| 1911 | return s.runtime.PullImageWithAuth(ctx, pullRef, cfg.InternalRegistryUsername, cfg.InternalRegistryPassword) | 1915 | return s.runtime.PullImageWithAuth(ctx, pullRef, cfg.InternalRegistryUsername, cfg.InternalRegistryPassword) | ||
| 1912 | }, isInternal); err != nil { | 1916 | }, isInternal); err != nil { | ||
| 1913 | - | return log.WrapErr(err, "failed to pull image with internal auth") | 1917 | + | return log.WrapErr(fmt.Errorf("%w: %w", domain.ErrImagePullFailed, err), "failed to pull image with internal auth") |
| 1914 | } | 1918 | } | ||
| 1915 | case isInternal: | 1919 | case isInternal: | ||
| 1916 | if err := s.pullImageWithRetry(ctx, func(ctx context.Context) error { | 1920 | if err := s.pullImageWithRetry(ctx, func(ctx context.Context) error { | ||
| 1917 | return s.runtime.PullImage(ctx, pullRef) | 1921 | return s.runtime.PullImage(ctx, pullRef) | ||
| 1918 | }, isInternal); err != nil { | 1922 | }, isInternal); err != nil { | ||
| 1919 | - | return log.WrapErr(err, "failed to pull image") | 1923 | + | return log.WrapErr(fmt.Errorf("%w: %w", domain.ErrImagePullFailed, err), "failed to pull image") |
| 1920 | } | 1924 | } | ||
| 1921 | case cfg.RegistryAuthEnabled: | 1925 | case cfg.RegistryAuthEnabled: | ||
| 1922 | if cfg.ServiceTokenUsername == "" || cfg.ServiceToken == "" { | 1926 | if cfg.ServiceTokenUsername == "" || cfg.ServiceToken == "" { | ||
| 1923 | return log.WrapErr(fmt.Errorf("registry service token not configured"), "failed to pull image for registry auth") | 1927 | return log.WrapErr(fmt.Errorf("registry service token not configured"), "failed to pull image for registry auth") | ||
| 1924 | } | 1928 | } | ||
| 1925 | if err := s.runtime.PullImageWithAuth(ctx, pullRef, cfg.ServiceTokenUsername, cfg.ServiceToken); err != nil { | 1929 | if err := s.runtime.PullImageWithAuth(ctx, pullRef, cfg.ServiceTokenUsername, cfg.ServiceToken); err != nil { | ||
| 1926 | - | return log.WrapErr(err, "failed to pull image with auth") | 1930 | + | return log.WrapErr(fmt.Errorf("%w: %w", domain.ErrImagePullFailed, err), "failed to pull image with auth") |
| 1927 | } | 1931 | } | ||
| 1928 | default: | 1932 | default: | ||
| 1929 | if err := s.runtime.PullImage(ctx, pullRef); err != nil { | 1933 | if err := s.runtime.PullImage(ctx, pullRef); err != nil { | ||
| 1930 | - | return log.WrapErr(err, "failed to pull image") | 1934 | + | return log.WrapErr(fmt.Errorf("%w: %w", domain.ErrImagePullFailed, err), "failed to pull image") |
| 1931 | } | 1935 | } | ||
| 1932 | } | 1936 | } | ||
| 1933 | 1937 | ||||
M internal/usecase/container/service_test.go +7 -0
| @@ -499,9 +499,16 @@ func TestService_Deploy_ImagePullFailure(t *testing.T) { | |||
| 499 | 499 | assert.Equal(t, "verify registry auth and confirm the image exists at the requested tag", deployErr.Hint) | |
| 500 | 500 | assert.Equal(t, "gordon-test.example.com", deployErr.ContainerName) | |
| 501 | 501 | assert.Empty(t, deployErr.ContainerID) | |
| 502 | + | assert.ErrorIs(t, deployErr.Err, domain.ErrImagePullFailed) | |
| 502 | 503 | assert.ErrorContains(t, deployErr.Err, "failed to pull image") | |
| 503 | 504 | } | |
| 504 | 505 | ||
| 506 | + | func TestIsPullFailure_ErrImagePullFailed(t *testing.T) { | |
| 507 | + | err := fmt.Errorf("wrapped: %w", domain.ErrImagePullFailed) | |
| 508 | + | ||
| 509 | + | assert.True(t, isPullFailure(err)) | |
| 510 | + | } | |
| 511 | + | ||
| 505 | 512 | func TestService_Deploy_DoesNotMisclassifyUnauthorizedEnvLoadFailure(t *testing.T) { | |
| 506 | 513 | runtime := mocks.NewMockContainerRuntime(t) | |
| 507 | 514 | envLoader := mocks.NewMockEnvLoader(t) | |
| @@ -499,9 +499,16 @@ func TestService_Deploy_ImagePullFailure(t *testing.T) { | |||||
| 499 | assert.Equal(t, "verify registry auth and confirm the image exists at the requested tag", deployErr.Hint) | 499 | assert.Equal(t, "verify registry auth and confirm the image exists at the requested tag", deployErr.Hint) | ||
| 500 | assert.Equal(t, "gordon-test.example.com", deployErr.ContainerName) | 500 | assert.Equal(t, "gordon-test.example.com", deployErr.ContainerName) | ||
| 501 | assert.Empty(t, deployErr.ContainerID) | 501 | assert.Empty(t, deployErr.ContainerID) | ||
| 502 | + | assert.ErrorIs(t, deployErr.Err, domain.ErrImagePullFailed) | |||
| 502 | assert.ErrorContains(t, deployErr.Err, "failed to pull image") | 503 | assert.ErrorContains(t, deployErr.Err, "failed to pull image") | ||
| 503 | } | 504 | } | ||
| 504 | 505 | ||||
| 506 | + | func TestIsPullFailure_ErrImagePullFailed(t *testing.T) { | |||
| 507 | + | err := fmt.Errorf("wrapped: %w", domain.ErrImagePullFailed) | |||
| 508 | + | ||||
| 509 | + | assert.True(t, isPullFailure(err)) | |||
| 510 | + | } | |||
| 511 | + | ||||
| 505 | func TestService_Deploy_DoesNotMisclassifyUnauthorizedEnvLoadFailure(t *testing.T) { | 512 | func TestService_Deploy_DoesNotMisclassifyUnauthorizedEnvLoadFailure(t *testing.T) { | ||
| 506 | runtime := mocks.NewMockContainerRuntime(t) | 513 | runtime := mocks.NewMockContainerRuntime(t) | ||
| 507 | envLoader := mocks.NewMockEnvLoader(t) | 514 | envLoader := mocks.NewMockEnvLoader(t) | ||