Compare commits

..

No commits in common. "adfeb21d4bf1c1db2db523cfef244a98e9363cd6" and "02feff014f433bffa6afc7efe6f636c7a02f7358" have entirely different histories.

11 changed files with 17 additions and 1725 deletions

View file

@ -13,14 +13,10 @@ 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 \
salt-master salt-minion salt-ssh salt-syndic salt-cloud salt-api
puppet hiera
- name: Install Poetry
run: |

View file

@ -5,10 +5,9 @@
* 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. 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)
* Support for detecting Docker images
* Add support for detecting flatpaks and snaps (manifests Ansible code only, not Puppet at this time)
# 0.6.0

View file

@ -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 or `--target salt` for Salt output.
Generate configuration-management output from an existing harvest bundle. Ansible remains the default; use `--target puppet` for Puppet output.
**Inputs**
- `--harvest /path/to/harvest` (directory)
@ -129,12 +129,11 @@ 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|salt`: choose the manifest target (`ansible` is the default).
- `--fqdn <host>`: enables **multi-site** output style for Ansible, emits Puppet Hiera/node output, or emits Salt top/pillar output targeted at that minion ID. Without `--fqdn`, Puppet emits `node default { ... }` and Salt targets `*` in `states/top.sls`.
- `--target ansible|puppet`: choose the manifest target (`ansible` is the default).
- `--fqdn <host>`: enables **multi-site** output style for Ansible or emits Puppet Hiera/node output. Without `--fqdn`, Puppet emits `node default { ... }`.
- `--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**
@ -468,34 +467,6 @@ 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/<role>/files/nodes/<fqdn>/...` and referenced through `salt://` URLs:
```bash
enroll manifest --harvest /tmp/enroll-harvest --out /tmp/enroll-salt --target salt --fqdn host.example.com
cd /tmp/enroll-salt
sudo salt-call --local --file-root ./states --pillar-root ./pillar --id host.example.com state.apply test=True
```
Re-running Salt `--fqdn` output into the same directory adds or replaces that minion's top/pillar data without deleting other generated minions.
Docker and Podman images with registry digests are rendered as guarded `cmd.run` states that use the local `docker`/`podman` CLI directly (`pull`, `image inspect`, and `tag`).
This is because Salt Stack, in 3008, does not have proper Docker extensions that actually work. Wow.
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
# Generate encrypted manifest bundle (writes /tmp/enroll-ansible/manifest.tar.gz.sops)

View file

@ -29,8 +29,8 @@ from ..yamlutil import _merge_mappings_overwrite, _yaml_load_mapping
class AnsibleManagedFileRoleSpec:
"""Declarative managed-file singleton role rendering spec.
Puppet and Salt collect these singleton snapshots in a simple loop and feed
each one through the same managed-content renderer. Ansible has more
Puppet collects these singleton snapshots in a simple loop and feeds
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/Salt."""
"""Render file-centric singleton roles in the same loop style as Puppet."""
for spec in MANAGED_FILE_ROLE_SPECS:
snapshot = roles.get(spec.key, {})

View file

@ -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 and Salt use provider-backed package resources instead of selecting
apt/dnf/yum in the generated manifest. Ansible's package module is the
Puppet uses 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.
"""

View file

@ -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", "salt"],
choices=["ansible", "puppet"],
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, Puppet Hiera, or Salt pillar).",
help="Host FQDN/name for site-mode output (creates target-specific host inventory/data such as Ansible host_vars or Puppet Hiera).",
)
p.add_argument(
"--no-common-roles",

View file

@ -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, Salt states, etc.
renderer can turn into Ansible tasks, Puppet resources, 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 and Salt compile a single resource catalog. Ansible can tolerate the
same package, service, or parent directory appearing in more than one role;
Puppet compiles 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.
"""

View file

@ -9,7 +9,6 @@ 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,
@ -191,7 +190,7 @@ def manifest(
- In plain mode: None
"""
target = (target or "ansible").strip().lower()
if target not in {"ansible", "puppet", "salt"}:
if target not in {"ansible", "puppet"}:
raise ValueError(f"unsupported manifest target: {target!r}")
sops_mode = bool(sops_fingerprints)
@ -211,13 +210,6 @@ 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,
@ -246,13 +238,6 @@ 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,

File diff suppressed because it is too large Load diff

View file

@ -20,8 +20,6 @@ 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() {
@ -107,13 +105,6 @@ 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}"
@ -179,25 +170,6 @@ 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
@ -205,7 +177,6 @@ main() {
prepare_harvest_fixture
run_ansible_noop_tests
run_puppet_noop_tests
run_salt_noop_tests
}
main "$@"

View file

@ -1,468 +0,0 @@
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:" 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
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:" 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
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