ソースを参照

merge: release/v0.0.7 into release/v0.1.0

xcad 4 ヶ月 前
コミット
c9896208f5

+ 14 - 0
CHANGELOG.md

@@ -8,6 +8,19 @@ 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
@@ -38,6 +51,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 - 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

+ 3 - 2
cli/__main__.py

@@ -116,9 +116,10 @@ def init_app() -> None:
     logger.debug(f"Discovering modules in {modules_path}")
     
     for finder, name, ispkg in pkgutil.iter_modules([str(modules_path)]):
-      if not ispkg and not name.startswith('_') and name != 'base':
+      # Import both module files and packages (for multi-schema modules)
+      if not name.startswith('_') and name != 'base':
         try:
-          logger.debug(f"Importing module: {name}")
+          logger.debug(f"Importing module: {name} ({'package' if ispkg else 'file'})")
           importlib.import_module(f"cli.modules.{name}")
         except ImportError as e:
           error_info = f"Import failed for '{name}': {str(e)}"

+ 45 - 18
cli/core/collection.py

@@ -144,11 +144,12 @@ class VariableCollection:
   
   @staticmethod
   def _parse_need(need_str: str) -> tuple[str, Optional[Any]]:
-    """Parse a need string into variable name and expected value.
+    """Parse a need string into variable name and expected value(s).
     
-    Supports two formats:
-    1. New format: "variable_name=value" - checks if variable equals value
-    2. Old format (backwards compatibility): "section_name" - checks if section is enabled
+    Supports three formats:
+    1. New format with multiple values: "variable_name=value1,value2" - checks if variable equals any value
+    2. New format with single value: "variable_name=value" - checks if variable equals value
+    3. Old format (backwards compatibility): "section_name" - checks if section is enabled
     
     Args:
         need_str: Need specification string
@@ -156,17 +157,26 @@ class VariableCollection:
     Returns:
         Tuple of (variable_or_section_name, expected_value)
         For old format, expected_value is None (means check section enabled)
-        For new format, expected_value is the string value after '='
+        For new format, expected_value is the string value(s) after '=' (string or list)
     
     Examples:
         "traefik_enabled=true" -> ("traefik_enabled", "true")
         "storage_mode=nfs" -> ("storage_mode", "nfs")
+        "network_mode=bridge,macvlan" -> ("network_mode", ["bridge", "macvlan"])
         "traefik" -> ("traefik", None)  # Old format: section name
     """
     if '=' in need_str:
-      # New format: variable=value
+      # New format: variable=value or variable=value1,value2
       parts = need_str.split('=', 1)
-      return (parts[0].strip(), parts[1].strip())
+      var_name = parts[0].strip()
+      value_part = parts[1].strip()
+      
+      # Check if multiple values are provided (comma-separated)
+      if ',' in value_part:
+        values = [v.strip() for v in value_part.split(',')]
+        return (var_name, values)
+      else:
+        return (var_name, value_part)
     else:
       # Old format: section name (backwards compatibility)
       return (need_str.strip(), None)
@@ -175,7 +185,7 @@ class VariableCollection:
     """Check if a single need condition is satisfied.
     
     Args:
-        need_str: Need specification ("variable=value" or "section_name")
+        need_str: Need specification ("variable=value", "variable=value1,value2" or "section_name")
         
     Returns:
         True if need is satisfied, False otherwise
@@ -190,24 +200,41 @@ class VariableCollection:
         return False
       return section.is_enabled()
     else:
-      # New format: check if variable has expected value
+      # New format: check if variable has expected value(s)
       variable = self._variable_map.get(var_or_section)
       if not variable:
         logger.warning(f"Need references missing variable '{var_or_section}'")
         return False
       
-      # Convert both values for comparison
+      # Convert actual value for comparison
       try:
         actual_value = variable.convert(variable.value)
-        # Convert expected value using variable's type
-        expected_converted = variable.convert(expected_value)
-        
-        # Handle boolean comparisons specially
-        if variable.type == "bool":
-          return bool(actual_value) == bool(expected_converted)
         
