Merge pull request 'The lab raises a mesh, draws it, and now places tier 0 inside it' (#1) from feat/scenario-lifecycle into main
This commit was merged in pull request #1.
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
node_modules/
|
||||||
@@ -26,38 +26,244 @@ The bootstrap scenario is a **strict subset** — same virtualisation, same netw
|
|||||||
lifecycle, stopping before a control plane exists. The full scenario is reached by putting more
|
lifecycle, stopping before a control plane exists. The full scenario is reached by putting more
|
||||||
inside the machines, not by building a second thing.
|
inside the machines, not by building a second thing.
|
||||||
|
|
||||||
**Bootstrap is what gets built here first.** Nothing in this repository requires a forge, a
|
## Using it
|
||||||
coordinator or a pipeline to be useful.
|
|
||||||
|
|
||||||
## Shape
|
|
||||||
|
|
||||||
```
|
```
|
||||||
scenarios/ declarations of a mesh to raise
|
mesh-lab check can this machine run scenarios at all
|
||||||
lifecycle/ create · snapshot · reset · destroy
|
mesh-lab validate scenarios/x.yml parse and check, raising nothing
|
||||||
network/ segments and addressing
|
mesh-lab raise scenarios/x.yml materialise it, wait until the machines are USABLE
|
||||||
place/ getting a binary onto a machine
|
mesh-lab list instances currently standing
|
||||||
|
mesh-lab exec <instance> <machine> -- <cmd...>
|
||||||
|
mesh-lab snapshot <instance> <label>
|
||||||
|
mesh-lab restore <instance> <label>
|
||||||
|
mesh-lab destroy <instance>
|
||||||
|
mesh-lab diagram scenarios/x.yml draw what the scenario asks for
|
||||||
|
mesh-lab diagram --live <instance> draw what is actually standing
|
||||||
```
|
```
|
||||||
|
|
||||||
Of the two jobs a runner might hold, **scenario lifecycle comes first** — something must
|
`check` refuses rather than warns. A machine without copy-on-write storage runs scenarios
|
||||||
materialise and reset a mesh before anything can be written against it. **Assertion execution
|
correctly and snapshots roughly 76× slower — which does not make the lab slow, it makes it
|
||||||
comes later**, with the full scenario.
|
unused, and a warning about that is read once and ignored forever.
|
||||||
|
|
||||||
## Rules
|
If the incus socket is not reachable as your user — the group was granted to a session that
|
||||||
|
already existed — set `MESH_LAB_INCUS="sudo -n incus"`.
|
||||||
|
|
||||||
- **Nothing new drives delivery.** A full scenario runs the real pipeline against a mesh named
|
## What a scenario declares
|
||||||
by the request. A second delivery path would be blind to exactly the faults worth catching.
|
|
||||||
- **A scenario starts from nothing, every time.** Adopting a machine that already exists is out
|
The **underlay**: what a hosting provider and a home router would provide, and nothing the
|
||||||
of scope — that is what makes a scenario a fixture rather than a snapshot.
|
mesh is responsible for.
|
||||||
- **The real topology is one scenario among many**, not the baseline. Anything only testable
|
|
||||||
against the shape the mesh happens to have is a gap in the vocabulary.
|
```yaml
|
||||||
|
segments:
|
||||||
|
hosting: # one public network
|
||||||
|
kind: public
|
||||||
|
cidr: [192.0.2.0/24, "2001:db8:a::/48"]
|
||||||
|
isp-home: # another, unrelated — routed to it, never bridged
|
||||||
|
kind: public
|
||||||
|
cidr: [198.51.100.0/24, "2001:db8:b::/48"]
|
||||||
|
home:
|
||||||
|
kind: private
|
||||||
|
cidr: [192.168.1.0/24, "2001:db8:b:1::/64"]
|
||||||
|
mtu: 1492
|
||||||
|
gateway:
|
||||||
|
to: isp-home
|
||||||
|
address: [198.51.100.7] # what the world sees this network as
|
||||||
|
nat: [v4] # v4 translated, v6 routed
|
||||||
|
forwardable: true
|
||||||
|
mapping_ttl: 120s
|
||||||
|
machines:
|
||||||
|
home-server:
|
||||||
|
at: { segment: home, address: [192.168.1.135, "2001:db8:b:1::135"] }
|
||||||
|
published: [{ port: 443, on: home }]
|
||||||
|
inbound: allow
|
||||||
|
```
|
||||||
|
|
||||||
|
It declares **nothing** about overlay addresses, hubs, peering, names or certificates. Those
|
||||||
|
are what the mesh does, and a scenario that supplied them would be certifying its own work.
|
||||||
|
|
||||||
|
Public segments must use documentation ranges (RFC 5737, RFC 3849) and the validator refuses
|
||||||
|
anything else **before raising**. That is not pedantry: the mesh decides public-versus-private
|
||||||
|
by matching the address, so a private range on a segment meant to be routable makes the mesh
|
||||||
|
silently never form — no error, nothing to notice.
|
||||||
|
|
||||||
|
## Reaching in
|
||||||
|
|
||||||
|
Everything goes through incus, never over IP. A scenario is a closed address space, so two
|
||||||
|
instances raised from one declaration hold the same addresses and never meet — and the
|
||||||
|
workstation has no route into either.
|
||||||
|
|
||||||
|
So a reachability question is asked **from inside**: *can this machine reach that one* is
|
||||||
|
`exec` on the first, testing the second. The workstation's opinion would be a different
|
||||||
|
question with a misleadingly similar answer.
|
||||||
|
|
||||||
|
## What is implemented, and what is not
|
||||||
|
|
||||||
|
The declaration model is complete — it is the design's shape, and validating against it is
|
||||||
|
useful before any of it can be raised. **The runtime is not**, and the gap is refused rather
|
||||||
|
than ignored:
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| segments as isolated links | **works** |
|
||||||
|
| machines, multi-homed or detached | **works** |
|
||||||
|
| declared addresses, both families | **works** |
|
||||||
|
| segment MTU | **works** |
|
||||||
|
| raise · exec · snapshot · restore · destroy · list | **works** |
|
||||||
|
| gateways, NAT, masquerade | **works** |
|
||||||
|
| `published:` ports (DNAT through the gateway's address) | **works** |
|
||||||
|
| `mapping_ttl:` (conntrack timeout) | **works**, and verified after setting — a declared expiry that silently did not apply would be the fault this catches |
|
||||||
|
| `forwardable: false` | **works** — outbound only, no DNAT, unsolicited inbound dropped |
|
||||||
|
| `policy:` between segments | **works**, asymmetric |
|
||||||
|
| `inbound: deny` | **works** — host firewall, read back after applying |
|
||||||
|
| several public networks, routed not bridged | **works** — a transit router, never a shared bridge |
|
||||||
|
| `place: [host]` | **works** — tier 0 is placed and asked what the machine is |
|
||||||
|
| `place:` anything above tier 0 | **refused, by name** — those tiers do not exist yet |
|
||||||
|
|
||||||
|
`raise` refuses a scenario declaring anything in the lower half, naming every gap. It does not
|
||||||
|
raise a mesh that silently lacks what it declared — that is the fault this lab exists to catch
|
||||||
|
(`novox/hq` 04-ISSUES/003: a firewall key declared in five manifests and read by no code, so a
|
||||||
|
manifest appears to restrict a port and restricts nothing).
|
||||||
|
|
||||||
|
`bootstrap-single.yml` places the host. The rest raise an underlay and put nothing on it,
|
||||||
|
which is still correct for what they test.
|
||||||
|
|
||||||
|
Placing needs a built host binary — set `MESH_LAB_HOST_BINARY` to one. It is an explicit path
|
||||||
|
rather than a search on purpose: the declaration design leaves *where `place:` gets its
|
||||||
|
artifacts from* open, and guessing would harden into the answer by accident.
|
||||||
|
|
||||||
|
## Measured on a workstation
|
||||||
|
|
||||||
|
| | one machine | two machines | two machines + a router |
|
||||||
|
|---|---|---|---|
|
||||||
|
| raise, to usable | 12.5 s | 14.6 s | 32 s |
|
||||||
|
| snapshot | 0.14 s | 0.28 s | — |
|
||||||
|
| restore, to usable again | 10.5 s | 11.6 s | — |
|
||||||
|
|
||||||
|
A router adds seconds, not a boot: it is a container, because it is scenery rather than
|
||||||
|
something under test (`novox/hq` ADR 0033).
|
||||||
|
|
||||||
|
**Verified by running**, not asserted — a machine at `192.168.1.135` behind a household
|
||||||
|
gateway, reached from a machine on a routable address:
|
||||||
|
|
||||||
|
```
|
||||||
|
home-server -> anchor 0% loss, through masquerade
|
||||||
|
anchor -> 192.168.1.135 (private, direct) unreachable ✓
|
||||||
|
anchor -> 192.0.2.50:8080 (the GATEWAY) HTTP 200
|
||||||
|
home -> devices (policy allow) reachable ✓
|
||||||
|
devices -> home (policy deny) blocked ✓
|
||||||
|
roamer behind unforwardable NAT -> anchor reachable ✓ (outbound only)
|
||||||
|
anchor -> roamer unreachable ✓
|
||||||
|
workstation with inbound: deny, dialling out reachable ✓ (defended, not disconnected)
|
||||||
|
home-server -> workstation refused ✓
|
||||||
|
```
|
||||||
|
|
||||||
|
The third line is the case research 004 says only exists in production.
|
||||||
|
|
||||||
|
**Routed, never bridged**, proven rather than asserted — ping TTL across the full topology:
|
||||||
|
|
||||||
|
```
|
||||||
|
within one segment ttl=64 no hops
|
||||||
|
across two unrelated public networks ttl=62 gateway + transit
|
||||||
|
multicast between public networks 0 replies
|
||||||
|
```
|
||||||
|
|
||||||
|
A flat "internet" would have shown ttl=64 and answered multicast, which would have let a node
|
||||||
|
discover a peer it could never reach in production — and report success.
|
||||||
|
|
||||||
|
Machines boot concurrently, so a second machine costs seconds rather than doubling the wait.
|
||||||
|
Nearly all of the remaining time is boot, which cannot be avoided.
|
||||||
|
|
||||||
|
These numbers depend entirely on a copy-on-write pool. On `dir` the same snapshot takes 9.9 s
|
||||||
|
and a full copy of the disk, and a second one did not finish in two minutes — which is why
|
||||||
|
`check` refuses rather than warns.
|
||||||
|
|
||||||
|
## Drawing one
|
||||||
|
|
||||||
|
```
|
||||||
|
mesh-lab diagram scenarios/the-ordinary-shape.yml what the declaration asks for
|
||||||
|
mesh-lab diagram --live <instance> what the hypervisor actually holds
|
||||||
|
```
|
||||||
|
|
||||||
|
Both produce draw.io files, laid out the same way — public networks at the top, each private
|
||||||
|
one below the network it sits behind. Drawing both sources through one layout is the point: a
|
||||||
|
difference between what was asked for and what exists becomes a difference you can *see*.
|
||||||
|
|
||||||
|
Two kinds of symbol, and the split matters:
|
||||||
|
|
||||||
|
- the **shape** says what a resource is, and is fixed per kind — a server is always the server
|
||||||
|
shape, a gateway always the router shape, whatever else is true about it;
|
||||||
|
- the **badges** say what is true about that particular one, and come entirely from metadata:
|
||||||
|
`N` translated, `F` forwarding (green yes, red no), `T` mappings expire, `D` refuses inbound,
|
||||||
|
`C` container, `VM` virtual machine, `▶` running. Each carries the full sentence as a
|
||||||
|
tooltip, because a one-letter code with no explanation is a private language.
|
||||||
|
|
||||||
|
Badges exist because the interesting properties of a network are exactly the ones with no
|
||||||
|
visual consequence. An address that is translated looks identical to one that is not, until
|
||||||
|
traffic proves otherwise.
|
||||||
|
|
||||||
|
The live drawing reads **only** the hypervisor — the same tags `destroy` uses — and never
|
||||||
|
re-opens the scenario file. A picture built from the declaration and labelled *as raised*
|
||||||
|
would report the request as though it were the result, which is the whole failure the pairing
|
||||||
|
exists to expose. So `raise` records what it applied: a segment's kind and ranges on the link,
|
||||||
|
a gateway's translation, forwardability and mapping expiry on the gateway, and `inbound: deny`
|
||||||
|
on the machine.
|
||||||
|
|
||||||
|
**Every behavioural tag is written after the thing works, never before.** A tag written when
|
||||||
|
the resource is created would restate the request; a failed raise leaves its wreckage standing
|
||||||
|
on purpose, so a picture of that wreckage would badge translation the gateway was never
|
||||||
|
configured to do. The gateway is tagged after its ruleset is applied, and the machine after
|
||||||
|
the read-back proves its firewall loaded.
|
||||||
|
|
||||||
|
That pairing has already earned itself. Drawn side by side, the live picture showed every
|
||||||
|
virtual machine holding no addresses at all: a container's interface carries the device's
|
||||||
|
name, a virtual machine names its own, and joining them by name silently dropped one whole
|
||||||
|
class of machine. The two pictures disagreed, so the bug was visible in seconds.
|
||||||
|
|
||||||
## Where the reasoning lives
|
## Where the reasoning lives
|
||||||
|
|
||||||
Design and decisions are in [`novox/hq`](https://git.novox.be/novox/hq), not here:
|
Design and decisions are in [`novox/hq`](https://git.novox.be/novox/hq), not here:
|
||||||
|
|
||||||
- `03-DESIGN/01-to-be/01-end-to-end-testing.md` — the design
|
- `03-DESIGN/01-to-be/02-scenario-declaration.md` — what a scenario declares
|
||||||
- `02-DECISIONS/0016-a-lab-node-is-a-virtual-machine.md` — a lab node is a virtual machine
|
- `03-DESIGN/01-to-be/03-scenario-lifecycle.md` — what happens to one
|
||||||
- `02-DECISIONS/0029-the-labs-first-scenario-has-no-pipeline.md` — two scenario classes, and why this is first
|
- `02-DECISIONS/0031-the-lab-provides-the-underlay.md`
|
||||||
- `02-DECISIONS/0030-the-repository-structure.md` — why this is its own repository
|
- `02-DECISIONS/0032-a-scenario-is-an-isolated-address-space.md`
|
||||||
|
|
||||||
This repository carries implementation. It does not carry decisions.
|
This repository carries implementation. It does not carry decisions.
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
No build step — Node strips the types.
|
||||||
|
|
||||||
|
```
|
||||||
|
npm test the declaration layer and the diagram, offline, 49 tests
|
||||||
|
npm run test:integration real scenarios against a real hypervisor, 14 tests
|
||||||
|
npm run typecheck source and tests both — a test that does not compile is a test
|
||||||
|
that silently never ran
|
||||||
|
npm run check typecheck + both suites — this is the gate
|
||||||
|
```
|
||||||
|
|
||||||
|
**A test names the decision it defends** (`novox/hq` ADR 0034). A decision with no test is one
|
||||||
|
that will quietly stop being true, and nobody learns that from a document:
|
||||||
|
|
||||||
|
| Test | Defends |
|
||||||
|
|---|---|
|
||||||
|
| the lab provides the underlay and nothing of the overlay | ADR 0031 |
|
||||||
|
| the workstation has no route into the scenario | ADR 0032 |
|
||||||
|
| a router is a container while machines are virtual machines | ADR 0033 |
|
||||||
|
| raise waits for *usable*, not for the call to return | the lifecycle design |
|
||||||
|
| snapshots are whole-scenario | the lifecycle design |
|
||||||
|
| a public range that is not documentation space is refused | the declaration design |
|
||||||
|
| a scenario declaring what cannot be materialised is refused | the declaration design |
|
||||||
|
| the live diagram distinguishes scenery from a node | ADR 0033 |
|
||||||
|
| the live diagram draws what exists, never what was asked for | the diagram design |
|
||||||
|
| a picture nobody can open is not a picture | the diagram design |
|
||||||
|
|
||||||
|
**Mocking the hypervisor is forbidden.** A fake would assert that the fake behaves as expected,
|
||||||
|
which is the shape of test this project exists to stop shipping. Integration tests skip with a
|
||||||
|
reason on a machine that cannot raise scenarios, rather than passing green having checked
|
||||||
|
nothing.
|
||||||
|
|
||||||
|
That suite earned itself on its first run: it found that a snapshot of a running machine could
|
||||||
|
miss a file written seconds earlier — not stale, **absent** — because the write was still in
|
||||||
|
the guest's page cache. The design had listed that as an open question. The test answered it,
|
||||||
|
and `snapshot` now flushes first.
|
||||||
|
|||||||
Generated
+68
@@ -0,0 +1,68 @@
|
|||||||
|
{
|
||||||
|
"name": "@novox/mesh-lab",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"lockfileVersion": 3,
|
||||||
|
"requires": true,
|
||||||
|
"packages": {
|
||||||
|
"": {
|
||||||
|
"name": "@novox/mesh-lab",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"dependencies": {
|
||||||
|
"yaml": "^2.6.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"mesh-lab": "dist/cli.js"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"typescript": "^5.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "22.20.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
|
||||||
|
"integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~6.21.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/typescript": {
|
||||||
|
"version": "5.9.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||||
|
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"tsc": "bin/tsc",
|
||||||
|
"tsserver": "bin/tsserver"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.17"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "6.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||||
|
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/yaml": {
|
||||||
|
"version": "2.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
|
||||||
|
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"bin": {
|
||||||
|
"yaml": "bin.mjs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 14.6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/eemeli"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"name": "@novox/mesh-lab",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"bin": {
|
||||||
|
"mesh-lab": "./src/cli.ts"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json",
|
||||||
|
"test": "node --test --experimental-strip-types 'test/*.test.ts'",
|
||||||
|
"test:integration": "node --test --test-concurrency=1 --experimental-strip-types 'test/integration/*.test.ts'",
|
||||||
|
"check": "npm run typecheck && npm test && npm run test:integration"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"yaml": "^2.6.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"typescript": "^5.6.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
# A machine on a routable address, and a machine behind a household connection.
|
||||||
|
#
|
||||||
|
# This is the case that only exists in production today: the second machine is reachable
|
||||||
|
# from the first only through a forwarded port, at the GATEWAY's address, never its own.
|
||||||
|
scenario: behind-nat
|
||||||
|
|
||||||
|
segments:
|
||||||
|
hosting:
|
||||||
|
kind: public
|
||||||
|
cidr: [192.0.2.0/24]
|
||||||
|
|
||||||
|
home:
|
||||||
|
kind: private
|
||||||
|
cidr: [192.168.1.0/24]
|
||||||
|
gateway:
|
||||||
|
to: hosting
|
||||||
|
address: [192.0.2.50] # what the world sees the household as
|
||||||
|
nat: [v4]
|
||||||
|
forwardable: true
|
||||||
|
mapping_ttl: 120s
|
||||||
|
|
||||||
|
machines:
|
||||||
|
anchor:
|
||||||
|
at: { segment: hosting, address: [192.0.2.10] }
|
||||||
|
inbound: allow
|
||||||
|
|
||||||
|
home-server: # a dash in the name, on purpose
|
||||||
|
at: { segment: home, address: [192.168.1.135] }
|
||||||
|
published:
|
||||||
|
- { port: 8080, on: home }
|
||||||
|
inbound: allow
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# The smallest useful scenario: one machine, one public network, nothing else.
|
||||||
|
#
|
||||||
|
# This is the bootstrap class — no forge, no control plane, no pipeline. It exists to
|
||||||
|
# develop the node host, which is why it is the first thing the lab can raise.
|
||||||
|
scenario: bootstrap-single
|
||||||
|
|
||||||
|
segments:
|
||||||
|
hosting:
|
||||||
|
kind: public
|
||||||
|
cidr: [192.0.2.0/24, "2001:db8:a::/48"]
|
||||||
|
|
||||||
|
machines:
|
||||||
|
anchor:
|
||||||
|
at: { segment: hosting, address: [192.0.2.10, "2001:db8:a::10"] }
|
||||||
|
inbound: allow
|
||||||
|
|
||||||
|
place:
|
||||||
|
all: [host]
|
||||||
|
|
||||||
|
# The host is placed. Everything above tier 0 is still refused by name — this lab is built to
|
||||||
|
# develop it. Declaring it anyway would make the scenario unraisable, and correctly so: the
|
||||||
|
# lab refuses declarations it cannot materialise rather than raising a mesh that silently
|
||||||
|
# lacks them.
|
||||||
|
|
||||||
|
snapshot: raised
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# Two things production has and a flat lab cannot show.
|
||||||
|
#
|
||||||
|
# `devices` and `home` sit behind ONE router — identical gateway declarations — with a
|
||||||
|
# policy allowing home→devices and denying the reverse. That is an ordinary segmented
|
||||||
|
# household router, and the asymmetry is the normal case.
|
||||||
|
#
|
||||||
|
# `cafe` sits behind a gateway we do not control. Outbound works; nothing initiates
|
||||||
|
# inward, and nothing can be published there at all.
|
||||||
|
scenario: segmented-and-unforwardable
|
||||||
|
|
||||||
|
segments:
|
||||||
|
hosting:
|
||||||
|
kind: public
|
||||||
|
cidr: [192.0.2.0/24]
|
||||||
|
|
||||||
|
home:
|
||||||
|
kind: private
|
||||||
|
cidr: [192.168.1.0/24]
|
||||||
|
gateway:
|
||||||
|
to: hosting
|
||||||
|
address: [192.0.2.50]
|
||||||
|
nat: [v4]
|
||||||
|
forwardable: true
|
||||||
|
mapping_ttl: 120s
|
||||||
|
|
||||||
|
devices:
|
||||||
|
kind: private
|
||||||
|
cidr: [192.168.30.0/24]
|
||||||
|
gateway:
|
||||||
|
to: hosting
|
||||||
|
address: [192.0.2.50] # identical → the SAME router
|
||||||
|
nat: [v4]
|
||||||
|
forwardable: true
|
||||||
|
mapping_ttl: 120s
|
||||||
|
|
||||||
|
cafe:
|
||||||
|
kind: private
|
||||||
|
cidr: [10.50.0.0/16]
|
||||||
|
gateway:
|
||||||
|
to: hosting
|
||||||
|
address: [192.0.2.80]
|
||||||
|
nat: [v4]
|
||||||
|
forwardable: false # carrier-grade NAT, or simply not ours
|
||||||
|
mapping_ttl: 30s
|
||||||
|
|
||||||
|
policy:
|
||||||
|
- { from: devices, to: home, allow: false }
|
||||||
|
- { from: home, to: devices, allow: true }
|
||||||
|
|
||||||
|
machines:
|
||||||
|
anchor:
|
||||||
|
at: { segment: hosting, address: [192.0.2.10] }
|
||||||
|
inbound: allow
|
||||||
|
|
||||||
|
home-server:
|
||||||
|
at: { segment: home, address: [192.168.1.135] }
|
||||||
|
inbound: allow
|
||||||
|
|
||||||
|
thermostat:
|
||||||
|
at: { segment: devices, address: [192.168.30.20] }
|
||||||
|
inbound: allow
|
||||||
|
|
||||||
|
roamer:
|
||||||
|
at: { segment: cafe, address: [10.50.3.23] }
|
||||||
|
inbound: allow
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
# One machine with a routable address, one publicly named but behind a household
|
||||||
|
# connection, one stationary machine on that network, one that roams.
|
||||||
|
#
|
||||||
|
# Three unrelated public networks, routed to each other and never bridged — putting them
|
||||||
|
# in one prefix would make ARP adjacency, non-decrementing TTL and crossing multicast
|
||||||
|
# true in the lab and false in production.
|
||||||
|
scenario: the-ordinary-shape
|
||||||
|
|
||||||
|
segments:
|
||||||
|
hosting:
|
||||||
|
kind: public
|
||||||
|
cidr: [192.0.2.0/24, "2001:db8:a::/48"]
|
||||||
|
|
||||||
|
isp-home:
|
||||||
|
kind: public
|
||||||
|
cidr: [198.51.100.0/24, "2001:db8:b::/48"]
|
||||||
|
|
||||||
|
isp-mobile:
|
||||||
|
kind: public
|
||||||
|
cidr: [203.0.113.0/24, "2001:db8:c::/48"]
|
||||||
|
|
||||||
|
home:
|
||||||
|
kind: private
|
||||||
|
cidr: [192.168.1.0/24, "2001:db8:b:1::/64"]
|
||||||
|
mtu: 1492
|
||||||
|
gateway:
|
||||||
|
to: isp-home
|
||||||
|
address: [198.51.100.7, "2001:db8:b::7"]
|
||||||
|
nat: [v4]
|
||||||
|
forwardable: true
|
||||||
|
mapping_ttl: 120s
|
||||||
|
|
||||||
|
devices:
|
||||||
|
kind: private
|
||||||
|
cidr: [192.168.30.0/24]
|
||||||
|
gateway:
|
||||||
|
to: isp-home
|
||||||
|
address: [198.51.100.7]
|
||||||
|
nat: [v4]
|
||||||
|
forwardable: true
|
||||||
|
mapping_ttl: 120s
|
||||||
|
|
||||||
|
cafe:
|
||||||
|
kind: private
|
||||||
|
cidr: [10.50.0.0/16]
|
||||||
|
mtu: 1400
|
||||||
|
gateway:
|
||||||
|
to: isp-mobile
|
||||||
|
address: [203.0.113.129]
|
||||||
|
nat: [v4]
|
||||||
|
forwardable: false
|
||||||
|
mapping_ttl: 30s
|
||||||
|
|
||||||
|
policy:
|
||||||
|
- { from: devices, to: home, allow: false }
|
||||||
|
- { from: home, to: devices, allow: true }
|
||||||
|
|
||||||
|
machines:
|
||||||
|
anchor:
|
||||||
|
at: { segment: hosting, address: [192.0.2.10, "2001:db8:a::10"] }
|
||||||
|
inbound: allow
|
||||||
|
|
||||||
|
home-server:
|
||||||
|
at: { segment: home, address: [192.168.1.135, "2001:db8:b:1::135"] }
|
||||||
|
published:
|
||||||
|
- { port: 443, on: home }
|
||||||
|
inbound: allow
|
||||||
|
|
||||||
|
workstation:
|
||||||
|
at: { segment: home, address: [192.168.1.250, "2001:db8:b:1::250"] }
|
||||||
|
inbound: deny
|
||||||
|
|
||||||
|
laptop:
|
||||||
|
at: { segment: home, address: [192.168.1.98, "2001:db8:b:1::98"] }
|
||||||
|
inbound: deny
|
||||||
|
|
||||||
|
# No `place:` yet. The node host it would place does not exist — this lab is being built to
|
||||||
|
# develop it, and the lab refuses declarations it cannot materialise rather than raising a
|
||||||
|
# mesh that silently lacks them.
|
||||||
|
|
||||||
|
snapshot: raised
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Two machines on one public network. The smallest scenario in which reachability is a
|
||||||
|
# question at all — and the first that can be answered from inside.
|
||||||
|
scenario: two-on-a-segment
|
||||||
|
|
||||||
|
segments:
|
||||||
|
hosting:
|
||||||
|
kind: public
|
||||||
|
cidr: [192.0.2.0/24, "2001:db8:a::/48"]
|
||||||
|
|
||||||
|
machines:
|
||||||
|
anchor:
|
||||||
|
at: { segment: hosting, address: [192.0.2.10, "2001:db8:a::10"] }
|
||||||
|
inbound: allow
|
||||||
|
peer:
|
||||||
|
at: { segment: hosting, address: [192.0.2.20, "2001:db8:a::20"] }
|
||||||
|
inbound: allow
|
||||||
|
|
||||||
|
# No `place:` yet. The node host it would place does not exist — this lab is being built to
|
||||||
|
# develop it. Declaring it anyway would make the scenario unraisable, and correctly so: the
|
||||||
|
# lab refuses declarations it cannot materialise rather than raising a mesh that silently
|
||||||
|
# lacks them.
|
||||||
|
|
||||||
|
snapshot: raised
|
||||||
Executable
+209
@@ -0,0 +1,209 @@
|
|||||||
|
#!/usr/bin/env -S node --experimental-strip-types
|
||||||
|
/**
|
||||||
|
* The lab's two callers want different things from the same verbs: a coordinator wants
|
||||||
|
* structured results and clean teardown, a person wants readable output and the failing
|
||||||
|
* scenario left standing. Both use these verbs; the difference is what happens after.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { loadScenario } from "./declaration/parse.ts";
|
||||||
|
import { DeclarationError } from "./declaration/validate.ts";
|
||||||
|
import { isReachable, supportedDrivers, pools } from "./incus/client.ts";
|
||||||
|
import { raise, RaiseError } from "./lifecycle/raise.ts";
|
||||||
|
import { assertSupported, UnsupportedError } from "./lifecycle/supported.ts";
|
||||||
|
import { diagramFromDeclaration } from "./diagram/from-declaration.ts";
|
||||||
|
import { diagramFromLive } from "./diagram/from-live.ts";
|
||||||
|
import { toDrawio } from "./diagram/drawio.ts";
|
||||||
|
import { writeFileSync } from "node:fs";
|
||||||
|
import { destroy, exec, list, restore, snapshot, snapshots } from "./lifecycle/operate.ts";
|
||||||
|
|
||||||
|
const USAGE = `mesh-lab — raise a disposable mesh on one machine
|
||||||
|
|
||||||
|
check verify this machine can run scenarios
|
||||||
|
validate <scenario.yml> parse and check a declaration, raising nothing
|
||||||
|
raise <scenario.yml> materialise it, and wait until the machines are usable
|
||||||
|
list scenario instances currently standing
|
||||||
|
exec <instance> <machine> -- <cmd...>
|
||||||
|
snapshot <instance> <label> capture the whole scenario as one state
|
||||||
|
restore <instance> <label> return the whole scenario to it
|
||||||
|
snapshots <instance>
|
||||||
|
destroy <instance>
|
||||||
|
diagram <scenario.yml> [out.drawio] draw what a scenario asks for
|
||||||
|
diagram --live <instance> [out.drawio] draw what is actually raised
|
||||||
|
|
||||||
|
Set MESH_LAB_INCUS if the daemon needs a different invocation, e.g. "sudo -n incus".
|
||||||
|
`;
|
||||||
|
|
||||||
|
function fail(message: string): never {
|
||||||
|
console.error(message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refuse to run degraded rather than warning. A warning about a slow inner loop is read
|
||||||
|
* once and ignored forever, and the loop stays slow.
|
||||||
|
*/
|
||||||
|
async function check(): Promise<void> {
|
||||||
|
const problems: string[] = [];
|
||||||
|
|
||||||
|
if (!(await isReachable())) {
|
||||||
|
problems.push(
|
||||||
|
"the incus daemon is not reachable as this user. If the group was granted recently, " +
|
||||||
|
"a session that predates it cannot see it — log out and back in, or set MESH_LAB_INCUS.",
|
||||||
|
);
|
||||||
|
console.error(problems.map((p) => ` ✗ ${p}`).join("\n"));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log(" ✓ daemon reachable");
|
||||||
|
|
||||||
|
const drivers = await supportedDrivers();
|
||||||
|
const cowDrivers = drivers.filter((d) => d === "btrfs" || d === "zfs");
|
||||||
|
if (cowDrivers.length === 0) {
|
||||||
|
problems.push(
|
||||||
|
"no copy-on-write storage driver is offered. Snapshots would be full copies — " +
|
||||||
|
"measured at roughly 76x slower, which does not make the lab slow, it makes it unused.",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log(` ✓ copy-on-write driver available (${cowDrivers.join(", ")})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const available = await pools();
|
||||||
|
const cowPool = available.find((p) => p.driver === "btrfs" || p.driver === "zfs");
|
||||||
|
if (!cowPool) {
|
||||||
|
problems.push(
|
||||||
|
`no pool uses a copy-on-write driver (have: ${available.map((p) => `${p.name}/${p.driver}`).join(", ") || "none"}). ` +
|
||||||
|
"A pool that exists and is the slow kind is the failure with no symptom.",
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log(` ✓ copy-on-write pool '${cowPool.name}' (${cowPool.driver})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (problems.length > 0) {
|
||||||
|
console.error(`\n${problems.map((p) => ` ✗ ${p}`).join("\n\n")}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
console.log("\nthis machine can run scenarios");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
|
const [verb, ...rest] = process.argv.slice(2);
|
||||||
|
|
||||||
|
switch (verb) {
|
||||||
|
case "check":
|
||||||
|
return check();
|
||||||
|
|
||||||
|
case "validate": {
|
||||||
|
const path = rest[0] ?? fail("validate needs a scenario file");
|
||||||
|
const scenario = loadScenario(path);
|
||||||
|
console.log(
|
||||||
|
`${scenario.scenario}: ${Object.keys(scenario.segments).length} segments, ` +
|
||||||
|
`${Object.keys(scenario.machines).length} machines — valid`,
|
||||||
|
);
|
||||||
|
// Valid and raisable are different questions, and a scenario can be the first
|
||||||
|
// without being the second.
|
||||||
|
try {
|
||||||
|
assertSupported(scenario);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof UnsupportedError) {
|
||||||
|
console.log(`\nnot yet raisable:\n - ${err.missing.join("\n - ")}`);
|
||||||
|
} else throw err;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "raise": {
|
||||||
|
const path = rest[0] ?? fail("raise needs a scenario file");
|
||||||
|
const scenario = loadScenario(path);
|
||||||
|
const started = Date.now();
|
||||||
|
const raised = await raise(scenario, {
|
||||||
|
onProgress: (m) => console.log(m),
|
||||||
|
...(process.env["MESH_LAB_IMAGE"] ? { image: process.env["MESH_LAB_IMAGE"] } : {}),
|
||||||
|
});
|
||||||
|
const seconds = ((Date.now() - started) / 1000).toFixed(1);
|
||||||
|
console.log(`\nraised ${raised.instanceId} in ${seconds}s — ${raised.machines.length} machines usable`);
|
||||||
|
if (scenario.snapshot) {
|
||||||
|
const took = await snapshot(raised.instanceId, scenario.snapshot);
|
||||||
|
console.log(`snapshot '${scenario.snapshot}' in ${took.toFixed(2)}s`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "list": {
|
||||||
|
const found = await list();
|
||||||
|
if (found.length === 0) return console.log("no scenario instances standing");
|
||||||
|
for (const instance of found) {
|
||||||
|
console.log(`${instance.instanceId}`);
|
||||||
|
for (const m of instance.machines) console.log(` ${m.machine.padEnd(16)} ${m.status}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "exec": {
|
||||||
|
const [instanceId, machine, ...command] = rest;
|
||||||
|
if (!instanceId || !machine || command.length === 0) fail("exec <instance> <machine> <cmd...>");
|
||||||
|
const result = await exec(instanceId, machine, command.filter((c) => c !== "--"));
|
||||||
|
process.stdout.write(result.stdout);
|
||||||
|
process.stderr.write(result.stderr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "snapshot": {
|
||||||
|
const [instanceId, label] = rest;
|
||||||
|
if (!instanceId || !label) fail("snapshot <instance> <label>");
|
||||||
|
const took = await snapshot(instanceId, label);
|
||||||
|
console.log(`snapshot '${label}' in ${took.toFixed(2)}s`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "restore": {
|
||||||
|
const [instanceId, label] = rest;
|
||||||
|
if (!instanceId || !label) fail("restore <instance> <label>");
|
||||||
|
const { restoreSeconds, usableSeconds } = await restore(instanceId, label, 180, (m) =>
|
||||||
|
console.log(m),
|
||||||
|
);
|
||||||
|
console.log(
|
||||||
|
`restored '${label}' in ${restoreSeconds.toFixed(2)}s — usable again after ` +
|
||||||
|
`${usableSeconds.toFixed(1)}s`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "snapshots": {
|
||||||
|
const instanceId = rest[0] ?? fail("snapshots <instance>");
|
||||||
|
const found = await snapshots(instanceId);
|
||||||
|
console.log(found.length ? found.join("\n") : "none");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "diagram": {
|
||||||
|
const live = rest[0] === "--live";
|
||||||
|
const target = (live ? rest[1] : rest[0]) ?? fail("diagram needs a scenario file or --live <instance>");
|
||||||
|
const diagram = live
|
||||||
|
? await diagramFromLive(target)
|
||||||
|
: diagramFromDeclaration(loadScenario(target));
|
||||||
|
const out = (live ? rest[2] : rest[1]) ?? `${diagram.title}.drawio`;
|
||||||
|
writeFileSync(out, toDrawio(diagram));
|
||||||
|
console.log(
|
||||||
|
`${out} — ${diagram.segments.length} segments, ${diagram.machines.length} machines ` +
|
||||||
|
`(${diagram.source})`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
case "destroy": {
|
||||||
|
const instanceId = rest[0] ?? fail("destroy <instance>");
|
||||||
|
const { machines, networks } = await destroy(instanceId);
|
||||||
|
console.log(`destroyed ${instanceId} — ${machines} machines, ${networks} segments`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
console.log(USAGE);
|
||||||
|
process.exit(verb ? 1 : 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err: unknown) => {
|
||||||
|
if (err instanceof DeclarationError || err instanceof RaiseError) fail(err.message);
|
||||||
|
if (err instanceof UnsupportedError) fail(err.message);
|
||||||
|
fail(err instanceof Error ? err.message : String(err));
|
||||||
|
});
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* Address parsing, enough to answer two questions the validator asks: which family is
|
||||||
|
* this, and is it inside that range.
|
||||||
|
*
|
||||||
|
* Written rather than depended on because it is small, and because the one rule it
|
||||||
|
* exists to enforce — public segments use documentation ranges — is the difference
|
||||||
|
* between a lab that reproduces the internet and one that silently never forms a mesh.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type Family = "v4" | "v6";
|
||||||
|
|
||||||
|
export interface Cidr {
|
||||||
|
family: Family;
|
||||||
|
/** Network address, as an integer. */
|
||||||
|
base: bigint;
|
||||||
|
prefix: number;
|
||||||
|
text: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const V4_BITS = 32n;
|
||||||
|
const V6_BITS = 128n;
|
||||||
|
|
||||||
|
export function familyOf(address: string): Family {
|
||||||
|
return address.includes(":") ? "v6" : "v4";
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseV4(text: string): bigint {
|
||||||
|
const parts = text.split(".");
|
||||||
|
if (parts.length !== 4) throw new Error(`not an IPv4 address: ${text}`);
|
||||||
|
let value = 0n;
|
||||||
|
for (const part of parts) {
|
||||||
|
if (!/^\d{1,3}$/.test(part)) throw new Error(`not an IPv4 address: ${text}`);
|
||||||
|
const octet = Number(part);
|
||||||
|
if (octet > 255) throw new Error(`octet out of range in ${text}`);
|
||||||
|
value = (value << 8n) | BigInt(octet);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseV6(text: string): bigint {
|
||||||
|
// Reject the forms this does not implement rather than mis-parsing them. An embedded
|
||||||
|
// IPv4 suffix is legal and rare; getting it wrong silently would be worse than refusing.
|
||||||
|
if (text.includes(".")) throw new Error(`IPv4-in-IPv6 form is not supported: ${text}`);
|
||||||
|
const halves = text.split("::");
|
||||||
|
if (halves.length > 2) throw new Error(`not an IPv6 address: ${text}`);
|
||||||
|
|
||||||
|
const head = halves[0] ? halves[0].split(":").filter(Boolean) : [];
|
||||||
|
const tail = halves.length === 2 && halves[1] ? halves[1].split(":").filter(Boolean) : [];
|
||||||
|
const explicit = head.length + tail.length;
|
||||||
|
if (explicit > 8) throw new Error(`too many groups in ${text}`);
|
||||||
|
if (halves.length === 1 && explicit !== 8) throw new Error(`not an IPv6 address: ${text}`);
|
||||||
|
|
||||||
|
const groups = [...head, ...Array<string>(8 - explicit).fill("0"), ...tail];
|
||||||
|
let value = 0n;
|
||||||
|
for (const group of groups) {
|
||||||
|
if (!/^[0-9a-fA-F]{1,4}$/.test(group)) throw new Error(`not an IPv6 address: ${text}`);
|
||||||
|
value = (value << 16n) | BigInt(parseInt(group, 16));
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseAddress(text: string): { family: Family; value: bigint } {
|
||||||
|
const family = familyOf(text);
|
||||||
|
return { family, value: family === "v4" ? parseV4(text) : parseV6(text) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseCidr(text: string): Cidr {
|
||||||
|
const slash = text.lastIndexOf("/");
|
||||||
|
if (slash === -1) throw new Error(`not a CIDR range (no prefix length): ${text}`);
|
||||||
|
const addressText = text.slice(0, slash);
|
||||||
|
const prefix = Number(text.slice(slash + 1));
|
||||||
|
const { family, value } = parseAddress(addressText);
|
||||||
|
const bits = family === "v4" ? V4_BITS : V6_BITS;
|
||||||
|
if (!Number.isInteger(prefix) || prefix < 0 || BigInt(prefix) > bits) {
|
||||||
|
throw new Error(`prefix length out of range for ${family}: ${text}`);
|
||||||
|
}
|
||||||
|
const hostBits = bits - BigInt(prefix);
|
||||||
|
const base = (value >> hostBits) << hostBits;
|
||||||
|
return { family, base, prefix, text };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function contains(range: Cidr, address: string): boolean {
|
||||||
|
const { family, value } = parseAddress(address);
|
||||||
|
if (family !== range.family) return false;
|
||||||
|
const bits = family === "v4" ? V4_BITS : V6_BITS;
|
||||||
|
const hostBits = bits - BigInt(range.prefix);
|
||||||
|
return ((value >> hostBits) << hostBits) === range.base;
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
/** YAML in, a validated Scenario out. Normalises the shorthands the design's examples use. */
|
||||||
|
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { parse as parseYaml } from "yaml";
|
||||||
|
import type { Attachment, Machine, Scenario, Segment } from "./types.ts";
|
||||||
|
import { validate } from "./validate.ts";
|
||||||
|
|
||||||
|
/** `address: "1.2.3.4"` and `address: [...]` both mean a list. */
|
||||||
|
function toList(value: unknown): string[] {
|
||||||
|
if (value === undefined || value === null) return [];
|
||||||
|
return Array.isArray(value) ? value.map(String) : [String(value)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normaliseAttachment(raw: unknown): Attachment {
|
||||||
|
const at = (raw ?? {}) as Record<string, unknown>;
|
||||||
|
return { segment: String(at["segment"] ?? ""), address: toList(at["address"]) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function normaliseMachine(raw: unknown): Machine {
|
||||||
|
const machine = (raw ?? {}) as Record<string, unknown>;
|
||||||
|
const at = machine["at"];
|
||||||
|
|
||||||
|
const attachment: Machine["at"] =
|
||||||
|
at === "detached"
|
||||||
|
? "detached"
|
||||||
|
: Array.isArray(at)
|
||||||
|
? at.map(normaliseAttachment)
|
||||||
|
: [normaliseAttachment(at)];
|
||||||
|
|
||||||
|
const result: Machine = { at: attachment };
|
||||||
|
const published = machine["published"];
|
||||||
|
if (Array.isArray(published)) {
|
||||||
|
result.published = published.map((entry) => {
|
||||||
|
const p = (entry ?? {}) as Record<string, unknown>;
|
||||||
|
return { port: Number(p["port"]), on: String(p["on"] ?? "") };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const inbound = machine["inbound"];
|
||||||
|
if (inbound === "allow" || inbound === "deny") result.inbound = inbound;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normaliseSegment(raw: unknown): Segment {
|
||||||
|
const segment = (raw ?? {}) as Record<string, unknown>;
|
||||||
|
const result: Segment = {
|
||||||
|
kind: segment["kind"] === "public" ? "public" : "private",
|
||||||
|
cidr: toList(segment["cidr"]),
|
||||||
|
};
|
||||||
|
if (segment["mtu"] !== undefined) result.mtu = Number(segment["mtu"]);
|
||||||
|
|
||||||
|
const gateway = segment["gateway"] as Record<string, unknown> | undefined;
|
||||||
|
if (gateway) {
|
||||||
|
const nat = toList(gateway["nat"]).filter((f): f is "v4" | "v6" => f === "v4" || f === "v6");
|
||||||
|
result.gateway = {
|
||||||
|
to: String(gateway["to"] ?? ""),
|
||||||
|
address: toList(gateway["address"]),
|
||||||
|
nat,
|
||||||
|
// Absent means forwardable: a gateway you control is the ordinary case, and the
|
||||||
|
// interesting one — carrier-grade NAT — should have to be stated.
|
||||||
|
forwardable: gateway["forwardable"] !== false,
|
||||||
|
};
|
||||||
|
const ttl = gateway["mapping_ttl"] ?? gateway["mappingTtl"];
|
||||||
|
if (ttl !== undefined) result.gateway.mappingTtl = String(ttl);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseScenario(text: string): Scenario {
|
||||||
|
const raw = (parseYaml(text) ?? {}) as Record<string, unknown>;
|
||||||
|
|
||||||
|
const segments: Record<string, Segment> = {};
|
||||||
|
for (const [name, value] of Object.entries(
|
||||||
|
(raw["segments"] ?? {}) as Record<string, unknown>,
|
||||||
|
)) {
|
||||||
|
segments[name] = normaliseSegment(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const machines: Record<string, Machine> = {};
|
||||||
|
for (const [name, value] of Object.entries(
|
||||||
|
(raw["machines"] ?? {}) as Record<string, unknown>,
|
||||||
|
)) {
|
||||||
|
machines[name] = normaliseMachine(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const scenario: Scenario = {
|
||||||
|
scenario: String(raw["scenario"] ?? ""),
|
||||||
|
segments,
|
||||||
|
machines,
|
||||||
|
};
|
||||||
|
|
||||||
|
const policy = raw["policy"];
|
||||||
|
if (Array.isArray(policy)) {
|
||||||
|
scenario.policy = policy.map((entry) => {
|
||||||
|
const p = (entry ?? {}) as Record<string, unknown>;
|
||||||
|
return { from: String(p["from"] ?? ""), to: String(p["to"] ?? ""), allow: p["allow"] !== false };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const place = raw["place"];
|
||||||
|
if (place && typeof place === "object") {
|
||||||
|
const normalised: Record<string, string[]> = {};
|
||||||
|
for (const [key, value] of Object.entries(place as Record<string, unknown>)) {
|
||||||
|
normalised[key] = toList(value);
|
||||||
|
}
|
||||||
|
scenario.place = normalised;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw["snapshot"] !== undefined) scenario.snapshot = String(raw["snapshot"]);
|
||||||
|
|
||||||
|
validate(scenario);
|
||||||
|
return scenario;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadScenario(path: string): Scenario {
|
||||||
|
return parseScenario(readFileSync(path, "utf-8"));
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
/**
|
||||||
|
* A scenario declares an UNDERLAY and what to place on it — the facts a machine would
|
||||||
|
* have before any of our software touched it. It declares nothing the mesh is
|
||||||
|
* responsible for: no overlay addresses, no hub, no peering, no names, no certificates.
|
||||||
|
* Those are outcomes to observe, and a scenario that supplied them would be certifying
|
||||||
|
* its own work.
|
||||||
|
*
|
||||||
|
* See novox/hq: 02-DECISIONS/0031-the-lab-provides-the-underlay.md
|
||||||
|
* 03-DESIGN/01-to-be/02-scenario-declaration.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** An IP family. Reachability is a property of (machine, family), never of a machine. */
|
||||||
|
export type Family = "v4" | "v6";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How a segment reaches its parent.
|
||||||
|
*
|
||||||
|
* `address` is the address the outside world sees the network as — for a household
|
||||||
|
* connection, what the ISP hands out. It is load-bearing rather than decorative: it is
|
||||||
|
* what a peer records as an endpoint when a machine here dials out, and what a public
|
||||||
|
* name for a published machine here resolves to.
|
||||||
|
*/
|
||||||
|
export interface Gateway {
|
||||||
|
/** Parent segment name. */
|
||||||
|
to: string;
|
||||||
|
/** Addresses the gateway holds on the parent segment, one per family. */
|
||||||
|
address: string[];
|
||||||
|
/**
|
||||||
|
* Which families are translated. `["v4"]` is the modern default — v4 translated, v6
|
||||||
|
* routed. `[]` is a routed range where machines keep their own addresses.
|
||||||
|
*/
|
||||||
|
nat: Family[];
|
||||||
|
/**
|
||||||
|
* Whether an inbound mapping can be created. Independent of `nat`, and the field that
|
||||||
|
* separates a home gateway from carrier-grade NAT — which is your own connection and
|
||||||
|
* still unforwardable.
|
||||||
|
*/
|
||||||
|
forwardable: boolean;
|
||||||
|
/**
|
||||||
|
* How long an unused inbound mapping survives, e.g. "120s". Absent means mappings never
|
||||||
|
* expire, which no real gateway does — so absence is a simplification, not a default.
|
||||||
|
*/
|
||||||
|
mappingTtl?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A broadcast domain. Several public segments are unrelated and routed, never bridged. */
|
||||||
|
export interface Segment {
|
||||||
|
/**
|
||||||
|
* `public` stands in for a public network — and there is normally more than one,
|
||||||
|
* unrelated to each other. `private` is everything else; a private segment with no
|
||||||
|
* gateway is an island that reaches nothing.
|
||||||
|
*/
|
||||||
|
kind: "public" | "private";
|
||||||
|
/** Address ranges, one per family. */
|
||||||
|
cidr: string[];
|
||||||
|
/** Largest packet the segment carries. Default 1500. Lower reproduces tunnelled paths. */
|
||||||
|
mtu?: number;
|
||||||
|
gateway?: Gateway;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Where a machine sits: a segment and the addresses it holds there. */
|
||||||
|
export interface Attachment {
|
||||||
|
segment: string;
|
||||||
|
address: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A destination-NAT rule on a named gateway, stated as an outcome rather than a port list. */
|
||||||
|
export interface Publication {
|
||||||
|
port: number;
|
||||||
|
/** The segment whose gateway forwards. Named, because a machine may sit behind several. */
|
||||||
|
on: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Machine {
|
||||||
|
/**
|
||||||
|
* One attachment, or several for a machine on multiple segments at once. Multi-homing
|
||||||
|
* is not exotic: it is what any node with both a LAN and a WAN interface is.
|
||||||
|
* `"detached"` is a machine on no segment — it exists and reaches nothing.
|
||||||
|
*/
|
||||||
|
at: Attachment[] | "detached";
|
||||||
|
published?: Publication[];
|
||||||
|
/**
|
||||||
|
* A host firewall. Distinct from NAT and behaves differently: a machine can be perfectly
|
||||||
|
* routable and still refuse everything unsolicited, which is the normal state of a
|
||||||
|
* v6-addressed machine. Without this, v6 addressing would imply reachability.
|
||||||
|
*/
|
||||||
|
inbound?: "allow" | "deny";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reachability between segments, as a segmented router enforces it. Asymmetric by design. */
|
||||||
|
export interface Policy {
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
allow: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What goes inside the machines. The ONLY part that differs between scenario classes. */
|
||||||
|
export interface Placement {
|
||||||
|
/** Applied to every machine. */
|
||||||
|
all?: string[];
|
||||||
|
/** Per-machine, overriding `all` for that machine. */
|
||||||
|
[machine: string]: string[] | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Scenario {
|
||||||
|
/** The kind. Instances are many; this names the shape, not one of them. */
|
||||||
|
scenario: string;
|
||||||
|
segments: Record<string, Segment>;
|
||||||
|
machines: Record<string, Machine>;
|
||||||
|
policy?: Policy[];
|
||||||
|
place?: Placement;
|
||||||
|
/** Name the state once placement finishes, so a run can return to it. */
|
||||||
|
snapshot?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
/**
|
||||||
|
* Every rule the design states about a scenario, checked before anything is raised.
|
||||||
|
*
|
||||||
|
* The reason validation is this strict is that the failures it prevents are silent. A
|
||||||
|
* private range on a segment meant to be public does not produce an error — the mesh
|
||||||
|
* simply never forms, because its own code decides public-versus-private by matching the
|
||||||
|
* address. Publishing through a gateway that cannot forward does not produce an error
|
||||||
|
* either; it produces a machine that looks reachable and is not.
|
||||||
|
*
|
||||||
|
* So: refuse the declaration, loudly, before spending a minute raising something that
|
||||||
|
* would have taught us the wrong thing.
|
||||||
|
*
|
||||||
|
* See novox/hq 03-DESIGN/01-to-be/02-scenario-declaration.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Scenario, Segment } from "./types.ts";
|
||||||
|
import { contains, familyOf, parseAddress, parseCidr, type Cidr } from "./net.ts";
|
||||||
|
|
||||||
|
/** RFC 5737 and RFC 3849. The only addresses guaranteed never to route on the real internet. */
|
||||||
|
const DOCUMENTATION_RANGES = [
|
||||||
|
"192.0.2.0/24",
|
||||||
|
"198.51.100.0/24",
|
||||||
|
"203.0.113.0/24",
|
||||||
|
"2001:db8::/32",
|
||||||
|
].map(parseCidr);
|
||||||
|
|
||||||
|
export class DeclarationError extends Error {
|
||||||
|
readonly problems: string[];
|
||||||
|
|
||||||
|
constructor(problems: string[]) {
|
||||||
|
super(`scenario declaration is not valid:\n - ${problems.join("\n - ")}`);
|
||||||
|
this.name = "DeclarationError";
|
||||||
|
this.problems = problems;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a range sits inside documentation space. Compared as ranges rather than as a
|
||||||
|
* sample address so that a range wider than the reserved block — `203.0.0.0/8`, say — is
|
||||||
|
* correctly refused instead of passing because its first address happens to fall inside.
|
||||||
|
*/
|
||||||
|
function isDocumentationRange(range: Cidr): boolean {
|
||||||
|
return DOCUMENTATION_RANGES.some(
|
||||||
|
(allowed) =>
|
||||||
|
allowed.family === range.family &&
|
||||||
|
range.prefix >= allowed.prefix &&
|
||||||
|
containsRange(allowed, range),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Is `inner` entirely within `outer`? Both already parsed, so no address parsing can throw. */
|
||||||
|
function containsRange(outer: Cidr, inner: Cidr): boolean {
|
||||||
|
const bits = outer.family === "v4" ? 32n : 128n;
|
||||||
|
const hostBits = bits - BigInt(outer.prefix);
|
||||||
|
return ((inner.base >> hostBits) << hostBits) === outer.base;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ranges of a segment, parsed once, with malformed entries reported rather than thrown. */
|
||||||
|
function rangesOf(name: string, segment: Segment, problems: string[]): Cidr[] {
|
||||||
|
const ranges: Cidr[] = [];
|
||||||
|
for (const text of segment.cidr) {
|
||||||
|
try {
|
||||||
|
ranges.push(parseCidr(text));
|
||||||
|
} catch (err) {
|
||||||
|
problems.push(`segment '${name}': ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ranges;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inAnyRange(ranges: Cidr[], address: string): boolean {
|
||||||
|
return ranges.some((range) => contains(range, address));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validate(scenario: Scenario): void {
|
||||||
|
const problems: string[] = [];
|
||||||
|
const segmentNames = Object.keys(scenario.segments);
|
||||||
|
|
||||||
|
if (!scenario.scenario) problems.push("scenario has no name");
|
||||||
|
if (segmentNames.length === 0) problems.push("scenario declares no segments");
|
||||||
|
if (Object.keys(scenario.machines).length === 0) {
|
||||||
|
problems.push("scenario declares no machines");
|
||||||
|
}
|
||||||
|
|
||||||
|
const ranges = new Map<string, Cidr[]>();
|
||||||
|
for (const [name, segment] of Object.entries(scenario.segments)) {
|
||||||
|
ranges.set(name, rangesOf(name, segment, problems));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, segment] of Object.entries(scenario.segments)) {
|
||||||
|
// A public segment stands in for the internet. Anything that is not documentation
|
||||||
|
// space could route somewhere real, and — far more likely — a private range here
|
||||||
|
// makes the mesh's own public-versus-private test fail silently.
|
||||||
|
if (segment.kind === "public") {
|
||||||
|
// Only ranges that parsed — a malformed one is already reported, and re-parsing it
|
||||||
|
// here would throw out of validation with a single cryptic message instead of the
|
||||||
|
// full list.
|
||||||
|
for (const range of ranges.get(name) ?? []) {
|
||||||
|
if (!isDocumentationRange(range)) {
|
||||||
|
problems.push(
|
||||||
|
`segment '${name}' is public but '${range.text}' is not documentation space ` +
|
||||||
|
`(RFC 5737 / RFC 3849). A non-documentation range here either routes somewhere ` +
|
||||||
|
`real, or — if private — makes the mesh silently never form.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (segment.mtu !== undefined && (segment.mtu < 576 || segment.mtu > 9000)) {
|
||||||
|
problems.push(`segment '${name}': mtu ${segment.mtu} is outside any plausible range`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const gateway = segment.gateway;
|
||||||
|
if (!gateway) continue;
|
||||||
|
|
||||||
|
if (!segmentNames.includes(gateway.to)) {
|
||||||
|
problems.push(`segment '${name}': gateway points at unknown segment '${gateway.to}'`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (gateway.to === name) {
|
||||||
|
problems.push(`segment '${name}': gateway points at itself`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The gateway's address is what the world sees this network as, so it belongs to the
|
||||||
|
// PARENT segment, not this one. Getting this backwards is easy and produces a topology
|
||||||
|
// that raises fine and reproduces nothing.
|
||||||
|
const parentRanges = ranges.get(gateway.to) ?? [];
|
||||||
|
for (const address of gateway.address) {
|
||||||
|
try {
|
||||||
|
parseAddress(address);
|
||||||
|
} catch (err) {
|
||||||
|
problems.push(`segment '${name}' gateway: ${(err as Error).message}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (parentRanges.length > 0 && !inAnyRange(parentRanges, address)) {
|
||||||
|
problems.push(
|
||||||
|
`segment '${name}' gateway: address '${address}' is not within '${gateway.to}' ` +
|
||||||
|
`(${scenario.segments[gateway.to]?.cidr.join(", ")}). A gateway's address is the ` +
|
||||||
|
`one the parent network sees, not one on the segment behind it.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const family of gateway.nat) {
|
||||||
|
if (family !== "v4" && family !== "v6") {
|
||||||
|
problems.push(`segment '${name}' gateway: '${family}' is not an address family`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two gateways sharing an address are the same box, and one box cannot behave two ways.
|
||||||
|
// Left unchecked this raised two routers holding one address on one segment, where the
|
||||||
|
// address resolved to whichever answered ARP last — so a published port worked or did
|
||||||
|
// not, run to run, with nothing reporting a fault.
|
||||||
|
const gateways = Object.entries(scenario.segments)
|
||||||
|
.filter(([, segment]) => segment.gateway)
|
||||||
|
.map(([name, segment]) => ({ name, gateway: segment.gateway! }));
|
||||||
|
|
||||||
|
for (let i = 0; i < gateways.length; i++) {
|
||||||
|
for (let j = i + 1; j < gateways.length; j++) {
|
||||||
|
const a = gateways[i]!;
|
||||||
|
const b = gateways[j]!;
|
||||||
|
if (a.gateway.to !== b.gateway.to) continue;
|
||||||
|
const shared = a.gateway.address.filter((address) => b.gateway.address.includes(address));
|
||||||
|
if (shared.length === 0) continue;
|
||||||
|
|
||||||
|
const differences: string[] = [];
|
||||||
|
if ([...a.gateway.nat].sort().join(",") !== [...b.gateway.nat].sort().join(",")) {
|
||||||
|
differences.push(`nat (${a.gateway.nat.join("+") || "none"} vs ${b.gateway.nat.join("+") || "none"})`);
|
||||||
|
}
|
||||||
|
if (a.gateway.forwardable !== b.gateway.forwardable) {
|
||||||
|
differences.push(`forwardable (${a.gateway.forwardable} vs ${b.gateway.forwardable})`);
|
||||||
|
}
|
||||||
|
if (a.gateway.mappingTtl !== b.gateway.mappingTtl) {
|
||||||
|
differences.push(`mapping_ttl (${a.gateway.mappingTtl ?? "none"} vs ${b.gateway.mappingTtl ?? "none"})`);
|
||||||
|
}
|
||||||
|
if (differences.length > 0) {
|
||||||
|
problems.push(
|
||||||
|
`segments '${a.name}' and '${b.name}' declare gateways on '${a.gateway.to}' sharing ` +
|
||||||
|
`address '${shared[0]}', so they are one gateway — but they disagree on ` +
|
||||||
|
`${differences.join(" and ")}. One box cannot behave two ways.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A gateway chain must terminate. A cycle would raise forever rather than fail.
|
||||||
|
for (const name of segmentNames) {
|
||||||
|
const seen = new Set<string>([name]);
|
||||||
|
let current = scenario.segments[name]?.gateway?.to;
|
||||||
|
while (current) {
|
||||||
|
if (seen.has(current)) {
|
||||||
|
problems.push(`segment '${name}': gateway chain loops through '${current}'`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
seen.add(current);
|
||||||
|
current = scenario.segments[current]?.gateway?.to;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, machine] of Object.entries(scenario.machines)) {
|
||||||
|
if (machine.at === "detached") {
|
||||||
|
if (machine.published?.length) {
|
||||||
|
problems.push(`machine '${name}' is detached but declares published ports`);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(machine.at) || machine.at.length === 0) {
|
||||||
|
problems.push(`machine '${name}': 'at' must be an attachment, a list of them, or "detached"`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachedTo = new Set<string>();
|
||||||
|
for (const attachment of machine.at) {
|
||||||
|
if (!segmentNames.includes(attachment.segment)) {
|
||||||
|
problems.push(`machine '${name}': unknown segment '${attachment.segment}'`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (attachedTo.has(attachment.segment)) {
|
||||||
|
problems.push(`machine '${name}': attached to '${attachment.segment}' more than once`);
|
||||||
|
}
|
||||||
|
attachedTo.add(attachment.segment);
|
||||||
|
|
||||||
|
const segmentRanges = ranges.get(attachment.segment) ?? [];
|
||||||
|
const seenFamilies = new Set<string>();
|
||||||
|
for (const address of attachment.address) {
|
||||||
|
try {
|
||||||
|
parseAddress(address);
|
||||||
|
} catch (err) {
|
||||||
|
problems.push(`machine '${name}': ${(err as Error).message}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const family = familyOf(address);
|
||||||
|
if (seenFamilies.has(family)) {
|
||||||
|
problems.push(`machine '${name}': two ${family} addresses on '${attachment.segment}'`);
|
||||||
|
}
|
||||||
|
seenFamilies.add(family);
|
||||||
|
|
||||||
|
if (segmentRanges.length > 0 && !inAnyRange(segmentRanges, address)) {
|
||||||
|
problems.push(
|
||||||
|
`machine '${name}': address '${address}' is not within segment ` +
|
||||||
|
`'${attachment.segment}' (${scenario.segments[attachment.segment]?.cidr.join(", ")})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const publication of machine.published ?? []) {
|
||||||
|
if (!Number.isInteger(publication.port) || publication.port < 1 || publication.port > 65535) {
|
||||||
|
problems.push(`machine '${name}': port ${publication.port} is not a port`);
|
||||||
|
}
|
||||||
|
const via = scenario.segments[publication.on];
|
||||||
|
if (!via) {
|
||||||
|
problems.push(`machine '${name}': publishes on unknown segment '${publication.on}'`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!attachedTo.has(publication.on)) {
|
||||||
|
problems.push(
|
||||||
|
`machine '${name}': publishes on '${publication.on}' but is not attached to it`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!via.gateway) {
|
||||||
|
problems.push(
|
||||||
|
`machine '${name}': publishes on '${publication.on}', which has no gateway to ` +
|
||||||
|
`forward through`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// The constraint being reproduced, not an implementation limit: a machine behind a
|
||||||
|
// gateway it does not control cannot be published, and pretending otherwise would
|
||||||
|
// make the lab certify something production cannot do.
|
||||||
|
if (!via.gateway.forwardable) {
|
||||||
|
problems.push(
|
||||||
|
`machine '${name}': cannot publish through '${publication.on}' — its gateway is ` +
|
||||||
|
`not forwardable. That is the constraint being reproduced, not a limitation.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const rule of scenario.policy ?? []) {
|
||||||
|
for (const side of [rule.from, rule.to]) {
|
||||||
|
if (!segmentNames.includes(side)) {
|
||||||
|
problems.push(`policy: unknown segment '${side}'`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (rule.from === rule.to) {
|
||||||
|
problems.push(`policy: '${rule.from}' to itself is not a rule`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const machine of Object.keys(scenario.place ?? {})) {
|
||||||
|
if (machine === "all") continue;
|
||||||
|
if (!(machine in scenario.machines)) {
|
||||||
|
problems.push(`place: '${machine}' is not a machine in this scenario`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (problems.length > 0) throw new DeclarationError(problems);
|
||||||
|
}
|
||||||
@@ -0,0 +1,455 @@
|
|||||||
|
/**
|
||||||
|
* Render a diagram as draw.io XML.
|
||||||
|
*
|
||||||
|
* draw.io rather than a rendered image because the output is **editable**: an automatic
|
||||||
|
* layout of a network is usually 90% right and needs a human nudge, and a picture nobody
|
||||||
|
* can adjust gets regenerated rather than corrected.
|
||||||
|
*
|
||||||
|
* Laid out as lanes rather than by a generic graph algorithm. A network topology has a
|
||||||
|
* natural vertical order — public at the top, each private network below the one it sits
|
||||||
|
* behind — and a force-directed layout throws that away, producing a picture that is
|
||||||
|
* correct and unreadable.
|
||||||
|
*
|
||||||
|
* Two kinds of symbol, on purpose:
|
||||||
|
*
|
||||||
|
* - the **shape** says what a resource is, and is fixed per kind — a server is always the
|
||||||
|
* server shape, a gateway always the router shape;
|
||||||
|
* - the **badges** say what is true about that particular one, and come entirely from
|
||||||
|
* metadata: translation, forwardability, mapping expiry, whether it refuses inbound.
|
||||||
|
*
|
||||||
|
* Which matters because the interesting properties of a network are exactly the ones with
|
||||||
|
* no visual consequence. An address that is translated looks identical to one that is not.
|
||||||
|
*
|
||||||
|
* Shapes come from draw.io's bundled network library, so the file needs no external images
|
||||||
|
* and renders anywhere draw.io opens. Badges use core mxGraph primitives rather than icon
|
||||||
|
* shapes: a stencil name that turns out not to exist renders as an empty box, and a badge
|
||||||
|
* that silently disappears is worse than a plain one that does not.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Diagram, DiagramMachine, DiagramSegment } from "./model.ts";
|
||||||
|
|
||||||
|
const LANE_MIN_HEIGHT = 170;
|
||||||
|
const LANE_EMPTY_HEIGHT = 62;
|
||||||
|
/** Depth becomes indentation, which is how "behind" is shown without drawing a line. */
|
||||||
|
const INDENT = 40;
|
||||||
|
/** A gap holding nothing needs no room; one between top-level groups needs a little. */
|
||||||
|
const TIGHT_GAP = 24;
|
||||||
|
const GROUP_GAP = 50;
|
||||||
|
// Wide enough that a gateway placed on a boundary sits BETWEEN the lanes rather than on
|
||||||
|
// top of one — an earlier value let a router's box cover the lane's own name and ranges.
|
||||||
|
const LANE_GAP = 110;
|
||||||
|
const LANE_X = 40;
|
||||||
|
const LANE_WIDTH = 980;
|
||||||
|
const NODE_WIDTH = 150;
|
||||||
|
const NODE_HEIGHT = 60;
|
||||||
|
const SLOT_WIDTH = NODE_WIDTH + 60;
|
||||||
|
const SLOTS_PER_ROW = Math.max(1, Math.floor((LANE_WIDTH - 60) / SLOT_WIDTH));
|
||||||
|
const BADGE = 18;
|
||||||
|
|
||||||
|
const STYLE = {
|
||||||
|
publicLane:
|
||||||
|
"rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;dashed=1;" +
|
||||||
|
"verticalAlign=top;align=left;spacingLeft=10;spacingTop=4;fontSize=11;fontStyle=1",
|
||||||
|
privateLane:
|
||||||
|
"rounded=1;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#999999;dashed=1;" +
|
||||||
|
"verticalAlign=top;align=left;spacingLeft=10;spacingTop=4;fontSize=11;fontStyle=1",
|
||||||
|
machine:
|
||||||
|
"sketch=0;html=1;verticalLabelPosition=bottom;verticalAlign=top;align=center;" +
|
||||||
|
"shape=mxgraph.networks.server;fillColor=#ffffff;strokeColor=#333333;fontSize=10",
|
||||||
|
router:
|
||||||
|
"sketch=0;html=1;verticalLabelPosition=bottom;verticalAlign=top;align=center;" +
|
||||||
|
"shape=mxgraph.networks.router;fillColor=#fff2cc;strokeColor=#d6b656;fontSize=10",
|
||||||
|
transit:
|
||||||
|
"sketch=0;html=1;verticalLabelPosition=bottom;verticalAlign=top;align=center;" +
|
||||||
|
"shape=mxgraph.networks.cloud;fillColor=#d5e8d4;strokeColor=#82b366;fontSize=10",
|
||||||
|
link: "edgeStyle=orthogonalEdgeStyle;rounded=0;html=1;endArrow=none;strokeColor=#666666",
|
||||||
|
note: "text;html=1;align=left;verticalAlign=top;fontSize=9;fontColor=#666666",
|
||||||
|
badge:
|
||||||
|
"ellipse;whiteSpace=wrap;html=1;fontSize=9;fontStyle=1;fontColor=#ffffff;" +
|
||||||
|
"verticalAlign=middle;align=center;spacing=0",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A metadata fact, rendered as a mark on the resource it is a fact about. */
|
||||||
|
interface Badge {
|
||||||
|
code: string;
|
||||||
|
fill: string;
|
||||||
|
stroke: string;
|
||||||
|
/** The full sentence, shown on hover — the code alone would be a private language. */
|
||||||
|
tip: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const COLOUR = {
|
||||||
|
amber: { fill: "#d79b00", stroke: "#b07000" },
|
||||||
|
red: { fill: "#b85450", stroke: "#8c3a37" },
|
||||||
|
green: { fill: "#82b366", stroke: "#5b8047" },
|
||||||
|
blue: { fill: "#6c8ebf", stroke: "#4b6a94" },
|
||||||
|
grey: { fill: "#9e9e9e", stroke: "#757575" },
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn a machine's recorded facts into marks.
|
||||||
|
*
|
||||||
|
* Absence is deliberately not a badge. A gateway that does not translate gets no NAT mark
|
||||||
|
* rather than a struck-through one, because a diagram that badges every negative is a
|
||||||
|
* diagram nobody reads.
|
||||||
|
*/
|
||||||
|
function badgesFor(machine: DiagramMachine): { badges: Badge[]; remaining: string[] } {
|
||||||
|
const badges: Badge[] = [];
|
||||||
|
const remaining: string[] = [];
|
||||||
|
|
||||||
|
if (machine.status) {
|
||||||
|
const running = machine.status.toLowerCase() === "running";
|
||||||
|
badges.push({
|
||||||
|
code: running ? "▶" : "■",
|
||||||
|
...(running ? COLOUR.green : COLOUR.grey),
|
||||||
|
tip: `status: ${machine.status}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const note of machine.notes) {
|
||||||
|
const nat = /^NAT (.+)$/.exec(note);
|
||||||
|
const ttl = /^mappings expire (\d+)s$/.exec(note);
|
||||||
|
if (nat) {
|
||||||
|
badges.push({ code: "N", ...COLOUR.amber, tip: `translates ${nat[1]} — addresses behind it are not seen outside` });
|
||||||
|
} else if (note === "NOT forwardable") {
|
||||||
|
badges.push({ code: "F", ...COLOUR.red, tip: "no port forwarding — nothing behind this gateway is reachable from outside" });
|
||||||
|
} else if (note === "forwardable") {
|
||||||
|
badges.push({ code: "F", ...COLOUR.green, tip: "port forwarding available" });
|
||||||
|
} else if (ttl) {
|
||||||
|
badges.push({ code: "T", ...COLOUR.blue, tip: `mappings expire after ${ttl[1]}s of no traffic` });
|
||||||
|
} else if (note === "refuses inbound") {
|
||||||
|
badges.push({ code: "D", ...COLOUR.red, tip: "refuses inbound connections it did not start" });
|
||||||
|
} else if (note === "container") {
|
||||||
|
badges.push({ code: "C", ...COLOUR.grey, tip: "container — scenery, nothing under test runs here" });
|
||||||
|
} else if (note === "virtual machine") {
|
||||||
|
badges.push({ code: "VM", ...COLOUR.blue, tip: "virtual machine — its own kernel" });
|
||||||
|
} else {
|
||||||
|
remaining.push(note);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { badges, remaining };
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeXml(text: string): string {
|
||||||
|
return text
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shapeFor(machine: DiagramMachine): string {
|
||||||
|
return machine.kind === "router"
|
||||||
|
? STYLE.router
|
||||||
|
: machine.kind === "transit"
|
||||||
|
? STYLE.transit
|
||||||
|
: STYLE.machine;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Cell {
|
||||||
|
id: string;
|
||||||
|
value: string;
|
||||||
|
style: string;
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
w: number;
|
||||||
|
h: number;
|
||||||
|
/** Rendered as a wrapping <object>, which is how draw.io carries a tooltip. */
|
||||||
|
tip?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toDrawio(diagram: Diagram): string {
|
||||||
|
const cells: Cell[] = [];
|
||||||
|
const edges: { id: string; source: string; target: string }[] = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Order: each public network, then everything behind it, depth first.
|
||||||
|
*
|
||||||
|
* A single stack sorted by depth put a private network far from the public one it sits
|
||||||
|
* behind, so a gateway's link to the outside ran the height of the picture and crossed
|
||||||
|
* networks it had nothing to do with — two such links overlapped and read as one wire.
|
||||||
|
* Grouping makes every gateway adjacent to both lanes it joins, and every link short.
|
||||||
|
*/
|
||||||
|
const byName = new Map(diagram.segments.map((s) => [s.name, s]));
|
||||||
|
const childrenOf = new Map<string, string[]>();
|
||||||
|
for (const segment of diagram.segments) {
|
||||||
|
if (!segment.behind) continue;
|
||||||
|
childrenOf.set(segment.behind, [...(childrenOf.get(segment.behind) ?? []), segment.name]);
|
||||||
|
}
|
||||||
|
const lanes: DiagramSegment[] = [];
|
||||||
|
const placed = new Set<string>();
|
||||||
|
const walk = (name: string): void => {
|
||||||
|
const segment = byName.get(name);
|
||||||
|
if (!segment || placed.has(name)) return;
|
||||||
|
placed.add(name);
|
||||||
|
lanes.push(segment);
|
||||||
|
for (const child of [...(childrenOf.get(name) ?? [])].sort()) walk(child);
|
||||||
|
};
|
||||||
|
// By name, not by the order the source happened to yield them. The hypervisor cannot know
|
||||||
|
// declaration order, so ordering by it would give the two pictures different shapes and
|
||||||
|
// make the comparison they exist for impossible to read.
|
||||||
|
const byNameOrder = [...diagram.segments].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
for (const segment of byNameOrder) if (segment.kind === "public") walk(segment.name);
|
||||||
|
for (const segment of byNameOrder) walk(segment.name); // isolated ones
|
||||||
|
|
||||||
|
const laneX = (segment: DiagramSegment) => LANE_X + segment.depth * INDENT;
|
||||||
|
const laneW = (segment: DiagramSegment) => LANE_WIDTH - segment.depth * INDENT;
|
||||||
|
const slotsIn = (segment: DiagramSegment) =>
|
||||||
|
Math.max(1, Math.floor((laneW(segment) - 60) / SLOT_WIDTH));
|
||||||
|
|
||||||
|
// A lane is sized to what it holds. Fixed heights meant a lane with enough machines to
|
||||||
|
// wrap onto a second row drew that row outside the box it was supposed to be inside.
|
||||||
|
const occupants = new Map<string, number>();
|
||||||
|
for (const machine of diagram.machines) {
|
||||||
|
// Only a machine that sits INSIDE a lane occupies it. A gateway is counted against the
|
||||||
|
// segment it faces but drawn in the gap, so counting it here left a segment holding no
|
||||||
|
// machines at full height with nothing in it.
|
||||||
|
if (machine.attachments.length !== 1) continue;
|
||||||
|
const only = machine.attachments[0]?.segment;
|
||||||
|
if (only) occupants.set(only, (occupants.get(only) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
const laneHeight = (segment: DiagramSegment): number => {
|
||||||
|
const rows = Math.ceil((occupants.get(segment.name) ?? 0) / slotsIn(segment));
|
||||||
|
// A segment whose only occupants are the gateways in the gaps beside it holds nothing
|
||||||
|
// itself, so it collapses to its own name and ranges. Left at full height it padded a
|
||||||
|
// layered scenario with empty boxes and pushed the interesting rows apart.
|
||||||
|
if (rows === 0) return LANE_EMPTY_HEIGHT;
|
||||||
|
return Math.max(LANE_MIN_HEIGHT, 45 + rows * (NODE_HEIGHT + 45));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which lane each gateway is drawn above — decided ONCE, by position in the lane order,
|
||||||
|
* and used both to size the gap and to place the box.
|
||||||
|
*
|
||||||
|
* Two rules deciding this separately is what put a gateway inside an unrelated network:
|
||||||
|
* the gap was reserved above one sibling while the box was drawn above the other, so it
|
||||||
|
* overflowed into the lane above and landed on top of a machine.
|
||||||
|
*/
|
||||||
|
const laneIndex = new Map(lanes.map((segment, index) => [segment.name, index]));
|
||||||
|
const servedTop = new Map<DiagramMachine, string>();
|
||||||
|
for (const machine of diagram.machines) {
|
||||||
|
if (machine.kind === "transit" || machine.attachments.length < 2) continue;
|
||||||
|
const served = machine.attachments
|
||||||
|
.slice(1)
|
||||||
|
.map((a) => a.segment)
|
||||||
|
.filter((n) => laneIndex.has(n));
|
||||||
|
if (served.length === 0) continue;
|
||||||
|
servedTop.set(
|
||||||
|
machine,
|
||||||
|
served.reduce((best, n) => ((laneIndex.get(n) ?? 0) < (laneIndex.get(best) ?? 0) ? n : best), served[0]!),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const gatewayAbove = new Set(servedTop.values());
|
||||||
|
|
||||||
|
const transit = diagram.machines.find((m) => m.kind === "transit");
|
||||||
|
const laneY = new Map<string, number>();
|
||||||
|
const laneH = new Map<string, number>();
|
||||||
|
const laneOf = new Map<string, DiagramSegment>();
|
||||||
|
let cursor = transit ? 80 + NODE_HEIGHT + GROUP_GAP : 80;
|
||||||
|
|
||||||
|
lanes.forEach((segment, index) => {
|
||||||
|
const y = cursor;
|
||||||
|
const h = laneHeight(segment);
|
||||||
|
laneY.set(segment.name, y);
|
||||||
|
laneH.set(segment.name, h);
|
||||||
|
laneOf.set(segment.name, segment);
|
||||||
|
const next = lanes[index + 1];
|
||||||
|
cursor =
|
||||||
|
y + h +
|
||||||
|
(!next ? 0 : gatewayAbove.has(next.name) ? LANE_GAP : next.depth === 0 ? GROUP_GAP : TIGHT_GAP);
|
||||||
|
const facts = [
|
||||||
|
segment.cidr.join(" "),
|
||||||
|
segment.mtu ? `MTU ${segment.mtu}` : "",
|
||||||
|
segment.behind ? `behind ${segment.behind}` : segment.kind === "public" ? "public" : "isolated",
|
||||||
|
].filter(Boolean);
|
||||||
|
cells.push({
|
||||||
|
id: `lane-${segment.name}`,
|
||||||
|
value: `<b>${segment.name}</b><br/><font style="font-size:10px">${facts.join(" · ")}</font>`,
|
||||||
|
style: segment.kind === "public" ? STYLE.publicLane : STYLE.privateLane,
|
||||||
|
x: laneX(segment),
|
||||||
|
y,
|
||||||
|
w: laneW(segment),
|
||||||
|
h,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cell ids are numbered, not named. A declared machine is free to be called `gw-home`,
|
||||||
|
// which is also what the gateway serving `home` is called — two cells sharing an id makes
|
||||||
|
// a file draw.io opens with one of them missing, silently.
|
||||||
|
const idOf = new Map<DiagramMachine, string>();
|
||||||
|
diagram.machines.forEach((machine, index) => idOf.set(machine, `m${index}`));
|
||||||
|
|
||||||
|
// A machine sits inside its first lane. A router straddles, so it is placed between the
|
||||||
|
// lanes it joins — which is what makes the picture readable at a glance.
|
||||||
|
const perLane = new Map<string, number>();
|
||||||
|
let detachedSlot = 0;
|
||||||
|
|
||||||
|
for (const machine of diagram.machines) {
|
||||||
|
const id = idOf.get(machine)!;
|
||||||
|
const { badges, remaining } = badgesFor(machine);
|
||||||
|
|
||||||
|
let x: number;
|
||||||
|
let y = 80;
|
||||||
|
|
||||||
|
if (machine.kind === "transit") {
|
||||||
|
// Transit is not on a boundary — it reaches every public network at once, so a line
|
||||||
|
// to each would cross everything between. Stated once, at the top, where it applies.
|
||||||
|
x = LANE_X;
|
||||||
|
y = 80;
|
||||||
|
} else if (machine.attachments.length === 0) {
|
||||||
|
// Detached: parked to the side, because it genuinely is nowhere.
|
||||||
|
x = LANE_X + LANE_WIDTH + 60;
|
||||||
|
y = 80 + detachedSlot * (NODE_HEIGHT + 50);
|
||||||
|
detachedSlot++;
|
||||||
|
} else if (machine.attachments.filter((a) => laneY.has(a.segment)).length > 1) {
|
||||||
|
// A gateway sits in the gap directly above the lane it SERVES, indented to that lane,
|
||||||
|
// so it reads as the door into it rather than a box floating nearby. Its first
|
||||||
|
// attachment is the segment it faces outward on; the rest are the ones it serves.
|
||||||
|
const top = servedTop.get(machine) ?? machine.attachments[1]?.segment ?? "";
|
||||||
|
const segment = laneOf.get(top);
|
||||||
|
x = (segment ? laneX(segment) : LANE_X) + 40;
|
||||||
|
y = (laneY.get(top) ?? 80) - LANE_GAP / 2 - NODE_HEIGHT / 2;
|
||||||
|
} else {
|
||||||
|
const only = machine.attachments[0]!.segment;
|
||||||
|
const segment = laneOf.get(only);
|
||||||
|
const per = segment ? slotsIn(segment) : SLOTS_PER_ROW;
|
||||||
|
const slot = perLane.get(only) ?? 0;
|
||||||
|
perLane.set(only, slot + 1);
|
||||||
|
// Wrap onto a second row rather than running off the end of the lane. An earlier
|
||||||
|
// version placed slot 5 outside the box it was supposed to be inside.
|
||||||
|
x = (segment ? laneX(segment) : LANE_X) + 40 + (slot % per) * SLOT_WIDTH;
|
||||||
|
y = (laneY.get(only) ?? 80) + 45 + Math.floor(slot / per) * (NODE_HEIGHT + 45);
|
||||||
|
}
|
||||||
|
|
||||||
|
const addresses = machine.attachments
|
||||||
|
.flatMap((a) => a.addresses)
|
||||||
|
.slice(0, 3)
|
||||||
|
.join("<br/>");
|
||||||
|
const label =
|
||||||
|
`<b>${machine.name}</b>` +
|
||||||
|
(addresses ? `<br/><font style="font-size:9px">${addresses}</font>` : "");
|
||||||
|
|
||||||
|
cells.push({
|
||||||
|
id,
|
||||||
|
value: label,
|
||||||
|
style: shapeFor(machine),
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
w: NODE_WIDTH,
|
||||||
|
h: NODE_HEIGHT,
|
||||||
|
tip: machine.attachments
|
||||||
|
.map((a) => `${a.segment}${a.addresses.length ? `: ${a.addresses.join(", ")}` : ""}`)
|
||||||
|
.join(" · "),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Badges sit along the top edge and read left to right in the order the facts are
|
||||||
|
// stated, which is the order the declaration states them in.
|
||||||
|
const badgeRun = badges.length * BADGE + Math.max(0, badges.length - 1) * 3;
|
||||||
|
badges.forEach((badge, index) => {
|
||||||
|
cells.push({
|
||||||
|
id: `${id}-b${index}`,
|
||||||
|
value: badge.code,
|
||||||
|
style: `${STYLE.badge};fillColor=${badge.fill};strokeColor=${badge.stroke}`,
|
||||||
|
x: x + NODE_WIDTH - badgeRun + index * (BADGE + 3),
|
||||||
|
y: y - BADGE / 2,
|
||||||
|
w: BADGE,
|
||||||
|
h: BADGE,
|
||||||
|
tip: badge.tip,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (remaining.length > 0) {
|
||||||
|
cells.push({
|
||||||
|
id: `${id}-n`,
|
||||||
|
value: remaining.join("<br/>"),
|
||||||
|
style: STYLE.note,
|
||||||
|
x,
|
||||||
|
y: y + NODE_HEIGHT + 4,
|
||||||
|
w: NODE_WIDTH + 60,
|
||||||
|
h: 13 * remaining.length,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// A link is drawn only where it is short. With the grouping above a gateway is always
|
||||||
|
// adjacent to both lanes it joins; anything else would be a line crossing networks it
|
||||||
|
// does not touch, and a lane already says "behind X" in words.
|
||||||
|
if (machine.kind === "transit") continue;
|
||||||
|
for (const attachment of machine.attachments) {
|
||||||
|
const ly = laneY.get(attachment.segment);
|
||||||
|
const lh = laneH.get(attachment.segment);
|
||||||
|
if (ly === undefined || lh === undefined) continue;
|
||||||
|
const above = ly + lh <= y && y - (ly + lh) <= LANE_GAP;
|
||||||
|
const below = y + NODE_HEIGHT <= ly && ly - (y + NODE_HEIGHT) <= LANE_GAP;
|
||||||
|
const within = y >= ly && y + NODE_HEIGHT <= ly + lh;
|
||||||
|
if (!above && !below && !within) continue;
|
||||||
|
edges.push({ id: `${id}-e-${attachment.segment}`, source: id, target: `lane-${attachment.segment}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const header =
|
||||||
|
`<b>${diagram.title}</b> — ${diagram.source === "live" ? "as raised" : "as declared"}`;
|
||||||
|
cells.unshift({ id: "title", value: header, style: STYLE.note + ";fontSize=14", x: LANE_X, y: 30, w: 600, h: 24 });
|
||||||
|
|
||||||
|
const legend = [
|
||||||
|
"<b>badges</b> — read from metadata, not from the file that asked for it",
|
||||||
|
"N translates · F forwarding (green yes, red no) · T mappings expire",
|
||||||
|
"D refuses inbound · C container · VM virtual machine · ▶ running",
|
||||||
|
].join("<br/>");
|
||||||
|
cells.push({
|
||||||
|
id: "legend",
|
||||||
|
value: legend,
|
||||||
|
style: STYLE.note,
|
||||||
|
x: LANE_X,
|
||||||
|
y: cursor + 10,
|
||||||
|
w: LANE_WIDTH,
|
||||||
|
h: 48,
|
||||||
|
});
|
||||||
|
|
||||||
|
// A cell with a tooltip has to be wrapped in <object> — draw.io reads `tooltip` from the
|
||||||
|
// wrapper, never from mxCell itself, and putting it on the mxCell loses it without error.
|
||||||
|
const vertex = (c: Cell): string => {
|
||||||
|
const geometry =
|
||||||
|
` <mxGeometry x="${c.x}" y="${c.y}" width="${c.w}" height="${c.h}" as="geometry" />\n`;
|
||||||
|
if (c.tip) {
|
||||||
|
return (
|
||||||
|
` <object id="${c.id}" label="${escapeXml(c.value)}" tooltip="${escapeXml(c.tip)}">\n` +
|
||||||
|
` <mxCell style="${c.style}" vertex="1" parent="1">\n` +
|
||||||
|
` ${geometry}` +
|
||||||
|
` </mxCell>\n` +
|
||||||
|
` </object>`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// The label is HTML and this is an XML attribute, so the whole thing is escaped here —
|
||||||
|
// exactly once. Escaping the pieces and concatenating raw tags is what made the first
|
||||||
|
// file unparseable.
|
||||||
|
return (
|
||||||
|
` <mxCell id="${c.id}" value="${escapeXml(c.value)}" style="${c.style}" vertex="1" parent="1">\n` +
|
||||||
|
geometry +
|
||||||
|
` </mxCell>`
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const body = [
|
||||||
|
...cells.map(vertex),
|
||||||
|
...edges.map(
|
||||||
|
(e) =>
|
||||||
|
` <mxCell id="${e.id}" style="${STYLE.link}" edge="1" parent="1" source="${e.source}" target="${e.target}">\n` +
|
||||||
|
` <mxGeometry relative="1" as="geometry" />\n` +
|
||||||
|
` </mxCell>`,
|
||||||
|
),
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
return `<mxfile host="mesh-lab">
|
||||||
|
<diagram name="${escapeXml(diagram.title)}">
|
||||||
|
<mxGraphModel dx="1200" dy="800" grid="1" gridSize="10" page="1" pageWidth="1169" pageHeight="826">
|
||||||
|
<root>
|
||||||
|
<mxCell id="0" />
|
||||||
|
<mxCell id="1" parent="0" />
|
||||||
|
${body}
|
||||||
|
</root>
|
||||||
|
</mxGraphModel>
|
||||||
|
</diagram>
|
||||||
|
</mxfile>
|
||||||
|
`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
/** The picture of what a scenario asks for. Needs nothing running. */
|
||||||
|
|
||||||
|
import type { Scenario } from "../declaration/types.ts";
|
||||||
|
import { planRouters, routerMachineName, ttlSeconds } from "../lifecycle/router.ts";
|
||||||
|
import { depthOf, type Diagram, type DiagramMachine, type DiagramSegment } from "./model.ts";
|
||||||
|
|
||||||
|
export function diagramFromDeclaration(scenario: Scenario): Diagram {
|
||||||
|
const parentOf = (name: string) => scenario.segments[name]?.gateway?.to;
|
||||||
|
|
||||||
|
const segments: DiagramSegment[] = Object.entries(scenario.segments).map(([name, segment]) => ({
|
||||||
|
name,
|
||||||
|
kind: segment.kind,
|
||||||
|
cidr: segment.cidr,
|
||||||
|
mtu: segment.mtu,
|
||||||
|
behind: segment.gateway?.to,
|
||||||
|
depth: depthOf(name, parentOf),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const machines: DiagramMachine[] = Object.entries(scenario.machines).map(([name, spec]) => {
|
||||||
|
const notes: string[] = [];
|
||||||
|
if (spec.inbound === "deny") notes.push("refuses inbound");
|
||||||
|
for (const publication of spec.published ?? []) {
|
||||||
|
notes.push(`published :${publication.port} via ${publication.on}`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
kind: "machine" as const,
|
||||||
|
notes,
|
||||||
|
attachments:
|
||||||
|
spec.at === "detached"
|
||||||
|
? []
|
||||||
|
: spec.at.map((a) => ({ segment: a.segment, addresses: a.address })),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Routers are implicit in a declaration — a scenario says a segment sits behind one and
|
||||||
|
// never names it. The diagram has to show them anyway, or the picture omits the machines
|
||||||
|
// that carry every interesting property.
|
||||||
|
for (const plan of planRouters(scenario, "diagram")) {
|
||||||
|
const notes: string[] = [];
|
||||||
|
notes.push(plan.nat.length ? `NAT ${plan.nat.join("+")}` : "routed, no NAT");
|
||||||
|
notes.push(plan.forwardable ? "forwardable" : "NOT forwardable");
|
||||||
|
const ttl = ttlSeconds(plan.mappingTtl);
|
||||||
|
if (ttl !== undefined) notes.push(`mappings expire ${ttl}s`);
|
||||||
|
|
||||||
|
machines.push({
|
||||||
|
name: routerMachineName(plan),
|
||||||
|
kind: "router",
|
||||||
|
notes,
|
||||||
|
attachments: [
|
||||||
|
{ segment: plan.outside, addresses: plan.outsideAddresses },
|
||||||
|
...plan.inside.map((segment) => ({ segment, addresses: [] })),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicSegments = segments.filter((s) => s.kind === "public");
|
||||||
|
if (publicSegments.length > 1) {
|
||||||
|
machines.push({
|
||||||
|
name: "transit",
|
||||||
|
kind: "transit",
|
||||||
|
notes: ["routed, never bridged"],
|
||||||
|
attachments: publicSegments.map((s) => ({ segment: s.name, addresses: [] })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return { title: scenario.scenario, source: "declared", segments, machines };
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
/**
|
||||||
|
* The picture of what is actually raised, read from the hypervisor's own metadata.
|
||||||
|
*
|
||||||
|
* Deliberately reads the same tags `destroy` uses rather than re-deriving anything from the
|
||||||
|
* declaration: a diagram built from the declaration would draw what was asked for and call
|
||||||
|
* it what exists, which is the whole failure this pairing is meant to expose. Everything
|
||||||
|
* shown here was either recorded on the resource when it was raised, or is being reported
|
||||||
|
* by the running machine now — nothing is inferred from a file on disk.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { incusOk, taggedNetworks } from "../incus/client.ts";
|
||||||
|
import { depthOf, type Diagram, type DiagramMachine, type DiagramSegment } from "./model.ts";
|
||||||
|
|
||||||
|
interface RawInstance {
|
||||||
|
name?: string;
|
||||||
|
type?: string;
|
||||||
|
status?: string;
|
||||||
|
config?: Record<string, string>;
|
||||||
|
devices?: Record<string, Record<string, string>>;
|
||||||
|
state?: {
|
||||||
|
network?: Record<
|
||||||
|
string,
|
||||||
|
{ hwaddr?: string; addresses?: { family?: string; address?: string; netmask?: string; scope?: string }[] }
|
||||||
|
>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function diagramFromLive(instanceId: string): Promise<Diagram> {
|
||||||
|
const networks = (await taggedNetworks()).filter((n) => n.instanceId === instanceId);
|
||||||
|
|
||||||
|
const json = (await incusOk(["list", "--format", "json"], 30_000)) ?? "[]";
|
||||||
|
const parsed = JSON.parse(json) as RawInstance[];
|
||||||
|
const mine = parsed.filter((i) => i.config?.["user.mesh-lab.instance"] === instanceId);
|
||||||
|
if (mine.length === 0 && networks.length === 0) throw new Error(`no scenario instance '${instanceId}'`);
|
||||||
|
|
||||||
|
const segmentOfLink = new Map(networks.map((n) => [n.name, n.segment]));
|
||||||
|
|
||||||
|
// A gateway records the segments behind it, so the tree is recoverable from the routers
|
||||||
|
// alone. Without this every segment would draw at the same depth and a picture of a
|
||||||
|
// layered scenario would look flat — which is exactly the property under test.
|
||||||
|
const parent = new Map<string, string>();
|
||||||
|
for (const item of mine) {
|
||||||
|
const inside = item.config?.["user.mesh-lab.router"];
|
||||||
|
const outside = item.config?.["user.mesh-lab.outside"];
|
||||||
|
if (!inside || !outside) continue;
|
||||||
|
for (const segment of inside.split(",").filter(Boolean)) parent.set(segment, outside);
|
||||||
|
}
|
||||||
|
const parentOf = (name: string) => parent.get(name);
|
||||||
|
|
||||||
|
const segments: DiagramSegment[] = networks.map((n) => ({
|
||||||
|
name: n.segment,
|
||||||
|
// Untagged links come from an instance raised before segment shape was recorded. Drawn
|
||||||
|
// as private rather than guessed at, and the missing tag is said out loud on the lane.
|
||||||
|
kind: n.kind ?? "private",
|
||||||
|
cidr: n.cidr,
|
||||||
|
mtu: n.mtu,
|
||||||
|
behind: parentOf(n.segment),
|
||||||
|
depth: depthOf(n.segment, parentOf),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const machines: DiagramMachine[] = mine.map((item) => {
|
||||||
|
const config = item.config ?? {};
|
||||||
|
const isTransit = config["user.mesh-lab.transit"] !== undefined;
|
||||||
|
const isRouter = config["user.mesh-lab.router"] !== undefined;
|
||||||
|
|
||||||
|
// Addresses are joined to devices by MAC, not by name. A container's interface is
|
||||||
|
// called what the device is called; a virtual machine names its own — `enp5s0` for the
|
||||||
|
// device configured as `eth0` — so matching on the name attached every address to a
|
||||||
|
// container and none to a VM, which read as machines that had failed to come up.
|
||||||
|
const heldByMac = new Map<string, string[]>();
|
||||||
|
const heldByName = new Map<string, string[]>();
|
||||||
|
for (const [name, iface] of Object.entries(item.state?.network ?? {})) {
|
||||||
|
const held = (iface.addresses ?? [])
|
||||||
|
.filter((a) => a.scope === "global" && a.address)
|
||||||
|
.map((a) => (a.netmask ? `${a.address}/${a.netmask}` : (a.address as string)));
|
||||||
|
if (held.length === 0) continue;
|
||||||
|
heldByName.set(name, held);
|
||||||
|
if (iface.hwaddr) heldByMac.set(iface.hwaddr.toLowerCase(), held);
|
||||||
|
}
|
||||||
|
|
||||||
|
const attachments: DiagramMachine["attachments"] = [];
|
||||||
|
for (const [device, spec] of Object.entries(item.devices ?? {})) {
|
||||||
|
if (spec["type"] !== "nic") continue;
|
||||||
|
const segment = segmentOfLink.get(spec["parent"] ?? "");
|
||||||
|
if (!segment) continue;
|
||||||
|
const mac = spec["hwaddr"]?.toLowerCase();
|
||||||
|
const addresses = (mac ? heldByMac.get(mac) : undefined) ?? heldByName.get(device) ?? [];
|
||||||
|
attachments.push({ segment, addresses });
|
||||||
|
}
|
||||||
|
// Outside first, then the segments behind it — the same order the declared picture uses,
|
||||||
|
// and what lets the layout place a gateway above the lanes it SERVES rather than below
|
||||||
|
// the one it faces. Sorting alphabetically threw that away.
|
||||||
|
const outside = config["user.mesh-lab.outside"];
|
||||||
|
attachments.sort((a, b) =>
|
||||||
|
a.segment === outside ? -1 : b.segment === outside ? 1 : a.segment.localeCompare(b.segment),
|
||||||
|
);
|
||||||
|
|
||||||
|
const notes: string[] = [];
|
||||||
|
notes.push(item.type === "container" ? "container" : "virtual machine");
|
||||||
|
if (config["user.mesh-lab.inbound"] === "deny") notes.push("refuses inbound");
|
||||||
|
if (isRouter) {
|
||||||
|
const nat = (config["user.mesh-lab.nat"] ?? "").split(",").filter(Boolean);
|
||||||
|
notes.push(nat.length > 0 ? `NAT ${nat.join("+")}` : "routed, no NAT");
|
||||||
|
if (config["user.mesh-lab.forwardable"] !== undefined) {
|
||||||
|
notes.push(config["user.mesh-lab.forwardable"] === "true" ? "forwardable" : "NOT forwardable");
|
||||||
|
}
|
||||||
|
const ttl = config["user.mesh-lab.mapping-ttl"];
|
||||||
|
if (ttl) notes.push(`mappings expire ${ttl}s`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: config["user.mesh-lab.machine"] ?? item.name ?? "?",
|
||||||
|
kind: isTransit ? "transit" : isRouter ? "router" : "machine",
|
||||||
|
notes,
|
||||||
|
attachments,
|
||||||
|
...(item.status ? { status: item.status } : {}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return { title: instanceId, source: "live", segments, machines };
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* What a diagram draws, independent of where it came from.
|
||||||
|
*
|
||||||
|
* Two sources produce one of these: a declaration, and a raised instance. Drawing both
|
||||||
|
* through the same model and the same layout is the point — a difference between the
|
||||||
|
* picture of what was asked for and the picture of what exists is a difference you can
|
||||||
|
* see, which is the check this project keeps finding absent everywhere else.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface DiagramSegment {
|
||||||
|
name: string;
|
||||||
|
kind: "public" | "private";
|
||||||
|
cidr: string[];
|
||||||
|
mtu: number | undefined;
|
||||||
|
/** Parent segment, for a private network behind a gateway. */
|
||||||
|
behind: string | undefined;
|
||||||
|
/** Depth from a public segment. Public is 0. */
|
||||||
|
depth: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DiagramMachine {
|
||||||
|
name: string;
|
||||||
|
/** Segments it sits on, with the addresses it holds there. */
|
||||||
|
attachments: { segment: string; addresses: string[] }[];
|
||||||
|
kind: "machine" | "router" | "transit";
|
||||||
|
/** Badges rendered on the node: NAT, forwardable, TTL, refuses inbound. */
|
||||||
|
notes: string[];
|
||||||
|
/** Present only for a live diagram — what the hypervisor reports. */
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Diagram {
|
||||||
|
title: string;
|
||||||
|
/** Where this came from, so a reader never has to guess which picture they hold. */
|
||||||
|
source: "declared" | "live";
|
||||||
|
segments: DiagramSegment[];
|
||||||
|
machines: DiagramMachine[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Depth of a segment: how many gateways lie between it and a public network. */
|
||||||
|
export function depthOf(
|
||||||
|
name: string,
|
||||||
|
parentOf: (segment: string) => string | undefined,
|
||||||
|
): number {
|
||||||
|
let depth = 0;
|
||||||
|
let current = parentOf(name);
|
||||||
|
const seen = new Set([name]);
|
||||||
|
while (current && !seen.has(current)) {
|
||||||
|
seen.add(current);
|
||||||
|
depth++;
|
||||||
|
current = parentOf(current);
|
||||||
|
}
|
||||||
|
return depth;
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
/**
|
||||||
|
* A thin wrapper over the incus CLI. Thin on purpose: the lab's value is in the scenario
|
||||||
|
* model, not in re-describing a hypervisor.
|
||||||
|
*
|
||||||
|
* Everything here goes through incus's own channel and never over IP. A scenario is a
|
||||||
|
* closed address space — two scenarios raised from one declaration hold the same
|
||||||
|
* addresses and must never meet — so the workstation has no route into either, and
|
||||||
|
* reaching a machine by address would make concurrency impossible in the worst way:
|
||||||
|
* not with an error, but with one scenario's traffic arriving in another.
|
||||||
|
*
|
||||||
|
* See novox/hq 02-DECISIONS/0032-a-scenario-is-an-isolated-address-space.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { spawn } from "node:child_process";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How to invoke incus. Overridable because the socket is group-owned and a session that
|
||||||
|
* predates the group grant cannot reach it — which is a real thing that happens on the
|
||||||
|
* machine that just installed it.
|
||||||
|
*/
|
||||||
|
const INCUS = (process.env["MESH_LAB_INCUS"] ?? "incus").split(" ").filter(Boolean);
|
||||||
|
|
||||||
|
export interface IncusResult {
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class IncusError extends Error {
|
||||||
|
readonly args: string[];
|
||||||
|
readonly stderr: string;
|
||||||
|
readonly exitCode: number | null;
|
||||||
|
|
||||||
|
constructor(args: string[], stderr: string, exitCode: number | null) {
|
||||||
|
const detail = stderr.trim() || (exitCode === null ? "timed out" : `exit ${exitCode}`);
|
||||||
|
super(`incus ${args.join(" ")} failed: ${detail}`);
|
||||||
|
this.name = "IncusError";
|
||||||
|
this.args = args;
|
||||||
|
this.stderr = stderr;
|
||||||
|
this.exitCode = exitCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs incus with **stdin closed**, which is not incidental.
|
||||||
|
*
|
||||||
|
* Several incus subcommands accept a YAML definition on stdin and, given a descriptor
|
||||||
|
* that is not a terminal, wait for one. Under a shell that never happens because stdin is
|
||||||
|
* a TTY; under a spawned process it hangs until the timeout kills it — and the timeout
|
||||||
|
* kill produces an empty stderr, so the failure arrives with no explanation at all.
|
||||||
|
*
|
||||||
|
* Found exactly that way: the first raise reported "failed: (no output)" on a command
|
||||||
|
* that worked perfectly when typed.
|
||||||
|
*/
|
||||||
|
export async function incus(args: string[], timeoutMs = 60_000): Promise<IncusResult> {
|
||||||
|
const [command, ...prefix] = INCUS;
|
||||||
|
if (!command) throw new Error("MESH_LAB_INCUS is empty");
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(command, [...prefix, ...args], {
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
|
||||||
|
let stdout = "";
|
||||||
|
let stderr = "";
|
||||||
|
let timedOut = false;
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
timedOut = true;
|
||||||
|
child.kill("SIGKILL");
|
||||||
|
}, timeoutMs);
|
||||||
|
|
||||||
|
child.stdout.on("data", (chunk) => (stdout += chunk));
|
||||||
|
child.stderr.on("data", (chunk) => (stderr += chunk));
|
||||||
|
|
||||||
|
child.on("error", (err) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(new IncusError(args, err.message, null));
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on("close", (code) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
if (timedOut) {
|
||||||
|
reject(new IncusError(args, `timed out after ${timeoutMs}ms`, null));
|
||||||
|
} else if (code === 0) {
|
||||||
|
resolve({ stdout, stderr });
|
||||||
|
} else {
|
||||||
|
reject(new IncusError(args, stderr, code));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Like `incus`, but a failure is an answer rather than an exception. Returns the command's
|
||||||
|
* output, or `null` if it failed.
|
||||||
|
*
|
||||||
|
* **Never truthiness-test this.** Plenty of incus commands succeed with no output at all —
|
||||||
|
* `exec … true`, `network delete`, `start` — so an empty string means *worked and said
|
||||||
|
* nothing*, and `if (await incusOk(...))` reads that as failure. Use `succeeds()` when the
|
||||||
|
* question is whether it worked, and `!== null` when the output matters.
|
||||||
|
*
|
||||||
|
* This is the mesh's own recurring fault in miniature: absence and success made
|
||||||
|
* indistinguishable. It cost a raise that reported a machine unreachable while `incus exec`
|
||||||
|
* on that machine worked perfectly.
|
||||||
|
*/
|
||||||
|
export async function incusOk(args: string[], timeoutMs = 60_000): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
return (await incus(args, timeoutMs)).stdout;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Did the command work? For commands whose output is not the point. */
|
||||||
|
export async function succeeds(args: string[], timeoutMs = 60_000): Promise<boolean> {
|
||||||
|
return (await incusOk(args, timeoutMs)) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function isReachable(): Promise<boolean> {
|
||||||
|
return succeeds(["info"], 15_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Storage drivers the daemon offers. It only advertises those whose tooling it found. */
|
||||||
|
export async function supportedDrivers(): Promise<string[]> {
|
||||||
|
const info = (await incusOk(["info"], 15_000)) ?? "";
|
||||||
|
const block = info.split("storage_supported_drivers:")[1] ?? "";
|
||||||
|
return [...block.matchAll(/-\s+name:\s*(\w+)/g)].map((m) => m[1] ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Pool {
|
||||||
|
name: string;
|
||||||
|
driver: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pools(): Promise<Pool[]> {
|
||||||
|
const csv = (await incusOk(["storage", "list", "--format", "csv"])) ?? "";
|
||||||
|
return csv
|
||||||
|
.split("\n")
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((line) => {
|
||||||
|
const [name = "", driver = ""] = line.split(",");
|
||||||
|
return { name, driver };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function networkExists(name: string): Promise<boolean> {
|
||||||
|
return succeeds(["network", "show", name], 15_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function instanceExists(name: string): Promise<boolean> {
|
||||||
|
return succeeds(["config", "show", name], 15_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaggedInstance {
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
instanceId: string;
|
||||||
|
machine: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instances this lab owns, identified by the config keys set when they were created —
|
||||||
|
* never by parsing their names.
|
||||||
|
*
|
||||||
|
* Names were parsed at first, splitting on the last dash to separate machine from
|
||||||
|
* instance. That works until a machine is called something like `home-server`, at which
|
||||||
|
* point the instance id absorbs half the machine name and `destroy` silently finds
|
||||||
|
* nothing. Metadata is what the instance actually knows about itself.
|
||||||
|
*/
|
||||||
|
export async function taggedInstances(): Promise<TaggedInstance[]> {
|
||||||
|
const json = (await incusOk(["list", "--format", "json"], 30_000)) ?? "[]";
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(json);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (!Array.isArray(parsed)) return [];
|
||||||
|
|
||||||
|
const tagged: TaggedInstance[] = [];
|
||||||
|
for (const entry of parsed) {
|
||||||
|
const item = entry as { name?: string; status?: string; config?: Record<string, string> };
|
||||||
|
const instanceId = item.config?.["user.mesh-lab.instance"];
|
||||||
|
const machine = item.config?.["user.mesh-lab.machine"];
|
||||||
|
if (!instanceId || !machine || !item.name) continue;
|
||||||
|
tagged.push({ name: item.name, status: item.status ?? "", instanceId, machine });
|
||||||
|
}
|
||||||
|
return tagged;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaggedNetwork {
|
||||||
|
name: string;
|
||||||
|
instanceId: string;
|
||||||
|
segment: string;
|
||||||
|
/** Absent on a link raised before segment shape was recorded. */
|
||||||
|
kind?: "public" | "private";
|
||||||
|
cidr: string[];
|
||||||
|
mtu?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function taggedNetworks(): Promise<TaggedNetwork[]> {
|
||||||
|
const json = (await incusOk(["network", "list", "--format", "json"], 30_000)) ?? "[]";
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(json);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
if (!Array.isArray(parsed)) return [];
|
||||||
|
|
||||||
|
const tagged: TaggedNetwork[] = [];
|
||||||
|
for (const entry of parsed) {
|
||||||
|
const item = entry as { name?: string; config?: Record<string, string> };
|
||||||
|
const instanceId = item.config?.["user.mesh-lab.instance"];
|
||||||
|
const segment = item.config?.["user.mesh-lab.segment"];
|
||||||
|
if (!instanceId || !segment || !item.name) continue;
|
||||||
|
const kind = item.config?.["user.mesh-lab.kind"];
|
||||||
|
const cidr = item.config?.["user.mesh-lab.cidr"];
|
||||||
|
tagged.push({
|
||||||
|
name: item.name,
|
||||||
|
instanceId,
|
||||||
|
segment,
|
||||||
|
...(kind === "public" || kind === "private" ? { kind } : {}),
|
||||||
|
cidr: cidr ? cidr.split(",").filter(Boolean) : [],
|
||||||
|
...(Number.isFinite(Number(item.config?.["user.mesh-lab.mtu"]))
|
||||||
|
? { mtu: Number(item.config?.["user.mesh-lab.mtu"]) }
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return tagged;
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
/**
|
||||||
|
* Put the declared addresses on the machines.
|
||||||
|
*
|
||||||
|
* The lab provides the underlay, and an address is underlay — it is what a hosting
|
||||||
|
* provider or a home router would have given the machine before any of our software ran.
|
||||||
|
* So the lab assigns it, and the mesh is left to build everything above it.
|
||||||
|
*
|
||||||
|
* Addresses are applied by matching on MAC rather than interface name. A guest names its
|
||||||
|
* interfaces by bus position — `enp5s0`, not `eth0` — so the name the hypervisor uses and
|
||||||
|
* the name the guest uses are different, and matching by name silently configures the
|
||||||
|
* wrong interface on a multi-homed machine.
|
||||||
|
*
|
||||||
|
* See novox/hq 02-DECISIONS/0031-the-lab-provides-the-underlay.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Attachment, Scenario } from "../declaration/types.ts";
|
||||||
|
import { incus } from "../incus/client.ts";
|
||||||
|
import { macFor } from "./names.ts";
|
||||||
|
import { transitAddress } from "./router.ts";
|
||||||
|
|
||||||
|
export interface Wire {
|
||||||
|
device: string;
|
||||||
|
mac: string;
|
||||||
|
addresses: string[];
|
||||||
|
mtu: number | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A systemd-networkd unit per interface. Static, because the declaration is the authority:
|
||||||
|
* a scenario that let the hypervisor hand out addresses would be the lab supplying facts
|
||||||
|
* the declaration is supposed to own.
|
||||||
|
*/
|
||||||
|
function networkUnit(wire: Wire): string {
|
||||||
|
const lines = [
|
||||||
|
"[Match]",
|
||||||
|
`MACAddress=${wire.mac}`,
|
||||||
|
"",
|
||||||
|
"[Network]",
|
||||||
|
...wire.addresses.map((address) => `Address=${address}`),
|
||||||
|
// No gateway and no DNS on purpose. Routing off the segment is a gateway's job, and a
|
||||||
|
// scenario that pre-wired it would be arranging what it is supposed to observe.
|
||||||
|
"IPv6AcceptRA=no",
|
||||||
|
];
|
||||||
|
if (wire.mtu !== undefined) {
|
||||||
|
lines.push("", "[Link]", `MTUBytes=${wire.mtu}`);
|
||||||
|
}
|
||||||
|
return lines.join("\n") + "\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyAddresses(
|
||||||
|
scenario: Scenario,
|
||||||
|
instanceId: string,
|
||||||
|
machineNames: Map<string, string>,
|
||||||
|
log: (message: string) => void = () => {},
|
||||||
|
): Promise<void> {
|
||||||
|
for (const [machine, spec] of Object.entries(scenario.machines)) {
|
||||||
|
if (spec.at === "detached") continue;
|
||||||
|
const name = machineNames.get(machine);
|
||||||
|
if (!name) continue;
|
||||||
|
|
||||||
|
const wires: Wire[] = [];
|
||||||
|
for (const [index, attachment] of spec.at.entries()) {
|
||||||
|
wires.push({
|
||||||
|
device: `eth${index}`,
|
||||||
|
mac: macFor(instanceId, machine, index),
|
||||||
|
addresses: attachment.address.map((address) => withPrefix(scenario, attachment.segment, address)),
|
||||||
|
mtu: scenario.segments[attachment.segment]?.mtu,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [index, wire] of wires.entries()) {
|
||||||
|
const unit = networkUnit(wire);
|
||||||
|
await incus(
|
||||||
|
["exec", name, "--", "sh", "-c",
|
||||||
|
`mkdir -p /etc/systemd/network && cat > /etc/systemd/network/10-mlab-${index}.network <<'MLAB'\n${unit}MLAB`],
|
||||||
|
30_000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await incus(["exec", name, "--", "systemctl", "enable", "--now", "systemd-networkd"], 60_000);
|
||||||
|
await incus(["exec", name, "--", "systemctl", "restart", "systemd-networkd"], 60_000);
|
||||||
|
log(` addressed ${machine} (${wires.map((w) => w.addresses.join(",")).join(" | ")})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* systemd-networkd wants a prefix length on the address. The declaration gives a bare
|
||||||
|
* address and the segment gives the range, so the two are combined here rather than making
|
||||||
|
* every scenario repeat the prefix on every machine.
|
||||||
|
*/
|
||||||
|
function withPrefix(scenario: Scenario, segment: string, address: string): string {
|
||||||
|
const ranges = scenario.segments[segment]?.cidr ?? [];
|
||||||
|
const wantV6 = address.includes(":");
|
||||||
|
for (const range of ranges) {
|
||||||
|
const slash = range.lastIndexOf("/");
|
||||||
|
if (slash === -1) continue;
|
||||||
|
const isV6 = range.slice(0, slash).includes(":");
|
||||||
|
if (isV6 === wantV6) return `${address}${range.slice(slash)}`;
|
||||||
|
}
|
||||||
|
return address;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Point each machine at the router serving its segment.
|
||||||
|
*
|
||||||
|
* The route is the machine's, not the mesh's — a default route is what a home network hands
|
||||||
|
* out, and a machine that could not reach beyond its own segment would be reproducing the
|
||||||
|
* wrong topology. What the lab still does not supply is the overlay: no peers, no hub, no
|
||||||
|
* names.
|
||||||
|
*
|
||||||
|
* The router's inside address is the first host address of the range, chosen rather than
|
||||||
|
* declared because a scenario has nothing to say about it.
|
||||||
|
*/
|
||||||
|
export async function applyDefaultRoutes(
|
||||||
|
scenario: Scenario,
|
||||||
|
machineNames: Map<string, string>,
|
||||||
|
log: (message: string) => void = () => {},
|
||||||
|
): Promise<void> {
|
||||||
|
for (const [machine, spec] of Object.entries(scenario.machines)) {
|
||||||
|
if (spec.at === "detached") continue;
|
||||||
|
const name = machineNames.get(machine);
|
||||||
|
if (!name) continue;
|
||||||
|
|
||||||
|
// A machine behind a gateway routes through it. A machine sitting directly on a public
|
||||||
|
// segment routes through transit instead — otherwise it can reach its own network and
|
||||||
|
// nothing else, which is not what being on the internet means.
|
||||||
|
const behind = spec.at.find((a) => scenario.segments[a.segment]?.gateway);
|
||||||
|
if (!behind) {
|
||||||
|
await routeViaTransit(scenario, spec, name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = spec.at.indexOf(behind);
|
||||||
|
for (const range of scenario.segments[behind.segment]?.cidr ?? []) {
|
||||||
|
const slash = range.lastIndexOf("/");
|
||||||
|
if (slash === -1) continue;
|
||||||
|
const base = range.slice(0, slash);
|
||||||
|
const via = base.includes(":")
|
||||||
|
? `${base.replace(/::$/, "")}::1`
|
||||||
|
: (() => { const o = base.split("."); o[3] = "1"; return o.join("."); })();
|
||||||
|
const family = base.includes(":") ? "-6" : "-4";
|
||||||
|
await incus(
|
||||||
|
["exec", name, "--", "sh", "-c",
|
||||||
|
`ip ${family} route replace default via ${via} dev $(ip -o link | awk -F': ' 'NR==${index + 2}{print $2}') 2>/dev/null || true`],
|
||||||
|
30_000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
log(` routed ${machine} via its gateway on ${behind.segment}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A machine on a public segment reaches the other public networks through transit. */
|
||||||
|
async function routeViaTransit(
|
||||||
|
scenario: Scenario,
|
||||||
|
spec: { at: Attachment[] | "detached" },
|
||||||
|
name: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (spec.at === "detached") return;
|
||||||
|
const onPublic = spec.at.find((a) => scenario.segments[a.segment]?.kind === "public");
|
||||||
|
if (!onPublic) return;
|
||||||
|
|
||||||
|
const index = spec.at.indexOf(onPublic);
|
||||||
|
for (const cidr of scenario.segments[onPublic.segment]?.cidr ?? []) {
|
||||||
|
const via = transitAddress(cidr);
|
||||||
|
if (!via) continue;
|
||||||
|
const gateway = via.slice(0, via.lastIndexOf("/"));
|
||||||
|
const family = gateway.includes(":") ? "-6" : "-4";
|
||||||
|
await incus(
|
||||||
|
["exec", name, "--", "sh", "-c",
|
||||||
|
`ip ${family} route replace default via ${gateway} dev $(ip -o link | awk -F': ' 'NR==${index + 2}{print $2}') 2>/dev/null || true`],
|
||||||
|
30_000,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* `inbound: deny` — a host firewall on the machine itself.
|
||||||
|
*
|
||||||
|
* Distinct from NAT and behaving differently: a machine can be perfectly routable and
|
||||||
|
* still refuse everything unsolicited, which is the normal state of a v6-addressed
|
||||||
|
* machine. Without this, v6 addressing would silently imply reachability, and a scenario
|
||||||
|
* that said a machine refuses traffic would produce one that accepts it.
|
||||||
|
*
|
||||||
|
* Established and related traffic is accepted, so the machine can still dial out. That is
|
||||||
|
* what a host firewall does; a machine that could not reach anything would be reproducing
|
||||||
|
* a disconnected machine rather than a defended one.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Scenario } from "../declaration/types.ts";
|
||||||
|
import { incus, succeeds } from "../incus/client.ts";
|
||||||
|
|
||||||
|
const RULESET = `flush ruleset
|
||||||
|
table inet mlab {
|
||||||
|
chain input {
|
||||||
|
type filter hook input priority filter; policy drop;
|
||||||
|
ct state established,related accept
|
||||||
|
iif lo accept
|
||||||
|
ct state invalid drop
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export async function applyHostFirewalls(
|
||||||
|
scenario: Scenario,
|
||||||
|
machineNames: Map<string, string>,
|
||||||
|
log: (message: string) => void = () => {},
|
||||||
|
): Promise<void> {
|
||||||
|
for (const [machine, spec] of Object.entries(scenario.machines)) {
|
||||||
|
if (spec.inbound !== "deny") continue;
|
||||||
|
const name = machineNames.get(machine);
|
||||||
|
if (!name) continue;
|
||||||
|
|
||||||
|
await incus(
|
||||||
|
["exec", name, "--", "sh", "-c",
|
||||||
|
`cat > /tmp/mlab-host.nft <<'MLABNFT'\n${RULESET}MLABNFT\nnft -f /tmp/mlab-host.nft`],
|
||||||
|
60_000,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Read back. A declared refusal that silently did not apply is the fault this lab
|
||||||
|
// exists to catch, and a ruleset that failed to load leaves the machine wide open —
|
||||||
|
// which looks exactly like a machine that is working.
|
||||||
|
const check = await incus(
|
||||||
|
["exec", name, "--", "sh", "-c", "nft list table inet mlab >/dev/null 2>&1 && echo present || echo absent"],
|
||||||
|
20_000,
|
||||||
|
);
|
||||||
|
if (check.stdout.trim() !== "present") {
|
||||||
|
throw new Error(
|
||||||
|
`${machine}: inbound: deny was declared but the ruleset is not loaded — the machine ` +
|
||||||
|
`would accept traffic the scenario says it refuses`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// Recorded only after the read-back proved it loaded. A tag written before the check
|
||||||
|
// would be a claim rather than a record, and anything reading the instance back would
|
||||||
|
// report a defended machine that is in fact wide open.
|
||||||
|
await succeeds(["config", "set", name, "user.mesh-lab.inbound", "deny"], 20_000);
|
||||||
|
log(` ${machine} refuses unsolicited inbound`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
/**
|
||||||
|
* Properties that must hold of ANY raised scenario, whatever it declares.
|
||||||
|
*
|
||||||
|
* Distinct from validation, which reads a file and can only catch what the file says. These
|
||||||
|
* read what actually came up. The first one exists because two routers were raised holding
|
||||||
|
* one address on one segment: the declaration was accepted, the raise reported success, and
|
||||||
|
* the address resolved to whichever container answered ARP last — so a published port
|
||||||
|
* worked or did not, run to run, with nothing reporting a fault.
|
||||||
|
*
|
||||||
|
* Pure over already-collected facts, so the logic is testable without a hypervisor and the
|
||||||
|
* reading of the hypervisor stays in one place.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** One address, held by one machine, on one segment. */
|
||||||
|
export interface Held {
|
||||||
|
machine: string;
|
||||||
|
segment: string;
|
||||||
|
address: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Conflict {
|
||||||
|
segment: string;
|
||||||
|
address: string;
|
||||||
|
machines: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strip a prefix length: what is held is an address, the mask is a property of the link. */
|
||||||
|
function bare(address: string): string {
|
||||||
|
const slash = address.lastIndexOf("/");
|
||||||
|
return slash === -1 ? address : address.slice(0, slash);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two machines holding one address on one segment.
|
||||||
|
*
|
||||||
|
* The same address on DIFFERENT segments is not a conflict — `192.168.1.1` on one private
|
||||||
|
* network and on another are two different machines' idea of "the gateway", which is the
|
||||||
|
* normal case and must not be reported.
|
||||||
|
*/
|
||||||
|
export function duplicateAddresses(held: Held[]): Conflict[] {
|
||||||
|
const byPlace = new Map<string, Set<string>>();
|
||||||
|
for (const entry of held) {
|
||||||
|
const key = `${entry.segment} ${bare(entry.address)}`;
|
||||||
|
const machines = byPlace.get(key) ?? new Set<string>();
|
||||||
|
machines.add(entry.machine);
|
||||||
|
byPlace.set(key, machines);
|
||||||
|
}
|
||||||
|
|
||||||
|
const conflicts: Conflict[] = [];
|
||||||
|
for (const [key, machines] of byPlace) {
|
||||||
|
if (machines.size < 2) continue;
|
||||||
|
const [segment, address] = key.split(" ");
|
||||||
|
conflicts.push({
|
||||||
|
segment: segment as string,
|
||||||
|
address: address as string,
|
||||||
|
machines: [...machines].sort(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return conflicts.sort((a, b) => a.address.localeCompare(b.address));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Render conflicts as something a failing test can print without further work. */
|
||||||
|
export function describeConflicts(conflicts: Conflict[]): string {
|
||||||
|
return conflicts
|
||||||
|
.map((c) => `${c.address} is held by ${c.machines.join(" and ")} on '${c.segment}'`)
|
||||||
|
.join("; ");
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* How a scenario instance's resources are named.
|
||||||
|
*
|
||||||
|
* A declaration is a KIND; instances are many. Two instances of one declaration hold the
|
||||||
|
* same addresses and must never meet, so every resource carries the instance id and
|
||||||
|
* nothing is shared between them.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PREFIX = "mlab";
|
||||||
|
|
||||||
|
/** Instance ids are short and sortable — the last one left standing has to be findable. */
|
||||||
|
export function newInstanceId(scenario: string, now: Date): string {
|
||||||
|
const stamp = now.toISOString().replace(/[-:T]/g, "").slice(2, 12);
|
||||||
|
return `${scenario}-${stamp}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** incus network names are limited to 15 characters, so this hashes rather than truncates. */
|
||||||
|
export function networkName(instanceId: string, segment: string): string {
|
||||||
|
const digest = hash(`${instanceId}/${segment}`);
|
||||||
|
return `${PREFIX}${digest}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function machineName(instanceId: string, machine: string): string {
|
||||||
|
return `${PREFIX}-${instanceId}-${machine}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function instanceIdOf(machineName: string, machine: string): string | null {
|
||||||
|
const suffix = `-${machine}`;
|
||||||
|
if (!machineName.startsWith(`${PREFIX}-`) || !machineName.endsWith(suffix)) return null;
|
||||||
|
return machineName.slice(PREFIX.length + 1, machineName.length - suffix.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function machinePrefix(instanceId: string): string {
|
||||||
|
return `${PREFIX}-${instanceId}-`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** FNV-1a, rendered base36. Short, stable, and collisions are a naming clash not a leak. */
|
||||||
|
function hash(text: string): string {
|
||||||
|
let h = 0x811c9dc5;
|
||||||
|
for (let i = 0; i < text.length; i++) {
|
||||||
|
h ^= text.charCodeAt(i);
|
||||||
|
h = Math.imul(h, 0x01000193) >>> 0;
|
||||||
|
}
|
||||||
|
return h.toString(36).padStart(7, "0").slice(0, 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A deterministic MAC for a machine's Nth interface, in the locally-administered range.
|
||||||
|
*
|
||||||
|
* Set explicitly at creation rather than read back afterwards: incus assigns a MAC at
|
||||||
|
* runtime and does not record it in the device config, so querying returns nothing. A
|
||||||
|
* derived address is also stable across raises of the same instance, which makes an
|
||||||
|
* in-guest match on it reproducible.
|
||||||
|
*/
|
||||||
|
export function macFor(instanceId: string, machine: string, index: number): string {
|
||||||
|
const digest = hash(`${instanceId}/${machine}/${index}`);
|
||||||
|
const octets: string[] = [];
|
||||||
|
let value = 0;
|
||||||
|
for (let i = 0; i < digest.length; i++) value = (value * 31 + digest.charCodeAt(i)) >>> 0;
|
||||||
|
// 02 marks it locally administered, which is what a made-up address is supposed to say.
|
||||||
|
octets.push("02");
|
||||||
|
for (let i = 0; i < 5; i++) {
|
||||||
|
octets.push(((value >>> (i * 5)) & 0xff).toString(16).padStart(2, "0"));
|
||||||
|
}
|
||||||
|
return octets.join(":");
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
/**
|
||||||
|
* What happens to a scenario once it is raised: inspect it, run things in it, capture and
|
||||||
|
* return it to a state, and tear it down.
|
||||||
|
*
|
||||||
|
* Snapshots are WHOLE-SCENARIO. Per-machine would be cheaper and wrong: the mesh keeps
|
||||||
|
* state that spans nodes, so restoring one machine to an earlier moment while its peers
|
||||||
|
* move on produces a mesh that has never existed and could not. Faults found there would
|
||||||
|
* be artefacts of the lab.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { incus, incusOk, succeeds, taggedInstances, taggedNetworks } from "../incus/client.ts";
|
||||||
|
import { machineName } from "./names.ts";
|
||||||
|
import { waitUntilAllUsable } from "./ready.ts";
|
||||||
|
|
||||||
|
export interface Instance {
|
||||||
|
instanceId: string;
|
||||||
|
machines: { name: string; machine: string; status: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every scenario instance the daemon currently holds, found by the metadata each resource
|
||||||
|
* carries rather than by parsing names — a machine called `home-server` would otherwise
|
||||||
|
* be split in the wrong place and its instance would appear not to exist.
|
||||||
|
*/
|
||||||
|
export async function list(): Promise<Instance[]> {
|
||||||
|
const byInstance = new Map<string, Instance["machines"]>();
|
||||||
|
for (const item of await taggedInstances()) {
|
||||||
|
const entry = byInstance.get(item.instanceId) ?? [];
|
||||||
|
entry.push({ name: item.name, machine: item.machine, status: item.status });
|
||||||
|
byInstance.set(item.instanceId, entry);
|
||||||
|
}
|
||||||
|
return [...byInstance.entries()]
|
||||||
|
.map(([instanceId, machines]) => ({
|
||||||
|
instanceId,
|
||||||
|
machines: machines.sort((a, b) => a.machine.localeCompare(b.machine)),
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.instanceId.localeCompare(b.instanceId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function machinesOf(instanceId: string): Promise<string[]> {
|
||||||
|
const found = (await list()).find((i) => i.instanceId === instanceId);
|
||||||
|
if (!found) throw new Error(`no scenario instance '${instanceId}'`);
|
||||||
|
return found.machines.map((m) => m.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a command on a machine, through incus rather than over IP.
|
||||||
|
*
|
||||||
|
* A reachability question is therefore asked from INSIDE: *can this machine reach that
|
||||||
|
* one* is exec on the first, testing the second. The workstation is not on the scenario's
|
||||||
|
* network and its opinion would be a different question with a similar-looking answer.
|
||||||
|
*/
|
||||||
|
export async function exec(
|
||||||
|
instanceId: string,
|
||||||
|
machine: string,
|
||||||
|
command: string[],
|
||||||
|
): Promise<{ stdout: string; stderr: string }> {
|
||||||
|
const found = (await taggedInstances()).find(
|
||||||
|
(i) => i.instanceId === instanceId && i.machine === machine,
|
||||||
|
);
|
||||||
|
const name = found?.name ?? machineName(instanceId, machine);
|
||||||
|
return incus(["exec", name, "--", ...command], 120_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capture the whole scenario as one state. Every machine, one name.
|
||||||
|
*
|
||||||
|
* **Every machine is flushed first, and that is not a precaution.** A snapshot of a running
|
||||||
|
* machine captures its disk, not its memory, so a write still sitting in the guest's page
|
||||||
|
* cache is simply not in the snapshot. Without the flush a file written seconds earlier can
|
||||||
|
* be absent after restore — not stale, absent.
|
||||||
|
*
|
||||||
|
* Found by the integration test on its first run, which is the question the design listed as
|
||||||
|
* open: *does a scenario snapshot need the machines stopped?* It does not, but it does need
|
||||||
|
* them flushed.
|
||||||
|
*
|
||||||
|
* This buys write-durability, not application-consistency. A database mid-transaction is
|
||||||
|
* still captured mid-transaction — the snapshot is crash-consistent, and anything needing
|
||||||
|
* more has to quiesce itself.
|
||||||
|
*/
|
||||||
|
export async function snapshot(instanceId: string, label: string): Promise<number> {
|
||||||
|
const machines = await machinesOf(instanceId);
|
||||||
|
const started = Date.now();
|
||||||
|
|
||||||
|
for (const name of machines) {
|
||||||
|
await incusOk(["exec", name, "--", "sync"], 60_000);
|
||||||
|
}
|
||||||
|
for (const name of machines) {
|
||||||
|
await incus(["snapshot", "create", name, label], 300_000);
|
||||||
|
}
|
||||||
|
return (Date.now() - started) / 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RestoreResult {
|
||||||
|
/** How long the restore itself took. */
|
||||||
|
restoreSeconds: number;
|
||||||
|
/** How long until the scenario was usable again — the number that matters. */
|
||||||
|
usableSeconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the whole scenario to a state. Restoring a subset would produce a mesh that never
|
||||||
|
* was, so this is all-or-nothing.
|
||||||
|
*
|
||||||
|
* Restoring a virtual machine replaces its disk and the machine comes back up, so it
|
||||||
|
* reports RUNNING while its agent is still starting — measured, the restore call returns
|
||||||
|
* in 0.79s and the very next command fails. Reporting that as "restored" would be
|
||||||
|
* transport reported as effect, so this waits for usable and returns both numbers.
|
||||||
|
*/
|
||||||
|
export async function restore(
|
||||||
|
instanceId: string,
|
||||||
|
label: string,
|
||||||
|
readyTimeoutSeconds = 180,
|
||||||
|
log: (message: string) => void = () => {},
|
||||||
|
): Promise<RestoreResult> {
|
||||||
|
const machines = await machinesOf(instanceId);
|
||||||
|
const started = Date.now();
|
||||||
|
for (const name of machines) {
|
||||||
|
await incus(["snapshot", "restore", name, label], 300_000);
|
||||||
|
}
|
||||||
|
const restoreSeconds = (Date.now() - started) / 1000;
|
||||||
|
|
||||||
|
for (const name of machines) {
|
||||||
|
await succeeds(["start", name], 60_000);
|
||||||
|
}
|
||||||
|
await waitUntilAllUsable(machines, readyTimeoutSeconds, log);
|
||||||
|
|
||||||
|
return { restoreSeconds, usableSeconds: (Date.now() - started) / 1000 };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function snapshots(instanceId: string): Promise<string[]> {
|
||||||
|
const machines = await machinesOf(instanceId);
|
||||||
|
const first = machines[0];
|
||||||
|
if (!first) return [];
|
||||||
|
const csv = (await incusOk(["snapshot", "list", first, "--format", "csv"])) ?? "";
|
||||||
|
return csv
|
||||||
|
.split("\n")
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((line) => line.split(",")[0] ?? "")
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tear the instance down: machines first, then the links they were on.
|
||||||
|
*
|
||||||
|
* Networks are removed last and only if empty — a link still carrying an interface
|
||||||
|
* cannot be deleted, and forcing it would leave the daemon with a reference to something
|
||||||
|
* gone.
|
||||||
|
*/
|
||||||
|
export async function destroy(instanceId: string): Promise<{ machines: number; networks: number }> {
|
||||||
|
const machines = (await taggedInstances()).filter((i) => i.instanceId === instanceId);
|
||||||
|
for (const machine of machines) {
|
||||||
|
await succeeds(["delete", "--force", machine.name], 300_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Links go last and only once nothing is attached: a network still carrying an
|
||||||
|
// interface cannot be deleted, and forcing it would leave a dangling reference.
|
||||||
|
let networks = 0;
|
||||||
|
for (const network of await taggedNetworks()) {
|
||||||
|
if (network.instanceId !== instanceId) continue;
|
||||||
|
// `network delete` also succeeds silently — counted with succeeds(), not truthiness.
|
||||||
|
if (await succeeds(["network", "delete", network.name], 30_000)) networks++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { machines: machines.length, networks };
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
/**
|
||||||
|
* `place:` — putting something inside the machines.
|
||||||
|
*
|
||||||
|
* Until now the lab raised an underlay and put nothing on it: correct, and useless, because
|
||||||
|
* the thing it exists to test did not exist (novox/hq 03-DESIGN/00-as-is/11-the-lab.md). Tier
|
||||||
|
* 0 now does, so this is the seam where the lab acquires a consumer.
|
||||||
|
*
|
||||||
|
* Only `host` is placeable. Everything else in the placement vocabulary — the substrate, a
|
||||||
|
* control plane, a forge — is still refused by name rather than ignored, because a scenario
|
||||||
|
* that declares something and raises without it is the fault this lab was built to catch
|
||||||
|
* (novox/hq 04-ISSUES/003).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Scenario } from "../declaration/types.ts";
|
||||||
|
import { incus, incusOk } from "../incus/client.ts";
|
||||||
|
|
||||||
|
/** What this stage can put inside a machine. */
|
||||||
|
export const PLACEABLE = ["host"] as const;
|
||||||
|
export type Placeable = (typeof PLACEABLE)[number];
|
||||||
|
|
||||||
|
/** Where the host binary lives on a machine once placed. */
|
||||||
|
export const HOST_PATH = "/usr/local/bin/mesh-host";
|
||||||
|
|
||||||
|
export interface Placement {
|
||||||
|
machine: string;
|
||||||
|
artifacts: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve `place:` to one list per machine.
|
||||||
|
*
|
||||||
|
* `all:` applies to every machine; a per-machine entry **overrides** it rather than adding to
|
||||||
|
* it, which is what the declaration design says and is worth being exact about — a scenario
|
||||||
|
* naming one artifact for one machine gets that artifact and not that artifact plus the rest.
|
||||||
|
*/
|
||||||
|
export function planPlacements(scenario: Scenario): Placement[] {
|
||||||
|
const place = scenario.place;
|
||||||
|
if (!place) return [];
|
||||||
|
|
||||||
|
const all = place.all ?? [];
|
||||||
|
return Object.keys(scenario.machines).map((machine) => {
|
||||||
|
const own = place[machine];
|
||||||
|
return { machine, artifacts: own ?? all };
|
||||||
|
}).filter((p) => p.artifacts.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The host binary to place, from the environment.
|
||||||
|
*
|
||||||
|
* Deliberately an explicit path rather than a search. The declaration design leaves *where
|
||||||
|
* `place:` gets its artifacts from* open — before the mesh is self-hosting they come from
|
||||||
|
* outside, afterwards from the mesh — and it suggests a named source rather than a path. This
|
||||||
|
* is neither: it is the smallest thing that works while that stays undecided, and it refuses
|
||||||
|
* loudly rather than guessing, so nothing here hardens into the answer by accident.
|
||||||
|
*/
|
||||||
|
export function hostBinaryPath(): string | null {
|
||||||
|
return process.env["MESH_LAB_HOST_BINARY"] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class PlacementError extends Error {
|
||||||
|
readonly machine: string;
|
||||||
|
|
||||||
|
constructor(machine: string, message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "PlacementError";
|
||||||
|
this.machine = machine;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PlacedHost {
|
||||||
|
machine: string;
|
||||||
|
/** What the host reported about the machine, read back from it. */
|
||||||
|
profile: unknown;
|
||||||
|
version: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Put the host on a machine and ask it what the machine is.
|
||||||
|
*
|
||||||
|
* The result is read back from the running binary, never assumed from the fact that the copy
|
||||||
|
* succeeded (novox/hq ADR 0035). A file arriving is not a host working, which is the same
|
||||||
|
* distinction the host itself makes about installed packages.
|
||||||
|
*/
|
||||||
|
export async function placeHost(
|
||||||
|
instanceName: string,
|
||||||
|
machine: string,
|
||||||
|
binary: string,
|
||||||
|
log: (message: string) => void = () => {},
|
||||||
|
): Promise<PlacedHost> {
|
||||||
|
await incus(["file", "push", binary, `${instanceName}${HOST_PATH}`, "--mode", "0755"], 180_000);
|
||||||
|
|
||||||
|
// Read back that it is there and executable before trusting it to answer questions.
|
||||||
|
const version = (await incusOk(["exec", instanceName, "--", HOST_PATH, "version"], 60_000))?.trim();
|
||||||
|
if (!version) {
|
||||||
|
throw new PlacementError(
|
||||||
|
machine,
|
||||||
|
`the host binary was copied to ${machine} but does not run there. A file arriving is ` +
|
||||||
|
`not a host working.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reported = await incusOk(
|
||||||
|
["exec", instanceName, "--", HOST_PATH, "profile", "--json"],
|
||||||
|
120_000,
|
||||||
|
);
|
||||||
|
if (!reported) {
|
||||||
|
throw new PlacementError(
|
||||||
|
machine,
|
||||||
|
`the host runs on ${machine} (${version}) but reported no profile. A host that cannot ` +
|
||||||
|
`say what a machine is cannot be asked to change it.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let profile: unknown;
|
||||||
|
try {
|
||||||
|
profile = JSON.parse(reported);
|
||||||
|
} catch (err) {
|
||||||
|
throw new PlacementError(
|
||||||
|
machine,
|
||||||
|
`the host on ${machine} reported something that is not a profile: ${(err as Error).message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
log(` placed the host on ${machine} (${version})`);
|
||||||
|
return { machine, profile, version };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Place everything a scenario declares. */
|
||||||
|
export async function applyPlacements(
|
||||||
|
scenario: Scenario,
|
||||||
|
machineNames: Map<string, string>,
|
||||||
|
log: (message: string) => void = () => {},
|
||||||
|
): Promise<PlacedHost[]> {
|
||||||
|
const placements = planPlacements(scenario);
|
||||||
|
if (placements.length === 0) return [];
|
||||||
|
|
||||||
|
const binary = hostBinaryPath();
|
||||||
|
if (!binary) {
|
||||||
|
throw new Error(
|
||||||
|
`this scenario places the host, and no host binary was given. Set ` +
|
||||||
|
`MESH_LAB_HOST_BINARY to a built mesh-host. Guessing at a path would place ` +
|
||||||
|
`whatever happened to be there.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const placed: PlacedHost[] = [];
|
||||||
|
for (const { machine, artifacts } of placements) {
|
||||||
|
const name = machineNames.get(machine);
|
||||||
|
if (!name) continue;
|
||||||
|
for (const artifact of artifacts) {
|
||||||
|
if (artifact !== "host") continue; // refused earlier; belt and braces
|
||||||
|
placed.push(await placeHost(name, machine, binary, log));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return placed;
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
/**
|
||||||
|
* Materialise a declaration into a running scenario instance.
|
||||||
|
*
|
||||||
|
* Two rules from the design shape everything here.
|
||||||
|
*
|
||||||
|
* `raise` waits for the machines to be USABLE, not for the calls to return. Measured on a
|
||||||
|
* workstation those are 3.4s and 14.3s apart, and reporting the earlier number would be
|
||||||
|
* the mesh's own recurring failure — transport reported as effect.
|
||||||
|
*
|
||||||
|
* A failed raise LEAVES THE WRECKAGE. Tearing down on failure destroys the only evidence
|
||||||
|
* of what went wrong, and a scenario that failed to raise is more interesting than one
|
||||||
|
* that succeeded.
|
||||||
|
*
|
||||||
|
* See novox/hq 03-DESIGN/01-to-be/03-scenario-lifecycle.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Scenario, Segment } from "../declaration/types.ts";
|
||||||
|
import { incus, incusOk, succeeds, pools, supportedDrivers } from "../incus/client.ts";
|
||||||
|
import { machineName, macFor, networkName, newInstanceId } from "./names.ts";
|
||||||
|
import { waitUntilAllUsable } from "./ready.ts";
|
||||||
|
import { applyAddresses, applyDefaultRoutes } from "./address.ts";
|
||||||
|
import { assertSupported } from "./supported.ts";
|
||||||
|
import { planRouters, raiseRouters, raiseTransit } from "./router.ts";
|
||||||
|
import { applyHostFirewalls } from "./firewall.ts";
|
||||||
|
import { applyPlacements } from "./place.ts";
|
||||||
|
|
||||||
|
/** Drivers whose snapshots are copy-on-write. On `dir` a snapshot is a full copy. */
|
||||||
|
const COW_DRIVERS = ["btrfs", "zfs"];
|
||||||
|
|
||||||
|
export interface RaiseOptions {
|
||||||
|
/** Base image for machines. */
|
||||||
|
image?: string;
|
||||||
|
/** Reuse an existing instance id rather than minting one — makes raise convergent. */
|
||||||
|
instanceId?: string;
|
||||||
|
/** Seconds to wait for each machine to become usable. */
|
||||||
|
readyTimeoutSeconds?: number;
|
||||||
|
onProgress?: (message: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RaisedScenario {
|
||||||
|
instanceId: string;
|
||||||
|
scenario: string;
|
||||||
|
machines: string[];
|
||||||
|
networks: string[];
|
||||||
|
pool: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RaiseError extends Error {
|
||||||
|
readonly instanceId: string;
|
||||||
|
readonly step: string;
|
||||||
|
|
||||||
|
constructor(instanceId: string, step: string, cause: unknown) {
|
||||||
|
const detail = cause instanceof Error ? cause.message : String(cause);
|
||||||
|
super(
|
||||||
|
`raise failed at '${step}': ${detail}\n` +
|
||||||
|
`The instance '${instanceId}' has been LEFT STANDING for inspection. ` +
|
||||||
|
`Destroy it with: mesh-lab destroy ${instanceId}`,
|
||||||
|
);
|
||||||
|
this.name = "RaiseError";
|
||||||
|
this.instanceId = instanceId;
|
||||||
|
this.step = step;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick a pool that can snapshot cheaply, and say so loudly when there is not one.
|
||||||
|
*
|
||||||
|
* Measured: a `dir` snapshot of a 1.5 GB machine takes 9.9s and a full 1.6 GB, with a
|
||||||
|
* second snapshot unfinished after two minutes. On copy-on-write it is 0.13s and costs
|
||||||
|
* the delta. Restoring is the operation the inner loop repeats most, so a `dir` pool does
|
||||||
|
* not make the lab slow — it makes it unused.
|
||||||
|
*/
|
||||||
|
async function choosePool(log: (m: string) => void): Promise<string> {
|
||||||
|
const available = await pools();
|
||||||
|
const cow = available.find((p) => COW_DRIVERS.includes(p.driver));
|
||||||
|
if (cow) return cow.name;
|
||||||
|
|
||||||
|
const drivers = await supportedDrivers();
|
||||||
|
const possible = drivers.filter((d) => COW_DRIVERS.includes(d));
|
||||||
|
log(
|
||||||
|
possible.length > 0
|
||||||
|
? `WARNING: no copy-on-write pool exists, though the daemon offers ${possible.join("/")}. ` +
|
||||||
|
`Snapshots will be full copies — roughly 76x slower, and the inner loop unusable.`
|
||||||
|
: `WARNING: the daemon offers no copy-on-write driver. Snapshots will be full copies — ` +
|
||||||
|
`roughly 76x slower, and the inner loop unusable. Install btrfs tooling and restart it.`,
|
||||||
|
);
|
||||||
|
const fallback = available[0];
|
||||||
|
if (!fallback) throw new Error("no storage pool exists at all");
|
||||||
|
return fallback.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One isolated link per segment. Nothing joins them to anything outside the instance, and
|
||||||
|
* incus is told not to hand out addresses: a scenario declares the underlay, and letting
|
||||||
|
* a hypervisor's DHCP assign addresses would be the lab supplying facts the declaration
|
||||||
|
* is supposed to own.
|
||||||
|
*/
|
||||||
|
async function createNetwork(
|
||||||
|
instanceId: string,
|
||||||
|
segment: string,
|
||||||
|
spec: Segment,
|
||||||
|
): Promise<string> {
|
||||||
|
const name = networkName(instanceId, segment);
|
||||||
|
if (await succeeds(["network", "show", name], 15_000)) return name;
|
||||||
|
await incus([
|
||||||
|
"network", "create", name,
|
||||||
|
"ipv4.address=none",
|
||||||
|
"ipv6.address=none",
|
||||||
|
"ipv4.nat=false",
|
||||||
|
"ipv6.nat=false",
|
||||||
|
`user.mesh-lab.instance=${instanceId}`,
|
||||||
|
`user.mesh-lab.segment=${segment}`,
|
||||||
|
// Whether a segment is public and which ranges it carries are facts a link cannot be
|
||||||
|
// asked for afterwards — incus knows only that it is an isolated bridge. Recorded here
|
||||||
|
// so anything reading a raised instance back reads what was applied, rather than
|
||||||
|
// re-opening the declaration and reporting the request as though it were the result.
|
||||||
|
`user.mesh-lab.kind=${spec.kind}`,
|
||||||
|
`user.mesh-lab.cidr=${spec.cidr.join(",")}`,
|
||||||
|
...(spec.mtu === undefined ? [] : [`user.mesh-lab.mtu=${spec.mtu}`]),
|
||||||
|
]);
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createMachine(
|
||||||
|
instanceId: string,
|
||||||
|
machine: string,
|
||||||
|
attachments: { segment: string }[],
|
||||||
|
image: string,
|
||||||
|
pool: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const name = machineName(instanceId, machine);
|
||||||
|
if (await succeeds(["config", "show", name], 15_000)) return name;
|
||||||
|
|
||||||
|
const args = [
|
||||||
|
"init", image, name,
|
||||||
|
"--vm",
|
||||||
|
"-s", pool,
|
||||||
|
// Arch images refuse to boot under secureboot with the shipped keys. Discovered by
|
||||||
|
// the first launch failing with exactly that message.
|
||||||
|
"-c", "security.secureboot=false",
|
||||||
|
"-c", "limits.memory=1GiB",
|
||||||
|
"-c", "limits.cpu=2",
|
||||||
|
"-c", `user.mesh-lab.instance=${instanceId}`,
|
||||||
|
"-c", `user.mesh-lab.machine=${machine}`,
|
||||||
|
];
|
||||||
|
await incus(args, 300_000);
|
||||||
|
|
||||||
|
// eth0 comes from the profile and points at the wrong network, so every attachment is
|
||||||
|
// explicit. A machine on no segment gets no interface at all — that is what detached is.
|
||||||
|
await succeeds(["config", "device", "remove", name, "eth0"], 15_000);
|
||||||
|
for (const [index, attachment] of attachments.entries()) {
|
||||||
|
await incus([
|
||||||
|
"config", "device", "add", name, `eth${index}`, "nic",
|
||||||
|
"nictype=bridged",
|
||||||
|
`parent=${networkName(instanceId, attachment.segment)}`,
|
||||||
|
// Explicit, because incus assigns one at runtime without recording it in the device
|
||||||
|
// config — so reading it back returns nothing, and the guest has no stable handle.
|
||||||
|
`hwaddr=${macFor(instanceId, machine, index)}`,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function raise(
|
||||||
|
scenario: Scenario,
|
||||||
|
options: RaiseOptions = {},
|
||||||
|
): Promise<RaisedScenario> {
|
||||||
|
const log = options.onProgress ?? (() => {});
|
||||||
|
const image = options.image ?? "images:archlinux/current";
|
||||||
|
const readyTimeout = options.readyTimeoutSeconds ?? 180;
|
||||||
|
const instanceId = options.instanceId ?? newInstanceId(scenario.scenario, new Date());
|
||||||
|
|
||||||
|
// Refuse before spending a minute raising something that would silently lack half of
|
||||||
|
// what it declares. Deliberately outside the try: this is not a raise failure, nothing
|
||||||
|
// has been created, and there is no wreckage to leave standing.
|
||||||
|
assertSupported(scenario);
|
||||||
|
|
||||||
|
let step = "choosing a storage pool";
|
||||||
|
try {
|
||||||
|
const pool = await choosePool(log);
|
||||||
|
log(`instance ${instanceId} pool ${pool}`);
|
||||||
|
|
||||||
|
step = "creating segments";
|
||||||
|
const networks: string[] = [];
|
||||||
|
for (const [segment, spec] of Object.entries(scenario.segments)) {
|
||||||
|
networks.push(await createNetwork(instanceId, segment, spec));
|
||||||
|
log(` segment ${segment}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
step = "creating machines";
|
||||||
|
const created: string[] = [];
|
||||||
|
const byMachine = new Map<string, string>();
|
||||||
|
for (const [machine, spec] of Object.entries(scenario.machines)) {
|
||||||
|
const attachments = spec.at === "detached" ? [] : spec.at;
|
||||||
|
const name = await createMachine(instanceId, machine, attachments, image, pool);
|
||||||
|
created.push(name);
|
||||||
|
byMachine.set(machine, name);
|
||||||
|
log(` machine ${machine}${spec.at === "detached" ? " (detached)" : ""}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
step = "starting machines";
|
||||||
|
for (const name of created) {
|
||||||
|
await succeeds(["start", name], 60_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
step = "waiting for machines to become usable";
|
||||||
|
await waitUntilAllUsable(created, readyTimeout, log);
|
||||||
|
|
||||||
|
step = "applying declared addresses";
|
||||||
|
await applyAddresses(scenario, instanceId, byMachine, log);
|
||||||
|
|
||||||
|
// Transit first: a gateway's default route points at it, so it has to exist.
|
||||||
|
step = "wiring the public networks together";
|
||||||
|
const transit = await raiseTransit(scenario, instanceId, log);
|
||||||
|
|
||||||
|
step = "raising routers";
|
||||||
|
const routers = await raiseRouters(scenario, instanceId, planRouters(scenario, instanceId), log);
|
||||||
|
if (transit) routers.push(transit);
|
||||||
|
|
||||||
|
step = "routing machines through their gateways";
|
||||||
|
await applyDefaultRoutes(scenario, byMachine, log);
|
||||||
|
|
||||||
|
// Last: a machine that refuses inbound must still have been reachable while the lab
|
||||||
|
// was configuring it.
|
||||||
|
step = "applying host firewalls";
|
||||||
|
await applyHostFirewalls(scenario, byMachine, log);
|
||||||
|
|
||||||
|
// Last, and only once the underlay is real. Placing before the machines can reach each
|
||||||
|
// other would test the host against a network the scenario does not describe.
|
||||||
|
step = "placing";
|
||||||
|
await applyPlacements(scenario, byMachine, log);
|
||||||
|
|
||||||
|
return {
|
||||||
|
instanceId,
|
||||||
|
scenario: scenario.scenario,
|
||||||
|
machines: [...created, ...routers],
|
||||||
|
networks,
|
||||||
|
pool,
|
||||||
|
};
|
||||||
|
} catch (cause) {
|
||||||
|
throw new RaiseError(instanceId, step, cause);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/**
|
||||||
|
* "Usable" means a command runs on the machine. Anything weaker is transport reported as
|
||||||
|
* effect — the mesh's own recurring fault, and one this lab exists to catch rather than
|
||||||
|
* commit.
|
||||||
|
*
|
||||||
|
* Two measurements make the case. Raising: the launch call returns in 3.4s and the machine
|
||||||
|
* is usable at 14.3s. Restoring: the call returns in 0.79s and the machine is RUNNING
|
||||||
|
* immediately — with its agent still starting, so the very next command fails.
|
||||||
|
*
|
||||||
|
* Both verbs therefore wait for the same thing, using the same code.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { succeeds } from "../incus/client.ts";
|
||||||
|
|
||||||
|
export interface ReadyResult {
|
||||||
|
name: string;
|
||||||
|
seconds: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitUntilUsable(
|
||||||
|
name: string,
|
||||||
|
timeoutSeconds: number,
|
||||||
|
log: (message: string) => void = () => {},
|
||||||
|
): Promise<ReadyResult> {
|
||||||
|
const started = Date.now();
|
||||||
|
const deadline = started + timeoutSeconds * 1000;
|
||||||
|
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
// `exec … true` succeeds with EMPTY output, so this asks whether it worked rather than
|
||||||
|
// what it said. Truthiness-testing the output reported every machine as unreachable
|
||||||
|
// while `incus exec` on it worked perfectly.
|
||||||
|
if (await succeeds(["exec", name, "--", "true"], 10_000)) {
|
||||||
|
const seconds = (Date.now() - started) / 1000;
|
||||||
|
log(` ${name} usable after ${seconds.toFixed(1)}s`);
|
||||||
|
return { name, seconds };
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`${name} did not become usable within ${timeoutSeconds}s — it may be running but ` +
|
||||||
|
`unreachable, which is not the same as ready`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function waitUntilAllUsable(
|
||||||
|
names: string[],
|
||||||
|
timeoutSeconds: number,
|
||||||
|
log: (message: string) => void = () => {},
|
||||||
|
): Promise<number> {
|
||||||
|
const started = Date.now();
|
||||||
|
// Concurrently: a scenario's machines boot independently, and waiting for them in turn
|
||||||
|
// would make a four-machine scenario four boots long instead of one.
|
||||||
|
await Promise.all(names.map((name) => waitUntilUsable(name, timeoutSeconds, log)));
|
||||||
|
return (Date.now() - started) / 1000;
|
||||||
|
}
|
||||||
@@ -0,0 +1,517 @@
|
|||||||
|
/**
|
||||||
|
* Materialise the routers a declaration implies.
|
||||||
|
*
|
||||||
|
* A gateway is the one implicit machine in an otherwise explicit declaration — a scenario
|
||||||
|
* says a segment sits behind one and never names the thing that serves it, because it has
|
||||||
|
* nothing to say about it.
|
||||||
|
*
|
||||||
|
* A router is **scenery, not a node**, so it is a container rather than a virtual machine
|
||||||
|
* (novox/hq ADR 0033). Nothing under test runs on it and no assertion is made about its
|
||||||
|
* internals; it exists so packets behave the way they behave in the world. What it has to
|
||||||
|
* reproduce is kernel behaviour, and a container has the same kernel.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Family, Scenario } from "../declaration/types.ts";
|
||||||
|
import { incus, succeeds } from "../incus/client.ts";
|
||||||
|
import { macFor, networkName } from "./names.ts";
|
||||||
|
import { waitUntilUsable } from "./ready.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The router image, built once and cached.
|
||||||
|
*
|
||||||
|
* A scenario is a closed address space, so a router has no route to a package repository —
|
||||||
|
* installing nftables at raise time cannot work, and the first attempt failed exactly that
|
||||||
|
* way. So the image is prepared once, with temporary connectivity, and every scenario
|
||||||
|
* afterwards raises from it needing no network at all.
|
||||||
|
*
|
||||||
|
* That is the same property the mesh's own artifacts have: what ships is self-contained,
|
||||||
|
* and a deploy touches no network.
|
||||||
|
*/
|
||||||
|
const ROUTER_IMAGE = "mesh-lab-router";
|
||||||
|
const ROUTER_BASE = "images:alpine/edge";
|
||||||
|
|
||||||
|
/** Wait until the container can actually resolve and fetch — not merely run a command. */
|
||||||
|
async function waitForNetwork(name: string, timeoutSeconds: number): Promise<void> {
|
||||||
|
const deadline = Date.now() + timeoutSeconds * 1000;
|
||||||
|
let lastError = "no attempt made";
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
try {
|
||||||
|
await incus(["exec", name, "--", "apk", "update"], 30_000);
|
||||||
|
return;
|
||||||
|
} catch (err) {
|
||||||
|
lastError = err instanceof Error ? err.message.split("\n")[0] ?? "" : String(err);
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`${name} had no working network after ${timeoutSeconds}s — the router image cannot be ` +
|
||||||
|
`built without one. Last error: ${lastError}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the router image if it is missing. One-time, and the only step in the whole lab that
|
||||||
|
* needs the workstation to be online.
|
||||||
|
*/
|
||||||
|
export async function ensureRouterImage(log: (message: string) => void = () => {}): Promise<void> {
|
||||||
|
if (await succeeds(["image", "info", ROUTER_IMAGE], 20_000)) return;
|
||||||
|
|
||||||
|
log(` building the router image (once) — installing nftables into ${ROUTER_BASE}`);
|
||||||
|
const builder = "mlab-router-build";
|
||||||
|
await succeeds(["delete", "--force", builder], 60_000);
|
||||||
|
|
||||||
|
// Default profile on purpose: this is the one container that needs to reach a repository.
|
||||||
|
await incus(["launch", ROUTER_BASE, builder], 300_000);
|
||||||
|
await waitUntilUsable(builder, 120, () => {});
|
||||||
|
|
||||||
|
// `exec` works before the container has an address. Usable means a command runs; it does
|
||||||
|
// not mean the network is up, and the first attempt failed on DNS because those were
|
||||||
|
// treated as the same thing. Wait for the thing actually needed.
|
||||||
|
await waitForNetwork(builder, 60);
|
||||||
|
|
||||||
|
// Not swallowed. A router without nftables is a router that silently does not route, and
|
||||||
|
// an earlier attempt shipped exactly that because the failure was hidden behind `|| true`.
|
||||||
|
await incus(["exec", builder, "--", "apk", "add", "--no-cache", "--update", "nftables"], 180_000);
|
||||||
|
await incus(["exec", builder, "--", "sh", "-c", "command -v nft"], 20_000);
|
||||||
|
|
||||||
|
// The stock image ships `auto eth0 / iface eth0 inet dhcp`, and its boot-time networking
|
||||||
|
// service acts on it — flushing the static address the scenario just set, on eth0 only,
|
||||||
|
// which is why the outside interface came up bare while the inside ones were fine.
|
||||||
|
//
|
||||||
|
// A scenario declares the underlay; a router that reconfigures itself from an image
|
||||||
|
// default is the lab overriding the declaration.
|
||||||
|
await incus([
|
||||||
|
"exec", builder, "--", "sh", "-c",
|
||||||
|
"printf 'auto lo\\niface lo inet loopback\\n' > /etc/network/interfaces",
|
||||||
|
], 30_000);
|
||||||
|
|
||||||
|
await incus(["stop", builder], 120_000);
|
||||||
|
await incus(["publish", builder, "--alias", ROUTER_IMAGE], 300_000);
|
||||||
|
await succeeds(["delete", "--force", builder], 60_000);
|
||||||
|
log(` router image ready`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The address the transit router holds on a public segment: the last usable host address.
|
||||||
|
*
|
||||||
|
* Chosen rather than declared, like a gateway's inside address — a scenario has nothing to
|
||||||
|
* say about the internet's own routers, only about the networks they connect.
|
||||||
|
*/
|
||||||
|
export function transitAddress(cidr: string): string | null {
|
||||||
|
const slash = cidr.lastIndexOf("/");
|
||||||
|
if (slash === -1) return null;
|
||||||
|
const base = cidr.slice(0, slash);
|
||||||
|
const prefix = cidr.slice(slash);
|
||||||
|
if (base.includes(":")) return `${base.replace(/::$/, "")}::fffe${prefix}`;
|
||||||
|
const octets = base.split(".");
|
||||||
|
octets[3] = "254";
|
||||||
|
return `${octets.join(".")}${prefix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The name a router answers to in `list` and `exec` — scenery, but addressable. */
|
||||||
|
export function routerMachineName(plan: RouterPlan): string {
|
||||||
|
return `gw-${plan.inside.join("-")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RouterPlan {
|
||||||
|
/** Router name, one per distinct gateway. */
|
||||||
|
name: string;
|
||||||
|
/** The segment(s) behind this router. Several share one when they share a gateway. */
|
||||||
|
inside: string[];
|
||||||
|
/** The segment this router reaches out through. */
|
||||||
|
outside: string;
|
||||||
|
/** Addresses this router holds on the outside segment — what the world sees. */
|
||||||
|
outsideAddresses: string[];
|
||||||
|
nat: Family[];
|
||||||
|
forwardable: boolean;
|
||||||
|
mappingTtl: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Group segments by the gateway they declare. Identical gateway declarations mean ONE
|
||||||
|
* router, not several — that is what a VLAN-capable router is, and two routers sharing an
|
||||||
|
* external address would not work anyway.
|
||||||
|
*/
|
||||||
|
/**
|
||||||
|
* Gateways that share an address are ONE gateway.
|
||||||
|
*
|
||||||
|
* Grouping on the exact address list instead split a household in two: `home` declaring a
|
||||||
|
* v4 and a v6 address and `devices` declaring only the v4 produced two router containers,
|
||||||
|
* both holding the same v4 address on the same segment. The lab raised it, and the shared
|
||||||
|
* address resolved to whichever container answered ARP last — so a published port worked or
|
||||||
|
* did not, run to run, with nothing reporting a fault.
|
||||||
|
*
|
||||||
|
* One public address is one box. Checked against the real thing this models: a bridged
|
||||||
|
* modem, a single gateway holding the public address, everything behind it on one network.
|
||||||
|
* Two routers on one address is not a topology, it is a collision.
|
||||||
|
*/
|
||||||
|
export function planRouters(scenario: Scenario, instanceId: string): RouterPlan[] {
|
||||||
|
const plans: RouterPlan[] = [];
|
||||||
|
|
||||||
|
for (const [segmentName, segment] of Object.entries(scenario.segments)) {
|
||||||
|
const gateway = segment.gateway;
|
||||||
|
if (!gateway) continue;
|
||||||
|
|
||||||
|
const existing = plans.find(
|
||||||
|
(plan) =>
|
||||||
|
plan.outside === gateway.to &&
|
||||||
|
plan.outsideAddresses.some((address) => gateway.address.includes(address)),
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
existing.inside.push(segmentName);
|
||||||
|
// The union, so a gateway declared with a v6 address on only one of the segments it
|
||||||
|
// serves still carries it. The declarations must otherwise agree — validate refuses
|
||||||
|
// the case where they do not, so there is nothing to reconcile here.
|
||||||
|
for (const address of gateway.address) {
|
||||||
|
if (!existing.outsideAddresses.includes(address)) existing.outsideAddresses.push(address);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
plans.push({
|
||||||
|
name: `mlab-${instanceId}-gw${plans.length}`,
|
||||||
|
inside: [segmentName],
|
||||||
|
outside: gateway.to,
|
||||||
|
outsideAddresses: [...gateway.address],
|
||||||
|
nat: gateway.nat,
|
||||||
|
forwardable: gateway.forwardable,
|
||||||
|
mappingTtl: gateway.mappingTtl,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return plans;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "120s" / "2m" / "90" → seconds. */
|
||||||
|
export function ttlSeconds(text: string | undefined): number | undefined {
|
||||||
|
if (!text) return undefined;
|
||||||
|
const match = /^(\d+)\s*([smh]?)$/.exec(text.trim());
|
||||||
|
if (!match) return undefined;
|
||||||
|
const value = Number(match[1]);
|
||||||
|
return match[2] === "m" ? value * 60 : match[2] === "h" ? value * 3600 : value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function withPrefix(scenario: Scenario, segment: string, address: string): string {
|
||||||
|
const wantV6 = address.includes(":");
|
||||||
|
for (const range of scenario.segments[segment]?.cidr ?? []) {
|
||||||
|
const slash = range.lastIndexOf("/");
|
||||||
|
if (slash === -1) continue;
|
||||||
|
if (range.slice(0, slash).includes(":") === wantV6) return `${address}${range.slice(slash)}`;
|
||||||
|
}
|
||||||
|
return address;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The address a machine holds on a segment, for DNAT targets and as an inside gateway. */
|
||||||
|
function addressOn(scenario: Scenario, machine: string, segment: string, family: Family): string | null {
|
||||||
|
const spec = scenario.machines[machine];
|
||||||
|
if (!spec || spec.at === "detached") return null;
|
||||||
|
for (const attachment of spec.at) {
|
||||||
|
if (attachment.segment !== segment) continue;
|
||||||
|
for (const address of attachment.address) {
|
||||||
|
if ((family === "v6") === address.includes(":")) return address;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The router's own address on an inside segment: the first host address of that range.
|
||||||
|
*
|
||||||
|
* Chosen rather than declared because a scenario has nothing to say about it — the
|
||||||
|
* declaration describes what the world sees the network as, and the inside address is an
|
||||||
|
* implementation detail of the machine serving it.
|
||||||
|
*/
|
||||||
|
function insideAddress(scenario: Scenario, segment: string, family: Family): string | null {
|
||||||
|
for (const range of scenario.segments[segment]?.cidr ?? []) {
|
||||||
|
const slash = range.lastIndexOf("/");
|
||||||
|
if (slash === -1) continue;
|
||||||
|
const base = range.slice(0, slash);
|
||||||
|
const isV6 = base.includes(":");
|
||||||
|
if (isV6 !== (family === "v6")) continue;
|
||||||
|
if (isV6) return `${base.replace(/::$/, "::")}1${range.slice(slash)}`.replace("::1/", "::1/");
|
||||||
|
const octets = base.split(".");
|
||||||
|
octets[3] = "1";
|
||||||
|
return `${octets.join(".")}${range.slice(slash)}`;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wire the public segments together.
|
||||||
|
*
|
||||||
|
* The internet is not a network — it is unrelated networks that route to each other, many
|
||||||
|
* hops apart with no shared broadcast domain. So public segments are separate links joined
|
||||||
|
* by a router, never bridged: bridging them would make ARP adjacency, non-decrementing TTL
|
||||||
|
* and crossing multicast true in the lab and false in production, and the mesh has already
|
||||||
|
* been bitten by multicast name resolution.
|
||||||
|
*
|
||||||
|
* One transit router, an interface on every public segment, forwarding and no translation.
|
||||||
|
* It is the closest thing the lab has to "the internet", and it is deliberately dumb.
|
||||||
|
*/
|
||||||
|
export async function raiseTransit(
|
||||||
|
scenario: Scenario,
|
||||||
|
instanceId: string,
|
||||||
|
log: (message: string) => void = () => {},
|
||||||
|
): Promise<string | null> {
|
||||||
|
const publicSegments = Object.entries(scenario.segments)
|
||||||
|
.filter(([, segment]) => segment.kind === "public")
|
||||||
|
.map(([name]) => name);
|
||||||
|
|
||||||
|
// One public network needs no transit: everything on it is already adjacent.
|
||||||
|
if (publicSegments.length < 2) return null;
|
||||||
|
|
||||||
|
const name = `mlab-${instanceId}-transit`;
|
||||||
|
if (!(await succeeds(["config", "show", name], 15_000))) {
|
||||||
|
await incus([
|
||||||
|
"init", ROUTER_IMAGE, name,
|
||||||
|
"-c", `user.mesh-lab.instance=${instanceId}`,
|
||||||
|
"-c", "user.mesh-lab.machine=transit",
|
||||||
|
"-c", `user.mesh-lab.transit=${publicSegments.join(",")}`,
|
||||||
|
], 300_000);
|
||||||
|
await succeeds(["config", "device", "remove", name, "eth0"], 15_000);
|
||||||
|
for (const [index, segment] of publicSegments.entries()) {
|
||||||
|
await incus([
|
||||||
|
"config", "device", "add", name, `eth${index}`, "nic",
|
||||||
|
"nictype=bridged",
|
||||||
|
`parent=${networkName(instanceId, segment)}`,
|
||||||
|
`hwaddr=${macFor(instanceId, "transit", index)}`,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await succeeds(["start", name], 60_000);
|
||||||
|
await waitUntilUsable(name, 120, () => {});
|
||||||
|
|
||||||
|
for (const [index, segment] of publicSegments.entries()) {
|
||||||
|
const device = `eth${index}`;
|
||||||
|
await sh(name, `ip link set ${device} up`);
|
||||||
|
for (const cidr of scenario.segments[segment]?.cidr ?? []) {
|
||||||
|
const address = transitAddress(cidr);
|
||||||
|
if (address) await sh(name, `ip addr replace ${address} dev ${device}`);
|
||||||
|
}
|
||||||
|
const mtu = scenario.segments[segment]?.mtu;
|
||||||
|
if (mtu) await sh(name, `ip link set ${device} mtu ${mtu}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await sh(
|
||||||
|
name,
|
||||||
|
"sysctl -w net.ipv4.ip_forward=1 >/dev/null; sysctl -w net.ipv6.conf.all.forwarding=1 >/dev/null",
|
||||||
|
);
|
||||||
|
log(` transit router across ${publicSegments.join(", ")}`);
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function raiseRouters(
|
||||||
|
scenario: Scenario,
|
||||||
|
instanceId: string,
|
||||||
|
plans: RouterPlan[],
|
||||||
|
log: (message: string) => void = () => {},
|
||||||
|
): Promise<string[]> {
|
||||||
|
const created: string[] = [];
|
||||||
|
|
||||||
|
if (plans.length > 0) await ensureRouterImage(log);
|
||||||
|
|
||||||
|
for (const plan of plans) {
|
||||||
|
if (!(await succeeds(["config", "show", plan.name], 15_000))) {
|
||||||
|
await incus([
|
||||||
|
"init", ROUTER_IMAGE, plan.name,
|
||||||
|
"-c", `user.mesh-lab.instance=${instanceId}`,
|
||||||
|
// Tagged as a machine as well as a router: destroy finds an instance's resources
|
||||||
|
// with one query, and a router that only carried `router=` was left behind — which
|
||||||
|
// then held its networks open, so `destroy` reported removing zero segments.
|
||||||
|
"-c", `user.mesh-lab.machine=${routerMachineName(plan)}`,
|
||||||
|
"-c", `user.mesh-lab.router=${plan.inside.join(",")}`,
|
||||||
|
// `outside` is structural — it is which link eth0 is on, true the moment the device
|
||||||
|
// is added. The gateway's *behaviour* is not recorded here; see configureRouter.
|
||||||
|
"-c", `user.mesh-lab.outside=${plan.outside}`,
|
||||||
|
], 300_000);
|
||||||
|
await succeeds(["config", "device", "remove", plan.name, "eth0"], 15_000);
|
||||||
|
|
||||||
|
// eth0 faces outward, then one interface per segment behind it.
|
||||||
|
const links = [plan.outside, ...plan.inside];
|
||||||
|
for (const [index, segment] of links.entries()) {
|
||||||
|
await incus([
|
||||||
|
"config", "device", "add", plan.name, `eth${index}`, "nic",
|
||||||
|
"nictype=bridged",
|
||||||
|
`parent=${networkName(instanceId, segment)}`,
|
||||||
|
`hwaddr=${macFor(instanceId, `gw-${plan.name}`, index)}`,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await succeeds(["start", plan.name], 60_000);
|
||||||
|
created.push(plan.name);
|
||||||
|
log(` router ${plan.inside.join("+")} → ${plan.outside}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const name of created) {
|
||||||
|
await waitUntilUsable(name, 120, () => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const plan of plans) {
|
||||||
|
await configureRouter(scenario, plan, log);
|
||||||
|
}
|
||||||
|
|
||||||
|
return created;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sh(name: string, script: string, timeoutMs = 60_000): Promise<void> {
|
||||||
|
await incus(["exec", name, "--", "sh", "-c", script], timeoutMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function configureRouter(
|
||||||
|
scenario: Scenario,
|
||||||
|
plan: RouterPlan,
|
||||||
|
log: (message: string) => void,
|
||||||
|
): Promise<void> {
|
||||||
|
// Addresses: eth0 outside, then one per inside segment.
|
||||||
|
const links: { device: string; segment: string; addresses: string[] }[] = [
|
||||||
|
{
|
||||||
|
device: "eth0",
|
||||||
|
segment: plan.outside,
|
||||||
|
addresses: plan.outsideAddresses.map((a) => withPrefix(scenario, plan.outside, a)),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
for (const [index, segment] of plan.inside.entries()) {
|
||||||
|
const addresses: string[] = [];
|
||||||
|
for (const family of ["v4", "v6"] as Family[]) {
|
||||||
|
const address = insideAddress(scenario, segment, family);
|
||||||
|
if (address) addresses.push(address);
|
||||||
|
}
|
||||||
|
links.push({ device: `eth${index + 1}`, segment, addresses });
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const link of links) {
|
||||||
|
// Up first: an address on a down interface is accepted and then not used.
|
||||||
|
await sh(plan.name, `ip link set ${link.device} up`);
|
||||||
|
for (const address of link.addresses) {
|
||||||
|
// `replace` rather than `add`, so re-running is safe and a real failure still fails.
|
||||||
|
await sh(plan.name, `ip addr replace ${address} dev ${link.device}`);
|
||||||
|
}
|
||||||
|
const mtu = scenario.segments[link.segment]?.mtu;
|
||||||
|
if (mtu) await sh(plan.name, `ip link set ${link.device} mtu ${mtu}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await sh(
|
||||||
|
plan.name,
|
||||||
|
"sysctl -w net.ipv4.ip_forward=1 >/dev/null; sysctl -w net.ipv6.conf.all.forwarding=1 >/dev/null",
|
||||||
|
);
|
||||||
|
|
||||||
|
// A gateway reaches other public networks the way anything does: through transit. Without
|
||||||
|
// this it can only reach its own outside segment, and every scenario with more than one
|
||||||
|
// public network becomes a set of islands.
|
||||||
|
for (const cidr of scenario.segments[plan.outside]?.cidr ?? []) {
|
||||||
|
const via = transitAddress(cidr);
|
||||||
|
if (!via) continue;
|
||||||
|
const gateway = via.slice(0, via.lastIndexOf("/"));
|
||||||
|
const family = gateway.includes(":") ? "-6" : "-4";
|
||||||
|
await sh(plan.name, `ip ${family} route replace default via ${gateway} dev eth0 2>/dev/null || true`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ttl = ttlSeconds(plan.mappingTtl);
|
||||||
|
if (ttl !== undefined) {
|
||||||
|
// What makes keepalive behaviour testable rather than hoped for: a connection held
|
||||||
|
// through NAT without refreshing dies when the mapping does.
|
||||||
|
//
|
||||||
|
// Read back rather than assumed. These sysctls are not present on every kernel, and a
|
||||||
|
// scenario that declared an expiring mapping and silently got a permanent one would be
|
||||||
|
// the fault this lab exists to catch.
|
||||||
|
await sh(
|
||||||
|
plan.name,
|
||||||
|
`sysctl -w net.netfilter.nf_conntrack_udp_timeout=${ttl} >/dev/null 2>&1; ` +
|
||||||
|
`sysctl -w net.netfilter.nf_conntrack_tcp_timeout_established=${ttl} >/dev/null 2>&1; true`,
|
||||||
|
);
|
||||||
|
const readback = await incus(
|
||||||
|
["exec", plan.name, "--", "sh", "-c",
|
||||||
|
"cat /proc/sys/net/netfilter/nf_conntrack_udp_timeout 2>/dev/null || echo missing"],
|
||||||
|
20_000,
|
||||||
|
);
|
||||||
|
if (readback.stdout.trim() !== String(ttl)) {
|
||||||
|
throw new Error(
|
||||||
|
`${plan.name}: mapping_ttl of ${plan.mappingTtl} was declared but conntrack reports ` +
|
||||||
|
`'${readback.stdout.trim()}'. The scenario would silently have permanent mappings.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await applyRules(scenario, plan);
|
||||||
|
|
||||||
|
// Recorded last, and only here. Everything above either read itself back or threw, so a
|
||||||
|
// gateway carrying these tags is one that demonstrably does these things. Written at
|
||||||
|
// `init` they would have been a restatement of the request — and a raise that failed
|
||||||
|
// half way leaves its wreckage standing on purpose, so a picture of that wreckage would
|
||||||
|
// have badged translation the router was never configured to do.
|
||||||
|
const recorded = [
|
||||||
|
`user.mesh-lab.nat=${plan.nat.join(",")}`,
|
||||||
|
`user.mesh-lab.forwardable=${plan.forwardable}`,
|
||||||
|
...(ttl === undefined ? [] : [`user.mesh-lab.mapping-ttl=${ttl}`]),
|
||||||
|
];
|
||||||
|
for (const entry of recorded) {
|
||||||
|
const at = entry.indexOf("=");
|
||||||
|
await succeeds(["config", "set", plan.name, entry.slice(0, at), entry.slice(at + 1)], 20_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
log(` ${plan.name}: nat=${plan.nat.join(",") || "none"} forwardable=${plan.forwardable}${ttl ? ` ttl=${ttl}s` : ""}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One ruleset per router, written whole — partial rule edits drift, a whole file does not. */
|
||||||
|
async function applyRules(scenario: Scenario, plan: RouterPlan): Promise<void> {
|
||||||
|
const parts: string[] = ["flush ruleset"];
|
||||||
|
|
||||||
|
for (const family of plan.nat) {
|
||||||
|
const table = family === "v4" ? "ip" : "ip6";
|
||||||
|
parts.push(
|
||||||
|
`table ${table} nat {`,
|
||||||
|
` chain postrouting { type nat hook postrouting priority srcnat; policy accept;`,
|
||||||
|
` oifname "eth0" masquerade`,
|
||||||
|
` }`,
|
||||||
|
` chain prerouting { type nat hook prerouting priority dstnat; policy accept;`,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (plan.forwardable) {
|
||||||
|
for (const [machine, spec] of Object.entries(scenario.machines)) {
|
||||||
|
for (const publication of spec.published ?? []) {
|
||||||
|
if (!plan.inside.includes(publication.on)) continue;
|
||||||
|
const target = addressOn(scenario, machine, publication.on, family);
|
||||||
|
if (!target) continue;
|
||||||
|
const destination = family === "v6" ? `[${target}]` : target;
|
||||||
|
parts.push(
|
||||||
|
` iifname "eth0" tcp dport ${publication.port} dnat to ${destination}:${publication.port}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
parts.push(` }`, `}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filtering: unsolicited inbound, and policy between segments the router serves.
|
||||||
|
const filterLines: string[] = [];
|
||||||
|
if (!plan.forwardable) {
|
||||||
|
// A gateway you do not control: outbound works, nothing initiates inward. That is the
|
||||||
|
// constraint being reproduced, not an implementation limit.
|
||||||
|
filterLines.push(` iifname "eth0" ct state new drop`);
|
||||||
|
}
|
||||||
|
for (const rule of scenario.policy ?? []) {
|
||||||
|
if (rule.allow) continue;
|
||||||
|
const fromIndex = plan.inside.indexOf(rule.from);
|
||||||
|
const toIndex = plan.inside.indexOf(rule.to);
|
||||||
|
if (fromIndex === -1 || toIndex === -1) continue;
|
||||||
|
filterLines.push(` iifname "eth${fromIndex + 1}" oifname "eth${toIndex + 1}" drop`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filterLines.length > 0) {
|
||||||
|
parts.push(
|
||||||
|
`table inet filter {`,
|
||||||
|
` chain forward { type filter hook forward priority filter; policy accept;`,
|
||||||
|
` ct state established,related accept`,
|
||||||
|
...filterLines,
|
||||||
|
` }`,
|
||||||
|
`}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ruleset = parts.join("\n");
|
||||||
|
await sh(
|
||||||
|
plan.name,
|
||||||
|
`cat > /tmp/mlab.nft <<'MLABNFT'\n${ruleset}\nMLABNFT\nnft -f /tmp/mlab.nft`,
|
||||||
|
60_000,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* What this lab can actually materialise, and a refusal for everything else.
|
||||||
|
*
|
||||||
|
* A declaration the runtime silently ignores is the worst thing this project could ship.
|
||||||
|
* The mesh already has that fault catalogued: a firewall key declared in five manifests
|
||||||
|
* and read by no code, so a manifest appears to restrict a port and restricts nothing
|
||||||
|
* (novox/hq 04-ISSUES/003). A scenario that declares a gateway and raises without one
|
||||||
|
* would be the same fault, in the tool built to catch it.
|
||||||
|
*
|
||||||
|
* So unimplemented parts of the model are refused loudly at raise time rather than
|
||||||
|
* ignored. The declaration schema deliberately runs ahead of the runtime — it is the
|
||||||
|
* design's shape, and validating against it is useful before any of it can be raised —
|
||||||
|
* but the gap between the two has to be visible.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Scenario } from "../declaration/types.ts";
|
||||||
|
import { PLACEABLE, planPlacements } from "./place.ts";
|
||||||
|
|
||||||
|
export class UnsupportedError extends Error {
|
||||||
|
readonly missing: string[];
|
||||||
|
|
||||||
|
constructor(missing: string[]) {
|
||||||
|
super(
|
||||||
|
`this scenario declares things the lab cannot yet materialise:\n - ${missing.join("\n - ")}\n\n` +
|
||||||
|
`Raising it would produce a mesh that silently lacks them, which is the exact fault ` +
|
||||||
|
`this lab exists to catch. Validation accepts them because the declaration model is ` +
|
||||||
|
`complete; the runtime is not.`,
|
||||||
|
);
|
||||||
|
this.name = "UnsupportedError";
|
||||||
|
this.missing = missing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertSupported(scenario: Scenario): void {
|
||||||
|
const missing: string[] = [];
|
||||||
|
|
||||||
|
// `place: [host]` works. Everything else in the vocabulary is named individually rather
|
||||||
|
// than refused as a whole, so a scenario that places a host and a substrate is told exactly
|
||||||
|
// which half the lab cannot do.
|
||||||
|
const unplaceable = new Set<string>();
|
||||||
|
for (const { artifacts } of planPlacements(scenario)) {
|
||||||
|
for (const artifact of artifacts) {
|
||||||
|
if (!(PLACEABLE as readonly string[]).includes(artifact)) unplaceable.add(artifact);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const artifact of [...unplaceable].sort()) {
|
||||||
|
missing.push(
|
||||||
|
`place: ${artifact} — only ${PLACEABLE.join(", ")} can be placed; the tiers above ` +
|
||||||
|
`tier 0 do not exist yet`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (missing.length > 0) throw new UnsupportedError(missing);
|
||||||
|
}
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { loadScenario } from "../src/declaration/parse.ts";
|
||||||
|
import { diagramFromDeclaration } from "../src/diagram/from-declaration.ts";
|
||||||
|
import { toDrawio } from "../src/diagram/drawio.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A generated diagram that will not open is worse than no diagram — it looks like a
|
||||||
|
* deliverable and is not one. The first version was unparseable because HTML labels were
|
||||||
|
* concatenated raw into an XML attribute, so these assert the file itself.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function parseCells(xml: string): { id: string; vertex: boolean; edge: boolean; source?: string; target?: string }[] {
|
||||||
|
const cells: ReturnType<typeof parseCells> = [];
|
||||||
|
// <object> wrappers carry the id for any cell with a tooltip; the mxCell inside has none.
|
||||||
|
for (const match of xml.matchAll(/<object ([^>]*?)>/g)) {
|
||||||
|
const id = /id="([^"]*)"/.exec(match[1] ?? "")?.[1];
|
||||||
|
if (id) cells.push({ id, vertex: true, edge: false });
|
||||||
|
}
|
||||||
|
for (const match of xml.matchAll(/<mxCell ([^>]*?)(?:\/>|>)/g)) {
|
||||||
|
const attrs = match[1] ?? "";
|
||||||
|
const get = (name: string) => new RegExp(`${name}="([^"]*)"`).exec(attrs)?.[1];
|
||||||
|
const id = get("id");
|
||||||
|
if (!id) continue;
|
||||||
|
const entry: (typeof cells)[number] = {
|
||||||
|
id,
|
||||||
|
vertex: get("vertex") === "1",
|
||||||
|
edge: get("edge") === "1",
|
||||||
|
};
|
||||||
|
const source = get("source");
|
||||||
|
const target = get("target");
|
||||||
|
if (source) entry.source = source;
|
||||||
|
if (target) entry.target = target;
|
||||||
|
cells.push(entry);
|
||||||
|
}
|
||||||
|
return cells;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scenario = loadScenario("scenarios/the-ordinary-shape.yml");
|
||||||
|
const xml = toDrawio(diagramFromDeclaration(scenario));
|
||||||
|
|
||||||
|
test("the file is well-formed XML — raw markup in an attribute is not", () => {
|
||||||
|
// No unescaped angle bracket may appear inside a value="..." attribute.
|
||||||
|
for (const match of xml.matchAll(/(?:value|label|tooltip)="([^"]*)"/g)) {
|
||||||
|
assert.doesNotMatch(match[1] ?? "", /[<>]/, "a label carries raw markup into an attribute");
|
||||||
|
}
|
||||||
|
assert.match(xml, /^<mxfile /);
|
||||||
|
assert.match(xml, /<\/mxfile>\s*$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("every edge connects two cells that exist", () => {
|
||||||
|
const cells = parseCells(xml);
|
||||||
|
const ids = new Set(cells.map((c) => c.id));
|
||||||
|
for (const edge of cells.filter((c) => c.edge)) {
|
||||||
|
assert.ok(ids.has(edge.source ?? ""), `edge ${edge.id} has no source`);
|
||||||
|
assert.ok(ids.has(edge.target ?? ""), `edge ${edge.id} has no target`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the diagram draws the implicit routers, not only what is written down", () => {
|
||||||
|
// A scenario never names its gateways. A picture that showed only declared machines
|
||||||
|
// would omit every node carrying NAT, forwarding and expiry.
|
||||||
|
const diagram = diagramFromDeclaration(scenario);
|
||||||
|
assert.ok(diagram.machines.some((m) => m.kind === "router"), "no router drawn");
|
||||||
|
assert.ok(diagram.machines.some((m) => m.kind === "transit"), "no transit drawn");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a router's badges say what the declaration decided", () => {
|
||||||
|
const diagram = diagramFromDeclaration(scenario);
|
||||||
|
const unforwardable = diagram.machines.find((m) => m.notes.some((n) => n.includes("NOT forwardable")));
|
||||||
|
assert.ok(unforwardable, "the unforwardable gateway is not marked as such");
|
||||||
|
assert.ok(
|
||||||
|
diagram.machines.some((m) => m.notes.some((n) => n.includes("mappings expire"))),
|
||||||
|
"a declared mapping expiry is not shown",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a metadata fact becomes a badge, and absence of the fact does not", () => {
|
||||||
|
// The point of the badges: the properties worth seeing are the ones with no visual
|
||||||
|
// consequence. A translated address looks exactly like an untranslated one.
|
||||||
|
for (const tip of ["translates v4", "no port forwarding", "mappings expire"]) {
|
||||||
|
assert.ok(xml.includes(tip), `no badge explains '${tip}'`);
|
||||||
|
}
|
||||||
|
// A gateway that does not translate gets no mark, rather than a struck-through one.
|
||||||
|
assert.doesNotMatch(xml, /label="N"[^>]*tooltip="[^"]*no NAT/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("every badge says in words what its letter means", () => {
|
||||||
|
// A one-letter code with no tooltip is a private language. Each badge is wrapped in an
|
||||||
|
// <object>, which is the only place draw.io reads a tooltip from.
|
||||||
|
const badges = [...xml.matchAll(/<object [^>]*label="([A-Z▶■]{1,2})"[^>]*tooltip="([^"]*)"/g)];
|
||||||
|
assert.ok(badges.length > 0, "no badges rendered at all");
|
||||||
|
for (const [, code, tip] of badges) {
|
||||||
|
assert.ok((tip ?? "").length > 10, `badge ${code} has no explanation`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no two cells share an id — a machine may be named after a gateway", () => {
|
||||||
|
const ids = parseCells(xml).map((c) => c.id);
|
||||||
|
assert.equal(new Set(ids).size, ids.length, "duplicate cell id");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("segments are ordered public first, then by depth behind them", () => {
|
||||||
|
const diagram = diagramFromDeclaration(scenario);
|
||||||
|
const publicDepths = diagram.segments.filter((s) => s.kind === "public").map((s) => s.depth);
|
||||||
|
assert.deepEqual([...new Set(publicDepths)], [0], "a public segment should be at depth 0");
|
||||||
|
const home = diagram.segments.find((s) => s.name === "home");
|
||||||
|
assert.equal(home?.depth, 1, "a segment behind one gateway is at depth 1");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a declared address appears on the machine that holds it", () => {
|
||||||
|
assert.match(xml, /192\.168\.1\.135/);
|
||||||
|
assert.match(xml, /198\.51\.100\.7/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("every shape names a stencil that exists", () => {
|
||||||
|
// A style naming a stencil draw.io does not have renders as an empty box — no error, no
|
||||||
|
// warning, just a missing picture. Checked against the names in draw.io's own
|
||||||
|
// stencils/networks.xml, which is where mxgraph.networks.* is defined.
|
||||||
|
const KNOWN = new Set([
|
||||||
|
"mxgraph.networks.server",
|
||||||
|
"mxgraph.networks.router",
|
||||||
|
"mxgraph.networks.cloud",
|
||||||
|
"mxgraph.networks.firewall",
|
||||||
|
"mxgraph.networks.switch",
|
||||||
|
"mxgraph.networks.pc",
|
||||||
|
"mxgraph.networks.laptop",
|
||||||
|
"mxgraph.networks.storage",
|
||||||
|
"mxgraph.networks.modem",
|
||||||
|
"mxgraph.networks.mainframe",
|
||||||
|
]);
|
||||||
|
const used = new Set([...xml.matchAll(/shape=([a-z0-9_.]+)/g)].map((m) => m[1] as string));
|
||||||
|
assert.ok(used.size > 0, "no stencil shapes used at all");
|
||||||
|
for (const shape of used) {
|
||||||
|
assert.ok(KNOWN.has(shape), `'${shape}' is not a stencil draw.io ships`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a resource's shape is fixed by kind, and never varies with its metadata", () => {
|
||||||
|
// The split the whole design rests on: shape says what a thing is, badges say what is
|
||||||
|
// true about it. A gateway that stops translating must still look like a gateway.
|
||||||
|
const routers = [...xml.matchAll(/shape=mxgraph\.networks\.router/g)].length;
|
||||||
|
const diagram = diagramFromDeclaration(scenario);
|
||||||
|
assert.equal(routers, diagram.machines.filter((m) => m.kind === "router").length);
|
||||||
|
assert.equal(
|
||||||
|
[...xml.matchAll(/shape=mxgraph\.networks\.server/g)].length,
|
||||||
|
diagram.machines.filter((m) => m.kind === "machine").length,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no link crosses a network it does not touch", () => {
|
||||||
|
// The layout fault that made the first drawings unreadable: a gateway placed below the
|
||||||
|
// lane it FACES, with its link to the outside running the height of the picture through
|
||||||
|
// three networks it has nothing to do with — and overlapping another such link, so the
|
||||||
|
// two read as one wire. Every link must now be short and local.
|
||||||
|
const geometry = new Map<string, { y: number; h: number }>();
|
||||||
|
for (const match of xml.matchAll(
|
||||||
|
/<(?:mxCell|object)[^>]*id="([^"]*)"[\s\S]{0,400}?<mxGeometry x="[^"]*" y="([^"]*)" width="[^"]*" height="([^"]*)"/g,
|
||||||
|
)) {
|
||||||
|
geometry.set(match[1] as string, { y: Number(match[2]), h: Number(match[3]) });
|
||||||
|
}
|
||||||
|
|
||||||
|
const lanes = [...geometry.entries()]
|
||||||
|
.filter(([id]) => id.startsWith("lane-"))
|
||||||
|
.map(([id, g]) => ({ name: id.slice(5), top: g.y, bottom: g.y + g.h }));
|
||||||
|
assert.ok(lanes.length > 0, "no lanes found");
|
||||||
|
|
||||||
|
for (const edge of xml.matchAll(/<mxCell id="([^"]*)"[^>]*edge="1"[^>]*source="([^"]*)" target="([^"]*)"/g)) {
|
||||||
|
const [, , source, target] = edge;
|
||||||
|
const from = geometry.get(source as string);
|
||||||
|
const lane = lanes.find((l) => `lane-${l.name}` === target);
|
||||||
|
if (!from || !lane) continue;
|
||||||
|
|
||||||
|
const span = { top: Math.min(from.y, lane.top), bottom: Math.max(from.y + from.h, lane.bottom) };
|
||||||
|
const attached = new Set([target, source]);
|
||||||
|
for (const other of lanes) {
|
||||||
|
if (attached.has(`lane-${other.name}`)) continue;
|
||||||
|
const crosses = other.top >= span.top && other.bottom <= span.bottom;
|
||||||
|
assert.ok(!crosses, `link ${source}→${target} crosses '${other.name}'`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a network behind another is drawn inside it, not merely below it", () => {
|
||||||
|
// "Behind" is shown by indentation. Without it the reader has only a wire to follow, and
|
||||||
|
// in a layered scenario that wire is exactly what became unreadable.
|
||||||
|
const laneX = new Map<string, number>();
|
||||||
|
for (const match of xml.matchAll(
|
||||||
|
/<mxCell id="lane-([^"]*)"[\s\S]{0,400}?<mxGeometry x="([^"]*)"/g,
|
||||||
|
)) {
|
||||||
|
laneX.set(match[1] as string, Number(match[2]));
|
||||||
|
}
|
||||||
|
const diagram = diagramFromDeclaration(scenario);
|
||||||
|
for (const segment of diagram.segments) {
|
||||||
|
if (!segment.behind) continue;
|
||||||
|
const mine = laneX.get(segment.name);
|
||||||
|
const parent = laneX.get(segment.behind);
|
||||||
|
assert.ok(mine !== undefined && parent !== undefined, `${segment.name} or its parent is missing`);
|
||||||
|
assert.ok(mine > parent, `${segment.name} is not indented inside ${segment.behind}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a gateway sits immediately above the network it serves", () => {
|
||||||
|
// What the grouping guarantees, and the whole reason a gateway reads as the door into a
|
||||||
|
// network rather than a box floating near one.
|
||||||
|
//
|
||||||
|
// It does NOT guarantee adjacency to the network the gateway FACES: a public network with
|
||||||
|
// two private networks behind it can only put one of them next to it. That case is carried
|
||||||
|
// by the lane's own "behind …" and by both children being indented to the same depth —
|
||||||
|
// and the link that would otherwise cross the sibling is suppressed, which is what the
|
||||||
|
// crossing test above asserts.
|
||||||
|
const diagram = diagramFromDeclaration(scenario);
|
||||||
|
const order = [...xml.matchAll(/<mxCell id="lane-([^"]*)"/g)].map((m) => m[1] as string);
|
||||||
|
let checked = 0;
|
||||||
|
for (const machine of diagram.machines) {
|
||||||
|
if (machine.kind !== "router") continue;
|
||||||
|
const served = machine.attachments.slice(1).map((a) => a.segment);
|
||||||
|
const top = served.reduce((b, n) => (order.indexOf(n) < order.indexOf(b) ? n : b), served[0] ?? "");
|
||||||
|
assert.ok(order.includes(top), `${machine.name} serves '${top}', which is not drawn`);
|
||||||
|
checked++;
|
||||||
|
}
|
||||||
|
assert.ok(checked > 0, "no gateways to check");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a public network is followed by everything behind it, before the next public one", () => {
|
||||||
|
// The ordering rule itself. Sorting by depth alone put a private network far from the
|
||||||
|
// public one it sits behind, which is what made the links long in the first place.
|
||||||
|
const diagram = diagramFromDeclaration(scenario);
|
||||||
|
const order = [...xml.matchAll(/<mxCell id="lane-([^"]*)"/g)].map((m) => m[1] as string);
|
||||||
|
const rootOf = (name: string): string => {
|
||||||
|
let current = name;
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (;;) {
|
||||||
|
const segment = diagram.segments.find((s) => s.name === current);
|
||||||
|
if (!segment?.behind || seen.has(current)) return current;
|
||||||
|
seen.add(current);
|
||||||
|
current = segment.behind;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// Reading down the page, the root never returns to one already left behind.
|
||||||
|
const finished = new Set<string>();
|
||||||
|
let previous = "";
|
||||||
|
for (const name of order) {
|
||||||
|
const root = rootOf(name);
|
||||||
|
if (root !== previous) {
|
||||||
|
assert.ok(!finished.has(root), `'${root}' is split apart by another group`);
|
||||||
|
if (previous) finished.add(previous);
|
||||||
|
previous = root;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("both sources lay the same topology out identically", () => {
|
||||||
|
// The comparison is the feature. The hypervisor cannot know declaration order, so laying
|
||||||
|
// out by it would give the two pictures different shapes and nothing could be read off
|
||||||
|
// the difference. Proved by shuffling the segments and checking the layout does not move.
|
||||||
|
const shuffled = {
|
||||||
|
...diagramFromDeclaration(scenario),
|
||||||
|
segments: [...diagramFromDeclaration(scenario).segments].reverse(),
|
||||||
|
};
|
||||||
|
const laneOrder = (out: string) => [...out.matchAll(/<mxCell id="lane-([^"]*)"/g)].map((m) => m[1]);
|
||||||
|
assert.deepEqual(laneOrder(toDrawio(shuffled)), laneOrder(xml));
|
||||||
|
});
|
||||||
|
|
||||||
|
const ALL_SCENARIOS = [
|
||||||
|
"bootstrap-single",
|
||||||
|
"two-on-a-segment",
|
||||||
|
"behind-nat",
|
||||||
|
"segmented-and-unforwardable",
|
||||||
|
"the-ordinary-shape",
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const name of ALL_SCENARIOS) {
|
||||||
|
test(`${name}: no box is drawn inside a network it is not on`, () => {
|
||||||
|
// A gateway landed on top of a machine in an unrelated network, because the gap was
|
||||||
|
// reserved above one sibling while the box was placed above the other. Two rules deciding
|
||||||
|
// the same thing separately; both now read one map.
|
||||||
|
const each = loadScenario(`scenarios/${name}.yml`);
|
||||||
|
const out = toDrawio(diagramFromDeclaration(each));
|
||||||
|
const boxes: { id: string; y: number; h: number; lanes: string[] }[] = [];
|
||||||
|
const lanes: { name: string; top: number; bottom: number }[] = [];
|
||||||
|
for (const match of out.matchAll(
|
||||||
|
/id="([^"]*)"[\s\S]{0,400}?<mxGeometry x="[^"]*" y="([^"]*)" width="[^"]*" height="([^"]*)"/g,
|
||||||
|
)) {
|
||||||
|
const [, id, y, h] = match;
|
||||||
|
if (id === "title" || id === "legend" || /-b\d+$|-n$/.test(id as string)) continue;
|
||||||
|
if ((id as string).startsWith("lane-")) {
|
||||||
|
lanes.push({ name: (id as string).slice(5), top: Number(y), bottom: Number(y) + Number(h) });
|
||||||
|
} else {
|
||||||
|
boxes.push({ id: id as string, y: Number(y), h: Number(h), lanes: [] });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
diagramFromDeclaration(each).machines.forEach((machine, index) => {
|
||||||
|
const box = boxes.find((b) => b.id === `m${index}`);
|
||||||
|
if (box) box.lanes = machine.attachments.map((a) => a.segment);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const box of boxes) {
|
||||||
|
for (const lane of lanes) {
|
||||||
|
if (box.lanes.includes(lane.name)) continue;
|
||||||
|
const overlaps = box.y < lane.bottom && box.y + box.h > lane.top;
|
||||||
|
assert.ok(!overlaps, `${box.id} is drawn inside '${lane.name}', which it is not on`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
/**
|
||||||
|
* Integration tests run against a real hypervisor. Mocking it is forbidden — a test that
|
||||||
|
* fakes the system under integration asserts that the fake behaves as expected, which is
|
||||||
|
* the shape of test this project exists to stop shipping (novox/hq ADR 0034).
|
||||||
|
*
|
||||||
|
* Consequence, accepted: these are slow, and they need a machine that can raise scenarios.
|
||||||
|
* They skip rather than fail where it cannot, so that a machine without a hypervisor gets
|
||||||
|
* an honest "not run" instead of a green suite that checked nothing.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { isReachable, pools, supportedDrivers } from "../../src/incus/client.ts";
|
||||||
|
import { destroy, list } from "../../src/lifecycle/operate.ts";
|
||||||
|
import { diagramFromLive } from "../../src/diagram/from-live.ts";
|
||||||
|
import { duplicateAddresses, describeConflicts, type Held } from "../../src/lifecycle/invariants.ts";
|
||||||
|
import type { Scenario } from "../../src/declaration/types.ts";
|
||||||
|
|
||||||
|
export interface Capability {
|
||||||
|
usable: boolean;
|
||||||
|
why: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Can this machine run scenarios at all? Checked once, reported honestly. */
|
||||||
|
export async function labIsUsable(): Promise<Capability> {
|
||||||
|
if (!(await isReachable())) {
|
||||||
|
return {
|
||||||
|
usable: false,
|
||||||
|
why: "the incus daemon is not reachable as this user (try MESH_LAB_INCUS='sudo -n incus')",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const drivers = await supportedDrivers();
|
||||||
|
if (!drivers.some((d) => d === "btrfs" || d === "zfs")) {
|
||||||
|
return { usable: false, why: "no copy-on-write driver — snapshots would be full copies" };
|
||||||
|
}
|
||||||
|
if (!(await pools()).some((p) => p.driver === "btrfs" || p.driver === "zfs")) {
|
||||||
|
return { usable: false, why: "no pool uses a copy-on-write driver" };
|
||||||
|
}
|
||||||
|
return { usable: true, why: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tear down anything a test left behind, whether it passed or not. */
|
||||||
|
export async function destroyAll(prefix: string): Promise<void> {
|
||||||
|
for (const instance of await list()) {
|
||||||
|
if (instance.instanceId.startsWith(prefix)) {
|
||||||
|
await destroy(instance.instanceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every address the hypervisor says is held, by which machine, on which segment.
|
||||||
|
*
|
||||||
|
* Read through the live diagram because that is already the one place that joins addresses
|
||||||
|
* to devices by MAC and devices to segments by tag. A second reader would be a second thing
|
||||||
|
* to get wrong in the same way — and the way it was wrong once, a virtual machine's
|
||||||
|
* addresses silently going missing, is exactly what these assertions would then miss.
|
||||||
|
*/
|
||||||
|
export async function heldAddresses(instanceId: string): Promise<Held[]> {
|
||||||
|
const drawn = await diagramFromLive(instanceId);
|
||||||
|
return drawn.machines.flatMap((machine) =>
|
||||||
|
machine.attachments.flatMap((attachment) =>
|
||||||
|
attachment.addresses.map((address) => ({
|
||||||
|
machine: machine.name,
|
||||||
|
segment: attachment.segment,
|
||||||
|
address,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bare(address: string): string {
|
||||||
|
const slash = address.lastIndexOf("/");
|
||||||
|
return slash === -1 ? address : address.slice(0, slash);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invariants that hold of ANY raised scenario, whatever it declares.
|
||||||
|
*
|
||||||
|
* Asserted against what actually came up, never against the declaration — the declaration
|
||||||
|
* is what was accepted, and in the fault that prompted these, it was accepted.
|
||||||
|
*/
|
||||||
|
export async function assertUniversalInvariants(
|
||||||
|
scenario: Scenario,
|
||||||
|
instanceId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const held = await heldAddresses(instanceId);
|
||||||
|
assert.ok(held.length > 0, `${scenario.scenario}: no addresses were read back at all`);
|
||||||
|
|
||||||
|
const conflicts = duplicateAddresses(held);
|
||||||
|
assert.deepEqual(
|
||||||
|
conflicts,
|
||||||
|
[],
|
||||||
|
`${scenario.scenario}: address conflict — ${describeConflicts(conflicts)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Every address the scenario declared is one the machine actually holds. A machine that
|
||||||
|
// came up bare looks identical to one that came up correctly until something asks it.
|
||||||
|
const holders = new Map<string, Set<string>>();
|
||||||
|
for (const entry of held) {
|
||||||
|
const key = `${entry.machine} ${entry.segment}`;
|
||||||
|
holders.set(key, (holders.get(key) ?? new Set<string>()).add(bare(entry.address)));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, spec] of Object.entries(scenario.machines)) {
|
||||||
|
if (spec.at === "detached") continue;
|
||||||
|
for (const attachment of spec.at) {
|
||||||
|
const actual = holders.get(`${name} ${attachment.segment}`) ?? new Set<string>();
|
||||||
|
for (const address of attachment.address) {
|
||||||
|
assert.ok(
|
||||||
|
actual.has(address),
|
||||||
|
`${scenario.scenario}: '${name}' declared ${address} on '${attachment.segment}' ` +
|
||||||
|
`but holds ${[...actual].join(", ") || "nothing"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
/**
|
||||||
|
* The lab, placing tier 0 inside a machine it raised.
|
||||||
|
*
|
||||||
|
* This is the seam that ends the lab being infrastructure with no consumer
|
||||||
|
* (novox/hq 03-DESIGN/00-as-is/11-the-lab.md). It needs a built host binary; without one it
|
||||||
|
* skips with a reason rather than passing having checked nothing.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, after } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { loadScenario } from "../../src/declaration/parse.ts";
|
||||||
|
import { raise } from "../../src/lifecycle/raise.ts";
|
||||||
|
import { destroy, exec } from "../../src/lifecycle/operate.ts";
|
||||||
|
import { hostBinaryPath, HOST_PATH } from "../../src/lifecycle/place.ts";
|
||||||
|
import { labIsUsable, destroyAll } from "./harness.ts";
|
||||||
|
|
||||||
|
const capability = await labIsUsable();
|
||||||
|
const binary = hostBinaryPath();
|
||||||
|
|
||||||
|
const skip = !capability.usable
|
||||||
|
? `lab not usable: ${capability.why}`
|
||||||
|
: !binary
|
||||||
|
? "MESH_LAB_HOST_BINARY is not set — build novox/mesh-host and point at it"
|
||||||
|
: !existsSync(binary)
|
||||||
|
? `MESH_LAB_HOST_BINARY points at ${binary}, which does not exist`
|
||||||
|
: false;
|
||||||
|
|
||||||
|
let instanceId = "";
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
if (instanceId) await destroy(instanceId);
|
||||||
|
await destroyAll("bootstrap-single-");
|
||||||
|
}, { timeout: 400_000 });
|
||||||
|
|
||||||
|
test("a raised machine contains the host", { skip, timeout: 900_000 }, async () => {
|
||||||
|
const raised = await raise(loadScenario("scenarios/bootstrap-single.yml"), {});
|
||||||
|
instanceId = raised.instanceId;
|
||||||
|
|
||||||
|
const { stdout } = await exec(instanceId, "anchor", [HOST_PATH, "version"]);
|
||||||
|
assert.ok(stdout.trim().length > 0, "the host is on the machine but does not run there");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the host reports the MACHINE, not the workstation that placed it", { skip, timeout: 120_000 }, async () => {
|
||||||
|
// The check that proves detection detects rather than reporting a constant. A raised VM and
|
||||||
|
// the workstation differ in every capability, so a host that reported the workstation's
|
||||||
|
// answers would be obvious here and invisible anywhere else.
|
||||||
|
const { stdout } = await exec(instanceId, "anchor", [HOST_PATH, "profile", "--json"]);
|
||||||
|
const profile = JSON.parse(stdout) as {
|
||||||
|
capabilities: { name: string; present: boolean; detail: string }[];
|
||||||
|
};
|
||||||
|
const by = new Map(profile.capabilities.map((c) => [c.name, c]));
|
||||||
|
|
||||||
|
// A machine raised by the lab is root and has a clean init. The workstation session is
|
||||||
|
// neither, so these are the two that would flip if the wrong machine were being read.
|
||||||
|
assert.equal(by.get("privileged")?.present, true, "a raised machine should be root");
|
||||||
|
assert.equal(by.get("service-manager")?.present, true, "a raised machine should have an init");
|
||||||
|
|
||||||
|
for (const [name, verdict] of by) {
|
||||||
|
assert.ok(verdict.detail.trim().length > 0, `${name} was reported with no reason`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ADR 0031 — the host confirms the machine carries no overlay", { skip, timeout: 120_000 }, async () => {
|
||||||
|
// The lab provides the underlay and NOTHING of the overlay. Asserted elsewhere by looking
|
||||||
|
// for wireguard interfaces; here the placed host reports it independently, which is a
|
||||||
|
// second witness rather than the same check twice.
|
||||||
|
const { stdout } = await exec(instanceId, "anchor", [HOST_PATH, "profile", "--json"]);
|
||||||
|
const profile = JSON.parse(stdout) as { capabilities: { name: string; present: boolean }[] };
|
||||||
|
const overlay = profile.capabilities.find((c) => c.name === "overlay");
|
||||||
|
|
||||||
|
assert.equal(overlay?.present, false, "a freshly raised machine already had an overlay");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the host's inventory is of the raised machine", { skip, timeout: 120_000 }, async () => {
|
||||||
|
const { stdout } = await exec(instanceId, "anchor", [HOST_PATH, "inventory", "--json"]);
|
||||||
|
const inv = JSON.parse(stdout) as {
|
||||||
|
machine: string; cpus: number; memory_kb: number; observed_at: string; unreadable?: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
assert.ok(inv.machine.length > 0, "the machine did not report a name");
|
||||||
|
assert.ok(inv.cpus > 0 && inv.memory_kb > 0, "the machine reported no cpus or no memory");
|
||||||
|
assert.deepEqual(inv.unreadable ?? [], [], "something could not be read on a machine we raised");
|
||||||
|
|
||||||
|
// The scenario gives each machine 1GiB and 2 cpus. A host reporting the workstation's
|
||||||
|
// 24 cpus would pass every check above.
|
||||||
|
assert.ok(inv.cpus <= 4, `reported ${inv.cpus} cpus — that is not the raised machine`);
|
||||||
|
});
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* Every scenario, raised for real, checked against the invariants that hold of all of them.
|
||||||
|
*
|
||||||
|
* The suite next door raises one scenario and asks deep questions of it. This one asks
|
||||||
|
* shallow questions of every scenario, which is the half that was missing: both faults found
|
||||||
|
* by hand so far — two gateways holding one address, and a gateway drawn across an unrelated
|
||||||
|
* network — lived in scenarios nothing ever built.
|
||||||
|
*
|
||||||
|
* The list grows. Each entry costs a boot, so it is added deliberately rather than by
|
||||||
|
* globbing the directory: a scenario that is expensive and adds no new shape is not worth
|
||||||
|
* the wall clock, and one that is cheap and adds a shape is.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, after } from "node:test";
|
||||||
|
import { loadScenario } from "../../src/declaration/parse.ts";
|
||||||
|
import { raise } from "../../src/lifecycle/raise.ts";
|
||||||
|
import { destroy } from "../../src/lifecycle/operate.ts";
|
||||||
|
import { labIsUsable, destroyAll, assertUniversalInvariants } from "./harness.ts";
|
||||||
|
|
||||||
|
const capability = await labIsUsable();
|
||||||
|
const skip = capability.usable ? false : `lab not usable: ${capability.why}`;
|
||||||
|
|
||||||
|
/** Grows one step at a time. Each addition is a boot, and a shape not covered before. */
|
||||||
|
const SCENARIOS = [
|
||||||
|
"bootstrap-single", // one machine, one public network — the floor
|
||||||
|
];
|
||||||
|
|
||||||
|
const raised: string[] = [];
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
for (const instanceId of raised) await destroy(instanceId);
|
||||||
|
}, { timeout: 600_000 });
|
||||||
|
|
||||||
|
for (const name of SCENARIOS) {
|
||||||
|
test(`${name}: comes up holding what it declared, with no address held twice`, { skip, timeout: 900_000 }, async () => {
|
||||||
|
const scenario = loadScenario(`scenarios/${name}.yml`);
|
||||||
|
const instance = await raise(scenario, {});
|
||||||
|
raised.push(instance.instanceId);
|
||||||
|
await assertUniversalInvariants(scenario, instance.instanceId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
// Anything a failed raise left standing. RaiseError leaves wreckage on purpose, which is
|
||||||
|
// right for a person debugging and wrong for the next run of the suite.
|
||||||
|
for (const name of SCENARIOS) await destroyAll(`${name}-`);
|
||||||
|
}, { timeout: 600_000 });
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
/**
|
||||||
|
* Each test names the decision it defends. A decision with no test is one that will quietly
|
||||||
|
* stop being true (novox/hq ADR 0034).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { test, before, after } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { loadScenario } from "../../src/declaration/parse.ts";
|
||||||
|
import { raise } from "../../src/lifecycle/raise.ts";
|
||||||
|
import { destroy, exec, list, restore, snapshot } from "../../src/lifecycle/operate.ts";
|
||||||
|
import { incus, incusOk } from "../../src/incus/client.ts";
|
||||||
|
import { labIsUsable, destroyAll, assertUniversalInvariants } from "./harness.ts";
|
||||||
|
import { diagramFromLive } from "../../src/diagram/from-live.ts";
|
||||||
|
import { diagramFromDeclaration } from "../../src/diagram/from-declaration.ts";
|
||||||
|
import { toDrawio } from "../../src/diagram/drawio.ts";
|
||||||
|
|
||||||
|
const capability = await labIsUsable();
|
||||||
|
const skip = capability.usable ? false : `lab not usable: ${capability.why}`;
|
||||||
|
|
||||||
|
let instanceId = "";
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
if (skip) return;
|
||||||
|
const scenario = loadScenario("scenarios/behind-nat.yml");
|
||||||
|
const raised = await raise(scenario, {});
|
||||||
|
instanceId = raised.instanceId;
|
||||||
|
});
|
||||||
|
|
||||||
|
after(async () => {
|
||||||
|
if (instanceId) await destroy(instanceId);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ADR 0031 — the lab provides the underlay and NOTHING of the overlay", { skip, timeout: 120_000 }, async () => {
|
||||||
|
// A scenario that pre-built peering would certify its own work. Whatever the mesh is
|
||||||
|
// responsible for must be absent from a freshly raised machine.
|
||||||
|
const { stdout } = await exec(instanceId, "home-server", [
|
||||||
|
"sh", "-c",
|
||||||
|
"ip link show type wireguard 2>/dev/null | wc -l; " +
|
||||||
|
"ls /etc/wireguard 2>/dev/null | wc -l; " +
|
||||||
|
"ls /etc/hal /etc/mesh 2>/dev/null | wc -l",
|
||||||
|
]);
|
||||||
|
const counts = stdout.trim().split("\n").map((n) => Number(n.trim()));
|
||||||
|
assert.deepEqual(counts, [0, 0, 0], "a raised machine carries no overlay, no mesh config");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ADR 0031 — the declared address IS what the machine holds", { skip }, async () => {
|
||||||
|
const { stdout } = await exec(instanceId, "home-server", ["ip", "-o", "-4", "addr", "show"]);
|
||||||
|
assert.match(stdout, /192\.168\.1\.135\/24/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("design — raise waits for USABLE, not for the call to return", { skip, timeout: 120_000 }, async () => {
|
||||||
|
// The measured gap is 3.4s to 14.3s. Reporting the earlier number is transport reported
|
||||||
|
// as effect. If raise has returned, every machine must answer immediately.
|
||||||
|
for (const machine of ["anchor", "home-server"]) {
|
||||||
|
const { stdout } = await exec(instanceId, machine, ["sh", "-c", "echo alive"]);
|
||||||
|
assert.equal(stdout.trim(), "alive", `${machine} was not usable when raise returned`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ADR 0033 — a router is scenery: containers, while machines are virtual machines", { skip, timeout: 120_000 }, async () => {
|
||||||
|
const json = (await incusOk(["list", "--format", "json"], 30_000)) ?? "[]";
|
||||||
|
const all = JSON.parse(json) as { name?: string; type?: string; config?: Record<string, string> }[];
|
||||||
|
const mine = all.filter((i) => i.config?.["user.mesh-lab.instance"] === instanceId);
|
||||||
|
assert.ok(mine.length >= 3, "expected machines and a router");
|
||||||
|
|
||||||
|
for (const item of mine) {
|
||||||
|
const isRouter = item.config?.["user.mesh-lab.router"] !== undefined;
|
||||||
|
assert.equal(
|
||||||
|
item.type,
|
||||||
|
isRouter ? "container" : "virtual-machine",
|
||||||
|
`${item.name} is a ${item.type} but ${isRouter ? "is" : "is not"} a router`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("design — NAT: a private address is not reachable from outside", { skip, timeout: 120_000 }, async () => {
|
||||||
|
const { stdout } = await exec(instanceId, "anchor", [
|
||||||
|
"sh", "-c", "ping -c1 -W2 192.168.1.135 >/dev/null 2>&1 && echo reachable || echo unreachable",
|
||||||
|
]);
|
||||||
|
assert.equal(stdout.trim(), "unreachable");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("design — published: reachable at the GATEWAY's address, never its own", { skip, timeout: 180_000 }, async () => {
|
||||||
|
await exec(instanceId, "home-server", [
|
||||||
|
"sh", "-c", "nohup python3 -m http.server 8080 --bind 0.0.0.0 >/tmp/s.log 2>&1 & sleep 2",
|
||||||
|
]);
|
||||||
|
const { stdout } = await exec(instanceId, "anchor", [
|
||||||
|
"sh", "-c", "curl -s -m5 -o /dev/null -w '%{http_code}' http://192.0.2.50:8080/ || echo failed",
|
||||||
|
]);
|
||||||
|
assert.equal(stdout.trim(), "200", "the forwarded port did not reach the machine behind NAT");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("design — snapshots are WHOLE-scenario: restore returns every machine", { skip, timeout: 600_000 }, async () => {
|
||||||
|
// Restoring a subset would produce a mesh that has never existed, so faults found there
|
||||||
|
// would be artefacts of the lab.
|
||||||
|
await exec(instanceId, "anchor", ["sh", "-c", "echo dirty > /root/marker"]);
|
||||||
|
await exec(instanceId, "home-server", ["sh", "-c", "echo dirty > /root/marker"]);
|
||||||
|
await snapshot(instanceId, "test-point");
|
||||||
|
|
||||||
|
await exec(instanceId, "anchor", ["sh", "-c", "echo changed > /root/marker"]);
|
||||||
|
await exec(instanceId, "home-server", ["sh", "-c", "echo changed > /root/marker"]);
|
||||||
|
await restore(instanceId, "test-point");
|
||||||
|
|
||||||
|
for (const machine of ["anchor", "home-server"]) {
|
||||||
|
const { stdout } = await exec(instanceId, machine, ["cat", "/root/marker"]);
|
||||||
|
assert.equal(stdout.trim(), "dirty", `${machine} was not returned to the snapshot`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("design — restore leaves the scenario USABLE, not merely running", { skip, timeout: 120_000 }, async () => {
|
||||||
|
// The restore call returns in under a second while the agent is still starting. Reporting
|
||||||
|
// that as restored would be transport reported as effect.
|
||||||
|
const { stdout } = await exec(instanceId, "anchor", ["sh", "-c", "echo alive"]);
|
||||||
|
assert.equal(stdout.trim(), "alive");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ADR 0032 — the workstation has no route into the scenario", { skip, timeout: 60_000 }, async () => {
|
||||||
|
// Reachability is asked from INSIDE. If the workstation could reach a scenario address,
|
||||||
|
// two scenarios carrying the same prefix would put one's traffic in the other.
|
||||||
|
const { stdout } = await incus(["exec", `mlab-${instanceId}-anchor`, "--", "echo", "inside"]);
|
||||||
|
assert.equal(stdout.trim(), "inside", "exec is the only way in, and it works");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("what came up holds what was declared, with no address held twice", { skip, timeout: 120_000 }, async () => {
|
||||||
|
// The same invariants every scenario is held to, asserted here too — this instance is
|
||||||
|
// already standing, so it costs nothing to ask.
|
||||||
|
await assertUniversalInvariants(loadScenario("scenarios/behind-nat.yml"), instanceId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The diagram tests read the instance the file raised, so they run before the one that
|
||||||
|
// tears it down. Ordering is load-bearing here: appended after the destroy test they read
|
||||||
|
// an instance that no longer existed, and reported it as the diagram failing.
|
||||||
|
test("the live diagram reads the hypervisor, and a VM's addresses are not lost", { skip }, async () => {
|
||||||
|
// A container's interface carries the device's name; a virtual machine names its own, so
|
||||||
|
// joining addresses to devices by name attached every address to a container and none to
|
||||||
|
// a VM. The picture then showed machines that looked like they had failed to come up.
|
||||||
|
const drawn = await diagramFromLive(instanceId);
|
||||||
|
const server = drawn.machines.find((m) => m.name === "home-server");
|
||||||
|
assert.ok(server, "home-server missing from the live picture");
|
||||||
|
assert.equal(server.kind, "machine");
|
||||||
|
assert.ok(
|
||||||
|
server.attachments.some((a) => a.addresses.some((address) => address.startsWith("192.168.1.135"))),
|
||||||
|
`a virtual machine's addresses were not read back: ${JSON.stringify(server.attachments)}`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the live diagram draws what exists, never what was asked for", { skip, timeout: 300_000 }, async () => {
|
||||||
|
// Every property shown must have come off the hypervisor. Proved by changing the running
|
||||||
|
// system and watching only the live picture move.
|
||||||
|
const scenario = loadScenario("scenarios/behind-nat.yml");
|
||||||
|
const declared = diagramFromDeclaration(scenario);
|
||||||
|
const before = await diagramFromLive(instanceId);
|
||||||
|
assert.equal(before.machines.length, declared.machines.length, "the two pictures disagree on size");
|
||||||
|
|
||||||
|
const anchor = (await list())
|
||||||
|
.find((i) => i.instanceId === instanceId)
|
||||||
|
?.machines.find((m) => m.machine === "anchor");
|
||||||
|
assert.ok(anchor, "anchor not found");
|
||||||
|
await incus(["stop", anchor.name], 120_000);
|
||||||
|
try {
|
||||||
|
const after = await diagramFromLive(instanceId);
|
||||||
|
assert.equal(after.machines.find((m) => m.name === "anchor")?.status, "Stopped");
|
||||||
|
// The declaration has not changed, and neither has its picture.
|
||||||
|
assert.equal(
|
||||||
|
diagramFromDeclaration(scenario).machines.find((m) => m.name === "anchor")?.status,
|
||||||
|
undefined,
|
||||||
|
"a declared picture reported a runtime status it cannot know",
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
await incus(["start", anchor.name], 120_000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ADR 0033 — the live diagram distinguishes scenery from a node", { skip }, async () => {
|
||||||
|
// The router is drawn as a router because the hypervisor says it is a container tagged as
|
||||||
|
// a gateway — not because the diagram re-read the scenario and inferred it.
|
||||||
|
const drawn = await diagramFromLive(instanceId);
|
||||||
|
const gateway = drawn.machines.find((m) => m.kind === "router");
|
||||||
|
assert.ok(gateway, "no gateway in the live picture");
|
||||||
|
assert.ok(gateway.notes.includes("container"), "the gateway is not reported as scenery");
|
||||||
|
assert.ok(gateway.notes.some((n) => n.startsWith("NAT ")), "translation is not shown");
|
||||||
|
assert.ok(gateway.notes.includes("forwardable"), "forwardability is not shown");
|
||||||
|
assert.ok(
|
||||||
|
gateway.notes.some((n) => /mappings expire \d+s/.test(n)),
|
||||||
|
"the declared mapping expiry was not recorded on the gateway",
|
||||||
|
);
|
||||||
|
|
||||||
|
const segments = new Map(drawn.segments.map((s) => [s.name, s]));
|
||||||
|
assert.equal(segments.get("hosting")?.kind, "public", "segment kind was not recorded at raise");
|
||||||
|
assert.equal(segments.get("home")?.behind, "hosting", "the tree was not recovered from the gateway tags");
|
||||||
|
assert.equal(segments.get("home")?.depth, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a picture nobody can open is not a picture", { skip }, async () => {
|
||||||
|
// The first generated file was unparseable — HTML labels concatenated into an XML
|
||||||
|
// attribute. Checked here against real output as well as against the fixtures, because
|
||||||
|
// live labels carry names and addresses the declared ones never contain.
|
||||||
|
const xml = toDrawio(await diagramFromLive(instanceId));
|
||||||
|
for (const match of xml.matchAll(/(?:value|label|tooltip)="([^"]*)"/g)) {
|
||||||
|
assert.doesNotMatch(match[1] ?? "", /[<>]/, "raw markup reached an XML attribute");
|
||||||
|
}
|
||||||
|
const ids = [...xml.matchAll(/<(?:mxCell|object) [^>]*id="([^"]*)"/g)].map((m) => m[1]);
|
||||||
|
assert.equal(new Set(ids).size, ids.length, "duplicate cell id — draw.io drops one silently");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("housekeeping — destroy removes machines, routers and segments", { skip, timeout: 400_000 }, async () => {
|
||||||
|
const before = (await list()).find((i) => i.instanceId === instanceId);
|
||||||
|
assert.ok(before, "the instance should exist before it is destroyed");
|
||||||
|
|
||||||
|
const { machines, networks } = await destroy(instanceId);
|
||||||
|
assert.ok(machines >= 3, `expected machines and a router, removed ${machines}`);
|
||||||
|
assert.ok(networks >= 2, `expected both segments removed, removed ${networks}`);
|
||||||
|
|
||||||
|
const after = (await list()).find((i) => i.instanceId === instanceId);
|
||||||
|
assert.equal(after, undefined, "the instance should be gone");
|
||||||
|
instanceId = "";
|
||||||
|
await destroyAll("behind-nat-");
|
||||||
|
});
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { duplicateAddresses, describeConflicts } from "../src/lifecycle/invariants.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The fault this defends against, in the shape it actually occurred: two gateways declared
|
||||||
|
* with the same public address became two router containers, both holding it on one segment.
|
||||||
|
*/
|
||||||
|
|
||||||
|
test("two machines holding one address on one segment is a conflict", () => {
|
||||||
|
const conflicts = duplicateAddresses([
|
||||||
|
{ machine: "gw-home", segment: "isp-home", address: "198.51.100.7/24" },
|
||||||
|
{ machine: "gw-devices", segment: "isp-home", address: "198.51.100.7/24" },
|
||||||
|
{ machine: "transit", segment: "isp-home", address: "198.51.100.254/24" },
|
||||||
|
]);
|
||||||
|
assert.equal(conflicts.length, 1);
|
||||||
|
assert.equal(conflicts[0]?.address, "198.51.100.7");
|
||||||
|
assert.deepEqual(conflicts[0]?.machines, ["gw-devices", "gw-home"]);
|
||||||
|
assert.match(describeConflicts(conflicts), /198\.51\.100\.7 is held by gw-devices and gw-home/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the same address on different segments is NOT a conflict", () => {
|
||||||
|
// Every private network has its own `.1`. Reporting that would make the check useless.
|
||||||
|
assert.deepEqual(
|
||||||
|
duplicateAddresses([
|
||||||
|
{ machine: "gw-a", segment: "home", address: "192.168.1.1/24" },
|
||||||
|
{ machine: "gw-b", segment: "cafe", address: "192.168.1.1/24" },
|
||||||
|
]),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("one machine holding an address twice is not two machines", () => {
|
||||||
|
// A machine multi-homed onto the same segment, or an address read back from two places.
|
||||||
|
assert.deepEqual(
|
||||||
|
duplicateAddresses([
|
||||||
|
{ machine: "gw", segment: "isp", address: "198.51.100.7/24" },
|
||||||
|
{ machine: "gw", segment: "isp", address: "198.51.100.7" },
|
||||||
|
]),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the prefix length is not part of the address", () => {
|
||||||
|
// The same address declared /24 in one place and /16 in another is still one address.
|
||||||
|
const conflicts = duplicateAddresses([
|
||||||
|
{ machine: "a", segment: "isp", address: "198.51.100.7/24" },
|
||||||
|
{ machine: "b", segment: "isp", address: "198.51.100.7/16" },
|
||||||
|
]);
|
||||||
|
assert.equal(conflicts.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("both families are checked", () => {
|
||||||
|
const conflicts = duplicateAddresses([
|
||||||
|
{ machine: "a", segment: "isp", address: "2001:db8:b::7/48" },
|
||||||
|
{ machine: "b", segment: "isp", address: "2001:db8:b::7/48" },
|
||||||
|
]);
|
||||||
|
assert.equal(conflicts.length, 1);
|
||||||
|
assert.equal(conflicts[0]?.address, "2001:db8:b::7");
|
||||||
|
});
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { machineName, machinePrefix, networkName, newInstanceId, instanceIdOf } from "../src/lifecycle/names.ts";
|
||||||
|
|
||||||
|
test("network names fit incus's 15-character limit", () => {
|
||||||
|
const id = newInstanceId("the-ordinary-shape", new Date("2026-08-24T22:15:00Z"));
|
||||||
|
for (const segment of ["hosting", "isp-home", "isp-mobile", "home", "devices", "cafe"]) {
|
||||||
|
const name = networkName(id, segment);
|
||||||
|
assert.ok(name.length <= 15, `${name} is ${name.length} chars`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("network names are unique per (instance, segment)", () => {
|
||||||
|
const a = newInstanceId("x", new Date("2026-08-24T22:15:00Z"));
|
||||||
|
const b = newInstanceId("x", new Date("2026-08-24T23:15:00Z"));
|
||||||
|
const names = new Set([
|
||||||
|
networkName(a, "home"), networkName(a, "cafe"),
|
||||||
|
networkName(b, "home"), networkName(b, "cafe"),
|
||||||
|
]);
|
||||||
|
assert.equal(names.size, 4, "two instances of one declaration must not share a network");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("machine names round-trip to their instance id", () => {
|
||||||
|
const id = newInstanceId("bootstrap-single", new Date("2026-08-24T22:15:00Z"));
|
||||||
|
const name = machineName(id, "anchor");
|
||||||
|
assert.equal(instanceIdOf(name, "anchor"), id);
|
||||||
|
assert.ok(name.startsWith(machinePrefix(id)));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("instance ids are sortable by time", () => {
|
||||||
|
const early = newInstanceId("x", new Date("2026-08-24T09:00:00Z"));
|
||||||
|
const late = newInstanceId("x", new Date("2026-08-24T21:00:00Z"));
|
||||||
|
assert.ok(early < late, `${early} should sort before ${late}`);
|
||||||
|
});
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { parseCidr, contains, familyOf, parseAddress } from "../src/declaration/net.ts";
|
||||||
|
|
||||||
|
test("family is inferred from the address", () => {
|
||||||
|
assert.equal(familyOf("203.0.113.1"), "v4");
|
||||||
|
assert.equal(familyOf("2001:db8::1"), "v6");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("v4 containment", () => {
|
||||||
|
const range = parseCidr("203.0.113.0/24");
|
||||||
|
assert.equal(contains(range, "203.0.113.0"), true);
|
||||||
|
assert.equal(contains(range, "203.0.113.255"), true);
|
||||||
|
assert.equal(contains(range, "203.0.114.0"), false);
|
||||||
|
assert.equal(contains(range, "202.0.113.1"), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("v4 containment on a non-byte prefix", () => {
|
||||||
|
const range = parseCidr("198.51.100.0/25");
|
||||||
|
assert.equal(contains(range, "198.51.100.127"), true);
|
||||||
|
assert.equal(contains(range, "198.51.100.128"), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("v6 containment, including :: compression", () => {
|
||||||
|
const range = parseCidr("2001:db8:a::/48");
|
||||||
|
assert.equal(contains(range, "2001:db8:a::10"), true);
|
||||||
|
assert.equal(contains(range, "2001:db8:a:ffff::1"), true);
|
||||||
|
assert.equal(contains(range, "2001:db8:b::10"), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a range never contains an address of the other family", () => {
|
||||||
|
assert.equal(contains(parseCidr("203.0.113.0/24"), "2001:db8::1"), false);
|
||||||
|
assert.equal(contains(parseCidr("2001:db8::/32"), "203.0.113.1"), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a cidr whose address has host bits set still masks to its network", () => {
|
||||||
|
// 203.0.113.5/24 means the 203.0.113.0/24 network, not a range starting at .5
|
||||||
|
assert.equal(contains(parseCidr("203.0.113.5/24"), "203.0.113.1"), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("malformed input is refused rather than guessed at", () => {
|
||||||
|
assert.throws(() => parseCidr("203.0.113.0"), /no prefix length/);
|
||||||
|
assert.throws(() => parseCidr("203.0.113.0/33"), /out of range/);
|
||||||
|
assert.throws(() => parseCidr("2001:db8::/129"), /out of range/);
|
||||||
|
assert.throws(() => parseAddress("203.0.113"), /not an IPv4/);
|
||||||
|
assert.throws(() => parseAddress("203.0.113.256"), /out of range/);
|
||||||
|
assert.throws(() => parseAddress("2001:db8::1::2"), /not an IPv6/);
|
||||||
|
assert.throws(() => parseAddress("::ffff:192.0.2.1"), /not supported/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { parseScenario } from "../src/declaration/parse.ts";
|
||||||
|
import { planPlacements, PLACEABLE } from "../src/lifecycle/place.ts";
|
||||||
|
import { assertSupported, UnsupportedError } from "../src/lifecycle/supported.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `place:` is the seam where the lab stops being infrastructure with no consumer. Each test
|
||||||
|
* names what it defends, per novox/hq ADR 0034.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function scenario(place: string): ReturnType<typeof parseScenario> {
|
||||||
|
return parseScenario(`
|
||||||
|
scenario: placing
|
||||||
|
segments:
|
||||||
|
hosting:
|
||||||
|
kind: public
|
||||||
|
cidr: [192.0.2.0/24]
|
||||||
|
machines:
|
||||||
|
anchor:
|
||||||
|
at: { segment: hosting, address: [192.0.2.10] }
|
||||||
|
peer:
|
||||||
|
at: { segment: hosting, address: [192.0.2.20] }
|
||||||
|
${place}
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("`all:` reaches every machine", () => {
|
||||||
|
const placements = planPlacements(scenario("place:\n all: [host]"));
|
||||||
|
assert.deepEqual(
|
||||||
|
placements.map((p) => p.machine).sort(),
|
||||||
|
["anchor", "peer"],
|
||||||
|
);
|
||||||
|
for (const p of placements) assert.deepEqual(p.artifacts, ["host"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a per-machine entry OVERRIDES `all:`, it does not add to it", () => {
|
||||||
|
// Worth being exact about: a scenario naming one artifact for one machine gets that
|
||||||
|
// artifact, not that artifact plus everything in `all:`. The opposite reading would place
|
||||||
|
// things nobody asked for, which is the shape of fault this lab exists to catch.
|
||||||
|
const placements = planPlacements(scenario("place:\n all: [host]\n anchor: [substrate]"));
|
||||||
|
const byMachine = new Map(placements.map((p) => [p.machine, p.artifacts]));
|
||||||
|
|
||||||
|
assert.deepEqual(byMachine.get("anchor"), ["substrate"], "anchor should have ONLY substrate");
|
||||||
|
assert.deepEqual(byMachine.get("peer"), ["host"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a machine placed with nothing is not a placement", () => {
|
||||||
|
const placements = planPlacements(scenario("place:\n all: [host]\n anchor: []"));
|
||||||
|
assert.deepEqual(placements.map((p) => p.machine), ["peer"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("no `place:` at all is no placements, not an error", () => {
|
||||||
|
assert.deepEqual(planPlacements(scenario("")), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("placing the host is supported", () => {
|
||||||
|
// The whole point of stage 1: this used to be refused.
|
||||||
|
assert.doesNotThrow(() => assertSupported(scenario("place:\n all: [host]")));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a tier that does not exist is refused BY NAME", () => {
|
||||||
|
// Named individually rather than refused as a whole, so a scenario placing a host and a
|
||||||
|
// substrate is told exactly which half the lab cannot do — rather than being told `place:`
|
||||||
|
// is unsupported when half of it now works.
|
||||||
|
try {
|
||||||
|
assertSupported(scenario("place:\n all: [host, substrate]\n peer: [control]"));
|
||||||
|
assert.fail("expected a refusal");
|
||||||
|
} catch (err) {
|
||||||
|
assert.ok(err instanceof UnsupportedError);
|
||||||
|
const missing = err.missing.join("\n");
|
||||||
|
assert.match(missing, /substrate/, "the substrate was not named");
|
||||||
|
assert.match(missing, /control/, "the control plane was not named");
|
||||||
|
assert.doesNotMatch(missing, /place: host/, "the host is placeable and was refused anyway");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the refusal says what CAN be placed", () => {
|
||||||
|
// A refusal that does not say what is possible sends someone to the source to find out.
|
||||||
|
try {
|
||||||
|
assertSupported(scenario("place:\n all: [forge]"));
|
||||||
|
assert.fail("expected a refusal");
|
||||||
|
} catch (err) {
|
||||||
|
assert.ok(err instanceof UnsupportedError);
|
||||||
|
for (const placeable of PLACEABLE) {
|
||||||
|
assert.match(err.missing.join("\n"), new RegExp(placeable));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { parseScenario } from "../src/declaration/parse.ts";
|
||||||
|
import { planRouters, ttlSeconds } from "../src/lifecycle/router.ts";
|
||||||
|
|
||||||
|
test("segments sharing a gateway declaration share ONE router", () => {
|
||||||
|
// That is what a VLAN-capable router is, and two routers sharing an external address
|
||||||
|
// would not work anyway.
|
||||||
|
const scenario = parseScenario(`scenario: x
|
||||||
|
segments:
|
||||||
|
pub: { kind: public, cidr: [192.0.2.0/24] }
|
||||||
|
home: { kind: private, cidr: [192.168.1.0/24], gateway: { to: pub, address: [192.0.2.5], nat: [v4] } }
|
||||||
|
iot: { kind: private, cidr: [192.168.30.0/24], gateway: { to: pub, address: [192.0.2.5], nat: [v4] } }
|
||||||
|
machines: { a: { at: { segment: home, address: [192.168.1.9] } } }`);
|
||||||
|
const plans = planRouters(scenario, "inst");
|
||||||
|
assert.equal(plans.length, 1, "one gateway declaration, one router");
|
||||||
|
assert.deepEqual(plans[0]?.inside.sort(), ["home", "iot"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("different external addresses mean different routers", () => {
|
||||||
|
const scenario = parseScenario(`scenario: x
|
||||||
|
segments:
|
||||||
|
pub: { kind: public, cidr: [192.0.2.0/24] }
|
||||||
|
home: { kind: private, cidr: [192.168.1.0/24], gateway: { to: pub, address: [192.0.2.5], nat: [v4] } }
|
||||||
|
other: { kind: private, cidr: [192.168.30.0/24], gateway: { to: pub, address: [192.0.2.6], nat: [v4] } }
|
||||||
|
machines: { a: { at: { segment: home, address: [192.168.1.9] } } }`);
|
||||||
|
assert.equal(planRouters(scenario, "inst").length, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a scenario with no gateways needs no routers", () => {
|
||||||
|
const scenario = parseScenario(`scenario: x
|
||||||
|
segments: { net: { kind: public, cidr: [192.0.2.0/24] } }
|
||||||
|
machines: { a: { at: { segment: net, address: [192.0.2.1] } } }`);
|
||||||
|
assert.equal(planRouters(scenario, "inst").length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("forwardable and nat carry through to the plan", () => {
|
||||||
|
const scenario = parseScenario(`scenario: x
|
||||||
|
segments:
|
||||||
|
pub: { kind: public, cidr: [192.0.2.0/24] }
|
||||||
|
cafe: { kind: private, cidr: [10.50.0.0/16], gateway: { to: pub, address: [192.0.2.9], nat: [v4], forwardable: false, mapping_ttl: 30s } }
|
||||||
|
machines: { a: { at: { segment: cafe, address: [10.50.0.9] } } }`);
|
||||||
|
const plan = planRouters(scenario, "inst")[0];
|
||||||
|
assert.equal(plan?.forwardable, false);
|
||||||
|
assert.deepEqual(plan?.nat, ["v4"]);
|
||||||
|
assert.equal(plan?.mappingTtl, "30s");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("mapping ttl parses the forms a declaration uses", () => {
|
||||||
|
assert.equal(ttlSeconds("30s"), 30);
|
||||||
|
assert.equal(ttlSeconds("120s"), 120);
|
||||||
|
assert.equal(ttlSeconds("2m"), 120);
|
||||||
|
assert.equal(ttlSeconds("1h"), 3600);
|
||||||
|
assert.equal(ttlSeconds("90"), 90);
|
||||||
|
assert.equal(ttlSeconds(undefined), undefined);
|
||||||
|
assert.equal(ttlSeconds("soon"), undefined);
|
||||||
|
});
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { parseScenario } from "../src/declaration/parse.ts";
|
||||||
|
import { assertSupported, UnsupportedError } from "../src/lifecycle/supported.ts";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A declaration the runtime silently ignores is the fault this lab exists to catch —
|
||||||
|
* novox/hq 04-ISSUES/003, where a firewall key is declared in five manifests and read by no
|
||||||
|
* code. These tests exist so the lab never commits it, and they move as the runtime catches
|
||||||
|
* up with the model.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const withGateway = `scenario: x
|
||||||
|
segments:
|
||||||
|
pub: { kind: public, cidr: [192.0.2.0/24] }
|
||||||
|
home: { kind: private, cidr: [192.168.1.0/24], gateway: { to: pub, address: [192.0.2.5], nat: [v4] } }
|
||||||
|
machines: { a: { at: { segment: home, address: [192.168.1.9] } } }`;
|
||||||
|
|
||||||
|
test("a plain scenario is raisable", () => {
|
||||||
|
const scenario = parseScenario(`scenario: x
|
||||||
|
segments: { net: { kind: public, cidr: [192.0.2.0/24] } }
|
||||||
|
machines: { a: { at: { segment: net, address: [192.0.2.1] } } }`);
|
||||||
|
assert.doesNotThrow(() => assertSupported(scenario));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("gateways are implemented — a router is materialised for them", () => {
|
||||||
|
assert.doesNotThrow(() => assertSupported(parseScenario(withGateway)));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("published ports and policy are implemented", () => {
|
||||||
|
const scenario = parseScenario(`scenario: x
|
||||||
|
segments:
|
||||||
|
pub: { kind: public, cidr: [192.0.2.0/24] }
|
||||||
|
home: { kind: private, cidr: [192.168.1.0/24], gateway: { to: pub, address: [192.0.2.5], nat: [v4] } }
|
||||||
|
iot: { kind: private, cidr: [192.168.30.0/24], gateway: { to: pub, address: [192.0.2.5], nat: [v4] } }
|
||||||
|
policy: [{ from: iot, to: home, allow: false }]
|
||||||
|
machines:
|
||||||
|
a:
|
||||||
|
at: { segment: home, address: [192.168.1.9] }
|
||||||
|
published: [{ port: 443, on: home }]`);
|
||||||
|
assert.doesNotThrow(() => assertSupported(scenario));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("inbound: deny is implemented — a host firewall is applied and read back", () => {
|
||||||
|
const scenario = parseScenario(`scenario: x
|
||||||
|
segments: { net: { kind: public, cidr: [192.0.2.0/24] } }
|
||||||
|
machines: { a: { at: { segment: net, address: [192.0.2.1] }, inbound: deny } }`);
|
||||||
|
assert.doesNotThrow(() => assertSupported(scenario));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the host is placeable — it used to be refused, and tier 0 now exists", () => {
|
||||||
|
// These two tests failed the moment placement worked, which is what they were for. They
|
||||||
|
// defended "there is nothing to place yet" while that was true; the decision changed, so
|
||||||
|
// they change with it rather than being deleted (novox/hq ADR 0034).
|
||||||
|
const scenario = parseScenario(`scenario: x
|
||||||
|
segments: { net: { kind: public, cidr: [192.0.2.0/24] } }
|
||||||
|
machines: { a: { at: { segment: net, address: [192.0.2.1] } } }
|
||||||
|
place: { all: [host] }`);
|
||||||
|
assert.doesNotThrow(() => assertSupported(scenario));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a tier above 0 is still refused, and named", () => {
|
||||||
|
// The refusal narrowed rather than disappearing. A scenario placing a host AND a substrate
|
||||||
|
// must be told which half is missing — not that `place:` is unsupported, when half of it
|
||||||
|
// now works.
|
||||||
|
const scenario = parseScenario(`scenario: x
|
||||||
|
segments: { net: { kind: public, cidr: [192.0.2.0/24] } }
|
||||||
|
machines: { a: { at: { segment: net, address: [192.0.2.1] } } }
|
||||||
|
place: { all: [host, substrate] }`);
|
||||||
|
try {
|
||||||
|
assertSupported(scenario);
|
||||||
|
assert.fail("should have refused");
|
||||||
|
} catch (err) {
|
||||||
|
assert.ok(err instanceof UnsupportedError);
|
||||||
|
assert.equal(err.missing.length, 1, `expected only the substrate: ${err.missing.join(", ")}`);
|
||||||
|
assert.match(err.missing[0] ?? "", /substrate/);
|
||||||
|
assert.match(err instanceof Error ? err.message : "", /silently lacks them/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("inbound: allow is not a gap — only deny needs enforcing", () => {
|
||||||
|
const scenario = parseScenario(`scenario: x
|
||||||
|
segments: { net: { kind: public, cidr: [192.0.2.0/24] } }
|
||||||
|
machines: { a: { at: { segment: net, address: [192.0.2.1] }, inbound: allow } }`);
|
||||||
|
assert.doesNotThrow(() => assertSupported(scenario));
|
||||||
|
});
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { parseScenario } from "../src/declaration/parse.ts";
|
||||||
|
import { loadScenario } from "../src/declaration/parse.ts";
|
||||||
|
import { planRouters } from "../src/lifecycle/router.ts";
|
||||||
|
|
||||||
|
/** Every rejection below is a fault that would otherwise be silent at runtime. */
|
||||||
|
function refuses(yaml: string, pattern: RegExp): void {
|
||||||
|
assert.throws(() => parseScenario(yaml), (err: Error) => {
|
||||||
|
assert.match(err.message, pattern);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("the shipped scenarios are valid", () => {
|
||||||
|
for (const file of ["scenarios/bootstrap-single.yml", "scenarios/the-ordinary-shape.yml"]) {
|
||||||
|
assert.doesNotThrow(() => loadScenario(file));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a public segment on a private range is refused — the mesh would silently never form", () => {
|
||||||
|
refuses(
|
||||||
|
`scenario: x
|
||||||
|
segments: { net: { kind: public, cidr: [192.168.1.0/24] } }
|
||||||
|
machines: { a: { at: { segment: net, address: [192.168.1.1] } } }`,
|
||||||
|
/not documentation space/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a public segment on a real routable range is refused", () => {
|
||||||
|
refuses(
|
||||||
|
`scenario: x
|
||||||
|
segments: { net: { kind: public, cidr: [8.8.8.0/24] } }
|
||||||
|
machines: { a: { at: { segment: net, address: [8.8.8.8] } } }`,
|
||||||
|
/not documentation space/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a private segment may use any range, including someone else's RFC 1918", () => {
|
||||||
|
assert.doesNotThrow(() =>
|
||||||
|
parseScenario(`scenario: x
|
||||||
|
segments:
|
||||||
|
pub: { kind: public, cidr: [192.0.2.0/24] }
|
||||||
|
cafe: { kind: private, cidr: [10.50.0.0/16], gateway: { to: pub, address: [192.0.2.5], nat: [v4], forwardable: false } }
|
||||||
|
machines: { a: { at: { segment: cafe, address: [10.50.0.9] } } }`),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("publishing through an unforwardable gateway is refused — that is the constraint", () => {
|
||||||
|
refuses(
|
||||||
|
`scenario: x
|
||||||
|
segments:
|
||||||
|
pub: { kind: public, cidr: [192.0.2.0/24] }
|
||||||
|
cafe: { kind: private, cidr: [10.50.0.0/16], gateway: { to: pub, address: [192.0.2.5], nat: [v4], forwardable: false } }
|
||||||
|
machines:
|
||||||
|
a:
|
||||||
|
at: { segment: cafe, address: [10.50.0.9] }
|
||||||
|
published: [{ port: 443, on: cafe }]`,
|
||||||
|
/not forwardable/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a gateway address must be on the PARENT segment, not the one behind it", () => {
|
||||||
|
refuses(
|
||||||
|
`scenario: x
|
||||||
|
segments:
|
||||||
|
pub: { kind: public, cidr: [192.0.2.0/24] }
|
||||||
|
home: { kind: private, cidr: [192.168.1.0/24], gateway: { to: pub, address: [192.168.1.1], nat: [v4] } }
|
||||||
|
machines: { a: { at: { segment: home, address: [192.168.1.9] } } }`,
|
||||||
|
/is not within 'pub'/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a machine address outside its segment is refused", () => {
|
||||||
|
refuses(
|
||||||
|
`scenario: x
|
||||||
|
segments: { net: { kind: public, cidr: [192.0.2.0/24] } }
|
||||||
|
machines: { a: { at: { segment: net, address: [203.0.113.9] } } }`,
|
||||||
|
/is not within segment 'net'/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an unknown segment reference is refused", () => {
|
||||||
|
refuses(
|
||||||
|
`scenario: x
|
||||||
|
segments: { net: { kind: public, cidr: [192.0.2.0/24] } }
|
||||||
|
machines: { a: { at: { segment: nope, address: [192.0.2.1] } } }`,
|
||||||
|
/unknown segment 'nope'/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a gateway loop is refused rather than raised forever", () => {
|
||||||
|
refuses(
|
||||||
|
`scenario: x
|
||||||
|
segments:
|
||||||
|
a: { kind: private, cidr: [10.0.0.0/24], gateway: { to: b, address: [10.0.1.1], nat: [] } }
|
||||||
|
b: { kind: private, cidr: [10.0.1.0/24], gateway: { to: a, address: [10.0.0.1], nat: [] } }
|
||||||
|
machines: { m: { at: { segment: a, address: [10.0.0.9] } } }`,
|
||||||
|
/loops through/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a detached machine cannot publish", () => {
|
||||||
|
refuses(
|
||||||
|
`scenario: x
|
||||||
|
segments: { net: { kind: public, cidr: [192.0.2.0/24] } }
|
||||||
|
machines: { a: { at: detached, published: [{ port: 443, on: net }] } }`,
|
||||||
|
/detached but declares published/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("publishing on a segment the machine is not attached to is refused", () => {
|
||||||
|
refuses(
|
||||||
|
`scenario: x
|
||||||
|
segments:
|
||||||
|
pub: { kind: public, cidr: [192.0.2.0/24] }
|
||||||
|
home: { kind: private, cidr: [192.168.1.0/24], gateway: { to: pub, address: [192.0.2.5], nat: [v4] } }
|
||||||
|
machines:
|
||||||
|
a:
|
||||||
|
at: { segment: pub, address: [192.0.2.10] }
|
||||||
|
published: [{ port: 443, on: home }]`,
|
||||||
|
/not attached to it/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("two addresses of one family on one attachment is refused", () => {
|
||||||
|
refuses(
|
||||||
|
`scenario: x
|
||||||
|
segments: { net: { kind: public, cidr: [192.0.2.0/24] } }
|
||||||
|
machines: { a: { at: { segment: net, address: [192.0.2.1, 192.0.2.2] } } }`,
|
||||||
|
/two v4 addresses/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("place naming a machine that does not exist is refused", () => {
|
||||||
|
refuses(
|
||||||
|
`scenario: x
|
||||||
|
segments: { net: { kind: public, cidr: [192.0.2.0/24] } }
|
||||||
|
machines: { a: { at: { segment: net, address: [192.0.2.1] } } }
|
||||||
|
place: { ghost: [host] }`,
|
||||||
|
/'ghost' is not a machine/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("every problem is reported, not just the first", () => {
|
||||||
|
try {
|
||||||
|
parseScenario(`scenario: x
|
||||||
|
segments: { net: { kind: public, cidr: [192.168.0.0/24] } }
|
||||||
|
machines: { a: { at: { segment: nope, address: [1.2.3.4] } } }
|
||||||
|
place: { ghost: [host] }`);
|
||||||
|
assert.fail("should have thrown");
|
||||||
|
} catch (err) {
|
||||||
|
const problems = (err as { problems: string[] }).problems;
|
||||||
|
assert.ok(problems.length >= 3, `expected several problems, got ${problems.length}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a detached machine is valid", () => {
|
||||||
|
assert.doesNotThrow(() =>
|
||||||
|
parseScenario(`scenario: x
|
||||||
|
segments: { net: { kind: public, cidr: [192.0.2.0/24] } }
|
||||||
|
machines:
|
||||||
|
a: { at: { segment: net, address: [192.0.2.1] } }
|
||||||
|
roamer: { at: detached }`),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a multi-homed machine is valid", () => {
|
||||||
|
assert.doesNotThrow(() =>
|
||||||
|
parseScenario(`scenario: x
|
||||||
|
segments:
|
||||||
|
pub: { kind: public, cidr: [192.0.2.0/24] }
|
||||||
|
home: { kind: private, cidr: [192.168.1.0/24], gateway: { to: pub, address: [192.0.2.5], nat: [v4] } }
|
||||||
|
machines:
|
||||||
|
border:
|
||||||
|
at:
|
||||||
|
- { segment: pub, address: [192.0.2.60] }
|
||||||
|
- { segment: home, address: [192.168.1.2] }`),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("an isolated private segment with no gateway is valid — a site with no internet", () => {
|
||||||
|
assert.doesNotThrow(() =>
|
||||||
|
parseScenario(`scenario: x
|
||||||
|
segments: { island: { kind: private, cidr: [10.9.0.0/24] } }
|
||||||
|
machines: { a: { at: { segment: island, address: [10.9.0.1] } } }`),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("two gateways sharing an address are one gateway, not two", () => {
|
||||||
|
// Modelled on the real thing: a bridged modem, one gateway holding the public address,
|
||||||
|
// everything behind it. Two routers on one address is not a topology, it is a collision —
|
||||||
|
// and the lab raised it happily, with the address resolving to whichever container
|
||||||
|
// answered ARP last.
|
||||||
|
const scenario = parseScenario(`
|
||||||
|
scenario: shared-gateway
|
||||||
|
segments:
|
||||||
|
isp:
|
||||||
|
kind: public
|
||||||
|
cidr: [198.51.100.0/24, "2001:db8:b::/48"]
|
||||||
|
home:
|
||||||
|
kind: private
|
||||||
|
cidr: [192.168.1.0/24]
|
||||||
|
gateway: { to: isp, address: [198.51.100.7, "2001:db8:b::7"], nat: [v4], forwardable: true, mapping_ttl: 120s }
|
||||||
|
devices:
|
||||||
|
kind: private
|
||||||
|
cidr: [192.168.30.0/24]
|
||||||
|
gateway: { to: isp, address: [198.51.100.7], nat: [v4], forwardable: true, mapping_ttl: 120s }
|
||||||
|
machines:
|
||||||
|
thermostat:
|
||||||
|
at: { segment: devices, address: [192.168.30.20] }
|
||||||
|
`);
|
||||||
|
const plans = planRouters(scenario, "test");
|
||||||
|
assert.equal(plans.length, 1, `expected one gateway, got ${plans.map((p) => p.inside.join("+")).join(" / ")}`);
|
||||||
|
assert.deepEqual([...plans[0]!.inside].sort(), ["devices", "home"]);
|
||||||
|
// The union: a v6 address declared on only one of the segments it serves is still carried.
|
||||||
|
assert.deepEqual([...plans[0]!.outsideAddresses].sort(), ["198.51.100.7", "2001:db8:b::7"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("one box cannot behave two ways", () => {
|
||||||
|
// If two gateways share an address they are the same box, so a disagreement about what
|
||||||
|
// that box does is a contradiction — refused rather than silently resolved one way.
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
parseScenario(`
|
||||||
|
scenario: contradictory-gateway
|
||||||
|
segments:
|
||||||
|
isp:
|
||||||
|
kind: public
|
||||||
|
cidr: [198.51.100.0/24]
|
||||||
|
home:
|
||||||
|
kind: private
|
||||||
|
cidr: [192.168.1.0/24]
|
||||||
|
gateway: { to: isp, address: [198.51.100.7], nat: [v4], forwardable: true }
|
||||||
|
devices:
|
||||||
|
kind: private
|
||||||
|
cidr: [192.168.30.0/24]
|
||||||
|
gateway: { to: isp, address: [198.51.100.7], nat: [v4], forwardable: false }
|
||||||
|
machines:
|
||||||
|
thermostat:
|
||||||
|
at: { segment: devices, address: [192.168.30.20] }
|
||||||
|
`),
|
||||||
|
/one gateway.*disagree.*forwardable/s,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2022",
|
||||||
|
"module": "nodenext",
|
||||||
|
"moduleResolution": "nodenext",
|
||||||
|
"strict": true,
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
"noImplicitOverride": true,
|
||||||
|
"exactOptionalPropertyTypes": true,
|
||||||
|
"rootDir": "src",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"types": [
|
||||||
|
"node"
|
||||||
|
],
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": [
|
||||||
|
"src/**/*.ts"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
// The tests were outside the typecheck gate, so a test that did not compile was simply a
|
||||||
|
// test that never ran — silently, which is the failure mode this project keeps finding.
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rootDir": "."
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "test/**/*.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user