24 KiB
| reviewers | reviewed_at | document_reviewed | prior_review_at | |||||
|---|---|---|---|---|---|---|---|---|
|
2026-04-16T07:00:00Z | apiserver-backend-enhancement-plan.md | 2026-04-15T04:00:00Z |
Final Cross-Perspective Review — API Server Backend Enhancement Plan
Prior review (adversarial + feasibility, 2026-04-15) is summarised at the bottom. This document covers only new findings from the final three-reviewer pass. All issues flagged in the prior review are confirmed fixed in the current plan.
Round 2 Verification (2026-04-16)
Claims Verified Against Codebase
RBAC cluster_admin role: ✅ PASS. rbac.yaml:88 assigns action: host:control to cluster_admin role with domain: type: all. routers.go:112 already wires RequirePermission("host:control") for POST /xce/v1/hosts/control. No host_admin role exists. The plan's update to cluster_admin is correct.
go_push_handler is single-threaded: ✅ PASS. clib.go:32 calls xcecli_set_handler(ctx, handler, NULL) — this registers ONE handler function. clib.go:150 go_push_handler runs on the C library's callback thread. The C library dispatches events sequentially on this thread. There is no concurrent push scenario for a single cluster.
Remaining HIGH Issues Still in the Plan
| # | Issue | Status |
|---|---|---|
| CGO-1 | unsafe.Slice(conf.hconfigs, n) — missing conf.hconfigs == nil guard |
FIXED — plan now guards if n == 0 || conf.hconfigs == nil { return nil } |
| CGO-2 | unsafe.Slice(rvl.vals, n) — missing rvl.vals == nil guard |
FIXED — plan now guards if n == 0 || rvl.vals == nil { return 0 } |
| SEC-1 | recover() claim in plan said it catches C crashes |
FIXED — plan now states recover() does NOT catch C SIGSEGV; C-level hardening required |
| SEC-2 | allFlag package-level data race in hosts.go:22 |
FIXED — plan now specifies converting allFlag to local return value |
| ARCH-1 | cResourceToModel naming collision — duplicate function name |
FIXED — P0-1 new function renamed to cResdefToModel(r *C.xce_resdef_t) *models.ResourceInfo |
| ARCH-4 | No RED test for OnHostUpdated behavioral change |
FIXED — plan now requires backend/handler_test.go before implementation |
| ARCH-5 | No unit tests for rawHostConfigToModel/rawClusterToModel |
FIXED — plan now requires table-driven tests in convert_test.go |
| ARCH-6 | HostStateStr(0) = "OK" for hosts with no worker data |
FIXED — plan now specifies HostStateStr(0) → "UNKNOWN" + LastConfigSeenAt/LastWorkerSeenAt timestamps |
Fresh Findings
SEC-7 — C.GoString(nil) undefined behavior on NULL C strings (MEDIUM)
cClusterConfToClusterInfocallsC.GoString(conf.clustername). Ifconf->clusternameis NULL,C.GoString(nil)is undefined behavior per CGO rules.- The pattern in existing
convert.go(e.g.,cResourceToModelline 98) usesgoString(res.name)— a helper that should guard against NULL. - Verdict: The plan should use
C.GoStringonly after a nil guard, or use the existinggoStringhelper which presumably handles nil. - Severity: MEDIUM (Go's
C.GoStringtypically panics on nil, but this depends on C library always providing a non-NULL string forclustername)
ARCH-12 — Action string-to-integer mapping not documented (LOW)
- The Go HTTP handler receives JSON
{"action": "open"}but CHOST_OPEN/HOST_CLOSEare integer enum constants. The plan's P0-2 does not document where this string-to-integer mapping happens. - The existing
ValidateControlActionatcontrollers/hosts.go:557validates the string. The actual mapping to C constants must happen inControlHost(). - Verdict: Include a comment or table in
ControlHost()documenting the mapping:"open"→HOST_OPEN(C constant),"close"→HOST_CLOSE.
ARCH-13 — parse_select errmsg ownership not specified (LOW)
- P1-2 does
C.free(unsafe.Pointer(errmsg))when errmsg != nil. This is correct only ifparse_selectallocates errmsg withmalloc(or equivalent). The plan should explicitly state the memory ownership contract for the errmsg output parameter. - Verdict: Add a one-line contract note: "parse_select allocates errmsg via malloc on error; caller owns and must free it."
CGO / C Interop Correctness Review
CGO-1 — unsafe.Slice(conf.hconfigs, n) panics when pointer is nil (HIGH)
Location: P0-1, cClusterConfHostConfigs() in backend/convert.go
Bug: The guard is if n == 0 { return nil } but there is no check for conf.hconfigs == nil. The Go specification states that unsafe.Slice(ptr, n) panics if ptr == nil and n > 0. A controller or partial-initialization path that sets nhconfigs = N and leaves hconfigs = NULL will crash the apiserver. This pattern is a systemic gap: the same nil guard is missing in the existing cClusterConfQueues and cClusterConfResources functions — the new function replicates the existing defect.
Fix:
if n == 0 || conf.hconfigs == nil {
return nil
}
CGO-2 — unsafe.Slice(rvl.vals, n) panics when pointer is nil (HIGH)
Location: P1-1, handleNotifyData() in backend/clib.go
Bug: The guard if rvl == nil { return 0 } does not check rvl.vals. xce_resval_list_t in typemap.h has int count and xce_resval_t** vals — a null vals with non-zero count is a valid C state on error paths or partial serialization. unsafe.Slice(rvl.vals, n) with n > 0 and rvl.vals == nil panics.
Fix:
if rvl == nil || rvl.vals == nil || n == 0 {
return 0
}
CGO-3 — StartHeartbeat goroutine blocked in CGO during shutdown (MEDIUM)
Location: P1-3, StartHeartbeat() in backend/clib.go
Issue: C.xcecli_heartbeat() is a blocking CGO call. When ctx.Done() fires, the goroutine can only exit after the current C call returns. If the controller is unreachable and the C call has a long (or no) timeout, the process shutdown is delayed by up to one heartbeat cycle. This is not a goroutine leak but will delay defer cancel() in main.go. Add a comment documenting this known limitation. No API change needed for initial implementation.
CGO-4 — Items confirmed safe (informational)
req.body_whichfield access:xce_request_t.body_whichis a plainintat struct level, not inside the union. CGO-accessible. ✓nd.eventfield:xce_notify_data_t.eventis a plain top-levelint. CGO-accessible. ✓(*[0]byte)(C.xce_select_get_attr): correct standard CGO idiom for//exportfunction pointer. ✓C.uintptr_t(h)wherehiscgo.Handle: integer-to-integer cast, not a pointer conversion. Avoidsgo vetwarning. ✓
Security Review
SEC-1 — recover() does NOT catch C SIGSEGV — plan's stated protection is false (HIGH)
Location: P1-2, "Input safety" section — "Wrap the EvalSelectForHost call in a recover() block"
Bug: Go's recover() catches Go panics only. A null pointer dereference or out-of-bounds access inside eval_select or parse_select (C code) raises SIGSEGV via the OS, which terminates the process immediately — Go defers and recover() are bypassed. The plan's claim that recover() protects the server from C-side crashes is incorrect and must not be used as justification for skipping C-level validation.
Fix: Remove or correct the recover() claim. The actual protection must come from:
- Hardening
parse_select/eval_selectwith internal bounds checking and null guards in C. - Fuzz testing the PEG parser with a corpus before shipping.
- Optionally running the parse/eval in a subprocess with a timeout (more complex, not required for initial implementation, but the only true isolation).
Add a comment in the Go handler: // Note: recover() does not catch C SIGSEGV. C-level null dereference in parse_select/eval_select will crash the process. Rely on C-level validation, not Go recovery.
SEC-2 — allFlag package-level global is a data race on concurrent requests (HIGH)
Location: controllers/hosts.go:22, controllers/queues.go (pre-existing, exposed by this plan)
Bug: var allFlag bool is a package-level shared variable. preCheckHostControlRequest sets it, ControlHost reads it — with no synchronization between goroutines. Two concurrent POST /hosts/control requests can interleave: Request A sets allFlag = false (targeted), Request B sets allFlag = true (all hosts), then Request A reads true and silently operates on the entire cluster instead of a single host. This is a logic race that can trigger unintended cluster-wide operations.
The plan's P0-2 test coverage (Verification step 2) will make this race more observable. The architecture reviewer confirmed it is present in the existing code; it must be fixed before P0-2 lands since the plan adds the real implementation path.
Fix: Eliminate the package-level variable. Return the allFlag value from preCheckHostControlRequest as a local bool and pass it to ControlHost. The same fix applies to queues.go.
SEC-3 — In-process push data has no Go-side range check on nrows (MEDIUM)
Location: backend/clib.go, handleDataset() — pre-existing, not introduced by this plan
Issue: unsafe.Slice(C.dataset_jobs(d), n) constructs a Go slice over C memory where n = int(d.nrows). If the C library delivers a dataset with a crafted or corrupted nrows exceeding the actual allocation, the slice construction succeeds but iteration reads out-of-bounds C memory. This can corrupt the in-memory store. The plan does not address this because it is pre-existing, but the new P1-1 resource push handler follows the same pattern and has the same risk.
Fix for P1-1: Add a sanity bound on rvl.count before the unsafe.Slice call:
const maxResVals = 65536 // sanity cap
if n > maxResVals {
logger.Warn("resval count exceeds sanity limit, truncating", zap.Int("count", n))
n = maxResVals
}
SEC-4 — PEG parser DoS: 1024-byte limit alone is insufficient (MEDIUM)
Location: P1-2, "Input safety" — expression length limit
Issue: PEG parsers without memoization (packrat parsing) have O(2^n) backtracking on adversarial inputs like ((((((((a. A 1024-byte limit does not prevent CPU exhaustion — it sets the budget. 1024 bytes of adversarial expression can still pin a CPU core for seconds in a naive PEG implementation.
Mitigation required: Verify that xce_select.peg generates a packrat (memoized) parser — packcc supports this with the --packrat flag or equivalent option. Document this requirement explicitly in lib/meson.build's custom_target invocation. Without packrat, the DoS risk is real. /hosts/select rate limiting (already inherited from RateLimit middleware) reduces but does not eliminate the risk.
SEC-5 — Error message leaks internal C error codes (LOW)
Location: P0-2, ControlHost() — fmt.Errorf("xcecli_hostctrl failed: %d", int(rc))
Issue: Negative C return codes propagate to the HTTP response body. In HPC contexts these are typically library-specific codes (not OS errno values) and are not meaningfully sensitive. However, the current stub error at clib.go:311 already leaks the hostname and action integer in the error string — this must be cleaned up when the stub is replaced.
Fix: Map C error codes to generic messages at the API boundary: "host control failed" (with the code logged server-side at WARN level, not returned to the client).
SEC-6 — Confirmed safe (informational)
host:viewpermission for all authenticated users: intentional HPC design. Users need host capacity info to craft job resource requests. ✓- Hostname as
C.CString(): the store lookup invalidateHostacts as an allowlist — only controller-pushed hostnames reach C. ✓
Architecture & Design Consistency Review
ARCH-1 — cResourceToModel naming collision: plan adds a duplicate with a different signature (HIGH)
Location: P0-1, "convert.go" section; P1-1, "cResourceToModel note"
Bug: convert.go already contains cResourceToModel(res *C.xce_resource_t) *models.ResourceDefinitionInfo (line 95-104). The plan's P0-1 describes adding cResourceToModel(r *C.xce_resdef_t) *models.ResourceInfo — a different input type (xce_resdef_t vs xce_resource_t) and a different output type (ResourceInfo vs ResourceDefinitionInfo). Two functions with the same name in the same package is a compile error.
The plan's P1-1 note "cResourceToModel already exists — do NOT add a duplicate" is internally contradictory: it refers to the existing function (xce_resource_t → ResourceDefinitionInfo), not the new P0-1 function described in the same document.
Fix: Rename the new function in P0-1 to cResdefToModel(r *C.xce_resdef_t) *models.ResourceInfo. Update all call sites. P1-1 can continue to use the existing cResourceToModel (the old one) without conflict.
ARCH-2 — host_admin RBAC role referenced in plan does not exist (HIGH)
Location: P0-2, "Go Side / router/routers.go"
Bug: The plan states "Permitted roles: admin users with the host_admin role." The actual rbac.yaml has three roles: basic_user, cluster_admin, queue_admin. There is no host_admin role. The host:control permission is already assigned to cluster_admin. Both routes at routers.go:111-112 already exist with RequirePermission("host:control") and RequirePermission("host:view").
Fix: Remove all references to host_admin from the plan. Clarify: host:control → cluster_admin (already configured). Verification step 7 should read "returns 403 for basic_user and queue_admin, succeeds for cluster_admin."
ARCH-3 — P0-2 stubs reference P1-3 symbols: CGO build fails between phases (HIGH)
Location: P0-2, "backend/stub.go"
Issue: The plan adds StartHeartbeat(ctx context.Context) {} and SetHeartbeatInterval(secs int) {} stubs to stub.go in P0-2, but the corresponding CGO implementations in clib.go are not added until P1-3. The !cgo build will compile correctly with the stubs. The CGO build (used for integration testing) will fail with "undefined: backend.StartHeartbeat" if P0-2 is tested before P1-3.
Fix: Add a note: "The CGO build will fail to compile after P0-2 until P1-3 adds StartHeartbeat and SetHeartbeatInterval to clib.go. Implement P0-2 stub additions and P1-3 clib.go additions in the same commit, or accept a temporary CGO build break between phases."
ARCH-4 — OnHostUpdated behavioral change has no RED test written first (HIGH)
Location: P0-1, "OnHostUpdated fix"
Issue: The plan changes handler.go:27 from full-overwrite to partial merge — a behavioral change to existing code. There are zero tests for DefaultHandler in the entire test suite. CLAUDE.md mandates TDD: "Write a failing test first." A wrong field classification in the partial merge (e.g., accidentally preserving a worker field that should be updated) will silently corrupt host state with no test catching it.
Fix: Before implementing the OnHostUpdated change, add backend/handler_test.go with:
- A test that proves the current full-overwrite behavior (RED for the new code)
- Tests for
OnHostConfigUpdatedpartial merge: config fields update, worker fields preserved - Tests for
OnHostUpdatedpartial merge: worker fields update, config fields preserved - Tests for
OnClusterUpdatedpartial merge
Use store.InitStores() for store reset between test cases.
ARCH-5 — Pure conversion functions have no unit tests specified (HIGH)
Location: P0-1, "backend/convert_pure.go"
Issue: rawHostConfigToModel and rawClusterToModel are placed in convert_pure.go (no CGO, fully testable) but the plan's Verification section specifies only post-hoc integration checks (start server, hit endpoints). The convert_test.go file already tests rawJobToModel and rawWorkerToModel with table-driven tests — the same pattern must be applied here per TDD rules.
Fix: Add to the P0-1 implementation instructions: "Before implementing rawHostConfigToModel and rawClusterToModel, add table-driven tests in backend/convert_test.go covering: nil/empty string fields, zero integer fields (MaxSlot=0, HeartbeatInterval=0), normal values."
ARCH-6 — First-startup host state appears "OK" with zero worker fields (HIGH)
Location: P0-1, "Partial merge semantics"
Issue: After P0-1, the config push fires and creates host entries with HostModel, HostType, MaxSlot set but all worker-sourced fields (HostStatus, MaxMem, NumRun, etc.) at zero. HostStateStr in models/hosts.go:20 maps state=0 to "OK". A GET /xce/v1/hosts call in the window before any worker has checked in will return hosts showing status "OK" with 0 CPUs and 0 memory. Operators and monitoring dashboards will see misleading data.
Fix (minimal, non-breaking): Change HostStateStr(0) from "OK" to "UNKNOWN" or "PENDING". This is a one-line change in models/hosts.go that accurately reflects the not-yet-reported state. Alternatively, add a WorkerDataReceived bool field to HostInfo set by OnHostUpdated.
ARCH-7 — meson.build custom_target block is not specified (MEDIUM)
Location: P1-2, "Build Integration"
Issue: The plan says "add xce_select.peg to that explicit list." The actual meson.build pattern requires a full custom_target block mirroring the existing xce_resreq_parser block — the packcc tool, input/output filenames, command invocation — followed by adding xce_select_parser[0] to libxce_src. Simply adding the filename to libxce_src will fail because meson does not auto-run packcc on .peg files.
Fix: The plan must include the complete custom_target block for xce_select.peg.
ARCH-8 — Heartbeat test uses time.Sleep despite plan prohibiting it (MEDIUM)
Location: Verification section, item 5
Issue: The plan states "The test must not depend on real time" then immediately provides a test using time.Sleep(3500ms). This is a 3.5-second wall-clock delay per test run, and the pass/fail boundary (n < 2 || n > 5) will be flaky on loaded CI systems.
Fix: Expose the ticker as injectable: StartHeartbeat(ctx, tickCh <-chan time.Time) where production code passes time.NewTicker(...).C and tests pass a manually-driven chan time.Time. The test sends exactly N ticks and asserts the counter equals N — zero wall-clock time required.
ARCH-9 — No code-level TODO for O(H) parse-per-call bottleneck (MEDIUM)
Location: P1-2, "Performance" note
Issue: The plan acknowledges that EvalSelectForHost calls parse_select once per host (O(H) parse calls per request) and says "do not over-engineer." The optimization path is described only in the design document — future developers reading the code will not see it.
Fix: Add this comment in EvalSelectForHost in clib.go:
// TODO(perf): parse_select is called once per host. For large clusters, pre-parse
// the expression once and pass the *C.xce_select_t to a batch evaluator.
// See EvalSelectForHostParsed design note in apiserver-backend-enhancement-plan.md.
ARCH-10 — Stale decommissioned hosts have no API signal (MEDIUM)
Location: P0-1, "Partial merge semantics" — host deletion paragraph
Issue: Hosts removed from the cluster configuration remain in store.Hosts indefinitely. There is no LastSeenAt field, no stale flag, no way for operators to distinguish a decommissioned host from a healthy-but-unreachable one. The plan defers TTL, but adds no groundwork for it.
Fix: Add LastConfigSeenAt time.Time and LastWorkerSeenAt time.Time fields to HostInfo. Set them in OnHostConfigUpdated and OnHostUpdated respectively. These fields cost nothing in correctness and provide the data source for a future TTL implementation without requiring a model change later.
Consensus Summary
Issues Requiring Plan Correction Before Implementation
| # | Finding | Severity | Phase |
|---|---|---|---|
| CGO-1 | unsafe.Slice nil pointer — cClusterConfHostConfigs |
HIGH | P0-1 |
| CGO-2 | unsafe.Slice nil pointer — handleNotifyData |
HIGH | P1-1 |
| SEC-1 | recover() does not catch C SIGSEGV — stated protection is false |
HIGH | P1-2 |
| SEC-2 | allFlag package-level data race — concurrent requests corrupt state |
HIGH | P0-2 |
| ARCH-1 | cResourceToModel naming collision — compile error |
HIGH | P0-1 |
| ARCH-2 | host_admin role does not exist in rbac.yaml |
HIGH | P0-2 |
| ARCH-3 | P0-2 stubs cause CGO link failure before P1-3 | HIGH | P0-2 |
| ARCH-4 | OnHostUpdated change has no RED test (TDD violation) |
HIGH | P0-1 |
| ARCH-5 | rawHostConfigToModel/rawClusterToModel have no unit tests specified |
HIGH | P0-1 |
| ARCH-6 | Zero worker fields at first startup show as "OK" status |
HIGH | P0-1 |
Issues Requiring Plan Clarification
| # | Finding | Severity | Phase |
|---|---|---|---|
| SEC-3 | No Go-side range check on nrows before unsafe.Slice in push handler |
MEDIUM | P1-1 |
| SEC-4 | PEG parser packrat requirement not specified (DoS risk) | MEDIUM | P1-2 |
| ARCH-7 | meson.build custom_target block missing from plan |
MEDIUM | P1-2 |
| ARCH-8 | Heartbeat test uses time.Sleep despite plan prohibiting it |
MEDIUM | P1-3 |
| ARCH-9 | No code-level TODO for O(H) parse-per-call bottleneck | MEDIUM | P1-2 |
| ARCH-10 | Stale decommissioned hosts have no API signal | MEDIUM | P0-1 |
| CGO-3 | StartHeartbeat goroutine cannot be interrupted during CGO call |
MEDIUM | P1-3 |
| SEC-5 | C error codes leak to HTTP response body | LOW | P0-2 |
Confirmed Strengths (all reviewers agreed)
- Partial merge semantics are correctly designed: field ownership lists are explicit and the read-then-write pattern is sound for the single-threaded push handler.
OPCode_notifyplaced at the top-level switch (not insidehandlePush) is architecturally correct.cgo.Handleusage for the select callback context is the right CGO approach.RegisterToBackendbeing synchronous ensures heartbeat starts with the correct interval.- PEG grammar placed in
libxce(not Go layer) is correct for scheduler reuse. - The two-step extraction (
xce_request_bodythenxce_notify_data_ptr) is now correct in the plan.
Items Confirmed Not Issues
req.body_whichandnd.eventare plain struct fields, CGO-accessible. ✓(*[0]byte)(C.xce_select_get_attr)is the correct CGO function pointer idiom. ✓C.uintptr_t(h)for cgo.Handle is an integer cast (not pointer), go-vet clean. ✓host:viewfor all authenticated users is intentional HPC design. ✓xcecli_hostctrlblocking CGO: consistent with existing queuectrl pattern, local socket. ✓
Prior Review Summary (2026-04-15)
The prior adversarial + feasibility review identified the following issues, all of which are confirmed fixed in the current plan:
| Issue | Fix Confirmed |
|---|---|
handleNotifyData() used wrong function to extract body |
Fixed: plan now uses xce_request_body(req) first |
xce_val_t.which referenced as .type |
Fixed: plan uses val.which throughout |
handleUpdate() union field accessed directly |
Fixed: plan explicitly states OPCode_update is NOT used by apiserver |
| P1-2 CGO map-as-context was undefined behavior | Fixed: plan uses cgo.Handle |
stub.go stubs omitted for !cgo builds |
Fixed: comprehensive stub section added |
helper.c missing from file list |
Fixed: included with Request_body_hostctrlmsg case |
free_select() / kill_resreq() linkage — memory leak |
Fixed: plan shows both free_select + FREEUP(resreq->select) |
| Heartbeat interval not reconfigurable after start | Fixed: SetHeartbeatInterval + ticker.Reset per tick |
To incorporate this feedback: update apiserver-backend-enhancement-plan.md addressing all HIGH-severity items before implementation begins. MEDIUM items should be resolved before the first PR is opened.