fix: stop and start an instance from the instance's own menu #8
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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)"])
|
||||||
|
|||||||
Reference in New Issue
Block a user