Support for detecting Docker images
Some checks failed
Lint / test (push) Waiting to run
CI / test (push) Has been cancelled

This commit is contained in:
Miguel Jacq 2026-06-17 18:05:02 +10:00
parent e2be9a6239
commit ebc27e1111
Signed by: mig5
GPG key ID: 03906B4110AAD3B8
19 changed files with 1600 additions and 15 deletions

View file

@ -6,8 +6,8 @@
* Detect active sysctl parameters and write them to a `/etc/sysctl.d/99-enroll.conf` file * 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 * 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 Puppet code, as well as Ansible!
* Support manifesting Salt code as well! * A lot of under-the-bonnet refactoring to make it easier to extend to cover other config managers (that don't suck) in future.
* A lot of under-the-bonnet refactoring to make it easier to extend to cover other config managers in future. * Support for detecting Docker images
# 0.6.0 # 0.6.0

View file

@ -441,6 +441,13 @@ enroll manifest --harvest /tmp/enroll-harvest --out /tmp/enroll-ansible
enroll manifest --harvest /tmp/enroll-harvest --out /tmp/enroll-ansible --fqdn "$(hostname -f)" enroll manifest --harvest /tmp/enroll-harvest --out /tmp/enroll-ansible --fqdn "$(hostname -f)"
``` ```
### Container image caches
If Docker or Podman is available during harvest, Enroll records local image-cache metadata from `image ls` and `image inspect`. Images that expose registry `RepoDigest` values are reproducible by digest, for example `registry.example.net/app@sha256:...`; those are the references rendered into manifests. Local image IDs and tag-only images are preserved as evidence and notes, but are not treated as exact registry pull references.
For Ansible, digest-pinned Docker images are pulled with `community.docker.docker_image_pull` and digest-pinned Podman images are pulled with `containers.podman.podman_image`; harvested tag aliases are re-applied where possible. The generated `requirements.yml` includes `community.docker` and `containers.podman` alongside any other required collections. In `--fqdn` mode the image list is host-specific inventory data.
### Puppet target ### Puppet target
```bash ```bash
enroll manifest --harvest /tmp/enroll-harvest --out /tmp/enroll-puppet --target puppet enroll manifest --harvest /tmp/enroll-harvest --out /tmp/enroll-puppet --target puppet
@ -459,7 +466,7 @@ Or with absolute paths:
sudo puppet apply --modulepath /tmp/enroll-puppet/modules /tmp/enroll-puppet/manifests/site.pp --noop sudo puppet apply --modulepath /tmp/enroll-puppet/modules /tmp/enroll-puppet/manifests/site.pp --noop
``` ```
Flatpak, Snap, and live firewall runtime snapshots are listed as notes in the generated Puppet README rather than converted into Puppet resources. 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 ### Salt target
```bash ```bash
@ -481,7 +488,7 @@ cd /tmp/enroll-salt
sudo salt-call --local --file-root ./states --pillar-root ./pillar --id host.example.com state.apply test=True 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. 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 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` ### Manifest with `--sops`
```bash ```bash

View file

@ -9,6 +9,7 @@ from .ansible_renderer.model import (
AnsibleRole, AnsibleRole,
_collect_ansible_roles, _collect_ansible_roles,
) )
from .ansible_renderer.roles.container_images import _render_container_images_role
from .ansible_renderer.roles.desktop import _render_flatpak_role, _render_snap_role from .ansible_renderer.roles.desktop import _render_flatpak_role, _render_snap_role
from .ansible_renderer.roles.managed_files import _render_managed_file_roles from .ansible_renderer.roles.managed_files import _render_managed_file_roles
from .ansible_renderer.roles.packages import ( from .ansible_renderer.roles.packages import (
@ -67,6 +68,9 @@ class AnsibleManifestRenderer:
_render_users_role(ctx, manifest_plan, roles.get("users", {})) _render_users_role(ctx, manifest_plan, roles.get("users", {}))
_render_flatpak_role(ctx, manifest_plan, roles.get("flatpak", {})) _render_flatpak_role(ctx, manifest_plan, roles.get("flatpak", {}))
_render_snap_role(ctx, manifest_plan, roles.get("snap", {})) _render_snap_role(ctx, manifest_plan, roles.get("snap", {}))
_render_container_images_role(
ctx, manifest_plan, roles.get("container_images", {})
)
_render_managed_file_roles(ctx, manifest_plan, roles) _render_managed_file_roles(ctx, manifest_plan, roles)
_render_sysctl_role(ctx, manifest_plan, roles.get("sysctl", {})) _render_sysctl_role(ctx, manifest_plan, roles.get("sysctl", {}))
_render_firewall_runtime_role( _render_firewall_runtime_role(

View file

@ -150,14 +150,62 @@ def _ensure_ansible_cfg(cfg_path: str) -> None:
return return
def _ensure_requirements_yaml(req_path: str) -> None: def _ensure_requirements_yaml(
if not os.path.exists(req_path): req_path: str,
with open(req_path, "w", encoding="utf-8") as f: collections: Optional[List[Dict[str, str]]] = None,
f.write("---\n") ) -> None:
f.write("collections:\n") requested = collections or [{"name": "community.general", "version": ">=13.0.0"}]
f.write(" - name: community.general\n")
f.write(' version: ">=13.0.0"\n') existing: Dict[str, Any] = {}
return if os.path.exists(req_path):
try:
existing = _yaml_load_mapping(Path(req_path).read_text(encoding="utf-8"))
except Exception:
existing = {}
current_items = existing.get("collections")
if not isinstance(current_items, list):
current_items = []
by_name: Dict[str, Dict[str, str]] = {}
ordered_names: List[str] = []
for item in current_items:
if isinstance(item, str):
name = item.strip()
if not name:
continue
entry: Dict[str, str] = {"name": name}
elif isinstance(item, dict):
name = str(item.get("name") or "").strip()
if not name:
continue
entry = {str(k): str(v) for k, v in item.items() if v is not None}
entry["name"] = name
else:
continue
if name not in by_name:
ordered_names.append(name)
by_name[name] = entry
for item in requested:
name = str(item.get("name") or "").strip()
if not name:
continue
entry = dict(item)
entry["name"] = name
if name not in by_name:
ordered_names.append(name)
by_name[name] = entry
else:
by_name[name].update(
{k: v for k, v in entry.items() if v not in (None, "")}
)
out = {"collections": [by_name[name] for name in ordered_names]}
Path(req_path).parent.mkdir(parents=True, exist_ok=True)
Path(req_path).write_text(
"---\n" + _yaml_dump_mapping(out, sort_keys=False), encoding="utf-8"
)
def _ensure_inventory_host(inv_path: str, fqdn: str) -> None: def _ensure_inventory_host(inv_path: str, fqdn: str) -> None:

View file

@ -125,6 +125,7 @@ class AnsibleManifestPlan:
"extra_paths", "extra_paths",
"flatpak", "flatpak",
"snap", "snap",
"container_images",
"users", "users",
"tail_package", "tail_package",
"sysctl", "sysctl",

View file

@ -0,0 +1,192 @@
from __future__ import annotations
import os
from typing import Any, Dict, List
from ..context import AnsibleManifestContext
from ..layout import (
_ensure_requirements_yaml,
_write_hostvars,
_write_role_defaults,
_write_role_scaffold,
)
from ..model import AnsibleManifestPlan
from ..vars import _normalise_container_image_item
_CONTAINER_COLLECTIONS = [
{"name": "community.docker", "version": ">=4.0.0"},
{"name": "containers.podman", "version": ">=1.0.0"},
]
def _render_container_images_role(
ctx: AnsibleManifestContext,
manifest_plan: AnsibleManifestPlan,
container_images_snapshot: Dict[str, Any],
) -> None:
raw_images = container_images_snapshot.get("images", []) or []
if not container_images_snapshot and not raw_images:
return
images = [_normalise_container_image_item(img) for img in raw_images]
if not images and not (container_images_snapshot.get("notes") or []):
return
role = container_images_snapshot.get("role_name", "container_images")
role_dir = os.path.join(ctx.roles_root, role)
_write_role_scaffold(role_dir)
_ensure_requirements_yaml(
os.path.join(ctx.out_dir, "requirements.yml"), _CONTAINER_COLLECTIONS
)
vars_map = {"container_images": images}
if ctx.site_mode:
_write_role_defaults(role_dir, {"container_images": []})
_write_hostvars(ctx.out_dir, ctx.fqdn or "", role, vars_map)
else:
_write_role_defaults(role_dir, vars_map)
with open(os.path.join(role_dir, "meta", "main.yml"), "w", encoding="utf-8") as f:
f.write(
"---\n"
"dependencies: []\n"
"collections:\n"
" - community.docker\n"
" - containers.podman\n"
)
tasks = """---
- name: Pull Docker images by immutable registry digest
community.docker.docker_image_pull:
name: "{{ item.pull_ref }}"
pull: not_present
platform: "{{ item.platform | default(omit, true) }}"
loop: "{{ container_images | default([]) | selectattr('engine', 'equalto', 'docker') | selectattr('pull_ref', 'defined') | list }}"
when:
- item.pull_ref | default('') | length > 0
become: true
- name: Tag Docker images with harvested tag aliases
community.docker.docker_image_tag:
name: "{{ item.0.pull_ref }}"
repository:
- "{{ item.1.ref }}"
loop: "{{ query('subelements', container_images | default([]) | selectattr('engine', 'equalto', 'docker') | selectattr('pull_ref', 'defined') | list, 'tag_aliases', {'skip_missing': True}) }}"
when:
- item.0.pull_ref | default('') | length > 0
- item.1.repository | default('') | length > 0
- item.1.tag | default('') | length > 0
become: true
- name: Pull system Podman images by immutable registry digest
containers.podman.podman_image:
name: "{{ item.pull_ref }}"
state: present
force: false
platform: "{{ item.platform | default(omit, true) }}"
loop: "{{ container_images | default([]) | selectattr('engine', 'equalto', 'podman') | rejectattr('scope', 'equalto', 'user') | selectattr('pull_ref', 'defined') | list }}"
when:
- item.pull_ref | default('') | length > 0
become: true
- name: Tag system Podman images with harvested tag aliases
containers.podman.podman_tag:
image: "{{ item.0.pull_ref }}"
target_names:
- "{{ item.1.ref }}"
loop: "{{ query('subelements', container_images | default([]) | selectattr('engine', 'equalto', 'podman') | rejectattr('scope', 'equalto', 'user') | selectattr('pull_ref', 'defined') | list, 'tag_aliases', {'skip_missing': True}) }}"
when:
- item.0.pull_ref | default('') | length > 0
- item.1.ref | default('') | length > 0
become: true
- name: Pull user Podman images by immutable registry digest
containers.podman.podman_image:
name: "{{ item.pull_ref }}"
state: present
force: false
platform: "{{ item.platform | default(omit, true) }}"
loop: "{{ container_images | default([]) | selectattr('engine', 'equalto', 'podman') | selectattr('scope', 'equalto', 'user') | selectattr('pull_ref', 'defined') | list }}"
when:
- item.pull_ref | default('') | length > 0
- item.user | default('') | length > 0
become: true
become_user: "{{ item.user }}"
- name: Tag user Podman images with harvested tag aliases
containers.podman.podman_tag:
image: "{{ item.0.pull_ref }}"
target_names:
- "{{ item.1.ref }}"
loop: "{{ query('subelements', container_images | default([]) | selectattr('engine', 'equalto', 'podman') | selectattr('scope', 'equalto', 'user') | selectattr('pull_ref', 'defined') | list, 'tag_aliases', {'skip_missing': True}) }}"
when:
- item.0.pull_ref | default('') | length > 0
- item.0.user | default('') | length > 0
- item.1.ref | default('') | length > 0
become: true
become_user: "{{ item.0.user }}"
"""
with open(os.path.join(role_dir, "tasks", "main.yml"), "w", encoding="utf-8") as f:
f.write(tasks)
with open(
os.path.join(role_dir, "handlers", "main.yml"), "w", encoding="utf-8"
) as f:
f.write("---\n")
def _fmt_image(img: Dict[str, Any]) -> str:
pull_ref = (
img.get("pull_ref") or "(no registry digest; not rendered as an exact pull)"
)
tags = img.get("repo_tags") or []
tag_part = f" tags={', '.join(tags)}" if tags else ""
platform = img.get("platform")
platform_part = f" platform={platform}" if platform else ""
return f"- {img.get('engine', 'unknown')}: {pull_ref}{tag_part}{platform_part}"
notes = list(container_images_snapshot.get("notes", []) or [])
unpinned_notes: List[str] = []
for img in images:
if img.get("pull_ref"):
continue
label = (
", ".join(img.get("repo_tags") or [])
or img.get("image_id")
or "unknown image"
)
unpinned_notes.append(
f"{label}: no RepoDigest was available, so no exact pull task is emitted."
)
readme = (
"""# container_images
Generated Docker and Podman image-cache restoration role.
Images are pulled by immutable registry digest, such as
`registry.example.net/app@sha256:...`, when the harvest found a usable
`RepoDigest`. Local image IDs are recorded in `state.json` for evidence but are
not registry pull references.
**Note:** This role requires the `community.docker` and `containers.podman`
Ansible collections. Install them with:
`ansible-galaxy collection install -r requirements.yml`.
Registry credentials are not harvested. Private-registry authentication must be
managed separately before this role runs.
## Container images
"""
+ "\n".join(_fmt_image(img) for img in images)
+ """
## Notes
"""
+ ("\n".join([f"- {n}" for n in notes + unpinned_notes]) or "- (none)")
+ "\n"
)
with open(os.path.join(role_dir, "README.md"), "w", encoding="utf-8") as f:
f.write(readme)
manifest_plan.add("container_images", role)

View file

@ -133,3 +133,19 @@ def _build_managed_links_var(
continue continue
out.append({"dest": dest, "src": src}) out.append({"dest": dest, "src": src})
return out return out
def _normalise_container_image_item(item: Any) -> Dict[str, Any]:
if isinstance(item, dict):
out = dict(item)
else:
out = {"pull_ref": str(item)}
out.setdefault("engine", "docker")
out.setdefault("scope", "system")
out.setdefault("user", None)
out.setdefault("home", None)
out.setdefault("repo_tags", [])
out.setdefault("repo_digests", [])
out.setdefault("tag_aliases", [])
out.setdefault("notes", [])
return out

View file

@ -195,6 +195,7 @@ def role_order_key(role: str) -> tuple[int, str]:
"etc_custom": 80, "etc_custom": 80,
"usr_local_custom": 81, "usr_local_custom": 81,
"extra_paths": 82, "extra_paths": 82,
"container_images": 88,
"users": 90, "users": 90,
"sysctl": 95, "sysctl": 95,
"firewall_runtime": 99, "firewall_runtime": 99,

View file

@ -542,6 +542,7 @@ def harvest(
# includes are harvested into an extra role. # includes are harvested into an extra role.
path_filter = PathFilter(include=include_paths or (), exclude=exclude_paths or ()) path_filter = PathFilter(include=include_paths or (), exclude=exclude_paths or ())
from .harvest_collectors.container_images import ContainerImagesCollector
from .harvest_collectors.cron_logrotate import CronLogrotateCollector from .harvest_collectors.cron_logrotate import CronLogrotateCollector
from .harvest_collectors.package_manager import PackageManagerConfigCollector from .harvest_collectors.package_manager import PackageManagerConfigCollector
from .harvest_collectors.paths import ExtraPathsCollector, UsrLocalCustomCollector from .harvest_collectors.paths import ExtraPathsCollector, UsrLocalCustomCollector
@ -646,6 +647,11 @@ def harvest(
flatpak_snapshot = users_collection.flatpak_snapshot flatpak_snapshot = users_collection.flatpak_snapshot
snap_snapshot = users_collection.snap_snapshot snap_snapshot = users_collection.snap_snapshot
# -------------------------
# Container image inventory (Docker/Podman image caches)
# -------------------------
container_images_snapshot = ContainerImagesCollector(context).collect()
# ------------------------- # -------------------------
# Package manager config role # Package manager config role
# - Debian: apt_config # - Debian: apt_config
@ -1015,6 +1021,7 @@ def harvest(
"users": asdict(users_snapshot), "users": asdict(users_snapshot),
"flatpak": asdict(flatpak_snapshot), "flatpak": asdict(flatpak_snapshot),
"snap": asdict(snap_snapshot), "snap": asdict(snap_snapshot),
"container_images": asdict(container_images_snapshot),
"services": [asdict(s) for s in service_snaps], "services": [asdict(s) for s in service_snaps],
"packages": [asdict(p) for p in pkg_snaps], "packages": [asdict(p) for p in pkg_snaps],
"apt_config": asdict(apt_config_snapshot), "apt_config": asdict(apt_config_snapshot),

View file

@ -0,0 +1,251 @@
from __future__ import annotations
import json
import re
import shutil
import subprocess # nosec B404
from collections.abc import (
Iterable,
) # nosec - executes fixed docker/podman command arguments only
from typing import Any, Dict, List, Optional, Sequence, Tuple
from ..harvest_types import ContainerImagesSnapshot
from .context import HarvestCollector
_DIGEST_RE = re.compile(r"@sha256:[0-9A-Fa-f]{32,}")
_SHA_ID_RE = re.compile(r"^(?:sha256:)?[0-9A-Fa-f]{64}$")
def _normalise_image_id(value: Any) -> Optional[str]:
s = str(value or "").strip()
if not s:
return None
if s.startswith("sha256:"):
return s
if _SHA_ID_RE.match(s):
return "sha256:" + s
return s
def _as_string_list(value: Any) -> List[str]:
if not value:
return []
if isinstance(value, str):
values = [value]
elif isinstance(value, Iterable):
values = list(value)
else:
values = [value]
out: List[str] = []
for item in values:
s = str(item or "").strip()
if not s or s in {"<none>", "<none>:<none>"}:
continue
if s not in out:
out.append(s)
return out
def _pullable_digests(value: Any) -> List[str]:
return [s for s in _as_string_list(value) if _DIGEST_RE.search(s)]
def _split_tag_ref(ref: str) -> Optional[Dict[str, str]]:
"""Split an image tag into repository/tag, preserving registry ports."""
s = str(ref or "").strip()
if not s or "@" in s or s == "<none>:<none>":
return None
last_slash = s.rfind("/")
last_colon = s.rfind(":")
if last_colon > last_slash:
repository = s[:last_colon]
tag = s[last_colon + 1 :]
else:
repository = s
tag = "latest"
if not repository or not tag:
return None
return {"ref": s, "repository": repository, "tag": tag}
def _tag_aliases(value: Any) -> List[Dict[str, str]]:
out: List[Dict[str, str]] = []
seen = set()
for ref in _as_string_list(value):
item = _split_tag_ref(ref)
if not item:
continue
key = (item["repository"], item["tag"])
if key in seen:
continue
seen.add(key)
out.append(item)
return out
def _platform_from_inspect(
item: Dict[str, Any],
) -> Tuple[Optional[str], Optional[str], Optional[str], Optional[str]]:
os_name = item.get("Os") or item.get("OS")
arch = item.get("Architecture") or item.get("Arch")
variant = item.get("Variant")
os_s = str(os_name).strip() if os_name not in (None, "") else None
arch_s = str(arch).strip() if arch not in (None, "") else None
variant_s = str(variant).strip() if variant not in (None, "") else None
platform = None
if os_s and arch_s:
platform = f"{os_s}/{arch_s}"
if variant_s:
platform = f"{platform}/{variant_s}"
return os_s, arch_s, variant_s, platform
def _run_command(
argv: Sequence[str], *, timeout: int = 20
) -> subprocess.CompletedProcess[str]:
return subprocess.run( # nosec - argv is constructed from fixed binary names and image ids
list(argv),
check=False,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
timeout=timeout,
)
def _chunks(items: Sequence[str], size: int) -> Iterable[List[str]]:
for i in range(0, len(items), size):
yield list(items[i : i + size])
class ContainerImagesCollector(HarvestCollector):
"""Collect local Docker and Podman image metadata.
The harvest records pullable registry digests where present. Local image IDs
are kept as evidence but are not treated as pull references.
"""
def collect(self) -> ContainerImagesSnapshot:
images: List[Dict[str, Any]] = []
notes: List[str] = []
images.extend(self._collect_engine("docker", notes=notes))
images.extend(self._collect_engine("podman", notes=notes))
if images:
digest_count = len([img for img in images if img.get("pull_ref")])
notes.append(
f"Detected {len(images)} container image(s); {digest_count} have registry digests usable for exact pulls."
)
return ContainerImagesSnapshot(
role_name="container_images",
images=images,
notes=notes,
)
def _collect_engine(self, engine: str, *, notes: List[str]) -> List[Dict[str, Any]]:
exe = shutil.which(engine)
if not exe:
return []
try:
listed = _run_command([exe, "image", "ls", "-q", "--no-trunc"])
except Exception as exc:
notes.append(f"Failed to list {engine} images: {exc!r}")
return []
if listed.returncode != 0:
detail = (listed.stderr or listed.stdout or "").strip()
if detail:
notes.append(f"Failed to list {engine} images: {detail}")
else:
notes.append(
f"Failed to list {engine} images: exit {listed.returncode}"
)
return []
image_ids = []
seen_ids = set()
for line in listed.stdout.splitlines():
image_id = _normalise_image_id(line)
if not image_id or image_id in seen_ids:
continue
seen_ids.add(image_id)
image_ids.append(image_id)
if not image_ids:
return []
out: List[Dict[str, Any]] = []
for chunk in _chunks(image_ids, 40):
try:
inspected = _run_command([exe, "image", "inspect", *chunk])
except Exception as exc:
notes.append(f"Failed to inspect {engine} images: {exc!r}")
continue
if inspected.returncode != 0:
detail = (inspected.stderr or inspected.stdout or "").strip()
notes.append(
f"Failed to inspect {engine} images {', '.join(chunk[:3])}: {detail or inspected.returncode}"
)
continue
try:
data = json.loads(inspected.stdout or "[]")
except json.JSONDecodeError as exc:
notes.append(f"Failed to parse {engine} image inspect JSON: {exc}")
continue
if not isinstance(data, list):
notes.append(f"Unexpected {engine} image inspect JSON shape")
continue
for item in data:
if isinstance(item, dict):
normalised = self._normalise_inspect(engine, item)
if normalised is not None:
out.append(normalised)
return out
def _normalise_inspect(
self, engine: str, item: Dict[str, Any]
) -> Optional[Dict[str, Any]]:
image_id = _normalise_image_id(item.get("Id") or item.get("ID"))
repo_tags = _as_string_list(item.get("RepoTags"))
repo_digests = _pullable_digests(item.get("RepoDigests"))
pull_ref = sorted(repo_digests)[0] if repo_digests else None
os_name, arch, variant, platform = _platform_from_inspect(item)
if not image_id and not repo_tags and not repo_digests:
return None
notes: List[str] = []
if not pull_ref:
if repo_tags:
notes.append(
"Image has tag(s) but no RepoDigest; exact digest-pinned pull cannot be rendered."
)
else:
notes.append(
"Image has no tag or RepoDigest; local-only/dangling images cannot be pulled from a registry."
)
out: Dict[str, Any] = {
"engine": engine,
"scope": "system",
"user": None,
"home": None,
"image_id": image_id,
"repo_tags": repo_tags,
"repo_digests": repo_digests,
"pull_ref": pull_ref,
"tag_aliases": _tag_aliases(repo_tags),
"os": os_name,
"architecture": arch,
"variant": variant,
"platform": platform,
"size": item.get("Size"),
"created": item.get("Created"),
"source": f"{engine} image inspect",
"notes": notes,
}
return out

View file

@ -98,6 +98,13 @@ class SnapSnapshot:
notes: List[str] = field(default_factory=list) notes: List[str] = field(default_factory=list)
@dataclass
class ContainerImagesSnapshot:
role_name: str
images: List[Dict[str, Any]] = field(default_factory=list)
notes: List[str] = field(default_factory=list)
@dataclass @dataclass
class AptConfigSnapshot: class AptConfigSnapshot:
role_name: str role_name: str

View file

@ -1,7 +1,9 @@
from __future__ import annotations from __future__ import annotations
import hashlib
import json import json
import re import re
import shlex
import shutil import shutil
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional, Set, Tuple from typing import Any, Dict, Iterable, List, Optional, Set, Tuple
@ -26,6 +28,10 @@ class PuppetRole(CMModule):
role_name=role_name, role_name=role_name,
module_name=_puppet_name(role_name, fallback="enroll_role"), module_name=_puppet_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)
def add_package_snapshot(self, snap: Dict[str, Any]) -> None: def add_package_snapshot(self, snap: Dict[str, Any]) -> None:
pkg = str(snap.get("package") or "").strip() pkg = str(snap.get("package") or "").strip()
@ -82,6 +88,43 @@ class PuppetRole(CMModule):
"Per-user Flatpak resources were detected but are not yet rendered as native Puppet resources." "Per-user Flatpak resources were detected but are not yet rendered as native Puppet resources."
) )
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 Puppet pull resource 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"
image_name, image_digest = _split_digest_ref(pull_ref)
item["image"] = image_name
item["image_digest"] = image_digest
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( def add_managed_content(
self, self,
snap: Dict[str, Any], snap: Dict[str, Any],
@ -196,6 +239,32 @@ def _pp_bool(value: bool) -> str:
return "true" if bool(value) else "false" return "true" if bool(value) else "false"
def _shell_quote(value: Any) -> str:
return shlex.quote(str(value or ""))
def _split_digest_ref(value: Any) -> Tuple[str, Optional[str]]:
text = str(value or "").strip()
if "@" not in text:
return text, None
image, digest = text.split("@", 1)
return image, digest
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 _pp_array(values: Iterable[Any]) -> str: def _pp_array(values: Iterable[Any]) -> str:
return "[" + ", ".join(_pp_quote(v) for v in values) + "]" return "[" + ", ".join(_pp_quote(v) for v in values) + "]"
@ -210,6 +279,20 @@ def _resource(
lines.append("") lines.append("")
def _state_title(prefix: str, value: Any) -> str:
safe = re.sub(r"[^A-Za-z0-9_.-]+", "-", str(value or "item")).strip("-._")
if not safe:
safe = "item"
if len(safe) > 64:
digest = hashlib.sha1(
str(value).encode("utf-8", errors="replace")
).hexdigest()[ # nosec B324
:8
]
safe = safe[:48] + "-" + digest
return f"enroll-{prefix}-{safe}"
def _copy_artifact( def _copy_artifact(
bundle_dir: str, bundle_dir: str,
role: str, role: str,
@ -378,6 +461,15 @@ def _collect_puppet_roles(
file_prefix=node_file_prefix, 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")
):
prole = ensure_role(
str(container_images.get("role_name") or "container_images")
)
prole.add_container_images_snapshot(container_images)
fw = roles.get("firewall_runtime") or {} fw = roles.get("firewall_runtime") or {}
if isinstance(fw, dict): if isinstance(fw, dict):
has_fw = ( has_fw = (
@ -496,6 +588,99 @@ def _render_role_class(prole: PuppetRole) -> str:
], ],
) )
for image in prole.container_images:
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":
attrs: List[Tuple[str, str]] = [("ensure", _pp_quote("present"))]
if image.get("image"):
attrs.append(("image", _pp_quote(image["image"])))
if image.get("image_digest"):
attrs.append(("image_digest", _pp_quote(image["image_digest"])))
_resource(lines, "docker::image", pull_ref, attrs)
for alias in image.get("tag_aliases") or []:
tag_ref = str(alias.get("ref") or "").strip()
if not tag_ref:
continue
_resource(
lines,
"exec",
_state_title("docker-tag", tag_ref),
[
(
"command",
_pp_quote(
alias.get("tag_cmd")
or _container_tag_cmd(engine, pull_ref, tag_ref)
),
),
(
"unless",
_pp_quote(
alias.get("tag_unless")
or _container_exists_cmd(engine, tag_ref)
),
),
("path", "['/usr/bin', '/bin']"),
("require", f"Docker::Image[{_pp_quote(pull_ref)}]"),
],
)
elif engine == "podman":
_resource(
lines,
"exec",
_state_title("podman-pull", pull_ref),
[
(
"command",
_pp_quote(
image.get("pull_cmd")
or _container_pull_cmd(engine, pull_ref)
),
),
(
"unless",
_pp_quote(
image.get("pull_unless")
or _container_exists_cmd(engine, pull_ref)
),
),
("path", "['/usr/bin', '/bin']"),
],
)
for alias in image.get("tag_aliases") or []:
tag_ref = str(alias.get("ref") or "").strip()
if not tag_ref:
continue
_resource(
lines,
"exec",
_state_title("podman-tag", tag_ref),
[
(
"command",
_pp_quote(
alias.get("tag_cmd")
or _container_tag_cmd(engine, pull_ref, tag_ref)
),
),
(
"unless",
_pp_quote(
alias.get("tag_unless")
or _container_exists_cmd(engine, tag_ref)
),
),
("path", "['/usr/bin', '/bin']"),
(
"require",
f"Exec[{_pp_quote(_state_title('podman-pull', pull_ref))}]",
),
],
)
if has_sysctl_conf: if has_sysctl_conf:
lines.append(" if $sysctl_apply {") lines.append(" if $sysctl_apply {")
lines.append(" exec { 'enroll-apply-sysctl':") lines.append(" exec { 'enroll-apply-sysctl':")
@ -608,6 +793,9 @@ def _role_hiera_values(prole: PuppetRole) -> Dict[str, Any]:
for name in sorted(prole.services) for name in sorted(prole.services)
} }
if prole.container_images:
data[f"{prefix}container_images"] = list(prole.container_images)
if prole.notes: if prole.notes:
data[f"{prefix}notes"] = list(prole.notes) data[f"{prefix}notes"] = list(prole.notes)
@ -632,6 +820,7 @@ def _render_hiera_role_class(prole: PuppetRole) -> str:
" Hash[String, Hash] $files = {},", " Hash[String, Hash] $files = {},",
" Hash[String, Hash] $links = {},", " Hash[String, Hash] $links = {},",
" Hash[String, Hash] $services = {},", " Hash[String, Hash] $services = {},",
" Array[Hash] $container_images = [],",
" Array[String] $notes = [],", " Array[String] $notes = [],",
" Boolean $sysctl_apply = true,", " Boolean $sysctl_apply = true,",
" Boolean $sysctl_ignore_apply_errors = true,", " Boolean $sysctl_ignore_apply_errors = true,",
@ -679,6 +868,38 @@ def _render_hiera_role_class(prole: PuppetRole) -> str:
" }", " }",
" }", " }",
"", "",
" $container_images.each |Integer $idx, Hash $image| {",
" if $image['engine'] == 'docker' and $image['pull_ref'] {",
" docker::image { $image['pull_ref']:",
" ensure => 'present',",
" image => $image['image'],",
" image_digest => $image['image_digest'],",
" }",
" $image['tag_aliases'].each |Integer $tag_idx, Hash $alias| {",
' exec { "enroll-docker-tag-${idx}-${tag_idx}":',
" command => $alias['tag_cmd'],",
" unless => $alias['tag_unless'],",
" path => ['/usr/bin', '/bin'],",
" require => Docker::Image[$image['pull_ref']],",
" }",
" }",
" } elsif $image['engine'] == 'podman' and $image['pull_ref'] {",
' exec { "enroll-podman-pull-${idx}":',
" command => $image['pull_cmd'],",
" unless => $image['pull_unless'],",
" path => ['/usr/bin', '/bin'],",
" }",
" $image['tag_aliases'].each |Integer $tag_idx, Hash $alias| {",
' exec { "enroll-podman-tag-${idx}-${tag_idx}":',
" command => $alias['tag_cmd'],",
" unless => $alias['tag_unless'],",
" path => ['/usr/bin', '/bin'],",
' require => Exec["enroll-podman-pull-${idx}"],',
" }",
" }",
" }",
" }",
"",
" if $sysctl_apply and '/etc/sysctl.d/99-enroll.conf' in $files {", " if $sysctl_apply and '/etc/sysctl.d/99-enroll.conf' in $files {",
" exec { 'enroll-apply-sysctl':", " exec { 'enroll-apply-sysctl':",
" command => $sysctl_ignore_apply_errors ? {", " command => $sysctl_ignore_apply_errors ? {",
@ -791,7 +1012,16 @@ def _hiera_node_names(out: Path) -> List[str]:
return sorted(out_names) return sorted(out_names)
def _write_metadata(module_dir: Path, module_name: str) -> None: def _write_metadata(module_dir: Path, module_name: str, prole: PuppetRole) -> None:
dependencies: List[Dict[str, str]] = []
if any(img.get("engine") == "docker" for img in prole.container_images):
dependencies.append(
{
"name": "puppetlabs-docker",
"version_requirement": ">= 8.0.0 < 15.0.0",
}
)
(module_dir / "metadata.json").write_text( (module_dir / "metadata.json").write_text(
json.dumps( json.dumps(
{ {
@ -801,7 +1031,7 @@ def _write_metadata(module_dir: Path, module_name: str) -> None:
"summary": f"Generated Enroll Puppet module for {module_name}", "summary": f"Generated Enroll Puppet module for {module_name}",
"license": "UNLICENSED", "license": "UNLICENSED",
"source": "", "source": "",
"dependencies": [], "dependencies": dependencies,
}, },
indent=2, indent=2,
sort_keys=True, sort_keys=True,
@ -890,10 +1120,13 @@ This Puppet target reuses the existing harvest state without changing harvesting
- Managed directories, files, and symlinks from harvested roles. - Managed directories, files, and symlinks from harvested roles.
- Basic service enablement/running-state resources. - Basic service enablement/running-state resources.
- `/etc/sysctl.d/99-enroll.conf` plus a refresh-only sysctl apply exec when present. - `/etc/sysctl.d/99-enroll.conf` plus a refresh-only sysctl apply exec when present.
- Docker images by digest using the `puppetlabs-docker` module's `docker::image` defined type.
- Podman images by digest using guarded `podman pull` / `podman tag` exec resources.
## Current limitations ## Current limitations
- Flatpak, Snap, and live firewall runtime snapshots are listed as notes when present rather than rendered as Puppet resources. - Flatpak, Snap, and live firewall runtime snapshots are listed as notes when present rather than rendered as Puppet resources.
- Docker image resources require the `puppetlabs-docker` module to be installed in the Puppet environment.
- JinjaTurtle templating is currently Ansible-oriented and is not applied to Puppet output. - JinjaTurtle templating is currently Ansible-oriented and is not applied to Puppet output.
- Review generated resources before applying them broadly across unlike hosts. - Review generated resources before applying them broadly across unlike hosts.
@ -958,7 +1191,7 @@ class PuppetManifestRenderer:
), ),
encoding="utf-8", encoding="utf-8",
) )
_write_metadata(module_dir, prole.module_name) _write_metadata(module_dir, prole.module_name, prole)
node_names: List[str] = [] node_names: List[str] = []
if hiera_mode and fqdn: if hiera_mode and fqdn:

View file

@ -4,6 +4,7 @@ RESERVED_SINGLETON_ROLE_NAMES = {
"users", "users",
"flatpak", "flatpak",
"snap", "snap",
"container_images",
"apt_config", "apt_config",
"dnf_config", "dnf_config",
"firewall_runtime", "firewall_runtime",

View file

@ -3,6 +3,7 @@ from __future__ import annotations
import hashlib import hashlib
import json import json
import re import re
import shlex
import shutil import shutil
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
@ -27,6 +28,10 @@ class SaltRole(CMModule):
role_name=role_name, role_name=role_name,
module_name=_salt_name(role_name, fallback="enroll_role"), 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 @property
def sls_name(self) -> str: def sls_name(self) -> str:
@ -85,6 +90,40 @@ class SaltRole(CMModule):
"Per-user Flatpak resources were detected but are not rendered as native Salt states." "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( def add_managed_content(
self, self,
snap: Dict[str, Any], snap: Dict[str, Any],
@ -186,6 +225,24 @@ def _yaml_bool(value: Any) -> str:
return "true" if bool(value) else "false" 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]: def _clean_gecos_part(value: Any) -> Optional[str]:
text = str(value or "").strip() text = str(value or "").strip()
return text or None return text or None
@ -382,6 +439,15 @@ def _collect_salt_roles(
file_prefix=node_file_prefix, 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 {} fw = roles.get("firewall_runtime") or {}
if isinstance(fw, dict): if isinstance(fw, dict):
has_fw = ( has_fw = (
@ -509,6 +575,64 @@ def _render_static_role(srole: SaltRole) -> str:
] ]
) )
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: if "/etc/sysctl.d/99-enroll.conf" in srole.files:
lines.extend( lines.extend(
[ [
@ -600,6 +724,8 @@ def _role_pillar_values(srole: SaltRole) -> Dict[str, Any]:
} }
if "/etc/sysctl.d/99-enroll.conf" in srole.files: if "/etc/sysctl.d/99-enroll.conf" in srole.files:
data["sysctl_apply"] = True data["sysctl_apply"] = True
if srole.container_images:
data["container_images"] = list(srole.container_images)
if srole.notes: if srole.notes:
data["notes"] = list(srole.notes) data["notes"] = list(srole.notes)
return data return data
@ -687,6 +813,38 @@ def _render_pillar_role(srole: SaltRole) -> str:
" - enable: {{ svc.get('enable', False)|yaml_encode }}", " - enable: {{ svc.get('enable', False)|yaml_encode }}",
"{% endfor %}", "{% 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', {}) %}", "{% if role.get('sysctl_apply') and '/etc/sysctl.d/99-enroll.conf' in role.get('files', {}) %}",
f"enroll_apply_sysctl_{role_key}:", f"enroll_apply_sysctl_{role_key}:",
" cmd.run:", " cmd.run:",
@ -875,6 +1033,8 @@ This Salt target reuses the existing harvest state without changing harvesting b
- Managed directories, files, and symlinks from harvested roles. - Managed directories, files, and symlinks from harvested roles.
- Basic service enablement/running-state resources. - Basic service enablement/running-state resources.
- `/etc/sysctl.d/99-enroll.conf` plus an `onchanges` sysctl apply command when present. - `/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 ## Current limitations

View file

@ -16,6 +16,181 @@
], ],
"unevaluatedProperties": false "unevaluatedProperties": false
}, },
"ContainerImageTagAlias": {
"additionalProperties": false,
"properties": {
"ref": {
"minLength": 1,
"type": "string"
},
"repository": {
"minLength": 1,
"type": "string"
},
"tag": {
"minLength": 1,
"type": "string"
}
},
"required": [
"ref",
"repository",
"tag"
],
"type": "object"
},
"ContainerImage": {
"additionalProperties": false,
"properties": {
"architecture": {
"type": [
"string",
"null"
]
},
"created": {
"type": [
"string",
"null"
]
},
"engine": {
"enum": [
"docker",
"podman"
],
"type": "string"
},
"home": {
"type": [
"string",
"null"
]
},
"image_id": {
"type": [
"string",
"null"
]
},
"notes": {
"items": {
"type": "string"
},
"type": "array"
},
"os": {
"type": [
"string",
"null"
]
},
"platform": {
"type": [
"string",
"null"
]
},
"pull_ref": {
"type": [
"string",
"null"
]
},
"repo_digests": {
"items": {
"type": "string"
},
"type": "array"
},
"repo_tags": {
"items": {
"type": "string"
},
"type": "array"
},
"scope": {
"enum": [
"system",
"user"
],
"type": "string"
},
"size": {
"type": [
"integer",
"null"
]
},
"source": {
"type": "string"
},
"tag_aliases": {
"items": {
"$ref": "#/$defs/ContainerImageTagAlias"
},
"type": "array"
},
"user": {
"type": [
"string",
"null"
]
},
"variant": {
"type": [
"string",
"null"
]
}
},
"required": [
"engine",
"scope",
"user",
"home",
"image_id",
"repo_tags",
"repo_digests",
"pull_ref",
"tag_aliases",
"os",
"architecture",
"variant",
"platform",
"size",
"created",
"source",
"notes"
],
"type": "object"
},
"ContainerImagesSnapshot": {
"additionalProperties": false,
"properties": {
"images": {
"items": {
"$ref": "#/$defs/ContainerImage"
},
"type": "array"
},
"notes": {
"items": {
"type": "string"
},
"type": "array"
},
"role_name": {
"const": "container_images"
}
},
"required": [
"role_name",
"images",
"notes"
],
"type": "object"
},
"DnfConfigSnapshot": { "DnfConfigSnapshot": {
"allOf": [ "allOf": [
{ {
@ -1060,6 +1235,9 @@
}, },
"snap": { "snap": {
"$ref": "#/$defs/SnapSnapshot" "$ref": "#/$defs/SnapSnapshot"
},
"container_images": {
"$ref": "#/$defs/ContainerImagesSnapshot"
} }
}, },
"required": [ "required": [

View file

@ -39,3 +39,105 @@ def test_runtime_state_collector_preserves_non_root_skip_schema(monkeypatch, tmp
assert result.sysctl_snapshot.role_name == "sysctl" assert result.sysctl_snapshot.role_name == "sysctl"
assert "not running as root" in result.firewall_runtime_snapshot.notes[0] assert "not running as root" in result.firewall_runtime_snapshot.notes[0]
assert "not running as root" in result.sysctl_snapshot.notes[0] assert "not running as root" in result.sysctl_snapshot.notes[0]
def test_container_images_collector_records_digest_pinned_docker_images(
monkeypatch, tmp_path
):
import json
import subprocess
from enroll.harvest_collectors import container_images as ci
from enroll.harvest_collectors.container_images import ContainerImagesCollector
def fake_which(cmd):
return f"/usr/bin/{cmd}" if cmd == "docker" else None
def fake_run(argv, check=False, stdout=None, stderr=None, text=False, timeout=None):
if argv[:4] == ["/usr/bin/docker", "image", "ls", "-q"]:
return subprocess.CompletedProcess(argv, 0, "sha256:" + "a" * 64 + "\n", "")
if argv[:3] == ["/usr/bin/docker", "image", "inspect"]:
return subprocess.CompletedProcess(
argv,
0,
json.dumps(
[
{
"Id": "sha256:" + "a" * 64,
"RepoTags": ["docker.io/library/nginx:1.27"],
"RepoDigests": [
"docker.io/library/nginx@sha256:" + "b" * 64
],
"Os": "linux",
"Architecture": "amd64",
"Size": 123,
"Created": "2026-01-01T00:00:00Z",
}
]
),
"",
)
raise AssertionError(argv)
monkeypatch.setattr(ci.shutil, "which", fake_which)
monkeypatch.setattr(ci.subprocess, "run", fake_run)
result = ContainerImagesCollector(_context(tmp_path)).collect()
assert result.role_name == "container_images"
assert len(result.images) == 1
image = result.images[0]
assert image["engine"] == "docker"
assert image["pull_ref"] == "docker.io/library/nginx@sha256:" + "b" * 64
assert image["platform"] == "linux/amd64"
assert image["tag_aliases"] == [
{
"ref": "docker.io/library/nginx:1.27",
"repository": "docker.io/library/nginx",
"tag": "1.27",
}
]
def test_container_images_collector_records_unpullable_tagged_images(
monkeypatch, tmp_path
):
import json
import subprocess
from enroll.harvest_collectors import container_images as ci
from enroll.harvest_collectors.container_images import ContainerImagesCollector
def fake_which(cmd):
return "/usr/bin/podman" if cmd == "podman" else None
monkeypatch.setattr(ci.shutil, "which", fake_which)
def fake_run(argv, check=False, stdout=None, stderr=None, text=False, timeout=None):
if argv[:4] == ["/usr/bin/podman", "image", "ls", "-q"]:
return subprocess.CompletedProcess(argv, 0, "c" * 64 + "\n", "")
if argv[:3] == ["/usr/bin/podman", "image", "inspect"]:
return subprocess.CompletedProcess(
argv,
0,
json.dumps(
[
{
"Id": "c" * 64,
"RepoTags": ["localhost/demo:latest"],
"RepoDigests": [],
"Os": "linux",
"Architecture": "amd64",
}
]
),
"",
)
raise AssertionError(argv)
monkeypatch.setattr(ci.subprocess, "run", fake_run)
result = ContainerImagesCollector(_context(tmp_path)).collect()
assert result.images[0]["pull_ref"] is None
assert "exact digest-pinned pull cannot be rendered" in result.images[0]["notes"][0]

View file

@ -1989,3 +1989,170 @@ def test_manifest_writes_sysctl_role(tmp_path: Path):
pb = (out / "playbook.yml").read_text(encoding="utf-8") pb = (out / "playbook.yml").read_text(encoding="utf-8")
assert "role: sysctl" in pb assert "role: sysctl" in pb
assert (out / "roles" / "sysctl" / "files" / "sysctl" / "99-enroll.conf").exists() assert (out / "roles" / "sysctl" / "files" / "sysctl" / "99-enroll.conf").exists()
def test_manifest_renders_container_image_role_for_ansible(tmp_path: Path):
bundle = tmp_path / "bundle"
out = tmp_path / "ansible"
digest = "docker.io/library/nginx@sha256:" + "a" * 64
podman_digest = "quay.io/example/app@sha256:" + "b" * 64
state = {
"roles": {
"users": {
"role_name": "users",
"users": [],
"managed_files": [],
"excluded": [],
"notes": [],
},
"services": [],
"packages": [],
"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.mkdir(parents=True, exist_ok=True)
(bundle / "state.json").write_text(json.dumps(state, indent=2), encoding="utf-8")
manifest.manifest(str(bundle), str(out))
defaults = (out / "roles" / "container_images" / "defaults" / "main.yml").read_text(
encoding="utf-8"
)
tasks = (out / "roles" / "container_images" / "tasks" / "main.yml").read_text(
encoding="utf-8"
)
meta = (out / "roles" / "container_images" / "meta" / "main.yml").read_text(
encoding="utf-8"
)
requirements = (out / "requirements.yml").read_text(encoding="utf-8")
playbook = (out / "playbook.yml").read_text(encoding="utf-8")
assert "container_images:" in defaults
assert digest in defaults
assert podman_digest in defaults
assert "community.docker.docker_image_pull" in tasks
assert "community.docker.docker_image_tag" in tasks
assert "containers.podman.podman_image" in tasks
assert "containers.podman.podman_tag" in tasks
assert "repository:" in tasks
assert "target_names:" in tasks
assert "community.docker" in meta
assert "containers.podman" in meta
assert "name: community.docker" in requirements
assert "name: containers.podman" in requirements
assert "role: container_images" in playbook
def test_manifest_writes_container_images_to_hostvars_in_fqdn_mode(tmp_path: Path):
bundle = tmp_path / "bundle"
out = tmp_path / "ansible"
digest = "docker.io/library/nginx@sha256:" + "a" * 64
state = {
"roles": {
"users": {
"role_name": "users",
"users": [],
"managed_files": [],
"excluded": [],
"notes": [],
},
"services": [],
"packages": [],
"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": [],
"os": "linux",
"architecture": "amd64",
"variant": None,
"platform": "linux/amd64",
"size": 123,
"created": "2026-01-01T00:00:00Z",
"source": "docker image inspect",
"notes": [],
}
],
"notes": [],
},
}
}
bundle.mkdir(parents=True, exist_ok=True)
(bundle / "state.json").write_text(json.dumps(state, indent=2), encoding="utf-8")
manifest.manifest(str(bundle), str(out), fqdn="host.example.test")
defaults = (out / "roles" / "container_images" / "defaults" / "main.yml").read_text(
encoding="utf-8"
)
hostvars = (
out / "inventory" / "host_vars" / "host.example.test" / "container_images.yml"
).read_text(encoding="utf-8")
playbook = (out / "playbooks" / "host.example.test.yml").read_text(encoding="utf-8")
assert "container_images: []" in defaults
assert digest in hostvars
assert "role: container_images" in playbook

View file

@ -603,3 +603,112 @@ def test_manifest_rejects_unknown_target(tmp_path: Path):
assert "unsupported manifest target" in str(e) assert "unsupported manifest target" in str(e)
else: else:
raise AssertionError("expected ValueError") raise AssertionError("expected ValueError")
def test_manifest_puppet_renders_container_images_static_and_hiera(tmp_path: Path):
digest = "docker.io/library/nginx@sha256:" + "a" * 64
podman_digest = "quay.io/example/app@sha256:" + "b" * 64
state = {
"roles": {
"users": {
"role_name": "users",
"users": [],
"managed_dirs": [],
"managed_files": [],
"excluded": [],
"notes": [],
},
"services": [],
"packages": [],
"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": [],
"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 / "puppet"
_write_state(bundle, state)
manifest.manifest(str(bundle), str(out), target="puppet")
site_pp = (out / "manifests" / "site.pp").read_text(encoding="utf-8")
assert "include container_images" in site_pp
pp = (out / "modules" / "container_images" / "manifests" / "init.pp").read_text(
encoding="utf-8"
)
assert "docker::image" in pp
assert "image_digest => 'sha256:" + "a" * 64 + "'" in pp
assert "docker tag" in pp
assert "podman pull" in pp
metadata = json.loads(
(out / "modules" / "container_images" / "metadata.json").read_text(
encoding="utf-8"
)
)
assert metadata["dependencies"] == [
{"name": "puppetlabs-docker", "version_requirement": ">= 8.0.0 < 15.0.0"}
]
fqdn_out = tmp_path / "puppet-fqdn"
manifest.manifest(str(bundle), str(fqdn_out), target="puppet", fqdn="node.example")
node_data = yaml.safe_load(
(fqdn_out / "data" / "nodes" / "node.example.yaml").read_text(encoding="utf-8")
)
assert node_data["container_images::container_images"][0]["pull_ref"] == digest
fqdn_pp = (
fqdn_out / "modules" / "container_images" / "manifests" / "init.pp"
).read_text(encoding="utf-8")
assert "Array[Hash] $container_images = []" in fqdn_pp
assert "docker::image" in fqdn_pp
assert "enroll-podman-pull-${idx}" in fqdn_pp
assert "$image['pull_cmd']" in fqdn_pp
assert "podman pull" in (
fqdn_out / "data" / "nodes" / "node.example.yaml"
).read_text(encoding="utf-8")

View file

@ -354,3 +354,104 @@ def test_cli_manifest_target_salt_is_forwarded(monkeypatch, tmp_path):
assert called["harvest"] == str(tmp_path / "bundle") assert called["harvest"] == str(tmp_path / "bundle")
assert called["out"] == str(tmp_path / "salt") assert called["out"] == str(tmp_path / "salt")
assert called["target"] == "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")