Compare commits
92 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b149b2e5d7 | |||
| ebc27e1111 | |||
| e2be9a6239 | |||
| e448994470 | |||
| 845f8d9ad1 | |||
| c7e3b94355 | |||
| ee08bf43ba | |||
| ceca3df83c | |||
| 20cc48e1ce | |||
| ed9ec6893a | |||
| de7531424d | |||
| 5e6c8e6455 | |||
| 3c84b3c070 | |||
| 380a0b8ca2 | |||
| 33b9d44c55 | |||
| f9e93cd6fd | |||
| e682aae41e | |||
| 9546e1b8ed | |||
| 3c19ae54b2 | |||
| 8774d019d3 | |||
| 1e996f4a43 | |||
| e2339616fb | |||
| 00329cdd33 | |||
| 9dfbd411de | |||
| 8f425b595b | |||
| eb1d096c90 | |||
| 11351cce87 | |||
| bbfc338734 | |||
| 76df10ee92 | |||
| a0fbed5ca5 | |||
| 6c58beddfe | |||
| fbb06f1177 | |||
| 62b2f2ffe6 | |||
| bf735c8328 | |||
| 1544dc0295 | |||
| b25dd1e314 | |||
| 3fcfefe644 | |||
| 618dd20e7c | |||
| 5695f4258e | |||
| 5c686d27cc | |||
| 4ea7267b92 | |||
| d403dcb918 | |||
| 778237740a | |||
| 87ddf52e81 | |||
| 5f6b0f49d9 | |||
| 1856e3a79d | |||
| 478b0e1b9d | |||
| f5eaac9f75 | |||
| 5754ef1aad | |||
| d172d848c4 | |||
| f84d795c49 | |||
| 95b784c1a0 | |||
| ebd30247d1 | |||
| 9a249cc973 | |||
| 9749190cd8 | |||
| ca3d958a96 | |||
| 8be821c494 | |||
| 8daed96b7c | |||
| e0ef5ede98 | |||
| 025f00f924 | |||
| 66d032d981 | |||
| 45e0d9bb16 | |||
| 9f30c56e8a | |||
| 7a9a0abcd1 | |||
| aea58c8684 | |||
| ca4cf00e84 | |||
| d3fdfc9ef7 | |||
| bcf3dd7422 | |||
| 91ec1b8791 | |||
| b5e32770a3 | |||
| e04b158c39 | |||
| a1433d645f | |||
| e68ec0bffc | |||
| 24cedc8c8d | |||
| c9003d589d | |||
| 59674d4660 | |||
| 56d0148614 | |||
| 04234e296f | |||
| a2be708a31 | |||
| 9df4dc862d | |||
| fd55bcde9b | |||
| 1d3ce6191e | |||
| 626d76c755 | |||
| f82fd894ca | |||
| 9a2516d858 | |||
| 6c3275b44a | |||
| 824010b2ab | |||
| 29b52d451d | |||
| c88405ef01 | |||
| 781efef467 | |||
| 09438246ae | |||
| e4887b7add |
101 changed files with 24613 additions and 3732 deletions
|
|
@ -21,6 +21,7 @@ jobs:
|
|||
python3-poetry-core \
|
||||
python3-yaml \
|
||||
python3-paramiko \
|
||||
python3-jsonschema \
|
||||
rsync \
|
||||
ca-certificates
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ 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 \
|
||||
puppet hiera
|
||||
|
||||
- name: Install Poetry
|
||||
run: |
|
||||
|
|
@ -27,6 +28,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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -8,3 +8,4 @@ dist
|
|||
*.pdf
|
||||
*.csv
|
||||
*.html
|
||||
coverage.xml
|
||||
|
|
|
|||
76
CHANGELOG.md
76
CHANGELOG.md
|
|
@ -1,3 +1,75 @@
|
|||
# 0.7.0
|
||||
|
||||
* Add support for detecting flatpaks and snaps
|
||||
* BREAKING CHANGE: Group all package and systemd-unit roles into Debian Section/RPM Group roles by default, including managed config files and unit state. This mode is not used if `--fqdn` or `--no-common-roles` is set, in which case, the traditional behaviour of preserving one role per package/unit is used instead.
|
||||
* BREAKING CHANGE: Only capture user-specific .bashrc style files when using `--dangerous` mode, in case they contain sensitive env vars.
|
||||
* Detect active sysctl parameters and write them to a `/etc/sysctl.d/99-enroll.conf` file
|
||||
* Use `no_log` on systemd unit interrogations to suppress potential sensitive output when applying Ansible
|
||||
* Support manifesting Puppet code, as well as Ansible!
|
||||
* A lot of under-the-bonnet refactoring to make it easier to extend to cover other config managers (that don't suck) in future.
|
||||
* Support for detecting Docker images
|
||||
|
||||
# 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.
|
||||
* Set pty for remote calls when sudo is required, to help systems with limits on sudo without pty
|
||||
|
||||
# 0.2.1
|
||||
|
||||
* 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
|
||||
|
||||
* Add version CLI arg
|
||||
|
|
@ -16,8 +88,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
|
||||
|
||||
|
|
|
|||
5
CONTRIBUTORS.md
Normal file
5
CONTRIBUTORS.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
## Contributors
|
||||
|
||||
mig5 would like to thank the following people for their contributions to Enroll.
|
||||
|
||||
* [slhck](https://slhck.info/)
|
||||
|
|
@ -26,6 +26,7 @@ RUN set -eux; \
|
|||
python3-poetry-core \
|
||||
python3-yaml \
|
||||
python3-paramiko \
|
||||
python3-jsonschema \
|
||||
rsync \
|
||||
ca-certificates \
|
||||
; \
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
# syntax=docker/dockerfile:1
|
||||
FROM fedora:42
|
||||
ARG BASE_IMAGE=fedora:42
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
RUN set -eux; \
|
||||
dnf -y update; \
|
||||
|
|
@ -21,6 +22,7 @@ RUN set -eux; \
|
|||
python3-rpm-macros \
|
||||
python3-yaml \
|
||||
python3-paramiko \
|
||||
python3-jsonschema \
|
||||
openssl-devel \
|
||||
python3-poetry-core ; \
|
||||
dnf -y clean all
|
||||
|
|
@ -33,24 +35,8 @@ set -euo pipefail
|
|||
SRC="${SRC:-/src}"
|
||||
WORKROOT="${WORKROOT:-/work}"
|
||||
OUT="${OUT:-/out}"
|
||||
DEPS_DIR="${DEPS_DIR:-/deps}"
|
||||
|
||||
# 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)-)')
|
||||
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 <dir>:/deps" >&2
|
||||
fi
|
||||
VERSION_ID="$(grep VERSION_ID /etc/os-release | cut -d= -f2)"
|
||||
echo "Version ID is ${VERSION_ID}"
|
||||
|
||||
mkdir -p "${WORKROOT}" "${OUT}"
|
||||
WORK="${WORKROOT}/src"
|
||||
|
|
|
|||
280
README.md
280
README.md
|
|
@ -4,15 +4,18 @@
|
|||
<img src="https://git.mig5.net/mig5/enroll/raw/branch/main/enroll.svg" alt="Enroll logo" width="240" />
|
||||
</div>
|
||||
|
||||
**enroll** inspects a Linux machine (Debian-like or RedHat-like) and generates Ansible roles/playbooks (and optionally inventory) for what it finds.
|
||||
**enroll** inspects a Linux machine (Debian-like or RedHat-like) and generates configuration-management code: Ansible roles/playbooks by default, or Puppet control-repo style output for what it finds.
|
||||
|
||||
- Detects packages that have been installed.
|
||||
- Detects package ownership of `/etc` files where possible
|
||||
- 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/<service>/...` (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. In `--dangerous` mode, it also auto-harvests common shell dotfiles such as `.bashrc`, `.profile`, `.bash_logout`, and `.bash_aliases` when appropriate.
|
||||
- Captures miscellaneous `/etc` files it can't attribute to a package and installs them in an `etc_custom` role.
|
||||
- When running as root/sudo, captures live writable sysctl state into a `sysctl` role that manages `/etc/sysctl.d/99-enroll.conf`.
|
||||
- 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.
|
||||
|
||||
|
|
@ -23,7 +26,7 @@
|
|||
`enroll` works in two phases:
|
||||
|
||||
1) **Harvest**: collect host facts + relevant files into a harvest bundle (`state.json` + harvested artifacts)
|
||||
2) **Manifest**: turn that harvest into Ansible roles/playbooks (and optionally inventory)
|
||||
2) **Manifest**: turn that harvest into configuration-management code such as Ansible roles/playbooks or Puppet manifests
|
||||
|
||||
Additionally, some other functionalities exist:
|
||||
|
||||
|
|
@ -34,7 +37,7 @@ Additionally, some other functionalities exist:
|
|||
|
||||
## Output modes: single-site vs multi-site (`--fqdn`)
|
||||
|
||||
`enroll manifest` (and `enroll single-shot`) support two distinct output styles.
|
||||
`enroll manifest` (and `enroll single-shot`) support multiple output targets. Ansible is the default target and supports two distinct output styles.
|
||||
|
||||
### Single-site mode (default: *no* `--fqdn`)
|
||||
Use when enrolling **one server** (or generating a “golden” role set you intend to reuse).
|
||||
|
|
@ -68,12 +71,16 @@ Harvest state about a host and write a harvest bundle.
|
|||
- “Manual” packages
|
||||
- Changed-from-default config (plus related custom/unowned files under service dirs)
|
||||
- Non-system users + SSH public keys
|
||||
- In `--dangerous` mode: common per-user shell dotfiles that are likely to represent deliberate account customisation
|
||||
- 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 writable sysctl state via `sysctl -a`, emitted as `/etc/sysctl.d/99-enroll.conf` at manifest time when running as root/sudo (`sysctl` role)
|
||||
- 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,22 +95,57 @@ 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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `enroll manifest`
|
||||
Generate Ansible output from an existing harvest bundle.
|
||||
Generate configuration-management output from an existing harvest bundle. Ansible remains the default; use `--target puppet` for Puppet output.
|
||||
|
||||
**Inputs**
|
||||
- `--harvest /path/to/harvest` (directory)
|
||||
or `--harvest /path/to/harvest.tar.gz.sops` (if using `--sops`)
|
||||
|
||||
**Output**
|
||||
- In plaintext mode: an Ansible repo-like directory structure (roles/playbooks, and inventory in multi-site mode).
|
||||
- In plaintext Ansible mode: an Ansible repo-like directory structure (roles/playbooks, and inventory in multi-site mode).
|
||||
- In plaintext Puppet mode: a Puppet control-repo style layout with `manifests/site.pp` and generated modules under `modules/`. By default, package and service resources are grouped by Debian Section/RPM Group where possible; `--fqdn` or `--no-common-roles` preserves one generated module per Enroll role/snapshot.
|
||||
- In `--sops` mode: a single encrypted file `manifest.tar.gz.sops` containing the generated output.
|
||||
|
||||
**Common flags**
|
||||
- `--fqdn <host>`: enables **multi-site** output style
|
||||
- `--target ansible|puppet`: choose the manifest target (`ansible` is the default).
|
||||
- `--fqdn <host>`: enables **multi-site** output style for Ansible or emits Puppet Hiera/node output. Without `--fqdn`, Puppet emits `node default { ... }`.
|
||||
- `--no-common-roles`: disables the default grouping of package and systemd-unit roles into Debian Section/RPM Group roles, preserving one generated role per package/unit. `--fqdn` implies this behaviour.
|
||||
|
||||
**Role tags**
|
||||
Generated playbooks tag each role so you can target just the parts you need:
|
||||
|
||||
- Tag format: `role_<role_name>` (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
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -112,7 +154,7 @@ Convenience wrapper that runs **harvest → manifest** in one command.
|
|||
|
||||
Use this when you want “get me something workable ASAP”.
|
||||
|
||||
Supports the same general flags as harvest/manifest, including `--fqdn`, remote harvest flags, and `--sops`.
|
||||
Supports the same general flags as harvest/manifest, including `--target`, `--fqdn`, `--no-common-roles`, remote harvest flags, and `--sops`.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -128,6 +170,26 @@ Compare two harvest bundles and report what changed.
|
|||
**Inputs**
|
||||
- `--old <harvest>` and `--new <harvest>` (directories or `state.json` paths)
|
||||
- `--sops` when comparing SOPS-encrypted harvest bundles
|
||||
- `--exclude-path <PATTERN>` (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 <tmp>/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,10 +205,78 @@ 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/<role_name>/<src_rel>`
|
||||
* 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.
|
||||
|
||||
Automatic harvesting of per-user shell dotfiles is also disabled by default, even when those files differ from `/etc/skel`, because `.bashrc`, `.profile`, `.bash_aliases`, and similar files commonly contain exported tokens, credentials, or aliases/functions with embedded secrets. Use `--dangerous` for automatic shell-dotfile capture, or use targeted `--include-path` patterns for narrower safe-mode review.
|
||||
|
||||
If you opt in to collecting everything:
|
||||
|
||||
### `--dangerous`
|
||||
|
|
@ -191,7 +321,7 @@ sudo apt update
|
|||
sudo apt install enroll
|
||||
```
|
||||
|
||||
### Fedora 42
|
||||
## Fedora
|
||||
|
||||
```bash
|
||||
sudo rpm --import https://mig5.net/static/mig5.asc
|
||||
|
|
@ -199,7 +329,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/$basearch
|
||||
baseurl=https://rpm.mig5.net/$releasever/rpm/$basearch
|
||||
enabled=1
|
||||
gpgcheck=1
|
||||
repo_gpgcheck=1
|
||||
|
|
@ -255,6 +385,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)
|
||||
|
|
@ -302,6 +440,33 @@ enroll manifest --harvest /tmp/enroll-harvest --out /tmp/enroll-ansible
|
|||
enroll manifest --harvest /tmp/enroll-harvest --out /tmp/enroll-ansible --fqdn "$(hostname -f)"
|
||||
```
|
||||
|
||||
|
||||
### Container image caches
|
||||
|
||||
If Docker or Podman is available during harvest, Enroll records local image-cache metadata from `image ls` and `image inspect`. Images that expose registry `RepoDigest` values are reproducible by digest, for example `registry.example.net/app@sha256:...`; those are the references rendered into manifests. Local image IDs and tag-only images are preserved as evidence and notes, but are not treated as exact registry pull references.
|
||||
|
||||
For Ansible, digest-pinned Docker images are pulled with `community.docker.docker_image_pull` and digest-pinned Podman images are pulled with `containers.podman.podman_image`; harvested tag aliases are re-applied where possible. The generated `requirements.yml` includes `community.docker` and `containers.podman` alongside any other required collections. In `--fqdn` mode the image list is host-specific inventory data.
|
||||
|
||||
### Puppet target
|
||||
```bash
|
||||
enroll manifest --harvest /tmp/enroll-harvest --out /tmp/enroll-puppet --target puppet
|
||||
```
|
||||
|
||||
The Puppet target renders native packages, users/groups, managed directories/files/symlinks, basic service state, and the generated sysctl file/apply exec when present. Without `--fqdn`, `site.pp` uses `node default { ... }`; with `--fqdn`, it uses `node '<host>' { ... }`. Run from the generated output directory with the generated modules on Puppet's module path, for example:
|
||||
|
||||
```bash
|
||||
cd /tmp/enroll-puppet
|
||||
sudo puppet apply --modulepath ./modules manifests/site.pp --noop
|
||||
```
|
||||
|
||||
Or with absolute paths:
|
||||
|
||||
```bash
|
||||
sudo puppet apply --modulepath /tmp/enroll-puppet/modules /tmp/enroll-puppet/manifests/site.pp --noop
|
||||
```
|
||||
|
||||
Docker images with registry digests are rendered as `docker::image` resources and require the Puppet environment to provide `puppetlabs-docker`; the generated module metadata records that dependency. Podman images with registry digests are rendered as guarded `podman pull` / `podman tag` exec resources. Images without `RepoDigest` are recorded in harvest state and notes, but are not converted into exact pull resources. Flatpak, Snap, and live firewall runtime snapshots are listed as notes in the generated Puppet README rather than converted into Puppet resources.
|
||||
|
||||
### Manifest with `--sops`
|
||||
```bash
|
||||
# Generate encrypted manifest bundle (writes /tmp/enroll-ansible/manifest.tar.gz.sops)
|
||||
|
|
@ -330,7 +495,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 +507,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 +598,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_<name>` (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 +651,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.
|
||||
|
|
|
|||
78
debian/changelog
vendored
78
debian/changelog
vendored
|
|
@ -1,3 +1,81 @@
|
|||
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 <mig@mig5.net> Thu, 14 May 2026 15:00 +1000
|
||||
|
||||
enroll (0.5.0) unstable; urgency=medium
|
||||
|
||||
* Add ssh config support where JinjaTurtle is used
|
||||
|
||||
-- Miguel Jacq <mig@mig5.net> 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 <mig@mig5.net> 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 <mig@mig5.net> 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 <mig@mig5.net> 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 <mig@mig5.net> 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 <mig@mig5.net> 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 <mig@mig5.net> 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 <mig@mig5.net> 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.
|
||||
* Set pty for remote calls when sudo is required, to help systems with limits on sudo without pty
|
||||
|
||||
-- Miguel Jacq <mig@mig5.net> Sat, 03 Jan 2026 09:56:00 +1100
|
||||
|
||||
enroll (0.2.1) unstable; urgency=medium
|
||||
|
||||
* 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
|
||||
|
||||
-- Miguel Jacq <mig@mig5.net> Fri, 02 Jan 2026 21:30:00 +1100
|
||||
|
||||
enroll (0.2.0) unstable; urgency=medium
|
||||
|
||||
* Add version CLI arg
|
||||
|
|
|
|||
5
debian/control
vendored
5
debian/control
vendored
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -1,8 +1,48 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import configparser
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List, Set, Tuple
|
||||
import re
|
||||
import shutil
|
||||
import subprocess # nosec
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional, Set, Tuple
|
||||
|
||||
|
||||
@dataclass
|
||||
class FlatpakInstall:
|
||||
name: str
|
||||
method: str
|
||||
remote: Optional[str] = None
|
||||
branch: Optional[str] = None
|
||||
arch: Optional[str] = None
|
||||
kind: Optional[str] = None
|
||||
ref: Optional[str] = None
|
||||
user: Optional[str] = None
|
||||
home: Optional[str] = None
|
||||
source: str = "filesystem"
|
||||
|
||||
|
||||
@dataclass
|
||||
class FlatpakRemote:
|
||||
name: str
|
||||
method: str
|
||||
url: str
|
||||
user: Optional[str] = None
|
||||
home: Optional[str] = None
|
||||
source: str = "filesystem"
|
||||
|
||||
|
||||
@dataclass
|
||||
class SnapInstall:
|
||||
name: str
|
||||
channel: Optional[str] = None
|
||||
revision: Optional[int] = None
|
||||
classic: bool = False
|
||||
devmode: bool = False
|
||||
dangerous: bool = False
|
||||
notes: List[str] = field(default_factory=list)
|
||||
source: str = "snap-list"
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -16,6 +56,7 @@ class UserRecord:
|
|||
primary_group: str
|
||||
supplementary_groups: List[str]
|
||||
ssh_files: List[str]
|
||||
flatpaks: List[FlatpakInstall] = field(default_factory=list)
|
||||
|
||||
|
||||
def parse_login_defs(path: str = "/etc/login.defs") -> Dict[str, int]:
|
||||
|
|
@ -115,6 +156,612 @@ def find_user_ssh_files(home: str) -> List[str]:
|
|||
return sorted(set(out))
|
||||
|
||||
|
||||
def _read_first_existing_text(paths: List[str]) -> Optional[str]:
|
||||
for path in paths:
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8", errors="replace") as f:
|
||||
value = f.read().strip()
|
||||
if value:
|
||||
return value
|
||||
except OSError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _parse_flatpak_ref(
|
||||
ref: str,
|
||||
) -> Tuple[Optional[str], str, Optional[str], Optional[str]]:
|
||||
"""Return (kind, name, arch, branch) for a Flatpak ref.
|
||||
|
||||
refs look like app/org.example.App/x86_64/stable or
|
||||
runtime/org.example.Platform/x86_64/23.08. If the value is already just an
|
||||
application/runtime ID, keep it as the name and leave the other fields empty.
|
||||
"""
|
||||
parts = [p for p in (ref or "").strip().split("/") if p]
|
||||
if len(parts) >= 4 and parts[0] in {"app", "runtime"}:
|
||||
return parts[0], parts[1], parts[2], parts[3]
|
||||
return None, (ref or "").strip(), None, None
|
||||
|
||||
|
||||
def _parse_plain_flatpak_list_output(
|
||||
output: str,
|
||||
*,
|
||||
method: str,
|
||||
user: Optional[str] = None,
|
||||
home: Optional[str] = None,
|
||||
) -> List[FlatpakInstall]:
|
||||
"""Parse default `flatpak list` table output.
|
||||
|
||||
Example:
|
||||
Name Application ID Version Branch Installation
|
||||
OnionShare org.onionshare.OnionShare 2.6.4 stable system
|
||||
"""
|
||||
out: List[FlatpakInstall] = []
|
||||
seen: Set[
|
||||
Tuple[str, Optional[str], Optional[str], Optional[str], Optional[str]]
|
||||
] = set()
|
||||
id_re = re.compile(r"\b(?:[A-Za-z0-9_-]+\.)+[A-Za-z0-9_-]+\b")
|
||||
for line in output.splitlines():
|
||||
line = line.rstrip()
|
||||
if not line.strip():
|
||||
continue
|
||||
if "Application ID" in line and "Installation" in line:
|
||||
continue
|
||||
match = id_re.search(line)
|
||||
if not match:
|
||||
continue
|
||||
name = match.group(0)
|
||||
tail = line[match.end() :].split()
|
||||
installation = tail[-1] if tail else ""
|
||||
if installation in {"system", "user"} and installation != method:
|
||||
continue
|
||||
branch = None
|
||||
if len(tail) >= 2 and tail[-1] in {"system", "user"}:
|
||||
branch = tail[-2]
|
||||
elif tail:
|
||||
branch = tail[-1]
|
||||
|
||||
key = (name, None, branch, None, None)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append(
|
||||
FlatpakInstall(
|
||||
name=name,
|
||||
method=method,
|
||||
remote=None,
|
||||
branch=branch,
|
||||
arch=None,
|
||||
kind=None,
|
||||
ref=None,
|
||||
user=user,
|
||||
home=home,
|
||||
source="flatpak-list",
|
||||
)
|
||||
)
|
||||
return sorted(out, key=lambda f: (f.name, f.branch or ""))
|
||||
|
||||
|
||||
def _parse_flatpak_list_output(
|
||||
output: str,
|
||||
*,
|
||||
method: str,
|
||||
columns: Optional[Tuple[str, ...]] = None,
|
||||
user: Optional[str] = None,
|
||||
home: Optional[str] = None,
|
||||
) -> List[FlatpakInstall]:
|
||||
"""Parse Flatpak list output.
|
||||
|
||||
If columns is None, parse the default table. Otherwise columns names must
|
||||
match the order passed to `flatpak list --columns=...`.
|
||||
"""
|
||||
if columns is None:
|
||||
return _parse_plain_flatpak_list_output(
|
||||
output, method=method, user=user, home=home
|
||||
)
|
||||
|
||||
out: List[FlatpakInstall] = []
|
||||
seen: Set[
|
||||
Tuple[str, Optional[str], Optional[str], Optional[str], Optional[str]]
|
||||
] = set()
|
||||
for line in output.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
lower = line.lower()
|
||||
if lower.startswith("ref") or lower.startswith("application id"):
|
||||
continue
|
||||
|
||||
parts = line.split("\t")
|
||||
if len(parts) < len(columns):
|
||||
parts = line.split()
|
||||
if not parts:
|
||||
continue
|
||||
|
||||
fields = {
|
||||
name: parts[idx].strip()
|
||||
for idx, name in enumerate(columns)
|
||||
if idx < len(parts)
|
||||
}
|
||||
ref = fields.get("ref") or fields.get("application") or ""
|
||||
kind, name, ref_arch, ref_branch = _parse_flatpak_ref(ref)
|
||||
if not name:
|
||||
continue
|
||||
|
||||
remote = fields.get("origin") or None
|
||||
branch = fields.get("branch") or ref_branch
|
||||
arch = fields.get("arch") or ref_arch
|
||||
if remote in {"", "-"}:
|
||||
remote = None
|
||||
if branch in {"", "-"}:
|
||||
branch = None
|
||||
if arch in {"", "-"}:
|
||||
arch = None
|
||||
|
||||
key = (name, remote, branch, arch, kind)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append(
|
||||
FlatpakInstall(
|
||||
name=name,
|
||||
method=method,
|
||||
remote=remote,
|
||||
branch=branch,
|
||||
arch=arch,
|
||||
kind=kind,
|
||||
ref=ref if "/" in ref else None,
|
||||
user=user,
|
||||
home=home,
|
||||
source="flatpak-list",
|
||||
)
|
||||
)
|
||||
return sorted(
|
||||
out,
|
||||
key=lambda f: (
|
||||
f.kind or "",
|
||||
f.name,
|
||||
f.remote or "",
|
||||
f.branch or "",
|
||||
f.arch or "",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
_KNOWN_FLATPAK_LIST_COLUMNS = {
|
||||
"name",
|
||||
"description",
|
||||
"application",
|
||||
"version",
|
||||
"branch",
|
||||
"arch",
|
||||
"origin",
|
||||
"installation",
|
||||
"ref",
|
||||
"active",
|
||||
"latest",
|
||||
"size",
|
||||
"options",
|
||||
}
|
||||
|
||||
|
||||
def _parse_flatpak_columns_help(output: str) -> Set[str]:
|
||||
"""Parse `flatpak list --columns=help` output into supported fields."""
|
||||
supported: Set[str] = set()
|
||||
for line in output.splitlines():
|
||||
# Help output varies a bit between Flatpak versions. Treat any known
|
||||
# token as a supported field, whether it appears alone or in a
|
||||
# description table.
|
||||
for token in re.findall(r"[A-Za-z_][A-Za-z0-9_-]*", line.lower()):
|
||||
if token in _KNOWN_FLATPAK_LIST_COLUMNS:
|
||||
supported.add(token)
|
||||
return supported
|
||||
|
||||
|
||||
def _run_flatpak_columns_help() -> Optional[Set[str]]:
|
||||
if shutil.which("flatpak") is None:
|
||||
return None
|
||||
try:
|
||||
proc = subprocess.run( # nosec
|
||||
["flatpak", "list", "--columns=help"],
|
||||
shell=False,
|
||||
check=False,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
if proc.returncode != 0:
|
||||
return None
|
||||
supported = _parse_flatpak_columns_help(proc.stdout or "")
|
||||
return supported or None
|
||||
|
||||
|
||||
def _flatpak_list_attempts(
|
||||
scope: str, supported: Optional[Set[str]]
|
||||
) -> List[Tuple[List[str], Optional[Tuple[str, ...]]]]:
|
||||
def supported_columns(*wanted: str) -> Optional[Tuple[str, ...]]:
|
||||
if supported is not None and not set(wanted).issubset(supported):
|
||||
return None
|
||||
return tuple(wanted)
|
||||
|
||||
column_sets: List[Tuple[str, ...]] = []
|
||||
for wanted in (
|
||||
("application", "origin", "branch", "arch"),
|
||||
("application", "branch", "arch"),
|
||||
("application", "branch"),
|
||||
("application",),
|
||||
("ref", "origin", "branch", "arch"),
|
||||
("ref", "branch", "arch"),
|
||||
("ref", "branch"),
|
||||
("ref",),
|
||||
):
|
||||
cols = supported_columns(*wanted)
|
||||
if cols is not None and cols not in column_sets:
|
||||
column_sets.append(cols)
|
||||
|
||||
attempts: List[Tuple[List[str], Optional[Tuple[str, ...]]]] = [
|
||||
(
|
||||
["flatpak", "list", scope, "--columns=" + ",".join(cols)],
|
||||
cols,
|
||||
)
|
||||
for cols in column_sets
|
||||
]
|
||||
attempts.append((["flatpak", "list", scope], None))
|
||||
return attempts
|
||||
|
||||
|
||||
def _run_flatpak_list(method: str) -> Optional[Tuple[str, Optional[Tuple[str, ...]]]]:
|
||||
if shutil.which("flatpak") is None:
|
||||
return None
|
||||
|
||||
scope = "--system" if method == "system" else "--user"
|
||||
supported = _run_flatpak_columns_help()
|
||||
for args, columns in _flatpak_list_attempts(scope, supported):
|
||||
try:
|
||||
proc = subprocess.run( # nosec
|
||||
args,
|
||||
shell=False,
|
||||
check=False,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
except Exception: # nosec B112
|
||||
continue
|
||||
if proc.returncode == 0:
|
||||
return proc.stdout or "", columns
|
||||
return None
|
||||
|
||||
|
||||
def _flatpak_remote_from_ref(
|
||||
flatpak_root: str, app_id: str, arch: str, branch: str, remote_names: List[str]
|
||||
) -> Optional[str]:
|
||||
for remote_name in remote_names:
|
||||
ref = os.path.join(
|
||||
flatpak_root,
|
||||
"repo",
|
||||
"refs",
|
||||
"remotes",
|
||||
remote_name,
|
||||
"app",
|
||||
app_id,
|
||||
arch,
|
||||
branch,
|
||||
)
|
||||
if os.path.exists(ref):
|
||||
return remote_name
|
||||
return None
|
||||
|
||||
|
||||
def _parse_flatpak_deploy_origin(branch_dir: str) -> Optional[str]:
|
||||
active_dir = os.path.join(branch_dir, "active")
|
||||
candidates = [
|
||||
os.path.join(active_dir, "origin"),
|
||||
os.path.join(active_dir, "metadata"),
|
||||
]
|
||||
|
||||
origin = _read_first_existing_text([candidates[0]])
|
||||
if origin:
|
||||
return origin
|
||||
|
||||
metadata = candidates[1]
|
||||
if os.path.isfile(metadata):
|
||||
parser = configparser.ConfigParser(interpolation=None)
|
||||
try:
|
||||
parser.read(metadata, encoding="utf-8")
|
||||
except Exception:
|
||||
return None
|
||||
for section in ("Application", "Runtime"):
|
||||
if parser.has_option(section, "origin"):
|
||||
value = parser.get(section, "origin", fallback="").strip()
|
||||
if value:
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _find_flatpaks_in_root(
|
||||
flatpak_root: str,
|
||||
*,
|
||||
method: str,
|
||||
user: Optional[str] = None,
|
||||
home: Optional[str] = None,
|
||||
) -> List[FlatpakInstall]:
|
||||
apps_dir = os.path.join(flatpak_root, "app")
|
||||
if not os.path.isdir(apps_dir):
|
||||
return []
|
||||
|
||||
remote_names = [
|
||||
r.name
|
||||
for r in find_flatpak_remotes(flatpak_root, method=method, user=user, home=home)
|
||||
]
|
||||
out: List[FlatpakInstall] = []
|
||||
|
||||
try:
|
||||
app_ids = sorted(os.listdir(apps_dir))
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
seen: Set[Tuple[str, Optional[str], Optional[str], Optional[str]]] = set()
|
||||
for app_id in app_ids:
|
||||
app_path = os.path.join(apps_dir, app_id)
|
||||
if not os.path.isdir(app_path):
|
||||
continue
|
||||
try:
|
||||
arches = sorted(os.listdir(app_path))
|
||||
except OSError:
|
||||
continue
|
||||
for arch in arches:
|
||||
arch_path = os.path.join(app_path, arch)
|
||||
if not os.path.isdir(arch_path):
|
||||
continue
|
||||
try:
|
||||
branches = sorted(os.listdir(arch_path))
|
||||
except OSError:
|
||||
continue
|
||||
for branch in branches:
|
||||
branch_path = os.path.join(arch_path, branch)
|
||||
if not os.path.isdir(branch_path):
|
||||
continue
|
||||
active_dir = os.path.join(branch_path, "active")
|
||||
if not os.path.exists(active_dir):
|
||||
continue
|
||||
remote = _parse_flatpak_deploy_origin(branch_path)
|
||||
if not remote:
|
||||
remote = _flatpak_remote_from_ref(
|
||||
flatpak_root, app_id, arch, branch, remote_names
|
||||
)
|
||||
key = (app_id, remote, branch, arch)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
out.append(
|
||||
FlatpakInstall(
|
||||
name=app_id,
|
||||
method=method,
|
||||
remote=remote,
|
||||
branch=branch or None,
|
||||
arch=arch or None,
|
||||
kind="app",
|
||||
ref=f"app/{app_id}/{arch}/{branch}",
|
||||
user=user,
|
||||
home=home,
|
||||
)
|
||||
)
|
||||
|
||||
return sorted(
|
||||
out, key=lambda f: (f.name, f.remote or "", f.branch or "", f.arch or "")
|
||||
)
|
||||
|
||||
|
||||
def find_flatpak_remotes(
|
||||
flatpak_root: str,
|
||||
*,
|
||||
method: str,
|
||||
user: Optional[str] = None,
|
||||
home: Optional[str] = None,
|
||||
) -> List[FlatpakRemote]:
|
||||
"""Return configured Flatpak remotes for a Flatpak installation root.
|
||||
|
||||
Flatpak stores remotes in the OSTree repo config. This gives us the remote
|
||||
names and repository URLs. It does not reliably preserve the original
|
||||
.flatpakref/.flatpakrepo URL that was used during installation.
|
||||
"""
|
||||
config_path = os.path.join(flatpak_root, "repo", "config")
|
||||
if not os.path.isfile(config_path):
|
||||
return []
|
||||
|
||||
parser = configparser.ConfigParser(interpolation=None, strict=False)
|
||||
try:
|
||||
parser.read(config_path, encoding="utf-8")
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
out: List[FlatpakRemote] = []
|
||||
for section in parser.sections():
|
||||
match = re.fullmatch(r'remote\s+"(.+)"', section)
|
||||
if not match:
|
||||
continue
|
||||
name = match.group(1).strip()
|
||||
url = parser.get(section, "url", fallback="").strip()
|
||||
if not name or not url:
|
||||
continue
|
||||
out.append(
|
||||
FlatpakRemote(
|
||||
name=name,
|
||||
method=method,
|
||||
url=url,
|
||||
user=user,
|
||||
home=home,
|
||||
)
|
||||
)
|
||||
|
||||
return sorted(out, key=lambda r: (r.method, r.user or "", r.name))
|
||||
|
||||
|
||||
def find_user_flatpaks(home: str, user: Optional[str] = None) -> List[FlatpakInstall]:
|
||||
"""Return per-user Flatpak applications installed under a home directory."""
|
||||
flatpak_root = os.path.join(home, ".local", "share", "flatpak")
|
||||
return _find_flatpaks_in_root(flatpak_root, method="user", user=user, home=home)
|
||||
|
||||
|
||||
def find_user_flatpak_remotes(
|
||||
home: str, user: Optional[str] = None
|
||||
) -> List[FlatpakRemote]:
|
||||
flatpak_root = os.path.join(home, ".local", "share", "flatpak")
|
||||
return find_flatpak_remotes(flatpak_root, method="user", user=user, home=home)
|
||||
|
||||
|
||||
def find_system_flatpaks() -> List[FlatpakInstall]:
|
||||
"""Return Flatpak refs installed system-wide.
|
||||
|
||||
Prefer `flatpak list --system` because it is Flatpak's own view of
|
||||
installed refs and includes layouts the filesystem scanner might miss.
|
||||
Fall back to the on-disk app deployment tree when the command is
|
||||
unavailable or produces unparsable output.
|
||||
"""
|
||||
listing = _run_flatpak_list("system")
|
||||
if listing is not None:
|
||||
output, columns = listing
|
||||
parsed = _parse_flatpak_list_output(output, method="system", columns=columns)
|
||||
if parsed or not output.strip():
|
||||
return parsed
|
||||
return _find_flatpaks_in_root("/var/lib/flatpak", method="system")
|
||||
|
||||
|
||||
def find_system_flatpak_remotes() -> List[FlatpakRemote]:
|
||||
return find_flatpak_remotes("/var/lib/flatpak", method="system")
|
||||
|
||||
|
||||
def _parse_snap_notes(notes: str) -> List[str]:
|
||||
if not notes or notes == "-":
|
||||
return []
|
||||
cleaned = notes.replace(",", " ").replace(";", " ")
|
||||
return sorted(
|
||||
{n.strip().lower() for n in cleaned.split() if n.strip() and n.strip() != "-"}
|
||||
)
|
||||
|
||||
|
||||
def _parse_snap_list_output(output: str) -> List[SnapInstall]:
|
||||
out: List[SnapInstall] = []
|
||||
for idx, line in enumerate(output.splitlines()):
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
if idx == 0 and line.lower().startswith("name"):
|
||||
continue
|
||||
parts = line.split(maxsplit=5)
|
||||
if len(parts) < 5:
|
||||
continue
|
||||
name = parts[0]
|
||||
revision: Optional[int]
|
||||
try:
|
||||
revision = int(parts[2])
|
||||
except ValueError:
|
||||
revision = None
|
||||
tracking = parts[3]
|
||||
channel = None if tracking in {"-", ""} else tracking
|
||||
notes = _parse_snap_notes(parts[5] if len(parts) > 5 else "")
|
||||
out.append(
|
||||
SnapInstall(
|
||||
name=name,
|
||||
channel=channel,
|
||||
revision=revision,
|
||||
classic="classic" in notes,
|
||||
devmode="devmode" in notes,
|
||||
dangerous="dangerous" in notes,
|
||||
notes=notes,
|
||||
source="snap-list",
|
||||
)
|
||||
)
|
||||
return sorted(out, key=lambda s: s.name)
|
||||
|
||||
|
||||
def _run_snap_list() -> Optional[str]:
|
||||
if shutil.which("snap") is None:
|
||||
return None
|
||||
try:
|
||||
proc = subprocess.run( # nosec
|
||||
["snap", "list"],
|
||||
shell=False,
|
||||
check=False,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
if proc.returncode != 0:
|
||||
return None
|
||||
return proc.stdout or ""
|
||||
|
||||
|
||||
def _find_system_snaps_from_filesystem() -> List[SnapInstall]:
|
||||
snapd_snaps = "/var/lib/snapd/snaps"
|
||||
if not os.path.isdir(snapd_snaps):
|
||||
return []
|
||||
|
||||
current_revisions: Dict[str, int] = {}
|
||||
snap_mounts = "/snap"
|
||||
if os.path.isdir(snap_mounts):
|
||||
try:
|
||||
mount_names = os.listdir(snap_mounts)
|
||||
except OSError:
|
||||
mount_names = []
|
||||
for name in mount_names:
|
||||
current = os.path.join(snap_mounts, name, "current")
|
||||
try:
|
||||
target = os.readlink(current)
|
||||
except OSError:
|
||||
continue
|
||||
try:
|
||||
current_revisions[name] = int(os.path.basename(target.rstrip("/")))
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
candidates: Dict[str, List[int]] = {}
|
||||
try:
|
||||
entries = os.listdir(snapd_snaps)
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
for entry in entries:
|
||||
if not entry.endswith(".snap") or "_" not in entry:
|
||||
continue
|
||||
name, rev_text = entry[:-5].rsplit("_", 1)
|
||||
try:
|
||||
revision = int(rev_text)
|
||||
except ValueError:
|
||||
continue
|
||||
candidates.setdefault(name, []).append(revision)
|
||||
|
||||
out: List[SnapInstall] = []
|
||||
for name, revisions in candidates.items():
|
||||
revision = current_revisions.get(name)
|
||||
if revision is None:
|
||||
revision = max(revisions)
|
||||
out.append(SnapInstall(name=name, revision=revision, source="filesystem"))
|
||||
return sorted(out, key=lambda s: s.name)
|
||||
|
||||
|
||||
def find_system_snaps() -> List[SnapInstall]:
|
||||
"""Return system-wide snap packages.
|
||||
|
||||
Prefer `snap list` because it exposes channel tracking and confinement notes.
|
||||
Fall back to snapd's on-disk snap filenames when the command is unavailable.
|
||||
"""
|
||||
output = _run_snap_list()
|
||||
if output is not None:
|
||||
parsed = _parse_snap_list_output(output)
|
||||
if parsed:
|
||||
return parsed
|
||||
return _find_system_snaps_from_filesystem()
|
||||
|
||||
|
||||
def collect_non_system_users() -> List[UserRecord]:
|
||||
defs = parse_login_defs()
|
||||
uid_min = defs.get("UID_MIN", 1000)
|
||||
|
|
@ -139,6 +786,10 @@ def collect_non_system_users() -> List[UserRecord]:
|
|||
|
||||
ssh_files = find_user_ssh_files(home) if home and home.startswith("/") else []
|
||||
|
||||
flatpaks: List[FlatpakInstall] = []
|
||||
if home and home.startswith("/"):
|
||||
flatpaks = find_user_flatpaks(home, user=name)
|
||||
|
||||
users.append(
|
||||
UserRecord(
|
||||
name=name,
|
||||
|
|
@ -150,6 +801,7 @@ def collect_non_system_users() -> List[UserRecord]:
|
|||
primary_group=primary_group,
|
||||
supplementary_groups=supp,
|
||||
ssh_files=ssh_files,
|
||||
flatpaks=flatpaks,
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
|||
110
enroll/ansible.py
Normal file
110
enroll/ansible.py
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from .ansible_renderer.context import _prepare_ansible_context
|
||||
from .ansible_renderer.layout import _write_manifest_playbook, _write_site_scaffold
|
||||
from .ansible_renderer.model import (
|
||||
AnsibleManifestPlan,
|
||||
AnsibleRole,
|
||||
_collect_ansible_roles,
|
||||
)
|
||||
from .ansible_renderer.roles.container_images import _render_container_images_role
|
||||
from .ansible_renderer.roles.desktop import _render_flatpak_role, _render_snap_role
|
||||
from .ansible_renderer.roles.managed_files import _render_managed_file_roles
|
||||
from .ansible_renderer.roles.packages import (
|
||||
_render_common_ansible_roles,
|
||||
_render_package_roles,
|
||||
_render_service_roles,
|
||||
)
|
||||
from .ansible_renderer.roles.runtime import (
|
||||
_render_firewall_runtime_role,
|
||||
_render_sysctl_role,
|
||||
)
|
||||
from .ansible_renderer.roles.users import _render_users_role
|
||||
from .state import inventory_packages_from_state, roles_from_state
|
||||
|
||||
|
||||
class AnsibleManifestRenderer:
|
||||
"""Render Ansible roles and playbook from a harvest bundle."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
bundle_dir: str,
|
||||
out_dir: str,
|
||||
*,
|
||||
fqdn: Optional[str] = None,
|
||||
jinjaturtle: str = "auto",
|
||||
no_common_roles: bool = False,
|
||||
) -> None:
|
||||
self.bundle_dir = bundle_dir
|
||||
self.out_dir = out_dir
|
||||
self.fqdn = fqdn
|
||||
self.jinjaturtle = jinjaturtle
|
||||
self.no_common_roles = no_common_roles
|
||||
|
||||
def render(self) -> None:
|
||||
state = AnsibleRole.load_state(self.bundle_dir)
|
||||
roles = roles_from_state(state)
|
||||
inventory_packages = inventory_packages_from_state(state)
|
||||
|
||||
ctx = _prepare_ansible_context(
|
||||
self.bundle_dir,
|
||||
self.out_dir,
|
||||
fqdn=self.fqdn,
|
||||
jinjaturtle=self.jinjaturtle,
|
||||
)
|
||||
_write_site_scaffold(ctx)
|
||||
|
||||
use_common_roles = (not ctx.site_mode) and (not self.no_common_roles)
|
||||
collection = _collect_ansible_roles(
|
||||
roles,
|
||||
inventory_packages,
|
||||
use_common_roles=use_common_roles,
|
||||
)
|
||||
|
||||
manifest_plan = AnsibleManifestPlan()
|
||||
|
||||
_render_users_role(ctx, manifest_plan, roles.get("users", {}))
|
||||
_render_flatpak_role(ctx, manifest_plan, roles.get("flatpak", {}))
|
||||
_render_snap_role(ctx, manifest_plan, roles.get("snap", {}))
|
||||
_render_container_images_role(
|
||||
ctx, manifest_plan, roles.get("container_images", {})
|
||||
)
|
||||
_render_managed_file_roles(ctx, manifest_plan, roles)
|
||||
_render_sysctl_role(ctx, manifest_plan, roles.get("sysctl", {}))
|
||||
_render_firewall_runtime_role(
|
||||
ctx, manifest_plan, roles.get("firewall_runtime", {})
|
||||
)
|
||||
_render_service_roles(ctx, manifest_plan, collection.services)
|
||||
|
||||
common_tail_roles = _render_common_ansible_roles(
|
||||
ctx, manifest_plan, collection.common_role_groups, collection.packages
|
||||
)
|
||||
_render_package_roles(ctx, manifest_plan, collection.packages)
|
||||
|
||||
# Place cron/logrotate at the end of the playbook so users exist before
|
||||
# per-user crontabs are restored and core packages/services are in place.
|
||||
for role in ("cron", "logrotate"):
|
||||
manifest_plan.mark_tail_package(role)
|
||||
for role in common_tail_roles:
|
||||
manifest_plan.mark_tail_package(role)
|
||||
|
||||
_write_manifest_playbook(ctx, manifest_plan.ordered_roles())
|
||||
|
||||
|
||||
def manifest_from_bundle_dir(
|
||||
bundle_dir: str,
|
||||
out_dir: str,
|
||||
*,
|
||||
fqdn: Optional[str] = None,
|
||||
jinjaturtle: str = "auto",
|
||||
no_common_roles: bool = False,
|
||||
) -> None:
|
||||
AnsibleManifestRenderer(
|
||||
bundle_dir,
|
||||
out_dir,
|
||||
fqdn=fqdn,
|
||||
jinjaturtle=jinjaturtle,
|
||||
no_common_roles=no_common_roles,
|
||||
).render()
|
||||
1
enroll/ansible_renderer/__init__.py
Normal file
1
enroll/ansible_renderer/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Ansible manifest renderer implementation."""
|
||||
56
enroll/ansible_renderer/context.py
Normal file
56
enroll/ansible_renderer/context.py
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from ..jinjaturtle import find_jinjaturtle_cmd
|
||||
|
||||
|
||||
@dataclass
|
||||
class AnsibleManifestContext:
|
||||
bundle_dir: str
|
||||
out_dir: str
|
||||
roles_root: str
|
||||
fqdn: Optional[str]
|
||||
site_mode: bool
|
||||
jt_exe: Optional[str]
|
||||
jt_enabled: bool
|
||||
|
||||
|
||||
def _resolve_jinjaturtle_mode(jinjaturtle: str) -> Tuple[Optional[str], bool]:
|
||||
jt_exe = find_jinjaturtle_cmd()
|
||||
if jinjaturtle not in ("auto", "on", "off"):
|
||||
raise ValueError("jinjaturtle must be one of: auto, on, off")
|
||||
if jinjaturtle == "on":
|
||||
if not jt_exe:
|
||||
raise RuntimeError("jinjaturtle requested but not found on PATH")
|
||||
return jt_exe, True
|
||||
if jinjaturtle == "auto":
|
||||
return jt_exe, jt_exe is not None
|
||||
return jt_exe, False
|
||||
|
||||
|
||||
def _prepare_ansible_context(
|
||||
bundle_dir: str,
|
||||
out_dir: str,
|
||||
*,
|
||||
fqdn: Optional[str],
|
||||
jinjaturtle: str,
|
||||
) -> AnsibleManifestContext:
|
||||
site_mode = fqdn is not None and fqdn != ""
|
||||
jt_exe, jt_enabled = _resolve_jinjaturtle_mode(jinjaturtle)
|
||||
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
roles_root = os.path.join(out_dir, "roles")
|
||||
os.makedirs(roles_root, exist_ok=True)
|
||||
|
||||
return AnsibleManifestContext(
|
||||
bundle_dir=bundle_dir,
|
||||
out_dir=out_dir,
|
||||
roles_root=roles_root,
|
||||
fqdn=fqdn,
|
||||
site_mode=site_mode,
|
||||
jt_exe=jt_exe,
|
||||
jt_enabled=jt_enabled,
|
||||
)
|
||||
69
enroll/ansible_renderer/jinjaturtle.py
Normal file
69
enroll/ansible_renderer/jinjaturtle.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
|
||||
from ..jinjaturtle import can_jinjify_path, infer_other_formats, run_jinjaturtle
|
||||
from .yamlutil import _merge_mappings_overwrite, _yaml_dump_mapping, _yaml_load_mapping
|
||||
|
||||
|
||||
def _jinjify_managed_files(
|
||||
bundle_dir: str,
|
||||
role: str,
|
||||
role_dir: str,
|
||||
managed_files: List[Dict[str, Any]],
|
||||
*,
|
||||
jt_exe: Optional[str],
|
||||
jt_enabled: bool,
|
||||
overwrite_templates: bool,
|
||||
) -> Tuple[Set[str], str]:
|
||||
"""
|
||||
Return (templated_src_rels, combined_vars_text).
|
||||
combined_vars_text is a YAML mapping fragment (no leading ---).
|
||||
"""
|
||||
templated: Set[str] = set()
|
||||
vars_map: Dict[str, Any] = {}
|
||||
|
||||
if not (jt_enabled and jt_exe):
|
||||
return templated, ""
|
||||
|
||||
for mf in managed_files:
|
||||
dest_path = mf.get("path", "")
|
||||
src_rel = mf.get("src_rel", "")
|
||||
if not dest_path or not src_rel:
|
||||
continue
|
||||
if not can_jinjify_path(dest_path):
|
||||
continue
|
||||
|
||||
artifact_path = os.path.join(bundle_dir, "artifacts", role, src_rel)
|
||||
if not os.path.isfile(artifact_path):
|
||||
continue
|
||||
|
||||
try:
|
||||
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.)
|
||||
continue # nosec
|
||||
|
||||
tmpl_rel = src_rel + ".j2"
|
||||
tmpl_dst = os.path.join(role_dir, "templates", tmpl_rel)
|
||||
if overwrite_templates or not os.path.exists(tmpl_dst):
|
||||
os.makedirs(os.path.dirname(tmpl_dst), exist_ok=True)
|
||||
with open(tmpl_dst, "w", encoding="utf-8") as f:
|
||||
f.write(res.template_text)
|
||||
|
||||
templated.add(src_rel)
|
||||
if res.vars_text.strip():
|
||||
# merge YAML mappings; last wins (avoids duplicate keys)
|
||||
chunk = _yaml_load_mapping(res.vars_text)
|
||||
if chunk:
|
||||
vars_map = _merge_mappings_overwrite(vars_map, chunk)
|
||||
|
||||
if vars_map:
|
||||
combined = _yaml_dump_mapping(vars_map, sort_keys=True)
|
||||
return templated, combined
|
||||
return templated, ""
|
||||
304
enroll/ansible_renderer/layout.py
Normal file
304
enroll/ansible_renderer/layout.py
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
|
||||
from .context import AnsibleManifestContext
|
||||
from .yamlutil import _merge_mappings_overwrite, _yaml_dump_mapping, _yaml_load_mapping
|
||||
|
||||
|
||||
def _copy2_replace(src: str, dst: str) -> None:
|
||||
dst_dir = os.path.dirname(dst)
|
||||
os.makedirs(dst_dir, exist_ok=True)
|
||||
|
||||
# Copy to a temp file in the same directory, then atomically replace.
|
||||
fd, tmp = tempfile.mkstemp(prefix=".enroll-tmp-", dir=dst_dir)
|
||||
os.close(fd)
|
||||
try:
|
||||
shutil.copy2(src, tmp)
|
||||
|
||||
# Ensure the working tree stays mergeable: make the file user-writable.
|
||||
st = os.stat(tmp, follow_symlinks=False)
|
||||
mode = stat.S_IMODE(st.st_mode)
|
||||
if not (mode & stat.S_IWUSR):
|
||||
os.chmod(tmp, mode | stat.S_IWUSR)
|
||||
|
||||
os.replace(tmp, dst)
|
||||
finally:
|
||||
try:
|
||||
os.unlink(tmp)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def _copy_artifacts(
|
||||
bundle_dir: str,
|
||||
role: str,
|
||||
dst_files_dir: str,
|
||||
*,
|
||||
preserve_existing: bool = False,
|
||||
exclude_rels: Optional[Set[str]] = None,
|
||||
) -> None:
|
||||
"""Copy harvested artifacts for a role into a destination *files* directory.
|
||||
|
||||
In non --fqdn mode, this is usually <role_dir>/files.
|
||||
In --fqdn site mode, this is usually:
|
||||
inventory/host_vars/<fqdn>/<role>/.files
|
||||
"""
|
||||
artifacts_dir = os.path.join(bundle_dir, "artifacts", role)
|
||||
if not os.path.isdir(artifacts_dir):
|
||||
return
|
||||
for root, _, files in os.walk(artifacts_dir):
|
||||
for fn in files:
|
||||
src = os.path.join(root, fn)
|
||||
rel = os.path.relpath(src, artifacts_dir)
|
||||
dst = os.path.join(dst_files_dir, rel)
|
||||
|
||||
# If a file was successfully templatised by JinjaTurtle, do NOT
|
||||
# also materialise the raw copy in the destination files dir.
|
||||
if exclude_rels and rel in exclude_rels:
|
||||
try:
|
||||
if os.path.isfile(dst):
|
||||
os.remove(dst)
|
||||
except Exception:
|
||||
pass # nosec
|
||||
continue
|
||||
|
||||
if preserve_existing and os.path.exists(dst):
|
||||
continue
|
||||
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
||||
_copy2_replace(src, dst)
|
||||
|
||||
|
||||
def _write_role_scaffold(role_dir: str) -> None:
|
||||
os.makedirs(os.path.join(role_dir, "tasks"), exist_ok=True)
|
||||
os.makedirs(os.path.join(role_dir, "handlers"), exist_ok=True)
|
||||
os.makedirs(os.path.join(role_dir, "defaults"), exist_ok=True)
|
||||
os.makedirs(os.path.join(role_dir, "meta"), exist_ok=True)
|
||||
os.makedirs(os.path.join(role_dir, "files"), exist_ok=True)
|
||||
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 = [
|
||||
"---",
|
||||
"- name: Apply all roles on all hosts",
|
||||
" gather_facts: true",
|
||||
" hosts: all",
|
||||
" become: true",
|
||||
" roles:",
|
||||
]
|
||||
for r in roles:
|
||||
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")
|
||||
|
||||
|
||||
def _write_playbook_host(path: str, fqdn: str, roles: List[str]) -> None:
|
||||
pb_lines = [
|
||||
"---",
|
||||
f"- name: Apply all roles on {fqdn}",
|
||||
f" hosts: {fqdn}",
|
||||
" gather_facts: true",
|
||||
" become: true",
|
||||
" roles:",
|
||||
]
|
||||
for r in roles:
|
||||
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")
|
||||
|
||||
|
||||
def _ensure_ansible_cfg(cfg_path: str) -> None:
|
||||
if not os.path.exists(cfg_path):
|
||||
with open(cfg_path, "w", encoding="utf-8") as f:
|
||||
f.write("[defaults]\n")
|
||||
f.write("roles_path = roles\n")
|
||||
f.write("interpreter_python=/usr/bin/python3\n")
|
||||
f.write("inventory = inventory\n")
|
||||
f.write("stdout_callback = unixy\n")
|
||||
f.write("force_color = 1\n")
|
||||
f.write("vars_plugins_enabled = host_group_vars\n")
|
||||
f.write("fact_caching = jsonfile\n")
|
||||
f.write("fact_caching_connection = .enroll_cached_facts\n")
|
||||
f.write("forks = 30\n")
|
||||
f.write("remote_tmp = /tmp/ansible-${USER}\n")
|
||||
f.write("timeout = 12\n")
|
||||
f.write("[ssh_connection]\n")
|
||||
f.write("pipelining = True\n")
|
||||
f.write("scp_if_ssh = True\n")
|
||||
return
|
||||
|
||||
|
||||
def _ensure_requirements_yaml(
|
||||
req_path: str,
|
||||
collections: Optional[List[Dict[str, str]]] = None,
|
||||
) -> None:
|
||||
requested = collections or [{"name": "community.general", "version": ">=13.0.0"}]
|
||||
|
||||
existing: Dict[str, Any] = {}
|
||||
if os.path.exists(req_path):
|
||||
try:
|
||||
existing = _yaml_load_mapping(Path(req_path).read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
existing = {}
|
||||
|
||||
current_items = existing.get("collections")
|
||||
if not isinstance(current_items, list):
|
||||
current_items = []
|
||||
|
||||
by_name: Dict[str, Dict[str, str]] = {}
|
||||
ordered_names: List[str] = []
|
||||
for item in current_items:
|
||||
if isinstance(item, str):
|
||||
name = item.strip()
|
||||
if not name:
|
||||
continue
|
||||
entry: Dict[str, str] = {"name": name}
|
||||
elif isinstance(item, dict):
|
||||
name = str(item.get("name") or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
entry = {str(k): str(v) for k, v in item.items() if v is not None}
|
||||
entry["name"] = name
|
||||
else:
|
||||
continue
|
||||
if name not in by_name:
|
||||
ordered_names.append(name)
|
||||
by_name[name] = entry
|
||||
|
||||
for item in requested:
|
||||
name = str(item.get("name") or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
entry = dict(item)
|
||||
entry["name"] = name
|
||||
if name not in by_name:
|
||||
ordered_names.append(name)
|
||||
by_name[name] = entry
|
||||
else:
|
||||
by_name[name].update(
|
||||
{k: v for k, v in entry.items() if v not in (None, "")}
|
||||
)
|
||||
|
||||
out = {"collections": [by_name[name] for name in ordered_names]}
|
||||
Path(req_path).parent.mkdir(parents=True, exist_ok=True)
|
||||
Path(req_path).write_text(
|
||||
"---\n" + _yaml_dump_mapping(out, sort_keys=False), encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
def _ensure_inventory_host(inv_path: str, fqdn: str) -> None:
|
||||
os.makedirs(os.path.dirname(inv_path), exist_ok=True)
|
||||
if not os.path.exists(inv_path):
|
||||
with open(inv_path, "w", encoding="utf-8") as f:
|
||||
f.write("[all]\n")
|
||||
f.write(fqdn + "\n")
|
||||
return
|
||||
|
||||
with open(inv_path, "r", encoding="utf-8") as f:
|
||||
lines = [ln.rstrip("\n") for ln in f.readlines()]
|
||||
|
||||
# ensure there is an [all] group; if not, create it at top
|
||||
if not any(ln.strip() == "[all]" for ln in lines):
|
||||
lines = ["[all]"] + lines
|
||||
|
||||
# check if fqdn already present (exact match, ignoring whitespace)
|
||||
if any(ln.strip() == fqdn for ln in lines):
|
||||
return
|
||||
|
||||
# append at end
|
||||
lines.append(fqdn)
|
||||
with open(inv_path, "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(lines) + "\n")
|
||||
|
||||
|
||||
def _hostvars_path(site_root: str, fqdn: str, role: str) -> str:
|
||||
return os.path.join(site_root, "inventory", "host_vars", fqdn, f"{role}.yml")
|
||||
|
||||
|
||||
def _host_role_files_dir(site_root: str, fqdn: str, role: str) -> str:
|
||||
"""Host-specific files dir for a given role.
|
||||
|
||||
Layout:
|
||||
inventory/host_vars/<fqdn>/<role>/.files/
|
||||
"""
|
||||
return os.path.join(site_root, "inventory", "host_vars", fqdn, role, ".files")
|
||||
|
||||
|
||||
def _write_hostvars(site_root: str, fqdn: str, role: str, data: Dict[str, Any]) -> None:
|
||||
"""Write host_vars YAML for a role for a specific host.
|
||||
|
||||
This is host-specific state and should track the current harvest output.
|
||||
Existing keys not mentioned in `data` are preserved, but keys in `data`
|
||||
are overwritten (including list values).
|
||||
"""
|
||||
path = _hostvars_path(site_root, fqdn, role)
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
|
||||
existing_map: Dict[str, Any] = {}
|
||||
if os.path.exists(path):
|
||||
try:
|
||||
existing_text = Path(path).read_text(encoding="utf-8")
|
||||
existing_map = _yaml_load_mapping(existing_text)
|
||||
except Exception:
|
||||
existing_map = {}
|
||||
|
||||
merged = _merge_mappings_overwrite(existing_map, data)
|
||||
|
||||
out = "---\n" + _yaml_dump_mapping(merged, sort_keys=True)
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(out)
|
||||
|
||||
|
||||
def _write_role_defaults(role_dir: str, mapping: Dict[str, Any]) -> None:
|
||||
"""Overwrite role defaults/main.yml with the provided mapping."""
|
||||
defaults_path = os.path.join(role_dir, "defaults", "main.yml")
|
||||
os.makedirs(os.path.dirname(defaults_path), exist_ok=True)
|
||||
out = "---\n" + _yaml_dump_mapping(mapping, sort_keys=True)
|
||||
with open(defaults_path, "w", encoding="utf-8") as f:
|
||||
f.write(out)
|
||||
|
||||
|
||||
def _write_site_scaffold(ctx: AnsibleManifestContext) -> None:
|
||||
if not ctx.site_mode:
|
||||
return
|
||||
os.makedirs(os.path.join(ctx.out_dir, "inventory"), exist_ok=True)
|
||||
os.makedirs(os.path.join(ctx.out_dir, "inventory", "host_vars"), exist_ok=True)
|
||||
os.makedirs(os.path.join(ctx.out_dir, "playbooks"), exist_ok=True)
|
||||
_ensure_inventory_host(
|
||||
os.path.join(ctx.out_dir, "inventory", "hosts.ini"), ctx.fqdn or ""
|
||||
)
|
||||
_ensure_ansible_cfg(os.path.join(ctx.out_dir, "ansible.cfg"))
|
||||
_ensure_requirements_yaml(os.path.join(ctx.out_dir, "requirements.yml"))
|
||||
|
||||
|
||||
def _write_manifest_playbook(ctx: AnsibleManifestContext, roles: List[str]) -> None:
|
||||
if ctx.site_mode:
|
||||
_write_playbook_host(
|
||||
os.path.join(ctx.out_dir, "playbooks", f"{ctx.fqdn}.yml"),
|
||||
ctx.fqdn or "",
|
||||
roles,
|
||||
)
|
||||
else:
|
||||
_write_playbook_all(os.path.join(ctx.out_dir, "playbook.yml"), roles)
|
||||
227
enroll/ansible_renderer/model.py
Normal file
227
enroll/ansible_renderer/model.py
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
|
||||
from ..cm import CMModule, package_section_label, section_label_for_packages
|
||||
from ..role_names import avoid_reserved_role_name
|
||||
|
||||
|
||||
@dataclass
|
||||
class AnsibleRoleCollection:
|
||||
services: List[Dict[str, Any]]
|
||||
packages: List[Dict[str, Any]]
|
||||
common_role_groups: Dict[str, List[Dict[str, Any]]]
|
||||
|
||||
|
||||
class AnsibleRole(CMModule):
|
||||
"""Ansible-specific view of a renderer-neutral CMModule."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
role_name: str,
|
||||
*,
|
||||
var_prefix: Optional[str] = None,
|
||||
section_label: Optional[str] = None,
|
||||
grouped: bool = False,
|
||||
) -> None:
|
||||
super().__init__(role_name=role_name, module_name=role_name)
|
||||
self.var_prefix = var_prefix or role_name
|
||||
self.section_label = section_label
|
||||
self.grouped = grouped
|
||||
self.entries: List[Dict[str, Any]] = []
|
||||
self.excluded: List[Dict[str, Any]] = []
|
||||
self.origin_lines: List[str] = []
|
||||
|
||||
def add_package_snapshot(self, snap: Dict[str, Any]) -> None:
|
||||
pkg = str(snap.get("package") or "").strip()
|
||||
source_role = str(snap.get("role_name") or pkg or self.role_name)
|
||||
self.entries.append({"kind": "package", "snapshot": snap})
|
||||
if pkg:
|
||||
self.packages.add(pkg)
|
||||
self.origin_lines.append(f"package `{pkg}` from role `{source_role}`")
|
||||
self.add_managed_content(snap)
|
||||
|
||||
def add_service_snapshot(self, snap: Dict[str, Any]) -> None:
|
||||
unit = str(snap.get("unit") or "").strip()
|
||||
source_role = str(snap.get("role_name") or unit or self.role_name)
|
||||
self.entries.append({"kind": "service", "snapshot": snap})
|
||||
for pkg in snap.get("packages", []) or []:
|
||||
pkg_s = str(pkg or "").strip()
|
||||
if pkg_s:
|
||||
self.packages.add(pkg_s)
|
||||
if unit:
|
||||
unit_file_state = str(snap.get("unit_file_state") or "")
|
||||
self.services.setdefault(
|
||||
unit,
|
||||
{
|
||||
"name": unit,
|
||||
"manage": True,
|
||||
"enabled": unit_file_state in ("enabled", "enabled-runtime"),
|
||||
"state": (
|
||||
"started" if snap.get("active_state") == "active" else "stopped"
|
||||
),
|
||||
},
|
||||
)
|
||||
self.origin_lines.append(f"service `{unit}` from role `{source_role}`")
|
||||
self.add_managed_content(snap)
|
||||
|
||||
def add_managed_content(self, snap: Dict[str, Any]) -> None:
|
||||
for d in self.managed_dirs_from_snapshot(snap):
|
||||
path = str(d.get("path") or "").strip()
|
||||
self.add_managed_dir(
|
||||
path,
|
||||
dest=path,
|
||||
owner=d.get("owner") or "root",
|
||||
group=d.get("group") or "root",
|
||||
mode=d.get("mode") or "0755",
|
||||
)
|
||||
|
||||
for mf in self.managed_files_from_snapshot(snap):
|
||||
path = str(mf.get("path") or "").strip()
|
||||
src_rel = str(mf.get("src_rel") or "").strip()
|
||||
if not path or not src_rel:
|
||||
continue
|
||||
self.add_managed_file(
|
||||
path,
|
||||
dest=path,
|
||||
src_rel=src_rel,
|
||||
owner=mf.get("owner") or "root",
|
||||
group=mf.get("group") or "root",
|
||||
mode=mf.get("mode") or "0644",
|
||||
reason=mf.get("reason") or "managed_file",
|
||||
)
|
||||
|
||||
for ml in self.managed_links_from_snapshot(snap):
|
||||
path = str(ml.get("path") or "").strip()
|
||||
target = str(ml.get("target") or "").strip()
|
||||
if not path or not target:
|
||||
continue
|
||||
self.add_managed_link(path, dest=path, src=target)
|
||||
|
||||
self.excluded.extend(snap.get("excluded", []) or [])
|
||||
self.add_snapshot_notes(snap)
|
||||
|
||||
@property
|
||||
def sorted_packages(self) -> List[str]:
|
||||
return sorted(self.packages)
|
||||
|
||||
@property
|
||||
def systemd_units_var(self) -> List[Dict[str, Any]]:
|
||||
return [self.services[k] for k in sorted(self.services)]
|
||||
|
||||
|
||||
class AnsibleManifestPlan:
|
||||
"""Track generated Ansible roles without scattering category lists."""
|
||||
|
||||
_ORDER = (
|
||||
"apt_config",
|
||||
"dnf_config",
|
||||
"package",
|
||||
"service",
|
||||
"etc_custom",
|
||||
"usr_local_custom",
|
||||
"extra_paths",
|
||||
"flatpak",
|
||||
"snap",
|
||||
"container_images",
|
||||
"users",
|
||||
"tail_package",
|
||||
"sysctl",
|
||||
"firewall_runtime",
|
||||
)
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._roles: Dict[str, List[str]] = {category: [] for category in self._ORDER}
|
||||
self._tail_packages: List[str] = []
|
||||
|
||||
def add(self, category: str, role: str) -> None:
|
||||
if category not in self._roles:
|
||||
raise ValueError(f"unknown Ansible role category: {category}")
|
||||
if role and role not in self._roles[category]:
|
||||
self._roles[category].append(role)
|
||||
|
||||
def roles(self, category: str) -> List[str]:
|
||||
return list(self._roles.get(category, []))
|
||||
|
||||
def has(self, category: str, role: str) -> bool:
|
||||
return role in self._roles.get(category, [])
|
||||
|
||||
def mark_tail_package(self, role: str) -> None:
|
||||
if self.has("package", role) and role not in self._tail_packages:
|
||||
self._tail_packages.append(role)
|
||||
|
||||
def ordered_roles(self) -> List[str]:
|
||||
tail = set(self._tail_packages)
|
||||
package_roles = [r for r in self._roles["package"] if r not in tail]
|
||||
out: List[str] = []
|
||||
for category in self._ORDER:
|
||||
if category == "package":
|
||||
out.extend(package_roles)
|
||||
elif category == "tail_package":
|
||||
out.extend(self._tail_packages)
|
||||
else:
|
||||
out.extend(self._roles[category])
|
||||
return out
|
||||
|
||||
|
||||
def _role_id(raw: str) -> str:
|
||||
"""Return an Ansible-safe role identifier from an arbitrary label."""
|
||||
|
||||
s = re.sub(r"[^A-Za-z0-9]+", "_", raw or "misc")
|
||||
s = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", s)
|
||||
s = s.lower()
|
||||
s = re.sub(r"_+", "_", s).strip("_")
|
||||
if not s:
|
||||
s = "misc"
|
||||
if not re.match(r"^[a-z_]", s):
|
||||
s = "r_" + s
|
||||
return s
|
||||
|
||||
|
||||
def _section_role_name(label: str, occupied_roles: Set[str]) -> str:
|
||||
"""Create a stable section role name, avoiding generated-role collisions."""
|
||||
|
||||
base = avoid_reserved_role_name(_role_id(label), prefix="section")
|
||||
role = base if base not in occupied_roles else f"section_{base}"
|
||||
n = 2
|
||||
while role in occupied_roles:
|
||||
role = f"section_{base}_{n}"
|
||||
n += 1
|
||||
occupied_roles.add(role)
|
||||
return role
|
||||
|
||||
|
||||
def _collect_ansible_roles(
|
||||
roles: Dict[str, Any],
|
||||
inventory_packages: Dict[str, Any],
|
||||
*,
|
||||
use_common_roles: bool,
|
||||
) -> AnsibleRoleCollection:
|
||||
services = roles.get("services", []) or []
|
||||
packages = roles.get("packages", []) or []
|
||||
common_role_groups: Dict[str, List[Dict[str, Any]]] = {}
|
||||
|
||||
if use_common_roles:
|
||||
for svc in services:
|
||||
label = section_label_for_packages(
|
||||
svc.get("packages", []) or [], inventory_packages
|
||||
)
|
||||
common_role_groups.setdefault(label, []).append(
|
||||
{"kind": "service", "snapshot": svc}
|
||||
)
|
||||
for pr in packages:
|
||||
label = package_section_label(pr, inventory_packages)
|
||||
common_role_groups.setdefault(label, []).append(
|
||||
{"kind": "package", "snapshot": pr}
|
||||
)
|
||||
return AnsibleRoleCollection(
|
||||
services=[], packages=[], common_role_groups=common_role_groups
|
||||
)
|
||||
|
||||
return AnsibleRoleCollection(
|
||||
services=services,
|
||||
packages=packages,
|
||||
common_role_groups=common_role_groups,
|
||||
)
|
||||
226
enroll/ansible_renderer/readme.py
Normal file
226
enroll/ansible_renderer/readme.py
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from typing import Any, Callable, Dict, List, Set
|
||||
|
||||
|
||||
def _markdown_list(items: List[str]) -> str:
|
||||
values = [str(item) for item in items if str(item)]
|
||||
return "\n".join(f"- {item}" for item in values) or "- (none)"
|
||||
|
||||
|
||||
def _managed_file_lines(
|
||||
managed_files: List[Dict[str, Any]], *, include_reason: bool
|
||||
) -> List[str]:
|
||||
out: List[str] = []
|
||||
for mf in managed_files:
|
||||
path = str(mf.get("path") or "")
|
||||
if not path:
|
||||
continue
|
||||
if include_reason:
|
||||
out.append(f"{path} ({mf.get('reason')})")
|
||||
else:
|
||||
out.append(path)
|
||||
return out
|
||||
|
||||
|
||||
def _excluded_lines(excluded: List[Dict[str, Any]]) -> List[str]:
|
||||
return [f"{e.get('path')} ({e.get('reason')})" for e in excluded if e.get("path")]
|
||||
|
||||
|
||||
def _read_artifact_lines(bundle_dir: str, role: str, src_rel: str) -> List[str]:
|
||||
art_path = os.path.join(bundle_dir, "artifacts", role, src_rel)
|
||||
try:
|
||||
with open(art_path, "r", encoding="utf-8", errors="replace") as f:
|
||||
return [line.rstrip("\n") for line in f]
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
|
||||
def _apt_config_readme(
|
||||
*,
|
||||
bundle_dir: str,
|
||||
role: str,
|
||||
snapshot: Dict[str, Any],
|
||||
managed_files: List[Dict[str, Any]],
|
||||
managed_dirs: List[Dict[str, Any]],
|
||||
excluded: List[Dict[str, Any]],
|
||||
notes: List[Any],
|
||||
) -> str:
|
||||
source_paths: List[str] = []
|
||||
keyring_paths: List[str] = []
|
||||
repo_hosts: Set[str] = set()
|
||||
url_re = re.compile(r"(?:https?|ftp)://([^/\s]+)", re.IGNORECASE)
|
||||
|
||||
for mf in managed_files:
|
||||
path = str(mf.get("path") or "")
|
||||
src_rel = str(mf.get("src_rel") or "")
|
||||
if not path or not src_rel:
|
||||
continue
|
||||
if path == "/etc/apt/sources.list" or path.startswith(
|
||||
"/etc/apt/sources.list.d/"
|
||||
):
|
||||
source_paths.append(path)
|
||||
for line in _read_artifact_lines(bundle_dir, role, src_rel):
|
||||
s = line.strip()
|
||||
if not s or s.startswith("#"):
|
||||
continue
|
||||
for match in url_re.finditer(s):
|
||||
repo_hosts.add(match.group(1))
|
||||
if (
|
||||
path.startswith("/etc/apt/trusted.gpg")
|
||||
or path.startswith("/etc/apt/keyrings/")
|
||||
or path.startswith("/usr/share/keyrings/")
|
||||
):
|
||||
keyring_paths.append(path)
|
||||
|
||||
return f"""# apt_config
|
||||
|
||||
APT configuration harvested from the system (sources, pinning, and keyrings).
|
||||
|
||||
## Repository hosts
|
||||
{_markdown_list(sorted(repo_hosts))}
|
||||
|
||||
## Source files
|
||||
{_markdown_list(sorted(set(source_paths)))}
|
||||
|
||||
## Keyrings
|
||||
{_markdown_list(sorted(set(keyring_paths)))}
|
||||
|
||||
## Managed files
|
||||
{_markdown_list(_managed_file_lines(managed_files, include_reason=True))}
|
||||
|
||||
## Excluded
|
||||
{_markdown_list(_excluded_lines(excluded))}
|
||||
|
||||
## Notes
|
||||
{_markdown_list([str(n) for n in notes])}
|
||||
"""
|
||||
|
||||
|
||||
def _dnf_config_readme(
|
||||
*,
|
||||
bundle_dir: str,
|
||||
role: str,
|
||||
snapshot: Dict[str, Any],
|
||||
managed_files: List[Dict[str, Any]],
|
||||
managed_dirs: List[Dict[str, Any]],
|
||||
excluded: List[Dict[str, Any]],
|
||||
notes: List[Any],
|
||||
) -> str:
|
||||
repo_paths: List[str] = []
|
||||
key_paths: List[str] = []
|
||||
repo_hosts: Set[str] = set()
|
||||
url_re = re.compile(r"(?:https?|ftp)://([^/\s]+)", re.IGNORECASE)
|
||||
file_url_re = re.compile(r"file://(/[^\s]+)")
|
||||
|
||||
for mf in managed_files:
|
||||
path = str(mf.get("path") or "")
|
||||
src_rel = str(mf.get("src_rel") or "")
|
||||
if not path or not src_rel:
|
||||
continue
|
||||
if path.startswith("/etc/yum.repos.d/") and path.endswith(".repo"):
|
||||
repo_paths.append(path)
|
||||
for line in _read_artifact_lines(bundle_dir, role, src_rel):
|
||||
s = line.strip()
|
||||
if not s or s.startswith("#") or s.startswith(";"):
|
||||
continue
|
||||
for match in url_re.finditer(s):
|
||||
repo_hosts.add(match.group(1))
|
||||
for match in file_url_re.finditer(s):
|
||||
key_paths.append(match.group(1))
|
||||
if path.startswith("/etc/pki/rpm-gpg/"):
|
||||
key_paths.append(path)
|
||||
|
||||
return f"""# dnf_config
|
||||
|
||||
DNF/YUM configuration harvested from the system (repos, config files, and RPM GPG keys).
|
||||
|
||||
## Repository hosts
|
||||
{_markdown_list(sorted(repo_hosts))}
|
||||
|
||||
## Repo files
|
||||
{_markdown_list(sorted(set(repo_paths)))}
|
||||
|
||||
## GPG keys
|
||||
{_markdown_list(sorted(set(key_paths)))}
|
||||
|
||||
## Managed files
|
||||
{_markdown_list(_managed_file_lines(managed_files, include_reason=True))}
|
||||
|
||||
## Excluded
|
||||
{_markdown_list(_excluded_lines(excluded))}
|
||||
|
||||
## Notes
|
||||
{_markdown_list([str(n) for n in notes])}
|
||||
"""
|
||||
|
||||
|
||||
def _simple_managed_files_readme(
|
||||
title: str,
|
||||
description: str,
|
||||
*,
|
||||
include_reason: bool,
|
||||
) -> Callable[..., str]:
|
||||
def _builder(
|
||||
*,
|
||||
bundle_dir: str,
|
||||
role: str,
|
||||
snapshot: Dict[str, Any],
|
||||
managed_files: List[Dict[str, Any]],
|
||||
managed_dirs: List[Dict[str, Any]],
|
||||
excluded: List[Dict[str, Any]],
|
||||
notes: List[Any],
|
||||
) -> str:
|
||||
return f"""# {title}
|
||||
|
||||
{description}
|
||||
|
||||
## Managed files
|
||||
{_markdown_list(_managed_file_lines(managed_files, include_reason=include_reason))}
|
||||
|
||||
## Excluded
|
||||
{_markdown_list(_excluded_lines(excluded))}
|
||||
|
||||
## Notes
|
||||
{_markdown_list([str(n) for n in notes])}
|
||||
"""
|
||||
|
||||
return _builder
|
||||
|
||||
|
||||
def _extra_paths_readme(
|
||||
*,
|
||||
bundle_dir: str,
|
||||
role: str,
|
||||
snapshot: Dict[str, Any],
|
||||
managed_files: List[Dict[str, Any]],
|
||||
managed_dirs: List[Dict[str, Any]],
|
||||
excluded: List[Dict[str, Any]],
|
||||
notes: List[Any],
|
||||
) -> str:
|
||||
include_pats = snapshot.get("include_patterns", []) or []
|
||||
exclude_pats = snapshot.get("exclude_patterns", []) or []
|
||||
return f"""# {role}
|
||||
|
||||
User-requested extra file harvesting.
|
||||
|
||||
## Include patterns
|
||||
{_markdown_list([str(p) for p in include_pats])}
|
||||
|
||||
## Exclude patterns
|
||||
{_markdown_list([str(p) for p in exclude_pats])}
|
||||
|
||||
## Managed directories
|
||||
{_markdown_list([str(d.get('path') or '') for d in managed_dirs])}
|
||||
|
||||
## Managed files
|
||||
{_markdown_list(_managed_file_lines(managed_files, include_reason=False))}
|
||||
|
||||
## Excluded
|
||||
{_markdown_list(_excluded_lines(excluded))}
|
||||
|
||||
## Notes
|
||||
{_markdown_list([str(n) for n in notes])}
|
||||
"""
|
||||
1
enroll/ansible_renderer/roles/__init__.py
Normal file
1
enroll/ansible_renderer/roles/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Role writers for the Ansible renderer."""
|
||||
192
enroll/ansible_renderer/roles/container_images.py
Normal file
192
enroll/ansible_renderer/roles/container_images.py
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from ..context import AnsibleManifestContext
|
||||
from ..layout import (
|
||||
_ensure_requirements_yaml,
|
||||
_write_hostvars,
|
||||
_write_role_defaults,
|
||||
_write_role_scaffold,
|
||||
)
|
||||
from ..model import AnsibleManifestPlan
|
||||
from ..vars import _normalise_container_image_item
|
||||
|
||||
_CONTAINER_COLLECTIONS = [
|
||||
{"name": "community.docker", "version": ">=4.0.0"},
|
||||
{"name": "containers.podman", "version": ">=1.0.0"},
|
||||
]
|
||||
|
||||
|
||||
def _render_container_images_role(
|
||||
ctx: AnsibleManifestContext,
|
||||
manifest_plan: AnsibleManifestPlan,
|
||||
container_images_snapshot: Dict[str, Any],
|
||||
) -> None:
|
||||
raw_images = container_images_snapshot.get("images", []) or []
|
||||
if not container_images_snapshot and not raw_images:
|
||||
return
|
||||
|
||||
images = [_normalise_container_image_item(img) for img in raw_images]
|
||||
if not images and not (container_images_snapshot.get("notes") or []):
|
||||
return
|
||||
|
||||
role = container_images_snapshot.get("role_name", "container_images")
|
||||
role_dir = os.path.join(ctx.roles_root, role)
|
||||
_write_role_scaffold(role_dir)
|
||||
_ensure_requirements_yaml(
|
||||
os.path.join(ctx.out_dir, "requirements.yml"), _CONTAINER_COLLECTIONS
|
||||
)
|
||||
|
||||
vars_map = {"container_images": images}
|
||||
if ctx.site_mode:
|
||||
_write_role_defaults(role_dir, {"container_images": []})
|
||||
_write_hostvars(ctx.out_dir, ctx.fqdn or "", role, vars_map)
|
||||
else:
|
||||
_write_role_defaults(role_dir, vars_map)
|
||||
|
||||
with open(os.path.join(role_dir, "meta", "main.yml"), "w", encoding="utf-8") as f:
|
||||
f.write(
|
||||
"---\n"
|
||||
"dependencies: []\n"
|
||||
"collections:\n"
|
||||
" - community.docker\n"
|
||||
" - containers.podman\n"
|
||||
)
|
||||
|
||||
tasks = """---
|
||||
|
||||
- name: Pull Docker images by immutable registry digest
|
||||
community.docker.docker_image_pull:
|
||||
name: "{{ item.pull_ref }}"
|
||||
pull: not_present
|
||||
platform: "{{ item.platform | default(omit, true) }}"
|
||||
loop: "{{ container_images | default([]) | selectattr('engine', 'equalto', 'docker') | selectattr('pull_ref', 'defined') | list }}"
|
||||
when:
|
||||
- item.pull_ref | default('') | length > 0
|
||||
become: true
|
||||
|
||||
- name: Tag Docker images with harvested tag aliases
|
||||
community.docker.docker_image_tag:
|
||||
name: "{{ item.0.pull_ref }}"
|
||||
repository:
|
||||
- "{{ item.1.ref }}"
|
||||
loop: "{{ query('subelements', container_images | default([]) | selectattr('engine', 'equalto', 'docker') | selectattr('pull_ref', 'defined') | list, 'tag_aliases', {'skip_missing': True}) }}"
|
||||
when:
|
||||
- item.0.pull_ref | default('') | length > 0
|
||||
- item.1.repository | default('') | length > 0
|
||||
- item.1.tag | default('') | length > 0
|
||||
become: true
|
||||
|
||||
- name: Pull system Podman images by immutable registry digest
|
||||
containers.podman.podman_image:
|
||||
name: "{{ item.pull_ref }}"
|
||||
state: present
|
||||
force: false
|
||||
platform: "{{ item.platform | default(omit, true) }}"
|
||||
loop: "{{ container_images | default([]) | selectattr('engine', 'equalto', 'podman') | rejectattr('scope', 'equalto', 'user') | selectattr('pull_ref', 'defined') | list }}"
|
||||
when:
|
||||
- item.pull_ref | default('') | length > 0
|
||||
become: true
|
||||
|
||||
- name: Tag system Podman images with harvested tag aliases
|
||||
containers.podman.podman_tag:
|
||||
image: "{{ item.0.pull_ref }}"
|
||||
target_names:
|
||||
- "{{ item.1.ref }}"
|
||||
loop: "{{ query('subelements', container_images | default([]) | selectattr('engine', 'equalto', 'podman') | rejectattr('scope', 'equalto', 'user') | selectattr('pull_ref', 'defined') | list, 'tag_aliases', {'skip_missing': True}) }}"
|
||||
when:
|
||||
- item.0.pull_ref | default('') | length > 0
|
||||
- item.1.ref | default('') | length > 0
|
||||
become: true
|
||||
|
||||
- name: Pull user Podman images by immutable registry digest
|
||||
containers.podman.podman_image:
|
||||
name: "{{ item.pull_ref }}"
|
||||
state: present
|
||||
force: false
|
||||
platform: "{{ item.platform | default(omit, true) }}"
|
||||
loop: "{{ container_images | default([]) | selectattr('engine', 'equalto', 'podman') | selectattr('scope', 'equalto', 'user') | selectattr('pull_ref', 'defined') | list }}"
|
||||
when:
|
||||
- item.pull_ref | default('') | length > 0
|
||||
- item.user | default('') | length > 0
|
||||
become: true
|
||||
become_user: "{{ item.user }}"
|
||||
|
||||
- name: Tag user Podman images with harvested tag aliases
|
||||
containers.podman.podman_tag:
|
||||
image: "{{ item.0.pull_ref }}"
|
||||
target_names:
|
||||
- "{{ item.1.ref }}"
|
||||
loop: "{{ query('subelements', container_images | default([]) | selectattr('engine', 'equalto', 'podman') | selectattr('scope', 'equalto', 'user') | selectattr('pull_ref', 'defined') | list, 'tag_aliases', {'skip_missing': True}) }}"
|
||||
when:
|
||||
- item.0.pull_ref | default('') | length > 0
|
||||
- item.0.user | default('') | length > 0
|
||||
- item.1.ref | default('') | length > 0
|
||||
become: true
|
||||
become_user: "{{ item.0.user }}"
|
||||
"""
|
||||
with open(os.path.join(role_dir, "tasks", "main.yml"), "w", encoding="utf-8") as f:
|
||||
f.write(tasks)
|
||||
|
||||
with open(
|
||||
os.path.join(role_dir, "handlers", "main.yml"), "w", encoding="utf-8"
|
||||
) as f:
|
||||
f.write("---\n")
|
||||
|
||||
def _fmt_image(img: Dict[str, Any]) -> str:
|
||||
pull_ref = (
|
||||
img.get("pull_ref") or "(no registry digest; not rendered as an exact pull)"
|
||||
)
|
||||
tags = img.get("repo_tags") or []
|
||||
tag_part = f" tags={', '.join(tags)}" if tags else ""
|
||||
platform = img.get("platform")
|
||||
platform_part = f" platform={platform}" if platform else ""
|
||||
return f"- {img.get('engine', 'unknown')}: {pull_ref}{tag_part}{platform_part}"
|
||||
|
||||
notes = list(container_images_snapshot.get("notes", []) or [])
|
||||
unpinned_notes: List[str] = []
|
||||
for img in images:
|
||||
if img.get("pull_ref"):
|
||||
continue
|
||||
label = (
|
||||
", ".join(img.get("repo_tags") or [])
|
||||
or img.get("image_id")
|
||||
or "unknown image"
|
||||
)
|
||||
unpinned_notes.append(
|
||||
f"{label}: no RepoDigest was available, so no exact pull task is emitted."
|
||||
)
|
||||
|
||||
readme = (
|
||||
"""# container_images
|
||||
|
||||
Generated Docker and Podman image-cache restoration role.
|
||||
|
||||
Images are pulled by immutable registry digest, such as
|
||||
`registry.example.net/app@sha256:...`, when the harvest found a usable
|
||||
`RepoDigest`. Local image IDs are recorded in `state.json` for evidence but are
|
||||
not registry pull references.
|
||||
|
||||
**Note:** This role requires the `community.docker` and `containers.podman`
|
||||
Ansible collections. Install them with:
|
||||
`ansible-galaxy collection install -r requirements.yml`.
|
||||
|
||||
Registry credentials are not harvested. Private-registry authentication must be
|
||||
managed separately before this role runs.
|
||||
|
||||
## Container images
|
||||
"""
|
||||
+ "\n".join(_fmt_image(img) for img in images)
|
||||
+ """
|
||||
|
||||
## Notes
|
||||
"""
|
||||
+ ("\n".join([f"- {n}" for n in notes + unpinned_notes]) or "- (none)")
|
||||
+ "\n"
|
||||
)
|
||||
with open(os.path.join(role_dir, "README.md"), "w", encoding="utf-8") as f:
|
||||
f.write(readme)
|
||||
|
||||
manifest_plan.add("container_images", role)
|
||||
308
enroll/ansible_renderer/roles/desktop.py
Normal file
308
enroll/ansible_renderer/roles/desktop.py
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from ..context import AnsibleManifestContext
|
||||
from ..layout import (
|
||||
_ensure_requirements_yaml,
|
||||
_write_hostvars,
|
||||
_write_role_defaults,
|
||||
_write_role_scaffold,
|
||||
)
|
||||
from ..model import AnsibleManifestPlan
|
||||
from ..vars import (
|
||||
_normalise_flatpak_item,
|
||||
_normalise_flatpak_remote,
|
||||
_normalise_snap_item,
|
||||
)
|
||||
|
||||
|
||||
def _render_flatpak_role(
|
||||
ctx: AnsibleManifestContext,
|
||||
manifest_plan: AnsibleManifestPlan,
|
||||
flatpak_snapshot: Dict[str, Any],
|
||||
) -> None:
|
||||
out_dir = ctx.out_dir
|
||||
roles_root = ctx.roles_root
|
||||
fqdn = ctx.fqdn
|
||||
site_mode = ctx.site_mode
|
||||
|
||||
# -------------------------
|
||||
# Flatpak role (system-wide Flatpak remotes and applications)
|
||||
# -------------------------
|
||||
raw_flatpak_apps = flatpak_snapshot.get("system_flatpaks", []) or []
|
||||
raw_flatpak_remotes = flatpak_snapshot.get("remotes", []) or []
|
||||
|
||||
if flatpak_snapshot:
|
||||
role = flatpak_snapshot.get("role_name", "flatpak")
|
||||
role_dir = os.path.join(roles_root, role)
|
||||
_write_role_scaffold(role_dir)
|
||||
_ensure_requirements_yaml(os.path.join(out_dir, "requirements.yml"))
|
||||
|
||||
flatpak_system_flatpaks = [
|
||||
_normalise_flatpak_item(fp, method="system") for fp in raw_flatpak_apps
|
||||
]
|
||||
flatpak_remotes = [_normalise_flatpak_remote(r) for r in raw_flatpak_remotes]
|
||||
|
||||
vars_map = {
|
||||
"flatpak_system_flatpaks": flatpak_system_flatpaks,
|
||||
"flatpak_remotes": flatpak_remotes,
|
||||
}
|
||||
if site_mode:
|
||||
_write_role_defaults(
|
||||
role_dir,
|
||||
{"flatpak_system_flatpaks": [], "flatpak_remotes": []},
|
||||
)
|
||||
_write_hostvars(out_dir, fqdn or "", role, vars_map)
|
||||
else:
|
||||
_write_role_defaults(role_dir, vars_map)
|
||||
|
||||
with open(
|
||||
os.path.join(role_dir, "meta", "main.yml"), "w", encoding="utf-8"
|
||||
) as f:
|
||||
f.write(
|
||||
"---\n" "dependencies: []\n" "collections:\n" " - community.general\n"
|
||||
)
|
||||
|
||||
tasks = """---
|
||||
|
||||
- name: Ensure system Flatpak remotes exist
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- flatpak
|
||||
- remote-add
|
||||
- --system
|
||||
- --if-not-exists
|
||||
- "{{ item.name }}"
|
||||
- "{{ item.url }}"
|
||||
loop: "{{ flatpak_remotes | default([]) }}"
|
||||
when:
|
||||
- item.name is defined
|
||||
- item.url is defined
|
||||
- item.url | length > 0
|
||||
become: true
|
||||
changed_when: false
|
||||
|
||||
- name: Install system-wide Flatpaks
|
||||
community.general.flatpak:
|
||||
name:
|
||||
- "{{ item.name }}"
|
||||
state: present
|
||||
method: system
|
||||
remote: "{{ item.remote | default(omit) }}"
|
||||
from_url: "{{ item.from_url | default(omit) }}"
|
||||
loop: "{{ flatpak_system_flatpaks | default([]) }}"
|
||||
when:
|
||||
- item.name is defined
|
||||
- item.name | length > 0
|
||||
become: true
|
||||
"""
|
||||
with open(
|
||||
os.path.join(role_dir, "tasks", "main.yml"), "w", encoding="utf-8"
|
||||
) as f:
|
||||
f.write(tasks)
|
||||
|
||||
with open(
|
||||
os.path.join(role_dir, "handlers", "main.yml"), "w", encoding="utf-8"
|
||||
) as f:
|
||||
f.write("---\n")
|
||||
|
||||
def _fmt_flatpak_apps(items: List[Dict[str, Any]]) -> str:
|
||||
lines = []
|
||||
for item in items:
|
||||
name = item.get("name")
|
||||
if not name:
|
||||
continue
|
||||
detail_parts = []
|
||||
for key in ("remote", "branch", "arch"):
|
||||
value = item.get(key)
|
||||
if value not in (None, "", []):
|
||||
detail_parts.append(f"{key}={value}")
|
||||
details = f" ({', '.join(detail_parts)})" if detail_parts else ""
|
||||
lines.append(f"- {name}{details}")
|
||||
return "\n".join(lines) or "- (none)"
|
||||
|
||||
def _fmt_flatpak_remotes(items: List[Dict[str, Any]]) -> str:
|
||||
lines = []
|
||||
for item in items:
|
||||
name = item.get("name")
|
||||
url = item.get("url")
|
||||
if not name or not url:
|
||||
continue
|
||||
lines.append(f"- {name}: {url}")
|
||||
return "\n".join(lines) or "- (none)"
|
||||
|
||||
notes = flatpak_snapshot.get("notes", []) or []
|
||||
readme = (
|
||||
"""# flatpak
|
||||
|
||||
Generated system-wide Flatpak remotes and applications.
|
||||
|
||||
**Note:** This role requires the `community.general` Ansible collection.
|
||||
Install it with: `ansible-galaxy collection install -r requirements.yml`.
|
||||
|
||||
Flatpak `remote` is harvested from the installed deployment where detectable.
|
||||
The original `.flatpakref` URL is generally not preserved by Flatpak after
|
||||
installation, so `from_url` is only emitted if a future/hand-edited state file
|
||||
contains it.
|
||||
|
||||
## System Flatpak remotes
|
||||
"""
|
||||
+ _fmt_flatpak_remotes(flatpak_remotes)
|
||||
+ """\n
|
||||
## System-wide Flatpaks
|
||||
"""
|
||||
+ _fmt_flatpak_apps(flatpak_system_flatpaks)
|
||||
+ """\n
|
||||
## Notes
|
||||
"""
|
||||
+ ("\n".join([f"- {n}" for n in notes]) or "- (none)")
|
||||
+ """\n"""
|
||||
)
|
||||
with open(os.path.join(role_dir, "README.md"), "w", encoding="utf-8") as f:
|
||||
f.write(readme)
|
||||
|
||||
manifest_plan.add("flatpak", role)
|
||||
|
||||
|
||||
def _render_snap_role(
|
||||
ctx: AnsibleManifestContext,
|
||||
manifest_plan: AnsibleManifestPlan,
|
||||
snap_snapshot: Dict[str, Any],
|
||||
) -> None:
|
||||
out_dir = ctx.out_dir
|
||||
roles_root = ctx.roles_root
|
||||
fqdn = ctx.fqdn
|
||||
site_mode = ctx.site_mode
|
||||
|
||||
# -------------------------
|
||||
# Snap role (system-wide snap packages)
|
||||
# -------------------------
|
||||
raw_system_snaps = snap_snapshot.get("system_snaps", []) or []
|
||||
|
||||
if raw_system_snaps:
|
||||
role = snap_snapshot.get("role_name", "snap") if snap_snapshot else "snap"
|
||||
role_dir = os.path.join(roles_root, role)
|
||||
_write_role_scaffold(role_dir)
|
||||
_ensure_requirements_yaml(os.path.join(out_dir, "requirements.yml"))
|
||||
|
||||
snap_system_snaps = [_normalise_snap_item(s) for s in raw_system_snaps]
|
||||
|
||||
vars_map = {"snap_system_snaps": snap_system_snaps}
|
||||
if site_mode:
|
||||
_write_role_defaults(role_dir, {"snap_system_snaps": []})
|
||||
_write_hostvars(out_dir, fqdn or "", role, vars_map)
|
||||
else:
|
||||
_write_role_defaults(role_dir, vars_map)
|
||||
|
||||
with open(
|
||||
os.path.join(role_dir, "meta", "main.yml"), "w", encoding="utf-8"
|
||||
) as f:
|
||||
f.write(
|
||||
"---\n" "dependencies: []\n" "collections:\n" " - community.general\n"
|
||||
)
|
||||
|
||||
tasks = """---
|
||||
|
||||
- name: Install system-wide snaps with full detected attributes
|
||||
community.general.snap:
|
||||
name:
|
||||
- "{{ item.name }}"
|
||||
state: present
|
||||
channel: "{{ item.channel | default(omit) if not (item.install_revision | default(false)) else omit }}"
|
||||
revision: "{{ item.revision | default(omit) if (item.install_revision | default(false)) else omit }}"
|
||||
classic: "{{ item.classic | default(false) }}"
|
||||
devmode: "{{ item.devmode | default(false) }}"
|
||||
dangerous: "{{ item.dangerous | default(false) }}"
|
||||
loop: "{{ snap_system_snaps | default([]) }}"
|
||||
when:
|
||||
- item.name is defined
|
||||
- item.name | length > 0
|
||||
become: true
|
||||
register: _enroll_snap_full_results
|
||||
ignore_errors: true
|
||||
|
||||
- name: Install system-wide snaps with compatibility options
|
||||
community.general.snap:
|
||||
name:
|
||||
- "{{ item.item.name }}"
|
||||
state: present
|
||||
channel: "{{ item.item.channel | default(omit) if not (item.item.install_revision | default(false)) else omit }}"
|
||||
classic: "{{ item.item.classic | default(false) }}"
|
||||
loop: "{{ (_enroll_snap_full_results | default({})).results | default([]) }}"
|
||||
when:
|
||||
- item.failed | default(false)
|
||||
- item.item.name is defined
|
||||
- item.item.name | length > 0
|
||||
become: true
|
||||
register: _enroll_snap_compat_results
|
||||
ignore_errors: true
|
||||
|
||||
- name: Install system-wide snaps with minimal options
|
||||
community.general.snap:
|
||||
name:
|
||||
- "{{ item.item.item.name }}"
|
||||
state: present
|
||||
loop: "{{ (_enroll_snap_compat_results | default({})).results | default([]) }}"
|
||||
when:
|
||||
- item.failed | default(false)
|
||||
- item.item.item.name is defined
|
||||
- item.item.item.name | length > 0
|
||||
become: true
|
||||
ignore_errors: true
|
||||
"""
|
||||
with open(
|
||||
os.path.join(role_dir, "tasks", "main.yml"), "w", encoding="utf-8"
|
||||
) as f:
|
||||
f.write(tasks)
|
||||
|
||||
with open(
|
||||
os.path.join(role_dir, "handlers", "main.yml"), "w", encoding="utf-8"
|
||||
) as f:
|
||||
f.write("---\n")
|
||||
|
||||
def _fmt_snap_apps(items: List[Dict[str, Any]]) -> str:
|
||||
lines = []
|
||||
for item in items:
|
||||
name = item.get("name")
|
||||
if not name:
|
||||
continue
|
||||
detail_parts = []
|
||||
for key in ("channel", "revision"):
|
||||
value = item.get(key)
|
||||
if value not in (None, "", []):
|
||||
detail_parts.append(f"{key}={value}")
|
||||
for key in ("classic", "devmode", "dangerous"):
|
||||
if item.get(key):
|
||||
detail_parts.append(key)
|
||||
details = f" ({', '.join(detail_parts)})" if detail_parts else ""
|
||||
lines.append(f"- {name}{details}")
|
||||
return "\n".join(lines) or "- (none)"
|
||||
|
||||
notes = snap_snapshot.get("notes", []) or []
|
||||
readme = (
|
||||
"""# snap
|
||||
|
||||
Generated system-wide snap packages.
|
||||
|
||||
**Note:** This role requires the `community.general` Ansible collection.
|
||||
Install it with: `ansible-galaxy collection install -r requirements.yml`.
|
||||
|
||||
The first install task uses all harvested attributes. If the installed
|
||||
`community.general.snap` module is too old for some parameters, the generated
|
||||
role falls back to reduced then minimal install tasks on a best-effort basis.
|
||||
|
||||
## System-wide snaps
|
||||
"""
|
||||
+ _fmt_snap_apps(snap_system_snaps)
|
||||
+ """\n
|
||||
## Notes
|
||||
"""
|
||||
+ ("\n".join([f"- {n}" for n in notes]) or "- (none)")
|
||||
+ """\n"""
|
||||
)
|
||||
with open(os.path.join(role_dir, "README.md"), "w", encoding="utf-8") as f:
|
||||
f.write(readme)
|
||||
|
||||
manifest_plan.add("snap", role)
|
||||
257
enroll/ansible_renderer/roles/managed_files.py
Normal file
257
enroll/ansible_renderer/roles/managed_files.py
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Dict, Optional, Tuple
|
||||
|
||||
from ..context import AnsibleManifestContext
|
||||
from ..jinjaturtle import _jinjify_managed_files
|
||||
from ..layout import (
|
||||
_copy_artifacts,
|
||||
_host_role_files_dir,
|
||||
_write_hostvars,
|
||||
_write_role_defaults,
|
||||
_write_role_scaffold,
|
||||
)
|
||||
from ..model import AnsibleManifestPlan
|
||||
from ..readme import (
|
||||
_apt_config_readme,
|
||||
_dnf_config_readme,
|
||||
_extra_paths_readme,
|
||||
_simple_managed_files_readme,
|
||||
)
|
||||
from ..tasks import _render_generic_files_tasks
|
||||
from ..vars import _build_managed_dirs_var, _build_managed_files_var
|
||||
from ..yamlutil import _merge_mappings_overwrite, _yaml_load_mapping
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AnsibleManagedFileRoleSpec:
|
||||
"""Declarative managed-file singleton role rendering spec.
|
||||
|
||||
Puppet collects these singleton snapshots in a simple loop and feeds
|
||||
each one through the same managed-content renderer. Ansible has more
|
||||
layout concerns (defaults vs host_vars, optional JinjaTurtle templates,
|
||||
handlers), but the resource intent is the same, so keep the per-role
|
||||
differences in data rather than spelling out one branch per role.
|
||||
"""
|
||||
|
||||
key: str
|
||||
default_role: str
|
||||
category: str
|
||||
readme_builder: Callable[..., str]
|
||||
notify_systemd: Optional[str] = None
|
||||
handlers: str = "---\n"
|
||||
include_dirs_when_empty: bool = False
|
||||
|
||||
|
||||
_SYSTEMD_DAEMON_RELOAD_HANDLER = """---
|
||||
- name: Run systemd daemon-reload
|
||||
ansible.builtin.systemd:
|
||||
daemon_reload: true
|
||||
no_log: "{{ enroll_hide_systemd_status | default(true) | bool }}"
|
||||
"""
|
||||
|
||||
|
||||
MANAGED_FILE_ROLE_SPECS: Tuple[AnsibleManagedFileRoleSpec, ...] = (
|
||||
AnsibleManagedFileRoleSpec(
|
||||
key="apt_config",
|
||||
default_role="apt_config",
|
||||
category="apt_config",
|
||||
readme_builder=_apt_config_readme,
|
||||
),
|
||||
AnsibleManagedFileRoleSpec(
|
||||
key="dnf_config",
|
||||
default_role="dnf_config",
|
||||
category="dnf_config",
|
||||
readme_builder=_dnf_config_readme,
|
||||
),
|
||||
AnsibleManagedFileRoleSpec(
|
||||
key="etc_custom",
|
||||
default_role="etc_custom",
|
||||
category="etc_custom",
|
||||
notify_systemd="Run systemd daemon-reload",
|
||||
handlers=_SYSTEMD_DAEMON_RELOAD_HANDLER,
|
||||
readme_builder=_simple_managed_files_readme(
|
||||
"etc_custom",
|
||||
"Unowned /etc config files not attributed to packages or services.",
|
||||
include_reason=False,
|
||||
),
|
||||
),
|
||||
AnsibleManagedFileRoleSpec(
|
||||
key="usr_local_custom",
|
||||
default_role="usr_local_custom",
|
||||
category="usr_local_custom",
|
||||
readme_builder=_simple_managed_files_readme(
|
||||
"usr_local_custom",
|
||||
"Unowned /usr/local files (scripts in /usr/local/bin and config under /usr/local/etc).",
|
||||
include_reason=False,
|
||||
),
|
||||
),
|
||||
AnsibleManagedFileRoleSpec(
|
||||
key="extra_paths",
|
||||
default_role="extra_paths",
|
||||
category="extra_paths",
|
||||
readme_builder=_extra_paths_readme,
|
||||
include_dirs_when_empty=True,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _managed_file_role_has_resources(
|
||||
snapshot: Dict[str, Any], spec: AnsibleManagedFileRoleSpec
|
||||
) -> bool:
|
||||
if not snapshot:
|
||||
return False
|
||||
if snapshot.get("managed_files"):
|
||||
return True
|
||||
return bool(spec.include_dirs_when_empty and snapshot.get("managed_dirs"))
|
||||
|
||||
|
||||
def _write_managed_files_role_from_spec(
|
||||
ctx: AnsibleManifestContext,
|
||||
manifest_plan: AnsibleManifestPlan,
|
||||
snapshot: Dict[str, Any],
|
||||
spec: AnsibleManagedFileRoleSpec,
|
||||
) -> None:
|
||||
role = _write_managed_files_role(
|
||||
snapshot=snapshot,
|
||||
default_role=spec.default_role,
|
||||
bundle_dir=ctx.bundle_dir,
|
||||
roles_root=ctx.roles_root,
|
||||
out_dir=ctx.out_dir,
|
||||
fqdn=ctx.fqdn,
|
||||
site_mode=ctx.site_mode,
|
||||
jt_exe=ctx.jt_exe,
|
||||
jt_enabled=ctx.jt_enabled,
|
||||
notify_systemd=spec.notify_systemd,
|
||||
handlers=spec.handlers,
|
||||
readme_builder=spec.readme_builder,
|
||||
)
|
||||
manifest_plan.add(spec.category, role)
|
||||
|
||||
|
||||
def _write_managed_files_role(
|
||||
*,
|
||||
snapshot: Dict[str, Any],
|
||||
default_role: str,
|
||||
bundle_dir: str,
|
||||
roles_root: str,
|
||||
out_dir: str,
|
||||
fqdn: Optional[str],
|
||||
site_mode: bool,
|
||||
jt_exe: Optional[str],
|
||||
jt_enabled: bool,
|
||||
notify_systemd: Optional[str],
|
||||
handlers: str,
|
||||
readme_builder: Callable[..., str],
|
||||
) -> str:
|
||||
"""Render an Ansible role whose main purpose is managed files/dirs.
|
||||
|
||||
This covers apt_config, dnf_config, etc_custom, usr_local_custom, and
|
||||
extra_paths. Their harvested state shape is the same; only their README
|
||||
and optional handler differ.
|
||||
"""
|
||||
|
||||
role = snapshot.get("role_name", default_role)
|
||||
role_dir = os.path.join(roles_root, role)
|
||||
_write_role_scaffold(role_dir)
|
||||
|
||||
var_prefix = role
|
||||
managed_files = snapshot.get("managed_files", []) or []
|
||||
managed_dirs = snapshot.get("managed_dirs", []) or []
|
||||
excluded = snapshot.get("excluded", []) or []
|
||||
notes = snapshot.get("notes", []) or []
|
||||
|
||||
templated, jt_vars = _jinjify_managed_files(
|
||||
bundle_dir,
|
||||
role,
|
||||
role_dir,
|
||||
managed_files,
|
||||
jt_exe=jt_exe,
|
||||
jt_enabled=jt_enabled,
|
||||
overwrite_templates=not site_mode,
|
||||
)
|
||||
|
||||
if site_mode:
|
||||
_copy_artifacts(
|
||||
bundle_dir,
|
||||
role,
|
||||
_host_role_files_dir(out_dir, fqdn or "", role),
|
||||
exclude_rels=templated,
|
||||
)
|
||||
else:
|
||||
_copy_artifacts(
|
||||
bundle_dir,
|
||||
role,
|
||||
os.path.join(role_dir, "files"),
|
||||
exclude_rels=templated,
|
||||
)
|
||||
|
||||
files_var = _build_managed_files_var(
|
||||
managed_files,
|
||||
templated,
|
||||
notify_other=None,
|
||||
notify_systemd=notify_systemd,
|
||||
)
|
||||
dirs_var = _build_managed_dirs_var(managed_dirs)
|
||||
|
||||
jt_map = _yaml_load_mapping(jt_vars) if jt_vars.strip() else {}
|
||||
vars_map: Dict[str, Any] = {
|
||||
f"{var_prefix}_managed_files": files_var,
|
||||
f"{var_prefix}_managed_dirs": dirs_var,
|
||||
}
|
||||
vars_map = _merge_mappings_overwrite(vars_map, jt_map)
|
||||
|
||||
if site_mode:
|
||||
_write_role_defaults(
|
||||
role_dir,
|
||||
{f"{var_prefix}_managed_files": [], f"{var_prefix}_managed_dirs": []},
|
||||
)
|
||||
_write_hostvars(out_dir, fqdn or "", role, vars_map)
|
||||
else:
|
||||
_write_role_defaults(role_dir, vars_map)
|
||||
|
||||
tasks = "---\n" + _render_generic_files_tasks(
|
||||
var_prefix, include_restart_notify=False
|
||||
)
|
||||
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, "handlers", "main.yml"), "w", encoding="utf-8"
|
||||
) as f:
|
||||
f.write(handlers.rstrip() + "\n")
|
||||
|
||||
with open(os.path.join(role_dir, "meta", "main.yml"), "w", encoding="utf-8") as f:
|
||||
f.write("---\ndependencies: []\n")
|
||||
|
||||
readme = readme_builder(
|
||||
bundle_dir=bundle_dir,
|
||||
role=role,
|
||||
snapshot=snapshot,
|
||||
managed_files=managed_files,
|
||||
managed_dirs=managed_dirs,
|
||||
excluded=excluded,
|
||||
notes=notes,
|
||||
)
|
||||
with open(os.path.join(role_dir, "README.md"), "w", encoding="utf-8") as f:
|
||||
f.write(readme)
|
||||
|
||||
return role
|
||||
|
||||
|
||||
def _render_managed_file_roles(
|
||||
ctx: AnsibleManifestContext,
|
||||
manifest_plan: AnsibleManifestPlan,
|
||||
roles: Dict[str, Any],
|
||||
) -> None:
|
||||
"""Render file-centric singleton roles in the same loop style as Puppet."""
|
||||
|
||||
for spec in MANAGED_FILE_ROLE_SPECS:
|
||||
snapshot = roles.get(spec.key, {})
|
||||
if not isinstance(snapshot, dict):
|
||||
continue
|
||||
if not _managed_file_role_has_resources(snapshot, spec):
|
||||
continue
|
||||
_write_managed_files_role_from_spec(ctx, manifest_plan, snapshot, spec)
|
||||
601
enroll/ansible_renderer/roles/packages.py
Normal file
601
enroll/ansible_renderer/roles/packages.py
Normal file
|
|
@ -0,0 +1,601 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Dict, List, Set
|
||||
|
||||
from ..context import AnsibleManifestContext
|
||||
from ..jinjaturtle import _jinjify_managed_files
|
||||
from ..layout import (
|
||||
_copy_artifacts,
|
||||
_host_role_files_dir,
|
||||
_write_hostvars,
|
||||
_write_role_defaults,
|
||||
_write_role_scaffold,
|
||||
)
|
||||
from ..model import AnsibleManifestPlan, AnsibleRole, _section_role_name
|
||||
from ..tasks import (
|
||||
_render_generic_files_tasks,
|
||||
_render_grouped_systemd_tasks,
|
||||
_render_install_packages_tasks,
|
||||
)
|
||||
from ..vars import (
|
||||
_build_managed_dirs_var,
|
||||
_build_managed_files_var,
|
||||
_build_managed_links_var,
|
||||
)
|
||||
from ..yamlutil import _merge_mappings_overwrite, _yaml_load_mapping
|
||||
from ...role_names import avoid_reserved_role_name
|
||||
|
||||
|
||||
def _render_service_roles(
|
||||
ctx: AnsibleManifestContext,
|
||||
manifest_plan: AnsibleManifestPlan,
|
||||
services_to_manifest: List[Dict[str, Any]],
|
||||
) -> None:
|
||||
bundle_dir = ctx.bundle_dir
|
||||
out_dir = ctx.out_dir
|
||||
roles_root = ctx.roles_root
|
||||
fqdn = ctx.fqdn
|
||||
site_mode = ctx.site_mode
|
||||
jt_exe = ctx.jt_exe
|
||||
jt_enabled = ctx.jt_enabled
|
||||
|
||||
# -------------------------
|
||||
# Service roles
|
||||
# -------------------------
|
||||
for svc in services_to_manifest:
|
||||
source_role = svc["role_name"]
|
||||
role = avoid_reserved_role_name(source_role, prefix="service")
|
||||
unit = svc["unit"]
|
||||
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 []
|
||||
|
||||
ansible_role = AnsibleRole(role)
|
||||
ansible_role.add_service_snapshot(svc)
|
||||
|
||||
role_dir = os.path.join(roles_root, role)
|
||||
_write_role_scaffold(role_dir)
|
||||
|
||||
var_prefix = role
|
||||
|
||||
unit_state = ansible_role.services.get(unit, {})
|
||||
enabled_at_harvest = bool(unit_state.get("enabled"))
|
||||
desired_state = str(unit_state.get("state") or "stopped")
|
||||
|
||||
templated, jt_vars = _jinjify_managed_files(
|
||||
bundle_dir,
|
||||
source_role,
|
||||
role_dir,
|
||||
managed_files,
|
||||
jt_exe=jt_exe,
|
||||
jt_enabled=jt_enabled,
|
||||
overwrite_templates=not site_mode,
|
||||
)
|
||||
|
||||
# Copy only the non-templated artifacts.
|
||||
if site_mode:
|
||||
_copy_artifacts(
|
||||
bundle_dir,
|
||||
source_role,
|
||||
_host_role_files_dir(out_dir, fqdn or "", role),
|
||||
exclude_rels=templated,
|
||||
)
|
||||
else:
|
||||
_copy_artifacts(
|
||||
bundle_dir,
|
||||
source_role,
|
||||
os.path.join(role_dir, "files"),
|
||||
exclude_rels=templated,
|
||||
)
|
||||
|
||||
files_var = _build_managed_files_var(
|
||||
managed_files,
|
||||
templated,
|
||||
notify_other="Restart service",
|
||||
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 {}
|
||||
base_vars: Dict[str, Any] = {
|
||||
f"{var_prefix}_unit_name": 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,
|
||||
f"{var_prefix}_manage_unit": True,
|
||||
f"{var_prefix}_systemd_enabled": bool(enabled_at_harvest),
|
||||
f"{var_prefix}_systemd_state": desired_state,
|
||||
}
|
||||
base_vars = _merge_mappings_overwrite(base_vars, jt_map)
|
||||
|
||||
if site_mode:
|
||||
# Role defaults are host-agnostic/safe; all harvested state is in host_vars.
|
||||
_write_role_defaults(
|
||||
role_dir,
|
||||
{
|
||||
f"{var_prefix}_unit_name": unit,
|
||||
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",
|
||||
},
|
||||
)
|
||||
_write_hostvars(out_dir, fqdn or "", role, base_vars)
|
||||
else:
|
||||
_write_role_defaults(role_dir, base_vars)
|
||||
|
||||
handlers = f"""---
|
||||
- name: Run systemd daemon-reload
|
||||
ansible.builtin.systemd:
|
||||
daemon_reload: true
|
||||
no_log: "{{ enroll_hide_systemd_status | default(true) | bool }}"
|
||||
|
||||
- name: Restart service
|
||||
ansible.builtin.service:
|
||||
name: "{{{{ {var_prefix}_unit_name }}}}"
|
||||
state: restarted
|
||||
when:
|
||||
- {var_prefix}_manage_unit | default(false)
|
||||
- ({var_prefix}_systemd_state | default('stopped')) == 'started'
|
||||
"""
|
||||
with open(
|
||||
os.path.join(role_dir, "handlers", "main.yml"), "w", encoding="utf-8"
|
||||
) as f:
|
||||
f.write(handlers)
|
||||
|
||||
task_parts: List[str] = []
|
||||
task_parts.append("---\n" + _render_install_packages_tasks(role, var_prefix))
|
||||
|
||||
task_parts.append(
|
||||
_render_generic_files_tasks(var_prefix, include_restart_notify=True)
|
||||
)
|
||||
|
||||
task_parts.append(
|
||||
f"""- name: Probe whether systemd unit exists and is manageable
|
||||
ansible.builtin.systemd:
|
||||
name: "{{{{ {var_prefix}_unit_name }}}}"
|
||||
no_log: "{{{{ enroll_hide_systemd_status | default(true) | bool }}}}"
|
||||
check_mode: true
|
||||
register: _unit_probe
|
||||
failed_when: false
|
||||
changed_when: false
|
||||
when: {var_prefix}_manage_unit | default(false)
|
||||
|
||||
- name: Ensure unit enablement matches harvest
|
||||
ansible.builtin.systemd:
|
||||
name: "{{{{ {var_prefix}_unit_name }}}}"
|
||||
enabled: "{{{{ {var_prefix}_systemd_enabled | bool }}}}"
|
||||
no_log: "{{{{ enroll_hide_systemd_status | default(true) | bool }}}}"
|
||||
when:
|
||||
- {var_prefix}_manage_unit | default(false)
|
||||
- _unit_probe is succeeded
|
||||
|
||||
- name: Ensure unit running state matches harvest
|
||||
ansible.builtin.systemd:
|
||||
name: "{{{{ {var_prefix}_unit_name }}}}"
|
||||
state: "{{{{ {var_prefix}_systemd_state }}}}"
|
||||
no_log: "{{{{ enroll_hide_systemd_status | default(true) | bool }}}}"
|
||||
when:
|
||||
- {var_prefix}_manage_unit | default(false)
|
||||
- _unit_probe is succeeded
|
||||
"""
|
||||
)
|
||||
|
||||
tasks = "\n".join(task_parts).rstrip() + "\n"
|
||||
with open(
|
||||
os.path.join(role_dir, "tasks", "main.yml"), "w", encoding="utf-8"
|
||||
) as f:
|
||||
f.write(tasks)
|
||||
|
||||
with open(
|
||||
os.path.join(role_dir, "meta", "main.yml"), "w", encoding="utf-8"
|
||||
) as f:
|
||||
f.write("---\ndependencies: []\n")
|
||||
|
||||
excluded = svc.get("excluded", [])
|
||||
notes = svc.get("notes", [])
|
||||
readme = f"""# {role}
|
||||
|
||||
Generated from `{unit}`.
|
||||
|
||||
## Packages
|
||||
{os.linesep.join("- " + p for p in pkgs) or "- (none detected)"}
|
||||
|
||||
## 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)"}
|
||||
|
||||
## Notes
|
||||
{os.linesep.join("- " + n for n in notes) or "- (none)"}
|
||||
"""
|
||||
with open(os.path.join(role_dir, "README.md"), "w", encoding="utf-8") as f:
|
||||
f.write(readme)
|
||||
|
||||
manifest_plan.add("service", role)
|
||||
|
||||
|
||||
def _render_common_ansible_roles(
|
||||
ctx: AnsibleManifestContext,
|
||||
manifest_plan: AnsibleManifestPlan,
|
||||
common_role_groups: Dict[str, List[Dict[str, Any]]],
|
||||
package_roles: List[Dict[str, Any]],
|
||||
) -> List[str]:
|
||||
bundle_dir = ctx.bundle_dir
|
||||
roles_root = ctx.roles_root
|
||||
jt_exe = ctx.jt_exe
|
||||
jt_enabled = ctx.jt_enabled
|
||||
|
||||
common_tail_roles: List[str] = []
|
||||
|
||||
# -------------------------
|
||||
# Common package section/group roles
|
||||
#
|
||||
# Outside --fqdn/site mode, package and systemd-unit roles are grouped by
|
||||
# Debian Section or RPM Group by default. Managed config and unit state can
|
||||
# live in those section roles too; --no-common-roles preserves the historic
|
||||
# one-role-per-package/unit output, and --fqdn implies that mode because
|
||||
# grouped role contents would be unsafe across multiple harvested hosts.
|
||||
# -------------------------
|
||||
# -------------------------
|
||||
# Manually installed package roles
|
||||
# -------------------------
|
||||
occupied_roles: Set[str] = set(
|
||||
manifest_plan.roles("apt_config")
|
||||
+ manifest_plan.roles("dnf_config")
|
||||
+ manifest_plan.roles("users")
|
||||
+ manifest_plan.roles("flatpak")
|
||||
+ manifest_plan.roles("snap")
|
||||
+ manifest_plan.roles("service")
|
||||
+ manifest_plan.roles("firewall_runtime")
|
||||
+ manifest_plan.roles("sysctl")
|
||||
+ manifest_plan.roles("etc_custom")
|
||||
+ manifest_plan.roles("usr_local_custom")
|
||||
+ manifest_plan.roles("extra_paths")
|
||||
)
|
||||
for pr in package_roles:
|
||||
occupied_roles.add(
|
||||
avoid_reserved_role_name(str(pr.get("role_name") or ""), prefix="package")
|
||||
)
|
||||
|
||||
for section_label, entries in sorted(common_role_groups.items()):
|
||||
role = _section_role_name(section_label, occupied_roles)
|
||||
ansible_role = AnsibleRole(
|
||||
role,
|
||||
var_prefix=role,
|
||||
section_label=section_label,
|
||||
grouped=True,
|
||||
)
|
||||
for entry in entries:
|
||||
kind = entry.get("kind") or "package"
|
||||
snap = entry.get("snapshot") or {}
|
||||
if kind == "service":
|
||||
ansible_role.add_service_snapshot(snap)
|
||||
else:
|
||||
ansible_role.add_package_snapshot(snap)
|
||||
|
||||
role_dir = os.path.join(roles_root, role)
|
||||
_write_role_scaffold(role_dir)
|
||||
|
||||
var_prefix = ansible_role.var_prefix
|
||||
files_var: List[Dict[str, Any]] = []
|
||||
dirs_var: List[Dict[str, Any]] = []
|
||||
links_var: List[Dict[str, Any]] = []
|
||||
jt_combined: Dict[str, Any] = {}
|
||||
|
||||
seen_files: Set[tuple] = set()
|
||||
seen_dirs: Set[tuple] = set()
|
||||
seen_links: Set[tuple] = set()
|
||||
|
||||
for entry in ansible_role.entries:
|
||||
kind = entry.get("kind") or "package"
|
||||
snap = entry.get("snapshot") or {}
|
||||
source_role = str(snap.get("role_name") or "")
|
||||
managed_files = snap.get("managed_files", []) or []
|
||||
managed_dirs = snap.get("managed_dirs", []) or []
|
||||
managed_links = snap.get("managed_links", []) or []
|
||||
|
||||
templated: Set[str] = set()
|
||||
jt_vars = ""
|
||||
if managed_files and source_role:
|
||||
templated, jt_vars = _jinjify_managed_files(
|
||||
bundle_dir,
|
||||
source_role,
|
||||
role_dir,
|
||||
managed_files,
|
||||
jt_exe=jt_exe,
|
||||
jt_enabled=jt_enabled,
|
||||
overwrite_templates=True,
|
||||
)
|
||||
|
||||
_copy_artifacts(
|
||||
bundle_dir,
|
||||
source_role,
|
||||
os.path.join(role_dir, "files"),
|
||||
exclude_rels=templated,
|
||||
)
|
||||
|
||||
notify_other = "Restart managed services" if kind == "service" else None
|
||||
for item in _build_managed_files_var(
|
||||
managed_files,
|
||||
templated,
|
||||
notify_other=notify_other,
|
||||
notify_systemd="Run systemd daemon-reload",
|
||||
):
|
||||
key = (item.get("dest"), item.get("src_rel"), item.get("kind"))
|
||||
if key not in seen_files:
|
||||
seen_files.add(key)
|
||||
files_var.append(item)
|
||||
|
||||
for item in _build_managed_dirs_var(managed_dirs):
|
||||
key = (
|
||||
item.get("dest"),
|
||||
item.get("owner"),
|
||||
item.get("group"),
|
||||
item.get("mode"),
|
||||
)
|
||||
if key not in seen_dirs:
|
||||
seen_dirs.add(key)
|
||||
dirs_var.append(item)
|
||||
|
||||
for item in _build_managed_links_var(managed_links):
|
||||
key = (item.get("dest"), item.get("src"))
|
||||
if key not in seen_links:
|
||||
seen_links.add(key)
|
||||
links_var.append(item)
|
||||
|
||||
jt_map = _yaml_load_mapping(jt_vars) if jt_vars.strip() else {}
|
||||
jt_combined = _merge_mappings_overwrite(jt_combined, jt_map)
|
||||
|
||||
packages = ansible_role.sorted_packages
|
||||
files_var = sorted(files_var, key=lambda x: str(x.get("dest") or ""))
|
||||
dirs_var = sorted(dirs_var, key=lambda x: str(x.get("dest") or ""))
|
||||
links_var = sorted(links_var, key=lambda x: str(x.get("dest") or ""))
|
||||
systemd_units = ansible_role.systemd_units_var
|
||||
|
||||
base_vars: Dict[str, Any] = {
|
||||
f"{var_prefix}_packages": packages,
|
||||
f"{var_prefix}_managed_files": files_var,
|
||||
f"{var_prefix}_managed_dirs": dirs_var,
|
||||
f"{var_prefix}_managed_links": links_var,
|
||||
f"{var_prefix}_systemd_units": systemd_units,
|
||||
}
|
||||
base_vars = _merge_mappings_overwrite(base_vars, jt_combined)
|
||||
|
||||
_write_role_defaults(role_dir, base_vars)
|
||||
|
||||
if {"cron", "logrotate"}.intersection(ansible_role.packages):
|
||||
common_tail_roles.append(role)
|
||||
|
||||
handlers = (
|
||||
"""---
|
||||
- name: Run systemd daemon-reload
|
||||
ansible.builtin.systemd:
|
||||
daemon_reload: true
|
||||
no_log: "{{ enroll_hide_systemd_status | default(true) | bool }}"
|
||||
|
||||
- name: Restart managed services
|
||||
ansible.builtin.service:
|
||||
name: "{{ item.name }}"
|
||||
state: restarted
|
||||
loop: "{{ """
|
||||
+ f"{var_prefix}_systemd_units"
|
||||
+ """ | default([]) }}"
|
||||
when:
|
||||
- item.manage | default(false)
|
||||
- (item.state | default('stopped')) == 'started'
|
||||
"""
|
||||
)
|
||||
with open(
|
||||
os.path.join(role_dir, "handlers", "main.yml"), "w", encoding="utf-8"
|
||||
) as f:
|
||||
f.write(handlers)
|
||||
|
||||
task_parts: List[str] = []
|
||||
task_parts.append("---\n" + _render_install_packages_tasks(role, var_prefix))
|
||||
task_parts.append(
|
||||
_render_generic_files_tasks(var_prefix, include_restart_notify=True)
|
||||
)
|
||||
task_parts.append(_render_grouped_systemd_tasks(var_prefix))
|
||||
|
||||
tasks = "\n".join(task_parts).rstrip() + "\n"
|
||||
with open(
|
||||
os.path.join(role_dir, "tasks", "main.yml"), "w", encoding="utf-8"
|
||||
) as f:
|
||||
f.write(tasks)
|
||||
|
||||
with open(
|
||||
os.path.join(role_dir, "meta", "main.yml"), "w", encoding="utf-8"
|
||||
) as f:
|
||||
f.write("---\ndependencies: []\n")
|
||||
|
||||
readme = f"""# {role}
|
||||
|
||||
Common role for package section/group `{section_label}`.
|
||||
|
||||
## Origin roles
|
||||
{os.linesep.join("- " + line for line in sorted(ansible_role.origin_lines)) or "- (none)"}
|
||||
|
||||
## Packages
|
||||
{os.linesep.join("- " + p for p in packages) or "- (none)"}
|
||||
|
||||
## Managed files
|
||||
{os.linesep.join("- " + mf["dest"] for mf in files_var) or "- (none)"}
|
||||
|
||||
## Managed symlinks
|
||||
{os.linesep.join("- " + ml["dest"] + " -> " + ml["src"] for ml in links_var) or "- (none)"}
|
||||
|
||||
## Systemd units
|
||||
{os.linesep.join("- " + u["name"] + " (enabled=" + str(u["enabled"]).lower() + ", state=" + u["state"] + ")" for u in systemd_units) or "- (none)"}
|
||||
|
||||
## Excluded (possible secrets / unsafe)
|
||||
{os.linesep.join("- " + e.get("path", "") + " (" + e.get("reason", "") + ")" for e in ansible_role.excluded) or "- (none)"}
|
||||
|
||||
## Notes
|
||||
{os.linesep.join("- " + n for n in ansible_role.notes) or "- (none)"}
|
||||
"""
|
||||
with open(os.path.join(role_dir, "README.md"), "w", encoding="utf-8") as f:
|
||||
f.write(readme)
|
||||
|
||||
manifest_plan.add("package", role)
|
||||
|
||||
return common_tail_roles
|
||||
|
||||
|
||||
def _render_package_roles(
|
||||
ctx: AnsibleManifestContext,
|
||||
manifest_plan: AnsibleManifestPlan,
|
||||
package_roles: List[Dict[str, Any]],
|
||||
) -> None:
|
||||
bundle_dir = ctx.bundle_dir
|
||||
out_dir = ctx.out_dir
|
||||
roles_root = ctx.roles_root
|
||||
fqdn = ctx.fqdn
|
||||
site_mode = ctx.site_mode
|
||||
jt_exe = ctx.jt_exe
|
||||
jt_enabled = ctx.jt_enabled
|
||||
|
||||
# Process package roles (those with configuration files)
|
||||
for pr in package_roles:
|
||||
source_role = pr["role_name"]
|
||||
role = avoid_reserved_role_name(source_role, prefix="package")
|
||||
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 []
|
||||
|
||||
ansible_role = AnsibleRole(role)
|
||||
ansible_role.add_package_snapshot(pr)
|
||||
|
||||
role_dir = os.path.join(roles_root, role)
|
||||
_write_role_scaffold(role_dir)
|
||||
|
||||
var_prefix = role
|
||||
|
||||
templated, jt_vars = _jinjify_managed_files(
|
||||
bundle_dir,
|
||||
source_role,
|
||||
role_dir,
|
||||
managed_files,
|
||||
jt_exe=jt_exe,
|
||||
jt_enabled=jt_enabled,
|
||||
overwrite_templates=not site_mode,
|
||||
)
|
||||
|
||||
# Copy only the non-templated artifacts.
|
||||
if site_mode:
|
||||
_copy_artifacts(
|
||||
bundle_dir,
|
||||
source_role,
|
||||
_host_role_files_dir(out_dir, fqdn or "", role),
|
||||
exclude_rels=templated,
|
||||
)
|
||||
else:
|
||||
_copy_artifacts(
|
||||
bundle_dir,
|
||||
source_role,
|
||||
os.path.join(role_dir, "files"),
|
||||
exclude_rels=templated,
|
||||
)
|
||||
|
||||
pkgs = ansible_role.sorted_packages
|
||||
|
||||
files_var = _build_managed_files_var(
|
||||
managed_files,
|
||||
templated,
|
||||
notify_other=None,
|
||||
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 {}
|
||||
base_vars: Dict[str, Any] = {
|
||||
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)
|
||||
|
||||
if site_mode:
|
||||
_write_role_defaults(
|
||||
role_dir,
|
||||
{
|
||||
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)
|
||||
else:
|
||||
_write_role_defaults(role_dir, base_vars)
|
||||
|
||||
handlers = """---
|
||||
- name: Run systemd daemon-reload
|
||||
ansible.builtin.systemd:
|
||||
daemon_reload: true
|
||||
no_log: "{{ enroll_hide_systemd_status | default(true) | bool }}"
|
||||
"""
|
||||
with open(
|
||||
os.path.join(role_dir, "handlers", "main.yml"), "w", encoding="utf-8"
|
||||
) as f:
|
||||
f.write(handlers)
|
||||
|
||||
task_parts: List[str] = []
|
||||
task_parts.append("---\n" + _render_install_packages_tasks(role, var_prefix))
|
||||
task_parts.append(
|
||||
_render_generic_files_tasks(var_prefix, include_restart_notify=False)
|
||||
)
|
||||
|
||||
tasks = "\n".join(task_parts).rstrip() + "\n"
|
||||
with open(
|
||||
os.path.join(role_dir, "tasks", "main.yml"), "w", encoding="utf-8"
|
||||
) as f:
|
||||
f.write(tasks)
|
||||
|
||||
with open(
|
||||
os.path.join(role_dir, "meta", "main.yml"), "w", encoding="utf-8"
|
||||
) as f:
|
||||
f.write("---\ndependencies: []\n")
|
||||
|
||||
excluded = pr.get("excluded", [])
|
||||
notes = pr.get("notes", [])
|
||||
readme = f"""# {role}
|
||||
|
||||
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)"}
|
||||
|
||||
## Notes
|
||||
{os.linesep.join("- " + n for n in notes) or "- (none)"}
|
||||
|
||||
> Note: package roles (those not discovered via a systemd service) do not attempt to restart or enable services automatically.
|
||||
"""
|
||||
with open(os.path.join(role_dir, "README.md"), "w", encoding="utf-8") as f:
|
||||
f.write(readme)
|
||||
|
||||
manifest_plan.add("package", role)
|
||||
219
enroll/ansible_renderer/roles/runtime.py
Normal file
219
enroll/ansible_renderer/roles/runtime.py
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Dict
|
||||
|
||||
from ..context import AnsibleManifestContext
|
||||
from ..layout import (
|
||||
_copy_artifacts,
|
||||
_host_role_files_dir,
|
||||
_write_hostvars,
|
||||
_write_role_defaults,
|
||||
_write_role_scaffold,
|
||||
)
|
||||
from ..model import AnsibleManifestPlan
|
||||
from ..tasks import (
|
||||
_render_firewall_runtime_tasks,
|
||||
_render_install_packages_tasks,
|
||||
_render_sysctl_handlers,
|
||||
_render_sysctl_tasks,
|
||||
)
|
||||
|
||||
|
||||
def _render_sysctl_role(
|
||||
ctx: AnsibleManifestContext,
|
||||
manifest_plan: AnsibleManifestPlan,
|
||||
sysctl_snapshot: Dict[str, Any],
|
||||
) -> None:
|
||||
if not (sysctl_snapshot and (sysctl_snapshot.get("managed_files") or [])):
|
||||
return
|
||||
|
||||
role = sysctl_snapshot.get("role_name", "sysctl")
|
||||
role_dir = os.path.join(ctx.roles_root, role)
|
||||
_write_role_scaffold(role_dir)
|
||||
|
||||
var_prefix = role
|
||||
managed_files = sysctl_snapshot.get("managed_files", []) or []
|
||||
conf_src_rel = ""
|
||||
for mf in managed_files:
|
||||
if mf.get("path") == "/etc/sysctl.d/99-enroll.conf":
|
||||
conf_src_rel = mf.get("src_rel") or ""
|
||||
break
|
||||
if not conf_src_rel and managed_files:
|
||||
conf_src_rel = managed_files[0].get("src_rel") or ""
|
||||
|
||||
parameters = sysctl_snapshot.get("parameters", {}) or {}
|
||||
notes = sysctl_snapshot.get("notes", []) or []
|
||||
|
||||
if ctx.site_mode:
|
||||
_copy_artifacts(
|
||||
ctx.bundle_dir,
|
||||
role,
|
||||
_host_role_files_dir(ctx.out_dir, ctx.fqdn or "", role),
|
||||
)
|
||||
else:
|
||||
_copy_artifacts(ctx.bundle_dir, role, os.path.join(role_dir, "files"))
|
||||
|
||||
vars_map: Dict[str, Any] = {
|
||||
f"{var_prefix}_conf_src_rel": conf_src_rel,
|
||||
f"{var_prefix}_apply": True,
|
||||
f"{var_prefix}_ignore_apply_errors": True,
|
||||
}
|
||||
|
||||
if ctx.site_mode:
|
||||
_write_role_defaults(
|
||||
role_dir,
|
||||
{
|
||||
f"{var_prefix}_conf_src_rel": "",
|
||||
f"{var_prefix}_apply": True,
|
||||
f"{var_prefix}_ignore_apply_errors": True,
|
||||
},
|
||||
)
|
||||
_write_hostvars(ctx.out_dir, ctx.fqdn or "", role, vars_map)
|
||||
else:
|
||||
_write_role_defaults(role_dir, vars_map)
|
||||
|
||||
tasks = "---\n" + _render_sysctl_tasks(var_prefix)
|
||||
with open(os.path.join(role_dir, "tasks", "main.yml"), "w", encoding="utf-8") as f:
|
||||
f.write(tasks.rstrip() + "\n")
|
||||
|
||||
handlers_dir = os.path.join(role_dir, "handlers")
|
||||
os.makedirs(handlers_dir, exist_ok=True)
|
||||
with open(os.path.join(handlers_dir, "main.yml"), "w", encoding="utf-8") as f:
|
||||
f.write(_render_sysctl_handlers(var_prefix))
|
||||
|
||||
with open(os.path.join(role_dir, "meta", "main.yml"), "w", encoding="utf-8") as f:
|
||||
f.write("---\ndependencies: []\n")
|
||||
|
||||
param_count = len(parameters) if isinstance(parameters, dict) else 0
|
||||
sample_params = []
|
||||
if isinstance(parameters, dict):
|
||||
sample_params = sorted(parameters.keys())[:25]
|
||||
|
||||
readme = f"""# {role}
|
||||
|
||||
Generated from live writable sysctl state captured during harvest.
|
||||
|
||||
This role deploys the captured values to `/etc/sysctl.d/99-enroll.conf`. The generated file intentionally contains writable, single-line sysctl values only; read-only, multiline, action-like, and host-identity keys are skipped to avoid creating a noisy or brittle boot-time configuration.
|
||||
|
||||
## Captured parameters
|
||||
|
||||
Captured parameter count: {param_count}
|
||||
|
||||
{os.linesep.join("- " + x for x in sample_params) or "- (none)"}
|
||||
|
||||
{"- ..." if param_count > len(sample_params) else ""}
|
||||
|
||||
## Notes
|
||||
{os.linesep.join("- " + n for n in notes) or "- (none)"}
|
||||
|
||||
## Safety notes
|
||||
- `sysctl_apply` defaults to `true` and applies `/etc/sysctl.d/99-enroll.conf` when the file changes.
|
||||
- `sysctl_ignore_apply_errors` defaults to `true`, because sysctl availability and writability can vary across kernels, containers, and hardware.
|
||||
- Review this role before applying it broadly across unlike hosts.
|
||||
"""
|
||||
with open(os.path.join(role_dir, "README.md"), "w", encoding="utf-8") as f:
|
||||
f.write(readme)
|
||||
|
||||
manifest_plan.add("sysctl", role)
|
||||
|
||||
|
||||
def _render_firewall_runtime_role(
|
||||
ctx: AnsibleManifestContext,
|
||||
manifest_plan: AnsibleManifestPlan,
|
||||
firewall_runtime_snapshot: Dict[str, Any],
|
||||
) -> None:
|
||||
if not (
|
||||
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")
|
||||
)
|
||||
):
|
||||
return
|
||||
|
||||
role = firewall_runtime_snapshot.get("role_name", "firewall_runtime")
|
||||
role_dir = os.path.join(ctx.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 []
|
||||
|
||||
if ctx.site_mode:
|
||||
_copy_artifacts(
|
||||
ctx.bundle_dir,
|
||||
role,
|
||||
_host_role_files_dir(ctx.out_dir, ctx.fqdn or "", role),
|
||||
)
|
||||
else:
|
||||
_copy_artifacts(ctx.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 ctx.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(ctx.out_dir, ctx.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)
|
||||
|
||||
manifest_plan.add("firewall_runtime", role)
|
||||
434
enroll/ansible_renderer/roles/users.py
Normal file
434
enroll/ansible_renderer/roles/users.py
Normal file
|
|
@ -0,0 +1,434 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from ..context import AnsibleManifestContext
|
||||
from ..layout import (
|
||||
_copy_artifacts,
|
||||
_ensure_requirements_yaml,
|
||||
_host_role_files_dir,
|
||||
_write_hostvars,
|
||||
_write_role_defaults,
|
||||
_write_role_scaffold,
|
||||
)
|
||||
from ..model import AnsibleManifestPlan
|
||||
from ..vars import _normalise_flatpak_item, _normalise_flatpak_remote
|
||||
|
||||
|
||||
def _render_users_role(
|
||||
ctx: AnsibleManifestContext,
|
||||
manifest_plan: AnsibleManifestPlan,
|
||||
users_snapshot: Dict[str, Any],
|
||||
) -> None:
|
||||
bundle_dir = ctx.bundle_dir
|
||||
out_dir = ctx.out_dir
|
||||
roles_root = ctx.roles_root
|
||||
fqdn = ctx.fqdn
|
||||
site_mode = ctx.site_mode
|
||||
|
||||
# -------------------------
|
||||
# Users role (non-system users)
|
||||
# -------------------------
|
||||
if users_snapshot:
|
||||
role = users_snapshot.get("role_name", "users")
|
||||
role_dir = os.path.join(roles_root, role)
|
||||
_write_role_scaffold(role_dir)
|
||||
|
||||
# Users role includes harvested SSH-related files; in site mode keep them
|
||||
# host-specific to avoid cross-host clobber.
|
||||
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"))
|
||||
|
||||
users = users_snapshot.get("users", [])
|
||||
managed_files = users_snapshot.get("managed_files", [])
|
||||
excluded = users_snapshot.get("excluded", [])
|
||||
notes = users_snapshot.get("notes", [])
|
||||
|
||||
# Build groups list and a simplified user dict list suitable for loops
|
||||
group_names: List[str] = []
|
||||
group_set = set()
|
||||
users_data: List[Dict[str, Any]] = []
|
||||
for u in users:
|
||||
name = u.get("name")
|
||||
if not name:
|
||||
continue
|
||||
pg = u.get("primary_group") or name
|
||||
home = u.get("home") or f"/home/{name}"
|
||||
sshdir = home.rstrip("/") + "/.ssh"
|
||||
supp = u.get("supplementary_groups") or []
|
||||
if pg:
|
||||
group_set.add(pg)
|
||||
for g in supp:
|
||||
if g:
|
||||
group_set.add(g)
|
||||
|
||||
users_data.append(
|
||||
{
|
||||
"name": name,
|
||||
"uid": u.get("uid"),
|
||||
"primary_group": pg,
|
||||
"home": home,
|
||||
"ssh_dir": sshdir,
|
||||
"shell": u.get("shell"),
|
||||
"gecos": u.get("gecos"),
|
||||
"supplementary_groups": sorted(set(supp)),
|
||||
}
|
||||
)
|
||||
|
||||
group_names = sorted(group_set)
|
||||
|
||||
# User-managed files (authorized_keys plus dangerous-mode shell dotfiles).
|
||||
# Keep the variable name for compatibility with existing generated data.
|
||||
ssh_files: List[Dict[str, Any]] = []
|
||||
for mf in managed_files:
|
||||
dest = mf.get("path") or ""
|
||||
src_rel = mf.get("src_rel") or ""
|
||||
if not dest or not src_rel:
|
||||
continue
|
||||
|
||||
owner = "root"
|
||||
group = "root"
|
||||
for u in users_data:
|
||||
home_prefix = (u.get("home") or "").rstrip("/") + "/"
|
||||
if home_prefix and dest.startswith(home_prefix):
|
||||
owner = str(u.get("name") or "root")
|
||||
group = str(u.get("primary_group") or owner)
|
||||
break
|
||||
|
||||
# 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,
|
||||
"src_rel": src_rel,
|
||||
"owner": owner,
|
||||
"group": group,
|
||||
"mode": mode,
|
||||
}
|
||||
)
|
||||
|
||||
# Only create .ssh directories for users that actually have harvested
|
||||
# files under .ssh. This mirrors Puppet's behaviour and avoids creating
|
||||
# empty SSH directories merely because a user account exists.
|
||||
ssh_dirs_by_dest: Dict[str, Dict[str, Any]] = {}
|
||||
for item in ssh_files:
|
||||
dest = str(item.get("dest") or "")
|
||||
if not dest:
|
||||
continue
|
||||
for user in users_data:
|
||||
ssh_dir = str(user.get("ssh_dir") or "").rstrip("/")
|
||||
if not ssh_dir or not dest.startswith(ssh_dir + "/"):
|
||||
continue
|
||||
ssh_dirs_by_dest.setdefault(
|
||||
ssh_dir,
|
||||
{
|
||||
"dest": ssh_dir,
|
||||
"owner": str(user.get("name") or item.get("owner") or "root"),
|
||||
"group": str(
|
||||
user.get("primary_group") or item.get("group") or "root"
|
||||
),
|
||||
"mode": "0700",
|
||||
},
|
||||
)
|
||||
break
|
||||
ssh_dirs = sorted(
|
||||
ssh_dirs_by_dest.values(), key=lambda item: str(item.get("dest") or "")
|
||||
)
|
||||
|
||||
# Build Flatpak and Snap lists. Flatpak can be installed system-wide or
|
||||
# per-user. Snap packages are system-wide; per-user ~/snap/* directories
|
||||
# are runtime/user data and are not treated as install sources.
|
||||
users_flatpaks: List[Dict[str, Any]] = []
|
||||
user_flatpak_map = users_snapshot.get("user_flatpaks", {}) or {}
|
||||
home_by_user = {
|
||||
str(u.get("name")): str(u.get("home") or "") for u in users_data
|
||||
}
|
||||
for uname, flatpaks in user_flatpak_map.items():
|
||||
for fp in flatpaks or []:
|
||||
users_flatpaks.append(
|
||||
_normalise_flatpak_item(
|
||||
fp,
|
||||
method="user",
|
||||
user=str(uname),
|
||||
home=home_by_user.get(str(uname)) or None,
|
||||
)
|
||||
)
|
||||
|
||||
flatpak_remotes = [
|
||||
_normalise_flatpak_remote(r)
|
||||
for r in (users_snapshot.get("user_flatpak_remotes", []) or [])
|
||||
]
|
||||
users_needs_community = bool(flatpak_remotes or users_flatpaks)
|
||||
if users_needs_community:
|
||||
_ensure_requirements_yaml(os.path.join(out_dir, "requirements.yml"))
|
||||
|
||||
# Variables are host-specific in site mode; in non-site mode they live in role defaults.
|
||||
if site_mode:
|
||||
_write_role_defaults(
|
||||
role_dir,
|
||||
{
|
||||
"users_groups": [],
|
||||
"users_users": [],
|
||||
"users_ssh_dirs": [],
|
||||
"users_ssh_files": [],
|
||||
"users_flatpaks": [],
|
||||
"users_flatpak_remotes": [],
|
||||
},
|
||||
)
|
||||
_write_hostvars(
|
||||
out_dir,
|
||||
fqdn or "",
|
||||
role,
|
||||
{
|
||||
"users_groups": group_names,
|
||||
"users_users": users_data,
|
||||
"users_ssh_dirs": ssh_dirs,
|
||||
"users_ssh_files": ssh_files,
|
||||
"users_flatpaks": users_flatpaks,
|
||||
"users_flatpak_remotes": flatpak_remotes,
|
||||
},
|
||||
)
|
||||
else:
|
||||
_write_role_defaults(
|
||||
role_dir,
|
||||
{
|
||||
"users_groups": group_names,
|
||||
"users_users": users_data,
|
||||
"users_ssh_dirs": ssh_dirs,
|
||||
"users_ssh_files": ssh_files,
|
||||
"users_flatpaks": users_flatpaks,
|
||||
"users_flatpak_remotes": flatpak_remotes,
|
||||
},
|
||||
)
|
||||
|
||||
with open(
|
||||
os.path.join(role_dir, "meta", "main.yml"), "w", encoding="utf-8"
|
||||
) as f:
|
||||
if users_needs_community:
|
||||
f.write(
|
||||
"---\n"
|
||||
"dependencies: []\n"
|
||||
"collections:\n"
|
||||
" - community.general\n"
|
||||
)
|
||||
else:
|
||||
f.write("---\ndependencies: []\n")
|
||||
|
||||
# tasks (data-driven)
|
||||
users_tasks = """---
|
||||
|
||||
- name: Ensure groups exist
|
||||
ansible.builtin.group:
|
||||
name: "{{ item }}"
|
||||
state: present
|
||||
loop: "{{ users_groups | default([]) }}"
|
||||
|
||||
- name: Ensure users exist
|
||||
ansible.builtin.user:
|
||||
name: "{{ item.name }}"
|
||||
uid: "{{ item.uid | default(omit) }}"
|
||||
group: "{{ item.primary_group }}"
|
||||
home: "{{ item.home }}"
|
||||
create_home: true
|
||||
shell: "{{ item.shell | default(omit) }}"
|
||||
comment: "{{ item.gecos | default(omit) }}"
|
||||
state: present
|
||||
loop: "{{ users_users | default([]) }}"
|
||||
|
||||
- name: Ensure users supplementary groups
|
||||
ansible.builtin.user:
|
||||
name: "{{ item.name }}"
|
||||
groups: "{{ item.supplementary_groups | default([]) | join(',') }}"
|
||||
append: true
|
||||
loop: "{{ users_users | default([]) }}"
|
||||
when: (item.supplementary_groups | default([])) | length > 0
|
||||
|
||||
- name: Ensure .ssh directories exist for managed SSH files
|
||||
ansible.builtin.file:
|
||||
path: "{{ item.dest }}"
|
||||
state: directory
|
||||
owner: "{{ item.owner }}"
|
||||
group: "{{ item.group }}"
|
||||
mode: "{{ item.mode }}"
|
||||
loop: "{{ users_ssh_dirs | default([]) }}"
|
||||
|
||||
- name: Deploy user-managed files
|
||||
vars:
|
||||
_enroll_ff:
|
||||
files:
|
||||
- "{{ inventory_dir }}/host_vars/{{ inventory_hostname }}/{{ role_name }}/.files/{{ item.src_rel }}"
|
||||
- "{{ role_path }}/files/{{ item.src_rel }}"
|
||||
ansible.builtin.copy:
|
||||
src: "{{ lookup('ansible.builtin.first_found', _enroll_ff) }}"
|
||||
dest: "{{ item.dest }}"
|
||||
owner: "{{ item.owner }}"
|
||||
group: "{{ item.group }}"
|
||||
mode: "{{ item.mode }}"
|
||||
loop: "{{ users_ssh_files | default([]) }}"
|
||||
"""
|
||||
|
||||
if flatpak_remotes or users_flatpaks:
|
||||
users_tasks += """
|
||||
- name: Ensure user Flatpak remotes exist
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- flatpak
|
||||
- remote-add
|
||||
- --user
|
||||
- --if-not-exists
|
||||
- "{{ item.name }}"
|
||||
- "{{ item.url }}"
|
||||
loop: "{{ users_flatpak_remotes | default([]) | selectattr('method', 'equalto', 'user') | list }}"
|
||||
when:
|
||||
- item.name is defined
|
||||
- item.url is defined
|
||||
- item.url | length > 0
|
||||
- item.user is defined
|
||||
become: true
|
||||
become_user: "{{ item.user }}"
|
||||
environment:
|
||||
HOME: "{{ item.home | default('/home/' ~ item.user, true) }}"
|
||||
XDG_DATA_HOME: "{{ (item.home | default('/home/' ~ item.user, true)) ~ '/.local/share' }}"
|
||||
changed_when: false
|
||||
|
||||
- name: Install user Flatpaks
|
||||
community.general.flatpak:
|
||||
name:
|
||||
- "{{ item.name }}"
|
||||
state: present
|
||||
method: user
|
||||
remote: "{{ item.remote | default(omit) }}"
|
||||
from_url: "{{ item.from_url | default(omit) }}"
|
||||
loop: "{{ users_flatpaks | default([]) }}"
|
||||
when:
|
||||
- item.name is defined
|
||||
- item.name | length > 0
|
||||
- item.user is defined
|
||||
become: true
|
||||
become_user: "{{ item.user }}"
|
||||
environment:
|
||||
HOME: "{{ item.home | default('/home/' ~ item.user, true) }}"
|
||||
XDG_DATA_HOME: "{{ (item.home | default('/home/' ~ item.user, true)) ~ '/.local/share' }}"
|
||||
"""
|
||||
|
||||
with open(
|
||||
os.path.join(role_dir, "tasks", "main.yml"), "w", encoding="utf-8"
|
||||
) as f:
|
||||
f.write(users_tasks)
|
||||
|
||||
with open(
|
||||
os.path.join(role_dir, "handlers", "main.yml"), "w", encoding="utf-8"
|
||||
) as f:
|
||||
f.write("---\n")
|
||||
|
||||
def _fmt_app_list(items: List[Dict[str, Any]]) -> str:
|
||||
lines = []
|
||||
for item in items:
|
||||
name = item.get("name")
|
||||
if not name:
|
||||
continue
|
||||
detail_parts = []
|
||||
for key in ("remote", "channel", "revision", "branch", "arch"):
|
||||
value = item.get(key)
|
||||
if value not in (None, "", []):
|
||||
detail_parts.append(f"{key}={value}")
|
||||
for key in ("classic", "devmode", "dangerous"):
|
||||
if item.get(key):
|
||||
detail_parts.append(key)
|
||||
details = f" ({', '.join(detail_parts)})" if detail_parts else ""
|
||||
lines.append(f"- {name}{details}")
|
||||
return "\n".join(lines) or "- (none)"
|
||||
|
||||
def _fmt_user_flatpaks(items: List[Dict[str, Any]]) -> str:
|
||||
lines = []
|
||||
for item in items:
|
||||
name = item.get("name")
|
||||
user = item.get("user")
|
||||
if not name or not user:
|
||||
continue
|
||||
detail_parts = []
|
||||
for key in ("remote", "branch", "arch"):
|
||||
value = item.get(key)
|
||||
if value not in (None, "", []):
|
||||
detail_parts.append(f"{key}={value}")
|
||||
details = f" ({', '.join(detail_parts)})" if detail_parts else ""
|
||||
lines.append(f"- {user}: {name}{details}")
|
||||
return "\n".join(lines) or "- (none)"
|
||||
|
||||
def _fmt_remotes(items: List[Dict[str, Any]]) -> str:
|
||||
lines = []
|
||||
for item in items:
|
||||
name = item.get("name")
|
||||
url = item.get("url")
|
||||
method = item.get("method") or "system"
|
||||
user = item.get("user")
|
||||
if not name or not url:
|
||||
continue
|
||||
owner = f"user={user}" if user else "system"
|
||||
lines.append(f"- {name} ({method}, {owner}): {url}")
|
||||
return "\n".join(lines) or "- (none)"
|
||||
|
||||
readme = (
|
||||
"""# users
|
||||
|
||||
Generated non-system user accounts, SSH public material, and per-user Flatpak
|
||||
applications/remotes.
|
||||
|
||||
**Note:** User Flatpak tasks require the `community.general` Ansible collection.
|
||||
Install it with: `ansible-galaxy collection install -r requirements.yml`.
|
||||
|
||||
Flatpak `remote` is harvested from the installed deployment where detectable.
|
||||
The original `.flatpakref` URL is generally not preserved by Flatpak after
|
||||
installation, so `from_url` is only emitted if a future/hand-edited state file
|
||||
contains it.
|
||||
|
||||
|
||||
## Users
|
||||
"""
|
||||
+ (
|
||||
"\n".join([f"- {u.get('name')} (uid {u.get('uid')})" for u in users])
|
||||
or "- (none)"
|
||||
)
|
||||
+ """\n
|
||||
## Included SSH files
|
||||
"""
|
||||
+ (
|
||||
"\n".join(
|
||||
[f"- {mf.get('path')} ({mf.get('reason')})" for mf in managed_files]
|
||||
)
|
||||
or "- (none)"
|
||||
)
|
||||
+ """\n
|
||||
## Flatpak remotes
|
||||
"""
|
||||
+ _fmt_remotes(flatpak_remotes)
|
||||
+ """\n
|
||||
## User Flatpaks
|
||||
"""
|
||||
+ _fmt_user_flatpaks(users_flatpaks)
|
||||
+ """\n
|
||||
## Excluded
|
||||
"""
|
||||
+ (
|
||||
"\n".join([f"- {e.get('path')} ({e.get('reason')})" for e in excluded])
|
||||
or "- (none)"
|
||||
)
|
||||
+ """\n
|
||||
## Notes
|
||||
"""
|
||||
+ ("\n".join([f"- {n}" for n in notes]) or "- (none)")
|
||||
+ """\n"""
|
||||
)
|
||||
with open(os.path.join(role_dir, "README.md"), "w", encoding="utf-8") as f:
|
||||
f.write(readme)
|
||||
|
||||
manifest_plan.add("users", role)
|
||||
290
enroll/ansible_renderer/tasks.py
Normal file
290
enroll/ansible_renderer/tasks.py
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
from __future__ import annotations
|
||||
|
||||
|
||||
def _render_generic_files_tasks(
|
||||
var_prefix: str, *, include_restart_notify: bool
|
||||
) -> str:
|
||||
"""Render generic tasks to deploy <var_prefix>_managed_files safely."""
|
||||
# Using first_found makes roles work in both modes:
|
||||
# - site-mode: inventory/host_vars/<host>/<role>/.files/...
|
||||
# - non-site: roles/<role>/files/...
|
||||
return f"""- name: Ensure managed directories exist (preserve owner/group/mode)
|
||||
ansible.builtin.file:
|
||||
path: "{{{{ item.dest }}}}"
|
||||
state: directory
|
||||
owner: "{{{{ item.owner }}}}"
|
||||
group: "{{{{ item.group }}}}"
|
||||
mode: "{{{{ item.mode }}}}"
|
||||
loop: "{{{{ {var_prefix}_managed_dirs | default([]) }}}}"
|
||||
|
||||
- name: Deploy any systemd unit files (templates)
|
||||
ansible.builtin.template:
|
||||
src: "{{{{ item.src_rel }}}}.j2"
|
||||
dest: "{{{{ item.dest }}}}"
|
||||
owner: "{{{{ item.owner }}}}"
|
||||
group: "{{{{ item.group }}}}"
|
||||
mode: "{{{{ item.mode }}}}"
|
||||
loop: >-
|
||||
{{{{ {var_prefix}_managed_files | default([])
|
||||
| selectattr('is_systemd_unit', 'equalto', true)
|
||||
| selectattr('kind', 'equalto', 'template')
|
||||
| list }}}}
|
||||
notify: "{{{{ item.notify | default([]) }}}}"
|
||||
|
||||
- name: Deploy any systemd unit files (raw files)
|
||||
vars:
|
||||
_enroll_ff:
|
||||
files:
|
||||
- "{{{{ inventory_dir }}}}/host_vars/{{{{ inventory_hostname }}}}/{{{{ role_name }}}}/.files/{{{{ item.src_rel }}}}"
|
||||
- "{{{{ role_path }}}}/files/{{{{ item.src_rel }}}}"
|
||||
ansible.builtin.copy:
|
||||
src: "{{{{ lookup('ansible.builtin.first_found', _enroll_ff) }}}}"
|
||||
dest: "{{{{ item.dest }}}}"
|
||||
owner: "{{{{ item.owner }}}}"
|
||||
group: "{{{{ item.group }}}}"
|
||||
mode: "{{{{ item.mode }}}}"
|
||||
loop: >-
|
||||
{{{{ {var_prefix}_managed_files | default([])
|
||||
| selectattr('is_systemd_unit', 'equalto', true)
|
||||
| selectattr('kind', 'equalto', 'copy')
|
||||
| list }}}}
|
||||
notify: "{{{{ item.notify | default([]) }}}}"
|
||||
|
||||
- name: Reload systemd to pick up unit changes
|
||||
ansible.builtin.meta: flush_handlers
|
||||
when: >-
|
||||
({var_prefix}_managed_files | default([])
|
||||
| selectattr('is_systemd_unit', 'equalto', true)
|
||||
| list
|
||||
| length) > 0
|
||||
|
||||
- name: Deploy any other managed files (templates)
|
||||
ansible.builtin.template:
|
||||
src: "{{{{ item.src_rel }}}}.j2"
|
||||
dest: "{{{{ item.dest }}}}"
|
||||
owner: "{{{{ item.owner }}}}"
|
||||
group: "{{{{ item.group }}}}"
|
||||
mode: "{{{{ item.mode }}}}"
|
||||
loop: >-
|
||||
{{{{ {var_prefix}_managed_files | default([])
|
||||
| selectattr('is_systemd_unit', 'equalto', false)
|
||||
| selectattr('kind', 'equalto', 'template')
|
||||
| list }}}}
|
||||
notify: "{{{{ item.notify | default([]) }}}}"
|
||||
|
||||
- name: Deploy any other managed files (raw files)
|
||||
vars:
|
||||
_enroll_ff:
|
||||
files:
|
||||
- "{{{{ inventory_dir }}}}/host_vars/{{{{ inventory_hostname }}}}/{{{{ role_name }}}}/.files/{{{{ item.src_rel }}}}"
|
||||
- "{{{{ role_path }}}}/files/{{{{ item.src_rel }}}}"
|
||||
ansible.builtin.copy:
|
||||
src: "{{{{ lookup('ansible.builtin.first_found', _enroll_ff) }}}}"
|
||||
dest: "{{{{ item.dest }}}}"
|
||||
owner: "{{{{ item.owner }}}}"
|
||||
group: "{{{{ item.group }}}}"
|
||||
mode: "{{{{ item.mode }}}}"
|
||||
loop: >-
|
||||
{{{{ {var_prefix}_managed_files | default([])
|
||||
| selectattr('is_systemd_unit', 'equalto', false)
|
||||
| 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([]) }}}}"
|
||||
"""
|
||||
|
||||
|
||||
def _render_install_packages_tasks(role: str, var_prefix: str) -> str:
|
||||
"""Render package installation through Ansible's generic package provider.
|
||||
|
||||
Puppet uses provider-backed package resources instead of selecting
|
||||
apt/dnf/yum in the generated manifest. Ansible's package module is the
|
||||
equivalent abstraction: it proxies to the target host's detected package
|
||||
manager and keeps generated roles provider-neutral.
|
||||
"""
|
||||
|
||||
return f"""- name: Install packages for {role}
|
||||
ansible.builtin.package:
|
||||
name: "{{{{ {var_prefix}_packages | default([]) }}}}"
|
||||
state: present
|
||||
when: ({var_prefix}_packages | default([])) | length > 0
|
||||
|
||||
"""
|
||||
|
||||
|
||||
def _render_grouped_systemd_tasks(var_prefix: str) -> str:
|
||||
"""Render tasks to manage multiple systemd units in a common role."""
|
||||
|
||||
return f"""- name: Probe whether grouped systemd units exist and are manageable
|
||||
ansible.builtin.systemd:
|
||||
name: "{{{{ item.name }}}}"
|
||||
no_log: "{{{{ enroll_hide_systemd_status | default(true) | bool }}}}"
|
||||
check_mode: true
|
||||
loop: "{{{{ {var_prefix}_systemd_units | default([]) }}}}"
|
||||
register: _enroll_unit_probes
|
||||
failed_when: false
|
||||
changed_when: false
|
||||
when: item.manage | default(false)
|
||||
|
||||
- name: Ensure grouped unit enablement matches harvest
|
||||
ansible.builtin.systemd:
|
||||
name: "{{{{ item.item.name }}}}"
|
||||
enabled: "{{{{ item.item.enabled | bool }}}}"
|
||||
no_log: "{{{{ enroll_hide_systemd_status | default(true) | bool }}}}"
|
||||
loop: "{{{{ _enroll_unit_probes.results | default([]) }}}}"
|
||||
when:
|
||||
- item.item.manage | default(false)
|
||||
- not (item.failed | default(false))
|
||||
|
||||
- name: Ensure grouped unit running state matches harvest
|
||||
ansible.builtin.systemd:
|
||||
name: "{{{{ item.item.name }}}}"
|
||||
state: "{{{{ item.item.state }}}}"
|
||||
no_log: "{{{{ enroll_hide_systemd_status | default(true) | bool }}}}"
|
||||
loop: "{{{{ _enroll_unit_probes.results | default([]) }}}}"
|
||||
when:
|
||||
- item.item.manage | default(false)
|
||||
- not (item.failed | default(false))
|
||||
"""
|
||||
|
||||
|
||||
def _render_sysctl_tasks(var_prefix: str) -> str:
|
||||
return f"""- name: Ensure sysctl.d exists
|
||||
ansible.builtin.file:
|
||||
path: /etc/sysctl.d
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0755"
|
||||
|
||||
- name: Deploy captured sysctl configuration
|
||||
vars:
|
||||
_enroll_ff:
|
||||
files:
|
||||
- "{{{{ inventory_dir }}}}/host_vars/{{{{ inventory_hostname }}}}/{{{{ role_name }}}}/.files/{{{{ {var_prefix}_conf_src_rel }}}}"
|
||||
- "{{{{ role_path }}}}/files/{{{{ {var_prefix}_conf_src_rel }}}}"
|
||||
ansible.builtin.copy:
|
||||
src: "{{{{ lookup('ansible.builtin.first_found', _enroll_ff) }}}}"
|
||||
dest: /etc/sysctl.d/99-enroll.conf
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0644"
|
||||
when: ({var_prefix}_conf_src_rel | default('') | length) > 0
|
||||
notify: Apply captured sysctl configuration
|
||||
"""
|
||||
|
||||
|
||||
def _render_sysctl_handlers(var_prefix: str) -> str:
|
||||
return f"""---
|
||||
- name: Apply captured sysctl configuration
|
||||
ansible.builtin.command:
|
||||
argv:
|
||||
- sysctl
|
||||
- -e
|
||||
- -p
|
||||
- /etc/sysctl.d/99-enroll.conf
|
||||
register: _enroll_sysctl_apply
|
||||
changed_when: false
|
||||
failed_when:
|
||||
- not ({var_prefix}_ignore_apply_errors | default(true) | bool)
|
||||
- _enroll_sysctl_apply.rc != 0
|
||||
when: {var_prefix}_apply | default(true) | bool
|
||||
"""
|
||||
|
||||
|
||||
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
|
||||
"""
|
||||
151
enroll/ansible_renderer/vars.py
Normal file
151
enroll/ansible_renderer/vars.py
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, Optional, Set
|
||||
|
||||
|
||||
def _normalise_flatpak_item(
|
||||
item: Any,
|
||||
*,
|
||||
method: str,
|
||||
user: Optional[str] = None,
|
||||
home: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
if isinstance(item, str):
|
||||
out: Dict[str, Any] = {"name": item, "method": method}
|
||||
elif isinstance(item, dict):
|
||||
out = dict(item)
|
||||
out.setdefault("method", method)
|
||||
else:
|
||||
out = {"name": str(item), "method": method}
|
||||
if user:
|
||||
out.setdefault("user", user)
|
||||
if home:
|
||||
out.setdefault("home", home)
|
||||
return out
|
||||
|
||||
|
||||
def _normalise_flatpak_remote(item: Any) -> Dict[str, Any]:
|
||||
if isinstance(item, dict):
|
||||
out = dict(item)
|
||||
else:
|
||||
out = {"name": str(item)}
|
||||
out.setdefault("method", "system")
|
||||
return out
|
||||
|
||||
|
||||
def _normalise_snap_item(item: Any) -> Dict[str, Any]:
|
||||
if isinstance(item, str):
|
||||
out: Dict[str, Any] = {"name": item}
|
||||
elif isinstance(item, dict):
|
||||
out = dict(item)
|
||||
else:
|
||||
out = {"name": str(item)}
|
||||
|
||||
notes = out.get("notes") or []
|
||||
if isinstance(notes, str):
|
||||
notes = [notes]
|
||||
notes_l = {str(n).lower() for n in notes}
|
||||
out["classic"] = bool(out.get("classic") or "classic" in notes_l)
|
||||
out["devmode"] = bool(out.get("devmode") or "devmode" in notes_l)
|
||||
out["dangerous"] = bool(out.get("dangerous") or "dangerous" in notes_l)
|
||||
|
||||
# The Ansible snap module's revision parameter pins/holds the snap. For
|
||||
# ordinary store snaps that track a channel, preserve the channel instead
|
||||
# of freezing every harvested host at today's revision.
|
||||
if out.get("revision") is not None and not out.get("channel"):
|
||||
out["install_revision"] = True
|
||||
else:
|
||||
out["install_revision"] = False
|
||||
return out
|
||||
|
||||
|
||||
def _build_managed_dirs_var(
|
||||
managed_dirs: List[Dict[str, Any]],
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Convert enroll managed_dirs into an Ansible-friendly list of dicts.
|
||||
|
||||
Each dict drives a role task loop and is safe across hosts.
|
||||
"""
|
||||
out: List[Dict[str, Any]] = []
|
||||
for d in managed_dirs:
|
||||
dest = d.get("path") or ""
|
||||
if not dest:
|
||||
continue
|
||||
out.append(
|
||||
{
|
||||
"dest": dest,
|
||||
"owner": d.get("owner") or "root",
|
||||
"group": d.get("group") or "root",
|
||||
"mode": d.get("mode") or "0755",
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _build_managed_files_var(
|
||||
managed_files: List[Dict[str, Any]],
|
||||
templated_src_rels: Set[str],
|
||||
*,
|
||||
notify_other: Optional[str] = None,
|
||||
notify_systemd: Optional[str] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Convert enroll managed_files into an Ansible-friendly list of dicts.
|
||||
|
||||
Each dict drives a role task loop and is safe across hosts.
|
||||
"""
|
||||
out: List[Dict[str, Any]] = []
|
||||
for mf in managed_files:
|
||||
dest = mf.get("path") or ""
|
||||
src_rel = mf.get("src_rel") or ""
|
||||
if not dest or not src_rel:
|
||||
continue
|
||||
is_unit = str(dest).startswith("/etc/systemd/system/")
|
||||
kind = "template" if src_rel in templated_src_rels else "copy"
|
||||
notify: List[str] = []
|
||||
if is_unit and notify_systemd:
|
||||
notify.append(notify_systemd)
|
||||
if (not is_unit) and notify_other:
|
||||
notify.append(notify_other)
|
||||
out.append(
|
||||
{
|
||||
"dest": dest,
|
||||
"src_rel": src_rel,
|
||||
"owner": mf.get("owner") or "root",
|
||||
"group": mf.get("group") or "root",
|
||||
"mode": mf.get("mode") or "0644",
|
||||
"kind": kind,
|
||||
"is_systemd_unit": bool(is_unit),
|
||||
"notify": notify,
|
||||
}
|
||||
)
|
||||
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 _normalise_container_image_item(item: Any) -> Dict[str, Any]:
|
||||
if isinstance(item, dict):
|
||||
out = dict(item)
|
||||
else:
|
||||
out = {"pull_ref": str(item)}
|
||||
out.setdefault("engine", "docker")
|
||||
out.setdefault("scope", "system")
|
||||
out.setdefault("user", None)
|
||||
out.setdefault("home", None)
|
||||
out.setdefault("repo_tags", [])
|
||||
out.setdefault("repo_digests", [])
|
||||
out.setdefault("tag_aliases", [])
|
||||
out.setdefault("notes", [])
|
||||
return out
|
||||
69
enroll/ansible_renderer/yamlutil.py
Normal file
69
enroll/ansible_renderer/yamlutil.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List
|
||||
|
||||
|
||||
def _try_yaml():
|
||||
try:
|
||||
import yaml # type: ignore
|
||||
except Exception:
|
||||
return None
|
||||
return yaml
|
||||
|
||||
|
||||
def _yaml_load_mapping(text: str) -> Dict[str, Any]:
|
||||
yaml = _try_yaml()
|
||||
if yaml is None:
|
||||
return {}
|
||||
try:
|
||||
obj = yaml.safe_load(text)
|
||||
except Exception:
|
||||
return {}
|
||||
if obj is None:
|
||||
return {}
|
||||
if isinstance(obj, dict):
|
||||
return obj
|
||||
return {}
|
||||
|
||||
|
||||
def _yaml_dump_mapping(obj: Dict[str, Any], *, sort_keys: bool = True) -> str:
|
||||
yaml = _try_yaml()
|
||||
if yaml is None:
|
||||
# fall back to a naive key: value dump (best-effort)
|
||||
lines: List[str] = []
|
||||
for k, v in sorted(obj.items()) if sort_keys else obj.items():
|
||||
lines.append(f"{k}: {v!r}")
|
||||
return "\n".join(lines).rstrip() + "\n"
|
||||
|
||||
# ansible-lint/yamllint's indentation rules are stricter than YAML itself.
|
||||
# In particular, they expect sequences nested under a mapping key to be
|
||||
# indented (e.g. `foo:\n - a`), whereas PyYAML's default is often
|
||||
# `foo:\n- a`.
|
||||
class _IndentDumper(yaml.SafeDumper): # type: ignore
|
||||
def increase_indent(self, flow: bool = False, indentless: bool = False):
|
||||
return super().increase_indent(flow, False)
|
||||
|
||||
return (
|
||||
yaml.dump(
|
||||
obj,
|
||||
Dumper=_IndentDumper,
|
||||
default_flow_style=False,
|
||||
sort_keys=sort_keys,
|
||||
indent=2,
|
||||
allow_unicode=True,
|
||||
).rstrip()
|
||||
+ "\n"
|
||||
)
|
||||
|
||||
|
||||
def _merge_mappings_overwrite(
|
||||
existing: Dict[str, Any], incoming: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""Merge incoming into existing with overwrite.
|
||||
|
||||
NOTE: Unlike role defaults merging, host_vars should reflect the current
|
||||
harvest for a host. Therefore lists are replaced rather than unioned.
|
||||
"""
|
||||
merged = dict(existing)
|
||||
merged.update(incoming)
|
||||
return merged
|
||||
275
enroll/capture.py
Normal file
275
enroll/capture.py
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
from typing import List, Optional, Set
|
||||
|
||||
from .fsutil import stat_triplet
|
||||
from .harvest_types import ExcludedFile, ManagedFile, ManagedLink
|
||||
from .ignore import IgnorePolicy
|
||||
from .pathfilter import PathFilter
|
||||
|
||||
|
||||
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: unreadable/missing baselines, non-regular
|
||||
files, and unexpectedly large files are treated as different so callers err
|
||||
on the side of preserving user state.
|
||||
"""
|
||||
|
||||
try:
|
||||
st_a = os.stat(a, follow_symlinks=True)
|
||||
except OSError:
|
||||
return True
|
||||
|
||||
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 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:
|
||||
return False
|
||||
except OSError:
|
||||
return True
|
||||
|
||||
|
||||
def copy_into_bundle(
|
||||
bundle_dir: str, role_name: str, abs_path: str, src_rel: str
|
||||
) -> None:
|
||||
dst = os.path.join(bundle_dir, "artifacts", role_name, src_rel)
|
||||
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
||||
shutil.copy2(abs_path, dst)
|
||||
|
||||
|
||||
def capture_file(
|
||||
*,
|
||||
bundle_dir: str,
|
||||
role_name: str,
|
||||
abs_path: str,
|
||||
reason: str,
|
||||
policy: IgnorePolicy,
|
||||
path_filter: PathFilter,
|
||||
managed_out: List[ManagedFile],
|
||||
excluded_out: List[ExcludedFile],
|
||||
seen_role: Optional[Set[str]] = None,
|
||||
seen_global: Optional[Set[str]] = None,
|
||||
metadata: Optional[tuple[str, str, str]] = None,
|
||||
) -> bool:
|
||||
"""Try to capture a single file into the bundle.
|
||||
|
||||
Returns True if the file was copied and appended to ``managed_out``.
|
||||
``seen_role`` de-duplicates within a role; ``seen_global`` de-duplicates
|
||||
across harvest stages so multiple generated roles do not manage one path.
|
||||
"""
|
||||
|
||||
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 = policy.deny_reason(abs_path)
|
||||
if deny:
|
||||
excluded_out.append(ExcludedFile(path=abs_path, reason=deny))
|
||||
_mark_seen()
|
||||
return False
|
||||
|
||||
try:
|
||||
owner, group, mode = (
|
||||
metadata if metadata is not None else stat_triplet(abs_path)
|
||||
)
|
||||
except OSError:
|
||||
excluded_out.append(ExcludedFile(path=abs_path, reason="unreadable"))
|
||||
_mark_seen()
|
||||
return False
|
||||
|
||||
src_rel = abs_path.lstrip("/")
|
||||
try:
|
||||
copy_into_bundle(bundle_dir, role_name, abs_path, src_rel)
|
||||
except OSError:
|
||||
excluded_out.append(ExcludedFile(path=abs_path, reason="unreadable"))
|
||||
_mark_seen()
|
||||
return False
|
||||
|
||||
managed_out.append(
|
||||
ManagedFile(
|
||||
path=abs_path,
|
||||
src_rel=src_rel,
|
||||
owner=owner,
|
||||
group=group,
|
||||
mode=mode,
|
||||
reason=reason,
|
||||
)
|
||||
)
|
||||
_mark_seen()
|
||||
return True
|
||||
|
||||
|
||||
USER_SHELL_DOTFILES_WITH_SKEL_BASELINE = [
|
||||
(".bashrc", "user_shell_rc"),
|
||||
(".profile", "user_profile"),
|
||||
(".bash_logout", "user_shell_logout"),
|
||||
]
|
||||
|
||||
USER_SHELL_DOTFILES_WITHOUT_SKEL_BASELINE = [
|
||||
(".bash_aliases", "user_shell_aliases"),
|
||||
]
|
||||
|
||||
|
||||
def capture_user_shell_dotfiles(
|
||||
*,
|
||||
bundle_dir: str,
|
||||
role_name: str,
|
||||
home: str,
|
||||
skel_dir: str,
|
||||
enabled: bool,
|
||||
policy: IgnorePolicy,
|
||||
path_filter: PathFilter,
|
||||
managed_out: List[ManagedFile],
|
||||
excluded_out: List[ExcludedFile],
|
||||
seen_role: Optional[Set[str]],
|
||||
seen_global: Optional[Set[str]],
|
||||
) -> int:
|
||||
"""Capture selected per-user shell dotfiles when explicitly enabled."""
|
||||
|
||||
if not enabled:
|
||||
return 0
|
||||
|
||||
home = (home or "").rstrip("/")
|
||||
if not home or not home.startswith("/"):
|
||||
return 0
|
||||
|
||||
captured = 0
|
||||
max_compare_bytes = int(getattr(policy, "max_file_bytes", 256_000))
|
||||
|
||||
for rel, reason in USER_SHELL_DOTFILES_WITH_SKEL_BASELINE:
|
||||
upath = os.path.join(home, rel)
|
||||
if not os.path.isfile(upath) or os.path.islink(upath):
|
||||
continue
|
||||
skel_path = os.path.join(skel_dir, rel)
|
||||
if not files_differ(upath, skel_path, max_bytes=max_compare_bytes):
|
||||
continue
|
||||
if capture_file(
|
||||
bundle_dir=bundle_dir,
|
||||
role_name=role_name,
|
||||
abs_path=upath,
|
||||
reason=reason,
|
||||
policy=policy,
|
||||
path_filter=path_filter,
|
||||
managed_out=managed_out,
|
||||
excluded_out=excluded_out,
|
||||
seen_role=seen_role,
|
||||
seen_global=seen_global,
|
||||
):
|
||||
captured += 1
|
||||
|
||||
for rel, reason in USER_SHELL_DOTFILES_WITHOUT_SKEL_BASELINE:
|
||||
upath = os.path.join(home, rel)
|
||||
if not os.path.isfile(upath) or os.path.islink(upath):
|
||||
continue
|
||||
if capture_file(
|
||||
bundle_dir=bundle_dir,
|
||||
role_name=role_name,
|
||||
abs_path=upath,
|
||||
reason=reason,
|
||||
policy=policy,
|
||||
path_filter=path_filter,
|
||||
managed_out=managed_out,
|
||||
excluded_out=excluded_out,
|
||||
seen_role=seen_role,
|
||||
seen_global=seen_global,
|
||||
):
|
||||
captured += 1
|
||||
|
||||
return captured
|
||||
|
||||
|
||||
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:
|
||||
"""Record a symlink for later materialisation by the manifest renderer."""
|
||||
|
||||
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:
|
||||
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
|
||||
387
enroll/cli.py
387
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
|
||||
|
||||
|
||||
|
|
@ -294,9 +308,23 @@ def _encrypt_harvest_dir_to_sops(
|
|||
|
||||
|
||||
def _add_common_manifest_args(p: argparse.ArgumentParser) -> None:
|
||||
p.add_argument(
|
||||
"--target",
|
||||
choices=["ansible", "puppet"],
|
||||
default="ansible",
|
||||
help="Manifest target to generate (default: ansible).",
|
||||
)
|
||||
p.add_argument(
|
||||
"--fqdn",
|
||||
help="Host FQDN/name for site-mode output (creates inventory/, inventory/host_vars/, playbooks/).",
|
||||
help="Host FQDN/name for site-mode output (creates target-specific host inventory/data such as Ansible host_vars or Puppet Hiera).",
|
||||
)
|
||||
p.add_argument(
|
||||
"--no-common-roles",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Do not group package and systemd-unit roles into common section/group roles. "
|
||||
"This preserves one generated role per package/unit. --fqdn implies this."
|
||||
),
|
||||
)
|
||||
g = p.add_mutually_exclusive_group()
|
||||
g.add_argument(
|
||||
|
|
@ -340,16 +368,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)."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -399,7 +473,6 @@ def main() -> None:
|
|||
"Excludes apply to all harvesting, including defaults."
|
||||
),
|
||||
)
|
||||
|
||||
h.add_argument(
|
||||
"--sops",
|
||||
nargs="+",
|
||||
|
|
@ -415,7 +488,9 @@ def main() -> None:
|
|||
help="Don't use sudo on the remote host (when using --remote options). This may result in a limited harvest due to permission restrictions.",
|
||||
)
|
||||
|
||||
m = sub.add_parser("manifest", help="Render Ansible roles from a harvest")
|
||||
m = sub.add_parser(
|
||||
"manifest", help="Render configuration-management code from a harvest"
|
||||
)
|
||||
_add_config_args(m)
|
||||
m.add_argument(
|
||||
"--harvest",
|
||||
|
|
@ -447,7 +522,8 @@ def main() -> None:
|
|||
_add_common_manifest_args(m)
|
||||
|
||||
s = sub.add_parser(
|
||||
"single-shot", help="Harvest state, then manifest Ansible code, in one shot"
|
||||
"single-shot",
|
||||
help="Harvest state, then manifest configuration-management code, in one shot",
|
||||
)
|
||||
_add_config_args(s)
|
||||
_add_remote_args(s)
|
||||
|
|
@ -483,7 +559,6 @@ def main() -> None:
|
|||
"Excludes apply to all harvesting, including defaults."
|
||||
),
|
||||
)
|
||||
|
||||
s.add_argument(
|
||||
"--sops",
|
||||
nargs="+",
|
||||
|
|
@ -536,6 +611,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:<regex>'. "
|
||||
"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 +696,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 +776,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 +807,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 +833,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 +885,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,
|
||||
|
|
@ -696,6 +928,8 @@ def main() -> None:
|
|||
fqdn=args.fqdn,
|
||||
jinjaturtle=_jt_mode(args),
|
||||
sops_fingerprints=getattr(args, "sops", None),
|
||||
no_common_roles=bool(getattr(args, "no_common_roles", False)),
|
||||
target=getattr(args, "target", "ansible"),
|
||||
)
|
||||
if getattr(args, "sops", None) and out_enc:
|
||||
print(str(out_enc))
|
||||
|
|
@ -704,8 +938,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 +1042,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 []),
|
||||
|
|
@ -788,6 +1067,8 @@ def main() -> None:
|
|||
fqdn=args.fqdn,
|
||||
jinjaturtle=_jt_mode(args),
|
||||
sops_fingerprints=list(sops_fps),
|
||||
no_common_roles=bool(getattr(args, "no_common_roles", False)),
|
||||
target=getattr(args, "target", "ansible"),
|
||||
)
|
||||
if not args.harvest:
|
||||
print(str(out_file))
|
||||
|
|
@ -798,10 +1079,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 []),
|
||||
|
|
@ -812,6 +1099,8 @@ def main() -> None:
|
|||
args.out,
|
||||
fqdn=args.fqdn,
|
||||
jinjaturtle=_jt_mode(args),
|
||||
no_common_roles=bool(getattr(args, "no_common_roles", False)),
|
||||
target=getattr(args, "target", "ansible"),
|
||||
)
|
||||
# For usability (when --harvest wasn't provided), print the harvest path.
|
||||
if not args.harvest:
|
||||
|
|
@ -842,6 +1131,8 @@ def main() -> None:
|
|||
fqdn=args.fqdn,
|
||||
jinjaturtle=_jt_mode(args),
|
||||
sops_fingerprints=list(sops_fps),
|
||||
no_common_roles=bool(getattr(args, "no_common_roles", False)),
|
||||
target=getattr(args, "target", "ansible"),
|
||||
)
|
||||
if not args.harvest:
|
||||
print(str(out_file))
|
||||
|
|
@ -861,56 +1152,20 @@ def main() -> None:
|
|||
args.out,
|
||||
fqdn=args.fqdn,
|
||||
jinjaturtle=_jt_mode(args),
|
||||
no_common_roles=bool(getattr(args, "no_common_roles", False)),
|
||||
target=getattr(args, "target", "ansible"),
|
||||
)
|
||||
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
|
||||
|
|
|
|||
300
enroll/cm.py
Normal file
300
enroll/cm.py
Normal file
|
|
@ -0,0 +1,300 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, Iterator, List, Mapping, Set
|
||||
|
||||
from .state import load_state, state_path, write_state
|
||||
|
||||
|
||||
@dataclass
|
||||
class CMModule:
|
||||
"""Renderer-neutral configuration-management resource group.
|
||||
|
||||
A CMModule is intentionally small: it captures the resources that a target
|
||||
renderer can turn into Ansible tasks, Puppet resources, etc.
|
||||
The renderer may still decide how to name/include/order the group.
|
||||
"""
|
||||
|
||||
role_name: str
|
||||
module_name: str
|
||||
packages: Set[str] = field(default_factory=set)
|
||||
groups: Set[str] = field(default_factory=set)
|
||||
users: Dict[str, Dict[str, Any]] = field(default_factory=dict)
|
||||
dirs: Dict[str, Dict[str, Any]] = field(default_factory=dict)
|
||||
files: Dict[str, Dict[str, Any]] = field(default_factory=dict)
|
||||
links: Dict[str, Dict[str, Any]] = field(default_factory=dict)
|
||||
services: Dict[str, Dict[str, Any]] = field(default_factory=dict)
|
||||
notes: List[str] = field(default_factory=list)
|
||||
|
||||
def has_resources(self) -> bool:
|
||||
return bool(
|
||||
self.packages
|
||||
or self.groups
|
||||
or self.users
|
||||
or self.dirs
|
||||
or self.files
|
||||
or self.links
|
||||
or self.services
|
||||
or self.notes
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def state_path(bundle_dir: str | Path) -> Path:
|
||||
"""Return the canonical state.json path for a harvest bundle."""
|
||||
|
||||
return state_path(bundle_dir)
|
||||
|
||||
@classmethod
|
||||
def load_state(cls, bundle_dir: str | Path) -> Dict[str, Any]:
|
||||
"""Load state.json for a renderer using the shared bundle state loader."""
|
||||
|
||||
return load_state(bundle_dir)
|
||||
|
||||
@classmethod
|
||||
def _load_state(cls, bundle_dir: str | Path) -> Dict[str, Any]:
|
||||
"""Backward-compatible alias for renderer subclasses."""
|
||||
|
||||
return cls.load_state(bundle_dir)
|
||||
|
||||
@classmethod
|
||||
def write_state(
|
||||
cls,
|
||||
bundle_dir: str | Path,
|
||||
state: Mapping[str, Any],
|
||||
*,
|
||||
indent: int = 2,
|
||||
sort_keys: bool = True,
|
||||
) -> Path:
|
||||
"""Write state.json using the shared bundle state writer."""
|
||||
|
||||
return write_state(bundle_dir, state, indent=indent, sort_keys=sort_keys)
|
||||
|
||||
@staticmethod
|
||||
def _snapshot_items(snap: Dict[str, Any], key: str) -> Iterator[Dict[str, Any]]:
|
||||
values = snap.get(key) or []
|
||||
if not isinstance(values, list):
|
||||
return
|
||||
for item in values:
|
||||
if isinstance(item, dict):
|
||||
yield item
|
||||
|
||||
@classmethod
|
||||
def managed_dirs_from_snapshot(
|
||||
cls, snap: Dict[str, Any]
|
||||
) -> Iterator[Dict[str, Any]]:
|
||||
return cls._snapshot_items(snap, "managed_dirs")
|
||||
|
||||
@classmethod
|
||||
def managed_files_from_snapshot(
|
||||
cls, snap: Dict[str, Any]
|
||||
) -> Iterator[Dict[str, Any]]:
|
||||
return cls._snapshot_items(snap, "managed_files")
|
||||
|
||||
@classmethod
|
||||
def managed_links_from_snapshot(
|
||||
cls, snap: Dict[str, Any]
|
||||
) -> Iterator[Dict[str, Any]]:
|
||||
return cls._snapshot_items(snap, "managed_links")
|
||||
|
||||
def add_managed_dir(
|
||||
self,
|
||||
path: str,
|
||||
*,
|
||||
owner: Any = "root",
|
||||
group: Any = "root",
|
||||
mode: Any = "0755",
|
||||
**attrs: Any,
|
||||
) -> None:
|
||||
if not path:
|
||||
return
|
||||
data: Dict[str, Any] = {
|
||||
"owner": owner or "root",
|
||||
"group": group or "root",
|
||||
"mode": mode or "0755",
|
||||
}
|
||||
data.update(attrs)
|
||||
self.dirs.setdefault(path, data)
|
||||
|
||||
def add_managed_file(
|
||||
self,
|
||||
path: str,
|
||||
*,
|
||||
owner: Any = "root",
|
||||
group: Any = "root",
|
||||
mode: Any = "0644",
|
||||
**attrs: Any,
|
||||
) -> None:
|
||||
if not path:
|
||||
return
|
||||
data: Dict[str, Any] = {
|
||||
"owner": owner or "root",
|
||||
"group": group or "root",
|
||||
"mode": mode or "0644",
|
||||
}
|
||||
data.update(attrs)
|
||||
self.files.setdefault(path, data)
|
||||
|
||||
def add_managed_link(self, path: str, **attrs: Any) -> None:
|
||||
if path:
|
||||
self.links.setdefault(path, attrs)
|
||||
|
||||
def add_snapshot_notes(self, snap: Dict[str, Any]) -> None:
|
||||
self.notes.extend(str(n) for n in (snap.get("notes", []) or []))
|
||||
|
||||
def remove_directory_resource_conflicts(self) -> None:
|
||||
for path in set(self.files) | set(self.links):
|
||||
self.dirs.pop(path, None)
|
||||
|
||||
|
||||
def package_section_label(
|
||||
package_role: Dict[str, Any], inventory_packages: Dict[str, Any]
|
||||
) -> str:
|
||||
"""Return the Debian Section/RPM Group label for a package role."""
|
||||
|
||||
pkg = str(package_role.get("package") or "").strip()
|
||||
inv = inventory_packages.get(pkg) or {}
|
||||
candidates: List[str] = []
|
||||
|
||||
for value in (package_role.get("section"), inv.get("section"), inv.get("group")):
|
||||
if isinstance(value, str) and value.strip():
|
||||
candidates.append(value.strip())
|
||||
|
||||
for inst in inv.get("installations", []) or []:
|
||||
if not isinstance(inst, dict):
|
||||
continue
|
||||
for key in ("section", "group"):
|
||||
value = inst.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
candidates.append(value.strip())
|
||||
|
||||
for value in candidates:
|
||||
if value.lower() not in {"(none)", "none", "unspecified"}:
|
||||
return value
|
||||
return "misc"
|
||||
|
||||
|
||||
def section_label_for_packages(
|
||||
packages: List[str], inventory_packages: Dict[str, Any]
|
||||
) -> str:
|
||||
"""Return a stable section/group label for a set of packages."""
|
||||
|
||||
for pkg in packages or []:
|
||||
label = package_section_label({"package": pkg}, inventory_packages)
|
||||
if label and label.lower() != "misc":
|
||||
return label
|
||||
return "misc"
|
||||
|
||||
|
||||
def role_order_key(role: str) -> tuple[int, str]:
|
||||
# Keep broadly similar ordering to generated Ansible playbooks: package/config
|
||||
# scaffolding first, then services/users, then host-specific runtime state.
|
||||
priority = {
|
||||
"apt_config": 10,
|
||||
"dnf_config": 11,
|
||||
"etc_custom": 80,
|
||||
"usr_local_custom": 81,
|
||||
"extra_paths": 82,
|
||||
"container_images": 88,
|
||||
"users": 90,
|
||||
"sysctl": 95,
|
||||
"firewall_runtime": 99,
|
||||
}
|
||||
return (priority.get(role, 50), role)
|
||||
|
||||
|
||||
def _drop_duplicate_set_items(
|
||||
module: CMModule,
|
||||
values: Set[str],
|
||||
seen: Set[str],
|
||||
resource_type: str,
|
||||
) -> Set[str]:
|
||||
kept: Set[str] = set()
|
||||
for value in sorted(values):
|
||||
if value in seen:
|
||||
module.notes.append(
|
||||
f"Skipped duplicate {resource_type}[{value}] already emitted earlier in this catalog."
|
||||
)
|
||||
continue
|
||||
kept.add(value)
|
||||
seen.add(value)
|
||||
return kept
|
||||
|
||||
|
||||
def _drop_duplicate_mapping_items(
|
||||
module: CMModule,
|
||||
values: Dict[str, Dict[str, Any]],
|
||||
seen: Set[str],
|
||||
resource_type: str,
|
||||
*,
|
||||
excluded_titles: Set[str] | None = None,
|
||||
excluded_reason: str = "conflicts with another resource",
|
||||
) -> Dict[str, Dict[str, Any]]:
|
||||
kept: Dict[str, Dict[str, Any]] = {}
|
||||
excluded_titles = excluded_titles or set()
|
||||
for title, attrs in values.items():
|
||||
if title in excluded_titles:
|
||||
module.notes.append(f"Skipped {resource_type}[{title}]: {excluded_reason}.")
|
||||
continue
|
||||
if title in seen:
|
||||
module.notes.append(
|
||||
f"Skipped duplicate {resource_type}[{title}] already emitted earlier in this catalog."
|
||||
)
|
||||
continue
|
||||
kept[title] = attrs
|
||||
seen.add(title)
|
||||
return kept
|
||||
|
||||
|
||||
def resolve_catalog_conflicts(modules: Iterable[CMModule]) -> None:
|
||||
"""Resolve global catalog conflicts before renderer output.
|
||||
|
||||
Puppet compiles a single resource catalog. Ansible can tolerate the same
|
||||
package, service, or parent directory appearing in more than one role;
|
||||
catalog targets cannot. Resolve those conflicts in the shared model rather
|
||||
than deleting renderer output after the fact.
|
||||
"""
|
||||
|
||||
ordered = list(modules)
|
||||
concrete_file_paths: Set[str] = set()
|
||||
for module in ordered:
|
||||
concrete_file_paths.update(module.files)
|
||||
concrete_file_paths.update(module.links)
|
||||
|
||||
seen_packages: Set[str] = set()
|
||||
seen_groups: Set[str] = set()
|
||||
seen_users: Set[str] = set()
|
||||
seen_dirs: Set[str] = set()
|
||||
seen_files: Set[str] = set()
|
||||
seen_links: Set[str] = set()
|
||||
seen_services: Set[str] = set()
|
||||
|
||||
for module in ordered:
|
||||
module.packages = _drop_duplicate_set_items(
|
||||
module, module.packages, seen_packages, "Package"
|
||||
)
|
||||
module.groups = _drop_duplicate_set_items(
|
||||
module, module.groups, seen_groups, "Group"
|
||||
)
|
||||
module.users = _drop_duplicate_mapping_items(
|
||||
module, module.users, seen_users, "User"
|
||||
)
|
||||
module.dirs = _drop_duplicate_mapping_items(
|
||||
module,
|
||||
module.dirs,
|
||||
seen_dirs,
|
||||
"File",
|
||||
excluded_titles=concrete_file_paths,
|
||||
excluded_reason="a file or link with the same path is emitted in this catalog",
|
||||
)
|
||||
module.files = _drop_duplicate_mapping_items(
|
||||
module, module.files, seen_files | seen_links, "File"
|
||||
)
|
||||
seen_files.update(module.files)
|
||||
module.links = _drop_duplicate_mapping_items(
|
||||
module, module.links, seen_links | seen_files, "File"
|
||||
)
|
||||
seen_links.update(module.links)
|
||||
module.services = _drop_duplicate_mapping_items(
|
||||
module, module.services, seen_services, "Service"
|
||||
)
|
||||
|
|
@ -69,7 +69,7 @@ def list_installed_packages() -> Dict[str, List[Dict[str, str]]]:
|
|||
Uses dpkg-query and is expected to work on Debian/Ubuntu-like systems.
|
||||
|
||||
Output format:
|
||||
{"pkg": [{"version": "...", "arch": "..."}, ...], ...}
|
||||
{"pkg": [{"version": "...", "arch": "...", "section": "..."}, ...], ...}
|
||||
"""
|
||||
|
||||
try:
|
||||
|
|
@ -77,7 +77,7 @@ def list_installed_packages() -> Dict[str, List[Dict[str, str]]]:
|
|||
[
|
||||
"dpkg-query",
|
||||
"-W",
|
||||
"-f=${Package}\t${Version}\t${Architecture}\n",
|
||||
"-f=${Package}\t${Version}\t${Architecture}\t${Section}\n",
|
||||
],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
|
|
@ -97,7 +97,10 @@ def list_installed_packages() -> Dict[str, List[Dict[str, str]]]:
|
|||
name, ver, arch = parts[0].strip(), parts[1].strip(), parts[2].strip()
|
||||
if not name:
|
||||
continue
|
||||
out.setdefault(name, []).append({"version": ver, "arch": arch})
|
||||
instance = {"version": ver, "arch": arch}
|
||||
if len(parts) >= 4 and parts[3].strip():
|
||||
instance["section"] = parts[3].strip()
|
||||
out.setdefault(name, []).append(instance)
|
||||
|
||||
# Stable ordering for deterministic JSON dumps.
|
||||
for k in list(out.keys()):
|
||||
|
|
|
|||
548
enroll/diff.py
548
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,79 @@ from pathlib import Path
|
|||
from typing import Any, Dict, Iterable, List, Optional, Tuple
|
||||
|
||||
from .remote import _safe_extract_tar
|
||||
from .state import (
|
||||
inventory_packages_from_state as _packages_inventory,
|
||||
load_state as _load_state,
|
||||
roles_from_state as _roles,
|
||||
state_path,
|
||||
)
|
||||
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()
|
||||
|
||||
|
|
@ -47,7 +122,7 @@ class BundleRef:
|
|||
|
||||
@property
|
||||
def state_path(self) -> Path:
|
||||
return self.dir / "state.json"
|
||||
return state_path(self.dir)
|
||||
|
||||
|
||||
def _bundle_from_input(path: str, *, sops_mode: bool) -> BundleRef:
|
||||
|
|
@ -120,24 +195,10 @@ def _bundle_from_input(path: str, *, sops_mode: bool) -> BundleRef:
|
|||
)
|
||||
|
||||
|
||||
def _load_state(bundle_dir: Path) -> Dict[str, Any]:
|
||||
sp = bundle_dir / "state.json"
|
||||
with open(sp, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def _packages_inventory(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return (state.get("inventory") or {}).get("packages") or {}
|
||||
|
||||
|
||||
def _all_packages(state: Dict[str, Any]) -> List[str]:
|
||||
return sorted(_packages_inventory(state).keys())
|
||||
|
||||
|
||||
def _roles(state: Dict[str, Any]) -> Dict[str, Any]:
|
||||
return state.get("roles") or {}
|
||||
|
||||
|
||||
def _pkg_version_key(entry: Dict[str, Any]) -> Optional[str]:
|
||||
"""Return a stable string used for version comparison."""
|
||||
installs = entry.get("installations") or []
|
||||
|
|
@ -234,6 +295,12 @@ def _iter_managed_files(state: Dict[str, Any]) -> Iterable[Tuple[str, Dict[str,
|
|||
for mf in ac.get("managed_files", []) or []:
|
||||
yield str(ac_role), mf
|
||||
|
||||
# sysctl
|
||||
sc = _roles(state).get("sysctl") or {}
|
||||
sc_role = sc.get("role_name") or "sysctl"
|
||||
for mf in sc.get("managed_files", []) or []:
|
||||
yield str(sc_role), mf
|
||||
|
||||
# etc_custom
|
||||
ec = _roles(state).get("etc_custom") or {}
|
||||
ec_role = ec.get("role_name") or "etc_custom"
|
||||
|
|
@ -289,6 +356,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 +384,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 +460,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 +546,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 +566,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 +604,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
|
||||