feat: add borg execution, mkdir apply, and missing path validation

This commit is contained in:
Eddie Nielsen 2026-03-23 11:45:19 +00:00
parent f6b0521c34
commit 80ec0a74e8

View file

@ -2,7 +2,9 @@ from __future__ import annotations
import argparse import argparse
import json import json
import shlex
import socket import socket
import subprocess
import sys import sys
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@ -16,7 +18,7 @@ def check_path_exists(path: str) -> bool:
def create_missing_paths(paths: list[str]) -> list[str]: def create_missing_paths(paths: list[str]) -> list[str]:
created = [] created: list[str] = []
for path in sorted(set(paths)): for path in sorted(set(paths)):
p = Path(path) p = Path(path)
if not p.exists(): if not p.exists():
@ -60,7 +62,20 @@ def build_borg_command(repo: str, archive_name: str, include_paths: list[str]) -
return "\n".join(lines) return "\n".join(lines)
def find_missing_paths(plan: dict[str, Any]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: def build_borg_argv(repo: str, archive_name: str, include_paths: list[str]) -> list[str]:
return [
"borg",
"create",
"--stats",
"--progress",
f"{repo}::{archive_name}",
*include_paths,
]
def find_missing_paths(
plan: dict[str, Any],
) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
missing_include = [ missing_include = [
item for item in plan.get("include", []) item for item in plan.get("include", [])
if not check_path_exists(item["source"]) if not check_path_exists(item["source"])
@ -98,61 +113,223 @@ def print_human_summary(compose_file: Path, project_root: Path, plan: dict[str,
print() print()
def main() -> None: def print_missing_paths_report(
parser = argparse.ArgumentParser(description="DockerVault") missing_include: list[dict[str, Any]],
missing_review: list[dict[str, Any]],
) -> None:
all_missing = missing_include + missing_review
if not all_missing:
return
parser.add_argument("compose_file", nargs="?", default="docker-compose.yml") print("WARNING: Missing paths detected:")
parser.add_argument("--borg", action="store_true") for item in all_missing:
parser.add_argument("--borg-repo", default="/backup-repo") bucket = "include" if item in missing_include else "review"
parser.add_argument("--borg-archive", default="{hostname}-{now:%Y-%m-%d_%H-%M}") print(f" - {item['source']} (service={item['service']}, bucket={bucket})")
parser.add_argument("--fail-on-missing", action="store_true") print()
parser.add_argument("--apply-mkdir", action="store_true")
def print_created_paths(created_paths: list[str]) -> None:
if not created_paths:
return
print("Created missing paths:")
for path in created_paths:
print(f" - {path}")
print()
def plan_to_json_dict(
compose_file: Path,
project_root: Path,
plan: dict[str, Any],
borg_repo: str | None = None,
borg_archive: str | None = None,
borg_command: str | None = None,
missing_include: list[dict[str, Any]] | None = None,
missing_review: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
return {
"compose_file": str(compose_file.resolve()),
"project_root": str(project_root.resolve()),
"include": plan.get("include", []),
"review": plan.get("review", []),
"skip": plan.get("skip", []),
"missing": {
"include": missing_include or [],
"review": missing_review or [],
},
"borg": {
"repo": borg_repo,
"archive": borg_archive,
"command": borg_command,
}
if borg_repo or borg_archive or borg_command
else None,
}
def main() -> None:
parser = argparse.ArgumentParser(
description="DockerVault - intelligent Docker backup discovery"
)
parser.add_argument(
"compose_file",
nargs="?",
default="docker-compose.yml",
help="Path to docker-compose.yml",
)
parser.add_argument(
"--json",
action="store_true",
help="Print plan as JSON",
)
parser.add_argument(
"--borg",
action="store_true",
help="Show suggested borg create command",
)
parser.add_argument(
"--run-borg",
action="store_true",
help="Execute borg create",
)
parser.add_argument(
"--borg-repo",
default="/backup-repo",
help="Borg repository path or URI (default: /backup-repo)",
)
parser.add_argument(
"--borg-archive",
default="{hostname}-{now:%Y-%m-%d_%H-%M}",
help=(
"Archive naming template. Supported fields: "
"{hostname}, {project}, {compose_stem}, {now:...}"
),
)
parser.add_argument(
"--fail-on-missing",
action="store_true",
help="Exit with status 2 if include/review paths are missing",
)
parser.add_argument(
"--apply-mkdir",
action="store_true",
help="Create missing include/review paths",
)
args = parser.parse_args() args = parser.parse_args()
compose_file = Path(args.compose_file).resolve() compose_file = Path(args.compose_file).resolve()
if not compose_file.exists():
raise SystemExit(f"Compose file not found: {compose_file}")
project_root = compose_file.parent project_root = compose_file.parent
project_name = project_root.name project_name = project_root.name or compose_file.stem
plan = classify_compose(compose_file) plan = classify_compose(compose_file)
missing_include, missing_review = find_missing_paths(plan) missing_include, missing_review = find_missing_paths(plan)
all_missing = missing_include + missing_review all_missing = missing_include + missing_review
print_human_summary(compose_file, project_root, plan) created_paths: list[str] = []
if args.apply_mkdir and all_missing:
created_paths = create_missing_paths([item["source"] for item in all_missing])
missing_include, missing_review = find_missing_paths(plan)
all_missing = missing_include + missing_review
if all_missing: borg_command: str | None = None
print("WARNING: Missing paths detected:") borg_argv: list[str] | None = None
for item in all_missing: archive_name: str | None = None
print(f" - {item['source']} ({item['service']})")
paths = [item["source"] for item in all_missing] if args.borg or args.run_borg:
if args.apply_mkdir:
created = create_missing_paths(paths)
print()
print("Created missing paths:")
for p in created:
print(f" - {p}")
else:
print()
print("Suggested fix:")
print(build_mkdir_suggestion(paths))
if args.borg:
archive = render_borg_archive(args.borg_archive, project_name, compose_file)
include_paths = [item["source"] for item in plan.get("include", [])] include_paths = [item["source"] for item in plan.get("include", [])]
try:
archive_name = render_borg_archive(
args.borg_archive,
project_name,
compose_file,
)
except KeyError as exc:
raise SystemExit(
f"Invalid borg archive template field: {exc}. "
"Allowed: hostname, project, compose_stem, now"
) from exc
borg_command = build_borg_command(
repo=args.borg_repo,
archive_name=archive_name,
include_paths=include_paths,
)
borg_argv = build_borg_argv(
repo=args.borg_repo,
archive_name=archive_name,
include_paths=include_paths,
)
if args.json:
print(
json.dumps(
plan_to_json_dict(
compose_file=compose_file,
project_root=project_root,
plan=plan,
borg_repo=args.borg_repo if (args.borg or args.run_borg) else None,
borg_archive=archive_name,
borg_command=borg_command,
missing_include=missing_include,
missing_review=missing_review,
),
indent=2,
)
)
if args.fail_on_missing and all_missing:
sys.exit(2)
return
print_human_summary(compose_file, project_root, plan)
print_missing_paths_report(missing_include, missing_review)
print_created_paths(created_paths)
if all_missing and not args.apply_mkdir:
print("Suggested fix:")
print(build_mkdir_suggestion([item["source"] for item in all_missing]))
print() print()
if borg_command:
print("Suggested borg command:") print("Suggested borg command:")
print(build_borg_command(args.borg_repo, archive, include_paths)) print(borg_command)
print()
if args.fail_on_missing and all_missing: if args.fail_on_missing and all_missing:
print() print("ERROR: Failing because include/review paths are missing.")
print("ERROR: Missing required paths")
sys.exit(2) sys.exit(2)
if args.run_borg:
if borg_argv is None:
raise SystemExit("Internal error: borg command was not prepared")
print("Running borg create...")
print(" ".join(shlex.quote(part) for part in borg_argv))
print()
try:
completed = subprocess.run(borg_argv, check=False)
except FileNotFoundError as exc:
raise SystemExit("borg executable not found in PATH") from exc
if completed.returncode != 0:
raise SystemExit(completed.returncode)
if __name__ == "__main__": if __name__ == "__main__":
main() main()