Skip to content

Marketplace: drive libvirt over the tunnel, and prove a node with a real VM - #370

Open
v0l wants to merge 23 commits into
masterfrom
feat/marketplace-node-libvirt
Open

Marketplace: drive libvirt over the tunnel, and prove a node with a real VM#370
v0l wants to merge 23 commits into
masterfrom
feat/marketplace-node-libvirt

Conversation

@v0l

@v0l v0l commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

LNVPS manages VMs on a marketplace node by driving a libvirtd on it over the tunnel, and a node is only opened to customers once a VM has actually run there.

What lands

A libvirtd LNVPS can drive, and nothing of the operator's. Which libvirtd it is turns out to be the whole design, and every part is forced by something in the code:

  • It runs inside the data plane network namespace, because a domain's tap is created in the namespace of the libvirtd that starts it and the guest bridge only exists there.
  • It is a second instance, not the machine's. Moving the operator's into our namespace would take the networking off every VM they already run and put their domains inside the connection LNVPS drives. An operator listing spare capacity on a box they already use is most of the marketplace pitch.
  • It gets a private mount namespace too: two system libvirtds sharing /var/lib/libvirt contend over domain state, storage pools and sockets.
  • It carries its own storage pool and its own packet-filter definitions, because that private /etc/libvirt starts empty.

Access is TLS with a client certificate, and that is load-bearing rather than belt-and-braces. The guest bridge shares a namespace with the tunnel interface, so a customer VM can address the listener; the certificate is what makes a packet-filter regression survivable instead of handing a guest root on the node and everyone else on it. Each node is verified against the certificate it registered, not a fleet CA — one CA would mean any node's certificate satisfies a connection to any other, which is exactly the substitution being prevented.

A host is enabled only by a passing probe. LNVPS builds a real VM on the node through the ordinary customer path, logs in, measures time-to-login, memory allocated and touched (allocation alone proves nothing where the host overcommits), and disk rates with the cache bypassed — then destroys it. Results are a series (GET /api/admin/v1/marketplace/nodes/{id}/health) including failures, because one bad run is a bad afternoon and a trend is a node to act on. Nothing about the probe VM is stored, its address is derived rather than allocated, and its id sits in a range no customer VM can reach — domains are named from the VM id, so a probe sharing one would delete a customer's disk.

Bugs this found in already-merged code

Running the thing found six defects that no unit test could have caught. Three were in code that shipped weeks ago:

  1. The node's packet filter accepted only ICMP, so every TCP call LNVPS makes to a node was dropped — the libvirt connection and the control API alike. A node in that state pings, handshakes, reports itself healthy and answers nothing. True since the filter landed.
  2. AdminResource::MarketplaceNode and MarketplaceOperator were granted to nobody, and there is no super-admin bypass — so the entire admin marketplace surface has been answering 403 to every caller since increment 1: approve, suspend, drain, reject, revenue share, payouts. A test now compares the migrations against the enum so the next resource cannot repeat it.
  3. A node with an IPv6 gateway could not bring its data plane up at all. Holding an address makes the kernel route it, and the node tried to delete that route; with IPv6 the attempt fails outright and aborts the apply. The probe VM is the first IPv6 guest anywhere, which is why nothing had hit it.
  4. libvirtd's default pid file is /run/libvirtd.pid — in /run, which the instance does not replace — so the operator's daemon holds the lock and ours refused to start.
  5. libvirt will not serve a CA certificate as its own leaf. The original "self-signed and CA-capable so LNVPS can pin it" design was unstartable; the node now roots a one-certificate chain.
  6. Both ends validate the certificate they present against the file they verify the peer with, so each side's trust file carries two CAs. Each error names the complainant's own certificate, which is why it bit once per side.

The common shape: every component correct in isolation, the assembly dead. That is what the netns harness exists for, and it is now three harnesses — the tunnel, the node's libvirtd started from production's own systemd unit, and (still failing, see below) a full probe.

