Просмотр исходного кода

chore: merge main into release/v0.1.0 (includes v0.0.7 and workflow fix)

xcad 4 месяцев назад
Родитель
Сommit
d27c01a28f

+ 10 - 2
.github/workflows/release-create-cli-release.yaml

@@ -96,8 +96,16 @@ jobs:
       - name: Extract changelog for this version
         id: changelog
         run: |
-          # Extract the [Unreleased] section from CHANGELOG.md
-          CHANGELOG=$(awk '/^## \[Unreleased\]/{flag=1; next} /^## \[/{flag=0} flag' CHANGELOG.md)
+          # Extract the changelog for this version from CHANGELOG.md
+          VERSION="${{ steps.version.outputs.version }}"
+          
+          # First try to extract the section for this specific version
+          CHANGELOG=$(awk -v ver="$VERSION" '/^## \[/{if($0 ~ "\\[" ver "\\]"){flag=1; next} else if(flag){exit}} flag' CHANGELOG.md)
+          
+          # If empty, fall back to [Unreleased] section
+          if [ -z "$CHANGELOG" ]; then
+            CHANGELOG=$(awk '/^## \[Unreleased\]/{flag=1; next} /^## \[/{flag=0} flag' CHANGELOG.md)
+          fi
 
           if [ -z "$CHANGELOG" ]; then
             echo "No changelog entries found for this release"

+ 2 - 0
.gitignore

@@ -27,3 +27,5 @@
 # Test outputs
 tests/
 config.yaml
+
+*~

+ 41 - 6
AGENTS.md

@@ -67,10 +67,15 @@ The project is stored in a public GitHub Repository, use issues, and branches fo
 
 ### Modules
 
+**Module Structure:**
+Modules can be either single files or packages:
+- **Single file**: `cli/modules/modulename.py` (for simple modules)
+- **Package**: `cli/modules/modulename/` with `__init__.py` (for multi-schema modules)
+
 **Creating Modules:**
 - Subclass `Module` from `cli/core/module.py`
-- Define `name` and `description` class attributes
-- Optional: Define module-wide `spec` for default variables (common across all templates)
+- Define `name`, `description`, and `schema_version` class attributes
+- For multi-schema modules: organize specs in separate files (e.g., `spec_v1_0.py`, `spec_v1_1.py`)
 - Call `registry.register(YourModule)` at module bottom
 - Auto-discovered and registered at CLI startup
 
@@ -83,8 +88,19 @@ spec = VariableCollection.from_dict({
 })
 ```
 
+**Multi-Schema Modules:**
+For modules supporting multiple schema versions, use package structure:
+```
+cli/modules/compose/
+  __init__.py          # Module class, loads appropriate spec
+  spec_v1_0.py         # Schema 1.0 specification
+  spec_v1_1.py         # Schema 1.1 specification
+```
+
 **Existing Modules:**
-- `cli/modules/compose.py` - Docker Compose (defines extensive module spec with traefik, database, email, authentik sections)
+- `cli/modules/compose/` - Docker Compose package with schema 1.0 and 1.1 support
+  - `spec_v1_0.py` - Basic compose spec
+  - `spec_v1_1.py` - Extended with network_mode, swarm support
 
 **(Work in Progress):** terraform, docker, ansible, kubernetes, packer modules
 
@@ -185,7 +201,8 @@ spec:
 ```
 
 **How It Works:**
-- **Module Schema Version**: Each module (in `cli/modules/*.py`) defines `schema_version` (e.g., "1.0")
+- **Module Schema Version**: Each module defines `schema_version` (e.g., "1.1")
+- **Module Spec Loading**: Modules load appropriate spec based on template's schema version
 - **Template Schema Version**: Each template declares `schema` at the top level (defaults to "1.0")
 - **Compatibility Check**: Template schema ≤ Module schema → Compatible
 - **Incompatibility**: Template schema > Module schema → `IncompatibleSchemaVersionError`
@@ -201,12 +218,30 @@ spec:
 - Set template schema when using features from a specific schema
 - Example: Template using new variable type added in schema 1.1 should set `schema: "1.1"`
 
-**Module Example:**
+**Single-File Module Example:**
 ```python
+class SimpleModule(Module):
+  name = "simple"
+  description = "Simple module"
+  schema_version = "1.0"
+  spec = VariableCollection.from_dict({...})  # Single spec
+```
+
+**Multi-Schema Module Example:**
+```python
+# cli/modules/compose/__init__.py
 class ComposeModule(Module):
   name = "compose"
   description = "Manage Docker Compose configurations"
-  schema_version = "1.0"  # Current schema version supported
+  schema_version = "1.1"  # Highest schema version supported
+  
+  def get_spec(self, template_schema: str) -> VariableCollection:
+    """Load spec based on template schema version."""
+    if template_schema == "1.0":
+      from .spec_v1_0 import get_spec
+    elif template_schema == "1.1":
+      from .spec_v1_1 import get_spec
+    return get_spec()
 ```
 
 **Version Management:**

+ 37 - 50
CHANGELOG.md

@@ -7,56 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 
 ## [Unreleased]
 
