Lesson 01 / 27
What Is Infrastructure as Code
The same staging environment is set up twice — once by hand from a prose request, once from a record — and the two setups are compared item by item. For a quarter's worth of changes, how many times the question of who, when, and what changed gets answered, how many items fall outside the record's scope, and the cost of tying to a record in lines and extra steps are counted.
Published Updated
In this lesson
- Counts the difference between two setups of the same environment item by item and separates where the difference comes from
- Measures change traceability with a single criterion: how many times the who, when, what-changed question gets answered
- Counts the item the record does not cover, and the cost of tying to a record in lines and extra steps
Contents
The previous course tied the application’s path to production to the repository: when a branch merges, the pipeline runs, gates are passed, and deployment proceeds on its own. The application’s path is written from end to end. The machines it stands on, the queue, the data store, and the network, though, are set up through a different route — a request is opened, someone reads that request and sets up the environment, and the result of the setup lives only in the environment itself.
Infrastructure as code is tying infrastructure to a readable record: what the environment should be is written in a file, and the environment is set up from that file. This course’s unit of measure is the record, and every lesson counts three things — the difference between what the record says and reality, the record’s scope, and the record’s cost. This lesson sets up the first: the same environment is set up twice, once by hand from a prose request and once from a record, then compared item by item. Then, for a quarter’s worth of changes, it measures how many times the question “who, when, what changed” gets answered, how many items fall outside the record’s scope, and the cost of tying to a record, in lines and extra steps.
The measurement runs on the regional measurement network: fictional software that collects
water-meter readings, verifies them, converts them into invoices, and opens field work orders. It
has three environments; only the staging environment is set up here. Infrastructure is not really
provisioned — resources and setups are modeled with node; the record and request files that feed
the model are genuinely written to disk.
IF1. The staging environment is represented by six resources — one network, two machine groups, one queue, one data store, one object store. An item is one attribute of a resource; the record writes twenty items. IF2. The setup request is prose, and seven of its eleven items give no number. A hand is a model; its interpretation is written in advance — there is no randomness. IF3. The provider default is five items: settings the record does not write, that arise in the environment regardless. They are born the same in both setups. IF4. A quarter sees fourteen changes; each change asks three questions — who, when, what changed. Each change’s route is written in advance. IF5. A change routed through the record answers all three questions; a change that falls into a work ticket answers two (it does not record what changed); an untracked change answers none. IF6. The record route has three extra steps per change: write the record, get it reviewed, read the plan.
Same Environment, Two Recipes
#!/usr/bin/env bash # Two recipes for the same environment: the record (configuration language) and a prose setup request. set -e cat > record.infra <<'SON' # Our configuration language's format: resource <type> "<name>" { <key> = <value> } # A value is a number, a "string", or true/false; a line starting with # is a comment. resource network "measurement-network" { block = "10.40.0.0/16" private = true region = "north" } resource machine "collector" { count = 3 size = "medium" disk_gb = 40 image = "base-7" } resource machine "verifier" { count = 2 size = "small" disk_gb = 20 image = "base-7" } resource queue "reading-queue" { retention_hours = 72 partitions = 6 dead_letter = true } resource data_store "measurement-store" { size_gb = 200 backup_days = 14 version = "5.2" } resource object_store "archive" { class = "sparse" versioning = true lifecycle_days = 365 } SON cat > request.txt <<'SON' staging environment setup request 1. open the measurement network as private, address block 10.40.0.0/16, north region 2. open enough collector machines, size medium, image base-7 3. size the collector disk to the measurement volume 4. two verifier machines, size small, disk 20 GB, image base-7 5. open the reading queue, set retention close to production, dead letter on 6. set the queue partition count to the worker count 7. open the measurement store at 200 GB, version 5.2 8. set the store's backup window to the corporate standard 9. open an object store for the archive, versioning on 10. choose the archive's storage class by cost 11. write a reasonable value for the archive lifecycle day count SON
The configuration language is defined here with its own small format: a resource line carries a
type, a name, and key-value pairs inside curly braces. Six lines count twenty items, and each item
is tied to a single value — two readers understand the same thing. The request targets the same
environment, but seven of its items give a criterion instead of a number: “enough”, “to the
measurement volume”, “close to production”, “the corporate standard”, “by cost”, “a reasonable
value”. These seven points are the request’s unwritten part, and whoever builds it fills them
in.
// setup.mjs — parses the record and builds the same environment two ways: from the record and by hand. Both are models. import { readFileSync } from "node:fs"; const BLOCK = /^resource\s+(\w+)\s+"([\w-]+)"\s*\{(.*)\}$/; // the language's one-line format export const readRecord = (text) => { const k = new Map(); for (const line of text.split("\n")) { const m = BLOCK.exec(line.trim()); // comments and blank lines are skipped if (m) for (const x of m[3].matchAll(/(\w+)\s*=\s*("[^"]*"|[\w./-]+)/g)) k.set(`${m[1]}.${m[2]}.${x[1]}`, x[2].replaceAll('"', "")); } return k; }; export const record = readRecord(readFileSync("record.infra", "utf8")); // Provider default: items the record does not write, that arise in the environment regardless. const providerDefaults = new Map([["network.measurement-network.egress_path", "open"], ["machine.collector.access_rule", "management-port-open"], ["queue.reading-queue.encryption", "provider-key"], ["data_store.measurement-store.maintenance_window", "sunday-03:00"], ["object_store.archive.access_rule", "in-account"]]); // The hand's pre-written interpretation: the seven points the request gives no number for (2, 3, 5, 6, 8, 10, 11). const handInterpretation = new Map([["machine.collector.count", "2"], ["machine.collector.disk_gb", "40"], ["queue.reading-queue.retention_hours", "24"], ["queue.reading-queue.partitions", "6"], ["data_store.measurement-store.backup_days", "7"], ["object_store.archive.class", "dense"], ["object_store.archive.lifecycle_days", "90"]]); const handSkipped = ["object_store.archive.versioning"]; // written in the request, skipped in practice const handAdded = new Map([["network.measurement-network.access_rule", "office-block"], ["machine.collector.extra_disk", "attached"], ["machine.verifier.dns_record", "verifier.internal"]]); export const buildFromRecord = () => new Map([...record, ...providerDefaults]); export const buildManually = () => { const o = new Map([...record, ...handInterpretation, ...providerDefaults, ...handAdded]); for (const a of handSkipped) o.delete(a); return o; }; export const interpretationPoints = handInterpretation.size, outOfRecordCount = providerDefaults.size;
The Difference, Item by Item
// diff.mjs — compares the two setups item by item; fixes nothing. import { record, buildFromRecord, buildManually, interpretationPoints, outOfRecordCount } from "./setup.mjs"; const a = buildFromRecord(), b = buildManually(); const all = [...new Set([...a.keys(), ...b.keys()])].sort(); const diff = all.filter((k) => a.get(k) !== b.get(k)).map((k) => ({ k, kind: !b.has(k) ? "missing" : !a.has(k) ? "extra" : "diverged" })); const countBy = (t) => diff.filter((f) => f.kind === t).length; console.log(`items from record ${a.size} by hand ${b.size} union ${all.length}`); console.log(`diff ${diff.length} items (diverged ${countBy("diverged")}, missing ${countBy("missing")},` + ` extra ${countBy("extra")})`); for (const f of diff) console.log(` ${f.kind.padEnd(8)} ${f.k.padEnd(42)} ${(a.get(f.k) ?? "-").padEnd(20)}` + ` ${b.get(f.k) ?? "-"}`); console.log(`items in the record ${record.size} points the request gives no number for ${interpretationPoints}` + ` of these diverged ${diff.filter((f) => f.kind === "diverged").length}`); console.log(`items outside the record's scope ${outOfRecordCount} the same in both setups,` + ` written in neither`);
items from record 25 by hand 27 union 28 diff 9 items (diverged 5, missing 1, extra 3) diverged data_store.measurement-store.backup_days 14 7 diverged machine.collector.count 3 2 extra machine.collector.extra_disk - attached extra machine.verifier.dns_record - verifier.internal extra network.measurement-network.access_rule - office-block diverged object_store.archive.class sparse dense diverged object_store.archive.lifecycle_days 365 90 missing object_store.archive.versioning true - diverged queue.reading-queue.retention_hours 72 24 items in the record 20 points the request gives no number for 7 of these diverged 5 items outside the record's scope 5 the same in both setups, written in neither
The two setups of the same environment diverged on nine items, and the difference comes from three separate kinds. Diverged covers five items, five of the seven points the request gave no number for: the remaining two are invisible because the hand’s interpretation happened to land on the same value as the record. These two are not a gain, they are a coincidence — the interpretation-point count is still seven, only five of them diverged in this run. The one missing item was written explicitly in the request and never applied; an item skipped in a prose list leaves no trace anywhere. The three extra items never appear in the request at all: an access rule, an extra disk, and a DNS record. They come from the routine of the person setting up the environment and are written in no recipe — they exist once setup is done, but why they exist lives only in one person’s memory.
The direction of the difference is meaningful too. In four of the five diverged items, the hand’s value is smaller than the record’s — two collectors instead of three, twenty-four hours instead of seventy-two, seven days instead of fourteen, ninety days instead of three hundred sixty-five. When a request without a criterion is read, the value chosen answers the load visible at that moment; the value the record carries is the one decided once. The difference between the two setups is therefore not a list of errors, it is a list of two separate moments of decision.
The last line is the second half of this lesson’s measurement axis. The five items the record does not cover are the same in both setups, because the provider gives rise to both: the egress path, the management-port rule, the encryption key, the maintenance window, the in-account access rule. Because the record says nothing about them, a comparison never shows them as a difference. Writing the record does not zero out the difference; it makes the difference visible only as far as the record’s scope reaches.
Who, When, What Changed
// track.mjs — how traceable a quarter's changes are in the two environments. Model. import { record } from "./setup.mjs"; // Fourteen changes in a quarter; written in advance, no randomness. const CHANGES = [["machine.collector.count", "record"], ["machine.collector.size", "record"], ["queue.reading-queue.partitions", "record"], ["data_store.measurement-store.size_gb", "record"], ["machine.collector.access_rule", "hand"], ["object_store.archive.lifecycle_days", "record"], ["network.measurement-network.block", "record"], ["machine.verifier.count", "record"], ["queue.reading-queue.retention_hours", "hand"], ["data_store.measurement-store.backup_days", "record"], ["machine.verifier.image", "record"], ["object_store.archive.class", "record"], ["network.measurement-network.egress_path", "hand"], ["machine.collector.disk_gb", "record"]]; const ticketFiled = [0, 3, 7, 10]; // the four that get a ticket opened in the manual environment // A change routed through the record answers all three questions; a work ticket answers two (it does not record what changed). const answered = { record: 3, ticket: 2, untracked: 0 }; const A = CHANGES.map(([, y]) => (y === "record" ? "record" : "untracked")); const B = CHANGES.map((_, i) => (ticketFiled.includes(i) ? "ticket" : "untracked")); const sumAnswers = (x) => x.reduce((t, s) => t + answered[s], 0); const percent = (n) => (100 * n / (CHANGES.length * 3)).toFixed(1); console.log(`changes ${CHANGES.length} questions ${CHANGES.length * 3} (who / when / what changed)`); for (const [name, x] of [["environment set up from the record", A], ["manually set up environment", B]]) console.log(` ${name.padEnd(35)}: answered ${sumAnswers(x)}/${CHANGES.length * 3}` + ` (${percent(sumAnswers(x))}%) untracked changes ${x.filter((s) => s === "untracked").length}`); const handOnly = CHANGES.filter(([, y]) => y === "hand").map(([k]) => k); console.log(`${handOnly.length} changes that skip the record in the record-built environment:`); for (const k of handOnly) console.log(` ${k.padEnd(41)} ${record.has(k) ? "in the record -> shows up as drift" : "not in the record -> shows up in no comparison"}`);
changes 14 questions 42 (who / when / what changed) environment set up from the record : answered 33/42 (78.6%) untracked changes 3 manually set up environment : answered 8/42 (19.0%) untracked changes 10 3 changes that skip the record in the record-built environment: machine.collector.access_rule not in the record -> shows up in no comparison queue.reading-queue.retention_hours in the record -> shows up as drift network.measurement-network.egress_path not in the record -> shows up in no comparison
Thirty-three of the forty-two questions are answered on the record route, eight on the manual route. The difference is not one of discipline, it is one of where things are kept: on the record route a change lives in a file and the file is versioned, so all three questions are read from the same place. On the work-ticket route two questions are answered — who opened it, when it closed — but the third, what changed, is left to prose, and prose carries no measurable value.
The real information is in the last three lines. Even in the environment set up from the record, three changes skipped the record, and the three produced three different outcomes. When an item written in the record is changed by hand, a comparison shows it as drift; when an item not written in the record is changed by hand, it shows up in no comparison at all. Traceability comes not from the record’s existence but from its scope: for an item the record never touches, there is no difference at all between the record route and the manual route.
The Record’s Cost
#!/usr/bin/env bash # The cost of tying to a record: lines written and extra steps per change. printf 'lines written request %s record %s parser and applier %s (once)\n' \ "$(wc -l < request.txt | tr -d ' ')" "$(wc -l < record.infra | tr -d ' ')" \ "$(wc -l < setup.mjs | tr -d ' ')" printf 'record has %s item lines, %s items -> items per line %s\n' \ "$(grep -c '^resource' record.infra)" "20" "$(node -e 'console.log((20/6).toFixed(2))')" printf 'extra step per change hand route 0 record route 3 (write, get reviewed, read the plan)\n' printf 'over a quarter (14 changes) hand route 0 extra steps record route %s extra steps\n' "$((14 * 3))"
lines written request 12 record 8 parser and applier 38 (once) record has 6 item lines, 20 items -> items per line 3.33 extra step per change hand route 0 record route 3 (write, get reviewed, read the plan) over a quarter (14 changes) hand route 0 extra steps record route 42 extra steps
The record itself is shorter than the request: eight lines, six of them carrying items, 3.33 items per line. The cost is not there. The thirty-eight-line parser and applier is written once and reused for every environment — that is a one-time cost. The cost paid continuously is in the last line: on the record route, every change asks for three extra steps, and over a quarter that comes to forty-two extra steps. On the manual route these steps are zero; a person walks into the environment and changes the value.
What is bought for the forty-two extra steps is countable too: twenty-five more questions answered and twenty items made comparable. But what is not bought is just as clear. Five items stayed outside the record, and the forty-two extra steps do not touch them; three changes skipped the record, and the extra steps did not stop them either. A record does not produce an obligation, it produces common ground for comparison — who uses that ground is a separate question, and the rest of this course largely measures it.
Summary
- The two setups of the same environment diverged on 9 items: 5 diverged (5 of the 7 points the request gave no number for), 1 missing (written, skipped), 3 extra (written in no recipe).
- Two of the request’s seven interpretation points did not show up because the hand’s interpretation happened to land on the same value as the record; the interpretation-point count did not change, only five of them diverged in this run.
- The 5 items the record does not cover were born the same in both setups; a comparison never shows them as a difference.
- 33 of a quarter’s 42 questions were answered on the record route, 8 on the manual route; the difference comes not from discipline but from where the change is kept.
- The record is 8 lines, the parser and applier 38 lines (once); the continuous cost is 3 extra steps per change, 42 extra steps over a quarter.
Next Step
In this lesson, the record was a list of items: each line counted a resource’s attributes and wrote what the environment should be. This is not the only form a record can take. The same environment can also be written as a list of steps — first open the network, then open the machines, then attach the queue — and that list also lives in a file, is versioned, is reviewed. Both forms are records, and both ask for the same three extra steps.
The difference does not show up on the first run. The next lesson writes the same target in both forms and runs both through partial failure: where the step list stops when the third step errors, what the desired-state definition does; how much work each produces when rerun; and how many lines each requires when the target changes.
To keep your progress and take notes, Log in
My notes
Log in to take notes.