from pathlib import Path from typing import Any import base64 import json import makejinja import re import validate # Return the stripped contents of file_path, rejecting a missing or empty file def _read_stripped(file_path: str) -> str: try: content = Path(file_path).read_text().strip() except FileNotFoundError: raise FileNotFoundError(f"File not found: {file_path}") from None if not content: raise ValueError(f"{file_path} is empty") return content # Return the parsed contents of a JSON file def _read_json(file_path: str) -> dict[str, Any]: try: return json.loads(_read_stripped(file_path)) except json.JSONDecodeError: raise ValueError(f"Could not decode JSON file: {file_path}") from None # Return the age public or private key from age.key def age_key(key_type: str, file_path: str = 'age.key') -> str: file_content = _read_stripped(file_path) if key_type == 'public': # Matches both classic (age1...) and post-quantum (age1pq1...) recipients key_match = re.search(r"# public key: (age1[\w]+)", file_content) if not key_match: raise ValueError("Could not find public key in the age key file.") return key_match.group(1) elif key_type == 'private': # (?:PQ-)? matches post-quantum identities (AGE-SECRET-KEY-PQ-1...) as well as classic ones key_match = re.search(r"(AGE-SECRET-KEY-(?:PQ-)?1[\w]+)", file_content) if not key_match: raise ValueError("Could not find private key in the age key file.") return key_match.group(1) else: raise ValueError("Invalid key type. Use 'public' or 'private'.") # Return cloudflare tunnel fields from cloudflare-tunnel.json def cloudflare_tunnel_id(file_path: str = 'cloudflare-tunnel.json') -> str: data = _read_json(file_path) tunnel_id = data.get("TunnelID") if tunnel_id is None: raise KeyError(f"Missing 'TunnelID' key in {file_path}") if not tunnel_id: raise ValueError(f"'TunnelID' is empty in {file_path}") return tunnel_id # Return cloudflare tunnel fields from cloudflare-tunnel.json in TUNNEL_TOKEN format def cloudflare_tunnel_secret(file_path: str = 'cloudflare-tunnel.json') -> str: data = _read_json(file_path) for field in ("AccountTag", "TunnelID", "TunnelSecret"): if field not in data: raise KeyError(f"Missing '{field}' key in {file_path}") if not data[field]: raise ValueError(f"'{field}' is empty in {file_path}") transformed_data = { "a": data["AccountTag"], "t": data["TunnelID"], "s": data["TunnelSecret"] } json_string = json.dumps(transformed_data, separators=(',', ':')) return base64.b64encode(json_string.encode('utf-8')).decode('utf-8') # Return the Flux deploy key from deploy.key def deploy_key(file_path: str = 'deploy.key') -> str: return _read_stripped(file_path) # Return the Flux webhook token from flux-webhook-token.txt def webhook_token(file_path: str = 'flux-webhook-token.txt') -> str: return _read_stripped(file_path) CONFIG_FILE = 'cluster.toml' # SSH host keys as published by each provider. Must cover every host in # KNOWN_SSH_HOSTS in validate.py; any other host requires the user to set # repository.known_hosts in cluster.toml. KNOWN_HOSTS = { 'github.com': ( 'github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl\n' 'github.com ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBEmKSENjQEezOmxkZMy7opKgwFB9nkt5YRrYMjNuG5N87uRgg6CLrbo5wAdT/y6v0mKV0U2w0WZ2YB/++Tpockg=\n' 'github.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQCj7ndNxQowgcQnjshcLrqPEiiphnt+VTTvDP6mHBL9j1aNUkY4Ue1gvwnGLVlOhGeYrnZaMgRK6+PKCUXaDbC7qtbW8gIkhL7aGCsOr/C56SJMy/BCZfxd1nWzAOxSDPgVsmerOBYfNqltV9/hWCqBywINIR+5dIg6JTJ72pcEpEjcYgXkE2YEFXV1JHnsKgbLWNlhScqb2UmyRkQyytRLtL+38TGxkxCflmO+5Z8CSSNY7GidjMIZ7Q4zMjA2n1nGrlTDkzwDCsw+wqFPGQA179cnfGWOWRVruj16z6XyvxvjJwbz0wQZ75XK5tKSb7FNyeIEs4TT4jk+S4dhPeAUC5y+bDYirYgM4GC7uEnztnZyaVWQ7B381AK4Qdrwt51ZqExKbQpTUNn+EjqoTwvqNj4kqx5QUCI0ThS/YkOxJCXmPUWZbhjpCg56i+2aB6CmK2JGhn57K5mj0MNdBXA4/WnwH6XoPWJzK5Nyu2zB3nAZp+S5hpQs+p1vN1/wsjk=' ), 'gitlab.com': ( 'gitlab.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAfuCHKVTjquxvt6CM6tdG4SLp1Btn/nOeHHE5UOzRdf\n' 'gitlab.com ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBFSMqzJeV9rUzU4kWitGjeR4PWSa29SPqJ1fVkhtj3Hw9xjLVXVYrU9QlYWrOLXBpQ6KWjbjTDTdDkoohFzgbEY=\n' 'gitlab.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCsj2bNKTBSpIYDEGk9KxsGh3mySTRgMtXL583qmBpzeQ+jqCMRgBqB98u3z++J1sKlXHWfM9dyhSevkMwSbhoR8XIq/U0tCNyokEi/ueaBMCvbcTHhO7FcwzY92WK4Yt0aGROY5qX2UKSeOvuP4D6TPqKF1onrSzH9bx9XUf2lEdWT/ia1NEKjunUqu1xOB/StKDHMoX4/OKyIzuS0q/T1zOATthvasJFoPrAjkohTyaDUz2LN5JoH839hViyEG82yB+MjcFV5MU3N1l1QL3cVUCh93xSaua1N85qivl+siMkPGbO5xR/En4iEY6K2XPASUEMaieWVNTRCtJ4S8H+9' ), 'codeberg.org': ( 'codeberg.org ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIVIC02vnjFyL+I4RHfvIGNtOgJMe769VTF1VR4EB3ZB\n' 'codeberg.org ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBL2pDxWr18SoiDJCGZ5LmxPygTlPu+cCKSkpqkvCyQzl5xmIMeKNdfdBpfbCGDPoZQghePzFZkKJNR/v9Win3Sc=\n' 'codeberg.org ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC8hZi7K1/2E2uBX8gwPRJAHvRAob+3Sn+y2hxiEhN0buv1igjYFTgFO2qQD8vLfU/HT/P/rqvEeTvaDfY1y/vcvQ8+YuUYyTwE2UaVU5aJv89y6PEZBYycaJCPdGIfZlLMmjilh/Sk8IWSEK6dQr+g686lu5cSWrFW60ixWpHpEVB26eRWin3lKYWSQGMwwKv4LwmW3ouqqs4Z4vsqRFqXJ/eCi3yhpT+nOjljXvZKiYTpYajqUC48IHAxTWugrKe1vXWOPxVXXMQEPsaIRc2hpK+v1LmfB7GnEGvF1UAKnEZbUuiD9PBEeD5a1MZQIzcoPWCrTxipEpuXQ5Tni4mN' ), } # makejinja adds import_paths (this directory) to sys.path, and both # makejinja and pydantic come from the uv project environment, so the # validator runs in-process. def validate_config() -> dict[str, Any]: try: return validate.load(CONFIG_FILE) except validate.ConfigError as e: raise RuntimeError(f"config validation failed:\n{e}") from None class Plugin(makejinja.plugin.Plugin): def __init__(self, data: dict[str, Any]): self._data = data def data(self) -> makejinja.plugin.Data: data = validate_config() if ( data['ingress']['mode'] == 'cloudflare-tunnel' and not Path('cloudflare-tunnel.json').is_file() ): raise RuntimeError('cloudflare-tunnel.json not found — see README') # The deploy key secret always pins every bundled provider host key # (entries are matched per-hostname, so unused ones are inert); # user-supplied entries for self-hosted git servers are appended. repository = data['repository'] repository['known_hosts'] = '\n'.join( [*KNOWN_HOSTS.values(), repository['known_hosts']] ).strip() return data def functions(self) -> makejinja.plugin.Functions: return [ age_key, cloudflare_tunnel_id, cloudflare_tunnel_secret, deploy_key, webhook_token ]