Testing

  • 30 unit suites green, clippy clean, migrations applied against real MariaDB.
  • sudo ./scripts/tunnel-e2e.sh — tunnel and node-libvirt harnesses pass on a real kernel, with a real libvirtd running beside the machine's own.
  • The e2e harness is now derived from one tag and one index rather than constants, which immediately exposed two places it had been agreeing with itself by accident.

Not proven yet

No probe has completed end to end. The probe e2e gets as far as a running VM — tunnel carries, libvirt authenticates, disk clones, domain starts — and then times out waiting for SSH. That test is deliberately not in this branch: the guest either is not getting its address or is not reachable across the bridge, and I would rather land the parts that are proven than a test that is red. Given this branch's track record I expect that last hop to surface one or two more assembly bugs.

So: the probe is written and unit-tested, not yet demonstrated. Everything else here has been run.

v0l added 23 commits August 7, 2026 13:03
The gate in #369 never spawned a VM. It held an IPv4 address from a customer
range, had the node hold it on the bridge, and pinged it from the route server —
a data-plane reachability check wearing the health gate's name. It proved
routing, filtering and forwarding; it proved nothing about the node building and
running a VM, which is the first thing a customer touches. It also spent an
address from the platform's scarcest pool to check the cheapest property worth
checking.

Replaced in the plan by probe VMs: a real VM built through the customer path,
IPv6 only, held in memory rather than the database so an API restart mid-probe
leaves it absent from the next document and the node tears it down, chosen at
random when a node polls, measured over SSH for login, memory that actually
allocates, disk speed and provisioning time, and recorded as a series.

Blocked on the node being able to build a VM at all, which is increment 5.
The node holds desired state and manages libvirt itself; LNVPS reads state over
the tunnel through a host client whose creating operations are no-ops, because
creation is the document's job.

Not libvirtd on the tunnel: libvirt is machine-wide, so a connection to it hands
LNVPS every domain on the operator's machine including their own, and it adds a
second authentication scheme beside the mutually-pinned channel already built.
The node is the only party that needs libvirt and is already the party that
configures the machine.

Still a host client, because every existing worker flow speaks VmHostClient: a
node that implements the reading half slots into all of them with no special
cases, and the writing half being a no-op is honest about where the write path
actually is.

Split into 4d1 (the document), 4d2 (the node's VM manager) and 4d3 (the host
client), with probe VMs following once a node can build a VM at all.
Increment 4d1. `GET /api/v1/node/state` returns a node's whole desired state:
the data plane it already got, plus every VM it should be running — shape,
image, rendered cloud-init, MAC, addresses, and whether LNVPS wants it running.

The node makes its machine match: it creates what is missing and destroys what
is absent. There is no delete message, because absence is a better one — it
survives LNVPS failing. An API that dies mid-provision leaves nothing to clean
up, since the half-built VM is simply not in the next document, and a probe VM
that only ever existed in LNVPS's memory disappears the same way.

Three things the node deliberately does not get:

- **Database rows.** The format is node-shaped. A node has no business knowing
  about users, cost plans or subscriptions, and a wire format that mirrored the
  schema would make every migration a node-compatibility question.
- **The decision about cloud-init.** It arrives rendered. Sending the
  ingredients would put the decision on the operator's machine, where a node
  running an older daemon would configure a guest differently from the rest of
  the fleet.
- **The image itself.** A URL and a checksum: the node fetches it over its own
  connection and verifies it. Streaming images through LNVPS would make every
  provision wait on our bandwidth for a file the node can get directly.

Two distinctions that matter more than they look:

- A **deleted** VM is absent, which destroys it. A **disabled** one stays and is
  marked not-running — removing it would tell the node to destroy the customer's
  disk, which is a very different thing from "stop billing this".
- A node with no tunnel is told its data plane is `null` but is still told about
  its VMs. Its disks and domains outlive its network, and a tunnel being
  re-allocated must not read as an instruction to destroy every customer on the
  machine.
…ment

The node stays dumb: tunnel, data plane, firewall, and a libvirtd that LNVPS
drives directly with the existing client. get_host_client gains an arm and every
worker flow works, because a marketplace node becomes just another libvirt host.

