소스 검색

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

xcad 4 달 전
부모
커밋
d27c01a28f

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

@@ -96,8 +96,16 @@ jobs:
       - name: Extract changelog for this version
       - name: Extract changelog for this version
         id: changelog
         id: changelog
         run: |
         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
           if [ -z "$CHANGELOG" ]; then
             echo "No changelog entries found for this release"
             echo "No changelog entries found for this release"

+ 2 - 0
.gitignore

@@ -27,3 +27,5 @@
 # Test outputs
 # Test outputs
 tests/
 tests/
 config.yaml
 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
 ### 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:**
 **Creating Modules:**
 - Subclass `Module` from `cli/core/module.py`
 - 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
 - Call `registry.register(YourModule)` at module bottom
 - Auto-discovered and registered at CLI startup
 - 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:**
 **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
 **(Work in Progress):** terraform, docker, ansible, kubernetes, packer modules
 
 
@@ -185,7 +201,8 @@ spec:
 ```
 ```
 
 
 **How It Works:**
 **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")
 - **Template Schema Version**: Each template declares `schema` at the top level (defaults to "1.0")
 - **Compatibility Check**: Template schema ≤ Module schema → Compatible
 - **Compatibility Check**: Template schema ≤ Module schema → Compatible
 - **Incompatibility**: Template schema > Module schema → `IncompatibleSchemaVersionError`
 - **Incompatibility**: Template schema > Module schema → `IncompatibleSchemaVersionError`
@@ -201,12 +218,30 @@ spec:
 - Set template schema when using features from a specific schema
 - 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"`
 - Example: Template using new variable type added in schema 1.1 should set `schema: "1.1"`
 
 
