---
title: 'Packaging and Templating'
source: 'https://academia.sh/en/courses/orchestration/packaging-and-templating'
course: 'Container Orchestration'
language: en
updated: '2026-09-13T05:08:28+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Measures the cost of a hand-duplicated chart with a single number: how many copies diverge from each other'
    - 'Compares the lines the template saves against the conditional-branch burden it brings in return'
    - 'Shows what the template engine does not know, and which flaw that lets through to production'
---

# Packaging and Templating

The same application is set up in five environments, first by hand, then with a single template; lines written, values that vary per environment, and copy-paste drift are all counted. The template's own cost is measured too: number of variables, number of conditional branches, and how many lines can be known for certain just by reading the template.

The previous lesson turned security context into policy: running privileged, a read-only root, and
capability restriction are now rules the cluster enforces when it accepts a declaration.
Enforcement works, but who writes the thing being enforced has not changed. Setting up the same
application across five environments still means writing the same object set five times, with only
a handful of values changing between the five copies.

A **chart** is the complete set of objects an application needs to run on the cluster: a
deployment, a service object, a config object, an ingress rule, a quota. A **template** is text
that writes the chart's environment-independent part once and pulls the varying part out. The
delegated decision is this: a human stops writing the final object set for each environment, and a
generator writes it instead. What this lesson measures is that delegation's cost — both the lines
it saves and the burden the generator itself brings in.

The measurement is again over the **regional measurement network**: the fictional software that
collects, verifies, and opens field work orders for water meter readings. No real cluster is run;
what is measured is a **model** built with `node` — a template engine that works over text, and a
step-by-step imitation of manual editing.

- **OO31.** The chart consists of five objects and is represented in sixteen lines.
- **OO32.** There are five environments: development, testing, pre-production, production-west,
  production-east.
- **OO33.** In the manual path, the base chart is written once and copied four times; the copies
  are then edited.
- **OO34.** Three rounds of change are run; in every round, who skips or writes something wrong is
  **hand-written**; there is no randomness.
- **OO35.** The template engine knows three jobs — value substitution, conditional block, loop —
  and **does not know** the object schema.
- **OO36.** The schema is defined within the lesson and applied only to the generated output.

## Five Environments, One Object Set

```js
// data.mjs — base object set, per-environment values, the template, and the template engine.
export const BASE = `object deployment reading-collector
  replicas 3
  image-tag 4.2.0
  cpu-request 200
  cpu-limit 500
object service-object reading-collector
  port 8080
object config-object collector-config
  log-level summary
  batch-size 500
  verifier-address verifier.measurement-network:8080
object ingress-rule collector-ingress
  host collector.pre.internal
object quota measurement-network
  cpu-total 4000
  memory-total 8192`;                               // hand-written for the pre-production environment

export const FIELD = ["replicas", "log-level", "batch-size", "host", "cpu-total",
  "memory-total", "cpu-limit"];
const RAW = {                                        // environment: [seven values] + open branches
  "development":       [1, "detail", 50, "collector.development.internal", 1000, 2048, 300, []],
  "testing":           [2, "summary", 200, "collector.testing.internal", 2000, 4096, 400, ["ingress"]],
  "pre-production":    [3, "summary", 500, "collector.pre.internal", 4000, 8192, 500, ["ingress"]],
  "production-west":   [6, "warning", 1000, "collector.west.internal", 12000, 24576, 800, ["ingress"]],
  "production-east":   [6, "warning", 1000, "collector.east.internal", 12000, 24576, 800, ["ingress", "standby"]] };
export const ENVIRONMENT = Object.keys(RAW);
export const VALUE = Object.fromEntries(ENVIRONMENT.map((o) => [o, Object.fromEntries(
  [...FIELD.map((a, i) => [a, RAW[o][i]]), ...RAW[o][7].map((x) => [x, true])])]));

export const TEMPLATE = `object deployment reading-collector
  replicas {{replicas}}
  image-tag 4.2.0
  cpu-request 200
  cpu-limit {{cpu-limit}}
object service-object reading-collector
  port 8080
