feat: add initial CLI skeleton and scan command
This commit is contained in:
parent
b892738845
commit
cf630318db
9 changed files with 233 additions and 0 deletions
4
dockervault/__init__.py
Normal file
4
dockervault/__init__.py
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
"""DockerVault package."""
|
||||||
|
|
||||||
|
__all__ = ["__version__"]
|
||||||
|
__version__ = "0.1.0"
|
||||||
BIN
dockervault/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
dockervault/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
BIN
dockervault/__pycache__/cli.cpython-310.pyc
Normal file
BIN
dockervault/__pycache__/cli.cpython-310.pyc
Normal file
Binary file not shown.
BIN
dockervault/__pycache__/models.cpython-310.pyc
Normal file
BIN
dockervault/__pycache__/models.cpython-310.pyc
Normal file
Binary file not shown.
BIN
dockervault/__pycache__/scanner.cpython-310.pyc
Normal file
BIN
dockervault/__pycache__/scanner.cpython-310.pyc
Normal file
Binary file not shown.
77
dockervault/cli.py
Normal file
77
dockervault/cli.py
Normal file
|
|
@ -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())
|
||||||
27
dockervault/models.py
Normal file
27
dockervault/models.py
Normal file
|
|
@ -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())
|
||||||
93
dockervault/scanner.py
Normal file
93
dockervault/scanner.py
Normal file
|
|
@ -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)
|
||||||
32
pyproject.toml
Normal file
32
pyproject.toml
Normal file
|
|
@ -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*"]
|
||||||
Loading…
Add table
Add a link
Reference in a new issue