Lesson 18 / 27
Module Authoring
The same record, written separately at three collection points, is extracted into a single module; input surface, line gain and the cost of flexibility growing across six versions are counted. The four items the module's author never saw are also measured.
Published Updated
In this lesson
- Separates the input surface when extracting a resource set into a module: fixed field, required input, defaulted input
- Counts the line gain from modularizing and the items the module does not cover, separately
- Shows the cost of flexibility by measuring the input each new use adds to the module
Contents
The previous lesson ran the same definition across multiple environments: the workspace split state, and the environments were produced from the same text without touching each other’s resources. The definition itself was still one single piece, though. The regional measurement network’s three collection points contain the same three resources, and each carries its own record block start to finish; nothing shared sits in a shared place.
A module is a piece of configuration that names a set of resources created and deleted
together, whose interface is made of inputs and outputs. The word is the same as module
in software packages; the difference in context is this: there, the interface was callable
functions and what it produced was behavior; here, the interface is a list of variables, and what
it produces is the resource itself.
The measurement is again on the regional measurement network: fictional software that collects
water meter readings. No real infrastructure is provisioned; what is measured is a model built
with node.
SM25. There are three collection points; each consists of three resources, and the three
records were written separately by hand.
SM26. The configuration language is defined for this lesson: resource <type> <name> { field = value }, input <name> [= default], output <name> = <value>, module <type> <name> { field = value }. $name is bound to the call’s name, a field whose value is none is not produced, and
a resource carrying if is conditional.
SM27. The module is written by hand; the author decides which field surfaces on the
interface. The code tests the module against the three records and compares the interface against
the fields that change in the record.
SM28. Only required inputs and fields that diverge from the default are written into the call.
SM29. Four items were actually changed by hand and appear in no record; all four are written
out, there is no randomness.
SM30. The module grows to six versions across five new uses; each version’s change is written
in advance, and one of the eight opened uses is later closed.
The Same Record in Three Places
// records.mjs — the regional measurement network's three collection points; three separately hand-written records. export const RECORD = `# point north resource virtual-machine-cluster north-collector { count = 4 image = collector-4.2.0 region = north-1 } resource disk north-buffer { size-gb = 200 attached = north-collector } resource network-rule north-inbound { source-block = 10.20.0.0/16 port = 8080 direction = inbound } # point west resource virtual-machine-cluster west-collector { count = 6 image = collector-4.2.0 region = west-1 } resource disk west-buffer { size-gb = 500 attached = west-collector } resource network-rule west-inbound { source-block = 10.30.0.0/16 port = 8080 direction = inbound } # point east resource virtual-machine-cluster east-collector { count = 6 image = collector-4.2.0 region = east-1 } resource disk east-buffer { size-gb = 500 attached = east-collector } resource network-rule east-inbound { source-block = 10.40.0.0/16 port = 8443 direction = inbound }`; export const POINT = ["north", "west", "east"]; export const MANUAL = [ // exists in reality, in no record ["west", "disk", "size-gb", "750"], // disk manually enlarged ["north", "virtual-machine-cluster", "count", "6"], // cluster manually scaled ["east", "network-rule", "extra-port", "9443"], // field not in the record added by hand ["north", "object-store", "*", "north-archive"]]; // out-of-record resource opened by hand
The record is forty-two lines, and each point has eight field slots. The MANUAL list is the
second reality standing next to the record: four items that exist in the field but are written in
no record. How many of these four the module’s author sees — that is what gets measured.
The Module’s Input Surface
// module.mjs — a hand-written module tested against three records; input surface, line gain and record-reality gap. import { RECORD, POINT, MANUAL } from "./records.mjs"; export const lineCount = (t) => t.split("\n").filter((s) => s.trim()).length; export const TEXT = Object.fromEntries(RECORD.split(/^# point /m).filter(Boolean) .map((p) => [p.split("\n")[0], p.split("\n").slice(1).join("\n").trim()])); export const K = Object.fromEntries(POINT.map((n) => [n, TEXT[n].split("\n").reduce((b, s) => { const k = s.match(/^resource (\S+) (\S+) \{$/), f = s.match(/^ {2}([\w-]+) = (.+)$/); if (k) b.push({ type: k[1], name: k[2], fields: [] }); else if (f) b.at(-1).fields.push([f[1], f[2]]); return b; }, [])])); const D = Object.fromEntries(POINT.map((n) => [n, Object.fromEntries(K[n].flatMap((b) => b.fields))])); export const MODULE = `input source-block input count = 6 input size-gb = 500 input port = 8080 resource virtual-machine-cluster $name-collector { count = $count image = collector-4.2.0 region = $name-1 } resource disk $name-buffer { size-gb = $size-gb attached = $name-collector } resource network-rule $name-inbound { source-block = $source-block port = $port direction = inbound } output buffer-name = $name-buffer output inbound-port = $port`; // written by hand; the choice of output is not in the record export const generate = (mod, name, arg) => { // $name is the call's name; a field whose value is `none` is not produced const names = [...mod.matchAll(/^input ([\w-]+)/gm)].map((m) => m[1]).sort((a, b) => b.length - a.length); const lines = []; let skip = false; for (const s of mod.split("\n")) { if (/^(input|output) /.test(s)) continue; let t = s.replaceAll("$name", name); for (const i of names) t = t.replaceAll("$" + i, arg[i]); const k = t.match(/ if (\S+) \{$/); if (k) { skip = k[1] === "no"; t = t.replace(/ if \S+ \{$/, " {"); } else if (t === "}" && skip) { skip = false; continue; } if (!skip && !/ = none$/.test(t)) lines.push(t); } return lines.join("\n"); }; export const required = (m) => [...m.matchAll(/^input ([\w-]+)$/gm)].map((x) => x[1]); export const defaults = (m) => Object.fromEntries([...m.matchAll(/^input ([\w-]+) = (.+)$/gm)].map((x) => [x[1], x[2]])); export const passed = (m, n) => { // fields that diverge from required and default are passed to the call const v = defaults(m); return Object.fromEntries([...required(m), ...Object.keys(v)].filter((a) => D[n][a] !== v[a]).map((a) => [a, D[n][a]])); }; export const callLines = (m, n) => Object.keys(passed(m, n)).length + 2; // line count of the call if (import.meta.main) { // does not run when version.mjs imports this const fields = Object.keys(D[POINT[0]]), v = defaults(MODULE); const changing = fields.filter((a) => new Set(POINT.map((n) => D[n][a])).size > 1); const iface = [...required(MODULE), ...Object.keys(v)], outside = changing.filter((a) => !iface.includes(a)); const same = POINT.filter((n) => generate(MODULE, n, { ...v, ...passed(MODULE, n) }) === TEXT[n]).length; const byHand = POINT.reduce((t, n) => t + lineCount(TEXT[n]), 0), calls = POINT.map((n) => callLines(MODULE, n)); const total = calls.reduce((x, y) => x + y); console.log(`field slots ${fields.length} | changing ${changing} | fixed ${fields.length - changing.length}` + ` | input surface ${iface.length} = required ${required(MODULE)} + defaulted ${Object.keys(v)}`); console.log(`changing but outside interface ${outside.length} (${outside}) | module ${lineCount(MODULE)} = ${iface.length}+` + `${lineCount(MODULE) - iface.length - 2}+2 | by hand ${byHand} | module path ${lineCount(MODULE)}+${total}=${lineCount(MODULE) + total}` + ` | gain ${byHand - lineCount(MODULE) - total} | calls ${calls} | same as original ${same}/3`); let covered = 0; for (const [n, type, a, d] of MANUAL) { // the gap between record and reality const b = K[n].find((x) => x.type === type), y = b?.fields.find(([x]) => x === a); console.log(` ${n.padEnd(6)} ` + (!b ? `out-of-record resource ${d}` : !y ? `field not in record ${a} = ${d}` : `value gap ${a}: record ${y[1]}, reality ${d}`).padEnd(44) + (b ? "in scope" : "out of scope")); if (b) covered++; } console.log(`items not written in record ${MANUAL.length} | seen by the module's author 0 | to be reverted ${covered}` + ` | left unrecorded ${MANUAL.length - covered}`); }
field slots 8 | changing count,region,size-gb,attached,source-block,port | fixed 2 | input surface 4 = required source-block + defaulted count,size-gb,port changing but outside interface 2 (region,attached) | module 20 = 4+14+2 | by hand 42 | module path 20+12=32 | gain 10 | calls 5,3,4 | same as original 3/3 west value gap size-gb: record 500, reality 750 in scope north value gap count: record 4, reality 6 in scope east field not in record extra-port = 9443 in scope north out-of-record resource north-archive out of scope items not written in record 4 | seen by the module's author 0 | to be reverted 3 | left unrecorded 1
The input surface is four: one required, three defaulted. The module is twenty lines; four are interface declarations, fourteen are body, two are output.
The first row counts what the interface is missing. Six of the eight field slots vary between the
three records, but only four are on the interface; the two fields that vary but are not on the
interface are region and attached. The measurement cannot tell them apart, because both are
written through $name. But attached belongs outside, rightfully: it is a dependency name,
pointing at a resource the module produces itself. region is not the same — the region value
starting with the point’s name is not a rule, it is a pattern that happens to hold across three
examples. The module’s author trusted the pattern and kept the field off the interface; the cost
is paid in the next section.
In the opposite direction stands output. Which resource’s name gets exposed outward is written
in none of the three records. Input can be inferred from what differs in the record, output
cannot be inferred.
The call lines are five, three, and four. West is called with three lines because all three defaults match west’s record; choosing a default rewards the majority, and costs whoever is in the minority an extra line. Line gain goes from forty-two to thirty-two, that is twenty-four percent — not a number that would justify writing a module for three uses on its own.
The Four Items the Record Does Not Cover
The module reproduced the three records exactly, but saw zero of the four items that actually
exist: what it looked at was the record. Two are value gaps — west’s disk is seven hundred
fifty gigabytes instead of five hundred, north’s machine cluster is six instead of four. One is a
field that is in no record at all: a second port was manually opened on east’s network rule.
The last is an out-of-record resource: no record says anything about the north-archive
object store up north.
The module’s scope splits these four in two. Three sit inside the blocks the module produces; the moment it applies, the disk reverts to five hundred, the machine count to four, and the extra port is deleted — modularizing never saw these three items, but the apply will silently revert them. The fourth is a resource type the module knows nothing about; the module neither produces it nor deletes it. Modularizing does not reduce drift: it makes what it covers revertible from a single place, and leaves what it does not cover exactly where it is.
The Cost of Flexibility
// version.mjs — the module grows to six versions across five new uses; input surface and the cost of flexibility are counted. import { MODULE, generate, lineCount, required, defaults } from "./module.mjs"; const append = (m, ...s) => m.replace(/^(input .*)$(?![\s\S]*^input )/m, ["$1", ...s].join("\n")); const VERSION = [ // each version's change is written in advance ["v1", "written by hand", ["north", "west", "east"], (m) => m], ["v2", "region name pattern broken", ["south"], (m) => append(m.replace(" region = $name-1", " region = $region"), "input region")], ["v3", "usage that wants no disk", ["test-site"], (m) => append(m.replace("e disk $name-buffer {", "e disk $name-buffer if $has-disk {"), "input has-disk = yes")], ["v4", "outbound direction and a second port", ["field-lab"], (m) => append(m.replace(" direction = inbound", " direction = $direction") .replace(" port = $port\n", " port = $port\n extra-port = $extra-port\n"), "input direction = inbound", "input extra-port = none")], ["v5", "usage that wants an old image", ["archive-point"], (m) => append(m.replace(" image = collector-4.2.0", " image = $image"), "input image = collector-4.2.0")], ["v6", "machine tag", ["central-anatolia"], (m) => append(m.replace(" count = $count\n", " count = $count\n tag = $tag\n"), "input tag = none")]]; const RAW = { // the values each use passes into the module north: "source-block=10.20.0.0/16 region=north-1 count=4 size-gb=200", west: "source-block=10.30.0.0/16 region=west-1", east: "source-block=10.40.0.0/16 region=east-1 port=8443", south: "source-block=10.50.0.0/16 region=south-2", "test-site": "source-block=10.60.0.0/16 region=test-1 has-disk=no count=1", "field-lab": "source-block=10.70.0.0/16 region=field-1 direction=outbound extra-port=9443 count=1", "archive-point": "source-block=10.80.0.0/16 region=archive-1 image=collector-3.9.0 size-gb=2000", "central-anatolia": "source-block=10.90.0.0/16 region=central-1 tag=pilot count=2" }; const ARGS = Object.fromEntries(Object.entries(RAW).map(([k, v]) => [k, Object.fromEntries(v.split(" ").map((x) => x.split("=")))])); let m = MODULE, open = [], prevRequired = 1; console.log("version change uses input (required/defaulted) module lines breaking fix"); for (const [s, desc, newUses, transform] of VERSION) { m = transform(m); const req = required(m).length, total = req + Object.keys(defaults(m)).length; const breaking = req > prevRequired ? open.length : 0; // a new required input breaks old calls open = [...open, ...newUses]; console.log(`${s.padEnd(9)} ${desc.padEnd(38)} ${String(open.length).padEnd(9)} ${`${total} (${req}/${total - req})`.padEnd(28)} ` + `${String(lineCount(m)).padEnd(14)} ${breaking ? `${breaking} definitions, ${breaking} lines` : "-"}`); prevRequired = req; } open = open.filter((a) => a !== "archive-point"); // one use closed, the input it opened stayed in the module const dv = defaults(m), varNames = Object.keys(dv); const usedBy = (a) => open.filter((k) => a in ARGS[k]).length; const dead = varNames.filter((a) => usedBy(a) === 0), single = varNames.filter((a) => usedBy(a) === 1); const byHand = open.reduce((t, a) => t + lineCount(generate(m, a, { ...dv, ...ARGS[a] })), 0); const cs = open.reduce((t, a) => t + Object.keys(ARGS[a]).length + 2, 0); console.log(`opened 8, closed 1, open ${open.length} | met without changing the module 0/5 | input surface 4 -> ` + `${varNames.length + 2}, per use ${((varNames.length - 2) / 5).toFixed(1)} | by hand ${byHand} | module path ` + `${lineCount(m)}+${cs}=${lineCount(m) + cs} | gain ${byHand - lineCount(m) - cs}`); console.log(`defaulted inputs ${varNames.length}: two or more ${varNames.length - single.length - dead.length}` + `, single use ${single.length} (${single}), dead ${dead.length} (${dead})`);
version change uses input (required/defaulted) module lines breaking fix v1 written by hand 3 4 (1/3) 20 - v2 region name pattern broken 4 5 (2/3) 21 3 definitions, 3 lines v3 usage that wants no disk 5 6 (2/4) 22 - v4 outbound direction and a second port 6 8 (2/6) 25 - v5 usage that wants an old image 7 9 (2/7) 26 - v6 machine tag 8 10 (2/8) 28 - opened 8, closed 1, open 7 | met without changing the module 0/5 | input surface 4 -> 10, per use 1.2 | by hand 96 | module path 28+38=66 | gain 30 defaulted inputs 8: two or more 1, single use 6 (size-gb,port,has-disk,direction,extra-port,tag), dead 1 (image)
The cost of flexibility reads in one line: zero of the five new uses were met without changing the module. Every new use forced open a new input on the interface; the input surface went from four to ten, and the module’s text grew from twenty to twenty-eight lines. The measured rate is 1.2 inputs per use.
The breaking fix column at the second version is the cost’s most visible form. When the
region field’s naming pattern broke at the fourth point, the field surfaced as a required input,
and all three of the three definitions that existed at that moment became invalid. The burden
of opening a new required input falls not on the module’s author, but on its users.
The last line counts who flexibility actually serves. Of the eight defaulted inputs, only one is
passed by two or more uses; six exist for a single use, one for none. The dead input, image, was
opened at the fifth version because the archive point wanted an old image; the archive point
closed, the input stayed in the module. Removing an input concerns every definition that uses it,
so no one takes it on.
The gain still grew, though. If the seven open uses had been written by hand, they would run ninety-six lines; the module path is sixty-six lines, and the gain is thirty-one percent. Gain grows with the number of uses, and so does the interface; the two do not grow at the same rate.
Summary
- The hand-written module’s input surface is 4: 1 required, 3 defaulted; of 20 lines, 4 are interface declarations, 2 are output, and the choice of output cannot be read from the record.
- 6 of the 8 field slots vary across the three records, but only 4 are on the interface;
regionwas left off because it relied on a naming pattern. - Line gain went from 42 to 32 (24 percent) at three uses, and from 96 to 66 (31 percent) at seven uses.
- The module reproduced the three records exactly (3/3) but saw 0 of the 4 items that actually
exist; 3 are silently reverted on apply, and the out-of-record
north-archivestays unrecorded. - 0 of the 5 new uses were met without changing the module; the input surface went from 4 to 10, and of 8 defaulted inputs, 6 serve a single use and 1 serves none.
- Opening a new required input is breaking: the second version required a one-line fix in all 3 of the 3 definitions that existed at the time; this burden falls on the users.
Next Step
All six versions of this module lived in the same repository, under the same hand; the author and the user were the same side. Once modules start being pulled from a shared place, that relationship breaks: the upgrade decision is made in one place and felt in others. The next lesson runs two setups, version-pinned and unpinned, over one period, and counts this: how many applies produce a different result while the definition file never changed, how many users an upgrade touches, and how many lines of fixing a breaking change demands across how many definitions. The cost of pinning is measured in the same place: how many modules stay on an old version, and how many fixes they never get.
To keep your progress and take notes, Log in
My notes
Log in to take notes.