-        # String comparison for other types
-        return str(actual_value) == str(expected_converted) if actual_value is not None else False
+        # Handle multiple expected values (comma-separated in needs)
+        if isinstance(expected_value, list):
+          # Check if actual value matches any of the expected values
+          for expected in expected_value:
+            expected_converted = variable.convert(expected)
+            
+            # Handle boolean comparisons specially
+            if variable.type == "bool":
+              if bool(actual_value) == bool(expected_converted):
+                return True
+            else:
+              # String comparison for other types
+              if actual_value is not None and str(actual_value) == str(expected_converted):
+                return True
+          return False  # None of the expected values matched
+        else:
+          # Single expected value (original behavior)
+          expected_converted = variable.convert(expected_value)
+          
+          # Handle boolean comparisons specially
+          if variable.type == "bool":
+            return bool(actual_value) == bool(expected_converted)
+          
+          # String comparison for other types
+          return str(actual_value) == str(expected_converted) if actual_value is not None else False
       except Exception as e:
         logger.debug(f"Failed to compare need '{need_str}': {e}")
         return False

+ 6 - 2
cli/core/display.py

@@ -448,11 +448,15 @@ class DisplayManager:
                 header_text = f"[bold]{section.title}{required_text}{disabled_text}[/bold]"
             variables_table.add_row(header_text, "", "", "")
             for var_name, variable in section.variables.items():
+                # 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 template.variables.is_variable_satisfied(var_name):
+                if not show_all and not var_satisfied:
                     continue
                 
-                row_style = "bright_black" if is_dimmed else None
+                # 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
                 # If origin is 'config' and original value differs from current, show: original → config_value

+ 29 - 0
cli/modules/compose/__init__.py

@@ -0,0 +1,29 @@
+"""Docker Compose module with multi-schema support."""
+
+from ...core.module import Module
+from ...core.registry import registry
+
+# Import schema specifications
+from .spec_v1_0 import spec as spec_1_0
+from .spec_v1_1 import spec as spec_1_1
+
+# Schema version mapping
+SCHEMAS = {
+  "1.0": spec_1_0,
+  "1.1": spec_1_1,
+}
+
+# Default spec points to latest version
+spec = spec_1_1
+
+
+class ComposeModule(Module):
+  """Docker Compose module."""
+
+  name = "compose"
+  description = "Manage Docker Compose configurations"
+  schema_version = "1.1"  # Current schema version supported by this module
+  schemas = SCHEMAS  # Available schema versions
+
+
+registry.register(ComposeModule)

+ 282 - 0
cli/modules/compose/spec_v1_0.py

