fix(landscape): accept an empty run body; pin the builder's Alpine release

POST /landscape/runs documents an optional body, but DecodeJSON returned
io.EOF on an empty one and the handler turned that into 400 — breaking the
most common call, an all-defaults run. decodeRunRequest now treats io.EOF as
the zero request; a malformed body is still 400.

The Dockerfile's Alpine ABI coupling was enforced only by a comment: a
floating golang:1.26-alpine builder against a pinned alpine:3.24 runtime
would build green and die at startup on a libgdal soname mismatch as soon as
the Go image rolls forward. Both stages are now pinned to 3.24.

Also: the route-mount test walks the router so all six routes are asserted
(GET /slots and GET /results included), and the spec documents the 404 those
two return when migration 00011's seeded groups are absent.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-05 14:22:26 +05:00
parent ad3c9fbdcd
commit 6aec00ee29
4 changed files with 129 additions and 26 deletions

View File

@@ -761,6 +761,10 @@ paths:
Every leaf input group of the computation graph, in graph order, with Every leaf input group of the computation graph, in graph order, with
the dataset currently filling it (any status) or `null` when nothing the dataset currently filling it (any status) or `null` when nothing
has been uploaded yet. has been uploaded yet.
Yields 404 when a slot's dataset group is missing — that means
migration `00011_landscape.sql`, which seeds the groups, has not been
applied to this database.
responses: responses:
"200": "200":
description: The input slots description: The input slots
@@ -769,6 +773,7 @@ paths:
schema: schema:
type: array type: array
items: { $ref: "#/components/schemas/LandscapeSlot" } items: { $ref: "#/components/schemas/LandscapeSlot" }
"404": { $ref: "#/components/responses/NotFound" }
/landscape/slots/{code}: /landscape/slots/{code}:
parameters: parameters:
@@ -842,6 +847,9 @@ paths:
Validates the params and overrides, persists the run with one pending Validates the params and overrides, persists the run with one pending
node per graph node, and publishes the worker job. Returns 202: the node per graph node, and publishes the worker job. Returns 202: the
graph is executed asynchronously, poll `GET /landscape/runs/{id}`. graph is executed asynchronously, poll `GET /landscape/runs/{id}`.
Every field has a default, so the body is optional: sending no body at
all (or `{}`) starts a default incremental run.
requestBody: requestBody:
required: false required: false
content: content:
@@ -879,6 +887,10 @@ paths:
One entry per output group of the graph with its freshest `ready` One entry per output group of the graph with its freshest `ready`
raster, the COG URL to render it, and the group's SLD URL when one has raster, the COG URL to render it, and the group's SLD URL when one has
been uploaded. `latest` is `null` while nothing has been computed. been uploaded. `latest` is `null` while nothing has been computed.
Yields 404 when an output group is missing — that means migration
`00011_landscape.sql`, which seeds the groups, has not been applied to
this database.
responses: responses:
"200": "200":
description: The result layers description: The result layers
@@ -887,6 +899,7 @@ paths:
schema: schema:
type: array type: array
items: { $ref: "#/components/schemas/LandscapeResult" } items: { $ref: "#/components/schemas/LandscapeResult" }
"404": { $ref: "#/components/responses/NotFound" }
components: components:
parameters: parameters:

View File

@@ -1,5 +1,5 @@
# Build context is the repo root: docker build -f build/package/Dockerfile . # Build context is the repo root: docker build -f build/package/Dockerfile .
FROM golang:1.26-alpine AS builder FROM golang:1.26-alpine3.24 AS builder
# godal (GDAL bindings) needs cgo, a C/C++ toolchain and the GDAL headers. # godal (GDAL bindings) needs cgo, a C/C++ toolchain and the GDAL headers.
# g++ is required: parts of the binding compile as C++ against GDAL's headers. # g++ is required: parts of the binding compile as C++ against GDAL's headers.
@@ -13,8 +13,11 @@ RUN go mod download
COPY . . COPY . .
RUN CGO_ENABLED=1 GOOS=linux go build -trimpath -o /out/gis ./cmd/gis RUN CGO_ENABLED=1 GOOS=linux go build -trimpath -o /out/gis ./cmd/gis
# Must match the builder's Alpine release (golang:1.26-alpine is Alpine 3.24): # Must stay on the same Alpine release as the builder — hence the pinned
# an older base ships a different libgdal soname and the binary will not start. # `golang:1.26-alpine3.24` above rather than the floating `-alpine` tag. A
# mismatch ships a different libgdal soname, and the image still builds green
# while the binary dies at startup with "libgdal.so.NN: No such file".
# Bump both lines together.
FROM alpine:3.24 FROM alpine:3.24
# gdal: shared library for godal (raster math in the worker). # gdal: shared library for godal (raster math in the worker).

View File

