From adfeb21d4bf1c1db2db523cfef244a98e9363cd6 Mon Sep 17 00:00:00 2001 From: Miguel Jacq Date: Thu, 18 Jun 2026 20:35:38 +1000 Subject: [PATCH] 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