Tenant Admission #
The operator’s half of creating a tenant. The operator admits a name; the tenant then declares everything else itself, through the Deevnet API ( ADR-0015). The tenant’s half is Tenant Operations.
Admission is the only substrate act a new tenant needs. The operator never edits an inventory file to make a tenant, and the tenant never holds a Proxmox credential, a vault password or an index.
Before you start #
| The API | https://api.mobile.deevnet.net, reachable from the Builder |
| A work session | OpenBao open: ANSIBLE_ASK_VAULT_PASS=true make bao-open from ansible-collection-deevnet.mgmt (
A work session). Admission, a Wi-Fi key rotation and every operator-only call below need it. While it is sealed the API answers 503, OpenBao is sealed or unreachable; tenant work waits until it is opened, and changes nothing |
| The operator token | nothing to fetch: make admit reads it from the running API container over SSH, and never prints it. It is also vault_deevnet_api_token, in the inventory’s deevnet_api group vault, for calling the API by hand |
| The Deevnet Root CA | ansible-inventory-deevnet/pki/deevnet-root-ca.pem: the one trust anchor for every Deevnet site, the same for every tenant (
ADR-0031). Tenants download it under the same name |
| The provider | the tenant installs deevnet/deevnet 0.5.x itself with install-provider.sh (on Windows, install-provider.ps1) from the tenant downloads (
Before You Start). No role installs it. Before tenants will be downloading from the site, check the downloads tree is current: deevnet.builder site.yml --tags fetched-artifacts, the provider repo’s make stage, the image factory’s make pi-backend-publish, then deevnet.mgmt site.yml --tags tenant-downloads |
1. Admit the name #
From ansible-collection-deevnet.mgmt on the Builder:
make admit NAME=mabell # or bind the Wi-Fi key to one laptop:
make admit NAME=mabell MAC=AA-BB-CC-00-11-22
admitted mabell: expires 2026-09-26T…Z, Wi-Fi DVNTM-TD
handover details: /home/<you>/mabell-admission.txt (mode 0600)
The terminal shows nothing secret. ~/<name>-admission.txt holds everything the tenant is handed:
the enrollment token and its expiry, the API endpoint, the DVNTM-TD Wi-Fi key, the Deevnet Root CA’s URL
and fingerprint, and the tenant’s first three steps. It is written to be read and passed on by a
person.
The Wi-Fi key is the tenant’s own DVNTM-TD key
(
ADR-0029): a
tenant needs that network to reach the API at all, so its first key comes with the admission. MAC=
binds it to one laptop. When the tenant creates itself, the key becomes its own Wi-Fi key
admission.
| The name | ^[a-z][a-z0-9]{0,7}$ — it becomes a PVE SDN zone ID, a DNS label and a state-store user |
| A reserved name | refused with 400: home, mobile, mgmt, dvnt, dvntm, device, mqtt and artifacts. A tenant’s name is the second label of its site-less names, <service>.<tenant>.deevnet.net, so a tenant named for a site or a global alias would hold that site’s or alias’s names (
ADR-0040 §4). The list grows with the sites and aliases |
| The token | single-use, expires after DEEVNET_ENROLLMENT_TTL (72h by default) |
| A name already registered | answers 409. Admission creates nothing but that key; it only authorizes |
| Admitting the name again | issues a new key; the one handed over before stops working. make admit refuses while ~/<name>-admission.txt exists; FORCE=1 admits anyway |
| An admission never used | make unadmit NAME=<name> revokes its key (DELETE /v1/admissions/<name>) and deletes the handover file. The token itself just expires |
2. Hand over four things #
The tenant needs exactly these, and nothing else from the substrate. All four are in
~/<name>-admission.txt:
- the enrollment token
- the API endpoint,
https://api.mobile.deevnet.net - the
DVNTM-TDWi-Fi key ( §3) - the Deevnet Root CA, at
https://downloads.mobile.deevnet.net/deevnet-root-ca.pem, with its fingerprint
The token is bound to the name. Presenting it for a different name spends it and answers 401,
so a mistyped name costs a new admission.
Delivery is by hand today. ADR-0012 §9 says the token is delivered age-encrypted to the tenant’s own key; that tooling is not built. Until it is, hand the token over on a channel you would trust with a password, and admit close to when the tenant will apply — an unspent token is a credential for 72 hours. Delete
~/<name>-admission.txtonce it is handed over: after the first apply the Wi-Fi key in it is the tenant’s to keep, not yours.
3. Where the tenant applies from #
A tenant’s applies need OpenBao open too, so they run inside a work session. While it is sealed, every call with the tenant’s token answers 503, OpenBao is sealed or unreachable; tenant work waits until it is opened, and nothing changes; the tenant applies again once the session is open.
Every apply has to reach the API, and after the first one the state store too. Give the tenant the
DVNTM-TD key, the one in the admission’s wifi, not a shared one: that segment reaches the API, the state store,
the broker, the log store, Grafana and SSH on tenant workloads, and nothing else
(
CHG-0022,
CHG-0024). Guest is
internet-only and IoT cannot reach the API. A trusted seat still works, but it reaches the
management plane, so don’t offer it to a visitor. See
Before You Start for how the tenant side
reads this.
You have no standing access to a tenant’s workloads
(
ADR-0028). They clone the
fedora-tenant-* template, which carries no a_autoprov, and trust only the keys in the tenant’s
ssh_keys. To help on one, the tenant adds your public key and replaces the workload.
Rotating a tenant’s Wi-Fi key #
A tenant’s DVNTM-TD password is handed over once: the API never returns a key’s password again,
so a lost one is replaced, not recovered. Rotate the key to give it a new password — after a loss, a
possible leak, or on a schedule:
make rotate-wifi-key NAME=<tenant> # the admission key
make rotate-wifi-key NAME=<tenant> KEY=<key> # any other key
It shows the key’s trust class, SSID and MAC binding, asks for the tenant’s name, then deletes the key
and creates it again under the same name and settings. The new password goes to
~/<tenant>-wifi-<key>.txt (mode 0600), to hand over like an admission. The old password stops
working on every device that used it; each one forgets the network and joins again.
Only the admission key is the operator’s to rotate. It came with the admission and the tenant’s
Terraform does not declare it. A key the tenant declares, such as its devices’ key, lives in the
tenant’s state, and rotating it here would leave that state holding the old password: the tenant
rotates its own with terraform apply -replace=<the key's resource>.
Reissuing a state secret #
A tenant that has lost the keys to its own state gets a new state secret from the operator. The keys live inside the state they unlock, and the site never hands back the stored secret, so a new one is the only way back in. OpenBao must be open, in a work session:
make reissue-state-secret NAME=<tenant>
It asks for the tenant’s name, then has the API mint a new secret and set it on the state store. The
old secret stops working for everyone who holds it, CI included. The new one goes to
~/<tenant>-state-secret.txt (mode 0600), as the two lines of the tenant’s .backend.env, to hand
over like an admission.
The tenant must not restore from its old state afterwards. That state still holds the old secret, and a restore sends back the secrets in state, which win. A restore from it would put the old secret back and lock the tenant out again.
What the substrate does on the first apply #
| Network | an EVPN zone and VRF on the tenant hypervisor, one VNet, a /24 from 10.20.128.0/18, anycast .1, SNAT at the exit node |
| DNS | <tenant>.<site>.deevnet.net and its reverse, delegated, with a TSIG key the tenant writes records with. The Mobile Factory serves no site-less zone: a tenant’s <tenant>.deevnet.net names resolve only at a production site (
ADR-0040 §1) |
| State | a bucket prefix and keys in the state store |
| Logs | partitions (index, 0..2), an ingest and a read token, and the vmauth routes that separate them from every other tenant (
ADR-0027) |
| Dashboards | a Grafana organization named for the tenant, one Editor login, and three log data sources carrying its read token, with fixed UIDs (
ADR-0024). A delete renames the emptied organization deleted-<tenant>-<id>, because Grafana 13 cannot delete one |
| Devices, if declared | a PPSK per trust class, registry entries, and MQTT accounts confined to the tenant’s own topic prefix |
The index is the tenant’s number in all of it — subnet, VXLAN VNI, log account — so it is allocated once, against both the registry and the live fabric, and never by hand.
To confirm the fabric side, pvesh get /cluster/sdn/zones on the tenant hypervisor shows
vrf_<tenant> with its own VNI.
Operator-only calls #
| Call | Use |
|---|---|
GET /v1/tenants | every tenant, by index, without secrets. The list is lean — ask GET /v1/tenants/{name} before concluding something is missing |
DELETE /v1/tenants/{name} | take a tenant out of service, after its workloads (the tenant can call it too, with its own token). Use make remove-tenant, which deletes the workloads first:
Tenant Removal |
POST /v1/tenants/{name}/reconcile | re-ensure every backend with the secrets the registry holds. The repair after a backend is rebuilt, and how a tenant created before a service existed is handed that service’s credentials |
When it goes wrong #
| Symptom | What it means |
|---|---|
409 on admission | the name is already registered. The tenant uses its own token, not a new admission |
401 on the tenant’s first apply | the enrollment token was issued for a different name, and is now spent. Admit again |
501 on every tenant route | the API has no site configured, or no OpenBao |
| A tenant lists with no log store | the list endpoint returns lean records. Ask the detail endpoint |