enroll/enroll/manifest.py
2025-12-14 20:53:22 +11:00

392 lines
14 KiB
Python

from __future__ import annotations
import json
import os
import shutil
from typing import Any, Dict, List
def _yaml_list(items: List[str], indent: int = 2) -> str:
pad = " " * indent
if not items:
return f"{pad}[]"
return "\n".join(f"{pad}- {x}" for x in items)
def _copy_artifacts(bundle_dir: str, role: str, role_dir: str) -> None:
artifacts_dir = os.path.join(bundle_dir, "artifacts", role)
if not os.path.isdir(artifacts_dir):
return
for root, _, files in os.walk(artifacts_dir):
for fn in files:
src = os.path.join(root, fn)
rel = os.path.relpath(src, artifacts_dir)
dst = os.path.join(role_dir, "files", rel)
os.makedirs(os.path.dirname(dst), exist_ok=True)
shutil.copy2(src, dst)
def _write_role_scaffold(role_dir: str) -> None:
os.makedirs(os.path.join(role_dir, "tasks"), exist_ok=True)
os.makedirs(os.path.join(role_dir, "handlers"), exist_ok=True)
os.makedirs(os.path.join(role_dir, "defaults"), exist_ok=True)
os.makedirs(os.path.join(role_dir, "meta"), exist_ok=True)
os.makedirs(os.path.join(role_dir, "files"), exist_ok=True)
def _write_playbook(path: str, roles: List[str]) -> None:
pb_lines = ["---", "- hosts: all", " become: true", " roles:"]
for r in roles:
pb_lines.append(f" - {r}")
with open(path, "w", encoding="utf-8") as f:
f.write("\n".join(pb_lines) + "\n")
def manifest(bundle_dir: str, out_dir: str) -> None:
state_path = os.path.join(bundle_dir, "state.json")
with open(state_path, "r", encoding="utf-8") as f:
state = json.load(f)
services: List[Dict[str, Any]] = state.get("services", [])
package_roles: List[Dict[str, Any]] = state.get("package_roles", [])
users_snapshot: Dict[str, Any] = state.get("users", {})
os.makedirs(out_dir, exist_ok=True)
roles_root = os.path.join(out_dir, "roles")
os.makedirs(roles_root, exist_ok=True)
manifested_users_roles: List[str] = []
manifested_service_roles: List[str] = []
manifested_pkg_roles: List[str] = []
# -------------------------
# Users role (non-system users)
# -------------------------
if users_snapshot and users_snapshot.get("users"):
role = users_snapshot.get("role_name", "users")
role_dir = os.path.join(roles_root, role)
_write_role_scaffold(role_dir)
_copy_artifacts(bundle_dir, role, role_dir)
users = users_snapshot.get("users", [])
managed_files = users_snapshot.get("managed_files", [])
excluded = users_snapshot.get("excluded", [])
notes = users_snapshot.get("notes", [])
# Build group set from users
group_names = set()
for u in users:
pg = u.get("primary_group")
if pg:
group_names.add(pg)
for g in u.get("supplementary_groups", []) or []:
group_names.add(g)
group_names = sorted(group_names)
# defaults: store users list (handy for later), but tasks are explicit for readability
defaults = """---
users_accounts:
""" + ("\n".join([f" - name: {u.get('name')}" for u in users]) + "\n")
with open(os.path.join(role_dir, "defaults", "main.yml"), "w", encoding="utf-8") as f:
f.write(defaults)
with open(os.path.join(role_dir, "meta", "main.yml"), "w", encoding="utf-8") as f:
f.write("---\ndependencies: []\n")
# tasks
lines: List[str] = ["---"]
# groups first (idempotent; safe even if already present)
for g in group_names:
lines.append(f"- name: Ensure group {g} exists")
lines.append(" ansible.builtin.group:")
lines.append(f" name: {g}")
lines.append(" state: present")
# users
for u in users:
name = u["name"]
lines.append(f"- name: Ensure user {name} exists")
lines.append(" ansible.builtin.user:")
lines.append(f" name: {name}")
lines.append(f" uid: {u.get('uid')}")
lines.append(f" group: {u.get('primary_group')}")
supp = u.get("supplementary_groups") or []
if supp:
lines.append(" groups: " + ",".join(supp))
lines.append(" append: true")
lines.append(f" home: {u.get('home')}")
lines.append(" create_home: true")
if u.get("shell"):
lines.append(f" shell: {u.get('shell')}")
if u.get("gecos"):
# quote to avoid YAML surprises
gec = u.get("gecos").replace('"', '\"')
lines.append(f' comment: "{gec}"')
lines.append(" password_lock: true")
lines.append(" state: present")
# Ensure ~/.ssh
home = u.get("home") or f"/home/{name}"
sshdir = home.rstrip("/") + "/.ssh"
lines.append(f"- name: Ensure {name} .ssh directory exists")
lines.append(" ansible.builtin.file:")
lines.append(f" path: {sshdir}")
lines.append(" state: directory")
lines.append(f" owner: {name}")
lines.append(f" group: {u.get('primary_group')}")
lines.append(" mode: '0700'")
# Copy harvested SSH public material (authorized_keys + *.pub)
for mf in managed_files:
dest = mf["path"]
src = mf["src_rel"]
# Determine file owner from dest path: /home/<user>/...
owner = None
for u in users:
if dest.startswith((u.get("home") or "").rstrip("/") + "/"):
owner = u["name"]
group = u.get("primary_group")
break
if owner is None:
# fallback: try /home/<user>/
parts = dest.split("/")
owner = parts[2] if len(parts) > 2 and parts[1] == "home" else "root"
group = owner
mode = "0600" if mf.get("reason") == "authorized_keys" else "0644"
lines.append(f"- name: Deploy {dest}")
lines.append(" ansible.builtin.copy:")
lines.append(f" src: {src}")
lines.append(f" dest: {dest}")
lines.append(f" owner: {owner}")
lines.append(f" group: {group}")
lines.append(f" mode: '{mode}'")
tasks = "\n".join(lines).rstrip() + "\n"
with open(os.path.join(role_dir, "tasks", "main.yml"), "w", encoding="utf-8") as f:
f.write(tasks)
# handlers (none needed)
with open(os.path.join(role_dir, "handlers", "main.yml"), "w", encoding="utf-8") as f:
f.write("---\n")
readme = """# users
Generated non-system user accounts and SSH public material.
## Users
""" + ("\n".join([f"- {u.get('name')} (uid {u.get('uid')})" for u in users]) or "- (none)") + """\n
## Included SSH files
""" + ("\n".join([f"- {mf.get('path')} ({mf.get('reason')})" for mf in managed_files]) or "- (none)") + """\n
## Excluded
""" + ("\n".join([f"- {e.get('path')} ({e.get('reason')})" for e in excluded]) or "- (none)") + """\n
## Notes
""" + ("\n".join([f"- {n}" for n in notes]) or "- (none)") + """\n"""
with open(os.path.join(role_dir, "README.md"), "w", encoding="utf-8") as f:
f.write(readme)
manifested_users_roles.append(role)
# -------------------------
# Service roles
# -------------------------
for svc in services:
role = svc["role_name"]
unit = svc["unit"]
pkgs = svc["packages"]
managed_files = svc["managed_files"]
role_dir = os.path.join(roles_root, role)
_write_role_scaffold(role_dir)
_copy_artifacts(bundle_dir, role, role_dir)
var_prefix = role
defaults = f"""---
{var_prefix}_packages:
{_yaml_list(pkgs, indent=2)}
"""
with open(os.path.join(role_dir, "defaults", "main.yml"), "w", encoding="utf-8") as f:
f.write(defaults)
handlers = """---
- name: systemd daemon-reload
ansible.builtin.systemd:
daemon_reload: true
- name: Restart service
ansible.builtin.service:
name: "{{ unit_name }}"
state: restarted
"""
with open(os.path.join(role_dir, "handlers", "main.yml"), "w", encoding="utf-8") as f:
f.write(handlers)
systemd_files = [mf for mf in managed_files if mf["path"].startswith("/etc/systemd/system/")]
other_files = [mf for mf in managed_files if mf not in systemd_files]
def copy_task(mf: Dict[str, Any], notify: str | None) -> str:
notify_line = f" notify: {notify}\n" if notify else ""
return f"""- name: Deploy {mf["path"]}
ansible.builtin.copy:
src: "{mf["src_rel"]}"
dest: "{mf["path"]}"
owner: "{mf["owner"]}"
group: "{mf["group"]}"
mode: "{mf["mode"]}"
{notify_line}"""
task_parts: List[str] = []
task_parts.append(f"""---
- name: Set unit name
ansible.builtin.set_fact:
unit_name: "{unit}"
- name: Install packages for {role}
ansible.builtin.apt:
name: "{{{{ {var_prefix}_packages }}}}"
state: present
update_cache: true
when: {var_prefix}_packages | length > 0
""")
if systemd_files:
for mf in systemd_files:
task_parts.append(copy_task(mf, "[systemd daemon-reload]"))
task_parts.append("""- name: Reload systemd to pick up unit changes
ansible.builtin.meta: flush_handlers
""")
for mf in other_files:
task_parts.append(copy_task(mf, "[Restart service]"))
task_parts.append(f"""- name: Ensure {unit} is enabled and running
ansible.builtin.service:
name: "{{{{ unit_name }}}}"
enabled: true
state: started
""")
tasks = "\n".join(task_parts).rstrip() + "\n"
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, "meta", "main.yml"), "w", encoding="utf-8") as f:
f.write("---\ndependencies: []\n")
excluded = svc.get("excluded", [])
notes = svc.get("notes", [])
readme = f"""# {role}
Generated from `{unit}`.
## Packages
{os.linesep.join("- " + p for p in pkgs) or "- (none detected)"}
## Managed files
{os.linesep.join("- " + mf["path"] + " (" + mf["reason"] + ")" for mf in managed_files) or "- (none)"}
## Excluded (possible secrets / unsafe)
{os.linesep.join("- " + e["path"] + " (" + e["reason"] + ")" for e in excluded) or "- (none)"}
## Notes
{os.linesep.join("- " + n for n in notes) or "- (none)"}
"""
with open(os.path.join(role_dir, "README.md"), "w", encoding="utf-8") as f:
f.write(readme)
manifested_service_roles.append(role)
# -------------------------
# Manual package roles
# -------------------------
for pr in package_roles:
role = pr["role_name"]
pkg = pr["package"]
managed_files = pr["managed_files"]
role_dir = os.path.join(roles_root, role)
_write_role_scaffold(role_dir)
_copy_artifacts(bundle_dir, role, role_dir)
var_prefix = role
defaults = f"""---
{var_prefix}_packages:
- {pkg}
"""
with open(os.path.join(role_dir, "defaults", "main.yml"), "w", encoding="utf-8") as f:
f.write(defaults)
handlers = """---
- name: systemd daemon-reload
ansible.builtin.systemd:
daemon_reload: true
"""
with open(os.path.join(role_dir, "handlers", "main.yml"), "w", encoding="utf-8") as f:
f.write(handlers)
systemd_files = [mf for mf in managed_files if mf["path"].startswith("/etc/systemd/system/")]
other_files = [mf for mf in managed_files if mf not in systemd_files]
def copy_task(mf: Dict[str, Any], notify: str | None) -> str:
notify_line = f" notify: {notify}\n" if notify else ""
return f"""- name: Deploy {mf["path"]}
ansible.builtin.copy:
src: "{mf["src_rel"]}"
dest: "{mf["path"]}"
owner: "{mf["owner"]}"
group: "{mf["group"]}"
mode: "{mf["mode"]}"
{notify_line}"""
task_parts: List[str] = []
task_parts.append(f"""---
- name: Install manual package {pkg}
ansible.builtin.apt:
name: "{{{{ {var_prefix}_packages }}}}"
state: present
update_cache: true
""")
if systemd_files:
for mf in systemd_files:
task_parts.append(copy_task(mf, "[systemd daemon-reload]"))
task_parts.append("""- name: Reload systemd to pick up unit changes
ansible.builtin.meta: flush_handlers
""")
for mf in other_files:
task_parts.append(copy_task(mf, None))
tasks = "\n".join(task_parts).rstrip() + "\n"
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, "meta", "main.yml"), "w", encoding="utf-8") as f:
f.write("---\ndependencies: []\n")
excluded = pr.get("excluded", [])
notes = pr.get("notes", [])
readme = f"""# {role}
Generated for manual package `{pkg}`.
## Managed files
{os.linesep.join("- " + mf["path"] + " (" + mf["reason"] + ")" for mf in managed_files) or "- (none)"}
## Excluded (possible secrets / unsafe)
{os.linesep.join("- " + e["path"] + " (" + e["reason"] + ")" for e in excluded) or "- (none)"}
## Notes
{os.linesep.join("- " + n for n in notes) or "- (none)"}
> Note: package roles do not attempt to restart or enable services automatically.
"""
with open(os.path.join(role_dir, "README.md"), "w", encoding="utf-8") as f:
f.write(readme)
manifested_pkg_roles.append(role)
# Playbooks
_write_playbook(os.path.join(out_dir, "playbook.yml"), manifested_users_roles + manifested_pkg_roles + manifested_service_roles)