parent
02feff014f
commit
0d111caf62
10 changed files with 1675 additions and 15 deletions
|
|
@ -13,10 +13,14 @@ jobs:
|
|||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
mkdir -m 755 -p /etc/apt/keyrings
|
||||
curl -fsSL https://packages.broadcom.com/artifactory/api/security/keypair/SaltProjectKey/public | gpg --dearmor | tee /etc/apt/keyrings/salt-archive-keyring.pgp > /dev/null
|
||||
curl -fsSL https://github.com/saltstack/salt-install-guide/releases/latest/download/salt.sources | tee /etc/apt/sources.list.d/salt.sources
|
||||
apt-get update
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||
ansible ansible-lint python3-venv pipx systemctl python3-apt jq python3-jsonschema \
|
||||
puppet hiera
|
||||
puppet hiera \
|
||||
salt-master salt-minion salt-ssh salt-syndic salt-cloud salt-api
|
||||
|
||||
- name: Install Poetry
|
||||
run: |
|
||||
|
|
|
|||
29
README.md
29
README.md
|
|
@ -120,7 +120,7 @@ enroll single-shot --remote-host myhost.example.com --remote-user myuser --ssh-k
|
|||
---
|
||||
|
||||
### `enroll manifest`
|
||||
Generate configuration-management output from an existing harvest bundle. Ansible remains the default; use `--target puppet` for Puppet output.
|
||||
Generate configuration-management output from an existing harvest bundle. Ansible remains the default; use `--target puppet` for Puppet output or `--target salt` for Salt output.
|
||||
|
||||
**Inputs**
|
||||
- `--harvest /path/to/harvest` (directory)
|
||||
|
|
@ -129,11 +129,12 @@ Generate configuration-management output from an existing harvest bundle. Ansibl
|
|||
**Output**
|
||||
- In plaintext Ansible mode: an Ansible repo-like directory structure (roles/playbooks, and inventory in multi-site mode).
|
||||
- In plaintext Puppet mode: a Puppet control-repo style layout with `manifests/site.pp` and generated modules under `modules/`. By default, package and service resources are grouped by Debian Section/RPM Group where possible; `--fqdn` or `--no-common-roles` preserves one generated module per Enroll role/snapshot.
|
||||
- In plaintext Salt mode: a Salt state tree under `states/`, plus `pillar/` data in `--fqdn` mode. By default, package and service resources are grouped by Debian Section/RPM Group where possible; `--fqdn` or `--no-common-roles` preserves one generated SLS role per Enroll role/snapshot.
|
||||
- In `--sops` mode: a single encrypted file `manifest.tar.gz.sops` containing the generated output.
|
||||
|
||||
**Common flags**
|
||||
- `--target ansible|puppet`: choose the manifest target (`ansible` is the default).
|
||||
- `--fqdn <host>`: enables **multi-site** output style for Ansible or emits Puppet Hiera/node output. Without `--fqdn`, Puppet emits `node default { ... }`.
|
||||
- `--target ansible|puppet|salt`: choose the manifest target (`ansible` is the default).
|
||||
- `--fqdn <host>`: enables **multi-site** output style for Ansible, emits Puppet Hiera/node output, or emits Salt top/pillar output targeted at that minion ID. Without `--fqdn`, Puppet emits `node default { ... }` and Salt targets `*` in `states/top.sls`.
|
||||
- `--no-common-roles`: disables the default grouping of package and systemd-unit roles into Debian Section/RPM Group roles, preserving one generated role per package/unit. `--fqdn` implies this behaviour.
|
||||
|
||||
**Role tags**
|
||||
|
|
@ -467,6 +468,28 @@ sudo puppet apply --modulepath /tmp/enroll-puppet/modules /tmp/enroll-puppet/man
|
|||
|
||||
Docker images with registry digests are rendered as `docker::image` resources and require the Puppet environment to provide `puppetlabs-docker`; the generated module metadata records that dependency. Podman images with registry digests are rendered as guarded `podman pull` / `podman tag` exec resources. Images without `RepoDigest` are recorded in harvest state and notes, but are not converted into exact pull resources. Flatpak, Snap, and live firewall runtime snapshots are listed as notes in the generated Puppet README rather than converted into Puppet resources.
|
||||
|
||||
### Salt target
|
||||
```bash
|
||||
enroll manifest --harvest /tmp/enroll-harvest --out /tmp/enroll-salt --target salt
|
||||
```
|
||||
|
||||
The Salt target renders native packages, users/groups, managed directories/files/symlinks, basic service state, and the generated sysctl file/apply command when present. Without `--fqdn`, it writes a self-contained state tree under `states/` and targets all minions in `states/top.sls`:
|
||||
|
||||
```bash
|
||||
cd /tmp/enroll-salt
|
||||
sudo salt-call --local --file-root ./states state.apply test=True
|
||||
```
|
||||
|
||||
With `--fqdn`, it uses Salt's state/pillar split: `states/top.sls` targets the minion ID to reusable generated role SLS files, while `pillar/top.sls` targets the same minion ID to node-specific data under `pillar/nodes/`. Host-specific file artifacts are stored under `states/roles/<role>/files/nodes/<fqdn>/...` and referenced through `salt://` URLs:
|
||||
|
||||
```bash
|
||||
enroll manifest --harvest /tmp/enroll-harvest --out /tmp/enroll-salt --target salt --fqdn host.example.com
|
||||
cd /tmp/enroll-salt
|
||||
sudo salt-call --local --file-root ./states --pillar-root ./pillar --id host.example.com state.apply test=True
|
||||
```
|
||||
|
||||
Re-running Salt `--fqdn` output into the same directory adds or replaces that minion's top/pillar data without deleting other generated minions. Docker 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
|
||||
# Generate encrypted manifest bundle (writes /tmp/enroll-ansible/manifest.tar.gz.sops)
|
||||
|
|
|
|||
|
|
@ -29,8 +29,8 @@ from ..yamlutil import _merge_mappings_overwrite, _yaml_load_mapping
|
|||
class AnsibleManagedFileRoleSpec:
|
||||
"""Declarative managed-file singleton role rendering spec.
|
||||
|
||||
Puppet collects these singleton snapshots in a simple loop and feeds
|
||||
each one through the same managed-content renderer. Ansible has more
|
||||
Puppet and Salt collect these singleton snapshots in a simple loop and feed
|
||||
each one through the same managed-content renderer. Ansible has more
|
||||
layout concerns (defaults vs host_vars, optional JinjaTurtle templates,
|
||||
handlers), but the resource intent is the same, so keep the per-role
|
||||
differences in data rather than spelling out one branch per role.
|
||||
|
|
@ -246,7 +246,7 @@ def _render_managed_file_roles(
|
|||
manifest_plan: AnsibleManifestPlan,
|
||||
roles: Dict[str, Any],
|
||||
) -> None:
|
||||
"""Render file-centric singleton roles in the same loop style as Puppet."""
|
||||
"""Render file-centric singleton roles in the same loop style as Puppet/Salt."""
|
||||
|
||||
for spec in MANAGED_FILE_ROLE_SPECS:
|
||||
snapshot = roles.get(spec.key, {})
|
||||
|
|
|
|||
|
|
@ -104,8 +104,8 @@ def _render_generic_files_tasks(
|
|||
def _render_install_packages_tasks(role: str, var_prefix: str) -> str:
|
||||
"""Render package installation through Ansible's generic package provider.
|
||||
|
||||
Puppet uses provider-backed package resources instead of selecting
|
||||
apt/dnf/yum in the generated manifest. Ansible's package module is the
|
||||
Puppet and Salt use provider-backed package resources instead of selecting
|
||||
apt/dnf/yum in the generated manifest. Ansible's package module is the
|
||||
equivalent abstraction: it proxies to the target host's detected package
|
||||
manager and keeps generated roles provider-neutral.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -310,13 +310,13 @@ def _encrypt_harvest_dir_to_sops(
|
|||
def _add_common_manifest_args(p: argparse.ArgumentParser) -> None:
|
||||
p.add_argument(
|
||||
"--target",
|
||||
choices=["ansible", "puppet"],
|
||||
choices=["ansible", "puppet", "salt"],
|
||||
default="ansible",
|
||||
help="Manifest target to generate (default: ansible).",
|
||||
)
|
||||
p.add_argument(
|
||||
"--fqdn",
|
||||
help="Host FQDN/name for site-mode output (creates target-specific host inventory/data such as Ansible host_vars or Puppet Hiera).",
|
||||
help="Host FQDN/name for site-mode output (creates target-specific host inventory/data such as Ansible host_vars, Puppet Hiera, or Salt pillar).",
|
||||
)
|
||||
p.add_argument(
|
||||
"--no-common-roles",
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ class CMModule:
|
|||
"""Renderer-neutral configuration-management resource group.
|
||||
|
||||
A CMModule is intentionally small: it captures the resources that a target
|
||||
renderer can turn into Ansible tasks, Puppet resources, etc.
|
||||
renderer can turn into Ansible tasks, Puppet resources, Salt states, etc.
|
||||
The renderer may still decide how to name/include/order the group.
|
||||
"""
|
||||
|
||||
|
|
@ -249,8 +249,8 @@ def _drop_duplicate_mapping_items(
|
|||
def resolve_catalog_conflicts(modules: Iterable[CMModule]) -> None:
|
||||
"""Resolve global catalog conflicts before renderer output.
|
||||
|
||||
Puppet compiles a single resource catalog. Ansible can tolerate the same
|
||||
package, service, or parent directory appearing in more than one role;
|
||||
Puppet and Salt compile a single resource catalog. Ansible can tolerate the
|
||||
same package, service, or parent directory appearing in more than one role;
|
||||
catalog targets cannot. Resolve those conflicts in the shared model rather
|
||||
than deleting renderer output after the fact.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ from typing import List, Optional
|
|||
|
||||
from .ansible import manifest_from_bundle_dir as manifest_ansible_from_bundle_dir
|
||||
from .puppet import manifest_from_bundle_dir as manifest_puppet_from_bundle_dir
|
||||
from .salt import manifest_from_bundle_dir as manifest_salt_from_bundle_dir
|
||||
from .remote import _safe_extract_tar
|
||||
from .sopsutil import (
|
||||
decrypt_file_binary_to,
|
||||
|
|
@ -190,7 +191,7 @@ def manifest(
|
|||
- In plain mode: None
|
||||
"""
|
||||
target = (target or "ansible").strip().lower()
|
||||
if target not in {"ansible", "puppet"}:
|
||||
if target not in {"ansible", "puppet", "salt"}:
|
||||
raise ValueError(f"unsupported manifest target: {target!r}")
|
||||
|
||||
sops_mode = bool(sops_fingerprints)
|
||||
|
|
@ -210,6 +211,13 @@ def manifest(
|
|||
fqdn=fqdn,
|
||||
no_common_roles=no_common_roles,
|
||||
)
|
||||
elif target == "salt":
|
||||
manifest_salt_from_bundle_dir(
|
||||
resolved_bundle_dir,
|
||||
out,
|
||||
fqdn=fqdn,
|
||||
no_common_roles=no_common_roles,
|
||||
)
|
||||
else:
|
||||
manifest_ansible_from_bundle_dir(
|
||||
resolved_bundle_dir,
|
||||
|
|
@ -238,6 +246,13 @@ def manifest(
|
|||
fqdn=fqdn,
|
||||
no_common_roles=no_common_roles,
|
||||
)
|
||||
elif target == "salt":
|
||||
manifest_salt_from_bundle_dir(
|
||||
resolved_bundle_dir,
|
||||
str(tmp_out),
|
||||
fqdn=fqdn,
|
||||
no_common_roles=no_common_roles,
|
||||
)
|
||||
else:
|
||||
manifest_ansible_from_bundle_dir(
|
||||
resolved_bundle_dir,
|
||||
|
|
|
|||
1132
enroll/salt.py
Normal file
1132
enroll/salt.py
Normal file
File diff suppressed because it is too large
Load diff
29
tests.sh
29
tests.sh
|
|
@ -20,6 +20,8 @@ ANSIBLE_NO_COMMON_DIR="${WORK_DIR}/ansible-no-common"
|
|||
ANSIBLE_FQDN_DIR="${WORK_DIR}/ansible-fqdn"
|
||||
PUPPET_DIR="${WORK_DIR}/puppet"
|
||||
PUPPET_FQDN_DIR="${WORK_DIR}/puppet-fqdn"
|
||||
SALT_DIR="${WORK_DIR}/salt"
|
||||
SALT_FQDN_DIR="${WORK_DIR}/salt-fqdn"
|
||||
TEST_FQDN="${ENROLL_TEST_FQDN:-enroll-ci.example.test}"
|
||||
|
||||
cleanup() {
|
||||
|
|
@ -105,6 +107,13 @@ ensure_puppet() {
|
|||
require_cmd puppet "Install Puppet before running the Puppet noop integration tests."
|
||||
}
|
||||
|
||||
ensure_salt() {
|
||||
if ! command -v salt-call >/dev/null 2>&1; then
|
||||
apt_install salt-minion || true
|
||||
fi
|
||||
require_cmd salt-call "Install Salt's salt-call binary before running the Salt noop integration tests. On Debian 13 this may require configuring the upstream Salt/Broadcom package repository first."
|
||||
}
|
||||
|
||||
run_pytests() {
|
||||
section "Python unit tests"
|
||||
cd "${PROJECT_ROOT}"
|
||||
|
|
@ -170,6 +179,25 @@ run_puppet_noop_tests() {
|
|||
--noop
|
||||
}
|
||||
|
||||
run_salt_noop_tests() {
|
||||
section "Salt manifest noop tests"
|
||||
ensure_salt
|
||||
cd "${PROJECT_ROOT}"
|
||||
rm -rf "${SALT_DIR}" "${SALT_FQDN_DIR}"
|
||||
|
||||
run poetry run enroll manifest --harvest "${BUNDLE_DIR}" --out "${SALT_DIR}" --target salt
|
||||
run salt-call --local --retcode-passthrough --file-root "${SALT_DIR}/states" state.apply test=True
|
||||
|
||||
run poetry run enroll manifest --harvest "${BUNDLE_DIR}" --out "${SALT_FQDN_DIR}" --target salt --fqdn "${TEST_FQDN}"
|
||||
run salt-call \
|
||||
--local \
|
||||
--retcode-passthrough \
|
||||
--id "${TEST_FQDN}" \
|
||||
--file-root "${SALT_FQDN_DIR}/states" \
|
||||
--pillar-root "${SALT_FQDN_DIR}/pillar" \
|
||||
state.apply test=True
|
||||
}
|
||||
|
||||
main() {
|
||||
require_root
|
||||
require_debian_ci
|
||||
|
|
@ -177,6 +205,7 @@ main() {
|
|||
prepare_harvest_fixture
|
||||
run_ansible_noop_tests
|
||||
run_puppet_noop_tests
|
||||
run_salt_noop_tests
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
|
|
|||
457
tests/test_manifest_salt.py
Normal file
457
tests/test_manifest_salt.py
Normal file
|
|
@ -0,0 +1,457 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from enroll import manifest
|
||||
|
||||
|
||||
def _write_state(bundle: Path, state: dict) -> None:
|
||||
bundle.mkdir(parents=True, exist_ok=True)
|
||||
(bundle / "state.json").write_text(json.dumps(state, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def _sample_state() -> dict:
|
||||
return {
|
||||
"schema_version": 3,
|
||||
"host": {"hostname": "test", "os": "debian", "pkg_backend": "dpkg"},
|
||||
"inventory": {
|
||||
"packages": {"foo": {"section": "net"}, "curl": {"section": "net"}}
|
||||
},
|
||||
"roles": {
|
||||
"users": {
|
||||
"role_name": "users",
|
||||
"users": [
|
||||
{
|
||||
"name": "alice",
|
||||
"uid": 1000,
|
||||
"gid": 1000,
|
||||
"gecos": "Alice Example",
|
||||
"home": "/home/alice",
|
||||
"shell": "/bin/bash",
|
||||
"primary_group": "alice",
|
||||
"supplementary_groups": ["docker"],
|
||||
}
|
||||
],
|
||||
"managed_dirs": [],
|
||||
"managed_files": [],
|
||||
"managed_links": [],
|
||||
"excluded": [],
|
||||
"notes": [],
|
||||
},
|
||||
"services": [
|
||||
{
|
||||
"unit": "foo.service",
|
||||
"role_name": "foo",
|
||||
"packages": ["foo"],
|
||||
"active_state": "active",
|
||||
"unit_file_state": "enabled",
|
||||
"managed_dirs": [
|
||||
{
|
||||
"path": "/etc/foo",
|
||||
"owner": "root",
|
||||
"group": "root",
|
||||
"mode": "0755",
|
||||
}
|
||||
],
|
||||
"managed_files": [
|
||||
{
|
||||
"path": "/etc/foo/foo.conf",
|
||||
"src_rel": "etc/foo.conf",
|
||||
"owner": "root",
|
||||
"group": "root",
|
||||
"mode": "0644",
|
||||
}
|
||||
],
|
||||
"managed_links": [
|
||||
{"path": "/etc/foo/enabled.conf", "target": "/etc/foo/foo.conf"}
|
||||
],
|
||||
"excluded": [],
|
||||
"notes": [],
|
||||
}
|
||||
],
|
||||
"packages": [
|
||||
{
|
||||
"package": "curl",
|
||||
"role_name": "curl",
|
||||
"section": "net",
|
||||
"managed_dirs": [],
|
||||
"managed_files": [],
|
||||
"managed_links": [],
|
||||
"excluded": [],
|
||||
"notes": [],
|
||||
}
|
||||
],
|
||||
"apt_config": {
|
||||
"role_name": "apt_config",
|
||||
"managed_dirs": [],
|
||||
"managed_files": [],
|
||||
"managed_links": [],
|
||||
},
|
||||
"dnf_config": {
|
||||
"role_name": "dnf_config",
|
||||
"managed_dirs": [],
|
||||
"managed_files": [],
|
||||
"managed_links": [],
|
||||
},
|
||||
"sysctl": {
|
||||
"role_name": "sysctl",
|
||||
"managed_dirs": [],
|
||||
"managed_files": [
|
||||
{
|
||||
"path": "/etc/sysctl.d/99-enroll.conf",
|
||||
"src_rel": "sysctl/99-enroll.conf",
|
||||
"owner": "root",
|
||||
"group": "root",
|
||||
"mode": "0644",
|
||||
}
|
||||
],
|
||||
"managed_links": [],
|
||||
"parameters": {"net.ipv4.ip_forward": "1"},
|
||||
"notes": [],
|
||||
},
|
||||
"firewall_runtime": {"role_name": "firewall_runtime", "packages": []},
|
||||
"etc_custom": {
|
||||
"role_name": "etc_custom",
|
||||
"managed_dirs": [],
|
||||
"managed_files": [],
|
||||
"managed_links": [],
|
||||
},
|
||||
"usr_local_custom": {
|
||||
"role_name": "usr_local_custom",
|
||||
"managed_dirs": [],
|
||||
"managed_files": [],
|
||||
"managed_links": [],
|
||||
},
|
||||
"extra_paths": {
|
||||
"role_name": "extra_paths",
|
||||
"managed_dirs": [],
|
||||
"managed_files": [],
|
||||
"managed_links": [],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _write_sample_artifacts(bundle: Path) -> None:
|
||||
artifact = bundle / "artifacts" / "foo" / "etc" / "foo.conf"
|
||||
artifact.parent.mkdir(parents=True, exist_ok=True)
|
||||
artifact.write_text("setting = true\n", encoding="utf-8")
|
||||
sysctl_artifact = bundle / "artifacts" / "sysctl" / "sysctl" / "99-enroll.conf"
|
||||
sysctl_artifact.parent.mkdir(parents=True, exist_ok=True)
|
||||
sysctl_artifact.write_text("net.ipv4.ip_forward = 1\n", encoding="utf-8")
|
||||
|
||||
|
||||
def test_manifest_salt_writes_single_site_state_tree(tmp_path: Path):
|
||||
bundle = tmp_path / "bundle"
|
||||
out = tmp_path / "salt"
|
||||
_write_sample_artifacts(bundle)
|
||||
_write_state(bundle, _sample_state())
|
||||
|
||||
manifest.manifest(str(bundle), str(out), target="salt")
|
||||
|
||||
top = yaml.safe_load((out / "states" / "top.sls").read_text(encoding="utf-8"))
|
||||
assert top["base"]["*"] == ["roles.net", "roles.users", "roles.sysctl"]
|
||||
|
||||
net_sls = (out / "states" / "roles" / "net" / "init.sls").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert "pkg.installed:" in net_sls
|
||||
assert '- name: "curl"' in net_sls
|
||||
assert '- name: "foo"' in net_sls
|
||||
assert '"/etc/foo/foo.conf":' in net_sls
|
||||
assert 'source: "salt://roles/net/files/etc/foo.conf"' in net_sls
|
||||
assert "file.symlink:" in net_sls
|
||||
assert "service.running:" in net_sls
|
||||
assert (out / "states" / "roles" / "net" / "files" / "etc" / "foo.conf").exists()
|
||||
|
||||
users_sls = (out / "states" / "roles" / "users" / "init.sls").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert "group.present:" in users_sls
|
||||
assert "user.present:" in users_sls
|
||||
assert "Alice Example" in users_sls
|
||||
assert "optional_groups" not in users_sls
|
||||
assert "- remove_groups: false" in users_sls
|
||||
|
||||
sysctl_sls = (out / "states" / "roles" / "sysctl" / "init.sls").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert "cmd.run:" in sysctl_sls
|
||||
assert "sysctl -e -p /etc/sysctl.d/99-enroll.conf || true" in sysctl_sls
|
||||
assert (out / "README.md").exists()
|
||||
assert (out / "config" / "master.d" / "enroll.conf").exists()
|
||||
|
||||
|
||||
def test_manifest_salt_fqdn_mode_uses_pillar_and_accumulates_nodes(tmp_path: Path):
|
||||
out = tmp_path / "salt"
|
||||
|
||||
def write_bundle(name: str, content: str) -> Path:
|
||||
bundle = tmp_path / name
|
||||
_write_sample_artifacts(bundle)
|
||||
(bundle / "artifacts" / "foo" / "etc" / "foo.conf").write_text(
|
||||
content, encoding="utf-8"
|
||||
)
|
||||
state = _sample_state()
|
||||
state["host"]["hostname"] = name
|
||||
_write_state(bundle, state)
|
||||
return bundle
|
||||
|
||||
first = write_bundle("first", "first=true\n")
|
||||
second = write_bundle("second", "second=true\n")
|
||||
|
||||
manifest.manifest(str(first), str(out), target="salt", fqdn="first.example")
|
||||
manifest.manifest(str(second), str(out), target="salt", fqdn="second.example")
|
||||
|
||||
state_top = yaml.safe_load((out / "states" / "top.sls").read_text(encoding="utf-8"))
|
||||
assert state_top["base"]["first.example"] == [
|
||||
"roles.curl",
|
||||
"roles.foo",
|
||||
"roles.users",
|
||||
"roles.sysctl",
|
||||
]
|
||||
assert state_top["base"]["second.example"] == [
|
||||
"roles.curl",
|
||||
"roles.foo",
|
||||
"roles.users",
|
||||
"roles.sysctl",
|
||||
]
|
||||
|
||||
pillar_top = yaml.safe_load(
|
||||
(out / "pillar" / "top.sls").read_text(encoding="utf-8")
|
||||
)
|
||||
assert set(pillar_top["base"]) == {"first.example", "second.example"}
|
||||
first_pillar_sls = pillar_top["base"]["first.example"][0]
|
||||
first_node = out / "pillar" / Path(*first_pillar_sls.split("."))
|
||||
first_data = yaml.safe_load(
|
||||
first_node.with_suffix(".sls").read_text(encoding="utf-8")
|
||||
)
|
||||
assert first_data["enroll"]["classes"] == [
|
||||
"roles.curl",
|
||||
"roles.foo",
|
||||
"roles.users",
|
||||
"roles.sysctl",
|
||||
]
|
||||
assert first_data["enroll"]["roles"]["foo"]["packages"] == ["foo"]
|
||||
assert first_data["enroll"]["roles"]["foo"]["files"]["/etc/foo/foo.conf"][
|
||||
"source"
|
||||
] == ("salt://roles/foo/files/nodes/first.example/etc/foo.conf")
|
||||
|
||||
foo_sls = (out / "states" / "roles" / "foo" / "init.sls").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert "salt['pillar.get']('enroll:roles:foo'" in foo_sls
|
||||
assert "pkg.installed:" in foo_sls
|
||||
assert "file.managed:" in foo_sls
|
||||
assert (
|
||||
out
|
||||
/ "states"
|
||||
/ "roles"
|
||||
/ "foo"
|
||||
/ "files"
|
||||
/ "nodes"
|
||||
/ "first.example"
|
||||
/ "etc"
|
||||
/ "foo.conf"
|
||||
).exists()
|
||||
assert (
|
||||
out
|
||||
/ "states"
|
||||
/ "roles"
|
||||
/ "foo"
|
||||
/ "files"
|
||||
/ "nodes"
|
||||
/ "second.example"
|
||||
/ "etc"
|
||||
/ "foo.conf"
|
||||
).exists()
|
||||
|
||||
|
||||
def test_manifest_salt_user_gecos_and_groups_are_salt_safe(tmp_path: Path):
|
||||
bundle = tmp_path / "bundle"
|
||||
out = tmp_path / "salt"
|
||||
state = _sample_state()
|
||||
state["roles"]["users"]["users"][0]["name"] = "node"
|
||||
state["roles"]["users"]["users"][0]["primary_group"] = "node"
|
||||
state["roles"]["users"]["users"][0]["gid"] = 1000
|
||||
state["roles"]["users"]["users"][0]["gecos"] = "Node,,,"
|
||||
_write_sample_artifacts(bundle)
|
||||
_write_state(bundle, state)
|
||||
|
||||
manifest.manifest(str(bundle), str(out), target="salt")
|
||||
|
||||
users_sls = (out / "states" / "roles" / "users" / "init.sls").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert '- fullname: "Node"' in users_sls
|
||||
assert "Node,,," not in users_sls
|
||||
assert "optional_groups" not in users_sls
|
||||
assert "- remove_groups: false" in users_sls
|
||||
|
||||
|
||||
def test_manifest_salt_fqdn_user_pillar_gecos_and_groups_are_salt_safe(tmp_path: Path):
|
||||
bundle = tmp_path / "bundle"
|
||||
out = tmp_path / "salt"
|
||||
state = _sample_state()
|
||||
state["roles"]["users"]["users"][0]["gecos"] = "Node,,,"
|
||||
_write_sample_artifacts(bundle)
|
||||
_write_state(bundle, state)
|
||||
|
||||
manifest.manifest(str(bundle), str(out), target="salt", fqdn="node.example")
|
||||
|
||||
pillar_top = yaml.safe_load(
|
||||
(out / "pillar" / "top.sls").read_text(encoding="utf-8")
|
||||
)
|
||||
node_sls = pillar_top["base"]["node.example"][0]
|
||||
pillar_path = out / "pillar" / Path(*node_sls.split("."))
|
||||
data = yaml.safe_load(pillar_path.with_suffix(".sls").read_text(encoding="utf-8"))
|
||||
alice = data["enroll"]["roles"]["users"]["users"]["alice"]
|
||||
assert alice["fullname"] == "Node"
|
||||
assert "Node,,," not in pillar_path.with_suffix(".sls").read_text(encoding="utf-8")
|
||||
assert alice["remove_groups"] is False
|
||||
assert "optional_groups" not in pillar_path.with_suffix(".sls").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
users_sls = (out / "states" / "roles" / "users" / "init.sls").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert "optional_groups" not in users_sls
|
||||
assert "remove_groups" in users_sls
|
||||
|
||||
|
||||
def test_cli_manifest_target_salt_is_forwarded(monkeypatch, tmp_path):
|
||||
import sys
|
||||
|
||||
import enroll.cli as cli
|
||||
|
||||
called = {}
|
||||
|
||||
def fake_manifest(harvest_dir: str, out_dir: str, **kwargs):
|
||||
called["harvest"] = harvest_dir
|
||||
called["out"] = out_dir
|
||||
called["target"] = kwargs.get("target")
|
||||
|
||||
monkeypatch.setattr(cli, "manifest", fake_manifest)
|
||||
monkeypatch.setattr(
|
||||
sys,
|
||||
"argv",
|
||||
[
|
||||
"enroll",
|
||||
"manifest",
|
||||
"--harvest",
|
||||
str(tmp_path / "bundle"),
|
||||
"--out",
|
||||
str(tmp_path / "salt"),
|
||||
"--target",
|
||||
"salt",
|
||||
],
|
||||
)
|
||||
|
||||
cli.main()
|
||||
assert called["harvest"] == str(tmp_path / "bundle")
|
||||
assert called["out"] == str(tmp_path / "salt")
|
||||
assert called["target"] == "salt"
|
||||
|
||||
|
||||
def test_manifest_salt_renders_container_images_in_single_and_fqdn_modes(
|
||||
tmp_path: Path,
|
||||
):
|
||||
digest = "docker.io/library/nginx@sha256:" + "a" * 64
|
||||
podman_digest = "quay.io/example/app@sha256:" + "b" * 64
|
||||
state = _sample_state()
|
||||
state["roles"]["container_images"] = {
|
||||
"role_name": "container_images",
|
||||
"images": [
|
||||
{
|
||||
"engine": "docker",
|
||||
"scope": "system",
|
||||
"user": None,
|
||||
"home": None,
|
||||
"image_id": "sha256:" + "c" * 64,
|
||||
"repo_tags": ["docker.io/library/nginx:1.27"],
|
||||
"repo_digests": [digest],
|
||||
"pull_ref": digest,
|
||||
"tag_aliases": [
|
||||
{
|
||||
"ref": "docker.io/library/nginx:1.27",
|
||||
"repository": "docker.io/library/nginx",
|
||||
"tag": "1.27",
|
||||
}
|
||||
],
|
||||
"os": "linux",
|
||||
"architecture": "amd64",
|
||||
"variant": None,
|
||||
"platform": "linux/amd64",
|
||||
"size": 123,
|
||||
"created": "2026-01-01T00:00:00Z",
|
||||
"source": "docker image inspect",
|
||||
"notes": [],
|
||||
},
|
||||
{
|
||||
"engine": "podman",
|
||||
"scope": "system",
|
||||
"user": None,
|
||||
"home": None,
|
||||
"image_id": "sha256:" + "d" * 64,
|
||||
"repo_tags": ["quay.io/example/app:prod"],
|
||||
"repo_digests": [podman_digest],
|
||||
"pull_ref": podman_digest,
|
||||
"tag_aliases": [
|
||||
{
|
||||
"ref": "quay.io/example/app:prod",
|
||||
"repository": "quay.io/example/app",
|
||||
"tag": "prod",
|
||||
}
|
||||
],
|
||||
"os": "linux",
|
||||
"architecture": "amd64",
|
||||
"variant": None,
|
||||
"platform": "linux/amd64",
|
||||
"size": 456,
|
||||
"created": "2026-01-01T00:00:00Z",
|
||||
"source": "podman image inspect",
|
||||
"notes": [],
|
||||
},
|
||||
],
|
||||
"notes": [],
|
||||
}
|
||||
bundle = tmp_path / "bundle"
|
||||
out = tmp_path / "salt"
|
||||
_write_sample_artifacts(bundle)
|
||||
_write_state(bundle, state)
|
||||
|
||||
manifest.manifest(str(bundle), str(out), target="salt")
|
||||
|
||||
top = yaml.safe_load((out / "states" / "top.sls").read_text(encoding="utf-8"))
|
||||
assert "roles.container_images" in top["base"]["*"]
|
||||
sls = (out / "states" / "roles" / "container_images" / "init.sls").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
assert "docker_image.present:" 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