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

feat(cli): add optional variables and swarm volume configuration

- Add 'optional' property to variables allowing empty/None values
- Implement swarm volume configuration with local, mount, and NFS backends
- Fix Variable.to_dict() to preserve optional flag during serialization
- Update traefik template with swarm configs and volume management
- Add swarm volume variables to compose module spec
xcad 4 месяцев назад
Родитель
Сommit
b5587c9902

+ 5 - 0
CHANGELOG.md

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

+ 10 - 1
cli/core/collection.py

@@ -539,6 +539,9 @@ class VariableCollection:
           
           # Check required fields
           if variable.value is None:
+            # Optional variables can be None/empty
+            if hasattr(variable, 'optional') and variable.optional:
+              continue
             if variable.is_required():
               errors.append(f"{section.key}.{var_name} (required - no default provided)")
             continue
@@ -645,7 +648,7 @@ class VariableCollection:
           'prompt': other_var.prompt,
           'options': other_var.options,
           'sensitive': other_var.sensitive,
-          'extra': other_var.extra
+          'extra': other_var.extra,
         }
         
         # Add fields that were explicitly provided, even if falsy/empty
@@ -653,6 +656,12 @@ class VariableCollection:
           if field in other_var._explicit_fields:
             update[field] = value
         
+        # For boolean flags, only copy if explicitly provided in other
+        # This prevents False defaults from overriding True values
+        for bool_field in ('optional', 'autogenerated', 'required'):
+          if bool_field in other_var._explicit_fields:
+            update[bool_field] = getattr(other_var, bool_field)
+        
         # Special handling for value/default (allow explicit null to clear)
         if 'value' in other_var._explicit_fields:
           update['value'] = other_var.value

+ 14 - 0
cli/core/variable.py

@@ -55,6 +55,8 @@ class Variable:
     self.autogenerated: bool = data.get("autogenerated", False)
     # Flag indicating this variable is required even when section is disabled
     self.required: bool = data.get("required", False)
+    # Flag indicating this variable can be empty/optional
+    self.optional: bool = data.get("optional", False)
     # Original value before config override (used for display)
     self.original_value: Optional[Any] = data.get("original_value")
     # Variable dependencies - can be string or list of strings in format "var_name=value"
@@ -142,6 +144,10 @@ class Variable:
     if self.autogenerated and (converted is None or (isinstance(converted, str) and (converted == "" or converted == "*auto"))):
       return None  # Signal that auto-generation should happen
     
+    # Allow empty values for optional variables
+    if self.optional and (converted is None or (isinstance(converted, str) and converted == "")):
+      return None
+    
     # Check if this is a required field and the value is empty
     if check_required and self.is_required():
       if converted is None or (isinstance(converted, str) and converted == ""):
@@ -232,6 +238,8 @@ class Variable:
       result['autogenerated'] = True
     if self.required:
       result['required'] = True
+    if self.optional:
+      result['optional'] = True
     if self.options is not None:  # Allow empty list
       result['options'] = self.options
     
@@ -339,11 +347,16 @@ class Variable:
     - It has an explicit 'required: true' flag (highest precedence)
     - OR it doesn't have a default value (value is None)
       AND it's not marked as autogenerated (which can be empty and generated later)