-### Added
-- Multi-Schema Module Support
-  - Modules can now maintain multiple schema versions simultaneously
-  - Schema specs organized in separate files (e.g., `spec_v1_0.py`, `spec_v1_1.py`)
-  - CLI automatically uses appropriate schema based on template declaration
-  - Module discovery now supports both file and package modules
-- Compose Schema 1.1 Network Enhancements
-  - Added `network_mode` with options: bridge, host, macvlan
-  - Macvlan support with conditional fields (IP address, interface, subnet, gateway)
-  - Host mode support for direct host network access
-  - Network fields conditionally shown based on selected mode
-- Comma-Separated Values in Dependencies
-  - `needs` now supports multiple values: `network_mode=bridge,macvlan`
-  - Variable shown if actual value matches ANY of the specified values
-- Template Schema Versioning (#1360)
-  - Templates can now declare schema version (defaults to "1.0" for backward compatibility)
-  - Modules validate template compatibility against supported schema version
-  - Incompatible templates show clear error with upgrade instructions
-  - New `cli/core/version.py` module for semantic version comparison
-  - New `IncompatibleSchemaVersionError` exception for version mismatches
-- Variable-level Dependencies
-  - Variables can now have `needs` dependencies with format `variable=value`
-  - Sections support new dependency format: `needs: "variable=value"`
-  - Backward compatible with old section-only dependencies (`needs: "section_name"`)
-  - `is_variable_satisfied()` method added to VariableCollection
-- Show/Generate `--all` flag
-  - Added `--all` flag to `show` and `generate` commands
-  - Shows all variables/sections regardless of needs satisfaction
-  - Useful for debugging and viewing complete template structure
-- Optional Variables
-  - Variables can now be marked with `optional: true` to allow empty/None values
-- Docker Swarm Volume Configuration
-  - Support for local, mount, and NFS storage backends
-  - Configurable NFS server, paths, and mount options
-- Storage Configuration for Docker Compose
-  - New `storage` section in compose module spec
-  - New `config` section in compose module spec
-  - Support for multiple storage backends: local, mount, nfs, glusterfs
-  - Dedicated configuration options for each backend type
-
-### Changed
-- Compose module schema version bumped to "1.1"
-- Traefik TLS section now uses variable-level dependencies (`needs: "traefik_enabled=true"`)
-- Display manager hides sections/variables with unsatisfied needs by default (unless `--all` flag is used)
-- Variables with unsatisfied needs are dimmed when shown with `--all` flag
-- Dependency validation now supports both old (section) and new (variable=value) formats
-
-## [0.0.6] - 2025-01-XX
+## [0.0.7] - 2025-10-28
 
 ### Added
+- Multiple Library Support (#1314) for git and local libraries
+- Multi-Schema Module Support and Backward Compatibility (Schema-1.0)
+- Schema-1.1 `network_mode` with options: bridge, host, macvlan
+- Schema-1.1 `swarm` module support
+- Variable-level and Section-level depenendencies `needs` with multiple values support
+- Optional Variables `optional: true` to allow empty/None values
+- PEP 8 formatting alignment
+- CLI variable dependency validation - raises error when CLI-provided variables have unsatisfied dependencies
 - Support for required variables independent of section state (#1355)
   - Variables can now be marked with `required: true` in template specs
   - Required variables are always prompted, validated, and included in rendering
@@ -64,20 +25,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
   - Required variables from disabled sections are still collected and available
 
 ### Changed
+- Schema-1.1 Unified Docker Swarm Placement (#1359) - Simplified swarm placement constraints into a single variable
+- Refactored compose module from single file to package structure
+- Dependency validation moved to `validate_all()` for better error reporting
+- Schema-1.1 removed `network_enabled`, `ports_enabled` and `database_enabled` toggles (no longer optional)
 - Improved error handling and display output consistency
 - Updated dependency PyYAML to v6.0.3 (Python 3.14 compatibility)
 - Updated dependency rich to v14.2.0 (Python 3.14 compatibility)
+- Pinned all dependencies to specific tested versions for consistent installations
 
 ### Fixed
+- Required sections now ignore toggle and are always enabled
+- Module spec loading based on correct template schema version
+- Interactive prompts now skip all variables (including required) when parent section is disabled
 - Absolute paths without leading slash treated as relative paths in generate command (#1357)
   - Paths like `Users/xcad/Projects/test` are now correctly normalized to `/Users/xcad/Projects/test`
   - Supports common Unix/Linux root directories: Users/, home/, usr/, opt/, var/, tmp/
 - Repository fetch fails when library directory already exists (#1279)
+- **Critical:** Python 3.9 compatibility - removed Context type annotations causing RuntimeError
+- Context access now uses click.get_current_context() for better compatibility
+
+## [0.0.6] - 2025-10-14
+
+### Changed
+- Pinned all dependencies to specific tested versions for consistent installations
+  - typer==0.19.2
+  - rich==14.1.0
+  - PyYAML==6.0.2
+  - python-frontmatter==1.1.0
+  - Jinja2==3.1.6
+
+### Fixed
+- **Critical:** Python 3.9 compatibility - removed Context type annotations causing RuntimeError
+- Context access now uses click.get_current_context() for better compatibility
+- Added tests directory to .gitignore
 
 ## [0.0.4] - 2025-01-XX
 
 Initial public release with core CLI functionality.
 
-[unreleased]: https://github.com/christianlempa/boilerplates/compare/v0.0.6...HEAD
-[0.0.6]: https://github.com/christianlempa/boilerplates/compare/v0.0.4...v0.0.6
+[unreleased]: https://github.com/christianlempa/boilerplates/compare/v0.0.7...HEAD
+[0.0.7]: https://github.com/christianlempa/boilerplates/compare/v0.0.6...v0.0.7
+[0.0.6]: https://github.com/christianlempa/boilerplates/releases/tag/v0.0.6
 [0.0.4]: https://github.com/christianlempa/boilerplates/releases/tag/v0.0.4

+ 100 - 9
cli/core/collection.py

@@ -95,7 +95,7 @@ class VariableCollection:
             vars_data = {}
 
         for var_name, var_data in vars_data.items():
-            var_init_data = {"name": var_name, **var_data}
+            var_init_data = {"name": var_name, "parent_section": section, **var_data}
             variable = Variable(var_init_data)
             section.variables[var_name] = variable
             # NOTE: Populate the direct lookup map for efficient access.
@@ -303,8 +303,12 @@ class VariableCollection:
                 dep_var, expected_value = self._parse_need(dep)
                 if expected_value is not None:  # Only validate new format
                     if dep_var not in self._variable_map:
-                        raise ValueError(
-                            f"Variable '{var_name}' has need '{dep}', but variable '{dep_var}' does not exist"
+                        # NOTE: We only warn here, not raise an error, because the variable might be
+                        # added later during merge with module spec. The actual runtime check in
+                        # _is_need_satisfied() will handle missing variables gracefully.
+                        logger.debug(
+                            f"Variable '{var_name}' has need '{dep}', but variable '{dep_var}' "
+                            f"not found (might be added during merge)"
                         )
 
         # Check for circular dependencies using depth-first search
@@ -396,6 +400,55 @@ class VariableCollection:
 
         return True
 
+    def reset_disabled_bool_variables(self) -> list[str]:
+        """Reset bool variables with unsatisfied dependencies to False.
+        
+        This ensures that disabled bool variables don't accidentally remain True
+        and cause confusion in templates or configuration.
+        
+        Note: CLI-provided variables are NOT reset here - they are validated
+        later in validate_all() to provide better error messages.
+        
+        Returns:
+            List of variable names that were reset
+        """
+        reset_vars = []
+        
+        for section_key, section in self._sections.items():
+            # Check if section dependencies are satisfied
+            section_satisfied = self.is_section_satisfied(section_key)
+            is_enabled = section.is_enabled()
+            
+            for var_name, variable in section.variables.items():
+                # Only process bool variables
+                if variable.type != "bool":
+                    continue
+                    
+                # Check if variable's own dependencies are satisfied
+                var_satisfied = self.is_variable_satisfied(var_name)
+                
+                # If section is disabled OR variable dependencies aren't met, reset to False
+                if not section_satisfied or not is_enabled or not var_satisfied:
+                    # Only reset if current value is not already False
+                    if variable.value is not False:
+                        # Don't reset CLI-provided variables - they'll be validated later
+                        if variable.origin == "cli":
+                            continue
+                        
+                        # Store original value if not already stored (for display purposes)
+                        if not hasattr(variable, "_original_disabled"):
+                            variable._original_disabled = variable.value
+                        
+                        variable.value = False
+                        reset_vars.append(var_name)
+                        logger.debug(
+                            f"Reset disabled bool variable '{var_name}' to False "
+                            f"(section satisfied: {section_satisfied}, enabled: {is_enabled}, "
+                            f"var satisfied: {var_satisfied})"
+                        )
+        
+        return reset_vars
+
     def sort_sections(self) -> None:
         """Sort sections with the following priority:
 
@@ -630,9 +683,38 @@ class VariableCollection:
         Validates:
         - All variables in enabled sections with satisfied dependencies
         - Required variables even if their section is disabled (but dependencies must be satisfied)
+        - CLI-provided bool variables with unsatisfied dependencies
         """
         errors: list[str] = []
 
+        # First, check for CLI-provided bool variables with unsatisfied dependencies
+        for section_key, section in self._sections.items():
+            section_satisfied = self.is_section_satisfied(section_key)
+            is_enabled = section.is_enabled()
+            
+            for var_name, variable in section.variables.items():
+                # Check CLI-provided bool variables with unsatisfied dependencies
+                if variable.type == "bool" and variable.origin == "cli" and variable.value is not False:
+                    var_satisfied = self.is_variable_satisfied(var_name)
+                    
+                    if not section_satisfied or not is_enabled or not var_satisfied:
+                        # Build error message with unmet needs (use set to avoid duplicates)
+                        unmet_needs = set()
+                        if not section_satisfied:
+                            for need in section.needs:
+                                if not self._is_need_satisfied(need):
+                                    unmet_needs.add(need)
+                        if not var_satisfied:
+                            for need in variable.needs:
+                                if not self._is_need_satisfied(need):
+                                    unmet_needs.add(need)
+                        
+                        needs_str = ", ".join(sorted(unmet_needs)) if unmet_needs else "dependencies not satisfied"
+                        errors.append(
+                            f"{section.key}.{var_name} (set via CLI to {variable.value} but requires: {needs_str})"
+                        )
+
+        # Then validate all other variables
         for section_key, section in self._sections.items():
             # Skip sections with unsatisfied dependencies (even for required variables)
             if not self.is_section_satisfied(section_key):
@@ -651,8 +733,9 @@ class VariableCollection:
 
             # Validate variables in the section
             for var_name, variable in section.variables.items():
-                # Skip non-required variables in disabled sections
-                if not is_enabled and not variable.required:
+                # Skip all variables (including required ones) in disabled sections
+                # Required variables are only required when their section is actually enabled
+                if not is_enabled:
                     continue
 
                 try:
@@ -749,6 +832,9 @@ class VariableCollection:
             for var_name, variable in section.variables.items():
                 merged._variable_map[var_name] = variable
 
+        # Validate dependencies after merge is complete
+        merged._validate_dependencies()
+
         return merged
 
     def _merge_sections(
@@ -758,15 +844,14 @@ class VariableCollection:
         merged_section = self_section.clone()
 
         # Update section metadata from other (other takes precedence)
-        # Only override if explicitly provided in other AND has a value
+        # Explicit null/empty values clear the property (reset mechanism)
         for attr in ("title", "description", "toggle"):
-            other_value = getattr(other_section, attr)
             if (
                 hasattr(other_section, "_explicit_fields")
                 and attr in other_section._explicit_fields
-                and other_value
             ):
-                setattr(merged_section, attr, other_value)
+                # Set to the other value even if null/empty (enables explicit reset)
+                setattr(merged_section, attr, getattr(other_section, attr))
 
         merged_section.required = other_section.required
         # Respect explicit clears for dependencies (explicit null/empty clears, missing field preserves)
@@ -806,6 +891,12 @@ class VariableCollection:
                     if bool_field in other_var._explicit_fields:
                         update[bool_field] = getattr(other_var, bool_field)
 
+                # Special handling for needs (allow explicit null/empty to clear)
+                if "needs" in other_var._explicit_fields:
+                    update["needs"] = (
+                        other_var.needs.copy() if other_var.needs else []
+                    )
+
                 # Special handling for value/default (allow explicit null to clear)
                 if "value" in other_var._explicit_fields:
                     update["value"] = other_var.value

+ 23 - 19
cli/core/display.py

@@ -195,6 +195,7 @@ class DisplayManager:
         table.add_column("Name")
         table.add_column("Tags")
         table.add_column("Version", no_wrap=True)
+        table.add_column("Schema", no_wrap=True)
         table.add_column("Library", no_wrap=True)
 
         for template in templates:
@@ -204,6 +205,7 @@ class DisplayManager:
             version = (
                 str(template.metadata.version) if template.metadata.version else ""
             )
+            schema = template.schema_version if hasattr(template, 'schema_version') else "1.0"
 
             # Show library with type indicator and color
             library_name = template.metadata.library or ""
@@ -223,23 +225,22 @@ class DisplayManager:
             # Display qualified ID if present (e.g., "alloy.default")
             display_id = template.id
 
-            table.add_row(display_id, name, tags, version, library_display)
+            table.add_row(display_id, name, tags, version, schema, library_display)
 
         console.print(table)
 
     def display_template_details(
-        self, template: Template, template_id: str, show_all: bool = False
+        self, template: Template, template_id: str
     ) -> None:
         """Display template information panel and variables table.
 
         Args:
             template: Template instance to display
             template_id: ID of the template
-            show_all: If True, show all variables/sections regardless of needs satisfaction
         """
         self._display_template_header(template, template_id)
         self._display_file_tree(template)
-        self._display_variables_table(template, show_all=show_all)
+        self._display_variables_table(template)
 
     def display_section_header(self, title: str, description: str | None) -> None:
         """Display a section header."""
@@ -370,6 +371,7 @@ class DisplayManager:
             if template.metadata.version
             else "Not specified"
         )
+        schema = template.schema_version if hasattr(template, 'schema_version') else "1.0"
         description = template.metadata.description or "No description available"
 
         # Get library information
@@ -387,7 +389,7 @@ class DisplayManager:
             )
 
         console.print(
-            f"[bold blue]{template_name} ({template_id} - [cyan]{version}[/cyan]) {library_display}[/bold blue]"
+            f"[bold blue]{template_name} ({template_id} - [cyan]{version}[/cyan] - [magenta]schema {schema}[/magenta]) {library_display}[/bold blue]"
         )
         console.print(description)
 
@@ -455,13 +457,15 @@ class DisplayManager:
             console.print(file_tree)
 
     def _display_variables_table(
-        self, template: Template, show_all: bool = False
+        self, template: Template
     ) -> None:
         """Display a table of variables for a template.
 
+        All variables and sections are always shown. Disabled sections/variables
+        are displayed with dimmed styling.
+
         Args:
             template: Template instance
-            show_all: If True, show all variables/sections regardless of needs satisfaction
         """
         if not (template.variables and template.variables.has_sections()):
             return
@@ -480,12 +484,6 @@ class DisplayManager:
             if not section.variables:
                 continue
 
-            # Skip sections with unsatisfied needs unless show_all is True
-            if not show_all and not template.variables.is_section_satisfied(
-                section.key
-            ):
-                continue
-
             if not first_section:
                 variables_table.add_row("", "", "", "", style="bright_black")
             first_section = False
@@ -526,17 +524,23 @@ class DisplayManager:
                 # Check if variable's needs are satisfied
                 var_satisfied = template.variables.is_variable_satisfied(var_name)
 
-                # Skip variables with unsatisfied needs unless show_all is True
-                if not show_all and not var_satisfied:
-                    continue
-
                 # Dim the variable if section is dimmed OR variable needs are not satisfied
                 row_style = "bright_black" if (is_dimmed or not var_satisfied) else None
 
                 # Build default value display
+                # Special case: disabled bool variables show as "original → False"
+                if (is_dimmed or not var_satisfied) and variable.type == "bool":
+                    # Show that disabled bool variables are forced to False
+                    if hasattr(variable, "_original_disabled") and variable._original_disabled is not False:
+                        orig_val = str(variable._original_disabled)
+                        default_val = f"{orig_val} {IconManager.arrow_right()} False"
+                    else:
+                        default_val = "False"
                 # If origin is 'config' and original value differs from current, show: original → config_value
-                if (
-                    variable.origin == "config"
+                # BUT only for enabled variables (don't show arrow for disabled ones)
+                elif (
+                    not (is_dimmed or not var_satisfied)
+                    and variable.origin == "config"
                     and hasattr(variable, "_original_stored")
                     and variable.original_value != variable.value
                 ):

+ 14 - 15
cli/core/module.py

@@ -217,11 +217,6 @@ class Module(ABC):
     def show(
         self,
         id: str,
-        all_vars: bool = Option(
-            False,
-            "--all",
-            help="Show all variables/sections, even those with unsatisfied needs",
-        ),
     ) -> None:
         """Show template details."""
         logger.debug(f"Showing template '{id}' from module '{self.name}'")
@@ -254,8 +249,13 @@ class Module(ABC):
 
             # Re-sort sections after applying config (toggle values may have changed)
             template.variables.sort_sections()
+            
+            # Reset disabled bool variables to False to prevent confusion
+            reset_vars = template.variables.reset_disabled_bool_variables()
+            if reset_vars:
+                logger.debug(f"Reset {len(reset_vars)} disabled bool variables to False")
 
-        self._display_template_details(template, id, show_all=all_vars)
+        self._display_template_details(template, id)
 
     def _apply_variable_defaults(self, template: Template) -> None:
         """Apply config defaults and CLI overrides to template variables.
@@ -610,11 +610,6 @@ class Module(ABC):
         quiet: bool = Option(
             False, "--quiet", "-q", help="Suppress all non-error output"
         ),
-        all_vars: bool = Option(
-            False,
-            "--all",
-            help="Show all variables/sections, even those with unsatisfied needs",
-        ),
     ) -> None:
         """Generate from template.
 
@@ -656,9 +651,14 @@ class Module(ABC):
         # Re-sort sections after all overrides (toggle values may have changed)
         if template.variables:
             template.variables.sort_sections()
+            
+            # Reset disabled bool variables to False to prevent confusion
+            reset_vars = template.variables.reset_disabled_bool_variables()
+            if reset_vars:
+                logger.debug(f"Reset {len(reset_vars)} disabled bool variables to False")
 
         if not quiet:
-            self._display_template_details(template, id, show_all=all_vars)
+            self._display_template_details(template, id)
             console.print()
 
         # Collect variable values
@@ -1285,13 +1285,12 @@ class Module(ABC):
             ) from exc
 
     def _display_template_details(
-        self, template: Template, id: str, show_all: bool = False
+        self, template: Template, id: str
     ) -> None:
         """Display template information panel and variables table.
 
         Args:
             template: Template instance to display
             id: Template ID
-            show_all: If True, show all variables/sections regardless of needs satisfaction
         """
-        self.display.display_template_details(template, id, show_all=show_all)
+        self.display.display_template_details(template, id)

+ 3 - 3
cli/core/prompt.py

@@ -110,10 +110,10 @@ class PromptHandler:
                     )
                     continue
 
-                # Skip non-required variables if section is disabled
-                if not section_will_be_enabled and not variable.required:
+                # Skip all variables if section is disabled
+                if not section_will_be_enabled:
                     logger.debug(
-                        f"Skipping non-required variable '{var_name}' from disabled section '{section_key}'"
+                        f"Skipping variable '{var_name}' from disabled section '{section_key}'"
                     )
                     continue
 

+ 25 - 7
cli/core/template.py

@@ -415,17 +415,19 @@ class Template:
 
     @staticmethod
     @lru_cache(maxsize=32)
-    def _load_module_specs(kind: str) -> dict:
-        """Load specifications from the corresponding module with caching.
+    def _load_module_specs_for_schema(kind: str, schema_version: str) -> dict:
+        """Load specifications from the corresponding module for a specific schema version.
 
         Uses LRU cache to avoid re-loading the same module spec multiple times.
         This significantly improves performance when listing many templates of the same kind.
 
         Args:
             kind: The module kind (e.g., 'compose', 'terraform')
+            schema_version: The schema version to load (e.g., '1.0', '1.1')
 
         Returns:
-            Dictionary containing the module's spec, or empty dict if kind is empty
+            Dictionary containing the module's spec for the requested schema version,
+            or empty dict if kind is empty
 
         Raises:
             ValueError: If module cannot be loaded or spec is invalid
@@ -436,8 +438,22 @@ class Template:
             import importlib
 
             module = importlib.import_module(f"cli.modules.{kind}")
-            spec = getattr(module, "spec", {})
-            logger.debug(f"Loaded and cached module spec for kind '{kind}'")
+            
+            # Check if module has schema-specific specs (multi-schema support)
+            # Try SCHEMAS constant first (uppercase), then schemas attribute
+            schemas = getattr(module, "SCHEMAS", None) or getattr(module, "schemas", None)
+            if schemas and schema_version in schemas:
+                spec = schemas[schema_version]
+                logger.debug(
+                    f"Loaded and cached module spec for kind '{kind}' schema {schema_version}"
+                )
+            else:
+                # Fallback to default spec if schema mapping not available
+                spec = getattr(module, "spec", {})
+                logger.debug(
+                    f"Loaded and cached module spec for kind '{kind}' (default/no schema mapping)"
+                )
+            
             return spec
         except Exception as e:
             raise ValueError(
@@ -887,10 +903,12 @@ class Template:
 
     @property
     def module_specs(self) -> dict:
-        """Get the spec from the module definition."""
+        """Get the spec from the module definition for this template's schema version."""
         if self.__module_specs is None:
             kind = self._template_data.get("kind")
-            self.__module_specs = self._load_module_specs(kind)
+            self.__module_specs = self._load_module_specs_for_schema(
+                kind, self.schema_version
+            )
         return self.__module_specs
 
     @property

+ 11 - 0
cli/core/variable.py

@@ -37,6 +37,8 @@ class Variable:
 
         # Initialize fields
         self.name: str = data["name"]
+        # Reference to parent section (set by VariableCollection)
+        self.parent_section: Optional["VariableSection"] = data.get("parent_section")
         self.description: Optional[str] = data.get("description") or data.get(
             "display", ""
         )
@@ -404,6 +406,14 @@ class Variable:
         # No default value and not autogenerated = required
         return True
 
+    def get_parent(self) -> Optional["VariableSection"]:
+        """Get the parent VariableSection that contains this variable.
+        
+        Returns:
+            The parent VariableSection if set, None otherwise
+        """
+        return self.parent_section
+
     def clone(self, update: Optional[Dict[str, Any]] = None) -> "Variable":
         """Create a deep copy of the variable with optional field updates.
 
@@ -433,6 +443,7 @@ class Variable:
             "optional": self.optional,
             "original_value": self.original_value,
             "needs": self.needs.copy() if self.needs else None,
+            "parent_section": self.parent_section,
         }
 
         # Apply updates if provided

+ 1 - 1
cli/modules/compose/spec_v1_0.py

@@ -174,7 +174,7 @@ spec = OrderedDict(
                 },
                 "database_external": {
                     "description": "Use an external database server?",
-                    "extra": "If 'no', a database container will be created in the compose project.",
+                    "extra": "skips creation of internal database container",
                     "type": "bool",
                     "default": False,
                 },

+ 10 - 26
cli/modules/compose/spec_v1_1.py

@@ -56,19 +56,12 @@ spec = OrderedDict(
         },
         "network": {
             "title": "Network",
-            "toggle": "network_enabled",
             "vars": {
-                "network_enabled": {
-                    "description": "Enable custom network block",
-                    "type": "bool",
-                    "default": False,
-                },
                 "network_mode": {
                     "description": "Docker network mode",
                     "type": "enum",
                     "options": ["bridge", "host", "macvlan"],
                     "default": "bridge",
-                    "extra": "bridge=default Docker networking, host=use host network stack, macvlan=dedicated MAC address on physical network",
                 },
                 "network_name": {
                     "description": "Docker network name",
@@ -77,9 +70,9 @@ spec = OrderedDict(
                     "needs": "network_mode=bridge,macvlan",
                 },
                 "network_external": {
-                    "description": "Use existing Docker network",
+                    "description": "Use existing Docker network (external)",
                     "type": "bool",
-                    "default": True,
+                    "default": False,
                     "needs": "network_mode=bridge,macvlan",
                 },
                 "network_macvlan_ipv4_address": {
@@ -111,17 +104,14 @@ spec = OrderedDict(
         "ports": {
             "title": "Ports",
             "toggle": "ports_enabled",
+            "needs": "network_mode=bridge",
             "vars": {
-                "ports_enabled": {
-                    "description": "Expose ports via 'ports' mapping",
-                    "type": "bool",
-                    "default": True,
-                }
             },
         },
         "traefik": {
             "title": "Traefik",
             "toggle": "traefik_enabled",
+            "needs": "network_mode=bridge",
             "description": "Traefik routes external traffic to your service.",
             "vars": {
                 "traefik_enabled": {
@@ -148,7 +138,7 @@ spec = OrderedDict(
         "traefik_tls": {
             "title": "Traefik TLS/SSL",
             "toggle": "traefik_tls_enabled",
-            "needs": "traefik_enabled=true",
+            "needs": "traefik_enabled=true;network_mode=bridge",
             "description": "Enable HTTPS/TLS for Traefik with certificate management.",
             "vars": {
                 "traefik_tls_enabled": {
@@ -170,6 +160,7 @@ spec = OrderedDict(
         },
         "swarm": {
             "title": "Docker Swarm",
+            "needs": "network_mode=bridge",
             "toggle": "swarm_enabled",
             "description": "Deploy service in Docker Swarm mode.",
             "vars": {
@@ -183,7 +174,6 @@ spec = OrderedDict(
                     "type": "enum",
                     "options": ["replicated", "global"],
                     "default": "replicated",
-                    "extra": "replicated=run specific number of tasks, global=run one task per node",
                 },
                 "swarm_replicas": {
                     "description": "Number of replicas",
@@ -197,7 +187,7 @@ spec = OrderedDict(
                     "default": "",
                     "optional": True,
                     "needs": "swarm_placement_mode=replicated",
-                    "extra": "Constrains service to run on specific node by hostname (optional)",
+                    "extra": "Constrains service to run on specific node by hostname",
                 },
                 "swarm_volume_mode": {
                     "description": "Swarm volume storage backend",
@@ -239,22 +229,16 @@ spec = OrderedDict(
         "database": {
             "title": "Database",
             "toggle": "database_enabled",
-            "description": "Connect to external database (PostgreSQL or MySQL)",
             "vars": {
-                "database_enabled": {
-                    "description": "Enable external database integration",
-                    "type": "bool",
-                    "default": False,
-                },
                 "database_type": {
                     "description": "Database type",
                     "type": "enum",
-                    "options": ["postgres", "mysql"],
-                    "default": "postgres",
+                    "options": ["default", "sqlite", "postgres", "mysql"],
+                    "default": "default",
                 },
                 "database_external": {
                     "description": "Use an external database server?",
-                    "extra": "If 'no', a database container will be created in the compose project.",
+                    "extra": "skips creation of internal database container",
                     "type": "bool",
                     "default": False,
                 },

+ 23 - 0
library/compose/pihole/.env.pihole.j2

@@ -0,0 +1,23 @@
+# Pi-hole Configuration
+# Contains application configuration
+
+# Timezone
+TZ={{ container_timezone }}
+
+# User and Group IDs
+PIHOLE_UID={{ user_uid }}
+PIHOLE_GID={{ user_gid }}
+
+# Web Interface Admin Password
+{% if swarm_enabled %}
+# In swarm mode, password is loaded from Docker secret (use secret name, not path)
+WEBPASSWORD_FILE={{ webpassword_secret_name }}
+{% else %}
+# In compose mode, password is stored directly
+FTLCONF_webserver_api_password={{ webpassword }}
+{% endif %}
+
+# DNS Listening Mode
+{% if network_mode == 'bridge' %}
+FTLCONF_dns_listeningMode=all
+{% endif %}

+ 1 - 0
library/compose/pihole/.env.secret.j2

@@ -0,0 +1 @@
+{{ webpassword }}

+ 108 - 13
library/compose/pihole/compose.yaml.j2

@@ -1,10 +1,14 @@
 services:
   {{ service_name }}:
+    {% if not swarm_enabled %}
     container_name: {{ container_name }}
-    image: docker.io/pihole/pihole:2025.08.0
+    {% endif %}
+    image: docker.io/pihole/pihole:2025.10.2
+    env_file:
+      - .env.pihole
     {% if network_mode == 'host' %}
     network_mode: host
-    {% elif traefik_enabled or network_mode == 'macvlan' %}
+    {% else %}
     networks:
       {% if traefik_enabled %}
       {{ traefik_network }}:
@@ -16,27 +20,86 @@ services:
       {{ network_name }}:
       {% endif %}
     {% endif %}
-    {% if ports_enabled and network_mode not in ['host', 'macvlan'] and (not traefik_enabled or dns_enabled or dhcp_enabled) %}
+    {% if network_mode not in ['host', 'macvlan'] %}
     ports:
       {% if not traefik_enabled %}
+      {% if swarm_enabled %}
+      - target: 80
+        published: {{ ports_http }}
+        protocol: tcp
+        mode: host
+      - target: 443
+        published: {{ ports_https }}
+        protocol: tcp
+        mode: host
+      {% else %}
       - "{{ ports_http }}:80/tcp"
       - "{{ ports_https }}:443/tcp"
       {% endif %}
-      {% if dns_enabled %}
-      - "53:53/tcp"
-      - "53:53/udp"
       {% endif %}
-      {% if dhcp_enabled %}
-      - "67:67/udp"
+      {% if swarm_enabled %}
+      - target: 53
+        published: {{ ports_dns }}
+        protocol: tcp
+        mode: host
+      - target: 53
+        published: {{ ports_dns }}
+        protocol: udp
+        mode: host
+      - target: 123
+        published: {{ ports_ntp }}
+        protocol: udp
+        mode: host
+      {% else %}
+      - "{{ ports_dns }}:53/tcp"
+      - "{{ ports_dns }}:53/udp"
+      - "{{ ports_ntp }}:123/udp"
       {% endif %}
     {% endif %}
-    environment:
-      - TZ={{ container_timezone }}
-      {% if pihole_webpassword %}      - FTLCONF_webserver_api_password={{ pihole_webpassword }}
-      {% endif %}      - FTLCONF_dns_upstreams={{ pihole_dns_upstreams }}
     volumes:
+      {% if not swarm_enabled %}
+      - config_dnsmasq:/etc/dnsmasq.d
+      - config_pihole:/etc/pihole
+      {% else %}
+      {% if swarm_volume_mode == 'mount' %}
+      - {{ swarm_volume_mount_path }}/dnsmasq:/etc/dnsmasq.d:rw
+      - {{ swarm_volume_mount_path }}/pihole:/etc/pihole:rw
+      {% elif swarm_volume_mode == 'local' %}
       - config_dnsmasq:/etc/dnsmasq.d
       - config_pihole:/etc/pihole
+      {% elif swarm_volume_mode == 'nfs' %}
+      - config_dnsmasq:/etc/dnsmasq.d
+      - config_pihole:/etc/pihole
+      {% endif %}
+      {% endif %}
+    cap_add:
+      - NET_ADMIN
+      - SYS_TIME
+    {% if swarm_enabled %}
+    secrets:
+      - {{ webpassword_secret_name }}
+    deploy:
+      mode: replicated
+      replicas: 1
+      placement:
+        constraints:
+          - node.hostname == {{ swarm_placement_host }}
+      {% if traefik_enabled %}
+      labels:
+        - traefik.enable=true
+        - traefik.http.services.{{ service_name }}-web.loadBalancer.server.port=80
+        - traefik.http.routers.{{ service_name }}-http.service={{ service_name }}-web
+        - traefik.http.routers.{{ service_name }}-http.rule=Host(`{{ traefik_host }}`)
+        - traefik.http.routers.{{ service_name }}-http.entrypoints={{ traefik_entrypoint }}
+        {% if traefik_tls_enabled %}
+        - traefik.http.routers.{{ service_name }}-https.service={{ service_name }}-web
+        - traefik.http.routers.{{ service_name }}-https.rule=Host(`{{ traefik_host }}`)
+        - traefik.http.routers.{{ service_name }}-https.entrypoints={{ traefik_tls_entrypoint }}
+        - traefik.http.routers.{{ service_name }}-https.tls=true
+        - traefik.http.routers.{{ service_name }}-https.tls.certresolver={{ traefik_tls_certresolver }}
+        {% endif %}
+      {% endif %}
+    {% else %}
     {% if traefik_enabled %}
     labels:
       - traefik.enable=true
@@ -53,14 +116,41 @@ services:
       {% endif %}
     {% endif %}
     restart: {{ restart_policy }}
+    {% endif %}
 
+{% if swarm_enabled %}
+{% if swarm_volume_mode in ['local', 'nfs'] %}
 volumes:
   config_dnsmasq:
+    {% if swarm_volume_mode == 'nfs' %}
     driver: local
+    driver_opts:
+      type: nfs
+      o: addr={{ swarm_volume_nfs_server }},{{ swarm_volume_nfs_options }}
+      device: ":{{ swarm_volume_nfs_path }}/dnsmasq"
+    {% endif %}
   config_pihole:
+    {% if swarm_volume_mode == 'nfs' %}
     driver: local
+    driver_opts:
+      type: nfs
+      o: addr={{ swarm_volume_nfs_server }},{{ swarm_volume_nfs_options }}
+      device: ":{{ swarm_volume_nfs_path }}/pihole"
+    {% endif %}
+{% endif %}
 
-{% if network_mode != 'host' and (network_mode in ['bridge', 'macvlan'] or traefik_enabled) %}
+secrets:
+  {{ webpassword_secret_name }}:
+    file: ./.env.secret
+{% else %}
+volumes:
+  config_dnsmasq:
+    driver: local
+  config_pihole:
+    driver: local
+{% endif %}
+
+{% if network_mode != 'host' %}
 networks:
   {% if network_mode == 'macvlan' %}
   {{ network_name }}:
@@ -76,7 +166,12 @@ networks:
     external: true
   {% elif network_mode == 'bridge' and not network_external %}
   {{ network_name }}:
+    {% if swarm_enabled %}
+    driver: overlay
+    attachable: true
+    {% else %}
     driver: bridge
+    {% endif %}
   {% endif %}
   {% if traefik_enabled %}
   {{ traefik_network }}:

+ 53 - 46
library/compose/pihole/template.yaml

@@ -4,7 +4,7 @@ schema: "1.1"
 metadata:
   name: Pihole
   description: >
-    Network-wide advertisement and internet tracker blocking application that functions as a DNS sinkhole.
+    Network-wide advertisement and internet tracker blocking application that functions as a DNS blackhole.
     Provides DNS-level content filtering for all network devices, improving browsing performance, privacy, and security.
     Supports custom blocklists, whitelists, and seamless integration with existing network infrastructure.
 
@@ -14,34 +14,28 @@ metadata:
     Documentation: https://docs.pi-hole.net/
 
     GitHub: https://github.com/pi-hole/pi-hole
-  version: 2025.08.0
+  version: 2025.10.2
   author: Christian Lempa
-  date: '2025-09-28'
+  date: '2025-10-28'
   tags:
     - dns
     - ad-blocking
-  draft: false
   next_steps: |
-    1. Start: docker compose up -d
-
-    2. Access web interface:
-       {% if network_enabled and network_mode == 'macvlan' -%}
-       http://{{ network_macvlan_ipv4_address }}
-       {% elif traefik_enabled -%}
-       {% if traefik_tls_enabled %}https{% else %}http{% endif %}://{{ traefik_host }}
-       {%- elif ports_enabled -%}
-       http://localhost:{{ ports_http }}
-       {%- endif %}
-
-    3. Login password: {{ pihole_webpassword }}
-
-    {% if network_enabled and network_mode == 'macvlan' -%}
-    4. Configure devices to use {{ network_macvlan_ipv4_address }} as DNS server
-       {% if dhcp_enabled %}Configure DHCP in Settings > DHCP{% endif %}
-    {%- elif ports_enabled and dns_enabled -%}
-    4. Configure devices to use Docker host IP as DNS server (port 53)
-       {% if dhcp_enabled %}Configure DHCP in Settings > DHCP (port 67){% endif %}
-    {%- endif %}
+    {% if swarm_enabled -%}
+    1. Deploy to Docker Swarm:
+       docker stack deploy -c compose.yaml pihole
+    2. Access the Web Interface:
+       {%- if traefik_enabled == True -%}https://{{ traefik_host }}/admin
+       {%- else -%}https://<your-swarm-node-ip>:{{ ports_https }}/admin{%- endif %}
+    3. Configure devices to use swarm node IP as DNS
+    {% else -%}
+    1. Deploy to Docker:
+       docker compose up -d
+    2. Access the Web Interface:
+       {%- if traefik_enabled == True -%}https://{{ traefik_host }}/admin
+       {%- else -%}https://<your-docker-host-ip>:{{ ports_https }}/admin{%- endif %}
+    3. Configure devices to use docker host IP as DNS
+    {% endif -%}
 spec:
   general:
     vars:
@@ -49,48 +43,61 @@ spec:
         default: "pihole"
       container_name:
         default: "pihole"
-  pihole:
+  admin_settings:
+    description: "Admin Pi-hole Settings"
     required: true
     vars:
-      pihole_webpassword:
+      webpassword:
         description: "Web interface admin password"
         type: str
         sensitive: true
         default: ""
         autogenerated: true
-      pihole_dns_upstreams:
-        description: "Upstream DNS servers"
-        type: str
-        default: "1.1.1.1;1.0.0.1"
-        extra: "Separate multiple DNS servers with semicolons (;)"
-      dns_enabled:
-        type: bool
-        description: "Enable DNS server functionality"
-        default: true
-        extra: "Exposes port 53 for DNS queries in bridge network mode"
-      dhcp_enabled:
-        type: bool
-        description: "Enable DHCP server functionality"
-        default: false
-        extra: "Exposes port 67 for DHCP in bridge network mode"
   traefik:
     vars:
+      traefik_enabled:
+        needs: "network_mode=bridge"
       traefik_host:
         default: "pihole.home.arpa"
   network:
-    required: true
     vars:
+      network_mode:
+        extra: >
+          If you need DHCP functionality, use 'host' or 'macvlan' mode.
+          NOTE: Swarm only supports 'bridge' mode!"
       network_name:
         default: "pihole_network"
-      network_external:
-        default: false
   ports:
     vars:
-      ports_enabled:
-        extra: "Only required in bridge network mode without Traefik"
       ports_http:
+        description: "External HTTP port"
         type: int
         default: 8080
+        needs: ["traefik_enabled=false", "network_mode=bridge"]
       ports_https:
+        description: "External HTTPS port"
         type: int
         default: 8443
+        needs: ["traefik_enabled=false", "network_mode=bridge"]
+      ports_dns:
+        description: "External DNS port"
+        type: int
+        default: 53
+        needs: "network_mode=bridge"
+      ports_ntp:
+        description: "External NTP port"
+        type: int
+        default: 123
+        needs: "network_mode=bridge"
+  swarm:
+    vars:
+      swarm_enabled:
+        needs: "network_mode=bridge"
+      swarm_placement_host:
+        required: true
+        optional: false
+        needs: null
+      webpassword_secret_name:
+        description: "Docker Swarm secret name for admin password"
+        type: str
+        default: "pihole_webpassword"

+ 1 - 0
library/compose/semaphoreui/.env.semaphore.j2

@@ -16,6 +16,7 @@ SEMAPHORE_DB_HOST={{ database_host }}
 SEMAPHORE_DB_HOST={{ service_name }}-{{ database_type }}
 {% endif %}
 SEMAPHORE_DB_PORT={% if database_type == 'postgres' %}5432{% else %}3306{% endif %}
+
 SEMAPHORE_DB={{ database_name }}
 SEMAPHORE_DB_USER={{ database_user }}
 SEMAPHORE_DB_PASS={{ database_password }}

+ 1 - 1
library/compose/traefik/template.yaml

@@ -57,7 +57,7 @@ metadata:
        - Review and limit network exposure
 
     For more information, visit: https://doc.traefik.io/traefik/
-  draft: false
+  draft: true
 spec:
   general:
     title: "General"

+ 3 - 2
renovate.json

@@ -27,7 +27,7 @@
       ]
     },
     {
-      "description": "Update MariaDB or MySQL on a patch level only, bumps to major and minor versions might break compatibilty with an application",
+      "description": "Update MariaDB or MySQL on a patch level only, bumps to major and minor versions might break compatibility with an application",
       "enabled": false,
       "matchManagers": [
         "custom.regex"
@@ -41,7 +41,7 @@
       ]
     },
     {
-      "description": "Update PostgreSQL on a minor version or patch level only, bumps to major versions might break compatibilty with an application",
+      "description": "Update PostgreSQL on a minor version or patch level only, bumps to major versions might break compatibility with an application",
       "enabled": false,
       "matchManagers": [
         "custom.regex"
@@ -160,6 +160,7 @@
       "datasourceTemplate": "terraform-provider"
     }
   ],
+  "gitAuthor": "github-actions[bot] <github-actions[bot]@users.noreply.github.com>",
   "prConcurrentLimit": 30,
   "prHourlyLimit": 5,
   "separateMinorPatch": true,