fix(hub): slice-3 follow-ups — /host-report 413 oversize + contract golden (v0.7.1)
- handleHostReport: read maxHostReportBytes+1 (4 MiB const) and reject oversize with 413 instead of silent LimitReader truncation. Controller handleReport (1 MiB) is unchanged. Test asserts 413. - contract: hub/internal/api/testdata/host-report.golden.json (byte-identical with felhom-agent's copy) + TestHostReport_GoldenContract drives the real handler and asserts 200 + denorm + both guests upserted. - CHANGELOG v0.7.1. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -89,3 +89,45 @@ Hub version is the `main.Version` ldflags var (`build.sh <VER>`), default `"dev"
|
||||
### Repo state
|
||||
Branch: `main`. Verified `go build/vet/test ./...` green in `hub/` locally (go1.26) and on the
|
||||
build server (go1.26).
|
||||
|
||||
---
|
||||
|
||||
## Hub slice-3 follow-ups (v0.7.1) — 2026-06-08
|
||||
|
||||
Validation follow-ups (hub half). Pushed to `main`; build/vet/test green locally (go1.26) and on
|
||||
the build server.
|
||||
|
||||
### §3 — `/host-report` rejects oversize with 413 (not silent truncation)
|
||||
`handleHostReport` now reads `maxHostReportBytes+1` (const `4 << 20`, defined near
|
||||
`defaultHostPollSeconds`) and returns **`413 Payload too large`** when exceeded, instead of relying
|
||||
on `LimitReader` truncation (which could accept a truncated-but-valid JSON as a partial report,
|
||||
dropping guests from the mirror). **Scope-frozen:** the controller `handleReport` 1 MiB read is
|
||||
**unchanged** (diff touches only the host path); the small divergence is acceptable until cutover.
|
||||
`TestHandleHostReport_OversizeRejected` now asserts 413.
|
||||
|
||||
### §4 — cross-repo contract golden fixture (hub half)
|
||||
- `hub/internal/api/testdata/host-report.golden.json` — a **byte-identical copy** of felhom-agent's
|
||||
golden (verified by md5).
|
||||
- `TestHostReport_GoldenContract` — mints a host, POSTs the golden through the **real**
|
||||
`handleHostReport`, asserts 200 + denorm (`guest_total=2`, `guest_running=1`,
|
||||
`cloudflared_status="active"`) + both guests upserted. Proves `hostReportPayload` still extracts
|
||||
the contract from the real wire shape.
|
||||
|
||||
**Caveat (called out):** the two golden files are a *duplicated* contract with no shared source of
|
||||
truth. JSON can't hold a comment, so the mandatory "keep byte-identical" marker lives in each test
|
||||
file's doc comment. When slices 5/6 add real `storage_targets`/`backups` fields, promote this to a
|
||||
shared Go types module (the proper fix); this fixture is the bridge.
|
||||
|
||||
### Versioning / scope
|
||||
Recorded **v0.7.1** in `hub/CHANGELOG.md`. The hub version is the `main.Version` ldflags var
|
||||
(`build.sh <VER>`, default `"dev"`) — there is no in-repo version constant to bump (the task's
|
||||
pointer to `web/version.go` is the controller-image `VersionChecker`, unrelated); the image tag is
|
||||
applied at build/deploy (ArgoCD), not in this task. No deploy performed.
|
||||
|
||||
### Untouched (confirmed)
|
||||
Controller path (`handleReport`/`reports`/`customer_configs`/`checkAuthCustomer`/existing checkers)
|
||||
unchanged. The agent's proxmox client timeout was a "confirm" item — already bounded (30s default),
|
||||
no change.
|
||||
|
||||
### Repo state
|
||||
Branch: `main`. Verified `go build/vet/test ./...` green in `hub/` locally (go1.26) and on the build server (go1.26).
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# Felhom Hub — Changelog
|
||||
|
||||
## v0.7.1 (2026-06-08)
|
||||
|
||||
### Changed
|
||||
- **`/host-report` rejects oversize bodies explicitly with 413** (`handler.go`) instead of silently truncating at the 4 MiB `LimitReader` cap. Reads one byte past `maxHostReportBytes` and returns `413 Payload too large` — a truncated-but-valid JSON could otherwise be accepted as a partial report (silently dropping guests from the mirror). The controller `handleReport` 1 MiB path is **unchanged** (frozen until slice-10 cutover).
|
||||
|
||||
### Added
|
||||
- **Cross-repo contract fixture** `hub/internal/api/testdata/host-report.golden.json` (byte-identical with felhom-agent's copy) + `TestHostReport_GoldenContract` — POSTs the golden through the real `handleHostReport` and asserts 200 + denorm (`guest_total`/`guest_running`/`cloudflared_status`) + both guests upserted, proving `hostReportPayload` still extracts the contract from the real shape. Duplicated contract (no shared types module yet); revisit at slices 5/6.
|
||||
|
||||
## v0.7.0 (2026-06-08)
|
||||
|
||||
### Added — host-domain ingest (slice 3, additive; controller path untouched)
|
||||
|
||||
@@ -226,6 +226,13 @@ func (h *Handler) handleReport(w http.ResponseWriter, r *http.Request) {
|
||||
// per-host override UI yet — that is a later slice).
|
||||
const defaultHostPollSeconds = 900
|
||||
|
||||
// maxHostReportBytes bounds a host-report body. Larger than the controller path's
|
||||
// 1 MiB because host reports carry the full guest list + (later) storage/backup
|
||||
// arrays. We read one byte past it and reject explicitly (413) rather than letting
|
||||
// LimitReader silently truncate — a truncated-but-valid JSON would otherwise be
|
||||
// accepted as a partial report, dropping guests from the mirror.
|
||||
const maxHostReportBytes = 4 << 20 // 4 MiB
|
||||
|
||||
// hostReportPayload is the subset of the agent host-report (slice-3 contract,
|
||||
// §3 / agent spec §4) the hub needs for denorm + guest reality. Unknown fields
|
||||
// (storage_targets/backups/restore_tests/pbs_snapshots/audit_tail) are ignored,
|
||||
@@ -258,13 +265,15 @@ func (h *Handler) handleHostReport(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// 4 MiB: host reports carry the full guest list + future storage/backup arrays;
|
||||
// the controller path's 1 MiB is too tight here.
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 4<<20))
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, maxHostReportBytes+1))
|
||||
if err != nil {
|
||||
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(body) > maxHostReportBytes {
|
||||
http.Error(w, "Payload too large", http.StatusRequestEntityTooLarge)
|
||||
return
|
||||
}
|
||||
var rep hostReportPayload
|
||||
if err := json.Unmarshal(body, &rep); err != nil || rep.HostID == "" {
|
||||
http.Error(w, "Invalid payload: host_id required", http.StatusBadRequest)
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -152,8 +153,8 @@ func TestHandleHostReport_OversizeRejected(t *testing.T) {
|
||||
st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY"})
|
||||
big := `{"host_id":"h1","guests":[{"vmid":1,"name":"` + strings.Repeat("a", 5<<20) + `"}]}`
|
||||
rr := do(h, http.MethodPost, "/host-report", "HKEY", big)
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Errorf("oversize body status = %d, want 400", rr.Code)
|
||||
if rr.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Errorf("oversize body status = %d, want 413", rr.Code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,3 +189,44 @@ func TestAdminCreateHost(t *testing.T) {
|
||||
t.Errorf("round-trip host-report with minted key = %d, body=%s", rr2.Code, rr2.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestHostReport_GoldenContract drives the real handler with the shared golden
|
||||
// host-report and proves hostReportPayload still extracts what it needs from the
|
||||
// real wire shape (denorm + guest upsert).
|
||||
//
|
||||
// testdata/host-report.golden.json MUST be kept byte-identical with felhom-agent's
|
||||
// internal/hub/testdata/host-report.golden.json — it is a duplicated contract until
|
||||
// a shared types module exists (revisit when slices 5/6 add real fields).
|
||||
func TestHostReport_GoldenContract(t *testing.T) {
|
||||
h, st, dbPath := newTestHandler(t)
|
||||
st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ckey", RetrievalPassword: "p"})
|
||||
st.UpsertHost(&store.Host{HostID: "demo-host-01", CustomerID: "c1", APIKey: "HKEY"})
|
||||
|
||||
golden, err := os.ReadFile("testdata/host-report.golden.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rr := do(h, http.MethodPost, "/host-report", "HKEY", string(golden))
|
||||
if rr.Code != 200 {
|
||||
t.Fatalf("golden report status = %d, body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
db, _ := sql.Open("sqlite", dbPath)
|
||||
defer db.Close()
|
||||
|
||||
var total, running int
|
||||
var cf string
|
||||
if err := db.QueryRow(`SELECT guest_total, guest_running, cloudflared_status FROM host_reports WHERE host_id='demo-host-01' ORDER BY id DESC LIMIT 1`).
|
||||
Scan(&total, &running, &cf); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 2 || running != 1 || cf != "active" {
|
||||
t.Errorf("denorm total=%d running=%d cloudflared=%q (want 2,1,active)", total, running, cf)
|
||||
}
|
||||
|
||||
var guestCount int
|
||||
db.QueryRow(`SELECT COUNT(*) FROM guests WHERE host_id='demo-host-01'`).Scan(&guestCount)
|
||||
if guestCount != 2 {
|
||||
t.Errorf("guests upserted = %d, want 2", guestCount)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"host_id": "demo-host-01",
|
||||
"reported_at": "2026-06-08T12:00:00Z",
|
||||
"agent_version": "0.3.1",
|
||||
"host": {
|
||||
"node": "demo-felhom",
|
||||
"cpu_percent": 3.2,
|
||||
"memory_total_bytes": 16777216000,
|
||||
"memory_used_bytes": 4194304000,
|
||||
"memory_percent": 25,
|
||||
"disk_total_bytes": 152000000000,
|
||||
"disk_used_bytes": 30000000000,
|
||||
"disk_percent": 19.7,
|
||||
"loadavg": ["0.10", "0.20", "0.15"],
|
||||
"uptime_seconds": 86400
|
||||
},
|
||||
"guests": [
|
||||
{
|
||||
"vmid": 100,
|
||||
"name": "felhom-cust-acme",
|
||||
"status": "running",
|
||||
"controller_version": "",
|
||||
"spec": { "cores": 2, "memory_bytes": 2147483648, "disk_bytes": 21474836480 }
|
||||
},
|
||||
{
|
||||
"vmid": 101,
|
||||
"name": "felhom-cust-beta",
|
||||
"status": "stopped",
|
||||
"controller_version": ""
|
||||
}
|
||||
],
|
||||
"storage_targets": [],
|
||||
"backups": [],
|
||||
"restore_tests": [],
|
||||
"pbs_snapshots": [],
|
||||
"cloudflared": { "status": "active" },
|
||||
"audit_tail": []
|
||||
}
|
||||
Reference in New Issue
Block a user