Skip to content
GitHub stars

Template Guide ​

Templates are the core of Lynq's resource generation system. This guide covers template syntax, available functions, and best practices.

Template Basics ​

Lynq uses Go's text/template engine with the Sprig function library, providing 200+ built-in functions.

Template Syntax ​

yaml
# Basic variable substitution
nameTemplate: "{{ .uid }}-app"

# With function
nameTemplate: "{{ .uid | trunc63 }}"

# Conditional
nameTemplate: "{{ if .region }}{{ .region }}-{{ end }}{{ .uid }}"

# Default value
nameTemplate: "{{ .uid }}-{{ .planId | default \"basic\" }}"

Available Variables ​

Required Variables ​

These are always available from the template context:

yaml
.uid         # Node unique identifier (from uid mapping)
.activate    # Activation status (from activate mapping)

Deprecated Variables ​

DEPRECATED

The following variables are deprecated since v1.1.11 and will be removed in v1.3.0:

yaml
.hostOrUrl   # Original URL/host from registry (from hostOrUrl mapping)
.host        # Auto-extracted host from .hostOrUrl

Migration Guide

Instead of using .hostOrUrl and .host:

Before (deprecated):

yaml
# In LynqHub
spec:
  valueMappings:
    uid: node_id
    hostOrUrl: domain_url  # ⚠️ Deprecated
    activate: is_active

# In template
env:
  - name: HOST
    value: "{{ .host }}"  # ⚠️ Deprecated

After (v1.1.11+):

yaml
# In LynqHub
spec:
  valueMappings:
    uid: node_id
    activate: is_active
  extraValueMappings:
    domainUrl: domain_url  # βœ… Use extraValueMappings

# In template
env:
  - name: HOST
    value: "{{ .domainUrl | toHost }}"  # βœ… Use toHost() function

Why this change? Lynq is now a database-driven automation platform, not limited to tenant/host provisioning. Requiring .hostOrUrl was an unnecessary constraint from the legacy "tenant-operator" design.

Context Variables ​

Automatically provided:

yaml
.hubId   # LynqHub name
.templateRef  # LynqForm name

Custom Variables ​

From extraValueMappings in LynqHub:

yaml
spec:
  extraValueMappings:
    planId: subscription_plan
    region: deployment_region
    dbHost: database_host

Access in templates:

yaml
.planId   # Maps to subscription_plan column
.region   # Maps to deployment_region column
.dbHost   # Maps to database_host column

Template Functions ​

Built-in Custom Functions ​

toHost(url) βœ… ​

Extract hostname from URL:

Recommended Usage

Use with extraValueMappings instead of deprecated .hostOrUrl:

yaml
# Input: https://acme.example.com:8080/path
# Output: acme.example.com
env:
- name: HOST
  value: "{{ .domainUrl | toHost }}"  # βœ… Recommended (v1.1.11+)
  # value: "{{ .hostOrUrl | toHost }}"  # ⚠️ Deprecated, removed in v1.3.0

trunc63(s) βœ… ​

Truncate to 63 characters (Kubernetes name limit):

yaml
# Ensures name fits K8s limits
nameTemplate: "{{ .uid }}-{{ .region }}-deployment | trunc63 }}"

sha1sum(s) βœ… ​

Generate SHA1 hash:

yaml
# Create unique, stable resource names
nameTemplate: "app-{{ .uid | sha1sum | trunc 8 }}"

fromJson(s) βœ… ​

Parse JSON string:

yaml
# Parse JSON configuration from database
env:
- name: API_KEY
  value: "{{ (.config | fromJson).apiKey }}"
- name: ENDPOINT
  value: "{{ (.config | fromJson).endpoint }}"

Type Conversion Functions ^1.1.15 ​

New in v1.1.15

Type conversion functions solve the YAML quoting problem, enabling proper type conversion for Kubernetes resource fields.

The YAML Quoting Problem ​

When writing LynqForm CRDs in YAML, template expressions must be quoted due to YAML parser requirements:

yaml
# ❌ INVALID YAML - Parser error
containerPort: {{ .appPort }}

# βœ… VALID YAML - Must quote template expressions
containerPort: "{{ .appPort }}"

The problem: While quotes make the YAML valid, the rendered output remains a quoted string, causing Kubernetes API validation to fail for numeric/boolean fields:

