From 0d111caf622986c0b15140ff7153b73db4fa52be Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Thu, 18 Jun 2026 20:12:56 +1000 Subject: [PATCH 1/2] Revert "Remove salt" This reverts commit b149b2e5d78e7370b4aed31e00a60090ab9d32c7. --- .forgejo/workflows/ci.yml | 6 +- README.md | 29 +- .../ansible_renderer/roles/managed_files.py | 6 +- enroll/ansible_renderer/tasks.py | 4 +- enroll/cli.py | 4 +- enroll/cm.py | 6 +- enroll/manifest.py | 17 +- enroll/salt.py | 1132 +++++++++++++++++ tests.sh | 29 + tests/test_manifest_salt.py | 457 +++++++ 10 files changed, 1675 insertions(+), 15 deletions(-) create mode 100644 enroll/salt.py create mode 100644 tests/test_manifest_salt.py diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 796d087..ae047f3 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -13,10 +13,14 @@ jobs: - name: Install system dependencies run: | + mkdir -m 755 -p /etc/apt/keyrings + curl -fsSL https://packages.broadcom.com/artifactory/api/security/keypair/SaltProjectKey/public | gpg --dearmor | tee /etc/apt/keyrings/salt-archive-keyring.pgp > /dev/null + curl -fsSL https://github.com/saltstack/salt-install-guide/releases/latest/download/salt.sources | tee /etc/apt/sources.list.d/salt.sources apt-get update DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ ansible ansible-lint python3-venv pipx systemctl python3-apt jq python3-jsonschema \ - puppet hiera + puppet hiera \ + salt-master salt-minion salt-ssh salt-syndic salt-cloud salt-api - name: Install Poetry run: | diff --git a/README.md b/README.md index dfa693b..ee20805 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,7 @@ 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 configuration-management output from an existing harvest bundle. Ansible remains the default; use `--target puppet` for Puppet output or `--target salt` for Salt output. **Inputs** - `--harvest /path/to/harvest` (directory) @@ -129,11 +129,12 @@ Generate configuration-management output from an existing harvest bundle. Ansibl **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 Salt mode: a Salt state tree under `states/`, plus `pillar/` data in `--fqdn` mode. By default, package and service resources are grouped by Debian Section/RPM Group where possible; `--fqdn` or `--no-common-roles` preserves one generated SLS role per Enroll role/snapshot. - In `--sops` mode: a single encrypted file `manifest.tar.gz.sops` containing the generated output. **Common flags** -- `--target ansible|puppet`: choose the manifest target (`ansible` is the default). -- `--fqdn `: enables **multi-site** output style for Ansible or emits Puppet Hiera/node output. Without `--fqdn`, Puppet emits `node default { ... }`. +- `--target ansible|puppet|salt`: choose the manifest target (`ansible` is the default). +- `--fqdn `: enables **multi-site** output style for Ansible, emits Puppet Hiera/node output, or emits Salt top/pillar output targeted at that minion ID. Without `--fqdn`, Puppet emits `node default { ... }` and Salt targets `*` in `states/top.sls`. - `--no-common-roles`: disables the default grouping of package and systemd-unit roles into Debian Section/RPM Group roles, preserving one generated role per package/unit. `--fqdn` implies this behaviour. **Role tags** @@ -467,6 +468,28 @@ sudo puppet apply --modulepath /tmp/enroll-puppet/modules /tmp/enroll-puppet/man Docker images with registry digests are rendered as `docker::image` resources and require the Puppet environment to provide `puppetlabs-docker`; the generated module metadata records that dependency. Podman images with registry digests are rendered as guarded `podman pull` / `podman tag` exec resources. Images without `RepoDigest` are recorded in harvest state and notes, but are not converted into exact pull resources. Flatpak, Snap, and live firewall runtime snapshots are listed as notes in the generated Puppet README rather than converted into Puppet resources. +### Salt target +```bash +enroll manifest --harvest /tmp/enroll-harvest --out /tmp/enroll-salt --target salt +``` + +The Salt target renders native packages, users/groups, managed directories/files/symlinks, basic service state, and the generated sysctl file/apply command when present. Without `--fqdn`, it writes a self-contained state tree under `states/` and targets all minions in `states/top.sls`: + +```bash +cd /tmp/enroll-salt +sudo salt-call --local --file-root ./states state.apply test=True +``` + +With `--fqdn`, it uses Salt's state/pillar split: `states/top.sls` targets the minion ID to reusable generated role SLS files, while `pillar/top.sls` targets the same minion ID to node-specific data under `pillar/nodes/`. Host-specific file artifacts are stored under `states/roles//files/nodes//...` and referenced through `salt://` URLs: + +```bash +enroll manifest --harvest /tmp/enroll-harvest --out /tmp/enroll-salt --target salt --fqdn host.example.com +cd /tmp/enroll-salt +sudo salt-call --local --file-root ./states --pillar-root ./pillar --id host.example.com state.apply test=True +``` + +Re-running Salt `--fqdn` output into the same directory adds or replaces that minion's top/pillar data without deleting other generated minions. Docker images with registry digests are rendered with Salt's native `docker_image.present` state. Podman images with registry digests are rendered as guarded `podman pull` / `podman tag` command states. Images without `RepoDigest` are recorded in harvest state and notes, but are not converted into exact pull states. Flatpak, Snap, and live firewall runtime snapshots are listed as notes in the generated Salt README rather than converted into Salt states. + ### Manifest with `--sops` ```bash # Generate encrypted manifest bundle (writes /tmp/enroll-ansible/manifest.tar.gz.sops) diff --git a/enroll/ansible_renderer/roles/managed_files.py b/enroll/ansible_renderer/roles/managed_files.py index f65297d..4afff8e 100644 --- a/enroll/ansible_renderer/roles/managed_files.py +++ b/enroll/ansible_renderer/roles/managed_files.py @@ -29,8 +29,8 @@ from ..yamlutil import _merge_mappings_overwrite, _yaml_load_mapping class AnsibleManagedFileRoleSpec: """Declarative managed-file singleton role rendering spec. - Puppet collects these singleton snapshots in a simple loop and feeds - each one through the same managed-content renderer. Ansible has more + Puppet and Salt collect these singleton snapshots in a simple loop and feed + each one through the same managed-content renderer. Ansible has more layout concerns (defaults vs host_vars, optional JinjaTurtle templates, handlers), but the resource intent is the same, so keep the per-role differences in data rather than spelling out one branch per role. @@ -246,7 +246,7 @@ def _render_managed_file_roles( manifest_plan: AnsibleManifestPlan, roles: Dict[str, Any], ) -> None: - """Render file-centric singleton roles in the same loop style as Puppet.""" + """Render file-centric singleton roles in the same loop style as Puppet/Salt.""" for spec in MANAGED_FILE_ROLE_SPECS: snapshot = roles.get(spec.key, {}) diff --git a/enroll/ansible_renderer/tasks.py b/enroll/ansible_renderer/tasks.py index efdab0c..02ee492 100644 --- a/enroll/ansible_renderer/tasks.py +++ b/enroll/ansible_renderer/tasks.py @@ -104,8 +104,8 @@ def _render_generic_files_tasks( def _render_install_packages_tasks(role: str, var_prefix: str) -> str: """Render package installation through Ansible's generic package provider. - Puppet uses provider-backed package resources instead of selecting - apt/dnf/yum in the generated manifest. Ansible's package module is the + Puppet and Salt use provider-backed package resources instead of selecting + apt/dnf/yum in the generated manifest. Ansible's package module is the equivalent abstraction: it proxies to the target host's detected package manager and keeps generated roles provider-neutral. """ diff --git a/enroll/cli.py b/enroll/cli.py index 3e70e2c..7586cd2 100644 --- a/enroll/cli.py +++ b/enroll/cli.py @@ -310,13 +310,13 @@ def _encrypt_harvest_dir_to_sops( def _add_common_manifest_args(p: argparse.ArgumentParser) -> None: p.add_argument( "--target", - choices=["ansible", "puppet"], + choices=["ansible", "puppet", "salt"], default="ansible", help="Manifest target to generate (default: ansible).", ) p.add_argument( "--fqdn", - help="Host FQDN/name for site-mode output (creates target-specific host inventory/data such as Ansible host_vars or Puppet Hiera).", + help="Host FQDN/name for site-mode output (creates target-specific host inventory/data such as Ansible host_vars, Puppet Hiera, or Salt pillar).", ) p.add_argument( "--no-common-roles", diff --git a/enroll/cm.py b/enroll/cm.py index b843ee1..2799807 100644 --- a/enroll/cm.py +++ b/enroll/cm.py @@ -12,7 +12,7 @@ class CMModule: """Renderer-neutral configuration-management resource group. A CMModule is intentionally small: it captures the resources that a target - renderer can turn into Ansible tasks, Puppet resources, etc. + renderer can turn into Ansible tasks, Puppet resources, Salt states, etc. The renderer may still decide how to name/include/order the group. """ @@ -249,8 +249,8 @@ def _drop_duplicate_mapping_items( def resolve_catalog_conflicts(modules: Iterable[CMModule]) -> None: """Resolve global catalog conflicts before renderer output. - Puppet compiles a single resource catalog. Ansible can tolerate the same - package, service, or parent directory appearing in more than one role; + Puppet and Salt compile a single resource catalog. Ansible can tolerate the + same package, service, or parent directory appearing in more than one role; catalog targets cannot. Resolve those conflicts in the shared model rather than deleting renderer output after the fact. """ diff --git a/enroll/manifest.py b/enroll/manifest.py index 32ea271..8e2e916 100644 --- a/enroll/manifest.py +++ b/enroll/manifest.py @@ -9,6 +9,7 @@ from typing import List, Optional from .ansible import manifest_from_bundle_dir as manifest_ansible_from_bundle_dir from .puppet import manifest_from_bundle_dir as manifest_puppet_from_bundle_dir +from .salt import manifest_from_bundle_dir as manifest_salt_from_bundle_dir from .remote import _safe_extract_tar from .sopsutil import ( decrypt_file_binary_to, @@ -190,7 +191,7 @@ def manifest( - In plain mode: None """ target = (target or "ansible").strip().lower() - if target not in {"ansible", "puppet"}: + if target not in {"ansible", "puppet", "salt"}: raise ValueError(f"unsupported manifest target: {target!r}") sops_mode = bool(sops_fingerprints) @@ -210,6 +211,13 @@ def manifest( fqdn=fqdn, no_common_roles=no_common_roles, ) + elif target == "salt": + manifest_salt_from_bundle_dir( + resolved_bundle_dir, + out, + fqdn=fqdn, + no_common_roles=no_common_roles, + ) else: manifest_ansible_from_bundle_dir( resolved_bundle_dir, @@ -238,6 +246,13 @@ def manifest( fqdn=fqdn, no_common_roles=no_common_roles, ) + elif target == "salt": + manifest_salt_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, diff --git a/enroll/salt.py b/enroll/salt.py new file mode 100644 index 0000000..c778497 --- /dev/null +++ b/enroll/salt.py @@ -0,0 +1,1132 @@ +from __future__ import annotations + +import hashlib +import json +import re +import shlex +import shutil +from pathlib import Path +from typing import Any, Dict, List, Optional + +import yaml + +from .cm import ( + CMModule, + package_section_label, + resolve_catalog_conflicts, + role_order_key, + section_label_for_packages, +) +from .state import inventory_packages_from_state, roles_from_state + + +class SaltRole(CMModule): + """Salt-specific view of a renderer-neutral CMModule.""" + + def __init__(self, role_name: str) -> None: + super().__init__( + role_name=role_name, + module_name=_salt_name(role_name, fallback="enroll_role"), + ) + self.container_images: List[Dict[str, Any]] = [] + + def has_resources(self) -> bool: + return super().has_resources() or bool(self.container_images) + + @property + def sls_name(self) -> str: + return f"roles.{self.module_name}" + + def add_package_snapshot(self, snap: Dict[str, Any]) -> None: + pkg = str(snap.get("package") or "").strip() + if pkg: + self.packages.add(pkg) + + def add_service_snapshot(self, snap: Dict[str, Any]) -> None: + for pkg in snap.get("packages", []) or []: + pkg_s = str(pkg or "").strip() + if pkg_s: + self.packages.add(pkg_s) + unit = str(snap.get("unit") or "").strip() + if unit: + unit_file_state = str(snap.get("unit_file_state") or "") + self.services[unit] = { + "name": unit, + "state": "running" if snap.get("active_state") == "active" else "dead", + "enable": unit_file_state in ("enabled", "enabled-runtime"), + } + + def add_users_snapshot(self, 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: + self.groups.add(primary_group) + supplementary = sorted( + { + str(g).strip() + for g in (u.get("supplementary_groups") or []) + if str(g).strip() + } + ) + self.groups.update(supplementary) + user_data: Dict[str, Any] = { + "name": name, + "uid": u.get("uid"), + "gid": primary_group or u.get("gid"), + "home": u.get("home") or f"/home/{name}", + "shell": u.get("shell"), + "groups": supplementary, + } + user_data.update(_gecos_attrs(u.get("gecos"))) + self.users[name] = user_data + + if snap.get("user_flatpaks") or snap.get("user_flatpak_remotes"): + self.notes.append( + "Per-user Flatpak resources were detected but are not rendered as native Salt states." + ) + + def add_container_images_snapshot(self, snap: Dict[str, Any]) -> None: + for raw in snap.get("images", []) or []: + if not isinstance(raw, dict): + continue + engine = str(raw.get("engine") or "").strip().lower() + pull_ref = str(raw.get("pull_ref") or "").strip() + if engine not in {"docker", "podman"}: + continue + if not pull_ref: + tags = ", ".join(str(t) for t in (raw.get("repo_tags") or []) if t) + label = tags or str(raw.get("image_id") or "unknown image") + self.notes.append( + f"Container image {label} has no RepoDigest; exact Salt pull state was not rendered." + ) + continue + item = dict(raw) + item["engine"] = engine + item["pull_ref"] = pull_ref + item["scope"] = str(item.get("scope") or "system").strip() or "system" + item["tag_aliases"] = [ + dict(alias) + for alias in (item.get("tag_aliases") or []) + if isinstance(alias, dict) and alias.get("ref") + ] + item["pull_cmd"] = _container_pull_cmd(engine, pull_ref) + item["pull_unless"] = _container_exists_cmd(engine, pull_ref) + for alias in item["tag_aliases"]: + alias_ref = str(alias.get("ref") or "") + alias["tag_cmd"] = _container_tag_cmd(engine, pull_ref, alias_ref) + alias["tag_unless"] = _container_exists_cmd(engine, alias_ref) + self.container_images.append(item) + for note in snap.get("notes", []) or []: + self.notes.append(str(note)) + + def add_managed_content( + self, + snap: Dict[str, Any], + *, + bundle_dir: str, + artifact_role: str, + role_files_dir: Path, + file_prefix: Optional[str] = None, + ) -> None: + for d in self.managed_dirs_from_snapshot(snap): + path = str(d.get("path") or "").strip() + self.add_managed_dir( + path, + user=d.get("owner") or "root", + group=d.get("group") or "root", + mode=d.get("mode") or "0755", + makedirs=True, + reason=d.get("reason") or "managed_dir", + ) + + for mf in self.managed_files_from_snapshot(snap): + path = str(mf.get("path") or "").strip() + src_rel = str(mf.get("src_rel") or "").strip() + if not path or not src_rel: + continue + role_rel = _copy_artifact( + bundle_dir, + artifact_role, + src_rel, + role_files_dir, + dst_prefix=file_prefix, + ) + if not role_rel: + self.notes.append( + f"Skipped {path}: harvested artifact {artifact_role}/{src_rel} was not present." + ) + continue + self.add_managed_file( + path, + user=mf.get("owner") or "root", + group=mf.get("group") or "root", + mode=mf.get("mode") or "0644", + source=_source_uri(self.module_name, role_rel), + makedirs=True, + reason=mf.get("reason") or "managed_file", + ) + + for ml in self.managed_links_from_snapshot(snap): + path = str(ml.get("path") or "").strip() + target = str(ml.get("target") or "").strip() + if not path or not target: + continue + self.add_managed_link( + path, + target=target, + force=False, + makedirs=True, + reason=ml.get("reason") or "managed_link", + ) + + self.remove_directory_resource_conflicts() + + +_RESERVED_SALT_NAMES = {"top", "init", "files", "pillar", "states", "roles"} + + +def _salt_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_SALT_NAMES: + s = f"{fallback}_{s}" + return s + + +def _state_id(prefix: str, value: Any, *, role: str = "") -> str: + label = re.sub(r"[^A-Za-z0-9_]+", "_", str(value or "item").strip().lower()) + label = re.sub(r"_+", "_", label).strip("_") or "item" + digest = hashlib.sha1( + str(value).encode("utf-8", errors="replace") + ).hexdigest()[ # nosec B324 + :8 + ] # nosec B324 + parts = ["enroll", prefix] + if role: + parts.append(role) + parts.extend([label[:40], digest]) + return "_".join(parts) + + +def _yaml_quote(value: Any) -> str: + return json.dumps(str(value), ensure_ascii=False) + + +def _yaml_bool(value: Any) -> str: + return "true" if bool(value) else "false" + + +def _shell_quote(value: Any) -> str: + return shlex.quote(str(value or "")) + + +def _container_pull_cmd(engine: str, pull_ref: str) -> str: + return f"{engine} pull {_shell_quote(pull_ref)}" + + +def _container_exists_cmd(engine: str, ref: str) -> str: + if engine == "podman": + return f"podman image exists {_shell_quote(ref)}" + return f"docker image inspect {_shell_quote(ref)} >/dev/null 2>&1" + + +def _container_tag_cmd(engine: str, pull_ref: str, tag_ref: str) -> str: + return f"{engine} tag {_shell_quote(pull_ref)} {_shell_quote(tag_ref)}" + + +def _clean_gecos_part(value: Any) -> Optional[str]: + text = str(value or "").strip() + return text or None + + +def _gecos_attrs(value: Any) -> Dict[str, str]: + """Return Salt user.present-safe GECOS fields. + + Linux passwd GECOS is comma-separated. Passing the raw field as Salt's + ``fullname`` can fail for values such as ``Node,,,`` because Salt validates + commas inside individual GECOS subfields. Split it into Salt's native + fields instead. + """ + + raw = str(value or "") + if not raw.strip(): + return {} + parts = raw.split(",", 4) + keys = ("fullname", "roomnumber", "workphone", "homephone", "other") + out: Dict[str, str] = {} + for key, part in zip(keys, parts): + cleaned = _clean_gecos_part(part) + if cleaned: + out[key] = cleaned + return out + + +def _copy_artifact( + bundle_dir: str, + role: str, + src_rel: str, + dst_files_dir: Path, + *, + dst_prefix: Optional[str] = None, +) -> 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 + role_rel = Path(dst_prefix or "") / src_rel + dst = dst_files_dir / role_rel + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src, dst) + return role_rel.as_posix() + + +def _source_uri(module_name: str, role_rel: str) -> str: + return f"salt://roles/{module_name}/files/{role_rel}" + + +def _node_file_prefix(fqdn: str) -> str: + name = re.sub(r"[^A-Za-z0-9_.-]+", "_", str(fqdn or "").strip()) + name = name.strip("._-") or "node" + return f"nodes/{name}" + + +def _node_sls_basename(fqdn: str) -> str: + raw = str(fqdn or "node").strip() or "node" + name = re.sub(r"[^A-Za-z0-9_]+", "_", raw).strip("_").lower() or "node" + digest = hashlib.sha1( + raw.encode("utf-8", errors="replace") + ).hexdigest()[ # nosec B324 + :8 + ] # nosec B324 + return f"{name}_{digest}" + + +def _add_flatpak_snap_notes(roles: Dict[str, Any], out: Dict[str, SaltRole]) -> None: + flatpak = roles.get("flatpak") or {} + if isinstance(flatpak, dict) and ( + flatpak.get("system_flatpaks") or flatpak.get("remotes") + ): + srole = out.setdefault("flatpak", SaltRole("flatpak")) + srole.notes.append( + "Flatpak resources were detected but are not rendered as native Salt states." + ) + snap = roles.get("snap") or {} + if isinstance(snap, dict) and snap.get("system_snaps"): + srole = out.setdefault("snap", SaltRole("snap")) + srole.notes.append( + "Snap resources were detected but are not rendered as native Salt states." + ) + + +def _collect_salt_roles( + state: Dict[str, Any], + bundle_dir: str, + states_dir: Path, + *, + fqdn: Optional[str] = None, + no_common_roles: bool = False, +) -> List[SaltRole]: + roles = roles_from_state(state) + inventory_packages = inventory_packages_from_state(state) + use_common_roles = not fqdn and not no_common_roles + node_file_prefix = _node_file_prefix(fqdn) if fqdn else None + out: Dict[str, SaltRole] = {} + + def ensure_role(role_name: str) -> SaltRole: + role_name = _salt_name(role_name, fallback="enroll_role") + return out.setdefault(role_name, SaltRole(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 = _salt_name( + str(snap.get("role_name") or key), fallback="enroll_role" + ) + srole = ensure_role(role_name) + srole.add_managed_content( + snap, + bundle_dir=bundle_dir, + artifact_role=str(snap.get("role_name") or key), + role_files_dir=states_dir / "roles" / srole.module_name / "files", + file_prefix=node_file_prefix, + ) + + users_snap = roles.get("users") or {} + if isinstance(users_snap, dict): + role_name = _salt_name( + str(users_snap.get("role_name") or "users"), fallback="enroll_role" + ) + srole = ensure_role(role_name) + srole.add_users_snapshot(users_snap) + srole.add_managed_content( + users_snap, + bundle_dir=bundle_dir, + artifact_role=str(users_snap.get("role_name") or "users"), + role_files_dir=states_dir / "roles" / srole.module_name / "files", + file_prefix=node_file_prefix, + ) + + for svc in roles.get("services", []) or []: + if not isinstance(svc, dict): + continue + original_role_name = _salt_name( + str(svc.get("role_name") or svc.get("unit") or "service"), + fallback="service", + ) + if use_common_roles: + role_name = _salt_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 + srole = ensure_role(role_name) + srole.add_service_snapshot(svc) + srole.add_managed_content( + svc, + bundle_dir=bundle_dir, + artifact_role=str(svc.get("role_name") or original_role_name), + role_files_dir=states_dir / "roles" / srole.module_name / "files", + file_prefix=node_file_prefix, + ) + + for pkg in roles.get("packages", []) or []: + if not isinstance(pkg, dict): + continue + original_role_name = _salt_name( + str(pkg.get("role_name") or pkg.get("package") or "package"), + fallback="package", + ) + if use_common_roles: + role_name = _salt_name( + package_section_label(pkg, inventory_packages), + fallback="package_group", + ) + else: + role_name = original_role_name + srole = ensure_role(role_name) + srole.add_package_snapshot(pkg) + srole.add_managed_content( + pkg, + bundle_dir=bundle_dir, + artifact_role=str(pkg.get("role_name") or original_role_name), + role_files_dir=states_dir / "roles" / srole.module_name / "files", + file_prefix=node_file_prefix, + ) + + container_images = roles.get("container_images") or {} + if isinstance(container_images, dict) and ( + container_images.get("images") or container_images.get("notes") + ): + srole = ensure_role( + str(container_images.get("role_name") or "container_images") + ) + srole.add_container_images_snapshot(container_images) + + 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: + srole = ensure_role(str(fw.get("role_name") or "firewall_runtime")) + srole.packages.update(packages) + if has_fw: + srole.notes.append( + "Live firewall runtime snapshots were detected but are not rendered as Salt states." + ) + + _add_flatpak_snap_notes(roles, out) + + salt_roles = sorted(out.values(), key=lambda r: role_order_key(r.role_name)) + resolve_catalog_conflicts(salt_roles) + return [r for r in salt_roles if r.has_resources()] + + +def _render_static_role(srole: SaltRole) -> str: + lines: List[str] = ["# Generated by Enroll from harvest state.", ""] + + for package in sorted(srole.packages): + lines.extend( + [ + f"{_state_id('pkg', package, role=srole.module_name)}:", + " pkg.installed:", + f" - name: {_yaml_quote(package)}", + "", + ] + ) + + for group in sorted(srole.groups): + lines.extend( + [ + f"{_state_id('group', group, role=srole.module_name)}:", + " group.present:", + f" - name: {_yaml_quote(group)}", + "", + ] + ) + + for name in sorted(srole.users): + user = srole.users[name] + lines.extend( + [ + f"{_state_id('user', name, role=srole.module_name)}:", + " user.present:", + f" - name: {_yaml_quote(name)}", + ] + ) + if user.get("uid") is not None: + lines.append(f" - uid: {user['uid']}") + if user.get("gid") is not None: + lines.append(f" - gid: {_yaml_quote(user['gid'])}") + if user.get("home"): + lines.append(f" - home: {_yaml_quote(user['home'])}") + if user.get("shell"): + lines.append(f" - shell: {_yaml_quote(user['shell'])}") + for gecos_key in ("fullname", "roomnumber", "workphone", "homephone", "other"): + if user.get(gecos_key): + lines.append(f" - {gecos_key}: {_yaml_quote(user[gecos_key])}") + if user.get("groups"): + lines.append(" - groups:") + for group in user.get("groups") or []: + lines.append(f" - {_yaml_quote(group)}") + lines.append(" - remove_groups: false") + lines.append("") + + for path, attrs in sorted(srole.dirs.items()): + lines.extend( + [ + f"{_yaml_quote(path)}:", + " file.directory:", + f" - user: {_yaml_quote(attrs.get('user') or attrs.get('owner') or 'root')}", + f" - group: {_yaml_quote(attrs.get('group') or 'root')}", + f" - mode: {_yaml_quote(str(attrs.get('mode') or '0755'))}", + " - makedirs: true", + "", + ] + ) + + for path, attrs in sorted(srole.files.items()): + lines.extend( + [ + f"{_yaml_quote(path)}:", + " file.managed:", + f" - source: {_yaml_quote(attrs.get('source') or '')}", + f" - user: {_yaml_quote(attrs.get('user') or attrs.get('owner') or 'root')}", + f" - group: {_yaml_quote(attrs.get('group') or 'root')}", + f" - mode: {_yaml_quote(str(attrs.get('mode') or '0644'))}", + " - makedirs: true", + "", + ] + ) + + for path, attrs in sorted(srole.links.items()): + lines.extend( + [ + f"{_yaml_quote(path)}:", + " file.symlink:", + f" - target: {_yaml_quote(attrs.get('target') or '')}", + f" - force: {_yaml_bool(attrs.get('force', False))}", + " - makedirs: true", + "", + ] + ) + + for name in sorted(srole.services): + svc = srole.services[name] + state_fun = "running" if svc.get("state") == "running" else "dead" + lines.extend( + [ + f"{_state_id('service', name, role=srole.module_name)}:", + f" service.{state_fun}:", + f" - name: {_yaml_quote(svc.get('name') or name)}", + f" - enable: {_yaml_bool(svc.get('enable', False))}", + "", + ] + ) + + for idx, image in enumerate(srole.container_images, start=1): + engine = str(image.get("engine") or "").strip() + pull_ref = str(image.get("pull_ref") or "").strip() + if not engine or not pull_ref: + continue + if engine == "docker": + pull_state_id = _state_id("docker_image", pull_ref, role=srole.module_name) + lines.extend( + [ + f"{pull_state_id}:", + " docker_image.present:", + f" - name: {_yaml_quote(pull_ref)}", + " - force: false", + "", + ] + ) + for alias in image.get("tag_aliases") or []: + tag_ref = str(alias.get("ref") or "").strip() + if not tag_ref: + continue + lines.extend( + [ + f"{_state_id('docker_tag', tag_ref, role=srole.module_name)}:", + " cmd.run:", + f" - name: {_yaml_quote(alias.get('tag_cmd') or _container_tag_cmd(engine, pull_ref, tag_ref))}", + f" - unless: {_yaml_quote(alias.get('tag_unless') or _container_exists_cmd(engine, tag_ref))}", + " - require:", + f" - docker_image: {pull_state_id}", + "", + ] + ) + elif engine == "podman": + pull_state_id = _state_id("podman_pull", pull_ref, role=srole.module_name) + lines.extend( + [ + f"{pull_state_id}:", + " cmd.run:", + f" - name: {_yaml_quote(image.get('pull_cmd') or _container_pull_cmd(engine, pull_ref))}", + f" - unless: {_yaml_quote(image.get('pull_unless') or _container_exists_cmd(engine, pull_ref))}", + "", + ] + ) + for alias in image.get("tag_aliases") or []: + tag_ref = str(alias.get("ref") or "").strip() + if not tag_ref: + continue + lines.extend( + [ + f"{_state_id('podman_tag', tag_ref, role=srole.module_name)}:", + " cmd.run:", + f" - name: {_yaml_quote(alias.get('tag_cmd') or _container_tag_cmd(engine, pull_ref, tag_ref))}", + f" - unless: {_yaml_quote(alias.get('tag_unless') or _container_exists_cmd(engine, tag_ref))}", + " - require:", + f" - cmd: {pull_state_id}", + "", + ] + ) + + if "/etc/sysctl.d/99-enroll.conf" in srole.files: + lines.extend( + [ + f"{_state_id('cmd', 'apply_sysctl', role=srole.module_name)}:", + " cmd.run:", + " - name: sysctl -e -p /etc/sysctl.d/99-enroll.conf || true", + " - onchanges:", + " - file: /etc/sysctl.d/99-enroll.conf", + "", + ] + ) + + if srole.notes: + lines.append("# Notes and limitations") + for note in srole.notes: + lines.append(f"# - {note}") + lines.append("") + + return "\n".join(lines).rstrip() + "\n" + + +def _role_pillar_values(srole: SaltRole) -> Dict[str, Any]: + data: Dict[str, Any] = {} + if srole.packages: + data["packages"] = sorted(srole.packages) + if srole.groups: + data["groups"] = {group: {} for group in sorted(srole.groups)} + if srole.users: + users: Dict[str, Dict[str, Any]] = {} + for name in sorted(srole.users): + raw = srole.users[name] + attrs: Dict[str, Any] = {} + for key in ( + "uid", + "gid", + "home", + "shell", + "fullname", + "roomnumber", + "workphone", + "homephone", + "other", + ): + if raw.get(key) is not None: + attrs[key] = raw[key] + if raw.get("groups"): + attrs["groups"] = list(raw["groups"]) + attrs["remove_groups"] = False + users[name] = attrs + data["users"] = users + if srole.dirs: + data["dirs"] = { + path: { + "user": attrs.get("user") or attrs.get("owner") or "root", + "group": attrs.get("group") or "root", + "mode": str(attrs.get("mode") or "0755"), + "makedirs": True, + } + for path, attrs in sorted(srole.dirs.items()) + } + if srole.files: + data["files"] = { + path: { + "source": attrs.get("source") or "", + "user": attrs.get("user") or attrs.get("owner") or "root", + "group": attrs.get("group") or "root", + "mode": str(attrs.get("mode") or "0644"), + "makedirs": True, + } + for path, attrs in sorted(srole.files.items()) + } + if srole.links: + data["links"] = { + path: { + "target": attrs.get("target") or "", + "force": bool(attrs.get("force", False)), + "makedirs": True, + } + for path, attrs in sorted(srole.links.items()) + } + if srole.services: + data["services"] = { + name: { + "name": svc.get("name") or name, + "state": "running" if svc.get("state") == "running" else "dead", + "enable": bool(svc.get("enable", False)), + } + for name, svc in sorted(srole.services.items()) + } + if "/etc/sysctl.d/99-enroll.conf" in srole.files: + data["sysctl_apply"] = True + if srole.container_images: + data["container_images"] = list(srole.container_images) + if srole.notes: + data["notes"] = list(srole.notes) + return data + + +def _render_pillar_role(srole: SaltRole) -> str: + role_key = srole.module_name + lines = [ + "# Generated by Enroll from harvest state.", + f"{{% set role = salt['pillar.get']('enroll:roles:{role_key}', {{}}) %}}", + "", + "{% for package_name in role.get('packages', []) %}", + f"enroll_pkg_{role_key}_{{{{ loop.index }}}}:", + " pkg.installed:", + " - name: {{ package_name|yaml_dquote }}", + "{% endfor %}", + "", + "{% for group_name, group_attrs in role.get('groups', {}).items() %}", + f"enroll_group_{role_key}_{{{{ loop.index }}}}:", + " group.present:", + " - name: {{ group_name|yaml_dquote }}", + "{% endfor %}", + "", + "{% for user_name, user_attrs in role.get('users', {}).items() %}", + f"enroll_user_{role_key}_{{{{ loop.index }}}}:", + " user.present:", + " - name: {{ user_name|yaml_dquote }}", + "{% if user_attrs.get('uid') is not none %}", + " - uid: {{ user_attrs.get('uid') }}", + "{% endif %}", + "{% if user_attrs.get('gid') is not none %}", + " - gid: {{ user_attrs.get('gid')|yaml_dquote }}", + "{% endif %}", + "{% if user_attrs.get('home') %}", + " - home: {{ user_attrs.get('home')|yaml_dquote }}", + "{% endif %}", + "{% if user_attrs.get('shell') %}", + " - shell: {{ user_attrs.get('shell')|yaml_dquote }}", + "{% endif %}", + "{% for gecos_key in ['fullname', 'roomnumber', 'workphone', 'homephone', 'other'] %}", + "{% if user_attrs.get(gecos_key) %}", + " - {{ gecos_key }}: {{ user_attrs.get(gecos_key)|yaml_dquote }}", + "{% endif %}", + "{% endfor %}", + "{% if user_attrs.get('groups') %}", + " - groups:", + "{% for group_name in user_attrs.get('groups', []) %}", + " - {{ group_name|yaml_dquote }}", + "{% endfor %}", + " - remove_groups: {{ user_attrs.get('remove_groups', False)|yaml_encode }}", + "{% endif %}", + "{% endfor %}", + "", + "{% for path, attrs in role.get('dirs', {}).items() %}", + "{{ path|yaml_dquote }}:", + " file.directory:", + " - user: {{ attrs.get('user', 'root')|yaml_dquote }}", + " - group: {{ attrs.get('group', 'root')|yaml_dquote }}", + " - mode: {{ attrs.get('mode', '0755')|string|yaml_dquote }}", + " - makedirs: {{ attrs.get('makedirs', True)|yaml_encode }}", + "{% endfor %}", + "", + "{% for path, attrs in role.get('files', {}).items() %}", + "{{ path|yaml_dquote }}:", + " file.managed:", + " - source: {{ attrs.get('source', '')|yaml_dquote }}", + " - user: {{ attrs.get('user', 'root')|yaml_dquote }}", + " - group: {{ attrs.get('group', 'root')|yaml_dquote }}", + " - mode: {{ attrs.get('mode', '0644')|string|yaml_dquote }}", + " - makedirs: {{ attrs.get('makedirs', True)|yaml_encode }}", + "{% endfor %}", + "", + "{% for path, attrs in role.get('links', {}).items() %}", + "{{ path|yaml_dquote }}:", + " file.symlink:", + " - target: {{ attrs.get('target', '')|yaml_dquote }}", + " - force: {{ attrs.get('force', False)|yaml_encode }}", + " - makedirs: {{ attrs.get('makedirs', True)|yaml_encode }}", + "{% endfor %}", + "", + "{% for service_id, svc in role.get('services', {}).items() %}", + f"enroll_service_{role_key}_{{{{ loop.index }}}}:", + " service.{{ 'running' if svc.get('state') == 'running' else 'dead' }}:", + " - name: {{ svc.get('name', service_id)|yaml_dquote }}", + " - enable: {{ svc.get('enable', False)|yaml_encode }}", + "{% endfor %}", + "", + "{% for image in role.get('container_images', []) %}", + "{% if image.get('engine') == 'docker' and image.get('pull_ref') %}", + f"enroll_docker_image_{role_key}_{{{{ loop.index }}}}:", + " docker_image.present:", + " - name: {{ image.get('pull_ref')|yaml_dquote }}", + " - force: false", + "{% set image_loop = loop.index %}", + "{% for alias in image.get('tag_aliases', []) %}", + f"enroll_docker_tag_{role_key}_{{{{ image_loop }}}}_{{{{ loop.index }}}}:", + " cmd.run:", + " - name: {{ alias.get('tag_cmd')|yaml_dquote }}", + " - unless: {{ alias.get('tag_unless')|yaml_dquote }}", + " - require:", + f" - docker_image: enroll_docker_image_{role_key}_{{{{ image_loop }}}}", + "{% endfor %}", + "{% elif image.get('engine') == 'podman' and image.get('pull_ref') %}", + f"enroll_podman_pull_{role_key}_{{{{ loop.index }}}}:", + " cmd.run:", + " - name: {{ image.get('pull_cmd')|yaml_dquote }}", + " - unless: {{ image.get('pull_unless')|yaml_dquote }}", + "{% set image_loop = loop.index %}", + "{% for alias in image.get('tag_aliases', []) %}", + f"enroll_podman_tag_{role_key}_{{{{ image_loop }}}}_{{{{ loop.index }}}}:", + " cmd.run:", + " - name: {{ alias.get('tag_cmd')|yaml_dquote }}", + " - unless: {{ alias.get('tag_unless')|yaml_dquote }}", + " - require:", + f" - cmd: enroll_podman_pull_{role_key}_{{{{ image_loop }}}}", + "{% endfor %}", + "{% endif %}", + "{% endfor %}", + "", + "{% if role.get('sysctl_apply') and '/etc/sysctl.d/99-enroll.conf' in role.get('files', {}) %}", + f"enroll_apply_sysctl_{role_key}:", + " cmd.run:", + " - name: sysctl -e -p /etc/sysctl.d/99-enroll.conf || true", + " - onchanges:", + " - file: /etc/sysctl.d/99-enroll.conf", + "{% endif %}", + "", + "{% if role.get('notes') %}", + "# Notes and limitations", + "{% for note in role.get('notes', []) %}", + "# - {{ note }}", + "{% endfor %}", + "{% endif %}", + "", + ] + return "\n".join(lines).rstrip() + "\n" + + +def _write_yaml(path: Path, data: Dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text( + yaml.safe_dump(data, sort_keys=True, explicit_start=True), + encoding="utf-8", + ) + + +def _load_yaml_mapping(path: Path) -> Dict[str, Any]: + if not path.exists(): + return {} + try: + obj = yaml.safe_load(path.read_text(encoding="utf-8")) + except Exception: + return {} + return obj if isinstance(obj, dict) else {} + + +def _write_top(path: Path, mapping: Dict[str, List[str]]) -> None: + data = { + "base": {target: list(values) for target, values in sorted(mapping.items())} + } + _write_yaml(path, data) + + +def _read_top(path: Path) -> Dict[str, List[str]]: + data = _load_yaml_mapping(path) + base = data.get("base") if isinstance(data.get("base"), dict) else {} + out: Dict[str, List[str]] = {} + for target, values in base.items(): + if isinstance(values, list): + out[str(target)] = [str(v) for v in values if isinstance(v, str)] + return out + + +def _write_state_top( + states_dir: Path, target: str, sls_names: List[str], *, preserve: bool +) -> None: + top_path = states_dir / "top.sls" + mapping = _read_top(top_path) if preserve else {} + mapping[target] = list(sls_names) + _write_top(top_path, mapping) + + +def _write_pillar_top(pillar_dir: Path, fqdn: str, node_sls: str) -> None: + top_path = pillar_dir / "top.sls" + mapping = _read_top(top_path) + mapping[fqdn] = [node_sls] + _write_top(top_path, mapping) + + +def _write_pillar_node_data( + pillar_dir: Path, fqdn: str, salt_roles: List[SaltRole] +) -> Path: + node_base = _node_sls_basename(fqdn) + node_path = pillar_dir / "nodes" / f"{node_base}.sls" + data = { + "enroll": { + "classes": [r.sls_name for r in salt_roles], + "roles": {r.module_name: _role_pillar_values(r) for r in salt_roles}, + } + } + _write_yaml(node_path, data) + _write_pillar_top(pillar_dir, fqdn, f"nodes.{node_base}") + return node_path + + +def _clean_node_artifacts(states_dir: Path, fqdn: str) -> None: + prefix = Path(_node_file_prefix(fqdn)) + nodes_rel = prefix.parts + for files_dir in (states_dir / "roles").glob("*/files"): + target = files_dir.joinpath(*nodes_rel) + if target.exists(): + shutil.rmtree(target) + + +def _write_master_config(out: Path) -> None: + config_dir = out / "config" + config_dir.mkdir(parents=True, exist_ok=True) + (config_dir / "master.d" / "enroll.conf").parent.mkdir(parents=True, exist_ok=True) + (config_dir / "master.d" / "enroll.conf").write_text( + "# Generated by Enroll. Copy or merge into /etc/salt/master.d/enroll.conf.\n" + "file_roots:\n" + " base:\n" + " - /srv/salt\n" + "pillar_roots:\n" + " base:\n" + " - /srv/pillar\n", + encoding="utf-8", + ) + + +def _render_readme( + state: Dict[str, Any], + salt_roles: List[SaltRole], + *, + fqdn: Optional[str] = None, + node_path: Optional[Path] = None, +) -> str: + host = state.get("host", {}) if isinstance(state.get("host"), dict) else {} + hostname = host.get("hostname") or "unknown" + role_lines = ( + "\n".join( + f"- `{r.sls_name}` from Enroll role `{r.role_name}`" for r in salt_roles + ) + or "- None." + ) + notes: List[str] = [] + for r in salt_roles: + for note in r.notes: + notes.append(f"`{r.sls_name}`: {note}") + notes_text = "\n".join(f"- {n}" for n in notes) or "- None." + + if fqdn: + node_display = ( + node_path.relative_to(Path(node_path).parents[1]).as_posix() + if node_path + else "pillar/nodes/.sls" + ) + layout = f"""- `states/top.sls` targets minion `{fqdn}` to this node's generated role SLS files. +- `pillar/top.sls` targets minion `{fqdn}` to `{node_display}`. +- `pillar/nodes/*.sls` contains per-minion resource data under `enroll:roles:`. +- `states/roles//init.sls` contains reusable, data-driven Salt states. +- `states/roles//files/nodes//...` contains node-specific harvested file artifacts.""" + apply = f"""For a local dry run using the generated tree: + +```bash +sudo salt-call --local --file-root ./states --pillar-root ./pillar --id {fqdn} state.apply test=True +``` + +For master/minion use, copy or sync `states/` to your Salt state tree, copy or sync `pillar/` to your pillar tree, refresh pillar, then apply the highstate or the selected SLS files to minion `{fqdn}`.""" + else: + layout = """- `states/top.sls` targets `*` to the generated role SLS files. +- `states/roles//init.sls` contains concrete Salt states for each generated Enroll role/snapshot or common package group. +- `states/roles//files/` contains harvested file artifacts for that role or group. +- `config/master.d/enroll.conf` documents the expected Salt `file_roots` and `pillar_roots` layout if copied under `/srv`.""" + apply = """For a local dry run using the generated tree: + +```bash +sudo salt-call --local --file-root ./states state.apply test=True +``` + +For master/minion use, copy or sync `states/` to your Salt state tree and apply highstate or the selected SLS files.""" + + return f"""# Enroll Salt manifest + +Generated by Enroll from harvest data for `{hostname}`. + +This Salt target reuses the existing harvest state without changing harvesting behaviour. + +## Layout + +{layout} + +## Generated SLS roles + +{role_lines} + +## Apply / check + +{apply} + +## 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 an `onchanges` sysctl apply command when present. +- Docker images by digest using Salt's native `docker_image.present` state. +- Podman images by digest using guarded `podman pull` / `podman tag` command states. + +## Current limitations + +- Flatpak, Snap, and live firewall runtime snapshots are listed as notes when present rather than rendered as Salt states. +- JinjaTurtle templating is currently Ansible-oriented and is not applied to Salt output. +- Review generated resources before applying them broadly across unlike hosts. + +## Notes + +{notes_text} +""" + + +class SaltManifestRenderer: + """Render Salt state/pillar trees from a harvest bundle.""" + + def __init__( + self, + bundle_dir: str, + out_dir: str, + *, + fqdn: Optional[str] = None, + no_common_roles: bool = False, + ) -> None: + self.bundle_dir = bundle_dir + self.out_dir = out_dir + self.fqdn = fqdn + self.no_common_roles = no_common_roles + + def render(self) -> None: + state = SaltRole.load_state(self.bundle_dir) + out = Path(self.out_dir) + states_dir = out / "states" + pillar_dir = out / "pillar" + fqdn_mode = bool(self.fqdn) + + if out.exists() and not fqdn_mode: + shutil.rmtree(out) + states_dir.mkdir(parents=True, exist_ok=True) + if fqdn_mode: + pillar_dir.mkdir(parents=True, exist_ok=True) + _clean_node_artifacts(states_dir, str(self.fqdn)) + + salt_roles = _collect_salt_roles( + state, + self.bundle_dir, + states_dir, + fqdn=self.fqdn, + no_common_roles=self.no_common_roles, + ) + + for srole in salt_roles: + role_dir = states_dir / "roles" / srole.module_name + role_dir.mkdir(parents=True, exist_ok=True) + (role_dir / "init.sls").write_text( + _render_pillar_role(srole) if fqdn_mode else _render_static_role(srole), + encoding="utf-8", + ) + + node_path: Optional[Path] = None + if fqdn_mode and self.fqdn: + node_path = _write_pillar_node_data(pillar_dir, self.fqdn, salt_roles) + _write_state_top( + states_dir, + self.fqdn, + [r.sls_name for r in salt_roles], + preserve=True, + ) + else: + _write_state_top( + states_dir, + "*", + [r.sls_name for r in salt_roles], + preserve=False, + ) + _write_master_config(out) + (out / "README.md").write_text( + _render_readme(state, salt_roles, fqdn=self.fqdn, node_path=node_path), + encoding="utf-8", + ) + + +def manifest_from_bundle_dir( + bundle_dir: str, + out_dir: str, + *, + fqdn: Optional[str] = None, + no_common_roles: bool = False, +) -> None: + SaltManifestRenderer( + bundle_dir, + out_dir, + fqdn=fqdn, + no_common_roles=no_common_roles, + ).render() diff --git a/tests.sh b/tests.sh index 1295daa..c873bdf 100755 --- a/tests.sh +++ b/tests.sh @@ -20,6 +20,8 @@ ANSIBLE_NO_COMMON_DIR="${WORK_DIR}/ansible-no-common" ANSIBLE_FQDN_DIR="${WORK_DIR}/ansible-fqdn" PUPPET_DIR="${WORK_DIR}/puppet" PUPPET_FQDN_DIR="${WORK_DIR}/puppet-fqdn" +SALT_DIR="${WORK_DIR}/salt" +SALT_FQDN_DIR="${WORK_DIR}/salt-fqdn" TEST_FQDN="${ENROLL_TEST_FQDN:-enroll-ci.example.test}" cleanup() { @@ -105,6 +107,13 @@ ensure_puppet() { require_cmd puppet "Install Puppet before running the Puppet noop integration tests." } +ensure_salt() { + if ! command -v salt-call >/dev/null 2>&1; then + apt_install salt-minion || true + fi + require_cmd salt-call "Install Salt's salt-call binary before running the Salt noop integration tests. On Debian 13 this may require configuring the upstream Salt/Broadcom package repository first." +} + run_pytests() { section "Python unit tests" cd "${PROJECT_ROOT}" @@ -170,6 +179,25 @@ run_puppet_noop_tests() { --noop } +run_salt_noop_tests() { + section "Salt manifest noop tests" + ensure_salt + cd "${PROJECT_ROOT}" + rm -rf "${SALT_DIR}" "${SALT_FQDN_DIR}" + + run poetry run enroll manifest --harvest "${BUNDLE_DIR}" --out "${SALT_DIR}" --target salt + run salt-call --local --retcode-passthrough --file-root "${SALT_DIR}/states" state.apply test=True + + run poetry run enroll manifest --harvest "${BUNDLE_DIR}" --out "${SALT_FQDN_DIR}" --target salt --fqdn "${TEST_FQDN}" + run salt-call \ + --local \ + --retcode-passthrough \ + --id "${TEST_FQDN}" \ + --file-root "${SALT_FQDN_DIR}/states" \ + --pillar-root "${SALT_FQDN_DIR}/pillar" \ + state.apply test=True +} + main() { require_root require_debian_ci @@ -177,6 +205,7 @@ main() { prepare_harvest_fixture run_ansible_noop_tests run_puppet_noop_tests + run_salt_noop_tests } main "$@" diff --git a/tests/test_manifest_salt.py b/tests/test_manifest_salt.py new file mode 100644 index 0000000..6111a27 --- /dev/null +++ b/tests/test_manifest_salt.py @@ -0,0 +1,457 @@ +from __future__ import annotations + +import json +from pathlib import Path + +import yaml + +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 _sample_state() -> dict: + return { + "schema_version": 3, + "host": {"hostname": "test", "os": "debian", "pkg_backend": "dpkg"}, + "inventory": { + "packages": {"foo": {"section": "net"}, "curl": {"section": "net"}} + }, + "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": [], + "managed_links": [], + "excluded": [], + "notes": [], + }, + "services": [ + { + "unit": "foo.service", + "role_name": "foo", + "packages": ["foo"], + "active_state": "active", + "unit_file_state": "enabled", + "managed_dirs": [ + { + "path": "/etc/foo", + "owner": "root", + "group": "root", + "mode": "0755", + } + ], + "managed_files": [ + { + "path": "/etc/foo/foo.conf", + "src_rel": "etc/foo.conf", + "owner": "root", + "group": "root", + "mode": "0644", + } + ], + "managed_links": [ + {"path": "/etc/foo/enabled.conf", "target": "/etc/foo/foo.conf"} + ], + "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": [], + "managed_links": [], + }, + "dnf_config": { + "role_name": "dnf_config", + "managed_dirs": [], + "managed_files": [], + "managed_links": [], + }, + "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", + } + ], + "managed_links": [], + "parameters": {"net.ipv4.ip_forward": "1"}, + "notes": [], + }, + "firewall_runtime": {"role_name": "firewall_runtime", "packages": []}, + "etc_custom": { + "role_name": "etc_custom", + "managed_dirs": [], + "managed_files": [], + "managed_links": [], + }, + "usr_local_custom": { + "role_name": "usr_local_custom", + "managed_dirs": [], + "managed_files": [], + "managed_links": [], + }, + "extra_paths": { + "role_name": "extra_paths", + "managed_dirs": [], + "managed_files": [], + "managed_links": [], + }, + }, + } + + +def _write_sample_artifacts(bundle: Path) -> None: + 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") + + +def test_manifest_salt_writes_single_site_state_tree(tmp_path: Path): + bundle = tmp_path / "bundle" + out = tmp_path / "salt" + _write_sample_artifacts(bundle) + _write_state(bundle, _sample_state()) + + manifest.manifest(str(bundle), str(out), target="salt") + + top = yaml.safe_load((out / "states" / "top.sls").read_text(encoding="utf-8")) + assert top["base"]["*"] == ["roles.net", "roles.users", "roles.sysctl"] + + net_sls = (out / "states" / "roles" / "net" / "init.sls").read_text( + encoding="utf-8" + ) + assert "pkg.installed:" in net_sls + assert '- name: "curl"' in net_sls + assert '- name: "foo"' in net_sls + assert '"/etc/foo/foo.conf":' in net_sls + assert 'source: "salt://roles/net/files/etc/foo.conf"' in net_sls + assert "file.symlink:" in net_sls + assert "service.running:" in net_sls + assert (out / "states" / "roles" / "net" / "files" / "etc" / "foo.conf").exists() + + users_sls = (out / "states" / "roles" / "users" / "init.sls").read_text( + encoding="utf-8" + ) + assert "group.present:" in users_sls + assert "user.present:" in users_sls + assert "Alice Example" in users_sls + assert "optional_groups" not in users_sls + assert "- remove_groups: false" in users_sls + + sysctl_sls = (out / "states" / "roles" / "sysctl" / "init.sls").read_text( + encoding="utf-8" + ) + assert "cmd.run:" in sysctl_sls + assert "sysctl -e -p /etc/sysctl.d/99-enroll.conf || true" in sysctl_sls + assert (out / "README.md").exists() + assert (out / "config" / "master.d" / "enroll.conf").exists() + + +def test_manifest_salt_fqdn_mode_uses_pillar_and_accumulates_nodes(tmp_path: Path): + out = tmp_path / "salt" + + def write_bundle(name: str, content: str) -> Path: + bundle = tmp_path / name + _write_sample_artifacts(bundle) + (bundle / "artifacts" / "foo" / "etc" / "foo.conf").write_text( + content, encoding="utf-8" + ) + state = _sample_state() + state["host"]["hostname"] = name + _write_state(bundle, state) + return bundle + + first = write_bundle("first", "first=true\n") + second = write_bundle("second", "second=true\n") + + manifest.manifest(str(first), str(out), target="salt", fqdn="first.example") + manifest.manifest(str(second), str(out), target="salt", fqdn="second.example") + + state_top = yaml.safe_load((out / "states" / "top.sls").read_text(encoding="utf-8")) + assert state_top["base"]["first.example"] == [ + "roles.curl", + "roles.foo", + "roles.users", + "roles.sysctl", + ] + assert state_top["base"]["second.example"] == [ + "roles.curl", + "roles.foo", + "roles.users", + "roles.sysctl", + ] + + pillar_top = yaml.safe_load( + (out / "pillar" / "top.sls").read_text(encoding="utf-8") + ) + assert set(pillar_top["base"]) == {"first.example", "second.example"} + first_pillar_sls = pillar_top["base"]["first.example"][0] + first_node = out / "pillar" / Path(*first_pillar_sls.split(".")) + first_data = yaml.safe_load( + first_node.with_suffix(".sls").read_text(encoding="utf-8") + ) + assert first_data["enroll"]["classes"] == [ + "roles.curl", + "roles.foo", + "roles.users", + "roles.sysctl", + ] + assert first_data["enroll"]["roles"]["foo"]["packages"] == ["foo"] + assert first_data["enroll"]["roles"]["foo"]["files"]["/etc/foo/foo.conf"][ + "source" + ] == ("salt://roles/foo/files/nodes/first.example/etc/foo.conf") + + foo_sls = (out / "states" / "roles" / "foo" / "init.sls").read_text( + encoding="utf-8" + ) + assert "salt['pillar.get']('enroll:roles:foo'" in foo_sls + assert "pkg.installed:" in foo_sls + assert "file.managed:" in foo_sls + assert ( + out + / "states" + / "roles" + / "foo" + / "files" + / "nodes" + / "first.example" + / "etc" + / "foo.conf" + ).exists() + assert ( + out + / "states" + / "roles" + / "foo" + / "files" + / "nodes" + / "second.example" + / "etc" + / "foo.conf" + ).exists() + + +def test_manifest_salt_user_gecos_and_groups_are_salt_safe(tmp_path: Path): + bundle = tmp_path / "bundle" + out = tmp_path / "salt" + state = _sample_state() + state["roles"]["users"]["users"][0]["name"] = "node" + state["roles"]["users"]["users"][0]["primary_group"] = "node" + state["roles"]["users"]["users"][0]["gid"] = 1000 + state["roles"]["users"]["users"][0]["gecos"] = "Node,,," + _write_sample_artifacts(bundle) + _write_state(bundle, state) + + manifest.manifest(str(bundle), str(out), target="salt") + + users_sls = (out / "states" / "roles" / "users" / "init.sls").read_text( + encoding="utf-8" + ) + assert '- fullname: "Node"' in users_sls + assert "Node,,," not in users_sls + assert "optional_groups" not in users_sls + assert "- remove_groups: false" in users_sls + + +def test_manifest_salt_fqdn_user_pillar_gecos_and_groups_are_salt_safe(tmp_path: Path): + bundle = tmp_path / "bundle" + out = tmp_path / "salt" + state = _sample_state() + state["roles"]["users"]["users"][0]["gecos"] = "Node,,," + _write_sample_artifacts(bundle) + _write_state(bundle, state) + + manifest.manifest(str(bundle), str(out), target="salt", fqdn="node.example") + + pillar_top = yaml.safe_load( + (out / "pillar" / "top.sls").read_text(encoding="utf-8") + ) + node_sls = pillar_top["base"]["node.example"][0] + pillar_path = out / "pillar" / Path(*node_sls.split(".")) + data = yaml.safe_load(pillar_path.with_suffix(".sls").read_text(encoding="utf-8")) + alice = data["enroll"]["roles"]["users"]["users"]["alice"] + assert alice["fullname"] == "Node" + assert "Node,,," not in pillar_path.with_suffix(".sls").read_text(encoding="utf-8") + assert alice["remove_groups"] is False + assert "optional_groups" not in pillar_path.with_suffix(".sls").read_text( + encoding="utf-8" + ) + + users_sls = (out / "states" / "roles" / "users" / "init.sls").read_text( + encoding="utf-8" + ) + assert "optional_groups" not in users_sls + assert "remove_groups" in users_sls + + +def test_cli_manifest_target_salt_is_forwarded(monkeypatch, tmp_path): + import sys + + import enroll.cli as cli + + 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 / "salt"), + "--target", + "salt", + ], + ) + + cli.main() + assert called["harvest"] == str(tmp_path / "bundle") + assert called["out"] == str(tmp_path / "salt") + assert called["target"] == "salt" + + +def test_manifest_salt_renders_container_images_in_single_and_fqdn_modes( + tmp_path: Path, +): + digest = "docker.io/library/nginx@sha256:" + "a" * 64 + podman_digest = "quay.io/example/app@sha256:" + "b" * 64 + state = _sample_state() + state["roles"]["container_images"] = { + "role_name": "container_images", + "images": [ + { + "engine": "docker", + "scope": "system", + "user": None, + "home": None, + "image_id": "sha256:" + "c" * 64, + "repo_tags": ["docker.io/library/nginx:1.27"], + "repo_digests": [digest], + "pull_ref": digest, + "tag_aliases": [ + { + "ref": "docker.io/library/nginx:1.27", + "repository": "docker.io/library/nginx", + "tag": "1.27", + } + ], + "os": "linux", + "architecture": "amd64", + "variant": None, + "platform": "linux/amd64", + "size": 123, + "created": "2026-01-01T00:00:00Z", + "source": "docker image inspect", + "notes": [], + }, + { + "engine": "podman", + "scope": "system", + "user": None, + "home": None, + "image_id": "sha256:" + "d" * 64, + "repo_tags": ["quay.io/example/app:prod"], + "repo_digests": [podman_digest], + "pull_ref": podman_digest, + "tag_aliases": [ + { + "ref": "quay.io/example/app:prod", + "repository": "quay.io/example/app", + "tag": "prod", + } + ], + "os": "linux", + "architecture": "amd64", + "variant": None, + "platform": "linux/amd64", + "size": 456, + "created": "2026-01-01T00:00:00Z", + "source": "podman image inspect", + "notes": [], + }, + ], + "notes": [], + } + bundle = tmp_path / "bundle" + out = tmp_path / "salt" + _write_sample_artifacts(bundle) + _write_state(bundle, state) + + manifest.manifest(str(bundle), str(out), target="salt") + + top = yaml.safe_load((out / "states" / "top.sls").read_text(encoding="utf-8")) + assert "roles.container_images" in top["base"]["*"] + sls = (out / "states" / "roles" / "container_images" / "init.sls").read_text( + encoding="utf-8" + ) + assert "docker_image.present:" in sls + assert digest in sls + assert "docker tag" in sls + assert "podman pull" in sls + assert "podman tag" in sls + + fqdn_out = tmp_path / "salt-fqdn" + manifest.manifest(str(bundle), str(fqdn_out), target="salt", fqdn="node.example") + pillar_top = yaml.safe_load( + (fqdn_out / "pillar" / "top.sls").read_text(encoding="utf-8") + ) + node_sls = pillar_top["base"]["node.example"][0] + pillar_path = fqdn_out / "pillar" / Path(*node_sls.split(".")) + pillar = yaml.safe_load(pillar_path.with_suffix(".sls").read_text(encoding="utf-8")) + assert ( + pillar["enroll"]["roles"]["container_images"]["container_images"][0]["pull_ref"] + == digest + ) + fqdn_sls = ( + fqdn_out / "states" / "roles" / "container_images" / "init.sls" + ).read_text(encoding="utf-8") + assert "docker_image.present:" in fqdn_sls + assert "enroll_podman_pull_container_images" in fqdn_sls + assert "image.get('pull_cmd')" in fqdn_sls + assert "podman pull" in pillar_path.with_suffix(".sls").read_text(encoding="utf-8") From adfeb21d4bf1c1db2db523cfef244a98e9363cd6 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Thu, 18 Jun 2026 20:35:38 +1000 Subject: [PATCH 2/2] reintroduce Salt --- CHANGELOG.md | 5 ++-- README.md | 8 +++++- enroll/salt.py | 56 ++++++++++++++++++++++++++++--------- tests/test_manifest_salt.py | 17 +++++++++-- 4 files changed, 67 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 61117e7..aa8b32c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,9 +5,10 @@ * 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! + * Support manifesting Salt code, as well as Ansible and Puppet! * A lot of under-the-bonnet refactoring to make it easier to extend to cover other config managers (that don't suck) in future. - * Support for detecting Docker images - * Add support for detecting flatpaks and snaps (manifests Ansible code only, not Puppet at this time) + * Support for detecting Docker images. You will need to install puppetlabs-docker module if you're using the Puppet manifester. + * Add support for detecting flatpaks and snaps (manifests Ansible code only, not Puppet or Salt at this time) # 0.6.0 diff --git a/README.md b/README.md index ee20805..dea70df 100644 --- a/README.md +++ b/README.md @@ -488,7 +488,13 @@ cd /tmp/enroll-salt sudo salt-call --local --file-root ./states --pillar-root ./pillar --id host.example.com state.apply test=True ``` -Re-running Salt `--fqdn` output into the same directory adds or replaces that minion's top/pillar data without deleting other generated minions. Docker images with registry digests are rendered with Salt's native `docker_image.present` state. Podman images with registry digests are rendered as guarded `podman pull` / `podman tag` command states. Images without `RepoDigest` are recorded in harvest state and notes, but are not converted into exact pull states. Flatpak, Snap, and live firewall runtime snapshots are listed as notes in the generated Salt README rather than converted into Salt states. +Re-running Salt `--fqdn` output into the same directory adds or replaces that minion's top/pillar data without deleting other generated minions. + +Docker and Podman images with registry digests are rendered as guarded `cmd.run` states that use the local `docker`/`podman` CLI directly (`pull`, `image inspect`, and `tag`). + +This is because Salt Stack, in 3008, does not have proper Docker extensions that actually work. Wow. + +Certain other things, like in Puppet, are not 'manifested' into Salt states unlike Ansible, at this time: these are Flatpak, Snap, and live firewall rules. ### Manifest with `--sops` ```bash diff --git a/enroll/salt.py b/enroll/salt.py index c778497..606c3fc 100644 --- a/enroll/salt.py +++ b/enroll/salt.py @@ -119,7 +119,9 @@ class SaltRole(CMModule): for alias in item["tag_aliases"]: alias_ref = str(alias.get("ref") or "") alias["tag_cmd"] = _container_tag_cmd(engine, pull_ref, alias_ref) - alias["tag_unless"] = _container_exists_cmd(engine, alias_ref) + alias["tag_unless"] = _container_tag_matches_cmd( + engine, pull_ref, alias_ref + ) self.container_images.append(item) for note in snap.get("notes", []) or []: self.notes.append(str(note)) @@ -239,6 +241,34 @@ def _container_exists_cmd(engine: str, ref: str) -> str: return f"docker image inspect {_shell_quote(ref)} >/dev/null 2>&1" +def _container_image_id_expr(engine: str, ref: str) -> str: + """Return a shell expression that extracts an inspected image ID. + + Salt renders SLS files through Jinja before YAML, so Docker's normal + format template cannot be emitted literally without careful escaping. Use + JSON output plus sed instead; it avoids Go-template braces in generated + Salt states and pillar data. + """ + + sed_id = ( + r"sed -n 's/^[[:space:]]*\"Id\":[[:space:]]*\"\([^\"]*\)\".*/\1/p' " + r"| head -n 1" + ) + return ( + f"{_shell_quote(engine)} image inspect {_shell_quote(ref)} " + f"2>/dev/null | {sed_id}" + ) + + +def _container_tag_matches_cmd(engine: str, pull_ref: str, tag_ref: str) -> str: + """Return a shell guard that is true only when tag_ref points at pull_ref.""" + + return ( + f'test "$({_container_image_id_expr(engine, tag_ref)})" ' + f'= "$({_container_image_id_expr(engine, pull_ref)})"' + ) + + def _container_tag_cmd(engine: str, pull_ref: str, tag_ref: str) -> str: return f"{engine} tag {_shell_quote(pull_ref)} {_shell_quote(tag_ref)}" @@ -581,13 +611,13 @@ def _render_static_role(srole: SaltRole) -> str: if not engine or not pull_ref: continue if engine == "docker": - pull_state_id = _state_id("docker_image", pull_ref, role=srole.module_name) + pull_state_id = _state_id("docker_pull", pull_ref, role=srole.module_name) lines.extend( [ f"{pull_state_id}:", - " docker_image.present:", - f" - name: {_yaml_quote(pull_ref)}", - " - force: false", + " cmd.run:", + f" - name: {_yaml_quote(image.get('pull_cmd') or _container_pull_cmd(engine, pull_ref))}", + f" - unless: {_yaml_quote(image.get('pull_unless') or _container_exists_cmd(engine, pull_ref))}", "", ] ) @@ -600,9 +630,9 @@ def _render_static_role(srole: SaltRole) -> str: f"{_state_id('docker_tag', tag_ref, role=srole.module_name)}:", " cmd.run:", f" - name: {_yaml_quote(alias.get('tag_cmd') or _container_tag_cmd(engine, pull_ref, tag_ref))}", - f" - unless: {_yaml_quote(alias.get('tag_unless') or _container_exists_cmd(engine, tag_ref))}", + f" - unless: {_yaml_quote(alias.get('tag_unless') or _container_tag_matches_cmd(engine, pull_ref, tag_ref))}", " - require:", - f" - docker_image: {pull_state_id}", + f" - cmd: {pull_state_id}", "", ] ) @@ -815,10 +845,10 @@ def _render_pillar_role(srole: SaltRole) -> str: "", "{% for image in role.get('container_images', []) %}", "{% if image.get('engine') == 'docker' and image.get('pull_ref') %}", - f"enroll_docker_image_{role_key}_{{{{ loop.index }}}}:", - " docker_image.present:", - " - name: {{ image.get('pull_ref')|yaml_dquote }}", - " - force: false", + f"enroll_docker_pull_{role_key}_{{{{ loop.index }}}}:", + " cmd.run:", + " - name: {{ image.get('pull_cmd')|yaml_dquote }}", + " - unless: {{ image.get('pull_unless')|yaml_dquote }}", "{% set image_loop = loop.index %}", "{% for alias in image.get('tag_aliases', []) %}", f"enroll_docker_tag_{role_key}_{{{{ image_loop }}}}_{{{{ loop.index }}}}:", @@ -826,7 +856,7 @@ def _render_pillar_role(srole: SaltRole) -> str: " - name: {{ alias.get('tag_cmd')|yaml_dquote }}", " - unless: {{ alias.get('tag_unless')|yaml_dquote }}", " - require:", - f" - docker_image: enroll_docker_image_{role_key}_{{{{ image_loop }}}}", + f" - cmd: enroll_docker_pull_{role_key}_{{{{ image_loop }}}}", "{% endfor %}", "{% elif image.get('engine') == 'podman' and image.get('pull_ref') %}", f"enroll_podman_pull_{role_key}_{{{{ loop.index }}}}:", @@ -1033,7 +1063,7 @@ This Salt target reuses the existing harvest state without changing harvesting b - Managed directories, files, and symlinks from harvested roles. - Basic service enablement/running-state resources. - `/etc/sysctl.d/99-enroll.conf` plus an `onchanges` sysctl apply command when present. -- Docker images by digest using Salt's native `docker_image.present` state. +- Docker images by digest using guarded `docker pull` / `docker tag` command states. - Podman images by digest using guarded `podman pull` / `podman tag` command states. ## Current limitations diff --git a/tests/test_manifest_salt.py b/tests/test_manifest_salt.py index 6111a27..b912cdb 100644 --- a/tests/test_manifest_salt.py +++ b/tests/test_manifest_salt.py @@ -430,9 +430,14 @@ def test_manifest_salt_renders_container_images_in_single_and_fqdn_modes( sls = (out / "states" / "roles" / "container_images" / "init.sls").read_text( encoding="utf-8" ) - assert "docker_image.present:" in sls + assert "docker_image.present:" not in sls + assert "docker pull" in sls assert digest in sls + assert "docker image inspect" in sls + assert "{{.Id}}" not in sls + assert "sed -n" in sls assert "docker tag" in sls + assert "- cmd: enroll_docker_pull_container_images" in sls assert "podman pull" in sls assert "podman tag" in sls @@ -451,7 +456,13 @@ def test_manifest_salt_renders_container_images_in_single_and_fqdn_modes( fqdn_sls = ( fqdn_out / "states" / "roles" / "container_images" / "init.sls" ).read_text(encoding="utf-8") - assert "docker_image.present:" in fqdn_sls + assert "docker_image.present:" not in fqdn_sls + assert "enroll_docker_pull_container_images" in fqdn_sls assert "enroll_podman_pull_container_images" in fqdn_sls assert "image.get('pull_cmd')" in fqdn_sls - assert "podman pull" in pillar_path.with_suffix(".sls").read_text(encoding="utf-8") + pillar_text = pillar_path.with_suffix(".sls").read_text(encoding="utf-8") + assert "docker pull" in pillar_text + assert "docker image inspect" in pillar_text + assert "{{.Id}}" not in pillar_text + assert "sed -n" in pillar_text + assert "podman pull" in pillar_text