feat(api): add admin API for the SysAdmin-set auto-update maintenance window
Two admin-tier routes read and set a single platform-wide maintenance
window for the auto-update subsystem (decision core internal/updates):
GET /api/v1/updates/window
PUT /api/v1/updates/window
The window is stored as JSON {"start","end"} (RFC3339, or null when
unset) under the platform_settings key "update_window", reusing the
existing GetSetting/SetSetting KV seam -- no new Repo method, no
migration. Pointer times keep "unset" (null) distinct from a real
instant on both decode and encode; a never-set and an explicitly
cleared window both read back as {null,null}.
Validation mirrors the core's fail-closed Window: a window is either
fully set (both ends, end strictly after start) or fully cleared (both
null). A half-set, inverted, or empty-interval body is 400 and is never
persisted. Reads treat only a missing key as unset (ErrNotFound -> 200
nulls); any other store error 500s rather than fail open.
This is API + PERSISTENCE ONLY. Nothing consumes the stored window yet
-- the runner, the ReleaseSource/Notifier/Applier executors, and the
scheduler CronJob remain INTEGRATION-ONLY. Setting a window changes no
behavior until those land; it is the durable input they will read.
Nothing here force-updates ("不要强制自动更新").
This commit is contained in:
4 files changed
+392
No files matched your search
@@ -167,6 +167,26 @@ components:
|
||||
type: string
|
||||
description: Correlates the response with server logs (withRequestID middleware).
|
||||
|
||||
UpdateWindow:
|
||||
type: object
|
||||
description: >
|
||||
The SysAdmin-set auto-update maintenance window (internal/api/handlers_updates.go
|
||||
updateWindow). An absolute [start,end) interval during which Felis may apply a
|
||||
Scheduled component's update to itself; both ends null means unset (no apply is
|
||||
ever opened). Keys are always present; their values are null when unset.
|
||||
required: [start, end]
|
||||
properties:
|
||||
start:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
description: Window start (RFC3339, inclusive), or null when unset.
|
||||
end:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
description: Window end (RFC3339, exclusive), or null when unset.
|
||||
|
||||
PasskeyCredential:
|
||||
type: object
|
||||
description: >
|
||||
@@ -1464,6 +1484,67 @@ paths:
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
|
||||
/api/v1/updates/window:
|
||||
get:
|
||||
tags: [admin-updates]
|
||||
operationId: getUpdateWindow
|
||||
summary: Read the SysAdmin-set auto-update maintenance window (admin).
|
||||
description: >-
|
||||
The single platform-wide maintenance window during which Felis may apply a
|
||||
Scheduled component's update to itself (decision core internal/updates). An
|
||||
unset window — never set, or explicitly cleared — reads back as
|
||||
{start:null,end:null}. API+persistence only: nothing consumes the window
|
||||
until the INTEGRATION runner and executors are wired, so setting it changes
|
||||
no behavior yet.
|
||||
x-felis-face: [external]
|
||||
x-felis-tier: admin
|
||||
security: [{ accessJWT: [] }]
|
||||
responses:
|
||||
'200':
|
||||
description: The current maintenance window (both ends null when unset).
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UpdateWindow'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
put:
|
||||
tags: [admin-updates]
|
||||
operationId: setUpdateWindow
|
||||
summary: Set or clear the SysAdmin auto-update maintenance window (admin).
|
||||
description: >-
|
||||
Persist the maintenance window as an absolute [start,end) interval. Both
|
||||
ends must be set with end strictly after start, or both null to clear the
|
||||
window to unset. A half-set (exactly one end) or inverted/empty (end not
|
||||
after start) body is rejected 400, mirroring the decision core's fail-closed
|
||||
Window so a malformed schedule can never be stored. No forced auto-update:
|
||||
setting a window only permits an apply inside it; outside, a Scheduled
|
||||
component degrades to notify.
|
||||
x-felis-face: [external]
|
||||
x-felis-tier: admin
|
||||
security: [{ accessJWT: [] }]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UpdateWindow'
|
||||
responses:
|
||||
'200':
|
||||
description: The stored maintenance window (echoed back).
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/UpdateWindow'
|
||||
'400':
|
||||
$ref: '#/components/responses/BadRequest'
|
||||
'401':
|
||||
$ref: '#/components/responses/Unauthorized'
|
||||
'403':
|
||||
$ref: '#/components/responses/Forbidden'
|
||||
|
||||
/api/v1/fleet:
|
||||
get:
|
||||
tags: [admin-servers]
|
||||
|
||||
@@ -332,6 +332,12 @@ func (a *API) externalAPIRoutes() []apiRoute {
|
||||
{Method: "GET", Pattern: "/api/v1/submissions", Admin: true, h: a.handleListSubmissions},
|
||||
{Method: "POST", Pattern: "/api/v1/submissions/{id}/approve", Admin: true, h: a.handleApproveSubmission},
|
||||
{Method: "POST", Pattern: "/api/v1/submissions/{id}/reject", Admin: true, h: a.handleRejectSubmission},
|
||||
// Auto-update maintenance window (spec §B; decision core internal/updates).
|
||||
// Admin-tier: it governs whether Felis may apply an update to itself, so setting
|
||||
// it requires the admin Zero-Trust path, not a mere session. API+persistence
|
||||
// only — the runner/executors that consume the window are still INTEGRATION-ONLY.
|
||||
{Method: "GET", Pattern: "/api/v1/updates/window", Admin: true, h: a.handleGetUpdateWindow},
|
||||
{Method: "PUT", Pattern: "/api/v1/updates/window", Admin: true, h: a.handleSetUpdateWindow},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SysAdmin-set maintenance window for the auto-update subsystem (task #38; the
|
||||
// decision core is internal/updates). The user red line is "不要强制自动更新": Felis
|
||||
// never force-upgrades. A PolicyScheduled component may only be applied by Felis
|
||||
// while now falls inside a window a SysAdmin explicitly set here; outside it, the
|
||||
// same component degrades to notify-only. These two admin routes are how that
|
||||
// window is read and set.
|
||||
//
|
||||
// SINGLE GLOBAL WINDOW (deliberate). internal/updates models the window PER
|
||||
// Component (Component.Window), but this endpoint stores ONE platform-wide window.
|
||||
// The task scopes "the SysAdmin-set update Window" as a single maintenance slot,
|
||||
// and the core's own comment defers recurrence to the caller — so the (not-yet-
|
||||
// built, INTEGRATION-ONLY) `felis update` runner reads this one window and fans it
|
||||
// out to every Scheduled+manageable component when it assembles its []Component.
|
||||
// A future need for per-component windows would layer keys on top; this is the
|
||||
// platform default.
|
||||
//
|
||||
// This slice is API + PERSISTENCE ONLY. Nothing consumes the stored window yet:
|
||||
// the runner, the ReleaseSource/Notifier/Applier executors and the scheduler
|
||||
// CronJob are all still INTEGRATION-ONLY (task #38 remainder). Setting a window
|
||||
// today changes no behavior until those land — it is the durable input they will
|
||||
// read. The Panel UI that drives these routes is out of scope (hands-off-frontend).
|
||||
|
||||
// updateWindowKey is the platform_settings key holding the maintenance window as
|
||||
// JSON {"start","end"} (RFC3339, or null when unset). It reuses the generic
|
||||
// settings KV rather than a dedicated table: the window is a single small tuple a
|
||||
// human edits rarely, exactly the shape platform_settings exists for (like
|
||||
// LocalAuthEnabledKey).
|
||||
const updateWindowKey = "update_window"
|
||||
|
||||
// updateWindow is the wire + storage shape of the maintenance window. Pointer times
|
||||
// make "unset" (null) distinguishable from a real instant on both decode and
|
||||
// encode: an absent/null start or end marshals back to JSON null, and the core's
|
||||
// zero-Window semantics ("unset ⇒ Contains is always false") map onto a stored
|
||||
// {null,null}. The two ends are an absolute [start,end) interval — the SysAdmin
|
||||
// picks a concrete next window; recurrence is the runner's concern, not this store's.
|
||||
type updateWindow struct {
|
||||
Start *time.Time `json:"start"`
|
||||
End *time.Time `json:"end"`
|
||||
}
|
||||
|
||||
// handleGetUpdateWindow returns the current maintenance window (admin-tier). An
|
||||
// unset window — never written, or explicitly cleared to {null,null} — both read
|
||||
// back as {start:null,end:null}, so the caller has one shape to handle. Only a
|
||||
// genuinely missing key is treated as unset (ErrNotFound → 200 nulls); any OTHER
|
||||
// store error is a real failure and 500s rather than masquerading as "no window"
|
||||
// (a fail-open read on a DB blip would silently drop a scheduled window).
|
||||
func (a *API) handleGetUpdateWindow(w http.ResponseWriter, r *http.Request) {
|
||||
raw, err := a.Repo.GetSetting(r.Context(), updateWindowKey)
|
||||
switch {
|
||||
case errors.Is(err, ErrNotFound):
|
||||
writeJSON(w, http.StatusOK, updateWindow{})
|
||||
return
|
||||
case err != nil:
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
var win updateWindow
|
||||
if err := json.Unmarshal(raw, &win); err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, win)
|
||||
}
|
||||
|
||||
// handleSetUpdateWindow persists the maintenance window (admin-tier). Validation
|
||||
// mirrors the decision core's fail-closed Window: a window is either fully set
|
||||
// (both ends, end strictly after start) or fully cleared (both null) — never
|
||||
// half-set, never inverted. A half-set body (exactly one end) or an inverted /
|
||||
// empty interval (end not after start) is a 400, so a malformed schedule can never
|
||||
// be stored and later fail open. Both-null clears the window to unset.
|
||||
//
|
||||
// No "not in the past" check is enforced: a window whose end has passed is harmless
|
||||
// — the core's Window.Contains returns false once now ≥ end — so a stale window
|
||||
// simply never re-opens an apply rather than being an error to store.
|
||||
func (a *API) handleSetUpdateWindow(w http.ResponseWriter, r *http.Request) {
|
||||
if err := requireJSONContentType(r); err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
var body updateWindow
|
||||
if err := decodeJSON(w, r, &body); err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case body.Start == nil && body.End == nil:
|
||||
// clearing to unset — fall through to persist {null,null}
|
||||
case body.Start == nil || body.End == nil:
|
||||
writeError(w, r, newError(http.StatusBadRequest, "bad_request",
|
||||
"start and end must both be set or both be null"))
|
||||
return
|
||||
case !body.End.After(*body.Start):
|
||||
writeError(w, r, newError(http.StatusBadRequest, "bad_request",
|
||||
"end must be after start"))
|
||||
return
|
||||
}
|
||||
|
||||
value, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
if err := a.Repo.SetSetting(r.Context(), updateWindowKey, value); err != nil {
|
||||
writeError(w, r, err)
|
||||
return
|
||||
}
|
||||
a.audit(r, principalFromContext(r.Context()).Email, "updates.window_set", "")
|
||||
writeJSON(w, http.StatusOK, body)
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Auto-update maintenance-window admin API tests (task #38; decision core
|
||||
// internal/updates). The load-bearing cases: an unset window and an explicitly
|
||||
// cleared window both read back as {null,null} (two paths, one shape); a valid
|
||||
// window round-trips through persistence; a half-set or inverted window is
|
||||
// rejected fail-closed and never stored; and — the tier boundary — the routes are
|
||||
// admin-only, gating precisely on IsAdmin() = role==admin AND the admin-access
|
||||
// path, so neither a role=user nor an admin off the operator host can touch them.
|
||||
|
||||
// seedUpdatesAPI returns an API whose external face authenticates every request as
|
||||
// a full admin (role=admin AND ViaAdminAccess), so the admin-tier routes are
|
||||
// reachable and the tests exercise the handler logic. Gating tests override
|
||||
// api.External to vary the principal.
|
||||
func seedUpdatesAPI(t *testing.T) (*API, *fakeRepo) {
|
||||
t.Helper()
|
||||
repo := newFakeRepo()
|
||||
api := newTestAPI(repo, newFakeCluster())
|
||||
api.External = staticExternal{p: &Principal{
|
||||
UserID: "admin1", Email: "admin@" + testRoot, Role: "admin", ViaAdminAccess: true,
|
||||
}}
|
||||
return api, repo
|
||||
}
|
||||
|
||||
// decodeWindow reads the {start,end} body, each a string or nil.
|
||||
func decodeWindow(t *testing.T, w interface{ Bytes() []byte }) (start, end any) {
|
||||
t.Helper()
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(w.Bytes(), &body); err != nil {
|
||||
t.Fatalf("window body not JSON: %v", err)
|
||||
}
|
||||
if _, ok := body["start"]; !ok {
|
||||
t.Fatalf("window body missing start key: %s", string(w.Bytes()))
|
||||
}
|
||||
if _, ok := body["end"]; !ok {
|
||||
t.Fatalf("window body missing end key: %s", string(w.Bytes()))
|
||||
}
|
||||
return body["start"], body["end"]
|
||||
}
|
||||
|
||||
// TestUpdateWindowUnsetReturnsNulls: a never-set window reads back as {null,null}
|
||||
// (ErrNotFound → 200 nulls), so the client has one shape whether or not a window
|
||||
// was ever configured.
|
||||
func TestUpdateWindowUnsetReturnsNulls(t *testing.T) {
|
||||
api, _ := seedUpdatesAPI(t)
|
||||
w := do(api.ExternalHandler(), "GET", "/api/v1/updates/window", "", nil)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
if start, end := decodeWindow(t, w.Body); start != nil || end != nil {
|
||||
t.Fatalf("unset window = {%v,%v}, want {null,null}", start, end)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateWindowSetThenGet: a valid window is persisted and read back verbatim.
|
||||
func TestUpdateWindowSetThenGet(t *testing.T) {
|
||||
api, repo := seedUpdatesAPI(t)
|
||||
const startISO, endISO = "2026-08-01T02:00:00Z", "2026-08-01T04:00:00Z"
|
||||
body := `{"start":"` + startISO + `","end":"` + endISO + `"}`
|
||||
|
||||
put := do(api.ExternalHandler(), "PUT", "/api/v1/updates/window", body, jsonHeader)
|
||||
if put.Code != http.StatusOK {
|
||||
t.Fatalf("PUT code = %d, want 200 (%s)", put.Code, put.Body.String())
|
||||
}
|
||||
if start, end := decodeWindow(t, put.Body); start != startISO || end != endISO {
|
||||
t.Fatalf("PUT echo = {%v,%v}, want {%s,%s}", start, end, startISO, endISO)
|
||||
}
|
||||
// It was actually persisted (not merely echoed).
|
||||
if _, ok := repo.settings[updateWindowKey]; !ok {
|
||||
t.Fatalf("window was not written to platform_settings[%q]", updateWindowKey)
|
||||
}
|
||||
|
||||
get := do(api.ExternalHandler(), "GET", "/api/v1/updates/window", "", nil)
|
||||
if get.Code != http.StatusOK {
|
||||
t.Fatalf("GET code = %d, want 200 (%s)", get.Code, get.Body.String())
|
||||
}
|
||||
if start, end := decodeWindow(t, get.Body); start != startISO || end != endISO {
|
||||
t.Fatalf("GET after set = {%v,%v}, want {%s,%s}", start, end, startISO, endISO)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateWindowClearRoundtrips: PUT {null,null} clears a previously set window,
|
||||
// and the cleared window reads back as {null,null} — the SAME shape as never-set,
|
||||
// exercising the second code path that yields nulls.
|
||||
func TestUpdateWindowClearRoundtrips(t *testing.T) {
|
||||
api, _ := seedUpdatesAPI(t)
|
||||
set := do(api.ExternalHandler(), "PUT", "/api/v1/updates/window",
|
||||
`{"start":"2026-08-01T02:00:00Z","end":"2026-08-01T04:00:00Z"}`, jsonHeader)
|
||||
if set.Code != http.StatusOK {
|
||||
t.Fatalf("initial set code = %d, want 200 (%s)", set.Code, set.Body.String())
|
||||
}
|
||||
|
||||
clear := do(api.ExternalHandler(), "PUT", "/api/v1/updates/window", `{"start":null,"end":null}`, jsonHeader)
|
||||
if clear.Code != http.StatusOK {
|
||||
t.Fatalf("clear code = %d, want 200 (%s)", clear.Code, clear.Body.String())
|
||||
}
|
||||
if start, end := decodeWindow(t, clear.Body); start != nil || end != nil {
|
||||
t.Fatalf("clear echo = {%v,%v}, want {null,null}", start, end)
|
||||
}
|
||||
|
||||
get := do(api.ExternalHandler(), "GET", "/api/v1/updates/window", "", nil)
|
||||
if start, end := decodeWindow(t, get.Body); start != nil || end != nil {
|
||||
t.Fatalf("GET after clear = {%v,%v}, want {null,null}", start, end)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateWindowRejectsMalformed: a half-set (exactly one end) or inverted/empty
|
||||
// (end not after start) window is 400 and is NEVER written — the store can only
|
||||
// ever hold a fully-valid or a fully-cleared window, mirroring the core's
|
||||
// fail-closed Window.
|
||||
func TestUpdateWindowRejectsMalformed(t *testing.T) {
|
||||
for _, tc := range []struct{ name, body string }{
|
||||
{"start without end", `{"start":"2026-08-01T02:00:00Z","end":null}`},
|
||||
{"end without start", `{"start":null,"end":"2026-08-01T04:00:00Z"}`},
|
||||
{"inverted", `{"start":"2026-08-01T04:00:00Z","end":"2026-08-01T02:00:00Z"}`},
|
||||
{"empty interval", `{"start":"2026-08-01T02:00:00Z","end":"2026-08-01T02:00:00Z"}`},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
api, repo := seedUpdatesAPI(t)
|
||||
w := do(api.ExternalHandler(), "PUT", "/api/v1/updates/window", tc.body, jsonHeader)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("code = %d, want 400 (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
if code := decodeErr(t, w); code != "bad_request" {
|
||||
t.Fatalf("error code = %q, want bad_request", code)
|
||||
}
|
||||
if _, ok := repo.settings[updateWindowKey]; ok {
|
||||
t.Fatal("a rejected window must not be persisted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateWindowContentTypeGuard pins the cross-site-forgery guard on the mutating
|
||||
// PUT: a body an HTML form could emit is rejected 415 before any write.
|
||||
func TestUpdateWindowContentTypeGuard(t *testing.T) {
|
||||
api, repo := seedUpdatesAPI(t)
|
||||
w := do(api.ExternalHandler(), "PUT", "/api/v1/updates/window",
|
||||
`{"start":"2026-08-01T02:00:00Z","end":"2026-08-01T04:00:00Z"}`,
|
||||
map[string]string{"Content-Type": "application/x-www-form-urlencoded"})
|
||||
if w.Code != http.StatusUnsupportedMediaType {
|
||||
t.Fatalf("code = %d, want 415 (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
if _, ok := repo.settings[updateWindowKey]; ok {
|
||||
t.Fatal("a content-type-rejected PUT must not persist")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateWindowAdminGating proves the tier boundary discriminates on IsAdmin()
|
||||
// precisely — role==admin AND the admin-access path — not on accident. A full admin
|
||||
// reads 200; an admin who did NOT arrive via admin access, and a role=user player,
|
||||
// are both 403 on both the GET and the mutating PUT.
|
||||
func TestUpdateWindowAdminGating(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
p *Principal
|
||||
want int
|
||||
}{
|
||||
{"admin via admin-access", &Principal{UserID: "a1", Role: "admin", ViaAdminAccess: true}, http.StatusOK},
|
||||
{"admin off operator host", &Principal{UserID: "a1", Role: "admin", ViaAdminAccess: false}, http.StatusForbidden},
|
||||
{"role=user player", &Principal{UserID: "u1", Role: "user", ViaAdminAccess: false}, http.StatusForbidden},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
repo := newFakeRepo()
|
||||
api := newTestAPI(repo, newFakeCluster())
|
||||
api.External = staticExternal{p: tc.p}
|
||||
|
||||
get := do(api.ExternalHandler(), "GET", "/api/v1/updates/window", "", nil)
|
||||
if get.Code != tc.want {
|
||||
t.Fatalf("GET code = %d, want %d (%s)", get.Code, tc.want, get.Body.String())
|
||||
}
|
||||
// A valid PUT by a full admin is 200; a non-admin is 403 (adminOnly rejects
|
||||
// before the handler), so the wanted PUT code is the same as the GET's.
|
||||
put := do(api.ExternalHandler(), "PUT", "/api/v1/updates/window",
|
||||
`{"start":"2026-08-01T02:00:00Z","end":"2026-08-01T04:00:00Z"}`, jsonHeader)
|
||||
if put.Code != tc.want {
|
||||
t.Fatalf("PUT code = %d, want %d (%s)", put.Code, tc.want, put.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user