Support for detecting Docker images
This commit is contained in:
parent
e2be9a6239
commit
ebc27e1111
19 changed files with 1600 additions and 15 deletions
|
|
@ -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": [
|
||||
|
|
|
|||
Reference in a new issue