Many tweaks

This commit is contained in:
Miguel Jacq 2025-12-15 11:04:54 +11:00
parent 5398ad123c
commit 227be6dd51
Signed by: mig5
GPG key ID: 59B3F0C24135C6A9
20 changed files with 1350 additions and 174 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
- 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

@ -9,6 +9,8 @@ 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 (Poetry)
@ -21,22 +23,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:

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(
UserRecord(
name=name, name=name,
uid=uid, uid=uid,
gid=gid, gid=gid,
gecos=gecos, gecos=gecos,
home=home, home=home,
shell=shell, shell=shell, # nosec
primary_group=primary_group, primary_group=primary_group,
supplementary_groups=supp, supplementary_groups=supp,
ssh_files=ssh_files, 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,9 @@ 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 +36,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 +83,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 +154,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 +166,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

@ -18,8 +18,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 +42,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 +69,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] = []
@ -89,10 +136,12 @@ def _role_name_from_unit(unit: str) -> str:
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 +163,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 +174,20 @@ def _add_pkgs_from_etc_topdirs(hints: Set[str], topdir_to_pkgs: Dict[str, Set[st
def _maybe_add_specific_paths(hints: Set[str]) -> List[str]: 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/default/{h}",
f"/etc/init.d/{h}", f"/etc/init.d/{h}",
f"/etc/sysctl.d/{h}.conf", f"/etc/sysctl.d/{h}.conf",
f"/etc/logrotate.d/{h}", 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 +225,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 +243,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(
ServiceSnapshot(
unit=unit, unit=unit,
role_name=role, role_name=role,
packages=[], packages=[],
active_state=None,
sub_state=None,
unit_file_state=None,
condition_result=None,
managed_files=[], managed_files=[],
excluded=[], excluded=[],
notes=[str(e)], notes=[str(e)],
)) )
)
continue continue
pkgs: Set[str] = set() pkgs: Set[str] = set()
@ -243,6 +307,7 @@ def harvest(bundle_dir: str, policy: Optional[SecretPolicy] = None) -> str:
if not os.path.isfile(path) or os.path.islink(path): 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 +332,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 +352,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(
ManagedFile(
path=path, path=path,
src_rel=src_rel, src_rel=src_rel,
owner=owner, owner=owner,
group=group, group=group,
mode=mode, mode=mode,
reason=reason, reason=reason,
)) )
)
service_snaps.append(ServiceSnapshot( service_snaps.append(
ServiceSnapshot(
unit=unit, unit=unit,
role_name=role, role_name=role,
packages=sorted(pkgs), packages=sorted(pkgs),
active_state=ui.active_state,
sub_state=ui.sub_state,
unit_file_state=ui.unit_file_state,
condition_result=ui.condition_result,
managed_files=managed, managed_files=managed,
excluded=excluded, excluded=excluded,
notes=notes, 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 +428,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 +462,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(
ManagedFile(
path=path, path=path,
src_rel=src_rel, src_rel=src_rel,
owner=owner, owner=owner,
group=group, group=group,
mode=mode, mode=mode,
reason=reason, 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(
PackageSnapshot(
package=pkg, package=pkg,
role_name=role, role_name=role,
managed_files=managed, managed_files=managed,
excluded=excluded, excluded=excluded,
notes=notes, notes=notes,
)) )
)
# ------------------------- # -------------------------
# Users role (non-system users) # Users role (non-system users)
@ -402,7 +497,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,7 +505,8 @@ 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, "name": u.name,
"uid": u.uid, "uid": u.uid,
"gid": u.gid, "gid": u.gid,
@ -419,38 +515,41 @@ def harvest(bundle_dir: str, policy: Optional[SecretPolicy] = None) -> str:
"shell": u.shell, "shell": u.shell,
"primary_group": u.primary_group, "primary_group": u.primary_group,
"supplementary_groups": u.supplementary_groups, "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")
else "ssh_public_key"
)
users_managed.append(
ManagedFile(
path=sf, path=sf,
src_rel=src_rel, src_rel=src_rel,
owner=owner, owner=owner,
group=group, group=group,
mode=mode, mode=mode,
reason=reason, reason=reason,
)) )
)
users_snapshot = UsersSnapshot( users_snapshot = UsersSnapshot(
role_name=users_role_name, role_name=users_role_name,
@ -460,12 +559,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

@ -50,12 +50,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 +88,17 @@ def manifest(bundle_dir: str, out_dir: str) -> None:
# defaults: store users list (handy for later), but tasks are explicit for readability # defaults: 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 +120,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 +128,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 +170,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,11 +301,16 @@ 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 = """---
@ -219,10 +323,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 +345,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 +357,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,7 +438,9 @@ 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 = """---
@ -323,10 +448,14 @@ Generated from `{unit}`.
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 +470,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 +526,7 @@ 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,
) )

293
poetry.lock generated
View file

@ -1,7 +1,296 @@
# 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 = "colorama"
version = "0.4.6"
description = "Cross-platform colored terminal text."
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
]
[[package]]
name = "coverage"
version = "7.13.0"
description = "Code coverage measurement for Python"
optional = false
python-versions = ">=3.10"
files = [
{file = "coverage-7.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:02d9fb9eccd48f6843c98a37bd6817462f130b86da8660461e8f5e54d4c06070"},
{file = "coverage-7.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:367449cf07d33dc216c083f2036bb7d976c6e4903ab31be400ad74ad9f85ce98"},
{file = "coverage-7.13.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cdb3c9f8fef0a954c632f64328a3935988d33a6604ce4bf67ec3e39670f12ae5"},
{file = "coverage-7.13.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d10fd186aac2316f9bbb46ef91977f9d394ded67050ad6d84d94ed6ea2e8e54e"},
{file = "coverage-7.13.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f88ae3e69df2ab62fb0bc5219a597cb890ba5c438190ffa87490b315190bb33"},
{file = "coverage-7.13.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c4be718e51e86f553bcf515305a158a1cd180d23b72f07ae76d6017c3cc5d791"},
{file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a00d3a393207ae12f7c49bb1c113190883b500f48979abb118d8b72b8c95c032"},
{file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3a7b1cd820e1b6116f92c6128f1188e7afe421c7e1b35fa9836b11444e53ebd9"},
{file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:37eee4e552a65866f15dedd917d5e5f3d59805994260720821e2c1b51ac3248f"},
{file = "coverage-7.13.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:62d7c4f13102148c78d7353c6052af6d899a7f6df66a32bddcc0c0eb7c5326f8"},
{file = "coverage-7.13.0-cp310-cp310-win32.whl", hash = "sha256:24e4e56304fdb56f96f80eabf840eab043b3afea9348b88be680ec5986780a0f"},
{file = "coverage-7.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:74c136e4093627cf04b26a35dab8cbfc9b37c647f0502fc313376e11726ba303"},
{file = "coverage-7.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0dfa3855031070058add1a59fdfda0192fd3e8f97e7c81de0596c145dea51820"},
{file = "coverage-7.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4fdb6f54f38e334db97f72fa0c701e66d8479af0bc3f9bfb5b90f1c30f54500f"},
{file = "coverage-7.13.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7e442c013447d1d8d195be62852270b78b6e255b79b8675bad8479641e21fd96"},
{file = "coverage-7.13.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1ed5630d946859de835a85e9a43b721123a8a44ec26e2830b296d478c7fd4259"},
{file = "coverage-7.13.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f15a931a668e58087bc39d05d2b4bf4b14ff2875b49c994bbdb1c2217a8daeb"},
{file = "coverage-7.13.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:30a3a201a127ea57f7e14ba43c93c9c4be8b7d17a26e03bb49e6966d019eede9"},
{file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7a485ff48fbd231efa32d58f479befce52dcb6bfb2a88bb7bf9a0b89b1bc8030"},
{file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:22486cdafba4f9e471c816a2a5745337742a617fef68e890d8baf9f3036d7833"},
{file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:263c3dbccc78e2e331e59e90115941b5f53e85cfcc6b3b2fbff1fd4e3d2c6ea8"},
{file = "coverage-7.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e5330fa0cc1f5c3c4c3bb8e101b742025933e7848989370a1d4c8c5e401ea753"},
{file = "coverage-7.13.0-cp311-cp311-win32.whl", hash = "sha256:0f4872f5d6c54419c94c25dd6ae1d015deeb337d06e448cd890a1e89a8ee7f3b"},
{file = "coverage-7.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:51a202e0f80f241ccb68e3e26e19ab5b3bf0f813314f2c967642f13ebcf1ddfe"},
{file = "coverage-7.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:d2a9d7f1c11487b1c69367ab3ac2d81b9b3721f097aa409a3191c3e90f8f3dd7"},
{file = "coverage-7.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0b3d67d31383c4c68e19a88e28fc4c2e29517580f1b0ebec4a069d502ce1e0bf"},
{file = "coverage-7.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:581f086833d24a22c89ae0fe2142cfaa1c92c930adf637ddf122d55083fb5a0f"},
{file = "coverage-7.13.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0a3a30f0e257df382f5f9534d4ce3d4cf06eafaf5192beb1a7bd066cb10e78fb"},
{file = "coverage-7.13.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:583221913fbc8f53b88c42e8dbb8fca1d0f2e597cb190ce45916662b8b9d9621"},
{file = "coverage-7.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f5d9bd30756fff3e7216491a0d6d520c448d5124d3d8e8f56446d6412499e74"},
{file = "coverage-7.13.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a23e5a1f8b982d56fa64f8e442e037f6ce29322f1f9e6c2344cd9e9f4407ee57"},
{file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b01c22bc74a7fb44066aaf765224c0d933ddf1f5047d6cdfe4795504a4493f8"},
{file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:898cce66d0836973f48dda4e3514d863d70142bdf6dfab932b9b6a90ea5b222d"},
{file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:3ab483ea0e251b5790c2aac03acde31bff0c736bf8a86829b89382b407cd1c3b"},
{file = "coverage-7.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d84e91521c5e4cb6602fe11ece3e1de03b2760e14ae4fcf1a4b56fa3c801fcd"},
{file = "coverage-7.13.0-cp312-cp312-win32.whl", hash = "sha256:193c3887285eec1dbdb3f2bd7fbc351d570ca9c02ca756c3afbc71b3c98af6ef"},
{file = "coverage-7.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:4f3e223b2b2db5e0db0c2b97286aba0036ca000f06aca9b12112eaa9af3d92ae"},
{file = "coverage-7.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:086cede306d96202e15a4b77ace8472e39d9f4e5f9fd92dd4fecdfb2313b2080"},
{file = "coverage-7.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:28ee1c96109974af104028a8ef57cec21447d42d0e937c0275329272e370ebcf"},
{file = "coverage-7.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d1e97353dcc5587b85986cda4ff3ec98081d7e84dd95e8b2a6d59820f0545f8a"},
{file = "coverage-7.13.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:99acd4dfdfeb58e1937629eb1ab6ab0899b131f183ee5f23e0b5da5cba2fec74"},
{file = "coverage-7.13.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ff45e0cd8451e293b63ced93161e189780baf444119391b3e7d25315060368a6"},
{file = "coverage-7.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f4f72a85316d8e13234cafe0a9f81b40418ad7a082792fa4165bd7d45d96066b"},
{file = "coverage-7.13.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:11c21557d0e0a5a38632cbbaca5f008723b26a89d70db6315523df6df77d6232"},
{file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76541dc8d53715fb4f7a3a06b34b0dc6846e3c69bc6204c55653a85dd6220971"},
{file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:6e9e451dee940a86789134b6b0ffbe31c454ade3b849bb8a9d2cca2541a8e91d"},
{file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5c67dace46f361125e6b9cace8fe0b729ed8479f47e70c89b838d319375c8137"},
{file = "coverage-7.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f59883c643cb19630500f57016f76cfdcd6845ca8c5b5ea1f6e17f74c8e5f511"},
{file = "coverage-7.13.0-cp313-cp313-win32.whl", hash = "sha256:58632b187be6f0be500f553be41e277712baa278147ecb7559983c6d9faf7ae1"},
{file = "coverage-7.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:73419b89f812f498aca53f757dd834919b48ce4799f9d5cad33ca0ae442bdb1a"},
{file = "coverage-7.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:eb76670874fdd6091eedcc856128ee48c41a9bbbb9c3f1c7c3cf169290e3ffd6"},
{file = "coverage-7.13.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6e63ccc6e0ad8986386461c3c4b737540f20426e7ec932f42e030320896c311a"},
{file = "coverage-7.13.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:494f5459ffa1bd45e18558cd98710c36c0b8fbfa82a5eabcbe671d80ecffbfe8"},
{file = "coverage-7.13.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:06cac81bf10f74034e055e903f5f946e3e26fc51c09fc9f584e4a1605d977053"},
{file = "coverage-7.13.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f2ffc92b46ed6e6760f1d47a71e56b5664781bc68986dbd1836b2b70c0ce2071"},
{file = "coverage-7.13.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0602f701057c6823e5db1b74530ce85f17c3c5be5c85fc042ac939cbd909426e"},
{file = "coverage-7.13.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:25dc33618d45456ccb1d37bce44bc78cf269909aa14c4db2e03d63146a8a1493"},
{file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:71936a8b3b977ddd0b694c28c6a34f4fff2e9dd201969a4ff5d5fc7742d614b0"},
{file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:936bc20503ce24770c71938d1369461f0c5320830800933bc3956e2a4ded930e"},
{file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:af0a583efaacc52ae2521f8d7910aff65cdb093091d76291ac5820d5e947fc1c"},
{file = "coverage-7.13.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f1c23e24a7000da892a312fb17e33c5f94f8b001de44b7cf8ba2e36fbd15859e"},
{file = "coverage-7.13.0-cp313-cp313t-win32.whl", hash = "sha256:5f8a0297355e652001015e93be345ee54393e45dc3050af4a0475c5a2b767d46"},
{file = "coverage-7.13.0-cp313-cp313t-win_amd64.whl", hash = "sha256:6abb3a4c52f05e08460bd9acf04fec027f8718ecaa0d09c40ffbc3fbd70ecc39"},
{file = "coverage-7.13.0-cp313-cp313t-win_arm64.whl", hash = "sha256:3ad968d1e3aa6ce5be295ab5fe3ae1bf5bb4769d0f98a80a0252d543a2ef2e9e"},
{file = "coverage-7.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:453b7ec753cf5e4356e14fe858064e5520c460d3bbbcb9c35e55c0d21155c256"},
{file = "coverage-7.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:af827b7cbb303e1befa6c4f94fd2bf72f108089cfa0f8abab8f4ca553cf5ca5a"},
{file = "coverage-7.13.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9987a9e4f8197a1000280f7cc089e3ea2c8b3c0a64d750537809879a7b4ceaf9"},
{file = "coverage-7.13.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3188936845cd0cb114fa6a51842a304cdbac2958145d03be2377ec41eb285d19"},
{file = "coverage-7.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a2bdb3babb74079f021696cb46b8bb5f5661165c385d3a238712b031a12355be"},
{file = "coverage-7.13.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7464663eaca6adba4175f6c19354feea61ebbdd735563a03d1e472c7072d27bb"},
{file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8069e831f205d2ff1f3d355e82f511eb7c5522d7d413f5db5756b772ec8697f8"},
{file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6fb2d5d272341565f08e962cce14cdf843a08ac43bd621783527adb06b089c4b"},
{file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5e70f92ef89bac1ac8a99b3324923b4749f008fdbd7aa9cb35e01d7a284a04f9"},
{file = "coverage-7.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4b5de7d4583e60d5fd246dd57fcd3a8aa23c6e118a8c72b38adf666ba8e7e927"},
{file = "coverage-7.13.0-cp314-cp314-win32.whl", hash = "sha256:a6c6e16b663be828a8f0b6c5027d36471d4a9f90d28444aa4ced4d48d7d6ae8f"},
{file = "coverage-7.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:0900872f2fdb3ee5646b557918d02279dc3af3dfb39029ac4e945458b13f73bc"},
{file = "coverage-7.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:3a10260e6a152e5f03f26db4a407c4c62d3830b9af9b7c0450b183615f05d43b"},
{file = "coverage-7.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:9097818b6cc1cfb5f174e3263eba4a62a17683bcfe5c4b5d07f4c97fa51fbf28"},
{file = "coverage-7.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0018f73dfb4301a89292c73be6ba5f58722ff79f51593352759c1790ded1cabe"},
{file = "coverage-7.13.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:166ad2a22ee770f5656e1257703139d3533b4a0b6909af67c6b4a3adc1c98657"},
{file = "coverage-7.13.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f6aaef16d65d1787280943f1c8718dc32e9cf141014e4634d64446702d26e0ff"},
{file = "coverage-7.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e999e2dcc094002d6e2c7bbc1fb85b58ba4f465a760a8014d97619330cdbbbf3"},
{file = "coverage-7.13.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:00c3d22cf6fb1cf3bf662aaaa4e563be8243a5ed2630339069799835a9cc7f9b"},
{file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22ccfe8d9bb0d6134892cbe1262493a8c70d736b9df930f3f3afae0fe3ac924d"},
{file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:9372dff5ea15930fea0445eaf37bbbafbc771a49e70c0aeed8b4e2c2614cc00e"},
{file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:69ac2c492918c2461bc6ace42d0479638e60719f2a4ef3f0815fa2df88e9f940"},
{file = "coverage-7.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:739c6c051a7540608d097b8e13c76cfa85263ced467168dc6b477bae3df7d0e2"},
{file = "coverage-7.13.0-cp314-cp314t-win32.whl", hash = "sha256:fe81055d8c6c9de76d60c94ddea73c290b416e061d40d542b24a5871bad498b7"},
{file = "coverage-7.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:445badb539005283825959ac9fa4a28f712c214b65af3a2c464f1adc90f5fcbc"},
{file = "coverage-7.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:de7f6748b890708578fc4b7bb967d810aeb6fcc9bff4bb77dbca77dab2f9df6a"},
{file = "coverage-7.13.0-py3-none-any.whl", hash = "sha256:850d2998f380b1e266459ca5b47bc9e7daf9af1d070f66317972f382d46f1904"},
{file = "coverage-7.13.0.tar.gz", hash = "sha256:a394aa27f2d7ff9bc04cf703817773a59ad6dfbd577032e690f961d2460ee936"},
]
[package.dependencies]
tomli = {version = "*", optional = true, markers = "python_full_version <= \"3.11.0a6\" and extra == \"toml\""}
[package.extras]
toml = ["tomli"]
[[package]]
name = "exceptiongroup"
version = "1.3.1"
description = "Backport of PEP 654 (exception groups)"
optional = false
python-versions = ">=3.7"
files = [
{file = "exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598"},
{file = "exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219"},
]
[package.dependencies]
typing-extensions = {version = ">=4.6.0", markers = "python_version < \"3.13\""}
[package.extras]
test = ["pytest (>=6)"]
[[package]]
name = "iniconfig"
version = "2.3.0"
description = "brain-dead simple config-ini parsing"
optional = false
python-versions = ">=3.10"
files = [
{file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"},
{file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"},
]
[[package]]
name = "packaging"
version = "25.0"
description = "Core utilities for Python packages"
optional = false
python-versions = ">=3.8"
files = [
{file = "packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484"},
{file = "packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f"},
]
[[package]]
name = "pluggy"
version = "1.6.0"
description = "plugin and hook calling mechanisms for python"
optional = false
python-versions = ">=3.9"
files = [
{file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"},
{file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"},
]
[package.extras]
dev = ["pre-commit", "tox"]
testing = ["coverage", "pytest", "pytest-benchmark"]
[[package]]
name = "pygments"
version = "2.19.2"
description = "Pygments is a syntax highlighting package written in Python."
optional = false
python-versions = ">=3.8"
files = [
{file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"},
{file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"},
]
[package.extras]
windows-terminal = ["colorama (>=0.4.6)"]
[[package]]
name = "pytest"
version = "9.0.2"
description = "pytest: simple powerful testing with Python"
optional = false
python-versions = ">=3.10"
files = [
{file = "pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b"},
{file = "pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11"},
]
[package.dependencies]
colorama = {version = ">=0.4", markers = "sys_platform == \"win32\""}
exceptiongroup = {version = ">=1", markers = "python_version < \"3.11\""}
iniconfig = ">=1.0.1"
packaging = ">=22"
pluggy = ">=1.5,<2"
pygments = ">=2.7.2"
tomli = {version = ">=1", markers = "python_version < \"3.11\""}
[package.extras]
dev = ["argcomplete", "attrs (>=19.2)", "hypothesis (>=3.56)", "mock", "requests", "setuptools", "xmlschema"]
[[package]]
name = "pytest-cov"
version = "7.0.0"
description = "Pytest plugin for measuring coverage."
optional = false
python-versions = ">=3.9"
files = [
{file = "pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861"},
{file = "pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1"},
]
[package.dependencies]
coverage = {version = ">=7.10.6", extras = ["toml"]}
pluggy = ">=1.2"
pytest = ">=7"
[package.extras]
testing = ["process-tests", "pytest-xdist", "virtualenv"]
[[package]]
name = "tomli"
version = "2.3.0"
description = "A lil' TOML parser"
optional = false
python-versions = ">=3.8"
files = [
{file = "tomli-2.3.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:88bd15eb972f3664f5ed4b57c1634a97153b4bac4479dcb6a495f41921eb7f45"},
{file = "tomli-2.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:883b1c0d6398a6a9d29b508c331fa56adbcdff647f6ace4dfca0f50e90dfd0ba"},
{file = "tomli-2.3.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1381caf13ab9f300e30dd8feadb3de072aeb86f1d34a8569453ff32a7dea4bf"},
{file = "tomli-2.3.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0e285d2649b78c0d9027570d4da3425bdb49830a6156121360b3f8511ea3441"},
{file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0a154a9ae14bfcf5d8917a59b51ffd5a3ac1fd149b71b47a3a104ca4edcfa845"},
{file = "tomli-2.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:74bf8464ff93e413514fefd2be591c3b0b23231a77f901db1eb30d6f712fc42c"},
{file = "tomli-2.3.0-cp311-cp311-win32.whl", hash = "sha256:00b5f5d95bbfc7d12f91ad8c593a1659b6387b43f054104cda404be6bda62456"},
{file = "tomli-2.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:4dc4ce8483a5d429ab602f111a93a6ab1ed425eae3122032db7e9acf449451be"},
{file = "tomli-2.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d7d86942e56ded512a594786a5ba0a5e521d02529b3826e7761a05138341a2ac"},
{file = "tomli-2.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:73ee0b47d4dad1c5e996e3cd33b8a76a50167ae5f96a2607cbe8cc773506ab22"},
{file = "tomli-2.3.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:792262b94d5d0a466afb5bc63c7daa9d75520110971ee269152083270998316f"},
{file = "tomli-2.3.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f195fe57ecceac95a66a75ac24d9d5fbc98ef0962e09b2eddec5d39375aae52"},
{file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e31d432427dcbf4d86958c184b9bfd1e96b5b71f8eb17e6d02531f434fd335b8"},
{file = "tomli-2.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b0882799624980785240ab732537fcfc372601015c00f7fc367c55308c186f6"},
{file = "tomli-2.3.0-cp312-cp312-win32.whl", hash = "sha256:ff72b71b5d10d22ecb084d345fc26f42b5143c5533db5e2eaba7d2d335358876"},
{file = "tomli-2.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:1cb4ed918939151a03f33d4242ccd0aa5f11b3547d0cf30f7c74a408a5b99878"},
{file = "tomli-2.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5192f562738228945d7b13d4930baffda67b69425a7f0da96d360b0a3888136b"},
{file = "tomli-2.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:be71c93a63d738597996be9528f4abe628d1adf5e6eb11607bc8fe1a510b5dae"},
{file = "tomli-2.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4665508bcbac83a31ff8ab08f424b665200c0e1e645d2bd9ab3d3e557b6185b"},
{file = "tomli-2.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4021923f97266babc6ccab9f5068642a0095faa0a51a246a6a02fccbb3514eaf"},
{file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4ea38c40145a357d513bffad0ed869f13c1773716cf71ccaa83b0fa0cc4e42f"},
{file = "tomli-2.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ad805ea85eda330dbad64c7ea7a4556259665bdf9d2672f5dccc740eb9d3ca05"},
{file = "tomli-2.3.0-cp313-cp313-win32.whl", hash = "sha256:97d5eec30149fd3294270e889b4234023f2c69747e555a27bd708828353ab606"},
{file = "tomli-2.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0c95ca56fbe89e065c6ead5b593ee64b84a26fca063b5d71a1122bf26e533999"},
{file = "tomli-2.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:cebc6fe843e0733ee827a282aca4999b596241195f43b4cc371d64fc6639da9e"},
{file = "tomli-2.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4c2ef0244c75aba9355561272009d934953817c49f47d768070c3c94355c2aa3"},
{file = "tomli-2.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c22a8bf253bacc0cf11f35ad9808b6cb75ada2631c2d97c971122583b129afbc"},
{file = "tomli-2.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0eea8cc5c5e9f89c9b90c4896a8deefc74f518db5927d0e0e8d4a80953d774d0"},
{file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b74a0e59ec5d15127acdabd75ea17726ac4c5178ae51b85bfe39c4f8a278e879"},
{file = "tomli-2.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b5870b50c9db823c595983571d1296a6ff3e1b88f734a4c8f6fc6188397de005"},
{file = "tomli-2.3.0-cp314-cp314-win32.whl", hash = "sha256:feb0dacc61170ed7ab602d3d972a58f14ee3ee60494292d384649a3dc38ef463"},
{file = "tomli-2.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:b273fcbd7fc64dc3600c098e39136522650c49bca95df2d11cf3b626422392c8"},
{file = "tomli-2.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:940d56ee0410fa17ee1f12b817b37a4d4e4dc4d27340863cc67236c74f582e77"},
{file = "tomli-2.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f85209946d1fe94416debbb88d00eb92ce9cd5266775424ff81bc959e001acaf"},
{file = "tomli-2.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a56212bdcce682e56b0aaf79e869ba5d15a6163f88d5451cbde388d48b13f530"},
{file = "tomli-2.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c5f3ffd1e098dfc032d4d3af5c0ac64f6d286d98bc148698356847b80fa4de1b"},
{file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e01decd096b1530d97d5d85cb4dff4af2d8347bd35686654a004f8dea20fc67"},
{file = "tomli-2.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8a35dd0e643bb2610f156cca8db95d213a90015c11fee76c946aa62b7ae7e02f"},
{file = "tomli-2.3.0-cp314-cp314t-win32.whl", hash = "sha256:a1f7f282fe248311650081faafa5f4732bdbfef5d45fe3f2e702fbc6f2d496e0"},
{file = "tomli-2.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:70a251f8d4ba2d9ac2542eecf008b3c8a9fc5c3f9f02c56a9d7952612be2fdba"},
{file = "tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b"},
{file = "tomli-2.3.0.tar.gz", hash = "sha256:64be704a875d2a59753d80ee8a533c3fe183e3f06807ff7dc2232938ccb01549"},
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
description = "Backported and Experimental Type Hints for Python 3.9+"
optional = false
python-versions = ">=3.9"
files = [
{file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"},
{file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
]
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.10" python-versions = "^3.10"
content-hash = "53f2eabc9c26446fbcc00d348c47878e118afc2054778c3c803a0a8028af27d9" content-hash = "535a18dadbd873841a37b9823dc0871877559c03798b7fc25b86f030f191e630"

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,10 @@ 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"
[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"

8
tests.sh Executable file
View file

@ -0,0 +1,8 @@
#!/bin/bash
poetry run pytest -vvvv --cov=enroll --cov-report=term-missing --disable-warnings
poetry run enroll enroll --harvest /tmp/bundle --out /tmp/ansible && \
cd /tmp/ansible && \
sudo ansible-playbook playbook.yml -i "localhost," -c local --check --diff

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"