detached ee1db19

fix: address PR review comments from Copilot

- Use sentence case for proxy error messages (proxy/service.go)
- Rollback route if Save() fails in AddRoute (config/service.go)
- Improve ensureImage comment to clarify return values (container/service.go)
- Handle file.Close() error for writable files (admin/secrets.go)
- Add missing viper defaults for config options (app/run.go)
- Update test to use temp config file for AddRoute
commit
ee1db199f3ae9bf0f5ce56597e77af682bdfec11
author
bnema · · 2026-01-16 21:18 UTC
parent
d22c734
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
6 files · +40 -7
M internal/adapters/in/http/admin/secrets.go +6 -2
@@ -127,7 +127,7 @@ func (h *Handler) deleteSecret(domain, key string) error {
127127 }
128128
129129 // writeSecrets writes all secrets to the domain's env file.
130-func (h *Handler) writeSecrets(domain string, secrets map[string]string) error {
130+func (h *Handler) writeSecrets(domain string, secrets map[string]string) (err error) {
131131 envFile := h.domainToEnvFile(domain)
132132
133133 // Create/truncate file with secure permissions
@@ -135,7 +135,11 @@ func (h *Handler) writeSecrets(domain string, secrets map[string]string) error {
135135 if err != nil {
136136 return fmt.Errorf("failed to create env file: %w", err)
137137 }
138- defer file.Close()
138+ defer func() {
139+ if cerr := file.Close(); cerr != nil && err == nil {
140+ err = fmt.Errorf("failed to close env file: %w", cerr)
141+ }
142+ }()
139143
140144 // Write header comment
141145 if _, err := fmt.Fprintf(file, "# Environment variables for %s\n", domain); err != nil {
@@ -127,7 +127,7 @@ func (h *Handler) deleteSecret(domain, key string) error {
127 }127 }
128 128
129 // writeSecrets writes all secrets to the domain's env file.129 // writeSecrets writes all secrets to the domain's env file.
130-func (h *Handler) writeSecrets(domain string, secrets map[string]string) error {130+func (h *Handler) writeSecrets(domain string, secrets map[string]string) (err error) {
131 envFile := h.domainToEnvFile(domain)131 envFile := h.domainToEnvFile(domain)
132 132
133 // Create/truncate file with secure permissions133 // Create/truncate file with secure permissions
@@ -135,7 +135,11 @@ func (h *Handler) writeSecrets(domain string, secrets map[string]string) error {
135 if err != nil {135 if err != nil {
136 return fmt.Errorf("failed to create env file: %w", err)136 return fmt.Errorf("failed to create env file: %w", err)
137 }137 }
138- defer file.Close()138+ defer func() {
139+ if cerr := file.Close(); cerr != nil && err == nil {
140+ err = fmt.Errorf("failed to close env file: %w", cerr)
141+ }
142+ }()
139 143
140 // Write header comment144 // Write header comment
141 if _, err := fmt.Fprintf(file, "# Environment variables for %s\n", domain); err != nil {145 if _, err := fmt.Fprintf(file, "# Environment variables for %s\n", domain); err != nil {
M internal/app/run.go +7 -0
@@ -1098,6 +1098,13 @@ func loadConfig(v *viper.Viper, configPath string) error {
10981098 v.SetDefault("registry_auth.enabled", false)
10991099 v.SetDefault("registry_auth.type", "password")
11001100 v.SetDefault("registry_auth.token_expiry", "720h")
1101+ v.SetDefault("auto_route.enabled", false)
1102+ v.SetDefault("network_isolation.enabled", false)
1103+ v.SetDefault("network_isolation.network_prefix", "gordon")
1104+ v.SetDefault("network_isolation.dns_suffix", ".internal")
1105+ v.SetDefault("volumes.auto_create", true)
1106+ v.SetDefault("volumes.prefix", "gordon")
1107+ v.SetDefault("volumes.preserve", true)
11011108 v.SetDefault("deploy.pull_policy", container.PullPolicyIfTagChanged)
11021109
11031110 ConfigureViper(v, configPath)
@@ -1098,6 +1098,13 @@ func loadConfig(v *viper.Viper, configPath string) error {
1098 v.SetDefault("registry_auth.enabled", false)1098 v.SetDefault("registry_auth.enabled", false)
1099 v.SetDefault("registry_auth.type", "password")1099 v.SetDefault("registry_auth.type", "password")
1100 v.SetDefault("registry_auth.token_expiry", "720h")1100 v.SetDefault("registry_auth.token_expiry", "720h")
1101+ v.SetDefault("auto_route.enabled", false)
1102+ v.SetDefault("network_isolation.enabled", false)
1103+ v.SetDefault("network_isolation.network_prefix", "gordon")
1104+ v.SetDefault("network_isolation.dns_suffix", ".internal")
1105+ v.SetDefault("volumes.auto_create", true)
1106+ v.SetDefault("volumes.prefix", "gordon")
1107+ v.SetDefault("volumes.preserve", true)
1101 v.SetDefault("deploy.pull_policy", container.PullPolicyIfTagChanged)1108 v.SetDefault("deploy.pull_policy", container.PullPolicyIfTagChanged)
1102 1109
1103 ConfigureViper(v, configPath)1110 ConfigureViper(v, configPath)
M internal/usecase/config/service.go +12 -2
@@ -185,16 +185,26 @@ func (s *Service) AddRoute(ctx context.Context, route domain.Route) error {
185185 })
186186 log := zerowrap.FromCtx(ctx)
187187
188+ // Store previous value for rollback
188189 s.mu.Lock()
190+ previousImage, existed := s.config.Routes[route.Domain]
189191 if s.config.Routes == nil {
190192 s.config.Routes = make(map[string]string)
191193 }
192194 s.config.Routes[route.Domain] = route.Image
193195 s.mu.Unlock()
194196
195- // Persist to disk
197+ // Persist to disk - rollback on failure
196198 if err := s.Save(ctx); err != nil {
197- log.Warn().Err(err).Msg("failed to persist route to disk")
199+ log.Warn().Err(err).Msg("failed to persist route to disk, rolling back")
200+ s.mu.Lock()
201+ if existed {
202+ s.config.Routes[route.Domain] = previousImage
203+ } else {
204+ delete(s.config.Routes, route.Domain)
205+ }
206+ s.mu.Unlock()
207+ return err
198208 }
199209
200210 log.Info().Str("image", route.Image).Msg("route added to configuration")
@@ -185,16 +185,26 @@ func (s *Service) AddRoute(ctx context.Context, route domain.Route) error {
185 })185 })
186 log := zerowrap.FromCtx(ctx)186 log := zerowrap.FromCtx(ctx)
187 187
188+ // Store previous value for rollback
188 s.mu.Lock()189 s.mu.Lock()
190+ previousImage, existed := s.config.Routes[route.Domain]
189 if s.config.Routes == nil {191 if s.config.Routes == nil {
190 s.config.Routes = make(map[string]string)192 s.config.Routes = make(map[string]string)
191 }193 }
192 s.config.Routes[route.Domain] = route.Image194 s.config.Routes[route.Domain] = route.Image
193 s.mu.Unlock()195 s.mu.Unlock()
194 196
195- // Persist to disk197+ // Persist to disk - rollback on failure
196 if err := s.Save(ctx); err != nil {198 if err := s.Save(ctx); err != nil {
197- log.Warn().Err(err).Msg("failed to persist route to disk")199+ log.Warn().Err(err).Msg("failed to persist route to disk, rolling back")
200+ s.mu.Lock()
201+ if existed {
202+ s.config.Routes[route.Domain] = previousImage
203+ } else {
204+ delete(s.config.Routes, route.Domain)
205+ }
206+ s.mu.Unlock()
207+ return err
198 }208 }
199 209
200 log.Info().Str("image", route.Image).Msg("route added to configuration")210 log.Info().Str("image", route.Image).Msg("route added to configuration")
M internal/usecase/config/service_test.go +11 -1
@@ -2,11 +2,14 @@ package config
22
33 import (
44 "context"
5+ "os"
6+ "path/filepath"
57 "testing"
68
79 "github.com/bnema/zerowrap"
810 "github.com/spf13/viper"
911 "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
1013
1114 "gordon/internal/boundaries/out/mocks"
1215 "gordon/internal/domain"
@@ -96,7 +99,14 @@ func TestService_GetRoutes(t *testing.T) {
9699 }
97100
98101 func TestService_AddRoute(t *testing.T) {
102+ // Create temp config file for Save() to work
103+ tmpDir := t.TempDir()
104+ configFile := filepath.Join(tmpDir, "gordon.toml")
105+ err := os.WriteFile(configFile, []byte("[routes]\n"), 0600)
106+ require.NoError(t, err)
107+
99108 v := viper.New()
109+ v.SetConfigFile(configFile)
100110 eventBus := mocks.NewMockEventPublisher(t)
101111 svc := NewService(v, eventBus)
102112 ctx := testContext()
@@ -108,7 +118,7 @@ func TestService_AddRoute(t *testing.T) {
108118 Image: "newapp:latest",
109119 }
110120
111- err := svc.AddRoute(ctx, route)
121+ err = svc.AddRoute(ctx, route)
112122
113123 assert.NoError(t, err)
114124
@@ -2,11 +2,14 @@ package config
2 2
3 import (3 import (
4 "context"4 "context"
5+ "os"
6+ "path/filepath"
5 "testing"7 "testing"
6 8
7 "github.com/bnema/zerowrap"9 "github.com/bnema/zerowrap"
8 "github.com/spf13/viper"10 "github.com/spf13/viper"
9 "github.com/stretchr/testify/assert"11 "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/require"
10 13
11 "gordon/internal/boundaries/out/mocks"14 "gordon/internal/boundaries/out/mocks"
12 "gordon/internal/domain"15 "gordon/internal/domain"
@@ -96,7 +99,14 @@ func TestService_GetRoutes(t *testing.T) {
96 }99 }
97 100
98 func TestService_AddRoute(t *testing.T) {101 func TestService_AddRoute(t *testing.T) {
102+ // Create temp config file for Save() to work
103+ tmpDir := t.TempDir()
104+ configFile := filepath.Join(tmpDir, "gordon.toml")
105+ err := os.WriteFile(configFile, []byte("[routes]\n"), 0600)
106+ require.NoError(t, err)
107+
99 v := viper.New()108 v := viper.New()
109+ v.SetConfigFile(configFile)
100 eventBus := mocks.NewMockEventPublisher(t)110 eventBus := mocks.NewMockEventPublisher(t)
101 svc := NewService(v, eventBus)111 svc := NewService(v, eventBus)
102 ctx := testContext()112 ctx := testContext()
@@ -108,7 +118,7 @@ func TestService_AddRoute(t *testing.T) {
108 Image: "newapp:latest",118 Image: "newapp:latest",
109 }119 }
110 120
111- err := svc.AddRoute(ctx, route)121+ err = svc.AddRoute(ctx, route)
112 122
113 assert.NoError(t, err)123 assert.NoError(t, err)
114 124
M internal/usecase/container/service.go +3 -1
@@ -545,7 +545,9 @@ func (s *Service) pullRefForDeploy(ctx context.Context, imageRef string) (string
545545 }
546546
547547 // ensureImage ensures the image is available locally, pulling if needed.
548-// Returns the image reference to use for container operations.
548+// Returns the image reference to use for container operations:
549+// - For digest references (@sha256:...), returns the pullRef since Docker can't tag digests
550+// - For tagged images, returns the original imageRef after tagging the pulled image
549551 func (s *Service) ensureImage(ctx context.Context, imageRef string) (string, error) {
550552 ctx = zerowrap.CtxWithField(ctx, "image", imageRef)
551553 log := zerowrap.FromCtx(ctx)
@@ -545,7 +545,9 @@ func (s *Service) pullRefForDeploy(ctx context.Context, imageRef string) (string
545 }545 }
546 546
547 // ensureImage ensures the image is available locally, pulling if needed.547 // ensureImage ensures the image is available locally, pulling if needed.
548-// Returns the image reference to use for container operations.548+// Returns the image reference to use for container operations:
549+// - For digest references (@sha256:...), returns the pullRef since Docker can't tag digests
550+// - For tagged images, returns the original imageRef after tagging the pulled image
549 func (s *Service) ensureImage(ctx context.Context, imageRef string) (string, error) {551 func (s *Service) ensureImage(ctx context.Context, imageRef string) (string, error) {
550 ctx = zerowrap.CtxWithField(ctx, "image", imageRef)552 ctx = zerowrap.CtxWithField(ctx, "image", imageRef)
551 log := zerowrap.FromCtx(ctx)553 log := zerowrap.FromCtx(ctx)
M internal/usecase/proxy/service.go +1 -1
@@ -307,7 +307,7 @@ func (s *Service) proxyToTarget(w http.ResponseWriter, r *http.Request, target *
307307
308308 proxy := newReverseProxy(targetURL,
309309 func(w http.ResponseWriter, _ *http.Request, err error) {
310- log.Error().Err(err).Str("target", targetURL.String()).Msg("PROXY ERROR - connection failed")
310+ log.Error().Err(err).Str("target", targetURL.String()).Msg("proxy error: connection failed")
311311 http.Error(w, "Service Unavailable", http.StatusServiceUnavailable)
312312 },
313313 func(resp *http.Response) error {
@@ -307,7 +307,7 @@ func (s *Service) proxyToTarget(w http.ResponseWriter, r *http.Request, target *
307 307
308 proxy := newReverseProxy(targetURL,308 proxy := newReverseProxy(targetURL,
309 func(w http.ResponseWriter, _ *http.Request, err error) {309 func(w http.ResponseWriter, _ *http.Request, err error) {
310- log.Error().Err(err).Str("target", targetURL.String()).Msg("PROXY ERROR - connection failed")310+ log.Error().Err(err).Str("target", targetURL.String()).Msg("proxy error: connection failed")
311 http.Error(w, "Service Unavailable", http.StatusServiceUnavailable)311 http.Error(w, "Service Unavailable", http.StatusServiceUnavailable)
312 },312 },
313 func(resp *http.Response) error {313 func(resp *http.Response) error {

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