xcad пре 3 месеци
родитељ
комит
2dda44ede7

+ 126 - 7
AGENTS.md

@@ -47,10 +47,16 @@ The project is stored in a public GitHub Repository, use issues, and branches fo
   - `library/kubernetes/` - Kubernetes deployments
   - `library/packer/` - Packer templates
   - `library/terraform/` - OpenTofu/Terraform templates and examples
+- `archetypes/` - Testing tool for template snippets (archetype development)
+  - `archetypes/__init__.py` - Package initialization
+  - `archetypes/__main__.py` - CLI tool entry point
+  - `archetypes/<module>/` - Module-specific archetype snippets (e.g., `archetypes/compose/`)
 
 ### Core Components
 
-- `cli/core/collection.py` - Dataclass for VariableCollection (stores variable sections and variables)
+- `cli/core/collection.py` - VariableCollection class (manages sections and variables)
+  - **Key Attributes**: `_sections` (dict of VariableSection objects), `_variable_map` (flat lookup dict)
+  - **Key Methods**: `get_satisfied_values()` (returns enabled variables), `apply_defaults()`, `sort_sections()`
 - `cli/core/config.py` - Configuration management (loading, saving, validation)
 - `cli/core/display.py` - Centralized CLI output rendering (**Always use this to display output - never print directly**)
 - `cli/core/exceptions.py` - Custom exceptions for error handling (**Always use this for raising errors**)
@@ -59,9 +65,12 @@ The project is stored in a public GitHub Repository, use issues, and branches fo
 - `cli/core/prompt.py` - Interactive CLI prompts using rich library
 - `cli/core/registry.py` - Central registry for module classes (auto-discovers modules)
 - `cli/core/repo.py` - Repository management for syncing git-based template libraries
-- `cli/core/section.py` - Dataclass for VariableSection (stores section metadata and variables)
+- `cli/core/section.py` - VariableSection class (stores section metadata and variables)
+  - **Key Attributes**: `key`, `title`, `toggle`, `required`, `needs`, `variables` (dict of Variable objects)
 - `cli/core/template.py` - Template Class for parsing, managing and rendering templates
-- `cli/core/variable.py` - Dataclass for Variable (stores variable metadata and values)
+- `cli/core/variable.py` - Variable class (stores variable metadata and values)
+  - **Key Attributes**: `name`, `type`, `value` (stores default or current value), `description`, `sensitive`, `needs`
+  - **Note**: Default values are stored in `value` attribute, NOT in a separate `default` attribute
 - `cli/core/validators.py` - Semantic validators for template content (Docker Compose, YAML, etc.)
 - `cli/core/version.py` - Version comparison utilities for semantic versioning
 
@@ -80,12 +89,36 @@ Modules can be either single files or packages:
 - Auto-discovered and registered at CLI startup
 
 **Module Spec:**
-Optional class attribute for module-wide variable defaults. Example:
+Module-wide variable specification defining defaults for all templates of that kind.
+
+**Important**: The `spec` variable is an **OrderedDict** (or regular dict), NOT a VariableCollection object. It's converted to VariableCollection when needed.
+
+Example:
 ```python
-spec = VariableCollection.from_dict({
-  "general": {"vars": {"common_var": {"type": "str", "default": "value"}}},
-  "networking": {"title": "Network", "toggle": "net_enabled", "vars": {...}}
+from collections import OrderedDict
+
+# Spec is a dict/OrderedDict, not a VariableCollection
+spec = OrderedDict({
+  "general": {
+    "title": "General",
+    "vars": {
+      "common_var": {
+        "type": "str",
+        "default": "value",
+        "description": "A common variable"
+      }
+    }
+  },
+  "networking": {
+    "title": "Network",
+    "toggle": "net_enabled",
+    "vars": {...}
+  }
 })
+
+# To use the spec, convert it to VariableCollection:
+from cli.core.collection import VariableCollection
+variable_collection = VariableCollection(spec)
 ```
 
 **Multi-Schema Modules:**
