diff --git a/CHANGELOG.md b/CHANGELOG.md
index 75665e5..c24d38c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,9 +3,6 @@
* Add support for detecting flatpaks and snaps
* BREAKING CHANGE: Group all package and systemd-unit roles into Debian Section/RPM Group roles by default, including managed config files and unit state. This mode is not used if `--fqdn` or `--no-common-roles` is set, in which case, the traditional behaviour of preserving one role per package/unit is used instead.
* BREAKING CHANGE: Only capture user-specific .bashrc style files when using `--dangerous` mode, in case they contain sensitive env vars.
- * Detect active sysctl parameters and write them to a `/etc/sysctl.d/99-enroll.conf` file
- * Use `no_log` on systemd unit interrogations to suppress potential sensitive output when applying Ansible
- * Support manifesting Puppet code, as well as Ansible!
# 0.6.0
diff --git a/README.md b/README.md
index 7f30c39..b6d6e39 100644
--- a/README.md
+++ b/README.md
@@ -4,7 +4,7 @@
-**enroll** inspects a Linux machine (Debian-like or RedHat-like) and generates configuration-management code: Ansible roles/playbooks by default, or Puppet control-repo style output for what it finds.
+**enroll** inspects a Linux machine (Debian-like or RedHat-like) and generates Ansible roles/playbooks (and optionally inventory) for what it finds.
- Detects packages that have been installed.
- Detects package ownership of `/etc` files where possible
@@ -13,7 +13,6 @@
- Defensively excludes likely secrets (path denylist + content sniff + size caps).
- Captures non-system users and their SSH public keys. In `--dangerous` mode, it also auto-harvests common shell dotfiles such as `.bashrc`, `.profile`, `.bash_logout`, and `.bash_aliases` when appropriate.
- Captures miscellaneous `/etc` files it can't attribute to a package and installs them in an `etc_custom` role.
-- When running as root/sudo, captures live writable sysctl state into a `sysctl` role that manages `/etc/sysctl.d/99-enroll.conf`.
- Captures live ipset and iptables runtime state into a fallback `firewall_runtime` role, when active ipsets/iptables rules are present *and* no corresponding persistent ipset/iptables *files* were found.
- Captures symlinks in common applications that rely on them, e.g apache2/nginx 'sites-enabled'
- Ditto for /usr/local/bin (for non-binary files) and /usr/local/etc
@@ -26,7 +25,7 @@
`enroll` works in two phases:
1) **Harvest**: collect host facts + relevant files into a harvest bundle (`state.json` + harvested artifacts)
-2) **Manifest**: turn that harvest into configuration-management code such as Ansible roles/playbooks or Puppet manifests
+2) **Manifest**: turn that harvest into Ansible roles/playbooks (and optionally inventory)
Additionally, some other functionalities exist:
@@ -37,7 +36,7 @@ Additionally, some other functionalities exist:
## 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.
+`enroll manifest` (and `enroll single-shot`) support two distinct output styles.
### Single-site mode (default: *no* `--fqdn`)
Use when enrolling **one server** (or generating a “golden” role set you intend to reuse).
@@ -74,7 +73,6 @@ Harvest state about a host and write a harvest bundle.
- In `--dangerous` mode: common per-user shell dotfiles that are likely to represent deliberate account customisation
- Misc `/etc` that can't be attributed to a package (`etc_custom` role)
- Static firewall config files such as nftables, UFW, firewalld, `/etc/iptables/rules.v4`, `/etc/iptables/rules.v6`, and `/etc/ipset*`
-- Live writable sysctl state via `sysctl -a`, emitted as `/etc/sysctl.d/99-enroll.conf` at manifest time when running as root/sudo (`sysctl` role)
- Live kernel ipset/iptables state via `ipset save`, `iptables-save`, and `ip6tables-save` as a fallback, but only when the corresponding persistent config was not found (`firewall_runtime` role at manifest time)
- Optional user-specified extra files/dirs via `--include-path` (emitted as an `extra_paths` role at manifest time)
@@ -120,20 +118,18 @@ enroll single-shot --remote-host myhost.example.com --remote-user myuser --ssh-k
---
### `enroll manifest`
-Generate configuration-management output from an existing harvest bundle. Ansible remains the default; use `--target puppet` for Puppet output.
+Generate Ansible output from an existing harvest bundle.
**Inputs**
- `--harvest /path/to/harvest` (directory)
or `--harvest /path/to/harvest.tar.gz.sops` (if using `--sops`)
**Output**
-- 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 mode: an Ansible repo-like directory structure (roles/playbooks, and inventory in multi-site mode).
- In `--sops` mode: a single encrypted file `manifest.tar.gz.sops` containing the generated output.
**Common flags**
-- `--target ansible|puppet`: choose the manifest target (`ansible` is the default).
-- `--fqdn `: enables **multi-site** output style for Ansible, or emits a Puppet `node ''` block. Without `--fqdn`, Puppet emits `node default { ... }`.
+- `--fqdn `: enables **multi-site** output style
- `--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**
@@ -154,7 +150,7 @@ Convenience wrapper that runs **harvest → manifest** in one command.
Use this when you want “get me something workable ASAP”.
-Supports the same general flags as harvest/manifest, including `--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`.
---
@@ -440,26 +436,6 @@ enroll manifest --harvest /tmp/enroll-harvest --out /tmp/enroll-ansible
enroll manifest --harvest /tmp/enroll-harvest --out /tmp/enroll-ansible --fqdn "$(hostname -f)"
```
-### 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 '' { ... }`. 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
-```
-
-Flatpak, Snap, and live firewall runtime snapshots are listed as notes in the generated Puppet README rather than converted into Puppet resources.
-
### Manifest with `--sops`
```bash
# Generate encrypted manifest bundle (writes /tmp/enroll-ansible/manifest.tar.gz.sops)
diff --git a/enroll/cli.py b/enroll/cli.py
index 7106d7a..a621928 100644
--- a/enroll/cli.py
+++ b/enroll/cli.py
@@ -308,12 +308,6 @@ def _encrypt_harvest_dir_to_sops(
def _add_common_manifest_args(p: argparse.ArgumentParser) -> None:
- p.add_argument(
- "--target",
- choices=["ansible", "puppet"],
- default="ansible",
- help="Manifest target to generate (default: ansible). Puppet output is an initial conservative target.",
- )
p.add_argument(
"--fqdn",
help="Host FQDN/name for site-mode output (creates inventory/, inventory/host_vars/, playbooks/).",
@@ -488,9 +482,7 @@ def main() -> None:
help="Don't use sudo on the remote host (when using --remote options). This may result in a limited harvest due to permission restrictions.",
)
- m = sub.add_parser(
- "manifest", help="Render configuration-management code from a harvest"
- )
+ m = sub.add_parser("manifest", help="Render Ansible roles from a harvest")
_add_config_args(m)
m.add_argument(
"--harvest",
@@ -522,8 +514,7 @@ def main() -> None:
_add_common_manifest_args(m)
s = sub.add_parser(
- "single-shot",
- help="Harvest state, then manifest configuration-management code, in one shot",
+ "single-shot", help="Harvest state, then manifest Ansible code, in one shot"
)
_add_config_args(s)
_add_remote_args(s)
@@ -929,7 +920,6 @@ def main() -> None:
jinjaturtle=_jt_mode(args),
sops_fingerprints=getattr(args, "sops", None),
no_common_roles=bool(getattr(args, "no_common_roles", False)),
- target=getattr(args, "target", "ansible"),
)
if getattr(args, "sops", None) and out_enc:
print(str(out_enc))
@@ -1068,7 +1058,6 @@ def main() -> None:
jinjaturtle=_jt_mode(args),
sops_fingerprints=list(sops_fps),
no_common_roles=bool(getattr(args, "no_common_roles", False)),
- target=getattr(args, "target", "ansible"),
)
if not args.harvest:
print(str(out_file))
@@ -1100,7 +1089,6 @@ def main() -> None:
fqdn=args.fqdn,
jinjaturtle=_jt_mode(args),
no_common_roles=bool(getattr(args, "no_common_roles", False)),
- target=getattr(args, "target", "ansible"),
)
# For usability (when --harvest wasn't provided), print the harvest path.
if not args.harvest:
@@ -1132,7 +1120,6 @@ def main() -> None:
jinjaturtle=_jt_mode(args),
sops_fingerprints=list(sops_fps),
no_common_roles=bool(getattr(args, "no_common_roles", False)),
- target=getattr(args, "target", "ansible"),
)
if not args.harvest:
print(str(out_file))
@@ -1153,7 +1140,6 @@ def main() -> None:
fqdn=args.fqdn,
jinjaturtle=_jt_mode(args),
no_common_roles=bool(getattr(args, "no_common_roles", False)),
- target=getattr(args, "target", "ansible"),
)
except RemoteSudoPasswordRequired:
raise SystemExit(
diff --git a/enroll/diff.py b/enroll/diff.py
index eb496a5..8d54bb1 100644
--- a/enroll/diff.py
+++ b/enroll/diff.py
@@ -303,12 +303,6 @@ def _iter_managed_files(state: Dict[str, Any]) -> Iterable[Tuple[str, Dict[str,
for mf in ac.get("managed_files", []) or []:
yield str(ac_role), mf
- # sysctl
- sc = _roles(state).get("sysctl") or {}
- sc_role = sc.get("role_name") or "sysctl"
- for mf in sc.get("managed_files", []) or []:
- yield str(sc_role), mf
-
# etc_custom
ec = _roles(state).get("etc_custom") or {}
ec_role = ec.get("role_name") or "etc_custom"
diff --git a/enroll/explain.py b/enroll/explain.py
index b4f4de8..131f2df 100644
--- a/enroll/explain.py
+++ b/enroll/explain.py
@@ -383,7 +383,6 @@ def explain_state(
for rname in [
"apt_config",
"dnf_config",
- "sysctl",
"etc_custom",
"usr_local_custom",
"extra_paths",
@@ -436,7 +435,6 @@ def explain_state(
for rname in [
"apt_config",
"dnf_config",
- "sysctl",
"etc_custom",
"usr_local_custom",
"extra_paths",
diff --git a/enroll/harvest.py b/enroll/harvest.py
index 664d1ae..0f9aa1e 100644
--- a/enroll/harvest.py
+++ b/enroll/harvest.py
@@ -181,14 +181,6 @@ class FirewallRuntimeSnapshot:
notes: List[str] = field(default_factory=list)
-@dataclass
-class SysctlSnapshot:
- role_name: str
- managed_files: List[ManagedFile] = field(default_factory=list)
- parameters: Dict[str, str] = field(default_factory=dict)
- notes: List[str] = field(default_factory=list)
-
-
ALLOWED_UNOWNED_EXTS = {
".cfg",
".cnf",
@@ -1057,7 +1049,6 @@ _FIREWALL_CAPTURE_COMMANDS: Dict[str, Tuple[str, ...]] = {
"ipset_save": ("ipset", "save"),
"iptables_v4_save": ("iptables-save",),
"iptables_v6_save": ("ip6tables-save",),
- "sysctl_all": ("sysctl", "-a"),
}
@@ -1113,199 +1104,6 @@ def _write_generated_artifact(
f.write(content)
-_SYSCTL_KEY_RE = re.compile(r"^[A-Za-z0-9_.-]+$")
-_SYSCTL_GENERATED_DEST = "/etc/sysctl.d/99-enroll.conf"
-_SYSCTL_GENERATED_SRC_REL = "sysctl/99-enroll.conf"
-
-# Writable-looking action/identity keys that are poor candidates for persistent
-# config. This avoids generating a file that tries to replay one-shot triggers or
-# host identity that should be managed elsewhere (e.g. /etc/hostname).
-_SYSCTL_VOLATILE_KEYS = {
- "fs.binfmt_misc.status",
- "kernel.domainname",
- "kernel.hostname",
- "kernel.kexec_load_disabled",
- "kernel.kexec_load_limit_panic",
- "kernel.kexec_load_limit_reboot",
- "kernel.max_rcu_stall_to_panic",
- "kernel.modules_disabled",
- "kernel.ns_last_pid",
- "net.ipv4.route.flush",
- "net.ipv6.route.flush",
- "vm.compact_memory",
- "vm.drop_caches",
- "vm.stat_refresh",
-}
-
-_SYSCTL_VOLATILE_PREFIXES = (
- "fs.binfmt_misc.",
- "kernel.sched_domain.",
-)
-
-# These are paired with ratio/byte counterparts. The inactive side appears as 0
-# when read; replaying that 0 through sysctl -p is noisy and can be rejected by
-# kernels that enforce minimum values.
-_SYSCTL_SKIP_ZERO_VALUE_KEYS = {
- "vm.dirty_background_bytes",
- "vm.dirty_background_ratio",
- "vm.dirty_bytes",
- "vm.dirty_ratio",
-}
-
-
-def _sysctl_proc_path(key: str) -> str:
- return "/proc/sys/" + key.replace(".", "/")
-
-
-def _sysctl_key_is_persistable(key: str) -> tuple[bool, str]:
- if not key or not _SYSCTL_KEY_RE.fullmatch(key):
- return False, "invalid key"
- if key in _SYSCTL_VOLATILE_KEYS or any(
- key.startswith(prefix) for prefix in _SYSCTL_VOLATILE_PREFIXES
- ):
- return False, "volatile/action key"
-
- proc_path = _sysctl_proc_path(key)
- try:
- st = os.stat(proc_path)
- except OSError:
- return False, "no /proc/sys entry"
-
- if not stat.S_ISREG(st.st_mode):
- return False, "not a regular /proc/sys entry"
- if (stat.S_IMODE(st.st_mode) & 0o222) == 0:
- return False, "read-only /proc/sys entry"
- return True, ""
-
-
-def _sysctl_entry_is_persistable(key: str, value: str) -> tuple[bool, str]:
- ok, reason = _sysctl_key_is_persistable(key)
- if not ok:
- return ok, reason
-
- if key in _SYSCTL_SKIP_ZERO_VALUE_KEYS and str(value).strip() == "0":
- return False, "inactive mutually-exclusive zero value"
-
- return True, ""
-
-
-def _parse_sysctl_a_output(
- text: str,
- *,
- require_persistable: bool = True,
-) -> tuple[Dict[str, str], Dict[str, int]]:
- """Parse `sysctl -a` output into persistable key/value pairs.
-
- `sysctl -a` includes read-only, write-only, multiline, action-like, and
- host-identity values. Persisting those can create noisy or failing Ansible
- runs, so the default parser keeps only single-line writable-looking keys.
- """
-
- out: Dict[str, str] = {}
- skipped: Dict[str, int] = {
- "malformed": 0,
- "empty_value": 0,
- "non_persistable": 0,
- "duplicate": 0,
- }
-
- for raw in (text or "").splitlines():
- line = raw.strip()
- if not line:
- continue
- if " = " in line:
- key, value = line.split(" = ", 1)
- elif "=" in line:
- key, value = line.split("=", 1)
- else:
- skipped["malformed"] += 1
- continue
-
- key = key.strip()
- value = value.strip()
- if not key:
- skipped["malformed"] += 1
- continue
- if value == "":
- skipped["empty_value"] += 1
- continue
- if key in out:
- skipped["duplicate"] += 1
- continue
- if require_persistable:
- ok, _reason = _sysctl_entry_is_persistable(key, value)
- if not ok:
- skipped["non_persistable"] += 1
- continue
- out[key] = value
-
- return dict(sorted(out.items())), skipped
-
-
-def _render_sysctl_conf(parameters: Dict[str, str], notes: List[str]) -> str:
- lines = [
- "# Generated by Enroll from live sysctl state.",
- "# Review before applying broadly; runtime sysctl state can be host/kernel-specific.",
- ]
- for note in notes:
- lines.append(f"# {note}")
- lines.append("")
- for key, value in sorted((parameters or {}).items()):
- safe_value = str(value).replace("\n", " ").strip()
- lines.append(f"{key} = {safe_value}")
- lines.append("")
- return "\n".join(lines)
-
-
-def _collect_sysctl_snapshot(bundle_dir: str) -> SysctlSnapshot:
- role_name = "sysctl"
- notes: List[str] = []
- managed_files: List[ManagedFile] = []
-
- out, err = _run_capture_command("sysctl_all", timeout=20)
- if err:
- notes.append(err)
- return SysctlSnapshot(role_name=role_name, notes=notes)
-
- parameters, skipped = _parse_sysctl_a_output(out or "")
- if not parameters:
- notes.append("No persistable live sysctl parameters were detected.")
- return SysctlSnapshot(role_name=role_name, parameters=parameters, notes=notes)
-
- notes.append(f"Captured {len(parameters)} live writable sysctl parameter(s).")
- skipped_total = sum(skipped.values())
- if skipped_total:
- details = ", ".join(f"{k}={v}" for k, v in sorted(skipped.items()) if v)
- notes.append(
- "Skipped "
- f"{skipped_total} sysctl entr{'y' if skipped_total == 1 else 'ies'} "
- f"that were not suitable for persistence ({details})."
- )
-
- _write_generated_artifact(
- bundle_dir,
- role_name,
- _SYSCTL_GENERATED_SRC_REL,
- _render_sysctl_conf(parameters, notes),
- )
- managed_files.append(
- ManagedFile(
- path=_SYSCTL_GENERATED_DEST,
- src_rel=_SYSCTL_GENERATED_SRC_REL,
- owner="root",
- group="root",
- mode="0644",
- reason="system_sysctl",
- )
- )
- return SysctlSnapshot(
- role_name=role_name,
- managed_files=managed_files,
- parameters=parameters,
- notes=notes,
- )
-
-
def _ipset_save_has_state(text: str) -> bool:
for raw in (text or "").splitlines():
line = raw.strip()
@@ -1502,20 +1300,13 @@ def harvest(
_PERSISTENT_IPTABLES_V6_GLOBS
)
- running_as_root = not hasattr(os, "geteuid") or os.geteuid() == 0
- if not running_as_root:
+ if hasattr(os, "geteuid") and os.geteuid() != 0:
firewall_runtime_snapshot = FirewallRuntimeSnapshot(
role_name="firewall_runtime",
notes=[
"Live ipset/iptables runtime capture skipped because harvest is not running as root."
],
)
- sysctl_snapshot = SysctlSnapshot(
- role_name="sysctl",
- notes=[
- "Live sysctl runtime capture skipped because harvest is not running as root."
- ],
- )
else:
firewall_runtime_snapshot = _collect_firewall_runtime_snapshot(
bundle_dir,
@@ -1523,12 +1314,6 @@ def harvest(
persistent_iptables_v4_files=persistent_iptables_v4_files,
persistent_iptables_v6_files=persistent_iptables_v6_files,
)
- sysctl_snapshot = _collect_sysctl_snapshot(bundle_dir)
-
- # The generated sysctl role owns /etc/sysctl.d/99-enroll.conf; do not also
- # capture an existing file at that path into etc_custom/package roles.
- for mf in sysctl_snapshot.managed_files:
- captured_global.add(mf.path)
def _pick_installed(cands: List[str]) -> Optional[str]:
for c in cands:
@@ -2897,7 +2682,6 @@ def harvest(
"apt_config": asdict(apt_config_snapshot),
"dnf_config": asdict(dnf_config_snapshot),
"firewall_runtime": asdict(firewall_runtime_snapshot),
- "sysctl": asdict(sysctl_snapshot),
"etc_custom": asdict(etc_custom_snapshot),
"usr_local_custom": asdict(usr_local_custom_snapshot),
"extra_paths": asdict(extra_paths_snapshot),
diff --git a/enroll/manifest.py b/enroll/manifest.py
index 97e26e7..dc78bf5 100644
--- a/enroll/manifest.py
+++ b/enroll/manifest.py
@@ -11,7 +11,6 @@ from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Tuple
from .role_names import avoid_reserved_role_name
-from .puppet import manifest_puppet_from_bundle_dir
from .jinjaturtle import (
can_jinjify_path,
@@ -729,7 +728,6 @@ def _render_grouped_systemd_tasks(var_prefix: str) -> str:
return f"""- name: Probe whether grouped systemd units exist and are manageable
ansible.builtin.systemd:
name: "{{{{ item.name }}}}"
- no_log: "{{{{ enroll_hide_systemd_status | default(true) | bool }}}}"
check_mode: true
loop: "{{{{ {var_prefix}_systemd_units | default([]) }}}}"
register: _enroll_unit_probes
@@ -741,7 +739,6 @@ def _render_grouped_systemd_tasks(var_prefix: str) -> str:
ansible.builtin.systemd:
name: "{{{{ item.item.name }}}}"
enabled: "{{{{ item.item.enabled | bool }}}}"
- no_log: "{{{{ enroll_hide_systemd_status | default(true) | bool }}}}"
loop: "{{{{ _enroll_unit_probes.results | default([]) }}}}"
when:
- item.item.manage | default(false)
@@ -751,7 +748,6 @@ def _render_grouped_systemd_tasks(var_prefix: str) -> str:
ansible.builtin.systemd:
name: "{{{{ item.item.name }}}}"
state: "{{{{ item.item.state }}}}"
- no_log: "{{{{ enroll_hide_systemd_status | default(true) | bool }}}}"
loop: "{{{{ _enroll_unit_probes.results | default([]) }}}}"
when:
- item.item.manage | default(false)
@@ -759,50 +755,6 @@ def _render_grouped_systemd_tasks(var_prefix: str) -> str:
"""
-def _render_sysctl_tasks(var_prefix: str) -> str:
- return f"""- name: Ensure sysctl.d exists
- ansible.builtin.file:
- path: /etc/sysctl.d
- state: directory
- owner: root
- group: root
- mode: "0755"
-
-- name: Deploy captured sysctl configuration
- vars:
- _enroll_ff:
- files:
- - "{{{{ inventory_dir }}}}/host_vars/{{{{ inventory_hostname }}}}/{{{{ role_name }}}}/.files/{{{{ {var_prefix}_conf_src_rel }}}}"
- - "{{{{ role_path }}}}/files/{{{{ {var_prefix}_conf_src_rel }}}}"
- ansible.builtin.copy:
- src: "{{{{ lookup('ansible.builtin.first_found', _enroll_ff) }}}}"
- dest: /etc/sysctl.d/99-enroll.conf
- owner: root
- group: root
- mode: "0644"
- when: ({var_prefix}_conf_src_rel | default('') | length) > 0
- notify: Apply captured sysctl configuration
-"""
-
-
-def _render_sysctl_handlers(var_prefix: str) -> str:
- return f"""---
-- name: Apply captured sysctl configuration
- ansible.builtin.command:
- argv:
- - sysctl
- - -e
- - -p
- - /etc/sysctl.d/99-enroll.conf
- register: _enroll_sysctl_apply
- changed_when: false
- failed_when:
- - not ({var_prefix}_ignore_apply_errors | default(true) | bool)
- - _enroll_sysctl_apply.rc != 0
- when: {var_prefix}_apply | default(true) | bool
-"""
-
-
def _render_firewall_runtime_tasks(var_prefix: str) -> str:
"""Render tasks for live ipset/iptables snapshots."""
return f"""- name: Ensure firewall runtime snapshot directory exists
@@ -1040,7 +992,7 @@ def _encrypt_manifest_out_dir_to_sops(
return out_file
-def _manifest_ansible_from_bundle_dir(
+def _manifest_from_bundle_dir(
bundle_dir: str,
out_dir: str,
*,
@@ -1065,7 +1017,6 @@ def _manifest_ansible_from_bundle_dir(
apt_config_snapshot: Dict[str, Any] = roles.get("apt_config", {})
dnf_config_snapshot: Dict[str, Any] = roles.get("dnf_config", {})
firewall_runtime_snapshot: Dict[str, Any] = roles.get("firewall_runtime", {})
- sysctl_snapshot: Dict[str, Any] = roles.get("sysctl", {})
etc_custom_snapshot: Dict[str, Any] = roles.get("etc_custom", {})
usr_local_custom_snapshot: Dict[str, Any] = roles.get("usr_local_custom", {})
extra_paths_snapshot: Dict[str, Any] = roles.get("extra_paths", {})
@@ -1107,7 +1058,6 @@ def _manifest_ansible_from_bundle_dir(
manifested_apt_config_roles: List[str] = []
manifested_dnf_config_roles: List[str] = []
manifested_firewall_runtime_roles: List[str] = []
- manifested_sysctl_roles: List[str] = []
manifested_etc_custom_roles: List[str] = []
manifested_usr_local_custom_roles: List[str] = []
manifested_extra_paths_roles: List[str] = []
@@ -2091,104 +2041,6 @@ DNF/YUM configuration harvested from the system (repos, config files, and RPM GP
manifested_dnf_config_roles.append(role)
- # -------------------------
- # sysctl role (live writable sysctl state)
- # -------------------------
- if sysctl_snapshot and (sysctl_snapshot.get("managed_files") or []):
- role = sysctl_snapshot.get("role_name", "sysctl")
- role_dir = os.path.join(roles_root, role)
- _write_role_scaffold(role_dir)
-
- var_prefix = role
- managed_files = sysctl_snapshot.get("managed_files", []) or []
- conf_src_rel = ""
- for mf in managed_files:
- if mf.get("path") == "/etc/sysctl.d/99-enroll.conf":
- conf_src_rel = mf.get("src_rel") or ""
- break
- if not conf_src_rel and managed_files:
- conf_src_rel = managed_files[0].get("src_rel") or ""
-
- parameters = sysctl_snapshot.get("parameters", {}) or {}
- notes = sysctl_snapshot.get("notes", []) or []
-
- # Generated sysctl snapshots are host-specific in site mode.
- if site_mode:
- _copy_artifacts(
- bundle_dir,
- role,
- _host_role_files_dir(out_dir, fqdn or "", role),
- )
- else:
- _copy_artifacts(bundle_dir, role, os.path.join(role_dir, "files"))
-
- vars_map: Dict[str, Any] = {
- f"{var_prefix}_conf_src_rel": conf_src_rel,
- f"{var_prefix}_apply": True,
- f"{var_prefix}_ignore_apply_errors": True,
- }
-
- if site_mode:
- _write_role_defaults(
- role_dir,
- {
- f"{var_prefix}_conf_src_rel": "",
- f"{var_prefix}_apply": True,
- f"{var_prefix}_ignore_apply_errors": True,
- },
- )
- _write_hostvars(out_dir, fqdn or "", role, vars_map)
- else:
- _write_role_defaults(role_dir, vars_map)
-
- tasks = "---\n" + _render_sysctl_tasks(var_prefix)
- with open(
- os.path.join(role_dir, "tasks", "main.yml"), "w", encoding="utf-8"
- ) as f:
- f.write(tasks.rstrip() + "\n")
-
- handlers_dir = os.path.join(role_dir, "handlers")
- os.makedirs(handlers_dir, exist_ok=True)
- with open(os.path.join(handlers_dir, "main.yml"), "w", encoding="utf-8") as f:
- f.write(_render_sysctl_handlers(var_prefix))
-
- with open(
- os.path.join(role_dir, "meta", "main.yml"), "w", encoding="utf-8"
- ) as f:
- f.write("---\ndependencies: []\n")
-
- param_count = len(parameters) if isinstance(parameters, dict) else 0
- sample_params = []
- if isinstance(parameters, dict):
- sample_params = sorted(parameters.keys())[:25]
-
- readme = f"""# {role}
-
-Generated from live writable sysctl state captured during harvest.
-
-This role deploys the captured values to `/etc/sysctl.d/99-enroll.conf`. The generated file intentionally contains writable, single-line sysctl values only; read-only, multiline, action-like, and host-identity keys are skipped to avoid creating a noisy or brittle boot-time configuration.
-
-## Captured parameters
-
-Captured parameter count: {param_count}
-
-{os.linesep.join("- " + x for x in sample_params) or "- (none)"}
-
-{"- ..." if param_count > len(sample_params) else ""}
-
-## Notes
-{os.linesep.join("- " + n for n in notes) or "- (none)"}
-
-## Safety notes
-- `sysctl_apply` defaults to `true` and applies `/etc/sysctl.d/99-enroll.conf` when the file changes.
-- `sysctl_ignore_apply_errors` defaults to `true`, because sysctl availability and writability can vary across kernels, containers, and hardware.
-- Review this role before applying it broadly across unlike hosts.
-"""
- with open(os.path.join(role_dir, "README.md"), "w", encoding="utf-8") as f:
- f.write(readme)
-
- manifested_sysctl_roles.append(role)
-
# -------------------------
# firewall_runtime role (live ipset/iptables kernel state)
# -------------------------
@@ -2365,7 +2217,6 @@ This role restores live ipset and iptables state only for firewall families wher
- name: Run systemd daemon-reload
ansible.builtin.systemd:
daemon_reload: true
- no_log: "{{ enroll_hide_systemd_status | default(true) | bool }}"
"""
with open(
os.path.join(role_dir, "handlers", "main.yml"), "w", encoding="utf-8"
@@ -2735,7 +2586,6 @@ User-requested extra file harvesting.
- name: Run systemd daemon-reload
ansible.builtin.systemd:
daemon_reload: true
- no_log: "{{ enroll_hide_systemd_status | default(true) | bool }}"
- name: Restart service
ansible.builtin.service:
@@ -2761,7 +2611,6 @@ User-requested extra file harvesting.
f"""- name: Probe whether systemd unit exists and is manageable
ansible.builtin.systemd:
name: "{{{{ {var_prefix}_unit_name }}}}"
- no_log: "{{{{ enroll_hide_systemd_status | default(true) | bool }}}}"
check_mode: true
register: _unit_probe
failed_when: false
@@ -2772,7 +2621,6 @@ User-requested extra file harvesting.
ansible.builtin.systemd:
name: "{{{{ {var_prefix}_unit_name }}}}"
enabled: "{{{{ {var_prefix}_systemd_enabled | bool }}}}"
- no_log: "{{{{ enroll_hide_systemd_status | default(true) | bool }}}}"
when:
- {var_prefix}_manage_unit | default(false)
- _unit_probe is succeeded
@@ -2781,7 +2629,6 @@ User-requested extra file harvesting.
ansible.builtin.systemd:
name: "{{{{ {var_prefix}_unit_name }}}}"
state: "{{{{ {var_prefix}_systemd_state }}}}"
- no_log: "{{{{ enroll_hide_systemd_status | default(true) | bool }}}}"
when:
- {var_prefix}_manage_unit | default(false)
- _unit_probe is succeeded
@@ -2853,7 +2700,6 @@ Generated from `{unit}`.
+ manifested_snap_roles
+ manifested_service_roles
+ manifested_firewall_runtime_roles
- + manifested_sysctl_roles
+ manifested_etc_custom_roles
+ manifested_usr_local_custom_roles
+ manifested_extra_paths_roles
@@ -3008,7 +2854,6 @@ Generated from `{unit}`.
- name: Run systemd daemon-reload
ansible.builtin.systemd:
daemon_reload: true
- no_log: "{{ enroll_hide_systemd_status | default(true) | bool }}"
- name: Restart managed services
ansible.builtin.service:
@@ -3155,7 +3000,6 @@ Common role for package section/group `{section_label}`.
- name: Run systemd daemon-reload
ansible.builtin.systemd:
daemon_reload: true
- no_log: "{{ enroll_hide_systemd_status | default(true) | bool }}"
"""
with open(
os.path.join(role_dir, "handlers", "main.yml"), "w", encoding="utf-8"
@@ -3228,7 +3072,6 @@ Generated for package `{pkg}`.
+ manifested_snap_roles
+ manifested_users_roles
+ tail_roles
- + manifested_sysctl_roles
+ manifested_firewall_runtime_roles
)
@@ -3248,9 +3091,8 @@ def manifest(
jinjaturtle: str = "auto", # auto|on|off
sops_fingerprints: Optional[List[str]] = None,
no_common_roles: bool = False,
- target: str = "ansible",
) -> Optional[str]:
- """Render a configuration-management manifest from a harvest.
+ """Render an Ansible manifest from a harvest.
Plain mode:
- `bundle_dir` must be a directory
@@ -3266,10 +3108,6 @@ def manifest(
- In SOPS mode: the path to the encrypted manifest bundle (.sops)
- In plain mode: None
"""
- target = (target or "ansible").strip().lower()
- if target not in {"ansible", "puppet"}:
- raise ValueError(f"unsupported manifest target: {target!r}")
-
sops_mode = bool(sops_fingerprints)
# Decrypt/extract the harvest bundle if needed.
@@ -3280,21 +3118,13 @@ def manifest(
td_out: Optional[tempfile.TemporaryDirectory] = None
try:
if not sops_mode:
- if target == "puppet":
- manifest_puppet_from_bundle_dir(
- resolved_bundle_dir,
- out,
- fqdn=fqdn,
- no_common_roles=no_common_roles,
- )
- else:
- _manifest_ansible_from_bundle_dir(
- resolved_bundle_dir,
- out,
- fqdn=fqdn,
- jinjaturtle=jinjaturtle,
- no_common_roles=no_common_roles,
- )
+ _manifest_from_bundle_dir(
+ resolved_bundle_dir,
+ out,
+ fqdn=fqdn,
+ jinjaturtle=jinjaturtle,
+ no_common_roles=no_common_roles,
+ )
return None
# SOPS mode: generate into a secure temp dir, then tar+encrypt into a single file.
@@ -3308,21 +3138,13 @@ def manifest(
except OSError:
pass
- if target == "puppet":
- manifest_puppet_from_bundle_dir(
- resolved_bundle_dir,
- str(tmp_out),
- fqdn=fqdn,
- no_common_roles=no_common_roles,
- )
- else:
- _manifest_ansible_from_bundle_dir(
- resolved_bundle_dir,
- str(tmp_out),
- fqdn=fqdn,
- jinjaturtle=jinjaturtle,
- no_common_roles=no_common_roles,
- )
+ _manifest_from_bundle_dir(
+ resolved_bundle_dir,
+ str(tmp_out),
+ fqdn=fqdn,
+ jinjaturtle=jinjaturtle,
+ no_common_roles=no_common_roles,
+ )
enc = _encrypt_manifest_out_dir_to_sops(
tmp_out, out_file, list(sops_fingerprints or [])
diff --git a/enroll/puppet.py b/enroll/puppet.py
deleted file mode 100644
index a051a75..0000000
--- a/enroll/puppet.py
+++ /dev/null
@@ -1,759 +0,0 @@
-from __future__ import annotations
-
-import json
-import os
-import re
-import shutil
-from pathlib import Path
-from typing import Any, Dict, Iterable, List, Optional, Set, Tuple
-
-
-def _load_state(bundle_dir: str) -> Dict[str, Any]:
- with open(os.path.join(bundle_dir, "state.json"), "r", encoding="utf-8") as f:
- return json.load(f)
-
-
-_RESERVED_PUPPET_NAMES = {
- "application",
- "class",
- "default",
- "define",
- "import",
- "inherits",
- "node",
- "site",
-}
-
-
-def _puppet_name(raw: str, *, fallback: str = "role") -> str:
- s = re.sub(r"[^A-Za-z0-9_]+", "_", raw or fallback)
- s = re.sub(r"_+", "_", s).strip("_").lower()
- if not s:
- s = fallback
- if not re.match(r"^[a-z]", s):
- s = f"{fallback}_{s}"
- if s in _RESERVED_PUPPET_NAMES:
- s = f"{fallback}_{s}"
- return s
-
-
-def _pp_quote(value: Any) -> str:
- s = str(value)
- s = s.replace("\\", "\\\\").replace("'", "\\'")
- return f"'{s}'"
-
-
-def _pp_bool(value: bool) -> str:
- return "true" if bool(value) else "false"
-
-
-def _pp_array(values: Iterable[Any]) -> str:
- return "[" + ", ".join(_pp_quote(v) for v in values) + "]"
-
-
-def _resource(
- lines: List[str], rtype: str, title: str, attrs: List[Tuple[str, str]]
-) -> None:
- lines.append(f" {rtype} {{ {_pp_quote(title)}:")
- for key, value in attrs:
- lines.append(f" {key} => {value},")
- lines.append(" }")
- lines.append("")
-
-
-def _copy_artifact(
- bundle_dir: str, role: str, src_rel: str, dst_files_dir: Path
-) -> Optional[str]:
- if not role or not src_rel:
- return None
- src = Path(bundle_dir) / "artifacts" / role / src_rel
- if not src.is_file():
- return None
- dst = dst_files_dir / src_rel
- dst.parent.mkdir(parents=True, exist_ok=True)
- shutil.copy2(src, dst)
- return Path(src_rel).as_posix()
-
-
-def _source_uri(module_name: str, module_rel: str) -> str:
- return f"puppet:///modules/{module_name}/{module_rel}"
-
-
-def _roles(state: Dict[str, Any]) -> Dict[str, Any]:
- roles = state.get("roles")
- return roles if isinstance(roles, dict) else {}
-
-
-def _inventory_packages(state: Dict[str, Any]) -> Dict[str, Any]:
- inventory = state.get("inventory")
- if not isinstance(inventory, dict):
- return {}
- packages = inventory.get("packages")
- return packages if isinstance(packages, dict) else {}
-
-
-def _package_section_label(
- package_role: Dict[str, Any], inventory_packages: Dict[str, Any]
-) -> str:
- pkg = str(package_role.get("package") or "").strip()
- inv = inventory_packages.get(pkg) or {}
- candidates: List[str] = []
- for value in (package_role.get("section"), inv.get("section"), inv.get("group")):
- if isinstance(value, str) and value.strip():
- candidates.append(value.strip())
- for inst in inv.get("installations", []) or []:
- if not isinstance(inst, dict):
- continue
- for key in ("section", "group"):
- value = inst.get(key)
- if isinstance(value, str) and value.strip():
- candidates.append(value.strip())
- for value in candidates:
- if value.lower() not in {"(none)", "none", "unspecified"}:
- return value
- return "misc"
-
-
-def _section_label_for_packages(
- packages: List[str], inventory_packages: Dict[str, Any]
-) -> str:
- for pkg in packages or []:
- label = _package_section_label({"package": pkg}, inventory_packages)
- if label and label.lower() != "misc":
- return label
- return "misc"
-
-
-class _PuppetRole:
- def __init__(self, role_name: str) -> None:
- self.role_name = role_name
- self.module_name = _puppet_name(role_name, fallback="enroll_role")
- self.packages: Set[str] = set()
- self.groups: Set[str] = set()
- self.users: Dict[str, Dict[str, Any]] = {}
- self.dirs: Dict[str, Dict[str, Any]] = {}
- self.files: Dict[str, Dict[str, Any]] = {}
- self.links: Dict[str, Dict[str, Any]] = {}
- self.services: Dict[str, Dict[str, Any]] = {}
- self.notes: List[str] = []
-
- def has_resources(self) -> bool:
- return bool(
- self.packages
- or self.groups
- or self.users
- or self.dirs
- or self.files
- or self.links
- or self.services
- or self.notes
- )
-
-
-def _role_order_key(role: str) -> tuple[int, str]:
- # Keep broadly similar ordering to generated Ansible playbooks: package/config
- # scaffolding first, then services/users, then host-specific runtime state.
- priority = {
- "apt_config": 10,
- "dnf_config": 11,
- "etc_custom": 80,
- "usr_local_custom": 81,
- "extra_paths": 82,
- "users": 90,
- "sysctl": 95,
- "firewall_runtime": 99,
- }
- return (priority.get(role, 50), role)
-
-
-def _add_managed_content(
- prole: _PuppetRole,
- snap: Dict[str, Any],
- *,
- bundle_dir: str,
- artifact_role: str,
- module_files_dir: Path,
-) -> None:
- for d in snap.get("managed_dirs", []) or []:
- if not isinstance(d, dict):
- continue
- path = str(d.get("path") or "").strip()
- if not path:
- continue
- prole.dirs.setdefault(
- path,
- {
- "owner": d.get("owner") or "root",
- "group": d.get("group") or "root",
- "mode": d.get("mode") or "0755",
- "reason": d.get("reason") or "managed_dir",
- },
- )
-
- for mf in snap.get("managed_files", []) or []:
- if not isinstance(mf, dict):
- continue
- path = str(mf.get("path") or "").strip()
- src_rel = str(mf.get("src_rel") or "").strip()
- if not path or not src_rel:
- continue
- module_rel = _copy_artifact(
- bundle_dir, artifact_role, src_rel, module_files_dir
- )
- if not module_rel:
- prole.notes.append(
- f"Skipped {path}: harvested artifact {artifact_role}/{src_rel} was not present."
- )
- continue
- prole.files.setdefault(
- path,
- {
- "owner": mf.get("owner") or "root",
- "group": mf.get("group") or "root",
- "mode": mf.get("mode") or "0644",
- "source": _source_uri(prole.module_name, module_rel),
- "reason": mf.get("reason") or "managed_file",
- },
- )
-
- for ml in snap.get("managed_links", []) or []:
- if not isinstance(ml, dict):
- continue
- path = str(ml.get("path") or "").strip()
- target = str(ml.get("target") or "").strip()
- if not path or not target:
- continue
- prole.links.setdefault(
- path,
- {
- "target": target,
- "reason": ml.get("reason") or "managed_link",
- },
- )
-
- for path in set(prole.files) | set(prole.links):
- prole.dirs.pop(path, None)
-
-
-def _build_users_role(prole: _PuppetRole, snap: Dict[str, Any]) -> None:
- for u in snap.get("users", []) or []:
- if not isinstance(u, dict):
- continue
- name = str(u.get("name") or "").strip()
- if not name:
- continue
- primary_group = str(u.get("primary_group") or name).strip()
- if primary_group:
- prole.groups.add(primary_group)
- supplementary = sorted(
- {
- str(g).strip()
- for g in (u.get("supplementary_groups") or [])
- if str(g).strip()
- }
- )
- prole.groups.update(supplementary)
- prole.users[name] = {
- "name": name,
- "uid": u.get("uid"),
- "gid": u.get("gid"),
- "primary_group": primary_group or None,
- "home": u.get("home") or f"/home/{name}",
- "shell": u.get("shell"),
- "gecos": u.get("gecos"),
- "supplementary_groups": supplementary,
- }
-
- if snap.get("user_flatpaks") or snap.get("user_flatpak_remotes"):
- prole.notes.append(
- "Per-user Flatpak resources were detected but are not yet rendered as native Puppet resources."
- )
-
-
-def _build_service_role(prole: _PuppetRole, snap: Dict[str, Any]) -> None:
- for pkg in snap.get("packages", []) or []:
- pkg_s = str(pkg or "").strip()
- if pkg_s:
- prole.packages.add(pkg_s)
- unit = str(snap.get("unit") or "").strip()
- if unit:
- unit_file_state = str(snap.get("unit_file_state") or "")
- prole.services[unit] = {
- "name": unit,
- "ensure": "running" if snap.get("active_state") == "active" else "stopped",
- "enable": unit_file_state in ("enabled", "enabled-runtime"),
- }
-
-
-def _build_package_role(prole: _PuppetRole, snap: Dict[str, Any]) -> None:
- pkg = str(snap.get("package") or "").strip()
- if pkg:
- prole.packages.add(pkg)
-
-
-def _add_flatpak_snap_notes(roles: Dict[str, Any], out: Dict[str, _PuppetRole]) -> None:
- flatpak = roles.get("flatpak") or {}
- if isinstance(flatpak, dict) and (
- flatpak.get("system_flatpaks") or flatpak.get("remotes")
- ):
- prole = out.setdefault("flatpak", _PuppetRole("flatpak"))
- prole.notes.append(
- "Flatpak resources were detected but are not yet rendered as native Puppet resources."
- )
- snap = roles.get("snap") or {}
- if isinstance(snap, dict) and snap.get("system_snaps"):
- prole = out.setdefault("snap", _PuppetRole("snap"))
- prole.notes.append(
- "Snap resources were detected but are not yet rendered as native Puppet resources."
- )
-
-
-def _collect_puppet_roles(
- state: Dict[str, Any],
- bundle_dir: str,
- modules_dir: Path,
- *,
- fqdn: Optional[str] = None,
- no_common_roles: bool = False,
-) -> List[_PuppetRole]:
- roles = _roles(state)
- inventory_packages = _inventory_packages(state)
- use_common_modules = not fqdn and not no_common_roles
- out: Dict[str, _PuppetRole] = {}
-
- def ensure_role(role_name: str) -> _PuppetRole:
- role_name = _puppet_name(role_name, fallback="enroll_role")
- return out.setdefault(role_name, _PuppetRole(role_name))
-
- for key in (
- "apt_config",
- "dnf_config",
- "etc_custom",
- "usr_local_custom",
- "extra_paths",
- "sysctl",
- ):
- snap = roles.get(key) or {}
- if not isinstance(snap, dict):
- continue
- role_name = _puppet_name(
- str(snap.get("role_name") or key), fallback="enroll_role"
- )
- prole = ensure_role(role_name)
- module_files_dir = modules_dir / prole.module_name / "files"
- _add_managed_content(
- prole,
- snap,
- bundle_dir=bundle_dir,
- artifact_role=str(snap.get("role_name") or key),
- module_files_dir=module_files_dir,
- )
-
- users_snap = roles.get("users") or {}
- if isinstance(users_snap, dict):
- role_name = _puppet_name(
- str(users_snap.get("role_name") or "users"), fallback="enroll_role"
- )
- prole = ensure_role(role_name)
- _build_users_role(prole, users_snap)
- _add_managed_content(
- prole,
- users_snap,
- bundle_dir=bundle_dir,
- artifact_role=str(users_snap.get("role_name") or "users"),
- module_files_dir=modules_dir / prole.module_name / "files",
- )
-
- for svc in roles.get("services", []) or []:
- if not isinstance(svc, dict):
- continue
- original_role_name = _puppet_name(
- str(svc.get("role_name") or svc.get("unit") or "service"),
- fallback="service",
- )
- if use_common_modules:
- role_name = _puppet_name(
- _section_label_for_packages(
- [
- str(p).strip()
- for p in (svc.get("packages") or [])
- if str(p).strip()
- ],
- inventory_packages,
- ),
- fallback="package_group",
- )
- else:
- role_name = original_role_name
- prole = ensure_role(role_name)
- _build_service_role(prole, svc)
- _add_managed_content(
- prole,
- svc,
- bundle_dir=bundle_dir,
- artifact_role=str(svc.get("role_name") or original_role_name),
- module_files_dir=modules_dir / prole.module_name / "files",
- )
-
- for pkg in roles.get("packages", []) or []:
- if not isinstance(pkg, dict):
- continue
- original_role_name = _puppet_name(
- str(pkg.get("role_name") or pkg.get("package") or "package"),
- fallback="package",
- )
- if use_common_modules:
- role_name = _puppet_name(
- _package_section_label(pkg, inventory_packages),
- fallback="package_group",
- )
- else:
- role_name = original_role_name
- prole = ensure_role(role_name)
- _build_package_role(prole, pkg)
- _add_managed_content(
- prole,
- pkg,
- bundle_dir=bundle_dir,
- artifact_role=str(pkg.get("role_name") or original_role_name),
- module_files_dir=modules_dir / prole.module_name / "files",
- )
-
- fw = roles.get("firewall_runtime") or {}
- if isinstance(fw, dict):
- has_fw = (
- fw.get("ipset_save")
- or fw.get("iptables_v4_save")
- or fw.get("iptables_v6_save")
- )
- packages = [
- str(p).strip() for p in (fw.get("packages") or []) if str(p).strip()
- ]
- if has_fw or packages:
- prole = ensure_role(str(fw.get("role_name") or "firewall_runtime"))
- prole.packages.update(packages)
- if has_fw:
- prole.notes.append(
- "Live firewall runtime snapshots were detected but are not yet rendered as Puppet resources."
- )
-
- _add_flatpak_snap_notes(roles, out)
-
- puppet_roles = sorted(out.values(), key=lambda r: _role_order_key(r.role_name))
- _dedupe_puppet_roles(puppet_roles)
- return [r for r in puppet_roles if r.has_resources()]
-
-
-def _dedupe_puppet_roles(puppet_roles: List[_PuppetRole]) -> None:
- """Remove duplicate catalog resources across generated Puppet classes.
-
- Ansible can repeat the same directory task in multiple roles. Puppet cannot:
- a resource title such as File['/etc/default'] may appear only once in the
- compiled catalog. Keep the first declaration in manifest order and drop
- later duplicates.
- """
-
- concrete_file_paths: Set[str] = set()
- for prole in puppet_roles:
- concrete_file_paths.update(prole.files)
- concrete_file_paths.update(prole.links)
-
- seen_packages: Set[str] = set()
- seen_groups: Set[str] = set()
- seen_users: Set[str] = set()
- seen_dirs: Set[str] = set()
- seen_files: Set[str] = set()
- seen_links: Set[str] = set()
- seen_services: Set[str] = set()
-
- for prole in puppet_roles:
- prole.packages = {p for p in prole.packages if p not in seen_packages}
- seen_packages.update(prole.packages)
-
- prole.groups = {g for g in prole.groups if g not in seen_groups}
- seen_groups.update(prole.groups)
-
- prole.users = {k: v for k, v in prole.users.items() if k not in seen_users}
- seen_users.update(prole.users)
-
- prole.dirs = {
- k: v
- for k, v in prole.dirs.items()
- if k not in seen_dirs and k not in concrete_file_paths
- }
- seen_dirs.update(prole.dirs)
-
- prole.files = {
- k: v
- for k, v in prole.files.items()
- if k not in seen_files and k not in seen_links
- }
- seen_files.update(prole.files)
-
- prole.links = {
- k: v
- for k, v in prole.links.items()
- if k not in seen_links and k not in seen_files
- }
- seen_links.update(prole.links)
-
- prole.services = {
- k: v for k, v in prole.services.items() if k not in seen_services
- }
- seen_services.update(prole.services)
-
-
-def _render_role_class(prole: _PuppetRole) -> str:
- has_sysctl_conf = "/etc/sysctl.d/99-enroll.conf" in prole.files
- if has_sysctl_conf:
- lines: List[str] = [
- "# Generated by Enroll from harvest state.",
- f"class {prole.module_name} (",
- " Boolean $sysctl_apply = true,",
- " Boolean $sysctl_ignore_apply_errors = true,",
- ") {",
- "",
- ]
- else:
- lines = [
- "# Generated by Enroll from harvest state.",
- f"class {prole.module_name} {{",
- "",
- ]
-
- for package in sorted(prole.packages):
- _resource(lines, "package", package, [("ensure", _pp_quote("installed"))])
-
- for group in sorted(prole.groups):
- _resource(lines, "group", group, [("ensure", _pp_quote("present"))])
-
- for user in [prole.users[k] for k in sorted(prole.users)]:
- attrs: List[Tuple[str, str]] = [
- ("ensure", _pp_quote("present")),
- ("managehome", _pp_bool(True)),
- ]
- if user.get("uid") is not None:
- attrs.append(("uid", _pp_quote(user["uid"])))
- if user.get("primary_group"):
- attrs.append(("gid", _pp_quote(user["primary_group"])))
- if user.get("home"):
- attrs.append(("home", _pp_quote(user["home"])))
- if user.get("shell"):
- attrs.append(("shell", _pp_quote(user["shell"])))
- if user.get("gecos"):
- attrs.append(("comment", _pp_quote(user["gecos"])))
- if user.get("supplementary_groups"):
- attrs.append(("groups", _pp_array(user["supplementary_groups"])))
- attrs.append(("membership", _pp_quote("minimum")))
- _resource(lines, "user", user["name"], attrs)
-
- for path, d in sorted(prole.dirs.items()):
- _resource(
- lines,
- "file",
- path,
- [
- ("ensure", _pp_quote("directory")),
- ("owner", _pp_quote(d.get("owner") or "root")),
- ("group", _pp_quote(d.get("group") or "root")),
- ("mode", _pp_quote(d.get("mode") or "0755")),
- ],
- )
-
- for path, f in sorted(prole.files.items()):
- _resource(
- lines,
- "file",
- path,
- [
- ("ensure", _pp_quote("file")),
- ("source", _pp_quote(f.get("source") or "")),
- ("owner", _pp_quote(f.get("owner") or "root")),
- ("group", _pp_quote(f.get("group") or "root")),
- ("mode", _pp_quote(f.get("mode") or "0644")),
- ],
- )
-
- for path, lnk in sorted(prole.links.items()):
- _resource(
- lines,
- "file",
- path,
- [
- ("ensure", _pp_quote("link")),
- ("target", _pp_quote(lnk.get("target") or "")),
- ],
- )
-
- for svc in [prole.services[k] for k in sorted(prole.services)]:
- _resource(
- lines,
- "service",
- svc["name"],
- [
- ("ensure", _pp_quote(svc["ensure"])),
- ("enable", _pp_bool(bool(svc["enable"]))),
- ],
- )
-
- if has_sysctl_conf:
- lines.append(" if $sysctl_apply {")
- lines.append(" exec { 'enroll-apply-sysctl':")
- lines.append(" command => $sysctl_ignore_apply_errors ? {")
- lines.append(
- " true => \"/bin/sh -c 'sysctl -e -p /etc/sysctl.d/99-enroll.conf || true'\","
- )
- lines.append(" default => 'sysctl -e -p /etc/sysctl.d/99-enroll.conf',")
- lines.append(" },")
- lines.append(" path => ['/sbin', '/usr/sbin', '/bin', '/usr/bin'],")
- lines.append(" refreshonly => true,")
- lines.append(" subscribe => File['/etc/sysctl.d/99-enroll.conf'],")
- lines.append(" }")
- lines.append(" }")
- lines.append("")
-
- if prole.notes:
- lines.append(" # Notes and limitations")
- for note in prole.notes:
- lines.append(f" # - {note}")
- lines.append("")
-
- lines.append("}")
- lines.append("")
- return "\n".join(lines)
-
-
-def _render_site_pp(puppet_roles: List[_PuppetRole], fqdn: Optional[str]) -> str:
- node_name = _pp_quote(fqdn) if fqdn else "default"
- if not puppet_roles:
- return f"node {node_name} {{\n # No Puppet classes were generated from this harvest.\n}}\n"
- includes = "\n".join(f" include {r.module_name}" for r in puppet_roles)
- return f"node {node_name} {{\n{includes}\n}}\n"
-
-
-def _write_metadata(module_dir: Path, module_name: str) -> None:
- (module_dir / "metadata.json").write_text(
- json.dumps(
- {
- "name": f"enroll-{module_name}",
- "version": "0.1.0",
- "author": "Enroll",
- "summary": f"Generated Enroll Puppet module for {module_name}",
- "license": "UNLICENSED",
- "source": "",
- "dependencies": [],
- },
- indent=2,
- sort_keys=True,
- )
- + "\n",
- encoding="utf-8",
- )
-
-
-def _render_readme(state: Dict[str, Any], puppet_roles: List[_PuppetRole]) -> str:
- host = state.get("host", {}) if isinstance(state.get("host"), dict) else {}
- hostname = host.get("hostname") or "unknown"
- role_lines = (
- "\n".join(
- f"- `{r.module_name}` from Enroll role `{r.role_name}`"
- for r in puppet_roles
- )
- or "- None."
- )
- notes: List[str] = []
- for r in puppet_roles:
- for note in r.notes:
- notes.append(f"`{r.module_name}`: {note}")
- notes_text = "\n".join(f"- {n}" for n in notes) or "- None."
- return f"""# Enroll Puppet manifest
-
-Generated by Enroll from harvest data for `{hostname}`.
-
-This Puppet target reuses the existing harvest state without changing harvesting behaviour.
-
-## Layout
-
-- `manifests/site.pp` declares a `node` block and includes the generated classes in manifest order.
-- `modules//manifests/init.pp` contains resources for each generated Enroll role/snapshot or common package group.
-- `modules//files/` contains harvested file artifacts for that role or group.
-- Generated module names avoid Puppet reserved words such as `default`.
-
-## Generated modules
-
-{role_lines}
-
-## Apply / check
-
-Run from this generated output directory so Puppet can find `./modules`, or pass an absolute module path:
-
-```bash
-sudo puppet apply --modulepath ./modules manifests/site.pp --noop
-```
-
-```bash
-sudo puppet apply --modulepath /path/to/generated/modules /path/to/generated/manifests/site.pp --noop
-```
-
-## Generated resources
-
-- Native packages observed in package and service snapshots.
-- Local users and groups from the users snapshot.
-- Managed directories, files, and symlinks from harvested roles.
-- Basic service enablement/running-state resources.
-- `/etc/sysctl.d/99-enroll.conf` plus a refresh-only sysctl apply exec when present.
-
-## Current limitations
-
-- Flatpak, Snap, and live firewall runtime snapshots are listed as notes when present rather than rendered as Puppet resources.
-- JinjaTurtle templating is Ansible-oriented and is not applied to Puppet output.
-- Review generated resources before applying them broadly across unlike hosts.
-
-## Notes
-
-{notes_text}
-"""
-
-
-def manifest_puppet_from_bundle_dir(
- bundle_dir: str,
- out_dir: str,
- *,
- fqdn: Optional[str] = None,
- no_common_roles: bool = False,
-) -> None:
- """Render Puppet modules/site.pp from a harvest bundle."""
-
- state = _load_state(bundle_dir)
- out = Path(out_dir)
- if out.exists():
- shutil.rmtree(out)
- manifests_dir = out / "manifests"
- modules_dir = out / "modules"
- manifests_dir.mkdir(parents=True, exist_ok=True)
- modules_dir.mkdir(parents=True, exist_ok=True)
-
- puppet_roles = _collect_puppet_roles(
- state,
- bundle_dir,
- modules_dir,
- fqdn=fqdn,
- no_common_roles=no_common_roles,
- )
- for prole in puppet_roles:
- module_dir = modules_dir / prole.module_name
- module_manifests = module_dir / "manifests"
- module_files = module_dir / "files"
- module_manifests.mkdir(parents=True, exist_ok=True)
- module_files.mkdir(parents=True, exist_ok=True)
- (module_manifests / "init.pp").write_text(
- _render_role_class(prole), encoding="utf-8"
- )
- _write_metadata(module_dir, prole.module_name)
-
- (manifests_dir / "site.pp").write_text(
- _render_site_pp(puppet_roles, fqdn), encoding="utf-8"
- )
- (out / "README.md").write_text(
- _render_readme(state, puppet_roles), encoding="utf-8"
- )
diff --git a/enroll/role_names.py b/enroll/role_names.py
index b3ab3d3..b3fa584 100644
--- a/enroll/role_names.py
+++ b/enroll/role_names.py
@@ -7,7 +7,6 @@ RESERVED_SINGLETON_ROLE_NAMES = {
"apt_config",
"dnf_config",
"firewall_runtime",
- "sysctl",
"etc_custom",
"usr_local_custom",
"extra_paths",
diff --git a/enroll/schema/state.schema.json b/enroll/schema/state.schema.json
index a67cf64..11f8672 100644
--- a/enroll/schema/state.schema.json
+++ b/enroll/schema/state.schema.json
@@ -692,39 +692,6 @@
],
"type": "object"
},
- "SysctlSnapshot": {
- "additionalProperties": false,
- "properties": {
- "role_name": {
- "const": "sysctl"
- },
- "managed_files": {
- "items": {
- "$ref": "#/$defs/ManagedFile"
- },
- "type": "array"
- },
- "parameters": {
- "additionalProperties": {
- "type": "string"
- },
- "type": "object"
- },
- "notes": {
- "items": {
- "type": "string"
- },
- "type": "array"
- }
- },
- "required": [
- "role_name",
- "managed_files",
- "parameters",
- "notes"
- ],
- "type": "object"
- },
"FlatpakInstall": {
"additionalProperties": false,
"properties": {
@@ -917,6 +884,7 @@
"role_name"
],
"type": "object"
+
},
"SnapSnapshot": {
"additionalProperties": false,
@@ -1052,9 +1020,6 @@
"firewall_runtime": {
"$ref": "#/$defs/FirewallRuntimeSnapshot"
},
- "sysctl": {
- "$ref": "#/$defs/SysctlSnapshot"
- },
"flatpak": {
"$ref": "#/$defs/FlatpakSnapshot"
},
diff --git a/enroll/validate.py b/enroll/validate.py
index 48d7250..f3291e9 100644
--- a/enroll/validate.py
+++ b/enroll/validate.py
@@ -96,7 +96,6 @@ def _iter_managed_files(state: Dict[str, Any]) -> List[Tuple[str, Dict[str, Any]
"users",
"apt_config",
"dnf_config",
- "sysctl",
"etc_custom",
"usr_local_custom",
"extra_paths",
diff --git a/release.sh b/release.sh
index d41c468..d8454a2 100755
--- a/release.sh
+++ b/release.sh
@@ -10,7 +10,8 @@ poetry build
poetry publish
# Make AppImage
-poetry run pyproject-appimage --output dist/Enroll.AppImage
+poetry run pyproject-appimage
+mv Enroll.AppImage dist/
# Sign packages
for file in `ls -1 dist/`; do qubes-gpg-client --batch --armor --detach-sign dist/$file > dist/$file.asc; done
diff --git a/tests/test_cli.py b/tests/test_cli.py
index ed3ffe6..6a89c58 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -48,7 +48,6 @@ def test_cli_manifest_subcommand_calls_manifest(monkeypatch, tmp_path):
called["fqdn"] = kwargs.get("fqdn")
called["jinjaturtle"] = kwargs.get("jinjaturtle")
called["no_common_roles"] = kwargs.get("no_common_roles")
- called["target"] = kwargs.get("target")
monkeypatch.setattr(cli, "manifest", fake_manifest)
monkeypatch.setattr(
@@ -70,37 +69,6 @@ def test_cli_manifest_subcommand_calls_manifest(monkeypatch, tmp_path):
assert called["fqdn"] is None
assert called["jinjaturtle"] == "auto"
assert called["no_common_roles"] is False
- assert called["target"] == "ansible"
-
-
-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):
diff --git a/tests/test_harvest_helpers.py b/tests/test_harvest_helpers.py
index 5131809..53e7d58 100644
--- a/tests/test_harvest_helpers.py
+++ b/tests/test_harvest_helpers.py
@@ -303,94 +303,3 @@ def test_service_role_names_do_not_collide_with_singleton_roles():
assert _role_name_from_unit("flatpak.service") == "service_flatpak"
assert _role_name_from_unit("users.service") == "service_users"
assert _role_name_from_unit("nginx.service") == "nginx"
-
-
-def test_parse_sysctl_a_output_keeps_persistable_values(monkeypatch):
- monkeypatch.setattr(
- h,
- "_sysctl_key_is_persistable",
- lambda key: (key != "kernel.hostname", "test"),
- )
-
- params, skipped = h._parse_sysctl_a_output(
- "net.ipv4.ip_forward = 1\n"
- "kernel.hostname = example\n"
- "malformed line\n"
- "dev.cdrom.info = \n"
- "net.ipv4.ip_forward = 0\n"
- )
-
- assert params == {"net.ipv4.ip_forward": "1"}
- assert skipped["non_persistable"] == 1
- assert skipped["malformed"] == 1
- assert skipped["empty_value"] == 1
- assert skipped["duplicate"] == 1
-
-
-def test_sysctl_filter_skips_non_replayable_runtime_keys(monkeypatch):
- for key in (
- "fs.binfmt_misc.status",
- "fs.binfmt_misc.register",
- "kernel.kexec_load_disabled",
- "kernel.kexec_load_limit_panic",
- "kernel.kexec_load_limit_reboot",
- "kernel.max_rcu_stall_to_panic",
- "kernel.modules_disabled",
- "kernel.sched_domain.cpu0.domain0.flags",
- ):
- ok, reason = h._sysctl_key_is_persistable(key)
- assert ok is False
- assert reason == "volatile/action key"
-
- monkeypatch.setattr(h, "_sysctl_key_is_persistable", lambda key: (True, ""))
- for key in (
- "vm.dirty_background_bytes",
- "vm.dirty_background_ratio",
- "vm.dirty_bytes",
- "vm.dirty_ratio",
- ):
- ok, reason = h._sysctl_entry_is_persistable(key, "0")
- assert ok is False
- assert reason == "inactive mutually-exclusive zero value"
- assert h._sysctl_entry_is_persistable(key, "10")[0] is True
-
-
-def test_parse_sysctl_a_output_skips_non_replayable_values(monkeypatch):
- monkeypatch.setattr(
- h,
- "_sysctl_key_is_persistable",
- lambda key: (key != "kernel.modules_disabled", "volatile/action key"),
- )
-
- params, skipped = h._parse_sysctl_a_output(
- "kernel.modules_disabled = 0\n"
- "vm.dirty_background_bytes = 0\n"
- "vm.dirty_ratio = 20\n"
- "net.ipv4.ip_forward = 1\n"
- )
-
- assert params == {"net.ipv4.ip_forward": "1", "vm.dirty_ratio": "20"}
- assert skipped["non_persistable"] == 2
-
-
-def test_collect_sysctl_snapshot_writes_generated_artifact(monkeypatch, tmp_path: Path):
- monkeypatch.setattr(
- h,
- "_run_capture_command",
- lambda command_key, *, timeout=10: (
- "net.ipv4.ip_forward = 1\nvm.swappiness = 10\n",
- None,
- ),
- )
- monkeypatch.setattr(h, "_sysctl_key_is_persistable", lambda key: (True, ""))
-
- snap = h._collect_sysctl_snapshot(str(tmp_path))
-
- assert snap.role_name == "sysctl"
- assert snap.parameters == {"net.ipv4.ip_forward": "1", "vm.swappiness": "10"}
- assert len(snap.managed_files) == 1
- assert snap.managed_files[0].path == "/etc/sysctl.d/99-enroll.conf"
- conf = tmp_path / "artifacts" / "sysctl" / "sysctl" / "99-enroll.conf"
- text = conf.read_text(encoding="utf-8")
- assert "net.ipv4.ip_forward = 1" in text
- assert "vm.swappiness = 10" in text
diff --git a/tests/test_manifest.py b/tests/test_manifest.py
index 8c74064..3accec9 100644
--- a/tests/test_manifest.py
+++ b/tests/test_manifest.py
@@ -260,7 +260,6 @@ def test_manifest_writes_roles_and_playbook_with_clean_when(tmp_path: Path):
# Service role: systemd management should be gated on foo_manage_unit and a probe.
tasks = (out / "roles" / "foo" / "tasks" / "main.yml").read_text(encoding="utf-8")
assert "- name: Probe whether systemd unit exists and is manageable" in tasks
- assert 'no_log: "{{ enroll_hide_systemd_status | default(true) | bool }}"' in tasks
assert "when: foo_manage_unit | default(false)" in tasks
assert (
"when:\n - foo_manage_unit | default(false)\n - _unit_probe is succeeded\n"
@@ -619,7 +618,6 @@ def test_manifest_groups_systemd_units_into_common_role(tmp_path: Path):
assert "dest: /etc/NetworkManager/NetworkManager.conf" in defaults
tasks = (out / "roles" / "net" / "tasks" / "main.yml").read_text(encoding="utf-8")
assert "Ensure grouped unit enablement matches harvest" in tasks
- assert 'no_log: "{{ enroll_hide_systemd_status | default(true) | bool }}"' in tasks
def test_manifest_fqdn_implies_no_common_roles(tmp_path: Path):
@@ -1813,111 +1811,3 @@ def test_manifest_avoids_package_role_collision_with_flatpak_singleton(tmp_path)
assert (out / "roles" / "package_flatpak" / "tasks" / "main.yml").exists()
assert "role: flatpak" in playbook
assert "role: package_flatpak" in playbook
-
-
-def test_manifest_writes_sysctl_role(tmp_path: Path):
- bundle = tmp_path / "bundle"
- out = tmp_path / "ansible"
- (bundle / "artifacts" / "sysctl" / "sysctl").mkdir(parents=True, exist_ok=True)
- (bundle / "artifacts" / "sysctl" / "sysctl" / "99-enroll.conf").write_text(
- "net.ipv4.ip_forward = 1\n",
- encoding="utf-8",
- )
-
- state = {
- "schema_version": 3,
- "host": {"hostname": "test", "os": "debian", "pkg_backend": "dpkg"},
- "inventory": {"packages": {}},
- "roles": {
- "users": {
- "role_name": "users",
- "users": [],
- "managed_files": [],
- "excluded": [],
- "notes": [],
- },
- "services": [],
- "packages": [],
- "apt_config": {
- "role_name": "apt_config",
- "managed_files": [],
- "excluded": [],
- "notes": [],
- },
- "dnf_config": {
- "role_name": "dnf_config",
- "managed_files": [],
- "excluded": [],
- "notes": [],
- },
- "sysctl": {
- "role_name": "sysctl",
- "managed_files": [
- {
- "path": "/etc/sysctl.d/99-enroll.conf",
- "src_rel": "sysctl/99-enroll.conf",
- "owner": "root",
- "group": "root",
- "mode": "0644",
- "reason": "system_sysctl",
- }
- ],
- "parameters": {"net.ipv4.ip_forward": "1"},
- "notes": ["Captured 1 live writable sysctl parameter(s)."],
- },
- "firewall_runtime": {
- "role_name": "firewall_runtime",
- "packages": [],
- "ipset_save": None,
- "ipset_sets": [],
- "iptables_v4_save": None,
- "iptables_v6_save": None,
- "notes": [],
- },
- "etc_custom": {
- "role_name": "etc_custom",
- "managed_files": [],
- "excluded": [],
- "notes": [],
- },
- "usr_local_custom": {
- "role_name": "usr_local_custom",
- "managed_files": [],
- "excluded": [],
- "notes": [],
- },
- "extra_paths": {
- "role_name": "extra_paths",
- "include_patterns": [],
- "exclude_patterns": [],
- "managed_files": [],
- "excluded": [],
- "notes": [],
- },
- },
- }
- (bundle / "state.json").write_text(json.dumps(state, indent=2), encoding="utf-8")
-
- manifest.manifest(str(bundle), str(out))
-
- tasks = (out / "roles" / "sysctl" / "tasks" / "main.yml").read_text(
- encoding="utf-8"
- )
- assert "dest: /etc/sysctl.d/99-enroll.conf" in tasks
- assert "notify: Apply captured sysctl configuration" in tasks
-
- handlers = (out / "roles" / "sysctl" / "handlers" / "main.yml").read_text(
- encoding="utf-8"
- )
- assert "- -p" in handlers
- assert "- /etc/sysctl.d/99-enroll.conf" in handlers
-
- defaults = (out / "roles" / "sysctl" / "defaults" / "main.yml").read_text(
- encoding="utf-8"
- )
- assert "sysctl_conf_src_rel: sysctl/99-enroll.conf" in defaults
- assert "sysctl_ignore_apply_errors: true" in defaults
-
- pb = (out / "playbook.yml").read_text(encoding="utf-8")
- assert "role: sysctl" in pb
- assert (out / "roles" / "sysctl" / "files" / "sysctl" / "99-enroll.conf").exists()
diff --git a/tests/test_manifest_puppet.py b/tests/test_manifest_puppet.py
deleted file mode 100644
index 699b2b3..0000000
--- a/tests/test_manifest_puppet.py
+++ /dev/null
@@ -1,431 +0,0 @@
-from __future__ import annotations
-
-import json
-from pathlib import Path
-
-from enroll import manifest
-
-
-def _write_state(bundle: Path, state: dict) -> None:
- bundle.mkdir(parents=True, exist_ok=True)
- (bundle / "state.json").write_text(json.dumps(state, indent=2), encoding="utf-8")
-
-
-def test_manifest_puppet_writes_control_repo_style_output(tmp_path: Path):
- bundle = tmp_path / "bundle"
- out = tmp_path / "puppet"
- artifact = bundle / "artifacts" / "foo" / "etc" / "foo.conf"
- artifact.parent.mkdir(parents=True, exist_ok=True)
- artifact.write_text("setting = true\n", encoding="utf-8")
- sysctl_artifact = bundle / "artifacts" / "sysctl" / "sysctl" / "99-enroll.conf"
- sysctl_artifact.parent.mkdir(parents=True, exist_ok=True)
- sysctl_artifact.write_text("net.ipv4.ip_forward = 1\n", encoding="utf-8")
-
- state = {
- "schema_version": 3,
- "host": {"hostname": "test", "os": "debian", "pkg_backend": "dpkg"},
- "inventory": {"packages": {}},
- "roles": {
- "users": {
- "role_name": "users",
- "users": [
- {
- "name": "alice",
- "uid": 1000,
- "gid": 1000,
- "gecos": "Alice Example",
- "home": "/home/alice",
- "shell": "/bin/bash",
- "primary_group": "alice",
- "supplementary_groups": ["docker"],
- }
- ],
- "managed_dirs": [],
- "managed_files": [],
- "excluded": [],
- "notes": [],
- },
- "services": [
- {
- "unit": "foo.service",
- "role_name": "foo",
- "packages": ["foo"],
- "active_state": "active",
- "sub_state": "running",
- "unit_file_state": "enabled",
- "condition_result": "yes",
- "managed_dirs": [
- {
- "path": "/etc/foo",
- "owner": "root",
- "group": "root",
- "mode": "0755",
- "reason": "parent_dir",
- }
- ],
- "managed_files": [
- {
- "path": "/etc/foo/foo.conf",
- "src_rel": "etc/foo.conf",
- "owner": "root",
- "group": "root",
- "mode": "0644",
- "reason": "modified_conffile",
- }
- ],
- "managed_links": [],
- "excluded": [],
- "notes": [],
- }
- ],
- "packages": [
- {
- "package": "curl",
- "role_name": "curl",
- "section": "net",
- "managed_dirs": [],
- "managed_files": [],
- "managed_links": [],
- "excluded": [],
- "notes": [],
- }
- ],
- "apt_config": {
- "role_name": "apt_config",
- "managed_dirs": [],
- "managed_files": [],
- "excluded": [],
- "notes": [],
- },
- "dnf_config": {
- "role_name": "dnf_config",
- "managed_dirs": [],
- "managed_files": [],
- "excluded": [],
- "notes": [],
- },
- "sysctl": {
- "role_name": "sysctl",
- "managed_dirs": [],
- "managed_files": [
- {
- "path": "/etc/sysctl.d/99-enroll.conf",
- "src_rel": "sysctl/99-enroll.conf",
- "owner": "root",
- "group": "root",
- "mode": "0644",
- "reason": "system_sysctl",
- }
- ],
- "parameters": {"net.ipv4.ip_forward": "1"},
- "notes": [],
- },
- "firewall_runtime": {
- "role_name": "firewall_runtime",
- "packages": [],
- "ipset_save": None,
- "ipset_sets": [],
- "iptables_v4_save": None,
- "iptables_v6_save": None,
- "notes": [],
- },
- "etc_custom": {
- "role_name": "etc_custom",
- "managed_dirs": [],
- "managed_files": [],
- "excluded": [],
- "notes": [],
- },
- "usr_local_custom": {
- "role_name": "usr_local_custom",
- "managed_dirs": [],
- "managed_files": [],
- "excluded": [],
- "notes": [],
- },
- "extra_paths": {
- "role_name": "extra_paths",
- "include_patterns": [],
- "exclude_patterns": [],
- "managed_dirs": [],
- "managed_files": [],
- "managed_links": [],
- "excluded": [],
- "notes": [],
- },
- },
- }
- _write_state(bundle, state)
-
- manifest.manifest(str(bundle), str(out), target="puppet", fqdn="test.example")
-
- site_pp = (out / "manifests" / "site.pp").read_text(encoding="utf-8")
- assert site_pp == (
- "node 'test.example' {\n"
- " include curl\n"
- " include foo\n"
- " include users\n"
- " include sysctl\n"
- "}\n"
- )
-
- curl_pp = (out / "modules" / "curl" / "manifests" / "init.pp").read_text(
- encoding="utf-8"
- )
- assert "class curl" in curl_pp
- assert "package { 'curl':" in curl_pp
-
- foo_pp = (out / "modules" / "foo" / "manifests" / "init.pp").read_text(
- encoding="utf-8"
- )
- assert "class foo" in foo_pp
- assert "package { 'foo':" in foo_pp
- assert "file { '/etc/foo/foo.conf':" in foo_pp
- assert "source => 'puppet:///modules/foo/etc/foo.conf'" in foo_pp
- assert "service { 'foo.service':" in foo_pp
-
- users_pp = (out / "modules" / "users" / "manifests" / "init.pp").read_text(
- encoding="utf-8"
- )
- assert "class users" in users_pp
- assert "group { 'docker':" in users_pp
- assert "user { 'alice':" in users_pp
-
- sysctl_pp = (out / "modules" / "sysctl" / "manifests" / "init.pp").read_text(
- encoding="utf-8"
- )
- assert "class sysctl" in sysctl_pp
- assert "Boolean $sysctl_apply = true" in sysctl_pp
- assert "Boolean $sysctl_ignore_apply_errors = true" in sysctl_pp
- assert "exec { 'enroll-apply-sysctl':" in sysctl_pp
- assert "command => $sysctl_ignore_apply_errors ? {" in sysctl_pp
- assert "sysctl -e -p /etc/sysctl.d/99-enroll.conf || true" in sysctl_pp
-
- assert (out / "modules" / "foo" / "files" / "etc" / "foo.conf").exists()
- assert (out / "modules" / "sysctl" / "files" / "sysctl" / "99-enroll.conf").exists()
-
-
-def test_manifest_puppet_uses_default_node_and_common_package_modules(tmp_path: Path):
- bundle = tmp_path / "bundle"
- out = tmp_path / "puppet"
- artifact = bundle / "artifacts" / "foo" / "etc" / "foo.conf"
- artifact.parent.mkdir(parents=True, exist_ok=True)
- artifact.write_text("setting = true\n", encoding="utf-8")
-
- state = {
- "schema_version": 3,
- "host": {"hostname": "test", "os": "debian", "pkg_backend": "dpkg"},
- "inventory": {
- "packages": {
- "curl": {"section": "net"},
- "foo": {"installations": [{"section": "net"}]},
- }
- },
- "roles": {
- "services": [
- {
- "unit": "foo.service",
- "role_name": "foo",
- "packages": ["foo"],
- "active_state": "active",
- "unit_file_state": "enabled",
- "managed_dirs": [],
- "managed_files": [
- {
- "path": "/etc/foo/foo.conf",
- "src_rel": "etc/foo.conf",
- "owner": "root",
- "group": "root",
- "mode": "0644",
- }
- ],
- "managed_links": [],
- }
- ],
- "packages": [
- {
- "package": "curl",
- "role_name": "curl",
- "section": "net",
- "managed_dirs": [],
- "managed_files": [],
- "managed_links": [],
- }
- ],
- "users": {
- "role_name": "users",
- "users": [],
- "managed_dirs": [],
- "managed_files": [],
- },
- "apt_config": {
- "role_name": "apt_config",
- "managed_dirs": [],
- "managed_files": [],
- },
- "dnf_config": {
- "role_name": "dnf_config",
- "managed_dirs": [],
- "managed_files": [],
- },
- "sysctl": {"role_name": "sysctl", "managed_dirs": [], "managed_files": []},
- "firewall_runtime": {"role_name": "firewall_runtime", "packages": []},
- "etc_custom": {
- "role_name": "etc_custom",
- "managed_dirs": [],
- "managed_files": [],
- },
- "usr_local_custom": {
- "role_name": "usr_local_custom",
- "managed_dirs": [],
- "managed_files": [],
- },
- "extra_paths": {
- "role_name": "extra_paths",
- "managed_dirs": [],
- "managed_files": [],
- "managed_links": [],
- },
- },
- }
- _write_state(bundle, state)
-
- manifest.manifest(str(bundle), str(out), target="puppet")
-
- site_pp = (out / "manifests" / "site.pp").read_text(encoding="utf-8")
- assert site_pp == "node default {\n include net\n}\n"
-
- net_pp = (out / "modules" / "net" / "manifests" / "init.pp").read_text(
- encoding="utf-8"
- )
- assert "class net" in net_pp
- assert "package { 'curl':" in net_pp
- assert "package { 'foo':" in net_pp
- assert "file { '/etc/foo/foo.conf':" in net_pp
- assert "source => 'puppet:///modules/net/etc/foo.conf'" in net_pp
- assert "service { 'foo.service':" in net_pp
- assert (out / "modules" / "net" / "files" / "etc" / "foo.conf").exists()
- assert not (out / "modules" / "curl").exists()
- assert not (out / "modules" / "foo").exists()
-
-
-def test_manifest_puppet_avoids_reserved_module_names_and_duplicate_resources(
- tmp_path: Path,
-):
- bundle = tmp_path / "bundle"
- out = tmp_path / "puppet"
- state = {
- "schema_version": 3,
- "host": {"hostname": "test", "os": "debian", "pkg_backend": "dpkg"},
- "inventory": {
- "packages": {
- "alpha": {"section": "admin"},
- "beta": {"section": "misc"},
- "gamma": {"section": "default"},
- }
- },
- "roles": {
- "packages": [
- {
- "package": "alpha",
- "role_name": "alpha",
- "section": "admin",
- "managed_dirs": [
- {
- "path": "/etc/default",
- "owner": "root",
- "group": "root",
- "mode": "0755",
- }
- ],
- "managed_files": [],
- "managed_links": [],
- },
- {
- "package": "beta",
- "role_name": "beta",
- "section": "misc",
- "managed_dirs": [
- {
- "path": "/etc/default",
- "owner": "root",
- "group": "root",
- "mode": "0755",
- }
- ],
- "managed_files": [],
- "managed_links": [],
- },
- {
- "package": "gamma",
- "role_name": "gamma",
- "section": "default",
- "managed_dirs": [],
- "managed_files": [],
- "managed_links": [],
- },
- ],
- "users": {
- "role_name": "users",
- "users": [],
- "managed_dirs": [],
- "managed_files": [],
- },
- "apt_config": {
- "role_name": "apt_config",
- "managed_dirs": [],
- "managed_files": [],
- },
- "dnf_config": {
- "role_name": "dnf_config",
- "managed_dirs": [],
- "managed_files": [],
- },
- "sysctl": {"role_name": "sysctl", "managed_dirs": [], "managed_files": []},
- "firewall_runtime": {"role_name": "firewall_runtime", "packages": []},
- "etc_custom": {
- "role_name": "etc_custom",
- "managed_dirs": [],
- "managed_files": [],
- },
- "usr_local_custom": {
- "role_name": "usr_local_custom",
- "managed_dirs": [],
- "managed_files": [],
- },
- "extra_paths": {
- "role_name": "extra_paths",
- "managed_dirs": [],
- "managed_files": [],
- "managed_links": [],
- },
- },
- }
- _write_state(bundle, state)
-
- manifest.manifest(str(bundle), str(out), target="puppet")
-
- site_pp = (out / "manifests" / "site.pp").read_text(encoding="utf-8")
- assert "include default\n" not in site_pp
- assert "include package_group_default" in site_pp
- assert (
- out / "modules" / "package_group_default" / "manifests" / "init.pp"
- ).exists()
-
- init_pps = "\n".join(
- p.read_text(encoding="utf-8")
- for p in sorted((out / "modules").glob("*/manifests/init.pp"))
- )
- assert init_pps.count("file { '/etc/default':") == 1
-
-
-def test_manifest_rejects_unknown_target(tmp_path: Path):
- bundle = tmp_path / "bundle"
- _write_state(bundle, {"roles": {}})
-
- try:
- manifest.manifest(str(bundle), str(tmp_path / "out"), target="chef")
- except ValueError as e:
- assert "unsupported manifest target" in str(e)
- else:
- raise AssertionError("expected ValueError")