Two facts decide the shape. libvirtd's namespace determines where VM taps land,
so it has to run inside /run/netns/lnvps to reach br-lnvps — as a dedicated
instance, because moving the operator's libvirtd there would take the networking
off every VM they already run and hand LNVPS their domains. And guests can reach
the node's tunnel address from the bridge, so a listener there needs TLS client
certificates: one nft regression must not hand a customer VM control of the node
and everyone else on it.

Reverts the desired-state VM document, which this replaces.
…or's

The first half of driving VMs on a marketplace node over the tunnel: the node
runs a libvirtd for LNVPS's guests. Which libvirtd it is turns out to be the
whole design, and every part of it is forced by something in the code rather
than chosen.

It runs in the data plane network namespace, because a domain's tap is created
in the namespace of the libvirtd that starts it and the guest bridge only exists
there. A libvirtd in the machine's namespace would build VMs with nowhere to
plug them in.

It is a second instance rather than the machine's. Moving the operator's
libvirtd into our namespace would take the networking off every VM they already
run, and would put their domains inside the connection LNVPS drives. An operator
listing spare capacity on a box they already use is most of the marketplace
pitch.

It gets a private mount namespace as well as a network one. Two system libvirtds
sharing /var/lib/libvirt is not supported and not subtle either: they contend
over domain state, storage pool definitions and their sockets. Four BindPaths
lines give the second instance a complete separate world.

It writes logs directly instead of through virtlogd, which is reached through a
socket in /run/libvirt — one of the paths we replace, so the host's
socket-activated one is not visible in the sandbox. stdio_handler = "file"
removes the dependency rather than running a second helper daemon on hardware we
do not own.

Access is TLS with a client certificate, and that is load-bearing rather than
belt-and-braces. The guest bridge shares a namespace with the tunnel interface,
so a customer VM can address this listener; the packet filter drops that today,
and the certificate is what makes a filter regression survivable instead of
handing a guest control of the node and everyone else on it. libvirtd binds the
tunnel address only — the machine's other interfaces are the operator's LAN and
their uplink.

The server certificate is self-signed and CA-capable so LNVPS can pin it
directly as this node's trust anchor; libvirt verifies a chain rather than a
hash, and running our own CA would be a key to hold and a rotation to run for no
additional guarantee. LNVPS's client CA arrives in the node's already
authenticated document instead of being compiled in, where a rotation would
strand every deployed node. The identity persists across restarts, and is
regenerated only when the tunnel address moves, because a certificate that does
not name the address it is served on leaves the node silently unreachable.
The other half: LNVPS dials the node's libvirtd, and a marketplace node becomes
just another libvirt host. get_host_client gains an arm, so every existing
worker flow — create, start, stop, resize, migrate, terminal, stats — works
unchanged rather than being reimplemented against a node-specific protocol.

What differs from a libvirt host on our own network is trust, and that is the
bulk of this change.

Each node is verified against the certificate it registered, not against a fleet
CA. A single CA would mean any node's certificate satisfies a connection to any
other, which is exactly the substitution the certificate exists to prevent:
something else answering on a node's tunnel address and reporting that a
customer's VM is fine when it is not. libvirt's client verifies a chain against a
CA file rather than pinning a hash, which is why the node's whole certificate is
stored where the control API stores only a fingerprint.

libvirt reads its credentials from a directory, so each node gets one, with its
certificate as the CA and LNVPS's client certificate and key beside it. That
directory is a cache rather than a record: the node re-presents its certificate
on every poll, so an API host that loses it — a fresh container, a moved volume —
repairs itself within one poll interval instead of leaving nodes undialable until
somebody notices. The client key is written owner-only, because a key any process
on the API host can read is one that drives every node in the fleet.

One client certificate for the whole fleet, not one per node. Every node is told
to accept exactly one DN; a per-node client certificate would be a per-node key
to issue, ship and rotate for a decision that is identical everywhere. The CA
that signs it reaches nodes in their data-plane document, so rotating it takes
one poll rather than a new node package.

