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
This commit is contained in:
xcad
2025-10-15 12:03:17 +02:00
parent d13bcc8bae
commit b5587c9902
6 changed files with 123 additions and 99 deletions
+5
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 %}
-4
View File
@@ -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