@@ -1,6 +1,8 @@
package http package http
import ( import (
"errors"
"io"
"net/http" "net/http"
"gis/internal/landscape" "gis/internal/landscape"
@@ -89,12 +91,26 @@ func (h *LandscapeHandler) uploadSlot(w http.ResponseWriter, r *http.Request) {
httputil.WriteJSON(w, http.StatusCreated, dataset) httputil.WriteJSON(w, http.StatusCreated, dataset)
} }
// decodeRunRequest reads the run body. The whole body is optional — every field
// has a default — so an empty body is the zero request (a default incremental
// run), not an error. A malformed body writes a 400 and reports false.
func decodeRunRequest(w http.ResponseWriter, r *http.Request) (runRequest, bool) {
req, err := httputil.DecodeJSON[runRequest](w, r)
switch {
case errors.Is(err, io.EOF):
return runRequest{}, true
case err != nil:
httputil.WriteError(w, http.StatusBadRequest, "invalid request body")
return runRequest{}, false
}
return req, true
}
// createRun queues a Landscape Program run and returns it with 202 Accepted; // createRun queues a Landscape Program run and returns it with 202 Accepted;
// the worker executes it asynchronously. // the worker executes it asynchronously.
func (h *LandscapeHandler) createRun(w http.ResponseWriter, r *http.Request) { func (h *LandscapeHandler) createRun(w http.ResponseWriter, r *http.Request) {
req, err := httputil.DecodeJSON[runRequest](w, r) req, ok := decodeRunRequest(w, r)
if err != nil { if !ok {
httputil.WriteError(w, http.StatusBadRequest, "invalid request body")
return return
} }
if err := h.validate.Struct(req); err != nil { if err := h.validate.Struct(req); err != nil {

View File

@@ -3,6 +3,7 @@ package http
import ( import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"sort"
"strings" "strings"
"testing" "testing"
@@ -90,31 +91,101 @@ func TestGetRunInvalidID(t *testing.T) {
} }
} }
// TestRegisterMountsRoutes checks the route table itself: every path reaches a // TestDecodeRunRequestEmptyBody pins the "all defaults" call: POST
// handler (405/404 would mean it is not mounted). Each request is one that is // /landscape/runs with no body at all is a valid request, not a 400. Tested at
// rejected before the service is used. // the helper level because the 202 path needs a live service.
func TestDecodeRunRequestEmptyBody(t *testing.T) {
for _, body := range []string{"", "{}"} {
req := httptest.NewRequest(http.MethodPost, "/landscape/runs", strings.NewReader(body))
rec := httptest.NewRecorder()
got, ok := decodeRunRequest(rec, req)
if !ok {
t.Fatalf("body %q: decodeRunRequest reported failure with status %d", body, rec.Code)
}
if got.FromStage != nil || len(got.Overrides) != 0 || got.Params != nil {
t.Fatalf("body %q: got %+v, want the zero request", body, got)
}
if rec.Code != http.StatusOK || rec.Body.Len() != 0 {
t.Fatalf("body %q: helper wrote a response (status %d, body %s)", body, rec.Code, rec.Body.String())
}
// The zero request must also survive validation, or an empty body
// would still be rejected one step later.
if err := validator.New(validator.WithRequiredStructEnabled()).Struct(got); err != nil {
t.Fatalf("body %q: zero request fails validation: %v", body, err)
}
}
}
func TestDecodeRunRequestMalformedBody(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/landscape/runs", strings.NewReader(`{`))
rec := httptest.NewRecorder()
if _, ok := decodeRunRequest(rec, req); ok {
t.Fatal("decodeRunRequest accepted a truncated body")
}
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
}
// TestRegisterMountsRoutes walks the route table of a router with the handler
// registered, so every one of the six routes is checked — including GET /slots
// and GET /results, which cannot be exercised without a live service.
func TestRegisterMountsRoutes(t *testing.T) { func TestRegisterMountsRoutes(t *testing.T) {
r := chi.NewRouter() r := chi.NewRouter()
r.Route("/landscape", newLandscapeTestHandler().Register) r.Route("/landscape", newLandscapeTestHandler().Register)
cases := []struct { got := map[string]bool{}
method, path, body, contentType string err := chi.Walk(r, func(method, route string, _ http.Handler, _ ...func(http.Handler) http.Handler) error {
want int got[method+" "+strings.TrimSuffix(route, "/")] = true
}{ return nil
{http.MethodPost, "/landscape/runs", `{`, "application/json", http.StatusBadRequest}, })
{http.MethodPost, "/landscape/slots/lp-z-lcr", `{}`, "application/json", http.StatusBadRequest}, if err != nil {
{http.MethodGet, "/landscape/runs?page=0", "", "", http.StatusBadRequest}, t.Fatalf("walk routes: %v", err)
{http.MethodGet, "/landscape/runs/nope", "", "", http.StatusBadRequest},
} }
for _, c := range cases {
req := httptest.NewRequest(c.method, c.path, strings.NewReader(c.body)) want := []string{
if c.contentType != "" { "GET /landscape/slots",
req.Header.Set("Content-Type", c.contentType) "POST /landscape/slots/{code}",
} "GET /landscape/runs",
rec := httptest.NewRecorder() "POST /landscape/runs",
r.ServeHTTP(rec, req) "GET /landscape/runs/{id}",
if rec.Code != c.want { "GET /landscape/results",
t.Errorf("%s %s: status = %d, want %d (body %s)", c.method, c.path, rec.Code, c.want, rec.Body.String()) }
for _, w := range want {
if !got[w] {
t.Errorf("route %q is not mounted; mounted routes: %v", w, keys(got))
} }
} }
if len(got) != len(want) {
t.Errorf("mounted %d routes, want %d: %v", len(got), len(want), keys(got))
}
}
// TestRoutedRequestReachesHandler sends a real request through the router so the
// URL parameter wiring (chi.URLParam) is exercised, not just the pattern.
func TestRoutedRequestReachesHandler(t *testing.T) {
r := chi.NewRouter()
r.Route("/landscape", newLandscapeTestHandler().Register)
req := httptest.NewRequest(http.MethodGet, "/landscape/runs/nope", nil)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d (body %s)", rec.Code, http.StatusBadRequest, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "invalid id") {
t.Fatalf("body %s does not report the bad id", rec.Body.String())
}
}
func keys(m map[string]bool) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
} }