Repository: ChristianLempa/boilerplates
Branch: main
Commit: c7e46f7762f8
Files: 246
Total size: 894.6 KB
Directory structure:
gitextract_wuyffx3g/
├── .editorconfig
├── .github/
│ ├── FUNDING.yml
│ ├── issue_template.md
│ ├── pull_request_template.md
│ ├── scripts/
│ │ ├── generate_wiki_docs.py
│ │ └── sync-template-version.sh
│ └── workflows/
│ ├── codequality-ruff.yaml
│ ├── codequality-yamllint.yaml
│ ├── docs-update-wiki.yaml
│ ├── release-create-cli-release.yaml
│ └── renovate-sync-versions.yaml
├── .gitignore
├── .wiki/
│ ├── Core-Concepts-Defaults.md
│ ├── Core-Concepts-Libraries.md
│ ├── Core-Concepts-Templates.md
│ ├── Core-Concepts-Variables.md
│ ├── Getting-Started.md
│ ├── Home.md
│ ├── Installation.md
│ ├── Variables-Ansible.md
│ ├── Variables-Compose.md
│ ├── Variables-Helm.md
│ ├── Variables-Kubernetes.md
│ ├── Variables-Packer.md
│ ├── Variables-Terraform.md
│ ├── Variables.md
│ └── _Sidebar.md
├── .yamllint
├── AGENTS.md
├── CHANGELOG.md
├── CONTRIBUTING.md
├── LICENSE
├── MANIFEST.in
├── README.md
├── SECURITY.md
├── cli/
│ ├── __init__.py
│ ├── __main__.py
│ ├── core/
│ │ ├── config/
│ │ │ ├── __init__.py
│ │ │ └── config_manager.py
│ │ ├── display/
│ │ │ ├── __init__.py
│ │ │ ├── display_base.py
│ │ │ ├── display_icons.py
│ │ │ ├── display_settings.py
│ │ │ ├── display_status.py
│ │ │ ├── display_table.py
│ │ │ ├── display_template.py
│ │ │ └── display_variable.py
│ │ ├── exceptions.py
│ │ ├── input/
│ │ │ ├── __init__.py
│ │ │ ├── input_manager.py
│ │ │ ├── input_settings.py
│ │ │ └── prompt_manager.py
│ │ ├── library.py
│ │ ├── module/
│ │ │ ├── __init__.py
│ │ │ ├── base_commands.py
│ │ │ ├── base_module.py
│ │ │ ├── config_commands.py
│ │ │ └── helpers.py
│ │ ├── prompt.py
│ │ ├── registry.py
│ │ ├── repo.py
│ │ ├── template/
│ │ │ ├── __init__.py
│ │ │ ├── template.py
│ │ │ ├── variable.py
│ │ │ ├── variable_collection.py
│ │ │ └── variable_section.py
│ │ ├── validators.py
│ │ └── version.py
│ └── modules/
│ ├── __init__.py
│ ├── ansible/
│ │ └── __init__.py
│ ├── compose/
│ │ ├── __init__.py
│ │ └── validate.py
│ ├── helm/
│ │ └── __init__.py
│ ├── kubernetes/
│ │ └── __init__.py
│ ├── packer/
│ │ └── __init__.py
│ └── terraform/
│ └── __init__.py
├── flake.nix
├── library/
│ ├── ansible/
│ │ ├── checkmk-install-agent/
│ │ │ ├── playbook.yaml.j2
│ │ │ └── template.yaml
│ │ ├── checkmk-manage-host/
│ │ │ ├── playbook.yaml.j2
│ │ │ └── template.yaml
│ │ ├── docker-certs/
│ │ │ ├── playbook.yaml.j2
│ │ │ └── template.yaml
│ │ ├── docker-certs-enable/
│ │ │ ├── playbook.yaml.j2
│ │ │ └── template.yaml
│ │ ├── docker-install-ubuntu/
│ │ │ ├── main.yml.j2
│ │ │ └── template.yaml
│ │ ├── docker-prune/
│ │ │ ├── playbook.yaml.j2
│ │ │ └── template.yaml
│ │ ├── ubuntu-add-sshkey/
│ │ │ ├── playbook.yaml.j2
│ │ │ └── template.yaml
│ │ ├── ubuntu-apt-update/
│ │ │ ├── playbook.yaml.j2
│ │ │ └── template.yaml
│ │ └── ubuntu-vm-core/
│ │ ├── playbook.yaml.j2
│ │ └── template.yaml
│ ├── compose/
│ │ ├── adguardhome/
│ │ │ ├── compose.yaml.j2
│ │ │ └── template.yaml
│ │ ├── alloy/
│ │ │ ├── compose.yaml.j2
│ │ │ ├── config/
│ │ │ │ ├── common.alloy.j2
│ │ │ │ ├── logs_docker.alloy.j2
│ │ │ │ ├── logs_system.alloy.j2
│ │ │ │ ├── metrics_docker.alloy.j2
│ │ │ │ ├── metrics_system.alloy.j2
│ │ │ │ └── targets.alloy.j2
│ │ │ └── template.yaml
│ │ ├── authentik/
│ │ │ ├── compose.yaml.j2
│ │ │ └── template.yaml
│ │ ├── bind9/
│ │ │ ├── compose.yaml.j2
│ │ │ ├── config/
│ │ │ │ ├── named.conf.j2
│ │ │ │ ├── named.conf.zones.j2
│ │ │ │ └── tsig.key.j2
│ │ │ └── template.yaml
│ │ ├── checkmk/
│ │ │ ├── compose.yaml.j2
│ │ │ └── template.yaml
│ │ ├── dockge/
│ │ │ ├── compose.yaml.j2
│ │ │ └── template.yaml
│ │ ├── gitea/
│ │ │ ├── compose.yaml.j2
│ │ │ └── template.yaml
│ │ ├── gitlab/
│ │ │ ├── compose.yaml.j2
│ │ │ ├── config/
│ │ │ │ └── gitlab.rb.j2
│ │ │ └── template.yaml
│ │ ├── gitlab-runner/
│ │ │ ├── compose.yaml.j2
│ │ │ ├── config/
│ │ │ │ └── config.toml
│ │ │ └── template.yaml
│ │ ├── grafana/
│ │ │ ├── compose.yaml.j2
│ │ │ └── template.yaml
│ │ ├── homeassistant/
│ │ │ ├── compose.yaml.j2
│ │ │ └── template.yaml
│ │ ├── homepage/
│ │ │ ├── compose.yaml.j2
│ │ │ └── template.yaml
│ │ ├── homer/
│ │ │ ├── assets/
│ │ │ │ └── config.yml.j2
│ │ │ ├── compose.yaml.j2
│ │ │ └── template.yaml
│ │ ├── influxdb/
│ │ │ ├── compose.yaml.j2
│ │ │ └── template.yaml
│ │ ├── komodo/
│ │ │ ├── compose.yaml.j2
│ │ │ └── template.yaml
│ │ ├── loki/
│ │ │ ├── compose.yaml.j2
│ │ │ ├── config/
│ │ │ │ └── config.yaml.j2
│ │ │ └── template.yaml
│ │ ├── mariadb/
│ │ │ ├── compose.yaml.j2
│ │ │ └── template.yaml
│ │ ├── n8n/
│ │ │ ├── compose.yaml.j2
│ │ │ └── template.yaml
│ │ ├── netbox/
│ │ │ ├── compose.yaml.j2
│ │ │ └── template.yaml
│ │ ├── nextcloud/
│ │ │ ├── compose.yaml.j2
│ │ │ └── template.yaml
│ │ ├── nginx/
│ │ │ ├── compose.yaml.j2
│ │ │ ├── config/
│ │ │ │ └── default.conf
│ │ │ ├── data/
│ │ │ │ └── index.html
│ │ │ └── template.yaml
│ │ ├── openwebui/
│ │ │ ├── compose.yaml.j2
│ │ │ └── template.yaml
│ │ ├── passbolt/
│ │ │ ├── compose.yaml.j2
│ │ │ └── template.yaml
│ │ ├── pihole/
│ │ │ ├── compose.yaml.j2
│ │ │ └── template.yaml
│ │ ├── portainer/
│ │ │ ├── compose.yaml.j2
│ │ │ └── template.yaml
│ │ ├── postgres/
│ │ │ ├── compose.yaml.j2
│ │ │ ├── compose.yaml.j2.final
│ │ │ └── template.yaml
│ │ ├── prometheus/
│ │ │ ├── compose.yaml.j2
│ │ │ ├── config/
│ │ │ │ └── prometheus.yaml
│ │ │ └── template.yaml
│ │ ├── renovate/
│ │ │ ├── compose.yaml.j2
│ │ │ └── template.yaml
│ │ ├── semaphoreui/
│ │ │ ├── compose.yaml.j2
│ │ │ └── template.yaml
│ │ ├── traefik/
│ │ │ ├── compose.yaml.j2
│ │ │ └── template.yaml
│ │ ├── twingate-connector/
│ │ │ ├── compose.yaml.j2
│ │ │ └── template.yaml
│ │ ├── uptimekuma/
│ │ │ ├── compose.yaml.j2
│ │ │ └── template.yaml
│ │ └── whoami/
│ │ ├── compose.yaml.j2
│ │ └── template.yaml
│ ├── helm/
│ │ ├── authentik/
│ │ │ ├── secrets.yaml.j2
│ │ │ ├── template.yaml
│ │ │ └── values.yaml.j2
│ │ ├── certmanager/
│ │ │ ├── template.yaml
│ │ │ └── values.yaml.j2
│ │ ├── longhorn/
│ │ │ ├── template.yaml
│ │ │ └── values.yaml.j2
│ │ ├── netbox/
│ │ │ ├── template.yaml
│ │ │ └── values.yaml.j2
│ │ ├── portainer/
│ │ │ ├── template.yaml
│ │ │ └── values.yaml.j2
│ │ └── traefik/
│ │ ├── template.yaml
│ │ └── values.yaml.j2
│ ├── kubernetes/
│ │ ├── certmanager-certificate/
│ │ │ ├── certificate.yaml.j2
│ │ │ └── template.yaml
│ │ ├── certmanager-clusterissuer/
│ │ │ ├── clusterissuer.yaml.j2
│ │ │ └── template.yaml
│ │ ├── certmanager-issuer/
│ │ │ ├── issuer.yaml.j2
│ │ │ └── template.yaml
│ │ ├── core-configmap/
│ │ │ ├── configmap.yaml.j2
│ │ │ └── template.yaml
│ │ ├── core-ingress/
│ │ │ ├── ingress.yaml.j2
│ │ │ └── template.yaml
│ │ ├── core-ingressclass/
│ │ │ ├── ingressclass.yaml.j2
│ │ │ └── template.yaml
│ │ ├── core-persistentvolume/
│ │ │ ├── pv.yaml.j2
│ │ │ └── template.yaml
│ │ ├── core-persistentvolumeclaim/
│ │ │ ├── pvc.yaml.j2
│ │ │ └── template.yaml
│ │ ├── core-secret/
│ │ │ └── template.yaml
│ │ ├── core-service/
│ │ │ ├── service.yaml.j2
│ │ │ └── template.yaml
│ │ ├── core-serviceaccount/
│ │ │ ├── serviceaccount.yaml.j2
│ │ │ └── template.yaml
│ │ ├── core-storageclass/
│ │ │ ├── storageclass.yaml.j2
│ │ │ └── template.yaml
│ │ ├── traefik-ingressroute/
│ │ │ ├── ingressroute.yaml.j2
│ │ │ └── template.yaml
│ │ ├── traefik-ingressroutetcp/
│ │ │ ├── ingressroutetcp.yaml.j2
│ │ │ └── template.yaml
│ │ ├── traefik-middleware/
│ │ │ ├── middleware.yaml.j2
│ │ │ └── template.yaml
│ │ └── twingate-connector/
│ │ ├── connector.yaml.j2
│ │ └── template.yaml
│ ├── packer/
│ │ └── proxmox-iso-ubuntu/
│ │ ├── files/
│ │ │ └── 99-pve.cfg
│ │ ├── http/
│ │ │ ├── meta-data
│ │ │ └── user-data.j2
│ │ ├── proxmox-iso-ubuntu.pkr.hcl.j2
│ │ ├── template.yaml
│ │ └── variables.pkrvars.hcl.example
│ └── terraform/
│ ├── cloudflare-dns-record/
│ │ ├── cloudflare_dns_record.tf.j2
│ │ ├── cloudflare_zone.tf.j2
│ │ └── template.yaml
│ ├── cloudflare-ztna-application/
│ │ ├── cloudflare_account_zone.tf.j2
│ │ ├── cloudflare_zero_trust_access_application.tf.j2
│ │ ├── cloudflare_zero_trust_access_policy.tf.j2
│ │ └── template.yaml
│ ├── dns-a-record/
│ │ ├── dns_a_record_set.tf.j2
│ │ └── template.yaml
│ └── netbox-vm/
│ ├── netbox_virtual_machine.tf.j2
│ └── template.yaml
├── pyproject.toml
├── renovate.json
├── requirements.txt
├── scripts/
│ └── install.sh
└── tests/
├── __init__.py
├── test_variable.py
└── test_version.py
================================================
FILE CONTENTS
================================================
================================================
FILE: .editorconfig
================================================
# https://editorconfig.org/
root = true
[*]
charset = utf-8
end_of_line = lf
indent_size = 4
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
[*.json]
indent_size = 2
[*.{js,jsx,ts,tsx}]
indent_size = 2
[*.md]
indent_size = unset
trim_trailing_whitespace = false
[*.py]
indent_size = 4
[{*.{yaml,yml},.yamllint}]
indent_size = 2
================================================
FILE: .github/FUNDING.yml
================================================
---
# These are supported funding model platforms
patreon: christianlempa
================================================
FILE: .github/issue_template.md
================================================
### Issue Reporting
*Please write all text in English in order to facilitate communication and collaboration. Thank you!*
#### Description
[Provide a clear and concise description of the issue]
#### Steps to Reproduce
1. [First step]
2. [Second step]
3. [Any subsequent steps]
#### Expected Behavior
[Describe what you expected to happen]
#### Actual Behavior
[Describe what actually happened]
#### Screenshots
[If applicable, add screenshots to help explain the issue]
#### Additional Information
[Any additional information or context that can be helpful in resolving the issue]
#### Environment
[Include as many relevant details about the environment you experienced the bug in.]
================================================
FILE: .github/pull_request_template.md
================================================
### Pull Request
*Please write all text in English in order to facilitate communication and collaboration. Thank you!*
---
================================================
FILE: .github/scripts/generate_wiki_docs.py
================================================
#!/usr/bin/env python3
"""Generate GitHub Wiki documentation for module variables.
This script auto-generates variable documentation in GitHub Wiki markdown format
for all registered modules, using the latest schema version for each.
"""
import sys
from pathlib import Path
# Add project root to path (script is in .github/scripts, so go up twice)
project_root = Path(__file__).parent.parent.parent
sys.path.insert(0, str(project_root))
# ruff: noqa: E402
# Import all modules to register them
import cli.modules.ansible
import cli.modules.compose
import cli.modules.helm
import cli.modules.kubernetes
import cli.modules.packer
import cli.modules.terraform # noqa: F401
from cli.core.registry import registry # Module import after path manipulation
def format_value(value):
"""Format value for markdown display."""
if value is None or value == "":
return "_none_"
if isinstance(value, bool):
return "✓" if value else "✗"
if isinstance(value, list):
return ", ".join(f"`{v}`" for v in value)
return f"`{value}`"
def generate_module_docs(module_name: str, output_dir: Path): # noqa: PLR0912, PLR0915
"""Generate wiki documentation for a single module."""
# Get module class from registry
module_classes = dict(registry.iter_module_classes())
if module_name not in module_classes:
sys.stderr.write(f"Warning: Module '{module_name}' not found, skipping\n")
return False
module_cls = module_classes[module_name]
schema_version = module_cls.schema_version
# Get the spec for the latest schema version
if hasattr(module_cls, "schemas") and schema_version in module_cls.schemas:
spec = module_cls.schemas[schema_version]
elif hasattr(module_cls, "spec"):
spec = module_cls.spec
else:
sys.stderr.write(f"Warning: No spec found for module '{module_name}', skipping\n")
return False
# Generate markdown content
lines = []
# Header
lines.append(f"# {module_name.title()} Variables")
lines.append("")
lines.append(f"**Module:** `{module_name}` ")
lines.append(f"**Schema Version:** `{schema_version}` ")
lines.append(f"**Description:** {module_cls.description}")
lines.append("")
lines.append("---")
lines.append("")
lines.append(
"This page documents all available variables for the "
+ f"{module_name} module. Variables are organized into sections "
+ "that can be enabled/disabled based on your configuration needs."
)
lines.append("")
# Table of contents
lines.append("## Table of Contents")
lines.append("")
for section_key, section_data in spec.items():
section_title = section_data.get("title", section_key)
anchor = section_title.lower().replace(" ", "-").replace("/", "")
lines.append(f"- [{section_title}](#{anchor})")
lines.append("")
lines.append("---")
lines.append("")
# Process each section
for section_key, section_data in spec.items():
section_title = section_data.get("title", section_key)
section_desc = section_data.get("description", "")
section_toggle = section_data.get("toggle", "")
section_needs = section_data.get("needs", "")
section_required = section_data.get("required", False)
section_vars = section_data.get("vars", {})
# Section header
lines.append(f"## {section_title}")
lines.append("")
# Section metadata
metadata = []
if section_required:
metadata.append("**Required:** Yes")
if section_toggle:
metadata.append(f"**Toggle Variable:** `{section_toggle}`")
if section_needs:
if isinstance(section_needs, list):
needs_str = ", ".join(f"`{n}`" for n in section_needs)
else:
needs_str = f"`{section_needs}`"
metadata.append(f"**Depends On:** {needs_str}")
if metadata:
lines.append(" \n".join(metadata))
lines.append("")
if section_desc:
lines.append(section_desc)
lines.append("")
# Skip sections with no variables
if not section_vars:
lines.append("_No variables defined in this section._")
lines.append("")
continue
# Variables table
lines.append("| Variable | Type | Default | Description |")
lines.append("|----------|------|---------|-------------|")
for var_name, var_data in section_vars.items():
var_type = var_data.get("type", "str")
var_default = format_value(var_data.get("default"))
var_description = var_data.get("description", "").replace("\n", " ")
# Add extra metadata to description
extra_parts = []
if var_data.get("sensitive"):
extra_parts.append("**Sensitive**")
if var_data.get("autogenerated"):
extra_parts.append("**Auto-generated**")
if "options" in var_data:
opts = ", ".join(f"`{o}`" for o in var_data["options"])
extra_parts.append(f"**Options:** {opts}")
if "needs" in var_data:
extra_parts.append(f"**Needs:** `{var_data['needs']}`")
if "extra" in var_data:
extra_parts.append(var_data["extra"])
if extra_parts:
var_description += "
" + " • ".join(extra_parts)
lines.append(f"| `{var_name}` | `{var_type}` | {var_default} | {var_description} |")
lines.append("")
lines.append("---")
lines.append("")
# Footer
lines.append("## Notes")
lines.append("")
lines.append("- **Required sections** must be configured")
lines.append("- **Toggle variables** enable/disable entire sections")
lines.append("- **Dependencies** (`needs`) control when sections/variables are available")
lines.append("- **Sensitive variables** are masked during prompts")
lines.append("- **Auto-generated variables** are populated automatically if not provided")
lines.append("")
lines.append("---")
lines.append("")
lines.append(f"_Last updated: Schema version {schema_version}_")
# Write to file
output_file = output_dir / f"Variables-{module_name.title()}.md"
output_file.write_text("\n".join(lines))
sys.stdout.write(f"Generated: {output_file.name}\n")
return True
def generate_variables_index(modules: list[str], output_dir: Path):
"""Generate index page for all variable documentation."""
lines = []
lines.append("# Variables Documentation")
lines.append("")
lines.append("This section contains auto-generated documentation for all " + "available variables in each module.")
lines.append("")
lines.append("## Available Modules")
lines.append("")
for module_name in sorted(modules):
lines.append(f"- [{module_name.title()}](Variables-{module_name.title()})")
lines.append("")
lines.append("---")
lines.append("")
lines.append("Each module page includes:")
lines.append("")
lines.append("- Schema version information")
lines.append("- Complete list of sections and variables")
lines.append("- Variable types, defaults, and descriptions")
lines.append("- Section dependencies and toggle configurations")
lines.append("")
lines.append("---")
lines.append("")
lines.append("_This documentation is auto-generated from module schemas._")
output_file = output_dir / "Variables.md"
output_file.write_text("\n".join(lines))
sys.stdout.write(f"Generated: {output_file.name}\n")
# Minimum required arguments
MIN_ARGS = 2
def main():
"""Main entry point."""
if len(sys.argv) < MIN_ARGS:
sys.stderr.write("Usage: python3 scripts/generate_wiki_docs.py \n")
sys.exit(1)
output_dir = Path(sys.argv[1])
output_dir.mkdir(parents=True, exist_ok=True)
sys.stdout.write(f"Generating wiki documentation in: {output_dir}\n")
sys.stdout.write("\n")
# Get all registered modules
module_classes = dict(registry.iter_module_classes())
successful_modules = []
for module_name in sorted(module_classes.keys()):
if generate_module_docs(module_name, output_dir):
successful_modules.append(module_name)
sys.stdout.write("\n")
# Generate index page
if successful_modules:
generate_variables_index(successful_modules, output_dir)
sys.stdout.write("\n")
sys.stdout.write(f"✓ Successfully generated documentation for {len(successful_modules)} module(s)\n")
else:
sys.stderr.write("Error: No documentation generated\n")
sys.exit(1)
if __name__ == "__main__":
main()
================================================
FILE: .github/scripts/sync-template-version.sh
================================================
#!/usr/bin/env bash
# Sync Docker image versions to template.yaml metadata
# Triggered by GitHub Actions when Renovate updates dependencies
set -euo pipefail
# Extract version from different file types
extract_version() {
local file="$1"
local filename=$(basename "$file")
case "$filename" in
compose.yaml.j2|*.j2)
# Docker Compose or K8s manifest: extract from image: line
grep -E '^\s*image:\s*[^{]*:[^{}\s]+' "$file" | head -n1 | sed -E 's/.*:([^:]+)$/\1/' | tr -d ' ' || true
;;
values.yaml|values.yml)
# Helm values: extract from repository + tag
grep -A1 'repository:' "$file" | grep 'tag:' | sed -E 's/.*tag:\s*['\''"]?([^'\''"]+)['\''"]?/\1/' | tr -d ' ' || true
;;
esac
}
# Update template.yaml if version differs
update_template() {
local template_file="$1"
local new_version="$2"
local current_date=$(date +%Y-%m-%d)
local current_version=$(grep -E '^\s*version:\s*' "$template_file" | sed -E 's/.*version:\s*['\''"]?([^'\''"]+)['\''"]?/\1/' | tr -d ' ' || true)
if [ -n "$current_version" ] && [ "$new_version" != "$current_version" ]; then
echo "✓ Updating $template_file: $current_version → $new_version (date: $current_date)"
sed -i "s/version: .*/version: $new_version/" "$template_file"
sed -i "s/date: .*/date: '$current_date'/" "$template_file"
return 0
fi
return 1
}
# Main processing
updated=0
files=("${@:-$(find library -type f \( -name 'compose.yaml.j2' -o -name 'values.yaml' -o -name 'values.yml' \) 2>/dev/null)}")
for file in "${files[@]}"; do
[ ! -f "$file" ] && continue
template_file="$(dirname "$file")/template.yaml"
[ ! -f "$template_file" ] && continue
version=$(extract_version "$file")
[ -z "$version" ] || [[ "$version" =~ \{\{ ]] && continue
update_template "$template_file" "$version" && ((updated++)) || true
done
echo "Processed ${#files[@]} file(s), updated $updated template(s)"
exit 0
================================================
FILE: .github/workflows/codequality-ruff.yaml
================================================
---
name: Code Quality - Ruff
'on':
pull_request:
branches:
- main
push:
branches:
- main
- 'release/**'
permissions:
contents: read
jobs:
ruff:
name: Python Linting and Formatting
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.14'
- name: Install Ruff
run: pip install ruff
- name: Run Ruff Linting
run: ruff check .
- name: Run Ruff Formatting Check
run: ruff format --check .
================================================
FILE: .github/workflows/codequality-yamllint.yaml
================================================
---
name: Code Quality - yamllint
'on':
pull_request:
branches:
- main
permissions:
contents: read
jobs:
lint:
name: Linters
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
- run: yamllint --strict -- $(git ls-files '*.yaml' '*.yml')
================================================
FILE: .github/workflows/docs-update-wiki.yaml
================================================
---
name: Docs - Update Wiki
'on':
push:
branches:
- main
paths:
- 'cli/core/schema/**/*.json' # JSON schema files
- '.wiki/**' # Static wiki pages
- '.github/scripts/generate_wiki_docs.py' # Wiki generation script
- '.github/workflows/docs-update-wiki.yaml' # This workflow
workflow_dispatch: # Allow manual trigger
permissions:
contents: write
jobs:
update-wiki:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
fetch-depth: 1
- name: Checkout wiki repository
uses: actions/checkout@v6
with:
repository: ${{ github.repository }}.wiki
path: wiki
token: ${{ secrets.GITHUB_TOKEN }}
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.14'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -e .
- name: Generate variable documentation
run: |
python3 .github/scripts/generate_wiki_docs.py wiki/
- name: Sync wiki pages from .wiki directory
run: |
# Copy all markdown files from .wiki/ to wiki/ (except Variables-*.md which are auto-generated)
if [ -d ".wiki" ]; then
echo "Syncing wiki pages from .wiki/ directory..."
for file in .wiki/*.md; do
filename=$(basename "$file")
# Skip auto-generated variable documentation files
if [[ ! "$filename" =~ ^Variables- ]]; then
echo " Copying $filename"
cp "$file" "wiki/$filename"
fi
done
else
echo "No .wiki directory found, skipping static wiki pages sync"
fi
- name: Check for changes
id: changes
working-directory: wiki
run: |
git add .
if git diff --staged --quiet; then
echo "has_changes=false" >> $GITHUB_OUTPUT
echo "No changes detected in wiki documentation"
else
echo "has_changes=true" >> $GITHUB_OUTPUT
echo "Changes detected in wiki documentation"
fi
- name: Commit and push changes
if: steps.changes.outputs.has_changes == 'true'
working-directory: wiki
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git commit -m "Auto-update wiki pages"
# Pull with rebase to handle any remote changes, then push
# GitHub wikis use master as default branch
git pull --rebase origin master
git push origin master
- name: Summary
run: |
if [ "${{ steps.changes.outputs.has_changes }}" == "true" ]; then
echo "Wiki variable documentation updated successfully"
echo "View at: https://github.com/${{ github.repository }}/wiki"
else
echo "No changes to wiki documentation"
fi
================================================
FILE: .github/workflows/release-create-cli-release.yaml
================================================
---
name: Release - Create CLI GitHub Release
'on':
push:
tags:
- 'v*.*.*' # Trigger on version tags like v1.0.0, v2.1.3, etc.
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Extract version from tag
id: version
run: |
# Remove 'v' prefix if present (e.g., v1.0.0 -> 1.0.0)
VERSION="${GITHUB_REF#refs/tags/}"
VERSION="${VERSION#v}"
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "tag=$GITHUB_REF_NAME" >> $GITHUB_OUTPUT
echo "Extracted version: $VERSION from tag $GITHUB_REF_NAME"
- name: Validate version consistency
run: |
TAG_VERSION="${{ steps.version.outputs.version }}"
# Extract version from pyproject.toml
PYPROJECT_VERSION=$(grep '^version = ' pyproject.toml | sed 's/version = "\(.*\)"/\1/')
# Extract version from cli/__init__.py
CLI_VERSION=$(grep '^__version__ = ' cli/__init__.py | sed 's/__version__ = "\(.*\)"/\1/')
echo "Tag version: $TAG_VERSION"
echo "pyproject.toml: $PYPROJECT_VERSION"
echo "cli/__init__.py: $CLI_VERSION"
echo ""
# Check if all versions match
if [ "$TAG_VERSION" != "$PYPROJECT_VERSION" ]; then
echo "Error: Tag version ($TAG_VERSION) does not match pyproject.toml version ($PYPROJECT_VERSION)"
echo "Please update pyproject.toml to version $TAG_VERSION before creating the release."
exit 1
fi
if [ "$TAG_VERSION" != "$CLI_VERSION" ]; then
echo "Error: Tag version ($TAG_VERSION) does not match cli/__init__.py version ($CLI_VERSION)"
echo "Please update cli/__init__.py to version $TAG_VERSION before creating the release."
exit 1
fi
echo "Version consistency check passed"
echo "All version strings match: $TAG_VERSION"
- name: Set up Python
uses: actions/setup-python@v6
with:
python-version: '3.14'
- name: Install build dependencies
run: |
python -m pip install --upgrade pip
pip install build twine
- name: Build package
run: python -m build
- name: Check distribution
run: |
echo "Built packages:"
ls -lh dist/
echo ""
echo "Checking package integrity:"
twine check dist/*
# PyPI publishing disabled for now - install via GitHub releases
# - name: Publish to PyPI
# if: >
# ${{ !contains(steps.version.outputs.version, 'alpha') &&
# !contains(steps.version.outputs.version, 'beta') &&
# !contains(steps.version.outputs.version, 'rc') }}
# env:
# TWINE_USERNAME: __token__
# TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
# run: |
# echo "Publishing to PyPI..."
# twine upload dist/*
- name: Extract changelog for this version
id: changelog
run: |
# Extract the changelog for this version from CHANGELOG.md
VERSION="${{ steps.version.outputs.version }}"
# First try to extract the section for this specific version
CHANGELOG=$(awk -v ver="$VERSION" '/^## \[/{if($0 ~ "\\[" ver "\\]"){flag=1; next} else if(flag){exit}} flag' CHANGELOG.md)
# If empty, fall back to [Unreleased] section
if [ -z "$CHANGELOG" ]; then
CHANGELOG=$(awk '/^## \[Unreleased\]/{flag=1; next} /^## \[/{flag=0} flag' CHANGELOG.md)
fi
if [ -z "$CHANGELOG" ]; then
echo "No changelog entries found for this release"
CHANGELOG="See commit history for details."
fi
# Save to output using heredoc to handle multiline
{
echo 'content<> $GITHUB_OUTPUT
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.version.outputs.tag }}
name: Release ${{ steps.version.outputs.tag }}
body: |
## Boilerplates CLI ${{ steps.version.outputs.tag }}
${{ steps.changelog.outputs.content }}
---
### Installation
Install using the installation script:
```bash
curl -fsSL https://raw.githubusercontent.com/christianlempa/boilerplates/main/scripts/install.sh | bash
```
Or install a specific version:
```bash
curl -fsSL https://raw.githubusercontent.com/christianlempa/boilerplates/main/scripts/install.sh |
bash -s -- --version ${{ steps.version.outputs.tag }}
```
draft: false
prerelease: >
${{ contains(steps.version.outputs.version, 'alpha') ||
contains(steps.version.outputs.version, 'beta') ||
contains(steps.version.outputs.version, 'rc') }}
files: |
dist/*.whl
dist/*.tar.gz
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
================================================
FILE: .github/workflows/renovate-sync-versions.yaml
================================================
---
name: Renovate - Sync Template Versions
'on':
pull_request:
branches:
- main
paths:
- 'library/**'
permissions:
contents: write
pull-requests: write
jobs:
sync-versions:
name: Sync Template Versions
# Only run on Renovate PRs
if: startsWith(github.head_ref, 'renovate/')
runs-on: ubuntu-latest
steps:
- name: Checkout PR branch
uses: actions/checkout@v6
with:
ref: ${{ github.head_ref }}
fetch-depth: 0 # Fetch all history to compare with main
token: ${{ secrets.GITHUB_TOKEN }}
- name: Detect changed template files
id: changes
run: |
# Fetch main branch for comparison
git fetch origin main:main
# Get list of changed files in library/
CHANGED_FILES=$(git diff --name-only main...HEAD | grep '^library/' | grep -E '\.(j2|yaml|yml)$' || true)
if [ -n "$CHANGED_FILES" ]; then
echo "Changed template files:"
echo "$CHANGED_FILES"
echo "files<> $GITHUB_OUTPUT
echo "$CHANGED_FILES" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
echo "has_files=true" >> $GITHUB_OUTPUT
else
echo "No template files changed"
echo "has_files=false" >> $GITHUB_OUTPUT
fi
- name: Run template version sync
id: sync
if: steps.changes.outputs.has_files == 'true'
run: |
echo "Running template version sync script..."
chmod +x .github/scripts/sync-template-version.sh
# Pass changed files to the script
.github/scripts/sync-template-version.sh ${{ steps.changes.outputs.files }}
- name: Check for template.yaml changes
id: template_changes
run: |
if [[ -n $(git status --porcelain library/**/template.yaml) ]]; then
echo "has_changes=true" >> $GITHUB_OUTPUT
echo "Template version changes detected"
else
echo "has_changes=false" >> $GITHUB_OUTPUT
echo "No template version changes needed"
fi
- name: Commit and push changes
if: steps.template_changes.outputs.has_changes == 'true'
run: |
git config --local user.email "github-actions[bot]@users.noreply.github.com"
git config --local user.name "github-actions[bot]"
git add library/**/template.yaml
git commit -m "chore: sync template versions with image updates"
git push
================================================
FILE: .gitignore
================================================
# Ignore local development files
**/.vscode
**/.DS_Store
# Docker Secrets, Environment Files
**/secret.*
**/.env
**/.envrc
**/.direnv
# Ignore Ansible
**/.ansible
# Python
**/__pycache__/
**/*.py[cod]
**/*.pyo
**/*.pyd
**/.venv
**/venv/
**/.ruff_cache/
# Packaging
*.egg-info/
build/
dist/
# Installation tracking
.installed-version
# Test outputs
config.yaml
================================================
FILE: .wiki/Core-Concepts-Defaults.md
================================================
# Default Variables
Save time by setting default values for variables you use frequently. This page explains how to manage your default variable configuration.
## What are Default Variables?
**Default variables** are user-defined values that override module and template defaults. They allow you to:
- Avoid repetitive typing during template generation
- Standardize values across multiple templates
- Customize your environment once, use everywhere
## Precedence Order
Variables are resolved in this order (lowest to highest priority):
1. Module spec (module-wide defaults)
2. Template spec (template-specific defaults)
3. **User config** (your saved defaults) ← This page
4. CLI arguments (`--var` flags)
Your defaults override module and template values but can be overridden by CLI arguments.
## Managing Defaults
All default management commands follow this pattern:
```bash
boilerplates defaults [args]
```
### View Defaults
List all saved defaults for a module:
```bash
boilerplates compose defaults list
```
Output:
```
Default Variables (Compose):
container_timezone: America/New_York
restart_policy: unless-stopped
traefik_network: traefik
network_external: true
```
### Set a Default
Save a default value:
```bash
boilerplates compose defaults set container_timezone="America/New_York"
```
Response:
```
Set default: container_timezone = America/New_York
```
**Tips:**
- Use quotes for values with spaces
- Booleans: `true` or `false`
- Numbers: no quotes needed
**Examples:**
```bash
# String
boilerplates compose defaults set restart_policy="unless-stopped"
# Integer
boilerplates compose defaults set user_uid=1000
# Boolean
boilerplates compose defaults set traefik_enabled=true
# String with spaces
boilerplates compose defaults set container_hostname="my app server"
```
### Get a Default
View a single default value:
```bash
boilerplates compose defaults get container_timezone
```
Output:
```
container_timezone: America/New_York
```
### Remove a Default
Delete a saved default:
```bash
boilerplates compose defaults rm container_timezone
```
Response:
```
Removed default: container_timezone
```
The variable will now use module/template defaults again.
### Clear All Defaults
Remove all saved defaults for a module:
```bash
boilerplates compose defaults clear
```
Response:
```
Cleared all defaults for compose
```
**Warning:** This cannot be undone. Consider backing up your config first.
## Configuration Storage
Defaults are stored in:
```
~/.config/boilerplates/config.yaml
```
Example content:
```yaml
libraries:
- name: default
type: git
url: https://github.com/christianlempa/boilerplates
branch: main
directory: library
defaults:
compose:
container_timezone: America/New_York
restart_policy: unless-stopped
traefik_network: traefik
network_external: true
terraform:
region: us-east-1
instance_type: t3.micro
```
### Manual Editing
You can manually edit the config file:
```bash
# Edit configuration
nano ~/.config/boilerplates/config.yaml
# Verify defaults
boilerplates compose defaults list
```
## Common Use Cases
### Timezone Configuration
Set your local timezone once:
```bash
boilerplates compose defaults set container_timezone="Europe/Berlin"
```
Now all Docker containers use your timezone by default.
### Network Configuration
Standardize network settings:
```bash
boilerplates compose defaults set network_external=true
boilerplates compose defaults set network_name="docker-network"
```
### Traefik Configuration
Set common Traefik values:
```bash
boilerplates compose defaults set traefik_network="traefik"
boilerplates compose defaults set traefik_domain="example.com"
boilerplates compose defaults set traefik_tls_certresolver="cloudflare"
```
### User IDs
Match your host user:
```bash
boilerplates compose defaults set user_uid=$(id -u)
boilerplates compose defaults set user_gid=$(id -g)
```
### Restart Policy
Standardize container behavior:
```bash
boilerplates compose defaults set restart_policy="unless-stopped"
```
## Overriding Defaults
Even with defaults set, you can override them:
### Interactive Mode
During interactive generation, defaults appear as pre-filled values. Press Enter to accept or type a new value:
```
Container timezone [America/New_York]: Europe/London
```
### CLI Arguments
Override with `--var`:
```bash
boilerplates compose generate nginx \
--var container_timezone="UTC" \
--no-interactive
```
The CLI argument takes precedence over your saved default.
## Per-Module Defaults
Each module has its own defaults:
```bash
# Compose defaults
boilerplates compose defaults set restart_policy="unless-stopped"
# Terraform defaults (separate)
boilerplates terraform defaults set region="us-east-1"
# Ansible defaults (separate)
boilerplates ansible defaults set become=true
```
Defaults don't transfer between modules—they're module-specific.
## Backup and Restore
### Backup Configuration
Save your configuration:
```bash
cp ~/.config/boilerplates/config.yaml ~/boilerplates-config-backup.yaml
```
### Restore Configuration
Restore from backup:
```bash
cp ~/boilerplates-config-backup.yaml ~/.config/boilerplates/config.yaml
```
### Share Configuration
Share defaults with your team:
```bash
# Export defaults
cat ~/.config/boilerplates/config.yaml | grep -A 100 "defaults:" > team-defaults.yaml
# Share file with team
# Team members can merge into their config.yaml
```
## Advanced Usage
### Environment-Specific Defaults
Maintain multiple configurations:
```bash
# Production defaults
cp ~/.config/boilerplates/config.yaml ~/.config/boilerplates/config-prod.yaml
# Development defaults
cp ~/.config/boilerplates/config.yaml ~/.config/boilerplates/config-dev.yaml
# Switch between them
cp ~/.config/boilerplates/config-prod.yaml ~/.config/boilerplates/config.yaml
```
### Scripted Configuration
Set defaults programmatically:
```bash
#!/bin/bash
# Set common defaults
boilerplates compose defaults set container_timezone="$(cat /etc/timezone)"
boilerplates compose defaults set user_uid="$(id -u)"
boilerplates compose defaults set user_gid="$(id -g)"
boilerplates compose defaults set restart_policy="unless-stopped"
```
## Troubleshooting
### Defaults Not Applied
If defaults aren't being used:
```bash
# Verify defaults are set
boilerplates compose defaults list
# Check config file
cat ~/.config/boilerplates/config.yaml
# Ensure module name matches
# "compose" not "docker-compose"
```
### Config File Errors
If config file is corrupted:
```bash
# Validate YAML syntax
python3 -c "import yaml; yaml.safe_load(open('~/.config/boilerplates/config.yaml'))"
# Or remove and recreate
mv ~/.config/boilerplates/config.yaml ~/.config/boilerplates/config.yaml.bak
boilerplates repo update # Recreates config
```
### Wrong Values
If wrong values appear:
```bash
# Check precedence
# 1. Module spec
# 2. Template spec
# 3. User defaults ← Check here
# 4. CLI --var
# Verify your defaults
boilerplates compose defaults list
# Check template spec
boilerplates compose show
```
## Best Practices
### Essential Defaults
Set these common defaults:
```bash
# System
boilerplates compose defaults set container_timezone="$(cat /etc/timezone)"
boilerplates compose defaults set user_uid=$(id -u)
boilerplates compose defaults set user_gid=$(id -g)
# Containers
boilerplates compose defaults set restart_policy="unless-stopped"
# Networking (if using external networks)
boilerplates compose defaults set network_external=true
boilerplates compose defaults set network_name="docker-network"
```
### Don't Over-Configure
Only set defaults for values you use consistently:
**Good:**
- Timezone (same everywhere)
- User UID/GID (same everywhere)
- Network settings (if standardized)
**Bad:**
- Service names (unique per service)
- Hostnames (unique per service)
- Port numbers (conflict-prone)
### Document Your Defaults
Keep a list of your defaults for reference:
```bash
# Save to file
boilerplates compose defaults list > ~/my-defaults.txt
```
### Review Periodically
Check your defaults occasionally:
```bash
boilerplates compose defaults list
```
Remove obsolete or unused values.
## Next Steps
- [Libraries](Core-Concepts-Libraries) - Managing template libraries
- [Variables](Core-Concepts-Variables) - Understanding variable types and behavior
- [Getting Started](Getting-Started) - Using defaults in template generation
## See Also
- [Installation](Installation) - CLI setup
- [Concepts](Core-Concepts-Templates) - How templates work
================================================
FILE: .wiki/Core-Concepts-Libraries.md
================================================
# Libraries
Libraries are collections of templates that can be synced from Git repositories or loaded from local directories. This page explains how to manage template libraries.
## What is a Library?
A **library** is a collection of templates organized by module type (compose, terraform, ansible, etc.). Libraries can be:
- **Git-based** - Synced from remote repositories
- **Static** - Local directories on your filesystem
## Default Library
By default, Boilerplates uses the official template library:
```
Name: default
URL: https://github.com/christianlempa/boilerplates
Branch: main
Directory: library
```
This provides production-ready templates for various services and infrastructure.
## Library Location
Libraries are stored locally at:
```
~/.config/boilerplates/libraries/
└── default/
└── library/
├── compose/
├── terraform/
└── ansible/
```
## Managing Libraries
### List Libraries
View all configured libraries:
```bash
boilerplates repo list
```
Output:
```
Libraries:
default (git)
URL: https://github.com/christianlempa/boilerplates
Branch: main
Directory: library
Status: Synced
```
### Update Libraries
Sync all Git-based libraries:
```bash
boilerplates repo update
```
This:
- Pulls latest changes from Git repositories
- Uses sparse-checkout (only downloads template directories)
- Updates metadata cache
### Add Custom Library
Add your own template library:
```bash
boilerplates repo add my-templates https://github.com/user/templates \
--directory library \
--branch main
```
Parameters:
- **name** - Unique library identifier
- **url** - Git repository URL
- **--directory** - Path to templates within repository (default: `.`)
- **--branch** - Git branch to use (default: `main`)
### Remove Library
Remove a library from configuration:
```bash
boilerplates repo remove my-templates
```
This removes the configuration but keeps downloaded files. To fully clean up:
```bash
rm -rf ~/.config/boilerplates/libraries/my-templates
```
## Library Types
### Git Libraries
Synced from remote Git repositories:
```yaml
libraries:
- name: default
type: git
url: https://github.com/christianlempa/boilerplates
branch: main
directory: library
```
**Benefits:**
- Always up-to-date
- Version controlled
- Easy to share
- Automatic updates
**Use cases:**
- Official templates
- Team-shared templates
- Public template collections
### Static Libraries
Local directories on your filesystem:
```yaml
libraries:
- name: local
type: static
path: ~/my-templates
```
**Benefits:**
- No network required
- Full control
- Fast access
- Development/testing
**Use cases:**
- Local development
- Private templates
- Custom modifications
- Testing new templates
## Library Priority
When multiple libraries contain the same template, **priority** determines which is used:
```yaml
libraries:
- name: local # Priority 1 (highest)
type: static
path: ~/my-templates
- name: default # Priority 2
type: git
url: https://github.com/christianlempa/boilerplates
```
### Simple IDs
Use the template name without qualification:
```bash
boilerplates compose generate nginx
```
The CLI uses the first matching template (from `local` in the example above).
### Qualified IDs
Target a specific library:
```bash
boilerplates compose generate nginx.local # Uses local library
boilerplates compose generate nginx.default # Uses default library
```
## Configuration File
Library configuration is stored in:
```
~/.config/boilerplates/config.yaml
```
Example:
```yaml
libraries:
- name: default
type: git
url: https://github.com/christianlempa/boilerplates
branch: main
directory: library
- name: local
type: static
path: /Users/me/my-templates
```
### Manual Editing
You can manually edit `config.yaml`:
```bash
# Edit configuration
nano ~/.config/boilerplates/config.yaml
# Verify changes
boilerplates repo list
```
## Advanced Usage
### Multiple Git Branches
Use different branches for stable vs. development templates:
```yaml
libraries:
- name: stable
type: git
url: https://github.com/user/templates
branch: main
directory: library
- name: dev
type: git
url: https://github.com/user/templates
branch: development
directory: library
```
### Sparse Checkout
Git libraries use sparse-checkout to minimize disk usage:
```
# Only downloads:
library/compose/
library/terraform/
library/ansible/
# Ignores:
.github/
docs/
tests/
README.md
```
This keeps library downloads fast and disk usage low.
### Private Repositories
For private Git repositories, ensure SSH or HTTPS authentication is configured:
**SSH:**
```bash
boilerplates repo add private git@github.com:user/private-templates.git \
--directory library \
--branch main
```
Requires SSH key configured with GitHub/GitLab.
**HTTPS with credentials:**
```bash
# Configure Git credential helper
git config --global credential.helper store
# Add library (will prompt for credentials on first sync)
boilerplates repo add private https://github.com/user/private-templates.git \
--directory library \
--branch main
```
## Template Discovery
After adding libraries, templates are discovered automatically:
```bash
# Sync libraries
boilerplates repo update
# List templates from all libraries
boilerplates compose list
# Show template details (uses priority order)
boilerplates compose show nginx
# Show from specific library
boilerplates compose show nginx.local
```
## Troubleshooting
### Library Not Syncing
If `repo update` fails:
```bash
# Check network connectivity
ping github.com
# Verify Git access
git ls-remote https://github.com/christianlempa/boilerplates
# Remove and re-add library
boilerplates repo remove default
boilerplates repo add default https://github.com/christianlempa/boilerplates \
--directory library \
--branch main
```
### Templates Not Found
If templates don't appear:
```bash
# Verify library is configured
boilerplates repo list
# Update libraries
boilerplates repo update
# Check library directory structure
ls -la ~/.config/boilerplates/libraries/default/library/compose/
```
### Duplicate Template Names
If two libraries have the same template:
```bash
# Check which library provides it
boilerplates compose show nginx
# Use qualified ID to target specific library
boilerplates compose generate nginx.local
```
## Best Practices
### Library Organization
Structure your libraries consistently:
```
my-templates/
├── library/
│ ├── compose/
│ │ ├── app1/
│ │ └── app2/
│ ├── terraform/
│ └── ansible/
└── README.md
```
### Version Control
For Git libraries:
- Use semantic versioning tags
- Maintain a CHANGELOG
- Test templates before merging
- Use branches for development
### Naming
- Use descriptive library names
- Avoid special characters
- Keep names short but meaningful
**Good:** `production`, `dev`, `team-infra`
**Bad:** `my-lib-123`, `temp`, `new`
### Documentation
Each library should have:
- README.md with overview
- Template documentation
- Usage examples
- Contribution guidelines
## Next Steps
- [Default Variables](Core-Concepts-Defaults) - Managing variable defaults
- [Templates](Core-Concepts-Templates) - Understanding template structure
- [Developer Guide](Developers-Templates) - Creating templates for libraries
## See Also
- [Getting Started](Getting-Started) - Your first template
- [Installation](Installation) - Installing the CLI
================================================
FILE: .wiki/Core-Concepts-Templates.md
================================================
# Templates
Templates are the core building blocks of the Boilerplates CLI. This page explains what templates are, how they work, and how to use them effectively.
## What is a Template?
A template is a **directory-based configuration package** that contains:
- **Metadata** - Name, description, version, author information
- **Variable specifications** - Configurable parameters
- **Template files** - Jinja2 templates that generate your configuration
- **Static files** - Files copied as-is (optional)
When you generate a template, the CLI:
1. Prompts you for variable values (or uses defaults/CLI overrides)
2. Renders template files using Jinja2
3. Writes the generated files to your specified directory
## Template Structure
Every template is a directory containing at minimum a `template.yaml` file:
```
template-name/
├── template.yaml # Template definition and metadata
├── docker-compose.yml.j2 # Jinja2 template files
├── .env.j2 # Environment configuration
└── README.md # Static file (copied as-is)
```
### The template.yaml File
This file defines everything about your template:
```yaml
---
kind: compose # Module type (compose, terraform, ansible, etc.)
schema: "X.Y" # Schema version (affects available features)
metadata:
name: My Service
description: Service description with Markdown support
version: 1.0.0 # Application/service version
author: Your Name
date: '2025-01-12'
tags:
- docker
- service
spec:
# Variable specifications (see Variables page)
```
## Template Discovery
Templates are organized in **libraries**. A library is a collection of templates for a specific module type.
### Default Library Structure
```
~/.config/boilerplates/libraries/
└── default/
└── library/
├── compose/
│ ├── nginx/
│ ├── traefik/
│ └── whoami/
├── terraform/
└── ansible/
```
### Finding Templates
```bash
# List all templates for a module
boilerplates compose list
# Search templates by name
boilerplates compose search proxy
# Show details about a template
boilerplates compose show nginx
```
## Template Metadata
### Required Fields
```yaml
metadata:
name: Template Name # Display name
description: Description # What the template does
version: 1.0.0 # Application version
author: Your Name # Template author
date: '2025-01-12' # Last update date
```
### Optional Fields
```yaml
metadata:
tags: # Searchable tags
- docker
- web-server
draft: false # Hide from listings if true
next_steps: | # Post-generation instructions
## What's Next
1. Review the generated files
2. Customize as needed
3. Deploy!
```
### Description Markdown
The `description` field supports Markdown:
```yaml
metadata:
description: |
A **powerful reverse proxy** and load balancer.
## Features
- Automatic HTTPS
- Load balancing
- Let's Encrypt integration
## Resources
- **Project**: https://traefik.io
- **Documentation**: https://doc.traefik.io
```
This renders nicely when you run `boilerplates compose show `.
## Template Files
### Jinja2 Templates
Files ending in `.j2` are processed by Jinja2:
**docker-compose.yml.j2:**
```yaml
services:
{{ service_name }}:
image: nginx:{{ nginx_version }}
ports:
- "{{ nginx_port }}:80"
{% if enable_ssl %}
volumes:
- ./ssl:/etc/nginx/ssl
{% endif %}
```
After rendering with variables:
- `service_name=web`
- `nginx_version=1.25`
- `nginx_port=8080`
- `enable_ssl=true`
**Generated docker-compose.yml:**
```yaml
services:
web:
image: nginx:1.25
ports:
- "8080:80"
volumes:
- ./ssl:/etc/nginx/ssl
```
### Static Files
Files without `.j2` extension are copied as-is:
- `README.md` - Copied unchanged
- `scripts/setup.sh` - Copied unchanged
### File Includes
Templates can include other template files:
**main.j2:**
```jinja2
{% include 'common/header.j2' %}
services:
{{ service_name }}:
image: nginx:latest
```
**common/header.j2:**
```yaml
version: '3.8'
name: {{ project_name }}
```
## Schema Versioning
Templates declare a schema version that determines available features:
```yaml
schema: "X.Y" # Use schema version X.Y (e.g., "1.0", "1.2")
```
**Why Schema Versions?**
- Modules evolve with new features over time
- Older templates continue working (backward compatibility)
- Templates opt-into new features by upgrading schema version
**Checking Current Schema:**
To find the latest schema version and available features for each module, refer to the module-specific variable documentation:
- [Compose Variables](Variables-Compose) - Shows current schema version at bottom
- [Terraform Variables](Variables-Terraform)
- [Ansible Variables](Variables-Ansible)
- [Kubernetes Variables](Variables-Kubernetes)
- [Helm Variables](Variables-Helm)
- [Packer Variables](Variables-Packer)
Each Variables page documents the current schema and which features are available.
## Template Lifecycle
### 1. Discovery
```bash
boilerplates repo update # Sync libraries
boilerplates compose list # Discover templates
```
### 2. Preview
```bash
boilerplates compose show nginx
```
Shows:
- Metadata
- Variable specifications
- File structure
### 3. Generation
```bash
# Interactive mode
boilerplates compose generate nginx
# Non-interactive mode
boilerplates compose generate nginx ./my-nginx \
--var service_name=my-nginx \
--no-interactive
```
### 4. Validation (Optional)
```bash
# Validate template structure
boilerplates compose validate nginx
# Validate all templates
boilerplates compose validate
```
## Template Identification
Templates are identified by their directory name:
```
library/compose/nginx/ → template ID: nginx
library/compose/traefik/ → template ID: traefik
```
### Qualified IDs
When using multiple libraries, templates can have qualified IDs:
```bash
# Simple ID (uses first matching template from priority order)
boilerplates compose generate nginx
# Qualified ID (targets specific library)
boilerplates compose generate nginx.local
boilerplates compose generate nginx.default
```
## Template Inheritance
Templates inherit variables from module specifications. You only need to override what's different.
**Module spec defines:**
- `service_name` (default: empty)
- `container_port` (default: 8080)
- `restart_policy` (default: unless-stopped)
**Template overrides:**
```yaml
spec:
general:
vars:
service_name:
default: nginx # Override default
# container_port inherits 8080
# restart_policy inherits unless-stopped
```
This keeps templates concise—you only specify what's unique.
## Best Practices
### Naming Conventions
- **Template directories**: lowercase, hyphen-separated (`my-service`, `nginx-proxy`)
- **Service names**: match template name by default
- **File names**: descriptive and clear (`docker-compose.yml.j2`, not `dc.j2`)
### Version Management
**Application Versions:**
- Hardcode in template files: `image: nginx:1.25.3`
- Update `metadata.version` to match application
- Don't create version variables unless necessary
**Template Updates:**
- Increment `metadata.version` when updating
- Update `metadata.date` to current date
- Document changes in commit messages
### Documentation
- Use Markdown in `description`
- Provide `next_steps` for post-generation instructions
- Include links to official documentation
- Add usage examples
### Testing
Before publishing:
```bash
# Validate template
boilerplates compose validate my-template
# Test generation (dry run)
boilerplates compose generate my-template --dry-run
# Test with real generation
boilerplates compose generate my-template /tmp/test
# Verify generated files
cd /tmp/test && docker compose config
```
## Advanced Features
### Conditional File Generation
Use Jinja2 conditionals to skip entire sections:
```jinja2
{% if traefik_enabled %}
labels:
- "traefik.enable=true"
- "traefik.http.routers.{{ service_name }}.rule=Host(`{{ traefik_host }}`)"
{% endif %}
```
### Dynamic File Names
Template file names can use variables (though this is rare):
```
config-{{ environment }}.yml.j2 # Generates: config-prod.yml
```
### Template Validation
Templates are validated on load:
- Jinja2 syntax errors detected
- Undefined variables reported
- Schema compatibility checked
## Next Steps
- [Variables](Core-Concepts-Variables) - Learn about variable types and configuration
- [Configuration](Core-Concepts-Libraries) - Manage template libraries
- [Variable Reference](Variables-Compose) - Complete variable documentation for modules
- [Developer Guide](Developers-Templates) - Create your own templates
## See Also
- [Getting Started](Getting-Started) - Generate your first template
- [Installation](Installation) - Install the CLI
================================================
FILE: .wiki/Core-Concepts-Variables.md
================================================
# Variables
Variables are the configurable parameters that customize your templates. This page explains variable types, sections, dependencies, and how to work with them effectively.
## What are Variables?
Variables are **parameters** that control template generation. They can be:
- Simple values (strings, numbers, booleans)
- Selections from options (enums)
- Validated inputs (emails, URLs, hostnames)
**Example:**
```yaml
service_name: my-app # String
container_port: 8080 # Integer
traefik_enabled: true # Boolean
restart_policy: unless-stopped # Enum (selection from options)
```
## Variable Types
### String (`str`)
Text values with optional constraints:
```yaml
service_name:
type: str
description: Service name
default: my-service
```
**Usage:**
- Service names
- Hostnames
- Paths
- General text
### Integer (`int`)
Whole numbers:
```yaml
container_port:
type: int
description: Container port
default: 8080
```
**Usage:**
- Port numbers
- UIDs/GIDs
- Counts and limits
### Float (`float`)
Decimal numbers:
```yaml
cpu_limit:
type: float
description: CPU limit in cores
default: 1.5
```
**Usage:**
- Resource limits
- Ratios and percentages
### Boolean (`bool`)
True/false values:
```yaml
traefik_enabled:
type: bool
description: Enable Traefik integration
default: false
```
**Usage:**
- Feature toggles
- Conditional configuration
- Enable/disable options
### Enum (`enum`)
Selection from predefined options:
```yaml
restart_policy:
type: enum
description: Container restart policy
options: [unless-stopped, always, on-failure, no]
default: unless-stopped
```
**Usage:**
- Network modes (bridge, host, macvlan)
- Log levels (debug, info, warn, error)
- Policies and strategies
### Email (`email`)
Email addresses with validation:
```yaml
admin_email:
type: email
description: Administrator email
default: admin@example.com
```
**Validation:**
- Must match email format (user@domain.com)
- Rejects invalid email addresses
### URL (`url`)
Full URLs with scheme validation:
```yaml
api_endpoint:
type: url
description: API endpoint URL
default: https://api.example.com
```
**Validation:**
- Must include scheme (http://, https://)
- Must have valid host
### Hostname (`hostname`)
Domain names or hostnames:
```yaml
traefik_host:
type: hostname
description: Service hostname
default: app.example.com
```
**Validation:**
- Valid DNS hostname format
- Accepts subdomains and domains
## Variable Properties
Every variable can have these properties:
```yaml
variable_name:
type: str # Variable type (required)
description: Description # Help text
default: value # Default value
prompt: Custom prompt text # Override description in prompts
extra: Additional help # Extended help text
sensitive: false # Mask input (for passwords)
autogenerated: false # Auto-generate if empty
needs: condition # Dependency constraint
```
### Sensitive Variables
Mask input for passwords and secrets:
```yaml
admin_password:
type: str
description: Administrator password
sensitive: true
```
**Behavior:**
- Input is masked in prompts (`********`)
- Displayed as `***` in output
- Suitable for passwords, API keys, tokens
### Autogenerated Variables
Automatically generate values if not provided:
```yaml
secret_key:
type: str
description: Secret key
autogenerated: true
```
**Behavior:**
- Shows `*auto` placeholder in prompts
- Generates value during rendering if empty
- Press Enter to accept auto-generation
### Custom Prompts
Override the description text in interactive prompts:
```yaml
service_name:
description: Service name (used in docker-compose)
prompt: Enter service name
```
## Variable Sections
Variables are organized into **sections** that group related configuration:
```yaml
spec:
general: # Required by default
title: General Settings
vars:
service_name: {...}
container_port: {...}
networking: # Optional section
title: Network Configuration
toggle: networking_enabled
vars:
network_name: {...}
network_mode: {...}
```
### Required Sections
Sections marked as `required: true` must be configured:
```yaml
general:
title: General
required: true
vars:
service_name: {...}
```
**Behavior:**
- Users must provide values
- No way to skip
- `general` section is implicitly required
### Optional Sections
Sections with a toggle variable:
```yaml
traefik:
title: Traefik
toggle: traefik_enabled
vars:
traefik_host: {...}
traefik_network: {...}
```
**Behavior:**
- User chooses whether to enable
- If disabled, section variables are skipped
- Toggle variable is auto-created as boolean
### Section Dependencies
Sections can depend on other sections:
```yaml
traefik_tls:
title: Traefik TLS/SSL
needs: traefik
vars:
traefik_tls_enabled: {...}
```
**Behavior:**
- Only shown if dependency section is enabled
- Supports multiple dependencies: `needs: [traefik, networking]`
- Automatically sorted in dependency order
## Variable Dependencies
Variables can depend on other variables using `needs` constraints:
### Simple Constraint
Variable only visible when condition is met:
```yaml
network_name:
type: str
description: Network name
needs: network_mode=bridge
```
**Behavior:**
- Only shown when `network_mode` equals `bridge`
- Hidden for other network modes
### Multiple Values (OR)
Variable visible for multiple values:
```yaml
network_name:
type: str
description: Network name
needs: network_mode=bridge,macvlan
```
**Behavior:**
- Shown when `network_mode` is `bridge` OR `macvlan`
- Hidden when `network_mode` is `host`
### Multiple Constraints (AND)
Variable requires multiple conditions:
```yaml
traefik_tls_certresolver:
type: str
description: Certificate resolver
needs: traefik_enabled=true;network_mode=bridge
```
**Behavior:**
- Requires ALL conditions to be true
- Semicolon (`;`) separates conditions
- Comma (`,`) within a condition is OR
## Variable Precedence
Variables are resolved in priority order (lowest to highest):
1. **Module spec** - Default values for all templates
2. **Template spec** - Template-specific overrides
3. **User config** - Saved defaults in `~/.config/boilerplates/config.yaml`
4. **CLI arguments** - `--var` flags
**Example:**
```bash
# Module default: restart_policy=unless-stopped
# Template override: restart_policy=always
# User config: restart_policy=on-failure
# CLI override: --var restart_policy=no
# Result: restart_policy=no (CLI wins)
```
### Setting Default Values
Save frequently used values:
```bash
# Set a default
boilerplates compose defaults set container_timezone="America/New_York"
# View all defaults
boilerplates compose defaults list
# Remove a default
boilerplates compose defaults rm container_timezone
# Clear all defaults
boilerplates compose defaults clear
```
## Interactive Prompts
When generating templates interactively, the CLI prompts for each variable:
### Text Input
```
Service name: |my-app|
```
- Type your value or press Enter for default
- Default shown in brackets
### Boolean Input
```
Enable Traefik? (y/n) [n]:
```
- `y` or `yes` for true
- `n` or `no` for false
- Press Enter for default
### Enum Selection
```
Restart policy:
1) unless-stopped (default)
2) always
3) on-failure
4) no
Select [1]:
```
- Enter number to select
- Press Enter for default
### Sensitive Input
```
Admin password: ********
```
- Input is masked
- Not echoed to terminal
## Non-Interactive Mode
Skip prompts entirely using `--no-interactive`:
```bash
boilerplates compose generate nginx ./output \
--var service_name=my-nginx \
--var container_port=8080 \
--no-interactive
```
**Behavior:**
- Uses defaults for all variables
- No user interaction required
- Suitable for automation and CI/CD
## Variable Validation
### At Prompt Time
Variables are validated during prompts:
- Type checking (int must be number)
- Format validation (email, URL, hostname)
- Option validation (enum must be in options list)
**Example:**
```
Container port: abc
Error: Must be a valid integer
Container port:
```
### At Template Load
Templates are validated when loaded:
- Check for undefined variables used in templates
- Verify variable dependencies are valid
- Ensure no circular dependencies
## Advanced Features
### Template Variable Inheritance
Templates inherit ALL variables from their module schema. You only override what's different:
**Module defines:**
```yaml
general:
vars:
service_name:
type: str
container_port:
type: int
default: 8080
```
**Template overrides:**
```yaml
spec:
general:
vars:
service_name:
default: nginx # Only override default
# container_port inherits 8080
```
### Dynamic Visibility
Variables with `needs` constraints are dynamically shown/hidden based on other values:
```bash
# If user selects network_mode=host
# → Network name prompt is skipped (needs: network_mode=bridge)
# If user selects network_mode=bridge
# → Network name prompt is shown
```
### Dependency Resolution
The CLI automatically:
- Topologically sorts sections based on dependencies
- Validates no circular dependencies exist
- Reports errors for missing dependencies
## Best Practices
### Naming Conventions
- Use **snake_case** for variable names
- Group related variables with common prefixes:
- `traefik_*` for Traefik variables
- `network_*` for networking variables
- `ports_*` for port configuration
### Provide Good Defaults
- Choose sensible defaults for common use cases
- Use empty defaults when user input is required
- Document why a default was chosen
### Use Descriptions
- Clear, concise descriptions
- Explain what the variable controls
- Include examples if helpful
**Good:**
```yaml
traefik_host:
description: Service subdomain or full hostname (e.g., 'app' or 'app.example.com')
```
**Bad:**
```yaml
traefik_host:
description: Host
```
### Group Related Variables
Use sections to organize configuration:
```yaml
spec:
general: # Core configuration
network: # Network settings
traefik: # Reverse proxy
traefik_tls: # TLS/SSL configuration
```
### Use Dependencies Wisely
- Keep dependency chains short
- Avoid overly complex conditions
- Test dependency behavior thoroughly
## Next Steps
- [Configuration](Core-Concepts-Defaults) - Managing default values
- [Variable Reference](Variables-Compose) - Complete variable list for each module
- [Templates](Core-Concepts-Templates) - How variables are used in templates
## See Also
- [Getting Started](Getting-Started) - Your first template generation
- [Developer Guide](Developers-Templates) - Creating templates with variables
================================================
FILE: .wiki/Getting-Started.md
================================================
# Getting Started
Welcome to Boilerplates! This guide will help you get up and running in just a few minutes.
## Overview
Boilerplates provides two main components:
### Template Library
A collection of ready-to-use templates for common infrastructure components:
- **Docker Compose**: Containerized applications (Nginx, Traefik, Grafana, etc.)
- **Terraform**: Cloud infrastructure (AWS, Azure, GCP)
- **Ansible**: Configuration management and automation
- **Kubernetes**: Container orchestration deployments
- **Packer**: Machine image builders
Templates include:
- Pre-configured defaults for common use cases
- Documentation and usage examples
- Variable specifications for customization
- Best practices baked in
### Management CLI
A Python-based command-line tool to work with templates:
- Browse and search the template library
- Interactive configuration with validation
- Generate customized templates
- Manage multiple template libraries (official + custom)
- Sync updates from repositories
## Prerequisites
Before you begin, ensure you have:
- Python 3.10 or higher installed
- Git installed (for syncing template libraries)
- Basic command-line knowledge
- Internet connection (for downloading templates)
## Installation
The quickest way to install the management CLI is using the automated installer:
```bash
curl -fsSL https://raw.githubusercontent.com/christianlempa/boilerplates/main/scripts/install.sh | bash
```
This installs the `boilerplates` command and configures access to the official template library.
For detailed installation instructions including platform-specific guidance, see the [Installation](Installation) page.
## Your First Template
Once installed, let's generate your first template!
### 1. Sync the Template Library
Download the latest templates from the library:
```bash
boilerplates repo update
```
This syncs the official template library to `~/.config/boilerplates/libraries/default/`.
### 2. Browse the Template Library
Explore available Docker Compose templates:
```bash
boilerplates compose list
```
You'll see a table showing available templates from the library with their descriptions.
### 3. Inspect a Template
Before generating, preview a template's structure and variables:
```bash
boilerplates compose show nginx
```
This shows:
- Template metadata (name, version, author, description)
- Available configuration variables and defaults
- Template file structure
- Variable dependencies and sections
### 4. Generate Files from Template
Now, let's use the CLI to generate customized files from a template! You have two options:
**Interactive Mode** (Recommended for beginners):
```bash
boilerplates compose generate nginx
```
The tool prompts you for each variable. You can:
- Press Enter to accept defaults from the template
- Type custom values
- Navigate with arrow keys for selections
- Skip optional sections
**Non-Interactive Mode** (For automation):
```bash
boilerplates compose generate nginx my-nginx \
--var service_name=my-nginx \
--var container_port=8080 \
--no-interactive
```
This uses template defaults and provided variables without prompts.
### 5. Review Generated Files
After generation, you'll find:
```
my-nginx/
├── docker-compose.yml
└── .env
```
Review the files and adjust as needed for your environment.
## Basic Commands
Here are the essential commands you'll use regularly:
### Library Management
Manage template library repositories:
```bash
# Sync official template library
boilerplates repo update
# List all configured libraries
boilerplates repo list
# Add a custom template library
boilerplates repo add my-templates https://github.com/user/templates \
--directory library \
--branch main
```
### Working with Templates
Discover and use templates from the library:
```bash
# Browse available templates
boilerplates compose list
# Search the library
boilerplates compose search nginx
# Inspect template structure
boilerplates compose show nginx
# Generate files from template
boilerplates compose generate nginx ./output
# Validate template syntax
boilerplates compose validate
```
### Working with Defaults
Save frequently used values to avoid repetitive typing:
```bash
# Set a default value
boilerplates compose defaults set container_timezone="America/New_York"
# View all defaults
boilerplates compose defaults list
# Remove a default
boilerplates compose defaults rm container_timezone
# Clear all defaults
boilerplates compose defaults clear
```
## Common Workflows
### Workflow 1: Quick Generation with Defaults
Use template defaults without customization:
```bash
boilerplates compose generate portainer --no-interactive
```
### Workflow 2: Interactive Customization
Customize template variables interactively:
```bash
boilerplates compose show traefik # Review template structure
boilerplates compose generate traefik # Customize via prompts
```
### Workflow 3: Automation
For scripts and CI/CD pipelines:
```bash
boilerplates compose generate authentik ./auth \
--var service_name=authentik \
--var traefik_enabled=true \
--var traefik_host=auth.example.com \
--no-interactive \
--dry-run # Preview first
```
## Advanced Features
### Dry Run
Preview generated files without writing them:
```bash
boilerplates compose generate nginx --dry-run
```
### Debug Mode
Enable detailed logging for troubleshooting:
```bash
boilerplates --log-level DEBUG compose generate nginx
```
### Variable Override
Override specific variables without interactive prompts:
```bash
boilerplates compose generate grafana \
--var service_name=monitoring-grafana \
--var grafana_port=3000
```
## Next Steps
Now that you know the basics, explore more:
- [Templates](Core-Concepts-Templates) - Learn how templates work
- [Variables](Core-Concepts-Variables) - Understand variable types and dependencies
- [Configuration](Core-Concepts-Libraries) - Customize your setup
- [Variable Reference](Variables-Compose) - Complete variable documentation
## Troubleshooting
### CLI Command Not Found
If the `boilerplates` command is not found after installation:
```bash
# Ensure pipx binaries are in PATH
export PATH="$HOME/.local/bin:$PATH"
# Add to your shell profile (.bashrc, .zshrc, etc.)
echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
```
### Templates Not Available
If templates aren't showing up after installation:
```bash
# Sync the template library
boilerplates repo update
# Verify library is configured
boilerplates repo list
```
### Permission Issues
If you encounter permission errors:
```bash
# Ensure output directory is writable
chmod +w ./output-directory
# Or generate to a different location
boilerplates compose generate nginx ~/my-projects/nginx
```
## Getting Help
- **Documentation:** Browse the [Wiki](Home) for comprehensive guides
- **Discord:** Join the [community](https://christianlempa.de/discord) for real-time help
- **GitHub Issues:** Report bugs or request features
- **YouTube:** Watch [video tutorials](https://www.youtube.com/@christianlempa)
Happy templating!
================================================
FILE: .wiki/Home.md
================================================
# Boilerplates Documentation
Instant access to battle-tested templates for Docker, Terraform, Ansible, Kubernetes, and more.
Each template includes sensible defaults, best practices, and common configuration patterns—so you can focus on customizing for your environment.
## Boilerplates CLI Tool
The Boilerplates CLI is a Python-based tool that streamlines infrastructure template management. It provides an interactive interface for browsing, customizing, and generating configuration files from a curated template library. The tool handles variable validation, dependency resolution, and multi-source template management—giving you a consistent workflow whether you're deploying a single container or orchestrating complex infrastructure.
## User Documentation
**Getting Started**
- [Getting Started](Getting-Started) - Quick introduction and first steps
- [Installation](Installation) - Install the Boilerplates CLI tool on Linux, MacOS, or NixOS
**Core Concepts**
- [Templates](Core-Concepts-Templates) - Understanding templates and how they work
- [Variables](Core-Concepts-Variables) - Variable types, sections, and dependencies
- [Libraries](Core-Concepts-Libraries) - Managing template libraries
- [Defaults](Core-Concepts-Defaults) - Setting and managing default values
**Variable Reference**
- [Ansible Variables](Variables-Ansible)
- [Compose Variables](Variables-Compose)
- [Helm Variables](Variables-Helm)
- [Kubernetes Variables](Variables-Kubernetes)
- [Packer Variables](Variables-Packer)
- [Terraform Variables](Variables-Terraform)
## Developer Documentation
**Architecture & Development**
- [Architecture Overview](Developers-Architecture) - System design and core components
- [Module Development](Developers-Modules) - Creating new modules
- [Template Development](Developers-Templates) - Building templates
- [Contributing Guide](Developers-Contributing) - Detailed contribution workflow
**Contributing**
Before contributing, please read our [Contributing Guidelines](https://github.com/ChristianLempa/boilerplates/blob/main/CONTRIBUTING.md)
================================================
FILE: .wiki/Installation.md
================================================
# Installation
This guide covers installing the Boilerplates CLI on various platforms.
## Prerequisites
Before installing, ensure you have:
- **Python 3.10 or higher** - Check with `python3 --version`
- **Git** - Required for syncing template libraries
- **Internet connection** - For downloading dependencies and templates
### Checking Python Version
```bash
python3 --version
```
If you see version 3.10 or higher, you're ready to proceed. If not, see the platform-specific instructions below for installing Python.
## Quick Install (Recommended)
The automated installer script handles all dependencies and setup:
```bash
curl -fsSL https://raw.githubusercontent.com/christianlempa/boilerplates/main/scripts/install.sh | bash
```
### Install Specific Version
```bash
curl -fsSL https://raw.githubusercontent.com/christianlempa/boilerplates/main/scripts/install.sh | bash -s -- --version v0.1.0
```
The installer will:
1. Check for required dependencies (Python, pipx)
2. Install pipx if not present
3. Install the Boilerplates CLI in an isolated environment
4. Add the `boilerplates` command to your PATH
## Platform-Specific Installation
### Linux
#### Ubuntu / Debian
1. **Install Python and dependencies:**
```bash
sudo apt update
sudo apt install -y python3 python3-pip python3-venv git
```
2. **Install pipx:**
```bash
python3 -m pip install --user pipx
python3 -m pipx ensurepath
```
3. **Install Boilerplates:**
```bash
pipx install boilerplates-cli
```
4. **Verify installation:**
```bash
boilerplates --version
```
#### Fedora / RHEL / CentOS
1. **Install Python and dependencies:**
```bash
sudo dnf install -y python3 python3-pip git
```
2. **Install pipx:**
```bash
python3 -m pip install --user pipx
python3 -m pipx ensurepath
```
3. **Install Boilerplates:**
```bash
pipx install boilerplates-cli
```
#### Arch Linux
1. **Install Python and dependencies:**
```bash
sudo pacman -S python python-pip git
```
2. **Install pipx:**
```bash
python3 -m pip install --user pipx
python3 -m pipx ensurepath
```
3. **Install Boilerplates:**
```bash
pipx install boilerplates-cli
```
### MacOS
#### Using Homebrew (Recommended)
1. **Install Homebrew** (if not already installed):
```bash
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
```
2. **Install Python and pipx:**
```bash
brew install python pipx
pipx ensurepath
```
3. **Install Boilerplates:**
```bash
pipx install boilerplates-cli
```
#### Using Python from python.org
1. Download and install Python 3.10+ from [python.org](https://www.python.org/downloads/macos/)
2. **Install pipx:**
```bash
python3 -m pip install --user pipx
python3 -m pipx ensurepath
```
3. **Install Boilerplates:**
```bash
pipx install boilerplates-cli
```
### NixOS
Boilerplates is available as a Nix Flake for NixOS and Nix users.
#### Run Without Installing
```bash
nix run github:christianlempa/boilerplates -- --help
```
#### Install to Profile
```bash
nix profile install github:christianlempa/boilerplates
```
#### Use in Flake
Add to your `flake.nix`:
```nix
{
inputs.boilerplates.url = "github:christianlempa/boilerplates";
outputs = { self, nixpkgs, boilerplates }: {
# Use boilerplates.packages.${system}.default
packages.x86_64-linux.default = boilerplates.packages.x86_64-linux.default;
};
}
```
#### Temporary Shell
```bash
nix shell github:christianlempa/boilerplates
```
### Windows (WSL Recommended)
While Boilerplates can run on Windows, we recommend using Windows Subsystem for Linux (WSL) for the best experience.
#### Install WSL (Windows 10/11)
1. **Install WSL:**
```powershell
wsl --install
```
2. **Restart** your computer
3. **Follow Linux installation** instructions above (Ubuntu is the default distribution)
#### Native Windows (Not Recommended)
1. Install Python 3.10+ from [python.org](https://www.python.org/downloads/windows/)
2. Install pipx:
```powershell
python -m pip install --user pipx
python -m pipx ensurepath
```
3. Install Boilerplates:
```powershell
pipx install boilerplates-cli
```
## Manual Installation
For development or custom installations:
### Using pip (Not Recommended for End Users)
```bash
pip install --user boilerplates-cli
```
Note: This installs globally and may conflict with system packages. Use pipx instead.
### From Source
1. **Clone the repository:**
```bash
git clone https://github.com/ChristianLempa/boilerplates.git
cd boilerplates
```
2. **Create virtual environment:**
```bash
python3 -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
```
3. **Install in development mode:**
```bash
pip install -e .
```
4. **Run the CLI:**
```bash
python3 -m cli --help
```
## Post-Installation Setup
### Verify Installation
```bash
boilerplates --version
```
Expected output:
```
Boilerplates CLI v0.1.0
```
### Initialize Template Library
Sync the default template library:
```bash
boilerplates repo update
```
This downloads all available templates to:
```
~/.config/boilerplates/libraries/
```
### Shell Completion (Optional)
Enable tab completion for your shell:
#### Bash
```bash
echo 'eval "$(_BOILERPLATES_COMPLETE=bash_source boilerplates)"' >> ~/.bashrc
source ~/.bashrc
```
#### Zsh
```bash
echo 'eval "$(_BOILERPLATES_COMPLETE=zsh_source boilerplates)"' >> ~/.zshrc
source ~/.zshrc
```
#### Fish
```bash
echo '_BOILERPLATES_COMPLETE=fish_source boilerplates | source' >> ~/.config/fish/completions/boilerplates.fish
```
## Updating
### Update to Latest Version
```bash
pipx upgrade boilerplates-cli
```
### Update Template Library
```bash
boilerplates repo update
```
## Uninstalling
### Remove the CLI
```bash
pipx uninstall boilerplates-cli
```
### Remove Configuration and Templates
```bash
rm -rf ~/.config/boilerplates
```
## Troubleshooting
### Command Not Found After Installation
If `boilerplates` is not found, ensure pipx binaries are in your PATH:
```bash
# Add to your shell profile (.bashrc, .zshrc, etc.)
export PATH="$HOME/.local/bin:$PATH"
```
Then reload your shell:
```bash
source ~/.bashrc # or ~/.zshrc
```
### Python Version Too Old
If you have Python < 3.10, install a newer version:
**Ubuntu/Debian:**
```bash
sudo add-apt-repository ppa:deadsnakes/ppa
sudo apt update
sudo apt install python3.11
```
**Fedora:**
```bash
sudo dnf install python3.11
```
**MacOS (Homebrew):**
```bash
brew install python@3.11
```
### Permission Denied Errors
If you encounter permission errors during installation:
```bash
# Use --user flag
python3 -m pip install --user pipx
# Or use virtual environments
python3 -m venv ~/venvs/boilerplates
source ~/venvs/boilerplates/bin/activate
pip install boilerplates-cli
```
### SSL Certificate Errors
If you encounter SSL errors:
```bash
# Ubuntu/Debian
sudo apt install ca-certificates
# Update certificates
sudo update-ca-certificates
```
## Next Steps
Now that you've installed Boilerplates:
- [Getting Started](Getting-Started) - Generate your first template
- [Configuration](Core-Concepts-Libraries) - Customize your setup
- [Templates](Core-Concepts-Templates) - Learn about template structure
## Getting Help
- **Discord:** [Join the community](https://christianlempa.de/discord)
- **GitHub Issues:** [Report installation problems](https://github.com/ChristianLempa/boilerplates/issues)
- **Documentation:** [Browse the Wiki](Home)
================================================
FILE: .wiki/Variables-Ansible.md
================================================
# Ansible Variables
**Module:** `ansible`
**Schema Version:** `1.0`
**Description:** Manage Ansible playbooks
---
This page documents all available variables for the ansible module. Variables are organized into sections that can be enabled/disabled based on your configuration needs.
## Table of Contents
- [General](#general)
- [Options](#options)
- [Secrets](#secrets)
---
## General
**Required:** Yes
| Variable | Type | Default | Description |
|----------|------|---------|-------------|
| `playbook_name` | `str` | _none_ | Ansible playbook name |
| `target_hosts` | `str` | `{{ my_hosts | d([]) }}` | Target hosts pattern (e.g., 'all', 'webservers', or '{{ my_hosts | d([]) }}') |
| `become` | `bool` | ✗ | Run tasks with privilege escalation (sudo) |
---
## Options
**Toggle Variable:** `options_enabled`
| Variable | Type | Default | Description |
|----------|------|---------|-------------|
| `options_enabled` | `bool` | ✗ | Enable additional playbook options |
| `gather_facts` | `bool` | ✓ | Gather facts about target hosts |
---
## Secrets
**Toggle Variable:** `secrets_enabled`
| Variable | Type | Default | Description |
|----------|------|---------|-------------|
| `secrets_enabled` | `bool` | ✗ | Use external secrets file |
| `secrets_file` | `str` | `secrets.yaml` | Path to secrets file |
---
## Notes
- **Required sections** must be configured
- **Toggle variables** enable/disable entire sections
- **Dependencies** (`needs`) control when sections/variables are available
- **Sensitive variables** are masked during prompts
- **Auto-generated variables** are populated automatically if not provided
---
_Last updated: Schema version 1.0_
================================================
FILE: .wiki/Variables-Compose.md
================================================
# Compose Variables
**Module:** `compose`
**Schema Version:** `1.2`
**Description:** Manage Docker Compose configurations
---
This page documents all available variables for the compose module. Variables are organized into sections that can be enabled/disabled based on your configuration needs.
## Table of Contents
- [General](#general)
- [Network](#network)
- [Ports](#ports)
- [Traefik](#traefik)
- [Traefik TLS/SSL](#traefik-tlsssl)
- [Volume Storage](#volume-storage)
- [Resource Limits](#resource-limits)
- [Docker Swarm](#docker-swarm)
- [Database](#database)
- [Email Server](#email-server)
- [Authentik SSO](#authentik-sso)
---
## General
| Variable | Type | Default | Description |
|----------|------|---------|-------------|
| `service_name` | `str` | _none_ | Service name |
| `container_name` | `str` | _none_ | Container name |
| `container_hostname` | `str` | _none_ | Container internal hostname |
| `container_timezone` | `str` | `UTC` | Container timezone (e.g., Europe/Berlin) |
| `user_uid` | `int` | `1000` | User UID for container process |
| `user_gid` | `int` | `1000` | User GID for container process |
| `container_loglevel` | `enum` | `info` | Container log level
**Options:** `debug`, `info`, `warn`, `error` |
| `restart_policy` | `enum` | `unless-stopped` | Container restart policy
**Options:** `unless-stopped`, `always`, `on-failure`, `no` |
---
## Network
| Variable | Type | Default | Description |
|----------|------|---------|-------------|
| `network_mode` | `enum` | `bridge` | Docker network mode
**Options:** `bridge`, `host`, `macvlan` |
| `network_name` | `str` | `bridge` | Docker network name
**Needs:** `network_mode=bridge,macvlan` |
| `network_external` | `bool` | ✗ | Use existing Docker network (external)
**Needs:** `network_mode=bridge,macvlan` |
| `network_macvlan_ipv4_address` | `str` | `192.168.1.253` | Static IP address for container
**Needs:** `network_mode=macvlan` |
| `network_macvlan_parent_interface` | `str` | `eth0` | Host network interface name
**Needs:** `network_mode=macvlan` |
| `network_macvlan_subnet` | `str` | `192.168.1.0/24` | Network subnet in CIDR notation
**Needs:** `network_mode=macvlan` |
| `network_macvlan_gateway` | `str` | `192.168.1.1` | Network gateway IP address
**Needs:** `network_mode=macvlan` |
---
## Ports
**Toggle Variable:** `ports_enabled`
**Depends On:** `network_mode=bridge`
Expose service ports to the host.
| Variable | Type | Default | Description |
|----------|------|---------|-------------|
| `ports_http` | `int` | `8080` | HTTP port on host |
| `ports_https` | `int` | `8443` | HTTPS port on host |
| `ports_ssh` | `int` | `22` | SSH port on host |
| `ports_dns` | `int` | `53` | DNS port on host |
| `ports_dhcp` | `int` | `67` | DHCP port on host |
| `ports_smtp` | `int` | `25` | SMTP port on host |
---
## Traefik
**Toggle Variable:** `traefik_enabled`
**Depends On:** `network_mode=bridge`
Traefik routes external traffic to your service.
| Variable | Type | Default | Description |
|----------|------|---------|-------------|
| `traefik_enabled` | `bool` | ✗ | Enable Traefik reverse proxy integration |
| `traefik_network` | `str` | `traefik` | Traefik network name |
| `traefik_host` | `str` | _none_ | Service subdomain or full hostname (e.g., 'app' or 'app.example.com') |
| `traefik_domain` | `str` | `home.arpa` | Base domain (e.g., example.com) |
| `traefik_entrypoint` | `str` | `web` | HTTP entrypoint (non-TLS) |
---
## Traefik TLS/SSL
**Toggle Variable:** `traefik_tls_enabled`
**Depends On:** `traefik_enabled=true;network_mode=bridge`
Enable HTTPS/TLS for Traefik with certificate management.
| Variable | Type | Default | Description |
|----------|------|---------|-------------|
| `traefik_tls_enabled` | `bool` | ✓ | Enable HTTPS/TLS |
| `traefik_tls_entrypoint` | `str` | `websecure` | TLS entrypoint |
| `traefik_tls_certresolver` | `str` | `cloudflare` | Traefik certificate resolver name |
---
## Volume Storage
Configure persistent storage for your service.
| Variable | Type | Default | Description |
|----------|------|---------|-------------|
| `volume_mode` | `enum` | `local` | Volume storage backend
**Options:** `local`, `mount`, `nfs` • local: Docker-managed volumes | mount: Bind mount from host | nfs: Network filesystem |
| `volume_mount_path` | `str` | `/mnt/storage` | Host path for bind mounts
**Needs:** `volume_mode=mount` |
| `volume_nfs_server` | `str` | `192.168.1.1` | NFS server address
**Needs:** `volume_mode=nfs` |
| `volume_nfs_path` | `str` | `/export` | NFS export path
**Needs:** `volume_mode=nfs` |
| `volume_nfs_options` | `str` | `rw,nolock,soft` | NFS mount options (comma-separated)
**Needs:** `volume_mode=nfs` |
---
## Resource Limits
**Toggle Variable:** `resources_enabled`
Set CPU and memory limits for the service.
| Variable | Type | Default | Description |
|----------|------|---------|-------------|
| `resources_enabled` | `bool` | ✗ | Enable resource limits |
| `resources_cpu_limit` | `str` | `1.0` | Maximum CPU cores (e.g., 0.5, 1.0, 2.0) |
| `resources_cpu_reservation` | `str` | `0.25` | Reserved CPU cores
**Needs:** `swarm_enabled=true` |
| `resources_memory_limit` | `str` | `1G` | Maximum memory (e.g., 512M, 1G, 2G) |
| `resources_memory_reservation` | `str` | `512M` | Reserved memory
**Needs:** `swarm_enabled=true` |
---
## Docker Swarm
**Toggle Variable:** `swarm_enabled`
**Depends On:** `network_mode=bridge`
Deploy service in Docker Swarm mode.
| Variable | Type | Default | Description |
|----------|------|---------|-------------|
| `swarm_enabled` | `bool` | ✗ | Enable Docker Swarm mode |
| `swarm_placement_mode` | `enum` | `replicated` | Swarm placement mode
**Options:** `replicated`, `global` |
| `swarm_replicas` | `int` | `1` | Number of replicas
**Needs:** `swarm_placement_mode=replicated` |
| `swarm_placement_host` | `str` | _none_ | Target hostname for placement constraint
**Needs:** `swarm_placement_mode=replicated` • Constrains service to run on specific node by hostname |
---
## Database
**Toggle Variable:** `database_enabled`
| Variable | Type | Default | Description |
|----------|------|---------|-------------|
| `database_type` | `enum` | `default` | Database type
**Options:** `default`, `sqlite`, `postgres`, `mysql` |
| `database_external` | `bool` | ✗ | Use an external database server?
skips creation of internal database container |
| `database_host` | `str` | `database` | Database host |
| `database_port` | `int` | _none_ | Database port |
| `database_name` | `str` | _none_ | Database name |
| `database_user` | `str` | _none_ | Database user |
| `database_password` | `str` | _none_ | Database password
**Sensitive** • **Auto-generated** |
---
## Email Server
**Toggle Variable:** `email_enabled`
Configure email server for notifications and user management.
| Variable | Type | Default | Description |
|----------|------|---------|-------------|
| `email_enabled` | `bool` | ✗ | Enable email server configuration |
| `email_host` | `str` | _none_ | SMTP server hostname |
| `email_port` | `int` | `587` | SMTP server port |
| `email_username` | `str` | _none_ | SMTP username |
| `email_password` | `str` | _none_ | SMTP password
**Sensitive** |
| `email_from` | `str` | _none_ | From email address |
| `email_use_tls` | `bool` | ✓ | Use TLS encryption |
| `email_use_ssl` | `bool` | ✗ | Use SSL encryption |
---
## Authentik SSO
**Toggle Variable:** `authentik_enabled`
Integrate with Authentik for Single Sign-On authentication.
| Variable | Type | Default | Description |
|----------|------|---------|-------------|
| `authentik_enabled` | `bool` | ✗ | Enable Authentik SSO integration |
| `authentik_url` | `str` | _none_ | Authentik base URL (e.g., https://auth.example.com) |
| `authentik_slug` | `str` | _none_ | Authentik application slug |
| `authentik_client_id` | `str` | _none_ | OAuth client ID from Authentik provider |
| `authentik_client_secret` | `str` | _none_ | OAuth client secret from Authentik provider
**Sensitive** |
| `authentik_traefik_middleware` | `str` | `authentik-middleware@file` | Traefik middleware name for Authentik authentication
**Needs:** `traefik_enabled=true` |
---
## Notes
- **Required sections** must be configured
- **Toggle variables** enable/disable entire sections
- **Dependencies** (`needs`) control when sections/variables are available
- **Sensitive variables** are masked during prompts
- **Auto-generated variables** are populated automatically if not provided
---
_Last updated: Schema version 1.2_
================================================
FILE: .wiki/Variables-Helm.md
================================================
# Helm Variables
**Module:** `helm`
**Schema Version:** `1.0`
**Description:** Manage Helm charts
---
This page documents all available variables for the helm module. Variables are organized into sections that can be enabled/disabled based on your configuration needs.
## Table of Contents
- [General](#general)
- [Networking](#networking)
- [Traefik Ingress](#traefik-ingress)
- [Traefik TLS/SSL](#traefik-tlsssl)
- [Volumes](#volumes)
- [Database](#database)
- [Email Server](#email-server)
---
## General
| Variable | Type | Default | Description |
|----------|------|---------|-------------|
| `release_name` | `str` | _none_ | Helm release name |
| `namespace` | `str` | `default` | Kubernetes namespace for the Helm release |
---
## Networking
| Variable | Type | Default | Description |
|----------|------|---------|-------------|
| `network_mode` | `enum` | `ClusterIP` | Kubernetes service type
**Options:** `ClusterIP`, `NodePort`, `LoadBalancer` |
---
## Traefik Ingress
**Toggle Variable:** `traefik_enabled`
**Depends On:** `network_mode=ClusterIP`
Traefik routes external traffic to your service.
| Variable | Type | Default | Description |
|----------|------|---------|-------------|
| `traefik_enabled` | `bool` | ✗ | Enable Traefik Ingress/IngressRoute |
| `traefik_host` | `hostname` | _none_ | Hostname for Traefik ingress |
---
## Traefik TLS/SSL
**Toggle Variable:** `traefik_tls_enabled`
**Depends On:** `traefik_enabled=true;network_mode=ClusterIP`
Enable HTTPS/TLS for Traefik with certificate management.
| Variable | Type | Default | Description |
|----------|------|---------|-------------|
| `traefik_tls_enabled` | `bool` | ✓ | Enable HTTPS/TLS |
| `traefik_tls_secret` | `str` | `traefik-tls` | TLS secret name |
| `traefik_tls_certmanager` | `bool` | ✗ | Use cert-manager for automatic certificate provisioning |
| `certmanager_issuer` | `str` | `cloudflare` | Cert-manager ClusterIssuer or Issuer name
**Needs:** `traefik_tls_certmanager=true` |
| `certmanager_issuer_kind` | `enum` | `ClusterIssuer` | Issuer kind
**Options:** `ClusterIssuer`, `Issuer` • **Needs:** `traefik_tls_certmanager=true` |
---
## Volumes
| Variable | Type | Default | Description |
|----------|------|---------|-------------|
| `volumes_mode` | `enum` | `default` | Persistent volume mode
**Options:** `default`, `existing-pvc` |
| `volumes_pvc_name` | `str` | _none_ | Name of existing PVC
**Needs:** `volumes_mode=existing-pvc` |
---
## Database
**Toggle Variable:** `database_enabled`
| Variable | Type | Default | Description |
|----------|------|---------|-------------|
| `database_enabled` | `bool` | ✗ | Enable database configuration |
| `database_type` | `enum` | `postgres` | Database type
**Options:** `postgres`, `mysql`, `mariadb` |
| `database_host` | `hostname` | _none_ | Database host |
| `database_port` | `int` | _none_ | Database port |
| `database_name` | `str` | _none_ | Database name |
| `database_user` | `str` | _none_ | Database user |
| `database_password` | `str` | _none_ | Database password
**Sensitive** • **Auto-generated** |
---
## Email Server
**Toggle Variable:** `email_enabled`
Configure email server for notifications and user management.
| Variable | Type | Default | Description |
|----------|------|---------|-------------|
| `email_enabled` | `bool` | ✗ | Enable email server configuration |
| `email_host` | `hostname` | _none_ | SMTP server hostname |
| `email_port` | `int` | `587` | SMTP server port |
| `email_username` | `str` | _none_ | SMTP username |
| `email_password` | `str` | _none_ | SMTP password
**Sensitive** |
| `email_from` | `email` | _none_ | From email address |
| `email_use_tls` | `bool` | ✓ | Use TLS encryption |
| `email_use_ssl` | `bool` | ✗ | Use SSL encryption |
---
## Notes
- **Required sections** must be configured
- **Toggle variables** enable/disable entire sections
- **Dependencies** (`needs`) control when sections/variables are available
- **Sensitive variables** are masked during prompts
- **Auto-generated variables** are populated automatically if not provided
---
_Last updated: Schema version 1.0_
================================================
FILE: .wiki/Variables-Kubernetes.md
================================================
# Kubernetes Variables
**Module:** `kubernetes`
**Schema Version:** `1.0`
**Description:** Manage Kubernetes configurations
---
This page documents all available variables for the kubernetes module. Variables are organized into sections that can be enabled/disabled based on your configuration needs.
## Table of Contents
- [General](#general)
- [Traefik](#traefik)
- [Traefik TLS/SSL](#traefik-tlsssl)
- [Cert-Manager](#cert-manager)
---
## General
| Variable | Type | Default | Description |
|----------|------|---------|-------------|
| `resource_name` | `str` | _none_ | Resource name (metadata.name) |
| `namespace` | `str` | `default` | Kubernetes namespace |
---
## Traefik
**Toggle Variable:** `traefik_enabled`
Traefik IngressRoute configuration for HTTP/HTTPS routing
| Variable | Type | Default | Description |
|----------|------|---------|-------------|
| `traefik_enabled` | `bool` | ✗ | Enable Traefik IngressRoute |
| `traefik_entrypoint` | `str` | `web` | Traefik entrypoint (non-TLS) |
| `traefik_host` | `hostname` | _none_ | Domain name for the service (e.g., app.example.com) |
| `traefik_service_name` | `str` | _none_ | Backend Kubernetes service name |
| `traefik_service_port` | `int` | `80` | Backend service port |
---
## Traefik TLS/SSL
**Toggle Variable:** `traefik_tls_enabled`
**Depends On:** `traefik`
Enable HTTPS/TLS for Traefik with certificate management
| Variable | Type | Default | Description |
|----------|------|---------|-------------|
| `traefik_tls_enabled` | `bool` | ✓ | Enable HTTPS/TLS |
| `traefik_tls_entrypoint` | `str` | `websecure` | TLS entrypoint |
| `traefik_tls_certresolver` | `str` | `cloudflare` | Traefik certificate resolver name |
---
## Cert-Manager
**Toggle Variable:** `certmanager_enabled`
Cert-manager certificate management configuration
| Variable | Type | Default | Description |
|----------|------|---------|-------------|
| `certmanager_enabled` | `bool` | ✗ | Enable cert-manager certificate |
| `certmanager_issuer` | `str` | `cloudflare` | ClusterIssuer or Issuer name |
| `certmanager_issuer_kind` | `enum` | `ClusterIssuer` | Issuer kind
**Options:** `ClusterIssuer`, `Issuer` |
---
## Notes
- **Required sections** must be configured
- **Toggle variables** enable/disable entire sections
- **Dependencies** (`needs`) control when sections/variables are available
- **Sensitive variables** are masked during prompts
- **Auto-generated variables** are populated automatically if not provided
---
_Last updated: Schema version 1.0_
================================================
FILE: .wiki/Variables-Packer.md
================================================
# Packer Variables
**Module:** `packer`
**Schema Version:** `1.0`
**Description:** Manage Packer templates
---
This page documents all available variables for the packer module. Variables are organized into sections that can be enabled/disabled based on your configuration needs.
## Table of Contents
- [General](#general)
---
## General
| Variable | Type | Default | Description |
|----------|------|---------|-------------|
| `image_name` | `str` | _none_ | Image name |
---
## Notes
- **Required sections** must be configured
- **Toggle variables** enable/disable entire sections
- **Dependencies** (`needs`) control when sections/variables are available
- **Sensitive variables** are masked during prompts
- **Auto-generated variables** are populated automatically if not provided
---
_Last updated: Schema version 1.0_
================================================
FILE: .wiki/Variables-Terraform.md
================================================
# Terraform Variables
**Module:** `terraform`
**Schema Version:** `1.0`
**Description:** Manage Terraform configurations
---
This page documents all available variables for the terraform module. Variables are organized into sections that can be enabled/disabled based on your configuration needs.
## Table of Contents
- [General](#general)
---
## General
| Variable | Type | Default | Description |
|----------|------|---------|-------------|
| `resource_name` | `str` | _none_ | Resource name prefix |
| `backend_mode` | `enum` | `local` | Terraform backend mode
**Options:** `local`, `http` |
---
## Notes
- **Required sections** must be configured
- **Toggle variables** enable/disable entire sections
- **Dependencies** (`needs`) control when sections/variables are available
- **Sensitive variables** are masked during prompts
- **Auto-generated variables** are populated automatically if not provided
---
_Last updated: Schema version 1.0_
================================================
FILE: .wiki/Variables.md
================================================
# Variables Documentation
This section contains auto-generated documentation for all available variables in each module.
## Available Modules
- [Ansible](Variables-Ansible)
- [Compose](Variables-Compose)
- [Helm](Variables-Helm)
- [Kubernetes](Variables-Kubernetes)
- [Packer](Variables-Packer)
- [Terraform](Variables-Terraform)
---
Each module page includes:
- Schema version information
- Complete list of sections and variables
- Variable types, defaults, and descriptions
- Section dependencies and toggle configurations
---
_This documentation is auto-generated from module schemas._
================================================
FILE: .wiki/_Sidebar.md
================================================
## Boilerplates Wiki
- **[Home](Home)**
### Getting Started
- [Getting Started](Getting-Started)
- [Installation](Installation)
### Core Concepts
- [Templates](Core-Concepts-Templates)
- [Variables](Core-Concepts-Variables)
- [Libraries](Core-Concepts-Libraries)
- [Defaults](Core-Concepts-Defaults)
### Variables Reference
- [All Modules](Variables)
- [Ansible](Variables-Ansible)
- [Compose](Variables-Compose)
- [Helm](Variables-Helm)
- [Kubernetes](Variables-Kubernetes)
- [Packer](Variables-Packer)
- [Terraform](Variables-Terraform)
### Developer Docs
- [Architecture](Developers-Architecture)
- [Modules](Developers-Modules)
- [Templates](Developers-Templates)
- [Contributing](Developers-Contributing)
================================================
FILE: .yamllint
================================================
---
extends: default
rules:
comments-indentation: disable
indentation:
spaces: 2
indent-sequences: true
line-length:
max: 160
level: warning
================================================
FILE: AGENTS.md
================================================
# AGENTS.md
Guidance for AI Agents working with this repository.
## Project Overview
A sophisticated collection of infrastructure templates (boilerplates) with a Python CLI for management. Supports Terraform, Docker, Ansible, Kubernetes, etc. Built with Typer (CLI) and Jinja2 (templating).
## Development Setup
### Running and Testing
```bash
# Run the CLI application
python3 -m cli
# Debugging and Testing commands
python3 -m cli --log-level DEBUG compose list
```
### Production-Ready Testing
For detailed information about testing boilerplates in a production-like environment before release, see **WARP-LOCAL.md** (local file, not in git). This document covers:
- Test server infrastructure and Docker contexts
- Step-by-step testing procedures for Docker Compose, Swarm, and Kubernetes
- Comprehensive testing checklists
- Production release criteria
### Linting and Formatting
Should **always** happen before pushing anything to the repository.
- Use `yamllint` for YAML files
- Use `ruff` for Python code:
- `ruff check --fix .` - Check and auto-fix linting errors (including unused imports)
- `ruff format .` - Format code according to style guidelines
- Both commands must be run before committing
### Project Management and Git
The project is stored in a public GitHub Repository, use issues, and branches for features/bugfixes and open PRs for merging.
**Naming Conventions and Best-Practices:**
- Branches, PRs: `feature/2314-add-feature`, `problem/1249-fix-bug`
- Issues should have clear titles and descriptions, link related issues/PRs, and have appropriate labels like (problem, feature, discussion, question).
- Commit messages should be clear and concise, following the format: `type(scope): subject` (e.g., `fix(compose): correct variable parsing`).
## Architecture
### File Structure
- `cli/` - Python CLI application source code
- `cli/core/` - Core Components of the CLI application
- `cli/core/schema/` - JSON schema definitions for all modules
- `cli/modules/` - Modules implementing technology-specific functions
- `cli/__main__.py` - CLI entry point, auto-discovers modules and registers commands
- `library/` - Template collections organized by module
- `library/ansible/` - Ansible playbooks and configurations
- `library/compose/` - Docker Compose configurations
- `library/docker/` - Docker templates
- `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-specific archetype snippets (e.g., `archetypes/compose/`)
### Core Components
- `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/` - Centralized CLI output rendering package (**Always use DisplayManager - never print directly**)
- `__init__.py` - Package exports (DisplayManager, DisplaySettings, IconManager)
- `display_manager.py` - Main DisplayManager facade
- `display_settings.py` - DisplaySettings configuration class
- `icon_manager.py` - IconManager for Nerd Font icons
- `variable_display.py` - VariableDisplayManager for variable rendering
- `template_display.py` - TemplateDisplayManager for template display
- `status_display.py` - StatusDisplayManager for status messages
- `table_display.py` - TableDisplayManager for table rendering
- `cli/core/exceptions.py` - Custom exceptions for error handling (**Always use this for raising errors**)
- `cli/core/library.py` - LibraryManager for template discovery from git-synced libraries and static file paths
- `cli/core/module.py` - Abstract base class for modules (defines standard commands)
- `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/schema/` - Schema management package (**JSON-based schema system**)
- `loader.py` - SchemaLoader class for loading and validating JSON schemas
- `/` - Module-specific schema directories (e.g., `compose/`, `terraform/`)
- `/v*.json` - Version-specific JSON schema files (e.g., `v1.0.json`, `v1.2.json`)
- `cli/core/section.py` - VariableSection class (stores section metadata and variables)
- **Key Attributes**: `key`, `title`, `toggle`, `needs`, `variables` (dict of Variable objects)
- `cli/core/template.py` - Template Class for parsing, managing and rendering templates
- `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
### Modules
**Module Structure:**
Modules can be either single files or packages:
- **Single file**: `cli/modules/modulename.py` (for simple modules)
- **Package**: `cli/modules/modulename/` with `__init__.py` (for multi-schema modules)
**Creating Modules:**
- Subclass `Module` from `cli/core/module.py`
- Define `name`, `description`, and `schema_version` class attributes
- For multi-schema modules: organize specs in separate files (e.g., `spec_v1_0.py`, `spec_v1_1.py`)
- Call `registry.register(YourModule)` at module bottom
- Auto-discovered and registered at CLI startup
**Module Discovery and Registration:**
The system automatically discovers and registers modules at startup:
1. **Discovery**: CLI `__main__.py` imports all Python files in `cli/modules/` directory
2. **Registration**: Each module file calls `registry.register(ModuleClass)` at module level
3. **Storage**: Registry stores module classes in a central dictionary by module name
4. **Command Generation**: CLI framework auto-generates subcommands for each registered module
5. **Instantiation**: Modules are instantiated on-demand when commands are invoked
**Benefits:**
- No manual registration needed - just add a file to `cli/modules/`
- Modules are self-contained - can be added/removed without modifying core code
- Type-safe - registry validates module interfaces at registration time
**Module Schema System:**
**JSON Schema Architecture** (Refactored from Python specs):
All module schemas are now defined as **JSON files** in `cli/core/schema//v*.json`. This provides:
- **Version control**: Easy schema comparison and diffs in git
- **Language-agnostic**: Schemas can be consumed by tools outside Python
- **Validation**: Built-in JSON schema validation
- **Documentation**: Self-documenting schema structure
**Schema File Location:**
```
cli/core/schema/
compose/
v1.0.json
v1.1.json
v1.2.json
terraform/
v1.0.json
ansible/
v1.0.json
...other modules...
```
**JSON Schema Structure:**
Schemas are arrays of section objects, where each section contains:
```json
[
{
"key": "section_key",
"title": "Section Title",
"description": "Optional section description",
"toggle": "optional_toggle_variable_name",
"needs": "optional_dependency",
"required": true,
"vars": [
{
"name": "variable_name",
"type": "str",
"description": "Variable description",
"default": "default_value",
"required": true,
"sensitive": false,
"autogenerated": false,
"options": ["option1", "option2"],
"needs": "other_var=value",
"extra": "Additional help text"
}
]
}
]
```
**Schema Loading in Modules:**
Modules load JSON schemas on-demand using the SchemaLoader:
```python
from cli.core.schema import load_schema, has_schema, list_versions
class MyModule(Module):
name = "mymodule"
schema_version = "1.2" # Latest version supported
def get_spec(self, template_schema: str) -> OrderedDict:
"""Load JSON schema and convert to dict format."""
json_spec = load_schema(self.name, template_schema)
# Convert JSON array to OrderedDict format
return self._convert_json_to_dict(json_spec)
```
**Schema Design Principles:**
- **Backward compatibility**: Newer module versions can load older template schemas
- **Auto-created toggle variables**: Sections with `toggle` automatically create boolean variables
- **Conditional visibility**: Variables use `needs` constraints to show/hide based on other variable values
- **Mode-based organization**: Related settings grouped by operational mode (e.g., network_mode, volume_mode)
- **Incremental evolution**: New schemas add features without breaking existing templates
**Working with Schemas:**
- **View available versions**: Check `cli/core/schema//` directory or use `list_versions(module)`
- **Add new schema version**: Create new JSON file following naming convention (e.g., `v1.3.json`)
- **Update module**: Increment `schema_version` in module class when adding new schema
- **Validate schemas**: SchemaLoader automatically validates JSON structure on load
**Migration from Python Specs:**
Older Python-based `spec_v*.py` files have been migrated to JSON. The module `__init__.py` now:
1. Loads JSON schemas using SchemaLoader
2. Converts JSON array format to OrderedDict for backward compatibility
3. Provides lazy loading via `_SchemaDict` class
**Existing Modules:**
- `cli/modules/compose/` - Docker Compose (JSON schemas: v1.0, v1.1, v1.2)
- Other modules (ansible, terraform, kubernetes, helm, packer) - Work in Progress
**(Work in Progress):** terraform, docker, ansible, kubernetes, packer modules
### LibraryManager
- Loads libraries from config file
- Stores Git Libraries under: `~/.config/boilerplates/libraries/{name}/`
- Uses sparse-checkout to clone only template directories for git-based libraries (avoiding unnecessary files)
- Supports two library types: **git** (synced from repos) and **static** (local directories)
- Priority determined by config order (first = highest)
**Library Types:**
- `git`: Requires `url`, `branch`, `directory` fields
- `static`: Requires `path` field (absolute or relative to config)
**Duplicate Handling:**
- Within same library: Raises `DuplicateTemplateError`
- Across libraries: Uses qualified IDs (e.g., `alloy.default`, `alloy.local`)
- Simple IDs use priority: `compose show alloy` loads from first library
- Qualified IDs target specific library: `compose show alloy.local`
**Config Example:**
```yaml
libraries:
- name: default # Highest priority (checked first)
type: git
url: https://github.com/user/templates.git
branch: main
directory: library
- name: local # Lower priority
type: static
path: ~/my-templates
url: '' # Backward compatibility fields
branch: main
directory: .
```
**Note:** Static libraries include dummy `url`/`branch`/`directory` fields for backward compatibility with older CLI versions.
### ConfigManager
- User Config stored in `~/.config/boilerplates/config.yaml`
### DisplayManager and IconManager
**CRITICAL RULE - NEVER violate this:**
- NEVER use `console.print()` outside of display manager classes (`cli/core/display/` directory)
- NEVER import `Console` from `rich.console` except in display manager classes or `cli/__main__.py`
- ALWAYS use `module_instance.display.display_*()` or `display.display_*()` methods for ALL output
- Display managers (`cli/core/display/*.py`) are the ONLY exception - they implement console output
**Rationale:**
- `DisplayManager` provides a **centralized interface** for ALL CLI output rendering
- Direct console usage bypasses formatting standards, icon management, and output consistency
- `IconManager` provides **Nerd Font icons** internally for DisplayManager - never use emojis or direct icons
**DisplayManager Architecture** (Refactored for Single Responsibility Principle):
`DisplayManager` acts as a facade that delegates to specialized manager classes:
1. **VariableDisplayManager** - Handles all variable-related rendering
- `render_variable_value()` - Variable value formatting with context awareness
- `render_section()` - Section header display
- `render_variables_table()` - Complete variables table with dependencies
2. **TemplateDisplayManager** - Handles all template-related rendering
- `render_template()` - Main template display coordinator
- `render_template_header()` - Template metadata display
- `render_file_tree()` - Template file structure visualization
- `render_file_generation_confirmation()` - Files preview before generation
3. **StatusDisplayManager** - Handles status messages and error display
- `display_message()` - Core message formatting with level-based routing
- `display_error()`, `display_warning()`, `display_success()`, `display_info()` - Convenience methods
- `display_template_render_error()` - Detailed render error display
- `display_warning_with_confirmation()` - Interactive warning prompts
4. **TableDisplayManager** - Handles table rendering
- `render_templates_table()` - Templates list with library indicators
- `render_status_table()` - Status tables with success/error indicators
- `render_config_tree()` - Configuration tree visualization
**Usage Pattern:**
```python
# External code uses DisplayManager methods (backward compatible)
display = DisplayManager()
display.display_template(template, template_id)
# Internally, DisplayManager delegates to specialized managers
# display.templates.render_template(template, template_id)
```
**Design Principles:**
- External code calls `DisplayManager` methods only
- `DisplayManager` delegates to specialized managers internally
- Each specialized manager has a single, focused responsibility
- Backward compatibility maintained through delegation methods
- All managers can access parent DisplayManager via `self.parent`
## Templates
Templates are directory-based. Each template is a directory containing all the necessary files and subdirectories for the boilerplate.
### Template Rendering Flow
**How templates are loaded and rendered:**
1. **Discovery**: LibraryManager finds template directories containing `template.yaml`/`template.yml`
2. **Parsing**: Template class loads and parses the template metadata and spec
3. **Schema Resolution**: Module's `get_spec()` loads appropriate spec based on template's `schema` field
4. **Variable Inheritance**: Template inherits ALL variables from module schema
5. **Variable Merging**: Template spec overrides are merged with module spec (precedence: module < template < user config < CLI)
6. **Collection Building**: VariableCollection is constructed with merged variables and sections
7. **Dependency Resolution**: Sections are topologically sorted based on `needs` constraints
8. **Variable Resolution**: Variables with `needs` constraints are evaluated for visibility
9. **Jinja2 Rendering**: Template files (`.j2`) are rendered with final variable values
10. **Sanitization**: Rendered output is cleaned (whitespace, blank lines, trailing newline)
11. **Validation**: Optional semantic validation (YAML structure, Docker Compose schema, etc.)
**Key Architecture Points:**
- Templates don't "call" module specs - they declare a schema version and inherit from it
- Variable visibility is dynamic based on `needs` constraints (evaluated at prompt/render time)
- Jinja2 templates support `{% include %}` and `{% import %}` for composition
### Template Structure
Requires `template.yaml` or `template.yml` with metadata and variables:
```yaml
---
kind: compose
schema: "X.Y" # Optional: Defaults to "1.0" if not specified (e.g., "1.0", "1.2")
metadata:
name: My Service Template
description: A template for a service.
version: 1.0.0
author: Your Name
date: '2024-01-01'
spec:
general:
vars:
service_name:
type: str
description: Service name
```
### Template Metadata Versioning
**Template Version Field:**
The `metadata.version` field in `template.yaml` should reflect the version of the underlying application or resource:
- **Compose templates**: Match the Docker image version (e.g., `nginx:1.25.3` → `version: 1.25.3`)
- **Terraform templates**: Match the provider version (e.g., AWS provider 5.23.0 → `version: 5.23.0`)
- **Other templates**: Match the primary application/tool version being deployed
- Use `latest` or increment template-specific version (e.g., `0.1.0`, `0.2.0`) only when no specific application version applies
**Rationale:** This helps users identify which version of the application/provider the template is designed for and ensures template versions track upstream changes.
**Application Version Variables:**
- **IMPORTANT**: Application/image versions should be **hardcoded** in template files (e.g., `image: nginx:1.25.3`)
- Do NOT create template variables for application versions (e.g., no `nginx_version` variable)
- Users should update the template file directly when they need a different version
- This prevents version mismatches and ensures templates are tested with specific, known versions
- Exception: Only create version variables if there's a strong technical reason (e.g., multi-component version pinning)
### Template Schema Versioning
**Version Format:** Schemas use 2-level versioning in `MAJOR.MINOR` format (e.g., "1.0", "1.2", "2.0").
Templates and modules use schema versioning to ensure compatibility. Each module defines a supported schema version, and templates declare which schema version they use.
```yaml
---
kind: compose
schema: "X.Y" # Optional: Defaults to "1.0" if not specified (e.g., "1.0", "1.2")
metadata:
name: My Template
version: 1.0.0
# ... other metadata fields
spec:
# ... variable specifications
```
**How It Works:**
- **Module Schema Version**: Each module defines `schema_version` (e.g., "1.0", "1.2", "2.0")
- **Module Spec Loading**: Modules load appropriate spec based on template's schema version
- **Template Schema Version**: Each template declares `schema` at the top level (defaults to "1.0")
- **Compatibility Check**: Template schema ≤ Module schema → Compatible
- **Incompatibility**: Template schema > Module schema → `IncompatibleSchemaVersionError`
**Behavior:**
- Templates without `schema` field default to "1.0" (backward compatible)
- Older templates work with newer module versions (backward compatibility)
- Templates with newer schema versions fail on older modules with `IncompatibleSchemaVersionError`
- Version comparison uses MAJOR.MINOR format (e.g., "1.0" < "1.2" < "2.0")
**When to Use:**
- Increment module schema version when adding new features (new variable types, sections, etc.)
- Set template schema when using features from a specific schema version
- Templates using features from newer schemas must declare the appropriate schema version
**Single-File Module Example:**
```python
class SimpleModule(Module):
name = "simple"
description = "Simple module"
schema_version = "X.Y" # e.g., "1.0", "1.2"
spec = VariableCollection.from_dict({...}) # Single spec
```
**Multi-Schema Module Example:**
```python
# cli/modules/modulename/__init__.py
class ExampleModule(Module):
name = "modulename"
description = "Module description"
schema_version = "X.Y" # Highest schema version supported (e.g., "1.2", "2.0")
def get_spec(self, template_schema: str) -> VariableCollection:
"""Load spec based on template schema version."""
# Dynamically load the appropriate spec version
# template_schema will be like "1.0", "1.2", etc.
version_file = f"spec_v{template_schema.replace('.', '_')}"
spec_module = importlib.import_module(f".{version_file}", package=__package__)
return spec_module.get_spec()
```
**Version Management:**
- CLI version is defined in `cli/__init__.py` as `__version__`
- pyproject.toml version must match `__version__` for releases
- GitHub release workflow validates version consistency
### Template Files
- **Jinja2 Templates (`.j2`)**: Rendered by Jinja2, `.j2` extension removed in output. Support `{% include %}` and `{% import %}`.
- **Static Files**: Non-`.j2` files copied as-is.
- **Sanitization**: Auto-sanitized (single blank lines, no leading blanks, trimmed whitespace, single trailing newline).
- **Shortcodes**: Template descriptions support emoji-style shortcodes (e.g., `:warning:`, `:info:`, `:docker:`) which are automatically replaced with Nerd Font icons during display. Add new shortcodes to `IconManager.SHORTCODES` dict.
### Docker Compose Best Practices
**Traefik Integration:**
When using Traefik with Docker Compose, the `traefik.docker.network` label is **CRITICAL** for stacks with multiple networks. When containers are connected to multiple networks, Traefik must know which network to use for routing.
**Implementation:**
- Review `archetypes/compose/` directory for reference implementations of Traefik integration patterns
- The `traefik.docker.network={{ traefik_network }}` label must be present in both standard `labels:` and `deploy.labels:` sections
- Standard mode and Swarm mode require different label configurations - check archetypes for examples
### Variables
**How Templates Inherit Variables:**
Templates automatically inherit ALL variables from the module schema version they declare. The template's `schema: "X.Y"` field determines which module spec is loaded, and all variables from that schema are available.
**When to Define Template Variables:**
You only need to define variables in your template's `spec` section when:
1. **Overriding defaults**: Change default values for module variables (e.g., hardcode `service_name` for your specific app)
2. **Adding custom variables**: Define template-specific variables not present in the module schema
3. **Upgrading to newer schema**: To use new features, update `schema: "X.Y"` to a higher version - no template spec changes needed
**Variable Precedence** (lowest to highest):
1. Module `spec` (defaults for all templates of that kind)
2. Template `spec` (overrides module defaults)
3. User `config.yaml` (overrides template and module defaults)
4. CLI `--var` (highest priority)
**Template Variable Override Rules:**
- **Override module defaults**: Only specify properties that differ from module spec (e.g., change `default` value)
- **Create new variables**: Define template-specific variables not in module spec
- **Minimize duplication**: Do NOT re-specify `type`, `description`, or other properties if they remain unchanged from module spec
**Example:**
```yaml
# Template declares schema: "1.2" → inherits ALL variables from compose schema 1.2
# Template spec ONLY needs to override specific defaults:
spec:
general:
vars:
service_name:
default: whoami # Only override the default, type already defined in module
# All other schema 1.2 variables (network_mode, volume_mode, etc.) are automatically available
```
**Variable Types:**
- `str` (default), `int`, `float`, `bool`
- `email` - Email validation with regex
- `url` - URL validation (requires scheme and host)
- `hostname` - Hostname/domain validation
- `enum` - Choice from `options` list
**Variable Properties:**
- `sensitive: true` - Masked in prompts/display (e.g., passwords)
- `autogenerated: true` - Auto-generates value if empty (shows `*auto` placeholder)
- `default` - Default value
- `description` - Variable description
- `prompt` - Custom prompt text (overrides description)
- `extra` - Additional help text
- `options` - List of valid values (for enum type)
**Section Features:**
- **Toggle Settings**: Conditional sections via `toggle: "bool_var_name"`. If false, section is skipped.
- **IMPORTANT**: When a section has `toggle: "var_name"`, that boolean variable is AUTO-CREATED by the system
- Toggle variable behavior may vary by schema version - check current schema documentation
- Example: `ports` section with `toggle: "ports_enabled"` automatically provides `ports_enabled` boolean
- **Dependencies**: Use `needs: "section_name"` or `needs: ["sec1", "sec2"]`. Dependent sections only shown when dependencies are enabled.
**Dependency Resolution Architecture:**
Sections and variables support `needs` constraints to control visibility based on other variables.
**Section-Level Dependencies:**
- Format: `needs: "section_name"` or `needs: ["sec1", "sec2"]`
- Section only appears when all required sections are enabled (their toggle variables are true)
- Automatically validated: detects circular, missing, and self-dependencies
- Topologically sorted: ensures dependencies are prompted/processed before dependents
**Variable-Level Dependencies:**
- Format: `needs: "var_name=value"` or `needs: "var1=val1;var2=val2"` (semicolon-separated)
- Variable only visible when constraint is satisfied (e.g., `needs: "network_mode=bridge"`)
- Supports multiple values: `needs: "network_mode=bridge,macvlan"` (comma = OR)
- Evaluated dynamically at prompt and render time
**Validation:**
- Circular dependencies: Raises error if A needs B and B needs A
- Missing dependencies: Raises error if referencing non-existent sections/variables
- Self-dependencies: Raises error if section depends on itself
**Example Section with Dependencies:**
```yaml
spec:
traefik:
title: Traefik
required: false
toggle: traefik_enabled
vars:
traefik_enabled:
type: bool
default: false
traefik_host:
type: hostname
traefik_tls:
title: Traefik TLS/SSL
needs: traefik
toggle: traefik_tls_enabled
vars:
traefik_tls_enabled:
type: bool
default: true
traefik_tls_certresolver:
type: str
sensitive: false
default: myresolver
```
## Validation
**Jinja2 Validation:**
- Templates validated for Jinja2 syntax errors during load
- Checks for undefined variables (variables used but not declared in spec)
- Built into Template class
**Semantic Validation:**
- Validator registry system in `cli/core/validators.py`
- Extensible: `ContentValidator` abstract base class
- Built-in validators: `DockerComposeValidator`, `YAMLValidator`
- Validates rendered output (YAML structure, Docker Compose schema, etc.)
- Triggered via `compose validate` command with `--semantic` flag (enabled by default)
## Prompt
Uses `rich` library for interactive prompts. Supports:
- Text input
- Password input (masked, for `sensitive: true` variables)
- Selection from list (single/multiple)
- Confirmation (yes/no)
- Default values
- Autogenerated variables (show `*auto` placeholder, generate on render)
To skip the prompt use the `--no-interactive` flag, which will use defaults or empty values.
## Commands
**Standard Module Commands** (auto-registered for all modules):
- `list` - List all templates
- `search ` - Search templates by ID
- `show ` - Show template details
- `generate -o ` - Generate from template (supports `--dry-run`, `--var`, `--no-interactive`)
- `validate [template_id]` - Validate template(s) (Jinja2 + semantic). Omit template_id to validate all templates
- `defaults` - Manage config defaults (`get`, `set`, `rm`, `clear`, `list`)
**Core Commands:**
- `repo sync` - Sync git-based libraries
- `repo list` - List configured libraries
## Archetypes
The `archetypes` package provides reusable, standardized template building blocks for creating boilerplates. Archetypes are modular Jinja2 snippets that represent specific configuration sections.
### Purpose
1. **Template Development**: Provide standardized, tested building blocks for creating new templates
2. **Testing & Validation**: Enable testing of specific configuration sections in isolation with different variable combinations
### Usage
```bash
# List available archetypes for a module
python3 -m archetypes compose list
# Preview an archetype component
python3 -m archetypes compose generate
# Test with variable overrides
python3 -m archetypes compose generate \
--var traefik_enabled=true \
--var swarm_enabled=true
# Validate templates against archetypes
python3 -m archetypes compose validate # All templates
python3 -m archetypes compose validate # Single template
```
### Archetype Validation
The `validate` command compares templates against archetypes to measure coverage and identify which archetype patterns are being used.
**What it does:**
- Compares each template file against all available archetypes using **structural pattern matching**
- Abstracts away specific values to focus on:
- **Jinja2 control flow**: `{% if %}`, `{% elif %}`, `{% else %}`, `{% for %}` structures
- **YAML structure**: Key names, indentation, and nesting patterns
- **Variable usage patterns**: Presence of `{{ }}` placeholders (not specific names)
- **Wildcard placeholders**: `__ANY__`, `__ANYSTR__`, `__ANYINT__`, `__ANYBOOL__`
- **Repeat markers**: `{# @repeat-start #}` / `{# @repeat-end #}`
- **Optional markers**: `{# @optional-start #}` / `{# @optional-end #}`
- This allows detection of archetypes even when specific values differ (e.g., `grafana_data` vs `alloy_data`)
- Calculates **containment ratio**: what percentage of each archetype structure is found within the template
- Reports usage status: **exact** (≥95%), **high** (≥70%), **partial** (≥30%), or **none** (<30%)
- Provides coverage metrics: (exact + high matches) / total archetypes
### Advanced Pattern Matching in Archetypes
Archetypes support special annotations for flexible pattern matching:
**Wildcard Placeholders** (match any value):
- `__ANY__` - Matches anything
- `__ANYSTR__` - Matches any string
- `__ANYINT__` - Matches any integer
- `__ANYBOOL__` - Matches any boolean
**Repeat Markers** (pattern can appear 1+ times):
```yaml
{# @repeat-start #}
pattern
{# @repeat-end #}
```
**Optional Markers** (section may or may not exist):
```yaml
{# @optional-start #}
pattern
{# @optional-end #}
```
**Example:**
```yaml
volumes:
{# @repeat-start #}
__ANY__:
driver: local
{# @repeat-end #}
```
Matches any number of volumes with `driver: local`
**Usage:**
```bash
# Validate all templates in library - shows summary table
python3 -m archetypes compose validate
# Validate specific template - shows detailed archetype breakdown
python3 -m archetypes compose validate whoami
# Validate templates in custom location
python3 -m archetypes compose validate --library /path/to/templates
```
**Output:**
- **Summary mode** (all templates): Table showing exact/high/partial/none counts and coverage % per template
- **Detail mode** (single template): Table showing each archetype's status, similarity %, and matching file
**Use cases:**
- **Quality assurance**: Ensure templates follow established patterns
- **Refactoring**: Identify templates that could benefit from archetype alignment
- **Documentation**: Track which archetypes are most/least used across templates
### Template Development Workflow
1. **Discover**: Use `list` command to see available archetype components for your module
2. **Review**: Preview archetypes to understand implementation patterns
3. **Copy**: Copy relevant archetype components to your template directory
4. **Customize**: Modify as needed (hardcode image, add custom labels, etc.)
5. **Validate**: Use `compose validate` to check Jinja2 syntax and semantic correctness
### Architecture
**Key Concepts:**
- Each module can have its own `archetypes//` directory with reusable components
- `archetypes.yaml` configures schema version and variable overrides for testing
- Components are modular Jinja2 files that can be tested in isolation or composition
- **Testing only**: The `generate` command NEVER writes files - always shows preview output
**How it works:**
- Loads module spec based on schema version from `archetypes.yaml`
- Merges variable sources: module spec → archetypes.yaml → CLI --var
- Renders using Jinja2 with support for `{% include %}` directives
================================================
FILE: CHANGELOG.md
================================================
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Changed
- Dependency update: typer to v0.21.1 (#1627)
### Fixed
- Python 3.10+ requirement enforcement - Install script now validates Python version and provides clear upgrade instructions for AlmaLinux/RHEL 9 users
- Python 3.9 compatibility - Added `from __future__ import annotations` to compose module to support union type syntax on Python 3.9+ (requires Python 3.10+ at runtime)
## [0.1.3] - 2026-01-06
### Added
- Draft template visibility in list command (#1610) - Draft templates now shown with dimmed styling and status indicator instead of being hidden
### Changed
- Schema deprecation postponed (#1636) - Template-only variable definitions deferred to maintain backward compatibility and reduce migration complexity
## [0.1.2] - 2025-12-11
### Fixed
- Nix flake missing `email-validator` dependency causing build failures (#1573)
## [0.1.0] - 2025-12-10
### Added
- Variable file support with `--var-file` flag (#1331) - Load variables from YAML file for non-interactive deployments
- Variable override support for `show` command with `--var` and `--var-file` flags (#1421) - Preview variable overrides before generating
- Terraform template support (#1422) - Manage Terraform configurations with schema 1.0
- Kubernetes template support (#1423) - Manage Kubernetes configurations with schema 1.0
- Helm template support (#1424) - Manage Helm charts with schema 1.0
- Ansible template support (#1426) - Manage Ansible playbooks with schema 1.0
- Packer template support (#1427) - Manage Packer templates with schema 1.0
- Alphabetically sorted commands in help output with grouped panels for better organization
- Separate help panels for "Template Commands" and "Configuration Commands"
- Compose Schema 1.2: Port variables (http, https, ssh, dns, dhcp, smtp) - Templates only prompt for ports they use
- Compose Schema 1.2: Dedicated `volume` section for storage configuration (replaces swarm_volume_* variables)
- Compose Schema 1.2: `resources` section for CPU and memory limits
- Compose Schema 1.2: `traefik_domain` variable for base domain configuration (#1362) - Set once, use across all services
- Compose Schema 1.2: `database_host` now requires `database_external=true`
- Compose Schema 1.2: `email_encryption` replaces `email_tls` and `email_ssl` with options: none, ssl, tls
- Markdown formatting support for template descriptions and next steps (#1471)
- Output directory flag `--output`/`-o` for `generate` command (#1534) - Replaces positional directory argument
- Variable property `autogenerated_length` to specify custom length for auto-generated values (default: 32 characters)
- Nerd Font icon support with shortcode replacement in template descriptions - Rich visual feedback using standardized icon system
### Changed
- Schema is now managed in JSON for better standardization and clarity (#1555)
- Compose Schema 1.2: Removed `traefik_entrypoint` and `traefik_tls_entrypoint` variables
- Removed Jinja2 `| default()` filter extraction and merging (#1410) - All defaults must now be defined in template/module specs
- Refactored code quality (#1364) for all core modules from single files to package structure with specific submodules
- Improved debug logging to capture module discovery and registration during initialization
- Enhanced debug logging for better troubleshooting
- Simplified dry-run output to show only essential information (files, sizes, status)
- Traefik template now uses module spec variable `authentik_traefik_middleware` instead of template-specific `traefik_authentik_middleware_name`
- `validate` command now accepts template ID as positional argument (e.g., `compose validate netbox`) - Consistent with archetypes command pattern
- Sections can't be required anymore, only variables can be required - Simplifies logic and improves usability
- Variables are now optional by default - only explicitly marked `required: true` variables are required, display shows `(*)` indicator instead of `(required)`
### Deprecated
- Positional directory argument for `generate` command (#1534) - Use `--output`/`-o` flag instead (will be removed in v0.2.0)
### Fixed
- CLI --var flag now properly converts boolean and numeric strings to appropriate Python types (#1522)
- Empty template files are no longer created during generation (#1518)
- Enhanced user confirmation flow for template generation (#1428)
## [0.0.7] - 2025-10-28
### Added
- Multiple Library Support (#1314) for git and local libraries
- Multi-Schema Module Support and Backward Compatibility (Schema-1.0)
- Schema-1.1 `network_mode` with options: bridge, host, macvlan
- Schema-1.1 `swarm` module support
- Variable-level and Section-level depenendencies `needs` with multiple values support
- Optional Variables `optional: true` to allow empty/None values
- PEP 8 formatting alignment
- CLI variable dependency validation - raises error when CLI-provided variables have unsatisfied dependencies
- Support for required variables independent of section state (#1355)
- Variables can now be marked with `required: true` in template specs
- Required variables are always prompted, validated, and included in rendering
- Display shows yellow `(required)` indicator for required variables
- Required variables from disabled sections are still collected and available
### Changed
- Schema-1.1 Unified Docker Swarm Placement (#1359) - Simplified swarm placement constraints into a single variable
- Refactored compose module from single file to package structure
- Dependency validation moved to `validate_all()` for better error reporting
- Schema-1.1 removed `network_enabled`, `ports_enabled` and `database_enabled` toggles (no longer optional)
- Improved error handling and display output consistency
- Updated dependency PyYAML to v6.0.3 (Python 3.14 compatibility)
- Updated dependency rich to v14.2.0 (Python 3.14 compatibility)
- Pinned all dependencies to specific tested versions for consistent installations
### Fixed
- Required sections now ignore toggle and are always enabled
- Module spec loading based on correct template schema version
- Interactive prompts now skip all variables (including required) when parent section is disabled
- Absolute paths without leading slash treated as relative paths in generate command (#1357)
- Paths like `Users/xcad/Projects/test` are now correctly normalized to `/Users/xcad/Projects/test`
- Supports common Unix/Linux root directories: Users/, home/, usr/, opt/, var/, tmp/
- Repository fetch fails when library directory already exists (#1279)
- **Critical:** Python 3.9 compatibility - removed Context type annotations causing RuntimeError
- Context access now uses click.get_current_context() for better compatibility
## [0.0.6] - 2025-10-14
### Changed
- Pinned all dependencies to specific tested versions for consistent installations
- typer==0.19.2
- rich==14.1.0
- PyYAML==6.0.2
- python-frontmatter==1.1.0
- Jinja2==3.1.6
### Fixed
- **Critical:** Python 3.9 compatibility - removed Context type annotations causing RuntimeError
- Context access now uses click.get_current_context() for better compatibility
- Added tests directory to .gitignore
## [0.0.4] - 2025-01-XX
Initial public release with core CLI functionality.
[unreleased]: https://github.com/christianlempa/boilerplates/compare/v0.1.2...HEAD
[0.1.2]: https://github.com/christianlempa/boilerplates/compare/v0.1.1...v0.1.2
[0.1.1]: https://github.com/christianlempa/boilerplates/compare/v0.1.0...v0.1.1
[0.1.0]: https://github.com/christianlempa/boilerplates/compare/v0.0.7...v0.1.0
[0.0.7]: https://github.com/christianlempa/boilerplates/compare/v0.0.6...v0.0.7
[0.0.6]: https://github.com/christianlempa/boilerplates/releases/tag/v0.0.6
[0.0.4]: https://github.com/christianlempa/boilerplates/releases/tag/v0.0.4
================================================
FILE: CONTRIBUTING.md
================================================
# Contributing to Boilerplates
Thank you for your interest in contributing to the Boilerplates project! This document provides guidelines and instructions for contributing.
## Table of Contents
- [Code of Conduct](#code-of-conduct)
- [How to Contribute](#how-to-contribute)
- [CLI Development](#cli-development)
- [Template Contributions](#template-contributions)
- [Development Setup](#development-setup)
- [Code Standards](#code-standards)
- [Testing Guidelines](#testing-guidelines)
- [Pull Request Process](#pull-request-process)
## Code of Conduct
Be respectful and constructive in all interactions. We're here to build great tools together.
## How to Contribute
### CLI Development
**IMPORTANT:** Any changes to the CLI application (`cli/` directory) require coordination.
**Before making CLI changes:**
1. Join the [Discord server](https://christianlempa.de/discord)
2. Reach out to discuss your proposed changes
3. Wait for approval before opening a PR
**Rationale:** The CLI architecture is complex and tightly integrated. Coordinating changes ensures consistency and prevents conflicts.
### Template Contributions
Template contributions are welcome and encouraged! You can:
- Add new templates to `library/`
- Improve existing templates
- Fix bugs in templates
- Update template documentation
**Process:**
1. Read the [Developer Documentation](../../wiki/Developers) in the Wiki
2. Create a new branch: `feature/###-template-name` or `problem/###-fix-description`
3. Add or modify templates following the structure in `library/`
4. Test your template thoroughly
5. Open a pull request
**No prior approval needed** for template contributions, but feel free to open an issue first to discuss larger changes.
## Development Setup
### Prerequisites
- Python 3.10 or higher
- Git
- pipx (recommended) or pip
### Installation
1. Clone the repository:
```bash
git clone https://github.com/ChristianLempa/boilerplates.git
cd boilerplates
```
2. Create a virtual environment:
```bash
python3 -m venv venv
source venv/bin/activate # On Windows: venv\Scripts\activate
```
3. Install dependencies:
```bash
pip install -e .
```
4. Run the CLI in development mode:
```bash
python3 -m cli --help
```
### Development Commands
```bash
# Run CLI with debug logging
python3 -m cli --log-level DEBUG compose list
# Test template generation
python3 -m cli compose generate template-name --dry-run
# Validate templates
python3 -m cli compose validate
```
## Code Standards
### Python Style Guide
- Follow PEP 8 conventions
- Use **2-space indentation** (project standard)
- Maximum line length: 100 characters
- Use type hints where appropriate
### Naming Conventions
- **Files:** lowercase with underscores (`variable_display.py`)
- **Classes:** PascalCase (`VariableCollection`, `DisplayManager`)
- **Functions/Methods:** snake_case (`render_template`, `get_spec`)
- **Constants:** UPPER_SNAKE_CASE (`DEFAULT_TIMEOUT`, `MAX_RETRIES`)
- **Private methods:** prefix with underscore (`_parse_section`)
### Comment Anchors
Use standardized comment anchors for important notes:
```python
# TODO: Implement feature X
# FIXME: Bug in validation logic
# NOTE: This is a workaround for issue #123
# LINK: https://docs.python.org/3/library/typing.html
```
### DisplayManager Usage
**CRITICAL RULE:**
- NEVER use `console.print()` outside of display manager classes
- NEVER import `Console` from `rich.console` except in display manager classes
- ALWAYS use `display.display_*()` methods for ALL output
```python
# GOOD
display = DisplayManager()
display.display_success("Template generated successfully")
# BAD
from rich.console import Console
console = Console()
console.print("Template generated") # Don't do this!
```
### Docstrings
Use docstrings for all public classes and methods:
```python
def render_template(self, template: Template, template_id: str) -> None:
"""Render a complete template display.
Args:
template: The Template object to render
template_id: The template identifier
"""
pass
```
## Testing Guidelines
### Linting and Formatting
**REQUIRED before committing:**
```bash
# YAML files
yamllint library/
# Python code - check and auto-fix
ruff check --fix .
# Python code - format
ruff format .
```
### Validation Commands
```bash
# Validate all templates
python3 -m cli compose validate
# Validate specific template
python3 -m cli compose validate template-name
# Validate with semantic checks
python3 -m cli compose validate --semantic
```
### Manual Testing
Before submitting a PR, test your changes:
```bash
# Test template generation
python3 -m cli compose generate your-template --dry-run
# Test interactive mode
python3 -m cli compose generate your-template
# Test non-interactive mode
python3 -m cli compose generate your-template output-dir \
--var service_name=test \
--no-interactive
```
## Pull Request Process
### Branch Naming
- **Features:** `feature/###-description` (e.g., `feature/1234-add-nginx-template`)
- **Bug fixes:** `problem/###-description` (e.g., `problem/1235-fix-validation`)
### Commit Messages
Follow the format: `type(scope): subject`
**Types:**
- `feat`: New feature
- `fix`: Bug fix
- `docs`: Documentation changes
- `refactor`: Code refactoring
- `test`: Adding tests
- `chore`: Maintenance tasks
**Examples:**
```
feat(compose): add nginx template
fix(display): correct variable rendering for enum types
docs(wiki): update installation instructions
refactor(template): simplify Jinja2 rendering logic
```
### PR Checklist
Before submitting a pull request:
- [ ] Code follows style guidelines (run `ruff check` and `ruff format`)
- [ ] YAML files pass `yamllint`
- [ ] All templates validate successfully
- [ ] Changes are tested manually
- [ ] Commit messages follow conventions
- [ ] PR description explains the changes
- [ ] Related issues are referenced (e.g., "Closes #1234")
### PR Review
- PRs require approval before merging
- Address review comments promptly
- Keep PRs focused and reasonably sized
- Squash commits if requested
## Issue Labels
When creating issues, use appropriate labels:
- `feature` - New feature requests
- `problem` - Bug reports
- `discussion` - General discussions
- `question` - Questions about usage
- `documentation` - Documentation improvements
## Getting Help
- Check the [Wiki](../../wiki) for documentation
- Join [Discord](https://christianlempa.de/discord) for discussions
- Open an issue for bugs or feature requests
- Watch [YouTube tutorials](https://www.youtube.com/@christianlempa)
## License
By contributing, you agree that your contributions will be licensed under the same license as the project.
Thank you for contributing to Boilerplates!
================================================
FILE: LICENSE
================================================
MIT License
Copyright (c) 2021 Christian
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================
FILE: MANIFEST.in
================================================
# Include library directory with all templates
recursive-include library *
# Include JSON schema files
recursive-include cli/core/schema *.json
# Include documentation
include README.md
include LICENSE
# Exclude unnecessary files
global-exclude *.pyc
global-exclude __pycache__
global-exclude .DS_Store
================================================
FILE: README.md
================================================
# Christian's `Boilerplates`
[](https://youtu.be/apgp9egIKK8)
**Hey, there!**
**I'm Christian, and I'm passionate about creating educational tech content for IT Pros and Homelab nerds.**
## What are Boilerplates?
**Boilerplates** is a curated collection of production-ready templates for your homelab and infrastructure projects. Stop copying configurations from random GitHub repos or starting from scratch every time you spin up a new service!
## Boilerplates CLI
The Boilerplates CLI tool gives you instant access to battle-tested templates for Docker, Terraform, Ansible, Kubernetes, and more.
Each template includes sensible defaults, best practices, and common configuration patterns—so you can focus on customizing for your environment.
**Key Features:**
- 🚀 **Quick Setup** - Generate complete project structures in seconds
- 🔧 **Fully Customizable** - Interactive prompts or non-interactive mode with variable overrides
- 💾 **Smart Defaults** - Save your preferred values and reuse across projects
> **Note:** Technologies evolve rapidly. While I actively maintain these templates, always review generated configurations before deploying to production.
### Requirements
- **Python 3.10 or higher** is required
- Git
- pipx (automatically installed by the installer script)
> **Note for RHEL/AlmaLinux/Rocky Linux 9 users:** These distributions ship with Python 3.9 by default. You need to install Python 3.11 or later:
> ```bash
> sudo dnf install python3.11
> pipx reinstall --python python3.11 boilerplates
> ```
### Installation
#### Automated installer script
Install the Boilerplates CLI using the automated installer:
```bash
# Install latest version
curl -fsSL https://raw.githubusercontent.com/christianlempa/boilerplates/main/scripts/install.sh | bash
# Install specific version
curl -fsSL https://raw.githubusercontent.com/christianlempa/boilerplates/main/scripts/install.sh | bash -s -- --version v1.2.3
```
The installer uses `pipx` to create an isolated environment for the CLI tool. Once installed, the `boilerplates` command will be available in your terminal.
#### Nixos
If you are using nix flakes
```bash
# Run without installing
nix run github:christianlempa/boilerplates -- --help
# Install to your profile
nix profile install github:christianlempa/boilerplates
# Or directly in your flake
{
inputs.boilerplates.url = "github:christianlempa/boilerplates";
outputs = { self, nixpkgs, boilerplates }: {
# Use boilerplates.packages.${system}.default
};
}
# Use in a temporary shell
nix shell github:christianlempa/boilerplates
```
### Quick Start
```bash
# Explore
boilerplates --help
# Update Repository Library
boilerplates repo update
# List all available templates for a docker compose
boilerplates compose list
# Show details about a specific template
boilerplates compose show nginx
# Generate a template (interactive mode)
boilerplates compose generate authentik
# Generate with custom output directory
boilerplates compose generate nginx my-nginx-server
# Non-interactive mode with variable overrides
boilerplates compose generate traefik my-proxy \
--var service_name=traefik \
--var traefik_enabled=true \
--var traefik_host=proxy.example.com \
--no-interactive
```
### Managing Defaults
Save time by setting default values for variables you use frequently:
```bash
# Set a default value
boilerplates compose defaults set container_timezone="America/New_York"
boilerplates compose defaults set restart_policy="unless-stopped"
```
### Template Libraries
Boilerplates uses git-based libraries to manage templates. You can add custom repositories:
```bash
# List configured libraries
boilerplates repo list
# Update all libraries
boilerplates repo update
# Add a custom library
boilerplates repo add my-templates https://github.com/user/templates \
--directory library \
--branch main
# Remove a library
boilerplates repo remove my-templates
```
## Documentation
For comprehensive documentation, advanced usage, and template development guides, check out the **[Wiki](../../wiki)** _(coming soon)_.
If you're looking for detailed tutorials on specific tools and technologies, visit my [YouTube Channel](https://www.youtube.com/@christianlempa).
## Contribution
If you’d like to contribute to this project, reach out to me on social media or [Discord](https://christianlempa.de/discord), or create a pull request for the necessary changes.
## Other Resources
- [Dotfiles](https://github.com/christianlempa/dotfiles) - My personal configuration files on macOS
- [Cheat-Sheets](https://github.com/christianlempa/cheat-sheets) - Command Reference for various tools and technologies
## Support me
Creating high-quality videos and valuable resources that are accessible to everyone, free of charge, is a huge challenge. With your contribution, I can dedicate more time and effort into the creation process, which ultimately enhances the quality of the content. So, all your support, by becoming a member, truly makes a significant impact on what I do. And you’ll also get some cool benefits and perks in return, as a recognition of your support.
Remember, ***supporting me is entirely optional.*** Your choice to become a member or not won't change your access to my videos and resources. You are also welcome to reach out to me on Discord, if you have any questions or feedback.
[https://www.patreon.com/christianlempa](https://www.patreon.com/christianlempa)
---
## Legacy Templates (v1)
Looking for templates from my older videos? The original boilerplates collection is still available in the [`backup/boilerplates-v1`](https://github.com/ChristianLempa/boilerplates/tree/backup/boilerplates-v1) branch. These templates haven't been migrated to the new CLI tool yet, but you can still access and use them directly from that branch.
================================================
FILE: SECURITY.md
================================================
# Security Policy
I take the security of my projects seriously. If you discover any security vulnerabilities or have concerns regarding the security of this repository, please reach out to me immediately. I appreciate your efforts in responsibly disclosing the issue and will make every effort to address it promptly.
## Reporting a Vulnerability
To report a security vulnerability, please follow these steps:
1. Go to the **Security** tab of this repository on GitHub.
2. Click on **"Report a vulnerability"**.
3. Provide a clear description of the vulnerability and its potential impact. Be as detailed as possible.
4. If applicable, include steps or a PoC (Proof of Concept) to reproduce the vulnerability.
5. Submit the report.
Once I receive the private report notification, I will promptly investigate and assess the reported vulnerability.
Please do not disclose any potential vulnerabilities in public repositories, issue trackers, or forums until we have had a chance to review and address the issue.
## Scope
This security policy applies to all the code and files within this repository and its dependencies actively maintained by me. If you encounter a security issue in a dependency that is not directly maintained by me, please follow responsible disclosure practices and report it to the respective project.
While I strive to ensure the security of this project, please note that as an individual developer, there may be limitations on resources, response times, and mitigations.
Thank you for your help in making this project more secure.
================================================
FILE: cli/__init__.py
================================================
"""
Boilerplates CLI - A sophisticated command-line tool for managing infrastructure boilerplates.
"""
__version__ = "0.1.3"
__author__ = "Christian Lempa"
__description__ = "CLI tool for managing infrastructure boilerplates"
================================================
FILE: cli/__main__.py
================================================
#!/usr/bin/env python3
"""
Main entry point for the Boilerplates CLI application.
This file serves as the primary executable when running the CLI.
"""
from __future__ import annotations
import importlib
import logging
import pkgutil
import sys
from pathlib import Path
import click
from rich.console import Console
from typer import Option, Typer
from typer.core import TyperGroup
import cli.modules
from cli import __version__
from cli.core import repo
from cli.core.display import DisplayManager
from cli.core.registry import registry
class OrderedGroup(TyperGroup):
"""Typer Group that lists commands in alphabetical order."""
def list_commands(self, ctx: click.Context) -> list[str]:
return sorted(super().list_commands(ctx))
app = Typer(
help=(
"CLI tool for managing infrastructure boilerplates.\n\n"
"[dim]Easily generate, customize, and deploy templates for Docker Compose, "
"Terraform, Kubernetes, and more.\n\n "
"[white]Made with 💜 by [bold]Christian Lempa[/bold]"
),
add_completion=True,
rich_markup_mode="rich",
pretty_exceptions_enable=False,
no_args_is_help=True,
cls=OrderedGroup,
)
console = Console()
display = DisplayManager()
def setup_logging(log_level: str = "WARNING") -> None:
"""Configure the logging system with the specified log level.
Args:
log_level: The logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
Raises:
ValueError: If the log level is invalid
RuntimeError: If logging configuration fails
"""
numeric_level = getattr(logging, log_level.upper(), None)
if not isinstance(numeric_level, int):
raise ValueError(f"Invalid log level '{log_level}'. Valid levels: DEBUG, INFO, WARNING, ERROR, CRITICAL")
try:
logging.basicConfig(
level=numeric_level,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger(__name__)
logger.setLevel(numeric_level)
except Exception as e:
raise RuntimeError(f"Failed to configure logging: {e}") from e
@app.callback(invoke_without_command=True)
def main(
_version: bool | None = Option(
None,
"--version",
"-v",
help="Show the application version and exit.",
is_flag=True,
callback=lambda v: console.print(f"boilerplates version {__version__}") or sys.exit(0) if v else None,
is_eager=True,
),
log_level: str | None = Option(
None,
"--log-level",
help=("Set the logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL). If omitted, logging is disabled."),
),
) -> None:
"""CLI tool for managing infrastructure boilerplates."""
# Disable logging by default; only enable when user provides --log-level
if log_level:
# Re-enable logging and configure
logging.disable(logging.NOTSET)
setup_logging(log_level)
else:
# Silence all logging (including third-party) unless user explicitly requests it
logging.disable(logging.CRITICAL)
# Get context without type annotation (compatible with all Typer versions)
ctx = click.get_current_context()
# Store log level in context for potential use by other commands
ctx.ensure_object(dict)
ctx.obj["log_level"] = log_level
# If no subcommand is provided, show help and friendly intro
if ctx.invoked_subcommand is None:
console.print(ctx.get_help())
sys.exit(0)
def _import_modules(modules_path: Path, logger: logging.Logger) -> list[str]:
"""Import all modules and return list of failures."""
failed_imports = []
for _finder, name, ispkg in pkgutil.iter_modules([str(modules_path)]):
if not name.startswith("_") and name != "base":
try:
logger.debug(f"Importing module: {name} ({'package' if ispkg else 'file'})")
importlib.import_module(f"cli.modules.{name}")
except ImportError as e:
error_info = f"Import failed for '{name}': {e!s}"
failed_imports.append(error_info)
logger.warning(error_info)
except Exception as e:
error_info = f"Unexpected error importing '{name}': {e!s}"
failed_imports.append(error_info)
logger.error(error_info)
return failed_imports
def _register_repo_command(logger: logging.Logger) -> list[str]:
"""Register repo command and return list of failures."""
failed = []
try:
logger.debug("Registering repo command")
repo.register_cli(app)
except Exception as e:
error_info = f"Repo command registration failed: {e!s}"
failed.append(error_info)
logger.warning(error_info)
return failed
def _register_module_classes(logger: logging.Logger) -> tuple[list, list[str]]:
"""Register template-based modules and return (module_classes, failures)."""
failed_registrations = []
module_classes = list(registry.iter_module_classes())
logger.debug(f"Registering {len(module_classes)} template-based modules")
for _name, module_cls in module_classes:
try:
logger.debug(f"Registering module class: {module_cls.__name__}")
module_cls.register_cli(app)
except Exception as e:
error_info = f"Registration failed for '{module_cls.__name__}': {e!s}"
failed_registrations.append(error_info)
logger.warning(error_info)
display.warning(error_info)
return module_classes, failed_registrations
def _build_error_details(failed_imports: list[str], failed_registrations: list[str]) -> str:
"""Build detailed error message from failures."""
error_details = []
if failed_imports:
error_details.extend(["Import failures:"] + [f" - {err}" for err in failed_imports])
if failed_registrations:
error_details.extend(["Registration failures:"] + [f" - {err}" for err in failed_registrations])
return "\n".join(error_details) if error_details else ""
def init_app() -> None:
"""Initialize the application by discovering and registering modules.
Raises:
ImportError: If critical module import operations fail
RuntimeError: If application initialization fails
"""
logger = logging.getLogger(__name__)
failed_imports = []
failed_registrations = []
try:
# Auto-discover and import all modules
modules_path = Path(cli.modules.__file__).parent
logger.debug(f"Discovering modules in {modules_path}")
failed_imports = _import_modules(modules_path, logger)
# Register core repo command
repo_failures = _register_repo_command(logger)
# Register template-based modules
module_classes, failed_registrations = _register_module_classes(logger)
failed_registrations.extend(repo_failures)
# Validate we have modules
if not module_classes and not failed_imports:
raise RuntimeError("No modules found to register")
# Log summary
successful_modules = len(module_classes) - len(failed_registrations)
logger.info(f"Application initialized: {successful_modules} modules registered successfully")
if failed_imports:
logger.info(f"Module import failures: {len(failed_imports)}")
if failed_registrations:
logger.info(f"Module registration failures: {len(failed_registrations)}")
except Exception as e:
details = _build_error_details(failed_imports, failed_registrations) or str(e)
raise RuntimeError(f"Application initialization failed: {details}") from e
def run() -> None:
"""Run the CLI application."""
# Configure logging early if --log-level is provided
if "--log-level" in sys.argv:
try:
log_level_index = sys.argv.index("--log-level") + 1
if log_level_index < len(sys.argv):
log_level = sys.argv[log_level_index]
logging.disable(logging.NOTSET)
setup_logging(log_level)
except (ValueError, IndexError):
pass # Let Typer handle argument parsing errors
try:
init_app()
app()
except (ValueError, RuntimeError) as e:
# Handle configuration and initialization errors cleanly
display.error(str(e))
sys.exit(1)
except ImportError as e:
# Handle module import errors with detailed info
display.error(f"Module Import Error: {e}")
sys.exit(1)
except KeyboardInterrupt:
# Handle Ctrl+C gracefully
display.warning("Operation cancelled by user")
sys.exit(130)
except Exception as e:
# Handle unexpected errors - show simplified message
display.error(str(e))
display.info("Use --log-level DEBUG for more details")
sys.exit(1)
if __name__ == "__main__":
run()
================================================
FILE: cli/core/config/__init__.py
================================================
"""Config package for configuration management.
This package provides the ConfigManager class for managing application configuration,
including defaults, preferences, and library configurations.
"""
from .config_manager import ConfigManager, LibraryConfig
__all__ = ["ConfigManager", "LibraryConfig"]
================================================
FILE: cli/core/config/config_manager.py
================================================
from __future__ import annotations
import logging
import shutil
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Any
import yaml
from ..exceptions import ConfigError, ConfigValidationError, YAMLParseError
logger = logging.getLogger(__name__)
@dataclass
class LibraryConfig:
"""Configuration for a template library."""
name: str
library_type: str = "git"
url: str | None = None
directory: str | None = None
branch: str = "main"
path: str | None = None
enabled: bool = True
class ConfigManager:
"""Manages configuration for the CLI application."""
def __init__(self, config_path: str | Path | None = None) -> None:
"""Initialize the configuration manager.
Args:
config_path: Path to the configuration file. If None, auto-detects:
1. Checks for ./config.yaml (local project config)
2. Falls back to ~/.config/boilerplates/config.yaml (global config)
"""
if config_path is None:
# Check for local config.yaml in current directory first
local_config = Path.cwd() / "config.yaml"
if local_config.exists() and local_config.is_file():
self.config_path = local_config
self.is_local = True
logger.debug(f"Using local config: {local_config}")
else:
# Fall back to global config
config_dir = Path.home() / ".config" / "boilerplates"
config_dir.mkdir(parents=True, exist_ok=True)
self.config_path = config_dir / "config.yaml"
self.is_local = False
else:
self.config_path = Path(config_path)
self.is_local = False
# Create default config if it doesn't exist (only for global config)
if not self.config_path.exists():
if not self.is_local:
self._create_default_config()
else:
raise ConfigError(f"Local config file not found: {self.config_path}")
else:
# Migrate existing config if needed
self._migrate_config_if_needed()
def _create_default_config(self) -> None:
"""Create a default configuration file."""
default_config = {
"defaults": {},
"preferences": {"editor": "vim", "output_dir": None, "library_paths": []},
"libraries": [
{
"name": "default",
"type": "git",
"url": "https://github.com/christianlempa/boilerplates.git",
"branch": "main",
"directory": "library",
"enabled": True,
}
],
}
self._write_config(default_config)
logger.info(f"Created default configuration at {self.config_path}")
def _migrate_config_if_needed(self) -> None:
"""Migrate existing config to add missing sections and library types."""
try:
config = self._read_config()
needs_migration = False
# Add libraries section if missing
if "libraries" not in config:
logger.info("Migrating config: adding libraries section")
config["libraries"] = [
{
"name": "default",
"type": "git",
"url": "https://github.com/christianlempa/boilerplates.git",
"branch": "refactor/boilerplates-v2",
"directory": "library",
"enabled": True,
}
]
needs_migration = True
else:
# Migrate existing libraries to add 'type' field if missing
# For backward compatibility, assume all old libraries without
# 'type' are git libraries
libraries = config.get("libraries", [])
for library in libraries:
if "type" not in library:
lib_name = library.get("name", "unknown")
logger.info(f"Migrating library '{lib_name}': adding type: git")
library["type"] = "git"
needs_migration = True
# Write back if migration was needed
if needs_migration:
self._write_config(config)
logger.info("Config migration completed successfully")
except Exception as e:
logger.warning(f"Config migration failed: {e}")
def _read_config(self) -> dict[str, Any]:
"""Read configuration from file.
Returns:
Dictionary containing the configuration.
Raises:
YAMLParseError: If YAML parsing fails.
ConfigValidationError: If configuration structure is invalid.
ConfigError: If reading fails for other reasons.
"""
try:
with self.config_path.open() as f:
config = yaml.safe_load(f) or {}
# Validate config structure
self._validate_config_structure(config)
return config
except yaml.YAMLError as e:
logger.error(f"Failed to parse YAML configuration: {e}")
raise YAMLParseError(str(self.config_path), e) from e
except ConfigValidationError:
# Re-raise validation errors as-is
raise
except OSError as e:
logger.error(f"Failed to read configuration file: {e}")
raise ConfigError(f"Failed to read configuration file '{self.config_path}': {e}") from e
def _write_config(self, config: dict[str, Any]) -> None:
"""Write configuration to file atomically using temp file + rename pattern.
This prevents config file corruption if write operation fails partway through.
Args:
config: Dictionary containing the configuration to write.
Raises:
ConfigValidationError: If configuration structure is invalid.
ConfigError: If writing fails for any reason.
"""
tmp_path = None
try:
# Validate config structure before writing
self._validate_config_structure(config)
# Ensure parent directory exists
self.config_path.parent.mkdir(parents=True, exist_ok=True)
# Write to temporary file in same directory for atomic rename
with tempfile.NamedTemporaryFile(
mode="w",
delete=False,
dir=self.config_path.parent,
prefix=".config_",
suffix=".tmp",
) as tmp_file:
yaml.dump(config, tmp_file, default_flow_style=False)
tmp_path = tmp_file.name
# Atomic rename (overwrites existing file on POSIX systems)
shutil.move(tmp_path, self.config_path)
logger.debug(f"Configuration written atomically to {self.config_path}")
except ConfigValidationError:
# Re-raise validation errors as-is
if tmp_path:
Path(tmp_path).unlink(missing_ok=True)
raise
except (OSError, yaml.YAMLError) as e:
# Clean up temp file if it exists
if tmp_path:
try:
Path(tmp_path).unlink(missing_ok=True)
except OSError:
logger.warning(f"Failed to clean up temporary file: {tmp_path}")
logger.error(f"Failed to write configuration file: {e}")
raise ConfigError(f"Failed to write configuration to '{self.config_path}': {e}") from e
def _validate_config_structure(self, config: dict[str, Any]) -> None:
"""Validate the configuration structure - basic type checking.
Args:
config: Configuration dictionary to validate.
Raises:
ConfigValidationError: If configuration structure is invalid.
"""
if not isinstance(config, dict):
raise ConfigValidationError("Configuration must be a dictionary")
# Validate top-level types
self._validate_top_level_types(config)
# Validate defaults structure
self._validate_defaults_types(config)
# Validate libraries structure
self._validate_libraries_fields(config)
def _validate_top_level_types(self, config: dict[str, Any]) -> None:
"""Validate top-level config section types."""
if "defaults" in config and not isinstance(config["defaults"], dict):
raise ConfigValidationError("'defaults' must be a dictionary")
if "preferences" in config and not isinstance(config["preferences"], dict):
raise ConfigValidationError("'preferences' must be a dictionary")
if "libraries" in config and not isinstance(config["libraries"], list):
raise ConfigValidationError("'libraries' must be a list")
def _validate_defaults_types(self, config: dict[str, Any]) -> None:
"""Validate defaults section has correct types."""
if "defaults" not in config:
return
for module_name, module_defaults in config["defaults"].items():
if not isinstance(module_defaults, dict):
raise ConfigValidationError(f"Defaults for module '{module_name}' must be a dictionary")
def _validate_libraries_fields(self, config: dict[str, Any]) -> None:
"""Validate libraries have required fields."""
if "libraries" not in config:
return
for i, library in enumerate(config["libraries"]):
if not isinstance(library, dict):
raise ConfigValidationError(f"Library at index {i} must be a dictionary")
if "name" not in library:
raise ConfigValidationError(f"Library at index {i} missing required field 'name'")
lib_type = library.get("type", "git")
if lib_type == "git" and ("url" not in library or "directory" not in library):
raise ConfigValidationError(
f"Git library at index {i} missing required fields 'url' and/or 'directory'"
)
if lib_type == "static" and "path" not in library:
raise ConfigValidationError(f"Static library at index {i} missing required field 'path'")
def get_config_path(self) -> Path:
"""Get the path to the configuration file being used.
Returns:
Path to the configuration file (global or local).
"""
return self.config_path
def is_using_local_config(self) -> bool:
"""Check if a local configuration file is being used.
Returns:
True if using local config, False if using global config.
"""
return self.is_local
def get_defaults(self, module_name: str) -> dict[str, Any]:
"""Get default variable values for a module.
Returns defaults in a flat format:
{
"var_name": "value",
"var2_name": "value2"
}
Args:
module_name: Name of the module
Returns:
Dictionary of default values (flat key-value pairs)
"""
config = self._read_config()
defaults = config.get("defaults", {})
return defaults.get(module_name, {})
def set_defaults(self, module_name: str, defaults: dict[str, Any]) -> None:
"""Set default variable values for a module with comprehensive validation.
Args:
module_name: Name of the module
defaults: Dictionary of defaults (flat key-value pairs):
{"var_name": "value", "var2_name": "value2"}
Raises:
ConfigValidationError: If module name or variable names are invalid.
"""
# Basic validation
if not isinstance(module_name, str) or not module_name:
raise ConfigValidationError("Module name must be a non-empty string")
if not isinstance(defaults, dict):
raise ConfigValidationError("Defaults must be a dictionary")
config = self._read_config()
if "defaults" not in config:
config["defaults"] = {}
config["defaults"][module_name] = defaults
self._write_config(config)
logger.info(f"Updated defaults for module '{module_name}'")
def set_default_value(self, module_name: str, var_name: str, value: Any) -> None:
"""Set a single default variable value with comprehensive validation.
Args:
module_name: Name of the module
var_name: Name of the variable
value: Default value to set
Raises:
ConfigValidationError: If module name or variable name is invalid.
"""
# Basic validation
if not isinstance(module_name, str) or not module_name:
raise ConfigValidationError("Module name must be a non-empty string")
if not isinstance(var_name, str) or not var_name:
raise ConfigValidationError("Variable name must be a non-empty string")
defaults = self.get_defaults(module_name)
defaults[var_name] = value
self.set_defaults(module_name, defaults)
logger.info(f"Set default for '{module_name}.{var_name}' = '{value}'")
def get_default_value(self, module_name: str, var_name: str) -> Any | None:
"""Get a single default variable value.
Args:
module_name: Name of the module
var_name: Name of the variable
Returns:
Default value or None if not set
"""
defaults = self.get_defaults(module_name)
return defaults.get(var_name)
def clear_defaults(self, module_name: str) -> None:
"""Clear all defaults for a module.
Args:
module_name: Name of the module
"""
config = self._read_config()
if "defaults" in config and module_name in config["defaults"]:
del config["defaults"][module_name]
self._write_config(config)
logger.info(f"Cleared defaults for module '{module_name}'")
def get_preference(self, key: str) -> Any | None:
"""Get a user preference value.
Args:
key: Preference key (e.g., 'editor', 'output_dir', 'library_paths')
Returns:
Preference value or None if not set
"""
config = self._read_config()
preferences = config.get("preferences", {})
return preferences.get(key)
def set_preference(self, key: str, value: Any) -> None:
"""Set a user preference value with comprehensive validation.
Args:
key: Preference key
value: Preference value
Raises:
ConfigValidationError: If key or value is invalid for known preference types.
"""
# Basic validation
if not isinstance(key, str) or not key:
raise ConfigValidationError("Preference key must be a non-empty string")
config = self._read_config()
if "preferences" not in config:
config["preferences"] = {}
config["preferences"][key] = value
self._write_config(config)
logger.info(f"Set preference '{key}' = '{value}'")
def get_all_preferences(self) -> dict[str, Any]:
"""Get all user preferences.
Returns:
Dictionary of all preferences
"""
config = self._read_config()
return config.get("preferences", {})
def get_libraries(self) -> list[dict[str, Any]]:
"""Get all configured libraries.
Returns:
List of library configurations
"""
config = self._read_config()
return config.get("libraries", [])
def get_library_by_name(self, name: str) -> dict[str, Any] | None:
"""Get a specific library by name.
Args:
name: Name of the library
Returns:
Library configuration dictionary or None if not found
"""
libraries = self.get_libraries()
for library in libraries:
if library.get("name") == name:
return library
return None
def add_library(self, lib_config: LibraryConfig) -> None:
"""Add a new library to the configuration.
Args:
lib_config: Library configuration
Raises:
ConfigValidationError: If library with the same name already exists or validation fails
"""
# Basic validation
if not isinstance(lib_config.name, str) or not lib_config.name:
raise ConfigValidationError("Library name must be a non-empty string")
if lib_config.library_type not in ("git", "static"):
raise ConfigValidationError(f"Library type must be 'git' or 'static', got '{lib_config.library_type}'")
if self.get_library_by_name(lib_config.name):
raise ConfigValidationError(f"Library '{lib_config.name}' already exists")
# Type-specific validation
if lib_config.library_type == "git":
if not lib_config.url or not lib_config.directory:
raise ConfigValidationError("Git libraries require 'url' and 'directory' parameters")
library_dict = {
"name": lib_config.name,
"type": "git",
"url": lib_config.url,
"branch": lib_config.branch,
"directory": lib_config.directory,
"enabled": lib_config.enabled,
}
else: # static
if not lib_config.path:
raise ConfigValidationError("Static libraries require 'path' parameter")
# For backward compatibility with older CLI versions,
# add dummy values for git-specific fields
library_dict = {
"name": lib_config.name,
"type": "static",
"url": "", # Empty string for backward compatibility
"branch": "main", # Default value for backward compatibility
"directory": ".", # Default value for backward compatibility
"path": lib_config.path,
"enabled": lib_config.enabled,
}
config = self._read_config()
if "libraries" not in config:
config["libraries"] = []
config["libraries"].append(library_dict)
self._write_config(config)
logger.info(f"Added {lib_config.library_type} library '{lib_config.name}'")
def remove_library(self, name: str) -> None:
"""Remove a library from the configuration.
Args:
name: Name of the library to remove
Raises:
ConfigError: If library is not found
"""
config = self._read_config()
libraries = config.get("libraries", [])
# Find and remove the library
new_libraries = [lib for lib in libraries if lib.get("name") != name]
if len(new_libraries) == len(libraries):
raise ConfigError(f"Library '{name}' not found")
config["libraries"] = new_libraries
self._write_config(config)
logger.info(f"Removed library '{name}'")
def update_library(self, name: str, **kwargs: Any) -> None:
"""Update a library's configuration.
Args:
name: Name of the library to update
**kwargs: Fields to update (url, branch, directory, enabled)
Raises:
ConfigError: If library is not found
ConfigValidationError: If validation fails
"""
config = self._read_config()
libraries = config.get("libraries", [])
# Find the library
library_found = False
for library in libraries:
if library.get("name") == name:
library_found = True
# Update allowed fields
if "url" in kwargs:
library["url"] = kwargs["url"]
if "branch" in kwargs:
library["branch"] = kwargs["branch"]
if "directory" in kwargs:
library["directory"] = kwargs["directory"]
if "enabled" in kwargs:
library["enabled"] = kwargs["enabled"]
break
if not library_found:
raise ConfigError(f"Library '{name}' not found")
config["libraries"] = libraries
self._write_config(config)
logger.info(f"Updated library '{name}'")
def get_libraries_path(self) -> Path:
"""Get the path to the libraries directory.
Returns:
Path to the libraries directory (same directory as config file)
"""
return self.config_path.parent / "libraries"
================================================
FILE: cli/core/display/__init__.py
================================================
"""Display module for CLI output rendering.
This package provides centralized display management with mixin-based architecture.
DisplayManager inherits from multiple mixins to provide a flat, cohesive API.
"""
from __future__ import annotations
from rich.console import Console
from .display_base import BaseDisplay
from .display_icons import IconManager
from .display_settings import DisplaySettings
from .display_status import StatusDisplay
from .display_table import TableDisplay
from .display_template import TemplateDisplay
from .display_variable import VariableDisplay
# Console instances for stdout and stderr
console = Console()
console_err = Console(stderr=True)
class DisplayManager:
"""Main display coordinator using composition.
This class composes specialized display components to provide a unified API.
Each component handles a specific concern (status, tables, templates, variables).
Design Principles:
- Composition over inheritance
- Explicit dependencies
- Clear separation of concerns
- Easy to test and extend
"""
def __init__(self, quiet: bool = False, settings: DisplaySettings | None = None):
"""Initialize DisplayManager with composed display components.
Args:
quiet: If True, suppress all non-error output
settings: Optional DisplaySettings instance for customization
"""
self.quiet = quiet
self.settings = settings or DisplaySettings()
# Create base display component (includes utilities)
self.base = BaseDisplay(self.settings, quiet)
# Create specialized display components
self.status = StatusDisplay(self.settings, quiet, self.base)
self.variables = VariableDisplay(self.settings, self.base)
self.templates = TemplateDisplay(self.settings, self.base, self.variables, self.status)
self.tables = TableDisplay(self.settings, self.base)
# ===== Delegate to base display =====
def text(self, text: str, style: str | None = None) -> None:
"""Display plain text."""
return self.base.text(text, style)
def heading(self, text: str, style: str | None = None) -> None:
"""Display a heading."""
return self.base.heading(text, style)
def section(self, title: str, description: str | None = None) -> None:
"""Display a section header with optional description.
Args:
title: Section title
description: Optional section description
"""
self.base.text("")
self.base.text(f"[bold cyan]{title}[/bold cyan]")
if description:
self.base.text(f"[dim]{description}[/dim]")
def table(
self,
headers: list[str] | None = None,
rows: list[tuple] | None = None,
title: str | None = None,
show_header: bool = True,
borderless: bool = False,
) -> None:
"""Display a table."""
return self.base.table(headers, rows, title, show_header, borderless)
def tree(self, root_label: str, nodes: dict | list) -> None:
"""Display a tree."""
return self.base.tree(root_label, nodes)
def code(self, code_text: str, language: str | None = None) -> None:
"""Display code."""
return self.base.code(code_text, language)
def progress(self, *columns):
"""Create a progress bar."""
return self.base.progress(*columns)
def file_tree(
self,
root_label: str,
files: list,
file_info_fn: callable,
title: str | None = None,
) -> None:
"""Display a file tree structure."""
return self.base.file_tree(root_label, files, file_info_fn, title)
# ===== Formatting utilities =====
def truncate(self, value: str, max_length: int | None = None) -> str:
"""Truncate string value."""
return self.base.truncate(value, max_length)
def format_file_size(self, size_bytes: int) -> str:
"""Format file size in human-readable format."""
return self.base.format_file_size(size_bytes)
def data_table(
self,
columns: list[dict],
rows: list,
title: str | None = None,
row_formatter: callable | None = None,
) -> None:
"""Display a data table with configurable columns."""
return self.tables.data_table(columns, rows, title, row_formatter)
def display_status_table(
self,
title: str,
rows: list[tuple[str, str, bool]],
columns: tuple[str, str] = ("Item", "Status"),
) -> None:
"""Display a status table with success/error indicators."""
return self.tables.render_status_table(title, rows, columns)
# ===== Delegate to status display =====
def error(self, message: str, context: str | None = None, details: str | None = None) -> None:
"""Display an error message."""
return self.status.error(message, context, details)
def warning(self, message: str, context: str | None = None, details: str | None = None) -> None:
"""Display a warning message."""
return self.status.warning(message, context, details)
def success(self, message: str, context: str | None = None) -> None:
"""Display a success message."""
return self.status.success(message, context)
def info(self, message: str, context: str | None = None) -> None:
"""Display an info message."""
return self.status.info(message, context)
def skipped(self, message: str, reason: str | None = None) -> None:
"""Display skipped message."""
return self.status.skipped(message, reason)
# ===== Helper methods =====
def get_lock_icon(self) -> str:
"""Get lock icon."""
return self.base.get_lock_icon()
def print_table(self, table) -> None:
"""Print a pre-built Rich Table object.
Args:
table: Rich Table object to print
"""
return self.base._print_table(table)
# Export public API
__all__ = [
"DisplayManager",
"DisplaySettings",
"IconManager",
"console",
"console_err",
]
================================================
FILE: cli/core/display/display_base.py
================================================
"""Base display methods for DisplayManager."""
from __future__ import annotations
import logging
from pathlib import Path
from rich.console import Console
from rich.progress import Progress
from rich.syntax import Syntax
from rich.table import Table
from rich.tree import Tree
from .display_icons import IconManager
from .display_settings import DisplaySettings
logger = logging.getLogger(__name__)
console = Console()
class BaseDisplay:
"""Base display methods and utilities.
Provides fundamental display methods (text, heading, table, tree, code, progress)
and utility/helper methods for formatting.
"""
def __init__(self, settings: DisplaySettings, quiet: bool = False):
"""Initialize BaseDisplay.
Args:
settings: Display settings for formatting
quiet: If True, suppress non-error output
"""
self.settings = settings
self.quiet = quiet
def heading(self, text: str, style: str | None = None) -> None:
"""Display a standardized heading.
Args:
text: Heading text
style: Optional style override (defaults to STYLE_HEADER from settings)
"""
if style is None:
style = self.settings.STYLE_HEADER
console.print(f"[{style}]{text}[/{style}]")
console.print("") # Add newline after heading
def text(self, text: str, style: str | None = None) -> None:
"""Display plain text with optional styling.
Args:
text: Text to display
style: Optional Rich style markup
"""
if style:
console.print(f"[{style}]{text}[/{style}]")
else:
console.print(text)
def table(
self,
headers: list[str] | None = None,
rows: list[tuple] | None = None,
title: str | None = None,
show_header: bool = True,
borderless: bool = False,
) -> None:
"""Display a standardized table.
Args:
headers: Column headers (if None, no headers)
rows: List of tuples, one per row
title: Optional table title
show_header: Whether to show header row
borderless: If True, use borderless style (box=None)
"""
table = Table(
title=title,
show_header=show_header and headers is not None,
header_style=self.settings.STYLE_TABLE_HEADER,
box=None,
padding=self.settings.PADDING_TABLE_NORMAL if borderless else (0, 1),
)
# Add columns
if headers:
for header in headers:
table.add_column(header)
elif rows and len(rows) > 0:
# No headers, but need columns for data
for _ in range(len(rows[0])):
table.add_column()
# Add rows
if rows:
for row in rows:
table.add_row(*[str(cell) for cell in row])
console.print(table)
def tree(self, root_label: str, nodes: dict | list | Tree) -> None:
"""Display a tree structure.
Args:
root_label: Label for the root node
nodes: Hierarchical structure (dict, list, or pre-built Tree)
"""
if isinstance(nodes, Tree):
console.print(nodes)
else:
tree = Tree(root_label)
self._build_tree_nodes(tree, nodes)
console.print(tree)
def _build_tree_nodes(self, parent, nodes):
"""Recursively build tree nodes.
Args:
parent: Parent tree node
nodes: Dict or list of child nodes
"""
if isinstance(nodes, dict):
for key, value in nodes.items():
if isinstance(value, (dict, list)):
branch = parent.add(str(key))
self._build_tree_nodes(branch, value)
else:
parent.add(f"{key}: {value}")
elif isinstance(nodes, list):
for item in nodes:
if isinstance(item, (dict, list)):
self._build_tree_nodes(parent, item)
else:
parent.add(str(item))
def _print_tree(self, tree) -> None:
"""Print a pre-built Rich Tree object.
Args:
tree: Rich Tree object to print
"""
console.print(tree)
def _print_table(self, table) -> None:
"""Print a pre-built Rich Table object.
Enforces consistent header styling for all tables.
Args:
table: Rich Table object to print
"""
# Enforce consistent header style for all tables
table.header_style = self.settings.STYLE_TABLE_HEADER
console.print(table)
def _print_markdown(self, markdown) -> None:
"""Print a pre-built Rich Markdown object.
Args:
markdown: Rich Markdown object to print
"""
console.print(markdown)
def code(self, code_text: str, language: str | None = None) -> None:
"""Display code with optional syntax highlighting.
Args:
code_text: Code to display
language: Programming language for syntax highlighting
"""
if language:
syntax = Syntax(code_text, language, theme="monokai", line_numbers=False)
console.print(syntax)
else:
# Plain code block without highlighting
console.print(f"[dim]{code_text}[/dim]")
def progress(self, *columns):
"""Create a Rich Progress context manager with standardized console.
Args:
*columns: Progress columns (e.g., SpinnerColumn(), TextColumn())
Returns:
Progress context manager
Example:
with display.progress(
SpinnerColumn(), TextColumn("[progress.description]{task.description}")
) as progress:
task = progress.add_task("Processing...", total=None)
# do work
progress.remove_task(task)
"""
return Progress(*columns, console=console)
# ===== Formatting Utilities =====
def truncate(self, value: str, max_length: int | None = None) -> str:
"""Truncate a string value if it exceeds maximum length.
Args:
value: String value to truncate
max_length: Maximum length (uses default if None)
Returns:
Truncated string with suffix if needed
"""
if max_length is None:
max_length = self.settings.VALUE_MAX_LENGTH_DEFAULT
if max_length > 0 and len(value) > max_length:
return value[: max_length - len(self.settings.TRUNCATION_SUFFIX)] + self.settings.TRUNCATION_SUFFIX
return value
def format_file_size(self, size_bytes: int) -> str:
"""Format file size in human-readable format (B, KB, MB).
Args:
size_bytes: Size in bytes
Returns:
Formatted size string (e.g., "1.5KB", "2.3MB")
"""
if size_bytes < self.settings.SIZE_KB_THRESHOLD:
return f"{size_bytes}B"
if size_bytes < self.settings.SIZE_MB_THRESHOLD:
kb = size_bytes / self.settings.SIZE_KB_THRESHOLD
return f"{kb:.{self.settings.SIZE_DECIMAL_PLACES}f}KB"
mb = size_bytes / self.settings.SIZE_MB_THRESHOLD
return f"{mb:.{self.settings.SIZE_DECIMAL_PLACES}f}MB"
def file_tree(
self,
root_label: str,
files: list,
file_info_fn: callable,
title: str | None = None,
) -> None:
"""Display a file tree structure.
Args:
root_label: Label for root node (e.g., "📁 my-project")
files: List of file items to display
file_info_fn: Function that takes a file and returns
(path, display_name, color, extra_text) where:
- path: Path object for directory structure
- display_name: Name to show for the file
- color: Rich color for the filename
- extra_text: Optional additional text
title: Optional heading to display before tree
"""
if title:
self.heading(title)
tree = Tree(root_label)
tree_nodes = {Path(): tree}
for file_item in sorted(files, key=lambda f: file_info_fn(f)[0]):
path, display_name, color, extra_text = file_info_fn(file_item)
parts = path.parts
current_path = Path()
current_node = tree
# Build directory structure
for part in parts[:-1]:
current_path = current_path / part
if current_path not in tree_nodes:
new_node = current_node.add(f"{IconManager.folder()} [white]{part}[/white]")
tree_nodes[current_path] = new_node
current_node = tree_nodes[current_path]
# Add file
icon = IconManager.get_file_icon(display_name)
file_label = f"{icon} [{color}]{display_name}[/{color}]"
if extra_text:
file_label += f" {extra_text}"
current_node.add(file_label)
console.print(tree)
def _get_icon_by_type(self, icon_type: str) -> str:
"""Get icon by semantic type name.
Args:
icon_type: Type of icon (e.g., 'folder', 'file', 'config', 'lock')
Returns:
Icon unicode character
"""
icon_map = {
"folder": IconManager.folder(),
"file": IconManager.FILE_DEFAULT,
"config": IconManager.config(),
"lock": IconManager.lock(),
"arrow": IconManager.arrow_right(),
}
return icon_map.get(icon_type, "")
def get_lock_icon(self) -> str:
"""Get the lock icon for sensitive variables.
Returns:
Lock icon unicode character
"""
return IconManager.lock()
================================================
FILE: cli/core/display/display_icons.py
================================================
"""Icon management for consistent CLI display."""
from __future__ import annotations
from pathlib import Path
from typing import ClassVar
class IconManager:
"""Centralized icon management system for consistent CLI display.
This class provides standardized icons for file types, status indicators,
and UI elements. Icons use Nerd Font glyphs for consistent display.
Categories:
- File types: .yaml, .j2, .json, .md, etc.
- Status: success, warning, error, info, skipped
- UI elements: folders, config, locks, etc.
"""
# File Type Icons
FILE_FOLDER = "\uf07b"
FILE_DEFAULT = "\uf15b"
FILE_YAML = "\uf15c"
FILE_JSON = "\ue60b"
FILE_MARKDOWN = "\uf48a"
FILE_JINJA2 = "\ue235"
FILE_DOCKER = "\uf308"
FILE_COMPOSE = "\uf308"
FILE_SHELL = "\uf489"
FILE_PYTHON = "\ue73c"
FILE_TEXT = "\uf15c"
# Status Indicators
STATUS_SUCCESS = "\uf00c" # (check)
STATUS_ERROR = "\uf00d" # (times/x)
STATUS_WARNING = "\uf071" # (exclamation-triangle)
STATUS_INFO = "\uf05a" # (info-circle)
STATUS_SKIPPED = "\uf05e" # (ban/circle-slash)
# UI Elements
UI_CONFIG = "\ue5fc"
UI_LOCK = "\uf084"
UI_SETTINGS = "\uf013"
UI_ARROW_RIGHT = "\uf061" # (arrow-right)
UI_BULLET = "\uf111" # (circle)
UI_LIBRARY_GIT = "\uf418" # (git icon)
UI_LIBRARY_STATIC = "\uf07c" # (folder icon)
# Shortcode Mappings (emoji-style codes to Nerd Font icons)
# Format: ":code:" -> "\uf000"
#
# Usage:
# 1. In regular text: ":mycode: Some text" - icon replaces shortcode inline
# 2. In markdown lists: "- :mycode: List item" - icon replaces bullet with color
#
# To add new shortcodes:
# 1. Add entry to this dict: ":mycode:": "\uf000"
# 2. Use in template descriptions or markdown content
# 3. Shortcodes are automatically replaced when markdown is rendered
# 4. List items starting with shortcodes get colored icons instead of bullets
#
# Find Nerd Font codes at: https://www.nerdfonts.com/cheat-sheet
SHORTCODES: ClassVar[dict[str, str]] = {
":warning:": "\uf071", # (exclamation-triangle)
":info:": "\uf05a", # (info-circle)
":check:": "\uf00c", # (check)
":error:": "\uf00d", # (times/x)
":lock:": "\uf084", # (lock)
":folder:": "\uf07b", # (folder)
":file:": "\uf15b", # (file)
":gear:": "\uf013", # (settings/gear)
":rocket:": "\uf135", # (rocket)
":star:": "\uf005", # (star)
":lightning:": "\uf0e7", # (bolt/lightning)
":cloud:": "\uf0c2", # (cloud)
":database:": "\uf1c0", # (database)
":network:": "\uf6ff", # (network)
":docker:": "\uf308", # (docker)
":kubernetes:": "\ue287", # (kubernetes/helm)
}
@classmethod
def get_file_icon(cls, file_path: str | Path) -> str:
"""Get the appropriate icon for a file based on its extension or name.
Args:
file_path: Path to the file (can be string or Path object)
Returns:
Unicode icon character for the file type
Examples:
>>> IconManager.get_file_icon("config.yaml")
'\uf15c'
>>> IconManager.get_file_icon("template.j2")
'\ue235'
"""
if isinstance(file_path, str):
file_path = Path(file_path)
file_name = file_path.name.lower()
suffix = file_path.suffix.lower()
# Check for Docker Compose files
compose_names = {
"docker-compose.yml",
"docker-compose.yaml",
"compose.yml",
"compose.yaml",
}
if file_name in compose_names or file_name.startswith("docker-compose"):
return cls.FILE_DOCKER
# Check by extension
extension_map = {
".yaml": cls.FILE_YAML,
".yml": cls.FILE_YAML,
".json": cls.FILE_JSON,
".md": cls.FILE_MARKDOWN,
".j2": cls.FILE_JINJA2,
".sh": cls.FILE_SHELL,
".py": cls.FILE_PYTHON,
".txt": cls.FILE_TEXT,
}
return extension_map.get(suffix, cls.FILE_DEFAULT)
@classmethod
def get_status_icon(cls, status: str) -> str:
"""Get the appropriate icon for a status indicator.
Args:
status: Status type (success, error, warning, info, skipped)
Returns:
Unicode icon character for the status
Examples:
>>> IconManager.get_status_icon("success")
'✓'
>>> IconManager.get_status_icon("warning")
'⚠'
"""
status_map = {
"success": cls.STATUS_SUCCESS,
"error": cls.STATUS_ERROR,
"warning": cls.STATUS_WARNING,
"info": cls.STATUS_INFO,
"skipped": cls.STATUS_SKIPPED,
}
return status_map.get(status.lower(), cls.STATUS_INFO)
@classmethod
def folder(cls) -> str:
"""Get the folder icon."""
return cls.FILE_FOLDER
@classmethod
def config(cls) -> str:
"""Get the config icon."""
return cls.UI_CONFIG
@classmethod
def lock(cls) -> str:
"""Get the lock icon (for sensitive variables)."""
return cls.UI_LOCK
@classmethod
def arrow_right(cls) -> str:
"""Get the right arrow icon (for showing transitions/changes)."""
return cls.UI_ARROW_RIGHT
@classmethod
def replace_shortcodes(cls, text: str) -> str:
"""Replace emoji-style shortcodes with Nerd Font icons.
Args:
text: Text containing shortcodes like :warning:, :info:, etc.
Returns:
Text with shortcodes replaced by Nerd Font icons
Examples:
>>> IconManager.replace_shortcodes(":warning: This is a warning")
' This is a warning'
>>> IconManager.replace_shortcodes(":docker: :kubernetes: Stack")
' Stack'
"""
result = text
for shortcode, icon in cls.SHORTCODES.items():
result = result.replace(shortcode, icon)
return result
================================================
FILE: cli/core/display/display_settings.py
================================================
"""Display configuration settings for the CLI."""
class DisplaySettings:
"""Centralized display configuration settings.
This class holds all configurable display parameters including colors,
styles, layouts, and formatting options. Modify these values to customize
the CLI appearance.
"""
# === Color Scheme ===
COLOR_ERROR = "red"
COLOR_WARNING = "yellow"
COLOR_SUCCESS = "green"
COLOR_INFO = "blue"
COLOR_MUTED = "dim"
# Library type colors
COLOR_LIBRARY_GIT = "blue"
COLOR_LIBRARY_STATIC = "yellow"
# === Style Constants ===
STYLE_HEADER = "bold white underline"
STYLE_HEADER_ALT = "bold cyan"
STYLE_DISABLED = "bright_black"
STYLE_SECTION_TITLE = "bold cyan"
STYLE_SECTION_DESC = "dim"
STYLE_TEMPLATE_NAME = "bold white"
# Table styles
STYLE_TABLE_HEADER = "bold blue"
STYLE_VAR_COL_NAME = "white"
STYLE_VAR_COL_TYPE = "magenta"
STYLE_VAR_COL_DEFAULT = "green"
STYLE_VAR_COL_DESC = "white"
# === Text Labels ===
LABEL_REQUIRED = " [yellow](*)[/yellow]"
LABEL_DISABLED = " (disabled)"
TEXT_EMPTY_VALUE = "(none)"
TEXT_EMPTY_OVERRIDE = "(empty)"
TEXT_UNNAMED_TEMPLATE = "Unnamed Template"
TEXT_NO_DESCRIPTION = "No description available"
TEXT_VERSION_NOT_SPECIFIED = "Not specified"
# === Value Formatting ===
SENSITIVE_MASK = "********"
TRUNCATION_SUFFIX = "..."
VALUE_MAX_LENGTH_SHORT = 15
VALUE_MAX_LENGTH_DEFAULT = 30
# === Layout Constants ===
SECTION_SEPARATOR_CHAR = "─"
SECTION_SEPARATOR_LENGTH = 40
VAR_NAME_INDENT = " " # 2 spaces
# === Size Formatting ===
SIZE_KB_THRESHOLD = 1024
SIZE_MB_THRESHOLD = 1024 * 1024
SIZE_DECIMAL_PLACES = 1
# === Table Padding ===
PADDING_PANEL = (1, 2)
PADDING_TABLE_COMPACT = (0, 1)
PADDING_TABLE_NORMAL = (0, 2)
================================================
FILE: cli/core/display/display_status.py
================================================
from __future__ import annotations
import logging
import re
from typing import TYPE_CHECKING
from rich import box
from rich._loop import loop_first
from rich.console import Console, ConsoleOptions, RenderResult
from rich.markdown import Heading, ListItem, Markdown
from rich.panel import Panel
from rich.segment import Segment
from rich.text import Text
from .display_icons import IconManager
from .display_settings import DisplaySettings
if TYPE_CHECKING:
from .display_base import BaseDisplay
logger = logging.getLogger(__name__)
console_err = Console(stderr=True) # Keep for error output
class LeftAlignedHeading(Heading):
"""Custom Heading element with left alignment and no extra spacing."""
def __rich_console__(self, console: Console, options: ConsoleOptions) -> RenderResult:
text = self.text
text.justify = "left" # Override center justification
if self.tag == "h1":
# Draw a border around h1s (left-aligned)
yield Panel(
text,
box=box.HEAVY,
style="markdown.h1.border",
)
else:
# Styled text for h2 and beyond (no blank line before h2)
yield text
class IconListItem(ListItem):
"""Custom list item that replaces bullets with colored icons from shortcodes."""
def render_bullet(self, console: Console, options: ConsoleOptions) -> RenderResult:
"""Render list item with icon replacement if text starts with :shortcode:."""
# Get the text content from elements
text_content = ""
for element in self.elements:
if hasattr(element, "text"):
text_content = element.text.plain
break
icon_used = None
icon_color = "cyan" # Default color for icons
shortcode_found = None
# Scan for shortcode at the beginning
for shortcode, icon in IconManager.SHORTCODES.items():
if text_content.strip().startswith(shortcode):
icon_used = icon
shortcode_found = shortcode
# Map shortcodes to colors
shortcode_colors = {
":warning:": "yellow",
":error:": "red",
":check:": "green",
":success:": "green",
":info:": "blue",
":docker:": "blue",
":kubernetes:": "blue",
":rocket:": "magenta",
":star:": "yellow",
":lightning:": "yellow",
}
icon_color = shortcode_colors.get(shortcode, "cyan")
break
if icon_used and shortcode_found:
# Remove the shortcode from the text in all elements
for element in self.elements:
if hasattr(element, "text"):
# Replace the shortcode in the Text object
plain_text = element.text.plain
new_text = plain_text.replace(shortcode_found, "", 1).lstrip()
# Reconstruct the Text object with the same style
element.text = Text(new_text, style=element.text.style)
# Render with custom colored icon instead of bullet
render_options = options.update(width=options.max_width - 3)
lines = console.render_lines(self.elements, render_options, style=self.style)
bullet_style = console.get_style(icon_color, default="none")
bullet = Segment(f" {icon_used} ", bullet_style)
padding = Segment(" " * 3)
new_line = Segment("\n")
for first, line in loop_first(lines):
yield bullet if first else padding
yield from line
yield new_line
else:
# No icon found, use default list item rendering
yield from super().render_bullet(console, options)
class LeftAlignedMarkdown(Markdown):
"""Custom Markdown renderer with left-aligned headings and icon list items."""
def __init__(self, markup: str, **kwargs):
"""Initialize with custom heading and list item elements."""
super().__init__(markup, **kwargs)
# Replace heading element to use left alignment
self.elements["heading_open"] = LeftAlignedHeading
# Replace list item element to use icon replacement
self.elements["list_item_open"] = IconListItem
class StatusDisplay:
"""Status messages and error display.
Provides methods for displaying success, error, warning,
and informational messages with consistent formatting.
"""
def __init__(self, settings: DisplaySettings, quiet: bool, base: BaseDisplay):
"""Initialize StatusDisplay.
Args:
settings: Display settings for formatting
quiet: If True, suppress non-error output
base: BaseDisplay instance
"""
self.settings = settings
self.quiet = quiet
self.base = base
def _display_message(self, level: str, message: str, context: str | None = None) -> None:
"""Display a message with consistent formatting.
Args:
level: Message level (error, warning, success, info)
message: The message to display
context: Optional context information
"""
# Errors and warnings always go to stderr, even in quiet mode
# Success and info respect quiet mode and go to stdout
use_stderr = level in ("error", "warning")
should_print = use_stderr or not self.quiet
if not should_print:
return
settings = self.settings
colors = {
"error": settings.COLOR_ERROR,
"warning": settings.COLOR_WARNING,
"success": settings.COLOR_SUCCESS,
}
color = colors.get(level)
# Format message based on context
if context:
text = (
f"{level.capitalize()} in {context}: {message}"
if level in {"error", "warning"}
else f"{context}: {message}"
)
else:
text = f"{level.capitalize()}: {message}" if level in {"error", "warning"} else message
# Only use icons and colors for actual status indicators (error, warning, success)
# Plain info messages use default terminal color (no markup)
if level in {"error", "warning", "success"}:
icon = IconManager.get_status_icon(level)
formatted_text = f"[{color}]{icon} {text}[/{color}]"
else:
formatted_text = text
if use_stderr:
console_err.print(formatted_text)
else:
self.base.text(formatted_text)
# Log appropriately
log_message = f"{context}: {message}" if context else message
log_methods = {
"error": logger.error,
"warning": logger.warning,
"success": logger.info,
"info": logger.info,
}
log_methods.get(level, logger.info)(log_message)
def error(self, message: str, context: str | None = None, details: str | None = None) -> None:
"""Display an error message.
Args:
message: Error message
context: Optional context
details: Optional additional details (shown in dim style on same line)
"""
if details:
# Combine message and details on same line with different formatting
settings = self.settings
color = settings.COLOR_ERROR
icon = IconManager.get_status_icon("error")
# Format: Icon Error: Message (details in dim)
formatted = f"[{color}]{icon} Error: {message}[/{color}] [dim]({details})[/dim]"
console_err.print(formatted)
# Log at debug level to avoid duplicate console output (already printed to stderr)
logger.debug(f"Error displayed: {message} ({details})")
else:
# No details, use standard display
self._display_message("error", message, context)
def warning(self, message: str, context: str | None = None, details: str | None = None) -> None:
"""Display a warning message.
Args:
message: Warning message
context: Optional context
details: Optional additional details (shown in dim style on same line)
"""
if details:
# Combine message and details on same line with different formatting
settings = self.settings
color = settings.COLOR_WARNING
icon = IconManager.get_status_icon("warning")
# Format: Icon Warning: Message (details in dim)
formatted = f"[{color}]{icon} Warning: {message}[/{color}] [dim]({details})[/dim]"
console_err.print(formatted)
# Log at debug level to avoid duplicate console output (already printed to stderr)
logger.debug(f"Warning displayed: {message} ({details})")
else:
# No details, use standard display
self._display_message("warning", message, context)
def success(self, message: str, context: str | None = None) -> None:
"""Display a success message.
Args:
message: Success message
context: Optional context
"""
self._display_message("success", message, context)
def info(self, message: str, context: str | None = None) -> None:
"""Display an informational message.
Args:
message: Info message
context: Optional context
"""
self._display_message("info", message, context)
def skipped(self, message: str, reason: str | None = None) -> None:
"""Display a skipped/disabled message.
Args:
message: The main message to display
reason: Optional reason why it was skipped
"""
if reason:
self.base.text(f"\n{message} (skipped - {reason})", style="dim")
else:
self.base.text(f"\n{message} (skipped)", style="dim")
def markdown(self, content: str) -> None:
"""Render markdown content with left-aligned headings.
Replaces emoji-style shortcodes (e.g., :warning:, :info:) with Nerd Font icons
before rendering, EXCEPT for shortcodes at the start of list items which are
handled by IconListItem to replace the bullet.
Args:
content: Markdown-formatted text to render (may contain shortcodes)
"""
if not self.quiet:
# Replace shortcodes with Nerd Font icons, but preserve list item shortcodes
# Pattern: "- :shortcode:" at start of line should NOT be replaced
lines = content.split("\n")
processed_lines = []
for line in lines:
# Check if line is a list item starting with a shortcode
if re.match(r"^\s*-\s+:[a-z]+:", line):
# Keep the line as-is, IconListItem will handle it
processed_lines.append(line)
else:
# Replace shortcodes normally
processed_lines.append(IconManager.replace_shortcodes(line))
processed_content = "\n".join(processed_lines)
self.base._print_markdown(LeftAlignedMarkdown(processed_content))
================================================
FILE: cli/core/display/display_table.py
================================================
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from rich.table import Table
from rich.tree import Tree
from .display_icons import IconManager
from .display_settings import DisplaySettings
if TYPE_CHECKING:
from .display_base import BaseDisplay
logger = logging.getLogger(__name__)
class TableDisplay:
"""Table rendering.
Provides methods for displaying data tables with flexible formatting.
"""
def __init__(self, settings: DisplaySettings, base: BaseDisplay):
"""Initialize TableDisplay.
Args:
settings: Display settings for formatting
base: BaseDisplay instance for utility methods
"""
self.settings = settings
self.base = base
def data_table(
self,
columns: list[dict],
rows: list,
title: str | None = None,
row_formatter: callable | None = None,
) -> None:
"""Display a data table with configurable columns and formatting.
Args:
columns: List of column definitions, each dict with:
- name: Column header text
- style: Optional Rich style (e.g., "bold", "cyan")
- no_wrap: Optional bool to prevent text wrapping
- justify: Optional justify ("left", "right", "center")
rows: List of data rows (dicts, tuples, or objects)
title: Optional table title
row_formatter: Optional function(row) -> tuple to transform row data
"""
table = Table(title=title, show_header=True)
# Add columns
for col in columns:
table.add_column(
col["name"],
style=col.get("style"),
no_wrap=col.get("no_wrap", False),
justify=col.get("justify", "left"),
)
# Add rows
for row in rows:
if row_formatter:
formatted_row = row_formatter(row)
elif isinstance(row, dict):
formatted_row = tuple(str(row.get(col["name"], "")) for col in columns)
else:
formatted_row = tuple(str(cell) for cell in row)
table.add_row(*formatted_row)
self.base._print_table(table)
def render_templates_table(self, templates: list, module_name: str, _title: str) -> None:
"""Display a table of templates with library type indicators.
Args:
templates: List of Template objects
module_name: Name of the module
_title: Title for the table (unused, kept for API compatibility)
"""
if not templates:
logger.info(f"No templates found for module '{module_name}'")
return
logger.info(f"Listing {len(templates)} templates for module '{module_name}'")
table = Table()
table.add_column("ID", style="bold", no_wrap=True)
table.add_column("Name")
table.add_column("Tags")
table.add_column("Version", no_wrap=True)
table.add_column("Library", no_wrap=True)
settings = self.settings
for template in templates:
name = template.metadata.name or settings.TEXT_UNNAMED_TEMPLATE
tags_list = template.metadata.tags or []
tags = ", ".join(tags_list) if tags_list else "-"
version = str(template.metadata.version) if template.metadata.version else ""
# Format library with icon and color
library_name = template.metadata.library or ""
library_type = template.metadata.library_type or "git"
icon = IconManager.UI_LIBRARY_STATIC if library_type == "static" else IconManager.UI_LIBRARY_GIT
color = "yellow" if library_type == "static" else "blue"
library_display = f"[{color}]{icon} {library_name}[/{color}]"
table.add_row(template.id, name, tags, version, library_display)
self.base._print_table(table)
def render_status_table(
self,
_title: str,
rows: list[tuple[str, str, bool]],
columns: tuple[str, str] = ("Item", "Status"),
) -> None:
"""Display a status table with success/error indicators.
Args:
_title: Table title (unused, kept for API compatibility)
rows: List of tuples (name, message, success_bool)
columns: Column headers (name_header, status_header)
"""
table = Table(show_header=True)
table.add_column(columns[0], style="cyan", no_wrap=True)
table.add_column(columns[1])
for name, message, success in rows:
status_style = "green" if success else "red"
status_icon = IconManager.get_status_icon("success" if success else "error")
table.add_row(name, f"[{status_style}]{status_icon} {message}[/{status_style}]")
self.base._print_table(table)
def render_summary_table(self, title: str, items: dict[str, str]) -> None:
"""Display a simple two-column summary table.
Args:
title: Table title
items: Dictionary of key-value pairs to display
"""
settings = self.settings
table = Table(
title=title,
show_header=False,
box=None,
padding=settings.PADDING_TABLE_NORMAL,
)
table.add_column(style="bold")
table.add_column()
for key, value in items.items():
table.add_row(key, value)
self.base._print_table(table)
def render_file_operation_table(self, files: list[tuple[str, int, str]]) -> None:
"""Display a table of file operations with sizes and statuses.
Args:
files: List of tuples (file_path, size_bytes, status)
"""
settings = self.settings
table = Table(
show_header=True,
header_style=settings.STYLE_TABLE_HEADER,
box=None,
padding=settings.PADDING_TABLE_COMPACT,
)
table.add_column("File", style="white", no_wrap=False)
table.add_column("Size", justify="right", style=settings.COLOR_MUTED)
table.add_column("Status", style=settings.COLOR_WARNING)
for file_path, size_bytes, status in files:
size_str = self.base.format_file_size(size_bytes)
table.add_row(str(file_path), size_str, status)
self.base._print_table(table)
def _build_section_label(
self,
section_name: str,
section_data: dict,
show_all: bool,
) -> str:
"""Build section label with metadata."""
section_desc = section_data.get("description", "")
section_toggle = section_data.get("toggle")
section_needs = section_data.get("needs")
label = f"[cyan]{section_name}[/cyan]"
if section_toggle:
label += f" [dim](toggle: {section_toggle})[/dim]"
if section_needs:
needs_str = ", ".join(section_needs) if isinstance(section_needs, list) else section_needs
label += f" [dim](needs: {needs_str})[/dim]"
if show_all and section_desc:
label += f"\n [dim]{section_desc}[/dim]"
return label
def _build_variable_label(
self,
var_name: str,
var_data: dict,
show_all: bool,
) -> str:
"""Build variable label with type and default value."""
var_type = var_data.get("type", "string")
var_default = var_data.get("default", "")
var_desc = var_data.get("description", "")
var_sensitive = var_data.get("sensitive", False)
label = f"[green]{var_name}[/green] [dim]({var_type})[/dim]"
if var_default is not None and var_default != "":
settings = self.settings
display_val = settings.SENSITIVE_MASK if var_sensitive else str(var_default)
if not var_sensitive:
display_val = self.base.truncate(display_val, settings.VALUE_MAX_LENGTH_DEFAULT)
label += f" = [{settings.COLOR_WARNING}]{display_val}[/{settings.COLOR_WARNING}]"
if show_all and var_desc:
label += f"\n [dim]{var_desc}[/dim]"
return label
def _add_section_variables(self, section_node, section_vars: dict, show_all: bool) -> None:
"""Add variables to a section node."""
for var_name, var_data in section_vars.items():
if isinstance(var_data, dict):
var_label = self._build_variable_label(var_name, var_data, show_all)
section_node.add(var_label)
else:
# Simple key-value pair
section_node.add(f"[green]{var_name}[/green] = [yellow]{var_data}[/yellow]")
def render_config_tree(self, spec: dict, module_name: str, show_all: bool = False) -> None:
"""Display configuration spec as a tree view.
Args:
spec: The configuration spec dictionary
module_name: Name of the module
show_all: If True, show all details including descriptions
"""
if not spec:
self.base.text(f"No configuration found for module '{module_name}'", style="yellow")
return
# Create root tree node
icon = IconManager.config()
tree = Tree(f"[bold blue]{icon} {str.capitalize(module_name)} Configuration[/bold blue]")
for section_name, section_data in spec.items():
if not isinstance(section_data, dict):
continue
# Build and add section
section_label = self._build_section_label(section_name, section_data, show_all)
section_node = tree.add(section_label)
# Add variables to section
section_vars = section_data.get("vars") or {}
if section_vars:
self._add_section_variables(section_node, section_vars, show_all)
self.base._print_tree(tree)
================================================
FILE: cli/core/display/display_template.py
================================================
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING
from rich import box
from rich.console import Console
from rich.panel import Panel
from rich.text import Text
from .display_icons import IconManager
from .display_settings import DisplaySettings
if TYPE_CHECKING:
from ..template import Template
from .display_base import BaseDisplay
from .display_status import StatusDisplay
from .display_variable import VariableDisplay
class TemplateDisplay:
"""Template-related rendering.
Provides methods for displaying template information,
file trees, and metadata.
"""
def __init__(
self,
settings: DisplaySettings,
base: BaseDisplay,
variables: VariableDisplay,
status: StatusDisplay,
):
"""Initialize TemplateDisplay.
Args:
settings: Display settings for formatting
base: BaseDisplay instance
variables: VariableDisplay instance for rendering variables
status: StatusDisplay instance for markdown rendering
"""
self.settings = settings
self.base = base
self.variables = variables
self.status = status
def render_template(self, template: Template, template_id: str) -> None:
"""Display template information panel and variables table.
Args:
template: Template instance to display
template_id: ID of the template
"""
self.render_template_header(template, template_id)
self.render_file_tree(template)
self.variables.render_variables_table(template)
def render_template_header(self, template: Template, template_id: str) -> None:
"""Display the header for a template with library information.
Args:
template: Template instance
template_id: ID of the template
"""
settings = self.settings
template_name = template.metadata.name or settings.TEXT_UNNAMED_TEMPLATE
version = str(template.metadata.version) if template.metadata.version else settings.TEXT_VERSION_NOT_SPECIFIED
description = template.metadata.description or settings.TEXT_NO_DESCRIPTION
# Get library information and format with icon/color
library_name = template.metadata.library or ""
library_type = template.metadata.library_type or "git"
icon = IconManager.UI_LIBRARY_STATIC if library_type == "static" else IconManager.UI_LIBRARY_GIT
color = "yellow" if library_type == "static" else "blue"
# Create custom H1-style header with Rich markup support
# Build header content with Rich formatting
header_content = Text()
header_content.append(template_name, style="bold white")
header_content.append(" (", style="white")
header_content.append("id:", style="white")
header_content.append(template_id, style="dim")
header_content.append(" │ ", style="dim")
header_content.append("version:", style="white")
header_content.append(version, style="cyan")
header_content.append(" │ ", style="dim")
header_content.append("library:", style="white")
header_content.append(icon + " ", style=color)
header_content.append(library_name, style=color)
header_content.append(")", style="white")
panel = Panel(header_content, box=box.HEAVY, style="markdown.h1.border")
Console().print(panel)
self.base.text("")
self.status.markdown(description)
def render_file_tree(self, template: Template) -> None:
"""Display the file structure of a template.
Args:
template: Template instance
"""
self.base.text("")
self.base.heading("Template File Structure")
def get_template_file_info(template_file):
display_name = (
template_file.output_path.name
if hasattr(template_file, "output_path")
else template_file.relative_path.name
)
return (template_file.relative_path, display_name, "white", None)
if template.template_files:
self.base.file_tree(
f"{IconManager.folder()} [white]{template.id}[/white]",
template.template_files,
get_template_file_info,
)
def render_file_generation_confirmation(
self,
output_dir: Path,
files: dict[str, str],
existing_files: list[Path] | None = None,
) -> None:
"""Display files to be generated with confirmation prompt.
Args:
output_dir: Output directory path
files: Dictionary of file paths to content
existing_files: List of existing files that will be overwritten
"""
self.base.text("")
self.base.heading("Files to be Generated")
def get_file_generation_info(file_path_str):
file_path = Path(file_path_str)
file_name = file_path.parts[-1] if file_path.parts else file_path.name
full_path = output_dir / file_path
if existing_files and full_path in existing_files:
return (file_path, file_name, "yellow", "[red](will overwrite)[/red]")
return (file_path, file_name, "green", None)
self.base.file_tree(
f"{IconManager.folder()} [cyan]{output_dir.resolve()}[/cyan]",
files.keys(),
get_file_generation_info,
)
self.base.text("")
================================================
FILE: cli/core/display/display_variable.py
================================================
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from rich.table import Table
from .display_icons import IconManager
from .display_settings import DisplaySettings
logger = logging.getLogger(__name__)
if TYPE_CHECKING:
from ..template import Template
from .display_base import BaseDisplay
class VariableDisplay:
"""Variable-related rendering.
Provides methods for displaying variables, sections,
and their values with appropriate formatting based on context.
"""
def __init__(self, settings: DisplaySettings, base: BaseDisplay):
"""Initialize VariableDisplay.
Args:
settings: Display settings for formatting
base: BaseDisplay instance
"""
self.settings = settings
self.base = base
def render_variable_value(
self,
variable,
_context: str = "default",
is_dimmed: bool = False,
var_satisfied: bool = True,
) -> str:
"""Render variable value with appropriate formatting based on context.
Args:
variable: Variable instance to render
_context: Display context (unused, kept for API compatibility)
is_dimmed: Whether the variable should be dimmed
var_satisfied: Whether the variable's dependencies are satisfied
Returns:
Formatted string representation of the variable value
"""
# Handle disabled bool variables
if (is_dimmed or not var_satisfied) and variable.type == "bool":
if hasattr(variable, "_original_disabled") and variable._original_disabled is not False:
return f"{variable._original_disabled} {IconManager.arrow_right()} False"
return "False"
# Handle config overrides with arrow
if (
variable.origin == "config"
and hasattr(variable, "_original_stored")
and variable.original_value != variable.value
):
settings = self.settings
orig = self._format_value(
variable,
variable.original_value,
max_length=settings.VALUE_MAX_LENGTH_SHORT,
)
curr = variable.get_display_value(
mask_sensitive=True,
max_length=settings.VALUE_MAX_LENGTH_SHORT,
show_none=False,
)
if not curr:
curr = str(variable.value) if variable.value else settings.TEXT_EMPTY_OVERRIDE
arrow = IconManager.arrow_right()
color = settings.COLOR_WARNING
return f"[dim]{orig}[/dim] [bold {color}]{arrow} {curr}[/bold {color}]"
# Default formatting
settings = self.settings
value = variable.get_display_value(
mask_sensitive=True,
max_length=settings.VALUE_MAX_LENGTH_DEFAULT,
show_none=True,
)
if not variable.value:
return f"[{settings.COLOR_MUTED}]{value}[/{settings.COLOR_MUTED}]"
return value
def _format_value(self, variable, value, max_length: int | None = None) -> str:
"""Helper to format a specific value.
Args:
variable: Variable instance
value: Value to format
max_length: Maximum length before truncation
Returns:
Formatted value string
"""
settings = self.settings
if variable.sensitive:
return settings.SENSITIVE_MASK
if value is None or value == "":
return f"[{settings.COLOR_MUTED}]({settings.TEXT_EMPTY_VALUE})[/{settings.COLOR_MUTED}]"
val_str = str(value)
return self.base.truncate(val_str, max_length)
def render_section(self, title: str, description: str | None) -> None:
"""Display a section header.
Args:
title: Section title
description: Optional section description
"""
settings = self.settings
if description:
self.base.text(
f"\n{title} - {description}",
style=f"{settings.STYLE_SECTION_TITLE} {settings.STYLE_SECTION_DESC}",
)
else:
self.base.text(f"\n{title}", style=settings.STYLE_SECTION_TITLE)
self.base.text(
settings.SECTION_SEPARATOR_CHAR * settings.SECTION_SEPARATOR_LENGTH,
style=settings.COLOR_MUTED,
)
def _render_section_header(self, section, is_dimmed: bool) -> str:
"""Build section header text with appropriate styling.
Args:
section: VariableSection instance
is_dimmed: Whether section is dimmed (disabled)
Returns:
Formatted header text with Rich markup
"""
settings = self.settings
# Show (disabled) label if section has a toggle and is not enabled
disabled_text = settings.LABEL_DISABLED if (section.toggle and not section.is_enabled()) else ""
if is_dimmed:
style = settings.STYLE_DISABLED
return f"[bold {style}]{section.title}{disabled_text}[/bold {style}]"
return f"[bold]{section.title}{disabled_text}[/bold]"
def _render_variable_row(self, var_name: str, variable, is_dimmed: bool, var_satisfied: bool) -> tuple:
"""Build variable row data for table display.
Args:
var_name: Variable name
variable: Variable instance
is_dimmed: Whether containing section is dimmed
var_satisfied: Whether variable dependencies are satisfied
Returns:
Tuple of (var_display, type, default_val, description, row_style)
"""
settings = self.settings
# Build row style
row_style = settings.STYLE_DISABLED if (is_dimmed or not var_satisfied) else None
# Build default value
default_val = self.render_variable_value(variable, is_dimmed=is_dimmed, var_satisfied=var_satisfied)
# Build variable display name
sensitive_icon = f" {IconManager.lock()}" if variable.sensitive else ""
# Only show required indicator if variable is enabled (not dimmed and dependencies satisfied)
required_indicator = settings.LABEL_REQUIRED if variable.required and not is_dimmed and var_satisfied else ""
var_display = f"{settings.VAR_NAME_INDENT}{var_name}{sensitive_icon}{required_indicator}"
return (
var_display,
variable.type or "str",
default_val,
variable.description or "",
row_style,
)
def render_variables_table(self, template: Template) -> None:
"""Display a table of variables for a template.
All variables and sections are always shown. Disabled sections/variables
are displayed with dimmed styling.
Args:
template: Template instance
"""
if not (template.variables and template.variables.has_sections()):
return
settings = self.settings
self.base.text("")
self.base.heading("Template Variables")
variables_table = Table(show_header=True, header_style=settings.STYLE_TABLE_HEADER)
variables_table.add_column("Variable", style=settings.STYLE_VAR_COL_NAME, no_wrap=True)
variables_table.add_column("Type", style=settings.STYLE_VAR_COL_TYPE)
variables_table.add_column("Default", style=settings.STYLE_VAR_COL_DEFAULT)
variables_table.add_column("Description", style=settings.STYLE_VAR_COL_DESC)
first_section = True
for section in template.variables.get_sections().values():
if not section.variables:
continue
if not first_section:
variables_table.add_row("", "", "", "", style=settings.STYLE_DISABLED)
first_section = False
# Check if section is enabled AND dependencies are satisfied
is_enabled = section.is_enabled()
dependencies_satisfied = template.variables.is_section_satisfied(section.key)
is_dimmed = not (is_enabled and dependencies_satisfied)
# Render section header
header_text = self._render_section_header(section, is_dimmed)
variables_table.add_row(header_text, "", "", "")
# Render variables
for var_name, variable in section.variables.items():
# Check if variable's needs are satisfied
var_satisfied = template.variables.is_variable_satisfied(var_name)
# Build and add row
(
var_display,
var_type,
default_val,
description,
row_style,
) = self._render_variable_row(var_name, variable, is_dimmed, var_satisfied)
variables_table.add_row(var_display, var_type, default_val, description, style=row_style)
self.base._print_table(variables_table)
================================================
FILE: cli/core/exceptions.py
================================================
"""Custom exception classes for the boilerplates CLI.
This module defines specific exception types for better error handling
and diagnostics throughout the application.
"""
from __future__ import annotations
from dataclasses import dataclass, field
class BoilerplatesError(Exception):
"""Base exception for all boilerplates CLI errors."""
pass
class ConfigError(BoilerplatesError):
"""Raised when configuration operations fail."""
pass
class ConfigValidationError(ConfigError):
"""Raised when configuration validation fails."""
pass
class TemplateError(BoilerplatesError):
"""Base exception for template-related errors."""
pass
class TemplateNotFoundError(TemplateError):
"""Raised when a template cannot be found."""
def __init__(self, template_id: str, module_name: str | None = None):
self.template_id = template_id
self.module_name = module_name
msg = f"Template '{template_id}' not found"
if module_name:
msg += f" in module '{module_name}'"
super().__init__(msg)
class TemplateDraftError(TemplateError):
"""Raised when attempting to use a draft template."""
def __init__(self, template_id: str, module_name: str | None = None):
self.template_id = template_id
self.module_name = module_name
module_suffix = f" in module '{module_name}'" if module_name else ""
msg = (
f"Template '{template_id}' is in draft mode and not yet available for use{module_suffix}.\n"
"Draft templates are work-in-progress and cannot be generated yet.\n"
"To get updates when published, run 'boilerplates repo update' to sync your library."
)
super().__init__(msg)
class DuplicateTemplateError(TemplateError):
"""Raised when duplicate template IDs are found within the same library."""
def __init__(self, template_id: str, library_name: str):
self.template_id = template_id
self.library_name = library_name
super().__init__(
f"Duplicate template ID '{template_id}' found in library '{library_name}'. "
f"Each template within a library must have a unique ID."
)
class TemplateLoadError(TemplateError):
"""Raised when a template fails to load."""
pass
class TemplateSyntaxError(TemplateError):
"""Raised when a Jinja2 template has syntax errors."""
def __init__(self, template_id: str, errors: list[str]):
self.template_id = template_id
self.errors = errors
msg = f"Jinja2 syntax errors in template '{template_id}':\n" + "\n".join(errors)
super().__init__(msg)
class TemplateValidationError(TemplateError):
"""Raised when template validation fails."""
pass
class IncompatibleSchemaVersionError(TemplateError):
"""Raised when a template uses a schema version not supported by the module."""
def __init__(
self,
template_id: str,
template_schema: str,
module_schema: str,
module_name: str,
):
self.template_id = template_id
self.template_schema = template_schema
self.module_schema = module_schema
self.module_name = module_name
msg = (
f"Template '{template_id}' uses schema version {template_schema}, "
f"but module '{module_name}' only supports up to version {module_schema}.\n\n"
f"This template requires features not available in your current CLI version.\n"
f"Please upgrade the boilerplates CLI.\n\n"
f"Run: pip install --upgrade boilerplates"
)
super().__init__(msg)
@dataclass
class RenderErrorContext:
"""Context information for template rendering errors."""
file_path: str | None = None
line_number: int | None = None
column: int | None = None
context_lines: list[str] = field(default_factory=list)
variable_context: dict[str, str] = field(default_factory=dict)
suggestions: list[str] = field(default_factory=list)
original_error: Exception | None = None
class TemplateRenderError(TemplateError):
"""Raised when template rendering fails."""
def __init__(self, message: str, context: RenderErrorContext | None = None):
self.context = context or RenderErrorContext()
# Expose context fields as instance attributes for backward compatibility
self.file_path = self.context.file_path
self.line_number = self.context.line_number
self.column = self.context.column
self.context_lines = self.context.context_lines
self.variable_context = self.context.variable_context
self.suggestions = self.context.suggestions
self.original_error = self.context.original_error
# Build enhanced error message
parts = [message]
if self.context.file_path:
location = f"File: {self.context.file_path}"
if self.context.line_number:
location += f", Line: {self.context.line_number}"
if self.context.column:
location += f", Column: {self.context.column}"
parts.append(location)
super().__init__("\n".join(parts))
class VariableError(BoilerplatesError):
"""Base exception for variable-related errors."""
pass
class VariableValidationError(VariableError):
"""Raised when variable validation fails."""
def __init__(self, variable_name: str, message: str):
self.variable_name = variable_name
msg = f"Validation error for variable '{variable_name}': {message}"
super().__init__(msg)
class VariableTypeError(VariableError):
"""Raised when a variable has an incorrect type."""
def __init__(self, variable_name: str, expected_type: str, actual_type: str):
self.variable_name = variable_name
self.expected_type = expected_type
self.actual_type = actual_type
msg = f"Type error for variable '{variable_name}': expected {expected_type}, got {actual_type}"
super().__init__(msg)
class LibraryError(BoilerplatesError):
"""Raised when library operations fail."""
pass
class ModuleError(BoilerplatesError):
"""Raised when module operations fail."""
pass
class ModuleNotFoundError(ModuleError):
"""Raised when a module cannot be found."""
def __init__(self, module_name: str):
self.module_name = module_name
msg = f"Module '{module_name}' not found"
super().__init__(msg)
class ModuleLoadError(ModuleError):
"""Raised when a module fails to load."""
pass
class SchemaError(BoilerplatesError):
"""Raised when schema operations fail."""
def __init__(self, message: str, details: str | None = None):
self.details = details
msg = message
if details:
msg += f" ({details})"
super().__init__(msg)
class FileOperationError(BoilerplatesError):
"""Raised when file operations fail."""
pass
class RenderError(BoilerplatesError):
"""Raised when rendering operations fail."""
pass
class YAMLParseError(BoilerplatesError):
"""Raised when YAML parsing fails."""
def __init__(self, file_path: str, original_error: Exception):
self.file_path = file_path
self.original_error = original_error
msg = f"Failed to parse YAML file '{file_path}': {original_error}"
super().__init__(msg)
================================================
FILE: cli/core/input/__init__.py
================================================
"""Input management package for CLI user input operations.
This package provides centralized input handling with standardized styling
and validation across the entire CLI application.
"""
from .input_manager import InputManager
from .input_settings import InputSettings
from .prompt_manager import PromptHandler
__all__ = ["InputManager", "InputSettings", "PromptHandler"]
================================================
FILE: cli/core/input/input_manager.py
================================================
"""Input Manager for standardized user input handling.
This module provides a centralized interface for all user input operations,
ensuring consistent styling and validation across the CLI.
"""
from __future__ import annotations
import logging
import re
from typing import Callable
from rich.console import Console
from rich.prompt import Confirm, IntPrompt, Prompt
from .input_settings import InputSettings
logger = logging.getLogger(__name__)
console = Console()
class InputManager:
"""Manages all user input operations with standardized styling.
This class provides primitives for various types of user input including
text, passwords, confirmations, choices, and validated inputs.
"""
def __init__(self, settings: InputSettings | None = None):
"""Initialize InputManager.
Args:
settings: Input configuration settings (uses default if None)
"""
self.settings = settings or InputSettings()
def text(
self,
prompt: str,
default: str | None = None,
password: bool = False,
validator: Callable[[str], bool] | None = None,
error_message: str | None = None,
) -> str:
"""Prompt for text input.
Args:
prompt: Prompt message to display
default: Default value if user presses Enter
password: If True, mask the input
validator: Optional validation function
error_message: Custom error message for validation failure
Returns:
User input string
"""
if password:
return self.password(prompt, default)
while True:
result = Prompt.ask(
f"[{self.settings.PROMPT_STYLE}]{prompt}[/{self.settings.PROMPT_STYLE}]",
default=default or "",
console=console,
)
if validator and not validator(result):
msg = error_message or "Invalid input"
console.print(f"[{self.settings.PROMPT_ERROR_STYLE}]{msg}[/{self.settings.PROMPT_ERROR_STYLE}]")
continue
return result
def password(self, prompt: str, default: str | None = None) -> str:
"""Prompt for password input (masked).
Args:
prompt: Prompt message to display
default: Default value if user presses Enter
Returns:
User input string (masked during entry)
"""
return Prompt.ask(
f"[{self.settings.PROMPT_STYLE}]{prompt}[/{self.settings.PROMPT_STYLE}]",
default=default or "",
password=True,
console=console,
)
def confirm(self, prompt: str, default: bool | None = None) -> bool:
"""Prompt for yes/no confirmation.
Args:
prompt: Prompt message to display
default: Default value if user presses Enter
Returns:
True for yes, False for no
"""
if default is None:
default = self.settings.DEFAULT_CONFIRM_YES
return Confirm.ask(
f"[{self.settings.PROMPT_STYLE}]{prompt}[/{self.settings.PROMPT_STYLE}]",
default=default,
console=console,
)
def integer(
self,
prompt: str,
default: int | None = None,
min_value: int | None = None,
max_value: int | None = None,
) -> int:
"""Prompt for integer input with optional range validation.
Args:
prompt: Prompt message to display
default: Default value if user presses Enter
min_value: Minimum allowed value
max_value: Maximum allowed value
Returns:
Integer value
"""
while True:
if default is not None:
result = IntPrompt.ask(
f"[{self.settings.PROMPT_STYLE}]{prompt}[/{self.settings.PROMPT_STYLE}]",
default=default,
console=console,
)
else:
try:
result = IntPrompt.ask(
f"[{self.settings.PROMPT_STYLE}]{prompt}[/{self.settings.PROMPT_STYLE}]",
console=console,
)
except ValueError:
console.print(
f"[{self.settings.PROMPT_ERROR_STYLE}]{self.settings.MSG_INVALID_INTEGER}[/{self.settings.PROMPT_ERROR_STYLE}]"
)
continue
# Validate range
if min_value is not None and result < min_value:
error_style = self.settings.PROMPT_ERROR_STYLE
console.print(f"[{error_style}]Value must be at least {min_value}[/{error_style}]")
continue
if max_value is not None and result > max_value:
error_style = self.settings.PROMPT_ERROR_STYLE
console.print(f"[{error_style}]Value must be at most {max_value}[/{error_style}]")
continue
return result
def choice(self, prompt: str, choices: list[str], default: str | None = None) -> str:
"""Prompt user to select one option from a list.
Args:
prompt: Prompt message to display
choices: List of valid options
default: Default choice if user presses Enter
Returns:
Selected choice
"""
if not choices:
raise ValueError("Choices list cannot be empty")
choices_display = f"[{', '.join(choices)}]"
full_prompt = f"{prompt} {choices_display}"
while True:
result = Prompt.ask(
f"[{self.settings.PROMPT_STYLE}]{full_prompt}[/{self.settings.PROMPT_STYLE}]",
default=default or "",
console=console,
)
if result in choices:
return result
console.print(
f"[{self.settings.PROMPT_ERROR_STYLE}]{self.settings.MSG_INVALID_CHOICE}[/{self.settings.PROMPT_ERROR_STYLE}]"
)
def validate_email(self, email: str) -> bool:
"""Validate email address format.
Args:
email: Email address to validate
Returns:
True if valid, False otherwise
"""
pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$"
return bool(re.match(pattern, email))
def validate_url(self, url: str) -> bool:
"""Validate URL format.
Args:
url: URL to validate
Returns:
True if valid, False otherwise
"""
pattern = r"^https?://[^\s/$.?#].[^\s]*$"
return bool(re.match(pattern, url, re.IGNORECASE))
def validate_hostname(self, hostname: str) -> bool:
"""Validate hostname/domain format.
Args:
hostname: Hostname to validate
Returns:
True if valid, False otherwise
"""
pattern = (
r"^(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*"
r"[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$"
)
return bool(re.match(pattern, hostname))
================================================
FILE: cli/core/input/input_settings.py
================================================
"""Input configuration settings for the CLI.
This module defines all configurable input parameters including prompt styles,
colors, and default behaviors.
"""
class InputSettings:
"""Centralized input configuration settings.
This class holds all configurable input parameters including prompt styles,
colors, validation messages, and default behaviors.
"""
# === Prompt Styles ===
PROMPT_STYLE = "white"
PROMPT_DEFAULT_STYLE = "dim"
PROMPT_ERROR_STYLE = "red"
PROMPT_SUCCESS_STYLE = "green"
# === Validation Messages ===
MSG_INVALID_INTEGER = "Please enter a valid integer"
MSG_INVALID_FLOAT = "Please enter a valid number"
MSG_INVALID_EMAIL = "Please enter a valid email address"
MSG_INVALID_URL = "Please enter a valid URL"
MSG_INVALID_HOSTNAME = "Please enter a valid hostname"
MSG_REQUIRED = "This field is required"
MSG_INVALID_CHOICE = "Please select a valid option"
# === Default Values ===
DEFAULT_CONFIRM_YES = True
DEFAULT_PASSWORD_MASK = "•"
# === Prompt Labels ===
LABEL_DEFAULT = "default"
LABEL_AUTO = "*auto"
LABEL_OPTIONAL = "optional"
================================================
FILE: cli/core/input/prompt_manager.py
================================================
from __future__ import annotations
import logging
from typing import Any, Callable
from rich.console import Console
from rich.prompt import IntPrompt, Prompt
from ..display import DisplayManager
from ..input import InputManager
from ..template import Variable, VariableCollection
logger = logging.getLogger(__name__)
class PromptHandler:
"""Simple interactive prompt handler for collecting template variables."""
def __init__(self) -> None:
self.console = Console()
self.display = DisplayManager()
def _handle_section_toggle(self, section, collected: dict[str, Any]) -> bool:
"""Handle section toggle variable and return whether section should be enabled."""
if not section.toggle:
return True
toggle_var = section.variables.get(section.toggle)
if not toggle_var:
return True
current_value = toggle_var.convert(toggle_var.value)
new_value = self._prompt_variable(toggle_var, _required=False)
if new_value != current_value:
collected[toggle_var.name] = new_value
toggle_var.value = new_value
return section.is_enabled()
def _should_skip_variable(
self,
var_name: str,
section,
variables: VariableCollection,
section_enabled: bool,
) -> bool:
"""Determine if a variable should be skipped during collection."""
if section.toggle and var_name == section.toggle:
return True
if not variables.is_variable_satisfied(var_name):
logger.debug(f"Skipping variable '{var_name}' - needs not satisfied")
return True
if not section_enabled:
logger.debug(f"Skipping variable '{var_name}' from disabled section '{section.key}'")
return True
return False
def _collect_variable_value(self, variable: Variable, collected: dict[str, Any]) -> None:
"""Collect a single variable value and update if changed."""
current_value = variable.convert(variable.value)
new_value = self._prompt_variable(variable, _required=False)
if variable.autogenerated and new_value is None:
collected[variable.name] = None
variable.value = None
elif new_value != current_value:
collected[variable.name] = new_value
variable.value = new_value
def collect_variables(self, variables: VariableCollection) -> dict[str, Any]:
"""Collect values for variables by iterating through sections.
Args:
variables: VariableCollection with organized sections and variables
Returns:
Dict of variable names to collected values
"""
input_mgr = InputManager()
if not input_mgr.confirm("Customize any settings?", default=False):
logger.info("User opted to keep all default values")
return {}
collected: dict[str, Any] = {}
for _section_key, section in variables.get_sections().items():
if not section.variables:
continue
self.display.section(section.title, section.description)
section_enabled = self._handle_section_toggle(section, collected)
for var_name, variable in section.variables.items():
if self._should_skip_variable(var_name, section, variables, section_enabled):
continue
self._collect_variable_value(variable, collected)
logger.info(f"Variable collection completed. Collected {len(collected)} values")
return collected
def _prompt_variable(self, variable: Variable, _required: bool = False) -> Any:
"""Prompt for a single variable value based on its type.
Args:
variable: The variable to prompt for
_required: Whether the containing section is required
(unused, kept for API compatibility)
Returns:
The validated value entered by the user
"""
logger.debug(f"Prompting for variable '{variable.name}' (type: {variable.type})")
# Use variable's native methods for prompt text and default value
prompt_text = variable.get_prompt_text()
default_value = variable.get_normalized_default()
# Add lock icon before default value for sensitive or autogenerated variables
if variable.sensitive or variable.autogenerated:
# Format: "Prompt text 🔒 (default)"
# The lock icon goes between the text and the default value in parentheses
prompt_text = f"{prompt_text} {self.display.get_lock_icon()}"
# Check if this specific variable is required (has no default and not autogenerated)
var_is_required = variable.is_required()
# If variable is required, mark it in the prompt
if var_is_required:
prompt_text = f"{prompt_text} [bold red]*required[/bold red]"
handler = self._get_prompt_handler(variable)
# Add validation hint (includes both extra text and enum options)
hint = variable.get_validation_hint()
if hint:
# Show options/extra inline inside parentheses, before the default
prompt_text = f"{prompt_text} [dim]({hint})[/dim]"
while True:
try:
raw = handler(prompt_text, default_value)
# Use Variable's centralized validation method that handles:
# - Type conversion
# - Autogenerated variable detection
# - Required field validation
return variable.validate_and_convert(raw, check_required=True)
# Return the converted value (caller will update variable.value)
except ValueError as exc:
# Conversion/validation failed — show a consistent error message and retry
self._show_validation_error(str(exc))
except Exception as e:
# Unexpected error — log and retry using the stored (unconverted) value
logger.error(f"Error prompting for variable '{variable.name}': {e!s}")
default_value = variable.value
handler = self._get_prompt_handler(variable)
def _get_prompt_handler(self, variable: Variable) -> Callable:
"""Return the prompt function for a variable type."""
handlers = {
"bool": self._prompt_bool,
"int": self._prompt_int,
# For enum prompts we pass the variable.extra through so options and extra
# can be combined into a single inline hint.
"enum": lambda text, default: self._prompt_enum(
text,
variable.options or [],
default,
_extra=getattr(variable, "extra", None),
),
}
return handlers.get(
variable.type,
lambda text, default: self._prompt_string(text, default, is_sensitive=variable.sensitive),
)
def _show_validation_error(self, message: str) -> None:
"""Display validation feedback consistently."""
self.display.error(message)
def _prompt_string(self, prompt_text: str, default: Any = None, is_sensitive: bool = False) -> str | None:
value = Prompt.ask(
prompt_text,
default=str(default) if default is not None else "",
show_default=True,
password=is_sensitive,
)
stripped = value.strip() if value else None
return stripped if stripped else None
def _prompt_bool(self, prompt_text: str, default: Any = None) -> bool | None:
input_mgr = InputManager()
if default is None:
return input_mgr.confirm(prompt_text, default=None)
converted = default if isinstance(default, bool) else str(default).lower() in ("true", "1", "yes", "on")
return input_mgr.confirm(prompt_text, default=converted)
def _prompt_int(self, prompt_text: str, default: Any = None) -> int | None:
converted = None
if default is not None:
try:
converted = int(default)
except (ValueError, TypeError):
logger.warning(f"Invalid default integer value: {default}")
return IntPrompt.ask(prompt_text, default=converted)
def _prompt_enum(
self,
prompt_text: str,
options: list[str],
default: Any = None,
_extra: str | None = None,
) -> str:
"""Prompt for enum selection with validation.
Note: prompt_text should already include hint from variable.get_validation_hint()
but we keep this for backward compatibility and fallback.
"""
if not options:
return self._prompt_string(prompt_text, default)
# Validate default is in options
if default and str(default) not in options:
default = options[0]
while True:
value = Prompt.ask(
prompt_text,
default=str(default) if default else options[0],
show_default=True,
)
if value in options:
return value
self.console.print(f"[red]Invalid choice. Select from: {', '.join(options)}[/red]")
================================================
FILE: cli/core/library.py
================================================
from __future__ import annotations
import logging
from pathlib import Path
import yaml
from .config import ConfigManager
from .exceptions import DuplicateTemplateError, LibraryError, TemplateDraftError, TemplateNotFoundError
logger = logging.getLogger(__name__)
# Qualified ID format: "template_id.library_name"
QUALIFIED_ID_PARTS = 2
class Library:
"""Represents a single library with a specific path."""
def __init__(self, name: str, path: Path, priority: int = 0, library_type: str = "git") -> None:
"""Initialize a library instance.
Args:
name: Display name for the library
path: Path to the library directory
priority: Priority for library lookup (higher = checked first)
library_type: Type of library ("git" or "static")
"""
if library_type not in ("git", "static"):
raise ValueError(f"Invalid library type: {library_type}. Must be 'git' or 'static'.")
self.name = name
self.path = path
self.priority = priority # Higher priority = checked first
self.library_type = library_type
def _is_template_draft(self, template_path: Path) -> bool:
"""Check if a template is marked as draft."""
# Find the template file
for filename in ("template.yaml", "template.yml"):
template_file = template_path / filename
if template_file.exists():
break
else:
return False
try:
with template_file.open(encoding="utf-8") as f:
docs = [doc for doc in yaml.safe_load_all(f) if doc]
return docs[0].get("metadata", {}).get("draft", False) if docs else False
except (yaml.YAMLError, OSError) as e:
logger.warning(f"Error checking draft status for {template_path}: {e}")
return False
def find_by_id(self, module_name: str, template_id: str) -> tuple[Path, str]:
"""Find a template by its ID in this library for generation/show operations.
Note: Draft templates are intentionally excluded from this method.
They are visible in list/search commands (via find()) but cannot be
used for generation as they are work-in-progress.
Args:
module_name: The module name (e.g., 'compose', 'terraform')
template_id: The template ID to find
Returns:
Path to the template directory if found and not draft
Raises:
TemplateDraftError: If the template exists but is marked as draft
TemplateNotFoundError: If the template ID is not found in this library
"""
logger.debug(f"Looking for template '{template_id}' in module '{module_name}' in library '{self.name}'")
# Build the path to the specific template directory
template_path = self.path / module_name / template_id
# Check if template directory exists with a template file
has_template = template_path.is_dir() and any(
(template_path / f).exists() for f in ("template.yaml", "template.yml")
)
# Template not found at all
if not has_template:
raise TemplateNotFoundError(template_id, module_name)
# Template exists but is in draft mode
if self._is_template_draft(template_path):
raise TemplateDraftError(template_id, module_name)
logger.debug(f"Found template '{template_id}' at: {template_path}")
return template_path, self.name
def find(self, module_name: str, sort_results: bool = False) -> list[tuple[Path, str]]:
"""Find templates in this library for a specific module.
Includes all templates (both published and draft).
Args:
module_name: The module name (e.g., 'compose', 'terraform')
sort_results: Whether to return results sorted alphabetically
Returns:
List of Path objects representing template directories (including drafts)
Raises:
FileNotFoundError: If the module directory is not found in this library
"""
logger.debug(f"Looking for templates in module '{module_name}' in library '{self.name}'")
# Build the path to the module directory
module_path = self.path / module_name
# Check if the module directory exists
if not module_path.is_dir():
raise LibraryError(f"Module '{module_name}' not found in library '{self.name}'")
# Track seen IDs to detect duplicates within this library
seen_ids = {}
template_dirs = []
try:
for item in module_path.iterdir():
has_template = item.is_dir() and any((item / f).exists() for f in ("template.yaml", "template.yml"))
if has_template:
template_id = item.name
# Check for duplicate within same library
if template_id in seen_ids:
raise DuplicateTemplateError(template_id, self.name)
seen_ids[template_id] = True
template_dirs.append((item, self.name))
except PermissionError as e:
raise LibraryError(
f"Permission denied accessing module '{module_name}' in library '{self.name}': {e}"
) from e
# Sort if requested
if sort_results:
template_dirs.sort(key=lambda x: x[0].name.lower())
logger.debug(f"Found {len(template_dirs)} templates in module '{module_name}'")
return template_dirs
class LibraryManager:
"""Manages multiple libraries and provides methods to find templates."""
def __init__(self) -> None:
"""Initialize LibraryManager with git-based libraries from config."""
self.config = ConfigManager()
self.libraries = self._load_libraries_from_config()
def _resolve_git_library_path(self, name: str, lib_config: dict, libraries_path: Path) -> Path:
"""Resolve path for a git-based library."""
directory = lib_config.get("directory", ".")
library_base = libraries_path / name
if directory and directory != ".":
return library_base / directory
return library_base
def _resolve_static_library_path(self, name: str, lib_config: dict) -> Path | None:
"""Resolve path for a static library."""
path_str = lib_config.get("path")
if not path_str:
logger.warning(f"Static library '{name}' has no path configured")
return None
library_path = Path(path_str).expanduser()
if not library_path.is_absolute():
library_path = (self.config.config_path.parent / library_path).resolve()
return library_path
def _warn_missing_library(self, name: str, library_path: Path, lib_type: str) -> None:
"""Log warning about missing library."""
if lib_type == "git":
logger.warning(
f"Library '{name}' not found at {library_path}. Run 'boilerplates repo update' to sync libraries."
)
else:
logger.warning(f"Static library '{name}' not found at {library_path}")
def _load_libraries_from_config(self) -> list[Library]:
"""Load libraries from configuration.
Returns:
List of Library instances
"""
libraries = []
libraries_path = self.config.get_libraries_path()
library_configs = self.config.get_libraries()
for i, lib_config in enumerate(library_configs):
# Skip disabled libraries
if not lib_config.get("enabled", True):
logger.debug(f"Skipping disabled library: {lib_config.get('name')}")
continue
name = lib_config.get("name")
lib_type = lib_config.get("type", "git")
# Resolve library path based on type
if lib_type == "git":
library_path = self._resolve_git_library_path(name, lib_config, libraries_path)
elif lib_type == "static":
library_path = self._resolve_static_library_path(name, lib_config)
if not library_path:
continue
else:
logger.warning(f"Unknown library type '{lib_type}' for library '{name}'")
continue
# Check if library path exists
if not library_path.exists():
self._warn_missing_library(name, library_path, lib_type)
continue
# Create Library instance with priority based on order
priority = len(library_configs) - i
libraries.append(
Library(
name=name,
path=library_path,
priority=priority,
library_type=lib_type,
)
)
logger.debug(f"Loaded {lib_type} library '{name}' from {library_path} with priority {priority}")
if not libraries:
logger.warning("No libraries loaded. Run 'boilerplates repo update' to sync libraries.")
return libraries
def find_by_id(self, module_name: str, template_id: str) -> tuple[Path, str] | None:
"""Find a template by its ID across all libraries.
Supports both simple IDs and qualified IDs (template.library format).
Args:
module_name: The module name (e.g., 'compose', 'terraform')
template_id: The template ID to find (simple or qualified)
Returns:
Tuple of (template_path, library_name) if found, None otherwise
"""
logger.debug(f"Searching for template '{template_id}' in module '{module_name}' across all libraries")
# Check if this is a qualified ID (contains '.')
if "." in template_id:
parts = template_id.rsplit(".", 1)
if len(parts) == QUALIFIED_ID_PARTS:
base_id, requested_lib = parts
logger.debug(f"Parsing qualified ID: base='{base_id}', library='{requested_lib}'")
# Try to find in the specific library
for library in self.libraries:
if library.name == requested_lib:
try:
template_path, lib_name = library.find_by_id(module_name, base_id)
logger.debug(f"Found template '{base_id}' in library '{requested_lib}'")
return template_path, lib_name
except (TemplateNotFoundError, TemplateDraftError):
logger.debug(f"Template '{base_id}' not found in library '{requested_lib}'")
return None
logger.debug(f"Library '{requested_lib}' not found")
return None
# Simple ID - search by priority
for library in sorted(self.libraries, key=lambda x: x.priority, reverse=True):
try:
template_path, lib_name = library.find_by_id(module_name, template_id)
logger.debug(f"Found template '{template_id}' in library '{library.name}'")
return template_path, lib_name
except TemplateNotFoundError:
# Continue searching in next library
continue
except TemplateDraftError:
# Draft error should propagate immediately (don't search other libraries)
raise
logger.debug(f"Template '{template_id}' not found in any library")
return None
def find(self, module_name: str, sort_results: bool = False) -> list[tuple[Path, str, bool]]:
"""Find templates across all libraries for a specific module.
Handles duplicates by qualifying IDs with library names when needed.
Args:
module_name: The module name (e.g., 'compose', 'terraform')
sort_results: Whether to return results sorted alphabetically
Returns:
List of tuples (template_path, library_name, needs_qualification)
where needs_qualification is True if the template ID appears in multiple libraries
"""
logger.debug(f"Searching for templates in module '{module_name}' across all libraries")
all_templates = []
# Collect templates from all libraries
for library in sorted(self.libraries, key=lambda x: x.priority, reverse=True):
try:
templates = library.find(module_name, sort_results=False)
all_templates.extend(templates)
logger.debug(f"Found {len(templates)} templates in library '{library.name}'")
except (LibraryError, DuplicateTemplateError) as e:
# DuplicateTemplateError from library.find() should propagate up
if isinstance(e, DuplicateTemplateError):
raise
logger.debug(f"Module '{module_name}' not found in library '{library.name}'")
continue
# Track template IDs and their libraries to detect cross-library duplicates
id_to_occurrences = {}
for template_path, library_name in all_templates:
template_id = template_path.name
if template_id not in id_to_occurrences:
id_to_occurrences[template_id] = []
id_to_occurrences[template_id].append((template_path, library_name))
# Build result with qualification markers for duplicates
result = []
for template_id, occurrences in id_to_occurrences.items():
if len(occurrences) > 1:
# Duplicate across libraries - mark for qualified IDs
lib_names = ", ".join(lib for _, lib in occurrences)
logger.info(f"Template '{template_id}' found in multiple libraries: {lib_names}. Using qualified IDs.")
for template_path, library_name in occurrences:
# Mark that this ID needs qualification
result.append((template_path, library_name, True))
else:
# Unique template - no qualification needed
template_path, library_name = occurrences[0]
result.append((template_path, library_name, False))
# Sort if requested
if sort_results:
result.sort(key=lambda x: x[0].name.lower())
logger.debug(f"Found {len(result)} templates total")
return result
================================================
FILE: cli/core/module/__init__.py
================================================
"""Module package for template management.
This package provides the base Module class and related functionality for managing
template modules in the boilerplates CLI.
"""
from .base_module import Module
__all__ = ["Module"]
================================================
FILE: cli/core/module/base_commands.py
================================================
"""Base commands for module: list, search, show, validate, generate."""
from __future__ import annotations
import logging
from dataclasses import dataclass
from pathlib import Path
from jinja2 import Template as Jinja2Template
from typer import Exit
from ..config import ConfigManager
from ..display import DisplayManager, IconManager
from ..exceptions import (
TemplateRenderError,
TemplateSyntaxError,
TemplateValidationError,
)
from ..input import InputManager
from ..template import (
TEMPLATE_STATUS_DRAFT,
TEMPLATE_STATUS_PUBLISHED,
Template,
)
from ..validators import get_validator_registry
from .helpers import (
apply_cli_overrides,
apply_var_file,
apply_variable_defaults,
collect_variable_values,
)
logger = logging.getLogger(__name__)
# File size thresholds for display formatting
BYTES_PER_KB = 1024
BYTES_PER_MB = 1024 * 1024
@dataclass
class GenerationConfig:
"""Configuration for template generation."""
id: str
directory: str | None = None
output: str | None = None
interactive: bool = True
var: list[str] | None = None
var_file: str | None = None
dry_run: bool = False
show_files: bool = False
quiet: bool = False
@dataclass
class ConfirmationContext:
"""Context for file generation confirmation."""
output_dir: Path
rendered_files: dict[str, str]
existing_files: list[Path] | None
dir_not_empty: bool
dry_run: bool
interactive: bool
display: DisplayManager
def list_templates(module_instance, raw: bool = False) -> list:
"""List all templates."""
logger.debug(f"Listing templates for module '{module_instance.name}'")
# Load all templates using centralized helper
filtered_templates = module_instance._load_all_templates()
if filtered_templates:
if raw:
# Output raw format (tab-separated values for easy filtering with awk/sed/cut)
# Format: ID\tNAME\tTAGS\tVERSION\tLIBRARY
for template in filtered_templates:
tags_list = template.metadata.tags or []
",".join(tags_list) if tags_list else "-"
(str(template.metadata.version) if template.metadata.version else "-")
else:
# Output rich table format
def format_template_row(template):
name = template.metadata.name or "Unnamed Template"
tags_list = template.metadata.tags or []
tags = ", ".join(tags_list) if tags_list else "-"
version = str(template.metadata.version) if template.metadata.version else ""
# Get status and format it
status = template.status
if status == TEMPLATE_STATUS_PUBLISHED:
status_display = "[green]Published[/green]"
elif status == TEMPLATE_STATUS_DRAFT:
status_display = "[dim]Draft[/dim]"
else: # TEMPLATE_STATUS_INVALID
status_display = "[red]Invalid[/red]"
library_name = template.metadata.library or ""
library_type = template.metadata.library_type or "git"
# Format library with icon and color
icon = IconManager.UI_LIBRARY_STATIC if library_type == "static" else IconManager.UI_LIBRARY_GIT
color = "yellow" if library_type == "static" else "blue"
library_display = f"[{color}]{icon} {library_name}[/{color}]"
# Apply dimmed style to entire row if draft
if status == TEMPLATE_STATUS_DRAFT:
template_id = f"[dim]{template.id}[/dim]"
name = f"[dim]{name}[/dim]"
tags = f"[dim]{tags}[/dim]"
version = f"[dim]{version}[/dim]"
library_display = f"[dim]{icon} {library_name}[/dim]"
else:
template_id = template.id
return (template_id, name, tags, version, status_display, library_display)
module_instance.display.data_table(
columns=[
{"name": "ID", "style": "bold", "no_wrap": True},
{"name": "Name"},
{"name": "Tags"},
{"name": "Version", "no_wrap": True},
{"name": "Status", "no_wrap": True},
{"name": "Library", "no_wrap": True},
],
rows=filtered_templates,
row_formatter=format_template_row,
)
else:
logger.info(f"No templates found for module '{module_instance.name}'")
module_instance.display.info(
f"No templates found for module '{module_instance.name}'",
context="Use 'bp repo update' to update libraries or check library configuration",
)
return filtered_templates
def search_templates(module_instance, query: str) -> list:
"""Search for templates by ID containing the search string."""
logger.debug(f"Searching templates for module '{module_instance.name}' with query='{query}'")
# Load templates with search filter using centralized helper
filtered_templates = module_instance._load_all_templates(lambda t: query.lower() in t.id.lower())
if filtered_templates:
logger.info(f"Found {len(filtered_templates)} templates matching '{query}' for module '{module_instance.name}'")
def format_template_row(template):
name = template.metadata.name or "Unnamed Template"
tags_list = template.metadata.tags or []
tags = ", ".join(tags_list) if tags_list else "-"
version = str(template.metadata.version) if template.metadata.version else ""
# Get status and format it
status = template.status
if status == TEMPLATE_STATUS_PUBLISHED:
status_display = "[green]Published[/green]"
elif status == TEMPLATE_STATUS_DRAFT:
status_display = "[dim]Draft[/dim]"
else: # TEMPLATE_STATUS_INVALID
status_display = "[red]Invalid[/red]"
library_name = template.metadata.library or ""
library_type = template.metadata.library_type or "git"
# Format library with icon and color
icon = IconManager.UI_LIBRARY_STATIC if library_type == "static" else IconManager.UI_LIBRARY_GIT
color = "yellow" if library_type == "static" else "blue"
library_display = f"[{color}]{icon} {library_name}[/{color}]"
# Apply dimmed style to entire row if draft
if status == TEMPLATE_STATUS_DRAFT:
template_id = f"[dim]{template.id}[/dim]"
name = f"[dim]{name}[/dim]"
tags = f"[dim]{tags}[/dim]"
version = f"[dim]{version}[/dim]"
library_display = f"[dim]{icon} {library_name}[/dim]"
else:
template_id = template.id
return (template_id, name, tags, version, status_display, library_display)
module_instance.display.data_table(
columns=[
{"name": "ID", "style": "bold", "no_wrap": True},
{"name": "Name"},
{"name": "Tags"},
{"name": "Version", "no_wrap": True},
{"name": "Status", "no_wrap": True},
{"name": "Library", "no_wrap": True},
],
rows=filtered_templates,
row_formatter=format_template_row,
)
else:
logger.info(f"No templates found matching '{query}' for module '{module_instance.name}'")
module_instance.display.warning(
f"No templates found matching '{query}'",
context=f"module '{module_instance.name}'",
)
return filtered_templates
def show_template(module_instance, id: str, var: list[str] | None = None, var_file: str | None = None) -> None:
"""Show template details with optional variable overrides."""
logger.debug(f"Showing template '{id}' from module '{module_instance.name}'")
template = module_instance._load_template_by_id(id)
if not template:
module_instance.display.error(f"Template '{id}' not found", context=f"module '{module_instance.name}'")
return
# Apply defaults and overrides (same precedence as generate command)
if template.variables:
config = ConfigManager()
apply_variable_defaults(template, config, module_instance.name)
apply_var_file(template, var_file, module_instance.display)
apply_cli_overrides(template, var)
# Reset disabled bool variables to False to prevent confusion
reset_vars = template.variables.reset_disabled_bool_variables()
if reset_vars:
logger.debug(f"Reset {len(reset_vars)} disabled bool variables to False")
# Display template header
module_instance.display.templates.render_template_header(template, id)
# Display file tree
module_instance.display.templates.render_file_tree(template)
# Display variables table
module_instance.display.variables.render_variables_table(template)
def check_output_directory(
output_dir: Path,
rendered_files: dict[str, str],
interactive: bool,
display: DisplayManager,
) -> list[Path] | None:
"""Check output directory for conflicts and get user confirmation if needed."""
dir_exists = output_dir.exists()
dir_not_empty = dir_exists and any(output_dir.iterdir())
# Check which files already exist
existing_files = []
if dir_exists:
for file_path in rendered_files:
full_path = output_dir / file_path
if full_path.exists():
existing_files.append(full_path)
# Warn if directory is not empty
if dir_not_empty:
if interactive:
display.text("") # Add newline before warning
# Combine directory warning and file count on same line
warning_msg = f"Directory '{output_dir}' is not empty."
if existing_files:
warning_msg += f" {len(existing_files)} file(s) will be overwritten."
display.warning(warning_msg)
display.text("") # Add newline after warning
input_mgr = InputManager()
if not input_mgr.confirm("Continue?", default=False):
display.info("Generation cancelled")
return None
else:
# Non-interactive mode: show warning but continue
logger.warning(f"Directory '{output_dir}' is not empty")
if existing_files:
logger.warning(f"{len(existing_files)} file(s) will be overwritten")
return existing_files
def get_generation_confirmation(_ctx: ConfirmationContext) -> bool:
"""Display file generation confirmation and get user approval."""
# No confirmation needed - either non-interactive, dry-run, or already confirmed during directory check
return True
def _collect_subdirectories(rendered_files: dict[str, str]) -> set[Path]:
"""Collect unique subdirectories from file paths."""
subdirs = set()
for file_path in rendered_files:
parts = Path(file_path).parts
for i in range(1, len(parts)):
subdirs.add(Path(*parts[:i]))
return subdirs
def _analyze_file_operations(
output_dir: Path, rendered_files: dict[str, str]
) -> tuple[list[tuple[str, int, str]], int, int, int]:
"""Analyze file operations and return statistics."""
total_size = 0
new_files = 0
overwrite_files = 0
file_operations = []
for file_path, content in sorted(rendered_files.items()):
full_path = output_dir / file_path
file_size = len(content.encode("utf-8"))
total_size += file_size
if full_path.exists():
status = "Overwrite"
overwrite_files += 1
else:
status = "Create"
new_files += 1
file_operations.append((file_path, file_size, status))
return file_operations, total_size, new_files, overwrite_files
def _format_size(total_size: int) -> str:
"""Format byte size into human-readable string."""
if total_size < BYTES_PER_KB:
return f"{total_size}B"
if total_size < BYTES_PER_MB:
return f"{total_size / BYTES_PER_KB:.1f}KB"
return f"{total_size / BYTES_PER_MB:.1f}MB"
def execute_dry_run(
id: str,
output_dir: Path,
rendered_files: dict[str, str],
show_files: bool,
display: DisplayManager,
) -> tuple[int, int, str]:
"""Execute dry run mode - preview files without writing.
Returns:
Tuple of (total_files, overwrite_files, size_str) for summary display
"""
_file_operations, total_size, _new_files, overwrite_files = _analyze_file_operations(output_dir, rendered_files)
size_str = _format_size(total_size)
# Show file contents if requested
if show_files:
display.text("")
display.heading("File Contents")
for file_path, content in sorted(rendered_files.items()):
display.text(f"\n[cyan]{file_path}[/cyan]")
display.text(f"{'─' * 80}")
display.text(content)
display.text("")
logger.info(f"Dry run completed for template '{id}' - {len(rendered_files)} files, {total_size} bytes")
return len(rendered_files), overwrite_files, size_str
def write_rendered_files(
output_dir: Path,
rendered_files: dict[str, str],
_quiet: bool,
_display: DisplayManager,
) -> None:
"""Write rendered files to the output directory."""
output_dir.mkdir(parents=True, exist_ok=True)
for file_path, content in rendered_files.items():
full_path = output_dir / file_path
full_path.parent.mkdir(parents=True, exist_ok=True)
with full_path.open("w", encoding="utf-8") as f:
f.write(content)
logger.info(f"Template written to directory: {output_dir}")
def _prepare_template(
module_instance,
id: str,
var_file: str | None,
var: list[str] | None,
display: DisplayManager,
):
"""Load template and apply all defaults/overrides."""
template = module_instance._load_template_by_id(id)
config = ConfigManager()
apply_variable_defaults(template, config, module_instance.name)
apply_var_file(template, var_file, display)
apply_cli_overrides(template, var)
if template.variables:
reset_vars = template.variables.reset_disabled_bool_variables()
if reset_vars:
logger.debug(f"Reset {len(reset_vars)} disabled bool variables to False")
return template
def _render_template(template, id: str, display: DisplayManager, interactive: bool):
"""Validate, render template and collect variable values."""
variable_values = collect_variable_values(template, interactive)
if template.variables:
template.variables.validate_all()
debug_mode = logger.isEnabledFor(logging.DEBUG)
rendered_files, variable_values = template.render(template.variables, debug=debug_mode)
if not rendered_files:
display.error(
"Template rendering returned no files",
context="template generation",
)
raise Exit(code=1)
logger.info(f"Successfully rendered template '{id}'")
return rendered_files, variable_values
def _determine_output_dir(directory: str | None, output: str | None, id: str) -> tuple[Path, bool]:
"""Determine and normalize output directory path.
Returns:
Tuple of (output_dir, used_deprecated_arg) where used_deprecated_arg indicates
if the deprecated positional directory argument was used.
"""
used_deprecated_arg = False
# Priority: --output flag > positional directory argument > template ID
if output:
output_dir = Path(output)
elif directory:
output_dir = Path(directory)
used_deprecated_arg = True
logger.debug(f"Using deprecated positional directory argument: {directory}")
else:
output_dir = Path(id)
# Normalize paths that look like absolute paths but are relative
if not output_dir.is_absolute() and str(output_dir).startswith(("Users/", "home/", "usr/", "opt/", "var/", "tmp/")):
output_dir = Path("/") / output_dir
logger.debug(f"Normalized relative-looking absolute path to: {output_dir}")
return output_dir, used_deprecated_arg
def _display_template_error(display: DisplayManager, template_id: str, error: TemplateRenderError) -> None:
"""Display template rendering error with clean formatting."""
display.text("")
display.text("─" * 80, style="dim")
display.text("")
# Build details if available
details = None
if error.file_path:
details = error.file_path
if error.line_number:
details += f":line {error.line_number}"
# Display error with details
display.error(f"Failed to generate boilerplate from template '{template_id}'", details=details)
def _display_generic_error(display: DisplayManager, template_id: str, error: Exception) -> None:
"""Display generic error with clean formatting."""
display.text("")
display.text("─" * 80, style="dim")
display.text("")
# Truncate long error messages
max_error_length = 100
error_msg = str(error)
if len(error_msg) > max_error_length:
error_msg = f"{error_msg[:max_error_length]}..."
# Display error with details
display.error(f"Failed to generate boilerplate from template '{template_id}'", details=error_msg)
def generate_template(module_instance, config: GenerationConfig) -> None: # noqa: PLR0912, PLR0915
"""Generate from template."""
logger.info(f"Starting generation for template '{config.id}' from module '{module_instance.name}'")
display = DisplayManager(quiet=config.quiet) if config.quiet else module_instance.display
template = _prepare_template(module_instance, config.id, config.var_file, config.var, display)
# Determine output directory early to check for deprecated argument usage
output_dir, used_deprecated_arg = _determine_output_dir(config.directory, config.output, config.id)
if not config.quiet:
# Display template header
module_instance.display.templates.render_template_header(template, config.id)
# Display file tree
module_instance.display.templates.render_file_tree(template)
# Display variables table
module_instance.display.variables.render_variables_table(template)
module_instance.display.text("")
# Show deprecation warning BEFORE any user interaction
if used_deprecated_arg:
module_instance.display.warning(
"Using positional argument for output directory is deprecated and will be removed in v0.2.0",
details="Use --output/-o flag instead",
)
module_instance.display.text("")
try:
rendered_files, variable_values = _render_template(template, config.id, display, config.interactive)
# Check for conflicts and get confirmation (skip in quiet mode)
if not config.quiet:
existing_files = check_output_directory(output_dir, rendered_files, config.interactive, display)
if existing_files is None:
return # User cancelled
dir_not_empty = output_dir.exists() and any(output_dir.iterdir())
ctx = ConfirmationContext(
output_dir=output_dir,
rendered_files=rendered_files,
existing_files=existing_files,
dir_not_empty=dir_not_empty,
dry_run=config.dry_run,
interactive=config.interactive,
display=display,
)
if not get_generation_confirmation(ctx):
return # User cancelled
# Execute generation (dry run or actual)
dry_run_stats = None
if config.dry_run:
if not config.quiet:
dry_run_stats = execute_dry_run(config.id, output_dir, rendered_files, config.show_files, display)
else:
write_rendered_files(output_dir, rendered_files, config.quiet, display)
# Display next steps (not in quiet mode)
if template.metadata.next_steps and not config.quiet:
display.text("")
display.heading("Next Steps")
try:
next_steps_template = Jinja2Template(template.metadata.next_steps)
rendered_next_steps = next_steps_template.render(variable_values)
display.status.markdown(rendered_next_steps)
except Exception as e:
logger.warning(f"Failed to render next_steps as template: {e}")
# Fallback to plain text if rendering fails
display.status.markdown(template.metadata.next_steps)
# Display final status message at the end
if not config.quiet:
display.text("")
display.text("─" * 80, style="dim")
if config.dry_run and dry_run_stats:
total_files, overwrite_files, size_str = dry_run_stats
if overwrite_files > 0:
display.warning(
f"Dry run complete: {total_files} files ({size_str}) would be written to '{output_dir}' "
f"({overwrite_files} would be overwritten)"
)
else:
display.success(
f"Dry run complete: {total_files} files ({size_str}) would be written to '{output_dir}'"
)
else:
# Actual generation completed
display.success(f"Boilerplate generated successfully in '{output_dir}'")
except TemplateRenderError as e:
_display_template_error(display, config.id, e)
raise Exit(code=1) from None
except Exception as e:
_display_generic_error(display, config.id, e)
raise Exit(code=1) from None
def validate_templates(
module_instance,
template_id: str,
path: str | None,
verbose: bool,
semantic: bool,
) -> None:
"""Validate templates for Jinja2 syntax, undefined variables, and semantic correctness."""
# Load template based on input
template = _load_template_for_validation(module_instance, template_id, path)
if template:
_validate_single_template(module_instance, template, template_id, verbose, semantic)
else:
_validate_all_templates(module_instance, verbose)
def _load_template_for_validation(module_instance, template_id: str, path: str | None):
"""Load a template from path or ID for validation."""
if path:
template_path = Path(path).resolve()
if not template_path.exists():
module_instance.display.error(f"Path does not exist: {path}")
raise Exit(code=1) from None
if not template_path.is_dir():
module_instance.display.error(f"Path is not a directory: {path}")
raise Exit(code=1) from None
module_instance.display.info(f"[bold]Validating template from path:[/bold] [cyan]{template_path}[/cyan]")
try:
return Template(template_path, library_name="local")
except Exception as e:
module_instance.display.error(f"Failed to load template from path '{path}': {e}")
raise Exit(code=1) from None
if template_id:
try:
template = module_instance._load_template_by_id(template_id)
module_instance.display.info(f"Validating template: {template_id}")
return template
except Exception as e:
module_instance.display.error(f"Failed to load template '{template_id}': {e}")
raise Exit(code=1) from None
return None
def _validate_single_template(module_instance, template, template_id: str, verbose: bool, semantic: bool) -> None:
"""Validate a single template."""
try:
# Jinja2 validation
_ = template.used_variables
_ = template.variables
module_instance.display.success("Jinja2 validation passed")
# Semantic validation
if semantic:
_run_semantic_validation(module_instance, template, verbose)
# Verbose output
if verbose:
_display_validation_details(module_instance, template, semantic)
except TemplateRenderError as e:
module_instance.display.error(str(e), context=f"template '{template_id}'")
raise Exit(code=1) from None
except (TemplateSyntaxError, TemplateValidationError, ValueError) as e:
module_instance.display.error(f"Validation failed for '{template_id}':")
module_instance.display.info(f"\n{e}")
raise Exit(code=1) from None
except Exception as e:
module_instance.display.error(f"Unexpected error validating '{template_id}': {e}")
raise Exit(code=1) from None
def _run_semantic_validation(module_instance, template, verbose: bool) -> None:
"""Run semantic validation on rendered template files."""
module_instance.display.info("")
module_instance.display.info("Running semantic validation...")
registry = get_validator_registry()
debug_mode = logger.isEnabledFor(logging.DEBUG)
rendered_files, _ = template.render(template.variables, debug=debug_mode)
has_semantic_errors = False
for file_path, content in rendered_files.items():
result = registry.validate_file(content, file_path)
if result.errors or result.warnings or (verbose and result.info):
module_instance.display.info(f"\nFile: {file_path}")
result.display(f"{file_path}")
if result.errors:
has_semantic_errors = True
if has_semantic_errors:
module_instance.display.error("Semantic validation found errors")
raise Exit(code=1) from None
module_instance.display.success("Semantic validation passed")
def _display_validation_details(module_instance, template, semantic: bool) -> None:
"""Display verbose validation details."""
module_instance.display.info(f"\nTemplate path: {template.template_dir}")
module_instance.display.info(f"Found {len(template.used_variables)} variables")
if semantic:
debug_mode = logger.isEnabledFor(logging.DEBUG)
rendered_files, _ = template.render(template.variables, debug=debug_mode)
module_instance.display.info(f"Generated {len(rendered_files)} files")
def _validate_all_templates(module_instance, verbose: bool) -> None:
"""Validate all templates in the module."""
module_instance.display.info(f"Validating all {module_instance.name} templates...")
valid_count = 0
invalid_count = 0
errors = []
all_templates = module_instance._load_all_templates()
total = len(all_templates)
for template in all_templates:
try:
_ = template.used_variables
_ = template.variables
valid_count += 1
if verbose:
module_instance.display.success(template.id)
except ValueError as e:
invalid_count += 1
errors.append((template.id, str(e)))
if verbose:
module_instance.display.error(template.id)
except Exception as e:
invalid_count += 1
errors.append((template.id, f"Load error: {e}"))
if verbose:
module_instance.display.warning(template.id)
# Display summary
module_instance.display.info("")
module_instance.display.info(f"Total templates: {total}")
module_instance.display.info(f"Valid: {valid_count}")
module_instance.display.info(f"Invalid: {invalid_count}")
if errors:
module_instance.display.info("")
for template_id, error_msg in errors:
module_instance.display.error(f"{template_id}: {error_msg}")
raise Exit(code=1)
if total > 0:
module_instance.display.info("")
module_instance.display.success("All templates are valid")
================================================
FILE: cli/core/module/base_module.py
================================================
"""Base module class for template management."""
from __future__ import annotations
import logging
from abc import ABC
from typing import Annotated
from typer import Argument, Option, Typer
from ..display import DisplayManager
from ..library import LibraryManager
from ..template import Template
from .base_commands import (
GenerationConfig,
generate_template,
list_templates,
search_templates,
show_template,
validate_templates,
)
from .config_commands import (
config_clear,
config_get,
config_list,
config_remove,
config_set,
)
logger = logging.getLogger(__name__)
# Expected length of library entry tuple: (path, library_name, needs_qualification)
LIBRARY_ENTRY_MIN_LENGTH = 2
class Module(ABC):
"""Streamlined base module that auto-detects variables from templates.
Subclasses must define:
- name: str (class attribute)
- description: str (class attribute)
"""
# Class attributes that must be defined by subclasses
name: str
description: str
# Schema version supported by this module (override in subclasses)
schema_version: str = "1.0"
def __init__(self) -> None:
# Validate required class attributes
if not hasattr(self.__class__, "name") or not hasattr(self.__class__, "description"):
raise TypeError(f"Module {self.__class__.__name__} must define 'name' and 'description' class attributes")
logger.info(f"Initializing module '{self.name}'")
logger.debug(f"Module '{self.name}' configuration: description='{self.description}'")
self.libraries = LibraryManager()
self.display = DisplayManager()
def _load_all_templates(self, filter_fn=None) -> list:
"""Load all templates for this module with optional filtering."""
templates = []
entries = self.libraries.find(self.name, sort_results=True)
for entry in entries:
# Unpack entry - returns (path, library_name, needs_qualification)
template_dir = entry[0]
library_name = entry[1]
needs_qualification = entry[2] if len(entry) > LIBRARY_ENTRY_MIN_LENGTH else False
try:
# Get library object to determine type
library = next(
(lib for lib in self.libraries.libraries if lib.name == library_name),
None,
)
library_type = library.library_type if library else "git"
template = Template(template_dir, library_name=library_name, library_type=library_type)
# If template ID needs qualification, set qualified ID
if needs_qualification:
template.set_qualified_id()
# Apply filter if provided
if filter_fn is None or filter_fn(template):
templates.append(template)
except Exception as exc:
logger.error(f"Failed to load template from {template_dir}: {exc}")
continue
return templates
def _load_template_by_id(self, id: str):
"""Load a template by its ID, supporting qualified IDs."""
logger.debug(f"Loading template with ID '{id}' from module '{self.name}'")
# find_by_id now handles both simple and qualified IDs
result = self.libraries.find_by_id(self.name, id)
if not result:
raise FileNotFoundError(f"Template '{id}' not found in module '{self.name}'")
template_dir, library_name = result
# Get library type
library = next((lib for lib in self.libraries.libraries if lib.name == library_name), None)
library_type = library.library_type if library else "git"
try:
template = Template(template_dir, library_name=library_name, library_type=library_type)
# If the original ID was qualified, preserve it
if "." in id:
template.id = id
return template
except Exception as exc:
logger.error(f"Failed to load template '{id}': {exc}")
raise FileNotFoundError(f"Template '{id}' could not be loaded: {exc}") from exc
def list(
self,
raw: Annotated[bool, Option("--raw", help="Output raw list format instead of rich table")] = False,
) -> list:
"""List all templates."""
return list_templates(self, raw)
def search(
self,
query: Annotated[str, Argument(help="Search string to filter templates by ID")],
) -> list:
"""Search for templates by ID containing the search string."""
return search_templates(self, query)
def show(
self,
id: str,
var: Annotated[
list[str] | None,
Option(
"--var",
"-v",
help="Variable override (repeatable). Supports: KEY=VALUE or KEY VALUE",
),
] = None,
var_file: Annotated[
str | None,
Option(
"--var-file",
"-f",
help="Load variables from YAML file (overrides config defaults)",
),
] = None,
) -> None:
"""Show template details with optional variable overrides."""
return show_template(self, id, var, var_file)
def generate(
self,
id: Annotated[str, Argument(help="Template ID")],
directory: Annotated[
str | None, Argument(help="[DEPRECATED: use --output] Output directory (defaults to template ID)")
] = None,
*,
output: Annotated[
str | None,
Option(
"--output",
"-o",
help="Output directory (defaults to template ID)",
),
] = None,
interactive: Annotated[
bool,
Option(
"--interactive/--no-interactive",
"-i/-n",
help="Enable interactive prompting for variables",
),
] = True,
var: Annotated[
list[str] | None,
Option(
"--var",
"-v",
help="Variable override (repeatable). Supports: KEY=VALUE or KEY VALUE",
),
] = None,
var_file: Annotated[
str | None,
Option(
"--var-file",
"-f",
help="Load variables from YAML file (overrides config defaults, overridden by --var)",
),
] = None,
dry_run: Annotated[
bool,
Option("--dry-run", help="Preview template generation without writing files"),
] = False,
show_files: Annotated[
bool,
Option(
"--show-files",
help="Display generated file contents in plain text (use with --dry-run)",
),
] = False,
quiet: Annotated[bool, Option("--quiet", "-q", help="Suppress all non-error output")] = False,
) -> None:
"""Generate from template.
Variable precedence chain (lowest to highest):
1. Module spec (defined in cli/modules/*.py)
2. Template spec (from template.yaml)
3. Config defaults (from ~/.config/boilerplates/config.yaml)
4. Variable file (from --var-file)
5. CLI overrides (--var flags)
"""
config = GenerationConfig(
id=id,
directory=directory,
output=output,
interactive=interactive,
var=var,
var_file=var_file,
dry_run=dry_run,
show_files=show_files,
quiet=quiet,
)
return generate_template(self, config)
def validate(
self,
template_id: Annotated[
str | None,
Argument(help="Template ID to validate (omit to validate all templates)"),
] = None,
*,
path: Annotated[
str | None,
Option("--path", help="Path to template directory for validation"),
] = None,
verbose: Annotated[bool, Option("--verbose", "-v", help="Show detailed validation information")] = False,
semantic: Annotated[
bool,
Option(
"--semantic/--no-semantic",
help="Enable semantic validation (Docker Compose schema, etc.)",
),
] = True,
) -> None:
"""Validate templates for Jinja2 syntax, undefined variables, and semantic correctness.
Examples:
# Validate specific template
cli compose validate netbox
# Validate all templates
cli compose validate
# Validate with verbose output
cli compose validate netbox --verbose
"""
return validate_templates(self, template_id, path, verbose, semantic)
def config_get(
self,
var_name: str | None = None,
) -> None:
"""Get default value(s) for this module."""
return config_get(self, var_name)
def config_set(
self,
var_name: str,
value: str | None = None,
) -> None:
"""Set a default value for a variable."""
return config_set(self, var_name, value)
def config_remove(
self,
var_name: Annotated[str, Argument(help="Variable name to remove")],
) -> None:
"""Remove a specific default variable value."""
return config_remove(self, var_name)
def config_clear(
self,
var_name: str | None = None,
force: bool = False,
) -> None:
"""Clear default value(s) for this module."""
return config_clear(self, var_name, force)
def config_list(self) -> None:
"""Display the defaults for this specific module in YAML format."""
return config_list(self)
@classmethod
def register_cli(cls, app: Typer) -> None:
"""Register module commands with the main app."""
logger.debug(f"Registering CLI commands for module '{cls.name}'")
module_instance = cls()
module_app = Typer(help=cls.description)
module_app.command("list")(module_instance.list)
module_app.command("search")(module_instance.search)
module_app.command("show")(module_instance.show)
module_app.command("validate")(module_instance.validate)
module_app.command(
"generate",
context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
)(module_instance.generate)
# Add defaults commands (simplified - only manage default values)
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("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")
app.add_typer(
module_app,
name=cls.name,
help=cls.description,
rich_help_panel="Template Commands",
)
logger.info(f"Module '{cls.name}' CLI commands registered")
================================================
FILE: cli/core/module/config_commands.py
================================================
"""Config/defaults management commands for module."""
from __future__ import annotations
import logging
from typer import Exit
from cli.core.config import ConfigManager
from cli.core.input import InputManager
logger = logging.getLogger(__name__)
def config_get(module_instance, var_name: str | None = None) -> None:
"""Get default value(s) for this module."""
config = ConfigManager()
if var_name:
# Get specific variable default
value = config.get_default_value(module_instance.name, var_name)
if value is not None:
module_instance.display.info(f"[green]{var_name}[/green] = [yellow]{value}[/yellow]")
else:
module_instance.display.warning(
f"No default set for variable '{var_name}'",
context=f"module '{module_instance.name}'",
)
else:
# Show all defaults (flat list)
defaults = config.get_defaults(module_instance.name)
if defaults:
module_instance.display.info(f"[bold]Config defaults for module '{module_instance.name}':[/bold]")
for config_var_name, var_value in defaults.items():
module_instance.display.info(f" [green]{config_var_name}[/green] = [yellow]{var_value}[/yellow]")
else:
module_instance.display.warning(f"No defaults configured for module '{module_instance.name}'")
def config_set(module_instance, var_name: str, value: str | None = None) -> None:
"""Set a default value for a variable."""
config = ConfigManager()
# Parse var_name and value - support both "var value" and "var=value" formats
if "=" in var_name and value is None:
# Format: var_name=value
parts = var_name.split("=", 1)
actual_var_name = parts[0]
actual_value = parts[1]
elif value is not None:
# Format: var_name value
actual_var_name = var_name
actual_value = value
else:
module_instance.display.error(f"Missing value for variable '{var_name}'", context="config set")
module_instance.display.info("[dim]Usage: defaults set VAR_NAME VALUE or defaults set VAR_NAME=VALUE[/dim]")
raise Exit(code=1)
# Set the default value
config.set_default_value(module_instance.name, actual_var_name, actual_value)
module_instance.display.success(f"Set default: [cyan]{actual_var_name}[/cyan] = [yellow]{actual_value}[/yellow]")
module_instance.display.info(
"[dim]This will be used as the default value when generating templates with this module.[/dim]"
)
def config_remove(module_instance, var_name: str) -> None:
"""Remove a specific default variable value."""
config = ConfigManager()
defaults = config.get_defaults(module_instance.name)
if not defaults:
module_instance.display.warning(f"No defaults configured for module '{module_instance.name}'")
return
if var_name in defaults:
del defaults[var_name]
config.set_defaults(module_instance.name, defaults)
module_instance.display.success(f"Removed default for '{var_name}'")
else:
module_instance.display.error(f"No default found for variable '{var_name}'")
def config_clear(module_instance, var_name: str | None = None, force: bool = False) -> None:
"""Clear default value(s) for this module."""
config = ConfigManager()
defaults = config.get_defaults(module_instance.name)
if not defaults:
module_instance.display.warning(f"No defaults configured for module '{module_instance.name}'")
return
if var_name:
# Clear specific variable
if var_name in defaults:
del defaults[var_name]
config.set_defaults(module_instance.name, defaults)
module_instance.display.success(f"Cleared default for '{var_name}'")
else:
module_instance.display.error(f"No default found for variable '{var_name}'")
else:
# Clear all defaults
if not force:
detail_lines = [
f"This will clear ALL defaults for module '{module_instance.name}':",
"",
]
for clear_var_name, var_value in defaults.items():
detail_lines.append(f" [green]{clear_var_name}[/green] = [yellow]{var_value}[/yellow]")
module_instance.display.warning("Warning: This will clear ALL defaults")
module_instance.display.info("")
for line in detail_lines:
module_instance.display.info(line)
module_instance.display.info("")
input_mgr = InputManager()
if not input_mgr.confirm("Are you sure?", default=False):
module_instance.display.info("[green]Operation cancelled.[/green]")
return
config.clear_defaults(module_instance.name)
module_instance.display.success(f"Cleared all defaults for module '{module_instance.name}'")
def config_list(module_instance) -> None:
"""Display the defaults for this specific module as a table."""
config = ConfigManager()
# Get only the defaults for this module
defaults = config.get_defaults(module_instance.name)
if not defaults:
module_instance.display.warning(f"No defaults configured for module '{module_instance.name}'")
return
# Display defaults using DisplayManager
module_instance.display.heading(f"Defaults for module '{module_instance.name}':")
# Convert defaults to display format (rows for table)
rows = [(f"{var_name}:", str(var_value)) for var_name, var_value in defaults.items()]
module_instance.display.table(headers=None, rows=rows, title="", show_header=False, borderless=True)
================================================
FILE: cli/core/module/helpers.py
================================================
"""Helper methods for module variable application and template generation."""
from __future__ import annotations
import logging
from pathlib import Path
from typing import Any
import click
import yaml
from typer import Exit
from ..display import DisplayManager
from ..input import PromptHandler
logger = logging.getLogger(__name__)
def parse_var_inputs(var_options: list[str], extra_args: list[str]) -> dict[str, Any]:
"""Parse variable inputs from --var options and extra args with type conversion.
Supports formats:
--var KEY=VALUE
--var KEY VALUE
Values are automatically converted to appropriate types:
- 'true', 'yes', '1' → True
- 'false', 'no', '0' → False
- Numeric strings → int or float
- Everything else → string
Args:
var_options: List of variable options from CLI
extra_args: Additional arguments that may contain values
Returns:
Dictionary of parsed variables with converted types
"""
variables = {}
# Parse --var KEY=VALUE format
for var_option in var_options:
if "=" in var_option:
key, value = var_option.split("=", 1)
variables[key] = _convert_string_to_type(value)
# --var KEY VALUE format - value should be in extra_args
elif extra_args:
value = extra_args.pop(0)
variables[var_option] = _convert_string_to_type(value)
else:
logger.warning(f"No value provided for variable '{var_option}'")
return variables
def _convert_string_to_type(value: str) -> Any:
"""Convert string value to appropriate Python type.
Args:
value: String value to convert
Returns:
Converted value (bool, int, float, or str)
"""
# Boolean conversion
if value.lower() in ("true", "yes", "1"):
return True
if value.lower() in ("false", "no", "0"):
return False
# Integer conversion
try:
return int(value)
except ValueError:
pass
# Float conversion
try:
return float(value)
except ValueError:
pass
# Return as string
return value
def load_var_file(var_file_path: str) -> dict:
"""Load variables from a YAML file.
Args:
var_file_path: Path to the YAML file containing variables
Returns:
Dictionary of variable names to values (flat structure)
Raises:
FileNotFoundError: If the var file doesn't exist
ValueError: If the file is not valid YAML or has invalid structure
"""
var_path = Path(var_file_path).expanduser().resolve()
if not var_path.exists():
raise FileNotFoundError(f"Variable file not found: {var_file_path}")
if not var_path.is_file():
raise ValueError(f"Variable file path is not a file: {var_file_path}")
try:
with var_path.open(encoding="utf-8") as f:
content = yaml.safe_load(f)
except yaml.YAMLError as e:
raise ValueError(f"Invalid YAML in variable file: {e}") from e
except OSError as e:
raise ValueError(f"Error reading variable file: {e}") from e
if not isinstance(content, dict):
raise ValueError(f"Variable file must contain a YAML dictionary, got {type(content).__name__}")
logger.info(f"Loaded {len(content)} variables from file: {var_path.name}")
logger.debug(f"Variables from file: {', '.join(content.keys())}")
return content
def apply_variable_defaults(template, config_manager, module_name: str) -> None:
"""Apply config defaults to template variables.
Args:
template: Template instance with variables to configure
config_manager: ConfigManager instance
module_name: Name of the module
"""
if not template.variables:
return
config_defaults = config_manager.get_defaults(module_name)
if config_defaults:
logger.info(f"Loading config defaults for module '{module_name}'")
successful = template.variables.apply_defaults(config_defaults, "config")
if successful:
logger.debug(f"Applied config defaults for: {', '.join(successful)}")
def apply_var_file(template, var_file_path: str | None, display: DisplayManager) -> None:
"""Apply variables from a YAML file to template.
Args:
template: Template instance to apply variables to
var_file_path: Path to the YAML file containing variables
display: DisplayManager for error messages
Raises:
Exit: If the file cannot be loaded or contains invalid data
"""
if not var_file_path or not template.variables:
return
try:
var_file_vars = load_var_file(var_file_path)
if var_file_vars:
# Get list of valid variable names from template
valid_vars = set()
for section in template.variables.get_sections().values():
valid_vars.update(section.variables.keys())
# Warn about unknown variables
unknown_vars = set(var_file_vars.keys()) - valid_vars
if unknown_vars:
for var_name in sorted(unknown_vars):
logger.warning(f"Variable '{var_name}' from var-file does not exist in template '{template.id}'")
successful = template.variables.apply_defaults(var_file_vars, "var-file")
if successful:
logger.debug(f"Applied var-file overrides for: {', '.join(successful)}")
except (FileNotFoundError, ValueError) as e:
display.error(
f"Failed to load variable file: {e}",
context="variable file loading",
)
raise Exit(code=1) from e
def apply_cli_overrides(template, var: list[str] | None, ctx=None) -> None:
"""Apply CLI variable overrides to template.
Args:
template: Template instance to apply overrides to
var: List of variable override strings from --var flags
ctx: Context object containing extra args (optional, will get current context if None)
"""
if not template.variables:
return
# Get context if not provided (compatible with all Typer versions)
if ctx is None:
try:
ctx = click.get_current_context()
except RuntimeError:
ctx = None
extra_args = list(ctx.args) if ctx and hasattr(ctx, "args") else []
cli_overrides = parse_var_inputs(var or [], extra_args)
if cli_overrides:
logger.info(f"Received {len(cli_overrides)} variable overrides from CLI")
successful_overrides = template.variables.apply_defaults(cli_overrides, "cli")
if successful_overrides:
logger.debug(f"Applied CLI overrides for: {', '.join(successful_overrides)}")
def collect_variable_values(template, interactive: bool) -> dict[str, Any]:
"""Collect variable values from user prompts and template defaults.
Args:
template: Template instance with variables
interactive: Whether to prompt user for values interactively
Returns:
Dictionary of variable names to values
"""
variable_values = {}
# Collect values interactively if enabled
if interactive and template.variables:
prompt_handler = PromptHandler()
collected_values = prompt_handler.collect_variables(template.variables)
if collected_values:
variable_values.update(collected_values)
logger.info(f"Collected {len(collected_values)} variable values from user input")
# Add satisfied variable values (respects dependencies and toggles)
if template.variables:
variable_values.update(template.variables.get_satisfied_values())
return variable_values
================================================
FILE: cli/core/prompt.py
================================================
from __future__ import annotations
import logging
from typing import Any, Callable
from rich.console import Console
from rich.prompt import Confirm, IntPrompt, Prompt
from .display import DisplayManager
from .template.variable import Variable
from .template.variable_collection import VariableCollection
logger = logging.getLogger(__name__)
class PromptHandler:
"""Simple interactive prompt handler for collecting template variables."""
def __init__(self) -> None:
self.console = Console()
self.display = DisplayManager()
def collect_variables(self, variables: VariableCollection) -> dict[str, Any]:
"""Collect values for variables by iterating through sections.
Args:
variables: VariableCollection with organized sections and variables
Returns:
Dict of variable names to collected values
"""
if not Confirm.ask("Customize any settings?", default=False):
self.console.print("") # Add blank line after prompt
logger.info("User opted to keep all default values")
return {}
self.console.print("") # Add blank line after prompt
collected: dict[str, Any] = {}
# Process each section
for section_key, section in variables.get_sections().items():
if not section.variables:
continue
# Check if dependencies are satisfied
if not self._check_section_dependencies(variables, section_key, section):
continue
# Always show section header first
self.display.display_section_header(section.title, section.description)
# Handle section toggle and determine if enabled
section_will_be_enabled = self._handle_section_toggle(section, collected)
# Collect variables in this section
self._collect_section_variables(section, section_key, section_will_be_enabled, variables, collected)
logger.info(f"Variable collection completed. Collected {len(collected)} values")
return collected
def _check_section_dependencies(self, variables: VariableCollection, section_key: str, section) -> bool:
"""Check if section dependencies are satisfied and display skip message if not."""
if not variables.is_section_satisfied(section_key):
# Get list of unsatisfied dependencies for better user feedback
unsatisfied_keys = [dep for dep in section.needs if not variables.is_section_satisfied(dep)]
# Convert section keys to titles for user-friendly display
unsatisfied_titles = []
for dep_key in unsatisfied_keys:
dep_section = variables.get_section(dep_key)
unsatisfied_titles.append(dep_section.title if dep_section else dep_key)
dep_names = ", ".join(unsatisfied_titles) if unsatisfied_titles else "unknown"
self.display.display_skipped(section.title, f"requires {dep_names} to be enabled")
logger.debug(f"Skipping section '{section_key}' - dependencies not satisfied: {dep_names}")
return False
return True
def _handle_section_toggle(self, section, collected: dict[str, Any]) -> bool:
"""Handle section toggle prompt and return whether section will be enabled."""
# Handle sections with toggle
if not section.toggle:
return True
toggle_var = section.variables.get(section.toggle)
if not toggle_var:
return True
# Prompt for toggle variable
current_value = toggle_var.convert(toggle_var.value)
new_value = self._prompt_variable(toggle_var, required=False)
if new_value != current_value:
collected[toggle_var.name] = new_value
toggle_var.value = new_value
# Return whether section is enabled
return section.is_enabled()
def _collect_section_variables(
self,
section,
section_key: str,
section_enabled: bool,
variables: VariableCollection,
collected: dict[str, Any],
) -> None:
"""Collect values for all variables in a section."""
for var_name, variable in section.variables.items():
# Skip toggle variable (already handled)
if section.toggle and var_name == section.toggle:
continue
# Skip variables with unsatisfied needs
if not variables.is_variable_satisfied(var_name):
logger.debug(f"Skipping variable '{var_name}' - needs not satisfied")
continue
# Skip all variables if section is disabled
if not section_enabled:
logger.debug(f"Skipping variable '{var_name}' from disabled section '{section_key}'")
continue
# Prompt for the variable and update if changed
self._prompt_and_update_variable(variable, collected)
def _prompt_and_update_variable(self, variable: Variable, collected: dict[str, Any]) -> None:
"""Prompt for a variable and update collected values if changed."""
current_value = variable.convert(variable.value)
new_value = self._prompt_variable(variable, required=False)
# For autogenerated variables, always update even if None (signals autogeneration)
if variable.autogenerated and new_value is None:
collected[variable.name] = None
variable.value = None
elif new_value != current_value:
collected[variable.name] = new_value
variable.value = new_value
def _prompt_variable(self, variable: Variable, _required: bool = False) -> Any:
"""Prompt for a single variable value based on its type.
Args:
variable: The variable to prompt for
_required: Whether the containing section is required (unused, kept for API compatibility)
Returns:
The validated value entered by the user
"""
logger.debug(f"Prompting for variable '{variable.name}' (type: {variable.type})")
# Use variable's native methods for prompt text and default value
prompt_text = variable.get_prompt_text()
default_value = variable.get_normalized_default()
# Add lock icon before default value for sensitive or autogenerated variables
if variable.sensitive or variable.autogenerated:
# Format: "Prompt text 🔒 (default)"
# The lock icon goes between the text and the default value in parentheses
prompt_text = f"{prompt_text} {self.display.get_lock_icon()}"
# Check if this specific variable is required (has no default and not autogenerated)
var_is_required = variable.is_required()
# If variable is required, mark it in the prompt
if var_is_required:
prompt_text = f"{prompt_text} [bold red]*required[/bold red]"
handler = self._get_prompt_handler(variable)
# Add validation hint (includes both extra text and enum options)
hint = variable.get_validation_hint()
if hint:
# Show options/extra inline inside parentheses, before the default
prompt_text = f"{prompt_text} [dim]({hint})[/dim]"
while True:
try:
raw = handler(prompt_text, default_value)
# Use Variable's centralized validation method that handles:
# - Type conversion
# - Autogenerated variable detection
# - Required field validation
return variable.validate_and_convert(raw, check_required=True)
# Return the converted value (caller will update variable.value)
except ValueError as exc:
# Conversion/validation failed — show a consistent error message and retry
self._show_validation_error(str(exc))
except Exception as e:
# Unexpected error — log and retry using the stored (unconverted) value
logger.error(f"Error prompting for variable '{variable.name}': {e!s}")
default_value = variable.value
handler = self._get_prompt_handler(variable)
def _get_prompt_handler(self, variable: Variable) -> Callable:
"""Return the prompt function for a variable type."""
handlers = {
"bool": self._prompt_bool,
"int": self._prompt_int,
# For enum prompts we pass the variable.extra through so options and extra
# can be combined into a single inline hint.
"enum": lambda text, default: self._prompt_enum(
text,
variable.options or [],
default,
extra=getattr(variable, "extra", None),
),
}
return handlers.get(
variable.type,
lambda text, default: self._prompt_string(text, default, is_sensitive=variable.sensitive),
)
def _show_validation_error(self, message: str) -> None:
"""Display validation feedback consistently."""
self.display.display_validation_error(message)
def _prompt_string(self, prompt_text: str, default: Any = None, is_sensitive: bool = False) -> str | None:
value = Prompt.ask(
prompt_text,
default=str(default) if default is not None else "",
show_default=True,
password=is_sensitive,
)
stripped = value.strip() if value else None
return stripped if stripped else None
def _prompt_bool(self, prompt_text: str, default: Any = None) -> bool | None:
if default is None:
return Confirm.ask(prompt_text, default=None)
converted = default if isinstance(default, bool) else str(default).lower() in ("true", "1", "yes", "on")
return Confirm.ask(prompt_text, default=converted)
def _prompt_int(self, prompt_text: str, default: Any = None) -> int | None:
converted = None
if default is not None:
try:
converted = int(default)
except (ValueError, TypeError):
logger.warning(f"Invalid default integer value: {default}")
return IntPrompt.ask(prompt_text, default=converted)
def _prompt_enum(
self,
prompt_text: str,
options: list[str],
default: Any = None,
_extra: str | None = None,
) -> str:
"""Prompt for enum selection with validation.
Note: prompt_text should already include hint from variable.get_validation_hint()
but we keep this for backward compatibility and fallback.
"""
if not options:
return self._prompt_string(prompt_text, default)
# Validate default is in options
if default and str(default) not in options:
default = options[0]
while True:
value = Prompt.ask(
prompt_text,
default=str(default) if default else options[0],
show_default=True,
)
if value in options:
return value
self.console.print(f"[red]Invalid choice. Select from: {', '.join(options)}[/red]")
================================================
FILE: cli/core/registry.py
================================================
"""Module registry system."""
from __future__ import annotations
import logging
from collections.abc import Iterator
logger = logging.getLogger(__name__)
class ModuleRegistry:
"""Simple module registry without magic."""
def __init__(self) -> None:
self._modules = {}
logger.debug("Initializing module registry")
def register(self, module_class: type) -> None:
"""Register a module class."""
# Module class defines its own name attribute
logger.debug(f"Attempting to register module class '{module_class.name}'")
if module_class.name in self._modules:
logger.warning(f"Module '{module_class.name}' already registered, replacing with new implementation")
self._modules[module_class.name] = module_class
logger.info(f"Registered module '{module_class.name}' (total modules: {len(self._modules)})")
logger.debug(f"Module '{module_class.name}' details: description='{module_class.description}'")
def iter_module_classes(self) -> Iterator[tuple[str, type]]:
"""Yield registered module classes without instantiating them."""
logger.debug(f"Iterating over {len(self._modules)} registered module classes")
for name in sorted(self._modules.keys()):
yield name, self._modules[name]
# Global registry
registry = ModuleRegistry()
================================================
FILE: cli/core/repo.py
================================================
"""Repository management module for syncing library repositories."""
from __future__ import annotations
import logging
import shutil
import subprocess
from pathlib import Path
from rich.progress import SpinnerColumn, TextColumn
from rich.table import Table
from typer import Argument, Option, Typer
from ..core.config import ConfigManager, LibraryConfig
from ..core.display import DisplayManager, IconManager
from ..core.exceptions import ConfigError
logger = logging.getLogger(__name__)
display = DisplayManager()
app = Typer(help="Manage library repositories")
def _run_git_command(args: list[str], cwd: Path | None = None) -> tuple[bool, str, str]:
"""Run a git command and return the result.
Args:
args: Git command arguments (without 'git' prefix)
cwd: Working directory for the command
Returns:
Tuple of (success, stdout, stderr)
"""
try:
result = subprocess.run(
["git", *args],
check=False,
cwd=cwd,
capture_output=True,
text=True,
timeout=300, # 5 minute timeout
)
return result.returncode == 0, result.stdout, result.stderr
except subprocess.TimeoutExpired:
return False, "", "Command timed out after 5 minutes"
except FileNotFoundError:
return False, "", "Git command not found. Please install git."
except Exception as e:
return False, "", str(e)
def _clone_or_pull_repo(
name: str,
url: str,
target_path: Path,
branch: str | None = None,
sparse_dir: str | None = None,
) -> tuple[bool, str]:
"""Clone or pull a git repository with optional sparse-checkout.
Args:
name: Library name
url: Git repository URL
target_path: Target directory for the repository
branch: Git branch to clone/pull (optional)
sparse_dir: Directory to sparse-checkout (optional, use None or "." for full clone)
Returns:
Tuple of (success, message)
"""
if target_path.exists() and (target_path / ".git").exists():
return _pull_repo_updates(name, target_path, branch)
return _clone_new_repo(name, url, target_path, branch, sparse_dir)
def _pull_repo_updates(name: str, target_path: Path, branch: str | None) -> tuple[bool, str]:
"""Pull updates for an existing repository."""
logger.debug(f"Pulling updates for library '{name}' at {target_path}")
pull_branch = branch if branch else "main"
success, stdout, stderr = _run_git_command(["pull", "--ff-only", "origin", pull_branch], cwd=target_path)
if not success:
error_msg = stderr or stdout
logger.error(f"Failed to pull library '{name}': {error_msg}")
return False, f"Pull failed: {error_msg}"
if "Already up to date" in stdout or "Already up-to-date" in stdout:
return True, "Already up to date"
return True, "Updated successfully"
def _clone_new_repo(
name: str, url: str, target_path: Path, branch: str | None, sparse_dir: str | None
) -> tuple[bool, str]:
"""Clone a new repository, optionally with sparse-checkout."""
logger.debug(f"Cloning library '{name}' from {url} to {target_path}")
target_path.parent.mkdir(parents=True, exist_ok=True)
use_sparse = sparse_dir and sparse_dir != "."
if use_sparse:
return _clone_sparse_repo(url, target_path, branch, sparse_dir)
return _clone_full_repo(name, url, target_path, branch)
def _clone_sparse_repo(url: str, target_path: Path, branch: str | None, sparse_dir: str) -> tuple[bool, str]:
"""Clone repository with sparse-checkout."""
logger.debug(f"Using sparse-checkout for directory: {sparse_dir}")
target_path.mkdir(parents=True, exist_ok=True)
# Define git operations to perform sequentially
operations = [
(["init"], "Failed to initialize repo"),
(["remote", "add", "origin", url], "Failed to add remote"),
(["sparse-checkout", "init", "--no-cone"], "Failed to enable sparse-checkout"),
(
["sparse-checkout", "set", f"{sparse_dir}/*"],
"Failed to set sparse-checkout directory",
),
]
# Execute initial operations
for cmd, error_msg in operations:
success, stdout, stderr = _run_git_command(cmd, cwd=target_path)
if not success:
return False, f"{error_msg}: {stderr or stdout}"
# Fetch and checkout
fetch_branch = branch if branch else "main"
success, stdout, stderr = _run_git_command(["fetch", "--depth", "1", "origin", fetch_branch], cwd=target_path)
if not success:
return False, f"Fetch failed: {stderr or stdout}"
success, stdout, stderr = _run_git_command(["checkout", fetch_branch], cwd=target_path)
result_success = success
result_msg = "Cloned successfully (sparse)" if success else f"Checkout failed: {stderr or stdout}"
return result_success, result_msg
def _clone_full_repo(name: str, url: str, target_path: Path, branch: str | None) -> tuple[bool, str]:
"""Clone full repository."""
clone_args = ["clone", "--depth", "1"]
if branch:
clone_args.extend(["--branch", branch])
clone_args.extend([url, str(target_path)])
success, stdout, stderr = _run_git_command(clone_args)
if success:
return True, "Cloned successfully"
error_msg = stderr or stdout
logger.error(f"Failed to clone library '{name}': {error_msg}")
return False, f"Clone failed: {error_msg}"
def _process_library_update(lib: dict, libraries_path: Path, progress, verbose: bool) -> tuple[str, str, bool]:
"""Process a single library update and return result."""
name = lib.get("name")
lib_type = lib.get("type", "git")
enabled = lib.get("enabled", True)
if not enabled:
if verbose:
display.text(f"Skipping disabled library: {name}", style="dim")
return (name, "Skipped (disabled)", False)
if lib_type == "static":
if verbose:
display.text(f"Skipping static library: {name} (no sync needed)", style="dim")
return (name, "N/A (static)", True)
# Handle git libraries
url = lib.get("url")
branch = lib.get("branch")
directory = lib.get("directory", "library")
task = progress.add_task(f"Updating {name}...", total=None)
target_path = libraries_path / name
success, message = _clone_or_pull_repo(name, url, target_path, branch, directory)
progress.remove_task(task)
if verbose:
if success:
display.success(f"{name}: {message}")
else:
display.error(f"{name}: {message}")
return (name, message, success)
def _display_update_summary(results: list[tuple[str, str, bool]]) -> None:
"""Display update summary."""
total = len(results)
successful = sum(1 for _, _, success in results if success)
display.text("")
if successful == total:
display.text(f"All libraries updated successfully ({successful}/{total})", style="green")
elif successful > 0:
display.text(
f"Partially successful: {successful}/{total} libraries updated",
style="yellow",
)
else:
display.text("Failed to update libraries", style="red")
@app.command()
def update(
library_name: str | None = Argument(None, help="Name of specific library to update (updates all if not specified)"),
verbose: bool = Option(False, "--verbose", "-v", help="Show detailed output"),
) -> None:
"""Update library repositories by cloning or pulling from git.
This command syncs all configured libraries from their git repositories.
If a library doesn't exist locally, it will be cloned. If it exists, it will be pulled.
"""
config = ConfigManager()
libraries = config.get_libraries()
if not libraries:
display.warning("No libraries configured")
display.text("Libraries are auto-configured on first run with a default library.")
return
# Filter to specific library if requested
if library_name:
libraries = [lib for lib in libraries if lib.get("name") == library_name]
if not libraries:
display.error(f"Library '{library_name}' not found in configuration")
return
libraries_path = config.get_libraries_path()
results = []
with display.progress(SpinnerColumn(), TextColumn("[progress.description]{task.description}")) as progress:
for lib in libraries:
result = _process_library_update(lib, libraries_path, progress, verbose)
results.append(result)
# Display summary table
if not verbose:
display.display_status_table("Library Update Summary", results, columns=("Library", "Status"))
_display_update_summary(results)
def _get_library_path_for_git(lib: dict, libraries_path: Path, name: str) -> Path:
"""Get library path for git library type."""
directory = lib.get("directory", "library")
library_base = libraries_path / name
if directory and directory != ".":
return library_base / directory
return library_base
def _get_library_path_for_static(lib: dict, config: ConfigManager) -> Path:
"""Get library path for static library type."""
url_or_path = lib.get("path", "")
library_path = Path(url_or_path).expanduser()
if not library_path.is_absolute():
library_path = (config.config_path.parent / library_path).resolve()
return library_path
def _get_library_info(lib: dict, config: ConfigManager, libraries_path: Path) -> tuple[str, str, str, str, str, str]:
"""Extract library information based on type."""
name = lib.get("name", "")
lib_type = lib.get("type", "git")
enabled = lib.get("enabled", True)
if lib_type == "git":
url_or_path = lib.get("url", "")
branch = lib.get("branch", "main")
directory = lib.get("directory", "library")
library_path = _get_library_path_for_git(lib, libraries_path, name)
exists = library_path.exists()
type_icon = IconManager.UI_LIBRARY_GIT
elif lib_type == "static":
url_or_path = lib.get("path", "")
branch = "-"
directory = "-"
library_path = _get_library_path_for_static(lib, config)
exists = library_path.exists()
type_icon = IconManager.UI_LIBRARY_STATIC
else:
# Unknown type
url_or_path = ""
branch = "-"
directory = "-"
exists = False
type_icon = "?"
# Build status string
status_parts = []
if not enabled:
status_parts.append("[dim]disabled[/dim]")
elif exists:
status_parts.append("[green]available[/green]")
else:
status_parts.append("[yellow]not found[/yellow]")
status = " ".join(status_parts)
type_display = f"{type_icon} {lib_type}"
return url_or_path, branch, directory, type_display, type_icon, status
@app.command()
def list() -> None:
"""List all configured libraries."""
config = ConfigManager()
libraries = config.get_libraries()
if not libraries:
display.text("No libraries configured.", style="yellow")
return
settings = display.settings
table = Table(
title="Configured Libraries",
show_header=True,
header_style=settings.STYLE_TABLE_HEADER,
)
table.add_column("Name", style="cyan", no_wrap=True)
table.add_column("URL/Path", style="blue")
table.add_column("Branch", style="yellow")
table.add_column("Directory", style="magenta")
table.add_column("Type", style="cyan")
table.add_column("Status", style="green")
libraries_path = config.get_libraries_path()
for lib in libraries:
name = lib.get("name", "")
url_or_path, branch, directory, type_display, _type_icon, status = _get_library_info(
lib, config, libraries_path
)
table.add_row(name, url_or_path, branch, directory, type_display, status)
display.print_table(table)
@app.command()
def add(
name: str = Argument(..., help="Unique name for the library"),
*,
library_type: str | None = None,
url: str | None = None,
branch: str = "main",
directory: str = "library",
path: str | None = None,
enabled: bool = Option(True, "--enabled/--disabled", help="Enable or disable the library"),
sync: bool = Option(True, "--sync/--no-sync", help="Sync after adding (git only)"),
) -> None:
"""Add a new library to the configuration.
Examples:
# Add a git library
repo add mylib --type git --url https://github.com/user/templates.git
# Add a static library
repo add local --type static --path ~/my-templates
"""
config = ConfigManager()
try:
if library_type == "git":
if not url:
display.error("--url is required for git libraries")
return
lib_config = LibraryConfig(
name=name,
library_type="git",
url=url,
branch=branch,
directory=directory,
enabled=enabled,
)
elif library_type == "static":
if not path:
display.error("--path is required for static libraries")
return
lib_config = LibraryConfig(
name=name,
library_type="static",
path=path,
enabled=enabled,
)
else:
display.error(f"Invalid library type: {library_type}. Must be 'git' or 'static'.")
return
config.add_library(lib_config)
display.success(f"Added {library_type} library '{name}'")
if library_type == "git" and sync and enabled:
display.text(f"\nSyncing library '{name}'...")
update(library_name=name, verbose=True)
elif library_type == "static":
display.info(f"Static library points to: {path}")
except ConfigError as e:
display.error(str(e))
@app.command()
def remove(
name: str = Argument(..., help="Name of the library to remove"),
keep_files: bool = Option(False, "--keep-files", help="Keep the local library files (don't delete)"),
) -> None:
"""Remove a library from the configuration and delete its local files."""
config = ConfigManager()
try:
# Remove from config
config.remove_library(name)
display.success(f"Removed library '{name}' from configuration")
# Delete local files unless --keep-files is specified
if not keep_files:
libraries_path = config.get_libraries_path()
library_path = libraries_path / name
if library_path.exists():
shutil.rmtree(library_path)
display.success(f"Deleted local files at {library_path}")
else:
display.info(f"No local files found at {library_path}")
except ConfigError as e:
display.error(str(e))
# Register the repo command with the CLI
def register_cli(parent_app: Typer) -> None:
"""Register the repo command with the parent Typer app."""
parent_app.add_typer(app, name="repo", rich_help_panel="Configuration Commands")
================================================
FILE: cli/core/template/__init__.py
================================================
"""Template package for template and variable management.
This package provides Template, VariableCollection, VariableSection, and Variable
classes for managing templates and their variables.
"""
from .template import (
TEMPLATE_STATUS_DRAFT,
TEMPLATE_STATUS_INVALID,
TEMPLATE_STATUS_PUBLISHED,
Template,
TemplateErrorHandler,
TemplateFile,
TemplateMetadata,
)
from .variable import Variable
from .variable_collection import VariableCollection
from .variable_section import VariableSection
__all__ = [
"TEMPLATE_STATUS_DRAFT",
"TEMPLATE_STATUS_INVALID",
"TEMPLATE_STATUS_PUBLISHED",
"Template",
"TemplateErrorHandler",
"TemplateFile",
"TemplateMetadata",
"Variable",
"VariableCollection",
"VariableSection",
]
================================================
FILE: cli/core/template/template.py
================================================
from __future__ import annotations
import base64
import logging
import os
import re
import secrets
import string
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Literal
import yaml
from jinja2 import Environment, FileSystemLoader, meta
from jinja2.exceptions import (
TemplateError as Jinja2TemplateError,
)
from jinja2.exceptions import (
TemplateNotFound as Jinja2TemplateNotFound,
)
from jinja2.exceptions import (
TemplateSyntaxError as Jinja2TemplateSyntaxError,
)
from jinja2.exceptions import (
UndefinedError,
)
from jinja2.sandbox import SandboxedEnvironment
from ..exceptions import (
RenderErrorContext,
TemplateLoadError,
TemplateRenderError,
TemplateSyntaxError,
TemplateValidationError,
YAMLParseError,
)
from .variable_collection import VariableCollection
logger = logging.getLogger(__name__)
# Template Status Constants
TEMPLATE_STATUS_PUBLISHED = "published"
TEMPLATE_STATUS_DRAFT = "draft"
TEMPLATE_STATUS_INVALID = "invalid"
class TemplateErrorHandler:
"""Handles parsing and formatting of template rendering errors.
This class provides utilities for:
- Extracting error context from template files
- Generating helpful suggestions based on Jinja2 errors
- Parsing Jinja2 exceptions into structured error information
"""
@staticmethod
def extract_error_context(file_path: Path, line_number: int | None, context_size: int = 3) -> list[str]:
"""Extract lines of context around an error location.
Args:
file_path: Path to the file with the error
line_number: Line number where error occurred (1-indexed)
context_size: Number of lines to show before and after
Returns:
List of context lines with line numbers
"""
if not line_number or not file_path.exists():
return []
try:
with file_path.open(encoding="utf-8") as f:
lines = f.readlines()
start_line = max(0, line_number - context_size - 1)
end_line = min(len(lines), line_number + context_size)
context = []
for i in range(start_line, end_line):
line_num = i + 1
marker = ">>>" if line_num == line_number else " "
context.append(f"{marker} {line_num:4d} | {lines[i].rstrip()}")
return context
except OSError:
return []
@staticmethod
def get_common_jinja_suggestions(error_msg: str, available_vars: set) -> list[str]:
"""Generate helpful suggestions based on common Jinja2 errors.
Args:
error_msg: The error message from Jinja2
available_vars: Set of available variable names
Returns:
List of actionable suggestions
"""
suggestions = []
error_lower = error_msg.lower()
# Undefined variable errors
if "undefined" in error_lower or "is not defined" in error_lower:
# Try to extract variable name from error message
var_match = re.search(r"'([^']+)'.*is undefined", error_msg)
if not var_match:
var_match = re.search(r"'([^']+)'.*is not defined", error_msg)
if var_match:
undefined_var = var_match.group(1)
suggestions.append(f"Variable '{undefined_var}' is not defined in the template spec")
# Suggest similar variable names (basic fuzzy matching)
similar = [
v
for v in available_vars
if undefined_var.lower() in v.lower() or v.lower() in undefined_var.lower()
]
if similar:
suggestions.append(f"Did you mean one of these? {', '.join(sorted(similar)[:5])}")
suggestions.append(f"Add '{undefined_var}' to your template.yaml spec with a default value")
suggestions.append("Or use the Jinja2 default filter: {{ " + undefined_var + " | default('value') }}")
else:
suggestions.append("Check that all variables used in templates are defined in template.yaml")
suggestions.append("Use the Jinja2 default filter for optional variables: {{ var | default('value') }}")
# Syntax errors
elif "unexpected" in error_lower or "expected" in error_lower:
suggestions.append("Check for syntax errors in your Jinja2 template")
suggestions.append("Common issues: missing {% endfor %}, {% endif %}, or {% endblock %}")
suggestions.append("Make sure all {{ }} and {% %} tags are properly closed")
# Filter errors
elif "filter" in error_lower:
suggestions.append("Check that the filter name is spelled correctly")
suggestions.append("Verify the filter exists in Jinja2 built-in filters")
suggestions.append("Make sure filter arguments are properly formatted")
# Template not found
elif "not found" in error_lower or "does not exist" in error_lower:
suggestions.append("Check that the included/imported template file exists")
suggestions.append("Verify the template path is relative to the template directory")
suggestions.append("Make sure the file has the .j2 extension if it's a Jinja2 template")
# Type errors
elif "type" in error_lower and ("int" in error_lower or "str" in error_lower or "bool" in error_lower):
suggestions.append("Check that variable values have the correct type")
suggestions.append("Use Jinja2 filters to convert types: {{ var | int }}, {{ var | string }}")
# Add generic helpful tip
if not suggestions:
suggestions.append("Check the Jinja2 template syntax and variable usage")
suggestions.append("Enable --debug mode for more detailed rendering information")
return suggestions
@classmethod
def parse_jinja_error(
cls,
error: Exception,
template_file: TemplateFile,
template_dir: Path,
available_vars: set,
) -> tuple[str, int | None, int | None, list[str], list[str]]:
"""Parse a Jinja2 exception to extract detailed error information.
Args:
error: The Jinja2 exception
template_file: The TemplateFile being rendered
template_dir: Template directory path
available_vars: Set of available variable names
Returns:
Tuple of (error_message, line_number, column, context_lines, suggestions)
"""
error_msg = str(error)
line_number = None
column = None
context_lines = []
suggestions = []
# Extract line number from Jinja2 errors
if hasattr(error, "lineno"):
line_number = error.lineno
# Extract file path and get context
file_path = template_dir / template_file.relative_path
if line_number and file_path.exists():
context_lines = cls.extract_error_context(file_path, line_number)
# Generate suggestions based on error type
if isinstance(error, UndefinedError):
error_msg = f"Undefined variable: {error}"
suggestions = cls.get_common_jinja_suggestions(str(error), available_vars)
elif isinstance(error, Jinja2TemplateSyntaxError):
error_msg = f"Template syntax error: {error}"
suggestions = cls.get_common_jinja_suggestions(str(error), available_vars)
elif isinstance(error, Jinja2TemplateNotFound):
error_msg = f"Template file not found: {error}"
suggestions = cls.get_common_jinja_suggestions(str(error), available_vars)
else:
# Generic Jinja2 error
suggestions = cls.get_common_jinja_suggestions(error_msg, available_vars)
return error_msg, line_number, column, context_lines, suggestions
@dataclass
class TemplateFile:
"""Represents a single file within a template directory."""
relative_path: Path
file_type: Literal["j2", "static"]
output_path: Path # The path it will have in the output directory
@dataclass
class TemplateMetadata:
"""Represents template metadata with proper typing."""
name: str
description: str
author: str
date: str
version: str
module: str = ""
tags: list[str] = field(default_factory=list)
library: str = "unknown"
library_type: str = "git" # Type of library ("git" or "static")
next_steps: str = ""
draft: bool = False
def __init__(
self,
template_data: dict,
library_name: str | None = None,
library_type: str = "git",
) -> None:
"""Initialize TemplateMetadata from parsed YAML template data.
Args:
template_data: Parsed YAML data from template.yaml
library_name: Name of the library this template belongs to
"""
# Validate metadata format first
self._validate_metadata(template_data)
# Extract metadata section
metadata_section = template_data.get("metadata", {})
self.name = metadata_section.get("name", "")
# YAML block scalar (|) preserves a trailing newline. Remove only trailing newlines
# while preserving internal newlines/formatting.
raw_description = metadata_section.get("description", "")
# TODO: remove when all templates have been migrated to markdown
description = raw_description.rstrip("\n") if isinstance(raw_description, str) else str(raw_description)
self.description = description or "No description available"
self.author = metadata_section.get("author", "")
self.date = metadata_section.get("date", "")
self.version = metadata_section.get("version", "")
self.module = metadata_section.get("module", "")
self.tags = metadata_section.get("tags", []) or []
self.library = library_name or "unknown"
self.library_type = library_type
self.draft = metadata_section.get("draft", False)
# Extract next_steps (optional)
raw_next_steps = metadata_section.get("next_steps", "")
if isinstance(raw_next_steps, str):
next_steps = raw_next_steps.rstrip("\n")
else:
next_steps = str(raw_next_steps) if raw_next_steps else ""
self.next_steps = next_steps
@staticmethod
def _validate_metadata(template_data: dict) -> None:
"""Validate that template has required 'metadata' section with all required fields.
Args:
template_data: Parsed YAML data from template.yaml
Raises:
ValueError: If metadata section is missing or incomplete
"""
metadata_section = template_data.get("metadata")
if metadata_section is None:
raise ValueError("Template format error: missing 'metadata' section")
# Validate that metadata section has all required fields
required_fields = ["name", "author", "version", "date", "description"]
missing_fields = [field for field in required_fields if not metadata_section.get(field)]
if missing_fields:
raise ValueError(f"Template format error: missing required metadata fields: {missing_fields}")
@dataclass
class Template:
"""Represents a template directory."""
def __init__(self, template_dir: Path, library_name: str, library_type: str = "git") -> None:
"""Create a Template instance from a directory path.
Args:
template_dir: Path to the template directory
library_name: Name of the library this template belongs to
library_type: Type of library ("git" or "static"), defaults to "git"
"""
logger.debug(f"Loading template from directory: {template_dir}")
self.template_dir = template_dir
self.id = template_dir.name
self.original_id = template_dir.name # Store the original ID
self.library_name = library_name
self.library_type = library_type
# Initialize caches for lazy loading
self.__jinja_env: Environment | None = None
self.__used_variables: set[str] | None = None
self.__variables: VariableCollection | None = None
self.__template_files: list[TemplateFile] | None = None # New attribute
try:
# Find and parse the main template file (template.yaml or template.yml)
main_template_path = self._find_main_template_file()
with main_template_path.open(encoding="utf-8") as f:
# Load all YAML documents (handles templates with empty lines before ---)
documents = list(yaml.safe_load_all(f))
# Filter out None/empty documents and get the first non-empty one
valid_docs = [doc for doc in documents if doc is not None]
if not valid_docs:
raise ValueError("Template file contains no valid YAML data")
if len(valid_docs) > 1:
logger.warning("Template file contains multiple YAML documents, using the first one")
self._template_data = valid_docs[0]
# Validate template data
if not isinstance(self._template_data, dict):
raise ValueError("Template file must contain a valid YAML dictionary")
# Load metadata (always needed)
self.metadata = TemplateMetadata(self._template_data, library_name, library_type)
logger.debug(f"Loaded metadata: {self.metadata}")
# Validate 'kind' field (always needed)
self._validate_kind(self._template_data)
# NOTE: File collection is now lazy-loaded via the template_files property
# This significantly improves performance when listing many templates
logger.info(f"Loaded template '{self.id}' (v{self.metadata.version})")
except (ValueError, FileNotFoundError) as e:
logger.error(f"Error loading template from {template_dir}: {e}")
raise TemplateLoadError(f"Error loading template from {template_dir}: {e}") from e
except yaml.YAMLError as e:
logger.error(f"YAML parsing error in template {template_dir}: {e}")
raise YAMLParseError(str(template_dir / "template.y*ml"), e) from e
except OSError as e:
logger.error(f"File I/O error loading template {template_dir}: {e}")
raise TemplateLoadError(f"File I/O error loading template from {template_dir}: {e}") from e
def set_qualified_id(self, library_name: str | None = None) -> None:
"""Set a qualified ID for this template (used when duplicates exist across libraries).
Args:
library_name: Name of the library to qualify with. If None, uses self.library_name
"""
lib_name = library_name or self.library_name
self.id = f"{self.original_id}.{lib_name}"
logger.debug(f"Template ID qualified: {self.original_id} -> {self.id}")
def _find_main_template_file(self) -> Path:
"""Find the main template file (template.yaml or template.yml)."""
for filename in ["template.yaml", "template.yml"]:
path = self.template_dir / filename
if path.exists():
return path
raise FileNotFoundError(f"Main template file (template.yaml or template.yml) not found in {self.template_dir}")
def _warn_about_unused_variables(self, template_specs: dict) -> None:
"""Warn about variables defined in spec but not used in template files.
This helps identify unnecessary variable definitions that can be removed.
Args:
template_specs: Variables defined in template.yaml spec
"""
# Collect variables explicitly defined in template
defined_vars = set()
for section_data in (template_specs or {}).values():
if isinstance(section_data, dict) and "vars" in section_data:
defined_vars.update(section_data["vars"].keys())
# Get variables actually used in template files
used_vars = self.used_variables
# Find variables that are defined but not used
unused_vars = defined_vars - used_vars
if unused_vars:
# Show first N variables in warning, full list in debug
max_shown_vars = 10
shown_vars = sorted(list(unused_vars)[:max_shown_vars])
ellipsis = "..." if len(unused_vars) > max_shown_vars else ""
logger.warning(
f"Template '{self.id}' defines {len(unused_vars)} variable(s) that are not used in template files. "
f"Consider removing them from the spec: {', '.join(shown_vars)}{ellipsis}"
)
logger.debug(f"Template '{self.id}' unused variables: {sorted(unused_vars)}")
def _collect_template_files(self) -> None:
"""Collects all TemplateFile objects in the template directory."""
template_files: list[TemplateFile] = []
for root, _, files in os.walk(self.template_dir):
for filename in files:
file_path = Path(root) / filename
relative_path = file_path.relative_to(self.template_dir)
# Skip the main template file
if filename in ["template.yaml", "template.yml"]:
continue
if filename.endswith(".j2"):
file_type: Literal["j2", "static"] = "j2"
output_path = relative_path.with_suffix("") # Remove .j2 suffix
else:
file_type = "static"
output_path = relative_path # Static files keep their name
template_files.append(
TemplateFile(
relative_path=relative_path,
file_type=file_type,
output_path=output_path,
)
)
self.__template_files = template_files
def _extract_all_used_variables(self) -> set[str]:
"""Extract all undeclared variables from all .j2 files in the template directory.
Raises:
ValueError: If any Jinja2 template has syntax errors
"""
used_variables: set[str] = set()
syntax_errors = []
# Track which file uses which variable (for debugging)
self._variable_usage_map: dict[str, list[str]] = {}
for template_file in self.template_files: # Iterate over TemplateFile objects
if template_file.file_type == "j2":
file_path = self.template_dir / template_file.relative_path
try:
with file_path.open(encoding="utf-8") as f:
content = f.read()
ast = self.jinja_env.parse(content) # Use lazy-loaded jinja_env
file_vars = meta.find_undeclared_variables(ast)
used_variables.update(file_vars)
# Track which file uses each variable
for var in file_vars:
if var not in self._variable_usage_map:
self._variable_usage_map[var] = []
self._variable_usage_map[var].append(str(template_file.relative_path))
except OSError as e:
relative_path = file_path.relative_to(self.template_dir)
syntax_errors.append(f" - {relative_path}: File I/O error: {e}")
except Exception as e:
# Collect syntax errors for Jinja2 issues
relative_path = file_path.relative_to(self.template_dir)
syntax_errors.append(f" - {relative_path}: {e}")
# Raise error if any syntax errors were found
if syntax_errors:
logger.error(f"Jinja2 syntax errors found in template '{self.id}'")
raise TemplateSyntaxError(self.id, syntax_errors)
return used_variables
def _filter_specs_to_used(
self,
used_variables: set,
template_specs: dict,
) -> dict:
"""Filter specs to only include variables used in templates using VariableCollection.
Uses VariableCollection's native filter_to_used() method.
Keeps sensitive variables only if they're defined in the template spec or actually used.
"""
# Build set of variables explicitly defined in template spec
template_defined_vars = set()
for section_data in (template_specs or {}).values():
if isinstance(section_data, dict) and "vars" in section_data:
template_defined_vars.update(section_data["vars"].keys())
# Create VariableCollection from template specs
template_collection = VariableCollection(template_specs)
# Filter to only used variables (and sensitive ones that are template-defined)
# We keep sensitive variables that are either:
# 1. Actually used in template files, OR
# 2. Explicitly defined in the template spec (even if not yet used)
variables_to_keep = used_variables | template_defined_vars
filtered_collection = template_collection.filter_to_used(variables_to_keep, keep_sensitive=False)
# Convert back to dict format
filtered_specs = {}
for section_key, section in filtered_collection.get_sections().items():
filtered_specs[section_key] = section.to_dict()
return filtered_specs
@staticmethod
def _validate_kind(template_data: dict) -> None:
"""Validate that template has required 'kind' field.
Args:
template_data: Parsed YAML data from template.yaml
Raises:
ValueError: If 'kind' field is missing
"""
if not template_data.get("kind"):
raise TemplateValidationError("Template format error: missing 'kind' field")
def _validate_variable_definitions(self, used_variables: set[str], merged_specs: dict[str, Any]) -> None:
"""Validate that all variables used in Jinja2 content are defined in the spec."""
defined_variables = set()
for section_data in merged_specs.values():
if "vars" in section_data and isinstance(section_data["vars"], dict):
defined_variables.update(section_data["vars"].keys())
undefined_variables = used_variables - defined_variables
if undefined_variables:
undefined_list = sorted(undefined_variables)
# Build file location info for each undefined variable
file_locations = []
for var_name in undefined_list:
if hasattr(self, "_variable_usage_map") and var_name in self._variable_usage_map:
files = self._variable_usage_map[var_name]
file_locations.append(f" • {var_name}: {', '.join(files)}")
error_msg = (
f"Template validation error in '{self.id}': "
f"Variables used in template content but not defined in spec:\n"
)
if file_locations:
error_msg += "\n".join(file_locations) + "\n"
else:
error_msg += f"{undefined_list}\n"
error_msg += (
"\nPlease add these variables to your template's template.yaml spec. "
"Each variable must have a default value.\n\n"
"Example:\n"
"spec:\n"
" general:\n"
" vars:\n"
)
for var_name in undefined_list:
error_msg += (
f" {var_name}:\n"
f" type: str\n"
f" description: Description for {var_name}\n"
f" default: \n"
)
# Only log at DEBUG level - the exception will be displayed to user
logger.debug(error_msg)
raise TemplateValidationError(error_msg)
@staticmethod
def _create_jinja_env(searchpath: Path) -> Environment:
"""Create sandboxed Jinja2 environment for secure template processing.
Uses SandboxedEnvironment to prevent code injection vulnerabilities
when processing untrusted templates. This restricts access to dangerous
operations while still allowing safe template rendering.
Returns:
SandboxedEnvironment configured for template processing.
"""
# NOTE Use SandboxedEnvironment for security - prevents arbitrary code execution
return SandboxedEnvironment(
loader=FileSystemLoader(searchpath),
trim_blocks=True,
lstrip_blocks=True,
keep_trailing_newline=False,
)
def _generate_autogenerated_values(self, variables: VariableCollection, variable_values: dict) -> None:
"""Generate values for autogenerated variables that are empty.
Supports both plain and base64-encoded autogenerated values based on
the autogenerated_base64 flag. Base64 encoding generates random bytes
and encodes them, which is more suitable for cryptographic keys.
"""
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 == ""):
length = getattr(variable, "autogenerated_length", 32)
use_base64 = getattr(variable, "autogenerated_base64", False)
if use_base64:
# Generate random bytes and base64 encode
# Note: length refers to number of random bytes, not base64 string length
random_bytes = secrets.token_bytes(length)
generated_value = base64.b64encode(random_bytes).decode("utf-8")
logger.debug(
f"Auto-generated base64 value for variable '{var_name}' "
f"(bytes: {length}, encoded length: {len(generated_value)})"
)
else:
# Generate alphanumeric string
alphabet = string.ascii_letters + string.digits
generated_value = "".join(secrets.choice(alphabet) for _ in range(length))
logger.debug(f"Auto-generated value for variable '{var_name}' (length: {length})")
variable_values[var_name] = generated_value
def _log_render_start(self, debug: bool, variable_values: dict) -> None:
"""Log rendering start information."""
if debug:
logger.info(f"Rendering template '{self.id}' in debug mode")
logger.info(f"Available variables: {sorted(variable_values.keys())}")
logger.info(f"Variable values: {variable_values}")
else:
logger.debug(f"Rendering template '{self.id}' with variables: {variable_values}")
def _render_jinja2_file(self, template_file, variable_values: dict, _available_vars: set, debug: bool) -> str:
"""Render a single Jinja2 template file."""
if debug:
logger.info(f"Rendering Jinja2 template: {template_file.relative_path}")
template = self.jinja_env.get_template(str(template_file.relative_path))
rendered_content = template.render(**variable_values)
rendered_content = self._sanitize_content(rendered_content, template_file.output_path)
if debug:
logger.info(f"Successfully rendered: {template_file.relative_path} -> {template_file.output_path}")
return rendered_content
def _handle_jinja2_error(
self,
e: Exception,
template_file,
available_vars: set,
variable_values: dict,
debug: bool,
) -> None:
"""Handle Jinja2 rendering errors."""
error_msg, line_num, col, context_lines, suggestions = TemplateErrorHandler.parse_jinja_error(
e, template_file, self.template_dir, available_vars
)
logger.error(f"Error rendering template file {template_file.relative_path}: {error_msg}")
context = RenderErrorContext(
file_path=str(template_file.relative_path),
line_number=line_num,
column=col,
context_lines=context_lines,
variable_context={k: str(v) for k, v in variable_values.items()} if debug else {},
suggestions=suggestions,
original_error=e,
)
raise TemplateRenderError(message=error_msg, context=context) from e
def _render_static_file(self, template_file, debug: bool) -> str:
"""Read and return content of a static file."""
file_path = self.template_dir / template_file.relative_path
if debug:
logger.info(f"Copying static file: {template_file.relative_path}")
try:
with file_path.open(encoding="utf-8") as f:
return f.read()
except OSError as e:
logger.error(f"Error reading static file {file_path}: {e}")
context = RenderErrorContext(
file_path=str(template_file.relative_path),
suggestions=["Check that the file exists and has read permissions"],
original_error=e,
)
raise TemplateRenderError(
message=f"Error reading static file: {e}",
context=context,
) from e
def render(self, variables: VariableCollection, debug: bool = False) -> tuple[dict[str, str], dict[str, Any]]:
"""Render all .j2 files in the template directory.
Args:
variables: VariableCollection with values to use for rendering
debug: Enable debug mode with verbose output
Returns:
Tuple of (rendered_files, variable_values) where variable_values includes autogenerated values.
Empty files (files with only whitespace) are excluded from the returned dict.
"""
variable_values = variables.get_satisfied_values()
self._generate_autogenerated_values(variables, variable_values)
self._log_render_start(debug, variable_values)
rendered_files = {}
skipped_files = []
available_vars = set(variable_values.keys())
for template_file in self.template_files:
if template_file.file_type == "j2":
try:
content = self._render_jinja2_file(template_file, variable_values, available_vars, debug)
# Skip empty files (only whitespace, empty string, or just YAML document separator)
stripped = content.strip()
if stripped and stripped != "---":
rendered_files[str(template_file.output_path)] = content
else:
skipped_files.append(str(template_file.output_path))
if debug:
logger.info(f"Skipping empty file: {template_file.output_path}")
except (
UndefinedError,
Jinja2TemplateSyntaxError,
Jinja2TemplateNotFound,
Jinja2TemplateError,
) as e:
self._handle_jinja2_error(e, template_file, available_vars, variable_values, debug)
except Exception as e:
logger.error(f"Unexpected error rendering template file {template_file.relative_path}: {e}")
context = RenderErrorContext(
file_path=str(template_file.relative_path),
suggestions=["This is an unexpected error. Please check the template for issues."],
original_error=e,
)
raise TemplateRenderError(
message=f"Unexpected rendering error: {e}",
context=context,
) from e
elif template_file.file_type == "static":
content = self._render_static_file(template_file, debug)
# Static files are always included, even if empty
rendered_files[str(template_file.output_path)] = content
if skipped_files:
logger.debug(f"Skipped {len(skipped_files)} empty file(s): {', '.join(skipped_files)}")
return rendered_files, variable_values
def _sanitize_content(self, content: str, _file_path: Path) -> str:
"""Sanitize rendered content by removing excessive blank lines and trailing whitespace."""
if not content:
return content
lines = [line.rstrip() for line in content.split("\n")]
sanitized = []
prev_blank = False
for line in lines:
is_blank = not line
if is_blank and prev_blank:
continue # Skip consecutive blank lines
sanitized.append(line)
prev_blank = is_blank
# Remove leading blanks and ensure single trailing newline
return "\n".join(sanitized).lstrip("\n").rstrip("\n") + "\n"
@property
def template_files(self) -> list[TemplateFile]:
if self.__template_files is None:
self._collect_template_files() # Populate self.__template_files
return self.__template_files
@property
def template_specs(self) -> dict:
"""Get the spec section from template YAML data."""
return self._template_data.get("spec", {})
@property
def jinja_env(self) -> Environment:
if self.__jinja_env is None:
self.__jinja_env = self._create_jinja_env(self.template_dir)
return self.__jinja_env
@property
def used_variables(self) -> set[str]:
if self.__used_variables is None:
self.__used_variables = self._extract_all_used_variables()
return self.__used_variables
@property
def variables(self) -> VariableCollection:
if self.__variables is None:
# Warn about unused variables in spec
self._warn_about_unused_variables(self.template_specs)
# Validate that all used variables are defined
self._validate_variable_definitions(self.used_variables, self.template_specs)
# Filter specs to only used variables
filtered_specs = self._filter_specs_to_used(
self.used_variables,
self.template_specs,
)
self.__variables = VariableCollection(filtered_specs)
return self.__variables
@property
def status(self) -> str:
"""Get the status of the template.
Returns:
Status string: 'published' or 'draft'
Note:
The 'invalid' status is reserved for future use when template validation
is implemented without impacting list command performance.
"""
# Check if template is marked as draft in metadata
if self.metadata.draft:
return TEMPLATE_STATUS_DRAFT
# Template is published (valid and not draft)
return TEMPLATE_STATUS_PUBLISHED
================================================
FILE: cli/core/template/variable.py
================================================
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any
from urllib.parse import urlparse
from email_validator import EmailNotValidError, validate_email
if TYPE_CHECKING:
from cli.core.template.variable_section import VariableSection
logger = logging.getLogger(__name__)
# Constants
DEFAULT_AUTOGENERATED_LENGTH = 32
TRUE_VALUES = {"true", "1", "yes", "on"}
FALSE_VALUES = {"false", "0", "no", "off"}
class Variable:
"""Represents a single templating variable with lightweight validation."""
def __init__(self, data: dict[str, Any]) -> None:
"""Initialize Variable from a dictionary containing variable specification.
Args:
data: Dictionary containing variable specification with required
'name' key and optional keys: description, type, options,
prompt, value, default, section, origin
Raises:
ValueError: If data is not a dict, missing 'name' key, or has invalid default value
"""
# Validate input
if not isinstance(data, dict):
raise ValueError("Variable data must be a dictionary")
if "name" not in data:
raise ValueError("Variable data must contain 'name' key")
# Track which fields were explicitly provided in source data
self._explicit_fields: set[str] = set(data.keys())
# Initialize fields
self.name: str = data["name"]
# Reference to parent section (set by VariableCollection)
self.parent_section: VariableSection | None = data.get("parent_section")
self.description: str | None = data.get("description") or data.get("display", "")
self.type: str = data.get("type", "str")
self.options: list[Any] | None = data.get("options", [])
self.prompt: str | None = data.get("prompt")
if "value" in data:
self.value: Any = data.get("value")
elif "default" in data:
self.value: Any = data.get("default")
else:
# RULE: If bool variables don't have any default value or value at all,
# automatically set them to false
self.value: Any = False if self.type == "bool" else None
self.origin: str | None = data.get("origin")
self.sensitive: bool = data.get("sensitive", False)
# Optional extra explanation used by interactive prompts
self.extra: str | None = data.get("extra")
# Flag indicating this variable should be auto-generated when empty
self.autogenerated: bool = data.get("autogenerated", False)
# Length of auto-generated value
self.autogenerated_length: int = data.get("autogenerated_length", DEFAULT_AUTOGENERATED_LENGTH)
# Flag indicating if autogenerated value should be base64 encoded
self.autogenerated_base64: bool = data.get("autogenerated_base64", False)
# Flag indicating this variable is required (must have a value)
self.required: bool = data.get("required", False)
# Original value before config override (used for display)
self.original_value: Any | None = data.get("original_value")
# Variable dependencies - can be string or list of strings in format "var_name=value"
# Supports semicolon-separated multiple conditions: "var1=value1;var2=value2,value3"
needs_value = data.get("needs")
if needs_value:
if isinstance(needs_value, str):
# Split by semicolon to support multiple AND conditions in a single string
# Example: "traefik_enabled=true;network_mode=bridge,macvlan"
self.needs: list[str] = [need.strip() for need in needs_value.split(";") if need.strip()]
elif isinstance(needs_value, list):
self.needs: list[str] = needs_value
else:
raise ValueError(f"Variable '{self.name}' has invalid 'needs' value: must be string or list")
else:
self.needs: list[str] = []
# Validate and convert the default/initial value if present
if self.value is not None:
try:
self.value = self.convert(self.value)
except ValueError as exc:
raise ValueError(f"Invalid default for variable '{self.name}': {exc}") from exc
def convert(self, value: Any) -> Any:
"""Validate and convert a raw value based on the variable type.
This method performs type conversion but does NOT check if the value
is required. Use validate_and_convert() for full validation including
required field checks.
"""
if value is None:
return None
# Treat empty strings as None to avoid storing "" for missing values.
if isinstance(value, str) and value.strip() == "":
return None
# Type conversion mapping for cleaner code
converters = {
"bool": self._convert_bool,
"int": self._convert_int,
"float": self._convert_float,
"enum": self._convert_enum,
"url": self._convert_url,
"email": self._convert_email,
}
converter = converters.get(self.type)
if converter:
return converter(value)
# Default to string conversion
return str(value)
def validate_and_convert(self, value: Any, check_required: bool = True) -> Any:
"""Validate and convert a value with comprehensive checks.
This method combines type conversion with validation logic including
required field checks. It's the recommended method for user input validation.
Args:
value: The raw value to validate and convert
check_required: If True, raises ValueError for required fields with empty values
Returns:
The converted and validated value
Raises:
ValueError: If validation fails (invalid format, required field empty, etc.)
Examples:
# Basic validation
var.validate_and_convert("example@email.com") # Returns validated email
# Required field validation
var.validate_and_convert("", check_required=True) # Raises ValueError if required
# Autogenerated variables - allow empty values
var.validate_and_convert("", check_required=False) # Returns None for autogeneration
"""
# First, convert the value using standard type conversion
converted = self.convert(value)
# Special handling for autogenerated variables
# Allow empty values as they will be auto-generated later
if self.autogenerated and (converted is None or (isinstance(converted, str) and (converted in {"", "*auto"}))):
return None # Signal that auto-generation should happen
# Check if this is a required field and the value is empty
if (
check_required
and self.is_required()
and (converted is None or (isinstance(converted, str) and converted == ""))
):
raise ValueError("This field is required and cannot be empty")
return converted
def _convert_bool(self, value: Any) -> bool:
"""Convert value to boolean."""
if isinstance(value, bool):
return value
if isinstance(value, str):
lowered = value.strip().lower()
if lowered in TRUE_VALUES:
return True
if lowered in FALSE_VALUES:
return False
raise ValueError("value must be a boolean (true/false)")
def _convert_int(self, value: Any) -> int | None:
"""Convert value to integer."""
if isinstance(value, int):
return value
if isinstance(value, str) and value.strip() == "":
return None
try:
return int(value)
except (TypeError, ValueError) as exc:
raise ValueError("value must be an integer") from exc
def _convert_float(self, value: Any) -> float | None:
"""Convert value to float."""
if isinstance(value, float):
return value
if isinstance(value, str) and value.strip() == "":
return None
try:
return float(value)
except (TypeError, ValueError) as exc:
raise ValueError("value must be a float") from exc
def _convert_enum(self, value: Any) -> str | None:
if value == "":
return None
val = str(value)
if self.options and val not in self.options:
raise ValueError(f"value must be one of: {', '.join(self.options)}")
return val
def _convert_url(self, value: Any) -> str:
val = str(value).strip()
if not val:
return None
parsed = urlparse(val)
if not (parsed.scheme and parsed.netloc):
raise ValueError("value must be a valid URL (include scheme and host)")
return val
def _convert_email(self, value: Any) -> str:
val = str(value).strip()
if not val:
return None
try:
# Validate email using RFC 5321/5322 compliant parser
validated = validate_email(val, check_deliverability=False)
return validated.normalized
except EmailNotValidError as exc:
raise ValueError(f"value must be a valid email address: {exc}") from exc
def to_dict(self) -> dict[str, Any]:
"""Serialize Variable to a dictionary for storage."""
result = {}
# Always include type
if self.type:
result["type"] = self.type
# Include value/default if not None
if self.value is not None:
result["default"] = self.value
# Include string fields if truthy
for field in ("description", "prompt", "extra", "origin"):
if value := getattr(self, field):
result[field] = value
# Include boolean/list fields if truthy (but empty list is OK for options)
if self.sensitive:
result["sensitive"] = True
if self.autogenerated:
result["autogenerated"] = True
# Only include length if not default
if self.autogenerated_length != DEFAULT_AUTOGENERATED_LENGTH:
result["autogenerated_length"] = self.autogenerated_length
# Include base64 flag if enabled
if self.autogenerated_base64:
result["autogenerated_base64"] = True
if self.required:
result["required"] = True
if self.options is not None: # Allow empty list
result["options"] = self.options
# Store dependencies (single value if only one, list otherwise)
if self.needs:
result["needs"] = self.needs[0] if len(self.needs) == 1 else self.needs
return result
def get_display_value(self, mask_sensitive: bool = True, max_length: int = 30, show_none: bool = True) -> str:
"""Get formatted display value with optional masking and truncation.
Args:
mask_sensitive: If True, mask sensitive values with asterisks
max_length: Maximum length before truncation (0 = no limit)
show_none: If True, display "(none)" for None values instead of empty string
Returns:
Formatted string representation of the value
"""
if self.value is None or self.value == "":
# Show (*auto) for autogenerated variables instead of (none)
if self.autogenerated:
return "[dim](*auto)[/dim]" if show_none else ""
return "[dim](none)[/dim]" if show_none else ""
# Mask sensitive values
if self.sensitive and mask_sensitive:
return "********"
# Convert to string
display = str(self.value)
# Truncate if needed
if max_length > 0 and len(display) > max_length:
return display[: max_length - 3] + "..."
return display
def get_normalized_default(self) -> Any:
"""Get normalized default value suitable for prompts and display."""
try:
typed = self.convert(self.value)
except Exception:
typed = self.value
# Autogenerated: return display hint
if self.autogenerated and not typed:
return "*auto"
# Type-specific handlers
if self.type == "enum":
return (
typed
if not self.options
else (self.options[0] if typed is None or str(typed) not in self.options else str(typed))
)
if self.type == "bool":
return typed if isinstance(typed, bool) else (None if typed is None else bool(typed))
if self.type == "int":
try:
return int(typed) if typed not in (None, "") else None
except Exception:
return None
# Default: return string or None
return None if typed is None else str(typed)
def get_prompt_text(self) -> str:
"""Get formatted prompt text for interactive input.
Returns:
Prompt text with optional type hints and descriptions
"""
prompt_text = self.prompt or self.description or self.name
# Add type hint for semantic types if there's a default
if self.value is not None and self.type in ["email", "url"]:
prompt_text += f" ({self.type})"
return prompt_text
def get_validation_hint(self) -> str | None:
"""Get validation hint for prompts (e.g., enum options).
Returns:
Formatted hint string or None if no hint needed
"""
hints = []
# Add enum options
if self.type == "enum" and self.options:
hints.append(f"Options: {', '.join(self.options)}")
# Add extra help text
if self.extra:
hints.append(self.extra)
return " — ".join(hints) if hints else None
def is_required(self) -> bool:
"""Check if this variable requires a value (cannot be empty/None).
A variable is considered required ONLY if it has an explicit 'required: true' flag.
All other variables are optional by default.
Returns:
True if the variable must have a non-empty value, False otherwise
"""
# Only explicitly marked required variables are required
# Autogenerated variables can still be empty (will be generated later)
return self.required and not self.autogenerated
def get_parent(self) -> VariableSection | None:
"""Get the parent VariableSection that contains this variable.
Returns:
The parent VariableSection if set, None otherwise
"""
return self.parent_section
def clone(self, update: dict[str, Any] | None = None) -> Variable:
"""Create a deep copy of the variable with optional field updates.
This is more efficient than converting to dict and back when copying variables.
Args:
update: Optional dictionary of field updates to apply to the clone
Returns:
New Variable instance with copied data
Example:
var2 = var1.clone(update={'origin': 'template'})
"""
data = {
"name": self.name,
"type": self.type,
"value": self.value,
"description": self.description,
"prompt": self.prompt,
"options": self.options.copy() if self.options else None,
"origin": self.origin,
"sensitive": self.sensitive,
"extra": self.extra,
"autogenerated": self.autogenerated,
"autogenerated_length": self.autogenerated_length,
"autogenerated_base64": self.autogenerated_base64,
"required": self.required,
"original_value": self.original_value,
"needs": self.needs.copy() if self.needs else None,
"parent_section": self.parent_section,
}
# Apply updates if provided
if update:
data.update(update)
# Create new variable
cloned = Variable(data)
# Preserve explicit fields from original, and add any update keys
cloned._explicit_fields = self._explicit_fields.copy()
if update:
cloned._explicit_fields.update(update.keys())
return cloned
================================================
FILE: cli/core/template/variable_collection.py
================================================
from __future__ import annotations
import logging
from collections import defaultdict
from typing import Any
from .variable import Variable
from .variable_section import VariableSection
logger = logging.getLogger(__name__)
class VariableCollection:
"""Manages variables grouped by sections and builds Jinja context."""
def __init__(self, spec: dict[str, Any]) -> None:
"""Initialize VariableCollection from a specification dictionary.
Args:
spec: Dictionary containing the complete variable specification structure
Expected format (as used in compose.py):
{
"section_key": {
"title": "Section Title",
"prompt": "Optional prompt text",
"toggle": "optional_toggle_var_name",
"description": "Optional description",
"vars": {
"var_name": {
"description": "Variable description",
"type": "str",
"default": "default_value",
...
}
}
}
}
"""
if not isinstance(spec, dict):
raise ValueError("Spec must be a dictionary")
self._sections: dict[str, VariableSection] = {}
# NOTE: The _variable_map provides a flat, O(1) lookup for any variable by its name,
# avoiding the need to iterate through sections. It stores references to the same
# Variable objects contained in the _set structure.
self._variable_map: dict[str, Variable] = {}
self._initialize_sections(spec)
# Validate dependencies after all sections are loaded
self._validate_dependencies()
@classmethod
def from_json(cls, json_spec: list[dict[str, Any]]) -> VariableCollection:
"""Create VariableCollection from JSON array format.
Args:
json_spec: List of section specifications in JSON format.
Expected format:
[
{
"key": "section_key",
"title": "Section Title",
"description": "Optional description",
"toggle": "optional_toggle_var_name",
"required": true,
"needs": "dependency_section",
"vars": [
{
"name": "var_name",
"description": "Variable description",
"type": "str",
"default": "default_value",
...
}
]
}
]
Returns:
VariableCollection initialized from JSON spec
Raises:
ValueError: If json_spec is not a list or has invalid structure
"""
if not isinstance(json_spec, list):
raise ValueError("JSON spec must be a list")
# Convert JSON array format to dict format expected by __init__
dict_spec = {}
for section_data in json_spec:
section_key = cls._validate_and_extract_section_key(section_data)
section_dict = cls._build_section_dict(section_data)
vars_dict = cls._convert_vars_to_dict(section_data, section_key)
section_dict["vars"] = vars_dict
dict_spec[section_key] = section_dict
# Create and return VariableCollection using standard __init__
return cls(dict_spec)
@staticmethod
def _validate_and_extract_section_key(section_data: Any) -> str:
"""Validate section data and extract the section key.
Args:
section_data: Section data to validate
Returns:
The section key
Raises:
ValueError: If validation fails
"""
if not isinstance(section_data, dict):
raise ValueError(f"Section must be a dict, got {type(section_data).__name__}")
if "key" not in section_data:
raise ValueError("Section missing required 'key' field")
if "vars" not in section_data:
raise ValueError(f"Section '{section_data['key']}' missing required 'vars' field")
return section_data["key"]
@staticmethod
def _build_section_dict(section_data: dict[str, Any]) -> dict[str, Any]:
"""Build section dictionary with optional fields.
Args:
section_data: Source section data
Returns:
Dictionary with only present optional fields
"""
section_dict = {}
optional_fields = ["title", "description", "toggle", "needs"]
for field in optional_fields:
if field in section_data:
section_dict[field] = section_data[field]
return section_dict
@staticmethod
def _convert_vars_to_dict(section_data: dict[str, Any], section_key: str) -> dict[str, Any]:
"""Convert vars array to dictionary format.
Args:
section_data: Section data containing vars array
section_key: Section key for error messages
Returns:
Dictionary mapping variable names to their specifications
Raises:
ValueError: If vars format is invalid
"""
if not isinstance(section_data["vars"], list):
raise ValueError(f"Section '{section_key}' vars must be a list")
vars_dict = {}
for var_data in section_data["vars"]:
if not isinstance(var_data, dict):
raise ValueError(f"Variable in section '{section_key}' must be a dict")
if "name" not in var_data:
raise ValueError(f"Variable in section '{section_key}' missing 'name' field")
var_name = var_data["name"]
# Copy all fields except 'name' to the var dict
var_dict = {k: v for k, v in var_data.items() if k != "name"}
vars_dict[var_name] = var_dict
return vars_dict
def _initialize_sections(self, spec: dict[str, Any]) -> None:
"""Initialize sections from the spec."""
for section_key, section_data in spec.items():
if not isinstance(section_data, dict):
continue
section = self._create_section(section_key, section_data)
# Guard against None from empty YAML sections (vars: with no content)
vars_data = section_data.get("vars") or {}
self._initialize_variables(section, vars_data)
self._sections[section_key] = section
# Validate all variable names are unique across sections
self._validate_unique_variable_names()
def _create_section(self, key: str, data: dict[str, Any]) -> VariableSection:
"""Create a VariableSection from data."""
# Build section init data with only explicitly provided fields
# This prevents None values from overriding module spec values during merge
section_init_data = {
"key": key,
"title": data.get("title", key.replace("_", " ").title()),
}
# Only add optional fields if explicitly provided in the source data
if "description" in data:
section_init_data["description"] = data["description"]
if "toggle" in data:
section_init_data["toggle"] = data["toggle"]
if "needs" in data:
section_init_data["needs"] = data["needs"]
return VariableSection(section_init_data)
def _initialize_variables(self, section: VariableSection, vars_data: dict[str, Any]) -> None:
"""Initialize variables for a section."""
# Guard against None from empty YAML sections
if vars_data is None:
vars_data = {}
for var_name, var_data in vars_data.items():
var_init_data = {"name": var_name, "parent_section": section, **var_data}
variable = Variable(var_init_data)
section.variables[var_name] = variable
# NOTE: Populate the direct lookup map for efficient access.
self._variable_map[var_name] = variable
# Validate toggle variable after all variables are added
self._validate_section_toggle(section)
# TODO: Add more section-level validation:
# - Validate that required sections have at least one non-toggle variable
# - Validate that enum variables have non-empty options lists
# - Validate that variable names follow naming conventions (e.g., lowercase_with_underscores)
# - Validate that default values are compatible with their type definitions
def _validate_unique_variable_names(self) -> None:
"""Validate that all variable names are unique across all sections."""
var_to_sections: dict[str, list[str]] = defaultdict(list)
# Build mapping of variable names to sections
for section_key, section in self._sections.items():
for var_name in section.variables:
var_to_sections[var_name].append(section_key)
# Find duplicates and format error
duplicates = {var: sections for var, sections in var_to_sections.items() if len(sections) > 1}
if duplicates:
errors = ["Variable names must be unique across all sections, but found duplicates:"]
errors.extend(
f" - '{var}' appears in sections: {', '.join(secs)}" for var, secs in sorted(duplicates.items())
)
errors.append("\nPlease rename variables to be unique or consolidate them into a single section.")
error_msg = "\n".join(errors)
logger.error(error_msg)
raise ValueError(error_msg)
def _validate_section_toggle(self, section: VariableSection) -> None:
"""Validate that toggle variable is of type bool if it exists.
If the toggle variable doesn't exist (e.g., filtered out), removes the toggle.
Args:
section: The section to validate
Raises:
ValueError: If toggle variable exists but is not boolean type
"""
if not section.toggle:
return
toggle_var = section.variables.get(section.toggle)
if not toggle_var:
# Toggle variable doesn't exist (e.g., was filtered out) - remove toggle metadata
section.toggle = None
return
if toggle_var.type != "bool":
raise ValueError(
f"Section '{section.key}' toggle variable '{section.toggle}' must be type 'bool', "
f"but is type '{toggle_var.type}'"
)
@staticmethod
def _parse_need(need_str: str) -> tuple[str, bool, Any | None]:
"""Parse a need string into variable name, operator, and expected value(s).
Supports four formats:
1. Negation with multiple values: "variable_name!=value1,value2" - checks if variable does NOT equal any value
2. Negation with single value: "variable_name!=value" - checks if variable does NOT equal value
3. Equality with multiple values: "variable_name=value1,value2" - checks if variable equals any value
4. Equality with single value: "variable_name=value" - checks if variable equals value
5. Old format (backwards compatibility): "section_name" - checks if section is enabled
Args:
need_str: Need specification string
Returns:
Tuple of (variable_or_section_name, is_positive, expected_value)
- is_positive: True for '=' (must match), False for '!=' (must NOT match)
- For old format, expected_value is None (means check section enabled) and is_positive is True
- For new format, expected_value is the string value(s) after operator (string or list)
Examples:
"traefik_enabled=true" -> ("traefik_enabled", True, "true")
"storage_mode=nfs" -> ("storage_mode", True, "nfs")
"network_mode=bridge,macvlan" -> ("network_mode", True, ["bridge", "macvlan"])
"network_mode!=host,macvlan" -> ("network_mode", False, ["host", "macvlan"])
"network_mode!=host" -> ("network_mode", False, "host")
"traefik" -> ("traefik", True, None) # Old format: section name
"""
# Check for != operator first (must check before = to avoid false positive)
if "!=" in need_str:
# Negation format: variable!=value or variable!=value1,value2
parts = need_str.split("!=", 1)
var_name = parts[0].strip()
value_part = parts[1].strip()
# Check if multiple values are provided (comma-separated)
if "," in value_part:
values = [v.strip() for v in value_part.split(",")]
return (var_name, False, values)
return (var_name, False, value_part)
if "=" in need_str:
# Equality format: variable=value or variable=value1,value2
parts = need_str.split("=", 1)
var_name = parts[0].strip()
value_part = parts[1].strip()
# Check if multiple values are provided (comma-separated)
if "," in value_part:
values = [v.strip() for v in value_part.split(",")]
return (var_name, True, values)
return (var_name, True, value_part)
# Old format: section name (backwards compatibility)
return (need_str.strip(), True, None)
def _is_need_satisfied(self, need_str: str) -> bool:
"""Check if a single need condition is satisfied.
Args:
need_str: Need specification ("variable=value", "variable!=value",
"variable=value1,value2" or "section_name")
Returns:
True if need is satisfied, False otherwise
"""
var_or_section, is_positive, expected_value = self._parse_need(need_str)
# Old format: check if section is enabled (backwards compatibility)
if expected_value is None:
result = self._check_section_need(var_or_section)
section = self._sections.get(var_or_section)
if section:
logger.debug(
f"Checking section need '{need_str}': "
f"exists=True, enabled={section.is_enabled()}, satisfied={result}"
)
else:
logger.debug(f"Checking section need '{need_str}': exists=False, satisfied={result}")
return result
# New format: check if variable has expected value(s)
result = self._check_variable_need(var_or_section, is_positive, expected_value, need_str)
variable = self._variable_map.get(var_or_section)
if variable:
operator = "=" if is_positive else "!="
logger.debug(
f"Checking variable need '{need_str}': "
f"var_value={variable.value} {operator} expected={expected_value}, satisfied={result}"
)
else:
logger.debug(f"Checking variable need '{need_str}': variable not found, satisfied={result}")
return result
def _check_section_need(self, section_name: str) -> bool:
"""Check if a section-based need is satisfied."""
section = self._sections.get(section_name)
if not section:
logger.warning(f"Need references missing section '{section_name}'")
return False
return section.is_enabled()
def _check_variable_need(self, var_name: str, is_positive: bool, expected_value: Any, need_str: str) -> bool:
"""Check if a variable-based need is satisfied.
Args:
var_name: Variable name to check
is_positive: True for '=' (must match), False for '!=' (must NOT match)
expected_value: Expected value(s) to compare against
need_str: Original need string for logging
Returns:
True if need is satisfied, False otherwise
"""
variable = self._variable_map.get(var_name)
if not variable:
# Variable doesn't exist - ignore the constraint and treat as satisfied
# This allows templates to override sections without breaking needs constraints
logger.debug(
f"Need '{need_str}' references missing variable '{var_name}' - "
f"ignoring constraint and treating as satisfied"
)
return True
try:
actual_value = variable.convert(variable.value)
# Handle multiple expected values
if isinstance(expected_value, list):
matches = self._matches_any_value(variable, actual_value, expected_value)
else:
# Single expected value
matches = self._matches_single_value(variable, actual_value, expected_value)
# For positive checks (=), return match result directly
# For negative checks (!=), invert the result
return matches if is_positive else not matches
except Exception as e:
logger.debug(f"Failed to compare need '{need_str}': {e}")
return False
def _matches_any_value(self, variable: Variable, actual_value: Any, expected_values: list) -> bool:
"""Check if actual value matches any of the expected values."""
for expected in expected_values:
expected_converted = variable.convert(expected)
if self._values_match(variable, actual_value, expected_converted):
return True
return False
def _matches_single_value(self, variable: Variable, actual_value: Any, expected_value: Any) -> bool:
"""Check if actual value matches the expected value."""
expected_converted = variable.convert(expected_value)
return self._values_match(variable, actual_value, expected_converted)
def _values_match(self, variable: Variable, actual: Any, expected: Any) -> bool:
"""Compare two values based on variable type."""
if variable.type == "bool":
return bool(actual) == bool(expected)
return actual is not None and str(actual) == str(expected)
def _validate_dependencies(self) -> None:
"""Validate section dependencies for cycles.
Missing section references are logged as warnings but do not raise errors,
allowing templates to be modified without breaking when dependencies are removed.
Raises:
ValueError: If circular dependencies are found
"""
# Check for missing dependencies in sections
for section_key, section in self._sections.items():
for dep in section.needs:
var_or_section, _is_positive, expected_value = self._parse_need(dep)
if expected_value is None:
# Old format: validate section exists
if var_or_section not in self._sections:
logger.warning(
f"Section '{section_key}' depends on '{var_or_section}', "
f"but '{var_or_section}' does not exist. Ignoring this dependency."
)
# New format: validate variable exists
# NOTE: We only warn here, not raise an error, because the variable might be
# added later during merge with module spec. The actual runtime check in
# _is_need_satisfied() will handle missing variables gracefully.
elif var_or_section not in self._variable_map:
logger.debug(
f"Section '{section_key}' has need '{dep}', but variable '{var_or_section}' "
f"not found (might be added during merge)"
)
# Check for missing dependencies in variables
for var_name, variable in self._variable_map.items():
for dep in variable.needs:
dep_var, _is_positive, expected_value = self._parse_need(dep)
# Only validate new format and check if variable is missing
if expected_value is not None and dep_var not in self._variable_map:
# NOTE: We only warn here, not raise an error, because the variable might be
# added later during merge with module spec. The actual runtime check in
# _is_need_satisfied() will handle missing variables gracefully.
logger.debug(
f"Variable '{var_name}' has need '{dep}', but variable '{dep_var}' "
f"not found (might be added during merge)"
)
# Check for circular dependencies using depth-first search
# Note: Only checks section-level dependencies in old format (section names)
# Variable-level dependencies (variable=value) don't create cycles in the same way
visited = set()
rec_stack = set()
def has_cycle(section_key: str) -> bool:
visited.add(section_key)
rec_stack.add(section_key)
section = self._sections[section_key]
for dep in section.needs:
# Only check circular deps for old format (section references)
dep_name, _is_positive, expected_value = self._parse_need(dep)
# Old format section dependency - check for cycles
if expected_value is None and dep_name in self._sections:
if dep_name not in visited:
if has_cycle(dep_name):
return True
elif dep_name in rec_stack:
raise ValueError(
f"Circular dependency detected: '{section_key}' depends on '{dep_name}', "
f"which creates a cycle"
)
rec_stack.remove(section_key)
return False
for section_key in self._sections:
if section_key not in visited:
has_cycle(section_key)
def is_section_satisfied(self, section_key: str) -> bool:
"""Check if all dependencies for a section are satisfied.
Supports both formats:
- Old format: "section_name" - checks if section is enabled (backwards compatible)
- New format: "variable=value" - checks if variable has specific value
Args:
section_key: The key of the section to check
Returns:
True if all dependencies are satisfied, False otherwise
"""
section = self._sections.get(section_key)
if not section:
return False
# No dependencies = always satisfied
if not section.needs:
return True
# Check each dependency using the unified need satisfaction logic
for need in section.needs:
if not self._is_need_satisfied(need):
logger.debug(f"Section '{section_key}' need '{need}' is not satisfied")
return False
return True
def is_variable_satisfied(self, var_name: str) -> bool:
"""Check if all dependencies for a variable are satisfied.
A variable is satisfied if all its needs are met.
Needs are specified as "variable_name=value".
Args:
var_name: The name of the variable to check
Returns:
True if all dependencies are satisfied, False otherwise
"""
variable = self._variable_map.get(var_name)
if not variable:
return False
# No dependencies = always satisfied
if not variable.needs:
return True
# Check each dependency
for need in variable.needs:
if not self._is_need_satisfied(need):
logger.debug(f"Variable '{var_name}' need '{need}' is not satisfied")
return False
return True
def reset_disabled_bool_variables(self) -> list[str]:
"""Reset bool variables with unsatisfied dependencies to False.
This ensures that disabled bool variables don't accidentally remain True
and cause confusion in templates or configuration.
Note: CLI-provided variables are NOT reset here - they are validated
later in validate_all() to provide better error messages.
Returns:
List of variable names that were reset
"""
reset_vars = []
logger.debug("Starting reset of disabled bool variables")
for section_key, section in self._sections.items():
# Check if section dependencies are satisfied
section_satisfied = self.is_section_satisfied(section_key)
is_enabled = section.is_enabled()
for var_name, variable in section.variables.items():
# Only process bool variables
if variable.type != "bool":
continue
# Check if variable's own dependencies are satisfied
var_satisfied = self.is_variable_satisfied(var_name)
# If section is disabled OR variable dependencies aren't met, reset to False
if (
(not section_satisfied or not is_enabled or not var_satisfied)
and variable.value is not False
and variable.origin != "cli"
):
# Store original value if not already stored (for display purposes)
if not hasattr(variable, "_original_disabled"):
variable._original_disabled = variable.value
variable.value = False
reset_vars.append(var_name)
logger.debug(
f"Reset disabled bool variable '{var_name}' to False "
f"(section satisfied: {section_satisfied}, enabled: {is_enabled}, "
f"var satisfied: {var_satisfied})"
)
if reset_vars:
logger.debug(f"Reset {len(reset_vars)} disabled bool variables: {', '.join(reset_vars)}")
else:
logger.debug("No bool variables needed reset")
return reset_vars
def sort_sections(self) -> None:
"""Sort sections with the following priority:
1. Dependencies come before dependents (topological sort)
2. Enabled sections with satisfied dependencies first (in their original order)
3. Disabled sections or sections with unsatisfied dependencies last (in their original order)
This maintains the original ordering within each group while organizing
sections logically for display and user interaction, and ensures that
sections are prompted in the correct dependency order.
"""
# First, perform topological sort to respect dependencies
sorted_keys = self._topological_sort()
# Then apply priority sorting within dependency groups
section_items = [(key, self._sections[key]) for key in sorted_keys]
# Define sort key: (priority, original_index)
# Priority: 0 = enabled with satisfied dependencies, 1 = disabled or unsatisfied dependencies
def get_sort_key(item_with_index):
index, (key, section) = item_with_index
priority = 0 if section.is_enabled() and self.is_section_satisfied(key) else 1
return (priority, index)
# Sort with original index to maintain order within each priority group
# Note: This preserves the topological order from earlier
sorted_items = sorted(enumerate(section_items), key=get_sort_key)
# Rebuild _sections dict in new order
self._sections = {key: section for _, (key, section) in sorted_items}
# NOTE: Sort variables within each section by their dependencies.
# This is critical for correct behavior in both display and prompts:
# 1. DISPLAY: Variables are shown in logical order (dependencies before dependents)
# 2. PROMPTS: Users are asked for dependency values BEFORE dependent values
# Example: network_mode (bridge/host/macvlan) is prompted before
# network_macvlan_ipv4_address (which needs network_mode=macvlan)
# 3. VALIDATION: Ensures config/CLI overrides can be checked in correct order
# Without this sorting, users would be prompted for irrelevant variables or see
# confusing variable order in the UI.
for section in self._sections.values():
section.sort_variables(self._is_need_satisfied)
def _topological_sort(self) -> list[str]:
"""Perform topological sort on sections based on dependencies using Kahn's algorithm."""
in_degree = {key: len(section.needs) for key, section in self._sections.items()}
queue = [key for key, degree in in_degree.items() if degree == 0]
queue.sort(key=lambda k: list(self._sections.keys()).index(k)) # Preserve original order
result = []
while queue:
current = queue.pop(0)
result.append(current)
# Update in-degree for dependent sections
for key, section in self._sections.items():
if current in section.needs:
in_degree[key] -= 1
if in_degree[key] == 0:
queue.append(key)
# Fallback to original order if cycle detected
if len(result) != len(self._sections):
missing = set(self._sections.keys()) - set(result)
# Identify which sections have circular dependencies
circular_deps = []
for section_key in missing:
section = self._sections[section_key]
if section.needs:
circular_deps.append(f"{section_key} (needs: {', '.join(section.needs)})")
logger.warning(
f"Topological sort incomplete - circular dependency detected. "
f"Missing sections: {', '.join(missing)}. "
f"Circular dependencies: {'; '.join(circular_deps) if circular_deps else 'none identified'}. "
f"Using original order."
)
return list(self._sections.keys())
return result
def get_sections(self) -> dict[str, VariableSection]:
"""Get all sections in the collection."""
return self._sections.copy()
def get_section(self, key: str) -> VariableSection | None:
"""Get a specific section by its key."""
return self._sections.get(key)
def has_sections(self) -> bool:
"""Check if the collection has any sections."""
return bool(self._sections)
def get_all_values(self) -> dict[str, Any]:
"""Get all variable values as a dictionary."""
# NOTE: Uses _variable_map for O(1) access
return {name: var.convert(var.value) for name, var in self._variable_map.items()}
def get_satisfied_values(self) -> dict[str, Any]:
"""Get variable values only from sections with satisfied dependencies.
This respects both toggle states and section dependencies, ensuring that:
- Variables from disabled sections (toggle=false) are excluded EXCEPT required variables
- Variables from sections with unsatisfied dependencies are excluded
- Required variables are always included if their section dependencies are satisfied
Returns:
Dictionary of variable names to values for satisfied sections only
"""
satisfied_values = {}
for section_key, section in self._sections.items():
# Skip sections with unsatisfied dependencies (even required variables need satisfied deps)
if not self.is_section_satisfied(section_key):
logger.debug(f"Excluding variables from section '{section_key}' - dependencies not satisfied")
continue
# Check if section is enabled
is_enabled = section.is_enabled()
if is_enabled:
# Include all variables from enabled section
for var_name, variable in section.variables.items():
satisfied_values[var_name] = variable.convert(variable.value)
else:
# Section is disabled - exclude all variables
logger.debug(f"Section '{section_key}' is disabled - excluding all variables")
return satisfied_values
def get_sensitive_variables(self) -> dict[str, Any]:
"""Get only the sensitive variables with their values."""
return {name: var.value for name, var in self._variable_map.items() if var.sensitive and var.value}
def apply_defaults(self, defaults: dict[str, Any], origin: str = "cli") -> list[str]:
"""Apply default values to variables, updating their origin.
Args:
defaults: Dictionary mapping variable names to their default values
origin: Source of these defaults (e.g., 'config', 'cli')
Returns:
List of variable names that were successfully updated
"""
# NOTE: This method uses the _variable_map for a significant performance gain,
# as it allows direct O(1) lookup of variables instead of iterating
# through all sections to find a match.
successful = []
errors = []
for var_name, value in defaults.items():
try:
variable = self._variable_map.get(var_name)
if not variable:
logger.debug(
f"Default value for '{var_name}' not applicable to this template (variable not defined)"
)
continue
# Check if variable's needs are satisfied
# If not, warn that the override will have no effect
if not self.is_variable_satisfied(var_name):
# Build a friendly message about which needs aren't satisfied
unmet_needs = []
for need in variable.needs:
if not self._is_need_satisfied(need):
unmet_needs.append(need)
needs_str = ", ".join(unmet_needs) if unmet_needs else "unknown"
logger.warning(
f"Setting '{var_name}' via {origin} will have no effect - needs not satisfied: {needs_str}"
)
# Continue anyway to store the value (it might become relevant later)
# Store original value before overriding (for display purposes)
# Only store if this is the first time config is being applied
if origin == "config" and not hasattr(variable, "_original_stored"):
variable.original_value = variable.value
variable._original_stored = True
# Convert and set the new value
converted_value = variable.convert(value)
variable.value = converted_value
# Set origin to the current source (not a chain)
variable.origin = origin
successful.append(var_name)
except ValueError as e:
error_msg = f"Invalid value for '{var_name}': {value} - {e}"
errors.append(error_msg)
logger.error(error_msg)
if errors:
# Raise exception to halt execution on validation errors
raise ValueError(f"Variable validation failed: {'; '.join(errors)}")
return successful
def validate_all(self) -> None:
"""Validate all variables in the collection.
Validates:
- All variables in enabled sections with satisfied dependencies
- Required variables even if their section is disabled (but dependencies must be satisfied)
- CLI-provided bool variables with unsatisfied dependencies
"""
errors: list[str] = []
# First, check for CLI-provided bool variables with unsatisfied dependencies
self._validate_cli_bool_variables(errors)
# Then validate all other variables
self._validate_section_variables(errors)
if errors:
error_msg = "Variable validation failed: " + ", ".join(errors)
logger.error(error_msg)
raise ValueError(error_msg)
def _validate_cli_bool_variables(self, errors: list[str]) -> None:
"""Validate CLI-provided bool variables with unsatisfied dependencies."""
for section_key, section in self._sections.items():
section_satisfied = self.is_section_satisfied(section_key)
is_enabled = section.is_enabled()
for var_name, variable in section.variables.items():
# Check CLI-provided bool variables with unsatisfied dependencies
if not self._is_cli_bool_variable(variable):
continue
var_satisfied = self.is_variable_satisfied(var_name)
if section_satisfied and is_enabled and var_satisfied:
continue
# Build error message with unmet needs
unmet_needs = self._collect_unmet_needs(section, variable, section_satisfied, var_satisfied)
needs_str = ", ".join(sorted(unmet_needs)) if unmet_needs else "dependencies not satisfied"
errors.append(f"{section.key}.{var_name} (set via CLI to {variable.value} but requires: {needs_str})")
def _is_cli_bool_variable(self, variable: Variable) -> bool:
"""Check if variable is a CLI-provided boolean."""
return variable.type == "bool" and variable.origin == "cli" and variable.value is not False
def _collect_unmet_needs(
self, section, variable: Variable, section_satisfied: bool, var_satisfied: bool
) -> set[str]:
"""Collect all unmet needs from section and variable."""
unmet_needs = set()
if not section_satisfied:
for need in section.needs:
if not self._is_need_satisfied(need):
unmet_needs.add(need)
if not var_satisfied:
for need in variable.needs:
if not self._is_need_satisfied(need):
unmet_needs.add(need)
return unmet_needs
def _validate_section_variables(self, errors: list[str]) -> None:
"""Validate all variables in each section."""
for section_key, section in self._sections.items():
# Skip sections with unsatisfied dependencies
if not self.is_section_satisfied(section_key):
logger.debug(f"Skipping validation for section '{section_key}' - dependencies not satisfied")
continue
# Check if section is enabled
is_enabled = section.is_enabled()
if not is_enabled:
logger.debug(f"Section '{section_key}' is disabled - skipping all variables")
continue
# Validate variables in the section
for var_name, variable in section.variables.items():
self._validate_single_variable(section, var_name, variable, errors)
def _validate_single_variable(self, section, var_name: str, variable: Variable, errors: list[str]) -> None:
"""Validate a single variable and append errors."""
try:
# Skip autogenerated variables when empty
if variable.autogenerated and not variable.value:
return
# Skip variables with unsatisfied needs (even if required)
if not self.is_variable_satisfied(var_name):
logger.debug(f"Skipping validation for variable '{var_name}' - needs not satisfied")
return
# Check required fields
if variable.value is None:
if variable.is_required():
# Enhanced error message with context
origin_info = f" from {variable.origin}" if variable.origin else ""
logger.debug(
f"Required variable validation failed: '{var_name}'{origin_info} "
f"in section '{section.key}' has no value and no default"
)
errors.append(f"{section.key}.{var_name} (required{origin_info} - no default provided)")
return
typed = variable.convert(variable.value)
if variable.type not in ("bool",) and not typed:
msg = f"{section.key}.{var_name}"
error = f"{msg} (required - cannot be empty)" if variable.is_required() else f"{msg} (empty)"
errors.append(error)
except ValueError as e:
errors.append(f"{section.key}.{var_name} (invalid format: {e})")
def merge(
self,
other_spec: dict[str, Any] | VariableCollection,
origin: str = "override",
) -> VariableCollection:
"""Merge another spec or VariableCollection into this one with precedence tracking.
OPTIMIZED: Works directly on objects without dict conversions for better performance.
The other spec/collection has higher precedence and will override values in self.
Creates a new VariableCollection with merged data.
Args:
other_spec: Either a spec dictionary or another VariableCollection to merge
origin: Origin label for variables from other_spec (e.g., 'template', 'config')
Returns:
New VariableCollection with merged data
Example:
module_vars = VariableCollection(module_spec)
template_vars = module_vars.merge(template_spec, origin='template')
# Variables from template_spec override module_spec
# Origins tracked: 'module' or 'module -> template'
"""
logger.debug(f"Starting merge operation with origin '{origin}'")
# Convert dict to VariableCollection if needed (only once)
other = VariableCollection(other_spec) if isinstance(other_spec, dict) else other_spec
# Create new collection without calling __init__ (optimization)
merged = VariableCollection.__new__(VariableCollection)
merged._sections = {}
merged._variable_map = {}
# First pass: clone sections from self
for section_key, self_section in self._sections.items():
if section_key in other._sections:
# Section exists in both - will merge
merged._sections[section_key] = self._merge_sections(self_section, other._sections[section_key], origin)
else:
# Section only in self - clone it
merged._sections[section_key] = self_section.clone()
# Second pass: add sections that only exist in other
for section_key, other_section in other._sections.items():
if section_key not in merged._sections:
# New section from other - clone with origin update
merged._sections[section_key] = other_section.clone(origin_update=origin)
# Rebuild variable map for O(1) lookups
for section in merged._sections.values():
for var_name, variable in section.variables.items():
merged._variable_map[var_name] = variable
# Log merge statistics
self_var_count = sum(len(s.variables) for s in self._sections.values())
other_var_count = sum(len(s.variables) for s in other._sections.values())
merged_var_count = len(merged._variable_map)
logger.debug(
f"Merge complete: {len(self._sections)} sections (base) + {len(other._sections)} sections (override) = "
f"{len(merged._sections)} sections, {self_var_count} vars + "
f"{other_var_count} vars = {merged_var_count} vars"
)
# Validate dependencies after merge is complete
merged._validate_dependencies()
return merged
def _merge_sections(
self, self_section: VariableSection, other_section: VariableSection, origin: str
) -> VariableSection:
"""Merge two sections, with other_section taking precedence."""
merged_section = self_section.clone()
# Update section metadata from other (other takes precedence)
# Explicit null/empty values clear the property (reset mechanism)
for attr in ("title", "description", "toggle"):
if hasattr(other_section, "_explicit_fields") and attr in other_section._explicit_fields:
# Set to the other value even if null/empty (enables explicit reset)
setattr(merged_section, attr, getattr(other_section, attr))
# Respect explicit clears for dependencies (explicit null/empty clears, missing field preserves)
if hasattr(other_section, "_explicit_fields") and "needs" in other_section._explicit_fields:
merged_section.needs = other_section.needs.copy() if other_section.needs else []
# Merge variables
for var_name, other_var in other_section.variables.items():
if var_name in merged_section.variables:
# Variable exists in both - merge with other taking precedence
self_var = merged_section.variables[var_name]
# Build update dict with ONLY explicitly provided fields from other
update = {"origin": origin}
field_map = {
"type": other_var.type,
"description": other_var.description,
"prompt": other_var.prompt,
"options": other_var.options,
"sensitive": other_var.sensitive,
"extra": other_var.extra,
}
# Add fields that were explicitly provided, even if falsy/empty
for field, value in field_map.items():
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 ("autogenerated", "required"):
if bool_field in other_var._explicit_fields:
update[bool_field] = getattr(other_var, bool_field)
# Special handling for needs (allow explicit null/empty to clear)
if "needs" in other_var._explicit_fields:
update["needs"] = other_var.needs.copy() if other_var.needs else []
# Special handling for value/default (allow explicit null to clear)
if "value" in other_var._explicit_fields or "default" in other_var._explicit_fields:
update["value"] = other_var.value
merged_section.variables[var_name] = self_var.clone(update=update)
else:
# New variable from other - clone with origin
merged_section.variables[var_name] = other_var.clone(update={"origin": origin})
return merged_section
def filter_to_used(self, used_variables: set[str], keep_sensitive: bool = True) -> VariableCollection:
"""Filter collection to only variables that are used (or sensitive).
OPTIMIZED: Works directly on objects without dict conversions for better performance.
Creates a new VariableCollection containing only the variables in used_variables.
Sections with no remaining variables are removed.
Args:
used_variables: Set of variable names that are actually used
keep_sensitive: If True, also keep sensitive variables even if not in used set
Returns:
New VariableCollection with filtered variables
Example:
all_vars = VariableCollection(spec)
used_vars = all_vars.filter_to_used({'var1', 'var2', 'var3'})
# Only var1, var2, var3 (and any sensitive vars) remain
"""
# Create new collection without calling __init__ (optimization)
filtered = VariableCollection.__new__(VariableCollection)
filtered._sections = {}
filtered._variable_map = {}
# Filter each section
for section_key, section in self._sections.items():
# Create a new section with same metadata
filtered_section = VariableSection(
{
"key": section.key,
"title": section.title,
"description": section.description,
"toggle": section.toggle,
"needs": section.needs.copy() if section.needs else None,
}
)
# Clone only the variables that should be included
for var_name, variable in section.variables.items():
# Include if used OR if sensitive (and keep_sensitive is True)
should_include = var_name in used_variables or (keep_sensitive and variable.sensitive)
if should_include:
filtered_section.variables[var_name] = variable.clone()
# Only add section if it has variables
if filtered_section.variables:
filtered._sections[section_key] = filtered_section
# Add variables to map
for var_name, variable in filtered_section.variables.items():
filtered._variable_map[var_name] = variable
return filtered
def get_all_variable_names(self) -> set[str]:
"""Get set of all variable names across all sections.
Returns:
Set of all variable names
"""
return set(self._variable_map.keys())
================================================
FILE: cli/core/template/variable_section.py
================================================
from __future__ import annotations
from collections import OrderedDict
from typing import Any
from ..exceptions import VariableError
from .variable import Variable
class VariableSection:
"""Groups variables together with shared metadata for presentation."""
def __init__(self, data: dict[str, Any]) -> None:
"""Initialize VariableSection from a dictionary.
Args:
data: Dictionary containing section specification with required 'key' and 'title' keys
"""
if not isinstance(data, dict):
raise VariableError("VariableSection data must be a dictionary")
if "key" not in data:
raise VariableError("VariableSection data must contain 'key'")
if "title" not in data:
raise VariableError("VariableSection data must contain 'title'")
self.key: str = data["key"]
self.title: str = data["title"]
self.variables: OrderedDict[str, Variable] = OrderedDict()
self.description: str | None = data.get("description")
self.toggle: str | None = data.get("toggle")
# Track which fields were explicitly provided (to support explicit clears)
self._explicit_fields: set[str] = set(data.keys())
# Section dependencies - can be string or list of strings
# Supports semicolon-separated multiple conditions: "var1=value1;var2=value2,value3"
needs_value = data.get("needs")
if needs_value:
if isinstance(needs_value, str):
# Split by semicolon to support multiple AND conditions in a single string
# Example: "traefik_enabled=true;network_mode=bridge,macvlan"
self.needs: list[str] = [need.strip() for need in needs_value.split(";") if need.strip()]
elif isinstance(needs_value, list):
self.needs: list[str] = needs_value
else:
raise VariableError(f"Section '{self.key}' has invalid 'needs' value: must be string or list")
else:
self.needs: list[str] = []
def to_dict(self) -> dict[str, Any]:
"""Serialize VariableSection to a dictionary for storage."""
section_dict = {
"vars": {name: var.to_dict() for name, var in self.variables.items()},
}
# Add optional fields if present
for field in ("title", "description", "toggle"):
if value := getattr(self, field):
section_dict[field] = value
# Store dependencies (single value if only one, list otherwise)
if self.needs:
section_dict["needs"] = self.needs[0] if len(self.needs) == 1 else self.needs
return section_dict
def is_enabled(self) -> bool:
"""Check if section is currently enabled based on toggle variable.
Returns:
True if section is enabled (no toggle, or toggle is True), False otherwise
"""
if not self.toggle:
return True
toggle_var = self.variables.get(self.toggle)
if not toggle_var:
return True
try:
return bool(toggle_var.convert(toggle_var.value))
except Exception:
return False
def clone(self, origin_update: str | None = None) -> VariableSection:
"""Create a deep copy of the section with all variables.
This is more efficient than converting to dict and back when copying sections.
Args:
origin_update: Optional origin string to apply to all cloned variables
Returns:
New VariableSection instance with deep-copied variables
Example:
section2 = section1.clone(origin_update='template')
"""
# Create new section with same metadata
cloned = VariableSection(
{
"key": self.key,
"title": self.title,
"description": self.description,
"toggle": self.toggle,
"needs": self.needs.copy() if self.needs else None,
}
)
# Deep copy all variables
for var_name, variable in self.variables.items():
if origin_update:
cloned.variables[var_name] = variable.clone(update={"origin": origin_update})
else:
cloned.variables[var_name] = variable.clone()
return cloned
def _build_dependency_graph(self, var_list: list[str]) -> dict[str, list[str]]:
"""Build dependency graph for variables in this section."""
var_set = set(var_list)
dependencies = {var_name: [] for var_name in var_list}
for var_name in var_list:
variable = self.variables[var_name]
if not variable.needs:
continue
for need in variable.needs:
# Parse need format: "variable_name=value"
dep_var = need.split("=")[0] if "=" in need else need
# Only track dependencies within THIS section
if dep_var in var_set and dep_var != var_name:
dependencies[var_name].append(dep_var)
return dependencies
def _topological_sort(self, var_list: list[str], dependencies: dict[str, list[str]]) -> list[str]:
"""Perform topological sort using Kahn's algorithm."""
var_order = {var_name: index for index, var_name in enumerate(var_list)}
in_degree = {var_name: len(deps) for var_name, deps in dependencies.items()}
queue = [var for var, degree in in_degree.items() if degree == 0]
queue.sort(key=var_order.__getitem__)
result = []
while queue:
current = queue.pop(0)
result.append(current)
# Update in-degree for dependent variables
for var_name, deps in dependencies.items():
if current in deps:
in_degree[var_name] -= 1
if in_degree[var_name] == 0:
queue.append(var_name)
queue.sort(key=var_order.__getitem__)
# If not all variables were sorted (cycle), append remaining in original order
if len(result) != len(var_list):
result.extend(var_name for var_name in var_list if var_name not in result)
return result
def sort_variables(self, _is_need_satisfied_func=None) -> None:
"""Sort variables within section for optimal display and user interaction.
Current sorting strategy:
- Variables with no dependencies come first
- Variables that depend on others come after their dependencies (topological sort)
- Original order is preserved for variables at the same dependency level
Future sorting strategies can be added here (e.g., by type, required first, etc.)
Args:
_is_need_satisfied_func: Optional function to check if a variable need is satisfied
(unused, reserved for future use in conditional sorting)
"""
if not self.variables:
return
var_list = list(self.variables.keys())
dependencies = self._build_dependency_graph(var_list)
result = self._topological_sort(var_list, dependencies)
# Rebuild variables OrderedDict in new order
self.variables = OrderedDict((var_name, self.variables[var_name]) for var_name in result)
================================================
FILE: cli/core/validators.py
================================================
"""Semantic validators for template content.
This module provides validators for specific file types and formats,
enabling semantic validation beyond Jinja2 syntax checking.
"""
from __future__ import annotations
import logging
from abc import ABC, abstractmethod
from pathlib import Path
from typing import TYPE_CHECKING, Any, ClassVar
if TYPE_CHECKING:
pass
import yaml
from .display import DisplayManager
logger = logging.getLogger(__name__)
class ValidationResult:
"""Represents the result of a validation operation."""
def __init__(self):
self.errors: list[str] = []
self.warnings: list[str] = []
self.info: list[str] = []
def add_error(self, message: str) -> None:
"""Add an error message."""
self.errors.append(message)
logger.error(f"Validation error: {message}")
def add_warning(self, message: str) -> None:
"""Add a warning message."""
self.warnings.append(message)
logger.warning(f"Validation warning: {message}")
def add_info(self, message: str) -> None:
"""Add an info message."""
self.info.append(message)
logger.info(f"Validation info: {message}")
@property
def is_valid(self) -> bool:
"""Check if validation passed (no errors)."""
return len(self.errors) == 0
@property
def has_warnings(self) -> bool:
"""Check if validation has warnings."""
return len(self.warnings) > 0
def display(self, context: str = "Validation") -> None:
"""Display validation results using DisplayManager."""
display = DisplayManager()
if self.errors:
display.error(f"\n✗ {context} Failed:")
for error in self.errors:
display.error(f" • {error}")
if self.warnings:
display.warning(f"\n⚠ {context} Warnings:")
for warning in self.warnings:
display.warning(f" • {warning}")
if self.info:
display.text(f"\n[blue]i {context} Info:[/blue]")
for info_msg in self.info:
display.text(f" [blue]• {info_msg}[/blue]")
if self.is_valid and not self.has_warnings:
display.text(f"\n[green]✓ {context} Passed[/green]")
class ContentValidator(ABC):
"""Abstract base class for content validators."""
@abstractmethod
def validate(self, content: str, _file_path: str) -> ValidationResult:
"""Validate content and return results.
Args:
content: The file content to validate
_file_path: Path to the file (unused in base class, kept for API compatibility)
Returns:
ValidationResult with errors, warnings, and info
"""
pass
@abstractmethod
def can_validate(self, file_path: str) -> bool:
"""Check if this validator can validate the given file.
Args:
file_path: Path to the file
Returns:
True if this validator can handle the file
"""
pass
class DockerComposeValidator(ContentValidator):
"""Validator for Docker Compose files."""
COMPOSE_FILENAMES: ClassVar[set[str]] = {
"docker-compose.yml",
"docker-compose.yaml",
"compose.yml",
"compose.yaml",
}
def can_validate(self, file_path: str) -> bool:
"""Check if file is a Docker Compose file."""
filename = Path(file_path).name.lower()
return filename in self.COMPOSE_FILENAMES
def validate(self, content: str, _file_path: str) -> ValidationResult:
"""Validate Docker Compose file structure."""
result = ValidationResult()
try:
# Parse YAML
data = yaml.safe_load(content)
if not isinstance(data, dict):
result.add_error("Docker Compose file must be a YAML dictionary")
return result
# Check for version (optional in Compose v2, but good practice)
if "version" not in data:
result.add_info("No 'version' field specified (using Compose v2 format)")
# Check for services (required)
if "services" not in data:
result.add_error("Missing required 'services' section")
return result
services = data.get("services", {})
if not isinstance(services, dict):
result.add_error("'services' must be a dictionary")
return result
if not services:
result.add_warning("No services defined")
# Validate each service
for service_name, service_config in services.items():
self._validate_service(service_name, service_config, result)
# Check for networks (optional but recommended)
if "networks" in data:
networks = data.get("networks", {})
if networks and isinstance(networks, dict):
result.add_info(f"Defines {len(networks)} network(s)")
# Check for volumes (optional)
if "volumes" in data:
volumes = data.get("volumes", {})
if volumes and isinstance(volumes, dict):
result.add_info(f"Defines {len(volumes)} volume(s)")
except yaml.YAMLError as e:
result.add_error(f"YAML parsing error: {e}")
except Exception as e:
result.add_error(f"Unexpected validation error: {e}")
return result
def _validate_service(self, name: str, config: Any, result: ValidationResult) -> None:
"""Validate a single service configuration."""
if not isinstance(config, dict):
result.add_error(f"Service '{name}': configuration must be a dictionary")
return
# Check for image or build (at least one required)
has_image = "image" in config
has_build = "build" in config
if not has_image and not has_build:
result.add_error(f"Service '{name}': must specify 'image' or 'build'")
# Warn about common misconfigurations
if "restart" in config:
restart_value = config["restart"]
valid_restart_policies = ["no", "always", "on-failure", "unless-stopped"]
if restart_value not in valid_restart_policies:
result.add_warning(
f"Service '{name}': restart policy '{restart_value}' may be invalid. "
f"Valid values: {', '.join(valid_restart_policies)}"
)
# Check for environment variables
if "environment" in config:
env = config["environment"]
if isinstance(env, list):
# Check for duplicate keys in list format
keys = [e.split("=")[0] for e in env if isinstance(e, str) and "=" in e]
duplicates = {k for k in keys if keys.count(k) > 1}
if duplicates:
dups = ", ".join(duplicates)
result.add_warning(f"Service '{name}': duplicate environment variables: {dups}")
# Check for ports
if "ports" in config:
ports = config["ports"]
if not isinstance(ports, list):
result.add_warning(f"Service '{name}': 'ports' should be a list")
class YAMLValidator(ContentValidator):
"""Basic YAML syntax validator."""
def can_validate(self, file_path: str) -> bool:
"""Check if file is a YAML file."""
return Path(file_path).suffix.lower() in [".yml", ".yaml"]
def validate(self, content: str, _file_path: str) -> ValidationResult:
"""Validate YAML syntax."""
result = ValidationResult()
try:
yaml.safe_load(content)
result.add_info("YAML syntax is valid")
except yaml.YAMLError as e:
result.add_error(f"YAML parsing error: {e}")
return result
class ValidatorRegistry:
"""Registry for content validators."""
def __init__(self):
self.validators: list[ContentValidator] = []
self._register_default_validators()
def _register_default_validators(self) -> None:
"""Register built-in validators."""
self.register(DockerComposeValidator())
self.register(YAMLValidator())
def register(self, validator: ContentValidator) -> None:
"""Register a validator.
Args:
validator: The validator to register
"""
self.validators.append(validator)
logger.debug(f"Registered validator: {validator.__class__.__name__}")
def get_validator(self, file_path: str) -> ContentValidator | None:
"""Get the most appropriate validator for a file.
Args:
file_path: Path to the file
Returns:
ContentValidator if found, None otherwise
"""
# Try specific validators first (e.g., DockerComposeValidator before YAMLValidator)
for validator in self.validators:
if validator.can_validate(file_path):
return validator
return None
def validate_file(self, content: str, file_path: str) -> ValidationResult:
"""Validate file content using appropriate validator.
Args:
content: The file content
file_path: Path to the file
Returns:
ValidationResult with validation results
"""
validator = self.get_validator(file_path)
if validator:
logger.debug(f"Validating {file_path} with {validator.__class__.__name__}")
return validator.validate(content, file_path)
# No validator found - return empty result
result = ValidationResult()
result.add_info(f"No semantic validator available for {Path(file_path).suffix} files")
return result
# Global registry instance
_registry = ValidatorRegistry()
def get_validator_registry() -> ValidatorRegistry:
"""Get the global validator registry."""
return _registry
================================================
FILE: cli/core/version.py
================================================
"""Version comparison utilities for semantic versioning.
This module provides utilities for parsing and comparing semantic version strings.
Supports version strings in the format: major.minor (e.g., "1.0", "1.2")
"""
from __future__ import annotations
import logging
import re
logger = logging.getLogger(__name__)
def parse_version(version_str: str) -> tuple[int, int]:
"""Parse a semantic version string into a tuple of integers.
Args:
version_str: Version string in format "major.minor" (e.g., "1.0", "1.2")
Returns:
Tuple of (major, minor) as integers
Raises:
ValueError: If version string is not in valid semantic version format
Examples:
>>> parse_version("1.0")
(1, 0)
>>> parse_version("1.2")
(1, 2)
"""
if not version_str:
raise ValueError("Version string cannot be empty")
# Remove 'v' prefix if present
version_str = version_str.lstrip("v")
# Match semantic version pattern: major.minor
pattern = r"^(\d+)\.(\d+)$"
match = re.match(pattern, version_str)
if not match:
raise ValueError(f"Invalid version format '{version_str}'. Expected format: major.minor (e.g., '1.0', '1.2')")
major, minor = match.groups()
return (int(major), int(minor))
def compare_versions(version1: str, version2: str) -> int:
"""Compare two semantic version strings.
Args:
version1: First version string
version2: Second version string
Returns:
-1 if version1 < version2
0 if version1 == version2
1 if version1 > version2
Raises:
ValueError: If either version string is invalid
Examples:
>>> compare_versions("1.0", "0.9")
1
>>> compare_versions("1.0", "1.0")
0
>>> compare_versions("1.0", "1.1")
-1
"""
v1 = parse_version(version1)
v2 = parse_version(version2)
if v1 < v2:
return -1
if v1 > v2:
return 1
return 0
def is_compatible(current_version: str, required_version: str) -> bool:
"""Check if current version meets the minimum required version.
Args:
current_version: Current version
required_version: Minimum required version
Returns:
True if current_version >= required_version, False otherwise
Examples:
>>> is_compatible("1.0", "0.9")
True
>>> is_compatible("1.0", "1.0")
True
>>> is_compatible("1.0", "1.1")
False
"""
try:
return compare_versions(current_version, required_version) >= 0
except ValueError as e:
logger.warning("Version compatibility check failed: %s", e)
# If we can't parse versions, assume incompatible for safety
return False
================================================
FILE: cli/modules/__init__.py
================================================
"""Modules package."""
================================================
FILE: cli/modules/ansible/__init__.py
================================================
"""Ansible module."""
import logging
from ...core.module import Module
from ...core.registry import registry
logger = logging.getLogger(__name__)
class AnsibleModule(Module):
"""Ansible module."""
name = "ansible"
description = "Manage Ansible configurations"
registry.register(AnsibleModule)
================================================
FILE: cli/modules/compose/__init__.py
================================================
"""Docker Compose module."""
from __future__ import annotations
import logging
from typing import Annotated
from typer import Argument, Option
from ...core.module import Module
from ...core.module.base_commands import validate_templates
from ...core.registry import registry
from .validate import run_docker_validation
logger = logging.getLogger(__name__)
class ComposeModule(Module):
"""Docker Compose module with extended validation."""
name = "compose"
description = "Manage Docker Compose configurations"
def validate( # noqa: PLR0913
self,
template_id: Annotated[
str | None,
Argument(help="Template ID to validate (omit to validate all templates)"),
] = None,
*,
path: Annotated[
str | None,
Option("--path", help="Path to template directory for validation"),
] = None,
verbose: Annotated[bool, Option("--verbose", "-v", help="Show detailed validation information")] = False,
semantic: Annotated[
bool,
Option(
"--semantic/--no-semantic",
help="Enable semantic validation (Docker Compose schema, etc.)",
),
] = True,
docker: Annotated[
bool,
Option(
"--docker/--no-docker",
help="Enable Docker Compose validation using 'docker compose config'",
),
] = False,
docker_test_all: Annotated[
bool,
Option(
"--docker-test-all",
help="Test all variable combinations (minimal, maximal, each toggle). Requires --docker",
),
] = False,
) -> None:
"""Validate templates for Jinja2 syntax, undefined variables, and semantic correctness.
Extended for Docker Compose with optional docker compose config validation.
Use --docker for single config test, --docker-test-all for comprehensive testing.
Examples:
# Validate specific template
compose validate netbox
# Validate all templates
compose validate
# Validate with Docker Compose config check
compose validate netbox --docker
"""
# Run standard validation first
validate_templates(self, template_id, path, verbose, semantic)
# If docker validation is enabled and we have a specific template
if docker and (template_id or path):
run_docker_validation(self, template_id, path, docker_test_all, verbose)
registry.register(ComposeModule)
================================================
FILE: cli/modules/compose/validate.py
================================================
"""Docker Compose validation functionality."""
from __future__ import annotations
import logging
import subprocess
import tempfile
from pathlib import Path
from typer import Exit
from ...core.template import Template
logger = logging.getLogger(__name__)
def run_docker_validation(
module_instance,
template_id: str | None,
path: str | None,
test_all: bool,
verbose: bool,
) -> None:
"""Run Docker Compose validation using docker compose config.
Args:
module_instance: The module instance (for display and template loading)
template_id: Template ID to validate
path: Path to template directory
test_all: Test all variable combinations
verbose: Show detailed output
Raises:
Exit: If validation fails or docker is not available
"""
try:
# Load the template
if path:
template_path = Path(path).resolve()
template = Template(template_path, library_name="local")
else:
template = module_instance._load_template_by_id(template_id)
module_instance.display.info("")
module_instance.display.info("Running Docker Compose validation...")
# Test multiple combinations or single configuration
if test_all:
_test_variable_combinations(module_instance, template, verbose)
else:
# Single configuration with template defaults
success = _validate_compose_files(
module_instance, template, template.variables, verbose, "Template defaults"
)
if success:
module_instance.display.success("Docker Compose validation passed")
else:
module_instance.display.error("Docker Compose validation failed")
raise Exit(code=1) from None
except FileNotFoundError as e:
module_instance.display.error(
"Docker Compose CLI not found",
context="Install Docker Desktop or Docker Engine with Compose plugin",
)
raise Exit(code=1) from e
except Exception as e:
module_instance.display.error(f"Docker validation failed: {e}")
raise Exit(code=1) from e
def _validate_compose_files(module_instance, template, variables, verbose: bool, config_name: str) -> bool:
"""Validate rendered compose files using docker compose config.
Args:
module_instance: The module instance
template: The template object
variables: VariableCollection with configured values
verbose: Show detailed output
config_name: Name of this configuration (for display)
Returns:
True if validation passed, False otherwise
"""
try:
# Render the template
debug_mode = logger.isEnabledFor(logging.DEBUG)
rendered_files, _ = template.render(variables, debug=debug_mode)
# Find compose files
compose_files = [
(filename, content)
for filename, content in rendered_files.items()
if filename.endswith(("compose.yaml", "compose.yml", "docker-compose.yaml", "docker-compose.yml"))
]
if not compose_files:
module_instance.display.warning(f"[{config_name}] No Docker Compose files found")
return True
# Validate each compose file
has_errors = False
for filename, content in compose_files:
if verbose:
module_instance.display.info(f"[{config_name}] Validating: {filename}")
# Write to temporary file
with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as tmp_file:
tmp_file.write(content)
tmp_path = tmp_file.name
try:
# Run docker compose config
result = subprocess.run(
["docker", "compose", "-f", tmp_path, "config", "--quiet"],
capture_output=True,
text=True,
check=False,
)
if result.returncode != 0:
has_errors = True
module_instance.display.error(f"[{config_name}] Docker validation failed for {filename}")
if result.stderr:
module_instance.display.info(f"\n{result.stderr}")
elif verbose:
module_instance.display.success(f"[{config_name}] Docker validation passed: {filename}")
finally:
# Clean up temporary file
Path(tmp_path).unlink(missing_ok=True)
return not has_errors
except Exception as e:
module_instance.display.error(f"[{config_name}] Validation failed: {e}")
return False
def _test_variable_combinations(module_instance, template, verbose: bool) -> None:
"""Test multiple variable combinations intelligently.
Tests:
1. Minimal config (all toggles OFF)
2. Maximal config (all toggles ON)
3. Each toggle individually ON (to isolate toggle-specific issues)
Args:
module_instance: The module instance
template: The template object
verbose: Show detailed output
Raises:
Exit: If any validation fails
"""
module_instance.display.info("Testing multiple variable combinations...")
module_instance.display.info("")
# Find all boolean toggle variables
toggle_vars = _find_toggle_variables(template)
if not toggle_vars:
module_instance.display.warning("No toggle variables found - testing default configuration only")
success = _validate_compose_files(module_instance, template, template.variables, verbose, "Default")
if not success:
raise Exit(code=1) from None
module_instance.display.success("Docker Compose validation passed")
return
module_instance.display.info(f"Found {len(toggle_vars)} toggle variable(s): {', '.join(toggle_vars)}")
module_instance.display.info("")
all_passed = True
test_count = 0
# Test 1: Minimal (all OFF)
module_instance.display.info("[1/3] Testing minimal configuration (all toggles OFF)...")
toggle_config = dict.fromkeys(toggle_vars, False)
variables = _get_variables_with_toggles(module_instance, template, toggle_config)
if not _validate_compose_files(module_instance, template, variables, verbose, "Minimal"):
all_passed = False
test_count += 1
module_instance.display.info("")
# Test 2: Maximal (all ON)
module_instance.display.info("[2/3] Testing maximal configuration (all toggles ON)...")
toggle_config = dict.fromkeys(toggle_vars, True)
variables = _get_variables_with_toggles(module_instance, template, toggle_config)
if not _validate_compose_files(module_instance, template, variables, verbose, "Maximal"):
all_passed = False
test_count += 1
module_instance.display.info("")
# Test 3: Each toggle individually
module_instance.display.info(f"[3/3] Testing each toggle individually ({len(toggle_vars)} tests)...")
for i, toggle in enumerate(toggle_vars, 1):
# Set all OFF except the current one
toggle_config = {t: t == toggle for t in toggle_vars}
variables = _get_variables_with_toggles(module_instance, template, toggle_config)
config_name = f"{toggle}=true"
if not _validate_compose_files(module_instance, template, variables, verbose, config_name):
all_passed = False
test_count += 1
if verbose and i < len(toggle_vars):
module_instance.display.info("")
# Summary
module_instance.display.info("")
module_instance.display.info("─" * 80)
if all_passed:
module_instance.display.success(f"All {test_count} configuration(s) passed Docker Compose validation")
else:
module_instance.display.error("Some configurations failed Docker Compose validation")
raise Exit(code=1) from None
def _find_toggle_variables(template) -> list[str]:
"""Find all boolean toggle variables in a template.
Args:
template: The template object
Returns:
List of toggle variable names
"""
toggle_vars = []
for var_name, var in template.variables._variable_map.items():
if var.type == "bool" and var_name.endswith("_enabled"):
toggle_vars.append(var_name)
return sorted(toggle_vars)
def _get_variables_with_toggles(module_instance, template, toggle_config: dict[str, bool]): # noqa: ARG001
"""Get VariableCollection with specific toggle settings.
Args:
module_instance: The module instance (unused, for signature consistency)
template: The template object
toggle_config: Dict mapping toggle names to boolean values
Returns:
VariableCollection with configured toggle values
"""
# Reload template to get fresh VariableCollection
# (template.variables is mutated by previous calls)
fresh_template = Template(template.template_dir, library_name=template.metadata.library)
variables = fresh_template.variables
# Apply toggle configuration
for toggle_name, toggle_value in toggle_config.items():
if toggle_name in variables._variable_map:
variables._variable_map[toggle_name].value = toggle_value
return variables
================================================
FILE: cli/modules/helm/__init__.py
================================================
"""Helm module."""
import logging
from ...core.module import Module
from ...core.registry import registry
logger = logging.getLogger(__name__)
class HelmModule(Module):
"""Helm module."""
name = "helm"
description = "Manage Helm configurations"
registry.register(HelmModule)
================================================
FILE: cli/modules/kubernetes/__init__.py
================================================
"""Kubernetes module."""
import logging
from ...core.module import Module
from ...core.registry import registry
logger = logging.getLogger(__name__)
class KubernetesModule(Module):
"""Kubernetes module."""
name = "kubernetes"
description = "Manage Kubernetes configurations"
registry.register(KubernetesModule)
================================================
FILE: cli/modules/packer/__init__.py
================================================
"""Packer module."""
import logging
from ...core.module import Module
from ...core.registry import registry
logger = logging.getLogger(__name__)
class PackerModule(Module):
"""Packer module."""
name = "packer"
description = "Manage Packer configurations"
registry.register(PackerModule)
================================================
FILE: cli/modules/terraform/__init__.py
================================================
"""Terraform module."""
import logging
from ...core.module import Module
from ...core.registry import registry
logger = logging.getLogger(__name__)
class TerraformModule(Module):
"""Terraform module."""
name = "terraform"
description = "Manage Terraform configurations"
registry.register(TerraformModule)
================================================
FILE: flake.nix
================================================
{
description = "A curated collection of production-ready templates for your homelab and infrastructure projects";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = {
self,
nixpkgs,
flake-utils,
}:
flake-utils.lib.eachDefaultSystem (
system: let
pkgs = nixpkgs.legacyPackages.${system};
boilerplates = pkgs.python3Packages.buildPythonApplication {
pname = "boilerplates";
version = "0.1.2";
src = ./.;
format = "pyproject";
nativeBuildInputs = with pkgs.python3Packages; [
setuptools
wheel
];
propagatedBuildInputs = with pkgs.python3Packages; [
typer
rich
pyyaml
python-frontmatter
jinja2
email-validator
];
meta = with pkgs.lib; {
description = "A CLI for managing boilerplates and templates";
homepage = "https://github.com/christianlempa/boilerplates";
license = licenses.mit;
maintainers = ["Théo Posty "];
mainProgram = "boilerplates";
};
};
in {
packages = {
default = boilerplates;
boilerplates = boilerplates;
};
apps = {
default = {
type = "app";
program = "${boilerplates}/bin/boilerplates";
};
};
}
);
}
================================================
FILE: library/ansible/checkmk-install-agent/playbook.yaml.j2
================================================
---
- name: Install Checkmk agent on all hosts
hosts: {{ target_hosts }}
become: true
roles:
- checkmk.general.agent
vars:
checkmk_agent_version: "2.4.0p15"
checkmk_agent_server: {{ checkmk_server }}
checkmk_agent_server_protocol: {{ checkmk_protocol }}
checkmk_agent_site: {{ checkmk_site }}
checkmk_agent_auto_activate: {{ checkmk_auto_activate }}
checkmk_agent_tls: {{ checkmk_tls }}
checkmk_agent_user: {{ checkmk_user }}
checkmk_agent_pass: {{ checkmk_pass }}
checkmk_agent_host_name: {{ checkmk_host }}
================================================
FILE: library/ansible/checkmk-install-agent/template.yaml
================================================
---
kind: ansible
metadata:
name: Install Checkmk Agent
description: |-
Ansible playbook to install Checkmk monitoring agent on hosts. Uses the checkmk.general.agent role with automatic registration.
## References
- **Project**: https://github.com/Checkmk/ansible-collection-checkmk.general
- **Documentation**: https://docs.checkmk.com/
version: 2.4.0
author: Christian Lempa
date: "2025-11-11"
tags: []
icon:
provider: selfh
id: checkmk
draft: false
next_steps: ""
schema: "1.2"
spec:
general:
vars:
target_hosts:
description: Target hosts
type: str
required: true
checkmk:
title: Checkmk Configuration
vars:
checkmk_server:
type: str
description: Checkmk Server
required: true
checkmk_protocol:
type: str
description: Checkmk Server Protocol
enum:
- http
- https
default: https
required: true
checkmk_site:
type: str
description: Checkmk Site
default: cmk
required: true
checkmk_auto_activate:
type: bool
description: Auto Activate Agent
checkmk_tls:
type: bool
description: Use TLS for Agent Communication
checkmk_user:
type: str
description: Checkmk Automation User
required: true
checkmk_pass:
type: str
description: Checkmk Automation User Password
required: true
sensitive: true
checkmk_host:
type: str
description: Checkmk Host Name
required: true
================================================
FILE: library/ansible/checkmk-manage-host/playbook.yaml.j2
================================================
---
- name: Manage Checkmk host
hosts: localhost
gather_facts: false
tasks:
- name: "Create or update host in Checkmk"
checkmk.general.host:
server_url: "{{ checkmk_protocol }}://{{ checkmk_server }}"
site: {{ checkmk_site }}
automation_user: {{ checkmk_user }}
automation_secret: {{ checkmk_pass }}
name: {{ host_name }}
attributes:
ipaddress: {{ host_ip }}
folder: {{ host_folder }}
state: "present"
================================================
FILE: library/ansible/checkmk-manage-host/template.yaml
================================================
---
kind: ansible
metadata:
name: Manage Checkmk Host
description: |-
Ansible playbook to manage hosts in Checkmk monitoring. Uses the checkmk.general.host module to create or update host configuration.
## References
- **Project**: https://github.com/Checkmk/ansible-collection-checkmk.general
- **Documentation**: https://docs.checkmk.com/
version: 2.4.0
author: Christian Lempa
date: "2025-11-11"
tags: []
icon:
provider: selfh
id: checkmk
draft: false
next_steps: ""
schema: "1.2"
spec:
general:
vars:
target_hosts:
description: Target hosts or group
type: str
required: true
checkmk:
title: Checkmk Configuration
vars:
checkmk_server:
type: str
description: Checkmk Server
required: true
checkmk_protocol:
type: str
description: Checkmk Server Protocol
enum:
- http
- https
default: https
required: true
checkmk_site:
type: str
description: Checkmk Site
default: cmk
required: true
checkmk_user:
type: str
description: Checkmk Automation User
required: true
checkmk_pass:
type: str
description: Checkmk Automation User Password
required: true
sensitive: true
host:
title: Host Configuration
vars:
host_name:
type: str
description: Hostname to add to Checkmk
required: true
host_ip:
type: str
description: IP address of the host
required: true
host_folder:
type: str
description: Folder path in Checkmk
default: /
required: true
================================================
FILE: library/ansible/docker-certs/playbook.yaml.j2
================================================
---
- name: {{ playbook_name }}
hosts: {{ target_hosts }}
{% if become %}
become: true
{% endif %}
{% if options_enabled and not gather_facts %}
gather_facts: false
{% endif %}
{% if secrets_enabled %}
vars_files:
- {{ secrets_file }}
{% endif %}
vars:
certs_path: {{ certs_path }}
cert_validity_days: {{ cert_validity_days }}
cn_domain: {{ cn_domain }}
tasks:
- name: Check if docker certs are existing
ansible.builtin.stat:
path: {{ '{{' }} certs_path {{ '}}' }}
register: certs_dir
- name: Create docker certs directory (if needed)
ansible.builtin.file:
path: {{ '{{' }} certs_path {{ '}}' }}
state: directory
mode: '0700'
when: not certs_dir.stat.exists
- name: Check if docker certs directory is empty
ansible.builtin.command: ls -A {{ '{{' }} certs_path {{ '}}' }}
register: certs_list
when: certs_dir.stat.exists
changed_when: false
ignore_errors: true
- name: Fail if docker certs already exist
ansible.builtin.fail:
msg: "Docker certificates already exist in /root/docker-certs."
when: certs_list.stdout | default('') != ''
- name: Get machine's primary internal ip address from eth0 interface
ansible.builtin.setup:
register: ip_address
- name: Set machine's primary internal ip address
ansible.builtin.set_fact:
ip_address: {{ '{{' }} ip_address.ansible_facts.ansible_default_ipv4.address {{ '}}' }}
- name: Check if ip_address is a valid ip address
ansible.builtin.assert:
that:
- ip_address is match("^(?:[0-9]{1,3}\\.){3}[0-9]{1,3}$")
fail_msg: "ip_address is not a valid ip address."
success_msg: "ip_address is a valid ip address."
- name: Generate CA private key
ansible.builtin.command:
cmd: >
openssl genrsa -out {{ '{{' }} certs_path {{ '}}' }}/ca-key.pem 4096
args:
creates: {{ '{{' }} certs_path {{ '}}' }}/ca-key.pem
- name: Generate CA certificate
ansible.builtin.command:
cmd: >
openssl req -sha256 -new -x509
-subj "/CN={{ '{{' }} cn_domain {{ '}}' }}"
-days {{ '{{' }} cert_validity_days {{ '}}' }}
-key {{ '{{' }} certs_path {{ '}}' }}/ca-key.pem
-out {{ '{{' }} certs_path {{ '}}' }}/ca.pem
args:
creates: {{ '{{' }} certs_path {{ '}}' }}/ca.pem
- name: Generate server private key
ansible.builtin.command:
cmd: >
openssl genrsa -out {{ '{{' }} certs_path {{ '}}' }}/server-key.pem 4096
creates: {{ '{{' }} certs_path {{ '}}' }}/server-key.pem
- name: Generate server certificate signing request
ansible.builtin.command:
cmd: >
openssl req -sha256 -new
-subj "/CN={{ '{{' }} inventory_hostname {{ '}}' }}"
-key {{ '{{' }} certs_path {{ '}}' }}/server-key.pem
-out {{ '{{' }} certs_path {{ '}}' }}/server.csr
creates: {{ '{{' }} certs_path {{ '}}' }}/server.csr
- name: Generate server certificate extension file
ansible.builtin.shell: |
echo "subjectAltName = DNS:{{ '{{' }} inventory_hostname {{ '}}' }},IP:{{ '{{' }} ip_address {{ '}}' }},IP:127.0.0.1" >> {{ '{{' }} certs_path {{ '}}' }}/extfile.cnf
echo "extendedKeyUsage = serverAuth" >> {{ '{{' }} certs_path {{ '}}' }}/extfile.cnf
args:
creates: {{ '{{' }} certs_path {{ '}}' }}/extfile.cnf
- name: Generate server certificate
ansible.builtin.command:
cmd: >
openssl x509 -req -days {{ '{{' }} cert_validity_days {{ '}}' }} -sha256
-in {{ '{{' }} certs_path {{ '}}' }}/server.csr
-CA {{ '{{' }} certs_path {{ '}}' }}/ca.pem
-CAkey {{ '{{' }} certs_path {{ '}}' }}/ca-key.pem
-CAcreateserial -out {{ '{{' }} certs_path {{ '}}' }}/server-cert.pem
-extfile {{ '{{' }} certs_path {{ '}}' }}/extfile.cnf
creates: {{ '{{' }} certs_path {{ '}}' }}/server-cert.pem
- name: Generate client private key
ansible.builtin.command:
cmd: >
openssl genrsa -out {{ '{{' }} certs_path {{ '}}' }}/key.pem 4096
creates: {{ '{{' }} certs_path {{ '}}' }}/key.pem
- name: Generate client certificate signing request
ansible.builtin.command:
cmd: >
openssl req -sha256 -new
-subj "/CN=client"
-key {{ '{{' }} certs_path {{ '}}' }}/key.pem
-out {{ '{{' }} certs_path {{ '}}' }}/client.csr
creates: {{ '{{' }} certs_path {{ '}}' }}/client.csr
- name: Generate client certificate extension file
ansible.builtin.shell: |
echo "extendedKeyUsage = clientAuth" >> {{ '{{' }} certs_path {{ '}}' }}/client-extfile.cnf
args:
creates: {{ '{{' }} certs_path {{ '}}' }}/client-extfile.cnf
- name: Generate client certificate
ansible.builtin.command:
cmd: >
openssl x509 -req -days {{ '{{' }} cert_validity_days {{ '}}' }}
-sha256 -in {{ '{{' }} certs_path {{ '}}' }}/client.csr
-CA {{ '{{' }} certs_path {{ '}}' }}/ca.pem
-CAkey {{ '{{' }} certs_path {{ '}}' }}/ca-key.pem
-CAcreateserial -out {{ '{{' }} certs_path {{ '}}' }}/cert.pem
-extfile {{ '{{' }} certs_path {{ '}}' }}/client-extfile.cnf
creates: {{ '{{' }} certs_path {{ '}}' }}/cert.pem
- name: Remove client certificate signing request
ansible.builtin.file:
path: {{ '{{' }} certs_path {{ '}}' }}/server.csr
state: absent
- name: Remove client certificate signing request
ansible.builtin.file:
path: {{ '{{' }} certs_path {{ '}}' }}/client.csr
state: absent
- name: Remove server certificate extension file
ansible.builtin.file:
path: {{ '{{' }} certs_path {{ '}}' }}/extfile.cnf
state: absent
- name: Remove client certificate extension file
ansible.builtin.file:
path: {{ '{{' }} certs_path {{ '}}' }}/client-extfile.cnf
state: absent
- name: Set permissions for docker certs
ansible.builtin.file:
path: {{ '{{' }} certs_path {{ '}}' }}
mode: '0700'
recurse: true
follow: true
================================================
FILE: library/ansible/docker-certs/template.yaml
================================================
---
kind: ansible
metadata:
icon:
provider: selfh
id: docker
name: Generate Docker TLS Certificates
description: >
Ansible playbook to generate TLS certificates for Docker daemon.
Creates CA, server, and client certificates for secure Docker remote access.
Project: https://www.docker.com
Documentation: https://docs.docker.com/engine/security/protect-access/
version: 1.0.0
author: Christian Lempa
date: '2025-11-11'
draft: true
schema: "1.2"
spec:
general:
vars:
target_hosts:
description: Target hosts
type: str
required: true
playbook_name:
default: Docker Certs
become:
default: true
certificates:
title: Certificate Configuration
required: true
vars:
certs_path:
type: str
description: Path where certificates will be stored
default: /root/docker-certs
cert_validity_days:
type: int
description: Certificate validity period in days
default: 3650
cn_domain:
type: hostname
description: Common Name (CN) for the CA certificate
default: your-domain.tld
================================================
FILE: library/ansible/docker-certs-enable/playbook.yaml.j2
================================================
---
- name: {{ playbook_name }}
hosts: {{ target_hosts }}
{% if become %}
become: true
{% endif %}
{% if options_enabled and not gather_facts %}
gather_facts: false
{% endif %}
{% if secrets_enabled %}
vars_files:
- {{ secrets_file }}
{% endif %}
vars:
certs_path: {{ certs_path }}
tasks:
- name: Check if docker certs are existing
ansible.builtin.stat:
path: {{ '{{' }} certs_path {{ '}}' }}
register: certs_dir
- name: Fail if docker certs are not existing
ansible.builtin.fail:
msg: "Docker certificates are not existing in /root/docker-certs."
when: not certs_dir.stat.exists
- name: Get machine's primary internal ip address from eth0 interface
ansible.builtin.setup:
register: ip_address
- name: Set machine's primary internal ip address
ansible.builtin.set_fact:
ip_address: {{ '{{' }} ip_address.ansible_facts.ansible_default_ipv4.address {{ '}}' }}
- name: Check if ip_address is a valid ip address
ansible.builtin.assert:
that:
- ip_address is match("^(?:[0-9]{1,3}\\.){3}[0-9]{1,3}$")
fail_msg: "ip_address is not a valid ip address."
success_msg: "ip_address is a valid ip address."
- name: Change docker daemon to use certs
ansible.builtin.lineinfile:
path: /lib/systemd/system/docker.service
line: >
ExecStart=/usr/bin/dockerd -H fd:// --containerd=/run/containerd/containerd.sock
-H tcp://{{ '{{' }} ip_address {{ '}}' }}:2376 --tlsverify --tlscacert={{ '{{' }} certs_path {{ '}}' }}/ca.pem
--tlscert={{ '{{' }} certs_path {{ '}}' }}/server-cert.pem --tlskey={{ '{{' }} certs_path {{ '}}' }}/server-key.pem
regexp: '^ExecStart='
state: present
- name: Reload systemd daemon
ansible.builtin.systemd:
daemon_reload: true
- name: Restart docker daemon
ansible.builtin.systemd:
name: docker
state: restarted
enabled: true
================================================
FILE: library/ansible/docker-certs-enable/template.yaml
================================================
---
kind: ansible
metadata:
icon:
provider: selfh
id: docker
name: Enable Docker TLS
description: >
Ansible playbook to enable TLS on Docker daemon using existing certificates.
Configures Docker to use TLS for secure remote access.
Project: https://www.docker.com
Documentation: https://docs.docker.com/engine/security/protect-access/
version: 1.0.0
author: Christian Lempa
date: '2025-11-11'
draft: true
schema: "1.2"
spec:
general:
vars:
target_hosts:
description: Target hosts
type: str
required: true
playbook_name:
default: Docker Certs enable
become:
default: true
certificates:
title: Certificate Configuration
required: true
vars:
certs_path:
type: str
description: Path where certificates are stored
default: /root/docker-certs
================================================
FILE: library/ansible/docker-install-ubuntu/main.yml.j2
================================================
---
- name: Install Docker on Ubuntu
hosts: {{ target_hosts }}
become: true
gather_facts: true
tasks:
- name: Remove conflicting packages
ansible.builtin.apt:
name:
- docker.io
- docker-compose
- docker-compose-v2
- docker-doc
- podman-docker
- containerd
- runc
state: absent
- name: Install docker/ansible dependencies
ansible.builtin.apt:
name:
- ca-certificates
- curl
- python3-debian
update_cache: true
- name: Add docker repository with docker key from URL
ansible.builtin.deb822_repository:
name: docker
types: deb
uris: https://download.docker.com/linux/ubuntu
suites: '{{ ansible_distribution_release }}'
components: stable
architectures: amd64
signed_by: https://download.docker.com/linux/ubuntu/gpg
- name: Install docker engine
ansible.builtin.apt:
name:
- docker-ce
- docker-ce-cli
- containerd.io
- docker-buildx-plugin
- docker-compose-plugin
update_cache: true
================================================
FILE: library/ansible/docker-install-ubuntu/template.yaml
================================================
---
kind: ansible
metadata:
icon:
provider: selfh
id: docker
name: Install Docker on Ubuntu
description: >
Ansible playbook to install Docker Engine on Ubuntu systems.
Includes Docker CE, Buildx plugin, and Compose plugin.
Project: https://www.docker.com
Documentation: https://docs.docker.com/engine/install/ubuntu/
version: 27.5.1
author: Christian Lempa
date: '2025-11-11'
draft: true
schema: "1.2"
spec:
general:
vars:
target_hosts:
type: str
description: Target hosts or group
required: true
================================================
FILE: library/ansible/docker-prune/playbook.yaml.j2
================================================
---
- name: {{ playbook_name }}
hosts: {{ target_hosts }}
{% if become %}
become: true
{% endif %}
{% if options_enabled and not gather_facts %}
gather_facts: false
{% endif %}
{% if secrets_enabled %}
vars_files:
- {{ secrets_file }}
{% endif %}
tasks:
- name: Prune non-dangling images
community.docker.docker_prune:
containers: false
images: true
images_filters:
dangling: false
networks: false
volumes: false
builder_cache: false
================================================
FILE: library/ansible/docker-prune/template.yaml
================================================
---
kind: ansible
metadata:
icon:
provider: selfh
id: docker
name: Docker Prune
description: >
Ansible playbook to clean up Docker resources.
Prunes non-dangling images to free up disk space.
Project: https://www.docker.com
Documentation: https://docs.docker.com/engine/reference/commandline/system_prune/
version: 1.0.0
author: Christian Lempa
date: '2025-11-11'
draft: true
schema: "1.2"
spec:
general:
vars:
target_hosts:
description: Target hosts
type: str
required: true
playbook_name:
default: Clean docker
become:
default: false
================================================
FILE: library/ansible/ubuntu-add-sshkey/playbook.yaml.j2
================================================
---
- name: {{ playbook_name }}
hosts: {{ target_hosts }}
{% if become %}
become: true
{% endif %}
{% if options_enabled and not gather_facts %}
gather_facts: false
{% endif %}
{% if secrets_enabled %}
vars_files:
- {{ secrets_file }}
{% endif %}
tasks:
- name: Install public keys
ansible.posix.authorized_key:
user: {{ '{{' }} lookup('env', 'USER') {{ '}}' }}
state: present
key: {{ '{{' }} lookup('file', '~/.ssh/id_rsa.pub') {{ '}}' }}
- name: Change sudoers file
ansible.builtin.lineinfile:
path: /etc/sudoers
state: present
regexp: '^%sudo'
line: '%sudo ALL=(ALL) NOPASSWD: ALL'
validate: /usr/sbin/visudo -cf %s
================================================
FILE: library/ansible/ubuntu-add-sshkey/template.yaml
================================================
---
kind: ansible
metadata:
icon:
provider: selfh
id: ansible
name: Add SSH Key and Configure Sudoers
description: >
Ansible playbook to add SSH public key to authorized_keys.
Also configures passwordless sudo for sudo group.
Project: https://www.openssh.com
Documentation: https://www.openssh.com/manual.html
version: 1.0.0
author: Christian Lempa
date: '2025-11-11'
draft: true
schema: "1.2"
spec:
general:
vars:
target_hosts:
description: Target hosts
type: str
required: true
playbook_name:
default: Add ssh key
become:
default: true
================================================
FILE: library/ansible/ubuntu-apt-update/playbook.yaml.j2
================================================
---
- name: {{ playbook_name }}
hosts: {{ target_hosts }}
{% if become %}
become: true
{% endif %}
{% if options_enabled and not gather_facts %}
gather_facts: false
{% endif %}
{% if secrets_enabled %}
vars_files:
- {{ secrets_file }}
{% endif %}
tasks:
- name: Update packages with apt
when: ansible_pkg_mgr == 'apt'
ansible.builtin.apt:
update_cache: true
- name: Upgrade packages with apt
when: ansible_pkg_mgr == 'apt'
ansible.builtin.apt:
upgrade: dist
================================================
FILE: library/ansible/ubuntu-apt-update/template.yaml
================================================
---
kind: ansible
metadata:
icon:
provider: selfh
id: ansible
name: Update and Upgrade Ubuntu Packages
description: >
Ansible playbook to update and upgrade APT packages on Ubuntu systems.
Performs apt update and dist-upgrade.
Project: https://ubuntu.com
Documentation: https://ubuntu.com/server/docs
version: 1.0.0
author: Christian Lempa
date: '2025-11-11'
draft: true
schema: "1.2"
spec:
general:
vars:
playbook_name:
default: Update and upgrade apt packages
target_hosts:
default: all
become:
default: false
================================================
FILE: library/ansible/ubuntu-vm-core/playbook.yaml.j2
================================================
---
- name: {{ playbook_name }}
hosts: {{ target_hosts }}
{% if become %}
become: true
{% endif %}
{% if options_enabled and not gather_facts %}
gather_facts: false
{% endif %}
{% if secrets_enabled %}
vars_files:
- {{ secrets_file }}
{% endif %}
tasks:
- name: Install packages
ansible.builtin.apt:
name:
- prometheus-node-exporter
- nfs-common
- qemu-guest-agent
update_cache: true
- name: Start guest qemu-guest-agent
ansible.builtin.service:
name: qemu-guest-agent
state: started
enabled: true
================================================
FILE: library/ansible/ubuntu-vm-core/template.yaml
================================================
---
kind: ansible
metadata:
icon:
provider: selfh
id: ansible
name: Install Ubuntu VM Core Packages
description: >
Ansible playbook to install essential packages for Ubuntu virtual machines.
Includes Prometheus node exporter, NFS client, and QEMU guest agent.
Project: https://ubuntu.com
Documentation: https://ubuntu.com/server/docs
version: 1.0.0
author: Christian Lempa
date: '2025-11-11'
draft: true
schema: "1.2"
spec:
general:
vars:
target_hosts:
description: Target hosts
type: str
required: true
playbook_name:
default: Install core packages for virtual machines
become:
default: true
================================================
FILE: library/compose/adguardhome/compose.yaml.j2
================================================
---
services:
{{ service_name }}:
image: docker.io/adguard/adguardhome:v0.107.71
restart: {{ restart_policy }}
{% if network_mode == 'host' %}
network_mode: host
{% elif network_mode == 'bridge' or network_mode == 'macvlan' or traefik_enabled %}
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 not network_mode or network_mode == 'bridge' or traefik_enabled %}
ports:
{% if not traefik_enabled %}
- "{{ ports_http }}:80/tcp"
- "{{ ports_https }}:443/tcp"
{% endif %}
{% if initial_setup %}
- "{{ ports_initial }}:3000/tcp"
{% endif %}
- "{{ ports_https }}:443/udp"
- "{{ ports_dns }}:53/tcp"
- "{{ ports_dns }}:53/udp"
- "{{ ports_tls }}:853/tcp"
- "{{ ports_dnscrypt }}:5443/tcp"
- "{{ ports_dnscrypt }}:5443/udp"
{% endif %}
volumes:
{% if volume_mode == 'mount' %}
- {{ volume_mount_path }}/work:/opt/adguardhome/work:rw
- {{ volume_mount_path }}/conf:/opt/adguardhome/conf:rw
{% else %}
- {{ service_name }}_work:/opt/adguardhome/work
- {{ service_name }}_conf:/opt/adguardhome/conf
{% endif %}
cap_add:
- NET_ADMIN
- NET_BIND_SERVICE
- NET_RAW
{% if traefik_enabled %}
labels:
- traefik.enable=true
- traefik.docker.network={{ traefik_network }}
- traefik.http.services.{{ service_name }}_web.loadBalancer.server.port=80
- traefik.http.routers.{{ service_name }}_web_http.service={{ service_name }}_web
- traefik.http.routers.{{ service_name }}_web_http.rule=Host(`{{ traefik_host }}.{{ traefik_domain }}`)
- traefik.http.routers.{{ service_name }}_web_http.entrypoints=web
{% if traefik_tls_enabled %}
- traefik.http.routers.{{ service_name }}_web_https.service={{ service_name }}_web
- traefik.http.routers.{{ service_name }}_web_https.rule=Host(`{{ traefik_host }}.{{ traefik_domain }}`)
- traefik.http.routers.{{ service_name }}_web_https.entrypoints=websecure
- traefik.http.routers.{{ service_name }}_web_https.tls=true
- traefik.http.routers.{{ service_name }}_web_https.tls.certresolver={{ traefik_tls_certresolver }}
{% endif %}
{% endif %}
{% if network_mode == 'bridge' or network_mode == 'macvlan' or traefik_enabled %}
networks:
{% if network_mode == 'bridge' or network_mode == 'macvlan'%}
{{ network_name }}:
{% if network_external %}
external: true
{% else %}
{% if network_mode == 'macvlan' %}
driver: macvlan
driver_opts:
parent: {{ network_macvlan_parent_interface }}
ipam:
config:
- subnet: {{ network_macvlan_subnet }}
gateway: {{ network_macvlan_gateway }}
name: {{ network_name }}
{% else %}
driver: bridge
{% endif %}
{% endif %}
{% endif %}
{% if traefik_enabled %}
{{ traefik_network }}:
external: true
{% endif %}
{% endif %}
{% if volume_mode == 'local' %}
volumes:
{{ service_name }}_work:
driver: local
{{ service_name }}_conf:
driver: local
{% elif volume_mode == 'nfs' %}
volumes:
{{ service_name }}_work:
driver: local
driver_opts:
type: nfs
o: addr={{ volume_nfs_server }},nfsvers=4,{{ volume_nfs_options }}
device: ":{{ volume_nfs_path }}/work"
{{ service_name }}_conf:
driver: local
driver_opts:
type: nfs
o: addr={{ volume_nfs_server }},nfsvers=4,{{ volume_nfs_options }}
device: ":{{ volume_nfs_path }}/conf"
{% endif %}
================================================
FILE: library/compose/adguardhome/template.yaml
================================================
kind: compose
metadata:
name: AdGuard Home
description: 'Network-wide software for blocking ads and tracking. AdGuard Home
operates as a DNS server that
re-routes tracking domains to a "black hole", thus preventing your devices from
connecting to those servers.
It features advanced DNS filtering, parental controls, safe browsing, and HTTPS/DNS-over-TLS/DNS-over-QUIC
support.
## Prerequisites
- :info: During the initial setup, AdGuard Home runs an HTTP server on port 3000
to guide you through configuration.
**With Traefik enabled:** Access initial setup via container IP at `http://:3000`.
After setup completes, access the admin interface via the configured domain.
- :warning: **Security Notice:** The initial setup on port 3000 uses an unencrypted
HTTP connection.
Only use this for initial configuration and disable it (`initial_setup=false`)
after setup is complete.
- :warning: If you require DHCP functionality or want AdGuard Home to bind directly
to port 53,
you must set `network_mode` to `host` or `macvlan`. Note this exposes all container
ports directly on the host.
You can''t use `traefik_enabled` in this case!
## References
- **Project:** https://adguard.com/adguard-home/overview.html
- **Documentation:** https://github.com/AdguardTeam/AdGuardHome/wiki
- **GitHub:** https://github.com/AdguardTeam/AdGuardHome'
icon:
provider: selfh
id: adguard-home
version: v0.107.71
author: Christian Lempa
date: '2025-12-11'
tags:
- traefik
- network
- volume
next_steps: null
schema: '1.2'
spec:
general:
vars:
service_name:
default: adguardhome
restart_policy:
type: enum
options:
- unless-stopped
- always
- on-failure
- 'no'
default: unless-stopped
required: true
initial_setup:
description: Enable initial setup wizard on port 3000 (only used without Traefik)
type: bool
default: true
extra: 'Port 3000 is only used during the initial setup wizard when Traefik
is disabled. With Traefik enabled, access setup via container IP instead.
After completing setup, AdGuard Home switches to port 80 and port 3000 becomes
inactive. '
network:
vars:
network_mode:
extra: 'Use ''host'' mode if you need DHCP functionality or want AdGuard Home
to bind directly to port 53.
'
network_name:
default: adguardhome_network
network_macvlan_ipv4_address:
type: str
default: 192.168.1.253
needs:
- network_mode=macvlan
required: true
network_macvlan_parent_interface:
type: str
default: eth0
needs:
- network_mode=macvlan
required: true
network_macvlan_subnet:
type: str
default: 192.168.1.0/24
needs:
- network_mode=macvlan
required: true
network_macvlan_gateway:
type: str
default: 192.168.1.1
needs:
- network_mode=macvlan
required: true
network_external:
type: bool
default: false
description: Whether the network is external
ports:
vars:
ports_http:
default: 80
ports_https:
default: 443
ports_dns:
type: int
default: 53
required: true
ports_initial:
description: Initial setup wizard port (only when Traefik is disabled)
type: int
default: 3000
needs:
- traefik_enabled=false
- initial_setup=true
extra: 'Only used during first-time setup without Traefik. After configuration,
port becomes inactive. With Traefik, access setup via container IP instead.
'
ports_tls:
description: DNS over TLS Port
type: int
default: 853
required: true
ports_dnscrypt:
description: DNSCrypt Port
type: int
default: 5443
required: true
traefik:
vars:
traefik_host:
default: adguardhome
traefik_network:
default: traefik
type: str
required: true
traefik_domain:
default: home.arpa
type: str
required: true
traefik_enabled:
type: bool
default: false
description: Enable Traefik integration
toggle: traefik_enabled
title: Traefik
description: Configure Traefik reverse proxy integration
traefik_tls:
vars:
traefik_tls_certresolver:
type: str
default: cloudflare
required: true
traefik_tls_enabled:
type: bool
default: false
description: Enable Traefik TLS
toggle: traefik_tls_enabled
title: Traefik TLS
description: Configure Traefik TLS/SSL certificates
needs: traefik
volume:
vars:
volume_mode:
type: enum
options:
- local
- mount
- nfs
default: local
required: true
volume_mount_path:
type: str
default: /mnt/storage
needs:
- volume_mode=mount
required: true
volume_nfs_server:
type: str
default: 192.168.1.1
needs:
- volume_mode=nfs
required: true
volume_nfs_path:
type: str
default: /export
needs:
- volume_mode=nfs
required: true
volume_nfs_options:
type: str
default: rw,nolock,soft
needs:
- volume_mode=nfs
required: true
================================================
FILE: library/compose/alloy/compose.yaml.j2
================================================
services:
{{ service_name }}:
image: docker.io/grafana/alloy:v1.13.1
restart: {{ restart_policy }}
{% if container_hostname %}
hostname: {{ container_hostname }}
{% endif %}
{% if traefik_enabled %}
networks:
{{ traefik_network }}:
{% endif %}
command:
- run
- /etc/alloy/alloy.d/
- --server.http.listen-addr=127.0.0.1:12345
{% if not traefik_enabled %}
ports:
- "{{ ports_webui }}:12345"
{% endif %}
volumes:
- {{ service_name }}_data:/alloy/data
- ./config:/etc/alloy/alloy.d/:ro
{% if logs_enabled or metrics_enabled %}
- /:/rootfs:ro
- /sys:/sys:ro
{% endif %}
{% if logs_enabled and logs_system %}
- /run:/run:ro
- /var/log:/var/log:ro
{% endif %}
{% if (logs_enabled and logs_docker) or (metrics_enabled and metrics_docker) %}
- /var/lib/docker/:/var/lib/docker/:ro
{% endif %}
{% if metrics_enabled and metrics_system %}
- /run/udev/data:/run/udev/data:ro
{% endif %}
{% if traefik_enabled %}
labels:
- traefik.enable=true
- traefik.docker.network={{ traefik_network }}
- traefik.http.services.{{ service_name }}-web.loadBalancer.server.port=12345
- traefik.http.routers.{{ service_name }}-http.service={{ service_name }}-web
- traefik.http.routers.{{ service_name }}-http.rule=Host(`{{ traefik_host }}.{{ traefik_domain }}`)
- traefik.http.routers.{{ service_name }}-http.entrypoints=web
{% 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_domain }}`)
- traefik.http.routers.{{ service_name }}-https.entrypoints=websecure
- traefik.http.routers.{{ service_name }}-https.tls=true
- traefik.http.routers.{{ service_name }}-https.tls.certresolver={{ traefik_tls_certresolver }}
{% endif %}
{% endif %}
{% if traefik_enabled %}
networks:
{{ traefik_network }}:
external: true
{% endif %}
volumes:
{{ service_name }}_data:
driver: local
================================================
FILE: library/compose/alloy/config/common.alloy.j2
================================================
logging {
level = "info"
format = "logfmt"
}
================================================
FILE: library/compose/alloy/config/logs_docker.alloy.j2
================================================
{% if logs_enabled and logs_docker %}
discovery.docker "dockerlogs" {
host = "unix:///var/run/docker.sock"
}
discovery.relabel "dockerlogs" {
targets = []
rule {
source_labels = ["__meta_docker_container_name"]
regex = "/(.*)"
target_label = "service_name"
}
}
loki.source.docker "default" {
host = "unix:///var/run/docker.sock"
targets = discovery.docker.dockerlogs.targets
labels = {"platform" = "docker"}
relabel_rules = discovery.relabel.dockerlogs.rules
forward_to = [loki.write.default.receiver]
}
{% endif %}
================================================
FILE: library/compose/alloy/config/logs_system.alloy.j2
================================================
{% if logs_enabled and logs_system %}
loki.source.journal "journal" {
max_age = "24h0m0s"
relabel_rules = discovery.relabel.journal.rules
forward_to = [loki.write.default.receiver]
labels = {component = string.format("%s-journal", constants.hostname)}
// NOTE: This is important to fix https://github.com/grafana/alloy/issues/924
path = "/var/log/journal"
}
local.file_match "system" {
path_targets = [{
__address__ = "localhost",
__path__ = "/var/log/{syslog,messages,*.log}",
instance = constants.hostname,
job = string.format("%s-logs", constants.hostname),
}]
}
discovery.relabel "journal" {
targets = []
rule {
source_labels = ["__journal__systemd_unit"]
target_label = "unit"
}
rule {
source_labels = ["__journal__boot_id"]
target_label = "boot_id"
}
rule {
source_labels = ["__journal__transport"]
target_label = "transport"
}
rule {
source_labels = ["__journal_priority_keyword"]
target_label = "level"
}
}
loki.source.file "system" {
targets = local.file_match.system.targets
forward_to = [loki.write.default.receiver]
}
{% endif %}
================================================
FILE: library/compose/alloy/config/metrics_docker.alloy.j2
================================================
{% if metrics_enabled and metrics_docker %}
prometheus.exporter.cadvisor "dockermetrics" {
docker_host = "unix:///var/run/docker.sock"
storage_duration = "5m"
}
/* Relabel component to drop container_spec metrics that often have NaN values */
prometheus.relabel "docker_filter" {
forward_to = [prometheus.remote_write.default.receiver]
rule {
source_labels = ["__name__"]
regex = "container_spec_(cpu_period|cpu_quota|cpu_shares|memory_limit_bytes|memory_swap_limit_bytes|memory_reservation_limit_bytes)"
action = "drop"
}
}
prometheus.scrape "dockermetrics" {
targets = prometheus.exporter.cadvisor.dockermetrics.targets
forward_to = [prometheus.relabel.docker_filter.receiver]
scrape_interval = "10s"
}
{% endif %}
================================================
FILE: library/compose/alloy/config/metrics_system.alloy.j2
================================================
{% if metrics_enabled and metrics_system %}
discovery.relabel "metrics" {
targets = prometheus.exporter.unix.metrics.targets
rule {
target_label = "instance"
replacement = constants.hostname
}
rule {
target_label = "job"
replacement = string.format("%s-metrics", constants.hostname)
}
}
prometheus.exporter.unix "metrics" {
disable_collectors = ["ipvs", "btrfs", "infiniband", "xfs", "zfs"]
enable_collectors = ["meminfo"]
rootfs_path = "/rootfs"
filesystem {
fs_types_exclude = "^(autofs|binfmt_misc|bpf|cgroup2?|configfs|debugfs|devpts|devtmpfs|tmpfs|fusectl|hugetlbfs|iso9660|mqueue|nsfs|overlay|proc|procfs|pstore|rpc_pipefs|securityfs|selinuxfs|squashfs|sysfs|tracefs)$"
mount_points_exclude = "^/(dev|proc|run/credentials/.+|sys|var/lib/docker/.+)($|/)"
mount_timeout = "5s"
}
netclass {
ignored_devices = "^(veth.*|cali.*|[a-f0-9]{15})$"
}
netdev {
device_exclude = "^(veth.*|cali.*|[a-f0-9]{15})$"
}
}
prometheus.scrape "metrics" {
scrape_interval = "15s"
targets = discovery.relabel.metrics.output
forward_to = [prometheus.remote_write.default.receiver]
}
{% endif %}
================================================
FILE: library/compose/alloy/config/targets.alloy.j2
================================================
{% if logs_enabled %}
loki.write "default" {
endpoint {
url = "{{ logs_loki_url }}"
// Batching configuration to reduce request volume and prevent rate limiting
batch_wait = "5s" // Wait up to 5 seconds before sending
batch_size = "1MiB" // Send when batch reaches 1MB (1048576 bytes)
}
external_labels = {}
}
{% endif %}
{% if metrics_enabled %}
prometheus.remote_write "default" {
endpoint {
url = "{{ metrics_prometheus_url }}"
}
}
{% endif %}
================================================
FILE: library/compose/alloy/template.yaml
================================================
---
kind: compose
metadata:
name: Grafana Alloy
description: |-
Grafana Alloy is an open telemetry collector that collects, processes, and exports metrics to various backends.
## Prerequisites
- :warning: When alloy runs in a container, you should set the `container_hostname` to a unique value to identify
the instance in your monitoring backend. Otherwise it defaults to the container ID.
## Resources
- **Project**: https://grafana.com/oss/alloy/
- **Documentation**: https://grafana.com/docs/alloy/latest/
- **GitHub**: https://github.com/grafana/alloy
version: v1.13.1
author: Christian Lempa
date: '2026-02-16'
tags:
- traefik
icon:
provider: selfh
id: grafana
next_steps:
schema: "1.2"
spec:
general:
vars:
service_name:
default: alloy
container_hostname:
description: Container internal hostname
type: str
restart_policy:
description: Container restart policy
type: enum
options:
- unless-stopped
- always
- on-failure
- 'no'
default: unless-stopped
required: true
logs:
title: Log Collection
toggle: logs_enabled
vars:
logs_docker:
description: Enable Docker container log collection
type: bool
default: true
logs_enabled:
description: Enable log collection
type: bool
default: false
logs_loki_url:
description: Loki endpoint URL for sending logs
type: url
default: http://loki:3100/loki/api/v1/push
required: true
logs_system:
description: Enable system and journalctl log collection
type: bool
default: true
metrics:
title: Metrics Collection
toggle: metrics_enabled
vars:
metrics_docker:
description: Enable Docker container metrics collection (cAdvisor)
type: bool
default: true
metrics_enabled:
description: Enable metrics collection
type: bool
default: false
metrics_prometheus_url:
description: Prometheus remote write endpoint
type: url
default: http://prometheus:9090/api/v1/write
required: true
metrics_system:
description: Enable system (node) metrics collection
type: bool
default: true
ports:
title: Ports
vars:
ports_webui:
description: Port for Alloy web UI
type: int
default: 12345
traefik:
title: Traefik
toggle: traefik_enabled
vars:
traefik_enabled:
description: Enable Traefik reverse proxy integration
type: bool
default: false
traefik_network:
description: Traefik network name
type: str
default: traefik
required: true
traefik_host:
default: alloy
traefik_domain:
description: Base domain (e.g., example.com)
type: str
default: home.arpa
required: true
traefik_tls:
title: Traefik TLS/SSL
toggle: traefik_tls_enabled
vars:
traefik_tls_enabled:
description: Enable HTTPS/TLS
type: bool
default: true
traefik_tls_certresolver:
description: Traefik certificate resolver name
type: str
default: cloudflare
required: true
================================================
FILE: library/compose/authentik/compose.yaml.j2
================================================
---
services:
{{ service_name }}:
image: ghcr.io/goauthentik/server:2025.12.4
restart: {{ restart_policy }}
command: server
environment:
- AUTHENTIK_SECRET_KEY=${AUTHENTIK_SECRET_KEY}
- AUTHENTIK_ERROR_REPORTING__ENABLED={{ authentik_error_reporting }}
- AUTHENTIK_POSTGRESQL__HOST={{ service_name }}_postgres
- AUTHENTIK_POSTGRESQL__USER={{ database_user }}
- AUTHENTIK_POSTGRESQL__NAME={{ database_name }}
- AUTHENTIK_POSTGRESQL__PASSWORD=${DATABASE_PASSWORD}
{% if email_enabled %}
- AUTHENTIK_EMAIL__HOST={{ email_host }}
- AUTHENTIK_EMAIL__PORT={{ email_port }}
- AUTHENTIK_EMAIL__FROM={{ email_from }}
- AUTHENTIK_EMAIL__USERNAME={{ email_username }}
- AUTHENTIK_EMAIL__PASSWORD=${EMAIL_PASSWORD}
{% if email_encryption == "ssl" %}
- AUTHENTIK_EMAIL__USE_SSL=True
{% elif email_encryption == "starttls" %}
- AUTHENTIK_EMAIL__USE_TLS=True
{% endif %}
{% endif %}
networks:
{% if traefik_enabled %}
- {{ traefik_network }}
{% endif %}
- {{ service_name }}_backend
{% if not traefik_enabled %}
ports:
- "{{ ports_http }}:9000"
- "{{ ports_https }}:9443"
{% endif %}
volumes:
{% if volume_mode == 'mount' %}
- {{ volume_mount_path }}/data:/data
- {{ volume_mount_path }}/templates:/templates
{% elif volume_mode == 'local' or volume_mode == 'nfs' %}
- {{ service_name }}_data:/data
- {{ service_name }}_templates:/templates
{% endif %}
{% if traefik_enabled %}
labels:
- traefik.enable=true
- traefik.docker.network={{ traefik_network }}
- traefik.http.services.{{ service_name }}_web.loadBalancer.server.port=9000
- traefik.http.routers.{{ service_name }}_http.service={{ service_name }}_web
- traefik.http.routers.{{ service_name }}_http.rule=Host(`{{ traefik_host }}.{{ traefik_domain }}`)
- traefik.http.routers.{{ service_name }}_http.entrypoints=web
- "traefik.http.middlewares.authentik.forwardauth.address=\
http://{{ service_name }}:9000/outpost.goauthentik.io/auth/traefik"
- "traefik.http.middlewares.authentik.forwardauth.trustForwardHeader=true"
- "traefik.http.middlewares.authentik.forwardauth.authResponseHeaders=\
X-authentik-username,\
X-authentik-groups,\
X-authentik-entitlements,\
X-authentik-email,\
X-authentik-name,\
X-authentik-uid,\
X-authentik-jwt,\
X-authentik-meta-jwks,\
X-authentik-meta-outpost,\
X-authentik-meta-provider,\
X-authentik-meta-app,\
X-authentik-meta-version"
{% 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_domain }}`)
- traefik.http.routers.{{ service_name }}_https.entrypoints=websecure
- traefik.http.routers.{{ service_name }}_https.tls=true
- traefik.http.routers.{{ service_name }}_https.tls.certresolver={{ traefik_tls_certresolver }}
{% endif %}
{% endif %}
{% if not database_external %}
depends_on:
- {{ service_name }}_postgres
{% endif %}
{#
Authentik Worker: Background task processor
Handles long-running tasks like email sending, cleanup jobs, and scheduled tasks
#}
{{ service_name }}_worker:
image: ghcr.io/goauthentik/server:2025.12.4
restart: {{ restart_policy }}
command: worker
environment:
- AUTHENTIK_SECRET_KEY=${AUTHENTIK_SECRET_KEY}
- AUTHENTIK_ERROR_REPORTING__ENABLED={{ authentik_error_reporting }}
- AUTHENTIK_POSTGRESQL__HOST={{ service_name }}_postgres
- AUTHENTIK_POSTGRESQL__USER={{ database_user }}
- AUTHENTIK_POSTGRESQL__NAME={{ database_name }}
- AUTHENTIK_POSTGRESQL__PASSWORD=${DATABASE_PASSWORD}
{% if email_enabled %}
- AUTHENTIK_EMAIL__HOST={{ email_host }}
- AUTHENTIK_EMAIL__PORT={{ email_port }}
- AUTHENTIK_EMAIL__FROM={{ email_from }}
- AUTHENTIK_EMAIL__USERNAME={{ email_username }}
- AUTHENTIK_EMAIL__PASSWORD=${EMAIL_PASSWORD}
{% if email_encryption == "ssl" %}
- AUTHENTIK_EMAIL__USE_SSL=True
{% elif email_encryption == "starttls" %}
- AUTHENTIK_EMAIL__USE_TLS=True
{% endif %}
{% endif %}
{% if authentik_admin_password %}
- AUTHENTIK_BOOTSTRAP_PASSWORD=${AUTHENTIK_ADMIN_PASSWORD}
{% endif %}
user: root
networks:
- {{ service_name }}_backend
volumes:
{# the embedded outpost uses the docker socket to manage containers #}
- /run/docker.sock:/run/docker.sock
{% if volume_mode == 'mount' %}
- {{ volume_mount_path }}/data:/data
- {{ volume_mount_path }}/certs:/certs
- {{ volume_mount_path }}/templates:/templates
{% elif volume_mode == 'local' or volume_mode == 'nfs' %}
- {{ service_name }}_data:/data
- {{ service_name }}_certs:/certs
- {{ service_name }}_templates:/templates
{% endif %}
{% if not database_external %}
depends_on:
- {{ service_name }}_postgres
{% endif %}
{#
PostgreSQL database service
#}
{% if not database_external %}
{{ service_name }}_postgres:
image: docker.io/library/postgres:17.8
restart: {{ restart_policy }}
environment:
- POSTGRES_USER={{ database_user }}
- POSTGRES_PASSWORD=${DATABASE_PASSWORD}
- POSTGRES_DB={{ database_name }}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U {{ database_user }}"]
start_period: 30s
interval: 10s
timeout: 10s
retries: 5
networks:
- {{ service_name }}_backend
volumes:
{% if volume_mode == 'mount' %}
- {{ volume_mount_path }}/postgres:/var/lib/postgresql/data
{% elif volume_mode == 'local' or volume_mode == 'nfs' %}
- {{ service_name }}_postgres:/var/lib/postgresql/data
{% endif %}
{% endif %}
{#
Network definitions:
- Backend network: Internal communication between services
- Traefik network: External access via reverse proxy (always external)
#}
networks:
{{ service_name }}_backend:
driver: bridge
{% if traefik_enabled %}
{{ traefik_network }}:
external: true
{% endif %}
{#
Volume definitions:
- When volume_mode is 'local' (default): use docker-managed local volumes
- When volume_mode is 'nfs': configure NFS-backed volumes
- When volume_mode is 'mount': no volume definition needed (bind mounts used directly)
#}
{% if volume_mode == 'local' %}
volumes:
{% if not database_external %}
{{ service_name }}_postgres:
driver: local
{% endif %}
{{ service_name }}_redis:
driver: local
{{ service_name }}_data:
driver: local
{{ service_name }}_certs:
driver: local
{{ service_name }}_templates:
driver: local
{% elif volume_mode == 'nfs' %}
volumes:
{% if not database_external %}
{{ service_name }}_postgres:
driver: local
driver_opts:
type: nfs
o: addr={{ volume_nfs_server }},nfsvers=4,{{ volume_nfs_options }}
device: ":{{ volume_nfs_path }}/postgres"
{% endif %}
{{ service_name }}_redis:
driver: local
driver_opts:
type: nfs
o: addr={{ volume_nfs_server }},nfsvers=4,{{ volume_nfs_options }}
device: ":{{ volume_nfs_path }}/redis"
{{ service_name }}_data:
driver: local
driver_opts:
type: nfs
o: addr={{ volume_nfs_server }},nfsvers=4,{{ volume_nfs_options }}
device: ":{{ volume_nfs_path }}/data"
{{ service_name }}_certs:
driver: local
driver_opts:
type: nfs
o: addr={{ volume_nfs_server }},nfsvers=4,{{ volume_nfs_options }}
device: ":{{ volume_nfs_path }}/certs"
{{ service_name }}_templates:
driver: local
driver_opts:
type: nfs
o: addr={{ volume_nfs_server }},nfsvers=4,{{ volume_nfs_options }}
device: ":{{ volume_nfs_path }}/templates"
{% endif %}
================================================
FILE: library/compose/authentik/template.yaml
================================================
kind: compose
metadata:
name: Authentik
description: 'Integrate Authentik Single Sign-On (SSO) for secure and streamlined
user authentication.
Authentik is an open-source identity provider that supports various authentication
protocols.
This configuration enables OAuth-based SSO, allowing users to log in using their
Authentik
credentials, enhancing security and user experience.
## Prerequisites
- :warning: The `authentik_secret_key` must be generated using the following command
according to the official documentation.
```bash
echo "$(openssl rand -base64 60 | tr -d ''\n'')"
```
## References
* **Project:** https://goauthentik.io/
* **Documentation:** https://goauthentik.io/docs/
* **GitHub:** https://github.com/goauthentik/authentik'
icon:
provider: selfh
id: authentik
next_steps: 'Log in with your initial admin user:
```bash
Username: akadmin
Password: {{ authentik_admin_password }}
```'
version: 2025.12.4
author: Christian Lempa
date: '2026-02-12'
tags:
- traefik
- volume
schema: '1.2'
spec:
general:
vars:
service_name:
default: authentik
restart_policy:
type: enum
options:
- unless-stopped
- always
- on-failure
- 'no'
default: unless-stopped
required: true
database:
vars:
database_password:
description: Database password
type: str
sensitive: true
autogenerated: true
required: true
database_user:
type: str
default: authentik
description: The database user
database_name:
type: str
default: authentik
description: The database name
database_external:
type: bool
default: false
description: Use external database
ports:
vars:
ports_http:
default: 8000
ports_https:
default: 8443
traefik:
vars:
traefik_host:
default: authentik
traefik_network:
default: traefik
type: str
required: true
traefik_domain:
default: home.arpa
type: str
required: true
traefik_enabled:
type: bool
default: false
description: Enable Traefik integration
toggle: traefik_enabled
title: Traefik
description: Configure Traefik reverse proxy integration
authentik:
description: Configure Authentik application settings
required: true
vars:
authentik_secret_key:
description: Secret Key
extra: Used for cookie signing and unique user IDs
type: str
sensitive: true
autogenerated: true
required: true
authentik_admin_password:
description: Initial admin user password
type: str
sensitive: true
autogenerated: true
authentik_error_reporting:
description: Enable error reporting to Authentik developers
type: bool
traefik_tls:
vars:
traefik_tls_certresolver:
type: str
default: cloudflare
required: true
traefik_tls_enabled:
type: bool
default: false
description: Enable Traefik TLS
toggle: traefik_tls_enabled
title: Traefik TLS
description: Configure Traefik TLS/SSL certificates
needs: traefik
volume:
vars:
volume_mode:
type: enum
options:
- local
- mount
- nfs
default: local
required: true
volume_mount_path:
type: str
default: /mnt/storage
needs:
- volume_mode=mount
required: true
volume_nfs_server:
type: str
default: 192.168.1.1
needs:
- volume_mode=nfs
required: true
volume_nfs_path:
type: str
default: /export
needs:
- volume_mode=nfs
required: true
volume_nfs_options:
type: str
default: rw,nolock,soft
needs:
- volume_mode=nfs
required: true
email:
vars:
email_host:
description: SMTP server hostname
type: str
required: true
email_port:
description: SMTP server port
type: int
default: 25
required: true
email_username:
description: SMTP username
type: str
required: true
email_password:
description: SMTP password
type: str
sensitive: true
required: true
email_from:
description: From email address
type: str
required: true
email_enabled:
type: bool
default: false
description: Enable email integration
email_encryption:
type: str
default: tls
description: The email encryption type
toggle: email_enabled
title: Email
description: Configure email/SMTP integration
================================================
FILE: library/compose/bind9/compose.yaml.j2
================================================
services:
{{ service_name }}:
image: docker.io/ubuntu/bind9:9.20-24.10_edge
restart: {{ restart_policy }}
{% if container_hostname %}
hostname: {{ container_hostname }}
{% endif %}
environment:
- TZ={{ container_timezone }}
- BIND9_USER=bind
{% if network_mode == 'host' %}
network_mode: host
{% elif network_mode == 'bridge' or network_mode == 'macvlan' %}
networks:
{% if network_mode == 'macvlan' %}
{{ network_name }}:
ipv4_address: {{ network_macvlan_ipv4_address }}
{% elif network_mode == 'bridge' %}
{{ network_name }}:
{% endif %}
{% endif %}
{% if network_mode == '' or network_mode == 'bridge' %}
ports:
- "53:53/tcp"
- "53:53/udp"
{% endif %}
volumes:
{% if volume_mode == 'mount' %}
- {{ volume_mount_path }}/config:/etc/bind:rw
- {{ volume_mount_path }}/zones:/var/lib/bind:rw
- {{ volume_mount_path }}/cache:/var/cache/bind:rw
{% else %}
- {{ service_name }}_config:/etc/bind
- {{ service_name }}_zones:/var/lib/bind
- {{ service_name }}_cache:/var/cache/bind
{% endif %}
{#
Network definitions (only when needed):
- When network_mode is empty: no definition needed (uses Docker's default bridge)
- When network_mode is 'bridge': define custom bridge network
- When network_mode is 'macvlan': configure macvlan with static IP (for Compose mode)
#}
{% if network_mode == 'bridge' or network_mode == 'macvlan' %}
networks:
{{ network_name }}:
{% if network_external %}
external: true
{% else %}
{% if network_mode == 'macvlan' %}
driver: macvlan
driver_opts:
parent: {{ network_macvlan_parent_interface }}
ipam:
config:
- subnet: {{ network_macvlan_subnet }}
gateway: {{ network_macvlan_gateway }}
name: {{ network_name }}
{% elif network_mode == 'bridge' %}
driver: bridge
{% endif %}
{% endif %}
{% endif %}
{#
Volume definitions:
- When volume_mode is 'local' (default): use docker-managed local volumes
- When volume_mode is 'nfs': configure NFS-backed volumes
- When volume_mode is 'mount': no volume definition needed (bind mounts used directly)
#}
{% if volume_mode == 'local' %}
volumes:
{{ service_name }}_config:
driver: local
{{ service_name }}_zones:
driver: local
{{ service_name }}_cache:
driver: local
{% elif volume_mode == 'nfs' %}
volumes:
{{ service_name }}_config:
driver: local
driver_opts:
type: nfs
o: addr={{ volume_nfs_server }},{{ volume_nfs_options }}
device: ":{{ volume_nfs_path }}/config"
{{ service_name }}_zones:
driver: local
driver_opts:
type: nfs
o: addr={{ volume_nfs_server }},{{ volume_nfs_options }}
device: ":{{ volume_nfs_path }}/zones"
{{ service_name }}_cache:
driver: local
driver_opts:
type: nfs
o: addr={{ volume_nfs_server }},{{ volume_nfs_options }}
device: ":{{ volume_nfs_path }}/cache"
{% endif %}
================================================
FILE: library/compose/bind9/config/named.conf.j2
================================================
// BIND9 Main Configuration File
// Documentation: https://bind9.readthedocs.io/
{% if tsig_enabled %}
include "/etc/bind/tsig.key";
{% endif %}
acl "trusted" {
127.0.0.1;
::1;
10.0.0.0/8;
172.16.0.0/12;
192.168.0.0/16;
};
options {
directory "/var/cache/bind";
// DNS forwarders for recursive queries
forwarders {
1.1.1.1;
8.8.8.8;
};
// Allow recursion from trusted networks only
allow-recursion { trusted; };
// Allow queries from any (adjust as needed)
allow-query { any; };
// Disable zone transfers by default (enable per-zone with TSIG)
allow-transfer { none; };
// DNSSEC validation
dnssec-validation auto;
// Listen on all interfaces
listen-on { any; };
listen-on-v6 { any; };
// Disable query logging (enable for debugging)
// querylog yes;
};
// Local zones
zone "localhost" {
type master;
file "/etc/bind/db.local";
};
zone "127.in-addr.arpa" {
type master;
file "/etc/bind/db.127";
};
zone "0.in-addr.arpa" {
type master;
file "/etc/bind/db.0";
};
zone "255.in-addr.arpa" {
type master;
file "/etc/bind/db.255";
};
// Include your custom zones
include "/etc/bind/named.conf.zones";
================================================
FILE: library/compose/bind9/config/named.conf.zones.j2
================================================
zone "{{ domain_name }}" {
type master;
file "/var/lib/bind/db.{{ domain_name }}";
{% if tsig_enabled %}
allow-transfer { key "tsig-transfer-key"; };
allow-update { key "tsig-transfer-key"; };
{% endif %}
{% if dnssec_enabled %}
dnssec-policy default;
inline-signing yes;
{% endif %}
};
================================================
FILE: library/compose/bind9/config/tsig.key.j2
================================================
{% if tsig_enabled %}
key "tsig-transfer-key" {
algorithm hmac-sha256;
secret "{{ tsig_key_secret }}";
};
{% endif %}
================================================
FILE: library/compose/bind9/template.yaml
================================================
kind: compose
metadata:
name: BIND9
description: 'BIND9 is the most widely used DNS server on the Internet.
This template provides an authoritative and recursive DNS server with example
zones,
TSIG authentication for secure zone transfers, and DNSSEC support.
## References
* **Project:** https://www.isc.org/bind/
* **Documentation:** https://bind9.readthedocs.io/'
version: 9.20-24.10_edge
author: Christian Lempa
date: '2025-10-02'
tags:
- network
- volume
icon:
provider: selfh
id: bind-9
draft: true
schema: '1.2'
spec:
dns_security:
title: dns_security
vars:
dnssec_enabled:
description: Enable DNSSEC
type: bool
toggle: dnssec_enabled
description: Configure DNSSEC signing
dns_zone:
title: dns_zone
vars:
domain_name:
description: Primary domain name
type: str
default: home.arpa
tsig_enabled:
description: Enable TSIG
type: bool
tsig_key_secret:
description: TSIG key secret
type: str
sensitive: true
autogenerated: true
needs:
- tsig_enabled=true
toggle: tsig_enabled
description: Configure TSIG authentication for zone transfers
network: null
volume:
vars:
volume_mode:
type: enum
options:
- local
- mount
- nfs
default: local
required: true
volume_mount_path:
type: str
default: /mnt/storage
needs:
- volume_mode=mount
required: true
volume_nfs_server:
type: str
default: 192.168.1.1
needs:
- volume_mode=nfs
required: true
volume_nfs_path:
type: str
default: /export
needs:
- volume_mode=nfs
required: true
volume_nfs_options:
type: str
default: rw,nolock,soft
needs:
- volume_mode=nfs
required: true
general:
vars:
service_name:
default: bind9
container_hostname:
type: str
container_timezone:
type: str
restart_policy:
type: enum
options:
- unless-stopped
- always
- on-failure
- 'no'
default: unless-stopped
required: true
================================================
FILE: library/compose/checkmk/compose.yaml.j2
================================================
---
services:
{{ service_name }}:
image: checkmk/check-mk-raw:2.4.0-latest
restart: {{ restart_policy }}
environment:
{% if container_timezone %}
- TZ={{ container_timezone }}
{% endif %}
- CMK_PASSWORD=${CMK_PASSWORD}
- CMK_SITE_ID={{ cmk_site_id }}
tmpfs:
- /opt/omd/sites/{{ cmk_site_id }}/tmp:uid={{ user_uid }},gid={{ user_gid }}
volumes:
{% if not container_timezone %}
- /etc/localtime:/etc/localtime:ro
{% endif %}
- {{ service_name }}_data:/omd/sites:rw
{% if traefik_enabled %}
networks:
{{ traefik_network }}:
{% endif %}
ports:
{% if not traefik_enabled %}
- "{{ ports_http }}:8000"
{% endif %}
- "{{ ports_agent }}:5000"
- "{{ ports_snmp }}:162/udp"
{% if traefik_enabled %}
labels:
- traefik.enable=true
- traefik.docker.network={{ traefik_network }}
- traefik.http.services.{{ service_name }}-web.loadBalancer.server.port=5000
- traefik.http.services.{{ service_name }}-web.loadBalancer.server.scheme=http
- traefik.http.routers.{{ service_name }}-http.service={{ service_name }}-web
- traefik.http.routers.{{ service_name }}-http.rule=Host(`{{ traefik_host }}.{{ traefik_domain }}`)
- traefik.http.routers.{{ service_name }}-http.entrypoints=web
{% 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_domain }}`)
- traefik.http.routers.{{ service_name }}-https.entrypoints=websecure
- traefik.http.routers.{{ service_name }}-https.tls=true
- traefik.http.routers.{{ service_name }}-https.tls.certresolver={{ traefik_tls_certresolver }}
{% endif %}
{% endif %}
volumes:
{{ service_name }}_data:
driver: local
{% if traefik_enabled %}
networks:
{{ traefik_network }}:
external: true
{% endif %}
================================================
FILE: library/compose/checkmk/template.yaml
================================================
kind: compose
metadata:
icon:
provider: selfh
id: checkmk
name: Checkmk
description: 'Checkmk is a comprehensive IT monitoring solution that provides real-time
insights
into the health and performance of your infrastructure, applications, and services.
It offers a wide range of monitoring capabilities, including server, network,
cloud,
and application monitoring, with an emphasis on ease of use and scalability.
## References
* **Project:** https://checkmk.com/
* **Documentation:** https://docs.checkmk.com/latest/en/
* **GitHub:** https://github.com/tribe29/checkmk'
next_steps: 'Log in with your initial admin user:
```bash
Username: cmkadmin
Password: {{ cmk_password }}
```'
version: 2.4.0-latest
author: Christian Lempa
date: '2025-12-10'
tags:
- traefik
schema: '1.2'
spec:
general:
vars:
service_name:
default: checkmk
container_timezone:
type: str
user_uid:
type: int
default: 1000
user_gid:
type: int
default: 1000
restart_policy:
type: enum
options:
- unless-stopped
- always
- on-failure
- 'no'
default: unless-stopped
required: true
cmk_password:
type: str
description: CheckMK admin password
sensitive: true
autogenerated: true
required: true
cmk_site_id:
type: str
description: CheckMK site ID
default: cmk
required: true
traefik:
vars:
traefik_host:
default: checkmk
traefik_network:
default: traefik
type: str
required: true
traefik_domain:
default: home.arpa
type: str
required: true
traefik_enabled:
type: bool
default: false
description: Enable Traefik integration
toggle: traefik_enabled
title: Traefik
description: Configure Traefik reverse proxy integration
ports:
vars:
ports_http:
default: 8000
ports_agent:
description: Agent port
type: int
default: 5000
required: true
ports_snmp:
description: SNMP trap port
type: int
default: 162
required: true
traefik_tls:
vars:
traefik_tls_certresolver:
type: str
default: cloudflare
required: true
traefik_tls_enabled:
type: bool
default: false
description: Enable Traefik TLS
toggle: traefik_tls_enabled
title: Traefik TLS
description: Configure Traefik TLS/SSL certificates
needs: traefik
================================================
FILE: library/compose/dockge/compose.yaml.j2
================================================
---
services:
{{ service_name }}:
image: docker.io/louislam/dockge:1.5.0
restart: {{ restart_policy }}
environment:
- TZ={{ container_timezone }}
- DOCKGE_STACKS_DIR={{ stacks_path }}
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- {{ service_name }}_data:/app/data
- {{ stacks_path }}:{{ stacks_path }}
{% if traefik_enabled %}
networks:
- {{ traefik_network }}
{% endif %}
{% if not traefik_enabled %}
ports:
- "{{ ports_http }}:5001"
{% endif %}
{% if traefik_enabled %}
labels:
- traefik.enable=true
- traefik.docker.network={{ traefik_network }}
- traefik.http.services.{{ service_name }}-web.loadBalancer.server.port=5001
- traefik.http.routers.{{ service_name }}-http.service={{ service_name }}-web
- traefik.http.routers.{{ service_name }}-http.rule=Host(`{{ traefik_host }}.{{ traefik_domain }}`)
- traefik.http.routers.{{ service_name }}-http.entrypoints=web
{% 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_domain }}`)
- traefik.http.routers.{{ service_name }}-https.entrypoints=websecure
- traefik.http.routers.{{ service_name }}-https.tls=true
- traefik.http.routers.{{ service_name }}-https.tls.certresolver={{ traefik_tls_certresolver }}
{% endif %}
{% endif %}
volumes:
{{ service_name }}_data:
driver: local
{% if traefik_enabled %}
networks:
{{ traefik_network }}:
external: true
{% endif %}
================================================
FILE: library/compose/dockge/template.yaml
================================================
kind: compose
metadata:
icon:
provider: selfh
id: dockge
name: Dockge
description: 'Dockge is a powerful Docker management platform that simplifies container
orchestration and monitoring.
It provides an intuitive web interface to manage Docker containers, images, networks,
and volumes with ease.
## Prerequisites
* **Project:** https://dockge.com/
* **Documentation:** https://docs.dockge.com/
* **GitHub:** https://github.com/dockge/dockge'
version: 1.5.0
author: Christian Lempa
date: '2025-09-28'
tags:
- traefik
schema: '1.2'
spec:
general:
vars:
service_name:
default: dockge
container_timezone:
type: str
restart_policy:
type: enum
options:
- unless-stopped
- always
- on-failure
- 'no'
default: unless-stopped
required: true
stacks_path:
type: str
description: Docker Compose Path
default: /opt/stacks
required: true
traefik:
vars:
traefik_host:
default: dockge
traefik_network:
default: traefik
type: str
required: true
traefik_domain:
default: home.arpa
type: str
required: true
traefik_enabled:
type: bool
default: false
description: Enable Traefik integration
toggle: traefik_enabled
title: Traefik
description: Configure Traefik reverse proxy integration
traefik_tls:
vars:
traefik_tls_certresolver:
type: str
default: cloudflare
required: true
traefik_tls_enabled:
type: bool
default: false
description: Enable Traefik TLS
toggle: traefik_tls_enabled
title: Traefik TLS
description: Configure Traefik TLS/SSL certificates
needs: traefik
ports:
vars:
ports_http:
default: 5001
================================================
FILE: library/compose/gitea/compose.yaml.j2
================================================
services:
{{ service_name }}:
image: docker.io/gitea/gitea:1.25.4
restart: {{ restart_policy }}
environment:
- USER_UID={{ user_uid }}
- USER_GID={{ user_gid }}
{% if database_type != 'sqlite' %}
- GITEA__database__DB_TYPE={{ database_type }}
{% if database_external %}
- GITEA__database__HOST={{ database_host }}
{% else %}
- GITEA__database__HOST={{ service_name }}_db
{% endif %}
- GITEA__database__NAME={{ database_name }}
- GITEA__database__USER={{ database_user }}
- GITEA__database__PASSWD=${DATABASE_PASSWORD}
{% endif %}
- GITEA__server__SSH_PORT={{ ports_ssh }}
{% if not traefik_enabled %}
- GITEA__server__ROOT_URL={{ gitea_url }}
{% else %}
- GITEA__server__ROOT_URL=https://{{ traefik_host }}.{{ traefik_domain }}
{% endif %}
{% if not database_external and (database_type == "postgres" or database_type == "mysql") or traefik_enabled %}
networks:
{% if not database_external and database_type != 'sqlite' %}
- {{ service_name }}_backend
{% endif %}
{% if traefik_enabled %}
- {{ traefik_network }}
{% endif %}
{% endif %}
ports:
{% if not traefik_enabled %}
- "{{ ports_http }}:3000"
{% endif %}
- "{{ ports_ssh }}:22"
volumes:
- {{ service_name }}_data:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
{% if traefik_enabled %}
labels:
- traefik.enable=true
- traefik.docker.network={{ traefik_network }}
- traefik.http.services.{{ service_name }}_web.loadBalancer.server.port=3000
- traefik.http.routers.{{ service_name }}_http.service={{ service_name }}_web
- traefik.http.routers.{{ service_name }}_http.rule=Host(`{{ traefik_host }}.{{ traefik_domain }}`)
- traefik.http.routers.{{ service_name }}_http.entrypoints=web
{% 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_domain }}`)
- traefik.http.routers.{{ service_name }}_https.entrypoints=websecure
- traefik.http.routers.{{ service_name }}_https.tls=true
- traefik.http.routers.{{ service_name }}_https.tls.certresolver={{ traefik_tls_certresolver }}
{% endif %}
{% endif %}
{% if not database_external and database_type != 'sqlite' %}
depends_on:
- {{ service_name }}_db
{% endif %}
{% if not database_external and database_type == "postgres" %}
{{ service_name }}_db:
image: docker.io/library/postgres:17.8
restart: {{ restart_policy }}
environment:
- TZ={{ container_timezone }}
- POSTGRES_USER={{ database_user }}
- POSTGRES_PASSWORD=${DATABASE_PASSWORD}
- POSTGRES_DB={{ database_name }}
networks:
- {{ service_name }}_backend
healthcheck:
test: ["CMD-SHELL", "pg_isready -U {{ database_user }}"]
start_period: 30s
interval: 10s
timeout: 10s
retries: 5
volumes:
- {{ service_name }}_db:/var/lib/postgresql/data
{% elif not database_external and database_type == "mysql" %}
{{ service_name }}_db:
image: docker.io/library/mysql:8.1
restart: {{ restart_policy }}
environment:
- MYSQL_USER={{ database_user }}
- MYSQL_PASSWORD=${DATABASE_PASSWORD}
- MYSQL_DATABASE={{ database_name }}
- MYSQL_ROOT_PASSWORD=${DATABASE_PASSWORD}
networks:
- {{ service_name }}_backend
volumes:
- {{ service_name }}_db:/var/lib/mysql
{% endif %}
volumes:
{{ service_name }}_data:
driver: local
{% if not database_external %}
{% if database_type == "postgres" or database_type == "mysql" %}
{{ service_name }}_db:
driver: local
{% endif %}
{% endif %}
{% if not database_external and (database_type == "postgres" or database_type == "mysql") or traefik_enabled %}
networks:
{% if not database_external %}
{{ service_name }}_backend:
driver: bridge
{% endif %}
{% if traefik_enabled %}
{{ traefik_network }}:
external: true
{% endif %}
{% endif %}
================================================
FILE: library/compose/gitea/template.yaml
================================================
kind: compose
metadata:
name: Gitea
description: 'Self-hosted Git service with web interface. Gitea is a painless, self-hosted
Git service
written in Go. It''s similar to GitHub, Bitbucket, and GitLab, providing Git repository
hosting, code review, team collaboration, and more.
## Prerequisites
- :info: Gitea supports multiple database backends. You can choose between SQLite
(default),
PostgreSQL, or MySQL. SQLite is suitable for small deployments, while PostgreSQL
and MySQL
are recommended for larger installations.
## References
- **Project:** https://gitea.io/
- **Documentation:** https://docs.gitea.io/
- **GitHub:** https://github.com/go-gitea/gitea'
icon:
provider: selfh
id: gitea
version: 1.25.4
author: Christian Lempa
date: '2026-01-22'
tags:
- traefik
schema: '1.2'
spec:
general:
vars:
service_name:
default: gitea
container_timezone:
type: str
user_uid:
type: int
default: 1000
user_gid:
type: int
default: 1000
restart_policy:
type: enum
options:
- unless-stopped
- always
- on-failure
- 'no'
default: unless-stopped
required: true
gitea_url:
description: Public URL
type: str
needs:
- traefik_enabled=false
default: https://git.example.com
database:
vars:
database_type:
type: enum
options:
- sqlite
- postgres
- mysql
default: sqlite
description: Database backend type
database_name:
type: str
default: gitea
description: Database name
needs:
- database_type=postgres,mysql
database_user:
type: str
default: gitea
description: Database user
needs:
- database_type=postgres,mysql
database_host:
type: str
default: postgres
description: Database host
needs:
- database_type=postgres,mysql;database_external=true
database_password:
type: str
sensitive: true
autogenerated: true
description: Database password
needs:
- database_type=postgres,mysql
database_external:
type: bool
default: false
description: Use external database
needs:
- database_type=postgres,mysql
ports:
vars:
ports_http:
default: 3000
ports_ssh:
default: 2221
traefik:
vars:
traefik_host:
default: gitea
traefik_network:
default: traefik
type: str
required: true
traefik_domain:
default: home.arpa
type: str
required: true
traefik_enabled:
type: bool
default: false
description: Enable Traefik integration
toggle: traefik_enabled
title: Traefik
description: Configure Traefik reverse proxy integration
traefik_tls:
vars:
traefik_tls_certresolver:
type: str
default: cloudflare
required: true
traefik_tls_enabled:
type: bool
default: false
description: Enable Traefik TLS
toggle: traefik_tls_enabled
title: Traefik TLS
description: Configure Traefik TLS/SSL certificates
needs: traefik
================================================
FILE: library/compose/gitlab/compose.yaml.j2
================================================
services:
{{ service_name }}:
image: docker.io/gitlab/gitlab-ce:18.8.4-ce.0
restart: {{ restart_policy }}
shm_size: '256m'
env_file: .env
{% if traefik_enabled %}
networks:
{{ traefik_network }}:
{% endif %}
ports:
{% if not traefik_enabled %}
- "{{ ports_http }}:80"
{% endif %}
- "{{ ports_ssh }}:22"
{% if registry_enabled and not traefik_enabled %}
- "{{ ports_registry }}:5000"
{% endif %}
volumes:
- ./config/gitlab.rb:/etc/gitlab/gitlab.rb:ro
- {{ service_name }}_config:/etc/gitlab
- {{ service_name }}_logs:/var/log/gitlab
- {{ service_name }}_data:/var/opt/gitlab
{% if traefik_enabled %}
labels:
- traefik.enable=true
- traefik.docker.network={{ traefik_network }}
- traefik.http.services.{{ service_name }}_web.loadBalancer.server.port=80
- traefik.http.routers.{{ service_name }}_http.service={{ service_name }}_web
- traefik.http.routers.{{ service_name }}_http.rule=Host(`{{ traefik_host }}.{{ traefik_domain }}`)
- traefik.http.routers.{{ service_name }}_http.entrypoints=web
{% 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_domain }}`)
- traefik.http.routers.{{ service_name }}_https.entrypoints=websecure
- traefik.http.routers.{{ service_name }}_https.tls=true
- traefik.http.routers.{{ service_name }}_https.tls.certresolver={{ traefik_tls_certresolver }}
{% endif %}
{% if registry_enabled %}
- traefik.http.services.{{ service_name }}_registry.loadBalancer.server.port=5000
- traefik.http.routers.{{ service_name }}_registry-http.service={{ service_name }}_registry
- traefik.http.routers.{{ service_name }}_registry-http.rule=Host(`{{ traefik_registry_host }}`.{{ traefik_domain }}`)
- traefik.http.routers.{{ service_name }}_registry-http.entrypoints=web
{% if traefik_tls_enabled %}
- traefik.http.routers.{{ service_name }}_registry-https.service={{ service_name }}_registry
- traefik.http.routers.{{ service_name }}_registry-https.rule=Host(`{{ traefik_registry_host }}`.{{ traefik_domain }}`)
- traefik.http.routers.{{ service_name }}_registry-https.entrypoints=websecure
- traefik.http.routers.{{ service_name }}_registry-https.tls=true
- traefik.http.routers.{{ service_name }}_registry-https.tls.certresolver={{ traefik_tls_certresolver }}
{% endif %}
{% endif %}
{% endif %}
volumes:
{{ service_name }}_config:
driver: local
{{ service_name }}_logs:
driver: local
{{ service_name }}_data:
driver: local
{% if traefik_enabled %}
networks:
{{ traefik_network }}:
external: true
{% endif %}
================================================
FILE: library/compose/gitlab/config/gitlab.rb.j2
================================================
# GitLab Configuration
external_url '{{ external_url }}'
# Initial root user configuration (only used on first initialization)
gitlab_rails['initial_root_password'] = ENV['GITLAB_ROOT_PASSWORD']
gitlab_rails['initial_root_email'] = '{{ root_email }}'
# GitLab Shell SSH settings
gitlab_rails['gitlab_shell_ssh_port'] = {{ ports_ssh }}
# Let's Encrypt and built-in TLS settings are currently not supported by the template
# as we are using Traefik as a reverse proxy
letsencrypt['enable'] = false
nginx['listen_port'] = 80
nginx['listen_https'] = false
{% if traefik_tls_enabled %}
# Traefik settings, to redirect http to https
nginx['redirect_http_to_https'] = true
{% endif %}
{% if registry_enabled %}
# Container Registry settings
registry_external_url '{{ registry_external_url }}'
gitlab_rails['registry_enabled'] = true
registry_nginx['listen_https'] = false
registry_nginx['listen_port'] = 5000
{% endif %}
{% if authentik_enabled %}
# Authentik SSO settings
gitlab_rails['omniauth_auto_link_user'] = ['openid_connect']
gitlab_rails['omniauth_providers'] = [
{
name: "openid_connect",
label: "Authentik",
icon: "https://avatars.githubusercontent.com/u/82976448?s=200&v=4",
args: {
name: "openid_connect",
scope: ["openid","profile","email"],
response_type: "code",
issuer: "{{ authentik_url }}/application/o/{{ authentik_slug }}/",
discovery: true,
client_auth_method: "query",
uid_field: "email",
send_scope_to_token_endpoint: "false",
pkce: true,
client_options: {
identifier: "{{ authentik_client_id }}",
secret: "{{ authentik_client_secret }}",
redirect_uri: "{{ external_url }}/users/auth/openid_connect/callback"
}
}
}
]
{% endif %}
{% if email_enabled %}
# SMTP settings
gitlab_rails['smtp_enable'] = true
gitlab_rails['smtp_address'] = "{{ email_host }}"
gitlab_rails['smtp_port'] = {{ email_port }}
gitlab_rails['smtp_user_name'] = "{{ email_username }}"
gitlab_rails['smtp_password'] = "{{ email_password }}"
gitlab_rails['smtp_authentication'] = "login"
{% if email_use_ssl %}
gitlab_rails['smtp_ssl'] = true
gitlab_rails['smtp_force_ssl'] = true
{% elif email_use_tls %}
gitlab_rails['smtp_tls'] = true
{% endif %}
gitlab_rails['gitlab_email_from'] = '{{ email_from }}'
gitlab_rails['gitlab_email_reply_to'] = '{{ email_from }}'
{% endif %}
{% if performance_preset == 'homelab' %}
# Performance optimizations for homelab/low-resource environments
# NOTE: These settings reduce resource usage but may impact performance under high load
postgresql['shared_buffers'] = "256MB"
sidekiq['max_concurrency'] = 4
sidekiq['concurrency'] = 1
puma['worker_timeout'] = 120
puma['worker_processes'] = 1
{% endif %}
# Prometheus monitoring
prometheus_monitoring['enable'] = {{ prometheus_enabled | lower }}
# Default UI settings
gitlab_rails['gitlab_default_theme'] = {{ default_theme }}
gitlab_rails['gitlab_default_color_mode'] = {{ default_color_mode }}
# Product usage data
gitlab_rails['initial_gitlab_product_usage_data'] = {{ 'false' if disable_usage_data else 'true' }}
================================================
FILE: library/compose/gitlab/template.yaml
================================================
kind: compose
metadata:
name: GitLab
description: 'A **complete DevOps platform** that provides Git repository management,
CI/CD pipelines,
issue tracking, and container registry in a single application.
## Prerequisites
- ...
**Performance Presets**:
- `homelab`: Optimized for low-resource environments (limited workers, reduced
PostgreSQL buffers)
- `default`: Standard server configuration for production use
**External URL**:
- Set to your public domain (e.g., `https://gitlab.example.com`) for proper clone
URLs
- Affects SSH clone URLs and web links in emails/notifications
**Container Registry**:
- Enable if you need private Docker image hosting
- Requires separate external URL (e.g., `https://registry.example.com`)
## Resources
- **Project**: https://about.gitlab.com/
- **Documentation**: https://docs.gitlab.com/
- **Community**: https://forum.gitlab.com/'
icon:
provider: selfh
id: gitlab
version: 18.8.4-ce.0
author: Christian Lempa
date: '2026-02-10'
tags:
- traefik
next_steps: "## Post-Installation Steps\n1. **Start GitLab**:\n ```bash\n docker\
\ compose up -d\n ```\n2. **Wait for\\\n \\ initialization** (2-5 minutes):\n\
\ ```bash\n docker compose logs -f gitlab\n ```\n Wait for message:\
\ `gitlab\\\n \\ Reconfigured!`\n 3. **Access the web interface**:\n {% if\
\ traefik_enabled -%}\n - Via Traefik: https://{{ traefik_host\\\n \\ }}\n\
\ {% if not traefik_enabled and network_mode == 'bridge' %}- Direct access:\
\ http://localhost:{{ ports_http }}{%\\\n \\ endif %}\n {%- else -%}\n \
\ - Open {{ external_url }} in your browser\n {% if network_mode == 'bridge'\
\ %}- Or: http://localhost:{{\\\n \\ ports_http }}{% endif %}\n {%- endif\
\ %}\n 4. **Initial login credentials**:\n - **Username**: `root`\n - **Password**:\\\
\n \\ `{{ root_password }}`\n > **Important**: This password only works on\
\ FIRST initialization.\n > Change it immediately\\\n \\ after first login\
\ via GitLab's web interface!\n 5. **Configure SSH** (optional):\n - SSH clone\
\ URLs will use port `{{\\\n \\ ports_ssh }}`\n - Update your Git remote if\
\ needed\n ## Additional Resources\n - Documentation: https://docs.gitlab.com/\n\
\ \\\n - GitLab Runner: https://docs.gitlab.com/runner/"
schema: '1.2'
spec:
general:
vars:
service_name:
type: str
description: Docker service name
default: gitlab
external_url:
type: str
description: External URL for GitLab
default: http://localhost
root_email:
type: str
description: Initial root user email address
default: admin@example.com
root_password:
type: str
description: Initial root user password (only used on first initialization)
sensitive: true
autogenerated: true
extra: 'Leave empty to auto-generate. WARNING: Only sets password on FIRST
startup!'
default_theme:
type: int
description: Default GitLab UI theme (2 = dark mode)
default: 2
extra: 1 = Indigo, 2 = Dark, 3 = Light, 4 = Blue, 5 = Green
default_color_mode:
type: int
description: Default color mode (2 = dark mode)
default: 2
extra: 1 = Light, 2 = Dark
disable_usage_data:
type: bool
description: Disable product usage metrics upload to GitLab
default: true
restart_policy:
type: enum
options:
- unless-stopped
- always
- on-failure
- 'no'
default: unless-stopped
required: true
advanced:
title: Advanced Settings
description: Performance tuning and advanced configuration options
vars:
performance_preset:
type: enum
description: Performance optimization profile
options:
- homelab
- default
default: homelab
extra: homelab is optimized for low-resource environments, default is for
standard servers
prometheus_enabled:
type: bool
description: Enable Prometheus monitoring
default: false
extra: Disabling saves ~200-400MB RAM. GitLab UI metrics will be unavailable.
toggle: prometheus_enabled
ports:
vars:
ports_http:
default: 80
ports_ssh:
default: 2424
ports_registry:
type: int
description: Container Registry port
default: 5000
registry:
description: GitLab Container Registry configuration
toggle: registry_enabled
vars:
registry_enabled:
type: bool
description: Enable GitLab Container Registry
default: false
registry_external_url:
type: str
description: External URL for Container Registry
default: http://localhost:5000
traefik:
vars:
traefik_host:
default: gitlab.home.arpa
traefik_registry_host:
needs:
- registry_enabled=true
default: registry.home.arpa
type: str
description: Hostname for Container Registry in Traefik (if enabled)
traefik_network:
default: traefik
type: str
required: true
traefik_domain:
default: home.arpa
type: str
required: true
traefik_enabled:
type: bool
default: false
description: Enable Traefik integration
toggle: traefik_enabled
title: Traefik
description: Configure Traefik reverse proxy integration
traefik_tls:
vars:
traefik_tls_certresolver:
type: str
default: cloudflare
required: true
traefik_tls_enabled:
type: bool
default: false
description: Enable Traefik TLS
toggle: traefik_tls_enabled
title: Traefik TLS
description: Configure Traefik TLS/SSL certificates
needs: traefik
email:
toggle: email_enabled
vars:
email_enabled:
type: bool
default: false
description: Enable email server configuration
email_host:
type: str
default: ''
description: SMTP server hostname
email_port:
type: int
default: 587
description: SMTP server port
email_username:
type: str
default: ''
description: SMTP username
email_password:
type: str
sensitive: true
default: ''
description: SMTP password
email_from:
type: str
default: ''
description: From email address
email_use_tls:
type: bool
default: true
description: Use TLS encryption
email_use_ssl:
type: bool
default: false
description: Use SSL encryption
authentik:
toggle: authentik_enabled
vars:
authentik_enabled:
type: bool
default: false
description: Enable Authentik SSO integration
authentik_url:
type: str
default: https://auth.home.arpa
description: Authentik base URL (e.g., https://auth.example.com)
authentik_slug:
type: str
default: gitlab
description: Authentik application slug
authentik_client_id:
type: str
default: ''
description: OAuth client ID from Authentik provider
authentik_client_secret:
type: str
sensitive: true
default: ''
description: OAuth client secret from Authentik provider
================================================
FILE: library/compose/gitlab-runner/compose.yaml.j2
================================================
{#
GitLab Runner: CI/CD job executor for GitLab
Executes pipeline jobs using Docker executor
#}
services:
gitlab-runner:
image: docker.io/gitlab/gitlab-runner:alpine-v17.9.1
container_name: gitlab-runner-1
{#
Volume configuration:
- config.toml: Runner configuration (read-only)
- Docker socket: Required for Docker executor to spawn job containers
#}
volumes:
- ./config/config.toml:/etc/gitlab-runner/config.toml:ro
- /var/run/docker.sock:/var/run/docker.sock
restart: unless-stopped
================================================
FILE: library/compose/gitlab-runner/config/config.toml
================================================
concurrent = 10
log_level = "warning"
log_format = "json"
check_interval = 5
[[runners]]
name = "gitlab-runner-1"
url = "gitlab.example.com" # FIXME Change to your GitLab instance URL
executor = "docker"
token = "" # FIXME Add your registration token here
limit = 0
# FIXME To increase rate limits, when pulling down images from the Docker Hub you might want to authenticate:
# 1. Create a Docker Hub account and generate a personal access token
# 2. Encode the username and token in base64
# Example: echo -n 'username:token' | base64
# 3. Replace the with the base64 encoded string
environment = ["DOCKER_AUTH_CONFIG={\"auths\":{\"https://index.docker.io/v1/\":{\"auth\":\"\"}}}"]
[runners.docker]
tls_verify = false
image = "alpine:latest"
privileged = true
disable_cache = false
volumes = ["/var/run/docker.sock:/var/run/docker.sock", "/cache"]
[runners.cache]
Insecure = false
================================================
FILE: library/compose/gitlab-runner/template.yaml
================================================
---
kind: compose
metadata:
name: Gitlab-Runner
description: |-
Docker compose setup for Gitlab-Runner, a build instance for Gitlab CI/CD pipelines.
This template configures Gitlab-Runner with Docker executor and integrates it with a Gitlab instance.
Project: https://docs.gitlab.com/runner/
Documentation: https://docs.gitlab.com/runner/
GitHub: https://github.com/gitlab/gitlab-runner
version: alpine-v17.9.1
author: Christian Lempa
date: "2025-09-28"
tags: []
icon:
provider: selfh
id: gitlab
draft: true
next_steps: ""
schema: "1.2"
spec:
general:
vars:
service_name:
default: gitlab-runner
restart_policy:
type: enum
options: [unless-stopped, always, on-failure, "no"]
default: unless-stopped
required: true
================================================
FILE: library/compose/grafana/compose.yaml.j2
================================================
services:
{{ service_name }}:
image: docker.io/grafana/grafana-oss:12.3.3
restart: {{ restart_policy }}
{% if database_type == 'postgres' or authentik_enabled %}
environment:
{% if database_type == 'postgres' %}
- GF_DATABASE_TYPE=postgres
{% if database_external %}
- GF_DATABASE_HOST={{ database_host }}
{% else %}
- GF_DATABASE_HOST={{ service_name }}_db
{% endif %}
- GF_DATABASE_NAME={{ database_name }}
- GF_DATABASE_USER={{ database_user }}
- GF_DATABASE_PASSWORD=${GRAFANA_DB_PASSWORD}
- GF_DATABASE_SSL_MODE=disable
{% endif %}
{% if authentik_enabled %}
- GF_AUTH_GENERIC_OAUTH_ENABLED=true
- GF_AUTH_GENERIC_OAUTH_NAME={{ authentik_slug }}
- GF_AUTH_GENERIC_OAUTH_CLIENT_ID=${GRAFANA_OAUTH_CLIENT_ID}
- GF_AUTH_GENERIC_OAUTH_CLIENT_SECRET=${GRAFANA_OAUTH_CLIENT_SECRET}
- GF_AUTH_GENERIC_OAUTH_SCOPES=openid profile email
- GF_AUTH_GENERIC_OAUTH_AUTH_URL={{ authentik_url }}/application/o/authorize/
- GF_AUTH_GENERIC_OAUTH_TOKEN_URL={{ authentik_url }}/application/o/token/
- GF_AUTH_GENERIC_OAUTH_API_URL={{ authentik_url }}/application/o/userinfo/
- GF_AUTH_SIGNOUT_REDIRECT_URL={{ authentik_url }}/application/o/{{ authentik_slug }}/end-session/
- GF_AUTH_OAUTH_AUTO_LOGIN=true
{% if traefik_enabled %}
- GF_SERVER_ROOT_URL=https://{{ traefik_host }}.{{ traefik_domain }}
{% endif %}
- GF_AUTH_OAUTH_ALLOW_INSECURE_EMAIL_LOOKUP=true
- GF_AUTH_GENERIC_OAUTH_SKIP_ORG_ROLE_SYNC=true
{% endif %}
{% endif %}
{% if not database_external and (database_type == "postgres") or traefik_enabled %}
networks:
{% if not database_external and database_type != 'sqlite' %}
- {{ service_name }}_backend
{% endif %}
{% if traefik_enabled %}
- {{ traefik_network }}
{% endif %}
{% endif %}
{% if not traefik_enabled %}
ports:
- "{{ ports_http }}:3000"
{% endif %}
volumes:
- {{ service_name }}_data:/var/lib/grafana
{% if traefik_enabled and not swarm_enabled %}
labels:
- traefik.enable=true
- traefik.docker.network={{ traefik_network }}
- traefik.http.services.{{ service_name }}_web.loadBalancer.server.port=3000
- traefik.http.routers.{{ service_name }}_http.service={{ service_name }}_web
- traefik.http.routers.{{ service_name }}_http.rule=Host(`{{ traefik_host }}.{{ traefik_domain }}`)
- traefik.http.routers.{{ service_name }}_http.entrypoints=web
{% 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_domain }}`)
- traefik.http.routers.{{ service_name }}_https.entrypoints=websecure
- traefik.http.routers.{{ service_name }}_https.tls=true
- traefik.http.routers.{{ service_name }}_https.tls.certresolver={{ traefik_tls_certresolver }}
{% endif %}
{% endif %}
{% if not database_external and database_type == "postgres" %}
{{ service_name }}_db:
image: docker.io/library/postgres:17.8
restart: {{ restart_policy }}
environment:
- POSTGRES_USER={{ database_user }}
- POSTGRES_PASSWORD=${GRAFANA_DB_PASSWORD}
- POSTGRES_DB={{ database_name }}
networks:
- {{ service_name }}_backend
healthcheck:
test: ["CMD-SHELL", "pg_isready -U {{ database_user }}"]
start_period: 30s
interval: 10s
timeout: 10s
retries: 5
volumes:
- {{ service_name }}_db:/var/lib/postgresql/data
{% endif %}
{% if not database_external and (database_type == "postgres") or traefik_enabled %}
networks:
{% if not database_external %}
{{ service_name }}_backend:
driver: bridge
{% endif %}
{% if traefik_enabled %}
{{ traefik_network }}:
external: true
{% endif %}
{% endif %}
volumes:
{{ service_name }}_data:
driver: local
{% if not database_external and database_type == 'postgres' %}
{{ service_name }}_db:
driver: local
{% endif %}
================================================
FILE: library/compose/grafana/template.yaml
================================================
kind: compose
metadata:
name: Grafana
description: 'Grafana is an open-source platform for monitoring and observability
that allows you to visualize and analyze metrics, logs, and traces from various
data
sources. It provides a powerful and flexible dashboarding solution for IT infrastructure
and application monitoring.
## Prerequisites
- **Project:** https://grafana.com/
- **Documentation:** https://grafana.com/docs/grafana/latest/
- **GitHub:** https://github.com/grafana/grafana'
icon:
provider: selfh
id: grafana
next_steps: 'Log in with the initial admin user:
```bash
Username: admin
Password: admin
```'
version: 12.3.3
author: Christian Lempa
date: '2026-02-12'
tags:
- traefik
- authentik
schema: '1.2'
spec:
general:
vars:
service_name:
default: grafana
restart_policy:
type: enum
options:
- unless-stopped
- always
- on-failure
- 'no'
default: unless-stopped
required: true
ports:
vars:
ports_http:
default: 3000
authentik:
toggle: authentik_enabled
vars:
authentik_enabled:
type: bool
default: false
description: Enable Authentik SSO integration
authentik_url:
type: url
default: https://auth.home.arpa
required: true
needs: authentik_enabled=true
authentik_slug:
type: hostname
default: grafana
required: true
needs: authentik_enabled=true
authentik_client_id:
type: str
sensitive: true
required: true
needs: authentik_enabled=true
authentik_client_secret:
type: str
sensitive: true
required: true
needs: authentik_enabled=true
traefik:
vars:
traefik_host:
default: grafana
traefik_network:
default: traefik
type: str
required: true
traefik_domain:
default: home.arpa
type: str
required: true
traefik_enabled:
type: bool
default: false
description: Enable Traefik integration
toggle: traefik_enabled
title: Traefik
description: Configure Traefik reverse proxy integration
traefik_tls:
vars:
traefik_tls_certresolver:
type: str
default: cloudflare
required: true
traefik_tls_enabled:
type: bool
default: false
description: Enable Traefik TLS
toggle: traefik_tls_enabled
title: Traefik TLS
description: Configure Traefik TLS/SSL certificates
needs: traefik
database:
vars:
database_type:
type: enum
options:
- sqlite
- postgres
database_external:
type: bool
default: false
description: Use external database
needs: database_type=postgres
database_name:
type: str
default: grafana
needs: database_type=postgres
database_user:
type: str
default: grafana
needs: database_type=postgres
database_password:
type: str
sensitive: true
required: true
needs: database_type=postgres
database_host:
type: str
required: true
needs:
- database_type=postgres
- database_external=true
swarm:
toggle: swarm_enabled
vars:
swarm_enabled:
type: bool
default: false
description: Enable Docker Swarm mode
swarm_placement_mode:
type: str
default: replicated
description: The placement mode
needs:
- swarm_enabled=true
swarm_replicas:
type: int
default: 1
description: The number of replicas
needs:
- swarm_placement_mode=replicated
swarm_placement_host:
type: str
default: ''
description: The placement host
needs:
- swarm_placement_mode=replicated
================================================
FILE: library/compose/homeassistant/compose.yaml.j2
================================================
services:
{{ service_name }}:
container_name: {{ container_name }}
image: ghcr.io/home-assistant/home-assistant:2026.2.2
volumes:
- ./config:/config
- /etc/localtime:/etc/localtime:ro
- /run/dbus:/run/dbus:ro
# devices:
# - /dev/ttyACMO # (optional) Add serial devices to the container
privileged: true
restart: unless-stopped
================================================
FILE: library/compose/homeassistant/template.yaml
================================================
---
kind: compose
metadata:
name: Homeassistant
description: |-
Home Assistant is an open-source platform for smart home automation that puts local control and privacy first.
This template sets up Home Assistant in a Docker container using Docker Compose.
Project: https://www.home-assistant.io/
Documentation: https://www.home-assistant.io/docs/
version: 2026.2.2
author: Christian Lempa
date: '2026-02-14'
tags: []
icon:
provider: selfh
id: home-assistant
draft: true
next_steps: ""
schema: "1.2"
spec:
general:
vars:
container_name:
default: homeassistant
service_name:
default: homeassistant
================================================
FILE: library/compose/homepage/compose.yaml.j2
================================================
services:
{{ service_name }}:
image: ghcr.io/gethomepage/homepage:v1.10.1
{% if not swarm_enabled %}
restart: {{ restart_policy }}
{% if container_name %}
container_name: {{ container_name }}
{% endif %}
{% endif %}
{% if container_timezone or container_loglevel %}
environment:
{% if container_timezone %}
- TZ={{ container_timezone }}
{% endif %}
{% if container_loglevel %}
- LOG_LEVEL={{ container_loglevel }}
{% endif %}
{% endif %}
{% if traefik_enabled %}
networks:
{{ traefik_network }}:
{% endif %}
{% if not traefik_enabled %}
ports:
- {{ ports_http }}:3000
{% endif %}
volumes:
{% if volume_mode == 'local' %}
- {{ service_name }}_config:/app/config
- {{ service_name }}_images:/app/images
- {{ service_name }}_icons:/app/icons
{% elif volume_mode == 'mount' %}
- {{ volume_mount_path }}/{{ service_name }}/config:/app/config
- {{ volume_mount_path }}/{{ service_name }}/images:/app/images
- {{ volume_mount_path }}/{{ service_name }}/icons:/app/icons
{% endif %}
{% if traefik_enabled and not swarm_enabled %}
labels:
- traefik.enable=true
- traefik.docker.network={{ traefik_network }}
- traefik.http.services.{{ service_name }}-web.loadbalancer.server.port=3000
- traefik.http.routers.{{ service_name }}-http.service={{ service_name }}-web
- traefik.http.routers.{{ service_name }}-http.rule=Host(`{{ traefik_host }}.{{ traefik_domain }}`)
- 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_domain }}`)
- 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 %}
{% if swarm_enabled %}
deploy:
{% if swarm_placement_mode == 'replicated' %}
replicas: {{ swarm_replicas }}
placement:
constraints:
- node.hostname == {{ swarm_placement_host }}
{% else %}
mode: global
{% endif %}
{% if traefik_enabled %}
labels:
- traefik.enable=true
- traefik.docker.network={{ traefik_network }}
- traefik.http.services.{{ service_name }}-web.loadbalancer.server.port=3000
- traefik.http.routers.{{ service_name }}-http.service={{ service_name }}-web
- traefik.http.routers.{{ service_name }}-http.rule=Host(`{{ traefik_host }}.{{ traefik_domain }}`)
- 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_domain }}`)
- 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 %}
{% endif %}
{% if traefik_enabled %}
networks:
{{ traefik_network }}:
external: true
{% endif %}
{% if volume_mode == 'local' %}
volumes:
{{ service_name }}_config:
{{ service_name }}_images:
{{ service_name }}_icons:
{% endif %}
================================================
FILE: library/compose/homepage/template.yaml
================================================
kind: compose
metadata:
name: Homepage
description: 'Homepage is a modern, fully static, fast, secure fully customizable
application dashboard with integrations
for over 100 services and translations into multiple languages.
Project: https://gethomepage.dev/
Documentation: https://gethomepage.dev/latest/
GitHub: https://github.com/gethomepage/homepage'
version: v1.10.1
author: Christian Lempa
date: '2026-02-10'
tags:
- traefik
- swarm
- volume_modes
icon:
provider: simpleicons
id: homepage
draft: true
next_steps: ''
schema: '1.2'
spec:
general:
vars:
service_name:
description: The service name
type: str
required: true
default: homepage
container_name:
description: The container name
type: str
default: homepage
container_timezone:
description: The container timezone
type: str
container_loglevel:
description: The container log level
type: enum
options:
- debug
- info
- warn
- error
restart_policy:
description: The container restart policy
type: enum
options:
- unless-stopped
- always
- on-failure
- 'no'
required: true
default: unless-stopped
ports:
vars:
ports_http:
description: The HTTP port
type: int
needs:
- traefik_enabled=false
required: true
default: 3000
traefik:
vars:
traefik_enabled:
type: bool
default: false
description: Enable Traefik integration
traefik_entrypoint:
description: The Traefik entrypoint
type: str
required: true
default: web
traefik_host:
default: homepage
traefik_network:
default: traefik
type: str
required: true
traefik_domain:
default: home.arpa
type: str
required: true
toggle: traefik_enabled
title: Traefik
description: Configure Traefik reverse proxy integration
traefik_tls:
vars:
traefik_tls_enabled:
description: Enable Traefik TLS
type: bool
default: false
traefik_tls_entrypoint:
description: The Traefik TLS entrypoint
type: str
default: websecure
required: true
traefik_tls_certresolver:
type: str
required: true
default: cloudflare
toggle: traefik_tls_enabled
title: Traefik TLS
description: Configure Traefik TLS/SSL certificates
needs: traefik
volume:
vars:
volume_mode:
type: enum
options:
- local
- mount
- nfs
default: local
required: true
volume_mount_path:
type: str
default: /mnt/storage
needs:
- volume_mode=mount
required: true
volume_nfs_options:
type: str
default: nfsvers=4,soft,timeo=180,intr
description: The NFS mount options
volume_nfs_path:
type: str
default: ''
description: The NFS path
volume_nfs_server:
type: str
default: ''
description: The NFS server
swarm: null
================================================
FILE: library/compose/homer/assets/config.yml.j2
================================================
---
# Homepage configuration
# See https://fontawesome.com/icons for icons options
title: "{{ homer_title }}"
subtitle: "{{ homer_subtitle }}"
logo: "{{ homer_logo }}"
# icon: "fas fa-home" # Optional icon instead of logo
header: true
footer: false # Set to "" to enable footer with a custom message
# Optional theme customization
theme: default # Options: default, sui
# colors:
# light:
# highlight-primary: "#3367d6"
# highlight-secondary: "#4285f4"
# highlight-hover: "#5a95f5"
# background: "#f5f5f5"
# card-background: "#ffffff"
# text: "#363636"
# text-header: "#ffffff"
# dark:
# highlight-primary: "#3367d6"
# highlight-secondary: "#4285f4"
# highlight-hover: "#5a95f5"
# background: "#131313"
# card-background: "#2b2b2b"
# text: "#eaeaea"
# text-header: "#ffffff"
# Optional message displayed at the top
# message:
# style: "is-dark" # Options: is-dark, is-warning, is-info, is-success, is-danger
# title: "Welcome!"
# icon: "fa fa-grin"
# content: "This is your Homer dashboard.
Add your services below."
# Optional navbar links
# links:
# - name: "GitHub"
# icon: "fab fa-github"
# url: "https://github.com"
# target: "_blank" # optional: open in new tab
# - name: "Documentation"
# icon: "fas fa-book"
# url: "https://github.com/bastienwirtz/homer/blob/main/docs/configuration.md"
# Services
# First level array represents a group.
# Leave only an "items" key if not using groups (group name, icon & tagstyle are optional)
services:
- name: "Applications"
icon: "fas fa-cloud"
# items:
# - name: "Example App"
# logo: "assets/tools/sample.png" # or use "icon: fas fa-server"
# subtitle: "Description of the app"
# tag: "app" # Optional tag
# tagstyle: "is-success" # Optional: is-success, is-warning, is-info, is-danger
# url: "https://example.com"
# target: "_blank" # optional: open in new tab
# - name: "Monitoring"
# icon: "fas fa-chart-line"
# items:
# - name: "Grafana"
# icon: "fas fa-chart-area"
# subtitle: "Metrics & dashboards"
# url: "https://grafana.example.com"
================================================
FILE: library/compose/homer/compose.yaml.j2
================================================
services:
{{ service_name }}:
image: docker.io/b4bz/homer:v25.11.1
{% if not swarm_enabled %}
restart: {{ restart_policy }}
{% if container_name %}
container_name: {{ container_name }}
{% endif %}
{% endif %}
{% if container_timezone %}
environment:
- TZ={{ container_timezone }}
{% endif %}
{% if traefik_enabled %}
networks:
{{ traefik_network }}:
{% endif %}
{% if not traefik_enabled %}
ports:
{% if swarm_enabled %}
- target: 8080
published: {{ ports_http }}
protocol: tcp
mode: host
{% else %}
- "{{ ports_http }}:8080"
{% endif %}
{% endif %}
volumes:
- ./assets:/www/assets
{% if traefik_enabled and not swarm_enabled %}
labels:
- traefik.enable=true
- traefik.docker.network={{ traefik_network }}
- traefik.http.services.{{ service_name }}-web.loadBalancer.server.port=8080
- traefik.http.routers.{{ service_name }}-http.service={{ service_name }}-web
- traefik.http.routers.{{ service_name }}-http.rule=Host(`{{ traefik_host }}.{{ traefik_domain }}`)
- 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_domain }}`)
- 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 %}
{% if swarm_enabled %}
deploy:
{% if swarm_enabled %}
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 %}
restart_policy:
condition: on-failure
{% endif %}
{% if swarm_enabled and traefik_enabled %}
labels:
- traefik.enable=true
- traefik.docker.network={{ traefik_network }}
- traefik.http.services.{{ service_name }}-web.loadBalancer.server.port=8080
- traefik.http.routers.{{ service_name }}-http.service={{ service_name }}-web
- traefik.http.routers.{{ service_name }}-http.rule=Host(`{{ traefik_host }}.{{ traefik_domain }}`)
- traefik.http.routers.{{ service_name }}-http.entrypoints={{ traefik_entrypoint }}
{% if authentik_enabled %}
- traefik.http.routers.{{ service_name }}-http.middlewares={{ authentik_traefik_middleware }}
{% endif %}
{% 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_domain }}`)
- 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 }}
{% if authentik_enabled %}
- traefik.http.routers.{{ service_name }}-https.middlewares={{ authentik_traefik_middleware }}
{% endif %}
{% endif %}
{% endif %}
{% endif %}
{% if traefik_enabled %}
networks:
{{ traefik_network }}:
external: true
{% endif %}
================================================
FILE: library/compose/homer/template.yaml
================================================
kind: compose
metadata:
icon:
provider: selfh
id: homer
name: Homer
description: 'A very simple static homepage for your server to keep your services
on hand, from a simple yaml configuration file.
Project: https://github.com/bastienwirtz/homer
Documentation: https://github.com/bastienwirtz/homer/blob/main/docs/configuration.md
'
version: v25.11.1
author: Christian Lempa
date: '2025-12-11'
tags:
- traefik
- swarm
- authentik
draft: true
next_steps: "1. Start the Homer dashboard:\n docker compose up -d\n\n2. Customize\
\ your dashboard:\n - Edit assets/config.yml to add your services\n - Organize\
\ services into groups (Applications, Monitoring, etc.)\n - Add links to the\
\ navbar for quick access\n\n3. Optional: Add a logo:\n - Place your logo.png\
\ file in the assets/ directory\n - Or update the logo path in assets/config.yml\n\
\ - Supported formats: PNG, SVG, JPG\n\n4. Optional: Customize the theme:\n\
\ - Uncomment and modify the colors section in config.yml\n - Available themes:\
\ default, sui\n - See documentation for advanced theming options\n\n5. Access\
\ your dashboard:\n {% if traefik_enabled -%}\n - Via Traefik: https://{{\
\ traefik_host }}\n {% if not traefik_enabled and network_mode == 'bridge' %}-\
\ Direct access: http://localhost:{{ ports_http }}{% endif %}\n {%- else -%}\n\
\ - Open http://localhost:{{ ports_http }} in your browser\n {%- endif %}\n\
\nFor more information, visit: https://github.com/bastienwirtz/homer/blob/main/docs/configuration.md\n"
schema: '1.2'
spec:
general:
vars:
service_name:
default: homer
container_name:
default: homer
container_timezone:
type: str
restart_policy:
type: enum
options:
- unless-stopped
- always
- on-failure
- 'no'
default: unless-stopped
required: true
homer_title:
description: Dashboard title
type: str
default: My Dashboard
homer_subtitle:
description: Dashboard subtitle
type: str
default: Homer
homer_logo:
description: Logo file path (relative to assets/)
type: str
default: logo.png
ports:
vars:
ports_http:
description: Host port for HTTP (8080)
type: int
default: 8080
traefik:
toggle: traefik_enabled
vars:
traefik_enabled:
type: bool
default: false
description: Enable Traefik integration
traefik_entrypoint:
description: The Traefik entrypoint
type: str
required: true
default: web
traefik_host:
default: homepage
traefik_network:
default: traefik
type: str
required: true
traefik_domain:
default: home.arpa
type: str
required: true
traefik_tls:
toggle: traefik_tls_enabled
vars:
traefik_tls_enabled:
description: Enable Traefik TLS
type: bool
default: false
traefik_tls_entrypoint:
description: The Traefik TLS entrypoint
type: str
default: websecure
required: true
traefik_tls_certresolver:
type: str
required: true
default: cloudflare
swarm:
vars:
swarm_placement_mode:
type: enum
options:
- replicated
- global
default: replicated
required: true
swarm_replicas:
type: int
default: 1
needs:
- swarm_placement_mode=replicated
required: true
swarm_placement_host:
type: str
default: ''
needs:
- swarm_placement_mode=replicated
extra: Constrains service to run on specific node by hostname
swarm_enabled:
type: bool
default: false
description: Enable Docker Swarm mode
toggle: swarm_enabled
title: Docker Swarm
description: Configure Docker Swarm mode deployment
authentik:
vars:
authentik_traefik_middleware:
type: str
default: authentik-middleware@file
needs:
- traefik_enabled=true
required: true
authentik_client_id:
type: str
default: ''
description: The Authentik client ID
authentik_client_secret:
type: str
default: ''
description: The Authentik client secret
authentik_enabled:
type: bool
default: false
description: Enable Authentik SSO integration
authentik_slug:
type: str
default: ''
description: The Authentik application slug
authentik_url:
type: str
default: ''
description: The Authentik URL
toggle: authentik_enabled
title: Authentik SSO
description: Configure Authentik authentication integration
================================================
FILE: library/compose/influxdb/compose.yaml.j2
================================================
---
services:
{{ service_name }}:
image: docker.io/library/influxdb:2.8.0-alpine
restart: {{ restart_policy }}
environment:
- TZ={{ container_timezone }}
- DOCKER_INFLUXDB_INIT_MODE=setup
- DOCKER_INFLUXDB_INIT_USERNAME={{ influxdb_init_username }}
- DOCKER_INFLUXDB_INIT_PASSWORD=${INFLUXDB_INIT_PASSWORD}
{% if traefik_enabled %}
networks:
{{ traefik_network }}:
{% endif %}
{% if not traefik_enabled %}
ports:
- "{{ ports_http }}:8086"
{% endif %}
volumes:
- {{ service_name }}_data:/var/lib/influxdb2
{% if traefik_enabled %}
labels:
- traefik.enable=true
- traefik.docker.network={{ traefik_network }}
- traefik.http.services.{{ service_name }}-web.loadbalancer.server.port=8086
- traefik.http.services.{{ service_name }}-web.loadbalancer.server.scheme=http
- traefik.http.routers.{{ service_name }}-web-http.rule=Host(`{{ traefik_host }}.{{ traefik_domain }}`)
- traefik.http.routers.{{ service_name }}-web-http.entrypoints=web
- traefik.http.routers.{{ service_name }}-web-http.service={{ service_name }}-web
{% if traefik_tls_enabled %}
- traefik.http.routers.{{ service_name }}-web-https.rule=Host(`{{ traefik_host }}.{{ traefik_domain }}`)
- traefik.http.routers.{{ service_name }}-web-https.entrypoints=websecure
- traefik.http.routers.{{ service_name }}-web-https.tls=true
- traefik.http.routers.{{ service_name }}-web-https.tls.certresolver={{ traefik_tls_certresolver }}
- traefik.http.routers.{{ service_name }}-web-https.service={{ service_name }}-web
{% endif %}
{% endif %}
volumes:
{{ service_name }}_data:
driver: local
{% if traefik_enabled %}
networks:
{{ traefik_network }}:
external: true
{% endif %}
================================================
FILE: library/compose/influxdb/template.yaml
================================================
kind: compose
metadata:
icon:
provider: selfh
id: influxdb
name: Influxdb
description: 'InfluxDB is a powerful, open-source time series database designed
for high-performance handling of time-stamped data.
It is commonly used for monitoring, analytics, and IoT applications.
## References
* **Project:** https://www.influxdata.com/
* **Documentation:** https://docs.influxdata.com/influxdb/
* **GitHub:** https://github.com/influxdata/influxdb'
next_steps: 'Log in with your initial admin user:
```bash
Username: {{ influxdb_init_username }}
Password: {{ influxdb_init_password }}
```'
version: 2.8.0-alpine
author: Christian Lempa
date: '2025-12-11'
tags:
- traefik
draft: true
schema: '1.2'
spec:
ports:
vars:
ports_http:
description: Host port for HTTP API (8086)
type: int
default: 8086
influxdb:
description: InfluxDB initialization settings
required: true
vars:
influxdb_init_username:
description: Initial admin username
type: str
default: admin
required: true
influxdb_init_password:
description: Initial admin password
type: str
sensitive: true
autogenerated: true
required: true
traefik:
vars:
traefik_host:
default: influxdb
traefik_network:
default: traefik
type: str
required: true
traefik_domain:
default: home.arpa
type: str
required: true
traefik_enabled:
type: bool
default: false
description: Enable Traefik integration
toggle: traefik_enabled
title: Traefik
description: Configure Traefik reverse proxy integration
traefik_tls:
vars:
traefik_tls_certresolver:
type: str
default: cloudflare
required: true
traefik_tls_enabled:
type: bool
default: false
description: Enable Traefik TLS
toggle: traefik_tls_enabled
title: Traefik TLS
description: Configure Traefik TLS/SSL certificates
needs: traefik
general:
vars:
service_name:
default: influxdb
container_timezone:
type: str
restart_policy:
type: enum
options:
- unless-stopped
- always
- on-failure
- 'no'
default: unless-stopped
required: true
influxdb_version:
type: str
description: Influxdb version
default: latest
================================================
FILE: library/compose/komodo/compose.yaml.j2
================================================
---
services:
{{ service_name }}:
image: ghcr.io/moghtech/komodo:latest
{% if not swarm_enabled %}
restart: {{ restart_policy }}
container_name: {{ container_name }}
{% endif %}
hostname: {{ container_hostname }}
{% 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 == 'bridge' and not traefik_enabled %}
ports:
{% if swarm_enabled %}
- target: 9120
published: {{ ports_http }}
protocol: tcp
mode: host
{% else %}
- "{{ ports_http }}:9120/tcp"
{% endif %}
{% endif %}
{% if environment_enabled %}
environment:
KOMODO_DATABASE_ADDRESS: "{{ environment_database_address }}"
KOMODO_DATABASE_DB_NAME: "{{ environment_database_name }}"
{% if environment_database_username %}
KOMODO_DATABASE_USERNAME: "{{ environment_database_username }}"
{% endif %}
{% if environment_database_password %}
KOMODO_DATABASE_PASSWORD: "{{ environment_database_password }}"
{% endif %}
{% if environment_jwt_secret %}
KOMODO_JWT_SECRET: "{{ environment_jwt_secret }}"
{% endif %}
LOG_LEVEL: "{{ environment_log_level }}"
{% endif %}
volumes:
{% if volume_mode == 'mount' %}
- {{ volume_mount_path }}/data:/app/data:rw
- {{ volume_mount_path }}/repos:/app/repos:rw
{% elif volume_mode in ['local', 'nfs'] %}
- {{ service_name }}-data:/app/data
- {{ service_name }}-repos:/app/repos
{% endif %}
{% if swarm_enabled or resources_enabled %}
deploy:
{% if swarm_enabled %}
mode: replicated
replicas: 1
restart_policy:
condition: on-failure
{% endif %}
{% if resources_enabled %}
resources:
limits:
cpus: '{{ resources_cpu_limit }}'
memory: {{ resources_memory_limit }}
{% if swarm_enabled %}
reservations:
cpus: '{{ resources_cpu_reservation }}'
memory: {{ resources_memory_reservation }}
{% endif %}
{% endif %}
{% if swarm_enabled and traefik_enabled %}
labels:
- traefik.enable=true
- traefik.docker.network={{ traefik_network }}
- traefik.http.services.{{ service_name }}-web.loadBalancer.server.port=9120
- traefik.http.routers.{{ service_name }}-http.service={{ service_name }}-web
- traefik.http.routers.{{ service_name }}-http.rule=Host(`{{ traefik_host }}.{{ traefik_domain }}`)
- 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_domain }}`)
- 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 %}
{% endif %}
{% if traefik_enabled and not swarm_enabled %}
labels:
- traefik.enable=true
- traefik.docker.network={{ traefik_network }}
- traefik.http.services.{{ service_name }}-web.loadBalancer.server.port=9120
- traefik.http.routers.{{ service_name }}-http.service={{ service_name }}-web
- traefik.http.routers.{{ service_name }}-http.rule=Host(`{{ traefik_host }}.{{ traefik_domain }}`)
- 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_domain }}`)
- 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 %}
{% if volume_mode == 'local' %}
volumes:
{{ service_name }}-data:
driver: local
{{ service_name }}-repos:
driver: local
{% elif volume_mode == 'nfs' %}
volumes:
{{ service_name }}-data:
driver: local
driver_opts:
type: nfs
o: addr={{ volume_nfs_server }},{{ volume_nfs_options }}
device: ":{{ volume_nfs_path }}/data"
{{ service_name }}-repos:
driver: local
driver_opts:
type: nfs
o: addr={{ volume_nfs_server }},{{ volume_nfs_options }}
device: ":{{ volume_nfs_path }}/repos"
{% endif %}
{% if network_mode != 'host' %}
networks:
{{ network_name }}:
{% if network_external %}
external: true
{% else %}
{% if network_mode == 'macvlan' %}
driver: macvlan
driver_opts:
parent: {{ network_macvlan_parent_interface }}
ipam:
config:
- subnet: {{ network_macvlan_subnet }}
gateway: {{ network_macvlan_gateway }}
name: {{ network_name }}
{% elif swarm_enabled %}
driver: overlay
attachable: true
{% else %}
driver: bridge
{% endif %}
{% endif %}
{% if traefik_enabled %}
{{ traefik_network }}:
external: true
{% endif %}
{% endif %}
================================================
FILE: library/compose/komodo/template.yaml
================================================
kind: compose
metadata:
icon:
provider: selfh
id: komodo
name: Komodo
description: 'Build and deployment automation tool for managing software across
multiple servers. Komodo provides
unlimited server connections, flexible API access, and comprehensive management
of Docker deployments,
stacks, and builds. Features include real-time container monitoring, batch operations,
and integration
with Docker, Docker Compose, and build systems. Supports both MongoDB and FerretDB
as database backends.
## Important Notes
* Requires MongoDB or FerretDB for data storage (database not included in this
template)
* Requires Periphery agent on managed servers for remote operations
* Web interface and API accessible through configured ports
## References
* **Project:** https://github.com/moghtech/komodo
* **Documentation:** https://github.com/moghtech/komodo/tree/main/docsite/docs
* **Docker Hub:** https://hub.docker.com/r/moghtech/komodo
'
draft: true
version: latest
author: Christian Lempa
date: '2025-11-13'
tags:
- traefik
- swarm
- deployment
- automation
next_steps: '### 1. Prerequisites
* Deploy MongoDB or FerretDB database
* Configure database connection in environment variables
* Install Periphery agent on servers you want to manage
### 2. Deploy the Service
{% if swarm_enabled -%}
Deploy to Docker Swarm:
```bash
docker stack deploy -c compose.yaml komodo
```
{% else -%}
Start Komodo using Docker Compose:
```bash
docker compose up -d
```
{% endif -%}
### 3. Access the Web Interface
{% if traefik_enabled -%}
* Navigate to: **https://{{ traefik_host }}.{{ traefik_domain }}**
{% else -%}
* Navigate to: **http://localhost:{{ ports_http }}**
{% endif -%}
* Complete initial setup and create admin user
### 4. Install Periphery Agent
On each server you want to manage:
```bash
curl -sSL https://raw.githubusercontent.com/moghtech/komodo/main/scripts/setup-periphery.py
| python3
```
### 5. Configure Servers
* Add servers to Komodo through the web interface
* Configure API keys for programmatic access
* Start managing deployments, stacks, and builds
'
schema: '1.2'
spec:
general:
vars:
service_name:
default: komodo
container_name:
default: komodo
container_hostname:
default: komodo
restart_policy:
type: enum
options:
- unless-stopped
- always
- on-failure
- 'no'
default: unless-stopped
required: true
traefik:
vars:
traefik_enabled:
type: bool
default: false
description: Enable Traefik integration
traefik_entrypoint:
description: The Traefik entrypoint
type: str
required: true
default: web
traefik_host:
default: homepage
traefik_network:
default: traefik
type: str
required: true
traefik_domain:
default: home.arpa
type: str
required: true
toggle: traefik_enabled
title: Traefik
description: Configure Traefik reverse proxy integration
traefik_tls:
vars:
traefik_tls_enabled:
description: Enable Traefik TLS
type: bool
default: false
traefik_tls_entrypoint:
description: The Traefik TLS entrypoint
type: str
default: websecure
required: true
traefik_tls_certresolver:
type: str
required: true
default: cloudflare
toggle: traefik_tls_enabled
title: Traefik TLS
description: Configure Traefik TLS/SSL certificates
needs: traefik
network: null
ports:
vars:
ports_http:
description: External HTTP port (web interface and API)
type: int
default: 9120
needs:
- traefik_enabled=false
- network_mode=bridge
volume: null
resources:
vars:
resources_enabled:
type: bool
default: false
resources_cpu_limit:
type: str
default: 1.0
required: true
resources_cpu_reservation:
type: str
default: 0.25
needs:
- swarm_enabled=true
required: true
resources_memory_limit:
type: str
default: 1G
required: true
resources_memory_reservation:
type: str
default: 512M
needs:
- swarm_enabled=true
required: true
toggle: resources_enabled
title: Resource Limits
description: Configure container resource limits
environment:
title: Environment Variables
toggle: environment_enabled
required: true
vars:
environment_enabled:
type: bool
default: true
description: Configure environment variables (required)
environment_database_address:
type: str
default: mongodb://mongo:27017
description: Database connection address (MongoDB or FerretDB)
needs: environment_enabled=true
environment_database_name:
type: str
default: komodo
description: Database name
needs: environment_enabled=true
environment_database_username:
type: str
default: ''
description: Database username (optional)
needs: environment_enabled=true
environment_database_password:
type: str
default: ''
sensitive: true
description: Database password (optional)
needs: environment_enabled=true
environment_jwt_secret:
type: str
default: ''
sensitive: true
autogenerated: true
description: JWT secret for authentication (auto-generated if empty)
needs: environment_enabled=true
environment_log_level:
type: enum
default: info
options:
- debug
- info
- warn
- error
description: Log level
needs: environment_enabled=true
swarm:
vars:
swarm_enabled:
type: bool
default: false
description: Enable Docker Swarm mode
swarm_placement_host:
type: str
default: ''
description: The placement host
swarm_placement_mode:
type: str
default: replicated
description: The placement mode
swarm_replicas:
type: int
default: 1
description: The number of replicas
toggle: swarm_enabled
title: Docker Swarm
description: Configure Docker Swarm mode deployment
================================================
FILE: library/compose/loki/compose.yaml.j2
================================================
---
services:
{{ service_name }}:
image: docker.io/grafana/loki:3.6.5
restart: {{ restart_policy }}
command: "-config.file=/etc/loki/config.yaml"
{% if not traefik_enabled %}
ports:
- "{{ ports_http }}:3100"
{% endif %}
volumes:
- {{ service_name }}_data:/loki:rw
- ./config/config.yaml:/etc/loki/config.yaml:ro
{% if traefik_enabled %}
networks:
{{ traefik_network }}:
{% endif %}
{% if traefik_enabled %}
labels:
- traefik.enable=true
- traefik.docker.network={{ traefik_network }}
- traefik.http.services.{{ service_name }}_web.loadBalancer.server.port=3100
- traefik.http.routers.{{ service_name }}_http.service={{ service_name }}_web
- traefik.http.routers.{{ service_name }}_http.rule=Host(`{{ traefik_host }}.{{ traefik_domain }}`)
- traefik.http.routers.{{ service_name }}_http.entrypoints=web
{% 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_domain }}`)
- traefik.http.routers.{{ service_name }}_https.entrypoints=websecure
- traefik.http.routers.{{ service_name }}_https.tls=true
- traefik.http.routers.{{ service_name }}_https.tls.certresolver={{ traefik_tls_certresolver }}
{% endif %}
{% endif %}
volumes:
{{ service_name }}_data:
driver: local
{% if traefik_enabled %}
networks:
{{ traefik_network }}:
external: true
{% endif %}
================================================
FILE: library/compose/loki/config/config.yaml.j2
================================================
---
auth_enabled: false
server:
http_listen_port: 3100
common:
instance_addr: 127.0.0.1
path_prefix: /loki
storage:
filesystem:
chunks_directory: /loki/chunks
rules_directory: /loki/rules
replication_factor: 1
ring:
kvstore:
store: inmemory
schema_config:
configs:
- from: 2020-10-24
store: tsdb
object_store: filesystem
schema: v13
index:
prefix: index_
period: 24h
limits_config:
retention_period: "{{ data_retention_days }}d"
ingestion_rate_mb: 4
ingestion_burst_size_mb: 6
max_streams_per_user: 10000
max_line_size: 256000
ruler:
alertmanager_url: http://localhost:9093
================================================
FILE: library/compose/loki/template.yaml
================================================
kind: compose
metadata:
icon:
provider: selfh
id: loki
name: Loki
description: 'Loki is a horizontally scalable, highly available, multi-tenant log
aggregation system inspired by Prometheus.
This template sets up Loki in a Docker container using Docker Compose.
## References
* **Project:** https://grafana.com/oss/loki/
* **Documentation:** https://grafana.com/docs/loki/latest/
* **GitHub:** https://github.com/grafana/loki'
version: 3.6.5
author: Christian Lempa
date: '2026-02-06'
tags:
- traefik
- authentik
schema: '1.2'
spec:
general:
vars:
service_name:
default: loki
restart_policy:
type: enum
options:
- unless-stopped
- always
- on-failure
- 'no'
default: unless-stopped
required: true
data_retention_days:
description: Number of days to retain logs
type: int
default: 7
ports:
vars:
ports_http:
default: 3100
traefik:
vars:
traefik_host:
default: loki
traefik_network:
default: traefik
type: str
required: true
traefik_domain:
default: home.arpa
type: str
required: true
traefik_enabled:
type: bool
default: false
description: Enable Traefik integration
toggle: traefik_enabled
title: Traefik
description: Configure Traefik reverse proxy integration
traefik_tls:
vars:
traefik_tls_certresolver:
type: str
default: cloudflare
required: true
traefik_tls_enabled:
type: bool
default: false
description: Enable Traefik TLS
toggle: traefik_tls_enabled
title: Traefik TLS
description: Configure Traefik TLS/SSL certificates
needs: traefik
================================================
FILE: library/compose/mariadb/compose.yaml.j2
================================================
---
services:
{{ service_name }}:
image: docker.io/library/mariadb:12.2.2
restart: {{ restart_policy }}
environment:
- MARIADB_ROOT_PASSWORD=${MARIADB_ROOT_PASSWORD}
- MARIADB_DATABASE={{ database_name }}
- MARIADB_USER={{ database_user }}
- MARIADB_PASSWORD=${MARIADB_ROOT_PASSWORD}
ports:
- "{{ ports_mariadb }}:3306"
volumes:
- {{ service_name }}_data:/var/lib/mysql
volumes:
{{ service_name }}_data:
driver: local
================================================
FILE: library/compose/mariadb/template.yaml
================================================
---
kind: compose
metadata:
icon:
provider: selfh
id: mariadb
name: MariaDB
description: |-
MariaDB is a community-developed, commercially supported fork of the MySQL relational database management system.
It's designed to remain free and open-source software under the GNU General Public License.
## References
* **Project:** https://mariadb.org/
* **Documentation:** https://mariadb.com/kb/en/documentation/
* **GitHub:** https://github.com/MariaDB/server
next_steps: |-
Log in with your initial admin user:
```bash
Username: `root` or `{{ database_user }}`
Password: {{ database_password }}
```
version: 12.2.2
author: Christian Lempa
date: '2026-02-16'
tags: []
draft: true
schema: "1.2"
spec:
general:
vars:
service_name:
default: mariadb
restart_policy:
type: enum
options: [unless-stopped, always, on-failure, "no"]
default: unless-stopped
required: true
ports:
vars:
ports_mariadb:
description: "Host port for MariaDB"
type: int
default: 3306
required: true
database:
vars:
database_name:
type: str
required: true
database_user:
type: str
required: true
database_password:
type: str
sensitive: true
required: true
================================================
FILE: library/compose/n8n/compose.yaml.j2
================================================
{% if queue_enabled and not queue_redis_external -%}
services:
redis:
image: redis:8-alpine
{% if not swarm_enabled -%}
container_name: {{ service_name }}-redis
{% endif -%}
volumes:
- redis_data:/data
{% if network_mode == 'bridge' -%}
networks:
- {{ network_name }}
{% else -%}
network_mode: {{ network_mode }}
{% endif -%}
{% if not swarm_enabled -%}
restart: {{ restart_policy }}
{% endif -%}
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
{% if swarm_enabled -%}
deploy:
replicas: 1
placement:
constraints:
- node.role == manager
{% endif -%}
{% endif -%}
{{ service_name }}:
image: n8nio/n8n:2.8.3
{% if not swarm_enabled -%}
container_name: {{ container_name }}
hostname: {{ container_hostname }}
{% endif -%}
environment:
- N8N_LOG_LEVEL={{ container_loglevel }}
- GENERIC_TIMEZONE={{ container_timezone }}
- TZ={{ container_timezone }}
- N8N_ENFORCE_SETTINGS_FILE_PERMISSIONS=true
- N8N_RUNNERS_ENABLED=true
{% if traefik_enabled -%}
- N8N_HOST={{ traefik_host }}
{% if traefik_tls_enabled -%}
- N8N_PROTOCOL=https
- N8N_EDITOR_BASE_URL=https://{{ traefik_host }}
{% else -%}
- N8N_PROTOCOL=http
- N8N_EDITOR_BASE_URL=http://{{ traefik_host }}
{% endif -%}
{% endif -%}
- NODE_ENV=production
{% if database_enabled -%}
{% if database_type == 'postgres' -%}
- DB_TYPE=postgresdb
- DB_POSTGRESDB_HOST={{ database_host }}
- DB_POSTGRESDB_PORT={{ database_port }}
- DB_POSTGRESDB_DATABASE={{ database_name }}
- DB_POSTGRESDB_USER={{ database_user }}
{% if swarm_enabled -%}
- DB_POSTGRESDB_PASSWORD_FILE=/run/secrets/{{ database_password_secret_name }}
{% else -%}
- DB_POSTGRESDB_PASSWORD={{ database_password }}
{% endif -%}
{% elif database_type == 'mysql' -%}
- DB_TYPE=mysqldb
- DB_MYSQLDB_HOST={{ database_host }}
- DB_MYSQLDB_PORT={{ database_port }}
- DB_MYSQLDB_DATABASE={{ database_name }}
- DB_MYSQLDB_USER={{ database_user }}
{% if swarm_enabled -%}
- DB_MYSQLDB_PASSWORD_FILE=/run/secrets/{{ database_password_secret_name }}
{% else -%}
- DB_MYSQLDB_PASSWORD={{ database_password }}
{% endif -%}
{% endif -%}
{% endif -%}
{% if swarm_enabled -%}
- N8N_ENCRYPTION_KEY_FILE=/run/secrets/{{ encryption_key_secret_name }}
{% else -%}
- N8N_ENCRYPTION_KEY={{ encryption_key }}
{% endif -%}
{% if webhook_url -%}
- WEBHOOK_URL={{ webhook_url }}
{% endif -%}
- N8N_PROXY_HOPS={{ proxy_hops }}
{% if metrics_enabled -%}
- N8N_METRICS=true
{% if metrics_detailed -%}
- N8N_METRICS_INCLUDE_WORKFLOW_ID_LABELS=true
- N8N_METRICS_INCLUDE_NODE_TYPE_LABEL=true
- N8N_METRICS_INCLUDE_API_ENDPOINTS=true
- N8N_METRICS_INCLUDE_API_STATUS_CODE_LABELS=true
- N8N_METRICS_INCLUDE_CREDENTIAL_TYPE_LABEL=true
{% endif -%}
{% endif -%}
- EXECUTIONS_DATA_SAVE_ON_ERROR={{ execution_save_on_error }}
- EXECUTIONS_DATA_SAVE_ON_SUCCESS={{ execution_save_on_success }}
{% if queue_enabled -%}
- EXECUTIONS_MODE=queue
- QUEUE_BULL_REDIS_HOST={{ queue_redis_host }}
- QUEUE_BULL_REDIS_PORT={{ queue_redis_port }}
- QUEUE_HEALTH_CHECK_ACTIVE=true
{% if metrics_enabled -%}
- N8N_METRICS_INCLUDE_QUEUE_METRICS=true
{% endif -%}
{% endif -%}
volumes:
- /etc/localtime:/etc/localtime:ro
- data:/home/node/.n8n
{% if network_mode == 'bridge' -%}
networks:
{% if traefik_enabled -%}
- {{ traefik_network }}
{% endif -%}
- {{ network_name }}
{% else -%}
network_mode: {{ network_mode }}
{% endif -%}
{% if queue_enabled and not queue_redis_external -%}
depends_on:
redis:
condition: service_healthy
{% endif -%}
{% if traefik_enabled -%}
labels:
- traefik.enable=true
{% if network_mode == 'bridge' -%}
- traefik.docker.network={{ traefik_network }}
{% endif -%}
{% if traefik_webhook_host -%}
- traefik.http.routers.{{ service_name }}.rule=Host(`{{ traefik_host }}.{{ traefik_domain }}`) || Host(`{{ traefik_webhook_host }}`)
{% else -%}
- traefik.http.routers.{{ service_name }}.rule=Host(`{{ traefik_host }}.{{ traefik_domain }}`)
{% endif -%}
{% if traefik_tls_enabled -%}
- traefik.http.routers.{{ service_name }}.entrypoints={{ traefik_tls_entrypoint }}
- traefik.http.routers.{{ service_name }}.tls=true
- traefik.http.routers.{{ service_name }}.tls.certresolver={{ traefik_tls_certresolver }}
{% else -%}
- traefik.http.routers.{{ service_name }}.entrypoints={{ traefik_entrypoint }}
{% endif -%}
- traefik.http.services.{{ service_name }}-web.loadbalancer.server.port=5678
- traefik.http.routers.{{ service_name }}.service={{ service_name }}-web
{% endif -%}
{% if not swarm_enabled -%}
restart: {{ restart_policy }}
{% endif -%}
{% if swarm_enabled -%}
deploy:
replicas: {{ swarm_replicas }}
{% if swarm_placement_host -%}
placement:
constraints:
- node.hostname == {{ swarm_placement_host }}
{% endif -%}
{% if traefik_enabled -%}
labels:
- traefik.enable=true
- traefik.docker.network={{ traefik_network }}
{% if traefik_webhook_host -%}
- traefik.http.routers.{{ service_name }}.rule=Host(`{{ traefik_host }}.{{ traefik_domain }}`) || Host(`{{ traefik_webhook_host }}`)
{% else -%}
- traefik.http.routers.{{ service_name }}.rule=Host(`{{ traefik_host }}.{{ traefik_domain }}`)
{% endif -%}
{% if traefik_tls_enabled -%}
- traefik.http.routers.{{ service_name }}.entrypoints={{ traefik_tls_entrypoint }}
- traefik.http.routers.{{ service_name }}.tls=true
- traefik.http.routers.{{ service_name }}.tls.certresolver={{ traefik_tls_certresolver }}
{% else -%}
- traefik.http.routers.{{ service_name }}.entrypoints={{ traefik_entrypoint }}
{% endif -%}
- traefik.http.services.{{ service_name }}-web.loadbalancer.server.port=5678
- traefik.http.routers.{{ service_name }}.service={{ service_name }}-web
{% endif -%}
secrets:
- {{ encryption_key_secret_name }}
{% if database_enabled -%}
- {{ database_password_secret_name }}
{% endif -%}
{% endif -%}
{% if queue_enabled and queue_embedded_worker -%}
{{ service_name }}-worker:
image: n8nio/n8n:2.8.3
command: worker
{% if not swarm_enabled -%}
container_name: {{ container_name }}-worker
{% endif -%}
environment:
- N8N_LOG_LEVEL={{ container_loglevel }}
- GENERIC_TIMEZONE={{ container_timezone }}
- TZ={{ container_timezone }}
{% if database_enabled -%}
{% if database_type == 'postgres' -%}
- DB_TYPE=postgresdb
- DB_POSTGRESDB_HOST={{ database_host }}
- DB_POSTGRESDB_PORT={{ database_port }}
- DB_POSTGRESDB_DATABASE={{ database_name }}
- DB_POSTGRESDB_USER={{ database_user }}
{% if swarm_enabled -%}
- DB_POSTGRESDB_PASSWORD_FILE=/run/secrets/{{ database_password_secret_name }}
{% else -%}
- DB_POSTGRESDB_PASSWORD={{ database_password }}
{% endif -%}
{% elif database_type == 'mysql' -%}
- DB_TYPE=mysqldb
- DB_MYSQLDB_HOST={{ database_host }}
- DB_MYSQLDB_PORT={{ database_port }}
- DB_MYSQLDB_DATABASE={{ database_name }}
- DB_MYSQLDB_USER={{ database_user }}
{% if swarm_enabled -%}
- DB_MYSQLDB_PASSWORD_FILE=/run/secrets/{{ database_password_secret_name }}
{% else -%}
- DB_MYSQLDB_PASSWORD={{ database_password }}
{% endif -%}
{% endif -%}
{% endif -%}
{% if swarm_enabled -%}
- N8N_ENCRYPTION_KEY_FILE=/run/secrets/{{ encryption_key_secret_name }}
{% else -%}
- N8N_ENCRYPTION_KEY={{ encryption_key }}
{% endif -%}
- EXECUTIONS_MODE=queue
- QUEUE_BULL_REDIS_HOST={{ queue_redis_host }}
- QUEUE_BULL_REDIS_PORT={{ queue_redis_port }}
- QUEUE_HEALTH_CHECK_ACTIVE=true
{% if metrics_enabled -%}
- N8N_METRICS=true
{% if metrics_detailed -%}
- N8N_METRICS_INCLUDE_WORKFLOW_ID_LABELS=true
- N8N_METRICS_INCLUDE_NODE_TYPE_LABEL=true
{% endif -%}
{% endif -%}
volumes:
- /etc/localtime:/etc/localtime:ro
- data:/home/node/.n8n
{% if network_mode == 'bridge' -%}
networks:
- {{ network_name }}
{% else -%}
network_mode: {{ network_mode }}
{% endif -%}
{% if not queue_redis_external -%}
depends_on:
redis:
condition: service_healthy
{% endif -%}
{% if not swarm_enabled -%}
restart: {{ restart_policy }}
{% endif -%}
{% if swarm_enabled -%}
deploy:
replicas: 1
{% if swarm_placement_host -%}
placement:
constraints:
- node.hostname == {{ swarm_placement_host }}
{% endif -%}
secrets:
- {{ encryption_key_secret_name }}
{% if database_enabled -%}
- {{ database_password_secret_name }}
{% endif -%}
{% endif -%}
{% endif -%}
volumes:
data:
driver: local
{% if queue_enabled and not queue_redis_external -%}
redis_data:
driver: local
{% endif -%}
{% if network_mode == 'bridge' -%}
networks:
{{ network_name }}:
{% if network_external -%}
external: true
{% else -%}
driver: bridge
{% endif -%}
{% if traefik_enabled -%}
{{ traefik_network }}:
{% if traefik_network_external -%}
external: true
{% else -%}
driver: bridge
{% endif -%}
{% endif -%}
{% endif -%}
{% if swarm_enabled -%}
secrets:
{{ encryption_key_secret_name }}:
external: true
{% if database_enabled -%}
{{ database_password_secret_name }}:
external: true
{% endif -%}
{% endif -%}
================================================
FILE: library/compose/n8n/template.yaml
================================================
kind: compose
metadata:
icon:
provider: selfh
id: n8n
name: N8N
description: 'N8n is a free and source-available workflow automation tool. It enables
you to connect
various apps and services to automate repetitive tasks without coding.
With its user-friendly interface, you can create complex workflows by simply dragging
and dropping nodes that represent different actions and triggers.
## Prerequisites
- :info: By default, n8n uses SQLite as its database, which is suitable for small-scale
or
personal use. For production environments, it is recommended to use an external
database like PostgreSQL or MySQL for better performance and reliability. This
is also
required when using Queue Mode.
- :info: Queue mode allows n8n to handle a large number of workflows and tasks
efficiently, by using
multiple Workers, and Redis as the queue backend. This is essential for high-availability
setups where multiple n8n instances work together. It also supports Queue Monitoring
(when Prometheus metrics are enabled).
## Resources
- **Project**: https://n8n.io/
- **Documentation**: https://docs.n8n.io/
- **GitHub**: https://github.com/n8n-io/n8n
'
version: 8-alpine
author: Christian Lempa
date: '2025-12-17'
tags:
- traefik
- database
draft: true
schema: '1.2'
spec:
general:
vars:
service_name:
default: n8n
container_name:
default: n8n-server
container_hostname:
default: n8n-server
container_timezone:
type: str
container_loglevel:
type: enum
options:
- debug
- info
- warn
- error
restart_policy:
type: enum
options:
- unless-stopped
- always
- on-failure
- 'no'
default: unless-stopped
required: true
queue:
title: Queue Mode
description: Enable queue mode with Redis for scaled deployments
toggle: queue_enabled
needs: database_enabled=true
vars:
queue_enabled:
type: bool
description: Enable queue mode (requires Redis)
default: false
extra: Required for multiple workers and scaled deployments
queue_redis_external:
type: bool
description: Use external Redis instance
default: false
needs: queue_enabled
queue_redis_host:
type: str
description: Redis host
default: redis
needs: queue_enabled
queue_redis_port:
type: int
description: Redis port
default: 6379
needs: queue_enabled
queue_embedded_worker:
type: bool
description: Include embedded worker in this deployment
default: false
needs: queue_enabled
extra: Add a worker service to this compose file. For production, use separate
n8n-worker template.
database:
title: Database
toggle: database_enabled
description: External database configuration
vars:
database_enabled:
type: bool
description: Use external database
default: false
database_type:
type: enum
description: Database type
options:
- postgres
- mysql
default: postgres
needs: database_enabled
database_host:
type: str
description: Database host
default: postgres
needs: database_enabled
database_port:
type: int
description: Database port
default: 5432
needs: database_enabled
database_name:
type: str
description: Database name
default: n8n
needs: database_enabled
database_user:
type: str
description: Database username
default: n8n
needs: database_enabled
database_password:
type: str
description: Database password
sensitive: true
needs: database_enabled
database_external:
type: bool
default: false
description: Use external database
security:
title: Security
vars:
encryption_key:
type: str
description: N8N encryption key for credentials
sensitive: true
autogenerated: true
extra: Keep this secure! Used to encrypt stored credentials.
proxy_hops:
type: int
description: Number of proxy hops (X-Forwarded-For)
default: 1
extra: Set to 2 if behind multiple proxies (e.g., Cloudflare + Traefik)
webhooks:
title: Webhooks
description: Webhook configuration for external triggers
vars:
webhook_url:
type: url
description: Webhook base URL
default: ''
extra: Optional separate webhook URL (e.g., https://webhooks.example.com/)
metrics:
title: Metrics
toggle: metrics_enabled
description: Prometheus metrics configuration
vars:
metrics_enabled:
type: bool
description: Enable Prometheus metrics
default: false
metrics_detailed:
type: bool
description: Include detailed metrics (workflows, nodes, API endpoints)
default: false
needs: metrics_enabled
execution:
title: Execution Settings
vars:
execution_save_on_error:
type: enum
description: Save execution data on error
options:
- all
- none
default: all
execution_save_on_success:
type: enum
description: Save execution data on success
options:
- all
- none
default: none
extra: Set to 'none' to reduce database size
network:
vars:
network_mode:
extra: For queue mode with workers, use 'bridge' with shared networks. Swarm
only supports 'bridge'.
network_name:
default: n8n_network
network_external:
type: bool
default: false
description: Whether the network is external
traefik:
toggle: traefik_enabled
vars:
traefik_enabled:
needs: network_mode=bridge
traefik_host:
default: n8n.home.arpa
traefik_webhook_host:
type: hostname
description: Separate hostname for webhooks (optional)
default: ''
needs: traefik_enabled
extra: Leave empty to use same host for webhooks
traefik_network:
type: str
description: Traefik network name
default: traefik
needs: traefik_enabled
traefik_network_external:
type: bool
description: Use external Traefik network
default: true
needs: traefik_enabled
traefik_domain:
type: str
default: home.arpa
required: true
ports:
vars:
ports_http:
description: External HTTP port
type: int
default: 5678
needs:
- traefik_enabled=false
- network_mode=bridge
swarm:
toggle: swarm_enabled
vars:
swarm_enabled:
needs: network_mode=bridge
swarm_replicas:
type: int
description: Number of server replicas
default: 1
needs: swarm_enabled
extra: For HA, set > 1 (requires queue mode)
swarm_placement_host:
type: str
description: Target hostname for placement constraint
default: ''
extra: Constrains service to run on specific node by hostname
swarm_placement_mode:
type: str
default: replicated
description: The placement mode
traefik_tls:
vars:
traefik_tls_certresolver:
type: str
default: cloudflare
required: true
traefik_tls_enabled:
type: bool
default: false
description: Enable Traefik TLS
toggle: traefik_tls_enabled
title: Traefik TLS
description: Configure Traefik TLS/SSL certificates
needs: traefik
================================================
FILE: library/compose/netbox/compose.yaml.j2
================================================
---
services:
{{ service_name }}:
image: docker.io/netboxcommunity/netbox:v4.5.2
restart: {{ restart_policy }}
environment:
{% if container_timezone %}
- TIME_ZONE={{ container_timezone }}
{% endif %}
{% if traefik_enabled %}
- ALLOWED_HOSTS={{ traefik_host }}.{{ traefik_domain }}
{% else %}
- ALLOWED_HOSTS=*
{% endif %}
{% if database_external %}
- DB_HOST={{ database_host }}
{% else %}
- DB_HOST={{ service_name }}_postgres
{% endif %}
- DB_NAME={{ database_name }}
- DB_USER={{ database_user }}
- DB_PASSWORD=${DATABASE_PASSWORD}
- REDIS_HOST={{ service_name }}_redis
- REDIS_PASSWORD=${REDIS_PASSWORD}
- REDIS_CACHE_HOST={{ service_name }}_redis-cache
- REDIS_CACHE_PASSWORD=${REDIS_PASSWORD}
- SECRET_KEY=${NETBOX_SECRET_KEY}
{% if netbox_metrics_enabled %}
- METRICS_ENABLED=true
{% endif %}
{% if email_enabled %}
- EMAIL_SERVER={{ email_host }}
- EMAIL_PORT={{ email_port }}
- EMAIL_FROM={{ email_from }}
- EMAIL_USERNAME={{ email_username }}
- EMAIL_PASSWORD=${EMAIL_PASSWORD}
{% if email_encryption == "ssl" %}
- EMAIL_USE_SSL=True
{% elif email_encryption == "starttls" %}
- EMAIL_USE_TLS=True
{% endif %}
{% endif %}
networks:
{% if traefik_enabled %}
{{ traefik_network }}:
{% endif %}
{{ service_name }}_backend:
{% if not traefik_enabled %}
ports:
- "{{ ports_http }}:8080"
{% endif %}
volumes:
- {{ service_name }}_media:/opt/netbox/netbox/media
- {{ service_name }}_reports:/opt/netbox/netbox/reports
- {{ service_name }}_scripts:/opt/netbox/netbox/scripts
{% if traefik_enabled %}
labels:
- traefik.enable=true
- traefik.docker.network={{ traefik_network }}
- traefik.http.services.{{ service_name }}_web.loadBalancer.server.port=8080
- traefik.http.routers.{{ service_name }}_http.service={{ service_name }}_web
- traefik.http.routers.{{ service_name }}_http.rule=Host(`{{ traefik_host }}.{{ traefik_domain }}`)
- traefik.http.routers.{{ service_name }}_http.entrypoints=web
{% 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_domain }}`)
- traefik.http.routers.{{ service_name }}_https.entrypoints=websecure
- traefik.http.routers.{{ service_name }}_https.tls=true
- traefik.http.routers.{{ service_name }}_https.tls.certresolver={{ traefik_tls_certresolver }}
{% endif %}
{% endif %}
depends_on:
{% if not database_external %}
- {{ service_name }}_postgres
{% endif %}
- {{ service_name }}_redis
- {{ service_name }}_redis-cache
{{ service_name }}_worker:
image: docker.io/netboxcommunity/netbox:v4.5.2
restart: {{ restart_policy }}
command:
- /opt/netbox/venv/bin/python
- /opt/netbox/netbox/manage.py
- rqworker
environment:
{% if container_timezone %}
- TIME_ZONE={{ container_timezone }}
{% endif %}
{% if database_external %}
- DB_HOST={{ database_host }}
{% else %}
- DB_HOST={{ service_name }}_postgres
{% endif %}
- DB_NAME={{ database_name }}
- DB_USER={{ database_user }}
- DB_PASSWORD=${DATABASE_PASSWORD}
- REDIS_HOST={{ service_name }}_redis
- REDIS_PASSWORD=${REDIS_PASSWORD}
- REDIS_CACHE_HOST={{ service_name }}_redis-cache
- REDIS_CACHE_PASSWORD=${REDIS_PASSWORD}
- SECRET_KEY=${NETBOX_SECRET_KEY}
{% if netbox_metrics_enabled %}
- METRICS_ENABLED=true
{% endif %}
{% if email_enabled %}
- EMAIL_SERVER={{ email_host }}
- EMAIL_PORT={{ email_port }}
- EMAIL_FROM={{ email_from }}
- EMAIL_USERNAME={{ email_username }}
- EMAIL_PASSWORD=${EMAIL_PASSWORD}
{% if email_encryption == "ssl" %}
- EMAIL_USE_SSL=True
{% elif email_encryption == "starttls" %}
- EMAIL_USE_TLS=True
{% endif %}
{% endif %}
networks:
{{ service_name }}_backend:
volumes:
- {{ service_name }}_media:/opt/netbox/netbox/media
- {{ service_name }}_reports:/opt/netbox/netbox/reports
- {{ service_name }}_scripts:/opt/netbox/netbox/scripts
depends_on:
{% if not database_external %}
- {{ service_name }}_postgres
{% endif %}
- {{ service_name }}
- {{ service_name }}_redis
- {{ service_name }}_redis-cache
{{ service_name }}_redis:
image: docker.io/library/redis:8.6.0-alpine
restart: {{ restart_policy }}
command: ["redis-server", "--requirepass", "${REDIS_PASSWORD}"]
environment:
- REDIS_PASSWORD=${REDIS_PASSWORD}
networks:
{{ service_name }}_backend:
volumes:
- {{ service_name }}_redis:/data
{{ service_name }}_redis-cache:
image: docker.io/library/redis:8.6.0-alpine
restart: {{ restart_policy }}
command: ["redis-server", "--requirepass", "${REDIS_PASSWORD}"]
environment:
- REDIS_PASSWORD=${REDIS_PASSWORD}
networks:
{{ service_name }}_backend:
volumes:
- {{ service_name }}_redis-cache:/data
{% if not database_external %}
{{ service_name }}_postgres:
image: docker.io/library/postgres:17.8
restart: {{ restart_policy }}
environment:
{% if container_timezone %}
- TZ={{ container_timezone }}
{% endif %}
- POSTGRES_USER={{ database_user }}
- POSTGRES_PASSWORD=${DATABASE_PASSWORD}
- POSTGRES_DB={{ database_name }}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U {{ database_user }}"]
start_period: 30s
interval: 10s
timeout: 10s
retries: 5
networks:
- {{ service_name }}_backend
volumes:
- {{ service_name }}_postgres:/var/lib/postgresql/data
{% endif %}
networks:
{{ service_name }}_backend:
driver: bridge
{% if traefik_enabled %}
{{ traefik_network }}:
external: true
{% endif %}
volumes:
{% if not database_external %}
{{ service_name }}_postgres:
driver: local
{% endif %}
{{ service_name }}_redis:
driver: local
{{ service_name }}_redis-cache:
driver: local
{{ service_name }}_media:
driver: local
{{ service_name }}_reports:
driver: local
{{ service_name }}_scripts:
driver: local
================================================
FILE: library/compose/netbox/template.yaml
================================================
---
kind: compose
metadata:
name: NetBox
description: 'Network infrastructure management (IPAM/DCIM) and network automation source of truth. Provides comprehensive
API
for managing IP addresses, circuits, devices, racks, cables, and other network infrastructure components with powerful
automation capabilities.
## References
- **Project:** https://netbox.dev/
- **Documentation:** https://docs.netbox.dev/
- **GitHub:** https://github.com/netbox-community/netbox'
version: v4.5.2
author: Christian Lempa
date: '2026-02-03'
tags:
- traefik
- database
- email
icon:
provider: selfh
id: netbox
draft: false
next_steps: 'Log in with your initial admin user:
```bash
Username: admin
Password: admin
```'
schema: '1.2'
spec:
database:
title: Database
toggle: database_external
description: Configure external database connection
vars:
database_name:
default: netbox
database_user:
default: netbox
database_host:
type: str
required: true
database_password:
type: str
sensitive: true
required: true
redis_password:
description: Redis password for authentication
type: str
sensitive: true
autogenerated: true
required: true
database_external:
type: bool
default: false
description: Use external database
email:
title: Email
toggle: email_enabled
description: Configure email/SMTP integration
vars:
email_host:
description: SMTP server hostname
type: str
required: true
email_port:
description: SMTP server port
type: int
default: 25
required: true
email_username:
description: SMTP username
type: str
required: true
email_password:
description: SMTP password
type: str
sensitive: true
required: true
email_from:
description: From email address
type: str
required: true
email_enabled:
type: bool
default: false
description: Enable email integration
email_encryption:
type: str
default: tls
description: The email encryption type
general:
vars:
service_name:
default: netbox
container_timezone:
type: str
restart_policy:
type: enum
options:
- unless-stopped
- always
- on-failure
- 'no'
default: unless-stopped
required: true
netbox:
title: NetBox Configuration
toggle: netbox_metrics_enabled
description: Configure NetBox application settings
vars:
netbox_metrics_enabled:
description: Enable Prometheus metrics endpoint
type: bool
default: false
netbox_secret_key:
description: Secret Key
type: str
sensitive: true
autogenerated: true
autogenerated_length: 50
required: true
extra: Used for cryptographic signing and session management
ports:
vars:
ports_http:
description: Host port for HTTP
default: 8000
traefik:
title: Traefik
toggle: traefik_enabled
description: Configure Traefik reverse proxy integration
vars:
traefik_host:
default: netbox
traefik_network:
default: traefik
type: str
required: true
traefik_domain:
default: home.arpa
type: str
required: true
traefik_enabled:
type: bool
default: false
description: Enable Traefik integration
traefik_tls:
title: Traefik TLS
toggle: traefik_tls_enabled
needs: traefik
description: Configure Traefik TLS/SSL certificates
vars:
traefik_tls_certresolver:
type: str
default: cloudflare
required: true
traefik_tls_enabled:
type: bool
default: false
description: Enable Traefik TLS
================================================
FILE: library/compose/nextcloud/compose.yaml.j2
================================================
services:
{{ service_name }}-app:
image: docker.io/library/nextcloud:32.0.6-apache
{% if not swarm_enabled %}
restart: {{ restart_policy }}
container_name: {{ container_name }}-app
{% endif %}
environment:
- TZ={{ container_timezone }}
{% if database_type == 'mysql' %}
- MYSQL_PASSWORD=${MYSQL_PASSWORD}
- MYSQL_DATABASE={{ database_name }}
- MYSQL_USER={{ database_user }}
- MYSQL_HOST={{ service_name }}-db
{% elif database_type == 'postgres' %}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB={{ database_name }}
- POSTGRES_USER={{ database_user }}
- POSTGRES_HOST={{ service_name }}-db
{% endif %}
{% endif %}
{% if not traefik_enabled %}
ports:
{% if swarm_enabled %}
- target: 80
published: {{ ports_http }}
protocol: tcp
mode: host
{% else %}
- "{{ ports_http }}:80"
{% endif %}
{% endif %}
volumes:
- nextcloud-data:/var/www/html
{% if traefik_enabled and not swarm_enabled %}
labels:
- traefik.enable=true
- traefik.docker.network={{ traefik_network }}
- traefik.http.services.{{ service_name }}-web.loadbalancer.server.port=80
- traefik.http.routers.{{ service_name }}-web-http.rule=Host(`{{ traefik_host }}.{{ traefik_domain }}`)
- traefik.http.routers.{{ service_name }}-web-http.entrypoints={{ traefik_entrypoint }}
- traefik.http.routers.{{ service_name }}-web-http.service={{ service_name }}-web
{% if traefik_tls_enabled %}
- traefik.http.routers.{{ service_name }}-web-https.rule=Host(`{{ traefik_host }}.{{ traefik_domain }}`)
- traefik.http.routers.{{ service_name }}-web-https.entrypoints={{ traefik_tls_entrypoint }}
- traefik.http.routers.{{ service_name }}-web-https.tls=true
- traefik.http.routers.{{ service_name }}-web-https.tls.certresolver={{ traefik_tls_certresolver }}
- traefik.http.routers.{{ service_name }}-web-https.service={{ service_name }}-web
{% endif %}
{% endif %}
depends_on:
- {{ service_name }}-db
{% if swarm_enabled %}
deploy:
replicas: {{ swarm_replicas }}
{% if traefik_enabled %}
labels:
- traefik.enable=true
- traefik.docker.network={{ traefik_network }}
- traefik.http.services.{{ service_name }}-web.loadbalancer.server.port=80
- traefik.http.routers.{{ service_name }}-web-http.rule=Host(`{{ traefik_host }}.{{ traefik_domain }}`)
- traefik.http.routers.{{ service_name }}-web-http.entrypoints={{ traefik_entrypoint }}
- traefik.http.routers.{{ service_name }}-web-http.service={{ service_name }}-web
{% if traefik_tls_enabled %}
- traefik.http.routers.{{ service_name }}-web-https.rule=Host(`{{ traefik_host }}.{{ traefik_domain }}`)
- traefik.http.routers.{{ service_name }}-web-https.entrypoints={{ traefik_tls_entrypoint }}
- traefik.http.routers.{{ service_name }}-web-https.tls=true
- traefik.http.routers.{{ service_name }}-web-https.tls.certresolver={{ traefik_tls_certresolver }}
- traefik.http.routers.{{ service_name }}-web-https.service={{ service_name }}-web
{% endif %}
{% endif %}
{% endif %}
{{ service_name }}-db:
{% if database_type == 'mysql' %}
# See compatibility matrix for Nextcloud 31
# https://docs.nextcloud.com/server/31/admin_manual/installation/system_requirements.html
image: docker.io/library/mariadb:10.11.16
{% if not swarm_enabled %}
restart: {{ restart_policy }}
container_name: {{ service_name }}-db
{% endif %}
command: --transaction-isolation=READ-COMMITTED --binlog-format=ROW
environment:
- TZ={{ container_timezone }}
- MYSQL_RANDOM_ROOT_PASSWORD=true
- MYSQL_PASSWORD=${MYSQL_PASSWORD}
- MYSQL_DATABASE={{ database_name }}
- MYSQL_USER={{ database_user }}
{% endif %}
volumes:
- nextcloud-db:/var/lib/mysql
{% elif database_type == 'postgres' %}
image: docker.io/library/postgres:17.8
{% if not swarm_enabled %}
restart: {{ restart_policy }}
container_name: {{ service_name }}-db
{% endif %}
environment:
- TZ={{ container_timezone }}
- POSTGRES_USER={{ database_user }}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
- POSTGRES_DB={{ database_name }}
{% endif %}
volumes:
- nextcloud-db:/var/lib/postgresql/data
{% endif %}
{% if swarm_enabled %}
deploy:
replicas: 1
{% endif %}
volumes:
nextcloud-data:
driver: local
nextcloud-db:
driver: local
networks:
{% if traefik_enabled %}
{{ traefik_network }}:
external: true
{% endif %}
{% endif %}
================================================
FILE: library/compose/nextcloud/template.yaml
================================================
kind: compose
metadata:
name: Nextcloud
description: 'Self-hosted file sync and share platform. Nextcloud is a suite of
client-server software for creating and using file hosting services. It provides
functionality similar to Dropbox, with the added benefit of being self-hosted
and open-source.
Project: https://nextcloud.com/
Documentation: https://docs.nextcloud.com/
GitHub: https://github.com/nextcloud/server
'
icon:
provider: selfh
id: nextcloud
version: 32.0.6-apache
author: Christian Lempa
date: '2026-02-13'
tags:
- traefik
draft: true
schema: '1.2'
spec:
database:
required: true
vars:
database_type:
description: Database type (Nextcloud supports PostgreSQL or MySQL/MariaDB)
type: enum
options:
- postgres
- mysql
default: postgres
database_name:
type: str
required: true
database_user:
type: str
required: true
database_password:
type: str
sensitive: true
required: true
general:
vars:
service_name:
default: nextcloud
container_name:
default: nextcloud
container_timezone:
type: str
restart_policy:
type: enum
options:
- unless-stopped
- always
- on-failure
- 'no'
default: unless-stopped
required: true
network:
vars:
network_macvlan_ipv4_address_db:
description: Static IP address for database container (macvlan only)
type: str
default: 192.168.1.252
needs: network_mode=macvlan
ports:
vars:
ports_http:
description: Host port for HTTP
type: int
default: 80
nextcloud:
description: Configure Nextcloud application settings
vars:
admin_user:
description: Nextcloud admin username
type: str
default: admin
admin_password:
description: Nextcloud admin password
type: str
sensitive: true
autogenerated: true
default: ''
traefik:
vars:
traefik_host:
default: nextcloud
traefik_network:
default: traefik
type: str
required: true
traefik_domain:
default: home.arpa
type: str
required: true
traefik_enabled:
type: bool
default: false
description: Enable Traefik integration
toggle: traefik_enabled
title: Traefik
description: Configure Traefik reverse proxy integration
traefik_tls:
vars:
traefik_tls_certresolver:
type: str
default: cloudflare
required: true
traefik_tls_enabled:
type: bool
default: false
description: Enable Traefik TLS
toggle: traefik_tls_enabled
title: Traefik TLS
description: Configure Traefik TLS/SSL certificates
needs: traefik
swarm: null
================================================
FILE: library/compose/nginx/compose.yaml.j2
================================================
services:
{{ service_name }}:
image: docker.io/library/nginx:1.28.2-alpine
{#
If not in swarm mode, apply restart policy and container name
#}
{% if not swarm_enabled %}
restart: {{ restart_policy }}
container_name: {{ container_name }}
{% endif %}
{#
When traefik is enabled, add traefik network for reverse proxy access
#}
{% if traefik_enabled %}
networks:
{{ traefik_network }}:
{% endif %}
{#
Port mappings for HTTP and HTTPS (only when Traefik is disabled)
Note: Swarm mode uses 'host' mode for port publishing to avoid port conflicts
#}
{% if not traefik_enabled %}
ports:
{% if swarm_enabled %}
- target: 80
published: {{ ports_http }}
protocol: tcp
mode: host
- target: 443
published: {{ ports_https }}
protocol: tcp
mode: host
{% else %}
- "{{ ports_http }}:80"
- "{{ ports_https }}:443"
{% endif %}
{% endif %}
{#
Volume configuration (commented out - uncomment and customize as needed):
- config: Nginx configuration files
- data: Static content to serve
#}
# volumes:
# - ./config/default.conf:/etc/nginx/conf.d/default.conf:ro
# - ./data:/usr/share/nginx/html:ro
{#
When traefik_enabled is set, and not running in swarm mode, add traefik labels
(optionally enable TLS if traefik_tls_enabled is set)
#}
{% if traefik_enabled and not swarm_enabled %}
labels:
- traefik.enable=true
- traefik.docker.network={{ traefik_network }}
- traefik.http.services.{{ service_name }}-web.loadbalancer.server.port=80
- traefik.http.routers.{{ service_name }}-web-http.rule=Host(`{{ traefik_host }}.{{ traefik_domain }}`)
- traefik.http.routers.{{ service_name }}-web-http.entrypoints={{ traefik_entrypoint }}
- traefik.http.routers.{{ service_name }}-web-http.service={{ service_name }}-web
{% if traefik_tls_enabled %}
- traefik.http.routers.{{ service_name }}-web-https.rule=Host(`{{ traefik_host }}.{{ traefik_domain }}`)
- traefik.http.routers.{{ service_name }}-web-https.entrypoints={{ traefik_tls_entrypoint }}
- traefik.http.routers.{{ service_name }}-web-https.tls=true
- traefik.http.routers.{{ service_name }}-web-https.tls.certresolver={{ traefik_tls_certresolver }}
- traefik.http.routers.{{ service_name }}-web-https.service={{ service_name }}-web
{% endif %}
{% endif %}
{#
Deploy configuration for Swarm mode:
- Configure replicas
- Traefik: Labels for reverse proxy integration (Swarm mode)
#}
{% if swarm_enabled %}
deploy:
replicas: {{ swarm_replicas }}
{#
When traefik_enabled is set in swarm mode, add traefik labels
(optionally enable TLS if traefik_tls_enabled is set)
#}
{% if traefik_enabled %}
labels:
- traefik.enable=true
- traefik.docker.network={{ traefik_network }}
- traefik.http.services.{{ service_name }}-web.loadbalancer.server.port=80
- traefik.http.routers.{{ service_name }}-web-http.rule=Host(`{{ traefik_host }}.{{ traefik_domain }}`)
- traefik.http.routers.{{ service_name }}-web-http.entrypoints={{ traefik_entrypoint }}
- traefik.http.routers.{{ service_name }}-web-http.service={{ service_name }}-web
{% if traefik_tls_enabled %}
- traefik.http.routers.{{ service_name }}-web-https.rule=Host(`{{ traefik_host }}.{{ traefik_domain }}`)
- traefik.http.routers.{{ service_name }}-web-https.entrypoints={{ traefik_tls_entrypoint }}
- traefik.http.routers.{{ service_name }}-web-https.tls=true
- traefik.http.routers.{{ service_name }}-web-https.tls.certresolver={{ traefik_tls_certresolver }}
- traefik.http.routers.{{ service_name }}-web-https.service={{ service_name }}-web
{% endif %}
{% endif %}
{% endif %}
{#
Network definitions (only when Traefik is enabled):
- Traefik network: always external (managed by Traefik)
#}
{% if traefik_enabled %}
networks:
{{ traefik_network }}:
external: true
{% endif %}
================================================
FILE: library/compose/nginx/config/default.conf
================================================
server {
listen 80;
server_name _;
location / {
root /usr/share/nginx/html;
index index.html;
access_log on;
}
}
================================================
FILE: library/compose/nginx/data/index.html
================================================
this is a test website...
================================================
FILE: library/compose/nginx/template.yaml
================================================
---
kind: compose
metadata:
icon:
provider: selfh
id: nginx
name: Nginx
description: 'Nginx is a high-performance web server, reverse proxy, and load balancer known for its stability, rich feature
set, simple configuration, and low resource
consumption. It is widely used to serve static content, handle HTTP requests, and distribute traffic across multiple servers.
## Prerequisites
* **Project:** https://nginx.org/
* **Documentation:** https://nginx.org/en/docs/
* **GitHub:** https://github.com/nginx/nginx'
version: 1.28.2-alpine
author: Christian Lempa
date: '2026-02-05'
tags:
- traefik
- swarm
draft: true
schema: '1.2'
spec:
general:
vars:
service_name:
default: nginx
restart_policy:
type: enum
options:
- unless-stopped
- always
- on-failure
- 'no'
default: unless-stopped
container_name:
default: nginx
container_timezone:
default: UTC
ports:
vars:
ports_http:
description: HTTP port for nginx service
type: int
default: 8080
ports_https:
description: HTTPS port for nginx service
type: int
default: 8443
traefik:
title: Traefik
toggle: traefik_enabled
description: Configure Traefik reverse proxy integration
vars:
traefik_enabled:
type: bool
default: false
traefik_network:
default: traefik
traefik_host:
default: nginx
traefik_domain:
default: home.arpa
traefik_entrypoint:
default: web
type: bool
network:
vars:
network_mode:
type: enum
options:
- bridge
- host
- macvlan
default: bridge
network_name:
default: bridge
swarm:
toggle: swarm_enabled
vars:
swarm_enabled:
type: bool
default: false
swarm_replicas:
description: Number of replicas for Swarm mode
type: int
default: 1
swarm_placement_host:
type: str
default: ''
description: The placement host
swarm_placement_mode:
type: str
default: replicated
description: The placement mode
================================================
FILE: library/compose/openwebui/compose.yaml.j2
================================================
services:
{{ service_name }}:
image: ghcr.io/open-webui/open-webui:v0.8.2
container_name: {{ container_name }}
{#
Set container hostname for identification
#}
hostname: {{ container_hostname }}
{#
Environment variables for OpenWebUI configuration:
- Ollama API URL
- OAuth/OpenID configuration (if Authentik is enabled)
#}
environment:
- TZ={{ container_timezone }}
- OLLAMA_BASE_URL={{ ollama_base_url }}
{% if authentik_enabled %}
- ENABLE_OAUTH_SIGNUP=true
- OAUTH_MERGE_ACCOUNTS_BY_EMAIL={{ oauth_merge_accounts|lower }}
- OAUTH_PROVIDER_NAME=authentik
- OPENID_PROVIDER_URL={{ openid_provider_url }}
- OAUTH_CLIENT_ID={{ authentik_client_id }}
- OAUTH_CLIENT_SECRET={{ authentik_client_secret }}
- OAUTH_SCOPES={{ oauth_scopes }}
- OPENID_REDIRECT_URI={{ openid_redirect_uri }}
{% endif %}
{#
Volume configuration for persistent data
#}
volumes:
- data:/app/backend/data:rw
{#
When traefik is enabled, add traefik network for reverse proxy access
#}
{% if traefik_enabled %}
networks:
{{ traefik_network }}:
{% endif %}
{#
Port mappings for web interface (only when Traefik is disabled)
#}
{% if not traefik_enabled %}
ports:
- "{{ ports_http }}:8080"
{% endif %}
{#
When traefik_enabled is set, add traefik labels
(optionally enable TLS if traefik_tls_enabled is set)
#}
{% if traefik_enabled %}
labels:
- traefik.enable=true
- traefik.docker.network={{ traefik_network }}
- traefik.http.services.{{ service_name }}-web.loadBalancer.server.port=8080
- traefik.http.routers.{{ service_name }}-http.service={{ service_name }}-web
- traefik.http.routers.{{ service_name }}-http.rule=Host(`{{ traefik_host }}.{{ traefik_domain }}`)
- 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_domain }}`)
- 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 }}
{#
Volume definitions:
- data: Persistent storage for OpenWebUI data
#}
volumes:
data:
driver: local
{#
Network definitions (only when Traefik is enabled):
- Traefik network: always external (managed by Traefik)
#}
{% if traefik_enabled %}
networks:
{{ traefik_network }}:
external: true
{% endif %}
================================================
FILE: library/compose/openwebui/template.yaml
================================================
kind: compose
metadata:
icon:
provider: selfh
id: open-webui
name: Openwebui
description: 'OpenWebUI is an open-source web-based user interface for managing
and interacting with AI models. It provides a user-friendly platform to deploy,
monitor, and utilize various AI models for tasks such as image generation, text
generation, and more. OpenWebUI supports integration with popular AI frameworks
and offers features like model management, user authentication, and real-time
interaction.
Project: https://openwebui.io/
Documentation: https://docs.openwebui.io/
GitHub: https://github.com/openwebui/openwebui
'
version: 0.7.2
author: Christian Lempa
date: '2026-01-10'
tags:
- traefik
- authentik
draft: true
schema: '1.2'
spec:
general:
vars:
service_name:
default: openwebui
container_name:
default: openwebui
container_hostname:
type: str
container_timezone:
type: str
restart_policy:
type: enum
options:
- unless-stopped
- always
- on-failure
- 'no'
default: unless-stopped
required: true
ollama_base_url:
type: str
description: Ollama API base URL
default: http://ollama:11434
traefik:
vars:
traefik_host:
default: openwebui.home.arpa
traefik_network:
default: traefik
type: str
required: true
traefik_domain:
default: home.arpa
type: str
required: true
traefik_enabled:
type: bool
default: false
description: Enable Traefik integration
toggle: traefik_enabled
title: Traefik
description: Configure Traefik reverse proxy integration
traefik_tls:
vars:
traefik_tls_certresolver:
type: str
default: cloudflare
required: true
traefik_tls_enabled:
type: bool
default: false
description: Enable Traefik TLS
toggle: traefik_tls_enabled
title: Traefik TLS
description: Configure Traefik TLS/SSL certificates
needs: traefik
ports:
vars:
ports_http:
description: Web UI port
type: int
default: 8080
authentik:
toggle: authentik_enabled
vars:
authentik_enabled:
default: false
openid_provider_url:
type: str
description: OpenID provider configuration URL
default: https://authentik.example.com/application/o/openwebui/.well-known/openid-configuration
openid_redirect_uri:
type: str
description: OAuth redirect URI
default: https://openwebui.example.com/oauth/oidc/callback
oauth_scopes:
type: str
description: OAuth scopes (space-separated)
default: openid email profile
oauth_merge_accounts:
type: bool
description: Merge OAuth accounts by email
default: false
authentik_client_id:
type: str
sensitive: true
required: true
authentik_client_secret:
type: str
sensitive: true
required: true
authentik_slug:
type: str
default: ''
description: The Authentik application slug
authentik_url:
type: str
default: ''
description: The Authentik URL
================================================
FILE: library/compose/passbolt/compose.yaml.j2
================================================
---
services:
{{ service_name }}:
image: docker.io/passbolt/passbolt:5.9.0-1-ce
restart: unless-stopped
environment:
{% if container_timezone%}
- APP_DEFAULT_TIMEZONE={{ container_timezone }}
{% endif %}
- APP_FULL_BASE_URL=https://passbolt.domain.tld
- DATASOURCES_DEFAULT_HOST={{ service_name }}-db
- DATASOURCES_DEFAULT_USERNAME=$PASSBOLT_DB_USER
- DATASOURCES_DEFAULT_PASSWORD=$PASSBOLT_DB_PASS
- DATASOURCES_DEFAULT_DATABASE=$PASSBOLT_DB_NAME
- EMAIL_TRANSPORT_DEFAULT_HOST=your-mail-server
- EMAIL_TRANSPORT_DEFAULT_PORT=587
- EMAIL_TRANSPORT_DEFAULT_USERNAME=$EMAIL_TRANSPORT_DEFAULT_USERNAME
- EMAIL_TRANSPORT_DEFAULT_PASSWORD=$EMAIL_TRANSPORT_DEFAULT_PASSWORD
- EMAIL_TRANSPORT_DEFAULT_TLS=true
- EMAIL_DEFAULT_FROM=no-reply@domain.tld
volumes:
- {{ service_name }}-gpg:/etc/passbolt/gpg
- {{ service_name }}-jwt:/etc/passbolt/jwt
command: ["/usr/bin/wait-for.sh", "-t", "0", "passbolt-db:3306", "--", "/docker-entrypoint.sh"]
depends_on:
- {{ service_name }}-db
{{ service_name }}-db:
image: docker.io/library/mariadb:11.3.2
restart: unless-stopped
environment:
- MYSQL_RANDOM_ROOT_PASSWORD=true
- MYSQL_DATABASE=$PASSBOLT_DB_NAME
- MYSQL_USER=$PASSBOLT_DB_USER
- MYSQL_PASSWORD=$PASSBOLT_DB_PASS
volumes:
- passbolt-db:/var/lib/mysql
volumes:
passbolt-db:
{{ service_name }}-gpg:
{{ service_name }}-jwt:
================================================
FILE: library/compose/passbolt/template.yaml
================================================
---
kind: compose
metadata:
icon:
provider: selfh
id: passbolt
name: Passbolt
description: |-
Passbolt is an open-source password manager designed for teams and businesses. It provides a secure way to store,
share, and manage passwords and sensitive information collaboratively.
## References
- **Project:** https://www.passbolt.com/
- **Documentation:** https://help.passbolt.com/
- **GitHub:** https://github.com/passbolt/passbolt
version: 5.9.0-1-ce
author: Christian Lempa
date: '2026-02-10'
tags:
- traefik
- database
draft: true
schema: "1.2"
spec:
general:
vars:
service_name:
default: passbolt
container_timezone:
type: str
traefik:
vars:
traefik_host:
default: passbolt
database:
vars:
database_name:
default: passbolt
database_user:
default: passbolt
================================================
FILE: library/compose/pihole/compose.yaml.j2
================================================
---
services:
{{ service_name }}:
image: docker.io/pihole/pihole:2025.11.1
{#
If not in swarm mode, check whether container_name is set and apply restart policy,
else swarm mode handles restarts via deploy.restart_policy
#}
{% if not swarm_enabled %}
restart: {{ restart_policy }}
{% if container_name %}
container_name: {{ container_name }}
{% endif %}
{% endif %}
{#
Set container hostname (Pi-hole requires this for proper operation)
#}
{% if container_hostname %}
hostname: {{ container_hostname }}
{% endif %}
{#
Environment variables for Pi-hole configuration
- TZ: Timezone for proper log rotation
- PIHOLE_UID/GID: User/group for file permissions
- WEBPASSWORD: Admin password (from env file in compose mode, from secret in swarm mode)
- FTLCONF_dns_listeningMode: In bridge mode, listen on all interfaces
#}
environment:
- TZ={{ container_timezone }}
- PIHOLE_UID={{ user_uid }}
- PIHOLE_GID={{ user_gid }}
{% if swarm_enabled %}
- WEBPASSWORD_FILE={{ service_name }}_webpassword
{% else %}
- FTLCONF_webserver_api_password=${WEBPASSWORD}
{% endif %}
{% if network_mode == 'bridge' %}
- FTLCONF_dns_listeningMode=all
{% endif %}
{#
Network configuration based on network_mode:
- 'host': Use host networking (direct access to host network stack, not supported in Swarm)
- 'bridge': Custom bridge network (default Docker networking)
- 'macvlan': Container gets its own MAC/IP on physical network (requires external macvlan network setup in Swarm)
- '' (empty): Uses Docker's default bridge network
When traefik is enabled, always add traefik network
#}
{% if network_mode == 'host' %}
network_mode: host
{% elif network_mode == 'bridge' or network_mode == 'macvlan' or traefik_enabled %}
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 %}
{#
Port mappings when in bridge mode (or empty, which defaults to bridge)
- HTTP/HTTPS: Web interface (only if Traefik is disabled)
- DNS: Port 53 TCP/UDP (always exposed, even with Traefik, since DNS can't be proxied)
- NTP: Port 123 UDP (network time protocol, always exposed)
Note: Swarm mode uses 'host' mode for port publishing to avoid port conflicts
Note: Host and macvlan modes don't need port mappings (direct network access)
#}
{% if network_mode == '' or network_mode == 'bridge' or traefik_enabled %}
ports:
{% if not traefik_enabled %}
{% if swarm_enabled %}
- target: 80
published: {{ ports_http }}
protocol: tcp
mode: host
- target: 443
published: {{ ports_https }}
protocol: tcp
mode: host
{% else %}
- "{{ ports_http }}:80/tcp"
- "{{ ports_https }}:443/tcp"
{% endif %}
{% endif %}
{% if swarm_enabled %}
- target: 53
published: {{ ports_dns }}
protocol: tcp
mode: host
- target: 53
published: {{ ports_dns }}
protocol: udp
mode: host
- target: 123
published: {{ ports_ntp }}
protocol: udp
mode: host
{% else %}
- "{{ ports_dns }}:53/tcp"
- "{{ ports_dns }}:53/udp"
- "{{ ports_ntp }}:123/udp"
{% endif %}
{% endif %}
{#
Volume configuration for persistent data
- When volume_mode is 'mount': bind mount from host path
- When volume_mode is 'local', 'nfs', or empty: use docker-managed volumes
#}
volumes:
{% if volume_mode == 'mount' %}
- {{ volume_mount_path }}/dnsmasq:/etc/dnsmasq.d:rw
- {{ volume_mount_path }}/pihole:/etc/pihole:rw
{% else %}
- {{ service_name }}-dnsmasq:/etc/dnsmasq.d
- {{ service_name }}-pihole:/etc/pihole
{% endif %}
{#
Required capabilities:
- NET_ADMIN: For DHCP and routing table operations
- SYS_TIME: For NTP functionality
#}
cap_add:
- NET_ADMIN
- SYS_TIME
{#
When swarm_enabled is set, use Docker secrets for admin password
#}
{% if swarm_enabled %}
secrets:
- {{ service_name }}_webpassword
{% endif %}
{#
Deploy configuration for Swarm mode:
- Single replica with node placement constraint (Pi-hole doesn't support multi-replica)
- Multiple instances would conflict with DNS/DHCP services
- Traefik: Labels for reverse proxy integration (Swarm mode)
Note: For macvlan in Swarm, create config-only networks on each node first, then use --scope swarm
#}
{% if swarm_enabled %}
deploy:
mode: replicated
replicas: 1
placement:
constraints:
- node.hostname == {{ swarm_placement_host }}
restart_policy:
condition: on-failure
{#
When traefik_enabled is set in swarm mode, add traefik labels
(optionally enable TLS if traefik_tls_enabled is set)
#}
{% if traefik_enabled %}
labels:
- traefik.enable=true
- traefik.docker.network={{ traefik_network }}
- traefik.http.services.{{ service_name }}-web.loadBalancer.server.port=80
- traefik.http.routers.{{ service_name }}-http.service={{ service_name }}-web
- traefik.http.routers.{{ service_name }}-http.rule=Host(`{{ traefik_host }}.{{ traefik_domain }}`)
- traefik.http.routers.{{ service_name }}-http.entrypoints=web
{% 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_domain }}`)
- traefik.http.routers.{{ service_name }}-https.entrypoints=websecure
- traefik.http.routers.{{ service_name }}-https.tls=true
- traefik.http.routers.{{ service_name }}-https.tls.certresolver={{ traefik_tls_certresolver }}
{% endif %}
{% endif %}
{% endif %}
{#
When traefik_enabled is set, and not running in swarm mode, add traefik labels
(optionally enable TLS if traefik_tls_enabled is set)
#}
{% if traefik_enabled and not swarm_enabled %}
labels:
- traefik.enable=true
- traefik.docker.network={{ traefik_network }}
- traefik.http.services.{{ service_name }}-web.loadBalancer.server.port=80
- traefik.http.routers.{{ service_name }}-http.service={{ service_name }}-web
- traefik.http.routers.{{ service_name }}-http.rule=Host(`{{ traefik_host }}.{{ traefik_domain }}`)
- traefik.http.routers.{{ service_name }}-http.entrypoints=web
{% 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_domain }}`)
- traefik.http.routers.{{ service_name }}-https.entrypoints=websecure
- traefik.http.routers.{{ service_name }}-https.tls=true
- traefik.http.routers.{{ service_name }}-https.tls.certresolver={{ traefik_tls_certresolver }}
{% endif %}
{% endif %}
{#
When swarm_enabled is set, define Docker secret for admin password
#}
{% if swarm_enabled %}
secrets:
{{ service_name }}_webpassword:
file: ./.env.secret.webpassword
{% endif %}
{#
Volume definitions:
- When volume_mode is 'local' (default): use docker-managed local volumes
- When volume_mode is 'nfs': configure NFS-backed volumes
- When volume_mode is 'mount': no volume definition needed (bind mounts used directly)
#}
{% if volume_mode == 'local' %}
volumes:
{{ service_name }}-dnsmasq:
driver: local
{{ service_name }}-pihole:
driver: local
{% elif volume_mode == 'nfs' %}
volumes:
{{ service_name }}-dnsmasq:
driver: local
driver_opts:
type: nfs
o: addr={{ volume_nfs_server }},{{ volume_nfs_options }}
device: ":{{ volume_nfs_path }}/dnsmasq"
{{ service_name }}-pihole:
driver: local
driver_opts:
type: nfs
o: addr={{ volume_nfs_server }},{{ volume_nfs_options }}
device: ":{{ volume_nfs_path }}/pihole"
{% endif %}
{#
Network definitions (only when needed):
- When network_mode is empty: no definition needed (uses Docker's default bridge)
- When network_mode is 'bridge': define custom bridge network
- When network_mode is 'macvlan': configure macvlan with static IP (for Compose mode)
Note: In Swarm mode, macvlan networks must be created manually with config-only networks on each node
- When swarm_enabled: use overlay network for multi-host communication
- Traefik network: always external (managed by Traefik)
#}
{% if network_mode == 'bridge' or network_mode == 'macvlan' or traefik_enabled %}
networks:
{% if network_mode == 'bridge' or network_mode == 'macvlan' %}
{{ network_name }}:
{% if network_external %}
external: true
{% else %}
{% if network_mode == 'macvlan' %}
driver: macvlan
driver_opts:
parent: {{ network_macvlan_parent_interface }}
ipam:
config:
- subnet: {{ network_macvlan_subnet }}
gateway: {{ network_macvlan_gateway }}
name: {{ network_name }}
{% elif swarm_enabled %}
driver: overlay
attachable: true
{% else %}
driver: bridge
{% endif %}
{% endif %}
{% endif %}
{% if traefik_enabled %}
{{ traefik_network }}:
external: true
{% endif %}
{% endif %}
================================================
FILE: library/compose/pihole/template.yaml
================================================
kind: compose
metadata:
icon:
provider: selfh
id: pi-hole
name: Pihole
description: 'Network-wide advertisement and internet tracker blocking application
that functions as a DNS blackhole.
Provides DNS-level content filtering for all network devices, improving browsing
performance, privacy, and security.
Supports custom blocklists, whitelists, and seamless integration with existing
network infrastructure.
## Prerequisites
- :warning: Pi-hole uses local storage and configuration files and does NOT support
running multiple replicas.
This template enforces a single replica with node placement constraints to ensure
stable DNS resolution.
## References
- **Project:** https://pi-hole.net/
- **Documentation:** https://docs.pi-hole.net/
- **GitHub:** https://github.com/pi-hole/pi-hole
'
version: 2025.11.1
author: Christian Lempa
date: '2025-12-11'
tags:
- traefik
- swarm
- network
- volume
next_steps: 'Log in with your initial admin user:
```bash
Username: admin
Password: {{ webpassword }}
```'
schema: '1.2'
spec:
general:
vars:
service_name:
default: pihole
container_name:
default: pihole
container_hostname:
type: str
container_timezone:
type: str
user_uid:
type: int
default: 1000
user_gid:
type: int
default: 1000
restart_policy:
type: enum
options:
- unless-stopped
- always
- on-failure
- 'no'
default: unless-stopped
required: true
admin_settings:
description: Admin Pi-hole Settings
required: true
vars:
webpassword:
description: Web interface admin password
type: str
sensitive: true
autogenerated: true
ports:
vars:
ports_dns:
description: DNS port for Pi-hole
type: int
default: 53
required: true
ports_http:
description: HTTP port for Pi-hole
type: int
default: 80
ports_https:
description: HTTPS port for Pi-hole
type: int
default: 443
ports_ntp:
description: External NTP port
type: int
default: 123
required: true
traefik:
vars:
traefik_host:
default: pihole
traefik_network:
default: traefik
type: str
required: true
traefik_domain:
default: home.arpa
type: str
required: true
traefik_enabled:
type: bool
default: false
description: Enable Traefik integration
toggle: traefik_enabled
title: Traefik
description: Configure Traefik reverse proxy integration
traefik_tls:
vars:
traefik_tls_certresolver:
type: str
default: cloudflare
required: true
traefik_tls_enabled:
type: bool
default: false
description: Enable Traefik TLS
toggle: traefik_tls_enabled
title: Traefik TLS
description: Configure Traefik TLS/SSL certificates
needs: traefik
network:
vars:
network_mode:
extra: 'If you need DHCP functionality, use ''host'' or ''macvlan'' mode.
NOTE: Swarm only supports ''bridge'' mode!"
'
network_name:
default: pihole_network
network_macvlan_ipv4_address:
type: str
default: 192.168.1.253
needs:
- network_mode=macvlan
required: true
network_macvlan_parent_interface:
type: str
default: eth0
needs:
- network_mode=macvlan
required: true
network_macvlan_subnet:
type: str
default: 192.168.1.0/24
needs:
- network_mode=macvlan
required: true
network_macvlan_gateway:
type: str
default: 192.168.1.1
needs:
- network_mode=macvlan
required: true
network_external:
type: bool
default: false
description: Whether the network is external
swarm:
vars:
swarm_placement_host:
required: true
optional: false
needs: null
swarm_replicas:
description: Number of replicas for Swarm mode
type: int
default: 1
swarm_enabled:
type: bool
default: false
description: Enable Docker Swarm mode
swarm_placement_mode:
type: str
default: replicated
description: The placement mode
toggle: swarm_enabled
title: Docker Swarm
description: Configure Docker Swarm mode deployment
volume:
vars:
volume_mode:
type: enum
options:
- local
- mount
- nfs
default: local
required: true
volume_mount_path:
type: str
default: /mnt/storage
needs:
- volume_mode=mount
required: true
volume_nfs_server:
type: str
default: 192.168.1.1
needs:
- volume_mode=nfs
required: true
volume_nfs_path:
type: str
default: /export
needs:
- volume_mode=nfs
required: true
volume_nfs_options:
type: str
default: rw,nolock,soft
needs:
- volume_mode=nfs
required: true
================================================
FILE: library/compose/portainer/compose.yaml.j2
================================================
services:
{{ service_name }}:
image: docker.io/portainer/portainer-ce:2.38.1-alpine
{% if not swarm_enabled %}
restart: {{ restart_policy }}
{% endif %}
environment:
- TZ={{ container_timezone }}
{% if traefik_enabled %}
networks:
{{ traefik_network }}:
{% endif %}
ports:
{% if not traefik_enabled %}
- "{{ ports_http }}:9000"
- "{{ ports_https }}:9443"
{% endif %}
- "{{ ports_edge }}:8000"
volumes:
- /run/docker.sock:/var/run/docker.sock
{% if volume_mode == 'mount' %}
- {{ volume_mount_path }}/data:/data
{% else %}
- {{ service_name }}_data:/data
{% endif %}
{% if traefik_enabled and not swarm_enabled %}
labels:
- traefik.enable=true
- traefik.docker.network={{ traefik_network }}
- traefik.http.services.{{ service_name }}_web.loadBalancer.server.port=9000
- traefik.http.routers.{{ service_name }}_http.service={{ service_name }}_web
- traefik.http.routers.{{ service_name }}_http.rule=Host(`{{ traefik_host }}.{{ traefik_domain }}`)
- traefik.http.routers.{{ service_name }}_http.entrypoints=web
{% 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_domain }}`)
- traefik.http.routers.{{ service_name }}_https.entrypoints=websecure
- traefik.http.routers.{{ service_name }}_https.tls=true
- traefik.http.routers.{{ service_name }}_https.tls.certresolver={{ traefik_tls_certresolver }}
{% endif %}
{% endif %}
{% if swarm_enabled %}
deploy:
mode: replicated
replicas: 1
{% if swarm_placement_host %}
placement:
constraints:
- node.hostname == {{ swarm_placement_host }}
{% endif %}
restart_policy:
condition: on-failure
{% if traefik_enabled %}
labels:
- traefik.enable=true
- traefik.docker.network={{ traefik_network }}
- traefik.http.services.{{ service_name }}_web.loadBalancer.server.port=9000
- traefik.http.routers.{{ service_name }}_http.service={{ service_name }}_web
- traefik.http.routers.{{ service_name }}_http.rule=Host(`{{ traefik_host }}.{{ traefik_domain }}`)
- traefik.http.routers.{{ service_name }}_http.entrypoints=web
{% 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_domain }}`)
- traefik.http.routers.{{ service_name }}_https.entrypoints=websecure
- traefik.http.routers.{{ service_name }}_https.tls=true
- traefik.http.routers.{{ service_name }}_https.tls.certresolver={{ traefik_tls_certresolver }}
{% endif %}
{% endif %}
{% endif %}
{% if volume_mode == 'local' %}
volumes:
{{ service_name }}_data:
driver: local
{% elif volume_mode == 'nfs' %}
volumes:
{{ service_name }}_data:
driver: local
driver_opts:
type: nfs
o: addr={{ volume_nfs_server }},nfsvers=4,{{ volume_nfs_options }}
device: ":{{ volume_nfs_path }}/data"
{% endif %}
{% if traefik_enabled %}
networks:
{{ traefik_network }}:
external: true
{% endif %}
================================================
FILE: library/compose/portainer/template.yaml
================================================
kind: compose
metadata:
icon:
provider: selfh
id: portainer
name: Portainer
description: 'Portainer is a powerful and user-friendly management tool for Docker
and Kubernetes environments.
It provides a simple web-based interface to manage containers, images, networks,
and volumes,
making it easier to deploy and monitor applications.
## References
- **Project:** https://www.portainer.io/
- **Documentation:** https://docs.portainer.io/
- **GitHub:** https://github.com/portainer/portainer'
version: 2.38.1-alpine
author: Christian Lempa
date: '2026-02-12'
tags:
- traefik
- swarm
- volumes
schema: '1.2'
spec:
general:
vars:
service_name:
default: portainer
container_timezone:
type: str
restart_policy:
type: enum
options:
- unless-stopped
- always
- on-failure
- 'no'
default: unless-stopped
required: true
ports:
vars:
ports_http:
default: 9000
ports_https:
default: 9443
ports_edge:
description: Host port for Edge agent (8000)
type: int
default: 8000
required: true
traefik:
vars:
traefik_host:
default: portainer
traefik_network:
default: traefik
type: str
required: true
traefik_domain:
default: home.arpa
type: str
required: true
traefik_enabled:
type: bool
default: false
description: Enable Traefik integration
toggle: traefik_enabled
title: Traefik
description: Configure Traefik reverse proxy integration
traefik_tls:
vars:
traefik_tls_certresolver:
type: str
default: cloudflare
required: true
traefik_tls_enabled:
type: bool
default: false
description: Enable Traefik TLS
toggle: traefik_tls_enabled
title: Traefik TLS
description: Configure Traefik TLS/SSL certificates
needs: traefik
volume:
vars:
volume_mode:
type: enum
options:
- local
- mount
- nfs
default: local
required: true
volume_mount_path:
type: str
default: /mnt/storage
needs:
- volume_mode=mount
required: true
volume_nfs_server:
type: str
default: 192.168.1.1
needs:
- volume_mode=nfs
required: true
volume_nfs_path:
type: str
default: /export
needs:
- volume_mode=nfs
required: true
volume_nfs_options:
type: str
default: rw,nolock,soft
needs:
- volume_mode=nfs
required: true
swarm:
vars:
swarm_placement_host:
type: str
description: Target hostname for placement constraint
default: ''
extra: Constrains service to run on specific node by hostname
swarm_enabled:
type: bool
default: false
description: Enable Docker Swarm mode
swarm_placement_mode:
type: str
default: replicated
description: The placement mode
swarm_replicas:
type: int
default: 1
description: The number of replicas
toggle: swarm_enabled
title: Docker Swarm
description: Configure Docker Swarm mode deployment
================================================
FILE: library/compose/postgres/compose.yaml.j2
================================================
services:
{{ service_name }}:
image: docker.io/library/postgres:18.2
{#
If not in swarm mode, apply restart policy and container_name,
else swarm mode handles restarts via deploy.restart_policy
#}
{% if not swarm_enabled %}
restart: {{ restart_policy }}
container_name: {{ container_name }}
{% endif %}
{#
Set container hostname
#}
hostname: {{ container_hostname }}
{#
Environment variables for PostgreSQL configuration
- POSTGRES_INITDB_ARGS: Database initialization arguments (e.g., --data-checksums)
- POSTGRES_HOST_AUTH_METHOD: Authentication method (optional)
- POSTGRES_USER: Database superuser name
- POSTGRES_PASSWORD: Database password (from env or secret file)
- POSTGRES_DB: Default database name
- TZ: Timezone
#}
environment:
- POSTGRES_INITDB_ARGS={{ postgres_initdb_args }}
{% if postgres_host_auth_method %}
- POSTGRES_HOST_AUTH_METHOD={{ postgres_host_auth_method }}
{% endif %}
- POSTGRES_USER={{ database_user }}
{% if postgres_secrets_enabled %}
- POSTGRES_PASSWORD_FILE=/run/secrets/postgres_password
{% else %}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
{% endif %}
- POSTGRES_DB={{ database_name }}
- TZ={{ container_timezone }}
{#
Network configuration:
- Databases typically use bridge networking for internal communication
- Port exposure controlled separately for security
#}
{% if network_mode == 'bridge' or network_mode == '' %}
networks:
{{ network_name }}:
{% endif %}
{#
Port mappings (only expose if needed):
- PostgreSQL default port 5432
Note: Swarm mode uses 'host' mode for port publishing
#}
{% if network_mode == 'bridge' or network_mode == '' %}
ports:
{% if swarm_enabled %}
- target: 5432
published: {{ database_port }}
protocol: tcp
mode: host
{% else %}
- "{{ database_port }}:5432"
{% endif %}
{% endif %}
{#
Volume configuration for persistent data
- When volume_mode is 'mount': bind mount from host path
- When volume_mode is 'local', 'nfs', or empty: use docker-managed volumes
#}
volumes:
{% if volume_mode == 'mount' %}
- {{ volume_mount_path }}:/var/lib/postgresql/data:rw
{% else %}
- {{ service_name }}-data:/var/lib/postgresql/data
{% endif %}
{#
Use Docker secrets for password management (Swarm or Compose with secrets enabled)
#}
{% if postgres_secrets_enabled %}
secrets:
- postgres_password
{% endif %}
{#
Health check to monitor PostgreSQL availability
#}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U {{ database_user }}"]
start_period: 30s
interval: 10s
timeout: 10s
retries: 5
{#
Deploy configuration for Swarm mode:
- Single replica (PostgreSQL doesn't support multi-replica without replication setup)
- For HA, use external replication tools or PostgreSQL streaming replication
#}
{% if swarm_enabled %}
deploy:
mode: replicated
replicas: 1
restart_policy:
condition: on-failure
{% endif %}
{#
Docker secrets definition (when secrets are enabled)
#}
{% if postgres_secrets_enabled %}
secrets:
postgres_password:
file: secret.postgres_password.txt
{% endif %}
{#
Volume definitions:
- When volume_mode is 'local' (default): use docker-managed local volumes
- When volume_mode is 'nfs': configure NFS-backed volumes
- When volume_mode is 'mount': no volume definition needed (bind mounts used directly)
#}
{% if volume_mode == 'local' %}
volumes:
{{ service_name }}-data:
driver: local
{% elif volume_mode == 'nfs' %}
volumes:
{{ service_name }}-data:
driver: local
driver_opts:
type: nfs
o: addr={{ volume_nfs_server }},{{ volume_nfs_options }}
device: ":{{ volume_nfs_path }}"
{% endif %}
{#
Network definitions:
- Bridge network for service communication
- Use overlay network in Swarm mode for multi-host communication
#}
{% if network_mode == 'bridge' or network_mode == '' %}
networks:
{{ network_name }}:
{% if network_external %}
external: true
{% else %}
{% if swarm_enabled %}
driver: overlay
attachable: true
{% else %}
driver: bridge
{% endif %}
{% endif %}
{% endif %}
================================================
FILE: library/compose/postgres/compose.yaml.j2.final
================================================
services:
{{ service_name }}:
image: docker.io/library/postgres:17.6
{#
If not in swarm mode, apply restart policy and container_name,
else swarm mode handles restarts via deploy.restart_policy
#}
{% if not swarm_enabled %}
restart: {{ restart_policy }}
container_name: {{ container_name }}
{% endif %}
{#
Set container hostname
#}
hostname: {{ container_hostname }}
{#
Environment variables for PostgreSQL configuration
- POSTGRES_INITDB_ARGS: Database initialization arguments (e.g., --data-checksums)
- POSTGRES_HOST_AUTH_METHOD: Authentication method (optional)
- POSTGRES_USER: Database superuser name
- POSTGRES_PASSWORD: Database password (from env or secret file)
- POSTGRES_DB: Default database name
- TZ: Timezone
#}
environment:
- POSTGRES_INITDB_ARGS={{ postgres_initdb_args }}
{% if postgres_host_auth_method %}
- POSTGRES_HOST_AUTH_METHOD={{ postgres_host_auth_method }}
{% endif %}
- POSTGRES_USER={{ database_user }}
{% if postgres_secrets_enabled %}
- POSTGRES_PASSWORD_FILE=/run/secrets/postgres_password
{% else %}
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD}
{% endif %}
- POSTGRES_DB={{ database_name }}
- TZ={{ container_timezone }}
{#
Network configuration:
- Databases typically use bridge networking for internal communication
- Port exposure controlled separately for security
#}
{% if network_mode == 'bridge' or network_mode == '' %}
networks:
{{ network_name }}:
{% endif %}
{#
Port mappings (only expose if needed):
- PostgreSQL default port 5432
Note: Swarm mode uses 'host' mode for port publishing
#}
{% if network_mode == 'bridge' or network_mode == '' %}
ports:
{% if swarm_enabled %}
- target: 5432
published: {{ database_port }}
protocol: tcp
mode: host
{% else %}
- "{{ database_port }}:5432"
{% endif %}
{% endif %}
{#
Volume configuration for persistent data
- When volume_mode is 'mount': bind mount from host path
- When volume_mode is 'local', 'nfs', or empty: use docker-managed volumes
#}
volumes:
{% if volume_mode == 'mount' %}
- {{ volume_mount_path }}:/var/lib/postgresql/data:rw
{% else %}
- {{ service_name }}-data:/var/lib/postgresql/data
{% endif %}
{#
Use Docker secrets for password management (Swarm or Compose with secrets enabled)
#}
{% if postgres_secrets_enabled %}
secrets:
- postgres_password
{% endif %}
{#
Health check to monitor PostgreSQL availability
#}
healthcheck:
test: ["CMD-SHELL", "pg_isready -U {{ database_user }}"]
start_period: 30s
interval: 10s
timeout: 10s
retries: 5
{#
Deploy configuration for Swarm mode:
- Single replica (PostgreSQL doesn't support multi-replica without replication setup)
- For HA, use external replication tools or PostgreSQL streaming replication
#}
{% if swarm_enabled %}
deploy:
mode: replicated
replicas: 1
restart_policy:
condition: on-failure
{% endif %}
{#
Docker secrets definition (when secrets are enabled)
#}
{% if postgres_secrets_enabled %}
secrets:
postgres_password:
file: secret.postgres_password.txt
{% endif %}
{#
Volume definitions:
- When volume_mode is 'local' (default): use docker-managed local volumes
- When volume_mode is 'nfs': configure NFS-backed volumes
- When volume_mode is 'mount': no volume definition needed (bind mounts used directly)
#}
{% if volume_mode == 'local' %}
volumes:
{{ service_name }}-data:
driver: local
{% elif volume_mode == 'nfs' %}
volumes:
{{ service_name }}-data:
driver: local
driver_opts:
type: nfs
o: addr={{ volume_nfs_server }},{{ volume_nfs_options }}
device: ":{{ volume_nfs_path }}"
{% endif %}
{#
Network definitions:
- Bridge network for service communication
- Use overlay network in Swarm mode for multi-host communication
#}
{% if network_mode == 'bridge' or network_mode == '' %}
networks:
{{ network_name }}:
{% if network_external %}
external: true
{% else %}
{% if swarm_enabled %}
driver: overlay
attachable: true
{% else %}
driver: bridge
{% endif %}
{% endif %}
{% endif %}
================================================
FILE: library/compose/postgres/template.yaml
================================================
kind: compose
metadata:
icon:
provider: selfh
id: postgresql
name: PostgreSQL
description: 'PostgreSQL is a powerful, open source object-relational database system
with over 30 years of active development that has earned it a strong reputation
for reliability, feature robustness, and performance.
Project: https://www.postgresql.org/
Documentation: https://www.postgresql.org/docs/
GitHub: https://github.com/postgres/postgres
'
version: 18.2
author: Christian Lempa
date: '2026-02-13'
tags:
- swarm
draft: true
schema: '1.2'
spec:
general:
vars:
service_name:
default: postgres
container_name:
default: postgres
container_hostname:
type: str
container_timezone:
type: str
restart_policy:
type: enum
options:
- unless-stopped
- always
- on-failure
- 'no'
default: unless-stopped
required: true
ports:
vars:
ports_postgres:
description: PostgreSQL port
type: int
default: 5432
traefik:
vars:
traefik_host:
default: postgres
network:
vars:
network_mode:
extra: 'Use ''host'' mode if you need to bind directly to port 5432. NOTE:
Swarm only supports ''bridge'' mode!
'
network_name:
default: postgres_network
network_external:
type: bool
default: false
description: Whether the network is external
swarm:
vars:
swarm_replicas:
description: Number of replicas for Swarm mode
type: int
default: 1
swarm_enabled:
type: bool
default: false
description: Enable Docker Swarm mode
swarm_placement_host:
type: str
default: ''
description: The placement host
swarm_placement_mode:
type: str
default: replicated
description: The placement mode
toggle: swarm_enabled
title: Docker Swarm
description: Configure Docker Swarm mode deployment
volume:
vars:
volume_mode:
description: Volume mounting mode (local, mount, nfs)
type: str
default: local
options:
- local
- mount
- nfs
volume_mount_path:
description: Path for bind mounts when volume_mode is 'mount'
type: str
default: /var/lib/postgresql/data
volume_nfs_server:
description: NFS server address when volume_mode is 'nfs'
type: str
default: ''
volume_nfs_path:
description: NFS path when volume_mode is 'nfs'
type: str
default: ''
volume_nfs_options:
description: NFS mount options when volume_mode is 'nfs'
type: str
default: rw
database:
vars:
database_port:
type: int
default: 5432
required: true
database_name:
type: str
required: true
database_user:
type: str
required: true
================================================
FILE: library/compose/prometheus/compose.yaml.j2
================================================
---
services:
{{ service_name }}:
image: docker.io/prom/prometheus:v3.9.1
restart: {{ restart_policy }}
command:
- --config.file=/etc/prometheus/prometheus.yaml
- --storage.tsdb.retention.time={{ metrics_retention_time }}
{% if metrics_retention_size != '0' %}
- --storage.tsdb.retention.size={{ metrics_retention_size }}
{% endif %}
{% if metrics_enable_remote_write %}
- --web.enable-remote-write-receiver
{% endif %}
{% if traefik_enabled %}
{% if traefik_tls_enabled %}
- --web.external-url=https://{{ traefik_host }}.{{ traefik_domain }}
{% else %}
- --web.external-url=http://{{ traefik_host }}.{{ traefik_domain }}
{% endif %}
{% endif %}
{% if not traefik_enabled %}
ports:
- "{{ ports_http }}:9090"
{% endif %}
volumes:
- {{ service_name }}_data:/prometheus
- ./config/prometheus.yaml:/etc/prometheus/prometheus.yaml:ro
{% if traefik_enabled %}
networks:
{{ traefik_network }}:
labels:
- traefik.enable=true
- traefik.docker.network={{ traefik_network }}
- traefik.http.services.{{ service_name }}_web.loadBalancer.server.port=9090
- traefik.http.routers.{{ service_name }}_http.service={{ service_name }}_web
- traefik.http.routers.{{ service_name }}_http.rule=Host(`{{ traefik_host }}.{{ traefik_domain }}`)
- traefik.http.routers.{{ service_name }}_http.entrypoints=web
{% 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_domain }}`)
- traefik.http.routers.{{ service_name }}_https.entrypoints=websecure
- traefik.http.routers.{{ service_name }}_https.tls=true
- traefik.http.routers.{{ service_name }}_https.tls.certresolver={{ traefik_tls_certresolver }}
{% endif %}
{% endif %}
volumes:
{{ service_name }}_data:
driver: local
{% if traefik_enabled %}
networks:
{{ traefik_network }}:
external: true
{% endif %}
================================================
FILE: library/compose/prometheus/config/prometheus.yaml
================================================
---
global:
scrape_interval: 15s # By default, scrape targets every 15 seconds.
# Attach these labels to any time series or alerts when communicating with
# external systems (federation, remote storage, Alertmanager).
# external_labels:
# monitor: 'codelab-monitor'
# A scrape configuration containing exactly one endpoint to scrape:
# Here it's Prometheus itself.
scrape_configs:
# The job name is added as a label `job=` to any timeseries scraped from this config.
- job_name: 'prometheus'
# Override the global default and scrape targets from this job every 5 seconds.
scrape_interval: 5s
static_configs:
- targets: ['localhost:9090']
# Example job for node_exporter
# - job_name: 'node_exporter'
# static_configs:
# - targets: ['node_exporter:9100']
# Example job for cadvisor
# - job_name: 'cadvisor'
# static_configs:
# - targets: ['cadvisor:8080']
================================================
FILE: library/compose/prometheus/template.yaml
================================================
kind: compose
metadata:
icon:
provider: selfh
id: prometheus
name: Prometheus
description: 'Prometheus is an open-source systems monitoring and alerting toolkit
originally built at SoundCloud.
It is designed for reliability and scalability, making it suitable for monitoring
dynamic cloud environments.
Prometheus collects and stores metrics as time series data, providing powerful
querying capabilities and integration with various visualization tools.
## Swarm Deployment Warning
Prometheus uses local TSDB storage and does NOT support running multiple replicas.
This template enforces a single replica with node placement constraints. For true
HA, consider remote storage solutions (Thanos, Cortex, VictoriaMetrics).
Project: https://prometheus.io/
Documentation: https://prometheus.io/docs/
GitHub: https://github.com/prometheus/prometheus
'
version: v3.9.1
author: Christian Lempa
date: '2026-01-07'
tags:
- traefik
- swarm
- authentik
next_steps: "{% if swarm_enabled -%}\n1. Deploy to Docker Swarm:\n docker stack\
\ deploy -c compose.yaml {{ service_name }}\n2. Access Prometheus:\n {%- if\
\ traefik_enabled %} https://{{ traefik_host }}\n {%- else %} http://:{{\
\ ports_http }}{%- endif %}\n{% else -%}\n1. Start Prometheus with Docker Compose:\n\
\ docker compose up -d\n2. Access Prometheus:\n {%- if traefik_enabled %}\
\ https://{{ traefik_host }}\n {%- else %} http://localhost:{{ ports_http }}{%-\
\ endif %}\n{% endif -%}\n3. Edit config/prometheus.yaml to add scrape targets\n\
4. Reload configuration: docker exec {{ container_name if not swarm_enabled else\
\ service_name }} kill -HUP 1\n"
schema: '1.2'
spec:
general:
vars:
service_name:
default: prometheus
restart_policy:
type: enum
options:
- unless-stopped
- always
- on-failure
- 'no'
default: unless-stopped
required: true
metrics:
title: Metrics & Storage
description: Configure data retention and storage settings
vars:
metrics_retention_time:
type: str
description: How long to retain samples (e.g., 15d, 30d, 1y)
default: 15d
extra: Older data will be deleted. Use 'h', 'd', 'w', 'y' for time units.
metrics_retention_size:
type: str
description: Maximum storage size (e.g., 5GB, 10GB, 1TB)
default: '0'
extra: Set to 0 for unlimited. Triggers deletion when exceeded.
metrics_enable_remote_write:
type: bool
description: Enable remote write receiver (allows pushing metrics via /api/v1/write)
default: false
extra: 'Caution: Intended for low-volume use cases only. Not efficient for
general ingestion.'
ports:
vars:
ports_http:
default: 9090
traefik:
vars:
traefik_host:
default: prometheus
traefik_network:
default: traefik
type: str
required: true
traefik_domain:
default: home.arpa
type: str
required: true
traefik_enabled:
type: bool
default: false
description: Enable Traefik integration
toggle: traefik_enabled
title: Traefik
description: Configure Traefik reverse proxy integration
traefik_tls:
vars:
traefik_tls_certresolver:
type: str
default: cloudflare
required: true
traefik_tls_enabled:
type: bool
default: false
description: Enable Traefik TLS
toggle: traefik_tls_enabled
title: Traefik TLS
description: Configure Traefik TLS/SSL certificates
needs: traefik
================================================
FILE: library/compose/renovate/compose.yaml.j2
================================================
---
services:
{{ service_name }}:
image: ghcr.io/mend/renovate-ce:13.6.0-full
{#
If not in swarm mode, apply container name and restart policy
#}
{% if not swarm_enabled %}
container_name: {{ container_name }}
restart: {{ restart_policy }}
{% endif %}
{#
Set container hostname for identification
#}
hostname: {{ container_hostname }}
{#
When traefik is enabled, add traefik network for reverse proxy access
#}
{% if traefik_enabled %}
networks:
{{ traefik_network }}:
{% endif %}
{#
Port mappings for web interface (only when Traefik is disabled)
#}
{% if not traefik_enabled %}
ports:
- "{{ ports_http }}:8080"
{% endif %}
{#
Environment file containing Renovate configuration
#}
env_file:
- ./.env
{#
When swarm_enabled is set, use Docker secrets for sensitive data
#}
{% if swarm_enabled %}
secrets:
- source: {{ service_name }}_license_key
target: /run/secrets/{{ service_name }}_license_key
mode: 0400
- source: {{ service_name }}_git_token
target: /run/secrets/{{ service_name }}_git_token
mode: 0400
{% if webhook_secret %}
- source: {{ service_name }}_webhook_secret
target: /run/secrets/{{ service_name }}_webhook_secret
mode: 0400
{% endif %}
{#
Deploy configuration for Swarm mode:
- Configure replicas, placement constraints
- Traefik: Labels for reverse proxy integration (Swarm mode)
#}
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 %}
{#
When traefik_enabled is set in swarm mode, add traefik labels
(optionally enable TLS if traefik_tls_enabled is set)
#}
{% if traefik_enabled %}
labels:
- traefik.enable=true
- traefik.http.services.{{ service_name }}.loadbalancer.server.port=8080
- traefik.http.routers.{{ service_name }}-http.rule=Host(`{{ traefik_host }}.{{ traefik_domain }}`)
- traefik.http.routers.{{ service_name }}-http.entrypoints={{ traefik_entrypoint }}
{% if traefik_tls_enabled %}
- traefik.http.routers.{{ service_name }}-https.rule=Host(`{{ traefik_host }}.{{ traefik_domain }}`)
- 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 %}
{% else %}
{#
When traefik_enabled is set, and not running in swarm mode, add traefik labels
(optionally enable TLS if traefik_tls_enabled is set)
#}
{% if traefik_enabled %}
labels:
- traefik.enable=true
- traefik.http.services.{{ service_name }}.loadbalancer.server.port=8080
- traefik.http.routers.{{ service_name }}-http.rule=Host(`{{ traefik_host }}.{{ traefik_domain }}`)
- traefik.http.routers.{{ service_name }}-http.entrypoints={{ traefik_entrypoint }}
{% if traefik_tls_enabled %}
- traefik.http.routers.{{ service_name }}-https.rule=Host(`{{ traefik_host }}.{{ traefik_domain }}`)
- 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 %}
{% endif %}
{#
Health check: Verify Renovate service is responding
#}
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
{#
Docker Swarm secrets (only when swarm_enabled is set):
- License key, Git token, and webhook secret
#}
{% if swarm_enabled %}
secrets:
{{ service_name }}_license_key:
file: ./.env.secret.license
{{ service_name }}_git_token:
file: ./.env.secret.token
{% if webhook_secret %}
{{ service_name }}_webhook_secret:
file: ./.env.secret.webhook
{% endif %}
{% endif %}
{#
Network definitions (only when Traefik is enabled):
- Traefik network: always external (managed by Traefik)
#}
{% if traefik_enabled %}
networks:
{{ traefik_network }}:
external: true
{% endif %}
================================================
FILE: library/compose/renovate/template.yaml
================================================
---
kind: compose
metadata:
icon:
provider: selfh
id: mend-renovate
name: Renovate
description: |-
**Renovate** is an automated dependency update tool that creates pull requests for newer versions of dependencies.
Supports GitHub, GitLab, Bitbucket, Gitea, and more platforms with flexible configuration options.
## Use Cases
- Automated dependency updates for all your repositories
- Security vulnerability patching
- Multi-platform support (GitLab, GitHub, Bitbucket, etc.)
- Customizable update schedules and rules
- Self-hosted or cloud-based deployment
## Resources
- **Project**: https://www.mend.io/renovate/
- **Documentation**: https://docs.renovatebot.com/
- **GitHub**: https://github.com/renovatebot/renovate
version: 13.6.0-full
author: Christian Lempa
date: '2026-02-13'
tags:
- traefik
- swarm
draft: true
schema: "1.2"
spec:
general:
vars:
service_name:
default: "renovate"
restart_policy:
type: enum
options: ["unless-stopped", "always", "on-failure", "no"]
default: "unless-stopped"
container_name:
default: "renovate"
container_hostname:
default: "renovate"
container_timezone:
default: "UTC"
container_loglevel:
type: enum
options: [debug, info, warn, error]
renovate_settings:
title: "Renovate Settings"
required: true
vars:
renovate_platform:
type: "enum"
description: "Git Platform Type"
options:
- "gitlab"
- "github"
- "gitea"
default: "gitlab"
renovate_endpoint:
type: "url"
description: "Git Platform Endpoint URL"
default: "https://gitlab.com"
extra: "e.g., https://gitlab.com, https://github.com, https://selfhosted.home.arpa, ..."
renovate_autodiscover:
type: "bool"
description: "Auto-discover repositories"
default: false
extra: "Automatically find and process all accessible repositories"
authentication:
title: "Authentication"
required: true
vars:
git_token:
type: "str"
description: "Git platform Personal Access Token"
sensitive: true
extra: "Also used for public package lookups to avoid rate limiting"
license_key:
type: "str"
description: "Mend Renovate CE License Key"
sensitive: true
extra: "Get a FREE license key at https://www.mend.io/mend-renovate-community/#self-hosted"
webhook_secret:
type: "str"
description: "Webhook secret for platform integration"
sensitive: true
optional: true
default: "renovate"
ports:
vars:
ports_http:
type: "int"
description: "External HTTP port for web interface"
default: 8080
traefik:
title: Traefik
toggle: traefik_enabled
description: Configure Traefik reverse proxy integration
vars:
traefik_enabled:
type: bool
default: false
traefik_network:
default: "traefik"
traefik_host:
default: "renovate"
traefik_domain:
default: "home.arpa"
traefik_entrypoint:
default: "web"
type: bool
network:
vars:
network_mode:
type: enum
options: ["bridge", "host", "macvlan"]
default: "bridge"
network_name:
default: "bridge"
swarm:
toggle: swarm_enabled
vars:
swarm_enabled:
type: bool
default: false
swarm_placement_mode:
type: enum
options: ["replicated", "global"]
default: "replicated"
swarm_replicas:
type: int
default: 1
needs: "swarm_placement_mode=replicated"
swarm_placement_host:
default: ""
needs: "swarm_placement_mode=replicated"
================================================
FILE: library/compose/semaphoreui/compose.yaml.j2
================================================
services:
{{ service_name }}:
image: docker.io/semaphoreui/semaphore:v2.17.2
restart: {{ restart_policy }}
environment:
- SEMAPHORE_DB_DIALECT={{ database_type }}
{% if database_external %}
- SEMAPHORE_DB_HOST={{ database_host }}
{% else %}
- SEMAPHORE_DB_HOST={{ service_name }}_db
{% endif %}
- SEMAPHORE_DB={{ database_name }}
- SEMAPHORE_DB_USER={{ database_user }}
- SEMAPHORE_DB_PASS=${DATABASE_PASSWORD}
- SEMAPHORE_ADMIN={{ admin_user }}
- SEMAPHORE_ADMIN_NAME={{ admin_name }}
- SEMAPHORE_ADMIN_EMAIL={{ admin_email }}
- SEMAPHORE_ADMIN_PASSWORD=${SEMAPHORE_ADMIN_PASSWORD}
- SEMAPHORE_PLAYBOOK_PATH=/tmp/semaphore/
- SEMAPHORE_ACCESS_KEY_ENCRYPTION=${SEMAPHORE_ACCESS_KEY_ENCRYPTION}
- ANSIBLE_HOST_KEY_CHECKING={{ ansible_host_key_checking }}
{% if not database_external or traefik_enabled %}
networks:
{% if not database_external %}
- {{ service_name }}_backend
{% endif %}
{% if traefik_enabled %}
- {{ traefik_network }}
{% endif %}
{% endif %}
{% if not traefik_enabled %}
ports:
- "{{ ports_http }}:3000"
{% endif %}
{% if traefik_enabled %}
labels:
- traefik.enable=true
- traefik.docker.network={{ traefik_network }}
- traefik.http.services.{{ service_name }}-web.loadBalancer.server.port=3000
- traefik.http.routers.{{ service_name }}-http.service={{ service_name }}-web
- traefik.http.routers.{{ service_name }}-http.rule=Host(`{{ traefik_host }}.{{ traefik_domain }}`)
- traefik.http.routers.{{ service_name }}-http.entrypoints=web
{% 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_domain }}`)
- traefik.http.routers.{{ service_name }}-https.entrypoints=websecure
- traefik.http.routers.{{ service_name }}-https.tls=true
- traefik.http.routers.{{ service_name }}-https.tls.certresolver={{ traefik_tls_certresolver }}
{% endif %}
{% endif %}
{% if not database_external %}
depends_on:
- {{ service_name }}_db
{% endif %}
{% if not database_external and database_type == "postgres" %}
{{ service_name }}_db:
image: docker.io/library/postgres:17.8
restart: {{ restart_policy }}
environment:
- POSTGRES_USER={{ database_user }}
- POSTGRES_PASSWORD=${DATABASE_PASSWORD}
- POSTGRES_DB={{ database_name }}
networks:
- {{ service_name }}_backend
healthcheck:
test: ["CMD-SHELL", "pg_isready -U {{ database_user }}"]
start_period: 30s
interval: 10s
timeout: 10s
retries: 5
volumes:
- {{ service_name }}_db:/var/lib/postgresql/data
{% elif not database_external and database_type == "mysql" %}
{{ service_name }}_db:
image: docker.io/library/mysql:8.1
restart: {{ restart_policy }}
environment:
- MYSQL_USER={{ database_user }}
- MYSQL_PASSWORD=${DATABASE_PASSWORD}
- MYSQL_DATABASE={{ database_name }}
- MYSQL_ROOT_PASSWORD=${DATABASE_PASSWORD}
networks:
- {{ service_name }}_backend
volumes:
- {{ service_name }}_db:/var/lib/mysql
{% endif %}
{% if not database_external %}
volumes:
{{ service_name }}_db:
driver: local
{% endif %}
{% if not database_external or traefik_enabled %}
networks:
{% if not database_external %}
{{ service_name }}_backend:
driver: bridge
{% endif %}
{% if traefik_enabled %}
{{ traefik_network }}:
external: true
{% endif %}
{% endif %}
================================================
FILE: library/compose/semaphoreui/template.yaml
================================================
kind: compose
metadata:
icon:
provider: selfh
id: semaphore-ui
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
inventories, and schedule automated tasks. Perfect for teams who want a
user-friendly way to execute and monitor Ansible automation.
## Prerequisites
- :info: SemaphoreUI supports multiple database backends. You can choose between
SQLite (default),
PostgreSQL, or MySQL. SQLite is suitable for small deployments, while PostgreSQL
and MySQL
are recommended for larger installations.
## References
- **Project:** https://www.semaphoreui.com/
- **Documentation:** https://docs.semaphoreui.com/
- **GitHub:** https://github.com/semaphoreui/semaphore'
version: v2.17.2
author: Christian Lempa
date: '2026-02-15'
tags:
- traefik
- database
next_steps: 'Log in with your initial admin user:
```bash
Username: {{ admin_user }}
Password: {{ admin_pass }}
```'
schema: '1.2'
spec:
general:
vars:
service_name:
default: semaphoreui
container_name:
default: semaphoreui
restart_policy:
type: enum
options:
- unless-stopped
- always
- on-failure
- 'no'
default: unless-stopped
required: true
secret_key:
description: Secret key for encrypting access keys
type: str
sensitive: true
autogenerated: true
required: true
admin_user:
description: Administrator username
type: str
required: true
default: admin
admin_name:
description: Administrator full name
type: str
required: true
default: Administrator
admin_email:
description: Administrator email address
type: str
required: true
default: admin@home.arpa
admin_pass:
description: Administrator password
type: str
sensitive: true
autogenerated: true
required: true
ansible_host_key_checking:
description: Enable Ansible SSH host key checking
type: bool
ports:
vars:
ports_http:
default: 3000
traefik:
vars:
traefik_host:
default: semaphoreui
traefik_network:
default: traefik
type: str
required: true
traefik_domain:
default: home.arpa
type: str
required: true
traefik_enabled:
type: bool
default: false
description: Enable Traefik integration
toggle: traefik_enabled
title: Traefik
description: Configure Traefik reverse proxy integration
traefik_tls:
vars:
traefik_tls_certresolver:
type: str
default: cloudflare
required: true
traefik_tls_enabled:
type: bool
default: false
description: Enable Traefik TLS
toggle: traefik_tls_enabled
title: Traefik TLS
description: Configure Traefik TLS/SSL certificates
needs: traefik
database:
vars:
database_type:
options:
- postgres
- mysql
default: mysql
database_name:
default: semaphore
database_user:
default: semaphore
database_host:
type: str
default: postgres
required: true
needs:
- database_external=true
database_password:
type: str
sensitive: true
autogenerated: true
required: true
database_external:
type: bool
default: false
description: Use external database
================================================
FILE: library/compose/traefik/compose.yaml.j2
================================================
---
services:
{{ service_name }}:
image: docker.io/library/traefik:v3.6.8
{% if not swarm_enabled %}
{% if container_name %}
container_name: {{ container_name }}
{% endif %}
security_opt:
- no-new-privileges:true
{% endif %}
{% if container_hostname %}
hostname: {{ container_hostname }}
{% endif %}
command:
- "--global.checkNewVersion=false"
- "--global.sendAnonymousUsage=false"
{% if container_loglevel %}
- "--log.level={{ container_loglevel }}"
{% endif %}
- "--ping=true"
- "--ping.entryPoint=ping"
{% if accesslog_enabled %}
- "--accesslog=true"
{% endif %}
{% if dashboard_enabled %}
- "--api.dashboard=true"
- "--api.insecure=true"
{% endif %}
{% if prometheus_enabled %}
- "--metrics.prometheus=true"
- "--metrics.prometheus.entryPoint=metrics"
- "--metrics.prometheus.addRoutersLabels=true"
{% endif %}
- "--entrypoints.ping.address=:8082"
{% if prometheus_enabled %}
- "--entrypoints.metrics.address=:9090"
{% endif %}
- "--entrypoints.web.address=:80"
- "--entrypoints.web.http.encodedCharacters.allowEncodedSlash=true"
{% if traefik_tls_enabled and traefik_tls_redirect %}
- "--entrypoints.web.http.redirections.entryPoint.to=websecure"
- "--entrypoints.web.http.redirections.entryPoint.scheme=https"
{% endif %}
{% if traefik_tls_enabled %}
- "--entrypoints.websecure.address=:443"
- "--entrypoints.websecure.http.encodedCharacters.allowEncodedSlash=true"
- "--certificatesresolvers.{{ traefik_tls_certresolver }}.acme.email={{ traefik_tls_acme_email }}"
- "--certificatesresolvers.{{ traefik_tls_certresolver }}.acme.caServer=https://acme-v02.api.letsencrypt.org/directory"
- "--certificatesresolvers.{{ traefik_tls_certresolver }}.acme.dnsChallenge.provider={{ traefik_tls_certresolver }}"
- "--certificatesresolvers.{{ traefik_tls_certresolver }}.acme.dnsChallenge.resolvers=1.1.1.1:53,8.8.8.8:53"
{% endif %}
{% if traefik_tls_secure_ciphers %}
- "--tls.options.default.cipherSuites=TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305_SHA256,TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256"
{% endif %}
{% if traefik_tls_skipverify %}
- "--serversTransport.insecureSkipVerify=true"
{% endif %}
{% if swarm_enabled %}
- "--providers.swarm.endpoint=unix:///var/run/docker.sock"
- "--providers.swarm.exposedByDefault=false"
- "--providers.swarm.network={{ traefik_network }}"
{% else %}
- "--providers.docker=true"
- "--providers.docker.exposedByDefault=false"
- "--providers.docker.network={{ traefik_network }}"
{% endif %}
ports:
- "{{ ports_http }}:80"
- "{{ ports_https }}:443"
{% if dashboard_enabled %}
- "{{ ports_dashboard }}:8080"
{% endif %}
{#
We always need volumes, because of the docker socket and certs storage
- certs storage for ACME
- docker socket for automatic service discovery
- Traefik configuration in non-swarm mode
#}
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
{% if volume_mode == 'mount' %}
- {{ volume_mount_path }}:/var/traefik/certs/:rw
{% elif volume_mode == 'local' or volume_mode == 'nfs' %}
- {{ service_name }}_certs:/var/traefik/certs/:rw
{% endif %}
environment:
- TZ={{ container_timezone }}
{% if traefik_tls_enabled %}
{% if traefik_tls_certresolver == 'cloudflare' %}
{% if swarm_enabled %}
- CF_DNS_API_TOKEN_FILE=/run/secrets/{{ service_name }}_token
{% else %}
- CF_DNS_API_TOKEN=${CF_DNS_API_TOKEN}
{% endif %}
{% elif traefik_tls_certresolver == 'porkbun' %}
{% if swarm_enabled %}
- PORKBUN_API_KEY_FILE=/run/secrets/{{ service_name }}_token
- PORKBUN_SECRET_API_KEY_FILE=/run/secrets/{{ service_name }}_token_key
{% else %}
- PORKBUN_API_KEY=${PORKBUN_API_KEY}
- PORKBUN_SECRET_API_KEY=${PORKBUN_SECRET_API_KEY}
{% endif %}
{% elif traefik_tls_certresolver == 'route53' %}
{% if swarm_enabled %}
- AWS_ACCESS_KEY_ID_FILE=/run/secrets/{{ service_name }}_token
- AWS_SECRET_ACCESS_KEY_FILE=/run/secrets/{{ service_name }}_token_key
{% else %}
- AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}
- AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}
{% endif %}
- AWS_REGION={{ traefik_tls_acme_region }}
{% elif traefik_tls_certresolver == 'digitalocean' %}
{% if swarm_enabled %}
- DO_AUTH_TOKEN_FILE=/run/secrets/{{ service_name }}_token
{% else %}
- DO_AUTH_TOKEN=${DO_AUTH_TOKEN}
{% endif %}
{% elif traefik_tls_certresolver == 'godaddy' %}
{% if swarm_enabled %}
- GODADDY_API_KEY_FILE=/run/secrets/{{ service_name }}_token
- GODADDY_API_SECRET_FILE=/run/secrets/{{ service_name }}_token_key
{% else %}
- GODADDY_API_KEY=${GODADDY_API_KEY}
- GODADDY_API_SECRET=${GODADDY_API_SECRET}
{% endif %}
{% elif traefik_tls_certresolver == 'azure' %}
{% if swarm_enabled %}
- AZURE_CLIENT_ID_FILE=/run/secrets/{{ service_name }}_token
- AZURE_CLIENT_SECRET_FILE=/run/secrets/{{ service_name }}_token_key
{% else %}
- AZURE_CLIENT_ID=${AZURE_CLIENT_ID}
- AZURE_CLIENT_SECRET=${AZURE_CLIENT_SECRET}
{% endif %}
- AZURE_TENANT_ID={{ traefik_tls_acme_tenant_id }}
- AZURE_SUBSCRIPTION_ID={{ traefik_tls_acme_subscription_id }}
- AZURE_RESOURCE_GROUP={{ traefik_tls_acme_resource_group }}
{% elif traefik_tls_certresolver == 'namecheap' %}
{% if swarm_enabled %}
- NAMECHEAP_API_KEY_FILE=/run/secrets/{{ service_name }}_token
{% else %}
- NAMECHEAP_API_KEY=${NAMECHEAP_API_KEY}
{% endif %}
- NAMECHEAP_API_USER={{ traefik_tls_acme_username }}
{% elif traefik_tls_certresolver == 'ovh' %}
- OVH_ENDPOINT={{ traefik_tls_acme_endpoint | default('ovh-eu') }}
{% if swarm_enabled %}
- OVH_APPLICATION_KEY_FILE=/run/secrets/{{ service_name }}_token
- OVH_APPLICATION_SECRET_FILE=/run/secrets/{{ service_name }}_token_key
- OVH_CONSUMER_KEY_FILE=/run/secrets/{{ service_name }}_consumer_key
{% else %}
- OVH_APPLICATION_KEY=${OVH_APPLICATION_KEY}
- OVH_APPLICATION_SECRET=${OVH_APPLICATION_SECRET}
- OVH_CONSUMER_KEY=${OVH_CONSUMER_KEY}
{% endif %}
{% endif %}
{% endif %}
healthcheck:
test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:8082/ping"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
networks:
- {{ traefik_network }}
{% if swarm_enabled %}
{% if traefik_tls_enabled %}
secrets:
- {{ service_name }}_token
{% if traefik_tls_acme_secret_key %}
- {{ service_name }}_token_key
{% endif %}
{% if traefik_tls_certresolver == 'ovh' %}
- {{ service_name }}_consumer_key
{% endif %}
{% endif %}
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 %}
{% else %}
restart: {{ restart_policy }}
{% endif %}
{#
If Traefik TLS is enabled in swarm mode, define the necessary secrets for ACME DNS challenge.
#}
{% if swarm_enabled and traefik_tls_enabled %}
secrets:
{{ service_name }}_token:
file: ./.env.secret.token
{% if traefik_tls_acme_secret_key %}
{{ service_name }}_token_key:
file: ./.env.secret.token_key
{% endif %}
{% if traefik_tls_certresolver == 'ovh' %}
{{ service_name }}_consumer_key:
file: ./.env.secret.consumer_key
{% endif %}
{% endif %}
{#
Always define the traefik network, but if it's not external, set it up according to
swarm mode or bridge mode.
#}
networks:
{{ traefik_network }}:
{% if traefik_network_external %}
external: true
{% else %}
{% if swarm_enabled %}
driver: overlay
attachable: true
{% else %}
driver: bridge
{% endif %}
name: {{ traefik_network }}
{% endif %}
{#
Always define volumes for certs based on volume_mode
#}
{% if volume_mode == 'local' %}
volumes:
{{ service_name }}_certs:
driver: local
{% elif volume_mode == 'nfs' %}
volumes:
{{ service_name }}_certs:
driver: local
driver_opts:
type: nfs
o: addr={{ volume_nfs_server }},nfsvers=4,{{ volume_nfs_options }}
device: ":{{ volume_nfs_path }}"
{% endif %}
================================================
FILE: library/compose/traefik/template.yaml
================================================
kind: compose
metadata:
name: Traefik
description: 'Traefik is a modern HTTP reverse proxy and load balancer that makes
deploying microservices easy.
This template sets up Traefik with automatic HTTPS using Let''s Encrypt and can
be integrated with Authentik for SSO.
## References
- **Project:** https://traefik.io/
- **Documentation:** https://doc.traefik.io/traefik/
- **GitHub:** https://github.com/traefik/traefik'
version: v3.6.8
author: Christian Lempa
date: '2026-02-11'
tags:
- swarm
- volume
icon:
provider: simpleicons
id: traefikproxy
draft: false
next_steps: "Start the `{{ service_name }}` project\n{% if swarm_enabled %}\n1.\
\ Deploy Traefik to Docker Swarm:\n `docker stack deploy -c compose.yaml {{ service_name\
\ }}`\n{% else %}\n1. Copy the project directory for `{{ service_name }}` to the\
\ host.\n2. Start Traefik with Docker Compose from the project directory:\n `docker\
\ compose up -d`\n{% endif %}"
schema: '1.2'
spec:
general:
vars:
service_name:
default: traefik
container_name:
type: str
container_hostname:
type: str
container_timezone:
type: str
container_loglevel:
type: enum
options:
- debug
- info
- warn
- error
restart_policy:
type: enum
options:
- unless-stopped
- always
- on-failure
- 'no'
default: unless-stopped
required: true
ports:
vars:
ports_dashboard:
description: Dashboard port (external)
type: int
default: 8080
required: true
needs:
- dashboard_enabled=true
extra: Only used when dashboard is enabled
ports_http:
default: 80
extra: Maps to entrypoint 'web'
ports_https:
default: 443
extra: Maps to entrypoint 'websecure'
traefik:
title: Settings
vars:
accesslog_enabled:
description: Enable Traefik access log
type: bool
default: false
dashboard_enabled:
description: Enable Traefik dashboard
type: bool
default: false
extra: 'WARNING: Don''t use in production!'
prometheus_enabled:
description: Enable Prometheus metrics
type: bool
default: false
security_enabled:
description: Create production-ready security headers middleware
type: bool
default: true
extra: Enables HSTS, XSS protection, frame denial, etc.
traefik_network:
extra: Network that Traefik uses to connect to services
traefik_network_external:
description: Use existing Docker network (external)
type: bool
default: false
toggle: dashboard_enabled
description: Configure Traefik features and settings
traefik_tls:
title: TLS Settings
toggle: traefik_tls_enabled
vars:
traefik_tls_enabled:
description: Enable HTTPS/TLS with ACME
type: bool
default: false
traefik_tls_certresolver:
description: ACME DNS challenge provider
type: str
options:
- cloudflare
- porkbun
- godaddy
- digitalocean
- route53
- azure
- namecheap
- ovh
default: cloudflare
required: true
needs:
- traefik_tls_enabled=true
extra: DNS provider for domain validation
traefik_tls_acme_email:
description: Email address for ACME
type: str
required: true
needs:
- traefik_tls_enabled=true
traefik_tls_acme_endpoint:
description: OVH API endpoint
type: str
default: ovh-eu
required: false
needs:
- traefik_tls_enabled=true
- traefik_tls_certresolver=ovh
extra: Common values are ovh-eu, ovh-ca, ovh-us
traefik_tls_acme_region:
description: AWS Region
type: str
default: us-east-1
required: true
needs:
- traefik_tls_enabled=true
- traefik_tls_certresolver=route53
traefik_tls_acme_resource_group:
description: Azure Resource Group
type: str
required: true
needs:
- traefik_tls_enabled=true
- traefik_tls_certresolver=azure
traefik_tls_acme_secret_key:
description: DNS provider secret key
type: str
sensitive: true
required: true
needs:
- traefik_tls_enabled=true
- traefik_tls_certresolver=azure,godaddy,ovh,porkbun,route53
extra: AZURE_CLIENT_SECRET, GODADDY_API_SECRET, OVH_APPLICATION_SECRET, PORKBUN_SECRET_API_KEY, or AWS_SECRET_ACCESS_KEY
traefik_tls_acme_subscription_id:
description: Azure Subscription ID
type: str
required: true
needs:
- traefik_tls_enabled=true
- traefik_tls_certresolver=azure
traefik_tls_acme_tenant_id:
description: Azure Tenant ID
type: str
required: true
needs:
- traefik_tls_enabled=true
- traefik_tls_certresolver=azure
traefik_tls_acme_token:
description: DNS provider API token
type: str
sensitive: true
required: true
needs:
- traefik_tls_enabled=true
- traefik_tls_certresolver=cloudflare,digitalocean,godaddy,namecheap,ovh,porkbun
extra: CF_DNS_API_TOKEN, DO_AUTH_TOKEN, GODADDY_API_KEY, NAMECHEAP_API_KEY, OVH_APPLICATION_KEY, or PORKBUN_API_KEY
traefik_tls_acme_username:
description: Namecheap API username
type: str
required: true
needs:
- traefik_tls_enabled=true
- traefik_tls_certresolver=namecheap
traefik_tls_acme_consumer_key:
description: OVH Consumer Key
type: str
sensitive: true
required: true
needs:
- traefik_tls_enabled=true
- traefik_tls_certresolver=ovh
traefik_tls_redirect:
description: Redirect all HTTP traffic to HTTPS
type: bool
default: true
needs:
- traefik_tls_enabled=true
traefik_tls_secure_ciphers:
description: Enable strict cipher suites (recommended)
type: bool
default: false
needs:
- traefik_tls_enabled=true
extra: Enforces modern, secure cipher suites
traefik_tls_skipverify:
description: Skip TLS verification for backend servers
type: bool
default: false
needs:
- traefik_tls_enabled=true
extra: 'WARNING: Only enable for self-signed certificates in trusted environments'
volume:
vars:
volume_mode:
type: enum
options:
- local
- mount
- nfs
default: local
required: true
volume_mount_path:
type: str
default: /mnt/storage
needs:
- volume_mode=mount
required: true
volume_nfs_server:
type: str
default: 192.168.1.1
needs:
- volume_mode=nfs
required: true
volume_nfs_path:
type: str
default: /export
needs:
- volume_mode=nfs
required: true
volume_nfs_options:
type: str
default: rw,nolock,soft
needs:
- volume_mode=nfs
required: true
swarm:
title: Docker Swarm
toggle: swarm_enabled
vars:
swarm_placement_mode:
type: enum
options:
- replicated
- global
default: replicated
required: true
swarm_replicas:
type: int
default: 1
needs:
- swarm_placement_mode=replicated
required: true
swarm_placement_host:
type: str
description: Target hostname for placement constraint
default: ''
needs:
- swarm_placement_mode=replicated
extra: Constrains service to run on specific node by hostname
swarm_enabled:
type: bool
default: false
description: Enable Docker Swarm mode
================================================
FILE: library/compose/twingate-connector/compose.yaml.j2
================================================
services:
{{ service_name }}:
image: docker.io/twingate/connector:{{ twingate_version }}
{#
If not in swarm mode, apply restart policy and container_name,
else swarm mode handles restarts via deploy.restart_policy
#}
{% if not swarm_enabled %}
restart: {{ restart_policy }}
container_name: {{ container_name }}
{% endif %}
{#
Set container hostname (Twingate connector uses this for identification)
#}
hostname: {{ container_hostname }}
{#
Environment variables for Twingate Connector configuration
- TZ: Timezone
- TWINGATE_NETWORK: Your Twingate network name
- TWINGATE_ACCESS_TOKEN: Access token (from env or secret)
- TWINGATE_REFRESH_TOKEN: Refresh token (from env or secret)
- TWINGATE_LOG_LEVEL: Log verbosity level
- TWINGATE_DNS: Optional local DNS server override
#}
environment:
- TZ={{ container_timezone }}
- TWINGATE_NETWORK={{ twingate_network }}
{% if swarm_enabled %}
- TWINGATE_ACCESS_TOKEN=/run/secrets/twingate_access_token
- TWINGATE_REFRESH_TOKEN=/run/secrets/twingate_refresh_token
{% else %}
- TWINGATE_ACCESS_TOKEN=${TWINGATE_ACCESS_TOKEN:?error}
- TWINGATE_REFRESH_TOKEN=${TWINGATE_REFRESH_TOKEN:?error}
{% endif %}
- TWINGATE_LOG_LEVEL={{ twingate_log_level }}
{% if twingate_dns %}
- TWINGATE_DNS={{ twingate_dns }}
{% endif %}
{#
Required sysctls for Twingate connector networking
#}
sysctls:
net.ipv4.ping_group_range: "0 2147483647"
{#
Deploy configuration for Swarm mode:
- Supports both replicated and global deployment modes
- Uses Docker secrets for sensitive credentials
- Optional resource limits and reservations
#}
{% if swarm_enabled %}
secrets:
- twingate_access_token
- twingate_refresh_token
deploy:
{% if swarm_placement_mode == 'replicated' %}
replicas: {{ swarm_replicas }}
placement:
constraints:
- node.hostname == {{ swarm_placement_host }}
{% else %}
mode: global
{% endif %}
restart_policy:
condition: on-failure
{% if resources_enabled %}
resources:
limits:
cpus: '{{ resources_cpu_limit }}'
memory: {{ resources_memory_limit }}
reservations:
cpus: '{{ resources_cpu_reservation }}'
memory: {{ resources_memory_reservation }}
{% endif %}
{% endif %}
{#
Docker Swarm secrets (external secrets managed via docker secret create)
#}
{% if swarm_enabled %}
secrets:
twingate_access_token:
external: true
twingate_refresh_token:
external: true
{% endif %}
================================================
FILE: library/compose/twingate-connector/template.yaml
================================================
kind: compose
metadata:
icon:
provider: selfh
id: twingate
name: Twingate_Connector
description: 'The Twingate Connector is a lightweight software component that establishes
secure connections between your private network and the Twingate service. It acts
as a bridge, allowing authorized users to access internal resources without exposing
them directly to the internet. The Connector uses strong encryption and authentication
mechanisms to ensure that all data transmitted between users and resources remains
confidential and secure.
Project: https://www.twingate.com/
Documentation: https://docs.twingate.com/docs/architecture/connectors
GitHub: https://github.com/twingate/twingate-connector
'
version: 1.80.0
author: Christian Lempa
date: '2025-11-11'
tags:
- swarm
draft: true
schema: '1.2'
spec:
general:
vars:
service_name:
default: twingate
container_name:
default: twingate_connector
container_hostname:
default: twingate_connector
container_timezone:
type: str
restart_policy:
type: enum
options:
- unless-stopped
- always
- on-failure
- 'no'
default: unless-stopped
required: true
twingate_version:
type: str
description: Twingate Connector version
default: 1.79.0
twingate:
title: Twingate Configuration
required: true
vars:
twingate_network:
type: str
description: Your Twingate network name
prompt: Enter your Twingate network name
twingate_log_level:
type: int
description: Log level (1=ERROR, 2=WARN, 3=INFO, 4=DEBUG)
default: 1
twingate_dns:
type: str
description: Local DNS server IP (optional, leave empty to use default)
default: ''
resources:
vars:
resources_enabled:
type: bool
default: false
resources_cpu_limit:
type: str
default: 1.0
required: true
resources_cpu_reservation:
type: str
default: 0.25
needs:
- swarm_enabled=true
required: true
resources_memory_limit:
type: str
default: 1G
required: true
resources_memory_reservation:
type: str
default: 512M
needs:
- swarm_enabled=true
required: true
toggle: resources_enabled
title: Resource Limits
description: Configure container resource limits
swarm: null
================================================
FILE: library/compose/uptimekuma/compose.yaml.j2
================================================
---
services:
{{ service_name }}:
image: docker.io/louislam/uptime-kuma:2.1.3
environment:
- TZ={{ container_timezone }}
{% if database_type == 'sqlite' %}
- UPTIME_KUMA_DB_TYPE=sqlite
{% elif database_type == 'mariadb' %}
- UPTIME_KUMA_DB_TYPE=mariadb
{% if database_external %}
- UPTIME_KUMA_DB_HOSTNAME={{ database_host }}
{% else %}
- UPTIME_KUMA_DB_HOSTNAME={{ service_name }}_db
{% endif %}
- UPTIME_KUMA_DB_PORT={{ database_port }}
- UPTIME_KUMA_DB_USERNAME={{ database_user }}
- UPTIME_KUMA_DB_PASSWORD=${DATABASE_PASSWORD}
- UPTIME_KUMA_DB_NAME={{ database_name }}
{% endif %}
{% if (not database_external and database_type == 'mariadb') or traefik_enabled %}
networks:
{% if not database_external and database_type == 'mariadb' %}
- {{ service_name }}_backend
{% endif %}
{% if traefik_enabled %}
- {{ traefik_network }}
{% endif %}
{% endif %}
{% if traefik_enabled %}
labels:
- traefik.enable=true
- traefik.docker.network={{ traefik_network }}
- traefik.http.services.{{ service_name }}_web.loadBalancer.server.port=9000
- traefik.http.routers.{{ service_name }}_http.service={{ service_name }}_web
- traefik.http.routers.{{ service_name }}_http.rule=Host(`{{ traefik_host }}.{{ traefik_domain }}`)
- traefik.http.routers.{{ service_name }}_http.entrypoints=web
{% 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_domain }}`)
- traefik.http.routers.{{ service_name }}_https.entrypoints=websecure
- traefik.http.routers.{{ service_name }}_https.tls=true
- traefik.http.routers.{{ service_name }}_https.tls.certresolver={{ traefik_tls_certresolver }}
{% endif %}
{% else %}
ports:
- {{ ports_http }}:3001
{% endif %}
volumes:
- {{ service_name }}-data:/app/data
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3001"]
interval: 30s
retries: 3
start_period: 10s
timeout: 5s
restart: {{ restart_policy }}
{% if not database_external and database_type == "mariadb" %}
{{ service_name }}_db:
image: docker.io/library/mariadb:10.5
restart: {{ restart_policy }}
environment:
- TZ={{ container_timezone }}
- MYSQL_USER={{ database_user }}
- MYSQL_PASSWORD=${DATABASE_PASSWORD}
- MYSQL_DATABASE={{ database_name }}
- MYSQL_ROOT_PASSWORD=${DATABASE_PASSWORD}
networks:
- {{ service_name }}_backend
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h localhost -u {{ database_user }} -p${DATABASE_PASSWORD}"]
start_period: 30s
interval: 10s
timeout: 10s
retries: 5
volumes:
- {{ service_name }}_db:/var/lib/mysql
{% endif %}
volumes:
{{ service_name }}-data:
driver: local
{% if not database_external and database_type == "mariadb" %}
{{ service_name }}_db:
driver: local
{% endif %}
{% if (not database_external and database_type == "mariadb") or traefik_enabled %}
networks:
{% if not database_external and database_type == "mariadb" %}
{{ service_name }}_backend:
driver: bridge
{% endif %}
{% if traefik_enabled %}
{{ traefik_network }}:
external: true
{% endif %}
{% endif %}
================================================
FILE: library/compose/uptimekuma/template.yaml
================================================
---
kind: compose
metadata:
icon:
provider: simple-icons
id: uptimekuma
name: Uptimekuma
description: |
Uptimekuma is a self-hosted monitoring tool that allows you to keep track of the uptime and performance of your websites and services.
version: 2.1.3
author: Christian Lempa
date: '2026-03-03'
tags: [
monitoring,
uptime,
self-hosted,
docker
]
schema: '1.2'
spec:
general:
vars:
service_name:
type: str
default: uptimekuma
required: true
container_timezone:
type: str
default: UTC
required: true
restart_policy:
type: enum
options:
- unless-stopped
- always
- on-failure
- 'no'
default: unless-stopped
required: true
ports:
vars:
ports_http:
type: int
default: 3001
required: false
traefik:
vars:
traefik_host:
type: str
default: uptimekuma
required: false
traefik_network:
default: traefik
type: str
required: true
traefik_domain:
default: home.arpa
type: str
required: true
traefik_enabled:
type: bool
default: false
description: Enable Traefik integration
required: false
toggle: traefik_enabled
title: Traefik
description: Configure Traefik reverse proxy integration
traefik_tls:
vars:
traefik_tls_certresolver:
type: str
default: cloudflare
required: true
traefik_tls_enabled:
type: bool
default: false
description: Enable Traefik TLS
required: false
toggle: traefik_tls_enabled
title: Traefik TLS
description: Configure Traefik TLS/SSL certificates
needs: [
"traefik_enabled=true"
]
database:
vars:
database_type:
type: enum
options:
- sqlite
- mariadb
default: sqlite
description: Database backend type
required: true
database_external:
type: bool
default: false
description: Use external database
required: false
needs:
- database_type=mariadb
database_name:
type: str
default: uptimekuma
description: Database name
required: false
needs:
- database_type=mariadb
database_user:
type: str
default: uptimekuma
description: Database user
required: false
needs:
- database_type=mariadb
database_password:
type: str
sensitive: true
autogenerated: true
description: Database password
required: false
needs:
- database_type=mariadb
database_host:
type: str
default: mariadb
description: Database host
required: true
needs:
- database_type=mariadb;database_external=true
database_port:
type: int
default: 3306
description: Database port
required: false
needs:
- database_type=mariadb
================================================
FILE: library/compose/whoami/compose.yaml.j2
================================================
---
services:
{{ service_name }}:
image: traefik/whoami:v1.11.0
{% if not swarm_enabled %}
restart: {{ restart_policy }}
{% endif %}
{% if swarm_enabled or resources_enabled %}
deploy:
{% if swarm_enabled %}
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 %}
restart_policy:
condition: on-failure
labels:
- traefik.enable=true
- traefik.docker.network={{ traefik_network }}
- traefik.http.services.{{ service_name }}-web.loadbalancer.server.port=80
- traefik.http.routers.{{ service_name }}-http.service={{ service_name }}-web
- traefik.http.routers.{{ service_name }}-http.rule=Host(`{{ traefik_host }}.{{ traefik_domain }}`)
- traefik.http.routers.{{ service_name }}-http.entrypoints=web
{% if traefik_tls_enabled %}
- traefik.http.routers.{{ service_name }}-https.rule=Host(`{{ traefik_host }}.{{ traefik_domain }}`)
- traefik.http.routers.{{ service_name }}-https.entrypoints=websecure
- traefik.http.routers.{{ service_name }}-https.tls=true
- traefik.http.routers.{{ service_name }}-https.tls.certresolver={{ traefik_tls_certresolver }}
{% endif %}
{% endif %}
{% else %}
labels:
- traefik.enable=true
- traefik.docker.network={{ traefik_network }}
- traefik.http.services.{{ service_name }}-web.loadbalancer.server.port=80
- traefik.http.routers.{{ service_name }}-http.service={{ service_name }}-web
- traefik.http.routers.{{ service_name }}-http.rule=Host(`{{ traefik_host }}.{{ traefik_domain }}`)
- traefik.http.routers.{{ service_name }}-http.entrypoints=web
{% if traefik_tls_enabled %}
- traefik.http.routers.{{ service_name }}-https.rule=Host(`{{ traefik_host }}.{{ traefik_domain }}`)
- traefik.http.routers.{{ service_name }}-https.entrypoints=websecure
- traefik.http.routers.{{ service_name }}-https.tls=true
- traefik.http.routers.{{ service_name }}-https.tls.certresolver={{ traefik_tls_certresolver }}
{% endif %}
{% endif %}
networks:
- {{ traefik_network }}
networks:
{{ traefik_network }}:
external: true
================================================
FILE: library/compose/whoami/template.yaml
================================================
---
kind: compose
metadata:
icon:
provider: selfh
id: traefik
name: Whoami
description: |
A **simple web application** that displays information about the HTTP request it receives.
## Use Cases
- Testing reverse proxy configurations
- Debugging web server setups
- Verifying load balancer functionality
- Quick HTTP endpoint testing
## Resources
- **Project**: https://github.com/traefik/whoami
- **Documentation**: https://traefik.io/docs/
- **GitHub**: https://github.com/traefik/whoami
version: 1.11.0
author: Christian Lempa
date: '2025-10-30'
tags:
- traefik
- swarm
schema: "1.2"
spec:
general:
vars:
service_name:
default: whoami
restart_policy:
description: Container restart policy
type: enum
options:
- unless-stopped
- always
- on-failure
- 'no'
default: unless-stopped
required: true
traefik:
vars:
traefik_host:
default: whoami
traefik_network:
description: Traefik network name
type: str
default: traefik
required: true
traefik_domain:
description: Base domain (e.g., example.com)
type: str
default: home.arpa
required: true
traefik_tls:
toggle: traefik_tls_enabled
vars:
traefik_tls_enabled:
description: Enable HTTPS/TLS
type: bool
default: true
traefik_tls_certresolver:
description: Traefik certificate resolver name
type: str
default: cloudflare
required: true
resources:
toggle: resources_enabled
vars:
resources_enabled:
description: Enable resource limits
type: bool
default: false
swarm:
toggle: swarm_enabled
vars:
swarm_enabled:
description: Enable Docker Swarm mode
type: bool
default: false
swarm_placement_mode:
description: Swarm placement mode
type: enum
options:
- replicated
- global
default: replicated
required: true
swarm_replicas:
description: Number of replicas
type: int
default: 1
needs:
- swarm_placement_mode=replicated
required: true
swarm_placement_host:
description: Target hostname for placement constraint
type: str
default: ''
needs:
- swarm_placement_mode=replicated
extra: Constrains service to run on specific node by hostname
================================================
FILE: library/helm/authentik/secrets.yaml.j2
================================================
---
authentik:
secret_key: {{ authentik_secret_key }}
postgresql:
password: {{ database_password }}
{% if email_enabled %}
email:
password: {{ email_password }}
{% endif %}
================================================
FILE: library/helm/authentik/template.yaml
================================================
---
kind: helm
metadata:
icon:
provider: selfh
id: authentik
name: Authentik
description: >
Helm values template for Authentik, an open-source Identity Provider focused
on flexibility and versatility with support for various protocols (OAuth2, SAML, LDAP).
Chart Repository: https://charts.goauthentik.io
Chart Name: authentik
Chart Version: Compatible with Authentik 2025.6.3
Project: https://goauthentik.io/
Documentation: https://docs.goauthentik.io/
version: 2025.6.3
author: Christian Lempa
date: '2025-01-11'
draft: true
schema: "1.2"
spec:
general:
vars:
release_name:
default: authentik
namespace:
type: str
description: Kubernetes namespace
default: authentik
networking:
vars:
network_mode:
type: str
description: Network mode for service
default: ClusterIP
authentik:
title: Authentik Configuration
description: Configure Authentik application settings
required: true
vars:
authentik_secret_key:
type: str
description: Secret Key
extra: Used for cookie signing and unique user IDs
sensitive: true
autogenerated: true
authentik_log_level:
type: enum
description: Authentik log level
options:
- trace
- debug
- info
- warning
- error
default: error
authentik_error_reporting:
type: bool
description: Enable error reporting to Authentik developers
default: false
database:
title: Database Configuration
toggle: database_enabled
vars:
database_enabled:
type: bool
description: Enable database
default: true
database_type:
type: enum
description: Database type
options: [postgres, mysql]
default: postgres
database_host:
type: hostname
description: Database host
default: authentik-postgresql
database_port:
type: int
description: Database port
default: 5432
database_name:
type: str
description: Database name
default: authentik
database_user:
type: str
description: Database user
default: authentik
database_password:
type: str
description: PostgreSQL database password
sensitive: true
autogenerated: true
email:
title: Email Configuration
toggle: email_enabled
vars:
email_enabled:
type: bool
description: Enable email notifications
default: false
email_host:
type: hostname
description: SMTP server hostname
default: smtp.example.com
email_port:
type: int
description: SMTP server port
default: 587
email_username:
type: str
description: SMTP username
default: ""
email_password:
type: str
description: SMTP password
sensitive: true
default: ""
email_from:
type: email
description: From email address
default: authentik@example.com
email_use_tls:
type: bool
description: Use TLS/STARTTLS for SMTP connection
default: true
traefik:
title: Traefik Configuration
toggle: traefik_enabled
vars:
traefik_enabled:
type: bool
description: Enable Traefik ingress
default: false
traefik_host:
type: hostname
description: Traefik hostname
default: authentik.home.arpa
traefik_tls_enabled:
type: bool
description: Enable TLS for Traefik ingress
default: false
traefik_tls_secret:
type: str
description: TLS secret name for Traefik ingress
default: authentik-tls
traefik_tls_certmanager:
type: bool
description: Use cert-manager for TLS certificate
default: false
certmanager_issuer:
type: str
description: Cert-manager issuer name
needs: traefik_tls_certmanager=true
default: letsencrypt-prod
================================================
FILE: library/helm/authentik/values.yaml.j2
================================================
---
global:
image:
repository: "ghcr.io/goauthentik/server"
tag: "2025.6.3"
pullPolicy: IfNotPresent
authentik:
postgresql:
host: {{ database_host }}
name: {{ database_name }}
user: {{ database_user }}
port: {{ database_port }}
{% if email_enabled %}
email:
host: {{ email_host }}
port: {{ email_port }}
username: {{ email_username }}
use_tls: {{ email_use_tls | lower }}
from: {{ email_from }}
{% endif %}
error_reporting:
enabled: {{ authentik_error_reporting | lower }}
log_level: {{ authentik_log_level }}
server:
service:
type: {{ network_mode }}
{% if traefik_enabled %}
ingress:
enabled: true
ingressClassName: traefik
{% if traefik_tls_enabled and traefik_tls_certmanager %}
annotations:
cert-manager.io/cluster-issuer: {{ certmanager_issuer }}
{% endif %}
hosts:
- {{ traefik_host }}
{% if traefik_tls_enabled %}
tls:
- secretName: {{ traefik_tls_secret }}
hosts:
- {{ traefik_host }}
{% endif %}
{% endif %}
postgresql:
enabled: false
redis:
enabled: true
================================================
FILE: library/helm/certmanager/template.yaml
================================================
---
kind: helm
metadata:
name: Cert-Manager
description: |-
Helm values template for cert-manager, a Kubernetes add-on to automate the management and issuance of TLS certificates from various sources.
Chart Repository: https://charts.jetstack.io
Chart Name: cert-manager
Chart Version: Compatible with cert-manager v1.18.2
Project: https://cert-manager.io/
Documentation: https://cert-manager.io/docs/
version: 1.18.2
author: Christian Lempa
date: "2025-01-11"
tags: []
icon:
provider: selfh
id: lets-encrypt
draft: true
next_steps: ""
schema: "1.2"
spec:
general:
vars:
release_name:
type: str
description: Helm release name
default: cert-manager
namespace:
type: str
description: Kubernetes namespace
default: cert-manager
networking:
title: Networking Configuration
vars:
network_mode:
type: str
description: Network mode for service
default: ClusterIP
database:
title: Database Configuration
toggle: database_enabled
vars:
database_enabled:
type: bool
description: Enable database
default: false
database_type:
type: enum
description: Database type
options: [postgres, mysql]
default: postgres
database_host:
type: hostname
description: Database host
database_port:
type: int
description: Database port
default: 5432
database_name:
type: str
description: Database name
database_user:
type: str
description: Database user
database_password:
type: str
description: Database password
sensitive: true
dns:
title: DNS Configuration
vars:
dns_nameserver_1:
description: Primary DNS nameserver for DNS01 challenges
type: str
default: 1.1.1.1:53
dns_nameserver_2:
description: Secondary DNS nameserver for DNS01 challenges
type: str
default: 1.0.0.1:53
dns_recursive_nameservers_only:
description: Use only recursive nameservers for DNS01 challenges
type: bool
default: true
namespace:
type: str
default: cert-manager
release_name:
type: str
default: cert-manager
================================================
FILE: library/helm/certmanager/values.yaml.j2
================================================
---
image:
repository: quay.io/jetstack/cert-manager-controller
tag: v1.18.2
webhook:
image:
repository: quay.io/jetstack/cert-manager-webhook
tag: v1.18.2
cainjector:
image:
repository: quay.io/jetstack/cert-manager-cainjector
tag: v1.18.2
crds:
enabled: true
{% if dns_recursive_nameservers_only %}
extraArgs:
- --dns01-recursive-nameservers-only
- --dns01-recursive-nameservers={{ dns_nameserver_1 }},{{ dns_nameserver_2 }}
{% endif %}
================================================
FILE: library/helm/longhorn/template.yaml
================================================
---
kind: helm
metadata:
name: Longhorn
description: |-
Helm values template for Longhorn, a distributed block storage system for Kubernetes.
Provides persistent storage with built-in backup and disaster recovery.
Chart Repository: https://charts.longhorn.io
Chart Name: longhorn
Chart Version: Compatible with Longhorn v1.9.1
Project: https://longhorn.io/
Documentation: https://longhorn.io/docs/
version: 1.9.1
author: Christian Lempa
date: "2025-01-11"
tags: []
icon:
provider: selfh
id: rancher-longhorn
draft: true
next_steps: ""
schema: "1.2"
spec:
general:
vars:
release_name:
type: str
description: Helm release name
default: longhorn
namespace:
type: str
description: Kubernetes namespace
default: longhorn-system
networking:
title: Networking Configuration
vars:
network_mode:
type: str
description: Network mode for service
default: ClusterIP
database:
title: Database Configuration
toggle: database_enabled
vars:
database_enabled:
type: bool
description: Enable database
default: false
database_type:
type: enum
description: Database type
options: [postgres, mysql]
default: postgres
database_host:
type: hostname
description: Database host
database_port:
type: int
description: Database port
default: 5432
database_name:
type: str
description: Database name
database_user:
type: str
description: Database user
database_password:
type: str
description: Database password
sensitive: true
backup:
title: Backup Configuration
toggle: backup_enabled
vars:
backup_enabled:
description: Enable backup target configuration
type: bool
default: false
backup_target:
description: Backup target URL (e.g., s3://bucket or nfs://server/path)
type: str
namespace:
type: str
default: longhorn-system
release_name:
type: str
default: longhorn
ui:
title: Longhorn UI
vars:
ui_replicas:
description: Number of Longhorn UI replicas
type: int
default: 1
================================================
FILE: library/helm/longhorn/values.yaml.j2
================================================
---
image:
longhorn:
engine:
repository: "longhornio/longhorn-engine"
tag: "v1.9.1"
manager:
repository: "longhornio/longhorn-manager"
tag: "v1.9.1"
ui:
repository: "longhornio/longhorn-ui"
tag: "v1.9.1"
instanceManager:
repository: "longhornio/longhorn-instance-manager"
tag: "v1.9.1"
shareManager:
repository: "longhornio/longhorn-share-manager"
tag: "v1.9.1"
backingImageManager:
repository: "longhornio/backing-image-manager"
tag: "v1.9.1"
supportBundleKit:
repository: "longhornio/support-bundle-kit"
tag: "v0.0.60"
csi:
attacher:
repository: "longhornio/csi-attacher"
tag: "v4.9.0"
provisioner:
repository: "longhornio/csi-provisioner"
tag: "v5.3.0"
nodeDriverRegistrar:
repository: "longhornio/csi-node-driver-registrar"
tag: "v2.14.0"
resizer:
repository: "longhornio/csi-resizer"
tag: "v1.14.0"
snapshotter:
repository: "longhornio/csi-snapshotter"
tag: "v8.3.0"
livenessProbe:
repository: "longhornio/livenessprobe"
tag: "v2.16.0"
longhornUI:
replicas: {{ ui_replicas }}
{% if backup_enabled %}
defaultSettings:
backupTarget: {{ backup_target }}
{% endif %}
================================================
FILE: library/helm/netbox/template.yaml
================================================
---
kind: helm
metadata:
icon:
provider: selfh
id: netbox
name: NetBox
description: |
Helm values template for NetBox, an open-source network infrastructure management (IPAM/DCIM)
solution and network automation source of truth.
## Chart Information
* **Chart Repository:** https://charts.bootsource.github.io/charts
* **Chart Name:** netbox
* **Chart Version:** Compatible with NetBox 4.2.3
## References
* **Project:** https://netbox.dev/
* **Documentation:** https://docs.netbox.dev/
* **GitHub:** https://github.com/netbox-community/netbox
version: 4.2.3
author: Christian Lempa
date: '2025-01-13'
draft: true
schema: "1.2"
spec:
general:
vars:
release_name:
default: netbox
namespace:
default: netbox
database:
title: Database Configuration
toggle: database_enabled
vars:
database_enabled:
type: bool
description: Enable database
default: true
database_type:
type: enum
description: Database type
options: [postgres, mysql]
default: postgres
database_host:
type: hostname
description: Database host
default: netbox-postgresql
database_port:
type: int
description: Database port
default: 5432
database_name:
type: str
description: Database name
default: netbox
database_user:
type: str
description: Database user
default: netbox
database_password:
type: str
description: Database password
sensitive: true
autogenerated: true
networking:
title: Networking Configuration
vars:
network_mode:
type: str
description: Network mode for service
default: ClusterIP
redis:
title: Redis Configuration
description: Configure Redis for caching and task queuing
required: true
vars:
redis_enabled:
description: Enable Redis deployment
type: bool
default: true
redis_host:
description: Redis host for tasks
type: hostname
default: netbox-redis-master
redis_cache_host:
description: Redis host for caching
type: hostname
default: netbox-redis-master
redis_password:
description: Redis password
type: str
sensitive: true
autogenerated: true
email:
title: Email Configuration
toggle: email_enabled
vars:
email_enabled:
type: bool
description: Enable email notifications
default: false
email_host:
type: hostname
description: SMTP server hostname
default: smtp.example.com
email_port:
type: int
description: SMTP server port
default: 587
email_username:
type: str
description: SMTP username
default: ""
email_from:
type: email
description: From email address
default: netbox@example.com
email_use_tls:
type: bool
description: Use TLS/STARTTLS for SMTP connection
default: true
email_use_ssl:
type: bool
description: Use SSL for SMTP connection
default: false
traefik:
title: Traefik Configuration
toggle: traefik_enabled
vars:
traefik_enabled:
type: bool
description: Enable Traefik ingress
default: false
traefik_host:
type: hostname
description: Traefik hostname
default: netbox.home.arpa
traefik_tls_enabled:
type: bool
description: Enable TLS for Traefik ingress
default: false
traefik_tls_secret:
type: str
description: TLS secret name for Traefik ingress
default: netbox-tls
traefik_tls_certmanager:
type: bool
description: Use cert-manager for TLS certificate
default: false
certmanager_issuer:
type: str
description: Cert-manager issuer name
needs: traefik_tls_certmanager=true
default: letsencrypt-prod
volumes:
title: Volume Configuration
vars:
volumes_mode:
type: enum
description: Volume storage mode
options: [pvc, hostPath]
default: pvc
volumes_pvc_name:
type: str
description: PVC name for volumes
default: netbox-data
netbox:
title: NetBox Configuration
description: Configure NetBox application settings
required: true
vars:
netbox_secret_key:
description: Secret Key
extra: Used for cryptographic signing and session management
type: str
sensitive: true
autogenerated: true
netbox_superuser_name:
description: Initial superuser username
type: str
default: admin
netbox_superuser_email:
description: Initial superuser email
type: email
default: admin@example.com
netbox_superuser_password:
description: Initial superuser password
type: str
sensitive: true
autogenerated: true
netbox_superuser_api_token:
description: Initial superuser API token
type: str
sensitive: true
autogenerated: true
netbox_allowed_hosts:
description: Allowed hosts (comma-separated)
extra: Add your domain names or IP addresses
type: str
default: "*"
netbox_metrics_enabled:
description: Enable Prometheus metrics endpoint
type: bool
default: false
netbox_cors_enabled:
description: Enable CORS (Cross-Origin Resource Sharing)
type: bool
default: false
netbox_cors_origins:
description: Allowed CORS origins
needs: netbox_cors_enabled=true
type: str
default: "https://example.com"
================================================
FILE: library/helm/netbox/values.yaml.j2
================================================
---
image:
repository: docker.io/netboxcommunity/netbox
tag: v4.2.3
pullPolicy: IfNotPresent
replicaCount: 1
superuser:
name: {{ netbox_superuser_name }}
email: {{ netbox_superuser_email }}
password: {{ netbox_superuser_password }}
apiToken: {{ netbox_superuser_api_token }}
allowedHosts:
- "{{ netbox_allowed_hosts }}"
{% if database_enabled %}
postgresql:
enabled: false
externalDatabase:
host: {{ database_host }}
port: {{ database_port }}
database: {{ database_name }}
username: {{ database_user }}
existingSecretPasswordKey: "postgresql-password"
{% else %}
postgresql:
enabled: true
auth:
database: {{ database_name }}
username: {{ database_user }}
{% endif %}
{% if redis_enabled %}
redis:
enabled: false
externalRedis:
host: {{ redis_host }}
port: 6379
database: 0
existingSecretPasswordKey: "redis-password"
tasksRedis:
host: {{ redis_host }}
port: 6379
database: 0
existingSecretPasswordKey: "redis-password"
cachingRedis:
host: {{ redis_cache_host }}
port: 6379
database: 1
existingSecretPasswordKey: "redis-password"
{% else %}
redis:
enabled: true
architecture: standalone
auth:
enabled: true
{% endif %}
secretKey: {{ netbox_secret_key }}
{% if email_enabled %}
email:
server: {{ email_host }}
port: {{ email_port }}
username: {{ email_username }}
from: {{ email_from }}
useSSL: {{ email_use_ssl | lower }}
useTLS: {{ email_use_tls | lower }}
existingSecretPasswordKey: "email-password"
{% endif %}
{% if netbox_cors_enabled %}
cors:
originAllowAll: true
originWhitelist:
- {{ netbox_cors_origins }}
{% endif %}
{% if netbox_metrics_enabled %}
metrics:
enabled: true
serviceMonitor:
enabled: false
{% endif %}
service:
type: {{ network_mode }}
{% if traefik_enabled %}
ingress:
enabled: true
className: traefik
{% if traefik_tls_enabled and traefik_tls_certmanager %}
annotations:
cert-manager.io/cluster-issuer: {{ certmanager_issuer }}
{% endif %}
hosts:
- host: {{ traefik_host }}
paths:
- path: /
pathType: Prefix
{% if traefik_tls_enabled %}
tls:
- secretName: {{ traefik_tls_secret }}
hosts:
- {{ traefik_host }}
{% endif %}
{% endif %}
persistence:
enabled: true
{% if volumes_mode == 'existing-pvc' %}
existingClaim: {{ volumes_pvc_name }}
{% else %}
storageClass: ""
size: 10Gi
{% endif %}
worker:
enabled: true
replicaCount: 1
housekeeping:
enabled: true
================================================
FILE: library/helm/portainer/template.yaml
================================================
---
kind: helm
metadata:
icon:
provider: selfh
id: portainer
name: Portainer CE
description: >
Helm values template for Portainer Community Edition, a container management platform
that simplifies Docker and Kubernetes management.
Chart Repository: https://portainer.github.io/k8s/
Chart Name: portainer
Chart Version: Compatible with Portainer CE 2.34.0
Project: https://www.portainer.io/
Documentation: https://docs.portainer.io/
version: 2.34.0
author: Christian Lempa
date: '2025-01-11'
draft: true
schema: "1.2"
spec:
general:
vars:
release_name:
type: str
description: Helm release name
default: portainer
namespace:
type: str
description: Kubernetes namespace
default: portainer
networking:
vars:
network_mode:
type: str
description: Network mode for service
default: ClusterIP
database:
title: Database Configuration
toggle: database_enabled
vars:
database_enabled:
type: bool
description: Enable database
default: false
database_type:
type: enum
description: Database type
options: [postgres, mysql]
default: postgres
database_host:
type: hostname
description: Database host
database_port:
type: int
description: Database port
default: 5432
database_name:
type: str
description: Database name
database_user:
type: str
description: Database user
database_password:
type: str
description: Database password
sensitive: true
traefik:
title: Traefik Configuration
toggle: traefik_enabled
vars:
traefik_enabled:
type: bool
description: Enable Traefik ingress
default: false
traefik_host:
type: hostname
description: Traefik hostname
default: portainer.home.arpa
traefik_tls_enabled:
type: bool
description: Enable TLS for Traefik ingress
default: false
traefik_tls_secret:
type: str
description: TLS secret name for Traefik ingress
default: portainer-tls
volumes:
title: Volume Configuration
vars:
volumes_mode:
type: enum
description: Volume storage mode
options: [pvc, hostPath]
default: pvc
volumes_pvc_name:
type: str
description: PVC name for volumes
default: portainer
================================================
FILE: library/helm/portainer/values.yaml.j2
================================================
---
image:
repository: portainer/portainer-ce
tag: 2.34.0
pullPolicy: IfNotPresent
service:
type: {{ network_mode }}
{% if traefik_enabled %}
ingress:
enabled: true
hosts:
- host: {{ traefik_host }}
paths:
- path: /
port: "9000"
{% if traefik_tls_enabled %}
tls:
- secretName: {{ traefik_tls_secret }}
hosts:
- {{ traefik_host }}
{% endif %}
{% endif %}
{% if volumes_mode == 'existing-pvc' %}
persistence:
existingClaim: {{ volumes_pvc_name }}
{% endif %}
================================================
FILE: library/helm/traefik/template.yaml
================================================
---
kind: helm
metadata:
name: Traefik Ingress Controller
description: |-
Helm values template for Traefik v3, a modern HTTP reverse proxy and load balancer designed for microservices.
Chart Repository: https://traefik.github.io/charts
Chart Name: traefik
Chart Version: Compatible with Traefik v3.5.3
Project: https://traefik.io/
Documentation: https://doc.traefik.io/traefik/
version: 3.5.3
author: Christian Lempa
date: "2025-01-11"
tags: []
icon:
provider: simpleicons
id: traefikproxy
draft: true
next_steps: ""
schema: "1.2"
spec:
general:
vars:
release_name:
type: str
description: Helm release name
default: traefik
namespace:
type: str
description: Kubernetes namespace
default: traefik
networking:
title: Networking Configuration
vars:
network_mode:
type: str
description: Network mode for service
default: LoadBalancer
database:
title: Database Configuration
toggle: database_enabled
vars:
database_enabled:
type: bool
description: Enable database
default: false
database_type:
type: enum
description: Database type
options: [postgres, mysql]
default: postgres
database_host:
type: hostname
description: Database host
database_port:
type: int
description: Database port
default: 5432
database_name:
type: str
description: Database name
database_user:
type: str
description: Database user
database_password:
type: str
description: Database password
sensitive: true
dashboard:
title: Dashboard IngressRoute
toggle: dashboard_ingressroute_enabled
vars:
dashboard_host:
description: FQDN for the Traefik dashboard
type: hostname
dashboard_ingressroute_enabled:
description: Create IngressRoute for Traefik dashboard
type: bool
default: false
dashboard_middleware:
description: Authentication middleware name for dashboard protection
type: str
default: traefik-dashboard-auth
dashboard_tls_secret:
description: TLS secret name for dashboard
type: str
default: traefik-dashboard-tls
release_name:
type: str
default: traefik
http_redirect:
title: HTTP to HTTPS Redirect
toggle: http_redirect_enabled
vars:
http_redirect_enabled:
description: Automatically redirect HTTP traffic to HTTPS
type: bool
default: true
http_redirect_permanent:
description: Use permanent redirect (301) instead of temporary (302)
type: bool
default: true
traefik_config:
title: Traefik Settings
vars:
accesslog_enabled:
description: Enable Traefik access log
type: bool
default: false
dashboard_enabled:
description: Enable Traefik dashboard
type: bool
default: false
extra: 'WARNING: Don''t use in production!'
prometheus_enabled:
description: Enable Prometheus metrics
type: bool
default: false
================================================
FILE: library/helm/traefik/values.yaml.j2
================================================
---
image:
repository: traefik
tag: v3.5.3
pullPolicy: IfNotPresent
{% if http_redirect_enabled %}
ports:
web:
redirections:
entryPoint:
to: websecure
scheme: https
permanent: {{ http_redirect_permanent | lower }}
{% endif %}
{% if dashboard_enabled or accesslog_enabled or prometheus_enabled %}
additionalArguments:
{% if dashboard_enabled %}
- "--api.dashboard=true"
{% endif %}
{% if accesslog_enabled %}
- "--accesslog=true"
{% endif %}
{% if prometheus_enabled %}
- "--metrics.prometheus=true"
{% endif %}
{% endif %}
{% if dashboard_ingressroute_enabled %}
ingressRoute:
dashboard:
enabled: true
entryPoints:
- websecure
matchRule: Host(`{{ dashboard_host }}`)
middlewares:
- name: {{ dashboard_middleware }}
tls:
secretName: {{ dashboard_tls_secret }}
{% endif %}
================================================
FILE: library/kubernetes/certmanager-certificate/certificate.yaml.j2
================================================
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: {{ resource_name }}
namespace: {{ namespace }}
spec:
secretName: {{ secret_name }}
issuerRef:
name: {{ certmanager_issuer }}
kind: {{ certmanager_issuer_kind }}
dnsNames:
{%- for dns_name in dns_names.split(',') %}
- {{ dns_name.strip() }}
{%- endfor %}
================================================
FILE: library/kubernetes/certmanager-certificate/template.yaml
================================================
---
kind: kubernetes
metadata:
icon:
provider: selfh
id: lets-encrypt
name: Cert-Manager Certificate
description: >
Cert-manager Certificate resource for requesting TLS certificates from an Issuer or ClusterIssuer.
The certificate will be stored in a Kubernetes secret.
Requires cert-manager to be installed in the cluster.
Project: https://cert-manager.io
Documentation: https://cert-manager.io/docs/usage/certificate/
version: 1.16.2
author: Christian Lempa
date: '2025-01-11'
schema: "1.2"
spec:
general:
vars:
resource_name:
default: tls-certificate
namespace:
default: default
secret_name:
type: str
description: Name of secret to store the certificate
default: tls-secret
resources:
title: Resource Limits
toggle: resources_enabled
vars:
resources_enabled:
type: bool
description: Enable resource limits and requests
default: false
resources_cpu_limit:
type: str
description: CPU limit
default: 100m
resources_cpu_request:
type: str
description: CPU request
default: 50m
resources_memory_limit:
type: str
description: Memory limit
default: 128Mi
resources_memory_request:
type: str
description: Memory request
default: 64Mi
traefik:
title: Traefik Integration
toggle: traefik_enabled
vars:
traefik_enabled:
type: bool
description: Enable Traefik integration
default: false
traefik_host:
type: hostname
description: Traefik host
traefik_domain:
type: str
description: Traefik domain
traefik_tls_enabled:
type: bool
description: Enable TLS
default: true
traefik_tls_certresolver:
type: str
description: TLS certificate resolver
default: letsencrypt-prod
dns:
title: DNS Settings
vars:
dns_names:
type: str
description: DNS names for certificate (comma-separated, e.g., example.com,*.example.com)
certmanager:
title: Cert-Manager Settings
vars:
certmanager_issuer:
type: str
description: Cert-manager Issuer or ClusterIssuer name
default: letsencrypt-prod
certmanager_issuer_kind:
type: enum
description: Issuer type
options: [Issuer, ClusterIssuer]
default: ClusterIssuer
================================================
FILE: library/kubernetes/certmanager-clusterissuer/clusterissuer.yaml.j2
================================================
---
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
name: {{ resource_name }}
spec:
acme:
email: {{ acme_email }}
server: {{ acme_server }}
privateKeySecretRef:
name: {{ privatekey_secret_name }}
solvers:
- dns01:
cloudflare:
apiTokenSecretRef:
name: {{ api_token_secret_name }}
key: {{ api_token_secret_key }}
================================================
FILE: library/kubernetes/certmanager-clusterissuer/template.yaml
================================================
---
kind: kubernetes
metadata:
icon:
provider: selfh
id: lets-encrypt
name: Cert-Manager ClusterIssuer (Cloudflare)
description: >
Cert-manager ClusterIssuer for automatic TLS certificate management with Let's Encrypt and Cloudflare DNS-01 challenge.
Requires cert-manager to be installed in the cluster.
Project: https://cert-manager.io
Documentation: https://cert-manager.io/docs/configuration/acme/dns01/cloudflare/
version: 1.16.2
author: Christian Lempa
date: '2025-01-11'
schema: "1.2"
spec:
general:
vars:
resource_name:
default: cloudflare-clusterissuer
namespace:
type: str
description: Namespace (ClusterIssuer is cluster-scoped, but needed for schema)
default: cert-manager
acme_email:
type: email
description: Email address for ACME account registration
acme_server:
type: url
description: ACME server URL
default: https://acme-v02.api.letsencrypt.org/directory
privatekey_secret_name:
type: str
description: Name of secret to store ACME account private key
default: cloudflare-clusterissuer-account-key
api_token_secret_name:
type: str
description: Name of secret containing Cloudflare API token
default: cloudflare-api-token-secret
api_token_secret_key:
type: str
description: Key name in secret containing Cloudflare API token
default: api-token
resources:
title: Resource Limits
toggle: resources_enabled
vars:
resources_enabled:
type: bool
description: Enable resource limits and requests
default: false
resources_cpu_limit:
type: str
description: CPU limit
default: 100m
resources_cpu_request:
type: str
description: CPU request
default: 50m
resources_memory_limit:
type: str
description: Memory limit
default: 128Mi
resources_memory_request:
type: str
description: Memory request
default: 64Mi
traefik:
title: Traefik Integration
toggle: traefik_enabled
vars:
traefik_enabled:
type: bool
description: Enable Traefik integration
default: false
traefik_host:
type: hostname
description: Traefik host
traefik_domain:
type: str
description: Traefik domain
traefik_tls_enabled:
type: bool
description: Enable TLS
default: true
traefik_tls_certresolver:
type: str
description: TLS certificate resolver
default: letsencrypt-prod
================================================
FILE: library/kubernetes/certmanager-issuer/issuer.yaml.j2
================================================
---
apiVersion: cert-manager.io/v1
kind: Issuer
metadata:
name: {{ resource_name }}
namespace: {{ namespace }}
spec:
acme:
email: {{ acme_email }}
server: {{ acme_server }}
privateKeySecretRef:
name: {{ privatekey_secret_name }}
solvers:
- dns01:
cloudflare:
apiTokenSecretRef:
name: {{ api_token_secret_name }}
key: {{ api_token_secret_key }}
================================================
FILE: library/kubernetes/certmanager-issuer/template.yaml
================================================
---
kind: kubernetes
metadata:
icon:
provider: selfh
id: lets-encrypt
name: Cert-Manager Issuer (Cloudflare)
description: >
Cert-manager Issuer for automatic TLS certificate management with Let's Encrypt and Cloudflare DNS-01 challenge.
Issuer is namespace-scoped (unlike ClusterIssuer which is cluster-wide).
Requires cert-manager to be installed in the cluster.
Project: https://cert-manager.io
Documentation: https://cert-manager.io/docs/configuration/acme/dns01/cloudflare/
version: 1.16.2
author: Christian Lempa
date: '2025-01-11'
schema: "1.2"
spec:
general:
vars:
resource_name:
default: cloudflare-issuer
namespace:
default: default
acme_email:
type: email
description: Email address for ACME account registration
acme_server:
type: url
description: ACME server URL
default: https://acme-v02.api.letsencrypt.org/directory
privatekey_secret_name:
type: str
description: Name of secret to store ACME account private key
default: cloudflare-issuer-account-key
api_token_secret_name:
type: str
description: Name of secret containing Cloudflare API token
default: cloudflare-api-token-secret
api_token_secret_key:
type: str
description: Key name in secret containing Cloudflare API token
default: api-token
resources:
title: Resource Limits
toggle: resources_enabled
vars:
resources_enabled:
type: bool
description: Enable resource limits and requests
default: false
resources_cpu_limit:
type: str
description: CPU limit
default: 100m
resources_cpu_request:
type: str
description: CPU request
default: 50m
resources_memory_limit:
type: str
description: Memory limit
default: 128Mi
resources_memory_request:
type: str
description: Memory request
default: 64Mi
traefik:
title: Traefik Integration
toggle: traefik_enabled
vars:
traefik_enabled:
type: bool
description: Enable Traefik integration
default: false
traefik_host:
type: hostname
description: Traefik host
traefik_domain:
type: str
description: Traefik domain
traefik_tls_enabled:
type: bool
description: Enable TLS
default: true
traefik_tls_certresolver:
type: str
description: TLS certificate resolver
default: letsencrypt-prod
================================================
FILE: library/kubernetes/core-configmap/configmap.yaml.j2
================================================
---
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ resource_name }}
namespace: {{ namespace }}
data:
# Add your configuration key-value pairs here
# Example:
# app.properties: |
# color.good=green
# color.bad=red
================================================
FILE: library/kubernetes/core-configmap/template.yaml
================================================
---
kind: kubernetes
metadata:
icon:
provider: selfh
id: kubernetes
name: Kubernetes ConfigMap
description: >
Kubernetes ConfigMap resource for storing non-sensitive configuration data as key-value pairs.
ConfigMaps allow you to decouple configuration from container images.
Documentation: https://kubernetes.io/docs/concepts/configuration/configmap/
version: 1.31.0
author: Christian Lempa
date: '2025-01-11'
schema: "1.2"
spec:
general:
vars:
resource_name:
default: app-config
namespace:
default: default
resources:
title: Resource Limits
toggle: resources_enabled
vars:
resources_enabled:
type: bool
description: Enable resource limits and requests
default: false
resources_cpu_limit:
type: str
description: CPU limit
default: 100m
resources_cpu_request:
type: str
description: CPU request
default: 50m
resources_memory_limit:
type: str
description: Memory limit
default: 128Mi
resources_memory_request:
type: str
description: Memory request
default: 64Mi
traefik:
title: Traefik Integration
toggle: traefik_enabled
vars:
traefik_enabled:
type: bool
description: Enable Traefik integration
default: false
traefik_host:
type: hostname
description: Traefik host
traefik_domain:
type: str
description: Traefik domain
traefik_tls_enabled:
type: bool
description: Enable TLS
default: true
traefik_tls_certresolver:
type: str
description: TLS certificate resolver
default: letsencrypt-prod
================================================
FILE: library/kubernetes/core-ingress/ingress.yaml.j2
================================================
---
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: {{ resource_name }}
namespace: {{ namespace }}
spec:
ingressClassName: {{ ingress_class }}
rules:
- host: {{ ingress_host }}
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: {{ service_name }}
port:
number: {{ service_port }}
================================================
FILE: library/kubernetes/core-ingress/template.yaml
================================================
---
kind: kubernetes
metadata:
icon:
provider: selfh
id: kubernetes
name: Kubernetes Ingress
description: >
Kubernetes Ingress resource for HTTP/HTTPS routing to services.
Requires an Ingress controller (e.g., nginx-ingress, Traefik) to be installed.
Documentation: https://kubernetes.io/docs/concepts/services-networking/ingress/
version: 1.31.0
author: Christian Lempa
date: '2025-01-11'
schema: "1.2"
spec:
general:
vars:
resource_name:
default: app-ingress
namespace:
default: default
ingress_class:
type: str
description: Ingress class name
default: nginx
ingress_host:
type: hostname
description: Hostname for the ingress
service_name:
type: str
description: Backend service name
service_port:
type: int
description: Backend service port
default: 80
resources:
title: Resource Limits
toggle: resources_enabled
vars:
resources_enabled:
type: bool
description: Enable resource limits and requests
default: false
resources_cpu_limit:
type: str
description: CPU limit
default: 100m
resources_cpu_request:
type: str
description: CPU request
default: 50m
resources_memory_limit:
type: str
description: Memory limit
default: 128Mi
resources_memory_request:
type: str
description: Memory request
default: 64Mi
traefik:
title: Traefik Integration
toggle: traefik_enabled
vars:
traefik_enabled:
type: bool
description: Enable Traefik integration
default: false
traefik_host:
type: hostname
description: Traefik host
traefik_domain:
type: str
description: Traefik domain
traefik_tls_enabled:
type: bool
description: Enable TLS
default: true
traefik_tls_certresolver:
type: str
description: TLS certificate resolver
default: letsencrypt-prod
================================================
FILE: library/kubernetes/core-ingressclass/ingressclass.yaml.j2
================================================
---
apiVersion: networking.k8s.io/v1
kind: IngressClass
metadata:
name: {{ resource_name }}
{% if is_default %}
annotations:
ingressclass.kubernetes.io/is-default-class: "true"
{% endif %}
spec:
controller: {{ controller }}
================================================
FILE: library/kubernetes/core-ingressclass/template.yaml
================================================
---
kind: kubernetes
metadata:
icon:
provider: selfh
id: kubernetes
name: Kubernetes IngressClass
description: >
Kubernetes IngressClass for specifying which Ingress controller should handle Ingress resources.
IngressClass is cluster-scoped and defines ingress controller implementations.
Documentation: https://kubernetes.io/docs/concepts/services-networking/ingress/#ingress-class
version: 1.31.0
author: Christian Lempa
date: '2025-01-11'
schema: "1.2"
spec:
general:
vars:
resource_name:
default: nginx
controller:
type: str
description: Ingress controller identifier (e.g., k8s.io/ingress-nginx)
default: k8s.io/ingress-nginx
is_default:
type: bool
description: Set as default IngressClass
default: false
resources:
title: Resource Limits
toggle: resources_enabled
vars:
resources_enabled:
type: bool
description: Enable resource limits and requests
default: false
resources_cpu_limit:
type: str
description: CPU limit
default: 100m
resources_cpu_request:
type: str
description: CPU request
default: 50m
resources_memory_limit:
type: str
description: Memory limit
default: 128Mi
resources_memory_request:
type: str
description: Memory request
default: 64Mi
traefik:
title: Traefik Integration
toggle: traefik_enabled
vars:
traefik_enabled:
type: bool
description: Enable Traefik integration
default: false
traefik_host:
type: hostname
description: Traefik host
traefik_domain:
type: str
description: Traefik domain
traefik_tls_enabled:
type: bool
description: Enable TLS
default: true
traefik_tls_certresolver:
type: str
description: TLS certificate resolver
default: letsencrypt-prod
================================================
FILE: library/kubernetes/core-persistentvolume/pv.yaml.j2
================================================
---
apiVersion: v1
kind: PersistentVolume
metadata:
name: {{ resource_name }}
spec:
capacity:
storage: {{ storage_size }}
{% if storage_class %}
storageClassName: {{ storage_class }}
{% endif %}
accessModes:
- {{ access_mode }}
persistentVolumeReclaimPolicy: {{ reclaim_policy }}
hostPath:
path: {{ host_path }}
================================================
FILE: library/kubernetes/core-persistentvolume/template.yaml
================================================
---
kind: kubernetes
metadata:
icon:
provider: selfh
id: kubernetes
name: Kubernetes PersistentVolume
description: >
Kubernetes PersistentVolume for cluster-wide storage resources.
PVs are cluster-scoped and typically provisioned by administrators.
Documentation: https://kubernetes.io/docs/concepts/storage/persistent-volumes/
version: 1.31.0
author: Christian Lempa
date: '2025-01-11'
schema: "1.2"
spec:
general:
vars:
resource_name:
default: pv-nfs
storage_class:
type: str
description: Storage class name
default: ""
storage_size:
type: str
description: Storage capacity (e.g., 10Gi, 1Ti)
default: 10Gi
access_mode:
type: enum
description: Access mode
options:
- ReadWriteOnce
- ReadOnlyMany
- ReadWriteMany
- ReadWriteOncePod
default: ReadWriteMany
reclaim_policy:
type: enum
description: Reclaim policy
options:
- Retain
- Recycle
- Delete
default: Retain
host_path:
type: str
description: Host path for local storage (e.g., /mnt/data)
default: /mnt/data
resources:
title: Resource Limits
toggle: resources_enabled
vars:
resources_enabled:
type: bool
description: Enable resource limits and requests
default: false
resources_cpu_limit:
type: str
description: CPU limit
default: 100m
resources_cpu_request:
type: str
description: CPU request
default: 50m
resources_memory_limit:
type: str
description: Memory limit
default: 128Mi
resources_memory_request:
type: str
description: Memory request
default: 64Mi
traefik:
title: Traefik Integration
toggle: traefik_enabled
vars:
traefik_enabled:
type: bool
description: Enable Traefik integration
default: false
traefik_host:
type: hostname
description: Traefik host
traefik_domain:
type: str
description: Traefik domain
traefik_tls_enabled:
type: bool
description: Enable TLS
default: true
traefik_tls_certresolver:
type: str
description: TLS certificate resolver
default: letsencrypt-prod
================================================
FILE: library/kubernetes/core-persistentvolumeclaim/pvc.yaml.j2
================================================
---
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: {{ resource_name }}
namespace: {{ namespace }}
spec:
{% if storage_class %}
storageClassName: {{ storage_class }}
{% endif %}
accessModes:
- {{ access_mode }}
resources:
requests:
storage: {{ storage_size }}
================================================
FILE: library/kubernetes/core-persistentvolumeclaim/template.yaml
================================================
---
kind: kubernetes
metadata:
icon:
provider: selfh
id: kubernetes
name: Kubernetes PersistentVolumeClaim
description: >
Kubernetes PersistentVolumeClaim for requesting persistent storage.
PVCs are used by Pods to claim durable storage.
Documentation: https://kubernetes.io/docs/concepts/storage/persistent-volumes/
version: 1.31.0
author: Christian Lempa
date: '2025-01-11'
schema: "1.2"
spec:
general:
vars:
resource_name:
default: app-pvc
namespace:
default: default
storage_class:
type: str
description: Storage class name (leave empty for default)
default: ""
storage_size:
type: str
description: Storage size (e.g., 10Gi, 1Ti)
default: 10Gi
access_mode:
type: enum
description: Access mode
options:
- ReadWriteOnce
- ReadOnlyMany
- ReadWriteMany
- ReadWriteOncePod
default: ReadWriteOnce
resources:
title: Resource Limits
toggle: resources_enabled
vars:
resources_enabled:
type: bool
description: Enable resource limits and requests
default: false
resources_cpu_limit:
type: str
description: CPU limit
default: 100m
resources_cpu_request:
type: str
description: CPU request
default: 50m
resources_memory_limit:
type: str
description: Memory limit
default: 128Mi
resources_memory_request:
type: str
description: Memory request
default: 64Mi
traefik:
title: Traefik Integration
toggle: traefik_enabled
vars:
traefik_enabled:
type: bool
description: Enable Traefik integration
default: false
traefik_host:
type: hostname
description: Traefik host
traefik_domain:
type: str
description: Traefik domain
traefik_tls_enabled:
type: bool
description: Enable TLS
default: true
traefik_tls_certresolver:
type: str
description: TLS certificate resolver
default: letsencrypt-prod
================================================
FILE: library/kubernetes/core-secret/template.yaml
================================================
---
kind: kubernetes
metadata:
icon:
provider: selfh
id: kubernetes
name: Kubernetes Secret (Opaque)
description: >
Basic Kubernetes Secret with Opaque type for storing sensitive data like API tokens, passwords, or keys.
Data must be base64 encoded.
Documentation: https://kubernetes.io/docs/concepts/configuration/secret/
version: 1.31.0
author: Christian Lempa
date: '2025-01-11'
schema: "1.2"
spec:
general:
vars:
resource_name:
default: api-token-secret
namespace:
default: default
secret_type:
type: str
description: Secret type
default: Opaque
api_token:
type: str
description: API token value (plain text, Kubernetes will encode it)
default: ""
sensitive: true
autogenerated: true
resources:
title: Resource Limits
toggle: resources_enabled
vars:
resources_enabled:
type: bool
description: Enable resource limits and requests
default: false
resources_cpu_limit:
type: str
description: CPU limit
default: 100m
resources_cpu_request:
type: str
description: CPU request
default: 50m
resources_memory_limit:
type: str
description: Memory limit
default: 128Mi
resources_memory_request:
type: str
description: Memory request
default: 64Mi
traefik:
title: Traefik Integration
toggle: traefik_enabled
vars:
traefik_enabled:
type: bool
description: Enable Traefik integration
default: false
traefik_host:
type: hostname
description: Traefik host
traefik_domain:
type: str
description: Traefik domain
traefik_tls_enabled:
type: bool
description: Enable TLS
default: true
traefik_tls_certresolver:
type: str
description: TLS certificate resolver
default: letsencrypt-prod
================================================
FILE: library/kubernetes/core-service/service.yaml.j2
================================================
---
apiVersion: v1
kind: Service
metadata:
name: {{ resource_name }}
namespace: {{ namespace }}
spec:
type: {{ service_type }}
selector:
app.kubernetes.io/name: {{ app_selector }}
ports:
- name: {{ protocol | lower }}
protocol: {{ protocol }}
port: {{ service_port }}
targetPort: {{ target_port }}
================================================
FILE: library/kubernetes/core-service/template.yaml
================================================
---
kind: kubernetes
metadata:
icon:
provider: selfh
id: kubernetes
name: Kubernetes Service
description: >
Kubernetes Service resource for exposing applications running on a set of Pods.
Services provide stable network endpoints and load balancing.
Documentation: https://kubernetes.io/docs/concepts/services-networking/service/
version: 1.31.0
author: Christian Lempa
date: '2025-01-11'
schema: "1.2"
spec:
general:
vars:
resource_name:
default: app-service
namespace:
default: default
service_type:
type: enum
description: Service type
options:
- ClusterIP
- NodePort
- LoadBalancer
- ExternalName
default: ClusterIP
service_port:
type: int
description: Service port
default: 80
target_port:
type: int
description: Target port on pods
default: 8080
protocol:
type: enum
description: Protocol
options:
- TCP
- UDP
- SCTP
default: TCP
resources:
title: Resource Limits
toggle: resources_enabled
vars:
resources_enabled:
type: bool
description: Enable resource limits and requests
default: false
resources_cpu_limit:
type: str
description: CPU limit
default: 100m
resources_cpu_request:
type: str
description: CPU request
default: 50m
resources_memory_limit:
type: str
description: Memory limit
default: 128Mi
resources_memory_request:
type: str
description: Memory request
default: 64Mi
traefik:
title: Traefik Integration
toggle: traefik_enabled
vars:
traefik_enabled:
type: bool
description: Enable Traefik integration
default: false
traefik_host:
type: hostname
description: Traefik host
traefik_domain:
type: str
description: Traefik domain
traefik_tls_enabled:
type: bool
description: Enable TLS
default: true
traefik_tls_certresolver:
type: str
description: TLS certificate resolver
default: letsencrypt-prod
app_selector:
type: str
description: App label selector (e.g., app.kubernetes.io/name value)
================================================
FILE: library/kubernetes/core-serviceaccount/serviceaccount.yaml.j2
================================================
---
apiVersion: v1
kind: ServiceAccount
metadata:
name: {{ resource_name }}
namespace: {{ namespace }}
================================================
FILE: library/kubernetes/core-serviceaccount/template.yaml
================================================
---
kind: kubernetes
metadata:
icon:
provider: selfh
id: kubernetes
name: Kubernetes ServiceAccount
description: >
Kubernetes ServiceAccount for providing an identity for processes that run in Pods.
ServiceAccounts are used to control permissions and access to the Kubernetes API.
Documentation: https://kubernetes.io/docs/tasks/configure-pod-container/configure-service-account/
version: 1.31.0
author: Christian Lempa
date: '2025-01-11'
schema: "1.2"
spec:
general:
vars:
resource_name:
default: app-serviceaccount
namespace:
default: default
resources:
title: Resource Limits
toggle: resources_enabled
vars:
resources_enabled:
type: bool
description: Enable resource limits and requests
default: false
resources_cpu_limit:
type: str
description: CPU limit
default: 100m
resources_cpu_request:
type: str
description: CPU request
default: 50m
resources_memory_limit:
type: str
description: Memory limit
default: 128Mi
resources_memory_request:
type: str
description: Memory request
default: 64Mi
traefik:
title: Traefik Integration
toggle: traefik_enabled
vars:
traefik_enabled:
type: bool
description: Enable Traefik integration
default: false
traefik_host:
type: hostname
description: Traefik host
traefik_domain:
type: str
description: Traefik domain
traefik_tls_enabled:
type: bool
description: Enable TLS
default: true
traefik_tls_certresolver:
type: str
description: TLS certificate resolver
default: letsencrypt-prod
================================================
FILE: library/kubernetes/core-storageclass/storageclass.yaml.j2
================================================
---
apiVersion: storage.k8s.io/v1
kind: StorageClass
metadata:
name: {{ resource_name }}
provisioner: {{ provisioner }}
volumeBindingMode: {{ volume_binding_mode }}
reclaimPolicy: {{ reclaim_policy }}
================================================
FILE: library/kubernetes/core-storageclass/template.yaml
================================================
---
kind: kubernetes
metadata:
icon:
provider: selfh
id: kubernetes
name: Kubernetes StorageClass
description: >
Kubernetes StorageClass for defining different types of storage that can be dynamically provisioned.
StorageClass allows administrators to describe different storage "classes" available.
Documentation: https://kubernetes.io/docs/concepts/storage/storage-classes/
version: 1.31.0
author: Christian Lempa
date: '2025-01-11'
schema: "1.2"
spec:
general:
vars:
resource_name:
default: standard
provisioner:
type: str
description: Storage provisioner (e.g., kubernetes.io/no-provisioner, longhorn)
default: kubernetes.io/no-provisioner
volume_binding_mode:
type: enum
description: Volume binding mode
options:
- Immediate
- WaitForFirstConsumer
default: WaitForFirstConsumer
reclaim_policy:
type: enum
description: Reclaim policy for volumes
options:
- Retain
- Delete
default: Delete
resources:
title: Resource Limits
toggle: resources_enabled
vars:
resources_enabled:
type: bool
description: Enable resource limits and requests
default: false
resources_cpu_limit:
type: str
description: CPU limit
default: 100m
resources_cpu_request:
type: str
description: CPU request
default: 50m
resources_memory_limit:
type: str
description: Memory limit
default: 128Mi
resources_memory_request:
type: str
description: Memory request
default: 64Mi
traefik:
title: Traefik Integration
toggle: traefik_enabled
vars:
traefik_enabled:
type: bool
description: Enable Traefik integration
default: false
traefik_host:
type: hostname
description: Traefik host
traefik_domain:
type: str
description: Traefik domain
traefik_tls_enabled:
type: bool
description: Enable TLS
default: true
traefik_tls_certresolver:
type: str
description: TLS certificate resolver
default: letsencrypt-prod
================================================
FILE: library/kubernetes/traefik-ingressroute/ingressroute.yaml.j2
================================================
---
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: {{ resource_name }}
namespace: {{ namespace }}
spec:
entryPoints:
{% if traefik_tls_enabled %}
- {{ traefik_tls_entrypoint }}
{% else %}
- {{ traefik_entrypoint }}
{% endif %}
routes:
- match: Host(`{{ traefik_host }}`)
kind: Rule
services:
- name: {{ traefik_service_name }}
port: {{ traefik_service_port }}
{% if traefik_tls_enabled %}
tls:
certResolver: {{ traefik_tls_certresolver }}
{% endif %}
================================================
FILE: library/kubernetes/traefik-ingressroute/template.yaml
================================================
---
kind: kubernetes
metadata:
icon:
provider: selfh
id: traefik
name: Traefik IngressRoute
description: >
Traefik IngressRoute CRD for HTTP/HTTPS routing with advanced features.
Requires Traefik to be installed as the Ingress controller.
Project: https://traefik.io
Documentation: https://doc.traefik.io/traefik/routing/providers/kubernetes-crd/
version: 3.5.3
author: Christian Lempa
date: '2025-01-11'
schema: "1.2"
spec:
general:
vars:
resource_name:
default: app-ingressroute
namespace:
default: default
traefik_entrypoint:
type: str
description: Traefik entrypoint for HTTP (e.g., web)
default: web
traefik_tls_entrypoint:
type: str
description: Traefik entrypoint for HTTPS (e.g., websecure)
default: websecure
traefik_service_name:
type: str
description: Kubernetes service name to route traffic to
traefik_service_port:
type: int
description: Kubernetes service port
default: 80
resources:
title: Resource Limits
toggle: resources_enabled
vars:
resources_enabled:
type: bool
description: Enable resource limits and requests
default: false
resources_cpu_limit:
type: str
description: CPU limit
default: 100m
resources_cpu_request:
type: str
description: CPU request
default: 50m
resources_memory_limit:
type: str
description: Memory limit
default: 128Mi
resources_memory_request:
type: str
description: Memory request
default: 64Mi
traefik:
title: Traefik Configuration
toggle: traefik_enabled
vars:
traefik_enabled:
type: bool
description: Enable Traefik integration
default: true
traefik_host:
type: hostname
description: Domain name for the IngressRoute
traefik_domain:
type: str
description: Traefik domain
traefik_tls_enabled:
type: bool
description: Enable TLS
default: true
traefik_tls_certresolver:
type: str
description: TLS certificate resolver
default: letsencrypt-prod
================================================
FILE: library/kubernetes/traefik-ingressroutetcp/ingressroutetcp.yaml.j2
================================================
---
apiVersion: traefik.io/v1alpha1
kind: IngressRouteTCP
metadata:
name: {{ resource_name }}
namespace: {{ namespace }}
spec:
entryPoints:
- {{ traefik_entrypoint }}
routes:
- match: HostSNI(`*`)
services:
- name: {{ traefik_service_name }}
port: {{ traefik_service_port }}
{% if traefik_tls_enabled %}
tls:
passthrough: true
{% endif %}
================================================
FILE: library/kubernetes/traefik-ingressroutetcp/template.yaml
================================================
---
kind: kubernetes
metadata:
icon:
provider: selfh
id: traefik
name: Traefik IngressRouteTCP
description: >
Traefik IngressRouteTCP CRD for TCP routing (non-HTTP protocols).
Use for database connections, SSH, or any TCP-based protocol.
Requires Traefik to be installed as the Ingress controller.
Project: https://traefik.io
Documentation: https://doc.traefik.io/traefik/routing/providers/kubernetes-crd/#kind-ingressroutetcp
version: 3.5.3
author: Christian Lempa
date: '2025-01-11'
schema: "1.2"
spec:
general:
vars:
resource_name:
default: app-tcp-route
namespace:
default: default
resources:
title: Resource Limits
toggle: resources_enabled
vars:
resources_enabled:
type: bool
description: Enable resource limits and requests
default: false
resources_cpu_limit:
type: str
description: CPU limit
default: 100m
resources_cpu_request:
type: str
description: CPU request
default: 50m
resources_memory_limit:
type: str
description: Memory limit
default: 128Mi
resources_memory_request:
type: str
description: Memory request
default: 64Mi
traefik:
title: Traefik Configuration
toggle: traefik_enabled
vars:
traefik_enabled:
type: bool
description: Enable Traefik integration
default: true
traefik_host:
type: hostname
description: Traefik host
traefik_domain:
type: str
description: Traefik domain
traefik_entrypoint:
type: str
description: Traefik entrypoint for TCP routing
default: tcp
traefik_service_name:
type: str
description: Backend service name for TCP routing
traefik_service_port:
type: int
description: Backend service port for TCP routing
default: 5432
traefik_tls_enabled:
type: bool
description: Enable TLS
default: false
traefik_tls_certresolver:
type: str
description: TLS certificate resolver
default: letsencrypt-prod
================================================
FILE: library/kubernetes/traefik-middleware/middleware.yaml.j2
================================================
---
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: {{ resource_name }}
namespace: {{ namespace }}
spec:
{% if middleware_type == 'redirectScheme' %}
redirectScheme:
scheme: https
permanent: true
{% elif middleware_type == 'stripPrefix' %}
stripPrefix:
prefixes:
- /api
{% elif middleware_type == 'addPrefix' %}
addPrefix:
prefix: /api
{% elif middleware_type == 'headers' %}
headers:
customRequestHeaders:
X-Forwarded-Proto: https
{% elif middleware_type == 'rateLimit' %}
rateLimit:
average: 100
burst: 50
{% endif %}
================================================
FILE: library/kubernetes/traefik-middleware/template.yaml
================================================
---
kind: kubernetes
metadata:
name: Traefik Middleware
description: |-
Traefik Middleware CRD for modifying requests and responses.
Middlewares can add headers, redirect, rate-limit, authenticate, and more.
Requires Traefik to be installed as the Ingress controller.
Project: https://traefik.io
Documentation: https://doc.traefik.io/traefik/middlewares/overview/
version: 3.5.3
author: Christian Lempa
date: "2025-01-11"
tags: []
icon:
provider: selfh
id: traefik
draft: false
next_steps: ""
schema: "1.2"
spec:
general:
vars:
resource_name:
type: str
default: app-middleware
namespace:
default: default
middleware_type:
description: Middleware type
type: enum
default: redirectScheme
options: [redirectScheme, stripPrefix, addPrefix, headers, rateLimit]
resources:
title: Resource Limits
toggle: resources_enabled
vars:
resources_enabled:
type: bool
description: Enable resource limits and requests
default: false
resources_cpu_limit:
type: str
description: CPU limit
default: 100m
resources_cpu_request:
type: str
description: CPU request
default: 50m
resources_memory_limit:
type: str
description: Memory limit
default: 128Mi
resources_memory_request:
type: str
description: Memory request
default: 64Mi
traefik:
title: Traefik Integration
toggle: traefik_enabled
vars:
traefik_enabled:
type: bool
description: Enable Traefik integration
default: false
traefik_host:
type: hostname
description: Traefik host
traefik_domain:
type: str
description: Traefik domain
traefik_tls_enabled:
type: bool
description: Enable TLS
default: true
traefik_tls_certresolver:
type: str
description: TLS certificate resolver
default: letsencrypt-prod
================================================
FILE: library/kubernetes/twingate-connector/connector.yaml.j2
================================================
---
apiVersion: twingate.com/v1beta
kind: TwingateConnector
metadata:
name: {{ resource_name }}
namespace: {{ namespace }}
spec:
image:
repository: twingate/connector
tag: {{ image_tag }}
name: {{ connector_name }}
hasStatusNotificationsEnabled: {{ status_notifications | lower }}
================================================
FILE: library/kubernetes/twingate-connector/template.yaml
================================================
---
kind: kubernetes
metadata:
name: Twingate Connector
description: |-
Twingate Connector for secure zero-trust network access.
Requires the Twingate Kubernetes Operator to be installed in the cluster.
Project: https://www.twingate.com
Documentation: https://docs.twingate.com/docs/connector-kubernetes-operator
version: 1.74.0
author: Christian Lempa
date: "2025-01-11"
tags: []
icon:
provider: selfh
id: twingate
draft: false
next_steps: ""
schema: "1.2"
spec:
general:
vars:
resource_name:
default: twingate-connector
namespace:
default: default
image_tag:
type: str
description: Twingate connector image tag
default: "1.74.0"
connector_name:
type: str
description: Twingate connector name
status_notifications:
type: bool
description: Enable status notifications
default: true
resources:
title: Resource Limits
toggle: resources_enabled
vars:
resources_enabled:
type: bool
description: Enable resource limits and requests
default: false
resources_cpu_limit:
type: str
description: CPU limit
default: 100m
resources_cpu_request:
type: str
description: CPU request
default: 50m
resources_memory_limit:
type: str
description: Memory limit
default: 128Mi
resources_memory_request:
type: str
description: Memory request
default: 64Mi
traefik:
title: Traefik Integration
toggle: traefik_enabled
vars:
traefik_enabled:
type: bool
description: Enable Traefik integration
default: false
traefik_host:
type: hostname
description: Traefik host
traefik_domain:
type: str
description: Traefik domain
traefik_tls_enabled:
type: bool
description: Enable TLS
default: true
traefik_tls_certresolver:
type: str
description: TLS certificate resolver
default: letsencrypt-prod
================================================
FILE: library/packer/proxmox-iso-ubuntu/files/99-pve.cfg
================================================
datasource_list: [ConfigDrive, NoCloud]
================================================
FILE: library/packer/proxmox-iso-ubuntu/http/meta-data
================================================
================================================
FILE: library/packer/proxmox-iso-ubuntu/http/user-data.j2
================================================
#cloud-config
autoinstall:
version: 1
locale: {{ locale }}
keyboard:
layout: {{ keyboard_layout }}
ssh:
install-server: true
allow-pw: true
disable_root: true
ssh_quiet_keygen: true
allow_public_ssh_keys: true
packages:
- qemu-guest-agent
- sudo
storage:
layout:
name: direct
swap:
size: 0
user-data:
package_upgrade: false
timezone: {{ timezone }}
users:
- name: {{ ssh_username }}
groups: [adm, sudo]
lock-passwd: false
sudo: ALL=(ALL) NOPASSWD:ALL
shell: /bin/bash
{% if ssh_auth_method == "password" %}
passwd: {{ ssh_password }}
{% elif ssh_auth_method == "key" and ssh_public_key %}
ssh_authorized_keys:
- {{ ssh_public_key }}
{% endif %}
================================================
FILE: library/packer/proxmox-iso-ubuntu/proxmox-iso-ubuntu.pkr.hcl.j2
================================================
# Ubuntu Server Noble (24.04.x)
# ---
# Packer Template to create an Ubuntu Server (Noble 24.04.x) on Proxmox
# Variable Definitions
variable "proxmox_api_url" {
type = string
}
variable "proxmox_api_token_id" {
type = string
}
variable "proxmox_api_token_secret" {
type = string
sensitive = true
}
locals {
disk_storage = "{{ disk_storage }}"
}
# Resource Definition for the VM Template
source "proxmox-iso" "{{ image_name }}" {
# Proxmox Connection Settings
proxmox_url = "${var.proxmox_api_url}"
username = "${var.proxmox_api_token_id}"
token = "${var.proxmox_api_token_secret}"
{% if skip_tls_verify %}
# Skip TLS Verification
insecure_skip_tls_verify = true
{% endif %}
# VM General Settings
node = "{{ proxmox_node }}"
vm_id = "{{ vm_id }}"
vm_name = "{{ image_name }}"
template_description = "{{ vm_description }}"
# VM OS Settings
{% if iso_source == "local" %}
# Local ISO File
boot_iso {
type = "scsi"
iso_file = "{{ iso_file }}"
unmount = true
iso_checksum = "{{ iso_checksum }}"
}
{% elif iso_source == "download" %}
# Download ISO
boot_iso {
type = "scsi"
iso_url = "{{ iso_url }}"
unmount = true
iso_storage_pool = "{{ iso_storage }}"
iso_checksum = "{{ iso_checksum }}"
}
{% endif %}
# VM System Settings
qemu_agent = true
# VM Hard Disk Settings
scsi_controller = "virtio-scsi-pci"
disks {
disk_size = "{{ disk_size }}"
format = "qcow2"
storage_pool = local.disk_storage
type = "virtio"
}
# VM CPU Settings
cores = "{{ cpu_cores }}"
# VM Memory Settings
memory = "{{ memory_mb }}"
# VM Network Settings
network_adapters {
model = "virtio"
bridge = "{{ network_bridge }}"
firewall = "false"
}
# VM Cloud-Init Settings
cloud_init = true
cloud_init_storage_pool = "{{ cloudinit_storage }}"
# PACKER Boot Commands
boot = "c"
boot_wait = "{{ boot_wait }}"
communicator = "ssh"
boot_command = [
"",
"e",
"",
"",
"autoinstall ds=nocloud-net\\\\;s=http://{{ '{{ .HTTPIP }}' }}:{{ '{{ .HTTPPort }}' }}/ ---",
""
]
# PACKER Autoinstall Settings
http_directory = "http"
http_bind_address = "{{ http_bind_address }}"
http_port_min = {{ http_port_min }}
http_port_max = {{ http_port_max }}
ssh_username = "{{ ssh_username }}"
{% if ssh_auth_method == "password" %}
# SSH Password Authentication
ssh_password = "{{ ssh_password }}"
{% elif ssh_auth_method == "key" %}
# SSH Key Authentication
ssh_private_key_file = "{{ ssh_private_key_file }}"
{% endif %}
# Raise the timeout, when installation takes longer
ssh_timeout = "{{ ssh_timeout }}"
ssh_pty = true
}
# Build Definition to create the VM Template
build {
name = "{{ image_name }}"
sources = ["source.proxmox-iso.{{ image_name }}"]
# Provisioning the VM Template for Cloud-Init Integration in Proxmox #1
provisioner "shell" {
inline = [
"while [ ! -f /var/lib/cloud/instance/boot-finished ]; do echo 'Waiting for cloud-init...'; sleep 1; done",
"sudo rm /etc/ssh/ssh_host_*",
"sudo truncate -s 0 /etc/machine-id",
"sudo apt -y autoremove --purge",
"sudo apt -y clean",
"sudo apt -y autoclean",
"sudo cloud-init clean",
"sudo rm -f /etc/cloud/cloud.cfg.d/subiquity-disable-cloudinit-networking.cfg",
"sudo rm -f /etc/netplan/00-installer-config.yaml",
"sudo sync"
]
}
# Provisioning the VM Template for Cloud-Init Integration in Proxmox #2
provisioner "file" {
source = "files/99-pve.cfg"
destination = "/tmp/99-pve.cfg"
}
# Provisioning the VM Template for Cloud-Init Integration in Proxmox #3
provisioner "shell" {
inline = [ "sudo cp /tmp/99-pve.cfg /etc/cloud/cloud.cfg.d/99-pve.cfg" ]
}
# Add additional provisioning scripts here
# ...
}
================================================
FILE: library/packer/proxmox-iso-ubuntu/template.yaml
================================================
---
kind: packer
metadata:
icon:
provider: selfh
id: proxmox
name: Proxmox Ubuntu Server Template (ISO)
description: >
Packer template to create an Ubuntu Server VM template on Proxmox using ISO installation.
This template creates a cloud-init enabled Ubuntu VM that can be used as a template for cloning.
Features:
- Automated Ubuntu installation via autoinstall
- Cloud-init integration
- QEMU guest agent
- Configurable CPU, memory, and disk settings
- SSH authentication (password or key-based)
Project: https://www.packer.io/
Documentation: https://developer.hashicorp.com/packer/integrations/hashicorp/proxmox
version: 1.11.2
author: Christian Lempa
date: '2024-11-11'
schema: "1.2"
spec:
general:
vars:
playbook_name:
type: str
description: Name of the playbook
image_name:
default: ubuntu-server-noble
vm_id:
type: int
description: Proxmox VM ID for the template
default: 100
vm_description:
type: str
description: VM template description
default: Ubuntu Server Noble (24.04) Image
proxmox_node:
type: str
description: Proxmox node name where the VM will be created
default: pve
proxmox:
title: Proxmox Connection
required: true
vars:
proxmox_api_url:
type: url
description: Proxmox API URL (e.g., https://proxmox.example.com:8006/api2/json)
default: https://proxmox.local:8006/api2/json
proxmox_api_token_id:
type: str
description: Proxmox API token ID (e.g., root@pam!packer)
default: root@pam!packer
proxmox_api_token_secret:
type: str
description: Proxmox API token secret
sensitive: true
default: ""
skip_tls_verify:
type: bool
description: Skip TLS certificate verification
default: false
iso:
title: ISO Configuration
required: true
vars:
iso_source:
type: enum
description: ISO source type
options:
- local
- download
default: local
iso_file:
type: str
description: Local ISO file path (for local source)
default: local:iso/ubuntu-24.04-live-server-amd64.iso
iso_url:
type: url
description: ISO download URL (for download source)
default: https://releases.ubuntu.com/24.04/ubuntu-24.04-live-server-amd64.iso
iso_storage:
type: str
description: Proxmox storage for downloaded ISO
default: local
iso_checksum:
type: str
description: ISO checksum (SHA256 or checksum file URL)
default: e240e4b801f7bb68c20d1356b60968ad0c33a41d00d828e74ceb3364a0317be9
hardware:
title: Hardware Configuration
vars:
cpu_cores:
type: int
description: Number of CPU cores
default: 1
memory_mb:
type: int
description: Memory in MB
default: 2048
disk_size:
type: str
description: Disk size (e.g., 25G, 50G)
default: 25G
disk_storage:
type: str
description: Proxmox storage pool for disk
default: local-lvm
network_bridge:
type: str
description: Network bridge
default: vmbr0
cloudinit:
title: Cloud-Init Configuration
vars:
cloudinit_storage:
type: str
description: Proxmox storage pool for cloud-init disk
default: local-lvm
locale:
type: str
description: System locale
default: en_US
keyboard_layout:
type: str
description: Keyboard layout
default: us
timezone:
type: str
description: System timezone
default: UTC
ssh:
title: SSH Configuration
required: true
vars:
ssh_username:
type: str
description: SSH username for provisioning
default: ubuntu
ssh_auth_method:
type: enum
description: SSH authentication method
options:
- password
- key
default: password
ssh_password:
type: str
description: SSH password (for password auth)
sensitive: true
default: ""
ssh_private_key_file:
type: str
description: SSH private key file path (for key auth)
default: ~/.ssh/id_rsa
ssh_public_key:
type: str
description: SSH public key (for key auth in cloud-init)
default: ""
ssh_timeout:
type: str
description: SSH connection timeout
default: 30m
boot:
title: Boot Configuration
vars:
boot_wait:
type: str
description: Time to wait before typing boot command
default: 10s
http_bind_address:
type: str
description: HTTP server bind address for autoinstall
default: 0.0.0.0
http_port_min:
type: int
description: HTTP server minimum port
default: 8802
http_port_max:
type: int
description: HTTP server maximum port
default: 8802
================================================
FILE: library/packer/proxmox-iso-ubuntu/variables.pkrvars.hcl.example
================================================
# Example Packer Variables File
# Copy this file to variables.pkrvars.hcl and fill in your values
# Proxmox Connection
proxmox_api_url = "https://proxmox.example.com:8006/api2/json"
proxmox_api_token_id = "root@pam!packer"
proxmox_api_token_secret = "your-secret-token-here"
================================================
FILE: library/terraform/cloudflare-dns-record/cloudflare_dns_record.tf.j2
================================================
resource "cloudflare_dns_record" "{{ resource_name }}" {
zone_id = data.cloudflare_zone.main.zone_id
name = "{{ name }}"
content = "{{ content }}"
type = "{{ record_type }}"
ttl = {{ ttl }}
proxied = {{ proxied | lower }}
{% if mx_enabled and record_type == "MX" %}
priority = {{ priority }}
{% endif %}
{% if comment_enabled %}
comment = "{{ comment_text }}"
{% endif %}
}
================================================
FILE: library/terraform/cloudflare-dns-record/cloudflare_zone.tf.j2
================================================
data "cloudflare_zone" "main" {
zone_id = "{{ zone_id_value }}"
}
================================================
FILE: library/terraform/cloudflare-dns-record/template.yaml
================================================
---
kind: terraform
metadata:
name: Cloudflare DNS Record
description: |-
Create Cloudflare DNS records (A, AAAA, CNAME, TXT, MX) with configurable settings.
Supports all common DNS record types with proxy status and TTL configuration.
Project: https://www.cloudflare.com/
Documentation: https://registry.terraform.io/providers/cloudflare/cloudflare/latest/docs/resources/dns_record
version: 5.12.0
author: Christian Lempa
date: "2025-11-11"
tags: []
icon:
provider: selfh
id: cloudflare
draft: false
next_steps: ""
schema: "1.2"
spec:
comment:
title: Comment
toggle: comment_enabled
vars:
comment_enabled:
description: Add comment to DNS record
type: bool
default: false
comment_text:
description: Comment for the DNS record
type: str
default: Managed by Terraform
general:
vars:
resource_name:
description: Terraform resource name (alphanumeric and underscores only)
type: str
default: dns_record
zone_id_value:
description: Cloudflare Zone ID
type: str
mx:
title: MX Record Settings
toggle: mx_enabled
vars:
mx_enabled:
description: Configure MX record priority
type: bool
default: false
priority:
description: MX record priority (lower = higher priority)
type: int
default: 10
record:
title: DNS Record
required: true
vars:
content:
description: DNS record content (IP, hostname, or text)
type: str
default: 192.0.2.1
name:
description: DNS record name (use @ for root domain)
type: str
default: www
proxied:
description: Enable Cloudflare proxy (orange cloud)
type: bool
default: true
record_type:
description: DNS record type
type: enum
default: A
options: [A, AAAA, CNAME, TXT, MX]
ttl:
description: Time to live (1 = automatic, or 60-86400 seconds)
type: int
default: 1
depends_on:
title: Dependencies
toggle: depends_on_enabled
vars:
depends_on_enabled:
description: Enable resource dependencies
type: bool
default: false
dependencies:
description: Comma-separated list of resource dependencies
type: str
default: ""
lifecycle:
title: Lifecycle
toggle: lifecycle_enabled
vars:
lifecycle_enabled:
description: Enable lifecycle rules
type: bool
default: false
prevent_destroy:
description: Prevent resource destruction
type: bool
default: false
create_before_destroy:
description: Create replacement before destroying
type: bool
default: false
ignore_changes:
description: Comma-separated list of attributes to ignore changes for
type: str
default: ""
tags:
title: Tags
toggle: tags_enabled
vars:
tags_enabled:
description: Enable tags
type: bool
default: false
tags_json:
description: Tags in JSON format
type: str
default: "{}"
================================================
FILE: library/terraform/cloudflare-ztna-application/cloudflare_account_zone.tf.j2
================================================
data "cloudflare_account" "main" {
account_id = "{{ account_id_value }}"
}
data "cloudflare_zone" "main" {
zone_id = "{{ zone_id_value }}"
}
================================================
FILE: library/terraform/cloudflare-ztna-application/cloudflare_zero_trust_access_application.tf.j2
================================================
resource "cloudflare_zero_trust_access_application" "{{ resource_name }}" {
zone_id = data.cloudflare_zone.main.zone_id
name = "{{ app_name }}"
domain = "{{ domain }}"
type = "self_hosted"
session_duration = "{{ session_duration }}"
policies = [
{% if service_token_enabled %}
{
id = cloudflare_zero_trust_access_policy.{{ resource_name }}_service_token.id
}{{ "," if ip_policy_enabled else "" }}
{% endif %}
{% if ip_policy_enabled %}
{
id = cloudflare_zero_trust_access_policy.{{ resource_name }}_ip.id
}
{% endif %}
]
}
================================================
FILE: library/terraform/cloudflare-ztna-application/cloudflare_zero_trust_access_policy.tf.j2
================================================
{% if service_token_enabled %}
resource "cloudflare_zero_trust_access_policy" "{{ resource_name }}_service_token" {
account_id = data.cloudflare_account.main.account_id
name = "{{ service_token_policy_name }}"
decision = "non_identity"
include = [{
service_token = {
token_id = "{{ service_token_id }}"
}
}]
session_duration = "{{ session_duration }}"
}
{% endif %}
{% if ip_policy_enabled %}
resource "cloudflare_zero_trust_access_policy" "{{ resource_name }}_ip" {
account_id = data.cloudflare_account.main.account_id
name = "{{ ip_policy_name }}"
decision = "non_identity"
include = [
{% for ip_range in ip_ranges.split(',') %}
{
ip = {
ip = "{{ ip_range.strip() }}"
}
}{{ "," if not loop.last else "" }}
{% endfor %}
]
session_duration = "{{ session_duration }}"
}
{% endif %}
================================================
FILE: library/terraform/cloudflare-ztna-application/template.yaml
================================================
---
kind: terraform
metadata:
name: Cloudflare Zero Trust Access Application
description: |-
Create Cloudflare Zero Trust Access application with policies.
Supports service token authentication and IP-based access policies.
Project: https://www.cloudflare.com/zero-trust/
Documentation: https://registry.terraform.io/providers/cloudflare/cloudflare/latest/docs/resources/zero_trust_access_application
version: 5.12.0
author: Christian Lempa
date: "2025-11-11"
tags: []
icon:
provider: selfh
id: cloudflare
draft: false
next_steps: ""
schema: "1.2"
spec:
application:
title: Application
required: true
vars:
app_name:
description: Application name
type: str
default: my_application
domain:
description: Application domain
type: hostname
default: app.example.com
session_duration:
description: Session duration (e.g., 15m, 1h, 24h)
type: str
default: 15m
general:
vars:
account_id_value:
description: Cloudflare Account ID
type: str
resource_name:
description: Terraform resource name (alphanumeric and underscores only)
type: str
default: ztna_app
zone_id_value:
description: Cloudflare Zone ID
type: str
ip_policy:
title: IP-Based Policy
toggle: ip_policy_enabled
vars:
ip_policy_enabled:
description: Enable IP-based policy
type: bool
default: false
ip_policy_name:
description: Policy name for IP-based access
type: str
default: ip_policy
ip_ranges:
description: Comma-separated list of IP ranges (CIDR notation)
type: str
default: 192.0.2.0/24
service_token_policy:
title: Service Token Policy
toggle: service_token_enabled
vars:
service_token_enabled:
description: Enable service token policy
type: bool
default: false
service_token_id:
description: Cloudflare service token ID
type: str
service_token_policy_name:
description: Policy name for service token
type: str
default: service_token_policy
depends_on:
title: Dependencies
toggle: depends_on_enabled
vars:
depends_on_enabled:
description: Enable resource dependencies
type: bool
default: false
dependencies:
description: Comma-separated list of resource dependencies
type: str
default: ""
lifecycle:
title: Lifecycle
toggle: lifecycle_enabled
vars:
lifecycle_enabled:
description: Enable lifecycle rules
type: bool
default: false
prevent_destroy:
description: Prevent resource destruction
type: bool
default: false
create_before_destroy:
description: Create replacement before destroying
type: bool
default: false
ignore_changes:
description: Comma-separated list of attributes to ignore changes for
type: str
default: ""
tags:
title: Tags
toggle: tags_enabled
vars:
tags_enabled:
description: Enable tags
type: bool
default: false
tags_json:
description: Tags in JSON format
type: str
default: "{}"
================================================
FILE: library/terraform/dns-a-record/dns_a_record_set.tf.j2
================================================
resource "dns_a_record_set" "{{ resource_name }}" {
zone = "{{ zone }}"
name = "{{ record_name }}"
addresses = [
"{{ ip_address }}"{% if multiple_addresses and additional_ips %},
{% for ip in additional_ips.split(',') %}
"{{ ip.strip() }}"{% if not loop.last %},{% endif %}
{% endfor %}
{% endif %}
]
ttl = {{ ttl }}
{% if depends_on_enabled %}
depends_on = [{{ dependencies }}]
{% endif %}
{% if lifecycle_enabled %}
lifecycle {
{% if prevent_destroy %}
prevent_destroy = true
{% endif %}
{% if create_before_destroy %}
create_before_destroy = true
{% endif %}
{% if ignore_changes %}
ignore_changes = [{{ ignore_changes }}]
{% endif %}
}
{% endif %}
}
================================================
FILE: library/terraform/dns-a-record/template.yaml
================================================
---
kind: terraform
metadata:
name: DNS A Record
description: |-
Create DNS A record for hostname to IP address mapping.
Supports single or multiple IP addresses with configurable TTL.
Project: https://www.terraform.io/
Documentation: https://registry.terraform.io/providers/hashicorp/dns/latest/docs/resources/a_record_set
version: 3.4.3
author: Christian Lempa
date: "2025-12-02"
tags: []
icon:
provider: selfh
id: bind-9
draft: false
next_steps: ""
schema: "1.2"
spec:
addresses:
title: IP Addresses
required: true
vars:
additional_ips:
description: Additional IP addresses (comma-separated, e.g., 10.20.0.11,10.20.0.12)
type: str
default: ""
needs: [multiple_addresses=true]
ip_address:
description: Primary IP address
type: str
default: 10.20.0.10
multiple_addresses:
description: Enable multiple IP addresses
type: bool
default: false
dns:
title: DNS Configuration
required: true
vars:
record_name:
description: Record name (hostname without domain)
type: str
default: server
ttl:
description: Time to live in seconds
type: int
default: 3600
zone:
description: DNS zone (must end with a dot)
type: str
default: home.example.com.
general:
vars:
resource_name:
type: str
default: record
depends_on:
title: Dependencies
toggle: depends_on_enabled
vars:
depends_on_enabled:
description: Enable resource dependencies
type: bool
default: false
dependencies:
description: Comma-separated list of resource dependencies
type: str
default: ""
lifecycle:
title: Lifecycle
toggle: lifecycle_enabled
vars:
lifecycle_enabled:
description: Enable lifecycle rules
type: bool
default: false
prevent_destroy:
description: Prevent resource destruction
type: bool
default: false
create_before_destroy:
description: Create replacement before destroying
type: bool
default: false
ignore_changes:
description: Comma-separated list of attributes to ignore changes for
type: str
default: ""
================================================
FILE: library/terraform/netbox-vm/netbox_virtual_machine.tf.j2
================================================
data "netbox_cluster" "{{ resource_name }}_cluster" {
name = "{{ cluster_ref }}"
}
data "netbox_site" "{{ resource_name }}_site" {
name = "{{ site_ref }}"
}
resource "netbox_virtual_machine" "{{ resource_name }}" {
name = "{{ vm_name }}"
cluster_id = data.netbox_cluster.{{ resource_name }}_cluster.id
{% if site_ref %}
site_id = data.netbox_site.{{ resource_name }}_site.id
{% endif %}
status = "{{ status }}"
{% if device_ref %}
device_id = netbox_device.{{ device_ref }}.id
{% endif %}
{% if resources_enabled %}
vcpus = {{ vcpus }}
memory = {{ memory_mb }}
disk = {{ disk_gb }}
{% endif %}
{% if description_enabled %}
comments = "{{ description_text }}"
{% endif %}
{% if depends_on_enabled %}
depends_on = [{{ dependencies }}]
{% endif %}
{% if lifecycle_enabled %}
lifecycle {
{% if prevent_destroy %}
prevent_destroy = true
{% endif %}
{% if create_before_destroy %}
create_before_destroy = true
{% endif %}
{% if ignore_changes %}
ignore_changes = [{{ ignore_changes }}]
{% endif %}
}
{% endif %}
}
{% if ipam_enabled %}
resource "netbox_interface" "{{ resource_name }}_interface" {
name = "{{ interface_name }}"
virtual_machine_id = netbox_virtual_machine.{{ resource_name }}.id
}
resource "netbox_ip_address" "{{ resource_name }}_ip" {
ip_address = "{{ primary_ip4 }}"
status = "active"
{% if dns_name %}
dns_name = "{{ dns_name }}"
{% endif %}
interface_id = netbox_interface.{{ resource_name }}_interface.id
object_type = "virtualization.vminterface"
}
resource "netbox_primary_ip" "{{ resource_name }}_primary_ip" {
ip_address_id = netbox_ip_address.{{ resource_name }}_ip.id
virtual_machine_id = netbox_virtual_machine.{{ resource_name }}.id
}
{% endif %}
================================================
FILE: library/terraform/netbox-vm/template.yaml
================================================
---
kind: terraform
metadata:
icon:
provider: selfh
id: netbox
name: NetBox Virtual Machine
description: >
Register a virtual machine in NetBox with cluster and site association.
Defines VM metadata including name, cluster, site, host device, and resource allocation.
Project: https://netboxlabs.com/
Documentation: https://registry.terraform.io/providers/e-breuninger/netbox/latest/docs/resources/virtual_machine
version: 5.0.0
author: Christian Lempa
date: '2025-12-02'
schema: "1.2"
spec:
general:
vars:
resource_name:
default: vm
vm:
title: Virtual Machine Configuration
vars:
vm_name:
type: str
description: Virtual machine name
required: true
default: srv-prod-1
cluster_ref:
type: str
description: Cluster resource name
required: true
default: pve_prod_1
status:
type: enum
description: VM status
options:
- active
- planned
- staged
- offline
- decommissioning
default: active
site:
title: Site Assignment
vars:
site_ref:
type: str
description: Site data source name (leave empty to skip)
device:
title: Host Device
vars:
device_ref:
type: str
description: Device resource name for host assignment (leave empty to skip)
ipam:
title: IP Address Management
toggle: ipam_enabled
vars:
ipam_enabled:
type: bool
description: Enable IP address assignment
default: false
interface_name:
type: str
description: Network interface name
default: eth0
required: true
primary_ip4:
type: str
description: Primary IPv4 address resource name
required: true
dns_name:
type: str
description: DNS name for the IP address (leave empty to skip)
resources:
title: Resource Allocation
toggle: resources_enabled
vars:
resources_enabled:
type: bool
description: Specify resource allocation
default: false
vcpus:
type: int
description: Number of virtual CPUs
default: 2
memory_mb:
type: int
description: Memory allocation in MB
default: 4096
disk_gb:
type: int
description: Disk size in GB
default: 50
description:
title: Description
toggle: description_enabled
vars:
description_enabled:
type: bool
description: Add VM description
default: false
description_text:
type: str
description: VM description
default: Managed by Terraform
depends_on:
title: Dependencies
toggle: depends_on_enabled
vars:
depends_on_enabled:
description: Enable resource dependencies
type: bool
default: false
dependencies:
description: Comma-separated list of resource dependencies
type: str
default: ""
lifecycle:
title: Lifecycle
toggle: lifecycle_enabled
vars:
lifecycle_enabled:
description: Enable lifecycle rules
type: bool
default: false
prevent_destroy:
description: Prevent resource destruction
type: bool
default: false
create_before_destroy:
description: Create replacement before destroying
type: bool
default: false
ignore_changes:
description: Comma-separated list of attributes to ignore changes for
type: str
default: ""
================================================
FILE: pyproject.toml
================================================
[build-system]
requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "boilerplates"
version = "0.1.3"
description = "CLI tool for managing infrastructure boilerplates"
readme = "README.md"
requires-python = ">=3.10"
license = "MIT"
authors = [ {name = "Christian Lempa"} ]
keywords = ["boilerplates", "cli", "infrastructure"]
classifiers = [
"Programming Language :: Python :: 3",
"Operating System :: OS Independent",
]
dependencies = [
"typer[all]>=0.9.0",
"rich>=13.0.0",
"PyYAML>=6.0",
"python-frontmatter>=1.0.0",
"Jinja2>=3.0",
"email-validator>=2.0.0",
]
[project.scripts]
boilerplates = "cli.__main__:run"
[tool.setuptools.packages.find]
include = ["cli*"]
exclude = ["tests*", "scripts*"]
[tool.setuptools.package-data]
cli = ["core/schema/**/*.json"]
[tool.ruff]
# Extended line length for better readability
line-length = 120
# Python 3.9+ as minimum version
target-version = "py39"
# Exclude common directories
exclude = [
".git",
"__pycache__",
".venv",
"venv",
"build",
"dist",
"*.egg-info",
]
[tool.ruff.lint]
# Enable rule categories
select = [
"E", # pycodestyle errors
"F", # Pyflakes
"W", # pycodestyle warnings
"I", # isort (import sorting)
"N", # pep8-naming
"UP", # pyupgrade (modern Python syntax)
"B", # flake8-bugbear (likely bugs)
"C4", # flake8-comprehensions
"SIM", # flake8-simplify
"RET", # flake8-return
"ARG", # flake8-unused-arguments
"PTH", # flake8-use-pathlib
"PL", # Pylint
"RUF", # Ruff-specific rules
"T20", # flake8-print
]
# Allow auto-fixing for these rules
fixable = ["ALL"]
unfixable = []
[tool.ruff.format]
# Use PEP 8 standard: 4 spaces for indentation
indent-style = "space"
# Use double quotes (consistent with Python conventions)
quote-style = "double"
# Unix line endings
line-ending = "lf"
[tool.ruff.lint.per-file-ignores]
# CLI command functions need many parameters for command-line arguments
"cli/core/module/base_module.py" = ["PLR0913"] # generate() needs all CLI options
"cli/core/repo.py" = ["PLR0913"] # add() needs all library config options
[tool.pytest.ini_options]
testpaths = ["tests"]
python_files = ["test_*.py"]
python_classes = ["Test*"]
python_functions = ["test_*"]
addopts = [
"-v",
"--strict-markers",
"--strict-config",
"--tb=short",
]
markers = [
"slow: marks tests as slow (deselect with '-m \"not slow\"')",
"integration: marks tests as integration tests",
]
================================================
FILE: renovate.json
================================================
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [
"config:recommended",
":dependencyDashboard",
":enableVulnerabilityAlertsWithLabel('security')",
":preserveSemverRanges",
":rebaseStalePrs",
"group:recommended"
],
"labels": [
"renovate"
],
"ignorePaths": [],
"useBaseBranchConfig": "merge",
"packageRules": [
{
"groupName": "devDependencies (non-major)",
"matchDepTypes": [
"devDependencies",
"require-dev"
],
"matchUpdateTypes": [
"digest",
"minor",
"patch"
]
},
{
"description": "Update MariaDB or MySQL on a patch level only, bumps to major and minor versions might break compatibility with an application",
"enabled": false,
"matchManagers": [
"custom.regex"
],
"matchUpdateTypes": [
"major",
"minor"
],
"matchPackageNames": [
"/^([^/]+\\/)*(mariadb|mysql)(:.+)?$/"
]
},
{
"description": "Update PostgreSQL on a minor version or patch level only, bumps to major versions might break compatibility with an application",
"enabled": false,
"matchManagers": [
"custom.regex"
],
"matchUpdateTypes": [
"major"
],
"matchPackageNames": [
"/^([^/]+\\/)*postgres(:.+)?$/"
]
},
{
"description": "Update MariaDB or PostgreSQL to the most recent release if they are standalone and not part of an application stack",
"enabled": true,
"matchManagers": [
"custom.regex"
],
"matchFileNames": [
"library/compose/mariadb/**",
"library/compose/postgres/**"
],
"matchUpdateTypes": [
"major",
"minor",
"patch"
],
"matchPackageNames": [
"/^([^/]+\\/)*(mariadb|postgres)(:.+)?$/"
]
},
{
"description": "Do not match Canonical's Ubuntu version suffix as a compatibility hint",
"matchManagers": [
"custom.regex"
],
"versioning": "regex:^(?\\d+)\\.(?\\d+)(\\.(?\\d+))?(?:-\\d+(?:\\.\\d+)+_edge)?$",
"matchPackageNames": [
"/^([^/]+\\/)*ubuntu/bind9(:.+)?$/"
]
},
{
"description": "Catch a potential `security` suffix as part of the patch release as Grafana does not follow semver for security releases",
"matchManagers": [
"custom.regex"
],
"versioning": "regex:^(?\\d+)\\.(?\\d+)\\.(?\\d+(?:-security-\\d+)?)$",
"matchPackageNames": [
"/^([^/]+\\/)*grafana/grafana-oss(:.+)?$/"
]
},
{
"description": "Over time Heimdall changed its versioning schema several times, ensure we only consider the current style",
"matchManagers": [
"custom.regex"
],
"versioning": "regex:^(?\\d{1,2})\\.(?\\d+)(\\.(?\\d+))?$",
"matchPackageNames": [
"/^([^/]+\\/)*heimdall(:.+)?$/"
]
},
{
"description": "Track stable releases of Nginx only",
"matchManagers": [
"custom.regex"
],
"versioning": "regex:^(?\\d+)\\.(?\\d*[02468])(\\.(?\\d+))?(?:-(?.*))?$",
"matchPackageNames": [
"/^([^/]+\\/)*nginx(:.+)?$/"
]
},
{
"description": "Ignore erroneous version tags of Semaphore",
"matchManagers": [
"custom.regex"
],
"allowedVersions": "!/^v?2\\.19\\.10$/",
"matchPackageNames": [
"/^([^/]+\\/)*semaphore(:.+)?$/"
]
}
],
"customManagers": [
{
"customType": "regex",
"description": "Update Docker images in Jinja2 compose templates",
"managerFilePatterns": [
"/^library/compose/.+\\.j2$/"
],
"matchStrings": [
"image:\\s*(?[^:\\s]+):(?[^\\s\\n{]+)"
],
"datasourceTemplate": "docker"
},
{
"customType": "regex",
"description": "Update Docker images in Helm values.yaml (repository + tag pattern)",
"managerFilePatterns": [
"/^library/kubernetes/.+/helm/values\\.ya?ml$/",
"/^library/kubernetes/.+\\.j2$/"
],
"matchStrings": [
"repository:\\s*[\"']?(?[^:\\s\"']+)[\"']?\\s*\\n\\s*tag:\\s*[\"']?(?[^\\s\"']+)[\"']?"
],
"datasourceTemplate": "docker"
},
{
"customType": "regex",
"description": "Update Terraform/OpenTofu providers and modules in templates",
"managerFilePatterns": [
"/^library/terraform/.+\\.tf$/",
"/^library/terraform/.+\\.j2$/"
],
"matchStrings": [
"(?:source|module)\\s*=\\s*[\"'](?[^\"']+)[\"'](?:[\\s\\S]*?)version\\s*=\\s*[\"'](?[^\"']+)[\"']"
],
"datasourceTemplate": "terraform-provider"
}
],
"gitAuthor": "github-actions[bot] ",
"separateMinorPatch": true,
"stopUpdatingLabel": "renovate/stop_updating"
}
================================================
FILE: requirements.txt
================================================
typer==0.23.1
rich==14.3.2
PyYAML==6.0.3
python-frontmatter==1.1.0
Jinja2==3.1.6
================================================
FILE: scripts/install.sh
================================================
#!/usr/bin/env bash
set -euo pipefail
REPO_OWNER="christianlempa"
REPO_NAME="boilerplates"
VERSION="${VERSION:-latest}"
AUTO_INSTALL="${AUTO_INSTALL:-true}"
usage() {
cat <&2; }
error() { printf '[boilerplates][error] %s\n' "$*" >&2; exit 1; }
detect_os() {
if [[ "$OSTYPE" == "darwin"* ]]; then
OS_TYPE="macos"
elif [[ -f /etc/os-release ]]; then
OS_TYPE="linux"
. /etc/os-release
DISTRO_ID="$ID"
DISTRO_VERSION="${VERSION_ID:-}"
else
OS_TYPE="unknown"
fi
}
install_dependencies_macos() {
log "Detected macOS"
if ! command -v brew >/dev/null 2>&1; then
log "Homebrew not found. Installing Homebrew..."
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" || error "Failed to install Homebrew"
fi
if ! command -v python3 >/dev/null 2>&1; then
log "Installing Python3..."
brew install python3 || error "Failed to install Python3"
fi
if ! command -v git >/dev/null 2>&1; then
log "Installing git..."
brew install git || error "Failed to install git"
fi
if ! command -v pipx >/dev/null 2>&1; then
log "Installing pipx..."
brew install pipx || error "Failed to install pipx"
pipx ensurepath
fi
}
install_dependencies_linux() {
log "Detected Linux ($DISTRO_ID)"
case "$DISTRO_ID" in
ubuntu|debian|pop|linuxmint|elementary)
PKG_MANAGER="apt"
PYTHON_PKG="python3 python3-pip python3-venv"
PIPX_PKG="pipx"
GIT_PKG="git"
UPDATE_CMD="sudo apt update"
INSTALL_CMD="sudo apt install -y"
;;
fedora|rhel|centos|rocky|almalinux)
PKG_MANAGER="dnf"
PYTHON_PKG="python3 python3-pip"
PIPX_PKG="pipx"
GIT_PKG="git"
UPDATE_CMD="sudo dnf check-update || true"
INSTALL_CMD="sudo dnf install -y"
;;
opensuse*|sles)
PKG_MANAGER="zypper"
PYTHON_PKG="python3 python3-pip"
PIPX_PKG="python3-pipx"
GIT_PKG="git"
UPDATE_CMD="sudo zypper refresh"
INSTALL_CMD="sudo zypper install -y"
;;
arch|archarm|manjaro|endeavouros)
PKG_MANAGER="pacman"
PYTHON_PKG="python python-pip"
PIPX_PKG="python-pipx"
GIT_PKG="git"
UPDATE_CMD="sudo pacman -Sy"
INSTALL_CMD="sudo pacman -S --noconfirm"
;;
alpine)
PKG_MANAGER="apk"
PYTHON_PKG="python3 py3-pip"
PIPX_PKG="pipx"
GIT_PKG="git"
UPDATE_CMD="sudo apk update"
INSTALL_CMD="sudo apk add"
;;
*)
log "Unsupported Linux distribution: $DISTRO_ID"
log "Please install manually: python3, pip, git, and pipx"
return 1
;;
esac
if ! command -v python3 >/dev/null 2>&1; then
log "Installing Python3..."
$UPDATE_CMD
$INSTALL_CMD $PYTHON_PKG || error "Failed to install Python3"
fi
if ! command -v git >/dev/null 2>&1; then
log "Installing git..."
$INSTALL_CMD $GIT_PKG || error "Failed to install git"
fi
if ! python3 -m pip --version >/dev/null 2>&1; then
log "pip not available, installing..."
$INSTALL_CMD $PYTHON_PKG || error "Failed to install pip"
fi
if ! command -v pipx >/dev/null 2>&1 && [[ ! -x "$(python3 -m site --user-base 2>/dev/null)/bin/pipx" ]]; then
log "Installing pipx..."
# Try system package first if available
if [[ -n "${PIPX_PKG:-}" ]]; then
if $INSTALL_CMD $PIPX_PKG >/dev/null 2>&1; then
log "pipx installed from system package"
else
# System package failed, try pip with --break-system-packages
if python3 -m pip install --user --break-system-packages pipx 2>&1 | grep -q "Successfully installed"; then
log "pipx installed via pip"
elif python3 -m pip install --user pipx 2>&1 | grep -q "Successfully installed"; then
log "pipx installed via pip"
else
error "Failed to install pipx. Try installing manually: sudo apt install pipx"
fi
fi
else
# No system package, use pip
if python3 -m pip install --user --break-system-packages pipx 2>&1 | grep -q "Successfully installed"; then
log "pipx installed via pip"
elif python3 -m pip install --user pipx 2>&1 | grep -q "Successfully installed"; then
log "pipx installed via pip"
else
error "Failed to install pipx"
fi
fi
# Ensure pipx is in PATH
if command -v pipx >/dev/null 2>&1; then
pipx ensurepath >/dev/null 2>&1
elif [[ -x "$(python3 -m site --user-base 2>/dev/null)/bin/pipx" ]]; then
"$(python3 -m site --user-base)/bin/pipx" ensurepath >/dev/null 2>&1
fi
fi
}
check_python_version() {
if ! command -v python3 >/dev/null 2>&1; then
# Python not installed yet - will be handled by check_dependencies
return 0
fi
local python_version=$(python3 -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")' 2>/dev/null)
if [[ -z "$python_version" ]]; then
log "Warning: Could not determine Python version"
return 0
fi
local major=$(echo "$python_version" | cut -d. -f1)
local minor=$(echo "$python_version" | cut -d. -f2)
if [[ "$major" -lt 3 ]] || { [[ "$major" -eq 3 ]] && [[ "$minor" -lt 10 ]]; }; then
error "Python 3.10 or higher is required. Found: Python $python_version
Boilerplates requires Python 3.10+ for modern type hint syntax.
Your system has Python $python_version installed.
On AlmaLinux/RHEL/CentOS/Rocky Linux 9:
sudo dnf install python3.11
pipx reinstall --python python3.11 boilerplates
On Debian/Ubuntu:
sudo apt install python3.11
pipx reinstall --python python3.11 boilerplates
Alternatively, use pyenv to install Python 3.11+:
https://github.com/pyenv/pyenv#installation"
fi
log "Python version: $python_version (OK)"
}
check_dependencies() {
local missing_deps=()
command -v tar >/dev/null 2>&1 || missing_deps+=("tar")
command -v mktemp >/dev/null 2>&1 || missing_deps+=("mktemp")
if [[ ${#missing_deps[@]} -gt 0 ]]; then
error "Required system tools missing: ${missing_deps[*]}"
fi
local needs_install=false
if ! command -v python3 >/dev/null 2>&1; then
log "Python3 not found"
needs_install=true
fi
if ! command -v git >/dev/null 2>&1; then
log "git not found"
needs_install=true
fi
if ! python3 -m pip --version >/dev/null 2>&1; then
log "pip not found"
needs_install=true
fi
if ! command -v pipx >/dev/null 2>&1 && [[ ! -x "$(python3 -m site --user-base 2>/dev/null)/bin/pipx" ]]; then
log "pipx not found"
needs_install=true
fi
if [[ "$needs_install" == "true" ]]; then
if [[ "$AUTO_INSTALL" == "true" ]]; then
log "Installing missing dependencies..."
detect_os
if [[ "$OS_TYPE" == "macos" ]]; then
install_dependencies_macos
elif [[ "$OS_TYPE" == "linux" ]]; then
install_dependencies_linux
else
error "Unsupported OS. Please install manually: python3, pip, git, and pipx"
fi
else
error "Missing dependencies. Install: python3, pip, git, and pipx (or run without --no-auto-install)"
fi
fi
if command -v pipx >/dev/null 2>&1; then
PIPX_CMD="pipx"
elif [[ -x "$(python3 -m site --user-base 2>/dev/null)/bin/pipx" ]]; then
PIPX_CMD="$(python3 -m site --user-base)/bin/pipx"
else
error "pipx installation failed or not found in PATH. Try: python3 -m pip install --user pipx && python3 -m pipx ensurepath"
fi
log "All dependencies available"
# Check Python version after dependencies are installed
check_python_version
}
parse_args() {
while [[ $# -gt 0 ]]; do
case "$1" in
--version)
[[ $# -lt 2 ]] && error "--version requires an argument"
[[ "$2" =~ ^- ]] && error "--version requires a version string, not an option"
VERSION="$2"
shift 2
;;
--no-auto-install)
AUTO_INSTALL="false"
shift
;;
-h|--help) usage; exit 0 ;;
*) error "Unknown option: $1" ;;
esac
done
}
get_latest_release() {
local api_url="https://api.github.com/repos/$REPO_OWNER/$REPO_NAME/releases/latest"
local result
if command -v curl >/dev/null 2>&1; then
result=$(curl -qfsSL --max-time 10 "$api_url" 2>/dev/null | sed -En 's/.*"tag_name": "([^"]+)".*/\1/p')
elif command -v wget >/dev/null 2>&1; then
result=$(wget --timeout=10 -qO- "$api_url" 2>/dev/null | sed -En 's/.*"tag_name": "([^"]+)".*/\1/p')
else
error "Neither curl nor wget found"
fi
[[ -z "$result" ]] && error "Failed to fetch release information from GitHub"
echo "$result"
}
download_and_extract() {
local version="$1"
# Resolve "latest" to actual version
if [[ "$version" == "latest" ]]; then
log "Fetching latest release..."
version=$(get_latest_release)
log "Latest version: $version"
fi
# Ensure 'v' prefix for URL
local version_tag="$version"
[[ "$version_tag" =~ ^v ]] || version_tag="v$version_tag"
# Strip 'v' prefix for package name
local version_number="${version_tag#v}"
# Download from release assets (sdist)
local url="https://github.com/$REPO_OWNER/$REPO_NAME/releases/download/$version_tag/$REPO_NAME-$version_number.tar.gz"
TEMP_DIR=$(mktemp -d)
local archive="$TEMP_DIR/boilerplates.tar.gz"
log "Downloading $version_tag from release assets..."
if command -v curl >/dev/null 2>&1; then
curl -qfsSL --max-time 30 -o "$archive" "$url" || error "Download failed. URL: $url"
elif command -v wget >/dev/null 2>&1; then
wget --timeout=30 -qO "$archive" "$url" || error "Download failed. URL: $url"
fi
log "Extracting package..."
# Extract the tarball
tar -xzf "$archive" -C "$TEMP_DIR" || error "Extraction failed"
# Find the extracted directory (should be boilerplates-X.Y.Z)
local source_dir=$(find "$TEMP_DIR" -maxdepth 1 -type d -name "$REPO_NAME-*" | head -n1)
[[ -z "$source_dir" ]] && error "Failed to locate extracted files"
# Verify essential files exist
[[ ! -f "$source_dir/setup.py" ]] && [[ ! -f "$source_dir/pyproject.toml" ]] && \
error "Invalid package: missing setup.py or pyproject.toml"
# Return the path to the extracted directory
echo "$source_dir"
}
install_cli() {
local package_path="$1"
local version="$2"
log "Installing CLI via pipx..."
"$PIPX_CMD" ensurepath 2>&1 | grep -v "^$" || true
# Install from tarball
if ! "$PIPX_CMD" install --force "$package_path" >/dev/null 2>&1; then
error "pipx installation failed. Try: pipx uninstall boilerplates && pipx install boilerplates"
fi
log "CLI installed successfully"
# Verify installation
if command -v boilerplates >/dev/null 2>&1; then
log "Command 'boilerplates' is now available"
else
log "Warning: 'boilerplates' command not found in PATH. You may need to restart your shell or run: pipx ensurepath"
fi
}
main() {
parse_args "$@"
# Ensure cleanup on exit
trap '[[ -d "${TEMP_DIR:-}" ]] && rm -rf "$TEMP_DIR"' EXIT
log "Checking dependencies..."
check_dependencies
local package_path=$(download_and_extract "$VERSION")
install_cli "$package_path" "$VERSION"
# Get installed version
local installed_version=$(boilerplates --version 2>/dev/null | grep -oE 'v?[0-9]+\.[0-9]+\.[0-9]+' || echo "unknown")
cat <