diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml deleted file mode 100644 index d0707db..0000000 --- a/.forgejo/workflows/ci.yml +++ /dev/null @@ -1,46 +0,0 @@ -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 ansible-lint - - - 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 deleted file mode 100644 index 6e86a79..0000000 --- a/.forgejo/workflows/lint.yml +++ /dev/null @@ -1,40 +0,0 @@ -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 deleted file mode 100644 index fad2f6f..0000000 --- a/.forgejo/workflows/trivy.yml +++ /dev/null @@ -1,40 +0,0 @@ -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 deleted file mode 100644 index 3b6678d..0000000 --- a/CHANGELOG.txt +++ /dev/null @@ -1,13 +0,0 @@ -# 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 95e57d9..ff6f353 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,5 @@ # 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**: @@ -13,30 +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 - -### 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: +## Install (Poetry) ```bash poetry install @@ -47,22 +21,22 @@ poetry run enroll --help On the host (root recommended): -### 1. Harvest state/information about the host +### 1. Generate a bundle of state/information about the host ```bash -sudo poetry run enroll harvest --out /tmp/enroll-harvest +sudo poetry run enroll harvest --out /tmp/enroll-bundle ``` -### 2. Generate Ansible manifests (roles/playbook) from that harvest +### 2. Generate Ansible manifests (roles/playbook) from that bundle ```bash -sudo poetry run enroll manifest --harvest /tmp/enroll-harvest --out /tmp/enroll-ansible +sudo poetry run enroll manifest --bundle /tmp/enroll-bundle --out /tmp/enroll-ansible ``` ### Alternatively, do both steps in one shot: ```bash -sudo poetry run enroll enroll --harvest /tmp/enroll-harvest --out /tmp/enroll-ansible +sudo poetry run enroll export --bundle /tmp/enroll-bundle --out /tmp/enroll-ansible ``` Then run: @@ -83,9 +57,3 @@ 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 deleted file mode 100644 index c986e1f..0000000 --- a/enroll.svg +++ /dev/null @@ -1,112 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - enroll - - - diff --git a/enroll/accounts.py b/enroll/accounts.py index cf2fcd3..f47c0e4 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, Set, Tuple +from typing import Dict, List, Optional, Set, Tuple @dataclass @@ -27,12 +27,7 @@ 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: @@ -42,9 +37,7 @@ 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: @@ -67,9 +60,7 @@ def parse_passwd( 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]] = {} @@ -139,18 +130,16 @@ 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, # nosec - 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, + primary_group=primary_group, + supplementary_groups=supp, + ssh_files=ssh_files, + )) return users diff --git a/enroll/cli.py b/enroll/cli.py index 530a388..abdc01f 100644 --- a/enroll/cli.py +++ b/enroll/cli.py @@ -9,32 +9,16 @@ 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") - h.add_argument("--out", required=True, help="Harvest output directory") + h = sub.add_parser("harvest", help="Harvest service/package/config state into a bundle") + h.add_argument("--out", required=True, help="Bundle output directory") - 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", - ) + 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") - 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", - ) + 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") args = ap.parse_args() @@ -42,7 +26,7 @@ def main() -> None: path = harvest(args.out) print(path) elif args.cmd == "manifest": - manifest(args.harvest, args.out) - elif args.cmd == "enroll": - harvest(args.harvest) - manifest(args.harvest, args.out) + manifest(args.bundle, args.out) + elif args.cmd == "export": + harvest(args.bundle) + manifest(args.bundle, args.out) diff --git a/enroll/debian.py b/enroll/debian.py index d3f1563..2874403 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 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) 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) if p.returncode != 0: return None left = p.stdout.split(":", 1)[0].strip() @@ -23,11 +23,10 @@ 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 - ) # nosec + p = subprocess.run(["apt-mark", "showmanual"], text=True, capture_output=True) if p.returncode != 0: return [] pkgs: List[str] = [] @@ -38,7 +37,6 @@ 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]]]: @@ -85,9 +83,7 @@ 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. @@ -156,7 +152,7 @@ def read_pkg_md5sums(pkg: str) -> Dict[str, str]: def file_md5(path: str) -> str: - h = hashlib.md5() # nosec + h = hashlib.md5() with open(path, "rb") as f: for chunk in iter(lambda: f.read(1024 * 1024), b""): h.update(chunk) @@ -168,7 +164,6 @@ 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 306d8af..437cd66 100644 --- a/enroll/harvest.py +++ b/enroll/harvest.py @@ -3,7 +3,6 @@ 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 @@ -19,7 +18,8 @@ from .debian import ( stat_triplet, ) from .secrets import SecretPolicy -from .accounts import collect_non_system_users +from .accounts import collect_non_system_users, UserRecord + @dataclass @@ -43,10 +43,6 @@ 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] @@ -70,59 +66,15 @@ 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] = [] @@ -131,30 +83,16 @@ 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 = _role_id(unit.removesuffix(".service")) + base = unit.removesuffix(".service") return _safe_name(base) def _role_name_from_pkg(pkg: str) -> str: - return _safe_name(pkg) + return "pkg_" + _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) @@ -176,9 +114,7 @@ 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) @@ -187,20 +123,16 @@ def _add_pkgs_from_etc_topdirs( 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): @@ -238,10 +170,7 @@ 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() @@ -256,20 +185,14 @@ 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=[], - active_state=None, - sub_state=None, - unit_file_state=None, - condition_result=None, - managed_files=[], - excluded=[], - notes=[str(e)], - ) - ) + service_snaps.append(ServiceSnapshot( + unit=unit, + role_name=role, + packages=[], + managed_files=[], + excluded=[], + notes=[str(e)], + )) continue pkgs: Set[str] = set() @@ -320,7 +243,6 @@ 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: @@ -345,9 +267,7 @@ 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) @@ -365,49 +285,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, + )) - 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, - ) - ) + service_snaps.append(ServiceSnapshot( + unit=unit, + role_name=role, + packages=sorted(pkgs), + 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] = [] @@ -441,17 +343,13 @@ 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: @@ -475,31 +373,25 @@ 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) @@ -510,7 +402,7 @@ def harvest(bundle_dir: str, policy: Optional[SecretPolicy] = None) -> str: users_list: List[dict] = [] try: - user_records = collect_non_system_users() + us except Exception as e: user_records = [] users_notes.append(f"Failed to enumerate users: {e!r}") @@ -518,51 +410,47 @@ 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 only safe SSH public material: authorized_keys + *.pub + # Copy authorized_keys 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, @@ -572,91 +460,12 @@ 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 0fb6fae..b502abb 100644 --- a/enroll/manifest.py +++ b/enroll/manifest.py @@ -35,13 +35,7 @@ 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 = ["---", "- hosts: all", " become: true", " roles:"] for r in roles: pb_lines.append(f" - {r}") with open(path, "w", encoding="utf-8") as f: @@ -56,14 +50,12 @@ 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] = [] @@ -94,17 +86,11 @@ 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 @@ -126,7 +112,7 @@ users_accounts: lines.append(f" group: {u.get('primary_group')}") supp = u.get("supplementary_groups") or [] if supp: - lines.append(" groups: " + ",".join(sorted(supp))) + lines.append(" groups: " + ",".join(supp)) lines.append(" append: true") lines.append(f" home: {u.get('home')}") lines.append(" create_home: true") @@ -134,8 +120,9 @@ 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 @@ -176,122 +163,30 @@ 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 # ------------------------- @@ -307,20 +202,15 @@ Unowned /etc config files not attributed to packages or services. 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 = """--- -- name: Run systemd daemon-reload +- name: systemd daemon-reload ansible.builtin.systemd: daemon_reload: true @@ -329,14 +219,10 @@ Unowned /etc config files not attributed to packages or services. 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: @@ -351,8 +237,7 @@ Unowned /etc config files not attributed to packages or services. {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}" @@ -363,44 +248,30 @@ Unowned /etc config files not attributed to packages or services. 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 (preserve running state) + task_parts.append(f"""- name: Ensure {unit} is enabled and running 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", []) @@ -444,24 +315,18 @@ 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 = """--- -- name: Run systemd daemon-reload +- name: systemd daemon-reload 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: @@ -476,37 +341,29 @@ 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", []) @@ -532,10 +389,4 @@ Generated for manual package `{pkg}`. manifested_pkg_roles.append(role) # Playbooks - _write_playbook( - os.path.join(out_dir, "playbook.yml"), - manifested_users_roles - + manifested_etc_custom_roles - + manifested_pkg_roles - + manifested_service_roles, - ) + _write_playbook(os.path.join(out_dir, "playbook.yml"), manifested_users_roles + manifested_pkg_roles + manifested_service_roles) diff --git a/enroll/secrets.py b/enroll/secrets.py index 06514e5..9797e61 100644 --- a/enroll/secrets.py +++ b/enroll/secrets.py @@ -8,15 +8,6 @@ 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", @@ -26,9 +17,9 @@ DEFAULT_DENY_GLOBS = [ ] SENSITIVE_CONTENT_PATTERNS = [ - 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"), + 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"), ] diff --git a/enroll/systemd.py b/enroll/systemd.py index ae8ce8d..0cfbed0 100644 --- a/enroll/systemd.py +++ b/enroll/systemd.py @@ -1,7 +1,7 @@ from __future__ import annotations import re -import subprocess # nosec +import subprocess from dataclasses import dataclass from typing import List, Optional @@ -12,11 +12,7 @@ class UnitInfo: fragment_path: Optional[str] dropin_paths: List[str] env_files: List[str] - exec_paths: List[str] - active_state: Optional[str] - sub_state: Optional[str] - unit_file_state: Optional[str] - condition_result: Optional[str] + exec_paths: List[str] # binaries from ExecStart "path=" parts class UnitQueryError(RuntimeError): @@ -27,22 +23,14 @@ 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) 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() @@ -51,7 +39,7 @@ def list_enabled_services() -> List[str]: unit = parts[0].strip() if not unit.endswith(".service"): continue - # Skip template units like "getty@.service" + # Skip template units like "getty@.service" which are enabled but not valid for systemctl show if unit.endswith("@.service") or "@.service" in unit: continue units.append(unit) @@ -61,27 +49,13 @@ 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", - "-p", - "ActiveState", - "-p", - "SubState", - "-p", - "UnitFileState", - "-p", - "ConditionResult", + "systemctl", "show", unit, + "-p", "FragmentPath", + "-p", "DropInPaths", + "-p", "EnvironmentFiles", + "-p", "ExecStart", "--no-page", - ], # nosec + ], check=False, text=True, capture_output=True, @@ -96,6 +70,7 @@ 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] = [] @@ -112,8 +87,4 @@ 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 2cd66d4..a9b0222 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,511 +1,7 @@ # 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" -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 = "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" -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 = "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" -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 = "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" -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 = "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" -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"}, -] - -[[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)"] +package = [] [metadata] lock-version = "2.0" python-versions = "^3.10" -content-hash = "47927c9a0ec1b9be8dad6b4428cabaacc50a840e117c7e5f397d97ead83d5b76" +content-hash = "53f2eabc9c26446fbcc00d348c47878e118afc2054778c3c803a0a8028af27d9" diff --git a/pyproject.toml b/pyproject.toml index 89acb38..43c9c90 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "enroll" -version = "0.0.2" +version = "0.0.1" description = "Enroll a server's running state retrospectively into Ansible" authors = ["Miguel Jacq "] license = "GPL-3.0-or-later" @@ -14,18 +14,6 @@ python = "^3.10" [tool.poetry.scripts] enroll = "enroll.cli:main" -[tool.poetry.group.test.dependencies] -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 deleted file mode 100755 index 95e6412..0000000 --- a/release.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/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 diff --git a/tests.sh b/tests.sh deleted file mode 100755 index 68842f4..0000000 --- a/tests.sh +++ /dev/null @@ -1,24 +0,0 @@ -#!/bin/bash - -set -eo pipefail - -# Pytests -poetry run pytest -vvvv --cov=enroll --cov-report=term-missing --disable-warnings - -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 diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index 2b99213..0000000 --- a/tests/conftest.py +++ /dev/null @@ -1,7 +0,0 @@ -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 deleted file mode 100644 index a93c509..0000000 --- a/tests/test_cli.py +++ /dev/null @@ -1,77 +0,0 @@ -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 deleted file mode 100644 index 8e19fb4..0000000 --- a/tests/test_harvest.py +++ /dev/null @@ -1,141 +0,0 @@ -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 deleted file mode 100644 index 71f2030..0000000 --- a/tests/test_manifest.py +++ /dev/null @@ -1,115 +0,0 @@ -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 deleted file mode 100644 index f66d4cb..0000000 --- a/tests/test_secrets.py +++ /dev/null @@ -1,8 +0,0 @@ -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"