feat: add missing path validation and fail-on-missing
This commit is contained in:
parent
dc9a6dc322
commit
b81b29e674
1 changed files with 306 additions and 80 deletions
|
|
@ -2,106 +2,332 @@ from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
|
import socket
|
||||||
import sys
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from dockervault.scanner import scan_projects
|
from dockervault.classifier import classify_compose
|
||||||
|
|
||||||
|
|
||||||
def build_parser() -> argparse.ArgumentParser:
|
def check_path_exists(path: str) -> bool:
|
||||||
parser = argparse.ArgumentParser(
|
return Path(path).exists()
|
||||||
prog="dockervault",
|
|
||||||
description="DockerVault CLI"
|
|
||||||
)
|
|
||||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
||||||
|
|
||||||
scan_parser = subparsers.add_parser(
|
|
||||||
"scan",
|
def build_mkdir_suggestion(paths: list[str]) -> str:
|
||||||
help="Scan a folder for Docker Compose projects"
|
unique_paths = sorted(set(paths))
|
||||||
)
|
lines = ["mkdir -p \\"]
|
||||||
scan_parser.add_argument(
|
for index, path in enumerate(unique_paths):
|
||||||
"path",
|
suffix = " \\" if index < len(unique_paths) - 1 else ""
|
||||||
nargs="?",
|
lines.append(f" {path}{suffix}")
|
||||||
default=".",
|
return "\n".join(lines)
|
||||||
help="Base path to scan (default: current directory)"
|
|
||||||
)
|
|
||||||
scan_parser.add_argument(
|
def render_borg_archive(template: str, project: str, compose_path: Path) -> str:
|
||||||
"--json",
|
now = datetime.now()
|
||||||
action="store_true",
|
hostname = socket.gethostname()
|
||||||
help="Output scan results as JSON"
|
compose_stem = compose_path.stem
|
||||||
|
|
||||||
|
return template.format(
|
||||||
|
hostname=hostname,
|
||||||
|
project=project,
|
||||||
|
compose_stem=compose_stem,
|
||||||
|
now=now,
|
||||||
)
|
)
|
||||||
|
|
||||||
return parser
|
|
||||||
|
|
||||||
|
def build_borg_command(
|
||||||
|
repo: str,
|
||||||
|
archive_name: str,
|
||||||
|
include_paths: list[str],
|
||||||
|
) -> str:
|
||||||
|
lines = [
|
||||||
|
"borg create --stats --progress \\",
|
||||||
|
f" {repo}::{archive_name} \\",
|
||||||
|
]
|
||||||
|
|
||||||
def render_text(projects: list) -> str:
|
for index, path in enumerate(include_paths):
|
||||||
if not projects:
|
suffix = " \\" if index < len(include_paths) - 1 else ""
|
||||||
return "No Docker Compose projects found."
|
lines.append(f" {path}{suffix}")
|
||||||
|
|
||||||
lines: list[str] = []
|
|
||||||
lines.append(f"Found {len(projects)} project(s):")
|
|
||||||
|
|
||||||
for project in projects:
|
|
||||||
lines.append("")
|
|
||||||
lines.append(f"- {project.name}")
|
|
||||||
lines.append(f" Path: {project.root_path}")
|
|
||||||
lines.append(f" Compose files: {', '.join(project.compose_files) or '-'}")
|
|
||||||
lines.append(f" Services: {', '.join(project.service_names) or '-'}")
|
|
||||||
lines.append(f" Named volumes: {', '.join(project.named_volumes) or '-'}")
|
|
||||||
|
|
||||||
if project.backup_paths:
|
|
||||||
lines.append(" Backup candidates:")
|
|
||||||
for backup_path in project.backup_paths:
|
|
||||||
lines.append(f" - {backup_path}")
|
|
||||||
else:
|
|
||||||
lines.append(" Backup candidates: -")
|
|
||||||
|
|
||||||
for service in project.services:
|
|
||||||
lines.append(f" Service: {service.name}")
|
|
||||||
lines.append(f" Image: {service.image or '-'}")
|
|
||||||
lines.append(f" Restart: {service.restart or '-'}")
|
|
||||||
lines.append(f" Env files: {', '.join(service.env_files) or '-'}")
|
|
||||||
|
|
||||||
if service.mounts:
|
|
||||||
lines.append(" Mounts:")
|
|
||||||
for mount in service.mounts:
|
|
||||||
ro = " (ro)" if mount.read_only else ""
|
|
||||||
lines.append(
|
|
||||||
f" - {mount.kind}: {mount.source or '[anonymous]'} -> {mount.target}{ro}"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
lines.append(" Mounts: -")
|
|
||||||
|
|
||||||
return "\n".join(lines)
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def plan_to_json_dict(
|
||||||
parser = build_parser()
|
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 print_human_summary(compose_file: Path, project_root: Path, plan: dict[str, Any]) -> None:
|
||||||
|
print("DockerVault Backup Plan")
|
||||||
|
print("=======================")
|
||||||
|
print(f"Compose file: {compose_file.resolve()}")
|
||||||
|
print(f"Project root: {project_root.resolve()}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
print("INCLUDE PATHS:")
|
||||||
|
include = plan.get("include", [])
|
||||||
|
if include:
|
||||||
|
for item in include:
|
||||||
|
exists = check_path_exists(item["source"])
|
||||||
|
status = "✔ exists" if exists else "❌ missing"
|
||||||
|
|
||||||
|
print(
|
||||||
|
f" - {item['source']} "
|
||||||
|
f"[{item['priority']}] {status} "
|
||||||
|
f"service={item['service']} target={item['target']}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print(" - (none)")
|
||||||
|
print()
|
||||||
|
|
||||||
|
print("REVIEW PATHS:")
|
||||||
|
review = plan.get("review", [])
|
||||||
|
if review:
|
||||||
|
for item in review:
|
||||||
|
exists = check_path_exists(item["source"])
|
||||||
|
status = "✔ exists" if exists else "❌ missing"
|
||||||
|
|
||||||
|
print(
|
||||||
|
f" - {item['source']} "
|
||||||
|
f"[{item['priority']}] {status} "
|
||||||
|
f"service={item['service']} target={item['target']}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print(" - (none)")
|
||||||
|
print()
|
||||||
|
|
||||||
|
print("SKIP PATHS:")
|
||||||
|
skip = plan.get("skip", [])
|
||||||
|
if skip:
|
||||||
|
for item in skip:
|
||||||
|
exists = check_path_exists(item["source"])
|
||||||
|
status = "✔ exists" if exists else "❌ missing"
|
||||||
|
|
||||||
|
print(
|
||||||
|
f" - {item['source']} "
|
||||||
|
f"[{item['priority']}] {status} "
|
||||||
|
f"service={item['service']} target={item['target']}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print(" - (none)")
|
||||||
|
|
||||||
|
|
||||||
|
def find_missing_paths(plan: dict[str, Any]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||||||
|
missing_include = [
|
||||||
|
item for item in plan.get("include", [])
|
||||||
|
if not check_path_exists(item["source"])
|
||||||
|
]
|
||||||
|
|
||||||
|
missing_review = [
|
||||||
|
item for item in plan.get("review", [])
|
||||||
|
if not check_path_exists(item["source"])
|
||||||
|
]
|
||||||
|
|
||||||
|
return missing_include, missing_review
|
||||||
|
|
||||||
|
|
||||||
|
def print_missing_paths_report(
|
||||||
|
missing_include: list[dict[str, Any]],
|
||||||
|
missing_review: list[dict[str, Any]],
|
||||||
|
) -> None:
|
||||||
|
all_missing = missing_include + missing_review
|
||||||
|
|
||||||
|
if not all_missing:
|
||||||
|
return
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("WARNING: Missing paths detected:")
|
||||||
|
for item in all_missing:
|
||||||
|
bucket = "include" if item in missing_include else "review"
|
||||||
|
print(f" - {item['source']} (service={item['service']}, bucket={bucket})")
|
||||||
|
|
||||||
|
mkdir_paths = [item["source"] for item in all_missing]
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("Suggested fix for missing paths")
|
||||||
|
print("================================")
|
||||||
|
print(build_mkdir_suggestion(mkdir_paths))
|
||||||
|
|
||||||
|
|
||||||
|
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(
|
||||||
|
"--summary-only",
|
||||||
|
action="store_true",
|
||||||
|
help="Print human-readable summary only",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--json",
|
||||||
|
action="store_true",
|
||||||
|
help="Print backup plan as JSON",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--borg",
|
||||||
|
action="store_true",
|
||||||
|
help="Generate borg backup command output",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--borg-json",
|
||||||
|
action="store_true",
|
||||||
|
help="Print borg-related output as JSON",
|
||||||
|
)
|
||||||
|
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
if args.command == "scan":
|
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_name = project_root.name or compose_file.stem
|
||||||
|
|
||||||
|
plan = classify_compose(compose_file)
|
||||||
|
missing_include, missing_review = find_missing_paths(plan)
|
||||||
|
all_missing = missing_include + missing_review
|
||||||
|
|
||||||
|
if args.json:
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
plan_to_json_dict(
|
||||||
|
compose_file,
|
||||||
|
project_root,
|
||||||
|
plan,
|
||||||
|
missing_include=missing_include,
|
||||||
|
missing_review=missing_review,
|
||||||
|
),
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if args.fail_on_missing and all_missing:
|
||||||
|
sys.exit(2)
|
||||||
|
return
|
||||||
|
|
||||||
|
if args.borg or args.borg_json:
|
||||||
|
include_items = plan.get("include", [])
|
||||||
|
include_paths = [item["source"] for item in include_items]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
projects = scan_projects(Path(args.path))
|
archive_name = render_borg_archive(
|
||||||
except (FileNotFoundError, NotADirectoryError) as exc:
|
args.borg_archive,
|
||||||
print(f"Error: {exc}", file=sys.stderr)
|
project_name,
|
||||||
return 2
|
compose_file,
|
||||||
except json.JSONDecodeError as exc:
|
)
|
||||||
print(f"Error: invalid JSON/YAML data: {exc}", file=sys.stderr)
|
except KeyError as exc:
|
||||||
return 2
|
raise SystemExit(
|
||||||
except Exception as exc:
|
f"Invalid borg archive template field: {exc}. "
|
||||||
print(f"Error: {exc}", file=sys.stderr)
|
"Allowed: hostname, project, compose_stem, now"
|
||||||
return 2
|
) from exc
|
||||||
|
|
||||||
if args.json:
|
borg_command = build_borg_command(
|
||||||
print(json.dumps([project.to_dict() for project in projects], indent=2))
|
repo=args.borg_repo,
|
||||||
else:
|
archive_name=archive_name,
|
||||||
print(render_text(projects))
|
include_paths=include_paths,
|
||||||
|
)
|
||||||
|
|
||||||
return 0
|
if args.borg_json:
|
||||||
|
print(
|
||||||
|
json.dumps(
|
||||||
|
plan_to_json_dict(
|
||||||
|
compose_file,
|
||||||
|
project_root,
|
||||||
|
plan,
|
||||||
|
borg_repo=args.borg_repo,
|
||||||
|
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
|
||||||
|
|
||||||
parser.print_help()
|
print_human_summary(compose_file, project_root, plan)
|
||||||
return 1
|
print_missing_paths_report(missing_include, missing_review)
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("Suggested borg create command")
|
||||||
|
print("=============================")
|
||||||
|
print(borg_command)
|
||||||
|
|
||||||
|
if args.fail_on_missing and all_missing:
|
||||||
|
print()
|
||||||
|
print("ERROR: Failing because include/review paths are missing.")
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
return
|
||||||
|
|
||||||
|
print_human_summary(compose_file, project_root, plan)
|
||||||
|
print_missing_paths_report(missing_include, missing_review)
|
||||||
|
|
||||||
|
if args.fail_on_missing and all_missing:
|
||||||
|
print()
|
||||||
|
print("ERROR: Failing because include/review paths are missing.")
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
raise SystemExit(main())
|
main()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue