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
studio_console/env.py
ADDED
|
@@ -0,0 +1,600 @@
|
|
|
1
|
+
# studio_console/env.py
|
|
2
|
+
"""Environment / .env I/O, context detection, and shell helpers.
|
|
3
|
+
|
|
4
|
+
Zero TUI dependencies - this module never imports menu widgets.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
import tempfile
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Literal, NoReturn
|
|
17
|
+
|
|
18
|
+
from .constants import (
|
|
19
|
+
APP_DB_ROLE,
|
|
20
|
+
COMPONENT_TO_IMAGE,
|
|
21
|
+
ENV_SECTIONS, # noqa: F401 - re-exported for callers
|
|
22
|
+
IMAGE_BUILD_CONFIG,
|
|
23
|
+
SECRET_KEYS,
|
|
24
|
+
SECRET_PATTERNS,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
# Color output (minimal, no TUI)
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
_IS_TTY = hasattr(sys.stdout, "isatty") and sys.stdout.isatty()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _c(code: str, text: str) -> str:
|
|
35
|
+
if not _IS_TTY:
|
|
36
|
+
return text
|
|
37
|
+
return f"\033[{code}m{text}\033[0m"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _red(t: str) -> str:
|
|
41
|
+
return _c("0;31", t)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _green(t: str) -> str:
|
|
45
|
+
return _c("0;32", t)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# ---------------------------------------------------------------------------
|
|
49
|
+
# Print helpers (not TUI widgets - plain stderr/stdout)
|
|
50
|
+
# ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def ok(msg: str) -> None:
|
|
54
|
+
print(f"{_green('✓')} {msg}")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def error(msg: str) -> None:
|
|
58
|
+
print(f"{_red('✗')} {msg}", file=sys.stderr)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def fatal(msg: str) -> NoReturn:
|
|
62
|
+
error(msg)
|
|
63
|
+
sys.exit(1)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
# Context detection
|
|
68
|
+
# ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def detect_context() -> str:
|
|
72
|
+
"""Detect deployment context: runpod, container, or host."""
|
|
73
|
+
if os.getenv("RUNPOD_POD_ID"):
|
|
74
|
+
return "runpod"
|
|
75
|
+
if os.path.exists("/.dockerenv"):
|
|
76
|
+
return "container"
|
|
77
|
+
return "host"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _workspace_dir(context: str) -> Path:
|
|
81
|
+
"""Return the workspace root.
|
|
82
|
+
|
|
83
|
+
Honors SHS_WORKSPACE_DIR (preferred) then SHS_WORKSPACE_HOST (legacy)
|
|
84
|
+
from the shell environment. Defaults: ~/.studio (host) or /workspace
|
|
85
|
+
(container/runpod). .env sits at the workspace root; data subdirs
|
|
86
|
+
(db/, storage/, models/, backups/) sit alongside it.
|
|
87
|
+
"""
|
|
88
|
+
if context == "host":
|
|
89
|
+
raw = os.environ.get("SHS_WORKSPACE_DIR") or os.environ.get(
|
|
90
|
+
"SHS_WORKSPACE_HOST"
|
|
91
|
+
)
|
|
92
|
+
if raw:
|
|
93
|
+
return Path(os.path.expanduser(raw))
|
|
94
|
+
return Path.home() / ".studio"
|
|
95
|
+
return Path("/workspace")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def env_path(context: str) -> Path:
|
|
99
|
+
"""Return the .env file path — always at the workspace root."""
|
|
100
|
+
return _workspace_dir(context) / ".env"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def detect_legacy_layout(workspace: Path) -> str:
|
|
104
|
+
"""Detect pre-current layouts that need migration.
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
"split": workspace/private/.env exists (the failed split-layout attempt)
|
|
108
|
+
"flat": workspace/postgres/ or workspace/orgs/ exists at root (pre-split)
|
|
109
|
+
"none": current layout (or empty)
|
|
110
|
+
"""
|
|
111
|
+
if (workspace / "private" / ".env").exists():
|
|
112
|
+
return "split"
|
|
113
|
+
if (workspace / "postgres").exists() or (workspace / "orgs").exists():
|
|
114
|
+
return "flat"
|
|
115
|
+
return "none"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _root_from_env(env_file: Path | None, key: str) -> Path | None:
|
|
119
|
+
if env_file is not None and env_file.exists():
|
|
120
|
+
val = read_env(env_file).get(key, "").strip()
|
|
121
|
+
if val:
|
|
122
|
+
return Path(os.path.expanduser(val))
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# Data roots — each defaults to a subdir of the workspace; each is
|
|
127
|
+
# independently repointable for cloud (CloudSQL, GCS, RunPod network volumes).
|
|
128
|
+
def db_data(context: str, env_file: Path | None = None) -> Path:
|
|
129
|
+
return _root_from_env(env_file, "SHS_DB_DATA") or (_workspace_dir(context) / "db")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def storage_root(context: str, env_file: Path | None = None) -> Path:
|
|
133
|
+
return _root_from_env(env_file, "SHS_STORAGE_ROOT") or (
|
|
134
|
+
_workspace_dir(context) / "storage"
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def models_root(context: str, env_file: Path | None = None) -> Path:
|
|
139
|
+
return _root_from_env(env_file, "SHS_MODELS_ROOT") or (
|
|
140
|
+
_workspace_dir(context) / "models"
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def backup_root(context: str, env_file: Path | None = None) -> Path:
|
|
145
|
+
return _root_from_env(env_file, "CONSOLE_BACKUP_ROOT") or (
|
|
146
|
+
_workspace_dir(context) / "backups"
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
_SHAPE_FILE = Path("/etc/studio-shape")
|
|
151
|
+
_VALID_SHAPES = ("split", "core", "full")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def detect_shape(env_file: Path | None = None) -> str | None:
|
|
155
|
+
"""Return the deployment shape from .env or /etc/studio-shape, else None."""
|
|
156
|
+
if env_file is not None and env_file.exists():
|
|
157
|
+
shape = read_env(env_file).get("SHS_DEPLOYMENT_SHAPE", "").strip()
|
|
158
|
+
if shape in _VALID_SHAPES:
|
|
159
|
+
return shape
|
|
160
|
+
if _SHAPE_FILE.exists():
|
|
161
|
+
try:
|
|
162
|
+
shape = _SHAPE_FILE.read_text().strip()
|
|
163
|
+
except OSError:
|
|
164
|
+
return None
|
|
165
|
+
if shape in _VALID_SHAPES:
|
|
166
|
+
return shape
|
|
167
|
+
return None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# ---------------------------------------------------------------------------
|
|
171
|
+
# .env I/O
|
|
172
|
+
# ---------------------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def read_env(path: Path) -> dict[str, str]:
|
|
176
|
+
"""Read key=value pairs from a .env file."""
|
|
177
|
+
result: dict[str, str] = {}
|
|
178
|
+
if not path.exists():
|
|
179
|
+
return result
|
|
180
|
+
for line in path.read_text().splitlines():
|
|
181
|
+
line = line.strip()
|
|
182
|
+
if not line or line.startswith("#") or "=" not in line:
|
|
183
|
+
continue
|
|
184
|
+
key, _, value = line.partition("=")
|
|
185
|
+
result[key.strip()] = value.strip()
|
|
186
|
+
return result
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def derive_url_vars(
|
|
190
|
+
public_url: str, api_public_url: str = "", nginx_port: str = "80"
|
|
191
|
+
) -> dict[str, str]:
|
|
192
|
+
"""Derive the five browser/SSR/CORS URL vars from a public domain."""
|
|
193
|
+
public_url = public_url.strip()
|
|
194
|
+
api_public_url = api_public_url.strip()
|
|
195
|
+
localhost_origins = "http://localhost" if nginx_port == "80" else f"http://localhost:{nginx_port}"
|
|
196
|
+
|
|
197
|
+
if public_url.startswith("https://"):
|
|
198
|
+
api_host = api_public_url if api_public_url.startswith("https://") else public_url
|
|
199
|
+
api_url = api_host
|
|
200
|
+
ws_url = api_host.replace("https://", "wss://")
|
|
201
|
+
frontend_url = public_url
|
|
202
|
+
origins = [localhost_origins, public_url]
|
|
203
|
+
if api_public_url and api_public_url != public_url:
|
|
204
|
+
origins.append(api_public_url)
|
|
205
|
+
cors_origins = ",".join(origins)
|
|
206
|
+
else:
|
|
207
|
+
nginx_base = f"http://localhost:{nginx_port}"
|
|
208
|
+
api_url = nginx_base
|
|
209
|
+
ws_url = f"ws://localhost:{nginx_port}"
|
|
210
|
+
frontend_url = nginx_base
|
|
211
|
+
cors_origins = localhost_origins
|
|
212
|
+
|
|
213
|
+
return {
|
|
214
|
+
"SHS_API_BASE_URL": api_url,
|
|
215
|
+
"SHS_PUBLIC_API_URL": api_url,
|
|
216
|
+
"SHS_WS_URL": ws_url,
|
|
217
|
+
"SHS_FRONTEND_URL": frontend_url,
|
|
218
|
+
"SHS_CORS_ORIGINS": cors_origins,
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def derive_app_db_url(privileged_url: str, password: str | None = None) -> str:
|
|
223
|
+
"""Return the restricted-role URL: same DSN as *privileged_url*, shs_app creds.
|
|
224
|
+
|
|
225
|
+
The API's bootstrap creates/refreshes the role from these credentials on
|
|
226
|
+
every boot, so a fresh password here is safe — it never needs out-of-band
|
|
227
|
+
registration.
|
|
228
|
+
"""
|
|
229
|
+
import secrets
|
|
230
|
+
from urllib.parse import urlsplit
|
|
231
|
+
|
|
232
|
+
parts = urlsplit(privileged_url)
|
|
233
|
+
host = parts.hostname or "postgres"
|
|
234
|
+
port = f":{parts.port}" if parts.port else ""
|
|
235
|
+
query = f"?{parts.query}" if parts.query else ""
|
|
236
|
+
password = password or secrets.token_hex(24)
|
|
237
|
+
return f"{parts.scheme}://{APP_DB_ROLE}:{password}@{host}{port}{parts.path}{query}"
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def write_env(path: Path, data: dict[str, str]) -> None:
|
|
241
|
+
"""Atomic write of a .env file, preserving comments from existing file."""
|
|
242
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
243
|
+
|
|
244
|
+
# If the file exists, update values in place preserving structure
|
|
245
|
+
if path.exists():
|
|
246
|
+
lines = path.read_text().splitlines()
|
|
247
|
+
written_keys: set[str] = set()
|
|
248
|
+
new_lines: list[str] = []
|
|
249
|
+
for line in lines:
|
|
250
|
+
stripped = line.strip()
|
|
251
|
+
if stripped and not stripped.startswith("#") and "=" in stripped:
|
|
252
|
+
key = stripped.partition("=")[0].strip()
|
|
253
|
+
if key in data:
|
|
254
|
+
new_lines.append(f"{key}={data[key]}")
|
|
255
|
+
written_keys.add(key)
|
|
256
|
+
else:
|
|
257
|
+
new_lines.append(line)
|
|
258
|
+
else:
|
|
259
|
+
new_lines.append(line)
|
|
260
|
+
# Append any new keys not in the original file
|
|
261
|
+
for key, value in data.items():
|
|
262
|
+
if key not in written_keys:
|
|
263
|
+
new_lines.append(f"{key}={value}")
|
|
264
|
+
content = "\n".join(new_lines) + "\n"
|
|
265
|
+
else:
|
|
266
|
+
content = "\n".join(f"{k}={v}" for k, v in data.items()) + "\n"
|
|
267
|
+
|
|
268
|
+
# Atomic write: write to temp with 600 perms, then rename
|
|
269
|
+
fd, tmp = tempfile.mkstemp(dir=str(path.parent), suffix=".env.tmp")
|
|
270
|
+
closed = False
|
|
271
|
+
try:
|
|
272
|
+
os.chmod(fd, 0o600)
|
|
273
|
+
os.write(fd, content.encode())
|
|
274
|
+
os.close(fd)
|
|
275
|
+
closed = True
|
|
276
|
+
os.replace(tmp, str(path))
|
|
277
|
+
# Verify the secret file really landed at 0o600 — an overriding umask,
|
|
278
|
+
# prior file, or odd filesystem could leave it world-readable.
|
|
279
|
+
mode = os.stat(path).st_mode & 0o777
|
|
280
|
+
if mode != 0o600:
|
|
281
|
+
os.chmod(path, 0o600)
|
|
282
|
+
except BaseException:
|
|
283
|
+
if not closed:
|
|
284
|
+
os.close(fd)
|
|
285
|
+
if os.path.exists(tmp):
|
|
286
|
+
os.unlink(tmp)
|
|
287
|
+
raise
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def promote_runpod_secrets(env_file: Path) -> list[str]:
|
|
291
|
+
"""Copy RUNPOD_SHS_* env vars into .env without overwriting existing keys."""
|
|
292
|
+
prefix = "RUNPOD_SHS_"
|
|
293
|
+
existing = read_env(env_file)
|
|
294
|
+
new_keys: dict[str, str] = {}
|
|
295
|
+
promoted: list[str] = []
|
|
296
|
+
for env_key, value in os.environ.items():
|
|
297
|
+
if not env_key.startswith(prefix):
|
|
298
|
+
continue
|
|
299
|
+
target_key = "SHS_" + env_key[len(prefix):]
|
|
300
|
+
if target_key in existing:
|
|
301
|
+
continue
|
|
302
|
+
new_keys[target_key] = value
|
|
303
|
+
promoted.append(target_key)
|
|
304
|
+
if new_keys:
|
|
305
|
+
merged = {**existing, **new_keys}
|
|
306
|
+
write_env(env_file, merged)
|
|
307
|
+
return promoted
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def set_env_value(path: Path, key: str, value: str) -> None:
|
|
311
|
+
"""Set a single key in the .env file."""
|
|
312
|
+
data = read_env(path)
|
|
313
|
+
data[key] = value
|
|
314
|
+
write_env(path, data)
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def unset_env_values(path: Path, keys: list[str]) -> None:
|
|
318
|
+
"""Remove keys from the .env file entirely (used to scrub transient
|
|
319
|
+
bootstrap credentials once they live in the DB)."""
|
|
320
|
+
data = read_env(path)
|
|
321
|
+
changed = False
|
|
322
|
+
for k in keys:
|
|
323
|
+
if k in data:
|
|
324
|
+
del data[k]
|
|
325
|
+
changed = True
|
|
326
|
+
if changed:
|
|
327
|
+
write_env(path, data)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
# ---------------------------------------------------------------------------
|
|
331
|
+
# Secret masking
|
|
332
|
+
# ---------------------------------------------------------------------------
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _is_secret_key(key: str) -> bool:
|
|
336
|
+
"""Check if a key should be treated as a secret."""
|
|
337
|
+
if key in SECRET_KEYS:
|
|
338
|
+
return True
|
|
339
|
+
upper = key.upper()
|
|
340
|
+
return any(p in upper for p in SECRET_PATTERNS)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
# user:password@ inside any URL-shaped value (e.g. SHS_DATABASE_URL)
|
|
344
|
+
_URL_CRED_RE = re.compile(r"(://[^:/@]+:)[^@]+(@)")
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def mask_value(key: str, value: str) -> str:
|
|
348
|
+
"""Mask secret values for display."""
|
|
349
|
+
if _is_secret_key(key) and value:
|
|
350
|
+
if len(value) <= 4:
|
|
351
|
+
return "***"
|
|
352
|
+
return value[:4] + "***"
|
|
353
|
+
if "://" in value and "@" in value:
|
|
354
|
+
return _URL_CRED_RE.sub(r"\1***\2", value)
|
|
355
|
+
return value
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
# ---------------------------------------------------------------------------
|
|
359
|
+
# Shell helpers
|
|
360
|
+
# ---------------------------------------------------------------------------
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def run(
|
|
364
|
+
cmd: list[str], check: bool = True, capture: bool = False, timeout: int | None = 30
|
|
365
|
+
) -> subprocess.CompletedProcess[str]:
|
|
366
|
+
"""Run a shell command."""
|
|
367
|
+
return subprocess.run(
|
|
368
|
+
cmd,
|
|
369
|
+
check=check,
|
|
370
|
+
capture_output=capture,
|
|
371
|
+
text=True,
|
|
372
|
+
timeout=timeout,
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def run_quiet(cmd: list[str], timeout: int = 10) -> tuple[int, str]:
|
|
377
|
+
"""Run a command, return (returncode, stdout+stderr). Never raises."""
|
|
378
|
+
try:
|
|
379
|
+
r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
|
380
|
+
output = r.stdout.strip()
|
|
381
|
+
if r.returncode != 0 and r.stderr.strip():
|
|
382
|
+
output = output + "\n" + r.stderr.strip() if output else r.stderr.strip()
|
|
383
|
+
return r.returncode, output
|
|
384
|
+
except Exception:
|
|
385
|
+
return 1, ""
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _package_root() -> Path:
|
|
389
|
+
"""Return the studio-console package root (where docker-compose.yml lives).
|
|
390
|
+
|
|
391
|
+
Works for all three install methods:
|
|
392
|
+
- pip install → site-packages/studio_console/../ (one level up from package)
|
|
393
|
+
- curl install → ~/.studio-console/studio_console/../
|
|
394
|
+
- dev/monorepo → deploy/studio_console/../
|
|
395
|
+
"""
|
|
396
|
+
return Path(__file__).resolve().parent.parent
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _studio_dir(env_file: Path) -> Path:
|
|
400
|
+
"""Return the operator's Studio working directory (~/.studio by default)."""
|
|
401
|
+
return env_file.parent
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def compose_cmd(env_file: Path) -> list[str]:
|
|
405
|
+
"""Return the base docker compose command for production.
|
|
406
|
+
|
|
407
|
+
Resolves docker-compose.yml from the operator's studio dir first
|
|
408
|
+
(copied there on first run), then falls back to the package root.
|
|
409
|
+
Worker profiles are activated via COMPOSE_PROFILES in the .env file.
|
|
410
|
+
Includes the generated override file when it exists (nginx load balancing).
|
|
411
|
+
"""
|
|
412
|
+
studio_dir = _studio_dir(env_file)
|
|
413
|
+
compose_file = studio_dir / "docker-compose.yml"
|
|
414
|
+
if not compose_file.exists():
|
|
415
|
+
# Fall back to bundled copy (pre-first-run or pip install edge case)
|
|
416
|
+
compose_file = _package_root() / "docker-compose.yml"
|
|
417
|
+
cmd = ["docker", "compose", "-f", str(compose_file), "--env-file", str(env_file)]
|
|
418
|
+
override = studio_dir / "docker-compose.override.yml"
|
|
419
|
+
if override.exists():
|
|
420
|
+
cmd += ["-f", str(override)]
|
|
421
|
+
return cmd
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
# ---------------------------------------------------------------------------
|
|
425
|
+
# Password validation
|
|
426
|
+
# ---------------------------------------------------------------------------
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def validate_password(password: str) -> tuple[bool, str]:
|
|
430
|
+
"""Validate password strength."""
|
|
431
|
+
if len(password) < 8:
|
|
432
|
+
return False, "Must be at least 8 characters"
|
|
433
|
+
if not any(c.isupper() for c in password):
|
|
434
|
+
return False, "Must contain an uppercase letter"
|
|
435
|
+
if not any(c.islower() for c in password):
|
|
436
|
+
return False, "Must contain a lowercase letter"
|
|
437
|
+
if not any(c.isdigit() for c in password):
|
|
438
|
+
return False, "Must contain a digit"
|
|
439
|
+
if all(c.isalnum() for c in password):
|
|
440
|
+
return False, "Must contain a special character (!@#$%^&*...)"
|
|
441
|
+
return True, ""
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
# ---------------------------------------------------------------------------
|
|
445
|
+
# Repo / image helpers
|
|
446
|
+
# ---------------------------------------------------------------------------
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _find_repo_root(env_file: Path | None = None) -> Path | None:
|
|
450
|
+
"""Return the monorepo root (Studio checkout).
|
|
451
|
+
|
|
452
|
+
Resolution order:
|
|
453
|
+
1. CONSOLE_REPO_ROOT shell environment variable
|
|
454
|
+
2. CONSOLE_REPO_ROOT key in the .env file
|
|
455
|
+
3. None — callers must handle this gracefully
|
|
456
|
+
|
|
457
|
+
In standalone installs (pip/curl/brew) without CONSOLE_REPO_ROOT set,
|
|
458
|
+
this returns None and build-from-source is unavailable.
|
|
459
|
+
"""
|
|
460
|
+
# 1. Shell environment
|
|
461
|
+
shell_val = os.environ.get("CONSOLE_REPO_ROOT", "").strip()
|
|
462
|
+
if shell_val:
|
|
463
|
+
p = Path(shell_val).expanduser().resolve()
|
|
464
|
+
if p.is_dir():
|
|
465
|
+
return p
|
|
466
|
+
|
|
467
|
+
# 2. .env file
|
|
468
|
+
if env_file and env_file.exists():
|
|
469
|
+
env_val = read_env(env_file).get("CONSOLE_REPO_ROOT", "").strip()
|
|
470
|
+
if env_val:
|
|
471
|
+
p = Path(env_val).expanduser().resolve()
|
|
472
|
+
if p.is_dir():
|
|
473
|
+
return p
|
|
474
|
+
|
|
475
|
+
return None
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _images_from_env(env_file: Path) -> list[str]:
|
|
479
|
+
"""Determine which images to build based on CONSOLE_COMPONENTS in .env."""
|
|
480
|
+
env_data = read_env(env_file) if env_file.exists() else {}
|
|
481
|
+
components_str = env_data.get("CONSOLE_COMPONENTS", "")
|
|
482
|
+
if not components_str:
|
|
483
|
+
return list(IMAGE_BUILD_CONFIG) # No config - build all
|
|
484
|
+
|
|
485
|
+
components = [c.strip() for c in components_str.split(",")]
|
|
486
|
+
images: list[str] = []
|
|
487
|
+
seen: set[str] = set()
|
|
488
|
+
for comp in components:
|
|
489
|
+
img = COMPONENT_TO_IMAGE.get(comp)
|
|
490
|
+
if img and img not in seen:
|
|
491
|
+
images.append(img)
|
|
492
|
+
seen.add(img)
|
|
493
|
+
return images if images else list(IMAGE_BUILD_CONFIG)
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
# ---------------------------------------------------------------------------
|
|
497
|
+
# Env validation
|
|
498
|
+
# ---------------------------------------------------------------------------
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _env_example_path() -> Path | None:
|
|
502
|
+
"""Find .env.example — bundled in the package, or in the monorepo deploy/ dir."""
|
|
503
|
+
# Bundled copy (pip/curl/brew installs and standalone repo)
|
|
504
|
+
bundled = _package_root() / "templates" / ".env.example"
|
|
505
|
+
if bundled.exists():
|
|
506
|
+
return bundled
|
|
507
|
+
# Monorepo checkout fallback
|
|
508
|
+
repo = _find_repo_root()
|
|
509
|
+
if repo:
|
|
510
|
+
example = repo / "deploy" / ".env.example"
|
|
511
|
+
if example.exists():
|
|
512
|
+
return example
|
|
513
|
+
return None
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _validate_env(env_file: Path) -> None:
|
|
517
|
+
"""Check .env against .env.example. Fatal if required keys are missing."""
|
|
518
|
+
if not env_file.exists():
|
|
519
|
+
fatal(f"No .env found at {env_file}. Run studio-console first.")
|
|
520
|
+
|
|
521
|
+
example_file = _env_example_path()
|
|
522
|
+
if not example_file:
|
|
523
|
+
return # Can't validate without the example file
|
|
524
|
+
|
|
525
|
+
required = read_env(example_file)
|
|
526
|
+
actual = read_env(env_file)
|
|
527
|
+
|
|
528
|
+
# Keys that are in the example with a value are required
|
|
529
|
+
missing: list[str] = []
|
|
530
|
+
for key, val in required.items():
|
|
531
|
+
if val and key not in actual:
|
|
532
|
+
missing.append(key)
|
|
533
|
+
|
|
534
|
+
if missing:
|
|
535
|
+
error(f"Missing required env vars ({len(missing)}):")
|
|
536
|
+
for key in missing:
|
|
537
|
+
default = required[key]
|
|
538
|
+
print(f" {key}={default}")
|
|
539
|
+
print()
|
|
540
|
+
answer = input("Add missing vars with default values? [Y/n] ").strip().lower()
|
|
541
|
+
if answer in ("", "y", "yes"):
|
|
542
|
+
for key in missing:
|
|
543
|
+
set_env_value(env_file, key, required[key])
|
|
544
|
+
ok(f"Added {len(missing)} missing var(s) to {env_file}")
|
|
545
|
+
else:
|
|
546
|
+
fatal(
|
|
547
|
+
"Cannot start with missing env vars. Add them manually or re-run the wizard."
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
# ---------------------------------------------------------------------------
|
|
552
|
+
# Service introspection
|
|
553
|
+
# ---------------------------------------------------------------------------
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _get_running_services(
|
|
557
|
+
context: str, env_file: Path, use_container_names: bool = False
|
|
558
|
+
) -> list[str]:
|
|
559
|
+
"""Get list of all compose/supervisor services (any state).
|
|
560
|
+
|
|
561
|
+
When *use_container_names* is True, returns container names so scaled
|
|
562
|
+
instances show individually (e.g. studio-worker-general-1, -2, -3).
|
|
563
|
+
Otherwise returns compose service names (e.g. worker-general).
|
|
564
|
+
"""
|
|
565
|
+
if context == "host":
|
|
566
|
+
rc, out = run_quiet(
|
|
567
|
+
compose_cmd(env_file) + ["ps", "-a", "--format", "json"],
|
|
568
|
+
timeout=15,
|
|
569
|
+
)
|
|
570
|
+
if rc != 0 or not out:
|
|
571
|
+
return []
|
|
572
|
+
key = "Name" if use_container_names else "Service"
|
|
573
|
+
services: list[str] = []
|
|
574
|
+
for line in out.strip().splitlines():
|
|
575
|
+
try:
|
|
576
|
+
svc = json.loads(line)
|
|
577
|
+
name = svc.get(key, svc.get("Service", ""))
|
|
578
|
+
if name and name not in services:
|
|
579
|
+
services.append(name)
|
|
580
|
+
except json.JSONDecodeError:
|
|
581
|
+
pass
|
|
582
|
+
return services
|
|
583
|
+
else:
|
|
584
|
+
rc, out = run_quiet(["supervisorctl", "status"], timeout=10)
|
|
585
|
+
if not out:
|
|
586
|
+
return []
|
|
587
|
+
# All services regardless of state (matches host 'ps -a' and the
|
|
588
|
+
# docstring) — otherwise 'Start one' can't list a stopped service.
|
|
589
|
+
# Keep only real status lines (state token in column 2) so a supervisord
|
|
590
|
+
# error banner / traceback isn't mistaken for a service name.
|
|
591
|
+
states = {
|
|
592
|
+
"RUNNING", "STOPPED", "STARTING", "STOPPING",
|
|
593
|
+
"BACKOFF", "EXITED", "FATAL", "UNKNOWN",
|
|
594
|
+
}
|
|
595
|
+
services: list[str] = []
|
|
596
|
+
for line in out.splitlines():
|
|
597
|
+
parts = line.split()
|
|
598
|
+
if len(parts) >= 2 and parts[1] in states:
|
|
599
|
+
services.append(parts[0])
|
|
600
|
+
return services
|