diff --git a/dockervault/__init__.py b/dockervault/__init__.py new file mode 100644 index 0000000..b770313 --- /dev/null +++ b/dockervault/__init__.py @@ -0,0 +1,4 @@ +"""DockerVault package.""" + +__all__ = ["__version__"] +__version__ = "0.1.0" diff --git a/dockervault/__pycache__/__init__.cpython-310.pyc b/dockervault/__pycache__/__init__.cpython-310.pyc new file mode 100644 index 0000000..3d95bd7 Binary files /dev/null and b/dockervault/__pycache__/__init__.cpython-310.pyc differ diff --git a/dockervault/__pycache__/cli.cpython-310.pyc b/dockervault/__pycache__/cli.cpython-310.pyc new file mode 100644 index 0000000..aed7f61 Binary files /dev/null and b/dockervault/__pycache__/cli.cpython-310.pyc differ diff --git a/dockervault/__pycache__/models.cpython-310.pyc b/dockervault/__pycache__/models.cpython-310.pyc new file mode 100644 index 0000000..90128e9 Binary files /dev/null and b/dockervault/__pycache__/models.cpython-310.pyc differ diff --git a/dockervault/__pycache__/scanner.cpython-310.pyc b/dockervault/__pycache__/scanner.cpython-310.pyc new file mode 100644 index 0000000..25916c1 Binary files /dev/null and b/dockervault/__pycache__/scanner.cpython-310.pyc differ diff --git a/dockervault/cli.py b/dockervault/cli.py new file mode 100644 index 0000000..73f0982 --- /dev/null +++ b/dockervault/cli.py @@ -0,0 +1,77 @@ +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +from dockervault.scanner import scan_projects + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="dockervault", + description="DockerVault CLI" + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + scan_parser = subparsers.add_parser( + "scan", + help="Scan a folder for Docker Compose projects" + ) + scan_parser.add_argument( + "path", + nargs="?", + default=".", + help="Base path to scan (default: current directory)" + ) + scan_parser.add_argument( + "--json", + action="store_true", + help="Output scan results as JSON" + ) + + return parser + + +def render_text(projects: list) -> str: + if not projects: + return "No Docker Compose projects found." + + 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.services) or '-'}") + + return "\n".join(lines) + + +def main() -> int: + parser = build_parser() + args = parser.parse_args() + + if args.command == "scan": + try: + projects = scan_projects(Path(args.path)) + except (FileNotFoundError, NotADirectoryError) as exc: + print(f"Error: {exc}", file=sys.stderr) + return 2 + + if args.json: + print(json.dumps([project.to_dict() for project in projects], indent=2)) + else: + print(render_text(projects)) + + return 0 + + parser.print_help() + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/dockervault/models.py b/dockervault/models.py new file mode 100644 index 0000000..4f161da --- /dev/null +++ b/dockervault/models.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from pathlib import Path + + +@dataclass(slots=True) +class ComposeProject: + name: str + root_path: str + compose_files: list[str] = field(default_factory=list) + services: list[str] = field(default_factory=list) + + def to_dict(self) -> dict: + return asdict(self) + + +DEFAULT_COMPOSE_FILENAMES = { + "docker-compose.yml", + "docker-compose.yaml", + "compose.yml", + "compose.yaml", +} + + +def normalize_path(path: Path) -> str: + return str(path.resolve()) diff --git a/dockervault/scanner.py b/dockervault/scanner.py new file mode 100644 index 0000000..ca814b1 --- /dev/null +++ b/dockervault/scanner.py @@ -0,0 +1,93 @@ +from __future__ import annotations + +import re +from pathlib import Path + +from dockervault.models import ComposeProject, DEFAULT_COMPOSE_FILENAMES, normalize_path + +SERVICE_LINE_RE = re.compile(r"^\s{2}([A-Za-z0-9_.-]+):\s*$") + + +def find_compose_files(base_path: Path) -> list[Path]: + """Find likely Docker Compose files under base_path.""" + matches: list[Path] = [] + + for path in base_path.rglob("*"): + if path.is_file() and path.name in DEFAULT_COMPOSE_FILENAMES: + matches.append(path) + + return sorted(matches) + + +def parse_services_from_compose(compose_path: Path) -> list[str]: + """ + Light parser for service names. + + Keeps dependencies minimal for v0. + It looks for the `services:` block and collects entries indented by two spaces. + """ + try: + lines = compose_path.read_text(encoding="utf-8").splitlines() + except UnicodeDecodeError: + lines = compose_path.read_text(encoding="utf-8", errors="ignore").splitlines() + + in_services = False + services: list[str] = [] + + for line in lines: + stripped = line.strip() + + if not stripped or stripped.startswith("#"): + continue + + if not in_services: + if stripped == "services:": + in_services = True + continue + + # Leaving top-level services block + if not line.startswith(" ") and not line.startswith("\t"): + break + + match = SERVICE_LINE_RE.match(line) + if match: + services.append(match.group(1)) + + return sorted(set(services)) + + +def group_projects_by_folder(compose_files: list[Path]) -> list[ComposeProject]: + grouped: dict[Path, list[Path]] = {} + + for compose_file in compose_files: + grouped.setdefault(compose_file.parent, []).append(compose_file) + + projects: list[ComposeProject] = [] + + for folder, files in sorted(grouped.items()): + service_names: set[str] = set() + + for compose_file in files: + service_names.update(parse_services_from_compose(compose_file)) + + projects.append( + ComposeProject( + name=folder.name, + root_path=normalize_path(folder), + compose_files=[file.name for file in sorted(files)], + services=sorted(service_names), + ) + ) + + return projects + + +def scan_projects(base_path: Path) -> list[ComposeProject]: + if not base_path.exists(): + raise FileNotFoundError(f"Path does not exist: {base_path}") + + if not base_path.is_dir(): + raise NotADirectoryError(f"Path is not a directory: {base_path}") + + compose_files = find_compose_files(base_path) + return group_projects_by_folder(compose_files) diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b7dd3b7 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,32 @@ +[build-system] +requires = ["setuptools>=68", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "dockervault" +version = "0.1.0" +description = "CLI backup discovery tool for Docker environments" +readme = "README.md" +requires-python = ">=3.10" +authors = [ + { name = "Ed & NodeFox" } +] +license = { text = "MIT" } +keywords = ["docker", "backup", "cli", "borg", "inventory"] +classifiers = [ + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.11", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent" +] + +[project.scripts] +dockervault = "dockervault.cli:main" + +[tool.setuptools] +package-dir = {"" = "."} + +[tool.setuptools.packages.find] +where = ["."] +include = ["dockervault*"]