Compare commits

..

5 commits

Author SHA1 Message Date
839d2b9381
Add logo, add AppImage
Some checks failed
CI / test (push) Failing after 1m46s
Lint / test (push) Successful in 34s
Trivy / test (push) Successful in 26s
2025-12-15 11:48:09 +11:00
3f0e38dadf
Add ansible-lint in tests 2025-12-15 11:29:19 +11:00
4cdc78915f
Changes that make ansible-lint happy. nosec on the subprocess commands 2025-12-15 11:29:08 +11:00
9532462535
Changes that make ansible-lint happy. nosec on the subprocess commands 2025-12-15 11:28:59 +11:00
227be6dd51
Many tweaks 2025-12-15 11:04:54 +11:00
22 changed files with 1777 additions and 179 deletions

46
.forgejo/workflows/ci.yml Normal file
View file

@ -0,0 +1,46 @@
name: CI
on:
push:
jobs:
test:
runs-on: docker
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install system dependencies
run: |
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
ansible 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"

View file

@ -0,0 +1,40 @@
name: Lint
on:
push:
jobs:
test:
runs-on: docker
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install system dependencies
run: |
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
black pyflakes3 python3-bandit
- name: Run linters
run: |
black --diff --check enroll/*
black --diff --check tests/*
pyflakes3 enroll/*
pyflakes3 tests/*
bandit -r enroll/
# Notify if any previous step in this job failed
- name: Notify on failure
if: ${{ failure() }}
env:
WEBHOOK_URL: ${{ secrets.NODERED_WEBHOOK_URL }}
REPOSITORY: ${{ forgejo.repository }}
RUN_NUMBER: ${{ forgejo.run_number }}
SERVER_URL: ${{ forgejo.server_url }}
run: |
curl -X POST \
-H "Content-Type: application/json" \
-d "{\"repository\":\"$REPOSITORY\",\"run_number\":\"$RUN_NUMBER\",\"status\":\"failure\",\"url\":\"$SERVER_URL/$REPOSITORY/actions/runs/$RUN_NUMBER\"}" \
"$WEBHOOK_URL"

View file

@ -0,0 +1,40 @@
name: Trivy
on:
schedule:
- cron: '0 1 * * *'
push:
jobs:
test:
runs-on: docker
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install system dependencies
run: |
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends wget gnupg
wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | gpg --dearmor | tee /usr/share/keyrings/trivy.gpg > /dev/null
echo "deb [signed-by=/usr/share/keyrings/trivy.gpg] https://aquasecurity.github.io/trivy-repo/deb generic main" | tee -a /etc/apt/sources.list.d/trivy.list
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends trivy
- name: Run trivy
run: |
trivy fs --no-progress --ignore-unfixed --format table --disable-telemetry .
# Notify if any previous step in this job failed
- name: Notify on failure
if: ${{ failure() }}
env:
WEBHOOK_URL: ${{ secrets.NODERED_WEBHOOK_URL }}
REPOSITORY: ${{ forgejo.repository }}
RUN_NUMBER: ${{ forgejo.run_number }}
SERVER_URL: ${{ forgejo.server_url }}
run: |
curl -X POST \
-H "Content-Type: application/json" \
-d "{\"repository\":\"$REPOSITORY\",\"run_number\":\"$RUN_NUMBER\",\"status\":\"failure\",\"url\":\"$SERVER_URL/$REPOSITORY/actions/runs/$RUN_NUMBER\"}" \
"$WEBHOOK_URL"

13
CHANGELOG.txt Normal file
View file

@ -0,0 +1,13 @@
# 0.0.2
* Merge pkg_ and roles created based on file/service detection
* Avoid idempotency issue with users (password_lock)
* Rename subcommands/args ('export' is now 'enroll', '--bundle' is now '--harvest')
* Don't try and start systemd services that were Inactive at harvest time
* Capture miscellaneous files in /etc under their own etc_custom role, but not backup files
* Add tests
* Various other bug fixes
# 0.0.1
* Initial commit

View file

@ -1,5 +1,9 @@
# Enroll # Enroll
<div align="center">
<img src="https://git.mig5.net/mig5/enroll/raw/branch/main/enroll.svg" alt="Enroll logo" width="240" />
</div>
**enroll** inspects a Linux machine (currently Debian-only) and generates Ansible roles for things it finds running on the machine. **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**: It aims to be **optimistic and noninteractive**:
@ -9,8 +13,30 @@ It aims to be **optimistic and noninteractive**:
- Also captures **service-relevant custom/unowned files** under `/etc/<service>/...` (e.g. drop-in config includes). - Also captures **service-relevant custom/unowned files** under `/etc/<service>/...` (e.g. drop-in config includes).
- Defensively excludes likely secrets (path denylist + content sniff + size caps). - 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 non-system users that exist on the system, and their SSH public keys
- Captures miscellaneous `/etc` files that it can't attribute to a package, and installs it in an `etc_custom` role
- Avoids trying to start systemd services that were detected as being Inactive during harvest
## Install (Poetry) ## Install
### AppImage
Download the AppImage file from the Releases page (verify with GPG if you wish, my fingerprint is [here](https://mig5.net/static/mig5.asc),
then make it executable and run it:
```bash
chmod +x Enroll.AppImage
./Enroll.AppImage
```
### Pip
```bash
pip install enroll
```
### Poetry
Clone this repository with git, then:
```bash ```bash
poetry install poetry install
@ -21,22 +47,22 @@ poetry run enroll --help
On the host (root recommended): On the host (root recommended):
### 1. Generate a bundle of state/information about the host ### 1. Harvest state/information about the host
```bash ```bash
sudo poetry run enroll harvest --out /tmp/enroll-bundle sudo poetry run enroll harvest --out /tmp/enroll-harvest
``` ```
### 2. Generate Ansible manifests (roles/playbook) from that bundle ### 2. Generate Ansible manifests (roles/playbook) from that harvest
```bash ```bash
sudo poetry run enroll manifest --bundle /tmp/enroll-bundle --out /tmp/enroll-ansible sudo poetry run enroll manifest --harvest /tmp/enroll-harvest --out /tmp/enroll-ansible
``` ```
### Alternatively, do both steps in one shot: ### Alternatively, do both steps in one shot:
```bash ```bash
sudo poetry run enroll export --bundle /tmp/enroll-bundle --out /tmp/enroll-ansible sudo poetry run enroll enroll --harvest /tmp/enroll-harvest --out /tmp/enroll-ansible
``` ```
Then run: Then run:
@ -57,3 +83,9 @@ ansible-playbook -i "localhost," -c local /tmp/enroll-ansible/playbook.yml
## Troubleshooting ## Troubleshooting
- Run as root for the most complete harvest (`sudo ...`). - 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

112
enroll.svg Normal file
View file

@ -0,0 +1,112 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" width="720" height="720" viewBox="0 0 720 720" role="img" aria-label="enroll logo">
<defs>
<linearGradient id="bgGrad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#F7D58A"/>
<stop offset="1" stop-color="#E8B35E"/>
</linearGradient>
<linearGradient id="crumbGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#FFE7BD"/>
<stop offset="1" stop-color="#E6A44A"/>
</linearGradient>
<linearGradient id="shineGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#FFF2D6" stop-opacity="0.85"/>
<stop offset="1" stop-color="#FFF2D6" stop-opacity="0"/>
</linearGradient>
<filter id="softShadow" x="-20%" y="-20%" width="140%" height="140%">
<feGaussianBlur in="SourceAlpha" stdDeviation="6" result="blur"/>
<feOffset dx="0" dy="10" result="off"/>
<feColorMatrix in="off" type="matrix"
values="0 0 0 0 0.15 0 0 0 0 0.08 0 0 0 0 0.02 0 0 0 0.35 0" result="shadow"/>
<feMerge>
<feMergeNode in="shadow"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
<style>
.stroke { stroke:#5A3415; stroke-width:10; stroke-linecap:round; stroke-linejoin:round; }
.thin { stroke:#5A3415; stroke-width:7; stroke-linecap:round; stroke-linejoin:round; }
.text-dark { fill:#5A3415; font-family: ui-rounded, "Arial Rounded MT Bold", system-ui, -apple-system, "Segoe UI", Arial, sans-serif; font-weight: 800; }
.text-light { fill:#8A5A2D; font-family: ui-rounded, "Arial Rounded MT Bold", system-ui, -apple-system, "Segoe UI", Arial, sans-serif; font-weight: 800; }
.sesame { fill:#F8E6C8; opacity:0.92; }
.crumb { fill:#E9B56B; opacity:0.30; }
</style>
</defs>
<!-- NOTE: background removed for transparency -->
<!-- Roll group -->
<g filter="url(#softShadow)" transform="translate(-120,-70)">
<path class="stroke" fill="url(#crumbGrad)"
d="M220 450
C220 320, 320 235, 480 235
C640 235, 740 320, 740 450
C740 565, 640 610, 480 610
C320 610, 220 565, 220 450 Z"/>
<path d="M300 320
C340 285, 410 265, 480 265
C550 265, 620 285, 660 320
C610 305, 545 298, 480 298
C415 298, 350 305, 300 320 Z"
fill="url(#shineGrad)"/>
<g class="thin" opacity="0.85" fill="none">
<path d="M330 320 C350 285, 400 270, 430 315"/>
<path d="M420 305 C450 268, 510 268, 540 305"/>
<path d="M530 315 C560 270, 610 285, 630 320"/>
</g>
<g>
<ellipse class="sesame" cx="335" cy="368" rx="10" ry="6"/>
<ellipse class="sesame" cx="625" cy="368" rx="10" ry="6"/>
<ellipse class="sesame" cx="385" cy="345" rx="10" ry="6"/>
<ellipse class="sesame" cx="575" cy="345" rx="10" ry="6"/>
<ellipse class="sesame" cx="400" cy="330" rx="10" ry="6"/>
<ellipse class="sesame" cx="560" cy="330" rx="10" ry="6"/>
</g>
<g>
<circle class="crumb" cx="310" cy="430" r="6"/>
<circle class="crumb" cx="650" cy="430" r="6"/>
<circle class="crumb" cx="350" cy="470" r="5"/>
<circle class="crumb" cx="610" cy="470" r="5"/>
<circle class="crumb" cx="420" cy="520" r="6"/>
<circle class="crumb" cx="540" cy="520" r="6"/>
</g>
<g>
<circle cx="445" cy="365" r="12" fill="#5A3415"/>
<circle cx="515" cy="365" r="12" fill="#5A3415"/>
<path d="M445 405 C468 430, 492 430, 515 405"
fill="none" stroke="#5A3415" stroke-width="12" stroke-linecap="round"/>
</g>
<g transform="translate(330,470)">
<rect x="0" y="0" width="300" height="92" rx="26" fill="#F7C879" class="thin"/>
<g class="thin" fill="none" opacity="0.95">
<rect x="18" y="16" width="190" height="24" rx="11" fill="#FFE7BD"/>
<rect x="18" y="50" width="190" height="24" rx="11" fill="#FFE7BD"/>
<path d="M18 82 H232"/>
</g>
<g>
<circle cx="245" cy="28" r="6.5" fill="#5A3415"/>
<circle cx="268" cy="28" r="6.5" fill="#5A3415"/>
<circle cx="291" cy="28" r="6.5" fill="#5A3415"/>
<rect x="238" y="52" width="56" height="22" rx="9" fill="#FFE7BD" stroke="#5A3415" stroke-width="7"/>
</g>
</g>
</g>
<!-- Wordmark -->
<text x="360" y="650" text-anchor="middle" font-size="110">
<tspan class="text-dark">en</tspan><tspan class="text-light">roll</tspan>
</text>
</svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

View file

@ -2,7 +2,7 @@ from __future__ import annotations
import os import os
from dataclasses import dataclass from dataclasses import dataclass
from typing import Dict, List, Optional, Set, Tuple from typing import Dict, List, Set, Tuple
@dataclass @dataclass
@ -27,7 +27,12 @@ def parse_login_defs(path: str = "/etc/login.defs") -> Dict[str, int]:
if not line or line.startswith("#"): if not line or line.startswith("#"):
continue continue
parts = line.split() 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: try:
vals[parts[0]] = int(parts[1]) vals[parts[0]] = int(parts[1])
except ValueError: except ValueError:
@ -37,7 +42,9 @@ def parse_login_defs(path: str = "/etc/login.defs") -> Dict[str, int]:
return vals 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]] = [] rows: List[Tuple[str, int, int, str, str, str]] = []
with open(path, "r", encoding="utf-8", errors="replace") as f: with open(path, "r", encoding="utf-8", errors="replace") as f:
for line in f: for line in f:
@ -60,7 +67,9 @@ def parse_passwd(path: str = "/etc/passwd") -> List[Tuple[str, int, int, str, st
return rows 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] = {} gid_to_name: Dict[int, str] = {}
name_to_gid: Dict[str, int] = {} name_to_gid: Dict[str, int] = {}
members: Dict[str, Set[str]] = {} members: Dict[str, Set[str]] = {}
@ -130,16 +139,18 @@ def collect_non_system_users() -> List[UserRecord]:
ssh_files = find_user_ssh_files(home) if home and home.startswith("/") else [] ssh_files = find_user_ssh_files(home) if home and home.startswith("/") else []
users.append(UserRecord( users.append(
name=name, UserRecord(
uid=uid, name=name,
gid=gid, uid=uid,
gecos=gecos, gid=gid,
home=home, gecos=gecos,
shell=shell, home=home,
primary_group=primary_group, shell=shell, # nosec
supplementary_groups=supp, primary_group=primary_group,
ssh_files=ssh_files, supplementary_groups=supp,
)) ssh_files=ssh_files,
)
)
return users return users

View file

@ -9,16 +9,32 @@ def main() -> None:
ap = argparse.ArgumentParser(prog="enroll") ap = argparse.ArgumentParser(prog="enroll")
sub = ap.add_subparsers(dest="cmd", required=True) sub = ap.add_subparsers(dest="cmd", required=True)
h = sub.add_parser("harvest", help="Harvest service/package/config state into a bundle") h = sub.add_parser("harvest", help="Harvest service/package/config state")
h.add_argument("--out", required=True, help="Bundle output directory") h.add_argument("--out", required=True, help="Harvest output directory")
r = sub.add_parser("manifest", help="Render Ansible roles from a harvested bundle") r = sub.add_parser("manifest", help="Render Ansible roles from a harvest")
r.add_argument("--bundle", required=True, help="Path to the bundle directory created by the harvest command") r.add_argument(
r.add_argument("--out", required=True, help="Output directory for generated roles/playbook Ansible manifest") "--harvest",
required=True,
help="Path to the directory created by the harvest command",
)
r.add_argument(
"--out",
required=True,
help="Output directory for generated roles/playbook Ansible manifest",
)
e = sub.add_parser("export", help="Harvest then manifest in one shot") e = sub.add_parser(
e.add_argument("--bundle", required=True, help="Path to the directory to place the bundle in") "enroll", help="Harvest state, then manifest Ansible code, in one shot"
e.add_argument("--out", required=True, help="Output directory for generated roles/playbook Ansible manifest") )
e.add_argument(
"--harvest", required=True, help="Path to the directory to place the harvest in"
)
e.add_argument(
"--out",
required=True,
help="Output directory for generated roles/playbook Ansible manifest",
)
args = ap.parse_args() args = ap.parse_args()
@ -26,7 +42,7 @@ def main() -> None:
path = harvest(args.out) path = harvest(args.out)
print(path) print(path)
elif args.cmd == "manifest": elif args.cmd == "manifest":
manifest(args.bundle, args.out) manifest(args.harvest, args.out)
elif args.cmd == "export": elif args.cmd == "enroll":
harvest(args.bundle) harvest(args.harvest)
manifest(args.bundle, args.out) manifest(args.harvest, args.out)

View file

@ -3,19 +3,19 @@ from __future__ import annotations
import glob import glob
import hashlib import hashlib
import os import os
import subprocess import subprocess # nosec
from typing import Dict, List, Optional, Set, Tuple from typing import Dict, List, Optional, Set, Tuple
def _run(cmd: list[str]) -> str: def _run(cmd: list[str]) -> str:
p = subprocess.run(cmd, check=False, text=True, capture_output=True) p = subprocess.run(cmd, check=False, text=True, capture_output=True) # nosec
if p.returncode != 0: if p.returncode != 0:
raise RuntimeError(f"Command failed: {cmd}\n{p.stderr}") raise RuntimeError(f"Command failed: {cmd}\n{p.stderr}")
return p.stdout return p.stdout
def dpkg_owner(path: str) -> Optional[str]: def dpkg_owner(path: str) -> Optional[str]:
p = subprocess.run(["dpkg", "-S", path], text=True, capture_output=True) p = subprocess.run(["dpkg", "-S", path], text=True, capture_output=True) # nosec
if p.returncode != 0: if p.returncode != 0:
return None return None
left = p.stdout.split(":", 1)[0].strip() left = p.stdout.split(":", 1)[0].strip()
@ -23,10 +23,11 @@ def dpkg_owner(path: str) -> Optional[str]:
return pkg or None return pkg or None
def list_manual_packages() -> List[str]: def list_manual_packages() -> List[str]:
"""Return packages marked as manually installed (apt-mark showmanual).""" """Return packages marked as manually installed (apt-mark showmanual)."""
p = subprocess.run(["apt-mark", "showmanual"], text=True, capture_output=True) p = subprocess.run(
["apt-mark", "showmanual"], text=True, capture_output=True
) # nosec
if p.returncode != 0: if p.returncode != 0:
return [] return []
pkgs: List[str] = [] pkgs: List[str] = []
@ -37,6 +38,7 @@ def list_manual_packages() -> List[str]:
pkgs.append(line) pkgs.append(line)
return sorted(set(pkgs)) return sorted(set(pkgs))
def build_dpkg_etc_index( def build_dpkg_etc_index(
info_dir: str = "/var/lib/dpkg/info", info_dir: str = "/var/lib/dpkg/info",
) -> Tuple[Set[str], Dict[str, str], Dict[str, Set[str]], Dict[str, List[str]]]: ) -> Tuple[Set[str], Dict[str, str], Dict[str, Set[str]], Dict[str, List[str]]]:
@ -83,7 +85,9 @@ def build_dpkg_etc_index(
return owned, owner, topdir_to_pkgs, pkg_to_etc 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. pkg -> { "/etc/foo": md5hex, ... } based on dpkg status "Conffiles" field.
This md5 is the packaged baseline for the conffile. This md5 is the packaged baseline for the conffile.
@ -152,7 +156,7 @@ def read_pkg_md5sums(pkg: str) -> Dict[str, str]:
def file_md5(path: str) -> str: def file_md5(path: str) -> str:
h = hashlib.md5() h = hashlib.md5() # nosec
with open(path, "rb") as f: with open(path, "rb") as f:
for chunk in iter(lambda: f.read(1024 * 1024), b""): for chunk in iter(lambda: f.read(1024 * 1024), b""):
h.update(chunk) h.update(chunk)
@ -164,6 +168,7 @@ def stat_triplet(path: str) -> Tuple[str, str, str]:
mode = oct(st.st_mode & 0o777)[2:].zfill(4) mode = oct(st.st_mode & 0o777)[2:].zfill(4)
import pwd, grp import pwd, grp
try: try:
owner = pwd.getpwuid(st.st_uid).pw_name owner = pwd.getpwuid(st.st_uid).pw_name
except KeyError: except KeyError:

View file

@ -3,6 +3,7 @@ from __future__ import annotations
import glob import glob
import json import json
import os import os
import re
import shutil import shutil
from dataclasses import dataclass, asdict from dataclasses import dataclass, asdict
from typing import Dict, List, Optional, Set from typing import Dict, List, Optional, Set
@ -18,8 +19,7 @@ from .debian import (
stat_triplet, stat_triplet,
) )
from .secrets import SecretPolicy from .secrets import SecretPolicy
from .accounts import collect_non_system_users, UserRecord from .accounts import collect_non_system_users
@dataclass @dataclass
@ -43,6 +43,10 @@ class ServiceSnapshot:
unit: str unit: str
role_name: str role_name: str
packages: List[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] managed_files: List[ManagedFile]
excluded: List[ExcludedFile] excluded: List[ExcludedFile]
notes: List[str] notes: List[str]
@ -66,15 +70,59 @@ class UsersSnapshot:
notes: List[str] notes: List[str]
@dataclass
class EtcCustomSnapshot:
role_name: str
managed_files: List[ManagedFile]
excluded: List[ExcludedFile]
notes: List[str]
ALLOWED_UNOWNED_EXTS = { ALLOWED_UNOWNED_EXTS = {
".conf", ".cfg", ".ini", ".cnf", ".yaml", ".yml", ".json", ".toml", ".conf",
".rules", ".service", ".socket", ".timer", ".target", ".path", ".mount", ".cfg",
".network", ".netdev", ".link", ".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) "", # allow extensionless (common in /etc/default and /etc/init.d)
} }
MAX_UNOWNED_FILES_PER_ROLE = 400 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: def _safe_name(s: str) -> str:
out: List[str] = [] out: List[str] = []
@ -83,16 +131,30 @@ def _safe_name(s: str) -> str:
return "".join(out).replace("-", "_") 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: def _role_name_from_unit(unit: str) -> str:
base = unit.removesuffix(".service") base = _role_id(unit.removesuffix(".service"))
return _safe_name(base) return _safe_name(base)
def _role_name_from_pkg(pkg: str) -> str: def _role_name_from_pkg(pkg: str) -> str:
return "pkg_" + _safe_name(pkg) return _safe_name(pkg)
def _copy_into_bundle(bundle_dir: str, role_name: str, abs_path: str, src_rel: str) -> None: def _copy_into_bundle(
bundle_dir: str, role_name: str, abs_path: str, src_rel: str
) -> None:
dst = os.path.join(bundle_dir, "artifacts", role_name, src_rel) dst = os.path.join(bundle_dir, "artifacts", role_name, src_rel)
os.makedirs(os.path.dirname(dst), exist_ok=True) os.makedirs(os.path.dirname(dst), exist_ok=True)
shutil.copy2(abs_path, dst) shutil.copy2(abs_path, dst)
@ -114,7 +176,9 @@ def _hint_names(unit: str, pkgs: Set[str]) -> Set[str]:
return {h for h in hints if h} 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 h in hints:
for p in topdir_to_pkgs.get(h, set()): for p in topdir_to_pkgs.get(h, set()):
pkgs.add(p) pkgs.add(p)
@ -123,16 +187,20 @@ def _add_pkgs_from_etc_topdirs(hints: Set[str], topdir_to_pkgs: Dict[str, Set[st
def _maybe_add_specific_paths(hints: Set[str]) -> List[str]: def _maybe_add_specific_paths(hints: Set[str]) -> List[str]:
paths: List[str] = [] paths: List[str] = []
for h in hints: for h in hints:
paths.extend([ paths.extend(
f"/etc/default/{h}", [
f"/etc/init.d/{h}", f"/etc/default/{h}",
f"/etc/sysctl.d/{h}.conf", f"/etc/init.d/{h}",
f"/etc/logrotate.d/{h}", f"/etc/sysctl.d/{h}.conf",
]) f"/etc/logrotate.d/{h}",
]
)
return paths 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] = [] found: List[str] = []
for root in roots: for root in roots:
if not os.path.isdir(root): if not os.path.isdir(root):
@ -170,7 +238,10 @@ def harvest(bundle_dir: str, policy: Optional[SecretPolicy] = None) -> str:
os.makedirs(bundle_dir, exist_ok=True) os.makedirs(bundle_dir, exist_ok=True)
if hasattr(os, "geteuid") and os.geteuid() != 0: 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() owned_etc, etc_owner_map, topdir_to_pkgs, pkg_to_etc_paths = build_dpkg_etc_index()
conffiles_by_pkg = parse_status_conffiles() conffiles_by_pkg = parse_status_conffiles()
@ -185,14 +256,20 @@ def harvest(bundle_dir: str, policy: Optional[SecretPolicy] = None) -> str:
try: try:
ui = get_unit_info(unit) ui = get_unit_info(unit)
except UnitQueryError as e: except UnitQueryError as e:
service_snaps.append(ServiceSnapshot( service_snaps.append(
unit=unit, ServiceSnapshot(
role_name=role, unit=unit,
packages=[], role_name=role,
managed_files=[], packages=[],
excluded=[], active_state=None,
notes=[str(e)], sub_state=None,
)) unit_file_state=None,
condition_result=None,
managed_files=[],
excluded=[],
notes=[str(e)],
)
)
continue continue
pkgs: Set[str] = set() pkgs: Set[str] = set()
@ -243,6 +320,7 @@ def harvest(bundle_dir: str, policy: Optional[SecretPolicy] = None) -> str:
if not os.path.isfile(path) or os.path.islink(path): if not os.path.isfile(path) or os.path.islink(path):
continue continue
if path in conff: if path in conff:
# Only capture conffiles when they differ from the package default.
try: try:
current = file_md5(path) current = file_md5(path)
except OSError: except OSError:
@ -267,7 +345,9 @@ def harvest(bundle_dir: str, policy: Optional[SecretPolicy] = None) -> str:
candidates.setdefault(pth, "custom_unowned") candidates.setdefault(pth, "custom_unowned")
if not pkgs and not candidates: 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()): for path, reason in sorted(candidates.items()):
deny = policy.deny_reason(path) deny = policy.deny_reason(path)
@ -285,31 +365,49 @@ def harvest(bundle_dir: str, policy: Optional[SecretPolicy] = None) -> str:
except OSError: except OSError:
excluded.append(ExcludedFile(path=path, reason="unreadable")) excluded.append(ExcludedFile(path=path, reason="unreadable"))
continue continue
managed.append(ManagedFile( managed.append(
path=path, ManagedFile(
src_rel=src_rel, path=path,
owner=owner, src_rel=src_rel,
group=group, owner=owner,
mode=mode, group=group,
reason=reason, mode=mode,
)) reason=reason,
)
)
service_snaps.append(ServiceSnapshot( service_snaps.append(
unit=unit, ServiceSnapshot(
role_name=role, unit=unit,
packages=sorted(pkgs), role_name=role,
managed_files=managed, packages=sorted(pkgs),
excluded=excluded, active_state=ui.active_state,
notes=notes, sub_state=ui.sub_state,
)) unit_file_state=ui.unit_file_state,
condition_result=ui.condition_result,
managed_files=managed,
excluded=excluded,
notes=notes,
)
)
# ------------------------- # -------------------------
# Manual package roles # Manual package roles
# ------------------------- # -------------------------
manual_pkgs = list_manual_packages() manual_pkgs = list_manual_packages()
# Avoid duplicate roles: if a manual package is already managed by any service role, skip its pkg_<name> 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] = [] pkg_snaps: List[PackageSnapshot] = []
for pkg in manual_pkgs: for pkg in manual_pkgs:
if pkg in covered_by_services:
manual_pkgs_skipped.append(pkg)
continue
role = _role_name_from_pkg(pkg) role = _role_name_from_pkg(pkg)
notes: List[str] = [] notes: List[str] = []
excluded: List[ExcludedFile] = [] excluded: List[ExcludedFile] = []
@ -343,13 +441,17 @@ def harvest(bundle_dir: str, policy: Optional[SecretPolicy] = None) -> str:
topdirs = _topdirs_for_package(pkg, pkg_to_etc_paths) topdirs = _topdirs_for_package(pkg, pkg_to_etc_paths)
roots: List[str] = [] roots: List[str] = []
for td in sorted(topdirs): 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/{td}", f"/etc/{td}.d"])
roots.extend([f"/etc/default/{td}"]) roots.extend([f"/etc/default/{td}"])
roots.extend([f"/etc/init.d/{td}"]) roots.extend([f"/etc/init.d/{td}"])
roots.extend([f"/etc/logrotate.d/{td}"]) roots.extend([f"/etc/logrotate.d/{td}"])
roots.extend([f"/etc/sysctl.d/{td}.conf"]) 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") candidates.setdefault(pth, "custom_unowned")
for r in roots: for r in roots:
@ -373,25 +475,31 @@ def harvest(bundle_dir: str, policy: Optional[SecretPolicy] = None) -> str:
except OSError: except OSError:
excluded.append(ExcludedFile(path=path, reason="unreadable")) excluded.append(ExcludedFile(path=path, reason="unreadable"))
continue continue
managed.append(ManagedFile( managed.append(
path=path, ManagedFile(
src_rel=src_rel, path=path,
owner=owner, src_rel=src_rel,
group=group, owner=owner,
mode=mode, group=group,
reason=reason, mode=mode,
)) reason=reason,
)
)
if not pkg_to_etc_paths.get(pkg, []) and not managed: 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( pkg_snaps.append(
package=pkg, PackageSnapshot(
role_name=role, package=pkg,
managed_files=managed, role_name=role,
excluded=excluded, managed_files=managed,
notes=notes, excluded=excluded,
)) notes=notes,
)
)
# ------------------------- # -------------------------
# Users role (non-system users) # Users role (non-system users)
@ -402,7 +510,7 @@ def harvest(bundle_dir: str, policy: Optional[SecretPolicy] = None) -> str:
users_list: List[dict] = [] users_list: List[dict] = []
try: try:
us user_records = collect_non_system_users()
except Exception as e: except Exception as e:
user_records = [] user_records = []
users_notes.append(f"Failed to enumerate users: {e!r}") users_notes.append(f"Failed to enumerate users: {e!r}")
@ -410,47 +518,51 @@ def harvest(bundle_dir: str, policy: Optional[SecretPolicy] = None) -> str:
users_role_name = "users" users_role_name = "users"
for u in user_records: for u in user_records:
users_list.append({ users_list.append(
"name": u.name, {
"uid": u.uid, "name": u.name,
"gid": u.gid, "uid": u.uid,
"gecos": u.gecos, "gid": u.gid,
"home": u.home, "gecos": u.gecos,
"shell": u.shell, "home": u.home,
"primary_group": u.primary_group, "shell": u.shell,
"supplementary_groups": u.supplementary_groups, "primary_group": u.primary_group,
}) "supplementary_groups": u.supplementary_groups,
}
)
# Copy authorized_keys # Copy only safe SSH public material: authorized_keys + *.pub
for sf in u.ssh_files: for sf in u.ssh_files:
deny = policy.deny_reason(sf) deny = policy.deny_reason(sf)
if deny: if deny:
users_excluded.append(ExcludedFile(path=sf, reason=deny)) users_excluded.append(ExcludedFile(path=sf, reason=deny))
continue continue
# Force safe modes; still record current owner/group for reference.
try: try:
owner, group, mode = stat_triplet(sf) owner, group, mode = stat_triplet(sf)
except OSError: except OSError:
users_excluded.append(ExcludedFile(path=sf, reason="unreadable")) users_excluded.append(ExcludedFile(path=sf, reason="unreadable"))
continue continue
src_rel = sf.lstrip("/") src_rel = sf.lstrip("/")
try: try:
_copy_into_bundle(bundle_dir, users_role_name, sf, src_rel) _copy_into_bundle(bundle_dir, users_role_name, sf, src_rel)
except OSError: except OSError:
users_excluded.append(ExcludedFile(path=sf, reason="unreadable")) users_excluded.append(ExcludedFile(path=sf, reason="unreadable"))
continue continue
reason = (
reason = "authorized_keys" if sf.endswith("/authorized_keys") else "ssh_public_key" "authorized_keys"
users_managed.append(ManagedFile( if sf.endswith("/authorized_keys")
path=sf, else "ssh_public_key"
src_rel=src_rel, )
owner=owner, users_managed.append(
group=group, ManagedFile(
mode=mode, path=sf,
reason=reason, src_rel=src_rel,
)) owner=owner,
group=group,
mode=mode,
reason=reason,
)
)
users_snapshot = UsersSnapshot( users_snapshot = UsersSnapshot(
role_name=users_role_name, role_name=users_role_name,
@ -460,12 +572,91 @@ def harvest(bundle_dir: str, policy: Optional[SecretPolicy] = None) -> str:
notes=users_notes, 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 = { state = {
"host": {"hostname": os.uname().nodename, "os": "debian"}, "host": {"hostname": os.uname().nodename, "os": "debian"},
"users": asdict(users_snapshot), "users": asdict(users_snapshot),
"services": [asdict(s) for s in service_snaps], "services": [asdict(s) for s in service_snaps],
"manual_packages": manual_pkgs, "manual_packages": manual_pkgs,
"manual_packages_skipped": manual_pkgs_skipped,
"package_roles": [asdict(p) for p in pkg_snaps], "package_roles": [asdict(p) for p in pkg_snaps],
"etc_custom": asdict(etc_custom_snapshot),
} }
state_path = os.path.join(bundle_dir, "state.json") state_path = os.path.join(bundle_dir, "state.json")

View file

@ -35,7 +35,13 @@ def _write_role_scaffold(role_dir: str) -> None:
def _write_playbook(path: str, roles: List[str]) -> None: def _write_playbook(path: str, roles: List[str]) -> None:
pb_lines = ["---", "- hosts: all", " become: true", " roles:"] pb_lines = [
"---",
"- name: Apply all roles on host",
" hosts: all",
" become: true",
" roles:",
]
for r in roles: for r in roles:
pb_lines.append(f" - {r}") pb_lines.append(f" - {r}")
with open(path, "w", encoding="utf-8") as f: with open(path, "w", encoding="utf-8") as f:
@ -50,12 +56,14 @@ def manifest(bundle_dir: str, out_dir: str) -> None:
services: List[Dict[str, Any]] = state.get("services", []) services: List[Dict[str, Any]] = state.get("services", [])
package_roles: List[Dict[str, Any]] = state.get("package_roles", []) package_roles: List[Dict[str, Any]] = state.get("package_roles", [])
users_snapshot: Dict[str, Any] = state.get("users", {}) 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) os.makedirs(out_dir, exist_ok=True)
roles_root = os.path.join(out_dir, "roles") roles_root = os.path.join(out_dir, "roles")
os.makedirs(roles_root, exist_ok=True) os.makedirs(roles_root, exist_ok=True)
manifested_users_roles: List[str] = [] manifested_users_roles: List[str] = []
manifested_etc_custom_roles: List[str] = []
manifested_service_roles: List[str] = [] manifested_service_roles: List[str] = []
manifested_pkg_roles: List[str] = [] manifested_pkg_roles: List[str] = []
@ -86,11 +94,17 @@ def manifest(bundle_dir: str, out_dir: str) -> None:
# defaults: store users list (handy for later), but tasks are explicit for readability # defaults: store users list (handy for later), but tasks are explicit for readability
defaults = """--- defaults = """---
users_accounts: 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) 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") f.write("---\ndependencies: []\n")
# tasks # tasks
@ -112,7 +126,7 @@ users_accounts:
lines.append(f" group: {u.get('primary_group')}") lines.append(f" group: {u.get('primary_group')}")
supp = u.get("supplementary_groups") or [] supp = u.get("supplementary_groups") or []
if supp: if supp:
lines.append(" groups: " + ",".join(supp)) lines.append(" groups: " + ",".join(sorted(supp)))
lines.append(" append: true") lines.append(" append: true")
lines.append(f" home: {u.get('home')}") lines.append(f" home: {u.get('home')}")
lines.append(" create_home: true") lines.append(" create_home: true")
@ -120,9 +134,8 @@ users_accounts:
lines.append(f" shell: {u.get('shell')}") lines.append(f" shell: {u.get('shell')}")
if u.get("gecos"): if u.get("gecos"):
# quote to avoid YAML surprises # quote to avoid YAML surprises
gec = u.get("gecos").replace('"', '\"') gec = u.get("gecos").replace('"', '"')
lines.append(f' comment: "{gec}"') lines.append(f' comment: "{gec}"')
lines.append(" password_lock: true")
lines.append(" state: present") lines.append(" state: present")
# Ensure ~/.ssh # Ensure ~/.ssh
@ -163,30 +176,122 @@ users_accounts:
lines.append(f" mode: '{mode}'") lines.append(f" mode: '{mode}'")
tasks = "\n".join(lines).rstrip() + "\n" 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) f.write(tasks)
# handlers (none needed) # 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") f.write("---\n")
readme = """# users readme = (
"""# users
Generated non-system user accounts and SSH public material. Generated non-system user accounts and SSH public material.
## Users ## 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 ## 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 ## 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 ## 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: with open(os.path.join(role_dir, "README.md"), "w", encoding="utf-8") as f:
f.write(readme) f.write(readme)
manifested_users_roles.append(role) 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 # Service roles
# ------------------------- # -------------------------
@ -202,15 +307,20 @@ Generated non-system user accounts and SSH public material.
var_prefix = role var_prefix = role
was_active = svc.get("active_state") == "active"
defaults = f"""--- defaults = f"""---
{var_prefix}_packages: {var_prefix}_packages:
{_yaml_list(pkgs, indent=2)} {_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) f.write(defaults)
handlers = """--- handlers = """---
- name: systemd daemon-reload - name: Run systemd daemon-reload
ansible.builtin.systemd: ansible.builtin.systemd:
daemon_reload: true daemon_reload: true
@ -219,10 +329,14 @@ Generated non-system user accounts and SSH public material.
name: "{{ unit_name }}" name: "{{ unit_name }}"
state: restarted 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) 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] 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: def copy_task(mf: Dict[str, Any], notify: str | None) -> str:
@ -237,7 +351,8 @@ Generated non-system user accounts and SSH public material.
{notify_line}""" {notify_line}"""
task_parts: List[str] = [] task_parts: List[str] = []
task_parts.append(f"""--- task_parts.append(
f"""---
- name: Set unit name - name: Set unit name
ansible.builtin.set_fact: ansible.builtin.set_fact:
unit_name: "{unit}" unit_name: "{unit}"
@ -248,30 +363,44 @@ Generated non-system user accounts and SSH public material.
state: present state: present
update_cache: true update_cache: true
when: {var_prefix}_packages | length > 0 when: {var_prefix}_packages | length > 0
""") """
)
if systemd_files: if systemd_files:
for mf in systemd_files: for mf in systemd_files:
task_parts.append(copy_task(mf, "[systemd daemon-reload]")) 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 ansible.builtin.meta: flush_handlers
""") """
)
for mf in other_files: for mf in other_files:
task_parts.append(copy_task(mf, "[Restart service]")) task_parts.append(copy_task(mf, "[Restart service]"))
task_parts.append(f"""- name: Ensure {unit} is enabled and running task_parts.append(
f"""- name: Ensure {unit} is enabled (preserve running state)
ansible.builtin.service: ansible.builtin.service:
name: "{{{{ unit_name }}}}" name: "{{{{ unit_name }}}}"
enabled: true enabled: true
- name: Start {unit} if it was active at harvest time
ansible.builtin.service:
name: "{{{{ unit_name }}}}"
state: started state: started
""") when: {var_prefix}_start | bool
"""
)
tasks = "\n".join(task_parts).rstrip() + "\n" 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) 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") f.write("---\ndependencies: []\n")
excluded = svc.get("excluded", []) excluded = svc.get("excluded", [])
@ -315,18 +444,24 @@ Generated from `{unit}`.
{var_prefix}_packages: {var_prefix}_packages:
- {pkg} - {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) f.write(defaults)
handlers = """--- handlers = """---
- name: systemd daemon-reload - name: Run systemd daemon-reload
ansible.builtin.systemd: ansible.builtin.systemd:
daemon_reload: true 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) 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] 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: def copy_task(mf: Dict[str, Any], notify: str | None) -> str:
@ -341,29 +476,37 @@ Generated from `{unit}`.
{notify_line}""" {notify_line}"""
task_parts: List[str] = [] task_parts: List[str] = []
task_parts.append(f"""--- task_parts.append(
f"""---
- name: Install manual package {pkg} - name: Install manual package {pkg}
ansible.builtin.apt: ansible.builtin.apt:
name: "{{{{ {var_prefix}_packages }}}}" name: "{{{{ {var_prefix}_packages }}}}"
state: present state: present
update_cache: true update_cache: true
""") """
)
if systemd_files: if systemd_files:
for mf in systemd_files: for mf in systemd_files:
task_parts.append(copy_task(mf, "[systemd daemon-reload]")) 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 ansible.builtin.meta: flush_handlers
""") """
)
for mf in other_files: for mf in other_files:
task_parts.append(copy_task(mf, None)) task_parts.append(copy_task(mf, None))
tasks = "\n".join(task_parts).rstrip() + "\n" 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) 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") f.write("---\ndependencies: []\n")
excluded = pr.get("excluded", []) excluded = pr.get("excluded", [])
@ -389,4 +532,10 @@ Generated for manual package `{pkg}`.
manifested_pkg_roles.append(role) manifested_pkg_roles.append(role)
# Playbooks # Playbooks
_write_playbook(os.path.join(out_dir, "playbook.yml"), manifested_users_roles + manifested_pkg_roles + manifested_service_roles) _write_playbook(
os.path.join(out_dir, "playbook.yml"),
manifested_users_roles
+ manifested_etc_custom_roles
+ manifested_pkg_roles
+ manifested_service_roles,
)

View file

@ -8,6 +8,15 @@ from typing import Optional
DEFAULT_DENY_GLOBS = [ 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/ssl/private/*",
"/etc/ssh/ssh_host_*", "/etc/ssh/ssh_host_*",
"/etc/shadow", "/etc/shadow",
@ -17,9 +26,9 @@ DEFAULT_DENY_GLOBS = [
] ]
SENSITIVE_CONTENT_PATTERNS = [ SENSITIVE_CONTENT_PATTERNS = [
re.compile(br"-----BEGIN (RSA |EC |OPENSSH |)PRIVATE KEY-----"), re.compile(rb"-----BEGIN (RSA |EC |OPENSSH |)PRIVATE KEY-----"),
re.compile(br"(?i)\bpassword\s*="), re.compile(rb"(?i)\bpassword\s*="),
re.compile(br"(?i)\b(pass|passwd|token|secret|api[_-]?key)\b"), re.compile(rb"(?i)\b(pass|passwd|token|secret|api[_-]?key)\b"),
] ]

View file

@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
import re import re
import subprocess import subprocess # nosec
from dataclasses import dataclass from dataclasses import dataclass
from typing import List, Optional from typing import List, Optional
@ -12,7 +12,11 @@ class UnitInfo:
fragment_path: Optional[str] fragment_path: Optional[str]
dropin_paths: List[str] dropin_paths: List[str]
env_files: List[str] env_files: List[str]
exec_paths: List[str] # binaries from ExecStart "path=" parts exec_paths: List[str]
active_state: Optional[str]
sub_state: Optional[str]
unit_file_state: Optional[str]
condition_result: Optional[str]
class UnitQueryError(RuntimeError): class UnitQueryError(RuntimeError):
@ -23,14 +27,22 @@ class UnitQueryError(RuntimeError):
def _run(cmd: list[str]) -> str: def _run(cmd: list[str]) -> str:
p = subprocess.run(cmd, check=False, text=True, capture_output=True) p = subprocess.run(cmd, check=False, text=True, capture_output=True) # nosec
if p.returncode != 0: if p.returncode != 0:
raise RuntimeError(f"Command failed: {cmd}\n{p.stderr}") raise RuntimeError(f"Command failed: {cmd}\n{p.stderr}")
return p.stdout return p.stdout
def list_enabled_services() -> List[str]: 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] = [] units: List[str] = []
for line in out.splitlines(): for line in out.splitlines():
parts = line.split() parts = line.split()
@ -39,7 +51,7 @@ def list_enabled_services() -> List[str]:
unit = parts[0].strip() unit = parts[0].strip()
if not unit.endswith(".service"): if not unit.endswith(".service"):
continue continue
# Skip template units like "getty@.service" which are enabled but not valid for systemctl show # Skip template units like "getty@.service"
if unit.endswith("@.service") or "@.service" in unit: if unit.endswith("@.service") or "@.service" in unit:
continue continue
units.append(unit) units.append(unit)
@ -49,13 +61,27 @@ def list_enabled_services() -> List[str]:
def get_unit_info(unit: str) -> UnitInfo: def get_unit_info(unit: str) -> UnitInfo:
p = subprocess.run( p = subprocess.run(
[ [
"systemctl", "show", unit, "systemctl",
"-p", "FragmentPath", "show",
"-p", "DropInPaths", unit,
"-p", "EnvironmentFiles", "-p",
"-p", "ExecStart", "FragmentPath",
"-p",
"DropInPaths",
"-p",
"EnvironmentFiles",
"-p",
"ExecStart",
"-p",
"ActiveState",
"-p",
"SubState",
"-p",
"UnitFileState",
"-p",
"ConditionResult",
"--no-page", "--no-page",
], ], # nosec
check=False, check=False,
text=True, text=True,
capture_output=True, capture_output=True,
@ -70,7 +96,6 @@ def get_unit_info(unit: str) -> UnitInfo:
kv[k] = v.strip() kv[k] = v.strip()
fragment = kv.get("FragmentPath") or None fragment = kv.get("FragmentPath") or None
dropins = [pp for pp in (kv.get("DropInPaths", "") or "").split() if pp] dropins = [pp for pp in (kv.get("DropInPaths", "") or "").split() if pp]
env_files: List[str] = [] env_files: List[str] = []
@ -87,4 +112,8 @@ def get_unit_info(unit: str) -> UnitInfo:
dropin_paths=sorted(set(dropins)), dropin_paths=sorted(set(dropins)),
env_files=sorted(set(env_files)), env_files=sorted(set(env_files)),
exec_paths=sorted(set(exec_paths)), 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,
) )

508
poetry.lock generated
View file

@ -1,7 +1,511 @@
# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. # This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand.
package = []
[[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)"]
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.10" python-versions = "^3.10"
content-hash = "53f2eabc9c26446fbcc00d348c47878e118afc2054778c3c803a0a8028af27d9" content-hash = "47927c9a0ec1b9be8dad6b4428cabaacc50a840e117c7e5f397d97ead83d5b76"

View file

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "enroll" name = "enroll"
version = "0.0.1" version = "0.0.2"
description = "Enroll a server's running state retrospectively into Ansible" description = "Enroll a server's running state retrospectively into Ansible"
authors = ["Miguel Jacq <mig@mig5.net>"] authors = ["Miguel Jacq <mig@mig5.net>"]
license = "GPL-3.0-or-later" license = "GPL-3.0-or-later"
@ -14,6 +14,18 @@ python = "^3.10"
[tool.poetry.scripts] [tool.poetry.scripts]
enroll = "enroll.cli:main" 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] [build-system]
requires = ["poetry-core>=1.8.0"] requires = ["poetry-core>=1.8.0"]
build-backend = "poetry.core.masonry.api" build-backend = "poetry.core.masonry.api"
[tool.pyproject-appimage]
script = "enroll"
output = "Enroll.AppImage"

17
release.sh Executable file
View file

@ -0,0 +1,17 @@
#!/bin/bash
set -eo pipefail
# Clean caches etc
filedust -y .
# Publish to Pypi
poetry build
poetry publish
# Make AppImage
poetry run pyproject-appimage
mv Enroll.AppImage dist/
# Sign packages
for file in `ls -1 dist/`; do qubes-gpg-client --batch --armor --detach-sign dist/$file > dist/$file.asc; done

24
tests.sh Executable file
View file

@ -0,0 +1,24 @@
#!/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

7
tests/conftest.py Normal file
View file

@ -0,0 +1,7 @@
import sys
from pathlib import Path
# Ensure repository root is on sys.path so `import enroll` resolves to the local package.
ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))

77
tests/test_cli.py Normal file
View file

@ -0,0 +1,77 @@
import sys
import enroll.cli as cli
def test_cli_harvest_subcommand_calls_harvest(monkeypatch, capsys, tmp_path):
called = {}
def fake_harvest(out: str):
called["out"] = out
return str(tmp_path / "state.json")
monkeypatch.setattr(cli, "harvest", fake_harvest)
monkeypatch.setattr(sys, "argv", ["enroll", "harvest", "--out", str(tmp_path)])
cli.main()
assert called["out"] == str(tmp_path)
captured = capsys.readouterr()
assert str(tmp_path / "state.json") in captured.out
def test_cli_manifest_subcommand_calls_manifest(monkeypatch, tmp_path):
called = {}
def fake_manifest(harvest_dir: str, out_dir: str):
called["harvest"] = harvest_dir
called["out"] = out_dir
monkeypatch.setattr(cli, "manifest", fake_manifest)
monkeypatch.setattr(
sys,
"argv",
[
"enroll",
"manifest",
"--harvest",
str(tmp_path / "bundle"),
"--out",
str(tmp_path / "ansible"),
],
)
cli.main()
assert called["harvest"] == str(tmp_path / "bundle")
assert called["out"] == str(tmp_path / "ansible")
def test_cli_enroll_subcommand_runs_harvest_then_manifest(monkeypatch, tmp_path):
calls = []
def fake_harvest(bundle_dir: str):
calls.append(("harvest", bundle_dir))
return str(tmp_path / "bundle" / "state.json")
def fake_manifest(bundle_dir: str, out_dir: str):
calls.append(("manifest", bundle_dir, out_dir))
monkeypatch.setattr(cli, "harvest", fake_harvest)
monkeypatch.setattr(cli, "manifest", fake_manifest)
monkeypatch.setattr(
sys,
"argv",
[
"enroll",
"enroll",
"--harvest",
str(tmp_path / "bundle"),
"--out",
str(tmp_path / "ansible"),
],
)
cli.main()
assert calls == [
("harvest", str(tmp_path / "bundle")),
("manifest", str(tmp_path / "bundle"), str(tmp_path / "ansible")),
]

141
tests/test_harvest.py Normal file
View file

@ -0,0 +1,141 @@
import json
from pathlib import Path
import enroll.harvest as h
from enroll.systemd import UnitInfo
class AllowAllPolicy:
def deny_reason(self, path: str):
return None
def test_harvest_dedup_manual_packages_and_builds_etc_custom(
monkeypatch, tmp_path: Path
):
bundle = tmp_path / "bundle"
import os
real_isfile = os.path.isfile
real_isdir = os.path.isdir
real_exists = os.path.exists
real_islink = os.path.islink
# Fake filesystem: two /etc files exist, only one is dpkg-owned.
files = {
"/etc/openvpn/server.conf": b"server",
"/etc/default/keyboard": b"kbd",
}
dirs = {"/etc", "/etc/openvpn", "/etc/default"}
def fake_isfile(p: str) -> bool:
if p.startswith("/etc/") or p == "/etc":
return p in files
return real_isfile(p)
def fake_isdir(p: str) -> bool:
if p.startswith("/etc"):
return p in dirs
return real_isdir(p)
def fake_islink(p: str) -> bool:
if p.startswith("/etc"):
return False
return real_islink(p)
def fake_exists(p: str) -> bool:
if p.startswith("/etc"):
return p in files or p in dirs
return real_exists(p)
def fake_walk(root: str):
if root == "/etc":
yield ("/etc/openvpn", [], ["server.conf"])
yield ("/etc/default", [], ["keyboard"])
elif root == "/etc/openvpn":
yield ("/etc/openvpn", [], ["server.conf"])
elif root == "/etc/default":
yield ("/etc/default", [], ["keyboard"])
else:
yield (root, [], [])
monkeypatch.setattr(h.os.path, "isfile", fake_isfile)
monkeypatch.setattr(h.os.path, "isdir", fake_isdir)
monkeypatch.setattr(h.os.path, "islink", fake_islink)
monkeypatch.setattr(h.os.path, "exists", fake_exists)
monkeypatch.setattr(h.os, "walk", fake_walk)
# Avoid real system access
monkeypatch.setattr(h, "list_enabled_services", lambda: ["openvpn.service"])
monkeypatch.setattr(
h,
"get_unit_info",
lambda unit: UnitInfo(
name=unit,
fragment_path="/lib/systemd/system/openvpn.service",
dropin_paths=[],
env_files=[],
exec_paths=["/usr/sbin/openvpn"],
active_state="inactive",
sub_state="dead",
unit_file_state="enabled",
condition_result=None,
),
)
# Debian package index: openvpn owns /etc/openvpn/server.conf; keyboard is unowned.
def fake_build_index():
owned_etc = {"/etc/openvpn/server.conf"}
etc_owner_map = {"/etc/openvpn/server.conf": "openvpn"}
topdir_to_pkgs = {"openvpn": {"openvpn"}}
pkg_to_etc_paths = {"openvpn": ["/etc/openvpn/server.conf"], "curl": []}
return owned_etc, etc_owner_map, topdir_to_pkgs, pkg_to_etc_paths
monkeypatch.setattr(h, "build_dpkg_etc_index", fake_build_index)
# openvpn conffile hash mismatch => should be captured under service role
monkeypatch.setattr(
h,
"parse_status_conffiles",
lambda: {"openvpn": {"/etc/openvpn/server.conf": "old"}},
)
monkeypatch.setattr(h, "read_pkg_md5sums", lambda pkg: {})
monkeypatch.setattr(h, "file_md5", lambda path: "new")
monkeypatch.setattr(
h, "dpkg_owner", lambda p: "openvpn" if "openvpn" in p else None
)
monkeypatch.setattr(h, "list_manual_packages", lambda: ["openvpn", "curl"])
monkeypatch.setattr(h, "collect_non_system_users", lambda: [])
monkeypatch.setattr(h, "stat_triplet", lambda p: ("root", "root", "0644"))
# Avoid needing source files on disk by implementing our own bundle copier
def fake_copy(bundle_dir: str, role_name: str, abs_path: str, src_rel: str):
dst = Path(bundle_dir) / "artifacts" / role_name / src_rel
dst.parent.mkdir(parents=True, exist_ok=True)
dst.write_bytes(files.get(abs_path, b""))
monkeypatch.setattr(h, "_copy_into_bundle", fake_copy)
state_path = h.harvest(str(bundle), policy=AllowAllPolicy())
st = json.loads(Path(state_path).read_text(encoding="utf-8"))
assert "openvpn" in st["manual_packages"]
assert "curl" in st["manual_packages"]
assert "openvpn" in st["manual_packages_skipped"]
assert all(pr["package"] != "openvpn" for pr in st["package_roles"])
assert any(pr["package"] == "curl" for pr in st["package_roles"])
# Service role captured modified conffile
svc = st["services"][0]
assert svc["unit"] == "openvpn.service"
assert "openvpn" in svc["packages"]
assert any(mf["path"] == "/etc/openvpn/server.conf" for mf in svc["managed_files"])
# Unowned /etc/default/keyboard is attributed to etc_custom only
etc_custom = st["etc_custom"]
assert any(
mf["path"] == "/etc/default/keyboard" for mf in etc_custom["managed_files"]
)

115
tests/test_manifest.py Normal file
View file

@ -0,0 +1,115 @@
import json
from pathlib import Path
from enroll.manifest import manifest
def test_manifest_writes_roles_and_playbook_with_clean_when(tmp_path: Path):
bundle = tmp_path / "bundle"
out = tmp_path / "ansible"
(bundle / "artifacts" / "foo" / "etc").mkdir(parents=True, exist_ok=True)
(bundle / "artifacts" / "foo" / "etc" / "foo.conf").write_text(
"x", encoding="utf-8"
)
state = {
"host": {"hostname": "test", "os": "debian"},
"users": {
"role_name": "users",
"users": [
{
"name": "alice",
"uid": 1000,
"gid": 1000,
"gecos": "Alice",
"home": "/home/alice",
"shell": "/bin/bash",
"primary_group": "alice",
"supplementary_groups": ["docker", "qubes"],
}
],
"managed_files": [],
"excluded": [],
"notes": [],
},
"etc_custom": {
"role_name": "etc_custom",
"managed_files": [
{
"path": "/etc/default/keyboard",
"src_rel": "etc/default/keyboard",
"owner": "root",
"group": "root",
"mode": "0644",
"reason": "custom_unowned",
}
],
"excluded": [],
"notes": [],
},
"services": [
{
"unit": "foo.service",
"role_name": "foo",
"packages": ["foo"],
"active_state": "inactive",
"sub_state": "dead",
"unit_file_state": "enabled",
"condition_result": "no",
"managed_files": [
{
"path": "/etc/foo.conf",
"src_rel": "etc/foo.conf",
"owner": "root",
"group": "root",
"mode": "0644",
"reason": "modified_conffile",
}
],
"excluded": [],
"notes": [],
}
],
"package_roles": [
{
"package": "curl",
"role_name": "curl",
"managed_files": [],
"excluded": [],
"notes": [],
}
],
}
bundle.mkdir(parents=True, exist_ok=True)
(bundle / "state.json").write_text(json.dumps(state, indent=2), encoding="utf-8")
# Create artifact for etc_custom file so copy works
(bundle / "artifacts" / "etc_custom" / "etc" / "default").mkdir(
parents=True, exist_ok=True
)
(bundle / "artifacts" / "etc_custom" / "etc" / "default" / "keyboard").write_text(
"kbd", encoding="utf-8"
)
manifest(str(bundle), str(out))
# Service role: conditional start must be a clean Ansible expression
tasks = (out / "roles" / "foo" / "tasks" / "main.yml").read_text(encoding="utf-8")
assert "when: foo_start | bool" in tasks
# Ensure we didn't emit deprecated/broken '{{ }}' delimiters in when:
for line in tasks.splitlines():
if line.lstrip().startswith("when:"):
assert "{{" not in line and "}}" not in line
defaults = (out / "roles" / "foo" / "defaults" / "main.yml").read_text(
encoding="utf-8"
)
assert "foo_start: false" in defaults
# Playbook should include users, etc_custom, packages, and services
pb = (out / "playbook.yml").read_text(encoding="utf-8")
assert "- users" in pb
assert "- etc_custom" in pb
assert "- curl" in pb
assert "- foo" in pb

8
tests/test_secrets.py Normal file
View file

@ -0,0 +1,8 @@
from enroll.secrets import SecretPolicy
def test_secret_policy_denies_common_backup_files():
pol = SecretPolicy()
assert pol.deny_reason("/etc/shadow-") == "denied_path"
assert pol.deny_reason("/etc/passwd-") == "denied_path"
assert pol.deny_reason("/etc/group-") == "denied_path"