Skip to content
academia.sh

Lesson 26 / 30

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.

Published Updated

In this lesson

  • 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
Contents

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

// 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

// 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

// 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.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close