When no client identity is configured, nodes are still enrolled and networked but
no VM is placed on one, and the node is told to leave libvirt alone. The
alternative — an unauthenticated listener — would put hypervisor control on an
address the node's own guests can reach.

The node applies libvirt after its data plane and never before: libvirtd binds
the tunnel address and its unit names the namespace, so both must exist first. A
failure there is logged rather than fatal, because a node whose network is up
still carries the guests it already has, and refusing to continue would take them
down over a hypervisor LNVPS may not yet be placing VMs on.
Tests for the parts that would otherwise fail quietly, plus the changelog entry
and the work-file record.

The three that matter most are refusals. A node that never registered a
certificate is refused rather than dialled, because connecting without one means
not checking which machine answered — on hardware LNVPS does not own, at an
address that node's own guests share a namespace with. A marketplace host with no
node behind it is named as a broken row rather than producing a connection to
nowhere. And with no client identity configured, no client is built at all: an
unauthenticated hypervisor connection over the tunnel is worse than not having
the feature.

The rest cover rotation, which is the ordinary case rather than the exotic one —
a node restored from backup, or one whose tunnel address moved, regenerates its
identity and re-presents it, and both the stored certificate and the on-disk
trust anchor follow. A node that could not do that would be undialable for good.

MockDb's update dropped the new column, which would have made every test here
pass against a database that never stored anything.

The netns harness passes unchanged: it proves the network, and a hypervisor
started inside it would be testing systemd.
A disposable node stack, in lnvps_e2e::stack, with every name and address
derived from one tag and one index.

The literals were not a tidiness problem. A harness full of constants can only
run one stack at a time, so tests serialise, a run killed part-way leaves debris
the next one trips over, and two tests that both want "the node" fight over the
same interfaces. Worse, when the address in a test and the address in the
fixture are two separate constants, they can disagree — and what that looks like
is a failing assertion about production code.

This was not hypothetical. Deriving the addresses immediately exposed two places
where the harness had been getting away with agreeing with itself by accident:
the route server was configured to listen on one port while the node was told to
dial another, and the pool block was written as the route server's own address
rather than its network. Both had been invisible because every value was 0 or 1.

Names now come from the constraint that decides them. Namespaces are named after
the tag, because a human reading `ip netns list` during a failure wants to know
which test left them. Interfaces are named after the index and asserted against
the kernel's 15-byte limit, because `ip` refuses a longer name outright and a
descriptive name is worth nothing if the link cannot be created. Teardown sweeps
one list of interfaces, the same list the fixture creates from, so a link added
in one place cannot be forgotten in the other. It runs on construction as well as
on drop: a previous run killed with SIGKILL leaves its namespaces behind, and a
harness that fails because of the last failure hides which one was real.

The node's own constants — the bridge, the tunnel interface — are read from
production rather than restated, and the operator-owned `wg0` is named in the
fixture with the reason it exists: an operator using the obvious name for their
own tunnel is the case that made production rename its interface to wgln0.

Also removes the node's duplicate copy of LNVPS's client DN. The node holds no
opinion about it — LNVPS states it in the document — and a constant on that side
would only be a second opinion to disagree with. The libvirt instance's private
directories are now one list used both to create them and to render the unit that
bind-mounts them; two lists would be a mount of a directory nobody made, which
systemd reports as a unit that will not start and nothing else.
Stack::bring_up configures the route server and applies the node's data plane
from a single document, replacing the copy that lived in the tunnel test. A
second copy is a second place for the two ends to be configured differently, and
a harness whose ends disagree fails as a production bug — which is exactly how
the port and pool-block mismatches showed up in the previous commit.

The libvirt instance's namespace is now carried in Paths rather than taken from
the constant, so a harness can stand a whole node up beside a real one without
either taking the other's guests.

Preparation for running a real libvirtd under the harness. The instance is
started from the unit production renders, written where systemd reads units,
because the unit is the artefact least covered by anything else: two namespaces,
four bind mounts and a config file, none of which a unit test can do more than
assert the text of.
Running the thing found four defects, three of them in code already merged.