@@ -352,3 +385,89 @@ To skip the prompt use the `--no-interactive` flag, which will use defaults or e
 **Core Commands:**
 - `repo sync` - Sync git-based libraries
 - `repo list` - List configured libraries
+
+## Archetypes Testing Tool
+
+The `archetypes` package provides a testing tool for developing and testing individual template snippets (Jinja2 files) without needing a full template directory structure.
+
+### Purpose
+
+Archetypes are template "snippets" or "parts" that can be tested in isolation. This is useful for:
+- Developing specific sections of templates (e.g., network configurations, volume mounts)
+- Testing Jinja2 logic with different variable combinations
+- Validating template rendering before integrating into full templates
+
+### Usage
+
+```bash
+# Run the archetypes tool
+python3 -m archetypes
+
+# List all archetypes for a module
+python3 -m archetypes compose list
+
+# Show details of an archetype (displays variables and content)
+python3 -m archetypes compose show network-v1
+
+# Preview generated output (always in preview mode - never writes files)
+python3 -m archetypes compose generate network-v1
+
+# Preview with variable overrides
+python3 -m archetypes compose generate network-v1 \
+  --var network_mode=macvlan \
+  --var network_macvlan_ipv4_address=192.168.1.100
+
+# Preview with reference directory (for context only - no files written)
+python3 -m archetypes compose generate network-v1 /tmp/output --var network_mode=host
+```
+
+### Structure
+
+```
+archetypes/
+  __init__.py           # Package initialization
+  __main__.py           # CLI tool (auto-discovers modules)
+  compose/              # Module-specific archetypes
+    network-v1.j2       # Archetype snippet (just a .j2 file)
+    volumes-v1.j2       # Another archetype
+  terraform/            # Another module's archetypes
+    vpc.j2
+```
+
+### Key Features
+
+- **Auto-discovers modules**: Scans `archetypes/` for subdirectories (module names)
+- **Reuses CLI components**: Imports actual CLI classes (Template, VariableCollection, DisplayManager) for identical behavior
+- **Loads module specs**: Pulls variable specifications from `cli/modules/<module>/spec_v*.py` for defaults
+- **Full variable context**: Provides ALL variables with defaults (not just satisfied ones) for complete rendering
+- **Three commands**: `list`, `show`, `generate`
+- **Testing only**: The `generate` command NEVER writes files - it always shows preview output only
+
+### Implementation Details
+
+**How it works:**
+1. Module discovery: Finds subdirectories in `archetypes/` (e.g., `compose`)
+2. For each module, creates a Typer sub-app with list/show/generate commands
+3. Archetype files are simple `.j2` files (no `template.yaml` needed)
+4. Variable defaults come from module spec: `cli/modules/<module>/spec_v*.py`
+5. Rendering uses Jinja2 with full variable context from spec
+
+**ArchetypeTemplate class:**
+- Simplified template wrapper for single .j2 files
+- Loads module spec and converts to VariableCollection
+- Extracts ALL variables (not just satisfied) from spec sections
+- Merges user overrides (`--var`) on top of spec defaults
+- Renders using Jinja2 FileSystemLoader
+
+**Variable defaults source:**
+```python
+# Defaults come from module spec files
+from cli.modules.compose import spec  # OrderedDict with variable definitions
+vc = VariableCollection(spec)         # Convert to VariableCollection
+
+# Extract all variables with their default values
+for section_name, section in vc._sections.items():
+    for var_name, var in section.variables.items():
+        if var.value is not None:  # var.value stores the default
+            render_context[var_name] = var.value
+```

+ 3 - 0
archetypes/__init__.py

@@ -0,0 +1,3 @@
+"""Archetypes testing package for template snippet development."""
+
+__version__ = "0.1.0"

+ 434 - 0
archetypes/__main__.py

