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.
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