386f51edc6
The controller waits ~15 minutes with app mails after a crash boot of the host; it learns of the crash boot from this route. Reads /var/lib/felhom-crash-guard/state.json (read-only, no Proxmox call) and passes present/last_boot_at/last_boot_unclean/tripped through; a missing, unreadable or garbled file answers 200 present:false. Guest-token authed like every sibling route. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
206 lines
7.4 KiB
Go
206 lines
7.4 KiB
Go
package localapi
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
// The shape of /var/lib/felhom-crash-guard/state.json as read on demo-hp on 2026-10-06 (values from
|
|
// that read where they matter; lists/objects kept to the same key set).
|
|
const crashGuardFixture = `{
|
|
"armed": true,
|
|
"boot_id": "3f1c0f1e-6a0b-4d7e-9b7a-0c2d4e6f8a1b",
|
|
"config": {"LIMIT": 3, "WINDOW_MINUTES": 60, "PANIC_SECONDS": 10},
|
|
"kernel_panic": 10,
|
|
"last_boot_at": "2026-10-05T07:56:41Z",
|
|
"last_boot_unclean": true,
|
|
"last_trip": {},
|
|
"rearmed_at": "2026-10-04T14:02:11Z",
|
|
"rearmed_by": "operator",
|
|
"tripped": false,
|
|
"unclean_boots": ["2026-10-05T07:56:41Z"],
|
|
"unclean_boots_24h": 1,
|
|
"unclean_boots_in_window": 1,
|
|
"updated_at": "2026-10-05T07:57:02Z",
|
|
"version": 1
|
|
}`
|
|
|
|
// controllerCrashGuardState is a COPY of the controller's wire type, felhom-controller
|
|
// controller/internal/agentapi/crashguard.go `CrashGuardState` (commit 8b13a5e) — same field names,
|
|
// same tags. If either side renames a key, the contract test below fails.
|
|
type controllerCrashGuardState struct {
|
|
Present bool `json:"present"`
|
|
LastBootAt string `json:"last_boot_at,omitempty"`
|
|
LastBootUnclean bool `json:"last_boot_unclean"`
|
|
Tripped bool `json:"tripped"`
|
|
}
|
|
|
|
func newCrashGuardServer(t *testing.T, statePath string) http.Handler {
|
|
t.Helper()
|
|
srv, err := NewServer(Options{
|
|
ListenAddr: "127.0.0.1:0",
|
|
Guests: &fakeGuests{},
|
|
Backups: &fakeBackups{},
|
|
Store: &fakeStore{},
|
|
Storage: fakeStorage{},
|
|
Tokens: staticTokens{"A": 8200, "B": 9300},
|
|
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("new server: %v", err)
|
|
}
|
|
srv.crashGuardStatePath = statePath
|
|
return srv.Handler()
|
|
}
|
|
|
|
func writeCrashGuardFixture(t *testing.T, body string) string {
|
|
t.Helper()
|
|
p := filepath.Join(t.TempDir(), "state.json")
|
|
if err := os.WriteFile(p, []byte(body), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return p
|
|
}
|
|
|
|
// getCrashGuard calls the route and decodes the envelope with the CONTROLLER's type.
|
|
func getCrashGuard(t *testing.T, h http.Handler, token string) (int, controllerCrashGuardState, string) {
|
|
t.Helper()
|
|
w := do(t, h, "GET", "/host/crash-guard", token, "")
|
|
var env struct {
|
|
OK bool `json:"ok"`
|
|
Data controllerCrashGuardState `json:"data"`
|
|
}
|
|
if w.Code == http.StatusOK {
|
|
if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil {
|
|
t.Fatalf("decode %q: %v", w.Body.String(), err)
|
|
}
|
|
if !env.OK {
|
|
t.Fatalf("ok=false: %s", w.Body.String())
|
|
}
|
|
}
|
|
return w.Code, env.Data, w.Body.String()
|
|
}
|
|
|
|
// A present state file (demo-hp's shape) passes the three facts through.
|
|
func TestR856_CrashGuardPresentFile(t *testing.T) {
|
|
h := newCrashGuardServer(t, writeCrashGuardFixture(t, crashGuardFixture))
|
|
code, st, body := getCrashGuard(t, h, "A")
|
|
if code != http.StatusOK {
|
|
t.Fatalf("got %d, want 200 (%s)", code, body)
|
|
}
|
|
want := controllerCrashGuardState{Present: true, LastBootAt: "2026-10-05T07:56:41Z", LastBootUnclean: true, Tripped: false}
|
|
if st != want {
|
|
t.Fatalf("state = %+v, want %+v", st, want)
|
|
}
|
|
|
|
// A tripped, clean boot reads back as such (both bools are carried, not defaulted).
|
|
h = newCrashGuardServer(t, writeCrashGuardFixture(t,
|
|
`{"last_boot_at":"2026-10-05T09:56:41+02:00","last_boot_unclean":false,"tripped":true,"version":1}`))
|
|
_, st, _ = getCrashGuard(t, h, "B")
|
|
want = controllerCrashGuardState{Present: true, LastBootAt: "2026-10-05T07:56:41Z", LastBootUnclean: false, Tripped: true}
|
|
if st != want {
|
|
t.Fatalf("offset time / tripped: state = %+v, want %+v (time normalised to UTC Z)", st, want)
|
|
}
|
|
}
|
|
|
|
// No state file (no guard on this host, or no boot recorded yet) → 200 present:false.
|
|
func TestR856_CrashGuardMissingFile(t *testing.T) {
|
|
h := newCrashGuardServer(t, filepath.Join(t.TempDir(), "absent", "state.json"))
|
|
code, st, body := getCrashGuard(t, h, "A")
|
|
if code != http.StatusOK {
|
|
t.Fatalf("missing file: got %d, want 200 (%s)", code, body)
|
|
}
|
|
if st.Present || st.LastBootUnclean || st.Tripped || st.LastBootAt != "" {
|
|
t.Fatalf("missing file: state = %+v, want present:false and nothing else", st)
|
|
}
|
|
}
|
|
|
|
// A garbled file → 200 present:false, never a 5xx — every shape of garbage.
|
|
func TestR856_CrashGuardGarbageFile(t *testing.T) {
|
|
for name, body := range map[string]string{
|
|
"truncated": crashGuardFixture[:40],
|
|
"not json": "this is not json\n",
|
|
"empty": "",
|
|
"null": "null",
|
|
"array": `[{"last_boot_unclean":true}]`,
|
|
"wrong type": `{"last_boot_at":"2026-10-05T07:56:41Z","last_boot_unclean":"yes","tripped":false}`,
|
|
"lone brace": "{",
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
h := newCrashGuardServer(t, writeCrashGuardFixture(t, body))
|
|
code, st, raw := getCrashGuard(t, h, "A")
|
|
if code != http.StatusOK {
|
|
t.Fatalf("got %d, want 200 (%s)", code, raw)
|
|
}
|
|
if st.Present || st.LastBootUnclean {
|
|
t.Fatalf("garbage %q read as %+v, want present:false", name, st)
|
|
}
|
|
})
|
|
}
|
|
// The path is a directory, not a file: unreadable → present:false, 200.
|
|
h := newCrashGuardServer(t, t.TempDir())
|
|
if code, st, raw := getCrashGuard(t, h, "A"); code != http.StatusOK || st.Present {
|
|
t.Fatalf("directory path: got %d %+v (%s), want 200 present:false", code, st, raw)
|
|
}
|
|
}
|
|
|
|
// No / unknown token → 401, like every sibling route; a cross-guest ?vmid= → 403.
|
|
func TestR856_CrashGuardRequiresGuestToken(t *testing.T) {
|
|
h := newCrashGuardServer(t, writeCrashGuardFixture(t, crashGuardFixture))
|
|
for _, tok := range []string{"", "bogus"} {
|
|
w := do(t, h, "GET", "/host/crash-guard", tok, "")
|
|
if w.Code != http.StatusUnauthorized {
|
|
t.Fatalf("token %q: got %d, want 401", tok, w.Code)
|
|
}
|
|
if json.Valid(w.Body.Bytes()) {
|
|
var env struct {
|
|
Data controllerCrashGuardState `json:"data"`
|
|
}
|
|
_ = json.Unmarshal(w.Body.Bytes(), &env)
|
|
if env.Data.Present || env.Data.LastBootUnclean {
|
|
t.Fatalf("token %q: the refusal leaked the state: %s", tok, w.Body.String())
|
|
}
|
|
}
|
|
}
|
|
if w := do(t, h, "GET", "/host/crash-guard?vmid=9300", "A", ""); w.Code != http.StatusForbidden {
|
|
t.Fatalf("cross-guest query: got %d, want 403", w.Code)
|
|
}
|
|
}
|
|
|
|
// Wire contract: every key the controller's CrashGuardState decodes is emitted under exactly that
|
|
// name, and the agent emits no key the controller does not know.
|
|
func TestR856_CrashGuardWireMatchesControllerClient(t *testing.T) {
|
|
h := newCrashGuardServer(t, writeCrashGuardFixture(t, crashGuardFixture))
|
|
w := do(t, h, "GET", "/host/crash-guard", "A", "")
|
|
var env struct {
|
|
OK bool `json:"ok"`
|
|
Data map[string]json.RawMessage `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(w.Body.Bytes(), &env); err != nil || !env.OK {
|
|
t.Fatalf("envelope: %v %s", err, w.Body.String())
|
|
}
|
|
want := []string{"present", "last_boot_at", "last_boot_unclean", "tripped"}
|
|
for _, k := range want {
|
|
if _, ok := env.Data[k]; !ok {
|
|
t.Errorf("agent does not emit %q, which the controller decodes (%s)", k, w.Body.String())
|
|
}
|
|
}
|
|
if len(env.Data) != len(want) {
|
|
t.Errorf("agent emits %d keys, controller knows %d: %s", len(env.Data), len(want), w.Body.String())
|
|
}
|
|
// And the agent's own type agrees with the controller's copy, field for field.
|
|
var mine CrashGuardResponse
|
|
var theirs controllerCrashGuardState
|
|
raw, _ := json.Marshal(env.Data)
|
|
_ = json.Unmarshal(raw, &mine)
|
|
_ = json.Unmarshal(raw, &theirs)
|
|
if (controllerCrashGuardState{mine.Present, mine.LastBootAt, mine.LastBootUnclean, mine.Tripped}) != theirs {
|
|
t.Errorf("agent %+v vs controller %+v", mine, theirs)
|
|
}
|
|
}
|