@@ -0,0 +1,282 @@
+"""Compose module schema version 1.0 - Original specification."""
+from collections import OrderedDict
+
+spec = OrderedDict(
+    {
+      "general": {
+        "title": "General",
+        "vars": {
+          "service_name": {
+            "description": "Service name",
+            "type": "str",
+          },
+          "container_name": {
+            "description": "Container name",
+            "type": "str",
+          },
+          "container_hostname": {
+            "description": "Container internal hostname",
+            "type": "str",
+          },
+          "container_timezone": {
+            "description": "Container timezone (e.g., Europe/Berlin)",
+            "type": "str",
+            "default": "UTC",
+          },
+          "user_uid": {
+            "description": "User UID for container process",
+            "type": "int",
+            "default": 1000,
+          },
+          "user_gid": {
+            "description": "User GID for container process",
+            "type": "int",
+            "default": 1000,
+          },
+          "container_loglevel": {
+            "description": "Container log level",
+            "type": "enum",
+            "options": ["debug", "info", "warn", "error"],
+            "default": "info",
+          },
+          "restart_policy": {
+            "description": "Container restart policy",
+            "type": "enum",
+            "options": ["unless-stopped", "always", "on-failure", "no"],
+            "default": "unless-stopped",
+          },
+        },
+      },
+      "network": {
+        "title": "Network",
+        "toggle": "network_enabled",
+        "vars": {
+          "network_enabled": {
+            "description": "Enable custom network block",
+            "type": "bool",
+            "default": False,
+          },
+          "network_name": {
+            "description": "Docker network name",
+            "type": "str",
+            "default": "bridge",
+          },
+          "network_external": {
+            "description": "Use existing Docker network",
+            "type": "bool",
+            "default": True,
+          },
+        },
+      },
+      "ports": {
+        "title": "Ports",
+        "toggle": "ports_enabled",
+        "vars": {
+          "ports_enabled": {
+            "description": "Expose ports via 'ports' mapping",
+            "type": "bool",
+            "default": True,
+          }
+        },
+      },
+      "traefik": {
+        "title": "Traefik",
+        "toggle": "traefik_enabled",
+        "description": "Traefik routes external traffic to your service.",
+        "vars": {
+          "traefik_enabled": {
+            "description": "Enable Traefik reverse proxy integration",
+            "type": "bool",
+            "default": False,
+          },
+          "traefik_network": {
+            "description": "Traefik network name",
+            "type": "str",
+            "default": "traefik",
+          },
+          "traefik_host": {
+            "description": "Domain name for your service (e.g., app.example.com)",
+            "type": "str",
+          },
+          "traefik_entrypoint": {
+            "description": "HTTP entrypoint (non-TLS)",
+            "type": "str",
+            "default": "web",
+          },
+        },
+      },
+      "traefik_tls": {
+        "title": "Traefik TLS/SSL",
+        "toggle": "traefik_tls_enabled",
+        "needs": "traefik",
+        "description": "Enable HTTPS/TLS for Traefik with certificate management.",
+        "vars": {
+          "traefik_tls_enabled": {
+            "description": "Enable HTTPS/TLS",
+            "type": "bool",
+            "default": True,
+          },
+          "traefik_tls_entrypoint": {
+            "description": "TLS entrypoint",
+            "type": "str",
+            "default": "websecure",
+          },
+          "traefik_tls_certresolver": {
+            "description": "Traefik certificate resolver name",
+            "type": "str",
+            "default": "cloudflare",
+          },
+        },
+      },
+      "swarm": {
+        "title": "Docker Swarm",
+        "toggle": "swarm_enabled",
+        "description": "Deploy service in Docker Swarm mode with replicas.",
+        "vars": {
+          "swarm_enabled": {
+            "description": "Enable Docker Swarm mode",
+            "type": "bool",
+            "default": False,
+          },
+          "swarm_replicas": {
+            "description": "Number of replicas in Swarm",
+            "type": "int",
+            "default": 1,
+          },
+          "swarm_placement_mode": {
+            "description": "Swarm placement mode",
+            "type": "enum",
+            "options": ["global", "replicated"],
+            "default": "replicated"
+          },
+          "swarm_placement_host": {
+            "description": "Limit placement to specific node",
+            "type": "str",
+          }
+        },
+      },
+      "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",
+          },
+          "database_external": {
+            "description": "Use an external database server?",
+            "extra": "If 'no', a database container will be created in the compose project.",
+            "type": "bool",
+            "default": False,
+          },
+          "database_host": {
+            "description": "Database host",
+            "type": "str",
+            "default": "database",
+          },
+          "database_port": {
+            "description": "Database port",
+            "type": "int"
+          },
+          "database_name": {
+            "description": "Database name",
+            "type": "str",
+          },
+          "database_user": {
+            "description": "Database user",
+            "type": "str",
+          },
+          "database_password": {
+            "description": "Database password",
+            "type": "str",
+            "default": "",
+            "sensitive": True,
+            "autogenerated": True,
+          },
+        },
+      },
+      "email": {
+        "title": "Email Server",
+        "toggle": "email_enabled",
+        "description": "Configure email server for notifications and user management.",
+        "vars": {
+          "email_enabled": {
+            "description": "Enable email server configuration",
+            "type": "bool",
+            "default": False,
+          },
+          "email_host": {
+            "description": "SMTP server hostname",
+            "type": "str",
+          },
+          "email_port": {
+            "description": "SMTP server port",
+            "type": "int",
+            "default": 587,
+          },
+          "email_username": {
+            "description": "SMTP username",
+            "type": "str",
+          },
+          "email_password": {
+            "description": "SMTP password",
+            "type": "str",
+            "sensitive": True,
+          },
+          "email_from": {
+            "description": "From email address",
+            "type": "str",
+          },
+          "email_use_tls": {
+            "description": "Use TLS encryption",
+            "type": "bool",
+            "default": True,
+          },
+          "email_use_ssl": {
+            "description": "Use SSL encryption",
+            "type": "bool",
+            "default": False,
+          }
+        },
+      },
+      "authentik": {
+        "title": "Authentik SSO",
+        "toggle": "authentik_enabled",
+        "description": "Integrate with Authentik for Single Sign-On authentication.",
+        "vars": {
+          "authentik_enabled": {
+            "description": "Enable Authentik SSO integration",
+            "type": "bool",
+            "default": False,
+          },
+          "authentik_url": {
+            "description": "Authentik base URL (e.g., https://auth.example.com)",
+            "type": "str",
+          },
+          "authentik_slug": {
+            "description": "Authentik application slug",
+            "type": "str",
+          },
+          "authentik_client_id": {
+            "description": "OAuth client ID from Authentik provider",
+            "type": "str",
+          },
+          "authentik_client_secret": {
+            "description": "OAuth client secret from Authentik provider",
+            "type": "str",
+            "sensitive": True,
+          },
+        },
+      },
+    }
+  )
+
+

