studio-console 1.3.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- VERSION +1 -0
- docker-compose.yml +282 -0
- nginx/studio.conf.template +89 -0
- studio_console/__init__.py +17 -0
- studio_console/__main__.py +3 -0
- studio_console/cli.py +176 -0
- studio_console/cloudflare/__init__.py +1 -0
- studio_console/cloudflare/cf_api.py +285 -0
- studio_console/cloudflare/cf_wizard.py +1549 -0
- studio_console/commands.py +3001 -0
- studio_console/commands_container.py +526 -0
- studio_console/commands_launch.py +684 -0
- studio_console/constants.py +184 -0
- studio_console/data/README.md +37 -0
- studio_console/data/known_baselines.json +3 -0
- studio_console/env.py +600 -0
- studio_console/major_version.py +257 -0
- studio_console/tui.py +412 -0
- studio_console/wizard.py +1953 -0
- studio_console-1.3.4.dist-info/METADATA +235 -0
- studio_console-1.3.4.dist-info/RECORD +26 -0
- studio_console-1.3.4.dist-info/WHEEL +4 -0
- studio_console-1.3.4.dist-info/entry_points.txt +2 -0
- studio_console-1.3.4.dist-info/licenses/LEGAL.md +60 -0
- studio_console-1.3.4.dist-info/licenses/LICENSE +112 -0
- templates/.env.example +76 -0
|
@@ -0,0 +1,3001 @@
|
|
|
1
|
+
# studio_console/commands.py
|
|
2
|
+
"""Command functions, submenus, and config menu."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from .constants import (
|
|
14
|
+
COMPONENT_TO_IMAGE,
|
|
15
|
+
COMPONENT_TO_PROFILE,
|
|
16
|
+
ENV_SECTIONS,
|
|
17
|
+
IMAGE_BUILD_CONFIG,
|
|
18
|
+
SCALE_PROFILES,
|
|
19
|
+
SCALE_VARS,
|
|
20
|
+
THIRD_PARTY_IMAGES,
|
|
21
|
+
)
|
|
22
|
+
from . import major_version as mv
|
|
23
|
+
from .env import (
|
|
24
|
+
_find_repo_root,
|
|
25
|
+
_get_running_services,
|
|
26
|
+
_images_from_env,
|
|
27
|
+
_validate_env,
|
|
28
|
+
backup_root,
|
|
29
|
+
compose_cmd,
|
|
30
|
+
derive_app_db_url,
|
|
31
|
+
detect_context,
|
|
32
|
+
env_path,
|
|
33
|
+
fatal,
|
|
34
|
+
mask_value,
|
|
35
|
+
read_env,
|
|
36
|
+
run,
|
|
37
|
+
run_quiet,
|
|
38
|
+
set_env_value,
|
|
39
|
+
unset_env_values,
|
|
40
|
+
storage_root,
|
|
41
|
+
validate_password,
|
|
42
|
+
write_env,
|
|
43
|
+
)
|
|
44
|
+
from .tui import (
|
|
45
|
+
NavBack,
|
|
46
|
+
NavExit,
|
|
47
|
+
_bold,
|
|
48
|
+
_cyan,
|
|
49
|
+
_dim,
|
|
50
|
+
_green,
|
|
51
|
+
_interactive_single,
|
|
52
|
+
_interactive_yn,
|
|
53
|
+
_prompt,
|
|
54
|
+
_prompt_password,
|
|
55
|
+
_red,
|
|
56
|
+
_yellow,
|
|
57
|
+
error,
|
|
58
|
+
heading,
|
|
59
|
+
info,
|
|
60
|
+
ok,
|
|
61
|
+
warn,
|
|
62
|
+
warn_header,
|
|
63
|
+
)
|
|
64
|
+
from .wizard import (
|
|
65
|
+
SetupState,
|
|
66
|
+
_section_api_ui_scaling,
|
|
67
|
+
_write_override_and_nginx,
|
|
68
|
+
wizard,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# ---------------------------------------------------------------------------
|
|
72
|
+
# Shared helpers
|
|
73
|
+
# ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _apply_scale_flags(up_cmd: list[str], env_data: dict) -> list[str]:
|
|
77
|
+
"""Append --scale flags for any *active* worker with a count != 1.
|
|
78
|
+
|
|
79
|
+
Only scales services whose profile is in COMPOSE_PROFILES — compose errors
|
|
80
|
+
'no such service: X: disabled' if you --scale a profile that isn't active.
|
|
81
|
+
"""
|
|
82
|
+
active = {p for p in env_data.get("COMPOSE_PROFILES", "").split(",") if p}
|
|
83
|
+
for var, service in SCALE_PROFILES.items():
|
|
84
|
+
if service not in active:
|
|
85
|
+
continue
|
|
86
|
+
count = env_data.get(var, "")
|
|
87
|
+
if count and count != "1":
|
|
88
|
+
up_cmd += ["--scale", f"{service}={count}"]
|
|
89
|
+
return up_cmd
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _stop_api_ui_containers() -> None:
|
|
93
|
+
"""Remove all studio-api-*, studio-ui-*, studio-nginx-* containers directly.
|
|
94
|
+
|
|
95
|
+
Uses 'ps -a' so Created/Exited containers (not just running ones) are caught —
|
|
96
|
+
otherwise a leftover 'studio-api-2' in Created state collides with compose on
|
|
97
|
+
recreate ('container name already in use'). 'docker rm -f' force-removes
|
|
98
|
+
running ones too; '-s' is NOT a valid rm flag.
|
|
99
|
+
"""
|
|
100
|
+
ctrs: list[str] = []
|
|
101
|
+
for pattern in ("studio-api-", "studio-ui-", "studio-nginx-"):
|
|
102
|
+
_, out = run_quiet(["docker", "ps", "-aq", "--filter", f"name={pattern}"])
|
|
103
|
+
ctrs.extend(c.strip() for c in out.strip().splitlines() if c.strip())
|
|
104
|
+
ctrs = list(dict.fromkeys(ctrs))
|
|
105
|
+
if ctrs:
|
|
106
|
+
info(f"Stopping {len(ctrs)} API/UI/nginx container(s)...")
|
|
107
|
+
run_quiet(["docker", "rm", "-f"] + ctrs, timeout=30)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _log_picker(context: str, env_file: Path, follow: bool) -> None:
|
|
111
|
+
"""Interactive loop to pick a service and show/stream its logs."""
|
|
112
|
+
prompt = "Stream logs for" if follow else "View logs for"
|
|
113
|
+
while True:
|
|
114
|
+
services = _get_running_services(context, env_file)
|
|
115
|
+
labels, targets = _build_log_options(services)
|
|
116
|
+
try:
|
|
117
|
+
pick = _interactive_single(prompt, labels, default=0)
|
|
118
|
+
except NavBack:
|
|
119
|
+
break
|
|
120
|
+
cmd_logs(context, env_file, targets[pick], follow=follow)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _missing_images(env_file: Path) -> list[str]:
|
|
124
|
+
"""Return configured image names that are not present locally."""
|
|
125
|
+
env_data = read_env(env_file)
|
|
126
|
+
tag = env_data.get("SHS_STUDIO_VERSION", "latest")
|
|
127
|
+
missing: list[str] = []
|
|
128
|
+
for name in _images_from_env(env_file):
|
|
129
|
+
found = any(
|
|
130
|
+
run_quiet(["docker", "image", "inspect", f"{prefix}/{name}:{tag}"])[0] == 0
|
|
131
|
+
for prefix in ("ghcr.io/selfhosthub", "selfhosthub")
|
|
132
|
+
)
|
|
133
|
+
if not found:
|
|
134
|
+
missing.append(name)
|
|
135
|
+
return missing
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _pull_images(names: list[str], tag: str) -> bool:
|
|
139
|
+
"""Pull the given studio images from the registry. Returns True iff all succeed.
|
|
140
|
+
|
|
141
|
+
Tries the canonical ghcr.io/selfhosthub prefix first, then the bare
|
|
142
|
+
selfhosthub fallback (mirrors _missing_images' lookup order).
|
|
143
|
+
"""
|
|
144
|
+
all_ok = True
|
|
145
|
+
for name in names:
|
|
146
|
+
pulled = False
|
|
147
|
+
for prefix in ("ghcr.io/selfhosthub", "selfhosthub"):
|
|
148
|
+
ref = f"{prefix}/{name}:{tag}"
|
|
149
|
+
rc, _ = run_quiet(["docker", "pull", ref], timeout=300)
|
|
150
|
+
if rc == 0:
|
|
151
|
+
ok(f"{ref}")
|
|
152
|
+
pulled = True
|
|
153
|
+
break
|
|
154
|
+
if not pulled:
|
|
155
|
+
all_ok = False
|
|
156
|
+
return all_ok
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# ---------------------------------------------------------------------------
|
|
160
|
+
# Config menu (main interactive menu when .env exists)
|
|
161
|
+
# ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def config_menu(context: str, env_file: Path) -> None:
|
|
165
|
+
"""Interactive config menu when .env already exists."""
|
|
166
|
+
heading("Studio")
|
|
167
|
+
|
|
168
|
+
menu_options = [
|
|
169
|
+
f"Services {_dim('start · stop · restart · health · logs · links')}",
|
|
170
|
+
f"Setup {_dim('wizard — components · secrets · domain · cloudflare')}",
|
|
171
|
+
f"Images {_dim('build · upgrade · rollback')}",
|
|
172
|
+
f"Advanced {_dim('scale API/UI · per-service ops · cloudflare')}",
|
|
173
|
+
f"Backup {_dim('backup · restore')}",
|
|
174
|
+
f"Update console {_dim('upgrade studio-console to latest')}",
|
|
175
|
+
f"Exit",
|
|
176
|
+
]
|
|
177
|
+
|
|
178
|
+
while True:
|
|
179
|
+
# Quick status check before showing menu
|
|
180
|
+
env_data = read_env(env_file)
|
|
181
|
+
nginx_port = env_data.get("SHS_NGINX_PORT", "80")
|
|
182
|
+
health_url = f"http://localhost:{nginx_port}/health"
|
|
183
|
+
rc, _ = run_quiet(["curl", "-sf", health_url])
|
|
184
|
+
if rc == 0:
|
|
185
|
+
public_url = env_data.get("SHS_PUBLIC_BASE_URL", "")
|
|
186
|
+
local = (
|
|
187
|
+
f"http://localhost:{nginx_port}"
|
|
188
|
+
if nginx_port != "80"
|
|
189
|
+
else "http://localhost"
|
|
190
|
+
)
|
|
191
|
+
print(f" Status: {_green('running')}")
|
|
192
|
+
print(f" Local URL: {local}")
|
|
193
|
+
if public_url.startswith("https://"):
|
|
194
|
+
print(f" Public URL: {public_url}")
|
|
195
|
+
else:
|
|
196
|
+
rc2, ps_out = run_quiet(
|
|
197
|
+
compose_cmd(env_file) + ["ps", "--format", "json"], timeout=10
|
|
198
|
+
)
|
|
199
|
+
running = []
|
|
200
|
+
if rc2 == 0 and ps_out.strip():
|
|
201
|
+
import json as _json
|
|
202
|
+
|
|
203
|
+
for line in ps_out.strip().splitlines():
|
|
204
|
+
try:
|
|
205
|
+
svc = _json.loads(line)
|
|
206
|
+
state = svc.get("State", "")
|
|
207
|
+
if state == "running":
|
|
208
|
+
running.append(svc.get("Service", ""))
|
|
209
|
+
except _json.JSONDecodeError:
|
|
210
|
+
pass
|
|
211
|
+
if not running:
|
|
212
|
+
print(f" Status: {_cyan('configured')} | Services → Start all")
|
|
213
|
+
elif mv.scrape_guardrail_failure(env_file, context):
|
|
214
|
+
print(
|
|
215
|
+
f" Status: {_red('blocked')} | major-version boundary — see Services → Health"
|
|
216
|
+
)
|
|
217
|
+
else:
|
|
218
|
+
print(f" Status: {_yellow('starting...')}")
|
|
219
|
+
print()
|
|
220
|
+
|
|
221
|
+
idx = _interactive_single("Studio", menu_options, default=0, nav=False)
|
|
222
|
+
|
|
223
|
+
try:
|
|
224
|
+
if idx == 0:
|
|
225
|
+
_submenu_services(context, env_file)
|
|
226
|
+
elif idx == 1:
|
|
227
|
+
_submenu_setup(context, env_file)
|
|
228
|
+
elif idx == 2:
|
|
229
|
+
_submenu_images(context, env_file)
|
|
230
|
+
elif idx == 3:
|
|
231
|
+
_submenu_advanced(context, env_file)
|
|
232
|
+
elif idx == 4:
|
|
233
|
+
_submenu_backup(context, env_file)
|
|
234
|
+
elif idx == 5:
|
|
235
|
+
cmd_self_update(context)
|
|
236
|
+
elif idx == 6:
|
|
237
|
+
return
|
|
238
|
+
except (NavBack, NavExit):
|
|
239
|
+
pass # return to main menu
|
|
240
|
+
|
|
241
|
+
print()
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
# ---------------------------------------------------------------------------
|
|
245
|
+
# Restart helper
|
|
246
|
+
# ---------------------------------------------------------------------------
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _restart_for_setup(
|
|
250
|
+
context: str,
|
|
251
|
+
env_file: Path,
|
|
252
|
+
include_api: bool = False,
|
|
253
|
+
old_profiles: str = "",
|
|
254
|
+
) -> None:
|
|
255
|
+
"""Restart services after a settings change.
|
|
256
|
+
|
|
257
|
+
Stops containers using the *old* profile set (captured before wizard wrote
|
|
258
|
+
the new .env) so we catch workers that were just deselected and would
|
|
259
|
+
otherwise become orphans. Then starts using the *new* profile set.
|
|
260
|
+
|
|
261
|
+
When include_api=False only worker containers are touched; API/UI/nginx
|
|
262
|
+
are left running throughout.
|
|
263
|
+
"""
|
|
264
|
+
env_data = read_env(env_file)
|
|
265
|
+
base = compose_cmd(env_file)
|
|
266
|
+
new_profiles = {p for p in env_data.get("COMPOSE_PROFILES", "").split(",") if p}
|
|
267
|
+
|
|
268
|
+
# Resolve missing images BEFORE stopping anything — a missing image must not
|
|
269
|
+
# leave a running stack torn down. In source mode we build; in registry mode
|
|
270
|
+
# we pull. Only if a pull fails do we bail, with everything still up.
|
|
271
|
+
missing = _missing_images(env_file)
|
|
272
|
+
if missing:
|
|
273
|
+
if _find_repo_root(env_file):
|
|
274
|
+
info(f"Building missing images: {', '.join(missing)}")
|
|
275
|
+
cmd_build(env_file, missing, confirm=False)
|
|
276
|
+
else:
|
|
277
|
+
tag = env_data.get("SHS_STUDIO_VERSION", "latest")
|
|
278
|
+
info(f"Pulling missing images: {', '.join(missing)}")
|
|
279
|
+
if not _pull_images(missing, tag):
|
|
280
|
+
error(f"Failed to pull images: {', '.join(missing)}")
|
|
281
|
+
warn("Check the tag (SHS_STUDIO_VERSION) and registry access.")
|
|
282
|
+
warn("No changes applied — services left as they were.")
|
|
283
|
+
return
|
|
284
|
+
|
|
285
|
+
# Stop workers directly — catches orphans whose profiles were removed from .env.
|
|
286
|
+
# 'ps -a' so Created/Exited workers are removed too (avoids name collisions on
|
|
287
|
+
# recreate); 'rm -f' force-removes running ones ('-s' is not a valid rm flag).
|
|
288
|
+
_, ps_out = run_quiet(["docker", "ps", "-aq", "--filter", "name=studio-worker-"])
|
|
289
|
+
all_worker_ctrs = [c.strip() for c in ps_out.strip().splitlines() if c.strip()]
|
|
290
|
+
if all_worker_ctrs:
|
|
291
|
+
info(f"Stopping {len(all_worker_ctrs)} worker container(s)...")
|
|
292
|
+
run_quiet(["docker", "rm", "-f"] + all_worker_ctrs, timeout=30)
|
|
293
|
+
|
|
294
|
+
if include_api:
|
|
295
|
+
_stop_api_ui_containers()
|
|
296
|
+
|
|
297
|
+
info("Starting services...")
|
|
298
|
+
try:
|
|
299
|
+
if include_api:
|
|
300
|
+
run(
|
|
301
|
+
_apply_scale_flags(base + ["up", "-d", "--remove-orphans"], env_data),
|
|
302
|
+
timeout=120,
|
|
303
|
+
)
|
|
304
|
+
else:
|
|
305
|
+
# Workers only — explicit service names so API/UI/nginx are never touched
|
|
306
|
+
worker_services = [
|
|
307
|
+
svc for svc in SCALE_PROFILES.values() if svc in new_profiles
|
|
308
|
+
]
|
|
309
|
+
if worker_services:
|
|
310
|
+
run(
|
|
311
|
+
_apply_scale_flags(
|
|
312
|
+
base + ["up", "-d"] + list(dict.fromkeys(worker_services)),
|
|
313
|
+
env_data,
|
|
314
|
+
),
|
|
315
|
+
timeout=120,
|
|
316
|
+
)
|
|
317
|
+
else:
|
|
318
|
+
info("No active worker profiles — nothing to start.")
|
|
319
|
+
ok("Done")
|
|
320
|
+
except Exception as e:
|
|
321
|
+
error(f"Docker Compose failed: {e}")
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
# ---------------------------------------------------------------------------
|
|
325
|
+
# Submenus
|
|
326
|
+
# ---------------------------------------------------------------------------
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _submenu_setup(context: str, env_file: Path) -> None:
|
|
330
|
+
"""Setup submenu — runs the wizard then offers Apply now / Skip."""
|
|
331
|
+
old_profiles = (
|
|
332
|
+
read_env(env_file).get("COMPOSE_PROFILES", "") if env_file.exists() else ""
|
|
333
|
+
)
|
|
334
|
+
if wizard(context, env_file):
|
|
335
|
+
apply_options = [
|
|
336
|
+
f"Apply now {_dim('restarts changed services — brief downtime possible')}",
|
|
337
|
+
f"Skip {_dim('apply on next manual restart')}",
|
|
338
|
+
]
|
|
339
|
+
pick = _interactive_single(
|
|
340
|
+
"Apply changes?", apply_options, default=0, nav=False
|
|
341
|
+
)
|
|
342
|
+
if pick == 0:
|
|
343
|
+
_restart_for_setup(
|
|
344
|
+
context, env_file, include_api=True, old_profiles=old_profiles
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _cmd_scale_api_ui(env_file: Path) -> None:
|
|
349
|
+
"""Interactively change API/UI replica counts and regenerate nginx config."""
|
|
350
|
+
state = SetupState(env_file)
|
|
351
|
+
_section_api_ui_scaling(state)
|
|
352
|
+
|
|
353
|
+
env_data = read_env(env_file)
|
|
354
|
+
old_api = int(env_data.get("CONSOLE_API_REPLICAS", "1"))
|
|
355
|
+
old_ui = int(env_data.get("CONSOLE_UI_REPLICAS", "1"))
|
|
356
|
+
if state.api_replicas == old_api and state.ui_replicas == old_ui:
|
|
357
|
+
info("No changes")
|
|
358
|
+
return
|
|
359
|
+
|
|
360
|
+
set_env_value(env_file, "CONSOLE_API_REPLICAS", str(state.api_replicas))
|
|
361
|
+
set_env_value(env_file, "CONSOLE_UI_REPLICAS", str(state.ui_replicas))
|
|
362
|
+
set_env_value(env_file, "SHS_NGINX_PORT", str(state.nginx_port))
|
|
363
|
+
_write_override_and_nginx(state)
|
|
364
|
+
ok("Scaling updated")
|
|
365
|
+
if not _interactive_yn("Restart API + UI to apply?", default=True):
|
|
366
|
+
return
|
|
367
|
+
warn_header("This will restart API, UI, and nginx — workers will keep running")
|
|
368
|
+
_stop_api_ui_containers()
|
|
369
|
+
env_data = read_env(env_file)
|
|
370
|
+
run(
|
|
371
|
+
_apply_scale_flags(
|
|
372
|
+
compose_cmd(env_file) + ["up", "-d", "--remove-orphans"], env_data
|
|
373
|
+
),
|
|
374
|
+
timeout=120,
|
|
375
|
+
)
|
|
376
|
+
ok("Done")
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _submenu_services(context: str, env_file: Path) -> None:
|
|
380
|
+
"""Services submenu — daily ops: start/stop/restart all, health, logs, links."""
|
|
381
|
+
options = [
|
|
382
|
+
f"Start all {_dim('pull/build if needed, then start')}",
|
|
383
|
+
f"Stop all {_dim('stop all containers')}",
|
|
384
|
+
f"Restart all {_dim('restart every service')}",
|
|
385
|
+
f"Health {_dim('API + worker status')}",
|
|
386
|
+
f"View logs {_dim('recent logs')}",
|
|
387
|
+
f"Stream logs {_dim('follow live (Ctrl-C to stop)')}",
|
|
388
|
+
f"Links {_dim('UI, API, docs URLs')}",
|
|
389
|
+
]
|
|
390
|
+
idx = _interactive_single("Services", options, default=0)
|
|
391
|
+
if idx == 0:
|
|
392
|
+
cmd_start(context, env_file)
|
|
393
|
+
elif idx == 1:
|
|
394
|
+
cmd_stop(context, env_file)
|
|
395
|
+
elif idx == 2:
|
|
396
|
+
cmd_restart(context, env_file, None)
|
|
397
|
+
elif idx == 3:
|
|
398
|
+
cmd_health(context, env_file)
|
|
399
|
+
elif idx == 4:
|
|
400
|
+
_log_picker(context, env_file, follow=False)
|
|
401
|
+
elif idx == 5:
|
|
402
|
+
_log_picker(context, env_file, follow=True)
|
|
403
|
+
elif idx == 6:
|
|
404
|
+
cmd_links(context, env_file)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _build_log_options(
|
|
408
|
+
services: list[str],
|
|
409
|
+
) -> tuple[list[str], list[str | list[str] | None]]:
|
|
410
|
+
"""Build log menu labels and corresponding service targets.
|
|
411
|
+
|
|
412
|
+
When multiple api-N or ui-N instances are running, injects group entries
|
|
413
|
+
("All API", "All UI") so the user can tail all replicas at once.
|
|
414
|
+
|
|
415
|
+
Returns (labels, targets) where each target is:
|
|
416
|
+
None → all services
|
|
417
|
+
str → single service name
|
|
418
|
+
list[str] → group of service names passed together to docker compose logs
|
|
419
|
+
"""
|
|
420
|
+
labels: list[str] = ["All services"]
|
|
421
|
+
targets: list[str | list[str] | None] = [None]
|
|
422
|
+
|
|
423
|
+
api_replicas = [
|
|
424
|
+
s for s in services if s == "api" or (s.startswith("api-") and s[4:].isdigit())
|
|
425
|
+
]
|
|
426
|
+
ui_replicas = [
|
|
427
|
+
s for s in services if s == "ui" or (s.startswith("ui-") and s[3:].isdigit())
|
|
428
|
+
]
|
|
429
|
+
|
|
430
|
+
if len(api_replicas) > 1:
|
|
431
|
+
labels.append(f"All API {_dim(f'{len(api_replicas)} replicas')}")
|
|
432
|
+
targets.append(api_replicas)
|
|
433
|
+
if len(ui_replicas) > 1:
|
|
434
|
+
labels.append(f"All UI {_dim(f'{len(ui_replicas)} replicas')}")
|
|
435
|
+
targets.append(ui_replicas)
|
|
436
|
+
|
|
437
|
+
for svc in services:
|
|
438
|
+
labels.append(svc)
|
|
439
|
+
targets.append(svc)
|
|
440
|
+
|
|
441
|
+
return labels, targets
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _submenu_images(context: str, env_file: Path) -> None:
|
|
445
|
+
"""Images submenu - build, upgrade, rollback."""
|
|
446
|
+
options = [
|
|
447
|
+
f"Build {_dim('build configured images + pull postgres')}",
|
|
448
|
+
f"Upgrade {_dim('pull newer version from registry')}",
|
|
449
|
+
f"Rollback {_dim('switch to an older version')}",
|
|
450
|
+
]
|
|
451
|
+
idx = _interactive_single("Images", options, default=0)
|
|
452
|
+
if idx == 0:
|
|
453
|
+
cmd_build(env_file, None)
|
|
454
|
+
elif idx in (1, 2):
|
|
455
|
+
cmd_upgrade(context, env_file)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _owns_postgres(env_data: dict) -> bool:
|
|
459
|
+
"""True if SHS_DATABASE_URL points at a postgres instance we manage.
|
|
460
|
+
|
|
461
|
+
External DBs (CloudSQL, RDS, etc., or a CloudSQL Auth Proxy sidecar) own
|
|
462
|
+
their own backup tooling — console refuses backup/restore in those cases
|
|
463
|
+
rather than producing dumps that can't be restored back to the source.
|
|
464
|
+
"""
|
|
465
|
+
url = env_data.get("SHS_DATABASE_URL", "")
|
|
466
|
+
m = re.search(r"@([^:/]+)", url)
|
|
467
|
+
if not m:
|
|
468
|
+
return False
|
|
469
|
+
host = m.group(1).lower()
|
|
470
|
+
return host in ("postgres", "localhost", "127.0.0.1")
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _submenu_backup(context: str, env_file: Path) -> None:
|
|
474
|
+
"""Archive submenu - backup and restore."""
|
|
475
|
+
if not _owns_postgres(read_env(env_file)):
|
|
476
|
+
warn("This Studio uses an external database (CloudSQL, RDS, etc.).")
|
|
477
|
+
info("Use your database provider's backup and restore tooling.")
|
|
478
|
+
info(
|
|
479
|
+
_dim(
|
|
480
|
+
"(Org file backup/restore is not yet supported for external DB setups.)"
|
|
481
|
+
)
|
|
482
|
+
)
|
|
483
|
+
return
|
|
484
|
+
|
|
485
|
+
options = [
|
|
486
|
+
f"Backup all {_dim('database + .env + org files (potentially large files)')}",
|
|
487
|
+
f"Backup DB {_dim('pg_dump + .env')}",
|
|
488
|
+
f"Backup orgs {_dim('organization files only')}",
|
|
489
|
+
f"Restore DB {_dim('restore database from backup')}",
|
|
490
|
+
f"Restore orgs {_dim('restore organization files from backup')}",
|
|
491
|
+
]
|
|
492
|
+
br_idx = _interactive_single("Backup", options, default=0)
|
|
493
|
+
if br_idx == 0:
|
|
494
|
+
cmd_backup(context, env_file, what="all")
|
|
495
|
+
elif br_idx == 1:
|
|
496
|
+
cmd_backup(context, env_file, what="db")
|
|
497
|
+
elif br_idx == 2:
|
|
498
|
+
cmd_backup(context, env_file, what="orgs")
|
|
499
|
+
elif br_idx == 3:
|
|
500
|
+
db_file = _pick_db_file(context, env_file)
|
|
501
|
+
if db_file:
|
|
502
|
+
cmd_restore_db(context, env_file, db_file)
|
|
503
|
+
elif br_idx == 4:
|
|
504
|
+
path = _prompt("Backup path (enter for latest)", "")
|
|
505
|
+
if _interactive_yn(
|
|
506
|
+
"Restore org files? This will overwrite current files.", default=False
|
|
507
|
+
):
|
|
508
|
+
cmd_restore(context, env_file, path or None, what="orgs")
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def cmd_db_role(context: str, env_file: Path) -> None:
|
|
512
|
+
"""Show/enable the restricted runtime DB role (shs_app cutover).
|
|
513
|
+
|
|
514
|
+
Console only writes SHS_DATABASE_APP_URL — the API's bootstrap provisions
|
|
515
|
+
the role, grants, and RLS posture from it on every boot. SHS_DATABASE_URL
|
|
516
|
+
stays privileged; console's own psql/dump/restore tooling keeps using it.
|
|
517
|
+
"""
|
|
518
|
+
env_data = read_env(env_file)
|
|
519
|
+
|
|
520
|
+
if _app_db_url(env_data):
|
|
521
|
+
ok("Restricted DB role is configured (SHS_DATABASE_APP_URL is set).")
|
|
522
|
+
info("The API provisions the role and re-applies RLS policies on every boot.")
|
|
523
|
+
info("Check Services → Health for the live posture.")
|
|
524
|
+
return
|
|
525
|
+
|
|
526
|
+
db_url = env_data.get("SHS_DATABASE_URL", "") or os.environ.get(
|
|
527
|
+
"SHS_DATABASE_URL", ""
|
|
528
|
+
)
|
|
529
|
+
if not db_url:
|
|
530
|
+
error("No SHS_DATABASE_URL found — configure the database first.")
|
|
531
|
+
return
|
|
532
|
+
|
|
533
|
+
warn_header("The API currently connects as the privileged DB role — RLS is inert")
|
|
534
|
+
info("Enabling writes SHS_DATABASE_APP_URL (role shs_app) next to SHS_DATABASE_URL.")
|
|
535
|
+
info("The API provisions the role itself on next boot; console runs no SQL.")
|
|
536
|
+
info(_dim("Requires a studio image with restricted-role support; older images ignore it."))
|
|
537
|
+
if not _owns_postgres(env_data):
|
|
538
|
+
warn(
|
|
539
|
+
"External database: the SHS_DATABASE_URL role must have CREATEROLE, "
|
|
540
|
+
"or the API's boot fails closed while provisioning shs_app."
|
|
541
|
+
)
|
|
542
|
+
print()
|
|
543
|
+
|
|
544
|
+
if not _interactive_yn("Enable the restricted DB role?", default=False):
|
|
545
|
+
info("Skipped — no changes made.")
|
|
546
|
+
return
|
|
547
|
+
|
|
548
|
+
set_env_value(env_file, "SHS_DATABASE_APP_URL", derive_app_db_url(db_url))
|
|
549
|
+
ok("SHS_DATABASE_APP_URL written to .env")
|
|
550
|
+
|
|
551
|
+
if context == "host":
|
|
552
|
+
env_data = read_env(env_file)
|
|
553
|
+
if _interactive_yn(
|
|
554
|
+
"Apply now? Restarts services so the API boots on the restricted role.",
|
|
555
|
+
default=True,
|
|
556
|
+
):
|
|
557
|
+
run(
|
|
558
|
+
_apply_scale_flags(
|
|
559
|
+
compose_cmd(env_file) + ["up", "-d", "--remove-orphans"], env_data
|
|
560
|
+
),
|
|
561
|
+
timeout=120,
|
|
562
|
+
)
|
|
563
|
+
ok("Services restarted — check Services → Health for the RLS posture.")
|
|
564
|
+
else:
|
|
565
|
+
info("Takes effect on the next restart of the API.")
|
|
566
|
+
else:
|
|
567
|
+
warn(
|
|
568
|
+
"Restart this container from the host to apply — provisioning runs in "
|
|
569
|
+
"the container entrypoint, not under supervisord."
|
|
570
|
+
)
|
|
571
|
+
print(f" {_bold('docker restart <container>')} {_dim('(or stop/start the pod on RunPod)')}")
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def _submenu_advanced(context: str, env_file: Path) -> None:
|
|
575
|
+
"""Advanced submenu — scale API/UI, per-service ops, Cloudflare ops."""
|
|
576
|
+
options = [
|
|
577
|
+
f"Scale API/UI {_dim('set replica count, enable/disable nginx LB')}",
|
|
578
|
+
f"Start one {_dim('start a stopped service')}",
|
|
579
|
+
f"Stop one {_dim('stop a running service')}",
|
|
580
|
+
f"Restart one {_dim('restart a single service')}",
|
|
581
|
+
f"Show .env {_dim('current configuration values')}",
|
|
582
|
+
f"DB role {_dim('restricted runtime role (RLS) — status · enable')}",
|
|
583
|
+
f"Cloudflare {_dim('tunnel · routes · IP rules · Access')}",
|
|
584
|
+
]
|
|
585
|
+
idx = _interactive_single("Advanced", options, default=0)
|
|
586
|
+
if idx == 0:
|
|
587
|
+
_cmd_scale_api_ui(env_file)
|
|
588
|
+
elif idx in (1, 2, 3):
|
|
589
|
+
# Service names (not container names): each action targets a whole
|
|
590
|
+
# service type, restarting/stopping all its replicas at once.
|
|
591
|
+
services = _get_running_services(context, env_file)
|
|
592
|
+
if not services:
|
|
593
|
+
warn("No services found")
|
|
594
|
+
else:
|
|
595
|
+
action = {1: "Start", 2: "Stop", 3: "Restart"}[idx]
|
|
596
|
+
pick = _interactive_single(f"{action} which service?", services, default=0)
|
|
597
|
+
svc = services[pick]
|
|
598
|
+
if idx == 1:
|
|
599
|
+
run(compose_cmd(env_file) + ["start", svc], timeout=60)
|
|
600
|
+
elif idx == 2:
|
|
601
|
+
run(compose_cmd(env_file) + ["stop", svc], timeout=60)
|
|
602
|
+
else:
|
|
603
|
+
cmd_restart(context, env_file, svc)
|
|
604
|
+
elif idx == 4:
|
|
605
|
+
cmd_show_config(context, env_file)
|
|
606
|
+
elif idx == 5:
|
|
607
|
+
cmd_db_role(context, env_file)
|
|
608
|
+
elif idx == 6:
|
|
609
|
+
_submenu_cloudflare(context, env_file)
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def _submenu_cloudflare(context: str, env_file: Path) -> None:
|
|
613
|
+
"""Cloudflare ops submenu."""
|
|
614
|
+
from .cloudflare.cf_wizard import cf_full_setup, update_domain, update_ip_rules
|
|
615
|
+
|
|
616
|
+
env_data = read_env(env_file)
|
|
617
|
+
tunnel_token = env_data.get("CLOUDFLARE_TUNNEL_TOKEN", "")
|
|
618
|
+
api_token = env_data.get("CLOUDFLARE_API_TOKEN", "")
|
|
619
|
+
domain = env_data.get("SHS_PUBLIC_BASE_URL", "")
|
|
620
|
+
api_domain = env_data.get("CONSOLE_PUBLIC_API_BASE_URL", "")
|
|
621
|
+
ip_mode = env_data.get("CONSOLE_IP_RESTRICT_MODE", "none")
|
|
622
|
+
profiles = env_data.get("COMPOSE_PROFILES", "")
|
|
623
|
+
is_docker_mode = bool(tunnel_token) or "cloudflared" in profiles
|
|
624
|
+
is_split = api_domain.startswith("https://") and api_domain != domain
|
|
625
|
+
|
|
626
|
+
options = [
|
|
627
|
+
f"Full setup (API) {_dim('create tunnel + routes + Access app + IP policy')}",
|
|
628
|
+
f"Status {_dim('token, domain, tunnel running?')}",
|
|
629
|
+
f"Test {_dim('curl public URL health endpoint')}",
|
|
630
|
+
f"Update IP rules {_dim('add/remove IPs from bypass policy')}",
|
|
631
|
+
f"Update token {_dim('change tunnel token (manual)')}",
|
|
632
|
+
f"Update domain {_dim('change public domain + cleanup stale DNS')}",
|
|
633
|
+
f"Start tunnel {_dim('compose up cloudflared')}",
|
|
634
|
+
f"Stop tunnel {_dim('compose stop cloudflared')}",
|
|
635
|
+
f"Tunnel logs {_dim('follow cloudflared container logs')}",
|
|
636
|
+
]
|
|
637
|
+
idx = _interactive_single("Cloudflare", options, default=0)
|
|
638
|
+
|
|
639
|
+
if idx == 0:
|
|
640
|
+
cf_full_setup(env_file)
|
|
641
|
+
|
|
642
|
+
elif idx == 1:
|
|
643
|
+
print()
|
|
644
|
+
print(
|
|
645
|
+
f" {_bold('API token:')} "
|
|
646
|
+
f"{mask_value('CLOUDFLARE_API_TOKEN', api_token) if api_token else _dim('not set')}"
|
|
647
|
+
)
|
|
648
|
+
print(
|
|
649
|
+
f" {_bold('Tunnel token:')} "
|
|
650
|
+
f"{mask_value('CLOUDFLARE_TUNNEL_TOKEN', tunnel_token) if tunnel_token else _dim('not set')}"
|
|
651
|
+
)
|
|
652
|
+
print(
|
|
653
|
+
f" {_bold('Tunnel ID:')} {env_data.get('CLOUDFLARE_TUNNEL_ID', _dim('not set'))}"
|
|
654
|
+
)
|
|
655
|
+
if is_split:
|
|
656
|
+
print(f" {_bold('UI domain:')} {domain}")
|
|
657
|
+
print(f" {_bold('API domain:')} {api_domain}")
|
|
658
|
+
print(
|
|
659
|
+
f" {_bold('UI app:')} {env_data.get('CLOUDFLARE_ACCESS_APP_ID', _dim('not set'))}"
|
|
660
|
+
)
|
|
661
|
+
print(
|
|
662
|
+
f" {_bold('API app:')} {env_data.get('CLOUDFLARE_ACCESS_API_APP_ID', _dim('not set'))}"
|
|
663
|
+
)
|
|
664
|
+
else:
|
|
665
|
+
print(f" {_bold('Domain:')} {domain if domain else _dim('not set')}")
|
|
666
|
+
print(
|
|
667
|
+
f" {_bold('Access app:')} {env_data.get('CLOUDFLARE_ACCESS_APP_ID', _dim('not set'))}"
|
|
668
|
+
)
|
|
669
|
+
print(f" {_bold('IP restrict:')} {ip_mode}")
|
|
670
|
+
if is_docker_mode:
|
|
671
|
+
rc, out = run_quiet(
|
|
672
|
+
compose_cmd(env_file) + ["ps", "cloudflared", "--format", "{{.State}}"],
|
|
673
|
+
timeout=10,
|
|
674
|
+
)
|
|
675
|
+
if rc == 0 and "running" in out.lower():
|
|
676
|
+
tunnel_status = _green("running")
|
|
677
|
+
else:
|
|
678
|
+
tunnel_status = _yellow(
|
|
679
|
+
"not running (use Start tunnel to bring it up)"
|
|
680
|
+
)
|
|
681
|
+
print(f" {_bold('cloudflared:')} {tunnel_status}")
|
|
682
|
+
else:
|
|
683
|
+
print(f" {_bold('cloudflared:')} {_dim('not configured')}")
|
|
684
|
+
print()
|
|
685
|
+
|
|
686
|
+
elif idx == 2:
|
|
687
|
+
if not domain or not domain.startswith("https://"):
|
|
688
|
+
warn("No public domain configured")
|
|
689
|
+
return
|
|
690
|
+
base = domain.rstrip("/")
|
|
691
|
+
routes = [
|
|
692
|
+
("UI health (api)", f"{base}/health"),
|
|
693
|
+
("UI WebSocket", f"{base}/ws"),
|
|
694
|
+
("UI", base),
|
|
695
|
+
]
|
|
696
|
+
if is_split:
|
|
697
|
+
api_base = api_domain.rstrip("/")
|
|
698
|
+
routes.extend(
|
|
699
|
+
[
|
|
700
|
+
("API health", f"{api_base}/health"),
|
|
701
|
+
("API WebSocket", f"{api_base}/ws"),
|
|
702
|
+
]
|
|
703
|
+
)
|
|
704
|
+
for name, url in routes:
|
|
705
|
+
curl_cmd = [
|
|
706
|
+
"curl",
|
|
707
|
+
"-sf",
|
|
708
|
+
"--max-time",
|
|
709
|
+
"10",
|
|
710
|
+
"-o",
|
|
711
|
+
"/dev/null",
|
|
712
|
+
"-w",
|
|
713
|
+
"%{http_code}",
|
|
714
|
+
url,
|
|
715
|
+
]
|
|
716
|
+
rc, code = run_quiet(curl_cmd, timeout=15)
|
|
717
|
+
if rc == 0 and code and code[0] in ("2", "3"):
|
|
718
|
+
ok(f"{name:14s} {url} → {code}")
|
|
719
|
+
else:
|
|
720
|
+
warn(f"{name:14s} {url} → failed ({code or 'no response'})")
|
|
721
|
+
|
|
722
|
+
elif idx == 3:
|
|
723
|
+
update_ip_rules(env_file)
|
|
724
|
+
|
|
725
|
+
elif idx == 4:
|
|
726
|
+
import getpass
|
|
727
|
+
|
|
728
|
+
try:
|
|
729
|
+
new_token = getpass.getpass(f"▸ New tunnel token: ").strip()
|
|
730
|
+
except (EOFError, KeyboardInterrupt):
|
|
731
|
+
print()
|
|
732
|
+
return
|
|
733
|
+
if new_token:
|
|
734
|
+
set_env_value(env_file, "CLOUDFLARE_TUNNEL_TOKEN", new_token)
|
|
735
|
+
if "cloudflared" not in profiles:
|
|
736
|
+
new_profiles = (profiles + ",cloudflared").strip(",")
|
|
737
|
+
set_env_value(env_file, "COMPOSE_PROFILES", new_profiles)
|
|
738
|
+
ok("Token updated")
|
|
739
|
+
if _interactive_yn("Restart tunnel to apply?", default=True):
|
|
740
|
+
base_cmd = compose_cmd(env_file)
|
|
741
|
+
run_quiet(base_cmd + ["rm", "-sf", "cloudflared"], timeout=30)
|
|
742
|
+
run(base_cmd + ["up", "-d", "cloudflared"], timeout=60)
|
|
743
|
+
else:
|
|
744
|
+
warn("No token entered")
|
|
745
|
+
|
|
746
|
+
elif idx == 5:
|
|
747
|
+
if api_token:
|
|
748
|
+
update_domain(env_file)
|
|
749
|
+
else:
|
|
750
|
+
new_domain = _prompt(
|
|
751
|
+
"New public domain (e.g. https://app.yourdomain.com)", domain
|
|
752
|
+
)
|
|
753
|
+
while new_domain and not new_domain.startswith("https://"):
|
|
754
|
+
warn("Must start with https://")
|
|
755
|
+
new_domain = _prompt("New public domain", domain)
|
|
756
|
+
if new_domain:
|
|
757
|
+
set_env_value(env_file, "SHS_PUBLIC_BASE_URL", new_domain.rstrip("/"))
|
|
758
|
+
from .commands_container import _sync_derived_urls
|
|
759
|
+
|
|
760
|
+
_sync_derived_urls(env_file)
|
|
761
|
+
ok(f"Domain updated to {new_domain.rstrip('/')}")
|
|
762
|
+
warn("API token not set — DNS records and Access app not updated")
|
|
763
|
+
else:
|
|
764
|
+
warn("No domain entered")
|
|
765
|
+
|
|
766
|
+
elif idx == 6:
|
|
767
|
+
if not tunnel_token:
|
|
768
|
+
warn("No tunnel token set. Run 'Full setup (API)' first.")
|
|
769
|
+
return
|
|
770
|
+
if "cloudflared" not in profiles:
|
|
771
|
+
new_profiles = (profiles + ",cloudflared").strip(",")
|
|
772
|
+
set_env_value(env_file, "COMPOSE_PROFILES", new_profiles)
|
|
773
|
+
info("Starting cloudflared...")
|
|
774
|
+
run(compose_cmd(env_file) + ["up", "-d", "cloudflared"], timeout=60)
|
|
775
|
+
ok("Tunnel started")
|
|
776
|
+
|
|
777
|
+
elif idx == 7:
|
|
778
|
+
info("Stopping cloudflared...")
|
|
779
|
+
run_quiet(compose_cmd(env_file) + ["stop", "cloudflared"], timeout=30)
|
|
780
|
+
ok("Tunnel stopped")
|
|
781
|
+
|
|
782
|
+
elif idx == 8:
|
|
783
|
+
info("Streaming cloudflared logs (Ctrl-C to stop)")
|
|
784
|
+
try:
|
|
785
|
+
run(
|
|
786
|
+
compose_cmd(env_file) + ["logs", "-f", "cloudflared"],
|
|
787
|
+
timeout=None,
|
|
788
|
+
check=False,
|
|
789
|
+
)
|
|
790
|
+
except KeyboardInterrupt:
|
|
791
|
+
print()
|
|
792
|
+
|
|
793
|
+
|
|
794
|
+
# ---------------------------------------------------------------------------
|
|
795
|
+
# Subcommands
|
|
796
|
+
# ---------------------------------------------------------------------------
|
|
797
|
+
|
|
798
|
+
|
|
799
|
+
def cmd_build(env_file: Path, images: list[str] | None, confirm: bool = True) -> None:
|
|
800
|
+
"""Build Docker images from local source + pull third-party."""
|
|
801
|
+
repo = _find_repo_root(env_file)
|
|
802
|
+
if repo is None:
|
|
803
|
+
warn("Building from source requires CONSOLE_REPO_ROOT to be set.")
|
|
804
|
+
warn("Add it to ~/.studio/.env or export it in your shell:")
|
|
805
|
+
warn(" CONSOLE_REPO_ROOT=/path/to/studio")
|
|
806
|
+
warn("In a standard install, Studio images are pulled from the registry.")
|
|
807
|
+
return
|
|
808
|
+
|
|
809
|
+
env_data = read_env(env_file) if env_file.exists() else {}
|
|
810
|
+
tag = env_data.get("SHS_STUDIO_VERSION", "latest")
|
|
811
|
+
prefix = "ghcr.io/selfhosthub"
|
|
812
|
+
|
|
813
|
+
# Pull third-party images (postgres etc.)
|
|
814
|
+
for img in THIRD_PARTY_IMAGES:
|
|
815
|
+
rc, _ = run_quiet(["docker", "image", "inspect", img])
|
|
816
|
+
if rc != 0:
|
|
817
|
+
info(f"Pulling {img}...")
|
|
818
|
+
run(["docker", "pull", img], timeout=120)
|
|
819
|
+
ok(f"{img}")
|
|
820
|
+
else:
|
|
821
|
+
ok(f"{img} (already local)")
|
|
822
|
+
|
|
823
|
+
# Determine which images to build
|
|
824
|
+
if images:
|
|
825
|
+
# Normalize: accept "api" or "studio-api", commas or spaces
|
|
826
|
+
raw: list[str] = []
|
|
827
|
+
for img in images:
|
|
828
|
+
raw.extend(part.strip() for part in img.split(",") if part.strip())
|
|
829
|
+
targets: list[str] = []
|
|
830
|
+
for img in raw:
|
|
831
|
+
canonical = img if img.startswith("studio-") else f"studio-{img}"
|
|
832
|
+
if canonical not in IMAGE_BUILD_CONFIG:
|
|
833
|
+
fatal(
|
|
834
|
+
f"Unknown image: {img}. Available: {', '.join(IMAGE_BUILD_CONFIG)}"
|
|
835
|
+
)
|
|
836
|
+
targets.append(canonical)
|
|
837
|
+
else:
|
|
838
|
+
# Build only what was configured
|
|
839
|
+
targets = _images_from_env(env_file)
|
|
840
|
+
|
|
841
|
+
total = len(targets)
|
|
842
|
+
|
|
843
|
+
if confirm:
|
|
844
|
+
info(f"Images to build ({total}): {', '.join(targets)} [tag: {tag}]")
|
|
845
|
+
info(f"Source: {repo}")
|
|
846
|
+
warn("This may take several minutes.")
|
|
847
|
+
if not _interactive_yn("Build now?", default=True, nav=False):
|
|
848
|
+
info("Cancelled.")
|
|
849
|
+
return
|
|
850
|
+
|
|
851
|
+
info(f"Building {total} image(s), tag={tag}")
|
|
852
|
+
print()
|
|
853
|
+
|
|
854
|
+
for i, name in enumerate(targets, 1):
|
|
855
|
+
dockerfile, context_dir = IMAGE_BUILD_CONFIG[name]
|
|
856
|
+
full_tag = f"{prefix}/{name}:{tag}"
|
|
857
|
+
print(f" [{i}/{total}] {_bold(full_tag)}")
|
|
858
|
+
env_data = read_env(env_file)
|
|
859
|
+
build_args = []
|
|
860
|
+
if name == "ui":
|
|
861
|
+
public_api = env_data.get("SHS_PUBLIC_API_URL", "") or env_data.get(
|
|
862
|
+
"SHS_API_BASE_URL", ""
|
|
863
|
+
)
|
|
864
|
+
for var in (
|
|
865
|
+
"SHS_API_BASE_URL",
|
|
866
|
+
"SHS_WS_URL",
|
|
867
|
+
"NEXT_PUBLIC_WS_URL",
|
|
868
|
+
):
|
|
869
|
+
val = env_data.get(var, "")
|
|
870
|
+
if val:
|
|
871
|
+
build_args += ["--build-arg", f"{var}={val}"]
|
|
872
|
+
if public_api:
|
|
873
|
+
build_args += ["--build-arg", f"NEXT_PUBLIC_API_URL={public_api}"]
|
|
874
|
+
try:
|
|
875
|
+
run(
|
|
876
|
+
[
|
|
877
|
+
"docker",
|
|
878
|
+
"build",
|
|
879
|
+
"--build-context",
|
|
880
|
+
f"contracts={repo / 'contracts'}",
|
|
881
|
+
"-f",
|
|
882
|
+
str(repo / dockerfile),
|
|
883
|
+
"-t",
|
|
884
|
+
full_tag,
|
|
885
|
+
*build_args,
|
|
886
|
+
str(repo / context_dir),
|
|
887
|
+
],
|
|
888
|
+
timeout=600,
|
|
889
|
+
)
|
|
890
|
+
except Exception as e:
|
|
891
|
+
error(f"Build failed for {name}: {e}")
|
|
892
|
+
return
|
|
893
|
+
ok(f" {full_tag}")
|
|
894
|
+
|
|
895
|
+
print()
|
|
896
|
+
ok(f"All {total} image(s) built")
|
|
897
|
+
|
|
898
|
+
# Offer to restart running services so they pick up the new images
|
|
899
|
+
base = compose_cmd(env_file)
|
|
900
|
+
rc, out = run_quiet(base + ["ps", "--format", "{{.Names}}"])
|
|
901
|
+
if rc == 0 and out.strip():
|
|
902
|
+
running = [line.strip() for line in out.strip().splitlines() if line.strip()]
|
|
903
|
+
if running:
|
|
904
|
+
warn_header(
|
|
905
|
+
"Restart required to apply new images — Studio will be briefly unavailable"
|
|
906
|
+
)
|
|
907
|
+
info(f"Running services: {', '.join(running)}")
|
|
908
|
+
if _interactive_yn("Restart now?", default=True, nav=False):
|
|
909
|
+
run(base + ["up", "-d", "--force-recreate"], timeout=120)
|
|
910
|
+
ok("Services restarted")
|
|
911
|
+
else:
|
|
912
|
+
info("Images built. Restart manually when ready: Services → Restart")
|
|
913
|
+
else:
|
|
914
|
+
info("No running services to restart")
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
def _get_repo_version(env_file: Path | None = None) -> str | None:
|
|
918
|
+
"""Read SHS_STUDIO_VERSION from the repo's deploy/.env.example."""
|
|
919
|
+
repo = _find_repo_root(env_file)
|
|
920
|
+
if not repo:
|
|
921
|
+
return None
|
|
922
|
+
example = repo / "deploy" / ".env.example"
|
|
923
|
+
if not example.exists():
|
|
924
|
+
return None
|
|
925
|
+
for line in example.read_text().splitlines():
|
|
926
|
+
line = line.strip()
|
|
927
|
+
if line.startswith("SHS_STUDIO_VERSION="):
|
|
928
|
+
return line.partition("=")[2].strip()
|
|
929
|
+
return None
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
def _get_latest_registry_version() -> str | None:
|
|
933
|
+
"""Get the latest semver tag from GHCR."""
|
|
934
|
+
versions = _fetch_available_versions(limit=1)
|
|
935
|
+
return versions[0] if versions else None
|
|
936
|
+
|
|
937
|
+
|
|
938
|
+
def cmd_start(context: str, env_file: Path) -> None:
|
|
939
|
+
"""Start services. Builds missing images automatically."""
|
|
940
|
+
if context == "host":
|
|
941
|
+
_validate_env(env_file)
|
|
942
|
+
env_data = read_env(env_file)
|
|
943
|
+
|
|
944
|
+
target_tag = env_data.get("SHS_STUDIO_VERSION", "")
|
|
945
|
+
if mv.check_and_block(
|
|
946
|
+
env_file, mv.parse_target_major(target_tag), "install", context, target_tag
|
|
947
|
+
):
|
|
948
|
+
return False
|
|
949
|
+
|
|
950
|
+
missing = _missing_images(env_file)
|
|
951
|
+
if missing:
|
|
952
|
+
if _find_repo_root(env_file):
|
|
953
|
+
info(f"Building missing images: {', '.join(missing)}")
|
|
954
|
+
cmd_build(env_file, missing, confirm=False)
|
|
955
|
+
else:
|
|
956
|
+
version = env_data.get("SHS_STUDIO_VERSION", "")
|
|
957
|
+
info(
|
|
958
|
+
f"Pulling missing images from registry"
|
|
959
|
+
+ (f" (v{version})" if version else "")
|
|
960
|
+
+ f": {', '.join(missing)}"
|
|
961
|
+
)
|
|
962
|
+
try:
|
|
963
|
+
run(compose_cmd(env_file) + ["pull"], timeout=600)
|
|
964
|
+
except Exception:
|
|
965
|
+
error("The download was interrupted before it finished.")
|
|
966
|
+
warn("Run Start again — it should pick up where it left off.")
|
|
967
|
+
return False
|
|
968
|
+
ok("Images pulled")
|
|
969
|
+
|
|
970
|
+
# Ensure nginx:alpine is available (always used)
|
|
971
|
+
rc_ng, _ = run_quiet(["docker", "image", "inspect", "nginx:alpine"])
|
|
972
|
+
if rc_ng != 0:
|
|
973
|
+
info("Pulling nginx:alpine...")
|
|
974
|
+
run(["docker", "pull", "nginx:alpine"], timeout=120)
|
|
975
|
+
ok("nginx:alpine")
|
|
976
|
+
|
|
977
|
+
info("Starting Studio via Docker Compose...")
|
|
978
|
+
up_cmd = _apply_scale_flags(
|
|
979
|
+
compose_cmd(env_file) + ["up", "-d", "--remove-orphans"], env_data
|
|
980
|
+
)
|
|
981
|
+
|
|
982
|
+
try:
|
|
983
|
+
run(up_cmd, timeout=120)
|
|
984
|
+
except Exception as e:
|
|
985
|
+
error(f"Docker Compose failed: {e}")
|
|
986
|
+
warn(
|
|
987
|
+
"If images are missing, use Images → Upgrade to pull from the registry."
|
|
988
|
+
)
|
|
989
|
+
warn("If running from source, use Images → Build to build locally.")
|
|
990
|
+
return False
|
|
991
|
+
|
|
992
|
+
# Health check always via nginx
|
|
993
|
+
import time
|
|
994
|
+
|
|
995
|
+
nginx_port = env_data.get("SHS_NGINX_PORT", "80")
|
|
996
|
+
health_url = f"http://localhost:{nginx_port}/health"
|
|
997
|
+
|
|
998
|
+
info("Waiting for API...")
|
|
999
|
+
healthy = False
|
|
1000
|
+
for _ in range(60):
|
|
1001
|
+
rc, _ = run_quiet(["curl", "-sf", health_url])
|
|
1002
|
+
if rc == 0:
|
|
1003
|
+
healthy = True
|
|
1004
|
+
break
|
|
1005
|
+
time.sleep(2)
|
|
1006
|
+
|
|
1007
|
+
if healthy:
|
|
1008
|
+
ok("Studio started")
|
|
1009
|
+
else:
|
|
1010
|
+
warn(
|
|
1011
|
+
"API not responding yet - check logs: studio-console → Configure → View logs"
|
|
1012
|
+
)
|
|
1013
|
+
|
|
1014
|
+
# First boot: create super admin account directly in Postgres
|
|
1015
|
+
if healthy:
|
|
1016
|
+
_bootstrap_first_admin(env_file)
|
|
1017
|
+
|
|
1018
|
+
return healthy
|
|
1019
|
+
else:
|
|
1020
|
+
info("Starting all services via supervisorctl...")
|
|
1021
|
+
run(["supervisorctl", "start", "all"], timeout=30)
|
|
1022
|
+
ok("All services started")
|
|
1023
|
+
return True
|
|
1024
|
+
|
|
1025
|
+
|
|
1026
|
+
def cmd_stop(context: str, env_file: Path) -> None:
|
|
1027
|
+
"""Stop services."""
|
|
1028
|
+
if context == "host":
|
|
1029
|
+
_validate_env(env_file)
|
|
1030
|
+
info("Stopping Studio...")
|
|
1031
|
+
run(compose_cmd(env_file) + ["stop"], timeout=60)
|
|
1032
|
+
ok("Studio stopped")
|
|
1033
|
+
else:
|
|
1034
|
+
info("Stopping all services...")
|
|
1035
|
+
run(["supervisorctl", "stop", "all"], timeout=30)
|
|
1036
|
+
ok("All services stopped")
|
|
1037
|
+
|
|
1038
|
+
|
|
1039
|
+
def _api_service_name(env_data: dict) -> str:
|
|
1040
|
+
"""Return the running API service name: api-1 in multi-replica mode, api otherwise."""
|
|
1041
|
+
return "api-1" if int(env_data.get("CONSOLE_API_REPLICAS", "1")) > 1 else "api"
|
|
1042
|
+
|
|
1043
|
+
|
|
1044
|
+
class _BootstrapPlan:
|
|
1045
|
+
"""Exec plumbing for admin bootstrap, per deployment shape."""
|
|
1046
|
+
|
|
1047
|
+
def __init__(self, base, api_svc, pg_svc, api_base, exec_flags):
|
|
1048
|
+
self.base = base
|
|
1049
|
+
self.api_svc = api_svc
|
|
1050
|
+
self.pg_svc = pg_svc
|
|
1051
|
+
self.api_base = api_base
|
|
1052
|
+
self.exec_flags = exec_flags # compose needs -T; docker exec rejects it
|
|
1053
|
+
|
|
1054
|
+
|
|
1055
|
+
def _split_plan(env_file: Path, env_data: dict) -> "_BootstrapPlan":
|
|
1056
|
+
return _BootstrapPlan(
|
|
1057
|
+
base=compose_cmd(env_file),
|
|
1058
|
+
api_svc=_api_service_name(env_data),
|
|
1059
|
+
pg_svc="postgres",
|
|
1060
|
+
api_base=f"http://localhost:{env_data.get('SHS_NGINX_PORT', '80')}/api/v1",
|
|
1061
|
+
exec_flags=["-T"],
|
|
1062
|
+
)
|
|
1063
|
+
|
|
1064
|
+
|
|
1065
|
+
def _full_plan(container: str) -> "_BootstrapPlan":
|
|
1066
|
+
# base omits "exec"; helpers append it.
|
|
1067
|
+
return _BootstrapPlan(
|
|
1068
|
+
base=["docker"],
|
|
1069
|
+
api_svc=container,
|
|
1070
|
+
pg_svc=container,
|
|
1071
|
+
api_base="http://localhost:8000/api/v1",
|
|
1072
|
+
exec_flags=[],
|
|
1073
|
+
)
|
|
1074
|
+
|
|
1075
|
+
|
|
1076
|
+
def _core_plan(
|
|
1077
|
+
container: str, pg_container: str, nginx_port: int | str = 80
|
|
1078
|
+
) -> "_BootstrapPlan":
|
|
1079
|
+
# Core's API runs in `container`, but Postgres is an external sidecar
|
|
1080
|
+
# (`pg_container`) — so password hashing execs into the API container while
|
|
1081
|
+
# every psql runs against the sidecar. base omits "exec"; helpers append it.
|
|
1082
|
+
# Core launches with publish_internal=False, so port 8000 is not published:
|
|
1083
|
+
# reach the API through the front door.
|
|
1084
|
+
return _BootstrapPlan(
|
|
1085
|
+
base=["docker"],
|
|
1086
|
+
api_svc=container,
|
|
1087
|
+
pg_svc=pg_container,
|
|
1088
|
+
api_base=f"http://localhost:{nginx_port}/api/v1",
|
|
1089
|
+
exec_flags=[],
|
|
1090
|
+
)
|
|
1091
|
+
|
|
1092
|
+
|
|
1093
|
+
def _read_env_for_bootstrap(env_file: Path, plan: "_BootstrapPlan | None") -> dict:
|
|
1094
|
+
"""Read .env for the bootstrap flow, from the container for exec plans.
|
|
1095
|
+
|
|
1096
|
+
full/core write .env root-owned 0600 *inside* the container, so a host user
|
|
1097
|
+
(non-root on Ubuntu/runpod/vast; UID-namespaced on Mac/Win) can't read it.
|
|
1098
|
+
docker-exec plans (base == ["docker"]) cat it from the API container; split
|
|
1099
|
+
reads the user-owned host file directly.
|
|
1100
|
+
"""
|
|
1101
|
+
if plan is not None and plan.base == ["docker"]:
|
|
1102
|
+
rc, out = run_quiet(
|
|
1103
|
+
["docker", "exec", plan.api_svc, "cat", "/workspace/.env"], timeout=10
|
|
1104
|
+
)
|
|
1105
|
+
if rc != 0:
|
|
1106
|
+
return {}
|
|
1107
|
+
result: dict[str, str] = {}
|
|
1108
|
+
for line in out.splitlines():
|
|
1109
|
+
line = line.strip()
|
|
1110
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
1111
|
+
continue
|
|
1112
|
+
key, _, value = line.partition("=")
|
|
1113
|
+
result[key.strip()] = value.strip()
|
|
1114
|
+
return result
|
|
1115
|
+
return read_env(env_file)
|
|
1116
|
+
|
|
1117
|
+
|
|
1118
|
+
def _unset_env_for_bootstrap(
|
|
1119
|
+
env_file: Path, keys: list[str], plan: "_BootstrapPlan | None"
|
|
1120
|
+
) -> None:
|
|
1121
|
+
"""Strip keys from .env, inside the container for exec plans (root-owned file)."""
|
|
1122
|
+
if plan is not None and plan.base == ["docker"]:
|
|
1123
|
+
pattern = "|".join(k for k in keys)
|
|
1124
|
+
run_quiet(
|
|
1125
|
+
[
|
|
1126
|
+
"docker", "exec", plan.api_svc, "sh", "-c",
|
|
1127
|
+
f"grep -Ev '^({pattern})=' /workspace/.env > /workspace/.env.tmp "
|
|
1128
|
+
f"&& mv /workspace/.env.tmp /workspace/.env && chmod 0600 /workspace/.env",
|
|
1129
|
+
],
|
|
1130
|
+
timeout=10,
|
|
1131
|
+
)
|
|
1132
|
+
return
|
|
1133
|
+
unset_env_values(env_file, keys)
|
|
1134
|
+
|
|
1135
|
+
|
|
1136
|
+
def _super_admin_exists(plan: "_BootstrapPlan", env_data: dict) -> bool:
|
|
1137
|
+
"""True if a super_admin user is already in the DB — the bootstrap gate."""
|
|
1138
|
+
pg_user = env_data.get("POSTGRES_USER", "postgres")
|
|
1139
|
+
rc, out = run_quiet(
|
|
1140
|
+
plan.base
|
|
1141
|
+
+ [
|
|
1142
|
+
"exec",
|
|
1143
|
+
*plan.exec_flags,
|
|
1144
|
+
plan.pg_svc,
|
|
1145
|
+
"psql",
|
|
1146
|
+
"-U",
|
|
1147
|
+
pg_user,
|
|
1148
|
+
"-d",
|
|
1149
|
+
"selfhost_studio",
|
|
1150
|
+
"-tA",
|
|
1151
|
+
"-c",
|
|
1152
|
+
"SELECT 1 FROM users WHERE role = 'super_admin' LIMIT 1",
|
|
1153
|
+
],
|
|
1154
|
+
timeout=10,
|
|
1155
|
+
)
|
|
1156
|
+
return rc == 0 and out.strip() == "1"
|
|
1157
|
+
|
|
1158
|
+
|
|
1159
|
+
def _bootstrap_first_admin(
|
|
1160
|
+
env_file: Path, plan: "_BootstrapPlan | None" = None
|
|
1161
|
+
) -> bool:
|
|
1162
|
+
"""First-boot super admin + default org admin. Idempotent."""
|
|
1163
|
+
# Skip if a super_admin already exists.
|
|
1164
|
+
env_data = _read_env_for_bootstrap(env_file, plan)
|
|
1165
|
+
check_plan = plan or _split_plan(env_file, env_data)
|
|
1166
|
+
if _super_admin_exists(check_plan, env_data):
|
|
1167
|
+
return True
|
|
1168
|
+
|
|
1169
|
+
print()
|
|
1170
|
+
info("First boot - create your super admin account (username: super_admin)")
|
|
1171
|
+
admin_email = os.environ.get("SHS_ADMIN_EMAIL", "")
|
|
1172
|
+
admin_password = os.environ.get("SHS_ADMIN_PASSWORD", "")
|
|
1173
|
+
if not admin_email:
|
|
1174
|
+
while not admin_email or "@" not in admin_email:
|
|
1175
|
+
admin_email = _prompt("super_admin email", "super-admin@example.com")
|
|
1176
|
+
if not admin_email or "@" not in admin_email:
|
|
1177
|
+
warn("Enter a valid email address")
|
|
1178
|
+
if not admin_password:
|
|
1179
|
+
admin_password = _prompt_password("super_admin password")
|
|
1180
|
+
|
|
1181
|
+
print()
|
|
1182
|
+
info("Create the default org's admin account (username: admin)")
|
|
1183
|
+
default_admin_email = os.environ.get("CONSOLE_DEFAULT_ADMIN_EMAIL", "")
|
|
1184
|
+
default_admin_password = os.environ.get("CONSOLE_DEFAULT_ADMIN_PASSWORD", "")
|
|
1185
|
+
default_email_default = (
|
|
1186
|
+
"" if admin_email == "admin@example.com" else "admin@example.com"
|
|
1187
|
+
)
|
|
1188
|
+
if not default_admin_email:
|
|
1189
|
+
while True:
|
|
1190
|
+
default_admin_email = _prompt("admin email", default_email_default)
|
|
1191
|
+
if not default_admin_email or "@" not in default_admin_email:
|
|
1192
|
+
warn("Enter a valid email address")
|
|
1193
|
+
continue
|
|
1194
|
+
if default_admin_email == admin_email:
|
|
1195
|
+
warn("Org admin email must differ from the super admin email")
|
|
1196
|
+
continue
|
|
1197
|
+
break
|
|
1198
|
+
elif default_admin_email == admin_email:
|
|
1199
|
+
error(
|
|
1200
|
+
"CONSOLE_DEFAULT_ADMIN_EMAIL must differ from SHS_ADMIN_EMAIL "
|
|
1201
|
+
f"({admin_email})"
|
|
1202
|
+
)
|
|
1203
|
+
return False
|
|
1204
|
+
if not default_admin_password:
|
|
1205
|
+
default_admin_password = _prompt_password("admin password")
|
|
1206
|
+
|
|
1207
|
+
# Prompt for entitlement token; _create_admin_direct reads it from env.
|
|
1208
|
+
if not os.environ.get("SHS_ENTITLEMENT_TOKEN") and not _read_env_for_bootstrap(
|
|
1209
|
+
env_file, plan
|
|
1210
|
+
).get("SHS_ENTITLEMENT_TOKEN"):
|
|
1211
|
+
print()
|
|
1212
|
+
info("Entitlement token (enables the Plus catalog; leave blank to skip)")
|
|
1213
|
+
token = _prompt("Entitlement token", "").strip()
|
|
1214
|
+
if token:
|
|
1215
|
+
os.environ["SHS_ENTITLEMENT_TOKEN"] = token
|
|
1216
|
+
|
|
1217
|
+
info("Creating super admin account...")
|
|
1218
|
+
super_admin_ok = False
|
|
1219
|
+
try:
|
|
1220
|
+
_create_admin_direct(env_file, admin_email, admin_password, plan)
|
|
1221
|
+
ok(f"Super admin account created: {admin_email} (username: super_admin)")
|
|
1222
|
+
super_admin_ok = True
|
|
1223
|
+
except Exception as e:
|
|
1224
|
+
error(str(e))
|
|
1225
|
+
error("Try: Settings → Reset password")
|
|
1226
|
+
|
|
1227
|
+
info("Creating default org + admin account...")
|
|
1228
|
+
org_admin_ok = False
|
|
1229
|
+
try:
|
|
1230
|
+
_create_default_org_admin(
|
|
1231
|
+
env_file, default_admin_email, default_admin_password, plan
|
|
1232
|
+
)
|
|
1233
|
+
ok(
|
|
1234
|
+
f"Admin account created: {default_admin_email} (username: admin, org: default)"
|
|
1235
|
+
)
|
|
1236
|
+
org_admin_ok = True
|
|
1237
|
+
except Exception as e:
|
|
1238
|
+
error(str(e))
|
|
1239
|
+
|
|
1240
|
+
if super_admin_ok and org_admin_ok:
|
|
1241
|
+
# Strip transient bootstrap inputs from .env.
|
|
1242
|
+
_unset_env_for_bootstrap(
|
|
1243
|
+
env_file,
|
|
1244
|
+
[
|
|
1245
|
+
"SHS_ADMIN_EMAIL",
|
|
1246
|
+
"SHS_ADMIN_PASSWORD",
|
|
1247
|
+
"CONSOLE_DEFAULT_ADMIN_EMAIL",
|
|
1248
|
+
"CONSOLE_DEFAULT_ADMIN_PASSWORD",
|
|
1249
|
+
"SHS_ENTITLEMENT_TOKEN",
|
|
1250
|
+
],
|
|
1251
|
+
plan,
|
|
1252
|
+
)
|
|
1253
|
+
return True
|
|
1254
|
+
warn("Bootstrap incomplete — will retry account creation on next start.")
|
|
1255
|
+
return False
|
|
1256
|
+
|
|
1257
|
+
|
|
1258
|
+
def _create_admin_direct(
|
|
1259
|
+
env_file: Path, email: str, password: str, plan: "_BootstrapPlan | None" = None
|
|
1260
|
+
) -> None:
|
|
1261
|
+
"""Create super admin: hash via API container, insert via psql."""
|
|
1262
|
+
import uuid
|
|
1263
|
+
|
|
1264
|
+
env_data = _read_env_for_bootstrap(env_file, plan)
|
|
1265
|
+
plan = plan or _split_plan(env_file, env_data)
|
|
1266
|
+
base = plan.base
|
|
1267
|
+
pg_user = env_data.get("POSTGRES_USER", "postgres")
|
|
1268
|
+
api_svc = plan.api_svc
|
|
1269
|
+
|
|
1270
|
+
# 1. Hash password via bcrypt in the API container
|
|
1271
|
+
rc, hashed = run_quiet(
|
|
1272
|
+
base
|
|
1273
|
+
+ [
|
|
1274
|
+
"exec",
|
|
1275
|
+
*plan.exec_flags,
|
|
1276
|
+
"-e",
|
|
1277
|
+
f"_PW={password}",
|
|
1278
|
+
api_svc,
|
|
1279
|
+
"python",
|
|
1280
|
+
"-c",
|
|
1281
|
+
"import bcrypt,os; pw=os.environ['_PW'].encode(); "
|
|
1282
|
+
"print(bcrypt.hashpw(pw,bcrypt.gensalt()).decode())",
|
|
1283
|
+
],
|
|
1284
|
+
timeout=15,
|
|
1285
|
+
)
|
|
1286
|
+
if rc != 0 or not hashed.strip():
|
|
1287
|
+
raise RuntimeError(f"Failed to hash password (rc={rc}): {hashed}")
|
|
1288
|
+
hashed_pw = hashed.strip()
|
|
1289
|
+
|
|
1290
|
+
# 2. Get system org ID
|
|
1291
|
+
rc, org_out = run_quiet(
|
|
1292
|
+
base
|
|
1293
|
+
+ [
|
|
1294
|
+
"exec",
|
|
1295
|
+
*plan.exec_flags,
|
|
1296
|
+
plan.pg_svc,
|
|
1297
|
+
"psql",
|
|
1298
|
+
"-U",
|
|
1299
|
+
pg_user,
|
|
1300
|
+
"-d",
|
|
1301
|
+
"selfhost_studio",
|
|
1302
|
+
"-t",
|
|
1303
|
+
"-A",
|
|
1304
|
+
"-c",
|
|
1305
|
+
"SELECT id FROM organizations WHERE slug = 'system' LIMIT 1",
|
|
1306
|
+
],
|
|
1307
|
+
timeout=10,
|
|
1308
|
+
)
|
|
1309
|
+
if rc != 0 or not org_out.strip():
|
|
1310
|
+
raise RuntimeError(f"System org not found (rc={rc}): {org_out}")
|
|
1311
|
+
org_id = org_out.strip()
|
|
1312
|
+
|
|
1313
|
+
# 2b. Ensure system org is flagged as staging
|
|
1314
|
+
rc, upd_out = run_quiet(
|
|
1315
|
+
base
|
|
1316
|
+
+ [
|
|
1317
|
+
"exec",
|
|
1318
|
+
*plan.exec_flags,
|
|
1319
|
+
plan.pg_svc,
|
|
1320
|
+
"psql",
|
|
1321
|
+
"-U",
|
|
1322
|
+
pg_user,
|
|
1323
|
+
"-d",
|
|
1324
|
+
"selfhost_studio",
|
|
1325
|
+
"-c",
|
|
1326
|
+
"UPDATE organizations SET is_staging = TRUE WHERE slug = 'system'",
|
|
1327
|
+
],
|
|
1328
|
+
timeout=10,
|
|
1329
|
+
)
|
|
1330
|
+
if rc != 0:
|
|
1331
|
+
raise RuntimeError(
|
|
1332
|
+
f"Failed to set is_staging on system org (rc={rc}): {upd_out}. "
|
|
1333
|
+
"The Studio schema may be missing the is_staging column; upgrade Studio."
|
|
1334
|
+
)
|
|
1335
|
+
|
|
1336
|
+
# 3. Insert admin user
|
|
1337
|
+
admin_id = str(uuid.uuid4())
|
|
1338
|
+
sql = (
|
|
1339
|
+
"INSERT INTO users "
|
|
1340
|
+
"(id, username, email, hashed_password, role, "
|
|
1341
|
+
"is_active, is_public, first_name, last_name, "
|
|
1342
|
+
"organization_id, created_at, updated_at) "
|
|
1343
|
+
"VALUES ("
|
|
1344
|
+
f"'{admin_id}', 'super_admin', '{email}', "
|
|
1345
|
+
f"$hash${hashed_pw}$hash$, 'super_admin', true, false, "
|
|
1346
|
+
f"'System', 'Administrator', '{org_id}', NOW(), NOW()"
|
|
1347
|
+
") ON CONFLICT (username) DO NOTHING"
|
|
1348
|
+
)
|
|
1349
|
+
rc, out = run_quiet(
|
|
1350
|
+
base
|
|
1351
|
+
+ [
|
|
1352
|
+
"exec",
|
|
1353
|
+
*plan.exec_flags,
|
|
1354
|
+
plan.pg_svc,
|
|
1355
|
+
"psql",
|
|
1356
|
+
"-U",
|
|
1357
|
+
pg_user,
|
|
1358
|
+
"-d",
|
|
1359
|
+
"selfhost_studio",
|
|
1360
|
+
"-c",
|
|
1361
|
+
sql,
|
|
1362
|
+
],
|
|
1363
|
+
timeout=10,
|
|
1364
|
+
)
|
|
1365
|
+
if rc != 0:
|
|
1366
|
+
raise RuntimeError(f"INSERT failed (rc={rc}): {out}")
|
|
1367
|
+
|
|
1368
|
+
# 4. Create ENTITLEMENT_TOKEN secret via API (encrypted correctly by repository)
|
|
1369
|
+
api_base = plan.api_base
|
|
1370
|
+
entitlement_token = os.environ.get("SHS_ENTITLEMENT_TOKEN") or env_data.get(
|
|
1371
|
+
"SHS_ENTITLEMENT_TOKEN", ""
|
|
1372
|
+
)
|
|
1373
|
+
|
|
1374
|
+
rc, login_out = run_quiet(
|
|
1375
|
+
[
|
|
1376
|
+
"curl",
|
|
1377
|
+
"-sf",
|
|
1378
|
+
"-X",
|
|
1379
|
+
"POST",
|
|
1380
|
+
f"{api_base}/auth/token",
|
|
1381
|
+
"-d",
|
|
1382
|
+
f"username={email}&password={password}",
|
|
1383
|
+
],
|
|
1384
|
+
timeout=15,
|
|
1385
|
+
)
|
|
1386
|
+
if rc != 0 or not login_out.strip():
|
|
1387
|
+
warn(
|
|
1388
|
+
"Could not log in to create ENTITLEMENT_TOKEN secret - add it manually via Settings → Secrets"
|
|
1389
|
+
)
|
|
1390
|
+
return
|
|
1391
|
+
|
|
1392
|
+
try:
|
|
1393
|
+
access_token = json.loads(login_out).get("access_token", "")
|
|
1394
|
+
except Exception:
|
|
1395
|
+
access_token = ""
|
|
1396
|
+
|
|
1397
|
+
if not access_token:
|
|
1398
|
+
warn(
|
|
1399
|
+
"Login response missing access_token - ENTITLEMENT_TOKEN secret not created"
|
|
1400
|
+
)
|
|
1401
|
+
return
|
|
1402
|
+
|
|
1403
|
+
secret_data: dict = {"token": entitlement_token} if entitlement_token else {}
|
|
1404
|
+
payload = json.dumps(
|
|
1405
|
+
{
|
|
1406
|
+
"name": "ENTITLEMENT_TOKEN",
|
|
1407
|
+
"secret_type": "bearer",
|
|
1408
|
+
"secret_data": secret_data,
|
|
1409
|
+
"is_active": bool(entitlement_token),
|
|
1410
|
+
}
|
|
1411
|
+
)
|
|
1412
|
+
rc, secret_out = run_quiet(
|
|
1413
|
+
[
|
|
1414
|
+
"curl",
|
|
1415
|
+
"-sf",
|
|
1416
|
+
"-X",
|
|
1417
|
+
"POST",
|
|
1418
|
+
f"{api_base}/organizations/secrets",
|
|
1419
|
+
"-H",
|
|
1420
|
+
"Content-Type: application/json",
|
|
1421
|
+
"-H",
|
|
1422
|
+
f"Authorization: Bearer {access_token}",
|
|
1423
|
+
"-d",
|
|
1424
|
+
payload,
|
|
1425
|
+
],
|
|
1426
|
+
timeout=15,
|
|
1427
|
+
)
|
|
1428
|
+
if rc != 0:
|
|
1429
|
+
warn(
|
|
1430
|
+
"Failed to create ENTITLEMENT_TOKEN secret - add it manually via Settings → Secrets"
|
|
1431
|
+
)
|
|
1432
|
+
|
|
1433
|
+
|
|
1434
|
+
def _create_default_org_admin(
|
|
1435
|
+
env_file: Path, email: str, password: str, plan: "_BootstrapPlan | None" = None
|
|
1436
|
+
) -> None:
|
|
1437
|
+
"""Create the 'default' org (is_staging=TRUE) and its immutable 'admin' user."""
|
|
1438
|
+
import uuid
|
|
1439
|
+
|
|
1440
|
+
env_data = _read_env_for_bootstrap(env_file, plan)
|
|
1441
|
+
plan = plan or _split_plan(env_file, env_data)
|
|
1442
|
+
base = plan.base
|
|
1443
|
+
pg_user = env_data.get("POSTGRES_USER", "postgres")
|
|
1444
|
+
api_svc = plan.api_svc
|
|
1445
|
+
|
|
1446
|
+
# 1. Hash password via bcrypt in the API container
|
|
1447
|
+
rc, hashed = run_quiet(
|
|
1448
|
+
base
|
|
1449
|
+
+ [
|
|
1450
|
+
"exec",
|
|
1451
|
+
*plan.exec_flags,
|
|
1452
|
+
"-e",
|
|
1453
|
+
f"_PW={password}",
|
|
1454
|
+
api_svc,
|
|
1455
|
+
"python",
|
|
1456
|
+
"-c",
|
|
1457
|
+
"import bcrypt,os; pw=os.environ['_PW'].encode(); "
|
|
1458
|
+
"print(bcrypt.hashpw(pw,bcrypt.gensalt()).decode())",
|
|
1459
|
+
],
|
|
1460
|
+
timeout=15,
|
|
1461
|
+
)
|
|
1462
|
+
if rc != 0 or not hashed.strip():
|
|
1463
|
+
raise RuntimeError(f"Failed to hash password (rc={rc}): {hashed}")
|
|
1464
|
+
hashed_pw = hashed.strip()
|
|
1465
|
+
|
|
1466
|
+
# 2. Insert 'default' org with is_staging=TRUE (idempotent)
|
|
1467
|
+
org_id = str(uuid.uuid4())
|
|
1468
|
+
org_sql = (
|
|
1469
|
+
"INSERT INTO organizations "
|
|
1470
|
+
"(id, name, slug, is_active, is_staging, status, settings, created_at, updated_at) "
|
|
1471
|
+
f"VALUES ('{org_id}', 'Default', 'default', TRUE, TRUE, 'active', '{{}}', NOW(), NOW()) "
|
|
1472
|
+
"ON CONFLICT (slug) DO UPDATE SET is_staging = TRUE "
|
|
1473
|
+
"RETURNING id"
|
|
1474
|
+
)
|
|
1475
|
+
rc, org_out = run_quiet(
|
|
1476
|
+
base
|
|
1477
|
+
+ [
|
|
1478
|
+
"exec",
|
|
1479
|
+
*plan.exec_flags,
|
|
1480
|
+
plan.pg_svc,
|
|
1481
|
+
"psql",
|
|
1482
|
+
"-U",
|
|
1483
|
+
pg_user,
|
|
1484
|
+
"-d",
|
|
1485
|
+
"selfhost_studio",
|
|
1486
|
+
"-t",
|
|
1487
|
+
"-A",
|
|
1488
|
+
"-c",
|
|
1489
|
+
org_sql,
|
|
1490
|
+
],
|
|
1491
|
+
timeout=10,
|
|
1492
|
+
)
|
|
1493
|
+
if rc != 0 or not org_out.strip():
|
|
1494
|
+
raise RuntimeError(
|
|
1495
|
+
f"Default org INSERT failed (rc={rc}): {org_out}. "
|
|
1496
|
+
"The organizations schema may differ from what console expects; "
|
|
1497
|
+
"upgrade Studio."
|
|
1498
|
+
)
|
|
1499
|
+
org_id = org_out.strip().splitlines()[0]
|
|
1500
|
+
|
|
1501
|
+
# 3. Insert admin user
|
|
1502
|
+
admin_id = str(uuid.uuid4())
|
|
1503
|
+
sql = (
|
|
1504
|
+
"INSERT INTO users "
|
|
1505
|
+
"(id, username, email, hashed_password, role, "
|
|
1506
|
+
"is_active, is_public, first_name, last_name, "
|
|
1507
|
+
"organization_id, created_at, updated_at) "
|
|
1508
|
+
"VALUES ("
|
|
1509
|
+
f"'{admin_id}', 'admin', '{email}', "
|
|
1510
|
+
f"$hash${hashed_pw}$hash$, 'admin', true, false, "
|
|
1511
|
+
f"'Default', 'Administrator', '{org_id}', NOW(), NOW()"
|
|
1512
|
+
") ON CONFLICT (username) DO NOTHING"
|
|
1513
|
+
)
|
|
1514
|
+
rc, out = run_quiet(
|
|
1515
|
+
base
|
|
1516
|
+
+ [
|
|
1517
|
+
"exec",
|
|
1518
|
+
*plan.exec_flags,
|
|
1519
|
+
plan.pg_svc,
|
|
1520
|
+
"psql",
|
|
1521
|
+
"-U",
|
|
1522
|
+
pg_user,
|
|
1523
|
+
"-d",
|
|
1524
|
+
"selfhost_studio",
|
|
1525
|
+
"-c",
|
|
1526
|
+
sql,
|
|
1527
|
+
],
|
|
1528
|
+
timeout=10,
|
|
1529
|
+
)
|
|
1530
|
+
if rc != 0:
|
|
1531
|
+
raise RuntimeError(f"admin user INSERT failed (rc={rc}): {out}")
|
|
1532
|
+
|
|
1533
|
+
|
|
1534
|
+
def cmd_restart(context: str, env_file: Path, service: str | None) -> None:
|
|
1535
|
+
"""Restart a service or all."""
|
|
1536
|
+
if context == "host":
|
|
1537
|
+
_validate_env(env_file)
|
|
1538
|
+
if service:
|
|
1539
|
+
info(f"Restarting {service}...")
|
|
1540
|
+
run(compose_cmd(env_file) + ["restart", service], timeout=60)
|
|
1541
|
+
ok(f"{service} restarted")
|
|
1542
|
+
else:
|
|
1543
|
+
# Full restart = full apply: use 'up -d --remove-orphans' (not bare
|
|
1544
|
+
# 'restart') so deselected profiles are cleaned up and replica/scale
|
|
1545
|
+
# counts are honored. Scale flags must be reapplied or workers/API
|
|
1546
|
+
# collapse to 1 replica.
|
|
1547
|
+
info("Restarting all services...")
|
|
1548
|
+
env_data = read_env(env_file)
|
|
1549
|
+
run(
|
|
1550
|
+
_apply_scale_flags(
|
|
1551
|
+
compose_cmd(env_file) + ["up", "-d", "--remove-orphans"],
|
|
1552
|
+
env_data,
|
|
1553
|
+
),
|
|
1554
|
+
timeout=120,
|
|
1555
|
+
)
|
|
1556
|
+
ok("All services restarted")
|
|
1557
|
+
else:
|
|
1558
|
+
if service:
|
|
1559
|
+
info(f"Restarting {service}...")
|
|
1560
|
+
run(["supervisorctl", "restart", service], timeout=30)
|
|
1561
|
+
ok(f"{service} restarted")
|
|
1562
|
+
else:
|
|
1563
|
+
info("Restarting all services...")
|
|
1564
|
+
run(["supervisorctl", "restart", "all"], timeout=30)
|
|
1565
|
+
ok("All services restarted")
|
|
1566
|
+
|
|
1567
|
+
|
|
1568
|
+
def _app_db_url(env_data: dict) -> str:
|
|
1569
|
+
"""The restricted-role URL, from .env or process env (process env wins)."""
|
|
1570
|
+
return os.environ.get("SHS_DATABASE_APP_URL", "") or env_data.get(
|
|
1571
|
+
"SHS_DATABASE_APP_URL", ""
|
|
1572
|
+
)
|
|
1573
|
+
|
|
1574
|
+
|
|
1575
|
+
def _print_db_role_posture(env_data: dict, api_up: bool) -> None:
|
|
1576
|
+
"""One-line RLS posture. A healthy API with the app URL set proves
|
|
1577
|
+
restricted mode — boot is fail-closed on an RLS-inert role."""
|
|
1578
|
+
if _app_db_url(env_data):
|
|
1579
|
+
if api_up:
|
|
1580
|
+
print(
|
|
1581
|
+
f" {'DB role':24s} {_green('restricted')} "
|
|
1582
|
+
f"{_dim('RLS enforced (shs_app)')}"
|
|
1583
|
+
)
|
|
1584
|
+
else:
|
|
1585
|
+
print(
|
|
1586
|
+
f" {'DB role':24s} {_yellow('restricted (configured)')} "
|
|
1587
|
+
f"{_dim('API down — an RlsInertError in its logs means SHS_DATABASE_APP_URL is misconfigured')}"
|
|
1588
|
+
)
|
|
1589
|
+
else:
|
|
1590
|
+
print(
|
|
1591
|
+
f" {'DB role':24s} {_yellow('privileged')} "
|
|
1592
|
+
f"{_dim('RLS inert, app-layer checks only — enable via the DB role menu')}"
|
|
1593
|
+
)
|
|
1594
|
+
|
|
1595
|
+
|
|
1596
|
+
def cmd_health(context: str, env_file: Path) -> None:
|
|
1597
|
+
"""Check health of API and workers."""
|
|
1598
|
+
env_data = read_env(env_file)
|
|
1599
|
+
|
|
1600
|
+
# Container mode: no nginx, API binds 8000 directly. Host mode: nginx fronts API.
|
|
1601
|
+
if context == "host":
|
|
1602
|
+
_validate_env(env_file) # friendly fatal beats a misleading all-DOWN report
|
|
1603
|
+
nginx_port = env_data.get("SHS_NGINX_PORT", "80")
|
|
1604
|
+
api_base = f"http://localhost:{nginx_port}"
|
|
1605
|
+
print(f"\n{_bold('Health:')}")
|
|
1606
|
+
rc, _ = run_quiet(["curl", "-sf", f"{api_base}/health"])
|
|
1607
|
+
print(f" {'API (via nginx)':24s} {_green('UP') if rc == 0 else _red('DOWN')}")
|
|
1608
|
+
rc_ng, _ = run_quiet(
|
|
1609
|
+
["curl", "-sf", f"http://localhost:{nginx_port}/nginx-health"]
|
|
1610
|
+
)
|
|
1611
|
+
print(f" {'nginx':24s} {_green('UP') if rc_ng == 0 else _red('DOWN')}")
|
|
1612
|
+
_print_db_role_posture(env_data, api_up=rc == 0)
|
|
1613
|
+
|
|
1614
|
+
# If the API is down, distinguish "guardrail tripped" from "unknown" so
|
|
1615
|
+
# the operator isn't told to "just check logs" when the cause is known.
|
|
1616
|
+
if rc != 0:
|
|
1617
|
+
target_tag = env_data.get("SHS_STUDIO_VERSION", "")
|
|
1618
|
+
target_major = mv.parse_target_major(target_tag)
|
|
1619
|
+
mv_result, mv_info = mv.classify_db(env_file, target_major, context)
|
|
1620
|
+
if mv_result in ("prior_major", "unknown_future"):
|
|
1621
|
+
mv.render_block(mv_result, mv_info, "run")
|
|
1622
|
+
elif mv.scrape_guardrail_failure(env_file, context):
|
|
1623
|
+
warn(
|
|
1624
|
+
"API container logs show a major-version guardrail FATAL. "
|
|
1625
|
+
"The database schema does not match the running image. "
|
|
1626
|
+
"Check SHS_STUDIO_VERSION in ~/.studio/.env."
|
|
1627
|
+
)
|
|
1628
|
+
else:
|
|
1629
|
+
api_base = "http://localhost:8000"
|
|
1630
|
+
print(f"\n{_bold('Health:')}")
|
|
1631
|
+
rc, _ = run_quiet(["curl", "-sf", f"{api_base}/health"])
|
|
1632
|
+
print(f" {'API':24s} {_green('UP') if rc == 0 else _red('DOWN')}")
|
|
1633
|
+
_print_db_role_posture(env_data, api_up=rc == 0)
|
|
1634
|
+
|
|
1635
|
+
# Container/supervisor status
|
|
1636
|
+
if context == "host":
|
|
1637
|
+
rc2, out = run_quiet(
|
|
1638
|
+
compose_cmd(env_file) + ["ps", "--format", "json"],
|
|
1639
|
+
timeout=15,
|
|
1640
|
+
)
|
|
1641
|
+
if rc2 == 0 and out:
|
|
1642
|
+
try:
|
|
1643
|
+
# docker compose ps --format json can return one JSON per line
|
|
1644
|
+
for line in out.strip().splitlines():
|
|
1645
|
+
svc = json.loads(line)
|
|
1646
|
+
name = svc.get("Name", svc.get("Service", "?"))
|
|
1647
|
+
state = svc.get("State", "unknown")
|
|
1648
|
+
color = _green if state == "running" else _red
|
|
1649
|
+
print(f" {name:24s} {color(state)}")
|
|
1650
|
+
except json.JSONDecodeError:
|
|
1651
|
+
pass
|
|
1652
|
+
else:
|
|
1653
|
+
rc2, out = run_quiet(["supervisorctl", "status"], timeout=10)
|
|
1654
|
+
if rc2 == 0 or out:
|
|
1655
|
+
for line in out.splitlines():
|
|
1656
|
+
parts = line.split()
|
|
1657
|
+
if len(parts) >= 2:
|
|
1658
|
+
name, state = parts[0], parts[1]
|
|
1659
|
+
color = _green if state == "RUNNING" else _red
|
|
1660
|
+
print(f" {name:30s} {color(state)}")
|
|
1661
|
+
|
|
1662
|
+
# Worker health via API
|
|
1663
|
+
rc3, wout = run_quiet(
|
|
1664
|
+
["curl", "-sf", f"{api_base}/api/v1/infrastructure/health/workers"],
|
|
1665
|
+
)
|
|
1666
|
+
if rc3 == 0 and wout:
|
|
1667
|
+
try:
|
|
1668
|
+
data = json.loads(wout)
|
|
1669
|
+
for w in data.get("workers", []):
|
|
1670
|
+
wtype = w.get("type", "?")
|
|
1671
|
+
healthy = w.get("healthy", False)
|
|
1672
|
+
pid = w.get("pid", "?")
|
|
1673
|
+
color = _green if healthy else _red
|
|
1674
|
+
status = "UP" if healthy else "DOWN"
|
|
1675
|
+
print(f" {wtype:20s} {color(status)} (pid {pid})")
|
|
1676
|
+
except json.JSONDecodeError:
|
|
1677
|
+
pass
|
|
1678
|
+
print()
|
|
1679
|
+
|
|
1680
|
+
|
|
1681
|
+
def cmd_show_config(context: str, env_file: Path) -> None:
|
|
1682
|
+
"""Show .env grouped by section, mask secrets."""
|
|
1683
|
+
if not env_file.exists():
|
|
1684
|
+
fatal(f"No .env found at {env_file}. Run studio-console to create one.")
|
|
1685
|
+
|
|
1686
|
+
data = read_env(env_file)
|
|
1687
|
+
shown_keys: set[str] = set()
|
|
1688
|
+
|
|
1689
|
+
for section, keys in ENV_SECTIONS.items():
|
|
1690
|
+
section_data = {k: data[k] for k in keys if k in data}
|
|
1691
|
+
if not section_data:
|
|
1692
|
+
continue
|
|
1693
|
+
print(f"\n{_bold(section)}")
|
|
1694
|
+
for k, v in section_data.items():
|
|
1695
|
+
print(f" {k:40s} {mask_value(k, v)}")
|
|
1696
|
+
shown_keys.add(k)
|
|
1697
|
+
|
|
1698
|
+
# Show any keys not in known sections
|
|
1699
|
+
extra = {k: v for k, v in data.items() if k not in shown_keys}
|
|
1700
|
+
if extra:
|
|
1701
|
+
print(f"\n{_bold('Other')}")
|
|
1702
|
+
for k, v in extra.items():
|
|
1703
|
+
print(f" {k:40s} {mask_value(k, v)}")
|
|
1704
|
+
|
|
1705
|
+
|
|
1706
|
+
def cmd_config_set(env_file: Path, key: str, value: str) -> None:
|
|
1707
|
+
"""Set a config value in .env."""
|
|
1708
|
+
if not env_file.exists():
|
|
1709
|
+
fatal(f"No .env found at {env_file}. Run studio-console first.")
|
|
1710
|
+
set_env_value(env_file, key, value)
|
|
1711
|
+
ok(f"Set {key}={mask_value(key, value)}")
|
|
1712
|
+
|
|
1713
|
+
|
|
1714
|
+
def cmd_config_unset(env_file: Path, key: str) -> None:
|
|
1715
|
+
"""Remove a config value from .env."""
|
|
1716
|
+
if not env_file.exists():
|
|
1717
|
+
fatal(f"No .env found at {env_file}. Run studio-console first.")
|
|
1718
|
+
if key not in read_env(env_file):
|
|
1719
|
+
warn(f"{key} not set; nothing to remove.")
|
|
1720
|
+
return
|
|
1721
|
+
unset_env_values(env_file, [key])
|
|
1722
|
+
ok(f"Removed {key}")
|
|
1723
|
+
|
|
1724
|
+
|
|
1725
|
+
def cmd_logs(
|
|
1726
|
+
context: str,
|
|
1727
|
+
env_file: Path,
|
|
1728
|
+
services: str | list[str] | None,
|
|
1729
|
+
follow: bool = True,
|
|
1730
|
+
) -> None:
|
|
1731
|
+
"""View logs. follow=True streams live, follow=False shows recent.
|
|
1732
|
+
|
|
1733
|
+
*services* may be None (all), a single service name string, or a list of
|
|
1734
|
+
service names (e.g. ["api-1", "api-2"] for the "All API" group).
|
|
1735
|
+
"""
|
|
1736
|
+
if follow:
|
|
1737
|
+
print(_dim(" Press Ctrl-C to stop streaming\n"))
|
|
1738
|
+
if context == "host":
|
|
1739
|
+
_validate_env(env_file)
|
|
1740
|
+
cmd = compose_cmd(env_file) + ["logs", "--tail=200"]
|
|
1741
|
+
if follow:
|
|
1742
|
+
cmd.append("-f")
|
|
1743
|
+
if isinstance(services, list):
|
|
1744
|
+
cmd.extend(services)
|
|
1745
|
+
elif services:
|
|
1746
|
+
cmd.append(services)
|
|
1747
|
+
try:
|
|
1748
|
+
run(cmd, timeout=None, check=False)
|
|
1749
|
+
except KeyboardInterrupt:
|
|
1750
|
+
pass
|
|
1751
|
+
print()
|
|
1752
|
+
else:
|
|
1753
|
+
# supervisorctl only supports a single service name for tail -f
|
|
1754
|
+
target: str | None = None
|
|
1755
|
+
if isinstance(services, list):
|
|
1756
|
+
target = services[0] if services else None
|
|
1757
|
+
else:
|
|
1758
|
+
target = services
|
|
1759
|
+
try:
|
|
1760
|
+
tail_cmd = ["supervisorctl", "tail"]
|
|
1761
|
+
if follow:
|
|
1762
|
+
tail_cmd.append("-f")
|
|
1763
|
+
tail_cmd.append(target if target else "all")
|
|
1764
|
+
run(tail_cmd, timeout=None, check=False)
|
|
1765
|
+
except KeyboardInterrupt:
|
|
1766
|
+
pass
|
|
1767
|
+
print()
|
|
1768
|
+
|
|
1769
|
+
|
|
1770
|
+
def cmd_workers(context: str, env_file: Path) -> None:
|
|
1771
|
+
"""List workers - configured profiles and running state."""
|
|
1772
|
+
env_data = read_env(env_file)
|
|
1773
|
+
|
|
1774
|
+
# Show configured profiles (source of truth)
|
|
1775
|
+
profiles = env_data.get("COMPOSE_PROFILES", "")
|
|
1776
|
+
if profiles:
|
|
1777
|
+
print(f"\n{_bold('Configured (COMPOSE_PROFILES):')}")
|
|
1778
|
+
for p in profiles.split(","):
|
|
1779
|
+
print(f" {p.strip()}")
|
|
1780
|
+
else:
|
|
1781
|
+
print(f"\n{_bold('Configured (COMPOSE_PROFILES):')}")
|
|
1782
|
+
print(f" {_dim('none')}")
|
|
1783
|
+
|
|
1784
|
+
# Check running worker containers
|
|
1785
|
+
if context == "host":
|
|
1786
|
+
running = _get_running_services(context, env_file)
|
|
1787
|
+
worker_containers = [
|
|
1788
|
+
s for s in running if "worker" in s.lower() or s.startswith("w-")
|
|
1789
|
+
]
|
|
1790
|
+
|
|
1791
|
+
if worker_containers:
|
|
1792
|
+
print(f"\n{_bold('Running workers:')}")
|
|
1793
|
+
for w in worker_containers:
|
|
1794
|
+
print(f" {w:30s} {_green('running')}")
|
|
1795
|
+
else:
|
|
1796
|
+
print(f"\n{_bold('Running workers:')}")
|
|
1797
|
+
print(f" {_dim('none')}")
|
|
1798
|
+
|
|
1799
|
+
# Also check API's worker registry
|
|
1800
|
+
workers_base = f"http://localhost:{env_data.get('SHS_NGINX_PORT', '80')}"
|
|
1801
|
+
rc, wout = run_quiet(
|
|
1802
|
+
[
|
|
1803
|
+
"curl",
|
|
1804
|
+
"-sf",
|
|
1805
|
+
f"{workers_base}/api/v1/infrastructure/health/workers",
|
|
1806
|
+
],
|
|
1807
|
+
)
|
|
1808
|
+
if rc == 0 and wout:
|
|
1809
|
+
try:
|
|
1810
|
+
data = json.loads(wout)
|
|
1811
|
+
workers = data.get("workers", [])
|
|
1812
|
+
if workers:
|
|
1813
|
+
print(f"\n{_bold('Registered with API:')}")
|
|
1814
|
+
for w in workers:
|
|
1815
|
+
wtype = w.get("type", "?")
|
|
1816
|
+
healthy = w.get("healthy", False)
|
|
1817
|
+
color = _green if healthy else _red
|
|
1818
|
+
status = "UP" if healthy else "DOWN"
|
|
1819
|
+
print(f" {wtype:20s} {color(status)}")
|
|
1820
|
+
except json.JSONDecodeError:
|
|
1821
|
+
pass
|
|
1822
|
+
else:
|
|
1823
|
+
# Container/runpod - check supervisorctl
|
|
1824
|
+
rc, out = run_quiet(["supervisorctl", "status"], timeout=10)
|
|
1825
|
+
if out:
|
|
1826
|
+
worker_lines = [l for l in out.splitlines() if "worker" in l.lower()]
|
|
1827
|
+
if worker_lines:
|
|
1828
|
+
print(f"\n{_bold('Running workers:')}")
|
|
1829
|
+
for line in worker_lines:
|
|
1830
|
+
parts = line.split()
|
|
1831
|
+
if len(parts) >= 2:
|
|
1832
|
+
name, state = parts[0], parts[1]
|
|
1833
|
+
color = _green if state == "RUNNING" else _red
|
|
1834
|
+
print(f" {name:30s} {color(state)}")
|
|
1835
|
+
else:
|
|
1836
|
+
print(f"\n{_bold('Running workers:')}")
|
|
1837
|
+
print(f" {_dim('none')}")
|
|
1838
|
+
|
|
1839
|
+
# Only show scaling config if workers are configured
|
|
1840
|
+
components = env_data.get("CONSOLE_COMPONENTS", "")
|
|
1841
|
+
has_workers = "worker" in components.lower()
|
|
1842
|
+
if has_workers or context != "host":
|
|
1843
|
+
general = env_data.get("SHS_GENERAL_WORKERS", "1")
|
|
1844
|
+
transfer = env_data.get("SHS_TRANSFER_WORKERS", "1")
|
|
1845
|
+
print(f"\n{_bold('Scaling (supervisord):')}")
|
|
1846
|
+
print(
|
|
1847
|
+
f" {_dim(f'SHS_GENERAL_WORKERS={general} SHS_TRANSFER_WORKERS={transfer}')}"
|
|
1848
|
+
)
|
|
1849
|
+
print()
|
|
1850
|
+
|
|
1851
|
+
|
|
1852
|
+
def cmd_reset_password(context: str, env_file: Path) -> None:
|
|
1853
|
+
"""Reset super admin password. Prompts locally, passes to container."""
|
|
1854
|
+
new_password = _prompt_password("New super admin password")
|
|
1855
|
+
|
|
1856
|
+
if context == "host":
|
|
1857
|
+
env_data = read_env(env_file)
|
|
1858
|
+
health_url = f"http://localhost:{env_data.get('SHS_NGINX_PORT', '80')}/health"
|
|
1859
|
+
rc, _ = run_quiet(["curl", "-sf", health_url])
|
|
1860
|
+
if rc != 0:
|
|
1861
|
+
error("API is not running. Start services first.")
|
|
1862
|
+
return
|
|
1863
|
+
api_svc = _api_service_name(env_data)
|
|
1864
|
+
info("Resetting admin password...")
|
|
1865
|
+
try:
|
|
1866
|
+
# SHS_ names are the app script's env contract (scripts/reset_admin_password.py
|
|
1867
|
+
# reads SHS_ADMIN_PASSWORD / SHS_FORCE_PRODUCTION) — keep them, do NOT rename
|
|
1868
|
+
# to CONSOLE_. Transient injection only; never written to .env.
|
|
1869
|
+
run(
|
|
1870
|
+
compose_cmd(env_file)
|
|
1871
|
+
+ [
|
|
1872
|
+
"exec",
|
|
1873
|
+
"-e",
|
|
1874
|
+
"SHS_FORCE_PRODUCTION=true",
|
|
1875
|
+
"-e",
|
|
1876
|
+
f"SHS_ADMIN_PASSWORD={new_password}",
|
|
1877
|
+
api_svc,
|
|
1878
|
+
"python",
|
|
1879
|
+
"scripts/reset_admin_password.py",
|
|
1880
|
+
],
|
|
1881
|
+
timeout=60,
|
|
1882
|
+
)
|
|
1883
|
+
except subprocess.CalledProcessError:
|
|
1884
|
+
error("Failed to reset password. Check that the API container is healthy.")
|
|
1885
|
+
else:
|
|
1886
|
+
info("Resetting admin password...")
|
|
1887
|
+
# SHS_ names are the app script's env contract — keep, do NOT rename to CONSOLE_.
|
|
1888
|
+
os.environ["SHS_ADMIN_PASSWORD"] = new_password
|
|
1889
|
+
os.environ["SHS_FORCE_PRODUCTION"] = "true"
|
|
1890
|
+
try:
|
|
1891
|
+
run(
|
|
1892
|
+
["python3", "/app/api/scripts/reset_admin_password.py"],
|
|
1893
|
+
timeout=60,
|
|
1894
|
+
)
|
|
1895
|
+
except subprocess.CalledProcessError:
|
|
1896
|
+
error("Failed to reset password.")
|
|
1897
|
+
|
|
1898
|
+
|
|
1899
|
+
# Backup format is pinned: pg_dump plain-text + --inserts, with a leading
|
|
1900
|
+
# "-- studio-console" comment block recording image tag and digest. Both
|
|
1901
|
+
# _read_revision_from_dump and _parse_dump_header depend on this format.
|
|
1902
|
+
# Do not switch to -Fc / -Fd or remove --inserts without updating both.
|
|
1903
|
+
BACKUP_HEADER_VERSION = 1
|
|
1904
|
+
PG_DUMP_FLAGS = ["--inserts"]
|
|
1905
|
+
|
|
1906
|
+
_DUMP_HEADER_RE = re.compile(r"^-- studio-console:([a-z_]+)=(.*)$")
|
|
1907
|
+
_ALEMBIC_INSERT_RE = re.compile(
|
|
1908
|
+
r"^INSERT INTO (?:public\.)?alembic_version\s*(?:\([^)]*\)\s*)?VALUES\s*\('([^']+)'\)",
|
|
1909
|
+
re.MULTILINE,
|
|
1910
|
+
)
|
|
1911
|
+
|
|
1912
|
+
|
|
1913
|
+
def _api_image_digest() -> str | None:
|
|
1914
|
+
"""Return the image digest of a running studio-api container, or None."""
|
|
1915
|
+
rc, out = run_quiet(
|
|
1916
|
+
["docker", "ps", "--filter", "name=studio-api", "--format", "{{.ID}}"],
|
|
1917
|
+
timeout=5,
|
|
1918
|
+
)
|
|
1919
|
+
if rc != 0 or not out.strip():
|
|
1920
|
+
return None
|
|
1921
|
+
cid = out.strip().splitlines()[0].strip()
|
|
1922
|
+
rc, out = run_quiet(["docker", "inspect", "--format", "{{.Image}}", cid], timeout=5)
|
|
1923
|
+
if rc != 0:
|
|
1924
|
+
return None
|
|
1925
|
+
return out.strip() or None
|
|
1926
|
+
|
|
1927
|
+
|
|
1928
|
+
def _encryption_key_fingerprint(env_data: dict) -> str:
|
|
1929
|
+
"""sha256 of SHS_CREDENTIAL_ENCRYPTION_KEY, or "" if unset. Stored in the dump header so restore can verify the key without storing it."""
|
|
1930
|
+
import hashlib
|
|
1931
|
+
|
|
1932
|
+
key = (env_data.get("SHS_CREDENTIAL_ENCRYPTION_KEY") or "").strip()
|
|
1933
|
+
if not key:
|
|
1934
|
+
return ""
|
|
1935
|
+
return hashlib.sha256(key.encode("utf-8")).hexdigest()
|
|
1936
|
+
|
|
1937
|
+
|
|
1938
|
+
def _build_dump_header(env_data: dict, timestamp_iso: str) -> str:
|
|
1939
|
+
"""Build the leading SQL comment block prepended to every dump."""
|
|
1940
|
+
fields = {
|
|
1941
|
+
"header_version": str(BACKUP_HEADER_VERSION),
|
|
1942
|
+
"created_at": timestamp_iso,
|
|
1943
|
+
"studio_image_tag": env_data.get("SHS_STUDIO_VERSION") or "",
|
|
1944
|
+
"studio_image_digest": _api_image_digest() or "",
|
|
1945
|
+
"encryption_key_fp": _encryption_key_fingerprint(env_data),
|
|
1946
|
+
}
|
|
1947
|
+
lines = ["-- studio-console backup"]
|
|
1948
|
+
lines += [f"-- studio-console:{k}={v}" for k, v in fields.items()]
|
|
1949
|
+
lines.append("--")
|
|
1950
|
+
return "\n".join(lines) + "\n"
|
|
1951
|
+
|
|
1952
|
+
|
|
1953
|
+
def _parse_dump_header(db_file: str) -> dict:
|
|
1954
|
+
"""Read studio-console metadata from the dump's leading comment block.
|
|
1955
|
+
|
|
1956
|
+
Returns {} for legacy dumps with no header. Stops scanning at the first
|
|
1957
|
+
non-comment line so we never read past the preamble.
|
|
1958
|
+
"""
|
|
1959
|
+
meta: dict = {}
|
|
1960
|
+
try:
|
|
1961
|
+
with open(db_file, "r", encoding="utf-8", errors="replace") as fh:
|
|
1962
|
+
for line in fh:
|
|
1963
|
+
if not line.startswith("--"):
|
|
1964
|
+
break
|
|
1965
|
+
m = _DUMP_HEADER_RE.match(line.rstrip())
|
|
1966
|
+
if m:
|
|
1967
|
+
meta[m.group(1)] = m.group(2)
|
|
1968
|
+
except OSError:
|
|
1969
|
+
pass
|
|
1970
|
+
return meta
|
|
1971
|
+
|
|
1972
|
+
|
|
1973
|
+
def _read_revision_from_dump(db_file: str) -> str | None:
|
|
1974
|
+
"""Extract the alembic revision from a pg_dump --inserts plain-text dump."""
|
|
1975
|
+
try:
|
|
1976
|
+
text = Path(db_file).read_text(errors="replace")
|
|
1977
|
+
except OSError:
|
|
1978
|
+
return None
|
|
1979
|
+
m = _ALEMBIC_INSERT_RE.search(text)
|
|
1980
|
+
return m.group(1) if m else None
|
|
1981
|
+
|
|
1982
|
+
|
|
1983
|
+
def _read_current_db_revision(context: str, env_file: Path) -> str | None:
|
|
1984
|
+
"""Query the running DB's alembic_version. Returns None if unreachable or empty."""
|
|
1985
|
+
env_data = read_env(env_file)
|
|
1986
|
+
pg_user = env_data.get("POSTGRES_USER", "postgres")
|
|
1987
|
+
sql = "SELECT version_num FROM alembic_version LIMIT 1;"
|
|
1988
|
+
if context == "host":
|
|
1989
|
+
cmd = compose_cmd(env_file) + [
|
|
1990
|
+
"exec",
|
|
1991
|
+
"-T",
|
|
1992
|
+
"postgres",
|
|
1993
|
+
"psql",
|
|
1994
|
+
"-U",
|
|
1995
|
+
pg_user,
|
|
1996
|
+
"-d",
|
|
1997
|
+
"selfhost_studio",
|
|
1998
|
+
"-tAc",
|
|
1999
|
+
sql,
|
|
2000
|
+
]
|
|
2001
|
+
else:
|
|
2002
|
+
cmd = ["psql", "-U", pg_user, "-d", "selfhost_studio", "-tAc", sql]
|
|
2003
|
+
rc, out = run_quiet(cmd, timeout=10)
|
|
2004
|
+
if rc != 0:
|
|
2005
|
+
return None
|
|
2006
|
+
rev = out.strip()
|
|
2007
|
+
return rev or None
|
|
2008
|
+
|
|
2009
|
+
|
|
2010
|
+
def _format_local_time(iso_string: str) -> tuple[str, str]:
|
|
2011
|
+
"""Return (absolute, relative) labels for a header timestamp.
|
|
2012
|
+
|
|
2013
|
+
Header timestamps are ISO 8601 with offset (e.g. 2026-05-07T08:56:17-07:00).
|
|
2014
|
+
We re-render in the operator's local zone so 11pm-incident reading is unambiguous.
|
|
2015
|
+
"""
|
|
2016
|
+
import datetime
|
|
2017
|
+
|
|
2018
|
+
try:
|
|
2019
|
+
dt = datetime.datetime.fromisoformat(iso_string)
|
|
2020
|
+
except ValueError:
|
|
2021
|
+
return iso_string, ""
|
|
2022
|
+
local = dt.astimezone()
|
|
2023
|
+
abs_label = local.strftime("%b %-d, %Y %-I:%M %p %Z").strip()
|
|
2024
|
+
delta = datetime.datetime.now(local.tzinfo) - local
|
|
2025
|
+
secs = int(delta.total_seconds())
|
|
2026
|
+
if secs < 60:
|
|
2027
|
+
rel = "just now"
|
|
2028
|
+
elif secs < 3600:
|
|
2029
|
+
rel = f"{secs // 60} min ago"
|
|
2030
|
+
elif secs < 86400:
|
|
2031
|
+
rel = f"{secs // 3600} hr ago"
|
|
2032
|
+
elif secs < 86400 * 30:
|
|
2033
|
+
rel = f"{secs // 86400} day{'s' if secs // 86400 != 1 else ''} ago"
|
|
2034
|
+
else:
|
|
2035
|
+
rel = local.strftime("%b %Y")
|
|
2036
|
+
return abs_label, rel
|
|
2037
|
+
|
|
2038
|
+
|
|
2039
|
+
def _verify_post_restore(context: str, env_file: Path, db_file: str) -> None:
|
|
2040
|
+
"""After restore, confirm the live DB's alembic_version matches the dump's.
|
|
2041
|
+
|
|
2042
|
+
Mismatch means the apply succeeded but landed on the wrong schema — surface
|
|
2043
|
+
it loudly so the operator doesn't trust a silent success.
|
|
2044
|
+
"""
|
|
2045
|
+
expected = _read_revision_from_dump(db_file)
|
|
2046
|
+
if not expected:
|
|
2047
|
+
return # Nothing to verify against (legacy backup with no parseable rev).
|
|
2048
|
+
actual = _read_current_db_revision(context, env_file)
|
|
2049
|
+
if actual is None:
|
|
2050
|
+
warn("Could not verify post-restore schema (alembic_version unreadable).")
|
|
2051
|
+
return
|
|
2052
|
+
if actual != expected:
|
|
2053
|
+
error(
|
|
2054
|
+
f"Post-restore verification FAILED: expected schema {expected}, "
|
|
2055
|
+
f"got {actual}. The restore may be incomplete or corrupted."
|
|
2056
|
+
)
|
|
2057
|
+
error("Inspect the database before starting the API.")
|
|
2058
|
+
return
|
|
2059
|
+
ok(f"Schema verified: {actual}")
|
|
2060
|
+
|
|
2061
|
+
|
|
2062
|
+
def _offer_key_recovery(env_file: Path, backup_key_fp: str) -> bool:
|
|
2063
|
+
"""Paste/verify the backup's key against its fingerprint; persist to .env on match."""
|
|
2064
|
+
import hashlib
|
|
2065
|
+
|
|
2066
|
+
print()
|
|
2067
|
+
info(_cyan("If you have the key this backup was taken under, paste it now to"))
|
|
2068
|
+
info(_cyan("verify and use it. Leave blank to cancel."))
|
|
2069
|
+
for _ in range(3):
|
|
2070
|
+
pasted = _prompt("Encryption key for this backup").strip()
|
|
2071
|
+
if not pasted:
|
|
2072
|
+
return False
|
|
2073
|
+
if hashlib.sha256(pasted.encode("utf-8")).hexdigest() == backup_key_fp:
|
|
2074
|
+
data = read_env(env_file)
|
|
2075
|
+
data["SHS_CREDENTIAL_ENCRYPTION_KEY"] = pasted
|
|
2076
|
+
write_env(env_file, data)
|
|
2077
|
+
ok("Encryption key verified and saved to .env")
|
|
2078
|
+
return True
|
|
2079
|
+
warn(_yellow("That key does not match this backup. Try again or leave blank."))
|
|
2080
|
+
warn(_yellow("No matching key entered."))
|
|
2081
|
+
return False
|
|
2082
|
+
|
|
2083
|
+
|
|
2084
|
+
def _restore_preflight(context: str, env_file: Path, db_file: str) -> bool:
|
|
2085
|
+
"""Show pre-flight summary, run schema check, prompt appropriately.
|
|
2086
|
+
|
|
2087
|
+
Returns True if the operator confirms and the restore should proceed.
|
|
2088
|
+
Tiers:
|
|
2089
|
+
- prior_major / unknown_future → hard block (no override)
|
|
2090
|
+
- encryption key fingerprint differs → paste matching key or abort
|
|
2091
|
+
- schemas match → single y/N
|
|
2092
|
+
- schemas differ (both known) → require typing RESTORE
|
|
2093
|
+
- no header / unverifiable → require typing RESTORE
|
|
2094
|
+
"""
|
|
2095
|
+
backup_dir = os.path.dirname(db_file)
|
|
2096
|
+
backup_name = os.path.basename(backup_dir)
|
|
2097
|
+
header = _parse_dump_header(db_file)
|
|
2098
|
+
backup_rev = _read_revision_from_dump(db_file)
|
|
2099
|
+
current_rev = _read_current_db_revision(context, env_file)
|
|
2100
|
+
|
|
2101
|
+
# Major-version boundary check: if we restore this backup, the DB ends up at
|
|
2102
|
+
# backup_rev. Is backup_rev compatible with the running image's major?
|
|
2103
|
+
env_data = read_env(env_file)
|
|
2104
|
+
target_tag = env_data.get("SHS_STUDIO_VERSION", "")
|
|
2105
|
+
target_major = mv.parse_target_major(target_tag)
|
|
2106
|
+
mv_result, mv_info = mv.classify_revision(backup_rev, target_major)
|
|
2107
|
+
if mv_result in ("prior_major", "unknown_future"):
|
|
2108
|
+
mv.render_block(mv_result, mv_info, "restore")
|
|
2109
|
+
info("Aborted (incompatible backup).")
|
|
2110
|
+
return False
|
|
2111
|
+
|
|
2112
|
+
# Encryption-key check: the dump's encrypted columns are only readable with
|
|
2113
|
+
# the key they were written under. We never store that key in the backup, so
|
|
2114
|
+
# we compare fingerprints — live key vs. the fp recorded in the header.
|
|
2115
|
+
backup_key_fp = header.get("encryption_key_fp", "")
|
|
2116
|
+
live_key_fp = _encryption_key_fingerprint(env_data)
|
|
2117
|
+
key_mismatch = bool(backup_key_fp and live_key_fp and backup_key_fp != live_key_fp)
|
|
2118
|
+
|
|
2119
|
+
has_header = bool(header)
|
|
2120
|
+
created_iso = header.get("created_at", "")
|
|
2121
|
+
abs_time, rel_time = (
|
|
2122
|
+
_format_local_time(created_iso) if created_iso else ("unknown", "")
|
|
2123
|
+
)
|
|
2124
|
+
studio_tag = header.get("studio_image_tag") or "unknown"
|
|
2125
|
+
|
|
2126
|
+
if not has_header and not backup_rev:
|
|
2127
|
+
tier = "unverifiable"
|
|
2128
|
+
elif backup_rev and current_rev and backup_rev == current_rev:
|
|
2129
|
+
tier = "match"
|
|
2130
|
+
elif backup_rev and current_rev and backup_rev != current_rev:
|
|
2131
|
+
tier = "mismatch"
|
|
2132
|
+
else:
|
|
2133
|
+
tier = "unverifiable"
|
|
2134
|
+
|
|
2135
|
+
if tier == "match":
|
|
2136
|
+
schema_line = _green("✓ matches your current database")
|
|
2137
|
+
elif tier == "mismatch":
|
|
2138
|
+
schema_line = _yellow("⚠ DIFFERENT from your current database")
|
|
2139
|
+
else:
|
|
2140
|
+
schema_line = _yellow("⚠ unverifiable (legacy backup, no header)")
|
|
2141
|
+
|
|
2142
|
+
print()
|
|
2143
|
+
print(_bold("About to restore database"))
|
|
2144
|
+
print("─" * 40)
|
|
2145
|
+
print(f" Backup: {backup_name}")
|
|
2146
|
+
if has_header:
|
|
2147
|
+
time_label = f"{abs_time}" + (f" ({rel_time})" if rel_time else "")
|
|
2148
|
+
print(f" Created: {time_label}")
|
|
2149
|
+
print(f" Studio version: {studio_tag}")
|
|
2150
|
+
if backup_rev:
|
|
2151
|
+
print(f" Backup schema: {_dim(backup_rev)}")
|
|
2152
|
+
if current_rev:
|
|
2153
|
+
print(f" Current schema: {_dim(current_rev)}")
|
|
2154
|
+
print(f" Schema check: {schema_line}")
|
|
2155
|
+
if key_mismatch:
|
|
2156
|
+
print(f" Encryption key: {_yellow('⚠ does NOT match this backup')}")
|
|
2157
|
+
print()
|
|
2158
|
+
print("This will:")
|
|
2159
|
+
print(f" • Take a pre-restore snapshot to {_backups_root(context, env_file)}")
|
|
2160
|
+
print(" • Replace the database with the backup")
|
|
2161
|
+
print(" • Keep your current .env (the encryption key is not changed)")
|
|
2162
|
+
print()
|
|
2163
|
+
|
|
2164
|
+
if key_mismatch:
|
|
2165
|
+
warn(
|
|
2166
|
+
_yellow("Your live SHS_CREDENTIAL_ENCRYPTION_KEY differs from the one this backup was")
|
|
2167
|
+
)
|
|
2168
|
+
warn(
|
|
2169
|
+
_yellow(
|
|
2170
|
+
"written under. Restoring will leave the encrypted data UNREADABLE."
|
|
2171
|
+
)
|
|
2172
|
+
)
|
|
2173
|
+
if _offer_key_recovery(env_file, backup_key_fp):
|
|
2174
|
+
return True # key now matches; fall through to restore
|
|
2175
|
+
info(_cyan("Restore needs the matching key — the data is unreadable without it."))
|
|
2176
|
+
info("Aborted (no valid encryption key).")
|
|
2177
|
+
return False
|
|
2178
|
+
|
|
2179
|
+
if tier == "match":
|
|
2180
|
+
return _interactive_yn("Restore this database?", default=False)
|
|
2181
|
+
|
|
2182
|
+
if tier == "mismatch":
|
|
2183
|
+
warn(
|
|
2184
|
+
_yellow(
|
|
2185
|
+
"This backup was taken with a different schema than what's installed."
|
|
2186
|
+
)
|
|
2187
|
+
)
|
|
2188
|
+
warn(_yellow("Restoring it may corrupt data, crash the API, or both."))
|
|
2189
|
+
info(
|
|
2190
|
+
_cyan(
|
|
2191
|
+
"If unsure, cancel and check which Studio version produced this backup."
|
|
2192
|
+
)
|
|
2193
|
+
)
|
|
2194
|
+
else: # unverifiable
|
|
2195
|
+
warn(
|
|
2196
|
+
_yellow(
|
|
2197
|
+
"This backup has no version metadata, so schema compatibility "
|
|
2198
|
+
"can't be verified."
|
|
2199
|
+
)
|
|
2200
|
+
)
|
|
2201
|
+
info(
|
|
2202
|
+
_cyan(
|
|
2203
|
+
"If unsure, cancel and check which Studio version produced this backup."
|
|
2204
|
+
)
|
|
2205
|
+
)
|
|
2206
|
+
|
|
2207
|
+
print()
|
|
2208
|
+
typed = _prompt("Type RESTORE to proceed")
|
|
2209
|
+
if typed != "RESTORE":
|
|
2210
|
+
info("Aborted.")
|
|
2211
|
+
return False
|
|
2212
|
+
return True
|
|
2213
|
+
|
|
2214
|
+
|
|
2215
|
+
def cmd_backup(
|
|
2216
|
+
context: str,
|
|
2217
|
+
env_file: Path,
|
|
2218
|
+
what: str = "all",
|
|
2219
|
+
name_prefix: str = "studio",
|
|
2220
|
+
) -> str:
|
|
2221
|
+
"""Backup database and/or workspace files. Returns the backup directory path."""
|
|
2222
|
+
import datetime
|
|
2223
|
+
|
|
2224
|
+
env_data = read_env(env_file)
|
|
2225
|
+
now = datetime.datetime.now()
|
|
2226
|
+
timestamp = now.strftime("%Y%m%d_%H%M%S")
|
|
2227
|
+
timestamp_iso = now.astimezone().isoformat(timespec="seconds")
|
|
2228
|
+
backup_name = f"{name_prefix}-{timestamp}"
|
|
2229
|
+
|
|
2230
|
+
backup_dir = str(backup_root(context, env_file) / backup_name)
|
|
2231
|
+
os.makedirs(backup_dir, exist_ok=True)
|
|
2232
|
+
|
|
2233
|
+
if what in ("all", "db"):
|
|
2234
|
+
info("Backing up database...")
|
|
2235
|
+
db_file = os.path.join(backup_dir, "database.sql")
|
|
2236
|
+
pg_user = env_data.get("POSTGRES_USER", "postgres")
|
|
2237
|
+
dump_args = ["pg_dump", *PG_DUMP_FLAGS, "-U", pg_user, "selfhost_studio"]
|
|
2238
|
+
if context == "host":
|
|
2239
|
+
cmd = compose_cmd(env_file) + ["exec", "-T", "postgres", *dump_args]
|
|
2240
|
+
else:
|
|
2241
|
+
cmd = dump_args
|
|
2242
|
+
result = run(cmd, capture=True, timeout=120)
|
|
2243
|
+
header = _build_dump_header(env_data, timestamp_iso)
|
|
2244
|
+
Path(db_file).write_text(header + result.stdout)
|
|
2245
|
+
ok(f"Database: {db_file}")
|
|
2246
|
+
|
|
2247
|
+
if what in ("all", "orgs"):
|
|
2248
|
+
orgs_dir = str(storage_root(context, env_file) / "orgs")
|
|
2249
|
+
if os.path.isdir(orgs_dir):
|
|
2250
|
+
info("Archiving org files...")
|
|
2251
|
+
archive = os.path.join(backup_dir, "orgs.tar.gz")
|
|
2252
|
+
run(
|
|
2253
|
+
["tar", "czf", archive, "-C", os.path.dirname(orgs_dir), "orgs"],
|
|
2254
|
+
timeout=300,
|
|
2255
|
+
)
|
|
2256
|
+
ok(f"Orgs: {archive}")
|
|
2257
|
+
else:
|
|
2258
|
+
warn("No orgs directory found")
|
|
2259
|
+
|
|
2260
|
+
ok(f"Backup complete: {backup_dir}")
|
|
2261
|
+
|
|
2262
|
+
# The encryption key is deliberately NOT stored in the backup — co-locating
|
|
2263
|
+
# it with the ciphertext would let anyone holding the backup decrypt it.
|
|
2264
|
+
# Skip the notice for internal pre-restore snapshots (not operator-facing).
|
|
2265
|
+
if what in ("all", "db") and name_prefix != "pre-restore":
|
|
2266
|
+
_print_encryption_key_notice(env_data)
|
|
2267
|
+
|
|
2268
|
+
return backup_dir
|
|
2269
|
+
|
|
2270
|
+
|
|
2271
|
+
def _print_encryption_key_notice(env_data: dict) -> None:
|
|
2272
|
+
"""Remind the operator to store SHS_CREDENTIAL_ENCRYPTION_KEY separately from the backup."""
|
|
2273
|
+
key = (env_data.get("SHS_CREDENTIAL_ENCRYPTION_KEY") or "").strip()
|
|
2274
|
+
print()
|
|
2275
|
+
print(_bold("⚠ Record your encryption key separately"))
|
|
2276
|
+
print("─" * 40)
|
|
2277
|
+
if key:
|
|
2278
|
+
print("This backup does NOT contain your encryption key — by design, so a")
|
|
2279
|
+
print("stolen backup can't be decrypted. The dump is unrecoverable without it.")
|
|
2280
|
+
print()
|
|
2281
|
+
print("Copy SHS_CREDENTIAL_ENCRYPTION_KEY from your .env into a password manager or")
|
|
2282
|
+
print("secrets vault, kept apart from these backup files:")
|
|
2283
|
+
print()
|
|
2284
|
+
print(f" SHS_CREDENTIAL_ENCRYPTION_KEY={key}")
|
|
2285
|
+
else:
|
|
2286
|
+
print(_yellow("No SHS_CREDENTIAL_ENCRYPTION_KEY is set in .env — nothing to record."))
|
|
2287
|
+
print()
|
|
2288
|
+
|
|
2289
|
+
|
|
2290
|
+
def _backups_root(context: str, env_file: Path) -> str:
|
|
2291
|
+
"""Return the directory where backups live for the current context."""
|
|
2292
|
+
return str(backup_root(context, env_file))
|
|
2293
|
+
|
|
2294
|
+
|
|
2295
|
+
def _check_snapshot_space(context: str, env_file: Path) -> None:
|
|
2296
|
+
"""Abort if the backup volume can't hold a pre-restore snapshot.
|
|
2297
|
+
|
|
2298
|
+
Heuristic: require max(1.5 * pg_database_size, db_size + 100 MB).
|
|
2299
|
+
pg_dump --inserts is verbose for small DBs (often >1× on-disk size) and
|
|
2300
|
+
leaner than the live DB for large DBs (no indexes/bloat). The 1.5×
|
|
2301
|
+
factor is a safe upper bound for typical Studio installs.
|
|
2302
|
+
"""
|
|
2303
|
+
import shutil
|
|
2304
|
+
|
|
2305
|
+
env_data = read_env(env_file)
|
|
2306
|
+
pg_user = env_data.get("POSTGRES_USER", "postgres")
|
|
2307
|
+
sql = "SELECT pg_database_size('selfhost_studio')"
|
|
2308
|
+
if context == "host":
|
|
2309
|
+
cmd = compose_cmd(env_file) + [
|
|
2310
|
+
"exec",
|
|
2311
|
+
"-T",
|
|
2312
|
+
"postgres",
|
|
2313
|
+
"psql",
|
|
2314
|
+
"-U",
|
|
2315
|
+
pg_user,
|
|
2316
|
+
"-d",
|
|
2317
|
+
"selfhost_studio",
|
|
2318
|
+
"-tAc",
|
|
2319
|
+
sql,
|
|
2320
|
+
]
|
|
2321
|
+
else:
|
|
2322
|
+
cmd = ["psql", "-U", pg_user, "-d", "selfhost_studio", "-tAc", sql]
|
|
2323
|
+
rc, out = run_quiet(cmd, timeout=10)
|
|
2324
|
+
if rc != 0 or not out.strip().isdigit():
|
|
2325
|
+
return # Can't size the DB; skip the precheck rather than block.
|
|
2326
|
+
db_bytes = int(out.strip())
|
|
2327
|
+
required = max(int(db_bytes * 1.5), db_bytes + 100 * 1024 * 1024)
|
|
2328
|
+
|
|
2329
|
+
backups_root = _backups_root(context, env_file)
|
|
2330
|
+
os.makedirs(backups_root, exist_ok=True)
|
|
2331
|
+
free = shutil.disk_usage(backups_root).free
|
|
2332
|
+
if free < required:
|
|
2333
|
+
fatal(
|
|
2334
|
+
f"Pre-restore snapshot needs ~{required // (1024 * 1024)} MB, "
|
|
2335
|
+
f"only {free // (1024 * 1024)} MB free on {backups_root}.\n"
|
|
2336
|
+
f"Free up space, or set CONSOLE_SKIP_PRE_RESTORE_SNAPSHOT=1 to skip "
|
|
2337
|
+
f"(not recommended)."
|
|
2338
|
+
)
|
|
2339
|
+
|
|
2340
|
+
|
|
2341
|
+
def _take_pre_restore_snapshot(context: str, env_file: Path) -> str | None:
|
|
2342
|
+
"""Take a forced backup of current DB before restore. Returns dir path or None if skipped."""
|
|
2343
|
+
if os.environ.get("CONSOLE_SKIP_PRE_RESTORE_SNAPSHOT", "").strip() in (
|
|
2344
|
+
"1",
|
|
2345
|
+
"true",
|
|
2346
|
+
"yes",
|
|
2347
|
+
):
|
|
2348
|
+
warn("Pre-restore snapshot skipped (CONSOLE_SKIP_PRE_RESTORE_SNAPSHOT set)")
|
|
2349
|
+
return None
|
|
2350
|
+
_check_snapshot_space(context, env_file)
|
|
2351
|
+
info("Taking pre-restore snapshot of current database...")
|
|
2352
|
+
return cmd_backup(context, env_file, what="db", name_prefix="pre-restore")
|
|
2353
|
+
|
|
2354
|
+
|
|
2355
|
+
def _pick_db_file(context: str, env_file: Path) -> str | None:
|
|
2356
|
+
"""Prompt for a directory then let the user pick a .sql file from it."""
|
|
2357
|
+
default_dir = str(backup_root(context, env_file))
|
|
2358
|
+
raw = _prompt(f"Backup directory [{default_dir}]", "").strip()
|
|
2359
|
+
search_dir = os.path.expanduser(raw) if raw else default_dir
|
|
2360
|
+
|
|
2361
|
+
if not os.path.isdir(search_dir):
|
|
2362
|
+
error(f"Directory not found: {search_dir}")
|
|
2363
|
+
return None
|
|
2364
|
+
|
|
2365
|
+
import glob
|
|
2366
|
+
|
|
2367
|
+
sql_files = sorted(
|
|
2368
|
+
glob.glob(os.path.join(search_dir, "**", "*.sql"), recursive=True)
|
|
2369
|
+
)
|
|
2370
|
+
if not sql_files:
|
|
2371
|
+
error(f"No .sql files found in {search_dir}")
|
|
2372
|
+
return None
|
|
2373
|
+
|
|
2374
|
+
labels = []
|
|
2375
|
+
for f in sql_files:
|
|
2376
|
+
rel = os.path.relpath(f, search_dir)
|
|
2377
|
+
meta = _parse_dump_header(f)
|
|
2378
|
+
rev = _read_revision_from_dump(f)
|
|
2379
|
+
if meta or rev:
|
|
2380
|
+
rev_label = (rev or "no-rev")[:12]
|
|
2381
|
+
tag = meta.get("studio_image_tag") or "no-tag"
|
|
2382
|
+
labels.append(f"{rel} {_dim(f'rev={rev_label} tag={tag}')}")
|
|
2383
|
+
else:
|
|
2384
|
+
labels.append(f"{rel} {_dim('(legacy, no header)')}")
|
|
2385
|
+
idx = _interactive_single("Select database file to restore", labels, default=0)
|
|
2386
|
+
return sql_files[idx]
|
|
2387
|
+
|
|
2388
|
+
|
|
2389
|
+
def cmd_restore_db(
|
|
2390
|
+
context: str, env_file: Path, db_file: str, confirm: bool = True
|
|
2391
|
+
) -> bool:
|
|
2392
|
+
"""Restore a single .sql file into the database. Returns True on success."""
|
|
2393
|
+
import time
|
|
2394
|
+
|
|
2395
|
+
env_data = read_env(env_file)
|
|
2396
|
+
pg_user = env_data.get("POSTGRES_USER", "postgres")
|
|
2397
|
+
db_name = "selfhost_studio"
|
|
2398
|
+
|
|
2399
|
+
if confirm:
|
|
2400
|
+
if not _restore_preflight(context, env_file, db_file):
|
|
2401
|
+
return False
|
|
2402
|
+
|
|
2403
|
+
_take_pre_restore_snapshot(context, env_file)
|
|
2404
|
+
|
|
2405
|
+
if context == "host":
|
|
2406
|
+
info("Waiting for postgres...")
|
|
2407
|
+
base = compose_cmd(env_file) + ["exec", "-T", "postgres"]
|
|
2408
|
+
for _ in range(15):
|
|
2409
|
+
rc, _ = run_quiet(base + ["pg_isready", "-U", pg_user])
|
|
2410
|
+
if rc == 0:
|
|
2411
|
+
break
|
|
2412
|
+
time.sleep(2)
|
|
2413
|
+
else:
|
|
2414
|
+
error("Postgres not ready after 30s")
|
|
2415
|
+
return False
|
|
2416
|
+
|
|
2417
|
+
try:
|
|
2418
|
+
if context == "host":
|
|
2419
|
+
# Stop API/UI before dropping the schema — a live API reconnects
|
|
2420
|
+
# to postgres mid-restore (pg_terminate_backend only kills current
|
|
2421
|
+
# connections) and reads/writes a half-restored schema. The
|
|
2422
|
+
# pre-restore snapshot above is the recovery net if this fails.
|
|
2423
|
+
info("Stopping API/UI for exclusive DB access...")
|
|
2424
|
+
_stop_api_ui_containers()
|
|
2425
|
+
base = compose_cmd(env_file) + ["exec", "-T", "postgres"]
|
|
2426
|
+
run_quiet(
|
|
2427
|
+
base
|
|
2428
|
+
+ [
|
|
2429
|
+
"psql",
|
|
2430
|
+
"-q",
|
|
2431
|
+
"-U",
|
|
2432
|
+
pg_user,
|
|
2433
|
+
"-c",
|
|
2434
|
+
f"SELECT pg_terminate_backend(pid) FROM pg_stat_activity "
|
|
2435
|
+
f"WHERE datname = '{db_name}' AND pid <> pg_backend_pid();",
|
|
2436
|
+
],
|
|
2437
|
+
timeout=30,
|
|
2438
|
+
)
|
|
2439
|
+
run_quiet(
|
|
2440
|
+
base
|
|
2441
|
+
+ [
|
|
2442
|
+
"psql",
|
|
2443
|
+
"-q",
|
|
2444
|
+
"-U",
|
|
2445
|
+
pg_user,
|
|
2446
|
+
"-c",
|
|
2447
|
+
"DROP SCHEMA public CASCADE; CREATE SCHEMA public;",
|
|
2448
|
+
db_name,
|
|
2449
|
+
],
|
|
2450
|
+
timeout=30,
|
|
2451
|
+
)
|
|
2452
|
+
info("Applying dump...")
|
|
2453
|
+
with open(db_file) as f:
|
|
2454
|
+
subprocess.run(
|
|
2455
|
+
compose_cmd(env_file)
|
|
2456
|
+
+ ["exec", "-T", "postgres", "psql", "-q", "-U", pg_user, db_name],
|
|
2457
|
+
stdin=f,
|
|
2458
|
+
stdout=subprocess.DEVNULL,
|
|
2459
|
+
stderr=subprocess.PIPE,
|
|
2460
|
+
timeout=300,
|
|
2461
|
+
check=True,
|
|
2462
|
+
)
|
|
2463
|
+
else:
|
|
2464
|
+
run_quiet(
|
|
2465
|
+
[
|
|
2466
|
+
"psql",
|
|
2467
|
+
"-q",
|
|
2468
|
+
"-U",
|
|
2469
|
+
pg_user,
|
|
2470
|
+
"-c",
|
|
2471
|
+
f"SELECT pg_terminate_backend(pid) FROM pg_stat_activity "
|
|
2472
|
+
f"WHERE datname = '{db_name}' AND pid <> pg_backend_pid();",
|
|
2473
|
+
],
|
|
2474
|
+
timeout=30,
|
|
2475
|
+
)
|
|
2476
|
+
run_quiet(
|
|
2477
|
+
[
|
|
2478
|
+
"psql",
|
|
2479
|
+
"-q",
|
|
2480
|
+
"-U",
|
|
2481
|
+
pg_user,
|
|
2482
|
+
"-c",
|
|
2483
|
+
"DROP SCHEMA public CASCADE; CREATE SCHEMA public;",
|
|
2484
|
+
db_name,
|
|
2485
|
+
],
|
|
2486
|
+
timeout=30,
|
|
2487
|
+
)
|
|
2488
|
+
info("Applying dump...")
|
|
2489
|
+
with open(db_file) as f:
|
|
2490
|
+
subprocess.run(
|
|
2491
|
+
["psql", "-q", "-U", pg_user, db_name],
|
|
2492
|
+
stdin=f,
|
|
2493
|
+
stdout=subprocess.DEVNULL,
|
|
2494
|
+
stderr=subprocess.PIPE,
|
|
2495
|
+
timeout=300,
|
|
2496
|
+
check=True,
|
|
2497
|
+
)
|
|
2498
|
+
except subprocess.CalledProcessError as e:
|
|
2499
|
+
error(f"Database restore failed: {e}")
|
|
2500
|
+
if e.stderr:
|
|
2501
|
+
error(
|
|
2502
|
+
e.stderr.decode("utf-8", errors="replace")
|
|
2503
|
+
if isinstance(e.stderr, bytes)
|
|
2504
|
+
else e.stderr
|
|
2505
|
+
)
|
|
2506
|
+
error("Is postgres running? Try: studio-console start")
|
|
2507
|
+
return False
|
|
2508
|
+
except Exception as e:
|
|
2509
|
+
error(f"Database restore failed: {e}")
|
|
2510
|
+
error("Is postgres running? Try: studio-console start")
|
|
2511
|
+
return False
|
|
2512
|
+
|
|
2513
|
+
ok("Database restored")
|
|
2514
|
+
_verify_post_restore(context, env_file, db_file)
|
|
2515
|
+
# .env (and its encryption key) is intentionally left untouched — the key
|
|
2516
|
+
# is never stored in the backup. The preflight already gated key mismatch.
|
|
2517
|
+
|
|
2518
|
+
# API/UI were stopped for exclusive DB access — bring them back up.
|
|
2519
|
+
if context == "host":
|
|
2520
|
+
info("Restarting API/UI...")
|
|
2521
|
+
env_data2 = read_env(env_file)
|
|
2522
|
+
run(
|
|
2523
|
+
_apply_scale_flags(
|
|
2524
|
+
compose_cmd(env_file) + ["up", "-d", "--remove-orphans"], env_data2
|
|
2525
|
+
),
|
|
2526
|
+
timeout=120,
|
|
2527
|
+
)
|
|
2528
|
+
ok("API/UI restarted")
|
|
2529
|
+
if _app_db_url(env_data2):
|
|
2530
|
+
info(
|
|
2531
|
+
"Grants and RLS policies for the restricted role re-apply during "
|
|
2532
|
+
"boot; requests may fail with sanitized 500s until it finishes."
|
|
2533
|
+
)
|
|
2534
|
+
else:
|
|
2535
|
+
# Provisioning/grants/RLS re-apply in the container ENTRYPOINT, so the
|
|
2536
|
+
# restore flow ends with a container restart — supervisorctl restart
|
|
2537
|
+
# is not enough. Until then the restricted role authenticates but every
|
|
2538
|
+
# table access is permission-denied → sanitized 500s (fail-closed).
|
|
2539
|
+
warn("Restart this container from the host now to finish the restore.")
|
|
2540
|
+
print(f" {_bold('docker restart <container>')} {_dim('(or stop/start the pod on RunPod)')}")
|
|
2541
|
+
if _app_db_url(env_data):
|
|
2542
|
+
warn(
|
|
2543
|
+
"Until the restart, API requests fail with sanitized 500s — "
|
|
2544
|
+
"the restore dropped the restricted role's grants; boot re-applies them."
|
|
2545
|
+
)
|
|
2546
|
+
|
|
2547
|
+
return True
|
|
2548
|
+
|
|
2549
|
+
|
|
2550
|
+
def cmd_restore(
|
|
2551
|
+
context: str, env_file: Path, path: str | None, what: str = "all"
|
|
2552
|
+
) -> None:
|
|
2553
|
+
"""Restore database and/or org files from backup."""
|
|
2554
|
+
env_data = read_env(env_file)
|
|
2555
|
+
|
|
2556
|
+
if path:
|
|
2557
|
+
restore_dir = path
|
|
2558
|
+
else:
|
|
2559
|
+
backup_base = str(backup_root(context, env_file))
|
|
2560
|
+
if not os.path.isdir(backup_base):
|
|
2561
|
+
fatal("No backups found")
|
|
2562
|
+
entries = sorted(os.listdir(backup_base), reverse=True)
|
|
2563
|
+
if not entries:
|
|
2564
|
+
fatal("No backups found")
|
|
2565
|
+
restore_dir = os.path.join(backup_base, entries[0])
|
|
2566
|
+
|
|
2567
|
+
info(f"Restoring from: {restore_dir}")
|
|
2568
|
+
|
|
2569
|
+
if what in ("all", "db"):
|
|
2570
|
+
db_file = os.path.join(restore_dir, "database.sql")
|
|
2571
|
+
if os.path.isfile(db_file):
|
|
2572
|
+
print()
|
|
2573
|
+
if not _restore_preflight(context, env_file, db_file):
|
|
2574
|
+
return
|
|
2575
|
+
|
|
2576
|
+
_take_pre_restore_snapshot(context, env_file)
|
|
2577
|
+
|
|
2578
|
+
pg_user = env_data.get("POSTGRES_USER", "postgres")
|
|
2579
|
+
db_name = "selfhost_studio"
|
|
2580
|
+
|
|
2581
|
+
if context == "host":
|
|
2582
|
+
# Stop API/UI before dropping the schema — see cmd_restore_db.
|
|
2583
|
+
# A live API reconnects mid-restore and reads a half-restored
|
|
2584
|
+
# schema; the pre-restore snapshot above is the recovery net.
|
|
2585
|
+
info("Stopping API/UI for exclusive DB access...")
|
|
2586
|
+
_stop_api_ui_containers()
|
|
2587
|
+
base = compose_cmd(env_file) + ["exec", "-T", "postgres"]
|
|
2588
|
+
run_quiet(
|
|
2589
|
+
base
|
|
2590
|
+
+ [
|
|
2591
|
+
"psql",
|
|
2592
|
+
"-q",
|
|
2593
|
+
"-U",
|
|
2594
|
+
pg_user,
|
|
2595
|
+
"-c",
|
|
2596
|
+
f"SELECT pg_terminate_backend(pid) FROM pg_stat_activity "
|
|
2597
|
+
f"WHERE datname = '{db_name}' AND pid <> pg_backend_pid();",
|
|
2598
|
+
],
|
|
2599
|
+
timeout=30,
|
|
2600
|
+
)
|
|
2601
|
+
run_quiet(
|
|
2602
|
+
base
|
|
2603
|
+
+ [
|
|
2604
|
+
"psql",
|
|
2605
|
+
"-q",
|
|
2606
|
+
"-U",
|
|
2607
|
+
pg_user,
|
|
2608
|
+
"-c",
|
|
2609
|
+
"DROP SCHEMA public CASCADE; CREATE SCHEMA public;",
|
|
2610
|
+
db_name,
|
|
2611
|
+
],
|
|
2612
|
+
timeout=30,
|
|
2613
|
+
)
|
|
2614
|
+
info("Applying dump...")
|
|
2615
|
+
with open(db_file) as f:
|
|
2616
|
+
subprocess.run(
|
|
2617
|
+
compose_cmd(env_file)
|
|
2618
|
+
+ [
|
|
2619
|
+
"exec",
|
|
2620
|
+
"-T",
|
|
2621
|
+
"postgres",
|
|
2622
|
+
"psql",
|
|
2623
|
+
"-q",
|
|
2624
|
+
"-U",
|
|
2625
|
+
pg_user,
|
|
2626
|
+
db_name,
|
|
2627
|
+
],
|
|
2628
|
+
stdin=f,
|
|
2629
|
+
stdout=subprocess.DEVNULL,
|
|
2630
|
+
stderr=subprocess.PIPE,
|
|
2631
|
+
timeout=300,
|
|
2632
|
+
check=True,
|
|
2633
|
+
)
|
|
2634
|
+
else:
|
|
2635
|
+
run_quiet(
|
|
2636
|
+
[
|
|
2637
|
+
"psql",
|
|
2638
|
+
"-q",
|
|
2639
|
+
"-U",
|
|
2640
|
+
pg_user,
|
|
2641
|
+
"-c",
|
|
2642
|
+
f"SELECT pg_terminate_backend(pid) FROM pg_stat_activity "
|
|
2643
|
+
f"WHERE datname = '{db_name}' AND pid <> pg_backend_pid();",
|
|
2644
|
+
],
|
|
2645
|
+
timeout=30,
|
|
2646
|
+
)
|
|
2647
|
+
run_quiet(
|
|
2648
|
+
[
|
|
2649
|
+
"psql",
|
|
2650
|
+
"-q",
|
|
2651
|
+
"-U",
|
|
2652
|
+
pg_user,
|
|
2653
|
+
"-c",
|
|
2654
|
+
"DROP SCHEMA public CASCADE; CREATE SCHEMA public;",
|
|
2655
|
+
db_name,
|
|
2656
|
+
],
|
|
2657
|
+
timeout=30,
|
|
2658
|
+
)
|
|
2659
|
+
info("Applying dump...")
|
|
2660
|
+
with open(db_file) as f:
|
|
2661
|
+
subprocess.run(
|
|
2662
|
+
["psql", "-q", "-U", pg_user, db_name],
|
|
2663
|
+
stdin=f,
|
|
2664
|
+
stdout=subprocess.DEVNULL,
|
|
2665
|
+
stderr=subprocess.PIPE,
|
|
2666
|
+
timeout=300,
|
|
2667
|
+
check=True,
|
|
2668
|
+
)
|
|
2669
|
+
ok("Database restored")
|
|
2670
|
+
_verify_post_restore(context, env_file, db_file)
|
|
2671
|
+
# .env (and its encryption key) is intentionally left untouched — the
|
|
2672
|
+
# key is never stored in the backup. Preflight gated key mismatch.
|
|
2673
|
+
else:
|
|
2674
|
+
warn(f"No database.sql in {restore_dir}")
|
|
2675
|
+
|
|
2676
|
+
if what in ("all", "orgs"):
|
|
2677
|
+
orgs_archive = os.path.join(restore_dir, "orgs.tar.gz")
|
|
2678
|
+
if os.path.isfile(orgs_archive):
|
|
2679
|
+
# Archive root is `orgs/...` so extract into shared/ (the parent of orgs/).
|
|
2680
|
+
target = str(storage_root(context, env_file))
|
|
2681
|
+
info("Restoring organization files...")
|
|
2682
|
+
run(["tar", "xzf", orgs_archive, "-C", target], timeout=300)
|
|
2683
|
+
ok("Org files restored")
|
|
2684
|
+
else:
|
|
2685
|
+
warn(f"No orgs.tar.gz in {restore_dir}")
|
|
2686
|
+
|
|
2687
|
+
# API/UI were stopped for exclusive DB access — bring them back up.
|
|
2688
|
+
if context == "host":
|
|
2689
|
+
print()
|
|
2690
|
+
info("Restarting API/UI...")
|
|
2691
|
+
run(
|
|
2692
|
+
_apply_scale_flags(
|
|
2693
|
+
compose_cmd(env_file) + ["up", "-d", "--remove-orphans"], env_data
|
|
2694
|
+
),
|
|
2695
|
+
timeout=120,
|
|
2696
|
+
)
|
|
2697
|
+
ok("API/UI restarted")
|
|
2698
|
+
|
|
2699
|
+
|
|
2700
|
+
def _fetch_available_versions(limit: int = 10) -> list[str]:
|
|
2701
|
+
"""Fetch available version tags from GHCR."""
|
|
2702
|
+
import urllib.request
|
|
2703
|
+
import urllib.error
|
|
2704
|
+
|
|
2705
|
+
# GHCR requires a token even for public repos
|
|
2706
|
+
token_url = "https://ghcr.io/token?scope=repository:selfhosthub/studio-api:pull"
|
|
2707
|
+
try:
|
|
2708
|
+
with urllib.request.urlopen(token_url, timeout=10) as resp:
|
|
2709
|
+
token = json.loads(resp.read().decode()).get("token", "")
|
|
2710
|
+
except (urllib.error.URLError, OSError, json.JSONDecodeError):
|
|
2711
|
+
return []
|
|
2712
|
+
|
|
2713
|
+
url = "https://ghcr.io/v2/selfhosthub/studio-api/tags/list"
|
|
2714
|
+
try:
|
|
2715
|
+
req = urllib.request.Request(
|
|
2716
|
+
url,
|
|
2717
|
+
headers={
|
|
2718
|
+
"Accept": "application/json",
|
|
2719
|
+
"Authorization": f"Bearer {token}",
|
|
2720
|
+
},
|
|
2721
|
+
)
|
|
2722
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
2723
|
+
data = json.loads(resp.read().decode())
|
|
2724
|
+
except (urllib.error.URLError, OSError, json.JSONDecodeError):
|
|
2725
|
+
return []
|
|
2726
|
+
|
|
2727
|
+
# Filter to semver tags (X.Y.Z), skip "latest" and digests
|
|
2728
|
+
versions: list[str] = []
|
|
2729
|
+
for name in data.get("tags", []):
|
|
2730
|
+
if re.match(r"^\d+\.\d+\.\d+$", name):
|
|
2731
|
+
versions.append(name)
|
|
2732
|
+
|
|
2733
|
+
# Sort descending by semver
|
|
2734
|
+
versions.sort(key=lambda v: tuple(int(x) for x in v.split(".")), reverse=True)
|
|
2735
|
+
return versions[:limit]
|
|
2736
|
+
|
|
2737
|
+
|
|
2738
|
+
def cmd_upgrade(context: str, env_file: Path) -> None:
|
|
2739
|
+
"""Upgrade or rollback Studio to a specific version."""
|
|
2740
|
+
env_data = read_env(env_file)
|
|
2741
|
+
current = env_data.get("SHS_STUDIO_VERSION", "unknown")
|
|
2742
|
+
|
|
2743
|
+
if context != "host":
|
|
2744
|
+
warn("In-container upgrades are not supported.")
|
|
2745
|
+
print(f"\n Current version: {_bold(current)}")
|
|
2746
|
+
print()
|
|
2747
|
+
if context == "runpod":
|
|
2748
|
+
print(" To upgrade:")
|
|
2749
|
+
print(" 1. Update the pod template to use the new image tag")
|
|
2750
|
+
print(" 2. Stop and restart the pod")
|
|
2751
|
+
print(" Your data is safe on the network volume.")
|
|
2752
|
+
else:
|
|
2753
|
+
from .env import detect_shape
|
|
2754
|
+
|
|
2755
|
+
shape = detect_shape(env_file) or "core"
|
|
2756
|
+
image = "studio-full" if shape == "full" else "studio-core"
|
|
2757
|
+
print(" To upgrade:")
|
|
2758
|
+
print(
|
|
2759
|
+
f" 1. Pull the new image: docker pull ghcr.io/selfhosthub/{image}:<tag>"
|
|
2760
|
+
)
|
|
2761
|
+
print(" 2. Stop the current container")
|
|
2762
|
+
print(" 3. Start a new container with the updated image")
|
|
2763
|
+
return
|
|
2764
|
+
|
|
2765
|
+
print(f"\n Current version: {_bold(current)}")
|
|
2766
|
+
|
|
2767
|
+
info("Checking available versions...")
|
|
2768
|
+
versions = _fetch_available_versions()
|
|
2769
|
+
|
|
2770
|
+
if not versions:
|
|
2771
|
+
warn("Could not fetch versions from Docker Hub")
|
|
2772
|
+
target = _prompt("Enter version to install (e.g. 1.1.0)")
|
|
2773
|
+
else:
|
|
2774
|
+
print()
|
|
2775
|
+
for i, v in enumerate(versions, 1):
|
|
2776
|
+
marker = " ← current" if v == current else ""
|
|
2777
|
+
print(f" {_bold(str(i))}. {v}{marker}")
|
|
2778
|
+
print(f" {_bold(str(len(versions) + 1))}. Enter manually")
|
|
2779
|
+
|
|
2780
|
+
while True:
|
|
2781
|
+
raw = _prompt("Select version", "1")
|
|
2782
|
+
try:
|
|
2783
|
+
pick = int(raw.strip())
|
|
2784
|
+
if 1 <= pick <= len(versions):
|
|
2785
|
+
target = versions[pick - 1]
|
|
2786
|
+
break
|
|
2787
|
+
elif pick == len(versions) + 1:
|
|
2788
|
+
target = _prompt("Enter version (e.g. 1.1.0)")
|
|
2789
|
+
break
|
|
2790
|
+
except ValueError:
|
|
2791
|
+
pass
|
|
2792
|
+
print(f" {_red('✗')} Enter a number between 1 and {len(versions) + 1}")
|
|
2793
|
+
|
|
2794
|
+
if target == current:
|
|
2795
|
+
info(f"Already on {current}, nothing to do")
|
|
2796
|
+
return
|
|
2797
|
+
|
|
2798
|
+
if mv.check_and_block(
|
|
2799
|
+
env_file, mv.parse_target_major(target), "upgrade to", context, target
|
|
2800
|
+
):
|
|
2801
|
+
return
|
|
2802
|
+
|
|
2803
|
+
direction = "Upgrading" if target > current else "Rolling back"
|
|
2804
|
+
info(f"{direction}: {current} → {_bold(target)}")
|
|
2805
|
+
|
|
2806
|
+
# Update version in .env
|
|
2807
|
+
set_env_value(env_file, "SHS_STUDIO_VERSION", target)
|
|
2808
|
+
ok(f"SHS_STUDIO_VERSION set to {target}")
|
|
2809
|
+
|
|
2810
|
+
# Pull new images
|
|
2811
|
+
info("Pulling images...")
|
|
2812
|
+
run(compose_cmd(env_file) + ["pull"], timeout=300)
|
|
2813
|
+
ok("Images pulled")
|
|
2814
|
+
|
|
2815
|
+
# Restart — reapply scale flags + --remove-orphans so replica counts survive
|
|
2816
|
+
# the upgrade and stale containers from the old version are cleaned up.
|
|
2817
|
+
info("Restarting services...")
|
|
2818
|
+
env_data = read_env(env_file)
|
|
2819
|
+
run(
|
|
2820
|
+
_apply_scale_flags(
|
|
2821
|
+
compose_cmd(env_file) + ["up", "-d", "--remove-orphans"], env_data
|
|
2822
|
+
),
|
|
2823
|
+
timeout=120,
|
|
2824
|
+
)
|
|
2825
|
+
ok(f"Studio {direction.lower()} to {target}")
|
|
2826
|
+
|
|
2827
|
+
|
|
2828
|
+
def cmd_links(context: str, env_file: Path) -> None:
|
|
2829
|
+
"""Print service URLs."""
|
|
2830
|
+
env_data = read_env(env_file)
|
|
2831
|
+
_print_links(env_data, context)
|
|
2832
|
+
|
|
2833
|
+
|
|
2834
|
+
def _print_links(env_data: dict[str, str], context: str) -> None:
|
|
2835
|
+
"""Print service URLs from env data."""
|
|
2836
|
+
public_url = env_data.get("SHS_PUBLIC_BASE_URL", "")
|
|
2837
|
+
api_url = env_data.get("CONSOLE_PUBLIC_API_BASE_URL", "")
|
|
2838
|
+
nginx_port = env_data.get("SHS_NGINX_PORT", "80")
|
|
2839
|
+
has_public = public_url.startswith("https://")
|
|
2840
|
+
|
|
2841
|
+
print(f"\n{_bold('Links:')}")
|
|
2842
|
+
print(f" Studio: http://localhost:{nginx_port}")
|
|
2843
|
+
print(f" API: http://localhost:{nginx_port}/api")
|
|
2844
|
+
print(f" Health: http://localhost:{nginx_port}/health")
|
|
2845
|
+
print(f" Nginx: http://localhost:{nginx_port}/nginx-health")
|
|
2846
|
+
|
|
2847
|
+
if has_public:
|
|
2848
|
+
print()
|
|
2849
|
+
print(f" Public: {public_url}")
|
|
2850
|
+
if api_url.startswith("https://"):
|
|
2851
|
+
print(f" Public API: {api_url}")
|
|
2852
|
+
|
|
2853
|
+
if context in ("container", "runpod"):
|
|
2854
|
+
print(f" Supervisor: http://localhost:9001")
|
|
2855
|
+
|
|
2856
|
+
print()
|
|
2857
|
+
|
|
2858
|
+
|
|
2859
|
+
def _detect_install_method() -> str:
|
|
2860
|
+
"""Detect how studio-console was installed.
|
|
2861
|
+
|
|
2862
|
+
Returns one of: "uv", "pip", "brew", "curl", "dev"
|
|
2863
|
+
"""
|
|
2864
|
+
from pathlib import Path
|
|
2865
|
+
|
|
2866
|
+
pkg_dir = Path(__file__).resolve().parent.parent
|
|
2867
|
+
|
|
2868
|
+
# Marker written by install.sh
|
|
2869
|
+
marker = pkg_dir / ".install-method"
|
|
2870
|
+
if marker.exists():
|
|
2871
|
+
return marker.read_text().strip()
|
|
2872
|
+
|
|
2873
|
+
# uv tool install — path contains uv/tools, no pip module available
|
|
2874
|
+
if "uv/tools" in str(pkg_dir).replace("\\", "/"):
|
|
2875
|
+
return "uv"
|
|
2876
|
+
|
|
2877
|
+
# pip install puts the package inside site-packages
|
|
2878
|
+
if "site-packages" in str(pkg_dir):
|
|
2879
|
+
return "pip"
|
|
2880
|
+
|
|
2881
|
+
# Homebrew installs under Cellar
|
|
2882
|
+
if "Cellar" in str(pkg_dir) or "homebrew" in str(pkg_dir).lower():
|
|
2883
|
+
return "brew"
|
|
2884
|
+
|
|
2885
|
+
# Inside a git repo — dev mode
|
|
2886
|
+
if (pkg_dir / ".git").exists() or (pkg_dir.parent / ".git").exists():
|
|
2887
|
+
return "dev"
|
|
2888
|
+
|
|
2889
|
+
return "curl"
|
|
2890
|
+
|
|
2891
|
+
|
|
2892
|
+
def cmd_self_update(context: str) -> None:
|
|
2893
|
+
"""Update studio-console — delegates to the correct mechanism for the install method."""
|
|
2894
|
+
import json
|
|
2895
|
+
import os
|
|
2896
|
+
import shutil
|
|
2897
|
+
import subprocess
|
|
2898
|
+
import tarfile
|
|
2899
|
+
import tempfile
|
|
2900
|
+
import urllib.error
|
|
2901
|
+
import urllib.request
|
|
2902
|
+
from pathlib import Path
|
|
2903
|
+
|
|
2904
|
+
from . import __version__
|
|
2905
|
+
|
|
2906
|
+
if context in ("container", "runpod"):
|
|
2907
|
+
warn("studio-console is baked into the container image.")
|
|
2908
|
+
warn("Update by pulling a new image tag — not via self-update.")
|
|
2909
|
+
return
|
|
2910
|
+
|
|
2911
|
+
method = _detect_install_method()
|
|
2912
|
+
info(f"Current version: {__version__} (installed via {method})")
|
|
2913
|
+
|
|
2914
|
+
if method == "dev":
|
|
2915
|
+
warn("Running from source — use git pull to update.")
|
|
2916
|
+
return
|
|
2917
|
+
|
|
2918
|
+
if method == "brew":
|
|
2919
|
+
info("Updating via Homebrew...")
|
|
2920
|
+
run(["brew", "upgrade", "selfhosthub/studio/studio-console"], timeout=120)
|
|
2921
|
+
ok("Updated")
|
|
2922
|
+
return
|
|
2923
|
+
|
|
2924
|
+
if method == "pip":
|
|
2925
|
+
info("Updating via pip...")
|
|
2926
|
+
run(
|
|
2927
|
+
[os.sys.executable, "-m", "pip", "install", "--upgrade", "studio-console"],
|
|
2928
|
+
timeout=120,
|
|
2929
|
+
)
|
|
2930
|
+
ok("Updated — restart studio-console to use the new version")
|
|
2931
|
+
return
|
|
2932
|
+
|
|
2933
|
+
# uv and curl installs both resolve the latest GitHub release first
|
|
2934
|
+
info("Checking for updates...")
|
|
2935
|
+
api_url = "https://api.github.com/repos/selfhosthub/studio-console/releases/latest"
|
|
2936
|
+
try:
|
|
2937
|
+
req = urllib.request.Request(
|
|
2938
|
+
api_url, headers={"Accept": "application/vnd.github+json"}
|
|
2939
|
+
)
|
|
2940
|
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
|
2941
|
+
data = json.loads(resp.read().decode())
|
|
2942
|
+
except (urllib.error.URLError, OSError, json.JSONDecodeError) as e:
|
|
2943
|
+
fatal(f"Could not reach GitHub: {e}")
|
|
2944
|
+
|
|
2945
|
+
latest_tag = data.get("tag_name", "")
|
|
2946
|
+
latest_version = latest_tag.lstrip("v")
|
|
2947
|
+
if not latest_version:
|
|
2948
|
+
fatal("Could not determine latest version from GitHub response")
|
|
2949
|
+
|
|
2950
|
+
if latest_version == __version__:
|
|
2951
|
+
ok(f"Already up to date ({__version__})")
|
|
2952
|
+
return
|
|
2953
|
+
|
|
2954
|
+
info(f"Updating {__version__} → {latest_version}")
|
|
2955
|
+
|
|
2956
|
+
if method == "uv":
|
|
2957
|
+
wheel_url = (
|
|
2958
|
+
f"https://github.com/selfhosthub/studio-console/releases/download/"
|
|
2959
|
+
f"{latest_tag}/studio_console-{latest_version}-py3-none-any.whl"
|
|
2960
|
+
)
|
|
2961
|
+
info("Updating via uv tool...")
|
|
2962
|
+
run(["uv", "tool", "install", "--force", wheel_url], timeout=120)
|
|
2963
|
+
ok("Updated — restart studio-console to use the new version")
|
|
2964
|
+
return
|
|
2965
|
+
|
|
2966
|
+
tarball_url = f"https://github.com/selfhosthub/studio-console/archive/refs/tags/{latest_tag}.tar.gz"
|
|
2967
|
+
install_dir = Path(__file__).resolve().parent.parent
|
|
2968
|
+
|
|
2969
|
+
tmp = tempfile.mkdtemp()
|
|
2970
|
+
try:
|
|
2971
|
+
tarball_path = os.path.join(tmp, "release.tar.gz")
|
|
2972
|
+
info("Downloading...")
|
|
2973
|
+
urllib.request.urlretrieve(tarball_url, tarball_path)
|
|
2974
|
+
|
|
2975
|
+
extract_dir = os.path.join(tmp, "extracted")
|
|
2976
|
+
os.makedirs(extract_dir)
|
|
2977
|
+
with tarfile.open(tarball_path, "r:gz") as tf:
|
|
2978
|
+
members = tf.getmembers()
|
|
2979
|
+
prefix = members[0].name.split("/")[0] + "/" if members else ""
|
|
2980
|
+
for member in members:
|
|
2981
|
+
if member.name.startswith(prefix):
|
|
2982
|
+
member.name = member.name[len(prefix) :]
|
|
2983
|
+
if member.name:
|
|
2984
|
+
tf.extract(member, extract_dir)
|
|
2985
|
+
|
|
2986
|
+
for item in os.listdir(extract_dir):
|
|
2987
|
+
src = os.path.join(extract_dir, item)
|
|
2988
|
+
dst = os.path.join(str(install_dir), item)
|
|
2989
|
+
if os.path.isdir(src):
|
|
2990
|
+
if os.path.exists(dst):
|
|
2991
|
+
shutil.rmtree(dst)
|
|
2992
|
+
shutil.copytree(src, dst)
|
|
2993
|
+
else:
|
|
2994
|
+
shutil.copy2(src, dst)
|
|
2995
|
+
|
|
2996
|
+
# Preserve the install method marker
|
|
2997
|
+
(install_dir / ".install-method").write_text("curl")
|
|
2998
|
+
finally:
|
|
2999
|
+
shutil.rmtree(tmp, ignore_errors=True)
|
|
3000
|
+
|
|
3001
|
+
ok(f"Updated to {latest_version}")
|