dploydb 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.
dploydb/cli.py ADDED
@@ -0,0 +1,1111 @@
1
+ """Command-line interface for DployDB."""
2
+
3
+ import json
4
+ import os
5
+ from importlib.metadata import version
6
+ from pathlib import Path
7
+ from typing import Annotated, NoReturn, cast
8
+
9
+ import typer
10
+ from rich.console import Console
11
+
12
+ from dploydb.backup import create_configured_backup_result, verify_configured_backup
13
+ from dploydb.config import DEFAULT_CONFIG_PATH, initialize_configuration, load_configuration
14
+ from dploydb.deploy import DeploymentResult, deploy_configured_release
15
+ from dploydb.diagnostics import (
16
+ DoctorReport,
17
+ StatusReport,
18
+ inspect_runtime_status,
19
+ run_doctor,
20
+ )
21
+ from dploydb.errors import (
22
+ ERROR_EXIT_CODES,
23
+ DployDBError,
24
+ ErrorKind,
25
+ ExitCode,
26
+ InternalError,
27
+ RecoveryRequiredError,
28
+ redact_error,
29
+ )
30
+ from dploydb.manual_restore import (
31
+ DATA_LOSS_WARNING,
32
+ ManualRestoreResult,
33
+ RestoreSelection,
34
+ preview_configured_restore,
35
+ restore_configured_release,
36
+ )
37
+ from dploydb.models import (
38
+ BackupArtifact,
39
+ DeploymentState,
40
+ DiagnosticOutcome,
41
+ FailurePayload,
42
+ ReleaseManifest,
43
+ ReleasePointers,
44
+ RemoteBackupArtifact,
45
+ )
46
+ from dploydb.recovery import (
47
+ RecoveryDisposition,
48
+ RecoveryPlan,
49
+ RecoveryResult,
50
+ preview_configured_recovery,
51
+ recover_configured_deployment,
52
+ )
53
+ from dploydb.redaction import JsonValue, SecretRegistry
54
+ from dploydb.releases import ReleaseHistorySnapshot, ReleaseStore
55
+
56
+ app = typer.Typer(
57
+ help="Deployment safety for SQLite applications.",
58
+ no_args_is_help=True,
59
+ rich_markup_mode=None,
60
+ )
61
+ release_app = typer.Typer(
62
+ help="Inspect one durable deployment release.",
63
+ no_args_is_help=True,
64
+ rich_markup_mode=None,
65
+ )
66
+ app.add_typer(release_app, name="release")
67
+ console = Console(color_system=None, highlight=False, markup=False)
68
+
69
+
70
+ def _version_text() -> str:
71
+ return f"dploydb {version('dploydb')}"
72
+
73
+
74
+ def _yes_no_unknown(value: bool | None) -> str:
75
+ if value is None:
76
+ return "unknown"
77
+ return "yes" if value else "no"
78
+
79
+
80
+ def render_failure(payload: FailurePayload, *, json_output: bool) -> str:
81
+ """Render one failure contract without changing or hiding its safety facts."""
82
+ if json_output:
83
+ return json.dumps(payload.as_dict(), sort_keys=True, separators=(",", ":"))
84
+
85
+ return "\n".join(
86
+ (
87
+ "DployDB could not complete the operation.",
88
+ f"Error code: {payload.error_code}",
89
+ f"Exit code: {payload.exit_code}",
90
+ f"What failed: {payload.what_failed}",
91
+ f"Production changed: {_yes_no_unknown(payload.production_changed)}",
92
+ "Previous application running: "
93
+ f"{_yes_no_unknown(payload.previous_application_running)}",
94
+ f"Recovery required: {_yes_no_unknown(payload.recovery_required)}",
95
+ f"Relevant log: {payload.log_path or 'not available'}",
96
+ f"Next safe action: {payload.next_safe_action}",
97
+ )
98
+ )
99
+
100
+
101
+ def abort_with_error(error: DployDBError, *, json_output: bool) -> NoReturn:
102
+ """Emit an expected failure and exit without exposing a traceback."""
103
+ typer.echo(render_failure(error.payload, json_output=json_output), err=not json_output)
104
+ raise typer.Exit(code=int(error.exit_code))
105
+
106
+
107
+ def _internal_error() -> InternalError:
108
+ return InternalError(
109
+ "An unexpected internal failure prevented the command from completing.",
110
+ production_changed=False,
111
+ previous_application_running=None,
112
+ next_safe_action="Preserve the current evidence and rerun with a corrected installation.",
113
+ )
114
+
115
+
116
+ def _render_doctor(report: DoctorReport, *, json_output: bool) -> str:
117
+ if json_output:
118
+ return json.dumps(report.as_dict(), sort_keys=True, separators=(",", ":"))
119
+ mode = "deep" if report.deep else "standard"
120
+ lines = [f"DployDB doctor ({mode})", f"Project: {report.project}"]
121
+ labels = {
122
+ DiagnosticOutcome.PASSED: "PASS",
123
+ DiagnosticOutcome.WARNING: "WARN",
124
+ DiagnosticOutcome.FAILED: "FAIL",
125
+ DiagnosticOutcome.SKIPPED: "SKIP",
126
+ }
127
+ for check in report.checks:
128
+ lines.append(f"[{labels[check.outcome]}] {check.check_id}: {check.message}")
129
+ summary = report.as_dict()["summary"]
130
+ assert isinstance(summary, dict)
131
+ lines.append("Summary: " + ", ".join(f"{name}={count}" for name, count in summary.items()))
132
+ if report.failure is not None:
133
+ lines.extend(("", render_failure(report.failure, json_output=False)))
134
+ else:
135
+ lines.append("Next safe action: host diagnostics passed for the implemented checks.")
136
+ return "\n".join(lines)
137
+
138
+
139
+ def _render_status(report: StatusReport, *, json_output: bool) -> str:
140
+ if json_output:
141
+ return json.dumps(report.as_dict(), sort_keys=True, separators=(",", ":"))
142
+ lines = [
143
+ "DployDB status",
144
+ f"Project: {report.project}",
145
+ f"Status: {report.status.value}",
146
+ f"Lock state: {report.lock['state']}",
147
+ ]
148
+ if report.operation is not None:
149
+ lines.extend(
150
+ (
151
+ f"Operation: {report.operation['operation_id']}",
152
+ f"Operation type: {report.operation['operation_type']}",
153
+ f"Operation status: {report.operation['status']}",
154
+ f"Stage: {report.operation['stage']}",
155
+ )
156
+ )
157
+ lines.extend(f"Warning: {warning}" for warning in report.warnings)
158
+ if report.failure is not None:
159
+ lines.extend(("", render_failure(report.failure, json_output=False)))
160
+ else:
161
+ lines.append(f"Next safe action: {report.next_safe_action}")
162
+ return "\n".join(lines)
163
+
164
+
165
+ def _backup_result(
166
+ artifact: BackupArtifact,
167
+ *,
168
+ command: str,
169
+ secrets: SecretRegistry,
170
+ remote: RemoteBackupArtifact | None = None,
171
+ ) -> dict[str, object]:
172
+ metadata = artifact.metadata
173
+ result: dict[str, object] = {
174
+ "ok": True,
175
+ "command": command,
176
+ "project": secrets.redact_text(metadata.project),
177
+ "backup_id": metadata.backup_id,
178
+ "database_path": secrets.redact_text(str(artifact.database_path)),
179
+ "metadata_path": secrets.redact_text(str(artifact.metadata_path)),
180
+ "size_bytes": metadata.size_bytes,
181
+ "sha256": metadata.sha256,
182
+ "purpose": metadata.purpose.value,
183
+ "created_at": metadata.model_dump(mode="json")["created_at"],
184
+ "completed_at": metadata.model_dump(mode="json")["completed_at"],
185
+ "checks": metadata.sqlite.model_dump(mode="json"),
186
+ }
187
+ if command == "backup":
188
+ result["remote_uploaded"] = remote is not None
189
+ result["remote"] = (
190
+ None
191
+ if remote is None
192
+ else {
193
+ "provider": "s3",
194
+ "bucket": remote.bucket,
195
+ "database_object_key": remote.metadata.database_object_key,
196
+ "metadata_object_key": remote.metadata_object_key,
197
+ "uploaded_at": remote.metadata.model_dump(mode="json")["uploaded_at"],
198
+ "release_id": remote.metadata.release_id,
199
+ }
200
+ )
201
+ return result
202
+
203
+
204
+ def _render_backup_result(
205
+ artifact: BackupArtifact,
206
+ *,
207
+ command: str,
208
+ json_output: bool,
209
+ secrets: SecretRegistry,
210
+ remote: RemoteBackupArtifact | None = None,
211
+ ) -> str:
212
+ result = _backup_result(artifact, command=command, secrets=secrets, remote=remote)
213
+ if json_output:
214
+ return json.dumps(result, sort_keys=True, separators=(",", ":"))
215
+ action = "created" if command == "backup" else "verified"
216
+ lines = [
217
+ f"DployDB backup {action}.",
218
+ f"Project: {result['project']}",
219
+ f"Backup ID: {result['backup_id']}",
220
+ f"Database: {result['database_path']}",
221
+ f"Size: {result['size_bytes']} bytes",
222
+ f"SHA-256: {result['sha256']}",
223
+ "SQLite checks: passed",
224
+ ]
225
+ if command == "backup":
226
+ lines.append(
227
+ "Remote backup: verified and committed"
228
+ if remote is not None
229
+ else "Remote backup: not requested"
230
+ )
231
+ return "\n".join(lines)
232
+
233
+
234
+ def _rolled_back_failure(result: DeploymentResult) -> FailurePayload:
235
+ failure = result.release.failure
236
+ if failure is None:
237
+ raise ValueError("rolled-back deployment requires durable failure evidence")
238
+ try:
239
+ exit_code = int(ERROR_EXIT_CODES[ErrorKind(failure.error_code)])
240
+ except (KeyError, ValueError):
241
+ exit_code = int(ExitCode.OPERATION_FAILED)
242
+ return FailurePayload(
243
+ error_code=failure.error_code,
244
+ exit_code=exit_code,
245
+ what_failed=failure.what_failed,
246
+ production_changed=result.operation.safety.production_changed,
247
+ previous_application_running=result.operation.safety.previous_application_running,
248
+ recovery_required=False,
249
+ log_path=failure.log_path or str(result.release.operation_log_path),
250
+ next_safe_action=failure.next_safe_action,
251
+ )
252
+
253
+
254
+ def _deployment_result_data(
255
+ result: DeploymentResult,
256
+ *,
257
+ non_interactive: bool,
258
+ ) -> dict[str, object]:
259
+ release = result.release
260
+ common: dict[str, object] = {
261
+ "command": "deploy",
262
+ "outcome": release.status.value,
263
+ "release_id": release.release_id,
264
+ "operation_id": release.operation_id,
265
+ "requested_version": release.requested_version,
266
+ "production_changed": result.operation.safety.production_changed,
267
+ "previous_application_running": result.operation.safety.previous_application_running,
268
+ "recovery_required": result.operation.safety.recovery_required,
269
+ "traffic_activated": release.traffic_activated,
270
+ "final_backup_id": release.final_backup_id,
271
+ "final_backup_sha256": release.final_backup_sha256,
272
+ "log_path": str(release.operation_log_path),
273
+ "non_interactive": non_interactive,
274
+ }
275
+ if result.active:
276
+ return {"ok": True, **common}
277
+ if result.rolled_back:
278
+ payload = _rolled_back_failure(result)
279
+ return {**payload.as_dict(), **common}
280
+ raise ValueError(f"deployment returned non-terminal state {release.status.value}")
281
+
282
+
283
+ def _render_deployment_result(
284
+ result: DeploymentResult,
285
+ *,
286
+ json_output: bool,
287
+ non_interactive: bool,
288
+ ) -> str:
289
+ data = _deployment_result_data(result, non_interactive=non_interactive)
290
+ if json_output:
291
+ return json.dumps(data, sort_keys=True, separators=(",", ":"))
292
+ release = result.release
293
+ if release.status is DeploymentState.ACTIVE:
294
+ return "\n".join(
295
+ (
296
+ "DployDB deployment completed.",
297
+ "Outcome: active",
298
+ f"Release ID: {release.release_id}",
299
+ f"Version: {release.requested_version}",
300
+ "Production changed: yes",
301
+ "Previous application running: no",
302
+ "Recovery required: no",
303
+ f"Traffic activated: {_yes_no_unknown(release.traffic_activated)}",
304
+ f"Final backup: {release.final_backup_id or 'not available'}",
305
+ f"Relevant log: {release.operation_log_path}",
306
+ "Next safe action: monitor the active release and preserve its final backup.",
307
+ )
308
+ )
309
+ failure = _rolled_back_failure(result)
310
+ return "\n".join(
311
+ (
312
+ "DployDB deployment was rolled back safely.",
313
+ "Outcome: rolled_back",
314
+ f"Release ID: {release.release_id}",
315
+ f"Version: {release.requested_version}",
316
+ render_failure(failure, json_output=False),
317
+ )
318
+ )
319
+
320
+
321
+ def _release_role(release_id: str, pointers: ReleasePointers | None) -> str:
322
+ if pointers is None:
323
+ return "history"
324
+ if release_id == pointers.active_release_id:
325
+ return "active"
326
+ if release_id == pointers.previous_release_id:
327
+ return "previous"
328
+ return "history"
329
+
330
+
331
+ def _release_summary(
332
+ manifest: ReleaseManifest,
333
+ *,
334
+ pointers: ReleasePointers | None,
335
+ ) -> dict[str, object]:
336
+ timestamps = manifest.model_dump(mode="json")
337
+ return {
338
+ "release_id": manifest.release_id,
339
+ "operation_id": manifest.operation_id,
340
+ "requested_version": manifest.requested_version,
341
+ "status": manifest.status.value,
342
+ "role": _release_role(manifest.release_id, pointers),
343
+ "protected": manifest.release_id
344
+ in {
345
+ None if pointers is None else pointers.active_release_id,
346
+ None if pointers is None else pointers.previous_release_id,
347
+ },
348
+ "production_changed": manifest.production_changed,
349
+ "traffic_activated": manifest.traffic_activated,
350
+ "final_backup_id": manifest.final_backup_id,
351
+ "started_at": timestamps["started_at"],
352
+ "completed_at": timestamps["completed_at"],
353
+ "failure_code": None if manifest.failure is None else manifest.failure.error_code,
354
+ "log_path": str(manifest.operation_log_path),
355
+ }
356
+
357
+
358
+ def _release_history_data(
359
+ history: ReleaseHistorySnapshot,
360
+ *,
361
+ project: str,
362
+ secrets: SecretRegistry,
363
+ ) -> dict[str, object]:
364
+ pointers = history.pointers
365
+ releases = [_release_summary(manifest, pointers=pointers) for manifest in history.releases]
366
+ raw: JsonValue = {
367
+ "ok": True,
368
+ "command": "releases",
369
+ "project": project,
370
+ "active_release_id": None if pointers is None else pointers.active_release_id,
371
+ "previous_release_id": None if pointers is None else pointers.previous_release_id,
372
+ "count": len(releases),
373
+ "releases": cast(list[JsonValue], releases),
374
+ }
375
+ redacted = secrets.redact(raw)
376
+ if not isinstance(redacted, dict):
377
+ raise TypeError("release history must serialize as an object")
378
+ return cast(dict[str, object], redacted)
379
+
380
+
381
+ def _render_release_history(
382
+ history: ReleaseHistorySnapshot,
383
+ *,
384
+ project: str,
385
+ json_output: bool,
386
+ secrets: SecretRegistry,
387
+ ) -> str:
388
+ data = _release_history_data(history, project=project, secrets=secrets)
389
+ if json_output:
390
+ return json.dumps(data, sort_keys=True, separators=(",", ":"))
391
+ lines = [
392
+ "DployDB releases",
393
+ f"Project: {data['project']}",
394
+ f"Active release: {data['active_release_id'] or 'none'}",
395
+ f"Previous release: {data['previous_release_id'] or 'none'}",
396
+ f"Release count: {data['count']}",
397
+ ]
398
+ summaries = cast(list[dict[str, object]], data["releases"])
399
+ if not summaries:
400
+ lines.append("No deployment releases have been recorded.")
401
+ for item in summaries:
402
+ lines.append(
403
+ "- "
404
+ f"{item['release_id']} version={item['requested_version']} "
405
+ f"status={item['status']} role={item['role']} started={item['started_at']}"
406
+ )
407
+ lines.append("Next safe action: inspect a release with dploydb release show <release-id>.")
408
+ return "\n".join(lines)
409
+
410
+
411
+ def _release_detail_data(
412
+ manifest: ReleaseManifest,
413
+ *,
414
+ pointers: ReleasePointers | None,
415
+ secrets: SecretRegistry,
416
+ ) -> dict[str, object]:
417
+ raw_manifest = cast(JsonValue, manifest.model_dump(mode="json"))
418
+ raw: JsonValue = {
419
+ "ok": True,
420
+ "command": "release show",
421
+ "role": _release_role(manifest.release_id, pointers),
422
+ "protected": manifest.release_id
423
+ in {
424
+ None if pointers is None else pointers.active_release_id,
425
+ None if pointers is None else pointers.previous_release_id,
426
+ },
427
+ "release": raw_manifest,
428
+ }
429
+ redacted = secrets.redact(raw)
430
+ if not isinstance(redacted, dict):
431
+ raise TypeError("release detail must serialize as an object")
432
+ return cast(dict[str, object], redacted)
433
+
434
+
435
+ def _render_release_detail(
436
+ manifest: ReleaseManifest,
437
+ *,
438
+ pointers: ReleasePointers | None,
439
+ json_output: bool,
440
+ secrets: SecretRegistry,
441
+ ) -> str:
442
+ data = _release_detail_data(manifest, pointers=pointers, secrets=secrets)
443
+ if json_output:
444
+ return json.dumps(data, sort_keys=True, separators=(",", ":"))
445
+ release = cast(dict[str, object], data["release"])
446
+ lines = [
447
+ "DployDB release",
448
+ f"Release ID: {release['release_id']}",
449
+ f"Version: {release['requested_version']}",
450
+ f"Status: {release['status']}",
451
+ f"Role: {data['role']}",
452
+ f"Protected: {_yes_no_unknown(cast(bool, data['protected']))}",
453
+ f"Previous release: {release['previous_release_id'] or 'none'}",
454
+ f"Production changed: {_yes_no_unknown(cast(bool, release['production_changed']))}",
455
+ f"Traffic activated: {_yes_no_unknown(cast(bool, release['traffic_activated']))}",
456
+ f"Rehearsal backup: {release['rehearsal_backup_id'] or 'none'}",
457
+ f"Final backup: {release['final_backup_id'] or 'none'}",
458
+ f"Started: {release['started_at']}",
459
+ f"Completed: {release['completed_at'] or 'not completed'}",
460
+ f"Relevant log: {release['operation_log_path']}",
461
+ ]
462
+ failure = release["failure"]
463
+ if isinstance(failure, dict):
464
+ lines.extend(
465
+ (
466
+ f"Failure: {failure['what_failed']}",
467
+ f"Next safe action: {failure['next_safe_action']}",
468
+ )
469
+ )
470
+ else:
471
+ lines.append("Next safe action: preserve this release and its referenced backups.")
472
+ return "\n".join(lines)
473
+
474
+
475
+ def _restore_preview_data(
476
+ selection: RestoreSelection,
477
+ *,
478
+ secrets: SecretRegistry,
479
+ ) -> dict[str, object]:
480
+ raw = cast(JsonValue, selection.as_dict())
481
+ redacted = secrets.redact(raw)
482
+ if not isinstance(redacted, dict):
483
+ raise TypeError("restore preview must serialize as an object")
484
+ return {
485
+ "ok": True,
486
+ "command": "restore",
487
+ "outcome": "preview",
488
+ "executed": False,
489
+ "confirmation_required": True,
490
+ **cast(dict[str, object], redacted),
491
+ }
492
+
493
+
494
+ def _render_restore_preview(
495
+ selection: RestoreSelection,
496
+ *,
497
+ json_output: bool,
498
+ secrets: SecretRegistry,
499
+ ) -> str:
500
+ data = _restore_preview_data(selection, secrets=secrets)
501
+ if json_output:
502
+ return json.dumps(data, sort_keys=True, separators=(",", ":"))
503
+ return "\n".join(
504
+ (
505
+ "DployDB manual restore preview",
506
+ DATA_LOSS_WARNING,
507
+ f"Current release: {data['active_release_id']} ({data['active_version']})",
508
+ f"Selected release: {data['selected_release_id']} ({data['selected_version']})",
509
+ f"Selected backup: {data['selected_backup_id']}",
510
+ f"Selected backup SHA-256: {data['selected_backup_sha256']}",
511
+ f"Production database: {data['database_path']}",
512
+ f"Current container: {data['current_container_name']}",
513
+ f"Selected container: {data['selected_container_name']}",
514
+ "A verified backup of the current database will be created before replacement.",
515
+ )
516
+ )
517
+
518
+
519
+ def _render_manual_restore_result(
520
+ result: ManualRestoreResult,
521
+ *,
522
+ json_output: bool,
523
+ secrets: SecretRegistry,
524
+ ) -> str:
525
+ raw = cast(JsonValue, result.as_dict())
526
+ redacted = secrets.redact(raw)
527
+ if not isinstance(redacted, dict):
528
+ raise TypeError("manual restore result must serialize as an object")
529
+ data = cast(dict[str, object], redacted)
530
+ if json_output:
531
+ return json.dumps(data, sort_keys=True, separators=(",", ":"))
532
+ return "\n".join(
533
+ (
534
+ "DployDB manual restore completed.",
535
+ f"Selected release: {data['selected_release_id']} ({data['selected_version']})",
536
+ f"Replaced release: {data['replaced_release_id']}",
537
+ f"Pre-restore backup: {data['pre_restore_backup_id']}",
538
+ f"Restored backup: {data['restored_backup_id']}",
539
+ f"Database SHA-256: {data['database_sha256']}",
540
+ "Production changed: yes",
541
+ "Previous application running: yes",
542
+ "Recovery required: no",
543
+ f"Relevant log: {data['log_path']}",
544
+ "Next safe action: monitor the restored release and preserve the pre-restore backup.",
545
+ )
546
+ )
547
+
548
+
549
+ def _recovery_plan_data(
550
+ plan: RecoveryPlan,
551
+ *,
552
+ secrets: SecretRegistry,
553
+ ) -> dict[str, object]:
554
+ raw = cast(JsonValue, plan.as_dict())
555
+ redacted = secrets.redact(raw)
556
+ if not isinstance(redacted, dict):
557
+ raise TypeError("recovery plan must serialize as an object")
558
+ return {
559
+ "ok": plan.disposition is not RecoveryDisposition.MANUAL_REQUIRED,
560
+ "command": "recover",
561
+ "outcome": "preview",
562
+ "executed": False,
563
+ "confirmation_required": plan.executable,
564
+ **cast(dict[str, object], redacted),
565
+ }
566
+
567
+
568
+ def _render_recovery_plan(
569
+ plan: RecoveryPlan,
570
+ *,
571
+ json_output: bool,
572
+ secrets: SecretRegistry,
573
+ ) -> str:
574
+ data = _recovery_plan_data(plan, secrets=secrets)
575
+ if json_output:
576
+ return json.dumps(data, sort_keys=True, separators=(",", ":"))
577
+ actions = cast(list[str], data["actions"])
578
+ return "\n".join(
579
+ (
580
+ "DployDB recovery diagnosis",
581
+ f"Disposition: {data['disposition']}",
582
+ f"Release ID: {data['release_id']}",
583
+ f"Source operation: {data['operation_id']}",
584
+ f"Durable stage: {data['durable_stage']}",
585
+ "Production may have changed: "
586
+ f"{_yes_no_unknown(cast(bool, data['production_may_have_changed']))}",
587
+ "Traffic may have switched: "
588
+ f"{_yes_no_unknown(cast(bool, data['traffic_may_have_switched']))}",
589
+ "Automatic database restore allowed: "
590
+ f"{_yes_no_unknown(cast(bool, data['automatic_database_restore_allowed']))}",
591
+ f"Ordered actions: {', '.join(actions) if actions else 'none'}",
592
+ f"Reason: {data['reason']}",
593
+ f"Next safe action: {data['next_safe_action']}",
594
+ )
595
+ )
596
+
597
+
598
+ def _render_recovery_result(
599
+ result: RecoveryResult,
600
+ *,
601
+ json_output: bool,
602
+ secrets: SecretRegistry,
603
+ ) -> str:
604
+ raw = cast(JsonValue, result.as_dict())
605
+ redacted = secrets.redact(raw)
606
+ if not isinstance(redacted, dict):
607
+ raise TypeError("recovery result must serialize as an object")
608
+ data = cast(dict[str, object], redacted)
609
+ if json_output:
610
+ return json.dumps(data, sort_keys=True, separators=(",", ":"))
611
+ return "\n".join(
612
+ (
613
+ "DployDB recovery completed.",
614
+ f"Outcome: {data['outcome']}",
615
+ f"Release ID: {data['release_id']}",
616
+ f"Recovery operation: {data['recovery_operation_id']}",
617
+ f"Source operation: {data['source_operation_id']}",
618
+ f"Production changed: {_yes_no_unknown(cast(bool, data['production_changed']))}",
619
+ "Previous application running: "
620
+ f"{_yes_no_unknown(cast(bool, data['previous_application_running']))}",
621
+ "Recovery required: no",
622
+ f"Relevant log: {data['log_path']}",
623
+ "Next safe action: monitor the recovered release and preserve all recovery evidence.",
624
+ )
625
+ )
626
+
627
+
628
+ def _version_callback(value: bool) -> None:
629
+ if value:
630
+ console.print(_version_text())
631
+ raise typer.Exit()
632
+
633
+
634
+ @app.callback()
635
+ def main(
636
+ context: typer.Context,
637
+ show_version: Annotated[
638
+ bool | None,
639
+ typer.Option(
640
+ "--version",
641
+ callback=_version_callback,
642
+ is_eager=True,
643
+ help="Show the installed DployDB version and exit.",
644
+ ),
645
+ ] = None,
646
+ no_color: Annotated[
647
+ bool,
648
+ typer.Option(
649
+ "--no-color",
650
+ help="Disable terminal color; the NO_COLOR environment variable is also honored.",
651
+ ),
652
+ ] = False,
653
+ ) -> None:
654
+ """Deployment safety for SQLite applications."""
655
+ if no_color or "NO_COLOR" in os.environ:
656
+ context.color = False
657
+
658
+
659
+ @app.command("version")
660
+ def version_command() -> None:
661
+ """Show the installed DployDB version."""
662
+ console.print(_version_text())
663
+
664
+
665
+ @app.command("init")
666
+ def init_command(
667
+ config_path: Annotated[
668
+ Path,
669
+ typer.Option(
670
+ "--config",
671
+ "-c",
672
+ help="Configuration file to create without overwriting an existing path.",
673
+ ),
674
+ ] = DEFAULT_CONFIG_PATH,
675
+ json_output: Annotated[
676
+ bool,
677
+ typer.Option("--json", help="Emit stable machine-readable output."),
678
+ ] = False,
679
+ ) -> None:
680
+ """Create a restrictive, valid starter configuration."""
681
+ try:
682
+ created_path = initialize_configuration(config_path)
683
+ except DployDBError as error:
684
+ abort_with_error(error, json_output=json_output)
685
+
686
+ if json_output:
687
+ typer.echo(
688
+ json.dumps(
689
+ {"ok": True, "config_path": str(created_path)},
690
+ sort_keys=True,
691
+ separators=(",", ":"),
692
+ )
693
+ )
694
+ return
695
+
696
+ typer.echo(f"Created DployDB configuration: {created_path}")
697
+ typer.echo("Permissions: 0600")
698
+ typer.echo("Next safe action: edit the paths, then run dploydb doctor.")
699
+
700
+
701
+ @app.command("doctor")
702
+ def doctor_command(
703
+ config_path: Annotated[
704
+ Path,
705
+ typer.Option("--config", "-c", help="Configuration file to inspect."),
706
+ ] = DEFAULT_CONFIG_PATH,
707
+ deep: Annotated[
708
+ bool,
709
+ typer.Option("--deep", help="Run bounded Docker, write, and disk-space probes."),
710
+ ] = False,
711
+ json_output: Annotated[
712
+ bool,
713
+ typer.Option("--json", help="Emit stable machine-readable output."),
714
+ ] = False,
715
+ ) -> None:
716
+ """Check configured host safety without changing production."""
717
+ try:
718
+ loaded = load_configuration(config_path)
719
+ report = run_doctor(loaded, config_path=config_path, deep=deep)
720
+ except DployDBError as error:
721
+ abort_with_error(error, json_output=json_output)
722
+ except Exception:
723
+ abort_with_error(_internal_error(), json_output=json_output)
724
+ typer.echo(
725
+ _render_doctor(report, json_output=json_output),
726
+ err=not json_output and report.exit_code != 0,
727
+ )
728
+ if report.exit_code != 0:
729
+ raise typer.Exit(code=report.exit_code)
730
+
731
+
732
+ @app.command("status")
733
+ def status_command(
734
+ config_path: Annotated[
735
+ Path,
736
+ typer.Option("--config", "-c", help="Configuration file whose state is inspected."),
737
+ ] = DEFAULT_CONFIG_PATH,
738
+ json_output: Annotated[
739
+ bool,
740
+ typer.Option("--json", help="Emit stable machine-readable output."),
741
+ ] = False,
742
+ ) -> None:
743
+ """Explain lock and durable operation state without modifying it."""
744
+ try:
745
+ loaded = load_configuration(config_path)
746
+ report = inspect_runtime_status(loaded.config, secrets=loaded.secrets)
747
+ except DployDBError as error:
748
+ abort_with_error(error, json_output=json_output)
749
+ except Exception:
750
+ abort_with_error(_internal_error(), json_output=json_output)
751
+ typer.echo(
752
+ _render_status(report, json_output=json_output),
753
+ err=not json_output and report.exit_code != 0,
754
+ )
755
+ if report.exit_code != 0:
756
+ raise typer.Exit(code=report.exit_code)
757
+
758
+
759
+ @app.command("backup")
760
+ def backup_command(
761
+ config_path: Annotated[
762
+ Path,
763
+ typer.Option("--config", "-c", help="Configuration file for the backup."),
764
+ ] = DEFAULT_CONFIG_PATH,
765
+ json_output: Annotated[
766
+ bool,
767
+ typer.Option("--json", help="Emit stable machine-readable output."),
768
+ ] = False,
769
+ upload: Annotated[
770
+ bool,
771
+ typer.Option(
772
+ "--upload",
773
+ help="Upload the verified local backup to configured remote storage.",
774
+ ),
775
+ ] = False,
776
+ ) -> None:
777
+ """Create one verified local backup and optionally commit it remotely."""
778
+ loaded = None
779
+ try:
780
+ loaded = load_configuration(config_path)
781
+ result = create_configured_backup_result(loaded, upload=upload)
782
+ except DployDBError as error:
783
+ if loaded is not None:
784
+ error = redact_error(error, secrets=loaded.secrets)
785
+ abort_with_error(error, json_output=json_output)
786
+ except Exception:
787
+ abort_with_error(_internal_error(), json_output=json_output)
788
+ typer.echo(
789
+ _render_backup_result(
790
+ result.local,
791
+ command="backup",
792
+ json_output=json_output,
793
+ secrets=loaded.secrets,
794
+ remote=result.remote,
795
+ )
796
+ )
797
+
798
+
799
+ @app.command("releases")
800
+ def releases_command(
801
+ config_path: Annotated[
802
+ Path,
803
+ typer.Option("--config", "-c", help="Configuration file whose releases are listed."),
804
+ ] = DEFAULT_CONFIG_PATH,
805
+ json_output: Annotated[
806
+ bool,
807
+ typer.Option("--json", help="Emit stable machine-readable output."),
808
+ ] = False,
809
+ ) -> None:
810
+ """List every validated local deployment release without modifying state."""
811
+ loaded = None
812
+ try:
813
+ loaded = load_configuration(config_path)
814
+ history = ReleaseStore(
815
+ loaded.config.state_directory,
816
+ secrets=loaded.secrets,
817
+ ).read_history()
818
+ except DployDBError as error:
819
+ if loaded is not None:
820
+ error = redact_error(error, secrets=loaded.secrets)
821
+ abort_with_error(error, json_output=json_output)
822
+ except Exception:
823
+ abort_with_error(_internal_error(), json_output=json_output)
824
+ typer.echo(
825
+ _render_release_history(
826
+ history,
827
+ project=loaded.config.project,
828
+ json_output=json_output,
829
+ secrets=loaded.secrets,
830
+ )
831
+ )
832
+
833
+
834
+ @release_app.command("show")
835
+ def release_show_command(
836
+ release_id: Annotated[str, typer.Argument(help="Exact release ID to inspect.")],
837
+ config_path: Annotated[
838
+ Path,
839
+ typer.Option("--config", "-c", help="Configuration file whose release is shown."),
840
+ ] = DEFAULT_CONFIG_PATH,
841
+ json_output: Annotated[
842
+ bool,
843
+ typer.Option("--json", help="Emit stable machine-readable output."),
844
+ ] = False,
845
+ ) -> None:
846
+ """Show one complete validated release manifest without modifying state."""
847
+ loaded = None
848
+ try:
849
+ loaded = load_configuration(config_path)
850
+ manifest, pointers = ReleaseStore(
851
+ loaded.config.state_directory,
852
+ secrets=loaded.secrets,
853
+ ).lookup_history_release(release_id)
854
+ except DployDBError as error:
855
+ if loaded is not None:
856
+ error = redact_error(error, secrets=loaded.secrets)
857
+ abort_with_error(error, json_output=json_output)
858
+ except Exception:
859
+ abort_with_error(_internal_error(), json_output=json_output)
860
+ typer.echo(
861
+ _render_release_detail(
862
+ manifest,
863
+ pointers=pointers,
864
+ json_output=json_output,
865
+ secrets=loaded.secrets,
866
+ )
867
+ )
868
+
869
+
870
+ @app.command("restore")
871
+ def restore_command(
872
+ release_id: Annotated[
873
+ str,
874
+ typer.Argument(help="Protected previous release ID to restore."),
875
+ ],
876
+ config_path: Annotated[
877
+ Path,
878
+ typer.Option("--config", "-c", help="Configuration file for manual restore."),
879
+ ] = DEFAULT_CONFIG_PATH,
880
+ yes: Annotated[
881
+ bool,
882
+ typer.Option("--yes", help="Acknowledge the data-loss warning and execute restore."),
883
+ ] = False,
884
+ json_output: Annotated[
885
+ bool,
886
+ typer.Option("--json", help="Emit stable machine-readable output."),
887
+ ] = False,
888
+ ) -> None:
889
+ """Preview or confirm a backup-first restore of the protected previous release."""
890
+ loaded = None
891
+ try:
892
+ loaded = load_configuration(config_path)
893
+ preview = preview_configured_restore(loaded, release_id)
894
+ except DployDBError as error:
895
+ if loaded is not None:
896
+ error = redact_error(error, secrets=loaded.secrets)
897
+ abort_with_error(error, json_output=json_output)
898
+ except Exception:
899
+ abort_with_error(_internal_error(), json_output=json_output)
900
+
901
+ if not yes:
902
+ typer.echo(
903
+ _render_restore_preview(
904
+ preview,
905
+ json_output=json_output,
906
+ secrets=loaded.secrets,
907
+ )
908
+ )
909
+ if json_output:
910
+ return
911
+ confirmed = typer.confirm(
912
+ "Proceed with this destructive restore after creating the current-state backup?",
913
+ default=False,
914
+ )
915
+ if not confirmed:
916
+ typer.echo("Manual restore cancelled; production was not changed.")
917
+ return
918
+
919
+ try:
920
+ result = restore_configured_release(
921
+ loaded,
922
+ release_id,
923
+ config_path=config_path,
924
+ )
925
+ except DployDBError as error:
926
+ abort_with_error(
927
+ redact_error(error, secrets=loaded.secrets),
928
+ json_output=json_output,
929
+ )
930
+ except Exception:
931
+ abort_with_error(_internal_error(), json_output=json_output)
932
+ typer.echo(
933
+ _render_manual_restore_result(
934
+ result,
935
+ json_output=json_output,
936
+ secrets=loaded.secrets,
937
+ )
938
+ )
939
+
940
+
941
+ @app.command("recover")
942
+ def recover_command(
943
+ config_path: Annotated[
944
+ Path,
945
+ typer.Option("--config", "-c", help="Configuration file for recovery."),
946
+ ] = DEFAULT_CONFIG_PATH,
947
+ yes: Annotated[
948
+ bool,
949
+ typer.Option("--yes", help="Confirm execution of the diagnosed recovery plan."),
950
+ ] = False,
951
+ json_output: Annotated[
952
+ bool,
953
+ typer.Option("--json", help="Emit stable machine-readable output."),
954
+ ] = False,
955
+ ) -> None:
956
+ """Diagnose or execute recovery for an interrupted deployment."""
957
+ loaded = None
958
+ try:
959
+ loaded = load_configuration(config_path)
960
+ plan = preview_configured_recovery(loaded, config_path=config_path)
961
+ except DployDBError as error:
962
+ if loaded is not None:
963
+ error = redact_error(error, secrets=loaded.secrets)
964
+ abort_with_error(error, json_output=json_output)
965
+ except Exception:
966
+ abort_with_error(_internal_error(), json_output=json_output)
967
+
968
+ if plan.disposition is RecoveryDisposition.MANUAL_REQUIRED:
969
+ abort_with_error(
970
+ RecoveryRequiredError(
971
+ plan.reason,
972
+ production_changed=plan.production_may_have_changed,
973
+ previous_application_running=None,
974
+ log_path=(
975
+ loaded.config.state_directory
976
+ / "operations"
977
+ / plan.operation_id
978
+ / "events.jsonl"
979
+ ),
980
+ next_safe_action=plan.next_safe_action,
981
+ ),
982
+ json_output=json_output,
983
+ )
984
+ if plan.disposition is RecoveryDisposition.NO_ACTION:
985
+ typer.echo(
986
+ _render_recovery_plan(
987
+ plan,
988
+ json_output=json_output,
989
+ secrets=loaded.secrets,
990
+ )
991
+ )
992
+ return
993
+ if not yes:
994
+ typer.echo(
995
+ _render_recovery_plan(
996
+ plan,
997
+ json_output=json_output,
998
+ secrets=loaded.secrets,
999
+ )
1000
+ )
1001
+ if json_output:
1002
+ return
1003
+ confirmed = typer.confirm(
1004
+ "Execute exactly this recovery plan after revalidating it under the lock?",
1005
+ default=False,
1006
+ )
1007
+ if not confirmed:
1008
+ typer.echo("Recovery execution cancelled; diagnosis was read-only.")
1009
+ return
1010
+ try:
1011
+ result = recover_configured_deployment(
1012
+ loaded,
1013
+ config_path=config_path,
1014
+ )
1015
+ except DployDBError as error:
1016
+ abort_with_error(
1017
+ redact_error(error, secrets=loaded.secrets),
1018
+ json_output=json_output,
1019
+ )
1020
+ except Exception:
1021
+ abort_with_error(_internal_error(), json_output=json_output)
1022
+ typer.echo(
1023
+ _render_recovery_result(
1024
+ result,
1025
+ json_output=json_output,
1026
+ secrets=loaded.secrets,
1027
+ )
1028
+ )
1029
+
1030
+
1031
+ @app.command("deploy")
1032
+ def deploy_command(
1033
+ release_version: Annotated[
1034
+ str,
1035
+ typer.Option("--version", help="Validated release version to deploy."),
1036
+ ],
1037
+ config_path: Annotated[
1038
+ Path,
1039
+ typer.Option("--config", "-c", help="Configuration file for the deployment."),
1040
+ ] = DEFAULT_CONFIG_PATH,
1041
+ json_output: Annotated[
1042
+ bool,
1043
+ typer.Option("--json", help="Emit stable machine-readable output."),
1044
+ ] = False,
1045
+ non_interactive: Annotated[
1046
+ bool,
1047
+ typer.Option(
1048
+ "--non-interactive",
1049
+ help="Never wait for terminal input; fail when future confirmation is required.",
1050
+ ),
1051
+ ] = False,
1052
+ ) -> None:
1053
+ """Run the checked production cutover with automatic pre-traffic rollback."""
1054
+ loaded = None
1055
+ try:
1056
+ loaded = load_configuration(config_path)
1057
+ result = deploy_configured_release(
1058
+ loaded,
1059
+ version=release_version,
1060
+ config_path=config_path,
1061
+ )
1062
+ except DployDBError as error:
1063
+ if loaded is not None:
1064
+ error = redact_error(error, secrets=loaded.secrets)
1065
+ abort_with_error(error, json_output=json_output)
1066
+ except Exception:
1067
+ abort_with_error(_internal_error(), json_output=json_output)
1068
+
1069
+ typer.echo(
1070
+ _render_deployment_result(
1071
+ result,
1072
+ json_output=json_output,
1073
+ non_interactive=non_interactive,
1074
+ ),
1075
+ err=not json_output and result.rolled_back,
1076
+ )
1077
+ if result.rolled_back:
1078
+ raise typer.Exit(code=_rolled_back_failure(result).exit_code)
1079
+
1080
+
1081
+ @app.command("verify")
1082
+ def verify_command(
1083
+ backup_id: Annotated[str, typer.Argument(help="Committed local backup ID to verify.")],
1084
+ config_path: Annotated[
1085
+ Path,
1086
+ typer.Option("--config", "-c", help="Configuration file for backup storage."),
1087
+ ] = DEFAULT_CONFIG_PATH,
1088
+ json_output: Annotated[
1089
+ bool,
1090
+ typer.Option("--json", help="Emit stable machine-readable output."),
1091
+ ] = False,
1092
+ ) -> None:
1093
+ """Reverify one committed local backup without modifying state."""
1094
+ loaded = None
1095
+ try:
1096
+ loaded = load_configuration(config_path)
1097
+ artifact = verify_configured_backup(loaded, backup_id)
1098
+ except DployDBError as error:
1099
+ if loaded is not None:
1100
+ error = redact_error(error, secrets=loaded.secrets)
1101
+ abort_with_error(error, json_output=json_output)
1102
+ except Exception:
1103
+ abort_with_error(_internal_error(), json_output=json_output)
1104
+ typer.echo(
1105
+ _render_backup_result(
1106
+ artifact,
1107
+ command="verify",
1108
+ json_output=json_output,
1109
+ secrets=loaded.secrets,
1110
+ )
1111
+ )