Loading internal/api/api_test.go +14 −0 Changes for internal/api/api_test.go: 14 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -517,6 +517,20 @@ func (f *fakeRepo) LatestBackup(_ context.Context, serverName string) (*BackupRe }, nil } func (f *fakeRepo) BackupByID(_ context.Context, id string) (*BackupRecord, error) { for i := range f.backups { b := &f.backups[i] if b.view.Status == "present" && b.view.ID == id { return &BackupRecord{ ID: b.view.ID, ServerName: b.view.ServerName, FormerOwner: b.view.FormerOwner, BackupRef: b.ref, SizeBytes: b.view.SizeBytes, }, nil } } return nil, ErrNotFound } // ---- local-password auth fakes (spec §B) ---- // Each method mirrors the PGRepo contract: a returned StaffUser is copied so a // test cannot mutate the stored row by reference, SessionUser re-reads the Loading internal/api/handlers_backups.go +52 −16 Changes for internal/api/handlers_backups.go: 52 added lines, 16 removed lines. Original line number Diff line number Diff line Loading @@ -3,6 +3,7 @@ package api import ( "errors" "net/http" "strings" "felis.lolicon.best/internal/apis/felis/v1alpha1" "felis.lolicon.best/internal/naming" Loading Loading @@ -37,9 +38,10 @@ func (a *API) handleListBackups(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"backups": backups}) } // handleRestoreBackup starts restoring a server's world from its most recent // backup (spec §7 POST /servers/{name}/restore-backup; spec §466: former_owner // 3mo 内重新 claim → restore PVC). The authorization is deliberately stricter than // handleRestoreBackup starts restoring a server's world from a backup (spec §7 // POST /servers/{name}/restore-backup; spec §466). It accepts an optional JSON // body with a backup_id; when absent it restores the latest backup for the server // (backward-compatible default). The authorization is deliberately stricter than // ordinary owner-or-admin, in this order: // // ① name validation Loading @@ -47,20 +49,22 @@ func (a *API) handleListBackups(w http.ResponseWriter, r *http.Request) { // ③ owner-or-admin, else 403. A released world's server row is unowned // (owner_id NULL → OwnerID ""), so this also enforces "重新 claim": a former // owner must re-claim the server before they can restore into it. // ④ the latest present backup, else 404 no_backup // ⑤ former-owner match: a non-admin may restore ONLY a world they formerly owned. // The current-owner gate in ③ is not enough — user B who re-claims a released // server could otherwise resurrect user A's world (the backup still carries // former_owner=A), a data leak. Admin skips this check. // ⑥ stopped gate: the world PVC must be free, so restore is refused unless the // server is fully stopped. A running OR starting server still holds the RWO // world volume, which a restore Job could not mount — a clean 409 beats a Job // that fails to schedule. // ⑦ hand off to the Restorer. Restore is asynchronous (a restore Job, like an // ④ if the optional backup_id is supplied the handler resolves the specific // backup; otherwise it picks the most recent present backup, else // 404 no_backup // ⑤ cross-server guard: a backup requested by id must belong to the server in // the path — restoring server A's backup onto server B would be a data leak // ⑥ former-owner match: a non-admin may restore ONLY a world they formerly // owned. The current-owner gate in ③ is not enough — user B who // re-claims a released server could otherwise resurrect user A's world (the // backup still carries former_owner=A), a data leak. Admin skips this check. // ⑦ stopped gate: the world PVC must be free, so restore is refused unless the // server is fully stopped. // ⑧ hand off to the Restorer. Restore is asynchronous (a restore Job, like an // image build Job), so success means "enqueued" and the handler answers 202. // // The opaque backup_ref is resolved server-side from the latest backup and handed // to the Restorer directly; the client never names a backup by handle (spec §286 // The opaque backup_ref is resolved server-side from the backup and handed to the // Restorer directly; the client never names a backup by handle (spec §286 // principle). func (a *API) handleRestoreBackup(w http.ResponseWriter, r *http.Request) { p := principalFromContext(r.Context()) Loading @@ -83,7 +87,38 @@ func (a *API) handleRestoreBackup(w http.ResponseWriter, r *http.Request) { return } backup, err := a.Repo.LatestBackup(r.Context(), name) // Optional backup_id in the JSON body; absent → LatestBackup (backward compat). var body struct { BackupID string `json:"backup_id"` } if strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") { if err := decodeJSON(w, r, &body); err != nil { writeError(w, r, err) return } } // Resolve the backup record. When backup_id is specified the handler resolves // that exact backup; otherwise it picks the most recent present one. var backup *BackupRecord if body.BackupID != "" { backup, err = a.Repo.BackupByID(r.Context(), body.BackupID) if err != nil { if errors.Is(err, ErrNotFound) { writeError(w, r, newError(http.StatusNotFound, "no_backup", "no matching backup exists")) return } writeError(w, r, err) return } // Cross-server guard: the backup must belong to the server named in the path. if backup.ServerName != name { writeError(w, r, errForbidden) return } } else { backup, err = a.Repo.LatestBackup(r.Context(), name) if err != nil { if errors.Is(err, ErrNotFound) { writeError(w, r, newError(http.StatusNotFound, "no_backup", Loading @@ -93,6 +128,7 @@ func (a *API) handleRestoreBackup(w http.ResponseWriter, r *http.Request) { writeError(w, r, err) return } } // A non-admin may restore only a world they formerly owned (spec §466). Without // this a fresh claimant of a released server could resurrect the previous Loading internal/api/handlers_backups_test.go +123 −0 Changes for internal/api/handlers_backups_test.go: 123 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -290,4 +290,127 @@ func TestRestoreBackup(t *testing.T) { t.Fatalf("code = %d, want 400", w.Code) } }) // ---- restore by backup_id ---- // mkTwo supplements the base mk with two present backups for the same server // so tests can exercise restoring the older one by id. bk2 is older than bk1, // so LatestBackup still returns bk1 — restoring by "bk2" proves it reached the // correct record. mkTwo := func() (*API, *fakeRepo, *fakeCluster, *fakeRestorer) { repo := newFakeRepo() repo.byName["survival"] = &ServerRecord{Name: "survival", OwnerID: "owner1"} repo.backups = []fakeBackup{ {view: BackupView{ID: "bk1", ServerName: "survival", FormerOwner: "owner1", Status: "present", Reason: "inactive_15d", SizeBytes: 1024, CreatedAt: time.Unix(1_699_000_000, 0), ExpiresAt: time.Unix(1_706_000_000, 0)}, ref: "ref-bk1"}, {view: BackupView{ID: "bk2", ServerName: "survival", FormerOwner: "owner1", Status: "present", Reason: "manual", SizeBytes: 2048, CreatedAt: time.Unix(1_698_000_000, 0), ExpiresAt: time.Unix(1_706_000_000, 0)}, ref: "ref-bk2"}, } cl := newFakeCluster() cl.byName["survival"] = &ServerInfo{Name: "survival", Phase: "Stopped", Ready: false, DesiredState: string(v1alpha1.DesiredStopped)} restorer := &fakeRestorer{} api := newTestAPI(repo, cl) api.Restorer = restorer return api, repo, cl, restorer } jsonHeaders := map[string]string{"Content-Type": "application/json"} t.Run("restore by backup_id -> 202, correct BackupRef sent", func(t *testing.T) { api, _, _, restorer := mkTwo() api.External = staticExternal{p: owner} body := `{"backup_id":"bk2"}` w := do(api.ExternalHandler(), "POST", path, body, jsonHeaders) if w.Code != http.StatusAccepted { t.Fatalf("code = %d, want 202 (%s)", w.Code, w.Body.String()) } var resp struct { Name string `json:"name"` Status string `json:"status"` BackupID string `json:"backup_id"` } if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("body not JSON: %v (%s)", err, w.Body.String()) } if resp.BackupID != "bk2" { t.Fatalf("backup_id = %q, want bk2", resp.BackupID) } if restorer.gotRef != "ref-bk2" { t.Fatalf("restorer ref = %q, want ref-bk2 (proves BackupByID, not LatestBackup)", restorer.gotRef) } }) t.Run("restore by backup_id not found -> 404 no_backup", func(t *testing.T) { api, _, _, restorer := mkTwo() api.External = staticExternal{p: owner} body := `{"backup_id":"nonexistent"}` w := do(api.ExternalHandler(), "POST", path, body, jsonHeaders) if w.Code != http.StatusNotFound || decodeErr(t, w) != "no_backup" { t.Fatalf("code = %d body %s", w.Code, w.Body.String()) } if restorer.calls != 0 { t.Fatal("non-existent backup must not reach the restorer") } }) t.Run("restore by backup_id that is deleted -> 404 no_backup", func(t *testing.T) { api, repo, _, _ := mkTwo() repo.backups[1].view.Status = "deleted" api.External = staticExternal{p: owner} body := `{"backup_id":"bk2"}` w := do(api.ExternalHandler(), "POST", path, body, jsonHeaders) if w.Code != http.StatusNotFound || decodeErr(t, w) != "no_backup" { t.Fatalf("code = %d body %s", w.Code, w.Body.String()) } }) t.Run("restore by backup_id cross-server -> 403", func(t *testing.T) { api, repo, _, _ := mkTwo() // bk2 belongs to a different server; restoring it onto survival is forbidden. repo.backups[1].view.ServerName = "creative" api.External = staticExternal{p: owner} body := `{"backup_id":"bk2"}` w := do(api.ExternalHandler(), "POST", path, body, jsonHeaders) if w.Code != http.StatusForbidden { t.Fatalf("code = %d, want 403 (cross-server guard)", w.Code) } }) t.Run("no body -> falls back to LatestBackup (backward compat)", func(t *testing.T) { api, _, _, restorer := mkTwo() api.External = staticExternal{p: owner} w := do(api.ExternalHandler(), "POST", path, "", nil) if w.Code != http.StatusAccepted { t.Fatalf("code = %d, want 202 (%s)", w.Code, w.Body.String()) } if restorer.gotRef != "ref-bk1" { t.Fatalf("restorer ref = %q, want ref-bk1 (LatestBackup)", restorer.gotRef) } }) t.Run("empty JSON body -> falls back to LatestBackup", func(t *testing.T) { api, _, _, restorer := mkTwo() api.External = staticExternal{p: owner} body := `{}` w := do(api.ExternalHandler(), "POST", path, body, jsonHeaders) if w.Code != http.StatusAccepted { t.Fatalf("code = %d, want 202 (%s)", w.Code, w.Body.String()) } if restorer.gotRef != "ref-bk1" { t.Fatalf("restorer ref = %q, want ref-bk1 (LatestBackup)", restorer.gotRef) } }) t.Run("malformed JSON body -> 400", func(t *testing.T) { api, _, _, _ := mkTwo() api.External = staticExternal{p: owner} body := `not json` w := do(api.ExternalHandler(), "POST", path, body, jsonHeaders) if w.Code != http.StatusBadRequest { t.Fatalf("code = %d, want 400 (%s)", w.Code, w.Body.String()) } }) } internal/api/pgrepo.go +15 −0 Changes for internal/api/pgrepo.go: 15 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -462,6 +462,21 @@ func (p *PGRepo) LatestBackup(ctx context.Context, serverName string) (*BackupRe return &b, nil } // BackupByID returns a single present backup by its id, or ErrNotFound. func (p *PGRepo) BackupByID(ctx context.Context, id string) (*BackupRecord, error) { const q = `SELECT id, server_name, COALESCE(former_owner, ''), backup_ref, COALESCE(size_bytes, 0) FROM world_backups WHERE id = $1 AND status = 'present'` var b BackupRecord switch err := p.db.QueryRowContext(ctx, q, id).Scan( &b.ID, &b.ServerName, &b.FormerOwner, &b.BackupRef, &b.SizeBytes); { case errors.Is(err, sql.ErrNoRows): return nil, ErrNotFound case err != nil: return nil, err } return &b, nil } func (p *PGRepo) Audit(ctx context.Context, e AuditEntry) error { // A nil Payload must land as SQL NULL, not the text "null"; a non-nil Payload is // passed as a JSON text the jsonb column parses (same idiom as reaper.PGStore). Loading internal/api/repo.go +4 −0 Changes for internal/api/repo.go: 4 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -221,6 +221,10 @@ type Repo interface { // carries the server-side backup_ref + former_owner the restore path needs; the // client never sees them. LatestBackup(ctx context.Context, serverName string) (*BackupRecord, error) // BackupByID returns a single present backup by its id, or ErrNotFound when // none matches. Like LatestBackup the returned BackupRecord carries the // server-side backup_ref the restore path needs; the client never sees it. BackupByID(ctx context.Context, id string) (*BackupRecord, error) // SeedServer inserts the business-layer rows for a newly created server (spec // §15): a servers row (owner_id NULL — claimed later, spec §9.3) and its // subdomain alias, both idempotent. It returns ErrConflict if the subdomain is Loading Loading
internal/api/api_test.go +14 −0 Changes for internal/api/api_test.go: 14 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -517,6 +517,20 @@ func (f *fakeRepo) LatestBackup(_ context.Context, serverName string) (*BackupRe }, nil } func (f *fakeRepo) BackupByID(_ context.Context, id string) (*BackupRecord, error) { for i := range f.backups { b := &f.backups[i] if b.view.Status == "present" && b.view.ID == id { return &BackupRecord{ ID: b.view.ID, ServerName: b.view.ServerName, FormerOwner: b.view.FormerOwner, BackupRef: b.ref, SizeBytes: b.view.SizeBytes, }, nil } } return nil, ErrNotFound } // ---- local-password auth fakes (spec §B) ---- // Each method mirrors the PGRepo contract: a returned StaffUser is copied so a // test cannot mutate the stored row by reference, SessionUser re-reads the Loading
internal/api/handlers_backups.go +52 −16 Changes for internal/api/handlers_backups.go: 52 added lines, 16 removed lines. Original line number Diff line number Diff line Loading @@ -3,6 +3,7 @@ package api import ( "errors" "net/http" "strings" "felis.lolicon.best/internal/apis/felis/v1alpha1" "felis.lolicon.best/internal/naming" Loading Loading @@ -37,9 +38,10 @@ func (a *API) handleListBackups(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"backups": backups}) } // handleRestoreBackup starts restoring a server's world from its most recent // backup (spec §7 POST /servers/{name}/restore-backup; spec §466: former_owner // 3mo 内重新 claim → restore PVC). The authorization is deliberately stricter than // handleRestoreBackup starts restoring a server's world from a backup (spec §7 // POST /servers/{name}/restore-backup; spec §466). It accepts an optional JSON // body with a backup_id; when absent it restores the latest backup for the server // (backward-compatible default). The authorization is deliberately stricter than // ordinary owner-or-admin, in this order: // // ① name validation Loading @@ -47,20 +49,22 @@ func (a *API) handleListBackups(w http.ResponseWriter, r *http.Request) { // ③ owner-or-admin, else 403. A released world's server row is unowned // (owner_id NULL → OwnerID ""), so this also enforces "重新 claim": a former // owner must re-claim the server before they can restore into it. // ④ the latest present backup, else 404 no_backup // ⑤ former-owner match: a non-admin may restore ONLY a world they formerly owned. // The current-owner gate in ③ is not enough — user B who re-claims a released // server could otherwise resurrect user A's world (the backup still carries // former_owner=A), a data leak. Admin skips this check. // ⑥ stopped gate: the world PVC must be free, so restore is refused unless the // server is fully stopped. A running OR starting server still holds the RWO // world volume, which a restore Job could not mount — a clean 409 beats a Job // that fails to schedule. // ⑦ hand off to the Restorer. Restore is asynchronous (a restore Job, like an // ④ if the optional backup_id is supplied the handler resolves the specific // backup; otherwise it picks the most recent present backup, else // 404 no_backup // ⑤ cross-server guard: a backup requested by id must belong to the server in // the path — restoring server A's backup onto server B would be a data leak // ⑥ former-owner match: a non-admin may restore ONLY a world they formerly // owned. The current-owner gate in ③ is not enough — user B who // re-claims a released server could otherwise resurrect user A's world (the // backup still carries former_owner=A), a data leak. Admin skips this check. // ⑦ stopped gate: the world PVC must be free, so restore is refused unless the // server is fully stopped. // ⑧ hand off to the Restorer. Restore is asynchronous (a restore Job, like an // image build Job), so success means "enqueued" and the handler answers 202. // // The opaque backup_ref is resolved server-side from the latest backup and handed // to the Restorer directly; the client never names a backup by handle (spec §286 // The opaque backup_ref is resolved server-side from the backup and handed to the // Restorer directly; the client never names a backup by handle (spec §286 // principle). func (a *API) handleRestoreBackup(w http.ResponseWriter, r *http.Request) { p := principalFromContext(r.Context()) Loading @@ -83,7 +87,38 @@ func (a *API) handleRestoreBackup(w http.ResponseWriter, r *http.Request) { return } backup, err := a.Repo.LatestBackup(r.Context(), name) // Optional backup_id in the JSON body; absent → LatestBackup (backward compat). var body struct { BackupID string `json:"backup_id"` } if strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") { if err := decodeJSON(w, r, &body); err != nil { writeError(w, r, err) return } } // Resolve the backup record. When backup_id is specified the handler resolves // that exact backup; otherwise it picks the most recent present one. var backup *BackupRecord if body.BackupID != "" { backup, err = a.Repo.BackupByID(r.Context(), body.BackupID) if err != nil { if errors.Is(err, ErrNotFound) { writeError(w, r, newError(http.StatusNotFound, "no_backup", "no matching backup exists")) return } writeError(w, r, err) return } // Cross-server guard: the backup must belong to the server named in the path. if backup.ServerName != name { writeError(w, r, errForbidden) return } } else { backup, err = a.Repo.LatestBackup(r.Context(), name) if err != nil { if errors.Is(err, ErrNotFound) { writeError(w, r, newError(http.StatusNotFound, "no_backup", Loading @@ -93,6 +128,7 @@ func (a *API) handleRestoreBackup(w http.ResponseWriter, r *http.Request) { writeError(w, r, err) return } } // A non-admin may restore only a world they formerly owned (spec §466). Without // this a fresh claimant of a released server could resurrect the previous Loading
internal/api/handlers_backups_test.go +123 −0 Changes for internal/api/handlers_backups_test.go: 123 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -290,4 +290,127 @@ func TestRestoreBackup(t *testing.T) { t.Fatalf("code = %d, want 400", w.Code) } }) // ---- restore by backup_id ---- // mkTwo supplements the base mk with two present backups for the same server // so tests can exercise restoring the older one by id. bk2 is older than bk1, // so LatestBackup still returns bk1 — restoring by "bk2" proves it reached the // correct record. mkTwo := func() (*API, *fakeRepo, *fakeCluster, *fakeRestorer) { repo := newFakeRepo() repo.byName["survival"] = &ServerRecord{Name: "survival", OwnerID: "owner1"} repo.backups = []fakeBackup{ {view: BackupView{ID: "bk1", ServerName: "survival", FormerOwner: "owner1", Status: "present", Reason: "inactive_15d", SizeBytes: 1024, CreatedAt: time.Unix(1_699_000_000, 0), ExpiresAt: time.Unix(1_706_000_000, 0)}, ref: "ref-bk1"}, {view: BackupView{ID: "bk2", ServerName: "survival", FormerOwner: "owner1", Status: "present", Reason: "manual", SizeBytes: 2048, CreatedAt: time.Unix(1_698_000_000, 0), ExpiresAt: time.Unix(1_706_000_000, 0)}, ref: "ref-bk2"}, } cl := newFakeCluster() cl.byName["survival"] = &ServerInfo{Name: "survival", Phase: "Stopped", Ready: false, DesiredState: string(v1alpha1.DesiredStopped)} restorer := &fakeRestorer{} api := newTestAPI(repo, cl) api.Restorer = restorer return api, repo, cl, restorer } jsonHeaders := map[string]string{"Content-Type": "application/json"} t.Run("restore by backup_id -> 202, correct BackupRef sent", func(t *testing.T) { api, _, _, restorer := mkTwo() api.External = staticExternal{p: owner} body := `{"backup_id":"bk2"}` w := do(api.ExternalHandler(), "POST", path, body, jsonHeaders) if w.Code != http.StatusAccepted { t.Fatalf("code = %d, want 202 (%s)", w.Code, w.Body.String()) } var resp struct { Name string `json:"name"` Status string `json:"status"` BackupID string `json:"backup_id"` } if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("body not JSON: %v (%s)", err, w.Body.String()) } if resp.BackupID != "bk2" { t.Fatalf("backup_id = %q, want bk2", resp.BackupID) } if restorer.gotRef != "ref-bk2" { t.Fatalf("restorer ref = %q, want ref-bk2 (proves BackupByID, not LatestBackup)", restorer.gotRef) } }) t.Run("restore by backup_id not found -> 404 no_backup", func(t *testing.T) { api, _, _, restorer := mkTwo() api.External = staticExternal{p: owner} body := `{"backup_id":"nonexistent"}` w := do(api.ExternalHandler(), "POST", path, body, jsonHeaders) if w.Code != http.StatusNotFound || decodeErr(t, w) != "no_backup" { t.Fatalf("code = %d body %s", w.Code, w.Body.String()) } if restorer.calls != 0 { t.Fatal("non-existent backup must not reach the restorer") } }) t.Run("restore by backup_id that is deleted -> 404 no_backup", func(t *testing.T) { api, repo, _, _ := mkTwo() repo.backups[1].view.Status = "deleted" api.External = staticExternal{p: owner} body := `{"backup_id":"bk2"}` w := do(api.ExternalHandler(), "POST", path, body, jsonHeaders) if w.Code != http.StatusNotFound || decodeErr(t, w) != "no_backup" { t.Fatalf("code = %d body %s", w.Code, w.Body.String()) } }) t.Run("restore by backup_id cross-server -> 403", func(t *testing.T) { api, repo, _, _ := mkTwo() // bk2 belongs to a different server; restoring it onto survival is forbidden. repo.backups[1].view.ServerName = "creative" api.External = staticExternal{p: owner} body := `{"backup_id":"bk2"}` w := do(api.ExternalHandler(), "POST", path, body, jsonHeaders) if w.Code != http.StatusForbidden { t.Fatalf("code = %d, want 403 (cross-server guard)", w.Code) } }) t.Run("no body -> falls back to LatestBackup (backward compat)", func(t *testing.T) { api, _, _, restorer := mkTwo() api.External = staticExternal{p: owner} w := do(api.ExternalHandler(), "POST", path, "", nil) if w.Code != http.StatusAccepted { t.Fatalf("code = %d, want 202 (%s)", w.Code, w.Body.String()) } if restorer.gotRef != "ref-bk1" { t.Fatalf("restorer ref = %q, want ref-bk1 (LatestBackup)", restorer.gotRef) } }) t.Run("empty JSON body -> falls back to LatestBackup", func(t *testing.T) { api, _, _, restorer := mkTwo() api.External = staticExternal{p: owner} body := `{}` w := do(api.ExternalHandler(), "POST", path, body, jsonHeaders) if w.Code != http.StatusAccepted { t.Fatalf("code = %d, want 202 (%s)", w.Code, w.Body.String()) } if restorer.gotRef != "ref-bk1" { t.Fatalf("restorer ref = %q, want ref-bk1 (LatestBackup)", restorer.gotRef) } }) t.Run("malformed JSON body -> 400", func(t *testing.T) { api, _, _, _ := mkTwo() api.External = staticExternal{p: owner} body := `not json` w := do(api.ExternalHandler(), "POST", path, body, jsonHeaders) if w.Code != http.StatusBadRequest { t.Fatalf("code = %d, want 400 (%s)", w.Code, w.Body.String()) } }) }
internal/api/pgrepo.go +15 −0 Changes for internal/api/pgrepo.go: 15 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -462,6 +462,21 @@ func (p *PGRepo) LatestBackup(ctx context.Context, serverName string) (*BackupRe return &b, nil } // BackupByID returns a single present backup by its id, or ErrNotFound. func (p *PGRepo) BackupByID(ctx context.Context, id string) (*BackupRecord, error) { const q = `SELECT id, server_name, COALESCE(former_owner, ''), backup_ref, COALESCE(size_bytes, 0) FROM world_backups WHERE id = $1 AND status = 'present'` var b BackupRecord switch err := p.db.QueryRowContext(ctx, q, id).Scan( &b.ID, &b.ServerName, &b.FormerOwner, &b.BackupRef, &b.SizeBytes); { case errors.Is(err, sql.ErrNoRows): return nil, ErrNotFound case err != nil: return nil, err } return &b, nil } func (p *PGRepo) Audit(ctx context.Context, e AuditEntry) error { // A nil Payload must land as SQL NULL, not the text "null"; a non-nil Payload is // passed as a JSON text the jsonb column parses (same idiom as reaper.PGStore). Loading
internal/api/repo.go +4 −0 Changes for internal/api/repo.go: 4 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -221,6 +221,10 @@ type Repo interface { // carries the server-side backup_ref + former_owner the restore path needs; the // client never sees them. LatestBackup(ctx context.Context, serverName string) (*BackupRecord, error) // BackupByID returns a single present backup by its id, or ErrNotFound when // none matches. Like LatestBackup the returned BackupRecord carries the // server-side backup_ref the restore path needs; the client never sees it. BackupByID(ctx context.Context, id string) (*BackupRecord, error) // SeedServer inserts the business-layer rows for a newly created server (spec // §15): a servers row (owner_id NULL — claimed later, spec §9.3) and its // subdomain alias, both idempotent. It returns ErrConflict if the subdomain is Loading