diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cecc186 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.venv/ +*.egg-info/ +__pycache__/ +*.pyc diff --git a/dockervault/__pycache__/__init__.cpython-310.pyc b/dockervault/__pycache__/__init__.cpython-310.pyc deleted file mode 100644 index 3d95bd7..0000000 Binary files a/dockervault/__pycache__/__init__.cpython-310.pyc and /dev/null differ diff --git a/dockervault/__pycache__/cli.cpython-310.pyc b/dockervault/__pycache__/cli.cpython-310.pyc deleted file mode 100644 index aed7f61..0000000 Binary files a/dockervault/__pycache__/cli.cpython-310.pyc and /dev/null differ diff --git a/dockervault/__pycache__/models.cpython-310.pyc b/dockervault/__pycache__/models.cpython-310.pyc deleted file mode 100644 index 90128e9..0000000 Binary files a/dockervault/__pycache__/models.cpython-310.pyc and /dev/null differ diff --git a/dockervault/__pycache__/scanner.cpython-310.pyc b/dockervault/__pycache__/scanner.cpython-310.pyc deleted file mode 100644 index 25916c1..0000000 Binary files a/dockervault/__pycache__/scanner.cpython-310.pyc and /dev/null differ diff --git a/dockervault/cli.py b/dockervault/cli.py index 73f0982..2469eba 100644 --- a/dockervault/cli.py +++ b/dockervault/cli.py @@ -46,7 +46,31 @@ def render_text(projects: list) -> str: 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 '-'}") + 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) @@ -61,6 +85,12 @@ def main() -> int: except (FileNotFoundError, NotADirectoryError) as exc: print(f"Error: {exc}", file=sys.stderr) return 2 + except json.JSONDecodeError as exc: + print(f"Error: invalid JSON/YAML data: {exc}", file=sys.stderr) + return 2 + except Exception 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)) diff --git a/dockervault/models.py b/dockervault/models.py index 4f161da..f8ed090 100644 --- a/dockervault/models.py +++ b/dockervault/models.py @@ -4,15 +4,51 @@ from dataclasses import asdict, dataclass, field from pathlib import Path +@dataclass(slots=True) +class MountMapping: + source: str + target: str + kind: str + read_only: bool = False + + def to_dict(self) -> dict: + return asdict(self) + + +@dataclass(slots=True) +class ServiceDefinition: + name: str + image: str | None = None + restart: str | None = None + env_files: list[str] = field(default_factory=list) + mounts: list[MountMapping] = field(default_factory=list) + + def to_dict(self) -> dict: + return asdict(self) + + @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) + services: list[ServiceDefinition] = field(default_factory=list) + named_volumes: list[str] = field(default_factory=list) + backup_paths: list[str] = field(default_factory=list) def to_dict(self) -> dict: - return asdict(self) + return { + "name": self.name, + "root_path": self.root_path, + "compose_files": self.compose_files, + "services": [service.to_dict() for service in self.services], + "named_volumes": self.named_volumes, + "backup_paths": self.backup_paths, + } + + @property + def service_names(self) -> list[str]: + return [service.name for service in self.services] DEFAULT_COMPOSE_FILENAMES = { diff --git a/dockervault/scanner.py b/dockervault/scanner.py index ca814b1..d4fbf2c 100644 --- a/dockervault/scanner.py +++ b/dockervault/scanner.py @@ -1,11 +1,17 @@ from __future__ import annotations -import re from pathlib import Path +from typing import Any -from dockervault.models import ComposeProject, DEFAULT_COMPOSE_FILENAMES, normalize_path +import yaml -SERVICE_LINE_RE = re.compile(r"^\s{2}([A-Za-z0-9_.-]+):\s*$") +from dockervault.models import ( + ComposeProject, + DEFAULT_COMPOSE_FILENAMES, + MountMapping, + ServiceDefinition, + normalize_path, +) def find_compose_files(base_path: Path) -> list[Path]: @@ -19,41 +25,176 @@ def find_compose_files(base_path: Path) -> list[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. - """ +def load_yaml_file(compose_path: Path) -> dict[str, Any]: try: - lines = compose_path.read_text(encoding="utf-8").splitlines() + content = compose_path.read_text(encoding="utf-8") except UnicodeDecodeError: - lines = compose_path.read_text(encoding="utf-8", errors="ignore").splitlines() + content = compose_path.read_text(encoding="utf-8", errors="ignore") - in_services = False - services: list[str] = [] + data = yaml.safe_load(content) or {} + if not isinstance(data, dict): + return {} + return data - for line in lines: - stripped = line.strip() - if not stripped or stripped.startswith("#"): +def parse_env_files(value: Any) -> list[str]: + if isinstance(value, str): + return [value] + + if isinstance(value, list): + items: list[str] = [] + for item in value: + if isinstance(item, str): + items.append(item) + elif isinstance(item, dict): + path = item.get("path") + if isinstance(path, str): + items.append(path) + return sorted(set(items)) + + return [] + + +def normalize_volume_dict(volume: dict[str, Any]) -> MountMapping | None: + source = volume.get("source") or volume.get("src") or "" + target = volume.get("target") or volume.get("dst") or volume.get("destination") or "" + if not isinstance(target, str) or not target: + return None + + kind = volume.get("type") or ("bind" if source and str(source).startswith(("/", ".", "~")) else "volume") + read_only = bool(volume.get("read_only") or volume.get("readonly")) + + return MountMapping( + source=str(source), + target=target, + kind=str(kind), + read_only=read_only, + ) + + +def normalize_volume_string(value: str) -> MountMapping | None: + parts = value.split(":") + if len(parts) == 1: + return MountMapping(source="", target=parts[0], kind="anonymous", read_only=False) + + if len(parts) >= 2: + source = parts[0] + target = parts[1] + options = parts[2:] + read_only = any(option == "ro" for option in options) + + if source.startswith(("/", ".", "~")): + kind = "bind" + else: + kind = "volume" + + return MountMapping(source=source, target=target, kind=kind, read_only=read_only) + + return None + + +def parse_mounts(value: Any) -> list[MountMapping]: + mounts: list[MountMapping] = [] + + if not isinstance(value, list): + return mounts + + for item in value: + mapping: MountMapping | None = None + if isinstance(item, str): + mapping = normalize_volume_string(item) + elif isinstance(item, dict): + mapping = normalize_volume_dict(item) + + if mapping: + mounts.append(mapping) + + return mounts + + +def parse_service_definition(name: str, data: Any) -> ServiceDefinition: + if not isinstance(data, dict): + return ServiceDefinition(name=name) + + mounts = parse_mounts(data.get("volumes", [])) + env_files = parse_env_files(data.get("env_file")) + + return ServiceDefinition( + name=name, + image=data.get("image") if isinstance(data.get("image"), str) else None, + restart=data.get("restart") if isinstance(data.get("restart"), str) else None, + env_files=env_files, + mounts=mounts, + ) + + +def merge_service(existing: ServiceDefinition, incoming: ServiceDefinition) -> ServiceDefinition: + mounts_by_key: dict[tuple[str, str, str, bool], MountMapping] = { + (mount.source, mount.target, mount.kind, mount.read_only): mount + for mount in existing.mounts + } + for mount in incoming.mounts: + mounts_by_key[(mount.source, mount.target, mount.kind, mount.read_only)] = mount + + env_files = sorted(set(existing.env_files) | set(incoming.env_files)) + + return ServiceDefinition( + name=existing.name, + image=incoming.image or existing.image, + restart=incoming.restart or existing.restart, + env_files=env_files, + mounts=sorted(mounts_by_key.values(), key=lambda item: (item.target, item.source, item.kind)), + ) + + +def extract_project_from_compose(folder: Path, compose_files: list[Path]) -> ComposeProject: + services_by_name: dict[str, ServiceDefinition] = {} + named_volumes: set[str] = set() + backup_paths: set[str] = set() + + for compose_file in sorted(compose_files): + data = load_yaml_file(compose_file) + + for volume_name in (data.get("volumes") or {}).keys() if isinstance(data.get("volumes"), dict) else []: + if isinstance(volume_name, str): + named_volumes.add(volume_name) + + raw_services = data.get("services") or {} + if not isinstance(raw_services, dict): continue - if not in_services: - if stripped == "services:": - in_services = True - continue + for service_name, service_data in raw_services.items(): + if not isinstance(service_name, str): + continue - # Leaving top-level services block - if not line.startswith(" ") and not line.startswith("\t"): - break + incoming = parse_service_definition(service_name, service_data) + if service_name in services_by_name: + services_by_name[service_name] = merge_service(services_by_name[service_name], incoming) + else: + services_by_name[service_name] = incoming - match = SERVICE_LINE_RE.match(line) - if match: - services.append(match.group(1)) + for service in services_by_name.values(): + for mount in service.mounts: + if mount.kind == "bind" and mount.source: + candidate = Path(mount.source).expanduser() + if not candidate.is_absolute(): + candidate = (folder / candidate).resolve() + backup_paths.add(str(candidate)) - return sorted(set(services)) + for env_file in service.env_files: + candidate = Path(env_file).expanduser() + if not candidate.is_absolute(): + candidate = (folder / candidate).resolve() + backup_paths.add(str(candidate)) + + return ComposeProject( + name=folder.name, + root_path=normalize_path(folder), + compose_files=[file.name for file in sorted(compose_files)], + services=sorted(services_by_name.values(), key=lambda item: item.name), + named_volumes=sorted(named_volumes), + backup_paths=sorted(backup_paths), + ) def group_projects_by_folder(compose_files: list[Path]) -> list[ComposeProject]: @@ -65,19 +206,7 @@ def group_projects_by_folder(compose_files: list[Path]) -> list[ComposeProject]: 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), - ) - ) + projects.append(extract_project_from_compose(folder, files)) return projects diff --git a/pyproject.toml b/pyproject.toml index b7dd3b7..652c56e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "dockervault" -version = "0.1.0" +version = "0.2.0" description = "CLI backup discovery tool for Docker environments" readme = "README.md" requires-python = ">=3.10" @@ -16,11 +16,16 @@ keywords = ["docker", "backup", "cli", "borg", "inventory"] classifiers = [ "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "License :: OSI Approved :: MIT License", "Operating System :: OS Independent" ] +dependencies = [ + "PyYAML>=6.0", +] + [project.scripts] dockervault = "dockervault.cli:main"