@@ -0,0 +1,434 @@
+#!/usr/bin/env python3
+"""
+Archetypes testing tool - for developing and testing template snippets.
+Usage: python3 -m archetypes <module> <command>
+"""
+
+from __future__ import annotations
+
+import logging
+import sys
+from pathlib import Path
+from typing import Optional, Dict, Any, List
+
+from typer import Typer, Argument, Option
+from rich.console import Console
+from rich.table import Table
+from rich.panel import Panel
+
+# Import CLI components
+from cli.core.template import Template
+from cli.core.collection import VariableCollection
+from cli.core.display import DisplayManager
+from cli.core.exceptions import (
+    TemplateLoadError,
+    TemplateSyntaxError,
+    TemplateValidationError,
+    TemplateRenderError,
+)
+
+app = Typer(
+    help="Test and develop template snippets (archetypes) without full template structure.",
+    add_completion=True,
+    rich_markup_mode="rich",
+)
+console = Console()
+display = DisplayManager()
+
+# Base directory for archetypes
+ARCHETYPES_DIR = Path(__file__).parent
+
+
+def setup_logging(log_level: str = "WARNING") -> None:
+    """Configure logging for debugging."""
+    numeric_level = getattr(logging, log_level.upper(), None)
+    if not isinstance(numeric_level, int):
+        raise ValueError(f"Invalid log level: {log_level}")
+    
+    logging.basicConfig(
+        level=numeric_level,
+        format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
+        datefmt="%Y-%m-%d %H:%M:%S",
+    )
+
+
+class ArchetypeTemplate:
+    """Simplified template for testing individual .j2 files."""
+    
+    def __init__(self, file_path: Path, module_name: str):
+        self.file_path = file_path
+        self.module_name = module_name
+        self.id = file_path.stem  # Filename without extension
+        self.template_dir = file_path.parent
+        
+        # Create a minimal template.yaml in memory
+        self.metadata = type('obj', (object,), {
+            'name': f"Archetype: {self.id}",
+            'description': f"Testing archetype from {file_path.name}",
+            'version': "0.1.0",
+            'author': "Testing",
+            'library': "archetype",
+            'tags': ["archetype", "test"],
+        })()
+        
+        # Parse spec from module if available
+        self.variables = self._load_module_spec()
+    
+    def _load_module_spec(self) -> Optional[VariableCollection]:
+        """Load variable spec from the module and merge with extension.yaml if present."""
+        try:
+            # Import the module to get its spec
+            if self.module_name == "compose":
+                from cli.modules.compose import spec
+                from collections import OrderedDict
+                import yaml
+                
+                # Convert spec to dict if needed
+                if isinstance(spec, (dict, OrderedDict)):
+                    spec_dict = OrderedDict(spec)
+                elif isinstance(spec, VariableCollection):
+                    # Extract dict from existing VariableCollection (shouldn't happen)
+                    spec_dict = OrderedDict()
+                else:
+                    logging.warning(f"Spec for {self.module_name} has unexpected type: {type(spec)}")
+                    return None
+                
+                # Check for extension.yaml in the archetype directory
+                extension_file = self.template_dir / "extension.yaml"
+                if extension_file.exists():
+                    try:
+                        with open(extension_file, 'r') as f:
+                            extension_vars = yaml.safe_load(f)
+                        
+                        if extension_vars:
+                            # Apply extension defaults to existing variables in their sections
+                            # Extension vars that don't exist will be added to a "testing" section
+                            applied_count = 0
+                            new_vars = {}
+                            
+                            for var_name, var_spec in extension_vars.items():
+                                found = False
+                                # Search for the variable in existing sections
+                                for section_name, section_data in spec_dict.items():
+                                    if "vars" in section_data and var_name in section_data["vars"]:
+                                        # Update the default value for existing variable
+                                        if "default" in var_spec:
+                                            section_data["vars"][var_name]["default"] = var_spec["default"]
+                                            applied_count += 1
+                                            found = True
+                                            break
+                                
+                                # If variable doesn't exist in spec, add it to testing section
+                                if not found:
+                                    new_vars[var_name] = var_spec
+                            
+                            # Add new test-only variables to testing section
+                            if new_vars:
+                                if "testing" not in spec_dict:
+                                    spec_dict["testing"] = {
+                                        "title": "Testing Variables",
+                                        "description": "Additional variables for archetype testing",
+                                        "vars": {}
+                                    }
+                                spec_dict["testing"]["vars"].update(new_vars)
+                            
+                            logging.debug(f"Applied {applied_count} extension defaults, added {len(new_vars)} new test variables from {extension_file}")
+                    except Exception as e:
+                        logging.warning(f"Failed to load extension.yaml: {e}")
+                
+                return VariableCollection(spec_dict)
+        except Exception as e:
+            logging.warning(f"Could not load spec for module {self.module_name}: {e}")
+            return None
+    
+    def render(self, variables: Optional[Dict[str, Any]] = None) -> Dict[str, str]:
+        """Render the single .j2 file using CLI's Template class."""
+        # Create a minimal template directory structure in memory
+        # by using the Template class's rendering capabilities
+        from jinja2 import Environment, FileSystemLoader, StrictUndefined
+        
+        # Set up Jinja2 environment with the archetype directory
+        env = Environment(
+            loader=FileSystemLoader(str(self.template_dir)),
+            undefined=StrictUndefined,
+            trim_blocks=True,
+            lstrip_blocks=True,
+            keep_trailing_newline=True,
+        )
+        
+        # Get variable values
+        if variables is None:
+            variables = {}
+        
+        # Get default values from spec if available
+        if self.variables:
+            # Get ALL variable values, not just satisfied ones
+            # This is needed for archetype testing where we want full template context
+            # Include None values so templates can properly handle optional variables
+            spec_values = {}
+            for section_name, section in self.variables._sections.items():
+                for var_name, var in section.variables.items():
+                    # Include ALL variables, even if value is None
+                    # This allows Jinja2 templates to handle optional variables properly
+                    spec_values[var_name] = var.value
+            # Merge: CLI variables override spec defaults
+            final_values = {**spec_values, **variables}
+        else:
+            final_values = variables
+        
+        try:
+            # Load and render the template
+            template = env.get_template(self.file_path.name)
+            rendered_content = template.render(**final_values)
+            
+            # Remove .j2 extension for output filename
+            output_filename = self.file_path.name.replace('.j2', '')
+            
+            return {output_filename: rendered_content}
+        except Exception as e:
+            raise TemplateRenderError(f"Failed to render {self.file_path.name}: {e}")
+
+
+def find_archetypes(module_name: str) -> List[Path]:
+    """Find all .j2 files in the module's archetype directory."""
+    module_dir = ARCHETYPES_DIR / module_name
+    
+    if not module_dir.exists():
+        console.print(f"[red]Module directory not found: {module_dir}[/red]")
+        return []
+    
+    # Find all .j2 files
+    j2_files = list(module_dir.glob("*.j2"))
+    return sorted(j2_files)
+
+
+def create_module_commands(module_name: str) -> Typer:
+    """Create a Typer app with commands for a specific module."""
+    module_app = Typer(help=f"Manage {module_name} archetypes")
+    
+    @module_app.command()
+    def list() -> None:
+        """List all archetype files for this module."""
+        archetypes = find_archetypes(module_name)
+        
+        if not archetypes:
+            display.display_warning(
+                f"No archetypes found for module '{module_name}'",
+                context=f"directory: {ARCHETYPES_DIR / module_name}"
+            )
+            return
+        
+        # Create table
+        table = Table(title=f"Archetypes for '{module_name}'", show_header=True, header_style="bold cyan")
+        table.add_column("ID", style="cyan")
+        table.add_column("Filename", style="white")
+        table.add_column("Size", style="dim")
+        
+        for archetype_path in archetypes:
+            file_size = archetype_path.stat().st_size
+            if file_size < 1024:
+                size_str = f"{file_size}B"
+            else:
+                size_str = f"{file_size / 1024:.1f}KB"
+            
+            table.add_row(
+                archetype_path.stem,
+                archetype_path.name,
+                size_str,
+            )
+        
+        console.print(table)
+        console.print(f"\n[dim]Found {len(archetypes)} archetype(s)[/dim]")
+    
+    @module_app.command()
+    def show(
+        id: str = Argument(..., help="Archetype ID (filename without .j2)"),
+    ) -> None:
+        """Show details of an archetype file."""
+        archetypes = find_archetypes(module_name)
+        
+        # Find the archetype
+        archetype_path = None
+        for path in archetypes:
+            if path.stem == id:
+                archetype_path = path
+                break
+        
+        if not archetype_path:
+            display.display_error(
+                f"Archetype '{id}' not found",
+                context=f"module '{module_name}'"
+            )
+            return
+        
+        # Load archetype
+        archetype = ArchetypeTemplate(archetype_path, module_name)
+        
+        # Display details
+        console.print()
+        console.print(Panel(
+            f"[bold]{archetype.metadata.name}[/bold]\n"
+            f"{archetype.metadata.description}\n\n"
+            f"[dim]Module:[/dim] {module_name}\n"
+            f"[dim]File:[/dim] {archetype_path.name}\n"
+            f"[dim]Path:[/dim] {archetype_path}",
+            title="Archetype Details",
+            border_style="cyan",
+        ))
+        
+        # Show variables if spec is loaded
+        if archetype.variables:
+            console.print("\n[bold]Available Variables:[/bold]")
+            
+            # Access the private _sections attribute
+            for section_name, section in archetype.variables._sections.items():
+                if section.variables:
+                    console.print(f"\n[cyan]{section.title or section_name.capitalize()}:[/cyan]")
+                    for var_name, var in section.variables.items():
+                        default = var.value if var.value is not None else "[dim]none[/dim]"
+                        console.print(f"  {var_name}: {default}")
+        else:
+            console.print("\n[yellow]No variable spec loaded for this module[/yellow]")
+        
+        # Show file content
+        console.print("\n[bold]Template Content:[/bold]")
+        console.print("─" * 80)
+        with open(archetype_path, 'r') as f:
+            console.print(f.read())
+        console.print()
+    
+    @module_app.command()
+    def generate(
+        id: str = Argument(..., help="Archetype ID (filename without .j2)"),
+        directory: Optional[str] = Argument(
+            None, help="Output directory (for reference only - no files are written)"
+        ),
+        var: Optional[List[str]] = Option(
+            None,
+            "--var",
+            "-v",
+            help="Variable override (KEY=VALUE format)",
+        ),
+    ) -> None:
+        """Generate output from an archetype file (always in preview mode)."""
+        # Archetypes ALWAYS run in dry-run mode with content display
+        # This is a testing tool - it never writes actual files
+        dry_run = True
+        show_content = True
+        
+        archetypes = find_archetypes(module_name)
+        
+        # Find the archetype
+        archetype_path = None
+        for path in archetypes:
+            if path.stem == id:
+                archetype_path = path
+                break
+        
+        if not archetype_path:
+            display.display_error(
+                f"Archetype '{id}' not found",
+                context=f"module '{module_name}'"
+            )
+            return
+        
+        # Load archetype
+        archetype = ArchetypeTemplate(archetype_path, module_name)
+        
+        # Parse variable overrides
+        variables = {}
+        if var:
+            for var_option in var:
+                if "=" in var_option:
+                    key, value = var_option.split("=", 1)
+                    variables[key] = value
+                else:
+                    console.print(f"[yellow]Warning: Invalid --var format '{var_option}' (use KEY=VALUE)[/yellow]")
+        
+        # Render the archetype
+        try:
+            rendered_files = archetype.render(variables)
+        except Exception as e:
+            display.display_error(
+                f"Failed to render archetype: {e}",
+                context=f"archetype '{id}'"
+            )
+            return
+        
+        # Determine output directory (for display purposes only)
+        if directory:
+            output_dir = Path(directory)
+        else:
+            output_dir = Path.cwd()
+        
+        # Always show preview (archetypes never write files)
+        console.print()
+        console.print("[bold cyan]Archetype Preview (Testing Mode)[/bold cyan]")
+        console.print("[dim]This tool never writes files - it's for testing template snippets only[/dim]")
+        console.print()
+        console.print(f"[dim]Reference directory:[/dim] {output_dir}")
+        console.print(f"[dim]Files to preview:[/dim] {len(rendered_files)}")
+        console.print()
+        
+        for filename, content in rendered_files.items():
+            full_path = output_dir / filename
+            status = "Would overwrite" if full_path.exists() else "Would create"
+            size = len(content.encode('utf-8'))
+            console.print(f"  [{status}] {filename} ({size} bytes)")
+        
+        console.print()
+        console.print("[bold]Rendered Content:[/bold]")
+        console.print("─" * 80)
+        for filename, content in rendered_files.items():
+            console.print(content)
+        
+        console.print()
+        display.display_success("Preview complete - no files were written")
+    
+    return module_app
+
+
+def init_app() -> None:
+    """Initialize the application by discovering modules and registering commands."""
+    # Find all module directories in archetypes/
+    if ARCHETYPES_DIR.exists():
+        for module_dir in ARCHETYPES_DIR.iterdir():
+            if module_dir.is_dir() and not module_dir.name.startswith(('_', '.')):
+                module_name = module_dir.name
+                # Register module commands
+                module_app = create_module_commands(module_name)
+                app.add_typer(module_app, name=module_name)
+
+
+@app.callback(invoke_without_command=True)
+def main(
+    log_level: Optional[str] = Option(
+        None,
+        "--log-level",
+        help="Set logging level (DEBUG, INFO, WARNING, ERROR)",
+    ),
+) -> None:
+    """Archetypes testing tool for template snippet development."""
+    if log_level:
+        setup_logging(log_level)
+    else:
+        logging.disable(logging.CRITICAL)
+    
+    import click
+    ctx = click.get_current_context()
+    
+    if ctx.invoked_subcommand is None:
+        console.print(ctx.get_help())
+        sys.exit(0)
+
+
+if __name__ == "__main__":
+    try:
+        init_app()
+        app()
+    except KeyboardInterrupt:
+        console.print("\n[yellow]Operation cancelled[/yellow]")
+        sys.exit(130)
+    except Exception as e:
+        console.print(f"[bold red]Error:[/bold red] {e}")
+        sys.exit(1)