**The pid file.** libvirtd's default is /run/libvirtd.pid, which is in /run — a
directory this instance does *not* replace. The operator's libvirtd holds that
lock, so ours exited with "resource temporarily unavailable" naming a path
neither daemon appears to configure. It now writes inside the private
/run/libvirt, which is one of the four directories the unit already binds.

**The certificate.** libvirt refuses to serve a CA certificate as its own leaf:
"basic constraints show a CA, but we need one for a server". The whole
self-signed-and-CA-capable design was therefore unstartable. The node now roots
its own one-certificate chain — a CA, which is what it registers with LNVPS and
what LNVPS verifies against, and a server certificate signed by it, which is what
libvirtd presents. LNVPS still runs no CA for node identities.

**The trust files.** Both ends validate the certificate they *present* against
the same file they verify the far end with. With only the peer's CA in it, the
daemon will not start and the client will not connect, and both errors name the
complainant's own certificate rather than anything about the peer. Each side's
file now carries two CAs. The cost is stated in the code: a client certificate
issued by the node's own CA would also pass the node's check — tls_allowed_dn_list
still admits only LNVPS's DN, and anything holding that CA key already owns the
machine.

**The filter.** The node's input chain accepted ICMP and nothing else, so every
TCP call LNVPS makes to a node was dropped — the libvirt connection and the
control API alike. That is a node which pings, handshakes, reports itself
healthy, and answers nothing, and it has been the case since the filter landed.
TCP is now accepted on the control and libvirt ports, bound to the tunnel
interface and never the guest bridge: the bridge shares this namespace with the
tunnel, and libvirtd on that address is root on the machine.

Also gives the unit a RestartSec. Without it systemd spent all five attempts
inside a second and left "start request repeated too quickly" as the last thing
an operator sees, which says nothing about the cause — as it did here.
Five findings, three in already-merged code, none reachable by a test that
asserts what code decides rather than what a kernel does. The filter one is the
lesson: 4c2 and 4c3a each shipped with tests asserting the right ruleset and the
right client, and the combination was inert, because nothing asserted a packet.
The foundation for probe VMs: an address a probe can be reached on, and a series
to record what it found.

**The address is derived, not allocated.** A probe stores nothing — no VM row, no
IP assignment, no subscription — because a probe that outlives the process which
made it is our VM left running on hardware we do not own, and a table of them
needs a reaper, which is one more thing to fail quietly. But a guest is only
reachable if its address is in three places it does not control: the route
server's routes, the peer's AllowedIPs, and the node's packet filter, all built
from the database. An address that is not in the database is an address the
network drops. So the probe's is a pure function of the node's own inner
address — both ends can work it out, and an API that dies mid-probe leaves
nothing to reclaim.

It is IPv6 only. IPv4 is the scarce resource the marketplace exists to stretch,
so spending one to check a machine is backwards, and a node that cannot carry a
v6 guest cannot carry a dual-stack one.

The address is in the node's document **whether or not a probe is running**. A
probe lives for a few minutes between two of the node's polls; a document that
named it only while it ran would give probes that fail because the node had not
fetched the document yet, and nodes that tear the address down while LNVPS is
still logged in. It costs one entry in an anti-spoof list, and it buys a check
whose result never depends on timing.

**The results are a series, not a verdict.** One bad run is a bad afternoon — a
backup job, a neighbour compiling something — and suspending a node for it would
make the marketplace hostile to the people it needs. A trend is a node to act on,
and increment 12's trust tier wants the history rather than the last answer.
Failures are rows too: a node that never completes a probe looks identical to one
nobody probed unless they are written down.

Each row carries the shape and image it measured. Regions sell different
templates, so comparing raw seconds across nodes would rank machines by what we
happened to ask them for. Denormalised on purpose — a template edited later must
not change what an old measurement appears to say.
The runner. LNVPS assembles a VM in memory, builds it on the node through the
ordinary client, hands it to a measurement, and destroys it on every path out —
success, failure, or panic.

