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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ asobi deploy prod game/
| `asobi stop <name> [--game <slug>]` | Stop a running environment |
| `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 destroy <env_id>` | Delete by env_id and revoke its keys (idempotent; used by CI cleanup) |
| `asobi envs [--game <slug>]` | List your environments |
Expand Down
37 changes: 37 additions & 0 deletions cmd/asobi/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"fmt"
"io"
"os"
"slices"
"strconv"
"strings"
"sync"
Expand Down Expand Up @@ -56,6 +57,8 @@ func main() {
cmdStart()
case "resize":
cmdResize()
case "retention":
cmdRetention()
case "delete":
cmdDelete()
case "envs":
Expand Down Expand Up @@ -108,6 +111,9 @@ Usage:
asobi stop <name> [--game <slug>] Stop an environment
asobi start <name> [--game <slug>] Start an 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>]
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 envs [--game <slug>] List your environments
asobi health [env] [--game <slug>] Check engine health (of an environment)
Expand Down Expand Up @@ -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 <name> --after <never|%s> [--game <slug>]", strings.Join(auth.RetentionPeriods[1:], "|"))
}
if afterFlag == "" {
fatal("retention requires --after <never|%s>", 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 {
Expand Down
123 changes: 123 additions & 0 deletions internal/auth/retention_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
}
44 changes: 44 additions & 0 deletions internal/auth/saas.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down