object config-object collector-config
  log-level {{log-level}}
  batch-size {{batch-size}}
  verifier-address verifier.measurement-network:8080
{{#if ingress}}object ingress-rule collector-ingress
  host {{host}}
{{/if}}{{#if standby}}object deployment reading-collector-standby
  replicas {{standby-replicas}}
  image-tag 4.2.0
{{/if}}object quota measurement-network
  cpu-total {{cpu-total}}
  memory-total {{memory-total}}
{{#each extra-label}}  label {{.}}
{{/each}}`;

export const render = (s, d) => s                      // substitution, conditional block, loop
  .replace(/\{\{#if ([\w-]+)\}\}([\s\S]*?)\{\{\/if\}\}/g, (_, a, g) => (d[a] ? g : ""))
  .replace(/\{\{#each ([\w-]+)\}\}([\s\S]*?)\{\{\/each\}\}/g, (_, a, g) =>
    (d[a] ?? []).map((x) => g.replaceAll("{{.}}", x)).join(""))
  .replace(/\{\{([\w-]+)\}\}/g, (_, a) => String(d[a] ?? `<missing:${a}>`))
  .split("\n").filter((s) => s.trim() !== "").join("\n");
export const lines = (m) => m.split("\n").filter((s) => s.trim() !== "").length;
```

The base chart is written for the pre-production environment and is sixteen lines long. Seven
values vary across the five environments; the remaining nine lines are identical, character for
character, across all five. The template carries this distinction into the text: seven values come
out through `{{...}}`, and two objects go into a conditional block.

This distinction shows there are two kinds of variability. The first is **value variability** —
replica count, log level, quota ceiling; the object set stays the same, the number inside it
changes. The second is **structural variability** — the development environment has no ingress
rule at all, production-east has an extra deployment; what changes is not a value but whether the
object exists. Value variability is closed off with a plain list; structural variability demands a
conditional branch, and as the lesson will show by its end, most of the cost comes from there.

## Lines Written and Copy-Paste Drift

```js
// measure.mjs — the same app is set up in five environments by hand and via a template; lines, edits, drift counted.
import { BASE, TEMPLATE, render, lines, FIELD, ENVIRONMENT, VALUE } from "./data.mjs";

const COMMON = ["image-tag", "cpu-request", "port", "verifier-address", "readiness-probe"];
let written = lines(BASE), copied = 0, edited = 0;
const manual = {};
for (const o of ENVIRONMENT) {                       // the base is written once, copied four times
  let m = BASE;
  if (o !== "pre-production") {
    copied += lines(BASE);
    for (const a of FIELD) {
      const y = m.replace(new RegExp(`(  ${a}) .*`), `$1 ${VALUE[o][a]}`);
      if (y !== m) edited++;
      m = y;
    }
    if (!VALUE[o].ingress) { m = m.replace(/object ingress-rule.*\n  host .*\n/, ""); edited += 2; }
    if (VALUE[o].standby) { m += "\nobject deployment reading-collector-standby\n  replicas 6"; edited += 2; }
  }
  manual[o] = m;
}

const round = [                          // three rounds; skips and mistakes are hand-written
  { f: (m) => m.replace(/(  cpu-limit .*)/, "$1\n  readiness-probe /health"), skip: "production-east" },
  { f: (m) => m.replaceAll(/(  image-tag) .*/g, "$1 4.3.0"), skip: "testing" },
  { f: (m) => m.replace(/(  cpu-request) .*/, "$1 250"),
    wrong: { "pre-production": (m) => m.replace(/(  cpu-request) .*/, "$1 300") } }];
let template = TEMPLATE, templateEdited = 0;
for (const t of round) {
  for (const o of ENVIRONMENT) {
    if (t.skip === o) continue;
    manual[o] = (t.wrong?.[o] ?? t.f)(manual[o]);
    edited++;
  }
  template = t.f(template);                          // in the template path, the same round is one touch
  templateEdited++;
}
const generated = Object.fromEntries(ENVIRONMENT.map((o) => [o, render(template, VALUE[o])]));

const drift = (set) => {                          // copies that diverge from the majority on common fields
  const diverging = {};
  let count = 0;
  for (const a of COMMON) {
    const d = ENVIRONMENT.map((o) => (set[o].match(new RegExp(`  ${a} (.*)`)) ?? [, "-"])[1]);
    const majority = d.map((v) => [v, d.filter((x) => x === v).length]).sort((x, y) => y[1] - x[1])[0][0];
    ENVIRONMENT.forEach((o, i) => { if (d[i] !== majority) { count++; (diverging[o] ??= []).push(a); } });
  }
  return { diverged: Object.keys(diverging).length, count, diverging };
};
const e = drift(manual), s = drift(generated);
console.log(`manual path   : written ${written}  copied ${copied}  hand-edited ${edited}` +
  `  lines in repo ${ENVIRONMENT.map((o) => lines(manual[o])).reduce((a, b) => a + b)}`);
console.log(`template path : written ${lines(TEMPLATE)}  value lines ${ENVIRONMENT.length}` +
  `  hand-edited ${templateEdited}  lines in repo ${lines(TEMPLATE) + ENVIRONMENT.length}`);
console.log(`values that vary per environment ${FIELD.length}   fields that should stay common ${COMMON.length}`);
console.log(`drift (manual)  : ${e.diverged}/5 copies diverged, ${e.count} fields`);
for (const [o, a] of Object.entries(e.diverging)) console.log(`    ${o.padEnd(16)} ${a.join(", ")}`);
console.log(`drift (template): ${s.diverged}/5 copies diverged, ${s.count} fields`);
```

```
manual path   : written 16  copied 64  hand-edited 44  lines in repo 84
template path : written 21  value lines 5  hand-edited 3  lines in repo 26
values that vary per environment 7   fields that should stay common 5
drift (manual)  : 3/5 copies diverged, 3 fields
    testing          image-tag
    pre-production   cpu-request
    production-east  readiness-probe
drift (template): 0/5 copies diverged, 0 fields
```

In the manual path, eighty-four lines sit in the repository, sixty-four of them copies. The real
number is not the copies — it is the **forty-four lines hand-edited**: thirty-one at initial
setup, thirteen across three rounds of change. In the template path, the same job finishes with a
twenty-one-line template, a five-line value list, and three touches across three rounds. The
lines-in-repo ratio is roughly three to one; the touches-per-round ratio is thirteen to three.

Drift shows why the ratio comes out this way. The five copies have five fields that should stay
common, and after three rounds, **three copies have diverged on three fields**: testing was left on
the old image tag, pre-production wrote 300 instead of 250 for the resource request,
production-east never got the readiness probe at all. All three have the same one cause — the
common value sitting in five separate places. Drift's measurable side is right here too: even when
copies diverge, none of them errors out, because each one is a valid chart on its own. In the
template path, the same three rounds leave zero drift; the common value sits in one place, so
there is no copy left to skip.

## The Template's Own Cost

```js
// cost.mjs — the template's own cost: variables, conditional branches, readability, schema flaw.
import { TEMPLATE, render, ENVIRONMENT, VALUE } from "./data.mjs";

const variable = new Set([...TEMPLATE.matchAll(/\{\{([\w-]+)\}\}/g)].map((m) => m[1]));
const branch = [...TEMPLATE.matchAll(/\{\{#(?:if|each) ([\w-]+)\}\}/g)].map((m) => m[1]);
const shape = new Set(ENVIRONMENT.map((o) => branch.map((a) => (VALUE[o][a] ? 1 : 0)).join("")));
const marked = TEMPLATE.replace(/\{\{#(?:if|each) [\w-]+\}\}([\s\S]*?)\{\{\/(?:if|each)\}\}/g,
  (_, g) => g.split("\n").map((s) => (s.trim() ? "@" + s : s)).join("\n"));
const tS = marked.split("\n").filter((s) => s.trim()), conditional = tS.filter((s) => s.startsWith("@")).length;
console.log(`template ${tS.length} lines  variables ${variable.size}  conditional branches ${branch.length}` +
  `  producible shapes ${2 ** branch.length}  used across five environments ${shape.size}`);
console.log(`lines certain from reading the template ${tS.length - conditional}/${tS.length};` +
  ` ${conditional} lines require generation`);

const SCHEMA = { replicas: "number", "image-tag": "text", "cpu-request": "number", "cpu-limit": "number",
  port: "number", "log-level": "text", "batch-size": "number", "verifier-address": "text",
  host: "text", "cpu-total": "number", "memory-total": "number", label: "text" };
const check = (m) => m.split("\n").filter((s) => s.startsWith("  ")).flatMap((s) => {
  const [a, d] = s.trim().split(/ (.+)/);            // the schema is outside the engine, applied to the output
  if (!(a in SCHEMA)) return [`unknown field ${a}`];
  if (d?.startsWith("<missing:")) return [`${a} = ${d}`];
  return SCHEMA[a] === "number" && !/^\d+$/.test(d ?? "") ? [`${a} is not a number`] : [];
});
let flaws = 0, dead = 0;
for (const o of ENVIRONMENT) {
  const c = render(TEMPLATE, VALUE[o]), h = check(c);
  flaws += h.length;
  if (h.length) console.log(`  ${o.padEnd(16)} generated, schema flaw: ${h.join("; ")}`);
  if (!c.includes(String(VALUE[o].host))) dead++;
}
console.log(`caught by the engine ${0}/${flaws}   post-generation schema check ${flaws}/${flaws}` +
  `   dead value ${dead}   never-generated shapes ${2 ** branch.length - shape.size}`);
```

```
template 20 lines  variables 8  conditional branches 3  producible shapes 8  used across five environments 3
lines certain from reading the template 14/20; 6 lines require generation
  production-east  generated, schema flaw: replicas = <missing:standby-replicas>
caught by the engine 0/1   post-generation schema check 1/1   dead value 1   never-generated shapes 5
```

The template's cost collects into three numbers. The first is the **variable count**: eight
variables, seven defined in the value list, one defined nowhere at all. The second is the
**conditional-branch count**: three branches, eight producible shapes, only three used across the
five environments — the remaining five shapes sit in the template but are never generated, and
therefore never tested.

The third is readability, and it ties to this number: **fourteen of the twenty-line template's
lines can be known for certain by reading it; six require generation**. These six lines sit inside
the conditional blocks; whether they show up in an environment's output cannot be decided by
looking at the template, they have to be generated together with the value list. The hand-written
chart carries none of this burden — what is written there is the output itself. In the template
path, the human no longer reads the output; they read **the program that generates it**.

The last row shows delegation's real cost. The template engine knows three jobs and does not know
the object schema; because the `standby-replicas` variable is defined in no value list, it writes
`<missing:standby-replicas>` into production-east's output and **produces no error**. The engine
catches zero of this flaw; the post-generation schema check catches the one there is. The same
silence shows up the other way too: the `host` value defined for the development environment is
used in no output at all, because that environment never enters the ingress-rule branch — a
**dead value**, sitting in the value list with no warning produced anywhere.

## Delegation's Balance Sheet

| Measure | Manual | Templated |
|---|---|---|
| lines hand-edited at initial setup | 31 | 21 (template) + 5 (values) |
| hand touches across three change rounds | 13 | 3 |
| lines in repo | 84 | 26 |
| copies diverged on a common field | 3/5 | 0/5 |
| lines certain by reading | 84/84 | 14/20 |
| shapes never generated | none | 5 |
| who catches the schema flaw | a human | engine 0, post-hoc schema check 1 |

The delegated decision can be written in one sentence: **a generator, not a human, decides each
environment's object set's final form.** The information items this needs are countable too —
seven values per environment, a list of open branches per environment, and which field is supposed
to stay common. The first two are in the value list; **the third is written down nowhere**. The
template carries no knowledge that "the image tag must be the same across every environment"; that
field stays common only because it happens to be written as a constant in the template. If the
same field is moved into a branch tomorrow, the commonality ends silently, and there is no check
that would report it.

Information's cost can also be read from this table. Seven values get written once per environment
and grow linearly as environments are added; the branch list is one line per environment, but the
moment the template's branch count grows, the number of producible shapes grows by a power of two.
Three branches today mean eight shapes; add a fourth branch, and the five environments still
produce the same five outputs, but the count of untested shapes climbs from five to eleven. The
template's growth does not move at the same speed as the value list's growth.

The gain and the cost come out of the same place: the output no longer gets read. In the manual
path, all eighty-four of the eighty-four lines were readable, and the copies still diverged; in the
template path, drift is zero, but the text being read is no longer the output — it is the
generator, and six of the generator's lines are known only by running it. The template did not
eliminate drift, it **moved where drift lives**: from the difference between five copies to a
single generator's five never-generated shapes.

## Summary

- Setting up five environments by hand wrote 16 lines, copied 64 lines, and hand-edited 44 lines;
  the same job with a template finished with 21 lines of template, 5 lines of values, and 3
  touches.
- After three rounds of change, 3 of the 5 manual copies had diverged on 3 of the 5 fields that
  should have stayed common; drift in the template path stayed at 0.
- The template's cost is 8 variables and 3 conditional branches: only 3 of 8 producible shapes are
  used, and 5 are never generated and never tested.
- 14 of the template's 20 lines can be known for certain by reading it, 6 require generation; the
  human no longer reads the output, but the program that generates it.
- Because the engine does not know the schema, it wrote the undefined variable into the output as
  `<missing:...>` and caught 0 of the 1 flaw; the same silence, in the other direction, left 1 dead
  value behind.

## Next Step

A template produces new combinations from object types that already exist; it cannot produce **a
new type**. The regional measurement network's reading-collection arrangement does not sit neatly
in any of the five objects — its deployment, its quota, and its scheduled job need to be referred
to together, as a single concept. The next lesson extends the set of resource types the cluster
recognizes: a fictional type is defined, its fields are tied to a schema, and how many invalid
declarations get caught at admission versus how many only blow up at runtime are both counted. As
the schema grows, how caught errors and writing burden grow together, and how many fields old
declarations break on under the new schema, are measured there too.
