diff --git a/README.md b/README.md index c54b1b6..8b08396 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,7 @@ asobi deploy prod game/ | `asobi stop [--game ]` | Stop a running environment | | `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 destroy ` | Delete by env_id and revoke its keys (idempotent; used by CI cleanup) | | `asobi envs [--game ]` | List your environments | diff --git a/cmd/asobi/main.go b/cmd/asobi/main.go index 720ee43..2001049 100644 --- a/cmd/asobi/main.go +++ b/cmd/asobi/main.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "os" + "slices" "strconv" "strings" "sync" @@ -56,6 +57,8 @@ func main() { cmdStart() case "resize": cmdResize() + case "retention": + cmdRetention() case "delete": cmdDelete() case "envs": @@ -108,6 +111,9 @@ Usage: asobi stop [--game ] Stop an environment asobi start [--game ] Start an environment asobi resize --size [--game ] Resize an environment + 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 envs [--game ] List your environments asobi health [env] [--game ] Check engine health (of an environment) @@ -615,6 +621,37 @@ func cmdResize() { fmt.Printf("Environment %s resizing to %s\n", args[0], sizeFlag) } +// Unclaimed guests are accounts nobody ever signed in to claim. Nothing +// removes them unless an environment says how long to keep them, so a game +// that mints a guest per device accumulates them for ever. +// +// --after is required and has no default. Retention deletes player accounts +// permanently and the sweep writes no audit row, so it is spelled out on every +// invocation rather than inherited from somewhere. +func cmdRetention() { + gameFlag, args := extractFlag(os.Args[2:], "--game") + afterFlag, args := extractFlag(args, "--after") + if len(args) < 1 { + fatal("usage: asobi retention --after [--game ]", strings.Join(auth.RetentionPeriods[1:], "|")) + } + if afterFlag == "" { + fatal("retention requires --after ", strings.Join(auth.RetentionPeriods[1:], "|")) + } + if !slices.Contains(auth.RetentionPeriods, afterFlag) { + fatal("unknown retention period %q - accepted: %s", afterFlag, strings.Join(auth.RetentionPeriods, ", ")) + } + creds := mustLoadCreds() + game := resolveGame(gameFlag, creds) + if err := auth.SetRetention(creds, game, args[0], afterFlag); err != nil { + fatal("retention: %v", err) + } + if afterFlag == "never" { + fmt.Printf("Environment %s keeps unclaimed guests for ever\n", args[0]) + return + } + fmt.Printf("Environment %s deletes unclaimed guests after %s days of inactivity - it restarts to pick this up\n", args[0], afterFlag) +} + func cmdDelete() { gameFlag, args := extractFlag(os.Args[2:], "--game") if len(args) < 1 { diff --git a/internal/auth/retention_test.go b/internal/auth/retention_test.go new file mode 100644 index 0000000..419020a --- /dev/null +++ b/internal/auth/retention_test.go @@ -0,0 +1,123 @@ +package auth + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestSetRetentionSendsThePeriod(t *testing.T) { + var got map[string]string + mux := http.NewServeMux() + mux.HandleFunc("/internal/cli/envs/prod/retention", func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer at-1" { + w.WriteHeader(401) + return + } + body, _ := io.ReadAll(r.Body) + json.Unmarshal(body, &got) + w.WriteHeader(200) + w.Write([]byte(`{"retention":"Deleting unclaimed guests after 30 days of inactivity"}`)) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + creds := &Credentials{AccessToken: "at-1", SaasURL: srv.URL} + if err := SetRetention(creds, "", "prod", "30"); err != nil { + t.Fatalf("SetRetention: %v", err) + } + if got["after"] != "30" { + t.Fatalf(`after = %q, want "30"`, got["after"]) + } +} + +// "never" has to reach the server as a value rather than as an omitted field, +// or turning retention back off would read as a request that named no period +// and be refused. +func TestSetRetentionSendsNeverExplicitly(t *testing.T) { + var got map[string]string + mux := http.NewServeMux() + mux.HandleFunc("/internal/cli/envs/prod/retention", func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + json.Unmarshal(body, &got) + w.WriteHeader(200) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + creds := &Credentials{AccessToken: "at-1", SaasURL: srv.URL} + if err := SetRetention(creds, "", "prod", "never"); err != nil { + t.Fatalf("SetRetention: %v", err) + } + if _, ok := got["after"]; !ok { + t.Fatal("after was omitted; it must be sent") + } + if got["after"] != "never" { + t.Fatalf(`after = %q, want "never"`, got["after"]) + } +} + +// A role refusal is not an expired session. Reporting it as one sends somebody +// off to re-run `asobi login` to fix a permission they do not have. +func TestSetRetentionReportsRoleRefusalPlainly(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/internal/cli/envs/prod/retention", 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() + + creds := &Credentials{AccessToken: "at-1", SaasURL: srv.URL} + err := SetRetention(creds, "", "prod", "30") + if err == nil { + t.Fatal("want an error") + } + if !strings.Contains(err.Error(), "owner or admin") { + t.Fatalf("error = %q, want it to name the role requirement", err) + } +} + +func TestSetRetentionPassesTheGame(t *testing.T) { + var gotQuery string + mux := http.NewServeMux() + mux.HandleFunc("/internal/cli/envs/prod/retention", func(w http.ResponseWriter, r *http.Request) { + gotQuery = r.URL.RawQuery + w.WriteHeader(200) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + + creds := &Credentials{AccessToken: "at-1", SaasURL: srv.URL} + if err := SetRetention(creds, "arena", "prod", "90"); err != nil { + t.Fatalf("SetRetention: %v", err) + } + if !strings.Contains(gotQuery, "arena") { + t.Fatalf("query = %q, want it to carry the game slug", gotQuery) + } +} + +// The list the CLI checks against locally must be the one the server accepts. +// `1` is the value that matters: a one-day retention would erase a working +// game's whole guest population overnight. +func TestRetentionPeriodsAreTheOfferedSet(t *testing.T) { + want := []string{"never", "7", "30", "90", "365"} + if len(RetentionPeriods) != len(want) { + t.Fatalf("RetentionPeriods = %v, want %v", RetentionPeriods, want) + } + for i, v := range want { + if RetentionPeriods[i] != v { + t.Fatalf("RetentionPeriods[%d] = %q, want %q", i, RetentionPeriods[i], v) + } + } + for _, bad := range []string{"1", "0", "-30", "31", "forever", ""} { + for _, ok := range RetentionPeriods { + if bad == ok { + t.Fatalf("%q must not be an offered period", bad) + } + } + } +} diff --git a/internal/auth/saas.go b/internal/auth/saas.go index 52d2fad..9121975 100644 --- a/internal/auth/saas.go +++ b/internal/auth/saas.go @@ -268,6 +268,50 @@ func ResizeEnv(creds *Credentials, game, name, size string) error { return nil } +// RetentionPeriods are the values the control plane accepts for guest +// retention, in the order the help text lists them. Kept here so a typo is a +// local error rather than a round trip, but the server validates against its +// own list regardless - this is a convenience, not the check. +var RetentionPeriods = []string{"never", "7", "30", "90", "365"} + +// SetRetention sets how long an environment keeps unclaimed guest accounts. +// "never" keeps them for ever; a number is days of inactivity since the device +// last resumed the guest. Requires an owner or admin, and rolls the +// environment, because the engine reads the policy at boot. +func SetRetention(creds *Credentials, game, name, after string) error { + body, _ := json.Marshal(map[string]string{"after": after}) + req, err := http.NewRequest("POST", creds.SaasURL+"/internal/cli/envs/"+name+"/retention"+gameQuery(game), bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+creds.AccessToken) + resp, err := httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode == 401 { + refreshed, err := RefreshAccessToken(creds) + if err != nil { + return err + } + creds.AccessToken = refreshed + _ = SaveCredentials(creds) + return SetRetention(creds, game, name, after) + } + // 403 here is a role refusal, not an expired session, and saying so saves + // somebody re-running `asobi login` to fix a permission they do not have. + if resp.StatusCode == 403 { + return fmt.Errorf("only an owner or admin can change guest retention") + } + if resp.StatusCode >= 400 { + data, _ := io.ReadAll(resp.Body) + return fmt.Errorf("retention failed (%d): %s", resp.StatusCode, data) + } + return nil +} + // DeleteEnv deletes a named environment within a game. func DeleteEnv(creds *Credentials, game, name string) error { req, err := http.NewRequest("DELETE", creds.SaasURL+"/internal/cli/envs/"+name+gameQuery(game), nil)