Skip to content

Commit beeb786

Browse files
committed
fix(handler): close purge-vs-finalize race with in-lock site re-check
1 parent 2663497 commit beeb786

9 files changed

Lines changed: 92 additions & 4 deletions

File tree

internal/handler/deploy.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313

1414
"github.com/freeCodeCamp/artemis/internal/gc"
1515
"github.com/freeCodeCamp/artemis/internal/r2"
16+
"github.com/freeCodeCamp/artemis/internal/registry"
1617
"github.com/go-chi/chi/v5"
1718
)
1819

@@ -232,6 +233,14 @@ func (h *Handlers) DeployFinalize(w http.ResponseWriter, r *http.Request) {
232233

233234
aliasKey := h.aliasKey(claims.Site, mode)
234235
lockErr := h.withSiteLock(r.Context(), h.DeployPrefix.SiteDirname(claims.Site), func() error {
236+
if _, err := h.Registry.GetSite(r.Context(), claims.Site); err != nil {
237+
if errors.Is(err, registry.ErrNotFound) {
238+
writeError(w, http.StatusGone, "site_gone", "site was deleted; deploy cannot be finalized")
239+
return errAliasWriteHandled
240+
}
241+
writeUpstreamError(w, r, http.StatusBadGateway, "registry_read_failed", "registry.get.finalize", err)
242+
return errAliasWriteHandled
243+
}
235244
if err := h.R2.PutAlias(r.Context(), aliasKey, deployID); err != nil {
236245
writeUpstreamError(w, r, http.StatusBadGateway, "r2_put_failed", "r2.put.alias.finalize", err)
237246
return errAliasWriteHandled

internal/handler/deploy_test.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,35 @@ func TestDeployFinalize_VerifyThenAlias(t *testing.T) {
326326
assert.Equal(t, deployID, alias)
327327
}
328328

329+
func TestDeployFinalize_RejectsPurgedSiteUnderLock(t *testing.T) {
330+
store := newFakeR2()
331+
h, jwt := newTestHandlers(t, &fakeGH{}, standardSites(), store)
332+
333+
deployID := "20260420-141522-abc1234"
334+
prefix := "www/deploys/" + deployID + "/"
335+
store.objects[prefix+"index.html"] = []byte("<h1>hi</h1>")
336+
337+
require.NoError(t, h.Registry.Delete(context.Background(), "www"))
338+
339+
tok, _, err := jwt.Sign("alice", "www", deployID)
340+
require.NoError(t, err)
341+
342+
body, _ := json.Marshal(DeployFinalizeRequest{Mode: "preview", Files: []string{"index.html"}})
343+
w := withChiRoute(http.MethodPost, "/api/deploy/{deployId}/finalize",
344+
"/api/deploy/"+deployID+"/finalize",
345+
body,
346+
map[string]string{"Authorization": "Bearer " + tok},
347+
h.RequireDeployJWT(http.HandlerFunc(h.DeployFinalize)).ServeHTTP,
348+
context.Background(),
349+
)
350+
351+
require.Equal(t, http.StatusGone, w.Code, w.Body.String())
352+
store.mu.Lock()
353+
_, aliasWritten := store.aliases["www/preview"]
354+
store.mu.Unlock()
355+
assert.False(t, aliasWritten, "purged site must not get a resurrected alias even under a stale cache snapshot")
356+
}
357+
329358
func TestFinalizeMarker(t *testing.T) {
330359
store := newFakeR2()
331360
h, jwt := newTestHandlers(t, &fakeGH{}, standardSites(), store)

internal/handler/site_delete_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ func callDelete(h *Handlers, slug, login, token string) *httptest.ResponseRecord
2828
}
2929

3030
func TestSiteDelete_HappyPath(t *testing.T) {
31-
h, _ := newTestHandlers(t, staffCallerGH(), standardSites(), newFakeR2())
31+
h, _ := newTestHandlers(t, staffCallerGH(), &fakeSites{bySite: map[string][]string{}}, newFakeR2())
3232

3333
regBody, _ := json.Marshal(SiteRegisterRequest{Slug: "example", Teams: []string{"staff"}})
3434
require.Equal(t, http.StatusCreated, callRegister(h, regBody, "alice", "tok").Code)

internal/handler/sites_list_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ func callSitesList(h *Handlers, login, token string) *httptest.ResponseRecorder
2121
}
2222

2323
func TestSitesList_EmptyRegistry(t *testing.T) {
24-
h, _ := newTestHandlers(t, staffCallerGH(), standardSites(), newFakeR2())
24+
h, _ := newTestHandlers(t, staffCallerGH(), &fakeSites{bySite: map[string][]string{}}, newFakeR2())
2525

2626
w := callSitesList(h, "alice", "tok")
2727
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
@@ -32,7 +32,7 @@ func TestSitesList_EmptyRegistry(t *testing.T) {
3232
}
3333

3434
func TestSitesList_PopulatedReturnsRowsSorted(t *testing.T) {
35-
h, _ := newTestHandlers(t, staffCallerGH(), standardSites(), newFakeR2())
35+
h, _ := newTestHandlers(t, staffCallerGH(), &fakeSites{bySite: map[string][]string{}}, newFakeR2())
3636

3737
for _, slug := range []string{"charlie", "alpha", "bravo"} {
3838
body := []byte(`{"slug":"` + slug + `","teams":["staff"]}`)

internal/handler/test_helpers_test.go

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ type fakeRegistry struct {
2525
fixedNow time.Time
2626
// registerErr forces Register to return this error on the next call.
2727
registerErr error
28+
getErr error
2829
}
2930

3031
func newFakeRegistry() *fakeRegistry {
@@ -91,6 +92,20 @@ func (f *fakeRegistry) Delete(_ context.Context, slug string) error {
9192
return nil
9293
}
9394

95+
func (f *fakeRegistry) GetSite(_ context.Context, slug string) (registry.Site, error) {
96+
if f.getErr != nil {
97+
return registry.Site{}, f.getErr
98+
}
99+
site, ok := f.bySite[slug]
100+
if !ok {
101+
return registry.Site{}, registry.ErrNotFound
102+
}
103+
dup := make([]string, len(site.Teams))
104+
copy(dup, site.Teams)
105+
site.Teams = dup
106+
return site, nil
107+
}
108+
94109
func (f *fakeRegistry) Sites(_ context.Context) ([]registry.Site, error) {
95110
out := make([]registry.Site, 0, len(f.bySite))
96111
for _, s := range f.bySite {
@@ -120,6 +135,9 @@ func (e *erroringRegistry) Delete(_ context.Context, _ string) error {
120135
func (e *erroringRegistry) Sites(_ context.Context) ([]registry.Site, error) {
121136
return nil, e.err
122137
}
138+
func (e *erroringRegistry) GetSite(_ context.Context, _ string) (registry.Site, error) {
139+
return registry.Site{}, e.err
140+
}
123141

124142
// fakeGH implements GitHubAuthenticator with deterministic behaviour.
125143
//
@@ -451,11 +469,15 @@ func trimPrefix(s, p string) string {
451469
func newTestHandlers(t *testing.T, gh *fakeGH, st *fakeSites, store R2Store) (*Handlers, *fakeJWT) {
452470
t.Helper()
453471
jwt := newFakeJWT(t)
472+
reg := newFakeRegistry()
473+
for slug, teams := range st.bySite {
474+
_, _ = reg.Register(context.Background(), slug, teams, "test")
475+
}
454476
h := &Handlers{
455477
GH: gh,
456478
JWT: jwt,
457479
Sites: st,
458-
Registry: newFakeRegistry(),
480+
Registry: reg,
459481
R2: store,
460482
AliasProductionFmt: "<site>/production",
461483
AliasPreviewFmt: "<site>/preview",

internal/pg/registry.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,20 @@ func (s *RegistryStore) Import(ctx context.Context, src SitesSource) (int, error
133133
return imported, nil
134134
}
135135

136+
func (s *RegistryStore) GetSite(ctx context.Context, slug string) (registry.Site, error) {
137+
var site registry.Site
138+
err := s.pool.QueryRow(ctx,
139+
`SELECT slug, teams, created_at, updated_at, created_by FROM sites WHERE slug = $1`,
140+
slug).Scan(&site.Slug, &site.Teams, &site.CreatedAt, &site.UpdatedAt, &site.CreatedBy)
141+
if errors.Is(err, pgx.ErrNoRows) {
142+
return registry.Site{}, registry.ErrNotFound
143+
}
144+
if err != nil {
145+
return registry.Site{}, fmt.Errorf("pg registry get %s: %w", slug, err)
146+
}
147+
return site, nil
148+
}
149+
136150
func (s *RegistryStore) Sites(ctx context.Context) ([]registry.Site, error) {
137151
rows, err := s.pool.Query(ctx,
138152
`SELECT slug, teams, created_at, updated_at, created_by FROM sites ORDER BY slug`)

internal/pg/registry_test.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,13 @@ func TestRegistryPG(t *testing.T) {
3838
_, err = store.UpdateTeams(ctx, "absent", []string{"x"})
3939
assert.ErrorIs(t, err, registry.ErrNotFound)
4040

41+
got, err := store.GetSite(ctx, "www")
42+
require.NoError(t, err)
43+
assert.Equal(t, "www", got.Slug)
44+
assert.Equal(t, []string{"team-platform"}, got.Teams, "GetSite reflects the latest authoritative row")
45+
_, err = store.GetSite(ctx, "absent")
46+
assert.ErrorIs(t, err, registry.ErrNotFound)
47+
4148
_, err = store.Register(ctx, "learn", []string{"team-eng"}, "carol")
4249
require.NoError(t, err)
4350
sites, err := store.Sites(ctx)
@@ -49,6 +56,9 @@ func TestRegistryPG(t *testing.T) {
4956
require.NoError(t, store.Delete(ctx, "www"))
5057
assert.ErrorIs(t, store.Delete(ctx, "www"), registry.ErrNotFound, "double delete -> not found")
5158

59+
_, err = store.GetSite(ctx, "www")
60+
assert.ErrorIs(t, err, registry.ErrNotFound, "GetSite after delete -> not found")
61+
5262
assert.Equal(t, []string{"www", "www", "learn", "www"}, changed,
5363
"registry.changed fires on register/update/register/delete for Valkey cache invalidation")
5464
}

internal/registry/errors_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ func (fakeWriter) UpdateTeams(context.Context, string, []string) (Site, error) {
5050

5151
func (fakeWriter) Delete(context.Context, string) error { return nil }
5252

53+
func (fakeWriter) GetSite(context.Context, string) (Site, error) { return Site{}, nil }
54+
5355
var (
5456
_ Snapshot = fakeSnapshot{}
5557
_ Reader = fakeReader{}

internal/registry/types.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ type Writer interface {
4545
// reflects the source-of-truth at call time.
4646
Sites(ctx context.Context) ([]Site, error)
4747

48+
GetSite(ctx context.Context, slug string) (Site, error)
49+
4850
// Register creates a new site row and publishes a
4951
// registry.changed event on success. Returns ErrAlreadyExists
5052
// when slug is already registered.

0 commit comments

Comments
 (0)