+ 142 - 0
archetypes/compose/extension.yaml

@@ -0,0 +1,142 @@
+---
+# Extension variables for archetype testing
+# These variables are only available when testing archetypes
+# and are NOT part of the main module spec
+# They provide reasonable defaults for variables that normally have None values
+
+# General service defaults
+service_name:
+  type: str
+  description: Service name for testing
+  default: testapp
+
+container_name:
+  type: str
+  description: Container name for testing
+  default: testapp-container
+
+container_hostname:
+  type: str
+  description: Container hostname for testing
+  default: testapp-host
+
+# Traefik defaults
+traefik_host:
+  type: hostname
+  description: Traefik host for testing
+  default: app.example.com
+
+# Database defaults
+database_port:
+  type: int
+  description: Database port for testing
+  default: 5432
+
+database_name:
+  type: str
+  description: Database name for testing
+  default: testdb
+
+database_user:
+  type: str
+  description: Database user for testing
+  default: dbuser
+
+database_password:
+  type: str
+  description: Database password for testing
+  default: secretpassword123
+  sensitive: true
+
+# Email server defaults
+email_host:
+  type: str
+  description: Email server host for testing
+  default: smtp.example.com
+
+email_username:
+  type: str
+  description: Email username for testing
+  default: noreply@example.com
+
+email_password:
+  type: str
+  description: Email password for testing
+  default: emailpass123
+  sensitive: true
+
+email_from:
+  type: str
+  description: Email from address for testing
+  default: noreply@example.com
+
+# Authentik SSO defaults
+authentik_url:
+  type: url
+  description: Authentik URL for testing
+  default: https://auth.example.com
+
+authentik_slug:
+  type: str
+  description: Authentik application slug for testing
+  default: testapp
+
+authentik_client_id:
+  type: str
+  description: Authentik client ID for testing
+  default: client_id_12345
+
+authentik_client_secret:
+  type: str
+  description: Authentik client secret for testing
+  default: client_secret_abcdef
+  sensitive: true
+
+# Ports defaults
+ports_http:
+  type: int
+  description: HTTP port for testing
+  default: 8080
+
+ports_https:
+  type: int
+  description: HTTPS port for testing
+  default: 8443
+
+# Additional test variables
+test_image:
+  type: str
+  description: Docker image for testing
+  default: nginx:alpine
+
+test_port:
+  type: int
+  description: Port number for testing
+  default: 80
+
+test_secret_token:
+  type: str
+  description: Example secret token
+  default: my-secret-token-123
+  sensitive: true
+
+test_api_key:
+  type: str
+  description: Example API key
+  default: api_key_example_12345
+  sensitive: true
+
+test_database_url:
+  type: str
+  description: Example database connection string
+  default: postgresql://user:pass@localhost:5432/db
+
+test_environment_var:
+  type: str
+  description: Example environment variable
+  default: production
+
+test_config_path:
+  type: str
+  description: Example configuration file path
+  default: /etc/app/config.yaml

