-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathbootstrap-server.py
More file actions
executable file
·2027 lines (1759 loc) · 87.5 KB
/
Copy pathbootstrap-server.py
File metadata and controls
executable file
·2027 lines (1759 loc) · 87.5 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
"""Interactive bootstrap for a fresh Hetzner/Ubuntu server hosting mediforce.
Takes a fresh Ubuntu 22.04+ box and brings it to a running deployment.
Every prerequisite follows the same pattern: detect first, offer to use
what was found, otherwise guide the user (clear YOUR TURN / MY TURN
handoffs) through creating or providing it.
Targets the public Appsilon/mediforce repo by default. For private forks
(per-customer deployments), pass --repo Org/Name. Switching an existing
deployment to a different repo is supported: the script rewires the deploy
key and the git remote on the server (renaming the old origin to 'upstream'
so sync flows keep working).
State is persisted at ~/.mediforce/bootstrap-<host>.json so the run is
resumable after Ctrl+C or a network hiccup.
Usage:
python3 scripts/bootstrap-server.py # interactive
python3 scripts/bootstrap-server.py --host 1.2.3.4
python3 scripts/bootstrap-server.py --repo Appsilon/mediforce-pharmaverse
python3 scripts/bootstrap-server.py --branch main --from-step 6
python3 scripts/bootstrap-server.py --resume # force resume prompt
"""
from __future__ import annotations
import argparse
import base64
import getpass
import json
import os
import re
import secrets
import shlex
import shutil
import subprocess
import sys
import tempfile
import textwrap
import time
from dataclasses import dataclass, field, fields
from pathlib import Path
from typing import Callable, Optional
# ──────────────────────────────────────────────────────────────────────────
# Constants
# ──────────────────────────────────────────────────────────────────────────
DEFAULT_REPO = "Appsilon/mediforce"
DEFAULT_BRANCH = "main"
REMOTE_DEPLOY_DIR = "/opt/mediforce"
STATE_DIR = Path.home() / ".mediforce"
def _repo_slug_kebab(repo: str) -> str:
"""Turn 'Org/Repo_Name' into 'org-repo-name' for use in filenames/titles."""
return re.sub(r"[^a-z0-9]+", "-", repo.lower()).strip("-")
# ──────────────────────────────────────────────────────────────────────────
# Output primitives (color-aware)
# ──────────────────────────────────────────────────────────────────────────
_USE_COLOR = sys.stdout.isatty() and os.environ.get("NO_COLOR") is None
def _c(code: str, text: str) -> str:
return f"\033[{code}m{text}\033[0m" if _USE_COLOR else text
def bold(t: str) -> str: return _c("1", t)
def dim(t: str) -> str: return _c("2", t)
def green(t: str) -> str: return _c("32", t)
def yellow(t: str) -> str: return _c("33", t)
def red(t: str) -> str: return _c("31", t)
def cyan(t: str) -> str: return _c("36", t)
def section(title: str) -> None:
line = "─" * max(0, 60 - len(title))
print(f"\n{bold(cyan('══'))} {bold(title)} {cyan(line)}")
def info(msg: str) -> None: print(f" {dim('·')} {msg}")
def ok(msg: str) -> None: print(f" {green('✓')} {msg}")
def warn(msg: str) -> None: print(f" {yellow('!')} {msg}")
def error(msg: str) -> None: print(f" {red('✗')} {msg}")
# ──────────────────────────────────────────────────────────────────────────
# Input primitives
# ──────────────────────────────────────────────────────────────────────────
def ask(prompt: str, default: Optional[str] = None, secret: bool = False,
validate: Optional[Callable[[str], Optional[str]]] = None) -> str:
"""Ask a free-form question. Returns stripped answer.
validate: function returning None if ok, or error message string.
"""
suffix = f" [{default}]" if default else ""
while True:
if secret:
raw = getpass.getpass(f" {cyan('▸')} {prompt}{suffix}: ")
else:
raw = input(f" {cyan('▸')} {prompt}{suffix}: ")
answer = raw.strip() or (default or "")
if not answer:
error("A value is required.")
continue
if validate:
err = validate(answer)
if err:
error(err)
continue
return answer
def confirm(prompt: str, default: bool = True) -> bool:
suffix = "[Y/n]" if default else "[y/N]"
while True:
raw = input(f" {cyan('▸')} {prompt} {suffix} ").strip().lower()
if not raw:
return default
if raw in ("y", "yes"): return True
if raw in ("n", "no"): return False
error("Please answer y or n.")
def menu(prompt: str, options: list[tuple[str, str]],
allow_other: bool = False) -> str:
"""Pick one of (value, label) options. Returns value.
If allow_other, appends an "other — type your own" choice.
"""
print(f" {cyan('▸')} {prompt}")
for i, (_, label) in enumerate(options, 1):
print(f" {i}. {label}")
if allow_other:
print(f" {len(options) + 1}. other — type your own")
while True:
raw = input(f" choice [1-{len(options) + (1 if allow_other else 0)}]: ").strip()
if not raw.isdigit():
error("Enter a number.")
continue
idx = int(raw)
if 1 <= idx <= len(options):
return options[idx - 1][0]
if allow_other and idx == len(options) + 1:
return ask("value")
error("Out of range.")
def handoff(
what: str,
where: str,
steps: list[str],
verify: Callable[[], tuple[bool, str]],
save_to: Optional[str] = None,
) -> bool:
"""Clear ball-handoff to the user. Returns True when verify succeeds.
Loops with retry/help/abort options on verify failure.
"""
while True:
print()
print(f" {bold(yellow('── YOUR TURN ─────────────────────────────────────────'))}")
print(f" {bold('What:')} {what}")
print(f" {bold('Where:')} {where}")
if save_to:
print(f" {bold('Save to:')} {save_to}")
print(f" {bold('Steps:')}")
for i, stp in enumerate(steps, 1):
print(f" {i}. {stp}")
print(f"\n Press Enter when done (q=abort) ", end="", flush=True)
raw = input().strip().lower()
if raw == "q":
raise KeyboardInterrupt("aborted at handoff")
print(f"\n {bold(cyan('── MY TURN ──────────────────────────────────────────'))}")
passed, msg = verify()
if passed:
ok(msg)
return True
error(msg)
choice = menu(
"What next?",
[
("retry", "Retry — I did it now"),
("help", "Show the instructions again"),
("abort", "Abort"),
],
)
if choice == "abort":
raise KeyboardInterrupt("aborted after handoff failure")
if choice == "retry":
continue # re-runs verify via outer loop
if choice == "help":
continue # prints handoff block again
# ──────────────────────────────────────────────────────────────────────────
# Subprocess helpers
# ──────────────────────────────────────────────────────────────────────────
@dataclass
class RunResult:
rc: int
stdout: str
stderr: str
@property
def ok(self) -> bool: return self.rc == 0
def run(cmd: list[str] | str, input_: Optional[str] = None,
check: bool = False, capture: bool = True) -> RunResult:
if isinstance(cmd, str):
cmd_list = shlex.split(cmd)
else:
cmd_list = cmd
proc = subprocess.run(
cmd_list,
input=input_,
text=True,
capture_output=capture,
)
result = RunResult(proc.returncode, proc.stdout or "", proc.stderr or "")
if check and not result.ok:
raise RuntimeError(
f"command failed ({result.rc}): {' '.join(cmd_list)}\n{result.stderr}"
)
return result
def _sudo_prefix(ctx: "Context") -> str:
"""Empty when connected as root, 'sudo ' otherwise. Deploy has NOPASSWD sudo."""
return "" if ctx.user == "root" else "sudo "
def _ssh_base_opts(ctx: "Context") -> list[str]:
"""Common SSH options, including ControlMaster multiplexing so follow-up
calls to the same host reuse the first TCP/SSH handshake. Cuts overall
bootstrap time by several seconds on a slow RTT.
StrictHostKeyChecking=accept-new is pragmatic for a fresh server we're
about to configure — trust-on-first-use, prints the host key once, then
pins it in ~/.ssh/known_hosts for later runs.
"""
# ControlPath must stay under the UNIX-domain-socket limit (~104 chars on
# macOS). `%C` hashes user+host+port to a 16-char digest, keeping the path
# well below the limit regardless of tempdir. `~/.ssh/cm-%C` resolves to a
# short, stable location that works on both macOS and Linux.
#
# `-i` is treated as a hint (preferred key) but we do NOT set
# IdentitiesOnly=yes — on operator machines the private key often lives in
# an ssh-agent (e.g. 1Password) while the `--ssh-key` path is just the
# public-key placeholder. Restricting to the file alone drops to password
# auth, which fails against servers that disable root password login.
return [
"-i", str(ctx.ssh_key_path),
"-o", "StrictHostKeyChecking=accept-new",
"-o", "ConnectTimeout=10",
"-o", "ControlMaster=auto",
"-o", "ControlPath=~/.ssh/cm-%C",
"-o", "ControlPersist=60",
]
def ssh(ctx: "Context", remote_cmd: str, *, check: bool = False,
capture: bool = True, stream: bool = False) -> RunResult:
base = ["ssh", *_ssh_base_opts(ctx), f"{ctx.user}@{ctx.host}", remote_cmd]
if stream:
proc = subprocess.run(base)
return RunResult(proc.returncode, "", "")
return run(base, check=check, capture=capture)
def scp_upload(ctx: "Context", local: Path, remote: str,
mode: Optional[str] = None) -> None:
assert local.exists(), f"local file missing: {local}"
cmd = ["scp", *_ssh_base_opts(ctx), str(local), f"{ctx.user}@{ctx.host}:{remote}"]
result = run(cmd)
if not result.ok:
raise RuntimeError(f"scp failed: {result.stderr}")
if mode:
ssh(ctx, f"chmod {mode} {shlex.quote(remote)}", check=True)
def _safe_host_slug(host: str) -> str:
"""Sanitize a host string for use in filenames and shell-embedded strings.
Keeps letters, digits, dot, dash, underscore. Typical hostnames and IPv4
addresses pass through unchanged; anything else becomes `_`. Protects
against path traversal in state filenames and argv injection in the deploy
key comment passed to ssh-keygen.
"""
return re.sub(r"[^A-Za-z0-9._-]", "_", host)
# ──────────────────────────────────────────────────────────────────────────
# State
# ──────────────────────────────────────────────────────────────────────────
@dataclass
class State:
"""Persisted between runs. Never contains raw secret values."""
host: str = ""
user: str = "root"
ssh_key_path: str = ""
repo: str = ""
branch: str = DEFAULT_BRANCH
github_deploy_key_id: Optional[int] = None
firebase_account: str = ""
firebase_project_id: str = ""
firebase_web_config: dict = field(default_factory=dict)
firebase_sa_path: str = ""
domain: str = ""
completed_steps: list[str] = field(default_factory=list)
last_step: str = ""
started_at: str = ""
@classmethod
def load(cls, host: str) -> "State":
path = STATE_DIR / f"bootstrap-{_safe_host_slug(host)}.json"
if not path.exists():
return cls(host=host, started_at=time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()))
raw = json.loads(path.read_text())
# Forgiving loader: ignore unknown keys (older state files from before a field
# was removed) and let missing keys fall back to the dataclass defaults.
known = {f.name for f in fields(cls)}
filtered = {k: v for k, v in raw.items() if k in known}
return cls(**filtered)
def save(self) -> None:
STATE_DIR.mkdir(parents=True, exist_ok=True)
path = STATE_DIR / f"bootstrap-{_safe_host_slug(self.host)}.json"
path.write_text(json.dumps(self.__dict__, indent=2))
path.chmod(0o600)
def mark(self, step_name: str) -> None:
if step_name not in self.completed_steps:
self.completed_steps.append(step_name)
self.last_step = step_name
self.save()
# ──────────────────────────────────────────────────────────────────────────
# Context passed to every step
# ──────────────────────────────────────────────────────────────────────────
@dataclass
class Context:
host: str = ""
user: str = "root"
ssh_key_path: Path = Path()
state: State = field(default_factory=State)
dry_run: bool = False
# Values collected during run (not persisted unless on disk already)
collected: dict = field(default_factory=dict)
# ──────────────────────────────────────────────────────────────────────────
# Steps — stubs for now, filled in subsequent chunks
# ──────────────────────────────────────────────────────────────────────────
REQUIRED_LOCAL_TOOLS = ["ssh", "scp", "git", "curl"]
OPTIONAL_LOCAL_TOOLS = {
"gh": {
"why": "registers the server's deploy key with the GitHub repo (step 6)",
"install_darwin": "brew install gh",
"install_linux": "see https://github.com/cli/cli#installation",
},
"firebase": {
"why": "lists/creates Firebase projects and pulls client config (step 8)",
"install_darwin": "npm install -g firebase-tools",
"install_linux": "npm install -g firebase-tools",
},
}
def step_local_prereqs(ctx: Context) -> None:
missing_required = [t for t in REQUIRED_LOCAL_TOOLS if shutil.which(t) is None]
if missing_required:
error(f"Missing required local tools: {', '.join(missing_required)}")
info("Install them (e.g. `xcode-select --install` on macOS) and re-run.")
raise SystemExit(1)
for tool in REQUIRED_LOCAL_TOOLS:
ok(f"{tool} — found at {shutil.which(tool)}")
is_mac = sys.platform == "darwin"
for tool, meta in OPTIONAL_LOCAL_TOOLS.items():
if shutil.which(tool):
ok(f"{tool} — found at {shutil.which(tool)}")
continue
warn(f"{tool} not installed — needed to: {meta['why']}")
hint = meta["install_darwin"] if is_mac else meta["install_linux"]
info(f"Install hint: {hint}")
if is_mac and hint.startswith("brew ") and shutil.which("brew"):
if confirm(f"Run `{hint}` now?", default=True):
if ctx.dry_run:
info(f"[dry-run] would run: {hint}")
else:
run(hint, capture=False)
if shutil.which(tool) is None:
warn(f"{tool} still not found after install — continue anyway; step using it will re-check.")
else:
info("Install it yourself before the step that needs it, or continue and we'll halt there.")
def _list_local_ssh_keys() -> list[Path]:
"""Return private-key paths in ~/.ssh that have a matching .pub."""
ssh_dir = Path.home() / ".ssh"
if not ssh_dir.is_dir():
return []
keys: list[Path] = []
for entry in sorted(ssh_dir.iterdir()):
if not entry.is_file():
continue
if entry.suffix in (".pub", ".old", ".bak"):
continue
pub = entry.with_suffix(entry.suffix + ".pub") if entry.suffix else entry.with_name(entry.name + ".pub")
if pub.exists():
keys.append(entry)
return keys
def _test_ssh(ctx: Context) -> RunResult:
"""Lightweight connectivity + OS probe."""
return ssh(ctx, "whoami && lsb_release -rs 2>/dev/null || cat /etc/os-release", capture=True)
def _generate_ssh_key(host: str) -> Path:
slug = re.sub(r"[^a-zA-Z0-9]+", "_", host).strip("_") or "server"
target = Path.home() / ".ssh" / f"mediforce_{slug}_deploy"
if target.exists():
info(f"{target} already exists — reusing.")
return target
info(f"Generating ed25519 key at {target}")
run(
["ssh-keygen", "-t", "ed25519", "-f", str(target), "-N", "",
"-C", f"mediforce-bootstrap-{slug}-{time.strftime('%Y%m%d')}"],
check=True,
capture=False,
)
return target
def _has_ssh_key(ctx: Context) -> bool:
"""True when ctx.ssh_key_path points to a real file (not the unset Path())."""
return bool(ctx.state.ssh_key_path) and ctx.ssh_key_path.exists()
def step_target_server(ctx: Context) -> None:
# Resolve the SSH key to use.
if not _has_ssh_key(ctx):
print()
info("The bootstrap needs an SSH private key that can log into the target server.")
detected = _list_local_ssh_keys()
if confirm("Do you already have an SSH key for this server?", default=bool(detected)):
if detected:
options: list[tuple[str, str]] = [
(str(k), f"use {k.name} ({k})") for k in detected
]
options.append(("__provide__", "enter a path to a different key"))
choice = menu("Which key?", options)
else:
choice = "__provide__"
if choice == "__provide__":
raw = ask(
"Path to private key",
validate=lambda p: None if Path(p).expanduser().exists() else "file not found",
)
ctx.ssh_key_path = Path(raw).expanduser()
else:
ctx.ssh_key_path = Path(choice)
else:
info("OK — I'll generate a new ed25519 key and walk you through installing the public half on the server.")
ctx.ssh_key_path = _generate_ssh_key(ctx.host)
ctx.state.ssh_key_path = str(ctx.ssh_key_path)
ctx.state.save()
ok(f"using SSH key: {ctx.ssh_key_path}")
# Probe connectivity.
result = _test_ssh(ctx)
if not result.ok:
warn("SSH probe failed — need to install the key on the server first.")
if _has_ssh_key(ctx):
pub_path = ctx.ssh_key_path.with_name(ctx.ssh_key_path.name + ".pub")
pub = pub_path.read_text().strip() if pub_path.exists() else "(pub key file missing!)"
print()
print(f" {bold('Public key to add:')}")
print(f" {dim(pub)}")
print()
else:
warn("No SSH key selected — go back and pick or generate one first.")
raise SystemExit(1)
def _verify_ssh() -> tuple[bool, str]:
r = _test_ssh(ctx)
if r.ok:
return True, "SSH probe now works."
return False, f"SSH still fails: {r.stderr.strip()[:200]}"
handoff(
what=f"Authorize this key on {ctx.user}@{ctx.host}",
where="Hetzner Cloud Console → Servers → your server → details, or SSH in with an existing credential",
steps=[
"Option A (Hetzner Cloud Console): add the key above under Security → SSH Keys, then rebuild the server or attach it during creation",
"Option B (existing root access): append the pub key above to /root/.ssh/authorized_keys on the server",
f"Option C (ssh-copy-id if password auth is allowed): ssh-copy-id -i {ctx.ssh_key_path}.pub {ctx.user}@{ctx.host}",
],
verify=_verify_ssh,
)
result = _test_ssh(ctx)
# Parse OS info.
stdout = result.stdout.strip().splitlines()
remote_user = stdout[0] if stdout else "?"
remainder = " ".join(stdout[1:]) if len(stdout) > 1 else ""
ok(f"connected as {remote_user}@{ctx.host}")
version_match = re.search(r"VERSION_ID=\"?(\d+)\.(\d+)\"?", remainder) or \
re.match(r"^\s*(\d+)\.(\d+)", remainder)
if version_match:
major, minor = int(version_match.group(1)), int(version_match.group(2))
if major < 22:
warn(f"OS appears to be Ubuntu {major}.{minor} — this script targets 22.04+; continue at your own risk.")
if not confirm("Continue anyway?", default=False):
raise SystemExit(1)
else:
ok(f"Ubuntu {major}.{minor} — supported")
else:
warn(f"Couldn't parse OS version from probe; proceeding. (raw: {remainder[:120]})")
BASE_PACKAGES = [
"git", "curl", "ca-certificates", "gnupg", "lsb-release",
"ufw", "jq", "unzip",
]
APT_ENV = "export DEBIAN_FRONTEND=noninteractive"
def _apt_install(ctx: Context, packages: list[str]) -> None:
cmd = f"{APT_ENV} && apt-get install -y {' '.join(packages)}"
result = ssh(ctx, cmd, capture=False, stream=True)
if result.rc != 0:
raise RuntimeError(f"apt-get install failed (rc={result.rc})")
def step_system_packages(ctx: Context) -> None:
missing_cmd = (
"missing=(); for p in " + " ".join(BASE_PACKAGES) + "; do "
"dpkg -s \"$p\" >/dev/null 2>&1 || missing+=(\"$p\"); done; "
"echo \"${missing[@]}\""
)
result = ssh(ctx, missing_cmd, check=True)
missing = result.stdout.strip().split()
if not missing:
ok("all base packages already installed")
return
info(f"Missing: {', '.join(missing)}")
if ctx.dry_run:
info(f"[dry-run] would apt-get update && install: {' '.join(missing)}")
return
info("Running apt-get update …")
update = ssh(ctx, f"{APT_ENV} && apt-get update -qq", capture=False, stream=True)
if update.rc != 0:
raise RuntimeError(f"apt-get update failed (rc={update.rc})")
info(f"Installing {len(missing)} package(s) …")
_apt_install(ctx, missing)
ok(f"installed: {', '.join(missing)}")
def step_docker(ctx: Context) -> None:
probe = ssh(ctx, "docker --version 2>/dev/null; docker compose version 2>/dev/null")
lines = [line for line in probe.stdout.strip().splitlines() if line]
if len(lines) >= 2:
for line in lines:
ok(line)
return
info("Docker not installed — installing from Docker's official apt repository.")
if ctx.dry_run:
info("[dry-run] would install docker-ce, docker-compose-plugin, etc.")
return
script = r"""
set -euo pipefail
export DEBIAN_FRONTEND=noninteractive
install -m 0755 -d /etc/apt/keyrings
if [ ! -s /etc/apt/keyrings/docker.gpg ]; then
curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
| gpg --dearmor -o /etc/apt/keyrings/docker.gpg
chmod a+r /etc/apt/keyrings/docker.gpg
fi
codename=$(. /etc/os-release && echo "${UBUNTU_CODENAME:-$VERSION_CODENAME}")
arch=$(dpkg --print-architecture)
echo "deb [arch=$arch signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/ubuntu $codename stable" \
> /etc/apt/sources.list.d/docker.list
apt-get update -qq
apt-get install -y docker-ce docker-ce-cli containerd.io \
docker-buildx-plugin docker-compose-plugin
systemctl enable --now docker
"""
result = ssh(ctx, script, capture=False, stream=True)
if result.rc != 0:
raise RuntimeError(f"docker install failed (rc={result.rc})")
verify = ssh(ctx, "docker --version && docker compose version", check=True)
for line in verify.stdout.strip().splitlines():
ok(line)
def step_docker_gc(ctx: Context) -> None:
"""Cap BuildKit cache so it cannot fill the Docker data volume.
Without this cap, the builder keeps cache indefinitely (we've seen 85GB+
build cache pile up in under a week, filling the 98GB Hetzner volume and
breaking deploys with ENOSPC). A 20GB cap is plenty for one working set
and leaves headroom for images + containers on typical volumes.
"""
if ctx.dry_run:
info("[dry-run] would write /etc/docker/daemon.json and restart docker")
return
info("Configuring Docker builder GC (cap cache at 20GB)")
# Remote python script — checks for existing config, writes + restarts
# docker only if needed. Base64-encoded so we pass it as a single argv
# token with no escaping landmines (heredoc, quotes, backslashes).
remote_py = textwrap.dedent('''
import json, os, sys
path = "/etc/docker/daemon.json"
try:
with open(path) as f:
cfg = json.load(f)
except (FileNotFoundError, ValueError):
cfg = {}
if cfg.get("builder", {}).get("gc", {}).get("defaultKeepStorage"):
print("ALREADY_CONFIGURED")
sys.exit(0)
cfg.setdefault("builder", {}).setdefault("gc", {}).update({
"enabled": True,
"defaultKeepStorage": "20GB",
"policy": [{"keepStorage": "20GB", "all": True}],
})
os.makedirs("/etc/docker", exist_ok=True)
with open(path, "w") as f:
json.dump(cfg, f, indent=2)
print("WROTE")
''').strip()
b64 = base64.b64encode(remote_py.encode()).decode()
sudo = "" if ctx.user == "root" else "sudo "
# Single ssh call — one password prompt instead of N. bash -lc runs remote
# commands; the result of the python invocation drives whether we bounce
# the docker daemon (only when config actually changed).
remote_cmd = (
f"set -eu; "
f"result=$(echo {b64} | base64 -d | {sudo}python3 -); "
f'echo "$result"; '
f'if [ "$result" = "WROTE" ]; then {sudo}systemctl restart docker; echo RESTARTED; fi'
)
result = ssh(ctx, remote_cmd, capture=True)
if result.rc != 0:
raise RuntimeError(f"docker GC config failed (rc={result.rc}): {result.stderr}")
out = result.stdout.strip()
if "ALREADY_CONFIGURED" in out:
ok("docker builder GC already configured")
elif "RESTARTED" in out:
ok("docker builder GC: 20GB cap set, daemon restarted")
else:
raise RuntimeError(f"unexpected output from docker GC step: {out!r}")
def step_deploy_user(ctx: Context) -> None:
probe = ssh(ctx, "getent passwd deploy || true")
if probe.stdout.strip():
ok("user 'deploy' already exists")
else:
info("About to create a 'deploy' user with the following privileges:")
info(" • home dir /home/deploy, shell /bin/bash")
info(" • groups: docker (can run docker without sudo)")
info(" sudo (member of the sudo group)")
info(" • ssh: authorized_keys copied from /root/.ssh/authorized_keys")
info(" → the same SSH key that reaches root will reach deploy")
warn(" • sudoers: NOPASSWD:ALL — deploy can become root without a password.")
warn(" This is a trust decision. The script needs it so deploy.sh")
warn(" can manage Docker + CI-style restarts non-interactively.")
if not confirm("Create deploy user with these privileges?", default=True):
raise SystemExit("aborted at deploy-user creation")
if ctx.dry_run:
info("[dry-run] would: useradd -m -s /bin/bash -G docker,sudo deploy")
return
ssh(ctx, "useradd -m -s /bin/bash -G docker,sudo deploy", check=True)
ssh(ctx,
"mkdir -p /home/deploy/.ssh && "
"chmod 700 /home/deploy/.ssh && "
"cp /root/.ssh/authorized_keys /home/deploy/.ssh/authorized_keys && "
"chmod 600 /home/deploy/.ssh/authorized_keys && "
"chown -R deploy:deploy /home/deploy/.ssh",
check=True)
ssh(ctx,
"echo 'deploy ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/deploy && "
"chmod 0440 /etc/sudoers.d/deploy",
check=True)
ok("deploy user created, authorized_keys copied, sudoers configured")
# deploy.sh tees to /var/log/mediforce-deploy.log. Ensure it exists and is
# writable by deploy so subsequent re-runs from step 12 can be done as the
# deploy user without needing root on the shell side. Idempotent: does not
# truncate an existing log with history.
ssh(ctx,
"touch /var/log/mediforce-deploy.log && "
"chown deploy:deploy /var/log/mediforce-deploy.log && "
"chmod 0644 /var/log/mediforce-deploy.log",
check=True)
ok("/var/log/mediforce-deploy.log writable by deploy")
# Sanity check: deploy can docker ps and ssh back.
check = ssh(ctx, "sudo -u deploy -H bash -c 'groups && docker ps >/dev/null && echo DOCKER_OK'")
if "DOCKER_OK" not in check.stdout:
warn(f"deploy user cannot run docker yet: {check.stdout.strip()} / {check.stderr.strip()}")
info("If this is a freshly-added group membership, a re-login is required — moving on.")
else:
ok("deploy can run docker")
def _deploy_key_title(ctx: Context) -> str:
# Repo slug in title so targeting a different fork from the same host
# doesn't collide at title-match lookup. Host is sanitized because it
# flows into an ssh-keygen -C argument inside a nested bash -c '…' string.
return f"mediforce-bootstrap-{_safe_host_slug(ctx.host)}-{_repo_slug_kebab(ctx.state.repo)}"
def _gh_auth_ok() -> bool:
return run(["gh", "auth", "status"], check=False).ok
def _is_public_repo(repo: str) -> bool:
"""Check if a GitHub repo is publicly accessible (no auth needed to clone)."""
result = run(["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}",
f"https://api.github.com/repos/{repo}"], check=False)
return result.stdout.strip() == "200"
def _clone_url(repo: str, public: bool) -> str:
return f"https://github.com/{repo}.git" if public else f"git@github.com:{repo}.git"
def step_github_access(ctx: Context) -> None:
repo = ctx.state.repo
if _is_public_repo(repo):
ok(f"{repo} is public — HTTPS clone, no deploy key needed")
return
info(f"{repo} is private — deploy key required for clone")
# Ensure local gh is authenticated.
if not shutil.which("gh"):
error("`gh` CLI missing locally — install it (e.g. `brew install gh`) and re-run step 6.")
raise SystemExit(1)
if not _gh_auth_ok():
info("gh is not authenticated on this machine.")
if not confirm("Run `gh auth login` now?", default=True):
raise SystemExit("gh auth required to register deploy keys")
run(["gh", "auth", "login"], capture=False)
if not _gh_auth_ok():
raise SystemExit("gh auth still failing — abort")
ok("gh authenticated locally")
# Ensure deploy key exists on server (as deploy user).
key_path = "/home/deploy/.ssh/github_deploy"
probe = ssh(ctx, f"sudo -u deploy test -f {key_path} && echo HAVE || echo NONE")
if "HAVE" in probe.stdout:
info(f"Deploy key already present at {key_path}")
else:
if ctx.dry_run:
info(f"[dry-run] would ssh-keygen at {key_path}")
return
info("Generating ed25519 deploy key on server (as deploy user)")
ssh(ctx,
"sudo -u deploy bash -c '"
"mkdir -p ~/.ssh && chmod 700 ~/.ssh && "
f"ssh-keygen -t ed25519 -f {key_path} -N \"\" -C \"{_deploy_key_title(ctx)}\" "
"'",
check=True)
# Add github.com to known_hosts so no interactive prompt later.
ssh(ctx,
"sudo -u deploy bash -c '"
"ssh-keyscan -t ed25519 github.com >> ~/.ssh/known_hosts 2>/dev/null && "
"chmod 600 ~/.ssh/known_hosts'",
check=True)
# Configure ssh so `git` uses this key for github.com without extra env.
ssh(ctx,
"sudo -u deploy bash -c '"
"printf \"Host github.com\\n IdentityFile %s\\n IdentitiesOnly yes\\n\" "
f"{key_path} > ~/.ssh/config && chmod 600 ~/.ssh/config'",
check=True)
ok("deploy key generated + ssh config written")
# Pull the public key.
pub = ssh(ctx, f"sudo -u deploy cat {key_path}.pub", check=True).stdout.strip()
# Check if already registered in repo (match by title).
repo = ctx.state.repo
title = _deploy_key_title(ctx)
existing = run(["gh", "api", f"repos/{repo}/keys", "--paginate"], check=True)
registered_id: Optional[int] = None
for entry in json.loads(existing.stdout):
if entry.get("title") == title:
registered_id = entry["id"]
break
if registered_id is not None:
ctx.state.github_deploy_key_id = registered_id
ctx.state.save()
ok(f"deploy key already registered on {repo} (id={registered_id})")
else:
if ctx.dry_run:
info(f"[dry-run] would POST key '{title}' to repos/{repo}/keys")
else:
info(f"Registering deploy key on {repo} as '{title}'")
result = run(
["gh", "api", "-X", "POST", f"repos/{repo}/keys",
"-f", f"title={title}",
"-f", f"key={pub}",
"-F", "read_only=true"],
check=True,
)
data = json.loads(result.stdout)
ctx.state.github_deploy_key_id = data.get("id")
ctx.state.save()
ok(f"registered (id={data.get('id')}, read-only)")
# End-to-end: can deploy user talk to GitHub?
test = ssh(ctx, "sudo -u deploy ssh -T git@github.com 2>&1 || true")
if "successfully authenticated" in test.stdout.lower():
ok("deploy user can authenticate to github.com")
else:
warn(f"github auth probe returned: {test.stdout.strip()[:200]}")
if not confirm("Continue anyway?", default=True):
raise SystemExit("github key not working")
def _deploy_git(ctx: Context, git_cmd: str, *, check: bool = False,
capture: bool = True, stream: bool = False) -> RunResult:
"""Run a git command as the deploy user inside REMOTE_DEPLOY_DIR."""
wrapped = f"sudo -u deploy bash -c 'cd {REMOTE_DEPLOY_DIR} && git {git_cmd}'"
return ssh(ctx, wrapped, check=check, capture=capture, stream=stream)
def step_clone_repo(ctx: Context) -> None:
repo = ctx.state.repo
branch = ctx.state.branch or DEFAULT_BRANCH
public = _is_public_repo(repo)
expected_url = _clone_url(repo, public)
probe = ssh(ctx, f"test -d {REMOTE_DEPLOY_DIR}/.git && echo HAVE || echo NONE")
if "HAVE" in probe.stdout:
current_url = _deploy_git(ctx, "remote get-url origin", check=True).stdout.strip()
current_branch = _deploy_git(ctx, "rev-parse --abbrev-ref HEAD", check=True).stdout.strip()
if current_url == expected_url:
ok(f"repo already cloned at {REMOTE_DEPLOY_DIR} (origin={repo}, branch={current_branch})")
else:
warn(f"Existing clone points to a different remote:")
info(f" current origin: {current_url}")
info(f" configured: {expected_url}")
choice = menu(
"Switch remote?",
[
("re-remote", "Rename current 'origin' to 'upstream', set origin to configured repo"),
("abort", "Abort — I want to change --repo instead"),
],
)
if choice == "abort":
raise SystemExit("aborted — existing clone points to a different repo")
if ctx.dry_run:
info(f"[dry-run] would rename origin→upstream and set origin to {expected_url}")
return
# If an 'upstream' remote already exists (previous re-remote), drop it
# to make room for the rename.
existing_upstream = _deploy_git(ctx, "remote get-url upstream 2>/dev/null || true")
if existing_upstream.stdout.strip():
info(f"removing pre-existing upstream remote ({existing_upstream.stdout.strip()})")
_deploy_git(ctx, "remote remove upstream", check=True)
_deploy_git(ctx, "remote rename origin upstream", check=True)
_deploy_git(ctx, f"remote add origin {expected_url}", check=True)
ok(f"origin → {expected_url}, previous origin preserved as 'upstream'")
# Verify we can fetch from the new origin.
fetch = _deploy_git(ctx, "fetch origin", capture=False, stream=True)
if fetch.rc != 0:
raise RuntimeError(
"fetch from new origin failed — is the deploy key registered on the new repo?"
)
ok("fetch from new origin succeeded")
# Align branch if it differs from configured.
current_branch = _deploy_git(ctx, "rev-parse --abbrev-ref HEAD", check=True).stdout.strip()
if current_branch != branch:
warn(f"Current checkout is {current_branch!r}, configured branch is {branch!r}")
if confirm(f"Check out {branch!r}?", default=True):
_deploy_git(ctx, f"fetch origin {branch}", check=True)
_deploy_git(ctx, f"checkout {branch}", check=True)
ok(f"checked out {branch}")
return
if ctx.dry_run:
info(f"[dry-run] would clone {expected_url} (branch {branch}) to {REMOTE_DEPLOY_DIR}")
return
info(f"Cloning {repo} (branch {branch}) into {REMOTE_DEPLOY_DIR}")
ssh(ctx, f"mkdir -p {REMOTE_DEPLOY_DIR} && chown deploy:deploy {REMOTE_DEPLOY_DIR}", check=True)
ssh(ctx,
f"sudo -u deploy git clone --branch {shlex.quote(branch)} {expected_url} {REMOTE_DEPLOY_DIR}",
capture=False, stream=True, check=False)
verify = _deploy_git(ctx, "rev-parse HEAD")
if not verify.ok or not verify.stdout.strip():
raise RuntimeError(f"clone verification failed: {verify.stderr.strip()[:200]}")
sha = verify.stdout.strip()[:12]
ok(f"cloned at {sha}")
FIREBASE_CONFIG_FIELDS = [
"apiKey", "authDomain", "projectId",
"messagingSenderId", "appId",
]
def _fb(account: str, *args: str) -> list[str]:
"""Build a firebase CLI invocation, optionally scoped to an account."""
base = ["firebase"]
if account:
base += ["--account", account]
return base + list(args)
def _firebase_login_accounts() -> tuple[Optional[str], list[str]]:
"""Return (active_account, other_accounts). Both None/[] if nobody is logged in.
Parses the plain-text output of `firebase login:list`. The CLI doesn't have
a --json variant for this subcommand as of firebase-tools 13.x, so format
changes in a future release would break this. If that happens, the caller
will see (None, []) and fall through to the login-add flow; we additionally
print the raw output as a debugging aid so the operator can recognize drift.
"""
result = run(["firebase", "login:list"], check=False)
text = (result.stdout or "") + "\n" + (result.stderr or "")
active_match = re.search(r"Logged in as\s+([\w.+\-]+@[\w.\-]+)", text)
others = re.findall(r"^\s*-\s+([\w.+\-]+@[\w.\-]+)", text, re.MULTILINE)
if active_match or others:
return (active_match.group(1) if active_match else None), others
if "no authorized accounts" in text.lower() or "not currently logged in" in text.lower():
return None, []
# Neither matches nor known "nothing logged in" marker — could be a CLI
# format change. Surface the raw output so the operator can see.
warn("Couldn't parse `firebase login:list` output — the CLI format may have changed.")
info(f"Raw output (first 300 chars): {text[:300]!r}")
return None, []
def _firebase_list_projects(account: str) -> list[dict]:
result = run(_fb(account, "projects:list", "--json"), check=True)
data = json.loads(result.stdout)
return data.get("result", [])
def _firebase_pick_account(ctx: Context) -> str:
"""Make user pick a Google account for the rest of the Firebase flow."""
active, others = _firebase_login_accounts()
while True:
options: list[tuple[str, str]] = []
if active:
options.append((active, f"{active} (currently active)"))
for email in others:
options.append((email, email))
options.append(("__add__", "add a different Google account (firebase login:add)"))
if not active and not others:
info("No Firebase accounts on this machine yet — adding one now.")
run(["firebase", "login"], capture=False)
active, others = _firebase_login_accounts()
continue
choice = menu("Which Google account should Firebase use?", options)
if choice == "__add__":
run(["firebase", "login:add"], capture=False)
active, others = _firebase_login_accounts()
continue
return choice
def step_firebase(ctx: Context) -> None:
if not shutil.which("firebase"):
error("`firebase` CLI not installed — run step 1's install hint and retry this step.")
raise SystemExit(1)
# Pick the Google account to use.
account = ctx.state.firebase_account
if account: