"""File generation using Jinja2 templates.""" import sys from pathlib import Path from jinja2 import Environment, BaseLoader, TemplateNotFound from models import HostConfig class PackageTemplateLoader(BaseLoader): """Custom Jinja2 loader that works with both dev and installed packages.""" def __init__(self): # Try to find templates in multiple locations self.template_dirs = [] # Location 1: Development (scripts/create-host/templates) dev_dir = Path(__file__).parent / "templates" if dev_dir.exists(): self.template_dirs.append(dev_dir) # Location 2: Installed via Nix (../share/create-host/templates from bin dir) # When installed via Nix, __file__ is in lib/python3.X/site-packages/ # and templates are in ../../../share/create-host/templates for site_path in sys.path: site_dir = Path(site_path) # Try to find the Nix store path if "site-packages" in str(site_dir): # Go up to the package root (e.g., /nix/store/xxx-create-host-0.1.0) pkg_root = site_dir.parent.parent.parent share_templates = pkg_root / "share" / "create-host" / "templates" if share_templates.exists(): self.template_dirs.append(share_templates) # Location 3: Fallback - sys.path templates for site_path in sys.path: site_templates = Path(site_path) / "templates" if site_templates.exists(): self.template_dirs.append(site_templates) def get_source(self, environment, template): for template_dir in self.template_dirs: template_path = template_dir / template if template_path.exists(): mtime = template_path.stat().st_mtime source = template_path.read_text() return source, str(template_path), lambda: mtime == template_path.stat().st_mtime raise TemplateNotFound(template) def generate_host_files(config: HostConfig, repo_root: Path) -> None: """ Generate host configuration files from templates. Args: config: Host configuration repo_root: Path to repository root """ # Setup Jinja2 environment with custom loader env = Environment( loader=PackageTemplateLoader(), trim_blocks=True, lstrip_blocks=True, ) # Create host directory host_dir = repo_root / "hosts" / config.hostname host_dir.mkdir(parents=True, exist_ok=True) # Generate default.nix default_template = env.get_template("default.nix.j2") default_content = default_template.render(hostname=config.hostname) (host_dir / "default.nix").write_text(default_content) # Generate configuration.nix config_template = env.get_template("configuration.nix.j2") config_content = config_template.render( hostname=config.hostname, domain=config.domain, nameservers=config.nameservers, is_static_ip=config.is_static_ip, ip=config.ip, gateway=config.gateway, state_version=config.state_version, ) (host_dir / "configuration.nix").write_text(config_content) def generate_vault_terraform(hostname: str, repo_root: Path) -> None: """ Generate or update Vault Terraform configuration for a new host. Creates/updates terraform/vault/hosts-generated.tf with: - Host policy granting access to hosts//* secrets - AppRole configuration for the host - Placeholder secret entry (user adds actual secrets separately) Args: hostname: Hostname for the new host repo_root: Path to repository root """ vault_tf_path = repo_root / "terraform" / "vault" / "hosts-generated.tf" # Read existing file if it exists, otherwise start with empty structure if vault_tf_path.exists(): content = vault_tf_path.read_text() else: # Create initial file structure content = """# WARNING: Auto-generated by create-host tool # Manual edits will be overwritten when create-host is run # Generated host policies # Each host gets access to its own secrets under hosts//* locals { generated_host_policies = { } # Placeholder secrets - user should add actual secrets manually or via tofu generated_secrets = { } } # Create policies for generated hosts resource "vault_policy" "generated_host_policies" { for_each = local.generated_host_policies name = "host-\${each.key}" policy = <<-EOT # Allow host to read its own secrets %{for path in each.value.paths~} path "${path}" { capabilities = ["read", "list"] } %{endfor~} EOT } # Create AppRoles for generated hosts resource "vault_approle_auth_backend_role" "generated_hosts" { for_each = local.generated_host_policies backend = vault_auth_backend.approle.path role_name = each.key token_policies = ["host-\${each.key}"] secret_id_ttl = 0 # Never expire (wrapped tokens provide time limit) token_ttl = 3600 token_max_ttl = 3600 secret_id_num_uses = 0 # Unlimited uses } """ # Parse existing policies from the file import re policies_match = re.search( r'generated_host_policies = \{(.*?)\n \}', content, re.DOTALL ) if policies_match: policies_content = policies_match.group(1) else: policies_content = "" # Check if hostname already exists if f'"{hostname}"' in policies_content: # Already exists, don't duplicate return # Add new policy entry new_policy = f''' "{hostname}" = {{ paths = [ "secret/data/hosts/{hostname}/*", ] }}''' # Insert before the closing brace if policies_content.strip(): # There are existing entries, add after them new_policies_content = policies_content.rstrip() + new_policy + "\n " else: # First entry new_policies_content = new_policy + "\n " # Replace the policies map new_content = re.sub( r'(generated_host_policies = \{)(.*?)(\n \})', rf'\1{new_policies_content}\3', content, flags=re.DOTALL ) # Write the updated file vault_tf_path.write_text(new_content)