yaml
# After template rendering (without type functions)
containerPort: "8080"  # ❌ String - Kubernetes API rejects
replicas: "3"          # ❌ String - Expected integer
enabled: "true"        # ❌ String - Expected boolean

# Expected by Kubernetes
containerPort: 8080    # βœ… Integer
replicas: 3            # βœ… Integer
enabled: true          # βœ… Boolean

Type Conversion Functions ​

toInt(value) ​

Convert value to integer (int):

yaml
# Basic conversion
replicas: "{{ .maxReplicas | toInt }}"          # "3" β†’ 3
containerPort: "{{ .appPort | toInt }}"         # "8080" β†’ 8080

# With default value
replicas: "{{ .replicas | default \"2\" | toInt }}"  # Ensures integer output

# From float (truncates)
value: "{{ .cpuCount | toInt }}"                # 2.8 β†’ 2

Conversion rules:

  • String β†’ int: "123" β†’ 123
  • Float β†’ int: 2.8 β†’ 2 (truncates)
  • Already int: Returns as-is
  • Invalid input: Returns 0 (graceful fallback)

toFloat(value) ​

Convert value to floating-point number (float64):

yaml
# Resource limits with decimals
resources:
  limits:
    cpu: "{{ .cpuLimit | toFloat }}"            # "1.5" β†’ 1.5
    memory: "{{ .memoryGb | toFloat }}Gi"       # "2.5" β†’ "2.5Gi"

# Percentage calculations
targetCPUUtilization: "{{ .threshold | toFloat }}"  # "75.5" β†’ 75.5

Conversion rules:

  • String β†’ float: "1.5" β†’ 1.5
  • Int β†’ float: 2 β†’ 2.0
  • Already float: Returns as-is
  • Invalid input: Returns 0.0

toBool(value) ​

Convert value to boolean:

yaml
# Feature flags
enabled: "{{ .featureEnabled | toBool }}"       # "true" β†’ true
readOnly: "{{ .isReadOnly | toBool }}"          # "false" β†’ false

# From integers (common in databases)
automountServiceAccountToken: "{{ .autoMount | toBool }}"  # 1 β†’ true, 0 β†’ false

Truthy values (converted to true):

  • Strings: "true", "True", "TRUE", "1", "yes", "Yes", "YES", "y", "Y"
  • Numbers: Any non-zero integer (1, 42, -5)
  • Boolean: true

Falsy values (converted to false):

  • Strings: "false", "False", "FALSE", "0", "no", "No", "NO", "n", "N", "" (empty)
  • Numbers: 0
  • Boolean: false

Complete Example ​

yaml
apiVersion: lynq.sh/v1
kind: LynqForm
metadata:
  name: typed-app
spec:
  hubId: production-db
  deployments:
    - id: app
      nameTemplate: "{{ .uid }}-api"
      spec:
        apiVersion: apps/v1
        kind: Deployment
        spec:
          # Integer field - type conversion required
          replicas: "{{ .maxReplicas | default \"2\" | toInt }}"
          template:
            spec:
              # Boolean field - type conversion required
              automountServiceAccountToken: "{{ .mountToken | toBool }}"
              containers:
                - name: app
                  image: "{{ .image }}"
                  ports:
                    # Integer field - type conversion required
                    - containerPort: "{{ .appPort | toInt }}"
                      protocol: TCP
                  env:
                    # String fields - no conversion needed
                    - name: APP_ENV
                      value: "{{ .environment }}"
                    - name: TENANT_ID
                      value: "{{ .uid }}"
                    # Integer env var - stays as string (env values are always strings)
                    - name: MAX_CONNECTIONS
                      value: "{{ .maxConns }}"
                  resources:
                    limits:
                      # Float field - type conversion required
                      cpu: "{{ .cpuLimit | toFloat }}"
                      memory: "{{ .memoryLimit }}Mi"
                    requests:
                      cpu: "{{ .cpuRequest | toFloat }}"
                      memory: "{{ .memoryRequest }}Mi"

When to Use Type Conversion ​

Guidelines

βœ… Use type conversion for:

  • Kubernetes resource fields expecting numbers: replicas, containerPort, targetPort
  • Boolean fields: automountServiceAccountToken, readOnlyRootFilesystem, privileged
  • Numeric resource limits: cpu, memory (when using float values)
  • HPA/VPA metrics: targetCPUUtilizationPercentage, minReplicas, maxReplicas

