enroll/tests/test_manifest.py
Miguel Jacq 6a36a9d2d5
Remote mode and dangerous flag, other tweaks
* Add remote mode for harvesting a remote machine via a local workstation (no need to install enroll remotely)
   Optionally use `--no-sudo` if you don't want the remote user to have passwordless sudo when conducting the
   harvest, albeit you'll end up with less useful data (same as if running `enroll harvest` on a machine without
   sudo)
 * Add `--dangerous` flag to capture even sensitive data (use at your own risk!)
 * Do a better job at capturing other config files in `/etc/<package>/` even if that package doesn't normally
   ship or manage those files.
2025-12-17 17:02:16 +11:00

251 lines
8.2 KiB
Python

import json
from pathlib import Path
from enroll.manifest import manifest
def test_manifest_writes_roles_and_playbook_with_clean_when(tmp_path: Path):
bundle = tmp_path / "bundle"
out = tmp_path / "ansible"
(bundle / "artifacts" / "foo" / "etc").mkdir(parents=True, exist_ok=True)
(bundle / "artifacts" / "foo" / "etc" / "foo.conf").write_text(
"x", encoding="utf-8"
)
state = {
"host": {"hostname": "test", "os": "debian"},
"users": {
"role_name": "users",
"users": [
{
"name": "alice",
"uid": 1000,
"gid": 1000,
"gecos": "Alice",
"home": "/home/alice",
"shell": "/bin/bash",
"primary_group": "alice",
"supplementary_groups": ["docker", "qubes"],
}
],
"managed_files": [],
"excluded": [],
"notes": [],
},
"etc_custom": {
"role_name": "etc_custom",
"managed_files": [
{
"path": "/etc/default/keyboard",
"src_rel": "etc/default/keyboard",
"owner": "root",
"group": "root",
"mode": "0644",
"reason": "custom_unowned",
}
],
"excluded": [],
"notes": [],
},
"services": [
{
"unit": "foo.service",
"role_name": "foo",
"packages": ["foo"],
"active_state": "inactive",
"sub_state": "dead",
"unit_file_state": "enabled",
"condition_result": "no",
"managed_files": [
{
"path": "/etc/foo.conf",
"src_rel": "etc/foo.conf",
"owner": "root",
"group": "root",
"mode": "0644",
"reason": "modified_conffile",
}
],
"excluded": [],
"notes": [],
}
],
"package_roles": [
{
"package": "curl",
"role_name": "curl",
"managed_files": [],
"excluded": [],
"notes": [],
}
],
}
bundle.mkdir(parents=True, exist_ok=True)
(bundle / "state.json").write_text(json.dumps(state, indent=2), encoding="utf-8")
# Create artifact for etc_custom file so copy works
(bundle / "artifacts" / "etc_custom" / "etc" / "default").mkdir(
parents=True, exist_ok=True
)
(bundle / "artifacts" / "etc_custom" / "etc" / "default" / "keyboard").write_text(
"kbd", encoding="utf-8"
)
manifest(str(bundle), str(out))
# Service role: systemd management should be gated on foo_manage_unit and a probe.
tasks = (out / "roles" / "foo" / "tasks" / "main.yml").read_text(encoding="utf-8")
assert "- name: Probe whether systemd unit exists and is manageable" in tasks
assert "when: foo_manage_unit | default(false)" in tasks
assert (
"when:\n - foo_manage_unit | default(false)\n - _unit_probe is succeeded\n"
in tasks
)
# Ensure we didn't emit deprecated/broken '{{ }}' delimiters in when: lines.
for line in tasks.splitlines():
if line.lstrip().startswith("when:"):
assert "{{" not in line and "}}" not in line
defaults = (out / "roles" / "foo" / "defaults" / "main.yml").read_text(
encoding="utf-8"
)
assert "foo_manage_unit: true" in defaults
assert "foo_systemd_enabled: true" in defaults
assert "foo_systemd_state: stopped" in defaults
# Playbook should include users, etc_custom, packages, and services
pb = (out / "playbook.yml").read_text(encoding="utf-8")
assert "- users" in pb
assert "- etc_custom" in pb
assert "- curl" in pb
assert "- foo" in pb
def test_manifest_site_mode_creates_host_inventory_and_raw_files(tmp_path: Path):
"""In --fqdn mode, host-specific state goes into inventory/host_vars."""
fqdn = "host1.example.test"
bundle = tmp_path / "bundle"
out = tmp_path / "ansible"
# Artifacts for a service-managed file.
(bundle / "artifacts" / "foo" / "etc").mkdir(parents=True, exist_ok=True)
(bundle / "artifacts" / "foo" / "etc" / "foo.conf").write_text(
"x", encoding="utf-8"
)
# Artifacts for etc_custom file so copy works.
(bundle / "artifacts" / "etc_custom" / "etc" / "default").mkdir(
parents=True, exist_ok=True
)
(bundle / "artifacts" / "etc_custom" / "etc" / "default" / "keyboard").write_text(
"kbd", encoding="utf-8"
)
state = {
"host": {"hostname": "test", "os": "debian"},
"users": {
"role_name": "users",
"users": [],
"managed_files": [],
"excluded": [],
"notes": [],
},
"etc_custom": {
"role_name": "etc_custom",
"managed_files": [
{
"path": "/etc/default/keyboard",
"src_rel": "etc/default/keyboard",
"owner": "root",
"group": "root",
"mode": "0644",
"reason": "custom_unowned",
}
],
"excluded": [],
"notes": [],
},
"services": [
{
"unit": "foo.service",
"role_name": "foo",
"packages": ["foo"],
"active_state": "active",
"sub_state": "running",
"unit_file_state": "enabled",
"condition_result": "yes",
"managed_files": [
{
"path": "/etc/foo.conf",
"src_rel": "etc/foo.conf",
"owner": "root",
"group": "root",
"mode": "0644",
"reason": "modified_conffile",
}
],
"excluded": [],
"notes": [],
}
],
"package_roles": [],
}
bundle.mkdir(parents=True, exist_ok=True)
(bundle / "state.json").write_text(json.dumps(state, indent=2), encoding="utf-8")
manifest(str(bundle), str(out), fqdn=fqdn)
# Host playbook exists.
assert (out / "playbooks" / f"{fqdn}.yml").exists()
# Role defaults are safe/host-agnostic in site mode.
foo_defaults = (out / "roles" / "foo" / "defaults" / "main.yml").read_text(
encoding="utf-8"
)
assert "foo_packages: []" in foo_defaults
assert "foo_managed_files: []" in foo_defaults
assert "foo_manage_unit: false" in foo_defaults
# Host vars contain host-specific state.
foo_hostvars = (out / "inventory" / "host_vars" / fqdn / "foo.yml").read_text(
encoding="utf-8"
)
assert "foo_packages" in foo_hostvars
assert "foo_managed_files" in foo_hostvars
assert "foo_manage_unit: true" in foo_hostvars
assert "foo_systemd_state: started" in foo_hostvars
# Non-templated raw config is stored per-host under .files.
assert (
out / "inventory" / "host_vars" / fqdn / "foo" / ".files" / "etc" / "foo.conf"
).exists()
def test_copy2_replace_overwrites_readonly_destination(tmp_path: Path):
"""Merging into an existing manifest should tolerate read-only files.
Some harvested artifacts (e.g. private keys) may be mode 0400. If a previous
run copied them into the destination tree, a subsequent run must still be
able to update/replace them.
"""
import os
import stat
from enroll.manifest import _copy2_replace
src = tmp_path / "src"
dst = tmp_path / "dst"
src.write_text("new", encoding="utf-8")
dst.write_text("old", encoding="utf-8")
os.chmod(dst, 0o400)
_copy2_replace(str(src), str(dst))
assert dst.read_text(encoding="utf-8") == "new"
mode = stat.S_IMODE(dst.stat().st_mode)
assert mode & stat.S_IWUSR # destination should remain mergeable