Jelajahi Sumber

template default value handling and auto-generation simplified

xcad 5 bulan lalu
induk
melakukan
b77ee73de3

+ 21 - 10
cli/core/display.py

@@ -253,7 +253,7 @@ class DisplayManager:
         console.print("[bold blue]Template Variables:[/bold blue]")
 
         variables_table = Table(show_header=True, header_style="bold blue")
-        variables_table.add_column("Variable", style="cyan", no_wrap=True)
+        variables_table.add_column("Variable", style="white", no_wrap=True)
         variables_table.add_column("Type", style="magenta")
         variables_table.add_column("Default", style="green")
         variables_table.add_column("Description", style="white")
@@ -264,7 +264,7 @@ class DisplayManager:
                 continue
 
             if not first_section:
-                variables_table.add_row("", "", "", "", style="dim")
+                variables_table.add_row("", "", "", "", style="bright_black")
             first_section = False
 
             # Check if section is enabled AND dependencies are satisfied
@@ -274,17 +274,28 @@ class DisplayManager:
 
             # Only show (disabled) if section has no dependencies (dependencies make it obvious)
             disabled_text = " (disabled)" if (is_dimmed and not section.needs) else ""
-            required_text = " [yellow](required)[/yellow]" if section.required else ""
-            # Add dependency information
-            needs_text = ""
-            if section.needs:
-              needs_list = ", ".join(section.needs)
-              needs_text = f" [dim](needs: {needs_list})[/dim]"
-            header_text = f"[bold dim]{section.title}{required_text}{needs_text}{disabled_text}[/bold dim]" if is_dimmed else f"[bold]{section.title}{required_text}{needs_text}{disabled_text}[/bold]"
+            
+            # For disabled sections, make entire heading bold and dim (don't include colored markup inside)
+            if is_dimmed:
+                # Build text without internal markup, then wrap entire thing in bold bright_black (dimmed appearance)
+                required_part = " (required)" if section.required else ""
+                needs_part = ""
+                if section.needs:
+                    needs_list = ", ".join(section.needs)
+                    needs_part = f" (needs: {needs_list})"
+                header_text = f"[bold bright_black]{section.title}{required_part}{needs_part}{disabled_text}[/bold bright_black]"
+            else:
+                # For enabled sections, include the colored markup
+                required_text = " [yellow](required)[/yellow]" if section.required else ""
+                needs_text = ""
+                if section.needs:
+                    needs_list = ", ".join(section.needs)
+                    needs_text = f" [dim](needs: {needs_list})[/dim]"
+                header_text = f"[bold]{section.title}{required_text}{needs_text}{disabled_text}[/bold]"
             variables_table.add_row(header_text, "", "", "")
 
             for var_name, variable in section.variables.items():
-                row_style = "dim" if is_dimmed else None
+                row_style = "bright_black" if is_dimmed else None
                 
                 # Build default value display
                 # If origin is 'config' and original value differs from current, show: original → config_value

+ 34 - 17
cli/core/module.py

@@ -151,6 +151,9 @@ class Module(ABC):
         successful = template.variables.apply_defaults(config_defaults, "config")
         if successful:
           logger.debug(f"Applied config defaults for: {', '.join(successful)}")
+      
+      # Re-sort sections after applying config (toggle values may have changed)
+      template.variables.sort_sections()
     
     self._display_template_details(template, id)
 
@@ -160,6 +163,7 @@ class Module(ABC):
     directory: Optional[str] = Argument(None, help="Output directory (defaults to template ID)"),
     interactive: bool = Option(True, "--interactive/--no-interactive", "-i/-n", help="Enable interactive prompting for variables"),
     var: Optional[list[str]] = Option(None, "--var", "-v", help="Variable override (repeatable). Use KEY=VALUE or --var KEY VALUE"),
+    dry_run: bool = Option(False, "--dry-run", help="Preview template generation without writing files"),
     ctx: Context = None,
   ) -> None:
     """Generate from template.
@@ -179,6 +183,9 @@ class Module(ABC):
         
         # Generate with variables
         cli compose generate traefik --var traefik_enabled=false
+        
+        # Preview without writing files (dry run)
+        cli compose generate traefik --dry-run
     """
 
     logger.info(f"Starting generation for template '{id}' from module '{self.name}'")
@@ -207,6 +214,10 @@ class Module(ABC):
         successful_overrides = template.variables.apply_defaults(cli_overrides, "cli")
         if successful_overrides:
           logger.debug(f"Applied CLI overrides for: {', '.join(successful_overrides)}")
+    
+    # Re-sort sections after all overrides (toggle values may have changed)
+    if template.variables:
+      template.variables.sort_sections()
 
     self._display_template_details(template, id)
     console.print()
@@ -228,7 +239,7 @@ class Module(ABC):
       if template.variables:
         template.variables.validate_all()
       
-      rendered_files = template.render(template.variables)
+      rendered_files, variable_values = template.render(template.variables)
       
       # Safety check for render result
       if not rendered_files:
@@ -277,24 +288,30 @@ class Module(ABC):
         )
         
         # Final confirmation (only if we didn't already ask about overwriting)
-        if not dir_not_empty:
+        if not dir_not_empty and not dry_run:
           if not Confirm.ask("Generate these files?", default=True):
             console.print("[yellow]Generation cancelled.[/yellow]")
             return
       
-      # Create the output directory if it doesn't exist
-      output_dir.mkdir(parents=True, exist_ok=True)
-
-      # Write rendered files to the output directory
-      for file_path, content in rendered_files.items():
-        full_path = output_dir / file_path
-        full_path.parent.mkdir(parents=True, exist_ok=True)
-        with open(full_path, 'w', encoding='utf-8') as f:
-          f.write(content)
-        console.print(f"[green]Generated file: {file_path}[/green]")
-      
-      console.print(f"\n[green]✓ Template generated successfully in '{output_dir}'[/green]")
-      logger.info(f"Template written to directory: {output_dir}")
+      # Skip file writing in dry-run mode
+      if dry_run:
+        console.print(f"\n[yellow]✓ Dry run complete - no files were written[/yellow]")
+        console.print(f"[dim]Files would have been generated in '{output_dir}'[/dim]")
+        logger.info(f"Dry run completed for template '{id}'")
+      else:
+        # Create the output directory if it doesn't exist
+        output_dir.mkdir(parents=True, exist_ok=True)
+
+        # Write rendered files to the output directory
+        for file_path, content in rendered_files.items():
+          full_path = output_dir / file_path
+          full_path.parent.mkdir(parents=True, exist_ok=True)
+          with open(full_path, 'w', encoding='utf-8') as f:
+            f.write(content)
+          console.print(f"[green]Generated file: {file_path}[/green]")
+        
+        console.print(f"\n[green]✓ Template generated successfully in '{output_dir}'[/green]")
+        logger.info(f"Template written to directory: {output_dir}")
       
       # Display next steps if provided in template metadata
       if template.metadata.next_steps:
