forje-cloud 0.1.0__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.
- forje_cloud/__init__.py +8 -0
- forje_cloud/cli.py +508 -0
- forje_cloud/core/__init__.py +22 -0
- forje_cloud/core/context.py +229 -0
- forje_cloud/core/hashlog.py +165 -0
- forje_cloud/core/runner.py +252 -0
- forje_cloud/core/step.py +201 -0
- forje_cloud/docgen.py +294 -0
- forje_cloud/restore.py +225 -0
- forje_cloud/services/__init__.py +1 -0
- forje_cloud/services/analyzer.py +183 -0
- forje_cloud/services/cloudflare.py +244 -0
- forje_cloud/services/llm.py +163 -0
- forje_cloud/services/telegram.py +112 -0
- forje_cloud/steps/__init__.py +56 -0
- forje_cloud/steps/backup.py +582 -0
- forje_cloud/steps/docker.py +195 -0
- forje_cloud/steps/edge.py +370 -0
- forje_cloud/steps/hardening.py +172 -0
- forje_cloud/steps/monitoring.py +247 -0
- forje_cloud/steps/system.py +119 -0
- forje_cloud/ui.py +261 -0
- forje_cloud/wizard.py +265 -0
- forje_cloud-0.1.0.dist-info/METADATA +384 -0
- forje_cloud-0.1.0.dist-info/RECORD +28 -0
- forje_cloud-0.1.0.dist-info/WHEEL +4 -0
- forje_cloud-0.1.0.dist-info/entry_points.txt +2 -0
- forje_cloud-0.1.0.dist-info/licenses/LICENSE +21 -0
forje_cloud/__init__.py
ADDED
forje_cloud/cli.py
ADDED
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
"""Command line interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from . import __version__, docgen, ui
|
|
11
|
+
from .core import Config, Context, HashLog, Plan, Runner, Status
|
|
12
|
+
from .steps import default_pipeline
|
|
13
|
+
from .wizard import run_wizard
|
|
14
|
+
|
|
15
|
+
DEFAULT_LOG = "/var/log/forje-cloud/runs.jsonl"
|
|
16
|
+
DEFAULT_DOC = "INFRASTRUCTURE.md"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _config_from_args(args: argparse.Namespace) -> Config:
|
|
20
|
+
"""Build a configuration from flags and the environment.
|
|
21
|
+
|
|
22
|
+
Flags win over environment variables. Anything still unset is either
|
|
23
|
+
collected by the wizard or left at its default.
|
|
24
|
+
"""
|
|
25
|
+
cfg = Config()
|
|
26
|
+
|
|
27
|
+
for field, value in (
|
|
28
|
+
("project", args.project),
|
|
29
|
+
("hostname", args.hostname),
|
|
30
|
+
("deploy_user", args.deploy_user),
|
|
31
|
+
("edge_network", args.network),
|
|
32
|
+
("domain", args.domain),
|
|
33
|
+
):
|
|
34
|
+
if value:
|
|
35
|
+
setattr(cfg, field, value)
|
|
36
|
+
|
|
37
|
+
cfg.cf_api_token = args.cf_token or os.environ.get("CF_API_TOKEN", "")
|
|
38
|
+
cfg.cf_account_id = os.environ.get("CF_ACCOUNT_ID", "")
|
|
39
|
+
cfg.cf_zone_id = os.environ.get("CF_ZONE_ID", "")
|
|
40
|
+
cfg.tunnel_name = cfg.tunnel_name or cfg.project
|
|
41
|
+
|
|
42
|
+
cfg.r2_access_key = os.environ.get("R2_ACCESS_KEY_ID", "")
|
|
43
|
+
cfg.r2_secret_key = os.environ.get("R2_SECRET_ACCESS_KEY", "")
|
|
44
|
+
cfg.r2_account_id = os.environ.get("R2_ACCOUNT_ID", "")
|
|
45
|
+
cfg.r2_bucket = os.environ.get("R2_BUCKET", "")
|
|
46
|
+
cfg.backup_enabled = bool(cfg.r2_bucket and cfg.r2_access_key)
|
|
47
|
+
|
|
48
|
+
cfg.telegram_token = os.environ.get("TELEGRAM_TOKEN", "")
|
|
49
|
+
cfg.telegram_chat_id = os.environ.get("TELEGRAM_CHAT_ID", "")
|
|
50
|
+
cfg.llm_api_key = os.environ.get("LLM_API_KEY", "")
|
|
51
|
+
cfg.llm_provider = os.environ.get("LLM_PROVIDER", "claude")
|
|
52
|
+
cfg.monitoring_enabled = bool(cfg.telegram_token and cfg.llm_api_key)
|
|
53
|
+
|
|
54
|
+
cfg.close_ssh = args.close_ssh
|
|
55
|
+
return cfg
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _context(cfg: Config, log_path: str) -> Context:
|
|
59
|
+
return Context(
|
|
60
|
+
cfg=cfg,
|
|
61
|
+
runner=Runner(assume_root=(os.geteuid() == 0)),
|
|
62
|
+
log=HashLog(log_path),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _write_doc(cfg: Config, results: list, path: str) -> None:
|
|
67
|
+
document = docgen.generate(cfg, results)
|
|
68
|
+
destination = Path(path)
|
|
69
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
destination.write_text(document, encoding="utf-8")
|
|
71
|
+
ui.note(f"Infrastructure documented: {destination}")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def cmd_setup(args: argparse.Namespace) -> int:
|
|
75
|
+
"""Collect configuration interactively, then provision."""
|
|
76
|
+
cfg = _config_from_args(args)
|
|
77
|
+
|
|
78
|
+
completed = run_wizard(cfg)
|
|
79
|
+
if completed is None:
|
|
80
|
+
ui.warn("Cancelled. Nothing was changed.")
|
|
81
|
+
return 1
|
|
82
|
+
|
|
83
|
+
ctx = _context(completed, args.log)
|
|
84
|
+
|
|
85
|
+
ui.section("Applying")
|
|
86
|
+
if ctx.runner.needs_sudo_password():
|
|
87
|
+
ui.hint("Some steps need administrator rights.")
|
|
88
|
+
ctx.runner.prompt_sudo()
|
|
89
|
+
print()
|
|
90
|
+
|
|
91
|
+
plan = Plan(default_pipeline(), ctx)
|
|
92
|
+
plan.run(
|
|
93
|
+
yes=True,
|
|
94
|
+
on_result=lambda r: ui.result_line(r.step, r.status.value, r.detail, r.error),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
counts = plan.summary()
|
|
98
|
+
ui.summary_box(
|
|
99
|
+
[
|
|
100
|
+
("Configured", str(counts["applied"])),
|
|
101
|
+
("Already present", str(counts["skipped"])),
|
|
102
|
+
("Needs attention", str(counts["drifted"])),
|
|
103
|
+
("Failed", str(counts["failed"] + counts["rolled_back"])),
|
|
104
|
+
]
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
_write_doc(completed, plan.results, args.doc)
|
|
108
|
+
ui.note(f"Audit log: {ctx.log.path}")
|
|
109
|
+
|
|
110
|
+
if completed.domain:
|
|
111
|
+
print()
|
|
112
|
+
ui.note(f"Your server is reachable at https://{completed.domain}")
|
|
113
|
+
ui.hint(f"Any subdomain of {completed.domain} works, with HTTPS, immediately.")
|
|
114
|
+
|
|
115
|
+
ctx.clear_secrets()
|
|
116
|
+
print()
|
|
117
|
+
return 1 if counts["failed"] + counts["rolled_back"] else 0
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def cmd_status(args: argparse.Namespace) -> int:
|
|
121
|
+
"""Report what this machine already has, changing nothing."""
|
|
122
|
+
cfg = _config_from_args(args)
|
|
123
|
+
ctx = _context(cfg, args.log)
|
|
124
|
+
|
|
125
|
+
ui.banner("Current state", "Nothing is changed by this command.")
|
|
126
|
+
|
|
127
|
+
for step in default_pipeline():
|
|
128
|
+
try:
|
|
129
|
+
verdict = step.check(ctx)
|
|
130
|
+
except Exception as exc: # noqa: BLE001
|
|
131
|
+
ui.result_line(step.name, "failed", f"could not check: {exc}")
|
|
132
|
+
continue
|
|
133
|
+
|
|
134
|
+
if verdict.ok:
|
|
135
|
+
ui.result_line(step.name, "skipped", verdict.detail)
|
|
136
|
+
elif verdict.drift:
|
|
137
|
+
ui.result_line(step.name, "drifted", verdict.detail)
|
|
138
|
+
else:
|
|
139
|
+
ui.result_line(step.name, "failed", verdict.detail)
|
|
140
|
+
|
|
141
|
+
ctx.clear_secrets()
|
|
142
|
+
print()
|
|
143
|
+
return 0
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def cmd_plan(args: argparse.Namespace) -> int:
|
|
147
|
+
"""Report what a run would do, changing nothing."""
|
|
148
|
+
cfg = _config_from_args(args)
|
|
149
|
+
ctx = _context(cfg, args.log)
|
|
150
|
+
|
|
151
|
+
ui.banner("Plan", "A preview. Nothing is changed by this command.")
|
|
152
|
+
|
|
153
|
+
plan = Plan(default_pipeline(), ctx)
|
|
154
|
+
plan.run(
|
|
155
|
+
dry_run=True,
|
|
156
|
+
on_result=lambda r: ui.result_line(r.step, r.status.value, r.detail),
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
counts = plan.summary()
|
|
160
|
+
ui.summary_box(
|
|
161
|
+
[
|
|
162
|
+
("Already present", str(counts["skipped"])),
|
|
163
|
+
("Would configure", str(counts["failed"])),
|
|
164
|
+
("Needs attention", str(counts["drifted"])),
|
|
165
|
+
]
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
ctx.clear_secrets()
|
|
169
|
+
return 0
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def cmd_apply(args: argparse.Namespace) -> int:
|
|
173
|
+
"""Provision without prompting, using flags and the environment."""
|
|
174
|
+
cfg = _config_from_args(args)
|
|
175
|
+
ctx = _context(cfg, args.log)
|
|
176
|
+
|
|
177
|
+
ui.banner("Applying", "Only what is missing will be configured.")
|
|
178
|
+
|
|
179
|
+
if ctx.runner.needs_sudo_password():
|
|
180
|
+
ctx.runner.prompt_sudo()
|
|
181
|
+
print()
|
|
182
|
+
|
|
183
|
+
plan = Plan(default_pipeline(), ctx)
|
|
184
|
+
plan.run(
|
|
185
|
+
yes=args.yes,
|
|
186
|
+
on_result=lambda r: ui.result_line(r.step, r.status.value, r.detail, r.error),
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
counts = plan.summary()
|
|
190
|
+
ui.summary_box(
|
|
191
|
+
[
|
|
192
|
+
("Configured", str(counts["applied"])),
|
|
193
|
+
("Already present", str(counts["skipped"])),
|
|
194
|
+
("Needs attention", str(counts["drifted"])),
|
|
195
|
+
("Failed", str(counts["failed"] + counts["rolled_back"])),
|
|
196
|
+
]
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
_write_doc(cfg, plan.results, args.doc)
|
|
200
|
+
ui.note(f"Audit log: {ctx.log.path}")
|
|
201
|
+
|
|
202
|
+
ctx.clear_secrets()
|
|
203
|
+
print()
|
|
204
|
+
return 1 if counts["failed"] + counts["rolled_back"] else 0
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _restore_client(args: argparse.Namespace) -> "RestoreClient":
|
|
208
|
+
"""Build a restore client, prompting for anything not supplied."""
|
|
209
|
+
from .restore import RestoreClient
|
|
210
|
+
|
|
211
|
+
account = os.environ.get("R2_ACCOUNT_ID", "") or ui.ask(
|
|
212
|
+
"R2 account ID",
|
|
213
|
+
"The Cloudflare account holding the backups.",
|
|
214
|
+
)
|
|
215
|
+
bucket = args.bucket or os.environ.get("R2_BUCKET", "") or ui.ask(
|
|
216
|
+
"Bucket",
|
|
217
|
+
"The bucket the backups were written to.",
|
|
218
|
+
)
|
|
219
|
+
access = os.environ.get("R2_ACCESS_KEY_ID", "") or ui.ask(
|
|
220
|
+
"R2 access key ID", "From R2 > Manage API Tokens.", secret=True
|
|
221
|
+
)
|
|
222
|
+
secret = os.environ.get("R2_SECRET_ACCESS_KEY", "") or ui.ask(
|
|
223
|
+
"R2 secret access key", "Shown once, when the token was created.", secret=True
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
return RestoreClient(
|
|
227
|
+
Runner(assume_root=(os.geteuid() == 0)),
|
|
228
|
+
bucket=bucket,
|
|
229
|
+
endpoint=f"https://{account}.r2.cloudflarestorage.com",
|
|
230
|
+
access_key=access,
|
|
231
|
+
secret_key=secret,
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def cmd_restore(args: argparse.Namespace) -> int:
|
|
236
|
+
"""Restore volumes, databases and images from a backup."""
|
|
237
|
+
from .restore import RestoreError
|
|
238
|
+
|
|
239
|
+
ui.banner("Restore", "Choose a backup, then choose what to bring back.")
|
|
240
|
+
|
|
241
|
+
client = _restore_client(args)
|
|
242
|
+
|
|
243
|
+
ui.section("Available backups")
|
|
244
|
+
backups = client.list_backups()
|
|
245
|
+
if not backups:
|
|
246
|
+
ui.error("No backups found in that bucket.")
|
|
247
|
+
return 1
|
|
248
|
+
|
|
249
|
+
for index, backup in enumerate(backups, start=1):
|
|
250
|
+
marker = "*" if index == 1 else " "
|
|
251
|
+
print(
|
|
252
|
+
ui.paint(
|
|
253
|
+
f" {marker} {index:>2}) {backup.stamp} {backup.summary()}", "dim"
|
|
254
|
+
)
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
print()
|
|
258
|
+
ui.hint("The most recent is selected by default.")
|
|
259
|
+
answer = input(ui.paint(" > [1] ", "cyan")).strip()
|
|
260
|
+
try:
|
|
261
|
+
backup = backups[int(answer) - 1 if answer else 0]
|
|
262
|
+
except (ValueError, IndexError):
|
|
263
|
+
ui.error("Not a valid choice.")
|
|
264
|
+
return 1
|
|
265
|
+
|
|
266
|
+
if not backup.manifest:
|
|
267
|
+
ui.error(f"Backup {backup.stamp} has no manifest and cannot be read.")
|
|
268
|
+
return 1
|
|
269
|
+
|
|
270
|
+
ui.section(f"Restoring from {backup.stamp}")
|
|
271
|
+
ui.note(f"Taken on {backup.host} — {backup.summary()}")
|
|
272
|
+
|
|
273
|
+
workdir = "/tmp/forje-restore"
|
|
274
|
+
client._runner.run(f"mkdir -p {workdir}", check=False)
|
|
275
|
+
|
|
276
|
+
restored: list[str] = []
|
|
277
|
+
failed: list[str] = []
|
|
278
|
+
|
|
279
|
+
# Images first: services cannot start without them.
|
|
280
|
+
if backup.images:
|
|
281
|
+
chosen = ui.multiselect(
|
|
282
|
+
"Images",
|
|
283
|
+
"Locally built images. Without these, services have nothing to run.",
|
|
284
|
+
[(item["image"], item["image"]) for item in backup.images],
|
|
285
|
+
)
|
|
286
|
+
for item in backup.images:
|
|
287
|
+
if item["image"] not in chosen:
|
|
288
|
+
continue
|
|
289
|
+
try:
|
|
290
|
+
ui.result_line("image", "applied", client.restore_image(backup, item, workdir))
|
|
291
|
+
restored.append(str(item["image"]))
|
|
292
|
+
except Exception as exc: # noqa: BLE001
|
|
293
|
+
ui.result_line("image", "failed", str(item["image"]), str(exc))
|
|
294
|
+
failed.append(str(item["image"]))
|
|
295
|
+
|
|
296
|
+
if backup.volumes:
|
|
297
|
+
chosen = ui.multiselect(
|
|
298
|
+
"Volumes",
|
|
299
|
+
"Application data. Existing contents are replaced, not merged.",
|
|
300
|
+
[
|
|
301
|
+
(
|
|
302
|
+
item["volume"],
|
|
303
|
+
f"{item['volume']:<28} {int(item.get('bytes', 0)) / 1048576:>8.1f} MB",
|
|
304
|
+
)
|
|
305
|
+
for item in backup.volumes
|
|
306
|
+
],
|
|
307
|
+
)
|
|
308
|
+
for item in backup.volumes:
|
|
309
|
+
if item["volume"] not in chosen:
|
|
310
|
+
continue
|
|
311
|
+
try:
|
|
312
|
+
ui.result_line("volume", "applied", client.restore_volume(backup, item, workdir))
|
|
313
|
+
restored.append(str(item["volume"]))
|
|
314
|
+
except Exception as exc: # noqa: BLE001
|
|
315
|
+
ui.result_line("volume", "failed", str(item["volume"]), str(exc))
|
|
316
|
+
failed.append(str(item["volume"]))
|
|
317
|
+
|
|
318
|
+
if backup.databases:
|
|
319
|
+
ui.section("Databases")
|
|
320
|
+
ui.hint("A database is loaded into a running container of the same engine.")
|
|
321
|
+
ui.hint("Start the database service first, then select it here.")
|
|
322
|
+
|
|
323
|
+
chosen = ui.multiselect(
|
|
324
|
+
"Databases",
|
|
325
|
+
"Dumps taken with each engine's own tool.",
|
|
326
|
+
[
|
|
327
|
+
(item["container"], f"{item['container']:<28} {item['engine']}")
|
|
328
|
+
for item in backup.databases
|
|
329
|
+
],
|
|
330
|
+
default_all=False,
|
|
331
|
+
)
|
|
332
|
+
for item in backup.databases:
|
|
333
|
+
if item["container"] not in chosen:
|
|
334
|
+
continue
|
|
335
|
+
target = ui.ask(
|
|
336
|
+
f"Container for {item['container']}",
|
|
337
|
+
f"The running {item['engine']} container to load this dump into.",
|
|
338
|
+
default=str(item["container"]),
|
|
339
|
+
)
|
|
340
|
+
try:
|
|
341
|
+
ui.result_line(
|
|
342
|
+
"database",
|
|
343
|
+
"applied",
|
|
344
|
+
client.restore_database(backup, item, workdir, target),
|
|
345
|
+
)
|
|
346
|
+
restored.append(str(item["container"]))
|
|
347
|
+
except (RestoreError, Exception) as exc: # noqa: BLE001
|
|
348
|
+
ui.result_line("database", "failed", str(item["container"]), str(exc))
|
|
349
|
+
failed.append(str(item["container"]))
|
|
350
|
+
|
|
351
|
+
compose_path = client.fetch_compose(backup, os.getcwd())
|
|
352
|
+
if compose_path:
|
|
353
|
+
print()
|
|
354
|
+
ui.note(f"Recovery compose written: {compose_path}")
|
|
355
|
+
ui.hint("It describes every workload running when the backup was taken.")
|
|
356
|
+
ui.hint(f"Deploy it with: docker stack deploy -c {os.path.basename(compose_path)} <name>")
|
|
357
|
+
|
|
358
|
+
ui.summary_box(
|
|
359
|
+
[("Restored", str(len(restored))), ("Failed", str(len(failed)))]
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
client._runner.run(f"rm -rf {workdir}", check=False)
|
|
363
|
+
return 1 if failed else 0
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def cmd_compose(args: argparse.Namespace) -> int:
|
|
367
|
+
"""Reconstruct a Compose file covering every workload on this machine."""
|
|
368
|
+
from .steps.backup import COMPOSE_SCRIPT
|
|
369
|
+
|
|
370
|
+
runner = Runner(assume_root=(os.geteuid() == 0))
|
|
371
|
+
|
|
372
|
+
script = "/tmp/forje-compose.sh"
|
|
373
|
+
Path(script).write_text(COMPOSE_SCRIPT, encoding="utf-8")
|
|
374
|
+
|
|
375
|
+
result = runner.run(f"sh {script}", sudo=True, check=False, timeout=300)
|
|
376
|
+
if not result.ok:
|
|
377
|
+
ui.error("Could not read the running services.")
|
|
378
|
+
return 1
|
|
379
|
+
|
|
380
|
+
destination = Path(args.out)
|
|
381
|
+
destination.write_text(result.out + "\n", encoding="utf-8")
|
|
382
|
+
runner.run(f"rm -f {script}", check=False)
|
|
383
|
+
|
|
384
|
+
services = result.out.count("\n image: ")
|
|
385
|
+
ui.result_line("compose", "applied", f"{services} services written to {destination}")
|
|
386
|
+
ui.hint("Servers accumulate stacks deployed from files nobody kept.")
|
|
387
|
+
ui.hint("This one is read back out of Swarm, so it matches what is running.")
|
|
388
|
+
return 0
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def cmd_verify(args: argparse.Namespace) -> int:
|
|
392
|
+
"""Check the audit log for tampering."""
|
|
393
|
+
log = HashLog(args.log)
|
|
394
|
+
intact, message = log.verify()
|
|
395
|
+
count = sum(1 for _ in log.entries())
|
|
396
|
+
|
|
397
|
+
if intact:
|
|
398
|
+
ui.result_line("audit log", "applied", f"{message}; {count} entries")
|
|
399
|
+
return 0
|
|
400
|
+
|
|
401
|
+
ui.result_line("audit log", "failed", message)
|
|
402
|
+
return 1
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def cmd_docs(args: argparse.Namespace) -> int:
|
|
406
|
+
"""Regenerate the infrastructure document from the current state."""
|
|
407
|
+
cfg = _config_from_args(args)
|
|
408
|
+
ctx = _context(cfg, args.log)
|
|
409
|
+
|
|
410
|
+
results = []
|
|
411
|
+
plan = Plan(default_pipeline(), ctx)
|
|
412
|
+
results = plan.run(dry_run=True)
|
|
413
|
+
|
|
414
|
+
_write_doc(cfg, results, args.doc)
|
|
415
|
+
ctx.clear_secrets()
|
|
416
|
+
return 0
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
420
|
+
parser = argparse.ArgumentParser(
|
|
421
|
+
prog="forje-cloud",
|
|
422
|
+
description="Set up a server: containers, HTTPS, backups and alerts.",
|
|
423
|
+
)
|
|
424
|
+
parser.add_argument(
|
|
425
|
+
"--version", action="version", version=f"forje-cloud {__version__}"
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
shared = argparse.ArgumentParser(add_help=False)
|
|
429
|
+
shared.add_argument("--project", help="short name used to label resources")
|
|
430
|
+
shared.add_argument("--hostname", help="name for this machine")
|
|
431
|
+
shared.add_argument("--deploy-user", help="non-root account that owns deployments")
|
|
432
|
+
shared.add_argument("--network", help="shared overlay network name")
|
|
433
|
+
shared.add_argument("--domain", help="domain to publish, already on Cloudflare")
|
|
434
|
+
shared.add_argument("--cf-token", help="Cloudflare API token (or CF_API_TOKEN)")
|
|
435
|
+
shared.add_argument(
|
|
436
|
+
"--close-ssh",
|
|
437
|
+
action="store_true",
|
|
438
|
+
help="close port 22 once the tunnel is confirmed working",
|
|
439
|
+
)
|
|
440
|
+
shared.add_argument("--log", default=DEFAULT_LOG, help="audit log path")
|
|
441
|
+
shared.add_argument("--doc", default=DEFAULT_DOC, help="generated document path")
|
|
442
|
+
|
|
443
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
444
|
+
|
|
445
|
+
setup = commands.add_parser(
|
|
446
|
+
"setup", parents=[shared], help="guided setup (recommended)"
|
|
447
|
+
)
|
|
448
|
+
setup.set_defaults(func=cmd_setup)
|
|
449
|
+
|
|
450
|
+
status = commands.add_parser(
|
|
451
|
+
"status", parents=[shared], help="show what this machine already has"
|
|
452
|
+
)
|
|
453
|
+
status.set_defaults(func=cmd_status)
|
|
454
|
+
|
|
455
|
+
plan = commands.add_parser(
|
|
456
|
+
"plan", parents=[shared], help="preview a run without changing anything"
|
|
457
|
+
)
|
|
458
|
+
plan.set_defaults(func=cmd_plan)
|
|
459
|
+
|
|
460
|
+
apply_cmd = commands.add_parser(
|
|
461
|
+
"apply", parents=[shared], help="provision without prompting"
|
|
462
|
+
)
|
|
463
|
+
apply_cmd.add_argument(
|
|
464
|
+
"--yes", action="store_true", help="authorize destructive steps"
|
|
465
|
+
)
|
|
466
|
+
apply_cmd.set_defaults(func=cmd_apply)
|
|
467
|
+
|
|
468
|
+
verify = commands.add_parser(
|
|
469
|
+
"verify", parents=[shared], help="check the audit log for tampering"
|
|
470
|
+
)
|
|
471
|
+
verify.set_defaults(func=cmd_verify)
|
|
472
|
+
|
|
473
|
+
docs = commands.add_parser(
|
|
474
|
+
"docs", parents=[shared], help="regenerate the infrastructure document"
|
|
475
|
+
)
|
|
476
|
+
docs.set_defaults(func=cmd_docs)
|
|
477
|
+
|
|
478
|
+
restore = commands.add_parser(
|
|
479
|
+
"restore", parents=[shared], help="restore volumes, databases and images"
|
|
480
|
+
)
|
|
481
|
+
restore.add_argument("--bucket", help="R2 bucket holding the backups")
|
|
482
|
+
restore.set_defaults(func=cmd_restore)
|
|
483
|
+
|
|
484
|
+
compose = commands.add_parser(
|
|
485
|
+
"compose",
|
|
486
|
+
parents=[shared],
|
|
487
|
+
help="reconstruct a Compose file for every workload running here",
|
|
488
|
+
)
|
|
489
|
+
compose.add_argument(
|
|
490
|
+
"--out", default="recovery-compose.yml", help="where to write the file"
|
|
491
|
+
)
|
|
492
|
+
compose.set_defaults(func=cmd_compose)
|
|
493
|
+
|
|
494
|
+
return parser
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def main(argv: list[str] | None = None) -> int:
|
|
498
|
+
args = build_parser().parse_args(argv)
|
|
499
|
+
try:
|
|
500
|
+
return args.func(args)
|
|
501
|
+
except KeyboardInterrupt:
|
|
502
|
+
print()
|
|
503
|
+
ui.warn("Interrupted. Nothing further was changed.")
|
|
504
|
+
return 130
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
if __name__ == "__main__":
|
|
508
|
+
sys.exit(main())
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Execution core: the step contract, the runner, and the audit log."""
|
|
2
|
+
|
|
3
|
+
from .context import Config, Context, Plan
|
|
4
|
+
from .hashlog import HashLog, LogEntry
|
|
5
|
+
from .runner import Result, Runner
|
|
6
|
+
from .step import BaseStep, CheckResult, Status, Step, StepResult, run_step
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"BaseStep",
|
|
10
|
+
"CheckResult",
|
|
11
|
+
"Config",
|
|
12
|
+
"Context",
|
|
13
|
+
"HashLog",
|
|
14
|
+
"LogEntry",
|
|
15
|
+
"Plan",
|
|
16
|
+
"Result",
|
|
17
|
+
"Runner",
|
|
18
|
+
"Status",
|
|
19
|
+
"Step",
|
|
20
|
+
"StepResult",
|
|
21
|
+
"run_step",
|
|
22
|
+
]
|