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,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}")
|