Make the hypervisor identity plural so a deployment can run more than one #89

Closed
opened 2026-09-21 18:48:29 +01:00 by allod-agent · 4 comments
Member

Let a deployment run more than one hypervisor by making the hypervisor identity an attrset keyed by machine name, the way the dev and privacy archetypes already are.

Primary goals:

  • Plural hypervisor identities — secrets exports hypervisorIdentities keyed by machine name instead of the singleton nexusIdentity, so a deployment can declare a laptop hypervisor and a server hypervisor in one inventory.
  • One merge rule for all three archetypes — archetypes merges hypervisor identities exactly as it merges devIdentities and privacyIdentities, and the arity assert stops special-casing "plus one".
  • No name assumed anywhere — the framework reads machine names from inventory and identity records from secrets, and assumes neither the count nor the spelling of either.

Current state and specifics

secrets exports one hypervisor identity record, lib.nexusIdentity, and archetypes binds it into the identity set at flake.nix:607:

identities =
  let merged = devIdentities // privacyIdentities // { nexus = nexusIdentity; };

The literal nexus key is being replaced by nexusIdentity.hostname in the work for allod/archetypes#88, which frees the name but leaves the count at one. machineConfigurations asserts identityNames == machineNames (flake.nix:628), so a second machine of type hypervisor in inventory has no identity to bind and fails there, and the arity assert immediately below flake.nix:607 hard-codes + 1.

Nothing else in the framework assumes a single hypervisor: mkHypervisor (flake.nix:506) is already a per-machine builder taking name, identity and definition, and profiles already keys hypervisor profile definitions by name.

The trigger that decides when this is worth doing: a second machine of type hypervisor is declared in any deployment's inventory. Until then the singleton costs nothing, and the contract is better written against a real second hypervisor than guessed at now.

Scope

In scope: the secrets export (hypervisorIdentities, replacing or alongside nexusIdentity), the merge and arity assert in archetypes/flake.nix, and whatever in the deployment's forks keys off the singleton.

Tracked elsewhere: freeing the hypervisor name from the literal nexus, which lands with allod/archetypes#88.

Refs allod/archetypes#88

Let a deployment run more than one hypervisor by making the hypervisor identity an attrset keyed by machine name, the way the dev and privacy archetypes already are. Primary goals: - **Plural hypervisor identities** — `secrets` exports `hypervisorIdentities` keyed by machine name instead of the singleton `nexusIdentity`, so a deployment can declare a laptop hypervisor and a server hypervisor in one inventory. - **One merge rule for all three archetypes** — `archetypes` merges hypervisor identities exactly as it merges `devIdentities` and `privacyIdentities`, and the arity assert stops special-casing "plus one". - **No name assumed anywhere** — the framework reads machine names from inventory and identity records from secrets, and assumes neither the count nor the spelling of either. ### Current state and specifics `secrets` exports one hypervisor identity record, `lib.nexusIdentity`, and `archetypes` binds it into the identity set at `flake.nix:607`: identities = let merged = devIdentities // privacyIdentities // { nexus = nexusIdentity; }; The literal `nexus` key is being replaced by `nexusIdentity.hostname` in the work for allod/archetypes#88, which frees the *name* but leaves the *count* at one. `machineConfigurations` asserts `identityNames == machineNames` (`flake.nix:628`), so a second machine of type `hypervisor` in inventory has no identity to bind and fails there, and the arity assert immediately below `flake.nix:607` hard-codes `+ 1`. Nothing else in the framework assumes a single hypervisor: `mkHypervisor` (`flake.nix:506`) is already a per-machine builder taking `name`, `identity` and `definition`, and `profiles` already keys hypervisor profile definitions by name. The trigger that decides when this is worth doing: a second machine of type `hypervisor` is declared in any deployment's inventory. Until then the singleton costs nothing, and the contract is better written against a real second hypervisor than guessed at now. ### Scope In scope: the `secrets` export (`hypervisorIdentities`, replacing or alongside `nexusIdentity`), the merge and arity assert in `archetypes/flake.nix`, and whatever in the deployment's forks keys off the singleton. Tracked elsewhere: freeing the hypervisor *name* from the literal `nexus`, which lands with allod/archetypes#88. Refs allod/archetypes#88

The trigger this issue names is about to be met: a deployment is adding a headless server hypervisor beside its laptop hypervisor, to run the dev machines. Plural identities are necessary for that and not sufficient. Three more places assume one hypervisor, and a deployment that declared a second one after this issue landed as written would evaluate and still be wrong.

Read against the public masters on 2026-09-29.