+ 51 - 0
archetypes/compose/network-v1.j2

@@ -0,0 +1,51 @@
+---
+services:
+  test_service:
+    {% if network_mode == 'host' %}
+    network_mode: host
+    {% else %}
+    networks:
+      {% if traefik_enabled %}
+      {{ traefik_network }}:
+      {% endif %}
+      {% if network_mode == 'macvlan' %}
+      {{ network_name }}:
+        ipv4_address: {{ network_macvlan_ipv4_address }}
+      {% elif network_mode == 'bridge' %}
+      {{ network_name }}:
+      {% endif %}
+    {% endif %}
+
+{% if network_mode != 'host' %}
+networks:
+  {% if network_mode == 'macvlan' %}
+  {{ network_name }}:
+    {% if network_external %}
+    external: true
+    {% else %}
+    driver: macvlan
+    driver_opts:
+      parent: {{ network_macvlan_parent_interface }}
+    ipam:
+      config:
+        - subnet: {{ network_macvlan_subnet }}
+          gateway: {{ network_macvlan_gateway }}
+    name: {{ network_name }}
+    {% endif %}
+  {% elif network_mode == 'bridge' and network_external %}
+  {{ network_name }}:
+    external: true
+  {% elif network_mode == 'bridge' and not network_external %}
+  {{ network_name }}:
+    {% if swarm_enabled %}
+    driver: overlay
+    attachable: true
+    {% else %}
+    driver: bridge
+    {% endif %}
+  {% endif %}
+  {% if traefik_enabled %}
+  {{ traefik_network }}:
+    external: true
+  {% endif %}
+{% endif %}

