Преглед на файлове

new release + pihole template

xcad преди 3 месеца
родител
ревизия
27df602f72
променени са 9 файла, в които са добавени 170 реда и са изтрити 118 реда
  1. 41 6
      AGENTS.md
  2. 17 38
      CHANGELOG.md
  3. 53 11
      cli/core/collection.py
  4. 3 3
      cli/core/prompt.py
  5. 11 0
      cli/core/variable.py
  6. 5 21
      cli/modules/compose/spec_v1_1.py
  7. 9 4
      library/compose/pihole/compose.yaml.j2
  8. 30 34
      library/compose/pihole/template.yaml
  9. 1 1
      library/compose/traefik/template.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:**

+ 17 - 38
CHANGELOG.md

@@ -8,46 +8,25 @@ 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
+- 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
 
 ### 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
+- 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)
+
+### 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
 
 ## [0.0.6] - 2025-01-XX
 

+ 53 - 11
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.
@@ -406,6 +406,9 @@ class VariableCollection:
         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
         """
@@ -426,12 +429,16 @@ class VariableCollection:
                 
                 # 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:
-                    # Store original value if not already stored (for display purposes)
-                    if not hasattr(variable, "_original_disabled"):
-                        variable._original_disabled = variable.value
-                    
                     # 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(
@@ -676,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):
@@ -697,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:
@@ -807,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)
@@ -855,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

+ 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
 

+ 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

+ 5 - 21
cli/modules/compose/spec_v1_1.py

@@ -56,13 +56,7 @@ 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",
@@ -78,7 +72,7 @@ spec = OrderedDict(
                 "network_external": {
                     "description": "Use existing Docker network (external)",
                     "type": "bool",
-                    "default": True,
+                    "default": False,
                     "needs": "network_mode=bridge,macvlan",
                 },
                 "network_macvlan_ipv4_address": {
@@ -112,11 +106,6 @@ spec = OrderedDict(
             "toggle": "ports_enabled",
             "needs": "network_mode=bridge",
             "vars": {
-                "ports_enabled": {
-                    "description": "Expose ports via 'ports' mapping",
-                    "type": "bool",
-                    "default": True,
-                }
             },
         },
         "traefik": {
@@ -171,6 +160,7 @@ spec = OrderedDict(
         },
         "swarm": {
             "title": "Docker Swarm",
+            "needs": "network_mode=bridge",
             "toggle": "swarm_enabled",
             "description": "Deploy service in Docker Swarm mode.",
             "vars": {
@@ -184,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",
@@ -198,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",
@@ -241,16 +230,11 @@ spec = OrderedDict(
             "title": "Database",
             "toggle": "database_enabled",
             "vars": {
-                "database_enabled": {
-                    "description": "Enable database configuration",
-                    "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?",

+ 9 - 4
library/compose/pihole/compose.yaml.j2

@@ -3,12 +3,12 @@ services:
     {% if not swarm_enabled %}
     container_name: {{ container_name }}
     {% endif %}
-    image: docker.io/pihole/pihole:2025.08.0
+    image: docker.io/pihole/pihole:2025.10.2
     env_file:
       - .env.pihole
-    {% if network_enabled == 'true' and network_mode == 'host' %}
+    {% if network_mode == 'host' %}
     network_mode: host
-    {% elif traefik_enabled or network_enabled == 'true' %}
+    {% else %}
     networks:
       {% if traefik_enabled %}
       {{ traefik_network }}:
@@ -24,11 +24,16 @@ services:
     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 %}
       {% endif %}
@@ -145,7 +150,7 @@ volumes:
     driver: local
 {% endif %}
 
-{% if network_mode != 'host' and (network_mode in ['bridge', 'macvlan'] or traefik_enabled) %}
+{% if network_mode != 'host' %}
 networks:
   {% if network_mode == 'macvlan' %}
   {{ network_name }}:

+ 30 - 34
library/compose/pihole/template.yaml

@@ -14,45 +14,27 @@ 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: |
     {% if swarm_enabled -%}
-    1. Create Docker Swarm secret for admin password:
-       echo "{{ webpassword }}" | docker secret create {{ webpassword_secret_name }} -
-       (Or use the generated .env.secret file)
-
-    2. Deploy to Swarm:
+    1. Deploy to Docker Swarm:
        docker stack deploy -c compose.yaml pihole
-
-    3. Verify deployment:
-       docker service ls
-       docker service logs pihole_{{ service_name }}
-
-    4. Access web interface:
-       {% if network_mode == 'macvlan' -%}https://{{ network_macvlan_ipv4_address }}/admin/login
-       {%- elif traefik_enabled == True -%}https://{{ traefik_host }}/admin/login
-       {%- else -%}https://localhost:{{ ports_https }}/admin/login{%- endif %}
-
-    5. Login password: Stored in Docker secret '{{ webpassword_secret_name }}'
-
-    6. Configure devices to use your swarm node's IP address as DNS server
+    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. Start: docker compose up -d
-
-    2. Access web interface:
-       {% if network_mode == 'macvlan' -%}https://{{ network_macvlan_ipv4_address }}/admin/login
-       {%- elif traefik_enabled == True -%}https://{{ traefik_host }}/admin/login
-       {%- else -%}https://localhost:{{ ports_https }}/admin/login{%- endif %}
-
-    3. Login password: {{ webpassword }}
-
-    4. Configure devices to use your host's IP address as DNS server
+    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:
@@ -73,34 +55,48 @@ spec:
         autogenerated: true
   traefik:
     vars:
+      traefik_enabled:
+        needs: "network_mode=bridge"
       traefik_host:
         default: "pihole.home.arpa"
   network:
     vars:
       network_mode:
-        extra: "If you need DHCP functionality, use 'host' or 'macvlan' mode"
+        extra: >
+          If you need DHCP functionality, use 'host' or 'macvlan' mode.
+          NOTE: Swarm only supports 'bridge' mode!"
       network_name:
         default: "pihole_network"
   ports:
-    needs: "network_mode=bridge"
     vars:
+      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
-        extra: "Only used if Traefik is not enabled"
+        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

+ 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"