Skip to content
academia.sh

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; region was 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-archive stays 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.

Start typing to search.

↑↓ ↵ Esc navigate · open · close