From 227be6dd51239d7ee9b9080bc95452ec7f989c4e Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Mon, 15 Dec 2025 11:04:54 +1100 Subject: [PATCH 1/5] Many tweaks --- .forgejo/workflows/ci.yml | 46 +++++ .forgejo/workflows/lint.yml | 40 ++++ .forgejo/workflows/trivy.yml | 40 ++++ CHANGELOG.txt | 13 ++ README.md | 12 +- enroll/accounts.py | 41 +++-- enroll/cli.py | 40 ++-- enroll/debian.py | 17 +- enroll/harvest.py | 344 ++++++++++++++++++++++++++--------- enroll/manifest.py | 208 +++++++++++++++++---- enroll/secrets.py | 15 +- enroll/systemd.py | 53 ++++-- poetry.lock | 293 ++++++++++++++++++++++++++++- pyproject.toml | 6 +- tests.sh | 8 + tests/conftest.py | 7 + tests/test_cli.py | 77 ++++++++ tests/test_harvest.py | 141 ++++++++++++++ tests/test_manifest.py | 115 ++++++++++++ tests/test_secrets.py | 8 + 20 files changed, 1350 insertions(+), 174 deletions(-) create mode 100644 .forgejo/workflows/ci.yml create mode 100644 .forgejo/workflows/lint.yml create mode 100644 .forgejo/workflows/trivy.yml create mode 100644 CHANGELOG.txt create mode 100755 tests.sh create mode 100644 tests/conftest.py create mode 100644 tests/test_cli.py create mode 100644 tests/test_harvest.py create mode 100644 tests/test_manifest.py create mode 100644 tests/test_secrets.py diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml new file mode 100644 index 0000000..4f9d0e7 --- /dev/null +++ b/.forgejo/workflows/ci.yml @@ -0,0 +1,46 @@ +name: CI + +on: + push: + +jobs: + test: + runs-on: docker + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install system dependencies + run: | + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + ansible + + - name: Install Poetry + run: | + pipx install poetry==1.8.3 + /root/.local/bin/poetry --version + echo "$HOME/.local/bin" >> "$GITHUB_PATH" + + - name: Install project deps (including test extras) + run: | + poetry install --with test + + - name: Run test script + run: | + ./tests.sh + + # Notify if any previous step in this job failed + - name: Notify on failure + if: ${{ failure() }} + env: + WEBHOOK_URL: ${{ secrets.NODERED_WEBHOOK_URL }} + REPOSITORY: ${{ forgejo.repository }} + RUN_NUMBER: ${{ forgejo.run_number }} + SERVER_URL: ${{ forgejo.server_url }} + run: | + curl -X POST \ + -H "Content-Type: application/json" \ + -d "{\"repository\":\"$REPOSITORY\",\"run_number\":\"$RUN_NUMBER\",\"status\":\"failure\",\"url\":\"$SERVER_URL/$REPOSITORY/actions/runs/$RUN_NUMBER\"}" \ + "$WEBHOOK_URL" diff --git a/.forgejo/workflows/lint.yml b/.forgejo/workflows/lint.yml new file mode 100644 index 0000000..6e86a79 --- /dev/null +++ b/.forgejo/workflows/lint.yml @@ -0,0 +1,40 @@ +name: Lint + +on: + push: + +jobs: + test: + runs-on: docker + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install system dependencies + run: | + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + black pyflakes3 python3-bandit + + - name: Run linters + run: | + black --diff --check enroll/* + black --diff --check tests/* + pyflakes3 enroll/* + pyflakes3 tests/* + bandit -r enroll/ + + # Notify if any previous step in this job failed + - name: Notify on failure + if: ${{ failure() }} + env: + WEBHOOK_URL: ${{ secrets.NODERED_WEBHOOK_URL }} + REPOSITORY: ${{ forgejo.repository }} + RUN_NUMBER: ${{ forgejo.run_number }} + SERVER_URL: ${{ forgejo.server_url }} + run: | + curl -X POST \ + -H "Content-Type: application/json" \ + -d "{\"repository\":\"$REPOSITORY\",\"run_number\":\"$RUN_NUMBER\",\"status\":\"failure\",\"url\":\"$SERVER_URL/$REPOSITORY/actions/runs/$RUN_NUMBER\"}" \ + "$WEBHOOK_URL" diff --git a/.forgejo/workflows/trivy.yml b/.forgejo/workflows/trivy.yml new file mode 100644 index 0000000..fad2f6f --- /dev/null +++ b/.forgejo/workflows/trivy.yml @@ -0,0 +1,40 @@ +name: Trivy + +on: + schedule: + - cron: '0 1 * * *' + push: + +jobs: + test: + runs-on: docker + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install system dependencies + run: | + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends wget gnupg + wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | gpg --dearmor | tee /usr/share/keyrings/trivy.gpg > /dev/null + echo "deb [signed-by=/usr/share/keyrings/trivy.gpg] https://aquasecurity.github.io/trivy-repo/deb generic main" | tee -a /etc/apt/sources.list.d/trivy.list + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends trivy + + - name: Run trivy + run: | + trivy fs --no-progress --ignore-unfixed --format table --disable-telemetry . + + # Notify if any previous step in this job failed + - name: Notify on failure + if: ${{ failure() }} + env: + WEBHOOK_URL: ${{ secrets.NODERED_WEBHOOK_URL }} + REPOSITORY: ${{ forgejo.repository }} + RUN_NUMBER: ${{ forgejo.run_number }} + SERVER_URL: ${{ forgejo.server_url }} + run: | + curl -X POST \ + -H "Content-Type: application/json" \ + -d "{\"repository\":\"$REPOSITORY\",\"run_number\":\"$RUN_NUMBER\",\"status\":\"failure\",\"url\":\"$SERVER_URL/$REPOSITORY/actions/runs/$RUN_NUMBER\"}" \ + "$WEBHOOK_URL" diff --git a/CHANGELOG.txt b/CHANGELOG.txt new file mode 100644 index 0000000..3b6678d --- /dev/null +++ b/CHANGELOG.txt @@ -0,0 +1,13 @@ +# 0.0.2 + + * Merge pkg_ and roles created based on file/service detection + * Avoid idempotency issue with users (password_lock) + * Rename subcommands/args ('export' is now 'enroll', '--bundle' is now '--harvest') + * Don't try and start systemd services that were Inactive at harvest time + * Capture miscellaneous files in /etc under their own etc_custom role, but not backup files + * Add tests + * Various other bug fixes + +# 0.0.1 + + * Initial commit diff --git a/README.md b/README.md index ff6f353..27b790d 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,8 @@ It aims to be **optimistic and noninteractive**: - Also captures **service-relevant custom/unowned files** under `/etc//...` (e.g. drop-in config includes). - Defensively excludes likely secrets (path denylist + content sniff + size caps). - Captures non-system users that exist on the system, and their SSH public keys +- Captures miscellaneous `/etc` files that it can't attribute to a package, and installs it in an `etc_custom` role +- Avoids trying to start systemd services that were detected as being Inactive during harvest ## Install (Poetry) @@ -21,22 +23,22 @@ poetry run enroll --help On the host (root recommended): -### 1. Generate a bundle of state/information about the host +### 1. Harvest state/information about the host ```bash -sudo poetry run enroll harvest --out /tmp/enroll-bundle +sudo poetry run enroll harvest --out /tmp/enroll-harvest ``` -### 2. Generate Ansible manifests (roles/playbook) from that bundle +### 2. Generate Ansible manifests (roles/playbook) from that harvest ```bash -sudo poetry run enroll manifest --bundle /tmp/enroll-bundle --out /tmp/enroll-ansible +sudo poetry run enroll manifest --harvest /tmp/enroll-harvest --out /tmp/enroll-ansible ``` ### Alternatively, do both steps in one shot: ```bash -sudo poetry run enroll export --bundle /tmp/enroll-bundle --out /tmp/enroll-ansible +sudo poetry run enroll enroll --harvest /tmp/enroll-harvest --out /tmp/enroll-ansible ``` Then run: diff --git a/enroll/accounts.py b/enroll/accounts.py index f47c0e4..7c92f4e 100644 --- a/enroll/accounts.py +++ b/enroll/accounts.py @@ -2,7 +2,7 @@ from __future__ import annotations import os from dataclasses import dataclass -from typing import Dict, List, Optional, Set, Tuple +from typing import Dict, List, Set, Tuple @dataclass @@ -27,7 +27,12 @@ def parse_login_defs(path: str = "/etc/login.defs") -> Dict[str, int]: if not line or line.startswith("#"): continue parts = line.split() - if len(parts) >= 2 and parts[0] in {"UID_MIN", "UID_MAX", "SYS_UID_MIN", "SYS_UID_MAX"}: + if len(parts) >= 2 and parts[0] in { + "UID_MIN", + "UID_MAX", + "SYS_UID_MIN", + "SYS_UID_MAX", + }: try: vals[parts[0]] = int(parts[1]) except ValueError: @@ -37,7 +42,9 @@ def parse_login_defs(path: str = "/etc/login.defs") -> Dict[str, int]: return vals -def parse_passwd(path: str = "/etc/passwd") -> List[Tuple[str, int, int, str, str, str]]: +def parse_passwd( + path: str = "/etc/passwd", +) -> List[Tuple[str, int, int, str, str, str]]: rows: List[Tuple[str, int, int, str, str, str]] = [] with open(path, "r", encoding="utf-8", errors="replace") as f: for line in f: @@ -60,7 +67,9 @@ def parse_passwd(path: str = "/etc/passwd") -> List[Tuple[str, int, int, str, st return rows -def parse_group(path: str = "/etc/group") -> Tuple[Dict[int, str], Dict[str, int], Dict[str, Set[str]]]: +def parse_group( + path: str = "/etc/group", +) -> Tuple[Dict[int, str], Dict[str, int], Dict[str, Set[str]]]: gid_to_name: Dict[int, str] = {} name_to_gid: Dict[str, int] = {} members: Dict[str, Set[str]] = {} @@ -130,16 +139,18 @@ def collect_non_system_users() -> List[UserRecord]: ssh_files = find_user_ssh_files(home) if home and home.startswith("/") else [] - users.append(UserRecord( - name=name, - uid=uid, - gid=gid, - gecos=gecos, - home=home, - shell=shell, - primary_group=primary_group, - supplementary_groups=supp, - ssh_files=ssh_files, - )) + users.append( + UserRecord( + name=name, + uid=uid, + gid=gid, + gecos=gecos, + home=home, + shell=shell, # nosec + primary_group=primary_group, + supplementary_groups=supp, + ssh_files=ssh_files, + ) + ) return users diff --git a/enroll/cli.py b/enroll/cli.py index abdc01f..530a388 100644 --- a/enroll/cli.py +++ b/enroll/cli.py @@ -9,16 +9,32 @@ def main() -> None: ap = argparse.ArgumentParser(prog="enroll") sub = ap.add_subparsers(dest="cmd", required=True) - h = sub.add_parser("harvest", help="Harvest service/package/config state into a bundle") - h.add_argument("--out", required=True, help="Bundle output directory") + h = sub.add_parser("harvest", help="Harvest service/package/config state") + h.add_argument("--out", required=True, help="Harvest output directory") - r = sub.add_parser("manifest", help="Render Ansible roles from a harvested bundle") - r.add_argument("--bundle", required=True, help="Path to the bundle directory created by the harvest command") - r.add_argument("--out", required=True, help="Output directory for generated roles/playbook Ansible manifest") + r = sub.add_parser("manifest", help="Render Ansible roles from a harvest") + r.add_argument( + "--harvest", + required=True, + help="Path to the directory created by the harvest command", + ) + r.add_argument( + "--out", + required=True, + help="Output directory for generated roles/playbook Ansible manifest", + ) - e = sub.add_parser("export", help="Harvest then manifest in one shot") - e.add_argument("--bundle", required=True, help="Path to the directory to place the bundle in") - e.add_argument("--out", required=True, help="Output directory for generated roles/playbook Ansible manifest") + e = sub.add_parser( + "enroll", help="Harvest state, then manifest Ansible code, in one shot" + ) + e.add_argument( + "--harvest", required=True, help="Path to the directory to place the harvest in" + ) + e.add_argument( + "--out", + required=True, + help="Output directory for generated roles/playbook Ansible manifest", + ) args = ap.parse_args() @@ -26,7 +42,7 @@ def main() -> None: path = harvest(args.out) print(path) elif args.cmd == "manifest": - manifest(args.bundle, args.out) - elif args.cmd == "export": - harvest(args.bundle) - manifest(args.bundle, args.out) + manifest(args.harvest, args.out) + elif args.cmd == "enroll": + harvest(args.harvest) + manifest(args.harvest, args.out) diff --git a/enroll/debian.py b/enroll/debian.py index 2874403..6324d94 100644 --- a/enroll/debian.py +++ b/enroll/debian.py @@ -3,19 +3,19 @@ from __future__ import annotations import glob import hashlib import os -import subprocess +import subprocess # nosec from typing import Dict, List, Optional, Set, Tuple def _run(cmd: list[str]) -> str: - p = subprocess.run(cmd, check=False, text=True, capture_output=True) + p = subprocess.run(cmd, check=False, text=True, capture_output=True) # nosec if p.returncode != 0: raise RuntimeError(f"Command failed: {cmd}\n{p.stderr}") return p.stdout def dpkg_owner(path: str) -> Optional[str]: - p = subprocess.run(["dpkg", "-S", path], text=True, capture_output=True) + p = subprocess.run(["dpkg", "-S", path], text=True, capture_output=True) #nosec if p.returncode != 0: return None left = p.stdout.split(":", 1)[0].strip() @@ -23,10 +23,9 @@ def dpkg_owner(path: str) -> Optional[str]: return pkg or None - def list_manual_packages() -> List[str]: """Return packages marked as manually installed (apt-mark showmanual).""" - p = subprocess.run(["apt-mark", "showmanual"], text=True, capture_output=True) + p = subprocess.run(["apt-mark", "showmanual"], text=True, capture_output=True) #nosec if p.returncode != 0: return [] pkgs: List[str] = [] @@ -37,6 +36,7 @@ def list_manual_packages() -> List[str]: pkgs.append(line) return sorted(set(pkgs)) + def build_dpkg_etc_index( info_dir: str = "/var/lib/dpkg/info", ) -> Tuple[Set[str], Dict[str, str], Dict[str, Set[str]], Dict[str, List[str]]]: @@ -83,7 +83,9 @@ def build_dpkg_etc_index( return owned, owner, topdir_to_pkgs, pkg_to_etc -def parse_status_conffiles(status_path: str = "/var/lib/dpkg/status") -> Dict[str, Dict[str, str]]: +def parse_status_conffiles( + status_path: str = "/var/lib/dpkg/status", +) -> Dict[str, Dict[str, str]]: """ pkg -> { "/etc/foo": md5hex, ... } based on dpkg status "Conffiles" field. This md5 is the packaged baseline for the conffile. @@ -152,7 +154,7 @@ def read_pkg_md5sums(pkg: str) -> Dict[str, str]: def file_md5(path: str) -> str: - h = hashlib.md5() + h = hashlib.md5() # nosec with open(path, "rb") as f: for chunk in iter(lambda: f.read(1024 * 1024), b""): h.update(chunk) @@ -164,6 +166,7 @@ def stat_triplet(path: str) -> Tuple[str, str, str]: mode = oct(st.st_mode & 0o777)[2:].zfill(4) import pwd, grp + try: owner = pwd.getpwuid(st.st_uid).pw_name except KeyError: diff --git a/enroll/harvest.py b/enroll/harvest.py index 437cd66..9df950d 100644 --- a/enroll/harvest.py +++ b/enroll/harvest.py @@ -18,8 +18,7 @@ from .debian import ( stat_triplet, ) from .secrets import SecretPolicy -from .accounts import collect_non_system_users, UserRecord - +from .accounts import collect_non_system_users @dataclass @@ -43,6 +42,10 @@ class ServiceSnapshot: unit: str role_name: str packages: List[str] + active_state: Optional[str] + sub_state: Optional[str] + unit_file_state: Optional[str] + condition_result: Optional[str] managed_files: List[ManagedFile] excluded: List[ExcludedFile] notes: List[str] @@ -66,15 +69,59 @@ class UsersSnapshot: notes: List[str] +@dataclass +class EtcCustomSnapshot: + role_name: str + managed_files: List[ManagedFile] + excluded: List[ExcludedFile] + notes: List[str] + + ALLOWED_UNOWNED_EXTS = { - ".conf", ".cfg", ".ini", ".cnf", ".yaml", ".yml", ".json", ".toml", - ".rules", ".service", ".socket", ".timer", ".target", ".path", ".mount", - ".network", ".netdev", ".link", + ".conf", + ".cfg", + ".ini", + ".cnf", + ".yaml", + ".yml", + ".json", + ".toml", + ".rules", + ".service", + ".socket", + ".timer", + ".target", + ".path", + ".mount", + ".network", + ".netdev", + ".link", "", # allow extensionless (common in /etc/default and /etc/init.d) } MAX_UNOWNED_FILES_PER_ROLE = 400 +# Directories that are shared across many packages; never attribute unowned files in these trees to a single package. +SHARED_ETC_TOPDIRS = { + "default", + "apparmor.d", + "network", + "init.d", + "systemd", + "pam.d", + "ssh", + "ssl", + "sudoers.d", + "cron.d", + "cron.daily", + "cron.weekly", + "cron.monthly", + "cron.hourly", + "logrotate.d", + "sysctl.d", + "modprobe.d", +} + def _safe_name(s: str) -> str: out: List[str] = [] @@ -89,10 +136,12 @@ def _role_name_from_unit(unit: str) -> str: def _role_name_from_pkg(pkg: str) -> str: - return "pkg_" + _safe_name(pkg) + return _safe_name(pkg) -def _copy_into_bundle(bundle_dir: str, role_name: str, abs_path: str, src_rel: str) -> None: +def _copy_into_bundle( + bundle_dir: str, role_name: str, abs_path: str, src_rel: str +) -> None: dst = os.path.join(bundle_dir, "artifacts", role_name, src_rel) os.makedirs(os.path.dirname(dst), exist_ok=True) shutil.copy2(abs_path, dst) @@ -114,7 +163,9 @@ def _hint_names(unit: str, pkgs: Set[str]) -> Set[str]: return {h for h in hints if h} -def _add_pkgs_from_etc_topdirs(hints: Set[str], topdir_to_pkgs: Dict[str, Set[str]], pkgs: Set[str]) -> None: +def _add_pkgs_from_etc_topdirs( + hints: Set[str], topdir_to_pkgs: Dict[str, Set[str]], pkgs: Set[str] +) -> None: for h in hints: for p in topdir_to_pkgs.get(h, set()): pkgs.add(p) @@ -123,16 +174,20 @@ def _add_pkgs_from_etc_topdirs(hints: Set[str], topdir_to_pkgs: Dict[str, Set[st def _maybe_add_specific_paths(hints: Set[str]) -> List[str]: paths: List[str] = [] for h in hints: - paths.extend([ - f"/etc/default/{h}", - f"/etc/init.d/{h}", - f"/etc/sysctl.d/{h}.conf", - f"/etc/logrotate.d/{h}", - ]) + paths.extend( + [ + f"/etc/default/{h}", + f"/etc/init.d/{h}", + f"/etc/sysctl.d/{h}.conf", + f"/etc/logrotate.d/{h}", + ] + ) return paths -def _scan_unowned_under_roots(roots: List[str], owned_etc: Set[str], limit: int = MAX_UNOWNED_FILES_PER_ROLE) -> List[str]: +def _scan_unowned_under_roots( + roots: List[str], owned_etc: Set[str], limit: int = MAX_UNOWNED_FILES_PER_ROLE +) -> List[str]: found: List[str] = [] for root in roots: if not os.path.isdir(root): @@ -170,7 +225,10 @@ def harvest(bundle_dir: str, policy: Optional[SecretPolicy] = None) -> str: os.makedirs(bundle_dir, exist_ok=True) if hasattr(os, "geteuid") and os.geteuid() != 0: - print("Warning: not running as root; harvest may miss files or metadata.", flush=True) + print( + "Warning: not running as root; harvest may miss files or metadata.", + flush=True, + ) owned_etc, etc_owner_map, topdir_to_pkgs, pkg_to_etc_paths = build_dpkg_etc_index() conffiles_by_pkg = parse_status_conffiles() @@ -185,14 +243,20 @@ def harvest(bundle_dir: str, policy: Optional[SecretPolicy] = None) -> str: try: ui = get_unit_info(unit) except UnitQueryError as e: - service_snaps.append(ServiceSnapshot( - unit=unit, - role_name=role, - packages=[], - managed_files=[], - excluded=[], - notes=[str(e)], - )) + service_snaps.append( + ServiceSnapshot( + unit=unit, + role_name=role, + packages=[], + active_state=None, + sub_state=None, + unit_file_state=None, + condition_result=None, + managed_files=[], + excluded=[], + notes=[str(e)], + ) + ) continue pkgs: Set[str] = set() @@ -243,6 +307,7 @@ def harvest(bundle_dir: str, policy: Optional[SecretPolicy] = None) -> str: if not os.path.isfile(path) or os.path.islink(path): continue if path in conff: + # Only capture conffiles when they differ from the package default. try: current = file_md5(path) except OSError: @@ -267,7 +332,9 @@ def harvest(bundle_dir: str, policy: Optional[SecretPolicy] = None) -> str: candidates.setdefault(pth, "custom_unowned") if not pkgs and not candidates: - notes.append("No packages or /etc candidates detected (unexpected for enabled service).") + notes.append( + "No packages or /etc candidates detected (unexpected for enabled service)." + ) for path, reason in sorted(candidates.items()): deny = policy.deny_reason(path) @@ -285,31 +352,49 @@ def harvest(bundle_dir: str, policy: Optional[SecretPolicy] = None) -> str: except OSError: excluded.append(ExcludedFile(path=path, reason="unreadable")) continue - managed.append(ManagedFile( - path=path, - src_rel=src_rel, - owner=owner, - group=group, - mode=mode, - reason=reason, - )) + managed.append( + ManagedFile( + path=path, + src_rel=src_rel, + owner=owner, + group=group, + mode=mode, + reason=reason, + ) + ) - service_snaps.append(ServiceSnapshot( - unit=unit, - role_name=role, - packages=sorted(pkgs), - managed_files=managed, - excluded=excluded, - notes=notes, - )) + service_snaps.append( + ServiceSnapshot( + unit=unit, + role_name=role, + packages=sorted(pkgs), + active_state=ui.active_state, + sub_state=ui.sub_state, + unit_file_state=ui.unit_file_state, + condition_result=ui.condition_result, + managed_files=managed, + excluded=excluded, + notes=notes, + ) + ) # ------------------------- # Manual package roles # ------------------------- manual_pkgs = list_manual_packages() + # Avoid duplicate roles: if a manual package is already managed by any service role, skip its pkg_ role. + covered_by_services: Set[str] = set() + for s in service_snaps: + for p in s.packages: + covered_by_services.add(p) + + manual_pkgs_skipped: List[str] = [] pkg_snaps: List[PackageSnapshot] = [] for pkg in manual_pkgs: + if pkg in covered_by_services: + manual_pkgs_skipped.append(pkg) + continue role = _role_name_from_pkg(pkg) notes: List[str] = [] excluded: List[ExcludedFile] = [] @@ -343,13 +428,17 @@ def harvest(bundle_dir: str, policy: Optional[SecretPolicy] = None) -> str: topdirs = _topdirs_for_package(pkg, pkg_to_etc_paths) roots: List[str] = [] for td in sorted(topdirs): + if td in SHARED_ETC_TOPDIRS: + continue roots.extend([f"/etc/{td}", f"/etc/{td}.d"]) roots.extend([f"/etc/default/{td}"]) roots.extend([f"/etc/init.d/{td}"]) roots.extend([f"/etc/logrotate.d/{td}"]) roots.extend([f"/etc/sysctl.d/{td}.conf"]) - for pth in _scan_unowned_under_roots([r for r in roots if os.path.isdir(r)], owned_etc): + for pth in _scan_unowned_under_roots( + [r for r in roots if os.path.isdir(r)], owned_etc + ): candidates.setdefault(pth, "custom_unowned") for r in roots: @@ -373,25 +462,31 @@ def harvest(bundle_dir: str, policy: Optional[SecretPolicy] = None) -> str: except OSError: excluded.append(ExcludedFile(path=path, reason="unreadable")) continue - managed.append(ManagedFile( - path=path, - src_rel=src_rel, - owner=owner, - group=group, - mode=mode, - reason=reason, - )) + managed.append( + ManagedFile( + path=path, + src_rel=src_rel, + owner=owner, + group=group, + mode=mode, + reason=reason, + ) + ) if not pkg_to_etc_paths.get(pkg, []) and not managed: - notes.append("No /etc files detected for this package (may be a meta package).") + notes.append( + "No /etc files detected for this package (may be a meta package)." + ) - pkg_snaps.append(PackageSnapshot( - package=pkg, - role_name=role, - managed_files=managed, - excluded=excluded, - notes=notes, - )) + pkg_snaps.append( + PackageSnapshot( + package=pkg, + role_name=role, + managed_files=managed, + excluded=excluded, + notes=notes, + ) + ) # ------------------------- # Users role (non-system users) @@ -402,7 +497,7 @@ def harvest(bundle_dir: str, policy: Optional[SecretPolicy] = None) -> str: users_list: List[dict] = [] try: - us + user_records = collect_non_system_users() except Exception as e: user_records = [] users_notes.append(f"Failed to enumerate users: {e!r}") @@ -410,47 +505,51 @@ def harvest(bundle_dir: str, policy: Optional[SecretPolicy] = None) -> str: users_role_name = "users" for u in user_records: - users_list.append({ - "name": u.name, - "uid": u.uid, - "gid": u.gid, - "gecos": u.gecos, - "home": u.home, - "shell": u.shell, - "primary_group": u.primary_group, - "supplementary_groups": u.supplementary_groups, - }) + users_list.append( + { + "name": u.name, + "uid": u.uid, + "gid": u.gid, + "gecos": u.gecos, + "home": u.home, + "shell": u.shell, + "primary_group": u.primary_group, + "supplementary_groups": u.supplementary_groups, + } + ) - # Copy authorized_keys + # Copy only safe SSH public material: authorized_keys + *.pub for sf in u.ssh_files: deny = policy.deny_reason(sf) if deny: users_excluded.append(ExcludedFile(path=sf, reason=deny)) continue - - # Force safe modes; still record current owner/group for reference. try: owner, group, mode = stat_triplet(sf) except OSError: users_excluded.append(ExcludedFile(path=sf, reason="unreadable")) continue - src_rel = sf.lstrip("/") try: _copy_into_bundle(bundle_dir, users_role_name, sf, src_rel) except OSError: users_excluded.append(ExcludedFile(path=sf, reason="unreadable")) continue - - reason = "authorized_keys" if sf.endswith("/authorized_keys") else "ssh_public_key" - users_managed.append(ManagedFile( - path=sf, - src_rel=src_rel, - owner=owner, - group=group, - mode=mode, - reason=reason, - )) + reason = ( + "authorized_keys" + if sf.endswith("/authorized_keys") + else "ssh_public_key" + ) + users_managed.append( + ManagedFile( + path=sf, + src_rel=src_rel, + owner=owner, + group=group, + mode=mode, + reason=reason, + ) + ) users_snapshot = UsersSnapshot( role_name=users_role_name, @@ -460,12 +559,91 @@ def harvest(bundle_dir: str, policy: Optional[SecretPolicy] = None) -> str: notes=users_notes, ) + # ------------------------- + # etc_custom role (unowned /etc files not already attributed elsewhere) + # ------------------------- + etc_notes: List[str] = [] + etc_excluded: List[ExcludedFile] = [] + etc_managed: List[ManagedFile] = [] + etc_role_name = "etc_custom" + + # Build a set of files already captured by other roles. + already: Set[str] = set() + for s in service_snaps: + for mf in s.managed_files: + already.add(mf.path) + for p in pkg_snaps: + for mf in p.managed_files: + already.add(mf.path) + for mf in users_managed: + already.add(mf.path) + + # Walk /etc for unowned config-ish files + scanned = 0 + for dirpath, _, filenames in os.walk("/etc"): + for fn in filenames: + path = os.path.join(dirpath, fn) + if path in already: + continue + if path in owned_etc: + continue + if not os.path.isfile(path) or os.path.islink(path): + continue + if not _is_confish(path): + continue + + deny = policy.deny_reason(path) + if deny: + etc_excluded.append(ExcludedFile(path=path, reason=deny)) + continue + + try: + owner, group, mode = stat_triplet(path) + except OSError: + etc_excluded.append(ExcludedFile(path=path, reason="unreadable")) + continue + + src_rel = path.lstrip("/") + try: + _copy_into_bundle(bundle_dir, etc_role_name, path, src_rel) + except OSError: + etc_excluded.append(ExcludedFile(path=path, reason="unreadable")) + continue + + etc_managed.append( + ManagedFile( + path=path, + src_rel=src_rel, + owner=owner, + group=group, + mode=mode, + reason="custom_unowned", + ) + ) + scanned += 1 + if scanned >= 2000: + etc_notes.append( + "Reached file cap (2000) while scanning /etc for unowned files." + ) + break + if scanned >= 2000: + break + + etc_custom_snapshot = EtcCustomSnapshot( + role_name=etc_role_name, + managed_files=etc_managed, + excluded=etc_excluded, + notes=etc_notes, + ) + state = { "host": {"hostname": os.uname().nodename, "os": "debian"}, "users": asdict(users_snapshot), "services": [asdict(s) for s in service_snaps], "manual_packages": manual_pkgs, + "manual_packages_skipped": manual_pkgs_skipped, "package_roles": [asdict(p) for p in pkg_snaps], + "etc_custom": asdict(etc_custom_snapshot), } state_path = os.path.join(bundle_dir, "state.json") diff --git a/enroll/manifest.py b/enroll/manifest.py index b502abb..a7395e1 100644 --- a/enroll/manifest.py +++ b/enroll/manifest.py @@ -50,12 +50,14 @@ def manifest(bundle_dir: str, out_dir: str) -> None: services: List[Dict[str, Any]] = state.get("services", []) package_roles: List[Dict[str, Any]] = state.get("package_roles", []) users_snapshot: Dict[str, Any] = state.get("users", {}) + etc_custom_snapshot: Dict[str, Any] = state.get("etc_custom", {}) os.makedirs(out_dir, exist_ok=True) roles_root = os.path.join(out_dir, "roles") os.makedirs(roles_root, exist_ok=True) manifested_users_roles: List[str] = [] + manifested_etc_custom_roles: List[str] = [] manifested_service_roles: List[str] = [] manifested_pkg_roles: List[str] = [] @@ -86,11 +88,17 @@ def manifest(bundle_dir: str, out_dir: str) -> None: # defaults: store users list (handy for later), but tasks are explicit for readability defaults = """--- users_accounts: -""" + ("\n".join([f" - name: {u.get('name')}" for u in users]) + "\n") - with open(os.path.join(role_dir, "defaults", "main.yml"), "w", encoding="utf-8") as f: +""" + ( + "\n".join([f" - name: {u.get('name')}" for u in users]) + "\n" + ) + with open( + os.path.join(role_dir, "defaults", "main.yml"), "w", encoding="utf-8" + ) as f: f.write(defaults) - with open(os.path.join(role_dir, "meta", "main.yml"), "w", encoding="utf-8") as f: + with open( + os.path.join(role_dir, "meta", "main.yml"), "w", encoding="utf-8" + ) as f: f.write("---\ndependencies: []\n") # tasks @@ -112,7 +120,7 @@ users_accounts: lines.append(f" group: {u.get('primary_group')}") supp = u.get("supplementary_groups") or [] if supp: - lines.append(" groups: " + ",".join(supp)) + lines.append(" groups: " + ",".join(sorted(supp))) lines.append(" append: true") lines.append(f" home: {u.get('home')}") lines.append(" create_home: true") @@ -120,9 +128,8 @@ users_accounts: lines.append(f" shell: {u.get('shell')}") if u.get("gecos"): # quote to avoid YAML surprises - gec = u.get("gecos").replace('"', '\"') + gec = u.get("gecos").replace('"', '"') lines.append(f' comment: "{gec}"') - lines.append(" password_lock: true") lines.append(" state: present") # Ensure ~/.ssh @@ -163,30 +170,122 @@ users_accounts: lines.append(f" mode: '{mode}'") tasks = "\n".join(lines).rstrip() + "\n" - with open(os.path.join(role_dir, "tasks", "main.yml"), "w", encoding="utf-8") as f: + with open( + os.path.join(role_dir, "tasks", "main.yml"), "w", encoding="utf-8" + ) as f: f.write(tasks) # handlers (none needed) - with open(os.path.join(role_dir, "handlers", "main.yml"), "w", encoding="utf-8") as f: + with open( + os.path.join(role_dir, "handlers", "main.yml"), "w", encoding="utf-8" + ) as f: f.write("---\n") - readme = """# users + readme = ( + """# users Generated non-system user accounts and SSH public material. ## Users -""" + ("\n".join([f"- {u.get('name')} (uid {u.get('uid')})" for u in users]) or "- (none)") + """\n +""" + + ( + "\n".join([f"- {u.get('name')} (uid {u.get('uid')})" for u in users]) + or "- (none)" + ) + + """\n ## Included SSH files -""" + ("\n".join([f"- {mf.get('path')} ({mf.get('reason')})" for mf in managed_files]) or "- (none)") + """\n +""" + + ( + "\n".join( + [f"- {mf.get('path')} ({mf.get('reason')})" for mf in managed_files] + ) + or "- (none)" + ) + + """\n ## Excluded -""" + ("\n".join([f"- {e.get('path')} ({e.get('reason')})" for e in excluded]) or "- (none)") + """\n +""" + + ( + "\n".join([f"- {e.get('path')} ({e.get('reason')})" for e in excluded]) + or "- (none)" + ) + + """\n ## Notes -""" + ("\n".join([f"- {n}" for n in notes]) or "- (none)") + """\n""" +""" + + ("\n".join([f"- {n}" for n in notes]) or "- (none)") + + """\n""" + ) with open(os.path.join(role_dir, "README.md"), "w", encoding="utf-8") as f: f.write(readme) manifested_users_roles.append(role) + # ------------------------- + # etc_custom role (unowned /etc not already attributed) + # ------------------------- + if etc_custom_snapshot and etc_custom_snapshot.get("managed_files"): + role = etc_custom_snapshot.get("role_name", "etc_custom") + role_dir = os.path.join(roles_root, role) + _write_role_scaffold(role_dir) + _copy_artifacts(bundle_dir, role, role_dir) + + managed_files = etc_custom_snapshot.get("managed_files", []) + excluded = etc_custom_snapshot.get("excluded", []) + notes = etc_custom_snapshot.get("notes", []) + + # tasks: just deploy files (no restarts) + lines: List[str] = ["---"] + for mf in managed_files: + dest = mf["path"] + src = mf["src_rel"] + lines.append(f"- name: Deploy {dest}") + lines.append(" ansible.builtin.copy:") + lines.append(f" src: {src}") + lines.append(f" dest: {dest}") + lines.append(f" owner: {mf.get('owner')}") + lines.append(f" group: {mf.get('group')}") + lines.append(f" mode: '{mf.get('mode')}'") + + tasks = "\n".join(lines).rstrip() + "\n" + with open( + os.path.join(role_dir, "tasks", "main.yml"), "w", encoding="utf-8" + ) as f: + f.write(tasks) + + with open( + os.path.join(role_dir, "handlers", "main.yml"), "w", encoding="utf-8" + ) as f: + f.write("---\n") + with open( + os.path.join(role_dir, "meta", "main.yml"), "w", encoding="utf-8" + ) as f: + f.write("---\ndependencies: []\n") + + readme = ( + """# etc_custom + +Unowned /etc config files not attributed to packages or services. + +## Managed files +""" + + ("\n".join([f"- {mf.get('path')}" for mf in managed_files]) or "- (none)") + + """\n +## Excluded +""" + + ( + "\n".join([f"- {e.get('path')} ({e.get('reason')})" for e in excluded]) + or "- (none)" + ) + + """\n +## Notes +""" + + ("\n".join([f"- {n}" for n in notes]) or "- (none)") + + """\n""" + ) + with open(os.path.join(role_dir, "README.md"), "w", encoding="utf-8") as f: + f.write(readme) + + manifested_etc_custom_roles.append(role) + # ------------------------- # Service roles # ------------------------- @@ -202,11 +301,16 @@ Generated non-system user accounts and SSH public material. var_prefix = role + was_active = svc.get("active_state") == "active" defaults = f"""--- {var_prefix}_packages: {_yaml_list(pkgs, indent=2)} +{var_prefix}_active_state_at_harvest: "{svc.get("active_state")}" +{var_prefix}_start: {"true" if was_active else "false"} """ - with open(os.path.join(role_dir, "defaults", "main.yml"), "w", encoding="utf-8") as f: + with open( + os.path.join(role_dir, "defaults", "main.yml"), "w", encoding="utf-8" + ) as f: f.write(defaults) handlers = """--- @@ -219,10 +323,14 @@ Generated non-system user accounts and SSH public material. name: "{{ unit_name }}" state: restarted """ - with open(os.path.join(role_dir, "handlers", "main.yml"), "w", encoding="utf-8") as f: + with open( + os.path.join(role_dir, "handlers", "main.yml"), "w", encoding="utf-8" + ) as f: f.write(handlers) - systemd_files = [mf for mf in managed_files if mf["path"].startswith("/etc/systemd/system/")] + systemd_files = [ + mf for mf in managed_files if mf["path"].startswith("/etc/systemd/system/") + ] other_files = [mf for mf in managed_files if mf not in systemd_files] def copy_task(mf: Dict[str, Any], notify: str | None) -> str: @@ -237,7 +345,8 @@ Generated non-system user accounts and SSH public material. {notify_line}""" task_parts: List[str] = [] - task_parts.append(f"""--- + task_parts.append( + f"""--- - name: Set unit name ansible.builtin.set_fact: unit_name: "{unit}" @@ -248,30 +357,44 @@ Generated non-system user accounts and SSH public material. state: present update_cache: true when: {var_prefix}_packages | length > 0 -""") +""" + ) if systemd_files: for mf in systemd_files: task_parts.append(copy_task(mf, "[systemd daemon-reload]")) - task_parts.append("""- name: Reload systemd to pick up unit changes + task_parts.append( + """- name: Reload systemd to pick up unit changes ansible.builtin.meta: flush_handlers -""") +""" + ) for mf in other_files: task_parts.append(copy_task(mf, "[Restart service]")) - task_parts.append(f"""- name: Ensure {unit} is enabled and running + task_parts.append( + f"""- name: Ensure {unit} is enabled (preserve running state) ansible.builtin.service: name: "{{{{ unit_name }}}}" enabled: true + +- name: Start {unit} if it was active at harvest time + ansible.builtin.service: + name: "{{{{ unit_name }}}}" state: started -""") + when: {var_prefix}_start | bool +""" + ) tasks = "\n".join(task_parts).rstrip() + "\n" - with open(os.path.join(role_dir, "tasks", "main.yml"), "w", encoding="utf-8") as f: + with open( + os.path.join(role_dir, "tasks", "main.yml"), "w", encoding="utf-8" + ) as f: f.write(tasks) - with open(os.path.join(role_dir, "meta", "main.yml"), "w", encoding="utf-8") as f: + with open( + os.path.join(role_dir, "meta", "main.yml"), "w", encoding="utf-8" + ) as f: f.write("---\ndependencies: []\n") excluded = svc.get("excluded", []) @@ -315,7 +438,9 @@ Generated from `{unit}`. {var_prefix}_packages: - {pkg} """ - with open(os.path.join(role_dir, "defaults", "main.yml"), "w", encoding="utf-8") as f: + with open( + os.path.join(role_dir, "defaults", "main.yml"), "w", encoding="utf-8" + ) as f: f.write(defaults) handlers = """--- @@ -323,10 +448,14 @@ Generated from `{unit}`. ansible.builtin.systemd: daemon_reload: true """ - with open(os.path.join(role_dir, "handlers", "main.yml"), "w", encoding="utf-8") as f: + with open( + os.path.join(role_dir, "handlers", "main.yml"), "w", encoding="utf-8" + ) as f: f.write(handlers) - systemd_files = [mf for mf in managed_files if mf["path"].startswith("/etc/systemd/system/")] + systemd_files = [ + mf for mf in managed_files if mf["path"].startswith("/etc/systemd/system/") + ] other_files = [mf for mf in managed_files if mf not in systemd_files] def copy_task(mf: Dict[str, Any], notify: str | None) -> str: @@ -341,29 +470,37 @@ Generated from `{unit}`. {notify_line}""" task_parts: List[str] = [] - task_parts.append(f"""--- + task_parts.append( + f"""--- - name: Install manual package {pkg} ansible.builtin.apt: name: "{{{{ {var_prefix}_packages }}}}" state: present update_cache: true -""") +""" + ) if systemd_files: for mf in systemd_files: task_parts.append(copy_task(mf, "[systemd daemon-reload]")) - task_parts.append("""- name: Reload systemd to pick up unit changes + task_parts.append( + """- name: Reload systemd to pick up unit changes ansible.builtin.meta: flush_handlers -""") +""" + ) for mf in other_files: task_parts.append(copy_task(mf, None)) tasks = "\n".join(task_parts).rstrip() + "\n" - with open(os.path.join(role_dir, "tasks", "main.yml"), "w", encoding="utf-8") as f: + with open( + os.path.join(role_dir, "tasks", "main.yml"), "w", encoding="utf-8" + ) as f: f.write(tasks) - with open(os.path.join(role_dir, "meta", "main.yml"), "w", encoding="utf-8") as f: + with open( + os.path.join(role_dir, "meta", "main.yml"), "w", encoding="utf-8" + ) as f: f.write("---\ndependencies: []\n") excluded = pr.get("excluded", []) @@ -389,4 +526,7 @@ Generated for manual package `{pkg}`. manifested_pkg_roles.append(role) # Playbooks - _write_playbook(os.path.join(out_dir, "playbook.yml"), manifested_users_roles + manifested_pkg_roles + manifested_service_roles) + _write_playbook( + os.path.join(out_dir, "playbook.yml"), + manifested_users_roles + manifested_etc_custom_roles + manifested_pkg_roles + manifested_service_roles, + ) diff --git a/enroll/secrets.py b/enroll/secrets.py index 9797e61..06514e5 100644 --- a/enroll/secrets.py +++ b/enroll/secrets.py @@ -8,6 +8,15 @@ from typing import Optional DEFAULT_DENY_GLOBS = [ + # Common backup copies created by passwd tools (can contain sensitive data) + "/etc/passwd-", + "/etc/group-", + "/etc/shadow-", + "/etc/gshadow-", + "/etc/subuid-", + "/etc/subgid-", + "/etc/*shadow-", + "/etc/*gshadow-", "/etc/ssl/private/*", "/etc/ssh/ssh_host_*", "/etc/shadow", @@ -17,9 +26,9 @@ DEFAULT_DENY_GLOBS = [ ] SENSITIVE_CONTENT_PATTERNS = [ - re.compile(br"-----BEGIN (RSA |EC |OPENSSH |)PRIVATE KEY-----"), - re.compile(br"(?i)\bpassword\s*="), - re.compile(br"(?i)\b(pass|passwd|token|secret|api[_-]?key)\b"), + re.compile(rb"-----BEGIN (RSA |EC |OPENSSH |)PRIVATE KEY-----"), + re.compile(rb"(?i)\bpassword\s*="), + re.compile(rb"(?i)\b(pass|passwd|token|secret|api[_-]?key)\b"), ] diff --git a/enroll/systemd.py b/enroll/systemd.py index 0cfbed0..828c518 100644 --- a/enroll/systemd.py +++ b/enroll/systemd.py @@ -1,7 +1,7 @@ from __future__ import annotations import re -import subprocess +import subprocess # nosec from dataclasses import dataclass from typing import List, Optional @@ -12,7 +12,11 @@ class UnitInfo: fragment_path: Optional[str] dropin_paths: List[str] env_files: List[str] - exec_paths: List[str] # binaries from ExecStart "path=" parts + exec_paths: List[str] + active_state: Optional[str] + sub_state: Optional[str] + unit_file_state: Optional[str] + condition_result: Optional[str] class UnitQueryError(RuntimeError): @@ -23,14 +27,22 @@ class UnitQueryError(RuntimeError): def _run(cmd: list[str]) -> str: - p = subprocess.run(cmd, check=False, text=True, capture_output=True) + p = subprocess.run(cmd, check=False, text=True, capture_output=True) # nosec if p.returncode != 0: raise RuntimeError(f"Command failed: {cmd}\n{p.stderr}") return p.stdout def list_enabled_services() -> List[str]: - out = _run(["systemctl", "list-unit-files", "--type=service", "--state=enabled", "--no-legend"]) + out = _run( + [ + "systemctl", + "list-unit-files", + "--type=service", + "--state=enabled", + "--no-legend", + ] + ) units: List[str] = [] for line in out.splitlines(): parts = line.split() @@ -39,7 +51,7 @@ def list_enabled_services() -> List[str]: unit = parts[0].strip() if not unit.endswith(".service"): continue - # Skip template units like "getty@.service" which are enabled but not valid for systemctl show + # Skip template units like "getty@.service" if unit.endswith("@.service") or "@.service" in unit: continue units.append(unit) @@ -49,13 +61,27 @@ def list_enabled_services() -> List[str]: def get_unit_info(unit: str) -> UnitInfo: p = subprocess.run( [ - "systemctl", "show", unit, - "-p", "FragmentPath", - "-p", "DropInPaths", - "-p", "EnvironmentFiles", - "-p", "ExecStart", + "systemctl", + "show", + unit, + "-p", + "FragmentPath", + "-p", + "DropInPaths", + "-p", + "EnvironmentFiles", + "-p", + "ExecStart", + "-p", + "ActiveState", + "-p", + "SubState", + "-p", + "UnitFileState", + "-p", + "ConditionResult", "--no-page", - ], + ], # nosec check=False, text=True, capture_output=True, @@ -70,7 +96,6 @@ def get_unit_info(unit: str) -> UnitInfo: kv[k] = v.strip() fragment = kv.get("FragmentPath") or None - dropins = [pp for pp in (kv.get("DropInPaths", "") or "").split() if pp] env_files: List[str] = [] @@ -87,4 +112,8 @@ def get_unit_info(unit: str) -> UnitInfo: dropin_paths=sorted(set(dropins)), env_files=sorted(set(env_files)), exec_paths=sorted(set(exec_paths)), + active_state=kv.get("ActiveState") or None, + sub_state=kv.get("SubState") or None, + unit_file_state=kv.get("UnitFileState") or None, + condition_result=kv.get("ConditionResult") or None, ) diff --git a/poetry.lock b/poetry.lock index a9b0222..3bffe25 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,7 +1,296 @@ # This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. -package = [] + +[[package]] +name = "colorama" +version = "0.4.6" +description = "Cross-platform colored terminal text." +optional = false +python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" +files = [ + {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, + {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, +] + +[[package]] +name = "coverage" +version = "7.13.0" +description = "Code coverage measurement for Python" +optional = false +python-versions = ">=3.10" +files = [ + {file = "coverage-7.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:02d9fb9eccd48f6843c98a37bd6817462f130b86da8660461e8f5e54d4c06070"}, + {file = "coverage-7.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:367449cf07d33dc216c083f2036bb7d976c6e4903ab31be400ad74ad9f85ce98"}, + {file = "coverage-7.13.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cdb3c9f8fef0a954c632f64328a3935988d33a6604ce4bf67ec3e39670f12ae5"}, + {file = "coverage-7.13.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d10fd186aac2316f9bbb46ef91977f9d394ded67050ad6d84d94ed6ea2e8e54e"}, + {file = "coverage-7.13.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f88ae3e69df2ab62fb0bc5219a597cb890ba5c438190ffa87490b315190bb33"}, + {file = "coverage-7.13.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4be718e51e86f553bcf515305a158a1cd180d23b72f07ae76d6017c3cc5d791"}, + {file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a00d3a393207ae12f7c49bb1c113190883b500f48979abb118d8b72b8c95c032"}, + {file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a7b1cd820e1b6116f92c6128f1188e7afe421c7e1b35fa9836b11444e53ebd9"}, + {file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:37eee4e552a65866f15dedd917d5e5f3d59805994260720821e2c1b51ac3248f"}, + {file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:62d7c4f13102148c78d7353c6052af6d899a7f6df66a32bddcc0c0eb7c5326f8"}, + {file = "coverage-7.13.0-cp310-cp310-win32.whl", hash = "sha256:24e4e56304fdb56f96f80eabf840eab043b3afea9348b88be680ec5986780a0f"}, + {file = "coverage-7.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:74c136e4093627cf04b26a35dab8cbfc9b37c647f0502fc313376e11726ba303"}, + {file = "coverage-7.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0dfa3855031070058add1a59fdfda0192fd3e8f97e7c81de0596c145dea51820"}, + {file = "coverage-7.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4fdb6f54f38e334db97f72fa0c701e66d8479af0bc3f9bfb5b90f1c30f54500f"}, + {file = "coverage-7.13.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7e442c013447d1d8d195be62852270b78b6e255b79b8675bad8479641e21fd96"}, + {file = "coverage-7.13.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ed5630d946859de835a85e9a43b721123a8a44ec26e2830b296d478c7fd4259"}, + {file = "coverage-7.13.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f15a931a668e58087bc39d05d2b4bf4b14ff2875b49c994bbdb1c2217a8daeb"}, + {file = "coverage-7.13.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30a3a201a127ea57f7e14ba43c93c9c4be8b7d17a26e03bb49e6966d019eede9"}, + {file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7a485ff48fbd231efa32d58f479befce52dcb6bfb2a88bb7bf9a0b89b1bc8030"}, + {file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:22486cdafba4f9e471c816a2a5745337742a617fef68e890d8baf9f3036d7833"}, + {file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:263c3dbccc78e2e331e59e90115941b5f53e85cfcc6b3b2fbff1fd4e3d2c6ea8"}, + {file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e5330fa0cc1f5c3c4c3bb8e101b742025933e7848989370a1d4c8c5e401ea753"}, + {file = "coverage-7.13.0-cp311-cp311-win32.whl", hash = "sha256:0f4872f5d6c54419c94c25dd6ae1d015deeb337d06e448cd890a1e89a8ee7f3b"}, + {file = "coverage-7.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:51a202e0f80f241ccb68e3e26e19ab5b3bf0f813314f2c967642f13ebcf1ddfe"}, + {file = "coverage-7.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:d2a9d7f1c11487b1c69367ab3ac2d81b9b3721f097aa409a3191c3e90f8f3dd7"}, + {file = "coverage-7.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0b3d67d31383c4c68e19a88e28fc4c2e29517580f1b0ebec4a069d502ce1e0bf"}, + {file = "coverage-7.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:581f086833d24a22c89ae0fe2142cfaa1c92c930adf637ddf122d55083fb5a0f"}, + {file = "coverage-7.13.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0a3a30f0e257df382f5f9534d4ce3d4cf06eafaf5192beb1a7bd066cb10e78fb"}, + {file = "coverage-7.13.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:583221913fbc8f53b88c42e8dbb8fca1d0f2e597cb190ce45916662b8b9d9621"}, + {file = "coverage-7.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f5d9bd30756fff3e7216491a0d6d520c448d5124d3d8e8f56446d6412499e74"}, + {file = "coverage-7.13.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a23e5a1f8b982d56fa64f8e442e037f6ce29322f1f9e6c2344cd9e9f4407ee57"}, + {file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b01c22bc74a7fb44066aaf765224c0d933ddf1f5047d6cdfe4795504a4493f8"}, + {file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:898cce66d0836973f48dda4e3514d863d70142bdf6dfab932b9b6a90ea5b222d"}, + {file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:3ab483ea0e251b5790c2aac03acde31bff0c736bf8a86829b89382b407cd1c3b"}, + {file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d84e91521c5e4cb6602fe11ece3e1de03b2760e14ae4fcf1a4b56fa3c801fcd"}, + {file = "coverage-7.13.0-cp312-cp312-win32.whl", hash = "sha256:193c3887285eec1dbdb3f2bd7fbc351d570ca9c02ca756c3afbc71b3c98af6ef"}, + {file = "coverage-7.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:4f3e223b2b2db5e0db0c2b97286aba0036ca000f06aca9b12112eaa9af3d92ae"}, + {file = "coverage-7.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:086cede306d96202e15a4b77ace8472e39d9f4e5f9fd92dd4fecdfb2313b2080"}, + {file = "coverage-7.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:28ee1c96109974af104028a8ef57cec21447d42d0e937c0275329272e370ebcf"}, + {file = "coverage-7.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d1e97353dcc5587b85986cda4ff3ec98081d7e84dd95e8b2a6d59820f0545f8a"}, + {file = "coverage-7.13.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:99acd4dfdfeb58e1937629eb1ab6ab0899b131f183ee5f23e0b5da5cba2fec74"}, + {file = "coverage-7.13.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ff45e0cd8451e293b63ced93161e189780baf444119391b3e7d25315060368a6"}, + {file = "coverage-7.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f4f72a85316d8e13234cafe0a9f81b40418ad7a082792fa4165bd7d45d96066b"}, + {file = "coverage-7.13.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:11c21557d0e0a5a38632cbbaca5f008723b26a89d70db6315523df6df77d6232"}, + {file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76541dc8d53715fb4f7a3a06b34b0dc6846e3c69bc6204c55653a85dd6220971"}, + {file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6e9e451dee940a86789134b6b0ffbe31c454ade3b849bb8a9d2cca2541a8e91d"}, + {file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5c67dace46f361125e6b9cace8fe0b729ed8479f47e70c89b838d319375c8137"}, + {file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f59883c643cb19630500f57016f76cfdcd6845ca8c5b5ea1f6e17f74c8e5f511"}, + {file = "coverage-7.13.0-cp313-cp313-win32.whl", hash = "sha256:58632b187be6f0be500f553be41e277712baa278147ecb7559983c6d9faf7ae1"}, + {file = "coverage-7.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:73419b89f812f498aca53f757dd834919b48ce4799f9d5cad33ca0ae442bdb1a"}, + {file = "coverage-7.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:eb76670874fdd6091eedcc856128ee48c41a9bbbb9c3f1c7c3cf169290e3ffd6"}, + {file = "coverage-7.13.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6e63ccc6e0ad8986386461c3c4b737540f20426e7ec932f42e030320896c311a"}, + {file = "coverage-7.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:494f5459ffa1bd45e18558cd98710c36c0b8fbfa82a5eabcbe671d80ecffbfe8"}, + {file = "coverage-7.13.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:06cac81bf10f74034e055e903f5f946e3e26fc51c09fc9f584e4a1605d977053"}, + {file = "coverage-7.13.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f2ffc92b46ed6e6760f1d47a71e56b5664781bc68986dbd1836b2b70c0ce2071"}, + {file = "coverage-7.13.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0602f701057c6823e5db1b74530ce85f17c3c5be5c85fc042ac939cbd909426e"}, + {file = "coverage-7.13.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:25dc33618d45456ccb1d37bce44bc78cf269909aa14c4db2e03d63146a8a1493"}, + {file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:71936a8b3b977ddd0b694c28c6a34f4fff2e9dd201969a4ff5d5fc7742d614b0"}, + {file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:936bc20503ce24770c71938d1369461f0c5320830800933bc3956e2a4ded930e"}, + {file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:af0a583efaacc52ae2521f8d7910aff65cdb093091d76291ac5820d5e947fc1c"}, + {file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f1c23e24a7000da892a312fb17e33c5f94f8b001de44b7cf8ba2e36fbd15859e"}, + {file = "coverage-7.13.0-cp313-cp313t-win32.whl", hash = "sha256:5f8a0297355e652001015e93be345ee54393e45dc3050af4a0475c5a2b767d46"}, + {file = "coverage-7.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6abb3a4c52f05e08460bd9acf04fec027f8718ecaa0d09c40ffbc3fbd70ecc39"}, + {file = "coverage-7.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:3ad968d1e3aa6ce5be295ab5fe3ae1bf5bb4769d0f98a80a0252d543a2ef2e9e"}, + {file = "coverage-7.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:453b7ec753cf5e4356e14fe858064e5520c460d3bbbcb9c35e55c0d21155c256"}, + {file = "coverage-7.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:af827b7cbb303e1befa6c4f94fd2bf72f108089cfa0f8abab8f4ca553cf5ca5a"}, + {file = "coverage-7.13.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9987a9e4f8197a1000280f7cc089e3ea2c8b3c0a64d750537809879a7b4ceaf9"}, + {file = "coverage-7.13.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3188936845cd0cb114fa6a51842a304cdbac2958145d03be2377ec41eb285d19"}, + {file = "coverage-7.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2bdb3babb74079f021696cb46b8bb5f5661165c385d3a238712b031a12355be"}, + {file = "coverage-7.13.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7464663eaca6adba4175f6c19354feea61ebbdd735563a03d1e472c7072d27bb"}, + {file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8069e831f205d2ff1f3d355e82f511eb7c5522d7d413f5db5756b772ec8697f8"}, + {file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6fb2d5d272341565f08e962cce14cdf843a08ac43bd621783527adb06b089c4b"}, + {file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5e70f92ef89bac1ac8a99b3324923b4749f008fdbd7aa9cb35e01d7a284a04f9"}, + {file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4b5de7d4583e60d5fd246dd57fcd3a8aa23c6e118a8c72b38adf666ba8e7e927"}, + {file = "coverage-7.13.0-cp314-cp314-win32.whl", hash = "sha256:a6c6e16b663be828a8f0b6c5027d36471d4a9f90d28444aa4ced4d48d7d6ae8f"}, + {file = "coverage-7.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:0900872f2fdb3ee5646b557918d02279dc3af3dfb39029ac4e945458b13f73bc"}, + {file = "coverage-7.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:3a10260e6a152e5f03f26db4a407c4c62d3830b9af9b7c0450b183615f05d43b"}, + {file = "coverage-7.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9097818b6cc1cfb5f174e3263eba4a62a17683bcfe5c4b5d07f4c97fa51fbf28"}, + {file = "coverage-7.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0018f73dfb4301a89292c73be6ba5f58722ff79f51593352759c1790ded1cabe"}, + {file = "coverage-7.13.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:166ad2a22ee770f5656e1257703139d3533b4a0b6909af67c6b4a3adc1c98657"}, + {file = "coverage-7.13.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f6aaef16d65d1787280943f1c8718dc32e9cf141014e4634d64446702d26e0ff"}, + {file = "coverage-7.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e999e2dcc094002d6e2c7bbc1fb85b58ba4f465a760a8014d97619330cdbbbf3"}, + {file = "coverage-7.13.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:00c3d22cf6fb1cf3bf662aaaa4e563be8243a5ed2630339069799835a9cc7f9b"}, + {file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22ccfe8d9bb0d6134892cbe1262493a8c70d736b9df930f3f3afae0fe3ac924d"}, + {file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9372dff5ea15930fea0445eaf37bbbafbc771a49e70c0aeed8b4e2c2614cc00e"}, + {file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:69ac2c492918c2461bc6ace42d0479638e60719f2a4ef3f0815fa2df88e9f940"}, + {file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:739c6c051a7540608d097b8e13c76cfa85263ced467168dc6b477bae3df7d0e2"}, + {file = "coverage-7.13.0-cp314-cp314t-win32.whl", hash = "sha256:fe81055d8c6c9de76d60c94ddea73c290b416e061d40d542b24a5871bad498b7"}, + {file = "coverage-7.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:445badb539005283825959ac9fa4a28f712c214b65af3a2c464f1adc90f5fcbc"}, + {file = "coverage-7.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:de7f6748b890708578fc4b7bb967d810aeb6fcc9bff4bb77dbca77dab2f9df6a"}, + {file = "coverage-7.13.0-py3-none-any.whl", hash = "sha256:850d2998f380b1e266459ca5b47bc9e7daf9af1d070f66317972f382d46f1904"}, + {file = "coverage-7.13.0.tar.gz", hash = "sha256:a394aa27f2d7ff9bc04cf703817773a59ad6dfbd577032e690f961d2460ee936"}, +] + +[package.dependencies] +tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""} + +[package.extras] +toml = ["tomli"] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +description = "Backport of PEP 654 (exception groups)" +optional = false +python-versions = ">=3.7" +files = [ + {file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"}, + {file = "exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219"}, +] + +[package.dependencies] +typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} + +[package.extras] +test = ["pytest (>=6)"] + +[[package]] +name = "iniconfig" +version = "2.3.0" +description = "brain-dead simple config-ini parsing" +optional = false +python-versions = ">=3.10" +files = [ + {file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"}, + {file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"}, +] + +[[package]] +name = "packaging" +version = "25.0" +description = "Core utilities for Python packages" +optional = false +python-versions = ">=3.8" +files = [ + {file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"}, + {file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"}, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +description = "plugin and hook calling mechanisms for python" +optional = false +python-versions = ">=3.9" +files = [ + {file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"}, + {file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"}, +] + +[package.extras] +dev = ["pre-commit", "tox"] +testing = ["coverage", "pytest", "pytest-benchmark"] + +[[package]] +name = "pygments" +version = "2.19.2" +description = "Pygments is a syntax highlighting package written in Python." +optional = false +python-versions = ">=3.8" +files = [ + {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"}, + {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"}, +] + +[package.extras] +windows-terminal = ["colorama (>=0.4.6)"] + +[[package]] +name = "pytest" +version = "9.0.2" +description = "pytest: simple powerful testing with Python" +optional = false +python-versions = ">=3.10" +files = [ + {file = "pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b"}, + {file = "pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11"}, +] + +[package.dependencies] +colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""} +exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""} +iniconfig = ">=1.0.1" +packaging = ">=22" +pluggy = ">=1.5,<2" +pygments = ">=2.7.2" +tomli = {version = ">=1", markers = "python_version < \"3.11\""} + +[package.extras] +dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"] + +[[package]] +name = "pytest-cov" +version = "7.0.0" +description = "Pytest plugin for measuring coverage." +optional = false +python-versions = ">=3.9" +files = [ + {file = "pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861"}, + {file = "pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1"}, +] + +[package.dependencies] +coverage = {version = ">=7.10.6", extras = ["toml"]} +pluggy = ">=1.2" +pytest = ">=7" + +[package.extras] +testing = ["process-tests", "pytest-xdist", "virtualenv"] + +[[package]] +name = "tomli" +version = "2.3.0" +description = "A lil' TOML parser" +optional = false +python-versions = ">=3.8" +files = [ + {file = "tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45"}, + {file = "tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba"}, + {file = "tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf"}, + {file = "tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441"}, + {file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845"}, + {file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c"}, + {file = "tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456"}, + {file = "tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be"}, + {file = "tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac"}, + {file = "tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22"}, + {file = "tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f"}, + {file = "tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52"}, + {file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8"}, + {file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6"}, + {file = "tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876"}, + {file = "tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878"}, + {file = "tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b"}, + {file = "tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae"}, + {file = "tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b"}, + {file = "tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf"}, + {file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f"}, + {file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05"}, + {file = "tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606"}, + {file = "tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999"}, + {file = "tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e"}, + {file = "tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3"}, + {file = "tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc"}, + {file = "tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0"}, + {file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879"}, + {file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005"}, + {file = "tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463"}, + {file = "tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8"}, + {file = "tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77"}, + {file = "tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf"}, + {file = "tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530"}, + {file = "tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b"}, + {file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67"}, + {file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f"}, + {file = "tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0"}, + {file = "tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba"}, + {file = "tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b"}, + {file = "tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549"}, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +description = "Backported and Experimental Type Hints for Python 3.9+" +optional = false +python-versions = ">=3.9" +files = [ + {file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"}, + {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, +] [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "53f2eabc9c26446fbcc00d348c47878e118afc2054778c3c803a0a8028af27d9" +content-hash = "535a18dadbd873841a37b9823dc0871877559c03798b7fc25b86f030f191e630" diff --git a/pyproject.toml b/pyproject.toml index 43c9c90..b911822 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "enroll" -version = "0.0.1" +version = "0.0.2" description = "Enroll a server's running state retrospectively into Ansible" authors = ["Miguel Jacq "] license = "GPL-3.0-or-later" @@ -14,6 +14,10 @@ python = "^3.10" [tool.poetry.scripts] enroll = "enroll.cli:main" +[tool.poetry.group.test.dependencies] +pytest = "^9.0.2" +pytest-cov = "^7.0.0" + [build-system] requires = ["poetry-core>=1.8.0"] build-backend = "poetry.core.masonry.api" diff --git a/tests.sh b/tests.sh new file mode 100755 index 0000000..cf5e03b --- /dev/null +++ b/tests.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +poetry run pytest -vvvv --cov=enroll --cov-report=term-missing --disable-warnings + +poetry run enroll enroll --harvest /tmp/bundle --out /tmp/ansible && \ + cd /tmp/ansible && \ + sudo ansible-playbook playbook.yml -i "localhost," -c local --check --diff + diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..2b99213 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,7 @@ +import sys +from pathlib import Path + +# Ensure repository root is on sys.path so `import enroll` resolves to the local package. +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..a93c509 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,77 @@ +import sys + +import enroll.cli as cli + + +def test_cli_harvest_subcommand_calls_harvest(monkeypatch, capsys, tmp_path): + called = {} + + def fake_harvest(out: str): + called["out"] = out + return str(tmp_path / "state.json") + + monkeypatch.setattr(cli, "harvest", fake_harvest) + monkeypatch.setattr(sys, "argv", ["enroll", "harvest", "--out", str(tmp_path)]) + + cli.main() + assert called["out"] == str(tmp_path) + captured = capsys.readouterr() + assert str(tmp_path / "state.json") in captured.out + + +def test_cli_manifest_subcommand_calls_manifest(monkeypatch, tmp_path): + called = {} + + def fake_manifest(harvest_dir: str, out_dir: str): + called["harvest"] = harvest_dir + called["out"] = out_dir + + monkeypatch.setattr(cli, "manifest", fake_manifest) + monkeypatch.setattr( + sys, + "argv", + [ + "enroll", + "manifest", + "--harvest", + str(tmp_path / "bundle"), + "--out", + str(tmp_path / "ansible"), + ], + ) + + cli.main() + assert called["harvest"] == str(tmp_path / "bundle") + assert called["out"] == str(tmp_path / "ansible") + + +def test_cli_enroll_subcommand_runs_harvest_then_manifest(monkeypatch, tmp_path): + calls = [] + + def fake_harvest(bundle_dir: str): + calls.append(("harvest", bundle_dir)) + return str(tmp_path / "bundle" / "state.json") + + def fake_manifest(bundle_dir: str, out_dir: str): + calls.append(("manifest", bundle_dir, out_dir)) + + monkeypatch.setattr(cli, "harvest", fake_harvest) + monkeypatch.setattr(cli, "manifest", fake_manifest) + monkeypatch.setattr( + sys, + "argv", + [ + "enroll", + "enroll", + "--harvest", + str(tmp_path / "bundle"), + "--out", + str(tmp_path / "ansible"), + ], + ) + + cli.main() + assert calls == [ + ("harvest", str(tmp_path / "bundle")), + ("manifest", str(tmp_path / "bundle"), str(tmp_path / "ansible")), + ] diff --git a/tests/test_harvest.py b/tests/test_harvest.py new file mode 100644 index 0000000..8e19fb4 --- /dev/null +++ b/tests/test_harvest.py @@ -0,0 +1,141 @@ +import json +from pathlib import Path + +import enroll.harvest as h +from enroll.systemd import UnitInfo + + +class AllowAllPolicy: + def deny_reason(self, path: str): + return None + + +def test_harvest_dedup_manual_packages_and_builds_etc_custom( + monkeypatch, tmp_path: Path +): + bundle = tmp_path / "bundle" + + import os + + real_isfile = os.path.isfile + real_isdir = os.path.isdir + real_exists = os.path.exists + real_islink = os.path.islink + + # Fake filesystem: two /etc files exist, only one is dpkg-owned. + files = { + "/etc/openvpn/server.conf": b"server", + "/etc/default/keyboard": b"kbd", + } + dirs = {"/etc", "/etc/openvpn", "/etc/default"} + + def fake_isfile(p: str) -> bool: + if p.startswith("/etc/") or p == "/etc": + return p in files + return real_isfile(p) + + def fake_isdir(p: str) -> bool: + if p.startswith("/etc"): + return p in dirs + return real_isdir(p) + + def fake_islink(p: str) -> bool: + if p.startswith("/etc"): + return False + return real_islink(p) + + def fake_exists(p: str) -> bool: + if p.startswith("/etc"): + return p in files or p in dirs + return real_exists(p) + + def fake_walk(root: str): + if root == "/etc": + yield ("/etc/openvpn", [], ["server.conf"]) + yield ("/etc/default", [], ["keyboard"]) + elif root == "/etc/openvpn": + yield ("/etc/openvpn", [], ["server.conf"]) + elif root == "/etc/default": + yield ("/etc/default", [], ["keyboard"]) + else: + yield (root, [], []) + + monkeypatch.setattr(h.os.path, "isfile", fake_isfile) + monkeypatch.setattr(h.os.path, "isdir", fake_isdir) + monkeypatch.setattr(h.os.path, "islink", fake_islink) + monkeypatch.setattr(h.os.path, "exists", fake_exists) + monkeypatch.setattr(h.os, "walk", fake_walk) + + # Avoid real system access + monkeypatch.setattr(h, "list_enabled_services", lambda: ["openvpn.service"]) + monkeypatch.setattr( + h, + "get_unit_info", + lambda unit: UnitInfo( + name=unit, + fragment_path="/lib/systemd/system/openvpn.service", + dropin_paths=[], + env_files=[], + exec_paths=["/usr/sbin/openvpn"], + active_state="inactive", + sub_state="dead", + unit_file_state="enabled", + condition_result=None, + ), + ) + + # Debian package index: openvpn owns /etc/openvpn/server.conf; keyboard is unowned. + def fake_build_index(): + owned_etc = {"/etc/openvpn/server.conf"} + etc_owner_map = {"/etc/openvpn/server.conf": "openvpn"} + topdir_to_pkgs = {"openvpn": {"openvpn"}} + pkg_to_etc_paths = {"openvpn": ["/etc/openvpn/server.conf"], "curl": []} + return owned_etc, etc_owner_map, topdir_to_pkgs, pkg_to_etc_paths + + monkeypatch.setattr(h, "build_dpkg_etc_index", fake_build_index) + + # openvpn conffile hash mismatch => should be captured under service role + monkeypatch.setattr( + h, + "parse_status_conffiles", + lambda: {"openvpn": {"/etc/openvpn/server.conf": "old"}}, + ) + monkeypatch.setattr(h, "read_pkg_md5sums", lambda pkg: {}) + monkeypatch.setattr(h, "file_md5", lambda path: "new") + + monkeypatch.setattr( + h, "dpkg_owner", lambda p: "openvpn" if "openvpn" in p else None + ) + monkeypatch.setattr(h, "list_manual_packages", lambda: ["openvpn", "curl"]) + monkeypatch.setattr(h, "collect_non_system_users", lambda: []) + + monkeypatch.setattr(h, "stat_triplet", lambda p: ("root", "root", "0644")) + + # Avoid needing source files on disk by implementing our own bundle copier + def fake_copy(bundle_dir: str, role_name: str, abs_path: str, src_rel: str): + dst = Path(bundle_dir) / "artifacts" / role_name / src_rel + dst.parent.mkdir(parents=True, exist_ok=True) + dst.write_bytes(files.get(abs_path, b"")) + + monkeypatch.setattr(h, "_copy_into_bundle", fake_copy) + + state_path = h.harvest(str(bundle), policy=AllowAllPolicy()) + st = json.loads(Path(state_path).read_text(encoding="utf-8")) + + assert "openvpn" in st["manual_packages"] + assert "curl" in st["manual_packages"] + assert "openvpn" in st["manual_packages_skipped"] + assert all(pr["package"] != "openvpn" for pr in st["package_roles"]) + assert any(pr["package"] == "curl" for pr in st["package_roles"]) + + # Service role captured modified conffile + svc = st["services"][0] + assert svc["unit"] == "openvpn.service" + assert "openvpn" in svc["packages"] + assert any(mf["path"] == "/etc/openvpn/server.conf" for mf in svc["managed_files"]) + + # Unowned /etc/default/keyboard is attributed to etc_custom only + etc_custom = st["etc_custom"] + assert any( + mf["path"] == "/etc/default/keyboard" for mf in etc_custom["managed_files"] + ) diff --git a/tests/test_manifest.py b/tests/test_manifest.py new file mode 100644 index 0000000..71f2030 --- /dev/null +++ b/tests/test_manifest.py @@ -0,0 +1,115 @@ +import json +from pathlib import Path + +from enroll.manifest import manifest + + +def test_manifest_writes_roles_and_playbook_with_clean_when(tmp_path: Path): + bundle = tmp_path / "bundle" + out = tmp_path / "ansible" + (bundle / "artifacts" / "foo" / "etc").mkdir(parents=True, exist_ok=True) + (bundle / "artifacts" / "foo" / "etc" / "foo.conf").write_text( + "x", encoding="utf-8" + ) + + state = { + "host": {"hostname": "test", "os": "debian"}, + "users": { + "role_name": "users", + "users": [ + { + "name": "alice", + "uid": 1000, + "gid": 1000, + "gecos": "Alice", + "home": "/home/alice", + "shell": "/bin/bash", + "primary_group": "alice", + "supplementary_groups": ["docker", "qubes"], + } + ], + "managed_files": [], + "excluded": [], + "notes": [], + }, + "etc_custom": { + "role_name": "etc_custom", + "managed_files": [ + { + "path": "/etc/default/keyboard", + "src_rel": "etc/default/keyboard", + "owner": "root", + "group": "root", + "mode": "0644", + "reason": "custom_unowned", + } + ], + "excluded": [], + "notes": [], + }, + "services": [ + { + "unit": "foo.service", + "role_name": "foo", + "packages": ["foo"], + "active_state": "inactive", + "sub_state": "dead", + "unit_file_state": "enabled", + "condition_result": "no", + "managed_files": [ + { + "path": "/etc/foo.conf", + "src_rel": "etc/foo.conf", + "owner": "root", + "group": "root", + "mode": "0644", + "reason": "modified_conffile", + } + ], + "excluded": [], + "notes": [], + } + ], + "package_roles": [ + { + "package": "curl", + "role_name": "curl", + "managed_files": [], + "excluded": [], + "notes": [], + } + ], + } + + bundle.mkdir(parents=True, exist_ok=True) + (bundle / "state.json").write_text(json.dumps(state, indent=2), encoding="utf-8") + + # Create artifact for etc_custom file so copy works + (bundle / "artifacts" / "etc_custom" / "etc" / "default").mkdir( + parents=True, exist_ok=True + ) + (bundle / "artifacts" / "etc_custom" / "etc" / "default" / "keyboard").write_text( + "kbd", encoding="utf-8" + ) + + manifest(str(bundle), str(out)) + + # Service role: conditional start must be a clean Ansible expression + tasks = (out / "roles" / "foo" / "tasks" / "main.yml").read_text(encoding="utf-8") + assert "when: foo_start | bool" in tasks + # Ensure we didn't emit deprecated/broken '{{ }}' delimiters in when: + for line in tasks.splitlines(): + if line.lstrip().startswith("when:"): + assert "{{" not in line and "}}" not in line + + defaults = (out / "roles" / "foo" / "defaults" / "main.yml").read_text( + encoding="utf-8" + ) + assert "foo_start: false" in defaults + + # Playbook should include users, etc_custom, packages, and services + pb = (out / "playbook.yml").read_text(encoding="utf-8") + assert "- users" in pb + assert "- etc_custom" in pb + assert "- curl" in pb + assert "- foo" in pb diff --git a/tests/test_secrets.py b/tests/test_secrets.py new file mode 100644 index 0000000..f66d4cb --- /dev/null +++ b/tests/test_secrets.py @@ -0,0 +1,8 @@ +from enroll.secrets import SecretPolicy + + +def test_secret_policy_denies_common_backup_files(): + pol = SecretPolicy() + assert pol.deny_reason("/etc/shadow-") == "denied_path" + assert pol.deny_reason("/etc/passwd-") == "denied_path" + assert pol.deny_reason("/etc/group-") == "denied_path" From 9532462535ab9a210e7c27774113a525a66e1f30 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Mon, 15 Dec 2025 11:28:59 +1100 Subject: [PATCH 2/5] Changes that make ansible-lint happy. nosec on the subprocess commands --- enroll/harvest.py | 14 +++++++++++++- enroll/manifest.py | 6 +++--- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/enroll/harvest.py b/enroll/harvest.py index 9df950d..faa9d42 100644 --- a/enroll/harvest.py +++ b/enroll/harvest.py @@ -3,6 +3,7 @@ from __future__ import annotations import glob import json import os +import re import shutil from dataclasses import dataclass, asdict from typing import Dict, List, Optional, Set @@ -130,8 +131,19 @@ def _safe_name(s: str) -> str: return "".join(out).replace("-", "_") +def _role_id(raw: str) -> str: + # normalize separators first + s = re.sub(r"[^A-Za-z0-9]+", "_", raw) + # split CamelCase -> snake_case + s = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", s) + s = s.lower() + s = re.sub(r"_+", "_", s).strip("_") + if not re.match(r"^[a-z_]", s): + s = "r_" + s + return s + def _role_name_from_unit(unit: str) -> str: - base = unit.removesuffix(".service") + base = _role_id(unit.removesuffix(".service")) return _safe_name(base) diff --git a/enroll/manifest.py b/enroll/manifest.py index a7395e1..440bf9c 100644 --- a/enroll/manifest.py +++ b/enroll/manifest.py @@ -35,7 +35,7 @@ def _write_role_scaffold(role_dir: str) -> None: def _write_playbook(path: str, roles: List[str]) -> None: - pb_lines = ["---", "- hosts: all", " become: true", " roles:"] + pb_lines = ["---", "- name: Apply all roles on host", " hosts: all", " become: true", " roles:"] for r in roles: pb_lines.append(f" - {r}") with open(path, "w", encoding="utf-8") as f: @@ -314,7 +314,7 @@ Unowned /etc config files not attributed to packages or services. f.write(defaults) handlers = """--- -- name: systemd daemon-reload +- name: Run systemd daemon-reload ansible.builtin.systemd: daemon_reload: true @@ -444,7 +444,7 @@ Generated from `{unit}`. f.write(defaults) handlers = """--- -- name: systemd daemon-reload +- name: Run systemd daemon-reload ansible.builtin.systemd: daemon_reload: true """ From 4cdc78915f95f34c46a20eb7af1e51954655cd50 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Mon, 15 Dec 2025 11:29:08 +1100 Subject: [PATCH 3/5] Changes that make ansible-lint happy. nosec on the subprocess commands --- enroll/accounts.py | 2 +- enroll/debian.py | 12 +++++++----- enroll/harvest.py | 1 + enroll/manifest.py | 13 +++++++++++-- enroll/systemd.py | 6 +++--- 5 files changed, 23 insertions(+), 11 deletions(-) diff --git a/enroll/accounts.py b/enroll/accounts.py index 7c92f4e..cf2fcd3 100644 --- a/enroll/accounts.py +++ b/enroll/accounts.py @@ -146,7 +146,7 @@ def collect_non_system_users() -> List[UserRecord]: gid=gid, gecos=gecos, home=home, - shell=shell, # nosec + shell=shell, # nosec primary_group=primary_group, supplementary_groups=supp, ssh_files=ssh_files, diff --git a/enroll/debian.py b/enroll/debian.py index 6324d94..d3f1563 100644 --- a/enroll/debian.py +++ b/enroll/debian.py @@ -3,19 +3,19 @@ from __future__ import annotations import glob import hashlib import os -import subprocess # nosec +import subprocess # nosec from typing import Dict, List, Optional, Set, Tuple def _run(cmd: list[str]) -> str: - p = subprocess.run(cmd, check=False, text=True, capture_output=True) # nosec + p = subprocess.run(cmd, check=False, text=True, capture_output=True) # nosec if p.returncode != 0: raise RuntimeError(f"Command failed: {cmd}\n{p.stderr}") return p.stdout def dpkg_owner(path: str) -> Optional[str]: - p = subprocess.run(["dpkg", "-S", path], text=True, capture_output=True) #nosec + p = subprocess.run(["dpkg", "-S", path], text=True, capture_output=True) # nosec if p.returncode != 0: return None left = p.stdout.split(":", 1)[0].strip() @@ -25,7 +25,9 @@ def dpkg_owner(path: str) -> Optional[str]: def list_manual_packages() -> List[str]: """Return packages marked as manually installed (apt-mark showmanual).""" - p = subprocess.run(["apt-mark", "showmanual"], text=True, capture_output=True) #nosec + p = subprocess.run( + ["apt-mark", "showmanual"], text=True, capture_output=True + ) # nosec if p.returncode != 0: return [] pkgs: List[str] = [] @@ -154,7 +156,7 @@ def read_pkg_md5sums(pkg: str) -> Dict[str, str]: def file_md5(path: str) -> str: - h = hashlib.md5() # nosec + h = hashlib.md5() # nosec with open(path, "rb") as f: for chunk in iter(lambda: f.read(1024 * 1024), b""): h.update(chunk) diff --git a/enroll/harvest.py b/enroll/harvest.py index faa9d42..306d8af 100644 --- a/enroll/harvest.py +++ b/enroll/harvest.py @@ -142,6 +142,7 @@ def _role_id(raw: str) -> str: s = "r_" + s return s + def _role_name_from_unit(unit: str) -> str: base = _role_id(unit.removesuffix(".service")) return _safe_name(base) diff --git a/enroll/manifest.py b/enroll/manifest.py index 440bf9c..0fb6fae 100644 --- a/enroll/manifest.py +++ b/enroll/manifest.py @@ -35,7 +35,13 @@ def _write_role_scaffold(role_dir: str) -> None: def _write_playbook(path: str, roles: List[str]) -> None: - pb_lines = ["---", "- name: Apply all roles on host", " hosts: all", " become: true", " roles:"] + pb_lines = [ + "---", + "- name: Apply all roles on host", + " hosts: all", + " become: true", + " roles:", + ] for r in roles: pb_lines.append(f" - {r}") with open(path, "w", encoding="utf-8") as f: @@ -528,5 +534,8 @@ Generated for manual package `{pkg}`. # Playbooks _write_playbook( os.path.join(out_dir, "playbook.yml"), - manifested_users_roles + manifested_etc_custom_roles + manifested_pkg_roles + manifested_service_roles, + manifested_users_roles + + manifested_etc_custom_roles + + manifested_pkg_roles + + manifested_service_roles, ) diff --git a/enroll/systemd.py b/enroll/systemd.py index 828c518..ae8ce8d 100644 --- a/enroll/systemd.py +++ b/enroll/systemd.py @@ -1,7 +1,7 @@ from __future__ import annotations import re -import subprocess # nosec +import subprocess # nosec from dataclasses import dataclass from typing import List, Optional @@ -27,7 +27,7 @@ class UnitQueryError(RuntimeError): def _run(cmd: list[str]) -> str: - p = subprocess.run(cmd, check=False, text=True, capture_output=True) # nosec + p = subprocess.run(cmd, check=False, text=True, capture_output=True) # nosec if p.returncode != 0: raise RuntimeError(f"Command failed: {cmd}\n{p.stderr}") return p.stdout @@ -81,7 +81,7 @@ def get_unit_info(unit: str) -> UnitInfo: "-p", "ConditionResult", "--no-page", - ], # nosec + ], # nosec check=False, text=True, capture_output=True, From 3f0e38dadf8a280320bec3a5bcbe97f1c51447fa Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Mon, 15 Dec 2025 11:29:19 +1100 Subject: [PATCH 4/5] Add ansible-lint in tests --- .forgejo/workflows/ci.yml | 2 +- tests.sh | 22 +++++++++++++++++++--- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 4f9d0e7..d0707db 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: run: | apt-get update DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ - ansible + ansible ansible-lint - name: Install Poetry run: | diff --git a/tests.sh b/tests.sh index cf5e03b..68842f4 100755 --- a/tests.sh +++ b/tests.sh @@ -1,8 +1,24 @@ #!/bin/bash +set -eo pipefail + +# Pytests poetry run pytest -vvvv --cov=enroll --cov-report=term-missing --disable-warnings -poetry run enroll enroll --harvest /tmp/bundle --out /tmp/ansible && \ - cd /tmp/ansible && \ - sudo ansible-playbook playbook.yml -i "localhost," -c local --check --diff +BUNDLE_DIR="/tmp/bundle" +ANSIBLE_DIR="/tmp/ansible" +rm -rf "${BUNDLE_DIR}" "${ANSIBLE_DIR}" +# Generate data +poetry run \ + enroll enroll \ + --harvest "${BUNDLE_DIR}" \ + --out "${ANSIBLE_DIR}" + +builtin cd "${ANSIBLE_DIR}" + +# Lint +ansible-lint "${ANSIBLE_DIR}" + +# Run +sudo ansible-playbook playbook.yml -i "localhost," -c local --check --diff From 839d2b938175a567fb81ad942b2bf9b4633124ac Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Mon, 15 Dec 2025 11:48:09 +1100 Subject: [PATCH 5/5] Add logo, add AppImage --- README.md | 32 +++++++- enroll.svg | 112 +++++++++++++++++++++++++ poetry.lock | 217 ++++++++++++++++++++++++++++++++++++++++++++++++- pyproject.toml | 8 ++ release.sh | 17 ++++ 5 files changed, 384 insertions(+), 2 deletions(-) create mode 100644 enroll.svg create mode 100755 release.sh diff --git a/README.md b/README.md index 27b790d..95e57d9 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ # Enroll +
+ Enroll logo +
+ **enroll** inspects a Linux machine (currently Debian-only) and generates Ansible roles for things it finds running on the machine. It aims to be **optimistic and noninteractive**: @@ -12,7 +16,27 @@ It aims to be **optimistic and noninteractive**: - Captures miscellaneous `/etc` files that it can't attribute to a package, and installs it in an `etc_custom` role - Avoids trying to start systemd services that were detected as being Inactive during harvest -## Install (Poetry) +## Install + +### AppImage + +Download the AppImage file from the Releases page (verify with GPG if you wish, my fingerprint is [here](https://mig5.net/static/mig5.asc), +then make it executable and run it: + +```bash +chmod +x Enroll.AppImage +./Enroll.AppImage +``` + +### Pip + +```bash +pip install enroll +``` + +### Poetry + +Clone this repository with git, then: ```bash poetry install @@ -59,3 +83,9 @@ ansible-playbook -i "localhost," -c local /tmp/enroll-ansible/playbook.yml ## Troubleshooting - Run as root for the most complete harvest (`sudo ...`). + +## Found a bug, have a suggestion? + +You can e-mail me (see the pyproject.toml for details) or contact me on the Fediverse: + +https://goto.mig5.net/@mig5 diff --git a/enroll.svg b/enroll.svg new file mode 100644 index 0000000..c986e1f --- /dev/null +++ b/enroll.svg @@ -0,0 +1,112 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + enroll + + + diff --git a/poetry.lock b/poetry.lock index 3bffe25..2cd66d4 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,5 +1,138 @@ # This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +[[package]] +name = "certifi" +version = "2025.11.12" +description = "Python package for providing Mozilla's CA Bundle." +optional = false +python-versions = ">=3.7" +files = [ + {file = "certifi-2025.11.12-py3-none-any.whl", hash = "sha256:97de8790030bbd5c2d96b7ec782fc2f7820ef8dba6db909ccf95449f2d062d4b"}, + {file = "certifi-2025.11.12.tar.gz", hash = "sha256:d8ab5478f2ecd78af242878415affce761ca6bc54a22a27e026d7c25357c3316"}, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.4" +description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." +optional = false +python-versions = ">=3.7" +files = [ + {file = "charset_normalizer-3.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e824f1492727fa856dd6eda4f7cee25f8518a12f3c4a56a74e8095695089cf6d"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4bd5d4137d500351a30687c2d3971758aac9a19208fc110ccb9d7188fbe709e8"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:027f6de494925c0ab2a55eab46ae5129951638a49a34d87f4c3eda90f696b4ad"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f820802628d2694cb7e56db99213f930856014862f3fd943d290ea8438d07ca8"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:798d75d81754988d2565bff1b97ba5a44411867c0cf32b77a7e8f8d84796b10d"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d1bb833febdff5c8927f922386db610b49db6e0d4f4ee29601d71e7c2694313"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cd98cdc06614a2f768d2b7286d66805f94c48cde050acdbbb7db2600ab3197e"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:077fbb858e903c73f6c9db43374fd213b0b6a778106bc7032446a8e8b5b38b93"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:244bfb999c71b35de57821b8ea746b24e863398194a4014e4c76adc2bbdfeff0"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:64b55f9dce520635f018f907ff1b0df1fdc31f2795a922fb49dd14fbcdf48c84"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:faa3a41b2b66b6e50f84ae4a68c64fcd0c44355741c6374813a800cd6695db9e"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6515f3182dbe4ea06ced2d9e8666d97b46ef4c75e326b79bb624110f122551db"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc00f04ed596e9dc0da42ed17ac5e596c6ccba999ba6bd92b0e0aef2f170f2d6"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-win32.whl", hash = "sha256:f34be2938726fc13801220747472850852fe6b1ea75869a048d6f896838c896f"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:a61900df84c667873b292c3de315a786dd8dac506704dea57bc957bd31e22c7d"}, + {file = "charset_normalizer-3.4.4-cp310-cp310-win_arm64.whl", hash = "sha256:cead0978fc57397645f12578bfd2d5ea9138ea0fac82b2f63f7f7c6877986a69"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6e1fcf0720908f200cd21aa4e6750a48ff6ce4afe7ff5a79a90d5ed8a08296f8"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f819d5fe9234f9f82d75bdfa9aef3a3d72c4d24a6e57aeaebba32a704553aa0"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a59cb51917aa591b1c4e6a43c132f0cdc3c76dbad6155df4e28ee626cc77a0a3"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ef3c867360f88ac904fd3f5e1f902f13307af9052646963ee08ff4f131adafc"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d9e45d7faa48ee908174d8fe84854479ef838fc6a705c9315372eacbc2f02897"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:840c25fb618a231545cbab0564a799f101b63b9901f2569faecd6b222ac72381"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ca5862d5b3928c4940729dacc329aa9102900382fea192fc5e52eb69d6093815"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9c7f57c3d666a53421049053eaacdd14bbd0a528e2186fcb2e672effd053bb0"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:277e970e750505ed74c832b4bf75dac7476262ee2a013f5574dd49075879e161"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:31fd66405eaf47bb62e8cd575dc621c56c668f27d46a61d975a249930dd5e2a4"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0d3d8f15c07f86e9ff82319b3d9ef6f4bf907608f53fe9d92b28ea9ae3d1fd89"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9f7fcd74d410a36883701fafa2482a6af2ff5ba96b9a620e9e0721e28ead5569"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ebf3e58c7ec8a8bed6d66a75d7fb37b55e5015b03ceae72a8e7c74495551e224"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-win32.whl", hash = "sha256:eecbc200c7fd5ddb9a7f16c7decb07b566c29fa2161a16cf67b8d068bd21690a"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:5ae497466c7901d54b639cf42d5b8c1b6a4fead55215500d2f486d34db48d016"}, + {file = "charset_normalizer-3.4.4-cp311-cp311-win_arm64.whl", hash = "sha256:65e2befcd84bc6f37095f5961e68a6f077bf44946771354a28ad434c2cce0ae1"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0a98e6759f854bd25a58a73fa88833fba3b7c491169f86ce1180c948ab3fd394"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5b290ccc2a263e8d185130284f8501e3e36c5e02750fc6b6bdeb2e9e96f1e25"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74bb723680f9f7a6234dcf67aea57e708ec1fbdf5699fb91dfd6f511b0a320ef"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1e34719c6ed0b92f418c7c780480b26b5d9c50349e9a9af7d76bf757530350d"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2437418e20515acec67d86e12bf70056a33abdacb5cb1655042f6538d6b085a8"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:11d694519d7f29d6cd09f6ac70028dba10f92f6cdd059096db198c283794ac86"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ac1c4a689edcc530fc9d9aa11f5774b9e2f33f9a0c6a57864e90908f5208d30a"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21d142cc6c0ec30d2efee5068ca36c128a30b0f2c53c1c07bd78cb6bc1d3be5f"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5dbe56a36425d26d6cfb40ce79c314a2e4dd6211d51d6d2191c00bed34f354cc"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5bfbb1b9acf3334612667b61bd3002196fe2a1eb4dd74d247e0f2a4d50ec9bbf"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d055ec1e26e441f6187acf818b73564e6e6282709e9bcb5b63f5b23068356a15"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:af2d8c67d8e573d6de5bc30cdb27e9b95e49115cd9baad5ddbd1a6207aaa82a9"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:780236ac706e66881f3b7f2f32dfe90507a09e67d1d454c762cf642e6e1586e0"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-win32.whl", hash = "sha256:5833d2c39d8896e4e19b689ffc198f08ea58116bee26dea51e362ecc7cd3ed26"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:a79cfe37875f822425b89a82333404539ae63dbdddf97f84dcbc3d339aae9525"}, + {file = "charset_normalizer-3.4.4-cp312-cp312-win_arm64.whl", hash = "sha256:376bec83a63b8021bb5c8ea75e21c4ccb86e7e45ca4eb81146091b56599b80c3"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14"}, + {file = "charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c"}, + {file = "charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:ce8a0633f41a967713a59c4139d29110c07e826d131a316b50ce11b1d79b4f84"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaabd426fe94daf8fd157c32e571c85cb12e66692f15516a83a03264b08d06c3"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4ef880e27901b6cc782f1b95f82da9313c0eb95c3af699103088fa0ac3ce9ac"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2aaba3b0819274cc41757a1da876f810a3e4d7b6eb25699253a4effef9e8e4af"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:778d2e08eda00f4256d7f672ca9fef386071c9202f5e4607920b86d7803387f2"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f155a433c2ec037d4e8df17d18922c3a0d9b3232a396690f17175d2946f0218d"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a8bf8d0f749c5757af2142fe7903a9df1d2e8aa3841559b2bad34b08d0e2bcf3"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:194f08cbb32dc406d6e1aea671a68be0823673db2832b38405deba2fb0d88f63"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:6aee717dcfead04c6eb1ce3bd29ac1e22663cdea57f943c87d1eab9a025438d7"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:cd4b7ca9984e5e7985c12bc60a6f173f3c958eae74f3ef6624bb6b26e2abbae4"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:b7cf1017d601aa35e6bb650b6ad28652c9cd78ee6caff19f3c28d03e1c80acbf"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:e912091979546adf63357d7e2ccff9b44f026c075aeaf25a52d0e95ad2281074"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:5cb4d72eea50c8868f5288b7f7f33ed276118325c1dfd3957089f6b519e1382a"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-win32.whl", hash = "sha256:837c2ce8c5a65a2035be9b3569c684358dfbf109fd3b6969630a87535495ceaa"}, + {file = "charset_normalizer-3.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:44c2a8734b333e0578090c4cd6b16f275e07aa6614ca8715e6c038e865e70576"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:a9768c477b9d7bd54bc0c86dbaebdec6f03306675526c9927c0e8a04e8f94af9"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bee1e43c28aa63cb16e5c14e582580546b08e535299b8b6158a7c9c768a1f3d"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fd44c878ea55ba351104cb93cc85e74916eb8fa440ca7903e57575e97394f608"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0f04b14ffe5fdc8c4933862d8306109a2c51e0704acfa35d51598eb45a1e89fc"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cd09d08005f958f370f539f186d10aec3377d55b9eeb0d796025d4886119d76e"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4fe7859a4e3e8457458e2ff592f15ccb02f3da787fcd31e0183879c3ad4692a1"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fa09f53c465e532f4d3db095e0c55b615f010ad81803d383195b6b5ca6cbf5f3"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:7fa17817dc5625de8a027cb8b26d9fefa3ea28c8253929b8d6649e705d2835b6"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:5947809c8a2417be3267efc979c47d76a079758166f7d43ef5ae8e9f92751f88"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:4902828217069c3c5c71094537a8e623f5d097858ac6ca8252f7b4d10b7560f1"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:7c308f7e26e4363d79df40ca5b2be1c6ba9f02bdbccfed5abddb7859a6ce72cf"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:2c9d3c380143a1fedbff95a312aa798578371eb29da42106a29019368a475318"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:cb01158d8b88ee68f15949894ccc6712278243d95f344770fa7593fa2d94410c"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-win32.whl", hash = "sha256:2677acec1a2f8ef614c6888b5b4ae4060cc184174a938ed4e8ef690e15d3e505"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:f8e160feb2aed042cd657a72acc0b481212ed28b1b9a95c0cee1621b524e1966"}, + {file = "charset_normalizer-3.4.4-cp39-cp39-win_arm64.whl", hash = "sha256:b5d84d37db046c5ca74ee7bb47dd6cbc13f80665fdde3e8040bdd3fb015ecb50"}, + {file = "charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f"}, + {file = "charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a"}, +] + [[package]] name = "colorama" version = "0.4.6" @@ -118,6 +251,20 @@ tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.1 [package.extras] toml = ["tomli"] +[[package]] +name = "desktop-entry-lib" +version = "5.0" +description = "A library for working with .desktop files" +optional = false +python-versions = ">=3.10" +files = [ + {file = "desktop_entry_lib-5.0-py3-none-any.whl", hash = "sha256:e60a0c2c5e42492dbe5378e596b1de87d1b1c4dc74d1f41998a164ee27a1226f"}, + {file = "desktop_entry_lib-5.0.tar.gz", hash = "sha256:9a621bac1819fe21021356e41fec0ac096ed56e6eb5dcfe0639cd8654914b864"}, +] + +[package.extras] +xdg-desktop-portal = ["jeepney"] + [[package]] name = "exceptiongroup" version = "1.3.1" @@ -135,6 +282,20 @@ typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""} [package.extras] test = ["pytest (>=6)"] +[[package]] +name = "idna" +version = "3.11" +description = "Internationalized Domain Names in Applications (IDNA)" +optional = false +python-versions = ">=3.8" +files = [ + {file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"}, + {file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"}, +] + +[package.extras] +all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] + [[package]] name = "iniconfig" version = "2.3.0" @@ -186,6 +347,22 @@ files = [ [package.extras] windows-terminal = ["colorama (>=0.4.6)"] +[[package]] +name = "pyproject-appimage" +version = "4.2" +description = "Generate AppImages from your Python projects" +optional = false +python-versions = ">=3.9" +files = [ + {file = "pyproject_appimage-4.2-py3-none-any.whl", hash = "sha256:d6892643db5759dc06531a4546bdab404a519c63814c060f8749979a8625d9cc"}, + {file = "pyproject_appimage-4.2.tar.gz", hash = "sha256:6b6387250cb1e6ecbb08a13f5810749396ebe8637f2f35bf2296bfdd5e65cd6e"}, +] + +[package.dependencies] +desktop-entry-lib = "*" +requests = "*" +tomli = {version = "*", markers = "python_version < \"3.11\""} + [[package]] name = "pytest" version = "9.0.2" @@ -228,6 +405,27 @@ pytest = ">=7" [package.extras] testing = ["process-tests", "pytest-xdist", "virtualenv"] +[[package]] +name = "requests" +version = "2.32.5" +description = "Python HTTP for Humans." +optional = false +python-versions = ">=3.9" +files = [ + {file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"}, + {file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"}, +] + +[package.dependencies] +certifi = ">=2017.4.17" +charset_normalizer = ">=2,<4" +idna = ">=2.5,<4" +urllib3 = ">=1.21.1,<3" + +[package.extras] +socks = ["PySocks (>=1.5.6,!=1.5.7)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] + [[package]] name = "tomli" version = "2.3.0" @@ -290,7 +488,24 @@ files = [ {file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"}, ] +[[package]] +name = "urllib3" +version = "2.6.2" +description = "HTTP library with thread-safe connection pooling, file post, and more." +optional = false +python-versions = ">=3.9" +files = [ + {file = "urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd"}, + {file = "urllib3-2.6.2.tar.gz", hash = "sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797"}, +] + +[package.extras] +brotli = ["brotli (>=1.2.0)", "brotlicffi (>=1.2.0.0)"] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["backports-zstd (>=1.0.0)"] + [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "535a18dadbd873841a37b9823dc0871877559c03798b7fc25b86f030f191e630" +content-hash = "47927c9a0ec1b9be8dad6b4428cabaacc50a840e117c7e5f397d97ead83d5b76" diff --git a/pyproject.toml b/pyproject.toml index b911822..89acb38 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,14 @@ enroll = "enroll.cli:main" pytest = "^9.0.2" pytest-cov = "^7.0.0" + +[tool.poetry.group.dev.dependencies] +pyproject-appimage = "^4.2" + [build-system] requires = ["poetry-core>=1.8.0"] build-backend = "poetry.core.masonry.api" + +[tool.pyproject-appimage] +script = "enroll" +output = "Enroll.AppImage" diff --git a/release.sh b/release.sh new file mode 100755 index 0000000..95e6412 --- /dev/null +++ b/release.sh @@ -0,0 +1,17 @@ +#!/bin/bash + +set -eo pipefail + +# Clean caches etc +filedust -y . + +# Publish to Pypi +poetry build +poetry publish + +# Make AppImage +poetry run pyproject-appimage +mv Enroll.AppImage dist/ + +# Sign packages +for file in `ls -1 dist/`; do qubes-gpg-client --batch --armor --detach-sign dist/$file > dist/$file.asc; done