❌ Don't use type conversion for:

  • Environment variable values (always strings in containers)
  • Labels and annotations (always strings)
  • Command arguments (always strings)
  • ConfigMap/Secret data values (always strings)
  • Image tags (always strings, even if numeric like "1.2.3")

How It Works (Technical Details) ​

The hybrid approach uses type markers internally:

  1. Template function wraps result with marker:

    go
    toInt("42") β†’ "__LYNQ_TYPE_INT__42"  // Internal representation
  2. Go template engine processes normally:

    • Template rendering treats marker as string
    • Marker survives template evaluation
  3. Controller automatically restores type:

    go
    renderUnstructured() detects marker β†’ converts to int β†’ 42
  4. Kubernetes receives correctly-typed value:

    yaml
    containerPort: 42  # Pure integer, no quotes
Why not use Sprig's atoi?

Go's text/template engine always returns rendered results as strings, regardless of function return types. Sprig's atoi returns an integer during template execution, but the final output is still a string.

The type marker approach is the only way to preserve type information across the template rendering boundary.

Sprig Functions (200+) ​

Full documentation: https://masterminds.github.io/sprig/

String Functions ​

yaml
# Uppercase/lowercase
nameTemplate: "{{ .uid | upper }}"
nameTemplate: "{{ .uid | lower }}"

# Trim whitespace
value: "{{ .name | trim }}"

# Replace
value: "{{ .uid | replace \".\" \"-\" }}"

# Quote
value: "{{ .name | quote }}"

Encoding Functions ​

yaml
# Base64 encode/decode
value: "{{ .secret | b64enc }}"
value: "{{ .encoded | b64dec }}"

# URL encoding
value: "{{ .param | urlquery }}"

# SHA256
value: "{{ .data | sha256sum }}"

Default Values ​

yaml
# Provide default if variable is empty
image: "{{ .deployImage | default \"nginx:stable\" }}"
port: "{{ .appPort | default \"8080\" }}"
region: "{{ .region | default \"us-east-1\" }}"

Conditionals ​

yaml
# If/else
env:
- name: DEBUG
  value: "{{ if eq .planId \"enterprise\" }}true{{ else }}false{{ end }}"

# Ternary
replicas: {{ ternary 5 2 (eq .planId "enterprise") }}

Lists and Iteration ​

yaml
# Join
annotations:
  tags: "{{ list \"app\" .uid .region | join \",\" }}"