-**Module Example:**
+**Single-File Module Example:**
 ```python
 ```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):
 class ComposeModule(Module):
   name = "compose"
   name = "compose"
   description = "Manage Docker Compose configurations"
   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:**
 **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]
 ## [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
 ### 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)
 - Support for required variables independent of section state (#1355)
   - Variables can now be marked with `required: true` in template specs
   - Variables can now be marked with `required: true` in template specs
   - Required variables are always prompted, validated, and included in rendering
   - 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
   - Required variables from disabled sections are still collected and available
 
 
 ### Changed
 ### 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
 - Improved error handling and display output consistency
 - Updated dependency PyYAML to v6.0.3 (Python 3.14 compatibility)
 - Updated dependency PyYAML to v6.0.3 (Python 3.14 compatibility)
 - Updated dependency rich to v14.2.0 (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
 ### 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)
 - 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`
   - 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/
   - Supports common Unix/Linux root directories: Users/, home/, usr/, opt/, var/, tmp/
 - Repository fetch fails when library directory already exists (#1279)
 - 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
 ## [0.0.4] - 2025-01-XX
 
 
 Initial public release with core CLI functionality.
 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
 [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 = {}
             vars_data = {}
 
 
         for var_name, var_data in vars_data.items():
         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)
             variable = Variable(var_init_data)
             section.variables[var_name] = variable
             section.variables[var_name] = variable
             # NOTE: Populate the direct lookup map for efficient access.
             # NOTE: Populate the direct lookup map for efficient access.
@@ -303,8 +303,12 @@ class VariableCollection:
                 dep_var, expected_value = self._parse_need(dep)
                 dep_var, expected_value = self._parse_need(dep)
                 if expected_value is not None:  # Only validate new format
                 if expected_value is not None:  # Only validate new format
                     if dep_var not in self._variable_map:
                     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
         # Check for circular dependencies using depth-first search
@@ -396,6 +400,55 @@ class VariableCollection:
 
 
         return True
         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:
     def sort_sections(self) -> None:
         """Sort sections with the following priority:
         """Sort sections with the following priority:
 
 
@@ -630,9 +683,38 @@ class VariableCollection:
         Validates:
         Validates:
         - All variables in enabled sections with satisfied dependencies
         - All variables in enabled sections with satisfied dependencies
         - Required variables even if their section is disabled (but dependencies must be satisfied)
         - Required variables even if their section is disabled (but dependencies must be satisfied)
+        - CLI-provided bool variables with unsatisfied dependencies
         """
         """
         errors: list[str] = []
         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():
         for section_key, section in self._sections.items():
             # Skip sections with unsatisfied dependencies (even for required variables)
             # Skip sections with unsatisfied dependencies (even for required variables)
             if not self.is_section_satisfied(section_key):
             if not self.is_section_satisfied(section_key):
@@ -651,8 +733,9 @@ class VariableCollection:
 
 
             # Validate variables in the section
             # Validate variables in the section
             for var_name, variable in section.variables.items():
             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
                     continue
 
 
                 try:
                 try:
@@ -749,6 +832,9 @@ class VariableCollection:
             for var_name, variable in section.variables.items():
             for var_name, variable in section.variables.items():
                 merged._variable_map[var_name] = variable
                 merged._variable_map[var_name] = variable
 
 
+        # Validate dependencies after merge is complete
+        merged._validate_dependencies()
+
         return merged
         return merged
 
 
     def _merge_sections(
     def _merge_sections(
@@ -758,15 +844,14 @@ class VariableCollection:
         merged_section = self_section.clone()
         merged_section = self_section.clone()
 
 
         # Update section metadata from other (other takes precedence)
         # 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"):
         for attr in ("title", "description", "toggle"):
-            other_value = getattr(other_section, attr)
             if (
             if (
                 hasattr(other_section, "_explicit_fields")
                 hasattr(other_section, "_explicit_fields")
                 and attr in 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
         merged_section.required = other_section.required
         # Respect explicit clears for dependencies (explicit null/empty clears, missing field preserves)
         # 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:
                     if bool_field in other_var._explicit_fields:
                         update[bool_field] = getattr(other_var, bool_field)
                         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)
                 # Special handling for value/default (allow explicit null to clear)
                 if "value" in other_var._explicit_fields:
                 if "value" in other_var._explicit_fields:
                     update["value"] = other_var.value
                     update["value"] = other_var.value

+ 23 - 19
cli/core/display.py

@@ -195,6 +195,7 @@ class DisplayManager:
         table.add_column("Name")
         table.add_column("Name")
         table.add_column("Tags")
         table.add_column("Tags")
         table.add_column("Version", no_wrap=True)
         table.add_column("Version", no_wrap=True)
+        table.add_column("Schema", no_wrap=True)
         table.add_column("Library", no_wrap=True)
         table.add_column("Library", no_wrap=True)
 
 
         for template in templates:
         for template in templates:
@@ -204,6 +205,7 @@ class DisplayManager:
             version = (
             version = (
                 str(template.metadata.version) if template.metadata.version else ""
                 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
             # Show library with type indicator and color
             library_name = template.metadata.library or ""
             library_name = template.metadata.library or ""
@@ -223,23 +225,22 @@ class DisplayManager:
             # Display qualified ID if present (e.g., "alloy.default")
             # Display qualified ID if present (e.g., "alloy.default")
             display_id = template.id
             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)
         console.print(table)
 
 
     def display_template_details(
     def display_template_details(
-        self, template: Template, template_id: str, show_all: bool = False
+        self, template: Template, template_id: str
     ) -> None:
     ) -> None:
         """Display template information panel and variables table.
         """Display template information panel and variables table.
 
 
         Args:
         Args:
             template: Template instance to display
             template: Template instance to display
             template_id: ID of the template
             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_template_header(template, template_id)
         self._display_file_tree(template)
         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:
     def display_section_header(self, title: str, description: str | None) -> None:
         """Display a section header."""
         """Display a section header."""
@@ -370,6 +371,7 @@ class DisplayManager:
             if template.metadata.version
             if template.metadata.version
             else "Not specified"
             else "Not specified"
         )
         )
+        schema = template.schema_version if hasattr(template, 'schema_version') else "1.0"
         description = template.metadata.description or "No description available"
         description = template.metadata.description or "No description available"
 
 
         # Get library information
         # Get library information
@@ -387,7 +389,7 @@ class DisplayManager:
             )
             )
 
 
         console.print(
         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)
         console.print(description)
 
 
@@ -455,13 +457,15 @@ class DisplayManager:
             console.print(file_tree)
             console.print(file_tree)
 
 
     def _display_variables_table(
     def _display_variables_table(
-        self, template: Template, show_all: bool = False
+        self, template: Template
     ) -> None:
     ) -> None:
         """Display a table of variables for a template.
         """Display a table of variables for a template.
 
 
+        All variables and sections are always shown. Disabled sections/variables
+        are displayed with dimmed styling.
+
         Args:
         Args:
             template: Template instance
             template: Template instance
-            show_all: If True, show all variables/sections regardless of needs satisfaction
         """
         """
         if not (template.variables and template.variables.has_sections()):
         if not (template.variables and template.variables.has_sections()):
             return
             return
@@ -480,12 +484,6 @@ class DisplayManager:
             if not section.variables:
             if not section.variables:
                 continue
                 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:
             if not first_section:
                 variables_table.add_row("", "", "", "", style="bright_black")
                 variables_table.add_row("", "", "", "", style="bright_black")
             first_section = False
             first_section = False
@@ -526,17 +524,23 @@ class DisplayManager:
                 # Check if variable's needs are satisfied
                 # Check if variable's needs are satisfied
                 var_satisfied = template.variables.is_variable_satisfied(var_name)
                 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
                 # 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
                 row_style = "bright_black" if (is_dimmed or not var_satisfied) else None
 
 
                 # Build default value display
                 # 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 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 hasattr(variable, "_original_stored")
                     and variable.original_value != variable.value
                     and variable.original_value != variable.value
                 ):
                 ):

+ 14 - 15
cli/core/module.py

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

+ 25 - 7
cli/core/template.py

@@ -415,17 +415,19 @@ class Template:
 
 
     @staticmethod
     @staticmethod
     @lru_cache(maxsize=32)
     @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.
         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.
         This significantly improves performance when listing many templates of the same kind.
 
 
         Args:
         Args:
             kind: The module kind (e.g., 'compose', 'terraform')
             kind: The module kind (e.g., 'compose', 'terraform')
+            schema_version: The schema version to load (e.g., '1.0', '1.1')
 
 
         Returns:
         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:
         Raises:
             ValueError: If module cannot be loaded or spec is invalid
             ValueError: If module cannot be loaded or spec is invalid
@@ -436,8 +438,22 @@ class Template:
             import importlib
             import importlib
 
 
             module = importlib.import_module(f"cli.modules.{kind}")
             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
             return spec
         except Exception as e:
         except Exception as e:
             raise ValueError(
             raise ValueError(
@@ -887,10 +903,12 @@ class Template:
 
 
     @property
     @property
     def module_specs(self) -> dict:
     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:
         if self.__module_specs is None:
             kind = self._template_data.get("kind")
             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
         return self.__module_specs
 
 
     @property
     @property

+ 11 - 0
cli/core/variable.py

@@ -37,6 +37,8 @@ class Variable:
 
 
         # Initialize fields
         # Initialize fields
         self.name: str = data["name"]
         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(
         self.description: Optional[str] = data.get("description") or data.get(
             "display", ""
             "display", ""
         )
         )
@@ -404,6 +406,14 @@ class Variable:
         # No default value and not autogenerated = required
         # No default value and not autogenerated = required
         return True
         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":
     def clone(self, update: Optional[Dict[str, Any]] = None) -> "Variable":
         """Create a deep copy of the variable with optional field updates.
         """Create a deep copy of the variable with optional field updates.
 
 
@@ -433,6 +443,7 @@ class Variable:
             "optional": self.optional,
             "optional": self.optional,
             "original_value": self.original_value,
             "original_value": self.original_value,
             "needs": self.needs.copy() if self.needs else None,
             "needs": self.needs.copy() if self.needs else None,
+            "parent_section": self.parent_section,
         }
         }
 
 
         # Apply updates if provided
         # Apply updates if provided

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

@@ -174,7 +174,7 @@ spec = OrderedDict(
                 },
                 },
                 "database_external": {
                 "database_external": {
                     "description": "Use an external database server?",
                     "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",
                     "type": "bool",
                     "default": False,
                     "default": False,
                 },
                 },

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

@@ -56,19 +56,12 @@ spec = OrderedDict(
         },
         },
         "network": {
         "network": {
             "title": "Network",
             "title": "Network",
-            "toggle": "network_enabled",
             "vars": {
             "vars": {
-                "network_enabled": {
-                    "description": "Enable custom network block",
-                    "type": "bool",
-                    "default": False,
-                },
                 "network_mode": {
                 "network_mode": {
                     "description": "Docker network mode",
                     "description": "Docker network mode",
                     "type": "enum",
                     "type": "enum",
                     "options": ["bridge", "host", "macvlan"],
                     "options": ["bridge", "host", "macvlan"],
                     "default": "bridge",
                     "default": "bridge",
-                    "extra": "bridge=default Docker networking, host=use host network stack, macvlan=dedicated MAC address on physical network",
                 },
                 },
                 "network_name": {
                 "network_name": {
                     "description": "Docker network name",
                     "description": "Docker network name",
@@ -77,9 +70,9 @@ spec = OrderedDict(
                     "needs": "network_mode=bridge,macvlan",
                     "needs": "network_mode=bridge,macvlan",
                 },
                 },
                 "network_external": {
                 "network_external": {
-                    "description": "Use existing Docker network",
+                    "description": "Use existing Docker network (external)",
                     "type": "bool",
                     "type": "bool",
-                    "default": True,
+                    "default": False,
                     "needs": "network_mode=bridge,macvlan",
                     "needs": "network_mode=bridge,macvlan",
                 },
                 },
                 "network_macvlan_ipv4_address": {
                 "network_macvlan_ipv4_address": {
@@ -111,17 +104,14 @@ spec = OrderedDict(
         "ports": {
         "ports": {
             "title": "Ports",
             "title": "Ports",
             "toggle": "ports_enabled",
             "toggle": "ports_enabled",
+            "needs": "network_mode=bridge",
             "vars": {
             "vars": {
-                "ports_enabled": {
-                    "description": "Expose ports via 'ports' mapping",
-                    "type": "bool",
-                    "default": True,
-                }
             },
             },
         },
         },
         "traefik": {
         "traefik": {
             "title": "Traefik",
             "title": "Traefik",
             "toggle": "traefik_enabled",
             "toggle": "traefik_enabled",
+            "needs": "network_mode=bridge",
             "description": "Traefik routes external traffic to your service.",
             "description": "Traefik routes external traffic to your service.",
             "vars": {
             "vars": {
                 "traefik_enabled": {
                 "traefik_enabled": {
@@ -148,7 +138,7 @@ spec = OrderedDict(
         "traefik_tls": {
         "traefik_tls": {
             "title": "Traefik TLS/SSL",
             "title": "Traefik TLS/SSL",
             "toggle": "traefik_tls_enabled",
             "toggle": "traefik_tls_enabled",
-            "needs": "traefik_enabled=true",
+            "needs": "traefik_enabled=true;network_mode=bridge",
             "description": "Enable HTTPS/TLS for Traefik with certificate management.",
             "description": "Enable HTTPS/TLS for Traefik with certificate management.",
             "vars": {
             "vars": {
                 "traefik_tls_enabled": {
                 "traefik_tls_enabled": {
@@ -170,6 +160,7 @@ spec = OrderedDict(
         },
         },
         "swarm": {
         "swarm": {
             "title": "Docker Swarm",
             "title": "Docker Swarm",
+            "needs": "network_mode=bridge",
             "toggle": "swarm_enabled",
             "toggle": "swarm_enabled",
             "description": "Deploy service in Docker Swarm mode.",
             "description": "Deploy service in Docker Swarm mode.",
             "vars": {
             "vars": {
@@ -183,7 +174,6 @@ spec = OrderedDict(
                     "type": "enum",
                     "type": "enum",
                     "options": ["replicated", "global"],
                     "options": ["replicated", "global"],
                     "default": "replicated",
                     "default": "replicated",
-                    "extra": "replicated=run specific number of tasks, global=run one task per node",
                 },
                 },
                 "swarm_replicas": {
                 "swarm_replicas": {
                     "description": "Number of replicas",
                     "description": "Number of replicas",
@@ -197,7 +187,7 @@ spec = OrderedDict(
                     "default": "",
                     "default": "",
                     "optional": True,
                     "optional": True,
                     "needs": "swarm_placement_mode=replicated",
                     "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": {
                 "swarm_volume_mode": {
                     "description": "Swarm volume storage backend",
                     "description": "Swarm volume storage backend",
@@ -239,22 +229,16 @@ spec = OrderedDict(
         "database": {
         "database": {
             "title": "Database",
             "title": "Database",
             "toggle": "database_enabled",
             "toggle": "database_enabled",
-            "description": "Connect to external database (PostgreSQL or MySQL)",
             "vars": {
             "vars": {
-                "database_enabled": {
-                    "description": "Enable external database integration",
-                    "type": "bool",
-                    "default": False,
-                },
                 "database_type": {
                 "database_type": {
                     "description": "Database type",
                     "description": "Database type",
                     "type": "enum",
                     "type": "enum",
-                    "options": ["postgres", "mysql"],
-                    "default": "postgres",
+                    "options": ["default", "sqlite", "postgres", "mysql"],
+                    "default": "default",
                 },
                 },
                 "database_external": {
                 "database_external": {
                     "description": "Use an external database server?",
                     "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",
                     "type": "bool",
                     "default": False,
                     "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:
 services:
   {{ service_name }}:
   {{ service_name }}:
+    {% if not swarm_enabled %}
     container_name: {{ container_name }}
     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' %}
     {% if network_mode == 'host' %}
     network_mode: host
     network_mode: host
-    {% elif traefik_enabled or network_mode == 'macvlan' %}
+    {% else %}
     networks:
     networks:
       {% if traefik_enabled %}
       {% if traefik_enabled %}
       {{ traefik_network }}:
       {{ traefik_network }}:
@@ -16,27 +20,86 @@ services:
       {{ network_name }}:
       {{ network_name }}:
       {% endif %}
       {% endif %}
     {% 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:
     ports:
       {% if not traefik_enabled %}
       {% 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_http }}:80/tcp"
       - "{{ ports_https }}:443/tcp"
       - "{{ ports_https }}:443/tcp"
       {% endif %}
       {% endif %}
-      {% if dns_enabled %}
-      - "53:53/tcp"
-      - "53:53/udp"
       {% endif %}
       {% 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 %}
     {% endif %}
     {% endif %}
-    environment:
-      - TZ={{ container_timezone }}
-      {% if pihole_webpassword %}      - FTLCONF_webserver_api_password={{ pihole_webpassword }}
-      {% endif %}      - FTLCONF_dns_upstreams={{ pihole_dns_upstreams }}
     volumes:
     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_dnsmasq:/etc/dnsmasq.d
       - config_pihole:/etc/pihole
       - 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 %}
     {% if traefik_enabled %}
     labels:
     labels:
       - traefik.enable=true
       - traefik.enable=true
@@ -53,14 +116,41 @@ services:
       {% endif %}
       {% endif %}
     {% endif %}
     {% endif %}
     restart: {{ restart_policy }}
     restart: {{ restart_policy }}
+    {% endif %}
 
 
+{% if swarm_enabled %}
+{% if swarm_volume_mode in ['local', 'nfs'] %}
 volumes:
 volumes:
   config_dnsmasq:
   config_dnsmasq:
+    {% if swarm_volume_mode == 'nfs' %}
     driver: local
     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:
   config_pihole:
+    {% if swarm_volume_mode == 'nfs' %}
     driver: local
     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:
 networks:
   {% if network_mode == 'macvlan' %}
   {% if network_mode == 'macvlan' %}
   {{ network_name }}:
   {{ network_name }}:
@@ -76,7 +166,12 @@ networks:
     external: true
     external: true
   {% elif network_mode == 'bridge' and not network_external %}
   {% elif network_mode == 'bridge' and not network_external %}
   {{ network_name }}:
   {{ network_name }}:
+    {% if swarm_enabled %}
+    driver: overlay
+    attachable: true
+    {% else %}
     driver: bridge
     driver: bridge
+    {% endif %}
   {% endif %}
   {% endif %}
   {% if traefik_enabled %}
   {% if traefik_enabled %}
   {{ traefik_network }}:
   {{ traefik_network }}:

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

@@ -4,7 +4,7 @@ schema: "1.1"
 metadata:
 metadata:
   name: Pihole
   name: Pihole
   description: >
   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.
     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.
     Supports custom blocklists, whitelists, and seamless integration with existing network infrastructure.
 
 
@@ -14,34 +14,28 @@ metadata:
     Documentation: https://docs.pi-hole.net/
     Documentation: https://docs.pi-hole.net/
 
 
     GitHub: https://github.com/pi-hole/pi-hole
     GitHub: https://github.com/pi-hole/pi-hole
-  version: 2025.08.0
+  version: 2025.10.2
   author: Christian Lempa
   author: Christian Lempa
-  date: '2025-09-28'
+  date: '2025-10-28'
   tags:
   tags:
     - dns
     - dns
     - ad-blocking
     - ad-blocking
-  draft: false
   next_steps: |
   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:
 spec:
   general:
   general:
     vars:
     vars:
@@ -49,48 +43,61 @@ spec:
         default: "pihole"
         default: "pihole"
       container_name:
       container_name:
         default: "pihole"
         default: "pihole"
-  pihole:
+  admin_settings:
+    description: "Admin Pi-hole Settings"
     required: true
     required: true
     vars:
     vars:
-      pihole_webpassword:
+      webpassword:
         description: "Web interface admin password"
         description: "Web interface admin password"
         type: str
         type: str
         sensitive: true
         sensitive: true
         default: ""
         default: ""
         autogenerated: true
         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:
   traefik:
     vars:
     vars:
+      traefik_enabled:
+        needs: "network_mode=bridge"
       traefik_host:
       traefik_host:
         default: "pihole.home.arpa"
         default: "pihole.home.arpa"
   network:
   network:
-    required: true
     vars:
     vars:
+      network_mode:
+        extra: >
+          If you need DHCP functionality, use 'host' or 'macvlan' mode.
+          NOTE: Swarm only supports 'bridge' mode!"
       network_name:
       network_name:
         default: "pihole_network"
         default: "pihole_network"
-      network_external:
-        default: false
   ports:
   ports:
     vars:
     vars:
-      ports_enabled:
-        extra: "Only required in bridge network mode without Traefik"
       ports_http:
       ports_http:
+        description: "External HTTP port"
         type: int
         type: int
         default: 8080
         default: 8080
+        needs: ["traefik_enabled=false", "network_mode=bridge"]
       ports_https:
       ports_https:
+        description: "External HTTPS port"
         type: int
         type: int
         default: 8443
         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 }}
 SEMAPHORE_DB_HOST={{ service_name }}-{{ database_type }}
 {% endif %}
 {% endif %}
 SEMAPHORE_DB_PORT={% if database_type == 'postgres' %}5432{% else %}3306{% endif %}
 SEMAPHORE_DB_PORT={% if database_type == 'postgres' %}5432{% else %}3306{% endif %}
+
 SEMAPHORE_DB={{ database_name }}
 SEMAPHORE_DB={{ database_name }}
 SEMAPHORE_DB_USER={{ database_user }}
 SEMAPHORE_DB_USER={{ database_user }}
 SEMAPHORE_DB_PASS={{ database_password }}
 SEMAPHORE_DB_PASS={{ database_password }}

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

@@ -57,7 +57,7 @@ metadata:
        - Review and limit network exposure
        - Review and limit network exposure
 
 
     For more information, visit: https://doc.traefik.io/traefik/
     For more information, visit: https://doc.traefik.io/traefik/
-  draft: false
+  draft: true
 spec:
 spec:
   general:
   general:
     title: "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,
       "enabled": false,
       "matchManagers": [
       "matchManagers": [
         "custom.regex"
         "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,
       "enabled": false,
       "matchManagers": [
       "matchManagers": [
         "custom.regex"
         "custom.regex"
@@ -160,6 +160,7 @@
       "datasourceTemplate": "terraform-provider"
       "datasourceTemplate": "terraform-provider"
     }
     }
   ],
   ],
+  "gitAuthor": "github-actions[bot] <github-actions[bot]@users.noreply.github.com>",
   "prConcurrentLimit": 30,
   "prConcurrentLimit": 30,
   "prHourlyLimit": 5,
   "prHourlyLimit": 5,
   "separateMinorPatch": true,
   "separateMinorPatch": true,