A declaration goes in and a disposable mesh comes out. Verified on a workstation, not asserted: two machines raised and addressed in 14.6s, snapshot 0.28s, restore-to-usable 11.6s, both families pinging with no loss, and the workstation with no route into any of it. The declaration layer implements the model in full — three positions a machine can be in, keyed on forwardability; gateways carrying the address the world sees them as; both address families; multi-homing; MTU; inter-segment policy. It is validated hard because the failures it prevents are silent: a private range on a public segment produces no error, the mesh simply never forms. Public segments are refused unless they use RFC 5737 or RFC 3849 space, and a range wider than the reserved block is refused too. 33 tests, all offline. The runtime implements less than the model, and refuses the difference. A scenario declaring gateways, published ports, policy, inbound deny or place is rejected at raise with every gap named. Raising it would produce a mesh that silently lacks what it declared, which is the fault this lab exists to catch — 04-ISSUES/003, where a firewall key is declared in five manifests and read by no code. Three bugs found by review and by running it, all of one family: The readiness check truthiness-tested incusOk's return. `exec … true` succeeds with EMPTY output, so every machine reported unreachable while incus exec on it worked perfectly. succeeds() now exists so the mistake is not available, and network delete had the same bug — it counted zero segments removed while removing them. list() split instance from machine on the last dash, so a machine called home-server absorbed half the instance id and destroy found nothing. Resources are now found by the metadata they carry, never by name. restore reported success in 0.79s while the machine's agent was still starting, so the next command failed. Both raise and restore now wait for usable and say how long that took — reporting the earlier number is transport reported as effect, which is the fault the lab is being built to find. Two incus behaviours worth recording. Its CLI reads a YAML definition from stdin when stdin is not a terminal, so a spawned command hangs until the timeout kills it and arrives with empty stderr — a failure with no explanation, on a command that works when typed. And it assigns a MAC at runtime without recording it in device config, so MACs are derived and set explicitly, which the guest needs anyway: it names interfaces by bus position, and matching by name configures the wrong one on a multi-homed machine. No build step; Node strips the types. The lifecycle has no unit tests because a fake hypervisor would assert that the fake behaves as expected, which is the shape of test this project exists to stop shipping.
162 lines
6.4 KiB
Markdown
162 lines
6.4 KiB
Markdown
# mesh-lab
|
||
|
||
The lab: a disposable Novox Mesh on one machine.
|
||
|
||
It ships to nobody. It runs on a workstation, raises virtual machines, puts things inside
|
||
them, and throws them away.
|
||
|
||
## Why it exists first
|
||
|
||
The node host takes over a machine's packages, services and network. It cannot be developed
|
||
against a machine anyone needs — so the place to develop it has to exist before it does.
|
||
|
||
That makes this repository **phase 0** of the migration, ahead of every tier it will later
|
||
test.
|
||
|
||
## Two classes of scenario
|
||
|
||
| | **Bootstrap** | **Full** |
|
||
|---|---|---|
|
||
| Contains | virtual machines, the node host, a pinned substrate bundle | a complete mesh: forge, control plane, delivery, modules |
|
||
| Verdict from | what the host reports about the state it reconciled | a pipeline result ending in verify |
|
||
| Exercises | tiers 0 and 1 | tier 2 and above, and modules |
|
||
| Exists to | **develop the mesh** | **test what runs on it** |
|
||
|
||
The bootstrap scenario is a **strict subset** — same virtualisation, same networking, same
|
||
lifecycle, stopping before a control plane exists. The full scenario is reached by putting more
|
||
inside the machines, not by building a second thing.
|
||
|
||
## Using it
|
||
|
||
```
|
||
mesh-lab check can this machine run scenarios at all
|
||
mesh-lab validate scenarios/x.yml parse and check, raising nothing
|
||
mesh-lab raise scenarios/x.yml materialise it, wait until the machines are USABLE
|
||
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>
|
||
```
|
||
|
||
`check` refuses rather than warns. A machine without copy-on-write storage runs scenarios
|
||
correctly and snapshots roughly 76× slower — which does not make the lab slow, it makes it
|
||
unused, and a warning about that is read once and ignored forever.
|
||
|
||
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"`.
|
||
|
||
## What a scenario declares
|
||
|
||
The **underlay**: what a hosting provider and a home router would provide, and nothing the
|
||
mesh is responsible for.
|
||
|
||
```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, forwarding | **refused at raise** |
|
||
| `published:` ports | **refused at raise** |
|
||
| `policy:` between segments | **refused at raise** |
|
||
| `inbound: deny` | **refused at raise** |
|
||
| `place:` | **refused at raise** |
|
||
|
||
`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).
|
||
|
||
`the-ordinary-shape.yml` therefore validates and does not raise. That is the intended state:
|
||
it is the topology being built toward, and the tool says exactly what is missing.
|
||
|
||
## Measured on a workstation
|
||
|
||
| | one machine | two machines |
|
||
|---|---|---|
|
||
| raise, to usable | 12.5 s | 14.6 s |
|
||
| snapshot | 0.14 s | 0.28 s |
|
||
| restore, to usable again | 10.5 s | 11.6 s |
|
||
|
||
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.
|
||
|
||
## Where the reasoning lives
|
||
|
||
Design and decisions are in [`novox/hq`](https://git.novox.be/novox/hq), not here:
|
||
|
||
- `03-DESIGN/01-to-be/02-scenario-declaration.md` — what a scenario declares
|
||
- `03-DESIGN/01-to-be/03-scenario-lifecycle.md` — what happens to one
|
||
- `02-DECISIONS/0031-the-lab-provides-the-underlay.md`
|
||
- `02-DECISIONS/0032-a-scenario-is-an-isolated-address-space.md`
|
||
|
||
This repository carries implementation. It does not carry decisions.
|
||
|
||
## Development
|
||
|
||
No build step — Node strips the types.
|
||
|
||
```
|
||
npm test the declaration layer, offline
|
||
npm run typecheck
|
||
```
|
||
|
||
The lifecycle is not unit-tested. It talks to a hypervisor, and a fake one would assert that
|
||
the fake behaves as expected — which is the shape of test this project exists to stop
|
||
shipping. It is exercised by raising real scenarios.
|