fix: stop and start an instance from the instance's own menu

1.3.0 put suspend and resume in the main menu and on the command line, which
meant remembering a separate command and picking the instance separately for
each of the two. They belong where you are already looking at the instance.

The menu of one instance now ends with a single line above "Back", worded for the
state that instance is in when the menu is drawn:

  Instance 2 - supa2.didgibot.com - running
    ...
    Suspend it (stop it, keep its data)

  Instance 2 - supa2.didgibot.com - suspended
    ...
    Resume it (start it again with its data)

The state is read again every time the menu is drawn, so right after suspending
an instance the same line reads "Resume it". The instance's state is in the menu
title, and the instance picker names it next to every instance, so you can see
which ones are awake before choosing one.

- The main menu's two entries "Suspend instance" and "Resume instance" are gone.
  "Show instance" is now "Show instance (and suspend or resume it)": one way in.
- Asking for the tunnel command of a sleeping instance says it is suspended and
  to resume it from this menu, instead of printing a command that cannot work.
- The line printed after suspending, and the one `supaspin list` prints under a
  sleeping instance, point at the menu line rather than at a command.

`supaspin suspend N` and `supaspin resume N` still work for scripting.

Verified on instance 2 (supa2.didgibot.com): suspended from the instance menu,
the menu came back reading "Resume it" and the picker showed "(suspended)",
resumed from the same line, 12/12 pods with 0 restarts and its 55 tables and
11 MB of data intact.

