template.py 45 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051
  1. from __future__ import annotations
  2. import base64
  3. import importlib
  4. import logging
  5. import os
  6. import re
  7. import secrets
  8. import string
  9. from dataclasses import dataclass, field
  10. from functools import lru_cache
  11. from pathlib import Path
  12. from typing import Any, Literal
  13. import yaml
  14. from jinja2 import Environment, FileSystemLoader, meta
  15. from jinja2.exceptions import (
  16. TemplateError as Jinja2TemplateError,
  17. )
  18. from jinja2.exceptions import (
  19. TemplateNotFound as Jinja2TemplateNotFound,
  20. )
  21. from jinja2.exceptions import (
  22. TemplateSyntaxError as Jinja2TemplateSyntaxError,
  23. )
  24. from jinja2.exceptions import (
  25. UndefinedError,
  26. )
  27. from jinja2.sandbox import SandboxedEnvironment
  28. from ..exceptions import (
  29. IncompatibleSchemaVersionError,
  30. RenderErrorContext,
  31. TemplateLoadError,
  32. TemplateRenderError,
  33. TemplateSyntaxError,
  34. TemplateValidationError,
  35. YAMLParseError,
  36. )
  37. from ..version import is_compatible
  38. from .variable_collection import VariableCollection
  39. logger = logging.getLogger(__name__)
  40. # Template Status Constants
  41. TEMPLATE_STATUS_PUBLISHED = "published"
  42. TEMPLATE_STATUS_DRAFT = "draft"
  43. TEMPLATE_STATUS_INVALID = "invalid"
  44. class TemplateErrorHandler:
  45. """Handles parsing and formatting of template rendering errors.
  46. This class provides utilities for:
  47. - Extracting error context from template files
  48. - Generating helpful suggestions based on Jinja2 errors
  49. - Parsing Jinja2 exceptions into structured error information
  50. """
  51. @staticmethod
  52. def extract_error_context(file_path: Path, line_number: int | None, context_size: int = 3) -> list[str]:
  53. """Extract lines of context around an error location.
  54. Args:
  55. file_path: Path to the file with the error
  56. line_number: Line number where error occurred (1-indexed)
  57. context_size: Number of lines to show before and after
  58. Returns:
  59. List of context lines with line numbers
  60. """
  61. if not line_number or not file_path.exists():
  62. return []
  63. try:
  64. with file_path.open(encoding="utf-8") as f:
  65. lines = f.readlines()
  66. start_line = max(0, line_number - context_size - 1)
  67. end_line = min(len(lines), line_number + context_size)
  68. context = []
  69. for i in range(start_line, end_line):
  70. line_num = i + 1
  71. marker = ">>>" if line_num == line_number else " "
  72. context.append(f"{marker} {line_num:4d} | {lines[i].rstrip()}")
  73. return context
  74. except OSError:
  75. return []
  76. @staticmethod
  77. def get_common_jinja_suggestions(error_msg: str, available_vars: set) -> list[str]:
  78. """Generate helpful suggestions based on common Jinja2 errors.
  79. Args:
  80. error_msg: The error message from Jinja2
  81. available_vars: Set of available variable names
  82. Returns:
  83. List of actionable suggestions
  84. """
  85. suggestions = []
  86. error_lower = error_msg.lower()
  87. # Undefined variable errors
  88. if "undefined" in error_lower or "is not defined" in error_lower:
  89. # Try to extract variable name from error message
  90. var_match = re.search(r"'([^']+)'.*is undefined", error_msg)
  91. if not var_match:
  92. var_match = re.search(r"'([^']+)'.*is not defined", error_msg)
  93. if var_match:
  94. undefined_var = var_match.group(1)
  95. suggestions.append(f"Variable '{undefined_var}' is not defined in the template spec")
  96. # Suggest similar variable names (basic fuzzy matching)
  97. similar = [
  98. v
  99. for v in available_vars
  100. if undefined_var.lower() in v.lower() or v.lower() in undefined_var.lower()
  101. ]
  102. if similar:
  103. suggestions.append(f"Did you mean one of these? {', '.join(sorted(similar)[:5])}")
  104. suggestions.append(f"Add '{undefined_var}' to your template.yaml spec with a default value")
  105. suggestions.append("Or use the Jinja2 default filter: {{ " + undefined_var + " | default('value') }}")
  106. else:
  107. suggestions.append("Check that all variables used in templates are defined in template.yaml")
  108. suggestions.append("Use the Jinja2 default filter for optional variables: {{ var | default('value') }}")
  109. # Syntax errors
  110. elif "unexpected" in error_lower or "expected" in error_lower:
  111. suggestions.append("Check for syntax errors in your Jinja2 template")
  112. suggestions.append("Common issues: missing {% endfor %}, {% endif %}, or {% endblock %}")
  113. suggestions.append("Make sure all {{ }} and {% %} tags are properly closed")
  114. # Filter errors
  115. elif "filter" in error_lower:
  116. suggestions.append("Check that the filter name is spelled correctly")
  117. suggestions.append("Verify the filter exists in Jinja2 built-in filters")
  118. suggestions.append("Make sure filter arguments are properly formatted")
  119. # Template not found
  120. elif "not found" in error_lower or "does not exist" in error_lower:
  121. suggestions.append("Check that the included/imported template file exists")
  122. suggestions.append("Verify the template path is relative to the template directory")
  123. suggestions.append("Make sure the file has the .j2 extension if it's a Jinja2 template")
  124. # Type errors
  125. elif "type" in error_lower and ("int" in error_lower or "str" in error_lower or "bool" in error_lower):
  126. suggestions.append("Check that variable values have the correct type")
  127. suggestions.append("Use Jinja2 filters to convert types: {{ var | int }}, {{ var | string }}")
  128. # Add generic helpful tip
  129. if not suggestions:
  130. suggestions.append("Check the Jinja2 template syntax and variable usage")
  131. suggestions.append("Enable --debug mode for more detailed rendering information")
  132. return suggestions
  133. @classmethod
  134. def parse_jinja_error(
  135. cls,
  136. error: Exception,
  137. template_file: TemplateFile,
  138. template_dir: Path,
  139. available_vars: set,
  140. ) -> tuple[str, int | None, int | None, list[str], list[str]]:
  141. """Parse a Jinja2 exception to extract detailed error information.
  142. Args:
  143. error: The Jinja2 exception
  144. template_file: The TemplateFile being rendered
  145. template_dir: Template directory path
  146. available_vars: Set of available variable names
  147. Returns:
  148. Tuple of (error_message, line_number, column, context_lines, suggestions)
  149. """
  150. error_msg = str(error)
  151. line_number = None
  152. column = None
  153. context_lines = []
  154. suggestions = []
  155. # Extract line number from Jinja2 errors
  156. if hasattr(error, "lineno"):
  157. line_number = error.lineno
  158. # Extract file path and get context
  159. file_path = template_dir / template_file.relative_path
  160. if line_number and file_path.exists():
  161. context_lines = cls.extract_error_context(file_path, line_number)
  162. # Generate suggestions based on error type
  163. if isinstance(error, UndefinedError):
  164. error_msg = f"Undefined variable: {error}"
  165. suggestions = cls.get_common_jinja_suggestions(str(error), available_vars)
  166. elif isinstance(error, Jinja2TemplateSyntaxError):
  167. error_msg = f"Template syntax error: {error}"
  168. suggestions = cls.get_common_jinja_suggestions(str(error), available_vars)
  169. elif isinstance(error, Jinja2TemplateNotFound):
  170. error_msg = f"Template file not found: {error}"
  171. suggestions = cls.get_common_jinja_suggestions(str(error), available_vars)
  172. else:
  173. # Generic Jinja2 error
  174. suggestions = cls.get_common_jinja_suggestions(error_msg, available_vars)
  175. return error_msg, line_number, column, context_lines, suggestions
  176. @dataclass
  177. class TemplateFile:
  178. """Represents a single file within a template directory."""
  179. relative_path: Path
  180. file_type: Literal["j2", "static"]
  181. output_path: Path # The path it will have in the output directory
  182. @dataclass
  183. class TemplateMetadata:
  184. """Represents template metadata with proper typing."""
  185. name: str
  186. description: str
  187. author: str
  188. date: str
  189. version: str
  190. module: str = ""
  191. tags: list[str] = field(default_factory=list)
  192. library: str = "unknown"
  193. library_type: str = "git" # Type of library ("git" or "static")
  194. next_steps: str = ""
  195. draft: bool = False
  196. def __init__(
  197. self,
  198. template_data: dict,
  199. library_name: str | None = None,
  200. library_type: str = "git",
  201. ) -> None:
  202. """Initialize TemplateMetadata from parsed YAML template data.
  203. Args:
  204. template_data: Parsed YAML data from template.yaml
  205. library_name: Name of the library this template belongs to
  206. """
  207. # Validate metadata format first
  208. self._validate_metadata(template_data)
  209. # Extract metadata section
  210. metadata_section = template_data.get("metadata", {})
  211. self.name = metadata_section.get("name", "")
  212. # YAML block scalar (|) preserves a trailing newline. Remove only trailing newlines
  213. # while preserving internal newlines/formatting.
  214. raw_description = metadata_section.get("description", "")
  215. # TODO: remove when all templates have been migrated to markdown
  216. description = raw_description.rstrip("\n") if isinstance(raw_description, str) else str(raw_description)
  217. self.description = description or "No description available"
  218. self.author = metadata_section.get("author", "")
  219. self.date = metadata_section.get("date", "")
  220. self.version = metadata_section.get("version", "")
  221. self.module = metadata_section.get("module", "")
  222. self.tags = metadata_section.get("tags", []) or []
  223. self.library = library_name or "unknown"
  224. self.library_type = library_type
  225. self.draft = metadata_section.get("draft", False)
  226. # Extract next_steps (optional)
  227. raw_next_steps = metadata_section.get("next_steps", "")
  228. if isinstance(raw_next_steps, str):
  229. next_steps = raw_next_steps.rstrip("\n")
  230. else:
  231. next_steps = str(raw_next_steps) if raw_next_steps else ""
  232. self.next_steps = next_steps
  233. @staticmethod
  234. def _validate_metadata(template_data: dict) -> None:
  235. """Validate that template has required 'metadata' section with all required fields.
  236. Args:
  237. template_data: Parsed YAML data from template.yaml
  238. Raises:
  239. ValueError: If metadata section is missing or incomplete
  240. """
  241. metadata_section = template_data.get("metadata")
  242. if metadata_section is None:
  243. raise ValueError("Template format error: missing 'metadata' section")
  244. # Validate that metadata section has all required fields
  245. required_fields = ["name", "author", "version", "date", "description"]
  246. missing_fields = [field for field in required_fields if not metadata_section.get(field)]
  247. if missing_fields:
  248. raise ValueError(f"Template format error: missing required metadata fields: {missing_fields}")
  249. @dataclass
  250. class Template:
  251. """Represents a template directory."""
  252. def __init__(self, template_dir: Path, library_name: str, library_type: str = "git") -> None:
  253. """Create a Template instance from a directory path.
  254. Args:
  255. template_dir: Path to the template directory
  256. library_name: Name of the library this template belongs to
  257. library_type: Type of library ("git" or "static"), defaults to "git"
  258. """
  259. logger.debug(f"Loading template from directory: {template_dir}")
  260. self.template_dir = template_dir
  261. self.id = template_dir.name
  262. self.original_id = template_dir.name # Store the original ID
  263. self.library_name = library_name
  264. self.library_type = library_type
  265. # Initialize caches for lazy loading
  266. self.__module_specs: dict | None = None
  267. self.__merged_specs: dict | None = None
  268. self.__jinja_env: Environment | None = None
  269. self.__used_variables: set[str] | None = None
  270. self.__variables: VariableCollection | None = None
  271. self.__template_files: list[TemplateFile] | None = None # New attribute
  272. self._schema_deprecation_warned: bool = False # Track if deprecation warning shown
  273. try:
  274. # Find and parse the main template file (template.yaml or template.yml)
  275. main_template_path = self._find_main_template_file()
  276. with main_template_path.open(encoding="utf-8") as f:
  277. # Load all YAML documents (handles templates with empty lines before ---)
  278. documents = list(yaml.safe_load_all(f))
  279. # Filter out None/empty documents and get the first non-empty one
  280. valid_docs = [doc for doc in documents if doc is not None]
  281. if not valid_docs:
  282. raise ValueError("Template file contains no valid YAML data")
  283. if len(valid_docs) > 1:
  284. logger.warning("Template file contains multiple YAML documents, using the first one")
  285. self._template_data = valid_docs[0]
  286. # Validate template data
  287. if not isinstance(self._template_data, dict):
  288. raise ValueError("Template file must contain a valid YAML dictionary")
  289. # Load metadata (always needed)
  290. self.metadata = TemplateMetadata(self._template_data, library_name, library_type)
  291. logger.debug(f"Loaded metadata: {self.metadata}")
  292. # Validate 'kind' field (always needed)
  293. self._validate_kind(self._template_data)
  294. # Extract schema version (only if explicitly declared in template.yaml)
  295. # If schema property is missing, template is self-contained (no schema inheritance)
  296. if "schema" in self._template_data:
  297. self.schema_version = str(self._template_data["schema"])
  298. logger.debug(f"Template uses schema version: {self.schema_version}")
  299. else:
  300. # No schema property = template is self-contained
  301. self.schema_version = None
  302. logger.debug(f"Template is self-contained (no schema property)")
  303. # Note: Schema version validation is done by the module when loading templates
  304. # NOTE: File collection is now lazy-loaded via the template_files property
  305. # This significantly improves performance when listing many templates
  306. logger.info(f"Loaded template '{self.id}' (v{self.metadata.version})")
  307. except (ValueError, FileNotFoundError) as e:
  308. logger.error(f"Error loading template from {template_dir}: {e}")
  309. raise TemplateLoadError(f"Error loading template from {template_dir}: {e}") from e
  310. except yaml.YAMLError as e:
  311. logger.error(f"YAML parsing error in template {template_dir}: {e}")
  312. raise YAMLParseError(str(template_dir / "template.y*ml"), e) from e
  313. except OSError as e:
  314. logger.error(f"File I/O error loading template {template_dir}: {e}")
  315. raise TemplateLoadError(f"File I/O error loading template from {template_dir}: {e}") from e
  316. def set_qualified_id(self, library_name: str | None = None) -> None:
  317. """Set a qualified ID for this template (used when duplicates exist across libraries).
  318. Args:
  319. library_name: Name of the library to qualify with. If None, uses self.library_name
  320. """
  321. lib_name = library_name or self.library_name
  322. self.id = f"{self.original_id}.{lib_name}"
  323. logger.debug(f"Template ID qualified: {self.original_id} -> {self.id}")
  324. def _find_main_template_file(self) -> Path:
  325. """Find the main template file (template.yaml or template.yml)."""
  326. for filename in ["template.yaml", "template.yml"]:
  327. path = self.template_dir / filename
  328. if path.exists():
  329. return path
  330. raise FileNotFoundError(f"Main template file (template.yaml or template.yml) not found in {self.template_dir}")
  331. @staticmethod
  332. @lru_cache(maxsize=32)
  333. def _load_module_specs_for_schema(kind: str, schema_version: str) -> dict:
  334. """Load specifications from the corresponding module for a specific schema version.
  335. DEPRECATION NOTICE (v0.1.3): Module schemas are being deprecated. Templates should define
  336. all variables in their template.yaml spec section. In future versions, this method will
  337. return an empty dict and templates will need to be self-contained.
  338. Uses LRU cache to avoid re-loading the same module spec multiple times.
  339. This significantly improves performance when listing many templates of the same kind.
  340. Args:
  341. kind: The module kind (e.g., 'compose', 'terraform')
  342. schema_version: The schema version to load (e.g., '1.0', '1.1')
  343. Returns:
  344. Dictionary containing the module's spec for the requested schema version,
  345. or empty dict if kind is empty
  346. Raises:
  347. ValueError: If module cannot be loaded or spec is invalid
  348. """
  349. if not kind:
  350. return {}
  351. # Log cache statistics for performance monitoring
  352. cache_info = Template._load_module_specs_for_schema.cache_info()
  353. logger.debug(
  354. f"Loading module spec: kind='{kind}', schema={schema_version} "
  355. f"(cache: hits={cache_info.hits}, misses={cache_info.misses}, size={cache_info.currsize})"
  356. )
  357. try:
  358. module = importlib.import_module(f"cli.modules.{kind}")
  359. # Check if module has schema-specific specs (multi-schema support)
  360. # Try SCHEMAS constant first (uppercase), then schemas attribute
  361. schemas = getattr(module, "SCHEMAS", None) or getattr(module, "schemas", None)
  362. if schemas and schema_version in schemas:
  363. spec = schemas[schema_version]
  364. logger.debug(f"Loaded and cached module spec for kind '{kind}' schema {schema_version}")
  365. else:
  366. # Fallback to default spec if schema mapping not available
  367. spec = getattr(module, "spec", {})
  368. logger.debug(f"Loaded and cached module spec for kind '{kind}' (default/no schema mapping)")
  369. return spec
  370. except Exception as e:
  371. raise ValueError(f"Error loading module specifications for kind '{kind}': {e}") from e
  372. def _merge_specs(self, module_specs: dict, template_specs: dict) -> dict:
  373. """Deep merge template specs with module specs using VariableCollection.
  374. Uses VariableCollection's native merge() method for consistent merging logic.
  375. Module specs are base, template specs override with origin tracking.
  376. """
  377. # Create VariableCollection from module specs (base)
  378. module_collection = VariableCollection(module_specs) if module_specs else VariableCollection({})
  379. # Set origin for module variables
  380. for section in module_collection.get_sections().values():
  381. for variable in section.variables.values():
  382. if not variable.origin:
  383. variable.origin = "module"
  384. # Merge template specs into module specs (template overrides)
  385. if template_specs:
  386. merged_collection = module_collection.merge(template_specs, origin="template")
  387. else:
  388. merged_collection = module_collection
  389. # Convert back to dict format
  390. merged_spec = {}
  391. for section_key, section in merged_collection.get_sections().items():
  392. merged_spec[section_key] = section.to_dict()
  393. return merged_spec
  394. def _warn_about_inherited_variables(self, module_specs: dict, template_specs: dict) -> None:
  395. """Warn about variables inherited from module schemas (deprecation warning).
  396. DEPRECATION (v0.1.3): Templates should define all variables explicitly in template.yaml.
  397. This method detects variables that are used in template files but come from module schemas,
  398. and warns users to add them to the template spec.
  399. Args:
  400. module_specs: Variables defined in module schema
  401. template_specs: Variables defined in template.yaml
  402. """
  403. # Skip if no module specs (already self-contained)
  404. if not module_specs:
  405. return
  406. # Collect variables from module specs
  407. module_vars = set()
  408. for section_data in module_specs.values():
  409. if isinstance(section_data, dict) and "vars" in section_data:
  410. module_vars.update(section_data["vars"].keys())
  411. # Collect variables explicitly defined in template
  412. template_vars = set()
  413. for section_data in (template_specs or {}).values():
  414. if isinstance(section_data, dict) and "vars" in section_data:
  415. template_vars.update(section_data["vars"].keys())
  416. # Get variables actually used in template files
  417. used_vars = self.used_variables
  418. # Find variables that are:
  419. # 1. Used in template files AND defined in module schema (inherited variables)
  420. # 2. NOT explicitly defined in template.yaml (missing definitions)
  421. # These are the problematic ones - the template relies on schema
  422. inherited_vars = used_vars & module_vars
  423. missing_definitions = inherited_vars - template_vars
  424. # Only warn once per template (use a flag to avoid repeated warnings)
  425. if missing_definitions and not self._schema_deprecation_warned:
  426. self._schema_deprecation_warned = True
  427. # Show first N variables in warning, full list in debug
  428. max_shown_vars = 10
  429. shown_vars = sorted(list(missing_definitions)[:max_shown_vars])
  430. ellipsis = "..." if len(missing_definitions) > max_shown_vars else ""
  431. logger.warning(
  432. f"DEPRECATION WARNING: Template '{self.id}' uses {len(missing_definitions)} "
  433. f"variable(s) from module schema without defining them in template.yaml. "
  434. f"In future versions, all used variables must be defined in template.yaml. "
  435. f"Missing definitions: {', '.join(shown_vars)}{ellipsis}"
  436. )
  437. logger.debug(
  438. f"Template '{self.id}' should add these variable definitions to template.yaml spec: "
  439. f"{sorted(missing_definitions)}"
  440. )
  441. def _warn_about_unused_variables(self, template_specs: dict) -> None:
  442. """Warn about variables defined in spec but not used in template files.
  443. This helps identify unnecessary variable definitions that can be removed.
  444. Args:
  445. template_specs: Variables defined in template.yaml spec
  446. """
  447. # Collect variables explicitly defined in template
  448. defined_vars = set()
  449. for section_data in (template_specs or {}).values():
  450. if isinstance(section_data, dict) and "vars" in section_data:
  451. defined_vars.update(section_data["vars"].keys())
  452. # Get variables actually used in template files
  453. used_vars = self.used_variables
  454. # Find variables that are defined but not used
  455. unused_vars = defined_vars - used_vars
  456. if unused_vars:
  457. # Show first N variables in warning, full list in debug
  458. max_shown_vars = 10
  459. shown_vars = sorted(list(unused_vars)[:max_shown_vars])
  460. ellipsis = "..." if len(unused_vars) > max_shown_vars else ""
  461. logger.warning(
  462. f"Template '{self.id}' defines {len(unused_vars)} variable(s) that are not used in template files. "
  463. f"Consider removing them from the spec: {', '.join(shown_vars)}{ellipsis}"
  464. )
  465. logger.debug(
  466. f"Template '{self.id}' unused variables: {sorted(unused_vars)}"
  467. )
  468. def _collect_template_files(self) -> None:
  469. """Collects all TemplateFile objects in the template directory."""
  470. template_files: list[TemplateFile] = []
  471. for root, _, files in os.walk(self.template_dir):
  472. for filename in files:
  473. file_path = Path(root) / filename
  474. relative_path = file_path.relative_to(self.template_dir)
  475. # Skip the main template file
  476. if filename in ["template.yaml", "template.yml"]:
  477. continue
  478. if filename.endswith(".j2"):
  479. file_type: Literal["j2", "static"] = "j2"
  480. output_path = relative_path.with_suffix("") # Remove .j2 suffix
  481. else:
  482. file_type = "static"
  483. output_path = relative_path # Static files keep their name
  484. template_files.append(
  485. TemplateFile(
  486. relative_path=relative_path,
  487. file_type=file_type,
  488. output_path=output_path,
  489. )
  490. )
  491. self.__template_files = template_files
  492. def _extract_all_used_variables(self) -> set[str]:
  493. """Extract all undeclared variables from all .j2 files in the template directory.
  494. Raises:
  495. ValueError: If any Jinja2 template has syntax errors
  496. """
  497. used_variables: set[str] = set()
  498. syntax_errors = []
  499. # Track which file uses which variable (for debugging)
  500. self._variable_usage_map: dict[str, list[str]] = {}
  501. for template_file in self.template_files: # Iterate over TemplateFile objects
  502. if template_file.file_type == "j2":
  503. file_path = self.template_dir / template_file.relative_path
  504. try:
  505. with file_path.open(encoding="utf-8") as f:
  506. content = f.read()
  507. ast = self.jinja_env.parse(content) # Use lazy-loaded jinja_env
  508. file_vars = meta.find_undeclared_variables(ast)
  509. used_variables.update(file_vars)
  510. # Track which file uses each variable
  511. for var in file_vars:
  512. if var not in self._variable_usage_map:
  513. self._variable_usage_map[var] = []
  514. self._variable_usage_map[var].append(str(template_file.relative_path))
  515. except OSError as e:
  516. relative_path = file_path.relative_to(self.template_dir)
  517. syntax_errors.append(f" - {relative_path}: File I/O error: {e}")
  518. except Exception as e:
  519. # Collect syntax errors for Jinja2 issues
  520. relative_path = file_path.relative_to(self.template_dir)
  521. syntax_errors.append(f" - {relative_path}: {e}")
  522. # Raise error if any syntax errors were found
  523. if syntax_errors:
  524. logger.error(f"Jinja2 syntax errors found in template '{self.id}'")
  525. raise TemplateSyntaxError(self.id, syntax_errors)
  526. return used_variables
  527. def _filter_specs_to_used(
  528. self,
  529. used_variables: set,
  530. merged_specs: dict,
  531. _module_specs: dict,
  532. template_specs: dict,
  533. ) -> dict:
  534. """Filter specs to only include variables used in templates using VariableCollection.
  535. Uses VariableCollection's native filter_to_used() method.
  536. Keeps sensitive variables only if they're defined in the template spec or actually used.
  537. """
  538. # Build set of variables explicitly defined in template spec
  539. template_defined_vars = set()
  540. for section_data in (template_specs or {}).values():
  541. if isinstance(section_data, dict) and "vars" in section_data:
  542. template_defined_vars.update(section_data["vars"].keys())
  543. # Create VariableCollection from merged specs
  544. merged_collection = VariableCollection(merged_specs)
  545. # Filter to only used variables (and sensitive ones that are template-defined)
  546. # We keep sensitive variables that are either:
  547. # 1. Actually used in template files, OR
  548. # 2. Explicitly defined in the template spec (even if not yet used)
  549. variables_to_keep = used_variables | template_defined_vars
  550. filtered_collection = merged_collection.filter_to_used(variables_to_keep, keep_sensitive=False)
  551. # Convert back to dict format
  552. filtered_specs = {}
  553. for section_key, section in filtered_collection.get_sections().items():
  554. filtered_specs[section_key] = section.to_dict()
  555. return filtered_specs
  556. def _validate_schema_version(self, module_schema: str, module_name: str) -> None:
  557. """Validate that template schema version is supported by the module.
  558. Self-contained templates (with schema=None) don't require validation.
  559. Args:
  560. module_schema: Schema version supported by the module
  561. module_name: Name of the module (for error messages)
  562. Raises:
  563. IncompatibleSchemaVersionError: If template schema > module schema
  564. """
  565. template_schema = self.schema_version
  566. # Self-contained templates (no schema property) don't need validation
  567. if template_schema is None:
  568. logger.debug(f"Template '{self.id}' is self-contained (no schema inheritance)")
  569. return
  570. # Compare schema versions
  571. if not is_compatible(module_schema, template_schema):
  572. logger.error(
  573. f"Template '{self.id}' uses schema version {template_schema}, "
  574. f"but module '{module_name}' only supports up to {module_schema}"
  575. )
  576. raise IncompatibleSchemaVersionError(
  577. template_id=self.id,
  578. template_schema=template_schema,
  579. module_schema=module_schema,
  580. module_name=module_name,
  581. )
  582. logger.debug(
  583. f"Template '{self.id}' schema version compatible: "
  584. f"template uses {template_schema}, module supports {module_schema}"
  585. )
  586. @staticmethod
  587. def _validate_kind(template_data: dict) -> None:
  588. """Validate that template has required 'kind' field.
  589. Args:
  590. template_data: Parsed YAML data from template.yaml
  591. Raises:
  592. ValueError: If 'kind' field is missing
  593. """
  594. if not template_data.get("kind"):
  595. raise TemplateValidationError("Template format error: missing 'kind' field")
  596. def _validate_variable_definitions(self, used_variables: set[str], merged_specs: dict[str, Any]) -> None:
  597. """Validate that all variables used in Jinja2 content are defined in the spec."""
  598. defined_variables = set()
  599. for section_data in merged_specs.values():
  600. if "vars" in section_data and isinstance(section_data["vars"], dict):
  601. defined_variables.update(section_data["vars"].keys())
  602. undefined_variables = used_variables - defined_variables
  603. if undefined_variables:
  604. undefined_list = sorted(undefined_variables)
  605. # Build file location info for each undefined variable
  606. file_locations = []
  607. for var_name in undefined_list:
  608. if hasattr(self, "_variable_usage_map") and var_name in self._variable_usage_map:
  609. files = self._variable_usage_map[var_name]
  610. file_locations.append(f" • {var_name}: {', '.join(files)}")
  611. error_msg = (
  612. f"Template validation error in '{self.id}': "
  613. f"Variables used in template content but not defined in spec:\n"
  614. )
  615. if file_locations:
  616. error_msg += "\n".join(file_locations) + "\n"
  617. else:
  618. error_msg += f"{undefined_list}\n"
  619. error_msg += (
  620. "\nPlease add these variables to your template's template.yaml spec. "
  621. "Each variable must have a default value.\n\n"
  622. "Example:\n"
  623. "spec:\n"
  624. " general:\n"
  625. " vars:\n"
  626. )
  627. for var_name in undefined_list:
  628. error_msg += (
  629. f" {var_name}:\n"
  630. f" type: str\n"
  631. f" description: Description for {var_name}\n"
  632. f" default: <your_default_value_here>\n"
  633. )
  634. # Only log at DEBUG level - the exception will be displayed to user
  635. logger.debug(error_msg)
  636. raise TemplateValidationError(error_msg)
  637. @staticmethod
  638. def _create_jinja_env(searchpath: Path) -> Environment:
  639. """Create sandboxed Jinja2 environment for secure template processing.
  640. Uses SandboxedEnvironment to prevent code injection vulnerabilities
  641. when processing untrusted templates. This restricts access to dangerous
  642. operations while still allowing safe template rendering.
  643. Returns:
  644. SandboxedEnvironment configured for template processing.
  645. """
  646. # NOTE Use SandboxedEnvironment for security - prevents arbitrary code execution
  647. return SandboxedEnvironment(
  648. loader=FileSystemLoader(searchpath),
  649. trim_blocks=True,
  650. lstrip_blocks=True,
  651. keep_trailing_newline=False,
  652. )
  653. def _generate_autogenerated_values(self, variables: VariableCollection, variable_values: dict) -> None:
  654. """Generate values for autogenerated variables that are empty.
  655. Supports both plain and base64-encoded autogenerated values based on
  656. the autogenerated_base64 flag. Base64 encoding generates random bytes
  657. and encodes them, which is more suitable for cryptographic keys.
  658. """
  659. for section in variables.get_sections().values():
  660. for var_name, variable in section.variables.items():
  661. if variable.autogenerated and (variable.value is None or variable.value == ""):
  662. length = getattr(variable, "autogenerated_length", 32)
  663. use_base64 = getattr(variable, "autogenerated_base64", False)
  664. if use_base64:
  665. # Generate random bytes and base64 encode
  666. # Note: length refers to number of random bytes, not base64 string length
  667. random_bytes = secrets.token_bytes(length)
  668. generated_value = base64.b64encode(random_bytes).decode("utf-8")
  669. logger.debug(
  670. f"Auto-generated base64 value for variable '{var_name}' "
  671. f"(bytes: {length}, encoded length: {len(generated_value)})"
  672. )
  673. else:
  674. # Generate alphanumeric string
  675. alphabet = string.ascii_letters + string.digits
  676. generated_value = "".join(secrets.choice(alphabet) for _ in range(length))
  677. logger.debug(f"Auto-generated value for variable '{var_name}' (length: {length})")
  678. variable_values[var_name] = generated_value
  679. def _log_render_start(self, debug: bool, variable_values: dict) -> None:
  680. """Log rendering start information."""
  681. if debug:
  682. logger.info(f"Rendering template '{self.id}' in debug mode")
  683. logger.info(f"Available variables: {sorted(variable_values.keys())}")
  684. logger.info(f"Variable values: {variable_values}")
  685. else:
  686. logger.debug(f"Rendering template '{self.id}' with variables: {variable_values}")
  687. def _render_jinja2_file(self, template_file, variable_values: dict, _available_vars: set, debug: bool) -> str:
  688. """Render a single Jinja2 template file."""
  689. if debug:
  690. logger.info(f"Rendering Jinja2 template: {template_file.relative_path}")
  691. template = self.jinja_env.get_template(str(template_file.relative_path))
  692. rendered_content = template.render(**variable_values)
  693. rendered_content = self._sanitize_content(rendered_content, template_file.output_path)
  694. if debug:
  695. logger.info(f"Successfully rendered: {template_file.relative_path} -> {template_file.output_path}")
  696. return rendered_content
  697. def _handle_jinja2_error(
  698. self,
  699. e: Exception,
  700. template_file,
  701. available_vars: set,
  702. variable_values: dict,
  703. debug: bool,
  704. ) -> None:
  705. """Handle Jinja2 rendering errors."""
  706. error_msg, line_num, col, context_lines, suggestions = TemplateErrorHandler.parse_jinja_error(
  707. e, template_file, self.template_dir, available_vars
  708. )
  709. logger.error(f"Error rendering template file {template_file.relative_path}: {error_msg}")
  710. context = RenderErrorContext(
  711. file_path=str(template_file.relative_path),
  712. line_number=line_num,
  713. column=col,
  714. context_lines=context_lines,
  715. variable_context={k: str(v) for k, v in variable_values.items()} if debug else {},
  716. suggestions=suggestions,
  717. original_error=e,
  718. )
  719. raise TemplateRenderError(message=error_msg, context=context) from e
  720. def _render_static_file(self, template_file, debug: bool) -> str:
  721. """Read and return content of a static file."""
  722. file_path = self.template_dir / template_file.relative_path
  723. if debug:
  724. logger.info(f"Copying static file: {template_file.relative_path}")
  725. try:
  726. with file_path.open(encoding="utf-8") as f:
  727. return f.read()
  728. except OSError as e:
  729. logger.error(f"Error reading static file {file_path}: {e}")
  730. context = RenderErrorContext(
  731. file_path=str(template_file.relative_path),
  732. suggestions=["Check that the file exists and has read permissions"],
  733. original_error=e,
  734. )
  735. raise TemplateRenderError(
  736. message=f"Error reading static file: {e}",
  737. context=context,
  738. ) from e
  739. def render(self, variables: VariableCollection, debug: bool = False) -> tuple[dict[str, str], dict[str, Any]]:
  740. """Render all .j2 files in the template directory.
  741. Args:
  742. variables: VariableCollection with values to use for rendering
  743. debug: Enable debug mode with verbose output
  744. Returns:
  745. Tuple of (rendered_files, variable_values) where variable_values includes autogenerated values.
  746. Empty files (files with only whitespace) are excluded from the returned dict.
  747. """
  748. variable_values = variables.get_satisfied_values()
  749. self._generate_autogenerated_values(variables, variable_values)
  750. self._log_render_start(debug, variable_values)
  751. rendered_files = {}
  752. skipped_files = []
  753. available_vars = set(variable_values.keys())
  754. for template_file in self.template_files:
  755. if template_file.file_type == "j2":
  756. try:
  757. content = self._render_jinja2_file(template_file, variable_values, available_vars, debug)
  758. # Skip empty files (only whitespace, empty string, or just YAML document separator)
  759. stripped = content.strip()
  760. if stripped and stripped != "---":
  761. rendered_files[str(template_file.output_path)] = content
  762. else:
  763. skipped_files.append(str(template_file.output_path))
  764. if debug:
  765. logger.info(f"Skipping empty file: {template_file.output_path}")
  766. except (
  767. UndefinedError,
  768. Jinja2TemplateSyntaxError,
  769. Jinja2TemplateNotFound,
  770. Jinja2TemplateError,
  771. ) as e:
  772. self._handle_jinja2_error(e, template_file, available_vars, variable_values, debug)
  773. except Exception as e:
  774. logger.error(f"Unexpected error rendering template file {template_file.relative_path}: {e}")
  775. context = RenderErrorContext(
  776. file_path=str(template_file.relative_path),
  777. suggestions=["This is an unexpected error. Please check the template for issues."],
  778. original_error=e,
  779. )
  780. raise TemplateRenderError(
  781. message=f"Unexpected rendering error: {e}",
  782. context=context,
  783. ) from e
  784. elif template_file.file_type == "static":
  785. content = self._render_static_file(template_file, debug)
  786. # Static files are always included, even if empty
  787. rendered_files[str(template_file.output_path)] = content
  788. if skipped_files:
  789. logger.debug(f"Skipped {len(skipped_files)} empty file(s): {', '.join(skipped_files)}")
  790. return rendered_files, variable_values
  791. def _sanitize_content(self, content: str, _file_path: Path) -> str:
  792. """Sanitize rendered content by removing excessive blank lines and trailing whitespace."""
  793. if not content:
  794. return content
  795. lines = [line.rstrip() for line in content.split("\n")]
  796. sanitized = []
  797. prev_blank = False
  798. for line in lines:
  799. is_blank = not line
  800. if is_blank and prev_blank:
  801. continue # Skip consecutive blank lines
  802. sanitized.append(line)
  803. prev_blank = is_blank
  804. # Remove leading blanks and ensure single trailing newline
  805. return "\n".join(sanitized).lstrip("\n").rstrip("\n") + "\n"
  806. @property
  807. def template_files(self) -> list[TemplateFile]:
  808. if self.__template_files is None:
  809. self._collect_template_files() # Populate self.__template_files
  810. return self.__template_files
  811. @property
  812. def template_specs(self) -> dict:
  813. """Get the spec section from template YAML data."""
  814. return self._template_data.get("spec", {})
  815. @property
  816. def module_specs(self) -> dict:
  817. """Get the spec from the module definition for this template's schema version.
  818. Returns empty dict if template doesn't declare a schema (self-contained).
  819. """
  820. if self.__module_specs is None:
  821. # Only load module specs if template explicitly declares a schema version
  822. # Templates without a schema property are self-contained
  823. if self.schema_version is not None:
  824. kind = self._template_data.get("kind")
  825. self.__module_specs = self._load_module_specs_for_schema(kind, self.schema_version)
  826. else:
  827. self.__module_specs = {}
  828. return self.__module_specs
  829. @property
  830. def merged_specs(self) -> dict:
  831. if self.__merged_specs is None:
  832. # Warn about inherited variables (deprecation)
  833. self._warn_about_inherited_variables(self.module_specs, self.template_specs)
  834. # Warn about unused variables in spec
  835. self._warn_about_unused_variables(self.template_specs)
  836. self.__merged_specs = self._merge_specs(self.module_specs, self.template_specs)
  837. return self.__merged_specs
  838. @property
  839. def jinja_env(self) -> Environment:
  840. if self.__jinja_env is None:
  841. self.__jinja_env = self._create_jinja_env(self.template_dir)
  842. return self.__jinja_env
  843. @property
  844. def used_variables(self) -> set[str]:
  845. if self.__used_variables is None:
  846. self.__used_variables = self._extract_all_used_variables()
  847. return self.__used_variables
  848. @property
  849. def variables(self) -> VariableCollection:
  850. if self.__variables is None:
  851. # Validate that all used variables are defined
  852. self._validate_variable_definitions(self.used_variables, self.merged_specs)
  853. # Filter specs to only used variables
  854. filtered_specs = self._filter_specs_to_used(
  855. self.used_variables,
  856. self.merged_specs,
  857. self.module_specs,
  858. self.template_specs,
  859. )
  860. self.__variables = VariableCollection(filtered_specs)
  861. # Sort sections: required first, then enabled, then disabled
  862. self.__variables.sort_sections()
  863. return self.__variables
  864. @property
  865. def status(self) -> str:
  866. """Get the status of the template.
  867. Returns:
  868. Status string: 'published' or 'draft'
  869. Note:
  870. The 'invalid' status is reserved for future use when template validation
  871. is implemented without impacting list command performance.
  872. """
  873. # Check if template is marked as draft in metadata
  874. if self.metadata.draft:
  875. return TEMPLATE_STATUS_DRAFT
  876. # Template is published (valid and not draft)
  877. return TEMPLATE_STATUS_PUBLISHED