Skip to content
academia.sh

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.

Start typing to search.

↑↓ ↵ Esc navigate · open · close