+ 65 - 0
archetypes/compose/ports-v1.j2

@@ -0,0 +1,65 @@
+---
+services:
+  {{ service_name }}:
+    image: {{ test_image }}
+    {% if swarm_enabled %}
+    deploy:
+      mode: {{ swarm_placement_mode }}
+      {% if swarm_placement_mode == 'replicated' %}
+      replicas: {{ swarm_replicas }}
+      {% endif %}
+      {% if traefik_enabled %}
+      labels:
+        - traefik.enable=true
+        - traefik.http.services.{{ service_name }}-web.loadBalancer.server.port={{ test_port }}
+        - traefik.http.routers.{{ service_name }}-http.service={{ service_name }}-web
+        - traefik.http.routers.{{ service_name }}-http.rule=Host(`{{ traefik_host }}`)
+        - traefik.http.routers.{{ service_name }}-http.entrypoints={{ traefik_entrypoint }}
+      {% endif %}
+    {% else %}
+    {% if traefik_enabled %}
+    labels:
+      - traefik.enable=true
+      - traefik.http.services.{{ service_name }}-web.loadBalancer.server.port={{ test_port }}
+      - traefik.http.routers.{{ service_name }}-http.service={{ service_name }}-web
+      - traefik.http.routers.{{ service_name }}-http.rule=Host(`{{ traefik_host }}`)
+      - traefik.http.routers.{{ service_name }}-http.entrypoints={{ traefik_entrypoint }}
+    {% endif %}
+    restart: {{ restart_policy }}
+    {% endif %}
+    {% if not traefik_enabled %}
+    ports:
+      {% if swarm_enabled %}
+      # Swarm mode: long syntax with mode: host
+      - target: {{ test_port }}
+        published: {{ ports_http }}
+        protocol: tcp
+        mode: host
+      - target: 443
+        published: {{ ports_https }}
+        protocol: tcp
+        mode: host
+      {% else %}
+      # Standalone mode: short syntax
+      - "{{ ports_http }}:{{ test_port }}"
+      - "{{ ports_https }}:443"
+      {% endif %}
+    {% endif %}
+    networks:
+      {% if traefik_enabled %}
+      - {{ traefik_network }}
+      {% endif %}
+      - {{ network_name }}
+
+networks:
+  {% if traefik_enabled %}
+  {{ traefik_network }}:
+    external: true
+  {% endif %}
+  {{ network_name }}:
+    {% if swarm_enabled %}
+    driver: overlay
+    attachable: true
+    {% else %}
+    driver: bridge
+    {% endif %}

