From dd0f306157e8c02f42d8e03adc1896f701da8abd Mon Sep 17 00:00:00 2001 From: Daniel Widgren Date: Mon, 17 Aug 2026 10:37:10 +0200 Subject: [PATCH] fix: asobi delete says what the control plane actually does widgrensit/asobi_saas#291 routes CLI destruction through the FSM, so a durable environment now retires - compute down, database kept 30 days, name reusable immediately - instead of being dropped inline, and the response is the queued "destroying" rather than a completed "deleted". "Environment X deleted" promised something the control plane deliberately does not do, and would have kept promising it. Two refusals also get their own message. A 403 is a role refusal, not an expired session, and the generic "delete failed (403)" sends somebody to re-run `asobi login` to fix a permission they do not have. A 409 means deletion protection is set, which is a thing they can act on rather than retry. --- README.md | 2 +- cmd/asobi/main.go | 8 ++++++-- internal/auth/retention_test.go | 33 +++++++++++++++++++++++++++++++++ internal/auth/saas.go | 18 +++++++++++++++++- 4 files changed, 57 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 8b08396..f0fa09a 100644 --- a/README.md +++ b/README.md @@ -92,7 +92,7 @@ asobi deploy prod game/ | `asobi start [--game ]` | Start a stopped environment | | `asobi resize --size [--game ]` | Resize an environment | | `asobi retention --after [--game ]` | How long the environment keeps unclaimed guest accounts. Owner or admin only | -| `asobi delete [--game ]` | Delete an environment | +| `asobi delete [--game ]` | Destroy an environment. A durable one retires (compute down, database kept 30 days, name reusable at once); an ephemeral one is dropped. Owner or admin only, and refused while deletion protection is set | | `asobi destroy ` | Delete by env_id and revoke its keys (idempotent; used by CI cleanup) | | `asobi envs [--game ]` | List your environments | | `asobi env list [--ephemeral] [--json]` | Structured environment list for scripting | diff --git a/cmd/asobi/main.go b/cmd/asobi/main.go index 2001049..7585187 100644 --- a/cmd/asobi/main.go +++ b/cmd/asobi/main.go @@ -114,7 +114,8 @@ Usage: asobi retention --after [--game ] Keep unclaimed guests for ever, or delete them after N days without a sign-in. Owner/admin only - asobi delete [--game ] Delete an environment + asobi delete [--game ] Destroy an environment. Durable + ones retire (database kept 30 days). Owner/admin only asobi envs [--game ] List your environments asobi health [env] [--game ] Check engine health (of an environment) asobi config set Set config (url, api_key) @@ -662,7 +663,10 @@ func cmdDelete() { if err := auth.DeleteEnv(creds, game, args[0]); err != nil { fatal("delete: %v", err) } - fmt.Printf("Environment %s deleted\n", args[0]) + // "destroying", not "deleted": the teardown is queued and a durable + // environment retires rather than disappearing. Saying "deleted" would + // promise something the control plane deliberately does not do. + fmt.Printf("Environment %s is being destroyed\n", args[0]) } func cmdEnvs() { diff --git a/internal/auth/retention_test.go b/internal/auth/retention_test.go index 419020a..6b57557 100644 --- a/internal/auth/retention_test.go +++ b/internal/auth/retention_test.go @@ -121,3 +121,36 @@ func TestRetentionPeriodsAreTheOfferedSet(t *testing.T) { } } } + +// Destroy refusals have specific causes, and reporting them generically sends +// somebody to re-run `asobi login` for a permission they do not have, or to +// retry a call that will keep refusing. +func TestDeleteEnvReportsRoleRefusal(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/internal/cli/envs/prod", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(403) + w.Write([]byte(`{"error":"requires_owner_or_admin"}`)) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + err := DeleteEnv(&Credentials{AccessToken: "at-1", SaasURL: srv.URL}, "", "prod") + if err == nil || !strings.Contains(err.Error(), "owner or admin") { + t.Fatalf("error = %v, want it to name the role requirement", err) + } +} + +func TestDeleteEnvReportsProtection(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/internal/cli/envs/prod", func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(409) + w.Write([]byte(`{"error":"environment_protected"}`)) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + err := DeleteEnv(&Credentials{AccessToken: "at-1", SaasURL: srv.URL}, "", "prod") + if err == nil || !strings.Contains(err.Error(), "protected") { + t.Fatalf("error = %v, want it to name the protection flag", err) + } +} diff --git a/internal/auth/saas.go b/internal/auth/saas.go index 9121975..a7f2acf 100644 --- a/internal/auth/saas.go +++ b/internal/auth/saas.go @@ -312,7 +312,14 @@ func SetRetention(creds *Credentials, game, name, after string) error { return nil } -// DeleteEnv deletes a named environment within a game. +// DeleteEnv destroys a named environment within a game. Requires an owner or +// admin, and refuses an environment with deletion protection set. +// +// A durable environment is RETIRED rather than dropped: compute goes away, the +// database is retained for 30 days, and `asobi create` can reuse the name +// immediately. Ephemeral environments are dropped outright. The call returns as +// soon as the teardown is queued, so the environment is still going away when +// this returns. func DeleteEnv(creds *Credentials, game, name string) error { req, err := http.NewRequest("DELETE", creds.SaasURL+"/internal/cli/envs/"+name+gameQuery(game), nil) if err != nil { @@ -333,6 +340,15 @@ func DeleteEnv(creds *Credentials, game, name string) error { _ = SaveCredentials(creds) return DeleteEnv(creds, game, name) } + // Both of these are refusals with a specific cause, and the generic + // "delete failed (403)" sends somebody to re-run `asobi login` to fix a + // permission they do not have, or to retry a call that will keep refusing. + if resp.StatusCode == 403 { + return fmt.Errorf("only an owner or admin can destroy an environment") + } + if resp.StatusCode == 409 { + return fmt.Errorf("environment is protected; remove deletion protection first") + } if resp.StatusCode >= 400 { data, _ := io.ReadAll(resp.Body) return fmt.Errorf("delete failed (%d): %s", resp.StatusCode, data)