**The id is the safety property.** Domains on a node are named from the VM id and
delete_vm finds a domain by that name, so a probe sharing an id with a customer's
VM would destroy a customer's disk. Probe ids start at 0xFFFF_0000_0000_0000: an
AUTO_INCREMENT column would have to issue eighteen quintillion rows to reach it,
which makes the collision impossible rather than unlikely. The id is also stable
per node, so a probe left behind by a killed process is found and removed by the
next one instead of accumulating — and it is removed *before* a probe starts as
well as after, because a stale domain would otherwise make every later probe on
that node fail for a reason that has nothing to do with the machine.

**Nothing is stored about the VM.** It exists in this process's memory and in the
node's libvirt. A row pointing at one would need a reaper, and a reaper that
fails leaves our VM running on hardware we do not own. A delete that fails is
reported on the result rather than logged, because a node whose probes cannot be
cleaned up is one LNVPS is accumulating VMs on, and that belongs in the series
rather than in a log nobody reads.

**It is built from what customers buy** — the region's cheapest enabled template
and a real image of the matching architecture, with cloud-init rendered by the
same code a customer's VM uses. A dedicated probe shape would be one more thing
to keep in step, and a node could pass on it while failing on everything for
sale. The synthetic IP assignment carries a bare address because that is what the
database stores: the renderer parses it as an address and silently drops anything
with a prefix, which boots a guest with no network on a node that is perfectly
fine.

An unreachable node is a failed probe rather than an error thrown away, because
an unreachable node is exactly what this exists to detect.
AdminResource::MarketplaceNode (28) and MarketplaceOperator (29) were added to
the enum and used by every admin marketplace endpoint, and never granted to any
role. There is no super-admin bypass — has_permission is a set lookup against
the tuples a role holds — so the entire admin surface of the marketplace has
been answering 403 to every caller since it landed: approve, suspend, drain,
reject, revenue share, payouts, node status.

Nothing failed when the resources were introduced, because a resource with no
grants is a legitimate state: it means nobody may use it. That is the same shape
as the node filter that accepted only ICMP — each piece correct, the combination
inert.

The two resources stay separate, as intended: node actions are operational and
operator actions are money, and an admin who can pull a misbehaving node out of
service should not thereby be able to change what its owner is paid.
A resource nobody granted is a set of endpoints that answer 403 to every caller,
including super_admin, and nothing fails at the point the omission is made — a
resource with no grants is a legitimate state as far as the schema is concerned.
That is how the marketplace surface shipped unusable.

The test reads the migrations as text and compares the grants it finds against
the AdminResource enum. Crude, and the only way to check this without a
database: the grants live in SQL, the enum lives in Rust, and nothing else
compares them.

Verified by removing the grant migration, at which point it names exactly the
eight missing marketplace tuples.

Writing it turned up the three shapes the migrations use — SELECT-based grants,
three-column tuples in the original seed with the role as a SQL variable, and
four-column tuples carrying an explicit created_at. A parser that understood only
one of them would have reported healthy resources as ungranted, which is the
failure mode that gets a test deleted rather than fixed.
The measurement half of the probe. Everything LNVPS can see from outside is what
the operator's node chooses to report; this is the part that finds out what a
customer would actually get, by being one for a few minutes.

Three numbers, each chosen for what it catches:

- **Time to first login**, from asking for the VM to having a shell. It catches
  the node that is technically working and unusably slow.
- **Memory allocated and touched**, written through /dev/shm. Allocation alone
  proves nothing where the host overcommits — the pages have to be faulted in
  and filled before the machine admits it does not have them. A node reselling
  four times its RAM passes an allocation check and fails this.
- **Disk write and read rates**, with conv=fdatasync and the cache dropped. An
  operator can present an NVMe node backed by a network volume, or a disk that
  has begun to fail; both are visible as a rate and invisible as a
  specification.

Nothing is installed in the guest. A probe that waited on a package mirror would
be timing the internet, and a node with no outbound access would fail a test
about the node.

Rates are timed here rather than parsed out of dd, whose output format varies
with version and locale — a parser that quietly returned zero on an unfamiliar
line would report healthy nodes as broken. The login window is deliberately
long: a slow node is a finding to record, and a tight limit turns "this node is
slow" into "this node did not answer", which is less useful and more alarming.