@@ -372,7 +389,7 @@ class Module(ABC):
     
     Examples:
         # Remove a default value
-        cli compose defaults remove service_name
+        cli compose defaults rm service_name
     """
     from .config import ConfigManager
     config = ConfigManager()
@@ -586,7 +603,7 @@ class Module(ABC):
     defaults_app = Typer(help="Manage default values for template variables")
     defaults_app.command("get", help="Get default value(s)")(module_instance.config_get)
     defaults_app.command("set", help="Set a default value")(module_instance.config_set)
-    defaults_app.command("remove", help="Remove a specific default value")(module_instance.config_remove)
+    defaults_app.command("rm", help="Remove a specific default value")(module_instance.config_remove)
     defaults_app.command("clear", help="Clear default value(s)")(module_instance.config_clear)
     defaults_app.command("list", help="Display the config for this module in YAML format")(module_instance.config_list)
     module_app.add_typer(defaults_app, name="defaults")

+ 23 - 63
cli/core/template.py

@@ -386,77 +386,37 @@ class Template:
   def _create_jinja_env(searchpath: Path) -> Environment:
     """Create standardized Jinja2 environment for consistent template processing.
     
-    Includes custom filters for generating random values:
-    - random_string(length): Generate random alphanumeric string
-    - random_hex(length): Generate random hexadecimal string
-    - random_base64(length): Generate random base64 string
-    - random_uuid: Generate a UUID4
+    Returns a simple Jinja2 environment without custom filters.
+    Variable autogeneration is handled by the render() method.
     """
-    import secrets
-    import string
-    import base64
-    import uuid
-    
-    env = Environment(
+    return Environment(
       loader=FileSystemLoader(searchpath),
       trim_blocks=True,
       lstrip_blocks=True,
       keep_trailing_newline=False,
     )
-    
-    # Add custom filters for generating random values
-    def random_string(value: str = '', length: int = 32) -> str:
-      """Generate a random alphanumeric string of specified length.
-      
-      Usage: {{ '' | random_string(64) }} or {{ 'ignored' | random_string(32) }}
-      """
-      alphabet = string.ascii_letters + string.digits
-      return ''.join(secrets.choice(alphabet) for _ in range(length))
-    
-    def pwgen(value: str = '', length: int = 50) -> str:
-      """Generate a secure random string (mimics pwgen -s).
-      
-      Default length is 50 (matching Authentik recommendation).
-      Usage: {{ '' | pwgen }} or {{ '' | pwgen(64) }}
-      """
-      alphabet = string.ascii_letters + string.digits
-      return ''.join(secrets.choice(alphabet) for _ in range(length))
-    
-    def random_hex(value: str = '', length: int = 32) -> str:
-      """Generate a random hexadecimal string of specified length.
-      
-      Usage: {{ '' | random_hex(64) }}
-      """
-      return secrets.token_hex(length // 2)
-    
-    def random_base64(value: str = '', length: int = 32) -> str:
-      """Generate a random base64 string of specified length.
-      
-      Usage: {{ '' | random_base64(64) }}
-      """
-      num_bytes = (length * 3) // 4  # Convert length to approximate bytes
-      return base64.b64encode(secrets.token_bytes(num_bytes)).decode('utf-8')[:length]
-    
-    def random_uuid(value: str = '') -> str:
-      """Generate a random UUID4.
-      
-      Usage: {{ '' | random_uuid }}
-      """
-      return str(uuid.uuid4())
-    
-    # Register filters
-    env.filters['random_string'] = random_string
-    env.filters['pwgen'] = pwgen
-    env.filters['random_hex'] = random_hex
-    env.filters['random_base64'] = random_base64
-    env.filters['random_uuid'] = random_uuid
-    
-    return env
 
-  def render(self, variables: VariableCollection) -> Dict[str, str]:
-    """Render all .j2 files in the template directory."""
+  def render(self, variables: VariableCollection) -> tuple[Dict[str, str], Dict[str, Any]]:
+    """Render all .j2 files in the template directory.
+    
+    Returns:
+        Tuple of (rendered_files, variable_values) where variable_values includes autogenerated values
+    """
     # Use get_satisfied_values() to exclude variables from sections with unsatisfied dependencies
     variable_values = variables.get_satisfied_values()
+    
+    # Auto-generate values for autogenerated variables that are empty
+    import secrets
+    import string
+    for section in variables.get_sections().values():
+      for var_name, variable in section.variables.items():
+        if variable.autogenerated and (variable.value is None or variable.value == ""):
+          # Generate a secure random string (32 characters by default)
+          alphabet = string.ascii_letters + string.digits
+          generated_value = ''.join(secrets.choice(alphabet) for _ in range(32))
+          variable_values[var_name] = generated_value
+          logger.debug(f"Auto-generated value for variable '{var_name}'")
+    
     logger.debug(f"Rendering template '{self.id}' with variables: {variable_values}")
     rendered_files = {}
     for template_file in self.template_files: # Iterate over TemplateFile objects
@@ -482,7 +442,7 @@ class Template:
               logger.error(f"Error reading static file {file_path}: {e}")
               raise
           
-    return rendered_files
+    return rendered_files, variable_values
   
   def _sanitize_content(self, content: str, file_path: Path) -> str:
     """Sanitize rendered content by removing excessive blank lines.

+ 3 - 0
cli/core/variables.py

