Unverified Commit d2de11af authored by Lemon-miaow's avatar Lemon-miaow
Browse files

feat(panel): fleet

parent 742f15f3
Loading
Loading
Loading
Loading
+15 −4
Changes for docs/openapi.yaml: 15 added lines, 4 removed lines.
Original line number Diff line number Diff line
@@ -1417,14 +1417,15 @@ paths:
        Every MinecraftServer's CRD+status lifecycle view, for the SysAdmin
        FleetTable. Admin-tier — it reads every owner's server. A path distinct
        from the internal velocity GET /api/v1/servers because one {method, path}
        cannot carry both the service and admin tiers. CRD truth only: owner and
        the other Postgres business fields are deliberately not joined (§1).
        cannot carry both the service and admin tiers. Lifecycle is CRD truth (§1);
        the owner is the only business field, joined READ-ONLY from Postgres (§6)
        for display — best-effort, so a Postgres blip degrades to owner-less rows.
      x-felis-face: [external]
      x-felis-tier: admin
      security: [{ accessJWT: [] }]
      responses:
        '200':
          description: Every server's status projection (fleet-wide).
          description: Every server's status projection (fleet-wide), each with its owner.
          content:
            application/json:
              schema:
@@ -1433,7 +1434,17 @@ paths:
                properties:
                  servers:
                    type: array
                    items: { $ref: '#/components/schemas/ServerInfo' }
                    items:
                      allOf:
                        - $ref: '#/components/schemas/ServerInfo'
                        - type: object
                          properties:
                            owner:
                              type: string
                              description: >-
                                The owner's display identity (email, or username when
                                the address is absent). Absent for an unclaimed server
                                or when the best-effort owner lookup failed.
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
+64 −5
Changes for internal/api/api_test.go: 64 added lines, 5 removed lines.
Original line number Diff line number Diff line
@@ -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
Changes for internal/api/handlers_user.go: 27 added lines, 5 removed lines.
Original line number Diff line number Diff line
@@ -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 −0
Changes for internal/api/pgrepo.go: 27 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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 −0
Changes for internal/api/repo.go: 9 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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)
Loading