The keypair is generated per probe and dropped with it, and never printed: a
long-lived key that opened a shell on every operator's node would be the most
valuable secret LNVPS holds. Key generation lives in lnvps_api_common beside the
SSH client that has to load it back, and a test proves the pair matches —
a mismatch would look exactly like a node refusing logins.

Finding the right RNG took four attempts: the dependency graph carries three
versions of rand_core, ssh-key's CryptoRng bound resolves to 0.10's, and 0.10
removed OsRng in favour of rand::rng(). The compiler names the trait through
x25519_dalek, which is why it is worth writing down.
Ties the two halves together and adds the scheduling.

run_probe builds a VM on a node, logs in as the image's own default user — a
probe that assumed root would fail on every image that disables it, which is
most of them, and look like a broken node — measures, destroys, and records.
The result is written down whatever it is: the first thing anybody will ask
about a suspended node is what it did before.

Selection is a cooldown and an ordering, not a scheduler. A node that has never
been probed comes first, because it is the one LNVPS knows nothing about and may
already be placing customers on; after that, longest since. A node probed within
six hours is left alone — a probe costs the operator a disk clone, a boot and a
few hundred megabytes of I/O on hardware they are barely being paid for, and
doing it every few minutes would be indistinguishable from abuse. A *failed*
probe starts the same cooldown, because retrying a broken node immediately
hammers the machine least able to cope, and the failure is already recorded for
whoever decides what to do about it.

An approved node with no host yet is skipped rather than recorded as a failure:
it is a state every node passes through on the way in, and a failure row would
make a normal enrolment look like a broken machine.

The shell commands are now pure functions with their own tests, which is both
better structure and the only way to cover them: the wrappers around them need a
live guest, which is the e2e's job. Worth having done — the tests state why the
memory test writes 45% of RAM (tmpfs defaults to half, and the guest still has
to run), why it frees the memory in the same command (a lost connection must not
leave RAM full), and why the disk write syncs (without it a slow disk with
plenty of RAM reports a gigabyte a second, which is the exact node this exists
to catch).
Wires the probe into the worker and makes a passing probe the thing that opens a
node to customers.

This is the gate the whole marketplace rests on. An enabled host is one LNVPS
will place paying customers on, and until now the only thing standing between an
operator's machine and a customer was that the machine answered questions about
itself. Everything LNVPS can see from outside is what the node chooses to
report; a VM having actually run there is not.

One node per sweep, every five minutes. The per-node cooldown decides how often
any given machine is touched; this only decides how quickly a newly approved node
gets its first VM, which is what enables its host and lets it start earning. A
sweep that probed the whole fleet at once would arrive as a thundering herd on
the operators least able to absorb it.

A failing probe is recorded and not acted on. One bad run is a bad afternoon — a
backup job, a noisy neighbour — and taking a node out of service for it would
make the marketplace hostile to the people it needs; suspension on a trend is
increment 12's job. A node that cannot be probed at all does not have its host
enabled, which is asserted directly, because the failure mode here is not a
crash but a customer placed on hardware nobody has tested.

A build without the linux-ssh feature refuses to probe rather than probing
without measuring: creating and destroying a VM proves the node can build one,
and reporting that as health would be the overclaim this whole increment exists
to avoid.
GET /api/admin/v1/marketplace/nodes/{id}/health — the probe series, newest
first, paged at the database.

A series rather than a verdict, because that is the question an admin actually
has. One bad run is a bad afternoon; a node worth suspending is one whose numbers
have been getting worse. Failures are rows with the reason verbatim: somebody
deciding whether to take an operator's hardware out of service needs what
actually happened rather than a category, and a node that never completes a probe
is indistinguishable from one nobody probed unless the failures are visible.

Every row carries the shape and image it measured. Regions sell different
templates, so comparing raw numbers across nodes would rank machines by what
LNVPS happened to ask them for.

Remembered rather than live, unlike /status: these are measurements that were
made, and re-running one because somebody opened a page would put real load on an
operator's machine.

