Support for detecting Docker images
This commit is contained in:
parent
e2be9a6239
commit
ebc27e1111
19 changed files with 1600 additions and 15 deletions
|
|
@ -6,8 +6,8 @@
|
|||
* 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!
|
||||
* A lot of under-the-bonnet refactoring to make it easier to extend to cover other config managers in future.
|
||||
* 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
|
||||
|
||||
# 0.6.0
|
||||
|
||||
|
|
|
|||
11
README.md
11
README.md
|
|
@ -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)"
|
||||
```
|
||||
|
||||
|
||||
### 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
|
||||
```bash
|
||||
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
|
||||
```
|
||||
|
||||
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
|
||||
```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
|
||||
```
|
||||
|
||||
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`
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from .ansible_renderer.model import (
|
|||
AnsibleRole,
|
||||
_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.managed_files import _render_managed_file_roles
|
||||
from .ansible_renderer.roles.packages import (
|
||||
|
|
@ -67,6 +68,9 @@ class AnsibleManifestRenderer:
|
|||
_render_users_role(ctx, manifest_plan, roles.get("users", {}))
|
||||
_render_flatpak_role(ctx, manifest_plan, roles.get("flatpak", {}))
|
||||
_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_sysctl_role(ctx, manifest_plan, roles.get("sysctl", {}))
|
||||
_render_firewall_runtime_role(
|
||||
|
|
|
|||
|
|
@ -150,14 +150,62 @@ def _ensure_ansible_cfg(cfg_path: str) -> None:
|
|||
return
|
||||
|
||||
|
||||
def _ensure_requirements_yaml(req_path: str) -> None:
|
||||
if not os.path.exists(req_path):
|
||||
with open(req_path, "w", encoding="utf-8") as f:
|
||||
f.write("---\n")
|
||||
f.write("collections:\n")
|
||||
f.write(" - name: community.general\n")
|
||||
f.write(' version: ">=13.0.0"\n')
|
||||
return
|
||||
def _ensure_requirements_yaml(
|
||||
req_path: str,
|
||||
collections: Optional[List[Dict[str, str]]] = None,
|
||||
) -> None:
|
||||
requested = collections or [{"name": "community.general", "version": ">=13.0.0"}]
|
||||
|
||||
existing: Dict[str, Any] = {}
|
||||
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:
|
||||
|
|
|
|||
|
|
@ -125,6 +125,7 @@ class AnsibleManifestPlan:
|
|||
"extra_paths",
|
||||
"flatpak",
|
||||
"snap",
|
||||
"container_images",
|
||||
"users",
|
||||
"tail_package",
|
||||
"sysctl",
|
||||
|
|
|
|||
192
enroll/ansible_renderer/roles/container_images.py
Normal file
192
enroll/ansible_renderer/roles/container_images.py
Normal 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)
|
||||
|
|
@ -133,3 +133,19 @@ def _build_managed_links_var(
|
|||
continue
|
||||
out.append({"dest": dest, "src": src})
|
||||
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
|
||||
|
|
|
|||
|
|
@ -195,6 +195,7 @@ def role_order_key(role: str) -> tuple[int, str]:
|
|||
"etc_custom": 80,
|
||||
"usr_local_custom": 81,
|
||||
"extra_paths": 82,
|
||||
"container_images": 88,
|
||||
"users": 90,
|
||||
"sysctl": 95,
|
||||
"firewall_runtime": 99,
|
||||
|
|
|
|||
|
|
@ -542,6 +542,7 @@ def harvest(
|
|||
# includes are harvested into an extra role.
|
||||
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.package_manager import PackageManagerConfigCollector
|
||||
from .harvest_collectors.paths import ExtraPathsCollector, UsrLocalCustomCollector
|
||||
|
|
@ -646,6 +647,11 @@ def harvest(
|
|||
flatpak_snapshot = users_collection.flatpak_snapshot
|
||||
snap_snapshot = users_collection.snap_snapshot
|
||||
|
||||
# -------------------------
|
||||
# Container image inventory (Docker/Podman image caches)
|
||||
# -------------------------
|
||||
container_images_snapshot = ContainerImagesCollector(context).collect()
|
||||
|
||||
# -------------------------
|
||||
# Package manager config role
|
||||
# - Debian: apt_config
|
||||
|
|
@ -1015,6 +1021,7 @@ def harvest(
|
|||
"users": asdict(users_snapshot),
|
||||
"flatpak": asdict(flatpak_snapshot),
|
||||
"snap": asdict(snap_snapshot),
|
||||
"container_images": asdict(container_images_snapshot),
|
||||
"services": [asdict(s) for s in service_snaps],
|
||||
"packages": [asdict(p) for p in pkg_snaps],
|
||||
"apt_config": asdict(apt_config_snapshot),
|
||||
|
|
|
|||
251
enroll/harvest_collectors/container_images.py
Normal file
251
enroll/harvest_collectors/container_images.py
Normal 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
|
||||
|
|
@ -98,6 +98,13 @@ class SnapSnapshot:
|
|||
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
|
||||
class AptConfigSnapshot:
|
||||
role_name: str
|
||||
|
|
|
|||
239
enroll/puppet.py
239
enroll/puppet.py
|
|
@ -1,7 +1,9 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterable, List, Optional, Set, Tuple
|
||||
|
|
@ -26,6 +28,10 @@ class PuppetRole(CMModule):
|
|||
role_name=role_name,
|
||||
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:
|
||||
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."
|
||||
)
|
||||
|
||||
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(
|
||||
self,
|
||||
snap: Dict[str, Any],
|
||||
|
|
@ -196,6 +239,32 @@ def _pp_bool(value: bool) -> str:
|
|||
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:
|
||||
return "[" + ", ".join(_pp_quote(v) for v in values) + "]"
|
||||
|
||||
|
|
@ -210,6 +279,20 @@ def _resource(
|
|||
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(
|
||||
bundle_dir: str,
|
||||
role: str,
|
||||
|
|
@ -378,6 +461,15 @@ def _collect_puppet_roles(
|
|||
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 {}
|
||||
if isinstance(fw, dict):
|
||||
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:
|
||||
lines.append(" if $sysctl_apply {")
|
||||
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)
|
||||
}
|
||||
|
||||
if prole.container_images:
|
||||
data[f"{prefix}container_images"] = list(prole.container_images)
|
||||
|
||||
if 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] $links = {},",
|
||||
" Hash[String, Hash] $services = {},",
|
||||
" Array[Hash] $container_images = [],",
|
||||
" Array[String] $notes = [],",
|
||||
" Boolean $sysctl_apply = 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 {",
|
||||
" exec { 'enroll-apply-sysctl':",
|
||||
" command => $sysctl_ignore_apply_errors ? {",
|
||||
|
|
@ -791,7 +1012,16 @@ def _hiera_node_names(out: Path) -> List[str]:
|
|||
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(
|
||||
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}",
|
||||
"license": "UNLICENSED",
|
||||
"source": "",
|
||||
"dependencies": [],
|
||||
"dependencies": dependencies,
|
||||
},
|
||||
indent=2,
|
||||
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.
|
||||
- Basic service enablement/running-state resources.
|
||||
- `/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
|
||||
|
||||
- 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.
|
||||
- Review generated resources before applying them broadly across unlike hosts.
|
||||
|
||||
|
|
@ -958,7 +1191,7 @@ class PuppetManifestRenderer:
|
|||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
_write_metadata(module_dir, prole.module_name)
|
||||
_write_metadata(module_dir, prole.module_name, prole)
|
||||
|
||||
node_names: List[str] = []
|
||||
if hiera_mode and fqdn:
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ RESERVED_SINGLETON_ROLE_NAMES = {
|
|||
"users",
|
||||
"flatpak",
|
||||
"snap",
|
||||
"container_images",
|
||||
"apt_config",
|
||||
"dnf_config",
|
||||
"firewall_runtime",
|
||||
|
|
|
|||
160
enroll/salt.py
160
enroll/salt.py
|
|
@ -3,6 +3,7 @@ from __future__ import annotations
|
|||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
|
@ -27,6 +28,10 @@ class SaltRole(CMModule):
|
|||
role_name=role_name,
|
||||
module_name=_salt_name(role_name, fallback="enroll_role"),
|
||||
)
|
||||
self.container_images: List[Dict[str, Any]] = []
|
||||
|
||||
def has_resources(self) -> bool:
|
||||
return super().has_resources() or bool(self.container_images)
|
||||
|
||||
@property
|
||||
def sls_name(self) -> str:
|
||||
|
|
@ -85,6 +90,40 @@ class SaltRole(CMModule):
|
|||
"Per-user Flatpak resources were detected but are not rendered as native Salt states."
|
||||
)
|
||||
|
||||
def add_container_images_snapshot(self, snap: Dict[str, Any]) -> None:
|
||||
for raw in snap.get("images", []) or []:
|
||||
if not isinstance(raw, dict):
|
||||
continue
|
||||
engine = str(raw.get("engine") or "").strip().lower()
|
||||
pull_ref = str(raw.get("pull_ref") or "").strip()
|
||||
if engine not in {"docker", "podman"}:
|
||||
continue
|
||||
if not pull_ref:
|
||||
tags = ", ".join(str(t) for t in (raw.get("repo_tags") or []) if t)
|
||||
label = tags or str(raw.get("image_id") or "unknown image")
|
||||
self.notes.append(
|
||||
f"Container image {label} has no RepoDigest; exact Salt pull state was not rendered."
|
||||
)
|
||||
continue
|
||||
item = dict(raw)
|
||||
item["engine"] = engine
|
||||
item["pull_ref"] = pull_ref
|
||||
item["scope"] = str(item.get("scope") or "system").strip() or "system"
|
||||
item["tag_aliases"] = [
|
||||
dict(alias)
|
||||
for alias in (item.get("tag_aliases") or [])
|
||||
if isinstance(alias, dict) and alias.get("ref")
|
||||
]
|
||||
item["pull_cmd"] = _container_pull_cmd(engine, pull_ref)
|
||||
item["pull_unless"] = _container_exists_cmd(engine, pull_ref)
|
||||
for alias in item["tag_aliases"]:
|
||||
alias_ref = str(alias.get("ref") or "")
|
||||
alias["tag_cmd"] = _container_tag_cmd(engine, pull_ref, alias_ref)
|
||||
alias["tag_unless"] = _container_exists_cmd(engine, alias_ref)
|
||||
self.container_images.append(item)
|
||||
for note in snap.get("notes", []) or []:
|
||||
self.notes.append(str(note))
|
||||
|
||||
def add_managed_content(
|
||||
self,
|
||||
snap: Dict[str, Any],
|
||||
|
|
@ -186,6 +225,24 @@ def _yaml_bool(value: Any) -> str:
|
|||
return "true" if bool(value) else "false"
|
||||
|
||||
|
||||
def _shell_quote(value: Any) -> str:
|
||||
return shlex.quote(str(value or ""))
|
||||
|
||||
|
||||
def _container_pull_cmd(engine: str, pull_ref: str) -> str:
|
||||
return f"{engine} pull {_shell_quote(pull_ref)}"
|
||||
|
||||
|
||||
def _container_exists_cmd(engine: str, ref: str) -> str:
|
||||
if engine == "podman":
|
||||
return f"podman image exists {_shell_quote(ref)}"
|
||||
return f"docker image inspect {_shell_quote(ref)} >/dev/null 2>&1"
|
||||
|
||||
|
||||
def _container_tag_cmd(engine: str, pull_ref: str, tag_ref: str) -> str:
|
||||
return f"{engine} tag {_shell_quote(pull_ref)} {_shell_quote(tag_ref)}"
|
||||
|
||||
|
||||
def _clean_gecos_part(value: Any) -> Optional[str]:
|
||||
text = str(value or "").strip()
|
||||
return text or None
|
||||
|
|
@ -382,6 +439,15 @@ def _collect_salt_roles(
|
|||
file_prefix=node_file_prefix,
|
||||
)
|
||||
|
||||
container_images = roles.get("container_images") or {}
|
||||
if isinstance(container_images, dict) and (
|
||||
container_images.get("images") or container_images.get("notes")
|
||||
):
|
||||
srole = ensure_role(
|
||||
str(container_images.get("role_name") or "container_images")
|
||||
)
|
||||
srole.add_container_images_snapshot(container_images)
|
||||
|
||||
fw = roles.get("firewall_runtime") or {}
|
||||
if isinstance(fw, dict):
|
||||
has_fw = (
|
||||
|
|
@ -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:
|
||||
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:
|
||||
data["sysctl_apply"] = True
|
||||
if srole.container_images:
|
||||
data["container_images"] = list(srole.container_images)
|
||||
if srole.notes:
|
||||
data["notes"] = list(srole.notes)
|
||||
return data
|
||||
|
|
@ -687,6 +813,38 @@ def _render_pillar_role(srole: SaltRole) -> str:
|
|||
" - enable: {{ svc.get('enable', False)|yaml_encode }}",
|
||||
"{% endfor %}",
|
||||
"",
|
||||
"{% for image in role.get('container_images', []) %}",
|
||||
"{% if image.get('engine') == 'docker' and image.get('pull_ref') %}",
|
||||
f"enroll_docker_image_{role_key}_{{{{ loop.index }}}}:",
|
||||
" docker_image.present:",
|
||||
" - name: {{ image.get('pull_ref')|yaml_dquote }}",
|
||||
" - force: false",
|
||||
"{% set image_loop = loop.index %}",
|
||||
"{% for alias in image.get('tag_aliases', []) %}",
|
||||
f"enroll_docker_tag_{role_key}_{{{{ image_loop }}}}_{{{{ loop.index }}}}:",
|
||||
" cmd.run:",
|
||||
" - name: {{ alias.get('tag_cmd')|yaml_dquote }}",
|
||||
" - unless: {{ alias.get('tag_unless')|yaml_dquote }}",
|
||||
" - require:",
|
||||
f" - docker_image: enroll_docker_image_{role_key}_{{{{ image_loop }}}}",
|
||||
"{% endfor %}",
|
||||
"{% elif image.get('engine') == 'podman' and image.get('pull_ref') %}",
|
||||
f"enroll_podman_pull_{role_key}_{{{{ loop.index }}}}:",
|
||||
" cmd.run:",
|
||||
" - name: {{ image.get('pull_cmd')|yaml_dquote }}",
|
||||
" - unless: {{ image.get('pull_unless')|yaml_dquote }}",
|
||||
"{% set image_loop = loop.index %}",
|
||||
"{% for alias in image.get('tag_aliases', []) %}",
|
||||
f"enroll_podman_tag_{role_key}_{{{{ image_loop }}}}_{{{{ loop.index }}}}:",
|
||||
" cmd.run:",
|
||||
" - name: {{ alias.get('tag_cmd')|yaml_dquote }}",
|
||||
" - unless: {{ alias.get('tag_unless')|yaml_dquote }}",
|
||||
" - require:",
|
||||
f" - cmd: enroll_podman_pull_{role_key}_{{{{ image_loop }}}}",
|
||||
"{% endfor %}",
|
||||
"{% endif %}",
|
||||
"{% endfor %}",
|
||||
"",
|
||||
"{% if role.get('sysctl_apply') and '/etc/sysctl.d/99-enroll.conf' in role.get('files', {}) %}",
|
||||
f"enroll_apply_sysctl_{role_key}:",
|
||||
" cmd.run:",
|
||||
|
|
@ -875,6 +1033,8 @@ 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.
|
||||
- Podman images by digest using guarded `podman pull` / `podman tag` command states.
|
||||
|
||||
## Current limitations
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,181 @@
|
|||
],
|
||||
"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": {
|
||||
"allOf": [
|
||||
{
|
||||
|
|
@ -1060,6 +1235,9 @@
|
|||
},
|
||||
"snap": {
|
||||
"$ref": "#/$defs/SnapSnapshot"
|
||||
},
|
||||
"container_images": {
|
||||
"$ref": "#/$defs/ContainerImagesSnapshot"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
|
|
|||
|
|
@ -39,3 +39,105 @@ def test_runtime_state_collector_preserves_non_root_skip_schema(monkeypatch, tmp
|
|||
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.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]
|
||||
|
|
|
|||
|
|
@ -1989,3 +1989,170 @@ def test_manifest_writes_sysctl_role(tmp_path: Path):
|
|||
pb = (out / "playbook.yml").read_text(encoding="utf-8")
|
||||
assert "role: sysctl" in pb
|
||||
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
|
||||
|
|
|
|||
|
|
@ -603,3 +603,112 @@ def test_manifest_rejects_unknown_target(tmp_path: Path):
|
|||
assert "unsupported manifest target" in str(e)
|
||||
else:
|
||||
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")
|
||||
|
|
|
|||
|
|
@ -354,3 +354,104 @@ def test_cli_manifest_target_salt_is_forwarded(monkeypatch, tmp_path):
|
|||
assert called["harvest"] == str(tmp_path / "bundle")
|
||||
assert called["out"] == str(tmp_path / "salt")
|
||||
assert called["target"] == "salt"
|
||||
|
||||
|
||||
def test_manifest_salt_renders_container_images_in_single_and_fqdn_modes(
|
||||
tmp_path: Path,
|
||||
):
|
||||
digest = "docker.io/library/nginx@sha256:" + "a" * 64
|
||||
podman_digest = "quay.io/example/app@sha256:" + "b" * 64
|
||||
state = _sample_state()
|
||||
state["roles"]["container_images"] = {
|
||||
"role_name": "container_images",
|
||||
"images": [
|
||||
{
|
||||
"engine": "docker",
|
||||
"scope": "system",
|
||||
"user": None,
|
||||
"home": None,
|
||||
"image_id": "sha256:" + "c" * 64,
|
||||
"repo_tags": ["docker.io/library/nginx:1.27"],
|
||||
"repo_digests": [digest],
|
||||
"pull_ref": digest,
|
||||
"tag_aliases": [
|
||||
{
|
||||
"ref": "docker.io/library/nginx:1.27",
|
||||
"repository": "docker.io/library/nginx",
|
||||
"tag": "1.27",
|
||||
}
|
||||
],
|
||||
"os": "linux",
|
||||
"architecture": "amd64",
|
||||
"variant": None,
|
||||
"platform": "linux/amd64",
|
||||
"size": 123,
|
||||
"created": "2026-01-01T00:00:00Z",
|
||||
"source": "docker image inspect",
|
||||
"notes": [],
|
||||
},
|
||||
{
|
||||
"engine": "podman",
|
||||
"scope": "system",
|
||||
"user": None,
|
||||
"home": None,
|
||||
"image_id": "sha256:" + "d" * 64,
|
||||
"repo_tags": ["quay.io/example/app:prod"],
|
||||
"repo_digests": [podman_digest],
|
||||
"pull_ref": podman_digest,
|
||||
"tag_aliases": [
|
||||
{
|
||||
"ref": "quay.io/example/app:prod",
|
||||
"repository": "quay.io/example/app",
|
||||
"tag": "prod",
|
||||
}
|
||||
],
|
||||
"os": "linux",
|
||||
"architecture": "amd64",
|
||||
"variant": None,
|
||||
"platform": "linux/amd64",
|
||||
"size": 456,
|
||||
"created": "2026-01-01T00:00:00Z",
|
||||
"source": "podman image inspect",
|
||||
"notes": [],
|
||||
},
|
||||
],
|
||||
"notes": [],
|
||||
}
|
||||
bundle = tmp_path / "bundle"
|
||||
out = tmp_path / "salt"
|
||||
_write_sample_artifacts(bundle)
|
||||
_write_state(bundle, state)
|
||||
|
||||
manifest.manifest(str(bundle), str(out), target="salt")
|
||||
|
||||
top = yaml.safe_load((out / "states" / "top.sls").read_text(encoding="utf-8"))
|
||||
assert "roles.container_images" in top["base"]["*"]
|
||||
sls = (out / "states" / "roles" / "container_images" / "init.sls").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert "docker_image.present:" in sls
|
||||
assert digest in sls
|
||||
assert "docker tag" in sls
|
||||
assert "podman pull" in sls
|
||||
assert "podman tag" in sls
|
||||
|
||||
fqdn_out = tmp_path / "salt-fqdn"
|
||||
manifest.manifest(str(bundle), str(fqdn_out), target="salt", fqdn="node.example")
|
||||
pillar_top = yaml.safe_load(
|
||||
(fqdn_out / "pillar" / "top.sls").read_text(encoding="utf-8")
|
||||
)
|
||||
node_sls = pillar_top["base"]["node.example"][0]
|
||||
pillar_path = fqdn_out / "pillar" / Path(*node_sls.split("."))
|
||||
pillar = yaml.safe_load(pillar_path.with_suffix(".sls").read_text(encoding="utf-8"))
|
||||
assert (
|
||||
pillar["enroll"]["roles"]["container_images"]["container_images"][0]["pull_ref"]
|
||||
== digest
|
||||
)
|
||||
fqdn_sls = (
|
||||
fqdn_out / "states" / "roles" / "container_images" / "init.sls"
|
||||
).read_text(encoding="utf-8")
|
||||
assert "docker_image.present:" in fqdn_sls
|
||||
assert "enroll_podman_pull_container_images" in fqdn_sls
|
||||
assert "image.get('pull_cmd')" in fqdn_sls
|
||||
assert "podman pull" in pillar_path.with_suffix(".sls").read_text(encoding="utf-8")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue