feat(panel): fleet

This commit is contained in:
Lemon-miaow committed 2026-07-01 03:46:07 +08:00
1 parent 742f15f348
commit d2de11af22
14 files changed
+978 -38

No files matched your search

+64 -5
View File
@@ -31,6 +31,11 @@ type fakeRepo struct {
// the account_links-bridged web view of the same data.
allowUUID map[string]map[string]bool
mine map[string][]MyServerView
// owners mirrors the ServerOwners join (name -> owner display identity); only
// claimed servers appear. ownersErr forces the lookup to fail so a test can
// prove the fleet read degrades to owner-less rows rather than 500ing.
owners map[string]string
ownersErr error
claimOK map[string]bool // name -> claim succeeds; absent name -> ErrNotFound
audits []AuditEntry
joins []string
@@ -139,6 +144,7 @@ func newFakeRepo() *fakeRepo {
linked: map[string]bool{}, quota: map[string]bool{},
allowlist: map[string]map[string]bool{}, allowUUID: map[string]map[string]bool{},
mine: map[string][]MyServerView{},
owners: map[string]string{},
claimOK: map[string]bool{},
seeded: map[string]bool{}, aliases: map[string]string{},
linkCodes: map[string]fakeLinkCode{}, links: map[string]string{},
@@ -411,6 +417,12 @@ func (f *fakeRepo) RecordJoin(_ context.Context, n, uuid string) error {
func (f *fakeRepo) MyServers(_ context.Context, u string) ([]MyServerView, error) {
return f.mine[u], nil
}
func (f *fakeRepo) ServerOwners(_ context.Context) (map[string]string, error) {
if f.ownersErr != nil {
return nil, f.ownersErr
}
return f.owners, nil
}
func (f *fakeRepo) SeedServer(_ context.Context, name, subdomain string) error {
if f.seedErr != nil {
return f.seedErr
@@ -861,21 +873,68 @@ func TestFleetAdminRead(t *testing.T) {
}
})
t.Run("admin reads the whole fleet", func(t *testing.T) {
api := newTestAPI(newFakeRepo(), cl)
// fleetRow mirrors the on-the-wire fleetServerView: the lifecycle fields plus
// the presentational owner join. A server absent from ServerOwners (unclaimed)
// or a failed lookup must serialize owner as "" (omitempty drops it).
type fleetRow struct {
Name string `json:"name"`
Owner string `json:"owner"`
}
adminAPI := func(repo *fakeRepo) *API {
api := newTestAPI(repo, cl)
api.External = staticExternal{p: &Principal{UserID: "a1", Email: "[email protected]",
Role: "admin", ViaAdminAccess: true}}
return api
}
readFleet := func(t *testing.T, api *API) []fleetRow {
t.Helper()
w := do(api.ExternalHandler(), "GET", "/api/v1/fleet", "", nil)
if w.Code != http.StatusOK {
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
}
var got map[string][]ServerInfo
var got map[string][]fleetRow
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("body not JSON: %v", err)
}
return got["servers"]
}
t.Run("admin reads the whole fleet", func(t *testing.T) {
rows := readFleet(t, adminAPI(newFakeRepo()))
// Fleet-wide: all three servers, not a caller-scoped subset.
if len(got["servers"]) != 3 {
t.Fatalf("servers = %d, want 3 (the fleet read must not be caller-scoped)", len(got["servers"]))
if len(rows) != 3 {
t.Fatalf("servers = %d, want 3 (the fleet read must not be caller-scoped)", len(rows))
}
})
t.Run("owner merges for claimed, absent for unclaimed", func(t *testing.T) {
repo := newFakeRepo()
// Only "survival" is claimed; "creative"/"skyblock" stay unowned.
repo.owners["survival"] = "[email protected]"
byName := map[string]string{}
for _, r := range readFleet(t, adminAPI(repo)) {
byName[r.Name] = r.Owner
}
if byName["survival"] != "[email protected]" {
t.Fatalf("survival owner = %q, want [email protected]", byName["survival"])
}
if byName["creative"] != "" {
t.Fatalf("creative owner = %q, want empty (unclaimed)", byName["creative"])
}
})
t.Run("owner lookup failure degrades to owner-less rows", func(t *testing.T) {
repo := newFakeRepo()
repo.owners["survival"] = "[email protected]" // would merge, but the lookup errors
repo.ownersErr = fmt.Errorf("postgres unreachable")
rows := readFleet(t, adminAPI(repo)) // must still be 200, not 500
if len(rows) != 3 {
t.Fatalf("servers = %d, want 3 (a Postgres blip must not drop the fleet)", len(rows))
}
for _, r := range rows {
if r.Owner != "" {
t.Fatalf("%s owner = %q, want empty (owner lookup failed → degrade)", r.Name, r.Owner)
}
}
})
}
+27 -5
View File
@@ -208,17 +208,39 @@ func (a *API) handleMyServers(w http.ResponseWriter, r *http.Request) {
// same CRD-truth source as the velocity pull, §1) but is a DISTINCT handler so
// each route's provenance and tier stay honest, and so the two never share a
// {method, path} key — the OpenAPI parity test forbids one path carrying both the
// service and admin tiers across faces. CRD truth only: owner and the other
// Postgres business fields are deliberately not joined here (§1 — the CRD is the
// lifecycle authority, Postgres the business authority; this read stays on the
// lifecycle side).
// service and admin tiers across faces. Lifecycle is read from the CRD (§1); the
// one business field the cockpit needs — the owner — is joined READ-ONLY from
// Postgres at request time (§6 business authority) purely for display. This keeps
// §1 honest: owner is never written back to the CRD and the CRD is never treated
// as its source; the two stores keep their split, the read just renders both.
func (a *API) handleFleet(w http.ResponseWriter, r *http.Request) {
servers, err := a.Cluster.ListServers(r.Context())
if err != nil {
writeError(w, r, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"servers": servers})
// Owner is presentational and best-effort. The cockpit exists for the lifecycle
// view, so a Postgres hiccup must degrade to owner-less rows, never 500 the whole
// fleet: a lookup error is swallowed and owners stays nil, leaving every row's
// Owner "" (a nil map reads as zero values).
owners, _ := a.Repo.ServerOwners(r.Context())
views := make([]fleetServerView, len(servers))
for i, s := range servers {
views[i] = fleetServerView{ServerInfo: s, Owner: owners[s.Name]}
}
writeJSON(w, http.StatusOK, map[string]any{"servers": views})
}
// fleetServerView is one row of the SysAdmin cockpit's fleet read: the CRD
// lifecycle view (ServerInfo, §1 authority) with the owner's display identity
// joined alongside. The embed keeps every lifecycle field flat in the JSON so the
// shape is a strict superset of ServerInfo; Owner is the only addition.
type fleetServerView struct {
ServerInfo
// Owner is the claiming user's display identity (email, or username when the
// address is absent), or "" when the server is unclaimed or the best-effort
// owner lookup failed — the cockpit renders "" as "unclaimed".
Owner string `json:"owner,omitempty"`
}
// createServerRequest is the structured §15 create-server form. This is the
+27
View File
@@ -259,6 +259,33 @@ func (p *PGRepo) MyServers(ctx context.Context, userID string) ([]MyServerView,
return out, rows.Err()
}
// ServerOwners returns name -> owner display identity for every currently-owned,
// non-deleted server (the SysAdmin cockpit's fleet read). The INNER JOIN drops
// unclaimed servers (owner_id NULL) and the deleted_at filter drops soft-deleted
// ones, so the map holds only servers that have a live owner — the cockpit reads a
// missing key as "no owner". The display value prefers the recognizable email
// (the same identity the audit log records as the human actor, §6) and falls back
// to the never-NULL username when the address is absent.
func (p *PGRepo) ServerOwners(ctx context.Context) (map[string]string, error) {
const q = `SELECT s.name, COALESCE(NULLIF(u.email, ''), u.username)
FROM servers s JOIN users u ON u.id = s.owner_id
WHERE s.deleted_at IS NULL`
rows, err := p.db.QueryContext(ctx, q)
if err != nil {
return nil, err
}
defer rows.Close()
out := make(map[string]string)
for rows.Next() {
var name, owner string
if err := rows.Scan(&name, &owner); err != nil {
return nil, err
}
out[name] = owner
}
return out, rows.Err()
}
// SeedServer inserts the business rows backing a newly created server (spec
// §15): the servers row (owner_id left NULL — the server is created unowned and
// claimed later, spec §9.3) and its subdomain alias. Both inserts are
+9
View File
@@ -176,6 +176,15 @@ type Repo interface {
RecordJoin(ctx context.Context, name, mcUUID string) error
// MyServers lists the servers a user owns or may claim.
MyServers(ctx context.Context, userID string) ([]MyServerView, error)
// ServerOwners maps each currently-owned server to its owner's display identity
// (email, or username when the address is absent), for the SysAdmin cockpit's
// fleet read. It is a READ-ONLY presentational join: owner stays authored in
// Postgres (§6 business authority) and is never written back to the CRD, so this
// does not breach §1's store-of-record split. Unclaimed and soft-deleted servers
// are simply absent from the map, so a missing key reads as "no owner". The
// cockpit treats it as best-effort — a lookup error degrades to owner-less rows
// rather than failing the fleet read — so callers may ignore the error.
ServerOwners(ctx context.Context) (map[string]string, error)
// AllBackups lists every present world backup, newest first (spec §7 GET
// /backups, admin scope). Expired/deleted rows are never returned.
AllBackups(ctx context.Context) ([]BackupView, error)