mirror of
https://github.com/ChristianLempa/boilerplates.git
synced 2026-09-24 15:19:43 -05:00
complete refactoring
This commit is contained in:
@@ -79,6 +79,8 @@ boilerplate --log-level DEBUG [command]
|
||||
- `BaseModule`: Abstract base class providing shared commands (config management)
|
||||
- Module Commands: Each module implements technology-specific operations
|
||||
- Template Library: Structured collection of boilerplates with metadata
|
||||
- `Template.vars_map`: Unified variables metadata and defaults (merged from module variables_spec and frontmatter)
|
||||
- `PromptHandler`: Interactive prompting based on vars_map and template usage
|
||||
|
||||
### Template Format
|
||||
|
||||
@@ -109,10 +111,11 @@ tags:
|
||||
|
||||
The codebase has been optimized following the ARCHITECTURE_OPTIMIZATION.md plan:
|
||||
|
||||
### Simplified Variable System
|
||||
- Reduced from 3 classes (Variable, VariableGroup, VariableManager) to 2 classes (Variable, VariableRegistry)
|
||||
- Removed complex enable/disable logic
|
||||
- Streamlined data transformations
|
||||
### Simplified Variable System (2025-09)
|
||||
- Replaced custom registry with a unified variables map (vars_map) on Template
|
||||
- Module variables are defined as a simple dict (variables_spec) and merged with template frontmatter
|
||||
- Dotted names (e.g., traefik.tls.certresolver) imply hierarchy for prompting/sections
|
||||
- No separate Variable/Registry classes needed
|
||||
|
||||
### Streamlined Module System
|
||||
- Removed decorator pattern (@register_module)
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
"""Global configuration management for the boilerplate CLI."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
import logging
|
||||
import yaml
|
||||
|
||||
# Using standard Python exceptions
|
||||
|
||||
logger = logging.getLogger('boilerplates')
|
||||
|
||||
|
||||
@dataclass
|
||||
class LibraryConfig:
|
||||
"""Configuration for a single library."""
|
||||
name: str
|
||||
type: str # 'local' or 'git'
|
||||
path: Optional[str] = None # For local libraries
|
||||
repo: Optional[str] = None # For git libraries
|
||||
branch: str = "main" # For git libraries
|
||||
priority: int = 0 # Higher priority = checked first
|
||||
|
||||
|
||||
@dataclass
|
||||
class Config:
|
||||
"""Global configuration management."""
|
||||
|
||||
# Paths
|
||||
config_dir: Path = field(default_factory=lambda: Path.home() / ".boilerplates")
|
||||
cache_dir: Path = field(default_factory=lambda: Path.home() / ".boilerplates" / "cache")
|
||||
|
||||
# Libraries
|
||||
libraries: List[LibraryConfig] = field(default_factory=list)
|
||||
|
||||
# Application settings
|
||||
log_level: str = "INFO"
|
||||
default_editor: str = "vim"
|
||||
auto_update_remotes: bool = False
|
||||
template_validation: bool = True
|
||||
|
||||
# UI settings
|
||||
use_rich_output: bool = True
|
||||
confirm_generation: bool = True
|
||||
show_summary: bool = True
|
||||
|
||||
def __post_init__(self):
|
||||
"""Ensure directories exist."""
|
||||
self.config_dir.mkdir(parents=True, exist_ok=True)
|
||||
self.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@classmethod
|
||||
def load(cls, config_path=None):
|
||||
"""Load configuration from file or use defaults.
|
||||
|
||||
Args:
|
||||
config_path: Optional path to config file. If not provided,
|
||||
uses ~/.boilerplates/config.yaml
|
||||
|
||||
Returns:
|
||||
Config instance with loaded or default values
|
||||
"""
|
||||
if config_path is None:
|
||||
config_path = Path.home() / ".boilerplates" / "config.yaml"
|
||||
|
||||
if config_path.exists():
|
||||
try:
|
||||
with open(config_path, 'r') as f:
|
||||
data = yaml.safe_load(f) or {}
|
||||
|
||||
# Parse libraries if present
|
||||
libraries = []
|
||||
for lib_data in data.get('libraries', []):
|
||||
try:
|
||||
libraries.append(LibraryConfig(**lib_data))
|
||||
except TypeError as e:
|
||||
logger.warning(f"Invalid library configuration: {lib_data}, error: {e}")
|
||||
|
||||
# Remove libraries from data to avoid duplicate in Config init
|
||||
if 'libraries' in data:
|
||||
del data['libraries']
|
||||
|
||||
# Convert path strings to Path objects
|
||||
if 'config_dir' in data:
|
||||
data['config_dir'] = Path(data['config_dir'])
|
||||
if 'cache_dir' in data:
|
||||
data['cache_dir'] = Path(data['cache_dir'])
|
||||
|
||||
config = cls(**data, libraries=libraries)
|
||||
logger.debug(f"Loaded configuration from {config_path}")
|
||||
return config
|
||||
|
||||
except yaml.YAMLError as e:
|
||||
raise ValueError(f"Invalid YAML format in config.yaml: {e}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load config from {config_path}: {e}, using defaults")
|
||||
return cls()
|
||||
else:
|
||||
logger.debug(f"No config file found at {config_path}, using defaults")
|
||||
return cls()
|
||||
|
||||
def save(self, config_path=None):
|
||||
"""Save configuration to file.
|
||||
|
||||
Args:
|
||||
config_path: Optional path to save config. If not provided,
|
||||
uses ~/.boilerplates/config.yaml
|
||||
"""
|
||||
if config_path is None:
|
||||
config_path = self.config_dir / "config.yaml"
|
||||
|
||||
data = {
|
||||
'config_dir': str(self.config_dir),
|
||||
'cache_dir': str(self.cache_dir),
|
||||
'log_level': self.log_level,
|
||||
'default_editor': self.default_editor,
|
||||
'auto_update_remotes': self.auto_update_remotes,
|
||||
'template_validation': self.template_validation,
|
||||
'use_rich_output': self.use_rich_output,
|
||||
'confirm_generation': self.confirm_generation,
|
||||
'show_summary': self.show_summary,
|
||||
'libraries': [
|
||||
{
|
||||
'name': lib.name,
|
||||
'type': lib.type,
|
||||
'path': lib.path,
|
||||
'repo': lib.repo,
|
||||
'branch': lib.branch,
|
||||
'priority': lib.priority
|
||||
}
|
||||
for lib in self.libraries
|
||||
]
|
||||
}
|
||||
|
||||
# Remove None values from library configs
|
||||
for lib in data['libraries']:
|
||||
lib = {k: v for k, v in lib.items() if v is not None}
|
||||
|
||||
try:
|
||||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(config_path, 'w') as f:
|
||||
yaml.safe_dump(data, f, default_flow_style=False, sort_keys=False)
|
||||
logger.debug(f"Saved configuration to {config_path}")
|
||||
except Exception as e:
|
||||
raise OSError(f"Failed to save config.yaml: {e}")
|
||||
|
||||
def add_library(self, library):
|
||||
"""Add a library configuration.
|
||||
|
||||
Args:
|
||||
library: LibraryConfig instance to add
|
||||
"""
|
||||
# Check for duplicate names
|
||||
existing_names = {lib.name for lib in self.libraries}
|
||||
if library.name in existing_names:
|
||||
raise ValueError(f"Library with name '{library.name}' already exists")
|
||||
|
||||
self.libraries.append(library)
|
||||
# Sort by priority (highest first)
|
||||
self.libraries.sort(key=lambda l: l.priority, reverse=True)
|
||||
|
||||
def remove_library(self, name):
|
||||
"""Remove a library configuration by name.
|
||||
|
||||
Args:
|
||||
name: Name of the library to remove
|
||||
|
||||
Returns:
|
||||
True if library was removed, False if not found
|
||||
"""
|
||||
original_count = len(self.libraries)
|
||||
self.libraries = [lib for lib in self.libraries if lib.name != name]
|
||||
return len(self.libraries) < original_count
|
||||
|
||||
def get_library(self, name):
|
||||
"""Get a library configuration by name.
|
||||
|
||||
Args:
|
||||
name: Name of the library
|
||||
|
||||
Returns:
|
||||
LibraryConfig if found, None otherwise
|
||||
"""
|
||||
for lib in self.libraries:
|
||||
if lib.name == name:
|
||||
return lib
|
||||
return None
|
||||
|
||||
|
||||
# Global configuration instance
|
||||
_config = None
|
||||
|
||||
|
||||
def get_config():
|
||||
"""Get the global configuration instance.
|
||||
|
||||
Returns:
|
||||
The global Config instance, loading it if necessary
|
||||
"""
|
||||
global _config
|
||||
if _config is None:
|
||||
_config = Config.load()
|
||||
return _config
|
||||
|
||||
|
||||
def set_config(config):
|
||||
"""Set the global configuration instance.
|
||||
|
||||
Args:
|
||||
config: Config instance to use globally
|
||||
"""
|
||||
global _config
|
||||
_config = config
|
||||
+17
-364
@@ -1,8 +1,6 @@
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import logging
|
||||
from .config import get_config, LibraryConfig
|
||||
# Using standard Python exceptions
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -16,378 +14,33 @@ class Library:
|
||||
self.priority = priority # Higher priority = checked first
|
||||
|
||||
def find_by_id(self, module_name, files, template_id):
|
||||
"""
|
||||
Find a template by its ID in this library.
|
||||
|
||||
Args:
|
||||
module_name: The module name (e.g., 'terraform', 'compose') to search within.
|
||||
This narrows the search to the specific technology directory in the library.
|
||||
files: List of file patterns to search for (e.g., ['*.tf', '*.yaml']).
|
||||
This filters templates to only those with matching file extensions,
|
||||
ensuring we only process relevant template files for the module.
|
||||
template_id: The unique identifier of the template to find.
|
||||
This is typically derived from the template's directory name or filename.
|
||||
|
||||
Returns:
|
||||
Template object if found, None otherwise.
|
||||
"""
|
||||
from .template import Template # Import here to avoid circular import
|
||||
|
||||
logger.debug(f"Searching for template '{template_id}' in library '{self.name}' at {self.path} (module: {module_name})")
|
||||
|
||||
module_path = self.path / module_name
|
||||
if not module_path.exists():
|
||||
logger.debug(f"Module path '{module_path}' does not exist in library '{self.name}'")
|
||||
return None
|
||||
|
||||
# Try to find the template directory directly by ID
|
||||
template_dir = module_path / template_id
|
||||
if template_dir.exists() and template_dir.is_dir():
|
||||
# Look for template files in this specific directory
|
||||
for filename in files:
|
||||
for file_path in template_dir.glob(filename):
|
||||
if file_path.is_file():
|
||||
template = Template.from_file(file_path)
|
||||
# Set module context if not already specified in frontmatter
|
||||
if not template.module:
|
||||
template.module = module_name
|
||||
# Verify this is actually the template we want
|
||||
if template.id == template_id:
|
||||
logger.info(f"Found template '{template_id}' in library '{self.name}' (direct lookup)")
|
||||
return template
|
||||
|
||||
# Fallback to the original method if direct lookup fails
|
||||
# This handles cases where template ID doesn't match directory structure
|
||||
logger.debug(f"Direct lookup failed for '{template_id}', falling back to full scan in library '{self.name}'")
|
||||
for template in self.find(module_name, files, sorted=False):
|
||||
if template.id == template_id:
|
||||
logger.info(f"Found template '{template_id}' in library '{self.name}' (full scan)")
|
||||
return template
|
||||
|
||||
logger.debug(f"Template '{template_id}' not found in library '{self.name}'")
|
||||
return None
|
||||
"""Find a template by its ID in this library."""
|
||||
pass
|
||||
|
||||
def find(self, module_name, files, sorted=False):
|
||||
"""Find templates in this library for a specific module."""
|
||||
from .template import Template # Import here to avoid circular import
|
||||
|
||||
logger.debug(f"Scanning for templates in library '{self.name}' (module: {module_name}, files: {files})")
|
||||
|
||||
templates = []
|
||||
module_path = self.path / module_name
|
||||
|
||||
if not module_path.exists():
|
||||
logger.debug(f"Module path '{module_path}' does not exist in library '{self.name}'")
|
||||
return templates
|
||||
|
||||
# Find all files matching the specified filenames
|
||||
for filename in files:
|
||||
for file_path in module_path.rglob(filename):
|
||||
if file_path.is_file():
|
||||
# Create Template object using the new class method
|
||||
template = Template.from_file(file_path)
|
||||
# Set module context if not already specified in frontmatter
|
||||
if not template.module:
|
||||
template.module = module_name
|
||||
templates.append(template)
|
||||
|
||||
if sorted:
|
||||
templates.sort(key=lambda t: t.id)
|
||||
|
||||
if templates:
|
||||
logger.info(f"Found {len(templates)} templates in library '{self.name}' for module '{module_name}'")
|
||||
else:
|
||||
logger.debug(f"No templates found in library '{self.name}' for module '{module_name}'")
|
||||
return templates
|
||||
|
||||
|
||||
class RemoteLibrary(Library):
|
||||
"""Support for Git-based remote template libraries."""
|
||||
|
||||
def __init__(self, name: str, repo_url: str, branch: str = "main", priority: int = 0):
|
||||
"""Initialize a remote library.
|
||||
|
||||
Args:
|
||||
name: Name of the library
|
||||
repo_url: Git repository URL
|
||||
branch: Branch to use (default: main)
|
||||
priority: Library priority (higher = checked first)
|
||||
"""
|
||||
self.repo_url = repo_url
|
||||
self.branch = branch
|
||||
|
||||
# Set up local cache path
|
||||
config = get_config()
|
||||
local_cache = config.cache_dir / name
|
||||
|
||||
# Initialize parent with cache path
|
||||
super().__init__(name, local_cache, priority)
|
||||
|
||||
# Update the cache on initialization if configured
|
||||
if config.auto_update_remotes:
|
||||
try:
|
||||
self.update()
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to auto-update remote library '{name}': {e}")
|
||||
|
||||
def update(self) -> bool:
|
||||
"""Pull latest changes from remote repository.
|
||||
|
||||
Returns:
|
||||
True if update was successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
if not self.path.exists():
|
||||
# Clone repository
|
||||
logger.info(f"Cloning remote library '{self.name}' from {self.repo_url}")
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
result = subprocess.run(
|
||||
["git", "clone", "-b", self.branch, self.repo_url, str(self.path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
raise ConnectionError(f"Git clone failed for '{self.name}': {result.stderr}")
|
||||
|
||||
logger.info(f"Successfully cloned library '{self.name}'")
|
||||
return True
|
||||
|
||||
else:
|
||||
# Pull updates
|
||||
logger.info(f"Updating remote library '{self.name}'")
|
||||
|
||||
# First, fetch to see if there are updates
|
||||
result = subprocess.run(
|
||||
["git", "fetch", "origin", self.branch],
|
||||
cwd=self.path,
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
logger.warning(f"Failed to fetch updates for '{self.name}': {result.stderr}")
|
||||
return False
|
||||
|
||||
# Check if we're behind
|
||||
result = subprocess.run(
|
||||
["git", "rev-list", "--count", f"HEAD..origin/{self.branch}"],
|
||||
cwd=self.path,
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
|
||||
behind_count = int(result.stdout.strip()) if result.stdout.strip().isdigit() else 0
|
||||
|
||||
if behind_count > 0:
|
||||
# Pull the updates
|
||||
result = subprocess.run(
|
||||
["git", "pull", "origin", self.branch],
|
||||
cwd=self.path,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
raise ConnectionError(f"Git pull failed for '{self.name}': {result.stderr}")
|
||||
|
||||
logger.info(f"Successfully updated library '{self.name}' ({behind_count} new commits)")
|
||||
return True
|
||||
else:
|
||||
logger.debug(f"Library '{self.name}' is already up to date")
|
||||
return True
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
raise RuntimeError(
|
||||
f"Git command failed for '{self.name}': {e.stderr if hasattr(e, 'stderr') else str(e)}"
|
||||
)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Library update failed for '{self.name}': {str(e)}")
|
||||
|
||||
def get_info(self) -> dict:
|
||||
"""Get information about the remote library.
|
||||
|
||||
Returns:
|
||||
Dictionary with library information
|
||||
"""
|
||||
info = {
|
||||
'name': self.name,
|
||||
'type': 'remote',
|
||||
'repo': self.repo_url,
|
||||
'branch': self.branch,
|
||||
'priority': self.priority,
|
||||
'cached': self.path.exists(),
|
||||
'cache_path': str(self.path)
|
||||
}
|
||||
|
||||
if self.path.exists():
|
||||
try:
|
||||
# Get current commit hash
|
||||
result = subprocess.run(
|
||||
["git", "rev-parse", "HEAD"],
|
||||
cwd=self.path,
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
if result.returncode == 0:
|
||||
info['current_commit'] = result.stdout.strip()[:8]
|
||||
|
||||
# Get last update time
|
||||
result = subprocess.run(
|
||||
["git", "log", "-1", "--format=%ci"],
|
||||
cwd=self.path,
|
||||
capture_output=True,
|
||||
text=True
|
||||
)
|
||||
if result.returncode == 0:
|
||||
info['last_updated'] = result.stdout.strip()
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to get git info for '{self.name}': {e}")
|
||||
|
||||
return info
|
||||
|
||||
pass
|
||||
|
||||
class LibraryManager:
|
||||
"""Manager for multiple libraries with priority-based ordering."""
|
||||
"""Manages multiple libraries and provides methods to find templates."""
|
||||
|
||||
# FIXME: For now this is static and only has one library
|
||||
def __init__(self):
|
||||
self.libraries = []
|
||||
self._initialize_libraries()
|
||||
|
||||
def _initialize_libraries(self):
|
||||
"""Initialize libraries from configuration."""
|
||||
config = get_config()
|
||||
logger.info(f"Initializing library manager with {len(config.libraries)} configured libraries")
|
||||
|
||||
# First, add configured libraries
|
||||
for lib_config in config.libraries:
|
||||
try:
|
||||
library = self._create_library_from_config(lib_config)
|
||||
if library:
|
||||
self.libraries.append(library)
|
||||
logger.info(f"Loaded library '{lib_config.name}' (type: {lib_config.type}, priority: {lib_config.priority})")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to load library '{lib_config.name}': {e}")
|
||||
|
||||
# Then add the default built-in library if not already configured
|
||||
if not any(lib.name == "default" for lib in self.libraries):
|
||||
script_dir = Path(__file__).parent.parent.parent # Go up from cli/core/ to project root
|
||||
default_library = Library("default", script_dir / "library", priority=-1) # Lower priority
|
||||
self.libraries.append(default_library)
|
||||
logger.info(f"Added default built-in library at '{script_dir / 'library'}' (priority: -1)")
|
||||
|
||||
# Sort libraries by priority (highest first)
|
||||
self._sort_by_priority()
|
||||
logger.info(f"Successfully initialized {len(self.libraries)} libraries")
|
||||
if self.libraries:
|
||||
logger.debug(f"Libraries in priority order: {[(lib.name, lib.priority) for lib in self.libraries]}")
|
||||
|
||||
def _create_library_from_config(self, lib_config):
|
||||
"""Create a Library instance from configuration.
|
||||
|
||||
Args:
|
||||
lib_config: LibraryConfig instance
|
||||
|
||||
Returns:
|
||||
Library instance or None if creation fails
|
||||
"""
|
||||
if lib_config.type == "local":
|
||||
if lib_config.path:
|
||||
path = Path(lib_config.path).expanduser()
|
||||
if path.exists():
|
||||
return Library(lib_config.name, path, lib_config.priority)
|
||||
else:
|
||||
logger.warning(f"Local library path does not exist: {path}")
|
||||
return None
|
||||
elif lib_config.type == "git":
|
||||
if lib_config.repo:
|
||||
return RemoteLibrary(
|
||||
lib_config.name,
|
||||
lib_config.repo,
|
||||
lib_config.branch,
|
||||
lib_config.priority
|
||||
)
|
||||
else:
|
||||
logger.warning(f"Git library '{lib_config.name}' missing repo URL")
|
||||
return None
|
||||
else:
|
||||
logger.warning(f"Unknown library type: {lib_config.type}")
|
||||
return None
|
||||
|
||||
def _sort_by_priority(self):
|
||||
"""Sort libraries by priority (highest first)."""
|
||||
self.libraries.sort(key=lambda lib: lib.priority, reverse=True)
|
||||
|
||||
def add_library(self, library: Library):
|
||||
"""Add a library to the collection.
|
||||
|
||||
Args:
|
||||
library: Library instance to add
|
||||
"""
|
||||
# Check for duplicate names
|
||||
if any(lib.name == library.name for lib in self.libraries):
|
||||
logger.warning(f"Library '{library.name}' already exists, replacing")
|
||||
self.libraries = [lib for lib in self.libraries if lib.name != library.name]
|
||||
|
||||
self.libraries.append(library)
|
||||
self._sort_by_priority()
|
||||
self.libraries = [
|
||||
Library(name="default", path=Path(__file__).parent.parent / "libraries", priority=0)
|
||||
]
|
||||
|
||||
def find_by_id(self, module_name, files, template_id):
|
||||
"""Find a template by its ID across all libraries."""
|
||||
for library in self.libraries:
|
||||
template = library.find_by_id(module_name, files, template_id)
|
||||
if template:
|
||||
return template
|
||||
|
||||
def find(self, module_name, files, sorted=False):
|
||||
"""Find templates across all libraries for a specific module."""
|
||||
logger.debug(f"Searching across {len(self.libraries)} libraries for module '{module_name}'")
|
||||
all_templates = []
|
||||
library_counts = {}
|
||||
|
||||
for library in self.libraries:
|
||||
templates = library.find(module_name, files, sorted=sorted)
|
||||
if templates:
|
||||
library_counts[library.name] = len(templates)
|
||||
all_templates.extend(templates)
|
||||
|
||||
if sorted:
|
||||
all_templates.sort(key=lambda t: t.id)
|
||||
|
||||
if all_templates:
|
||||
logger.info(f"Found {len(all_templates)} total templates for module '{module_name}'")
|
||||
if library_counts:
|
||||
logger.debug(f"Template distribution: {library_counts}")
|
||||
else:
|
||||
logger.debug(f"No templates found for module '{module_name}' across any library")
|
||||
return all_templates
|
||||
|
||||
def find_by_id(self, module_name, files, template_id):
|
||||
"""
|
||||
Find a template by its ID across all libraries.
|
||||
|
||||
Args:
|
||||
module_name: The module name (e.g., 'terraform', 'compose') to search within.
|
||||
This narrows the search to the specific technology directory across all libraries,
|
||||
allowing for modular organization of templates by technology type.
|
||||
files: List of file patterns to search for (e.g., ['*.tf', '*.yaml']).
|
||||
This filters templates to only those with matching file extensions,
|
||||
ensuring we only process relevant template files for the specific module type.
|
||||
template_id: The unique identifier of the template to find.
|
||||
This is typically derived from the template's directory name or filename,
|
||||
providing a human-readable way to reference specific templates.
|
||||
|
||||
Returns:
|
||||
Template object if found across any library, None otherwise.
|
||||
|
||||
Note:
|
||||
This method searches through all registered libraries in priority order (highest first),
|
||||
returning the first matching template found. This allows higher-priority libraries
|
||||
to override templates from lower-priority ones.
|
||||
"""
|
||||
logger.debug(f"Searching for template '{template_id}' across {len(self.libraries)} libraries (module: {module_name})")
|
||||
for library in self.libraries: # Already sorted by priority
|
||||
template = library.find_by_id(module_name, files, template_id)
|
||||
if template:
|
||||
logger.info(f"Retrieved template '{template_id}' from library '{library.name}' (priority: {library.priority})")
|
||||
return template
|
||||
|
||||
logger.warning(f"Template '{template_id}' not found in any library")
|
||||
return None
|
||||
return templates
|
||||
return []
|
||||
|
||||
+4
-138
@@ -2,10 +2,9 @@ from abc import ABC
|
||||
from pathlib import Path
|
||||
from typing import Optional, Dict, Any
|
||||
import logging
|
||||
import yaml
|
||||
from typer import Typer, Option, Argument
|
||||
from rich.console import Console
|
||||
# Using standard Python exceptions
|
||||
|
||||
from .library import LibraryManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -34,21 +33,6 @@ class Module(ABC):
|
||||
if hasattr(self, '_init_variables'):
|
||||
logger.debug(f"Module '{self.name}' has variable initialization method")
|
||||
self._init_variables()
|
||||
|
||||
# Validate module variable registry consistency after initialization
|
||||
# NOTE: This ensures the module's variable hierarchy is properly structured (e.g., traefik.host requires traefik to exist).
|
||||
# The registry defines parent-child relationships where child variables like 'traefik.tls.certresolver' can only be used
|
||||
# when their parents ('traefik' and 'traefik.tls') are enabled. This prevents invalid module configurations.
|
||||
if hasattr(self, 'variables') and self.variables:
|
||||
var_count = len(self.variables.get_all_variables())
|
||||
logger.info(f"Module '{self.name}' registered {var_count} variables")
|
||||
|
||||
registry_errors = self.variables.validate_parent_child_relationships()
|
||||
if registry_errors:
|
||||
error_msg = f"Module '{self.name}' has invalid variable registry:\n" + "\n".join(f" - {e}" for e in registry_errors)
|
||||
logger.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
logger.debug(f"Module '{self.name}' variable registry validation completed successfully")
|
||||
|
||||
self.metadata = self._build_metadata()
|
||||
logger.info(f"Module '{self.name}' initialization completed successfully")
|
||||
@@ -118,117 +102,6 @@ class Module(ABC):
|
||||
print(f"\n{template.content}")
|
||||
|
||||
|
||||
def _enrich_template_with_variables(self, template):
|
||||
"""Enrich template with module variable registry defaults (optimized).
|
||||
|
||||
This method updates the template's vars with module defaults while preserving
|
||||
template-specific variables and frontmatter definitions.
|
||||
|
||||
Args:
|
||||
template: Template instance to enrich
|
||||
"""
|
||||
# Skip if already enriched or no variables
|
||||
if template._is_enriched or not hasattr(self, 'variables') or not self.variables:
|
||||
if template._is_enriched:
|
||||
logger.debug(f"Template '{template.id}' already enriched, skipping")
|
||||
else:
|
||||
logger.debug(f"Module '{self.name}' has no variables, skipping enrichment for '{template.id}'")
|
||||
return
|
||||
|
||||
logger.debug(f"Enriching template '{template.id}' with {len(self.variables.get_all_variables())} module variables")
|
||||
|
||||
# Get template variables first (this is cached)
|
||||
template_vars = template._parse_template_variables(
|
||||
template.content,
|
||||
getattr(template, 'frontmatter_variables', {})
|
||||
)
|
||||
|
||||
# Only get module variables that are actually used in the template
|
||||
used_variables = template._get_used_variables()
|
||||
module_vars = {}
|
||||
module_defaults = {}
|
||||
|
||||
for var_name in used_variables:
|
||||
var_obj = self.variables.get_variable(var_name)
|
||||
if var_obj:
|
||||
module_vars[var_name] = var_obj.default if var_obj.default is not None else None
|
||||
if var_obj.default is not None:
|
||||
module_defaults[var_name] = var_obj.default
|
||||
|
||||
if module_defaults:
|
||||
logger.debug(f"Module provides {len(module_defaults)} defaults for used variables")
|
||||
logger.debug(f"Module default values: {module_defaults}")
|
||||
|
||||
# Merge with template taking precedence
|
||||
final_vars = dict(module_vars)
|
||||
overrides = {}
|
||||
|
||||
for var_name, var_value in template_vars.items():
|
||||
if var_name in final_vars and final_vars[var_name] != var_value and var_value is not None:
|
||||
logger.warning(
|
||||
f"Variable '{var_name}' defined in both module and template. Template takes precedence."
|
||||
)
|
||||
overrides[var_name] = var_value
|
||||
final_vars[var_name] = var_value
|
||||
|
||||
if overrides:
|
||||
logger.debug(f"Template overrode {len(overrides)} module variables")
|
||||
|
||||
# Set final variables and mark as enriched
|
||||
template.vars = final_vars
|
||||
template._is_enriched = True
|
||||
|
||||
if final_vars:
|
||||
logger.info(f"Template '{template.id}' enriched with {len(final_vars)} variables from module '{self.name}'")
|
||||
else:
|
||||
logger.debug(f"Template '{template.id}' has no variables after enrichment")
|
||||
|
||||
def _check_template_readiness(self, template):
|
||||
"""Check if template is ready for generation (replaces complex validation).
|
||||
|
||||
Args:
|
||||
template: Template instance to check
|
||||
|
||||
Raises:
|
||||
ValueError: If template has critical issues preventing generation
|
||||
"""
|
||||
logger.debug(f"Checking template readiness for '{template.id}'")
|
||||
errors = []
|
||||
|
||||
# Check for basic template issues
|
||||
if not template.content.strip():
|
||||
errors.append("Template has no content")
|
||||
|
||||
# Check for undefined variables (variables used but not available)
|
||||
undefined_vars = []
|
||||
for var_name, var_value in template.vars.items():
|
||||
if var_value is None:
|
||||
# Check if it's in module registry
|
||||
if hasattr(self, 'variables') and self.variables:
|
||||
var_obj = self.variables.get_variable(var_name)
|
||||
if not var_obj:
|
||||
# Not in module registry and no template default - problematic
|
||||
undefined_vars.append(var_name)
|
||||
|
||||
if undefined_vars:
|
||||
errors.append(
|
||||
f"Template uses undefined variables: {', '.join(undefined_vars)}. "
|
||||
f"These variables are not registered in the module and have no template defaults."
|
||||
)
|
||||
|
||||
# Check for syntax errors by attempting to create AST
|
||||
try:
|
||||
template._get_ast()
|
||||
except Exception as e:
|
||||
errors.append(f"Template has Jinja2 syntax errors: {str(e)}")
|
||||
|
||||
if errors:
|
||||
logger.error(f"Template '{template.id}' failed readiness check with {len(errors)} errors")
|
||||
error_msg = f"Template '{template.id}' is not ready for generation:\n" + "\n".join(f" - {e}" for e in errors)
|
||||
raise ValueError(error_msg)
|
||||
|
||||
logger.debug(f"Template '{template.id}' passed readiness check")
|
||||
|
||||
def generate(
|
||||
self,
|
||||
id: str = Argument(..., help="Template ID"),
|
||||
@@ -243,16 +116,9 @@ class Module(ABC):
|
||||
if not template:
|
||||
logger.error(f"Template '{id}' not found for generation in module '{self.name}'")
|
||||
raise FileNotFoundError(f"Template '{id}' not found in module '{self.name}'")
|
||||
|
||||
# Enrich template with module variables if available
|
||||
self._enrich_template_with_variables(template)
|
||||
|
||||
# Check for critical template issues after enrichment
|
||||
self._check_template_readiness(template)
|
||||
|
||||
logger.info(f"Template '{id}' generation completed successfully for module '{self.name}'")
|
||||
print("TEST SUCCESSFUL")
|
||||
|
||||
|
||||
# PLACEHOLDER FOR TEMPLATE GENERATION LOGIC
|
||||
|
||||
def register_cli(self, app: Typer):
|
||||
"""Register module commands with the main app."""
|
||||
logger.debug(f"Registering CLI commands for module '{self.name}'")
|
||||
|
||||
+4
-135
@@ -17,8 +17,8 @@ class Template:
|
||||
file_path: Path
|
||||
content: str = ""
|
||||
|
||||
|
||||
# Frontmatter fields with defaults
|
||||
id: str = ""
|
||||
name: str = ""
|
||||
description: str = "No description available"
|
||||
author: str = ""
|
||||
@@ -28,34 +28,10 @@ class Template:
|
||||
tags: List[str] = field(default_factory=list)
|
||||
files: List[str] = field(default_factory=list)
|
||||
|
||||
# Computed properties (will be set in __post_init__)
|
||||
id: str = field(init=False)
|
||||
relative_path: str = field(init=False)
|
||||
size: int = field(init=False)
|
||||
|
||||
# Template variable analysis results
|
||||
vars: Dict[str, Any] = field(default_factory=dict, init=False)
|
||||
frontmatter_variables: Dict[str, Any] = field(default_factory=dict, init=False)
|
||||
|
||||
# Cache for performance optimization
|
||||
_jinja_ast: Any = field(default=None, init=False, repr=False)
|
||||
_parsed_vars: Dict[str, Any] = field(default=None, init=False, repr=False)
|
||||
_is_enriched: bool = field(default=False, init=False, repr=False)
|
||||
|
||||
def __post_init__(self):
|
||||
"""Initialize computed properties after dataclass initialization."""
|
||||
# Set default name if not provided
|
||||
if not self.name:
|
||||
self.name = self.file_path.parent.name
|
||||
|
||||
# Computed properties
|
||||
self.id = self.file_path.parent.name
|
||||
self.relative_path = self.file_path.name
|
||||
self.size = self.file_path.stat().st_size if self.file_path.exists() else 0
|
||||
|
||||
# Initialize with empty vars - modules will enrich with their variables
|
||||
# Template parsing and variable enrichment is handled by the module
|
||||
self.vars = {}
|
||||
|
||||
|
||||
|
||||
@classmethod
|
||||
def from_file(cls, file_path: Path) -> "Template":
|
||||
@@ -85,7 +61,7 @@ class Template:
|
||||
if template.frontmatter_variables:
|
||||
logger.debug(f"Template '{template.id}' has {len(template.frontmatter_variables)} frontmatter variables: {list(template.frontmatter_variables.keys())}")
|
||||
|
||||
logger.info(f"Loaded template '{template.id}' (v{template.version or 'unversioned'}, {template.size} bytes)")
|
||||
logger.info(f"Loaded template '{template.id}' (v{template.version or 'unversioned'}")
|
||||
logger.debug(f"Template details: author='{template.author}', tags={template.tags}")
|
||||
return template
|
||||
except Exception as e:
|
||||
@@ -152,110 +128,3 @@ class Template:
|
||||
with open(file_path, 'r', encoding='utf-8') as f:
|
||||
post = frontmatter.load(f)
|
||||
return post.metadata, post.content
|
||||
|
||||
def render(self, variable_values: Dict[str, Any]) -> str:
|
||||
"""Render the template with the provided variable values."""
|
||||
logger = logging.getLogger('boilerplates')
|
||||
|
||||
try:
|
||||
logger.debug(f"Rendering template '{self.id}' with {len(variable_values)} provided variables")
|
||||
env = self._create_jinja_env()
|
||||
jinja_template = env.from_string(self.content)
|
||||
# Merge template vars (with defaults) with provided values
|
||||
# All variables should be defined at this point due to validation
|
||||
merged_variable_values = {**self.vars, **variable_values}
|
||||
logger.debug(f"Final render context has {len(merged_variable_values)} variables")
|
||||
|
||||
rendered_content = jinja_template.render(**merged_variable_values)
|
||||
initial_size = len(rendered_content)
|
||||
|
||||
# Clean up excessive blank lines and whitespace
|
||||
rendered_content = re.sub(r'\n\s*\n\s*\n+', '\n\n', rendered_content)
|
||||
final_content = rendered_content.strip()
|
||||
|
||||
logger.info(f"Successfully rendered template '{self.id}' ({initial_size} -> {len(final_content)} bytes)")
|
||||
return final_content
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to render template '{self.id}': {e}")
|
||||
raise ValueError(f"Failed to render template: {e}")
|
||||
|
||||
def _parse_template_variables(self, template_content: str, frontmatter_vars: Dict[str, Any] = None) -> Dict[str, Any]:
|
||||
"""Parse Jinja2 template to extract variables and their defaults (cached).
|
||||
|
||||
Handles:
|
||||
- Simple variables: service_name
|
||||
- Dotted notation: traefik.host, service_port.http
|
||||
- Frontmatter variable definitions
|
||||
|
||||
Args:
|
||||
template_content: The Jinja2 template content (ignored if cached)
|
||||
frontmatter_vars: Variables defined in template frontmatter
|
||||
|
||||
Returns:
|
||||
Dict mapping variable names to their default values (None if no default)
|
||||
"""
|
||||
# Use cache if available and no frontmatter changes
|
||||
cache_key = f"{hash(frontmatter_vars.__str__() if frontmatter_vars else 'None')}"
|
||||
if self._parsed_vars is not None and not frontmatter_vars:
|
||||
return self._parsed_vars
|
||||
|
||||
try:
|
||||
ast = self._get_ast() # Use cached AST
|
||||
|
||||
# Get all variables used in template
|
||||
all_variables = self._get_used_variables()
|
||||
if all_variables:
|
||||
logger.debug(f"Template uses {len(all_variables)} variables: {sorted(all_variables)}")
|
||||
else:
|
||||
logger.debug("Template does not use any variables")
|
||||
|
||||
# Initialize vars dict with all variables (default to None)
|
||||
vars_dict = {var_name: None for var_name in all_variables}
|
||||
|
||||
# Extract default values from | default() filters
|
||||
template_defaults = {}
|
||||
for node in ast.find_all(nodes.Filter):
|
||||
if node.name == 'default' and node.args and isinstance(node.args[0], nodes.Const):
|
||||
# Handle simple variable defaults
|
||||
if isinstance(node.node, nodes.Name):
|
||||
template_defaults[node.node.name] = node.args[0].value
|
||||
vars_dict[node.node.name] = node.args[0].value
|
||||
# Handle dotted variable defaults
|
||||
elif isinstance(node.node, nodes.Getattr):
|
||||
dotted_name = Template._build_dotted_name(node.node)
|
||||
if dotted_name:
|
||||
template_defaults[dotted_name] = node.args[0].value
|
||||
vars_dict[dotted_name] = node.args[0].value
|
||||
|
||||
if template_defaults:
|
||||
logger.info(f"Template defines {len(template_defaults)} variable defaults")
|
||||
logger.debug(f"Template default values: {template_defaults}")
|
||||
|
||||
# Process frontmatter variables (frontmatter takes precedence)
|
||||
if frontmatter_vars:
|
||||
frontmatter_overrides = {}
|
||||
for var_name, var_config in frontmatter_vars.items():
|
||||
if var_name in vars_dict and vars_dict[var_name] is not None:
|
||||
logger.warning(f"Variable '{var_name}' defined in both template content and frontmatter. Frontmatter definition takes precedence.")
|
||||
|
||||
# Handle both simple values and complex variable configurations
|
||||
if isinstance(var_config, dict) and 'default' in var_config:
|
||||
frontmatter_overrides[var_name] = var_config['default']
|
||||
vars_dict[var_name] = var_config['default']
|
||||
else:
|
||||
frontmatter_overrides[var_name] = var_config
|
||||
vars_dict[var_name] = var_config
|
||||
|
||||
if frontmatter_overrides:
|
||||
logger.info(f"Frontmatter defines/overrides {len(frontmatter_overrides)} variables")
|
||||
logger.debug(f"Frontmatter variable values: {frontmatter_overrides}")
|
||||
|
||||
# Cache result if no frontmatter (pure template parsing)
|
||||
if not frontmatter_vars:
|
||||
self._parsed_vars = vars_dict.copy()
|
||||
|
||||
return vars_dict
|
||||
except Exception as e:
|
||||
logger.debug(f"Error parsing template variables: {e}")
|
||||
return {}
|
||||
|
||||
@@ -1,151 +0,0 @@
|
||||
from typing import Any, Dict, List, Optional
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class VariableType(Enum):
|
||||
"""Supported variable types."""
|
||||
STR = "str"
|
||||
INT = "int"
|
||||
BOOL = "bool"
|
||||
ENUM = "enum"
|
||||
FLOAT = "float"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Variable:
|
||||
"""Represents a single variable with metadata."""
|
||||
|
||||
name: str
|
||||
type: VariableType = VariableType.STR
|
||||
description: str = ""
|
||||
display: str = "" # Display name for UI
|
||||
default: Any = None
|
||||
options: List[str] = field(default_factory=list) # For enum types
|
||||
parent: Optional[str] = None # Parent variable name (for dotted notation)
|
||||
|
||||
def has_parent(self) -> bool:
|
||||
"""Check if this variable has a parent."""
|
||||
return self.parent is not None
|
||||
|
||||
def get_full_name(self) -> str:
|
||||
"""Get the full dotted name."""
|
||||
if self.parent:
|
||||
return f"{self.parent}.{self.name}"
|
||||
return self.name
|
||||
|
||||
|
||||
class VariableRegistry:
|
||||
"""Registry for managing module variables."""
|
||||
|
||||
def __init__(self):
|
||||
self._variables: Dict[str, Variable] = {} # Full name -> Variable
|
||||
|
||||
def register_variable(self, variable: Variable) -> Variable:
|
||||
"""Register a variable in the registry."""
|
||||
full_name = variable.get_full_name()
|
||||
logger.debug(f"Attempting to register variable '{full_name}' of type '{variable.type}'")
|
||||
|
||||
# Convert string type to enum if needed
|
||||
if isinstance(variable.type, str):
|
||||
try:
|
||||
original_type = variable.type
|
||||
variable.type = VariableType(variable.type.lower())
|
||||
logger.debug(f"Converted string type '{original_type}' to VariableType.{variable.type.name} for '{full_name}'")
|
||||
except ValueError:
|
||||
logger.warning(f"Unknown variable type '{variable.type}' for '{full_name}', defaulting to STR")
|
||||
variable.type = VariableType.STR
|
||||
|
||||
# Validate enum options
|
||||
if variable.type == VariableType.ENUM and not variable.options:
|
||||
logger.error(f"Variable '{full_name}' of type 'enum' must have options")
|
||||
raise ValueError(f"Variable '{full_name}' of type 'enum' must have options")
|
||||
|
||||
if variable.type == VariableType.ENUM:
|
||||
logger.debug(f"Variable '{full_name}' has {len(variable.options)} enum options: {variable.options}")
|
||||
|
||||
if variable.default is not None:
|
||||
logger.debug(f"Variable '{full_name}' has default value: {variable.default}")
|
||||
|
||||
# Check if already registered
|
||||
if full_name in self._variables:
|
||||
logger.debug(f"Variable '{full_name}' already registered, replacing")
|
||||
|
||||
self._variables[full_name] = variable
|
||||
logger.info(f"Registered variable '{full_name}' (type: {variable.type.name}, parent: {variable.parent or 'none'})")
|
||||
return variable
|
||||
|
||||
def get_variable(self, name: str) -> Optional[Variable]:
|
||||
"""Get variable by full name."""
|
||||
variable = self._variables.get(name)
|
||||
if variable:
|
||||
logger.debug(f"Retrieved variable '{name}' from registry (type: {variable.type.name})")
|
||||
else:
|
||||
logger.debug(f"Variable '{name}' not found in registry (available: {list(self._variables.keys())})")
|
||||
return variable
|
||||
|
||||
def get_all_variables(self) -> Dict[str, Variable]:
|
||||
"""Get all registered variables."""
|
||||
count = len(self._variables)
|
||||
if count > 0:
|
||||
logger.debug(f"Retrieved {count} variables from registry: {sorted(self._variables.keys())}")
|
||||
else:
|
||||
logger.debug("No variables registered in registry")
|
||||
return self._variables.copy()
|
||||
|
||||
def get_parent_variables(self) -> List[Variable]:
|
||||
"""Get all variables that have children (enabler variables)."""
|
||||
parent_names = set()
|
||||
for var in self._variables.values():
|
||||
if var.parent:
|
||||
parent_names.add(var.parent)
|
||||
|
||||
parent_vars = [self._variables[name] for name in parent_names if name in self._variables]
|
||||
logger.debug(f"Found {len(parent_vars)} parent variables: {sorted(parent_names)}")
|
||||
return parent_vars
|
||||
|
||||
def get_children_of(self, parent_name: str) -> List[Variable]:
|
||||
"""Get all child variables of a specific parent."""
|
||||
children = [var for var in self._variables.values() if var.parent == parent_name]
|
||||
logger.debug(f"Found {len(children)} children for parent '{parent_name}'")
|
||||
return children
|
||||
|
||||
def validate_parent_child_relationships(self) -> List[str]:
|
||||
"""Validate that all parent-child relationships are consistent."""
|
||||
logger.debug(f"Starting validation of parent-child relationships for {len(self._variables)} variables")
|
||||
errors = []
|
||||
parent_count = 0
|
||||
child_count = 0
|
||||
|
||||
for var in self._variables.values():
|
||||
if var.parent:
|
||||
child_count += 1
|
||||
# Check if parent exists
|
||||
if var.parent not in self._variables:
|
||||
error_msg = f"Variable '{var.get_full_name()}' references non-existent parent '{var.parent}'"
|
||||
logger.warning(f"Validation error: {error_msg}")
|
||||
errors.append(error_msg)
|
||||
else:
|
||||
parent_var = self._variables[var.parent]
|
||||
# Parent should generally be boolean if it has children
|
||||
if parent_var.type != VariableType.BOOL:
|
||||
warning_msg = f"Parent variable '{var.parent}' is type '{parent_var.type.name}' but has children"
|
||||
logger.warning(f"Validation warning: {warning_msg}")
|
||||
errors.append(f"Parent variable '{var.parent}' should be boolean type (has children)")
|
||||
else:
|
||||
# Count root/parent variables
|
||||
children = self.get_children_of(var.name)
|
||||
if children:
|
||||
parent_count += 1
|
||||
|
||||
if errors:
|
||||
logger.error(f"Variable registry validation failed with {len(errors)} errors")
|
||||
for error in errors:
|
||||
logger.debug(f" - {error}")
|
||||
else:
|
||||
logger.info(f"Variable registry validation passed ({parent_count} parents, {child_count} children)")
|
||||
|
||||
return errors
|
||||
@@ -1,37 +0,0 @@
|
||||
"""
|
||||
Modules package for the Boilerplates CLI.
|
||||
|
||||
To add a new module:
|
||||
1. Create a new Python file: cli/modules/[module_name].py
|
||||
2. Create a class inheriting from Module with the import: from ..core.module import Module
|
||||
3. Ensure the class properly sets 'files' parameter and implements required methods
|
||||
4. Import and register the module in cli/__main__.py
|
||||
|
||||
Available modules:
|
||||
- compose: Manage Docker Compose configurations and services
|
||||
- ansible: Manage Ansible playbooks and configurations
|
||||
- docker: Manage Docker configurations and files
|
||||
- github_actions: Manage GitHub Actions workflows
|
||||
- gitlab_ci: Manage GitLab CI/CD pipelines
|
||||
- kestra: Manage Kestra workflows and configurations
|
||||
- kubernetes: Manage Kubernetes manifests and configurations
|
||||
- packer: Manage Packer templates and configurations
|
||||
- terraform: Manage Terraform configurations and modules
|
||||
- vagrant: Manage Vagrant configurations and files
|
||||
|
||||
Example:
|
||||
# In cli/modules/mymodule.py
|
||||
from ..core.module import Module
|
||||
|
||||
class MyModule(Module):
|
||||
def __init__(self):
|
||||
super().__init__(
|
||||
name="mymodule",
|
||||
description="My module description",
|
||||
files=["config.yml", "settings.json"],
|
||||
vars={"key": "value"} # optional
|
||||
)
|
||||
|
||||
def register(self, app):
|
||||
return super().register(app)
|
||||
"""
|
||||
@@ -9,5 +9,4 @@ class AnsibleModule(Module):
|
||||
files = ["playbook.yml", "playbook.yaml", "main.yml", "main.yaml",
|
||||
"site.yml", "site.yaml"]
|
||||
|
||||
# Register the module
|
||||
registry.register(AnsibleModule)
|
||||
|
||||
+36
-168
@@ -1,179 +1,47 @@
|
||||
from ..core.module import Module
|
||||
from ..core.registry import registry
|
||||
from ..core.variables import Variable, VariableRegistry, VariableType
|
||||
|
||||
|
||||
class ComposeModule(Module):
|
||||
"""Docker Compose module."""
|
||||
|
||||
name = "compose"
|
||||
description = "Manage Docker Compose configurations"
|
||||
files = ["docker-compose.yml", "compose.yml", "compose.yaml"]
|
||||
|
||||
def _init_variables(self):
|
||||
"""Initialize module-specific variables."""
|
||||
self.variables = VariableRegistry()
|
||||
|
||||
# Register root variables
|
||||
self.variables.register_variable(Variable(
|
||||
name="service_name",
|
||||
type=VariableType.STR,
|
||||
description="Service name",
|
||||
display="Service Name"
|
||||
))
|
||||
|
||||
self.variables.register_variable(Variable(
|
||||
name="container_name",
|
||||
type=VariableType.STR,
|
||||
description="Custom container name (leave empty to use service name)",
|
||||
display="Container Name"
|
||||
))
|
||||
|
||||
self.variables.register_variable(Variable(
|
||||
name="container_timezone",
|
||||
type=VariableType.STR,
|
||||
description="Container timezone (e.g., Europe/Berlin, America/New_York)",
|
||||
display="Container Timezone"
|
||||
))
|
||||
|
||||
self.variables.register_variable(Variable(
|
||||
name="container_loglevel",
|
||||
type=VariableType.ENUM,
|
||||
description="Container log level",
|
||||
display="Log Level",
|
||||
default="info",
|
||||
options=["debug", "info", "warn", "error"]
|
||||
))
|
||||
|
||||
self.variables.register_variable(Variable(
|
||||
name="container_hostname",
|
||||
type=VariableType.STR,
|
||||
description="Container hostname (shows up in logs and networking)",
|
||||
display="Container Hostname"
|
||||
))
|
||||
|
||||
self.variables.register_variable(Variable(
|
||||
name="restart_policy",
|
||||
type=VariableType.ENUM,
|
||||
description="Container restart policy",
|
||||
display="Restart Policy",
|
||||
default="unless-stopped",
|
||||
options=["unless-stopped", "always", "on-failure", "no"]
|
||||
))
|
||||
|
||||
self.variables.register_variable(Variable(
|
||||
name="ports",
|
||||
type=VariableType.BOOL,
|
||||
description="Enable port mapping",
|
||||
display="Enable Ports"
|
||||
))
|
||||
|
||||
# Network variables
|
||||
self.variables.register_variable(Variable(
|
||||
name="network",
|
||||
type=VariableType.BOOL,
|
||||
description="Enable custom network configuration",
|
||||
display="Enable Network"
|
||||
))
|
||||
|
||||
self.variables.register_variable(Variable(
|
||||
name="name",
|
||||
type=VariableType.STR,
|
||||
description="Docker network name (e.g., frontend, backend, bridge)",
|
||||
display="Network Name",
|
||||
default="bridge",
|
||||
parent="network"
|
||||
))
|
||||
|
||||
self.variables.register_variable(Variable(
|
||||
name="external",
|
||||
type=VariableType.BOOL,
|
||||
description="Use existing network (must be created before running)",
|
||||
display="External Network",
|
||||
parent="network"
|
||||
))
|
||||
|
||||
# Traefik variables
|
||||
self.variables.register_variable(Variable(
|
||||
name="traefik",
|
||||
type=VariableType.BOOL,
|
||||
description="Enable Traefik reverse proxy (requires Traefik to be running separately)",
|
||||
display="Enable Traefik"
|
||||
))
|
||||
|
||||
self.variables.register_variable(Variable(
|
||||
name="host",
|
||||
type=VariableType.STR,
|
||||
description="Domain name for your service (e.g., app.example.com)",
|
||||
display="Host Domain",
|
||||
parent="traefik"
|
||||
))
|
||||
|
||||
self.variables.register_variable(Variable(
|
||||
name="entrypoint",
|
||||
type=VariableType.STR,
|
||||
description="HTTP entrypoint for non-TLS traffic (e.g., web, http)",
|
||||
display="HTTP Entrypoint",
|
||||
default="web",
|
||||
parent="traefik"
|
||||
))
|
||||
|
||||
self.variables.register_variable(Variable(
|
||||
name="tls",
|
||||
type=VariableType.BOOL,
|
||||
description="Enable HTTPS/TLS (requires valid domain and DNS configuration)",
|
||||
display="Enable TLS",
|
||||
parent="traefik"
|
||||
))
|
||||
|
||||
self.variables.register_variable(Variable(
|
||||
name="entrypoint",
|
||||
type=VariableType.STR,
|
||||
description="TLS entrypoint for HTTPS traffic (e.g., websecure, https)",
|
||||
display="TLS Entrypoint",
|
||||
default="websecure",
|
||||
parent="traefik.tls"
|
||||
))
|
||||
|
||||
self.variables.register_variable(Variable(
|
||||
name="certresolver",
|
||||
type=VariableType.STR,
|
||||
description="Certificate resolver name (e.g., letsencrypt, staging)",
|
||||
display="Cert Resolver",
|
||||
parent="traefik.tls"
|
||||
))
|
||||
|
||||
# PostgreSQL variables
|
||||
self.variables.register_variable(Variable(
|
||||
name="postgres",
|
||||
type=VariableType.BOOL,
|
||||
description="Enable PostgreSQL database",
|
||||
display="Enable PostgreSQL"
|
||||
))
|
||||
|
||||
self.variables.register_variable(Variable(
|
||||
name="host",
|
||||
type=VariableType.STR,
|
||||
description="PostgreSQL host (e.g., localhost, postgres, db.example.com)",
|
||||
display="PostgreSQL Host",
|
||||
parent="postgres"
|
||||
))
|
||||
|
||||
# Docker Swarm variables
|
||||
self.variables.register_variable(Variable(
|
||||
name="swarm",
|
||||
type=VariableType.BOOL,
|
||||
description="Enable Docker Swarm mode (requires Docker Swarm to be initialized)",
|
||||
display="Enable Swarm"
|
||||
))
|
||||
|
||||
self.variables.register_variable(Variable(
|
||||
name="replicas",
|
||||
type=VariableType.INT,
|
||||
description="Number of container instances",
|
||||
display="Replicas",
|
||||
default=1,
|
||||
parent="swarm"
|
||||
))
|
||||
files = ["docker-compose.yml", "docker-compose.yaml", "compose.yml", "compose.yaml"]
|
||||
|
||||
variables_spec = {
|
||||
# Root
|
||||
"service_name": {"type": "str", "display": "Service Name", "description": "Service name"},
|
||||
"container_name": {"type": "str", "display": "Container Name", "description": "Custom container name (leave empty to use service name)"},
|
||||
"container_timezone": {"type": "str", "display": "Container Timezone", "description": "Container timezone (e.g., Europe/Berlin, America/New_York)"},
|
||||
"container_loglevel": {"type": "enum", "display": "Log Level", "description": "Container log level", "default": "info", "options": ["debug", "info", "warn", "error"]},
|
||||
"container_hostname": {"type": "str", "display": "Container Hostname", "description": "Container hostname (shows up in logs and networking)"},
|
||||
"restart_policy": {"type": "enum", "display": "Restart Policy", "description": "Container restart policy", "default": "unless-stopped", "options": ["unless-stopped", "always", "on-failure", "no"]},
|
||||
|
||||
# Ports
|
||||
"ports": {"type": "bool", "display": "Enable Ports", "description": "Enable port mapping"},
|
||||
|
||||
# Network
|
||||
"network": {"type": "bool", "display": "Enable Network", "description": "Enable custom network configuration"},
|
||||
"network.name": {"type": "str", "display": "Network Name", "description": "Docker network name (e.g., frontend, backend, bridge)", "default": "bridge"},
|
||||
"network.external": {"type": "bool", "display": "External Network", "description": "Use existing network (must be created before running)"},
|
||||
|
||||
# Traefik
|
||||
"traefik": {"type": "bool", "display": "Enable Traefik", "description": "Enable Traefik reverse proxy (requires Traefik to be running separately)"},
|
||||
"traefik.host": {"type": "hostname", "display": "Host Domain", "description": "Domain name for your service (e.g., app.example.com)"},
|
||||
"traefik.entrypoint": {"type": "str", "display": "HTTP Entrypoint", "description": "HTTP entrypoint for non-TLS traffic (e.g., web, http)", "default": "web"},
|
||||
"traefik.tls": {"type": "bool", "display": "Enable TLS", "description": "Enable HTTPS/TLS (requires valid domain and DNS configuration)"},
|
||||
"traefik.tls.entrypoint": {"type": "str", "display": "TLS Entrypoint", "description": "TLS entrypoint for HTTPS traffic (e.g., websecure, https)", "default": "websecure"},
|
||||
"traefik.tls.certresolver": {"type": "str", "display": "Cert Resolver", "description": "Certificate resolver name (e.g., letsencrypt, staging)"},
|
||||
|
||||
# PostgreSQL
|
||||
"postgres": {"type": "bool", "display": "Enable PostgreSQL", "description": "Enable PostgreSQL database"},
|
||||
"postgres.host": {"type": "str", "display": "PostgreSQL Host", "description": "PostgreSQL host (e.g., localhost, postgres, db.example.com)"},
|
||||
|
||||
# Swarm
|
||||
"swarm": {"type": "bool", "display": "Enable Swarm", "description": "Enable Docker Swarm mode (requires Docker Swarm to be initialized)"},
|
||||
"swarm.replicas": {"type": "int", "display": "Replicas", "description": "Number of container instances", "default": 1},
|
||||
}
|
||||
|
||||
# Register the module
|
||||
registry.register(ComposeModule)
|
||||
|
||||
@@ -10,11 +10,9 @@ tags:
|
||||
- workflows
|
||||
- compose
|
||||
variables:
|
||||
template.custom_config:
|
||||
description: "Custom configuration for n8n"
|
||||
hint: "Additional environment variables or settings"
|
||||
type: "string"
|
||||
default: ""
|
||||
ports.http:
|
||||
description: "Port for HTTP access to n8n"
|
||||
default: "5678"
|
||||
---
|
||||
services:
|
||||
{{ service_name }}:
|
||||
@@ -63,7 +61,7 @@ services:
|
||||
restart: {{ restart_policy | default('unless-stopped') }}
|
||||
{% if ports %}
|
||||
ports:
|
||||
- "5678:5678"
|
||||
- "{{ ports.http | default('5678') }}:5678"
|
||||
{% endif %}
|
||||
|
||||
volumes:
|
||||
|
||||
@@ -8,6 +8,12 @@ tags:
|
||||
- traefik
|
||||
- reverse-proxy
|
||||
- load-balancer
|
||||
files:
|
||||
- config/traefik.yaml
|
||||
variables:
|
||||
acme_email:
|
||||
display: "ACME Email"
|
||||
description: "Email address for ACME (Let's Encrypt) registration"
|
||||
---
|
||||
services:
|
||||
traefik:
|
||||
|
||||
@@ -37,7 +37,7 @@ entryPoints:
|
||||
certificatesResolvers:
|
||||
cloudflare:
|
||||
acme:
|
||||
email: your-email@example.com # <-- Change this to your email
|
||||
email: {{ acme_email }}
|
||||
storage: /var/traefik/certs/cloudflare-acme.json
|
||||
caServer: "https://acme-v02.api.letsencrypt.org/directory"
|
||||
dnsChallenge:
|
||||
|
||||
Reference in New Issue
Block a user