fastapi-gsap/gsap_broker/templates/loader.py
Tyler J King 77964e4042 feat(templates): add template system — manifest, policy, loader, registries
bastion.toml manifest parser with variable validation and dependency
declarations. Declarative compliance policy schema with per-platform
check implementations. Template loader with variable substitution
(Bastion-owned files only — never touches Ansible/Terraform).
PolicyRegistry and AccordRegistry with builtin fallbacks.

BOUNDARY: loader never touches automation framework files.

Signed-off-by: Tyler King <tking@guildhouse.dev>
2026-04-14 11:09:41 -04:00

181 lines
6.6 KiB
Python

# Copyright 2026 Guildhouse Dev
# SPDX-License-Identifier: Apache-2.0
"""Template repository loader.
Loads a template from a local directory (already cloned/forked).
Parses the manifest, validates variables, resolves dependencies
(by reading their manifests -- actual git cloning is a separate
concern), and loads all content into the broker's registries.
IMPORTANT: The loader does NOT parse or template automation
framework files (Ansible, Terraform, Salt). It only processes
Bastion-owned files in Bastion-owned directories.
"""
from __future__ import annotations
import logging
import re
import subprocess
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import tomllib
from gsap_broker.templates.manifest import TemplateManifest
from gsap_broker.templates.policy import CompliancePolicy
from gsap_broker.templates.registry import AccordRegistry, PolicyRegistry
logger = logging.getLogger(__name__)
# Bastion-owned directories where variable substitution is applied.
# Files outside these directories are NEVER modified.
_BASTION_OWNED_DIRS = frozenset({"policies", "accords", "harnesses", "hbom", "dashboards"})
@dataclass
class LoadResult:
"""Result of loading a template."""
template_name: str = ""
policies_loaded: list[str] = field(default_factory=list)
accords_loaded: list[str] = field(default_factory=list)
warnings: list[str] = field(default_factory=list)
errors: list[str] = field(default_factory=list)
provenance: dict[str, Any] = field(default_factory=dict)
@property
def success(self) -> bool:
return len(self.errors) == 0
class TemplateLoader:
"""Loads bastion.toml template repos into broker registries."""
def __init__(
self,
policy_registry: PolicyRegistry,
accord_registry: AccordRegistry,
):
self._policies = policy_registry
self._accords = accord_registry
def load(self, template_dir: str | Path, variables: dict[str, Any]) -> LoadResult:
"""Load a template from a local directory.
1. Parse bastion.toml
2. Validate required variables are provided
3. Substitute variables into Bastion-owned files ONLY
4. Parse and load policies into policy registry
5. Parse and load accords into accord registry
6. Record template provenance (git commit hash if available)
Returns LoadResult with loaded items and any warnings.
"""
root = Path(template_dir)
result = LoadResult()
# 1. Parse manifest
manifest_path = root / "bastion.toml"
if not manifest_path.exists():
result.errors.append(f"No bastion.toml found in {root}")
return result
manifest = TemplateManifest.from_toml(manifest_path)
result.template_name = manifest.template.name
# 2. Validate variables
# Apply defaults first
effective_vars = {}
for name, var in manifest.variables.items():
if name in variables:
effective_vars[name] = variables[name]
elif var.default is not None:
effective_vars[name] = var.default
errors = manifest.validate_variables(effective_vars)
if errors:
result.errors.extend(errors)
return result
# 3. Provenance
result.provenance = self._compute_provenance(root)
# 4. Load policies
policies_dir = root / manifest.contents.policies
if policies_dir.is_dir():
for f in sorted(policies_dir.glob("*.toml")):
try:
content = self._substitute_variables(
f.read_text(), effective_vars, manifest.variables
)
# Parse from substituted content
data = tomllib.loads(content)
policy = CompliancePolicy.model_validate(data)
self._policies.register(policy, result.provenance)
result.policies_loaded.append(policy.name)
except Exception as e:
result.warnings.append(f"Failed to load policy {f.name}: {e}")
# 5. Load accords
accords_dir = root / manifest.contents.accords
if accords_dir.is_dir():
for f in sorted(accords_dir.glob("*.toml")):
try:
content = self._substitute_variables(
f.read_text(), effective_vars, manifest.variables
)
data = tomllib.loads(content)
name = data.get("name", f.stem)
self._accords.register(name, data, result.provenance)
result.accords_loaded.append(name)
except Exception as e:
result.warnings.append(f"Failed to load accord {f.name}: {e}")
return result
def _substitute_variables(
self, content: str, values: dict[str, Any], var_defs: dict
) -> str:
"""Replace ${variable_name} in content with values.
ONLY called on Bastion-owned TOML files.
NEVER on Ansible/Terraform/script files.
Warns on unresolved optional variables.
"""
def replacer(match: re.Match) -> str:
var_name = match.group(1)
if var_name in values:
val = values[var_name]
# Don't log sensitive values
var_def = var_defs.get(var_name)
if var_def and not var_def.sensitive:
logger.debug("Substituting ${%s} = %s", var_name, val)
else:
logger.debug("Substituting ${%s} = [REDACTED]", var_name)
return str(val)
logger.warning("Unresolved variable: ${%s}", var_name)
return match.group(0)
return re.sub(r"\$\{(\w+)\}", replacer, content)
def _compute_provenance(self, template_dir: Path) -> dict[str, Any]:
"""Compute git commit hash and repo origin for template provenance."""
provenance: dict[str, Any] = {"template_dir": str(template_dir)}
try:
result = subprocess.run(
["git", "rev-parse", "HEAD"],
cwd=template_dir, capture_output=True, text=True, timeout=5,
)
if result.returncode == 0:
provenance["git_commit"] = result.stdout.strip()
result = subprocess.run(
["git", "remote", "get-url", "origin"],
cwd=template_dir, capture_output=True, text=True, timeout=5,
)
if result.returncode == 0:
provenance["git_origin"] = result.stdout.strip()
except Exception:
pass
return provenance