The permission test is deliberate: this endpoint checks marketplace_node::view,
which is the check that was granted to nobody until this week.
The instance's /etc/libvirt is its own and starts empty, so the pools on the
operator's machine are not visible to it — that is the isolation working, not a
problem to route around. But it means every VM creation would have failed with
"storage pool not found", which nothing so far would have caught: the client
asks for a pool by name, and a name that does not exist is a runtime error on
the first real provision.

Defined by writing the XML libvirtd reads at startup rather than through
libvirt's API, because this instance's /etc/libvirt is ours to write. The node
therefore still needs no libvirt client: the daemon stays a thing that writes
configuration files, which is the whole reason it is small enough to audit.

Autostart is a symlink into the definitions directory, which is how libvirt
records it itself — a copy would be a second definition free to drift, and a
pool that has to be started by hand is a node that works until it reboots.

The pool name is fixed rather than configurable. LNVPS's client asks for a pool
by name, so a setting here would be a setting an operator could change into a
node that silently refuses every VM. Its target is a plain directory an operator
can bind-mount or symlink elsewhere, which keeps the arrangement one libvirt
understands without LNVPS having to describe every storage layout.

The e2e now asserts the pool exists and is running on the real daemon.
Holding an address on an interface makes the kernel route it, and that route is
not the node's to remove. With IPv6 the attempt fails outright — "no such
process" — which aborts the whole apply, so a node with an IPv6 gateway could
not bring its data plane up at all.

Found by the first probe VM, which is the first guest anywhere with an IPv6
gateway: every customer so far has had a v4 one, where the kernel keeps the
equivalent route in the local table and the reconcile never saw it. The unit
test fails without the fix.

Also copies libvirt's stock packet-filter definitions into the LNVPS instance.
A guest's interface references `no-mac-spoofing`, and libvirt refuses to start a
domain whose filter is missing; the operator's copies are not visible in this
instance, which is the isolation working. Copied rather than reimplemented —
writing our own would filter a marketplace guest differently from every other
libvirt guest on earth, invisibly until it mattered.

The stack fixture gains apply_pool, which configures the route server from the
database through plan_pool exactly as the worker does. Building that side by
hand let the harness configure it more helpfully than the API would, which is
how a probe that fails in production passes in a test.
A probe VM now runs end to end against a real node: LNVPS builds it through the
customer path, logs in over the tunnel, measures it and destroys it. The e2e
takes 75 seconds and is in scripts/tunnel-e2e.sh with the other three.

Getting there found four more bugs, three of them in code every customer VM
already goes through.

**An all-digit MAC is a number to YAML.** `52:54:01:00:00:01` unquoted is
sexagesimal — 41135256001 — so netplan rejects it, cloud-init aborts its network
stage, and the guest boots with no network on a host that is working. A MAC with
any hex letter is a string and works, so this breaks roughly one VM in sixteen
and looks like bad hardware. Values are now quoted. The test asserts the
rendered text rather than round-tripping: the parser in this workspace is YAML
1.2, which dropped sexagesimal and reads it correctly either way, while the
parser that consumes the file is netplan's 1.1.

**A gateway outside the guest's prefix needs on-link.** Without it netplan
silently produces a configuration in which the guest cannot reach its router at
all.

**A probe's gateway must not be the route server's address.** The node holds its
guests' gateway on the bridge, and the route server holds its own address on the
same pool: sharing it means the guest's replies are delivered to the node and
the route server never sees them. Confirmed by tcpdump — echo requests arriving,
replies vanishing into the node.

**A guest whose prefix covers the pool treats the route server as on-link** and
resolves it on a bridge where nothing answers. The probe is now addressed with a
host prefix and an on-link gateway, so everything it sends goes to the node,
which is the only thing on that link that can route.

The last two are the same mistake from opposite ends, and both were invisible in
unit tests: every assertion about addressing passed while no packet could
complete a round trip.

The login window now starts when the guest does rather than when the VM was
requested. Building it includes fetching an OS image the node has never seen and
cloning a disk — minutes on a cold node — and a deadline measured from the
request spends its whole budget there and then condemns the node for being new.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant