A Kubernetes operator that renders Jinja-like templates into ConfigMaps, Secrets or arbitrary Kubernetes manifests. Template variables come from ConfigMaps, Secrets (by name or label selector) and DNS lookups in the same namespace; the operator re-renders automatically whenever a source changes. Raw manifest outputs are applied under a tenant ServiceAccount's identity, so authorization stays plain Kubernetes RBAC.
flowchart LR
subgraph ns["Namespace of the CR"]
CM[ConfigMaps]
SEC[Secrets]
JT["JinjaTemplate CR<br/>(spec.sources, spec.template)"]
end
DNS[(DNS A / AAAA)]
R["Reconciler<br/>resolve β render"]
CM --> R
SEC --> R
DNS --> R
JT --> R
R --> OUT1[ConfigMap output]
R --> OUT2[Secret output]
R -- "impersonates<br/>spec.output.serviceAccountName" --> OUT3[RawObject<br/>any manifest]
- π¨ Jinja-like templating β Gonja engine with filters, loops and conditionals
- π¦ ConfigMap / Secret sources β a single key by name, or a whole group via label selector
- π DNS sources β resolve
A/AAAArecords into a sorted IP list, with TTL-driven refresh, removal grace periods and stale-on-error behavior - π Reactive reconciliation β source changes, deleted outputs and new label-selector matches all trigger a re-render
- ποΈ Multi-key output β emit several independently rendered keys into one ConfigMap/Secret
- π Inline or external templates β in the CR, or loaded from a ConfigMap key
- π§± Raw object output β render a complete manifest (e.g. a Calico
GlobalNetworkPolicy) and apply it via server-side apply - π ServiceAccount impersonation β raw outputs are applied and deleted as a tenant ServiceAccount; authorization is standard RBAC, auditable with
kubectl auth can-i --as=β¦ - π Configurable OwnerReference β decide per CR (or globally) whether outputs are garbage-collected with the CR
- π Cluster-scoped operator β one instance watches all namespaces
Everything the operator generates deterministically.
| Item | Pattern | Notes |
|---|---|---|
| Output resource name | spec.output.name, else metadata.name of the CR |
Not applicable to RawObject β the name comes from the rendered manifest |
| Output data key | spec.output.key, else content |
Ignored when spec.output.keys is set |
| Output namespace | Always the CR's own namespace | Cross-namespace outputs are rejected |
| Secret type | Opaque |
Fixed for Secret outputs |
| Label on outputs | jto.gtrfc.com/managed-by: jinja-template-operator |
Set on ConfigMap, Secret and RawObject outputs |
| Label on outputs | jto.gtrfc.com/jinja-template: <cr-name> |
References the owning CR |
| Item | Value | Notes |
|---|---|---|
| API group / version / kind | jto.gtrfc.com/v1, JinjaTemplate |
Namespaced CR |
| Finalizer | jto.gtrfc.com/raw-output-cleanup |
Only on CRs owning a cluster-scoped RawObject |
| Server-side apply field manager | jinja-template-operator |
Used for RawObject applies |
| Impersonated identity | system:serviceaccount:<cr-namespace>:<spec.output.serviceAccountName> |
Namespace is always the CR's own |
| Leader election Lease | jinja-template-operator.jto.gtrfc.com |
In the operator's release namespace |
| Event reporting controller | jinjatemplate-controller |
Appears in kubectl describe events |
| Condition types | Ready, DNSHealthy |
DNSHealthy only exists while DNS sources are configured |
<fullname> is the release name, or <release>-jinja-template-operator when the release name does not already contain the chart name (fullnameOverride wins).
| Resource | Name |
|---|---|
| Deployment | <fullname> |
| ServiceAccount | <fullname> (override: serviceAccount.name) |
| ClusterRole | <fullname>-manager-role |
| ClusterRoleBinding | <fullname>-manager-rolebinding |
| Aggregate ClusterRoles | <fullname>-aggregate-admin / -aggregate-edit / -aggregate-view |
| ValidatingAdmissionPolicy (+ Binding) | <fullname>-rawobject-author-check |
| Document | Content |
|---|---|
| DEVELOPER.md | Repo layout, package responsibilities, reconcile pipeline, extension checklists, build/test matrix, CI/release |
| SECURITY_ARCHITECTURE.md | Trust boundaries, secret flow, impersonation model, privilege footprint, residual risks |
| examples/calico-globalnetworkpolicy/ | End-to-end RawObject walkthrough including RBAC |
| Full reference | Every JinjaTemplate field and Helm value |
| Gonja | Template engine syntax (filters, loops, conditionals) |
| Kubernetes: user impersonation | Mechanism behind RawObject authorization |
| Kubernetes: ValidatingAdmissionPolicy | Optional author check (K8s β₯ 1.30) |
1. Install the operator
helm repo add jinja-template-operator https://guided-traffic.github.io/jinja-template-operator
helm repo update
helm install jinja-template-operator jinja-template-operator/jinja-template-operator \
--namespace jinja-template-operator-system \
--create-namespace2a. Render a ConfigMap from a ConfigMap + Secret
apiVersion: jto.gtrfc.com/v1
kind: JinjaTemplate
metadata:
name: app-config
namespace: my-app
spec:
sources:
- name: db_host
configMap:
name: database-config
key: host
- name: db_password
secret:
name: db-credentials
key: password
template: |
DATABASE_URL=postgres://admin:{{ db_password }}@{{ db_host }}:5432/mydb
output:
kind: ConfigMap # β ConfigMap "app-config" in namespace "my-app"
key: app.env2b. Fan out over a label selector
apiVersion: jto.gtrfc.com/v1
kind: JinjaTemplate
metadata:
name: all-endpoints
namespace: platform
spec:
sources:
- name: services
configMap:
labelSelector:
matchLabels:
type: endpoint
template: |
{% for svc in services %}
# {{ svc.name }}
{% for key, value in svc.data.items() %}
{{ key }}={{ value }}
{% endfor %}
{% endfor %}
output:
kind: ConfigMap
key: endpoints.conf2c. Build a credential Secret with one key per value
apiVersion: jto.gtrfc.com/v1
kind: JinjaTemplate
metadata:
name: my-db-credentials
namespace: my-app
spec:
sources:
- name: db_password
secret:
name: my-db-user
key: password
output:
kind: Secret
keys:
- key: DB_HOST
template: "db-cluster-rw.my-app.svc.cluster.local"
- key: DB_PASSWORD
template: "{{ db_password }}"3. Verify
kubectl get jinjatemplate -n my-app
# NAME OUTPUT KIND OUTPUT NAME READY AGE
# app-config ConfigMap True 10s
kubectl get configmap app-config -n my-app -o jsonpath='{.data}'
kubectl describe jinjatemplate app-config -n my-app # conditions + events on failureUpgrade & uninstall
helm repo update
helm upgrade jinja-template-operator jinja-template-operator/jinja-template-operator \
--namespace jinja-template-operator-systemThe chart ships the CRD as a regular template, so helm uninstall removes it β and with it every JinjaTemplate CR. Outputs created with setOwnerReference: true are garbage-collected along with their CRs; outputs created with setOwnerReference: false survive.
kubectl get jinjatemplates -A # check what would be lost first
helm uninstall jinja-template-operator --namespace jinja-template-operator-systemIf CRs with the jto.gtrfc.com/raw-output-cleanup finalizer still exist when the operator is gone, they stay in Terminating until the finalizer is removed manually.
Every field the CRD accepts, in one manifest. Field-by-field notes follow below.
apiVersion: jto.gtrfc.com/v1
kind: JinjaTemplate
metadata:
name: reference-example
namespace: my-app
spec:
setOwnerReference: true # default (from operator.defaultOwnerReference)
sources:
# Direct ConfigMap reference β single string
- name: db_host
configMap:
name: database-config
key: host
# Direct Secret reference β single string
- name: db_password
secret:
name: db-credentials
key: password
# Label selector β list of {name, data}
- name: endpoints
configMap:
labelSelector:
matchLabels:
type: endpoint
# Label selector on Secrets β list of {name, data}
- name: upstreams
secret:
labelSelector:
matchExpressions:
- key: role
operator: In
values: ["upstream"]
# DNS lookup β sorted list of IP strings
- name: backend_ips
dns:
host: backend.example.com
recordType: A # default; also AAAA or A+AAAA
refreshIntervalSeconds: 60 # example; omit to follow the record TTL
nameserver: 10.96.0.10 # example; omit to use the system resolver
removalGracePeriodSeconds: 300 # example; omit/0 removes immediately
# Exactly one of template / templateFrom β both ignored when output.keys is set
template: |
DATABASE_URL=postgres://admin:{{ db_password }}@{{ db_host }}:5432/mydb
{% for ip in backend_ips %}
upstream {{ ip }};
{% endfor %}
# templateFrom:
# configMapRef:
# name: nginx-template
# key: nginx.conf.j2
output:
kind: ConfigMap # ConfigMap | Secret | RawObject
name: reference-output # example; defaults to the CR name
key: app.env # default: content
# keys: # multi-key mode; replaces template/templateFrom/key
# - key: DB_HOST
# template: "db-cluster-rw.my-app.svc.cluster.local"
# - key: nginx.conf
# templateFrom:
# configMapRef:
# name: nginx-template
# key: nginx.conf.j2
# serviceAccountName: gnp-applier # required for RawObject, forbidden otherwiseField reference β every spec field
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
spec.setOwnerReference |
bool |
No | operator.defaultOwnerReference (true) |
Per-CR override for OwnerReference / finalizer-based cleanup of the output |
spec.sources |
[]Source |
No | [] |
Variable sources; a CR with a constant template needs none |
spec.sources[].name |
string |
Yes | β | Variable name in the template; must be unique |
spec.sources[].configMap.name |
string |
NoΒΉ | β | ConfigMap name (direct reference; requires key) |
spec.sources[].configMap.key |
string |
NoΒΉ | β | Key within the ConfigMap |
spec.sources[].configMap.labelSelector |
LabelSelector |
NoΒΉ | β | Selects ConfigMaps in the CR's namespace β list of {name, data} |
spec.sources[].secret.name |
string |
NoΒΉ | β | Secret name (direct reference; requires key) |
spec.sources[].secret.key |
string |
NoΒΉ | β | Key within the Secret |
spec.sources[].secret.labelSelector |
LabelSelector |
NoΒΉ | β | Selects Secrets in the CR's namespace β list of {name, data}. Security: every matching Secret's values enter the template context β scope the selector tightly |
spec.sources[].dns.host |
string |
YesΒΉ | β | DNS name to resolve β sorted list of IP strings |
spec.sources[].dns.recordType |
string |
No | A |
A, AAAA or A+AAAA; CNAME chains are followed (max 10 hops), the result contains only IPs |
spec.sources[].dns.refreshIntervalSeconds |
int32 (β₯ 1) |
No | record TTL | Fixed re-resolution interval; TTL-driven refreshes are floored at 5 s |
spec.sources[].dns.nameserver |
string |
No | first entry of /etc/resolv.conf |
host or host:port (port defaults to 53), used for every CNAME hop. Security: an untrusted resolver controls what ends up in the output |
spec.sources[].dns.removalGracePeriodSeconds |
int32 (β₯ 0) |
No | 0 |
Keep a vanished record in the list this long before dropping it |
spec.template |
string |
NoΒ² | β | Inline Jinja template |
spec.templateFrom.configMapRef.name |
string |
NoΒ² | β | ConfigMap holding the template |
spec.templateFrom.configMapRef.key |
string |
NoΒ² | β | Key within that ConfigMap |
spec.output.kind |
string |
Yes | β | ConfigMap, Secret or RawObject |
spec.output.name |
string |
No | CR name | Output resource name; must not be set for RawObject |
spec.output.key |
string |
No | content |
Data key for the rendered content; ignored with output.keys, forbidden for RawObject |
spec.output.keys |
[]OutputKey |
NoΒ³ | β | Multi-key mode; forbidden for RawObject |
spec.output.keys[].key |
string |
Yes | β | Data key; must be unique within the list |
spec.output.keys[].template |
string |
Noβ΄ | β | Inline template for this key (rendered value is whitespace-trimmed) |
spec.output.keys[].templateFrom.configMapRef |
ConfigMapKeyRef |
Noβ΄ | β | External template for this key |
spec.output.serviceAccountName |
string |
For RawObject |
β | ServiceAccount in the CR's namespace whose identity is impersonated for apply/delete. Security: this is the authorization boundary for raw outputs β see SECURITY_ARCHITECTURE.md |
ΒΉ Each source specifies exactly one of
configMap,secretordns. WithinconfigMap/secret, use eithername+key(single value) orlabelSelector(list).Β² Exactly one of
spec.templateorspec.templateFrom.configMapRefβ unlessspec.output.keysis set, in which case both are ignored.Β³ With
spec.output.keysthe output contains exactly the declared keys; keys removed from the list disappear on the next reconcile.β΄ Each
output.keysentry provides exactly one oftemplateortemplateFrom.configMapRef.
Status reference β conditions, reasons and status fields
| Condition | Status | Meaning |
|---|---|---|
Ready |
True |
Sources resolved, template rendered, output written |
Ready |
False |
Something failed β see reason below |
DNSHealthy |
True |
All DNS lookups succeeded (condition exists only while DNS sources are configured) |
DNSHealthy |
False |
At least one lookup failed; the last known records stay in use and Ready stays True |
| Reason | Requeued? | Meaning |
|---|---|---|
RenderSuccess |
β | Everything worked |
InvalidSpec |
No | Spec validation failed; fix the CR |
RenderFailed |
No | Gonja compile/execute error; fix the template |
RawObjectInvalid |
No | Rendered output is not a single valid manifest, or targets a foreign namespace |
SourceResolutionFailed |
Backoff | Referenced ConfigMap/Secret or key missing |
TemplateLoadFailed |
Backoff | Template ConfigMap or key missing |
DNSLookupFailed |
Backoff | Only when the first ever lookup for a source fails; later failures set DNSHealthy=False instead |
OutputFailed |
Backoff | Write of the output resource failed |
ServiceAccountNotFound |
Backoff | spec.output.serviceAccountName does not exist β acts as a revocation |
OutputForbidden |
Backoff | RBAC denied the impersonated apply; message contains a kubectl auth can-i hint |
OldOutputDeleted |
β | Event only: the previous output was removed after a target change |
FinalizeForbidden |
Backoff | Event only: raw output could not be deleted during CR deletion |
| Status field | Description |
|---|---|
status.conditions |
Standard metav1.Condition list (Ready, DNSHealthy) |
status.lastOutput |
{apiVersion, kind, name, namespace, serviceAccountName} of the current output; drives cleanup after a target change. apiVersion/namespace/serviceAccountName are set for RawObject outputs only |
status.dnsSources[] |
Per DNS source: records[] {value, lastSeen}, lastSuccessfulLookup, lastError β persists grace-period state across operator restarts |
Errors are also emitted as Kubernetes Events: kubectl describe jinjatemplate <name>.
| Source form | Context value | Usage |
|---|---|---|
configMap/secret with name + key |
string |
{{ my_source }} |
configMap/secret with labelSelector |
list of {name, data} |
{% for item in my_source %}{{ item.name }} {{ item.data.items() }}{% endfor %} |
dns |
sorted list of IP strings |
{% for ip in my_source %}server {{ ip }};{% endfor %} |
Secret values are decoded to plain strings before rendering β a Secret source rendered into a ConfigMap output publishes it in clear text.
- The result is always a list, also for a single record; entries are deduplicated and sorted.
- CNAME chains are followed transparently, up to 10 hops.
- Refresh:
refreshIntervalSecondsif set, otherwise the response TTL (floored at 5 s). Empty responses without TTL retry after 30 s. removalGracePeriodSeconds: a value that disappears from responses stays in the list untillastSeen + gracehas passed. New values appear immediately. NXDOMAIN counts as a successful empty response, so records age out through the grace period.- Lookup failure (timeout, SERVFAIL): the last known records stay in use indefinitely,
ReadystaysTrue,DNSHealthyturnsFalseplus a Warning event. Failed lookups never age records. Only a failing first lookup setsReady=False. - State lives in
status.dnsSourcesand survives operator restarts.
With output.kind: RawObject the rendered template is applied as a complete manifest instead of being wrapped in a ConfigMap/Secret. Full walkthrough: examples/calico-globalnetworkpolicy/.
apiVersion: jto.gtrfc.com/v1
kind: JinjaTemplate
metadata:
name: hans-fischer-com-access
namespace: infra
spec:
sources:
- name: api_ips
dns:
host: hans-fischer.com
template: |
apiVersion: crd.projectcalico.org/v1
kind: GlobalNetworkPolicy
metadata:
name: hans-fischer-com-access
spec:
selector: has(gnp/hans-fischer-com-access)
types:
- Egress
egress:
- action: Allow
protocol: TCP
destination:
nets:
{% for ip in api_ips %}
- {{ ip }}/32
{% endfor %}
ports:
- 443
output:
kind: RawObject
serviceAccountName: gnp-applier- The rendered output must be a single YAML document with
apiVersion,kindandmetadata.name(nogenerateName).output.name,output.keyandoutput.keysmust not be set. - Authorization is ServiceAccount impersonation. The operator applies and deletes the object as
system:serviceaccount:<cr-namespace>:<serviceAccountName>; the SA needsget/create/patchon the target kind (plusdeletefor cleanup and finalization). Neither the operator ClusterRole nor the Helm chart grants anything on target kinds β see examples/β¦/rbac.yaml. Audit with:kubectl auth can-i create globalnetworkpolicies.crd.projectcalico.org \ --as=system:serviceaccount:infra:gnp-applier
- Missing SA β
Ready=False/ServiceAccountNotFound; RBAC denial βOutputForbiddenwith a remediation hint. Both retry with backoff, so granting the permission turns the CR green on its own. - Namespaced kinds are written to the CR's own namespace only and carry an OwnerReference. Cluster-scoped kinds cannot carry a namespaced OwnerReference, so the operator adds the finalizer
jto.gtrfc.com/raw-output-cleanupto the CR and deletes the object itself (under the identity recorded instatus.lastOutput.serviceAccountName) β unlesssetOwnerReference: false. If the SA or its RBAC is gone at that point, the CR staysTerminating; restore the SA, re-grant RBAC, or remove the finalizer to abandon the output. - Applied via server-side apply with force-ownership (field manager
jinja-template-operator). Raw outputs are not watched β external modifications are corrected on the next reconcile (source change, DNS refresh, CR change or operator restart), not immediately. - Security model and mitigations: SECURITY_ARCHITECTURE.md.
| Trigger | Effect |
|---|---|
JinjaTemplate create/update/delete |
Full reconcile (delete runs the raw-output finalizer) |
| ConfigMap/Secret event in the CR's namespace | Reconcile if the object is a source (by name or matching label selector), a templateFrom ConfigMap, or the CR's own ConfigMap/Secret output |
| DNS refresh | Time-based requeue from TTL / refreshIntervalSeconds / grace-period expiry |
| Transient errors | Controller-runtime exponential backoff |
Watches cover ConfigMaps and Secrets cluster-wide; ServiceAccounts, RBAC and RawObject targets are not watched.
All chart values β values.yaml
| Value | Description | Default |
|---|---|---|
operator.defaultOwnerReference |
Global default for spec.setOwnerReference |
true |
operator.rawObjects.authorCheck.enabled |
ValidatingAdmissionPolicy requiring CR authors to hold impersonate on the referenced ServiceAccount (K8s β₯ 1.30). Security: closes the confused-deputy gap; off by default because GitOps controllers would then need that permission themselves |
false |
replicaCount |
Operator replicas | 1 |
image.repository |
Image repository | guidedtraffic/jinja-template-operator |
image.tag |
Image tag (empty β chart appVersion) |
"" |
image.pullPolicy |
Image pull policy | IfNotPresent |
imagePullSecrets |
Pull secrets for the Deployment | [] |
nameOverride / fullnameOverride |
Override generated names | "" |
serviceAccount.create |
Create the operator ServiceAccount | true |
serviceAccount.name |
Override its name | "" |
serviceAccount.annotations |
Annotations on it | {} |
rbac.createAggregateClusterRoles |
Add JinjaTemplate rights to the built-in admin/edit/view roles. Security: grants every namespace admin/editor the right to create RawObject CRs |
true |
podAnnotations / podLabels |
Extra pod metadata | {} |
podSecurityContext |
runAsNonRoot: true, seccompProfile.type: RuntimeDefault |
see values.yaml |
securityContext |
allowPrivilegeEscalation: false, capabilities.drop: [ALL], readOnlyRootFilesystem: true, runAsNonRoot: true |
see values.yaml |
resources.limits.cpu / .memory |
CPU / memory limit | 500m / 128Mi |
resources.requests.cpu / .memory |
CPU / memory request | 10m / 64Mi |
leaderElection.enabled |
Leader election (adds leases RBAC) |
true |
logLevel |
Declared in values.yaml but not wired into the Deployment; use --zap-log-level via a chart change if you need it |
info |
healthProbe.port |
Container port for /healthz and /readyz |
8081 |
metrics.port |
Container port declared for metrics β see the note below | 8080 |
nodeSelector / tolerations / affinity |
Scheduling | {} / [] / {} |
Metrics, honestly: the chart declares the container port and passes --metrics-bind-address, but cmd/main.go never hands that flag to the manager, so the controller-runtime default applies: metrics are served on :8080 as plain HTTP without authentication. The chart creates no Service, ServiceMonitor or NetworkPolicy, so the endpoint is reachable only via the pod IP. Scraping requires your own Service/ServiceMonitor; restrict access with a NetworkPolicy until the endpoint is secured. Tracked in SECURITY_ARCHITECTURE.md.
make build # build bin/manager
make run # run against the current kubeconfig (debug logging)
make test-unit # unit tests
make test-integration # envtest integration tests
make e2e-local # Kind cluster + Helm install + E2E tests
make lint # go vet + gofmt + golangci-lint
make gosec vuln # security scan + vulnerability checkDetails on repo layout, the reconcile pipeline, extension checklists and the release process: DEVELOPER.md.
Apache License 2.0 β see LICENSE.