Free lesson · Forward Deployed GenAI Engineering

Generate K8s manifests from customer-parameterized Jinja2 templates

You build a ManifestGenerator that produces Deployment, Service, Ingress, HPA, and ConfigMap YAML from Jinja2 templates with PyYAML schema validation against K8s APIs.

Course: AI Solution Delivery · Chapter 6 · Deploying in Customer Environments

Free to read — no subscription required.

Introduction

When you deploy AI solutions into customer Kubernetes clusters, generating per-tenant manifests by hand is error-prone and impossible to scale — each customer needs isolated namespaces, distinct resource limits, and a dedicated LLM proxy URL. This lesson teaches you to automate that process using Pydantic for validated configuration and Jinja2 for templated manifest generation. By the end, you will be able to generate a complete, customer-scoped Kubernetes deployment bundle — Deployment, Service, ConfigMap, HPA, and Ingress — from a single validated config object.

Key Terminology

  • CustomerConfig — A Pydantic BaseModel subclass that holds all per-tenant deployment parameters, enforcing constraints (e.g., replicas bounded to 1–20 via Field(ge=1, le=20)) so invalid specifications are rejected before any YAML is rendered.
  • ManifestGenerator — A class that wraps a Jinja2 Environment and exposes methods like generate_bundle to render all five Kubernetes resource manifests (Deployment, Service, ConfigMap, HPA, Ingress) from a single validated CustomerConfig instance.
  • Jinja2 Environment with FileSystemLoader — The Jinja2 rendering context configured with a directory of .yaml.j2 template files; templates are loaded from disk on first access and reused across tenants, making multi-customer generation efficient.
  • Manifest bundle — The dictionary returned by generate_bundle, keyed by resource type (e.g. "deployment", "hpa"), where each value is a fully rendered YAML string ready for kubectl apply.
  • Per-tenant isolation — The design principle that each CustomerConfig carries its own namespace, llm_proxy_url, and resource limits so that rendered manifests for one customer cannot reference or bleed into another tenant's cluster resources.
  • Conditional manifest rendering — The pattern where a Jinja2 template branches on a config flag (e.g., config.enable_hpa) to emit a resource only when that tenant actually requires it, keeping bundles minimal and correct per customer.

Concepts

Why Validated Configuration Must Precede Template Rendering

Generating Kubernetes manifests from code introduces a class of bug that silent string formatting cannot catch: a replica count of 0, a missing llm_proxy_url, or an out-of-range CPU limit will produce syntactically valid YAML that Kubernetes will accept — and then fail or misbehave at runtime. The fix is to make invalid config unrepresentable. Pydantic's BaseModel runs field-level validation at instantiation time, so CustomerConfig(replicas=0, ...) raises a ValidationError immediately rather than producing a broken manifest minutes later during kubectl apply. This is the core contract: by the time any template sees a config object, it is already proven valid.

Required fields with no default — like llm_proxy_url — act as a forcing function. There is no way to construct a CustomerConfig without supplying a value, which means every generated manifest is guaranteed to point a tenant's AI workload at the right proxy endpoint. The validation layer is the first gate; the template is the second.

Separating Config Shape from YAML Shape

Once config is validated, the rendering problem is one of separation: the Python code should know nothing about YAML syntax, and the YAML templates should know nothing about business rules. Jinja2's Environment + FileSystemLoader enforces this boundary cleanly. Each .yaml.j2 file expresses structure — how a Deployment or HorizontalPodAutoscaler is laid out — while config fields supply the values. The template can branch on config.enable_hpa to conditionally include the HPA resource, but the decision about what enable_hpa means belongs entirely to CustomerConfig.

This separation pays off at scale: adding a new tenant means supplying a new CustomerConfig instance; changing the HPA template structure means editing one .yaml.j2 file. Neither change requires touching the other layer (see Code Walkthrough).

Generating a Complete Bundle in One Pass

The generate_bundle method iterates over all five resource types in a single call, returning a dictionary of rendered YAML strings keyed by resource name. This design matters operationally: the caller gets an atomic snapshot of everything needed for a tenant deployment — "deployment", "service", "configmap", "hpa", and "ingress" — and can inspect, log, or apply each independently. Because the Environment is constructed once in __init__ and templates are cached after first load, generating bundles for dozens of tenants in sequence reuses the same parsed template objects rather than re-reading files from disk on every call.

Loading diagram...

The namespace field is the thread that ties the bundle together: every one of the five rendered manifests carries the same namespace value, guaranteeing that a customer's resources land in their isolated namespace and never cross into a neighboring tenant's scope.

Code Walkthrough

Now that you understand how CustomerConfig enforces per-tenant isolation and how ManifestGenerator maps validated config to Kubernetes YAML, you can see both classes working together in a full generation cycle.

The CustomerConfig class uses Pydantic's Field constraints to reject invalid specifications — replica counts outside 1–20, for example — before any YAML is rendered. ManifestGenerator wraps a Jinja2 Environment with a FileSystemLoader, so each template file (e.g. deployment.yaml.j2) is rendered with the validated config as its context variable.

