feat(submit): give the upload lane a lifecycle — withdraw + admin delete (#76)

Nothing ever removed a submission: users could not retract a pending row, no
route deleted blobs or rows, and the reaper never touches uploads — so every
upload accumulated on the 5 GiB PVC forever and the only cleanup was SQL or
kubectl against the store.

- Blobs.Delete on both transports (local: RemoveAll of the id-namespaced dir,
  id re-validated at the boundary; S3: idempotent object DELETE).
- Store: DeleteSubmission (admin, any status) and DeletePendingSubmission
  (owner+pending CAS — a reviewed row can never be withdrawn out from under
  its build).
- Manager.Delete / Manager.Withdraw delete the ROW first (under the CAS for
  withdraw) and the blob after, so a live row can never point at a reaped
  blob; a cleanup failure names the orphan explicitly instead of failing mute.
- API: DELETE /me/submissions/{id} (withdraw, app tier) and
  DELETE /api/v1/submissions/{id} (admin) both return the row as it was;
  audit events submission.withdraw / submission.delete; openapi documents both
  paths; admin route pinned in the admin-only table.
- Panel: two-step withdraw on a pending row (frees the pending slot and the
  storage budget); two-step delete on every admin row; zh/en copy; wire tests.

Unit: submit (withdraw happy path / wrong owner / reviewed row / no transport /
blob-cleanup failure), local+S3 delete idempotence, api handlers (200/404/409/
503 + route tier); pgint: withdraw CAS + admin delete exactly-once.
go vet/go test/gofmt clean; panel vitest 120 + typecheck green.
This commit is contained in:
Lemon-miaow committed 2026-09-24 10:24:23 +08:00
1 parent ad4d256d8f
commit 854320ac3f
20 files changed
+828 -45

No files matched your search

+9
View File
@@ -518,6 +518,11 @@ func (a *API) externalAPIRoutes() []apiRoute {
// App-tier and owner-scoped (the id must belong to the principal), exactly
// like the create/list routes above.
{Method: "POST", Pattern: "/api/v1/me/submissions/{id}/context", h: a.handleUploadSubmissionContext},
// Withdraw the caller's OWN pending submission: the row and its uploaded
// context are deleted, freeing the pending slot and storage budget. Same
// owner-scoping as the upload route — a reviewed submission is frozen (409)
// and another user's id is invisible (404).
{Method: "DELETE", Pattern: "/api/v1/me/submissions/{id}", h: a.handleWithdrawSubmission},
// Admin (Zero-Trust) tier: create / mutate spec / image admission. These gate
// on Principal.IsAdmin() inside the handler via the adminOnly wrapper, so the
// boundary is exercised even where the body is a later-phase stub.
@@ -547,6 +552,10 @@ 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},
// Retire a submission outright (row + uploaded context), any status. The
// lane's lifecycle valve: without it, rejected/consumed uploads accumulated
// on the uploads PVC forever — there is no other delete path.
{Method: "DELETE", Pattern: "/api/v1/submissions/{id}", Admin: true, h: a.handleDeleteSubmission},
// The reviewer's read path to the uploaded blob: the executed Dockerfile
// lives inside it, so approval would otherwise be blind.
{Method: "GET", Pattern: "/api/v1/submissions/{id}/context", Admin: true, h: a.handleAdminSubmissionContext},
+49
View File
@@ -43,6 +43,13 @@ type SubmissionService interface {
// Reject is the admin's other verdict: pending_review -> rejected with a
// required reason; it starts no build.
Reject(ctx context.Context, id, reviewedBy, reason string) (*submit.Submission, error)
// Withdraw retracts the caller's OWN pending submission: the row and its
// uploaded context are deleted. A reviewed submission is frozen (409) and a
// submission the caller does not own reads back as 404, like the upload route.
Withdraw(ctx context.Context, id, submittedBy string) (*submit.Submission, error)
// Delete retires any submission outright (the admin lifecycle valve): the row
// and its uploaded context are removed, any status.
Delete(ctx context.Context, id string) (*submit.Submission, error)
// OpenContext returns the stored build-context blob for the internal
// context-fetch route: the build Pod's initContainer cannot mount the uploads
// PVC across namespaces and holds no object-store credentials, so it streams
@@ -294,6 +301,48 @@ func (a *API) handleRejectSubmission(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, sub)
}
// handleWithdrawSubmission retracts the caller's own pending submission
// (app-tier): the row and its uploaded context are deleted, freeing the pending
// slot and the storage budget for a fresh submission. The submitter is the
// principal, never the body; a submission the caller does not own is reported as
// 404, so this endpoint cannot probe or clear another user's uploads, and a
// reviewed submission is 409 (its build may already be consuming the context).
func (a *API) handleWithdrawSubmission(w http.ResponseWriter, r *http.Request) {
if a.Submissions == nil {
writeError(w, r, errSubmissionsUnavailable)
return
}
p := principalFromContext(r.Context())
sub, err := a.Submissions.Withdraw(r.Context(), r.PathValue("id"), p.UserID)
if err != nil {
writeSubmitError(w, r, err)
return
}
a.audit(r, p.Email, "submission.withdraw", sub.ID)
writeJSON(w, http.StatusOK, sub)
}
// handleDeleteSubmission retires any submission outright (admin-tier): the row
// and its uploaded context are removed, any status. This is the lane's lifecycle
// valve — the only path that reclaims a rejected or consumed upload from the
// uploads PVC. The reviewer identity goes to the audit event, not the (now
// nonexistent) row. Deleting an approved submission whose build is still running
// fails that build's context fetch; the admin has explicitly chosen to retire it.
func (a *API) handleDeleteSubmission(w http.ResponseWriter, r *http.Request) {
if a.Submissions == nil {
writeError(w, r, errSubmissionsUnavailable)
return
}
p := principalFromContext(r.Context())
sub, err := a.Submissions.Delete(r.Context(), r.PathValue("id"))
if err != nil {
writeSubmitError(w, r, err)
return
}
a.audit(r, p.Email, "submission.delete", sub.ID)
writeJSON(w, http.StatusOK, sub)
}
// errSubmissionsUnavailable is returned when the approval lane is not configured
// on this api instance (a nil Submissions service), so the admin/app boundary is
// still exercised before the subsystem is wired in.
+106 -21
View File
@@ -20,27 +20,32 @@ import (
// what the handler forwarded (the point of the owner-scoping checks: the
// submitter and reviewer must come from the principal, never the body).
type fakeSubmissions struct {
created *submit.CreateRequest
createErr error
uploadedID string
uploadedBy string
uploadedN int64
uploadErr error
listedBy string
byResult []submit.Submission
byErr error
listed []submit.Submission
listErr error
approvedID string
approvedBy string
approveErr error
rejectedID string
rejectedBy string
rejectReas string
rejectErr error
openedID string
openBody string
openErr error
created *submit.CreateRequest
createErr error
uploadedID string
uploadedBy string
uploadedN int64
uploadErr error
listedBy string
byResult []submit.Submission
byErr error
listed []submit.Submission
listErr error
approvedID string
approvedBy string
approveErr error
rejectedID string
rejectedBy string
rejectReas string
rejectErr error
withdrawnID string
withdrawBy string
withdrawErr error
deletedID string
deleteErr error
openedID string
openBody string
openErr error
}
func (f *fakeSubmissions) Create(_ context.Context, req submit.CreateRequest) (*submit.Submission, error) {
@@ -88,6 +93,22 @@ func (f *fakeSubmissions) Reject(_ context.Context, id, reviewedBy, reason strin
return &submit.Submission{ID: id, Status: submit.StatusRejected, ReviewedBy: reviewedBy, RejectReason: reason}, nil
}
func (f *fakeSubmissions) Withdraw(_ context.Context, id, submittedBy string) (*submit.Submission, error) {
f.withdrawnID, f.withdrawBy = id, submittedBy
if f.withdrawErr != nil {
return nil, f.withdrawErr
}
return &submit.Submission{ID: id, SubmittedBy: submittedBy, Status: submit.StatusPendingReview}, nil
}
func (f *fakeSubmissions) Delete(_ context.Context, id string) (*submit.Submission, error) {
f.deletedID = id
if f.deleteErr != nil {
return nil, f.deleteErr
}
return &submit.Submission{ID: id, Status: submit.StatusRejected}, nil
}
// openErr injects the OpenContext outcome; the body recorder lets the internal
// route test assert byte-exact streaming and the 404 mapping.
func (f *fakeSubmissions) OpenContext(_ context.Context, id string) (io.ReadCloser, error) {
@@ -249,6 +270,69 @@ func TestUploadSubmissionContextWithoutServiceIs503(t *testing.T) {
}
}
// Withdraw retracts the caller's OWN pending submission: the submitter is the
// principal (never the body), a reviewed submission is 409, and a foreign id is
// 404 — the same posture as the upload route.
func TestWithdrawSubmission(t *testing.T) {
t.Run("withdraws as the principal", func(t *testing.T) {
fs := &fakeSubmissions{}
w := do(appSubAPI(fs).ExternalHandler(), "DELETE", "/api/v1/me/submissions/sub-3", "", nil)
if w.Code != http.StatusOK {
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
}
if fs.withdrawnID != "sub-3" || fs.withdrawBy != "user-7" {
t.Fatalf("withdraw forwarded (%q, %q), want (sub-3, user-7)", fs.withdrawnID, fs.withdrawBy)
}
})
t.Run("reviewed submission is 409", func(t *testing.T) {
fs := &fakeSubmissions{withdrawErr: submit.ErrAlreadyReviewed}
w := do(appSubAPI(fs).ExternalHandler(), "DELETE", "/api/v1/me/submissions/sub-3", "", nil)
if w.Code != http.StatusConflict {
t.Fatalf("code = %d, want 409 (%s)", w.Code, w.Body.String())
}
if got := decodeErr(t, w); got != "already_reviewed" {
t.Errorf("error code = %q, want already_reviewed", got)
}
})
t.Run("foreign or unknown id is 404", func(t *testing.T) {
fs := &fakeSubmissions{withdrawErr: submit.ErrNotFound}
w := do(appSubAPI(fs).ExternalHandler(), "DELETE", "/api/v1/me/submissions/sub-x", "", nil)
if w.Code != http.StatusNotFound {
t.Fatalf("code = %d, want 404 (%s)", w.Code, w.Body.String())
}
})
t.Run("no service is 503", func(t *testing.T) {
app := appSubAPI(nil)
app.Submissions = nil
w := do(app.ExternalHandler(), "DELETE", "/api/v1/me/submissions/sub-3", "", nil)
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("code = %d, want 503 (%s)", w.Code, w.Body.String())
}
})
}
// The admin delete retires any submission and maps the lane's 404; the route's
// admin gate itself is pinned by TestSubmissionAdminRoutesAreAdminOnly.
func TestDeleteSubmissionAdmin(t *testing.T) {
t.Run("deletes the named row", func(t *testing.T) {
fs := &fakeSubmissions{}
w := do(adminSubAPI(fs).ExternalHandler(), "DELETE", "/api/v1/submissions/sub-8", "", nil)
if w.Code != http.StatusOK {
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
}
if fs.deletedID != "sub-8" {
t.Fatalf("delete forwarded id %q, want sub-8", fs.deletedID)
}
})
t.Run("unknown is 404", func(t *testing.T) {
fs := &fakeSubmissions{deleteErr: submit.ErrNotFound}
w := do(adminSubAPI(fs).ExternalHandler(), "DELETE", "/api/v1/submissions/sub-x", "", nil)
if w.Code != http.StatusNotFound {
t.Fatalf("code = %d, want 404 (%s)", w.Code, w.Body.String())
}
})
}
// The "my uploads" list scopes strictly to the principal's id — there is no
// parameter that could widen it to another user's submissions.
func TestMySubmissionsScopesToPrincipal(t *testing.T) {
@@ -343,6 +427,7 @@ func TestSubmissionAdminRoutesAreAdminOnly(t *testing.T) {
{"GET", "/api/v1/submissions", ""},
{"POST", "/api/v1/submissions/sub-1/approve", ""},
{"POST", "/api/v1/submissions/sub-1/reject", `{"reason":"no"}`},
{"DELETE", "/api/v1/submissions/sub-1", ""},
{"GET", "/api/v1/submissions/sub-1/context", ""},
}
for _, c := range cases {