What assumes one hypervisor Where What goes wrong with two
Who can decrypt a secret allod/secrets secrets.nix: every entry is encrypted to the one hostKey; lib/pi-credential-recipients.nix takes one hypervisorPublicKeys list Every secret is readable by both hypervisors or by neither. Nothing can say "this secret belongs to that hypervisor", so an always-on server reachable over SSH decrypts what only the laptop consumes
Which hypervisor runs a guest allod/inventory flake.nix: a guest entry names no hypervisor, and vm-specs.json lists every guest. allod/nexus scripts/new-vm:5-9 offers every key in that file Either hypervisor can create any guest, with the same reserved address and MAC on its own default network. Nothing stops a second copy of a machine that already exists
Whose key a machine authorizes nix/builders/dev.nix:69 and nix/builders/privacy.nix:19 hand every guest secrets.lib.identity.hostPublicKeys; nix/builders/hypervisor.nix:30,59 authorizes a hypervisor's own keys on itself; flake.nix:304 does the same for the installer's root A guest authorizes every hypervisor or the wrong one, when it should authorize the one that runs it. A headless hypervisor has to authorize a key that is not its own, and the identity record has no field for that

What each one wants

  • Recipients per hypervisor — a secret names the hypervisors that consume it, and the recipient list is derived from that. The default for a secret that names none should be a refusal, not "all of them".
  • A host fact on every guest — inventory states which hypervisor runs each guest, the facts a provisioning script reads are filtered to the hypervisor it runs on, and a guest with no host fails evaluation.
  • Authorization derived from the host fact — a guest authorizes its own hypervisor's keys. A hypervisor's identity gains a list of operator keys it accepts, empty by default, so a laptop hypervisor authorizes nobody.

Not this issue

Remote administration is a separate question and wants its own issue. The host module runs no sshd and opens no port (allod/nexus nix/configuration.nix:126), which is the right posture for a machine someone sits at. A headless hypervisor needs a way in, and the shape already used for internet-facing machines fits: a module the machine imports, with no enable option, so a hypervisor that does not import it has no listener by construction. A deployment can then assert its absence on the laptop with a build check.

Suggested handling

Keep this issue as the canonical goal and either widen its scope to the three rows above or file one child for each. The first row should not land after the rest: between plural identities landing and recipients being split, declaring a second hypervisor encrypts every existing secret to it.

The trigger this issue names is about to be met: a deployment is adding a headless server hypervisor beside its laptop hypervisor, to run the dev machines. Plural identities are necessary for that and not sufficient. Three more places assume one hypervisor, and a deployment that declared a second one after this issue landed as written would evaluate and still be wrong. Read against the public masters on 2026-09-29. | What assumes one hypervisor | Where | What goes wrong with two | | --- | --- | --- | | Who can decrypt a secret | `allod/secrets` `secrets.nix`: every entry is encrypted to the one `hostKey`; `lib/pi-credential-recipients.nix` takes one `hypervisorPublicKeys` list | Every secret is readable by both hypervisors or by neither. Nothing can say "this secret belongs to that hypervisor", so an always-on server reachable over SSH decrypts what only the laptop consumes | | Which hypervisor runs a guest | `allod/inventory` `flake.nix`: a guest entry names no hypervisor, and `vm-specs.json` lists every guest. `allod/nexus` `scripts/new-vm:5-9` offers every key in that file | Either hypervisor can create any guest, with the same reserved address and MAC on its own default network. Nothing stops a second copy of a machine that already exists | | Whose key a machine authorizes | `nix/builders/dev.nix:69` and `nix/builders/privacy.nix:19` hand every guest `secrets.lib.identity.hostPublicKeys`; `nix/builders/hypervisor.nix:30,59` authorizes a hypervisor's own keys on itself; `flake.nix:304` does the same for the installer's root | A guest authorizes every hypervisor or the wrong one, when it should authorize the one that runs it. A headless hypervisor has to authorize a key that is not its own, and the identity record has no field for that | ### What each one wants - **Recipients per hypervisor** — a secret names the hypervisors that consume it, and the recipient list is derived from that. The default for a secret that names none should be a refusal, not "all of them". - **A host fact on every guest** — inventory states which hypervisor runs each guest, the facts a provisioning script reads are filtered to the hypervisor it runs on, and a guest with no host fails evaluation. - **Authorization derived from the host fact** — a guest authorizes its own hypervisor's keys. A hypervisor's identity gains a list of operator keys it accepts, empty by default, so a laptop hypervisor authorizes nobody. ### Not this issue Remote administration is a separate question and wants its own issue. The host module runs no sshd and opens no port (`allod/nexus` `nix/configuration.nix:126`), which is the right posture for a machine someone sits at. A headless hypervisor needs a way in, and the shape already used for internet-facing machines fits: a module the machine imports, with no `enable` option, so a hypervisor that does not import it has no listener by construction. A deployment can then assert its absence on the laptop with a build check. ### Suggested handling Keep this issue as the canonical goal and either widen its scope to the three rows above or file one child for each. The first row should not land after the rest: between plural identities landing and recipients being split, declaring a second hypervisor encrypts every existing secret to it.

A correction to my comment above, and where the work stands.

Correction

I wrote that declaring a second hypervisor after plural identities land "encrypts every existing secret to it". That is wrong. Recipients come from the primary's key slot, identity.hostPublicKeys (secrets.nix, lib/pi-credential-recipients.nix), not from the identity list. A second identity encrypts nothing new, and a second hypervisor decrypts nothing until a secret's recipient list names it. So plural identities are safe to land first and alone.

