detached 25b0699

feat(deploy): add configurable image pull policy

commit
25b069916b349eaecb2df03f8a9378dbf9193612
author
bnema · · 2026-01-16 20:50 UTC
parent
382b937
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +89 -4
A docs/config/deploy.md +26 -0
@@ -0,0 +1,26 @@
1+# Deploy Configuration
2+
3+Controls how Gordon pulls images during deployment.
4+
5+## Example
6+
7+```toml
8+[deploy]
9+pull_policy = "if-tag-changed"
10+```
11+
12+## Settings
13+
14+### `deploy.pull_policy`
15+
16+How to decide when to pull an image:
17+
18+- `always`: always pull, even if the tag exists locally.
19+- `if-not-present`: only pull when the tag is missing locally.
20+- `if-tag-changed`: pull for tag references to check for updates; skip pulls for digest references (`@sha256:...`).
21+
22+Default: `if-tag-changed`
23+
24+## Notes
25+
26+- Changes to `deploy.pull_policy` require a restart to take effect.
@@ -0,0 +1,26 @@
1+# Deploy Configuration
2+
3+Controls how Gordon pulls images during deployment.
4+
5+## Example
6+
7+```toml
8+[deploy]
9+pull_policy = "if-tag-changed"
10+```
11+
12+## Settings
13+
14+### `deploy.pull_policy`
15+
16+How to decide when to pull an image:
17+
18+- `always`: always pull, even if the tag exists locally.
19+- `if-not-present`: only pull when the tag is missing locally.
20+- `if-tag-changed`: pull for tag references to check for updates; skip pulls for digest references (`@sha256:...`).
21+
22+Default: `if-tag-changed`
23+
24+## Notes
25+
26+- Changes to `deploy.pull_policy` require a restart to take effect.
M docs/config/index.md +10 -3
@@ -7,7 +7,7 @@ Gordon uses a single TOML configuration file located at `~/.config/gordon/gordon
77 | Location | Purpose |
88 |----------|---------|
99 | `~/.config/gordon/gordon.toml` | Default configuration file |
10-| Custom path via `--config` flag | `gordon start --config /path/to/config.toml` |
10+| Custom path via `--config` flag | `gordon serve --config /path/to/config.toml` |
1111
1212 ## Minimal Configuration
1313
@@ -48,6 +48,10 @@ type = "token" # "password" or "token"
4848 token_secret = "gordon/registry/token_secret"
4949 token_expiry = "720h" # Duration or 0 for never
5050
51+# Deploy behavior
52+[deploy]
53+pull_policy = "if-tag-changed" # always, if-not-present, if-tag-changed
54+
5155 # Logging
5256 [logging]
5357 level = "info" # trace, debug, info, warn, error
@@ -109,6 +113,7 @@ enabled = false # Auto-create routes from image names
109113 | `[server]` | Core server settings | [Server](./server.md) |
110114 | `[secrets]` | Secrets backend configuration | [Secrets](./secrets.md) |
111115 | `[registry_auth]` | Registry authentication | [Registry Auth](./registry-auth.md) |
116+| `[deploy]` | Deployment behavior | [Deploy](./deploy.md) |
112117 | `[logging]` | Logging configuration | [Logging](./logging.md) |
113118 | `[env]` | Environment variable settings | [Environment](./env.md) |
114119 | `[volumes]` | Volume management | [Volumes](./volumes.md) |
@@ -130,6 +135,7 @@ enabled = false # Auto-create routes from image names
130135 | `registry_auth.enabled` | `false` |
131136 | `registry_auth.type` | `"password"` |
132137 | `registry_auth.token_expiry` | `"720h"` |
138+| `deploy.pull_policy` | `"if-tag-changed"` |
133139 | `logging.level` | `"info"` |
134140 | `logging.format` | `"console"` |
135141 | `logging.file.enabled` | `false` |
@@ -158,14 +164,15 @@ The following settings require a restart to take effect:
158164 - `server.data_dir`
159165 - `secrets.backend`
160166 - `registry_auth` settings
167+- `deploy.pull_policy`
161168
162169 ## Environment Variable Override
163170
164171 Configuration values can be overridden with environment variables:
165172
166173 ```bash
167-GORDON_SERVER_PORT=8080 gordon start
168-GORDON_LOGGING_LEVEL=debug gordon start
174+GORDON_SERVER_PORT=8080 gordon serve
175+GORDON_LOGGING_LEVEL=debug gordon serve
169176 ```
170177
171178 Pattern: `GORDON_SECTION_KEY` (uppercase, underscores instead of dots)
@@ -7,7 +7,7 @@ Gordon uses a single TOML configuration file located at `~/.config/gordon/gordon
7 | Location | Purpose |7 | Location | Purpose |
8 |----------|---------|8 |----------|---------|
9 | `~/.config/gordon/gordon.toml` | Default configuration file |9 | `~/.config/gordon/gordon.toml` | Default configuration file |
10-| Custom path via `--config` flag | `gordon start --config /path/to/config.toml` |10+| Custom path via `--config` flag | `gordon serve --config /path/to/config.toml` |
11 11
12 ## Minimal Configuration12 ## Minimal Configuration
13 13
@@ -48,6 +48,10 @@ type = "token" # "password" or "token"
48 token_secret = "gordon/registry/token_secret"48 token_secret = "gordon/registry/token_secret"
49 token_expiry = "720h" # Duration or 0 for never49 token_expiry = "720h" # Duration or 0 for never
50 50
51+# Deploy behavior
52+[deploy]
53+pull_policy = "if-tag-changed" # always, if-not-present, if-tag-changed
54+
51 # Logging55 # Logging
52 [logging]56 [logging]
53 level = "info" # trace, debug, info, warn, error57 level = "info" # trace, debug, info, warn, error
@@ -109,6 +113,7 @@ enabled = false # Auto-create routes from image names
109 | `[server]` | Core server settings | [Server](./server.md) |113 | `[server]` | Core server settings | [Server](./server.md) |
110 | `[secrets]` | Secrets backend configuration | [Secrets](./secrets.md) |114 | `[secrets]` | Secrets backend configuration | [Secrets](./secrets.md) |
111 | `[registry_auth]` | Registry authentication | [Registry Auth](./registry-auth.md) |115 | `[registry_auth]` | Registry authentication | [Registry Auth](./registry-auth.md) |
116+| `[deploy]` | Deployment behavior | [Deploy](./deploy.md) |
112 | `[logging]` | Logging configuration | [Logging](./logging.md) |117 | `[logging]` | Logging configuration | [Logging](./logging.md) |
113 | `[env]` | Environment variable settings | [Environment](./env.md) |118 | `[env]` | Environment variable settings | [Environment](./env.md) |
114 | `[volumes]` | Volume management | [Volumes](./volumes.md) |119 | `[volumes]` | Volume management | [Volumes](./volumes.md) |
@@ -130,6 +135,7 @@ enabled = false # Auto-create routes from image names
130 | `registry_auth.enabled` | `false` |135 | `registry_auth.enabled` | `false` |
131 | `registry_auth.type` | `"password"` |136 | `registry_auth.type` | `"password"` |
132 | `registry_auth.token_expiry` | `"720h"` |137 | `registry_auth.token_expiry` | `"720h"` |
138+| `deploy.pull_policy` | `"if-tag-changed"` |
133 | `logging.level` | `"info"` |139 | `logging.level` | `"info"` |
134 | `logging.format` | `"console"` |140 | `logging.format` | `"console"` |
135 | `logging.file.enabled` | `false` |141 | `logging.file.enabled` | `false` |
@@ -158,14 +164,15 @@ The following settings require a restart to take effect:
158 - `server.data_dir`164 - `server.data_dir`
159 - `secrets.backend`165 - `secrets.backend`
160 - `registry_auth` settings166 - `registry_auth` settings
167+- `deploy.pull_policy`
161 168
162 ## Environment Variable Override169 ## Environment Variable Override
163 170
164 Configuration values can be overridden with environment variables:171 Configuration values can be overridden with environment variables:
165 172
166 ```bash173 ```bash
167-GORDON_SERVER_PORT=8080 gordon start174+GORDON_SERVER_PORT=8080 gordon serve
168-GORDON_LOGGING_LEVEL=debug gordon start175+GORDON_LOGGING_LEVEL=debug gordon serve
169 ```176 ```
170 177
171 Pattern: `GORDON_SECTION_KEY` (uppercase, underscores instead of dots)178 Pattern: `GORDON_SECTION_KEY` (uppercase, underscores instead of dots)
M internal/usecase/container/service.go +53 -1
@@ -25,6 +25,7 @@ type Config struct {
2525 RegistryPassword string
2626 InternalRegistryUsername string
2727 InternalRegistryPassword string
28+ PullPolicy string
2829 VolumeAutoCreate bool
2930 VolumePrefix string
3031 VolumePreserve bool
@@ -36,6 +37,12 @@ type Config struct {
3637 ReadinessDelay time.Duration // Delay after container starts before considering it ready
3738 }
3839
40+const (
41+ PullPolicyAlways = "always"
42+ PullPolicyIfNotPresent = "if-not-present"
43+ PullPolicyIfTagChanged = "if-tag-changed"
44+)
45+
3946 // Service implements the ContainerService interface.
4047 type Service struct {
4148 runtime out.ContainerRuntime
@@ -513,6 +520,23 @@ func (s *Service) buildImageRef(image string) string {
513520 return fmt.Sprintf("%s/%s", s.config.RegistryDomain, image)
514521 }
515522
523+func normalizePullPolicy(policy string) string {
524+ switch strings.ToLower(strings.TrimSpace(policy)) {
525+ case PullPolicyAlways:
526+ return PullPolicyAlways
527+ case PullPolicyIfTagChanged:
528+ return PullPolicyIfTagChanged
529+ case PullPolicyIfNotPresent:
530+ return PullPolicyIfNotPresent
531+ default:
532+ return PullPolicyIfNotPresent
533+ }
534+}
535+
536+func isDigestRef(imageRef string) bool {
537+ return strings.Contains(imageRef, "@sha256:")
538+}
539+
516540 func (s *Service) pullRefForDeploy(ctx context.Context, imageRef string) (string, bool) {
517541 if !domain.IsInternalDeploy(ctx) {
518542 return imageRef, false
@@ -521,7 +545,7 @@ func (s *Service) pullRefForDeploy(ctx context.Context, imageRef string) (string
521545 }
522546
523547 // ensureImage ensures the image is available locally, pulling if needed.
524-// Returns the canonical image reference to use for container operations.
548+// Returns the image reference to use for container operations.
525549 func (s *Service) ensureImage(ctx context.Context, imageRef string) (string, error) {
526550 ctx = zerowrap.CtxWithField(ctx, "image", imageRef)
527551 log := zerowrap.FromCtx(ctx)
@@ -550,6 +574,15 @@ func (s *Service) ensureImage(ctx context.Context, imageRef string) (string, err
550574 return "", err
551575 }
552576
577+ // For digest references, we can't create a tag (Docker doesn't allow it).
578+ // In this case, use the pullRef directly since the image is available under that reference.
579+ if strings.Contains(imageRef, "@sha256:") {
580+ log.Info().
581+ Str("pull_ref", pullRef).
582+ Msg("digest reference: using pull reference for container operations")
583+ return pullRef, nil
584+ }
585+
553586 if err := s.tagImageIfNeeded(ctx, pullRef, imageRef); err != nil {
554587 return "", err
555588 }
@@ -576,6 +609,18 @@ func (s *Service) ensureLocalImage(ctx context.Context, imageRef, pullRef string
576609 return false, nil
577610 }
578611
612+ pullPolicy := normalizePullPolicy(s.config.PullPolicy)
613+ switch pullPolicy {
614+ case PullPolicyAlways:
615+ log.Info().Str("pull_policy", pullPolicy).Msg("pull policy forces image pull")
616+ return false, nil
617+ case PullPolicyIfTagChanged:
618+ if !isDigestRef(imageRef) {
619+ log.Info().Str("pull_policy", pullPolicy).Msg("tag reference detected, pulling to check for updates")
620+ return false, nil
621+ }
622+ }
623+
579624 localImages, err := s.runtime.ListImages(ctx)
580625 if err != nil {
581626 log.WrapErr(err, "failed to list local images, will attempt pull")
@@ -641,6 +686,13 @@ func (s *Service) tagImageIfNeeded(ctx context.Context, sourceRef, targetRef str
641686 return nil
642687 }
643688
689+ // Cannot create a tag with a digest reference - Docker/Podman doesn't allow it.
690+ // When using digest references (image@sha256:...), skip tagging as the image
691+ // is already available by its digest.
692+ if strings.Contains(targetRef, "@sha256:") {
693+ return nil
694+ }
695+
644696 log := zerowrap.FromCtx(ctx)
645697 if err := s.runtime.TagImage(ctx, sourceRef, targetRef); err != nil {
646698 return log.WrapErr(err, "failed to tag image from pull reference")
@@ -25,6 +25,7 @@ type Config struct {
25 RegistryPassword string25 RegistryPassword string
26 InternalRegistryUsername string26 InternalRegistryUsername string
27 InternalRegistryPassword string27 InternalRegistryPassword string
28+ PullPolicy string
28 VolumeAutoCreate bool29 VolumeAutoCreate bool
29 VolumePrefix string30 VolumePrefix string
30 VolumePreserve bool31 VolumePreserve bool
@@ -36,6 +37,12 @@ type Config struct {
36 ReadinessDelay time.Duration // Delay after container starts before considering it ready37 ReadinessDelay time.Duration // Delay after container starts before considering it ready
37 }38 }
38 39
40+const (
41+ PullPolicyAlways = "always"
42+ PullPolicyIfNotPresent = "if-not-present"
43+ PullPolicyIfTagChanged = "if-tag-changed"
44+)
45+
39 // Service implements the ContainerService interface.46 // Service implements the ContainerService interface.
40 type Service struct {47 type Service struct {
41 runtime out.ContainerRuntime48 runtime out.ContainerRuntime
@@ -513,6 +520,23 @@ func (s *Service) buildImageRef(image string) string {
513 return fmt.Sprintf("%s/%s", s.config.RegistryDomain, image)520 return fmt.Sprintf("%s/%s", s.config.RegistryDomain, image)
514 }521 }
515 522
523+func normalizePullPolicy(policy string) string {
524+ switch strings.ToLower(strings.TrimSpace(policy)) {
525+ case PullPolicyAlways:
526+ return PullPolicyAlways
527+ case PullPolicyIfTagChanged:
528+ return PullPolicyIfTagChanged
529+ case PullPolicyIfNotPresent:
530+ return PullPolicyIfNotPresent
531+ default:
532+ return PullPolicyIfNotPresent
533+ }
534+}
535+
536+func isDigestRef(imageRef string) bool {
537+ return strings.Contains(imageRef, "@sha256:")
538+}
539+
516 func (s *Service) pullRefForDeploy(ctx context.Context, imageRef string) (string, bool) {540 func (s *Service) pullRefForDeploy(ctx context.Context, imageRef string) (string, bool) {
517 if !domain.IsInternalDeploy(ctx) {541 if !domain.IsInternalDeploy(ctx) {
518 return imageRef, false542 return imageRef, false
@@ -521,7 +545,7 @@ func (s *Service) pullRefForDeploy(ctx context.Context, imageRef string) (string
521 }545 }
522 546
523 // ensureImage ensures the image is available locally, pulling if needed.547 // ensureImage ensures the image is available locally, pulling if needed.
524-// Returns the canonical image reference to use for container operations.548+// Returns the image reference to use for container operations.
525 func (s *Service) ensureImage(ctx context.Context, imageRef string) (string, error) {549 func (s *Service) ensureImage(ctx context.Context, imageRef string) (string, error) {
526 ctx = zerowrap.CtxWithField(ctx, "image", imageRef)550 ctx = zerowrap.CtxWithField(ctx, "image", imageRef)
527 log := zerowrap.FromCtx(ctx)551 log := zerowrap.FromCtx(ctx)
@@ -550,6 +574,15 @@ func (s *Service) ensureImage(ctx context.Context, imageRef string) (string, err
550 return "", err574 return "", err
551 }575 }
552 576
577+ // For digest references, we can't create a tag (Docker doesn't allow it).
578+ // In this case, use the pullRef directly since the image is available under that reference.
579+ if strings.Contains(imageRef, "@sha256:") {
580+ log.Info().
581+ Str("pull_ref", pullRef).
582+ Msg("digest reference: using pull reference for container operations")
583+ return pullRef, nil
584+ }
585+
553 if err := s.tagImageIfNeeded(ctx, pullRef, imageRef); err != nil {586 if err := s.tagImageIfNeeded(ctx, pullRef, imageRef); err != nil {
554 return "", err587 return "", err
555 }588 }
@@ -576,6 +609,18 @@ func (s *Service) ensureLocalImage(ctx context.Context, imageRef, pullRef string
576 return false, nil609 return false, nil
577 }610 }
578 611
612+ pullPolicy := normalizePullPolicy(s.config.PullPolicy)
613+ switch pullPolicy {
614+ case PullPolicyAlways:
615+ log.Info().Str("pull_policy", pullPolicy).Msg("pull policy forces image pull")
616+ return false, nil
617+ case PullPolicyIfTagChanged:
618+ if !isDigestRef(imageRef) {
619+ log.Info().Str("pull_policy", pullPolicy).Msg("tag reference detected, pulling to check for updates")
620+ return false, nil
621+ }
622+ }
623+
579 localImages, err := s.runtime.ListImages(ctx)624 localImages, err := s.runtime.ListImages(ctx)
580 if err != nil {625 if err != nil {
581 log.WrapErr(err, "failed to list local images, will attempt pull")626 log.WrapErr(err, "failed to list local images, will attempt pull")
@@ -641,6 +686,13 @@ func (s *Service) tagImageIfNeeded(ctx context.Context, sourceRef, targetRef str
641 return nil686 return nil
642 }687 }
643 688
689+ // Cannot create a tag with a digest reference - Docker/Podman doesn't allow it.
690+ // When using digest references (image@sha256:...), skip tagging as the image
691+ // is already available by its digest.
692+ if strings.Contains(targetRef, "@sha256:") {
693+ return nil
694+ }
695+
644 log := zerowrap.FromCtx(ctx)696 log := zerowrap.FromCtx(ctx)
645 if err := s.runtime.TagImage(ctx, sourceRef, targetRef); err != nil {697 if err := s.runtime.TagImage(ctx, sourceRef, targetRef); err != nil {
646 return log.WrapErr(err, "failed to tag image from pull reference")698 return log.WrapErr(err, "failed to tag image from pull reference")

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