Compare commits
2 commits
02feff014f
...
adfeb21d4b
| Author | SHA1 | Date | |
|---|---|---|---|
| adfeb21d4b | |||
| 0d111caf62 |
11 changed files with 1725 additions and 17 deletions
|
|
@ -13,10 +13,14 @@ jobs:
|
||||||
|
|
||||||
- name: Install system dependencies
|
- name: Install system dependencies
|
||||||
run: |
|
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
|
apt-get update
|
||||||
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
|
||||||
ansible ansible-lint python3-venv pipx systemctl python3-apt jq python3-jsonschema \
|
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
|
- name: Install Poetry
|
||||||
run: |
|
run: |
|
||||||
|
|
|
||||||
|
|
@ -5,9 +5,10 @@
|
||||||
* Detect active sysctl parameters and write them to a `/etc/sysctl.d/99-enroll.conf` file
|
* Detect active sysctl parameters and write them to a `/etc/sysctl.d/99-enroll.conf` file
|
||||||
* Use `no_log` on systemd unit interrogations to suppress potential sensitive output when applying Ansible
|
* Use `no_log` on systemd unit interrogations to suppress potential sensitive output when applying Ansible
|
||||||
* Support manifesting Puppet code, as well as Ansible!
|
* Support manifesting Puppet code, as well as Ansible!
|
||||||
|
* Support manifesting Salt code, as well as Ansible and Puppet!
|
||||||
* A lot of under-the-bonnet refactoring to make it easier to extend to cover other config managers (that don't suck) in future.
|
* 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
|
* Support for detecting Docker images. You will need to install puppetlabs-docker module if you're using the Puppet manifester.
|
||||||
* Add support for detecting flatpaks and snaps (manifests Ansible code only, not Puppet at this time)
|
* Add support for detecting flatpaks and snaps (manifests Ansible code only, not Puppet or Salt at this time)
|
||||||
|
|
||||||
# 0.6.0
|
# 0.6.0
|
||||||
|
|
||||||
|
|
|
||||||
35
README.md
35
README.md
|
|
@ -120,7 +120,7 @@ enroll single-shot --remote-host myhost.example.com --remote-user myuser --ssh-k
|
||||||
---
|
---
|
||||||
|
|
||||||
### `enroll manifest`
|
### `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**
|
**Inputs**
|
||||||
- `--harvest /path/to/harvest` (directory)
|
- `--harvest /path/to/harvest` (directory)
|
||||||
|
|
@ -129,11 +129,12 @@ Generate configuration-management output from an existing harvest bundle. Ansibl
|
||||||
**Output**
|
**Output**
|
||||||
- In plaintext Ansible mode: an Ansible repo-like directory structure (roles/playbooks, and inventory in multi-site mode).
|
- 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 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.
|
- In `--sops` mode: a single encrypted file `manifest.tar.gz.sops` containing the generated output.
|
||||||
|
|
||||||
**Common flags**
|
**Common flags**
|
||||||
- `--target ansible|puppet`: choose the manifest target (`ansible` is the default).
|
- `--target ansible|puppet|salt`: 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 { ... }`.
|
- `--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.
|
- `--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**
|
**Role tags**
|
||||||
|
|
@ -467,6 +468,34 @@ 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.
|
Docker images with registry digests are rendered as `docker::image` resources and require the Puppet environment to provide `puppetlabs-docker`; the generated module metadata records that dependency. Podman images with registry digests are rendered as guarded `podman pull` / `podman tag` exec resources. Images without `RepoDigest` are recorded in harvest state and notes, but are not converted into exact pull resources. Flatpak, Snap, and live firewall runtime snapshots are listed as notes in the generated Puppet README rather than converted into Puppet resources.
|
||||||
|
|
||||||
|
### Salt target
|
||||||
|
```bash
|
||||||
|
enroll manifest --harvest /tmp/enroll-harvest --out /tmp/enroll-salt --target salt
|
||||||
|
```
|
||||||
|
|
||||||
|
The Salt target renders native packages, users/groups, managed directories/files/symlinks, basic service state, and the generated sysctl file/apply command when present. Without `--fqdn`, it writes a self-contained state tree under `states/` and targets all minions in `states/top.sls`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /tmp/enroll-salt
|
||||||
|
sudo salt-call --local --file-root ./states state.apply test=True
|
||||||
|
```
|
||||||
|
|
||||||
|
With `--fqdn`, it uses Salt's state/pillar split: `states/top.sls` targets the minion ID to reusable generated role SLS files, while `pillar/top.sls` targets the same minion ID to node-specific data under `pillar/nodes/`. Host-specific file artifacts are stored under `states/roles/<role>/files/nodes/<fqdn>/...` and referenced through `salt://` URLs:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
enroll manifest --harvest /tmp/enroll-harvest --out /tmp/enroll-salt --target salt --fqdn host.example.com
|
||||||
|
cd /tmp/enroll-salt
|
||||||
|
sudo salt-call --local --file-root ./states --pillar-root ./pillar --id host.example.com state.apply test=True
|
||||||
|
```
|
||||||
|
|
||||||
|
Re-running Salt `--fqdn` output into the same directory adds or replaces that minion's top/pillar data without deleting other generated minions.
|
||||||
|
|
||||||
|
Docker and Podman images with registry digests are rendered as guarded `cmd.run` states that use the local `docker`/`podman` CLI directly (`pull`, `image inspect`, and `tag`).
|
||||||
|
|
||||||
|
This is because Salt Stack, in 3008, does not have proper Docker extensions that actually work. Wow.
|
||||||
|
|
||||||
|
Certain other things, like in Puppet, are not 'manifested' into Salt states unlike Ansible, at this time: these are Flatpak, Snap, and live firewall rules.
|
||||||
|
|
||||||
### Manifest with `--sops`
|
### Manifest with `--sops`
|
||||||
```bash
|
```bash
|
||||||
# Generate encrypted manifest bundle (writes /tmp/enroll-ansible/manifest.tar.gz.sops)
|
# 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:
|
class AnsibleManagedFileRoleSpec:
|
||||||
"""Declarative managed-file singleton role rendering spec.
|
"""Declarative managed-file singleton role rendering spec.
|
||||||
|
|
||||||
Puppet collects these singleton snapshots in a simple loop and feeds
|
Puppet and Salt collect these singleton snapshots in a simple loop and feed
|
||||||
each one through the same managed-content renderer. Ansible has more
|
each one through the same managed-content renderer. Ansible has more
|
||||||
layout concerns (defaults vs host_vars, optional JinjaTurtle templates,
|
layout concerns (defaults vs host_vars, optional JinjaTurtle templates,
|
||||||
handlers), but the resource intent is the same, so keep the per-role
|
handlers), but the resource intent is the same, so keep the per-role
|
||||||
differences in data rather than spelling out one branch 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,
|
manifest_plan: AnsibleManifestPlan,
|
||||||
roles: Dict[str, Any],
|
roles: Dict[str, Any],
|
||||||
) -> None:
|
) -> 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:
|
for spec in MANAGED_FILE_ROLE_SPECS:
|
||||||
snapshot = roles.get(spec.key, {})
|
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:
|
def _render_install_packages_tasks(role: str, var_prefix: str) -> str:
|
||||||
"""Render package installation through Ansible's generic package provider.
|
"""Render package installation through Ansible's generic package provider.
|
||||||
|
|
||||||
Puppet uses provider-backed package resources instead of selecting
|
Puppet and Salt use provider-backed package resources instead of selecting
|
||||||
apt/dnf/yum in the generated manifest. Ansible's package module is the
|
apt/dnf/yum in the generated manifest. Ansible's package module is the
|
||||||
equivalent abstraction: it proxies to the target host's detected package
|
equivalent abstraction: it proxies to the target host's detected package
|
||||||
manager and keeps generated roles provider-neutral.
|
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:
|
def _add_common_manifest_args(p: argparse.ArgumentParser) -> None:
|
||||||
p.add_argument(
|
p.add_argument(
|
||||||
"--target",
|
"--target",
|
||||||
choices=["ansible", "puppet"],
|
choices=["ansible", "puppet", "salt"],
|
||||||
default="ansible",
|
default="ansible",
|
||||||
help="Manifest target to generate (default: ansible).",
|
help="Manifest target to generate (default: ansible).",
|
||||||
)
|
)
|
||||||
p.add_argument(
|
p.add_argument(
|
||||||
"--fqdn",
|
"--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(
|
p.add_argument(
|
||||||
"--no-common-roles",
|
"--no-common-roles",
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ class CMModule:
|
||||||
"""Renderer-neutral configuration-management resource group.
|
"""Renderer-neutral configuration-management resource group.
|
||||||
|
|
||||||
A CMModule is intentionally small: it captures the resources that a target
|
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.
|
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:
|
def resolve_catalog_conflicts(modules: Iterable[CMModule]) -> None:
|
||||||
"""Resolve global catalog conflicts before renderer output.
|
"""Resolve global catalog conflicts before renderer output.
|
||||||
|
|
||||||
Puppet compiles a single resource catalog. Ansible can tolerate the same
|
Puppet and Salt compile a single resource catalog. Ansible can tolerate the
|
||||||
package, service, or parent directory appearing in more than one role;
|
same package, service, or parent directory appearing in more than one role;
|
||||||
catalog targets cannot. Resolve those conflicts in the shared model rather
|
catalog targets cannot. Resolve those conflicts in the shared model rather
|
||||||
than deleting renderer output after the fact.
|
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 .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 .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 .remote import _safe_extract_tar
|
||||||
from .sopsutil import (
|
from .sopsutil import (
|
||||||
decrypt_file_binary_to,
|
decrypt_file_binary_to,
|
||||||
|
|
@ -190,7 +191,7 @@ def manifest(
|
||||||
- In plain mode: None
|
- In plain mode: None
|
||||||
"""
|
"""
|
||||||
target = (target or "ansible").strip().lower()
|
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}")
|
raise ValueError(f"unsupported manifest target: {target!r}")
|
||||||
|
|
||||||
sops_mode = bool(sops_fingerprints)
|
sops_mode = bool(sops_fingerprints)
|
||||||
|
|
@ -210,6 +211,13 @@ def manifest(
|
||||||
fqdn=fqdn,
|
fqdn=fqdn,
|
||||||
no_common_roles=no_common_roles,
|
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:
|
else:
|
||||||
manifest_ansible_from_bundle_dir(
|
manifest_ansible_from_bundle_dir(
|
||||||
resolved_bundle_dir,
|
resolved_bundle_dir,
|
||||||
|
|
@ -238,6 +246,13 @@ def manifest(
|
||||||
fqdn=fqdn,
|
fqdn=fqdn,
|
||||||
no_common_roles=no_common_roles,
|
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:
|
else:
|
||||||
manifest_ansible_from_bundle_dir(
|
manifest_ansible_from_bundle_dir(
|
||||||
resolved_bundle_dir,
|
resolved_bundle_dir,
|
||||||
|
|
|
||||||
1162
enroll/salt.py
Normal file
1162
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"
|
ANSIBLE_FQDN_DIR="${WORK_DIR}/ansible-fqdn"
|
||||||
PUPPET_DIR="${WORK_DIR}/puppet"
|
PUPPET_DIR="${WORK_DIR}/puppet"
|
||||||
PUPPET_FQDN_DIR="${WORK_DIR}/puppet-fqdn"
|
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}"
|
TEST_FQDN="${ENROLL_TEST_FQDN:-enroll-ci.example.test}"
|
||||||
|
|
||||||
cleanup() {
|
cleanup() {
|
||||||
|
|
@ -105,6 +107,13 @@ ensure_puppet() {
|
||||||
require_cmd puppet "Install Puppet before running the Puppet noop integration tests."
|
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() {
|
run_pytests() {
|
||||||
section "Python unit tests"
|
section "Python unit tests"
|
||||||
cd "${PROJECT_ROOT}"
|
cd "${PROJECT_ROOT}"
|
||||||
|
|
@ -170,6 +179,25 @@ run_puppet_noop_tests() {
|
||||||
--noop
|
--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() {
|
main() {
|
||||||
require_root
|
require_root
|
||||||
require_debian_ci
|
require_debian_ci
|
||||||
|
|
@ -177,6 +205,7 @@ main() {
|
||||||
prepare_harvest_fixture
|
prepare_harvest_fixture
|
||||||
run_ansible_noop_tests
|
run_ansible_noop_tests
|
||||||
run_puppet_noop_tests
|
run_puppet_noop_tests
|
||||||
|
run_salt_noop_tests
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|
|
||||||
468
tests/test_manifest_salt.py
Normal file
468
tests/test_manifest_salt.py
Normal file
|
|
@ -0,0 +1,468 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
from enroll import manifest
|
||||||
|
|
||||||
|
|
||||||
|
def _write_state(bundle: Path, state: dict) -> None:
|
||||||
|
bundle.mkdir(parents=True, exist_ok=True)
|
||||||
|
(bundle / "state.json").write_text(json.dumps(state, indent=2), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def _sample_state() -> dict:
|
||||||
|
return {
|
||||||
|
"schema_version": 3,
|
||||||
|
"host": {"hostname": "test", "os": "debian", "pkg_backend": "dpkg"},
|
||||||
|
"inventory": {
|
||||||
|
"packages": {"foo": {"section": "net"}, "curl": {"section": "net"}}
|
||||||
|
},
|
||||||
|
"roles": {
|
||||||
|
"users": {
|
||||||
|
"role_name": "users",
|
||||||
|
"users": [
|
||||||
|
{
|
||||||
|
"name": "alice",
|
||||||
|
"uid": 1000,
|
||||||
|
"gid": 1000,
|
||||||
|
"gecos": "Alice Example",
|
||||||
|
"home": "/home/alice",
|
||||||
|
"shell": "/bin/bash",
|
||||||
|
"primary_group": "alice",
|
||||||
|
"supplementary_groups": ["docker"],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"managed_dirs": [],
|
||||||
|
"managed_files": [],
|
||||||
|
"managed_links": [],
|
||||||
|
"excluded": [],
|
||||||
|
"notes": [],
|
||||||
|
},
|
||||||
|
"services": [
|
||||||
|
{
|
||||||
|
"unit": "foo.service",
|
||||||
|
"role_name": "foo",
|
||||||
|
"packages": ["foo"],
|
||||||
|
"active_state": "active",
|
||||||
|
"unit_file_state": "enabled",
|
||||||
|
"managed_dirs": [
|
||||||
|
{
|
||||||
|
"path": "/etc/foo",
|
||||||
|
"owner": "root",
|
||||||
|
"group": "root",
|
||||||
|
"mode": "0755",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"managed_files": [
|
||||||
|
{
|
||||||
|
"path": "/etc/foo/foo.conf",
|
||||||
|
"src_rel": "etc/foo.conf",
|
||||||
|
"owner": "root",
|
||||||
|
"group": "root",
|
||||||
|
"mode": "0644",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"managed_links": [
|
||||||
|
{"path": "/etc/foo/enabled.conf", "target": "/etc/foo/foo.conf"}
|
||||||
|
],
|
||||||
|
"excluded": [],
|
||||||
|
"notes": [],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"packages": [
|
||||||
|
{
|
||||||
|
"package": "curl",
|
||||||
|
"role_name": "curl",
|
||||||
|
"section": "net",
|
||||||
|
"managed_dirs": [],
|
||||||
|
"managed_files": [],
|
||||||
|
"managed_links": [],
|
||||||
|
"excluded": [],
|
||||||
|
"notes": [],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"apt_config": {
|
||||||
|
"role_name": "apt_config",
|
||||||
|
"managed_dirs": [],
|
||||||
|
"managed_files": [],
|
||||||
|
"managed_links": [],
|
||||||
|
},
|
||||||
|
"dnf_config": {
|
||||||
|
"role_name": "dnf_config",
|
||||||
|
"managed_dirs": [],
|
||||||
|
"managed_files": [],
|
||||||
|
"managed_links": [],
|
||||||
|
},
|
||||||
|
"sysctl": {
|
||||||
|
"role_name": "sysctl",
|
||||||
|
"managed_dirs": [],
|
||||||
|
"managed_files": [
|
||||||
|
{
|
||||||
|
"path": "/etc/sysctl.d/99-enroll.conf",
|
||||||
|
"src_rel": "sysctl/99-enroll.conf",
|
||||||
|
"owner": "root",
|
||||||
|
"group": "root",
|
||||||
|
"mode": "0644",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"managed_links": [],
|
||||||
|
"parameters": {"net.ipv4.ip_forward": "1"},
|
||||||
|
"notes": [],
|
||||||
|
},
|
||||||
|
"firewall_runtime": {"role_name": "firewall_runtime", "packages": []},
|
||||||
|
"etc_custom": {
|
||||||
|
"role_name": "etc_custom",
|
||||||
|
"managed_dirs": [],
|
||||||
|
"managed_files": [],
|
||||||
|
"managed_links": [],
|
||||||
|
},
|
||||||
|
"usr_local_custom": {
|
||||||
|
"role_name": "usr_local_custom",
|
||||||
|
"managed_dirs": [],
|
||||||
|
"managed_files": [],
|
||||||
|
"managed_links": [],
|
||||||
|
},
|
||||||
|
"extra_paths": {
|
||||||
|
"role_name": "extra_paths",
|
||||||
|
"managed_dirs": [],
|
||||||
|
"managed_files": [],
|
||||||
|
"managed_links": [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _write_sample_artifacts(bundle: Path) -> None:
|
||||||
|
artifact = bundle / "artifacts" / "foo" / "etc" / "foo.conf"
|
||||||
|
artifact.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
artifact.write_text("setting = true\n", encoding="utf-8")
|
||||||
|
sysctl_artifact = bundle / "artifacts" / "sysctl" / "sysctl" / "99-enroll.conf"
|
||||||
|
sysctl_artifact.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
sysctl_artifact.write_text("net.ipv4.ip_forward = 1\n", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_manifest_salt_writes_single_site_state_tree(tmp_path: Path):
|
||||||
|
bundle = tmp_path / "bundle"
|
||||||
|
out = tmp_path / "salt"
|
||||||
|
_write_sample_artifacts(bundle)
|
||||||
|
_write_state(bundle, _sample_state())
|
||||||
|
|
||||||
|
manifest.manifest(str(bundle), str(out), target="salt")
|
||||||
|
|
||||||
|
top = yaml.safe_load((out / "states" / "top.sls").read_text(encoding="utf-8"))
|
||||||
|
assert top["base"]["*"] == ["roles.net", "roles.users", "roles.sysctl"]
|
||||||
|
|
||||||
|
net_sls = (out / "states" / "roles" / "net" / "init.sls").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
assert "pkg.installed:" in net_sls
|
||||||
|
assert '- name: "curl"' in net_sls
|
||||||
|
assert '- name: "foo"' in net_sls
|
||||||
|
assert '"/etc/foo/foo.conf":' in net_sls
|
||||||
|
assert 'source: "salt://roles/net/files/etc/foo.conf"' in net_sls
|
||||||
|
assert "file.symlink:" in net_sls
|
||||||
|
assert "service.running:" in net_sls
|
||||||
|
assert (out / "states" / "roles" / "net" / "files" / "etc" / "foo.conf").exists()
|
||||||
|
|
||||||
|
users_sls = (out / "states" / "roles" / "users" / "init.sls").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
assert "group.present:" in users_sls
|
||||||
|
assert "user.present:" in users_sls
|
||||||
|
assert "Alice Example" in users_sls
|
||||||
|
assert "optional_groups" not in users_sls
|
||||||
|
assert "- remove_groups: false" in users_sls
|
||||||
|
|
||||||
|
sysctl_sls = (out / "states" / "roles" / "sysctl" / "init.sls").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
assert "cmd.run:" in sysctl_sls
|
||||||
|
assert "sysctl -e -p /etc/sysctl.d/99-enroll.conf || true" in sysctl_sls
|
||||||
|
assert (out / "README.md").exists()
|
||||||
|
assert (out / "config" / "master.d" / "enroll.conf").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_manifest_salt_fqdn_mode_uses_pillar_and_accumulates_nodes(tmp_path: Path):
|
||||||
|
out = tmp_path / "salt"
|
||||||
|
|
||||||
|
def write_bundle(name: str, content: str) -> Path:
|
||||||
|
bundle = tmp_path / name
|
||||||
|
_write_sample_artifacts(bundle)
|
||||||
|
(bundle / "artifacts" / "foo" / "etc" / "foo.conf").write_text(
|
||||||
|
content, encoding="utf-8"
|
||||||
|
)
|
||||||
|
state = _sample_state()
|
||||||
|
state["host"]["hostname"] = name
|
||||||
|
_write_state(bundle, state)
|
||||||
|
return bundle
|
||||||
|
|
||||||
|
first = write_bundle("first", "first=true\n")
|
||||||
|
second = write_bundle("second", "second=true\n")
|
||||||
|
|
||||||
|
manifest.manifest(str(first), str(out), target="salt", fqdn="first.example")
|
||||||
|
manifest.manifest(str(second), str(out), target="salt", fqdn="second.example")
|
||||||
|
|
||||||
|
state_top = yaml.safe_load((out / "states" / "top.sls").read_text(encoding="utf-8"))
|
||||||
|
assert state_top["base"]["first.example"] == [
|
||||||
|
"roles.curl",
|
||||||
|
"roles.foo",
|
||||||
|
"roles.users",
|
||||||
|
"roles.sysctl",
|
||||||
|
]
|
||||||
|
assert state_top["base"]["second.example"] == [
|
||||||
|
"roles.curl",
|
||||||
|
"roles.foo",
|
||||||
|
"roles.users",
|
||||||
|
"roles.sysctl",
|
||||||
|
]
|
||||||
|
|
||||||
|
pillar_top = yaml.safe_load(
|
||||||
|
(out / "pillar" / "top.sls").read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
assert set(pillar_top["base"]) == {"first.example", "second.example"}
|
||||||
|
first_pillar_sls = pillar_top["base"]["first.example"][0]
|
||||||
|
first_node = out / "pillar" / Path(*first_pillar_sls.split("."))
|
||||||
|
first_data = yaml.safe_load(
|
||||||
|
first_node.with_suffix(".sls").read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
assert first_data["enroll"]["classes"] == [
|
||||||
|
"roles.curl",
|
||||||
|
"roles.foo",
|
||||||
|
"roles.users",
|
||||||
|
"roles.sysctl",
|
||||||
|
]
|
||||||
|
assert first_data["enroll"]["roles"]["foo"]["packages"] == ["foo"]
|
||||||
|
assert first_data["enroll"]["roles"]["foo"]["files"]["/etc/foo/foo.conf"][
|
||||||
|
"source"
|
||||||
|
] == ("salt://roles/foo/files/nodes/first.example/etc/foo.conf")
|
||||||
|
|
||||||
|
foo_sls = (out / "states" / "roles" / "foo" / "init.sls").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
assert "salt['pillar.get']('enroll:roles:foo'" in foo_sls
|
||||||
|
assert "pkg.installed:" in foo_sls
|
||||||
|
assert "file.managed:" in foo_sls
|
||||||
|
assert (
|
||||||
|
out
|
||||||
|
/ "states"
|
||||||
|
/ "roles"
|
||||||
|
/ "foo"
|
||||||
|
/ "files"
|
||||||
|
/ "nodes"
|
||||||
|
/ "first.example"
|
||||||
|
/ "etc"
|
||||||
|
/ "foo.conf"
|
||||||
|
).exists()
|
||||||
|
assert (
|
||||||
|
out
|
||||||
|
/ "states"
|
||||||
|
/ "roles"
|
||||||
|
/ "foo"
|
||||||
|
/ "files"
|
||||||
|
/ "nodes"
|
||||||
|
/ "second.example"
|
||||||
|
/ "etc"
|
||||||
|
/ "foo.conf"
|
||||||
|
).exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_manifest_salt_user_gecos_and_groups_are_salt_safe(tmp_path: Path):
|
||||||
|
bundle = tmp_path / "bundle"
|
||||||
|
out = tmp_path / "salt"
|
||||||
|
state = _sample_state()
|
||||||
|
state["roles"]["users"]["users"][0]["name"] = "node"
|
||||||
|
state["roles"]["users"]["users"][0]["primary_group"] = "node"
|
||||||
|
state["roles"]["users"]["users"][0]["gid"] = 1000
|
||||||
|
state["roles"]["users"]["users"][0]["gecos"] = "Node,,,"
|
||||||
|
_write_sample_artifacts(bundle)
|
||||||
|
_write_state(bundle, state)
|
||||||
|
|
||||||
|
manifest.manifest(str(bundle), str(out), target="salt")
|
||||||
|
|
||||||
|
users_sls = (out / "states" / "roles" / "users" / "init.sls").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
assert '- fullname: "Node"' in users_sls
|
||||||
|
assert "Node,,," not in users_sls
|
||||||
|
assert "optional_groups" not in users_sls
|
||||||
|
assert "- remove_groups: false" in users_sls
|
||||||
|
|
||||||
|
|
||||||
|
def test_manifest_salt_fqdn_user_pillar_gecos_and_groups_are_salt_safe(tmp_path: Path):
|
||||||
|
bundle = tmp_path / "bundle"
|
||||||
|
out = tmp_path / "salt"
|
||||||
|
state = _sample_state()
|
||||||
|
state["roles"]["users"]["users"][0]["gecos"] = "Node,,,"
|
||||||
|
_write_sample_artifacts(bundle)
|
||||||
|
_write_state(bundle, state)
|
||||||
|
|
||||||
|
manifest.manifest(str(bundle), str(out), target="salt", fqdn="node.example")
|
||||||
|
|
||||||
|
pillar_top = yaml.safe_load(
|
||||||
|
(out / "pillar" / "top.sls").read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
node_sls = pillar_top["base"]["node.example"][0]
|
||||||
|
pillar_path = out / "pillar" / Path(*node_sls.split("."))
|
||||||
|
data = yaml.safe_load(pillar_path.with_suffix(".sls").read_text(encoding="utf-8"))
|
||||||
|
alice = data["enroll"]["roles"]["users"]["users"]["alice"]
|
||||||
|
assert alice["fullname"] == "Node"
|
||||||
|
assert "Node,,," not in pillar_path.with_suffix(".sls").read_text(encoding="utf-8")
|
||||||
|
assert alice["remove_groups"] is False
|
||||||
|
assert "optional_groups" not in pillar_path.with_suffix(".sls").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
|
||||||
|
users_sls = (out / "states" / "roles" / "users" / "init.sls").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
assert "optional_groups" not in users_sls
|
||||||
|
assert "remove_groups" in users_sls
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_manifest_target_salt_is_forwarded(monkeypatch, tmp_path):
|
||||||
|
import sys
|
||||||
|
|
||||||
|
import enroll.cli as cli
|
||||||
|
|
||||||
|
called = {}
|
||||||
|
|
||||||
|
def fake_manifest(harvest_dir: str, out_dir: str, **kwargs):
|
||||||
|
called["harvest"] = harvest_dir
|
||||||
|
called["out"] = out_dir
|
||||||
|
called["target"] = kwargs.get("target")
|
||||||
|
|
||||||
|
monkeypatch.setattr(cli, "manifest", fake_manifest)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
sys,
|
||||||
|
"argv",
|
||||||
|
[
|
||||||
|
"enroll",
|
||||||
|
"manifest",
|
||||||
|
"--harvest",
|
||||||
|
str(tmp_path / "bundle"),
|
||||||
|
"--out",
|
||||||
|
str(tmp_path / "salt"),
|
||||||
|
"--target",
|
||||||
|
"salt",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
cli.main()
|
||||||
|
assert called["harvest"] == str(tmp_path / "bundle")
|
||||||
|
assert called["out"] == str(tmp_path / "salt")
|
||||||
|
assert called["target"] == "salt"
|
||||||
|
|
||||||
|
|
||||||
|
def test_manifest_salt_renders_container_images_in_single_and_fqdn_modes(
|
||||||
|
tmp_path: Path,
|
||||||
|
):
|
||||||
|
digest = "docker.io/library/nginx@sha256:" + "a" * 64
|
||||||
|
podman_digest = "quay.io/example/app@sha256:" + "b" * 64
|
||||||
|
state = _sample_state()
|
||||||
|
state["roles"]["container_images"] = {
|
||||||
|
"role_name": "container_images",
|
||||||
|
"images": [
|
||||||
|
{
|
||||||
|
"engine": "docker",
|
||||||
|
"scope": "system",
|
||||||
|
"user": None,
|
||||||
|
"home": None,
|
||||||
|
"image_id": "sha256:" + "c" * 64,
|
||||||
|
"repo_tags": ["docker.io/library/nginx:1.27"],
|
||||||
|
"repo_digests": [digest],
|
||||||
|
"pull_ref": digest,
|
||||||
|
"tag_aliases": [
|
||||||
|
{
|
||||||
|
"ref": "docker.io/library/nginx:1.27",
|
||||||
|
"repository": "docker.io/library/nginx",
|
||||||
|
"tag": "1.27",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"os": "linux",
|
||||||
|
"architecture": "amd64",
|
||||||
|
"variant": None,
|
||||||
|
"platform": "linux/amd64",
|
||||||
|
"size": 123,
|
||||||
|
"created": "2026-01-01T00:00:00Z",
|
||||||
|
"source": "docker image inspect",
|
||||||
|
"notes": [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"engine": "podman",
|
||||||
|
"scope": "system",
|
||||||
|
"user": None,
|
||||||
|
"home": None,
|
||||||
|
"image_id": "sha256:" + "d" * 64,
|
||||||
|
"repo_tags": ["quay.io/example/app:prod"],
|
||||||
|
"repo_digests": [podman_digest],
|
||||||
|
"pull_ref": podman_digest,
|
||||||
|
"tag_aliases": [
|
||||||
|
{
|
||||||
|
"ref": "quay.io/example/app:prod",
|
||||||
|
"repository": "quay.io/example/app",
|
||||||
|
"tag": "prod",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"os": "linux",
|
||||||
|
"architecture": "amd64",
|
||||||
|
"variant": None,
|
||||||
|
"platform": "linux/amd64",
|
||||||
|
"size": 456,
|
||||||
|
"created": "2026-01-01T00:00:00Z",
|
||||||
|
"source": "podman image inspect",
|
||||||
|
"notes": [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"notes": [],
|
||||||
|
}
|
||||||
|
bundle = tmp_path / "bundle"
|
||||||
|
out = tmp_path / "salt"
|
||||||
|
_write_sample_artifacts(bundle)
|
||||||
|
_write_state(bundle, state)
|
||||||
|
|
||||||
|
manifest.manifest(str(bundle), str(out), target="salt")
|
||||||
|
|
||||||
|
top = yaml.safe_load((out / "states" / "top.sls").read_text(encoding="utf-8"))
|
||||||
|
assert "roles.container_images" in top["base"]["*"]
|
||||||
|
sls = (out / "states" / "roles" / "container_images" / "init.sls").read_text(
|
||||||
|
encoding="utf-8"
|
||||||
|
)
|
||||||
|
assert "docker_image.present:" not in sls
|
||||||
|
assert "docker pull" in sls
|
||||||
|
assert digest in sls
|
||||||
|
assert "docker image inspect" in sls
|
||||||
|
assert "{{.Id}}" not in sls
|
||||||
|
assert "sed -n" in sls
|
||||||
|
assert "docker tag" in sls
|
||||||
|
assert "- cmd: enroll_docker_pull_container_images" in sls
|
||||||
|
assert "podman pull" in sls
|
||||||
|
assert "podman tag" in sls
|
||||||
|
|
||||||
|
fqdn_out = tmp_path / "salt-fqdn"
|
||||||
|
manifest.manifest(str(bundle), str(fqdn_out), target="salt", fqdn="node.example")
|
||||||
|
pillar_top = yaml.safe_load(
|
||||||
|
(fqdn_out / "pillar" / "top.sls").read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
node_sls = pillar_top["base"]["node.example"][0]
|
||||||
|
pillar_path = fqdn_out / "pillar" / Path(*node_sls.split("."))
|
||||||
|
pillar = yaml.safe_load(pillar_path.with_suffix(".sls").read_text(encoding="utf-8"))
|
||||||
|
assert (
|
||||||
|
pillar["enroll"]["roles"]["container_images"]["container_images"][0]["pull_ref"]
|
||||||
|
== digest
|
||||||
|
)
|
||||||
|
fqdn_sls = (
|
||||||
|
fqdn_out / "states" / "roles" / "container_images" / "init.sls"
|
||||||
|
).read_text(encoding="utf-8")
|
||||||
|
assert "docker_image.present:" not in fqdn_sls
|
||||||
|
assert "enroll_docker_pull_container_images" in fqdn_sls
|
||||||
|
assert "enroll_podman_pull_container_images" in fqdn_sls
|
||||||
|
assert "image.get('pull_cmd')" in fqdn_sls
|
||||||
|
pillar_text = pillar_path.with_suffix(".sls").read_text(encoding="utf-8")
|
||||||
|
assert "docker pull" in pillar_text
|
||||||
|
assert "docker image inspect" in pillar_text
|
||||||
|
assert "{{.Id}}" not in pillar_text
|
||||||
|
assert "sed -n" in pillar_text
|
||||||
|
assert "podman pull" in pillar_text
|
||||||
Loading…
Add table
Add a link
Reference in a new issue