The narrower rule that does hold: the primary's key slot must never hold a second hypervisor's key. A second hypervisor's host key is registered in machine-host-keys.json like any machine, and the primary is never registered there.

Prepared, awaiting relay

Five branches cover this issue and the three gaps. Each is independent of the others: it evaluates against today's revisions of its inputs, so none needs a lock bump before it. With one hypervisor declared, every machine and the installer evaluate to the same derivation as before.

Repository Branch What it does
allod/secrets agent/hypervisor-identities Exports lib.hypervisorIdentities beside lib.nexusIdentity; adds operatorPublicKeys
allod/secrets agent/hypervisor-recipient-coverage Checks that a secret a guest can read is readable by the hypervisor that runs it
allod/inventory agent/host-fact Optional host fact on a guest; required once there is more than one hypervisor
allod/archetypes agent/plural-hypervisors Merges plural identities, carries host in vmFacts, derives authorized keys from it
allod/nexus agent/host-guard Provisioning scripts refuse a guest another hypervisor runs

Remote administration is separate: allod/nexus#68.

Left open by these changes

  • Rotating a second hypervisor's host key has no tooling; the rotation scripts assume one hypervisor key file.
  • The installer is one image for the fleet, so it trusts every declared hypervisor's keys.
  • fork-safety.sh was not run on the archetypes branch; it belongs to the lock-bump path.
A correction to my comment above, and where the work stands. ### Correction I wrote that declaring a second hypervisor after plural identities land "encrypts every existing secret to it". That is wrong. Recipients come from the primary's key slot, `identity.hostPublicKeys` (`secrets.nix`, `lib/pi-credential-recipients.nix`), not from the identity list. A second identity encrypts nothing new, and a second hypervisor decrypts nothing until a secret's recipient list names it. So plural identities are safe to land first and alone. The narrower rule that does hold: the primary's key slot must never hold a second hypervisor's key. A second hypervisor's host key is registered in `machine-host-keys.json` like any machine, and the primary is never registered there. ### Prepared, awaiting relay Five branches cover this issue and the three gaps. Each is independent of the others: it evaluates against today's revisions of its inputs, so none needs a lock bump before it. With one hypervisor declared, every machine and the installer evaluate to the same derivation as before. | Repository | Branch | What it does | | --- | --- | --- | | `allod/secrets` | `agent/hypervisor-identities` | Exports `lib.hypervisorIdentities` beside `lib.nexusIdentity`; adds `operatorPublicKeys` | | `allod/secrets` | `agent/hypervisor-recipient-coverage` | Checks that a secret a guest can read is readable by the hypervisor that runs it | | `allod/inventory` | `agent/host-fact` | Optional `host` fact on a guest; required once there is more than one hypervisor | | `allod/archetypes` | `agent/plural-hypervisors` | Merges plural identities, carries `host` in `vmFacts`, derives authorized keys from it | | `allod/nexus` | `agent/host-guard` | Provisioning scripts refuse a guest another hypervisor runs | Remote administration is separate: allod/nexus#68. ### Left open by these changes - Rotating a second hypervisor's host key has no tooling; the rotation scripts assume one hypervisor key file. - The installer is one image for the fleet, so it trusts every declared hypervisor's keys. - `fork-safety.sh` was not run on the archetypes branch; it belongs to the lock-bump path.
vnprc referenced this issue from a commit 2026-09-30 03:04:54 +01:00

Everything this issue asks for is on master: hypervisor identities are read as a set keyed by hostname, merged by the same rule as the dev and privacy archetypes through nix/identities.nix, and the arity assert no longer hardcodes one hypervisor. The three gaps from the comment above landed alongside: the host fact (inventory, vmFacts, the provisioning guard), authorization derived from it, and the recipient coverage check in secrets. The post-landing fixes are allod/archetypes#121, allod/nexus#70, allod/secrets#39 and allod/inventory#19, all closed. The one thing still open is the transitional fallback, allod/archetypes#122. This issue can be closed as completed by its author or a maintainer; my account cannot close it.

Everything this issue asks for is on master: hypervisor identities are read as a set keyed by hostname, merged by the same rule as the dev and privacy archetypes through nix/identities.nix, and the arity assert no longer hardcodes one hypervisor. The three gaps from the comment above landed alongside: the host fact (inventory, vmFacts, the provisioning guard), authorization derived from it, and the recipient coverage check in secrets. The post-landing fixes are allod/archetypes#121, allod/nexus#70, allod/secrets#39 and allod/inventory#19, all closed. The one thing still open is the transitional fallback, allod/archetypes#122. This issue can be closed as completed by its author or a maintainer; my account cannot close it.
Owner

complete

complete
vnprc closed this issue 2026-09-30 04:31:58 +01:00
Sign in to join this conversation.
No milestone
No project
No assignees
3 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
allod/archetypes#89
No description provided.