detached f8d5e7c

fix(container): preserve image pull failure sentinel

commit
f8d5e7c4a8724739453d00db1ec091fae6bba5f3
author
brice · · 2026-04-08 18:48 UTC
parent
7cfa412
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 {
468468
469469 reader, err := r.client.ImagePull(ctx, imageRef, image.PullOptions{})
470470 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")
472472 }
473473 defer reader.Close()
474474
@@ -528,7 +528,7 @@ func (r *Runtime) PullImageWithAuth(ctx context.Context, imageRef, username, pas
528528 RegistryAuth: authStr,
529529 })
530530 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")
532532 }
533533 defer reader.Close()
534534
@@ -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 {
835835 return false
836836 }
837837
838+ if errors.Is(err, domain.ErrImagePullFailed) {
839+ return true
840+ }
841+
838842 msg := strings.ToLower(err.Error())
839843 return strings.Contains(msg, "failed to pull image") ||
840844 strings.Contains(msg, "pull access denied")
@@ -1910,24 +1914,24 @@ func (s *Service) pullImage(ctx context.Context, pullRef string, isInternal bool
19101914 if err := s.pullImageWithRetry(ctx, func(ctx context.Context) error {
19111915 return s.runtime.PullImageWithAuth(ctx, pullRef, cfg.InternalRegistryUsername, cfg.InternalRegistryPassword)
19121916 }, 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")
19141918 }
19151919 case isInternal:
19161920 if err := s.pullImageWithRetry(ctx, func(ctx context.Context) error {
19171921 return s.runtime.PullImage(ctx, pullRef)
19181922 }, 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")
19201924 }
19211925 case cfg.RegistryAuthEnabled:
19221926 if cfg.ServiceTokenUsername == "" || cfg.ServiceToken == "" {
19231927 return log.WrapErr(fmt.Errorf("registry service token not configured"), "failed to pull image for registry auth")
19241928 }
19251929 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")
19271931 }
19281932 default:
19291933 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")
19311935 }
19321936 }
19331937
@@ -835,6 +835,10 @@ func isPullFailure(err error) bool {
835 return false835 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) {
499499 assert.Equal(t, "verify registry auth and confirm the image exists at the requested tag", deployErr.Hint)
500500 assert.Equal(t, "gordon-test.example.com", deployErr.ContainerName)
501501 assert.Empty(t, deployErr.ContainerID)
502+ assert.ErrorIs(t, deployErr.Err, domain.ErrImagePullFailed)
502503 assert.ErrorContains(t, deployErr.Err, "failed to pull image")
503504 }
504505
506+func TestIsPullFailure_ErrImagePullFailed(t *testing.T) {
507+ err := fmt.Errorf("wrapped: %w", domain.ErrImagePullFailed)
508+
509+ assert.True(t, isPullFailure(err))
510+}
511+
505512 func TestService_Deploy_DoesNotMisclassifyUnauthorizedEnvLoadFailure(t *testing.T) {
506513 runtime := mocks.NewMockContainerRuntime(t)
507514 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)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close