detached e2d78b5

fix(preview): atomic writes, pinned images, structured logging, and error handling

- Atomic temp-file-rename writes in PreviewStore to prevent partial writes
- Pin busybox image to 1.37 instead of latest for reproducibility
- Add zerowrap structured logging to WaitForContainer and dispatcher Handle
- Handle filepath.Match errors in matchesTagPatterns (skip malformed patterns)
- Distinguish not-found vs internal errors in preview admin HTTP handlers
commit
e2d78b5d419639d1afea9b691acf86550f4fa23a
author
brice · · 2026-03-19 05:27 UTC
parent
9f0ec73
signature
unsigned
5 files · +57 -6
M internal/adapters/in/http/admin/preview_handler.go +10 -2
@@ -92,7 +92,11 @@ func (h *Handler) handlePreviewAction(w http.ResponseWriter, r *http.Request, pa
9292
9393 if err := h.previewSvc.Delete(ctx, name); err != nil {
9494 log.Error().Err(err).Str("name", name).Msg("failed to delete preview")
95- h.sendError(w, http.StatusNotFound, "preview not found")
95+ if strings.Contains(err.Error(), "not found") {
96+ http.Error(w, err.Error(), http.StatusNotFound)
97+ } else {
98+ http.Error(w, err.Error(), http.StatusInternalServerError)
99+ }
96100 return
97101 }
98102
@@ -127,7 +131,11 @@ func (h *Handler) handlePreviewAction(w http.ResponseWriter, r *http.Request, pa
127131
128132 if err := h.previewSvc.Extend(ctx, name, ttl); err != nil {
129133 log.Error().Err(err).Str("name", name).Dur("ttl", ttl).Msg("failed to extend preview TTL")
130- h.sendError(w, http.StatusNotFound, "preview not found")
134+ if strings.Contains(err.Error(), "not found") {
135+ http.Error(w, err.Error(), http.StatusNotFound)
136+ } else {
137+ http.Error(w, err.Error(), http.StatusInternalServerError)
138+ }
131139 return
132140 }
133141
@@ -92,7 +92,11 @@ func (h *Handler) handlePreviewAction(w http.ResponseWriter, r *http.Request, pa
92 92
93 if err := h.previewSvc.Delete(ctx, name); err != nil {93 if err := h.previewSvc.Delete(ctx, name); err != nil {
94 log.Error().Err(err).Str("name", name).Msg("failed to delete preview")94 log.Error().Err(err).Str("name", name).Msg("failed to delete preview")
95- h.sendError(w, http.StatusNotFound, "preview not found")95+ if strings.Contains(err.Error(), "not found") {
96+ http.Error(w, err.Error(), http.StatusNotFound)
97+ } else {
98+ http.Error(w, err.Error(), http.StatusInternalServerError)
99+ }
96 return100 return
97 }101 }
98 102
@@ -127,7 +131,11 @@ func (h *Handler) handlePreviewAction(w http.ResponseWriter, r *http.Request, pa
127 131
128 if err := h.previewSvc.Extend(ctx, name, ttl); err != nil {132 if err := h.previewSvc.Extend(ctx, name, ttl); err != nil {
129 log.Error().Err(err).Str("name", name).Dur("ttl", ttl).Msg("failed to extend preview TTL")133 log.Error().Err(err).Str("name", name).Dur("ttl", ttl).Msg("failed to extend preview TTL")
130- h.sendError(w, http.StatusNotFound, "preview not found")134+ if strings.Contains(err.Error(), "not found") {
135+ http.Error(w, err.Error(), http.StatusNotFound)
136+ } else {
137+ http.Error(w, err.Error(), http.StatusInternalServerError)
138+ }
131 return139 return
132 }140 }
133 141
M internal/adapters/out/docker/runtime.go +11 -1
@@ -216,14 +216,24 @@ func (r *Runtime) StartContainer(ctx context.Context, containerID string) error
216216
217217 // WaitForContainer waits for a container to stop running.
218218 func (r *Runtime) WaitForContainer(ctx context.Context, containerID string) error {
219+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
220+ zerowrap.FieldLayer: "adapter",
221+ zerowrap.FieldAdapter: "docker",
222+ zerowrap.FieldAction: "WaitForContainer",
223+ zerowrap.FieldEntityID: containerID,
224+ })
225+ log := zerowrap.FromCtx(ctx)
226+
219227 statusCh, errCh := r.client.ContainerWait(ctx, containerID, container.WaitConditionNotRunning)
220228 select {
221229 case err := <-errCh:
222230 if err != nil {
223- return err
231+ return log.WrapErr(err, "failed to wait for container")
224232 }
225233 case <-statusCh:
226234 }
235+
236+ log.Debug().Msg("container wait completed")
227237 return nil
228238 }
229239
@@ -216,14 +216,24 @@ func (r *Runtime) StartContainer(ctx context.Context, containerID string) error
216 216
217 // WaitForContainer waits for a container to stop running.217 // WaitForContainer waits for a container to stop running.
218 func (r *Runtime) WaitForContainer(ctx context.Context, containerID string) error {218 func (r *Runtime) WaitForContainer(ctx context.Context, containerID string) error {
219+ ctx = zerowrap.CtxWithFields(ctx, map[string]any{
220+ zerowrap.FieldLayer: "adapter",
221+ zerowrap.FieldAdapter: "docker",
222+ zerowrap.FieldAction: "WaitForContainer",
223+ zerowrap.FieldEntityID: containerID,
224+ })
225+ log := zerowrap.FromCtx(ctx)
226+
219 statusCh, errCh := r.client.ContainerWait(ctx, containerID, container.WaitConditionNotRunning)227 statusCh, errCh := r.client.ContainerWait(ctx, containerID, container.WaitConditionNotRunning)
220 select {228 select {
221 case err := <-errCh:229 case err := <-errCh:
222 if err != nil {230 if err != nil {
223- return err231+ return log.WrapErr(err, "failed to wait for container")
224 }232 }
225 case <-statusCh:233 case <-statusCh:
226 }234 }
235+
236+ log.Debug().Msg("container wait completed")
227 return nil237 return nil
228 }238 }
229 239
M internal/adapters/out/filesystem/previewstore.go +25 -1
@@ -6,6 +6,7 @@ import (
66 "errors"
77 "io/fs"
88 "os"
9+ "path/filepath"
910 "sync"
1011
1112 "github.com/bnema/gordon/internal/domain"
@@ -61,5 +62,28 @@ func (s *PreviewStore) Save(_ context.Context, previews []domain.PreviewRoute) e
6162 if err != nil {
6263 return err
6364 }
64- return os.WriteFile(s.path, data, 0o644)
65+
66+ // Atomic write: temp file → rename
67+ dir := filepath.Dir(s.path)
68+ tmp, err := os.CreateTemp(dir, ".previews-*.json.tmp")
69+ if err != nil {
70+ return err
71+ }
72+ tmpName := tmp.Name()
73+
74+ if _, err := tmp.Write(data); err != nil {
75+ tmp.Close()
76+ os.Remove(tmpName)
77+ return err
78+ }
79+ if err := tmp.Sync(); err != nil {
80+ tmp.Close()
81+ os.Remove(tmpName)
82+ return err
83+ }
84+ if err := tmp.Close(); err != nil {
85+ os.Remove(tmpName)
86+ return err
87+ }
88+ return os.Rename(tmpName, s.path)
6589 }
@@ -6,6 +6,7 @@ import (
6 "errors"6 "errors"
7 "io/fs"7 "io/fs"
8 "os"8 "os"
9+ "path/filepath"
9 "sync"10 "sync"
10 11
11 "github.com/bnema/gordon/internal/domain"12 "github.com/bnema/gordon/internal/domain"
@@ -61,5 +62,28 @@ func (s *PreviewStore) Save(_ context.Context, previews []domain.PreviewRoute) e
61 if err != nil {62 if err != nil {
62 return err63 return err
63 }64 }
64- return os.WriteFile(s.path, data, 0o644)65+
66+ // Atomic write: temp file → rename
67+ dir := filepath.Dir(s.path)
68+ tmp, err := os.CreateTemp(dir, ".previews-*.json.tmp")
69+ if err != nil {
70+ return err
71+ }
72+ tmpName := tmp.Name()
73+
74+ if _, err := tmp.Write(data); err != nil {
75+ tmp.Close()
76+ os.Remove(tmpName)
77+ return err
78+ }
79+ if err := tmp.Sync(); err != nil {
80+ tmp.Close()
81+ os.Remove(tmpName)
82+ return err
83+ }
84+ if err := tmp.Close(); err != nil {
85+ os.Remove(tmpName)
86+ return err
87+ }
88+ return os.Rename(tmpName, s.path)
65 }89 }
M internal/usecase/auto/dispatcher.go +10 -1
@@ -4,6 +4,8 @@ import (
44 "context"
55 "path/filepath"
66
7+ "github.com/bnema/zerowrap"
8+
79 "github.com/bnema/gordon/internal/boundaries/out"
810 "github.com/bnema/gordon/internal/domain"
911 )
@@ -38,8 +40,11 @@ func (d *ImagePushDispatcher) CanHandle(t domain.EventType) bool {
3840 }
3941
4042 func (d *ImagePushDispatcher) Handle(ctx context.Context, event domain.Event) error {
43+ log := zerowrap.FromCtx(ctx)
44+
4145 payload, ok := event.Data.(domain.ImagePushedPayload)
4246 if !ok {
47+ log.Warn().Str("event_type", string(event.Type)).Msg("dispatcher received event with unexpected payload type; forwarding to route handler")
4348 return d.routeHandler.Handle(ctx, event)
4449 }
4550
@@ -51,7 +56,11 @@ func (d *ImagePushDispatcher) Handle(ctx context.Context, event domain.Event) er
5156
5257 func (d *ImagePushDispatcher) matchesTagPatterns(reference string) bool {
5358 for _, pattern := range d.config.GetPreviewTagPatterns() {
54- if matched, _ := filepath.Match(pattern, reference); matched {
59+ matched, err := filepath.Match(pattern, reference)
60+ if err != nil {
61+ continue // skip malformed patterns
62+ }
63+ if matched {
5564 return true
5665 }
5766 }
@@ -4,6 +4,8 @@ import (
4 "context"4 "context"
5 "path/filepath"5 "path/filepath"
6 6
7+ "github.com/bnema/zerowrap"
8+
7 "github.com/bnema/gordon/internal/boundaries/out"9 "github.com/bnema/gordon/internal/boundaries/out"
8 "github.com/bnema/gordon/internal/domain"10 "github.com/bnema/gordon/internal/domain"
9 )11 )
@@ -38,8 +40,11 @@ func (d *ImagePushDispatcher) CanHandle(t domain.EventType) bool {
38 }40 }
39 41
40 func (d *ImagePushDispatcher) Handle(ctx context.Context, event domain.Event) error {42 func (d *ImagePushDispatcher) Handle(ctx context.Context, event domain.Event) error {
43+ log := zerowrap.FromCtx(ctx)
44+
41 payload, ok := event.Data.(domain.ImagePushedPayload)45 payload, ok := event.Data.(domain.ImagePushedPayload)
42 if !ok {46 if !ok {
47+ log.Warn().Str("event_type", string(event.Type)).Msg("dispatcher received event with unexpected payload type; forwarding to route handler")
43 return d.routeHandler.Handle(ctx, event)48 return d.routeHandler.Handle(ctx, event)
44 }49 }
45 50
@@ -51,7 +56,11 @@ func (d *ImagePushDispatcher) Handle(ctx context.Context, event domain.Event) er
51 56
52 func (d *ImagePushDispatcher) matchesTagPatterns(reference string) bool {57 func (d *ImagePushDispatcher) matchesTagPatterns(reference string) bool {
53 for _, pattern := range d.config.GetPreviewTagPatterns() {58 for _, pattern := range d.config.GetPreviewTagPatterns() {
54- if matched, _ := filepath.Match(pattern, reference); matched {59+ matched, err := filepath.Match(pattern, reference)
60+ if err != nil {
61+ continue // skip malformed patterns
62+ }
63+ if matched {
55 return true64 return true
56 }65 }
57 }66 }
M internal/usecase/auto/preview/volumes.go +1 -1
@@ -48,7 +48,7 @@ func CloneVolumes(ctx context.Context, cloner VolumeCloner, previewName string,
4848 }
4949
5050 helperConfig := &domain.ContainerConfig{
51- Image: "busybox:latest",
51+ Image: "busybox:1.37",
5252 Name: "gordon-vol-copy-" + destName,
5353 Cmd: []string{"cp", "-a", "/src/.", "/dst/"},
5454 Volumes: map[string]string{
@@ -48,7 +48,7 @@ func CloneVolumes(ctx context.Context, cloner VolumeCloner, previewName string,
48 }48 }
49 49
50 helperConfig := &domain.ContainerConfig{50 helperConfig := &domain.ContainerConfig{
51- Image: "busybox:latest",51+ Image: "busybox:1.37",
52 Name: "gordon-vol-copy-" + destName,52 Name: "gordon-vol-copy-" + destName,
53 Cmd: []string{"cp", "-a", "/src/.", "/dst/"},53 Cmd: []string{"cp", "-a", "/src/.", "/dst/"},
54 Volumes: map[string]string{54 Volumes: map[string]string{

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