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:
20 files changed
+828
-45
No files matched your search
@@ -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},
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user