diff --git a/.forgejo/workflows/build-deb.yml b/.forgejo/workflows/build-deb.yml
index 28276df..047d2bc 100644
--- a/.forgejo/workflows/build-deb.yml
+++ b/.forgejo/workflows/build-deb.yml
@@ -21,6 +21,7 @@ jobs:
python3-poetry-core \
python3-yaml \
python3-paramiko \
+ python3-jsonschema \
rsync \
ca-certificates
diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml
index 41efa55..9e5379b 100644
--- a/.forgejo/workflows/ci.yml
+++ b/.forgejo/workflows/ci.yml
@@ -15,7 +15,7 @@ jobs:
run: |
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
- ansible ansible-lint python3-venv pipx systemctl python3-apt
+ ansible ansible-lint python3-venv pipx systemctl python3-apt jq python3-jsonschema
- name: Install Poetry
run: |
@@ -27,6 +27,11 @@ jobs:
run: |
poetry install --with dev
+ - name: Install sops
+ run: |
+ curl -L -o /usr/local/bin/sops https://github.com/getsops/sops/releases/download/v3.13.1/sops-v3.13.1.linux.amd64
+ chmod +x /usr/local/bin/sops
+
- name: Run test script
run: |
./tests.sh
diff --git a/.forgejo/workflows/trivy.yml b/.forgejo/workflows/trivy.yml
deleted file mode 100644
index d5585f4..0000000
--- a/.forgejo/workflows/trivy.yml
+++ /dev/null
@@ -1,40 +0,0 @@
-name: Trivy
-
-on:
- schedule:
- - cron: '0 1 * * *'
- push:
-
-jobs:
- test:
- runs-on: docker
-
- steps:
- - name: Checkout
- uses: actions/checkout@v4
-
- - name: Install system dependencies
- run: |
- DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends wget gnupg
- wget -qO - https://aquasecurity.github.io/trivy-repo/deb/public.key | gpg --dearmor | tee /usr/share/keyrings/trivy.gpg > /dev/null
- echo "deb [signed-by=/usr/share/keyrings/trivy.gpg] https://aquasecurity.github.io/trivy-repo/deb generic main" | tee -a /etc/apt/sources.list.d/trivy.list
- apt-get update
- DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends trivy
-
- - name: Run trivy
- run: |
- trivy fs --no-progress --ignore-unfixed --format table --disable-telemetry --skip-version-check --exit-code 1 .
-
- # Notify if any previous step in this job failed
- - name: Notify on failure
- if: ${{ failure() }}
- env:
- WEBHOOK_URL: ${{ secrets.NODERED_WEBHOOK_URL }}
- REPOSITORY: ${{ forgejo.repository }}
- RUN_NUMBER: ${{ forgejo.run_number }}
- SERVER_URL: ${{ forgejo.server_url }}
- run: |
- curl -X POST \
- -H "Content-Type: application/json" \
- -d "{\"repository\":\"$REPOSITORY\",\"run_number\":\"$RUN_NUMBER\",\"status\":\"failure\",\"url\":\"$SERVER_URL/$REPOSITORY/actions/runs/$RUN_NUMBER\"}" \
- "$WEBHOOK_URL"
diff --git a/.gitignore b/.gitignore
index 07c956d..4ef962d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,3 +8,4 @@ dist
*.pdf
*.csv
*.html
+coverage.xml
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 0740cb4..ef94a82 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,54 @@
+# 0.6.0
+
+ * Add support for capturing ipset and iptables configuration files
+ * Add support for generating ipset and iptables configuration files from runtime, if the former weren't present (`firewall_runtime` role)
+ * Dependency updates
+
+# 0.5.0
+
+ * Add support for templating `sshd_config`, if a compatible version of JinjaTurtle is also present.
+ * Dependency updates
+
+# 0.4.4
+
+ * Update cryptography dependency
+ * Add capability to handle passphrases on encrypted SSH private keys. Prompting can be forced with `--ask-key-passphrase` or automated (e.g for CI) with `--ssh-key-passphrase env SOMEVAR`
+
+# 0.4.3
+
+ * Add support for AddressFamily and ConnectTimeout in the .ssh/config when using `--remote-ssh-config`.
+ * Update dependencies
+
+# 0.4.2
+
+ * Support `--remote-ssh-config [path-to-ssh-config]` as an argument in case extra params are required beyond `--remote-port` or `--remote-user`. Note: `--remote-host` must still be set, but it can be an 'alias' represented by the 'Host' value in the ssh config.
+
+# 0.4.1
+
+ * Add interactive output when 'enroll diff --enforce' is invoking Ansible.
+
+# 0.4.0
+
+ * Introduce `enroll validate` - a tool to validate a harvest against the state schema, or check for missing or orphaned obsolete artifacts in a harvest.
+ * Attempt to generate Jinja2 templates of systemd unit files and Postfix main.cf (now that JinjaTurtle supports it)
+ * Update pynacl dependency to resolve CVE-2025-69277
+ * Add `--exclude-path` to `enroll diff` command, so that you can ignore certain churn from the diff (stuff you still wanted to harvest as a baseline but don't care if it changes day to day)
+ * Add `--ignore-package-versions` to `enroll diff` command, to optionally ignore package upgrades (e.g due to patching) from the diff.
+ * Add tags to the playbook for each role, to allow easier targeting of specific roles during play later.
+ * Add `--enforce` mode to `enroll diff`. If there is diff detected between the two harvests, and it can enforce restoring the state from the older harvest, it will manifest the state and apply it with ansible. Only the specific roles that had diffed will be applied (via the new tags capability)
+
+# 0.3.0
+
+ * Introduce `enroll explain` - a tool to analyze and explain what's in (or not in) a harvest and why.
+ * Centralise the cron and logrotate stuff into their respective roles, we had a bit of duplication between roles based on harvest discovery.
+ * Capture other files in the user's home directory such as `.bashrc`, `.bash_aliases`, `.profile`, if these files differ from the `/etc/skel` defaults
+ * Ignore files that end with a tilde or - (probably backup files generated by editors or shadow file changes)
+ * Manage certain symlinks e.g for apache2/nginx sites-enabled and so on
+
+# 0.2.3
+
+ * Introduce --ask-become-pass or -K to support password-required sudo on remote hosts, just like Ansible. It will also fall back to this prompt if a password is required but the arg wasn't passed in.
+
# 0.2.2
* Fix stat() of parent directory so that we set directory perms correct on --include paths.
@@ -5,7 +56,7 @@
# 0.2.1
- * Don't accidentally add extra_paths role to usr_local_custom list, resulting in extra_paths appearing twice in manifested playbook
+ * Don't accidentally add `extra_paths` role to `usr_local_custom` list, resulting in `extra_paths` appearing twice in manifested playbook
* Ensure directories in the tree of anything included with --include are defined in the state and manifest so we make dirs before we try to create files
# 0.2.0
@@ -26,8 +77,8 @@
# 0.1.5
* Consolidate logrotate and cron files into their main service/package roles if they exist.
- * Standardise on MAX_FILES_CAP in one place
- * Manage apt stuff in its own role, not in etc_custom
+ * Standardise on `MAX_FILES_CAP` in one place
+ * Manage apt stuff in its own role, not in `etc_custom`
# 0.1.4
diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md
new file mode 100644
index 0000000..a411acc
--- /dev/null
+++ b/CONTRIBUTORS.md
@@ -0,0 +1,5 @@
+## Contributors
+
+mig5 would like to thank the following people for their contributions to Enroll.
+
+ * [slhck](https://slhck.info/)
diff --git a/Dockerfile.debbuild b/Dockerfile.debbuild
index a466ee2..c6ebedb 100644
--- a/Dockerfile.debbuild
+++ b/Dockerfile.debbuild
@@ -26,6 +26,7 @@ RUN set -eux; \
python3-poetry-core \
python3-yaml \
python3-paramiko \
+ python3-jsonschema \
rsync \
ca-certificates \
; \
diff --git a/Dockerfile.rpmbuild b/Dockerfile.rpmbuild
index f76a673..dd83546 100644
--- a/Dockerfile.rpmbuild
+++ b/Dockerfile.rpmbuild
@@ -22,6 +22,7 @@ RUN set -eux; \
python3-rpm-macros \
python3-yaml \
python3-paramiko \
+ python3-jsonschema \
openssl-devel \
python3-poetry-core ; \
dnf -y clean all
@@ -34,25 +35,8 @@ set -euo pipefail
SRC="${SRC:-/src}"
WORKROOT="${WORKROOT:-/work}"
OUT="${OUT:-/out}"
-DEPS_DIR="${DEPS_DIR:-/deps}"
VERSION_ID="$(grep VERSION_ID /etc/os-release | cut -d= -f2)"
echo "Version ID is ${VERSION_ID}"
-# Install jinjaturtle from local rpm
-# Filter out .src.rpm and debug* subpackages if present.
-if [ -d "${DEPS_DIR}" ] && compgen -G "${DEPS_DIR}/*.rpm" > /dev/null; then
- mapfile -t rpms < <(ls -1 "${DEPS_DIR}"/*.rpm | grep -vE '(\.src\.rpm$|-(debuginfo|debugsource)-)' | grep "${VERSION_ID}")
- if [ "${#rpms[@]}" -gt 0 ]; then
- echo "Installing dependency RPMs from ${DEPS_DIR}:"
- printf ' - %s\n' "${rpms[@]}"
- dnf -y install "${rpms[@]}"
- dnf -y clean all
- else
- echo "NOTE: Only src/debug RPMs found in ${DEPS_DIR}; nothing installed." >&2
- fi
-else
- echo "NOTE: No RPMs found in ${DEPS_DIR}. If the build fails with missing python3dist(jinjaturtle)," >&2
- echo " mount your jinjaturtle RPM directory as -v
:/deps" >&2
-fi
mkdir -p "${WORKROOT}" "${OUT}"
WORK="${WORKROOT}/src"
diff --git a/README.md b/README.md
index e399633..d2d51ad 100644
--- a/README.md
+++ b/README.md
@@ -11,8 +11,10 @@
- Captures config that has **changed from packaged defaults** where possible (e.g dpkg conffile hashes + package md5sums when available).
- Also captures **service-relevant custom/unowned files** under `/etc//...` (e.g. drop-in config includes).
- Defensively excludes likely secrets (path denylist + content sniff + size caps).
-- Captures non-system users and their SSH public keys.
+- Captures non-system users and their SSH public keys and any .bashrc or .bash_aliases or .profile files that deviate from the skel defaults.
- Captures miscellaneous `/etc` files it can't attribute to a package and installs them in an `etc_custom` role.
+- Captures live ipset and iptables runtime state into a fallback `firewall_runtime` role, when active ipsets/iptables rules are present *and* no corresponding persistent ipset/iptables *files* were found.
+- Captures symlinks in common applications that rely on them, e.g apache2/nginx 'sites-enabled'
- Ditto for /usr/local/bin (for non-binary files) and /usr/local/etc
- Avoids trying to start systemd services that were detected as inactive during harvest.
@@ -69,11 +71,13 @@ Harvest state about a host and write a harvest bundle.
- Changed-from-default config (plus related custom/unowned files under service dirs)
- Non-system users + SSH public keys
- Misc `/etc` that can't be attributed to a package (`etc_custom` role)
+- Static firewall config files such as nftables, UFW, firewalld, `/etc/iptables/rules.v4`, `/etc/iptables/rules.v6`, and `/etc/ipset*`
+- Live kernel ipset/iptables state via `ipset save`, `iptables-save`, and `ip6tables-save` as a fallback, but only when the corresponding persistent config was not found (`firewall_runtime` role at manifest time)
- Optional user-specified extra files/dirs via `--include-path` (emitted as an `extra_paths` role at manifest time)
**Common flags**
- Remote harvesting:
- - `--remote-host`, `--remote-user`, `--remote-port`
+ - `--remote-host`, `--remote-user`, `--remote-port`, `--remote-ssh-config`
- `--no-sudo` (if you don't want/need sudo)
- Sensitive-data behaviour:
- default: tries to avoid likely secrets
@@ -88,6 +92,27 @@ Harvest state about a host and write a harvest bundle.
- glob (default): supports `*` and `**` (prefix with `glob:` to force)
- regex: prefix with `re:` or `regex:`
- Precedence: excludes win over includes.
+ * Using remote mode and auth requires secrets?
+ * sudo password:
+ * `--ask-become-pass` (or `-K`) prompts for the sudo password.
+ * If you forget, and remote sudo requires a password, Enroll will still fall back to prompting in interactive mode (slightly slower due to retry).
+ * SSH private-key passphrase:
+ * `--ask-key-passphrase` prompts for the SSH key passphrase.
+ * `--ssh-key-passphrase-env ENV_VAR` reads the SSH key passphrase from an environment variable (useful for CI/non-interactive runs).
+ * If neither is provided, and Enroll detects an encrypted key in an interactive session, it will still fall back to prompting on-demand.
+ * In non-interactive sessions, pass `--ask-key-passphrase` or `--ssh-key-passphrase-env ENV_VAR` when using encrypted private keys.
+ * Note: `--ask-key-passphrase` and `--ssh-key-passphrase-env` are mutually exclusive.
+
+Examples (encrypted SSH key)
+
+```bash
+# Interactive
+enroll harvest --remote-host myhost.example.com --remote-user myuser --ask-key-passphrase --out /tmp/enroll-harvest
+
+# Non-interactive / CI
+export ENROLL_SSH_KEY_PASSPHRASE='correct horse battery staple'
+enroll single-shot --remote-host myhost.example.com --remote-user myuser --ssh-key-passphrase-env ENROLL_SSH_KEY_PASSPHRASE --harvest /tmp/enroll-harvest --out /tmp/enroll-ansible --fqdn myhost.example.com
+```
---
@@ -105,6 +130,17 @@ Generate Ansible output from an existing harvest bundle.
**Common flags**
- `--fqdn `: enables **multi-site** output style
+**Role tags**
+Generated playbooks tag each role so you can target just the parts you need:
+
+- Tag format: `role_` (e.g. `role_services`, `role_users`)
+- Fallback/safe tag: `role_other`
+
+Example:
+```bash
+ansible-playbook -i "localhost," -c local /tmp/enroll-ansible/playbook.yml --tags role_services,role_users
+```
+
---
### `enroll single-shot`
@@ -128,6 +164,26 @@ Compare two harvest bundles and report what changed.
**Inputs**
- `--old ` and `--new ` (directories or `state.json` paths)
- `--sops` when comparing SOPS-encrypted harvest bundles
+- `--exclude-path ` (repeatable) to ignore file/dir drift under matching paths (same pattern syntax as harvest)
+- `--ignore-package-versions` to ignore package version-only drift (upgrades/downgrades)
+- `--enforce` to apply the **old** harvest state locally (requires `ansible-playbook` on `PATH`)
+
+**Noise suppression**
+- `--exclude-path` is useful for things that change often but you still want in the harvest baseline (e.g. `/var/anacron`).
+- `--ignore-package-versions` keeps routine upgrades from alerting; package add/remove drift is still reported.
+
+**Enforcement (`--enforce`)**
+If a diff exists and `ansible-playbook` is available, Enroll will:
+1) generate a manifest from the **old** harvest into a temporary directory
+2) run `ansible-playbook -i localhost, -c local /playbook.yml` (often with `--tags role_<...>` to limit runtime)
+3) record in the diff report that the old harvest was enforced
+
+Enforcement is intentionally “safe”:
+- reinstalls packages that were removed (`state: present`), but does **not** attempt downgrades/pinning
+- restores users, files (contents + permissions/ownership), and service enable/start state
+
+If `ansible-playbook` is not on `PATH`, Enroll returns an error and does not enforce.
+
**Output formats**
- `--format json` (default for webhooks)
@@ -143,6 +199,72 @@ Compare two harvest bundles and report what changed.
---
+### `enroll explain`
+Analyze a harvest and provide user-friendly explanations for what's in it and why.
+
+This may also explain why something *wasn't* included (e.g a binary file, a file that was too large, unreadable due to permissions, or looked like a log file/secret.
+
+Provide either the path to the harvest or the path to its state.json. It can also handle SOPS-encrypted harvests.
+
+Output can be provided in plaintext or json.
+
+---
+
+### `enroll validate`
+
+Validates a harvest by checking:
+
+ * state.json exists and is valid JSON
+ * state.json validates against a JSON Schema (by default the vendored one)
+ * Every `managed_file` entry has a corresponding artifact at: `artifacts//`
+ * That there are no **unreferenced files** sitting in `artifacts/` that aren't in the state.
+
+#### Schema location + overrides
+
+The master schema lives at: `enroll/schema/state.schema.json`.
+
+You can override with a local file or URL:
+
+```
+enroll validate /path/to/harvest --schema ./state.schema.json
+enroll validate /path/to/harvest --schema https://enroll.sh/schema/state.schema.json
+```
+
+Or skip schema checks (still does artifact consistency checks):
+
+```
+enroll validate /path/to/harvest --no-schema
+```
+
+#### CLI usage examples
+
+Validate a local harvest:
+
+```
+enroll validate ./harvest
+```
+
+Validate a harvest tarball or a sops bundle:
+
+```
+enroll validate ./harvest.tar.gz
+enroll validate ./harvest.sops --sops
+```
+
+JSON output + write to file:
+
+```
+enroll validate ./harvest --format json --out validate.json
+```
+
+Return exit code 1 for any warnings, not just errors (useful for CI):
+
+```
+enroll validate ./harvest --fail-on-warnings
+```
+
+---
+
## Sensitive data
By default, `enroll` does **not** assume how you handle secrets in Ansible. It will attempt to avoid harvesting likely sensitive data (private keys, passwords, tokens, etc.). This can mean it skips some config files you may ultimately want to manage.
@@ -199,7 +321,7 @@ sudo rpm --import https://mig5.net/static/mig5.asc
sudo tee /etc/yum.repos.d/mig5.repo > /dev/null << 'EOF'
[mig5]
name=mig5 Repository
-baseurl=https://rpm.mig5.net/rpm/$releasever/$basearch
+baseurl=https://rpm.mig5.net/$releasever/rpm/$basearch
enabled=1
gpgcheck=1
repo_gpgcheck=1
@@ -255,6 +377,14 @@ enroll harvest --out /tmp/enroll-harvest
enroll harvest --remote-host myhost.example.com --remote-user myuser --out /tmp/enroll-harvest
```
+### Remote harvest over SSH, where the SSH configuration is in ~/.ssh/config (e.g a different SSH key)
+
+Note: you must still pass `--remote-host`, but in this case, its value can be the 'Host' alias of an entry in your `~/.ssh/config`.
+
+```bash
+enroll harvest --remote-host myhostalias --remote-ssh-config ~/.ssh/config --out /tmp/enroll-harvest
+```
+
### Include paths (`--include-path`)
```bash
# Add a few dotfiles from /home (still secret-safe unless --dangerous)
@@ -330,7 +460,7 @@ enroll single-shot --remote-host myhost.example.com --remote-user myuser --har
## Diff
-### Compare two harvest directories
+### Compare two harvest directories, output in json
```bash
enroll diff --old /path/to/harvestA --new /path/to/harvestB --format json
```
@@ -342,6 +472,83 @@ enroll diff --old /path/to/golden/harvest --new /path/to/new/harvest --web
`diff` mode also supports email sending and text or markdown format, as well as `--exit-code` mode to trigger a return code of 2 (useful for crons or CI)
+### Ignore a specific directory or file from the diff
+```bash
+enroll diff --old /path/to/harvestA --new /path/to/harvestB --exclude-path /var/anacron
+```
+
+### Ignore package version drift (routine upgrades) but still alert on add/remove
+```bash
+enroll diff --old /path/to/harvestA --new /path/to/harvestB --ignore-package-versions
+```
+
+### Enforce the old harvest state when drift is detected (requires Ansible)
+```bash
+enroll diff --old /path/to/harvestA --new /path/to/harvestB --enforce --ignore-package-versions --exclude-path /var/anacron
+```
+
+---
+
+## Explain
+
+### Explain a harvest
+
+All of these do the same thing:
+
+```bash
+enroll explain /path/to/state.json
+enroll explain /path/to/bundle_dir
+enroll explain /path/to/harvest.tar.gz
+```
+
+### Explain a SOPS-encrypted harvest
+
+```bash
+enroll explain /path/to/harvest.tar.gz.sops --sops
+```
+
+### Explain with JSON output and more examples
+
+```bash
+enroll explain /path/to/state.json --format json --max-examples 25
+```
+
+### Example output
+
+```
+❯ enroll explain /tmp/syrah.harvest
+Enroll explain: /tmp/syrah.harvest
+Host: syrah.mig5.net (os: debian, pkg: dpkg)
+Enroll: 0.2.3
+
+Inventory
+- Packages: 254
+- Why packages were included (observed_via):
+ - user_installed: 248 – Package appears explicitly installed (as opposed to only pulled in as a dependency).
+ - package_role: 232 – Package was referenced by an enroll packages snapshot/role. (e.g. acl, acpid, adduser)
+ - systemd_unit: 22 – Package is associated with a systemd unit that was harvested. (e.g. postfix.service, tor.service, apparmor.service)
+
+Roles collected
+- users: 1 user(s), 1 file(s), 0 excluded
+- services: 19 unit(s), 111 file(s), 6 excluded
+- packages: 232 package snapshot(s), 41 file(s), 0 excluded
+- apt_config: 26 file(s), 7 dir(s), 10 excluded
+- dnf_config: 0 file(s), 0 dir(s), 0 excluded
+- firewall_runtime: 2 snapshot(s), 1 ipset(s)
+- etc_custom: 70 file(s), 20 dir(s), 0 excluded
+- usr_local_custom: 35 file(s), 1 dir(s), 0 excluded
+- extra_paths: 0 file(s), 0 dir(s), 0 excluded
+
+Why files were included (managed_files.reason)
+- custom_unowned (179): A file not owned by any package (often custom/operator-managed).. Examples: /etc/apparmor.d/local/lsb_release, /etc/apparmor.d/local/nvidia_modprobe, /etc/apparmor.d/local/sbin.dhclient
+- usr_local_bin_script (35): Executable scripts under /usr/local/bin (often operator-installed).. Examples: /usr/local/bin/check_firewall, /usr/local/bin/awslogs
+- apt_keyring (13): Repository signing key material used by APT.. Examples: /etc/apt/keyrings/openvpn-repo-public.asc, /etc/apt/trusted.gpg, /etc/apt/trusted.gpg.d/deb.torproject.org-keyring.gpg
+- modified_conffile (10): A package-managed conffile differs from the packaged/default version.. Examples: /etc/dnsmasq.conf, /etc/ssh/moduli, /etc/tor/torrc
+- logrotate_snippet (9): logrotate snippets/configs referenced in system configuration.. Examples: /etc/logrotate.d/rsyslog, /etc/logrotate.d/tor, /etc/logrotate.d/apt
+- apt_config (7): APT configuration affecting package installation and repository behavior.. Examples: /etc/apt/apt.conf.d/01autoremove, /etc/apt/apt.conf.d/20listchanges, /etc/apt/apt.conf.d/70debconf
+[...]
+```
+
---
## Run Ansible
@@ -356,6 +563,12 @@ ansible-playbook -i "localhost," -c local /tmp/enroll-ansible/playbook.yml
ansible-playbook /tmp/enroll-ansible/playbooks/"$(hostname -f)".yml
```
+### Run only specific roles (tags)
+Generated playbooks tag each role as `role_` (e.g. `role_users`, `role_services`), so you can speed up targeted runs:
+```bash
+ansible-playbook -i "localhost," -c local /tmp/enroll-ansible/playbook.yml --tags role_users
+```
+
## Configuration file
As can be seen above, there are a lot of powerful 'permutations' available to all four subcommands.
@@ -403,7 +616,13 @@ exclude_path = /usr/local/bin/docker-*, /usr/local/bin/some-tool
[manifest]
# you can set defaults here too, e.g.
no_jinjaturtle = true
-sops = 00AE817C24A10C2540461A9C1D7CDE0234DB458D
+sops = 54A91143AE0AB4F7743B01FE888ED1B423A3BC99
+
+[diff]
+# ignore noisy drift
+exclude_path = /var/anacron
+ignore_package_versions = true
+# enforce = true # requires ansible-playbook on PATH
[single-shot]
# if you use single-shot, put its defaults here.
diff --git a/debian/changelog b/debian/changelog
index c461964..5292e0e 100644
--- a/debian/changelog
+++ b/debian/changelog
@@ -1,3 +1,67 @@
+enroll (0.6.0) unstable; urgency=medium
+
+ * Add support for capturing ipset and iptables configuration files
+ * Add support for generating ipset and iptables configuration files from runtime, if the former weren't present ('firewall_runtime' role)
+
+ -- Miguel Jacq Thu, 14 May 2026 15:00 +1000
+
+enroll (0.5.0) unstable; urgency=medium
+
+ * Add ssh config support where JinjaTurtle is used
+
+ -- Miguel Jacq Tue, 12 May 2026 12:00 +1000
+
+enroll (0.4.4) unstable; urgency=medium
+
+ * Add capability to handle passphrases on encrypted SSH private keys. Prompting can be forced with `--ask-key-passphrase` or automated (e.g for CI) with `--ssh-key-passphrase env SOMEVAR`
+
+ -- Miguel Jacq Tue, 17 Feb 2026 11:00 +1100
+
+enroll (0.4.3) unstable; urgency=medium
+
+ * Add support for AddressFamily and ConnectTimeout in the .ssh/config when using `--remote-ssh-config`.
+
+ -- Miguel Jacq Fri, 16 Jan 2026 11:00 +1100
+
+enroll (0.4.2) unstable; urgency=medium
+
+ * Support `--remote-ssh-config [path-to-ssh-config]` as an argument in case extra params are required beyond `--remote-port` or `--remote-user`. Note: `--remote-host` must still be set, but it can be an 'alias' represented by the 'Host' value in the ssh config.
+
+ -- Miguel Jacq Tue, 13 Jan 2026 21:55:00 +1100
+
+enroll (0.4.1) unstable; urgency=medium
+ * Add interactive output when 'enroll diff --enforce' is invoking Ansible.
+
+ -- Miguel Jacq Sun, 11 Jan 2026 10:00:00 +1100
+
+enroll (0.4.0) unstable; urgency=medium
+ * Introduce `enroll validate` - a tool to validate a harvest against the state schema, or check for missing or orphaned obsolete artifacts in a harvest.
+ * Attempt to generate Jinja2 templates of systemd unit files and Postfix main.cf (now that JinjaTurtle supports it)
+ * Update pynacl dependency to resolve CVE-2025-69277
+ * Add `--exclude-path` to `enroll diff` command, so that you can ignore certain churn from the diff (stuff you still wanted to harvest as a baseline but don't care if it changes day to day)
+ * Add `--ignore-package-versions` to `enroll diff` command, to optionally ignore package upgrades (e.g due to patching) from the diff.
+ * Add tags to the playbook for each role, to allow easier targeting of specific roles during play later.
+ * Add `--enforce` mode to `enroll diff`. If there is diff detected between the two harvests, and it can enforce restoring the state from the older harvest, it will manifest the state and apply it with ansible.
+Only the specific roles that had diffed will be applied (via the new tags capability)
+
+ -- Miguel Jacq Sat, 10 Jan 2026 10:30:00 +1100
+
+enroll (0.3.0) unstable; urgency=medium
+
+ * Introduce `enroll explain` - a tool to analyze and explain what's in (or not in) a harvest and why.
+ * Centralise the cron and logrotate stuff into their respective roles, we had a bit of duplication between roles based on harvest discovery.
+ * Capture other files in the user's home directory such as `.bashrc`, `.bash_aliases`, `.profile`, if these files differ from the `/etc/skel` defaults
+ * Ignore files that end with a tilde or - (probably backup files generated by editors or shadow file changes)
+ * Manage certain symlinks e.g for apache2/nginx sites-enabled and so on
+
+ -- Miguel Jacq Mon, 05 Jan 2026 17:00:00 +1100
+
+enroll (0.2.3) unstable; urgency=medium
+
+ * Introduce --ask-become-pass or -K to support password-required sudo on remote hosts, just like Ansible. It will also fall back to this prompt if a password is required but the arg wasn't passed in.
+
+ -- Miguel Jacq Sun, 04 Jan 2026 20:38:00 +1100
+
enroll (0.2.2) unstable; urgency=medium
* Fix stat() of parent directory so that we set directory perms correct on --include paths.
diff --git a/debian/control b/debian/control
index 7f323fd..d5a21fe 100644
--- a/debian/control
+++ b/debian/control
@@ -10,12 +10,13 @@ Build-Depends:
python3-all,
python3-yaml,
python3-poetry-core,
- python3-paramiko
+ python3-paramiko,
+ python3-jsonschema
Standards-Version: 4.6.2
Homepage: https://git.mig5.net/mig5/enroll
Package: enroll
Architecture: all
-Depends: ${misc:Depends}, ${python3:Depends}, python3-yaml, python3-paramiko
+Depends: ${misc:Depends}, ${python3:Depends}, python3-yaml, python3-paramiko, python3-jsonschema
Description: Harvest a host into Ansible roles
A tool that inspects a system and emits Ansible roles/playbooks to reproduce it.
diff --git a/enroll/cli.py b/enroll/cli.py
index bb4d3f1..44de047 100644
--- a/enroll/cli.py
+++ b/enroll/cli.py
@@ -2,6 +2,7 @@ from __future__ import annotations
import argparse
import configparser
+import json
import os
import sys
import tarfile
@@ -10,11 +11,24 @@ from pathlib import Path
from typing import Optional
from .cache import new_harvest_cache_dir
-from .diff import compare_harvests, format_report, post_webhook, send_email
+from .diff import (
+ compare_harvests,
+ enforce_old_harvest,
+ format_report,
+ has_enforceable_drift,
+ post_webhook,
+ send_email,
+)
+from .explain import explain_state
from .harvest import harvest
from .manifest import manifest
-from .remote import remote_harvest
+from .remote import (
+ remote_harvest,
+ RemoteSudoPasswordRequired,
+ RemoteSSHKeyPassphraseRequired,
+)
from .sopsutil import SopsError, encrypt_file_binary
+from .validate import validate_harvest
from .version import get_enroll_version
@@ -340,16 +354,62 @@ def _add_remote_args(p: argparse.ArgumentParser) -> None:
"--remote-host",
help="SSH host to run harvesting on (if set, harvest runs remotely and is pulled locally).",
)
+ p.add_argument(
+ "--remote-ssh-config",
+ nargs="?",
+ const=str(Path.home() / ".ssh" / "config"),
+ default=None,
+ help=(
+ "Use OpenSSH-style ssh_config settings for --remote-host. "
+ "If provided without a value, defaults to ~/.ssh/config. "
+ "(Applies HostName/User/Port/IdentityFile/ProxyCommand/HostKeyAlias when supported.)"
+ ),
+ )
p.add_argument(
"--remote-port",
type=int,
- default=22,
- help="SSH port for --remote-host (default: 22).",
+ default=None,
+ help=(
+ "SSH port for --remote-host. If omitted, defaults to 22, or a value from ssh_config when "
+ "--remote-ssh-config is set."
+ ),
)
p.add_argument(
"--remote-user",
- default=os.environ.get("USER") or None,
- help="SSH username for --remote-host (default: local $USER).",
+ default=None,
+ help=(
+ "SSH username for --remote-host. If omitted, defaults to local $USER, or a value from ssh_config when "
+ "--remote-ssh-config is set."
+ ),
+ )
+
+ # Align terminology with Ansible: "become" == sudo.
+ p.add_argument(
+ "--ask-become-pass",
+ "-K",
+ action="store_true",
+ help=(
+ "Prompt for the remote sudo (become) password when using --remote-host "
+ "(similar to ansible --ask-become-pass)."
+ ),
+ )
+
+ keyp = p.add_mutually_exclusive_group()
+ keyp.add_argument(
+ "--ask-key-passphrase",
+ action="store_true",
+ help=(
+ "Prompt for the SSH private key passphrase when using --remote-host. "
+ "If not set, enroll will still prompt on-demand if it detects an encrypted key in an interactive session."
+ ),
+ )
+ keyp.add_argument(
+ "--ssh-key-passphrase-env",
+ metavar="ENV_VAR",
+ help=(
+ "Read the SSH private key passphrase from environment variable ENV_VAR "
+ "(useful for non-interactive runs/CI)."
+ ),
)
@@ -536,6 +596,33 @@ def main() -> None:
default="text",
help="Report output format (default: text).",
)
+ d.add_argument(
+ "--exclude-path",
+ action="append",
+ default=[],
+ metavar="PATTERN",
+ help=(
+ "Exclude file paths from the diff report (repeatable). Supports globs (including '**') and regex via 're:'. "
+ "This affects file drift reporting only (added/removed/changed files), not package/service/user diffs."
+ ),
+ )
+ d.add_argument(
+ "--ignore-package-versions",
+ action="store_true",
+ help=(
+ "Ignore package version changes in the diff report and exit status. "
+ "Package additions/removals are still reported. Useful when routine upgrades would otherwise create noisy drift."
+ ),
+ )
+ d.add_argument(
+ "--enforce",
+ action="store_true",
+ help=(
+ "If differences are detected, attempt to enforce the old harvest state locally by generating a manifest and "
+ "running ansible-playbook. Requires ansible-playbook on PATH. "
+ "Enroll does not attempt to downgrade packages; if the only drift is package version upgrades (or newly installed packages), enforcement is skipped."
+ ),
+ )
d.add_argument(
"--out",
help="Write the report to this file instead of stdout.",
@@ -594,6 +681,75 @@ def main() -> None:
help="Environment variable containing SMTP password (optional).",
)
+ e = sub.add_parser("explain", help="Explain a harvest state.json")
+ _add_config_args(e)
+ e.add_argument(
+ "harvest",
+ help=(
+ "Harvest input (directory, a path to state.json, a tarball, or a SOPS-encrypted bundle)."
+ ),
+ )
+ e.add_argument(
+ "--sops",
+ action="store_true",
+ help="Treat the input as a SOPS-encrypted bundle (auto-detected if the filename ends with .sops).",
+ )
+ e.add_argument(
+ "--format",
+ choices=["text", "json"],
+ default="text",
+ help="Output format.",
+ )
+ e.add_argument(
+ "--max-examples",
+ type=int,
+ default=3,
+ help="How many example paths/refs to show per reason.",
+ )
+
+ v = sub.add_parser(
+ "validate", help="Validate a harvest bundle (state.json + artifacts)"
+ )
+ _add_config_args(v)
+ v.add_argument(
+ "harvest",
+ help=(
+ "Harvest input (directory, a path to state.json, a tarball, or a SOPS-encrypted bundle)."
+ ),
+ )
+ v.add_argument(
+ "--sops",
+ action="store_true",
+ help="Treat the input as a SOPS-encrypted bundle (auto-detected if the filename ends with .sops).",
+ )
+ v.add_argument(
+ "--schema",
+ help=(
+ "Optional JSON schema source (file path or https:// URL). "
+ "If omitted, uses the schema vendored in the enroll codebase."
+ ),
+ )
+ v.add_argument(
+ "--no-schema",
+ action="store_true",
+ help="Skip JSON schema validation and only perform bundle consistency checks.",
+ )
+ v.add_argument(
+ "--fail-on-warnings",
+ action="store_true",
+ help="Exit non-zero if validation produces warnings.",
+ )
+ v.add_argument(
+ "--format",
+ choices=["text", "json"],
+ default="text",
+ help="Output format.",
+ )
+ v.add_argument(
+ "--out",
+ help="Write the report to this file instead of stdout.",
+ )
+
argv = sys.argv[1:]
cfg_path = _discover_config_path(argv)
argv = _inject_config_argv(
@@ -605,10 +761,23 @@ def main() -> None:
"manifest": m,
"single-shot": s,
"diff": d,
+ "explain": e,
+ "validate": v,
},
)
args = ap.parse_args(argv)
+ # Preserve historical defaults for remote harvesting unless ssh_config lookup is enabled.
+ # This lets ssh_config values take effect when the user did not explicitly set
+ # --remote-user / --remote-port.
+ if hasattr(args, "remote_host"):
+ rsc = getattr(args, "remote_ssh_config", None)
+ if not rsc:
+ if getattr(args, "remote_port", None) is None:
+ setattr(args, "remote_port", 22)
+ if getattr(args, "remote_user", None) is None:
+ setattr(args, "remote_user", os.environ.get("USER") or None)
+
try:
if args.cmd == "harvest":
sops_fps = getattr(args, "sops", None)
@@ -623,10 +792,16 @@ def main() -> None:
except OSError:
pass
remote_harvest(
+ ask_become_pass=args.ask_become_pass,
+ ask_key_passphrase=bool(args.ask_key_passphrase),
+ ssh_key_passphrase_env=getattr(
+ args, "ssh_key_passphrase_env", None
+ ),
local_out_dir=tmp_bundle,
remote_host=args.remote_host,
- remote_port=int(args.remote_port),
+ remote_port=args.remote_port,
remote_user=args.remote_user,
+ remote_ssh_config=args.remote_ssh_config,
dangerous=bool(args.dangerous),
no_sudo=bool(args.no_sudo),
include_paths=list(getattr(args, "include_path", []) or []),
@@ -643,10 +818,16 @@ def main() -> None:
else new_harvest_cache_dir(hint=args.remote_host).dir
)
state = remote_harvest(
+ ask_become_pass=args.ask_become_pass,
+ ask_key_passphrase=bool(args.ask_key_passphrase),
+ ssh_key_passphrase_env=getattr(
+ args, "ssh_key_passphrase_env", None
+ ),
local_out_dir=out_dir,
remote_host=args.remote_host,
- remote_port=int(args.remote_port),
+ remote_port=args.remote_port,
remote_user=args.remote_user,
+ remote_ssh_config=args.remote_ssh_config,
dangerous=bool(args.dangerous),
no_sudo=bool(args.no_sudo),
include_paths=list(getattr(args, "include_path", []) or []),
@@ -689,6 +870,42 @@ def main() -> None:
exclude_paths=list(getattr(args, "exclude_path", []) or []),
)
print(path)
+ elif args.cmd == "explain":
+ out = explain_state(
+ args.harvest,
+ sops_mode=bool(getattr(args, "sops", False)),
+ fmt=str(getattr(args, "format", "text")),
+ max_examples=int(getattr(args, "max_examples", 3)),
+ )
+ sys.stdout.write(out)
+
+ elif args.cmd == "validate":
+ res = validate_harvest(
+ args.harvest,
+ sops_mode=bool(getattr(args, "sops", False)),
+ schema=getattr(args, "schema", None),
+ no_schema=bool(getattr(args, "no_schema", False)),
+ )
+
+ fmt = str(getattr(args, "format", "text"))
+ if fmt == "json":
+ txt = json.dumps(res.to_dict(), indent=2, sort_keys=True) + "\n"
+ else:
+ txt = res.to_text()
+
+ out_path = getattr(args, "out", None)
+ if out_path:
+ p = Path(out_path).expanduser()
+ p.parent.mkdir(parents=True, exist_ok=True)
+ p.write_text(txt, encoding="utf-8")
+ else:
+ sys.stdout.write(txt)
+
+ if res.errors:
+ raise SystemExit(1)
+ if res.warnings and bool(getattr(args, "fail_on_warnings", False)):
+ raise SystemExit(1)
+
elif args.cmd == "manifest":
out_enc = manifest(
args.harvest,
@@ -704,8 +921,47 @@ def main() -> None:
args.old,
args.new,
sops_mode=bool(getattr(args, "sops", False)),
+ exclude_paths=list(getattr(args, "exclude_path", []) or []),
+ ignore_package_versions=bool(
+ getattr(args, "ignore_package_versions", False)
+ ),
)
+ # Optional enforcement: if drift is detected, attempt to restore the
+ # system to the *old* (baseline) state using ansible-playbook.
+ if bool(getattr(args, "enforce", False)):
+ if has_changes:
+ if not has_enforceable_drift(report):
+ report["enforcement"] = {
+ "requested": True,
+ "status": "skipped",
+ "reason": (
+ "no enforceable drift detected (only additions and/or package version changes); "
+ "enroll does not attempt to downgrade packages"
+ ),
+ }
+ else:
+ try:
+ info = enforce_old_harvest(
+ args.old,
+ sops_mode=bool(getattr(args, "sops", False)),
+ report=report,
+ )
+ except Exception as e:
+ raise SystemExit(
+ f"error: could not enforce old harvest state: {e}"
+ ) from e
+ report["enforcement"] = {
+ "requested": True,
+ **(info or {}),
+ }
+ else:
+ report["enforcement"] = {
+ "requested": True,
+ "status": "skipped",
+ "reason": "no differences detected",
+ }
+
txt = format_report(report, fmt=str(getattr(args, "format", "text")))
out_path = getattr(args, "out", None)
if out_path:
@@ -769,10 +1025,16 @@ def main() -> None:
except OSError:
pass
remote_harvest(
+ ask_become_pass=args.ask_become_pass,
+ ask_key_passphrase=bool(args.ask_key_passphrase),
+ ssh_key_passphrase_env=getattr(
+ args, "ssh_key_passphrase_env", None
+ ),
local_out_dir=tmp_bundle,
remote_host=args.remote_host,
- remote_port=int(args.remote_port),
+ remote_port=args.remote_port,
remote_user=args.remote_user,
+ remote_ssh_config=args.remote_ssh_config,
dangerous=bool(args.dangerous),
no_sudo=bool(args.no_sudo),
include_paths=list(getattr(args, "include_path", []) or []),
@@ -798,10 +1060,16 @@ def main() -> None:
else new_harvest_cache_dir(hint=args.remote_host).dir
)
remote_harvest(
+ ask_become_pass=args.ask_become_pass,
+ ask_key_passphrase=bool(args.ask_key_passphrase),
+ ssh_key_passphrase_env=getattr(
+ args, "ssh_key_passphrase_env", None
+ ),
local_out_dir=harvest_dir,
remote_host=args.remote_host,
- remote_port=int(args.remote_port),
+ remote_port=args.remote_port,
remote_user=args.remote_user,
+ remote_ssh_config=args.remote_ssh_config,
dangerous=bool(args.dangerous),
no_sudo=bool(args.no_sudo),
include_paths=list(getattr(args, "include_path", []) or []),
@@ -862,55 +1130,17 @@ def main() -> None:
fqdn=args.fqdn,
jinjaturtle=_jt_mode(args),
)
- elif args.cmd == "diff":
- report, has_changes = compare_harvests(
- args.old, args.new, sops_mode=bool(getattr(args, "sops", False))
- )
-
- rendered = format_report(report, fmt=str(args.format))
- if args.out:
- Path(args.out).expanduser().write_text(rendered, encoding="utf-8")
- else:
- print(rendered, end="")
-
- do_notify = bool(has_changes or getattr(args, "notify_always", False))
-
- if do_notify and getattr(args, "webhook", None):
- wf = str(getattr(args, "webhook_format", "json"))
- body = format_report(report, fmt=wf).encode("utf-8")
- headers = {"User-Agent": "enroll"}
- if wf == "json":
- headers["Content-Type"] = "application/json"
- else:
- headers["Content-Type"] = "text/plain; charset=utf-8"
- for hv in getattr(args, "webhook_header", []) or []:
- if ":" not in hv:
- raise SystemExit(
- "error: --webhook-header must be in the form 'K:V'"
- )
- k, v = hv.split(":", 1)
- headers[k.strip()] = v.strip()
- status, _ = post_webhook(str(args.webhook), body, headers=headers)
- if status and status >= 400:
- raise SystemExit(f"error: webhook returned HTTP {status}")
-
- if do_notify and (getattr(args, "email_to", []) or []):
- subject = getattr(args, "email_subject", None) or "enroll diff report"
- smtp_password = None
- pw_env = getattr(args, "smtp_password_env", None)
- if pw_env:
- smtp_password = os.environ.get(str(pw_env))
- send_email(
- to_addrs=list(getattr(args, "email_to", []) or []),
- subject=str(subject),
- body=rendered,
- from_addr=getattr(args, "email_from", None),
- smtp=getattr(args, "smtp", None),
- smtp_user=getattr(args, "smtp_user", None),
- smtp_password=smtp_password,
- )
-
- if getattr(args, "exit_code", False) and has_changes:
- raise SystemExit(2)
+ except RemoteSudoPasswordRequired:
+ raise SystemExit(
+ "error: remote sudo requires a password. Re-run with --ask-become-pass."
+ ) from None
+ except RemoteSSHKeyPassphraseRequired as e:
+ msg = str(e).strip() or (
+ "SSH private key passphrase is required. "
+ "Re-run with --ask-key-passphrase or --ssh-key-passphrase-env VAR."
+ )
+ raise SystemExit(f"error: {msg}") from None
+ except RuntimeError as e:
+ raise SystemExit(f"error: {e}") from None
except SopsError as e:
- raise SystemExit(f"error: {e}")
+ raise SystemExit(f"error: {e}") from None
diff --git a/enroll/diff.py b/enroll/diff.py
index 5ad0eac..8d54bb1 100644
--- a/enroll/diff.py
+++ b/enroll/diff.py
@@ -3,10 +3,15 @@ from __future__ import annotations
import hashlib
import json
import os
+import re
import shutil
import subprocess # nosec
import tarfile
import tempfile
+import sys
+import threading
+import time
+import itertools
import urllib.request
from contextlib import ExitStack
from dataclasses import dataclass
@@ -16,9 +21,73 @@ from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Tuple
from .remote import _safe_extract_tar
+from .pathfilter import PathFilter
from .sopsutil import decrypt_file_binary_to, require_sops_cmd
+def _progress_enabled() -> bool:
+ """Return True if we should display interactive progress UI on the CLI.
+
+ We only emit progress when stderr is a TTY, so it won't pollute JSON/text reports
+ captured by systemd, CI, webhooks, etc. Users can also disable this explicitly via
+ ENROLL_NO_PROGRESS=1.
+ """
+ if os.environ.get("ENROLL_NO_PROGRESS", "").strip() in {"1", "true", "yes"}:
+ return False
+ try:
+ return sys.stderr.isatty()
+ except Exception:
+ return False
+
+
+class _Spinner:
+ """A tiny terminal spinner with an elapsed-time counter (stderr-only)."""
+
+ def __init__(self, message: str, *, interval: float = 0.12) -> None:
+ self.message = message.rstrip()
+ self.interval = interval
+ self._stop = threading.Event()
+ self._thread: Optional[threading.Thread] = None
+ self._last_len = 0
+ self._start = 0.0
+
+ def start(self) -> None:
+ if self._thread is not None:
+ return
+ self._start = time.monotonic()
+ self._thread = threading.Thread(
+ target=self._run, name="enroll-spinner", daemon=True
+ )
+ self._thread.start()
+
+ def stop(self, final_line: Optional[str] = None) -> None:
+ self._stop.set()
+ if self._thread is not None:
+ self._thread.join(timeout=1.0)
+
+ # Clear spinner line.
+ try:
+ sys.stderr.write("\r" + (" " * max(self._last_len, 0)) + "\r")
+ if final_line:
+ sys.stderr.write(final_line.rstrip() + "\n")
+ sys.stderr.flush()
+ except Exception:
+ pass # nosec
+
+ def _run(self) -> None:
+ frames = itertools.cycle("|/-\\")
+ while not self._stop.is_set():
+ elapsed = time.monotonic() - self._start
+ line = f"{self.message} {next(frames)} {elapsed:0.1f}s"
+ try:
+ sys.stderr.write("\r" + line)
+ sys.stderr.flush()
+ self._last_len = max(self._last_len, len(line))
+ except Exception:
+ return
+ self._stop.wait(self.interval)
+
+
def _utc_now_iso() -> str:
return datetime.now(tz=timezone.utc).isoformat()
@@ -289,6 +358,8 @@ def compare_harvests(
new_path: str,
*,
sops_mode: bool = False,
+ exclude_paths: Optional[List[str]] = None,
+ ignore_package_versions: bool = False,
) -> Tuple[Dict[str, Any], bool]:
"""Compare two harvests.
@@ -315,17 +386,21 @@ def compare_harvests(
pkgs_removed = sorted(old_pkgs - new_pkgs)
pkgs_version_changed: List[Dict[str, Any]] = []
+ pkgs_version_changed_ignored_count = 0
for pkg in sorted(old_pkgs & new_pkgs):
a = old_inv.get(pkg) or {}
b = new_inv.get(pkg) or {}
if _pkg_version_key(a) != _pkg_version_key(b):
- pkgs_version_changed.append(
- {
- "package": pkg,
- "old": _pkg_version_display(a),
- "new": _pkg_version_display(b),
- }
- )
+ if ignore_package_versions:
+ pkgs_version_changed_ignored_count += 1
+ else:
+ pkgs_version_changed.append(
+ {
+ "package": pkg,
+ "old": _pkg_version_display(a),
+ "new": _pkg_version_display(b),
+ }
+ )
old_units = _service_units(old_state)
new_units = _service_units(new_state)
@@ -387,6 +462,17 @@ def compare_harvests(
old_files = _file_index(old_b.dir, old_state)
new_files = _file_index(new_b.dir, new_state)
+
+ # Optional user-supplied path exclusions (same semantics as harvest --exclude-path),
+ # applied only to file drift reporting.
+ diff_filter = PathFilter(include=(), exclude=exclude_paths or ())
+ if exclude_paths:
+ old_files = {
+ p: r for p, r in old_files.items() if not diff_filter.is_excluded(p)
+ }
+ new_files = {
+ p: r for p, r in new_files.items() if not diff_filter.is_excluded(p)
+ }
old_paths_set = set(old_files)
new_paths_set = set(new_files)
@@ -462,6 +548,10 @@ def compare_harvests(
report: Dict[str, Any] = {
"generated_at": _utc_now_iso(),
+ "filters": {
+ "exclude_paths": list(exclude_paths or []),
+ "ignore_package_versions": bool(ignore_package_versions),
+ },
"old": {
"input": old_path,
"bundle_dir": str(old_b.dir),
@@ -478,6 +568,9 @@ def compare_harvests(
"added": pkgs_added,
"removed": pkgs_removed,
"version_changed": pkgs_version_changed,
+ "version_changed_ignored_count": int(
+ pkgs_version_changed_ignored_count
+ ),
},
"services": {
"enabled_added": units_added,
@@ -513,6 +606,302 @@ def compare_harvests(
return report, has_changes
+def has_enforceable_drift(report: Dict[str, Any]) -> bool:
+ """Return True if the diff report contains drift that is safe/meaningful to enforce.
+
+ Enforce mode is intended to restore *state* (files/users/services) and to
+ reinstall packages that were removed.
+
+ It is deliberately conservative about package drift:
+ - Package *version* changes alone are not enforced (no downgrades).
+ - Newly installed packages are not removed.
+
+ This helper lets the CLI decide whether `--enforce` should actually run.
+ """
+
+ pk = report.get("packages", {}) or {}
+ if pk.get("removed"):
+ return True
+
+ sv = report.get("services", {}) or {}
+ # We do not try to disable newly-enabled services; we only restore units
+ # that were enabled in the baseline but are now missing.
+ if sv.get("enabled_removed") or []:
+ return True
+
+ for ch in sv.get("changed", []) or []:
+ changes = ch.get("changes") or {}
+ # Ignore package set drift for enforceability decisions; package
+ # enforcement is handled via reinstalling removed packages, and we
+ # avoid trying to "undo" upgrades/renames.
+ for k in changes.keys():
+ if k != "packages":
+ return True
+
+ us = report.get("users", {}) or {}
+ # We restore baseline users (missing/changed). We do not remove newly-added users.
+ if (us.get("removed") or []) or (us.get("changed") or []):
+ return True
+
+ fl = report.get("files", {}) or {}
+ # We restore baseline files (missing/changed). We do not delete newly-managed files.
+ if (fl.get("removed") or []) or (fl.get("changed") or []):
+ return True
+
+ return False
+
+
+def _role_tag(role: str) -> str:
+ """Return the Ansible tag name for a role (must match manifest generation)."""
+ r = str(role or "").strip()
+ safe = re.sub(r"[^A-Za-z0-9_-]+", "_", r).strip("_")
+ if not safe:
+ safe = "other"
+ return f"role_{safe}"
+
+
+def _enforcement_plan(
+ report: Dict[str, Any],
+ old_state: Dict[str, Any],
+ old_bundle_dir: Path,
+) -> Dict[str, Any]:
+ """Return a best-effort enforcement plan (roles/tags) for this diff report.
+
+ We only plan for drift that the baseline manifest can safely restore:
+ - packages that were removed (reinstall, no downgrades)
+ - baseline users that were removed/changed
+ - baseline files that were removed/changed
+ - baseline systemd units that were disabled/changed
+
+ We do NOT plan to remove newly-added packages/users/files/services.
+ """
+ roles: set[str] = set()
+
+ # --- Packages (only removals)
+ pk = report.get("packages", {}) or {}
+ removed_pkgs = set(pk.get("removed") or [])
+ if removed_pkgs:
+ pkg_to_roles: Dict[str, set[str]] = {}
+
+ for svc in _roles(old_state).get("services") or []:
+ r = str(svc.get("role_name") or "").strip()
+ for p in svc.get("packages", []) or []:
+ if p:
+ pkg_to_roles.setdefault(str(p), set()).add(r)
+
+ for pr in _roles(old_state).get("packages") or []:
+ r = str(pr.get("role_name") or "").strip()
+ p = pr.get("package")
+ if p:
+ pkg_to_roles.setdefault(str(p), set()).add(r)
+
+ for p in removed_pkgs:
+ for r in pkg_to_roles.get(str(p), set()):
+ if r:
+ roles.add(r)
+
+ # --- Users (removed/changed)
+ us = report.get("users", {}) or {}
+ if (us.get("removed") or []) or (us.get("changed") or []):
+ u = _roles(old_state).get("users") or {}
+ u_role = str(u.get("role_name") or "users")
+ if u_role:
+ roles.add(u_role)
+
+ # --- Files (removed/changed)
+ fl = report.get("files", {}) or {}
+ file_paths: List[str] = []
+ for e in fl.get("removed", []) or []:
+ if isinstance(e, dict):
+ p = e.get("path")
+ else:
+ p = e
+ if p:
+ file_paths.append(str(p))
+ for e in fl.get("changed", []) or []:
+ if isinstance(e, dict):
+ p = e.get("path")
+ else:
+ p = e
+ if p:
+ file_paths.append(str(p))
+
+ if file_paths:
+ idx = _file_index(old_bundle_dir, old_state)
+ for p in file_paths:
+ rec = idx.get(p)
+ if rec and rec.role:
+ roles.add(str(rec.role))
+
+ # --- Services (enabled_removed + meaningful changes)
+ sv = report.get("services", {}) or {}
+ units: List[str] = []
+ for u in sv.get("enabled_removed", []) or []:
+ if u:
+ units.append(str(u))
+ for ch in sv.get("changed", []) or []:
+ if not isinstance(ch, dict):
+ continue
+ unit = ch.get("unit")
+ changes = ch.get("changes") or {}
+ if unit and any(k != "packages" for k in changes.keys()):
+ units.append(str(unit))
+
+ if units:
+ old_units = _service_units(old_state)
+ for u in units:
+ snap = old_units.get(u)
+ if snap and snap.get("role_name"):
+ roles.add(str(snap.get("role_name")))
+
+ # Drop empty/unknown roles.
+ roles = {r for r in roles if r and str(r).strip() and str(r).strip() != "unknown"}
+
+ tags = sorted({_role_tag(r) for r in roles})
+ return {
+ "roles": sorted(roles),
+ "tags": tags,
+ }
+
+
+def enforce_old_harvest(
+ old_path: str,
+ *,
+ sops_mode: bool = False,
+ report: Optional[Dict[str, Any]] = None,
+) -> Dict[str, Any]:
+ """Enforce the *old* (baseline) harvest state on the current machine.
+
+ When Ansible is available, this:
+ 1) renders a temporary manifest from the old harvest, and
+ 2) runs ansible-playbook locally to apply it.
+
+ Returns a dict suitable for attaching to the diff report under
+ report['enforcement'].
+ """
+
+ ansible_playbook = shutil.which("ansible-playbook")
+ if not ansible_playbook:
+ raise RuntimeError(
+ "ansible-playbook not found on PATH (cannot enforce; install Ansible)"
+ )
+
+ # Import lazily to avoid heavy import cost and potential CLI cycles.
+ from .manifest import manifest
+
+ started_at = _utc_now_iso()
+
+ with ExitStack() as stack:
+ old_b = _bundle_from_input(old_path, sops_mode=sops_mode)
+ if old_b.tempdir:
+ stack.callback(old_b.tempdir.cleanup)
+
+ old_state = _load_state(old_b.dir)
+
+ plan: Optional[Dict[str, Any]] = None
+ tags: Optional[List[str]] = None
+ roles: List[str] = []
+ if report is not None:
+ plan = _enforcement_plan(report, old_state, old_b.dir)
+ roles = list(plan.get("roles") or [])
+ t = list(plan.get("tags") or [])
+ tags = t if t else None
+
+ with tempfile.TemporaryDirectory(prefix="enroll-enforce-") as td:
+ td_path = Path(td)
+ try:
+ os.chmod(td_path, 0o700)
+ except OSError:
+ pass
+
+ # 1) Generate a manifest in a temp directory.
+ manifest(str(old_b.dir), str(td_path))
+
+ playbook = td_path / "playbook.yml"
+ if not playbook.exists():
+ raise RuntimeError(
+ f"manifest did not produce expected playbook.yml at {playbook}"
+ )
+
+ # 2) Apply it locally.
+ env = dict(os.environ)
+ cfg = td_path / "ansible.cfg"
+ if cfg.exists():
+ env["ANSIBLE_CONFIG"] = str(cfg)
+
+ cmd = [
+ ansible_playbook,
+ "-i",
+ "localhost,",
+ "-c",
+ "local",
+ str(playbook),
+ ]
+ if tags:
+ cmd.extend(["--tags", ",".join(tags)])
+
+ spinner: Optional[_Spinner] = None
+ p: Optional[subprocess.CompletedProcess[str]] = None
+ t0 = time.monotonic()
+ if _progress_enabled():
+ if tags:
+ sys.stderr.write(
+ f"Enforce: running ansible-playbook (tags: {','.join(tags)})\n",
+ )
+ else:
+ sys.stderr.write("Enforce: running ansible-playbook\n")
+ sys.stderr.flush()
+ spinner = _Spinner(" ansible-playbook")
+ spinner.start()
+
+ try:
+ p = subprocess.run(
+ cmd,
+ cwd=str(td_path),
+ env=env,
+ capture_output=True,
+ text=True,
+ check=False,
+ ) # nosec
+ finally:
+ if spinner:
+ elapsed = time.monotonic() - t0
+ rc = p.returncode if p is not None else None
+ spinner.stop(
+ final_line=(
+ f"Enforce: ansible-playbook finished in {elapsed:0.1f}s"
+ + (f" (rc={rc})" if rc is not None else ""),
+ ),
+ )
+
+ finished_at = _utc_now_iso()
+
+ info: Dict[str, Any] = {
+ "status": "applied" if p.returncode == 0 else "failed",
+ "started_at": started_at,
+ "finished_at": finished_at,
+ "ansible_playbook": ansible_playbook,
+ "command": cmd,
+ "returncode": int(p.returncode),
+ }
+
+ # Record tag selection (if we could attribute drift to specific roles).
+ info["roles"] = roles
+ info["tags"] = list(tags or [])
+ if not tags:
+ info["scope"] = "full_playbook"
+
+ if p.returncode != 0:
+ err = (p.stderr or p.stdout or "").strip()
+ raise RuntimeError(
+ "ansible-playbook failed"
+ + (f" (rc={p.returncode})" if p.returncode is not None else "")
+ + (f": {err}" if err else "")
+ )
+
+ return info
+
+
def format_report(report: Dict[str, Any], *, fmt: str = "text") -> str:
fmt = (fmt or "text").lower()
if fmt == "json":
@@ -532,11 +921,60 @@ def _report_text(report: Dict[str, Any]) -> str:
f"new: {new.get('input')} (host={new.get('host')}, state_mtime={new.get('state_mtime')})"
)
+ filt = report.get("filters", {}) or {}
+ ex_paths = filt.get("exclude_paths", []) or []
+ if ex_paths:
+ lines.append(f"file exclude patterns: {', '.join(str(p) for p in ex_paths)}")
+
+ if filt.get("ignore_package_versions"):
+ ignored = int(
+ (report.get("packages", {}) or {}).get("version_changed_ignored_count") or 0
+ )
+ msg = "package version drift: ignored (--ignore-package-versions)"
+ if ignored:
+ msg += f" (ignored {ignored} change{'s' if ignored != 1 else ''})"
+ lines.append(msg)
+
+ enf = report.get("enforcement") or {}
+ if enf:
+ lines.append("\nEnforcement")
+ status = str(enf.get("status") or "").strip().lower()
+ if status == "applied":
+ extra = ""
+ tags = enf.get("tags") or []
+ scope = enf.get("scope")
+ if tags:
+ extra = f" (tags={','.join(str(t) for t in tags)})"
+ elif scope:
+ extra = f" ({scope})"
+ lines.append(
+ f" applied old harvest via ansible-playbook (rc={enf.get('returncode')})"
+ + extra
+ + (
+ f" (finished {enf.get('finished_at')})"
+ if enf.get("finished_at")
+ else ""
+ )
+ )
+ elif status == "failed":
+ lines.append(
+ f" attempted enforcement but ansible-playbook failed (rc={enf.get('returncode')})"
+ )
+ elif status == "skipped":
+ r = enf.get("reason")
+ lines.append(" skipped" + (f": {r}" if r else ""))
+ else:
+ # Best-effort formatting for future fields.
+ lines.append(" " + json.dumps(enf, sort_keys=True))
+
pk = report.get("packages", {})
lines.append("\nPackages")
lines.append(f" added: {len(pk.get('added', []) or [])}")
lines.append(f" removed: {len(pk.get('removed', []) or [])}")
- lines.append(f" version_changed: {len(pk.get('version_changed', []) or [])}")
+ ignored_v = int(pk.get("version_changed_ignored_count") or 0)
+ vc = len(pk.get("version_changed", []) or [])
+ suffix = f" (ignored {ignored_v})" if ignored_v else ""
+ lines.append(f" version_changed: {vc}{suffix}")
for p in pk.get("added", []) or []:
lines.append(f" + {p}")
for p in pk.get("removed", []) or []:
@@ -638,6 +1076,67 @@ def _report_markdown(report: Dict[str, Any]) -> str:
f"- **New**: `{new.get('input')}` (host={new.get('host')}, state_mtime={new.get('state_mtime')})\n"
)
+ filt = report.get("filters", {}) or {}
+ ex_paths = filt.get("exclude_paths", []) or []
+ if ex_paths:
+ out.append(
+ "- **File exclude patterns**: "
+ + ", ".join(f"`{p}`" for p in ex_paths)
+ + "\n"
+ )
+
+ if filt.get("ignore_package_versions"):
+ ignored = int(
+ (report.get("packages", {}) or {}).get("version_changed_ignored_count") or 0
+ )
+ msg = "- **Package version drift**: ignored (`--ignore-package-versions`)"
+ if ignored:
+ msg += f" (ignored {ignored} change{'s' if ignored != 1 else ''})"
+ out.append(msg + "\n")
+
+ enf = report.get("enforcement") or {}
+ if enf:
+ out.append("\n## Enforcement\n")
+ status = str(enf.get("status") or "").strip().lower()
+ if status == "applied":
+ extra = ""
+ tags = enf.get("tags") or []
+ scope = enf.get("scope")
+ if tags:
+ extra = " (tags=" + ",".join(str(t) for t in tags) + ")"
+ elif scope:
+ extra = f" ({scope})"
+ out.append(
+ "- ✅ Applied old harvest via ansible-playbook"
+ + extra
+ + (
+ f" (rc={enf.get('returncode')})"
+ if enf.get("returncode") is not None
+ else ""
+ )
+ + (
+ f" (finished `{enf.get('finished_at')}`)"
+ if enf.get("finished_at")
+ else ""
+ )
+ + "\n"
+ )
+ elif status == "failed":
+ out.append(
+ "- ⚠️ Attempted enforcement but ansible-playbook failed"
+ + (
+ f" (rc={enf.get('returncode')})"
+ if enf.get("returncode") is not None
+ else ""
+ )
+ + "\n"
+ )
+ elif status == "skipped":
+ r = enf.get("reason")
+ out.append("- Skipped" + (f": {r}" if r else "") + "\n")
+ else:
+ out.append(f"- {json.dumps(enf, sort_keys=True)}\n")
+
pk = report.get("packages", {})
out.append("## Packages\n")
out.append(f"- Added: {len(pk.get('added', []) or [])}\n")
@@ -647,7 +1146,10 @@ def _report_markdown(report: Dict[str, Any]) -> str:
for p in pk.get("removed", []) or []:
out.append(f" - `- {p}`\n")
- out.append(f"- Version changed: {len(pk.get('version_changed', []) or [])}\n")
+ ignored_v = int(pk.get("version_changed_ignored_count") or 0)
+ vc = len(pk.get("version_changed", []) or [])
+ suffix = f" (ignored {ignored_v})" if ignored_v else ""
+ out.append(f"- Version changed: {vc}{suffix}\n")
for ch in pk.get("version_changed", []) or []:
out.append(
f" - `~ {ch.get('package')}`: `{ch.get('old')}` → `{ch.get('new')}`\n"
diff --git a/enroll/explain.py b/enroll/explain.py
new file mode 100644
index 0000000..131f2df
--- /dev/null
+++ b/enroll/explain.py
@@ -0,0 +1,598 @@
+from __future__ import annotations
+
+import json
+from collections import Counter, defaultdict
+from dataclasses import dataclass
+from typing import Any, Dict, Iterable, List, Tuple
+
+from .diff import _bundle_from_input, _load_state # reuse existing bundle handling
+
+
+@dataclass(frozen=True)
+class ReasonInfo:
+ title: str
+ why: str
+
+
+_MANAGED_FILE_REASONS: Dict[str, ReasonInfo] = {
+ # Package manager / repo config
+ "apt_config": ReasonInfo(
+ "APT configuration",
+ "APT configuration affecting package installation and repository behavior.",
+ ),
+ "apt_source": ReasonInfo(
+ "APT repository source",
+ "APT source list entries (e.g. sources.list or sources.list.d).",
+ ),
+ "apt_keyring": ReasonInfo(
+ "APT keyring",
+ "Repository signing key material used by APT.",
+ ),
+ "apt_signed_by_keyring": ReasonInfo(
+ "APT Signed-By keyring",
+ "Keyring referenced via a Signed-By directive in an APT source.",
+ ),
+ "yum_conf": ReasonInfo(
+ "YUM/DNF main config",
+ "Primary YUM configuration (often /etc/yum.conf).",
+ ),
+ "yum_config": ReasonInfo(
+ "YUM/DNF config",
+ "YUM/DNF configuration files (including conf.d).",
+ ),
+ "yum_repo": ReasonInfo(
+ "YUM/DNF repository",
+ "YUM/DNF repository definitions (e.g. yum.repos.d).",
+ ),
+ "dnf_config": ReasonInfo(
+ "DNF configuration",
+ "DNF configuration affecting package installation and repositories.",
+ ),
+ "rpm_gpg_key": ReasonInfo(
+ "RPM GPG key",
+ "Repository signing keys used by RPM/YUM/DNF.",
+ ),
+ # SSH
+ "authorized_keys": ReasonInfo(
+ "SSH authorized keys",
+ "User authorized_keys files (controls who can log in with SSH keys).",
+ ),
+ "ssh_public_key": ReasonInfo(
+ "SSH public key",
+ "SSH host/user public keys relevant to authentication.",
+ ),
+ # System config / security
+ "system_security": ReasonInfo(
+ "Security configuration",
+ "Security-sensitive configuration (SSH, sudoers, PAM, auth, etc.).",
+ ),
+ "system_network": ReasonInfo(
+ "Network configuration",
+ "Network configuration (interfaces, resolv.conf, network managers, etc.).",
+ ),
+ "system_firewall": ReasonInfo(
+ "Firewall configuration",
+ "Firewall rules/configuration (ufw, nftables, iptables, ipset, etc.).",
+ ),
+ "system_sysctl": ReasonInfo(
+ "sysctl configuration",
+ "Kernel sysctl tuning (sysctl.conf / sysctl.d).",
+ ),
+ "system_modprobe": ReasonInfo(
+ "modprobe configuration",
+ "Kernel module configuration (modprobe.d).",
+ ),
+ "system_mounts": ReasonInfo(
+ "Mount configuration",
+ "Mount configuration (e.g. /etc/fstab and related).",
+ ),
+ "system_rc": ReasonInfo(
+ "Startup/rc configuration",
+ "Startup scripts / rc configuration that can affect boot behavior.",
+ ),
+ # systemd + timers
+ "systemd_dropin": ReasonInfo(
+ "systemd drop-in",
+ "systemd override/drop-in files that modify a unit's behavior.",
+ ),
+ "systemd_envfile": ReasonInfo(
+ "systemd EnvironmentFile",
+ "Files referenced by systemd units via EnvironmentFile.",
+ ),
+ "related_timer": ReasonInfo(
+ "Related systemd timer",
+ "A systemd timer captured because it is related to a unit/service.",
+ ),
+ # cron / logrotate
+ "system_cron": ReasonInfo(
+ "System cron",
+ "System cron configuration (crontab, cron.d, etc.).",
+ ),
+ "cron_snippet": ReasonInfo(
+ "Cron snippet",
+ "Cron snippets referenced/used by harvested services or configs.",
+ ),
+ "system_logrotate": ReasonInfo(
+ "System logrotate",
+ "System logrotate configuration.",
+ ),
+ "logrotate_snippet": ReasonInfo(
+ "logrotate snippet",
+ "logrotate snippets/configs referenced in system configuration.",
+ ),
+ # Custom paths / drift signals
+ "modified_conffile": ReasonInfo(
+ "Modified package conffile",
+ "A package-managed conffile differs from the packaged/default version.",
+ ),
+ "modified_packaged_file": ReasonInfo(
+ "Modified packaged file",
+ "A file owned by a package differs from the packaged version.",
+ ),
+ "custom_unowned": ReasonInfo(
+ "Unowned custom file",
+ "A file not owned by any package (often custom/operator-managed).",
+ ),
+ "custom_specific_path": ReasonInfo(
+ "Custom specific path",
+ "A specific path included by a custom rule or snapshot.",
+ ),
+ "usr_local_bin_script": ReasonInfo(
+ "/usr/local/bin script",
+ "Executable scripts under /usr/local/bin (often operator-installed).",
+ ),
+ "usr_local_etc_custom": ReasonInfo(
+ "/usr/local/etc custom",
+ "Custom configuration under /usr/local/etc.",
+ ),
+ # User includes
+ "user_include": ReasonInfo(
+ "User-included path",
+ "Included because you specified it via --include-path / include patterns.",
+ ),
+}
+
+_MANAGED_DIR_REASONS: Dict[str, ReasonInfo] = {
+ "parent_of_managed_file": ReasonInfo(
+ "Parent directory",
+ "Included so permissions/ownership can be recreated for managed files.",
+ ),
+ "user_include_dir": ReasonInfo(
+ "User-included directory",
+ "Included because you specified it via --include-path / include patterns.",
+ ),
+}
+
+_EXCLUDED_REASONS: Dict[str, ReasonInfo] = {
+ "user_excluded": ReasonInfo(
+ "User excluded",
+ "Excluded because you explicitly excluded it (e.g. --exclude-path / patterns).",
+ ),
+ "unreadable": ReasonInfo(
+ "Unreadable",
+ "Enroll could not read this path with the permissions it had.",
+ ),
+ "log_file": ReasonInfo(
+ "Log file",
+ "Excluded because it appears to be a log file (usually noisy/large).",
+ ),
+ "denied_path": ReasonInfo(
+ "Denied path",
+ "Excluded because the path is in a denylist for safety.",
+ ),
+ "too_large": ReasonInfo(
+ "Too large",
+ "Excluded because it exceeded the size limit for harvested files.",
+ ),
+ "not_regular_file": ReasonInfo(
+ "Not a regular file",
+ "Excluded because it was not a regular file (device, socket, etc.).",
+ ),
+ "binary_like": ReasonInfo(
+ "Binary-like",
+ "Excluded because it looked like binary content (not useful for config management).",
+ ),
+ "sensitive_content": ReasonInfo(
+ "Sensitive content",
+ "Excluded because it likely contains secrets (e.g. shadow, private keys).",
+ ),
+}
+
+_OBSERVED_VIA: Dict[str, ReasonInfo] = {
+ "user_installed": ReasonInfo(
+ "User-installed",
+ "Package appears explicitly installed (as opposed to only pulled in as a dependency).",
+ ),
+ "systemd_unit": ReasonInfo(
+ "Referenced by systemd unit",
+ "Package is associated with a systemd unit that was harvested.",
+ ),
+ "package_role": ReasonInfo(
+ "Referenced by package role",
+ "Package was referenced by an enroll packages snapshot/role.",
+ ),
+ "firewall_runtime": ReasonInfo(
+ "Referenced by firewall runtime role",
+ "Package was referenced by captured live ipset/iptables runtime state.",
+ ),
+}
+
+
+def _ri(mapping: Dict[str, ReasonInfo], key: str) -> ReasonInfo:
+ return mapping.get(key) or ReasonInfo(key, f"Captured with reason '{key}'")
+
+
+def _role_common_counts(role_obj: Dict[str, Any]) -> Tuple[int, int, int, int]:
+ """Return (managed_files, managed_dirs, excluded, notes) counts for a RoleCommon object."""
+ mf = len(role_obj.get("managed_files") or [])
+ md = len(role_obj.get("managed_dirs") or [])
+ ex = len(role_obj.get("excluded") or [])
+ nt = len(role_obj.get("notes") or [])
+ return mf, md, ex, nt
+
+
+def _summarize_reasons(
+ items: Iterable[Dict[str, Any]],
+ reason_key: str,
+ *,
+ mapping: Dict[str, ReasonInfo],
+ max_examples: int,
+) -> List[Dict[str, Any]]:
+ by_reason: Dict[str, List[str]] = defaultdict(list)
+ counts: Counter[str] = Counter()
+
+ for it in items:
+ if not isinstance(it, dict):
+ continue
+ r = it.get(reason_key)
+ if not r:
+ continue
+ r = str(r)
+ counts[r] += 1
+ p = it.get("path")
+ if (
+ max_examples > 0
+ and isinstance(p, str)
+ and p
+ and len(by_reason[r]) < max_examples
+ ):
+ by_reason[r].append(p)
+
+ out: List[Dict[str, Any]] = []
+ for reason, count in counts.most_common():
+ info = _ri(mapping, reason)
+ out.append(
+ {
+ "reason": reason,
+ "count": count,
+ "title": info.title,
+ "why": info.why,
+ "examples": by_reason.get(reason, []),
+ }
+ )
+ return out
+
+
+def explain_state(
+ harvest: str,
+ *,
+ sops_mode: bool = False,
+ fmt: str = "text",
+ max_examples: int = 3,
+) -> str:
+ """Explain a harvest bundle's state.json.
+
+ `harvest` may be:
+ - a bundle directory
+ - a path to state.json
+ - a tarball (.tar.gz/.tgz)
+ - a SOPS-encrypted bundle (.sops)
+ """
+ bundle = _bundle_from_input(harvest, sops_mode=sops_mode)
+ state = _load_state(bundle.dir)
+
+ host = state.get("host") or {}
+ enroll = state.get("enroll") or {}
+ roles = state.get("roles") or {}
+ inv = state.get("inventory") or {}
+ inv_pkgs = (inv.get("packages") or {}) if isinstance(inv, dict) else {}
+
+ role_summaries: List[Dict[str, Any]] = []
+
+ # Users
+ users_obj = roles.get("users") or {}
+ user_entries = users_obj.get("users") or []
+ mf, md, ex, _nt = (
+ _role_common_counts(users_obj) if isinstance(users_obj, dict) else (0, 0, 0, 0)
+ )
+ role_summaries.append(
+ {
+ "role": "users",
+ "summary": f"{len(user_entries)} user(s), {mf} file(s), {ex} excluded",
+ "notes": users_obj.get("notes") or [],
+ }
+ )
+
+ # Services
+ services_list = roles.get("services") or []
+ if isinstance(services_list, list):
+ total_mf = sum(
+ len((s.get("managed_files") or []))
+ for s in services_list
+ if isinstance(s, dict)
+ )
+ total_ex = sum(
+ len((s.get("excluded") or [])) for s in services_list if isinstance(s, dict)
+ )
+ role_summaries.append(
+ {
+ "role": "services",
+ "summary": f"{len(services_list)} unit(s), {total_mf} file(s), {total_ex} excluded",
+ "units": [
+ {
+ "unit": s.get("unit"),
+ "active_state": s.get("active_state"),
+ "sub_state": s.get("sub_state"),
+ "unit_file_state": s.get("unit_file_state"),
+ "condition_result": s.get("condition_result"),
+ }
+ for s in services_list
+ if isinstance(s, dict)
+ ],
+ }
+ )
+
+ # Package snapshots
+ pkgs_list = roles.get("packages") or []
+ if isinstance(pkgs_list, list):
+ total_mf = sum(
+ len((p.get("managed_files") or []))
+ for p in pkgs_list
+ if isinstance(p, dict)
+ )
+ total_ex = sum(
+ len((p.get("excluded") or [])) for p in pkgs_list if isinstance(p, dict)
+ )
+ role_summaries.append(
+ {
+ "role": "packages",
+ "summary": f"{len(pkgs_list)} package snapshot(s), {total_mf} file(s), {total_ex} excluded",
+ "packages": [
+ p.get("package") for p in pkgs_list if isinstance(p, dict)
+ ],
+ }
+ )
+
+ # Runtime firewall snapshot
+ firewall_obj = roles.get("firewall_runtime") or {}
+ if isinstance(firewall_obj, dict) and firewall_obj:
+ captures = [
+ key
+ for key in ("ipset_save", "iptables_v4_save", "iptables_v6_save")
+ if firewall_obj.get(key)
+ ]
+ role_summaries.append(
+ {
+ "role": "firewall_runtime",
+ "summary": f"{len(captures)} snapshot(s), {len(firewall_obj.get('ipset_sets') or [])} ipset(s)",
+ "notes": firewall_obj.get("notes") or [],
+ }
+ )
+
+ # Single snapshots
+ for rname in [
+ "apt_config",
+ "dnf_config",
+ "etc_custom",
+ "usr_local_custom",
+ "extra_paths",
+ ]:
+ robj = roles.get(rname) or {}
+ if not isinstance(robj, dict):
+ continue
+ mf, md, ex, _nt = _role_common_counts(robj)
+ extra: Dict[str, Any] = {}
+ if rname == "extra_paths":
+ extra = {
+ "include_patterns": robj.get("include_patterns") or [],
+ "exclude_patterns": robj.get("exclude_patterns") or [],
+ }
+ role_summaries.append(
+ {
+ "role": rname,
+ "summary": f"{mf} file(s), {md} dir(s), {ex} excluded",
+ "notes": robj.get("notes") or [],
+ **extra,
+ }
+ )
+
+ # Flatten managed/excluded across roles
+ all_managed_files: List[Dict[str, Any]] = []
+ all_managed_dirs: List[Dict[str, Any]] = []
+ all_excluded: List[Dict[str, Any]] = []
+
+ def _consume_role(role_obj: Dict[str, Any]) -> None:
+ for f in role_obj.get("managed_files") or []:
+ if isinstance(f, dict):
+ all_managed_files.append(f)
+ for d in role_obj.get("managed_dirs") or []:
+ if isinstance(d, dict):
+ all_managed_dirs.append(d)
+ for e in role_obj.get("excluded") or []:
+ if isinstance(e, dict):
+ all_excluded.append(e)
+
+ if isinstance(users_obj, dict):
+ _consume_role(users_obj)
+ if isinstance(services_list, list):
+ for s in services_list:
+ if isinstance(s, dict):
+ _consume_role(s)
+ if isinstance(pkgs_list, list):
+ for p in pkgs_list:
+ if isinstance(p, dict):
+ _consume_role(p)
+ for rname in [
+ "apt_config",
+ "dnf_config",
+ "etc_custom",
+ "usr_local_custom",
+ "extra_paths",
+ ]:
+ robj = roles.get(rname)
+ if isinstance(robj, dict):
+ _consume_role(robj)
+
+ managed_file_reasons = _summarize_reasons(
+ all_managed_files,
+ "reason",
+ mapping=_MANAGED_FILE_REASONS,
+ max_examples=max_examples,
+ )
+ managed_dir_reasons = _summarize_reasons(
+ all_managed_dirs,
+ "reason",
+ mapping=_MANAGED_DIR_REASONS,
+ max_examples=max_examples,
+ )
+ excluded_reasons = _summarize_reasons(
+ all_excluded,
+ "reason",
+ mapping=_EXCLUDED_REASONS,
+ max_examples=max_examples,
+ )
+
+ # Inventory observed_via breakdown (count packages that contain at least one entry for that kind)
+ observed_kinds: Counter[str] = Counter()
+ observed_refs: Dict[str, Counter[str]] = defaultdict(Counter)
+ for _pkg, entry in inv_pkgs.items():
+ if not isinstance(entry, dict):
+ continue
+ seen_kinds = set()
+ for ov in entry.get("observed_via") or []:
+ if not isinstance(ov, dict):
+ continue
+ kind = ov.get("kind")
+ if not kind:
+ continue
+ kind = str(kind)
+ seen_kinds.add(kind)
+ ref = ov.get("ref")
+ if isinstance(ref, str) and ref:
+ observed_refs[kind][ref] += 1
+ for k in seen_kinds:
+ observed_kinds[k] += 1
+
+ observed_via_summary: List[Dict[str, Any]] = []
+ for kind, cnt in observed_kinds.most_common():
+ info = _ri(_OBSERVED_VIA, kind)
+ top_refs = [
+ r for r, _ in observed_refs.get(kind, Counter()).most_common(max_examples)
+ ]
+ observed_via_summary.append(
+ {
+ "kind": kind,
+ "count": cnt,
+ "title": info.title,
+ "why": info.why,
+ "top_refs": top_refs,
+ }
+ )
+
+ report: Dict[str, Any] = {
+ "bundle_dir": str(bundle.dir),
+ "host": host,
+ "enroll": enroll,
+ "inventory": {
+ "package_count": len(inv_pkgs),
+ "observed_via": observed_via_summary,
+ },
+ "roles": role_summaries,
+ "reasons": {
+ "managed_files": managed_file_reasons,
+ "managed_dirs": managed_dir_reasons,
+ "excluded": excluded_reasons,
+ },
+ }
+
+ if fmt == "json":
+ return json.dumps(report, indent=2, sort_keys=True)
+
+ # Text rendering
+ out: List[str] = []
+ out.append(f"Enroll explained: {harvest}")
+ hn = host.get("hostname") or "(unknown host)"
+ os_family = host.get("os") or "unknown"
+ pkg_backend = host.get("pkg_backend") or "?"
+ ver = enroll.get("version") or "?"
+ out.append(f"Host: {hn} (os: {os_family}, pkg: {pkg_backend})")
+ out.append(f"Enroll: {ver}")
+ out.append("")
+
+ out.append("Inventory")
+ out.append(f"- Packages: {len(inv_pkgs)}")
+ if observed_via_summary:
+ out.append("- Why packages were included (observed_via):")
+ for ov in observed_via_summary:
+ extra = ""
+ if ov.get("top_refs"):
+ extra = f" (e.g. {', '.join(ov['top_refs'])})"
+ out.append(f" - {ov['kind']}: {ov['count']} – {ov['why']}{extra}")
+ out.append("")
+
+ out.append("Roles collected")
+ for rs in role_summaries:
+ out.append(f"- {rs['role']}: {rs['summary']}")
+ if rs["role"] == "extra_paths":
+ inc = rs.get("include_patterns") or []
+ exc = rs.get("exclude_patterns") or []
+ if inc:
+ suffix = "…" if len(inc) > max_examples else ""
+ out.append(
+ f" include_patterns: {', '.join(map(str, inc[:max_examples]))}{suffix}"
+ )
+ if exc:
+ suffix = "…" if len(exc) > max_examples else ""
+ out.append(
+ f" exclude_patterns: {', '.join(map(str, exc[:max_examples]))}{suffix}"
+ )
+ notes = rs.get("notes") or []
+ if notes:
+ for n in notes[:max_examples]:
+ out.append(f" note: {n}")
+ if len(notes) > max_examples:
+ out.append(
+ f" note: (+{len(notes) - max_examples} more. Use --format json to see them all)"
+ )
+ out.append("")
+
+ out.append("Why files were included (managed_files.reason)")
+ if managed_file_reasons:
+ for r in managed_file_reasons[:15]:
+ exs = r.get("examples") or []
+ ex_txt = f" Examples: {', '.join(exs)}" if exs else ""
+ out.append(f"- {r['reason']} ({r['count']}): {r['why']}.{ex_txt}")
+ if len(managed_file_reasons) > 15:
+ out.append(
+ f"- (+{len(managed_file_reasons) - 15} more reasons. Use --format json to see them all)"
+ )
+ else:
+ out.append("- (no managed files)")
+
+ if managed_dir_reasons:
+ out.append("")
+ out.append("Why directories were included (managed_dirs.reason)")
+ for r in managed_dir_reasons:
+ out.append(f"- {r['reason']} ({r['count']}): {r['why']}")
+
+ out.append("")
+ out.append("Why paths were excluded")
+ if excluded_reasons:
+ for r in excluded_reasons:
+ exs = r.get("examples") or []
+ ex_txt = f" Examples: {', '.join(exs)}" if exs else ""
+ out.append(f"- {r['reason']} ({r['count']}): {r['why']}.{ex_txt}")
+ else:
+ out.append("- (no excluded paths)")
+
+ return "\n".join(out) + "\n"
diff --git a/enroll/harvest.py b/enroll/harvest.py
index 7aba7c6..b64862e 100644
--- a/enroll/harvest.py
+++ b/enroll/harvest.py
@@ -5,9 +5,12 @@ import json
import os
import re
import shutil
+import shlex
+import stat
+import subprocess # nosec
import time
from dataclasses import dataclass, asdict, field
-from typing import Dict, List, Optional, Set
+from typing import Dict, List, Optional, Set, Tuple
from .systemd import (
list_enabled_services,
@@ -34,6 +37,19 @@ class ManagedFile:
reason: str
+@dataclass
+class ManagedLink:
+ """A symlink we want to materialise on the target host.
+
+ For configuration enablement patterns (e.g. sites-enabled), the symlink is
+ meaningful state even when the link target is captured elsewhere.
+ """
+
+ path: str
+ target: str
+ reason: str
+
+
@dataclass
class ManagedDir:
path: str
@@ -60,6 +76,7 @@ class ServiceSnapshot:
condition_result: Optional[str]
managed_dirs: List[ManagedDir] = field(default_factory=list)
managed_files: List[ManagedFile] = field(default_factory=list)
+ managed_links: List[ManagedLink] = field(default_factory=list)
excluded: List[ExcludedFile] = field(default_factory=list)
notes: List[str] = field(default_factory=list)
@@ -70,6 +87,7 @@ class PackageSnapshot:
role_name: str
managed_dirs: List[ManagedDir] = field(default_factory=list)
managed_files: List[ManagedFile] = field(default_factory=list)
+ managed_links: List[ManagedLink] = field(default_factory=list)
excluded: List[ExcludedFile] = field(default_factory=list)
notes: List[str] = field(default_factory=list)
@@ -123,12 +141,24 @@ class UsrLocalCustomSnapshot:
@dataclass
class ExtraPathsSnapshot:
role_name: str
- include_patterns: List[str]
- exclude_patterns: List[str]
- managed_dirs: List[ManagedDir]
- managed_files: List[ManagedFile]
- excluded: List[ExcludedFile]
- notes: List[str]
+ include_patterns: List[str] = field(default_factory=list)
+ exclude_patterns: List[str] = field(default_factory=list)
+ managed_dirs: List[ManagedDir] = field(default_factory=list)
+ managed_files: List[ManagedFile] = field(default_factory=list)
+ managed_links: List[ManagedLink] = field(default_factory=list)
+ excluded: List[ExcludedFile] = field(default_factory=list)
+ notes: List[str] = field(default_factory=list)
+
+
+@dataclass
+class FirewallRuntimeSnapshot:
+ role_name: str
+ packages: List[str] = field(default_factory=list)
+ ipset_save: Optional[str] = None
+ ipset_sets: List[str] = field(default_factory=list)
+ iptables_v4_save: Optional[str] = None
+ iptables_v6_save: Optional[str] = None
+ notes: List[str] = field(default_factory=list)
ALLOWED_UNOWNED_EXTS = {
@@ -157,11 +187,60 @@ MAX_FILES_CAP = 4000
MAX_UNOWNED_FILES_PER_ROLE = 500
+def _files_differ(a: str, b: str, *, max_bytes: int = 2_000_000) -> bool:
+ """Return True if file `a` differs from file `b`.
+
+ Best-effort and conservative:
+ - If `b` (baseline) does not exist or is not a regular file, treat as
+ "different" so we err on the side of capturing user state.
+ - If we can't stat/read either file, treat as "different" (capture will
+ later be filtered via IgnorePolicy).
+ - If files are large, avoid reading them fully.
+ """
+
+ try:
+ st_a = os.stat(a, follow_symlinks=True)
+ except OSError:
+ return True
+
+ # Refuse to do content comparisons on non-regular files.
+ if not stat.S_ISREG(st_a.st_mode):
+ return True
+
+ try:
+ st_b = os.stat(b, follow_symlinks=True)
+ except OSError:
+ return True
+
+ if not stat.S_ISREG(st_b.st_mode):
+ return True
+
+ if st_a.st_size != st_b.st_size:
+ return True
+
+ # If it's unexpectedly big, treat as different to avoid expensive reads.
+ if st_a.st_size > max_bytes:
+ return True
+
+ try:
+ with open(a, "rb") as fa, open(b, "rb") as fb:
+ while True:
+ ca = fa.read(1024 * 64)
+ cb = fb.read(1024 * 64)
+ if ca != cb:
+ return True
+ if not ca: # EOF on both
+ return False
+ except OSError:
+ return True
+
+
def _merge_parent_dirs(
existing_dirs: List[ManagedDir],
managed_files: List[ManagedFile],
*,
policy: IgnorePolicy,
+ extra_paths: Optional[List[str]] = None,
) -> List[ManagedDir]:
"""Ensure parent directories for managed_files are present in managed_dirs.
@@ -177,8 +256,18 @@ def _merge_parent_dirs(
d.path: d for d in (existing_dirs or []) if d.path
}
- for mf in managed_files or []:
- p = str(mf.path or "").rstrip("/")
+ def _iter_paths() -> List[str]:
+ paths: List[str] = []
+ for mf in managed_files or []:
+ if mf and mf.path:
+ paths.append(str(mf.path))
+ for p in extra_paths or []:
+ if p:
+ paths.append(str(p))
+ return paths
+
+ for p0 in _iter_paths():
+ p = str(p0 or "").rstrip("/")
if not p:
continue
dpath = os.path.dirname(p)
@@ -365,6 +454,72 @@ def _capture_file(
return True
+def _capture_link(
+ *,
+ role_name: str,
+ abs_path: str,
+ reason: str,
+ policy: IgnorePolicy,
+ path_filter: PathFilter,
+ managed_out: List[ManagedLink],
+ excluded_out: List[ExcludedFile],
+ seen_role: Optional[Set[str]] = None,
+ seen_global: Optional[Set[str]] = None,
+) -> bool:
+ """Try to capture a symlink into the manifest.
+
+ NOTE: Symlinks are *not* copied into artifacts; we record their link target
+ and materialise them via ansible.builtin.file state=link.
+ """
+
+ if seen_global is not None and abs_path in seen_global:
+ return False
+ if seen_role is not None and abs_path in seen_role:
+ return False
+
+ def _mark_seen() -> None:
+ if seen_role is not None:
+ seen_role.add(abs_path)
+ if seen_global is not None:
+ seen_global.add(abs_path)
+
+ if path_filter.is_excluded(abs_path):
+ excluded_out.append(ExcludedFile(path=abs_path, reason="user_excluded"))
+ _mark_seen()
+ return False
+
+ deny_link = getattr(policy, "deny_reason_link", None)
+ if callable(deny_link):
+ deny = deny_link(abs_path)
+ else:
+ # Fallback: apply deny_reason() but treat "not_regular_file" as acceptable
+ # for symlinks.
+ deny = policy.deny_reason(abs_path)
+ if deny in ("not_regular_file", "not_file", "not_regular"):
+ deny = None
+
+ if deny:
+ excluded_out.append(ExcludedFile(path=abs_path, reason=deny))
+ _mark_seen()
+ return False
+
+ if not os.path.islink(abs_path):
+ excluded_out.append(ExcludedFile(path=abs_path, reason="not_symlink"))
+ _mark_seen()
+ return False
+
+ try:
+ target = os.readlink(abs_path)
+ except OSError:
+ excluded_out.append(ExcludedFile(path=abs_path, reason="unreadable"))
+ _mark_seen()
+ return False
+
+ managed_out.append(ManagedLink(path=abs_path, target=target, reason=reason))
+ _mark_seen()
+ return True
+
+
def _is_confish(path: str) -> bool:
base = os.path.basename(path)
_, ext = os.path.splitext(base)
@@ -490,23 +645,12 @@ _SYSTEM_CAPTURE_GLOBS: List[tuple[str, str]] = [
# mounts
("/etc/fstab", "system_mounts"),
("/etc/crypttab", "system_mounts"),
- # logrotate
- ("/etc/logrotate.conf", "system_logrotate"),
- ("/etc/logrotate.d/*", "system_logrotate"),
# sysctl / modules
("/etc/sysctl.conf", "system_sysctl"),
("/etc/sysctl.d/*", "system_sysctl"),
("/etc/modprobe.d/*", "system_modprobe"),
("/etc/modules", "system_modprobe"),
("/etc/modules-load.d/*", "system_modprobe"),
- # cron
- ("/etc/crontab", "system_cron"),
- ("/etc/cron.d/*", "system_cron"),
- ("/etc/anacrontab", "system_cron"),
- ("/etc/anacron/*", "system_cron"),
- ("/var/spool/cron/crontabs/*", "system_cron"),
- ("/var/spool/crontabs/*", "system_cron"),
- ("/var/spool/cron/*", "system_cron"),
# network
("/etc/netplan/*", "system_network"),
("/etc/systemd/network/*", "system_network"),
@@ -522,6 +666,13 @@ _SYSTEM_CAPTURE_GLOBS: List[tuple[str, str]] = [
("/etc/nftables.d/*", "system_firewall"),
("/etc/iptables/rules.v4", "system_firewall"),
("/etc/iptables/rules.v6", "system_firewall"),
+ ("/etc/sysconfig/iptables", "system_firewall"),
+ ("/etc/sysconfig/ip6tables", "system_firewall"),
+ ("/etc/ipset.conf", "system_firewall"),
+ ("/etc/ipset/*", "system_firewall"),
+ ("/etc/ipset.d/*", "system_firewall"),
+ ("/etc/sysconfig/ipset", "system_firewall"),
+ ("/etc/default/ipset", "system_firewall"),
("/etc/ufw/*", "system_firewall"),
("/etc/default/ufw", "system_firewall"),
("/etc/firewalld/*", "system_firewall"),
@@ -533,6 +684,46 @@ _SYSTEM_CAPTURE_GLOBS: List[tuple[str, str]] = [
]
+# Persistent firewall files that are treated as authoritative for their
+# respective runtime state. If any matching file exists, the runtime capture
+# for that family is retained only as static managed-file harvest output and
+# not duplicated through the generated firewall_runtime role.
+_PERSISTENT_IPTABLES_V4_GLOBS = [
+ "/etc/iptables/rules.v4",
+ "/etc/sysconfig/iptables",
+]
+
+_PERSISTENT_IPTABLES_V6_GLOBS = [
+ "/etc/iptables/rules.v6",
+ "/etc/sysconfig/ip6tables",
+]
+
+_PERSISTENT_IPSET_GLOBS = [
+ "/etc/ipset.conf",
+ "/etc/ipset/*",
+ "/etc/ipset.d/*",
+ "/etc/sysconfig/ipset",
+]
+
+
+def _persistent_firewall_files(globs: List[str]) -> List[str]:
+ """Return persistent firewall files matching ``globs``.
+
+ This intentionally uses the same file walking helper as the static system
+ capture path so the runtime fallback decision matches what Enroll can
+ harvest as managed files.
+ """
+ seen: Set[str] = set()
+ out: List[str] = []
+ for spec in globs:
+ for path in _iter_matching_files(spec):
+ if path in seen:
+ continue
+ seen.add(path)
+ out.append(path)
+ return sorted(out)
+
+
def _iter_matching_files(spec: str, *, cap: int = MAX_FILES_CAP) -> List[str]:
"""Expand a glob spec and also walk directories to collect files."""
out: List[str] = []
@@ -723,6 +914,200 @@ def _iter_system_capture_paths() -> List[tuple[str, str]]:
return uniq
+_FIREWALL_CAPTURE_COMMANDS: Dict[str, Tuple[str, ...]] = {
+ "ipset_save": ("ipset", "save"),
+ "iptables_v4_save": ("iptables-save",),
+ "iptables_v6_save": ("ip6tables-save",),
+}
+
+
+def _run_capture_command(
+ command_key: str, *, timeout: int = 10
+) -> tuple[Optional[str], Optional[str]]:
+ """Return (stdout, error_note) for an allowlisted local state command.
+
+ The command key is resolved through ``_FIREWALL_CAPTURE_COMMANDS`` so this
+ helper never executes caller-supplied argv. Commands are run with
+ ``shell=False`` explicitly to avoid shell interpretation.
+ """
+ argv = _FIREWALL_CAPTURE_COMMANDS.get(command_key)
+ if argv is None:
+ return None, f"Unknown capture command: {command_key}"
+
+ exe = argv[0]
+ if shutil.which(exe) is None:
+ return None, f"{exe} not found on PATH."
+
+ try:
+ proc = subprocess.run( # nosec
+ argv,
+ shell=False,
+ check=False,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE,
+ text=True,
+ timeout=timeout,
+ )
+ except Exception as e: # noqa: BLE001
+ return None, f"{' '.join(argv)} failed: {e!r}"
+
+ if proc.returncode != 0:
+ stderr = (proc.stderr or "").strip()
+ if len(stderr) > 300:
+ stderr = stderr[:297] + "..."
+ return (
+ None,
+ f"{' '.join(argv)} exited {proc.returncode}: {stderr or '(no stderr)'}",
+ )
+
+ return proc.stdout or "", None
+
+
+def _write_generated_artifact(
+ bundle_dir: str, role_name: str, src_rel: str, content: str
+) -> None:
+ """Write a generated harvest artifact that did not exist as a file on disk."""
+ dst = os.path.join(bundle_dir, "artifacts", role_name, src_rel)
+ os.makedirs(os.path.dirname(dst), exist_ok=True)
+ with open(dst, "w", encoding="utf-8") as f:
+ f.write(content)
+
+
+def _ipset_save_has_state(text: str) -> bool:
+ for raw in (text or "").splitlines():
+ line = raw.strip()
+ if not line or line.startswith("#"):
+ continue
+ if line.startswith(("create ", "add ")):
+ return True
+ return False
+
+
+def _parse_ipset_set_names(text: str) -> List[str]:
+ names: List[str] = []
+ seen: Set[str] = set()
+ for raw in (text or "").splitlines():
+ line = raw.strip()
+ if not line or line.startswith("#"):
+ continue
+ try:
+ toks = shlex.split(line)
+ except ValueError:
+ toks = line.split()
+ if len(toks) >= 2 and toks[0] == "create" and toks[1] not in seen:
+ seen.add(toks[1])
+ names.append(toks[1])
+ return names
+
+
+def _iptables_save_has_state(text: str) -> bool:
+ """Return True when iptables-save output contains non-default state."""
+ for raw in (text or "").splitlines():
+ line = raw.strip()
+ if not line or line.startswith("#"):
+ continue
+ if line.startswith("*") or line == "COMMIT":
+ continue
+ if line.startswith(":"):
+ parts = line.split()
+ chain_name = parts[0][1:] if parts else ""
+ policy = parts[1] if len(parts) >= 2 else ""
+ # Built-in empty chains usually look like ':INPUT ACCEPT [0:0]'.
+ # A changed policy, or any custom chain, is meaningful state.
+ if policy not in ("ACCEPT", "-"):
+ return True
+ if policy == "-" and chain_name:
+ return True
+ continue
+ if line.startswith(("-A ", "-I ", "-N ", "-P ", "-R ")):
+ return True
+ return False
+
+
+def _collect_firewall_runtime_snapshot(
+ bundle_dir: str,
+ *,
+ persistent_ipset_files: Optional[List[str]] = None,
+ persistent_iptables_v4_files: Optional[List[str]] = None,
+ persistent_iptables_v6_files: Optional[List[str]] = None,
+) -> FirewallRuntimeSnapshot:
+ """Capture live kernel firewall state only when no persistent config exists.
+
+ Enroll also harvests persistent firewall files such as
+ /etc/iptables/rules.v4, /etc/iptables/rules.v6, and /etc/ipset.conf as
+ managed files. The generated runtime restore role is therefore a fallback:
+ it captures each firewall family only when that family has no persistent
+ file to avoid generating two roles that try to manage the same state.
+ """
+ role_name = "firewall_runtime"
+ packages: Set[str] = set()
+ notes: List[str] = []
+ ipset_save_rel: Optional[str] = None
+ ipset_sets: List[str] = []
+ iptables_v4_rel: Optional[str] = None
+ iptables_v6_rel: Optional[str] = None
+
+ persistent_ipset_files = persistent_ipset_files or []
+ persistent_iptables_v4_files = persistent_iptables_v4_files or []
+ persistent_iptables_v6_files = persistent_iptables_v6_files or []
+
+ if persistent_ipset_files:
+ notes.append(
+ "Live ipset runtime capture skipped because persistent ipset "
+ f"configuration was found: {', '.join(persistent_ipset_files)}"
+ )
+ else:
+ ipset_out, ipset_err = _run_capture_command("ipset_save")
+ if ipset_err:
+ notes.append(ipset_err)
+ elif ipset_out is not None and _ipset_save_has_state(ipset_out):
+ ipset_save_rel = "firewall/ipset.save"
+ _write_generated_artifact(bundle_dir, role_name, ipset_save_rel, ipset_out)
+ ipset_sets = _parse_ipset_set_names(ipset_out)
+ packages.add("ipset")
+
+ if persistent_iptables_v4_files:
+ notes.append(
+ "Live IPv4 iptables runtime capture skipped because persistent "
+ f"IPv4 iptables configuration was found: {', '.join(persistent_iptables_v4_files)}"
+ )
+ else:
+ ipt4_out, ipt4_err = _run_capture_command("iptables_v4_save")
+ if ipt4_err:
+ notes.append(ipt4_err)
+ elif ipt4_out is not None and _iptables_save_has_state(ipt4_out):
+ iptables_v4_rel = "firewall/iptables.v4"
+ _write_generated_artifact(bundle_dir, role_name, iptables_v4_rel, ipt4_out)
+ packages.add("iptables")
+
+ if persistent_iptables_v6_files:
+ notes.append(
+ "Live IPv6 iptables runtime capture skipped because persistent "
+ f"IPv6 iptables configuration was found: {', '.join(persistent_iptables_v6_files)}"
+ )
+ else:
+ ipt6_out, ipt6_err = _run_capture_command("iptables_v6_save")
+ if ipt6_err:
+ notes.append(ipt6_err)
+ elif ipt6_out is not None and _iptables_save_has_state(ipt6_out):
+ iptables_v6_rel = "firewall/iptables.v6"
+ _write_generated_artifact(bundle_dir, role_name, iptables_v6_rel, ipt6_out)
+ packages.add("iptables")
+
+ # Package names are intentionally added only when matching live state was
+ # captured. Merely having iptables/ipset installed should not create a role.
+
+ return FirewallRuntimeSnapshot(
+ role_name=role_name,
+ packages=sorted(packages),
+ ipset_save=ipset_save_rel,
+ ipset_sets=ipset_sets,
+ iptables_v4_save=iptables_v4_rel,
+ iptables_v6_save=iptables_v6_rel,
+ notes=notes,
+ )
+
+
def harvest(
bundle_dir: str,
policy: Optional[IgnorePolicy] = None,
@@ -762,6 +1147,158 @@ def harvest(
# This avoids multiple Ansible roles managing the same destination file.
captured_global: Set[str] = set()
+ # -------------------------
+ # Cron / logrotate unification
+ #
+ # If cron/logrotate are installed, capture all related configuration/state into
+ # dedicated package roles ("cron" and "logrotate") so the same destination path
+ # is never managed by unrelated roles.
+ #
+ # This includes user-specific crontabs under /var/spool, which means the cron role
+ # should be applied after users have been created (handled in manifest ordering).
+ # -------------------------
+
+ installed_pkgs = backend.installed_packages() or {}
+ installed_names: Set[str] = set(installed_pkgs.keys())
+
+ persistent_ipset_files = _persistent_firewall_files(_PERSISTENT_IPSET_GLOBS)
+ persistent_iptables_v4_files = _persistent_firewall_files(
+ _PERSISTENT_IPTABLES_V4_GLOBS
+ )
+ persistent_iptables_v6_files = _persistent_firewall_files(
+ _PERSISTENT_IPTABLES_V6_GLOBS
+ )
+
+ if hasattr(os, "geteuid") and os.geteuid() != 0:
+ firewall_runtime_snapshot = FirewallRuntimeSnapshot(
+ role_name="firewall_runtime",
+ notes=[
+ "Live ipset/iptables runtime capture skipped because harvest is not running as root."
+ ],
+ )
+ else:
+ firewall_runtime_snapshot = _collect_firewall_runtime_snapshot(
+ bundle_dir,
+ persistent_ipset_files=persistent_ipset_files,
+ persistent_iptables_v4_files=persistent_iptables_v4_files,
+ persistent_iptables_v6_files=persistent_iptables_v6_files,
+ )
+
+ def _pick_installed(cands: List[str]) -> Optional[str]:
+ for c in cands:
+ if c in installed_names:
+ return c
+ return None
+
+ cron_pkg = _pick_installed(
+ ["cron", "cronie", "cronie-anacron", "vixie-cron", "fcron"]
+ )
+ logrotate_pkg = _pick_installed(["logrotate"])
+
+ cron_role_name = "cron"
+ logrotate_role_name = "logrotate"
+
+ def _is_cron_path(p: str) -> bool:
+ return (
+ p == "/etc/crontab"
+ or p == "/etc/anacrontab"
+ or p in ("/etc/cron.allow", "/etc/cron.deny")
+ or p.startswith("/etc/cron.")
+ or p.startswith("/etc/cron.d/")
+ or p.startswith("/etc/anacron/")
+ or p.startswith("/var/spool/cron/")
+ or p.startswith("/var/spool/crontabs/")
+ or p.startswith("/var/spool/anacron/")
+ )
+
+ def _is_logrotate_path(p: str) -> bool:
+ return p == "/etc/logrotate.conf" or p.startswith("/etc/logrotate.d/")
+
+ cron_snapshot: Optional[PackageSnapshot] = None
+ logrotate_snapshot: Optional[PackageSnapshot] = None
+
+ if cron_pkg:
+ cron_managed: List[ManagedFile] = []
+ cron_excluded: List[ExcludedFile] = []
+ cron_notes: List[str] = []
+ cron_seen: Set[str] = set()
+
+ cron_globs = [
+ "/etc/crontab",
+ "/etc/cron.d/*",
+ "/etc/cron.hourly/*",
+ "/etc/cron.daily/*",
+ "/etc/cron.weekly/*",
+ "/etc/cron.monthly/*",
+ "/etc/cron.allow",
+ "/etc/cron.deny",
+ "/etc/anacrontab",
+ "/etc/anacron/*",
+ # user crontabs / spool state
+ "/var/spool/cron/*",
+ "/var/spool/cron/crontabs/*",
+ "/var/spool/crontabs/*",
+ "/var/spool/anacron/*",
+ ]
+ for spec in cron_globs:
+ for path in _iter_matching_files(spec):
+ if not os.path.isfile(path) or os.path.islink(path):
+ continue
+ _capture_file(
+ bundle_dir=bundle_dir,
+ role_name=cron_role_name,
+ abs_path=path,
+ reason="system_cron",
+ policy=policy,
+ path_filter=path_filter,
+ managed_out=cron_managed,
+ excluded_out=cron_excluded,
+ seen_role=cron_seen,
+ seen_global=captured_global,
+ )
+
+ cron_snapshot = PackageSnapshot(
+ package=cron_pkg,
+ role_name=cron_role_name,
+ managed_files=cron_managed,
+ excluded=cron_excluded,
+ notes=cron_notes,
+ )
+
+ if logrotate_pkg:
+ lr_managed: List[ManagedFile] = []
+ lr_excluded: List[ExcludedFile] = []
+ lr_notes: List[str] = []
+ lr_seen: Set[str] = set()
+
+ lr_globs = [
+ "/etc/logrotate.conf",
+ "/etc/logrotate.d/*",
+ ]
+ for spec in lr_globs:
+ for path in _iter_matching_files(spec):
+ if not os.path.isfile(path) or os.path.islink(path):
+ continue
+ _capture_file(
+ bundle_dir=bundle_dir,
+ role_name=logrotate_role_name,
+ abs_path=path,
+ reason="system_logrotate",
+ policy=policy,
+ path_filter=path_filter,
+ managed_out=lr_managed,
+ excluded_out=lr_excluded,
+ seen_role=lr_seen,
+ seen_global=captured_global,
+ )
+
+ logrotate_snapshot = PackageSnapshot(
+ package=logrotate_pkg,
+ role_name=logrotate_role_name,
+ managed_files=lr_managed,
+ excluded=lr_excluded,
+ notes=lr_notes,
+ )
# -------------------------
# Service roles
# -------------------------
@@ -777,6 +1314,17 @@ def harvest(
excluded_by_role: Dict[str, List[ExcludedFile]] = {}
enabled_services = list_enabled_services()
+
+ # Avoid role-name collisions with dedicated cron/logrotate package roles.
+ if cron_snapshot is not None or logrotate_snapshot is not None:
+ blocked_roles = set()
+ if cron_snapshot is not None:
+ blocked_roles.add(cron_role_name)
+ if logrotate_snapshot is not None:
+ blocked_roles.add(logrotate_role_name)
+ enabled_services = [
+ u for u in enabled_services if _role_name_from_unit(u) not in blocked_roles
+ ]
enabled_set = set(enabled_services)
def _service_sort_key(unit: str) -> tuple[int, str, str]:
@@ -886,6 +1434,10 @@ def harvest(
for path, reason in backend.modified_paths(pkg, etc_paths).items():
if not os.path.isfile(path) or os.path.islink(path):
continue
+ if cron_snapshot is not None and _is_cron_path(path):
+ continue
+ if logrotate_snapshot is not None and _is_logrotate_path(path):
+ continue
if backend.is_pkg_config_path(path):
continue
candidates.setdefault(path, reason)
@@ -1074,7 +1626,20 @@ def harvest(
manual_pkgs_skipped: List[str] = []
pkg_snaps: List[PackageSnapshot] = []
+ # Add dedicated cron/logrotate roles (if detected) as package roles.
+ # These roles centralise all cron/logrotate managed files so they aren't scattered
+ # across unrelated roles.
+ if cron_snapshot is not None:
+ pkg_snaps.append(cron_snapshot)
+ if logrotate_snapshot is not None:
+ pkg_snaps.append(logrotate_snapshot)
for pkg in sorted(manual_pkgs):
+ if cron_snapshot is not None and pkg == cron_pkg:
+ manual_pkgs_skipped.append(pkg)
+ continue
+ if logrotate_snapshot is not None and pkg == logrotate_pkg:
+ manual_pkgs_skipped.append(pkg)
+ continue
if pkg in covered_by_services:
manual_pkgs_skipped.append(pkg)
continue
@@ -1091,6 +1656,10 @@ def harvest(
for path, reason in backend.modified_paths(pkg, etc_paths).items():
if not os.path.isfile(path) or os.path.islink(path):
continue
+ if cron_snapshot is not None and _is_cron_path(path):
+ continue
+ if logrotate_snapshot is not None and _is_logrotate_path(path):
+ continue
if backend.is_pkg_config_path(path):
continue
candidates.setdefault(path, reason)
@@ -1147,11 +1716,72 @@ def harvest(
package=pkg,
role_name=role,
managed_files=managed,
+ managed_links=[],
excluded=excluded,
notes=notes,
)
)
+ # -------------------------
+ # Web server enablement symlinks (nginx/apache2)
+ #
+ # Debian-style nginx/apache2 configurations often use *-enabled directories
+ # populated with symlinks pointing back into *-available. The symlinks
+ # represent the enablement state and are important to reproduce.
+ #
+ # We only harvest these when the relevant service/package has already been
+ # detected in this run (i.e. we have a role that will manage nginx/apache2).
+ # -------------------------
+
+ def _find_role_snapshot(role_name: str):
+ for s in service_snaps:
+ if s.role_name == role_name:
+ return s
+ for p in pkg_snaps:
+ if p.role_name == role_name:
+ return p
+ return None
+
+ def _capture_enabled_symlinks(role_name: str, dirs: List[str]) -> None:
+ snap = _find_role_snapshot(role_name)
+ if snap is None:
+ return
+
+ role_seen = seen_by_role.setdefault(role_name, set())
+ for d in dirs:
+ if not os.path.isdir(d):
+ continue
+ for pth in sorted(glob.glob(os.path.join(d, "*"))):
+ if not os.path.islink(pth):
+ continue
+ _capture_link(
+ role_name=role_name,
+ abs_path=pth,
+ reason="enabled_symlink",
+ policy=policy,
+ path_filter=path_filter,
+ managed_out=snap.managed_links,
+ excluded_out=snap.excluded,
+ seen_role=role_seen,
+ seen_global=captured_global,
+ )
+
+ _capture_enabled_symlinks(
+ "nginx",
+ [
+ "/etc/nginx/modules-enabled",
+ "/etc/nginx/sites-enabled",
+ ],
+ )
+ _capture_enabled_symlinks(
+ "apache2",
+ [
+ "/etc/apache2/conf-enabled",
+ "/etc/apache2/mods-enabled",
+ "/etc/apache2/sites-enabled",
+ ],
+ )
+
# -------------------------
# Users role (non-system users)
# -------------------------
@@ -1169,6 +1799,18 @@ def harvest(
users_role_name = "users"
users_role_seen = seen_by_role.setdefault(users_role_name, set())
+ skel_dir = "/etc/skel"
+ # Dotfiles to harvest for non-system users. For the common "skeleton"
+ # files, only capture if the user's copy differs from /etc/skel.
+ skel_dotfiles = [
+ (".bashrc", "user_shell_rc"),
+ (".profile", "user_profile"),
+ (".bash_logout", "user_shell_logout"),
+ ]
+ extra_dotfiles = [
+ (".bash_aliases", "user_shell_aliases"),
+ ]
+
for u in user_records:
users_list.append(
{
@@ -1203,6 +1845,48 @@ def harvest(
seen_global=captured_global,
)
+ # Capture common per-user shell dotfiles when they differ from /etc/skel.
+ # These still go through IgnorePolicy and user path filters.
+ home = (u.home or "").rstrip("/")
+ if home and home.startswith("/"):
+ for rel, reason in skel_dotfiles:
+ upath = os.path.join(home, rel)
+ if not os.path.exists(upath):
+ continue
+ skel_path = os.path.join(skel_dir, rel)
+ if not _files_differ(upath, skel_path, max_bytes=policy.max_file_bytes):
+ continue
+ _capture_file(
+ bundle_dir=bundle_dir,
+ role_name=users_role_name,
+ abs_path=upath,
+ reason=reason,
+ policy=policy,
+ path_filter=path_filter,
+ managed_out=users_managed,
+ excluded_out=users_excluded,
+ seen_role=users_role_seen,
+ seen_global=captured_global,
+ )
+
+ # Capture other common per-user shell files unconditionally if present.
+ for rel, reason in extra_dotfiles:
+ upath = os.path.join(home, rel)
+ if not os.path.exists(upath):
+ continue
+ _capture_file(
+ bundle_dir=bundle_dir,
+ role_name=users_role_name,
+ abs_path=upath,
+ reason=reason,
+ policy=policy,
+ path_filter=path_filter,
+ managed_out=users_managed,
+ excluded_out=users_excluded,
+ seen_role=users_role_seen,
+ seen_global=captured_global,
+ )
+
users_snapshot = UsersSnapshot(
role_name=users_role_name,
users=users_list,
@@ -1693,7 +2377,7 @@ def harvest(
# -------------------------
# Inventory: packages (SBOM-ish)
# -------------------------
- installed = backend.installed_packages() or {}
+ installed = installed_pkgs
manual_set: Set[str] = set(manual_pkgs or [])
@@ -1714,6 +2398,7 @@ def harvest(
pkg_names |= manual_set
pkg_names |= set(pkg_units.keys())
pkg_names |= {ps.package for ps in pkg_snaps}
+ pkg_names |= set(firewall_runtime_snapshot.packages or [])
packages_inventory: Dict[str, Dict[str, object]] = {}
for pkg in sorted(pkg_names):
@@ -1729,6 +2414,13 @@ def harvest(
observed.append({"kind": "systemd_unit", "ref": unit})
for rn in sorted(set(pkg_role_names.get(pkg, []))):
observed.append({"kind": "package_role", "ref": rn})
+ if pkg in set(firewall_runtime_snapshot.packages or []):
+ observed.append(
+ {"kind": "firewall_runtime", "ref": firewall_runtime_snapshot.role_name}
+ )
+ pkg_roles_map.setdefault(pkg, set()).add(
+ firewall_runtime_snapshot.role_name
+ )
roles = sorted(pkg_roles_map.get(pkg, set()))
@@ -1748,11 +2440,17 @@ def harvest(
)
for s in service_snaps:
s.managed_dirs = _merge_parent_dirs(
- s.managed_dirs, s.managed_files, policy=policy
+ s.managed_dirs,
+ s.managed_files,
+ policy=policy,
+ extra_paths=[ml.path for ml in (s.managed_links or [])],
)
for p in pkg_snaps:
p.managed_dirs = _merge_parent_dirs(
- p.managed_dirs, p.managed_files, policy=policy
+ p.managed_dirs,
+ p.managed_files,
+ policy=policy,
+ extra_paths=[ml.path for ml in (p.managed_links or [])],
)
if apt_config_snapshot:
@@ -1806,6 +2504,7 @@ def harvest(
"packages": [asdict(p) for p in pkg_snaps],
"apt_config": asdict(apt_config_snapshot),
"dnf_config": asdict(dnf_config_snapshot),
+ "firewall_runtime": asdict(firewall_runtime_snapshot),
"etc_custom": asdict(etc_custom_snapshot),
"usr_local_custom": asdict(usr_local_custom_snapshot),
"extra_paths": asdict(extra_paths_snapshot),
diff --git a/enroll/ignore.py b/enroll/ignore.py
index 895c030..a7bf297 100644
--- a/enroll/ignore.py
+++ b/enroll/ignore.py
@@ -100,6 +100,12 @@ class IgnorePolicy:
# Always ignore plain *.log files (rarely useful as config, often noisy).
if path.endswith(".log"):
return "log_file"
+ # Ignore editor/backup files that end with a trailing tilde.
+ if path.endswith("~"):
+ return "backup_file"
+ # Ignore backup shadow files
+ if path.startswith("/etc/") and path.endswith("-"):
+ return "backup_file"
if not self.dangerous:
for g in self.deny_globs or []:
@@ -167,3 +173,45 @@ class IgnorePolicy:
return "not_directory"
return None
+
+ def deny_reason_link(self, path: str) -> Optional[str]:
+ """Symlink-specific deny logic.
+
+ Symlinks are meaningful configuration state (e.g. Debian-style
+ *-enabled directories). deny_reason() is file-oriented and rejects
+ symlinks as "not_regular_file".
+
+ For symlinks we:
+ - apply the usual deny_globs (unless dangerous)
+ - ensure the path is a symlink and we can readlink() it
+
+ No size checks or content scanning are performed for symlinks.
+ """
+
+ # Keep the same fast-path filename ignores as deny_reason().
+ if path.endswith(".log"):
+ return "log_file"
+ if path.endswith("~"):
+ return "backup_file"
+ if path.startswith("/etc/") and path.endswith("-"):
+ return "backup_file"
+
+ if not self.dangerous:
+ for g in self.deny_globs or []:
+ if fnmatch.fnmatch(path, g):
+ return "denied_path"
+
+ try:
+ os.lstat(path)
+ except OSError:
+ return "unreadable"
+
+ if not os.path.islink(path):
+ return "not_symlink"
+
+ try:
+ os.readlink(path)
+ except OSError:
+ return "unreadable"
+
+ return None
diff --git a/enroll/jinjaturtle.py b/enroll/jinjaturtle.py
index 67f0215..7a2702e 100644
--- a/enroll/jinjaturtle.py
+++ b/enroll/jinjaturtle.py
@@ -8,7 +8,51 @@ from pathlib import Path
from typing import Optional
-SUPPORTED_EXTS = {".ini", ".json", ".toml", ".yaml", ".yml", ".xml"}
+SYSTEMD_SUFFIXES = {
+ ".service",
+ ".socket",
+ ".target",
+ ".timer",
+ ".path",
+ ".mount",
+ ".automount",
+ ".slice",
+ ".swap",
+ ".scope",
+ ".link",
+ ".netdev",
+ ".network",
+}
+
+SUPPORTED_SUFFIXES = {
+ ".ini",
+ ".cfg",
+ ".json",
+ ".toml",
+ ".yaml",
+ ".yml",
+ ".xml",
+ ".repo",
+} | SYSTEMD_SUFFIXES
+
+
+def infer_other_formats(dest_path: str) -> Optional[str]:
+ p = Path(dest_path)
+ name = p.name.lower()
+ suffix = p.suffix.lower()
+ # postfix
+ if name == "main.cf":
+ return "postfix"
+ # systemd units
+ if suffix in SYSTEMD_SUFFIXES:
+ return "systemd"
+ # OpenSSH system config files and snippets
+ parts = {part.lower() for part in p.parts}
+ if name in {"sshd_config", "ssh_config"}:
+ return "ssh"
+ if suffix == ".conf" and {"sshd_config.d", "ssh_config.d"} & parts:
+ return "ssh"
+ return None
@dataclass(frozen=True)
@@ -22,9 +66,15 @@ def find_jinjaturtle_cmd() -> Optional[str]:
return shutil.which("jinjaturtle")
-def can_jinjify_path(path: str) -> bool:
- p = Path(path)
- return p.suffix.lower() in SUPPORTED_EXTS
+def can_jinjify_path(dest_path: str) -> bool:
+ p = Path(dest_path)
+ suffix = p.suffix.lower()
+ if infer_other_formats(dest_path):
+ return True
+ # allow unambiguous structured formats
+ if suffix in SUPPORTED_SUFFIXES:
+ return True
+ return False
def run_jinjaturtle(
diff --git a/enroll/manifest.py b/enroll/manifest.py
index f30e5f3..99adbb7 100644
--- a/enroll/manifest.py
+++ b/enroll/manifest.py
@@ -11,8 +11,9 @@ from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Tuple
from .jinjaturtle import (
- find_jinjaturtle_cmd,
can_jinjify_path,
+ find_jinjaturtle_cmd,
+ infer_other_formats,
run_jinjaturtle,
)
@@ -162,6 +163,19 @@ def _write_role_scaffold(role_dir: str) -> None:
os.makedirs(os.path.join(role_dir, "templates"), exist_ok=True)
+def _role_tag(role: str) -> str:
+ """Return a stable Ansible tag name for a role.
+
+ Used by `enroll diff --enforce` to run only the roles needed to repair drift.
+ """
+ r = str(role or "").strip()
+ # Ansible tag charset is fairly permissive, but keep it portable and consistent.
+ safe = re.sub(r"[^A-Za-z0-9_-]+", "_", r).strip("_")
+ if not safe:
+ safe = "other"
+ return f"role_{safe}"
+
+
def _write_playbook_all(path: str, roles: List[str]) -> None:
pb_lines = [
"---",
@@ -172,7 +186,8 @@ def _write_playbook_all(path: str, roles: List[str]) -> None:
" roles:",
]
for r in roles:
- pb_lines.append(f" - {r}")
+ pb_lines.append(f" - role: {r}")
+ pb_lines.append(f" tags: [{_role_tag(r)}]")
with open(path, "w", encoding="utf-8") as f:
f.write("\n".join(pb_lines) + "\n")
@@ -187,7 +202,8 @@ def _write_playbook_host(path: str, fqdn: str, roles: List[str]) -> None:
" roles:",
]
for r in roles:
- pb_lines.append(f" - {r}")
+ pb_lines.append(f" - role: {r}")
+ pb_lines.append(f" tags: [{_role_tag(r)}]")
with open(path, "w", encoding="utf-8") as f:
f.write("\n".join(pb_lines) + "\n")
@@ -309,7 +325,10 @@ def _jinjify_managed_files(
continue
try:
- res = run_jinjaturtle(jt_exe, artifact_path, role_name=role)
+ force_fmt = infer_other_formats(dest_path)
+ res = run_jinjaturtle(
+ jt_exe, artifact_path, role_name=role, force_format=force_fmt
+ )
except Exception:
# If jinjaturtle cannot process a file for any reason, skip silently.
# (Enroll's core promise is to be optimistic and non-interactive.)
@@ -406,6 +425,20 @@ def _build_managed_files_var(
return out
+def _build_managed_links_var(
+ managed_links: List[Dict[str, Any]],
+) -> List[Dict[str, Any]]:
+ """Convert enroll managed_links into an Ansible-friendly list of dicts."""
+ out: List[Dict[str, Any]] = []
+ for ml in managed_links or []:
+ dest = ml.get("path") or ""
+ src = ml.get("target") or ""
+ if not dest or not src:
+ continue
+ out.append({"dest": dest, "src": src})
+ return out
+
+
def _render_generic_files_tasks(
var_prefix: str, *, include_restart_notify: bool
) -> str:
@@ -495,6 +528,14 @@ def _render_generic_files_tasks(
| selectattr('kind', 'equalto', 'copy')
| list }}}}
notify: "{{{{ item.notify | default([]) }}}}"
+
+- name: Ensure managed symlinks exist
+ ansible.builtin.file:
+ src: "{{{{ item.src }}}}"
+ dest: "{{{{ item.dest }}}}"
+ state: link
+ force: true
+ loop: "{{{{ {var_prefix}_managed_links | default([]) }}}}"
"""
@@ -541,6 +582,97 @@ def _render_install_packages_tasks(role: str, var_prefix: str) -> str:
"""
+def _render_firewall_runtime_tasks(var_prefix: str) -> str:
+ """Render tasks for live ipset/iptables snapshots."""
+ return f"""- name: Ensure firewall runtime snapshot directory exists
+ ansible.builtin.file:
+ path: /etc/enroll/firewall
+ state: directory
+ owner: root
+ group: root
+ mode: "0750"
+
+- name: Deploy captured ipset snapshot
+ vars:
+ _enroll_ff:
+ files:
+ - "{{{{ inventory_dir }}}}/host_vars/{{{{ inventory_hostname }}}}/{{{{ role_name }}}}/.files/{{{{ {var_prefix}_ipset_save }}}}"
+ - "{{{{ role_path }}}}/files/{{{{ {var_prefix}_ipset_save }}}}"
+ ansible.builtin.copy:
+ src: "{{{{ lookup('ansible.builtin.first_found', _enroll_ff) }}}}"
+ dest: /etc/enroll/firewall/ipset.save
+ owner: root
+ group: root
+ mode: "0600"
+ when: ({var_prefix}_ipset_save | default('') | length) > 0
+
+- name: Flush captured ipsets before restoring members
+ ansible.builtin.command:
+ cmd: "ipset flush {{{{ item }}}}"
+ loop: "{{{{ {var_prefix}_ipset_sets | default([]) }}}}"
+ register: _enroll_ipset_flush
+ failed_when: false
+ changed_when: false
+ when:
+ - ({var_prefix}_ipset_save | default('') | length) > 0
+ - {var_prefix}_sync_ipsets_exact | default(true) | bool
+
+- name: Restore captured ipsets
+ ansible.builtin.shell: "ipset restore -exist < /etc/enroll/firewall/ipset.save"
+ args:
+ executable: /bin/sh
+ register: _enroll_ipset_restore
+ changed_when: _enroll_ipset_restore.rc == 0
+ when: ({var_prefix}_ipset_save | default('') | length) > 0
+
+- name: Deploy captured IPv4 iptables snapshot
+ vars:
+ _enroll_ff:
+ files:
+ - "{{{{ inventory_dir }}}}/host_vars/{{{{ inventory_hostname }}}}/{{{{ role_name }}}}/.files/{{{{ {var_prefix}_iptables_v4_save }}}}"
+ - "{{{{ role_path }}}}/files/{{{{ {var_prefix}_iptables_v4_save }}}}"
+ ansible.builtin.copy:
+ src: "{{{{ lookup('ansible.builtin.first_found', _enroll_ff) }}}}"
+ dest: /etc/enroll/firewall/iptables.v4
+ owner: root
+ group: root
+ mode: "0600"
+ when: ({var_prefix}_iptables_v4_save | default('') | length) > 0
+
+- name: Restore captured IPv4 iptables rules
+ ansible.builtin.command:
+ cmd: iptables-restore /etc/enroll/firewall/iptables.v4
+ register: _enroll_iptables_v4_restore
+ changed_when: _enroll_iptables_v4_restore.rc == 0
+ when:
+ - ({var_prefix}_iptables_v4_save | default('') | length) > 0
+ - {var_prefix}_restore_iptables | default(true) | bool
+
+- name: Deploy captured IPv6 iptables snapshot
+ vars:
+ _enroll_ff:
+ files:
+ - "{{{{ inventory_dir }}}}/host_vars/{{{{ inventory_hostname }}}}/{{{{ role_name }}}}/.files/{{{{ {var_prefix}_iptables_v6_save }}}}"
+ - "{{{{ role_path }}}}/files/{{{{ {var_prefix}_iptables_v6_save }}}}"
+ ansible.builtin.copy:
+ src: "{{{{ lookup('ansible.builtin.first_found', _enroll_ff) }}}}"
+ dest: /etc/enroll/firewall/iptables.v6
+ owner: root
+ group: root
+ mode: "0600"
+ when: ({var_prefix}_iptables_v6_save | default('') | length) > 0
+
+- name: Restore captured IPv6 iptables rules
+ ansible.builtin.command:
+ cmd: ip6tables-restore /etc/enroll/firewall/iptables.v6
+ register: _enroll_iptables_v6_restore
+ changed_when: _enroll_iptables_v6_restore.rc == 0
+ when:
+ - ({var_prefix}_iptables_v6_save | default('') | length) > 0
+ - {var_prefix}_restore_iptables | default(true) | bool
+"""
+
+
def _prepare_bundle_dir(
bundle: str,
*,
@@ -705,6 +837,7 @@ def _manifest_from_bundle_dir(
users_snapshot: Dict[str, Any] = roles.get("users", {})
apt_config_snapshot: Dict[str, Any] = roles.get("apt_config", {})
dnf_config_snapshot: Dict[str, Any] = roles.get("dnf_config", {})
+ firewall_runtime_snapshot: Dict[str, Any] = roles.get("firewall_runtime", {})
etc_custom_snapshot: Dict[str, Any] = roles.get("etc_custom", {})
usr_local_custom_snapshot: Dict[str, Any] = roles.get("usr_local_custom", {})
extra_paths_snapshot: Dict[str, Any] = roles.get("extra_paths", {})
@@ -741,6 +874,7 @@ def _manifest_from_bundle_dir(
manifested_users_roles: List[str] = []
manifested_apt_config_roles: List[str] = []
manifested_dnf_config_roles: List[str] = []
+ manifested_firewall_runtime_roles: List[str] = []
manifested_etc_custom_roles: List[str] = []
manifested_usr_local_custom_roles: List[str] = []
manifested_extra_paths_roles: List[str] = []
@@ -819,7 +953,12 @@ def _manifest_from_bundle_dir(
group = str(u.get("primary_group") or owner)
break
- mode = "0600" if mf.get("reason") == "authorized_keys" else "0644"
+ # Prefer the harvested file mode so we preserve any deliberate
+ # permissions (e.g. 0600 for certain dotfiles). For authorized_keys,
+ # enforce 0600 regardless.
+ mode = mf.get("mode") or "0644"
+ if mf.get("reason") == "authorized_keys":
+ mode = "0600"
ssh_files.append(
{
"dest": dest,
@@ -1286,6 +1425,104 @@ DNF/YUM configuration harvested from the system (repos, config files, and RPM GP
manifested_dnf_config_roles.append(role)
+ # -------------------------
+ # firewall_runtime role (live ipset/iptables kernel state)
+ # -------------------------
+ if firewall_runtime_snapshot and (
+ firewall_runtime_snapshot.get("ipset_save")
+ or firewall_runtime_snapshot.get("iptables_v4_save")
+ or firewall_runtime_snapshot.get("iptables_v6_save")
+ ):
+ role = firewall_runtime_snapshot.get("role_name", "firewall_runtime")
+ role_dir = os.path.join(roles_root, role)
+ _write_role_scaffold(role_dir)
+
+ var_prefix = role
+ packages = firewall_runtime_snapshot.get("packages", []) or []
+ ipset_save = firewall_runtime_snapshot.get("ipset_save") or ""
+ ipset_sets = firewall_runtime_snapshot.get("ipset_sets", []) or []
+ iptables_v4_save = firewall_runtime_snapshot.get("iptables_v4_save") or ""
+ iptables_v6_save = firewall_runtime_snapshot.get("iptables_v6_save") or ""
+ notes = firewall_runtime_snapshot.get("notes", []) or []
+
+ # Generated firewall snapshots are host-specific in site mode.
+ if site_mode:
+ _copy_artifacts(
+ bundle_dir,
+ role,
+ _host_role_files_dir(out_dir, fqdn or "", role),
+ )
+ else:
+ _copy_artifacts(bundle_dir, role, os.path.join(role_dir, "files"))
+
+ vars_map: Dict[str, Any] = {
+ f"{var_prefix}_packages": packages,
+ f"{var_prefix}_ipset_save": ipset_save,
+ f"{var_prefix}_ipset_sets": ipset_sets,
+ f"{var_prefix}_iptables_v4_save": iptables_v4_save,
+ f"{var_prefix}_iptables_v6_save": iptables_v6_save,
+ f"{var_prefix}_sync_ipsets_exact": True,
+ f"{var_prefix}_restore_iptables": True,
+ }
+
+ if site_mode:
+ _write_role_defaults(
+ role_dir,
+ {
+ f"{var_prefix}_packages": [],
+ f"{var_prefix}_ipset_save": "",
+ f"{var_prefix}_ipset_sets": [],
+ f"{var_prefix}_iptables_v4_save": "",
+ f"{var_prefix}_iptables_v6_save": "",
+ f"{var_prefix}_sync_ipsets_exact": True,
+ f"{var_prefix}_restore_iptables": True,
+ },
+ )
+ _write_hostvars(out_dir, fqdn or "", role, vars_map)
+ else:
+ _write_role_defaults(role_dir, vars_map)
+
+ tasks = (
+ "---\n"
+ + _render_install_packages_tasks(role, var_prefix)
+ + _render_firewall_runtime_tasks(var_prefix)
+ )
+ with open(
+ os.path.join(role_dir, "tasks", "main.yml"), "w", encoding="utf-8"
+ ) as f:
+ f.write(tasks.rstrip() + "\n")
+
+ with open(
+ os.path.join(role_dir, "meta", "main.yml"), "w", encoding="utf-8"
+ ) as f:
+ f.write("---\ndependencies: []\n")
+
+ readme = f"""# {role}
+
+Generated from live firewall runtime state captured during harvest.
+
+This role restores live ipset and iptables state only for firewall families where Enroll did not find corresponding persistent configuration on the source host. Static firewall configuration files, such as `/etc/iptables/rules.v4`, `/etc/iptables/rules.v6`, UFW, nftables, firewalld, or `/etc/ipset*`, are harvested separately as managed files and treated as authoritative for their respective family.
+
+## Captured snapshots
+- ipset: {ipset_save or "(none)"}
+- iptables IPv4: {iptables_v4_save or "(none)"}
+- iptables IPv6: {iptables_v6_save or "(none)"}
+
+## Captured ipsets
+{os.linesep.join("- " + x for x in ipset_sets) or "- (none)"}
+
+## Notes
+{os.linesep.join("- " + n for n in notes) or "- (none)"}
+
+## Safety notes
+- `firewall_runtime_sync_ipsets_exact` defaults to `true`; it flushes captured set members before replaying the saved members so stale entries are removed. This applies only when no persistent ipset config was found.
+- `firewall_runtime_restore_iptables` defaults to `true`; `iptables-restore`/`ip6tables-restore` replace only captured families. A family is captured only when no corresponding persistent iptables config was found.
+"""
+ with open(os.path.join(role_dir, "README.md"), "w", encoding="utf-8") as f:
+ f.write(readme)
+
+ manifested_firewall_runtime_roles.append(role)
+
# -------------------------
# etc_custom role (unowned /etc not already attributed)
# -------------------------
@@ -1647,6 +1884,7 @@ User-requested extra file harvesting.
pkgs = svc.get("packages", []) or []
managed_files = svc.get("managed_files", []) or []
managed_dirs = svc.get("managed_dirs", []) or []
+ managed_links = svc.get("managed_links", []) or []
role_dir = os.path.join(roles_root, role)
_write_role_scaffold(role_dir)
@@ -1691,6 +1929,8 @@ User-requested extra file harvesting.
notify_systemd="Run systemd daemon-reload",
)
+ links_var = _build_managed_links_var(managed_links)
+
dirs_var = _build_managed_dirs_var(managed_dirs)
jt_map = _yaml_load_mapping(jt_vars) if jt_vars.strip() else {}
@@ -1699,6 +1939,7 @@ User-requested extra file harvesting.
f"{var_prefix}_packages": pkgs,
f"{var_prefix}_managed_files": files_var,
f"{var_prefix}_managed_dirs": dirs_var,
+ f"{var_prefix}_managed_links": links_var,
f"{var_prefix}_manage_unit": True,
f"{var_prefix}_systemd_enabled": bool(enabled_at_harvest),
f"{var_prefix}_systemd_state": desired_state,
@@ -1714,6 +1955,7 @@ User-requested extra file harvesting.
f"{var_prefix}_packages": [],
f"{var_prefix}_managed_files": [],
f"{var_prefix}_managed_dirs": [],
+ f"{var_prefix}_managed_links": [],
f"{var_prefix}_manage_unit": False,
f"{var_prefix}_systemd_enabled": False,
f"{var_prefix}_systemd_state": "stopped",
@@ -1799,6 +2041,9 @@ Generated from `{unit}`.
## Managed files
{os.linesep.join("- " + mf["path"] + " (" + mf["reason"] + ")" for mf in managed_files) or "- (none)"}
+## Managed symlinks
+{os.linesep.join("- " + ml["path"] + " -> " + ml["target"] + " (" + ml.get("reason", "") + ")" for ml in managed_links) or "- (none)"}
+
## Excluded (possible secrets / unsafe)
{os.linesep.join("- " + e["path"] + " (" + e["reason"] + ")" for e in excluded) or "- (none)"}
@@ -1818,6 +2063,7 @@ Generated from `{unit}`.
pkg = pr.get("package") or ""
managed_files = pr.get("managed_files", []) or []
managed_dirs = pr.get("managed_dirs", []) or []
+ managed_links = pr.get("managed_links", []) or []
role_dir = os.path.join(roles_root, role)
_write_role_scaffold(role_dir)
@@ -1859,6 +2105,8 @@ Generated from `{unit}`.
notify_systemd="Run systemd daemon-reload",
)
+ links_var = _build_managed_links_var(managed_links)
+
dirs_var = _build_managed_dirs_var(managed_dirs)
jt_map = _yaml_load_mapping(jt_vars) if jt_vars.strip() else {}
@@ -1866,6 +2114,7 @@ Generated from `{unit}`.
f"{var_prefix}_packages": pkgs,
f"{var_prefix}_managed_files": files_var,
f"{var_prefix}_managed_dirs": dirs_var,
+ f"{var_prefix}_managed_links": links_var,
}
base_vars = _merge_mappings_overwrite(base_vars, jt_map)
@@ -1876,6 +2125,7 @@ Generated from `{unit}`.
f"{var_prefix}_packages": [],
f"{var_prefix}_managed_files": [],
f"{var_prefix}_managed_dirs": [],
+ f"{var_prefix}_managed_links": [],
},
)
_write_hostvars(out_dir, fqdn or "", role, base_vars)
@@ -1918,6 +2168,9 @@ Generated for package `{pkg}`.
## Managed files
{os.linesep.join("- " + mf["path"] + " (" + mf["reason"] + ")" for mf in managed_files) or "- (none)"}
+## Managed symlinks
+{os.linesep.join("- " + ml["path"] + " -> " + ml["target"] + " (" + ml.get("reason", "") + ")" for ml in managed_links) or "- (none)"}
+
## Excluded (possible secrets / unsafe)
{os.linesep.join("- " + e["path"] + " (" + e["reason"] + ")" for e in excluded) or "- (none)"}
@@ -1930,15 +2183,27 @@ Generated for package `{pkg}`.
f.write(readme)
manifested_pkg_roles.append(role)
+ # Place cron/logrotate at the end of the playbook so:
+ # - users exist before we restore per-user crontabs in /var/spool
+ # - most packages/services are installed/configured first
+ tail_roles: List[str] = []
+ for r in ("cron", "logrotate"):
+ if r in manifested_pkg_roles:
+ tail_roles.append(r)
+
+ main_pkg_roles = [r for r in manifested_pkg_roles if r not in set(tail_roles)]
+
all_roles = (
manifested_apt_config_roles
+ manifested_dnf_config_roles
- + manifested_pkg_roles
+ + main_pkg_roles
+ manifested_service_roles
+ manifested_etc_custom_roles
+ manifested_usr_local_custom_roles
+ manifested_extra_paths_roles
+ manifested_users_roles
+ + tail_roles
+ + manifested_firewall_runtime_roles
)
if site_mode:
diff --git a/enroll/remote.py b/enroll/remote.py
index b86cd08..45e2798 100644
--- a/enroll/remote.py
+++ b/enroll/remote.py
@@ -1,14 +1,183 @@
from __future__ import annotations
+import getpass
import os
import shlex
import shutil
+import sys
+import time
import tarfile
import tempfile
import zipapp
from pathlib import Path
from pathlib import PurePosixPath
-from typing import Optional
+from typing import Optional, Callable, TextIO
+
+
+class RemoteSudoPasswordRequired(RuntimeError):
+ """Raised when sudo requires a password but none was provided."""
+
+
+class RemoteSSHKeyPassphraseRequired(RuntimeError):
+ """Raised when SSH private key decryption needs a passphrase."""
+
+
+def _sudo_password_required(out: str, err: str) -> bool:
+ """Return True if sudo output indicates it needs a password/TTY."""
+ blob = (out + "\n" + err).lower()
+ patterns = (
+ "a password is required",
+ "password is required",
+ "a terminal is required to read the password",
+ "no tty present and no askpass program specified",
+ "must have a tty to run sudo",
+ "sudo: sorry, you must have a tty",
+ "askpass",
+ )
+ return any(p in blob for p in patterns)
+
+
+def _sudo_not_permitted(out: str, err: str) -> bool:
+ """Return True if sudo output indicates the user cannot sudo at all."""
+ blob = (out + "\n" + err).lower()
+ patterns = (
+ "is not in the sudoers file",
+ "not allowed to execute",
+ "may not run sudo",
+ "sorry, user",
+ )
+ return any(p in blob for p in patterns)
+
+
+def _sudo_tty_required(out: str, err: str) -> bool:
+ """Return True if sudo output indicates it requires a TTY (sudoers requiretty)."""
+ blob = (out + "\n" + err).lower()
+ patterns = (
+ "must have a tty",
+ "sorry, you must have a tty",
+ "sudo: sorry, you must have a tty",
+ "must have a tty to run sudo",
+ )
+ return any(p in blob for p in patterns)
+
+
+def _resolve_become_password(
+ ask_become_pass: bool,
+ *,
+ prompt: str = "sudo password: ",
+ getpass_fn: Callable[[str], str] = getpass.getpass,
+) -> Optional[str]:
+ if ask_become_pass:
+ return getpass_fn(prompt)
+ return None
+
+
+def _resolve_ssh_key_passphrase(
+ ask_key_passphrase: bool,
+ *,
+ env_var: Optional[str] = None,
+ prompt: str = "SSH key passphrase: ",
+ getpass_fn: Callable[[str], str] = getpass.getpass,
+) -> Optional[str]:
+ """Resolve SSH private-key passphrase from env and/or prompt.
+
+ Precedence:
+ 1) --ssh-key-passphrase-env style input (env_var)
+ 2) --ask-key-passphrase style interactive prompt
+ 3) None
+ """
+ if env_var:
+ val = os.environ.get(str(env_var))
+ if val is None:
+ raise RuntimeError(
+ "SSH key passphrase environment variable is not set: " f"{env_var}"
+ )
+ return val
+
+ if ask_key_passphrase:
+ return getpass_fn(prompt)
+
+ return None
+
+
+def remote_harvest(
+ *,
+ ask_become_pass: bool = False,
+ ask_key_passphrase: bool = False,
+ ssh_key_passphrase_env: Optional[str] = None,
+ no_sudo: bool = False,
+ prompt: str = "sudo password: ",
+ key_prompt: str = "SSH key passphrase: ",
+ getpass_fn: Optional[Callable[[str], str]] = None,
+ stdin: Optional[TextIO] = None,
+ **kwargs,
+):
+ """Call _remote_harvest, with a safe sudo password fallback.
+
+ Behavior:
+ - Run without a password unless --ask-become-pass is set.
+ - If the remote sudo policy requires a password and none was provided,
+ prompt and retry when running interactively.
+ """
+
+ # Resolve defaults at call time (easier to test/monkeypatch, and avoids capturing
+ # sys.stdin / getpass.getpass at import time).
+ if getpass_fn is None:
+ getpass_fn = getpass.getpass
+ if stdin is None:
+ stdin = sys.stdin
+
+ sudo_password = _resolve_become_password(
+ ask_become_pass and not no_sudo,
+ prompt=prompt,
+ getpass_fn=getpass_fn,
+ )
+ ssh_key_passphrase = _resolve_ssh_key_passphrase(
+ ask_key_passphrase,
+ env_var=ssh_key_passphrase_env,
+ prompt=key_prompt,
+ getpass_fn=getpass_fn,
+ )
+
+ while True:
+ try:
+ return _remote_harvest(
+ sudo_password=sudo_password,
+ no_sudo=no_sudo,
+ ssh_key_passphrase=ssh_key_passphrase,
+ **kwargs,
+ )
+ except RemoteSSHKeyPassphraseRequired:
+ # Already tried a passphrase and still failed.
+ if ssh_key_passphrase is not None:
+ raise RemoteSSHKeyPassphraseRequired(
+ "SSH private key could not be decrypted with the supplied "
+ "passphrase."
+ ) from None
+
+ # Fallback prompt if interactive.
+ if stdin is not None and getattr(stdin, "isatty", lambda: False)():
+ ssh_key_passphrase = getpass_fn(key_prompt)
+ continue
+
+ raise RemoteSSHKeyPassphraseRequired(
+ "SSH private key is encrypted and needs a passphrase. "
+ "Re-run with --ask-key-passphrase or "
+ "--ssh-key-passphrase-env VAR."
+ )
+
+ except RemoteSudoPasswordRequired:
+ if sudo_password is not None:
+ raise
+
+ # Fallback prompt if interactive.
+ if stdin is not None and getattr(stdin, "isatty", lambda: False)():
+ sudo_password = getpass_fn(prompt)
+ continue
+
+ raise RemoteSudoPasswordRequired(
+ "Remote sudo requires a password. Re-run with --ask-become-pass."
+ )
def _safe_extract_tar(tar: tarfile.TarFile, dest: Path) -> None:
@@ -79,7 +248,14 @@ def _build_enroll_pyz(tmpdir: Path) -> Path:
return pyz_path
-def _ssh_run(ssh, cmd: str, *, get_pty: bool = False) -> tuple[int, str, str]:
+def _ssh_run(
+ ssh,
+ cmd: str,
+ *,
+ get_pty: bool = False,
+ stdin_text: Optional[str] = None,
+ close_stdin: bool = False,
+) -> tuple[int, str, str]:
"""Run a command over a Paramiko SSHClient.
Paramiko's exec_command runs commands without a TTY by default.
@@ -90,22 +266,144 @@ def _ssh_run(ssh, cmd: str, *, get_pty: bool = False) -> tuple[int, str, str]:
We do not request a PTY for commands that stream binary data
(e.g. tar/gzip output), as a PTY can corrupt the byte stream.
"""
- _stdin, stdout, stderr = ssh.exec_command(cmd, get_pty=get_pty)
- out = stdout.read().decode("utf-8", errors="replace")
- err = stderr.read().decode("utf-8", errors="replace")
- rc = stdout.channel.recv_exit_status()
+ stdin, stdout, stderr = ssh.exec_command(cmd, get_pty=get_pty)
+ # All three file-like objects share the same underlying Channel.
+ chan = stdout.channel
+
+ if stdin_text is not None and stdin is not None:
+ try:
+ stdin.write(stdin_text)
+ stdin.flush()
+ except Exception:
+ # If the remote side closed stdin early, ignore.
+ pass # nosec
+ finally:
+ if close_stdin:
+ # For sudo -S, a wrong password causes sudo to re-prompt and wait
+ # forever for more input. We try hard to deliver EOF so sudo can
+ # fail fast.
+ try:
+ chan.shutdown_write() # sends EOF to the remote process
+ except Exception:
+ pass # nosec
+ try:
+ stdin.close()
+ except Exception:
+ pass # nosec
+
+ # Read incrementally to avoid blocking forever on stdout.read()/stderr.read()
+ # if the remote process is waiting for more input (e.g. sudo password retry).
+ out_chunks: list[bytes] = []
+ err_chunks: list[bytes] = []
+ # Keep a small tail of stderr to detect sudo retry messages without
+ # repeatedly joining potentially large buffers.
+ err_tail = b""
+
+ while True:
+ progressed = False
+ if chan.recv_ready():
+ out_chunks.append(chan.recv(1024 * 64))
+ progressed = True
+ if chan.recv_stderr_ready():
+ chunk = chan.recv_stderr(1024 * 64)
+ err_chunks.append(chunk)
+ err_tail = (err_tail + chunk)[-4096:]
+ progressed = True
+
+ # If we just attempted sudo -S with a single password line and sudo is
+ # asking again, detect it and stop waiting.
+ if close_stdin and stdin_text is not None:
+ blob = err_tail.lower()
+ if b"sorry, try again" in blob or b"incorrect password" in blob:
+ try:
+ chan.close()
+ except Exception:
+ pass # nosec
+ break
+
+ # Exit once the process has exited and we have drained the buffers.
+ if (
+ chan.exit_status_ready()
+ and not chan.recv_ready()
+ and not chan.recv_stderr_ready()
+ ):
+ break
+
+ if not progressed:
+ time.sleep(0.05)
+
+ out = b"".join(out_chunks).decode("utf-8", errors="replace")
+ err = b"".join(err_chunks).decode("utf-8", errors="replace")
+ rc = chan.recv_exit_status() if chan.exit_status_ready() else 1
return rc, out, err
-def remote_harvest(
+def _ssh_run_sudo(
+ ssh,
+ cmd: str,
+ *,
+ sudo_password: Optional[str] = None,
+ get_pty: bool = True,
+) -> tuple[int, str, str]:
+ """Run cmd via sudo with a safe non-interactive-first strategy.
+
+ Strategy:
+ 1) Try `sudo -n`.
+ 2) If sudo reports a password is required and we have one, retry with
+ `sudo -S` and feed it via stdin.
+ 3) If sudo reports a password is required and we *don't* have one, raise
+ RemoteSudoPasswordRequired.
+
+ We avoid requesting a PTY unless the remote sudo policy requires it.
+ This makes sudo -S behavior more reliable (wrong passwords fail fast
+ instead of blocking on a PTY).
+ """
+ cmd_n = f"sudo -n -p '' -- {cmd}"
+
+ # First try: never prompt, and prefer no PTY.
+ rc, out, err = _ssh_run(ssh, cmd_n, get_pty=False)
+ need_pty = False
+
+ # Some sudoers configurations require a TTY even for passwordless sudo.
+ if get_pty and rc != 0 and _sudo_tty_required(out, err):
+ need_pty = True
+ rc, out, err = _ssh_run(ssh, cmd_n, get_pty=True)
+
+ if rc == 0:
+ return rc, out, err
+
+ if _sudo_not_permitted(out, err):
+ return rc, out, err
+
+ if _sudo_password_required(out, err):
+ if sudo_password is None:
+ raise RemoteSudoPasswordRequired(
+ "Remote sudo requires a password, but none was provided."
+ )
+ cmd_s = f"sudo -S -p '' -- {cmd}"
+ return _ssh_run(
+ ssh,
+ cmd_s,
+ get_pty=need_pty,
+ stdin_text=str(sudo_password) + "\n",
+ close_stdin=True,
+ )
+
+ return rc, out, err
+
+
+def _remote_harvest(
*,
local_out_dir: Path,
remote_host: str,
- remote_port: int = 22,
+ remote_port: Optional[int] = None,
remote_user: Optional[str] = None,
+ remote_ssh_config: Optional[str] = None,
remote_python: str = "python3",
dangerous: bool = False,
no_sudo: bool = False,
+ sudo_password: Optional[str] = None,
+ ssh_key_passphrase: Optional[str] = None,
include_paths: Optional[list[str]] = None,
exclude_paths: Optional[list[str]] = None,
) -> Path:
@@ -140,13 +438,120 @@ def remote_harvest(
# Users should add the key to known_hosts.
ssh.set_missing_host_key_policy(paramiko.RejectPolicy())
- ssh.connect(
- hostname=remote_host,
- port=int(remote_port),
- username=remote_user,
- allow_agent=True,
- look_for_keys=True,
- )
+ # Resolve SSH connection parameters.
+ connect_host = remote_host
+ connect_port = int(remote_port) if remote_port is not None else 22
+ connect_user = remote_user
+ key_filename = None
+ sock = None
+ hostkey_name = connect_host
+
+ # Timeouts derived from ssh_config if set (ConnectTimeout).
+ # Used both for socket connect (when we create one) and Paramiko handshake/auth.
+ connect_timeout: Optional[float] = None
+
+ if remote_ssh_config:
+ from paramiko.config import SSHConfig # type: ignore
+ from paramiko.proxy import ProxyCommand # type: ignore
+ import socket as _socket
+
+ cfg_path = Path(str(remote_ssh_config)).expanduser()
+ if not cfg_path.exists():
+ raise RuntimeError(f"SSH config file not found: {cfg_path}")
+
+ cfg = SSHConfig()
+ with cfg_path.open("r", encoding="utf-8") as _fp:
+ cfg.parse(_fp)
+ hcfg = cfg.lookup(remote_host)
+
+ connect_host = str(hcfg.get("hostname") or remote_host)
+ hostkey_name = str(hcfg.get("hostkeyalias") or connect_host)
+
+ if remote_port is None and hcfg.get("port"):
+ try:
+ connect_port = int(str(hcfg.get("port")))
+ except ValueError:
+ pass
+ if connect_user is None and hcfg.get("user"):
+ connect_user = str(hcfg.get("user"))
+
+ ident = hcfg.get("identityfile")
+ if ident:
+ if isinstance(ident, (list, tuple)):
+ key_filename = [str(Path(p).expanduser()) for p in ident]
+ else:
+ key_filename = str(Path(str(ident)).expanduser())
+
+ # Honour OpenSSH ConnectTimeout (seconds) if present.
+ if hcfg.get("connecttimeout"):
+ try:
+ connect_timeout = float(str(hcfg.get("connecttimeout")))
+ except (TypeError, ValueError):
+ connect_timeout = None
+
+ proxycmd = hcfg.get("proxycommand")
+
+ # AddressFamily support: inet (IPv4 only), inet6 (IPv6 only), any (default).
+ addrfam = str(hcfg.get("addressfamily") or "any").strip().lower()
+ family: Optional[int] = None
+ if addrfam == "inet":
+ family = _socket.AF_INET
+ elif addrfam == "inet6":
+ family = _socket.AF_INET6
+
+ if proxycmd:
+ # ProxyCommand provides the transport; AddressFamily doesn't apply here.
+ sock = ProxyCommand(str(proxycmd))
+ elif family is not None:
+ # Enforce the requested address family by pre-connecting the socket and
+ # passing it into Paramiko via sock=.
+ last_err: Optional[OSError] = None
+ infos = _socket.getaddrinfo(
+ connect_host, connect_port, family, _socket.SOCK_STREAM
+ )
+ for af, socktype, proto, _, sa in infos:
+ s = _socket.socket(af, socktype, proto)
+ if connect_timeout is not None:
+ s.settimeout(connect_timeout)
+ try:
+ s.connect(sa)
+ sock = s
+ break
+ except OSError as e:
+ last_err = e
+ try:
+ s.close()
+ except Exception:
+ pass # nosec
+ if sock is None and last_err is not None:
+ raise last_err
+ elif hostkey_name != connect_host:
+ # If HostKeyAlias is used, connect to HostName via a socket but
+ # use HostKeyAlias for known_hosts lookups.
+ sock = _socket.create_connection(
+ (connect_host, connect_port), timeout=connect_timeout
+ )
+
+ # If we created a socket (sock!=None), pass hostkey_name as hostname so
+ # known_hosts lookup uses HostKeyAlias (or whatever hostkey_name resolved to).
+ try:
+ ssh.connect(
+ hostname=hostkey_name if sock is not None else connect_host,
+ port=connect_port,
+ username=connect_user,
+ key_filename=key_filename,
+ sock=sock,
+ allow_agent=True,
+ look_for_keys=True,
+ timeout=connect_timeout,
+ banner_timeout=connect_timeout,
+ auth_timeout=connect_timeout,
+ passphrase=ssh_key_passphrase,
+ )
+ except paramiko.PasswordRequiredException as e: # type: ignore[attr-defined]
+ raise RemoteSSHKeyPassphraseRequired(
+ "SSH private key is encrypted and no passphrase was provided."
+ ) from e
# If no username was explicitly provided, SSH may have selected a default.
# We need a concrete username for the (sudo) chown step below.
@@ -190,10 +595,15 @@ def remote_harvest(
argv.extend(["--exclude-path", str(p)])
_cmd = " ".join(map(shlex.quote, argv))
- cmd = f"sudo {_cmd}" if not no_sudo else _cmd
-
- # PTY for sudo commands (helps sudoers requiretty).
- rc, out, err = _ssh_run(ssh, cmd, get_pty=(not no_sudo))
+ if not no_sudo:
+ # Prefer non-interactive sudo first; retry with -S only when needed.
+ rc, out, err = _ssh_run_sudo(
+ ssh, _cmd, sudo_password=sudo_password, get_pty=True
+ )
+ cmd = f"sudo {_cmd}"
+ else:
+ cmd = _cmd
+ rc, out, err = _ssh_run(ssh, cmd, get_pty=False)
if rc != 0:
raise RuntimeError(
"Remote harvest failed.\n"
@@ -210,12 +620,17 @@ def remote_harvest(
"Unable to determine remote username for chown. "
"Pass --remote-user explicitly or use --no-sudo."
)
- cmd = f"sudo chown -R {resolved_user} {rbundle}"
- rc, out, err = _ssh_run(ssh, cmd, get_pty=True)
+ chown_cmd = f"chown -R {resolved_user} {rbundle}"
+ rc, out, err = _ssh_run_sudo(
+ ssh,
+ chown_cmd,
+ sudo_password=sudo_password,
+ get_pty=True,
+ )
if rc != 0:
raise RuntimeError(
"chown of harvest failed.\n"
- f"Command: {cmd}\n"
+ f"Command: sudo {chown_cmd}\n"
f"Exit code: {rc}\n"
f"Stdout: {out.strip()}\n"
f"Stderr: {err.strip()}"
diff --git a/enroll/schema/__init__.py b/enroll/schema/__init__.py
new file mode 100644
index 0000000..9d19c43
--- /dev/null
+++ b/enroll/schema/__init__.py
@@ -0,0 +1,4 @@
+"""Vendored JSON schemas.
+
+These are used by `enroll validate` so validation can run offline.
+"""
diff --git a/enroll/schema/state.schema.json b/enroll/schema/state.schema.json
new file mode 100644
index 0000000..d0bde52
--- /dev/null
+++ b/enroll/schema/state.schema.json
@@ -0,0 +1,788 @@
+{
+ "$defs": {
+ "AptConfigSnapshot": {
+ "allOf": [
+ {
+ "$ref": "#/$defs/RoleCommon"
+ },
+ {
+ "properties": {
+ "role_name": {
+ "const": "apt_config"
+ }
+ },
+ "type": "object"
+ }
+ ],
+ "unevaluatedProperties": false
+ },
+ "DnfConfigSnapshot": {
+ "allOf": [
+ {
+ "$ref": "#/$defs/RoleCommon"
+ },
+ {
+ "properties": {
+ "role_name": {
+ "const": "dnf_config"
+ }
+ },
+ "type": "object"
+ }
+ ],
+ "unevaluatedProperties": false
+ },
+ "EtcCustomSnapshot": {
+ "allOf": [
+ {
+ "$ref": "#/$defs/RoleCommon"
+ },
+ {
+ "properties": {
+ "role_name": {
+ "const": "etc_custom"
+ }
+ },
+ "type": "object"
+ }
+ ],
+ "unevaluatedProperties": false
+ },
+ "ExcludedFile": {
+ "additionalProperties": false,
+ "properties": {
+ "path": {
+ "minLength": 1,
+ "pattern": "^/.*",
+ "type": "string"
+ },
+ "reason": {
+ "enum": [
+ "user_excluded",
+ "unreadable",
+ "backup_file",
+ "log_file",
+ "denied_path",
+ "too_large",
+ "not_regular_file",
+ "not_symlink",
+ "binary_like",
+ "sensitive_content"
+ ],
+ "type": "string"
+ }
+ },
+ "required": [
+ "path",
+ "reason"
+ ],
+ "type": "object"
+ },
+ "ExtraPathsSnapshot": {
+ "allOf": [
+ {
+ "$ref": "#/$defs/RoleCommon"
+ },
+ {
+ "properties": {
+ "exclude_patterns": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "include_patterns": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "role_name": {
+ "const": "extra_paths"
+ }
+ },
+ "required": [
+ "include_patterns",
+ "exclude_patterns"
+ ],
+ "type": "object"
+ }
+ ],
+ "unevaluatedProperties": false
+ },
+ "InstalledPackageInstance": {
+ "additionalProperties": false,
+ "properties": {
+ "arch": {
+ "minLength": 1,
+ "type": "string"
+ },
+ "version": {
+ "minLength": 1,
+ "type": "string"
+ }
+ },
+ "required": [
+ "version",
+ "arch"
+ ],
+ "type": "object"
+ },
+ "ManagedDir": {
+ "additionalProperties": false,
+ "properties": {
+ "group": {
+ "minLength": 1,
+ "type": "string"
+ },
+ "mode": {
+ "pattern": "^[0-7]{4}$",
+ "type": "string"
+ },
+ "owner": {
+ "minLength": 1,
+ "type": "string"
+ },
+ "path": {
+ "minLength": 1,
+ "pattern": "^/.*",
+ "type": "string"
+ },
+ "reason": {
+ "enum": [
+ "parent_of_managed_file",
+ "user_include_dir"
+ ],
+ "type": "string"
+ }
+ },
+ "required": [
+ "path",
+ "owner",
+ "group",
+ "mode",
+ "reason"
+ ],
+ "type": "object"
+ },
+ "ManagedFile": {
+ "additionalProperties": false,
+ "properties": {
+ "group": {
+ "minLength": 1,
+ "type": "string"
+ },
+ "mode": {
+ "pattern": "^[0-7]{4}$",
+ "type": "string"
+ },
+ "owner": {
+ "minLength": 1,
+ "type": "string"
+ },
+ "path": {
+ "minLength": 1,
+ "pattern": "^/.*",
+ "type": "string"
+ },
+ "reason": {
+ "enum": [
+ "apt_config",
+ "apt_keyring",
+ "apt_signed_by_keyring",
+ "apt_source",
+ "authorized_keys",
+ "cron_snippet",
+ "custom_specific_path",
+ "custom_unowned",
+ "dnf_config",
+ "logrotate_snippet",
+ "modified_conffile",
+ "modified_packaged_file",
+ "related_timer",
+ "rpm_gpg_key",
+ "ssh_public_key",
+ "system_cron",
+ "system_firewall",
+ "system_logrotate",
+ "system_modprobe",
+ "system_mounts",
+ "system_network",
+ "system_rc",
+ "system_security",
+ "system_sysctl",
+ "systemd_dropin",
+ "systemd_envfile",
+ "user_include",
+ "user_profile",
+ "user_shell_aliases",
+ "user_shell_logout",
+ "user_shell_rc",
+ "usr_local_bin_script",
+ "usr_local_etc_custom",
+ "yum_conf",
+ "yum_config",
+ "yum_repo"
+ ],
+ "type": "string"
+ },
+ "src_rel": {
+ "minLength": 1,
+ "pattern": "^[^/].*",
+ "type": "string"
+ }
+ },
+ "required": [
+ "path",
+ "src_rel",
+ "owner",
+ "group",
+ "mode",
+ "reason"
+ ],
+ "type": "object"
+ },
+ "ManagedLink": {
+ "additionalProperties": false,
+ "type": "object",
+ "properties": {
+ "path": {
+ "type": "string",
+ "minLength": 1,
+ "pattern": "^/.*"
+ },
+ "target": {
+ "type": "string",
+ "minLength": 1
+ },
+ "reason": {
+ "type": "string",
+ "enum": [
+ "enabled_symlink"
+ ]
+ }
+ },
+ "required": [
+ "path",
+ "target",
+ "reason"
+ ]
+ },
+ "ObservedVia": {
+ "oneOf": [
+ {
+ "additionalProperties": false,
+ "properties": {
+ "kind": {
+ "const": "user_installed"
+ }
+ },
+ "required": [
+ "kind"
+ ],
+ "type": "object"
+ },
+ {
+ "additionalProperties": false,
+ "properties": {
+ "kind": {
+ "const": "systemd_unit"
+ },
+ "ref": {
+ "minLength": 1,
+ "type": "string"
+ }
+ },
+ "required": [
+ "kind",
+ "ref"
+ ],
+ "type": "object"
+ },
+ {
+ "additionalProperties": false,
+ "properties": {
+ "kind": {
+ "const": "package_role"
+ },
+ "ref": {
+ "minLength": 1,
+ "type": "string"
+ }
+ },
+ "required": [
+ "kind",
+ "ref"
+ ],
+ "type": "object"
+ },
+ {
+ "additionalProperties": false,
+ "properties": {
+ "kind": {
+ "const": "firewall_runtime"
+ },
+ "ref": {
+ "minLength": 1,
+ "type": "string"
+ }
+ },
+ "required": [
+ "kind",
+ "ref"
+ ],
+ "type": "object"
+ }
+ ]
+ },
+ "PackageInventoryEntry": {
+ "additionalProperties": false,
+ "properties": {
+ "arches": {
+ "items": {
+ "minLength": 1,
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "installations": {
+ "items": {
+ "$ref": "#/$defs/InstalledPackageInstance"
+ },
+ "type": "array"
+ },
+ "observed_via": {
+ "items": {
+ "$ref": "#/$defs/ObservedVia"
+ },
+ "type": "array"
+ },
+ "roles": {
+ "items": {
+ "minLength": 1,
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "version": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ },
+ "required": [
+ "version",
+ "arches",
+ "installations",
+ "observed_via",
+ "roles"
+ ],
+ "type": "object"
+ },
+ "PackageSnapshot": {
+ "allOf": [
+ {
+ "$ref": "#/$defs/RoleCommon"
+ },
+ {
+ "properties": {
+ "package": {
+ "minLength": 1,
+ "type": "string"
+ }
+ },
+ "required": [
+ "package"
+ ],
+ "type": "object"
+ }
+ ],
+ "unevaluatedProperties": false
+ },
+ "RoleCommon": {
+ "properties": {
+ "excluded": {
+ "items": {
+ "$ref": "#/$defs/ExcludedFile"
+ },
+ "type": "array"
+ },
+ "managed_dirs": {
+ "items": {
+ "$ref": "#/$defs/ManagedDir"
+ },
+ "type": "array"
+ },
+ "managed_files": {
+ "items": {
+ "$ref": "#/$defs/ManagedFile"
+ },
+ "type": "array"
+ },
+ "managed_links": {
+ "items": {
+ "$ref": "#/$defs/ManagedLink"
+ },
+ "type": "array"
+ },
+ "notes": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "role_name": {
+ "minLength": 1,
+ "pattern": "^[A-Za-z0-9_]+$",
+ "type": "string"
+ }
+ },
+ "required": [
+ "role_name",
+ "managed_dirs",
+ "managed_files",
+ "excluded",
+ "notes"
+ ],
+ "type": "object"
+ },
+ "ServiceSnapshot": {
+ "allOf": [
+ {
+ "$ref": "#/$defs/RoleCommon"
+ },
+ {
+ "properties": {
+ "active_state": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "condition_result": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "packages": {
+ "items": {
+ "minLength": 1,
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "role_name": {
+ "minLength": 1,
+ "pattern": "^[a-z_][a-z0-9_]*$",
+ "type": "string"
+ },
+ "sub_state": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "unit": {
+ "minLength": 1,
+ "type": "string"
+ },
+ "unit_file_state": {
+ "type": [
+ "string",
+ "null"
+ ]
+ }
+ },
+ "required": [
+ "unit",
+ "packages",
+ "active_state",
+ "sub_state",
+ "unit_file_state",
+ "condition_result"
+ ],
+ "type": "object"
+ }
+ ],
+ "unevaluatedProperties": false
+ },
+ "UserEntry": {
+ "additionalProperties": false,
+ "properties": {
+ "gecos": {
+ "type": "string"
+ },
+ "gid": {
+ "minimum": 0,
+ "type": "integer"
+ },
+ "home": {
+ "type": "string"
+ },
+ "name": {
+ "minLength": 1,
+ "type": "string"
+ },
+ "primary_group": {
+ "minLength": 1,
+ "type": "string"
+ },
+ "shell": {
+ "type": "string"
+ },
+ "supplementary_groups": {
+ "items": {
+ "minLength": 1,
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "uid": {
+ "minimum": 0,
+ "type": "integer"
+ }
+ },
+ "required": [
+ "name",
+ "uid",
+ "gid",
+ "gecos",
+ "home",
+ "shell",
+ "primary_group",
+ "supplementary_groups"
+ ],
+ "type": "object"
+ },
+ "UsersSnapshot": {
+ "allOf": [
+ {
+ "$ref": "#/$defs/RoleCommon"
+ },
+ {
+ "properties": {
+ "role_name": {
+ "const": "users"
+ },
+ "users": {
+ "items": {
+ "$ref": "#/$defs/UserEntry"
+ },
+ "type": "array"
+ }
+ },
+ "required": [
+ "users"
+ ],
+ "type": "object"
+ }
+ ],
+ "unevaluatedProperties": false
+ },
+ "UsrLocalCustomSnapshot": {
+ "allOf": [
+ {
+ "$ref": "#/$defs/RoleCommon"
+ },
+ {
+ "properties": {
+ "role_name": {
+ "const": "usr_local_custom"
+ }
+ },
+ "type": "object"
+ }
+ ],
+ "unevaluatedProperties": false
+ },
+ "FirewallRuntimeSnapshot": {
+ "additionalProperties": false,
+ "properties": {
+ "role_name": {
+ "const": "firewall_runtime"
+ },
+ "packages": {
+ "items": {
+ "minLength": 1,
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "ipset_save": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "ipset_sets": {
+ "items": {
+ "minLength": 1,
+ "type": "string"
+ },
+ "type": "array"
+ },
+ "iptables_v4_save": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "iptables_v6_save": {
+ "type": [
+ "string",
+ "null"
+ ]
+ },
+ "notes": {
+ "items": {
+ "type": "string"
+ },
+ "type": "array"
+ }
+ },
+ "required": [
+ "role_name",
+ "packages",
+ "ipset_save",
+ "ipset_sets",
+ "iptables_v4_save",
+ "iptables_v6_save",
+ "notes"
+ ],
+ "type": "object"
+ }
+ },
+ "$id": "https://enroll.sh/schema/state.schema.json",
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "additionalProperties": false,
+ "properties": {
+ "enroll": {
+ "additionalProperties": false,
+ "properties": {
+ "harvest_time": {
+ "minimum": 0,
+ "type": "integer"
+ },
+ "version": {
+ "type": "string"
+ }
+ },
+ "required": [
+ "version",
+ "harvest_time"
+ ],
+ "type": "object"
+ },
+ "host": {
+ "additionalProperties": false,
+ "properties": {
+ "hostname": {
+ "minLength": 1,
+ "type": "string"
+ },
+ "os": {
+ "enum": [
+ "debian",
+ "redhat",
+ "unknown"
+ ],
+ "type": "string"
+ },
+ "os_release": {
+ "additionalProperties": {
+ "type": "string"
+ },
+ "type": "object"
+ },
+ "pkg_backend": {
+ "enum": [
+ "dpkg",
+ "rpm"
+ ],
+ "type": "string"
+ }
+ },
+ "required": [
+ "hostname",
+ "os",
+ "pkg_backend",
+ "os_release"
+ ],
+ "type": "object"
+ },
+ "inventory": {
+ "additionalProperties": false,
+ "properties": {
+ "packages": {
+ "additionalProperties": {
+ "$ref": "#/$defs/PackageInventoryEntry"
+ },
+ "type": "object"
+ }
+ },
+ "required": [
+ "packages"
+ ],
+ "type": "object"
+ },
+ "roles": {
+ "additionalProperties": false,
+ "properties": {
+ "apt_config": {
+ "$ref": "#/$defs/AptConfigSnapshot"
+ },
+ "dnf_config": {
+ "$ref": "#/$defs/DnfConfigSnapshot"
+ },
+ "etc_custom": {
+ "$ref": "#/$defs/EtcCustomSnapshot"
+ },
+ "extra_paths": {
+ "$ref": "#/$defs/ExtraPathsSnapshot"
+ },
+ "packages": {
+ "items": {
+ "$ref": "#/$defs/PackageSnapshot"
+ },
+ "type": "array"
+ },
+ "services": {
+ "items": {
+ "$ref": "#/$defs/ServiceSnapshot"
+ },
+ "type": "array"
+ },
+ "users": {
+ "$ref": "#/$defs/UsersSnapshot"
+ },
+ "usr_local_custom": {
+ "$ref": "#/$defs/UsrLocalCustomSnapshot"
+ },
+ "firewall_runtime": {
+ "$ref": "#/$defs/FirewallRuntimeSnapshot"
+ }
+ },
+ "required": [
+ "users",
+ "services",
+ "packages",
+ "apt_config",
+ "dnf_config",
+ "etc_custom",
+ "usr_local_custom",
+ "extra_paths"
+ ],
+ "type": "object"
+ }
+ },
+ "required": [
+ "enroll",
+ "host",
+ "inventory",
+ "roles"
+ ],
+ "title": "Enroll harvest state.json schema (latest)",
+ "type": "object"
+}
diff --git a/enroll/validate.py b/enroll/validate.py
new file mode 100644
index 0000000..f3291e9
--- /dev/null
+++ b/enroll/validate.py
@@ -0,0 +1,254 @@
+from __future__ import annotations
+
+import json
+import urllib.request
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any, Dict, List, Optional, Set, Tuple
+
+import jsonschema
+
+from .diff import BundleRef, _bundle_from_input
+
+
+@dataclass
+class ValidationResult:
+ errors: List[str]
+ warnings: List[str]
+
+ @property
+ def ok(self) -> bool:
+ return not self.errors
+
+ def to_dict(self) -> Dict[str, Any]:
+ return {
+ "ok": self.ok,
+ "errors": list(self.errors),
+ "warnings": list(self.warnings),
+ }
+
+ def to_text(self) -> str:
+ lines: List[str] = []
+ if not self.errors and not self.warnings:
+ lines.append("OK: harvest bundle validated")
+ elif not self.errors and self.warnings:
+ lines.append(f"WARN: {len(self.warnings)} warning(s)")
+ else:
+ lines.append(f"ERROR: {len(self.errors)} validation error(s)")
+
+ if self.errors:
+ lines.append("")
+ lines.append("Errors:")
+ for e in self.errors:
+ lines.append(f"- {e}")
+ if self.warnings:
+ lines.append("")
+ lines.append("Warnings:")
+ for w in self.warnings:
+ lines.append(f"- {w}")
+ return "\n".join(lines) + "\n"
+
+
+def _default_schema_path() -> Path:
+ # Keep the schema vendored with the codebase so enroll can validate offline.
+ return Path(__file__).resolve().parent / "schema" / "state.schema.json"
+
+
+def _load_schema(schema: Optional[str]) -> Dict[str, Any]:
+ """Load a JSON schema.
+
+ If schema is None, load the vendored schema.
+ If schema begins with http(s)://, fetch it.
+ Otherwise, treat it as a local file path.
+ """
+
+ if not schema:
+ p = _default_schema_path()
+ with open(p, "r", encoding="utf-8") as f:
+ return json.load(f)
+
+ if schema.startswith("http://") or schema.startswith("https://"):
+ with urllib.request.urlopen(schema, timeout=10) as resp: # nosec
+ data = resp.read()
+ return json.loads(data.decode("utf-8"))
+
+ p = Path(schema).expanduser()
+ with open(p, "r", encoding="utf-8") as f:
+ return json.load(f)
+
+
+def _json_pointer(err: jsonschema.ValidationError) -> str:
+ # Build a JSON pointer-ish path that is easy to read.
+ if err.absolute_path:
+ parts = [str(p) for p in err.absolute_path]
+ return "/" + "/".join(parts)
+ return "/"
+
+
+def _iter_managed_files(state: Dict[str, Any]) -> List[Tuple[str, Dict[str, Any]]]:
+ """Return (role_name, managed_file_dict) tuples across all roles."""
+
+ roles = state.get("roles") or {}
+ out: List[Tuple[str, Dict[str, Any]]] = []
+
+ # Singleton roles
+ for rn in [
+ "users",
+ "apt_config",
+ "dnf_config",
+ "etc_custom",
+ "usr_local_custom",
+ "extra_paths",
+ ]:
+ snap = roles.get(rn) or {}
+ for mf in snap.get("managed_files") or []:
+ if isinstance(mf, dict):
+ out.append((rn, mf))
+
+ # Array roles
+ for s in roles.get("services") or []:
+ if not isinstance(s, dict):
+ continue
+ role_name = str(s.get("role_name") or "unknown")
+ for mf in s.get("managed_files") or []:
+ if isinstance(mf, dict):
+ out.append((role_name, mf))
+
+ for p in roles.get("packages") or []:
+ if not isinstance(p, dict):
+ continue
+ role_name = str(p.get("role_name") or "unknown")
+ for mf in p.get("managed_files") or []:
+ if isinstance(mf, dict):
+ out.append((role_name, mf))
+
+ return out
+
+
+def validate_harvest(
+ harvest_input: str,
+ *,
+ sops_mode: bool = False,
+ schema: Optional[str] = None,
+ no_schema: bool = False,
+) -> ValidationResult:
+ """Validate an enroll harvest bundle.
+
+ Checks:
+ - state.json parses
+ - state.json validates against the schema (unless no_schema)
+ - every managed_file src_rel exists in artifacts//
+ """
+
+ errors: List[str] = []
+ warnings: List[str] = []
+
+ bundle: BundleRef = _bundle_from_input(harvest_input, sops_mode=sops_mode)
+ try:
+ state_path = bundle.state_path
+ if not state_path.exists():
+ return ValidationResult(
+ errors=[f"missing state.json at {state_path}"], warnings=[]
+ )
+
+ try:
+ state = json.loads(state_path.read_text(encoding="utf-8"))
+ except Exception as e: # noqa: BLE001
+ return ValidationResult(
+ errors=[f"failed to parse state.json: {e!r}"], warnings=[]
+ )
+
+ if not no_schema:
+ try:
+ sch = _load_schema(schema)
+ validator = jsonschema.Draft202012Validator(sch)
+ for err in sorted(validator.iter_errors(state), key=str):
+ ptr = _json_pointer(err)
+ msg = err.message
+ errors.append(f"schema {ptr}: {msg}")
+ except Exception as e: # noqa: BLE001
+ errors.append(f"failed to load/validate schema: {e!r}")
+
+ # Artifact existence checks
+ artifacts_dir = bundle.dir / "artifacts"
+ referenced: Set[Tuple[str, str]] = set()
+ for role_name, mf in _iter_managed_files(state):
+ src_rel = str(mf.get("src_rel") or "")
+ if not src_rel:
+ errors.append(
+ f"managed_file missing src_rel for role {role_name} (path={mf.get('path')!r})"
+ )
+ continue
+ if src_rel.startswith("/") or ".." in src_rel.split("/"):
+ errors.append(
+ f"managed_file has suspicious src_rel for role {role_name}: {src_rel!r}"
+ )
+ continue
+
+ referenced.add((role_name, src_rel))
+ p = artifacts_dir / role_name / src_rel
+ if not p.exists():
+ errors.append(
+ f"missing artifact for role {role_name}: artifacts/{role_name}/{src_rel}"
+ )
+ continue
+ if not p.is_file():
+ errors.append(
+ f"artifact is not a file for role {role_name}: artifacts/{role_name}/{src_rel}"
+ )
+
+ # Runtime firewall snapshots are generated artifacts rather than managed files.
+ fw = (state.get("roles") or {}).get("firewall_runtime") or {}
+ if isinstance(fw, dict):
+ for key in ("ipset_save", "iptables_v4_save", "iptables_v6_save"):
+ src_rel = str(fw.get(key) or "")
+ if not src_rel:
+ continue
+ if src_rel.startswith("/") or ".." in src_rel.split("/"):
+ errors.append(
+ f"firewall_runtime {key} has suspicious src_rel: {src_rel!r}"
+ )
+ continue
+ referenced.add(
+ (str(fw.get("role_name") or "firewall_runtime"), src_rel)
+ )
+ p = (
+ artifacts_dir
+ / str(fw.get("role_name") or "firewall_runtime")
+ / src_rel
+ )
+ if not p.exists():
+ errors.append(
+ "missing firewall runtime artifact: "
+ f"artifacts/{fw.get('role_name') or 'firewall_runtime'}/{src_rel}"
+ )
+ elif not p.is_file():
+ errors.append(
+ "firewall runtime artifact is not a file: "
+ f"artifacts/{fw.get('role_name') or 'firewall_runtime'}/{src_rel}"
+ )
+
+ # Warn if there are extra files in artifacts not referenced.
+ if artifacts_dir.exists() and artifacts_dir.is_dir():
+ for fp in artifacts_dir.rglob("*"):
+ if not fp.is_file():
+ continue
+ try:
+ rel = fp.relative_to(artifacts_dir)
+ except ValueError:
+ continue
+ parts = rel.parts
+ if len(parts) < 2:
+ continue
+ role_name = parts[0]
+ src_rel = "/".join(parts[1:])
+ if (role_name, src_rel) not in referenced:
+ warnings.append(
+ f"unreferenced artifact present: artifacts/{role_name}/{src_rel}"
+ )
+
+ return ValidationResult(errors=errors, warnings=warnings)
+ finally:
+ # Ensure any temp extraction dirs are cleaned up.
+ if bundle.tempdir is not None:
+ bundle.tempdir.cleanup()
diff --git a/poetry.lock b/poetry.lock
index 0a90711..b338a10 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -1,5 +1,16 @@
# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand.
+[[package]]
+name = "attrs"
+version = "26.1.0"
+description = "Classes Without Boilerplate"
+optional = false
+python-versions = ">=3.9"
+files = [
+ {file = "attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309"},
+ {file = "attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32"},
+]
+
[[package]]
name = "bcrypt"
version = "5.0.0"
@@ -78,13 +89,13 @@ typecheck = ["mypy"]
[[package]]
name = "certifi"
-version = "2025.11.12"
+version = "2026.4.22"
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"},
+ {file = "certifi-2026.4.22-py3-none-any.whl", hash = "sha256:3cb2210c8f88ba2318d29b0388d1023c8492ff72ecdde4ebdaddbb13a31b1c4a"},
+ {file = "certifi-2026.4.22.tar.gz", hash = "sha256:8d455352a37b71bf76a79caa83a3d6c25afee4a385d632127b6afb3963f1c580"},
]
[[package]]
@@ -185,124 +196,140 @@ pycparser = {version = "*", markers = "implementation_name != \"PyPy\""}
[[package]]
name = "charset-normalizer"
-version = "3.4.4"
+version = "3.4.7"
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"},
+ {file = "charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d"},
+ {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8"},
+ {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790"},
+ {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc"},
+ {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393"},
+ {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153"},
+ {file = "charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af"},
+ {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34"},
+ {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1"},
+ {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752"},
+ {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53"},
+ {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616"},
+ {file = "charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a"},
+ {file = "charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374"},
+ {file = "charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943"},
+ {file = "charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008"},
+ {file = "charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7"},
+ {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7"},
+ {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e"},
+ {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c"},
+ {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df"},
+ {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265"},
+ {file = "charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4"},
+ {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e"},
+ {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38"},
+ {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c"},
+ {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b"},
+ {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c"},
+ {file = "charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d"},
+ {file = "charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad"},
+ {file = "charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00"},
+ {file = "charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1"},
+ {file = "charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46"},
+ {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2"},
+ {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b"},
+ {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a"},
+ {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116"},
+ {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb"},
+ {file = "charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1"},
+ {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15"},
+ {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5"},
+ {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d"},
+ {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7"},
+ {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464"},
+ {file = "charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49"},
+ {file = "charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c"},
+ {file = "charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6"},
+ {file = "charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d"},
+ {file = "charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063"},
+ {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c"},
+ {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66"},
+ {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18"},
+ {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd"},
+ {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215"},
+ {file = "charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859"},
+ {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8"},
+ {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5"},
+ {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832"},
+ {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6"},
+ {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48"},
+ {file = "charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a"},
+ {file = "charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e"},
+ {file = "charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110"},
+ {file = "charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c"},
+ {file = "charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d"},
+ {file = "charset_normalizer-3.4.7-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e5f4d355f0a2b1a31bc3edec6795b46324349c9cb25eed068049e4f472fb4259"},
+ {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16d971e29578a5e97d7117866d15889a4a07befe0e87e703ed63cd90cb348c01"},
+ {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:dca4bbc466a95ba9c0234ef56d7dd9509f63da22274589ebd4ed7f1f4d4c54e3"},
+ {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e80c8378d8f3d83cd3164da1ad2df9e37a666cdde7b1cb2298ed0b558064be30"},
+ {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36836d6ff945a00b88ba1e4572d721e60b5b8c98c155d465f56ad19d68f23734"},
+ {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux_2_31_armv7l.whl", hash = "sha256:bd9b23791fe793e4968dba0c447e12f78e425c59fc0e3b97f6450f4781f3ee60"},
+ {file = "charset_normalizer-3.4.7-cp38-cp38-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aef65cd602a6d0e0ff6f9930fcb1c8fec60dd2cfcb6facaf4bdb0e5873042db0"},
+ {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:82b271f5137d07749f7bf32f70b17ab6eaabedd297e75dce75081a24f76eb545"},
+ {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_armv7l.whl", hash = "sha256:1efde3cae86c8c273f1eb3b287be7d8499420cf2fe7585c41d370d3e790054a5"},
+ {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:c593052c465475e64bbfe5dbd81680f64a67fdc752c56d7a0ae205dc8aeefe0f"},
+ {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_riscv64.whl", hash = "sha256:af21eb4409a119e365397b2adbaca4c9ccab56543a65d5dbd9f920d6ac29f686"},
+ {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:84c018e49c3bf790f9c2771c45e9313a08c2c2a6342b162cd650258b57817706"},
+ {file = "charset_normalizer-3.4.7-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:dd915403e231e6b1809fe9b6d9fc55cf8fb5e02765ac625d9cd623342a7905d7"},
+ {file = "charset_normalizer-3.4.7-cp38-cp38-win32.whl", hash = "sha256:320ade88cfb846b8cd6b4ddf5ee9e80ee0c1f52401f2456b84ae1ae6a1a5f207"},
+ {file = "charset_normalizer-3.4.7-cp38-cp38-win_amd64.whl", hash = "sha256:1dc8b0ea451d6e69735094606991f32867807881400f808a106ee1d963c46a83"},
+ {file = "charset_normalizer-3.4.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:177a0ba5f0211d488e295aaf82707237e331c24788d8d76c96c5a41594723217"},
+ {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e0d51f618228538a3e8f46bd246f87a6cd030565e015803691603f55e12afb5"},
+ {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:14265bfe1f09498b9d8ec91e9ec9fa52775edf90fcbde092b25f4a33d444fea9"},
+ {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:87fad7d9ba98c86bcb41b2dc8dbb326619be2562af1f8ff50776a39e55721c5a"},
+ {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f22dec1690b584cea26fade98b2435c132c1b5f68e39f5a0b7627cd7ae31f1dc"},
+ {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_armv7l.whl", hash = "sha256:d61f00a0869d77422d9b2aba989e2d24afa6ffd552af442e0e58de4f35ea6d00"},
+ {file = "charset_normalizer-3.4.7-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6370e8686f662e6a3941ee48ed4742317cafbe5707e36406e9df792cdb535776"},
+ {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:a6c5863edfbe888d9eff9c8b8087354e27618d9da76425c119293f11712a6319"},
+ {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_armv7l.whl", hash = "sha256:ed065083d0898c9d5b4bbec7b026fd755ff7454e6e8b73a67f8c744b13986e24"},
+ {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:2cd4a60d0e2fb04537162c62bbbb4182f53541fe0ede35cdf270a1c1e723cc42"},
+ {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:813c0e0132266c08eb87469a642cb30aaff57c5f426255419572aaeceeaa7bf4"},
+ {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:07d9e39b01743c3717745f4c530a6349eadbfa043c7577eef86c502c15df2c67"},
+ {file = "charset_normalizer-3.4.7-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:c0f081d69a6e58272819b70288d3221a6ee64b98df852631c80f293514d3b274"},
+ {file = "charset_normalizer-3.4.7-cp39-cp39-win32.whl", hash = "sha256:8751d2787c9131302398b11e6c8068053dcb55d5a8964e114b6e196cf16cb366"},
+ {file = "charset_normalizer-3.4.7-cp39-cp39-win_amd64.whl", hash = "sha256:12a6fff75f6bc66711b73a2f0addfc4c8c15a20e805146a02d147a318962c444"},
+ {file = "charset_normalizer-3.4.7-cp39-cp39-win_arm64.whl", hash = "sha256:bb8cc7534f51d9a017b93e3e85b260924f909601c3df002bcdb58ddb4dc41a5c"},
+ {file = "charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d"},
+ {file = "charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5"},
]
[[package]]
@@ -318,103 +345,117 @@ files = [
[[package]]
name = "coverage"
-version = "7.13.0"
+version = "7.14.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"},
+ {file = "coverage-7.14.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:84c32d90bf4537f0e7b4dec9aaa9a938fb8205136b9d2ecf4d7629d5262dc075"},
+ {file = "coverage-7.14.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:7c843572c605ab51cfdb5c6b5f2586e2a8467c0d28eca4bdef4ec70c5fecbd82"},
+ {file = "coverage-7.14.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0c451757d3fa2603354fdc789b5e58a0e327a117c370a40e3476ba4eabab228c"},
+ {file = "coverage-7.14.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:3fd43f0616e765ab78d069cf8358def7363957a45cee446d65c502dcfeea7893"},
+ {file = "coverage-7.14.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:731e535b1498b27d13594a0527a79b0510867b0ad891532be41cb883f2128e20"},
+ {file = "coverage-7.14.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c7492f2d493b976941c7ca050f273cbda2f43c381124f7586a3e3c16d1804fec"},
+ {file = "coverage-7.14.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc38367eaa2abb1b766ac333142bce7655335a73537f5c8b75aaa89c2b987757"},
+ {file = "coverage-7.14.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0a951308cde22cf77f953955a754d04dccb57fe3bb8e345d685778ed9fc1632a"},
+ {file = "coverage-7.14.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fab3877e4ebb06bd9d4d4d00ee53309ee5478e66873c66a382272e3ee33eb7ea"},
+ {file = "coverage-7.14.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:b812eb847b19876ebf33fb6c4f11819af05ab6050b0bfa1bc53412ae81779adb"},
+ {file = "coverage-7.14.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d9c8ef6ed820c433de075657d72dda1f89a2984955e58b8a75feb3f184250218"},
+ {file = "coverage-7.14.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:d128b1bba9361fbaaf6a19e179e6cfd6a9103ce0c0555876f72780acc93efd85"},
+ {file = "coverage-7.14.0-cp310-cp310-win32.whl", hash = "sha256:65f267ca1370726ec2c1aa38bbe4df9a71a740f22878d2d4bf59d71a4cd8d323"},
+ {file = "coverage-7.14.0-cp310-cp310-win_amd64.whl", hash = "sha256:b34ece8065914f938ed7f2c5872bb865336977a52919149846eac3744327267a"},
+ {file = "coverage-7.14.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6a78e2a9d9c5e3b8d4ab9b9d28c985ea66fced0a7d7c2aec1f216e03a2011480"},
+ {file = "coverage-7.14.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a1816c505187592dcd1c5a5f226601a549f70365fbd00930ac88b0c225b76bb4"},
+ {file = "coverage-7.14.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d8e1762f0e9cbc26ec315471e7b47855218e833cd5a032d706fbf43845d878c7"},
+ {file = "coverage-7.14.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9336e23e8bb3a3925398261385e2a1533957d3e760e91070dcb0e98bfa514eed"},
+ {file = "coverage-7.14.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd1169b2230f9cbe9c638ba38022ed7a2b1e641cc07f7cea0365e4be2a74980"},
+ {file = "coverage-7.14.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d1bb3543b58fea74d2cd1abc4054cc927e4724687cb4560cd2ed88d2c7d820c0"},
+ {file = "coverage-7.14.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a93bac2cb577ef60074999ed56d8a1535894398e2ed920d4185c3ec0c8864742"},
+ {file = "coverage-7.14.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:5904abf7e18cddc463219b17552229650c6b79e061d31a1059283051169cf7d5"},
+ {file = "coverage-7.14.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:741f57cddc9004a8c81b084660215f33a6b597dbe62c31386b983ee26310e327"},
+ {file = "coverage-7.14.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:664123feb0929d7affc135717dbd70d61d98688a08ab1e5ba464739620c6252d"},
+ {file = "coverage-7.14.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:c83d2399a51bbec8429266905d33616f04bc5726b1138c35844d5fcd896b2e20"},
+ {file = "coverage-7.14.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb2e855b87321259a037429288ae85216d191c74de3e79bf57cd2bc0761992c"},
+ {file = "coverage-7.14.0-cp311-cp311-win32.whl", hash = "sha256:731dc15b385ac52289743d476245b61e1a2927e803bef655b52bc3b2a75a21f3"},
+ {file = "coverage-7.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:bfb0ed8ec5d25e93face268115d7964db9df8b9aae8edcde9ec6b16c726a7cc1"},
+ {file = "coverage-7.14.0-cp311-cp311-win_arm64.whl", hash = "sha256:7ebb1c6df9f78046a1b1e0a89674cd4bf73b7c648914eebcf976a57fd99a5627"},
+ {file = "coverage-7.14.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7ffd19fc8aed057fd686a17a4935eef5f9859d69208f96310e893e64b9b6ccf5"},
+ {file = "coverage-7.14.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:829994cfe1aeb773ca27bf246d4badc1e764893e3bfb98fff820fcecd1ca4662"},
+ {file = "coverage-7.14.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b4f07cf7edcb7ec39431a5074d7ea83b29a9f71fcfc494f0f40af4e65180420f"},
+ {file = "coverage-7.14.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ca3d9cf2c32b521bd9518385608787fa86f38daf993695307531822c3430ed67"},
+ {file = "coverage-7.14.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92af52828e7f29d827346b0294e5a0853fa206db77db0395b282918d41e28db9"},
+ {file = "coverage-7.14.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7b2bb6c9d7e769360d0f20a0f219603fd64f0c8f97de17ab25853261602be0fb"},
+ {file = "coverage-7.14.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1c9ed6ef99f88fb8c14aa8e2bf8eb0fe55fa2edfea68f8675d78741df1a5ac0e"},
+ {file = "coverage-7.14.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8231ade007f37959fbf58acc677f26b922c02eda6f0428ea307da0fd39681bf3"},
+ {file = "coverage-7.14.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d8b013632cc1ce1d09dbe4f32667b4d320ec2f54fc326ebeffcd0b0bcc2bb6c4"},
+ {file = "coverage-7.14.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1733198802d71ec4c524f322e2867ee05c62e9e75df86bdca545407a221827d1"},
+ {file = "coverage-7.14.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:72a305291fa8ee01332f1aaf38b348ca34097f6aa0b0ef627eef2837e57bbba5"},
+ {file = "coverage-7.14.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fcaba850dd317c65423a9d63d88f9573c53b00354d6dd95724576cc98a131595"},
+ {file = "coverage-7.14.0-cp312-cp312-win32.whl", hash = "sha256:5ac83957a80d0701310e96d8bec68cdcf4f90a7674b7d13f15a344315b41ab27"},
+ {file = "coverage-7.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:70390b0da32cb90b501953716302906e8bcce087cb283e70d8c97729f22e92b2"},
+ {file = "coverage-7.14.0-cp312-cp312-win_arm64.whl", hash = "sha256:91b993743d959b8be85b4abf9d5478216a69329c321efe5be0433c1a841d691d"},
+ {file = "coverage-7.14.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f2bbb8254370eb4c628ff3d6fa8a7f74ddc40565394d4f7ab791d1fe568e37ef"},
+ {file = "coverage-7.14.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:23b81107f46d3f21d0cbce30664fcec0f5d9f585638a67081750f99738f6bf66"},
+ {file = "coverage-7.14.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:22a7e06a5f11a757cdfe79018e9095f9f69ae283c5cd8123774c788deec8717b"},
+ {file = "coverage-7.14.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9d1aa57a1dc8e05bdc42e81c5d671d849577aeedf279f4c449d6d286f9ed88ca"},
+ {file = "coverage-7.14.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90c1a51bcfddf645b3bb7ec333d9e94393a8e94f55642380fa8a9a5a9e636cb7"},
+ {file = "coverage-7.14.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a841fae2fadcae4f438d43b6ccc4aac2ad609f47cdb6cfdce60cbb3fe5ca7bc2"},
+ {file = "coverage-7.14.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c79d2319cabef1fe8e86df73371126931550804738f78ad7d31e3aad85a67367"},
+ {file = "coverage-7.14.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1b23b0c6f0b1db6ad769b7050c8b641c0bf215ded26c1816955b17b7f26edfa9"},
+ {file = "coverage-7.14.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:55d3089079ce181a4566b1065ab28d2575eb76d8ac8f81f4fcda2bf037fee087"},
+ {file = "coverage-7.14.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:49c005cba1e2f9677fb2845dcdf9a2e72a52a17d63e8231aaaae35d9f50215ef"},
+ {file = "coverage-7.14.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:9117377b823daa28aa8635fbb08cda1cd6be3d7143257345459559aeef852d52"},
+ {file = "coverage-7.14.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7b79d646cf46d5cf9a9f40281d4441df5849e445726e369006d2b117710b33fe"},
+ {file = "coverage-7.14.0-cp313-cp313-win32.whl", hash = "sha256:fb609b3658479e33f9516d46f1a89dbb9b6c261366e3a11844a96ec487533dae"},
+ {file = "coverage-7.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:0773d8329cf32b6fd222e4b52622c61fe8d503eb966cfc8d3c3c10c96266d50e"},
+ {file = "coverage-7.14.0-cp313-cp313-win_arm64.whl", hash = "sha256:b4e26a0f1b696faf283bffe5b8569e44e336c582439df5d53281ab89ee0cba96"},
+ {file = "coverage-7.14.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:953f521ca9445300397e65fda3dca58b2dbd68fee983777420b57ac3c77e9f90"},
+ {file = "coverage-7.14.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:98af83fd65ae24b1fdd03aaead967a9f523bcd2f1aab2d4f3ffda65bb568a6f1"},
+ {file = "coverage-7.14.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:668b92e6958c4db7cf92e81caac328dfbbdbb215db2850ad28f0cbe1eea0bfbd"},
+ {file = "coverage-7.14.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9fbd898551762dea00d3fef2b1c4f99afd2c6a3ff952ea07d60a9bd5ed4f34bc"},
+ {file = "coverage-7.14.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:68af363c07ecd8d4b7d4043d85cb376d7d227eceb54e5323ee45da73dbd3e426"},
+ {file = "coverage-7.14.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6e57054a583da8ac55edf24117ea4c9133032cfc4cf72aa2d48c1e5d4b52f899"},
+ {file = "coverage-7.14.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc3499459bbcdd51a65b64c35ab7ed2764eaf3cba826e0df3f1d7fe2e102b70b"},
+ {file = "coverage-7.14.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:45899ec2138a4346ed34d601dedf5076fb74edf2d1dd9dc76a78e82397edee90"},
+ {file = "coverage-7.14.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8767486808c436f05b23ab98eb963fb29185e32a9357a166971685cb3459900f"},
+ {file = "coverage-7.14.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:a3b5ddfd6aa7ddad53ee3edb231e88a2151507a43229b7d71b953916deca127d"},
+ {file = "coverage-7.14.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:63df0fe568e698e1045792399f8ab6da3a6c2dce3182813fb92afa2641087b47"},
+ {file = "coverage-7.14.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:827d6397dbd95144939b18f89edf31f63e1f99633e8d5f32f22ba8bdda567477"},
+ {file = "coverage-7.14.0-cp313-cp313t-win32.whl", hash = "sha256:7bf43e000d24012599b879791cff41589af90674722421ef11b11a5431920bab"},
+ {file = "coverage-7.14.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3f5549365af25d770e06b1f8f5682d9a5637d06eb494db91c6fa75d3950cc917"},
+ {file = "coverage-7.14.0-cp313-cp313t-win_arm64.whl", hash = "sha256:6d160217ec6fe890f16ad3a9531761589443749e448f91986c972714fad361c8"},
+ {file = "coverage-7.14.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9aed9fa983514ca032790f3fe0d1c0e42ca7e16b42432af1706b50a9a46bef5d"},
+ {file = "coverage-7.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ba3b8390db29296dbbf49e91b6fe08f990743a90c8f447ba4c2ffc29670dfa63"},
+ {file = "coverage-7.14.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3a5d8e876dfa2f102e970b183863d6dedd023d3c0eeca1fe7a9787bc5f28b212"},
+ {file = "coverage-7.14.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5ebb8f4614a3787d567e610bbfdf96a4798dd69a1afb1bd8ad228d4111fe6ff3"},
+ {file = "coverage-7.14.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b9bf47223dd8db3d4c4b2e443b02bace480d428f0822c3f991600448a176c97"},
+ {file = "coverage-7.14.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3485a836550b303d006d57cc06e3d5afaabc642c77050b7c985a97b13e3776b8"},
+ {file = "coverage-7.14.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e7e88110bae996d199d1693ca8ec3fd52441d426401ae963437598667b4c5eb"},
+ {file = "coverage-7.14.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15228a6800ce7bdf1b74800595e56db7138cecb338fdbf044806e10dcf182dfe"},
+ {file = "coverage-7.14.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9d26ac7f5398bafc5b57421ad994e8a4749e8a7a0e62d05ec7d53014d5963bfa"},
+ {file = "coverage-7.14.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2fb73254ff43c911c967a899e1359bc5049b4b115d6e8fbdde4937d0a2246cd5"},
+ {file = "coverage-7.14.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:454a380af72c6adada298ed270d38c7a391288198dbfb8467f786f588751a90c"},
+ {file = "coverage-7.14.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:65c86fb646d2bd2972e96bd1a8b45817ed907cee68655d6295fe7ec031d04cca"},
+ {file = "coverage-7.14.0-cp314-cp314-win32.whl", hash = "sha256:6a6516b02a6101398e19a3f44820f69bab2590697f7def4331f668b14adaf828"},
+ {file = "coverage-7.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:45e0f79d8351fa76e256716df91eab12890d32678b9590df7ae1042e4bd4cf5d"},
+ {file = "coverage-7.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:4b899594a8b2d81e5cc064a0d7f9cac2081fed91049456cae7676787e41549c9"},
+ {file = "coverage-7.14.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f580f8c80acd94ac72e863efe2cab791d8c38d153e0b463b92dfa000d5c84cd1"},
+ {file = "coverage-7.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a2bd259c442cd43c49b30fbafc51776eb19ea396faf159d26a83e6a0a5f13b0c"},
+ {file = "coverage-7.14.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a706b908dfa85538863504c624b237a3cc34232bf403c057414ebfdb3b4d9f84"},
+ {file = "coverage-7.14.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7333cd944ee4393b9b3d3c1b598c936d4fc8d70573a4c7dacfec5590dd50e436"},
+ {file = "coverage-7.14.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f162bc9a15b82d947b02651b0c7e1609d6f7a8735ca330cfadec8481dd97d5a"},
+ {file = "coverage-7.14.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:362cb78e01a5dc82009d88004cf60f2e6b6d6fcbfdec05b05af73b0abf40118f"},
+ {file = "coverage-7.14.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:acebd068fca5512c3a6fde9c045f901613478781a73f0e82b307b214daef23fb"},
+ {file = "coverage-7.14.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:29fe3da551dface75deb2ccbf87b6b66e2e7ef38f6d89050b428be94afff3490"},
+ {file = "coverage-7.14.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b4cc4fce8672fffcb09b0eafc167b396b3ba53c4a7230f54b7aaffbf6c835fa9"},
+ {file = "coverage-7.14.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5d4a51aad8ba8bdcd2b8bd8f03d4aca19693fa2327a3470e4718a25b03481020"},
+ {file = "coverage-7.14.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9f323af3e1e4f68b60b7b247e37b8515563a61375518fa59de1af48ba28a3db6"},
+ {file = "coverage-7.14.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1a0abc7342ea9711c469dd8b821c6c311e6bc6aac1442e5fbd6b27fae0a8f3db"},
+ {file = "coverage-7.14.0-cp314-cp314t-win32.whl", hash = "sha256:a9f864ef57b7172e2db87a096642dd51e179e085ab6b2c371c29e885f65c8fb2"},
+ {file = "coverage-7.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:29943e552fdc08e082eb51400fb2f58e118a83b5542bd06531214e084399b644"},
+ {file = "coverage-7.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:742a73ea621953b012f2c4c2219b512180dd84489acf5b1596b0aafc55b9100b"},
+ {file = "coverage-7.14.0-py3-none-any.whl", hash = "sha256:8de5b61163aee3d05c8a2beab6f47913df7981dad1baf82c414d99158c286ab1"},
+ {file = "coverage-7.14.0.tar.gz", hash = "sha256:057a6af2f160a85384cde4ab36f0d2777bae1057bae255f95413cdd382aa5c74"},
]
[package.dependencies]
@@ -425,80 +466,68 @@ toml = ["tomli"]
[[package]]
name = "cryptography"
-version = "46.0.3"
+version = "48.0.0"
description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."
optional = false
-python-versions = "!=3.9.0,!=3.9.1,>=3.8"
+python-versions = "!=3.9.0,!=3.9.1,>=3.9"
files = [
- {file = "cryptography-46.0.3-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:109d4ddfadf17e8e7779c39f9b18111a09efb969a301a31e987416a0191ed93a"},
- {file = "cryptography-46.0.3-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:09859af8466b69bc3c27bdf4f5d84a665e0f7ab5088412e9e2ec49758eca5cbc"},
- {file = "cryptography-46.0.3-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:01ca9ff2885f3acc98c29f1860552e37f6d7c7d013d7334ff2a9de43a449315d"},
- {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:6eae65d4c3d33da080cff9c4ab1f711b15c1d9760809dad6ea763f3812d254cb"},
- {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e5bf0ed4490068a2e72ac03d786693adeb909981cc596425d09032d372bcc849"},
- {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5ecfccd2329e37e9b7112a888e76d9feca2347f12f37918facbb893d7bb88ee8"},
- {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a2c0cd47381a3229c403062f764160d57d4d175e022c1df84e168c6251a22eec"},
- {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:549e234ff32571b1f4076ac269fcce7a808d3bf98b76c8dd560e42dbc66d7d91"},
- {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:c0a7bb1a68a5d3471880e264621346c48665b3bf1c3759d682fc0864c540bd9e"},
- {file = "cryptography-46.0.3-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:10b01676fc208c3e6feeb25a8b83d81767e8059e1fe86e1dc62d10a3018fa926"},
- {file = "cryptography-46.0.3-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0abf1ffd6e57c67e92af68330d05760b7b7efb243aab8377e583284dbab72c71"},
- {file = "cryptography-46.0.3-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a04bee9ab6a4da801eb9b51f1b708a1b5b5c9eb48c03f74198464c66f0d344ac"},
- {file = "cryptography-46.0.3-cp311-abi3-win32.whl", hash = "sha256:f260d0d41e9b4da1ed1e0f1ce571f97fe370b152ab18778e9e8f67d6af432018"},
- {file = "cryptography-46.0.3-cp311-abi3-win_amd64.whl", hash = "sha256:a9a3008438615669153eb86b26b61e09993921ebdd75385ddd748702c5adfddb"},
- {file = "cryptography-46.0.3-cp311-abi3-win_arm64.whl", hash = "sha256:5d7f93296ee28f68447397bf5198428c9aeeab45705a55d53a6343455dcb2c3c"},
- {file = "cryptography-46.0.3-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:00a5e7e87938e5ff9ff5447ab086a5706a957137e6e433841e9d24f38a065217"},
- {file = "cryptography-46.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8daeb2d2174beb4575b77482320303f3d39b8e81153da4f0fb08eb5fe86a6c5"},
- {file = "cryptography-46.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:39b6755623145ad5eff1dab323f4eae2a32a77a7abef2c5089a04a3d04366715"},
- {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:db391fa7c66df6762ee3f00c95a89e6d428f4d60e7abc8328f4fe155b5ac6e54"},
- {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:78a97cf6a8839a48c49271cdcbd5cf37ca2c1d6b7fdd86cc864f302b5e9bf459"},
- {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:dfb781ff7eaa91a6f7fd41776ec37c5853c795d3b358d4896fdbb5df168af422"},
- {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6f61efb26e76c45c4a227835ddeae96d83624fb0d29eb5df5b96e14ed1a0afb7"},
- {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:23b1a8f26e43f47ceb6d6a43115f33a5a37d57df4ea0ca295b780ae8546e8044"},
- {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b419ae593c86b87014b9be7396b385491ad7f320bde96826d0dd174459e54665"},
- {file = "cryptography-46.0.3-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:50fc3343ac490c6b08c0cf0d704e881d0d660be923fd3076db3e932007e726e3"},
- {file = "cryptography-46.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:22d7e97932f511d6b0b04f2bfd818d73dcd5928db509460aaf48384778eb6d20"},
- {file = "cryptography-46.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:d55f3dffadd674514ad19451161118fd010988540cee43d8bc20675e775925de"},
- {file = "cryptography-46.0.3-cp314-cp314t-win32.whl", hash = "sha256:8a6e050cb6164d3f830453754094c086ff2d0b2f3a897a1d9820f6139a1f0914"},
- {file = "cryptography-46.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:760f83faa07f8b64e9c33fc963d790a2edb24efb479e3520c14a45741cd9b2db"},
- {file = "cryptography-46.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:516ea134e703e9fe26bcd1277a4b59ad30586ea90c365a87781d7887a646fe21"},
- {file = "cryptography-46.0.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:cb3d760a6117f621261d662bccc8ef5bc32ca673e037c83fbe565324f5c46936"},
- {file = "cryptography-46.0.3-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4b7387121ac7d15e550f5cb4a43aef2559ed759c35df7336c402bb8275ac9683"},
- {file = "cryptography-46.0.3-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:15ab9b093e8f09daab0f2159bb7e47532596075139dd74365da52ecc9cb46c5d"},
- {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:46acf53b40ea38f9c6c229599a4a13f0d46a6c3fa9ef19fc1a124d62e338dfa0"},
- {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:10ca84c4668d066a9878890047f03546f3ae0a6b8b39b697457b7757aaf18dbc"},
- {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:36e627112085bb3b81b19fed209c05ce2a52ee8b15d161b7c643a7d5a88491f3"},
- {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1000713389b75c449a6e979ffc7dcc8ac90b437048766cef052d4d30b8220971"},
- {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:b02cf04496f6576afffef5ddd04a0cb7d49cf6be16a9059d793a30b035f6b6ac"},
- {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:71e842ec9bc7abf543b47cf86b9a743baa95f4677d22baa4c7d5c69e49e9bc04"},
- {file = "cryptography-46.0.3-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:402b58fc32614f00980b66d6e56a5b4118e6cb362ae8f3fda141ba4689bd4506"},
- {file = "cryptography-46.0.3-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef639cb3372f69ec44915fafcd6698b6cc78fbe0c2ea41be867f6ed612811963"},
- {file = "cryptography-46.0.3-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:3b51b8ca4f1c6453d8829e1eb7299499ca7f313900dd4d89a24b8b87c0a780d4"},
- {file = "cryptography-46.0.3-cp38-abi3-win32.whl", hash = "sha256:6276eb85ef938dc035d59b87c8a7dc559a232f954962520137529d77b18ff1df"},
- {file = "cryptography-46.0.3-cp38-abi3-win_amd64.whl", hash = "sha256:416260257577718c05135c55958b674000baef9a1c7d9e8f306ec60d71db850f"},
- {file = "cryptography-46.0.3-cp38-abi3-win_arm64.whl", hash = "sha256:d89c3468de4cdc4f08a57e214384d0471911a3830fcdaf7a8cc587e42a866372"},
- {file = "cryptography-46.0.3-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a23582810fedb8c0bc47524558fb6c56aac3fc252cb306072fd2815da2a47c32"},
- {file = "cryptography-46.0.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:e7aec276d68421f9574040c26e2a7c3771060bc0cff408bae1dcb19d3ab1e63c"},
- {file = "cryptography-46.0.3-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:7ce938a99998ed3c8aa7e7272dca1a610401ede816d36d0693907d863b10d9ea"},
- {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:191bb60a7be5e6f54e30ba16fdfae78ad3a342a0599eb4193ba88e3f3d6e185b"},
- {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:c70cc23f12726be8f8bc72e41d5065d77e4515efae3690326764ea1b07845cfb"},
- {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:9394673a9f4de09e28b5356e7fff97d778f8abad85c9d5ac4a4b7e25a0de7717"},
- {file = "cryptography-46.0.3-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:94cd0549accc38d1494e1f8de71eca837d0509d0d44bf11d158524b0e12cebf9"},
- {file = "cryptography-46.0.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:6b5063083824e5509fdba180721d55909ffacccc8adbec85268b48439423d78c"},
- {file = "cryptography-46.0.3.tar.gz", hash = "sha256:a8b17438104fed022ce745b362294d9ce35b4c2e45c1d958ad4a4b019285f4a1"},
+ {file = "cryptography-48.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:0c558d2cdffd8f4bbb30fc7134c74d2ca9a476f830bb053074498fbc86f41ed6"},
+ {file = "cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c"},
+ {file = "cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3"},
+ {file = "cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5"},
+ {file = "cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c"},
+ {file = "cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f"},
+ {file = "cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25"},
+ {file = "cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602"},
+ {file = "cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c"},
+ {file = "cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5"},
+ {file = "cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321"},
+ {file = "cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74"},
+ {file = "cryptography-48.0.0-cp311-abi3-win32.whl", hash = "sha256:9c459db21422be75e2809370b829a87eb37f74cd785fc4aa9ea1e5f43b47cda4"},
+ {file = "cryptography-48.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:5b012212e08b8dd5edc78ef54da83dd9892fd9105323b3993eff6bea65dc21d7"},
+ {file = "cryptography-48.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:3cb07a3ed6431663cd321ea8a000a1314c74211f823e4177fefa2255e057d1ec"},
+ {file = "cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18"},
+ {file = "cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20"},
+ {file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff"},
+ {file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c"},
+ {file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db"},
+ {file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741"},
+ {file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166"},
+ {file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336"},
+ {file = "cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057"},
+ {file = "cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae"},
+ {file = "cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c"},
+ {file = "cryptography-48.0.0-cp314-cp314t-win32.whl", hash = "sha256:c17dfe85494deaeddc5ce251aebd1d60bbe6afc8b62071bb0b469431a000124f"},
+ {file = "cryptography-48.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27241b1dc9962e056062a8eef1991d02c3a24569c95975bd2322a8a52c6e5e12"},
+ {file = "cryptography-48.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:58d00498e8933e4a194f3076aee1b4a97dfec1a6da444535755822fe5d8b0b86"},
+ {file = "cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e"},
+ {file = "cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f"},
+ {file = "cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7"},
+ {file = "cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832"},
+ {file = "cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c"},
+ {file = "cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a"},
+ {file = "cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a"},
+ {file = "cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a"},
+ {file = "cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239"},
+ {file = "cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c"},
+ {file = "cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4"},
+ {file = "cryptography-48.0.0-cp39-abi3-win32.whl", hash = "sha256:77a2ccbbe917f6710e05ba9adaa25fb5075620bf3ea6fb751997875aff4ae4bd"},
+ {file = "cryptography-48.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:16cd65b9330583e4619939b3a3843eec1e6e789744bb01e7c7e2e62e33c239c8"},
+ {file = "cryptography-48.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:84cf79f0dc8b36ac5da873481716e87aef31fcfa0444f9e1d8b4b2cece142855"},
+ {file = "cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:fdfef35d751d510fcef5252703621574364fec16418c4a1e5e1055248401054b"},
+ {file = "cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:0890f502ddf7d9c6426129c3f49f5c0a39278ed7cd6322c8755ffca6ee675a13"},
+ {file = "cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:ecde28a596bead48b0cfd2a1b4416c3d43074c2d785e3a398d7ec1fc4d0f7fbb"},
+ {file = "cryptography-48.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:4defde8685ae324a9eb9d818717e93b4638ef67070ac9bc15b8ca85f63048355"},
+ {file = "cryptography-48.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:db63bf618e5dea46c07de12e900fe1cdd2541e6dc9dbae772a70b7d4d4765f6a"},
+ {file = "cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920"},
]
[package.dependencies]
-cffi = {version = ">=2.0.0", markers = "python_full_version >= \"3.9\" and platform_python_implementation != \"PyPy\""}
+cffi = {version = ">=2.0.0", markers = "platform_python_implementation != \"PyPy\""}
typing-extensions = {version = ">=4.13.2", markers = "python_full_version < \"3.11\""}
[package.extras]
-docs = ["sphinx (>=5.3.0)", "sphinx-inline-tabs", "sphinx-rtd-theme (>=3.0.0)"]
-docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"]
-nox = ["nox[uv] (>=2024.4.15)"]
-pep8test = ["check-sdist", "click (>=8.0.1)", "mypy (>=1.14)", "ruff (>=0.11.11)"]
-sdist = ["build (>=1.0.0)"]
ssh = ["bcrypt (>=3.1.5)"]
-test = ["certifi (>=2024)", "cryptography-vectors (==46.0.3)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"]
-test-randomorder = ["pytest-randomly"]
[[package]]
name = "desktop-entry-lib"
@@ -533,17 +562,17 @@ test = ["pytest (>=6)"]
[[package]]
name = "idna"
-version = "3.11"
+version = "3.15"
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"},
+ {file = "idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8"},
+ {file = "idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc"},
]
[package.extras]
-all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"]
+all = ["mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"]
[[package]]
name = "iniconfig"
@@ -558,35 +587,70 @@ files = [
[[package]]
name = "invoke"
-version = "2.2.1"
+version = "3.0.3"
description = "Pythonic task execution"
optional = false
-python-versions = ">=3.6"
+python-versions = ">=3.9"
files = [
- {file = "invoke-2.2.1-py3-none-any.whl", hash = "sha256:2413bc441b376e5cd3f55bb5d364f973ad8bdd7bf87e53c79de3c11bf3feecc8"},
- {file = "invoke-2.2.1.tar.gz", hash = "sha256:515bf49b4a48932b79b024590348da22f39c4942dff991ad1fb8b8baea1be707"},
+ {file = "invoke-3.0.3-py3-none-any.whl", hash = "sha256:f11327165e5cbb89b2ad1d88d3292b5113332c43b8553b494da435d6ec6f5053"},
+ {file = "invoke-3.0.3.tar.gz", hash = "sha256:437b6a622223824380bfb4e64f612711a6b648c795f565efc8625af66fb57f0c"},
]
+[[package]]
+name = "jsonschema"
+version = "4.26.0"
+description = "An implementation of JSON Schema validation for Python"
+optional = false
+python-versions = ">=3.10"
+files = [
+ {file = "jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce"},
+ {file = "jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326"},
+]
+
+[package.dependencies]
+attrs = ">=22.2.0"
+jsonschema-specifications = ">=2023.03.6"
+referencing = ">=0.28.4"
+rpds-py = ">=0.25.0"
+
+[package.extras]
+format = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3987", "uri-template", "webcolors (>=1.11)"]
+format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3986-validator (>0.1.0)", "rfc3987-syntax (>=1.1.0)", "uri-template", "webcolors (>=24.6.0)"]
+
+[[package]]
+name = "jsonschema-specifications"
+version = "2025.9.1"
+description = "The JSON Schema meta-schemas and vocabularies, exposed as a Registry"
+optional = false
+python-versions = ">=3.9"
+files = [
+ {file = "jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe"},
+ {file = "jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d"},
+]
+
+[package.dependencies]
+referencing = ">=0.31.0"
+
[[package]]
name = "packaging"
-version = "25.0"
+version = "26.2"
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"},
+ {file = "packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e"},
+ {file = "packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661"},
]
[[package]]
name = "paramiko"
-version = "4.0.0"
+version = "5.0.0"
description = "SSH2 protocol library"
optional = false
python-versions = ">=3.9"
files = [
- {file = "paramiko-4.0.0-py3-none-any.whl", hash = "sha256:0e20e00ac666503bf0b4eda3b6d833465a2b7aff2e2b3d79a8bba5ef144ee3b9"},
- {file = "paramiko-4.0.0.tar.gz", hash = "sha256:6a25f07b380cc9c9a88d2b920ad37167ac4667f8d9886ccebd8f90f654b5d69f"},
+ {file = "paramiko-5.0.0-py3-none-any.whl", hash = "sha256:b7044611c30140d9a75261653210e2002977b71a0497ff3ba0d98d7edbf62f7c"},
+ {file = "paramiko-5.0.0.tar.gz", hash = "sha256:36763b5b95c2a0dcfdf1abc48e48156ee425b21efe2f0e787c2dd5a95c0e5e79"},
]
[package.dependencies]
@@ -595,9 +659,6 @@ cryptography = ">=3.3"
invoke = ">=2.0"
pynacl = ">=1.5"
-[package.extras]
-gssapi = ["gssapi (>=1.4.1)", "pyasn1 (>=0.1.7)", "pywin32 (>=2.1.8)"]
-
[[package]]
name = "pluggy"
version = "1.6.0"
@@ -615,24 +676,24 @@ testing = ["coverage", "pytest", "pytest-benchmark"]
[[package]]
name = "pycparser"
-version = "2.23"
+version = "3.0"
description = "C parser in Python"
optional = false
-python-versions = ">=3.8"
+python-versions = ">=3.10"
files = [
- {file = "pycparser-2.23-py3-none-any.whl", hash = "sha256:e5c6e8d3fbad53479cab09ac03729e0a9faf2bee3db8208a550daf5af81a5934"},
- {file = "pycparser-2.23.tar.gz", hash = "sha256:78816d4f24add8f10a06d6f05b4d424ad9e96cfebf68a4ddc99c65c0720d00c2"},
+ {file = "pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"},
+ {file = "pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29"},
]
[[package]]
name = "pygments"
-version = "2.19.2"
+version = "2.20.0"
description = "Pygments is a syntax highlighting package written in Python."
optional = false
-python-versions = ">=3.8"
+python-versions = ">=3.9"
files = [
- {file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"},
- {file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"},
+ {file = "pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176"},
+ {file = "pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"},
]
[package.extras]
@@ -640,38 +701,36 @@ windows-terminal = ["colorama (>=0.4.6)"]
[[package]]
name = "pynacl"
-version = "1.6.1"
+version = "1.6.2"
description = "Python binding to the Networking and Cryptography (NaCl) library"
optional = false
python-versions = ">=3.8"
files = [
- {file = "pynacl-1.6.1-cp314-cp314t-macosx_10_10_universal2.whl", hash = "sha256:7d7c09749450c385301a3c20dca967a525152ae4608c0a096fe8464bfc3df93d"},
- {file = "pynacl-1.6.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fc734c1696ffd49b40f7c1779c89ba908157c57345cf626be2e0719488a076d3"},
- {file = "pynacl-1.6.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3cd787ec1f5c155dc8ecf39b1333cfef41415dc96d392f1ce288b4fe970df489"},
- {file = "pynacl-1.6.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b35d93ab2df03ecb3aa506be0d3c73609a51449ae0855c2e89c7ed44abde40b"},
- {file = "pynacl-1.6.1-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dece79aecbb8f4640a1adbb81e4aa3bfb0e98e99834884a80eb3f33c7c30e708"},
- {file = "pynacl-1.6.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:c2228054f04bf32d558fb89bb99f163a8197d5a9bf4efa13069a7fa8d4b93fc3"},
- {file = "pynacl-1.6.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:2b12f1b97346f177affcdfdc78875ff42637cb40dcf79484a97dae3448083a78"},
- {file = "pynacl-1.6.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e735c3a1bdfde3834503baf1a6d74d4a143920281cb724ba29fb84c9f49b9c48"},
- {file = "pynacl-1.6.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3384a454adf5d716a9fadcb5eb2e3e72cd49302d1374a60edc531c9957a9b014"},
- {file = "pynacl-1.6.1-cp314-cp314t-win32.whl", hash = "sha256:d8615ee34d01c8e0ab3f302dcdd7b32e2bcf698ba5f4809e7cc407c8cdea7717"},
- {file = "pynacl-1.6.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5f5b35c1a266f8a9ad22525049280a600b19edd1f785bccd01ae838437dcf935"},
- {file = "pynacl-1.6.1-cp314-cp314t-win_arm64.whl", hash = "sha256:d984c91fe3494793b2a1fb1e91429539c6c28e9ec8209d26d25041ec599ccf63"},
- {file = "pynacl-1.6.1-cp38-abi3-macosx_10_10_universal2.whl", hash = "sha256:a6f9fd6d6639b1e81115c7f8ff16b8dedba1e8098d2756275d63d208b0e32021"},
- {file = "pynacl-1.6.1-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:e49a3f3d0da9f79c1bec2aa013261ab9fa651c7da045d376bd306cf7c1792993"},
- {file = "pynacl-1.6.1-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7713f8977b5d25f54a811ec9efa2738ac592e846dd6e8a4d3f7578346a841078"},
- {file = "pynacl-1.6.1-cp38-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a3becafc1ee2e5ea7f9abc642f56b82dcf5be69b961e782a96ea52b55d8a9fc"},
- {file = "pynacl-1.6.1-cp38-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4ce50d19f1566c391fedc8dc2f2f5be265ae214112ebe55315e41d1f36a7f0a9"},
- {file = "pynacl-1.6.1-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:543f869140f67d42b9b8d47f922552d7a967e6c116aad028c9bfc5f3f3b3a7b7"},
- {file = "pynacl-1.6.1-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:a2bb472458c7ca959aeeff8401b8efef329b0fc44a89d3775cffe8fad3398ad8"},
- {file = "pynacl-1.6.1-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:3206fa98737fdc66d59b8782cecc3d37d30aeec4593d1c8c145825a345bba0f0"},
- {file = "pynacl-1.6.1-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:53543b4f3d8acb344f75fd4d49f75e6572fce139f4bfb4815a9282296ff9f4c0"},
- {file = "pynacl-1.6.1-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:319de653ef84c4f04e045eb250e6101d23132372b0a61a7acf91bac0fda8e58c"},
- {file = "pynacl-1.6.1-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:262a8de6bba4aee8a66f5edf62c214b06647461c9b6b641f8cd0cb1e3b3196fe"},
- {file = "pynacl-1.6.1-cp38-abi3-win32.whl", hash = "sha256:9fd1a4eb03caf8a2fe27b515a998d26923adb9ddb68db78e35ca2875a3830dde"},
- {file = "pynacl-1.6.1-cp38-abi3-win_amd64.whl", hash = "sha256:a569a4069a7855f963940040f35e87d8bc084cb2d6347428d5ad20550a0a1a21"},
- {file = "pynacl-1.6.1-cp38-abi3-win_arm64.whl", hash = "sha256:5953e8b8cfadb10889a6e7bd0f53041a745d1b3d30111386a1bb37af171e6daf"},
- {file = "pynacl-1.6.1.tar.gz", hash = "sha256:8d361dac0309f2b6ad33b349a56cd163c98430d409fa503b10b70b3ad66eaa1d"},
+ {file = "pynacl-1.6.2-cp314-cp314t-macosx_10_10_universal2.whl", hash = "sha256:622d7b07cc5c02c666795792931b50c91f3ce3c2649762efb1ef0d5684c81594"},
+ {file = "pynacl-1.6.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d071c6a9a4c94d79eb665db4ce5cedc537faf74f2355e4d502591d850d3913c0"},
+ {file = "pynacl-1.6.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe9847ca47d287af41e82be1dd5e23023d3c31a951da134121ab02e42ac218c9"},
+ {file = "pynacl-1.6.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04316d1fc625d860b6c162fff704eb8426b1a8bcd3abacea11142cbd99a6b574"},
+ {file = "pynacl-1.6.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44081faff368d6c5553ccf55322ef2819abb40e25afaec7e740f159f74813634"},
+ {file = "pynacl-1.6.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:a9f9932d8d2811ce1a8ffa79dcbdf3970e7355b5c8eb0c1a881a57e7f7d96e88"},
+ {file = "pynacl-1.6.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:bc4a36b28dd72fb4845e5d8f9760610588a96d5a51f01d84d8c6ff9849968c14"},
+ {file = "pynacl-1.6.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bffb6d0f6becacb6526f8f42adfb5efb26337056ee0831fb9a7044d1a964444"},
+ {file = "pynacl-1.6.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fef529ef3ee487ad8113d287a593fa26f48ee3620d92ecc6f1d09ea38e0709b"},
+ {file = "pynacl-1.6.2-cp314-cp314t-win32.whl", hash = "sha256:a84bf1c20339d06dc0c85d9aea9637a24f718f375d861b2668b2f9f96fa51145"},
+ {file = "pynacl-1.6.2-cp314-cp314t-win_amd64.whl", hash = "sha256:320ef68a41c87547c91a8b58903c9caa641ab01e8512ce291085b5fe2fcb7590"},
+ {file = "pynacl-1.6.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d29bfe37e20e015a7d8b23cfc8bd6aa7909c92a1b8f41ee416bbb3e79ef182b2"},
+ {file = "pynacl-1.6.2-cp38-abi3-macosx_10_10_universal2.whl", hash = "sha256:c949ea47e4206af7c8f604b8278093b674f7c79ed0d4719cc836902bf4517465"},
+ {file = "pynacl-1.6.2-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8845c0631c0be43abdd865511c41eab235e0be69c81dc66a50911594198679b0"},
+ {file = "pynacl-1.6.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22de65bb9010a725b0dac248f353bb072969c94fa8d6b1f34b87d7953cf7bbe4"},
+ {file = "pynacl-1.6.2-cp38-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46065496ab748469cdd999246d17e301b2c24ae2fdf739132e580a0e94c94a87"},
+ {file = "pynacl-1.6.2-cp38-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a66d6fb6ae7661c58995f9c6435bda2b1e68b54b598a6a10247bfcdadac996c"},
+ {file = "pynacl-1.6.2-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:26bfcd00dcf2cf160f122186af731ae30ab120c18e8375684ec2670dccd28130"},
+ {file = "pynacl-1.6.2-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c8a231e36ec2cab018c4ad4358c386e36eede0319a0c41fed24f840b1dac59f6"},
+ {file = "pynacl-1.6.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:68be3a09455743ff9505491220b64440ced8973fe930f270c8e07ccfa25b1f9e"},
+ {file = "pynacl-1.6.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b097553b380236d51ed11356c953bf8ce36a29a3e596e934ecabe76c985a577"},
+ {file = "pynacl-1.6.2-cp38-abi3-win32.whl", hash = "sha256:5811c72b473b2f38f7e2a3dc4f8642e3a3e9b5e7317266e4ced1fba85cae41aa"},
+ {file = "pynacl-1.6.2-cp38-abi3-win_amd64.whl", hash = "sha256:62985f233210dee6548c223301b6c25440852e13d59a8b81490203c3227c5ba0"},
+ {file = "pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c"},
+ {file = "pynacl-1.6.2.tar.gz", hash = "sha256:018494d6d696ae03c7e656e5e74cdfd8ea1326962cc401bcf018f1ed8436811c"},
]
[package.dependencies]
@@ -821,75 +880,220 @@ files = [
]
[[package]]
-name = "requests"
-version = "2.32.5"
-description = "Python HTTP for Humans."
+name = "referencing"
+version = "0.37.0"
+description = "JSON Referencing + Python"
optional = false
-python-versions = ">=3.9"
+python-versions = ">=3.10"
files = [
- {file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"},
- {file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"},
+ {file = "referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231"},
+ {file = "referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8"},
]
[package.dependencies]
-certifi = ">=2017.4.17"
+attrs = ">=22.2.0"
+rpds-py = ">=0.7.0"
+typing-extensions = {version = ">=4.4.0", markers = "python_version < \"3.13\""}
+
+[[package]]
+name = "requests"
+version = "2.34.1"
+description = "Python HTTP for Humans."
+optional = false
+python-versions = ">=3.10"
+files = [
+ {file = "requests-2.34.1-py3-none-any.whl", hash = "sha256:bf38a3ff993960d3dd819c08862c40b3c703306eb7c744fcd9f4ddbb95b548f0"},
+ {file = "requests-2.34.1.tar.gz", hash = "sha256:0fc5669f2b69704449fe1552360bd2a73a54512dfd03e65529157f1513322beb"},
+]
+
+[package.dependencies]
+certifi = ">=2023.5.7"
charset_normalizer = ">=2,<4"
idna = ">=2.5,<4"
-urllib3 = ">=1.21.1,<3"
+urllib3 = ">=1.26,<3"
[package.extras]
socks = ["PySocks (>=1.5.6,!=1.5.7)"]
-use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
+use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"]
+
+[[package]]
+name = "rpds-py"
+version = "0.30.0"
+description = "Python bindings to Rust's persistent data structures (rpds)"
+optional = false
+python-versions = ">=3.10"
+files = [
+ {file = "rpds_py-0.30.0-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:679ae98e00c0e8d68a7fda324e16b90fd5260945b45d3b824c892cec9eea3288"},
+ {file = "rpds_py-0.30.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:4cc2206b76b4f576934f0ed374b10d7ca5f457858b157ca52064bdfc26b9fc00"},
+ {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:389a2d49eded1896c3d48b0136ead37c48e221b391c052fba3f4055c367f60a6"},
+ {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:32c8528634e1bf7121f3de08fa85b138f4e0dc47657866630611b03967f041d7"},
+ {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f207f69853edd6f6700b86efb84999651baf3789e78a466431df1331608e5324"},
+ {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:67b02ec25ba7a9e8fa74c63b6ca44cf5707f2fbfadae3ee8e7494297d56aa9df"},
+ {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c0e95f6819a19965ff420f65578bacb0b00f251fefe2c8b23347c37174271f3"},
+ {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:a452763cc5198f2f98898eb98f7569649fe5da666c2dc6b5ddb10fde5a574221"},
+ {file = "rpds_py-0.30.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e0b65193a413ccc930671c55153a03ee57cecb49e6227204b04fae512eb657a7"},
+ {file = "rpds_py-0.30.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:858738e9c32147f78b3ac24dc0edb6610000e56dc0f700fd5f651d0a0f0eb9ff"},
+ {file = "rpds_py-0.30.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:da279aa314f00acbb803da1e76fa18666778e8a8f83484fba94526da5de2cba7"},
+ {file = "rpds_py-0.30.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7c64d38fb49b6cdeda16ab49e35fe0da2e1e9b34bc38bd78386530f218b37139"},
+ {file = "rpds_py-0.30.0-cp310-cp310-win32.whl", hash = "sha256:6de2a32a1665b93233cde140ff8b3467bdb9e2af2b91079f0333a0974d12d464"},
+ {file = "rpds_py-0.30.0-cp310-cp310-win_amd64.whl", hash = "sha256:1726859cd0de969f88dc8673bdd954185b9104e05806be64bcd87badbe313169"},
+ {file = "rpds_py-0.30.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a2bffea6a4ca9f01b3f8e548302470306689684e61602aa3d141e34da06cf425"},
+ {file = "rpds_py-0.30.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dc4f992dfe1e2bc3ebc7444f6c7051b4bc13cd8e33e43511e8ffd13bf407010d"},
+ {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:422c3cb9856d80b09d30d2eb255d0754b23e090034e1deb4083f8004bd0761e4"},
+ {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:07ae8a593e1c3c6b82ca3292efbe73c30b61332fd612e05abee07c79359f292f"},
+ {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:12f90dd7557b6bd57f40abe7747e81e0c0b119bef015ea7726e69fe550e394a4"},
+ {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99b47d6ad9a6da00bec6aabe5a6279ecd3c06a329d4aa4771034a21e335c3a97"},
+ {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:33f559f3104504506a44bb666b93a33f5d33133765b0c216a5bf2f1e1503af89"},
+ {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:946fe926af6e44f3697abbc305ea168c2c31d3e3ef1058cf68f379bf0335a78d"},
+ {file = "rpds_py-0.30.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:495aeca4b93d465efde585977365187149e75383ad2684f81519f504f5c13038"},
+ {file = "rpds_py-0.30.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d9a0ca5da0386dee0655b4ccdf46119df60e0f10da268d04fe7cc87886872ba7"},
+ {file = "rpds_py-0.30.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8d6d1cc13664ec13c1b84241204ff3b12f9bb82464b8ad6e7a5d3486975c2eed"},
+ {file = "rpds_py-0.30.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3896fa1be39912cf0757753826bc8bdc8ca331a28a7c4ae46b7a21280b06bb85"},
+ {file = "rpds_py-0.30.0-cp311-cp311-win32.whl", hash = "sha256:55f66022632205940f1827effeff17c4fa7ae1953d2b74a8581baaefb7d16f8c"},
+ {file = "rpds_py-0.30.0-cp311-cp311-win_amd64.whl", hash = "sha256:a51033ff701fca756439d641c0ad09a41d9242fa69121c7d8769604a0a629825"},
+ {file = "rpds_py-0.30.0-cp311-cp311-win_arm64.whl", hash = "sha256:47b0ef6231c58f506ef0b74d44e330405caa8428e770fec25329ed2cb971a229"},
+ {file = "rpds_py-0.30.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a161f20d9a43006833cd7068375a94d035714d73a172b681d8881820600abfad"},
+ {file = "rpds_py-0.30.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6abc8880d9d036ecaafe709079969f56e876fcf107f7a8e9920ba6d5a3878d05"},
+ {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca28829ae5f5d569bb62a79512c842a03a12576375d5ece7d2cadf8abe96ec28"},
+ {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a1010ed9524c73b94d15919ca4d41d8780980e1765babf85f9a2f90d247153dd"},
+ {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f8d1736cfb49381ba528cd5baa46f82fdc65c06e843dab24dd70b63d09121b3f"},
+ {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d948b135c4693daff7bc2dcfc4ec57237a29bd37e60c2fabf5aff2bbacf3e2f1"},
+ {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47f236970bccb2233267d89173d3ad2703cd36a0e2a6e92d0560d333871a3d23"},
+ {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:2e6ecb5a5bcacf59c3f912155044479af1d0b6681280048b338b28e364aca1f6"},
+ {file = "rpds_py-0.30.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a8fa71a2e078c527c3e9dc9fc5a98c9db40bcc8a92b4e8858e36d329f8684b51"},
+ {file = "rpds_py-0.30.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:73c67f2db7bc334e518d097c6d1e6fed021bbc9b7d678d6cc433478365d1d5f5"},
+ {file = "rpds_py-0.30.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5ba103fb455be00f3b1c2076c9d4264bfcb037c976167a6047ed82f23153f02e"},
+ {file = "rpds_py-0.30.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7cee9c752c0364588353e627da8a7e808a66873672bcb5f52890c33fd965b394"},
+ {file = "rpds_py-0.30.0-cp312-cp312-win32.whl", hash = "sha256:1ab5b83dbcf55acc8b08fc62b796ef672c457b17dbd7820a11d6c52c06839bdf"},
+ {file = "rpds_py-0.30.0-cp312-cp312-win_amd64.whl", hash = "sha256:a090322ca841abd453d43456ac34db46e8b05fd9b3b4ac0c78bcde8b089f959b"},
+ {file = "rpds_py-0.30.0-cp312-cp312-win_arm64.whl", hash = "sha256:669b1805bd639dd2989b281be2cfd951c6121b65e729d9b843e9639ef1fd555e"},
+ {file = "rpds_py-0.30.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f83424d738204d9770830d35290ff3273fbb02b41f919870479fab14b9d303b2"},
+ {file = "rpds_py-0.30.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e7536cd91353c5273434b4e003cbda89034d67e7710eab8761fd918ec6c69cf8"},
+ {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2771c6c15973347f50fece41fc447c054b7ac2ae0502388ce3b6738cd366e3d4"},
+ {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0a59119fc6e3f460315fe9d08149f8102aa322299deaa5cab5b40092345c2136"},
+ {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:76fec018282b4ead0364022e3c54b60bf368b9d926877957a8624b58419169b7"},
+ {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:692bef75a5525db97318e8cd061542b5a79812d711ea03dbc1f6f8dbb0c5f0d2"},
+ {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9027da1ce107104c50c81383cae773ef5c24d296dd11c99e2629dbd7967a20c6"},
+ {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9cf69cdda1f5968a30a359aba2f7f9aa648a9ce4b580d6826437f2b291cfc86e"},
+ {file = "rpds_py-0.30.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a4796a717bf12b9da9d3ad002519a86063dcac8988b030e405704ef7d74d2d9d"},
+ {file = "rpds_py-0.30.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5d4c2aa7c50ad4728a094ebd5eb46c452e9cb7edbfdb18f9e1221f597a73e1e7"},
+ {file = "rpds_py-0.30.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ba81a9203d07805435eb06f536d95a266c21e5b2dfbf6517748ca40c98d19e31"},
+ {file = "rpds_py-0.30.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:945dccface01af02675628334f7cf49c2af4c1c904748efc5cf7bbdf0b579f95"},
+ {file = "rpds_py-0.30.0-cp313-cp313-win32.whl", hash = "sha256:b40fb160a2db369a194cb27943582b38f79fc4887291417685f3ad693c5a1d5d"},
+ {file = "rpds_py-0.30.0-cp313-cp313-win_amd64.whl", hash = "sha256:806f36b1b605e2d6a72716f321f20036b9489d29c51c91f4dd29a3e3afb73b15"},
+ {file = "rpds_py-0.30.0-cp313-cp313-win_arm64.whl", hash = "sha256:d96c2086587c7c30d44f31f42eae4eac89b60dabbac18c7669be3700f13c3ce1"},
+ {file = "rpds_py-0.30.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:eb0b93f2e5c2189ee831ee43f156ed34e2a89a78a66b98cadad955972548be5a"},
+ {file = "rpds_py-0.30.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:922e10f31f303c7c920da8981051ff6d8c1a56207dbdf330d9047f6d30b70e5e"},
+ {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cdc62c8286ba9bf7f47befdcea13ea0e26bf294bda99758fd90535cbaf408000"},
+ {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47f9a91efc418b54fb8190a6b4aa7813a23fb79c51f4bb84e418f5476c38b8db"},
+ {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1f3587eb9b17f3789ad50824084fa6f81921bbf9a795826570bda82cb3ed91f2"},
+ {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:39c02563fc592411c2c61d26b6c5fe1e51eaa44a75aa2c8735ca88b0d9599daa"},
+ {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51a1234d8febafdfd33a42d97da7a43f5dcb120c1060e352a3fbc0c6d36e2083"},
+ {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:eb2c4071ab598733724c08221091e8d80e89064cd472819285a9ab0f24bcedb9"},
+ {file = "rpds_py-0.30.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6bdfdb946967d816e6adf9a3d8201bfad269c67efe6cefd7093ef959683c8de0"},
+ {file = "rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:c77afbd5f5250bf27bf516c7c4a016813eb2d3e116139aed0096940c5982da94"},
+ {file = "rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:61046904275472a76c8c90c9ccee9013d70a6d0f73eecefd38c1ae7c39045a08"},
+ {file = "rpds_py-0.30.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c5f36a861bc4b7da6516dbdf302c55313afa09b81931e8280361a4f6c9a2d27"},
+ {file = "rpds_py-0.30.0-cp313-cp313t-win32.whl", hash = "sha256:3d4a69de7a3e50ffc214ae16d79d8fbb0922972da0356dcf4d0fdca2878559c6"},
+ {file = "rpds_py-0.30.0-cp313-cp313t-win_amd64.whl", hash = "sha256:f14fc5df50a716f7ece6a80b6c78bb35ea2ca47c499e422aa4463455dd96d56d"},
+ {file = "rpds_py-0.30.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:68f19c879420aa08f61203801423f6cd5ac5f0ac4ac82a2368a9fcd6a9a075e0"},
+ {file = "rpds_py-0.30.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ec7c4490c672c1a0389d319b3a9cfcd098dcdc4783991553c332a15acf7249be"},
+ {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f251c812357a3fed308d684a5079ddfb9d933860fc6de89f2b7ab00da481e65f"},
+ {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac98b175585ecf4c0348fd7b29c3864bda53b805c773cbf7bfdaffc8070c976f"},
+ {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3e62880792319dbeb7eb866547f2e35973289e7d5696c6e295476448f5b63c87"},
+ {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e7fc54e0900ab35d041b0601431b0a0eb495f0851a0639b6ef90f7741b39a18"},
+ {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47e77dc9822d3ad616c3d5759ea5631a75e5809d5a28707744ef79d7a1bcfcad"},
+ {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b4dc1a6ff022ff85ecafef7979a2c6eb423430e05f1165d6688234e62ba99a07"},
+ {file = "rpds_py-0.30.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4559c972db3a360808309e06a74628b95eaccbf961c335c8fe0d590cf587456f"},
+ {file = "rpds_py-0.30.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0ed177ed9bded28f8deb6ab40c183cd1192aa0de40c12f38be4d59cd33cb5c65"},
+ {file = "rpds_py-0.30.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ad1fa8db769b76ea911cb4e10f049d80bf518c104f15b3edb2371cc65375c46f"},
+ {file = "rpds_py-0.30.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:46e83c697b1f1c72b50e5ee5adb4353eef7406fb3f2043d64c33f20ad1c2fc53"},
+ {file = "rpds_py-0.30.0-cp314-cp314-win32.whl", hash = "sha256:ee454b2a007d57363c2dfd5b6ca4a5d7e2c518938f8ed3b706e37e5d470801ed"},
+ {file = "rpds_py-0.30.0-cp314-cp314-win_amd64.whl", hash = "sha256:95f0802447ac2d10bcc69f6dc28fe95fdf17940367b21d34e34c737870758950"},
+ {file = "rpds_py-0.30.0-cp314-cp314-win_arm64.whl", hash = "sha256:613aa4771c99f03346e54c3f038e4cc574ac09a3ddfb0e8878487335e96dead6"},
+ {file = "rpds_py-0.30.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7e6ecfcb62edfd632e56983964e6884851786443739dbfe3582947e87274f7cb"},
+ {file = "rpds_py-0.30.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a1d0bc22a7cdc173fedebb73ef81e07faef93692b8c1ad3733b67e31e1b6e1b8"},
+ {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d08f00679177226c4cb8c5265012eea897c8ca3b93f429e546600c971bcbae7"},
+ {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5965af57d5848192c13534f90f9dd16464f3c37aaf166cc1da1cae1fd5a34898"},
+ {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a4e86e34e9ab6b667c27f3211ca48f73dba7cd3d90f8d5b11be56e5dbc3fb4e"},
+ {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e5d3e6b26f2c785d65cc25ef1e5267ccbe1b069c5c21b8cc724efee290554419"},
+ {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:626a7433c34566535b6e56a1b39a7b17ba961e97ce3b80ec62e6f1312c025551"},
+ {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:acd7eb3f4471577b9b5a41baf02a978e8bdeb08b4b355273994f8b87032000a8"},
+ {file = "rpds_py-0.30.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe5fa731a1fa8a0a56b0977413f8cacac1768dad38d16b3a296712709476fbd5"},
+ {file = "rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:74a3243a411126362712ee1524dfc90c650a503502f135d54d1b352bd01f2404"},
+ {file = "rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3e8eeb0544f2eb0d2581774be4c3410356eba189529a6b3e36bbbf9696175856"},
+ {file = "rpds_py-0.30.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dbd936cde57abfee19ab3213cf9c26be06d60750e60a8e4dd85d1ab12c8b1f40"},
+ {file = "rpds_py-0.30.0-cp314-cp314t-win32.whl", hash = "sha256:dc824125c72246d924f7f796b4f63c1e9dc810c7d9e2355864b3c3a73d59ade0"},
+ {file = "rpds_py-0.30.0-cp314-cp314t-win_amd64.whl", hash = "sha256:27f4b0e92de5bfbc6f86e43959e6edd1425c33b5e69aab0984a72047f2bcf1e3"},
+ {file = "rpds_py-0.30.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c2262bdba0ad4fc6fb5545660673925c2d2a5d9e2e0fb603aad545427be0fc58"},
+ {file = "rpds_py-0.30.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ee6af14263f25eedc3bb918a3c04245106a42dfd4f5c2285ea6f997b1fc3f89a"},
+ {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3adbb8179ce342d235c31ab8ec511e66c73faa27a47e076ccc92421add53e2bb"},
+ {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:250fa00e9543ac9b97ac258bd37367ff5256666122c2d0f2bc97577c60a1818c"},
+ {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9854cf4f488b3d57b9aaeb105f06d78e5529d3145b1e4a41750167e8c213c6d3"},
+ {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:993914b8e560023bc0a8bf742c5f303551992dcb85e247b1e5c7f4a7d145bda5"},
+ {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58edca431fb9b29950807e301826586e5bbf24163677732429770a697ffe6738"},
+ {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:dea5b552272a944763b34394d04577cf0f9bd013207bc32323b5a89a53cf9c2f"},
+ {file = "rpds_py-0.30.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ba3af48635eb83d03f6c9735dfb21785303e73d22ad03d489e88adae6eab8877"},
+ {file = "rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:dff13836529b921e22f15cb099751209a60009731a68519630a24d61f0b1b30a"},
+ {file = "rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:1b151685b23929ab7beec71080a8889d4d6d9fa9a983d213f07121205d48e2c4"},
+ {file = "rpds_py-0.30.0-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:ac37f9f516c51e5753f27dfdef11a88330f04de2d564be3991384b2f3535d02e"},
+ {file = "rpds_py-0.30.0.tar.gz", hash = "sha256:dd8ff7cf90014af0c0f787eea34794ebf6415242ee1d6fa91eaba725cc441e84"},
+]
[[package]]
name = "tomli"
-version = "2.3.0"
+version = "2.4.1"
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"},
+ {file = "tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30"},
+ {file = "tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a"},
+ {file = "tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076"},
+ {file = "tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9"},
+ {file = "tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c"},
+ {file = "tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc"},
+ {file = "tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049"},
+ {file = "tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e"},
+ {file = "tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece"},
+ {file = "tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a"},
+ {file = "tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085"},
+ {file = "tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9"},
+ {file = "tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5"},
+ {file = "tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585"},
+ {file = "tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1"},
+ {file = "tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917"},
+ {file = "tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9"},
+ {file = "tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257"},
+ {file = "tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54"},
+ {file = "tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a"},
+ {file = "tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897"},
+ {file = "tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f"},
+ {file = "tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d"},
+ {file = "tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5"},
+ {file = "tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd"},
+ {file = "tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36"},
+ {file = "tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd"},
+ {file = "tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf"},
+ {file = "tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac"},
+ {file = "tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662"},
+ {file = "tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853"},
+ {file = "tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15"},
+ {file = "tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba"},
+ {file = "tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6"},
+ {file = "tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7"},
+ {file = "tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232"},
+ {file = "tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4"},
+ {file = "tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c"},
+ {file = "tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d"},
+ {file = "tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41"},
+ {file = "tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c"},
+ {file = "tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f"},
+ {file = "tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8"},
+ {file = "tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26"},
+ {file = "tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396"},
+ {file = "tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe"},
+ {file = "tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f"},
]
[[package]]
@@ -905,13 +1109,13 @@ files = [
[[package]]
name = "urllib3"
-version = "2.6.2"
+version = "2.7.0"
description = "HTTP library with thread-safe connection pooling, file post, and more."
optional = false
-python-versions = ">=3.9"
+python-versions = ">=3.10"
files = [
- {file = "urllib3-2.6.2-py3-none-any.whl", hash = "sha256:ec21cddfe7724fc7cb4ba4bea7aa8e2ef36f607a4bab81aa6ce42a13dc3f03dd"},
- {file = "urllib3-2.6.2.tar.gz", hash = "sha256:016f9c98bb7e98085cb2b4b17b87d2c702975664e4f060c6532e64d1c1a5e797"},
+ {file = "urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897"},
+ {file = "urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c"},
]
[package.extras]
@@ -923,4 +1127,4 @@ zstd = ["backports-zstd (>=1.0.0)"]
[metadata]
lock-version = "2.0"
python-versions = "^3.10"
-content-hash = "20623104a1a5f4c6d4aaa759f25b2591d5de345d1464e727eb4140a6ef9a5b6e"
+content-hash = "30e16396439f2cdd69005a5b7bdf8144aac33422a77a63accbc9eaa74151d851"
diff --git a/pyproject.toml b/pyproject.toml
index 72dd732..a7a83d0 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,17 +1,21 @@
[tool.poetry]
name = "enroll"
-version = "0.2.2"
+version = "0.6.0"
description = "Enroll a server's running state retrospectively into Ansible"
authors = ["Miguel Jacq "]
license = "GPL-3.0-or-later"
readme = "README.md"
packages = [{ include = "enroll" }]
repository = "https://git.mig5.net/mig5/enroll"
+include = [
+ { path = "enroll/schema/state.schema.json", format = ["sdist", "wheel"] }
+]
[tool.poetry.dependencies]
python = "^3.10"
pyyaml = "^6"
paramiko = ">=3.5"
+jsonschema = "^4.23.0"
[tool.poetry.scripts]
enroll = "enroll.cli:main"
diff --git a/release.sh b/release.sh
index db3f27b..d8454a2 100755
--- a/release.sh
+++ b/release.sh
@@ -46,7 +46,7 @@ done
# RPM
sudo apt-get -y install createrepo-c rpm
BUILD_OUTPUT="${HOME}/git/enroll/dist"
-KEYID="00AE817C24A10C2540461A9C1D7CDE0234DB458D"
+KEYID="54A91143AE0AB4F7743B01FE888ED1B423A3BC99"
REPO_ROOT="${HOME}/git/repo_rpm"
REMOTE="letessier.mig5.net:/opt/repo_rpm"
@@ -57,7 +57,7 @@ DISTS=(
for dist in ${DISTS[@]}; do
release=$(echo ${dist} | cut -d: -f2)
- REPO_RELEASE_ROOT="${REPO_ROOT}/fc${release}"
+ REPO_RELEASE_ROOT="${REPO_ROOT}/${release}"
RPM_REPO="${REPO_RELEASE_ROOT}/rpm/x86_64"
mkdir -p "$RPM_REPO"
@@ -69,7 +69,10 @@ for dist in ${DISTS[@]}; do
--build-arg BASE_IMAGE=${dist} \
.
- docker run --rm -v "$PWD":/src -v "$PWD/dist/rpm":/out -v "$HOME/git/jinjaturtle/dist/rpm":/deps:ro enroll-rpm:${release}
+ rm -rf "$PWD/dist/rpm"/*
+ mkdir -p "$PWD/dist/rpm"
+
+ docker run --rm -v "$PWD":/src -v "$PWD/dist/rpm":/out enroll-rpm:${release}
sudo chown -R "${USER}" "$PWD/dist"
for file in `ls -1 "${BUILD_OUTPUT}/rpm"`; do
diff --git a/rpm/enroll.spec b/rpm/enroll.spec
index 3ad0bf9..0e83c84 100644
--- a/rpm/enroll.spec
+++ b/rpm/enroll.spec
@@ -1,4 +1,4 @@
-%global upstream_version 0.2.2
+%global upstream_version 0.6.0
Name: enroll
Version: %{upstream_version}
@@ -17,8 +17,8 @@ BuildRequires: python3-poetry-core
Requires: python3-yaml
Requires: python3-paramiko
+Requires: python3-jsonschema
-# Make sure private repo dependency is pulled in by package name as well.
Recommends: jinjaturtle
%description
@@ -43,6 +43,37 @@ Enroll a server's running state retrospectively into Ansible.
%{_bindir}/enroll
%changelog
+* Thu May 14 2026 Miguel Jacq - %{version}-%{release}
+- Add support for capturing ipset and iptables configuration files
+- Add support for generating ipset and iptables configuration files from runtime, if the former weren't present ('firewall_runtime' role)
+* Tue May 12 2026 Miguel Jacq - %{version}-%{release}
+- Add ssh config support where JinjaTurtle is used
+* Tue Feb 16 2026 Miguel Jacq - %{version}-%{release}
+- Add capability to handle passphrases on encrypted SSH private keys. Prompting can be forced with `--ask-key-passphrase` or automated (e.g for CI) with `--ssh-key-passphrase env SOMEVAR`
+* Fri Jan 16 2026 Miguel Jacq - %{version}-%{release}
+- Add support for AddressFamily and ConnectTimeout in the .ssh/config when using `--remote-ssh-config`.
+* Tue Jan 13 2026 Miguel Jacq - %{version}-%{release}
+- Support `--remote-ssh-config [path-to-ssh-config]` as an argument in case extra params are required beyond `--remote-port` or `--remote-user`. Note: `--remote-host` must still be s
+et, but it can be an 'alias' represented by the 'Host' value in the ssh config.
+* Sun Jan 11 2026 Miguel Jacq - %{version}-%{release}
+- Add interactive output when 'enroll diff --enforce' is invoking Ansible.
+* Sat Jan 10 2026 Miguel Jacq - %{version}-%{release}
+- Introduce `enroll validate` - a tool to validate a harvest against the state schema, or check for missing or orphaned obsolete artifacts in a harvest.
+- Attempt to generate Jinja2 templates of systemd unit files and Postfix main.cf (now that JinjaTurtle supports it)
+- Update pynacl dependency to resolve CVE-2025-69277
+- Add `--exclude-path` to `enroll diff` command, so that you can ignore certain churn from the diff (stuff you still wanted to harvest as a baseline but don't care if it changes day to day)
+- Add `--ignore-package-versions` to `enroll diff` command, to optionally ignore package upgrades (e.g due to patching) from the diff.
+- Add tags to the playbook for each role, to allow easier targeting of specific roles during play later.
+- Add `--enforce` mode to `enroll diff`. If there is diff detected between the two harvests, and it can enforce restoring the state from the older harvest, it will manifest the state and apply it with ansible.
+Only the specific roles that had diffed will be applied (via the new tags capability)
+* Mon Jan 05 2026 Miguel Jacq - %{version}-%{release}
+- Introduce `enroll explain` - a tool to analyze and explain what's in (or not in) a harvest and why.
+- Centralise the cron and logrotate stuff into their respective roles, we had a bit of duplication between roles based on harvest discovery.
+- Capture other files in the user's home directory such as `.bashrc`, `.bash_aliases`, `.profile`, if these files differ from the `/etc/skel` defaults
+- Ignore files that end with a tilde or - (probably backup files generated by editors or shadow file changes)
+- Manage certain symlinks e.g for apache2/nginx sites-enabled and so on
+* Sun Jan 04 2026 Miguel Jacq - %{version}-%{release}
+- Introduce --ask-become-pass or -K to support password-required sudo on remote hosts, just like Ansible. It will also fall back to this prompt if a password is required but the arg wasn't passed in.
* Sat Jan 03 2026 Miguel Jacq - %{version}-%{release}
- Fix stat() of parent directory so that we set directory perms correct on --include paths.
- Set pty for remote calls when sudo is required, to help systems with limits on sudo without pty
diff --git a/tests.sh b/tests.sh
index 6becc39..126a87b 100755
--- a/tests.sh
+++ b/tests.sh
@@ -9,14 +9,43 @@ BUNDLE_DIR="/tmp/bundle"
ANSIBLE_DIR="/tmp/ansible"
rm -rf "${BUNDLE_DIR}" "${ANSIBLE_DIR}"
+# Install something that has symlinks like apache2,
+# to extend the manifests that will be linted later
+DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends apache2
+
# Generate data
poetry run \
enroll single-shot \
--harvest "${BUNDLE_DIR}" \
--out "${ANSIBLE_DIR}"
-builtin cd "${ANSIBLE_DIR}"
+# Analyse
+poetry run \
+ enroll explain "${BUNDLE_DIR}"
+poetry run \
+ enroll explain "${BUNDLE_DIR}" --format json | jq
+# Validate
+poetry run \
+ enroll validate --fail-on-warnings "${BUNDLE_DIR}"
+
+# Install/remove something, harvest again and diff the harvests
+DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends cowsay
+poetry run \
+ enroll harvest --out "${BUNDLE_DIR}2"
+# Validate
+poetry run \
+ enroll validate --fail-on-warnings "${BUNDLE_DIR}2"
+# Diff
+poetry run \
+ enroll diff \
+ --old "${BUNDLE_DIR}" \
+ --new "${BUNDLE_DIR}2" \
+ --format json | jq
+DEBIAN_FRONTEND=noninteractive apt-get remove -y --purge cowsay
+
+# Ansible test
+builtin cd "${ANSIBLE_DIR}"
# Lint
ansible-lint "${ANSIBLE_DIR}"
diff --git a/tests/test_accounts.py b/tests/test_accounts.py
index d5cc267..36e5af9 100644
--- a/tests/test_accounts.py
+++ b/tests/test_accounts.py
@@ -141,3 +141,174 @@ def test_collect_non_system_users(monkeypatch, tmp_path: Path):
assert u.primary_group == "users"
assert u.supplementary_groups == ["admins"]
assert u.ssh_files == ["/home/alice/.ssh/authorized_keys"]
+
+
+def test_parse_login_defs_file_not_found(tmp_path: Path):
+ from enroll.accounts import parse_login_defs
+
+ nonexistent = tmp_path / "nonexistent" / "login.defs"
+ vals = parse_login_defs(str(nonexistent))
+ assert vals == {}
+
+
+def test_parse_login_defs_handles_invalid_numbers(tmp_path: Path):
+ from enroll.accounts import parse_login_defs
+
+ p = tmp_path / "login.defs"
+ p.write_text("UID_MIN not_a_number\nUID_MAX 60000\n", encoding="utf-8")
+ vals = parse_login_defs(str(p))
+ assert "UID_MIN" not in vals
+ assert vals["UID_MAX"] == 60000
+
+
+def test_parse_group_handles_invalid_gid(tmp_path: Path):
+ from enroll.accounts import parse_group
+
+ p = tmp_path / "group"
+ p.write_text(
+ "valid:x:1000:user1\n" "invalid_gid:x:notanint:user2\n",
+ encoding="utf-8",
+ )
+ gid_to_name, name_to_gid, members = parse_group(str(p))
+ assert 1000 in gid_to_name
+ assert gid_to_name[1000] == "valid"
+ assert "invalid_gid" not in name_to_gid
+
+
+def test_parse_group_line_too_short(tmp_path: Path):
+ from enroll.accounts import parse_group
+
+ p = tmp_path / "group"
+ p.write_text(
+ "valid:x:1000:user1\n" "shortline:x:1001\n",
+ encoding="utf-8",
+ )
+ gid_to_name, name_to_gid, members = parse_group(str(p))
+ assert 1000 in gid_to_name
+ assert 1001 not in gid_to_name
+
+
+def test_is_human_user_filters_by_uid_and_shell():
+ from enroll.accounts import is_human_user
+
+ assert is_human_user(1000, "/bin/bash", 1000) is True
+ assert is_human_user(999, "/bin/bash", 1000) is False
+ assert is_human_user(1000, "/usr/sbin/nologin", 1000) is False
+ assert is_human_user(1000, "/usr/bin/nologin", 1000) is False
+ assert is_human_user(1000, "/bin/false", 1000) is False
+ assert is_human_user(1000, "", 1000) is True
+
+
+def test_find_user_ssh_files_no_ssh_dir(tmp_path: Path):
+ from enroll.accounts import find_user_ssh_files
+
+ home = tmp_path / "home" / "user"
+ home.mkdir(parents=True)
+ assert find_user_ssh_files(str(home)) == []
+
+
+def test_find_user_ssh_files_ignores_symlink(tmp_path: Path):
+ from enroll.accounts import find_user_ssh_files
+
+ home = tmp_path / "home" / "user"
+ sshdir = home / ".ssh"
+ sshdir.mkdir(parents=True)
+ target = sshdir / "real_file"
+ target.write_text("x", encoding="utf-8")
+ os.symlink(str(target), str(sshdir / "authorized_keys"))
+
+ result = find_user_ssh_files(str(home))
+ assert result == []
+
+
+def test_find_user_ssh_files_handles_home_not_starting_with_slash():
+ from enroll.accounts import find_user_ssh_files
+
+ assert find_user_ssh_files("relative/path") == []
+ assert find_user_ssh_files("") == []
+
+
+def test_collect_non_system_users_skips_nologin_users(tmp_path: Path):
+ import enroll.accounts as a
+
+ orig_parse_login_defs = a.parse_login_defs
+ orig_parse_passwd = a.parse_passwd
+ orig_parse_group = a.parse_group
+
+ passwd = tmp_path / "passwd"
+ passwd.write_text(
+ "root:x:0:0:root:/root:/bin/bash\n"
+ "alice:x:1000:1000:Alice:/home/alice:/bin/bash\n"
+ "nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin\n"
+ "sysuser:x:100:100:Sys:/home/sys:/bin/bash\n",
+ encoding="utf-8",
+ )
+ group = tmp_path / "group"
+ group.write_text("users:x:1000:alice\n", encoding="utf-8")
+ defs = tmp_path / "login.defs"
+ defs.write_text("UID_MIN 1000\n", encoding="utf-8")
+
+ monkeypatch_wrapper = lambda fn, p: lambda path=str(p): fn(path)
+
+ a.parse_login_defs = monkeypatch_wrapper(orig_parse_login_defs, defs)
+ a.parse_passwd = monkeypatch_wrapper(orig_parse_passwd, passwd)
+ a.parse_group = monkeypatch_wrapper(orig_parse_group, group)
+ a.find_user_ssh_files = lambda home: []
+
+ users = a.collect_non_system_users()
+ assert [u.name for u in users] == ["alice"]
+
+
+def test_collect_non_system_users_skips_below_uid_min(tmp_path: Path):
+ import enroll.accounts as a
+
+ orig_parse_login_defs = a.parse_login_defs
+ orig_parse_passwd = a.parse_passwd
+ orig_parse_group = a.parse_group
+
+ passwd = tmp_path / "passwd"
+ passwd.write_text(
+ "root:x:0:0:root:/root:/bin/bash\n"
+ "sysuser:x:999:999:Sys:/home/sys:/bin/bash\n"
+ "alice:x:1000:1000:Alice:/home/alice:/bin/bash\n",
+ encoding="utf-8",
+ )
+ group = tmp_path / "group"
+ group.write_text("users:x:1000:alice\n", encoding="utf-8")
+ defs = tmp_path / "login.defs"
+ defs.write_text("UID_MIN 1000\n", encoding="utf-8")
+
+ a.parse_login_defs = lambda path=str(defs): orig_parse_login_defs(path)
+ a.parse_passwd = lambda path=str(passwd): orig_parse_passwd(path)
+ a.parse_group = lambda path=str(group): orig_parse_group(path)
+ a.find_user_ssh_files = lambda home: []
+
+ users = a.collect_non_system_users()
+ assert [u.name for u in users] == ["alice"]
+
+
+def test_parse_group_handles_empty_lines(tmp_path: Path):
+ from enroll.accounts import parse_group
+
+ p = tmp_path / "group"
+ p.write_text(
+ "valid:x:1000:user1\n" "\n" "another:x:1001:user2\n",
+ encoding="utf-8",
+ )
+ gid_to_name, name_to_gid, members = parse_group(str(p))
+ assert 1000 in gid_to_name
+ assert 1001 in gid_to_name
+
+
+def test_parse_group_handles_short_lines(tmp_path: Path):
+ from enroll.accounts import parse_group
+
+ p = tmp_path / "group"
+ p.write_text(
+ "valid:x:1000:user1\n" "short:x:1001\n" "another:x:1002:user2\n",
+ encoding="utf-8",
+ )
+ gid_to_name, name_to_gid, members = parse_group(str(p))
+ assert 1000 in gid_to_name
+ assert 1001 not in gid_to_name # skipped due to short line
+ assert 1002 in gid_to_name
diff --git a/tests/test_cache_security.py b/tests/test_cache_security.py
new file mode 100644
index 0000000..4fda1e1
--- /dev/null
+++ b/tests/test_cache_security.py
@@ -0,0 +1,97 @@
+from __future__ import annotations
+
+import os
+from pathlib import Path
+
+import pytest
+
+
+def test_ensure_dir_secure_refuses_symlink(tmp_path: Path):
+ from enroll.cache import _ensure_dir_secure
+
+ target = tmp_path / "target"
+ target.mkdir()
+ link = tmp_path / "link"
+ link.symlink_to(target, target_is_directory=True)
+
+ with pytest.raises(RuntimeError):
+ _ensure_dir_secure(link)
+
+
+def test_ensure_dir_secure_ignores_chmod_failures(tmp_path: Path, monkeypatch):
+ from enroll.cache import _ensure_dir_secure
+
+ d = tmp_path / "d"
+
+ def boom(_path: str, _mode: int):
+ raise OSError("no")
+
+ monkeypatch.setattr(os, "chmod", boom)
+
+ # Should not raise.
+ _ensure_dir_secure(d)
+ assert d.exists() and d.is_dir()
+
+
+def test_safe_component_returns_unknown_for_empty_string():
+ from enroll.cache import _safe_component
+
+ assert _safe_component("") == "unknown"
+ assert _safe_component(" ") == "unknown"
+
+
+def test_safe_component_truncates_long_strings():
+ from enroll.cache import _safe_component
+
+ long_str = "a" * 100
+ result = _safe_component(long_str)
+ assert len(result) <= 64
+
+
+def test_safe_component_replaces_special_chars():
+ from enroll.cache import _safe_component
+
+ result = _safe_component("hello world!")
+ assert result == "hello_world_"
+
+
+def test_enroll_cache_dir_uses_xdg_cache_home(monkeypatch):
+ from enroll.cache import enroll_cache_dir
+
+ monkeypatch.setenv("XDG_CACHE_HOME", "/custom/cache")
+ result = enroll_cache_dir()
+ assert str(result) == "/custom/cache/enroll"
+
+
+def test_harvest_cache_state_json_property():
+ from enroll.cache import HarvestCache
+
+ cache_dir = HarvestCache(dir=Path("/tmp/test"))
+ assert cache_dir.state_json == Path("/tmp/test/state.json")
+
+
+def test_new_harvest_cache_dir_chmod_fails(tmp_path: Path, monkeypatch):
+ from enroll.cache import new_harvest_cache_dir
+
+ def fake_enroll_cache_dir():
+ return tmp_path / "enroll"
+
+ def fake_chmod(path, mode):
+ raise OSError("no")
+
+ monkeypatch.setattr("enroll.cache.enroll_cache_dir", fake_enroll_cache_dir)
+ monkeypatch.setattr(os, "chmod", fake_chmod)
+
+ # Should not raise even though chmod fails
+ cache = new_harvest_cache_dir(hint="test")
+ assert cache.dir.exists()
+ assert isinstance(cache.dir, Path)
+
+
+def test_enroll_cache_dir_uses_default_when_xdg_not_set(monkeypatch):
+ from enroll.cache import enroll_cache_dir
+
+ # Remove XDG_CACHE_HOME if it exists
+ monkeypatch.delenv("XDG_CACHE_HOME", raising=False)
+ result = enroll_cache_dir()
+ assert str(result).endswith("/.local/cache/enroll")
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 4477b24..dcdb6a7 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -1,7 +1,14 @@
+from __future__ import annotations
import sys
+import pytest
import enroll.cli as cli
+from pathlib import Path
+
+from enroll.remote import RemoteSudoPasswordRequired
+from enroll.sopsutil import SopsError
+
def test_cli_harvest_subcommand_calls_harvest(monkeypatch, capsys, tmp_path):
called = {}
@@ -258,6 +265,113 @@ def test_cli_single_shot_remote_without_harvest_prints_state_path(
assert ("manifest", str(cache_dir), str(ansible_dir), "example.test") in calls
+def test_cli_harvest_remote_ask_become_pass_prompts_and_passes_password(
+ monkeypatch, tmp_path
+):
+ from enroll.cache import HarvestCache
+ import enroll.remote as r
+
+ cache_dir = tmp_path / "cache"
+ cache_dir.mkdir()
+
+ called = {}
+
+ def fake_cache_dir(*, hint=None):
+ return HarvestCache(dir=cache_dir)
+
+ def fake__remote_harvest(*, sudo_password=None, **kwargs):
+ called["sudo_password"] = sudo_password
+ return cache_dir / "state.json"
+
+ monkeypatch.setattr(cli, "new_harvest_cache_dir", fake_cache_dir)
+ monkeypatch.setattr(r, "_remote_harvest", fake__remote_harvest)
+ monkeypatch.setattr(r.getpass, "getpass", lambda _prompt="": "pw123")
+
+ monkeypatch.setattr(
+ sys,
+ "argv",
+ [
+ "enroll",
+ "harvest",
+ "--remote-host",
+ "example.test",
+ "--ask-become-pass",
+ ],
+ )
+
+ cli.main()
+ assert called["sudo_password"] == "pw123"
+
+
+def test_cli_harvest_remote_password_required_fallback_prompts_and_retries(
+ monkeypatch, tmp_path
+):
+ from enroll.cache import HarvestCache
+ import enroll.remote as r
+
+ cache_dir = tmp_path / "cache"
+ cache_dir.mkdir()
+
+ def fake_cache_dir(*, hint=None):
+ return HarvestCache(dir=cache_dir)
+
+ calls = []
+
+ def fake__remote_harvest(*, sudo_password=None, **kwargs):
+ calls.append(sudo_password)
+ if sudo_password is None:
+ raise r.RemoteSudoPasswordRequired("pw required")
+ return cache_dir / "state.json"
+
+ class _TTYStdin:
+ def isatty(self):
+ return True
+
+ monkeypatch.setattr(cli, "new_harvest_cache_dir", fake_cache_dir)
+ monkeypatch.setattr(r, "_remote_harvest", fake__remote_harvest)
+ monkeypatch.setattr(r.getpass, "getpass", lambda _prompt="": "pw456")
+ monkeypatch.setattr(sys, "stdin", _TTYStdin())
+
+ monkeypatch.setattr(
+ sys, "argv", ["enroll", "harvest", "--remote-host", "example.test"]
+ )
+
+ cli.main()
+ assert calls == [None, "pw456"]
+
+
+def test_cli_harvest_remote_password_required_noninteractive_errors(
+ monkeypatch, tmp_path
+):
+ from enroll.cache import HarvestCache
+ import enroll.remote as r
+
+ cache_dir = tmp_path / "cache"
+ cache_dir.mkdir()
+
+ def fake_cache_dir(*, hint=None):
+ return HarvestCache(dir=cache_dir)
+
+ def fake__remote_harvest(*, sudo_password=None, **kwargs):
+ raise r.RemoteSudoPasswordRequired("pw required")
+
+ class _NoTTYStdin:
+ def isatty(self):
+ return False
+
+ monkeypatch.setattr(cli, "new_harvest_cache_dir", fake_cache_dir)
+ monkeypatch.setattr(r, "_remote_harvest", fake__remote_harvest)
+ monkeypatch.setattr(sys, "stdin", _NoTTYStdin())
+
+ monkeypatch.setattr(
+ sys, "argv", ["enroll", "harvest", "--remote-host", "example.test"]
+ )
+
+ with pytest.raises(SystemExit) as e:
+ cli.main()
+ assert "--ask-become-pass" in str(e.value)
+
+
def test_cli_manifest_common_args(monkeypatch, tmp_path):
"""Ensure --fqdn and jinjaturtle mode flags are forwarded correctly."""
@@ -289,3 +403,286 @@ def test_cli_manifest_common_args(monkeypatch, tmp_path):
cli.main()
assert called["fqdn"] == "example.test"
assert called["jinjaturtle"] == "off"
+
+
+def test_cli_explain_passes_args_and_writes_stdout(monkeypatch, capsys, tmp_path):
+ called = {}
+
+ def fake_explain_state(
+ harvest: str,
+ *,
+ sops_mode: bool = False,
+ fmt: str = "text",
+ max_examples: int = 3,
+ ):
+ called["harvest"] = harvest
+ called["sops_mode"] = sops_mode
+ called["fmt"] = fmt
+ called["max_examples"] = max_examples
+ return "EXPLAINED\n"
+
+ monkeypatch.setattr(cli, "explain_state", fake_explain_state)
+
+ monkeypatch.setattr(
+ sys,
+ "argv",
+ [
+ "enroll",
+ "explain",
+ "--sops",
+ "--format",
+ "json",
+ "--max-examples",
+ "7",
+ str(tmp_path / "bundle" / "state.json"),
+ ],
+ )
+
+ cli.main()
+ out = capsys.readouterr().out
+ assert out == "EXPLAINED\n"
+ assert called["sops_mode"] is True
+ assert called["fmt"] == "json"
+ assert called["max_examples"] == 7
+
+
+def test_discover_config_path_missing_config_value_returns_none(monkeypatch):
+ # Covers the "--config" flag present with no value.
+ monkeypatch.delenv("ENROLL_CONFIG", raising=False)
+ monkeypatch.delenv("XDG_CONFIG_HOME", raising=False)
+ assert cli._discover_config_path(["--config"]) is None
+
+
+def test_discover_config_path_defaults_to_home_config(monkeypatch, tmp_path: Path):
+ # Covers the Path.home() / ".config" fallback.
+ monkeypatch.delenv("ENROLL_CONFIG", raising=False)
+ monkeypatch.delenv("XDG_CONFIG_HOME", raising=False)
+ monkeypatch.setattr(cli.Path, "home", lambda: tmp_path)
+ monkeypatch.setattr(cli.Path, "cwd", lambda: tmp_path)
+
+ cp = tmp_path / ".config" / "enroll" / "enroll.ini"
+ cp.parent.mkdir(parents=True)
+ cp.write_text("[enroll]\n", encoding="utf-8")
+
+ assert cli._discover_config_path(["harvest"]) == cp
+
+
+def test_cli_harvest_local_sops_encrypts_and_prints_path(
+ monkeypatch, tmp_path: Path, capsys
+):
+ out_dir = tmp_path / "out"
+ out_dir.mkdir()
+ calls: dict[str, object] = {}
+
+ def fake_harvest(bundle_dir: str, **kwargs):
+ calls["bundle"] = bundle_dir
+ # Create a minimal state.json so tooling that expects it won't break.
+ Path(bundle_dir).mkdir(parents=True, exist_ok=True)
+ (Path(bundle_dir) / "state.json").write_text("{}", encoding="utf-8")
+ return str(Path(bundle_dir) / "state.json")
+
+ def fake_encrypt(bundle_dir: Path, out_file: Path, fps: list[str]):
+ calls["encrypt"] = (bundle_dir, out_file, fps)
+ out_file.write_text("encrypted", encoding="utf-8")
+ return out_file
+
+ monkeypatch.setattr(cli, "harvest", fake_harvest)
+ monkeypatch.setattr(cli, "_encrypt_harvest_dir_to_sops", fake_encrypt)
+
+ monkeypatch.setattr(
+ sys,
+ "argv",
+ [
+ "enroll",
+ "harvest",
+ "--sops",
+ "ABCDEF",
+ "--out",
+ str(out_dir),
+ ],
+ )
+ cli.main()
+
+ printed = capsys.readouterr().out.strip()
+ assert printed.endswith("harvest.tar.gz.sops")
+ assert Path(printed).exists()
+ assert calls.get("encrypt")
+
+
+def test_cli_harvest_remote_sops_encrypts_and_prints_path(
+ monkeypatch, tmp_path: Path, capsys
+):
+ out_dir = tmp_path / "out"
+ out_dir.mkdir()
+ calls: dict[str, object] = {}
+
+ def fake_remote_harvest(**kwargs):
+ calls["remote"] = kwargs
+ # Create a minimal state.json in the temp bundle.
+ out = Path(kwargs["local_out_dir"]) / "state.json"
+ out.write_text("{}", encoding="utf-8")
+ return out
+
+ def fake_encrypt(bundle_dir: Path, out_file: Path, fps: list[str]):
+ calls["encrypt"] = (bundle_dir, out_file, fps)
+ out_file.write_text("encrypted", encoding="utf-8")
+ return out_file
+
+ monkeypatch.setattr(cli, "remote_harvest", fake_remote_harvest)
+ monkeypatch.setattr(cli, "_encrypt_harvest_dir_to_sops", fake_encrypt)
+
+ monkeypatch.setattr(
+ sys,
+ "argv",
+ [
+ "enroll",
+ "harvest",
+ "--remote-host",
+ "example.com",
+ "--remote-user",
+ "root",
+ "--sops",
+ "ABCDEF",
+ "--out",
+ str(out_dir),
+ ],
+ )
+ cli.main()
+
+ printed = capsys.readouterr().out.strip()
+ assert printed.endswith("harvest.tar.gz.sops")
+ assert Path(printed).exists()
+ assert calls.get("remote")
+ assert calls.get("encrypt")
+
+
+def test_cli_harvest_remote_password_required_exits_cleanly(monkeypatch):
+ def boom(**kwargs):
+ raise RemoteSudoPasswordRequired("pw required")
+
+ monkeypatch.setattr(cli, "remote_harvest", boom)
+ monkeypatch.setattr(
+ sys,
+ "argv",
+ [
+ "enroll",
+ "harvest",
+ "--remote-host",
+ "example.com",
+ "--remote-user",
+ "root",
+ ],
+ )
+ with pytest.raises(SystemExit) as e:
+ cli.main()
+ assert "--ask-become-pass" in str(e.value)
+
+
+def test_cli_runtime_error_is_wrapped_as_user_friendly_system_exit(monkeypatch):
+ def boom(*args, **kwargs):
+ raise RuntimeError("nope")
+
+ monkeypatch.setattr(cli, "harvest", boom)
+ monkeypatch.setattr(sys, "argv", ["enroll", "harvest", "--out", "/tmp/x"])
+ with pytest.raises(SystemExit) as e:
+ cli.main()
+ assert str(e.value) == "error: nope"
+
+
+def test_cli_sops_error_is_wrapped_as_user_friendly_system_exit(monkeypatch):
+ def boom(*args, **kwargs):
+ raise SopsError("sops broke")
+
+ monkeypatch.setattr(cli, "manifest", boom)
+ monkeypatch.setattr(
+ sys, "argv", ["enroll", "manifest", "--harvest", "/tmp/x", "--out", "/tmp/y"]
+ )
+ with pytest.raises(SystemExit) as e:
+ cli.main()
+ assert str(e.value) == "error: sops broke"
+
+
+def test_cli_diff_notifies_webhook_and_email_and_respects_exit_code(
+ monkeypatch, capsys
+):
+ calls: dict[str, object] = {}
+
+ def fake_compare(old, new, sops_mode=False, **kwargs):
+ calls["compare"] = (old, new, sops_mode)
+ return {"dummy": True}, True
+
+ def fake_format(report, fmt="text"):
+ calls.setdefault("format", []).append((report, fmt))
+ return "REPORT\n"
+
+ def fake_post(url, body, headers=None):
+ calls["webhook"] = (url, body, headers)
+ return 200, b"ok"
+
+ def fake_email(**kwargs):
+ calls["email"] = kwargs
+
+ monkeypatch.setattr(cli, "compare_harvests", fake_compare)
+ monkeypatch.setattr(cli, "format_report", fake_format)
+ monkeypatch.setattr(cli, "post_webhook", fake_post)
+ monkeypatch.setattr(cli, "send_email", fake_email)
+ monkeypatch.setenv("SMTPPW", "secret")
+
+ monkeypatch.setattr(
+ sys,
+ "argv",
+ [
+ "enroll",
+ "diff",
+ "--old",
+ "/tmp/old",
+ "--new",
+ "/tmp/new",
+ "--webhook",
+ "https://example.invalid/h",
+ "--webhook-header",
+ "X-Test: ok",
+ "--email-to",
+ "a@example.com",
+ "--smtp-password-env",
+ "SMTPPW",
+ "--exit-code",
+ ],
+ )
+
+ with pytest.raises(SystemExit) as e:
+ cli.main()
+ assert e.value.code == 2
+
+ assert calls.get("compare")
+ assert calls.get("webhook")
+ assert calls.get("email")
+ # No report printed when exiting via --exit-code? (we still render and print).
+ _ = capsys.readouterr()
+
+
+def test_cli_diff_webhook_http_error_raises_system_exit(monkeypatch):
+ def fake_compare(old, new, sops_mode=False, **kwargs):
+ return {"dummy": True}, True
+
+ monkeypatch.setattr(cli, "compare_harvests", fake_compare)
+ monkeypatch.setattr(cli, "format_report", lambda report, fmt="text": "R\n")
+ monkeypatch.setattr(cli, "post_webhook", lambda url, body, headers=None: (500, b""))
+
+ monkeypatch.setattr(
+ sys,
+ "argv",
+ [
+ "enroll",
+ "diff",
+ "--old",
+ "/tmp/old",
+ "--new",
+ "/tmp/new",
+ "--webhook",
+ "https://example.invalid/h",
+ ],
+ )
+ with pytest.raises(SystemExit) as e:
+ cli.main()
+ assert "HTTP 500" in str(e.value)
diff --git a/tests/test_cli_helpers.py b/tests/test_cli_helpers.py
new file mode 100644
index 0000000..264ff85
--- /dev/null
+++ b/tests/test_cli_helpers.py
@@ -0,0 +1,177 @@
+from __future__ import annotations
+
+import argparse
+import configparser
+import types
+import textwrap
+from pathlib import Path
+
+
+def test_discover_config_path_precedence(tmp_path: Path, monkeypatch):
+ """_discover_config_path: --config > ENROLL_CONFIG > ./enroll.ini > XDG."""
+ from enroll.cli import _discover_config_path
+
+ cfg1 = tmp_path / "one.ini"
+ cfg1.write_text("[enroll]\n", encoding="utf-8")
+
+ # Explicit --config should win.
+ assert _discover_config_path(["--config", str(cfg1)]) == cfg1
+
+ # --no-config disables config loading.
+ assert _discover_config_path(["--no-config", "--config", str(cfg1)]) is None
+
+ monkeypatch.chdir(tmp_path)
+
+ cfg2 = tmp_path / "two.ini"
+ cfg2.write_text("[enroll]\n", encoding="utf-8")
+ monkeypatch.setenv("ENROLL_CONFIG", str(cfg2))
+ assert _discover_config_path([]) == cfg2
+
+ # Local ./enroll.ini fallback.
+ monkeypatch.delenv("ENROLL_CONFIG", raising=False)
+ local = tmp_path / "enroll.ini"
+ local.write_text("[enroll]\n", encoding="utf-8")
+ assert _discover_config_path([]) == local
+
+ # XDG fallback.
+ local.unlink()
+ xdg = tmp_path / "xdg"
+ cfg3 = xdg / "enroll" / "enroll.ini"
+ cfg3.parent.mkdir(parents=True)
+ cfg3.write_text("[enroll]\n", encoding="utf-8")
+ monkeypatch.setenv("XDG_CONFIG_HOME", str(xdg))
+ assert _discover_config_path([]) == cfg3
+
+
+def test_config_value_parsing_and_list_splitting():
+ from enroll.cli import _parse_bool, _split_list_value
+
+ assert _parse_bool("1") is True
+ assert _parse_bool("yes") is True
+ assert _parse_bool("false") is False
+
+ assert _parse_bool("maybe") is None
+
+ assert _split_list_value("a,b , c") == ["a", "b", "c"]
+ # When newlines are present, we split on lines (not commas within a line).
+ assert _split_list_value("a,b\nc") == ["a,b", "c"]
+ assert _split_list_value("a\n\n b\n") == ["a", "b"]
+ assert _split_list_value(" ") == []
+
+
+def test_section_to_argv_handles_types_and_unknown_keys(capsys):
+ from enroll.cli import _section_to_argv
+
+ p = argparse.ArgumentParser(add_help=False)
+ p.add_argument("--dangerous", action="store_true")
+ p.add_argument("--no-color", dest="color", action="store_false")
+ p.add_argument("--include-path", dest="include_path", action="append")
+ p.add_argument("-v", action="count", default=0)
+ p.add_argument("--out")
+
+ cfg = configparser.ConfigParser()
+ cfg.read_dict(
+ {
+ "harvest": {
+ "dangerous": "true",
+ # Keys are matched by argparse dest; store_false actions still use dest.
+ "color": "false",
+ "include-path": "a,b,c",
+ "v": "2",
+ "out": "/tmp/bundle",
+ "unknown": "ignored",
+ }
+ }
+ )
+
+ argv = _section_to_argv(p, cfg, "harvest")
+
+ # Boolean store_true.
+ assert "--dangerous" in argv
+
+ # Boolean store_false: include the flag only when config wants False.
+ assert "--no-color" in argv
+
+ # Append: split lists and add one flag per item.
+ assert argv.count("--include-path") == 3
+ assert "a" in argv and "b" in argv and "c" in argv
+
+ # Count: repeats.
+ assert argv.count("-v") == 2
+
+ # Scalar.
+ assert "--out" in argv and "/tmp/bundle" in argv
+
+ err = capsys.readouterr().err
+ assert "unknown option" in err
+
+
+def test_inject_config_argv_inserts_global_and_subcommand(tmp_path: Path, capsys):
+ from enroll.cli import _inject_config_argv
+
+ cfg = tmp_path / "enroll.ini"
+ cfg.write_text(
+ textwrap.dedent(
+ """
+ [enroll]
+ dangerous = true
+
+ [harvest]
+ include-path = /etc/foo
+ unknown = 1
+ """
+ ).strip()
+ + "\n",
+ encoding="utf-8",
+ )
+
+ root = argparse.ArgumentParser(add_help=False)
+ root.add_argument("--dangerous", action="store_true")
+
+ harvest_p = argparse.ArgumentParser(add_help=False)
+ harvest_p.add_argument("--include-path", dest="include_path", action="append")
+
+ argv = _inject_config_argv(
+ ["harvest", "--out", "x"],
+ cfg_path=cfg,
+ root_parser=root,
+ subparsers={"harvest": harvest_p},
+ )
+
+ # Global tokens should appear before the subcommand.
+ assert argv[0] == "--dangerous"
+ assert argv[1] == "harvest"
+
+ # Subcommand tokens should appear right after the subcommand.
+ assert argv[2:4] == ["--include-path", "/etc/foo"]
+
+ # Unknown option should have produced a warning.
+ assert "unknown option" in capsys.readouterr().err
+
+
+def test_resolve_sops_out_file(tmp_path: Path, monkeypatch):
+ from enroll import cli
+
+ # Make a predictable cache dir for the default case.
+ fake_cache = types.SimpleNamespace(dir=tmp_path / "cache")
+ fake_cache.dir.mkdir(parents=True)
+ monkeypatch.setattr(cli, "new_harvest_cache_dir", lambda hint=None: fake_cache)
+
+ # If out is a directory, use it directly.
+ out_dir = tmp_path / "out"
+ out_dir.mkdir()
+ # The output filename is fixed; hint is only used when creating a cache dir.
+ assert (
+ cli._resolve_sops_out_file(out=out_dir, hint="bundle.tar.gz")
+ == out_dir / "harvest.tar.gz.sops"
+ )
+
+ # If out is a file path, keep it.
+ out_file = tmp_path / "x.sops"
+ assert cli._resolve_sops_out_file(out=out_file, hint="bundle.tar.gz") == out_file
+
+ # None uses the cache dir, and the name is fixed.
+ assert (
+ cli._resolve_sops_out_file(out=None, hint="bundle.tar.gz")
+ == fake_cache.dir / "harvest.tar.gz.sops"
+ )
diff --git a/tests/test_debian.py b/tests/test_debian.py
index abad361..ed9df7a 100644
--- a/tests/test_debian.py
+++ b/tests/test_debian.py
@@ -1,6 +1,7 @@
from __future__ import annotations
from pathlib import Path
+import pytest
def test_dpkg_owner_parses_output(monkeypatch):
@@ -96,3 +97,441 @@ def test_parse_status_conffiles_handles_continuations(tmp_path: Path):
assert m["nginx"]["/etc/nginx/nginx.conf"] == "abcdef"
assert m["nginx"]["/etc/nginx/mime.types"] == "123456"
assert "other" not in m
+
+
+def test_dpkg_owner_returns_none_on_diversion_only(monkeypatch):
+ import enroll.debian as d
+
+ class P:
+ def __init__(self, rc: int, out: str):
+ self.returncode = rc
+ self.stdout = out
+ self.stderr = ""
+
+ def fake_run(cmd, text, capture_output):
+ return P(0, "diversion by foo from: /etc/something\n")
+
+ monkeypatch.setattr(d.subprocess, "run", fake_run)
+ assert d.dpkg_owner("/etc/something") is None
+
+
+def test_dpkg_owner_handles_line_without_colon(monkeypatch):
+ import enroll.debian as d
+
+ class P:
+ def __init__(self, rc: int, out: str):
+ self.returncode = rc
+ self.stdout = out
+ self.stderr = ""
+
+ def fake_run(cmd, text, capture_output):
+ return P(0, "invalid line without colon\n")
+
+ monkeypatch.setattr(d.subprocess, "run", fake_run)
+ assert d.dpkg_owner("/etc/foo") is None
+
+
+def test_list_manual_packages_returns_empty_on_error(monkeypatch):
+ import enroll.debian as d
+
+ class P:
+ def __init__(self, rc: int, out: str):
+ self.returncode = rc
+ self.stdout = out
+ self.stderr = ""
+
+ def fake_run(cmd, text, capture_output):
+ return P(1, "error")
+
+ monkeypatch.setattr(d.subprocess, "run", fake_run)
+ assert d.list_manual_packages() == []
+
+
+def test_list_installed_packages_handles_exception(monkeypatch):
+ import enroll.debian as d
+
+ def fake_run(*args, **kwargs):
+ raise Exception("simulated error")
+
+ monkeypatch.setattr(d.subprocess, "run", fake_run)
+ assert d.list_installed_packages() == {}
+
+
+def test_list_installed_packages_parses_output():
+ import enroll.debian as d
+
+ class P:
+ def __init__(self, rc: int, out: str):
+ self.returncode = rc
+ self.stdout = out
+ self.stderr = ""
+
+ original_run = d.subprocess.run
+
+ def fake_run(cmd, text, capture_output, check):
+ return P(0, "nginx\t1.18.0\tamd64\nvim\t8.2\tamd64\n")
+
+ d.subprocess.run = fake_run
+ try:
+ result = d.list_installed_packages()
+ assert "nginx" in result
+ assert result["nginx"][0]["version"] == "1.18.0"
+ assert result["nginx"][0]["arch"] == "amd64"
+ assert "vim" in result
+ finally:
+ d.subprocess.run = original_run
+
+
+def test_list_installed_packages_skips_invalid_lines():
+ import enroll.debian as d
+
+ class P:
+ def __init__(self, rc: int, out: str):
+ self.returncode = rc
+ self.stdout = out
+ self.stderr = ""
+
+ original_run = d.subprocess.run
+
+ def fake_run(cmd, text, capture_output, check):
+ return P(0, "nginx\t1.18.0\tamd64\ninvalid_line\n\t1.0\tamd64\n")
+
+ d.subprocess.run = fake_run
+ try:
+ result = d.list_installed_packages()
+ assert "nginx" in result
+ assert "invalid_line" not in result
+ finally:
+ d.subprocess.run = original_run
+
+
+def test_list_installed_packages_handles_empty_name():
+ import enroll.debian as d
+
+ class P:
+ def __init__(self, rc: int, out: str):
+ self.returncode = rc
+ self.stdout = out
+ self.stderr = ""
+
+ original_run = d.subprocess.run
+
+ def fake_run(cmd, text, capture_output, check):
+ return P(0, "\t1.0\tamd64\nnginx\t1.18.0\tamd64\n")
+
+ d.subprocess.run = fake_run
+ try:
+ result = d.list_installed_packages()
+ assert "" not in result
+ assert "nginx" in result
+ finally:
+ d.subprocess.run = original_run
+
+
+def test_list_installed_packages_sorts_output():
+ import enroll.debian as d
+
+ class P:
+ def __init__(self, rc: int, out: str):
+ self.returncode = rc
+ self.stdout = out
+ self.stderr = ""
+
+ original_run = d.subprocess.run
+
+ def fake_run(cmd, text, capture_output, check):
+ return P(0, "nginx\t1.18.0\tamd64\nnginx\t1.19.0\tarm64\n")
+
+ d.subprocess.run = fake_run
+ try:
+ result = d.list_installed_packages()
+ assert len(result["nginx"]) == 2
+ assert result["nginx"][0]["arch"] == "amd64"
+ assert result["nginx"][1]["arch"] == "arm64"
+ finally:
+ d.subprocess.run = original_run
+
+
+def test_build_dpkg_etc_index_handles_missing_file(tmp_path: Path):
+ import enroll.debian as d
+
+ info = tmp_path / "info"
+ info.mkdir()
+ # Don't create any .list files
+
+ owned, owner_map, topdir_to_pkgs, pkg_to_etc = d.build_dpkg_etc_index(str(info))
+ assert owned == set()
+ assert owner_map == {}
+ assert topdir_to_pkgs == {}
+ assert pkg_to_etc == {}
+
+
+def test_build_dpkg_etc_index_skips_non_etc_paths(tmp_path: Path):
+ import enroll.debian as d
+
+ info = tmp_path / "info"
+ info.mkdir()
+ (info / "foo.list").write_text("/usr/bin/foo\n/etc/bar\n", encoding="utf-8")
+
+ owned, owner_map, topdir_to_pkgs, pkg_to_etc = d.build_dpkg_etc_index(str(info))
+ assert "/usr/bin/foo" not in owned
+ assert "/etc/bar" in owned
+ assert "foo" not in topdir_to_pkgs
+
+
+def test_parse_status_conffiles_handles_empty_status(tmp_path: Path):
+ import enroll.debian as d
+
+ status = tmp_path / "status"
+ status.write_text("", encoding="utf-8")
+ m = d.parse_status_conffiles(str(status))
+ assert m == {}
+
+
+def test_parse_status_conffiles_handles_package_without_conffiles(tmp_path: Path):
+ import enroll.debian as d
+
+ status = tmp_path / "status"
+ status.write_text(
+ "Package: nginx\nVersion: 1\nStatus: install ok installed\n",
+ encoding="utf-8",
+ )
+ m = d.parse_status_conffiles(str(status))
+ assert m == {}
+
+
+def test_read_pkg_md5sums_returns_empty_if_file_not_exists(tmp_path: Path):
+ import enroll.debian as d
+
+ result = d.read_pkg_md5sums("nonexistent_package")
+ assert result == {}
+
+
+def test_read_pkg_md5sums_parses_md5sums_file(tmp_path: Path, monkeypatch):
+ import enroll.debian as d
+
+ info_dir = tmp_path / "info"
+ info_dir.mkdir()
+ md5_file = info_dir / "nginx.md5sums"
+ md5_file.write_text(
+ "abcdef1234567890abcdef1234567890 etc/nginx/nginx.conf\n"
+ "1234567890abcdef1234567890abcdef etc/nginx/sites-enabled/default\n",
+ encoding="utf-8",
+ )
+
+ def fake_exists(path):
+ return str(path).endswith("nginx.md5sums")
+
+ monkeypatch.setattr(d.os.path, "exists", fake_exists)
+
+ original_open = open
+
+ def fake_open(path, *args, **kwargs):
+ if "nginx.md5sums" in str(path):
+ return original_open(md5_file, *args, **kwargs)
+ return original_open(path, *args, **kwargs)
+
+ monkeypatch.setattr("builtins.open", fake_open, raising=False)
+
+ result = d.read_pkg_md5sums("nginx")
+ assert result["etc/nginx/nginx.conf"] == "abcdef1234567890abcdef1234567890"
+ assert (
+ result["etc/nginx/sites-enabled/default"] == "1234567890abcdef1234567890abcdef"
+ )
+
+
+def test_dpkg_owner_raises_on_command_failure(monkeypatch):
+ """Test _run raises RuntimeError on non-zero exit."""
+ import enroll.debian as d
+
+ class P:
+ returncode = 1
+ stdout = ""
+ stderr = "command failed"
+
+ def fake_run(cmd, text, capture_output, check=False):
+ return P()
+
+ monkeypatch.setattr(d.subprocess, "run", fake_run)
+
+ with pytest.raises(RuntimeError) as exc_info:
+ d._run(["fake", "command"])
+
+ assert "Command failed" in str(exc_info.value)
+ assert "fake" in str(exc_info.value)
+
+
+def test_build_dpkg_etc_index_skips_invalid_line_formats(tmp_path: Path):
+ """Test that lines with less than 3 parts are skipped."""
+ import enroll.debian as d
+
+ info = tmp_path / "info"
+ info.mkdir()
+ # Create a .list file with invalid format (missing tab-separated fields)
+ (info / "foo.list").write_text(
+ "/etc/foo/bar\n" # This is a path, not a tab-separated line
+ "/etc/foo/baz\n",
+ encoding="utf-8",
+ )
+
+ # Should handle gracefully
+ owned, owner_map, topdir_to_pkgs, pkg_to_etc = d.build_dpkg_etc_index(str(info))
+ # The path lines should be processed normally
+ assert "/etc/foo/bar" in owned or "/etc/foo/baz" in owned
+
+
+def test_build_dpkg_etc_index_handles_file_not_found(tmp_path: Path):
+ """Test that FileNotFoundError is handled gracefully."""
+ import enroll.debian as d
+
+ info = tmp_path / "info"
+ info.mkdir()
+ # Create a .list file that references a non-existent path
+ (info / "foo.list").write_text(
+ "/nonexistent/path\n",
+ encoding="utf-8",
+ )
+
+ # Should not raise
+ owned, owner_map, topdir_to_pkgs, pkg_to_etc = d.build_dpkg_etc_index(str(info))
+ # The non-existent path should be skipped
+ assert "/nonexistent/path" not in owned
+
+
+def test_parse_status_conffiles_skips_empty_lines(tmp_path: Path):
+ """Test that empty lines in conffiles are skipped."""
+ import enroll.debian as d
+
+ status = tmp_path / "status"
+ status.write_text(
+ "Package: nginx\n"
+ "Version: 1\n"
+ "Conffiles:\n"
+ " /etc/nginx/nginx.conf abcdef\n"
+ " /etc/nginx/mime.types 123456\n"
+ "\n", # Empty line to trigger flush
+ encoding="utf-8",
+ )
+
+ m = d.parse_status_conffiles(str(status))
+ assert "/etc/nginx/nginx.conf" in m["nginx"]
+ assert "/etc/nginx/mime.types" in m["nginx"]
+
+
+def test_read_pkg_md5sums_skips_invalid_md5_lines(tmp_path: Path, monkeypatch):
+ """Test that lines without proper MD5 format are skipped."""
+ import enroll.debian as d
+
+ info_dir = tmp_path / "info"
+ info_dir.mkdir()
+ md5_file = info_dir / "foo.md5sums"
+ md5_file.write_text(
+ "abcdef1234567890abcdef1234567890 etc/foo/bar\n"
+ "invalid line without proper format\n"
+ "1234567890abcdef1234567890abcdef etc/foo/baz\n",
+ encoding="utf-8",
+ )
+
+ def fake_exists(path):
+ return str(path).endswith("foo.md5sums")
+
+ monkeypatch.setattr(d.os.path, "exists", fake_exists)
+
+ original_open = open
+
+ def fake_open(path, *args, **kwargs):
+ if "foo.md5sums" in str(path):
+ return original_open(md5_file, *args, **kwargs)
+ return original_open(path, *args, **kwargs)
+
+ monkeypatch.setattr("builtins.open", fake_open, raising=False)
+
+ result = d.read_pkg_md5sums("foo")
+ assert "etc/foo/bar" in result
+ assert "etc/foo/baz" in result
+
+
+def test_build_dpkg_etc_index_skips_lines_without_tabs(tmp_path: Path):
+ """Test that lines without tab separators are skipped (parts < 3)."""
+ import enroll.debian as d
+
+ info = tmp_path / "info"
+ info.mkdir()
+ # Create file with lines that don't have tab separators
+ (info / "foo.list").write_text(
+ "notabseparator\n" # No tab - should be skipped
+ "/etc/foo/bar\n", # This is a path line, processed differently
+ encoding="utf-8",
+ )
+
+ owned, owner_map, topdir_to_pkgs, pkg_to_etc = d.build_dpkg_etc_index(str(info))
+ # Path lines are still processed
+ assert "/etc/foo/bar" in owned
+
+
+def test_read_pkg_md5sums_skips_empty_lines(tmp_path: Path, monkeypatch):
+ """Test that empty lines in md5sums are skipped."""
+ import enroll.debian as d
+
+ info_dir = tmp_path / "info"
+ info_dir.mkdir()
+ md5_file = info_dir / "bar.md5sums"
+ md5_file.write_text(
+ "abcdef1234567890abcdef1234567890 etc/bar/file1\n"
+ "\n" # Empty line
+ "1234567890abcdef1234567890abcdef etc/bar/file2\n",
+ encoding="utf-8",
+ )
+
+ def fake_exists(path):
+ return str(path).endswith("bar.md5sums")
+
+ monkeypatch.setattr(d.os.path, "exists", fake_exists)
+
+ original_open = open
+
+ def fake_open(path, *args, **kwargs):
+ if "bar.md5sums" in str(path):
+ return original_open(md5_file, *args, **kwargs)
+ return original_open(path, *args, **kwargs)
+
+ monkeypatch.setattr("builtins.open", fake_open, raising=False)
+
+ result = d.read_pkg_md5sums("bar")
+ assert "etc/bar/file1" in result
+ assert "etc/bar/file2" in result
+
+
+def test_read_pkg_md5sums_skips_lines_not_starting_with_path(
+ tmp_path: Path, monkeypatch
+):
+ """Test that lines not starting with / are skipped."""
+ import enroll.debian as d
+
+ info_dir = tmp_path / "info"
+ info_dir.mkdir()
+ md5_file = info_dir / "baz.md5sums"
+ md5_file.write_text(
+ "abcdef1234567890abcdef1234567890 etc/baz/file1\n"
+ "invalid line\n" # Doesn't start with /
+ "1234567890abcdef1234567890abcdef etc/baz/file2\n",
+ encoding="utf-8",
+ )
+
+ def fake_exists(path):
+ return str(path).endswith("baz.md5sums")
+
+ monkeypatch.setattr(d.os.path, "exists", fake_exists)
+
+ original_open = open
+
+ def fake_open(path, *args, **kwargs):
+ if "baz.md5sums" in str(path):
+ return original_open(md5_file, *args, **kwargs)
+ return original_open(path, *args, **kwargs)
+
+ monkeypatch.setattr("builtins.open", fake_open, raising=False)
+
+ result = d.read_pkg_md5sums("baz")
+ assert "etc/baz/file1" in result
+ assert "etc/baz/file2" in result
diff --git a/tests/test_diff_bundle.py b/tests/test_diff_bundle.py
index 66ef094..2895484 100644
--- a/tests/test_diff_bundle.py
+++ b/tests/test_diff_bundle.py
@@ -6,6 +6,15 @@ from pathlib import Path
import pytest
+from enroll.diff import (
+ _Spinner,
+ _enforcement_plan,
+ has_enforceable_drift,
+ _role_tag,
+ _utc_now_iso,
+ _report_markdown,
+)
+
def _make_bundle_dir(tmp_path: Path) -> Path:
b = tmp_path / "bundle"
@@ -87,3 +96,1278 @@ def test_bundle_from_input_missing_path(tmp_path: Path):
with pytest.raises(RuntimeError, match="not found"):
d._bundle_from_input(str(tmp_path / "nope"), sops_mode=False)
+
+
+import json
+import sys
+
+
+from enroll.diff import (
+ _bundle_from_input,
+ _file_index,
+ _iter_managed_files,
+ _load_state,
+ _pkg_version_display,
+ _pkg_version_key,
+ _progress_enabled,
+ _roles,
+ _service_units,
+ _sha256,
+ _users_by_name,
+ compare_harvests,
+)
+from enroll.sopsutil import SopsError
+
+
+def test_progress_enabled_when_tty(monkeypatch):
+ monkeypatch.setattr(sys.stderr, "isatty", lambda: True)
+ monkeypatch.delenv("ENROLL_NO_PROGRESS", raising=False)
+ assert _progress_enabled() is True
+
+
+def test_progress_enabled_when_not_tty(monkeypatch):
+ monkeypatch.setattr(sys.stderr, "isatty", lambda: False)
+ monkeypatch.delenv("ENROLL_NO_PROGRESS", raising=False)
+ assert _progress_enabled() is False
+
+
+def test_progress_enabled_with_env_var(monkeypatch):
+ monkeypatch.setattr(sys.stderr, "isatty", lambda: True)
+ monkeypatch.setenv("ENROLL_NO_PROGRESS", "1")
+ assert _progress_enabled() is False
+
+ monkeypatch.setenv("ENROLL_NO_PROGRESS", "true")
+ assert _progress_enabled() is False
+
+ monkeypatch.setenv("ENROLL_NO_PROGRESS", "yes")
+ assert _progress_enabled() is False
+
+
+def test_sha256(tmp_path: Path):
+ test_file = tmp_path / "test.txt"
+ test_file.write_text("hello world", encoding="utf-8")
+ hash_result = _sha256(test_file)
+ assert len(hash_result) == 64
+
+
+def test_sha256_empty_file(tmp_path: Path):
+ test_file = tmp_path / "empty.txt"
+ test_file.write_bytes(b"")
+ hash_result = _sha256(test_file)
+ assert (
+ hash_result
+ == "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
+ )
+
+
+def test_bundle_from_input_directory(tmp_path: Path):
+ result = _bundle_from_input(str(tmp_path), sops_mode=False)
+ assert result.dir == tmp_path
+ assert result.tempdir is None
+
+
+def test_bundle_from_input_state_json_path(tmp_path: Path):
+ state_file = tmp_path / "state.json"
+ state_file.write_text("{}", encoding="utf-8")
+ result = _bundle_from_input(str(state_file), sops_mode=False)
+ assert result.dir == tmp_path
+ assert result.tempdir is None
+
+
+def test_bundle_from_input_not_found():
+ with pytest.raises(RuntimeError) as exc_info:
+ _bundle_from_input("/nonexistent/path", sops_mode=False)
+ assert "not found" in str(exc_info.value).lower()
+
+
+def test_bundle_from_input_tarball(tmp_path: Path):
+ bundle_dir = tmp_path / "bundle"
+ bundle_dir.mkdir()
+ state_file = bundle_dir / "state.json"
+ state_file.write_text("{}", encoding="utf-8")
+
+ tar_path = tmp_path / "bundle.tar.gz"
+ with tarfile.open(tar_path, "w:gz") as tf:
+ tf.add(bundle_dir, arcname="bundle")
+
+ result = _bundle_from_input(str(tar_path), sops_mode=False)
+ assert result.dir.exists()
+ assert result.tempdir is not None
+ result.tempdir.cleanup()
+
+
+def test_bundle_from_input_invalid_type(tmp_path: Path):
+ test_file = tmp_path / "test.txt"
+ test_file.write_text("not a bundle", encoding="utf-8")
+
+ with pytest.raises(RuntimeError) as exc_info:
+ _bundle_from_input(str(test_file), sops_mode=False)
+ assert "not a directory" in str(exc_info.value).lower()
+
+
+def test_load_state(tmp_path: Path):
+ state_file = tmp_path / "state.json"
+ state_file.write_text('{"host": {"hostname": "test"}}', encoding="utf-8")
+ result = _load_state(tmp_path)
+ assert result["host"]["hostname"] == "test"
+
+
+def test_roles_with_roles():
+ state = {"roles": {"users": {}, "services": []}}
+ result = _roles(state)
+ assert "users" in result
+
+
+def test_service_units_empty():
+ assert _service_units({}) == {}
+
+
+def test_service_units_with_services():
+ state = {
+ "roles": {
+ "services": [
+ {"unit": "nginx.service", "active_state": "active"},
+ {"unit": "ssh.service", "active_state": "inactive"},
+ ]
+ }
+ }
+ result = _service_units(state)
+ assert "nginx.service" in result
+ assert "ssh.service" in result
+ assert result["nginx.service"]["active_state"] == "active"
+
+
+def test_users_by_name_empty():
+ assert _users_by_name({}) == {}
+
+
+def test_users_by_name_with_users():
+ state = {
+ "roles": {
+ "users": {
+ "users": [
+ {"name": "alice", "uid": 1000},
+ {"name": "bob", "uid": 1001},
+ ]
+ }
+ }
+ }
+ result = _users_by_name(state)
+ assert "alice" in result
+ assert "bob" in result
+ assert result["alice"]["uid"] == 1000
+
+
+def test_pkg_version_key_with_version():
+ entry = {"version": "1.2.3"}
+ assert _pkg_version_key(entry) == "1.2.3"
+
+
+def test_pkg_version_key_with_installations():
+ entry = {
+ "installations": [
+ {"arch": "x86_64", "version": "1.2.3"},
+ {"arch": "aarch64", "version": "1.2.3"},
+ ]
+ }
+ result = _pkg_version_key(entry)
+ assert "x86_64:1.2.3" in result
+ assert "aarch64:1.2.3" in result
+
+
+def test_pkg_version_key_with_empty_version():
+ entry = {"version": None}
+ assert _pkg_version_key(entry) is None
+
+
+def test_pkg_version_key_with_invalid_installations():
+ entry = {"installations": ["not_a_dict", {"arch": "x86_64", "version": "1.0"}]}
+ result = _pkg_version_key(entry)
+ assert "x86_64:1.0" in result
+
+
+def test_pkg_version_display_with_version():
+ entry = {"version": "1.2.3"}
+ assert _pkg_version_display(entry) == "1.2.3"
+
+
+def test_pkg_version_display_with_installations():
+ entry = {
+ "installations": [
+ {"arch": "x86_64", "version": "1.2.3"},
+ ]
+ }
+ assert _pkg_version_display(entry) == "1.2.3 (x86_64)"
+
+
+def test_pkg_version_display_empty():
+ assert _pkg_version_display({}) is None
+
+
+def test_iter_managed_files_empty():
+ state = {"roles": {}}
+ files = list(_iter_managed_files(state))
+ assert files == []
+
+
+def test_iter_managed_files_services():
+ state = {
+ "roles": {
+ "services": [
+ {
+ "role_name": "nginx",
+ "managed_files": [
+ {"path": "/etc/nginx/nginx.conf", "src_rel": "nginx.conf"}
+ ],
+ }
+ ]
+ }
+ }
+ files = list(_iter_managed_files(state))
+ assert len(files) == 1
+ assert files[0] == (
+ "nginx",
+ {"path": "/etc/nginx/nginx.conf", "src_rel": "nginx.conf"},
+ )
+
+
+def test_iter_managed_files_packages():
+ state = {
+ "roles": {
+ "packages": [
+ {
+ "role_name": "vim",
+ "managed_files": [{"path": "/usr/bin/vim", "src_rel": "bin/vim"}],
+ }
+ ]
+ }
+ }
+ files = list(_iter_managed_files(state))
+ assert len(files) == 1
+ assert files[0][0] == "vim"
+
+
+def test_iter_managed_files_users():
+ state = {
+ "roles": {
+ "users": {
+ "role_name": "users",
+ "managed_files": [{"path": "/etc/passwd", "src_rel": "passwd"}],
+ }
+ }
+ }
+ files = list(_iter_managed_files(state))
+ assert len(files) == 1
+ assert files[0][0] == "users"
+
+
+def test_iter_managed_files_apt_config():
+ state = {
+ "roles": {
+ "apt_config": {
+ "role_name": "apt_config",
+ "managed_files": [
+ {"path": "/etc/apt/sources.list", "src_rel": "sources.list"}
+ ],
+ }
+ }
+ }
+ files = list(_iter_managed_files(state))
+ assert len(files) == 1
+ assert files[0][0] == "apt_config"
+
+
+def test_iter_managed_files_etc_custom():
+ state = {
+ "roles": {
+ "etc_custom": {
+ "role_name": "etc_custom",
+ "managed_files": [
+ {"path": "/etc/custom.conf", "src_rel": "custom.conf"}
+ ],
+ }
+ }
+ }
+ files = list(_iter_managed_files(state))
+ assert len(files) == 1
+ assert files[0][0] == "etc_custom"
+
+
+def test_iter_managed_files_usr_local_custom():
+ state = {
+ "roles": {
+ "usr_local_custom": {
+ "role_name": "usr_local_custom",
+ "managed_files": [
+ {"path": "/usr/local/bin/script", "src_rel": "bin/script"}
+ ],
+ }
+ }
+ }
+ files = list(_iter_managed_files(state))
+ assert len(files) == 1
+ assert files[0][0] == "usr_local_custom"
+
+
+def test_iter_managed_files_extra_paths():
+ state = {
+ "roles": {
+ "extra_paths": {
+ "role_name": "extra_paths",
+ "managed_files": [{"path": "/opt/app/config", "src_rel": "config"}],
+ }
+ }
+ }
+ files = list(_iter_managed_files(state))
+ assert len(files) == 1
+ assert files[0][0] == "extra_paths"
+
+
+def test_file_index_empty():
+ state = {"roles": {}}
+ index = _file_index(Path("/tmp"), state)
+ assert index == {}
+
+
+def test_file_index_with_files(tmp_path: Path):
+ state = {
+ "roles": {
+ "users": {
+ "managed_files": [
+ {"path": "/etc/passwd", "src_rel": "passwd", "owner": "root"},
+ ]
+ }
+ }
+ }
+ index = _file_index(tmp_path, state)
+ assert "/etc/passwd" in index
+ assert index["/etc/passwd"].role == "users"
+ assert index["/etc/passwd"].owner == "root"
+
+
+def test_file_index_duplicates_first_wins(tmp_path: Path):
+ state = {
+ "roles": {
+ "users": {
+ "managed_files": [
+ {"path": "/etc/passwd", "src_rel": "passwd"},
+ ]
+ },
+ "etc_custom": {
+ "managed_files": [
+ {"path": "/etc/passwd", "src_rel": "custom_passwd"},
+ ]
+ },
+ }
+ }
+ index = _file_index(tmp_path, state)
+ assert "/etc/passwd" in index
+ assert index["/etc/passwd"].src_rel == "passwd"
+
+
+def test_file_index_skips_missing_path_or_src_rel(tmp_path: Path):
+ state = {
+ "roles": {
+ "users": {
+ "managed_files": [
+ {"path": "/etc/passwd"}, # missing src_rel
+ {"src_rel": "passwd"}, # missing path
+ ]
+ }
+ }
+ }
+ index = _file_index(tmp_path, state)
+ assert index == {}
+
+
+def test_compare_harvests_no_changes(tmp_path: Path):
+ old_bundle = tmp_path / "old"
+ old_bundle.mkdir()
+ (old_bundle / "state.json").write_text(
+ json.dumps(
+ {
+ "inventory": {"packages": {"vim": {"version": "1.0"}}},
+ "roles": {},
+ }
+ ),
+ encoding="utf-8",
+ )
+
+ new_bundle = tmp_path / "new"
+ new_bundle.mkdir()
+ (new_bundle / "state.json").write_text(
+ json.dumps(
+ {
+ "inventory": {"packages": {"vim": {"version": "1.0"}}},
+ "roles": {},
+ }
+ ),
+ encoding="utf-8",
+ )
+
+ report, has_changes = compare_harvests(str(old_bundle), str(new_bundle))
+ assert has_changes is False
+ assert report["packages"]["added"] == []
+ assert report["packages"]["removed"] == []
+
+
+def test_compare_harvests_package_added(tmp_path: Path):
+ old_bundle = tmp_path / "old"
+ old_bundle.mkdir()
+ (old_bundle / "state.json").write_text(
+ json.dumps({"inventory": {"packages": {}}, "roles": {}}),
+ encoding="utf-8",
+ )
+
+ new_bundle = tmp_path / "new"
+ new_bundle.mkdir()
+ (new_bundle / "state.json").write_text(
+ json.dumps(
+ {"inventory": {"packages": {"vim": {"version": "1.0"}}}, "roles": {}}
+ ),
+ encoding="utf-8",
+ )
+
+ report, has_changes = compare_harvests(str(old_bundle), str(new_bundle))
+ assert has_changes is True
+ assert "vim" in report["packages"]["added"]
+
+
+def test_compare_harvests_package_removed(tmp_path: Path):
+ old_bundle = tmp_path / "old"
+ old_bundle.mkdir()
+ (old_bundle / "state.json").write_text(
+ json.dumps(
+ {"inventory": {"packages": {"vim": {"version": "1.0"}}}, "roles": {}}
+ ),
+ encoding="utf-8",
+ )
+
+ new_bundle = tmp_path / "new"
+ new_bundle.mkdir()
+ (new_bundle / "state.json").write_text(
+ json.dumps({"inventory": {"packages": {}}, "roles": {}}),
+ encoding="utf-8",
+ )
+
+ report, has_changes = compare_harvests(str(old_bundle), str(new_bundle))
+ assert has_changes is True
+ assert "vim" in report["packages"]["removed"]
+
+
+def test_compare_harvests_package_version_changed(tmp_path: Path):
+ old_bundle = tmp_path / "old"
+ old_bundle.mkdir()
+ (old_bundle / "state.json").write_text(
+ json.dumps(
+ {"inventory": {"packages": {"vim": {"version": "1.0"}}}, "roles": {}}
+ ),
+ encoding="utf-8",
+ )
+
+ new_bundle = tmp_path / "new"
+ new_bundle.mkdir()
+ (new_bundle / "state.json").write_text(
+ json.dumps(
+ {"inventory": {"packages": {"vim": {"version": "2.0"}}}, "roles": {}}
+ ),
+ encoding="utf-8",
+ )
+
+ report, has_changes = compare_harvests(str(old_bundle), str(new_bundle))
+ assert has_changes is True
+ assert len(report["packages"]["version_changed"]) == 1
+
+
+def test_compare_harvests_ignore_package_versions(tmp_path: Path):
+ old_bundle = tmp_path / "old"
+ old_bundle.mkdir()
+ (old_bundle / "state.json").write_text(
+ json.dumps(
+ {"inventory": {"packages": {"vim": {"version": "1.0"}}}, "roles": {}}
+ ),
+ encoding="utf-8",
+ )
+
+ new_bundle = tmp_path / "new"
+ new_bundle.mkdir()
+ (new_bundle / "state.json").write_text(
+ json.dumps(
+ {"inventory": {"packages": {"vim": {"version": "2.0"}}}, "roles": {}}
+ ),
+ encoding="utf-8",
+ )
+
+ report, has_changes = compare_harvests(
+ str(old_bundle), str(new_bundle), ignore_package_versions=True
+ )
+ assert report["packages"]["version_changed_ignored_count"] == 1
+
+
+def test_compare_harvests_service_added(tmp_path: Path):
+ old_bundle = tmp_path / "old"
+ old_bundle.mkdir()
+ (old_bundle / "state.json").write_text(
+ json.dumps({"inventory": {"packages": {}}, "roles": {"services": []}}),
+ encoding="utf-8",
+ )
+
+ new_bundle = tmp_path / "new"
+ new_bundle.mkdir()
+ (new_bundle / "state.json").write_text(
+ json.dumps(
+ {
+ "inventory": {"packages": {}},
+ "roles": {"services": [{"unit": "nginx.service"}]},
+ }
+ ),
+ encoding="utf-8",
+ )
+
+ report, has_changes = compare_harvests(str(old_bundle), str(new_bundle))
+ assert has_changes is True
+ assert "nginx.service" in report["services"]["enabled_added"]
+
+
+def test_compare_harvests_user_added(tmp_path: Path):
+ old_bundle = tmp_path / "old"
+ old_bundle.mkdir()
+ (old_bundle / "state.json").write_text(
+ json.dumps({"inventory": {"packages": {}}, "roles": {"users": {"users": []}}}),
+ encoding="utf-8",
+ )
+
+ new_bundle = tmp_path / "new"
+ new_bundle.mkdir()
+ (new_bundle / "state.json").write_text(
+ json.dumps(
+ {
+ "inventory": {"packages": {}},
+ "roles": {"users": {"users": [{"name": "alice", "uid": 1000}]}},
+ }
+ ),
+ encoding="utf-8",
+ )
+
+ report, has_changes = compare_harvests(str(old_bundle), str(new_bundle))
+ assert has_changes is True
+ assert "alice" in report["users"]["added"]
+
+
+def test_compare_harvests_with_exclude_paths(tmp_path: Path):
+ old_bundle = tmp_path / "old"
+ old_bundle.mkdir()
+ old_artifacts = old_bundle / "artifacts" / "users"
+ old_artifacts.mkdir(parents=True)
+ (old_artifacts / "passwd").write_text("old", encoding="utf-8")
+ (old_bundle / "state.json").write_text(
+ json.dumps(
+ {
+ "inventory": {"packages": {}},
+ "roles": {
+ "users": {
+ "managed_files": [{"path": "/etc/passwd", "src_rel": "passwd"}]
+ }
+ },
+ }
+ ),
+ encoding="utf-8",
+ )
+
+ new_bundle = tmp_path / "new"
+ new_bundle.mkdir()
+ new_artifacts = new_bundle / "artifacts" / "users"
+ new_artifacts.mkdir(parents=True)
+ (new_artifacts / "passwd").write_text("new", encoding="utf-8")
+ (new_bundle / "state.json").write_text(
+ json.dumps(
+ {
+ "inventory": {"packages": {}},
+ "roles": {
+ "users": {
+ "managed_files": [{"path": "/etc/passwd", "src_rel": "passwd"}]
+ }
+ },
+ }
+ ),
+ encoding="utf-8",
+ )
+
+ report, has_changes = compare_harvests(
+ str(old_bundle), str(new_bundle), exclude_paths=["/etc/passwd"]
+ )
+ assert "/etc/passwd" not in [f["path"] for f in report["files"]["added"]]
+ assert "/etc/passwd" not in [f["path"] for f in report["files"]["removed"]]
+ assert "/etc/passwd" not in [f["path"] for f in report["files"]["changed"]]
+
+
+def test_utc_now_iso():
+ result = _utc_now_iso()
+ assert "T" in result
+ assert "+" in result or "Z" in result
+
+
+def test_spinner_stop_without_start():
+ spinner = _Spinner("Test")
+ spinner.stop(final_line="Done")
+ # Should not raise
+
+
+def test_spinner_run_exception(monkeypatch):
+ class FakeStderr:
+ def write(self, s):
+ raise Exception("Write error")
+
+ def flush(self):
+ pass
+
+ monkeypatch.setattr(sys, "stderr", FakeStderr())
+
+ spinner = _Spinner("Test")
+ spinner.start()
+ spinner.stop()
+
+
+def test_spinner_double_start():
+ spinner = _Spinner("Test")
+ spinner.start()
+ spinner.start() # Should not raise or spawn another thread
+ spinner.stop()
+
+
+def test_role_tag_normal():
+ assert _role_tag("nginx") == "role_nginx"
+ assert _role_tag("my-app") == "role_my-app"
+
+
+def test_role_tag_with_special_chars():
+ assert _role_tag("my.app") == "role_my_app"
+ assert _role_tag("my app") == "role_my_app"
+
+
+def test_role_tag_empty():
+ assert _role_tag("") == "role_other"
+ assert _role_tag(" ") == "role_other"
+
+
+def test_has_enforceable_drift_packages_removed():
+ report = {"packages": {"removed": ["vim"]}}
+ assert has_enforceable_drift(report) is True
+
+
+def test_has_enforceable_drift_services_removed():
+ report = {"services": {"enabled_removed": ["nginx.service"]}}
+ assert has_enforceable_drift(report) is True
+
+
+def test_has_enforceable_drift_service_changed():
+ report = {
+ "services": {
+ "changed": [
+ {
+ "unit": "nginx.service",
+ "changes": {"active_state": {"old": "active", "new": "inactive"}},
+ }
+ ]
+ }
+ }
+ assert has_enforceable_drift(report) is True
+
+
+def test_has_enforceable_drift_service_package_only_changed():
+ # Service changed only in packages - should NOT be enforceable
+ report = {
+ "services": {
+ "changed": [
+ {
+ "unit": "nginx.service",
+ "changes": {"packages": {"added": ["nginx-extra"]}},
+ }
+ ]
+ }
+ }
+ assert has_enforceable_drift(report) is False
+
+
+def test_has_enforceable_drift_users_removed():
+ report = {"users": {"removed": ["alice"]}}
+ assert has_enforceable_drift(report) is True
+
+
+def test_has_enforceable_drift_users_changed():
+ report = {
+ "users": {
+ "changed": [
+ {"name": "alice", "changes": {"uid": {"old": 1000, "new": 1001}}}
+ ]
+ }
+ }
+ assert has_enforceable_drift(report) is True
+
+
+def test_has_enforceable_drift_files_removed():
+ report = {
+ "files": {
+ "removed": [{"path": "/etc/passwd", "role": "users", "reason": "conffile"}]
+ }
+ }
+ assert has_enforceable_drift(report) is True
+
+
+def test_has_enforceable_drift_files_changed():
+ report = {
+ "files": {
+ "changed": [
+ {
+ "path": "/etc/passwd",
+ "changes": {"content": {"old": "sha1", "new": "sha2"}},
+ }
+ ]
+ }
+ }
+ assert has_enforceable_drift(report) is True
+
+
+def test_has_enforceable_drift_no_drift():
+ report = {
+ "packages": {"added": ["newpkg"]},
+ "services": {"enabled_added": ["new.service"]},
+ "users": {"added": ["bob"]},
+ "files": {"added": ["/opt/newfile"]},
+ }
+ assert has_enforceable_drift(report) is False
+
+
+def test_enforcement_plan_packages_removed(monkeypatch, tmp_path: Path):
+ old_state = {
+ "roles": {
+ "services": [{"role_name": "nginx", "packages": ["nginx"]}],
+ "packages": [{"role_name": "vim", "package": "vim"}],
+ }
+ }
+ report = {"packages": {"removed": ["nginx", "vim"]}}
+
+ result = _enforcement_plan(report, old_state, tmp_path)
+ assert "nginx" in result.get("roles", [])
+ assert "vim" in result.get("roles", [])
+ assert "role_nginx" in result.get("tags", [])
+
+
+def test_enforcement_plan_users_changed():
+ old_state = {
+ "roles": {"users": {"role_name": "users", "users": [{"name": "alice"}]}}
+ }
+ report = {"users": {"changed": [{"name": "alice", "changes": {"uid": {}}}]}}
+
+ result = _enforcement_plan(report, old_state, Path("/tmp"))
+ assert "users" in result.get("roles", [])
+
+
+def test_enforcement_plan_files_removed(tmp_path: Path):
+ # Create the artifacts directory structure that _file_index expects
+ artifacts_dir = tmp_path / "artifacts" / "etc_custom"
+ artifacts_dir.mkdir(parents=True)
+
+ old_state = {
+ "roles": {
+ "etc_custom": {
+ "role_name": "etc_custom",
+ "managed_files": [
+ {"path": "/etc/custom.conf", "src_rel": "custom.conf"}
+ ],
+ }
+ }
+ }
+ report = {
+ "files": {"removed": [{"path": "/etc/custom.conf", "role": "etc_custom"}]}
+ }
+
+ result = _enforcement_plan(report, old_state, tmp_path)
+ assert "etc_custom" in result.get("roles", [])
+
+
+def test_enforcement_plan_no_drift():
+ old_state = {"roles": {}}
+ report = {"packages": {"added": ["newpkg"]}}
+
+ result = _enforcement_plan(report, old_state, Path("/tmp"))
+ assert result.get("roles", []) == []
+
+
+def test_bundle_from_input_tgz(monkeypatch, tmp_path: Path):
+ bundle_dir = tmp_path / "bundle"
+ bundle_dir.mkdir()
+ state_file = bundle_dir / "state.json"
+ state_file.write_text("{}", encoding="utf-8")
+
+ tar_path = tmp_path / "bundle.tgz"
+ with tarfile.open(tar_path, "w:gz") as tf:
+ tf.add(bundle_dir, arcname="bundle")
+
+ result = _bundle_from_input(str(tar_path), sops_mode=False)
+ assert result.dir.exists()
+ assert result.tempdir is not None
+ result.tempdir.cleanup()
+
+
+def test_bundle_from_input_sops_mode_no_sops(monkeypatch, tmp_path: Path):
+ # Create a fake .sops file
+ sops_file = tmp_path / "harvest.sops"
+ sops_file.write_bytes(b"encrypted")
+
+ def fake_require():
+ raise SopsError("sops not found")
+
+ import enroll.diff as d
+
+ monkeypatch.setattr(d, "require_sops_cmd", fake_require)
+
+ with pytest.raises(SopsError):
+ _bundle_from_input(str(sops_file), sops_mode=True)
+
+
+def test_report_markdown_basic():
+ report = {
+ "generated_at": "2024-01-01T00:00:00Z",
+ "old": {"input": "old.tar.gz", "host": "host1"},
+ "new": {"input": "new.tar.gz", "host": "host2"},
+ "packages": {"added": ["vim"], "removed": [], "version_changed": []},
+ "services": {"enabled_added": [], "enabled_removed": [], "changed": []},
+ "users": {"added": [], "removed": [], "changed": []},
+ "files": {"added": [], "removed": [], "changed": []},
+ }
+ result = _report_markdown(report)
+ assert "## Packages" in result
+ assert "+ vim" in result
+
+
+def test_report_markdown_with_enforcement_applied():
+ report = {
+ "generated_at": "2024-01-01T00:00:00Z",
+ "old": {"input": "old.tar.gz"},
+ "new": {"input": "new.tar.gz"},
+ "packages": {"added": [], "removed": [], "version_changed": []},
+ "services": {"enabled_added": [], "enabled_removed": [], "changed": []},
+ "users": {"added": [], "removed": [], "changed": []},
+ "files": {"added": [], "removed": [], "changed": []},
+ "enforcement": {
+ "status": "applied",
+ "tags": ["role_users"],
+ "returncode": 0,
+ "finished_at": "2024-01-01T00:01:00Z",
+ },
+ }
+ result = _report_markdown(report)
+ assert "Applied old harvest" in result
+ assert "role_users" in result
+
+
+def test_report_markdown_with_enforcement_failed():
+ report = {
+ "generated_at": "2024-01-01T00:00:00Z",
+ "old": {"input": "old.tar.gz"},
+ "new": {"input": "new.tar.gz"},
+ "packages": {"added": [], "removed": [], "version_changed": []},
+ "services": {"enabled_added": [], "enabled_removed": [], "changed": []},
+ "users": {"added": [], "removed": [], "changed": []},
+ "files": {"added": [], "removed": [], "changed": []},
+ "enforcement": {
+ "status": "failed",
+ "returncode": 1,
+ },
+ }
+ result = _report_markdown(report)
+ assert "ansible-playbook failed" in result
+
+
+def test_report_markdown_with_enforcement_skipped():
+ report = {
+ "generated_at": "2024-01-01T00:00:00Z",
+ "old": {"input": "old.tar.gz"},
+ "new": {"input": "new.tar.gz"},
+ "packages": {"added": [], "removed": [], "version_changed": []},
+ "services": {"enabled_added": [], "enabled_removed": [], "changed": []},
+ "users": {"added": [], "removed": [], "changed": []},
+ "files": {"added": [], "removed": [], "changed": []},
+ "enforcement": {
+ "status": "skipped",
+ "reason": "no drift",
+ },
+ }
+ result = _report_markdown(report)
+ assert "Skipped" in result
+ assert "no drift" in result
+
+
+def test_report_markdown_with_version_ignored():
+ report = {
+ "generated_at": "2024-01-01T00:00:00Z",
+ "old": {"input": "old.tar.gz"},
+ "new": {"input": "new.tar.gz"},
+ "packages": {
+ "added": [],
+ "removed": [],
+ "version_changed": [{"package": "vim", "old": "1.0", "new": "2.0"}],
+ "version_changed_ignored_count": 1,
+ },
+ "services": {"enabled_added": [], "enabled_removed": [], "changed": []},
+ "users": {"added": [], "removed": [], "changed": []},
+ "files": {"added": [], "removed": [], "changed": []},
+ }
+ result = _report_markdown(report)
+ assert "ignored 1" in result
+
+
+def test_report_markdown_with_service_package_changes():
+ report = {
+ "generated_at": "2024-01-01T00:00:00Z",
+ "old": {"input": "old.tar.gz"},
+ "new": {"input": "new.tar.gz"},
+ "packages": {"added": [], "removed": [], "version_changed": []},
+ "services": {
+ "enabled_added": [],
+ "enabled_removed": [],
+ "changed": [
+ {
+ "unit": "nginx.service",
+ "changes": {"packages": {"added": ["nginx-extra"], "removed": []}},
+ }
+ ],
+ },
+ "users": {"added": [], "removed": [], "changed": []},
+ "files": {"added": [], "removed": [], "changed": []},
+ }
+ result = _report_markdown(report)
+ assert "packages added" in result
+
+
+def test_report_markdown_empty():
+ report = {
+ "generated_at": "2024-01-01T00:00:00Z",
+ "old": {"input": "old.tar.gz"},
+ "new": {"input": "new.tar.gz"},
+ "packages": {},
+ "services": {},
+ "users": {},
+ "files": {},
+ }
+ result = _report_markdown(report)
+ assert "## Packages" in result
+ assert "## Services" in result
+
+
+def test_spinner_start_stop(monkeypatch):
+ """Test spinner can be started and stopped."""
+ import enroll.diff as d
+
+ # Mock threading to avoid actual thread creation
+ class FakeThread:
+ def __init__(self, target, name, daemon):
+ self.target = target
+ self.daemon = daemon
+
+ def start(self):
+ pass
+
+ def join(self, timeout):
+ pass
+
+ monkeypatch.setattr(d.threading, "Thread", FakeThread)
+
+ spinner = d._Spinner("test message")
+ spinner.start()
+ spinner.stop()
+
+
+def test_spinner_already_started(monkeypatch):
+ """Test spinner doesn't restart if already running."""
+ import enroll.diff as d
+
+ class FakeThread:
+ def __init__(self, target, name, daemon):
+ pass
+
+ def start(self):
+ pass
+
+ def join(self, timeout):
+ pass
+
+ monkeypatch.setattr(d.threading, "Thread", FakeThread)
+
+ spinner = d._Spinner("test message")
+ spinner.start()
+ spinner._thread = FakeThread(None, None, True) # Simulate already running
+ spinner.start() # Should return early
+
+
+def test_spinner_stop_clears_line(monkeypatch, tmp_path):
+ """Test spinner stop clears the line."""
+ import enroll.diff as d
+ import sys
+
+ class FakeThread:
+ def __init__(self, target, name, daemon):
+ pass
+
+ def start(self):
+ pass
+
+ def join(self, timeout):
+ pass
+
+ monkeypatch.setattr(d.threading, "Thread", FakeThread)
+
+ # Capture stderr writes
+ writes = []
+ original_write = sys.stderr.write
+
+ def capture_write(s):
+ writes.append(s)
+ return original_write(s)
+
+ monkeypatch.setattr(sys.stderr, "write", capture_write)
+
+ spinner = d._Spinner("test message")
+ spinner._last_len = 20
+ spinner.stop()
+
+ # Should have written clearing sequence
+ assert any("\r" in w for w in writes)
+
+
+def test_should_show_spinner_disabled_env(monkeypatch):
+ """Test spinner disabled via environment variable."""
+ import enroll.diff as d
+
+ monkeypatch.setenv("ENROLL_NO_PROGRESS", "1")
+ assert d._progress_enabled() is False
+
+ monkeypatch.setenv("ENROLL_NO_PROGRESS", "true")
+ assert d._progress_enabled() is False
+
+ monkeypatch.setenv("ENROLL_NO_PROGRESS", "yes")
+ assert d._progress_enabled() is False
+
+
+def test_should_show_spinner_exception_on_isatty(monkeypatch):
+ """Test spinner returns False when isatty raises exception."""
+ import enroll.diff as d
+ import sys
+
+ original_stderr = sys.stderr
+
+ class FakeStderr:
+ def isatty(self):
+ raise Exception("No tty")
+
+ monkeypatch.setattr(sys, "stderr", FakeStderr())
+ assert d._progress_enabled() is False
+
+ # Restore
+ monkeypatch.setattr(sys, "stderr", original_stderr)
+
+
+def test_all_packages_from_state():
+ """Test _all_packages extracts sorted package list."""
+ import enroll.diff as d
+
+ state = {
+ "inventory": {
+ "packages": {
+ "nginx": [{"version": "1.0"}],
+ "vim": [{"version": "2.0"}],
+ "bash": [{"version": "3.0"}],
+ }
+ }
+ }
+
+ result = d._all_packages(state)
+ assert result == ["bash", "nginx", "vim"]
+
+
+def test_all_packages_empty_state():
+ """Test _all_packages with empty state."""
+ import enroll.diff as d
+
+ state = {"inventory": {"packages": {}}}
+ result = d._all_packages(state)
+ assert result == []
+
+
+def test_roles_from_state():
+ """Test _roles extracts roles from state."""
+ import enroll.diff as d
+
+ state = {"roles": {"web": {}, "db": {}}}
+ result = d._roles(state)
+ assert result == {"web": {}, "db": {}}
+
+
+def test_roles_empty_state():
+ """Test _roles with empty state."""
+ import enroll.diff as d
+
+ state = {}
+ result = d._roles(state)
+ assert result == {}
+
+
+def test_pkg_version_key_with_multiple_versions():
+ """Test _pkg_version_key handles multiple versions."""
+ import enroll.diff as d
+
+ entry = {
+ "installations": [
+ {"version": "1.0", "arch": "amd64"},
+ {"version": "2.0", "arch": "arm64"},
+ ]
+ }
+
+ result = d._pkg_version_key(entry)
+ # Just check it returns a non-None value with version info
+ assert result is not None
+ assert len(result) > 0
+
+
+def test_pkg_version_key_without_version():
+ """Test _pkg_version_key skips entries without version."""
+ import enroll.diff as d
+
+ entry = {
+ "installations": [
+ {"arch": "amd64"}, # No version
+ ]
+ }
+
+ result = d._pkg_version_key(entry)
+ assert result is None
+
+
+def test_pkg_version_key_with_empty_installations():
+ """Test _pkg_version_key with empty installations."""
+ import enroll.diff as d
+
+ entry = {"installations": []}
+ result = d._pkg_version_key(entry)
+ assert result is None
+
+
+def test_pkg_version_key_without_installations():
+ """Test _pkg_version_key without installations key."""
+ import enroll.diff as d
+
+ entry = {}
+ result = d._pkg_version_key(entry)
+ assert result is None
+
+
+def test_pkg_version_key_with_direct_version():
+ """Test _pkg_version_key with direct version field."""
+ import enroll.diff as d
+
+ entry = {"version": "1.2.3"}
+ result = d._pkg_version_key(entry)
+ assert result == "1.2.3"
+
+
+def test_report_text_with_exclude_paths():
+ """Test _report_text includes exclude paths."""
+ import enroll.diff as d
+
+ report = {
+ "generated_at": "2024-01-01T00:00:00Z",
+ "old": {"input": "old.tar.gz", "host": "host1", "state_mtime": "mtime1"},
+ "new": {"input": "new.tar.gz", "host": "host2", "state_mtime": "mtime2"},
+ "filters": {"exclude_paths": ["/tmp/*", "/var/log/*"]},
+ "packages": {"added": [], "removed": [], "version_changed": []},
+ "services": {"enabled_added": [], "enabled_removed": [], "changed": []},
+ "users": {"added": [], "removed": [], "changed": []},
+ "files": {"added": [], "removed": [], "changed": []},
+ }
+ result = d._report_text(report)
+ assert "file exclude patterns" in result
+ assert "/tmp/*" in result
+
+
+def test_report_text_with_ignore_package_versions():
+ """Test _report_text includes ignore package versions message."""
+ import enroll.diff as d
+
+ report = {
+ "generated_at": "2024-01-01T00:00:00Z",
+ "old": {"input": "old.tar.gz", "host": "host1", "state_mtime": "mtime1"},
+ "new": {"input": "new.tar.gz", "host": "host2", "state_mtime": "mtime2"},
+ "filters": {"ignore_package_versions": True},
+ "packages": {"version_changed_ignored_count": 5},
+ "services": {"enabled_added": [], "enabled_removed": [], "changed": []},
+ "users": {"added": [], "removed": [], "changed": []},
+ "files": {"added": [], "removed": [], "changed": []},
+ }
+ result = d._report_text(report)
+ assert "package version drift: ignored" in result
+ assert "ignored 5 changes" in result
+
+
+def test_report_text_with_enforcement_applied():
+ """Test _report_text includes enforcement applied status."""
+ import enroll.diff as d
+
+ report = {
+ "generated_at": "2024-01-01T00:00:00Z",
+ "old": {"input": "old.tar.gz", "host": "host1", "state_mtime": "mtime1"},
+ "new": {"input": "new.tar.gz", "host": "host2", "state_mtime": "mtime2"},
+ "packages": {"added": [], "removed": [], "version_changed": []},
+ "services": {"enabled_added": [], "enabled_removed": [], "changed": []},
+ "users": {"added": [], "removed": [], "changed": []},
+ "files": {"added": [], "removed": [], "changed": []},
+ "enforcement": {
+ "status": "applied",
+ "returncode": 0,
+ "tags": ["test"],
+ "finished_at": "2024-01-01T01:00:00Z",
+ },
+ }
+ result = d._report_text(report)
+ assert "Enforcement" in result
+ assert "applied old harvest via ansible-playbook" in result
+ assert "tags=test" in result
+
+
+def test_report_text_with_enforcement_failed():
+ """Test _report_text includes enforcement failed status."""
+ import enroll.diff as d
+
+ report = {
+ "generated_at": "2024-01-01T00:00:00Z",
+ "old": {"input": "old.tar.gz", "host": "host1", "state_mtime": "mtime1"},
+ "new": {"input": "new.tar.gz", "host": "host2", "state_mtime": "mtime2"},
+ "packages": {"added": [], "removed": [], "version_changed": []},
+ "services": {"enabled_added": [], "enabled_removed": [], "changed": []},
+ "users": {"added": [], "removed": [], "changed": []},
+ "files": {"added": [], "removed": [], "changed": []},
+ "enforcement": {"status": "failed", "returncode": 1},
+ }
+ result = d._report_text(report)
+ assert "Enforcement" in result
+ assert "ansible-playbook failed" in result
+
+
+def test_report_text_with_enforcement_skipped():
+ """Test _report_text includes enforcement skipped status."""
+ import enroll.diff as d
+
+ report = {
+ "generated_at": "2024-01-01T00:00:00Z",
+ "old": {"input": "old.tar.gz", "host": "host1", "state_mtime": "mtime1"},
+ "new": {"input": "new.tar.gz", "host": "host2", "state_mtime": "mtime2"},
+ "packages": {"added": [], "removed": [], "version_changed": []},
+ "services": {"enabled_added": [], "enabled_removed": [], "changed": []},
+ "users": {"added": [], "removed": [], "changed": []},
+ "files": {"added": [], "removed": [], "changed": []},
+ "enforcement": {"status": "skipped", "reason": "no changes"},
+ }
+ result = d._report_text(report)
+ assert "Enforcement" in result
+ assert "skipped" in result
+ assert "no changes" in result
diff --git a/tests/test_diff_ignore_versions_exclude_enforce.py b/tests/test_diff_ignore_versions_exclude_enforce.py
new file mode 100644
index 0000000..fd0524f
--- /dev/null
+++ b/tests/test_diff_ignore_versions_exclude_enforce.py
@@ -0,0 +1,400 @@
+from __future__ import annotations
+
+import json
+import sys
+import types
+from pathlib import Path
+
+import pytest
+
+
+def _write_bundle(
+ root: Path, state: dict, artifacts: dict[str, bytes] | None = None
+) -> None:
+ root.mkdir(parents=True, exist_ok=True)
+ (root / "state.json").write_text(json.dumps(state, indent=2), encoding="utf-8")
+ artifacts = artifacts or {}
+ for rel, data in artifacts.items():
+ p = root / rel
+ p.parent.mkdir(parents=True, exist_ok=True)
+ p.write_bytes(data)
+
+
+def _minimal_roles() -> dict:
+ """A small roles structure that's sufficient for enroll.diff file indexing."""
+ return {
+ "users": {
+ "role_name": "users",
+ "users": [],
+ "managed_files": [],
+ "excluded": [],
+ "notes": [],
+ },
+ "services": [],
+ "packages": [],
+ "apt_config": {
+ "role_name": "apt_config",
+ "managed_files": [],
+ "excluded": [],
+ "notes": [],
+ },
+ "etc_custom": {
+ "role_name": "etc_custom",
+ "managed_files": [],
+ "excluded": [],
+ "notes": [],
+ },
+ "usr_local_custom": {
+ "role_name": "usr_local_custom",
+ "managed_files": [],
+ "excluded": [],
+ "notes": [],
+ },
+ "extra_paths": {
+ "role_name": "extra_paths",
+ "include_patterns": [],
+ "exclude_patterns": [],
+ "managed_files": [],
+ "excluded": [],
+ "notes": [],
+ },
+ }
+
+
+def test_diff_ignore_package_versions_suppresses_version_drift(tmp_path: Path):
+ from enroll.diff import compare_harvests
+
+ old = tmp_path / "old"
+ new = tmp_path / "new"
+
+ old_state = {
+ "schema_version": 3,
+ "host": {"hostname": "h1"},
+ "inventory": {
+ "packages": {
+ "curl": {
+ "version": "1.0",
+ "installations": [{"version": "1.0", "arch": "amd64"}],
+ }
+ }
+ },
+ "roles": _minimal_roles(),
+ }
+ new_state = {
+ **old_state,
+ "inventory": {
+ "packages": {
+ "curl": {
+ "version": "1.1",
+ "installations": [{"version": "1.1", "arch": "amd64"}],
+ }
+ }
+ },
+ }
+
+ _write_bundle(old, old_state)
+ _write_bundle(new, new_state)
+
+ # Without ignore flag, version drift is reported and counts as changes.
+ report, has_changes = compare_harvests(str(old), str(new))
+ assert has_changes is True
+ assert report["packages"]["version_changed"]
+
+ # With ignore flag, version drift is suppressed and does not count as changes.
+ report2, has_changes2 = compare_harvests(
+ str(old), str(new), ignore_package_versions=True
+ )
+ assert has_changes2 is False
+ assert report2["packages"]["version_changed"] == []
+ assert report2["packages"]["version_changed_ignored_count"] == 1
+ assert report2["filters"]["ignore_package_versions"] is True
+
+
+def test_diff_exclude_path_filters_file_drift_and_affects_has_changes(tmp_path: Path):
+ from enroll.diff import compare_harvests
+
+ old = tmp_path / "old"
+ new = tmp_path / "new"
+
+ # Only file drift is under /var/anacron, which is excluded.
+ old_state = {
+ "schema_version": 3,
+ "host": {"hostname": "h1"},
+ "inventory": {"packages": {}},
+ "roles": {
+ **_minimal_roles(),
+ "extra_paths": {
+ **_minimal_roles()["extra_paths"],
+ "managed_files": [
+ {
+ "path": "/var/anacron/daily.stamp",
+ "src_rel": "var/anacron/daily.stamp",
+ "owner": "root",
+ "group": "root",
+ "mode": "0644",
+ "reason": "extra_path",
+ }
+ ],
+ },
+ },
+ }
+ new_state = json.loads(json.dumps(old_state))
+
+ _write_bundle(
+ old,
+ old_state,
+ {"artifacts/extra_paths/var/anacron/daily.stamp": b"yesterday\n"},
+ )
+ _write_bundle(
+ new,
+ new_state,
+ {"artifacts/extra_paths/var/anacron/daily.stamp": b"today\n"},
+ )
+
+ report, has_changes = compare_harvests(
+ str(old), str(new), exclude_paths=["/var/anacron"]
+ )
+ assert has_changes is False
+ assert report["files"]["changed"] == []
+ assert report["filters"]["exclude_paths"] == ["/var/anacron"]
+
+
+def test_diff_exclude_path_only_filters_files_not_packages(tmp_path: Path):
+ from enroll.diff import compare_harvests
+
+ old = tmp_path / "old"
+ new = tmp_path / "new"
+
+ old_state = {
+ "schema_version": 3,
+ "host": {"hostname": "h1"},
+ "inventory": {"packages": {"curl": {"version": "1.0"}}},
+ "roles": {
+ **_minimal_roles(),
+ "extra_paths": {
+ **_minimal_roles()["extra_paths"],
+ "managed_files": [
+ {
+ "path": "/var/anacron/daily.stamp",
+ "src_rel": "var/anacron/daily.stamp",
+ "owner": "root",
+ "group": "root",
+ "mode": "0644",
+ "reason": "extra_path",
+ }
+ ],
+ },
+ },
+ }
+ new_state = {
+ **old_state,
+ "inventory": {
+ "packages": {
+ "curl": {"version": "1.0"},
+ "htop": {"version": "3.0"},
+ }
+ },
+ }
+
+ _write_bundle(
+ old,
+ old_state,
+ {"artifacts/extra_paths/var/anacron/daily.stamp": b"yesterday\n"},
+ )
+ _write_bundle(
+ new,
+ new_state,
+ {
+ "artifacts/extra_paths/var/anacron/daily.stamp": b"today\n",
+ },
+ )
+
+ report, has_changes = compare_harvests(
+ str(old), str(new), exclude_paths=["/var/anacron"]
+ )
+ assert has_changes is True
+ # File drift is filtered, but package drift remains.
+ assert report["files"]["changed"] == []
+ assert report["packages"]["added"] == ["htop"]
+
+
+def test_enforce_old_harvest_requires_ansible_playbook(monkeypatch, tmp_path: Path):
+ import enroll.diff as d
+
+ monkeypatch.setattr(d.shutil, "which", lambda name: None)
+
+ old = tmp_path / "old"
+ _write_bundle(old, {"inventory": {"packages": {}}, "roles": _minimal_roles()})
+
+ with pytest.raises(RuntimeError, match="ansible-playbook not found"):
+ d.enforce_old_harvest(str(old))
+
+
+def test_enforce_old_harvest_runs_ansible_with_tags_from_file_drift(
+ monkeypatch, tmp_path: Path
+):
+ import enroll.diff as d
+ import enroll.manifest as mf
+
+ # Pretend ansible-playbook is installed.
+ monkeypatch.setattr(d.shutil, "which", lambda name: "/usr/bin/ansible-playbook")
+
+ calls: dict[str, object] = {}
+
+ # Stub manifest generation to only create playbook.yml (fast, no real roles needed).
+ def fake_manifest(_harvest_dir: str, out_dir: str, **_kwargs):
+ out = Path(out_dir)
+ (out / "playbook.yml").write_text(
+ "---\n- hosts: all\n gather_facts: false\n roles: []\n",
+ encoding="utf-8",
+ )
+
+ monkeypatch.setattr(mf, "manifest", fake_manifest)
+
+ def fake_run(
+ argv, cwd=None, env=None, capture_output=False, text=False, check=False
+ ):
+ calls["argv"] = list(argv)
+ calls["cwd"] = cwd
+ return types.SimpleNamespace(returncode=0, stdout="ok", stderr="")
+
+ monkeypatch.setattr(d.subprocess, "run", fake_run)
+
+ old = tmp_path / "old"
+ old_state = {
+ "schema_version": 3,
+ "host": {"hostname": "h1"},
+ "inventory": {"packages": {}},
+ "roles": {
+ **_minimal_roles(),
+ "usr_local_custom": {
+ **_minimal_roles()["usr_local_custom"],
+ "managed_files": [
+ {
+ "path": "/etc/myapp.conf",
+ "src_rel": "etc/myapp.conf",
+ "owner": "root",
+ "group": "root",
+ "mode": "0644",
+ "reason": "custom",
+ }
+ ],
+ },
+ },
+ }
+ _write_bundle(old, old_state)
+
+ # Minimal report containing enforceable drift: a baseline file is "removed".
+ report = {
+ "packages": {"added": [], "removed": [], "version_changed": []},
+ "services": {"enabled_added": [], "enabled_removed": [], "changed": []},
+ "users": {"added": [], "removed": [], "changed": []},
+ "files": {
+ "added": [],
+ "removed": [{"path": "/etc/myapp.conf", "role": "usr_local_custom"}],
+ "changed": [],
+ },
+ }
+
+ info = d.enforce_old_harvest(str(old), report=report)
+ assert info["status"] == "applied"
+ assert "--tags" in info["command"]
+ assert "role_usr_local_custom" in ",".join(info.get("tags") or [])
+
+ argv = calls.get("argv")
+ assert argv and argv[0].endswith("ansible-playbook")
+ assert "--tags" in argv
+ # Ensure we pass the computed tag.
+ i = argv.index("--tags")
+ assert "role_usr_local_custom" in str(argv[i + 1])
+
+
+def test_cli_diff_forwards_exclude_and_ignore_flags(monkeypatch, capsys):
+ import enroll.cli as cli
+
+ calls: dict[str, object] = {}
+
+ def fake_compare(
+ old, new, *, sops_mode=False, exclude_paths=None, ignore_package_versions=False
+ ):
+ calls["compare"] = {
+ "old": old,
+ "new": new,
+ "sops_mode": sops_mode,
+ "exclude_paths": exclude_paths,
+ "ignore_package_versions": ignore_package_versions,
+ }
+ # No changes -> should not try to enforce.
+ return {"packages": {}, "services": {}, "users": {}, "files": {}}, False
+
+ monkeypatch.setattr(cli, "compare_harvests", fake_compare)
+ monkeypatch.setattr(cli, "format_report", lambda report, fmt="text": "R\n")
+ monkeypatch.setattr(
+ sys,
+ "argv",
+ [
+ "enroll",
+ "diff",
+ "--old",
+ "/tmp/old",
+ "--new",
+ "/tmp/new",
+ "--exclude-path",
+ "/var/anacron",
+ "--ignore-package-versions",
+ ],
+ )
+
+ cli.main()
+ _ = capsys.readouterr()
+ assert calls["compare"]["exclude_paths"] == ["/var/anacron"]
+ assert calls["compare"]["ignore_package_versions"] is True
+
+
+def test_cli_diff_enforce_skips_when_no_enforceable_drift(monkeypatch):
+ import enroll.cli as cli
+
+ # Drift exists, but is not enforceable (only additions / version changes).
+ report = {
+ "packages": {"added": ["htop"], "removed": [], "version_changed": []},
+ "services": {
+ "enabled_added": ["x.service"],
+ "enabled_removed": [],
+ "changed": [],
+ },
+ "users": {"added": ["bob"], "removed": [], "changed": []},
+ "files": {"added": [{"path": "/tmp/new"}], "removed": [], "changed": []},
+ }
+
+ monkeypatch.setattr(cli, "compare_harvests", lambda *a, **k: (report, True))
+ monkeypatch.setattr(cli, "has_enforceable_drift", lambda r: False)
+ called = {"enforce": False}
+ monkeypatch.setattr(
+ cli, "enforce_old_harvest", lambda *a, **k: called.update({"enforce": True})
+ )
+
+ captured = {}
+
+ def fake_format(rep, fmt="text"):
+ captured["report"] = rep
+ return "R\n"
+
+ monkeypatch.setattr(cli, "format_report", fake_format)
+
+ monkeypatch.setattr(
+ sys,
+ "argv",
+ [
+ "enroll",
+ "diff",
+ "--old",
+ "/tmp/old",
+ "--new",
+ "/tmp/new",
+ "--enforce",
+ ],
+ )
+
+ cli.main()
+ assert called["enforce"] is False
+ assert captured["report"]["enforcement"]["status"] == "skipped"
diff --git a/tests/test_diff_notifications.py b/tests/test_diff_notifications.py
new file mode 100644
index 0000000..53f6b57
--- /dev/null
+++ b/tests/test_diff_notifications.py
@@ -0,0 +1,83 @@
+from __future__ import annotations
+
+import types
+
+import pytest
+
+
+def test_post_webhook_success(monkeypatch):
+ from enroll.diff import post_webhook
+
+ class FakeResp:
+ status = 204
+
+ def read(self):
+ return b"OK"
+
+ def __enter__(self):
+ return self
+
+ def __exit__(self, exc_type, exc, tb):
+ return False
+
+ monkeypatch.setattr(
+ "enroll.diff.urllib.request.urlopen",
+ lambda req, timeout=30: FakeResp(),
+ )
+
+ status, body = post_webhook("https://example.com", b"x")
+ assert status == 204
+ assert body == "OK"
+
+
+def test_post_webhook_raises_on_error(monkeypatch):
+ from enroll.diff import post_webhook
+
+ def boom(_req, timeout=30):
+ raise OSError("nope")
+
+ monkeypatch.setattr("enroll.diff.urllib.request.urlopen", boom)
+
+ with pytest.raises(RuntimeError):
+ post_webhook("https://example.com", b"x")
+
+
+def test_send_email_uses_sendmail_when_present(monkeypatch):
+ from enroll.diff import send_email
+
+ calls = {}
+
+ monkeypatch.setattr("enroll.diff.shutil.which", lambda name: "/usr/sbin/sendmail")
+
+ def fake_run(argv, input=None, check=False, **_kwargs):
+ calls["argv"] = argv
+ calls["input"] = input
+ return types.SimpleNamespace(returncode=0)
+
+ monkeypatch.setattr("enroll.diff.subprocess.run", fake_run)
+
+ send_email(
+ subject="Subj",
+ body="Body",
+ from_addr="a@example.com",
+ to_addrs=["b@example.com"],
+ )
+
+ assert calls["argv"][0].endswith("sendmail")
+ msg = (calls["input"] or b"").decode("utf-8", errors="replace")
+ assert "Subject: Subj" in msg
+ assert "To: b@example.com" in msg
+
+
+def test_send_email_raises_when_no_delivery_method(monkeypatch):
+ from enroll.diff import send_email
+
+ monkeypatch.setattr("enroll.diff.shutil.which", lambda name: None)
+
+ with pytest.raises(RuntimeError):
+ send_email(
+ subject="Subj",
+ body="Body",
+ from_addr="a@example.com",
+ to_addrs=["b@example.com"],
+ )
diff --git a/tests/test_explain.py b/tests/test_explain.py
new file mode 100644
index 0000000..69f4a88
--- /dev/null
+++ b/tests/test_explain.py
@@ -0,0 +1,222 @@
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+import enroll.explain as ex
+
+
+def _write_state(bundle: Path, state: dict) -> Path:
+ bundle.mkdir(parents=True, exist_ok=True)
+ (bundle / "state.json").write_text(json.dumps(state, indent=2), encoding="utf-8")
+ return bundle / "state.json"
+
+
+def test_explain_state_text_renders_roles_inventory_and_reasons(tmp_path: Path):
+ bundle = tmp_path / "bundle"
+ state = {
+ "schema_version": 3,
+ "host": {"hostname": "h1", "os": "debian", "pkg_backend": "dpkg"},
+ "enroll": {"version": "0.0.0"},
+ "inventory": {
+ "packages": {
+ "foo": {
+ "installations": [{"version": "1.0", "arch": "amd64"}],
+ "observed_via": [
+ {"kind": "systemd_unit", "ref": "foo.service"},
+ {"kind": "package_role", "ref": "foo"},
+ ],
+ "roles": ["foo"],
+ },
+ "bar": {
+ "installations": [{"version": "2.0", "arch": "amd64"}],
+ "observed_via": [{"kind": "user_installed", "ref": "manual"}],
+ "roles": ["bar"],
+ },
+ }
+ },
+ "roles": {
+ "users": {
+ "role_name": "users",
+ "users": [{"name": "alice"}],
+ "managed_files": [
+ {
+ "path": "/home/alice/.ssh/authorized_keys",
+ "src_rel": "home/alice/.ssh/authorized_keys",
+ "owner": "alice",
+ "group": "alice",
+ "mode": "0600",
+ "reason": "authorized_keys",
+ }
+ ],
+ "managed_dirs": [
+ {
+ "path": "/home/alice/.ssh",
+ "owner": "alice",
+ "group": "alice",
+ "mode": "0700",
+ "reason": "parent_of_managed_file",
+ }
+ ],
+ "excluded": [{"path": "/etc/shadow", "reason": "sensitive_content"}],
+ "notes": ["n1", "n2"],
+ },
+ "services": [
+ {
+ "unit": "foo.service",
+ "role_name": "foo",
+ "packages": ["foo"],
+ "managed_files": [
+ {
+ "path": "/etc/foo.conf",
+ "src_rel": "etc/foo.conf",
+ "owner": "root",
+ "group": "root",
+ "mode": "0644",
+ "reason": "modified_conffile",
+ },
+ # Unknown reason should fall back to generic text.
+ {
+ "path": "/etc/odd.conf",
+ "src_rel": "etc/odd.conf",
+ "owner": "root",
+ "group": "root",
+ "mode": "0644",
+ "reason": "mystery_reason",
+ },
+ ],
+ "excluded": [],
+ "notes": [],
+ }
+ ],
+ "packages": [
+ {
+ "package": "bar",
+ "role_name": "bar",
+ "managed_files": [],
+ "excluded": [],
+ "notes": [],
+ }
+ ],
+ "extra_paths": {
+ "role_name": "extra_paths",
+ "include_patterns": ["/etc/a", "/etc/b"],
+ "exclude_patterns": ["/etc/x", "/etc/y"],
+ "managed_files": [],
+ "excluded": [],
+ "notes": [],
+ },
+ "apt_config": {
+ "role_name": "apt_config",
+ "managed_files": [],
+ "excluded": [],
+ "notes": [],
+ },
+ "dnf_config": {
+ "role_name": "dnf_config",
+ "managed_files": [],
+ "excluded": [],
+ "notes": [],
+ },
+ "etc_custom": {
+ "role_name": "etc_custom",
+ "managed_files": [],
+ "excluded": [],
+ "notes": [],
+ },
+ "usr_local_custom": {
+ "role_name": "usr_local_custom",
+ "managed_files": [],
+ "excluded": [],
+ "notes": [],
+ },
+ },
+ }
+
+ state_path = _write_state(bundle, state)
+
+ out = ex.explain_state(str(state_path), fmt="text", max_examples=1)
+
+ assert "Enroll explained:" in out
+ assert "Host: h1" in out
+ assert "Inventory" in out
+ # observed_via summary should include both kinds (order not strictly guaranteed)
+ assert "observed_via" in out
+ assert "systemd_unit" in out
+ assert "user_installed" in out
+
+ # extra_paths include/exclude patterns should be rendered with max_examples truncation.
+ assert "include_patterns:" in out
+ assert "/etc/a" in out
+ assert "exclude_patterns:" in out
+
+ # Reasons section should mention known and unknown reasons.
+ assert "modified_conffile" in out
+ assert "mystery_reason" in out
+ assert "Captured with reason 'mystery_reason'" in out
+
+ # Excluded paths section.
+ assert "Why paths were excluded" in out
+ assert "sensitive_content" in out
+
+
+def test_explain_state_json_contains_structured_report(tmp_path: Path):
+ bundle = tmp_path / "bundle"
+ state = {
+ "schema_version": 3,
+ "host": {"hostname": "h2", "os": "rhel", "pkg_backend": "rpm"},
+ "enroll": {"version": "1.2.3"},
+ "inventory": {"packages": {}},
+ "roles": {
+ "users": {
+ "role_name": "users",
+ "users": [],
+ "managed_files": [],
+ "excluded": [],
+ "notes": [],
+ },
+ "services": [],
+ "packages": [],
+ "apt_config": {
+ "role_name": "apt_config",
+ "managed_files": [],
+ "excluded": [],
+ "notes": [],
+ },
+ "dnf_config": {
+ "role_name": "dnf_config",
+ "managed_files": [],
+ "excluded": [],
+ "notes": [],
+ },
+ "etc_custom": {
+ "role_name": "etc_custom",
+ "managed_files": [],
+ "excluded": [],
+ "notes": [],
+ },
+ "usr_local_custom": {
+ "role_name": "usr_local_custom",
+ "managed_files": [],
+ "excluded": [],
+ "notes": [],
+ },
+ "extra_paths": {
+ "role_name": "extra_paths",
+ "include_patterns": [],
+ "exclude_patterns": [],
+ "managed_files": [],
+ "excluded": [],
+ "notes": [],
+ },
+ },
+ }
+ state_path = _write_state(bundle, state)
+
+ raw = ex.explain_state(str(state_path), fmt="json", max_examples=2)
+ rep = json.loads(raw)
+ assert rep["host"]["hostname"] == "h2"
+ assert rep["enroll"]["version"] == "1.2.3"
+ assert rep["inventory"]["package_count"] == 0
+ assert isinstance(rep["roles"], list)
+ assert "reasons" in rep
diff --git a/tests/test_fsutil_extra.py b/tests/test_fsutil_extra.py
new file mode 100644
index 0000000..9b70a67
--- /dev/null
+++ b/tests/test_fsutil_extra.py
@@ -0,0 +1,31 @@
+from __future__ import annotations
+
+import os
+from pathlib import Path
+
+
+def test_stat_triplet_falls_back_to_numeric_ids(tmp_path: Path, monkeypatch):
+ """If uid/gid cannot be resolved, stat_triplet should return numeric strings."""
+ from enroll.fsutil import stat_triplet
+
+ p = tmp_path / "f"
+ p.write_text("x", encoding="utf-8")
+ os.chmod(p, 0o644)
+
+ import grp
+ import pwd
+
+ def _no_user(_uid): # pragma: no cover - executed via monkeypatch
+ raise KeyError
+
+ def _no_group(_gid): # pragma: no cover - executed via monkeypatch
+ raise KeyError
+
+ monkeypatch.setattr(pwd, "getpwuid", _no_user)
+ monkeypatch.setattr(grp, "getgrgid", _no_group)
+
+ owner, group, mode = stat_triplet(str(p))
+
+ assert owner.isdigit()
+ assert group.isdigit()
+ assert mode == "0644"
diff --git a/tests/test_harvest.py b/tests/test_harvest.py
index 1b884aa..93dfd90 100644
--- a/tests/test_harvest.py
+++ b/tests/test_harvest.py
@@ -1,9 +1,28 @@
import json
+import os
+import pytest
+
from pathlib import Path
-import enroll.harvest as h
+import enroll.harvest as harvest
from enroll.platform import PlatformInfo
from enroll.systemd import UnitInfo
+from enroll.pathfilter import PathFilter
+from enroll.harvest import (
+ _is_confish,
+ _hint_names,
+ _topdirs_for_package,
+ _iter_matching_files,
+ _parse_apt_signed_by,
+ _capture_link,
+ _capture_file,
+ ManagedFile,
+ ManagedLink,
+ ExcludedFile,
+ IgnorePolicy,
+)
+
+from unittest.mock import MagicMock
class AllowAllPolicy:
@@ -154,17 +173,17 @@ def test_harvest_dedup_manual_packages_and_builds_etc_custom(
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)
+ monkeypatch.setattr(harvest.os.path, "isfile", fake_isfile)
+ monkeypatch.setattr(harvest.os.path, "isdir", fake_isdir)
+ monkeypatch.setattr(harvest.os.path, "islink", fake_islink)
+ monkeypatch.setattr(harvest.os.path, "exists", fake_exists)
+ monkeypatch.setattr(harvest.os, "walk", fake_walk)
# Avoid real system access
- monkeypatch.setattr(h, "list_enabled_services", lambda: ["openvpn.service"])
- monkeypatch.setattr(h, "list_enabled_timers", lambda: [])
+ monkeypatch.setattr(harvest, "list_enabled_services", lambda: ["openvpn.service"])
+ monkeypatch.setattr(harvest, "list_enabled_timers", lambda: [])
monkeypatch.setattr(
- h,
+ harvest,
"get_unit_info",
lambda unit: UnitInfo(
name=unit,
@@ -199,11 +218,11 @@ def test_harvest_dedup_manual_packages_and_builds_etc_custom(
)
monkeypatch.setattr(
- h, "detect_platform", lambda: PlatformInfo("debian", "dpkg", {})
+ harvest, "detect_platform", lambda: PlatformInfo("debian", "dpkg", {})
)
- monkeypatch.setattr(h, "get_backend", lambda info=None: backend)
+ monkeypatch.setattr(harvest, "get_backend", lambda info=None: backend)
- monkeypatch.setattr(h, "collect_non_system_users", lambda: [])
+ monkeypatch.setattr(harvest, "collect_non_system_users", lambda: [])
def fake_stat_triplet(p: str):
if p == "/usr/local/bin/myscript":
@@ -211,7 +230,7 @@ def test_harvest_dedup_manual_packages_and_builds_etc_custom(
# /usr/local/bin/readme.txt remains non-executable
return ("root", "root", "0644")
- monkeypatch.setattr(h, "stat_triplet", fake_stat_triplet)
+ monkeypatch.setattr(harvest, "stat_triplet", fake_stat_triplet)
# 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):
@@ -219,9 +238,9 @@ def test_harvest_dedup_manual_packages_and_builds_etc_custom(
dst.parent.mkdir(parents=True, exist_ok=True)
dst.write_bytes(files.get(abs_path, b""))
- monkeypatch.setattr(h, "_copy_into_bundle", fake_copy)
+ monkeypatch.setattr(harvest, "_copy_into_bundle", fake_copy)
- state_path = h.harvest(str(bundle), policy=AllowAllPolicy())
+ state_path = harvest.harvest(str(bundle), policy=AllowAllPolicy())
st = json.loads(Path(state_path).read_text(encoding="utf-8"))
inv = st["inventory"]["packages"]
@@ -274,21 +293,25 @@ def test_shared_cron_snippet_prefers_matching_role_over_lexicographic(
files = {"/etc/cron.d/ntpsec": b"# cron\n"}
dirs = {"/etc", "/etc/cron.d"}
- monkeypatch.setattr(h.os.path, "isfile", lambda p: p in files)
- monkeypatch.setattr(h.os.path, "islink", lambda p: False)
- monkeypatch.setattr(h.os.path, "isdir", lambda p: p in dirs)
- monkeypatch.setattr(h.os.path, "exists", lambda p: p in files or p in dirs)
- monkeypatch.setattr(h.os, "walk", lambda root: [("/etc/cron.d", [], ["ntpsec"])])
+ monkeypatch.setattr(harvest.os.path, "isfile", lambda p: p in files)
+ monkeypatch.setattr(harvest.os.path, "islink", lambda p: False)
+ monkeypatch.setattr(harvest.os.path, "isdir", lambda p: p in dirs)
+ monkeypatch.setattr(harvest.os.path, "exists", lambda p: p in files or p in dirs)
+ monkeypatch.setattr(
+ harvest.os, "walk", lambda root: [("/etc/cron.d", [], ["ntpsec"])]
+ )
# Only include the cron snippet in the system capture set.
monkeypatch.setattr(
- h, "_iter_system_capture_paths", lambda: [("/etc/cron.d/ntpsec", "system_cron")]
+ harvest,
+ "_iter_system_capture_paths",
+ lambda: [("/etc/cron.d/ntpsec", "system_cron")],
)
monkeypatch.setattr(
- h, "list_enabled_services", lambda: ["apparmor.service", "ntpsec.service"]
+ harvest, "list_enabled_services", lambda: ["apparmor.service", "ntpsec.service"]
)
- monkeypatch.setattr(h, "list_enabled_timers", lambda: [])
+ monkeypatch.setattr(harvest, "list_enabled_timers", lambda: [])
def fake_unit_info(unit: str) -> UnitInfo:
if unit == "apparmor.service":
@@ -315,7 +338,7 @@ def test_shared_cron_snippet_prefers_matching_role_over_lexicographic(
condition_result=None,
)
- monkeypatch.setattr(h, "get_unit_info", fake_unit_info)
+ monkeypatch.setattr(harvest, "get_unit_info", fake_unit_info)
# Make apparmor *also* claim the ntpsec package (simulates overly-broad
# package inference). The snippet routing should still prefer role 'ntpsec'.
@@ -340,21 +363,21 @@ def test_shared_cron_snippet_prefers_matching_role_over_lexicographic(
)
monkeypatch.setattr(
- h, "detect_platform", lambda: PlatformInfo("debian", "dpkg", {})
+ harvest, "detect_platform", lambda: PlatformInfo("debian", "dpkg", {})
)
- monkeypatch.setattr(h, "get_backend", lambda info=None: backend)
+ monkeypatch.setattr(harvest, "get_backend", lambda info=None: backend)
- monkeypatch.setattr(h, "stat_triplet", lambda p: ("root", "root", "0644"))
- monkeypatch.setattr(h, "collect_non_system_users", lambda: [])
+ monkeypatch.setattr(harvest, "stat_triplet", lambda p: ("root", "root", "0644"))
+ monkeypatch.setattr(harvest, "collect_non_system_users", lambda: [])
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[abs_path])
- monkeypatch.setattr(h, "_copy_into_bundle", fake_copy)
+ monkeypatch.setattr(harvest, "_copy_into_bundle", fake_copy)
- state_path = h.harvest(str(bundle), policy=AllowAllPolicy())
+ state_path = harvest.harvest(str(bundle), policy=AllowAllPolicy())
st = json.loads(Path(state_path).read_text(encoding="utf-8"))
# Cron snippet should end up attached to the ntpsec role, not apparmor.
@@ -367,3 +390,647 @@ def test_shared_cron_snippet_prefers_matching_role_over_lexicographic(
assert all(
mf["path"] != "/etc/cron.d/ntpsec" for mf in svc_apparmor["managed_files"]
)
+
+
+def test_files_differ_binary(tmp_path: Path):
+ file1 = tmp_path / "file1.bin"
+ file2 = tmp_path / "file2.bin"
+ file1.write_bytes(b"\x00\x01\x02\x03")
+ file2.write_bytes(b"\x00\x01\x02\x03")
+ assert harvest._files_differ(str(file1), str(file2)) is False
+
+
+def test_files_differ_binary_different(tmp_path: Path):
+ file1 = tmp_path / "file1.bin"
+ file2 = tmp_path / "file2.bin"
+ file1.write_bytes(b"\x00\x01\x02\x03")
+ file2.write_bytes(b"\x00\x01\x02\x04")
+ assert harvest._files_differ(str(file1), str(file2)) is True
+
+
+def test_files_differ_non_regular_a(tmp_path: Path):
+ directory = tmp_path / "dir"
+ directory.mkdir()
+ file1 = tmp_path / "file1.txt"
+ file1.write_text("content", encoding="utf-8")
+ assert harvest._files_differ(str(directory), str(file1)) is True
+
+
+def test_topdirs_for_package_with_multiple_paths():
+ pkg_to_etc_paths = {
+ "nginx": ["/etc/nginx/nginx.conf", "/etc/nginx/sites-enabled/default"],
+ }
+ result = harvest._topdirs_for_package("nginx", pkg_to_etc_paths)
+ assert result == {"nginx"}
+
+
+def test_topdirs_for_package_with_multiple_topdirs():
+ pkg_to_etc_paths = {
+ "multi": ["/etc/nginx/nginx.conf", "/etc/ssh/sshd_config"],
+ }
+ result = harvest._topdirs_for_package("multi", pkg_to_etc_paths)
+ assert result == {"nginx", "ssh"}
+
+
+def test_topdirs_for_package_empty():
+ result = harvest._topdirs_for_package("empty", {})
+ assert result == set()
+
+
+def test_topdirs_for_package_no_etc():
+ pkg_to_etc_paths = {
+ "other": ["/usr/share/doc/file"],
+ }
+ result = harvest._topdirs_for_package("other", pkg_to_etc_paths)
+ assert result == set()
+
+
+def test_files_differ_same_content(tmp_path: Path):
+ """Test that _files_differ returns False for identical content."""
+ file_a = tmp_path / "a.txt"
+ file_b = tmp_path / "b.txt"
+ file_a.write_text("same content", encoding="utf-8")
+ file_b.write_text("same content", encoding="utf-8")
+ assert harvest._files_differ(str(file_a), str(file_b)) is False
+
+
+def test_files_differ_different_content(tmp_path: Path):
+ """Test that _files_differ returns True for different content."""
+ file_a = tmp_path / "a.txt"
+ file_b = tmp_path / "b.txt"
+ file_a.write_text("content a", encoding="utf-8")
+ file_b.write_text("content b", encoding="utf-8")
+ assert harvest._files_differ(str(file_a), str(file_b)) is True
+
+
+def test_files_differ_missing_file(tmp_path: Path):
+ """Test that _files_differ returns True when one file is missing."""
+ file_a = tmp_path / "a.txt"
+ file_a.write_text("content", encoding="utf-8")
+ file_b = tmp_path / "b.txt"
+ assert harvest._files_differ(str(file_a), str(file_b)) is True
+
+
+def test_files_differ_both_missing(tmp_path: Path):
+ """Test that _files_differ returns True when both files are missing."""
+ file_a = tmp_path / "a.txt"
+ file_b = tmp_path / "b.txt"
+ # Both missing - should return True (they differ in the sense that neither exists)
+ assert harvest._files_differ(str(file_a), str(file_b)) is True
+
+
+def test_files_differ_non_regular_b(tmp_path: Path):
+ """Test that _files_differ handles non-regular file (symlink)."""
+ file_a = tmp_path / "a.txt"
+ file_a.write_text("content", encoding="utf-8")
+ link_b = tmp_path / "link"
+ link_b.symlink_to(file_a)
+ # Symlinks are followed, so content is the same
+ assert harvest._files_differ(str(file_a), str(link_b)) is False
+
+
+def test_files_differ_oserror_on_read(tmp_path: Path, monkeypatch):
+ """Test that _files_differ returns True on OSError during read."""
+ file_a = tmp_path / "a.txt"
+ file_b = tmp_path / "b.txt"
+ file_a.write_text("content", encoding="utf-8")
+ file_b.write_text("content", encoding="utf-8")
+
+ def fake_open(path, *args, **kwargs):
+ raise OSError("Permission denied")
+
+ monkeypatch.setattr("builtins.open", fake_open, raising=False)
+ assert harvest._files_differ(str(file_a), str(file_b)) is True
+
+
+def test_files_differ_large_file_returns_true(tmp_path: Path):
+ """Test that _files_differ returns True for files larger than max_bytes."""
+ file_a = tmp_path / "a.bin"
+ file_b = tmp_path / "b.bin"
+ # Create files larger than default max_bytes (2MB)
+ data = b"x" * 3_000_000
+ file_a.write_bytes(data)
+ file_b.write_bytes(data)
+ # Should return True because files are too large
+ assert harvest._files_differ(str(file_a), str(file_b), max_bytes=1_000_000) is True
+
+
+def test_files_differ_size_mismatch(tmp_path: Path):
+ """Test that _files_differ detects size mismatch quickly."""
+ file_a = tmp_path / "a.txt"
+ file_b = tmp_path / "b.txt"
+ file_a.write_text("short", encoding="utf-8")
+ file_b.write_text("much longer content here", encoding="utf-8")
+ assert harvest._files_differ(str(file_a), str(file_b)) is True
+
+
+def test_files_differ_large_files(tmp_path: Path):
+ """Test that _files_differ handles large files efficiently."""
+ file_a = tmp_path / "a.bin"
+ file_b = tmp_path / "b.bin"
+ # Create files with same content but large
+ data = b"x" * 10000
+ file_a.write_bytes(data)
+ file_b.write_bytes(data)
+ assert harvest._files_differ(str(file_a), str(file_b)) is False
+
+
+def test_hint_names_with_unit_and_packages():
+ """Test _hint_names extracts hints from unit and packages."""
+ result = harvest._hint_names("nginx.service", {"nginx-common", "nginx-core"})
+ assert "nginx" in result
+ assert "nginx-common" in result
+ assert "nginx-core" in result
+
+
+def test_hint_names_with_template_unit():
+ """Test _hint_names handles template units."""
+ result = harvest._hint_names("getty@tty1.service", set())
+ assert "getty" in result
+ assert "getty@tty1" in result
+
+
+def test_hint_names_with_dotted_unit():
+ """Test _hint_names handles dotted unit names."""
+ result = harvest._hint_names("nginx.service", set())
+ assert "nginx" in result
+
+
+def test_hint_names_empty():
+ """Test _hint_names with empty inputs."""
+ result = harvest._hint_names("", set())
+ assert result == set()
+
+
+def test_add_pkgs_from_etc_topdirs():
+ """Test _add_pkgs_from_etc_topdirs expands hints."""
+ hints = {"nginx"}
+ topdir_to_pkgs = {
+ "nginx": {"nginx-common", "nginx-core"},
+ "ssh": {"openssh-server"},
+ }
+ pkgs = set()
+ harvest._add_pkgs_from_etc_topdirs(hints, topdir_to_pkgs, pkgs)
+ # Should add packages from matching topdirs
+ assert "nginx-common" in pkgs or "nginx-core" in pkgs
+
+
+def test_add_pkgs_from_etc_topdirs_empty():
+ """Test _add_pkgs_from_etc_topdirs with empty inputs."""
+ hints = set()
+ topdir_to_pkgs = {}
+ pkgs = set()
+ harvest._add_pkgs_from_etc_topdirs(hints, topdir_to_pkgs, pkgs)
+ assert pkgs == set()
+
+
+def test_is_confish_with_conf(tmp_path: Path):
+ """Test _is_confish recognizes .conf files."""
+ file1 = tmp_path / "test.conf"
+ file1.write_text("[Unit]", encoding="utf-8")
+ assert harvest._is_confish(str(file1)) is True
+
+
+def test_is_confish_with_yaml(tmp_path: Path):
+ """Test _is_confish recognizes .yaml files."""
+ file1 = tmp_path / "test.yaml"
+ file1.write_text("key: value", encoding="utf-8")
+ assert harvest._is_confish(str(file1)) is True
+
+
+def test_is_confish_with_json(tmp_path: Path):
+ """Test _is_confish recognizes .json files."""
+ file1 = tmp_path / "test.json"
+ file1.write_text('{"key": "value"}', encoding="utf-8")
+ assert harvest._is_confish(str(file1)) is True
+
+
+def test_is_confish_with_service(tmp_path: Path):
+ """Test _is_confish recognizes .service files."""
+ file1 = tmp_path / "test.service"
+ file1.write_text("[Unit]", encoding="utf-8")
+ assert harvest._is_confish(str(file1)) is True
+
+
+def test_is_confish_with_extensionless(tmp_path: Path):
+ """Test _is_confish recognizes extensionless config files."""
+ file1 = tmp_path / "default"
+ file1.write_text("OPTIONS=", encoding="utf-8")
+ assert harvest._is_confish(str(file1)) is True
+
+
+def test_is_confish_not_config(tmp_path: Path):
+ """Test _is_confish rejects non-config files."""
+ file1 = tmp_path / "test.log"
+ file1.write_text("log", encoding="utf-8")
+ assert harvest._is_confish(str(file1)) is False
+
+
+def test_is_confish_nonexistent():
+ """Test _is_confish returns False for nonexistent files."""
+ assert harvest._is_confish("/nonexistent/file.xyz") is False
+
+
+"""Additional coverage tests for harvest.py"""
+
+
+class TestIsConfish:
+ """Tests for _is_confish function"""
+
+ def test_is_confish_true_extensions(self, tmp_path):
+ """Test files with config extensions are detected."""
+ for ext in [".conf", ".cfg", ".ini", ".yaml", ".json", ".cnf"]:
+ f = tmp_path / f"test{ext}"
+ f.write_text("test", encoding="utf-8")
+ assert _is_confish(str(f)) is True
+
+ def test_is_confish_false(self, tmp_path):
+ """Test non-config files are not detected."""
+ for name in ["data.txt", "script.sh"]:
+ f = tmp_path / name
+ f.write_text("test", encoding="utf-8")
+ assert _is_confish(str(f)) is False
+
+
+class TestHintNames:
+ """Tests for _hint_names function"""
+
+ def test_hint_names_simple(self):
+ """Test simple hint name extraction."""
+ result = _hint_names("nginx", {"nginx"})
+ assert "nginx" in result
+
+ def test_hint_names_multiple(self):
+ """Test multiple hint names."""
+ result = _hint_names("nginx", {"apache"})
+ assert "nginx" in result
+ assert "apache" in result
+
+ def test_hint_names_empty(self):
+ """Test empty hint names."""
+ result = _hint_names("", set())
+ assert result == set()
+
+ def test_hint_names_with_service(self):
+ """Test hint names with .service suffix."""
+ result = _hint_names("nginx.service", set())
+ assert "nginx" in result
+
+ def test_hint_names_with_template(self):
+ """Test hint names with template unit."""
+ result = _hint_names("nginx@.service", set())
+ assert "nginx" in result
+
+
+class TestTopdirsForPackage:
+ """Tests for _topdirs_for_package function"""
+
+ def test_topdirs_single_level(self):
+ """Test topdirs with single level paths."""
+ pkg_to_etc = {"nginx": ["/etc/nginx/nginx.conf"]}
+ result = _topdirs_for_package("nginx", pkg_to_etc)
+ assert result == {"nginx"}
+
+ def test_topdirs_multiple_paths(self):
+ """Test topdirs with multiple paths."""
+ pkg_to_etc = {"nginx": ["/etc/nginx/nginx.conf", "/etc/nginx/sites-enabled"]}
+ result = _topdirs_for_package("nginx", pkg_to_etc)
+ assert result == {"nginx"}
+
+ def test_topdirs_empty(self):
+ """Test topdirs with empty package."""
+ result = _topdirs_for_package("nonexistent", {})
+ assert result == set()
+
+
+class TestIterMatchingFiles:
+ """Tests for _iter_matching_files function"""
+
+ def test_iter_matching_files_glob(self, tmp_path):
+ """Test glob pattern matching."""
+ (tmp_path / "a.txt").write_text("a", encoding="utf-8")
+ (tmp_path / "b.txt").write_text("b", encoding="utf-8")
+ (tmp_path / "c.py").write_text("c", encoding="utf-8")
+
+ os.chdir(tmp_path)
+ result = _iter_matching_files("*.txt")
+ assert len(result) == 2
+ assert any("a.txt" in p for p in result)
+ assert any("b.txt" in p for p in result)
+
+ def test_iter_matching_files_directory_walk(self, tmp_path):
+ """Test directory walking."""
+ subdir = tmp_path / "sub"
+ subdir.mkdir()
+ (tmp_path / "a.txt").write_text("a", encoding="utf-8")
+ (subdir / "b.txt").write_text("b", encoding="utf-8")
+
+ os.chdir(tmp_path)
+ result = _iter_matching_files(str(tmp_path))
+ assert len(result) == 2
+
+ def test_iter_matching_files_cap(self, tmp_path):
+ """Test file cap limit."""
+ for i in range(100):
+ (tmp_path / f"file{i}.txt").write_text(str(i), encoding="utf-8")
+
+ os.chdir(tmp_path)
+ result = _iter_matching_files("*.txt", cap=10)
+ assert len(result) == 10
+
+
+class TestParseAptSignedBy:
+ """Tests for _parse_apt_signed_by function"""
+
+ def test_parse_apt_signed_by_bracket(self, tmp_path):
+ """Test parsing signed-by from bracket notation."""
+ sources_list = tmp_path / "sources.list"
+ sources_list.write_text(
+ "deb [signed-by=/usr/share/keyrings/nginx.gpg] http://nginx.net stable main\n",
+ encoding="utf-8",
+ )
+ result = _parse_apt_signed_by([str(sources_list)])
+ assert "/usr/share/keyrings/nginx.gpg" in result
+
+ def test_parse_apt_signed_by_header(self, tmp_path):
+ """Test parsing signed-by from header."""
+ sources_file = tmp_path / "sources.list"
+ sources_file.write_text(
+ "Signed-By: /usr/share/keyrings/foo.gpg\n", encoding="utf-8"
+ )
+ result = _parse_apt_signed_by([str(sources_file)])
+ assert "/usr/share/keyrings/foo.gpg" in result
+
+ def test_parse_apt_signed_by_multiple(self, tmp_path):
+ """Test parsing multiple signed-by paths."""
+ sources_file = tmp_path / "sources.list"
+ sources_file.write_text(
+ "Signed-By: /usr/share/keyrings/a.gpg, /usr/share/keyrings/b.gpg\n",
+ encoding="utf-8",
+ )
+ result = _parse_apt_signed_by([str(sources_file)])
+ assert "/usr/share/keyrings/a.gpg" in result
+ assert "/usr/share/keyrings/b.gpg" in result
+
+ def test_parse_apt_signed_by_oserror(self, tmp_path):
+ """Test handling of unreadable files."""
+ result = _parse_apt_signed_by(["/nonexistent/file"])
+ assert result == set()
+
+
+class TestCaptureLink:
+ """Tests for _capture_link function"""
+
+ def test_capture_link_basic(self, tmp_path):
+ """Test basic link capture."""
+ target = tmp_path / "target.txt"
+ target.write_text("content", encoding="utf-8")
+ link = tmp_path / "link.txt"
+ link.symlink_to(target)
+
+ policy = MagicMock(spec=IgnorePolicy)
+ policy.deny_reason_link = None
+ policy.deny_reason = MagicMock(return_value=None)
+ policy.deny_reason_link = None # No special link denial
+
+ managed: list[ManagedLink] = []
+ excluded: list[ExcludedFile] = []
+ path_filter = PathFilter([], [])
+
+ result = _capture_link(
+ role_name="test_role",
+ abs_path=str(link),
+ reason="test",
+ policy=policy,
+ path_filter=path_filter,
+ managed_out=managed,
+ excluded_out=excluded,
+ seen_role=set(),
+ seen_global=set(),
+ )
+ assert result is True
+ assert len(managed) == 1
+ assert managed[0].path == str(link)
+
+ def test_capture_link_deny(self, tmp_path):
+ """Test link capture with deny policy."""
+ target = tmp_path / "target.txt"
+ target.write_text("content", encoding="utf-8")
+ link = tmp_path / "link.txt"
+ link.symlink_to(target)
+
+ policy = MagicMock(spec=IgnorePolicy)
+ policy.deny_reason_link = None
+ policy.deny_reason = MagicMock(return_value="policy_deny")
+
+ managed: list[ManagedLink] = []
+ excluded: list[ExcludedFile] = []
+ path_filter = PathFilter([], [])
+
+ result = _capture_link(
+ role_name="test_role",
+ abs_path=str(link),
+ reason="test",
+ policy=policy,
+ path_filter=path_filter,
+ managed_out=managed,
+ excluded_out=excluded,
+ seen_role=set(),
+ seen_global=set(),
+ )
+ assert result is False
+ assert len(excluded) == 1
+
+ def test_capture_link_not_symlink(self, tmp_path):
+ """Test that regular files are rejected."""
+ f = tmp_path / "file.txt"
+ f.write_text("content", encoding="utf-8")
+
+ policy = MagicMock(spec=IgnorePolicy)
+ policy.deny_reason_link = None
+ policy.deny_reason = MagicMock(return_value=None)
+
+ managed: list[ManagedLink] = []
+ excluded: list[ExcludedFile] = []
+ path_filter = PathFilter([], [])
+
+ result = _capture_link(
+ role_name="test_role",
+ abs_path=str(f),
+ reason="test",
+ policy=policy,
+ path_filter=path_filter,
+ managed_out=managed,
+ excluded_out=excluded,
+ seen_role=set(),
+ seen_global=set(),
+ )
+ assert result is False
+ assert len(excluded) == 1
+
+ def test_capture_link_seen_role(self, tmp_path):
+ """Test link capture with seen_role deduplication."""
+ target = tmp_path / "target.txt"
+ target.write_text("content", encoding="utf-8")
+ link = tmp_path / "link.txt"
+ link.symlink_to(target)
+
+ policy = MagicMock(spec=IgnorePolicy)
+ policy.deny_reason_link = None
+ policy.deny_reason = MagicMock(return_value=None)
+
+ managed: list[ManagedLink] = []
+ excluded: list[ExcludedFile] = []
+ path_filter = PathFilter([], [])
+ seen_role = {str(link)}
+
+ result = _capture_link(
+ role_name="test_role",
+ abs_path=str(link),
+ reason="test",
+ policy=policy,
+ path_filter=path_filter,
+ managed_out=managed,
+ excluded_out=excluded,
+ seen_role=seen_role,
+ seen_global=None,
+ )
+ assert result is False
+ assert len(managed) == 0
+
+ def test_capture_link_seen_global(self, tmp_path):
+ """Test link capture with seen_global deduplication."""
+ target = tmp_path / "target.txt"
+ target.write_text("content", encoding="utf-8")
+ link = tmp_path / "link.txt"
+ link.symlink_to(target)
+
+ policy = MagicMock(spec=IgnorePolicy)
+ policy.deny_reason_link = None
+ policy.deny_reason = MagicMock(return_value=None)
+
+ managed: list[ManagedLink] = []
+ excluded: list[ExcludedFile] = []
+ path_filter = PathFilter([], [])
+ seen_global = {str(link)}
+
+ result = _capture_link(
+ role_name="test_role",
+ abs_path=str(link),
+ reason="test",
+ policy=policy,
+ path_filter=path_filter,
+ managed_out=managed,
+ excluded_out=excluded,
+ seen_role=None,
+ seen_global=seen_global,
+ )
+ assert result is False
+ assert len(managed) == 0
+
+
+class TestCaptureFile:
+ """Tests for _capture_file function"""
+
+ def test_capture_file_basic(self, tmp_path):
+ """Test basic file capture."""
+ bundle = tmp_path / "bundle"
+ bundle.mkdir()
+ (bundle / "artifacts").mkdir()
+
+ source = tmp_path / "source.txt"
+ source.write_text("content", encoding="utf-8")
+
+ policy = MagicMock(spec=IgnorePolicy)
+ policy.deny_reason_link = None
+ policy.deny_reason = MagicMock(return_value=None)
+
+ managed: list[ManagedFile] = []
+ excluded: list[ExcludedFile] = []
+ path_filter = PathFilter([], [])
+
+ result = _capture_file(
+ bundle_dir=str(bundle),
+ role_name="test_role",
+ abs_path=str(source),
+ reason="test",
+ policy=policy,
+ path_filter=path_filter,
+ managed_out=managed,
+ excluded_out=excluded,
+ seen_role=set(),
+ seen_global=set(),
+ metadata=None,
+ )
+ assert result is True
+ assert len(managed) == 1
+
+ def test_capture_file_seen_role(self, tmp_path):
+ """Test file capture with seen_role deduplication."""
+ bundle = tmp_path / "bundle"
+ bundle.mkdir()
+
+ source = tmp_path / "source.txt"
+ source.write_text("content", encoding="utf-8")
+
+ policy = MagicMock(spec=IgnorePolicy)
+ policy.deny_reason_link = None
+ policy.deny_reason = MagicMock(return_value=None)
+
+ managed: list[ManagedFile] = []
+ excluded: list[ExcludedFile] = []
+ path_filter = PathFilter([], [])
+ seen_role = {str(source)}
+
+ result = _capture_file(
+ bundle_dir=str(bundle),
+ role_name="test_role",
+ abs_path=str(source),
+ reason="test",
+ policy=policy,
+ path_filter=path_filter,
+ managed_out=managed,
+ excluded_out=excluded,
+ seen_role=seen_role,
+ seen_global=None,
+ metadata=None,
+ )
+ assert result is False
+ assert len(managed) == 0
+
+ def test_capture_file_seen_global(self, tmp_path):
+ """Test file capture with seen_global deduplication."""
+ bundle = tmp_path / "bundle"
+ bundle.mkdir()
+
+ source = tmp_path / "source.txt"
+ source.write_text("content", encoding="utf-8")
+
+ policy = MagicMock(spec=IgnorePolicy)
+ policy.deny_reason_link = None
+ policy.deny_reason = MagicMock(return_value=None)
+
+ managed: list[ManagedFile] = []
+ excluded: list[ExcludedFile] = []
+ path_filter = PathFilter([], [])
+ seen_global = {str(source)}
+
+ result = _capture_file(
+ bundle_dir=str(bundle),
+ role_name="test_role",
+ abs_path=str(source),
+ reason="test",
+ policy=policy,
+ path_filter=path_filter,
+ managed_out=managed,
+ excluded_out=excluded,
+ seen_role=None,
+ seen_global=seen_global,
+ metadata=None,
+ )
+ assert result is False
+ assert len(managed) == 0
+
+
+if __name__ == "__main__":
+ pytest.main([__file__, "-v"])
diff --git a/tests/test_harvest_cron_logrotate.py b/tests/test_harvest_cron_logrotate.py
new file mode 100644
index 0000000..d20d371
--- /dev/null
+++ b/tests/test_harvest_cron_logrotate.py
@@ -0,0 +1,164 @@
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+import enroll.harvest as h
+from enroll.platform import PlatformInfo
+from enroll.systemd import UnitInfo
+
+
+class AllowAllPolicy:
+ def deny_reason(self, path: str):
+ return None
+
+
+class FakeBackend:
+ def __init__(
+ self,
+ *,
+ name: str,
+ installed: dict[str, list[dict[str, str]]],
+ manual: list[str],
+ ):
+ self.name = name
+ self._installed = dict(installed)
+ self._manual = list(manual)
+
+ def build_etc_index(self):
+ # No package ownership information needed for this test.
+ return set(), {}, {}, {}
+
+ def installed_packages(self):
+ return dict(self._installed)
+
+ def list_manual_packages(self):
+ return list(self._manual)
+
+ def owner_of_path(self, path: str):
+ return None
+
+ def specific_paths_for_hints(self, hints: set[str]):
+ return []
+
+ def is_pkg_config_path(self, path: str) -> bool:
+ return False
+
+ def modified_paths(self, pkg: str, etc_paths: list[str]):
+ return {}
+
+
+def test_harvest_unifies_cron_and_logrotate_into_dedicated_package_roles(
+ monkeypatch, tmp_path: Path
+):
+ bundle = tmp_path / "bundle"
+
+ # Fake files we want harvested.
+ files = {
+ "/etc/crontab": b"* * * * * root echo hi\n",
+ "/etc/cron.d/php": b"# php cron\n",
+ "/var/spool/cron/crontabs/alice": b"@daily echo user\n",
+ "/etc/logrotate.conf": b"weekly\n",
+ "/etc/logrotate.d/rsyslog": b"/var/log/syslog { rotate 7 }\n",
+ }
+
+ monkeypatch.setattr(h.os.path, "islink", lambda p: False)
+ monkeypatch.setattr(h.os.path, "isfile", lambda p: p in files)
+ monkeypatch.setattr(h.os.path, "isdir", lambda p: False)
+ monkeypatch.setattr(h.os.path, "exists", lambda p: (p in files) or False)
+
+ # Expand cron/logrotate globs deterministically.
+ def fake_iter_matching(spec: str, cap: int = 10000):
+ mapping = {
+ "/etc/crontab": ["/etc/crontab"],
+ "/etc/cron.d/*": ["/etc/cron.d/php"],
+ "/etc/cron.hourly/*": [],
+ "/etc/cron.daily/*": [],
+ "/etc/cron.weekly/*": [],
+ "/etc/cron.monthly/*": [],
+ "/etc/cron.allow": [],
+ "/etc/cron.deny": [],
+ "/etc/anacrontab": [],
+ "/etc/anacron/*": [],
+ "/var/spool/cron/*": [],
+ "/var/spool/cron/crontabs/*": ["/var/spool/cron/crontabs/alice"],
+ "/var/spool/crontabs/*": [],
+ "/var/spool/anacron/*": [],
+ "/etc/logrotate.conf": ["/etc/logrotate.conf"],
+ "/etc/logrotate.d/*": ["/etc/logrotate.d/rsyslog"],
+ }
+ return list(mapping.get(spec, []))[:cap]
+
+ monkeypatch.setattr(h, "_iter_matching_files", fake_iter_matching)
+
+ # Avoid real system probing.
+ monkeypatch.setattr(
+ h, "detect_platform", lambda: PlatformInfo("debian", "dpkg", {})
+ )
+ backend = FakeBackend(
+ name="dpkg",
+ installed={
+ "cron": [{"version": "1", "arch": "amd64"}],
+ "logrotate": [{"version": "1", "arch": "amd64"}],
+ },
+ # Include cron/logrotate in manual packages to ensure they are skipped in the generic loop.
+ manual=["cron", "logrotate"],
+ )
+ monkeypatch.setattr(h, "get_backend", lambda info=None: backend)
+
+ # Include a service that would collide with cron role naming.
+ monkeypatch.setattr(
+ h, "list_enabled_services", lambda: ["cron.service", "foo.service"]
+ )
+ monkeypatch.setattr(h, "list_enabled_timers", lambda: [])
+ monkeypatch.setattr(
+ h,
+ "get_unit_info",
+ lambda unit: UnitInfo(
+ name=unit,
+ fragment_path=None,
+ dropin_paths=[],
+ env_files=[],
+ exec_paths=[],
+ active_state="active",
+ sub_state="running",
+ unit_file_state="enabled",
+ condition_result=None,
+ ),
+ )
+ monkeypatch.setattr(h, "collect_non_system_users", lambda: [])
+ monkeypatch.setattr(
+ h,
+ "stat_triplet",
+ lambda p: ("alice" if "alice" in p else "root", "root", "0644"),
+ )
+
+ # Avoid needing real source files 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"))
+
+ # cron.service must be skipped to avoid colliding with the dedicated "cron" package role.
+ svc_units = [s["unit"] for s in st["roles"]["services"]]
+ assert "cron.service" not in svc_units
+ assert "foo.service" in svc_units
+
+ pkgs = st["roles"]["packages"]
+ cron = next(p for p in pkgs if p["role_name"] == "cron")
+ logrotate = next(p for p in pkgs if p["role_name"] == "logrotate")
+
+ cron_paths = {mf["path"] for mf in cron["managed_files"]}
+ assert "/etc/crontab" in cron_paths
+ assert "/etc/cron.d/php" in cron_paths
+ # user crontab captured
+ assert "/var/spool/cron/crontabs/alice" in cron_paths
+
+ lr_paths = {mf["path"] for mf in logrotate["managed_files"]}
+ assert "/etc/logrotate.conf" in lr_paths
+ assert "/etc/logrotate.d/rsyslog" in lr_paths
diff --git a/tests/test_harvest_helpers.py b/tests/test_harvest_helpers.py
new file mode 100644
index 0000000..a0d2c91
--- /dev/null
+++ b/tests/test_harvest_helpers.py
@@ -0,0 +1,288 @@
+from __future__ import annotations
+
+import os
+from pathlib import Path
+
+import enroll.harvest as h
+
+
+def test_iter_matching_files_skips_symlinks_and_walks_dirs(monkeypatch, tmp_path: Path):
+ # Layout:
+ # root/real.txt (file)
+ # root/sub/nested.txt
+ # root/link -> ... (ignored)
+ root = tmp_path / "root"
+ (root / "sub").mkdir(parents=True)
+ (root / "real.txt").write_text("a", encoding="utf-8")
+ (root / "sub" / "nested.txt").write_text("b", encoding="utf-8")
+
+ paths = {
+ str(root): "dir",
+ str(root / "real.txt"): "file",
+ str(root / "sub"): "dir",
+ str(root / "sub" / "nested.txt"): "file",
+ str(root / "link"): "link",
+ }
+
+ monkeypatch.setattr(h.glob, "glob", lambda spec: [str(root), str(root / "link")])
+ monkeypatch.setattr(h.os.path, "islink", lambda p: paths.get(p) == "link")
+ monkeypatch.setattr(h.os.path, "isfile", lambda p: paths.get(p) == "file")
+ monkeypatch.setattr(h.os.path, "isdir", lambda p: paths.get(p) == "dir")
+ monkeypatch.setattr(
+ h.os,
+ "walk",
+ lambda p: [
+ (str(root), ["sub"], ["real.txt", "link"]),
+ (str(root / "sub"), [], ["nested.txt"]),
+ ],
+ )
+
+ out = h._iter_matching_files("/whatever/*", cap=100)
+ assert str(root / "real.txt") in out
+ assert str(root / "sub" / "nested.txt") in out
+ assert str(root / "link") not in out
+
+
+def test_parse_apt_signed_by_extracts_keyrings(tmp_path: Path):
+ f1 = tmp_path / "a.list"
+ f1.write_text(
+ "deb [signed-by=/usr/share/keyrings/foo.gpg] https://example.invalid stable main\n",
+ encoding="utf-8",
+ )
+ f2 = tmp_path / "b.sources"
+ f2.write_text(
+ "Types: deb\nSigned-By: /etc/apt/keyrings/bar.gpg, /usr/share/keyrings/baz.gpg\n",
+ encoding="utf-8",
+ )
+ f3 = tmp_path / "c.sources"
+ f3.write_text("Signed-By: | /bin/echo nope\n", encoding="utf-8")
+
+ out = h._parse_apt_signed_by([str(f1), str(f2), str(f3)])
+ assert "/usr/share/keyrings/foo.gpg" in out
+ assert "/etc/apt/keyrings/bar.gpg" in out
+ assert "/usr/share/keyrings/baz.gpg" in out
+
+
+def test_iter_apt_capture_paths_includes_signed_by_keyring(monkeypatch):
+ # Simulate:
+ # /etc/apt/apt.conf.d/00test
+ # /etc/apt/sources.list.d/test.list (signed-by outside /etc/apt)
+ # /usr/share/keyrings/ext.gpg
+ files = {
+ "/etc/apt/apt.conf.d/00test": "file",
+ "/etc/apt/sources.list.d/test.list": "file",
+ "/usr/share/keyrings/ext.gpg": "file",
+ }
+
+ monkeypatch.setattr(h.os.path, "isdir", lambda p: p in {"/etc/apt"})
+ monkeypatch.setattr(
+ h.os,
+ "walk",
+ lambda root: [
+ ("/etc/apt", ["apt.conf.d", "sources.list.d"], []),
+ ("/etc/apt/apt.conf.d", [], ["00test"]),
+ ("/etc/apt/sources.list.d", [], ["test.list"]),
+ ],
+ )
+ monkeypatch.setattr(h.os.path, "islink", lambda p: False)
+ monkeypatch.setattr(h.os.path, "isfile", lambda p: files.get(p) == "file")
+
+ # Only treat the sources glob as having a hit.
+ def fake_iter_matching(spec: str, cap: int = 10000):
+ if spec == "/etc/apt/sources.list.d/*.list":
+ return ["/etc/apt/sources.list.d/test.list"]
+ return []
+
+ monkeypatch.setattr(h, "_iter_matching_files", fake_iter_matching)
+
+ # Provide file contents for the sources file.
+ real_open = open
+
+ def fake_open(path, *a, **k):
+ if path == "/etc/apt/sources.list.d/test.list":
+ return real_open(os.devnull, "r", encoding="utf-8") # placeholder
+ return real_open(path, *a, **k)
+
+ # Easier: patch _parse_apt_signed_by directly to avoid filesystem reads.
+ monkeypatch.setattr(
+ h, "_parse_apt_signed_by", lambda sfs: {"/usr/share/keyrings/ext.gpg"}
+ )
+
+ out = h._iter_apt_capture_paths()
+ paths = {p for p, _r in out}
+ reasons = {p: r for p, r in out}
+ assert "/etc/apt/apt.conf.d/00test" in paths
+ assert "/etc/apt/sources.list.d/test.list" in paths
+ assert "/usr/share/keyrings/ext.gpg" in paths
+ assert reasons["/usr/share/keyrings/ext.gpg"] == "apt_signed_by_keyring"
+
+
+def test_iter_dnf_capture_paths(monkeypatch):
+ files = {
+ "/etc/dnf/dnf.conf": "file",
+ "/etc/yum/yum.conf": "file",
+ "/etc/yum.conf": "file",
+ "/etc/yum.repos.d/test.repo": "file",
+ "/etc/pki/rpm-gpg/RPM-GPG-KEY": "file",
+ }
+
+ def isdir(p):
+ return p in {"/etc/dnf", "/etc/yum", "/etc/yum.repos.d", "/etc/pki/rpm-gpg"}
+
+ def walk(root):
+ if root == "/etc/dnf":
+ return [("/etc/dnf", [], ["dnf.conf"])]
+ if root == "/etc/yum":
+ return [("/etc/yum", [], ["yum.conf"])]
+ if root == "/etc/pki/rpm-gpg":
+ return [("/etc/pki/rpm-gpg", [], ["RPM-GPG-KEY"])]
+ return []
+
+ monkeypatch.setattr(h.os.path, "isdir", isdir)
+ monkeypatch.setattr(h.os, "walk", walk)
+ monkeypatch.setattr(h.os.path, "islink", lambda p: False)
+ monkeypatch.setattr(h.os.path, "isfile", lambda p: files.get(p) == "file")
+ monkeypatch.setattr(
+ h,
+ "_iter_matching_files",
+ lambda spec, cap=10000: (
+ ["/etc/yum.repos.d/test.repo"] if spec.endswith("*.repo") else []
+ ),
+ )
+
+ out = h._iter_dnf_capture_paths()
+ paths = {p for p, _r in out}
+ assert "/etc/dnf/dnf.conf" in paths
+ assert "/etc/yum/yum.conf" in paths
+ assert "/etc/yum.conf" in paths
+ assert "/etc/yum.repos.d/test.repo" in paths
+ assert "/etc/pki/rpm-gpg/RPM-GPG-KEY" in paths
+
+
+def test_iter_system_capture_paths_dedupes_first_reason(monkeypatch):
+ monkeypatch.setattr(h, "_SYSTEM_CAPTURE_GLOBS", [("/a", "r1"), ("/b", "r2")])
+ monkeypatch.setattr(
+ h,
+ "_iter_matching_files",
+ lambda spec, cap=10000: ["/dup"] if spec in {"/a", "/b"} else [],
+ )
+ out = h._iter_system_capture_paths()
+ assert out == [("/dup", "r1")]
+
+
+def test_ipset_and_iptables_state_helpers(tmp_path: Path):
+ ipset_save = """create blocklist hash:ip family inet hashsize 1024 maxelem 65536
+add blocklist 203.0.113.10
+create nets hash:net family inet
+"""
+ assert h._ipset_save_has_state(ipset_save)
+ assert h._parse_ipset_set_names(ipset_save) == ["blocklist", "nets"]
+ assert not h._ipset_save_has_state("# empty\n")
+
+ empty_iptables = """*filter
+:INPUT ACCEPT [0:0]
+:FORWARD ACCEPT [0:0]
+:OUTPUT ACCEPT [0:0]
+COMMIT
+"""
+ assert not h._iptables_save_has_state(empty_iptables)
+
+ native_rule = empty_iptables.replace(
+ "COMMIT", "-A INPUT -p tcp --dport 22 -j ACCEPT\nCOMMIT"
+ )
+ assert h._iptables_save_has_state(native_rule)
+
+ changed_policy = empty_iptables.replace(":INPUT ACCEPT", ":INPUT DROP")
+ assert h._iptables_save_has_state(changed_policy)
+
+
+def test_collect_firewall_runtime_snapshot_writes_generated_artifacts(
+ monkeypatch, tmp_path: Path
+):
+ outputs = {
+ "ipset_save": (
+ "create blocklist hash:ip family inet\nadd blocklist 203.0.113.10\n",
+ None,
+ ),
+ "iptables_v4_save": (
+ "*filter\n:INPUT DROP [0:0]\n-A INPUT -m set --match-set blocklist src -j DROP\nCOMMIT\n",
+ None,
+ ),
+ "iptables_v6_save": ("*filter\n:INPUT ACCEPT [0:0]\nCOMMIT\n", None),
+ }
+
+ def fake_run(command_key, *, timeout=10):
+ return outputs[command_key]
+
+ monkeypatch.setattr(h, "_run_capture_command", fake_run)
+
+ snap = h._collect_firewall_runtime_snapshot(str(tmp_path))
+ assert snap.role_name == "firewall_runtime"
+ assert snap.packages == ["ipset", "iptables"]
+ assert snap.ipset_save == "firewall/ipset.save"
+ assert snap.ipset_sets == ["blocklist"]
+ assert snap.iptables_v4_save == "firewall/iptables.v4"
+ assert snap.iptables_v6_save is None
+
+ assert (
+ (tmp_path / "artifacts" / "firewall_runtime" / "firewall" / "ipset.save")
+ .read_text(encoding="utf-8")
+ .startswith("create blocklist")
+ )
+ assert (
+ (tmp_path / "artifacts" / "firewall_runtime" / "firewall" / "iptables.v4")
+ .read_text(encoding="utf-8")
+ .startswith("*filter")
+ )
+
+
+def test_collect_firewall_runtime_snapshot_is_per_family_fallback(
+ monkeypatch, tmp_path: Path
+):
+ calls = []
+ outputs = {
+ "ipset_save": (
+ "create blocklist hash:ip family inet\nadd blocklist 203.0.113.10\n",
+ None,
+ ),
+ "iptables_v4_save": (
+ "*filter\n:INPUT DROP [0:0]\n-A INPUT -p tcp --dport 22 -j ACCEPT\nCOMMIT\n",
+ None,
+ ),
+ "iptables_v6_save": (
+ "*filter\n:INPUT DROP [0:0]\n-A INPUT -p tcp --dport 22 -j ACCEPT\nCOMMIT\n",
+ None,
+ ),
+ }
+
+ def fake_run(command_key, *, timeout=10):
+ calls.append(command_key)
+ return outputs[command_key]
+
+ monkeypatch.setattr(h, "_run_capture_command", fake_run)
+
+ snap = h._collect_firewall_runtime_snapshot(
+ str(tmp_path),
+ persistent_ipset_files=["/etc/ipset.conf"],
+ persistent_iptables_v4_files=["/etc/iptables/rules.v4"],
+ persistent_iptables_v6_files=[],
+ )
+
+ assert "ipset_save" not in calls
+ assert "iptables_v4_save" not in calls
+ assert "iptables_v6_save" in calls
+ assert snap.ipset_save is None
+ assert snap.iptables_v4_save is None
+ assert snap.iptables_v6_save == "firewall/iptables.v6"
+ assert snap.packages == ["iptables"]
+ assert any("persistent ipset configuration" in note for note in snap.notes)
+ assert any("persistent IPv4 iptables configuration" in note for note in snap.notes)
+ assert not (
+ tmp_path / "artifacts" / "firewall_runtime" / "firewall" / "ipset.save"
+ ).exists()
+ assert not (
+ tmp_path / "artifacts" / "firewall_runtime" / "firewall" / "iptables.v4"
+ ).exists()
+ assert (
+ tmp_path / "artifacts" / "firewall_runtime" / "firewall" / "iptables.v6"
+ ).exists()
diff --git a/tests/test_harvest_symlinks.py b/tests/test_harvest_symlinks.py
new file mode 100644
index 0000000..b327542
--- /dev/null
+++ b/tests/test_harvest_symlinks.py
@@ -0,0 +1,263 @@
+import json
+from pathlib import Path
+
+import enroll.harvest as h
+from enroll.platform import PlatformInfo
+from enroll.systemd import UnitInfo
+
+
+class AllowAllPolicy:
+ def deny_reason(self, path: str):
+ return None
+
+ def deny_reason_link(self, path: str):
+ return None
+
+
+class FakeBackend:
+ """Minimal backend stub for harvest tests.
+
+ Keep harvest deterministic and avoid enumerating the real system.
+ """
+
+ name = "dpkg"
+
+ def build_etc_index(self):
+ return (set(), {}, {}, {})
+
+ def owner_of_path(self, path: str):
+ return None
+
+ def list_manual_packages(self):
+ return []
+
+ def installed_packages(self):
+ return {}
+
+ def specific_paths_for_hints(self, hints: set[str]):
+ return []
+
+ def is_pkg_config_path(self, path: str) -> bool:
+ return False
+
+ def modified_paths(self, pkg: str, etc_paths: list[str]):
+ return {}
+
+
+def _base_monkeypatches(monkeypatch, *, unit: str):
+ """Patch harvest to avoid live system access."""
+
+ monkeypatch.setattr(
+ h, "detect_platform", lambda: PlatformInfo("debian", "dpkg", {})
+ )
+ monkeypatch.setattr(h, "get_backend", lambda info=None: FakeBackend())
+
+ monkeypatch.setattr(h, "list_enabled_timers", lambda: [])
+ monkeypatch.setattr(
+ h,
+ "get_unit_info",
+ lambda u: UnitInfo(
+ name=u,
+ fragment_path=None,
+ dropin_paths=[],
+ env_files=[],
+ exec_paths=[],
+ active_state="inactive",
+ sub_state="dead",
+ unit_file_state="enabled",
+ condition_result=None,
+ ),
+ )
+
+ # Keep users empty and avoid touching /etc/skel.
+ monkeypatch.setattr(h, "collect_non_system_users", lambda: [])
+
+ # Avoid warning spam from non-root test runs.
+ if hasattr(h.os, "geteuid"):
+ monkeypatch.setattr(h.os, "geteuid", lambda: 0)
+
+ # Avoid walking the real filesystem.
+ monkeypatch.setattr(h.os, "walk", lambda root: iter(()))
+ monkeypatch.setattr(h, "_copy_into_bundle", lambda *a, **k: None)
+
+ # Default to a "no files exist" view of the world unless a test overrides.
+ monkeypatch.setattr(h.os.path, "isfile", lambda p: False)
+ monkeypatch.setattr(h.os.path, "exists", lambda p: False)
+
+ # Minimal enabled services list.
+ monkeypatch.setattr(h, "list_enabled_services", lambda: [unit] if unit else [])
+
+
+def test_harvest_captures_nginx_enabled_symlinks(monkeypatch, tmp_path: Path):
+ bundle = tmp_path / "bundle"
+ unit = "nginx.service"
+ _base_monkeypatches(monkeypatch, unit=unit)
+
+ # Fake filesystem for nginx enabled dirs.
+ dirs = {
+ "/etc",
+ "/etc/nginx",
+ "/etc/nginx/sites-enabled",
+ "/etc/nginx/modules-enabled",
+ }
+ links = {
+ "/etc/nginx/sites-enabled/default": "../sites-available/default",
+ "/etc/nginx/modules-enabled/mod-http": "../modules-available/mod-http",
+ }
+
+ monkeypatch.setattr(h.os.path, "isdir", lambda p: p in dirs)
+ monkeypatch.setattr(h.os.path, "islink", lambda p: p in links)
+ monkeypatch.setattr(h.os, "readlink", lambda p: links[p])
+
+ def fake_glob(pat: str):
+ if pat == "/etc/nginx/sites-enabled/*":
+ return [
+ "/etc/nginx/sites-enabled/default",
+ "/etc/nginx/sites-enabled/README",
+ ]
+ if pat == "/etc/nginx/modules-enabled/*":
+ return ["/etc/nginx/modules-enabled/mod-http"]
+ return []
+
+ monkeypatch.setattr(h.glob, "glob", fake_glob)
+
+ state_path = h.harvest(str(bundle), policy=AllowAllPolicy())
+ st = json.loads(Path(state_path).read_text(encoding="utf-8"))
+
+ svc = next(s for s in st["roles"]["services"] if s["role_name"] == "nginx")
+ managed_links = svc.get("managed_links") or []
+ assert {(ml["path"], ml["target"], ml["reason"]) for ml in managed_links} == {
+ (
+ "/etc/nginx/sites-enabled/default",
+ "../sites-available/default",
+ "enabled_symlink",
+ ),
+ (
+ "/etc/nginx/modules-enabled/mod-http",
+ "../modules-available/mod-http",
+ "enabled_symlink",
+ ),
+ }
+
+
+def test_harvest_does_not_capture_enabled_symlinks_without_role(
+ monkeypatch, tmp_path: Path
+):
+ bundle = tmp_path / "bundle"
+ _base_monkeypatches(monkeypatch, unit="")
+
+ # Dirs exist but nginx isn't detected, so nothing should be captured.
+ monkeypatch.setattr(
+ h.os.path,
+ "isdir",
+ lambda p: p
+ in {
+ "/etc",
+ "/etc/nginx/sites-enabled",
+ "/etc/nginx/modules-enabled",
+ },
+ )
+ monkeypatch.setattr(
+ h.glob, "glob", lambda pat: ["/etc/nginx/sites-enabled/default"]
+ )
+ monkeypatch.setattr(h.os.path, "islink", lambda p: True)
+ monkeypatch.setattr(h.os, "readlink", lambda p: "../sites-available/default")
+
+ state_path = h.harvest(str(bundle), policy=AllowAllPolicy())
+ st = json.loads(Path(state_path).read_text(encoding="utf-8"))
+
+ # No services => no place to attach nginx links.
+ assert st["roles"]["services"] == []
+ # And no package snapshots either.
+ assert st["roles"]["packages"] == []
+
+
+def test_harvest_symlink_capture_respects_ignore_policy(monkeypatch, tmp_path: Path):
+ bundle = tmp_path / "bundle"
+ _base_monkeypatches(monkeypatch, unit="nginx.service")
+
+ dirs = {"/etc", "/etc/nginx/sites-enabled", "/etc/nginx/modules-enabled"}
+ links = {
+ "/etc/nginx/sites-enabled/default": "../sites-available/default",
+ "/etc/nginx/sites-enabled/ok": "../sites-available/ok",
+ }
+
+ monkeypatch.setattr(h.os.path, "isdir", lambda p: p in dirs)
+ monkeypatch.setattr(h.os.path, "islink", lambda p: p in links)
+ monkeypatch.setattr(h.os, "readlink", lambda p: links[p])
+ monkeypatch.setattr(
+ h.glob,
+ "glob",
+ lambda pat: (
+ sorted(list(links.keys())) if pat == "/etc/nginx/sites-enabled/*" else []
+ ),
+ )
+
+ calls: list[str] = []
+
+ class Policy:
+ def deny_reason(self, path: str):
+ return None
+
+ def deny_reason_link(self, path: str):
+ calls.append(path)
+ if path.endswith("/default"):
+ return "denied_path"
+ return None
+
+ state_path = h.harvest(str(bundle), policy=Policy())
+ st = json.loads(Path(state_path).read_text(encoding="utf-8"))
+
+ svc = next(s for s in st["roles"]["services"] if s["role_name"] == "nginx")
+ managed_links = svc.get("managed_links") or []
+ excluded = svc.get("excluded") or []
+
+ assert any(p.endswith("/default") for p in calls)
+ assert any(p.endswith("/ok") for p in calls)
+ assert {ml["path"] for ml in managed_links} == {"/etc/nginx/sites-enabled/ok"}
+ assert {ex["path"] for ex in excluded} == {"/etc/nginx/sites-enabled/default"}
+ assert (
+ next(ex["reason"] for ex in excluded if ex["path"].endswith("/default"))
+ == "denied_path"
+ )
+
+
+def test_harvest_captures_apache2_enabled_symlinks(monkeypatch, tmp_path: Path):
+ bundle = tmp_path / "bundle"
+ _base_monkeypatches(monkeypatch, unit="apache2.service")
+
+ dirs = {
+ "/etc",
+ "/etc/apache2/conf-enabled",
+ "/etc/apache2/mods-enabled",
+ "/etc/apache2/sites-enabled",
+ }
+ links = {
+ "/etc/apache2/sites-enabled/000-default.conf": "../sites-available/000-default.conf",
+ "/etc/apache2/mods-enabled/rewrite.load": "../mods-available/rewrite.load",
+ "/etc/apache2/conf-enabled/security.conf": "../conf-available/security.conf",
+ }
+
+ monkeypatch.setattr(h.os.path, "isdir", lambda p: p in dirs)
+ monkeypatch.setattr(h.os.path, "islink", lambda p: p in links)
+ monkeypatch.setattr(h.os, "readlink", lambda p: links[p])
+
+ def fake_glob(pat: str):
+ if pat == "/etc/apache2/sites-enabled/*":
+ return ["/etc/apache2/sites-enabled/000-default.conf"]
+ if pat == "/etc/apache2/mods-enabled/*":
+ return ["/etc/apache2/mods-enabled/rewrite.load"]
+ if pat == "/etc/apache2/conf-enabled/*":
+ return ["/etc/apache2/conf-enabled/security.conf"]
+ return []
+
+ monkeypatch.setattr(h.glob, "glob", fake_glob)
+
+ state_path = h.harvest(str(bundle), policy=AllowAllPolicy())
+ st = json.loads(Path(state_path).read_text(encoding="utf-8"))
+
+ svc = next(s for s in st["roles"]["services"] if s["role_name"] == "apache2")
+ managed_links = svc.get("managed_links") or []
+ assert {ml["path"] for ml in managed_links} == set(links.keys())
+ assert {ml["target"] for ml in managed_links} == set(links.values())
+ assert all(ml["reason"] == "enabled_symlink" for ml in managed_links)
diff --git a/tests/test_ignore.py b/tests/test_ignore.py
index bba9f06..2ba9a90 100644
--- a/tests/test_ignore.py
+++ b/tests/test_ignore.py
@@ -1,9 +1,250 @@
+from __future__ import annotations
+
+import os
+from pathlib import Path
+
from enroll.ignore import IgnorePolicy
def test_ignore_policy_denies_common_backup_files():
pol = IgnorePolicy()
- assert pol.deny_reason("/etc/shadow-") == "denied_path"
- assert pol.deny_reason("/etc/passwd-") == "denied_path"
- assert pol.deny_reason("/etc/group-") == "denied_path"
+ assert pol.deny_reason("/etc/shadow-") == "backup_file"
+ assert pol.deny_reason("/etc/passwd-") == "backup_file"
+ assert pol.deny_reason("/etc/group-") == "backup_file"
+ assert pol.deny_reason("/etc/something~") == "backup_file"
assert pol.deny_reason("/foobar") == "unreadable"
+
+
+def test_deny_reason_dir_with_denied_path():
+ pol = IgnorePolicy()
+ assert pol.deny_reason_dir("/etc/ssl/private/key") == "denied_path"
+ assert pol.deny_reason_dir("/etc/ssh/ssh_host_key") == "denied_path"
+ assert pol.deny_reason_dir("/etc/ssh") is None
+
+
+def test_deny_reason_dir_unreadable(tmp_path: Path):
+ pol = IgnorePolicy()
+ nonexistent = tmp_path / "nonexistent"
+ assert pol.deny_reason_dir(str(nonexistent)) == "unreadable"
+
+
+def test_deny_reason_dir_symlink(tmp_path: Path):
+ pol = IgnorePolicy()
+ real_dir = tmp_path / "real"
+ real_dir.mkdir()
+ link = tmp_path / "link"
+ os.symlink(str(real_dir), str(link))
+ assert pol.deny_reason_dir(str(link)) == "symlink"
+
+
+def test_deny_reason_dir_not_directory(tmp_path: Path):
+ pol = IgnorePolicy()
+ regular_file = tmp_path / "file.txt"
+ regular_file.write_text("content", encoding="utf-8")
+ assert pol.deny_reason_dir(str(regular_file)) == "not_directory"
+
+
+def test_deny_reason_dir_dangerous_mode(tmp_path: Path):
+ pol = IgnorePolicy(dangerous=True)
+ real_dir = tmp_path / "private"
+ real_dir.mkdir()
+ assert pol.deny_reason_dir(str(real_dir)) is None
+
+
+def test_deny_reason_link_basic(tmp_path: Path):
+ pol = IgnorePolicy()
+ real_file = tmp_path / "real"
+ real_file.write_text("content", encoding="utf-8")
+ link = tmp_path / "link"
+ os.symlink(str(real_file), str(link))
+ assert pol.deny_reason_link(str(link)) is None
+
+
+def test_deny_reason_link_denied_path():
+ pol = IgnorePolicy()
+ assert pol.deny_reason_link("/etc/ssh/ssh_host_rsa_key") == "denied_path"
+
+
+def test_deny_reason_link_unreadable(tmp_path: Path):
+ pol = IgnorePolicy()
+ # Create a symlink in a directory that doesn't exist
+ # This simulates an unreadable path
+ broken_link = tmp_path / "broken_link"
+ os.symlink("/nonexistent/target", str(broken_link))
+ # Broken symlinks are still readable (we can readlink them)
+ # So they return None (allowed) unless they match deny globs
+ result = pol.deny_reason_link(str(broken_link))
+ # Broken symlinks are allowed - we can still read the link target
+ assert result is None
+
+
+def test_deny_reason_link_not_symlink(tmp_path: Path):
+ pol = IgnorePolicy()
+ regular_file = tmp_path / "file.txt"
+ regular_file.write_text("content", encoding="utf-8")
+ assert pol.deny_reason_link(str(regular_file)) == "not_symlink"
+
+
+def test_deny_reason_link_log_file():
+ pol = IgnorePolicy()
+ assert pol.deny_reason_link("/var/log/something.log") == "log_file"
+
+
+def test_deny_reason_link_backup_file():
+ pol = IgnorePolicy()
+ assert pol.deny_reason_link("/etc/passwd-") == "backup_file"
+ assert pol.deny_reason_link("/etc/something~") == "backup_file"
+
+
+def test_deny_reason_link_dangerous_mode(tmp_path: Path):
+ pol = IgnorePolicy(dangerous=True)
+ real_file = tmp_path / "real"
+ real_file.write_text("content", encoding="utf-8")
+ link = tmp_path / "link"
+ os.symlink(str(real_file), str(link))
+ assert pol.deny_reason_link(str(link)) is None
+
+
+def test_iter_effective_lines_with_comments():
+ pol = IgnorePolicy()
+ content = b"""
+# This is a comment
+; This is also a comment
+* continuation
+def main():
+ pass
+"""
+ lines = list(pol.iter_effective_lines(content))
+ assert b"def main():" in lines
+ assert b"# This is a comment" not in lines
+
+
+def test_iter_effective_lines_with_block_comments():
+ pol = IgnorePolicy()
+ content = b"""
+/* This is a block comment
+ spanning multiple lines */
+int x = 5;
+"""
+ lines = list(pol.iter_effective_lines(content))
+ assert b"int x = 5;" in lines
+ assert b"/*" not in lines
+
+
+def test_iter_effective_lines_empty():
+ pol = IgnorePolicy()
+ content = b""
+ lines = list(pol.iter_effective_lines(content))
+ assert lines == []
+
+
+def test_deny_reason_binary_not_allowed(tmp_path: Path):
+ pol = IgnorePolicy()
+ binary = tmp_path / "random.bin"
+ binary.write_bytes(b"\x00\x01\x02\x03")
+ reason = pol.deny_reason(str(binary))
+ assert reason == "binary_like"
+
+
+def test_deny_reason_sensitive_content(tmp_path: Path):
+ pol = IgnorePolicy()
+ config = tmp_path / "config.txt"
+ config.write_text("password=secret123", encoding="utf-8")
+ reason = pol.deny_reason(str(config))
+ assert reason == "sensitive_content"
+
+
+def test_deny_reason_sensitive_api_key(tmp_path: Path):
+ pol = IgnorePolicy()
+ config = tmp_path / "config.txt"
+ config.write_text("api_key=abc123", encoding="utf-8")
+ reason = pol.deny_reason(str(config))
+ assert reason == "sensitive_content"
+
+
+def test_deny_reason_private_key(tmp_path: Path):
+ pol = IgnorePolicy()
+ key = tmp_path / "key.pem"
+ key.write_text(
+ "-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA...", encoding="utf-8"
+ )
+ reason = pol.deny_reason(str(key))
+ assert reason == "sensitive_content"
+
+
+def test_deny_reason_too_large(tmp_path: Path):
+ pol = IgnorePolicy(max_file_bytes=100)
+ large = tmp_path / "large.txt"
+ large.write_bytes(b"x" * 200)
+ reason = pol.deny_reason(str(large))
+ assert reason == "too_large"
+
+
+def test_deny_reason_unreadable(tmp_path: Path):
+ pol = IgnorePolicy()
+ nonexistent = tmp_path / "nonexistent"
+ reason = pol.deny_reason(str(nonexistent))
+ assert reason == "unreadable"
+
+
+def test_deny_reason_not_regular_file(tmp_path: Path):
+ pol = IgnorePolicy()
+ directory = tmp_path / "dir"
+ directory.mkdir()
+ reason = pol.deny_reason(str(directory))
+ assert reason == "not_regular_file"
+
+
+def test_deny_reason_symlink_file(tmp_path: Path):
+ pol = IgnorePolicy()
+ real_file = tmp_path / "real"
+ real_file.write_text("content", encoding="utf-8")
+ link = tmp_path / "link"
+ os.symlink(str(real_file), str(link))
+ reason = pol.deny_reason(str(link))
+ assert reason == "not_regular_file"
+
+
+def test_deny_reason_logs(tmp_path: Path):
+ pol = IgnorePolicy()
+ log = tmp_path / "test.log"
+ log.write_text("log content", encoding="utf-8")
+ assert pol.deny_reason(str(log)) == "log_file"
+
+
+def test_deny_reason_backup_file(tmp_path: Path):
+ pol = IgnorePolicy()
+ backup = tmp_path / "file~"
+ backup.write_text("backup", encoding="utf-8")
+ assert pol.deny_reason(str(backup)) == "backup_file"
+
+
+def test_deny_reason_shadow_file():
+ pol = IgnorePolicy()
+ assert pol.deny_reason("/etc/shadow") == "denied_path"
+ assert pol.deny_reason("/etc/gshadow") == "denied_path"
+
+
+def test_deny_reason_ssl_private():
+ pol = IgnorePolicy()
+ assert pol.deny_reason("/etc/ssl/private/key.pem") == "denied_path"
+
+
+def test_deny_reason_ssh_host_keys():
+ pol = IgnorePolicy()
+ assert pol.deny_reason("/etc/ssh/ssh_host_rsa_key") == "denied_path"
+ assert pol.deny_reason("/etc/ssh/ssh_host_ed25519_key") == "denied_path"
+
+
+def test_deny_reason_letsencrypt():
+ pol = IgnorePolicy()
+ assert (
+ pol.deny_reason("/etc/letsencrypt/live/example.com/fullchain.pem")
+ == "denied_path"
+ )
+
+
+def test_deny_reason_shadow_backup():
+ pol = IgnorePolicy()
+ assert pol.deny_reason("/etc/shadow-") == "backup_file"
+ assert pol.deny_reason("/etc/passwd-") == "backup_file"
diff --git a/tests/test_ignore_dir.py b/tests/test_ignore_dir.py
new file mode 100644
index 0000000..3066c92
--- /dev/null
+++ b/tests/test_ignore_dir.py
@@ -0,0 +1,56 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+
+def test_iter_effective_lines_skips_comments_and_block_comments():
+ from enroll.ignore import IgnorePolicy
+
+ policy = IgnorePolicy(deny_globs=[])
+
+ content = b"""
+# comment
+; semi
+// slash
+* c-star
+
+valid=1
+/* block
+ignored=1
+*/
+valid=2
+"""
+
+ lines = [l.strip() for l in policy.iter_effective_lines(content)]
+ assert lines == [b"valid=1", b"valid=2"]
+
+
+def test_deny_reason_dir_behaviour(tmp_path: Path):
+ from enroll.ignore import IgnorePolicy
+
+ # Use an absolute pattern matching our temporary path.
+ deny_glob = str(tmp_path / "deny") + "/*"
+ pol = IgnorePolicy(deny_globs=[deny_glob], dangerous=False)
+
+ d = tmp_path / "dir"
+ d.mkdir()
+ f = tmp_path / "file"
+ f.write_text("x", encoding="utf-8")
+ link = tmp_path / "link"
+ link.symlink_to(d)
+
+ assert pol.deny_reason_dir(str(d)) is None
+ assert pol.deny_reason_dir(str(link)) == "symlink"
+ assert pol.deny_reason_dir(str(f)) == "not_directory"
+
+ # Denied by glob.
+ deny_path = tmp_path / "deny" / "x"
+ deny_path.mkdir(parents=True)
+ assert pol.deny_reason_dir(str(deny_path)) == "denied_path"
+
+ # Missing/unreadable.
+ assert pol.deny_reason_dir(str(tmp_path / "missing")) == "unreadable"
+
+ # Dangerous disables deny_globs.
+ pol2 = IgnorePolicy(deny_globs=[deny_glob], dangerous=True)
+ assert pol2.deny_reason_dir(str(deny_path)) is None
diff --git a/tests/test_jinjaturtle.py b/tests/test_jinjaturtle.py
index c0447b1..b2c9022 100644
--- a/tests/test_jinjaturtle.py
+++ b/tests/test_jinjaturtle.py
@@ -131,3 +131,15 @@ def test_manifest_uses_jinjaturtle_templates_and_does_not_copy_raw(
encoding="utf-8"
)
assert "foo_key: 1" in defaults
+
+
+def test_openssh_paths_are_jinjaturtle_supported_and_forced_to_ssh() -> None:
+ from enroll.jinjaturtle import can_jinjify_path, infer_other_formats
+
+ assert infer_other_formats("/etc/ssh/sshd_config") == "ssh"
+ assert infer_other_formats("/etc/ssh/ssh_config") == "ssh"
+ assert infer_other_formats("/etc/ssh/sshd_config.d/50-hardening.conf") == "ssh"
+ assert infer_other_formats("/etc/ssh/ssh_config.d/99-proxy.conf") == "ssh"
+
+ assert can_jinjify_path("/etc/ssh/sshd_config")
+ assert can_jinjify_path("/etc/ssh/ssh_config")
diff --git a/tests/test_manifest.py b/tests/test_manifest.py
index fec9cc3..1b78bcf 100644
--- a/tests/test_manifest.py
+++ b/tests/test_manifest.py
@@ -1,7 +1,12 @@
import json
from pathlib import Path
-from enroll.manifest import manifest
+import os
+import stat
+import tarfile
+import pytest
+
+import enroll.manifest as manifest
def test_manifest_writes_roles_and_playbook_with_clean_when(tmp_path: Path):
@@ -176,7 +181,7 @@ def test_manifest_writes_roles_and_playbook_with_clean_when(tmp_path: Path):
bundle / "artifacts" / "usr_local_custom" / "usr" / "local" / "bin" / "myscript"
).write_text("#!/bin/sh\necho hi\n", encoding="utf-8")
- manifest(str(bundle), str(out))
+ manifest.manifest(str(bundle), str(out))
# Service role: systemd management should be gated on foo_manage_unit and a probe.
tasks = (out / "roles" / "foo" / "tasks" / "main.yml").read_text(encoding="utf-8")
@@ -201,11 +206,11 @@ def test_manifest_writes_roles_and_playbook_with_clean_when(tmp_path: Path):
# 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 "- usr_local_custom" in pb
- assert "- curl" in pb
- assert "- foo" in pb
+ assert "role: users" in pb
+ assert "role: etc_custom" in pb
+ assert "role: usr_local_custom" in pb
+ assert "role: curl" in pb
+ assert "role: foo" in pb
def test_manifest_site_mode_creates_host_inventory_and_raw_files(tmp_path: Path):
@@ -345,7 +350,7 @@ def test_manifest_site_mode_creates_host_inventory_and_raw_files(tmp_path: Path)
/ "myapp.conf"
).write_text("myapp=1\n", encoding="utf-8")
- manifest(str(bundle), str(out), fqdn=fqdn)
+ manifest.manifest(str(bundle), str(out), fqdn=fqdn)
# Host playbook exists.
assert (out / "playbooks" / f"{fqdn}.yml").exists()
@@ -482,10 +487,10 @@ def test_manifest_includes_dnf_config_role_when_present(tmp_path: Path):
bundle.mkdir(parents=True, exist_ok=True)
(bundle / "state.json").write_text(json.dumps(state, indent=2), encoding="utf-8")
- manifest(str(bundle), str(out))
+ manifest.manifest(str(bundle), str(out))
pb = (out / "playbook.yml").read_text(encoding="utf-8")
- assert "- dnf_config" in pb
+ assert "role: dnf_config" in pb
tasks = (out / "roles" / "dnf_config" / "tasks" / "main.yml").read_text(
encoding="utf-8"
@@ -502,3 +507,560 @@ def test_render_install_packages_tasks_contains_dnf_branch():
assert "ansible.builtin.dnf" in txt
assert "ansible.builtin.package" in txt
assert "pkg_mgr" in txt
+
+
+def test_manifest_orders_cron_and_logrotate_at_playbook_tail(tmp_path: Path):
+ """Cron/logrotate roles should appear at the end.
+
+ The cron role may restore per-user crontabs under /var/spool, so it should
+ run after users have been created.
+ """
+
+ bundle = tmp_path / "bundle"
+ out = tmp_path / "ansible"
+
+ state = {
+ "schema_version": 3,
+ "host": {"hostname": "test", "os": "debian", "pkg_backend": "dpkg"},
+ "inventory": {"packages": {}},
+ "roles": {
+ "users": {
+ "role_name": "users",
+ "users": [{"name": "alice"}],
+ "managed_files": [],
+ "excluded": [],
+ "notes": [],
+ },
+ "services": [],
+ "packages": [
+ {
+ "package": "curl",
+ "role_name": "curl",
+ "managed_files": [],
+ "excluded": [],
+ "notes": [],
+ },
+ {
+ "package": "cron",
+ "role_name": "cron",
+ "managed_files": [
+ {
+ "path": "/var/spool/cron/crontabs/alice",
+ "src_rel": "var/spool/cron/crontabs/alice",
+ "owner": "alice",
+ "group": "root",
+ "mode": "0600",
+ "reason": "system_cron",
+ }
+ ],
+ "excluded": [],
+ "notes": [],
+ },
+ {
+ "package": "logrotate",
+ "role_name": "logrotate",
+ "managed_files": [
+ {
+ "path": "/etc/logrotate.conf",
+ "src_rel": "etc/logrotate.conf",
+ "owner": "root",
+ "group": "root",
+ "mode": "0644",
+ "reason": "system_logrotate",
+ }
+ ],
+ "excluded": [],
+ "notes": [],
+ },
+ ],
+ "apt_config": {
+ "role_name": "apt_config",
+ "managed_files": [],
+ "excluded": [],
+ "notes": [],
+ },
+ "dnf_config": {
+ "role_name": "dnf_config",
+ "managed_files": [],
+ "excluded": [],
+ "notes": [],
+ },
+ "etc_custom": {
+ "role_name": "etc_custom",
+ "managed_files": [],
+ "excluded": [],
+ "notes": [],
+ },
+ "usr_local_custom": {
+ "role_name": "usr_local_custom",
+ "managed_files": [],
+ "excluded": [],
+ "notes": [],
+ },
+ "extra_paths": {
+ "role_name": "extra_paths",
+ "include_patterns": [],
+ "exclude_patterns": [],
+ "managed_files": [],
+ "excluded": [],
+ "notes": [],
+ },
+ },
+ }
+
+ # Minimal artifacts for managed files.
+ (bundle / "artifacts" / "cron" / "var" / "spool" / "cron" / "crontabs").mkdir(
+ parents=True, exist_ok=True
+ )
+ (
+ bundle / "artifacts" / "cron" / "var" / "spool" / "cron" / "crontabs" / "alice"
+ ).write_text("@daily echo hi\n", encoding="utf-8")
+ (bundle / "artifacts" / "logrotate" / "etc").mkdir(parents=True, exist_ok=True)
+ (bundle / "artifacts" / "logrotate" / "etc" / "logrotate.conf").write_text(
+ "weekly\n", encoding="utf-8"
+ )
+
+ bundle.mkdir(parents=True, exist_ok=True)
+ (bundle / "state.json").write_text(json.dumps(state, indent=2), encoding="utf-8")
+
+ manifest.manifest(str(bundle), str(out))
+
+ pb = (out / "playbook.yml").read_text(encoding="utf-8").splitlines()
+ # Roles are emitted as indented list items under the `roles:` key.
+ roles = [
+ ln.strip().removeprefix("- ").strip() for ln in pb if ln.startswith(" - ")
+ ]
+
+ # Ensure tail ordering.
+ assert roles[-2:] == ["role: cron", "role: logrotate"]
+ assert "role: users" in roles
+ assert roles.index("role: users") < roles.index("role: cron")
+
+
+def test_yaml_helpers_fallback_when_yaml_unavailable(monkeypatch):
+ monkeypatch.setattr(manifest, "_try_yaml", lambda: None)
+ assert manifest._yaml_load_mapping("foo: 1\n") == {}
+ out = manifest._yaml_dump_mapping({"b": 2, "a": 1})
+ # Best-effort fallback is key: repr(value)
+ assert out.splitlines()[0].startswith("a: ")
+ assert out.endswith("\n")
+
+
+def test_copy2_replace_makes_readonly_sources_user_writable(
+ monkeypatch, tmp_path: Path
+):
+ src = tmp_path / "src.txt"
+ dst = tmp_path / "dst.txt"
+ src.write_text("hello", encoding="utf-8")
+ # Make source read-only; copy2 preserves mode, so tmp will be read-only too.
+ os.chmod(src, 0o444)
+
+ manifest._copy2_replace(str(src), str(dst))
+
+ st = os.stat(dst, follow_symlinks=False)
+ assert stat.S_IMODE(st.st_mode) & stat.S_IWUSR
+
+
+def test_prepare_bundle_dir_sops_decrypts_and_extracts(monkeypatch, tmp_path: Path):
+ enc = tmp_path / "harvest.tar.gz.sops"
+ enc.write_text("ignored", encoding="utf-8")
+
+ def fake_require():
+ return None
+
+ def fake_decrypt(src: str, dst: str, *, mode: int = 0o600):
+ # Create a minimal tar.gz with a state.json file.
+ with tarfile.open(dst, "w:gz") as tf:
+ p = tmp_path / "state.json"
+ p.write_text("{}", encoding="utf-8")
+ tf.add(p, arcname="state.json")
+
+ monkeypatch.setattr(manifest, "require_sops_cmd", fake_require)
+ monkeypatch.setattr(manifest, "decrypt_file_binary_to", fake_decrypt)
+
+ bundle_dir, td = manifest._prepare_bundle_dir(str(enc), sops_mode=True)
+ try:
+ assert (Path(bundle_dir) / "state.json").exists()
+ finally:
+ td.cleanup()
+
+
+def test_prepare_bundle_dir_rejects_non_dir_without_sops(tmp_path: Path):
+ fp = tmp_path / "bundle.tar.gz"
+ fp.write_text("x", encoding="utf-8")
+ with pytest.raises(RuntimeError):
+ manifest._prepare_bundle_dir(str(fp), sops_mode=False)
+
+
+def test_tar_dir_to_with_progress_writes_progress_when_tty(monkeypatch, tmp_path: Path):
+ src = tmp_path / "dir"
+ src.mkdir()
+ (src / "a.txt").write_text("a", encoding="utf-8")
+ (src / "b.txt").write_text("b", encoding="utf-8")
+
+ out = tmp_path / "out.tar.gz"
+ writes: list[bytes] = []
+
+ monkeypatch.setattr(manifest.os, "isatty", lambda fd: True)
+ monkeypatch.setattr(manifest.os, "write", lambda fd, b: writes.append(b) or len(b))
+
+ manifest._tar_dir_to_with_progress(str(src), str(out), desc="tarring")
+ assert out.exists()
+ assert writes # progress was written
+ assert writes[-1].endswith(b"\n")
+
+
+def test_encrypt_manifest_out_dir_to_sops_handles_missing_tmp_cleanup(
+ monkeypatch, tmp_path: Path
+):
+ src_dir = tmp_path / "manifest"
+ src_dir.mkdir()
+ (src_dir / "x.txt").write_text("x", encoding="utf-8")
+
+ out = tmp_path / "manifest.tar.gz.sops"
+
+ monkeypatch.setattr(manifest, "require_sops_cmd", lambda: None)
+
+ def fake_encrypt(in_fp, out_fp, *args, **kwargs):
+ Path(out_fp).write_text("enc", encoding="utf-8")
+
+ monkeypatch.setattr(manifest, "encrypt_file_binary", fake_encrypt)
+ # Simulate race where tmp tar is already removed.
+ monkeypatch.setattr(
+ manifest.os, "unlink", lambda p: (_ for _ in ()).throw(FileNotFoundError())
+ )
+
+ res = manifest._encrypt_manifest_out_dir_to_sops(str(src_dir), str(out), ["ABC"]) # type: ignore[arg-type]
+ assert str(res).endswith(".sops")
+ assert out.exists()
+
+
+def test_manifest_applies_jinjaturtle_to_jinjifyable_managed_file(
+ monkeypatch, tmp_path: Path
+):
+ # Create a minimal bundle with just an apt_config snapshot.
+ bundle = tmp_path / "bundle"
+ (bundle / "artifacts" / "apt_config" / "etc" / "apt").mkdir(parents=True)
+ (bundle / "artifacts" / "apt_config" / "etc" / "apt" / "foo.ini").write_text(
+ "key=VALUE\n", encoding="utf-8"
+ )
+
+ state = {
+ "schema_version": 1,
+ "inventory": {"packages": {}},
+ "roles": {
+ "services": [],
+ "packages": [],
+ "apt_config": {
+ "role_name": "apt_config",
+ "managed_files": [
+ {
+ "path": "/etc/apt/foo.ini",
+ "src_rel": "etc/apt/foo.ini",
+ "owner": "root",
+ "group": "root",
+ "mode": "0644",
+ "reason": "apt_config",
+ }
+ ],
+ "managed_dirs": [],
+ "excluded": [],
+ "notes": [],
+ },
+ },
+ }
+ (bundle / "state.json").write_text(
+ __import__("json").dumps(state), encoding="utf-8"
+ )
+
+ monkeypatch.setattr(manifest, "find_jinjaturtle_cmd", lambda: "jinjaturtle")
+
+ class _Res:
+ template_text = "key={{ foo }}\n"
+ vars_text = "foo: 123\n"
+
+ monkeypatch.setattr(manifest, "run_jinjaturtle", lambda *a, **k: _Res())
+
+ out_dir = tmp_path / "out"
+ manifest.manifest(str(bundle), str(out_dir), jinjaturtle="on")
+
+ tmpl = out_dir / "roles" / "apt_config" / "templates" / "etc" / "apt" / "foo.ini.j2"
+ assert tmpl.exists()
+ assert "{{ foo }}" in tmpl.read_text(encoding="utf-8")
+
+ defaults = out_dir / "roles" / "apt_config" / "defaults" / "main.yml"
+ txt = defaults.read_text(encoding="utf-8")
+ assert "foo: 123" in txt
+ # Non-templated file should not exist under files/.
+ assert not (
+ out_dir / "roles" / "apt_config" / "files" / "etc" / "apt" / "foo.ini"
+ ).exists()
+
+
+def test_manifest_writes_firewall_runtime_role(tmp_path: Path):
+ bundle = tmp_path / "bundle"
+ out = tmp_path / "ansible"
+ (bundle / "artifacts" / "firewall_runtime" / "firewall").mkdir(
+ parents=True, exist_ok=True
+ )
+ (bundle / "artifacts" / "firewall_runtime" / "firewall" / "ipset.save").write_text(
+ "create blocklist hash:ip family inet\nadd blocklist 203.0.113.10\n",
+ encoding="utf-8",
+ )
+ (bundle / "artifacts" / "firewall_runtime" / "firewall" / "iptables.v4").write_text(
+ "*filter\n:INPUT DROP [0:0]\n-A INPUT -m set --match-set blocklist src -j DROP\nCOMMIT\n",
+ encoding="utf-8",
+ )
+
+ state = {
+ "schema_version": 3,
+ "host": {"hostname": "test", "os": "debian", "pkg_backend": "dpkg"},
+ "inventory": {"packages": {}},
+ "roles": {
+ "users": {
+ "role_name": "users",
+ "users": [],
+ "managed_files": [],
+ "excluded": [],
+ "notes": [],
+ },
+ "services": [],
+ "packages": [],
+ "apt_config": {
+ "role_name": "apt_config",
+ "managed_files": [],
+ "excluded": [],
+ "notes": [],
+ },
+ "dnf_config": {
+ "role_name": "dnf_config",
+ "managed_files": [],
+ "excluded": [],
+ "notes": [],
+ },
+ "firewall_runtime": {
+ "role_name": "firewall_runtime",
+ "packages": ["ipset", "iptables"],
+ "ipset_save": "firewall/ipset.save",
+ "ipset_sets": ["blocklist"],
+ "iptables_v4_save": "firewall/iptables.v4",
+ "iptables_v6_save": None,
+ "notes": [],
+ },
+ "etc_custom": {
+ "role_name": "etc_custom",
+ "managed_files": [],
+ "excluded": [],
+ "notes": [],
+ },
+ "usr_local_custom": {
+ "role_name": "usr_local_custom",
+ "managed_files": [],
+ "excluded": [],
+ "notes": [],
+ },
+ "extra_paths": {
+ "role_name": "extra_paths",
+ "include_patterns": [],
+ "exclude_patterns": [],
+ "managed_files": [],
+ "excluded": [],
+ "notes": [],
+ },
+ },
+ }
+ (bundle / "state.json").write_text(json.dumps(state, indent=2), encoding="utf-8")
+
+ manifest.manifest(str(bundle), str(out))
+
+ tasks = (out / "roles" / "firewall_runtime" / "tasks" / "main.yml").read_text(
+ encoding="utf-8"
+ )
+ assert "ipset restore -exist" in tasks
+ assert "iptables-restore /etc/enroll/firewall/iptables.v4" in tasks
+ assert "ipset flush {{ item }}" in tasks
+
+ defaults = (out / "roles" / "firewall_runtime" / "defaults" / "main.yml").read_text(
+ encoding="utf-8"
+ )
+ assert "firewall_runtime_ipset_sets:" in defaults
+ assert "- blocklist" in defaults
+ assert "firewall_runtime_restore_iptables: true" in defaults
+
+ pb = (out / "playbook.yml").read_text(encoding="utf-8")
+ assert "role: firewall_runtime" in pb
+ assert (
+ out / "roles" / "firewall_runtime" / "files" / "firewall" / "ipset.save"
+ ).exists()
+
+
+def test_try_yaml_with_yaml_installed():
+ result = manifest._try_yaml()
+ # PyYAML should be installed for tests
+ if result is None:
+ pytest.skip("PyYAML not installed")
+ assert hasattr(result, "safe_load")
+ assert hasattr(result, "dump")
+
+
+def test_yaml_load_mapping_with_yaml(tmp_path: Path):
+ text = """
+key1: value1
+key2:
+ nested: value
+list:
+ - item1
+ - item2
+"""
+ result = manifest._yaml_load_mapping(text)
+ assert result["key1"] == "value1"
+ assert result["key2"]["nested"] == "value"
+ assert result["list"] == ["item1", "item2"]
+
+
+def test_yaml_load_mapping_empty():
+ result = manifest._yaml_load_mapping("")
+ assert result == {}
+
+
+def test_yaml_load_mapping_invalid():
+ result = manifest._yaml_load_mapping("invalid: yaml: :")
+ assert result == {}
+
+
+def test_yaml_load_mapping_not_dict():
+ result = manifest._yaml_load_mapping("- item1\n- item2")
+ assert result == {}
+
+
+def test_yaml_load_mapping_none():
+ result = manifest._yaml_load_mapping("~")
+ assert result == {}
+
+
+def test_yaml_dump_mapping_with_yaml(tmp_path: Path):
+ obj = {"key1": "value1", "key2": 123}
+ result = manifest._yaml_dump_mapping(obj)
+ assert "key1: value1" in result
+ assert "key2:" in result
+
+
+def test_yaml_dump_mapping_empty():
+ result = manifest._yaml_dump_mapping({})
+ # Empty dict produces '{}'
+ assert result.strip() == "{}"
+
+
+def test_yaml_dump_mapping_with_nested(tmp_path: Path):
+ obj = {"key1": {"nested": "value"}}
+ result = manifest._yaml_dump_mapping(obj)
+ assert "nested:" in result
+
+
+def test_merge_mappings_overwrite_simple():
+ existing = {"key1": "old", "key2": "keep"}
+ incoming = {"key1": "new", "key3": "added"}
+ result = manifest._merge_mappings_overwrite(existing, incoming)
+ assert result["key1"] == "new"
+ assert result["key2"] == "keep"
+ assert result["key3"] == "added"
+
+
+def test_merge_mappings_overwrite_nested():
+ existing = {"key1": {"a": 1}}
+ incoming = {"key1": {"b": 2}}
+ result = manifest._merge_mappings_overwrite(existing, incoming)
+ # Nested dicts are replaced, not merged
+ assert result["key1"] == {"b": 2}
+
+
+def test_merge_mappings_overwrite_empty():
+ result = manifest._merge_mappings_overwrite({}, {"key": "value"})
+ assert result == {"key": "value"}
+
+ result = manifest._merge_mappings_overwrite({"key": "value"}, {})
+ assert result == {"key": "value"}
+
+
+def test_copy2_replace(tmp_path: Path):
+ src = tmp_path / "src.txt"
+ src.write_text("content", encoding="utf-8")
+ dst = tmp_path / "dst" / "subdir" / "dst.txt"
+
+ manifest._copy2_replace(str(src), str(dst))
+
+ assert dst.exists()
+ assert dst.read_text(encoding="utf-8") == "content"
+
+
+def test_copy2_replace_preserves_metadata(tmp_path: Path):
+ src = tmp_path / "src.txt"
+ src.write_text("content", encoding="utf-8")
+ os.chmod(str(src), 0o644)
+ dst = tmp_path / "dst.txt"
+
+ manifest._copy2_replace(str(src), str(dst))
+
+ assert dst.exists()
+ st = dst.stat()
+ assert stat.S_IMODE(st.st_mode) == 0o644
+
+
+def test_copy2_replace_atomic(tmp_path: Path):
+ src = tmp_path / "src.txt"
+ src.write_text("content", encoding="utf-8")
+ dst = tmp_path / "dst.txt"
+
+ # Write initial content
+ dst.write_text("old", encoding="utf-8")
+
+ manifest._copy2_replace(str(src), str(dst))
+
+ assert dst.read_text(encoding="utf-8") == "content"
+
+
+def test_render_firewall_runtime_tasks_empty():
+ state = {"roles": {}}
+ result = manifest._render_firewall_runtime_tasks(state)
+ # Function always returns at least a basic playbook structure
+ assert isinstance(result, str)
+ assert len(result) > 0
+
+
+def test_render_firewall_runtime_tasks_with_iptables():
+ state = {
+ "roles": {
+ "firewall_runtime": {
+ "role_name": "firewall_runtime",
+ "iptables_v4_save": "artifacts/firewall_runtime/iptables.save",
+ }
+ }
+ }
+ result = manifest._render_firewall_runtime_tasks(state)
+ assert len(result) >= 1
+
+
+def test_render_firewall_runtime_tasks_with_ipset():
+ state = {
+ "roles": {
+ "firewall_runtime": {
+ "role_name": "firewall_runtime",
+ "ipset_save": "artifacts/firewall_runtime/ipset.save",
+ }
+ }
+ }
+ result = manifest._render_firewall_runtime_tasks(state)
+ assert len(result) >= 1
+
+
+def test_render_firewall_runtime_tasks_with_ipv6():
+ state = {
+ "roles": {
+ "firewall_runtime": {
+ "role_name": "firewall_runtime",
+ "iptables_v6_save": "artifacts/firewall_runtime/ip6tables.save",
+ }
+ }
+ }
+ result = manifest._render_firewall_runtime_tasks(state)
+ assert len(result) >= 1
diff --git a/tests/test_manifest_symlinks.py b/tests/test_manifest_symlinks.py
new file mode 100644
index 0000000..81c6fb7
--- /dev/null
+++ b/tests/test_manifest_symlinks.py
@@ -0,0 +1,96 @@
+import json
+from pathlib import Path
+
+import enroll.manifest as manifest
+
+
+def test_manifest_emits_symlink_tasks_and_vars(tmp_path: Path):
+ bundle = tmp_path / "bundle"
+ out = tmp_path / "ansible"
+
+ state = {
+ "host": {"hostname": "test", "os": "debian", "pkg_backend": "dpkg"},
+ "inventory": {"packages": {}},
+ "roles": {
+ "users": {
+ "role_name": "users",
+ "users": [],
+ "managed_files": [],
+ "excluded": [],
+ "notes": [],
+ },
+ "services": [
+ {
+ "unit": "nginx.service",
+ "role_name": "nginx",
+ "packages": ["nginx"],
+ "active_state": "active",
+ "sub_state": "running",
+ "unit_file_state": "enabled",
+ "condition_result": None,
+ "managed_files": [],
+ "managed_links": [
+ {
+ "path": "/etc/nginx/sites-enabled/default",
+ "target": "../sites-available/default",
+ "reason": "enabled_symlink",
+ }
+ ],
+ "excluded": [],
+ "notes": [],
+ }
+ ],
+ "packages": [],
+ "apt_config": {
+ "role_name": "apt_config",
+ "managed_files": [],
+ "excluded": [],
+ "notes": [],
+ },
+ "dnf_config": {
+ "role_name": "dnf_config",
+ "managed_files": [],
+ "excluded": [],
+ "notes": [],
+ },
+ "etc_custom": {
+ "role_name": "etc_custom",
+ "managed_files": [],
+ "excluded": [],
+ "notes": [],
+ },
+ "usr_local_custom": {
+ "role_name": "usr_local_custom",
+ "managed_files": [],
+ "excluded": [],
+ "notes": [],
+ },
+ "extra_paths": {
+ "role_name": "extra_paths",
+ "include_patterns": [],
+ "exclude_patterns": [],
+ "managed_files": [],
+ "managed_links": [],
+ "excluded": [],
+ "notes": [],
+ },
+ },
+ }
+
+ bundle.mkdir(parents=True, exist_ok=True)
+ (bundle / "artifacts").mkdir(parents=True, exist_ok=True)
+ (bundle / "state.json").write_text(json.dumps(state), encoding="utf-8")
+
+ manifest.manifest(str(bundle), str(out))
+
+ tasks = (out / "roles" / "nginx" / "tasks" / "main.yml").read_text(encoding="utf-8")
+ assert "- name: Ensure managed symlinks exist" in tasks
+ assert 'loop: "{{ nginx_managed_links | default([]) }}"' in tasks
+
+ defaults = (out / "roles" / "nginx" / "defaults" / "main.yml").read_text(
+ encoding="utf-8"
+ )
+ # The role defaults should include the converted link mapping.
+ assert "nginx_managed_links:" in defaults
+ assert "dest: /etc/nginx/sites-enabled/default" in defaults
+ assert "src: ../sites-available/default" in defaults
diff --git a/tests/test_misc_coverage.py b/tests/test_misc_coverage.py
deleted file mode 100644
index b4250fc..0000000
--- a/tests/test_misc_coverage.py
+++ /dev/null
@@ -1,96 +0,0 @@
-import stat
-from pathlib import Path
-
-import pytest
-
-from enroll.cache import _safe_component, new_harvest_cache_dir
-from enroll.ignore import IgnorePolicy
-from enroll.sopsutil import (
- SopsError,
- _pgp_arg,
- decrypt_file_binary_to,
- encrypt_file_binary,
-)
-
-
-def test_safe_component_sanitizes_and_bounds_length():
- assert _safe_component(" ") == "unknown"
- assert _safe_component("a/b c") == "a_b_c"
- assert _safe_component("x" * 200) == "x" * 64
-
-
-def test_new_harvest_cache_dir_uses_xdg_cache_home(tmp_path: Path, monkeypatch):
- monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path / "xdg"))
- hc = new_harvest_cache_dir(hint="my host/01")
- assert hc.dir.exists()
- assert "my_host_01" in hc.dir.name
- assert str(hc.dir).startswith(str(tmp_path / "xdg"))
- # best-effort: ensure directory is not world-readable on typical FS
- try:
- mode = stat.S_IMODE(hc.dir.stat().st_mode)
- assert mode & 0o077 == 0
- except OSError:
- pass
-
-
-def test_ignore_policy_denies_binary_and_sensitive_content(tmp_path: Path):
- p_bin = tmp_path / "binfile"
- p_bin.write_bytes(b"abc\x00def")
- assert IgnorePolicy().deny_reason(str(p_bin)) == "binary_like"
-
- p_secret = tmp_path / "secret.conf"
- p_secret.write_text("password=foo\n", encoding="utf-8")
- assert IgnorePolicy().deny_reason(str(p_secret)) == "sensitive_content"
-
- # dangerous mode disables heuristic scanning (but still checks file-ness/size)
- assert IgnorePolicy(dangerous=True).deny_reason(str(p_secret)) is None
-
-
-def test_ignore_policy_denies_usr_local_shadow_by_glob():
- # This should short-circuit before stat() (path doesn't need to exist).
- assert IgnorePolicy().deny_reason("/usr/local/etc/shadow") == "denied_path"
-
-
-def test_sops_pgp_arg_and_encrypt_decrypt_roundtrip(tmp_path: Path, monkeypatch):
- assert _pgp_arg([" ABC ", "DEF"]) == "ABC,DEF"
- with pytest.raises(SopsError):
- _pgp_arg([])
-
- # Stub out sops and subprocess.
- import enroll.sopsutil as s
-
- monkeypatch.setattr(s, "require_sops_cmd", lambda: "sops")
-
- class R:
- def __init__(self, rc: int, out: bytes, err: bytes = b""):
- self.returncode = rc
- self.stdout = out
- self.stderr = err
-
- calls = []
-
- def fake_run(cmd, capture_output, check):
- calls.append(cmd)
- # Return a deterministic payload so we can assert file writes.
- if "--encrypt" in cmd:
- return R(0, b"ENCRYPTED")
- if "--decrypt" in cmd:
- return R(0, b"PLAINTEXT")
- return R(1, b"", b"bad")
-
- monkeypatch.setattr(s.subprocess, "run", fake_run)
-
- src = tmp_path / "src.bin"
- src.write_bytes(b"x")
- enc = tmp_path / "out.sops"
- dec = tmp_path / "out.bin"
-
- encrypt_file_binary(src, enc, pgp_fingerprints=["ABC"], mode=0o600)
- assert enc.read_bytes() == b"ENCRYPTED"
-
- decrypt_file_binary_to(enc, dec, mode=0o644)
- assert dec.read_bytes() == b"PLAINTEXT"
-
- # Sanity: we invoked encrypt and decrypt.
- assert any("--encrypt" in c for c in calls)
- assert any("--decrypt" in c for c in calls)
diff --git a/tests/test_more_coverage.py b/tests/test_more_coverage.py
deleted file mode 100644
index 2c6693a..0000000
--- a/tests/test_more_coverage.py
+++ /dev/null
@@ -1,323 +0,0 @@
-from __future__ import annotations
-
-import json
-import os
-import subprocess
-import sys
-import types
-from pathlib import Path
-from types import SimpleNamespace
-
-import pytest
-
-
-def test_cache_dir_defaults_to_home_cache(monkeypatch, tmp_path: Path):
- # Ensure default path uses ~/.cache when XDG_CACHE_HOME is unset.
- from enroll.cache import enroll_cache_dir
-
- monkeypatch.delenv("XDG_CACHE_HOME", raising=False)
- monkeypatch.setattr(Path, "home", lambda: tmp_path)
-
- p = enroll_cache_dir()
- assert str(p).startswith(str(tmp_path))
- assert p.name == "enroll"
-
-
-def test_harvest_cache_state_json_property(tmp_path: Path):
- from enroll.cache import HarvestCache
-
- hc = HarvestCache(tmp_path / "h1")
- assert hc.state_json == hc.dir / "state.json"
-
-
-def test_cache_dir_security_rejects_symlink(tmp_path: Path):
- from enroll.cache import _ensure_dir_secure
-
- real = tmp_path / "real"
- real.mkdir()
- link = tmp_path / "link"
- link.symlink_to(real, target_is_directory=True)
-
- with pytest.raises(RuntimeError, match="Refusing to use symlink"):
- _ensure_dir_secure(link)
-
-
-def test_cache_dir_chmod_failures_are_ignored(monkeypatch, tmp_path: Path):
- from enroll import cache
-
- # Make the cache base path deterministic and writable.
- monkeypatch.setattr(cache, "enroll_cache_dir", lambda: tmp_path)
-
- # Force os.chmod to fail to cover the "except OSError: pass" paths.
- monkeypatch.setattr(
- os, "chmod", lambda *a, **k: (_ for _ in ()).throw(OSError("nope"))
- )
-
- hc = cache.new_harvest_cache_dir()
- assert hc.dir.exists()
- assert hc.dir.is_dir()
-
-
-def test_stat_triplet_falls_back_to_numeric_ids(monkeypatch, tmp_path: Path):
- from enroll.fsutil import stat_triplet
- import pwd
- import grp
-
- p = tmp_path / "x"
- p.write_text("x", encoding="utf-8")
-
- # Force username/group resolution failures.
- monkeypatch.setattr(
- pwd, "getpwuid", lambda _uid: (_ for _ in ()).throw(KeyError("no user"))
- )
- monkeypatch.setattr(
- grp, "getgrgid", lambda _gid: (_ for _ in ()).throw(KeyError("no group"))
- )
-
- owner, group, mode = stat_triplet(str(p))
- assert owner.isdigit()
- assert group.isdigit()
- assert len(mode) == 4
-
-
-def test_ignore_policy_iter_effective_lines_removes_block_comments():
- from enroll.ignore import IgnorePolicy
-
- pol = IgnorePolicy()
- data = b"""keep1
-/*
-drop me
-*/
-keep2
-"""
- assert list(pol.iter_effective_lines(data)) == [b"keep1", b"keep2"]
-
-
-def test_ignore_policy_deny_reason_dir_variants(tmp_path: Path):
- from enroll.ignore import IgnorePolicy
-
- pol = IgnorePolicy()
-
- # denied by glob
- assert pol.deny_reason_dir("/etc/shadow") == "denied_path"
-
- # symlink rejected
- d = tmp_path / "d"
- d.mkdir()
- link = tmp_path / "l"
- link.symlink_to(d, target_is_directory=True)
- assert pol.deny_reason_dir(str(link)) == "symlink"
-
- # not a directory
- f = tmp_path / "f"
- f.write_text("x", encoding="utf-8")
- assert pol.deny_reason_dir(str(f)) == "not_directory"
-
- # ok
- assert pol.deny_reason_dir(str(d)) is None
-
-
-def test_run_jinjaturtle_parses_outputs(monkeypatch, tmp_path: Path):
- # Fully unit-test enroll.jinjaturtle.run_jinjaturtle by stubbing subprocess.run.
- from enroll.jinjaturtle import run_jinjaturtle
-
- def fake_run(cmd, **kwargs): # noqa: ARG001
- # cmd includes "-d -t "
- d_idx = cmd.index("-d") + 1
- t_idx = cmd.index("-t") + 1
- defaults = Path(cmd[d_idx])
- template = Path(cmd[t_idx])
- defaults.write_text("---\nfoo: 1\n", encoding="utf-8")
- template.write_text("value={{ foo }}\n", encoding="utf-8")
- return SimpleNamespace(returncode=0, stdout="ok", stderr="")
-
- monkeypatch.setattr(subprocess, "run", fake_run)
-
- src = tmp_path / "src.ini"
- src.write_text("foo=1\n", encoding="utf-8")
-
- res = run_jinjaturtle("/bin/jinjaturtle", str(src), role_name="role1")
- assert "foo: 1" in res.vars_text
- assert "value=" in res.template_text
-
-
-def test_run_jinjaturtle_raises_on_failure(monkeypatch, tmp_path: Path):
- from enroll.jinjaturtle import run_jinjaturtle
-
- def fake_run(cmd, **kwargs): # noqa: ARG001
- return SimpleNamespace(returncode=2, stdout="out", stderr="bad")
-
- monkeypatch.setattr(subprocess, "run", fake_run)
-
- src = tmp_path / "src.ini"
- src.write_text("x", encoding="utf-8")
- with pytest.raises(RuntimeError, match="jinjaturtle failed"):
- run_jinjaturtle("/bin/jinjaturtle", str(src), role_name="role1")
-
-
-def test_require_sops_cmd_errors_when_missing(monkeypatch):
- from enroll.sopsutil import require_sops_cmd, SopsError
-
- monkeypatch.setattr("enroll.sopsutil.shutil.which", lambda _: None)
- with pytest.raises(SopsError, match="not found on PATH"):
- require_sops_cmd()
-
-
-def test_get_enroll_version_reports_unknown_on_metadata_failure(monkeypatch):
- import enroll.version as v
-
- fake_meta = types.ModuleType("importlib.metadata")
-
- def boom():
- raise RuntimeError("boom")
-
- fake_meta.packages_distributions = boom
- fake_meta.version = lambda _dist: boom()
-
- monkeypatch.setitem(sys.modules, "importlib.metadata", fake_meta)
- assert v.get_enroll_version() == "unknown"
-
-
-def test_get_enroll_version_returns_unknown_if_importlib_metadata_unavailable(
- monkeypatch,
-):
- import builtins
- import enroll.version as v
-
- real_import = builtins.__import__
-
- def fake_import(
- name, globals=None, locals=None, fromlist=(), level=0
- ): # noqa: A002
- if name == "importlib.metadata":
- raise ImportError("no metadata")
- return real_import(name, globals, locals, fromlist, level)
-
- monkeypatch.setattr(builtins, "__import__", fake_import)
- assert v.get_enroll_version() == "unknown"
-
-
-def test_compare_harvests_and_format_report(tmp_path: Path):
- from enroll.diff import compare_harvests, format_report
-
- old = tmp_path / "old"
- new = tmp_path / "new"
- (old / "artifacts").mkdir(parents=True)
- (new / "artifacts").mkdir(parents=True)
-
- def write_state(base: Path, state: dict) -> None:
- base.mkdir(parents=True, exist_ok=True)
- (base / "state.json").write_text(json.dumps(state, indent=2), encoding="utf-8")
-
- # Old bundle: pkg a@1.0, pkg b@1.0, one service, one user, one managed file.
- old_state = {
- "schema_version": 3,
- "host": {"hostname": "h1"},
- "inventory": {"packages": {"a": {"version": "1.0"}, "b": {"version": "1.0"}}},
- "roles": {
- "services": [
- {
- "unit": "svc.service",
- "role_name": "svc",
- "packages": ["a"],
- "active_state": "inactive",
- "sub_state": "dead",
- "unit_file_state": "enabled",
- "condition_result": None,
- "managed_files": [
- {
- "path": "/etc/foo.conf",
- "src_rel": "etc/foo.conf",
- "owner": "root",
- "group": "root",
- "mode": "0644",
- "reason": "modified_conffile",
- }
- ],
- }
- ],
- "packages": [],
- "users": {
- "role_name": "users",
- "users": [{"name": "alice", "shell": "/bin/sh"}],
- },
- "apt_config": {"role_name": "apt_config", "managed_files": []},
- "etc_custom": {"role_name": "etc_custom", "managed_files": []},
- "usr_local_custom": {"role_name": "usr_local_custom", "managed_files": []},
- "extra_paths": {"role_name": "extra_paths", "managed_files": []},
- },
- }
- (old / "artifacts" / "svc" / "etc").mkdir(parents=True, exist_ok=True)
- (old / "artifacts" / "svc" / "etc" / "foo.conf").write_text("old", encoding="utf-8")
- write_state(old, old_state)
-
- # New bundle: pkg a@2.0, pkg c@1.0, service changed, user changed, file moved role+content.
- new_state = {
- "schema_version": 3,
- "host": {"hostname": "h2"},
- "inventory": {"packages": {"a": {"version": "2.0"}, "c": {"version": "1.0"}}},
- "roles": {
- "services": [
- {
- "unit": "svc.service",
- "role_name": "svc",
- "packages": ["a", "c"],
- "active_state": "active",
- "sub_state": "running",
- "unit_file_state": "enabled",
- "condition_result": None,
- "managed_files": [],
- }
- ],
- "packages": [],
- "users": {
- "role_name": "users",
- "users": [{"name": "alice", "shell": "/bin/bash"}, {"name": "bob"}],
- },
- "apt_config": {"role_name": "apt_config", "managed_files": []},
- "etc_custom": {"role_name": "etc_custom", "managed_files": []},
- "usr_local_custom": {"role_name": "usr_local_custom", "managed_files": []},
- "extra_paths": {
- "role_name": "extra_paths",
- "managed_files": [
- {
- "path": "/etc/foo.conf",
- "src_rel": "etc/foo.conf",
- "owner": "root",
- "group": "root",
- "mode": "0600",
- "reason": "user_include",
- },
- {
- "path": "/etc/added.conf",
- "src_rel": "etc/added.conf",
- "owner": "root",
- "group": "root",
- "mode": "0644",
- "reason": "user_include",
- },
- ],
- },
- },
- }
- (new / "artifacts" / "extra_paths" / "etc").mkdir(parents=True, exist_ok=True)
- (new / "artifacts" / "extra_paths" / "etc" / "foo.conf").write_text(
- "new", encoding="utf-8"
- )
- (new / "artifacts" / "extra_paths" / "etc" / "added.conf").write_text(
- "x", encoding="utf-8"
- )
- write_state(new, new_state)
-
- report, changed = compare_harvests(str(old), str(new))
- assert changed is True
-
- txt = format_report(report, fmt="text")
- assert "Packages" in txt
-
- md = format_report(report, fmt="markdown")
- assert "# enroll diff report" in md
-
- js = format_report(report, fmt="json")
- parsed = json.loads(js)
- assert parsed["packages"]["added"] == ["c"]
diff --git a/tests/test_pathfilter.py b/tests/test_pathfilter.py
index 406b7e7..08e4ae4 100644
--- a/tests/test_pathfilter.py
+++ b/tests/test_pathfilter.py
@@ -3,6 +3,8 @@ from __future__ import annotations
import os
from pathlib import Path
+import enroll.pathfilter as pf
+
def test_compile_and_match_prefix_glob_and_regex(tmp_path: Path):
from enroll.pathfilter import PathFilter, compile_path_pattern
@@ -78,3 +80,261 @@ def test_expand_includes_notes_on_no_matches(tmp_path: Path):
paths, notes = expand_includes(pats, max_files=10)
assert paths == []
assert any("matched no files" in n.lower() for n in notes)
+
+
+def test_expand_includes_supports_regex_with_inferred_root(tmp_path: Path):
+ """Regex includes are expanded by walking an inferred literal prefix root."""
+ from enroll.pathfilter import compile_path_pattern, expand_includes
+
+ root = tmp_path / "root"
+ (root / "home" / "alice" / ".config" / "myapp").mkdir(parents=True)
+ target = root / "home" / "alice" / ".config" / "myapp" / "settings.ini"
+ target.write_text("x=1\n", encoding="utf-8")
+
+ # This is anchored and begins with an absolute path, so expand_includes should
+ # infer a narrow walk root instead of scanning '/'.
+ rex = rf"re:^{root}/home/[^/]+/\.config/myapp/.*$"
+ pat = compile_path_pattern(rex)
+ paths, notes = expand_includes([pat], max_files=10)
+ assert str(target) in paths
+ assert notes == []
+
+
+def test_compile_path_pattern_normalises_relative_prefix():
+ from enroll.pathfilter import compile_path_pattern
+
+ p = compile_path_pattern("etc/ssh")
+ assert p.kind == "prefix"
+ assert p.value == "/etc/ssh"
+
+
+def test_norm_abs_empty_string_is_root():
+ assert pf._norm_abs("") == "/"
+
+
+def test_posix_match_invalid_pattern_fails_closed(monkeypatch):
+ # Force PurePosixPath.match to raise to cover the exception handler.
+ real_match = pf.PurePosixPath.match
+
+ def boom(self, pat):
+ raise ValueError("bad pattern")
+
+ monkeypatch.setattr(pf.PurePosixPath, "match", boom)
+ try:
+ assert pf._posix_match("/etc/hosts", "[bad") is False
+ finally:
+ monkeypatch.setattr(pf.PurePosixPath, "match", real_match)
+
+
+def test_regex_literal_prefix_handles_escapes():
+ # Prefix stops at meta chars but includes escaped literals.
+ assert pf._regex_literal_prefix(r"^/etc/\./foo") == "/etc/./foo"
+
+
+def test_expand_includes_maybe_add_file_skips_non_files(monkeypatch, tmp_path: Path):
+ # Drive the _maybe_add_file branch that rejects symlinks/non-files.
+ pats = [pf.compile_path_pattern(str(tmp_path / "missing"))]
+
+ monkeypatch.setattr(pf.os.path, "isfile", lambda p: False)
+ monkeypatch.setattr(pf.os.path, "islink", lambda p: False)
+ monkeypatch.setattr(pf.os.path, "isdir", lambda p: False)
+
+ paths, notes = pf.expand_includes(pats, max_files=10)
+ assert paths == []
+ assert any("matched no files" in n for n in notes)
+
+
+def test_expand_includes_prunes_excluded_dirs(monkeypatch):
+ include = [pf.compile_path_pattern("/root/**")]
+ exclude = pf.PathFilter(exclude=["/root/skip/**"])
+
+ # Simulate filesystem walk:
+ # /root has dirnames ['skip', 'keep'] but skip should be pruned.
+ monkeypatch.setattr(
+ pf.os.path,
+ "isdir",
+ lambda p: p in {"/root", "/root/keep", "/root/skip"},
+ )
+ monkeypatch.setattr(pf.os.path, "islink", lambda p: False)
+ monkeypatch.setattr(pf.os.path, "isfile", lambda p: True)
+
+ def walk(root, followlinks=False):
+ assert root == "/root"
+ yield ("/root", ["skip", "keep"], [])
+ yield ("/root/keep", [], ["a.txt"])
+ # If pruning works, we should never walk into /root/skip.
+
+ monkeypatch.setattr(pf.os, "walk", walk)
+
+ paths, _notes = pf.expand_includes(include, exclude=exclude, max_files=10)
+ assert "/root/keep/a.txt" in paths
+ assert not any(p.startswith("/root/skip") for p in paths)
+
+
+def test_expand_includes_respects_max_files(monkeypatch):
+ include = [pf.compile_path_pattern("/root/**")]
+ monkeypatch.setattr(pf.os.path, "isdir", lambda p: p == "/root")
+ monkeypatch.setattr(pf.os.path, "islink", lambda p: False)
+ monkeypatch.setattr(pf.os.path, "isfile", lambda p: True)
+ monkeypatch.setattr(
+ pf.os,
+ "walk",
+ lambda root, followlinks=False: [("/root", [], ["a", "b", "c"])],
+ )
+ paths, notes = pf.expand_includes(include, max_files=2)
+ assert len(paths) == 2
+ assert "/root/c" not in paths
+
+
+def test_has_glob_chars():
+ assert pf._has_glob_chars("*.txt") is True
+ assert pf._has_glob_chars("file?.log") is True
+ assert pf._has_glob_chars("[abc]") is True
+ assert pf._has_glob_chars("file.txt") is False
+ assert pf._has_glob_chars("") is False
+
+
+def test_compile_path_pattern_regex_valid():
+ result = pf.compile_path_pattern("re:^/home/.*$")
+ assert result.kind == "regex"
+ assert result.regex is not None
+ assert result.regex.search("/home/user/file.txt") is not None
+ assert result.regex.search("/var/file.txt") is None
+
+
+def test_compile_path_pattern_glob_forced():
+ result = pf.compile_path_pattern("glob:/etc/*.conf")
+ assert result.kind == "glob"
+ assert result.value == "/etc/*.conf"
+
+
+def test_compile_path_pattern_glob_heuristic():
+ result = pf.compile_path_pattern("/etc/*.conf")
+ assert result.kind == "glob"
+
+
+def test_compile_path_pattern_prefix():
+ result = pf.compile_path_pattern("/etc/nginx")
+ assert result.kind == "prefix"
+ assert result.value == "/etc/nginx"
+
+
+def test_compiled_pattern_matches_prefix():
+ pat = pf.compile_path_pattern("/etc/nginx")
+ assert pat.matches("/etc/nginx") is True
+ assert pat.matches("/etc/nginx/conf.d") is True
+ assert pat.matches("/etc/ssh") is False
+
+
+def test_compiled_pattern_matches_glob():
+ pat = pf.compile_path_pattern("/etc/*.conf")
+ assert pat.matches("/etc/ssh.conf") is True
+ assert pat.matches("/etc/ssh/sshd.conf") is False
+
+
+def test_compiled_pattern_matches_regex():
+ pat = pf.compile_path_pattern("re:^/home/[^/]+/.bashrc$")
+ assert pat.matches("/home/alice/.bashrc") is True
+ assert pat.matches("/home/bob/.bashrc") is True
+ assert pat.matches("/home/alice/.profile") is False
+ assert pat.matches("/var/.bashrc") is False
+
+
+def test_path_filter_is_excluded():
+ pf_filter = pf.PathFilter(exclude=["/tmp/*", "/var/log"])
+ assert pf_filter.is_excluded("/tmp/file.txt") is True
+ assert pf_filter.is_excluded("/var/log/syslog") is True
+ assert pf_filter.is_excluded("/etc/ssh") is False
+
+
+def test_path_filter_empty():
+ pf_filter = pf.PathFilter()
+ assert pf_filter.is_excluded("/anything") is False
+ assert pf_filter.iter_include_patterns() == []
+
+
+def test_expand_includes_prefix_existing(tmp_path: Path):
+ etc_dir = tmp_path / "etc"
+ etc_dir.mkdir()
+ (etc_dir / "file1.txt").write_text("a")
+ (etc_dir / "file2.txt").write_text("b")
+
+ patterns = [pf.compile_path_pattern(str(etc_dir))]
+ paths, notes = pf.expand_includes(patterns, max_files=10)
+
+ assert len(paths) == 2
+ assert notes == []
+
+
+def test_expand_includes_prefix_nonexistent():
+ patterns = [pf.compile_path_pattern("/nonexistent/path")]
+ paths, notes = pf.expand_includes(patterns, max_files=10)
+
+ assert paths == []
+ assert len(notes) == 1
+ assert "matched no files" in notes[0]
+
+
+def test_expand_includes_glob_no_matches():
+ patterns = [pf.compile_path_pattern("/nonexistent/*.txt")]
+ paths, notes = pf.expand_includes(patterns, max_files=10)
+
+ assert paths == []
+ assert len(notes) == 1
+
+
+def test_expand_includes_skips_symlinks(tmp_path: Path):
+ real_file = tmp_path / "real.txt"
+ real_file.write_text("x")
+ link = tmp_path / "link.txt"
+ os.symlink(str(real_file), str(link))
+
+ patterns = [pf.compile_path_pattern(str(tmp_path))]
+ paths, notes = pf.expand_includes(patterns, max_files=10)
+
+ assert len(paths) == 1
+ assert paths[0].endswith("real.txt")
+
+
+def test_expand_includes_excludes_pattern(tmp_path: Path):
+ etc_dir = tmp_path / "etc"
+ etc_dir.mkdir()
+ (etc_dir / "include.txt").write_text("a")
+ (etc_dir / "exclude.txt").write_text("b")
+
+ patterns = [pf.compile_path_pattern(str(etc_dir))]
+ exclude = pf.PathFilter(exclude=["*exclude*"])
+ paths, notes = pf.expand_includes(patterns, exclude=exclude, max_files=10)
+
+ assert len(paths) == 1
+ assert paths[0].endswith("include.txt")
+
+
+def test_expand_includes_skips_directories(tmp_path: Path):
+ subdir = tmp_path / "subdir"
+ subdir.mkdir()
+ (tmp_path / "file.txt").write_text("x")
+
+ patterns = [pf.compile_path_pattern(str(subdir))]
+ paths, notes = pf.expand_includes(patterns, max_files=10)
+
+ assert paths == []
+
+
+def test_regex_literal_prefix_simple():
+ assert pf._regex_literal_prefix("/etc/nginx/") == "/etc/nginx/"
+
+
+def test_regex_literal_prefix_with_anchor():
+ assert pf._regex_literal_prefix("^/etc/nginx/") == "/etc/nginx/"
+
+
+def test_regex_literal_prefix_with_regex_chars():
+ assert pf._regex_literal_prefix("^/etc/.*\\.conf$") == "/etc/"
+
+
+def test_path_filter_with_include_patterns():
+ pf_filter = pf.PathFilter(include=["/etc/*.conf"], exclude=["/etc/secret.conf"])
+ patterns = pf_filter.iter_include_patterns()
+ assert len(patterns) == 1
+ assert patterns[0].kind == "glob"
diff --git a/tests/test_platform.py b/tests/test_platform.py
index 7ff66c6..c0c6b46 100644
--- a/tests/test_platform.py
+++ b/tests/test_platform.py
@@ -91,3 +91,176 @@ def test_specific_paths_for_hints_differs_between_backends():
paths = set(r.specific_paths_for_hints({"nginx"}))
assert "/etc/sysconfig/nginx" in paths
assert "/etc/sysconfig/nginx.conf" in paths
+
+
+def test_read_os_release_file_not_found(tmp_path: Path):
+ result = platform._read_os_release(str(tmp_path / "nonexistent"))
+ assert result == {}
+
+
+def test_read_os_release_handles_invalid_line(tmp_path: Path):
+ p = tmp_path / "os-release"
+ p.write_text(
+ "ID=ubuntu\n" "NO_EQUALS_SIGN\n" 'VERSION="22.04"\n',
+ encoding="utf-8",
+ )
+ result = platform._read_os_release(str(p))
+ assert result["ID"] == "ubuntu"
+ assert result["VERSION"] == "22.04"
+ assert "NO_EQUALS_SIGN" not in result
+
+
+def test_detect_platform_debian(monkeypatch):
+ def fake_read_os_release(path: str = "/etc/os-release") -> dict:
+ return {"ID": "debian", "VERSION_ID": "11"}
+
+ monkeypatch.setattr(platform, "_read_os_release", fake_read_os_release)
+ result = platform.detect_platform()
+ assert result.os_family == "debian"
+ assert result.pkg_backend == "dpkg"
+
+
+def test_detect_platform_ubuntu(monkeypatch):
+ def fake_read_os_release(path: str = "/etc/os-release") -> dict:
+ return {"ID": "ubuntu", "VERSION_ID": "22.04"}
+
+ monkeypatch.setattr(platform, "_read_os_release", fake_read_os_release)
+ result = platform.detect_platform()
+ assert result.os_family == "debian"
+ assert result.pkg_backend == "dpkg"
+
+
+def test_detect_platform_fedora(monkeypatch):
+ def fake_read_os_release(path: str = "/etc/os-release") -> dict:
+ return {"ID": "fedora", "VERSION_ID": "38"}
+
+ monkeypatch.setattr(platform, "_read_os_release", fake_read_os_release)
+ result = platform.detect_platform()
+ assert result.os_family == "redhat"
+ assert result.pkg_backend == "rpm"
+
+
+def test_detect_platform_rocky(monkeypatch):
+ def fake_read_os_release(path: str = "/etc/os-release") -> dict:
+ return {"ID": "rocky", "VERSION_ID": "9"}
+
+ monkeypatch.setattr(platform, "_read_os_release", fake_read_os_release)
+ result = platform.detect_platform()
+ assert result.os_family == "redhat"
+ assert result.pkg_backend == "rpm"
+
+
+def test_detect_platform_unknown_fallback_to_dpkg(monkeypatch):
+ def fake_read_os_release(path: str = "/etc/os-release") -> dict:
+ return {"ID": "unknown"}
+
+ monkeypatch.setattr(platform, "_read_os_release", fake_read_os_release)
+ monkeypatch.setattr(platform.shutil, "which", lambda x: x == "dpkg")
+ result = platform.detect_platform()
+ assert result.os_family == "debian"
+ assert result.pkg_backend == "dpkg"
+
+
+def test_detect_platform_unknown_fallback_to_rpm(monkeypatch):
+ def fake_read_os_release(path: str = "/etc/os-release") -> dict:
+ return {"ID": "unknown"}
+
+ monkeypatch.setattr(platform, "_read_os_release", fake_read_os_release)
+ monkeypatch.setattr(platform.shutil, "which", lambda x: x == "rpm")
+ result = platform.detect_platform()
+ assert result.os_family == "redhat"
+ assert result.pkg_backend == "rpm"
+
+
+def test_detect_platform_completely_unknown(monkeypatch):
+ def fake_read_os_release(path: str = "/etc/os-release") -> dict:
+ return {"ID": "unknown"}
+
+ monkeypatch.setattr(platform, "_read_os_release", fake_read_os_release)
+ monkeypatch.setattr(platform.shutil, "which", lambda x: False)
+ result = platform.detect_platform()
+ assert result.os_family == "unknown"
+ assert result.pkg_backend == "unknown"
+
+
+def test_detect_platform_debian_like(monkeypatch):
+ def fake_read_os_release(path: str = "/etc/os-release") -> dict:
+ return {"ID": "linuxmint", "ID_LIKE": "debian"}
+
+ monkeypatch.setattr(platform, "_read_os_release", fake_read_os_release)
+ result = platform.detect_platform()
+ assert result.os_family == "debian"
+ assert result.pkg_backend == "dpkg"
+
+
+def test_detect_platform_rhel_like(monkeypatch):
+ def fake_read_os_release(path: str = "/etc/os-release") -> dict:
+ return {"ID": "centos", "ID_LIKE": "rhel fedora"}
+
+ monkeypatch.setattr(platform, "_read_os_release", fake_read_os_release)
+ result = platform.detect_platform()
+ assert result.os_family == "redhat"
+ assert result.pkg_backend == "rpm"
+
+
+def test_get_backend_returns_dpkg(monkeypatch):
+ info = platform.PlatformInfo(os_family="debian", pkg_backend="dpkg", os_release={})
+ backend = platform.get_backend(info)
+ assert isinstance(backend, platform.DpkgBackend)
+ assert backend.name == "dpkg"
+
+
+def test_get_backend_returns_rpm(monkeypatch):
+ info = platform.PlatformInfo(os_family="redhat", pkg_backend="rpm", os_release={})
+ backend = platform.get_backend(info)
+ assert isinstance(backend, platform.RpmBackend)
+ assert backend.name == "rpm"
+
+
+def test_get_backend_unknown_with_rpm(monkeypatch):
+ info = platform.PlatformInfo(
+ os_family="unknown", pkg_backend="unknown", os_release={}
+ )
+ monkeypatch.setattr(platform.shutil, "which", lambda x: x == "rpm")
+ backend = platform.get_backend(info)
+ assert isinstance(backend, platform.RpmBackend)
+
+
+def test_get_backend_unknown_with_dpkg(monkeypatch):
+ info = platform.PlatformInfo(
+ os_family="unknown", pkg_backend="unknown", os_release={}
+ )
+ monkeypatch.setattr(platform.shutil, "which", lambda x: x == "dpkg")
+ backend = platform.get_backend(info)
+ assert isinstance(backend, platform.DpkgBackend)
+
+
+def test_dpkg_backend_specific_paths():
+ backend = platform.DpkgBackend()
+ paths = backend.specific_paths_for_hints({"nginx"})
+ assert "/etc/default/nginx" in paths
+ assert "/etc/init.d/nginx" in paths
+ assert "/etc/sysctl.d/nginx.conf" in paths
+
+
+def test_rpm_backend_specific_paths():
+ backend = platform.RpmBackend()
+ paths = backend.specific_paths_for_hints({"nginx"})
+ assert "/etc/sysconfig/nginx" in paths
+ assert "/etc/sysconfig/nginx.conf" in paths
+ assert "/etc/sysctl.d/nginx.conf" in paths
+
+
+def test_is_pkg_config_path_dpkg():
+ backend = platform.DpkgBackend()
+ assert backend.is_pkg_config_path("/etc/apt/sources.list") is True
+ assert backend.is_pkg_config_path("/etc/apt/trusted.gpg") is True
+ assert backend.is_pkg_config_path("/etc/ssh/sshd_config") is False
+
+
+def test_is_pkg_config_path_rpm():
+ backend = platform.RpmBackend()
+ assert backend.is_pkg_config_path("/etc/dnf/dnf.conf") is True
+ assert backend.is_pkg_config_path("/etc/yum.conf") is True
+ assert backend.is_pkg_config_path("/etc/yum.repos.d/custom.repo") is True
+ assert backend.is_pkg_config_path("/etc/ssh/sshd_config") is False
diff --git a/tests/test_platform_backends.py b/tests/test_platform_backends.py
new file mode 100644
index 0000000..6716d53
--- /dev/null
+++ b/tests/test_platform_backends.py
@@ -0,0 +1,72 @@
+from __future__ import annotations
+
+from collections import defaultdict
+
+
+def test_dpkg_backend_modified_paths_marks_conffiles_and_packaged(monkeypatch):
+ from enroll.platform import DpkgBackend
+
+ # Provide fake conffiles md5sums.
+ monkeypatch.setattr(
+ "enroll.debian.parse_status_conffiles",
+ lambda: {"mypkg": {"/etc/mypkg.conf": "aaaa"}},
+ )
+ monkeypatch.setattr(
+ "enroll.debian.read_pkg_md5sums",
+ lambda _pkg: {"etc/other.conf": "bbbb"},
+ )
+
+ # Fake file_md5 values (avoids touching /etc).
+ def fake_md5(p: str):
+ if p == "/etc/mypkg.conf":
+ return "zzzz" # differs from conffile baseline
+ if p == "/etc/other.conf":
+ return "cccc" # differs from packaged baseline
+ if p == "/etc/apt/sources.list":
+ return "bbbb"
+ return None
+
+ monkeypatch.setattr("enroll.platform.file_md5", fake_md5)
+
+ b = DpkgBackend()
+ out = b.modified_paths(
+ "mypkg",
+ ["/etc/mypkg.conf", "/etc/other.conf", "/etc/apt/sources.list"],
+ )
+
+ assert out["/etc/mypkg.conf"] == "modified_conffile"
+ assert out["/etc/other.conf"] == "modified_packaged_file"
+ # pkg config paths (like /etc/apt/...) are excluded.
+ assert "/etc/apt/sources.list" not in out
+
+
+def test_rpm_backend_modified_paths_caches_queries(monkeypatch):
+ from enroll.platform import RpmBackend
+
+ calls = defaultdict(int)
+
+ def fake_modified(_pkg=None):
+ calls["modified"] += 1
+ return {"/etc/foo.conf", "/etc/bar.conf"}
+
+ def fake_config(_pkg=None):
+ calls["config"] += 1
+ return {"/etc/foo.conf"}
+
+ monkeypatch.setattr("enroll.rpm.rpm_modified_files", fake_modified)
+ monkeypatch.setattr("enroll.rpm.rpm_config_files", fake_config)
+
+ b = RpmBackend()
+ etc = ["/etc/foo.conf", "/etc/bar.conf", "/etc/baz.conf"]
+
+ out1 = b.modified_paths("ignored", etc)
+ out2 = b.modified_paths("ignored", etc)
+
+ assert out1 == out2
+ assert out1["/etc/foo.conf"] == "modified_conffile"
+ assert out1["/etc/bar.conf"] == "modified_packaged_file"
+ assert "/etc/baz.conf" not in out1
+
+ # Caches should mean we only queried rpm once.
+ assert calls["modified"] == 1
+ assert calls["config"] == 1
diff --git a/tests/test_remote.py b/tests/test_remote.py
index 387a397..51cccc9 100644
--- a/tests/test_remote.py
+++ b/tests/test_remote.py
@@ -49,7 +49,7 @@ def test_safe_extract_tar_rejects_symlinks(tmp_path: Path):
_safe_extract_tar(tf, tmp_path)
-def test_remote_harvest_happy_path_requests_pty_for_sudo(tmp_path: Path, monkeypatch):
+def test_remote_harvest_happy_path(tmp_path: Path, monkeypatch):
import sys
import enroll.remote as r
@@ -65,19 +65,57 @@ def test_remote_harvest_happy_path_requests_pty_for_sudo(tmp_path: Path, monkeyp
# Prepare a tiny harvest bundle tar stream from the "remote".
tgz = _make_tgz_bytes({"state.json": b'{"ok": true}\n'})
+ # Track each SSH exec_command call with whether a PTY was requested.
calls: list[tuple[str, bool]] = []
class _Chan:
- def __init__(self, rc: int = 0):
+ def __init__(self, out: bytes = b"", err: bytes = b"", rc: int = 0):
+ self._out = out
+ self._err = err
+ self._out_i = 0
+ self._err_i = 0
self._rc = rc
+ self._closed = False
+
+ def recv_ready(self) -> bool:
+ return (not self._closed) and self._out_i < len(self._out)
+
+ def recv(self, n: int) -> bytes:
+ if self._closed:
+ return b""
+ chunk = self._out[self._out_i : self._out_i + n]
+ self._out_i += len(chunk)
+ return chunk
+
+ def recv_stderr_ready(self) -> bool:
+ return (not self._closed) and self._err_i < len(self._err)
+
+ def recv_stderr(self, n: int) -> bytes:
+ if self._closed:
+ return b""
+ chunk = self._err[self._err_i : self._err_i + n]
+ self._err_i += len(chunk)
+ return chunk
+
+ def exit_status_ready(self) -> bool:
+ return self._closed or (
+ self._out_i >= len(self._out) and self._err_i >= len(self._err)
+ )
def recv_exit_status(self) -> int:
return self._rc
+ def shutdown_write(self) -> None:
+ return
+
+ def close(self) -> None:
+ self._closed = True
+
class _Stdout:
- def __init__(self, payload: bytes = b"", rc: int = 0):
+ def __init__(self, payload: bytes = b"", rc: int = 0, err: bytes = b""):
self._bio = io.BytesIO(payload)
- self.channel = _Chan(rc)
+ # _ssh_run reads stdout/stderr via the underlying channel.
+ self.channel = _Chan(out=payload, err=err, rc=rc)
def read(self, n: int = -1) -> bytes:
return self._bio.read(n)
@@ -116,9 +154,8 @@ def test_remote_harvest_happy_path_requests_pty_for_sudo(tmp_path: Path, monkeyp
def open_sftp(self):
return self._sftp
- def exec_command(self, cmd: str, get_pty: bool = False):
+ def exec_command(self, cmd: str, *, get_pty: bool = False, **_kwargs):
calls.append((cmd, bool(get_pty)))
-
# The tar stream uses exec_command directly.
if cmd.startswith("tar -cz -C"):
return (None, _Stdout(tgz, rc=0), _Stderr(b""))
@@ -130,10 +167,20 @@ def test_remote_harvest_happy_path_requests_pty_for_sudo(tmp_path: Path, monkeyp
return (None, _Stdout(b"/tmp/enroll-remote-123\n"), _Stderr())
if cmd.startswith("chmod 700"):
return (None, _Stdout(b""), _Stderr())
+ if cmd.startswith("sudo -n") and " harvest " in cmd:
+ if not get_pty:
+ msg = b"sudo: sorry, you must have a tty to run sudo\n"
+ return (None, _Stdout(b"", rc=1, err=msg), _Stderr(msg))
+ return (None, _Stdout(b"", rc=0), _Stderr(b""))
+ if cmd.startswith("sudo -S") and " harvest " in cmd:
+ return (None, _Stdout(b""), _Stderr())
if " harvest " in cmd:
return (None, _Stdout(b""), _Stderr())
- if cmd.startswith("sudo chown -R"):
- return (None, _Stdout(b""), _Stderr())
+ if cmd.startswith("sudo -n") and " chown -R" in cmd:
+ if not get_pty:
+ msg = b"sudo: sorry, you must have a tty to run sudo\n"
+ return (None, _Stdout(b"", rc=1, err=msg), _Stderr(msg))
+ return (None, _Stdout(b"", rc=0), _Stderr(b""))
if cmd.startswith("rm -rf"):
return (None, _Stdout(b""), _Stderr())
@@ -154,6 +201,7 @@ def test_remote_harvest_happy_path_requests_pty_for_sudo(tmp_path: Path, monkeyp
out_dir = tmp_path / "out"
state_path = r.remote_harvest(
+ ask_become_pass=False,
local_out_dir=out_dir,
remote_host="example.com",
remote_port=2222,
@@ -169,15 +217,800 @@ def test_remote_harvest_happy_path_requests_pty_for_sudo(tmp_path: Path, monkeyp
assert b"ok" in state_path.read_bytes()
# Ensure we attempted remote harvest with sudo and passed include/exclude and dangerous.
- joined = "\n".join([c for c, _ in calls])
+ joined = "\n".join([c for c, _pty in calls])
assert "sudo" in joined
assert "--dangerous" in joined
assert "--include-path" in joined
assert "--exclude-path" in joined
- # Assert PTY is requested for sudo commands (harvest & chown), not for tar streaming.
- sudo_cmds = [(c, pty) for c, pty in calls if c.startswith("sudo ")]
- assert sudo_cmds, "expected at least one sudo command"
- assert all(pty for _, pty in sudo_cmds)
- tar_cmds = [(c, pty) for c, pty in calls if c.startswith("tar -cz -C")]
- assert tar_cmds and all(not pty for _, pty in tar_cmds)
+ # Ensure we fall back to PTY only when sudo reports it is required.
+ assert any(c == "id -un" and pty is False for c, pty in calls)
+
+ sudo_harvest = [
+ (c, pty) for c, pty in calls if c.startswith("sudo -n") and " harvest " in c
+ ]
+ assert any(pty is False for _c, pty in sudo_harvest)
+ assert any(pty is True for _c, pty in sudo_harvest)
+
+ sudo_chown = [
+ (c, pty) for c, pty in calls if c.startswith("sudo -n") and " chown -R" in c
+ ]
+ assert any(pty is False for _c, pty in sudo_chown)
+ assert any(pty is True for _c, pty in sudo_chown)
+
+ assert any(c.startswith("tar -cz -C") and pty is False for c, pty in calls)
+
+
+def test_remote_harvest_no_sudo_does_not_request_pty_or_chown(
+ tmp_path: Path, monkeypatch
+):
+ """When --no-sudo is used we should not request a PTY nor run sudo chown."""
+ import sys
+
+ import enroll.remote as r
+
+ monkeypatch.setattr(
+ r,
+ "_build_enroll_pyz",
+ lambda td: (Path(td) / "enroll.pyz").write_bytes(b"PYZ")
+ or (Path(td) / "enroll.pyz"),
+ )
+
+ tgz = _make_tgz_bytes({"state.json": b"{}"})
+ calls: list[tuple[str, bool]] = []
+
+ class _Chan:
+ def __init__(self, out: bytes = b"", err: bytes = b"", rc: int = 0):
+ self._out = out
+ self._err = err
+ self._out_i = 0
+ self._err_i = 0
+ self._rc = rc
+ self._closed = False
+
+ def recv_ready(self) -> bool:
+ return (not self._closed) and self._out_i < len(self._out)
+
+ def recv(self, n: int) -> bytes:
+ if self._closed:
+ return b""
+ chunk = self._out[self._out_i : self._out_i + n]
+ self._out_i += len(chunk)
+ return chunk
+
+ def recv_stderr_ready(self) -> bool:
+ return (not self._closed) and self._err_i < len(self._err)
+
+ def recv_stderr(self, n: int) -> bytes:
+ if self._closed:
+ return b""
+ chunk = self._err[self._err_i : self._err_i + n]
+ self._err_i += len(chunk)
+ return chunk
+
+ def exit_status_ready(self) -> bool:
+ return self._closed or (
+ self._out_i >= len(self._out) and self._err_i >= len(self._err)
+ )
+
+ def recv_exit_status(self) -> int:
+ return self._rc
+
+ def shutdown_write(self) -> None:
+ return
+
+ def close(self) -> None:
+ self._closed = True
+
+ class _Stdout:
+ def __init__(self, payload: bytes = b"", rc: int = 0, err: bytes = b""):
+ self._bio = io.BytesIO(payload)
+ # _ssh_run reads stdout/stderr via the underlying channel.
+ self.channel = _Chan(out=payload, err=err, rc=rc)
+
+ def read(self, n: int = -1) -> bytes:
+ return self._bio.read(n)
+
+ class _Stderr:
+ def __init__(self, payload: bytes = b""):
+ self._bio = io.BytesIO(payload)
+
+ def read(self, n: int = -1) -> bytes:
+ return self._bio.read(n)
+
+ class _SFTP:
+ def put(self, _local: str, _remote: str) -> None:
+ return
+
+ def close(self) -> None:
+ return
+
+ class FakeSSH:
+ def __init__(self):
+ self._sftp = _SFTP()
+
+ def load_system_host_keys(self):
+ return
+
+ def set_missing_host_key_policy(self, _policy):
+ return
+
+ def connect(self, **_kwargs):
+ return
+
+ def open_sftp(self):
+ return self._sftp
+
+ def exec_command(self, cmd: str, *, get_pty: bool = False, **_kwargs):
+ calls.append((cmd, bool(get_pty)))
+ if cmd == "mktemp -d":
+ return (None, _Stdout(b"/tmp/enroll-remote-456\n"), _Stderr())
+ if cmd.startswith("chmod 700"):
+ return (None, _Stdout(b""), _Stderr())
+ if cmd.startswith("tar -cz -C"):
+ return (None, _Stdout(tgz, rc=0), _Stderr())
+ if " harvest " in cmd:
+ return (None, _Stdout(b""), _Stderr())
+ if cmd.startswith("rm -rf"):
+ return (None, _Stdout(b""), _Stderr())
+ return (None, _Stdout(b""), _Stderr())
+
+ def close(self):
+ return
+
+ import types
+
+ class RejectPolicy:
+ pass
+
+ monkeypatch.setitem(
+ sys.modules,
+ "paramiko",
+ types.SimpleNamespace(SSHClient=FakeSSH, RejectPolicy=RejectPolicy),
+ )
+
+ out_dir = tmp_path / "out"
+ r.remote_harvest(
+ ask_become_pass=False,
+ local_out_dir=out_dir,
+ remote_host="example.com",
+ remote_user="alice",
+ no_sudo=True,
+ )
+
+ joined = "\n".join([c for c, _pty in calls])
+ assert "sudo" not in joined
+ assert "sudo chown" not in joined
+ assert any(" harvest " in c and pty is False for c, pty in calls)
+
+
+def test_remote_harvest_sudo_password_retry_uses_sudo_s_and_writes_password(
+ tmp_path: Path, monkeypatch
+):
+ """If sudo requires a password, we should fall back from -n to -S and feed stdin."""
+ import sys
+ import types
+
+ import enroll.remote as r
+
+ # Avoid building a real zipapp; just create a file.
+ monkeypatch.setattr(
+ r,
+ "_build_enroll_pyz",
+ lambda td: (Path(td) / "enroll.pyz").write_bytes(b"PYZ")
+ or (Path(td) / "enroll.pyz"),
+ )
+
+ tgz = _make_tgz_bytes({"state.json": b'{"ok": true}\n'})
+ calls: list[tuple[str, bool]] = []
+ stdin_by_cmd: dict[str, list[str]] = {}
+
+ class _Chan:
+ def __init__(self, out: bytes = b"", err: bytes = b"", rc: int = 0):
+ self._out = out
+ self._err = err
+ self._out_i = 0
+ self._err_i = 0
+ self._rc = rc
+ self._closed = False
+
+ def recv_ready(self) -> bool:
+ return (not self._closed) and self._out_i < len(self._out)
+
+ def recv(self, n: int) -> bytes:
+ if self._closed:
+ return b""
+ chunk = self._out[self._out_i : self._out_i + n]
+ self._out_i += len(chunk)
+ return chunk
+
+ def recv_stderr_ready(self) -> bool:
+ return (not self._closed) and self._err_i < len(self._err)
+
+ def recv_stderr(self, n: int) -> bytes:
+ if self._closed:
+ return b""
+ chunk = self._err[self._err_i : self._err_i + n]
+ self._err_i += len(chunk)
+ return chunk
+
+ def exit_status_ready(self) -> bool:
+ return self._closed or (
+ self._out_i >= len(self._out) and self._err_i >= len(self._err)
+ )
+
+ def recv_exit_status(self) -> int:
+ return self._rc
+
+ def shutdown_write(self) -> None:
+ return
+
+ def close(self) -> None:
+ self._closed = True
+
+ class _Stdout:
+ def __init__(self, payload: bytes = b"", rc: int = 0, err: bytes = b""):
+ self._bio = io.BytesIO(payload)
+ # _ssh_run reads stdout/stderr via the underlying channel.
+ self.channel = _Chan(out=payload, err=err, rc=rc)
+
+ def read(self, n: int = -1) -> bytes:
+ return self._bio.read(n)
+
+ class _Stderr:
+ def __init__(self, payload: bytes = b""):
+ self._bio = io.BytesIO(payload)
+
+ def read(self, n: int = -1) -> bytes:
+ return self._bio.read(n)
+
+ class _Stdin:
+ def __init__(self, cmd: str):
+ self._cmd = cmd
+ stdin_by_cmd.setdefault(cmd, [])
+
+ def write(self, s: str) -> None:
+ stdin_by_cmd[self._cmd].append(s)
+
+ def flush(self) -> None:
+ return
+
+ class _SFTP:
+ def put(self, _local: str, _remote: str) -> None:
+ return
+
+ def close(self) -> None:
+ return
+
+ class FakeSSH:
+ def __init__(self):
+ self._sftp = _SFTP()
+
+ def load_system_host_keys(self):
+ return
+
+ def set_missing_host_key_policy(self, _policy):
+ return
+
+ def connect(self, **_kwargs):
+ return
+
+ def open_sftp(self):
+ return self._sftp
+
+ def exec_command(self, cmd: str, *, get_pty: bool = False, **_kwargs):
+ calls.append((cmd, bool(get_pty)))
+
+ # Tar stream
+ if cmd.startswith("tar -cz -C"):
+ return (_Stdin(cmd), _Stdout(tgz, rc=0), _Stderr(b""))
+
+ if cmd == "mktemp -d":
+ return (_Stdin(cmd), _Stdout(b"/tmp/enroll-remote-789\n"), _Stderr())
+ if cmd.startswith("chmod 700"):
+ return (_Stdin(cmd), _Stdout(b""), _Stderr())
+
+ # First attempt: sudo -n fails, prompting is not allowed.
+ if cmd.startswith("sudo -n") and " harvest " in cmd:
+ return (
+ _Stdin(cmd),
+ _Stdout(b"", rc=1, err=b"sudo: a password is required\n"),
+ _Stderr(b"sudo: a password is required\n"),
+ )
+
+ # Retry: sudo -S succeeds and should have been fed the password via stdin.
+ if cmd.startswith("sudo -S") and " harvest " in cmd:
+ return (_Stdin(cmd), _Stdout(b"", rc=0), _Stderr(b""))
+
+ # chown succeeds passwordlessly (e.g., sudo timestamp is warm).
+ if cmd.startswith("sudo -n") and " chown -R" in cmd:
+ return (_Stdin(cmd), _Stdout(b"", rc=0), _Stderr(b""))
+
+ if cmd.startswith("rm -rf"):
+ return (_Stdin(cmd), _Stdout(b"", rc=0), _Stderr(b""))
+
+ # Fallback for unexpected commands.
+ return (_Stdin(cmd), _Stdout(b"", rc=0), _Stderr(b""))
+
+ def close(self):
+ return
+
+ class RejectPolicy:
+ pass
+
+ monkeypatch.setitem(
+ sys.modules,
+ "paramiko",
+ types.SimpleNamespace(SSHClient=FakeSSH, RejectPolicy=RejectPolicy),
+ )
+
+ out_dir = tmp_path / "out"
+ state_path = r.remote_harvest(
+ ask_become_pass=True,
+ getpass_fn=lambda _prompt="": "s3cr3t",
+ local_out_dir=out_dir,
+ remote_host="example.com",
+ remote_user="alice",
+ no_sudo=False,
+ )
+
+ assert state_path.exists()
+ assert b"ok" in state_path.read_bytes()
+
+ # Ensure we attempted with sudo -n first, then sudo -S.
+ sudo_n = [c for c, _pty in calls if c.startswith("sudo -n") and " harvest " in c]
+ sudo_s = [c for c, _pty in calls if c.startswith("sudo -S") and " harvest " in c]
+ assert len(sudo_n) == 1
+ assert len(sudo_s) == 1
+
+ # Ensure the password was written to stdin for the -S invocation.
+ assert stdin_by_cmd.get(sudo_s[0]) == ["s3cr3t\n"]
+
+
+def test_sudo_password_required_detection():
+ from enroll.remote import _sudo_password_required
+
+ assert _sudo_password_required("", "a password is required") is True
+ assert _sudo_password_required("", "password is required") is True
+ assert (
+ _sudo_password_required("", "a terminal is required to read the password")
+ is True
+ )
+ assert (
+ _sudo_password_required("", "no tty present and no askpass program specified")
+ is True
+ )
+ assert _sudo_password_required("", "must have a tty to run sudo") is True
+ assert _sudo_password_required("", "sudo: sorry, you must have a tty") is True
+ assert _sudo_password_required("", "askpass") is True
+ assert _sudo_password_required("success", "") is False
+
+
+def test_sudo_not_permitted_detection():
+ from enroll.remote import _sudo_not_permitted
+
+ assert _sudo_not_permitted("", "user is not in the sudoers file") is True
+ assert _sudo_not_permitted("", "not allowed to execute") is True
+ assert _sudo_not_permitted("", "may not run sudo") is True
+ assert _sudo_not_permitted("", "sorry, user") is True
+ assert _sudo_not_permitted("success", "") is False
+
+
+def test_sudo_tty_required_detection():
+ from enroll.remote import _sudo_tty_required
+
+ assert _sudo_tty_required("", "must have a tty") is True
+ assert _sudo_tty_required("", "sorry, you must have a tty") is True
+ assert _sudo_tty_required("", "sudo: sorry, you must have a tty") is True
+ assert _sudo_tty_required("", "must have a tty to run sudo") is True
+ assert _sudo_tty_required("success", "") is False
+
+
+def test_resolve_become_password_prompts_when_asked(monkeypatch):
+ from enroll.remote import _resolve_become_password
+
+ prompted = []
+
+ def fake_getpass(prompt):
+ prompted.append(prompt)
+ return "secret"
+
+ result = _resolve_become_password(
+ True, prompt="sudo password: ", getpass_fn=fake_getpass
+ )
+ assert result == "secret"
+ assert len(prompted) == 1
+
+
+def test_resolve_become_password_returns_none_when_not_asked():
+ from enroll.remote import _resolve_become_password
+
+ result = _resolve_become_password(False)
+ assert result is None
+
+
+def test_resolve_ssh_key_passphrase_from_env(monkeypatch):
+ from enroll.remote import _resolve_ssh_key_passphrase
+
+ monkeypatch.setenv("SSH_KEY_PASS", "env_secret")
+
+ result = _resolve_ssh_key_passphrase(False, env_var="SSH_KEY_PASS")
+ assert result == "env_secret"
+
+
+def test_resolve_ssh_key_passphrase_raises_when_env_not_set(monkeypatch):
+ from enroll.remote import _resolve_ssh_key_passphrase
+
+ monkeypatch.delenv("SSH_KEY_PASS", raising=False)
+
+ with pytest.raises(RuntimeError, match="SSH key passphrase environment variable"):
+ _resolve_ssh_key_passphrase(False, env_var="SSH_KEY_PASS")
+
+
+def test_resolve_ssh_key_passphrase_prompts_when_asked(monkeypatch):
+ from enroll.remote import _resolve_ssh_key_passphrase
+
+ prompted = []
+
+ def fake_getpass(prompt):
+ prompted.append(prompt)
+ return "prompt_secret"
+
+ result = _resolve_ssh_key_passphrase(
+ True, prompt="SSH key passphrase: ", getpass_fn=fake_getpass
+ )
+ assert result == "prompt_secret"
+ assert len(prompted) == 1
+
+
+def test_resolve_ssh_key_passphrase_returns_none_when_not_asked():
+ from enroll.remote import _resolve_ssh_key_passphrase
+
+ result = _resolve_ssh_key_passphrase(False, env_var=None)
+ assert result is None
+
+
+def test_safe_extract_tar_rejects_absolute_paths(tmp_path: Path):
+ from enroll.remote import _safe_extract_tar
+
+ import io
+ import tarfile
+
+ bio = io.BytesIO()
+ with tarfile.open(fileobj=bio, mode="w:gz") as tf:
+ ti = tarfile.TarInfo(name="/etc/passwd")
+ ti.size = 1
+ tf.addfile(ti, io.BytesIO(b"x"))
+
+ bio.seek(0)
+ with tarfile.open(fileobj=bio, mode="r:gz") as tf:
+ with pytest.raises(RuntimeError, match="Unsafe tar member path"):
+ _safe_extract_tar(tf, tmp_path)
+
+
+def test_safe_extract_tar_rejects_hardlinks(tmp_path: Path):
+ from enroll.remote import _safe_extract_tar
+
+ import io
+ import tarfile
+
+ bio = io.BytesIO()
+ with tarfile.open(fileobj=bio, mode="w:gz") as tf:
+ ti = tarfile.TarInfo(name="hardlink")
+ ti.type = tarfile.LNKTYPE
+ ti.linkname = "/etc/passwd"
+ tf.addfile(ti)
+
+ bio.seek(0)
+ with tarfile.open(fileobj=bio, mode="r:gz") as tf:
+ with pytest.raises(RuntimeError, match="Refusing to extract"):
+ _safe_extract_tar(tf, tmp_path)
+
+
+def test_safe_extract_tar_rejects_device_nodes(tmp_path: Path):
+ from enroll.remote import _safe_extract_tar
+
+ import io
+ import tarfile
+
+ bio = io.BytesIO()
+ with tarfile.open(fileobj=bio, mode="w:gz") as tf:
+ ti = tarfile.TarInfo(name="device")
+ ti.type = tarfile.CHRTYPE
+ tf.addfile(ti)
+
+ bio.seek(0)
+ with tarfile.open(fileobj=bio, mode="r:gz") as tf:
+ with pytest.raises(RuntimeError, match="Refusing to extract"):
+ _safe_extract_tar(tf, tmp_path)
+
+
+def test_safe_extract_tar_accepts_dot_entry(tmp_path: Path):
+ from enroll.remote import _safe_extract_tar
+
+ import io
+ import tarfile
+
+ bio = io.BytesIO()
+ with tarfile.open(fileobj=bio, mode="w:gz") as tf:
+ ti = tarfile.TarInfo(name=".")
+ ti.size = 0
+ tf.addfile(ti, io.BytesIO(b""))
+
+ bio.seek(0)
+ with tarfile.open(fileobj=bio, mode="r:gz") as tf:
+ _safe_extract_tar(tf, tmp_path)
+
+
+def test_safe_extract_tar_accepts_valid_files(tmp_path: Path):
+ from enroll.remote import _safe_extract_tar
+
+ import io
+ import tarfile
+
+ bio = io.BytesIO()
+ with tarfile.open(fileobj=bio, mode="w:gz") as tf:
+ ti = tarfile.TarInfo(name="foo/bar.txt")
+ ti.size = 5
+ tf.addfile(ti, io.BytesIO(b"hello"))
+
+ bio.seek(0)
+ with tarfile.open(fileobj=bio, mode="r:gz") as tf:
+ _safe_extract_tar(tf, tmp_path)
+
+ assert (tmp_path / "foo" / "bar.txt").read_bytes() == b"hello"
+
+
+def test_remote_harvest_ssh_key_passphrase_retry(monkeypatch, tmp_path: Path):
+ import sys
+
+ import enroll.remote as r
+
+ monkeypatch.setattr(
+ r,
+ "_build_enroll_pyz",
+ lambda td: (Path(td) / "enroll.pyz").write_bytes(b"PYZ")
+ or (Path(td) / "enroll.pyz"),
+ )
+
+ tgz = _make_tgz_bytes({"state.json": b'{"ok": true}\n'})
+
+ class _Chan:
+ def __init__(self, out: bytes = b"", err: bytes = b"", rc: int = 0):
+ self._out = out
+ self._err = err
+ self._out_i = 0
+ self._err_i = 0
+ self._rc = rc
+ self._closed = False
+
+ def recv_ready(self) -> bool:
+ return (not self._closed) and self._out_i < len(self._out)
+
+ def recv(self, n: int) -> bytes:
+ if self._closed:
+ return b""
+ chunk = self._out[self._out_i : self._out_i + n]
+ self._out_i += len(chunk)
+ return chunk
+
+ def recv_stderr_ready(self) -> bool:
+ return (not self._closed) and self._err_i < len(self._err)
+
+ def recv_stderr(self, n: int) -> bytes:
+ if self._closed:
+ return b""
+ chunk = self._err[self._err_i : self._err_i + n]
+ self._err_i += len(chunk)
+ return chunk
+
+ def exit_status_ready(self) -> bool:
+ return self._closed or (
+ self._out_i >= len(self._out) and self._err_i >= len(self._err)
+ )
+
+ def recv_exit_status(self) -> int:
+ return self._rc
+
+ def shutdown_write(self) -> None:
+ return
+
+ def close(self) -> None:
+ self._closed = True
+
+ class _Stdout:
+ def __init__(self, payload: bytes = b"", rc: int = 0, err: bytes = b""):
+ self._bio = io.BytesIO(payload)
+ self.channel = _Chan(out=payload, err=err, rc=rc)
+
+ def read(self, n: int = -1) -> bytes:
+ return self._bio.read(n)
+
+ class _Stderr:
+ def __init__(self, payload: bytes = b""):
+ self._bio = io.BytesIO(payload)
+
+ def read(self, n: int = -1) -> bytes:
+ return self._bio.read(n)
+
+ class _Stdin:
+ def __init__(self, cmd: str):
+ self._cmd = cmd
+
+ def write(self, s: str) -> None:
+ pass
+
+ def flush(self) -> None:
+ return
+
+ class _SFTP:
+ def put(self, _local: str, _remote: str) -> None:
+ return
+
+ def close(self) -> None:
+ return
+
+ class FakeSSH:
+ def __init__(self):
+ self._sftp = _SFTP()
+
+ def load_system_host_keys(self):
+ return
+
+ def set_missing_host_key_policy(self, _policy):
+ return
+
+ def connect(self, **_kwargs):
+ return
+
+ def open_sftp(self):
+ return self._sftp
+
+ def exec_command(self, cmd: str, *, get_pty: bool = False, **_kwargs):
+ if cmd.startswith("tar -cz -C"):
+ return (_Stdin(cmd), _Stdout(tgz, rc=0), _Stderr(b""))
+ if cmd == "mktemp -d":
+ return (_Stdin(cmd), _Stdout(b"/tmp/enroll-remote-789\n"), _Stderr())
+ if cmd.startswith("chmod 700"):
+ return (_Stdin(cmd), _Stdout(b""), _Stderr())
+ if " harvest " in cmd:
+ return (_Stdin(cmd), _Stdout(b""), _Stderr())
+ if cmd.startswith("rm -rf"):
+ return (_Stdin(cmd), _Stdout(b""), _Stderr())
+ return (_Stdin(cmd), _Stdout(b""), _Stderr())
+
+ def close(self):
+ return
+
+ RejectPolicy4 = type("RejectPolicy", (), {})
+
+ class FakeParamiko:
+ SSHClient = FakeSSH
+ RejectPolicy = RejectPolicy4 # type: ignore
+ PasswordRequiredException = Exception # type: ignore
+
+ monkeypatch.setitem(sys.modules, "paramiko", FakeParamiko)
+
+ prompts = []
+
+ def fake_getpass(prompt):
+ prompts.append(prompt)
+ return "passphrase"
+
+ out_dir = tmp_path / "out"
+ state_path = r.remote_harvest(
+ ask_key_passphrase=True,
+ getpass_fn=fake_getpass,
+ local_out_dir=out_dir,
+ remote_host="example.com",
+ remote_user="alice",
+ no_sudo=True,
+ )
+
+ assert state_path.exists()
+ assert len(prompts) == 1
+
+
+def test_remote_harvest_ssh_key_passphrase_raises_when_not_interactive(
+ monkeypatch, tmp_path: Path
+):
+ import sys
+
+ import enroll.remote as r
+
+ monkeypatch.setattr(
+ r,
+ "_build_enroll_pyz",
+ lambda td: (Path(td) / "enroll.pyz").write_bytes(b"PYZ")
+ or (Path(td) / "enroll.pyz"),
+ )
+
+ class _Chan:
+ def __init__(self):
+ self._closed = False
+
+ def recv_ready(self) -> bool:
+ return False
+
+ def recv(self, n: int) -> bytes:
+ return b""
+
+ def recv_stderr_ready(self) -> bool:
+ return False
+
+ def recv_stderr(self, n: int) -> bytes:
+ return b""
+
+ def exit_status_ready(self) -> bool:
+ return True
+
+ def recv_exit_status(self) -> int:
+ return 0
+
+ def shutdown_write(self) -> None:
+ return
+
+ def close(self) -> None:
+ self._closed = True
+
+ class _Stdout:
+ def __init__(self):
+ self.channel = _Chan()
+
+ def read(self, n: int = -1) -> bytes:
+ return b""
+
+ class _Stderr:
+ def read(self, n: int = -1) -> bytes:
+ return b""
+
+ class _SFTP:
+ def put(self, _local: str, _remote: str) -> None:
+ return
+
+ def close(self) -> None:
+ return
+
+ class FakeSSH:
+ def __init__(self):
+ self._sftp = _SFTP()
+
+ def load_system_host_keys(self):
+ return
+
+ def set_missing_host_key_policy(self, _policy):
+ return
+
+ def connect(self, **_kwargs):
+ raise Exception("PasswordRequired")
+
+ def open_sftp(self):
+ return self._sftp
+
+ def exec_command(self, cmd: str, **_kwargs):
+ return (_Stdout(), _Stdout(), _Stderr())
+
+ def close(self):
+ return
+
+ class RejectPolicy:
+ pass
+
+ RejectPolicy3 = RejectPolicy
+
+ class FakeParamiko:
+ SSHClient = FakeSSH
+ RejectPolicy = RejectPolicy3 # type: ignore
+ PasswordRequiredException = Exception # type: ignore
+
+ monkeypatch.setitem(sys.modules, "paramiko", FakeParamiko)
+
+ out_dir = tmp_path / "out"
+
+ with pytest.raises(RuntimeError, match="SSH private key is encrypted"):
+ r.remote_harvest(
+ ask_key_passphrase=False,
+ local_out_dir=out_dir,
+ remote_host="example.com",
+ stdin=io.StringIO(),
+ )
diff --git a/tests/test_rpm.py b/tests/test_rpm.py
index ea97c12..3aeccc8 100644
--- a/tests/test_rpm.py
+++ b/tests/test_rpm.py
@@ -1,4 +1,5 @@
from __future__ import annotations
+import pytest
import enroll.rpm as rpm
@@ -129,3 +130,80 @@ def test_rpm_config_files_and_modified_files_parsing(monkeypatch):
rpm, "_run", lambda cmd, allow_fail=False, merge_err=False: (1, out)
)
assert rpm.rpm_modified_files("mypkg") == {"/etc/foo.conf", "/etc/bar"}
+
+
+def test_list_manual_packages_uses_yum_fallback(monkeypatch):
+ # No dnf, yum present.
+ monkeypatch.setattr(
+ rpm.shutil, "which", lambda exe: "/usr/bin/yum" if exe == "yum" else None
+ )
+
+ def fake_run(cmd, allow_fail=False, merge_err=False):
+ assert cmd[:3] == ["yum", "-q", "history"]
+ return 0, "Installed Packages\nvim-enhanced.x86_64\nhtop\n"
+
+ monkeypatch.setattr(rpm, "_run", fake_run)
+
+ assert rpm.list_manual_packages() == ["htop", "vim-enhanced"]
+
+
+def test_list_installed_packages_parses_epoch_and_sorts(monkeypatch):
+ out = (
+ "bash\t0\t5.2.26\t1.el9\tx86_64\n"
+ "bash\t1\t5.2.26\t1.el9\taarch64\n"
+ "coreutils\t(none)\t9.1\t2.el9\tx86_64\n"
+ )
+ monkeypatch.setattr(
+ rpm, "_run", lambda cmd, allow_fail=False, merge_err=False: (0, out)
+ )
+ pkgs = rpm.list_installed_packages()
+ assert pkgs["bash"][0]["arch"] == "aarch64" # sorted by arch then version
+ assert pkgs["bash"][0]["version"].startswith("1:")
+ assert pkgs["coreutils"][0]["version"] == "9.1-2.el9"
+
+
+def test_rpm_config_files_returns_empty_on_failure(monkeypatch):
+ monkeypatch.setattr(
+ rpm, "_run", lambda cmd, allow_fail=False, merge_err=False: (1, "")
+ )
+ assert rpm.rpm_config_files("missing") == set()
+
+
+def test_rpm_owner_strips_epoch_prefix_when_present(monkeypatch):
+ # Defensive: rpm output might include epoch-like token.
+ monkeypatch.setattr(
+ rpm,
+ "_run",
+ lambda cmd, allow_fail=False, merge_err=False: (0, "1:bash\n"),
+ )
+ assert rpm.rpm_owner("/bin/bash") == "bash"
+
+
+def test_strip_arch_no_suffix():
+ assert rpm._strip_arch("vim") == "vim"
+ assert rpm._strip_arch("nginx ") == "nginx"
+
+
+def test_strip_arch_with_unknown_suffix():
+ assert rpm._strip_arch("package.unknown") == "package.unknown"
+
+
+def test_run_command_raises_on_fail():
+ with pytest.raises(RuntimeError):
+ rpm._run(["sh", "-c", "echo stderr >&2; exit 1"], allow_fail=False)
+
+
+def test_rpm_owner_empty_path():
+ assert rpm.rpm_owner("") is None
+
+
+def test_rpm_modified_files_empty(monkeypatch):
+ monkeypatch.setattr(
+ rpm, "_run", lambda cmd, allow_fail=False, merge_err=False: (0, "")
+ )
+ assert rpm.rpm_modified_files("vim") == set()
+
+
+def test_list_manual_packages_no_commands_available(monkeypatch):
+ monkeypatch.setattr(rpm.shutil, "which", lambda exe: None)
+ assert rpm.list_manual_packages() == []
diff --git a/tests/test_rpm_run.py b/tests/test_rpm_run.py
new file mode 100644
index 0000000..75b965e
--- /dev/null
+++ b/tests/test_rpm_run.py
@@ -0,0 +1,31 @@
+from __future__ import annotations
+
+import types
+
+import pytest
+
+import enroll.rpm as rpm
+
+
+def test_run_raises_on_nonzero_returncode_when_not_allow_fail(monkeypatch):
+ def fake_run(cmd, check, text, stdout, stderr):
+ return types.SimpleNamespace(returncode=1, stdout="OUT", stderr="ERR")
+
+ monkeypatch.setattr(rpm.subprocess, "run", fake_run)
+ with pytest.raises(RuntimeError) as e:
+ rpm._run(["rpm", "-q"]) # type: ignore[attr-defined]
+ assert "Command failed" in str(e.value)
+ assert "ERR" in str(e.value)
+ assert "OUT" in str(e.value)
+
+
+def test_run_merge_err_includes_stderr_in_stdout(monkeypatch):
+ def fake_run(cmd, check, text, stdout, stderr):
+ # When merge_err is True, stderr is redirected to STDOUT, so we only
+ # rely on stdout in our wrapper.
+ return types.SimpleNamespace(returncode=0, stdout="COMBINED", stderr=None)
+
+ monkeypatch.setattr(rpm.subprocess, "run", fake_run)
+ rc, out = rpm._run(["rpm", "-q"], merge_err=True)
+ assert rc == 0
+ assert out == "COMBINED"
diff --git a/tests/test_sopsutil.py b/tests/test_sopsutil.py
new file mode 100644
index 0000000..3aeffdc
--- /dev/null
+++ b/tests/test_sopsutil.py
@@ -0,0 +1,234 @@
+from __future__ import annotations
+
+import pytest
+
+from pathlib import Path
+from enroll.sopsutil import SopsError, _pgp_arg, find_sops_cmd, require_sops_cmd
+
+
+def test_find_sops_cmd():
+ result = find_sops_cmd()
+ if result is None:
+ pytest.skip("sops not installed")
+ assert result.endswith("sops")
+
+
+def test_require_sops_cmd():
+ exe = require_sops_cmd()
+ assert exe is not None
+ assert "sops" in exe
+
+
+def test_require_sops_cmd_raises_when_not_found(monkeypatch):
+ import enroll.sopsutil as s
+
+ def fake_find():
+ return None
+
+ monkeypatch.setattr(s, "find_sops_cmd", fake_find)
+
+ with pytest.raises(SopsError) as exc_info:
+ require_sops_cmd()
+
+ assert "sops" in str(exc_info.value).lower()
+ assert "not found" in str(exc_info.value).lower()
+
+
+def test_pgp_arg_with_empty_fingerprints():
+ with pytest.raises(SopsError) as exc_info:
+ _pgp_arg([])
+ assert "No GPG fingerprints" in str(exc_info.value)
+
+
+def test_pgp_arg_with_whitespace_fingerprints():
+ result = _pgp_arg([" ", "ABC123", " DEF456 "])
+ assert result == "ABC123,DEF456"
+
+
+def test_pgp_arg_with_single_fingerprint():
+ result = _pgp_arg(["ABC123DEF456"])
+ assert result == "ABC123DEF456"
+
+
+def test_pgp_arg_with_multiple_fingerprints():
+ result = _pgp_arg(["ABC123", "DEF456", "GHI789"])
+ assert result == "ABC123,DEF456,GHI789"
+
+
+def test_encrypt_file_binary_success(monkeypatch, tmp_path: Path):
+ """Test successful encryption path."""
+ # Create source file
+ src = tmp_path / "secret.txt"
+ src.write_text("secret data", encoding="utf-8")
+ dst = tmp_path / "encrypted.sops"
+
+ # Mock subprocess.run to succeed
+ class Result:
+ returncode = 0
+ stdout = b"encrypted data"
+ stderr = b""
+
+ def fake_run(cmd, capture_output, check):
+ return Result()
+
+ # Mock require_sops_cmd to return a fake path
+ def fake_require():
+ return "/fake/sops"
+
+ monkeypatch.setattr("enroll.sopsutil.subprocess.run", fake_run)
+ monkeypatch.setattr("enroll.sopsutil.require_sops_cmd", fake_require)
+
+ from enroll.sopsutil import encrypt_file_binary
+
+ encrypt_file_binary(src, dst, pgp_fingerprints=["ABC123"])
+
+ assert dst.exists()
+ assert dst.read_bytes() == b"encrypted data"
+
+
+def test_encrypt_file_binary_fails(monkeypatch, tmp_path: Path):
+ """Test encryption failure path."""
+ src = tmp_path / "secret.txt"
+ src.write_text("secret data", encoding="utf-8")
+ dst = tmp_path / "encrypted.sops"
+
+ class Result:
+ returncode = 1
+ stdout = b""
+ stderr = b"sops: gpg error"
+
+ def fake_run(cmd, capture_output, check):
+ return Result()
+
+ def fake_require():
+ return "/fake/sops"
+
+ monkeypatch.setattr("enroll.sopsutil.subprocess.run", fake_run)
+ monkeypatch.setattr("enroll.sopsutil.require_sops_cmd", fake_require)
+
+ from enroll.sopsutil import encrypt_file_binary, SopsError
+
+ with pytest.raises(SopsError) as exc_info:
+ encrypt_file_binary(src, dst, pgp_fingerprints=["ABC123"])
+
+ assert "encryption failed" in str(exc_info.value).lower()
+
+
+def test_encrypt_file_binary_chmod_fails(monkeypatch, tmp_path: Path):
+ """Test when chmod fails but file is still written."""
+ src = tmp_path / "secret.txt"
+ src.write_text("secret data", encoding="utf-8")
+ dst = tmp_path / "encrypted.sops"
+
+ class Result:
+ returncode = 0
+ stdout = b"encrypted data"
+ stderr = b""
+
+ def fake_run(cmd, capture_output, check):
+ return Result()
+
+ def fake_require():
+ return "/fake/sops"
+
+ def fake_chmod(path, mode):
+ raise OSError("Permission denied")
+
+ monkeypatch.setattr("enroll.sopsutil.subprocess.run", fake_run)
+ monkeypatch.setattr("enroll.sopsutil.require_sops_cmd", fake_require)
+ monkeypatch.setattr("enroll.sopsutil.os.chmod", fake_chmod)
+
+ from enroll.sopsutil import encrypt_file_binary
+
+ # Should not raise even though chmod fails
+ encrypt_file_binary(src, dst, pgp_fingerprints=["ABC123"])
+
+ assert dst.exists()
+
+
+def test_decrypt_file_binary_to_success(monkeypatch, tmp_path: Path):
+ """Test successful decryption path."""
+ src = tmp_path / "encrypted.sops"
+ src.write_bytes(b"encrypted data")
+ dst = tmp_path / "decrypted.txt"
+
+ class Result:
+ returncode = 0
+ stdout = b"decrypted data"
+ stderr = b""
+
+ def fake_run(cmd, capture_output, check):
+ return Result()
+
+ def fake_require():
+ return "/fake/sops"
+
+ monkeypatch.setattr("enroll.sopsutil.subprocess.run", fake_run)
+ monkeypatch.setattr("enroll.sopsutil.require_sops_cmd", fake_require)
+
+ from enroll.sopsutil import decrypt_file_binary_to
+
+ decrypt_file_binary_to(src, dst)
+
+ assert dst.exists()
+ assert dst.read_bytes() == b"decrypted data"
+
+
+def test_decrypt_file_binary_to_fails(monkeypatch, tmp_path: Path):
+ """Test decryption failure path."""
+ src = tmp_path / "encrypted.sops"
+ src.write_bytes(b"encrypted data")
+ dst = tmp_path / "decrypted.txt"
+
+ class Result:
+ returncode = 1
+ stdout = b""
+ stderr = b"sops: decryption failed"
+
+ def fake_run(cmd, capture_output, check):
+ return Result()
+
+ def fake_require():
+ return "/fake/sops"
+
+ monkeypatch.setattr("enroll.sopsutil.subprocess.run", fake_run)
+ monkeypatch.setattr("enroll.sopsutil.require_sops_cmd", fake_require)
+
+ from enroll.sopsutil import decrypt_file_binary_to, SopsError
+
+ with pytest.raises(SopsError) as exc_info:
+ decrypt_file_binary_to(src, dst)
+
+ assert "decryption failed" in str(exc_info.value).lower()
+
+
+def test_decrypt_file_binary_to_chmod_fails(monkeypatch, tmp_path: Path):
+ """Test when chmod fails during decryption but file is still written."""
+ src = tmp_path / "encrypted.sops"
+ src.write_bytes(b"encrypted data")
+ dst = tmp_path / "decrypted.txt"
+
+ class Result:
+ returncode = 0
+ stdout = b"decrypted data"
+ stderr = b""
+
+ def fake_run(cmd, capture_output, check):
+ return Result()
+
+ def fake_require():
+ return "/fake/sops"
+
+ def fake_chmod(path, mode):
+ raise OSError("Permission denied")
+
+ monkeypatch.setattr("enroll.sopsutil.subprocess.run", fake_run)
+ monkeypatch.setattr("enroll.sopsutil.require_sops_cmd", fake_require)
+ monkeypatch.setattr("enroll.sopsutil.os.chmod", fake_chmod)
+
+ from enroll.sopsutil import decrypt_file_binary_to
+
+ # Should not raise even though chmod fails
+ decrypt_file_binary_to(src, dst)
+
+ assert dst.exists()
diff --git a/tests/test_systemd.py b/tests/test_systemd.py
index f351159..16f8399 100644
--- a/tests/test_systemd.py
+++ b/tests/test_systemd.py
@@ -1,11 +1,10 @@
from __future__ import annotations
import pytest
+import enroll.systemd as s
def test_list_enabled_services_and_timers_filters_templates(monkeypatch):
- import enroll.systemd as s
-
def fake_run(cmd: list[str]) -> str:
if "--type=service" in cmd:
return "\n".join(
@@ -35,8 +34,6 @@ def test_list_enabled_services_and_timers_filters_templates(monkeypatch):
def test_get_unit_info_parses_fields(monkeypatch):
- import enroll.systemd as s
-
class P:
def __init__(self, rc: int, out: str, err: str = ""):
self.returncode = rc
@@ -71,8 +68,6 @@ def test_get_unit_info_parses_fields(monkeypatch):
def test_get_unit_info_raises_unit_query_error(monkeypatch):
- import enroll.systemd as s
-
class P:
def __init__(self, rc: int, out: str, err: str):
self.returncode = rc
@@ -90,8 +85,6 @@ def test_get_unit_info_raises_unit_query_error(monkeypatch):
def test_get_timer_info_parses_fields(monkeypatch):
- import enroll.systemd as s
-
class P:
def __init__(self, rc: int, out: str, err: str = ""):
self.returncode = rc
@@ -119,3 +112,210 @@ def test_get_timer_info_parses_fields(monkeypatch):
ti = s.get_timer_info("apt-daily.timer")
assert ti.trigger_unit == "apt-daily.service"
assert "/etc/default/apt" in ti.env_files
+
+
+def test_list_enabled_services_empty_output(monkeypatch):
+ def fake_run(cmd: list[str]) -> str:
+ return ""
+
+ monkeypatch.setattr(s, "_run", fake_run)
+ result = s.list_enabled_services()
+ assert result == []
+
+
+def test_list_enabled_timers_empty_output(monkeypatch):
+ def fake_run(cmd: list[str]) -> str:
+ return ""
+
+ monkeypatch.setattr(s, "_run", fake_run)
+ result = s.list_enabled_timers()
+ assert result == []
+
+
+def test_list_enabled_services_with_only_templates(monkeypatch):
+ def fake_run(cmd: list[str]) -> str:
+ return "getty@.service enabled\n"
+
+ monkeypatch.setattr(s, "_run", fake_run)
+ result = s.list_enabled_services()
+ assert result == []
+
+
+def test_list_enabled_timers_with_only_templates(monkeypatch):
+ def fake_run(cmd: list[str]) -> str:
+ return "foo@.timer enabled\n"
+
+ monkeypatch.setattr(s, "_run", fake_run)
+ result = s.list_enabled_timers()
+ assert result == []
+
+
+def test_get_timer_info_raises_on_failure(monkeypatch):
+ class P:
+ def __init__(self, rc: int, out: str, err: str):
+ self.returncode = rc
+ self.stdout = out
+ self.stderr = err
+
+ def fake_run(cmd, text, capture_output):
+ return P(1, "", "timer not found")
+
+ monkeypatch.setattr(s.subprocess, "run", fake_run)
+
+ with pytest.raises(RuntimeError) as exc_info:
+ s.get_timer_info("nonexistent.timer")
+
+ assert "nonexistent.timer" in str(exc_info.value)
+
+
+def test_get_timer_info_with_empty_fields(monkeypatch):
+ class P:
+ def __init__(self, rc: int, out: str, err: str = ""):
+ self.returncode = rc
+ self.stdout = out
+ self.stderr = err
+
+ def fake_run(cmd, text, capture_output):
+ return P(
+ 0,
+ "\n".join(
+ [
+ "FragmentPath=",
+ "DropInPaths=",
+ "EnvironmentFiles=",
+ "Unit=",
+ "ActiveState=",
+ "SubState=",
+ "UnitFileState=",
+ "ConditionResult=",
+ ]
+ ),
+ )
+
+ monkeypatch.setattr(s.subprocess, "run", fake_run)
+ ti = s.get_timer_info("empty.timer")
+ assert ti.fragment_path is None
+ assert ti.dropin_paths == []
+ assert ti.env_files == []
+ assert ti.trigger_unit is None
+ assert ti.active_state is None
+
+
+def test_get_unit_info_with_empty_fields(monkeypatch):
+ class P:
+ def __init__(self, rc: int, out: str, err: str = ""):
+ self.returncode = rc
+ self.stdout = out
+ self.stderr = err
+
+ def fake_run(cmd, check, text, capture_output):
+ return P(
+ 0,
+ "\n".join(
+ [
+ "FragmentPath=",
+ "DropInPaths=",
+ "EnvironmentFiles=",
+ "ExecStart=",
+ "ActiveState=",
+ "SubState=",
+ "UnitFileState=",
+ "ConditionResult=",
+ ]
+ ),
+ )
+
+ monkeypatch.setattr(s.subprocess, "run", fake_run)
+ ui = s.get_unit_info("empty.service")
+ assert ui.fragment_path is None
+ assert ui.dropin_paths == []
+ assert ui.env_files == []
+ assert ui.exec_paths == []
+ assert ui.active_state is None
+
+
+def test_run_command_raises_on_error(monkeypatch):
+ """Test _run raises RuntimeError on non-zero exit."""
+
+ class P:
+ returncode = 1
+ stdout = ""
+ stderr = "command failed"
+
+ def fake_run(cmd, check, text, capture_output):
+ return P()
+
+ monkeypatch.setattr(s.subprocess, "run", fake_run)
+
+ with pytest.raises(RuntimeError) as exc_info:
+ s._run(["fake", "command"])
+
+ assert "Command failed" in str(exc_info.value)
+ assert "fake" in str(exc_info.value)
+
+
+def test_list_enabled_services_filters_non_service_units(monkeypatch):
+ """Test that non-.service units are filtered out."""
+
+ def fake_run(cmd: list[str]) -> str:
+ return "\n".join(
+ [
+ "nginx.service enabled",
+ "network.target enabled", # not a service
+ "multi-user.target enabled", # not a service
+ ]
+ )
+
+ monkeypatch.setattr(s, "_run", fake_run)
+ result = s.list_enabled_services()
+ assert result == ["nginx.service"]
+
+
+def test_list_enabled_timers_filters_non_timer_units(monkeypatch):
+ """Test that non-.timer units are filtered out."""
+
+ def fake_run(cmd: list[str]) -> str:
+ return "\n".join(
+ [
+ "apt-daily.timer enabled",
+ "some.service enabled", # not a timer
+ ]
+ )
+
+ monkeypatch.setattr(s, "_run", fake_run)
+ result = s.list_enabled_timers()
+ assert result == ["apt-daily.timer"]
+
+
+def test_list_enabled_services_filters_empty_lines(monkeypatch):
+ """Test that empty lines are skipped."""
+
+ def fake_run(cmd: list[str]) -> str:
+ return "\n".join(
+ [
+ "nginx.service enabled",
+ "", # empty line
+ "ssh.service enabled",
+ ]
+ )
+
+ monkeypatch.setattr(s, "_run", fake_run)
+ result = s.list_enabled_services()
+ assert result == ["nginx.service", "ssh.service"]
+
+
+def test_list_enabled_timers_filters_empty_lines(monkeypatch):
+ """Test that empty lines are skipped."""
+
+ def fake_run(cmd: list[str]) -> str:
+ return "\n".join(
+ [
+ "apt-daily.timer enabled",
+ "", # empty line
+ "daily.timer enabled",
+ ]
+ )
+
+ monkeypatch.setattr(s, "_run", fake_run)
+ result = s.list_enabled_timers()
+ assert result == ["apt-daily.timer", "daily.timer"]
diff --git a/tests/test_validate.py b/tests/test_validate.py
new file mode 100644
index 0000000..05ee88b
--- /dev/null
+++ b/tests/test_validate.py
@@ -0,0 +1,413 @@
+from __future__ import annotations
+
+import json
+import sys
+from pathlib import Path
+
+import pytest
+
+import enroll.cli as cli
+from enroll.validate import validate_harvest
+
+
+def _base_state() -> dict:
+ return {
+ "enroll": {"version": "0.0.test", "harvest_time": 0},
+ "host": {
+ "hostname": "testhost",
+ "os": "unknown",
+ "pkg_backend": "dpkg",
+ "os_release": {},
+ },
+ "inventory": {"packages": {}},
+ "roles": {
+ "users": {
+ "role_name": "users",
+ "users": [],
+ "managed_dirs": [],
+ "managed_files": [],
+ "excluded": [],
+ "notes": [],
+ },
+ "services": [],
+ "packages": [],
+ "apt_config": {
+ "role_name": "apt_config",
+ "managed_dirs": [],
+ "managed_files": [],
+ "excluded": [],
+ "notes": [],
+ },
+ "dnf_config": {
+ "role_name": "dnf_config",
+ "managed_dirs": [],
+ "managed_files": [],
+ "excluded": [],
+ "notes": [],
+ },
+ "etc_custom": {
+ "role_name": "etc_custom",
+ "managed_dirs": [],
+ "managed_files": [],
+ "excluded": [],
+ "notes": [],
+ },
+ "usr_local_custom": {
+ "role_name": "usr_local_custom",
+ "managed_dirs": [],
+ "managed_files": [],
+ "excluded": [],
+ "notes": [],
+ },
+ "extra_paths": {
+ "role_name": "extra_paths",
+ "include_patterns": [],
+ "exclude_patterns": [],
+ "managed_dirs": [],
+ "managed_files": [],
+ "managed_links": [],
+ "excluded": [],
+ "notes": [],
+ },
+ },
+ }
+
+
+def _write_bundle(tmp_path: Path, state: dict) -> Path:
+ bundle = tmp_path / "bundle"
+ bundle.mkdir(parents=True)
+ (bundle / "artifacts").mkdir()
+ (bundle / "state.json").write_text(json.dumps(state, indent=2), encoding="utf-8")
+ return bundle
+
+
+def test_validate_ok_bundle(tmp_path: Path):
+ state = _base_state()
+ state["roles"]["etc_custom"]["managed_files"].append(
+ {
+ "path": "/etc/hosts",
+ "src_rel": "etc/hosts",
+ "owner": "root",
+ "group": "root",
+ "mode": "0644",
+ "reason": "custom_specific_path",
+ }
+ )
+
+ bundle = _write_bundle(tmp_path, state)
+ art = bundle / "artifacts" / "etc_custom" / "etc" / "hosts"
+ art.parent.mkdir(parents=True, exist_ok=True)
+ art.write_text("127.0.0.1 localhost\n", encoding="utf-8")
+
+ res = validate_harvest(str(bundle))
+ assert res.ok
+ assert res.errors == []
+
+
+def test_validate_missing_artifact_is_error(tmp_path: Path):
+ state = _base_state()
+ state["roles"]["etc_custom"]["managed_files"].append(
+ {
+ "path": "/etc/hosts",
+ "src_rel": "etc/hosts",
+ "owner": "root",
+ "group": "root",
+ "mode": "0644",
+ "reason": "custom_specific_path",
+ }
+ )
+ bundle = _write_bundle(tmp_path, state)
+ res = validate_harvest(str(bundle))
+ assert not res.ok
+ assert any("missing artifact" in e for e in res.errors)
+
+
+def test_validate_schema_error_is_reported(tmp_path: Path):
+ state = _base_state()
+ state["host"]["os"] = "not_a_real_os"
+ bundle = _write_bundle(tmp_path, state)
+ res = validate_harvest(str(bundle))
+ assert not res.ok
+ assert any(e.startswith("schema /host/os") for e in res.errors)
+
+
+def test_cli_validate_exits_1_on_validation_error(monkeypatch, tmp_path: Path):
+ state = _base_state()
+ state["roles"]["etc_custom"]["managed_files"].append(
+ {
+ "path": "/etc/hosts",
+ "src_rel": "etc/hosts",
+ "owner": "root",
+ "group": "root",
+ "mode": "0644",
+ "reason": "custom_specific_path",
+ }
+ )
+ bundle = _write_bundle(tmp_path, state)
+
+ monkeypatch.setattr(sys, "argv", ["enroll", "validate", str(bundle)])
+ with pytest.raises(SystemExit) as e:
+ cli.main()
+ assert e.value.code == 1
+
+
+def test_cli_validate_exits_1_on_validation_warning_with_flag(
+ monkeypatch, tmp_path: Path
+):
+ state = _base_state()
+ state["roles"]["etc_custom"]["managed_files"].append(
+ {
+ "path": "/etc/hosts",
+ "src_rel": "etc/hosts",
+ "owner": "root",
+ "group": "root",
+ "mode": "0644",
+ "reason": "custom_specific_path",
+ }
+ )
+
+ bundle = _write_bundle(tmp_path, state)
+ art = bundle / "artifacts" / "etc_custom" / "etc" / "hosts"
+ art.parent.mkdir(parents=True, exist_ok=True)
+ art.write_text("127.0.0.1 localhost\n", encoding="utf-8")
+
+ art2 = bundle / "artifacts" / "etc_custom" / "etc" / "hosts2"
+ art2.write_text("hello\n", encoding="utf-8")
+
+ monkeypatch.setattr(
+ sys, "argv", ["enroll", "validate", str(bundle), "--fail-on-warnings"]
+ )
+ with pytest.raises(SystemExit) as e:
+ cli.main()
+ assert e.value.code == 1
+
+
+def test_validation_result_ok():
+ from enroll.validate import ValidationResult
+
+ result = ValidationResult(errors=[], warnings=[])
+ assert result.ok is True
+ assert result.to_text() == "OK: harvest bundle validated\n"
+
+
+def test_validation_result_with_errors():
+ from enroll.validate import ValidationResult
+
+ result = ValidationResult(errors=["error1", "error2"], warnings=[])
+ assert result.ok is False
+ text = result.to_text()
+ assert "ERROR: 2 validation error(s)" in text
+ assert "error1" in text
+ assert "error2" in text
+
+
+def test_validation_result_with_warnings():
+ from enroll.validate import ValidationResult
+
+ result = ValidationResult(errors=[], warnings=["warn1"])
+ assert result.ok is True
+ text = result.to_text()
+ assert "WARN: 1 warning(s)" in text
+ assert "warn1" in text
+
+
+def test_validation_result_to_dict():
+ from enroll.validate import ValidationResult
+
+ result = ValidationResult(errors=["e1"], warnings=["w1"])
+ d = result.to_dict()
+ assert d["ok"] is False
+ assert d["errors"] == ["e1"]
+ assert d["warnings"] == ["w1"]
+
+
+def test_iter_managed_files_singleton_roles():
+ from enroll.validate import _iter_managed_files
+
+ state = {
+ "roles": {
+ "users": {"managed_files": [{"path": "/etc/passwd", "src_rel": "passwd"}]},
+ "packages": [
+ {
+ "role_name": "vim",
+ "managed_files": [{"path": "/usr/bin/vim", "src_rel": "vim"}],
+ }
+ ],
+ }
+ }
+ files = _iter_managed_files(state)
+ assert len(files) == 2
+ assert ("users", {"path": "/etc/passwd", "src_rel": "passwd"}) in files
+
+
+def test_iter_managed_files_services_role():
+ from enroll.validate import _iter_managed_files
+
+ state = {
+ "roles": {
+ "services": [
+ {
+ "role_name": "nginx",
+ "managed_files": [
+ {"path": "/etc/nginx/nginx.conf", "src_rel": "nginx.conf"}
+ ],
+ }
+ ]
+ }
+ }
+ files = _iter_managed_files(state)
+ assert len(files) == 1
+ assert files[0][0] == "nginx"
+
+
+def test_iter_managed_files_handles_non_dict_items():
+ from enroll.validate import _iter_managed_files
+
+ state = {
+ "roles": {
+ "users": {
+ "managed_files": [
+ "not_a_dict",
+ {"path": "/etc/passwd", "src_rel": "passwd"},
+ ]
+ },
+ "services": ["not_a_dict", {"role_name": "nginx", "managed_files": []}],
+ "packages": ["not_a_dict"],
+ }
+ }
+ files = _iter_managed_files(state)
+ assert len(files) == 1
+
+
+def test_iter_managed_files_empty_state():
+ from enroll.validate import _iter_managed_files
+
+ state = {"roles": {}}
+ files = _iter_managed_files(state)
+ assert files == []
+
+
+def test_validate_harvest_missing_state_json(tmp_path: Path):
+ bundle_dir = tmp_path / "bundle"
+ bundle_dir.mkdir()
+ result = validate_harvest(str(bundle_dir))
+ assert result.ok is False
+ assert any("missing state.json" in e for e in result.errors)
+
+
+def test_validate_harvest_invalid_json(tmp_path: Path):
+ bundle_dir = tmp_path / "bundle"
+ bundle_dir.mkdir()
+ state_file = bundle_dir / "state.json"
+ state_file.write_text("not valid json", encoding="utf-8")
+ result = validate_harvest(str(bundle_dir))
+ assert result.ok is False
+ assert any("failed to parse" in e for e in result.errors)
+
+
+def test_validate_harvest_schema_error(tmp_path: Path):
+ bundle_dir = tmp_path / "bundle"
+ bundle_dir.mkdir()
+ state_file = bundle_dir / "state.json"
+ state_file.write_text("{}", encoding="utf-8")
+ result = validate_harvest(
+ str(bundle_dir), schema="https://invalid.invalid/schema.json"
+ )
+ assert result.ok is False
+ assert any("failed to load/validate schema" in e for e in result.errors)
+
+
+def test_validate_harvest_missing_artifact(tmp_path: Path):
+ bundle_dir = tmp_path / "bundle"
+ bundle_dir.mkdir()
+ artifacts_dir = bundle_dir / "artifacts"
+ artifacts_dir.mkdir()
+ state = {
+ "roles": {
+ "users": {"managed_files": [{"path": "/etc/passwd", "src_rel": "passwd"}]}
+ }
+ }
+ state_file = bundle_dir / "state.json"
+ state_file.write_text(json.dumps(state), encoding="utf-8")
+ result = validate_harvest(str(bundle_dir))
+ assert result.ok is False
+ assert any("missing artifact" in e for e in result.errors)
+
+
+def test_validate_harvest_suspicious_src_rel(tmp_path: Path):
+ bundle_dir = tmp_path / "bundle"
+ bundle_dir.mkdir()
+ state = {
+ "roles": {
+ "users": {
+ "managed_files": [
+ {"path": "/etc/passwd", "src_rel": "../../../etc/passwd"}
+ ]
+ }
+ }
+ }
+ state_file = bundle_dir / "state.json"
+ state_file.write_text(json.dumps(state), encoding="utf-8")
+ result = validate_harvest(str(bundle_dir))
+ assert result.ok is False
+ assert any("suspicious src_rel" in e for e in result.errors)
+
+
+def test_validate_harvest_missing_src_rel(tmp_path: Path):
+ bundle_dir = tmp_path / "bundle"
+ bundle_dir.mkdir()
+ state = {"roles": {"users": {"managed_files": [{"path": "/etc/passwd"}]}}}
+ state_file = bundle_dir / "state.json"
+ state_file.write_text(json.dumps(state), encoding="utf-8")
+ result = validate_harvest(str(bundle_dir))
+ assert result.ok is False
+ assert any("missing src_rel" in e for e in result.errors)
+
+
+def test_validate_harvest_firewall_runtime_missing(tmp_path: Path):
+ bundle_dir = tmp_path / "bundle"
+ bundle_dir.mkdir()
+ artifacts_dir = bundle_dir / "artifacts"
+ fw_dir = artifacts_dir / "firewall_runtime"
+ fw_dir.mkdir(parents=True)
+ state = {
+ "roles": {
+ "firewall_runtime": {
+ "role_name": "firewall_runtime",
+ "iptables_v4_save": "iptables.save",
+ }
+ }
+ }
+ state_file = bundle_dir / "state.json"
+ state_file.write_text(json.dumps(state), encoding="utf-8")
+ result = validate_harvest(str(bundle_dir))
+ assert result.ok is False
+ assert any("missing firewall runtime artifact" in e for e in result.errors)
+
+
+def test_validate_harvest_firewall_runtime_suspicious(tmp_path: Path):
+ bundle_dir = tmp_path / "bundle"
+ bundle_dir.mkdir()
+ state = {
+ "roles": {
+ "firewall_runtime": {
+ "role_name": "firewall_runtime",
+ "iptables_v4_save": "../../../etc/passwd",
+ }
+ }
+ }
+ state_file = bundle_dir / "state.json"
+ state_file.write_text(json.dumps(state), encoding="utf-8")
+ result = validate_harvest(str(bundle_dir))
+ assert result.ok is False
+ assert any("suspicious src_rel" in e for e in result.errors)
+
+
+def test_validate_harvest_no_schema_option(tmp_path: Path):
+ bundle_dir = tmp_path / "bundle"
+ bundle_dir.mkdir()
+ state_file = bundle_dir / "state.json"
+ state_file.write_text("invalid json", encoding="utf-8")
+ result = validate_harvest(str(bundle_dir), no_schema=True)
+ assert result.ok is False
+ assert any("failed to parse" in e for e in result.errors)
diff --git a/tests/test_version_extra.py b/tests/test_version_extra.py
new file mode 100644
index 0000000..67c1ce4
--- /dev/null
+++ b/tests/test_version_extra.py
@@ -0,0 +1,12 @@
+from __future__ import annotations
+
+# The version module is hard to test fully because it uses importlib.metadata
+# which is difficult to mock. We'll test what we can.
+
+
+def test_get_enroll_version_returns_string():
+ from enroll.version import get_enroll_version
+
+ result = get_enroll_version()
+ assert isinstance(result, str)
+ assert len(result) > 0