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.
@@ -0,0 +1,526 @@
1
+ # studio_console/commands_container.py
2
+ """Container-mode menu for Core / Full images."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import os
7
+ import re
8
+ from pathlib import Path
9
+
10
+ from .cloudflare.cf_wizard import update_domain, update_ip_rules
11
+ from .commands import (
12
+ _submenu_backup,
13
+ cmd_db_role,
14
+ cmd_health,
15
+ cmd_logs,
16
+ cmd_reset_password,
17
+ cmd_restart,
18
+ cmd_show_config,
19
+ )
20
+ from .env import (
21
+ derive_url_vars,
22
+ detect_shape,
23
+ promote_runpod_secrets,
24
+ read_env,
25
+ run_quiet,
26
+ set_env_value,
27
+ )
28
+ from .tui import (
29
+ NavBack,
30
+ NavExit,
31
+ _bold,
32
+ _cyan,
33
+ _dim,
34
+ _red,
35
+ _interactive_single,
36
+ _interactive_yn,
37
+ _prompt,
38
+ error,
39
+ heading,
40
+ ok,
41
+ )
42
+ from .cloudflare.cf_wizard import cf_full_setup
43
+ from .wizard import (
44
+ SetupState,
45
+ _ask_api_hostname,
46
+ _ask_ip_restrict_mode,
47
+ _ask_root_domain,
48
+ _ask_ui_subdomain,
49
+ )
50
+
51
+
52
+ def container_menu(context: str, env_file: Path) -> None:
53
+ """Interactive menu for in-container operation."""
54
+ promote_runpod_secrets(env_file)
55
+
56
+ shape = detect_shape(env_file) or "core"
57
+ heading(f"Studio ({shape})")
58
+
59
+ actions: list[tuple[str, str]] = [
60
+ (f"Health {_dim('supervisorctl status')}", "health"),
61
+ (f"Restart {_dim('restart a service or all')}", "restart"),
62
+ (f"Logs {_dim('tail a service log')}", "logs"),
63
+ (f"Config {_dim('show .env (secrets masked)')}", "config"),
64
+ ]
65
+ actions.append((f"Workers {_dim('scale general / transfer')}", "workers"))
66
+ actions.append((f"DB role {_dim('restricted runtime role (RLS)')}", "dbrole"))
67
+ if shape == "full":
68
+ actions.append((f"Backup {_dim('backup · restore')}", "backup"))
69
+ actions += [
70
+ (f"Cloudflare {_dim('tunnel · domain · access rules')}", "cloudflare"),
71
+ (f"Reset password {_dim('super-admin password reset')}", "reset"),
72
+ (f"Exit", "exit"),
73
+ ]
74
+ menu_options = [label for label, _ in actions]
75
+
76
+ while True:
77
+ if env_file.exists():
78
+ env_data = read_env(env_file)
79
+ public_url = env_data.get("SHS_PUBLIC_BASE_URL", "")
80
+ if public_url.startswith("https://"):
81
+ print(f" Public URL: {public_url}")
82
+ print()
83
+
84
+ idx = _interactive_single("Studio", menu_options, default=0, nav=False)
85
+ action = actions[idx][1]
86
+
87
+ try:
88
+ if action == "health":
89
+ cmd_health(context, env_file)
90
+ elif action == "restart":
91
+ _restart_picker(context, env_file)
92
+ elif action == "logs":
93
+ _logs_picker(context, env_file)
94
+ elif action == "config":
95
+ if not env_file.exists():
96
+ error(f"No .env at {env_file} — entrypoint hasn't run.")
97
+ else:
98
+ cmd_show_config(context, env_file)
99
+ elif action == "workers":
100
+ _cmd_workers()
101
+ elif action == "dbrole":
102
+ cmd_db_role(context, env_file)
103
+ elif action == "backup":
104
+ _submenu_backup(context, env_file)
105
+ elif action == "cloudflare":
106
+ _cloudflare_menu(env_file)
107
+ elif action == "reset":
108
+ cmd_reset_password(context, env_file)
109
+ elif action == "exit":
110
+ return
111
+ except (NavBack, NavExit):
112
+ pass
113
+
114
+ print()
115
+
116
+
117
+ # Scalable workers in the full/core image: supervisord program group → env var
118
+ # holding its numprocs. Singleton services (api/ui/nginx/postgres) are pinned
119
+ # numprocs=1 in the image and are not listed here.
120
+ _WORKER_GROUPS: list[tuple[str, str]] = [
121
+ ("worker-general", "SHS_GENERAL_WORKERS"),
122
+ ("worker-transfer", "SHS_TRANSFER_WORKERS"),
123
+ ]
124
+
125
+ # Supervisor conf fragment per worker group. A literal numprocs can be rescaled
126
+ # in place; an %(ENV_...)s one needs a container restart to re-source .env.
127
+ _WORKER_CONF = {
128
+ "worker-general": "/etc/supervisor/conf.d/worker-general.conf",
129
+ "worker-transfer": "/etc/supervisor/conf.d/worker-transfer.conf",
130
+ }
131
+ _NUMPROCS_RE = re.compile(r"^numprocs=(.*)$", re.MULTILINE)
132
+
133
+
134
+ def _fragment_is_literal(group: str) -> bool | None:
135
+ """True if numprocs is a literal, False if env-expanded, None if unreadable."""
136
+ try:
137
+ text = Path(_WORKER_CONF[group]).read_text()
138
+ except OSError:
139
+ return None
140
+ m = _NUMPROCS_RE.search(text)
141
+ if not m:
142
+ return None
143
+ return m.group(1).strip().isdigit()
144
+
145
+
146
+ def _configured_numprocs(group: str) -> str | None:
147
+ """The literal numprocs from the conf fragment (the source of truth), or
148
+ None if unreadable/env-expanded."""
149
+ try:
150
+ text = Path(_WORKER_CONF[group]).read_text()
151
+ except OSError:
152
+ return None
153
+ m = _NUMPROCS_RE.search(text)
154
+ if m and m.group(1).strip().isdigit():
155
+ return m.group(1).strip()
156
+ return None
157
+
158
+
159
+ def _rewrite_numprocs(group: str, count: int) -> bool:
160
+ """Rewrite the numprocs= line in place, preserving the fragment's mode/owner."""
161
+ path = Path(_WORKER_CONF[group])
162
+ try:
163
+ text = path.read_text()
164
+ except OSError:
165
+ return False
166
+ new_text, n = _NUMPROCS_RE.subn(f"numprocs={count}", text)
167
+ if n != 1:
168
+ return False
169
+ try:
170
+ path.write_text(new_text)
171
+ except OSError:
172
+ return False
173
+ return True
174
+
175
+
176
+ def _running_proc_count(status_out: str, group: str) -> int:
177
+ """Count RUNNING procs in a supervisord process group.
178
+
179
+ numprocs groups list as `group:group_NN RUNNING pid ...`. Count ONLY
180
+ procs whose state field is RUNNING — a proc in FATAL/STOPPED/STARTING/
181
+ BACKOFF still appears in the listing, so matching the group name alone
182
+ over-reports the live count.
183
+ """
184
+ count = 0
185
+ prefix = f"{group}:"
186
+ for line in status_out.splitlines():
187
+ parts = line.split()
188
+ if len(parts) >= 2 and parts[0].startswith(prefix) and parts[1] == "RUNNING":
189
+ count += 1
190
+ return count
191
+
192
+
193
+ def _apply_worker_numprocs(group: str, count: int) -> None:
194
+ """Rescale a group in place (rewrite fragment + reread/update + verify), or
195
+ fall back to the container-restart notice when the fragment is env-expanded."""
196
+ from .env import run_quiet
197
+
198
+ if _fragment_is_literal(group) is not True:
199
+ _print_container_restart_notice()
200
+ return
201
+
202
+ if not _rewrite_numprocs(group, count):
203
+ error(f"{group}: could not update {_WORKER_CONF[group]} — skipped.")
204
+ _print_container_restart_notice()
205
+ return
206
+
207
+ run_quiet(["supervisorctl", "reread"], timeout=15)
208
+ run_quiet(["supervisorctl", "update"], timeout=30)
209
+
210
+ # New procs pass through STARTING (startsecs) before RUNNING — poll briefly.
211
+ running = _poll_worker_count(group, count)
212
+ if running == count:
213
+ ok(f"{group}: now {count}")
214
+ else:
215
+ error(
216
+ f"{group}: expected {count} but {running} running after reread/update — "
217
+ f"check `supervisorctl status` and {_WORKER_CONF[group]}."
218
+ )
219
+
220
+
221
+ def _poll_worker_count(group: str, target: int, timeout: float = 12.0) -> int:
222
+ """Poll supervisorctl until the group's RUNNING count hits target or timeout."""
223
+ import time
224
+
225
+ from .env import run_quiet
226
+
227
+ deadline = time.monotonic() + timeout
228
+ running = -1
229
+ while True:
230
+ _, status_out = run_quiet(["supervisorctl", "status"], timeout=10)
231
+ running = _running_proc_count(status_out, group)
232
+ if running == target or time.monotonic() >= deadline:
233
+ return running
234
+ time.sleep(1)
235
+
236
+
237
+ def _print_container_restart_notice() -> None:
238
+ container = os.environ.get("HOSTNAME", "").strip() or "<container>"
239
+ print(f" {_red('Restart the whole container to apply — the count is frozen at boot.')}")
240
+ print(f" {_red('Any in-flight jobs on these workers will be dropped.')}")
241
+ print(f" {_red('From the host (or the RunPod pod controls):')} {_red(_bold(f'docker restart {container}'))}")
242
+
243
+
244
+ def _cmd_workers() -> None:
245
+ """View and set general/transfer worker counts. The conf fragment's literal
246
+ numprocs is the source of truth; applies in place via reread/update when
247
+ supported, else prints the container-restart notice."""
248
+ from .env import run_quiet
249
+
250
+ _, status_out = run_quiet(["supervisorctl", "status"], timeout=10)
251
+
252
+ heading("Workers")
253
+ print(f" {'worker':<16}{'configured':<12}{'running'}")
254
+ for group, _var in _WORKER_GROUPS:
255
+ configured = _configured_numprocs(group) or "?"
256
+ running = _running_proc_count(status_out, group)
257
+ note = " (restart to apply)" if str(running) != configured else ""
258
+ print(f" {group:<16}{configured:<12}{running}{_dim(note)}")
259
+ print()
260
+
261
+ pending: list[tuple[str, int]] = []
262
+ for group, _var in _WORKER_GROUPS:
263
+ current = _configured_numprocs(group) or "1"
264
+ raw = _prompt(f"{group} count", current)
265
+ if raw == current:
266
+ continue
267
+ try:
268
+ count = int(raw)
269
+ if count < 0:
270
+ raise ValueError
271
+ except ValueError:
272
+ error(f"{group}: '{raw}' is not a non-negative integer — skipped.")
273
+ continue
274
+ pending.append((group, count))
275
+
276
+ if not pending:
277
+ return
278
+
279
+ if not _interactive_yn(
280
+ "Apply now? in-flight jobs on removed workers will be dropped.",
281
+ default=True,
282
+ nav=False,
283
+ ):
284
+ print(_dim(" No change — worker counts persist in each conf fragment."))
285
+ return
286
+
287
+ for group, count in pending:
288
+ _apply_worker_numprocs(group, count)
289
+
290
+
291
+ def _kick_cloudflared(env_file: Path) -> None:
292
+ """Start cloudflared and restart UI so __env.js picks up new public URLs."""
293
+ import time
294
+
295
+ token = read_env(env_file).get("CLOUDFLARE_TUNNEL_TOKEN", "").strip()
296
+ if not token:
297
+ return
298
+
299
+ _, out = run_quiet(["supervisorctl", "status", "cloudflared"], timeout=10)
300
+ state = out.split()[1] if len(out.split()) >= 2 else ""
301
+ action = "restart" if state == "RUNNING" else "start"
302
+ run_quiet(["supervisorctl", action, "cloudflared"], timeout=15)
303
+
304
+ deadline = time.monotonic() + 30
305
+ final_state = ""
306
+ while time.monotonic() < deadline:
307
+ _, out = run_quiet(["supervisorctl", "status", "cloudflared"], timeout=10)
308
+ final_state = out.split()[1] if len(out.split()) >= 2 else ""
309
+ if final_state == "RUNNING":
310
+ break
311
+ if final_state in ("FATAL", "EXITED", "BACKOFF"):
312
+ break
313
+ time.sleep(1)
314
+
315
+ if final_state == "RUNNING":
316
+ ok(f"cloudflared {action}ed")
317
+ else:
318
+ error(f"cloudflared is {final_state or 'unreachable'} — check supervisorctl tail cloudflared stderr")
319
+
320
+ run_quiet(["supervisorctl", "restart", "ui"], timeout=15)
321
+ deadline = time.monotonic() + 30
322
+ final_state = ""
323
+ while time.monotonic() < deadline:
324
+ _, out = run_quiet(["supervisorctl", "status", "ui"], timeout=10)
325
+ final_state = out.split()[1] if len(out.split()) >= 2 else ""
326
+ if final_state == "RUNNING":
327
+ break
328
+ if final_state in ("FATAL", "EXITED", "BACKOFF"):
329
+ break
330
+ time.sleep(1)
331
+
332
+ if final_state == "RUNNING":
333
+ ok("ui restarted")
334
+ else:
335
+ error(f"ui is {final_state or 'unreachable'} — check supervisorctl tail ui stderr")
336
+
337
+
338
+ def _sync_derived_urls(env_file: Path) -> None:
339
+ """Recompute API/WS/frontend/CORS after public_domain changes."""
340
+ env_data = read_env(env_file)
341
+ urls = derive_url_vars(
342
+ env_data.get("SHS_PUBLIC_BASE_URL", ""),
343
+ env_data.get("CONSOLE_PUBLIC_API_BASE_URL", ""),
344
+ env_data.get("SHS_NGINX_PORT", "80"),
345
+ )
346
+ for key, value in urls.items():
347
+ set_env_value(env_file, key, value)
348
+
349
+
350
+ def _cloudflared_state() -> str:
351
+ """Live cloudflared process state from supervisord (not marker/wizard state)."""
352
+ _, out = run_quiet(["supervisorctl", "status", "cloudflared"], timeout=10)
353
+ parts = out.split()
354
+ return parts[1] if len(parts) >= 2 else ""
355
+
356
+
357
+ def _print_cf_status(env_data: dict) -> None:
358
+ """Render tunnel + runtime state, sourced only from .env and supervisord."""
359
+ token = env_data.get("CLOUDFLARE_TUNNEL_TOKEN", "").strip()
360
+ domain = env_data.get("SHS_PUBLIC_BASE_URL", "").strip()
361
+ if not token:
362
+ print(f" {_dim('Tunnel:')} not configured")
363
+ return
364
+ state = _cloudflared_state()
365
+ label = {
366
+ "RUNNING": _cyan("running"),
367
+ "STOPPED": _dim("stopped"),
368
+ "STARTING": _dim("starting"),
369
+ }.get(state, _bold(f"{state or 'unknown'} — check: supervisorctl tail cloudflared stderr"))
370
+ print(f" {_dim('Tunnel:')} token set · cloudflared {label}")
371
+ if domain:
372
+ print(f" {_dim('Domain:')} {domain}")
373
+ print()
374
+
375
+
376
+ def _cloudflare_menu(env_file: Path) -> None:
377
+ """Cloudflare tunnel + domain + IP rules submenu (in-container)."""
378
+ if not env_file.exists():
379
+ error(f"No .env at {env_file} — entrypoint hasn't run.")
380
+ return
381
+
382
+ env_data = read_env(env_file)
383
+ _print_cf_status(env_data)
384
+ has_tunnel = bool(env_data.get("CLOUDFLARE_TUNNEL_ID"))
385
+
386
+ options = [
387
+ f"Setup / re-run wizard {_dim('full tunnel + DNS + Access setup')}",
388
+ f"Update domain {_dim('change public hostname')}" if has_tunnel else None,
389
+ f"Update IP rules {_dim('change Access allow/bypass list')}" if has_tunnel else None,
390
+ ]
391
+ options = [o for o in options if o is not None]
392
+
393
+ while True:
394
+ try:
395
+ idx = _interactive_single("Cloudflare", options, default=0)
396
+ except NavBack:
397
+ return
398
+
399
+ label = options[idx]
400
+ try:
401
+ if label.startswith("Setup"):
402
+ state = SetupState(env_file)
403
+ root = _ask_root_domain(state)
404
+ ui_sub = _ask_ui_subdomain(state, root)
405
+ state.public_domain = f"https://{ui_sub}.{root}"
406
+ _ask_api_hostname(state, root, ui_sub, required=True)
407
+ _ask_ip_restrict_mode(state)
408
+ set_env_value(env_file, "SHS_PUBLIC_BASE_URL", state.public_domain)
409
+ set_env_value(env_file, "CONSOLE_PUBLIC_API_BASE_URL", state.public_api_domain)
410
+ set_env_value(env_file, "CONSOLE_IP_RESTRICT_MODE", state.ip_restrict_mode)
411
+ cf_full_setup(env_file)
412
+ _sync_derived_urls(env_file)
413
+ _kick_cloudflared(env_file)
414
+ env_data = read_env(env_file)
415
+ has_tunnel = bool(env_data.get("CLOUDFLARE_TUNNEL_ID"))
416
+ options = [
417
+ f"Setup / re-run wizard {_dim('full tunnel + DNS + Access setup')}",
418
+ f"Update domain {_dim('change public hostname')}" if has_tunnel else None,
419
+ f"Update IP rules {_dim('change Access allow/bypass list')}" if has_tunnel else None,
420
+ ]
421
+ options = [o for o in options if o is not None]
422
+ elif label.startswith("Update domain"):
423
+ update_domain(env_file)
424
+ _kick_cloudflared(env_file)
425
+ elif label.startswith("Update IP rules"):
426
+ update_ip_rules(env_file)
427
+ except (NavBack, NavExit):
428
+ return
429
+ except Exception as exc:
430
+ error(f"Cloudflare action failed: {exc}")
431
+
432
+
433
+ _SUPERVISORD_STATES = (
434
+ "RUNNING", "STOPPED", "STARTING", "FATAL",
435
+ "EXITED", "BACKOFF", "STOPPING", "UNKNOWN",
436
+ )
437
+
438
+
439
+ def _list_services() -> list[str] | None:
440
+ """Return list of supervisord service names, or None if unreachable."""
441
+ from .env import run_quiet
442
+
443
+ _, out = run_quiet(["supervisorctl", "status"], timeout=10)
444
+ if not out.strip() or "refused connection" in out.lower() or "no such file" in out.lower():
445
+ return None
446
+ services: list[str] = []
447
+ for line in out.splitlines():
448
+ parts = line.split()
449
+ if len(parts) >= 2 and parts[1] in _SUPERVISORD_STATES:
450
+ services.append(parts[0])
451
+ return services
452
+
453
+
454
+ def _restart_picker(context: str, env_file: Path) -> None:
455
+ """Pick a service to restart, or restart all."""
456
+ services = _list_services()
457
+ if services is None:
458
+ error("supervisord is not reachable — entrypoint hasn't started it.")
459
+ return
460
+ if not services:
461
+ error("supervisorctl returned no services")
462
+ return
463
+
464
+ labels = ["All services"] + services
465
+ try:
466
+ pick = _interactive_single("Restart", labels, default=0)
467
+ except NavBack:
468
+ return
469
+
470
+ try:
471
+ if pick == 0:
472
+ cmd_restart(context, env_file, None)
473
+ else:
474
+ cmd_restart(context, env_file, services[pick - 1])
475
+ except Exception as exc:
476
+ error(f"Restart failed: {exc}")
477
+
478
+
479
+ def _logs_picker(context: str, env_file: Path) -> None:
480
+ """Loop: pick a service then mode, return to picker after each session."""
481
+ from .env import run
482
+
483
+ while True:
484
+ services = _list_services()
485
+ if services is None:
486
+ error("supervisord is not reachable — entrypoint hasn't started it.")
487
+ return
488
+ if not services:
489
+ error("supervisorctl returned no services")
490
+ return
491
+
492
+ try:
493
+ pick = _interactive_single("Service", services, default=0)
494
+ except NavBack:
495
+ return
496
+ service = services[pick]
497
+
498
+ try:
499
+ mode = _interactive_single(
500
+ "Mode",
501
+ [f"Recent {_dim('recent stdout + stderr, then exit')}", f"Stream {_dim('live stderr tail, Ctrl-C to stop')}"],
502
+ default=0,
503
+ )
504
+ except NavBack:
505
+ continue
506
+
507
+ # Use supervisorctl tail — the canonical API — instead of guessing raw
508
+ # log-file paths under /var/log/supervisor (fragile: depends on
509
+ # supervisord's logfile config and naming). Pass the full service name
510
+ # (group:service) so grouped programs resolve correctly.
511
+ # NB: supervisorctl tail's byte arg is BYTES not lines, and defaults to
512
+ # stdout — so for "Recent" we pull a generous byte window of BOTH
513
+ # streams; for "Stream" we follow stderr (where failures surface).
514
+ try:
515
+ if mode == 0:
516
+ print(_dim(" ── stdout ──"))
517
+ run(["supervisorctl", "tail", "-8000", service, "stdout"], timeout=15, check=False)
518
+ print(_dim(" ── stderr ──"))
519
+ run(["supervisorctl", "tail", "-8000", service, "stderr"], timeout=15, check=False)
520
+ else:
521
+ try:
522
+ run(["supervisorctl", "tail", "-f", service, "stderr"], timeout=None, check=False)
523
+ except KeyboardInterrupt:
524
+ pass
525
+ except Exception as exc:
526
+ error(f"Logs failed: {exc}")