+      AND it's not marked as optional (which can be empty)
       AND it's not a boolean type (booleans default to False if not set)
     
     Returns:
         True if the variable must have a non-empty value, False otherwise
     """
+    # Optional variables can always be empty
+    if self.optional:
+      return False
+    
     # Explicit required flag takes highest precedence
     if self.required:
       # But autogenerated variables can still be empty (will be generated later)
@@ -392,6 +405,7 @@ class Variable:
       'extra': self.extra,
       'autogenerated': self.autogenerated,
       'required': self.required,
+      'optional': self.optional,
       'original_value': self.original_value,
       'needs': self.needs.copy() if self.needs else None,
     }

+ 40 - 88
cli/modules/compose.py

@@ -155,9 +155,46 @@ spec = OrderedDict(
           },
           "swarm_placement_host": {
             "description": "Target hostname for placement constraint",
-            "type": "hostname",
+            "type": "str",
+            "default": "",
+            "optional": True,
             "needs": "swarm_placement_mode=replicated",
-            "extra": "Constrains service to run on specific node by hostname",
+            "extra": "Constrains service to run on specific node by hostname (optional)",
+          },
+          "swarm_volume_mode": {
+            "description": "Swarm volume storage backend",
+            "type": "enum",
+            "options": ["local", "mount", "nfs"],
+            "default": "local",
+            "extra": "WARNING: 'local' only works on single-node deployments!",
+          },
+          "swarm_volume_mount_path": {
+            "description": "Host path for bind mount",
+            "type": "str",
+            "default": "/mnt/storage",
+            "needs": "swarm_volume_mode=mount",
+            "extra": "Useful for shared/replicated storage",
+          },
+          "swarm_volume_nfs_server": {
+            "description": "NFS server address",
+            "type": "str",
+            "default": "192.168.1.1",
+            "needs": "swarm_volume_mode=nfs",
+            "extra": "IP address or hostname of NFS server",
+          },
+          "swarm_volume_nfs_path": {
+            "description": "NFS export path",
+            "type": "str",
+            "default": "/export",
+            "needs": "swarm_volume_mode=nfs",
+            "extra": "Path to NFS export on the server",
+          },
+          "swarm_volume_nfs_options": {
+            "description": "NFS mount options",
+            "type": "str",
+            "default": "rw,nolock,soft",
+            "needs": "swarm_volume_mode=nfs",
+            "extra": "Comma-separated NFS mount options",
           },
         },
       },
@@ -281,92 +318,7 @@ spec = OrderedDict(
             "sensitive": True,
           },
         },
-      },
-      "storage": {
-        "title": "Storage Configuration",
-        "toggle": "storage_enabled",
-        "description": "Configure persistent storage volumes",
-        "vars": {
-          "storage_enabled": {
-            "description": "Enable storage configuration",
-            "type": "bool",
-            "default": False,
-          },
-          "storage_mode": {
-            "description": "Storage backend",
-            "type": "enum",
-            "options": ["local", "mount", "nfs", "glusterfs"],
-            "default": "local",
-            "extra": "local=named volume, mount=bind mount, nfs=network filesystem, glusterfs=distributed storage",
-          },
-          "storage_host": {
-            "description": "Storage host/volume identifier",
-            "type": "str",
-            "extra": "local: volume name, mount: host path, nfs: server IP, glusterfs: server hostname",
-          },
-          "storage_path": {
-            "description": "NFS export path",
-            "type": "str",
-            "default": "/mnt/nfs",
-            "extra": "Only used when storage_mode=nfs",
-          },
-          "storage_nfs_options": {
-            "description": "NFS mount options",
-            "type": "str",
-            "default": "rw,nolock,soft",
-            "extra": "Only used when storage_mode=nfs. Comma-separated options.",
-          },
-          "storage_glusterfs_volume": {
-            "description": "GlusterFS volume name",
-            "type": "str",
-            "default": "gv0",
-            "extra": "Only used when storage_mode=glusterfs",
-          },
-        },
-      },
-      "config": {
-        "title": "Config Storage",
-        "toggle": "config_enabled",
-        "description": "Configure persistent storage for configuration files",
-        "vars": {
-          "config_enabled": {
-            "description": "Enable config storage configuration",
-            "type": "bool",
-            "default": False,
-          },
-          "config_mode": {
-            "description": "Storage backend for configuration",
-            "type": "enum",
-            "options": ["local", "mount", "nfs", "glusterfs"],
-            "default": "mount",
-            "extra": "local=named volume, mount=bind mount, nfs=network filesystem, glusterfs=distributed storage",
-          },
-          "config_host": {
-            "description": "Config storage host/volume identifier",
-            "type": "str",
-            "default": "./config",
-            "extra": "local: volume name, mount: host path, nfs: server IP, glusterfs: server hostname",
-          },
-          "config_path": {
-            "description": "NFS export path for config",
-            "type": "str",
-            "default": "/mnt/nfs/config",
-            "extra": "Only used when config_mode=nfs",
-          },
-          "config_nfs_options": {
-            "description": "NFS mount options for config",
-            "type": "str",
-            "default": "rw,nolock,soft",
-            "extra": "Only used when config_mode=nfs. Comma-separated options.",
-          },
-          "config_glusterfs_volume": {
-            "description": "GlusterFS volume name for config",
-            "type": "str",
-            "default": "gv0",
-            "extra": "Only used when config_mode=glusterfs",
-          },
-        },
-      },
+      }
     }
   )
 

+ 54 - 6
library/compose/traefik/compose.yaml.j2

@@ -14,8 +14,18 @@ services:
     {% endif %}
     volumes:
       - /var/run/docker.sock:/var/run/docker.sock:ro
+      {% if not swarm_enabled %}
       - ./config/:/etc/traefik/:ro
       - ./certs/:/var/traefik/certs/:rw
+      {% else %}
+      {% if swarm_volume_mode == 'mount' %}
+      - {{ swarm_volume_mount_path }}:/var/traefik/certs/:rw
+      {% elif swarm_volume_mode == 'local' %}
+      - traefik_certs:/var/traefik/certs/:rw
+      {% elif swarm_volume_mode == 'nfs' %}
+      - traefik_certs:/var/traefik/certs/:rw
+      {% endif %}
+      {% endif %}
       {% if traefik_tls_enabled %}
       {% if not swarm_enabled %}
       - ./.env.secret:/.env.secret:ro
@@ -23,6 +33,17 @@ services:
     env_file:
       - ./.env
     {% endif %}
+    {% if swarm_enabled %}
+    configs:
+      - source: traefik_config
+        target: /etc/traefik/traefik.yaml
+      - source: traefik_middlewares
+        target: /etc/traefik/files/middlewares.yaml
+      - source: traefik_tls
+        target: /etc/traefik/files/tls.yaml
+      - source: traefik_external_services
+        target: /etc/traefik/files/external-services.yaml
+    {% endif %}
     environment:
       - TZ={{ container_timezone }}
     healthcheck:
@@ -43,27 +64,48 @@ services:
         mode: 0400
     {% endif %}
     deploy:
-      {% if swarm_placement_mode in ['global', 'replicated'] %}
       mode: {{ swarm_placement_mode }}
       {% if swarm_placement_mode == 'replicated' %}
       replicas: {{ swarm_replicas }}
       {% endif %}
-      {% else %}
-      mode: replicated
-      replicas: {{ swarm_replicas }}
+      {% if swarm_placement_host %}
       placement:
         constraints:
-          - {{ swarm_placement_mode }}
+          - node.hostname == {{ swarm_placement_host }}
       {% endif %}
     {% else %}
     restart: {{ restart_policy }}
     {% endif %}
 
-{% if swarm_enabled and traefik_tls_enabled %}
+{% if swarm_enabled %}
+{% if swarm_volume_mode in ['local', 'nfs'] %}
+volumes:
+  traefik_certs:
+    {% if swarm_volume_mode == 'nfs' %}
+    driver: local
+    driver_opts:
+      type: nfs
+      o: addr={{ swarm_volume_nfs_server }},{{ swarm_volume_nfs_options }}
+      device: ":{{ swarm_volume_nfs_path }}"
+    {% endif %}
+{% endif %}
+
+configs:
+  traefik_config:
+    file: ./config/traefik.yaml
+  traefik_middlewares:
+    file: ./config/files/middlewares.yaml
+  traefik_tls:
+    file: ./config/files/tls.yaml
+  traefik_external_services:
+    file: ./config/files/external-services.yaml
+
+{% if traefik_tls_enabled %}
 secrets:
   {{ traefik_tls_acme_secret_name }}:
     file: ./.env.secret
 {% endif %}
+{% endif %}
 
 {% if network_enabled %}
 networks:
@@ -71,6 +113,12 @@ networks:
     {% if network_external %}
     external: true
     {% else %}
+    {% if swarm_enabled %}
+    driver: overlay
+    attachable: true
+    {% else %}
     driver: bridge
     {% endif %}
+    name: {{ network_name }}
+    {% endif %}
 {% endif %}

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

@@ -139,10 +139,6 @@ spec:
         default: true
       network_name:
         default: "proxy"
-  swarm:
-    vars:
-      swarm_placement_mode:
-        default: "global"
   authentik:
     title: Authentik Middleware
     description: Enable Authentik SSO integration for Traefik