diff --git a/README.md b/README.md index 52cc4a3..51da828 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # supaspin -Version 1.3.0 +Version 1.3.1 Create, list, show and delete throwaway [Supabase](https://supabase.com) instances on a single-node [microk8s](https://microk8s.io) server, from a terminal menu. @@ -90,12 +90,10 @@ Supabase Helm chart (0.8.0, Postgres 17.6.1.136) once into `/var/lib/supaspin/ch |---|---| | Create instance | picks the lowest free number, asks whether to use the saved mail settings or enter different ones, creates the disks, installs Supabase, waits until every pod is ready, writes `//.env` | | List instances | number, hostname, pods ready, memory in use, disk use per image, `.env` path, and for each instance the tunnel command with plain instructions (open it, keep it in the background, close it) | -| Show instance | the `.env` (secrets hidden unless you ask), the tunnel command, pod status and recent events | +| Show instance (and suspend or resume it) | the `.env` (secrets hidden unless you ask), the tunnel command, pod status and recent events - and the one line that stops this instance or starts it again, worded for whichever state it is in | | Delete instance | asks you to type the number; removes everything (see below) | | Copy instance values into another .env file | puts an instance's `.env` values into your app's `.env`, on this server or on another computer (see below) | | Apply current memory caps to running instances | puts this version's memory limits on instances that already exist, replacing their pods one service at a time; databases, uploads and `.env` files are not touched | -| Suspend instance | stops every pod of one instance and keeps everything else: its memory, processor time, pod slots and two server ports go back to the server, its disks and `.env` file stay | -| Resume instance | starts a suspended instance again, with the data it had | | Mail settings | view and change the saved default mail settings | | Settings | change any first-run setting | @@ -114,7 +112,7 @@ sudo supaspin suspend N # stop an instance, keep its data (see "Su sudo supaspin resume N # start it again sudo supaspin mail # change the saved default mail settings sudo supaspin setup # change settings -supaspin --version # prints "supaspin 1.3.0" +supaspin --version # prints "supaspin 1.3.1" ``` ### Connecting your app @@ -215,13 +213,24 @@ run `supaspin delete N` to clean up. ## Suspending an instance -An instance you are not using right now does not have to be deleted. `sudo supaspin suspend N` -stops all twelve of its pods and leaves everything else alone; `sudo supaspin resume N` starts -them again with the data they had. +An instance you are not using right now does not have to be deleted. It can be stopped and +started again with its data, from the menu: + +``` +sudo supaspin -> Show instance (and suspend or resume it) -> pick the instance + -> Suspend it (stop it, keep its data) +``` + +The picker names every instance with its state next to it, so you can see at a glance which ones +are awake. Inside an instance the same line reads **Resume it** when the instance is asleep, so +there is one line to look for either way and nothing to remember. Stopping takes about half a +minute; starting takes a minute or two, because the database answers first and the rest wait for it. + +The same two actions without the menu, if you prefer typing: ```bash -sudo supaspin suspend 2 # about half a minute -sudo supaspin resume 2 # a minute or two: the database answers first, the rest wait for it +sudo supaspin suspend 2 +sudo supaspin resume 2 ``` What the server gets back while an instance sleeps: @@ -240,7 +249,8 @@ What is kept, untouched: Disk space is **not** given back: that is the data you are keeping. `sudo supaspin list` shows the state of every instance, and a suspended one is listed as `suspended` with no tunnel commands, -because nothing is listening on its ports. +because nothing is listening on its ports. Asking for the tunnel command of a sleeping instance +says so instead of handing you a command that cannot work. Suspending is written into the instance's stored settings, not done by hand to the running pods, so it survives anything that re-applies the chart. `sudo supaspin upgrade` on a suspended instance diff --git a/VERSION b/VERSION index f0bb29e..3a3cd8c 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.0 +1.3.1 diff --git a/supaspin b/supaspin index 0b7ead5..57c42f4 100755 --- a/supaspin +++ b/supaspin @@ -39,7 +39,7 @@ import tty import yaml -VERSION = "1.3.0" +VERSION = "1.3.1" CONFIG_PATH = os.environ.get("SUPASPIN_CONFIG", "/etc/supaspin/config.yaml") STATE_DIR = os.environ.get("SUPASPIN_STATE", "/var/lib/supaspin") CHART_REPO = "https://supabase-community.github.io/supabase-kubernetes" @@ -877,7 +877,7 @@ def suspend_instance(cfg, n, log): apply_values(cfg, n, set_replicas(values, 0)) wait_gone(n, log) log(f" instance {n} is suspended; its disks and its .env file are untouched") - log(f" start it again with: supaspin resume {n}") + log(" start it again in this instance's menu: \"Resume it\"") return True @@ -1660,7 +1660,7 @@ def pick_instance(cfg, title): if not nums: print("No instances.") return None - i = menu(title, [f"{n} {cfg['slots'].get(n, '?')}" for n in nums]) + i = menu(title, [f"{n} {cfg['slots'].get(n, '?')} ({instance_state(n)})" for n in nums]) return None if i is None else nums[i] @@ -1719,11 +1719,19 @@ def setup_dialog(cfg, log): return cfg -def show_menu(cfg, n): +def show_menu(cfg, n, log=None): + """Everything you can do to one instance, including stopping and starting it. The stop/start + line is worded for the state the instance is in when the menu is drawn, and the state is read + again every time you come back to it, so the menu never offers something that makes no sense.""" + log = log or log_line while True: - i = menu(f"Instance {n} - {cfg['slots'].get(n, '?')}", - [".env", ".env (secrets shown)", "Tunnel command", "Pods and events", "Back"]) - if i is None or i == 4: + state = instance_state(n) + asleep = state == "suspended" + switch = ("Resume it (start it again with its data)" if asleep + else "Suspend it (stop it, keep its data)") + items = [".env", ".env (secrets shown)", "Tunnel command", "Pods and events", switch, "Back"] + i = menu(f"Instance {n} - {cfg['slots'].get(n, '?')} - {state}", items) + if i is None or i == 5: return print() try: @@ -1731,10 +1739,14 @@ def show_menu(cfg, n): text = read_env(cfg, n) print(f"{inst_dir(cfg, n)}/.env\n\n" + (text if i == 1 else mask_env(text))) elif i == 2: - print(tunnel_help(cfg, n)) - else: + print(tunnel_help(cfg, n) if not asleep else + f"Instance {n} is suspended: nothing is listening on its ports.\n" + "Resume it from this menu and the tunnel commands work again.") + elif i == 3: print(kubectl("get", "pods", "-n", namespace(n), "-o", "wide").stdout) print(kubectl("get", "events", "-n", namespace(n), "--sort-by=.lastTimestamp").stdout[-3000:]) + else: + (resume_instance if asleep else suspend_instance)(cfg, n, log) except Exception as e: print(f"{RED}{e}{OFF}") pause() @@ -1750,7 +1762,7 @@ def print_list(cfg): print(f"{BOLD}{r['n']}{OFF} {r['host']} {state} pods {r['pods']} memory {r['memory']}") print(f" disk {r['disk']}\n env {r['env']}") if state == "suspended": - print(f" start it again with: supaspin resume {r['n']}\n") + print(f" start it again in this instance's menu: \"Resume it\"\n") continue # the tunnel is pointless while nothing listens on the ports for line in tunnel_guide(cfg, r["n"]): print(f" {line}" if line else "") @@ -1759,12 +1771,13 @@ def print_list(cfg): def main_menu(cfg, log): while True: - i = menu(f"supaspin {VERSION}", ["Create instance", "List instances", "Show instance", - "Delete instance", "Copy instance values into another .env file", - "Apply current memory caps to running instances", - "Suspend instance (stop it, keep its data)", - "Resume instance", "Mail settings", "Settings", "Quit"]) - if i is None or i == 10: + i = menu(f"supaspin {VERSION}", + ["Create instance", "List instances", + "Show instance (and suspend or resume it)", "Delete instance", + "Copy instance values into another .env file", + "Apply current memory caps to running instances", + "Mail settings", "Settings", "Quit"]) + if i is None or i == 8: return print() try: @@ -1773,9 +1786,9 @@ def main_menu(cfg, log): elif i == 1: print_list(cfg) elif i == 2: - n = pick_instance(cfg, "Show which instance?") + n = pick_instance(cfg, "Which instance?") if n: - show_menu(cfg, n) + show_menu(cfg, n, log) continue elif i == 3: n = pick_instance(cfg, "Delete which instance?") @@ -1790,17 +1803,9 @@ def main_menu(cfg, log): elif i == 5: upgrade_all(cfg, log) elif i == 6: - n = pick_instance(cfg, "Suspend which instance?") - if n: - suspend_instance(cfg, n, log) - elif i == 7: - n = pick_instance(cfg, "Resume which instance?") - if n: - resume_instance(cfg, n, log) - elif i == 8: cfg["mail"] = mail_dialog(cfg) save_config(cfg) - elif i == 9: + elif i == 7: cfg = setup_dialog(cfg, log) except Exception as e: print(f"{RED}{e}{OFF}") diff --git a/tests/test_supaspin.py b/tests/test_supaspin.py index 7cea9ab..b3af2fb 100644 --- a/tests/test_supaspin.py +++ b/tests/test_supaspin.py @@ -1273,5 +1273,79 @@ class InstanceState(unittest.TestCase): sp.print_list(CFG) text = out.getvalue() self.assertIn("suspended", text) - self.assertIn("supaspin resume 1", text) + self.assertIn("Resume it", text) # the menu line, not a command to remember self.assertNotIn("ssh -N -L", text) + + +class InstanceMenu(unittest.TestCase): + """Stopping and starting an instance lives inside the instance you are looking at: one line, + worded for the state the instance is in, so there is no second command to remember.""" + + def run_menu(self, state, choose): + """Draw the menu for an instance in `state`, pick the item whose text `choose` matches + (None: leave at once). Returns (the item texts, what was called).""" + seen, called = [], [] + + def menu(title, items): + seen.append((title, list(items))) + if choose is None: + return None + for i, text in enumerate(items): + if choose in text: + return i + raise AssertionError(f"no item matching {choose!r} in {items}") + + calls = {"suspend": lambda cfg, n, log: called.append(("suspend", n)), + "resume": lambda cfg, n, log: called.append(("resume", n))} + out = io.StringIO() + with mock.patch.object(sp, "menu", menu), \ + mock.patch.object(sp, "instance_state", lambda n: state), \ + mock.patch.object(sp, "pause", lambda: (_ for _ in ()).throw(StopIteration)), \ + mock.patch.object(sp, "suspend_instance", calls["suspend"]), \ + mock.patch.object(sp, "resume_instance", calls["resume"]), \ + mock.patch.object(sp, "read_env", lambda cfg, n: "A=b\n"), \ + contextlib.redirect_stdout(out): + try: + sp.show_menu(CFG, 2, lambda *a, **k: None) + except StopIteration: + pass # the menu would loop for ever; stop at the first "press Enter" + return seen[0][1], called, out.getvalue(), seen[0][0] + + def test_a_running_instance_offers_to_suspend_it(self): + items, called, _, title = self.run_menu("running", None) + self.assertTrue(any("Suspend it" in t for t in items), items) + self.assertFalse(any("Resume" in t for t in items), items) + self.assertIn("running", title) + + def test_a_suspended_instance_offers_to_resume_it(self): + items, called, _, title = self.run_menu("suspended", None) + self.assertTrue(any("Resume it" in t for t in items), items) + self.assertFalse(any("Suspend" in t for t in items), items) + self.assertIn("suspended", title) + + def test_choosing_it_suspends_this_instance(self): + _, called, _, _ = self.run_menu("running", "Suspend it") + self.assertEqual(called, [("suspend", 2)]) + + def test_choosing_it_resumes_this_instance(self): + _, called, _, _ = self.run_menu("suspended", "Resume it") + self.assertEqual(called, [("resume", 2)]) + + def test_the_tunnel_of_a_sleeping_instance_says_why_it_is_pointless(self): + _, _, text, _ = self.run_menu("suspended", "Tunnel") + self.assertIn("suspended", text) + self.assertNotIn("ssh -N -L", text) + + def test_the_picker_names_the_state_of_every_instance(self): + seen = [] + + def menu(title, items): + seen.append(list(items)) + return None + + with mock.patch.object(sp, "menu", menu), \ + mock.patch.object(sp, "existing_numbers", lambda cfg: {1, 2}), \ + mock.patch.object(sp, "instance_state", lambda n: "suspended" if n == 2 else "running"): + sp.pick_instance(CFG, "Which instance?") + self.assertEqual(seen[0], ["1 supa1.example.com (running)", + "2 supa2.example.com (suspended)"])