Remove puppet and salt
This commit is contained in:
parent
88e9dba39a
commit
e9d7d74445
24 changed files with 256 additions and 7714 deletions
|
|
@ -35,13 +35,7 @@ jobs:
|
||||||
apt-get update
|
apt-get update
|
||||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||||
ca-certificates curl gnupg git tar gzip findutils bash nodejs procps \
|
ca-certificates curl gnupg git tar gzip findutils bash nodejs procps \
|
||||||
ansible ansible-lint python3 python3-venv python3-pip pipx systemctl python3-apt jq python3-jsonschema \
|
ansible ansible-lint python3 python3-venv python3-pip pipx systemctl python3-apt jq python3-jsonschema
|
||||||
puppet hiera
|
|
||||||
curl -fsSL https://packages.broadcom.com/artifactory/api/security/keypair/SaltProjectKey/public | gpg --dearmor | tee /etc/apt/keyrings/salt-archive-keyring.pgp > /dev/null
|
|
||||||
curl -fsSL https://github.com/saltstack/salt-install-guide/releases/latest/download/salt.sources | tee /etc/apt/sources.list.d/salt.sources
|
|
||||||
apt-get update
|
|
||||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
|
||||||
salt-master salt-minion salt-ssh salt-syndic salt-cloud salt-api
|
|
||||||
;;
|
;;
|
||||||
almalinux)
|
almalinux)
|
||||||
dnf -y upgrade --refresh
|
dnf -y upgrade --refresh
|
||||||
|
|
@ -49,15 +43,10 @@ jobs:
|
||||||
ca-certificates curl-minimal gnupg2 git tar gzip findutils bash which jq nodejs procps-ng \
|
ca-certificates curl-minimal gnupg2 git tar gzip findutils bash which jq nodejs procps-ng \
|
||||||
dnf-plugins-core epel-release
|
dnf-plugins-core epel-release
|
||||||
dnf -y config-manager --set-enabled crb || true
|
dnf -y config-manager --set-enabled crb || true
|
||||||
curl -fsSL https://github.com/saltstack/salt-install-guide/releases/latest/download/salt.repo > /etc/yum.repos.d/salt.repo
|
|
||||||
dnf -y install https://yum.puppet.com/puppet8-release-el-9.noarch.rpm
|
|
||||||
dnf -y makecache
|
dnf -y makecache
|
||||||
dnf -y install \
|
dnf -y install \
|
||||||
python3.11 python3.11-devel python3.11-pip gcc make \
|
python3.11 python3.11-devel python3.11-pip gcc make \
|
||||||
ansible-core ansible-lint systemd rpm httpd \
|
ansible-core ansible-lint systemd rpm httpd
|
||||||
puppet-agent \
|
|
||||||
salt-master salt-minion salt-ssh salt-syndic salt-cloud salt-api
|
|
||||||
echo "/opt/puppetlabs/bin" >> "$GITHUB_PATH"
|
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "Unsupported CI distro: ${DISTRO}" >&2
|
echo "Unsupported CI distro: ${DISTRO}" >&2
|
||||||
|
|
|
||||||
770
DEVELOPMENT.md
770
DEVELOPMENT.md
|
|
@ -21,25 +21,21 @@ Harvest bundle
|
||||||
state.json
|
state.json
|
||||||
artifacts/<role>/<path-relative-to-root>
|
artifacts/<role>/<path-relative-to-root>
|
||||||
|
|
|
|
||||||
| enroll manifest --target ansible|puppet|salt
|
| enroll manifest
|
||||||
v
|
v
|
||||||
Generated configuration-management output
|
Generated configuration-management output
|
||||||
Ansible roles/playbook
|
Ansible roles/playbook
|
||||||
Puppet modules/site.pp/Hiera data
|
|
||||||
Salt states/pillar data
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The harvest bundle is deliberately target-neutral. Ansible, Puppet, and Salt renderers all consume the same `state.json` shape and the same harvested artifacts. Renderer code should translate harvest state into the target's idioms; it should not invent source facts that belong in the harvest.
|
The harvest bundle is deliberately target-neutral. Ansible renderer consumes the same `state.json` shape and the same harvested artifacts. Renderer code should translate harvest state into the target's idioms; it should not invent source facts that belong in the harvest.
|
||||||
|
|
||||||
`enroll diff` is also built around harvest bundles. It compares two harvests and, when `--enforce` is requested, can generate a temporary manifest from the old harvest and apply it locally with the selected target:
|
`enroll diff` is also built around harvest bundles. It compares two harvests and, when `--enforce` is requested, can generate a temporary manifest from the old harvest and apply it locally with the selected target:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
enroll diff --old ./baseline --new ./current --enforce --target ansible
|
enroll diff --old ./baseline --new ./current --enforce
|
||||||
enroll diff --old ./baseline --new ./current --enforce --target puppet
|
|
||||||
enroll diff --old ./baseline --new ./current --enforce --target salt
|
|
||||||
```
|
```
|
||||||
|
|
||||||
For enforcement, the user is responsible for having the chosen local apply tool on `PATH`: `ansible-playbook`, `puppet`, or `salt-call`.
|
For enforcement, the user is responsible for having the chosen local apply tool on `PATH`: `ansible-playbook`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -67,8 +63,6 @@ enroll/
|
||||||
|
|
||||||
manifest.py target router and SOPS manifest wrapper
|
manifest.py target router and SOPS manifest wrapper
|
||||||
ansible.py Ansible renderer
|
ansible.py Ansible renderer
|
||||||
puppet.py Puppet renderer
|
|
||||||
salt.py Salt renderer
|
|
||||||
cm.py renderer-neutral CMModule model and grouping helpers
|
cm.py renderer-neutral CMModule model and grouping helpers
|
||||||
role_names.py reserved singleton role-name protection
|
role_names.py reserved singleton role-name protection
|
||||||
|
|
||||||
|
|
@ -131,7 +125,7 @@ The supported subcommands are:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
harvest collect a harvest bundle from a local or remote host
|
harvest collect a harvest bundle from a local or remote host
|
||||||
manifest generate Ansible/Puppet/Salt output from a harvest bundle
|
manifest generate Ansible output from a harvest bundle
|
||||||
single-shot run harvest and manifest in one command
|
single-shot run harvest and manifest in one command
|
||||||
diff compare two harvest bundles and optionally enforce old state
|
diff compare two harvest bundles and optionally enforce old state
|
||||||
explain produce a human/JSON explanation of a harvest
|
explain produce a human/JSON explanation of a harvest
|
||||||
|
|
@ -156,8 +150,8 @@ flowchart TD
|
||||||
F --> G[diff.format_report]
|
F --> G[diff.format_report]
|
||||||
F --> H{--enforce?}
|
F --> H{--enforce?}
|
||||||
H -->|yes| I[diff.enforce_old_harvest]
|
H -->|yes| I[diff.enforce_old_harvest]
|
||||||
I --> J[manifest.manifest target=ansible|puppet|salt]
|
I --> J[manifest.manifest]
|
||||||
J --> K[ansible-playbook or puppet apply or salt-call]
|
J --> K[ansible-playbook]
|
||||||
B -->|explain| L[explain.explain_state]
|
B -->|explain| L[explain.explain_state]
|
||||||
B -->|validate| M[validate.validate_harvest]
|
B -->|validate| M[validate.validate_harvest]
|
||||||
```
|
```
|
||||||
|
|
@ -172,10 +166,10 @@ harvest.py
|
||||||
depends on harvest_collectors, platform backends, capture policy, system scanners
|
depends on harvest_collectors, platform backends, capture policy, system scanners
|
||||||
|
|
||||||
manifest.py
|
manifest.py
|
||||||
depends on ansible.py, puppet.py, salt.py
|
depends on ansible.py
|
||||||
|
|
||||||
ansible.py / puppet.py / salt.py
|
ansible.py
|
||||||
depend on state.py, cm.py, harvested artifacts, and target-specific helpers
|
depends on state.py, cm.py, harvested artifacts, and helpers
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -362,8 +356,6 @@ This is one of the most important invariants in the project:
|
||||||
|
|
||||||
> A destination path should normally appear in only one generated role.
|
> A destination path should normally appear in only one generated role.
|
||||||
|
|
||||||
Puppet and Salt also run `cm.resolve_catalog_conflicts()` after renderer role collection because they compile a single global catalog and duplicate resources are hard failures.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. File capture and safety policy
|
## 7. File capture and safety policy
|
||||||
|
|
@ -595,7 +587,7 @@ artifacts/firewall_runtime/firewall/iptables.v4
|
||||||
artifacts/firewall_runtime/firewall/iptables.v6
|
artifacts/firewall_runtime/firewall/iptables.v6
|
||||||
```
|
```
|
||||||
|
|
||||||
Renderers should only create a firewall runtime role when at least one runtime artifact exists. When firewall runtime is rendered, Ansible/Puppet/Salt also create an `enroll_runtime` role/module/state to own `/etc/enroll` before `/etc/enroll/firewall`.
|
Renderers should only create a firewall runtime role when at least one runtime artifact exists. When firewall runtime is rendered, Ansible also creates an `enroll_runtime` role/module/state to own `/etc/enroll` before `/etc/enroll/firewall`.
|
||||||
|
|
||||||
### 9.2 `CronLogrotateCollector`
|
### 9.2 `CronLogrotateCollector`
|
||||||
|
|
||||||
|
|
@ -795,9 +787,7 @@ manifest(
|
||||||
Plain mode dispatches to:
|
Plain mode dispatches to:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
target=ansible -> ansible.manifest_from_bundle_dir(..., jinjaturtle=..., no_common_roles=...)
|
ansible.manifest_from_bundle_dir(..., jinjaturtle=..., no_common_roles=...)
|
||||||
target=puppet -> puppet.manifest_from_bundle_dir(..., jinjaturtle=..., no_common_roles=...)
|
|
||||||
target=salt -> salt.manifest_from_bundle_dir(..., jinjaturtle=..., no_common_roles=...)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
SOPS mode:
|
SOPS mode:
|
||||||
|
|
@ -819,7 +809,7 @@ Before dispatching to a renderer, `manifest.manifest()` calls `validate.validate
|
||||||
|
|
||||||
File: `cm.py`
|
File: `cm.py`
|
||||||
|
|
||||||
`CMModule` is the shared resource model used heavily by Puppet and Salt and partially by Ansible.
|
`CMModule` is the shared resource model used by Ansible and perhaps other renderers in the future.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -866,22 +856,6 @@ normal manifest, no --no-common-roles: group package/service roles
|
||||||
|
|
||||||
`--fqdn` implies no common roles because host-specific output should preserve per-host state rather than merging unrelated resources into shared roles.
|
`--fqdn` implies no common roles because host-specific output should preserve per-host state rather than merging unrelated resources into shared roles.
|
||||||
|
|
||||||
### 12.2 Catalog conflict resolution
|
|
||||||
|
|
||||||
`resolve_catalog_conflicts()` runs for Puppet and Salt.
|
|
||||||
|
|
||||||
It removes duplicates across generated modules/states for:
|
|
||||||
|
|
||||||
- packages,
|
|
||||||
- groups,
|
|
||||||
- users,
|
|
||||||
- directories,
|
|
||||||
- files,
|
|
||||||
- symlinks,
|
|
||||||
- services.
|
|
||||||
|
|
||||||
It also removes directory resources that conflict with a file or link at the same path. This matters because Puppet and Salt compile a single catalog; duplicates that Ansible might tolerate can fail hard there.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 13. Ansible renderer
|
## 13. Ansible renderer
|
||||||
|
|
@ -981,8 +955,6 @@ Ansible playbook roles are ordered intentionally:
|
||||||
|
|
||||||
Generated playbooks tag roles with `role_<safe_role_name>`. `diff --enforce --target ansible` uses these tags to narrow enforcement to roles relevant to the drift report when it can.
|
Generated playbooks tag roles with `role_<safe_role_name>`. `diff --enforce --target ansible` uses these tags to narrow enforcement to roles relevant to the drift report when it can.
|
||||||
|
|
||||||
Puppet and Salt enforcement do not currently narrow to per-role tags; they run the full generated local manifest/state tree.
|
|
||||||
|
|
||||||
### 13.5 Ansible and JinjaTurtle
|
### 13.5 Ansible and JinjaTurtle
|
||||||
|
|
||||||
Ansible uses `jinjaturtle.jinjify_managed_files()`.
|
Ansible uses `jinjaturtle.jinjify_managed_files()`.
|
||||||
|
|
@ -996,236 +968,7 @@ If JinjaTurtle is unavailable in `auto` mode, fails, emits missing variables, or
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 14. Puppet renderer
|
## 14. JinjaTurtle integration
|
||||||
|
|
||||||
File: `puppet.py`
|
|
||||||
|
|
||||||
Entry point:
|
|
||||||
|
|
||||||
```python
|
|
||||||
puppet.manifest_from_bundle_dir(
|
|
||||||
bundle_dir,
|
|
||||||
out_dir,
|
|
||||||
fqdn=None,
|
|
||||||
no_common_roles=False,
|
|
||||||
jinjaturtle="auto",
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
It instantiates `PuppetManifestRenderer(...).render()`.
|
|
||||||
|
|
||||||
### 14.1 Puppet render flow
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TD
|
|
||||||
A[PuppetManifestRenderer.render] --> B[PuppetRole.load_state]
|
|
||||||
B --> C[resolve_jinjaturtle_mode]
|
|
||||||
C --> D[_collect_puppet_roles]
|
|
||||||
D --> E[resolve_catalog_conflicts]
|
|
||||||
E --> F[_sync_service_notifications]
|
|
||||||
F --> G[write modules/<module>/manifests/init.pp]
|
|
||||||
G --> H[write metadata.json]
|
|
||||||
H --> I{fqdn?}
|
|
||||||
I -->|no| J[write manifests/site.pp with node default]
|
|
||||||
I -->|yes| K[write hiera.yaml]
|
|
||||||
K --> L[write data/nodes/<fqdn>.yaml]
|
|
||||||
L --> M[write Hiera-driven site.pp]
|
|
||||||
J --> N[README.md]
|
|
||||||
M --> N
|
|
||||||
```
|
|
||||||
|
|
||||||
### 14.2 `PuppetRole`
|
|
||||||
|
|
||||||
`PuppetRole` extends `CMModule` and converts snapshots into Puppet-friendly resources. It handles:
|
|
||||||
|
|
||||||
- packages,
|
|
||||||
- users and groups,
|
|
||||||
- managed dirs/files/symlinks,
|
|
||||||
- services,
|
|
||||||
- sysctl apply execs,
|
|
||||||
- Flatpak remotes/apps via guarded `exec`,
|
|
||||||
- Snap installs via guarded `exec`,
|
|
||||||
- Docker/Podman images by digest via guarded `exec`,
|
|
||||||
- firewall runtime files and refresh-only restore execs,
|
|
||||||
- JinjaTurtle ERB templates and class/Hiera parameter values.
|
|
||||||
|
|
||||||
`_puppet_name()` sanitises module names and avoids Puppet reserved words such as `default`, `class`, `node`, `site`, and `init`.
|
|
||||||
|
|
||||||
### 14.3 Output layout
|
|
||||||
|
|
||||||
Default mode:
|
|
||||||
|
|
||||||
```text
|
|
||||||
<out>/
|
|
||||||
manifests/site.pp
|
|
||||||
README.md
|
|
||||||
modules/
|
|
||||||
<module>/
|
|
||||||
metadata.json
|
|
||||||
manifests/init.pp
|
|
||||||
files/...
|
|
||||||
templates/...
|
|
||||||
```
|
|
||||||
|
|
||||||
Default `site.pp` includes generated classes in manifest order under a `node default` or named node block.
|
|
||||||
|
|
||||||
### 14.4 Puppet `--fqdn` / Hiera mode
|
|
||||||
|
|
||||||
When `--fqdn` is supplied, Puppet output switches to Hiera-style node data:
|
|
||||||
|
|
||||||
```text
|
|
||||||
<out>/
|
|
||||||
hiera.yaml
|
|
||||||
manifests/site.pp
|
|
||||||
data/
|
|
||||||
common.yaml
|
|
||||||
nodes/<fqdn>.yaml
|
|
||||||
modules/
|
|
||||||
<module>/
|
|
||||||
metadata.json
|
|
||||||
manifests/init.pp
|
|
||||||
files/nodes/<fqdn>/...
|
|
||||||
templates/...
|
|
||||||
```
|
|
||||||
|
|
||||||
In this mode:
|
|
||||||
|
|
||||||
- `site.pp` includes classes from Hiera key `enroll::classes`,
|
|
||||||
- `data/nodes/<fqdn>.yaml` contains class list and parameter data,
|
|
||||||
- module classes are data-driven via Automatic Parameter Lookup,
|
|
||||||
- node-specific raw file artifacts live under `modules/<module>/files/nodes/<fqdn>/...`,
|
|
||||||
- JinjaTurtle ERB template values are written into node Hiera data.
|
|
||||||
|
|
||||||
Re-running Enroll with another `--fqdn` into the same output directory is intended to add or replace that node's YAML without deleting existing node data.
|
|
||||||
|
|
||||||
### 14.5 Puppet and JinjaTurtle
|
|
||||||
|
|
||||||
Puppet now participates in the shared JinjaTurtle integration.
|
|
||||||
|
|
||||||
When enabled, Puppet calls `jinjaturtle` with ERB-specific options:
|
|
||||||
|
|
||||||
```text
|
|
||||||
--template-engine erb
|
|
||||||
--puppet-class <module_name>
|
|
||||||
```
|
|
||||||
|
|
||||||
The resulting template is written under:
|
|
||||||
|
|
||||||
```text
|
|
||||||
modules/<module>/templates/<src_rel>.erb
|
|
||||||
```
|
|
||||||
|
|
||||||
Static single-node mode renders class parameters with defaults and uses:
|
|
||||||
|
|
||||||
```puppet
|
|
||||||
content => template('<module>/<src_rel>.erb')
|
|
||||||
```
|
|
||||||
|
|
||||||
Hiera mode writes template parameter values into `data/nodes/<fqdn>.yaml` and renders data-driven file resources.
|
|
||||||
|
|
||||||
`jinjaturtle.missing_erb_template_vars()` checks that ERB instance variables such as `@main_key` have matching context/Hiera data. If variables are missing, Enroll falls back to raw file copying rather than emitting a broken Puppet template.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 15. Salt renderer
|
|
||||||
|
|
||||||
File: `salt.py`
|
|
||||||
|
|
||||||
Entry point:
|
|
||||||
|
|
||||||
```python
|
|
||||||
salt.manifest_from_bundle_dir(
|
|
||||||
bundle_dir,
|
|
||||||
out_dir,
|
|
||||||
fqdn=None,
|
|
||||||
no_common_roles=False,
|
|
||||||
jinjaturtle="auto",
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
It instantiates `SaltManifestRenderer(...).render()`.
|
|
||||||
|
|
||||||
### 15.1 Salt render flow
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart TD
|
|
||||||
A[SaltManifestRenderer.render] --> B[SaltRole.load_state]
|
|
||||||
B --> C[resolve_jinjaturtle_mode]
|
|
||||||
C --> D[_collect_salt_roles]
|
|
||||||
D --> E[resolve_catalog_conflicts]
|
|
||||||
E --> F[write states/roles/<role>/init.sls]
|
|
||||||
F --> G{fqdn?}
|
|
||||||
G -->|no| H[write states/top.sls target '*']
|
|
||||||
G -->|yes| I[write pillar node data]
|
|
||||||
I --> J[write states/top.sls and pillar/top.sls]
|
|
||||||
H --> K[write config/master.d/enroll.conf]
|
|
||||||
J --> K
|
|
||||||
K --> L[README.md]
|
|
||||||
```
|
|
||||||
|
|
||||||
### 15.2 `SaltRole`
|
|
||||||
|
|
||||||
`SaltRole` extends `CMModule` and changes `managed_owner_attr` to `user`, because Salt `file.managed` uses `user` rather than `owner`.
|
|
||||||
|
|
||||||
It prepares:
|
|
||||||
|
|
||||||
- packages as `pkg.installed`,
|
|
||||||
- groups as `group.present`,
|
|
||||||
- users as `user.present`,
|
|
||||||
- dirs/files/symlinks as Salt `file.*` states,
|
|
||||||
- services as `service.running` or `service.dead`,
|
|
||||||
- Flatpaks/Snaps via guarded `cmd.run`,
|
|
||||||
- Docker/Podman images via guarded `cmd.run`,
|
|
||||||
- firewall runtime restore commands,
|
|
||||||
- optional Jinja templates for managed files.
|
|
||||||
|
|
||||||
### 15.3 Output layout
|
|
||||||
|
|
||||||
Default mode:
|
|
||||||
|
|
||||||
```text
|
|
||||||
<out>/
|
|
||||||
README.md
|
|
||||||
config/master.d/enroll.conf
|
|
||||||
states/
|
|
||||||
top.sls
|
|
||||||
roles/<role>/
|
|
||||||
init.sls
|
|
||||||
files/...
|
|
||||||
templates/...
|
|
||||||
```
|
|
||||||
|
|
||||||
`--fqdn` mode:
|
|
||||||
|
|
||||||
```text
|
|
||||||
<out>/
|
|
||||||
states/
|
|
||||||
top.sls
|
|
||||||
roles/<role>/init.sls
|
|
||||||
pillar/
|
|
||||||
top.sls
|
|
||||||
nodes/<sanitised-fqdn>_<digest>.sls
|
|
||||||
```
|
|
||||||
|
|
||||||
The Salt renderer can accumulate node data in `--fqdn` mode and preserves existing top data where appropriate.
|
|
||||||
|
|
||||||
### 15.4 Salt and JinjaTurtle
|
|
||||||
|
|
||||||
Salt uses `jinjaturtle.jinjify_artifact()` directly. When successful, a managed file becomes a Salt `file.managed` with:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
source: salt://roles/<role>/templates/<src_rel>.j2
|
|
||||||
template: jinja
|
|
||||||
context: {...}
|
|
||||||
```
|
|
||||||
|
|
||||||
Salt has one additional compatibility step: `_saltify_jinjaturtle_template()` rewrites Ansible-oriented `to_json(...)` filters emitted by JinjaTurtle into Salt-safe context variables or `tojson` filters.
|
|
||||||
|
|
||||||
If templating fails or is unsupported, the renderer falls back to a literal file copy under `files/`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 16. Shared JinjaTurtle integration
|
|
||||||
|
|
||||||
File: `jinjaturtle.py`
|
File: `jinjaturtle.py`
|
||||||
|
|
||||||
|
|
@ -1270,27 +1013,24 @@ jinjify_artifact(
|
||||||
template_root,
|
template_root,
|
||||||
jt_exe=...,
|
jt_exe=...,
|
||||||
jt_enabled=...,
|
jt_enabled=...,
|
||||||
template_engine="jinja2" | "erb",
|
|
||||||
puppet_class=..., # Puppet only
|
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
Ansible uses `jinjify_managed_files()` because it merges variables into role defaults or host vars. Salt uses `jinjify_artifact()` directly because context lives with each `file.managed`. Puppet uses `jinjify_artifact(..., template_engine="erb", puppet_class=<module>)` so variables line up with Puppet class/Hiera names.
|
Ansible uses `jinjify_managed_files()` because it merges variables into role defaults or host vars.
|
||||||
|
|
||||||
Safety checks:
|
Safety checks:
|
||||||
|
|
||||||
- `missing_jinja_template_vars()` rejects Jinja2 templates that reference absent variables.
|
- `missing_jinja_template_vars()` rejects Jinja2 templates that reference absent variables.
|
||||||
- `missing_erb_template_vars()` rejects ERB templates that reference absent Puppet/Hiera variables.
|
|
||||||
|
|
||||||
When checks fail, Enroll deletes obsolete generated templates when appropriate and falls back to raw file copying.
|
When checks fail, Enroll deletes obsolete generated templates when appropriate and falls back to raw file copying.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 17. Diff, notifications, and enforcement
|
## 15. Diff, notifications, and enforcement
|
||||||
|
|
||||||
File: `diff.py`
|
File: `diff.py`
|
||||||
|
|
||||||
### 17.1 Inputs
|
### 15.1 Inputs
|
||||||
|
|
||||||
`compare_harvests()` accepts:
|
`compare_harvests()` accepts:
|
||||||
|
|
||||||
|
|
@ -1301,7 +1041,7 @@ File: `diff.py`
|
||||||
|
|
||||||
Bundle resolution is handled by `_bundle_from_input()`, which reuses `remote._safe_extract_tar()` for tarball extraction.
|
Bundle resolution is handled by `_bundle_from_input()`, which reuses `remote._safe_extract_tar()` for tarball extraction.
|
||||||
|
|
||||||
### 17.2 What diff compares
|
### 15.2 What diff compares
|
||||||
|
|
||||||
`compare_harvests()` compares:
|
`compare_harvests()` compares:
|
||||||
|
|
||||||
|
|
@ -1322,7 +1062,7 @@ Reports are formatted by:
|
||||||
format_report(report, fmt="text" | "markdown" | "json")
|
format_report(report, fmt="text" | "markdown" | "json")
|
||||||
```
|
```
|
||||||
|
|
||||||
### 17.3 Enforcement decision
|
### 15.3 Enforcement decision
|
||||||
|
|
||||||
`has_enforceable_drift()` is intentionally conservative.
|
`has_enforceable_drift()` is intentionally conservative.
|
||||||
|
|
||||||
|
|
@ -1343,29 +1083,7 @@ Not enforceable:
|
||||||
|
|
||||||
This keeps `--enforce` focused on restoring baseline state rather than deleting unknown current state or downgrading packages.
|
This keeps `--enforce` focused on restoring baseline state rather than deleting unknown current state or downgrading packages.
|
||||||
|
|
||||||
### 17.4 Target-selected enforcement
|
### 15.4 Notifications
|
||||||
|
|
||||||
`enforce_old_harvest()` now accepts `target="ansible" | "puppet" | "salt"`.
|
|
||||||
|
|
||||||
It performs:
|
|
||||||
|
|
||||||
1. resolve the old/baseline harvest,
|
|
||||||
2. build a best-effort enforcement plan from the diff report,
|
|
||||||
3. generate a temporary manifest from the old harvest using the selected target,
|
|
||||||
4. run the matching local apply tool,
|
|
||||||
5. attach enforcement metadata to the diff report.
|
|
||||||
|
|
||||||
Target commands:
|
|
||||||
|
|
||||||
```text
|
|
||||||
ansible -> ansible-playbook -i localhost, -c local playbook.yml
|
|
||||||
puppet -> puppet apply --modulepath ./modules [--hiera_config ./hiera.yaml] manifests/site.pp
|
|
||||||
salt -> salt-call --local --file-root ./states [--pillar-root ./pillar] state.apply
|
|
||||||
```
|
|
||||||
|
|
||||||
Only Ansible uses generated per-role tags to narrow the apply scope. Puppet and Salt enforcement deliberately run the full generated local manifest/state tree for now. The JSON report keeps target-specific compatibility fields such as `ansible_playbook`, `puppet`, or `salt_call`.
|
|
||||||
|
|
||||||
### 17.5 Notifications
|
|
||||||
|
|
||||||
`diff.py` also supports webhooks and email notifications:
|
`diff.py` also supports webhooks and email notifications:
|
||||||
|
|
||||||
|
|
@ -1376,9 +1094,9 @@ CLI notification options are only sent when differences exist unless `--notify-a
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 18. Explanation and validation
|
## 16. Explanation and validation
|
||||||
|
|
||||||
### 18.1 `explain.py`
|
### 16.1 `explain.py`
|
||||||
|
|
||||||
`explain_state()` reads a harvest and produces text or JSON explaining:
|
`explain_state()` reads a harvest and produces text or JSON explaining:
|
||||||
|
|
||||||
|
|
@ -1395,7 +1113,7 @@ CLI notification options are only sent when differences exist unless `--notify-a
|
||||||
|
|
||||||
This is intended to answer “what did Enroll collect and why?”
|
This is intended to answer “what did Enroll collect and why?”
|
||||||
|
|
||||||
### 18.2 `validate.py`
|
### 16.2 `validate.py`
|
||||||
|
|
||||||
`validate_harvest()` checks:
|
`validate_harvest()` checks:
|
||||||
|
|
||||||
|
|
@ -1411,7 +1129,7 @@ This is intended to answer “what did Enroll collect and why?”
|
||||||
`validate_harvest()` is used in three important contexts:
|
`validate_harvest()` is used in three important contexts:
|
||||||
|
|
||||||
- `enroll validate` exposes the checks directly to users.
|
- `enroll validate` exposes the checks directly to users.
|
||||||
- `manifest.manifest()` validates before rendering Ansible/Puppet/Salt output.
|
- `manifest.manifest()` validates before rendering Ansible output.
|
||||||
- `diff.compare_harvests()` validates both input bundles before comparing them, using `no_schema=True` so older harvests can still be inspected while artifact safety checks remain active.
|
- `diff.compare_harvests()` validates both input bundles before comparing them, using `no_schema=True` so older harvests can still be inspected while artifact safety checks remain active.
|
||||||
|
|
||||||
`diff --enforce` renders the old harvest through `manifest.manifest()`, so enforcement also passes through manifest-time validation before a local apply tool is invoked.
|
`diff --enforce` renders the old harvest through `manifest.manifest()`, so enforcement also passes through manifest-time validation before a local apply tool is invoked.
|
||||||
|
|
@ -1422,7 +1140,7 @@ The CLI supports local schema override with `--schema`, warning failure with `--
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 19. Remote harvesting
|
## 17. Remote harvesting
|
||||||
|
|
||||||
File: `remote.py`
|
File: `remote.py`
|
||||||
|
|
||||||
|
|
@ -1441,7 +1159,7 @@ It wraps `_remote_harvest()` and handles:
|
||||||
- retrying when remote sudo requires a password,
|
- retrying when remote sudo requires a password,
|
||||||
- retrying when an encrypted SSH private key needs a passphrase.
|
- retrying when an encrypted SSH private key needs a passphrase.
|
||||||
|
|
||||||
### 19.1 Remote harvest flow
|
### 17.1 Remote harvest flow
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart TD
|
flowchart TD
|
||||||
|
|
@ -1460,7 +1178,7 @@ flowchart TD
|
||||||
|
|
||||||
`_build_enroll_pyz()` packages the local `enroll` Python package into a zipapp and uses `enroll.cli:main` as its entry point.
|
`_build_enroll_pyz()` packages the local `enroll` Python package into a zipapp and uses `enroll.cli:main` as its entry point.
|
||||||
|
|
||||||
### 19.2 SSH config support
|
### 17.2 SSH config support
|
||||||
|
|
||||||
`--remote-ssh-config` enables Paramiko `SSHConfig` support for settings such as:
|
`--remote-ssh-config` enables Paramiko `SSHConfig` support for settings such as:
|
||||||
|
|
||||||
|
|
@ -1475,7 +1193,7 @@ flowchart TD
|
||||||
|
|
||||||
Unknown host keys are rejected by default through Paramiko's reject policy. Users should have valid host keys in known hosts.
|
Unknown host keys are rejected by default through Paramiko's reject policy. Users should have valid host keys in known hosts.
|
||||||
|
|
||||||
### 19.3 Safe tar extraction
|
### 17.3 Safe tar extraction
|
||||||
|
|
||||||
`_safe_extract_tar()` validates tar members before extraction and rejects:
|
`_safe_extract_tar()` validates tar members before extraction and rejects:
|
||||||
|
|
||||||
|
|
@ -1490,13 +1208,13 @@ This helper is reused by remote harvest, manifest SOPS extraction, validate/diff
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 20. SOPS support
|
## 18. SOPS support
|
||||||
|
|
||||||
File: `sopsutil.py`
|
File: `sopsutil.py`
|
||||||
|
|
||||||
SOPS support is binary tarball encryption, not field-level YAML encryption.
|
SOPS support is binary tarball encryption, not field-level YAML encryption.
|
||||||
|
|
||||||
### 20.1 Harvest SOPS mode
|
### 18.1 Harvest SOPS mode
|
||||||
|
|
||||||
`enroll harvest --sops <fingerprint...>`:
|
`enroll harvest --sops <fingerprint...>`:
|
||||||
|
|
||||||
|
|
@ -1505,7 +1223,7 @@ SOPS support is binary tarball encryption, not field-level YAML encryption.
|
||||||
3. encrypts it with SOPS binary mode,
|
3. encrypts it with SOPS binary mode,
|
||||||
4. writes `harvest.tar.gz.sops` or the requested output file.
|
4. writes `harvest.tar.gz.sops` or the requested output file.
|
||||||
|
|
||||||
### 20.2 Manifest SOPS mode
|
### 18.2 Manifest SOPS mode
|
||||||
|
|
||||||
`enroll manifest --sops <fingerprint...>`:
|
`enroll manifest --sops <fingerprint...>`:
|
||||||
|
|
||||||
|
|
@ -1514,7 +1232,7 @@ SOPS support is binary tarball encryption, not field-level YAML encryption.
|
||||||
3. tars the generated output,
|
3. tars the generated output,
|
||||||
4. encrypts it as a single SOPS file.
|
4. encrypts it as a single SOPS file.
|
||||||
|
|
||||||
### 20.3 Helpers
|
### 18.3 Helpers
|
||||||
|
|
||||||
`sopsutil.py` provides:
|
`sopsutil.py` provides:
|
||||||
|
|
||||||
|
|
@ -1527,7 +1245,7 @@ Encryption/decryption helpers write via temp files and default to mode `0600`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 21. Configuration file support
|
## 19. Configuration file support
|
||||||
|
|
||||||
`cli.py` supports optional INI config files.
|
`cli.py` supports optional INI config files.
|
||||||
|
|
||||||
|
|
@ -1551,33 +1269,21 @@ The translation is argparse-driven, so new flags often gain config-file support
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 22. CLI flags that affect multiple layers
|
## 20. CLI flags that affect multiple layers
|
||||||
|
|
||||||
### 22.1 `--target`
|
### 20.1 `--fqdn`
|
||||||
|
|
||||||
`--target ansible|puppet|salt` exists for:
|
|
||||||
|
|
||||||
- `enroll manifest`,
|
|
||||||
- `enroll single-shot`,
|
|
||||||
- `enroll diff --enforce`.
|
|
||||||
|
|
||||||
For `manifest` and `single-shot`, it chooses the output renderer. For `diff --enforce`, it chooses both the temporary manifest target and the local apply tool.
|
|
||||||
|
|
||||||
### 22.2 `--fqdn`
|
|
||||||
|
|
||||||
`--fqdn` changes output semantics, not just filenames:
|
`--fqdn` changes output semantics, not just filenames:
|
||||||
|
|
||||||
- Ansible: uses inventory/host_vars and host-specific artifacts.
|
- Ansible: uses inventory/host_vars and host-specific artifacts.
|
||||||
- Puppet: uses Hiera node data and Hiera-driven classes.
|
|
||||||
- Salt: uses pillar node data and minion-targeted top files.
|
|
||||||
|
|
||||||
`--fqdn` implies no common role grouping.
|
`--fqdn` implies no common role grouping.
|
||||||
|
|
||||||
### 22.3 `--no-common-roles`
|
### 20.2 `--no-common-roles`
|
||||||
|
|
||||||
Disables the default grouping of package/service snapshots by Debian Section or RPM Group. This preserves one generated role/module/state per package or unit snapshot.
|
Disables the default grouping of package/service snapshots by Debian Section or RPM Group. This preserves one generated role/module/state per package or unit snapshot.
|
||||||
|
|
||||||
### 22.4 `--jinjaturtle` / `--no-jinjaturtle`
|
### 20.3 `--jinjaturtle` / `--no-jinjaturtle`
|
||||||
|
|
||||||
The CLI maps these to renderer mode strings:
|
The CLI maps these to renderer mode strings:
|
||||||
|
|
||||||
|
|
@ -1587,11 +1293,9 @@ no flag -> auto
|
||||||
--no-jinjaturtle -> off
|
--no-jinjaturtle -> off
|
||||||
```
|
```
|
||||||
|
|
||||||
All three manifest targets receive this mode. Puppet uses ERB when JinjaTurtle is enabled; Ansible and Salt use Jinja2.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 23. Tests and how to navigate them
|
## 21. Tests and how to navigate them
|
||||||
|
|
||||||
Run tests with:
|
Run tests with:
|
||||||
|
|
||||||
|
|
@ -1606,402 +1310,8 @@ or the repository helper when appropriate:
|
||||||
./tests.sh
|
./tests.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Important test files:
|
or (just pytests, no root required)
|
||||||
|
|
||||||
| Test file | What it covers |
|
|
||||||
|---|---|
|
|
||||||
| `test_cli.py` | argparse dispatch, remote flags, manifest target forwarding, single-shot flow. |
|
|
||||||
| `test_cli_config_and_sops.py`, `test_cli_helpers.py` | config-file injection and SOPS output helpers. |
|
|
||||||
| `test_harvest.py`, `test_harvest_helpers.py` | harvest orchestration, sysctl/firewall helpers, role naming. |
|
|
||||||
| `test_harvest_collectors.py` | runtime and container image collectors. |
|
|
||||||
| `test_harvest_cron_logrotate.py` | cron/logrotate unification. |
|
|
||||||
| `test_harvest_symlinks.py` | nginx/apache enabled symlink capture. |
|
|
||||||
| `test_accounts.py` | users, Flatpak, Snap parsing/discovery. |
|
|
||||||
| `test_ignore.py`, `test_ignore_dir.py` | secret/noise policy. |
|
|
||||||
| `test_pathfilter.py` | include/exclude matching and expansion. |
|
|
||||||
| `test_platform.py`, `test_platform_backends.py` | platform detection and backend behaviour. |
|
|
||||||
| `test_debian.py`, `test_rpm.py`, `test_rpm_run.py` | package manager helpers. |
|
|
||||||
| `test_manifest.py`, `test_manifest_ansible.py` | Ansible rendering and role behaviour. |
|
|
||||||
| `test_manifest_puppet.py` | Puppet rendering, Hiera mode, reserved names, firewall/container/Flatpak/Snap/JinjaTurtle support. |
|
|
||||||
| `test_manifest_salt.py` | Salt rendering, pillar mode, JinjaTurtle, firewall/container/Flatpak/Snap support. |
|
|
||||||
| `test_manifest_symlinks.py` | symlink manifest output. |
|
|
||||||
| `test_jinjaturtle.py` | shared template generation and fallback safety. |
|
|
||||||
| `test_diff_bundle.py`, `test_diff_ignore_versions_exclude_enforce.py`, `test_diff_notifications.py` | diff, bundle resolution, target-selected enforcement, notifications. |
|
|
||||||
| `test_remote.py` | remote harvest, SSH/sudo prompts, safe tar extraction. |
|
|
||||||
| `test_explain.py` | harvest explanation output. |
|
|
||||||
| `test_validate.py` | schema/artifact validation. |
|
|
||||||
| `test_cm.py` | `CMModule` conflict resolution and service-package helpers. |
|
|
||||||
| `test_fsutil.py`, `test_fsutil_extra.py` | file hashing and stat metadata helpers. |
|
|
||||||
|
|
||||||
When changing behaviour, extend the closest specific tests rather than relying only on broad integration tests.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 24. Common maintenance tasks
|
|
||||||
|
|
||||||
### 24.1 Add a new thing to harvest
|
|
||||||
|
|
||||||
1. Add or extend a dataclass in `harvest_types.py` if existing snapshots cannot represent it.
|
|
||||||
2. Add a collector under `harvest_collectors/` if it is a distinct feature.
|
|
||||||
3. Add the collector to the sequence in `harvest.harvest()`.
|
|
||||||
4. Add the snapshot to the `state = {...}` object in `harvest.harvest()`.
|
|
||||||
5. Update `schema/state.schema.json`.
|
|
||||||
6. Update renderers that should emit the new resource.
|
|
||||||
7. Update `explain.py` and `validate.py` if users need visibility or artifact checks.
|
|
||||||
8. Add tests for harvest and each renderer.
|
|
||||||
|
|
||||||
### 24.2 Add a new renderer target
|
|
||||||
|
|
||||||
1. Create `<target>.py` with `manifest_from_bundle_dir()`.
|
|
||||||
2. Load state via `CMModule.load_state()` or `state.load_state()`.
|
|
||||||
3. Consume `roles_from_state()` and `inventory_packages_from_state()`.
|
|
||||||
4. Convert snapshots into renderer-specific role/module/state objects.
|
|
||||||
5. Reuse `CMModule.package_service_entries()` for package/service grouping.
|
|
||||||
6. Run conflict resolution if the target compiles a global catalog.
|
|
||||||
7. Write target output and README.
|
|
||||||
8. Add the target to `manifest.manifest()` validation and dispatch.
|
|
||||||
9. Add CLI choices in `_add_common_manifest_args()` and diff enforcement if applicable.
|
|
||||||
10. Add tests.
|
|
||||||
|
|
||||||
### 24.3 Add a new CLI flag
|
|
||||||
|
|
||||||
For harvest-affecting flags:
|
|
||||||
|
|
||||||
1. add the flag to `cli.py` for `harvest` and possibly `single-shot`,
|
|
||||||
2. forward it to `harvest.harvest()` or `remote.remote_harvest()`,
|
|
||||||
3. forward it through remote command construction if remote mode needs it,
|
|
||||||
4. check whether config-file injection handles it,
|
|
||||||
5. add tests in `test_cli.py` and feature-specific tests.
|
|
||||||
|
|
||||||
For manifest-affecting flags:
|
|
||||||
|
|
||||||
1. add it to `_add_common_manifest_args()` if all manifest-like commands need it,
|
|
||||||
2. forward it through `manifest.manifest()`,
|
|
||||||
3. forward it to target renderers,
|
|
||||||
4. add tests for forwarding and output.
|
|
||||||
|
|
||||||
For diff enforcement flags:
|
|
||||||
|
|
||||||
1. add argparse support under the `diff` subparser,
|
|
||||||
2. pass values to `compare_harvests()` or `enforce_old_harvest()`,
|
|
||||||
3. update report formatting if new fields appear,
|
|
||||||
4. add tests in `test_diff_ignore_versions_exclude_enforce.py` or `test_diff_notifications.py`.
|
|
||||||
|
|
||||||
### 24.4 Change file safety rules
|
|
||||||
|
|
||||||
Modify `ignore.py` and add tests in `test_ignore.py` / `test_ignore_dir.py`.
|
|
||||||
|
|
||||||
Be careful:
|
|
||||||
|
|
||||||
- relaxing safety affects secret exposure risk,
|
|
||||||
- tightening safety can make expected config disappear,
|
|
||||||
- binary allowance matters for APT/RPM keyrings,
|
|
||||||
- `--dangerous` must remain explicit for risky harvesting.
|
|
||||||
|
|
||||||
### 24.5 Change service/package attribution
|
|
||||||
|
|
||||||
Most logic is in:
|
|
||||||
|
|
||||||
- `harvest_collectors/services.py`,
|
|
||||||
- `package_hints.py`,
|
|
||||||
- `system_paths.py`,
|
|
||||||
- package backend `modified_paths()` implementations.
|
|
||||||
|
|
||||||
Preserve these invariants:
|
|
||||||
|
|
||||||
- cron/logrotate should stay unified when installed,
|
|
||||||
- shared directories should not be attributed too broadly,
|
|
||||||
- package-manager config belongs in `apt_config`/`dnf_config`,
|
|
||||||
- `captured_global` should prevent duplicates,
|
|
||||||
- stopped services should not receive broad restart notifications.
|
|
||||||
|
|
||||||
### 24.6 Change manifest role grouping
|
|
||||||
|
|
||||||
Common grouping uses:
|
|
||||||
|
|
||||||
- `CMModule.package_service_entries()`,
|
|
||||||
- `package_section_label()`,
|
|
||||||
- `section_label_for_packages()`.
|
|
||||||
|
|
||||||
Remember:
|
|
||||||
|
|
||||||
- default non-`--fqdn` output groups package/service roles unless `--no-common-roles` is set,
|
|
||||||
- `--fqdn` implies per-role output,
|
|
||||||
- Ansible, Puppet, and Salt grouping should stay conceptually aligned,
|
|
||||||
- Puppet/Salt need `resolve_catalog_conflicts()` after grouping.
|
|
||||||
|
|
||||||
### 24.7 Change JinjaTurtle support
|
|
||||||
|
|
||||||
Shared path support and safety checks belong in `jinjaturtle.py`.
|
|
||||||
|
|
||||||
Renderer-specific behaviour belongs in the renderer:
|
|
||||||
|
|
||||||
- Ansible: variables in defaults or host vars, templates under role `templates/`.
|
|
||||||
- Puppet: ERB templates, class params or Hiera values.
|
|
||||||
- Salt: `file.managed` context and Salt-safe Jinja rewrites.
|
|
||||||
|
|
||||||
Fallback-to-raw-copy is part of the product contract unless JinjaTurtle was explicitly required and missing.
|
|
||||||
|
|
||||||
### 24.8 Change diff enforcement
|
|
||||||
|
|
||||||
`diff --enforce` now has a target dimension.
|
|
||||||
|
|
||||||
When changing it, keep these distinctions clear:
|
|
||||||
|
|
||||||
- `has_enforceable_drift()` decides whether enforcement should run.
|
|
||||||
- `_enforcement_plan()` finds relevant baseline roles.
|
|
||||||
- Ansible uses role tags from the plan.
|
|
||||||
- Puppet and Salt currently run a full manifest/state apply.
|
|
||||||
- `_enforcement_command()` is the source of truth for local apply commands.
|
|
||||||
- `cli.py` attaches enforcement metadata to the report and formats it.
|
|
||||||
|
|
||||||
Do not make enforcement delete newly added packages/users/files/services unless the safety model is explicitly redesigned.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 25. Important maintenance hazards
|
|
||||||
|
|
||||||
### 25.1 Renderer output is downstream of harvest state
|
|
||||||
|
|
||||||
If a renderer needs information, first ask whether that information belongs in `state.json`. Avoid papering over missing harvest facts inside a renderer.
|
|
||||||
|
|
||||||
### 25.2 `--fqdn` mode is not cosmetic
|
|
||||||
|
|
||||||
`--fqdn` changes where variables and artifacts live and how target inclusion works.
|
|
||||||
|
|
||||||
A change that works in default mode can still break:
|
|
||||||
|
|
||||||
- Ansible host vars,
|
|
||||||
- Puppet Hiera node data,
|
|
||||||
- Salt pillar node data.
|
|
||||||
|
|
||||||
### 25.3 Puppet and Salt are stricter about duplicates
|
|
||||||
|
|
||||||
Ansible often tolerates repeated packages or tasks. Puppet and Salt compile catalogs where duplicate resources can fail. Keep `resolve_catalog_conflicts()` in mind whenever adding resources.
|
|
||||||
|
|
||||||
### 25.4 Secret avoidance is part of the product contract
|
|
||||||
|
|
||||||
Default harvest should avoid likely secrets. `--dangerous` exists because useful files may contain secrets. Do not silently make risky harvesting the default.
|
|
||||||
|
|
||||||
### 25.5 Runtime state should not override persistent config
|
|
||||||
|
|
||||||
Firewall runtime capture is skipped when persistent firewall config exists. Preserve this principle for future runtime snapshots.
|
|
||||||
|
|
||||||
### 25.6 JinjaTurtle is best-effort except when explicitly required
|
|
||||||
|
|
||||||
`auto` mode should not make manifest generation fail merely because templating failed. `on` should require the executable; unsupported or unsafe individual files should still fall back to raw copy unless code explicitly changes that contract.
|
|
||||||
|
|
||||||
### 25.7 Role names must be sanitised
|
|
||||||
|
|
||||||
Raw package/service names can be invalid or reserved in Ansible roles, Puppet classes, or Salt SLS names. Use role-name helpers and singleton collision protection.
|
|
||||||
|
|
||||||
### 25.8 Tests encode edge cases
|
|
||||||
|
|
||||||
Many behaviours exist because of previously found edge cases:
|
|
||||||
|
|
||||||
- non-root/no-sudo harvests,
|
|
||||||
- Puppet reserved words,
|
|
||||||
- Salt Docker module availability limitations,
|
|
||||||
- symlink capture,
|
|
||||||
- JinjaTurtle missing variables,
|
|
||||||
- Salt JSON filter compatibility,
|
|
||||||
- file caps,
|
|
||||||
- SOPS secure temp files,
|
|
||||||
- tar path traversal,
|
|
||||||
- target-selected diff enforcement.
|
|
||||||
|
|
||||||
Before simplifying logic, search the tests.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 26. Troubleshooting guide
|
|
||||||
|
|
||||||
### 26.1 Generated manifest references a missing artifact
|
|
||||||
|
|
||||||
Likely causes:
|
|
||||||
|
|
||||||
- `managed_files[*].src_rel` was added without copying into `artifacts/`,
|
|
||||||
- a renderer used the generated role/module name instead of the artifact role,
|
|
||||||
- a role was renamed after harvest but before artifact lookup,
|
|
||||||
- `--fqdn` file prefixes are wrong.
|
|
||||||
|
|
||||||
Start with:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
enroll validate /path/to/harvest
|
./pytests.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Then inspect:
|
|
||||||
|
|
||||||
```text
|
|
||||||
state.json roles.*.managed_files[*]
|
|
||||||
artifacts/<role>/<src_rel>
|
|
||||||
```
|
|
||||||
|
|
||||||
### 26.2 Puppet fails with duplicate resources
|
|
||||||
|
|
||||||
Check:
|
|
||||||
|
|
||||||
- `_collect_puppet_roles()`,
|
|
||||||
- `resolve_catalog_conflicts()`,
|
|
||||||
- `role_order_key()`,
|
|
||||||
- whether a new resource type needs conflict resolution,
|
|
||||||
- whether a directory resource conflicts with a file/link of the same path.
|
|
||||||
|
|
||||||
### 26.3 Salt fails with duplicate IDs or missing modules
|
|
||||||
|
|
||||||
Check:
|
|
||||||
|
|
||||||
- `_state_id()` naming,
|
|
||||||
- `_collect_salt_roles()` grouping,
|
|
||||||
- `resolve_catalog_conflicts()`,
|
|
||||||
- guarded `cmd.run` fallbacks for Docker/Podman/Snap/Flatpak.
|
|
||||||
|
|
||||||
Salt uses guarded shell commands for some resources because native states/modules are not consistently available across Salt installations.
|
|
||||||
|
|
||||||
### 26.4 Ansible check mode reports unexpected changes
|
|
||||||
|
|
||||||
Check:
|
|
||||||
|
|
||||||
- role ordering,
|
|
||||||
- grouped mode versus `--fqdn` / `--no-common-roles`,
|
|
||||||
- handler notifications,
|
|
||||||
- whether runtime roles were emitted without runtime artifacts,
|
|
||||||
- harvested directory/file mode normalisation.
|
|
||||||
|
|
||||||
Grouped and per-role output can legitimately produce different numbers of reported changes.
|
|
||||||
|
|
||||||
### 26.5 A file was not harvested
|
|
||||||
|
|
||||||
Check, in order:
|
|
||||||
|
|
||||||
1. Was it excluded by `--exclude-path`?
|
|
||||||
2. Was it denied by `IgnorePolicy`?
|
|
||||||
3. Was it too large?
|
|
||||||
4. Did it look binary?
|
|
||||||
5. Did it contain sensitive-looking content?
|
|
||||||
6. Was it already captured by another role via `captured_global`?
|
|
||||||
7. Is it outside known scanned locations?
|
|
||||||
8. Would `--include-path` collect it?
|
|
||||||
9. Does it require `--dangerous`?
|
|
||||||
|
|
||||||
`enroll explain` can show notes and exclusion reasons.
|
|
||||||
|
|
||||||
### 26.6 `diff --enforce` fails
|
|
||||||
|
|
||||||
Check:
|
|
||||||
|
|
||||||
- whether the selected `--target` tool is on `PATH`,
|
|
||||||
- `ansible-playbook` for Ansible,
|
|
||||||
- `puppet` for Puppet,
|
|
||||||
- `salt-call` for Salt,
|
|
||||||
- whether the generated temp manifest has the expected target entrypoint,
|
|
||||||
- whether the report contains enforceable drift,
|
|
||||||
- whether package drift is only version changes or additions, which enforcement skips.
|
|
||||||
|
|
||||||
### 26.7 Remote harvest fails with sudo or SSH key prompts
|
|
||||||
|
|
||||||
Relevant flags:
|
|
||||||
|
|
||||||
- `--ask-become-pass`,
|
|
||||||
- `--ask-key-passphrase`,
|
|
||||||
- `--ssh-key-passphrase-env`,
|
|
||||||
- `--no-sudo`,
|
|
||||||
- `--remote-ssh-config`.
|
|
||||||
|
|
||||||
Interactive sessions can prompt and retry. Non-interactive sessions should pass explicit flags or environment variables.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 27. Practical code-reading map
|
|
||||||
|
|
||||||
| Feature/question | Start with | Then read |
|
|
||||||
|---|---|---|
|
|
||||||
| CLI option behaviour | `cli.py` | called module for `args.cmd` |
|
|
||||||
| Local harvest ordering | `harvest.py:harvest()` | `harvest_collectors/` |
|
|
||||||
| Why a file was skipped | `capture.py`, `ignore.py`, `pathfilter.py` | `explain.py` |
|
|
||||||
| File metadata/hash helpers | `fsutil.py` | `debian.py`, `capture.py` |
|
|
||||||
| Service/package attribution | `harvest_collectors/services.py` | `package_hints.py`, `platform.py` |
|
|
||||||
| APT/DNF config capture | `harvest_collectors/package_manager.py` | `system_paths.py` |
|
|
||||||
| Users and SSH keys | `harvest_collectors/users.py` | `accounts.py` |
|
|
||||||
| Flatpak/Snap parsing | `accounts.py` | renderer Flatpak/Snap helpers |
|
|
||||||
| Docker/Podman images | `harvest_collectors/container_images.py` | renderer container image helpers |
|
|
||||||
| Runtime firewall | `harvest_collectors/runtime.py`, `harvest.py` | renderer firewall helpers |
|
|
||||||
| Sysctl | `harvest.py` sysctl helpers | renderer sysctl role functions |
|
|
||||||
| Ansible output | `ansible.py:AnsibleManifestRenderer.render()` | `_render_*` helpers |
|
|
||||||
| Puppet output | `puppet.py:PuppetManifestRenderer.render()` | `_collect_puppet_roles()` |
|
|
||||||
| Salt output | `salt.py:SaltManifestRenderer.render()` | `_collect_salt_roles()` |
|
|
||||||
| Grouping/common roles | `cm.py` | renderer collection functions |
|
|
||||||
| JinjaTurtle | `jinjaturtle.py` | renderer managed-content code |
|
|
||||||
| Diff/enforce | `diff.py` | `manifest.py`, target renderer |
|
|
||||||
| Validation | `validate.py` | schema file and `state.json` |
|
|
||||||
| Remote mode | `remote.py` | `cli.py` remote branches |
|
|
||||||
| SOPS | `sopsutil.py` | `cli.py`, `manifest.py`, `diff.py` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 28. Glossary
|
|
||||||
|
|
||||||
**Harvest bundle**
|
|
||||||
A directory or encrypted tarball containing `state.json` and `artifacts/`.
|
|
||||||
|
|
||||||
**Snapshot**
|
|
||||||
A structured object under `roles` in `state.json`, such as a `ServiceSnapshot` or `PackageSnapshot`.
|
|
||||||
|
|
||||||
**Managed file**
|
|
||||||
A file Enroll intends generated CM code to recreate. It has a destination path and a matching artifact file.
|
|
||||||
|
|
||||||
**Managed link**
|
|
||||||
A symlink Enroll intends generated CM code to recreate.
|
|
||||||
|
|
||||||
**Managed dir**
|
|
||||||
A directory Enroll intends generated CM code to ensure exists with recorded metadata.
|
|
||||||
|
|
||||||
**Role**
|
|
||||||
The Enroll logical group for related resources. In Ansible it usually maps to an Ansible role. In Puppet it maps to a module/class. In Salt it maps to an SLS role.
|
|
||||||
|
|
||||||
**Artifact role**
|
|
||||||
The role directory under `artifacts/` that contains a harvested file. This can differ from the generated renderer role when grouping is enabled.
|
|
||||||
|
|
||||||
**Common/grouped role**
|
|
||||||
A generated role/module/state that merges multiple package/service snapshots by Debian Section or RPM Group.
|
|
||||||
|
|
||||||
**Site mode / `--fqdn` mode**
|
|
||||||
Host-specific output mode. Ansible uses host vars, Puppet uses Hiera node data, and Salt uses pillar node data.
|
|
||||||
|
|
||||||
**Dangerous mode**
|
|
||||||
Explicit opt-in mode that relaxes safety checks and enables risky capture such as user shell dotfiles.
|
|
||||||
|
|
||||||
**JinjaTurtle**
|
|
||||||
Optional external tool used to convert recognised config files into Jinja2 or ERB templates plus variable defaults/context.
|
|
||||||
|
|
||||||
**Enforcement target**
|
|
||||||
The config manager chosen for `diff --enforce` with `--target ansible|puppet|salt`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 29. Final maintenance model
|
|
||||||
|
|
||||||
Most changes should preserve this pipeline:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Collect facts and files safely
|
|
||||||
-> represent them in target-neutral state.json
|
|
||||||
-> keep artifact references consistent
|
|
||||||
-> let each renderer translate the same state into its own idioms
|
|
||||||
-> validate the bundle and test each target
|
|
||||||
```
|
|
||||||
|
|
||||||
Before changing code, ask:
|
|
||||||
|
|
||||||
1. Is this a harvest concern or renderer concern?
|
|
||||||
2. Does `state.json` or the schema need to change?
|
|
||||||
3. Does this affect `--fqdn` mode?
|
|
||||||
4. Does this introduce duplicate ownership of a path/resource?
|
|
||||||
5. Does this weaken default secret avoidance?
|
|
||||||
6. Do Puppet and Salt need conflict handling?
|
|
||||||
7. Does JinjaTurtle fallback still behave safely?
|
|
||||||
8. Does `diff --enforce --target ...` still do the conservative thing?
|
|
||||||
9. Do existing tests explain why the current behaviour exists?
|
|
||||||
|
|
||||||
Keeping those boundaries clear is the main way to maintain Enroll without creating subtle cross-target regressions.
|
|
||||||
|
|
|
||||||
82
README.md
82
README.md
|
|
@ -4,7 +4,7 @@
|
||||||
<img src="https://git.mig5.net/mig5/enroll/raw/branch/main/enroll.svg" alt="Enroll logo" width="240" />
|
<img src="https://git.mig5.net/mig5/enroll/raw/branch/main/enroll.svg" alt="Enroll logo" width="240" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
**enroll** inspects a Linux machine (Debian-like or RedHat-like) and generates configuration-management code from it, as Ansible (default), Puppet or Salt.
|
**enroll** inspects a Linux machine (Debian-like or RedHat-like) and generates Ansible configuration-management code from it.
|
||||||
|
|
||||||
- Detects packages that have been installed.
|
- Detects packages that have been installed.
|
||||||
- Detects package ownership of `/etc` files where possible
|
- Detects package ownership of `/etc` files where possible
|
||||||
|
|
@ -27,7 +27,7 @@
|
||||||
`enroll` works in two phases:
|
`enroll` works in two phases:
|
||||||
|
|
||||||
1) **Harvest**: collect host facts + relevant files into a harvest bundle (`state.json` + harvested artifacts)
|
1) **Harvest**: collect host facts + relevant files into a harvest bundle (`state.json` + harvested artifacts)
|
||||||
2) **Manifest**: turn that harvest into configuration-management code such as Ansible, Puppet or Salt.
|
2) **Manifest**: turn that harvest into Ansible configuration-management code.
|
||||||
|
|
||||||
Additionally, some other functionalities exist:
|
Additionally, some other functionalities exist:
|
||||||
|
|
||||||
|
|
@ -38,8 +38,6 @@ Additionally, some other functionalities exist:
|
||||||
|
|
||||||
## Output modes: single-site vs multi-site (`--fqdn`)
|
## Output modes: single-site vs multi-site (`--fqdn`)
|
||||||
|
|
||||||
`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`)
|
### Single-site mode (default: *no* `--fqdn`)
|
||||||
Use when enrolling **one server** (or generating a “golden” role set you intend to reuse).
|
Use when enrolling **one server** (or generating a “golden” role set you intend to reuse).
|
||||||
|
|
||||||
|
|
@ -124,7 +122,7 @@ enroll single-shot --remote-host myhost.example.com --remote-user myuser --ssh-k
|
||||||
---
|
---
|
||||||
|
|
||||||
### `enroll manifest`
|
### `enroll manifest`
|
||||||
Generate configuration-management output from an existing harvest bundle. Ansible remains the default; use `--target puppet` for Puppet output or `--target salt` for Salt output.
|
Generate Ansible output from an existing harvest bundle.
|
||||||
|
|
||||||
**Inputs**
|
**Inputs**
|
||||||
- `--harvest /path/to/harvest` (directory)
|
- `--harvest /path/to/harvest` (directory)
|
||||||
|
|
@ -132,13 +130,10 @@ Generate configuration-management output from an existing harvest bundle. Ansibl
|
||||||
|
|
||||||
**Output**
|
**Output**
|
||||||
- In plaintext Ansible 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 plaintext Salt mode: a Salt state tree under `states/`, plus `pillar/` data in `--fqdn` mode. By default, package and service resources are grouped by Debian Section/RPM Group where possible; `--fqdn` or `--no-common-roles` preserves one generated SLS role per Enroll role/snapshot.
|
|
||||||
- In `--sops` mode: a single encrypted file `manifest.tar.gz.sops` containing the generated output.
|
- In `--sops` mode: a single encrypted file `manifest.tar.gz.sops` containing the generated output.
|
||||||
|
|
||||||
**Common flags**
|
**Common flags**
|
||||||
- `--target ansible|puppet|salt`: choose the manifest target (`ansible` is the default).
|
- `--fqdn <host>`: enables **multi-site** output style for Ansible (host-specific state lives in inventory `host_vars`).
|
||||||
- `--fqdn <host>`: enables **multi-site** output style for Ansible, emits Puppet Hiera/node output, or emits Salt top/pillar output targeted at that minion ID. Without `--fqdn`, Puppet emits `node default { ... }` and Salt targets `*` in `states/top.sls`.
|
|
||||||
- `--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.
|
- `--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**
|
**Role tags**
|
||||||
|
|
@ -159,7 +154,7 @@ Convenience wrapper that runs **harvest → manifest** in one command.
|
||||||
|
|
||||||
Use this when you want “get me something workable ASAP”.
|
Use this when you want “get me something workable ASAP”.
|
||||||
|
|
||||||
Supports the same general flags as harvest/manifest, including `--target`, `--fqdn`, `--no-common-roles`, remote harvest flags, and `--sops`.
|
Supports the same general flags as harvest/manifest, including `--fqdn`, `--no-common-roles`, remote harvest flags, and `--sops`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -178,14 +173,14 @@ Compare two harvest bundles and report what changed.
|
||||||
- `--exclude-path <PATTERN>` (repeatable) to ignore file/dir drift under matching paths (same pattern syntax as harvest)
|
- `--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)
|
- `--ignore-package-versions` to ignore package version-only drift (upgrades/downgrades)
|
||||||
- `--enforce` to apply the **old** harvest state locally (requires the relevant config manager tool on `PATH` - defaults to `ansible-playbook`)
|
- `--enforce` to apply the **old** harvest state locally (requires the relevant config manager tool on `PATH` - defaults to `ansible-playbook`)
|
||||||
- `--target` when using `--enforce`, to set the desired config manager tool to manifest to and run)
|
- `--enforce` runs `ansible-playbook` against the regenerated manifest)
|
||||||
|
|
||||||
**Noise suppression**
|
**Noise suppression**
|
||||||
- `--exclude-path` is useful for things that change often but you still want in the harvest baseline (e.g. `/var/anacron`).
|
- `--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.
|
- `--ignore-package-versions` keeps routine upgrades from alerting; package add/remove drift is still reported.
|
||||||
|
|
||||||
**Enforcement (`--enforce` (`--target`))**
|
**Enforcement (`--enforce`)**
|
||||||
If a diff exists and the config manager defined by `--target` (default: ansible) is on the PATH, Enroll will:
|
If a diff exists and `ansible-playbook` is on the PATH, Enroll will:
|
||||||
1) generate a manifest from the **old** harvest into a temporary directory
|
1) generate a manifest from the **old** harvest into a temporary directory
|
||||||
2) run the config manager tool against that manifest
|
2) run the config manager tool against that manifest
|
||||||
3) record in the diff report that the old harvest was enforced
|
3) record in the diff report that the old harvest was enforced
|
||||||
|
|
@ -316,16 +311,6 @@ For Ansible:
|
||||||
- single-site: `roles/<role>/defaults/main.yml`
|
- single-site: `roles/<role>/defaults/main.yml`
|
||||||
- multi-site: `inventory/host_vars/<fqdn>/<role>.yml`
|
- multi-site: `inventory/host_vars/<fqdn>/<role>.yml`
|
||||||
|
|
||||||
For Salt:
|
|
||||||
- Templates live in `states/roles/<role>/templates/...`
|
|
||||||
- `file.managed` uses `template: jinja` with per-file `context` values
|
|
||||||
- In `--fqdn` mode, template context values are written to pillar with the file metadata
|
|
||||||
|
|
||||||
For Puppet:
|
|
||||||
- JinjaTurtle will use its 'erb' mode if you are running a recent-enough version.
|
|
||||||
- Templates will be stored in `modules/<module>/templates/<file>.erb`
|
|
||||||
- In `--fqdn` mode, template context values are written to Hiera data.
|
|
||||||
|
|
||||||
You can force template generation on with `--jinjaturtle` or disable it with `--no-jinjaturtle`.
|
You can force template generation on with `--jinjaturtle` or disable it with `--no-jinjaturtle`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -473,53 +458,6 @@ If Docker or Podman is available during harvest, Enroll records local image-cach
|
||||||
|
|
||||||
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.
|
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 currently managed with `exec` statements. I know that's nasty, but the `puppetlabs-docker` module is even nastier and creates non-idempotent bash scripts for executing on every run. Worse, if you then reharvest that host that has Puppet installed, you'll get a File resource collision with that very shell script. Believe me, for the simple use case of 'make sure this Docker image is installed', this simple solution is better.
|
|
||||||
|
|
||||||
### Salt target
|
|
||||||
```bash
|
|
||||||
enroll manifest --harvest /tmp/enroll-harvest --out /tmp/enroll-salt --target salt
|
|
||||||
```
|
|
||||||
|
|
||||||
The Salt target renders native packages, users/groups, managed directories/files/symlinks, basic service state, and the generated sysctl file/apply command when present. Without `--fqdn`, it writes a self-contained state tree under `states/` and targets all minions in `states/top.sls`:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd /tmp/enroll-salt
|
|
||||||
sudo salt-call --local --file-root ./states state.apply test=True
|
|
||||||
```
|
|
||||||
|
|
||||||
With `--fqdn`, it uses Salt's state/pillar split: `states/top.sls` targets the minion ID to reusable generated role SLS files, while `pillar/top.sls` targets the same minion ID to node-specific data under `pillar/nodes/`. Host-specific file artifacts are stored under `states/roles/<role>/files/nodes/<fqdn>/...` and referenced through `salt://` URLs:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
enroll manifest --harvest /tmp/enroll-harvest --out /tmp/enroll-salt --target salt --fqdn host.example.com
|
|
||||||
cd /tmp/enroll-salt
|
|
||||||
sudo salt-call --local --file-root ./states --pillar-root ./pillar --id host.example.com state.apply test=True
|
|
||||||
```
|
|
||||||
|
|
||||||
Re-running Salt `--fqdn` output into the same directory adds or replaces that minion's top/pillar data without deleting other generated minions.
|
|
||||||
|
|
||||||
Docker and Podman images with registry digests are rendered as guarded `cmd.run` states that use the local `docker`/`podman` CLI directly (`pull`, `image inspect`, and `tag`).
|
|
||||||
|
|
||||||
This is because Salt Stack, in 3008, does not have proper Docker extensions that actually work. Wow. It's a bit like Puppet. Seriously, you should probably just be using Ansible.
|
|
||||||
|
|
||||||
|
|
||||||
### Manifest with `--sops`
|
### Manifest with `--sops`
|
||||||
```bash
|
```bash
|
||||||
# Generate encrypted manifest bundle (writes /tmp/enroll-ansible/manifest.tar.gz.sops)
|
# Generate encrypted manifest bundle (writes /tmp/enroll-ansible/manifest.tar.gz.sops)
|
||||||
|
|
@ -705,14 +643,12 @@ exclude_path = /usr/local/bin/docker-*, /usr/local/bin/some-tool
|
||||||
# you can set defaults here too, e.g.
|
# you can set defaults here too, e.g.
|
||||||
no_jinjaturtle = true
|
no_jinjaturtle = true
|
||||||
sops = 54A91143AE0AB4F7743B01FE888ED1B423A3BC99
|
sops = 54A91143AE0AB4F7743B01FE888ED1B423A3BC99
|
||||||
# target = ansible (the default), or salt, or puppet
|
|
||||||
|
|
||||||
[diff]
|
[diff]
|
||||||
# ignore noisy drift
|
# ignore noisy drift
|
||||||
exclude_path = /var/anacron
|
exclude_path = /var/anacron
|
||||||
ignore_package_versions = true
|
ignore_package_versions = true
|
||||||
# enforce = true # requires the target config manager on PATH
|
# enforce = true # requires ansible-playbook on PATH
|
||||||
# target = puppet (for example, as per manifest)
|
|
||||||
|
|
||||||
[single-shot]
|
[single-shot]
|
||||||
# if you use single-shot, put its defaults here.
|
# if you use single-shot, put its defaults here.
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ In particular:
|
||||||
* If an `enroll.ini` configuration file is loaded, its location and contents are assumed to be owned, selected, and understood by the operator.
|
* If an `enroll.ini` configuration file is loaded, its location and contents are assumed to be owned, selected, and understood by the operator.
|
||||||
* The operator is expected to understand the implications of options such as `--dangerous`, `--assume-safe-path`, `--sops`, `--enforce`, `--remote-host`, and `--remote-ssh-config`.
|
* The operator is expected to understand the implications of options such as `--dangerous`, `--assume-safe-path`, `--sops`, `--enforce`, `--remote-host`, and `--remote-ssh-config`.
|
||||||
* Harvest bundles used for `manifest`, `diff`, or `diff --enforce` are assumed to come from a trusted source unless the operator is deliberately inspecting untrusted input without applying it.
|
* Harvest bundles used for `manifest`, `diff`, or `diff --enforce` are assumed to come from a trusted source unless the operator is deliberately inspecting untrusted input without applying it.
|
||||||
* Configuration-management tools invoked by Enroll, such as Ansible, Puppet, Salt, SOPS, SSH, `sudo`, Docker, Podman, Flatpak, Snap, package managers, and system utilities, are assumed to be the trusted tools the operator intended to use.
|
* Configuration-management tools invoked by Enroll, such as Ansible, SOPS, SSH, `sudo`, Docker, Podman, Flatpak, Snap, package managers, and system utilities, are assumed to be the trusted tools the operator intended to use.
|
||||||
|
|
||||||
## What is in scope
|
## What is in scope
|
||||||
|
|
||||||
|
|
@ -31,7 +31,7 @@ In-scope security concerns include:
|
||||||
* Writing plaintext harvest outputs into private directories by default.
|
* Writing plaintext harvest outputs into private directories by default.
|
||||||
* Hardening root-run output path handling so Enroll does not accidentally write through attacker-prepared symlinks or unsafe parent directories.
|
* Hardening root-run output path handling so Enroll does not accidentally write through attacker-prepared symlinks or unsafe parent directories.
|
||||||
* Refusing to continue non-interactively when run as root with an unsafe `PATH`, unless the operator explicitly confirms with `--assume-safe-path`.
|
* Refusing to continue non-interactively when run as root with an unsafe `PATH`, unless the operator explicitly confirms with `--assume-safe-path`.
|
||||||
* Avoiding shell injection in generated manifests where harvested values are embedded into Ansible, Puppet, or Salt output.
|
* Avoiding shell injection in generated manifests where harvested values are embedded into Ansible output.
|
||||||
* Rejecting unknown SSH host keys by default during remote harvests.
|
* Rejecting unknown SSH host keys by default during remote harvests.
|
||||||
|
|
||||||
These measures are defense-in-depth. They are intended to reduce the chance of accidental exposure, unsafe filesystem writes, path traversal, command injection, or dangerous behavior when Enroll is used normally by an administrator.
|
These measures are defense-in-depth. They are intended to reduce the chance of accidental exposure, unsafe filesystem writes, path traversal, command injection, or dangerous behavior when Enroll is used normally by an administrator.
|
||||||
|
|
@ -45,7 +45,7 @@ The following are generally out of scope and should not be reported as Enroll vu
|
||||||
* A root user passing `--dangerous` and then observing that Enroll may collect sensitive information.
|
* A root user passing `--dangerous` and then observing that Enroll may collect sensitive information.
|
||||||
* A root user passing `--assume-safe-path` and then observing that Enroll does not prompt about `PATH` safety.
|
* A root user passing `--assume-safe-path` and then observing that Enroll does not prompt about `PATH` safety.
|
||||||
* A root user enforcing a malicious or manually edited harvest bundle with `diff --enforce`.
|
* A root user enforcing a malicious or manually edited harvest bundle with `diff --enforce`.
|
||||||
* A user applying generated Ansible, Puppet, or Salt manifests from an untrusted harvest.
|
* A user applying generated Ansible manifests from an untrusted harvest.
|
||||||
* A user configuring a webhook, email target, SSH proxy command, SOPS binary, package manager, or configuration-management tool that they do not trust.
|
* A user configuring a webhook, email target, SSH proxy command, SOPS binary, package manager, or configuration-management tool that they do not trust.
|
||||||
* A compromised system where an attacker already controls root-owned files, root’s shell, root’s configuration, or the privileged tools Enroll invokes.
|
* A compromised system where an attacker already controls root-owned files, root’s shell, root’s configuration, or the privileged tools Enroll invokes.
|
||||||
* Reports that amount to “if root runs this tool with malicious options, root can make the system do dangerous things.”
|
* Reports that amount to “if root runs this tool with malicious options, root can make the system do dangerous things.”
|
||||||
|
|
@ -79,7 +79,7 @@ Useful vulnerability reports include issues where Enroll behaves unsafely despit
|
||||||
* Enroll follows a symlink or hardlink in a way that causes privileged file disclosure or overwrite.
|
* Enroll follows a symlink or hardlink in a way that causes privileged file disclosure or overwrite.
|
||||||
* Enroll extracts a tar member outside the intended harvest directory.
|
* Enroll extracts a tar member outside the intended harvest directory.
|
||||||
* Enroll accepts a malicious harvest artifact that escapes the artifact root.
|
* Enroll accepts a malicious harvest artifact that escapes the artifact root.
|
||||||
* Enroll generates an Ansible, Puppet, or Salt manifest where ordinary harvested data can cause command injection.
|
* Enroll generates an Ansible manifest where ordinary harvested data can cause command injection.
|
||||||
* Enroll writes root-run output into an unsafe attacker-controlled path despite its safety checks.
|
* Enroll writes root-run output into an unsafe attacker-controlled path despite its safety checks.
|
||||||
* Enroll silently ignores a failed safety check and proceeds anyway.
|
* Enroll silently ignores a failed safety check and proceeds anyway.
|
||||||
* Enroll accepts an unknown SSH host key unexpectedly.
|
* Enroll accepts an unknown SSH host key unexpectedly.
|
||||||
|
|
|
||||||
|
|
@ -724,7 +724,7 @@ def _write_ansible_role_vars(
|
||||||
*,
|
*,
|
||||||
site_defaults: Optional[Dict[str, Any]] = None,
|
site_defaults: Optional[Dict[str, Any]] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Write role variables using the same mode split as Puppet Hiera/Salt Pillar."""
|
"""Write role variables using the single-site/site-mode split."""
|
||||||
|
|
||||||
if ctx.site_mode:
|
if ctx.site_mode:
|
||||||
_write_role_defaults(role_dir, site_defaults or {})
|
_write_role_defaults(role_dir, site_defaults or {})
|
||||||
|
|
|
||||||
|
|
@ -458,15 +458,9 @@ def _encrypt_harvest_dir_to_sops(
|
||||||
|
|
||||||
|
|
||||||
def _add_common_manifest_args(p: argparse.ArgumentParser) -> None:
|
def _add_common_manifest_args(p: argparse.ArgumentParser) -> None:
|
||||||
p.add_argument(
|
|
||||||
"--target",
|
|
||||||
choices=["ansible", "puppet", "salt"],
|
|
||||||
default="ansible",
|
|
||||||
help="Manifest target to generate (default: ansible).",
|
|
||||||
)
|
|
||||||
p.add_argument(
|
p.add_argument(
|
||||||
"--fqdn",
|
"--fqdn",
|
||||||
help="Host FQDN/name for site-mode output (creates target-specific host inventory/data such as Ansible host_vars, Puppet Hiera, or Salt pillar).",
|
help="Host FQDN/name for site-mode output (creates Ansible host_vars for that host).",
|
||||||
)
|
)
|
||||||
p.add_argument(
|
p.add_argument(
|
||||||
"--no-common-roles",
|
"--no-common-roles",
|
||||||
|
|
@ -808,15 +802,6 @@ def main() -> None:
|
||||||
"Enroll does not attempt to downgrade packages; if the only drift is package version upgrades (or newly installed packages), enforcement is skipped."
|
"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(
|
|
||||||
"--target",
|
|
||||||
choices=["ansible", "puppet", "salt"],
|
|
||||||
default="ansible",
|
|
||||||
help=(
|
|
||||||
"Configuration-management target to use with --enforce (default: ansible). "
|
|
||||||
"Requires ansible-playbook, puppet, or salt-call on PATH as appropriate."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
d.add_argument(
|
d.add_argument(
|
||||||
"--out",
|
"--out",
|
||||||
help="Write the report to this file instead of stdout.",
|
help="Write the report to this file instead of stdout.",
|
||||||
|
|
@ -1119,7 +1104,6 @@ def main() -> None:
|
||||||
jinjaturtle=_jt_mode(args),
|
jinjaturtle=_jt_mode(args),
|
||||||
sops_fingerprints=getattr(args, "sops", None),
|
sops_fingerprints=getattr(args, "sops", None),
|
||||||
no_common_roles=bool(getattr(args, "no_common_roles", False)),
|
no_common_roles=bool(getattr(args, "no_common_roles", False)),
|
||||||
target=getattr(args, "target", "ansible"),
|
|
||||||
)
|
)
|
||||||
if getattr(args, "sops", None) and out_enc:
|
if getattr(args, "sops", None) and out_enc:
|
||||||
print(str(out_enc))
|
print(str(out_enc))
|
||||||
|
|
@ -1135,7 +1119,7 @@ def main() -> None:
|
||||||
)
|
)
|
||||||
|
|
||||||
# Optional enforcement: if drift is detected, attempt to restore the
|
# Optional enforcement: if drift is detected, attempt to restore the
|
||||||
# system to the *old* (baseline) state using the selected target.
|
# system to the *old* (baseline) state using ansible.
|
||||||
if bool(getattr(args, "enforce", False)):
|
if bool(getattr(args, "enforce", False)):
|
||||||
if has_changes:
|
if has_changes:
|
||||||
if not has_enforceable_drift(report):
|
if not has_enforceable_drift(report):
|
||||||
|
|
@ -1153,7 +1137,6 @@ def main() -> None:
|
||||||
args.old,
|
args.old,
|
||||||
sops_mode=bool(getattr(args, "sops", False)),
|
sops_mode=bool(getattr(args, "sops", False)),
|
||||||
report=report,
|
report=report,
|
||||||
target=getattr(args, "target", "ansible"),
|
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise SystemExit(
|
raise SystemExit(
|
||||||
|
|
@ -1258,7 +1241,6 @@ def main() -> None:
|
||||||
jinjaturtle=_jt_mode(args),
|
jinjaturtle=_jt_mode(args),
|
||||||
sops_fingerprints=list(sops_fps),
|
sops_fingerprints=list(sops_fps),
|
||||||
no_common_roles=bool(getattr(args, "no_common_roles", False)),
|
no_common_roles=bool(getattr(args, "no_common_roles", False)),
|
||||||
target=getattr(args, "target", "ansible"),
|
|
||||||
)
|
)
|
||||||
if not args.harvest:
|
if not args.harvest:
|
||||||
print(str(out_file))
|
print(str(out_file))
|
||||||
|
|
@ -1291,7 +1273,6 @@ def main() -> None:
|
||||||
fqdn=args.fqdn,
|
fqdn=args.fqdn,
|
||||||
jinjaturtle=_jt_mode(args),
|
jinjaturtle=_jt_mode(args),
|
||||||
no_common_roles=bool(getattr(args, "no_common_roles", False)),
|
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.
|
# For usability (when --harvest wasn't provided), print the harvest path.
|
||||||
if not args.harvest:
|
if not args.harvest:
|
||||||
|
|
@ -1324,7 +1305,6 @@ def main() -> None:
|
||||||
jinjaturtle=_jt_mode(args),
|
jinjaturtle=_jt_mode(args),
|
||||||
sops_fingerprints=list(sops_fps),
|
sops_fingerprints=list(sops_fps),
|
||||||
no_common_roles=bool(getattr(args, "no_common_roles", False)),
|
no_common_roles=bool(getattr(args, "no_common_roles", False)),
|
||||||
target=getattr(args, "target", "ansible"),
|
|
||||||
)
|
)
|
||||||
if not args.harvest:
|
if not args.harvest:
|
||||||
print(str(out_file))
|
print(str(out_file))
|
||||||
|
|
@ -1345,7 +1325,6 @@ def main() -> None:
|
||||||
fqdn=args.fqdn,
|
fqdn=args.fqdn,
|
||||||
jinjaturtle=_jt_mode(args),
|
jinjaturtle=_jt_mode(args),
|
||||||
no_common_roles=bool(getattr(args, "no_common_roles", False)),
|
no_common_roles=bool(getattr(args, "no_common_roles", False)),
|
||||||
target=getattr(args, "target", "ansible"),
|
|
||||||
)
|
)
|
||||||
except RemoteSudoPasswordRequired:
|
except RemoteSudoPasswordRequired:
|
||||||
raise SystemExit(
|
raise SystemExit(
|
||||||
|
|
|
||||||
15
enroll/cm.py
15
enroll/cm.py
|
|
@ -22,9 +22,9 @@ from .state import load_state, state_path, write_state
|
||||||
class CMModule:
|
class CMModule:
|
||||||
"""Renderer-neutral configuration-management resource group.
|
"""Renderer-neutral configuration-management resource group.
|
||||||
|
|
||||||
A CMModule is intentionally small: it captures the resources that a target
|
A CMModule is intentionally small: it captures the resources that the
|
||||||
renderer can turn into Ansible tasks, Puppet resources, Salt states, etc.
|
renderer turns into Ansible tasks. The renderer may still decide how to
|
||||||
The renderer may still decide how to name/include/order the group.
|
name/include/order the group.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
role_name: str
|
role_name: str
|
||||||
|
|
@ -806,12 +806,11 @@ def _drop_duplicate_mapping_items(
|
||||||
|
|
||||||
|
|
||||||
def resolve_catalog_conflicts(modules: Iterable[CMModule]) -> None:
|
def resolve_catalog_conflicts(modules: Iterable[CMModule]) -> None:
|
||||||
"""Resolve global catalog conflicts before renderer output.
|
"""Resolve global catalog conflicts in the shared model.
|
||||||
|
|
||||||
Puppet and Salt compile a single resource catalog. Ansible can tolerate the
|
Deduplicates the same package, service, or parent directory appearing in
|
||||||
same package, service, or parent directory appearing in more than one role;
|
more than one role. The Ansible renderer tolerates such duplicates, but this
|
||||||
catalog targets cannot. Resolve those conflicts in the shared model rather
|
helper remains available for any catalog-style consumer of the shared model.
|
||||||
than deleting renderer output after the fact.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
ordered = list(modules)
|
ordered = list(modules)
|
||||||
|
|
|
||||||
|
|
@ -228,6 +228,10 @@ def read_pkg_md5sums(pkg: str) -> Dict[str, str]:
|
||||||
line = line.strip()
|
line = line.strip()
|
||||||
if not line:
|
if not line:
|
||||||
continue
|
continue
|
||||||
md5, rel = line.split(None, 1)
|
parts = line.split(None, 1)
|
||||||
|
if len(parts) != 2:
|
||||||
|
# Skip malformed/truncated lines instead of aborting the harvest.
|
||||||
|
continue
|
||||||
|
md5, rel = parts
|
||||||
m[rel.strip()] = md5.strip()
|
m[rel.strip()] = md5.strip()
|
||||||
return m
|
return m
|
||||||
|
|
|
||||||
131
enroll/diff.py
131
enroll/diff.py
|
|
@ -682,40 +682,7 @@ def _role_tag(role: str) -> str:
|
||||||
return f"role_{safe}"
|
return f"role_{safe}"
|
||||||
|
|
||||||
|
|
||||||
def _normalise_enforcement_target(target: str) -> str:
|
|
||||||
t = str(target or "ansible").strip().lower()
|
|
||||||
if t not in {"ansible", "puppet", "salt"}:
|
|
||||||
raise ValueError(f"unsupported enforcement target: {target!r}")
|
|
||||||
return t
|
|
||||||
|
|
||||||
|
|
||||||
def _enforcement_tool(target: str) -> Tuple[str, str]:
|
|
||||||
"""Return (binary-name, human-label) for a local enforcement target."""
|
|
||||||
if target == "puppet":
|
|
||||||
return "puppet", "puppet apply"
|
|
||||||
if target == "salt":
|
|
||||||
return "salt-call", "salt-call"
|
|
||||||
return "ansible-playbook", "ansible-playbook"
|
|
||||||
|
|
||||||
|
|
||||||
def _require_enforcement_tool(target: str) -> Tuple[str, str]:
|
|
||||||
binary, label = _enforcement_tool(target)
|
|
||||||
exe = shutil.which(binary)
|
|
||||||
if not exe:
|
|
||||||
install_hint = {
|
|
||||||
"ansible": "Ansible",
|
|
||||||
"puppet": "Puppet",
|
|
||||||
"salt": "Salt",
|
|
||||||
}.get(target, target)
|
|
||||||
raise RuntimeError(
|
|
||||||
f"{binary} not found on PATH "
|
|
||||||
f"(cannot enforce with target {target}; install {install_hint})"
|
|
||||||
)
|
|
||||||
return exe, label
|
|
||||||
|
|
||||||
|
|
||||||
def _enforcement_command(
|
def _enforcement_command(
|
||||||
target: str,
|
|
||||||
exe: str,
|
exe: str,
|
||||||
manifest_dir: Path,
|
manifest_dir: Path,
|
||||||
*,
|
*,
|
||||||
|
|
@ -724,7 +691,6 @@ def _enforcement_command(
|
||||||
"""Return the local apply command and environment for a rendered manifest."""
|
"""Return the local apply command and environment for a rendered manifest."""
|
||||||
env = dict(os.environ)
|
env = dict(os.environ)
|
||||||
|
|
||||||
if target == "ansible":
|
|
||||||
playbook = manifest_dir / "playbook.yml"
|
playbook = manifest_dir / "playbook.yml"
|
||||||
if not playbook.exists():
|
if not playbook.exists():
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
|
|
@ -747,47 +713,6 @@ def _enforcement_command(
|
||||||
cmd.extend(["--tags", ",".join(tags)])
|
cmd.extend(["--tags", ",".join(tags)])
|
||||||
return cmd, env
|
return cmd, env
|
||||||
|
|
||||||
if target == "puppet":
|
|
||||||
site_pp = manifest_dir / "manifests" / "site.pp"
|
|
||||||
if not site_pp.exists():
|
|
||||||
raise RuntimeError(
|
|
||||||
f"manifest did not produce expected Puppet site.pp at {site_pp}"
|
|
||||||
)
|
|
||||||
|
|
||||||
cmd = [
|
|
||||||
exe,
|
|
||||||
"apply",
|
|
||||||
"--modulepath",
|
|
||||||
str(manifest_dir / "modules"),
|
|
||||||
]
|
|
||||||
hiera_config = manifest_dir / "hiera.yaml"
|
|
||||||
if hiera_config.exists():
|
|
||||||
cmd.extend(["--hiera_config", str(hiera_config)])
|
|
||||||
cmd.append(str(site_pp))
|
|
||||||
return cmd, env
|
|
||||||
|
|
||||||
if target == "salt":
|
|
||||||
states_dir = manifest_dir / "states"
|
|
||||||
top_sls = states_dir / "top.sls"
|
|
||||||
if not top_sls.exists():
|
|
||||||
raise RuntimeError(
|
|
||||||
f"manifest did not produce expected Salt top.sls at {top_sls}"
|
|
||||||
)
|
|
||||||
|
|
||||||
cmd = [
|
|
||||||
exe,
|
|
||||||
"--local",
|
|
||||||
"--file-root",
|
|
||||||
str(states_dir),
|
|
||||||
]
|
|
||||||
pillar_dir = manifest_dir / "pillar"
|
|
||||||
if pillar_dir.exists():
|
|
||||||
cmd.extend(["--pillar-root", str(pillar_dir)])
|
|
||||||
cmd.extend(["state.apply"])
|
|
||||||
return cmd, env
|
|
||||||
|
|
||||||
raise ValueError(f"unsupported enforcement target: {target!r}")
|
|
||||||
|
|
||||||
|
|
||||||
def _enforcement_plan(
|
def _enforcement_plan(
|
||||||
report: Dict[str, Any],
|
report: Dict[str, Any],
|
||||||
|
|
@ -898,22 +823,18 @@ def enforce_old_harvest(
|
||||||
*,
|
*,
|
||||||
sops_mode: bool = False,
|
sops_mode: bool = False,
|
||||||
report: Optional[Dict[str, Any]] = None,
|
report: Optional[Dict[str, Any]] = None,
|
||||||
target: str = "ansible",
|
|
||||||
) -> Dict[str, Any]:
|
) -> Dict[str, Any]:
|
||||||
"""Enforce the *old* (baseline) harvest state on the current machine.
|
"""Enforce the *old* (baseline) harvest state on the current machine.
|
||||||
|
|
||||||
This renders a temporary manifest from the old harvest using the requested
|
This renders a temporary Ansible manifest from the old harvest, then runs
|
||||||
target, then runs the target's local apply command:
|
the local apply command:
|
||||||
- ansible: ansible-playbook -i localhost, -c local playbook.yml
|
- ansible-playbook -i localhost, -c local playbook.yml
|
||||||
- puppet: puppet apply --modulepath ./modules manifests/site.pp
|
|
||||||
- salt: salt-call --local --file-root ./states state.apply
|
|
||||||
|
|
||||||
Returns a dict suitable for attaching to the diff report under
|
Returns a dict suitable for attaching to the diff report under
|
||||||
report['enforcement'].
|
report['enforcement'].
|
||||||
"""
|
"""
|
||||||
|
|
||||||
target = _normalise_enforcement_target(target)
|
tool_exe = "ansible-playbook"
|
||||||
tool_exe, tool_label = _require_enforcement_tool(target)
|
|
||||||
|
|
||||||
# Import lazily to avoid heavy import cost and potential CLI cycles.
|
# Import lazily to avoid heavy import cost and potential CLI cycles.
|
||||||
from .manifest import manifest
|
from .manifest import manifest
|
||||||
|
|
@ -933,10 +854,8 @@ def enforce_old_harvest(
|
||||||
if report is not None:
|
if report is not None:
|
||||||
plan = _enforcement_plan(report, old_state, old_b.dir)
|
plan = _enforcement_plan(report, old_state, old_b.dir)
|
||||||
roles = list(plan.get("roles") or [])
|
roles = list(plan.get("roles") or [])
|
||||||
# Only Ansible has generated per-role tags that can safely narrow
|
# Ansible has generated per-role tags that can safely narrow the
|
||||||
# the apply scope. Puppet and Salt enforcement deliberately run the
|
# apply scope.
|
||||||
# full generated local manifest/catalog for now.
|
|
||||||
if target == "ansible":
|
|
||||||
t = list(plan.get("tags") or [])
|
t = list(plan.get("tags") or [])
|
||||||
tags = t if t else None
|
tags = t if t else None
|
||||||
|
|
||||||
|
|
@ -951,11 +870,10 @@ def enforce_old_harvest(
|
||||||
# refuses to write into an existing destination, so use a fresh
|
# refuses to write into an existing destination, so use a fresh
|
||||||
# child path under the secure temporary directory.
|
# child path under the secure temporary directory.
|
||||||
manifest_dir = td_path / "manifest"
|
manifest_dir = td_path / "manifest"
|
||||||
manifest(str(old_b.dir), str(manifest_dir), target=target)
|
manifest(str(old_b.dir), str(manifest_dir))
|
||||||
|
|
||||||
# 2) Apply it locally.
|
# 2) Apply it locally.
|
||||||
cmd, env = _enforcement_command(
|
cmd, env = _enforcement_command(
|
||||||
target,
|
|
||||||
tool_exe,
|
tool_exe,
|
||||||
manifest_dir,
|
manifest_dir,
|
||||||
tags=tags,
|
tags=tags,
|
||||||
|
|
@ -967,12 +885,12 @@ def enforce_old_harvest(
|
||||||
if _progress_enabled():
|
if _progress_enabled():
|
||||||
if tags:
|
if tags:
|
||||||
sys.stderr.write(
|
sys.stderr.write(
|
||||||
f"Enforce: running {tool_label} (tags: {','.join(tags)})\n",
|
f"Enforce: running {tool_exe} tags: {','.join(tags)})\n",
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
sys.stderr.write(f"Enforce: running {tool_label}\n")
|
sys.stderr.write(f"Enforce: running {tool_exe}\n")
|
||||||
sys.stderr.flush()
|
sys.stderr.flush()
|
||||||
spinner = _Spinner(f" {tool_label}")
|
spinner = _Spinner(f" {tool_exe}")
|
||||||
spinner.start()
|
spinner.start()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -990,7 +908,7 @@ def enforce_old_harvest(
|
||||||
rc = p.returncode if p is not None else None
|
rc = p.returncode if p is not None else None
|
||||||
spinner.stop(
|
spinner.stop(
|
||||||
final_line=(
|
final_line=(
|
||||||
f"Enforce: {tool_label} finished in {elapsed:0.1f}s"
|
f"Enforce: {tool_exe} finished in {elapsed:0.1f}s"
|
||||||
+ (f" (rc={rc})" if rc is not None else "")
|
+ (f" (rc={rc})" if rc is not None else "")
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
@ -999,22 +917,15 @@ def enforce_old_harvest(
|
||||||
|
|
||||||
info: Dict[str, Any] = {
|
info: Dict[str, Any] = {
|
||||||
"status": "applied" if p.returncode == 0 else "failed",
|
"status": "applied" if p.returncode == 0 else "failed",
|
||||||
"target": target,
|
|
||||||
"tool": tool_label,
|
|
||||||
"executable": tool_exe,
|
"executable": tool_exe,
|
||||||
"started_at": started_at,
|
"started_at": started_at,
|
||||||
"finished_at": finished_at,
|
"finished_at": finished_at,
|
||||||
"command": cmd,
|
"command": cmd,
|
||||||
"returncode": int(p.returncode),
|
"returncode": int(p.returncode),
|
||||||
}
|
}
|
||||||
# Keep the original Ansible-specific field for compatibility with
|
# Keep the Ansible-specific field for compatibility with existing
|
||||||
# existing consumers of the JSON report.
|
# consumers of the JSON report.
|
||||||
if target == "ansible":
|
|
||||||
info["ansible_playbook"] = tool_exe
|
info["ansible_playbook"] = tool_exe
|
||||||
elif target == "puppet":
|
|
||||||
info["puppet"] = tool_exe
|
|
||||||
elif target == "salt":
|
|
||||||
info["salt_call"] = tool_exe
|
|
||||||
|
|
||||||
info["roles"] = roles
|
info["roles"] = roles
|
||||||
info["tags"] = list(tags or [])
|
info["tags"] = list(tags or [])
|
||||||
|
|
@ -1024,7 +935,7 @@ def enforce_old_harvest(
|
||||||
if p.returncode != 0:
|
if p.returncode != 0:
|
||||||
err = (p.stderr or p.stdout or "").strip()
|
err = (p.stderr or p.stdout or "").strip()
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"{tool_label} failed"
|
f"{tool_exe} failed"
|
||||||
+ (f" (rc={p.returncode})" if p.returncode is not None else "")
|
+ (f" (rc={p.returncode})" if p.returncode is not None else "")
|
||||||
+ (f": {err}" if err else "")
|
+ (f": {err}" if err else "")
|
||||||
)
|
)
|
||||||
|
|
@ -1069,9 +980,6 @@ def _report_text(report: Dict[str, Any]) -> str:
|
||||||
if enf:
|
if enf:
|
||||||
lines.append("\nEnforcement")
|
lines.append("\nEnforcement")
|
||||||
status = str(enf.get("status") or "").strip().lower()
|
status = str(enf.get("status") or "").strip().lower()
|
||||||
tool = str(enf.get("tool") or "ansible-playbook")
|
|
||||||
target = str(enf.get("target") or "ansible")
|
|
||||||
via = f"{tool} ({target})" if target and target not in tool else tool
|
|
||||||
if status == "applied":
|
if status == "applied":
|
||||||
extra = ""
|
extra = ""
|
||||||
tags = enf.get("tags") or []
|
tags = enf.get("tags") or []
|
||||||
|
|
@ -1081,7 +989,7 @@ def _report_text(report: Dict[str, Any]) -> str:
|
||||||
elif scope:
|
elif scope:
|
||||||
extra = f" ({scope})"
|
extra = f" ({scope})"
|
||||||
lines.append(
|
lines.append(
|
||||||
f" applied old harvest via {via} (rc={enf.get('returncode')})"
|
f" applied old harvest (rc={enf.get('returncode')})"
|
||||||
+ extra
|
+ extra
|
||||||
+ (
|
+ (
|
||||||
f" (finished {enf.get('finished_at')})"
|
f" (finished {enf.get('finished_at')})"
|
||||||
|
|
@ -1091,7 +999,7 @@ def _report_text(report: Dict[str, Any]) -> str:
|
||||||
)
|
)
|
||||||
elif status == "failed":
|
elif status == "failed":
|
||||||
lines.append(
|
lines.append(
|
||||||
f" attempted enforcement but {via} failed (rc={enf.get('returncode')})"
|
f" attempted enforcement but failed (rc={enf.get('returncode')})"
|
||||||
)
|
)
|
||||||
elif status == "skipped":
|
elif status == "skipped":
|
||||||
r = enf.get("reason")
|
r = enf.get("reason")
|
||||||
|
|
@ -1231,9 +1139,6 @@ def _report_markdown(report: Dict[str, Any]) -> str:
|
||||||
if enf:
|
if enf:
|
||||||
out.append("\n## Enforcement\n")
|
out.append("\n## Enforcement\n")
|
||||||
status = str(enf.get("status") or "").strip().lower()
|
status = str(enf.get("status") or "").strip().lower()
|
||||||
tool = str(enf.get("tool") or "ansible-playbook")
|
|
||||||
target = str(enf.get("target") or "ansible")
|
|
||||||
via = f"{tool} ({target})" if target and target not in tool else tool
|
|
||||||
if status == "applied":
|
if status == "applied":
|
||||||
extra = ""
|
extra = ""
|
||||||
tags = enf.get("tags") or []
|
tags = enf.get("tags") or []
|
||||||
|
|
@ -1243,7 +1148,7 @@ def _report_markdown(report: Dict[str, Any]) -> str:
|
||||||
elif scope:
|
elif scope:
|
||||||
extra = f" ({scope})"
|
extra = f" ({scope})"
|
||||||
out.append(
|
out.append(
|
||||||
f"- ✅ Applied old harvest via {via}"
|
"- ✅ Applied old harvest"
|
||||||
+ extra
|
+ extra
|
||||||
+ (
|
+ (
|
||||||
f" (rc={enf.get('returncode')})"
|
f" (rc={enf.get('returncode')})"
|
||||||
|
|
@ -1259,7 +1164,7 @@ def _report_markdown(report: Dict[str, Any]) -> str:
|
||||||
)
|
)
|
||||||
elif status == "failed":
|
elif status == "failed":
|
||||||
out.append(
|
out.append(
|
||||||
f"- ⚠️ Attempted enforcement but {via} failed"
|
"- ⚠️ Attempted enforcement but failed"
|
||||||
+ (
|
+ (
|
||||||
f" (rc={enf.get('returncode')})"
|
f" (rc={enf.get('returncode')})"
|
||||||
if enf.get("returncode") is not None
|
if enf.get("returncode") is not None
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,9 @@ DEFAULT_DENY_GLOBS = [
|
||||||
"/etc/gshadow",
|
"/etc/gshadow",
|
||||||
"/etc/*shadow",
|
"/etc/*shadow",
|
||||||
"/etc/letsencrypt/*",
|
"/etc/letsencrypt/*",
|
||||||
|
"/etc/ppp/chap-secrets",
|
||||||
|
"/etc/ppp/pap-secrets",
|
||||||
|
"/etc/ppp/*-secrets",
|
||||||
"/usr/local/etc/ssl/private/*",
|
"/usr/local/etc/ssl/private/*",
|
||||||
"/usr/local/etc/ssh/ssh_host_*",
|
"/usr/local/etc/ssh/ssh_host_*",
|
||||||
"/usr/local/etc/*shadow",
|
"/usr/local/etc/*shadow",
|
||||||
|
|
@ -91,7 +94,20 @@ SENSITIVE_CONTENT_PATTERNS = [
|
||||||
\s*[:=]
|
\s*[:=]
|
||||||
"""
|
"""
|
||||||
),
|
),
|
||||||
re.compile(rb"(?i)\b(pass|passwd|token|secret|api[_-]?key)\b"),
|
re.compile(
|
||||||
|
rb"(?i)\b(pass|passwd|password|passphrase|token|secret|"
|
||||||
|
rb"credentials?|api[_-]?key)\b"
|
||||||
|
),
|
||||||
|
# Credentials embedded in connection-string URIs, e.g.
|
||||||
|
# postgres://user:pass@host, redis://:pass@host, amqp://u:p@host
|
||||||
|
# The keyword regex above keys on assignment-style names and misses these.
|
||||||
|
re.compile(rb"(?i)[a-z][a-z0-9+.-]*://[^/\s:@]*:[^/\s@]+@[^/\s]"),
|
||||||
|
# HTTP(S) Authorization / Proxy-Authorization header values carrying a
|
||||||
|
# bearer/basic/digest credential.
|
||||||
|
re.compile(
|
||||||
|
rb"(?im)^\s*(?:proxy-)?authorization\s*:\s*"
|
||||||
|
rb"(?:bearer|basic|token|digest)\s+\S"
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
COMMENT_PREFIXES = (b"#", b";", b"//")
|
COMMENT_PREFIXES = (b"#", b";", b"//")
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,6 @@ _JINJA_FOR_RE = re.compile(
|
||||||
r"{%\s*for\s+([A-Za-z_][A-Za-z0-9_]*)\s+in\s+([A-Za-z_][A-Za-z0-9_]*)\b"
|
r"{%\s*for\s+([A-Za-z_][A-Za-z0-9_]*)\s+in\s+([A-Za-z_][A-Za-z0-9_]*)\b"
|
||||||
)
|
)
|
||||||
_JINJA_SPECIAL_VARS = {"loop", "true", "false", "none", "True", "False", "None"}
|
_JINJA_SPECIAL_VARS = {"loop", "true", "false", "none", "True", "False", "None"}
|
||||||
_ERB_INSTANCE_VAR_RE = re.compile(r"<%=?[^%]*@([A-Za-z_][A-Za-z0-9_]*)", re.S)
|
|
||||||
|
|
||||||
|
|
||||||
def _find_undeclared_jinja_vars(template_text: str) -> Set[str]:
|
def _find_undeclared_jinja_vars(template_text: str) -> Set[str]:
|
||||||
|
|
@ -121,21 +120,6 @@ def missing_jinja_template_vars(
|
||||||
return {name for name in referenced if name not in context}
|
return {name for name in referenced if name not in context}
|
||||||
|
|
||||||
|
|
||||||
def missing_erb_template_vars(template_text: str, context: Dict[str, Any]) -> Set[str]:
|
|
||||||
"""Return ERB ``@param`` references absent from Puppet Hiera/class data."""
|
|
||||||
|
|
||||||
local_names: Set[str] = set()
|
|
||||||
for key in context:
|
|
||||||
text = str(key)
|
|
||||||
if "::" in text:
|
|
||||||
local_names.add(text.split("::", 1)[1])
|
|
||||||
else:
|
|
||||||
local_names.add(text)
|
|
||||||
|
|
||||||
referenced = set(_ERB_INSTANCE_VAR_RE.findall(template_text))
|
|
||||||
return {name for name in referenced if name not in local_names}
|
|
||||||
|
|
||||||
|
|
||||||
def jinjify_artifact(
|
def jinjify_artifact(
|
||||||
bundle_dir: str | Path,
|
bundle_dir: str | Path,
|
||||||
artifact_role: str,
|
artifact_role: str,
|
||||||
|
|
@ -147,14 +131,8 @@ def jinjify_artifact(
|
||||||
jt_enabled: bool,
|
jt_enabled: bool,
|
||||||
overwrite_templates: bool = True,
|
overwrite_templates: bool = True,
|
||||||
role_name: Optional[str] = None,
|
role_name: Optional[str] = None,
|
||||||
template_engine: str = "jinja2",
|
|
||||||
puppet_class: Optional[str] = None,
|
|
||||||
) -> Optional[JinjifiedArtifact]:
|
) -> Optional[JinjifiedArtifact]:
|
||||||
"""Best-effort conversion of one harvested artifact into a template.
|
"""Best-effort conversion of one harvested artifact into a Jinja2 template."""
|
||||||
|
|
||||||
Ansible/Salt use Jinja2 output. Puppet uses ERB output with Puppet Hiera
|
|
||||||
keys when a new enough JinjaTurtle is available.
|
|
||||||
"""
|
|
||||||
if not (jt_enabled and jt_exe and can_jinjify_path(dest_path)):
|
if not (jt_enabled and jt_exe and can_jinjify_path(dest_path)):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
@ -164,31 +142,20 @@ def jinjify_artifact(
|
||||||
return None
|
return None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
run_kwargs: Dict[str, Any] = {
|
result = run_jinjaturtle(
|
||||||
"role_name": role_name or artifact_role,
|
jt_exe,
|
||||||
"force_format": infer_other_formats(dest_path),
|
str(artifact_path),
|
||||||
}
|
role_name=role_name or artifact_role,
|
||||||
# Keep the historical call shape for Ansible/Salt and for tests that
|
force_format=infer_other_formats(dest_path),
|
||||||
# monkeypatch run_jinjaturtle with the old signature. Puppet/ERB is
|
)
|
||||||
# the only path that needs the newer JinjaTurtle CLI switches.
|
|
||||||
if template_engine != "jinja2":
|
|
||||||
run_kwargs["template_engine"] = template_engine
|
|
||||||
if puppet_class:
|
|
||||||
run_kwargs["puppet_class"] = puppet_class
|
|
||||||
result = run_jinjaturtle(jt_exe, str(artifact_path), **run_kwargs)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
return None # nosec - best-effort template generation
|
return None # nosec - best-effort template generation
|
||||||
|
|
||||||
ext = "erb" if template_engine == "erb" else "j2"
|
template_rel = Path(src_rel).as_posix() + ".j2"
|
||||||
template_rel = Path(src_rel).as_posix() + f".{ext}"
|
|
||||||
template_dst = Path(template_root) / template_rel
|
template_dst = Path(template_root) / template_rel
|
||||||
|
|
||||||
context = yaml_load_mapping(result.vars_text)
|
context = yaml_load_mapping(result.vars_text)
|
||||||
missing = (
|
missing = missing_jinja_template_vars(result.template_text, context)
|
||||||
missing_erb_template_vars(result.template_text, context)
|
|
||||||
if template_engine == "erb"
|
|
||||||
else missing_jinja_template_vars(result.template_text, context)
|
|
||||||
)
|
|
||||||
if missing:
|
if missing:
|
||||||
# If this role was generated into an existing output directory, avoid
|
# If this role was generated into an existing output directory, avoid
|
||||||
# leaving an obsolete template behind after falling back to a raw copy.
|
# leaving an obsolete template behind after falling back to a raw copy.
|
||||||
|
|
@ -243,10 +210,7 @@ def jinjify_managed_files(
|
||||||
) -> Tuple[Set[str], str]:
|
) -> Tuple[Set[str], str]:
|
||||||
"""Jinjify a list of managed files and return Ansible-style vars text.
|
"""Jinjify a list of managed files and return Ansible-style vars text.
|
||||||
|
|
||||||
The return shape intentionally matches the historical Ansible helper:
|
The return shape is ``(templated_src_rels, combined_vars_text)``.
|
||||||
``(templated_src_rels, combined_vars_text)``. Salt uses
|
|
||||||
:func:`jinjify_artifact` directly because it stores variables as a context
|
|
||||||
map per managed file.
|
|
||||||
"""
|
"""
|
||||||
templated: Set[str] = set()
|
templated: Set[str] = set()
|
||||||
vars_map: Dict[str, Any] = {}
|
vars_map: Dict[str, Any] = {}
|
||||||
|
|
@ -340,8 +304,6 @@ def run_jinjaturtle(
|
||||||
*,
|
*,
|
||||||
role_name: str,
|
role_name: str,
|
||||||
force_format: Optional[str] = None,
|
force_format: Optional[str] = None,
|
||||||
template_engine: str = "jinja2",
|
|
||||||
puppet_class: Optional[str] = None,
|
|
||||||
) -> JinjifyResult:
|
) -> JinjifyResult:
|
||||||
"""
|
"""
|
||||||
Run jinjaturtle against src_path and return (template, defaults-yaml).
|
Run jinjaturtle against src_path and return (template, defaults-yaml).
|
||||||
|
|
@ -349,9 +311,6 @@ def run_jinjaturtle(
|
||||||
|
|
||||||
jinjaturtle CLI:
|
jinjaturtle CLI:
|
||||||
jinjaturtle <config> -r <role> [-f <format>] [-d <defaults-output>] [-t <template-output>]
|
jinjaturtle <config> -r <role> [-f <format>] [-d <defaults-output>] [-t <template-output>]
|
||||||
|
|
||||||
Newer JinjaTurtle versions also support ``--template-engine erb`` and
|
|
||||||
``--puppet-class`` for Puppet/Hiera output.
|
|
||||||
"""
|
"""
|
||||||
src = Path(src_path)
|
src = Path(src_path)
|
||||||
if not src.is_file():
|
if not src.is_file():
|
||||||
|
|
@ -360,9 +319,7 @@ def run_jinjaturtle(
|
||||||
with tempfile.TemporaryDirectory(prefix="enroll-jt-") as td:
|
with tempfile.TemporaryDirectory(prefix="enroll-jt-") as td:
|
||||||
td_path = Path(td)
|
td_path = Path(td)
|
||||||
defaults_out = td_path / "defaults.yml"
|
defaults_out = td_path / "defaults.yml"
|
||||||
template_out = td_path / (
|
template_out = td_path / "template.j2"
|
||||||
"template.erb" if template_engine == "erb" else "template.j2"
|
|
||||||
)
|
|
||||||
|
|
||||||
cmd = [
|
cmd = [
|
||||||
jt_exe,
|
jt_exe,
|
||||||
|
|
@ -376,10 +333,6 @@ def run_jinjaturtle(
|
||||||
]
|
]
|
||||||
if force_format:
|
if force_format:
|
||||||
cmd.extend(["-f", force_format])
|
cmd.extend(["-f", force_format])
|
||||||
if template_engine != "jinja2":
|
|
||||||
cmd.extend(["--template-engine", template_engine])
|
|
||||||
if puppet_class:
|
|
||||||
cmd.extend(["--puppet-class", puppet_class])
|
|
||||||
|
|
||||||
p = subprocess.run(cmd, text=True, capture_output=True) # nosec
|
p = subprocess.run(cmd, text=True, capture_output=True) # nosec
|
||||||
if p.returncode != 0:
|
if p.returncode != 0:
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,6 @@ from pathlib import Path
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
from .ansible import manifest_from_bundle_dir as manifest_ansible_from_bundle_dir
|
from .ansible import manifest_from_bundle_dir as manifest_ansible_from_bundle_dir
|
||||||
from .puppet import manifest_from_bundle_dir as manifest_puppet_from_bundle_dir
|
|
||||||
from .salt import manifest_from_bundle_dir as manifest_salt_from_bundle_dir
|
|
||||||
from .harvest_safety import ensure_safe_output_parent
|
from .harvest_safety import ensure_safe_output_parent
|
||||||
from .manifest_safety import validate_site_fqdn
|
from .manifest_safety import validate_site_fqdn
|
||||||
from .remote import _safe_extract_tar
|
from .remote import _safe_extract_tar
|
||||||
|
|
@ -175,7 +173,6 @@ def manifest(
|
||||||
jinjaturtle: str = "auto", # auto|on|off
|
jinjaturtle: str = "auto", # auto|on|off
|
||||||
sops_fingerprints: Optional[List[str]] = None,
|
sops_fingerprints: Optional[List[str]] = None,
|
||||||
no_common_roles: bool = False,
|
no_common_roles: bool = False,
|
||||||
target: str = "ansible",
|
|
||||||
) -> Optional[str]:
|
) -> Optional[str]:
|
||||||
"""Render a configuration-management manifest from a harvest.
|
"""Render a configuration-management manifest from a harvest.
|
||||||
|
|
||||||
|
|
@ -193,9 +190,6 @@ def manifest(
|
||||||
- In SOPS mode: the path to the encrypted manifest bundle (.sops)
|
- In SOPS mode: the path to the encrypted manifest bundle (.sops)
|
||||||
- In plain mode: None
|
- In plain mode: None
|
||||||
"""
|
"""
|
||||||
target = (target or "ansible").strip().lower()
|
|
||||||
if target not in {"ansible", "puppet", "salt"}:
|
|
||||||
raise ValueError(f"unsupported manifest target: {target!r}")
|
|
||||||
fqdn = validate_site_fqdn(fqdn)
|
fqdn = validate_site_fqdn(fqdn)
|
||||||
|
|
||||||
sops_mode = bool(sops_fingerprints)
|
sops_mode = bool(sops_fingerprints)
|
||||||
|
|
@ -216,23 +210,6 @@ def manifest(
|
||||||
)
|
)
|
||||||
|
|
||||||
if not sops_mode:
|
if not sops_mode:
|
||||||
if target == "puppet":
|
|
||||||
manifest_puppet_from_bundle_dir(
|
|
||||||
resolved_bundle_dir,
|
|
||||||
out,
|
|
||||||
fqdn=fqdn,
|
|
||||||
no_common_roles=no_common_roles,
|
|
||||||
jinjaturtle=jinjaturtle,
|
|
||||||
)
|
|
||||||
elif target == "salt":
|
|
||||||
manifest_salt_from_bundle_dir(
|
|
||||||
resolved_bundle_dir,
|
|
||||||
out,
|
|
||||||
fqdn=fqdn,
|
|
||||||
no_common_roles=no_common_roles,
|
|
||||||
jinjaturtle=jinjaturtle,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
manifest_ansible_from_bundle_dir(
|
manifest_ansible_from_bundle_dir(
|
||||||
resolved_bundle_dir,
|
resolved_bundle_dir,
|
||||||
out,
|
out,
|
||||||
|
|
@ -248,23 +225,6 @@ def manifest(
|
||||||
td_out = tempfile.TemporaryDirectory(prefix="enroll-manifest-")
|
td_out = tempfile.TemporaryDirectory(prefix="enroll-manifest-")
|
||||||
tmp_out = Path(td_out.name) / "out"
|
tmp_out = Path(td_out.name) / "out"
|
||||||
|
|
||||||
if target == "puppet":
|
|
||||||
manifest_puppet_from_bundle_dir(
|
|
||||||
resolved_bundle_dir,
|
|
||||||
str(tmp_out),
|
|
||||||
fqdn=fqdn,
|
|
||||||
no_common_roles=no_common_roles,
|
|
||||||
jinjaturtle=jinjaturtle,
|
|
||||||
)
|
|
||||||
elif target == "salt":
|
|
||||||
manifest_salt_from_bundle_dir(
|
|
||||||
resolved_bundle_dir,
|
|
||||||
str(tmp_out),
|
|
||||||
fqdn=fqdn,
|
|
||||||
no_common_roles=no_common_roles,
|
|
||||||
jinjaturtle=jinjaturtle,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
manifest_ansible_from_bundle_dir(
|
manifest_ansible_from_bundle_dir(
|
||||||
resolved_bundle_dir,
|
resolved_bundle_dir,
|
||||||
str(tmp_out),
|
str(tmp_out),
|
||||||
|
|
|
||||||
1840
enroll/puppet.py
1840
enroll/puppet.py
File diff suppressed because it is too large
Load diff
|
|
@ -1,7 +1,5 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
|
||||||
import re
|
|
||||||
from collections.abc import Mapping, Set as AbstractSet
|
from collections.abc import Mapping, Set as AbstractSet
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
|
@ -50,183 +48,3 @@ def ansible_unsafe_data(value: Any) -> Any:
|
||||||
if isinstance(value, AbstractSet):
|
if isinstance(value, AbstractSet):
|
||||||
return sorted(ansible_unsafe_data(item) for item in value)
|
return sorted(ansible_unsafe_data(item) for item in value)
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
def escape_puppet_hiera_interpolation(value: str) -> str:
|
|
||||||
"""Preserve literal ``%{`` text in Puppet Hiera data sources.
|
|
||||||
|
|
||||||
Hiera treats ``%{...}`` in data values as interpolation. Enroll's Hiera
|
|
||||||
data is generated from harvested values, not authored Hiera expressions, so
|
|
||||||
any literal interpolation opener is escaped with Hiera's documented
|
|
||||||
``literal('%')`` helper.
|
|
||||||
"""
|
|
||||||
|
|
||||||
return str(value).replace("%{", "%{literal('%')}{")
|
|
||||||
|
|
||||||
|
|
||||||
def puppet_hiera_safe_data(value: Any) -> Any:
|
|
||||||
"""Recursively escape Hiera interpolation openers in harvested data."""
|
|
||||||
|
|
||||||
if isinstance(value, Mapping):
|
|
||||||
return {
|
|
||||||
escape_puppet_hiera_interpolation(str(key)): puppet_hiera_safe_data(inner)
|
|
||||||
for key, inner in value.items()
|
|
||||||
}
|
|
||||||
if isinstance(value, list):
|
|
||||||
return [puppet_hiera_safe_data(item) for item in value]
|
|
||||||
if isinstance(value, tuple):
|
|
||||||
return [puppet_hiera_safe_data(item) for item in value]
|
|
||||||
if isinstance(value, AbstractSet):
|
|
||||||
return sorted(puppet_hiera_safe_data(item) for item in value)
|
|
||||||
if isinstance(value, str):
|
|
||||||
return escape_puppet_hiera_interpolation(value)
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _plain_json_data(value: Any) -> Any:
|
|
||||||
if isinstance(value, Mapping):
|
|
||||||
return {str(key): _plain_json_data(inner) for key, inner in value.items()}
|
|
||||||
if isinstance(value, list):
|
|
||||||
return [_plain_json_data(item) for item in value]
|
|
||||||
if isinstance(value, tuple):
|
|
||||||
return [_plain_json_data(item) for item in value]
|
|
||||||
if isinstance(value, AbstractSet):
|
|
||||||
return sorted(_plain_json_data(item) for item in value)
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _escape_braces_inside_json_strings(text: str) -> str:
|
|
||||||
"""Replace literal braces only while scanning JSON string tokens."""
|
|
||||||
|
|
||||||
out: list[str] = []
|
|
||||||
in_string = False
|
|
||||||
escaped = False
|
|
||||||
for ch in text:
|
|
||||||
if not in_string:
|
|
||||||
out.append(ch)
|
|
||||||
if ch == '"':
|
|
||||||
in_string = True
|
|
||||||
continue
|
|
||||||
|
|
||||||
if escaped:
|
|
||||||
out.append(ch)
|
|
||||||
escaped = False
|
|
||||||
elif ch == "\\":
|
|
||||||
out.append(ch)
|
|
||||||
escaped = True
|
|
||||||
elif ch == '"':
|
|
||||||
out.append(ch)
|
|
||||||
in_string = False
|
|
||||||
elif ch == "{":
|
|
||||||
out.append("\\u007b")
|
|
||||||
elif ch == "}":
|
|
||||||
out.append("\\u007d")
|
|
||||||
else:
|
|
||||||
out.append(ch)
|
|
||||||
return "".join(out)
|
|
||||||
|
|
||||||
|
|
||||||
def salt_sls_json_quote(value: Any) -> str:
|
|
||||||
"""Return a double-quoted YAML/JSON scalar safe for Salt's Jinja pass.
|
|
||||||
|
|
||||||
Salt state and pillar SLS files normally use the ``jinja|yaml`` renderer
|
|
||||||
pipeline. YAML/JSON quoting alone does not stop ``{{ ... }}``, ``{% ... %}``
|
|
||||||
or ``{# ... #}`` inside harvested values from being evaluated before YAML is
|
|
||||||
parsed. JSON/YAML double-quoted scalars decode ``\u007b`` and ``\u007d``
|
|
||||||
after Jinja has run, so encode braces inside string tokens as Unicode escapes.
|
|
||||||
"""
|
|
||||||
|
|
||||||
dumped = json.dumps(str(value), ensure_ascii=False)
|
|
||||||
return _escape_braces_inside_json_strings(dumped)
|
|
||||||
|
|
||||||
|
|
||||||
_PLAIN_YAML_KEY_RE = re.compile(r"^[A-Za-z0-9_./:-]+$")
|
|
||||||
|
|
||||||
|
|
||||||
def _salt_yaml_key(value: Any) -> str:
|
|
||||||
text = str(value)
|
|
||||||
if text and _PLAIN_YAML_KEY_RE.match(text) and not text.startswith(("-", "?", ":")):
|
|
||||||
return text
|
|
||||||
return salt_sls_json_quote(text)
|
|
||||||
|
|
||||||
|
|
||||||
def _salt_yaml_scalar(value: Any) -> str:
|
|
||||||
if value is None:
|
|
||||||
return "null"
|
|
||||||
if isinstance(value, bool):
|
|
||||||
return "true" if value else "false"
|
|
||||||
if isinstance(value, int) and not isinstance(value, bool):
|
|
||||||
return str(value)
|
|
||||||
if isinstance(value, float):
|
|
||||||
return json.dumps(value, allow_nan=False)
|
|
||||||
return salt_sls_json_quote(value)
|
|
||||||
|
|
||||||
|
|
||||||
def _salt_yaml_lines(
|
|
||||||
value: Any, indent: int = 0, *, sort_keys: bool = True
|
|
||||||
) -> list[str]:
|
|
||||||
prefix = " " * indent
|
|
||||||
if isinstance(value, Mapping):
|
|
||||||
if not value:
|
|
||||||
return [prefix + "{}"]
|
|
||||||
keys = sorted(value, key=lambda item: str(item)) if sort_keys else list(value)
|
|
||||||
lines: list[str] = []
|
|
||||||
for key in keys:
|
|
||||||
inner = value[key]
|
|
||||||
key_text = _salt_yaml_key(key)
|
|
||||||
if isinstance(inner, Mapping):
|
|
||||||
if not inner:
|
|
||||||
lines.append(f"{prefix}{key_text}: {{}}")
|
|
||||||
else:
|
|
||||||
lines.append(f"{prefix}{key_text}:")
|
|
||||||
lines.extend(
|
|
||||||
_salt_yaml_lines(inner, indent + 2, sort_keys=sort_keys)
|
|
||||||
)
|
|
||||||
elif isinstance(inner, (list, tuple, set)):
|
|
||||||
seq = list(inner) if not isinstance(inner, set) else sorted(inner)
|
|
||||||
if not seq:
|
|
||||||
lines.append(f"{prefix}{key_text}: []")
|
|
||||||
else:
|
|
||||||
lines.append(f"{prefix}{key_text}:")
|
|
||||||
lines.extend(_salt_yaml_lines(seq, indent + 2, sort_keys=sort_keys))
|
|
||||||
else:
|
|
||||||
lines.append(f"{prefix}{key_text}: {_salt_yaml_scalar(inner)}")
|
|
||||||
return lines
|
|
||||||
|
|
||||||
if isinstance(value, (list, tuple, set)):
|
|
||||||
seq = list(value) if not isinstance(value, set) else sorted(value)
|
|
||||||
if not seq:
|
|
||||||
return [prefix + "[]"]
|
|
||||||
lines = []
|
|
||||||
for item in seq:
|
|
||||||
if isinstance(item, Mapping):
|
|
||||||
if not item:
|
|
||||||
lines.append(prefix + "- {}")
|
|
||||||
else:
|
|
||||||
lines.append(prefix + "-")
|
|
||||||
lines.extend(
|
|
||||||
_salt_yaml_lines(item, indent + 2, sort_keys=sort_keys)
|
|
||||||
)
|
|
||||||
elif isinstance(item, (list, tuple, set)):
|
|
||||||
lines.append(prefix + "-")
|
|
||||||
lines.extend(_salt_yaml_lines(item, indent + 2, sort_keys=sort_keys))
|
|
||||||
else:
|
|
||||||
lines.append(f"{prefix}- {_salt_yaml_scalar(item)}")
|
|
||||||
return lines
|
|
||||||
|
|
||||||
return [prefix + _salt_yaml_scalar(value)]
|
|
||||||
|
|
||||||
|
|
||||||
def salt_sls_yaml_dump(
|
|
||||||
value: Any,
|
|
||||||
*,
|
|
||||||
sort_keys: bool = True,
|
|
||||||
explicit_start: bool = False,
|
|
||||||
) -> str:
|
|
||||||
"""Dump block YAML whose string braces cannot form Salt Jinja delimiters."""
|
|
||||||
|
|
||||||
lines = _salt_yaml_lines(_plain_json_data(value), sort_keys=sort_keys)
|
|
||||||
rendered = "\n".join(lines).rstrip() + "\n"
|
|
||||||
if explicit_start:
|
|
||||||
rendered = "---\n" + rendered
|
|
||||||
return rendered
|
|
||||||
|
|
|
||||||
1759
enroll/salt.py
1759
enroll/salt.py
File diff suppressed because it is too large
Load diff
|
|
@ -5,7 +5,7 @@ build-backend = "poetry.core.masonry.api"
|
||||||
[project]
|
[project]
|
||||||
name = "enroll"
|
name = "enroll"
|
||||||
version = "0.7.0b7"
|
version = "0.7.0b7"
|
||||||
description = "Enroll a server's running state retrospectively into Ansible, Puppet or Salt"
|
description = "Enroll a server's running state retrospectively into Ansible"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
license = "GPL-3.0-or-later"
|
license = "GPL-3.0-or-later"
|
||||||
|
|
|
||||||
140
tests.sh
140
tests.sh
|
|
@ -2,10 +2,6 @@
|
||||||
|
|
||||||
set -Eeuo pipefail
|
set -Eeuo pipefail
|
||||||
|
|
||||||
if [[ -d /opt/puppetlabs/bin ]]; then
|
|
||||||
export PATH="/opt/puppetlabs/bin:${PATH}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
TMP_PARENT="${TMPDIR:-/tmp}"
|
TMP_PARENT="${TMPDIR:-/tmp}"
|
||||||
KEEP_WORKDIR=0
|
KEEP_WORKDIR=0
|
||||||
|
|
@ -22,16 +18,8 @@ BUNDLE_DIFF_DIR="${WORK_DIR}/bundle-diff"
|
||||||
ANSIBLE_DIR="${WORK_DIR}/ansible"
|
ANSIBLE_DIR="${WORK_DIR}/ansible"
|
||||||
ANSIBLE_NO_COMMON_DIR="${WORK_DIR}/ansible-no-common"
|
ANSIBLE_NO_COMMON_DIR="${WORK_DIR}/ansible-no-common"
|
||||||
ANSIBLE_FQDN_DIR="${WORK_DIR}/ansible-fqdn"
|
ANSIBLE_FQDN_DIR="${WORK_DIR}/ansible-fqdn"
|
||||||
PUPPET_DIR="${WORK_DIR}/puppet"
|
|
||||||
PUPPET_FQDN_DIR="${WORK_DIR}/puppet-fqdn"
|
|
||||||
SALT_DIR="${WORK_DIR}/salt"
|
|
||||||
SALT_FQDN_DIR="${WORK_DIR}/salt-fqdn"
|
|
||||||
ANSIBLE_JINJATURTLE_DIR="${WORK_DIR}/ansible-jinjaturtle"
|
ANSIBLE_JINJATURTLE_DIR="${WORK_DIR}/ansible-jinjaturtle"
|
||||||
ANSIBLE_NO_JINJATURTLE_DIR="${WORK_DIR}/ansible-no-jinjaturtle"
|
ANSIBLE_NO_JINJATURTLE_DIR="${WORK_DIR}/ansible-no-jinjaturtle"
|
||||||
PUPPET_JINJATURTLE_DIR="${WORK_DIR}/puppet-jinjaturtle"
|
|
||||||
PUPPET_NO_JINJATURTLE_DIR="${WORK_DIR}/puppet-no-jinjaturtle"
|
|
||||||
SALT_JINJATURTLE_DIR="${WORK_DIR}/salt-jinjaturtle"
|
|
||||||
SALT_NO_JINJATURTLE_DIR="${WORK_DIR}/salt-no-jinjaturtle"
|
|
||||||
TEST_FQDN="${ENROLL_TEST_FQDN:-enroll-ci.example.test}"
|
TEST_FQDN="${ENROLL_TEST_FQDN:-enroll-ci.example.test}"
|
||||||
JINJATURTLE_FIXTURE="${WORK_DIR}/enroll-tests-jinjaturtle.ini"
|
JINJATURTLE_FIXTURE="${WORK_DIR}/enroll-tests-jinjaturtle.ini"
|
||||||
ANSIBLE_PLAYBOOK_EXTRA_ARGS=()
|
ANSIBLE_PLAYBOOK_EXTRA_ARGS=()
|
||||||
|
|
@ -176,7 +164,6 @@ translate_packages() {
|
||||||
gnupg) translated+=(gnupg2) ;;
|
gnupg) translated+=(gnupg2) ;;
|
||||||
curl) translated+=(curl-minimal) ;;
|
curl) translated+=(curl-minimal) ;;
|
||||||
lsb-release) translated+=(redhat-lsb-core) ;;
|
lsb-release) translated+=(redhat-lsb-core) ;;
|
||||||
puppet) translated+=(puppet-agent) ;;
|
|
||||||
python3-apt) ;;
|
python3-apt) ;;
|
||||||
python3-jsonschema) translated+=(python3-jsonschema) ;;
|
python3-jsonschema) translated+=(python3-jsonschema) ;;
|
||||||
python3-venv) ;;
|
python3-venv) ;;
|
||||||
|
|
@ -232,42 +219,6 @@ ensure_epel_repo() {
|
||||||
DNF_UPDATED=
|
DNF_UPDATED=
|
||||||
}
|
}
|
||||||
|
|
||||||
ensure_salt_repo() {
|
|
||||||
if is_debian; then
|
|
||||||
if [[ -e /etc/apt/sources.list.d/salt.sources ]]; then
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
section "Setup: Salt apt repository"
|
|
||||||
pkg_install ca-certificates curl gnupg
|
|
||||||
run mkdir -m 755 -p /etc/apt/keyrings
|
|
||||||
run bash -c "curl -fsSL https://packages.broadcom.com/artifactory/api/security/keypair/SaltProjectKey/public | gpg --dearmor --yes -o /etc/apt/keyrings/salt-archive-keyring.pgp"
|
|
||||||
run bash -c "curl -fsSL https://github.com/saltstack/salt-install-guide/releases/latest/download/salt.sources > /etc/apt/sources.list.d/salt.sources"
|
|
||||||
APT_UPDATED=
|
|
||||||
elif is_rpm_family; then
|
|
||||||
if [[ -e /etc/yum.repos.d/salt.repo ]]; then
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
section "Setup: Salt dnf repository"
|
|
||||||
pkg_install ca-certificates curl
|
|
||||||
run bash -c "curl -fsSL https://github.com/saltstack/salt-install-guide/releases/latest/download/salt.repo > /etc/yum.repos.d/salt.repo"
|
|
||||||
DNF_UPDATED=
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
ensure_puppet_repo() {
|
|
||||||
if ! is_rpm_family; then
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
if rpm -q puppet8-release >/dev/null 2>&1 || [[ -e /etc/yum.repos.d/puppet8-release.repo ]]; then
|
|
||||||
return
|
|
||||||
fi
|
|
||||||
section "Setup: Puppet dnf repository"
|
|
||||||
local major
|
|
||||||
major="$(os_version_major)"
|
|
||||||
run dnf -y install "https://yum.puppet.com/puppet8-release-el-${major}.noarch.rpm"
|
|
||||||
DNF_UPDATED=
|
|
||||||
}
|
|
||||||
|
|
||||||
ensure_jinjaturtle() {
|
ensure_jinjaturtle() {
|
||||||
section "Setup: JinjaTurtle package"
|
section "Setup: JinjaTurtle package"
|
||||||
if command -v jinjaturtle >/dev/null 2>&1; then
|
if command -v jinjaturtle >/dev/null 2>&1; then
|
||||||
|
|
@ -311,22 +262,6 @@ ensure_ansible() {
|
||||||
require_cmd ansible-lint "Install the ansible-lint package."
|
require_cmd ansible-lint "Install the ansible-lint package."
|
||||||
}
|
}
|
||||||
|
|
||||||
ensure_puppet() {
|
|
||||||
ensure_puppet_repo
|
|
||||||
if ! command -v puppet >/dev/null 2>&1; then
|
|
||||||
pkg_install puppet || pkg_install puppet-agent
|
|
||||||
fi
|
|
||||||
require_cmd puppet "Install Puppet before running the Puppet noop integration tests."
|
|
||||||
}
|
|
||||||
|
|
||||||
ensure_salt() {
|
|
||||||
ensure_salt_repo
|
|
||||||
if ! command -v salt-call >/dev/null 2>&1; then
|
|
||||||
pkg_install salt-minion || true
|
|
||||||
fi
|
|
||||||
require_cmd salt-call "Install Salt's salt-call binary before running the Salt noop integration tests. This may require configuring the upstream Salt/Broadcom package repository first."
|
|
||||||
}
|
|
||||||
|
|
||||||
run_pytests() {
|
run_pytests() {
|
||||||
section "Python unit tests"
|
section "Python unit tests"
|
||||||
cd "${PROJECT_ROOT}"
|
cd "${PROJECT_ROOT}"
|
||||||
|
|
@ -396,34 +331,6 @@ run_ansible_jinjaturtle_variant() {
|
||||||
run ansible-playbook playbook.yml -i "localhost," -c local --check --diff "${ANSIBLE_PLAYBOOK_EXTRA_ARGS[@]}"
|
run ansible-playbook playbook.yml -i "localhost," -c local --check --diff "${ANSIBLE_PLAYBOOK_EXTRA_ARGS[@]}"
|
||||||
}
|
}
|
||||||
|
|
||||||
run_puppet_jinjaturtle_variant() {
|
|
||||||
local out_dir="$1"
|
|
||||||
local expected="$2"
|
|
||||||
local label="$3"
|
|
||||||
shift 3
|
|
||||||
|
|
||||||
ensure_puppet
|
|
||||||
cd "${PROJECT_ROOT}"
|
|
||||||
rm -rf "${out_dir}"
|
|
||||||
run poetry run enroll manifest --harvest "${BUNDLE_DIR}" --out "${out_dir}" --target puppet "$@"
|
|
||||||
assert_template_files "${out_dir}" "erb" "${expected}" "${label}"
|
|
||||||
run puppet apply --modulepath "${out_dir}/modules" "${out_dir}/manifests/site.pp" --noop
|
|
||||||
}
|
|
||||||
|
|
||||||
run_salt_jinjaturtle_variant() {
|
|
||||||
local out_dir="$1"
|
|
||||||
local expected="$2"
|
|
||||||
local label="$3"
|
|
||||||
shift 3
|
|
||||||
|
|
||||||
ensure_salt
|
|
||||||
cd "${PROJECT_ROOT}"
|
|
||||||
rm -rf "${out_dir}"
|
|
||||||
run poetry run enroll manifest --harvest "${BUNDLE_DIR}" --out "${out_dir}" --target salt "$@"
|
|
||||||
assert_template_files "${out_dir}" "j2" "${expected}" "${label}"
|
|
||||||
run salt-call --local --retcode-passthrough --file-root "${out_dir}/states" state.apply test=True
|
|
||||||
}
|
|
||||||
|
|
||||||
run_jinjaturtle_manifest_tests() {
|
run_jinjaturtle_manifest_tests() {
|
||||||
if is_rpm_family ; then
|
if is_rpm_family ; then
|
||||||
section "JinjaTurtle integration matrix"
|
section "JinjaTurtle integration matrix"
|
||||||
|
|
@ -437,14 +344,6 @@ run_jinjaturtle_manifest_tests() {
|
||||||
section "Ansible JinjaTurtle manifest noop tests"
|
section "Ansible JinjaTurtle manifest noop tests"
|
||||||
run_ansible_jinjaturtle_variant "${ANSIBLE_JINJATURTLE_DIR}" present "Ansible with JinjaTurtle on PATH"
|
run_ansible_jinjaturtle_variant "${ANSIBLE_JINJATURTLE_DIR}" present "Ansible with JinjaTurtle on PATH"
|
||||||
run_ansible_jinjaturtle_variant "${ANSIBLE_NO_JINJATURTLE_DIR}" absent "Ansible with --no-jinjaturtle" --no-jinjaturtle
|
run_ansible_jinjaturtle_variant "${ANSIBLE_NO_JINJATURTLE_DIR}" absent "Ansible with --no-jinjaturtle" --no-jinjaturtle
|
||||||
|
|
||||||
section "Puppet JinjaTurtle manifest noop tests"
|
|
||||||
run_puppet_jinjaturtle_variant "${PUPPET_JINJATURTLE_DIR}" present "Puppet with JinjaTurtle on PATH"
|
|
||||||
run_puppet_jinjaturtle_variant "${PUPPET_NO_JINJATURTLE_DIR}" absent "Puppet with --no-jinjaturtle" --no-jinjaturtle
|
|
||||||
|
|
||||||
section "Salt JinjaTurtle manifest noop tests"
|
|
||||||
run_salt_jinjaturtle_variant "${SALT_JINJATURTLE_DIR}" present "Salt with JinjaTurtle on PATH"
|
|
||||||
run_salt_jinjaturtle_variant "${SALT_NO_JINJATURTLE_DIR}" absent "Salt with --no-jinjaturtle" --no-jinjaturtle
|
|
||||||
}
|
}
|
||||||
|
|
||||||
run_ansible_noop_tests() {
|
run_ansible_noop_tests() {
|
||||||
|
|
@ -472,43 +371,6 @@ run_ansible_noop_tests() {
|
||||||
run ansible-playbook "playbooks/${TEST_FQDN}.yml" -i inventory/hosts.ini -c local --limit "${TEST_FQDN}" --check --diff "${ANSIBLE_PLAYBOOK_EXTRA_ARGS[@]}"
|
run ansible-playbook "playbooks/${TEST_FQDN}.yml" -i inventory/hosts.ini -c local --limit "${TEST_FQDN}" --check --diff "${ANSIBLE_PLAYBOOK_EXTRA_ARGS[@]}"
|
||||||
}
|
}
|
||||||
|
|
||||||
run_puppet_noop_tests() {
|
|
||||||
section "Puppet manifest noop tests"
|
|
||||||
ensure_puppet
|
|
||||||
cd "${PROJECT_ROOT}"
|
|
||||||
rm -rf "${PUPPET_DIR}" "${PUPPET_FQDN_DIR}"
|
|
||||||
|
|
||||||
run poetry run enroll manifest --harvest "${BUNDLE_DIR}" --out "${PUPPET_DIR}" --target puppet
|
|
||||||
run puppet apply --modulepath "${PUPPET_DIR}/modules" "${PUPPET_DIR}/manifests/site.pp" --noop
|
|
||||||
|
|
||||||
run poetry run enroll manifest --harvest "${BUNDLE_DIR}" --out "${PUPPET_FQDN_DIR}" --target puppet --fqdn "${TEST_FQDN}"
|
|
||||||
run puppet apply \
|
|
||||||
--modulepath "${PUPPET_FQDN_DIR}/modules" \
|
|
||||||
--hiera_config "${PUPPET_FQDN_DIR}/hiera.yaml" \
|
|
||||||
--certname "${TEST_FQDN}" \
|
|
||||||
"${PUPPET_FQDN_DIR}/manifests/site.pp" \
|
|
||||||
--noop
|
|
||||||
}
|
|
||||||
|
|
||||||
run_salt_noop_tests() {
|
|
||||||
section "Salt manifest noop tests"
|
|
||||||
ensure_salt
|
|
||||||
cd "${PROJECT_ROOT}"
|
|
||||||
rm -rf "${SALT_DIR}" "${SALT_FQDN_DIR}"
|
|
||||||
|
|
||||||
run poetry run enroll manifest --harvest "${BUNDLE_DIR}" --out "${SALT_DIR}" --target salt
|
|
||||||
run salt-call --local --retcode-passthrough --file-root "${SALT_DIR}/states" state.apply test=True
|
|
||||||
|
|
||||||
run poetry run enroll manifest --harvest "${BUNDLE_DIR}" --out "${SALT_FQDN_DIR}" --target salt --fqdn "${TEST_FQDN}"
|
|
||||||
run salt-call \
|
|
||||||
--local \
|
|
||||||
--retcode-passthrough \
|
|
||||||
--id "${TEST_FQDN}" \
|
|
||||||
--file-root "${SALT_FQDN_DIR}/states" \
|
|
||||||
--pillar-root "${SALT_FQDN_DIR}/pillar" \
|
|
||||||
state.apply test=True
|
|
||||||
}
|
|
||||||
|
|
||||||
main() {
|
main() {
|
||||||
require_root
|
require_root
|
||||||
require_supported_ci_os
|
require_supported_ci_os
|
||||||
|
|
@ -516,8 +378,6 @@ main() {
|
||||||
prepare_harvest_fixture
|
prepare_harvest_fixture
|
||||||
configure_ansible_playbook_extra_args
|
configure_ansible_playbook_extra_args
|
||||||
run_ansible_noop_tests
|
run_ansible_noop_tests
|
||||||
run_puppet_noop_tests
|
|
||||||
run_salt_noop_tests
|
|
||||||
run_jinjaturtle_manifest_tests
|
run_jinjaturtle_manifest_tests
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,6 @@ def test_cli_manifest_subcommand_calls_manifest(monkeypatch, tmp_path):
|
||||||
called["fqdn"] = kwargs.get("fqdn")
|
called["fqdn"] = kwargs.get("fqdn")
|
||||||
called["jinjaturtle"] = kwargs.get("jinjaturtle")
|
called["jinjaturtle"] = kwargs.get("jinjaturtle")
|
||||||
called["no_common_roles"] = kwargs.get("no_common_roles")
|
called["no_common_roles"] = kwargs.get("no_common_roles")
|
||||||
called["target"] = kwargs.get("target")
|
|
||||||
|
|
||||||
monkeypatch.setattr(cli, "manifest", fake_manifest)
|
monkeypatch.setattr(cli, "manifest", fake_manifest)
|
||||||
monkeypatch.setattr(
|
monkeypatch.setattr(
|
||||||
|
|
@ -70,7 +69,6 @@ def test_cli_manifest_subcommand_calls_manifest(monkeypatch, tmp_path):
|
||||||
assert called["fqdn"] is None
|
assert called["fqdn"] is None
|
||||||
assert called["jinjaturtle"] == "auto"
|
assert called["jinjaturtle"] == "auto"
|
||||||
assert called["no_common_roles"] is False
|
assert called["no_common_roles"] is False
|
||||||
assert called["target"] == "ansible"
|
|
||||||
|
|
||||||
|
|
||||||
def test_cli_force_unsafe_path_before_subcommand_reaches_guard(monkeypatch, tmp_path):
|
def test_cli_force_unsafe_path_before_subcommand_reaches_guard(monkeypatch, tmp_path):
|
||||||
|
|
@ -131,36 +129,6 @@ def test_cli_force_unsafe_path_after_subcommand_reaches_guard(monkeypatch, tmp_p
|
||||||
assert seen["force"] is True
|
assert seen["force"] is True
|
||||||
|
|
||||||
|
|
||||||
def test_cli_manifest_target_puppet_is_forwarded(monkeypatch, tmp_path):
|
|
||||||
called = {}
|
|
||||||
|
|
||||||
def fake_manifest(harvest_dir: str, out_dir: str, **kwargs):
|
|
||||||
called["harvest"] = harvest_dir
|
|
||||||
called["out"] = out_dir
|
|
||||||
called["target"] = kwargs.get("target")
|
|
||||||
|
|
||||||
monkeypatch.setattr(cli, "manifest", fake_manifest)
|
|
||||||
monkeypatch.setattr(
|
|
||||||
sys,
|
|
||||||
"argv",
|
|
||||||
[
|
|
||||||
"enroll",
|
|
||||||
"manifest",
|
|
||||||
"--harvest",
|
|
||||||
str(tmp_path / "bundle"),
|
|
||||||
"--out",
|
|
||||||
str(tmp_path / "puppet"),
|
|
||||||
"--target",
|
|
||||||
"puppet",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
cli.main()
|
|
||||||
assert called["harvest"] == str(tmp_path / "bundle")
|
|
||||||
assert called["out"] == str(tmp_path / "puppet")
|
|
||||||
assert called["target"] == "puppet"
|
|
||||||
|
|
||||||
|
|
||||||
def test_cli_manifest_no_common_roles_is_forwarded(monkeypatch, tmp_path):
|
def test_cli_manifest_no_common_roles_is_forwarded(monkeypatch, tmp_path):
|
||||||
called = {}
|
called = {}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1025,7 +1025,7 @@ def test_report_markdown_with_enforcement_failed():
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
result = _report_markdown(report)
|
result = _report_markdown(report)
|
||||||
assert "ansible-playbook failed" in result
|
assert "but failed" in result
|
||||||
|
|
||||||
|
|
||||||
def test_report_markdown_with_enforcement_skipped():
|
def test_report_markdown_with_enforcement_skipped():
|
||||||
|
|
@ -1378,7 +1378,7 @@ def test_report_text_with_enforcement_applied():
|
||||||
}
|
}
|
||||||
result = d._report_text(report)
|
result = d._report_text(report)
|
||||||
assert "Enforcement" in result
|
assert "Enforcement" in result
|
||||||
assert "applied old harvest via ansible-playbook" in result
|
assert "applied old harvest" in result
|
||||||
assert "tags=test" in result
|
assert "tags=test" in result
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1398,7 +1398,7 @@ def test_report_text_with_enforcement_failed():
|
||||||
}
|
}
|
||||||
result = d._report_text(report)
|
result = d._report_text(report)
|
||||||
assert "Enforcement" in result
|
assert "Enforcement" in result
|
||||||
assert "ansible-playbook failed" in result
|
assert "failed" in result
|
||||||
|
|
||||||
|
|
||||||
def test_report_text_with_enforcement_skipped():
|
def test_report_text_with_enforcement_skipped():
|
||||||
|
|
|
||||||
|
|
@ -218,18 +218,6 @@ def test_diff_exclude_path_only_filters_files_not_packages(tmp_path: Path):
|
||||||
assert report["packages"]["added"] == ["htop"]
|
assert report["packages"]["added"] == ["htop"]
|
||||||
|
|
||||||
|
|
||||||
def test_enforce_old_harvest_requires_ansible_playbook(monkeypatch, tmp_path: Path):
|
|
||||||
import enroll.diff as d
|
|
||||||
|
|
||||||
monkeypatch.setattr(d.shutil, "which", lambda name: None)
|
|
||||||
|
|
||||||
old = tmp_path / "old"
|
|
||||||
_write_bundle(old, {"inventory": {"packages": {}}, "roles": _minimal_roles()})
|
|
||||||
|
|
||||||
with pytest.raises(RuntimeError, match="ansible-playbook not found"):
|
|
||||||
d.enforce_old_harvest(str(old))
|
|
||||||
|
|
||||||
|
|
||||||
def test_enforce_old_harvest_runs_ansible_with_tags_from_file_drift(
|
def test_enforce_old_harvest_runs_ansible_with_tags_from_file_drift(
|
||||||
monkeypatch, tmp_path: Path
|
monkeypatch, tmp_path: Path
|
||||||
):
|
):
|
||||||
|
|
@ -310,121 +298,6 @@ def test_enforce_old_harvest_runs_ansible_with_tags_from_file_drift(
|
||||||
assert "role_usr_local_custom" in str(argv[i + 1])
|
assert "role_usr_local_custom" in str(argv[i + 1])
|
||||||
|
|
||||||
|
|
||||||
def test_enforce_old_harvest_runs_puppet_target(monkeypatch, tmp_path: Path):
|
|
||||||
import enroll.diff as d
|
|
||||||
import enroll.manifest as mf
|
|
||||||
|
|
||||||
monkeypatch.setattr(
|
|
||||||
d.shutil,
|
|
||||||
"which",
|
|
||||||
lambda name: "/usr/bin/puppet" if name == "puppet" else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
calls: dict[str, object] = {}
|
|
||||||
|
|
||||||
def fake_manifest(_harvest_dir: str, out_dir: str, **kwargs):
|
|
||||||
calls["manifest_target"] = kwargs.get("target")
|
|
||||||
out = Path(out_dir)
|
|
||||||
(out / "manifests").mkdir(parents=True)
|
|
||||||
(out / "modules").mkdir(parents=True)
|
|
||||||
(out / "manifests" / "site.pp").write_text(
|
|
||||||
"node default { }\n", encoding="utf-8"
|
|
||||||
)
|
|
||||||
|
|
||||||
monkeypatch.setattr(mf, "manifest", fake_manifest)
|
|
||||||
|
|
||||||
def fake_run(
|
|
||||||
argv, cwd=None, env=None, capture_output=False, text=False, check=False
|
|
||||||
):
|
|
||||||
calls["argv"] = list(argv)
|
|
||||||
calls["cwd"] = cwd
|
|
||||||
return types.SimpleNamespace(returncode=0, stdout="ok", stderr="")
|
|
||||||
|
|
||||||
monkeypatch.setattr(d.subprocess, "run", fake_run)
|
|
||||||
|
|
||||||
old = tmp_path / "old"
|
|
||||||
_write_bundle(old, {"inventory": {"packages": {}}, "roles": _minimal_roles()})
|
|
||||||
|
|
||||||
report = {
|
|
||||||
"packages": {"added": [], "removed": ["curl"], "version_changed": []},
|
|
||||||
"services": {"enabled_added": [], "enabled_removed": [], "changed": []},
|
|
||||||
"users": {"added": [], "removed": [], "changed": []},
|
|
||||||
"files": {"added": [], "removed": [], "changed": []},
|
|
||||||
}
|
|
||||||
|
|
||||||
info = d.enforce_old_harvest(str(old), report=report, target="puppet")
|
|
||||||
|
|
||||||
assert info["status"] == "applied"
|
|
||||||
assert info["target"] == "puppet"
|
|
||||||
assert info["tool"] == "puppet apply"
|
|
||||||
assert info["scope"] == "full_manifest"
|
|
||||||
assert info["tags"] == []
|
|
||||||
assert calls["manifest_target"] == "puppet"
|
|
||||||
|
|
||||||
argv = calls.get("argv")
|
|
||||||
assert argv and argv[:2] == ["/usr/bin/puppet", "apply"]
|
|
||||||
assert "--modulepath" in argv
|
|
||||||
assert any(
|
|
||||||
str(Path(calls["cwd"]) / "manifest" / "manifests" / "site.pp") == str(a)
|
|
||||||
for a in argv
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_enforce_old_harvest_runs_salt_target(monkeypatch, tmp_path: Path):
|
|
||||||
import enroll.diff as d
|
|
||||||
import enroll.manifest as mf
|
|
||||||
|
|
||||||
monkeypatch.setattr(
|
|
||||||
d.shutil,
|
|
||||||
"which",
|
|
||||||
lambda name: "/usr/bin/salt-call" if name == "salt-call" else None,
|
|
||||||
)
|
|
||||||
|
|
||||||
calls: dict[str, object] = {}
|
|
||||||
|
|
||||||
def fake_manifest(_harvest_dir: str, out_dir: str, **kwargs):
|
|
||||||
calls["manifest_target"] = kwargs.get("target")
|
|
||||||
out = Path(out_dir)
|
|
||||||
(out / "states").mkdir(parents=True)
|
|
||||||
(out / "states" / "top.sls").write_text("base:\n '*': []\n", encoding="utf-8")
|
|
||||||
|
|
||||||
monkeypatch.setattr(mf, "manifest", fake_manifest)
|
|
||||||
|
|
||||||
def fake_run(
|
|
||||||
argv, cwd=None, env=None, capture_output=False, text=False, check=False
|
|
||||||
):
|
|
||||||
calls["argv"] = list(argv)
|
|
||||||
calls["cwd"] = cwd
|
|
||||||
return types.SimpleNamespace(returncode=0, stdout="ok", stderr="")
|
|
||||||
|
|
||||||
monkeypatch.setattr(d.subprocess, "run", fake_run)
|
|
||||||
|
|
||||||
old = tmp_path / "old"
|
|
||||||
_write_bundle(old, {"inventory": {"packages": {}}, "roles": _minimal_roles()})
|
|
||||||
|
|
||||||
report = {
|
|
||||||
"packages": {"added": [], "removed": ["curl"], "version_changed": []},
|
|
||||||
"services": {"enabled_added": [], "enabled_removed": [], "changed": []},
|
|
||||||
"users": {"added": [], "removed": [], "changed": []},
|
|
||||||
"files": {"added": [], "removed": [], "changed": []},
|
|
||||||
}
|
|
||||||
|
|
||||||
info = d.enforce_old_harvest(str(old), report=report, target="salt")
|
|
||||||
|
|
||||||
assert info["status"] == "applied"
|
|
||||||
assert info["target"] == "salt"
|
|
||||||
assert info["tool"] == "salt-call"
|
|
||||||
assert info["scope"] == "full_manifest"
|
|
||||||
assert calls["manifest_target"] == "salt"
|
|
||||||
|
|
||||||
argv = calls.get("argv")
|
|
||||||
assert argv and argv[0] == "/usr/bin/salt-call"
|
|
||||||
assert "--local" in argv
|
|
||||||
assert "--file-root" in argv
|
|
||||||
assert "state.apply" in argv
|
|
||||||
assert str(Path(calls["cwd"]) / "manifest" / "states") in argv
|
|
||||||
|
|
||||||
|
|
||||||
def test_cli_diff_enforce_forwards_target(monkeypatch):
|
def test_cli_diff_enforce_forwards_target(monkeypatch):
|
||||||
import enroll.cli as cli
|
import enroll.cli as cli
|
||||||
|
|
||||||
|
|
@ -458,17 +331,38 @@ def test_cli_diff_enforce_forwards_target(monkeypatch):
|
||||||
"--new",
|
"--new",
|
||||||
"/tmp/new",
|
"/tmp/new",
|
||||||
"--enforce",
|
"--enforce",
|
||||||
"--target",
|
|
||||||
"puppet",
|
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
cli.main()
|
cli.main()
|
||||||
assert calls["old"] == "/tmp/old"
|
assert calls["old"] == "/tmp/old"
|
||||||
assert calls["target"] == "puppet"
|
|
||||||
assert calls["report"] is report
|
assert calls["report"] is report
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_diff_enforce_rejects_non_ansible_target(monkeypatch):
|
||||||
|
"""Salt and Puppet targets have been removed from --enforce."""
|
||||||
|
import enroll.cli as cli
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
sys,
|
||||||
|
"argv",
|
||||||
|
[
|
||||||
|
"enroll",
|
||||||
|
"diff",
|
||||||
|
"--old",
|
||||||
|
"/tmp/old",
|
||||||
|
"--new",
|
||||||
|
"/tmp/new",
|
||||||
|
"--enforce",
|
||||||
|
"--target",
|
||||||
|
"puppet",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(SystemExit):
|
||||||
|
cli.main()
|
||||||
|
|
||||||
|
|
||||||
def test_cli_diff_forwards_exclude_and_ignore_flags(monkeypatch, capsys):
|
def test_cli_diff_forwards_exclude_and_ignore_flags(monkeypatch, capsys):
|
||||||
import enroll.cli as cli
|
import enroll.cli as cli
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -136,7 +136,7 @@ def test_ansible_static_marks_harvested_jinja_values_unsafe(tmp_path: Path):
|
||||||
payload = "{{ lookup('pipe','touch /tmp/PWNED_BY_ENROLL_ANSIBLE') }}"
|
payload = "{{ lookup('pipe','touch /tmp/PWNED_BY_ENROLL_ANSIBLE') }}"
|
||||||
write_schema_state(bundle, _ansible_jinja_payload_state(payload))
|
write_schema_state(bundle, _ansible_jinja_payload_state(payload))
|
||||||
|
|
||||||
manifest.manifest(str(bundle), str(out), target="ansible")
|
manifest.manifest(str(bundle), str(out))
|
||||||
|
|
||||||
defaults = out / "roles" / "users" / "defaults" / "main.yml"
|
defaults = out / "roles" / "users" / "defaults" / "main.yml"
|
||||||
text = defaults.read_text(encoding="utf-8")
|
text = defaults.read_text(encoding="utf-8")
|
||||||
|
|
@ -152,7 +152,7 @@ def test_ansible_fqdn_marks_harvested_jinja_values_unsafe(tmp_path: Path):
|
||||||
payload = "{{ lookup('pipe','touch /tmp/PWNED_BY_ENROLL_ANSIBLE') }}"
|
payload = "{{ lookup('pipe','touch /tmp/PWNED_BY_ENROLL_ANSIBLE') }}"
|
||||||
write_schema_state(bundle, _ansible_jinja_payload_state(payload))
|
write_schema_state(bundle, _ansible_jinja_payload_state(payload))
|
||||||
|
|
||||||
manifest.manifest(str(bundle), str(out), target="ansible", fqdn="host.example.test")
|
manifest.manifest(str(bundle), str(out), fqdn="host.example.test")
|
||||||
|
|
||||||
hostvars = out / "inventory" / "host_vars" / "host.example.test" / "users.yml"
|
hostvars = out / "inventory" / "host_vars" / "host.example.test" / "users.yml"
|
||||||
text = hostvars.read_text(encoding="utf-8")
|
text = hostvars.read_text(encoding="utf-8")
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
74
tests/test_secret_detection.py
Normal file
74
tests/test_secret_detection.py
Normal file
|
|
@ -0,0 +1,74 @@
|
||||||
|
"""Regression test: safe-mode content sniff must catch URI-embedded credentials
|
||||||
|
and HTTP Authorization headers, not just assignment-style credential keys."""
|
||||||
|
|
||||||
|
from enroll.ignore import IgnorePolicy
|
||||||
|
|
||||||
|
|
||||||
|
def test_uri_embedded_credentials_denied():
|
||||||
|
pol = IgnorePolicy(dangerous=False)
|
||||||
|
leaking = [
|
||||||
|
b"DATABASE_URL=postgresql://admin:S3cr3tPass@db:5432/app\n",
|
||||||
|
b"REDIS_URL=redis://:mypassword@localhost:6379/0\n",
|
||||||
|
b"broker_url = amqp://guest:guest@rabbit:5672//\n",
|
||||||
|
b"uri: mongodb+srv://user:p%40ss@cluster0.mongodb.net\n",
|
||||||
|
]
|
||||||
|
for data in leaking:
|
||||||
|
assert (
|
||||||
|
pol._content_deny_reason("/etc/svc/app.conf", data) == "sensitive_content"
|
||||||
|
), data
|
||||||
|
|
||||||
|
|
||||||
|
def test_authorization_headers_denied():
|
||||||
|
pol = IgnorePolicy(dangerous=False)
|
||||||
|
for data in [
|
||||||
|
b"Authorization: Bearer eyJhbGciOiJIUzI1NiIs\n",
|
||||||
|
b"Authorization: Basic dXNlcjpwYXNzd29yZA==\n",
|
||||||
|
b"Proxy-Authorization: Bearer xyz\n",
|
||||||
|
]:
|
||||||
|
assert (
|
||||||
|
pol._content_deny_reason("/etc/svc/app.conf", data) == "sensitive_content"
|
||||||
|
), data
|
||||||
|
|
||||||
|
|
||||||
|
def test_benign_urls_not_false_positive():
|
||||||
|
pol = IgnorePolicy(dangerous=False)
|
||||||
|
for data in [
|
||||||
|
b"homepage = https://example.com/docs\n",
|
||||||
|
b"server = http://localhost:8080/api\n",
|
||||||
|
b"origin = git://github.com/u/repo.git\n",
|
||||||
|
b"bind = http://[::1]:9000/\n",
|
||||||
|
b"contact = mailto:admin@example.com\n",
|
||||||
|
b"log_level = info\nworkers = 4\n",
|
||||||
|
]:
|
||||||
|
assert pol._content_deny_reason("/etc/svc/app.conf", data) is None, data
|
||||||
|
|
||||||
|
|
||||||
|
def test_bare_word_password_denied():
|
||||||
|
"""Credential lines using the word password/passphrase/credentials without
|
||||||
|
an assignment delimiter (netrc, ppp, space/tab separated) must be denied."""
|
||||||
|
pol = IgnorePolicy(dangerous=False)
|
||||||
|
for data in [
|
||||||
|
b"machine api.example.com login bob password s3cret\n",
|
||||||
|
b"passphrase mysecretphrase\n",
|
||||||
|
b"credentials /run/creds/db\n",
|
||||||
|
]:
|
||||||
|
assert (
|
||||||
|
pol._content_deny_reason("/etc/svc.conf", data) == "sensitive_content"
|
||||||
|
), data
|
||||||
|
|
||||||
|
|
||||||
|
def test_ppp_secrets_paths_denied():
|
||||||
|
pol = IgnorePolicy(dangerous=False)
|
||||||
|
for p in ("/etc/ppp/chap-secrets", "/etc/ppp/pap-secrets", "/etc/ppp/my-secrets"):
|
||||||
|
assert pol._path_deny_reason(p) == "denied_path", p
|
||||||
|
|
||||||
|
|
||||||
|
def test_password_keyword_no_false_positives():
|
||||||
|
pol = IgnorePolicy(dangerous=False)
|
||||||
|
for data in [
|
||||||
|
b"passive_mode = true\n",
|
||||||
|
b"PassengerRoot /usr/lib\n",
|
||||||
|
b"description = reset the account\n",
|
||||||
|
b"compression = gzip\n",
|
||||||
|
]:
|
||||||
|
assert pol._content_deny_reason("/etc/app.conf", data) is None, data
|
||||||
Loading…
Add table
Add a link
Reference in a new issue