Code snippetpython
1from jinja2 import Environment, FileSystemLoader 2from pydantic import BaseModel, Field 3from typing import Dict, Optional 4import yaml 5 6class CustomerConfig(BaseModel): 7 """Customer-specific deployment configuration.""" 8 customer_name: str 9 namespace: str 10 image_registry: str 11 image_tag: str = "latest" 12 replicas: int = Field(ge=1, le=20, default=2) 13 cpu_request: str = "250m" 14 cpu_limit: str = "1000m" 15 memory_request: str = "512Mi" 16 memory_limit: str = "2Gi" 17 llm_proxy_url: str 18 env_vars: Dict[str, str] = {} 19 enable_hpa: bool = True 20 hpa_min_replicas: int = 2 21 hpa_max_replicas: int = 10 22 hpa_target_cpu: int = 70 23 ingress_host: Optional[str] = None 24 25class ManifestGenerator: 26 """Generates K8s YAML manifests from customer configuration.""" 27 28 def __init__(self, template_dir: str = "templates"): 29 self.env = Environment( 30 loader=FileSystemLoader(template_dir), 31 trim_blocks=True, 32 lstrip_blocks=True, 33 ) 34 35 def generate_deployment(self, config: CustomerConfig) -> str: 36 template = self.env.get_template("deployment.yaml.j2") 37 return template.render(config=config) 38 39 def generate_bundle(self, config: CustomerConfig) -> Dict[str, str]: 40 """Generate all manifests for a customer deployment.""" 41 manifests = {} 42 for resource in ["deployment", "service", "configmap", "hpa", "ingress"]: 43 template = self.env.get_template(f"{resource}.yaml.j2") 44 manifests[resource] = template.render(config=config) 45 return manifests

CustomerConfig declares llm_proxy_url as a required field with no default, so instantiation fails immediately if it is omitted — keeping misconfigured tenants out of the cluster entirely. enable_hpa and the three hpa_* fields travel alongside the config so the Jinja2 HPA template can branch on config.enable_hpa and emit the HorizontalPodAutoscaler resource only when scaling is actually requested for that tenant.

The generate_bundle method iterates over all five resource types and returns a dictionary keyed by resource name. Each value is a fully rendered YAML string ready for kubectl apply. Because the Jinja2 Environment is constructed once in __init__, template files are loaded from disk only on first access and reused across tenants — important when generating manifests for dozens of customers in a single deployment run.

Code snippetpython
1# Example: instantiate a customer config and generate the full bundle 2config = CustomerConfig( 3 customer_name="acme-corp", 4 namespace="acme-prod", 5 image_registry="registry.acme.io/ai-solution", 6 image_tag="v1.4.2", 7 replicas=3, 8 llm_proxy_url="http://llm-proxy.acme-prod.svc.cluster.local:8080", 9 ingress_host="ai.acme.io", 10) 11 12generator = ManifestGenerator(template_dir="templates") 13bundle = generator.generate_bundle(config) 14 15# Inspect the rendered Deployment manifest 16print(bundle["deployment"])

The namespace field flows directly into every rendered manifest, ensuring all five resources land in acme-prod and never bleed into a neighboring tenant's namespace. The ingress_host is optional — if left as None, the Ingress template renders nothing and the bundle contains an empty string for that key.

Confirm that the generated bundle["deployment"] YAML contains the correct namespace, image, and resources fields matching your CustomerConfig values, and that bundle["hpa"] is non-empty when enable_hpa=True.

Do's and Don'ts

Having walked through the material above, the following Do's and Don'ts distill it into practice.

Do's

  1. Do declare llm_proxy_url as a required field with no default in CustomerConfig — Pydantic raises a validation error at instantiation if this field is omitted, preventing misconfigured tenants from ever reaching the Jinja2 rendering stage and keeping invalid deployments out of the cluster entirely.
  2. Do construct the Jinja2 Environment once in ManifestGenerator.__init__ and reuse it across generate_bundle calls — templates are loaded from disk only on first access and cached by the FileSystemLoader, so generating manifests for dozens of customers in a single deployment run avoids repeated filesystem reads.
  3. Do propagate the namespace field into every template in generate_bundle — because all five resources (Deployment, Service, ConfigMap, HPA, Ingress) render with the same config context variable, each one inherits acme-prod (or whichever tenant namespace) automatically, preventing cross-tenant namespace bleed.

Don'ts

  1. Don't set a default value for llm_proxy_url in CustomerConfig — a fallback URL would silently point all misconfigured tenants at the same proxy endpoint, making namespace isolation meaningless and poisoning LLM traffic across customer boundaries.
  2. Don't skip the Field(ge=1, le=20) constraint on replicas when subclassing or copying CustomerConfig — without it, a caller can request zero or an arbitrarily large replica count that Kubernetes will attempt to schedule, causing resource exhaustion or a broken Deployment before any YAML is even applied.
  3. Don't check bundle["hpa"] for truthiness without also verifying enable_hpa=True on the config — when ingress_host is None or enable_hpa is False, the corresponding bundle value is an empty string, not None; code that treats an empty string as an error will incorrectly flag intentionally omitted resources as generation failures.

This lesson is free to read. Its 3 hands-on labs — real code, in a cloud IDE — are part of the Forward Deployed GenAI Engineering subscription.

From · cancel anytime

More free lessons in AI Solution Delivery

All free lessons in Forward Deployed GenAI Engineering