Эх сурвалжийг харах

Merge branch 'release/v0.0.7' into release/v0.1.0

xcad 6 сар өмнө
parent
commit
7f6864262c

+ 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
   - Added `--all` flag to `show` and `generate` commands
   - Shows all variables/sections regardless of needs satisfaction
   - Shows all variables/sections regardless of needs satisfaction
   - Useful for debugging and viewing complete template structure
   - 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
 - Storage Configuration for Docker Compose
   - New `storage` section in compose module spec
   - New `storage` section in compose module spec
   - New `config` 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
           # Check required fields
           if variable.value is None:
           if variable.value is None:
+            # Optional variables can be None/empty
+            if hasattr(variable, 'optional') and variable.optional:
+              continue
             if variable.is_required():
             if variable.is_required():
               errors.append(f"{section.key}.{var_name} (required - no default provided)")
               errors.append(f"{section.key}.{var_name} (required - no default provided)")
             continue
             continue
@@ -645,7 +648,7 @@ class VariableCollection:
           'prompt': other_var.prompt,
           'prompt': other_var.prompt,
           'options': other_var.options,
           'options': other_var.options,
           'sensitive': other_var.sensitive,
           'sensitive': other_var.sensitive,
-          'extra': other_var.extra
+          'extra': other_var.extra,
         }
         }
         
         
         # Add fields that were explicitly provided, even if falsy/empty
         # Add fields that were explicitly provided, even if falsy/empty
@@ -653,6 +656,12 @@ class VariableCollection:
           if field in other_var._explicit_fields:
           if field in other_var._explicit_fields:
             update[field] = value
             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)
         # 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

+ 14 - 0
cli/core/variable.py

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

+ 53 - 93
cli/modules/compose.py

@@ -133,24 +133,69 @@ spec = OrderedDict(
       "swarm": {
       "swarm": {
         "title": "Docker Swarm",
         "title": "Docker Swarm",
         "toggle": "swarm_enabled",
         "toggle": "swarm_enabled",
-        "description": "Deploy service in Docker Swarm mode with replicas.",
+        "description": "Deploy service in Docker Swarm mode.",
         "vars": {
         "vars": {
           "swarm_enabled": {
           "swarm_enabled": {
             "description": "Enable Docker Swarm mode",
             "description": "Enable Docker Swarm mode",
             "type": "bool",
             "type": "bool",
             "default": False,
             "default": False,
           },
           },
+          "swarm_placement_mode": {
+            "description": "Swarm placement mode",
+            "type": "enum",
+            "options": ["replicated", "global"],
+            "default": "replicated",
+            "extra": "replicated=run specific number of tasks, global=run one task per node",
+          },
           "swarm_replicas": {
           "swarm_replicas": {
-            "description": "Number of replicas in Swarm",
+            "description": "Number of replicas",
             "type": "int",
             "type": "int",
             "default": 1,
             "default": 1,
+            "needs": "swarm_placement_mode=replicated",
           },
           },
-          "swarm_placement": {
-            "description": "Swarm placement mode or node constraint",
+          "swarm_placement_host": {
+            "description": "Target hostname for placement constraint",
             "type": "str",
             "type": "str",
-            "default": "replicated",
-            "extra": "Options: 'replicated', 'global', or 'node.hostname==myhost' for custom placement",
-          }
+            "default": "",
+            "optional": True,
+            "needs": "swarm_placement_mode=replicated",
+            "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",
+          },
         },
         },
       },
       },
       "database": {
       "database": {
@@ -273,92 +318,7 @@ spec = OrderedDict(
             "sensitive": True,
             "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",
-          },
-        },
-      },
+      }
     }
     }
   )
   )
 
 

+ 56 - 8
library/compose/traefik/compose.yaml.j2

@@ -14,8 +14,18 @@ services:
     {% endif %}
     {% endif %}
     volumes:
     volumes:
       - /var/run/docker.sock:/var/run/docker.sock:ro
       - /var/run/docker.sock:/var/run/docker.sock:ro
+      {% if not swarm_enabled %}
       - ./config/:/etc/traefik/:ro
       - ./config/:/etc/traefik/:ro
       - ./certs/:/var/traefik/certs/:rw
       - ./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 traefik_tls_enabled %}
       {% if not swarm_enabled %}
       {% if not swarm_enabled %}
       - ./.env.secret:/.env.secret:ro
       - ./.env.secret:/.env.secret:ro
@@ -23,6 +33,17 @@ services:
     env_file:
     env_file:
       - ./.env
       - ./.env
     {% endif %}
     {% 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:
     environment:
       - TZ={{ container_timezone }}
       - TZ={{ container_timezone }}
     healthcheck:
     healthcheck:
@@ -43,27 +64,48 @@ services:
         mode: 0400
         mode: 0400
     {% endif %}
     {% endif %}
     deploy:
     deploy:
-      {% if swarm_placement in ['global', 'replicated'] %}
-      mode: {{ swarm_placement }}
-      {% if swarm_placement == 'replicated' %}
+      mode: {{ swarm_placement_mode }}
+      {% if swarm_placement_mode == 'replicated' %}
       replicas: {{ swarm_replicas }}
       replicas: {{ swarm_replicas }}
       {% endif %}
       {% endif %}
-      {% else %}
-      mode: replicated
-      replicas: {{ swarm_replicas }}
+      {% if swarm_placement_host %}
       placement:
       placement:
         constraints:
         constraints:
-          - {{ swarm_placement }}
+          - node.hostname == {{ swarm_placement_host }}
       {% endif %}
       {% endif %}
     {% else %}
     {% else %}
     restart: {{ restart_policy }}
     restart: {{ restart_policy }}
     {% endif %}
     {% 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:
 secrets:
   {{ traefik_tls_acme_secret_name }}:
   {{ traefik_tls_acme_secret_name }}:
     file: ./.env.secret
     file: ./.env.secret
 {% endif %}
 {% endif %}
+{% endif %}
 
 
 {% if network_enabled %}
 {% if network_enabled %}
 networks:
 networks:
@@ -71,6 +113,12 @@ networks:
     {% if network_external %}
     {% if network_external %}
     external: true
     external: true
     {% else %}
     {% else %}
+    {% if swarm_enabled %}
+    driver: overlay
+    attachable: true
+    {% else %}
     driver: bridge
     driver: bridge
     {% endif %}
     {% endif %}
+    name: {{ network_name }}
+    {% endif %}
 {% endif %}
 {% endif %}

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

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