Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ asobi deploy prod game/
| `asobi start <name> [--game <slug>]` | Start a stopped environment |
| `asobi resize <name> --size <xs\|s\|m\|l> [--game <slug>]` | Resize an environment |
| `asobi retention <name> --after <never\|7\|30\|90\|365> [--game <slug>]` | How long the environment keeps unclaimed guest accounts. Owner or admin only |
| `asobi delete <name> [--game <slug>]` | Delete an environment |
| `asobi delete <name> [--game <slug>]` | 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 <env_id>` | Delete by env_id and revoke its keys (idempotent; used by CI cleanup) |
| `asobi envs [--game <slug>]` | List your environments |
| `asobi env list [--ephemeral] [--json]` | Structured environment list for scripting |
Expand Down
8 changes: 6 additions & 2 deletions cmd/asobi/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,8 @@ Usage:
asobi retention <name> --after <never|7|30|90|365> [--game <slug>]
Keep unclaimed guests for ever, or delete them
after N days without a sign-in. Owner/admin only
asobi delete <name> [--game <slug>] Delete an environment
asobi delete <name> [--game <slug>] Destroy an environment. Durable
ones retire (database kept 30 days). Owner/admin only
asobi envs [--game <slug>] List your environments
asobi health [env] [--game <slug>] Check engine health (of an environment)
asobi config set <k> <v> Set config (url, api_key)
Expand Down Expand Up @@ -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() {
Expand Down
33 changes: 33 additions & 0 deletions internal/auth/retention_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
18 changes: 17 additions & 1 deletion internal/auth/saas.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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)
Expand Down