+ 77 - 0
archetypes/compose/swarm-v1.j2

@@ -0,0 +1,77 @@
+---
+services:
+  {{ service_name }}:
+    image: {{ test_image }}
+    {% if swarm_enabled %}
+    deploy:
+      mode: {{ swarm_placement_mode }}
+      {% if swarm_placement_mode == 'replicated' %}
+      replicas: {{ swarm_replicas }}
+      {% endif %}
+      {% if swarm_placement_host %}
+      placement:
+        constraints:
+          - node.hostname == {{ swarm_placement_host }}
+      {% endif %}
+      {% if traefik_enabled %}
+      labels:
+        - traefik.enable=true
+        - traefik.http.services.{{ service_name }}-web.loadBalancer.server.port={{ test_port }}
+        - traefik.http.routers.{{ service_name }}-http.service={{ service_name }}-web
+        - 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.service={{ service_name }}-web
+        - 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 %}
+      update_config:
+        parallelism: 1
+        delay: 10s
+      restart_policy:
+        condition: on-failure
+    {% else %}
+    {% if traefik_enabled %}
+    labels:
+      - traefik.enable=true
+      - traefik.http.services.{{ service_name }}-web.loadBalancer.server.port={{ test_port }}
+      - traefik.http.routers.{{ service_name }}-http.service={{ service_name }}-web
+      - 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.service={{ service_name }}-web
+      - 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 %}
+    restart: {{ restart_policy }}
+    {% endif %}
+    networks:
+      {% if traefik_enabled %}
+      - {{ traefik_network }}
+      {% endif %}
+      - {{ network_name }}
+
+{% if swarm_enabled %}
+networks:
+  {% if traefik_enabled %}
+  {{ traefik_network }}:
+    external: true
+  {% endif %}
+  {{ network_name }}:
+    driver: overlay
+    attachable: true
+{% else %}
+networks:
+  {% if traefik_enabled %}
+  {{ traefik_network }}:
+    external: true
+  {% endif %}
+  {{ network_name }}:
+    driver: bridge
+{% endif %}

+ 28 - 0
archetypes/compose/traefik-v1.j2

@@ -0,0 +1,28 @@
+---
+services:
+  {{ service_name }}:
+    image: {{ test_image }}
+    {% if traefik_enabled %}
+    labels:
+      - traefik.enable=true
+      - traefik.http.services.{{ service_name }}-web.loadBalancer.server.port={{ test_port }}
+      - traefik.http.routers.{{ service_name }}-http.service={{ service_name }}-web
+      - 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.service={{ service_name }}-web
+      - 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 %}
+    networks:
+      - {{ traefik_network }}
+    {% endif %}
+    restart: {{ restart_policy }}
+
+{% if traefik_enabled %}
+networks:
+  {{ traefik_network }}:
+    external: true
+{% endif %}

+ 9 - 0
network-v1

@@ -0,0 +1,9 @@
+---
+services:
+  test_service:
+    networks:
+      bridge:
+
+networks:
+  bridge:
+    driver: bridge