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 β
# 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:
.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:
.hostOrUrl # Original URL/host from registry (from hostOrUrl mapping)
.host # Auto-extracted host from .hostOrUrlMigration Guide
Instead of using .hostOrUrl and .host:
Before (deprecated):
# In LynqHub
spec:
valueMappings:
uid: node_id
hostOrUrl: domain_url # β οΈ Deprecated
activate: is_active
# In template
env:
- name: HOST
value: "{{ .host }}" # β οΈ DeprecatedAfter (v1.1.11+):
# 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() functionWhy 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:
.hubId # LynqHub name
.templateRef # LynqForm nameCustom Variables β
From extraValueMappings in LynqHub:
spec:
extraValueMappings:
planId: subscription_plan
region: deployment_region
dbHost: database_hostAccess in templates:
.planId # Maps to subscription_plan column
.region # Maps to deployment_region column
.dbHost # Maps to database_host columnTemplate Functions β
Built-in Custom Functions β
toHost(url) β
β
Extract hostname from URL:
Recommended Usage
Use with extraValueMappings instead of deprecated .hostOrUrl:
# 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.0trunc63(s) β
β
Truncate to 63 characters (Kubernetes name limit):
# Ensures name fits K8s limits
nameTemplate: "{{ .uid }}-{{ .region }}-deployment | trunc63 }}"sha1sum(s) β
β
Generate SHA1 hash:
# Create unique, stable resource names
nameTemplate: "app-{{ .uid | sha1sum | trunc 8 }}"fromJson(s) β
β
Parse JSON string:
# 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:
# β 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:
# 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 # β
BooleanType Conversion Functions β
toInt(value) β
Convert value to integer (int):
# 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 β 2Conversion 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):
# 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.5Conversion 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:
# Feature flags
enabled: "{{ .featureEnabled | toBool }}" # "true" β true
readOnly: "{{ .isReadOnly | toBool }}" # "false" β false
# From integers (common in databases)
automountServiceAccountToken: "{{ .autoMount | toBool }}" # 1 β true, 0 β falseTruthy 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 β
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:
Template function wraps result with marker:
gotoInt("42") β "__LYNQ_TYPE_INT__42" // Internal representationGo template engine processes normally:
- Template rendering treats marker as string
- Marker survives template evaluation
Controller automatically restores type:
gorenderUnstructured() detects marker β converts to int β 42Kubernetes receives correctly-typed value:
yamlcontainerPort: 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 β
# Uppercase/lowercase
nameTemplate: "{{ .uid | upper }}"
nameTemplate: "{{ .uid | lower }}"
# Trim whitespace
value: "{{ .name | trim }}"
# Replace
value: "{{ .uid | replace \".\" \"-\" }}"
# Quote
value: "{{ .name | quote }}"Encoding Functions β
# Base64 encode/decode
value: "{{ .secret | b64enc }}"
value: "{{ .encoded | b64dec }}"
# URL encoding
value: "{{ .param | urlquery }}"
# SHA256
value: "{{ .data | sha256sum }}"Default Values β
# Provide default if variable is empty
image: "{{ .deployImage | default \"nginx:stable\" }}"
port: "{{ .appPort | default \"8080\" }}"
region: "{{ .region | default \"us-east-1\" }}"Conditionals β
# 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 β
# 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 β
# 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 β
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 β
# 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-safeExample 3: JSON Configuration β
Database column contains JSON:
{
"apiKey": "sk-abc123",
"features": ["feature-a", "feature-b"],
"limits": {"requests": 1000}
}Template usage:
env:
- name: API_KEY
value: "{{ (.config | fromJson).apiKey }}"
- name: RATE_LIMIT
value: "{{ (.config | fromJson).limits.requests }}"Example 4: Conditional Resources β
# 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 β
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:
# 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:
# Good
nameTemplate: "{{ .uid }}-{{ .region }}-deployment | trunc63 }}"
# Bad (can exceed 63 chars)
nameTemplate: "{{ .uid }}-{{ .region }}-deployment"3. Quote String Values in 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:
# 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 8080Common 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:
# Good - env values are always strings
env:
- name: PORT
value: "{{ .appPort }}" # Keep as string
# Unnecessary
env:
- name: PORT
value: "{{ .appPort | toInt }}" # Don't convert5. Handle Missing Variables Gracefully β
# 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:
# 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 empty7. Use Comments for Complex Logic β
# 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 instead2. Auto-Processing (Deprecated) β
Note: This step is deprecated since v1.1.11.
Operator automatically extracts .host:
# .host = "acme.example.com" # DEPRECATED - extracted from .hostOrUrlNew 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:
# 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 β
# 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
- Cause: Using a function that doesn't exist
- Fix: Check Sprig docs or Custom Functions
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
defaultfunction or checkextraValueMappings
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
toIntfor integer fields:yamlcontainerPort: "{{ .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
toBoolfor boolean fields:yamlautomountServiceAccountToken: "{{ .autoMount | toBool }}" readOnlyRootFilesystem: "{{ .readOnly | toBool }}"
Advanced Template Techniques β
Nested Templates β
# Define reusable template values
{{- $appName := printf "%s-app" .uid }}
nameTemplate: "{{ $appName }}"Range Over Lists β
# 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 β
# 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:
# Add a new service to existing template
services:
- id: api-service
nameTemplate: "{{ .uid }}-api"
spec:
apiVersion: v1
kind: Service
# ... service specResult: Service is created for all existing LynqNodes using this template.
Modifying Resources β
Existing resources are updated according to their patchStrategy:
deployments:
- id: web
patchStrategy: apply # SSA updates managed fields only
spec:
# Modified spec hereResult: Resources are updated while preserving unmanaged fields.
Removing Resources β
Orphan Cleanup
Removed resources are automatically deleted or retained based on their deletionPolicy.
Example:
# 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: DeleteResult:
workerdeployment: Retained in cluster with orphan labels (no ownerReference was set initially)cachedeployment: Deleted from cluster (via ownerReference)webdeployment: Continues to be managed normally
Orphan markers added to retained resources:
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:
# 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-removedYou can easily find these orphaned resources later:
# 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:
- Operator tracks applied resources in
LynqNode.status.appliedResources - During reconciliation, compares current template with previous state
- Detects orphaned resources (in status but not in template)
- 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 β
- Test in non-production first: Validate template changes in dev/staging
- Use appropriate deletion policies:
Deletefor stateless resources (Deployments, Services)Retainfor stateful resources (PVCs, databases)
- Review
appliedResourcesstatus: Check what resources are currently tracked - Monitor reconciliation: Watch operator logs during template updates
- Use
creationPolicy: Oncefor resources that shouldn't be updated
Example workflow:
# 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 yamlSee Also β
- Policies Guide - Creation/deletion/conflict policies
- Dependencies Guide - Resource ordering
- API Reference - Complete CRD schema
