template.py 37 KB

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