-
Notifications
You must be signed in to change notification settings - Fork 20
Expand file tree
/
Copy pathserverctl.py
More file actions
2517 lines (2326 loc) · 104 KB
/
Copy pathserverctl.py
File metadata and controls
2517 lines (2326 loc) · 104 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
"""Human-facing lifecycle CLI for the project-local Tofu manager."""
from __future__ import annotations
import argparse
from datetime import datetime
import fcntl
import json
import math
import os
import re
import shlex
import shutil
import signal
import stat
import subprocess
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
from runtime_guards import resource_budget_manifest
from server_manager import (
SERVER_ENV_KEYS,
listener_pids,
probe_application_readiness,
read_lock_status,
)
from supervisor_protocol import (
SupervisorRefreshError,
refresh_supervisor,
supervisor_generation_matches,
supervisor_source_fingerprint,
)
from tofu_dotenv import parse_env_boolean, read_dotenv_values
PROJECT = os.path.realpath(os.environ.get('TOFU_PROJECT_PATH') or
os.path.dirname(os.path.abspath(__file__)))
MANAGER_HOST = os.environ.get('TOFU_SUPERVISOR_HOST', '127.0.0.1')
try:
MANAGER_PORT = int(os.environ.get('TOFU_SUPERVISOR_PORT', '15001'))
except ValueError:
MANAGER_PORT = 15001
BASE_URL = f'http://{MANAGER_HOST}:{MANAGER_PORT}'
# supervisor.sh intentionally waits up to 20 seconds for a detached watchdog
# on a saturated host. The caller must outlive that whole budget plus shell
# cleanup; a shorter timeout leaves a healthy manager behind while reporting
# launch failure.
_MANAGER_LAUNCH_TIMEOUT_FLOOR = 25.0
_FRONTEND_REPAIR_TIMEOUT_SECONDS = 600.0
STARTUP_STUCK_SECONDS = 300.0
_CGROUP_V2_USAGE = '/sys/fs/cgroup/memory.current'
_CGROUP_V2_LIMIT = '/sys/fs/cgroup/memory.max'
_CGROUP_V2_SWAP_LIMIT = '/sys/fs/cgroup/memory.swap.max'
_CGROUP_V2_EVENTS = '/sys/fs/cgroup/memory.events'
_CGROUP_V1_USAGE = '/sys/fs/cgroup/memory/memory.usage_in_bytes'
_CGROUP_V1_LIMIT = '/sys/fs/cgroup/memory/memory.limit_in_bytes'
_CGROUP_V1_MEMSW_LIMIT = '/sys/fs/cgroup/memory/memory.memsw.limit_in_bytes'
_CGROUP_V1_OOM = '/sys/fs/cgroup/memory/memory.oom_control'
_CGROUP_V1_FAILCNT = '/sys/fs/cgroup/memory/memory.failcnt'
class ManagerUnavailable(RuntimeError):
pass
def _control_command(*arguments: object) -> str:
"""Return one cwd-independent, shell-copyable serverctl invocation."""
script = str(Path(PROJECT) / 'serverctl.py')
return shlex.join([sys.executable, script, *(str(item) for item in arguments)])
def _bounded_log_lines(value: str) -> int:
try:
lines = int(value)
except (TypeError, ValueError) as exc:
raise argparse.ArgumentTypeError('must be an integer from 1 to 1000') from exc
if not 1 <= lines <= 1000:
raise argparse.ArgumentTypeError('must be an integer from 1 to 1000')
return lines
def _wait_seconds(value: str) -> float:
try:
seconds = float(value)
except (TypeError, ValueError) as exc:
raise argparse.ArgumentTypeError('must be between 0 and 3600 seconds') from exc
if not 0 <= seconds <= 3600:
raise argparse.ArgumentTypeError('must be between 0 and 3600 seconds')
return seconds
def _tcp_port(value: object, default: int = 15000) -> int:
"""Return a valid port without letting broken config crash diagnostics."""
try:
port = int(value)
except (TypeError, ValueError):
return default
return port if 1 <= port <= 65535 else default
def _valid_tcp_port(value: object) -> int | None:
try:
port = int(value)
except (TypeError, ValueError):
return None
return port if 1 <= port <= 65535 else None
def _version_text() -> str:
try:
version = (Path(PROJECT) / 'VERSION').read_text(encoding='utf-8').strip()
except OSError:
version = 'unknown'
return f'Tofu {version or "unknown"}'
def _login_base_url(explicit: str = '') -> str:
"""Resolve one browser origin, normalizing listener-only host values."""
configured = (explicit or os.environ.get('TOFU_PUBLIC_URL') or '').strip()
if configured:
candidate = configured
else:
raw_tls = _project_setting(PROJECT, 'TOFU_TLS', '').strip()
tls = parse_env_boolean(raw_tls)
if raw_tls and tls is None:
raise ValueError(
f'unsupported TOFU_TLS={raw_tls!r}; run serverctl.py doctor')
scheme = 'https' if tls else 'http'
published_port = _valid_tcp_port(os.environ.get('TOFU_PUBLISHED_PORT'))
port = published_port or _configured_port_snapshot(PROJECT)['port']
host = (os.environ.get('TOFU_PUBLIC_HOST') or
_project_setting(PROJECT, 'BIND_HOST', 'localhost')).strip()
if not host or host in ('0.0.0.0', '::', '[::]'):
host = 'localhost'
elif ':' in host and not host.startswith('['):
host = f'[{host}]'
candidate = f'{scheme}://{host}:{port}'
parsed = urllib.parse.urlsplit(candidate)
try:
parsed_port = parsed.port
except ValueError as exc:
raise ValueError(f'invalid login base URL port: {candidate!r}') from exc
if parsed.scheme not in ('http', 'https') or not parsed.hostname:
raise ValueError('login base URL must be an http(s) origin')
if parsed.username or parsed.password or parsed.query or parsed.fragment:
raise ValueError(
'login base URL must not contain credentials, a query, or a fragment')
if parsed.path not in ('', '/'):
raise ValueError('login base URL must not contain a path')
if parsed_port is not None and not 1 <= parsed_port <= 65535:
raise ValueError('login base URL port must be between 1 and 65535')
return urllib.parse.urlunsplit(
(parsed.scheme, parsed.netloc, '', '', '')).rstrip('/')
def _resolved_auth_mode() -> str:
from lib.auth_mode import get_mode
return get_mode()
def _read_first_run_token() -> tuple[str, Path]:
"""Read and validate the deliberately recoverable bootstrap credential."""
from lib import api_keys
path = Path(api_keys._FIRST_RUN_TOKEN_FILE)
metadata = path.lstat()
if not stat.S_ISREG(metadata.st_mode):
raise ValueError(f'bootstrap token path is not a regular file: {path}')
if metadata.st_mode & 0o077:
raise PermissionError(
f'bootstrap token permissions are too broad: {path}; run chmod 600')
if not 1 <= metadata.st_size <= 8192:
raise ValueError(f'bootstrap token file has an invalid size: {path}')
token = path.read_text(encoding='utf-8').strip()
if not token or any(character.isspace() for character in token):
raise ValueError(f'bootstrap token file is malformed: {path}')
if api_keys.validate_token(token) is None:
raise ValueError(f'bootstrap token is stale or revoked: {path}')
return token, path
def cmd_login_url(args: argparse.Namespace) -> int:
"""Print a copyable browser URL only after an explicit operator request."""
try:
base_url = _login_base_url(args.base_url)
mode = _resolved_auth_mode()
except (OSError, RuntimeError, ValueError) as exc:
print(f'Could not resolve login URL: {exc}', file=sys.stderr)
return 1
if mode == 'open':
print('Auth mode is open; no login token is required.')
print(f'Open: {base_url}')
return 0
try:
token, path = _read_first_run_token()
except FileNotFoundError:
print('No recoverable first-run token exists.', file=sys.stderr)
print('Use an existing API key, or create a new key from an already '
'authenticated Settings session.', file=sys.stderr)
return 1
except (OSError, PermissionError, RuntimeError, ValueError) as exc:
print(f'Could not read first-run token: {exc}', file=sys.stderr)
return 1
login_url = base_url + '/?' + urllib.parse.urlencode({'token': token})
print(f'Open once: {login_url}')
print(f'Token source: {path}')
print('This URL contains an admin credential. Do not share it or paste it '
'into logs/support bundles.', file=sys.stderr)
return 0
def _request(path: str, body: dict | None = None, timeout: float = 5.0) -> dict:
data = json.dumps(body).encode('utf-8') if body is not None else None
request = urllib.request.Request(
BASE_URL + path, data=data, method='POST' if body is not None else 'GET')
if data is not None:
request.add_header('Content-Type', 'application/json')
try:
with urllib.request.urlopen(request, timeout=timeout) as response:
return json.loads(response.read().decode('utf-8') or '{}')
except urllib.error.HTTPError as exc:
try:
payload = json.loads(exc.read().decode('utf-8') or '{}')
except (ValueError, TypeError):
payload = {'ok': False, 'message': str(exc)}
payload.setdefault('httpStatus', exc.code)
return payload
except (OSError, ValueError, urllib.error.URLError) as exc:
raise ManagerUnavailable(str(exc)) from exc
def _manager_health() -> dict | None:
try:
payload = _request('/health', timeout=1.0)
if payload.get('ok') and PROJECT in payload.get('projects', []):
return payload
except ManagerUnavailable:
pass
return None
def _lifecycle_owns_frontend() -> bool:
"""Resolve the declared role without importing application assembly."""
try:
project_values = read_dotenv_values(Path(PROJECT) / '.env')
except OSError:
project_values = {}
role = os.environ.get('TOFU_PROCESS_ROLE') \
or project_values.get('TOFU_PROCESS_ROLE') or 'all'
try:
from lib.process_roles import CAPABILITY_FRONTEND, process_role_has
return process_role_has(role, CAPABILITY_FRONTEND)
except (ImportError, ValueError):
# Invalid deployment configuration remains the server's authoritative
# startup error and must not trigger a mutating build first.
return False
def _validate_frontend_artifact() -> None:
local_project = Path(__file__).resolve().parent
selected_project = Path(PROJECT).resolve()
if selected_project == local_project:
from lib.vite_assets import validate_source_vite_artifact
validate_source_vite_artifact()
return
# ``TOFU_PROJECT_PATH`` may ask this CLI to own another checkout. Importing
# our own ``lib.vite_assets`` would validate the wrong static graph, so use
# that checkout's small verifier in an isolated interpreter instead.
verifier = selected_project / 'scripts' / 'verify_frontend_dist.py'
if not verifier.is_file():
raise RuntimeError(f'frontend validator is missing: {verifier}')
completed = subprocess.run(
[sys.executable, str(verifier), '--authoring-freshness'],
cwd=selected_project,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
timeout=30.0,
)
if completed.returncode:
detail = (completed.stdout or '').strip()[-2000:]
raise RuntimeError(detail or 'frontend artifact validation failed')
def _source_frontend_build_command() -> list[str] | None:
"""Return a source-build command only when local dev dependencies exist."""
project = Path(PROJECT)
build_script = project / 'scripts' / 'build_frontend.mjs'
vite_package = project / 'node_modules' / 'vite' / 'package.json'
if not build_script.is_file() or not vite_package.is_file():
return None
sibling_node = Path(sys.executable).with_name('node')
if sibling_node.is_file() and os.access(sibling_node, os.X_OK):
node = str(sibling_node)
else:
node = shutil.which('node') or ''
if not node:
return None
return [node, str(build_script)]
def prepare_source_frontend_artifact(operation: str) -> str:
"""Rebuild one stale source-checkout graph before a lifecycle action.
Release installs intentionally omit Node and rely on the content digest in
their verified prebuilt graph. A valid release never enters the repair
path; an invalid graph without a local builder fails before lifecycle state
changes, while the old worker can still serve its last published graph.
"""
if not _lifecycle_owns_frontend():
return ''
initial_error_message = ''
try:
_validate_frontend_artifact()
return ''
except Exception as initial_error:
initial_error_message = str(initial_error)
command = _source_frontend_build_command()
if command is None:
return (
'frontend artifact validation failed and no local Vite '
f'builder is available: {initial_error_message}')
lock_path = Path(PROJECT) / 'data' / '.frontend-build.lock'
try:
lock_path.parent.mkdir(parents=True, exist_ok=True)
lock_fd = os.open(lock_path, os.O_CREAT | os.O_RDWR, 0o600)
except OSError as exc:
return f'frontend artifact rebuild lock could not be created: {exc}'
with os.fdopen(lock_fd, 'a+', encoding='utf-8') as build_lock:
fcntl.flock(build_lock.fileno(), fcntl.LOCK_EX)
# @reboot and the minute-level recovery fallback may overlap. The
# first process publishes atomically; every waiter reuses that graph.
try:
_validate_frontend_artifact()
return ''
except Exception:
pass
print(
f'Frontend artifact is stale; rebuilding once before {operation}…',
file=sys.stderr,
)
try:
completed = subprocess.run(
command,
cwd=PROJECT,
timeout=_FRONTEND_REPAIR_TIMEOUT_SECONDS,
)
except subprocess.TimeoutExpired:
return (
'frontend artifact rebuild timed out after '
f'{_FRONTEND_REPAIR_TIMEOUT_SECONDS:.0f}s; '
'run `npm run build:frontend` manually')
except OSError as exc:
return f'frontend artifact rebuild could not start: {exc}'
if completed.returncode:
return (
f'frontend artifact rebuild failed with exit '
f'{completed.returncode}; '
'run `npm run build:frontend` manually')
try:
_validate_frontend_artifact()
except Exception as final_error:
return (
'frontend artifact remained invalid after rebuild: '
f'{final_error}; initial error: {initial_error_message}')
return ''
def _repair_source_frontend_artifact(operation: str) -> str:
"""Compatibility wrapper for older callers and focused lifecycle tests."""
return prepare_source_frontend_artifact(operation)
def cmd_prepare_frontend(args: argparse.Namespace) -> int:
"""Validate or atomically repair frontend without changing lifecycle."""
error = prepare_source_frontend_artifact(args.operation)
if error:
print(f'Frontend preflight failed: {error}', file=sys.stderr)
return 1
print('Frontend artifact is ready.')
return 0
def ensure_manager(timeout: float = 8.0) -> dict:
health = _manager_health()
if health:
if supervisor_generation_matches(health, PROJECT):
return health
try:
refresh_supervisor(
PROJECT,
environment=os.environ,
timeout=max(8.0, timeout),
)
except SupervisorRefreshError as exc:
raise ManagerUnavailable(
f'cannot refresh stale lifecycle manager: {exc}') from exc
refreshed = _manager_health()
if refreshed and supervisor_generation_matches(refreshed, PROJECT):
return refreshed
raise ManagerUnavailable(
'lifecycle manager restarted but did not report the current '
'source generation')
repair_error = _repair_source_frontend_artifact('manager startup')
if repair_error:
raise ManagerUnavailable(repair_error)
script = os.path.join(PROJECT, 'supervisor.sh')
env = os.environ.copy()
env['TOFU_SUPERVISOR_PROJECTS'] = PROJECT
env['TOFU_SUPERVISOR_PYTHON'] = sys.executable
try:
result = subprocess.run(
['bash', script, 'daemon'], cwd=PROJECT, env=env,
capture_output=True, text=True,
timeout=max(_MANAGER_LAUNCH_TIMEOUT_FLOOR, timeout + 2.0),
)
except (OSError, subprocess.SubprocessError) as exc:
raise ManagerUnavailable(f'cannot launch manager: {exc}') from exc
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
health = _manager_health()
if health and supervisor_generation_matches(health, PROJECT):
return health
time.sleep(0.2)
detail = ((result.stdout or '') + (result.stderr or '')).strip()
log = os.path.join(PROJECT, 'logs', 'server-manager.log')
raise ManagerUnavailable(
f'manager did not become ready on {BASE_URL}; {detail or "no launcher output"}; '
f'log: {log}')
def _remote_status(*, probe: bool = False) -> dict | None:
query = urllib.parse.urlencode({'projectPath': PROJECT, 'probe': '1' if probe else '0'})
try:
status = _request('/status?' + query, timeout=2.0)
except ManagerUnavailable:
return None
if probe and status.get('running') and 'ready' not in status:
# During an in-place CLI upgrade an already-running manager may still
# expose the pre-readiness schema. Probe the locked worker directly so
# compatibility never restores the old liveness-only false positive.
port = _valid_tcp_port(status.get('port'))
if port is not None:
direct = probe_application_readiness(
port,
status.get('pid'),
preferred_scheme=str(status.get('scheme') or '').lower(),
timeout=1.0,
)
status = {
**status,
'health': direct.get('health'),
'liveness': direct.get('liveness'),
'ready': direct.get('ready'),
'probeScheme': direct.get('scheme'),
'livenessError': direct.get('livenessError') or '',
'readinessError': direct.get('readinessError') or '',
'readinessState': direct.get('readinessState'),
'storageState': direct.get('storageState'),
'legacyManagerProbe': True,
}
if not direct.get('ready'):
status['observed'] = 'degraded'
status['lastError'] = (
direct.get('readinessError')
if direct.get('liveness') else
direct.get('livenessError')) or status.get('lastError') or ''
return status
def _status_liveness(status: dict | None) -> bool:
if not status:
return False
liveness = status.get('liveness')
if liveness is None:
liveness = status.get('health')
return liveness is True
def _status_ready(status: dict | None) -> bool:
"""Return true only for manager-verified HTTP readiness.
A listening TCP socket is useful startup progress, but it is not the
public readiness contract: imports, storage, or an HTTP probe may still be
failing behind that socket. Callers must obtain the status with
``probe=True`` before using this predicate.
"""
return bool(
status
and status.get('running')
and status.get('observed') == 'running'
and _status_liveness(status)
and status.get('ready') is True)
def _startup_age_seconds(status: dict | None, *, now: float | None = None) -> float:
try:
started_at = float((status or {}).get('processStartedAt') or 0)
except (TypeError, ValueError):
return 0.0
if started_at <= 0:
return 0.0
return max(0.0, (time.time() if now is None else float(now)) - started_at)
def _startup_stuck(status: dict | None, *, now: float | None = None) -> bool:
return bool(
status
and status.get('observed') in ('starting', 'degraded')
and status.get('ready') is not True
and _startup_age_seconds(status, now=now) >= STARTUP_STUCK_SECONDS)
def _declared_worker_port(lock_status: dict | None) -> int | None:
"""Read an explicit ``--port`` from the identity-checked worker command."""
command = str((lock_status or {}).get('cmdline') or '').strip()
if not command:
return None
try:
arguments = shlex.split(command)
except ValueError:
arguments = command.split()
values, error = _forwarded_option_values(arguments, '--port')
return None if error or not values else _valid_tcp_port(values[0])
def _probe_local_worker(port: int, expected_pid: int | None, *,
preferred_scheme: str = '') -> dict:
"""Probe loopback identity and readiness through the manager contract."""
return probe_application_readiness(
port,
expected_pid,
preferred_scheme=preferred_scheme,
timeout=1.0,
)
def _worker_port_drift(status: dict | None, lock_status: dict | None) -> dict | None:
"""Return evidence when manager intent and the live worker endpoint differ."""
if not lock_status or not lock_status.get('running'):
return None
declared_port = _declared_worker_port(lock_status)
manager_port = _valid_tcp_port((status or {}).get('port'))
if declared_port is None or manager_port is None or declared_port == manager_port:
return None
preferred_scheme = str((status or {}).get('scheme') or '').lower()
probe = _probe_local_worker(
declared_port, lock_status.get('pid'), preferred_scheme=preferred_scheme)
return {
'managerPort': manager_port,
'workerDeclaredPort': declared_port,
'workerPid': lock_status.get('pid'),
'listenerPids': listener_pids(declared_port),
**probe,
}
def _service_url(status: dict) -> str:
"""Return a copyable local URL without lying about explicit TLS mode."""
host = str(status.get('bindHost') or os.environ.get('BIND_HOST')
or _project_setting(PROJECT, 'BIND_HOST', 'localhost'))
if host in ('0.0.0.0', '::', '[::]'):
host = 'localhost'
elif ':' in host and not host.startswith('['):
host = f'[{host}]'
scheme = str(status.get('scheme') or '').lower()
if scheme not in ('http', 'https'):
try:
mode = (Path(PROJECT) / 'data' / '.last_serve_mode').read_text(
encoding='utf-8').strip().lower()
except OSError:
mode = ''
scheme = 'https' if mode == 'https' else 'http'
return f'{scheme}://{host}:{_tcp_port(status.get("port"))}'
def _print_start_failure_help() -> None:
print(f'Diagnose: {_control_command("doctor")}', file=sys.stderr)
print(f'Logs : {_control_command("logs", "-n", 200)}', file=sys.stderr)
def _post(action: str, **extra) -> dict:
ensure_manager()
return _request('/' + action, {
'projectPath': PROJECT,
'source': extra.pop('source', 'serverctl'),
**extra,
}, timeout=35.0 if action in ('stop', 'restart') else 5.0)
def _forwarded_server_env() -> dict[str, str]:
# This is the same explicit, non-secret allowlist the lifecycle manager
# accepts. Forwarding only the historical network/RSS subset silently
# discarded one-shot shell overrides such as TOFU_AGENT_WORKERS while the
# equivalent .env setting happened to work in the manager process.
keys = SERVER_ENV_KEYS
try:
project_values = read_dotenv_values(Path(PROJECT) / '.env')
except OSError as exc:
raise ManagerUnavailable(f'cannot read project .env: {exc}') from exc
result: dict[str, str] = {}
for key in keys:
# This mirrors server startup's fill-if-absent rule. Empty values are
# omitted because the lifecycle manager intentionally treats them as
# "not configured" rather than persisting an unusable launch setting.
value = os.environ.get(key) if key in os.environ else project_values.get(key)
if value:
result[key] = value
return result
def _forwarded_option_values(args: list[str], name: str) -> tuple[list[str], str]:
"""Extract one lifecycle-critical server option without importing the app."""
values: list[str] = []
index = 0
while index < len(args):
item = args[index]
if item == name:
if index + 1 >= len(args) or args[index + 1].startswith('--'):
return [], f'{name} requires a value'
values.append(args[index + 1])
index += 2
continue
if item.startswith(name + '='):
value = item.partition('=')[2]
if not value:
return [], f'{name} requires a value'
values.append(value)
index += 1
if len(values) > 1:
return [], f'{name} may be supplied only once'
return values, ''
def _forwarded_server_options_error(
args: list[str], server_env: dict[str, str]) -> str:
"""Reject options that would make manager and worker observe different state."""
value_options = ('--host', '--port', '--certfile', '--keyfile', '--workers')
flag_options = {'--no-tls'}
seen_flags: set[str] = set()
index = 0
while index < len(args):
item = args[index]
if item in flag_options:
if item in seen_flags:
return f'{item} may be supplied only once'
seen_flags.add(item)
index += 1
continue
if item in value_options:
if index + 1 >= len(args) or args[index + 1].startswith('--'):
return f'{item} requires a value'
index += 2
continue
option_name, equals, option_value = item.partition('=')
if equals and option_name in value_options:
if not option_value:
return f'{option_name} requires a value'
index += 1
continue
if item.startswith('-'):
return 'unsupported server option; run `python server.py --help`'
return 'positional server arguments are not supported'
ports, error = _forwarded_option_values(args, '--port')
if error:
return error
raw_port = ports[0] if ports else server_env.get('PORT')
if raw_port is not None:
try:
port = int(raw_port)
except (TypeError, ValueError):
port = 0
if not 1 <= port <= 65535:
source = '--port' if ports else 'PORT'
return f'{source} must be an integer from 1 to 65535 (got {raw_port!r})'
workers, error = _forwarded_option_values(args, '--workers')
if error:
return error
if workers:
try:
worker_count = int(workers[0])
except (TypeError, ValueError):
worker_count = 0
if worker_count != 1:
return '--workers must be 1; scale with separate replicas'
option_values: dict[str, list[str]] = {}
for option in ('--host', '--certfile', '--keyfile'):
values, error = _forwarded_option_values(args, option)
if error:
return error
option_values[option] = values
cert_configured = bool(
option_values['--certfile'] or server_env.get('TLS_CERTFILE'))
key_configured = bool(
option_values['--keyfile'] or server_env.get('TLS_KEYFILE'))
if cert_configured != key_configured:
return '--certfile and --keyfile must be configured together'
if cert_configured and key_configured:
configured_files = {
'--certfile/TLS_CERTFILE': (
option_values['--certfile'][0]
if option_values['--certfile'] else server_env['TLS_CERTFILE']),
'--keyfile/TLS_KEYFILE': (
option_values['--keyfile'][0]
if option_values['--keyfile'] else server_env['TLS_KEYFILE']),
}
for label, configured_path in configured_files.items():
path = Path(configured_path)
if not path.is_absolute():
path = Path(PROJECT) / path
if not path.is_file():
return f'{label} file does not exist: {path}'
raw_tls = str(server_env.get('TOFU_TLS') or '').strip()
if raw_tls and parse_env_boolean(raw_tls) is None \
and '--no-tls' not in seen_flags \
and not (cert_configured and key_configured):
return (
f'unsupported TOFU_TLS={raw_tls!r}; expected 0/1, false/true, '
'no/yes, off/on, or disabled/enabled')
return ''
def _requested_server_port(
args: list[str], server_env: dict[str, str]) -> int | None:
"""Resolve the requested port after validation, using server precedence."""
ports, error = _forwarded_option_values(args, '--port')
if error:
return None
raw_port = ports[0] if ports else server_env.get('PORT')
try:
port = int(raw_port) if raw_port is not None else None
except (TypeError, ValueError):
return None
return port if port is not None and 1 <= port <= 65535 else None
_ANSI_ESCAPE_RE = re.compile(r'\x1b\[[0-?]*[ -/]*[@-~]')
_BOOT_PROGRESS_RE = re.compile(r'\[boot \+\s*\d+(?:\.\d+)?s\]\s*(.+)')
_STARTUP_PHASE_RE = re.compile(
r'\[startup phase (?P<index>\d+)/(?P<total>\d+)\]\s+'
r'(?P<state>start|done|failed)\s+\|\s*(?P<label>[^|]+?)'
r'(?:\s*\|\s*(?P<duration>\d+(?:\.\d+)?)s)?\s*$')
class _StartupProgress:
"""Small foreground progress view backed by the worker's boot log.
The manager deliberately detaches the worker, so its existing ``[boot]``
messages otherwise disappear into server-console.log while a human-facing
``python server.py`` waits in silence. TTYs get one determinate line;
pipes get sparse, durable stage lines suitable for CI logs.
"""
def __init__(self, log_path: str | os.PathLike[str], *, stream=None) -> None:
self.log_path = Path(log_path)
self.stream = sys.stderr if stream is None else stream
setting = (os.environ.get('TOFU_STARTUP_PROGRESS') or '').strip().lower()
self.enabled = setting not in {'0', 'false', 'no', 'off'}
self.interactive = bool(
self.enabled
and getattr(self.stream, 'isatty', lambda: False)()
)
try:
self.offset = self.log_path.stat().st_size
except OSError:
self.offset = 0
self.started = time.monotonic()
self.last_stage = 'Contacting lifecycle manager…'
self.last_emitted_stage = ''
self.last_line_at = 0.0
self.failure_hint = ''
self._partial = ''
self.phase_total = 0
self.phases: dict[int, dict[str, object]] = {}
@property
def elapsed(self) -> float:
return max(0.0, time.monotonic() - self.started)
def start(self) -> None:
if not self.enabled:
return
self._emit(force=True)
def _consume_worker_log(self) -> None:
try:
size = self.log_path.stat().st_size
if size < self.offset:
self.offset = 0
self._partial = ''
with self.log_path.open('rb') as stream:
stream.seek(self.offset)
# A boot log storm must not make the foreground client ingest
# unbounded output. The latest 256 KiB contains every useful
# stage and failure line while the full log remains on disk.
available = max(0, size - self.offset)
if available > 256 * 1024:
stream.seek(size - 256 * 1024)
self._partial = ''
chunk = stream.read()
self.offset = stream.tell()
except OSError:
return
if not chunk:
return
text = self._partial + chunk.decode('utf-8', 'replace')
lines = text.split('\n')
self._partial = lines.pop()
for raw in lines:
line = _ANSI_ESCAPE_RE.sub('', raw).strip()
phase = _STARTUP_PHASE_RE.search(line)
if phase:
self._record_phase(phase.groupdict())
boot = _BOOT_PROGRESS_RE.search(line)
if boot:
stage = ' '.join(boot.group(1).split())
# Structured phase markers are the source of truth. Keep the
# old free-form boot messages as a fallback for workers from
# an older process image or a failed pre-lifespan launch.
if stage and not phase:
self.last_stage = stage[:180]
if ('storage sidecar startup refused' in line.lower()
or line.startswith('RuntimeError: storage sidecar')
or line.startswith('StorageError:')):
self.failure_hint = line[-500:]
def _record_phase(self, fields: dict[str, str | None]) -> None:
try:
index = int(fields['index'] or 0)
total = int(fields['total'] or 0)
except (TypeError, ValueError):
return
if index <= 0 or total <= 0 or index > total:
return
self.phase_total = max(self.phase_total, total)
label = ' '.join((fields.get('label') or '').split())
if not label:
return
phase = self.phases.setdefault(index, {
'label': label,
'state': 'pending',
'duration': None,
'started_at': None,
})
phase['label'] = label
state = fields.get('state')
if state == 'start':
phase['state'] = 'running'
phase['started_at'] = time.monotonic()
phase['duration'] = None
self.last_stage = label
else:
phase['state'] = 'done' if state == 'done' else 'failed'
raw_duration = fields.get('duration')
try:
duration = float(raw_duration) if raw_duration is not None else None
except (TypeError, ValueError):
duration = None
if duration is None and phase.get('started_at') is not None:
duration = max(0.0, time.monotonic() - float(phase['started_at']))
phase['duration'] = duration
if state == 'failed':
self.failure_hint = f'{label} failed'
self.last_stage = label
def _phase_counts(self) -> tuple[int, int, dict[str, object] | None]:
total = self.phase_total
if total <= 0:
return 0, 0, None
completed = sum(
1 for index in range(1, total + 1)
if self.phases.get(index, {}).get('state') == 'done')
current = next(
(self.phases[index] for index in range(1, total + 1)
if self.phases.get(index, {}).get('state') == 'running'),
None)
return completed, total, current
def _phase_elapsed(self, phase: dict[str, object] | None) -> float | None:
if not phase:
return None
duration = phase.get('duration')
if isinstance(duration, (int, float)):
return float(duration)
started_at = phase.get('started_at')
if isinstance(started_at, (int, float)):
return max(0.0, time.monotonic() - float(started_at))
return None
def _render_phase_summary(self) -> list[str]:
if self.phase_total <= 0:
return []
rows = []
for index in range(1, self.phase_total + 1):
phase = self.phases.get(index)
if not phase:
rows.append(f' ○ {index:>2}/{self.phase_total:<2} pending')
continue
state = phase.get('state')
icon = {'done': '✓', 'failed': '✗', 'running': '▶'}.get(state, '○')
elapsed = self._phase_elapsed(phase)
duration = f'{elapsed:6.1f}s' if elapsed is not None else ' —'
rows.append(
f' {icon} {index:>2}/{self.phase_total:<2} '
f'{str(phase.get("label") or ""):28.28} {duration}')
return rows
def tick(self, status: dict | None = None) -> None:
if not self.enabled:
return
self._consume_worker_log()
if (status and status.get('running')
and self.last_stage == 'Contacting lifecycle manager…'):
pid = status.get('pid')
if isinstance(pid, int):
self.last_stage = f'Worker PID {pid} launched; waiting for health check…'
self._emit()
def _emit(self, *, force: bool = False) -> None:
now = time.monotonic()
elapsed = self.elapsed
if self.interactive:
completed, total, current = self._phase_counts()
if total:
width = 16
filled = round(width * completed / total)
bar = '█' * filled + '░' * (width - filled)
current_label = str(current.get('label')) if current else self.last_stage
current_elapsed = self._phase_elapsed(current)
phase_time = (f'{current_elapsed:.1f}s'
if current_elapsed is not None else '—')
detail = (f'{completed}/{total} {completed * 100 // total:3d}% | '
f'{current_label} [{phase_time}]')
else:
# Legacy worker fallback: no fake movement. A static bar is
# more truthful than an indeterminate dot when phase metadata
# is unavailable.
bar = '░' * 16
detail = f'waiting for startup phase | {self.last_stage}'
self.stream.write(
f'\r\033[2K⏳ Tofu startup [{bar}] {elapsed:5.1f}s | {detail}')
self.stream.flush()
return
completed, total, current = self._phase_counts()
stage_key: object
if total and current:
current_elapsed = self._phase_elapsed(current)
current_time = (f'{current_elapsed:.1f}s'
if current_elapsed is not None else '—')
display_stage = (
f'{completed}/{total} | ▶ {current.get("label")} '
f'({current_time})')
stage_key = ('phase', completed, total, current.get('label'))
else:
display_stage = self.last_stage
stage_key = ('text', display_stage)
stage_changed = stage_key != self.last_emitted_stage
if not force and not stage_changed and now - self.last_line_at < 10.0:
return
self.stream.write(f'[startup +{elapsed:5.1f}s] {display_stage}\n')
self.stream.flush()
self.last_emitted_stage = stage_key
self.last_line_at = now
def finish(self, *, ready: bool) -> None:
if not self.enabled:
return
self._consume_worker_log()
if self.interactive:
self.stream.write('\r\033[2K')
outcome = 'ready' if ready else 'failed'
self.stream.write(f'Tofu startup {outcome} after {self.elapsed:.1f}s.\n')
summary = self._render_phase_summary()
if summary:
self.stream.write('Startup stages:\n' + '\n'.join(summary) + '\n')
if not ready and self.failure_hint:
self.stream.write(f'Startup error: {self.failure_hint}\n')
self.stream.flush()
def finish_waiting(self) -> None:
"""Close the foreground view for an accepted deferred start."""
if not self.enabled:
return
self._consume_worker_log()
if self.interactive:
self.stream.write('\r\033[2K')
self.stream.write(