From cf630318db0d8c34d400ef20551444154ccd08c7 Mon Sep 17 00:00:00 2001 From: Eddie Nielsen <“ed”@edcore.dk”> Date: Sun, 22 Mar 2026 11:53:15 +0000 Subject: [PATCH] feat: add initial CLI skeleton and scan command --- dockervault/__init__.py | 4 + .../__pycache__/__init__.cpython-310.pyc | Bin 0 -> 226 bytes dockervault/__pycache__/cli.cpython-310.pyc | Bin 0 -> 2191 bytes .../__pycache__/models.cpython-310.pyc | Bin 0 -> 1109 bytes .../__pycache__/scanner.cpython-310.pyc | Bin 0 -> 2527 bytes dockervault/cli.py | 77 +++++++++++++++ dockervault/models.py | 27 +++++ dockervault/scanner.py | 93 ++++++++++++++++++ pyproject.toml | 32 ++++++ 9 files changed, 233 insertions(+) create mode 100644 dockervault/__init__.py create mode 100644 dockervault/__pycache__/__init__.cpython-310.pyc create mode 100644 dockervault/__pycache__/cli.cpython-310.pyc create mode 100644 dockervault/__pycache__/models.cpython-310.pyc create mode 100644 dockervault/__pycache__/scanner.cpython-310.pyc create mode 100644 dockervault/cli.py create mode 100644 dockervault/models.py create mode 100644 dockervault/scanner.py create mode 100644 pyproject.toml 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 0000000000000000000000000000000000000000..3d95bd7f89cafacc331ed8deff62e252e982ae45 GIT binary patch literal 226 zcmd1j<>g`kg4tL1XZir?#~=v0lqz4mrI zOPkdO0qJWVcrKNiMUaoh|Hr|aQ>Sp0U=e;;Gg>`johB`ZZtE;yLU0uZOn|f>5k2AAQoQ}xd^r6sP z{BKiu>mUs4afO4GvoChpnDvB`s-j$Qe9U_e1)yfYiLvNO<*OP6qMG}Xvr>X!)SQ~% zcfd1Te__qmR31Hy^>^K2YDTlGCG#~g!R;?UZwV`L_bph(MWmAf#b>Kg-tTua^%be1 zFxJ!YqhX?BWhKQsYIU#%-C#3MRjl#S!NhZLJMRzjA`Ye*p;T?h=>X>-VTb&NX_;*Ebe(Lot5jGFACyJZ zh4qP_Ft$!Kc9wO;<=i8=f5N^t!9`j0Uzxsu`yO6d-p>2+a;%o8*;zhOoKtj^ChftF ztv?th>Ga$hd=&3vkzC;CzG_M-CKQNL2I*hYozU*z($xXXtv5Z+$}~YWox-PPxoj+)v$*j;7+aj+UU|+{(pc27>Ai^kKDWT z4o3AU6fYO870X6qDHwS{K<&E{jOK!4#BL^OTtFl~2oCuY_5~fZO%jpNa=%ptcm{(w zQ`Sl2%*w3WkL`@ka|2wMZ7X|ulGz4&hZ-O(-B}MkU;1b{`VtarD`}F&0P;~uN?=Tu zi=?gr`sk?8#81q75EN&yNRFR4!#&~idHl}v3)~m_3T<$b%tRoL9|?*kYB^MX17X-6 zqha!bAC%R^P~n(#>!TUt80559+2ebBSA4^=i;@}FR5!)WN1W|qh>Yfpr^L9dpPA|T zG?+O1hJB0ayX$=KDA_rp9`nZxzH0h2^JzaUI0e2wvR; zwaSd9R(eTGYj)CbemM|mL=>88q3NQUx=hbvv2%A3(pqjxh?bGsd9XKErz3A7s|+-% zi+0_?hovyUg$BxK;#JhJKPV;$@Jp+ZhHOjfbL3x^Czp70;wRDTp-PJBDh!Kvu(;d< znna1LMc(Az58{<8&vPm3ucYvVzDv?0iw}uz zT0aaohxjbUVW=ssnn0~@(DDfuGz0o4O&*jPvI{3>VwdX@!0UqttEyE=5mK90N_$y^tYz(}TfA|wHh;?gOx}}$TrJwqB%#}eIrXeN5;~w{~={v#$9$pb1UOJaFjZO%Ue$uqX zmw1oIS6t(nKI%~t5X5~u~=X3!R+8`6zL0AzO75Pdo>6qNmQ9?B$ zMq-SRlN+K}!M%MmI8mh-2tJrKvzh@{`Df^bb3L$2#f3EMvvV6(ied8@Di~}S(Tk@K zA+O)1j1oupAww_0mb7fd7}CI)Z8HWH=MwEMV_)W(ygTVLwjeWO+K0Q=LuzynYhhpg z1UJO6u&tqsz(e?&E^KWQIT2lfPwdnuoA_pLJ9p%+pO^Bsd+%i6tu**w-3KAI`ZxxO z)0OcBlEwKKR139ndz+q61@AOA4iC%^Pkb}2usD)ATOn^u)z<-3u##5!sOcocS zemKt|rKY`0^-HiviPv0vI7`w&jwf??EWoiwoowg+k0MO{9yHR?5F8>NS$t|BA&d6e z+u`o^@!k>JIoLlu7!TR*`@P|4dw)2t8!yaR3xV|8XrqyJ6JMi2sk&s%>C(tuqES1J F{sATB4ln=! literal 0 HcmV?d00001 diff --git a/dockervault/__pycache__/scanner.cpython-310.pyc b/dockervault/__pycache__/scanner.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..25916c1c44fc59c437abb0a2a986ed03c2be508e GIT binary patch literal 2527 zcmZ`*Pj4GV6yKShUE6CXEpD3rp@&sl5H5}TM;zKvDoW!>6>VA(3c|E(H=aqd>3Y|l znY4{rONjJH58RL{B>TXDZ@`UD!DX(T_zDpcyf>SKw!m7u`}WPun>X+M-tRe|nhG4a zUi|iJ@&ib}60!YDASa7DZJg(?XxbkI+l8Uzw7ra-AcdO ztrADjagBQu(hc}54|x3<>(==ZKFtq+)Zj;XlOFqxf3nkxY=2{ zcB`{&rn6l3<1`tHXaI|cC%(F;zP>nm=gVtz-FR+ze(rMAp1c2UYyO@(bK2BaVpUF* zczEMWp*Ll=6QqgKU*hifdjy@^y>JqP`#MJH5(sO~h!oBkVl^QP&i#WUTF^0>IPyqA zH>h?WqeRB6pc9DWql)$lvQ6O3ba-Jg$#|G14@J5aF66z3LWaBN3|Es>sBkmmpxbw` zZOECHZ#=n{<}0R>s0gPSmc@M`J#fbO@n9e_ZhVzXEqKe76;xOHvF@!4Wf+F!XGbnycV-)GPC09Z+?!rW){Ibec9v zKzXu)?|*RTPKKv zzuj?{v*$M4Bl3llktYuKMif%w7MP5SBUZ4n3+uhYljjR>%(y=x^1`E21y%Ef$14x1 zyv(a33K|BZwFyy+RtiuePIgdxW4~Y%GV%*w-Ywj5rSQj9UY|h1A)%*qO$UX$T_&|L z{3!_Gx|yu4>u?ZD1^HXeWeD%bNl%2BD%B3`q>qIdsE`ZHn8L00YFOLf@zWg=?vUCaSQD+f zsYwy@NQWn-NN8Ko?L(4g%C0 zRN?|NwFh-uyE`)$>$~;5%Ul13eGOJx*nbB?Vz~r>+W;0JV+R<7tT_`p22QbHV+d^x z;2yJyvyBixbB~VL$kX1o0|50vQ-ihs*w>YUZkG@}381CATCfd-D%&PlSkD}=dft*f_SszTFEXL!~oJlf-;Xk{x}%~K%2_7wnmoT{rJ>@93DyA1#^ z&$T6bIR|EB3-_)A%7o@(6?x3~kZ!CAL{$kVFVUi}(O zZJLU8Oz?lm%+hXk&r{=))I6nd!zQs;OK|8BmPXEzrRsae{ zWWP7LOT)w1`$C=rkw8Zd#Z!pVpU>Okyv7&Me?vczSLr&gVuruXO!A@seR3Y)gCZG6s>N;zXK6P5;1G26sCfW zElpO+zYooq8fd!@+?T2nRM?#@s7wWck5zFBRoEm;>|sjn30?w>je+H?>J_lInTo4O Xyte)tPL(#u3=L@D2KC^r>EnL`{mzls literal 0 HcmV?d00001 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*"]