Skip to content

Commit 1701c4a

Browse files
AlexgodorojaAlex Godoroja
andauthored
publish-server: decouple build from submit via an admin /admin/build endpoint (#30)
We can't build a bundle for every submission. /api/submit now just validates + records a 'submitted' case (no build) and returns 202. Building is an explicit, admin-token-gated action: POST /admin/build (guards submitted|build_failed → building → async buildAsync → pending|build_failed). The case page shows a 'Build bundles' button that POSTs id + token (token injected from the admin dashboard URL, same pattern as approve/reject). adminApprove still guards on pending. New status: submitted. e2e-managed drives submit → /admin/build → pending → approve (11/11 pass). Co-authored-by: Alex Godoroja <alex@vulturelabs.io>
1 parent c9bd9bb commit 1701c4a

5 files changed

Lines changed: 70 additions & 18 deletions

File tree

cmd/publish-server/main.go

Lines changed: 44 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,7 @@ func main() {
105105
mux.Handle("GET /static/", http.FileServer(http.FS(assets)))
106106
mux.HandleFunc("GET /admin", s.adminList)
107107
mux.HandleFunc("GET /admin/case", s.adminCase)
108+
mux.HandleFunc("POST /admin/build", s.adminBuild)
108109
mux.HandleFunc("POST /admin/approve", s.adminApprove)
109110
mux.HandleFunc("POST /admin/reject", s.adminReject)
110111

@@ -198,20 +199,56 @@ func (s *server) apiSubmit(w http.ResponseWriter, r *http.Request) {
198199
writeJSON(w, 422, map[string]any{"errors": errs})
199200
return
200201
}
201-
// Record a "building" case and return immediately — the 4-platform build can
202-
// outlast the ingress (Cloudflare ~100s) timeout if done inline, which dropped
203-
// the form's request (HTTP 524/000). The build runs in the background and the
204-
// case flips to "pending" (or "build_failed") when it finishes; the admin
205-
// board reflects the live status.
206-
c, err := s.cases.CreateBuilding(sub)
202+
// Record the submission WITHOUT building — we don't build a bundle for every
203+
// submission. An admin triggers the build per case (POST /admin/build, e.g.
204+
// the "Build bundles" button on the case page). Returns instantly.
205+
c, err := s.cases.CreateSubmitted(sub)
207206
if err != nil {
208207
writeJSON(w, 409, map[string]any{"errors": []string{err.Error()}})
209208
return
210209
}
211-
go s.buildAsync(c.CaseID, sub)
212210
writeJSON(w, 202, map[string]any{"case_id": c.CaseID, "status": c.Status})
213211
}
214212

213+
// adminBuild kicks off the async bundle build for a submitted (or previously
214+
// failed) case. Admin-token gated, same as approve/reject. The build runs in a
215+
// background goroutine; the case flips submitted/build_failed → building →
216+
// pending (or build_failed). Triggered by the "Build bundles" button on the
217+
// case page, which injects the admin token from the dashboard URL.
218+
func (s *server) adminBuild(w http.ResponseWriter, r *http.Request) {
219+
if !s.adminOK(r) {
220+
http.Error(w, "admin token required", http.StatusUnauthorized)
221+
return
222+
}
223+
id := r.FormValue("id")
224+
c, err := s.cases.Get(id)
225+
if err != nil {
226+
http.Error(w, "unknown case", 404)
227+
return
228+
}
229+
switch c.Status {
230+
case publish.StatusSubmitted, publish.StatusBuildFailed:
231+
// ok to (re)build
232+
case publish.StatusBuilding:
233+
http.Error(w, "already building", http.StatusConflict)
234+
return
235+
default:
236+
http.Error(w, fmt.Sprintf("case is %q — build only applies to submitted or build_failed cases", c.Status), http.StatusConflict)
237+
return
238+
}
239+
if _, err := s.cases.SetStatus(id, publish.StatusBuilding, "build started"); err != nil {
240+
http.Error(w, "could not start build: "+err.Error(), 500)
241+
return
242+
}
243+
go s.buildAsync(id, c.Submission)
244+
// Back to the case page (preserve the admin token), like approve/reject.
245+
u := "/admin/case?id=" + id
246+
if t := r.FormValue("token"); t != "" {
247+
u += "&token=" + t
248+
}
249+
http.Redirect(w, r, u, http.StatusSeeOther)
250+
}
251+
215252
// buildAsync builds the bundle for an already-recorded case and flips it to
216253
// pending, or marks it build_failed. Runs in its own goroutine so the submit
217254
// response is instant (no ingress timeout on the synchronous build).

cmd/publish-server/templates/case.html

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,19 @@ <h1>{{.C.Submission.Listing.DisplayName}} <span class="soft">{{.C.Submission.Ver
5656
<div class="card review"><h2>Generated pilotctl</h2><pre>{{range .Commands}}{{.}}
5757
{{end}}</pre></div>
5858

59+
{{if or (eq (printf "%s" .C.Status) "submitted") (eq (printf "%s" .C.Status) "build_failed")}}
60+
<div class="card review">
61+
<h2>Build</h2>
62+
<p class="muted" style="margin:0 0 8px">{{if eq (printf "%s" .C.Status) "build_failed"}}The last build failed — see the history below, then re-run it.{{else}}Submitted but not built yet. Build the per-platform bundles (runs in the background, ~a few seconds), then approve.{{end}}</p>
63+
<form method="post" action="/admin/build">
64+
<input type="hidden" name="id" value="{{.C.CaseID}}"><input type="hidden" name="token" value="{{.Token}}">
65+
<button class="primary" type="submit">Build bundles</button>
66+
</form>
67+
</div>
68+
{{else if eq (printf "%s" .C.Status) "building"}}
69+
<div class="card review"><h2>Building…</h2><p class="muted">Bundles are building in the background — refresh in a few seconds.</p></div>
70+
{{end}}
71+
5972
{{if eq (printf "%s" .C.Status) "pending"}}
6073
<div class="card review">
6174
<h2>Approve</h2>

internal/publish/case.go

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -80,11 +80,11 @@ func (s *CaseStore) Create(sub Submission, b *Bundle, build BuildInfo) (*Case, e
8080
return c, s.write(c)
8181
}
8282

83-
// CreateBuilding records a case in the "building" state with NO bundle yet, so
84-
// the submit HTTP response can return immediately while the bundle builds in a
85-
// background goroutine (a 4-platform build can outlast an ingress timeout if
86-
// done synchronously). Refuses to clobber an already-approved id+version.
87-
func (s *CaseStore) CreateBuilding(sub Submission) (*Case, error) {
83+
// CreateSubmitted records a validated case in the "submitted" state with NO
84+
// bundle. Building is a SEPARATE, admin-triggered step (POST /admin/build) — we
85+
// don't build a bundle for every submission automatically. Refuses to clobber
86+
// an already-approved id+version.
87+
func (s *CaseStore) CreateSubmitted(sub Submission) (*Case, error) {
8888
id := safeKey(sub.ID + "-" + sub.Version)
8989
if existing, err := s.Get(id); err == nil && existing.Status == StatusApproved {
9090
return nil, fmt.Errorf("%s v%s is already approved/published; bump the version", sub.ID, sub.Version)
@@ -95,8 +95,8 @@ func (s *CaseStore) CreateBuilding(sub Submission) (*Case, error) {
9595
}
9696
now := time.Now().UTC().Format(time.RFC3339)
9797
c := &Case{
98-
CaseID: id, Status: StatusBuilding, Submission: sub,
99-
History: []Event{{At: now, Status: StatusBuilding, Note: "submitted; building bundle"}},
98+
CaseID: id, Status: StatusSubmitted, Submission: sub,
99+
History: []Event{{At: now, Status: StatusSubmitted, Note: "submitted; awaiting build"}},
100100
CreatedAt: now, UpdatedAt: now,
101101
}
102102
return c, s.write(c)

internal/publish/helpers.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ import "strings"
66
type Status string
77

88
const (
9-
StatusBuilding Status = "building" // submitted; bundle building asynchronously
9+
StatusSubmitted Status = "submitted" // received + validated; not built yet (admin triggers the build)
10+
StatusBuilding Status = "building" // bundle building asynchronously
1011
StatusBuildFailed Status = "build_failed" // async build errored; see the case note
1112
StatusPending Status = "pending" // built + awaiting admin review
1213
StatusApproved Status = "approved"

scripts/e2e-managed.sh

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -73,17 +73,18 @@ resp=$(curl -s -X POST "http://$PUB/api/submit" -H 'Content-Type: application/js
7373
case_id=$(echo "$resp" | sed -n 's/.*"case_id":"\([^"]*\)".*/\1/p')
7474
[ -n "$case_id" ] && pass "admin submit accepted (case $case_id)" || fail "submit failed: $resp"
7575

76-
# submit builds the bundle ASYNCHRONOUSLY (returns 202 "building"); wait for the
77-
# case to reach "pending" before approving (approve guards on pending).
76+
# submit does NOT build — an admin triggers the build per case. Fire /admin/build,
77+
# then wait for the async build to reach "pending" before approving.
7878
CASE_JSON="$WORK/store/cases/${case_id}/case.json"
79+
curl -s -X POST "http://$PUB/admin/build" --data-urlencode "id=$case_id" --data-urlencode "token=dev-admin" >/dev/null
7980
st=""
8081
for _ in $(seq 1 90); do
8182
st=$(sed -n 's/.*"status": *"\([^"]*\)".*/\1/p' "$CASE_JSON" 2>/dev/null | head -1)
8283
[ "$st" = "pending" ] && break
8384
[ "$st" = "build_failed" ] && fail "async build failed: $(cat "$CASE_JSON")"
8485
sleep 1
8586
done
86-
[ "$st" = "pending" ] && pass "async build finished → pending" || fail "build did not reach pending (status=$st)"
87+
[ "$st" = "pending" ] && pass "admin build → pending" || fail "build did not reach pending (status=$st)"
8788

8889
# ── 2. admin board: approve (registers with the broker) ─────────────────────
8990
# (publish trigger no-ops without a token; registration happens regardless.)

0 commit comments

Comments
 (0)