@@ -230,6 +230,9 @@ class Variable:
         Formatted string representation of the value
     """
     if self.value is None or self.value == "":
+      # Show (auto-generated) for autogenerated variables instead of (none)
+      if self.autogenerated:
+        return "[dim](auto-generated)[/dim]" if show_none else ""
       return "[dim](none)[/dim]" if show_none else ""
     
     # Mask sensitive values

+ 17 - 14
cli/modules/compose.py

@@ -16,11 +16,25 @@ spec = OrderedDict(
             "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",
@@ -33,20 +47,6 @@ spec = OrderedDict(
             "options": ["unless-stopped", "always", "on-failure", "no"],
             "default": "unless-stopped",
           },
-          "container_hostname": {
-            "description": "Container internal hostname",
-            "type": "str",
-          },
-          "user_uid": {
-            "description": "User UID for container process",
-            "type": "int",
-            "default": 1000,
-          },
-          "user_gid": {
-            "description": "User GID for container process",
-            "type": "int",
-            "default": 1000,
-          },
         },
       },
       "network": {
@@ -126,6 +126,7 @@ spec = OrderedDict(
           "traefik_tls_certresolver": {
             "description": "Traefik certificate resolver name",
             "type": "str",
+            "default": "cloudflare",
           },
         },
       },
@@ -198,7 +199,9 @@ spec = OrderedDict(
           "database_password": {
             "description": "Database password",
             "type": "str",
+            "default": "",
             "sensitive": True,
+            "autogenerated": True,
           },
         },
       },

+ 6 - 1
library/compose/alloy/compose.yaml.j2

@@ -55,10 +55,15 @@ volumes:
   alloy_data:
     driver: local
 
-{% if network_enabled %}
+{% if network_enabled or traefik_enabled %}
 networks:
+  {% if network_enabled %}
   {{ network_name | default("bridge") }}:
     {% if network_external %}
     external: true
     {% endif %}
+  {% elif traefik_enabled %}
+  {{ traefik_network | default("traefik") }}:
+    external: true
+  {% endif %}
 {% endif %}

+ 9 - 2
library/compose/alloy/template.yaml

@@ -24,6 +24,9 @@ spec:
   general:
     vars:
       container_hostname:
+        type: hostname
+        description: Docker host hostname for container identification
+        default: hostname
         extra: This is needed because when alloy runs in a container, it doesn't know the hostname of the docker host.
   ports:
     vars:
@@ -31,6 +34,10 @@ spec:
         type: int
         description: Main port for Alloy HTTP server
         default: 12345
+  traefik:
+    vars:
+      traefik_host:
+        default: alloy.home.arpa
   logs:
     name: Log Collection
     description: Configure log collection for Docker containers and system logs
@@ -43,7 +50,7 @@ spec:
       logs_loki_url:
         type: url
         description: Loki endpoint URL for sending logs
-        default: "http://loki:3100/loki/api/v1/push"
+        default: http://loki:3100/loki/api/v1/push
       logs_docker:
         type: bool
         description: Enable Docker container log collection
@@ -64,7 +71,7 @@ spec:
       metrics_prometheus_url:
         type: url
         description: Prometheus remote write endpoint
-        default: "http://prometheus:9090/api/v1/write"
+        default: http://prometheus:9090/api/v1/write
       metrics_docker:
         type: bool
         description: Enable Docker container metrics collection (cAdvisor)

+ 0 - 46
library/compose/ansiblesemaphore/.env.semaphore.j2

@@ -1,46 +0,0 @@
-# Ansible Semaphore Application Configuration
-# Contains application settings and database connection
-
-# Timezone
-TZ={{ container_timezone | default('UTC') }}
-
-# Database Connection
-{% if database_type == 'mysql' %}
-SEMAPHORE_DB_DIALECT=mysql
-{% elif database_type == 'postgres' %}
-SEMAPHORE_DB_DIALECT=postgres
-{% endif %}
-{% if database_external %}
-SEMAPHORE_DB_HOST={{ database_host }}
-{% else %}
-SEMAPHORE_DB_HOST={{ service_name | default('semaphore') }}-{{ database_type }}
-{% endif %}
-SEMAPHORE_DB_PORT={% if database_type == 'postgres' %}5432{% else %}3306{% endif %}
-SEMAPHORE_DB={{ database_name | default('semaphore') }}
-SEMAPHORE_DB_USER={{ database_user | default('semaphore') }}
-SEMAPHORE_DB_PASS={{ database_password | default('semaphore') }}
-
-# Admin Configuration
-SEMAPHORE_ADMIN={{ semaphore_admin_name | default('admin') }}
-SEMAPHORE_ADMIN_NAME={{ semaphore_admin_name | default('admin') }}
-SEMAPHORE_ADMIN_EMAIL={{ semaphore_admin_email | default('admin@localhost') }}
-SEMAPHORE_ADMIN_PASSWORD={{ semaphore_admin_password if semaphore_admin_password else (none | pwgen(20)) }}
-
-# Playbook Configuration
-SEMAPHORE_PLAYBOOK_PATH={{ semaphore_playbook_path | default('/tmp/semaphore/') }}
-
-# Access Key Encryption
-SEMAPHORE_ACCESS_KEY_ENCRYPTION={{ semaphore_access_key_encryption if semaphore_access_key_encryption else (none | pwgen(32)) }}
-
-# Ansible Settings
-ANSIBLE_HOST_KEY_CHECKING={{ ansible_host_key_checking | default(false) }}
-
-{% if email_enabled -%}
-# Email Server Configuration
-SEMAPHORE_EMAIL_SENDER={{ email_from }}
-SEMAPHORE_EMAIL_HOST={{ email_host }}
-SEMAPHORE_EMAIL_PORT={{ email_port | default(587) }}
-SEMAPHORE_EMAIL_USERNAME={{ email_username }}
-SEMAPHORE_EMAIL_PASSWORD={{ email_password }}
-SEMAPHORE_EMAIL_SECURE={{ email_use_tls | default(true) }}
-{% endif %}

+ 0 - 97
library/compose/ansiblesemaphore/compose.yaml.j2

@@ -1,97 +0,0 @@
-services:
-  {{ service_name | default('semaphore') }}:
-    image: docker.io/semaphoreui/semaphore:v2.16.18
-    container_name: {{ container_name | default('semaphore') }}
-    user: "{{ user_uid | default(1000) }}:{{ user_gid | default(1000) }}"
-    env_file:
-      - .env.semaphore
-    {% if ports_enabled %}
-    ports:
-      - "{{ ports_http | default(3000) }}:3000"
-    {% endif %}
-    {% if network_enabled %}
-    networks:
-      - {{ network_name | default('bridge') }}
-    {% endif %}
-    {% if traefik_enabled %}
-    labels:
-      - traefik.enable=true
-      - traefik.http.services.{{ service_name | default('semaphore') }}.loadbalancer.server.port=3000
-      - traefik.http.routers.{{ service_name | default('semaphore') }}-http.rule=Host(`{{ traefik_host }}`)
-      - traefik.http.routers.{{ service_name | default('semaphore') }}-http.entrypoints={{ traefik_entrypoint | default('web') }}
-      {% if traefik_tls_enabled %}
-      - traefik.http.routers.{{ service_name | default('semaphore') }}-https.rule=Host(`{{ traefik_host }}`)
-      - traefik.http.routers.{{ service_name | default('semaphore') }}-https.entrypoints={{ traefik_tls_entrypoint | default('websecure') }}
-      - traefik.http.routers.{{ service_name | default('semaphore') }}-https.tls=true
-      - traefik.http.routers.{{ service_name | default('semaphore') }}-https.tls.certresolver={{ traefik_tls_certresolver }}
-      {% endif %}
-    {% endif %}
-    volumes:
-      - ./inventory:/inventory:ro
-      - ./authorized-keys:/authorized-keys:ro
-      - ./config:/etc/semaphore:rw
-    depends_on:
-      {% if database_type == 'mysql' %}
-      - {{ service_name | default('semaphore') }}-mysql
-      {% elif database_type == 'postgres' %}
-      - {{ service_name | default('semaphore') }}-postgres
-      {% endif %}
-    restart: {{ restart_policy | default('unless-stopped') }}
-
-  {% if not database_external %}
-  {% if database_type == 'mysql' %}
-  {{ service_name | default('semaphore') }}-mysql:
-    image: docker.io/library/mysql:8.4
-    container_name: {{ service_name | default('semaphore') }}-mysql
-    env_file:
-      - .env.database
-    healthcheck:
-      test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "{{ database_user | default('semaphore') }}", "-p{{ database_password | default('semaphore') }}"]
-      start_period: 30s
-      interval: 10s
-      timeout: 10s
-      retries: 5
-    volumes:
-      - database_data:/var/lib/mysql
-    {% if network_enabled %}
-    networks:
-      - {{ network_name | default('bridge') }}
-    {% endif %}
-    restart: {{ restart_policy | default('unless-stopped') }}
-  {% elif database_type == 'postgres' %}
-  {{ service_name | default('semaphore') }}-postgres:
-    image: docker.io/library/postgres:17.6
-    container_name: {{ service_name | default('semaphore') }}-postgres
-    env_file:
-      - .env.database
-    healthcheck:
-      test: ["CMD-SHELL", "pg_isready -U {{ database_user | default('semaphore') }}"]
-      start_period: 30s
-      interval: 10s
-      timeout: 10s
-      retries: 5
-    volumes:
-      - database_data:/var/lib/postgresql/data
-    {% if network_enabled %}
-    networks:
-      - {{ network_name | default('bridge') }}
-    {% endif %}
-    restart: {{ restart_policy | default('unless-stopped') }}
-  {% endif %}
-  {% endif %}
-
-{% if network_enabled %}
-networks:
-  {{ network_name | default('bridge') }}:
-    {% if network_external %}
-    external: true
-    {% else %}
-    driver: bridge
-    {% endif %}
-{% endif %}
-
-volumes:
-  {% if not database_external %}
-  database_data:
-    driver: local
-  {% endif %}

+ 11 - 11
library/compose/authentik/.env.authentik.j2

@@ -2,30 +2,30 @@
 # Contains sensitive application secrets and connection strings
 
 # Timezone
-TZ={{ container_timezone | default('UTC') }}
+TZ={{ container_timezone }}
 
 # Secret Key (used for cookie signing and unique user IDs)
-AUTHENTIK_SECRET_KEY={{ authentik_secret_key if authentik_secret_key else (none | pwgen(50)) }}
+AUTHENTIK_SECRET_KEY={{ authentik_secret_key }}
 
 # Error Reporting
-AUTHENTIK_ERROR_REPORTING__ENABLED={{ authentik_error_reporting | default(false) }}
+AUTHENTIK_ERROR_REPORTING__ENABLED={{ authentik_error_reporting }}
 
 # Redis Connection
-AUTHENTIK_REDIS__HOST={{ service_name | default('authentik') }}-redis
+AUTHENTIK_REDIS__HOST={{ service_name }}-redis
 
 # PostgreSQL Connection
-AUTHENTIK_POSTGRESQL__HOST={{ service_name | default('authentik') }}-postgres
-AUTHENTIK_POSTGRESQL__USER={{ database_user | default('authentik') }}
-AUTHENTIK_POSTGRESQL__NAME={{ database_name | default('authentik') }}
-AUTHENTIK_POSTGRESQL__PASSWORD={{ database_password | default('authentik') }}
+AUTHENTIK_POSTGRESQL__HOST={{ service_name }}-postgres
+AUTHENTIK_POSTGRESQL__USER={{ database_user }}
+AUTHENTIK_POSTGRESQL__NAME={{ database_name }}
+AUTHENTIK_POSTGRESQL__PASSWORD={{ database_password }}
 
 {% if email_enabled -%}
 # Email Server Configuration
 AUTHENTIK_EMAIL__HOST={{ email_host }}
-AUTHENTIK_EMAIL__PORT={{ email_port | default(25) }}
+AUTHENTIK_EMAIL__PORT={{ email_port }}
 AUTHENTIK_EMAIL__USERNAME={{ email_username }}
 AUTHENTIK_EMAIL__PASSWORD={{ email_password }}
-AUTHENTIK_EMAIL__USE_TLS={{ email_use_tls | default(false) }}
-AUTHENTIK_EMAIL__USE_SSL={{ email_use_ssl | default(false) }}
+AUTHENTIK_EMAIL__USE_TLS={{ email_use_tls }}
+AUTHENTIK_EMAIL__USE_SSL={{ email_use_ssl }}
 AUTHENTIK_EMAIL__FROM={{ email_from }}
 {% endif %}

+ 4 - 4
library/compose/authentik/.env.postgres.j2

@@ -2,8 +2,8 @@
 # Contains only database credentials needed by Postgres container
 
 # Timezone
-TZ={{ container_timezone | default('UTC') }}
+TZ={{ container_timezone }}
 
-POSTGRES_USER={{ database_user | default('authentik') }}
-POSTGRES_PASSWORD={{ database_password | default('authentik') }}
-POSTGRES_DB={{ database_name | default('authentik') }}
+POSTGRES_USER={{ database_user }}
+POSTGRES_PASSWORD={{ database_password }}
+POSTGRES_DB={{ database_name }}

+ 63 - 37
library/compose/authentik/compose.yaml.j2

@@ -1,44 +1,49 @@
 services:
-  {{ service_name | default('authentik-server') }}:
+  {{ service_name }}:
     image: ghcr.io/goauthentik/server:2025.6.3
-    container_name: {{ container_name | default('authentik-server') }}
+    container_name: {{ container_name }}
     command: server
     env_file:
       - .env.authentik
     {% if ports_enabled %}
     ports:
-      - "{{ ports_http | default(9000) }}:9000"
-      - "{{ ports_https | default(9443) }}:9443"
+      - "{{ ports_http }}:9000"
+      - "{{ ports_https }}:9443"
     {% endif %}
-    {% if network_enabled %}
+    {% if network_enabled or traefik_enabled %}
     networks:
-      - {{ network_name | default('bridge') }}
+      {% if network_enabled %}
+      - {{ network_name }}
+      {% endif %}
+      {% if traefik_enabled %}
+      - {{ traefik_network }}
+      {% endif %}
     {% endif %}
     {% if traefik_enabled %}
     labels:
       - traefik.enable=true
-      - traefik.http.services.{{ service_name | default('authentik') }}.loadbalancer.server.port=9000
-      - traefik.http.services.{{ service_name | default('authentik') }}.loadbalancer.server.scheme=http
-      - traefik.http.routers.{{ service_name | default('authentik') }}-http.rule=Host(`{{ traefik_host }}`)
-      - traefik.http.routers.{{ service_name | default('authentik') }}-http.entrypoints={{ traefik_entrypoint | default('web') }}
+      - traefik.http.services.{{ service_name }}.loadbalancer.server.port=9000
+      - traefik.http.services.{{ service_name }}.loadbalancer.server.scheme=http
+      - traefik.http.routers.{{ service_name }}-http.rule=Host(`{{ traefik_host }}`)
+      - traefik.http.routers.{{ service_name }}-http.entrypoints={{ traefik_entrypoint }}
       {% if traefik_tls_enabled %}
-      - traefik.http.routers.{{ service_name | default('authentik') }}-https.rule=Host(`{{ traefik_host }}`)
-      - traefik.http.routers.{{ service_name | default('authentik') }}-https.entrypoints={{ traefik_tls_entrypoint | default('websecure') }}
-      - traefik.http.routers.{{ service_name | default('authentik') }}-https.tls=true
-      - traefik.http.routers.{{ service_name | default('authentik') }}-https.tls.certresolver={{ traefik_tls_certresolver }}
+      - traefik.http.routers.{{ service_name }}-https.rule=Host(`{{ traefik_host }}`)
+      - traefik.http.routers.{{ service_name }}-https.entrypoints={{ traefik_tls_entrypoint }}
+      - traefik.http.routers.{{ service_name }}-https.tls=true
+      - traefik.http.routers.{{ service_name }}-https.tls.certresolver={{ traefik_tls_certresolver }}
       {% endif %}
     {% endif %}
     volumes:
       - ./media:/media
       - ./custom-templates:/templates
     depends_on:
-      - {{ service_name | default('authentik') }}-postgres
-      - {{ service_name | default('authentik') }}-redis
-    restart: {{ restart_policy | default('unless-stopped') }}
+      - {{ service_name }}-postgres
+      - {{ service_name }}-redis
+    restart: {{ restart_policy }}
 
-  {{ service_name | default('authentik') }}-worker:
+  {{ service_name }}-worker:
     image: ghcr.io/goauthentik/server:2025.6.3
-    container_name: {{ service_name | default('authentik') }}-worker
+    container_name: {{ service_name }}-worker
     command: worker
     env_file:
       - .env.authentik
@@ -48,18 +53,23 @@ services:
       - ./media:/media
       - ./certs:/certs
       - ./custom-templates:/templates
-    {% if network_enabled %}
+    {% if network_enabled or traefik_enabled %}
     networks:
-      - {{ network_name | default('bridge') }}
+      {% if network_enabled %}
+      - {{ network_name }}
+      {% endif %}
+      {% if traefik_enabled %}
+      - {{ traefik_network }}
+      {% endif %}
     {% endif %}
     depends_on:
-      - {{ service_name | default('authentik') }}-postgres
-      - {{ service_name | default('authentik') }}-redis
-    restart: {{ restart_policy | default('unless-stopped') }}
+      - {{ service_name }}-postgres
+      - {{ service_name }}-redis
+    restart: {{ restart_policy }}
 
-  {{ service_name | default('authentik') }}-redis:
+  {{ service_name }}-redis:
     image: docker.io/library/redis:8.2.1
-    container_name: {{ service_name | default('authentik') }}-redis
+    container_name: {{ service_name }}-redis
     command: --save 60 1 --loglevel warning
     healthcheck:
       test: ["CMD-SHELL", "redis-cli ping | grep PONG"]
@@ -69,31 +79,41 @@ services:
       timeout: 3s
     volumes:
       - redis_data:/data
-    {% if network_enabled %}
+    {% if network_enabled or traefik_enabled %}
     networks:
-      - {{ network_name | default('bridge') }}
+      {% if network_enabled %}
+      - {{ network_name }}
+      {% endif %}
+      {% if traefik_enabled %}
+      - {{ traefik_network }}
+      {% endif %}
     {% endif %}
-    restart: {{ restart_policy | default('unless-stopped') }}
+    restart: {{ restart_policy }}
 
   {% if not database_external %}
-  {{ service_name | default('authentik') }}-postgres:
+  {{ service_name }}-postgres:
     image: docker.io/library/postgres:17.6
-    container_name: {{ service_name | default('authentik') }}-db
+    container_name: {{ service_name }}-db
     env_file:
       - .env.postgres
     healthcheck:
-      test: ["CMD-SHELL", "pg_isready -U {{ database_user | default('authentik') }}"]
+      test: ["CMD-SHELL", "pg_isready -U {{ database_user }}"]
       start_period: 30s
       interval: 10s
       timeout: 10s
       retries: 5
     volumes:
       - database_data:/var/lib/postgresql/data
-    {% if network_enabled %}
+    {% if network_enabled or traefik_enabled %}
     networks:
-      - {{ network_name | default('bridge') }}
+      {% if network_enabled %}
+      - {{ network_name }}
+      {% endif %}
+      {% if traefik_enabled %}
+      - {{ traefik_network }}
+      {% endif %}
     {% endif %}
-    restart: {{ restart_policy | default('unless-stopped') }}
+    restart: {{ restart_policy }}
   {% endif %}
 
 volumes:
@@ -102,10 +122,16 @@ volumes:
   redis_data:
     driver: local
 
-{% if network_enabled %}
+{% if network_enabled or traefik_enabled %}
 networks:
-  {{ network_name | default('bridge') }}:
+  {% if network_enabled %}
+  {{ network_name }}:
     {% if network_external %}
     external: true
     {% endif %}
+  {% endif %}
+  {% if traefik_enabled %}
+  {{ traefik_network }}:
+    external: true
+  {% endif %}
 {% endif %}

+ 51 - 0
library/compose/authentik/template.yaml

@@ -19,9 +19,56 @@ metadata:
   date: '2025-09-28'
   tags:
     - authentication
+  next_steps: |
+    1. Start Authentik:
+       docker compose up -d
+
+    2. Access the web interface:
+       {% if traefik_enabled -%}
+       - Via Traefik: https://{{ traefik_host }}
+       {% if ports_enabled %}- Direct access: http://localhost:{{ ports_http }}{% endif %}
+       {%- else -%}
+       - Open http://localhost:{{ ports_http }} in your browser
+       {%- endif %}
+
+    3. Initial setup:
+       - Follow the setup wizard to create your admin account
+       - Configure authentication flows and providers
+       - Set up user directory (LDAP, Active Directory, or local)
+
+    4. Configure your first application:
+       - Navigate to Applications → Create
+       - Choose authentication provider (OAuth2, SAML, LDAP, etc.)
+       - Configure redirect URIs and client credentials
+       - Assign users or groups to the application
+
+    5. Important configuration:
+       - Secret Key: {{ authentik_secret_key }}
+       - Database Password: {{ database_password }}
+       - Store these credentials securely!
+
+    6. Security recommendations:
+       - Enable two-factor authentication for admin accounts
+       - Configure backup flows and recovery tokens
+       - Set up email notifications for security events
+       - Review and customize authentication policies
+       - Regularly backup the database and media files
+
+    For more information, visit: https://goauthentik.io/docs/
 spec:
+  general:
+    vars:
+      service_name:
+        default: "authentik"
+      container_name:
+        default: "authentik-server"
   database:
     required: true
+    vars:
+      database_name:
+        default: "authentik"
+      database_user:
+        default: "authentik"
   ports:
     vars:
       ports_http:
@@ -32,6 +79,10 @@ spec:
         description: "Host port for HTTPS (443)"
         type: int
         default: 8443
+  traefik:
+    vars:
+      traefik_host:
+        default: authentik.home.arpa
   authentik:
     description: "Configure Authentik application settings"
     required: true

+ 3 - 3
library/compose/bind9/config/tsig.key.j2

@@ -2,12 +2,12 @@
 // Auto-generated base64-encoded secret for secure zone transfers and dynamic DNS updates
 // Algorithm: hmac-sha256
 
-key "{{ tsig_key_name | default('transfer-key') }}" {
+key "{{ tsig_key_name }}" {
     algorithm hmac-sha256;
-    secret "{{ tsig_key_secret if tsig_key_secret else (none | random_base64(64)) }}";
+    secret "{{ tsig_key_secret }}";
 };
 
 // To manually generate a new key:
-// docker exec bind9 tsig-keygen -a hmac-sha256 {{ tsig_key_name | default('transfer-key') }}
+// docker exec bind9 tsig-keygen -a hmac-sha256 {{ tsig_key_name }}
 //
 // Then update the secret value above with the generated secret

+ 1 - 0
library/compose/gitea/template.yaml

@@ -40,6 +40,7 @@ spec:
       gitea_root_url:
         description: "Public URL for your Gitea instance (e.g., https://git.example.com)"
         type: str
+        default: "https://git.example.com"
       gitea_ssh_port:
         description: "SSH port number (should match ports_ssh)"
         type: int

+ 2 - 0
library/compose/gitlab/template.yaml

@@ -34,7 +34,9 @@ spec:
   ports:
     vars:
       ports_enabled:
+        type: bool
         description: Expose HTTP/HTTPS ports (disabled if using Traefik)
+        default: true
       ports_http:
         type: int
         description: HTTP port (disabled if using Traefik)

+ 11 - 11
library/compose/nextcloud/.env.nextcloud.j2

@@ -2,22 +2,22 @@
 # Contains Nextcloud-specific settings and database connection strings
 
 # Timezone
-TZ={{ container_timezone | default('UTC') }}
+TZ={{ container_timezone }}
 
 {% if database_type == 'mysql' -%}
 # MySQL Database Connection
-MYSQL_PASSWORD={{ database_password | default('nextcloud') }}
-MYSQL_DATABASE={{ database_name | default('nextcloud') }}
-MYSQL_USER={{ database_user | default('nextcloud') }}
-MYSQL_HOST={{ service_name | default('nextcloud') }}-db
+MYSQL_PASSWORD={{ database_password }}
+MYSQL_DATABASE={{ database_name }}
+MYSQL_USER={{ database_user }}
+MYSQL_HOST={{ service_name }}-db
 {% elif database_type == 'postgres' -%}
 # PostgreSQL Database Connection
-POSTGRES_PASSWORD={{ database_password | default('nextcloud') }}
-POSTGRES_DB={{ database_name | default('nextcloud') }}
-POSTGRES_USER={{ database_user | default('nextcloud') }}
-POSTGRES_HOST={{ service_name | default('nextcloud') }}-db
+POSTGRES_PASSWORD={{ database_password }}
+POSTGRES_DB={{ database_name }}
+POSTGRES_USER={{ database_user }}
+POSTGRES_HOST={{ service_name }}-db
 {% endif %}
 
 # Nextcloud Admin Credentials
-NEXTCLOUD_ADMIN_USER={{ admin_user | default('admin') }}
-NEXTCLOUD_ADMIN_PASSWORD={{ admin_password if admin_password else (none | pwgen(32)) }}
+NEXTCLOUD_ADMIN_USER={{ admin_user }}
+NEXTCLOUD_ADMIN_PASSWORD={{ admin_password }}

+ 6 - 6
library/compose/ansiblesemaphore/.env.database.j2 → library/compose/semaphoreui/.env.database.j2

@@ -4,9 +4,9 @@
 
 # Database Settings
 MYSQL_RANDOM_ROOT_PASSWORD=yes
-MYSQL_DATABASE={{ database_name | default('semaphore') }}
-MYSQL_USER={{ database_user | default('semaphore') }}
-MYSQL_PASSWORD={{ database_password | default('semaphore') }}
+MYSQL_DATABASE={{ database_name }}
+MYSQL_USER={{ database_user }}
+MYSQL_PASSWORD={{ database_password }}
 
 # Character Set
 MYSQL_CHARSET=utf8mb4
@@ -17,9 +17,9 @@ MYSQL_COLLATION=utf8mb4_unicode_ci
 # Used when database_type=postgres and database_external=false
 
 # Database Settings
-POSTGRES_DB={{ database_name | default('semaphore') }}
-POSTGRES_USER={{ database_user | default('semaphore') }}
-POSTGRES_PASSWORD={{ database_password | default('semaphore') }}
+POSTGRES_DB={{ database_name }}
+POSTGRES_USER={{ database_user }}
+POSTGRES_PASSWORD={{ database_password }}
 
 # PostgreSQL Configuration
 POSTGRES_INITDB_ARGS=--encoding=UTF8 --locale=C

+ 46 - 0
library/compose/semaphoreui/.env.semaphore.j2

@@ -0,0 +1,46 @@
+# Ansible Semaphore Application Configuration
+# Contains application settings and database connection
+
+# Timezone
+TZ={{ container_timezone }}
+
+# Database Connection
+{% if database_type == 'mysql' %}
+SEMAPHORE_DB_DIALECT=mysql
+{% elif database_type == 'postgres' %}
+SEMAPHORE_DB_DIALECT=postgres
+{% endif %}
+{% if database_external %}
+SEMAPHORE_DB_HOST={{ database_host }}
+{% else %}
+SEMAPHORE_DB_HOST={{ service_name }}-{{ database_type }}
+{% endif %}
+SEMAPHORE_DB_PORT={% if database_type == 'postgres' %}5432{% else %}3306{% endif %}
+SEMAPHORE_DB={{ database_name }}
+SEMAPHORE_DB_USER={{ database_user }}
+SEMAPHORE_DB_PASS={{ database_password }}
+
+# Admin Configuration
+SEMAPHORE_ADMIN={{ semaphore_admin_name }}
+SEMAPHORE_ADMIN_NAME={{ semaphore_admin_name }}
+SEMAPHORE_ADMIN_EMAIL={{ semaphore_admin_email }}
+SEMAPHORE_ADMIN_PASSWORD={{ semaphore_admin_password }}
+
+# Playbook Configuration
+SEMAPHORE_PLAYBOOK_PATH={{ semaphore_playbook_path }}
+
+# Access Key Encryption
+SEMAPHORE_ACCESS_KEY_ENCRYPTION={{ semaphore_access_key_encryption }}
+
+# Ansible Settings
+ANSIBLE_HOST_KEY_CHECKING={{ ansible_host_key_checking }}
+
+{% if email_enabled -%}
+# Email Server Configuration
+SEMAPHORE_EMAIL_SENDER={{ email_from }}
+SEMAPHORE_EMAIL_HOST={{ email_host }}
+SEMAPHORE_EMAIL_PORT={{ email_port }}
+SEMAPHORE_EMAIL_USERNAME={{ email_username }}
+SEMAPHORE_EMAIL_PASSWORD={{ email_password }}
+SEMAPHORE_EMAIL_SECURE={{ email_use_tls }}
+{% endif %}

+ 118 - 0
library/compose/semaphoreui/compose.yaml.j2

@@ -0,0 +1,118 @@
+services:
+  {{ service_name }}:
+    image: docker.io/semaphoreui/semaphore:v2.16.18
+    container_name: {{ container_name }}
+    user: "{{ user_uid }}:{{ user_gid }}"
+    env_file:
+      - .env.semaphore
+    {% if ports_enabled %}
+    ports:
+      - "{{ ports_http }}:3000"
+    {% endif %}
+    {% if network_enabled or traefik_enabled %}
+    networks:
+      {% if network_enabled %}
+      - {{ network_name }}
+      {% endif %}
+      {% if traefik_enabled %}
+      - {{ traefik_network }}
+      {% endif %}
+    {% endif %}
+    {% if traefik_enabled %}
+    labels:
+      - traefik.enable=true
+      - traefik.http.services.{{ service_name }}.loadbalancer.server.port=3000
+      - traefik.http.routers.{{ service_name }}-http.rule=Host(`{{ traefik_host }}`)
+      - traefik.http.routers.{{ service_name }}-http.entrypoints={{ traefik_entrypoint }}
+      {% if traefik_tls_enabled %}
+      - traefik.http.routers.{{ service_name }}-https.rule=Host(`{{ traefik_host }}`)
+      - traefik.http.routers.{{ service_name }}-https.entrypoints={{ traefik_tls_entrypoint }}
+      - traefik.http.routers.{{ service_name }}-https.tls=true
+      - traefik.http.routers.{{ service_name }}-https.tls.certresolver={{ traefik_tls_certresolver }}
+      {% endif %}
+    {% endif %}
+    volumes:
+      - ./inventory:/inventory:ro
+      - ./authorized-keys:/authorized-keys:ro
+      - ./config:/etc/semaphore:rw
+    depends_on:
+      {% if database_type == 'mysql' %}
+      - {{ service_name }}-mysql
+      {% elif database_type == 'postgres' %}
+      - {{ service_name }}-postgres
+      {% endif %}
+    restart: {{ restart_policy }}
+
+  {% if not database_external %}
+  {% if database_type == 'mysql' %}
+  {{ service_name }}-mysql:
+    image: docker.io/library/mysql:8.4
+    container_name: {{ service_name }}-mysql
+    env_file:
+      - .env.database
+    healthcheck:
+      test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "{{ database_user }}", "-p{{ database_password }}"]
+      start_period: 30s
+      interval: 10s
+      timeout: 10s
+      retries: 5
+    volumes:
+      - database_data:/var/lib/mysql
+    {% if network_enabled or traefik_enabled %}
+    networks:
+      {% if network_enabled %}
+      - {{ network_name }}
+      {% endif %}
+      {% if traefik_enabled %}
+      - {{ traefik_network }}
+      {% endif %}
+    {% endif %}
+    restart: {{ restart_policy }}
+  {% elif database_type == 'postgres' %}
+  {{ service_name }}-postgres:
+    image: docker.io/library/postgres:17.6
+    container_name: {{ service_name }}-postgres
+    env_file:
+      - .env.database
+    healthcheck:
+      test: ["CMD-SHELL", "pg_isready -U {{ database_user }}"]
+      start_period: 30s
+      interval: 10s
+      timeout: 10s
+      retries: 5
+    volumes:
+      - database_data:/var/lib/postgresql/data
+    {% if network_enabled or traefik_enabled %}
+    networks:
+      {% if network_enabled %}
+      - {{ network_name }}
+      {% endif %}
+      {% if traefik_enabled %}
+      - {{ traefik_network }}
+      {% endif %}
+    {% endif %}
+    restart: {{ restart_policy }}
+  {% endif %}
+  {% endif %}
+
+{% if network_enabled or traefik_enabled %}
+networks:
+  {% if network_enabled %}
+  {{ network_name }}:
+    {% if network_external %}
+    external: true
+    {% else %}
+    driver: bridge
+    {% endif %}
+  {% endif %}
+  {% if traefik_enabled %}
+  {{ traefik_network }}:
+    external: true
+  {% endif %}
+{% endif %}
+
+volumes:
+  {% if not database_external %}
+  database_data:
+    driver: local
+  {% endif %}

+ 50 - 4
library/compose/ansiblesemaphore/template.yaml → library/compose/semaphoreui/template.yaml

@@ -1,7 +1,7 @@
 ---
 kind: compose
 metadata:
-  name: Ansible Semaphore
+  name: Semaphore UI
   description: >
     Modern UI for Ansible automation with task scheduling and web-based management.
     Semaphore provides a beautiful web interface to run Ansible playbooks, manage
@@ -22,18 +22,64 @@ metadata:
     - automation
     - devops
     - infrastructure
+  next_steps: |
+    1. Start Semaphore UI:
+       docker compose up -d
+
+    2. Access the web interface:
+       {% if traefik_enabled -%}
+       - Via Traefik: https://{{ traefik_host }}
+       {% if ports_enabled %}- Direct access: http://localhost:{{ ports_http }}{% endif %}
+       {%- else -%}
+       - Open http://localhost:{{ ports_http }} in your browser
+       {%- endif %}
+
+    3. Login with your admin credentials:
+       - Username: {{ semaphore_admin_name }}
+       - Email: {{ semaphore_admin_email }}
+       - Password: {{ semaphore_admin_password }}
+
+    4. Getting started:
+       - Add SSH keys for accessing your managed hosts
+       - Create your first project and link it to a Git repository
+       - Add inventories (static or dynamic)
+       - Create task templates from your Ansible playbooks
+       - Schedule or run tasks on-demand
+
+    5. Security recommendations:
+       - Change the admin password after first login
+       - Enable two-factor authentication in user settings
+       - Review user permissions and create additional users
+       - Use SSH key authentication for Ansible connections
+
+    For more information, visit: https://docs.semaphoreui.com/
+
 spec:
+  general:
+    vars:
+      service_name:
+        default: "semaphore"
+      container_name:
+        default: "semaphore"
   database:
     required: true
     vars:
       database_type:
         default: "mysql"
+      database_name:
+        default: "semaphore"
+      database_user:
+        default: "semaphore"
   ports:
     vars:
       ports_http:
         description: "Host port for web interface"
         type: int
         default: 3000
+  traefik:
+    vars:
+      traefik_host:
+        default: semaphoreui.home.arpa
   semaphore:
     description: "Configure Ansible Semaphore application settings"
     required: true
@@ -45,21 +91,21 @@ spec:
       semaphore_admin_email:
         description: "Admin email address"
         type: str
-        default: "admin@localhost"
+        default: "admin@home.arpa"
       semaphore_admin_password:
         description: "Initial admin password"
         extra: "Leave empty for auto-generated 20-character secure password"
         type: str
+        default: ""
         sensitive: true
         autogenerated: true
-        default: ""
       semaphore_access_key_encryption:
         description: "Encryption key for access keys storage"
         extra: "Leave empty for auto-generated 32-character secure key"
         type: str
+        default: ""
         sensitive: true
         autogenerated: true
-        default: ""
       semaphore_playbook_path:
         description: "Path for temporary playbook execution"
         type: str

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

@@ -88,7 +88,9 @@ spec:
       swarm_placement_mode:
         default: "global"
       swarm_placement_host:
+        type: str
         description: "Placement constraint for node selection (optional)"
+        default: ""
   authentik:
     title: Authentik Middleware
     description: Enable Authentik SSO integration for Traefik

+ 0 - 23
setup.cfg

@@ -1,23 +0,0 @@
-[metadata]
-name = boilerplates
-version = 1.0.0
-description = CLI tool for managing infrastructure boilerplates
-long_description = file: README.md
-license_file = LICENSE
-author = Christian Lempa
-author_email =
-python_requires = >=3.9
-
-[options]
-packages = find:
-include_package_data = True
-install_requires =
-    typer[all]>=0.9.0
-    rich>=13.0.0
-    PyYAML>=6.0
-    python-frontmatter>=1.0.0
-    Jinja2>=3.0
-
-[options.entry_points]
-console_scripts =
-    boilerplate = cli.__main__:run