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 logo -**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")