From db84a2f6ba4b6551dbf443764f551add573d35b9 Mon Sep 17 00:00:00 2001 From: Samuel Enocsson Date: Sat, 4 Jul 2026 20:41:59 +0200 Subject: [PATCH] fix(api): plumb request tag into discovery so swarm mode deploys the requested version req.Tag was only echoed in the HTTP response, never used to match jobs. Compose mode didn't care (ComposeExecutor re-pulls the compose file's own pinned tag), but SwarmExecutor sets the service image directly from Job.Image, which was built from the untagged req.Image alone -- so a Swarm deploy silently rewrote the service to :latest instead of the requested tag. Build the full image:tag reference once in the handler and pass it into FindJobs; NormaliseImage/ImagesMatch already strip tags before matching, so this doesn't change which jobs match in either mode. --- internal/api/handlers.go | 7 ++++- internal/api/handlers_test.go | 50 ++++++++++++++++++++++++++++++++++- 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/internal/api/handlers.go b/internal/api/handlers.go index e9af556..420ff35 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -56,7 +56,12 @@ func (h *Handlers) Update(w http.ResponseWriter, r *http.Request) { return } - jobs, err := h.finder.FindJobs(r.Context(), req.Image) + requestedImage := req.Image + if req.Tag != "" { + requestedImage = req.Image + ":" + req.Tag + } + + jobs, err := h.finder.FindJobs(r.Context(), requestedImage) if err != nil { writeJSONError(w, http.StatusInternalServerError, "discovery failed: "+err.Error()) return diff --git a/internal/api/handlers_test.go b/internal/api/handlers_test.go index b62f830..462540a 100644 --- a/internal/api/handlers_test.go +++ b/internal/api/handlers_test.go @@ -21,9 +21,21 @@ import ( type fakeFinder struct { jobs []discovery.Job err error + + // gotImage records the image argument passed to FindJobs, for + // assertions on the exact reference the handler built. + gotImage string + // swarmShaped, when true, makes FindJobs return a Job whose Image + // field carries the received image argument, simulating + // discovery.SwarmDiscovery.FindJobs. + swarmShaped bool } -func (f *fakeFinder) FindJobs(_ context.Context, _ string) ([]discovery.Job, error) { +func (f *fakeFinder) FindJobs(_ context.Context, image string) ([]discovery.Job, error) { + f.gotImage = image + if f.swarmShaped { + return []discovery.Job{{Service: "myapp_web", ServiceID: "svc1", Image: image}}, f.err + } return f.jobs, f.err } @@ -157,6 +169,42 @@ func TestHealthz_503WhenDockerDown(t *testing.T) { require.Equal(t, http.StatusServiceUnavailable, w.Code) } +func TestUpdate_FindJobsCalledWithTaggedImage(t *testing.T) { + finder := &fakeFinder{} + h := api.NewHandlers(finder, &fakeSubmitter{}, &fakePinger{}, "v0.0.0", "abc", "now", nil) + body, _ := json.Marshal(api.UpdateRequest{Image: "registry.example.com/myapp", Tag: "v2"}) + req := httptest.NewRequest(http.MethodPost, "/update", bytes.NewReader(body)) + w := httptest.NewRecorder() + h.Update(w, req) + require.Equal(t, "registry.example.com/myapp:v2", finder.gotImage) +} + +func TestUpdate_FindJobsCalledWithBareImageWhenNoTag(t *testing.T) { + finder := &fakeFinder{} + h := api.NewHandlers(finder, &fakeSubmitter{}, &fakePinger{}, "v0.0.0", "abc", "now", nil) + body, _ := json.Marshal(api.UpdateRequest{Image: "registry.example.com/myapp"}) + req := httptest.NewRequest(http.MethodPost, "/update", bytes.NewReader(body)) + w := httptest.NewRecorder() + h.Update(w, req) + require.Equal(t, "registry.example.com/myapp", finder.gotImage) +} + +func TestUpdate_SwarmModeThreadsTagThroughDiscovery(t *testing.T) { + finder := &fakeFinder{swarmShaped: true} + h := api.NewHandlers(finder, &fakeSubmitter{}, &fakePinger{}, "v0.0.0", "abc", "now", nil) + body, _ := json.Marshal(api.UpdateRequest{Image: "registry.example.com/myapp", Tag: "v2"}) + req := httptest.NewRequest(http.MethodPost, "/update", bytes.NewReader(body)) + w := httptest.NewRecorder() + h.Update(w, req) + require.Equal(t, http.StatusOK, w.Code) + // The fake Finder's returned Job.Image (as SwarmDiscovery would build + // it) reflects the image argument it received from the handler. + // Asserting on the captured argument proves the full request -> + // discovery flow carries the tag through to what would reach the + // SwarmExecutor. + require.Equal(t, "registry.example.com/myapp:v2", finder.gotImage) +} + func TestVersion(t *testing.T) { h := api.NewHandlers(&fakeFinder{}, &fakeSubmitter{}, &fakePinger{}, "v1.2.3", "abcdef", "2026-05-22T00:00:00Z", nil) req := httptest.NewRequest(http.MethodGet, "/version", nil)