+ 42 - 14
cli/modules/compose.py → cli/modules/compose/spec_v1_1.py

@@ -1,7 +1,11 @@
-from collections import OrderedDict
+"""Compose module schema version 1.1 - Enhanced with network_mode and improved swarm.
 
-from ..core.module import Module
-from ..core.registry import registry
+Changes from 1.0:
+- network: Added network_mode (bridge/host/macvlan) with conditional macvlan fields
+- swarm: Added volume modes (local/mount/nfs) and conditional placement constraints
+- traefik_tls: Updated needs format from 'traefik' to 'traefik_enabled=true'
+"""
+from collections import OrderedDict
 
 spec = OrderedDict(
     {
@@ -58,15 +62,48 @@ spec = OrderedDict(
             "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",
             "type": "str",
             "default": "bridge",
+            "needs": "network_mode=bridge,macvlan",
           },
           "network_external": {
             "description": "Use existing Docker network",
             "type": "bool",
             "default": True,
+            "needs": "network_mode=bridge,macvlan",
+          },
+          "network_macvlan_ipv4_address": {
+            "description": "Static IP address for container",
+            "type": "str",
+            "default": "192.168.1.253",
+            "needs": "network_mode=macvlan",
+          },
+          "network_macvlan_parent_interface": {
+            "description": "Host network interface name",
+            "type": "str",
+            "default": "eth0",
+            "needs": "network_mode=macvlan",
+          },
+          "network_macvlan_subnet": {
+            "description": "Network subnet in CIDR notation",
+            "type": "str",
+            "default": "192.168.1.0/24",
+            "needs": "network_mode=macvlan",
+          },
+          "network_macvlan_gateway": {
+            "description": "Network gateway IP address",
+            "type": "str",
+            "default": "192.168.1.1",
+            "needs": "network_mode=macvlan",
           },
         },
       },
@@ -106,7 +143,7 @@ spec = OrderedDict(
             "default": "web",
           },
         },
-      },  
+      },
       "traefik_tls": {
         "title": "Traefik TLS/SSL",
         "toggle": "traefik_tls_enabled",
@@ -318,17 +355,8 @@ spec = OrderedDict(
             "sensitive": True,
           },
         },
-      }
+      },
     }
   )
 
 
-class ComposeModule(Module):
-  """Docker Compose module."""
-
-  name = "compose"
-  description = "Manage Docker Compose configurations"
-  schema_version = "1.1"  # Current schema version supported by this module
-
-
-registry.register(ComposeModule)

+ 21 - 11
library/compose/pihole/compose.yaml.j2

@@ -2,17 +2,21 @@ services:
   {{ service_name | default('pihole') }}:
     container_name: {{ container_name | default('pihole') }}
     image: docker.io/pihole/pihole:2025.08.0
-    {% if traefik_enabled or macvlan_enabled %}
+    {% if network_enabled and network_mode == 'host' %}
+    network_mode: host
+    {% elif traefik_enabled or (network_enabled and network_mode == 'macvlan') %}
     networks:
       {% if traefik_enabled %}
       {{ traefik_network | default('traefik') }}:
       {% endif %}
