Files
supaspin/supaspin
T
igorandClaude Opus 5.5 7184e7b578 fix: give each instance's Postgres a 12 GB disk image and a 4 GB memory limit (#16)
- db disk image 6144 -> 12288 MiB (VOLUMES["db"]): instance 4 had reached
  4.7 GB of its 6 GB and instances kept being recreated when the image filled.
- db memory request/limit 512/2048 -> 1024/4096 MiB (MEMORY["db"]); the
  per-instance totals become 2.375 GiB requested and 11 GiB limit.
- README (disk and memory rows, totals) and the unit tests follow the new
  numbers; release 1.6.5.

Only instances created afterwards get the bigger disk image: existing ones
have to be deleted and created again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 18:50:08 +00:00

2081 lines
91 KiB
Python
Executable File

#!/usr/bin/env python3
"""supaspin - create, list, show and delete throwaway Supabase instances on microk8s.
Each numbered instance gets its own namespace, capped disk images, capped memory,
an HTTPS hostname and fixed Postgres/Studio ports. Standard library plus python3-yaml.
Run as root: supaspin (menu)
supaspin create|list|show N|env N|tunnel N|delete N|setup|mail
supaspin upgrade [N]
Puts this version's memory caps on instances that already run, one service
at a time. Without N every instance. Data and .env files are not touched.
supaspin suspend N | resume N
Suspend stops every pod of one instance and gives back its memory, its pod
slots and its two server ports. The disks, the passwords in .env, the
certificate and the hostname stay. Resume starts it again with its data.
supaspin push-env N TARGET [--port P] [--key FILE] [--dry-run]
TARGET = a .env file on this server (/path/.env) or on another computer
(user@host:/path/.env, Windows: user@host:C:/path/.env). Puts the instance's
KEY=VALUE pairs into it: existing keys get the new value, missing keys are added.
"""
import argparse
import base64
import glob
import hashlib
import hmac
import json
import os
import pwd
import re
import secrets
import shutil
import string
import subprocess
import sys
import tempfile
import termios
import time
import tty
import yaml
VERSION = "1.6.5"
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"
CHART_NAME = "supabase"
CHART_VERSION = "0.8.0"
STORAGE_CLASS = "supaspin-local-sc"
ISSUER = "supaspin-letsencrypt"
PRIORITY_CLASS = "supaspin-low"
CERTS_NS = "supaspin-certs"
# Image cap per volume, in MiB. The chart's separate imgproxy volume is not used: imgproxy has to
# read the uploaded files, so it mounts the storage volume instead (see instance_values).
# The db image is 6 GB because of the analytics logs: browser-test traffic wrote about 1.2 GB of
# logs a day on instance 2 (2026-10-14), the nightly cleanup keeps LOG_RETENTION of them, and the
# write-ahead log may take up to 1 GB on top. That peaks near 4.5 GB; 2 GB filled up on instance 4.
# Doubled to 12 GB on 2026-11-03 at Igor's request: instance 4 had reached 4.7 GB of its 6 GB and
# instances kept having to be recreated when the image filled up.
VOLUMES = {"db": 12288, "storage": 1024, "functions": 256,
"snippets": 256, "deno": 256, "pgsodium": 256}
# The user Postgres runs as inside supabase/postgres 17.6.1.136 (`id postgres` in the image).
# Production's older image used 2000; do not copy that number.
DB_OWNED = ("db", "pgsodium")
DB_UID, DB_GID = 100, 101
# Memory per service: (request MiB, limit MiB). The limit is the ceiling the kernel kills the
# container at; the request is what the scheduler sets aside for it. The requests stay small on
# purpose: this is one machine shared with production, and reserving what an instance really uses
# three times over would leave production nothing to start in. The limits are about twice the
# highest use measured across three running instances on 2026-09-22, because the two logging
# services grow with their backlog and the old ceilings sat right on top of normal use:
# analytics 612 / 694 / 662 MiB against a 768 MiB ceiling - killed 36 times on instance 1
# vector 287 / 290 MiB against 512 - killed 297 times on instance 1, where it had a backlog
# realtime 269 / 287 / 300 MiB against 384
# kong 260 / 269 / 265 MiB against 512
# studio 203, storage 187, rest 162 MiB against 256
# db 163 / 175 / 273 MiB against 2048 - doubled to 1024 / 4096 on 2026-11-03 at Igor's
# request, together with the db image, so a larger database has room to work in
# On 2026-10-06 instance 3 filled its 2 GB database image with logs: analytics (Logflare) never
# deletes what it stored, the db died of it, and vector's undelivered backlog was killed against
# the 1024 MiB ceiling every few minutes. The fix is setup_log_retention: a pg_cron job deletes
# analytics logs older than LOG_RETENTION in every instance, which also bounds vector's backlog,
# so vector is back to the 512 MiB ceiling the production supabase charts carry.
# Raising a limit costs nothing until a container actually asks for the memory; raising a request
# would take the memory away from everything else on the node straight away.
SERVICES = ["db", "analytics", "kong", "auth", "functions", "imgproxy", "meta",
"realtime", "rest", "storage", "studio", "vector"]
MEMORY = {"db": (1024, 4096), "analytics": (128, 1536), "kong": (128, 768), "realtime": (128, 768),
"vector": (128, 512)}
DEFAULT_MEMORY = (128, 512)
# Analytics logs: deleted from every instance's _supabase database when older than this. Logflare
# stores logs in _analytics.log_events_* tables and never deletes them; without this job the
# capped db image fills up and the database dies (instance 3, 2026-10-06).
# pg_cron in the supabase image only runs jobs from the database named in cron.database_name,
# which the image pins to `postgres`, while the logs live in `_supabase`. The job therefore runs
# in `postgres` and reaches the logs over a local dblink connection, which needs no password: the
# image trusts local socket connections for supabase_admin.
# dblink and the helper function MUST live in the `extensions` schema, not `public`: the app's
# migrations revoke API-role privileges on every routine in public, and `postgres` (the role
# Prisma connects as) is no superuser here - revoking someone else's routines fails with
# "permission denied for function dblink_connect_u" (42501, maralago 0_init, 2026-10-08).
LOG_RETENTION = "2 days"
LOG_RETENTION_JOB = "supaspin-log-retention"
RETENTION_SQL = f"""
create extension if not exists pg_cron;
create extension if not exists dblink with schema extensions;
alter extension dblink set schema extensions;
drop function if exists public.supaspin_delete_old_logs();
create or replace function extensions.supaspin_delete_old_logs() returns void
language plpgsql as $fn$
declare
conn text := 'supaspin_lr_' || substr(md5(clock_timestamp()::text), 1, 8);
t text;
begin
perform extensions.dblink_connect(conn, 'dbname=_supabase user=supabase_admin');
for t in
select tablename from extensions.dblink(conn,
'select tablename from pg_tables where schemaname = ''_analytics'' and tablename like ''log_events\\_%''')
as log_tables(tablename text)
loop
perform extensions.dblink_exec(conn,
format('delete from _analytics.%I where timestamp < now() - interval ''{LOG_RETENTION}''', t));
end loop;
perform extensions.dblink_disconnect(conn);
exception when others then
begin perform extensions.dblink_disconnect(conn); exception when others then null; end;
raise;
end $fn$;
select extensions.supaspin_delete_old_logs();
delete from cron.job where jobname = '{LOG_RETENTION_JOB}';
select cron.schedule('{LOG_RETENTION_JOB}', '17 3 * * *', 'select extensions.supaspin_delete_old_logs()');
""".strip()
DEFAULT_MAIL = {"host": "smtp.example.com", "port": 587, "user": "", "password": "",
"admin_email": "admin@example.com", "sender_name": "Supabase"}
BOLD, DIM, RED, REV, OFF = "\x1b[1m", "\x1b[2m", "\x1b[31m", "\x1b[7m", "\x1b[0m"
# ------------------------------------------------------------ pure helpers --
def pg_port(n):
return 5440 + n
def studio_port(n):
return 3000 + n
def namespace(n):
return f"supaspin-{n}"
def release(n):
return f"supa{n}"
def fullname(n):
"""Name the chart gives to its objects: <release>-supabase."""
return f"{release(n)}-supabase"
def pvc_name(n, vol):
return f"{fullname(n)}-{vol}"
def tls_secret(n):
return f"supa{n}-tls"
_SLOT_NUM = re.compile(r"(\d+)")
def parse_slots(text):
"""Slots {number: hostname} from the comma-separated setup answer. A hostname with a
number in it keys the slot by that number - supa4.didgibot.com is slot 4 - so the
instance number, its ports and the hostname digit stay in step; a name without a
number takes its position in the list (the first name is slot 1)."""
slots = {}
for i, h in enumerate([p.strip() for p in text.split(",") if p.strip()], 1):
m = _SLOT_NUM.search(h)
n = int(m.group(1)) if m else i
if n in slots:
raise ValueError(f"'{h}' wants slot {n}, which '{slots[n]}' already takes.")
slots[n] = h
return slots
def buffering_middleware(n):
"""Name of the Traefik Middleware that caps the request body size for instance n."""
return f"supa{n}-buffering"
def ingress_annotations(cfg, n):
"""Ingress annotations for instance n.
Both controllers' annotations are always present: an annotation only takes effect
when a controller of that family actually routes the ingress, so on a Traefik
cluster the nginx keys sit inert (and vice versa) and nothing needs reconfiguring
when the router changes. The upload cap therefore lives in two places that agree:
proxy-body-size for nginx, and the Middleware object the traefik annotation points
at. The chart's own rewrite-target default must be nullified or every API path
breaks. The cert-manager annotation orders a certificate from the
supaspin-letsencrypt issuer; without it the TLS secret keeps whatever supaspin put
there (a saved or a self-signed certificate)."""
anns = {}
if cfg.get("lets_encrypt", True):
anns["cert-manager.io/cluster-issuer"] = ISSUER
# The chart default rewrites every path to "/", which breaks all APIs; null removes it.
anns["nginx.ingress.kubernetes.io/rewrite-target"] = None
anns["nginx.ingress.kubernetes.io/proxy-body-size"] = "50m"
anns["nginx.ingress.kubernetes.io/proxy-read-timeout"] = "3600"
anns["nginx.ingress.kubernetes.io/proxy-send-timeout"] = "3600"
if cfg.get("ingress_controller", "nginx") == "traefik":
anns["traefik.ingress.kubernetes.io/router.middlewares"] = \
f"{namespace(n)}-{buffering_middleware(n)}@kubernetescrd"
return anns
def lowest_free_number(slots, taken):
"""Lowest slot number that is not in `taken`; None if every slot is used."""
for n in sorted(int(k) for k in slots):
if n not in taken:
return n
return None
def b64url(data):
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
def sign_jwt(secret, role, now=None, years=10):
"""HS256 token with the claims Supabase expects for the anon/service keys."""
now = int(now if now is not None else time.time())
header = b64url(json.dumps({"alg": "HS256", "typ": "JWT"}, separators=(",", ":")).encode())
payload = b64url(json.dumps(
{"role": role, "iss": "supabase", "iat": now, "exp": now + years * 365 * 86400},
separators=(",", ":")).encode())
sig = hmac.new(secret.encode(), f"{header}.{payload}".encode(), hashlib.sha256).digest()
return f"{header}.{payload}.{b64url(sig)}"
def rand(n, alphabet=string.ascii_letters + string.digits):
return "".join(secrets.choice(alphabet) for _ in range(n))
def generate_secrets():
jwt_secret = rand(48)
return {
"JWT_SECRET": jwt_secret,
"ANON_KEY": sign_jwt(jwt_secret, "anon"),
"SERVICE_KEY": sign_jwt(jwt_secret, "service_role"),
"DB_PASSWORD": rand(32),
"DASHBOARD_USERNAME": "supabase",
"DASHBOARD_PASSWORD": rand(24),
"LOGFLARE_PUBLIC": rand(40),
"LOGFLARE_PRIVATE": rand(40),
"S3_KEY_ID": secrets.token_hex(16),
"S3_ACCESS_KEY": secrets.token_hex(32),
"REALTIME_SECRET_KEY_BASE": base64.b64encode(secrets.token_bytes(48)).decode(),
"REALTIME_DB_ENC_KEY": secrets.token_hex(8), # must be exactly 16 characters
"META_CRYPTO_KEY": secrets.token_hex(16),
}
def find_template():
for d in (os.environ.get("SUPASPIN_TEMPLATES"),
os.path.join(os.path.dirname(os.path.realpath(__file__)), "templates"),
"/usr/share/supaspin/templates"):
if d and os.path.exists(os.path.join(d, "values.yaml.tpl")):
return os.path.join(d, "values.yaml.tpl")
raise SystemExit("values.yaml.tpl not found (reinstall supaspin)")
def instance_values(cfg, n, sec, mail, template_text):
"""Fill the template, then add memory caps, priority class and volume sizes."""
host = cfg["slots"][n]
subs = dict(sec)
subs.update({
"PUBLIC_URL": f"https://{host}", "HOST": host,
"TLS_SECRET": tls_secret(n), "INGRESS_CLASS": cfg["ingress_class"],
"PROJECT_NAME": f"supaspin-{n}",
"INGRESS_ANNOTATIONS": ingress_annotations(cfg, n),
"SMTP_HOST": mail["host"], "SMTP_PORT": mail["port"], "SMTP_USER": mail["user"],
"SMTP_PASS": mail["password"], "SMTP_ADMIN_EMAIL": mail["admin_email"],
"SMTP_SENDER_NAME": mail["sender_name"],
})
# json.dumps gives a quoted YAML-safe string; the port is quoted as well (env values are strings).
# A dict is dumped as JSON too: JSON flow syntax is valid YAML, so the annotation map drops in.
quoted = {k: (json.dumps(v) if isinstance(v, dict) else json.dumps(str(v))) for k, v in subs.items()}
text = re.sub(r"\$\{(\w+)\}", lambda m: quoted[m.group(1)], template_text)
values = yaml.safe_load(text)
apply_memory(values)
persistence = values.setdefault("persistence", {})
for vol, mib in VOLUMES.items():
persistence[vol] = {"enabled": True, "storageClassName": STORAGE_CLASS,
"size": f"{mib}Mi"}
# With the file backend storage-api gives imgproxy a local path, so imgproxy must mount the
# very volume the uploads are written to; the chart's own imgproxy volume is empty and useless.
persistence["imgproxy"] = {"enabled": True, "existingClaim": pvc_name(n, "storage")}
return values
def apply_memory(values):
"""Write the memory caps and the priority class of every service into a values dictionary,
replacing whatever was there. Used both when an instance is created and when a running one is
brought up to the current caps."""
dep = values.setdefault("deployment", {})
for svc in SERVICES:
req, lim = MEMORY.get(svc, DEFAULT_MEMORY)
d = dep.setdefault(svc, {})
d["resources"] = {"requests": {"memory": f"{req}Mi"}, "limits": {"memory": f"{lim}Mi"}}
d["priorityClassName"] = PRIORITY_CLASS
return values
def set_replicas(values, count):
"""How many copies of each service to run. Zero stops the instance: the pods go away and give
back their memory, their processor time, their pod slots and the two ports they hold on the
server, while the disks, the passwords, the certificate and the hostname stay exactly as they
are. One starts it again."""
dep = values.setdefault("deployment", {})
for svc in SERVICES:
dep.setdefault(svc, {})["replicaCount"] = count
return values
def is_suspended(values):
"""True when the stored settings say to run no copy of any service."""
dep = values.get("deployment") or {}
counts = [(dep.get(svc) or {}).get("replicaCount", 1) for svc in SERVICES]
return all(c == 0 for c in counts)
def memory_limits(values):
"""{service: limit} as the values dictionary has it, for telling before from after."""
dep = values.get("deployment") or {}
out = {}
for svc in SERVICES:
lim = ((dep.get(svc) or {}).get("resources") or {}).get("limits") or {}
out[svc] = lim.get("memory")
return out
def total_memory_mib():
"""(requests, limits) in MiB, summed over all services."""
req = sum(MEMORY.get(s, DEFAULT_MEMORY)[0] for s in SERVICES)
lim = sum(MEMORY.get(s, DEFAULT_MEMORY)[1] for s in SERVICES)
return req, lim
def render_env(cfg, n, sec, mail):
host = cfg["slots"][n]
url = f"https://{host}"
lan = cfg["lan_ip"]
pw = sec["DB_PASSWORD"]
lines = [
f"# supaspin instance {n} - {url}",
"# Reach Postgres and Studio from outside the LAN through an SSH tunnel:",
f"# {tunnel_command(cfg, n)}",
"",
f"SUPABASE_URL={url}", f"SUPABASE_PUBLIC_URL={url}",
f"PUBLIC_SUPABASE_URL={url}", f"API_EXTERNAL_URL={url}",
"",
f"PUBLIC_SUPABASE_ANON_KEY={sec['ANON_KEY']}", f"ANON_KEY={sec['ANON_KEY']}",
f"JWT_ANON_KEY={sec['ANON_KEY']}",
f"SERVICE_ROLE_KEY={sec['SERVICE_KEY']}", f"JWT_SERVICE_KEY={sec['SERVICE_KEY']}",
f"JWT_SECRET={sec['JWT_SECRET']}", "JWT_EXPIRY=3600",
"",
f"POSTGRES_PASSWORD={pw}", "POSTGRES_DB=postgres", "POSTGRES_HOST=localhost",
f"POSTGRES_PORT={pg_port(n)}",
f"# On the LAN use {lan}:{pg_port(n)} instead of localhost.",
f"DATABASE_URL=postgresql://postgres:{pw}@localhost:{pg_port(n)}/postgres",
"",
f"DASHBOARD_USERNAME={sec['DASHBOARD_USERNAME']}",
f"DASHBOARD_PASSWORD={sec['DASHBOARD_PASSWORD']}",
"",
f"GOTRUE_SMTP_HOST={mail['host']}", f"GOTRUE_SMTP_PORT={mail['port']}",
f"GOTRUE_SMTP_USER={mail['user']}", f"GOTRUE_SMTP_PASS={mail['password']}",
f"GOTRUE_SMTP_ADMIN_EMAIL={mail['admin_email']}",
f"GOTRUE_SMTP_SENDER_NAME={mail['sender_name']}",
"",
]
return "\n".join(lines)
SECRET_KEYS = ("KEY", "SECRET", "PASSWORD", "DATABASE_URL", "PASS")
def mask_env(text):
out = []
for line in text.splitlines():
if "=" in line and not line.startswith("#"):
k, _, v = line.partition("=")
if any(s in k for s in SECRET_KEYS) and v:
line = f"{k}=********"
out.append(line)
return "\n".join(out)
def tunnel_command(cfg, n):
lan = cfg["lan_ip"]
return (f"ssh -N -p {cfg['ssh_port']} -L {pg_port(n)}:{lan}:{pg_port(n)} "
f"-L {studio_port(n)}:{lan}:{studio_port(n)} {cfg['ssh_user']}@{cfg['ssh_host']}")
def tunnel_socket(n):
"""The 'control file' of a background tunnel: ssh is later told to quit through it."""
return f"/tmp/supaspin-{n}.sock"
def tunnel_background_command(cfg, n):
lan = cfg["lan_ip"]
return (f"ssh -f -N -M -S {tunnel_socket(n)} -o ExitOnForwardFailure=yes -p {cfg['ssh_port']} "
f"-L {pg_port(n)}:{lan}:{pg_port(n)} -L {studio_port(n)}:{lan}:{studio_port(n)} "
f"{cfg['ssh_user']}@{cfg['ssh_host']}")
def tunnel_control_command(cfg, n, action):
"""`check` asks the background tunnel whether it runs, `exit` closes it."""
return f"ssh -S {tunnel_socket(n)} -O {action} {cfg['ssh_user']}@{cfg['ssh_host']}"
def tunnel_guide(cfg, n, detailed=False):
"""Plain-language tunnel instructions for instance n, as a list of lines."""
if not cfg.get("ssh_host"):
return ["Tunnel not available: run 'sudo supaspin setup' and fill in 'Public SSH host for tunnels'."]
lines = [
"Tunnel = a private connection from YOUR computer to this instance, so you can reach its",
f"database (localhost:{pg_port(n)}) and Studio admin screen (http://localhost:{studio_port(n)}) from your computer.",
"Run the commands below in a terminal on YOUR computer, not on this server.",
"",
"IMPORTANT - which computer you use matters:",
" Mac or Linux: all the commands below work.",
" Windows: ONLY the first command below (keep the tunnel open in the window) works. The background",
" and closing commands do NOT work on Windows, because the ssh program that comes with Windows",
' does not support the "control file" they need. On Windows keep the tunnel open in its own',
" PowerShell window and close it with Ctrl+C, or by closing that window.",
"",
"If you want to open the tunnel and keep it open in the terminal window you are using, run:",
f" {tunnel_command(cfg, n)}",
"The window then looks stuck and prints nothing - that is normal, it means the tunnel is open.",
"To close it, press Ctrl+C in that window.",
"",
"If you want the tunnel to keep running in the background, so your terminal stays free (Mac or Linux only), run:",
f" {tunnel_background_command(cfg, n)}",
"If it asks for your password, type it. Then you get your terminal back and the tunnel stays open.",
"",
"If you no longer need the background tunnel and want to close it (Mac or Linux only), run:",
f" {tunnel_control_command(cfg, n, 'exit')}",
'It answers "Exit request sent." and the tunnel is closed.',
]
if not detailed:
return lines + ["", f"More help (is the tunnel running? what if closing fails?): sudo supaspin tunnel {n}"]
return lines + [
"",
"If you want to check whether the background tunnel is running (Mac or Linux only), run:",
f" {tunnel_control_command(cfg, n, 'check')}",
'It answers "Master running" when the tunnel is open.',
"",
'If closing says "No such file or directory" but the tunnel still works, or starting a tunnel says',
'"address already in use" and the check above does not say "Master running", a leftover tunnel is',
"in the way. To stop it (Mac or Linux only), run:",
f' pkill -f "[s]upaspin-{n}.sock"',
"(The [s] is on purpose: it stops the command from also matching, and stopping, itself.)",
"Then start the tunnel again.",
]
def tunnel_help(cfg, n, pw="<password>"):
return "\n".join(tunnel_guide(cfg, n, detailed=True) + [
"",
"When the tunnel is open, put this in your app's .env:",
f" DATABASE_URL=postgresql://postgres:{pw}@localhost:{pg_port(n)}/postgres",
" (the password is POSTGRES_PASSWORD in the instance's .env: sudo supaspin env " + str(n) + ")",
f"Studio (admin UI): http://localhost:{studio_port(n)}",
f"On the server's own network you do not need a tunnel: use {cfg['lan_ip']}:{pg_port(n)} "
f"and http://{cfg['lan_ip']}:{studio_port(n)}",
])
def fstab_line(img, mount, root):
return f"{img} {mount} ext4 loop,nofail,x-systemd.requires-mounts-for={root} 0 0"
def fstab_without(text, mount):
"""fstab text with every line whose mount point is `mount` removed."""
keep = []
for line in text.splitlines():
f = line.split()
if len(f) >= 2 and not line.lstrip().startswith("#") and f[1] == mount:
continue
keep.append(line)
return "\n".join(keep) + "\n"
def fstab_mounts_under(text, prefix):
"""Mount points in fstab that lie below `prefix` (a folder), in file order."""
out = []
for line in text.splitlines():
f = line.split()
if len(f) >= 2 and not line.lstrip().startswith("#") and f[1].startswith(prefix.rstrip("/") + "/"):
out.append(f[1])
return out
def fstab_has(text, mount):
return any(len(l.split()) >= 2 and not l.lstrip().startswith("#") and l.split()[1] == mount
for l in text.splitlines())
class _Literal(str):
pass
def _literal_repr(dumper, data):
return dumper.represent_scalar("tag:yaml.org,2002:str", data, style="|")
yaml.SafeDumper.add_representer(_Literal, _literal_repr)
def strip_kong_services(text, names):
"""Cut whole `- name: X` service blocks out of kong.yml, leaving every other byte as it was.
Kong's start script substitutes placeholders inside the quoted strings of this file, so it
must not be parsed and re-written: a changed quoting style breaks the substitution.
"""
out, skipping = [], False
for line in text.splitlines(keepends=True):
m = re.match(r"^ - name: (\S+)\s*$", line)
if m:
skipping = m.group(1) in names
elif skipping and line.strip() and not line.startswith(" "):
skipping = False
if not skipping:
out.append(line)
return "".join(out)
def post_render(text, n, cfg):
"""Edit the rendered chart: fixed host ports on db and studio, no public Studio/pg-meta route."""
bind_ip = host_bind_ip(cfg)
docs = [d for d in yaml.safe_load_all(text) if d]
for d in docs:
kind, name = d.get("kind"), d.get("metadata", {}).get("name", "")
if kind == "StatefulSet" and name == f"{fullname(n)}-db":
_host_port(d, pg_port(n), bind_ip)
elif kind == "Deployment" and name == f"{fullname(n)}-studio":
_host_port(d, studio_port(n), bind_ip)
elif kind == "ConfigMap" and "kong.yml" in (d.get("data") or {}):
d["data"]["kong.yml"] = strip_kong_services(d["data"]["kong.yml"], ("meta", "dashboard"))
for d in docs: # keep multi-line scripts readable in the release manifest
for k, v in (d.get("data") or {}).items():
if isinstance(v, str) and "\n" in v:
d["data"][k] = _Literal(v)
return yaml.safe_dump_all(docs, sort_keys=False, default_flow_style=False)
def host_bind_ip(cfg):
"""Address the Postgres/Studio host ports listen on. `lan` (the default) is the LAN IP
only; on a machine with a public interface that keeps Postgres and the login-less Studio
off the internet. `all` is every interface - LAN and localhost - for a box behind NAT
that runs its own apps talking to localhost. `local` is localhost only."""
return {"lan": cfg["lan_ip"], "local": "127.0.0.1", "all": None}[cfg.get("port_bind", "lan")]
def _host_port(doc, port, ip):
c = doc["spec"]["template"]["spec"]["containers"][0]
p = c["ports"][0]
p["hostPort"] = port
if ip is not None:
# None = bind every interface (the `all` mode); omitting hostIP makes the kubelet
# listen on 0.0.0.0, which a hostIP cannot express.
p["hostIP"] = ip
if doc.get("kind") == "Deployment":
# A port on the server itself can be held by one pod at a time. The default rollout starts
# the replacement pod before stopping the old one, so the replacement never gets the port
# and waits for ever ("didn't have free ports for the requested pod ports"). Recreate stops
# the old pod first. A StatefulSet already replaces its pod that way.
doc["spec"]["strategy"] = {"type": "Recreate"}
def pv_manifest(cfg, n, vol):
return {
"apiVersion": "v1", "kind": "PersistentVolume",
"metadata": {"name": f"supaspin-{n}-{vol}",
"labels": {"supaspin/instance": str(n)}},
"spec": {
"capacity": {"storage": f"{VOLUMES[vol]}Mi"},
"accessModes": ["ReadWriteOnce"],
"persistentVolumeReclaimPolicy": "Retain",
"storageClassName": STORAGE_CLASS,
"claimRef": {"namespace": namespace(n), "name": pvc_name(n, vol)},
"local": {"path": f"{cfg['storage_root']}/{n}/{vol}/data"},
"nodeAffinity": {"required": {"nodeSelectorTerms": [{"matchExpressions": [
{"key": "kubernetes.io/hostname", "operator": "In",
"values": [cfg["node_name"]]}]}]}},
},
}
# ------------------------------------------------------------------ system --
def run(args, input_=None, check=True):
p = subprocess.run(args, input=input_, capture_output=True, text=True)
if check and p.returncode != 0:
raise RuntimeError(f"{' '.join(args)}\n{(p.stderr or p.stdout).strip()}")
return p
def tool(name):
"""kubectl / helm: the microk8s snap commands when present, else plain ones."""
snap = {"kubectl": "/snap/bin/microk8s.kubectl", "helm": "/snap/bin/microk8s.helm3"}[name]
return snap if os.path.exists(snap) else (shutil.which(name) or name)
def kubectl(*args, input_=None, check=True):
return run([tool("kubectl"), *args], input_=input_, check=check)
def helm(*args, check=True):
return run([tool("helm"), *args], check=check)
def kjson(*args):
p = kubectl(*args, "-o", "json", check=False)
return json.loads(p.stdout) if p.returncode == 0 and p.stdout.strip() else None
def apply(manifests):
docs = manifests if isinstance(manifests, list) else [manifests]
kubectl("apply", "-f", "-", input_=yaml.safe_dump_all(docs))
def put_secret(manifest):
"""Create a Secret, replacing any old one. Not `kubectl apply`: apply stores a full copy of the
secret (private key included) in a last-applied annotation."""
kubectl("delete", "secret", manifest["metadata"]["name"], "-n", manifest["metadata"]["namespace"],
"--ignore-not-found")
kubectl("create", "-f", "-", input_=yaml.safe_dump(manifest))
def load_config():
try:
with open(CONFIG_PATH) as f:
cfg = yaml.safe_load(f) or {}
except FileNotFoundError:
return None
cfg["slots"] = {int(k): v for k, v in cfg.get("slots", {}).items()}
return cfg
def save_config(cfg):
os.makedirs(os.path.dirname(CONFIG_PATH), exist_ok=True)
data = dict(cfg)
data["slots"] = {int(k): v for k, v in cfg["slots"].items()}
tmp = CONFIG_PATH + ".tmp"
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w") as f:
yaml.safe_dump(data, f, sort_keys=False)
os.replace(tmp, CONFIG_PATH)
def mounts():
with open("/proc/mounts") as f:
return [l.split()[1] for l in f]
def is_mounted(path):
return os.path.realpath(path) in mounts()
def inst_dir(cfg, n):
return f"{cfg['storage_root']}/{n}"
def vol_paths(cfg, n, vol):
d = f"{inst_dir(cfg, n)}/{vol}"
return f"{d}/{vol}.img", f"{d}/data"
def existing_numbers(cfg):
"""Instances that have any trace: folder, namespace or fstab line."""
found = set()
root = cfg["storage_root"]
if os.path.isdir(root):
found |= {int(e) for e in os.listdir(root) if e.isdigit()}
ns = kjson("get", "ns")
for item in (ns or {}).get("items", []):
m = re.fullmatch(r"supaspin-(\d+)", item["metadata"]["name"])
if m:
found.add(int(m.group(1)))
try:
with open("/etc/fstab") as f:
for m in re.finditer(re.escape(root) + r"/(\d+)/", f.read()):
found.add(int(m.group(1)))
except FileNotFoundError:
pass
return found
# --------------------------------------------------------------- volumes ---
def provision_volume(cfg, n, vol, log):
img, mnt = vol_paths(cfg, n, vol)
root = cfg["storage_root"]
os.makedirs(mnt, exist_ok=True)
if not is_mounted(mnt):
# After a reboot Kubernetes can start a pod before the image is mounted. Nothing may be
# written into the empty folder meanwhile (Postgres would set up a fresh database there),
# so it is made read-only until the image is mounted over it. Best effort.
run(["chattr", "+i", mnt], check=False)
fresh = not os.path.exists(img)
if fresh:
mib = VOLUMES[vol]
if run(["fallocate", "-l", f"{mib}M", img], check=False).returncode != 0:
run(["truncate", "-s", f"{mib}M", img])
run(["/sbin/mkfs.ext4", "-F", "-m", "0", "-L", f"supa{n}-{vol}", img])
with open("/etc/fstab") as f:
fstab = f.read()
if not fstab_has(fstab, mnt):
with open("/etc/fstab", "a") as f:
if not fstab.endswith("\n"):
f.write("\n")
f.write(fstab_line(img, mnt, root) + "\n")
run(["systemctl", "daemon-reload"])
if not is_mounted(mnt):
run(["mount", mnt])
# ext4 puts an empty lost+found in every new filesystem. The chart copies its default Postgres
# config into the pgsodium volume only when that volume is empty, so the folder must go.
lost = os.path.join(mnt, "lost+found")
if os.path.isdir(lost) and not os.listdir(lost):
os.rmdir(lost)
if vol in DB_OWNED:
os.chown(mnt, DB_UID, DB_GID)
os.chmod(mnt, 0o770)
else:
os.chmod(mnt, 0o777)
log(f" volume {vol}: {VOLUMES[vol]} MiB mounted at {mnt}")
def instance_mounts(cfg, n):
"""Every mount point of the instance found on the system (mounted now or listed in fstab),
not just the volumes this version creates: an instance made by an older version may have others."""
d = inst_dir(cfg, n)
found = {m for m in mounts() if m.startswith(d + "/")}
try:
with open("/etc/fstab") as f:
found |= set(fstab_mounts_under(f.read(), d))
except FileNotFoundError:
pass
return sorted(found)
def remove_mount(mnt, log):
if is_mounted(mnt):
r = run(["umount", mnt], check=False)
if r.returncode != 0:
raise RuntimeError(f"cannot unmount {mnt}: {r.stderr.strip()}")
with open("/etc/fstab") as f:
fstab = f.read()
if fstab_has(fstab, mnt):
with open("/etc/fstab", "w") as f:
f.write(fstab_without(fstab, mnt))
run(["chattr", "-i", mnt], check=False)
log(f" unmounted {mnt}, fstab line removed")
# ------------------------------------------------------------- operations --
def ensure_cluster_objects(cfg, log):
docs = [
{"apiVersion": "storage.k8s.io/v1", "kind": "StorageClass",
"metadata": {"name": STORAGE_CLASS}, "provisioner": "kubernetes.io/no-provisioner",
"reclaimPolicy": "Retain", "volumeBindingMode": "WaitForFirstConsumer"},
{"apiVersion": "scheduling.k8s.io/v1", "kind": "PriorityClass",
"metadata": {"name": PRIORITY_CLASS}, "value": -100,
"preemptionPolicy": "Never", "globalDefault": False,
"description": "supaspin test instances: evicted first, never evict others"},
{"apiVersion": "v1", "kind": "Namespace", "metadata": {"name": CERTS_NS}},
]
if cfg.get("lets_encrypt", True):
# cert-manager needs this object to know where to order certificates from; without
# Let's Encrypt it is dead weight, and cert-manager may not even be installed.
docs.insert(1, {"apiVersion": "cert-manager.io/v1", "kind": "ClusterIssuer",
"metadata": {"name": ISSUER},
"spec": {"acme": {"server": "https://acme-v02.api.letsencrypt.org/directory",
"email": cfg["acme_email"],
"privateKeySecretRef": {"name": ISSUER + "-account-key"},
"solvers": [{"http01": {"ingress": {"class": cfg["ingress_class"]}}}]}}})
for d in docs:
# Create only what is missing; never overwrite something already there.
r = kubectl("get", d["kind"].lower(), d["metadata"]["name"], check=False)
if r.returncode != 0:
apply(d)
log(f" created {d['kind']} {d['metadata']['name']}")
chart_dir = f"{STATE_DIR}/charts"
os.makedirs(chart_dir, exist_ok=True)
tgz = f"{chart_dir}/{CHART_NAME}-{cfg['chart_version']}.tgz"
if not os.path.exists(tgz):
log(f" downloading chart {cfg['chart_version']}")
for attempt in (1, 2, 3): # the chart host times out now and then
try:
helm("pull", CHART_NAME, "--repo", CHART_REPO, "--version", cfg["chart_version"],
"--destination", chart_dir)
break
except RuntimeError:
if attempt == 3:
raise
log(f" download failed, retrying ({attempt}/3)")
time.sleep(5)
def chart_path(cfg):
return f"{STATE_DIR}/charts/{CHART_NAME}-{cfg['chart_version']}.tgz"
def carry_secret(src, ns, name):
"""Copy of a TLS secret for another namespace: data and type, plus the cert-manager.io
annotations. Without those annotations cert-manager cannot tell which issuer signed the
certificate, decides it is wrong ("IncorrectIssuer") and orders a new one from Let's Encrypt."""
keep = {k: v for k, v in (src.get("metadata", {}).get("annotations") or {}).items()
if k.startswith("cert-manager.io/")}
meta = {"name": name, "namespace": ns}
if keep:
meta["annotations"] = keep
return {"apiVersion": "v1", "kind": "Secret",
"type": src.get("type", "kubernetes.io/tls"), "metadata": meta, "data": src["data"]}
def restore_cert(n, log):
r = kubectl("get", "secret", tls_secret(n), "-n", CERTS_NS, "-o", "json", check=False)
if r.returncode != 0:
return
put_secret(carry_secret(json.loads(r.stdout), namespace(n), tls_secret(n)))
kubectl("delete", "secret", tls_secret(n), "-n", CERTS_NS, "--ignore-not-found")
log(" restored saved TLS certificate")
def save_cert(n, log):
r = kubectl("get", "secret", tls_secret(n), "-n", namespace(n), "-o", "json", check=False)
if r.returncode != 0:
return
s = json.loads(r.stdout)
if not s.get("data", {}).get("tls.crt"):
return
put_secret(carry_secret(s, CERTS_NS, tls_secret(n)))
log(" kept TLS certificate in namespace " + CERTS_NS)
def pod_summary(n):
"""(ready_count, total, problems) over pods that are meant to keep running."""
# Only the release's own pods: cert-manager parks a short-lived helper pod in the namespace
# while it proves domain ownership to Let's Encrypt.
data = kjson("get", "pods", "-n", namespace(n), "-l", f"app.kubernetes.io/instance={release(n)}") \
or {"items": []}
ready = total = 0
problems = []
for p in data["items"]:
if p["status"].get("phase") == "Succeeded":
continue
total += 1
cs = p["status"].get("containerStatuses", [])
if p["status"].get("phase") == "Running" and cs and all(c.get("ready") for c in cs):
ready += 1
else:
waiting = next((c["state"]["waiting"].get("reason") for c in cs
if "waiting" in c.get("state", {})), p["status"].get("phase"))
problems.append(f"{p['metadata']['name'].replace(fullname(n) + '-', '')}={waiting}")
return ready, total, problems
def wait_ready(n, log, timeout=1200):
end = time.time() + timeout
expected = len(SERVICES)
while time.time() < end:
ready, total, problems = pod_summary(n)
log(f" pods ready {ready}/{expected}" + (f" waiting: {', '.join(problems)}" if problems else ""),
live=True)
if ready >= expected and total == ready:
log("")
return
time.sleep(5)
raise RuntimeError("pods did not become ready in time")
def setup_log_retention(cfg, n, log):
"""Give the instance a pg_cron job that deletes analytics logs older than LOG_RETENTION every
night. Logflare stores the logs it receives in the _supabase database and never deletes them;
without this job the capped db image fills up and the database dies (instance 3 on
2026-10-06). The job runs in the postgres database, the only one pg_cron accepts in this
image, and deletes in _supabase over a local dblink connection. Runs the first cleanup right
away, so an upgrade also frees a full disk. Runs after create, upgrade and resume;
scheduling under a fixed job name makes it idempotent."""
log(f" log retention: deleting analytics logs older than {LOG_RETENTION}, every night")
kubectl("-n", namespace(n), "exec", f"{fullname(n)}-db-0", "-c", "supabase-db", "--",
"psql", "-U", "supabase_admin", "-d", "postgres", "-v", "ON_ERROR_STOP=1",
"-c", RETENTION_SQL)
log(" log retention is set")
def middleware_manifest(n):
"""The Traefik request-size cap the ingress annotation of instance n points at. nginx gets
the same cap from proxy-body-size; Traefik has no such annotation, the rule lives here."""
return {"apiVersion": "traefik.io/v1alpha1", "kind": "Middleware",
"metadata": {"name": buffering_middleware(n), "namespace": namespace(n)},
"spec": {"buffering": {"maxRequestBodyBytes": 50 * 1024 * 1024}}} # 50 MiB
def ensure_middleware(cfg, n, log):
"""Create the Middleware for instance n on Traefik. On nginx the annotation is not
emitted, and applying a traefik.io object would fail there - the CRD is not installed."""
if cfg.get("ingress_controller", "nginx") != "traefik":
return
apply(middleware_manifest(n))
log(f" created Middleware {buffering_middleware(n)} (50 MiB request cap)")
def ensure_self_signed_cert(cfg, n, host, log):
"""A self-signed certificate for the hostname, so https answers without Let's Encrypt.
Skipped when a certificate is already in place (restored from supaspin-certs)."""
r = kubectl("get", "secret", tls_secret(n), "-n", namespace(n), check=False)
if r.returncode == 0:
return
with tempfile.TemporaryDirectory(prefix="supaspin-cert-") as d:
key, crt = f"{d}/tls.key", f"{d}/tls.crt"
run(["openssl", "req", "-x509", "-newkey", "rsa:2048", "-sha256", "-nodes",
"-keyout", key, "-out", crt, "-days", "3650",
"-subj", f"/CN={host}", "-addext", f"subjectAltName=DNS:{host}"])
kubectl("create", "secret", "tls", tls_secret(n), "-n", namespace(n),
"--cert", crt, "--key", key)
log(f" self-signed certificate for {host} (valid 10 years);"
" clients must trust it or ignore the warning")
def create_instance(cfg, mail, log, number=None):
taken = existing_numbers(cfg)
n = number if number is not None else lowest_free_number(cfg["slots"], taken)
if n is None:
raise RuntimeError("no free hostname slot: delete an instance or add slots in Settings")
if n in taken or n not in cfg["slots"]:
raise RuntimeError(f"instance {n} is not available")
host = cfg["slots"][n]
log(f"Creating instance {n} ({host})")
ensure_cluster_objects(cfg, log)
try:
os.makedirs(inst_dir(cfg, n), exist_ok=True)
for vol in VOLUMES:
provision_volume(cfg, n, vol, log)
kubectl("create", "namespace", namespace(n))
apply([pv_manifest(cfg, n, v) for v in VOLUMES])
restore_cert(n, log)
if not cfg.get("lets_encrypt", True):
ensure_self_signed_cert(cfg, n, host, log)
sec = generate_secrets()
with open(find_template()) as f:
values = instance_values(cfg, n, sec, mail, f.read())
vfile = write_values_file(n, values)
ensure_middleware(cfg, n, log)
log(" installing chart")
try:
helm("install", release(n), chart_path(cfg), "-n", namespace(n), "-f", vfile,
*post_render_args(n))
finally:
os.remove(vfile) # holds every secret
wait_ready(n, log)
setup_log_retention(cfg, n, log)
env_path = f"{inst_dir(cfg, n)}/.env"
fd = os.open(env_path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w") as f:
f.write(render_env(cfg, n, sec, mail))
log(f"Instance {n} is ready: https://{host}\n.env written to {env_path}")
return n
except Exception:
log(f"{RED}Create failed.{OFF}")
if sys.stdin.isatty() and ask(f"Delete the partial instance {n} now? (Y/n)", "y").lower().startswith("y"):
delete_instance(cfg, n, log)
else:
log(f"Run 'supaspin delete {n}' to clean up the partial instance.")
raise
def write_values_file(n, values):
"""The values Helm is given. It carries every secret of the instance, so it is written
readable by root only and the caller deletes it as soon as Helm has read it."""
vfile = f"{STATE_DIR}/values-{n}.yaml"
os.makedirs(STATE_DIR, exist_ok=True)
fd = os.open(vfile, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w") as f:
yaml.safe_dump(values, f, sort_keys=False)
return vfile
def post_render_args(n):
"""Helm hands the finished manifests to supaspin itself, which fixes the parts the chart
cannot express (the Postgres and Studio host ports, the gateway routes that are not used)."""
return ["--post-renderer", os.path.realpath(sys.argv[0]),
"--post-renderer-args", "post-render", "--post-renderer-args", str(n)]
def stored_values(cfg, n):
"""The settings Helm kept from the install. They carry every secret of the instance, which is
why supaspin deletes its own copy of them as soon as Helm has read it."""
ns, rel = namespace(n), release(n)
r = helm("get", "values", rel, "-n", ns, "-o", "yaml", check=False)
if r.returncode != 0:
raise RuntimeError(f"instance {n}: no Helm release {rel} in namespace {ns}\n{r.stderr.strip()}")
values = yaml.safe_load(r.stdout) or {}
if not values.get("deployment"):
raise RuntimeError(f"instance {n}: the stored settings of {rel} look empty; not touching it")
return values
def apply_values(cfg, n, values):
"""Hand the settings to Helm, which works out what to change and changes only that."""
vfile = write_values_file(n, values)
try:
helm("upgrade", release(n), chart_path(cfg), "-n", namespace(n), "-f", vfile,
*post_render_args(n))
finally:
os.remove(vfile) # holds every secret
def wait_gone(n, log, timeout=300):
"""Wait until the instance's own pods have stopped."""
end = time.time() + timeout
while time.time() < end:
_, total, _ = pod_summary(n)
log(f" pods still running: {total}", live=True)
if total == 0:
log("")
return
time.sleep(3)
raise RuntimeError("the pods did not stop in time")
def suspend_instance(cfg, n, log):
"""Stop every pod of an instance and keep everything else. The database, the uploads, the
passwords in .env, the certificate and the hostname are untouched; the instance answers
nothing until it is resumed."""
log(f"Suspending instance {n} ({cfg['slots'].get(n, '?')})")
values = stored_values(cfg, n)
if is_suspended(values):
log(" already suspended")
return False
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(" start it again in this instance's menu: \"Resume it\"")
return True
def resume_instance(cfg, n, log):
"""Start a suspended instance again, with the data it had when it was suspended."""
log(f"Resuming instance {n} ({cfg['slots'].get(n, '?')})")
values = stored_values(cfg, n)
if not is_suspended(values):
log(" already running")
return False
apply_values(cfg, n, set_replicas(values, 1))
log(" the database answers first; the services that need it retry until it does")
wait_ready(n, log)
setup_log_retention(cfg, n, log)
log(f" instance {n} is running again: https://{cfg['slots'].get(n, '?')}")
return True
def upgrade_instance(cfg, n, log):
"""Bring a running instance up to the memory caps this version of supaspin carries, without
deleting anything. The values Helm kept from the install are read back, the memory block in
them is replaced, and the chart is applied again: the pods are replaced one service at a time
and the database, the uploads and the .env file are untouched."""
log(f"Instance {n} ({cfg['slots'].get(n, '?')})")
values = stored_values(cfg, n)
asleep = is_suspended(values)
before = memory_limits(values)
after = memory_limits(apply_memory(values))
changed = [svc for svc in SERVICES if before[svc] != after[svc]]
for svc in changed:
log(f" {svc}: {before[svc] or 'unset'} -> {after[svc]}")
if not changed:
# The chart is applied even so: the caps are not the only thing this version may produce
# differently (the Studio rollout, the host ports, the gateway routes are all decided
# while the manifests are rendered). Helm changes nothing if nothing differs.
log(" caps already current; re-applying the chart in case anything else differs")
apply_values(cfg, n, values)
if asleep:
# A suspended instance stays suspended: the new caps are stored and take effect the moment
# it is resumed. Waiting for its pods would wait for ever - by design it has none.
log(f" instance {n} is suspended; the new caps and log retention apply when you resume it")
return bool(changed)
wait_ready(n, log)
setup_log_retention(cfg, n, log)
log(f" instance {n} is running with the current settings")
return bool(changed)
def upgrade_all(cfg, log, numbers=None):
"""Upgrade one instance, or every instance that exists."""
nums = [numbers] if isinstance(numbers, int) else sorted(numbers or existing_numbers(cfg))
if not nums:
log("No instances.")
return
req, lim = total_memory_mib()
log(f"Caps in this version: {req} MiB reserved and up to {lim} MiB per instance.")
failed = []
for n in nums:
try:
upgrade_instance(cfg, n, log)
except RuntimeError as e:
failed.append(n)
log(f"{RED} instance {n} failed: {e}{OFF}")
if failed:
raise RuntimeError("these instances were not upgraded: " + ", ".join(str(n) for n in failed))
def delete_instance(cfg, n, log):
log(f"Deleting instance {n}")
save_cert(n, log)
helm("uninstall", release(n), "-n", namespace(n), check=False)
kubectl("delete", "namespace", namespace(n), "--ignore-not-found", "--wait=true")
kubectl("delete", "pv", "-l", f"supaspin/instance={n}", "--ignore-not-found")
for mnt in instance_mounts(cfg, n):
remove_mount(mnt, log)
run(["systemctl", "daemon-reload"])
d = inst_dir(cfg, n)
for mnt in glob.glob(f"{d}/*/data"): # also the folders of a create that stopped before mounting
if not is_mounted(mnt):
run(["chattr", "-i", mnt], check=False)
if os.path.isdir(d):
busy = [m for m in mounts() if m == d or m.startswith(d + "/")]
if busy:
raise RuntimeError(f"refusing to delete {d}: still mounted: {', '.join(busy)}")
shutil.rmtree(d)
log(f" removed {d}")
log(f"Instance {n} deleted.")
def read_env(cfg, n):
path = f"{inst_dir(cfg, n)}/.env"
with open(path) as f:
return f.read()
def memory_use(n):
"""Memory the instance's pods use right now, read from the kernel's per-pod counters
(the cluster has no metrics server, so `kubectl top` does not work)."""
pods = kjson("get", "pods", "-n", namespace(n)) or {"items": []}
total, found = 0, False
for p in pods["items"]:
uid = p["metadata"]["uid"]
for pattern in (f"/sys/fs/cgroup/kubepods/*/pod{uid}/memory.current",
f"/sys/fs/cgroup/kubepods/pod{uid}/memory.current",
f"/sys/fs/cgroup/kubepods.slice/*/kubepods-*-pod{uid.replace('-', '_')}.slice/memory.current"):
hits = glob.glob(pattern)
if hits:
with open(hits[0]) as f:
total += int(f.read()) // 2**20
found = True
break
if not found:
return "n/a"
return f"{total}/{total_memory_mib()[1]} MiB"
def disk_use(cfg, n):
parts = []
d = inst_dir(cfg, n)
names = sorted({os.path.basename(os.path.dirname(m)) for m in instance_mounts(cfg, n)} | set(VOLUMES))
for vol in names:
mnt = f"{d}/{vol}/data"
if is_mounted(mnt):
u = shutil.disk_usage(mnt)
parts.append(f"{vol} {u.used // 2**20}/{u.total // 2**20}M")
else:
parts.append(f"{vol} -")
return ", ".join(parts)
def instance_state(n):
"""What the cluster is currently asked to run for this instance: "running", "suspended",
"partly suspended" (some services stopped, which should not happen on its own) or "unknown"
when the instance has no workloads at all."""
want = []
for kind in ("deployments", "statefulsets"):
data = kjson("get", kind, "-n", namespace(n), "-l", f"app.kubernetes.io/instance={release(n)}")
for item in (data or {}).get("items", []):
want.append(item["spec"].get("replicas", 1))
if not want:
return "unknown"
if all(w == 0 for w in want):
return "suspended"
return "partly suspended" if any(w == 0 for w in want) else "running"
def list_rows(cfg):
rows = []
for n in sorted(existing_numbers(cfg)):
ready, total, _ = pod_summary(n)
env = f"{inst_dir(cfg, n)}/.env"
rows.append({"n": n, "host": cfg["slots"].get(n, "?"), "pods": f"{ready}/{total}",
"state": instance_state(n),
"memory": memory_use(n), "disk": disk_use(cfg, n),
"env": env if os.path.exists(env) else "(none)"})
return rows
# ------------------------------------------------------ copy .env values --
_ASSIGN = re.compile(r"^([ \t]*(?:export[ \t]+)?)([A-Za-z_][A-Za-z0-9_.\-]*)([ \t]*=[ \t]*)(.*)$")
_PAIR = re.compile(r"^[ \t]*(?:export[ \t]+)?([A-Za-z_][A-Za-z0-9_.\-]*)[ \t]*=(.*)$")
_REMOTE = re.compile(r"^(?:([^@:/\s]+)@)?(\[[^\]\s]+\]|[^@:/\s]+):(.+)$")
_WINDOWS_PATH = re.compile(r"^[A-Za-z]:[\\/]")
SSH_ON = [
" - Mac: System Settings > General > Sharing > turn on 'Remote Login'.",
" - Linux: run sudo apt install openssh-server and then sudo systemctl enable --now ssh",
" - Windows: Settings > Apps > Optional features > add 'OpenSSH Server', then in an Administrator",
" PowerShell run: Start-Service sshd; Set-Service sshd -StartupType Automatic",
]
class TargetError(Exception):
"""The target file could not be reached or changed. `why` says what happened, `advice` how to fix it."""
def __init__(self, why, advice=()):
super().__init__(why)
self.why = why
self.advice = list(advice)
class EnvLine:
"""One logical line of a .env file: a comment, a blank, an assignment (with the following lines of
a multi-line quoted value) or anything else, which is kept as it is."""
__slots__ = ("kind", "text", "eol", "head", "key", "value", "more")
def __init__(self, kind, text, eol, head="", key="", value=""):
self.kind, self.text, self.eol = kind, text, eol
self.head, self.key, self.value = head, key, value
self.more = [] # (text, line ending) of the lines that continue a multi-line value
def raw(self):
return self.text + self.eol + "".join(t + e for t, e in self.more)
def _text(data):
"""A .env file's bytes as text (odd bytes are kept as they are), without the byte-order mark."""
text = data.decode("utf-8", "surrogateescape")
return text[1:] if text.startswith("\ufeff") else text
def _split_lines(text):
"""(content, line ending) per line; the ending is '' only for a last line that has none."""
parts = text.split("\n")
out = []
for i, part in enumerate(parts):
if i == len(parts) - 1:
if part:
out.append((part, ""))
elif part.endswith("\r"):
out.append((part[:-1], "\r\n"))
else:
out.append((part, "\n"))
return out
def _closes(rest, quote):
"""True if `rest` (the text after an opening quote) contains the matching closing quote."""
i = 0
while i < len(rest):
if rest[i] == "\\" and quote != "`":
i += 2
continue
if rest[i] == quote:
return True
i += 1
return False
def scan_env(text):
"""Split .env text into EnvLine objects. A line whose first character (after spaces) is # is a comment
and is never taken for a key."""
lines = _split_lines(text)
out, i = [], 0
while i < len(lines):
content, eol = lines[i]
i += 1
stripped = content.strip(" \t")
if not stripped:
out.append(EnvLine("blank", content, eol))
continue
if stripped.startswith("#"):
out.append(EnvLine("comment", content, eol))
continue
m = _ASSIGN.match(content)
if not m:
out.append(EnvLine("other", content, eol))
continue
line = EnvLine("assign", content, eol, head=m.group(1) + m.group(2) + m.group(3),
key=m.group(2), value=m.group(4))
quote = line.value[:1]
if quote in ("'", '"', "`") and not _closes(line.value[1:], quote):
# a quoted value that goes on over the next lines, up to the one with the closing quote;
# without a closing quote anywhere it is taken as a one-line value
j = i
while j < len(lines) and not _closes(lines[j][0], quote):
j += 1
if j < len(lines):
line.more = lines[i:j + 1]
i = j + 1
out.append(line)
return out
def env_pairs(text):
"""The active KEY=VALUE lines of an instance's .env, in file order: [(key, value), ...]."""
return [(e.key, e.value) for e in scan_env(text) if e.kind == "assign"]
def merge_env(data, pairs):
"""The .env file `data` (bytes) with `pairs` put in: an existing key gets the new value, a missing key
is added at the end. Every other line stays byte for byte as it was.
Returns (new bytes, report); the report lists the keys replaced, added and already correct."""
if b"\x00" in data:
raise TargetError("the file is not plain text (it looks like it was saved as UTF-16).",
["If you want to fix it: open the file in an editor, save it again as UTF-8 and "
"run this again.", "Nothing was changed."])
entries = scan_env(_text(data))
wanted = dict(pairs)
endings = [x for e in entries for x in [e.eol] + [m[1] for m in e.more]]
eol = "\r\n" if endings.count("\r\n") > endings.count("\n") else "\n"
status, out = {}, []
for e in entries:
if e.kind == "assign" and e.key in wanted:
same = e.value == wanted[e.key] and not e.more
status[e.key] = "same" if same and status.get(e.key, "same") == "same" else "replaced"
out.append(e.raw() if same else e.head + wanted[e.key] + (e.more[-1][1] if e.more else e.eol))
else:
out.append(e.raw())
missing = [k for k in wanted if k not in status]
if missing:
if out and not out[-1].endswith("\n"):
out[-1] += eol
out += [f"{k}={wanted[k]}{eol}" for k in missing]
text = ("\ufeff" if data.startswith(b"\xef\xbb\xbf") else "") + "".join(out)
report = {
"replaced": [k for k in wanted if status.get(k) == "replaced"],
"same": [k for k in wanted if status.get(k) == "same"],
"added": missing,
"lines": sum(1 + len(e.more) for e in entries),
"untouched": sum(1 + len(e.more) for e in entries if not (e.kind == "assign" and e.key in wanted)),
"comments": sum(e.kind == "comment" for e in entries),
}
return text.encode("utf-8", "surrogateescape"), report
def _other_pairs(data, wanted):
"""Every active KEY=VALUE line that is not one of the instance's keys. Deliberately simple (one line at a
time, no multi-line values) so that it does not share the merge's own understanding of the file."""
out = []
for line in _text(data).split("\n"):
line = line.rstrip("\r")
m = _PAIR.match(line)
if m and not line.lstrip(" \t").startswith("#") and m.group(1) not in wanted:
out.append((m.group(1), m.group(2)))
return out
def _kept(entries, wanted):
"""The lines that are not the instance's, as text. The only change allowed to them is a line ending on the
very last line: a file without a final newline gets one when keys are added after it."""
kept = [e.raw() for e in entries if not (e.kind == "assign" and e.key in wanted)]
if kept:
kept[-1] = kept[-1].rstrip("\r\n")
return kept
def check_merge(before, after, pairs):
"""Compare the target file before the change and as it is now. Returns what is wrong (empty = all good):
nothing but the instance's keys may differ, and each of them must have exactly the instance's value."""
wanted = dict(pairs)
b, a = scan_env(_text(before)), scan_env(_text(after))
problems = []
if _kept(b, wanted) != _kept(a, wanted):
problems.append("lines that are not the instance's were changed, moved or removed")
if _other_pairs(before, wanted) != _other_pairs(after, wanted):
problems.append("a key that is not the instance's lost or changed its value")
for key, value in wanted.items():
found = [e for e in a if e.kind == "assign" and e.key == key]
if not found:
problems.append(f"{key} is missing")
elif any(e.value != value or e.more for e in found):
problems.append(f"{key} does not have the instance's value")
return problems
def parse_target(text, home=None):
"""Where the .env file is: '/path', '~/path' or 'user@host:path'.
Returns {'kind': 'local', 'path'} or {'kind': 'ssh', 'user', 'host', 'path'}."""
text = text.strip()
if not text:
raise ValueError("Nothing was typed.")
if text == "~" or text.startswith(("/", "./", "../", "~/")):
return {"kind": "local", "path": home + text[1:] if home and text.startswith("~") else text}
m = _REMOTE.match(text)
if not m:
return {"kind": "local", "path": text}
user, host, path = m.groups()
if len(host) == 1 and path[0] == "\\":
raise ValueError("That looks like a Windows path without the computer's name. "
"Write it like this: user@computer:C:/Users/you/app/.env")
if _WINDOWS_PATH.match(path):
path = "/" + path.replace("\\", "/")
return {"kind": "ssh", "user": user, "host": host, "path": path}
def invoking_user():
"""The passwd entry of the person who ran sudo, or None when supaspin runs directly as root."""
name = os.environ.get("SUDO_USER")
if os.geteuid() != 0 or not name or name == "root":
return None
try:
return pwd.getpwnam(name)
except KeyError:
return None
def private_tmpdir(owner):
"""A folder that only this run can read, in memory (/run) when possible: it holds the safety copy of the
target file while the change is made."""
base = "/run" if os.path.isdir("/run") and os.access("/run", os.W_OK) else None
d = tempfile.mkdtemp(prefix="supaspin-", dir=base)
if owner:
os.chown(d, owner.pw_uid, owner.pw_gid)
return d
def put_private(path, data, owner):
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "wb") as f:
f.write(data)
if owner:
os.chown(path, owner.pw_uid, owner.pw_gid)
def ssh_error(err, spec, me):
"""Turn what scp/ssh printed into a TargetError with advice in plain words."""
host, port, path = spec["host"], spec["port"], spec["path"]
user = spec.get("user") or me
hand = f" ssh -p {port} {user}@{host}"
low = err.lower()
first = next((l.strip() for l in err.splitlines() if l.strip()), "no message")
if ("identity file" in low and "not accessible" in low) or "load key" in low or "incorrect passphrase" in low:
return TargetError(f"the SSH key file cannot be used: {first}",
[f"If you want to fix it: check that the path is right, that {me} can read the file "
"and that only its owner may (chmod 600 <the key file>)."])
if "could not resolve hostname" in low:
advice = ["If you want to fix it: use the computer's IP address (for example 192.168.10.50) instead of its "
"name, or check the spelling.", "A name that works on your own computer may not be known to this server."]
if len(host) == 1:
advice.append(f"If you meant a Windows path such as {host}:/Users/..., write it as "
f"user@computer:{host}:/Users/...")
return TargetError(f"the name '{host}' is not known to this server.", advice)
if "remote host identification has changed" in low:
forget = host if port == 22 else f"[{host}]:{port}"
return TargetError(f"the computer at {host} identifies itself differently than last time.", [
"This happens when that computer was reinstalled or when a different computer now has that address. "
"It can also mean that somebody is pretending to be it.",
f"If you are sure it is the right computer, run this on this server (as {me}, without sudo) to forget "
"the old fingerprint, then try again:", f" ssh-keygen -R {forget}"])
if "host key verification failed" in low:
return TargetError(f"this server does not trust {host} yet (its fingerprint is not on the list of "
"known computers).", [
f"If you want to trust that computer, run this once on this server (as {me}, without sudo), check the "
"fingerprint it shows and answer yes:", hand, "Then run this again."])
if any(s in low for s in ("timed out", "no route to host", "network is unreachable")):
return TargetError(f"{host} did not answer on port {port} (no connection could be made).", [
"This usually means this server cannot reach that computer at all.",
"If you want to fix it, check these in this order:",
" 1. Is the computer switched on, awake and connected to the network?",
" 2. Is the address right? Addresses can change - use the computer's current one.",
" 3. Is SSH switched on there?", *SSH_ON,
f" 4. Is a firewall or your router blocking port {port}?",
"To try the connection yourself, run this on this server:", hand])
if "connection refused" in low:
return TargetError(f"{host} answered, but nothing is listening for SSH on port {port}.", [
"If you want to fix it: switch SSH on there, or use the right port number.", *SSH_ON,
"To try the connection yourself, run this on this server:", hand])
if "permission denied (" in low or "too many authentication failures" in low or "authentication failed" in low:
return TargetError(f"{host} refused the login for the user '{user}'.", [
"Either the user name or the password is wrong, or that computer does not know this server's key.",
"If you want to log in with a password: run this again in a terminal (not from a script) and type the "
"password when SSH asks for it.",
f"If you want this server to log in without a password: run this on this server (as {me}, without "
"sudo). It asks for the password once and installs the key:", f" ssh-copy-id -p {port} {user}@{host}",
"ssh-copy-id does not work for a Windows computer: copy the content of ~/.ssh/id_ed25519.pub from this "
"server into C:\\Users\\<user>\\.ssh\\authorized_keys there (for an administrator account: "
"C:\\ProgramData\\ssh\\administrators_authorized_keys).",
"If the user name is wrong, run this again with the right one: user@computer:/path/.env"])
if "no such file" in low:
return TargetError(f"the file {path} does not exist on {host}.",
["If you want to fix it: check the spelling and the folder. Nothing was created."])
if "permission denied" in low:
return TargetError(f"the login worked, but user '{user}' is not allowed to open or change {path} on {host}.",
["If you want to fix it: give that user write permission on the file (and read "
"permission on its folder), or use the user that owns the file."])
if "subsystem request failed" in low:
return TargetError(f"{host} accepted the login but does not allow file copying (SFTP).", [
"If you want to fix it: on that computer make sure the SSH server settings contain a line starting "
"with 'Subsystem sftp', then restart SSH (Linux: sudo systemctl restart ssh). Mac and Windows have it "
"on by default."])
return TargetError(f"the connection to {host} failed: {first}",
["To see more, try the connection by hand on this server:", hand])
class LocalTarget:
"""A .env file on this server, changed in place so that its owner and permissions stay as they are."""
def __init__(self, spec):
self.path = self.label = spec["path"]
def _error(self, e):
if isinstance(e, FileNotFoundError):
return TargetError(f"the file {self.path} does not exist on this server.",
["If you want to fix it: check the spelling and the folder. Nothing was created."])
if isinstance(e, PermissionError):
return TargetError(f"this server is not allowed to open {self.path}.")
return TargetError(f"{self.path} cannot be used: {e.strerror or e}.")
def read(self):
try:
with open(self.path, "rb") as f:
return f.read()
except OSError as e:
raise self._error(e)
def write(self, data):
try:
with open(self.path, "r+b") as f:
f.write(data)
f.truncate()
f.flush()
os.fsync(f.fileno())
except OSError as e:
raise self._error(e)
def close(self):
pass
class SshTarget:
"""A .env file on another computer, copied with scp. scp needs nothing on the other side except an SSH
server with file copying (SFTP), so this works for Mac, Linux and Windows computers. SSH runs as the person
who typed sudo: their keys and their list of known computers apply. One connection, opened first, is shared
by all copies, so a password is asked only once."""
def __init__(self, spec, tmp, owner):
self.spec, self.tmp, self.owner = spec, tmp, owner
self.me = owner.pw_name if owner else "root"
self.dest = f"{spec['user']}@{spec['host']}" if spec.get("user") else spec["host"]
self.label = f"{self.dest}:{spec['path']}"
self.sock = os.path.join(tmp, "c")
self.opts = ["-o", "ConnectTimeout=10", "-o", "ServerAliveInterval=10", "-o", "ServerAliveCountMax=3",
"-o", f"Port={spec['port']}"]
if not sys.stdin.isatty():
self.opts += ["-o", "BatchMode=yes"] # never wait for a password nobody can type
if spec.get("key"):
self.opts += ["-i", spec["key"], "-o", "IdentitiesOnly=yes"] # -i (not -o) so ssh warns about a bad path
self.connected = False
def _as_owner(self, cmd):
return ["runuser", "-u", self.owner.pw_name, "--"] + cmd if self.owner else cmd
def _connect(self):
"""Open the one connection that all copies share. This is where SSH asks for a password or a key
passphrase, once per run. (scp cannot open a shared connection by itself, so ssh does it.)"""
if self.connected:
return
with tempfile.TemporaryFile("w+") as err:
p = subprocess.run(self._as_owner(["ssh", "-f", "-N", "-M", "-S", self.sock, *self.opts, self.dest]),
stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=err)
err.seek(0)
text = err.read()
if p.returncode != 0:
raise ssh_error(text, self.spec, self.me)
self.connected = True
def _scp(self, src, dst):
self._connect()
p = subprocess.run(self._as_owner(["scp", "-o", f"ControlPath={self.sock}", *self.opts, src, dst]),
stdin=subprocess.DEVNULL, capture_output=True, text=True)
if p.returncode != 0:
raise ssh_error(p.stderr or p.stdout, self.spec, self.me)
def read(self):
dst = os.path.join(self.tmp, "download")
self._scp(f"{self.dest}:{self.spec['path']}", dst)
with open(dst, "rb") as f:
data = f.read()
os.remove(dst)
return data
def write(self, data):
src = os.path.join(self.tmp, "upload")
put_private(src, data, self.owner)
try:
self._scp(src, f"{self.dest}:{self.spec['path']}")
finally:
os.remove(src)
def close(self):
if os.path.exists(self.sock):
subprocess.run(self._as_owner(["ssh", "-o", f"ControlPath={self.sock}", "-O", "exit", self.dest]),
stdin=subprocess.DEVNULL, capture_output=True)
def open_target(spec, tmp, owner):
return LocalTarget(spec) if spec["kind"] == "local" else SshTarget(spec, tmp, owner)
def undo_write(target, original):
"""After a failed write: 'unchanged' (the file never changed), 'restored' (put back and confirmed) or
'failed' (the file may be damaged and could not be put back)."""
try:
if target.read() == original:
return "unchanged"
target.write(original)
return "restored" if target.read() == original else "failed"
except TargetError:
return "failed"
def _print_changes(log, rep, dry):
names = ("Would replace", "Would add", "Already correct") if dry else ("Replaced", "Added", "Already correct")
for name, keys in zip(names, (rep["replaced"], rep["added"], rep["same"])):
log(f" {name} ({len(keys)}): {', '.join(keys) if keys else '(none)'}")
log(f" Untouched: {rep['untouched']} other lines (other keys, comments, blank lines) "
f"{'stay' if dry else 'are'} exactly as they {'are' if dry else 'were'}.")
def push_env(cfg, n, spec, log, dry_run=False):
"""Put instance n's .env values into the target file and report what happened. Before anything is
written, the target is copied to a private temporary folder; after writing, the file is read back and
compared with that copy. If anything is off, the copy is written back. The copy is deleted at the end,
except when it could not be written back (then it is the only good copy). Returns 0 or 1."""
who = f"instance {n} ({cfg['slots'].get(n, '?')})"
try:
pairs = env_pairs(read_env(cfg, n))
except FileNotFoundError:
log(f"{RED}FAILED{OFF} {who} has no .env file ({inst_dir(cfg, n)}/.env), so there is nothing to copy.")
return 1
owner = invoking_user()
tmp = private_tmpdir(owner)
target, written, keep = None, False, False
try:
try:
target = open_target(spec, tmp, owner)
log(f"Reading {target.label} ...")
original = target.read()
put_private(os.path.join(tmp, "original.env"), original, None)
new, rep = merge_env(original, pairs)
log(f" ok - {rep['lines']} lines, {rep['comments']} commented-out lines are ignored")
log(f"Working out the changes: {len(rep['replaced'])} to replace, {len(rep['added'])} to add, "
f"{len(rep['same'])} already correct")
if dry_run:
log(f"{BOLD}DRY RUN{OFF} {who} -> {target.label} Nothing was written.")
_print_changes(log, rep, dry=True)
return 0
if new == original:
log(f"{BOLD}SUCCESS{OFF} {who} -> {target.label}")
log(" Nothing to change: every value in the file was already correct. The file was not touched.")
return 0
log("Writing the file ...")
written = True # from here on the file may be damaged, even if the write itself fails
target.write(new)
log("Reading it back and checking that nothing else changed ...")
back = target.read()
problems = check_merge(original, back, pairs)
if not problems and back != new:
problems = ["the file that arrived is not the one that was sent"]
if problems:
raise TargetError("the check after writing failed: " + "; ".join(problems) + ".")
except (TargetError, KeyboardInterrupt) as e:
err = e if isinstance(e, TargetError) else TargetError("it was interrupted (Ctrl+C).")
state = undo_write(target, original) if written else "unchanged"
keep = state == "failed"
log(f"{RED}FAILED{OFF} {who} was NOT written to {target.label}")
log(f" Why: {err.why}")
if state == "unchanged":
log(" Your file was not changed.")
elif state == "restored":
log(" Your file was restored to exactly how it was. Nothing was lost.")
else:
log(f" {RED}WARNING: the file may be damaged and could not be put back.{OFF}")
log(f" An untouched copy of the original is kept on this server: {tmp}/original.env")
log(f" Copy it back to {target.label} by hand, then delete that folder: sudo rm -r {tmp}")
for line in err.advice:
log(f" {line}" if line else "")
return 1
log(f"{BOLD}SUCCESS{OFF} {who} -> {target.label}")
_print_changes(log, rep, dry=False)
return 0
finally:
if target is not None:
target.close()
if not keep:
shutil.rmtree(tmp, ignore_errors=True)
def ask_target(user):
"""Ask where the .env file is and how to log in there. Returns a target spec, or None."""
print("Which .env file should receive the values? Type where it is:")
print(" on this server: /home/igor/app/.env")
print(" on a Mac or Linux computer: igor@192.168.10.50:/Users/igor/app/.env")
print(" on a Windows computer: igor@192.168.10.50:C:/Users/igor/app/.env")
print("The file must already exist. Only the instance's own keys are changed; everything else stays.\n")
while True:
text = ask("File (Enter to cancel)")
if not text:
return None
try:
spec = parse_target(text, user.pw_dir if user else None)
break
except ValueError as e:
print(f"{RED}{e}{OFF}")
if spec["kind"] == "ssh":
if not spec["user"]:
spec["user"] = ask("SSH user on that computer", user.pw_name if user else "root")
try:
spec["port"] = int(ask("SSH port", "22"))
except ValueError:
print(f"{RED}The port must be a number.{OFF}")
return None
spec["key"] = ask("SSH key file (press Enter to use your normal keys, or to type a password when asked)") or None
return spec
def push_env_command(cfg, args):
ap = argparse.ArgumentParser(prog="supaspin push-env", description="Put an instance's .env values into "
"another .env file: existing keys get the new value, missing keys are added.")
ap.add_argument("number", type=int, help="instance number (see: supaspin list)")
ap.add_argument("target", nargs="?", help="/path/.env on this server, or user@host:/path/.env on another "
"computer (Windows: user@host:C:/path/.env). Put it in quotes. Asked for if left out.")
ap.add_argument("--port", type=int, default=22, help="SSH port of the other computer (default 22)")
ap.add_argument("--key", help="SSH key file to log in with (default: your normal keys, or a password)")
ap.add_argument("--dry-run", action="store_true", help="show what would change, write nothing")
a = ap.parse_args(args)
if a.number not in existing_numbers(cfg):
print(f"{RED}Instance {a.number} does not exist.{OFF} See: sudo supaspin list", file=sys.stderr)
return 1
user = invoking_user()
if a.target:
try:
spec = parse_target(a.target, user.pw_dir if user else None)
except ValueError as e:
print(f"{RED}{e}{OFF}", file=sys.stderr)
return 1
if spec["kind"] == "ssh":
spec.update(port=a.port, key=a.key)
elif sys.stdin.isatty():
spec = ask_target(user)
if spec is None:
return 1
else:
print(f"{RED}Say where the file is: supaspin push-env {a.number} user@host:/path/.env{OFF}", file=sys.stderr)
return 1
return push_env(cfg, a.number, spec, log_line, a.dry_run)
# --------------------------------------------------------------------- ui --
def log_line(msg, live=False):
if live and sys.stdout.isatty():
sys.stdout.write("\r\x1b[K" + msg)
sys.stdout.flush()
else:
print(msg, flush=True)
def ask(prompt, default=None):
suffix = f" [{default}]" if default not in (None, "") else ""
try:
val = input(f"{prompt}{suffix}: ").strip()
except EOFError:
val = ""
return val or (default if default is not None else "")
def confirm_number(n, what):
return ask(f"Type the instance number ({n}) to confirm {what}") == str(n)
def menu(title, items):
"""Arrow-key menu; returns index or None (q / Esc). Falls back to numbers without a terminal."""
if not sys.stdin.isatty():
print(title)
for i, it in enumerate(items, 1):
print(f" {i}. {it}")
v = ask("Choose")
return int(v) - 1 if v.isdigit() and 0 < int(v) <= len(items) else None
fd = sys.stdin.fileno()
saved = termios.tcgetattr(fd)
sel = 0
try:
tty.setraw(fd)
while True:
sys.stdout.write("\x1b[H\x1b[2J" + BOLD + title + OFF + "\r\n\r\n")
for i, it in enumerate(items):
sys.stdout.write((f" {REV} {it} {OFF}" if i == sel else f" {it}") + "\r\n")
sys.stdout.write(DIM + "\r\n up/down move, Enter choose, q back" + OFF + "\r\n")
sys.stdout.flush()
ch = os.read(fd, 8)
if ch in (b"\x1b[A", b"k"):
sel = (sel - 1) % len(items)
elif ch in (b"\x1b[B", b"j"):
sel = (sel + 1) % len(items)
elif ch in (b"\r", b"\n"):
return sel
elif ch in (b"q", b"\x1b", b"\x03"):
return None
finally:
termios.tcsetattr(fd, termios.TCSADRAIN, saved)
def pause():
if sys.stdin.isatty():
input("\nPress Enter to go back.")
def pick_instance(cfg, title):
nums = sorted(existing_numbers(cfg))
if not nums:
print("No instances.")
return None
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]
def mail_dialog(cfg):
m = dict(cfg.get("mail") or DEFAULT_MAIL)
for key, label in (("host", "SMTP host"), ("port", "SMTP port"), ("user", "SMTP user"),
("password", "SMTP password"), ("admin_email", "Admin e-mail"),
("sender_name", "Sender name")):
m[key] = ask(label, m.get(key, ""))
m["port"] = int(m["port"])
return m
def choose_mail(cfg):
saved = cfg.get("mail") or DEFAULT_MAIL
print(f"Saved mail settings: {saved['host']}:{saved['port']} as {saved['user'] or '(no user)'}")
if ask("Use the saved mail settings? (Y/n)", "y").lower().startswith("n"):
return mail_dialog(cfg)
return saved
def detect_defaults():
d = {"node_name": "", "lan_ip": "", "ssh_user": os.environ.get("SUDO_USER", "root")}
try:
d["node_name"] = kubectl("get", "nodes", "-o", "jsonpath={.items[0].metadata.name}").stdout.strip()
except Exception:
pass
try:
m = re.search(r"src (\S+)", run(["ip", "-4", "route", "get", "1.1.1.1"]).stdout)
d["lan_ip"] = m.group(1) if m else ""
except Exception:
pass
return d
def setup_dialog(cfg, log):
d = detect_defaults()
cfg = cfg or {}
cfg["storage_root"] = ask("Storage root (images live here)", cfg.get("storage_root", "/home/igor/k8s-storage/supabase"))
cfg["node_name"] = ask("Kubernetes node name", cfg.get("node_name", d["node_name"]))
cfg["lan_ip"] = ask("Server LAN IP (Postgres/Studio ports bind here)", cfg.get("lan_ip", d["lan_ip"]))
bind_default = cfg.get("port_bind", "lan")
bind = ask("Port bind: lan (LAN only), all (LAN + localhost), local (localhost only)",
bind_default).lower()
while bind not in ("lan", "all", "local"):
print(f"{RED}Answer 'lan', 'all' or 'local'.{OFF}")
bind = ask("Port bind: lan (LAN only), all (LAN + localhost), local (localhost only)",
bind_default).lower()
cfg["port_bind"] = bind
cfg["ingress_class"] = ask("Ingress class", cfg.get("ingress_class", "public"))
controller_default = cfg.get("ingress_controller", "nginx")
controller = ask("Ingress controller, traefik or nginx", controller_default).lower()
while controller not in ("traefik", "nginx"):
print(f"{RED}Answer 'traefik' or 'nginx'.{OFF}")
controller = ask("Ingress controller, traefik or nginx", controller_default).lower()
cfg["ingress_controller"] = controller
le_default = "y" if cfg.get("lets_encrypt", True) else "n"
le = ask("Fetch certificates from Let's Encrypt? Needs public DNS and ports 80/443 (Y/n)", le_default)
cfg["lets_encrypt"] = le.lower().startswith("y")
if cfg["lets_encrypt"]:
cfg["acme_email"] = ask("Let's Encrypt e-mail", cfg.get("acme_email", ""))
cur = ",".join(cfg["slots"][k] for k in sorted(cfg.get("slots", {}))) if cfg.get("slots") else ""
while True:
try:
cfg["slots"] = parse_slots(ask(
"Hostname slots, comma separated (a name with a number keys that slot: "
"supa4 = slot 4; a plain name takes its position)", cur))
break
except ValueError as e:
print(f"{RED}{e}{OFF}")
cfg["ssh_host"] = ask("Public SSH host for tunnels", cfg.get("ssh_host", ""))
cfg["ssh_port"] = int(ask("Public SSH port", cfg.get("ssh_port", 22)))
cfg["ssh_user"] = ask("SSH user", cfg.get("ssh_user", d["ssh_user"]))
cfg["chart_version"] = cfg.get("chart_version", CHART_VERSION)
print("Default mail settings:")
cfg["mail"] = mail_dialog(cfg)
save_config(cfg)
ensure_cluster_objects(cfg, log)
log("Settings saved to " + CONFIG_PATH)
return cfg
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:
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:
if i in (0, 1):
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) 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()
def print_list(cfg):
rows = list_rows(cfg)
if not rows:
print("No instances.")
return
for r in rows:
state = r.get("state", "running")
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 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 "")
print()
def main_menu(cfg, log):
while True:
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:
if i == 0:
create_instance(cfg, choose_mail(cfg), log)
elif i == 1:
print_list(cfg)
elif i == 2:
n = pick_instance(cfg, "Which instance?")
if n:
show_menu(cfg, n, log)
continue
elif i == 3:
n = pick_instance(cfg, "Delete which instance?")
if n and confirm_number(n, "deleting it"):
delete_instance(cfg, n, log)
elif i == 4:
n = pick_instance(cfg, "Copy which instance's values?")
spec = ask_target(invoking_user()) if n else None
if spec:
print()
push_env(cfg, n, spec, log)
elif i == 5:
upgrade_all(cfg, log)
elif i == 6:
cfg["mail"] = mail_dialog(cfg)
save_config(cfg)
elif i == 7:
cfg = setup_dialog(cfg, log)
except Exception as e:
print(f"{RED}{e}{OFF}")
pause()
def main(argv):
if len(argv) >= 2 and argv[1] == "post-render": # called by Helm; no root check, no output but YAML
cfg = load_config()
sys.stdout.write(post_render(sys.stdin.read(), int(argv[2]), cfg))
return 0
if len(argv) > 1 and argv[1] in ("help", "-h", "--help"):
print(__doc__)
return 0
if os.geteuid() != 0:
print("supaspin must run as root: sudo supaspin", file=sys.stderr)
return 1
cmd = argv[1] if len(argv) > 1 else "menu"
cfg = load_config()
if cmd in ("--version", "version"):
print(f"supaspin {VERSION}")
return 0
try:
if cfg is None or cmd == "setup":
print("First-time setup." if cfg is None else "Settings.")
cfg = setup_dialog(cfg, log_line)
if cmd == "setup":
return 0
n = int(argv[2]) if len(argv) > 2 and argv[2].isdigit() else None
if cmd == "menu":
main_menu(cfg, log_line)
elif cmd == "create":
mail = cfg.get("mail") or DEFAULT_MAIL
if "--ask-mail" in argv:
mail = choose_mail(cfg)
create_instance(cfg, mail, log_line)
elif cmd == "list":
print_list(cfg)
elif cmd == "show" and n:
print(mask_env(read_env(cfg, n)))
print("\n" + tunnel_help(cfg, n))
elif cmd == "env" and n:
print(read_env(cfg, n), end="")
elif cmd == "tunnel" and n:
print(tunnel_help(cfg, n))
elif cmd == "delete" and n:
if "--yes" in argv or confirm_number(n, "deleting it"):
delete_instance(cfg, n, log_line)
elif cmd == "upgrade":
upgrade_all(cfg, log_line, n)
elif cmd in ("suspend", "resume"):
if n is None:
# No "all" here on purpose: stopping every instance is not something to type by
# accident, and resuming them all at once would start 36 pods in one go.
print(f"supaspin {cmd} N - which instance? ({cmd} takes one number)",
file=sys.stderr)
return 2
(suspend_instance if cmd == "suspend" else resume_instance)(cfg, n, log_line)
elif cmd == "push-env":
return push_env_command(cfg, argv[2:])
elif cmd == "mail":
cfg["mail"] = mail_dialog(cfg)
save_config(cfg)
else:
print(__doc__)
return 2
except (RuntimeError, KeyboardInterrupt) as e:
print(f"{RED}{e}{OFF}", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))