purge and create clear the app policy (#328, #329)
The purge reads the app's policy key and deletes a present object before the marker; a delete it cannot make keeps the marker for the next pass. A create clears an earlier app's policy before inserting the row, inside the app's policy lock and a live writer holding, after rechecking the name; a clear that fails refuses the create.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
11 files changed,
+809
−17
internal/console/apicli.go+6 −0internal/console/apipolicy_test.go+423 −0internal/console/apitemporary.go+9 −1internal/console/claim_app_enroll.go+60 −2internal/console/claim_invariant_world_test.go+1 −0internal/console/claim_restore_test.go+2 −1internal/console/regionclaim_test.go+35 −0internal/console/server.go+70 −12internal/console/view_test.go+24 −1internal/release/trash.go+20 −0internal/release/trash_test.go+159 −0
diff --git a/internal/console/apicli.go b/internal/console/apicli.go| index 69e5f77f4..e6d4cee98 100644 |
| --- a/internal/console/apicli.go |
| +++ b/internal/console/apicli.go |
| @@ -24,6 +24,7 @@ import ( |
| "strings" |
| "time" |
| + "github.com/carlosframework/platform/internal/apppolicy" |
| "github.com/carlosframework/platform/internal/caps" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| @@ -481,6 +482,11 @@ func (s *Server) handleAPICreateApp(w http.ResponseWriter, r *http.Request) { |
| // A bucket error, like handleCLINewAccount's: the detail stays on |
| // the box (key names and endpoints don't belong in API bodies). |
| jsonError(w, http.StatusBadGateway, "could not check the release store — try again") |
| + case claimPolicyClearFailed: |
| + jsonError(w, http.StatusServiceUnavailable, fmt.Sprintf(appCreatePolicyClearMsg, apppolicy.Key(acct.Sqid, name))) |
| + case claimPolicyWriterRefused: |
| + // placed is the lease refusal's message for this outcome. |
| + jsonError(w, http.StatusServiceUnavailable, placed) |
| case claimOK: |
| s.audit(acct, name, addr, "create", "created app "+name+" (cli)") |
| // Stamped in the SAME call that claims the name, after the claim has |
diff --git a/internal/console/apipolicy_test.go b/internal/console/apipolicy_test.go| index 906a39933..4c31b5e68 100644 |
| --- a/internal/console/apipolicy_test.go |
| +++ b/internal/console/apipolicy_test.go |
| @@ -8,17 +8,22 @@ import ( |
| "fmt" |
| "io" |
| "net/http" |
| + "net/url" |
| "os" |
| + "path/filepath" |
| "reflect" |
| "slices" |
| "strings" |
| "sync" |
| + "sync/atomic" |
| "testing" |
| "time" |
| "github.com/carlosframework/platform/internal/apppolicy" |
| + "github.com/carlosframework/platform/internal/hostops" |
| "github.com/carlosframework/platform/internal/lease" |
| "github.com/carlosframework/platform/internal/provision" |
| + "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/release" |
| "github.com/carlosframework/platform/internal/svcrole" |
| ) |
| @@ -1143,3 +1148,421 @@ func TestPolicyRevokeSurvivesALostWriteAndAReplay(t *testing.T) { |
| t.Fatalf("stored %+v, want revision 3 with no egress allow", stored) |
| } |
| } |
| + |
| +// ---- the create-time clear (plan Task 11) ----------------------------------- |
| + |
| +// errDeleteDenied is what an s3:DeleteObject Deny answers (deployments built |
| +// from infra/modules/region-host deny it across the deployment bucket). |
| +var errDeleteDenied = errors.New("AccessDenied: s3:DeleteObject denied by an explicit deny") |
| + |
| +// policyDeleteFault is the fixture bucket's Delete hook. While on, a Delete |
| +// of one of keys (of any key, when keys is nil) fails with errDeleteDenied. |
| +// Every Delete is counted, and observe, when set, sees each one first. |
| +type policyDeleteFault struct { |
| + keys map[string]bool |
| + on atomic.Bool |
| + observe func(ctx context.Context, key string) |
| + mu sync.Mutex |
| + deletes []string |
| +} |
| + |
| +func installPolicyDeleteFault(t *testing.T, fx *consoleFixture, on bool, keys ...string) *policyDeleteFault { |
| + t.Helper() |
| + d, ok := fx.rel.(*deletableStore) |
| + if !ok { |
| + t.Fatalf("the fixture bucket is %T, want *deletableStore", fx.rel) |
| + } |
| + f := &policyDeleteFault{} |
| + for _, k := range keys { |
| + if f.keys == nil { |
| + f.keys = map[string]bool{} |
| + } |
| + f.keys[k] = true |
| + } |
| + f.on.Store(on) |
| + d.setDeleteHook(func(ctx context.Context, key string) error { |
| + f.mu.Lock() |
| + f.deletes = append(f.deletes, key) |
| + observe := f.observe |
| + f.mu.Unlock() |
| + if observe != nil { |
| + observe(ctx, key) |
| + } |
| + if f.on.Load() && (f.keys == nil || f.keys[key]) { |
| + return errDeleteDenied |
| + } |
| + return nil |
| + }) |
| + return f |
| +} |
| + |
| +func (f *policyDeleteFault) seen() []string { |
| + f.mu.Lock() |
| + defer f.mu.Unlock() |
| + return append([]string(nil), f.deletes...) |
| +} |
| + |
| +// policyCreateWorld is one console of either mode with the owner's CLI token |
| +// and a signed-in browser. The owner is a system operator in both. |
| +type policyCreateWorld struct { |
| + *consoleFixture |
| + claims bool |
| + token string |
| + browser *http.Client |
| + csrf string |
| +} |
| + |
| +func newPolicyCreateWorld(t *testing.T, claims bool) *policyCreateWorld { |
| + t.Helper() |
| + if claims { |
| + cx := newClaimCase(t) |
| + return &policyCreateWorld{consoleFixture: cx.consoleFixture, claims: true, token: cx.bearer, browser: cx.browser, csrf: cx.csrf} |
| + } |
| + fx := newConsoleFixture(t) |
| + fx.srv.cfg.TemporaryApps = true |
| + token := cliToken(t, fx, "paul@keymail.dev", "code-cli-"+claimSafeName(t.Name())) |
| + c := newBrowser(t) |
| + signUp(t, fx, c, "code-web-"+claimSafeName(t.Name())) |
| + return &policyCreateWorld{consoleFixture: fx, token: token, browser: c, csrf: sessionToken(t, fx, c)} |
| +} |
| + |
| +// forEachCreateMode runs body once with claims off and once with claims on. |
| +func forEachCreateMode(t *testing.T, body func(t *testing.T, w *policyCreateWorld)) { |
| + for _, claims := range []bool{false, true} { |
| + name := "claims off" |
| + if claims { |
| + name = "claims on" |
| + } |
| + t.Run(name, func(t *testing.T) { |
| + t.Parallel() |
| + body(t, newPolicyCreateWorld(t, claims)) |
| + }) |
| + } |
| +} |
| + |
| +// seedPredecessorPolicy writes, at app's key, the policy an earlier app of |
| +// the name left: signed by the fixture's key, xlarge, one egress allow. |
| +func seedPredecessorPolicy(t *testing.T, fx *consoleFixture, app string) []byte { |
| + t.Helper() |
| + wire := signPolicy(t, fx, apppolicy.Policy{V: 1, Account: fx.sqid, App: app, Revision: 1, |
| + SignedAt: time.Now().UTC(), DefaultSize: "xlarge", EgressAllow: []string{"10.0.5.20"}}) |
| + putAtKey(t, fx.rel, fx.sqid, app, wire) |
| + return wire |
| +} |
| + |
| +// policyObject is the bytes at app's key, nil when there is none. |
| +func policyObject(t *testing.T, fx *consoleFixture, app string) []byte { |
| + t.Helper() |
| + b, _, err := fx.rel.Get(context.Background(), apppolicy.Key(fx.sqid, app)) |
| + if errors.Is(err, os.ErrNotExist) { |
| + return nil |
| + } |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return b |
| +} |
| + |
| +func (w *policyCreateWorld) createCLI(t *testing.T, name string) (int, string) { |
| + t.Helper() |
| + st, _, out := rawReq(t, http.MethodPost, w.ts.URL+"/api/cli/apps/"+w.sqid+"/new", w.token, `{"name":"`+name+`"}`) |
| + return st, out |
| +} |
| + |
| +func (w *policyCreateWorld) createTemporary(t *testing.T, name string) (int, string) { |
| + t.Helper() |
| + st, _, out := rawReq(t, http.MethodPost, w.ts.URL+"/api/cli/apps/"+w.sqid+"/new-temporary", w.token, |
| + `{"name":"`+name+`","ttl_seconds":86400}`) |
| + return st, out |
| +} |
| + |
| +// createForm posts the browser's New app form and returns the page its |
| +// redirect lands on, where the flash is shown. |
| +func (w *policyCreateWorld) createForm(t *testing.T, name string) (string, string) { |
| + t.Helper() |
| + st, loc, body := postForm(t, w.browser, w.u("/apps/new"), url.Values{"csrf": {w.csrf}, "name": {name}}) |
| + if st != http.StatusSeeOther { |
| + t.Fatalf("form create %s = %d %s", name, st, body) |
| + } |
| + _, _, page := get(t, w.browser, w.ts.URL+loc) |
| + return loc, page |
| +} |
| + |
| +func (w *policyCreateWorld) hasApp(t *testing.T, name string) bool { |
| + t.Helper() |
| + has, err := w.st.HasAppIn(OpsAccountID, name) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return has |
| +} |
| + |
| +// reconcileRow runs a box's reconcile over the fixture bucket into reg and |
| +// returns host's row, nil when none was minted. |
| +func reconcileRow(t *testing.T, fx *consoleFixture, reg *registry.Registry, host string) *registry.Route { |
| + t.Helper() |
| + if err := provision.Reconcile(context.Background(), fx.rel, reg, |
| + provision.ReconcileConfig{DataDir: t.TempDir(), ReleasePub: fx.pub}); err != nil { |
| + t.Fatalf("reconcile: %v", err) |
| + } |
| + got, err := reg.Get(host) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return got |
| +} |
| + |
| +func tempRegistry(t *testing.T) *registry.Registry { |
| + t.Helper() |
| + reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db")) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { reg.Close() }) |
| + return reg |
| +} |
| + |
| +// Astra plan R5-1: a name that never had a policy is created without a |
| +// delete request, so the region-host Deny does not break every create. |
| +func TestCreateWithNoPolicySendsNoDelete(t *testing.T) { |
| + t.Parallel() |
| + w := newPolicyCreateWorld(t, false) |
| + f := installPolicyDeleteFault(t, w.consoleFixture, true) |
| + if st, out := w.createCLI(t, "shop"); st != http.StatusOK { |
| + t.Fatalf("create = %d %s", st, out) |
| + } |
| + if !w.hasApp(t, "shop") { |
| + t.Fatal("no app row") |
| + } |
| + if got := f.seen(); len(got) != 0 { |
| + t.Fatalf("the create sent deletes %v", got) |
| + } |
| +} |
| + |
| +func TestCreateRefusedWhenAPredecessorPolicyCannotBeDeleted(t *testing.T) { |
| + t.Parallel() |
| + w := newPolicyCreateWorld(t, false) |
| + installPolicyDeleteFault(t, w.consoleFixture, true) |
| + wire := seedPredecessorPolicy(t, w.consoleFixture, "shop") |
| + st, out := w.createCLI(t, "shop") |
| + if want := fmt.Sprintf(appCreatePolicyClearMsg, apppolicy.Key(w.sqid, "shop")); st != http.StatusServiceUnavailable || errorOf(t, out) != want { |
| + t.Fatalf("create = %d %s, want 503 %q", st, out, want) |
| + } |
| + if w.hasApp(t, "shop") { |
| + t.Fatal("the refused create left an app row") |
| + } |
| + if got := policyObject(t, w.consoleFixture, "shop"); !bytes.Equal(got, wire) { |
| + t.Fatalf("the object changed: %s", got) |
| + } |
| +} |
| + |
| +func TestCreatingAnAppClearsAStalePolicy(t *testing.T) { |
| + t.Parallel() |
| + forEachCreateMode(t, func(t *testing.T, w *policyCreateWorld) { |
| + key := apppolicy.Key(w.sqid, "shop") |
| + seedPredecessorPolicy(t, w.consoleFixture, "shop") |
| + f := installPolicyDeleteFault(t, w.consoleFixture, false) |
| + var action string |
| + f.observe = func(ctx context.Context, k string) { |
| + if k != key || !w.claims { |
| + return |
| + } |
| + // The operation the delete runs in, read from the raw bucket. |
| + b, _, err := w.rel.Get(context.WithoutCancel(ctx), admissionKeyOf(w.sqid, "shop")) |
| + var a hostops.AppAdmission |
| + if err == nil && json.Unmarshal(b, &a) == nil && a.State == "closed" && a.Op != nil { |
| + action = a.Op.Intent["action"] |
| + } |
| + } |
| + if st, out := w.createCLI(t, "shop"); st != http.StatusOK { |
| + t.Fatalf("create = %d %s", st, out) |
| + } |
| + if got := policyObject(t, w.consoleFixture, "shop"); got != nil { |
| + t.Fatalf("the predecessor's policy is still at %s: %s", key, got) |
| + } |
| + if !slices.Contains(f.seen(), key) { |
| + t.Fatalf("deletes %v, want %s", f.seen(), key) |
| + } |
| + if w.claims && action != "app-policy-clear" { |
| + t.Fatalf("the delete ran in operation %q, want app-policy-clear", action) |
| + } |
| + }) |
| +} |
| + |
| +// Astra plan R3-3. Fails if any mode lets the create stand after a failed |
| +// clear. |
| +func TestCreateIsRefusedWhileAPredecessorsPolicyLingers(t *testing.T) { |
| + t.Parallel() |
| + forEachCreateMode(t, func(t *testing.T, w *policyCreateWorld) { |
| + ctx := context.Background() |
| + const host = "shop-a.example.com" |
| + wire := seedPredecessorPolicy(t, w.consoleFixture, "shop") |
| + tmpWire := seedPredecessorPolicy(t, w.consoleFixture, "tmp-shop") |
| + f := installPolicyDeleteFault(t, w.consoleFixture, true, |
| + apppolicy.Key(w.sqid, "shop"), apppolicy.Key(w.sqid, "tmp-shop")) |
| + st, out := w.createCLI(t, "shop") |
| + if want := fmt.Sprintf(appCreatePolicyClearMsg, apppolicy.Key(w.sqid, "shop")); st != http.StatusServiceUnavailable || errorOf(t, out) != want { |
| + t.Fatalf("create = %d %s, want 503 %q", st, out, want) |
| + } |
| + if w.hasApp(t, "shop") { |
| + t.Fatal("the refused create left an app row") |
| + } |
| + if got := policyObject(t, w.consoleFixture, "shop"); !bytes.Equal(got, wire) { |
| + t.Fatalf("the object changed: %s", got) |
| + } |
| + evs, err := w.st.Events(OpsAccountID, "shop", 100) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + for _, e := range evs { |
| + if e.Action == "create" { |
| + t.Fatalf("a create event for a refused create: %+v", e) |
| + } |
| + } |
| + |
| + st, out = w.createTemporary(t, "tmp-shop") |
| + if want := fmt.Sprintf(appCreatePolicyClearMsg, apppolicy.Key(w.sqid, "tmp-shop")); st != http.StatusServiceUnavailable || errorOf(t, out) != want { |
| + t.Fatalf("temporary create = %d %s, want 503 %q", st, out, want) |
| + } |
| + if w.hasApp(t, "tmp-shop") { |
| + t.Fatal("the refused temporary create left an app row") |
| + } |
| + if got := policyObject(t, w.consoleFixture, "tmp-shop"); !bytes.Equal(got, tmpWire) { |
| + t.Fatalf("the tmp- object changed: %s", got) |
| + } |
| + |
| + // Under claims the failed delete was an issued effect, so it leaves |
| + // the name's admission closed and uncertain (controller ruling on |
| + // Task 11; a follow-up for claim activation): a later create of the |
| + // name is refused as a create failure rather than as a failed clear. |
| + // It fails closed either way. |
| + loc, page := w.createForm(t, "shop") |
| + if loc != w.path("/apps/new") || (!w.claims && !strings.Contains(page, "settings under this name, so nothing was created. Please try again.")) { |
| + t.Fatalf("form create -> %s, want the refusal on the form:\n%s", loc, page) |
| + } |
| + if w.hasApp(t, "shop") { |
| + t.Fatal("the refused form create left an app row") |
| + } |
| + if w.claims { |
| + if st, out := w.createCLI(t, "shop"); st == http.StatusOK { |
| + t.Fatalf("a second create = %d %s, want a refusal", st, out) |
| + } |
| + if w.hasApp(t, "shop") { |
| + t.Fatal("the second refused create left an app row") |
| + } |
| + } |
| + if got := policyObject(t, w.consoleFixture, "shop"); !bytes.Equal(got, wire) { |
| + t.Fatalf("the object changed after the later creates: %s", got) |
| + } |
| + |
| + // No app, so no instance and nothing for a box to run. |
| + if st, _, out := rawReq(t, http.MethodPost, instancesURL(w.consoleFixture, w.sqid, "shop"), w.token, `{"host":"`+host+`"}`); st != http.StatusNotFound { |
| + t.Fatalf("instance create = %d %s, want 404", st, out) |
| + } |
| + if keys, err := w.rel.List(ctx, "apps/"+w.sqid+"/shop/instances/"); err != nil || len(keys) != 0 { |
| + t.Fatalf("instance records %v %v, want none", keys, err) |
| + } |
| + reg := tempRegistry(t) |
| + if got := reconcileRow(t, w.consoleFixture, reg, host); got != nil { |
| + t.Fatalf("reconcile minted %+v for an app that does not exist", got) |
| + } |
| + |
| + if w.claims { |
| + return // no retry under claims: the admission stays closed (above) |
| + } |
| + |
| + // The fault cleared: the retry clears the key and creates the app, |
| + // which runs at the build's size with no allow. |
| + f.on.Store(false) |
| + if st, out := w.createCLI(t, "shop"); st != http.StatusOK { |
| + t.Fatalf("create after the fault cleared = %d %s", st, out) |
| + } |
| + if got := policyObject(t, w.consoleFixture, "shop"); got != nil { |
| + t.Fatalf("the predecessor's policy survived the create: %s", got) |
| + } |
| + if err := provision.PutManifest(ctx, w.rel, w.sqid, "shop", |
| + provision.Manifest{Schema: 1, App: "shop", Domain: "example.com"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + policyRecord(t, w.rel, w.sqid, "shop", host, time.Now()) |
| + got := reconcileRow(t, w.consoleFixture, reg, host) |
| + if got == nil { |
| + t.Fatal("reconcile minted no row for the new app's instance") |
| + } |
| + if got.Size != "" || got.EgressAllow != "" || got.PolicyExpiresAt != "" { |
| + t.Fatalf("row size %q egress %q expires %q, want all empty", got.Size, got.EgressAllow, got.PolicyExpiresAt) |
| + } |
| + }) |
| +} |
| + |
| +// Ruling 10's second-console case at the create doors: no clear, no row. |
| +func TestCreateIsRefusedWhileAnotherConsoleIsThePolicyWriter(t *testing.T) { |
| + t.Parallel() |
| + w := newPolicyCreateWorld(t, false) |
| + w.srv.cfg.Bucket = "deploy" |
| + f := &fakePolicyLeaser{err: fmt.Errorf("acquire lease: %w", |
| + &lease.HeldError{Owner: "console-b:4242", ExpiresAt: time.Now().Add(10 * time.Minute)})} |
| + w.srv.cfg.PolicyLease = f.leaser |
| + wire := seedPredecessorPolicy(t, w.consoleFixture, "shop") |
| + tmpWire := seedPredecessorPolicy(t, w.consoleFixture, "tmp-shop") |
| + const holder = "another console (console-b:4242)" |
| + |
| + if st, out := w.createCLI(t, "shop"); st != http.StatusServiceUnavailable || !strings.HasPrefix(errorOf(t, out), holder) { |
| + t.Fatalf("create = %d %s, want 503 naming console-b:4242", st, out) |
| + } |
| + if st, out := w.createTemporary(t, "tmp-shop"); st != http.StatusServiceUnavailable || !strings.HasPrefix(errorOf(t, out), holder) { |
| + t.Fatalf("temporary create = %d %s, want 503 naming console-b:4242", st, out) |
| + } |
| + if loc, page := w.createForm(t, "shop"); loc != w.path("/apps/new") || !strings.Contains(page, holder) { |
| + t.Fatalf("form create -> %s, want the holder named on the form:\n%s", loc, page) |
| + } |
| + for app, want := range map[string][]byte{"shop": wire, "tmp-shop": tmpWire} { |
| + if w.hasApp(t, app) { |
| + t.Fatalf("a refused create left a %s row", app) |
| + } |
| + if got := policyObject(t, w.consoleFixture, app); !bytes.Equal(got, want) { |
| + t.Fatalf("the %s object changed: %s", app, got) |
| + } |
| + } |
| +} |
| + |
| +// Task 9's review: an app purged with a policy, and its name recreated |
| +// through the real doors, starts with nothing from its predecessor, at the |
| +// policy door and on a box. |
| +func TestPurgeAndRecreateStartsWithoutThePredecessorsPolicy(t *testing.T) { |
| + t.Parallel() |
| + fx := newPolicyFx(t, "code-policy-purge-recreate") |
| + fx.mustPut(t, "kass", `{"egress_allow":["10.0.5.20"],"default_size":"xlarge"}`, policyCreate) |
| + c := newBrowser(t) |
| + signUp(t, fx.consoleFixture, c, "code-policy-purge-recreate-web") |
| + trashKass(t, fx.consoleFixture, "paul@keymail.dev", time.Now()) |
| + if st, _, body := postGated(t, fx.consoleFixture, c, fx.u("/trash/delete"), |
| + url.Values{"csrf": {sessionToken(t, fx.consoleFixture, c)}, "app": {"kass"}, "confirm": {"kass"}}); st != http.StatusSeeOther { |
| + t.Fatalf("delete now = %d:\n%s", st, body) |
| + } |
| + fx.noObject(t, "kass") |
| + if st, out := postJSON(t, fx.ts.URL+"/api/cli/apps/"+fx.sqid+"/new", map[string]string{"name": "kass"}, fx.owner); st != http.StatusOK { |
| + t.Fatalf("recreate = %d %v", st, out) |
| + } |
| + if g := fx.get(t, "kass"); len(g.Policy.EgressAllow) != 0 || g.Policy.DefaultSize != "" || g.ETag != "" { |
| + t.Fatalf("the recreated app's policy = %+v etag %q, want empty", g.Policy, g.ETag) |
| + } |
| + |
| + // A box runs the new app's instance at the build's size with no allow. |
| + const host = "kass-a.example.com" |
| + if err := provision.PutManifest(context.Background(), fx.rel, fx.sqid, "kass", |
| + provision.Manifest{Schema: 1, App: "kass", Domain: "example.com"}); err != nil { |
| + t.Fatal(err) |
| + } |
| + policyRecord(t, fx.rel, fx.sqid, "kass", host, time.Now()) |
| + got := reconcileRow(t, fx.consoleFixture, tempRegistry(t), host) |
| + if got == nil || got.Size != "" || got.EgressAllow != "" || got.PolicyExpiresAt != "" { |
| + t.Fatalf("the recreated app's row = %+v, want no size, allow or expiry", got) |
| + } |
| + |
| + // The first write builds on nothing, not on the purged app's revision. |
| + r := fx.mustPut(t, "kass", `{"default_size":"small"}`, policyCreate) |
| + if len(r.Policy.EgressAllow) != 0 || r.Policy.DefaultSize != "small" { |
| + t.Fatalf("the first write after the recreate = %+v, want only default small", r.Policy) |
| + } |
| +} |
diff --git a/internal/console/apitemporary.go b/internal/console/apitemporary.go| index 23166278d..97936f34b 100644 |
| --- a/internal/console/apitemporary.go |
| +++ b/internal/console/apitemporary.go |
| @@ -11,6 +11,7 @@ import ( |
| "strings" |
| "time" |
| + "github.com/carlosframework/platform/internal/apppolicy" |
| "github.com/carlosframework/platform/internal/provision" |
| "github.com/carlosframework/platform/internal/release" |
| ) |
| @@ -194,7 +195,7 @@ func (s *Server) handleAPICreateTemporaryApp(w http.ResponseWriter, r *http.Requ |
| }) |
| return |
| } |
| - outcome, _, held := s.claimAppWith(r.Context(), acct, name, addr, "", false, rq, now.Add(ttl)) |
| + outcome, msg, held := s.claimAppWith(r.Context(), acct, name, addr, "", false, rq, now.Add(ttl)) |
| switch outcome { |
| case claimOK: |
| case claimInvalid: |
| @@ -216,6 +217,13 @@ func (s *Server) handleAPICreateTemporaryApp(w http.ResponseWriter, r *http.Requ |
| case claimStoreFailed: |
| jsonError(w, http.StatusBadGateway, "could not check the release store. Nothing was created. Try again") |
| return |
| + case claimPolicyClearFailed: |
| + jsonError(w, http.StatusServiceUnavailable, fmt.Sprintf(appCreatePolicyClearMsg, apppolicy.Key(acct.Sqid, name))) |
| + return |
| + case claimPolicyWriterRefused: |
| + // msg is the lease refusal's message for this outcome. |
| + jsonError(w, http.StatusServiceUnavailable, msg) |
| + return |
| default: |
| jsonError(w, http.StatusInternalServerError, "could not create the app. Try again") |
| return |
diff --git a/internal/console/claim_app_enroll.go b/internal/console/claim_app_enroll.go| index 46679998b..66cebc584 100644 |
| --- a/internal/console/claim_app_enroll.go |
| +++ b/internal/console/claim_app_enroll.go |
| @@ -1,9 +1,20 @@ |
| package console |
| -import "context" |
| +import ( |
| + "context" |
| + "fmt" |
| +) |
| + |
| +// policyWriterRefused is a create that could not take the policy writer |
| +// holding its clear needs: ref is the lease refusal the door answers. |
| +type policyWriterRefused struct{ ref *doorRefusal } |
| + |
| +func (e policyWriterRefused) Error() string { |
| + return "console: policy writer refused: " + e.ref.Msg |
| +} |
| // createClaimAppRow inserts a new app's SQL row through insert. Flag-off it is |
| -// insert on the store, exactly as before. |
| +// insert on the store, after the create-time clear below. |
| // |
| // Enabled, the armed gate refuses an apps row nobody holds a permit for, and a |
| // permit is derived from canonical admission, so the order is reversed: the |
| @@ -13,8 +24,28 @@ import "context" |
| // binds app_id in the same permitted view. A refused enrollment (a foreign |
| // operation, a name that is not free) leaves no SQL row. A lost SQL race |
| // reports created false and binds nothing; the open admission is the winner's. |
| +// |
| +// In every mode the row is inserted only after an earlier app's policy at the |
| +// name's key is cleared (#328, #329; spec §3), inside policy publication's |
| +// exclusion: the app's policyLock, held from first thing until the insert has |
| +// returned, and a live writer holding (Astra plan R4-1). The policy door and |
| +// the re-sign take the same lock, so the recheck of the name under it sees any |
| +// row a racing create inserted, and a policy granted to that winner is never |
| +// cleared on this create's stale check. A writer refusal is |
| +// policyWriterRefused, before any enrollment, delete or insert; a failed clear |
| +// is errAppPolicyClear and inserts nothing. |
| func (s *Server) createClaimAppRow(ctx context.Context, acct *Account, app string, insert func(*Store) (bool, error)) (bool, error) { |
| + mu := s.policyLock(acct.Sqid, app) |
| + mu.Lock() |
| + defer mu.Unlock() |
| + hold, ref := s.policyWriter(ctx) |
| + if ref != nil { |
| + return false, policyWriterRefused{ref} |
| + } |
| if !s.cfg.HostClaim { |
| + if free, err := s.clearForCreate(ctx, acct, app, hold); err != nil || !free { |
| + return false, err |
| + } |
| return insert(s.store) |
| } |
| w, err := s.claimControl() |
| @@ -29,6 +60,11 @@ func (s *Server) createClaimAppRow(ctx context.Context, acct *Account, app strin |
| if err != nil { |
| return false, err |
| } |
| + // The enrollment context: its identity is what mutationAppID answers |
| + // before the row exists, so the boundary admits the app-scoped delete. |
| + if free, err := s.clearForCreate(ctx, acct, app, hold); err != nil || !free { |
| + return false, err |
| + } |
| created := false |
| err = s.store.withAutoAppSQL(ctx, acct.ID, app, "sql.CreateApp", func(st *Store) error { |
| var err error |
| @@ -42,3 +78,25 @@ func (s *Server) createClaimAppRow(ctx context.Context, acct *Account, app strin |
| } |
| return created, nil |
| } |
| + |
| +// clearForCreate is the create's recheck and clear, under the caller's |
| +// policyLock: a row for the name now is a lost race (free false, nothing |
| +// deleted); otherwise the clear runs as one attempt of hold, and a refused |
| +// attempt, or one whose holding ended during the clear, is a failed clear. |
| +func (s *Server) clearForCreate(ctx context.Context, acct *Account, app string, hold policyHolding) (free bool, err error) { |
| + if has, err := s.store.HasAppIn(acct.ID, app); err != nil || has { |
| + return false, err |
| + } |
| + actx, done, ref := s.policyAttempt(ctx, hold) |
| + defer done() |
| + if ref != nil { |
| + return false, fmt.Errorf("%w: %s", errAppPolicyClear, ref.Msg) |
| + } |
| + if err := s.clearAppPolicy(actx, acct, app); err != nil { |
| + return false, err |
| + } |
| + if !hold.sole && actx.Err() != nil { |
| + return false, fmt.Errorf("%w: the writer holding ended during the clear", errAppPolicyClear) |
| + } |
| + return true, nil |
| +} |
diff --git a/internal/console/claim_invariant_world_test.go b/internal/console/claim_invariant_world_test.go| index 6b56214f3..5032724f6 100644 |
| --- a/internal/console/claim_invariant_world_test.go |
| +++ b/internal/console/claim_invariant_world_test.go |
| @@ -514,6 +514,7 @@ func newClaimCaseWith(t *testing.T, opts ...func(*Config)) *claimCase { |
| HostClaim: true, |
| HostConsoleID: claimFxConsole, |
| Bucket: claimFxBucket, |
| + PolicyLease: (&fakePolicyLeaser{}).leaser, // Task 8's fake: an app create takes the writer holding |
| DBVersions: rawVersions, |
| Origin: ts.URL, |
| Keymail: fk.srv.URL, |
diff --git a/internal/console/claim_restore_test.go b/internal/console/claim_restore_test.go| index 5f66b2d0b..0d18934bd 100644 |
| --- a/internal/console/claim_restore_test.go |
| +++ b/internal/console/claim_restore_test.go |
| @@ -78,7 +78,8 @@ func newEnabledRestoreConsole(t *testing.T, raw release.Store, key ed25519.Priva |
| versions.AddBucket(crBucket) |
| srv, err := NewServer(Config{Origin: "https://console.example", Keymail: "https://keymail.example", HostClaim: true, |
| HostConsoleID: name, Bucket: crBucket, Releases: raw, DBVersions: versions, |
| - Key: key, ReleasePub: key.Public().(ed25519.PublicKey)}, st) |
| + PolicyLease: (&fakePolicyLeaser{}).leaser, // a create takes the policy writer holding |
| + Key: key, ReleasePub: key.Public().(ed25519.PublicKey)}, st) |
| if err != nil { |
| t.Fatal(err) |
| } |
diff --git a/internal/console/regionclaim_test.go b/internal/console/regionclaim_test.go| index c3a98b767..c3557cff1 100644 |
| --- a/internal/console/regionclaim_test.go |
| +++ b/internal/console/regionclaim_test.go |
| @@ -10,6 +10,7 @@ import ( |
| "testing" |
| "time" |
| + "github.com/carlosframework/platform/internal/apppolicy" |
| "github.com/carlosframework/platform/internal/geo" |
| "github.com/carlosframework/platform/internal/registry" |
| "github.com/carlosframework/platform/internal/svcrole" |
| @@ -204,6 +205,40 @@ func TestClaimLostAfterResolutionWritesNothing(t *testing.T) { |
| if strings.Contains(logs.String(), "app region bab/racy") { |
| t.Fatalf("a lost claim logged a region line: %s", logs.String()) |
| } |
| + |
| + // Astra plan R4-1: the create-time clear rechecks inside the policy |
| + // lock. A paused create (A, past its HasAppIn check) must not clear the |
| + // policy an operator granted the app that took the name meanwhile (B). |
| + // Fails if the clear runs on the stale existence check. |
| + var ( |
| + entered bool |
| + wB []byte |
| + ) |
| + fx.srv.beforeClaimInsert = func() { |
| + if entered { |
| + return // B's own create passing the same seam |
| + } |
| + entered = true |
| + if st, out := postJSON(t, newAppURL(fx), map[string]string{"name": "racy2", "region": "uk"}, token); st != http.StatusOK { |
| + t.Errorf("B's create = %d %v", st, out) |
| + return |
| + } |
| + if st, _, out := policyReq(t, fx, http.MethodPut, "racy2", token, `{"egress_allow":["10.0.5.20"]}`, policyCreate); st != http.StatusOK { |
| + t.Errorf("B's policy PUT = %d %s", st, out) |
| + return |
| + } |
| + wB = policyObject(t, fx, "racy2") |
| + } |
| + if st, _ := postJSON(t, newAppURL(fx), map[string]string{"name": "racy2", "region": "uk"}, token); st != http.StatusConflict { |
| + t.Fatalf("A's create of racy2 = %d, want 409", st) |
| + } |
| + if got := policyObject(t, fx, "racy2"); wB == nil || !bytes.Equal(got, wB) { |
| + t.Fatalf("the policy at %s = %s, want B's %s", apppolicy.Key(fx.sqid, "racy2"), got, wB) |
| + } |
| + var rows int |
| + if err := fx.st.db.QueryRow(`SELECT COUNT(*) FROM apps WHERE account_id = ? AND app = ?`, OpsAccountID, "racy2").Scan(&rows); err != nil || rows != 1 { |
| + t.Fatalf("racy2 rows = %d %v, want exactly one", rows, err) |
| + } |
| } |
| // Review Focus 5: a held apex refuses before resolution, so nothing is |
diff --git a/internal/console/server.go b/internal/console/server.go| index 6ce9dc44b..3a9f54b32 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -34,6 +34,7 @@ import ( |
| "github.com/aws/aws-sdk-go-v2/service/costexplorer" |
| "github.com/carlosframework/platform/internal/appconfig" |
| "github.com/carlosframework/platform/internal/applog" |
| + "github.com/carlosframework/platform/internal/apppolicy" |
| "github.com/carlosframework/platform/internal/boxid" |
| "github.com/carlosframework/platform/internal/dbreads" |
| "github.com/carlosframework/platform/internal/dnsrec" |
| @@ -4139,15 +4140,24 @@ func (s *Server) ensureAccountManifest(ctx context.Context, acct *Account) { |
| type claimOutcome int |
| const ( |
| - claimOK claimOutcome = iota |
| - claimInvalid // not a ValidApp name |
| - claimReserved // the literal name the console's own routes shadow (e.g. "new") |
| - claimTaken // any flavour of "not free" — table row, grant, bucket objects, lost race |
| - claimCheckFailed // a database lookup failed |
| - claimStoreFailed // the release-store listing failed |
| - claimCreateFailed // the insert itself failed |
| - claimApexHeld // another app already holds the name's platform apex (apex.go) |
| - claimTemporaryReserved // a tmp- name at a permanent door (temporary static sites spec ruling 7) |
| + claimOK claimOutcome = iota |
| + claimInvalid // not a ValidApp name |
| + claimReserved // the literal name the console's own routes shadow (e.g. "new") |
| + claimTaken // any flavour of "not free" — table row, grant, bucket objects, lost race |
| + claimCheckFailed // a database lookup failed |
| + claimStoreFailed // the release-store listing failed |
| + claimCreateFailed // the insert itself failed |
| + claimApexHeld // another app already holds the name's platform apex (apex.go) |
| + claimTemporaryReserved // a tmp- name at a permanent door (temporary static sites spec ruling 7) |
| + claimPolicyClearFailed // an earlier app's policy at the name's key could not be cleared (#328, #329) |
| + claimPolicyWriterRefused // the create could not take the policy writer holding (another console holds it) |
| +) |
| + |
| +// Create door copy (collected for review in plan Task 15): the create-time |
| +// clear of an earlier app's policy failed, so nothing was created. |
| +const ( |
| + appCreatePolicyClearMsg = "an earlier app's settings under this name could not be cleared, so nothing was created. Try again, or ask an operator to delete %s from the deployment bucket" |
| + appCreatePolicyClearFlash = "Could not clear an earlier app's settings under this name, so nothing was created. Please try again." |
| ) |
| // normalizeAppName is the one normalization both claim doors apply before |
| @@ -4268,6 +4278,12 @@ func (s *Server) claimApp(ctx context.Context, acct *Account, name, addr, placem |
| // held is set only with claimApexHeld: who holds the name's platform apex, so |
| // a door can say so without asking again. |
| // |
| +// Before the row is inserted, createClaimAppRow clears an earlier app's policy |
| +// at the name's key (#328, #329). A clear that failed is |
| +// claimPolicyClearFailed; a create that could not take the policy writer |
| +// holding is claimPolicyWriterRefused, and for that outcome alone the second |
| +// value is the refusal's message, not a placement. |
| +// |
| // A zero expiresAt is the permanent claim every existing door makes, and it |
| // refuses a tmp- name: nothing permanent may be created in that namespace |
| // (spec ruling 7). A non-zero one is /new-temporary's claim: it refuses any |
| @@ -4389,6 +4405,14 @@ func (s *Server) claimAppWith(ctx context.Context, acct *Account, name, addr, pl |
| return st.CreateAppInRegion(acct.ID, name, addr, region, source, time.Now()) |
| }) |
| if err != nil { |
| + var refused policyWriterRefused |
| + switch { |
| + case errors.As(err, &refused): |
| + return claimPolicyWriterRefused, refused.ref.Msg, nil |
| + case errors.Is(err, errAppPolicyClear): |
| + log.Printf("console: claim %s/%s: %v", acct.Sqid, name, err) |
| + return claimPolicyClearFailed, "", nil |
| + } |
| return claimCreateFailed, "", nil |
| } |
| if !created { // lost a claim race after the checks |
| @@ -4424,6 +4448,35 @@ func (s *Server) claimAppWith(ctx context.Context, acct *Account, name, addr, pl |
| return claimOK, placed, nil |
| } |
| +// errAppPolicyClear is a create-time clear that failed: an earlier app's |
| +// policy object at the name's key is still there (or may be), so the create |
| +// inserts no row (spec §3 "before the name is usable"). |
| +var errAppPolicyClear = errors.New("console: an earlier app's policy could not be cleared") |
| + |
| +// clearAppPolicy deletes an earlier app's policy at the name's key. It reads |
| +// first: deployments built from infra/modules/region-host deny |
| +// s3:DeleteObject across the deployment bucket, and a delete is sent even for |
| +// an absent key, so confirmed absence sends no delete (Astra plan R5-1). A |
| +// present object is deleted as the app's "app-policy-clear" mutation; any |
| +// failure, an access-denied delete included, is errAppPolicyClear. Under |
| +// claims a failed delete is an issued effect: it settles uncertain and leaves |
| +// the name's admission closed, so later creates of the name fail closed until |
| +// claim recovery covers it (a follow-up for claim activation). |
| +func (s *Server) clearAppPolicy(ctx context.Context, acct *Account, app string) error { |
| + key := apppolicy.Key(acct.Sqid, app) |
| + if _, _, err := s.cfg.Releases.Get(ctx, key); errors.Is(err, os.ErrNotExist) { |
| + return nil |
| + } else if err != nil { |
| + return fmt.Errorf("%w: read %s: %w", errAppPolicyClear, key, err) |
| + } |
| + if err := s.appMutation(ctx, acct, app, "app-policy-clear", func(ctx context.Context) error { |
| + return apppolicy.Delete(ctx, s.cfg.Releases, acct.Sqid, app) |
| + }); err != nil { |
| + return fmt.Errorf("%w: %w", errAppPolicyClear, err) |
| + } |
| + return nil |
| +} |
| + |
| // handleNewAppForm is the create-app page; the claim itself stays on |
| // POST /a/{sqid}/apps/new (handleNewApp). |
| func (s *Server) handleNewAppForm(w http.ResponseWriter, r *http.Request) { |
| @@ -4472,8 +4525,9 @@ func (s *Server) handleNewApp(w http.ResponseWriter, r *http.Request) { |
| return |
| } |
| // The browser door never places at create time, so the second return |
| - // (what actually stuck) is always "" here and there is nothing to echo |
| - // it to — this form has no placement concept at all. |
| + // (what actually stuck) is "" here and there is nothing to echo it to — |
| + // this form has no placement concept at all. With |
| + // claimPolicyWriterRefused it is the refusal's message instead. |
| defer s.planLock(acct)() |
| if err := s.checkCreateApp(r.Context(), acct, name); err != nil { |
| var planCap *planCapError |
| @@ -4485,7 +4539,7 @@ func (s *Server) handleNewApp(w http.ResponseWriter, r *http.Request) { |
| fail("Could not create the app. Please try again.") |
| return |
| } |
| - outcome, _, held := s.claimApp(r.Context(), acct, name, addr, "", role == RoleOwner, rq) |
| + outcome, msg, held := s.claimApp(r.Context(), acct, name, addr, "", role == RoleOwner, rq) |
| switch outcome { |
| case claimInvalid: |
| fail("Invalid app name — lowercase letters, digits, and dashes, starting with a letter.") |
| @@ -4503,6 +4557,10 @@ func (s *Server) handleNewApp(w http.ResponseWriter, r *http.Request) { |
| fail("Could not check the release store. Please try again.") |
| case claimCreateFailed: |
| fail("Could not create the app. Please try again.") |
| + case claimPolicyClearFailed: |
| + fail(appCreatePolicyClearFlash) |
| + case claimPolicyWriterRefused: |
| + fail(msg) |
| case claimOK: |
| detail := "created app " + name |
| if s.appRegionsOn() { |
diff --git a/internal/console/view_test.go b/internal/console/view_test.go| index 06833ce5d..12436f0b3 100644 |
| --- a/internal/console/view_test.go |
| +++ b/internal/console/view_test.go |
| @@ -7,6 +7,7 @@ import ( |
| "os" |
| "path/filepath" |
| "strings" |
| + "sync/atomic" |
| "testing" |
| "time" |
| @@ -26,6 +27,22 @@ type deletableStore struct { |
| // afterPut, when set, runs after each Put the store accepted: a |
| // test's writer that lands just after another's write. |
| afterPut func(ctx context.Context, key string) |
| + // deleteHook, when set, runs before each Delete and DeleteIfMatch |
| + // reaches the store; an error it returns fails the delete there (an |
| + // s3:DeleteObject Deny). Set with setDeleteHook, safely while the server |
| + // runs. |
| + deleteHook atomic.Pointer[func(ctx context.Context, key string) error] |
| +} |
| + |
| +func (d *deletableStore) setDeleteHook(h func(ctx context.Context, key string) error) { |
| + d.deleteHook.Store(&h) |
| +} |
| + |
| +func (d *deletableStore) runDeleteHook(ctx context.Context, key string) error { |
| + if h := d.deleteHook.Load(); h != nil && *h != nil { |
| + return (*h)(ctx, key) |
| + } |
| + return nil |
| } |
| func (d *deletableStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| @@ -45,10 +62,16 @@ func (d *deletableStore) Put(ctx context.Context, key string, body []byte, ifMat |
| // an unconditional delete (domains.DetachClaim on an instance catch-all) |
| // would then refuse every time. |
| func (d *deletableStore) DeleteIfMatch(ctx context.Context, key, etag string) error { |
| + if err := d.runDeleteHook(ctx, key); err != nil { |
| + return err |
| + } |
| return release.DeleteIfMatch(ctx, d.Store, key, etag) |
| } |
| -func (d *deletableStore) Delete(_ context.Context, key string) error { |
| +func (d *deletableStore) Delete(ctx context.Context, key string) error { |
| + if err := d.runDeleteHook(ctx, key); err != nil { |
| + return err |
| + } |
| if key == "" || strings.HasPrefix(key, "/") { |
| return fmt.Errorf("deletablestore: invalid key %q", key) |
| } |
diff --git a/internal/release/trash.go b/internal/release/trash.go| index 797f09e60..c84fbba81 100644 |
| --- a/internal/release/trash.go |
| +++ b/internal/release/trash.go |
| @@ -6,6 +6,7 @@ import ( |
| "encoding/json" |
| "errors" |
| "fmt" |
| + "log" |
| "os" |
| "time" |
| ) |
| @@ -294,6 +295,25 @@ func purgeApp(ctx context.Context, s Store, pub ed25519.PublicKey, acct, app str |
| return fmt.Errorf("release: list %s: %w", PromotedPrefix(acct, app), err) |
| } |
| keys = append(keys, promoted...) |
| + // The app's policy (#328, #329, spec §3) lives outside its prefix too, |
| + // at control/app-policy/<acct>/<app>.json, and a new app of this name |
| + // must not run with the purged app's egress allow or sizes. Read first, |
| + // deleted only when present: deployments built from |
| + // infra/modules/region-host deny s3:DeleteObject across the bucket, and |
| + // a delete is sent even for an absent key, so an unconditional one would |
| + // fail the purge of every app that never had a policy. A present policy |
| + // that cannot be deleted fails the purge before the marker, so the next |
| + // pass retries until permissions allow. |
| + policyKey := AppPolicyKey(acct, app) |
| + if _, _, err := s.Get(ctx, policyKey); err == nil { |
| + if err := s.Delete(ctx, policyKey); err != nil { |
| + log.Printf("release: purge %s/%s: policy object %s not deleted (%v); the purge retries until it can be removed", |
| + acct, app, policyKey, err) |
| + return fmt.Errorf("release: purge %s: %w", policyKey, err) |
| + } |
| + } else if !errors.Is(err, os.ErrNotExist) { |
| + return fmt.Errorf("release: read %s: %w", policyKey, err) |
| + } |
| for _, key := range keys { |
| if key == marker { |
| continue // last, below |
diff --git a/internal/release/trash_test.go b/internal/release/trash_test.go| index bda14bd17..a1d05aeaf 100644 |
| --- a/internal/release/trash_test.go |
| +++ b/internal/release/trash_test.go |
| @@ -4,6 +4,7 @@ import ( |
| "context" |
| "crypto/ed25519" |
| "errors" |
| + "log" |
| "os" |
| "slices" |
| "strings" |
| @@ -360,6 +361,164 @@ func TestPurgeAppSweepsTheAppsLedgers(t *testing.T) { |
| } |
| } |
| +// policyDeleteStore fails Delete of one key while deny is set, with the |
| +// error an S3 DeleteObject Deny answers, and records every Delete it is |
| +// asked for. Deny is read and set by one goroutine, the test's. |
| +type policyDeleteStore struct { |
| + Store |
| + denyKey string |
| + deny bool |
| + failKey string // a second key whose Delete always fails, "" for none |
| + deletes []string |
| +} |
| + |
| +func (s *policyDeleteStore) Delete(ctx context.Context, key string) error { |
| + s.deletes = append(s.deletes, key) |
| + if (s.deny && key == s.denyKey) || (s.failKey != "" && key == s.failKey) { |
| + return errors.New("AccessDenied: s3:DeleteObject denied by an explicit deny") |
| + } |
| + return s.Store.Delete(ctx, key) |
| +} |
| + |
| +// putPolicyObject lays down bytes at an app's policy key. The purge does not |
| +// read them, so they need not verify. |
| +func putPolicyObject(t *testing.T, s Store, acct, app string) { |
| + t.Helper() |
| + if _, err := s.Put(ctx, AppPolicyKey(acct, app), []byte(`{"payload":"x","sig":"y"}`), ""); err != nil { |
| + t.Fatal(err) |
| + } |
| +} |
| + |
| +func objectExists(t *testing.T, s Store, key string) bool { |
| + t.Helper() |
| + _, _, err := s.Get(ctx, key) |
| + if err != nil && !errors.Is(err, os.ErrNotExist) { |
| + t.Fatal(err) |
| + } |
| + return err == nil |
| +} |
| + |
| +// Contract 4c (#328, #329): the app's policy object goes too, before the |
| +// marker. It lives at control/app-policy/<acct>/<app>.json, outside the |
| +// app's prefix, and a purge that left it handed the next app of the name its |
| +// egress allow and sizes. |
| +func TestPurgeAppSweepsThePolicyBeforeTheMarker(t *testing.T) { |
| + s := &policyDeleteStore{Store: NewDirStore(t.TempDir())} |
| + _, pub := testKeys(t) |
| + seedApp(t, s, "bab", "derp") |
| + seedApp(t, s, "bab", "derpy") |
| + putPolicyObject(t, s, "bab", "derp") |
| + putPolicyObject(t, s, "bab", "derpy") |
| + if err := TrashApp(ctx, s, "bab", "derp", "paul@keymail.dev", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := PurgeApp(ctx, s, pub, "bab", "derp", time.Now()); err != nil { |
| + t.Fatalf("PurgeApp: %v", err) |
| + } |
| + if objectExists(t, s, AppPolicyKey("bab", "derp")) { |
| + t.Fatal("purge left the app's policy behind") |
| + } |
| + if !objectExists(t, s, AppPolicyKey("bab", "derpy")) { |
| + t.Fatal("purge reached the neighbouring app's policy") |
| + } |
| + marker := AppTombstoneKey("bab", "derp") |
| + if !slices.Contains(s.deletes, AppPolicyKey("bab", "derp")) || s.deletes[len(s.deletes)-1] != marker { |
| + t.Fatalf("delete order %v — the policy before the marker, the marker LAST", s.deletes) |
| + } |
| + |
| + // The marker's delete fails: the purge errors with the policy already |
| + // gone, and the next pass completes. |
| + s = &policyDeleteStore{Store: NewDirStore(t.TempDir()), failKey: marker} |
| + seedApp(t, s, "bab", "derp") |
| + putPolicyObject(t, s, "bab", "derp") |
| + if err := TrashApp(ctx, s, "bab", "derp", "paul@keymail.dev", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := PurgeApp(ctx, s, pub, "bab", "derp", time.Now()); err == nil { |
| + t.Fatal("PurgeApp succeeded with the marker's delete failing") |
| + } |
| + if objectExists(t, s, AppPolicyKey("bab", "derp")) { |
| + t.Fatal("the policy outlived a purge that died at the marker") |
| + } |
| + if !objectExists(t, s, marker) { |
| + t.Fatal("the marker is gone after its delete failed") |
| + } |
| + s.failKey = "" |
| + if err := PurgeApp(ctx, s, pub, "bab", "derp", time.Now()); err != nil { |
| + t.Fatalf("second PurgeApp: %v", err) |
| + } |
| + if objectExists(t, s, marker) { |
| + t.Fatal("the second pass left the marker") |
| + } |
| +} |
| + |
| +// Astra plan R6-2: a policy whose delete is denied (deployments built from |
| +// infra/modules/region-host deny s3:DeleteObject) fails the purge and keeps |
| +// the marker, so the next pass retries once permissions allow. |
| +func TestPurgeKeepsTheMarkerWhenThePolicyDeleteIsDenied(t *testing.T) { |
| + var logs strings.Builder |
| + log.SetOutput(&logs) |
| + defer log.SetOutput(os.Stderr) |
| + key := AppPolicyKey("bab", "derp") |
| + s := &policyDeleteStore{Store: NewDirStore(t.TempDir()), denyKey: key, deny: true} |
| + _, pub := testKeys(t) |
| + seedApp(t, s, "bab", "derp") |
| + putPolicyObject(t, s, "bab", "derp") |
| + if err := TrashApp(ctx, s, "bab", "derp", "paul@keymail.dev", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + marker := AppTombstoneKey("bab", "derp") |
| + if err := PurgeApp(ctx, s, pub, "bab", "derp", time.Now()); err == nil { |
| + t.Fatal("PurgeApp succeeded with the policy's delete denied") |
| + } |
| + if !objectExists(t, s, marker) { |
| + t.Fatal("the marker is gone after the policy's delete was denied") |
| + } |
| + if !objectExists(t, s, key) { |
| + t.Fatal("the policy is gone after its delete was denied") |
| + } |
| + if slices.Contains(s.deletes, marker) { |
| + t.Fatalf("deletes %v reached the marker", s.deletes) |
| + } |
| + want := "release: purge bab/derp: policy object " + key + " not deleted (AccessDenied: s3:DeleteObject denied by an explicit deny); the purge retries until it can be removed" |
| + if !strings.Contains(logs.String(), want) { |
| + t.Fatalf("log %q, want a line %q", logs.String(), want) |
| + } |
| + |
| + // Permissions restored: the next pass deletes the policy, then the marker. |
| + s.deny, s.deletes = false, nil |
| + if err := PurgeApp(ctx, s, pub, "bab", "derp", time.Now()); err != nil { |
| + t.Fatalf("PurgeApp after the deny was lifted: %v", err) |
| + } |
| + if objectExists(t, s, key) || objectExists(t, s, marker) { |
| + t.Fatal("the retried purge left the policy or the marker") |
| + } |
| + if i := slices.Index(s.deletes, key); i < 0 || s.deletes[len(s.deletes)-1] != marker { |
| + t.Fatalf("delete order %v — the policy, then the marker last", s.deletes) |
| + } |
| +} |
| + |
| +// Astra plan R5-1: confirmed absence sends no delete, so a purge of an app |
| +// that never had a policy is unaffected by the s3:DeleteObject Deny. |
| +func TestPurgeOfAnAppWithNoPolicySendsNoPolicyDelete(t *testing.T) { |
| + key := AppPolicyKey("bab", "derp") |
| + s := &policyDeleteStore{Store: NewDirStore(t.TempDir()), denyKey: key, deny: true} |
| + _, pub := testKeys(t) |
| + seedApp(t, s, "bab", "derp") |
| + if err := TrashApp(ctx, s, "bab", "derp", "paul@keymail.dev", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if err := PurgeApp(ctx, s, pub, "bab", "derp", time.Now()); err != nil { |
| + t.Fatalf("PurgeApp of an app with no policy: %v", err) |
| + } |
| + if slices.Contains(s.deletes, key) { |
| + t.Fatalf("deletes %v include the absent policy key", s.deletes) |
| + } |
| + if objectExists(t, s, AppTombstoneKey("bab", "derp")) { |
| + t.Fatal("the purge left the marker") |
| + } |
| +} |
| + |
| // Contract 5: idempotent. A half-purged app converges on a second pass, and |
| // an already-purged one reports ErrNotTrashed — which the caller reads as |
| // "done", not as a failure. |