-      {% if macvlan_enabled %}
-      pihole_macvlan:
-        ipv4_address: {{ macvlan_ipv4_address }}
+      {% if network_enabled and network_mode == 'macvlan' %}
+      {{ network_name | default('pihole_net') }}:
+        ipv4_address: {{ network_macvlan_ipv4_address }}
+      {% elif network_enabled and network_mode == 'bridge' %}
+      {{ network_name | default('bridge') }}:
       {% endif %}
     {% endif %}
-    {% if ports_enabled and not macvlan_enabled and (not traefik_enabled or dns_enabled or dhcp_enabled) %}
+    {% if ports_enabled and not (network_enabled and network_mode in ['host', 'macvlan']) and (not traefik_enabled or dns_enabled or dhcp_enabled) %}
     ports:
       {% if not traefik_enabled %}
       - "{{ ports_http }}:80/tcp"
@@ -56,17 +60,23 @@ volumes:
   config_pihole:
     driver: local
 
-{% if traefik_enabled or macvlan_enabled %}
+{% if network_enabled or traefik_enabled %}
 networks:
-  {% if macvlan_enabled %}
-  pihole_macvlan:
+  {% if network_enabled and network_mode == 'macvlan' %}
+  {{ network_name | default('pihole_net') }}:
     driver: macvlan
     driver_opts:
-      parent: {{ macvlan_parent_interface }}
+      parent: {{ network_macvlan_parent_interface }}
     ipam:
       config:
-        - subnet: {{ macvlan_subnet }}
-          gateway: {{ macvlan_gateway }}
+        - subnet: {{ network_macvlan_subnet }}
+          gateway: {{ network_macvlan_gateway }}
+  {% elif network_enabled and network_mode == 'bridge' and network_external %}
+  {{ network_name | default('bridge') }}:
+    external: true
+  {% elif network_enabled and network_mode == 'bridge' and not network_external %}
+  {{ network_name | default('bridge') }}:
+    driver: bridge
   {% endif %}
   {% if traefik_enabled %}
   {{ traefik_network | default('traefik') }}:

+ 10 - 26
library/compose/pihole/template.yaml

@@ -1,5 +1,6 @@
 ---
 kind: compose
+schema: "1.1"
 metadata:
   name: Pihole
   description: >
@@ -24,8 +25,8 @@ metadata:
     1. Start: docker compose up -d
 
     2. Access web interface:
-       {% if macvlan_enabled -%}
-       http://{{ macvlan_ipv4_address }}
+       {% 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 -%}
@@ -34,8 +35,8 @@ metadata:
 
     3. Login password: {{ pihole_webpassword }}
 
-    {% if macvlan_enabled -%}
-    4. Configure devices to use {{ macvlan_ipv4_address }} as DNS server
+    {% 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)
@@ -67,29 +68,12 @@ spec:
     vars:
       traefik_host:
         default: "pihole.home.arpa"
-  macvlan:
-    toggle: macvlan_enabled
+  network:
     vars:
-      macvlan_enabled:
-        type: bool
-        description: "Enable macvlan network mode"
-        default: false
-      macvlan_ipv4_address:
-        type: str
-        description: "Static IP address for Pi-hole"
-        default: "192.168.1.253"
-      macvlan_parent_interface:
-        type: str
-        description: "Host network interface name"
-        default: "eth0"
-      macvlan_subnet:
-        type: str
-        description: "Network subnet in CIDR notation"
-        default: "192.168.1.0/24"
-      macvlan_gateway:
-        type: str
-        description: "Network gateway IP address"
-        default: "192.168.1.1"
+      network_enabled:
+        default: true
+      network_name:
+        default: "pihole_network"
   ports:
     vars:
       ports_http:

+ 5 - 0
library/compose/traefik/template.yaml

@@ -1,5 +1,6 @@
 ---
 kind: compose
+schema: "1.1"
 metadata:
   name: Traefik
   description: >
@@ -137,8 +138,12 @@ spec:
     vars:
       network_enabled:
         default: true
+      network_mode:
+        default: "bridge"
       network_name:
         default: "proxy"
+      network_external:
+        default: false
   authentik:
     title: Authentik Middleware
     description: Enable Authentik SSO integration for Traefik