# Iterate (in ConfigMap data)
data:
  config.json: |
    {
      "tenants": [
        {{- range $i, $id := list \"tenant1\" \"tenant2\" }}
        {{ if $i }},{{ end }}
        "{{ $id }}"
        {{- end }}
      ]
    }

Math Functions ​

yaml
# Arithmetic
value: "{{ add .basePort 1000 }}"
value: "{{ mul .cpuLimit 2 }}"

# Min/max
value: "{{ max .minReplicas 3 }}"

Template Examples ​

Example 1: Multi-Region Deployment with Type Conversion ​

yaml
deployments:
  - id: app
    nameTemplate: "{{ .uid }}-{{ .region | default \"default\" }}"
    spec:
      apiVersion: apps/v1
      kind: Deployment
      spec:
        # Integer field - use toInt for conditional numeric values
        replicas: "{{ if eq .planId \"enterprise\" }}5{{ else }}2{{ end | toInt }}"
        template:
          spec:
            # Boolean field - use toBool
            automountServiceAccountToken: "{{ .autoMount | default \"true\" | toBool }}"
            containers:
            - name: app
              image: "{{ .deployImage | default \"myapp:latest\" }}"
              ports:
              - containerPort: "{{ .appPort | default \"8080\" | toInt }}"
                protocol: TCP
              env:
              - name: TENANT_ID
                value: "{{ .uid }}"
              - name: REGION
                value: "{{ .region | default \"us-east-1\" }}"
              - name: DATABASE_HOST
                value: "{{ .dbHost }}"
              resources:
                limits:
                  cpu: "{{ .cpuLimit | default \"1.0\" | toFloat }}"
                  memory: "{{ .memoryLimit | default \"512\" }}Mi"
                requests:
                  cpu: "{{ .cpuRequest | default \"0.5\" | toFloat }}"
                  memory: "{{ .memoryRequest | default \"256\" }}Mi"

Example 2: Secure Resource Names ​

yaml
# Use SHA1 to create short, unique, stable names
nameTemplate: "{{ .uid | sha1sum | trunc 8 }}-app"

# Ensures:
# - Fixed length (8 chars + "-app")
# - Unique per node
# - Stable across reconciliations
# - URL-safe

Example 3: JSON Configuration ​

Database column contains JSON:

json
{
  "apiKey": "sk-abc123",
  "features": ["feature-a", "feature-b"],
  "limits": {"requests": 1000}
}

Template usage:

yaml
env:
- name: API_KEY
  value: "{{ (.config | fromJson).apiKey }}"
- name: RATE_LIMIT
  value: "{{ (.config | fromJson).limits.requests }}"

Example 4: Conditional Resources ​

yaml
# Only create Redis for premium plans
{{- if or (eq .planId "premium") (eq .planId "enterprise") }}
deployments:
  - id: redis
    nameTemplate: "{{ .uid }}-redis"
    spec:
      apiVersion: apps/v1
      kind: Deployment
      spec:
        replicas: 1
        template:
          spec:
            containers:
            - name: redis
              image: redis:7-alpine
{{- end }}

Example 5: Dynamic Labels ​

yaml
labelsTemplate:
  app: "{{ .uid }}"
  node: "{{ .uid }}"
  plan: "{{ .planId | default \"basic\" }}"
  region: "{{ .region | default \"global\" }}"
  managed-by: "lynq"
  version: "{{ .appVersion | default \"v1.0.0\" }}"

Template Best Practices ​

1. Use Default Values ​

Always provide defaults for optional variables:

yaml
# Good
image: "{{ .deployImage | default \"nginx:stable\" }}"

# Bad (fails if deployImage is empty)
image: "{{ .deployImage }}"

2. Respect Kubernetes Naming Limits ​

Always truncate names that might exceed 63 characters:

yaml
# Good
nameTemplate: "{{ .uid }}-{{ .region }}-deployment | trunc63 }}"

# Bad (can exceed 63 chars)
nameTemplate: "{{ .uid }}-{{ .region }}-deployment"

3. Quote String Values in YAML ​

yaml
# Good
value: "{{ .uid }}"

# Bad (can cause YAML parsing errors)
value: {{ .uid }}

4. Use Type Conversion for Kubernetes Fields ​

Always use type conversion functions for fields expecting numbers or booleans:

yaml
# Good - Kubernetes receives correct types
replicas: "{{ .maxReplicas | toInt }}"
containerPort: "{{ .appPort | toInt }}"
automountServiceAccountToken: "{{ .autoMount | toBool }}"

# Bad - Kubernetes API validation fails
replicas: "{{ .maxReplicas }}"        # String "3" instead of 3
containerPort: "{{ .appPort }}"       # String "8080" instead of 8080

Common fields requiring type conversion:

  • Integers: replicas, containerPort, targetPort, minReplicas, maxReplicas
  • Floats: cpu (resource limits), targetCPUUtilizationPercentage
  • Booleans: automountServiceAccountToken, readOnlyRootFilesystem, privileged

Exception: Environment variables are always strings, don't convert:

yaml
# Good - env values are always strings
env:
  - name: PORT
    value: "{{ .appPort }}"  # Keep as string

# Unnecessary
env:
  - name: PORT
    value: "{{ .appPort | toInt }}"  # Don't convert

5. Handle Missing Variables Gracefully ​

yaml
# Good - provides default and checks existence
{{- if .optionalField }}
value: "{{ .optionalField }}"
{{- else }}
value: "default-value"
{{- end }}

# Or simpler
value: "{{ .optionalField | default \"default-value\" }}"

6. Combine Type Conversion with Default Values ​

Chain type conversion after default values to ensure proper types:

yaml
# Good - provides default then converts type
replicas: "{{ .replicas | default \"2\" | toInt }}"
cpu: "{{ .cpuLimit | default \"1.0\" | toFloat }}"
enabled: "{{ .feature | default \"true\" | toBool }}"

# Bad - type conversion on potentially empty value
replicas: "{{ .replicas | toInt }}"  # May fail if .replicas is empty

7. Use Comments for Complex Logic ​

yaml
# Calculate replicas based on plan tier
# enterprise: 5, premium: 3, basic: 2
replicas: {{- if eq .planId "enterprise" }}5{{- else if eq .planId "premium" }}3{{- else }}2{{- end }}

Template Rendering Process ​

1. Variable Collection ​

Hub controller collects variables from database row:

uid = "acme-corp"
# hostOrUrl = "https://acme.example.com"  # DEPRECATED v1.1.11+
activate = true
planId = "enterprise"
customUrl = "https://acme.example.com"  # Use extraValueMappings instead

2. Auto-Processing (Deprecated) ​

Note: This step is deprecated since v1.1.11.

Operator automatically extracts .host:

# .host = "acme.example.com"  # DEPRECATED - extracted from .hostOrUrl

New approach (v1.1.11+): Use toHost() function in templates:

# In template:
value: "{{ .customUrl | toHost }}"  # Extracts "acme.example.com"

3. Template Evaluation ​

For each resource in the template:

  • Render nameTemplate β†’ resource name
  • Render labelsTemplate β†’ labels
  • Render annotationsTemplate β†’ annotations
  • Render spec β†’ recursively render all string values in the resource

4. Resource Creation ​

Rendered resource is applied to Kubernetes using Server-Side Apply.

Debugging Templates ​

Check Rendered Values ​

View rendered LynqNode CR to see evaluated templates:

bash
# Get LynqNode CR
kubectl get lynqnode <lynqnode-name> -o yaml

# Check spec (contains rendered resources)
kubectl get lynqnode <lynqnode-name> -o jsonpath='{.spec.deployments[0].nameTemplate}'

Watch for Rendering Errors ​

bash
# Check operator logs
kubectl logs -n lynq-system deployment/lynq-controller-manager -f | grep "render"

# Check Node events
kubectl describe lynqnode <lynqnode-name>

Common Errors ​

Error: template: tmpl:1: function "unknownFunc" not defined

Error: template: tmpl:1:10: executing "tmpl" at <.missingVar>: map has no entry for key "missingVar"

  • Cause: Referencing a variable that doesn't exist
  • Fix: Use default function or check extraValueMappings

Error: yaml: line 10: mapping values are not allowed in this context

  • Cause: Missing quotes around template
  • Fix: Always quote templates: "{{ .value }}"

Error: admission webhook denied: spec.replicas: Invalid value: "string": spec.replicas in body must be of type integer

  • Cause: Kubernetes field received string instead of number
  • Fix: Use type conversion function:
    yaml
    # Before (error)
    replicas: "{{ .maxReplicas }}"  # Renders as string "3"
    
    # After (correct)
    replicas: "{{ .maxReplicas | toInt }}"  # Renders as integer 3

Error: json: cannot unmarshal string into Go struct field ... of type int32

  • Cause: Integer field received quoted string value
  • Fix: Use toInt for integer fields:
    yaml
    containerPort: "{{ .port | toInt }}"        # int/int32
    replicas: "{{ .replicas | toInt }}"         # int32

Error: json: cannot unmarshal string into Go struct field ... of type bool

  • Cause: Boolean field received string value
  • Fix: Use toBool for boolean fields:
    yaml
    automountServiceAccountToken: "{{ .autoMount | toBool }}"
    readOnlyRootFilesystem: "{{ .readOnly | toBool }}"

Advanced Template Techniques ​

Nested Templates ​

yaml
# Define reusable template values
{{- $appName := printf "%s-app" .uid }}

nameTemplate: "{{ $appName }}"

Range Over Lists ​

yaml
# In ConfigMap data field
data:
  endpoints.txt: |
    {{- range $i, $region := list "us-east-1" "us-west-2" "eu-west-1" }}
    {{ $region }}.example.com
    {{- end }}

Complex JSON Parsing ​

yaml
# Database field: config = '{"db":{"host":"localhost","port":5432}}'
env:
- name: DB_HOST
  value: "{{ ((.config | fromJson).db).host }}"
- name: DB_PORT
  value: "{{ ((.config | fromJson).db).port }}"

Template Evolution ​

Dynamic Updates

LynqForms can be safely modified at runtime. The operator automatically handles resource additions, modifications, and removals.

Adding Resources ​

New resources are automatically created during the next reconciliation:

yaml
# Add a new service to existing template
services:
  - id: api-service
    nameTemplate: "{{ .uid }}-api"
    spec:
      apiVersion: v1
      kind: Service
      # ... service spec

Result: Service is created for all existing LynqNodes using this template.

Modifying Resources ​

Existing resources are updated according to their patchStrategy:

yaml
deployments:
  - id: web
    patchStrategy: apply  # SSA updates managed fields only
    spec:
      # Modified spec here

Result: Resources are updated while preserving unmanaged fields.

Removing Resources ​

Orphan Cleanup

Removed resources are automatically deleted or retained based on their deletionPolicy.

Example:

yaml
# Before: Template has 3 deployments
deployments:
  - id: web
    deletionPolicy: Delete
  - id: worker
    deletionPolicy: Retain
  - id: cache
    deletionPolicy: Delete

# After: Removed worker and cache
deployments:
  - id: web
    deletionPolicy: Delete

Result:

  • worker deployment: Retained in cluster with orphan labels (no ownerReference was set initially)
  • cache deployment: Deleted from cluster (via ownerReference)
  • web deployment: Continues to be managed normally

Orphan markers added to retained resources:

yaml
metadata:
  labels:
    lynq.sh/orphaned: "true"  # Label for selector queries
  annotations:
    lynq.sh/orphaned-at: "2025-01-15T10:30:00Z"  # RFC3339 timestamp
    lynq.sh/orphaned-reason: "RemovedFromTemplate"

Why label + annotation?

  • Label values must be RFC 1123 compliant (no colons), so we use simple "true" for selectors
  • Annotations can store detailed metadata like timestamps without format restrictions

Re-adoption of Orphaned Resources:

When you re-add a previously removed resource back to the template:

  • Operator automatically detects and removes all orphan markers
  • Resource smoothly transitions back to managed state
  • No manual intervention needed

This enables safe experimentation:

yaml
# Day 1: Remove worker deployment
deployments:
  - id: web  # worker removed

# Day 2: Re-add worker deployment
deployments:
  - id: web
  - id: worker  # Re-added! Orphan markers auto-removed

You can easily find these orphaned resources later:

bash
# Find all orphaned resources (using label selector)
kubectl get all -A -l lynq.sh/orphaned=true

# Find resources orphaned due to template changes (filter by annotation)
kubectl get all -A -l lynq.sh/orphaned=true -o jsonpath='{range .items[?(@.metadata.annotations.k8s-lynq\.org/orphaned-reason=="RemovedFromTemplate")]}{.kind}/{.metadata.name}{"\n"}{end}'

How it works:

  1. Operator tracks applied resources in LynqNode.status.appliedResources
  2. During reconciliation, compares current template with previous state
  3. Detects orphaned resources (in status but not in template)
  4. Applies each resource's deletionPolicy:
    • Delete: Removes from cluster (automatic via ownerReference)
    • Retain: Removes tracking labels, adds orphan labels, keeps resource (no ownerReference to remove)

Benefits:

  • βœ… Safe template evolution without manual intervention
  • βœ… No accumulation of orphaned resources
  • βœ… Consistent behavior across all deletion scenarios
  • βœ… Automatic during normal reconciliation (no special operation needed)

Best Practices for Template Changes ​

  1. Test in non-production first: Validate template changes in dev/staging
  2. Use appropriate deletion policies:
    • Delete for stateless resources (Deployments, Services)
    • Retain for stateful resources (PVCs, databases)
  3. Review appliedResources status: Check what resources are currently tracked
  4. Monitor reconciliation: Watch operator logs during template updates
  5. Use creationPolicy: Once for resources that shouldn't be updated

Example workflow:

bash
# 1. Check current applied resources
kubectl get lynqnode -o jsonpath='{.status.appliedResources}'

# 2. Update template
kubectl apply -f updated-template.yaml

# 3. Monitor reconciliation
kubectl logs -n lynq-system deployment/lynq-controller-manager -f

# 4. Verify changes
kubectl get lynqnode -o yaml

See Also ​

Released under the Apache 2.0 License.
Built with ❀️ using Kubebuilder, Controller-Runtime, and VitePress.