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//... 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// 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)