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,684 @@
|
|
|
1
|
+
# studio_console/commands_launch.py
|
|
2
|
+
"""Host-side launcher for the single-image `full` and `core` shapes."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .env import run, run_quiet
|
|
11
|
+
from .tui import (
|
|
12
|
+
_bold,
|
|
13
|
+
_cyan,
|
|
14
|
+
_dim,
|
|
15
|
+
_interactive_single,
|
|
16
|
+
_prompt,
|
|
17
|
+
_prompt_password,
|
|
18
|
+
error,
|
|
19
|
+
heading,
|
|
20
|
+
info,
|
|
21
|
+
ok,
|
|
22
|
+
warn,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
MANIFEST_PATH_IN_IMAGE = "/app/contracts/launch-manifest.json"
|
|
26
|
+
DEFAULT_IMAGE = "ghcr.io/selfhosthub/studio-full"
|
|
27
|
+
DEFAULT_TAG = "latest"
|
|
28
|
+
CONTAINER_NAME = "studio-full"
|
|
29
|
+
STATE_DIR = Path.home() / ".studio-full"
|
|
30
|
+
STATE_FILE = STATE_DIR / ".console-state"
|
|
31
|
+
|
|
32
|
+
# Core (external-DB) launcher constants. Core needs a Postgres it does not
|
|
33
|
+
# provide; the launcher owns the optional sidecar and its lifecycle.
|
|
34
|
+
CORE_IMAGE = "ghcr.io/selfhosthub/studio-core"
|
|
35
|
+
CORE_CONTAINER_NAME = "studio-core"
|
|
36
|
+
CORE_STATE_DIR = Path.home() / ".studio-core"
|
|
37
|
+
CORE_STATE_FILE = CORE_STATE_DIR / ".console-state"
|
|
38
|
+
CORE_NETWORK = "studio-core-net"
|
|
39
|
+
CORE_PG_CONTAINER = "studio-core-pg"
|
|
40
|
+
CORE_PG_DB = "selfhost_studio"
|
|
41
|
+
CORE_PG_USER = "postgres"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _read_manifest(image_ref: str, shape: str = "full") -> dict | None:
|
|
45
|
+
"""Read the launch manifest for *shape* from inside the image."""
|
|
46
|
+
rc, out = run_quiet(
|
|
47
|
+
["docker", "run", "--rm", "--entrypoint", "cat", image_ref, MANIFEST_PATH_IN_IMAGE],
|
|
48
|
+
timeout=60,
|
|
49
|
+
)
|
|
50
|
+
if rc != 0:
|
|
51
|
+
error(f"Could not read launch manifest from {image_ref}")
|
|
52
|
+
if out:
|
|
53
|
+
print(f" {_dim(out)}")
|
|
54
|
+
return None
|
|
55
|
+
try:
|
|
56
|
+
parsed = json.loads(out)
|
|
57
|
+
except json.JSONDecodeError as e:
|
|
58
|
+
error(f"Launch manifest malformed: {e}")
|
|
59
|
+
return None
|
|
60
|
+
manifest = parsed.get("shapes", {}).get(shape)
|
|
61
|
+
if manifest is None:
|
|
62
|
+
error(f"Launch manifest has no '{shape}' shape — image predates {shape} support.")
|
|
63
|
+
return None
|
|
64
|
+
# engine.image (pgvector pin) lives at top level; carry it for the sidecar.
|
|
65
|
+
manifest["_engine"] = parsed.get("engine", {})
|
|
66
|
+
return manifest
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _load_state(state_file: Path = STATE_FILE) -> dict:
|
|
70
|
+
"""Read persisted creds (KEY=VALUE lines). Empty if absent."""
|
|
71
|
+
if not state_file.exists():
|
|
72
|
+
return {}
|
|
73
|
+
state: dict[str, str] = {}
|
|
74
|
+
for line in state_file.read_text().splitlines():
|
|
75
|
+
if "=" in line and not line.startswith("#"):
|
|
76
|
+
k, _, v = line.partition("=")
|
|
77
|
+
state[k.strip()] = v.strip()
|
|
78
|
+
return state
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _save_state(state: dict, state_dir: Path = STATE_DIR, state_file: Path = STATE_FILE) -> None:
|
|
82
|
+
"""Persist creds atomically at 0600."""
|
|
83
|
+
state_dir.mkdir(mode=0o700, exist_ok=True)
|
|
84
|
+
content = "\n".join(f"{k}={v}" for k, v in state.items()) + "\n"
|
|
85
|
+
fd, tmp = tempfile.mkstemp(dir=str(state_dir), suffix=".tmp")
|
|
86
|
+
try:
|
|
87
|
+
os.chmod(fd, 0o600)
|
|
88
|
+
os.write(fd, content.encode())
|
|
89
|
+
os.close(fd)
|
|
90
|
+
os.replace(tmp, str(state_file))
|
|
91
|
+
if os.stat(state_file).st_mode & 0o777 != 0o600:
|
|
92
|
+
os.chmod(state_file, 0o600)
|
|
93
|
+
except BaseException:
|
|
94
|
+
if os.path.exists(tmp):
|
|
95
|
+
os.unlink(tmp)
|
|
96
|
+
raise
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _ensure_creds(
|
|
100
|
+
manifest: dict,
|
|
101
|
+
state_dir: Path = STATE_DIR,
|
|
102
|
+
state_file: Path = STATE_FILE,
|
|
103
|
+
only: list[str] | None = None,
|
|
104
|
+
) -> dict:
|
|
105
|
+
"""Return required_env creds, prompting once and persisting if not yet stored.
|
|
106
|
+
|
|
107
|
+
*only* restricts which required_env names are handled here (the rest are
|
|
108
|
+
owned by a caller-specific flow, e.g. core's DB provisioning). Returns just
|
|
109
|
+
the handled keys.
|
|
110
|
+
"""
|
|
111
|
+
entries = [e for e in manifest.get("required_env", []) if only is None or e["name"] in only]
|
|
112
|
+
required = [e["name"] for e in entries]
|
|
113
|
+
state = _load_state(state_file)
|
|
114
|
+
if all(k in state and state[k] for k in required):
|
|
115
|
+
return {k: state[k] for k in required}
|
|
116
|
+
|
|
117
|
+
info("Supervisor credentials are required on every boot and are not stored in the")
|
|
118
|
+
info("container — console keeps them so you don't re-enter them each launch.")
|
|
119
|
+
print()
|
|
120
|
+
for entry in entries:
|
|
121
|
+
name = entry["name"]
|
|
122
|
+
if state.get(name):
|
|
123
|
+
continue
|
|
124
|
+
env_val = os.environ.get(name, "").strip()
|
|
125
|
+
if env_val:
|
|
126
|
+
state[name] = env_val
|
|
127
|
+
elif entry.get("secret"):
|
|
128
|
+
state[name] = _prompt_password(entry.get("description", name).split(".")[0])
|
|
129
|
+
else:
|
|
130
|
+
state[name] = _prompt(f"{name}")
|
|
131
|
+
_save_state(state, state_dir, state_file)
|
|
132
|
+
return {k: state[k] for k in required}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _ensure_workspace(workspace: Path) -> None:
|
|
136
|
+
"""Create the bind-mount root 0711 regardless of umask.
|
|
137
|
+
|
|
138
|
+
Docker auto-creates a missing bind-mount root root-owned with the daemon
|
|
139
|
+
umask (0700 under a 077 umask), which the container's non-root initdb can't
|
|
140
|
+
traverse. mkdir's mode is umask-masked, so chmod explicitly.
|
|
141
|
+
"""
|
|
142
|
+
workspace.mkdir(parents=True, exist_ok=True)
|
|
143
|
+
os.chmod(workspace, 0o711)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# Non-secret config injected from host env via -e on first boot only; not
|
|
147
|
+
# re-injected on relaunch, so operator-tuned values are not clobbered.
|
|
148
|
+
_FIRST_BOOT_SEED_VARS = [
|
|
149
|
+
"SHS_GENERAL_WORKERS",
|
|
150
|
+
"SHS_TRANSFER_WORKERS",
|
|
151
|
+
]
|
|
152
|
+
|
|
153
|
+
# Container path the entrypoint reads the tunnel token from (consumed_secret_files
|
|
154
|
+
# in the launch manifest). Must match Studio's entrypoint exactly.
|
|
155
|
+
CF_TOKEN_MOUNT = "/run/secrets/cf-token"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _seed_first_boot_vars(workspace: Path, creds: dict) -> None:
|
|
159
|
+
"""Inject worker counts and derived public-URL vars from host env, first boot only."""
|
|
160
|
+
if (workspace / ".env").exists():
|
|
161
|
+
for name in _FIRST_BOOT_SEED_VARS:
|
|
162
|
+
if os.environ.get(name, "").strip():
|
|
163
|
+
info(f"{name}: kept existing.")
|
|
164
|
+
if os.environ.get("SHS_PUBLIC_BASE_URL", "").strip():
|
|
165
|
+
info("public URLs: kept existing.")
|
|
166
|
+
return
|
|
167
|
+
for name in _FIRST_BOOT_SEED_VARS:
|
|
168
|
+
val = os.environ.get(name, "").strip()
|
|
169
|
+
if val:
|
|
170
|
+
creds[name] = val
|
|
171
|
+
info(f"seeded {name} (first boot).")
|
|
172
|
+
public_url = os.environ.get("SHS_PUBLIC_BASE_URL", "").strip()
|
|
173
|
+
if public_url:
|
|
174
|
+
from .env import derive_url_vars
|
|
175
|
+
|
|
176
|
+
# Only the browser bundle vars; SHS_API_BASE_URL/CORS are excluded so SSR
|
|
177
|
+
# stays on the in-container API and nginx single-origin CORS is untouched.
|
|
178
|
+
urls = derive_url_vars(
|
|
179
|
+
public_url,
|
|
180
|
+
os.environ.get("CONSOLE_PUBLIC_API_BASE_URL", ""),
|
|
181
|
+
os.environ.get("SHS_NGINX_PORT", "80"),
|
|
182
|
+
)
|
|
183
|
+
creds["SHS_PUBLIC_BASE_URL"] = public_url
|
|
184
|
+
for key in ("SHS_PUBLIC_API_URL", "SHS_WS_URL", "SHS_FRONTEND_URL"):
|
|
185
|
+
creds[key] = urls[key]
|
|
186
|
+
info("seeded public URLs (first boot).")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _cf_token_mount(workspace: Path, state_dir: Path) -> Path | None:
|
|
190
|
+
"""Write the token to a 0600 host file, return its path; None if no token or on relaunch."""
|
|
191
|
+
token_file = state_dir / ".cf-token"
|
|
192
|
+
token_file.unlink(missing_ok=True)
|
|
193
|
+
token = os.environ.get("CLOUDFLARE_TUNNEL_TOKEN", "").strip()
|
|
194
|
+
if not token:
|
|
195
|
+
return None
|
|
196
|
+
if (workspace / ".env").exists():
|
|
197
|
+
info("CLOUDFLARE_TUNNEL_TOKEN: kept existing.")
|
|
198
|
+
return None
|
|
199
|
+
state_dir.mkdir(mode=0o700, exist_ok=True)
|
|
200
|
+
fd = os.open(token_file, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
201
|
+
os.write(fd, token.encode())
|
|
202
|
+
os.close(fd)
|
|
203
|
+
info("cf-token: dropped (reason=first-boot).")
|
|
204
|
+
return token_file
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _build_run_cmd(
|
|
208
|
+
manifest: dict,
|
|
209
|
+
image_ref: str,
|
|
210
|
+
creds: dict,
|
|
211
|
+
workspace: Path,
|
|
212
|
+
container_name: str = CONTAINER_NAME,
|
|
213
|
+
network: str | None = None,
|
|
214
|
+
publish_internal: bool = True,
|
|
215
|
+
mounts: list[str] | None = None,
|
|
216
|
+
) -> list[str]:
|
|
217
|
+
"""Assemble `docker run` from the manifest.
|
|
218
|
+
|
|
219
|
+
*publish_internal* False publishes only ports without ``internal: true``
|
|
220
|
+
(core: expose nginx :80, keep api/ui/supervisor off the host).
|
|
221
|
+
*mounts* are extra ``-v`` specs (e.g. the consumed cf-token secret file).
|
|
222
|
+
"""
|
|
223
|
+
cmd = ["docker", "run", "-d", "--name", container_name]
|
|
224
|
+
if network:
|
|
225
|
+
cmd += ["--network", network]
|
|
226
|
+
for port in manifest.get("ports", []):
|
|
227
|
+
if not publish_internal and port.get("internal"):
|
|
228
|
+
continue
|
|
229
|
+
c = port["container"]
|
|
230
|
+
cmd += ["-p", f"{c}:{c}"]
|
|
231
|
+
vol = manifest["volumes"][0]["container_path"]
|
|
232
|
+
cmd += ["-v", f"{workspace}:{vol}"]
|
|
233
|
+
for spec in mounts or []:
|
|
234
|
+
cmd += ["-v", spec]
|
|
235
|
+
for name, value in creds.items():
|
|
236
|
+
cmd += ["-e", f"{name}={value}"]
|
|
237
|
+
cmd.append(image_ref)
|
|
238
|
+
return cmd
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def cmd_launch_full(context: str, tag: str | None = None, workspace: Path | None = None) -> bool:
|
|
242
|
+
"""Launch the full single-image deployment and exec into its console."""
|
|
243
|
+
if context != "host":
|
|
244
|
+
error("launch-full runs on the host — you appear to be inside a container.")
|
|
245
|
+
return False
|
|
246
|
+
if run_quiet(["docker", "version"])[0] != 0:
|
|
247
|
+
error("Docker is not available. Install Docker and retry.")
|
|
248
|
+
return False
|
|
249
|
+
|
|
250
|
+
heading("Launch Studio (full)")
|
|
251
|
+
tag = tag or DEFAULT_TAG
|
|
252
|
+
image_ref = f"{DEFAULT_IMAGE}:{tag}"
|
|
253
|
+
|
|
254
|
+
existing = run_quiet(["docker", "ps", "-aq", "--filter", f"name=^{CONTAINER_NAME}$"])[1]
|
|
255
|
+
if existing:
|
|
256
|
+
warn(f"Container '{CONTAINER_NAME}' already exists.")
|
|
257
|
+
print(f" {_dim('Connect:')} {_bold(f'docker exec -it {CONTAINER_NAME} studio-console')}")
|
|
258
|
+
return False
|
|
259
|
+
|
|
260
|
+
manifest = _read_manifest(image_ref)
|
|
261
|
+
if manifest is None:
|
|
262
|
+
return False
|
|
263
|
+
|
|
264
|
+
workspace = workspace or (Path.home() / ".studio")
|
|
265
|
+
_ensure_workspace(workspace)
|
|
266
|
+
|
|
267
|
+
creds = _ensure_creds(manifest)
|
|
268
|
+
_seed_first_boot_vars(workspace, creds)
|
|
269
|
+
cf_token = _cf_token_mount(workspace, STATE_DIR)
|
|
270
|
+
mounts = [f"{cf_token}:{CF_TOKEN_MOUNT}:ro"] if cf_token else None
|
|
271
|
+
cmd = _build_run_cmd(manifest, image_ref, creds, workspace, mounts=mounts)
|
|
272
|
+
|
|
273
|
+
info(f"Starting {image_ref} (data: {workspace}) ...")
|
|
274
|
+
if run(cmd, check=False).returncode != 0:
|
|
275
|
+
error("docker run failed.")
|
|
276
|
+
return False
|
|
277
|
+
|
|
278
|
+
ok(f"{CONTAINER_NAME} started.")
|
|
279
|
+
ui_port = next((p["container"] for p in manifest["ports"] if p.get("name") == "ui"), 3000)
|
|
280
|
+
print(f" {_dim('UI:')} {_cyan(f'http://localhost:{ui_port}')}")
|
|
281
|
+
print(f" {_dim('Console:')} {_bold(f'docker exec -it {CONTAINER_NAME} studio-console')}")
|
|
282
|
+
print()
|
|
283
|
+
|
|
284
|
+
api_healthy = _wait_healthy(manifest)
|
|
285
|
+
|
|
286
|
+
if api_healthy:
|
|
287
|
+
from .commands import _full_plan
|
|
288
|
+
|
|
289
|
+
_bootstrap_admin(workspace, _full_plan(CONTAINER_NAME))
|
|
290
|
+
|
|
291
|
+
info("Opening in-container console ...")
|
|
292
|
+
run(["docker", "exec", "-it", CONTAINER_NAME, "studio-console"], check=False, timeout=None)
|
|
293
|
+
return True
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
# --- core (external-DB) launcher -----------------------------------------
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def _ensure_core_network() -> bool:
|
|
300
|
+
"""Create the core docker network if absent. Named to avoid split collision."""
|
|
301
|
+
exists = run_quiet(
|
|
302
|
+
["docker", "network", "ls", "-q", "--filter", f"name=^{CORE_NETWORK}$"]
|
|
303
|
+
)[1]
|
|
304
|
+
if exists:
|
|
305
|
+
return True
|
|
306
|
+
return run_quiet(["docker", "network", "create", CORE_NETWORK])[0] == 0
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _sidecar_running() -> bool:
|
|
310
|
+
return bool(run_quiet(["docker", "ps", "-aq", "--filter", f"name=^{CORE_PG_CONTAINER}$"])[1])
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _start_sidecar(engine_image: str, workspace: Path) -> str | None:
|
|
314
|
+
"""Start (or reuse) the pinned pgvector sidecar; return its SHS_DATABASE_URL.
|
|
315
|
+
|
|
316
|
+
The sidecar owns its data volume and outlives core — never torn down on
|
|
317
|
+
core removal. Password is generated once and persisted in console state so
|
|
318
|
+
the derived URL is stable across launches.
|
|
319
|
+
"""
|
|
320
|
+
if not engine_image:
|
|
321
|
+
error("Manifest has no engine.image pin — cannot start a trusted Postgres sidecar.")
|
|
322
|
+
return None
|
|
323
|
+
if not _ensure_core_network():
|
|
324
|
+
error(f"Could not create docker network '{CORE_NETWORK}'.")
|
|
325
|
+
return None
|
|
326
|
+
|
|
327
|
+
state = _load_state(CORE_STATE_FILE)
|
|
328
|
+
password = state.get("_SIDECAR_PG_PASSWORD")
|
|
329
|
+
if not password:
|
|
330
|
+
password = os.urandom(24).hex()
|
|
331
|
+
state["_SIDECAR_PG_PASSWORD"] = password
|
|
332
|
+
_save_state(state, CORE_STATE_DIR, CORE_STATE_FILE)
|
|
333
|
+
|
|
334
|
+
url = (
|
|
335
|
+
f"postgresql+asyncpg://{CORE_PG_USER}:{password}"
|
|
336
|
+
f"@{CORE_PG_CONTAINER}:5432/{CORE_PG_DB}"
|
|
337
|
+
)
|
|
338
|
+
|
|
339
|
+
if _sidecar_running():
|
|
340
|
+
info(f"Reusing existing Postgres sidecar '{CORE_PG_CONTAINER}'.")
|
|
341
|
+
run_quiet(["docker", "start", CORE_PG_CONTAINER])
|
|
342
|
+
return url
|
|
343
|
+
|
|
344
|
+
db_dir = workspace / "db"
|
|
345
|
+
db_dir.mkdir(parents=True, exist_ok=True)
|
|
346
|
+
info(f"Starting Postgres sidecar '{CORE_PG_CONTAINER}' ({engine_image}) ...")
|
|
347
|
+
# PG 18+ images store data in a major-version subdir and require the mount
|
|
348
|
+
# at /var/lib/postgresql (not .../data) — mounting .../data exits non-zero.
|
|
349
|
+
cmd = [
|
|
350
|
+
"docker", "run", "-d", "--name", CORE_PG_CONTAINER,
|
|
351
|
+
"--network", CORE_NETWORK,
|
|
352
|
+
"-e", f"POSTGRES_USER={CORE_PG_USER}",
|
|
353
|
+
"-e", f"POSTGRES_PASSWORD={password}",
|
|
354
|
+
"-e", f"POSTGRES_DB={CORE_PG_DB}",
|
|
355
|
+
"-v", f"{db_dir}:/var/lib/postgresql",
|
|
356
|
+
engine_image,
|
|
357
|
+
]
|
|
358
|
+
if run(cmd, check=False).returncode != 0:
|
|
359
|
+
error("Failed to start Postgres sidecar.")
|
|
360
|
+
return None
|
|
361
|
+
if not _wait_pg_ready():
|
|
362
|
+
warn("Postgres sidecar did not report ready — core bootstrap may fail on first boot.")
|
|
363
|
+
return url
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _wait_pg_ready(attempts: int = 30, interval: int = 2) -> bool:
|
|
367
|
+
"""Poll pg_isready inside the sidecar until it accepts connections."""
|
|
368
|
+
import time
|
|
369
|
+
|
|
370
|
+
for _ in range(attempts):
|
|
371
|
+
rc, _ = run_quiet(
|
|
372
|
+
["docker", "exec", CORE_PG_CONTAINER, "pg_isready", "-U", CORE_PG_USER],
|
|
373
|
+
)
|
|
374
|
+
if rc == 0:
|
|
375
|
+
ok("Postgres sidecar ready.")
|
|
376
|
+
return True
|
|
377
|
+
time.sleep(interval)
|
|
378
|
+
return False
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _resolve_core_db_url(engine_image: str, workspace: Path) -> str | None:
|
|
382
|
+
"""Return SHS_DATABASE_URL for core, provisioning as chosen.
|
|
383
|
+
|
|
384
|
+
Precedence: a persisted URL, then SHS_DATABASE_URL from the environment (so a
|
|
385
|
+
cloud Postgres can be scripted, same as the supervisor creds above), then a
|
|
386
|
+
three-way prompt: enter a URL, spin up the sidecar, or defer.
|
|
387
|
+
Returns None to mean "defer, do not launch core yet".
|
|
388
|
+
"""
|
|
389
|
+
state = _load_state(CORE_STATE_FILE)
|
|
390
|
+
if state.get("SHS_DATABASE_URL"):
|
|
391
|
+
return state["SHS_DATABASE_URL"]
|
|
392
|
+
|
|
393
|
+
env_url = os.environ.get("SHS_DATABASE_URL", "").strip()
|
|
394
|
+
if env_url:
|
|
395
|
+
state["SHS_DATABASE_URL"] = env_url
|
|
396
|
+
_save_state(state, CORE_STATE_DIR, CORE_STATE_FILE)
|
|
397
|
+
return env_url
|
|
398
|
+
|
|
399
|
+
info("Core uses an external PostgreSQL. Choose how to supply it:")
|
|
400
|
+
print()
|
|
401
|
+
choices = [
|
|
402
|
+
"Enter a connection URL (local · docker · cloud Postgres)",
|
|
403
|
+
"Spin up a Postgres sidecar (pinned pgvector, local, opt-in)",
|
|
404
|
+
"Configure later (don't launch yet; add the URL from the console)",
|
|
405
|
+
]
|
|
406
|
+
idx = _interactive_single("Database", choices, default=0, nav=False)
|
|
407
|
+
|
|
408
|
+
if idx == 0:
|
|
409
|
+
info(_dim(
|
|
410
|
+
"The URL's role must have CREATEROLE — the API provisions the "
|
|
411
|
+
"restricted shs_app runtime role from it on boot."
|
|
412
|
+
))
|
|
413
|
+
url = _prompt_password("SHS_DATABASE_URL (postgresql+asyncpg://user:pass@host:5432/db)")
|
|
414
|
+
if not url.strip():
|
|
415
|
+
error("No URL entered — aborting.")
|
|
416
|
+
return None
|
|
417
|
+
url = url.strip()
|
|
418
|
+
elif idx == 1:
|
|
419
|
+
url = _start_sidecar(engine_image, workspace)
|
|
420
|
+
if url is None:
|
|
421
|
+
return None
|
|
422
|
+
else:
|
|
423
|
+
_defer_core_db()
|
|
424
|
+
return None
|
|
425
|
+
|
|
426
|
+
state["SHS_DATABASE_URL"] = url
|
|
427
|
+
_save_state(state, CORE_STATE_DIR, CORE_STATE_FILE)
|
|
428
|
+
return url
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _ensure_core_app_db_url(db_url: str) -> str:
|
|
432
|
+
"""Derive/persist core's restricted-role URL matching *db_url*'s DSN.
|
|
433
|
+
|
|
434
|
+
Password is generated once and persisted; if the operator repoints the
|
|
435
|
+
privileged URL at a different host/port/db, the DSN is re-derived keeping
|
|
436
|
+
the same password (the API's bootstrap refreshes the role from it on boot).
|
|
437
|
+
"""
|
|
438
|
+
from urllib.parse import urlsplit
|
|
439
|
+
|
|
440
|
+
from .env import derive_app_db_url
|
|
441
|
+
|
|
442
|
+
state = _load_state(CORE_STATE_FILE)
|
|
443
|
+
existing = state.get("SHS_DATABASE_APP_URL", "")
|
|
444
|
+
new_parts = urlsplit(db_url)
|
|
445
|
+
if existing:
|
|
446
|
+
old = urlsplit(existing)
|
|
447
|
+
if (old.hostname, old.port, old.path) == (
|
|
448
|
+
new_parts.hostname,
|
|
449
|
+
new_parts.port,
|
|
450
|
+
new_parts.path,
|
|
451
|
+
):
|
|
452
|
+
return existing
|
|
453
|
+
password = urlsplit(existing).password if existing else None
|
|
454
|
+
app_url = derive_app_db_url(db_url, password=password)
|
|
455
|
+
state["SHS_DATABASE_APP_URL"] = app_url
|
|
456
|
+
_save_state(state, CORE_STATE_DIR, CORE_STATE_FILE)
|
|
457
|
+
return app_url
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _print_byo_provision_help(db_url: str, app_url: str) -> None:
|
|
461
|
+
"""Fail-closed diagnosis for BYO Postgres: shs_app provisioning failed.
|
|
462
|
+
|
|
463
|
+
Two supported fixes (7cc4942ce): grant CREATEROLE for automatic mode, or
|
|
464
|
+
pre-create the role as a DB admin (manual mode — a correctly-attributed
|
|
465
|
+
role provisions fully every boot; the operator owns its password).
|
|
466
|
+
"""
|
|
467
|
+
from urllib.parse import unquote, urlsplit
|
|
468
|
+
|
|
469
|
+
priv_role = urlsplit(db_url).username or "<your role>"
|
|
470
|
+
app = urlsplit(app_url)
|
|
471
|
+
app_role = app.username or "shs_app"
|
|
472
|
+
app_password = unquote(app.password or "")
|
|
473
|
+
error("The API did not become healthy. On boot it provisions the restricted")
|
|
474
|
+
error(f"{app_role} role via SHS_DATABASE_URL and fails closed if it can't.")
|
|
475
|
+
create_sql = (
|
|
476
|
+
f"CREATE ROLE {app_role} LOGIN PASSWORD '{app_password}' "
|
|
477
|
+
"NOSUPERUSER NOBYPASSRLS NOCREATEDB NOCREATEROLE NOINHERIT;"
|
|
478
|
+
)
|
|
479
|
+
print(f" {_dim('Fix on your Postgres (either one), then restart the container:')}")
|
|
480
|
+
print(f" {_bold(f'ALTER ROLE {priv_role} CREATEROLE;')} {_dim('(automatic mode)')}")
|
|
481
|
+
print(f" {_bold(create_sql)} {_dim('(manual mode)')}")
|
|
482
|
+
print(f" {_dim('Diagnose:')} {_bold(f'docker logs {CORE_CONTAINER_NAME} --tail 30')}")
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _defer_core_db() -> None:
|
|
486
|
+
"""Record the deferral and tell the operator how to finish later."""
|
|
487
|
+
CORE_STATE_DIR.mkdir(mode=0o700, exist_ok=True)
|
|
488
|
+
warn("Core not launched — no database configured yet.")
|
|
489
|
+
print(f" {_dim('Add a database later, then launch:')}")
|
|
490
|
+
print(f" {_bold('studio-console launch-core')} {_dim('(re-run and pick a DB)')}")
|
|
491
|
+
print(f" {_dim('Or set the URL directly in console state, then re-run launch-core.')}")
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def cmd_launch_core(context: str, tag: str | None = None, workspace: Path | None = None) -> bool:
|
|
495
|
+
"""Launch the core single-image deployment against an external Postgres."""
|
|
496
|
+
if context != "host":
|
|
497
|
+
error("launch-core runs on the host — you appear to be inside a container.")
|
|
498
|
+
return False
|
|
499
|
+
if run_quiet(["docker", "version"])[0] != 0:
|
|
500
|
+
error("Docker is not available. Install Docker and retry.")
|
|
501
|
+
return False
|
|
502
|
+
|
|
503
|
+
heading("Launch Studio (core)")
|
|
504
|
+
tag = tag or DEFAULT_TAG
|
|
505
|
+
image_ref = f"{CORE_IMAGE}:{tag}"
|
|
506
|
+
|
|
507
|
+
existing = run_quiet(["docker", "ps", "-aq", "--filter", f"name=^{CORE_CONTAINER_NAME}$"])[1]
|
|
508
|
+
if existing:
|
|
509
|
+
warn(f"Container '{CORE_CONTAINER_NAME}' already exists.")
|
|
510
|
+
print(f" {_dim('Connect:')} {_bold(f'docker exec -it {CORE_CONTAINER_NAME} studio-console')}")
|
|
511
|
+
return False
|
|
512
|
+
|
|
513
|
+
manifest = _read_manifest(image_ref, shape="core")
|
|
514
|
+
if manifest is None:
|
|
515
|
+
return False
|
|
516
|
+
|
|
517
|
+
workspace = workspace or (Path.home() / ".studio-core")
|
|
518
|
+
_ensure_workspace(workspace)
|
|
519
|
+
|
|
520
|
+
# Supervisor creds via the shared flow; DB via the dedicated 3-way prompt.
|
|
521
|
+
creds = _ensure_creds(
|
|
522
|
+
manifest,
|
|
523
|
+
CORE_STATE_DIR,
|
|
524
|
+
CORE_STATE_FILE,
|
|
525
|
+
only=["SHS_SUPERVISOR_USER", "SHS_SUPERVISOR_PASSWORD"],
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
db_url = _resolve_core_db_url(manifest.get("_engine", {}).get("image", ""), workspace)
|
|
529
|
+
if db_url is None:
|
|
530
|
+
return False # deferred or aborted
|
|
531
|
+
creds["SHS_DATABASE_URL"] = db_url
|
|
532
|
+
# Restricted runtime role: the API provisions shs_app from this URL on
|
|
533
|
+
# boot and fails closed if it can't. Older images ignore the var.
|
|
534
|
+
creds["SHS_DATABASE_APP_URL"] = _ensure_core_app_db_url(db_url)
|
|
535
|
+
|
|
536
|
+
_seed_first_boot_vars(workspace, creds)
|
|
537
|
+
cf_token = _cf_token_mount(workspace, CORE_STATE_DIR)
|
|
538
|
+
mounts = [f"{cf_token}:{CF_TOKEN_MOUNT}:ro"] if cf_token else None
|
|
539
|
+
|
|
540
|
+
# If the sidecar is on the core network, core must join it to reach the DB.
|
|
541
|
+
network = CORE_NETWORK if _sidecar_running() else None
|
|
542
|
+
cmd = _build_run_cmd(
|
|
543
|
+
manifest, image_ref, creds, workspace,
|
|
544
|
+
container_name=CORE_CONTAINER_NAME,
|
|
545
|
+
network=network,
|
|
546
|
+
publish_internal=False,
|
|
547
|
+
mounts=mounts,
|
|
548
|
+
)
|
|
549
|
+
|
|
550
|
+
info(f"Starting {image_ref} (data: {workspace}) ...")
|
|
551
|
+
if run(cmd, check=False).returncode != 0:
|
|
552
|
+
error("docker run failed.")
|
|
553
|
+
return False
|
|
554
|
+
|
|
555
|
+
ok(f"{CORE_CONTAINER_NAME} started.")
|
|
556
|
+
nginx_port = next((p["container"] for p in manifest["ports"] if p.get("name") == "nginx"), 80)
|
|
557
|
+
print(f" {_dim('UI:')} {_cyan(f'http://localhost:{nginx_port}')}")
|
|
558
|
+
print(f" {_dim('Console:')} {_bold(f'docker exec -it {CORE_CONTAINER_NAME} studio-console')}")
|
|
559
|
+
print()
|
|
560
|
+
|
|
561
|
+
api_healthy = _wait_healthy(manifest, container_name=CORE_CONTAINER_NAME)
|
|
562
|
+
if api_healthy:
|
|
563
|
+
from .commands import _core_plan
|
|
564
|
+
|
|
565
|
+
_bootstrap_admin(
|
|
566
|
+
workspace, _core_plan(CORE_CONTAINER_NAME, CORE_PG_CONTAINER, nginx_port)
|
|
567
|
+
)
|
|
568
|
+
elif CORE_PG_CONTAINER not in db_url:
|
|
569
|
+
_print_byo_provision_help(db_url, creds["SHS_DATABASE_APP_URL"])
|
|
570
|
+
|
|
571
|
+
info("Opening in-container console ...")
|
|
572
|
+
run(["docker", "exec", "-it", CORE_CONTAINER_NAME, "studio-console"], check=False, timeout=None)
|
|
573
|
+
return True
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def cmd_set_core_db_url(context: str, url: str | None = None) -> bool:
|
|
577
|
+
"""Set/replace core's SHS_DATABASE_URL in host console state.
|
|
578
|
+
|
|
579
|
+
Core reads the DB URL from process env at launch (never from the workspace
|
|
580
|
+
.env), and the launcher owns that persistence — so this writes to console
|
|
581
|
+
state, and the next `launch-core` injects it via `docker run -e`.
|
|
582
|
+
"""
|
|
583
|
+
if context != "host":
|
|
584
|
+
error("core-db-url runs on the host — you appear to be inside a container.")
|
|
585
|
+
return False
|
|
586
|
+
|
|
587
|
+
url = (url or "").strip() or _prompt_password(
|
|
588
|
+
"SHS_DATABASE_URL (postgresql+asyncpg://user:pass@host:5432/db)"
|
|
589
|
+
).strip()
|
|
590
|
+
if not url:
|
|
591
|
+
error("No URL provided.")
|
|
592
|
+
return False
|
|
593
|
+
|
|
594
|
+
state = _load_state(CORE_STATE_FILE)
|
|
595
|
+
state["SHS_DATABASE_URL"] = url
|
|
596
|
+
_save_state(state, CORE_STATE_DIR, CORE_STATE_FILE)
|
|
597
|
+
_ensure_core_app_db_url(url) # keep the derived shs_app URL on the same DSN
|
|
598
|
+
ok("Saved core database URL.")
|
|
599
|
+
|
|
600
|
+
if run_quiet(["docker", "ps", "-q", "--filter", f"name=^{CORE_CONTAINER_NAME}$"])[1]:
|
|
601
|
+
warn("A core container is running — recreate it to apply:")
|
|
602
|
+
print(f" {_bold(f'docker rm -f {CORE_CONTAINER_NAME} && studio-console launch-core')}")
|
|
603
|
+
else:
|
|
604
|
+
print(f" {_dim('Launch:')} {_bold('studio-console launch-core')}")
|
|
605
|
+
return True
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def _bootstrap_admin(workspace: Path, plan: "object") -> None:
|
|
609
|
+
"""First-boot super admin creation, using the given shape's exec plan.
|
|
610
|
+
|
|
611
|
+
*plan* is a _BootstrapPlan: full execs one container for both API and psql;
|
|
612
|
+
core execs the API container for hashing but psql against the DB sidecar.
|
|
613
|
+
"""
|
|
614
|
+
env_file = workspace / ".env"
|
|
615
|
+
if not env_file.exists():
|
|
616
|
+
warn("Workspace .env not found — skipping admin bootstrap.")
|
|
617
|
+
return
|
|
618
|
+
|
|
619
|
+
from .commands import _bootstrap_first_admin
|
|
620
|
+
|
|
621
|
+
_bootstrap_first_admin(env_file, plan)
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
def _curl_status(url: str) -> int:
|
|
625
|
+
"""Return HTTP status code for url, or 0 if unreachable."""
|
|
626
|
+
rc, out = run_quiet(["curl", "-sf", "-o", "/dev/null", "-w", "%{http_code}", url], timeout=5)
|
|
627
|
+
try:
|
|
628
|
+
return int(out.strip())
|
|
629
|
+
except ValueError:
|
|
630
|
+
return 0
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def _wait_healthy(
|
|
634
|
+
manifest: dict,
|
|
635
|
+
attempts: int = 30,
|
|
636
|
+
interval: int = 3,
|
|
637
|
+
container_name: str = CONTAINER_NAME,
|
|
638
|
+
) -> bool:
|
|
639
|
+
"""Poll API + UI on first boot; flag a non-200 UI. Returns API health.
|
|
640
|
+
|
|
641
|
+
When api/ui are host-published (full), poll them directly. When they are
|
|
642
|
+
``internal: true`` (core), poll through the nginx front door instead —
|
|
643
|
+
``/health`` (served by nginx) and ``/`` — since those ports aren't
|
|
644
|
+
reachable from the host.
|
|
645
|
+
"""
|
|
646
|
+
import time
|
|
647
|
+
|
|
648
|
+
api_p = next((p for p in manifest["ports"] if p.get("name") == "api"), {})
|
|
649
|
+
ui_p = next((p for p in manifest["ports"] if p.get("name") == "ui"), {})
|
|
650
|
+
nginx_port = next((p["container"] for p in manifest["ports"] if p.get("name") == "nginx"), 80)
|
|
651
|
+
|
|
652
|
+
if api_p.get("internal") or ui_p.get("internal"):
|
|
653
|
+
# nginx serves `location = /health` directly; /api/* proxies to the API.
|
|
654
|
+
api_url = f"http://localhost:{nginx_port}/health"
|
|
655
|
+
ui_url = f"http://localhost:{nginx_port}/"
|
|
656
|
+
api_label = ui_label = f":{nginx_port}"
|
|
657
|
+
else:
|
|
658
|
+
api_port = api_p.get("container", 8000)
|
|
659
|
+
ui_port = ui_p.get("container", 3000)
|
|
660
|
+
api_url = f"http://localhost:{api_port}/health"
|
|
661
|
+
ui_url = f"http://localhost:{ui_port}"
|
|
662
|
+
api_label, ui_label = f":{api_port}", f":{ui_port}"
|
|
663
|
+
|
|
664
|
+
info("Waiting for the stack to come up (first boot runs bootstrap) ...")
|
|
665
|
+
api_ok = ui_status = 0
|
|
666
|
+
for _ in range(attempts):
|
|
667
|
+
if api_ok != 200:
|
|
668
|
+
api_ok = _curl_status(api_url)
|
|
669
|
+
ui_status = _curl_status(ui_url)
|
|
670
|
+
if api_ok == 200 and ui_status == 200:
|
|
671
|
+
ok("API and UI healthy.")
|
|
672
|
+
return True
|
|
673
|
+
time.sleep(interval)
|
|
674
|
+
|
|
675
|
+
if api_ok == 200:
|
|
676
|
+
ok(f"API healthy ({api_label}).")
|
|
677
|
+
else:
|
|
678
|
+
warn(f"API not healthy ({api_label}) — check: docker exec {container_name} supervisorctl status")
|
|
679
|
+
if ui_status == 200:
|
|
680
|
+
ok(f"UI healthy ({ui_label}).")
|
|
681
|
+
else:
|
|
682
|
+
warn(f"UI returned {ui_status or 'no response'} ({ui_label}) — SSR may be failing.")
|
|
683
|
+
print(f" {_dim('Logs:')} {_bold(f'docker exec {container_name} supervisorctl tail ui stderr')}")
|
|
684
|
+
return api_ok == 200
|