Tests: 136 pass (6 new). Version 1.3.0 -> 1.3.1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Igor
2026-09-22 22:32:33 +03:00
co-authored by Claude Opus 5
parent 2e4e46c878
commit bdc0542cfc
4 changed files with 129 additions and 40 deletions
+21 -11
View File
@@ -1,6 +1,6 @@
# supaspin # supaspin
Version 1.3.0 Version 1.3.1
Create, list, show and delete throwaway [Supabase](https://supabase.com) instances on a Create, list, show and delete throwaway [Supabase](https://supabase.com) instances on a
single-node [microk8s](https://microk8s.io) server, from a terminal menu. 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 `<root>/<N>/.env` | | 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 `<root>/<N>/.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) | | 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) | | 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) | | 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 | | 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 | | Mail settings | view and change the saved default mail settings |
| Settings | change any first-run setting | | 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 resume N # start it again
sudo supaspin mail # change the saved default mail settings sudo supaspin mail # change the saved default mail settings
sudo supaspin setup # change settings sudo supaspin setup # change settings
supaspin --version # prints "supaspin 1.3.0" supaspin --version # prints "supaspin 1.3.1"
``` ```
### Connecting your app ### Connecting your app
@@ -215,13 +213,24 @@ run `supaspin delete N` to clean up.
## Suspending an instance ## Suspending an instance
An instance you are not using right now does not have to be deleted. `sudo supaspin suspend N` An instance you are not using right now does not have to be deleted. It can be stopped and
stops all twelve of its pods and leaves everything else alone; `sudo supaspin resume N` starts started again with its data, from the menu:
them again with the data they had.
```
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 ```bash
sudo supaspin suspend 2 # about half a minute sudo supaspin suspend 2
sudo supaspin resume 2 # a minute or two: the database answers first, the rest wait for it sudo supaspin resume 2
``` ```
What the server gets back while an instance sleeps: 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 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, 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, 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 so it survives anything that re-applies the chart. `sudo supaspin upgrade` on a suspended instance
+1 -1
View File
@@ -1 +1 @@
1.3.0 1.3.1
+31 -26
View File
@@ -39,7 +39,7 @@ import tty
import yaml import yaml
VERSION = "1.3.0" VERSION = "1.3.1"
CONFIG_PATH = os.environ.get("SUPASPIN_CONFIG", "/etc/supaspin/config.yaml") CONFIG_PATH = os.environ.get("SUPASPIN_CONFIG", "/etc/supaspin/config.yaml")
STATE_DIR = os.environ.get("SUPASPIN_STATE", "/var/lib/supaspin") STATE_DIR = os.environ.get("SUPASPIN_STATE", "/var/lib/supaspin")
CHART_REPO = "https://supabase-community.github.io/supabase-kubernetes" 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)) apply_values(cfg, n, set_replicas(values, 0))
wait_gone(n, log) wait_gone(n, log)
log(f" instance {n} is suspended; its disks and its .env file are untouched") 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 return True
@@ -1660,7 +1660,7 @@ def pick_instance(cfg, title):
if not nums: if not nums:
print("No instances.") print("No instances.")
return None 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] return None if i is None else nums[i]
@@ -1719,11 +1719,19 @@ def setup_dialog(cfg, log):
return cfg 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: while True:
i = menu(f"Instance {n} - {cfg['slots'].get(n, '?')}", state = instance_state(n)
[".env", ".env (secrets shown)", "Tunnel command", "Pods and events", "Back"]) asleep = state == "suspended"
if i is None or i == 4: 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 return
print() print()
try: try:
@@ -1731,10 +1739,14 @@ def show_menu(cfg, n):
text = read_env(cfg, n) text = read_env(cfg, n)
print(f"{inst_dir(cfg, n)}/.env\n\n" + (text if i == 1 else mask_env(text))) print(f"{inst_dir(cfg, n)}/.env\n\n" + (text if i == 1 else mask_env(text)))
elif i == 2: elif i == 2:
print(tunnel_help(cfg, n)) print(tunnel_help(cfg, n) if not asleep else
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", "pods", "-n", namespace(n), "-o", "wide").stdout)
print(kubectl("get", "events", "-n", namespace(n), "--sort-by=.lastTimestamp").stdout[-3000:]) 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: except Exception as e:
print(f"{RED}{e}{OFF}") print(f"{RED}{e}{OFF}")
pause() 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"{BOLD}{r['n']}{OFF} {r['host']} {state} pods {r['pods']} memory {r['memory']}")
print(f" disk {r['disk']}\n env {r['env']}") print(f" disk {r['disk']}\n env {r['env']}")
if state == "suspended": 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 continue # the tunnel is pointless while nothing listens on the ports
for line in tunnel_guide(cfg, r["n"]): for line in tunnel_guide(cfg, r["n"]):
print(f" {line}" if line else "") print(f" {line}" if line else "")
@@ -1759,12 +1771,13 @@ def print_list(cfg):
def main_menu(cfg, log): def main_menu(cfg, log):
while True: while True:
i = menu(f"supaspin {VERSION}", ["Create instance", "List instances", "Show instance", i = menu(f"supaspin {VERSION}",
"Delete instance", "Copy instance values into another .env file", ["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", "Apply current memory caps to running instances",
"Suspend instance (stop it, keep its data)", "Mail settings", "Settings", "Quit"])
"Resume instance", "Mail settings", "Settings", "Quit"]) if i is None or i == 8:
if i is None or i == 10:
return return
print() print()
try: try:
@@ -1773,9 +1786,9 @@ def main_menu(cfg, log):
elif i == 1: elif i == 1:
print_list(cfg) print_list(cfg)
elif i == 2: elif i == 2:
n = pick_instance(cfg, "Show which instance?") n = pick_instance(cfg, "Which instance?")
if n: if n:
show_menu(cfg, n) show_menu(cfg, n, log)
continue continue
elif i == 3: elif i == 3:
n = pick_instance(cfg, "Delete which instance?") n = pick_instance(cfg, "Delete which instance?")
@@ -1790,17 +1803,9 @@ def main_menu(cfg, log):
elif i == 5: elif i == 5:
upgrade_all(cfg, log) upgrade_all(cfg, log)
elif i == 6: 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) cfg["mail"] = mail_dialog(cfg)
save_config(cfg) save_config(cfg)
elif i == 9: elif i == 7:
cfg = setup_dialog(cfg, log) cfg = setup_dialog(cfg, log)
except Exception as e: except Exception as e:
print(f"{RED}{e}{OFF}") print(f"{RED}{e}{OFF}")
+75 -1
View File
@@ -1273,5 +1273,79 @@ class InstanceState(unittest.TestCase):
sp.print_list(CFG) sp.print_list(CFG)
text = out.getvalue() text = out.getvalue()
self.assertIn("suspended", text) 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) 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)"])