SimpleVirt API Reference
This is the HTTP API of SimpleVirt-Command, the controller. It is the same API the SimpleVirt-Command web console uses: there is no second, richer interface behind it, and nothing the console can do is unavailable here.
It is written for someone integrating a client — a provisioning system, a monitoring collector, a script — against a running controller. For what the product does and how an operator drives it, see the user guide; for how the pieces fit together, the architecture guide.
Every endpoint in this document was read from the controller's route table. Each one
names the privilege it requires, because as of this release every route enforces
one server-side — a read included. A client that holds a session but not the
privilege gets 403, not data.
Contents
- Base URL and transport
- Authentication
- Request and response conventions
- Authorization
- Preview features
- Stability
- Resources: session · inventory · virtual machines · console · folders · hosts · host hardware · host storage · host networking · clusters · CPU compatibility · high availability · datastores · content library · networking · tasks · events and audit · alarms · metrics · access control · settings · lifecycle · backup · previews
- The host agent API
- Coverage of this document
Base URL and transport
All paths are relative to the controller's HTTPS listener:
https://<simplevirt-command-address>/api/v1/...TLS is always on. A fresh controller serves a self-signed certificate; an operator can install their own (see controller settings). A client must either trust the installed certificate or pin it — do not disable verification in production code.
Two paths sit outside /api/v1:
| Method | Path | Privilege | Description |
|---|---|---|---|
GET |
/healthz |
none | Liveness probe. Answers {"status":"ok"} with no session. Carries no fleet data. |
ANY |
/ |
none | The embedded web console and its static assets. An unknown /api/ path under it answers 404. |
Authentication
SimpleVirt-Command uses a session cookie plus a CSRF double-submit token. This is stated once here and is not repeated per endpoint, because it applies to all of them.
1. Sign in.
POST /api/v1/auth/login
Content-Type: application/json
{"username": "alice", "password": "..."}{"username": "alice", "csrf_token": "…"}The response also sets the session cookie:
Set-Cookie: simplevirt_command_session=…; Path=/; HttpOnly; Secure; SameSite=Strict2. Send the cookie on every request. Any client that keeps a cookie jar does this for you.
3. Send the CSRF token on every mutating request. Every POST, PUT, PATCH
and DELETE must carry the token from sign-in in a header:
X-CSRF-Token: <csrf_token>A missing or wrong token is 403 {"error":"invalid or missing CSRF token"}. GET
requests carry no CSRF token.
4. Recover the token without re-authenticating — a client that restarted but
still holds a valid cookie can re-read its identity, its token and its privilege set
from GET /api/v1/auth/me.
Accounts and directories. Sign-in checks the controller's local accounts first,
then LDAP/Active Directory when one is configured. Local accounts are the
break-glass path and are always admitted; a directory account is admitted only if it,
or a group it belongs to, maps to a role. Repeated failures are rate-limited per
source address and answer 429.
Sessions expire. An expired or unknown cookie is 401; sign in again. There is
no refresh endpoint and no bearer-token or API-key mechanism in this release — a
client integrating against SimpleVirt-Command signs in with an account like any other
principal. Give it its own account with a role scoped to what it actually needs.
Request and response conventions
JSON in, JSON out. Send
Content-Type: application/jsonon request bodies. The exceptions are the streaming endpoints, marked where they occur.Request bodies are strict. Unknown fields are rejected with
400 {"error":"invalid JSON request body"}. A client must not send fields this version does not define — including ones it saw in a newer release.Response objects are not strict. New fields are added over time; ignore what you do not recognise rather than failing on it.
Errors are always the same shape:
{"error": "a sentence describing what went wrong"}The message is written for a human to read. Do not match on its text.
Status codes carry the meaning:
400malformed request,401no or expired session,403missing privilege, missing or wrong CSRF token, or a disabled preview,404unknown object,409a conflicting state (already exists, wrong power state, admission control refused),413body too large,429rate-limited (sign-in),500controller fault,502the host behind a proxied call could not be reached,503a subsystem is not wired on this controller.A proxied host's own refusal reaches you intact. Many endpoints under
/api/v1/hosts/{id}/...are forwarded to that host. When the host refuses — a datastore is in use, a VM is in the wrong state, a lease fence is held — its status code and its own sentence are what you receive, not a generic controller error.502means the controller could not reach the host at all, which is a different problem.Timestamps are RFC 3339 strings in UTC unless a field name says otherwise.
Sizes are bytes, and the field name says so (
memory_total_bytes). Memory on VM-shaped objects is mebibytes (memory_mib).Identifiers: hosts and clusters carry opaque string
ids. Virtual machines are addressed by their libvirtuuid. Datastores are addressed by a stablekey(see datastores) — never by name.Paging. List endpoints that can grow take
?limit=. Each states its default and cap. There is no cursor paging in this release.Treat a missing or
nullarray as empty. Collections are normally serialized as[], but a client that also acceptsnullwill never break on one.
Authorization
Every route is bound to one privilege. Roles are sets of privileges; users and directory groups are bound to roles. A principal whose role does not include the privilege gets:
{"error": "you do not have permission to do this (vm.delete)"}with status 403. The denial is recorded in the audit log.
The full privilege set:
| Privilege | Covers |
|---|---|
vm.view |
See virtual machines, their detail, snapshots, placement and tasks |
vm.create |
Create a VM, deploy from a template, create a Kubernetes cluster |
vm.power |
Start, stop, reset, suspend, resume |
vm.edit |
Reconfigure: CPU/memory, disks, NICs, media, snapshots, template flag, folder |
vm.delete |
Delete a virtual machine |
vm.migrate |
Live, cold and storage migration; host evacuation |
vm.console |
Open the graphical or serial console |
host.view |
See hosts, their hardware, health, services, capacity and alarms |
host.manage |
Add/remove hosts, power and maintenance, host configuration, certificates, host accounts, updates, the controller audit log and the fleet event tail |
cluster.view |
See clusters, HA state and readiness, CPU compatibility |
cluster.manage |
Create/delete clusters, membership, cluster switches, cluster CPU profile |
cluster.ha |
Arm or disarm high availability; approve or reject a pending failover |
datastore.view |
See datastores, browse files, read the content library |
datastore.manage |
Add/remove datastores, upload/delete files, manage the library, reclaim orphans |
network.view |
See switches, networks and port groups |
network.manage |
Create/modify switches, port groups, uplinks, service adapters |
admin.users |
Local accounts, role bindings, certificates, backup/restore, support bundle, controller updates |
admin.roles |
Create, edit and delete custom roles |
admin.directory |
Configure LDAP/AD and directory role bindings |
GET /api/v1/authz/catalog serves this catalog from the controller, so a client
never has to hard-code it.
Two endpoints carry no privilege by design, and both are scoped so they can
reveal nothing about the fleet: GET /api/v1/auth/me (the caller's own identity —
gating it would refuse the very call a zero-privilege principal makes to discover it
has no privileges) and GET /api/v1/settings/features (which previews this build
offers, which the console must know before its first render).
Preview features
Eight capabilities ship as previews: built, in the binary, and off by default. An endpoint belonging to a disabled preview answers:
{"error": "…", "experimental": "cluster_services"}with status 403 — distinguishable from a privilege denial by the experimental
key. GET /api/v1/settings/features reports which are enabled on this controller.
Full detail is in the preview features guide.
Preview endpoints are marked (preview: flag) throughout this reference and are
collected in preview feature endpoints. Do not build a
client that depends on one without reading that guide first.
Stability
| Surface | Status | Notes |
|---|---|---|
/api/v1/... on SimpleVirt-Command |
Stable. Integrate against this. | Additive changes only within a major version: new endpoints, new response fields. Existing fields keep their meaning. |
/api/v1/agent/... on SimpleVirt-Command |
Reserved. | Not present on the controller; see below for what it is on a host. |
/api/v1/agent/... on a SimpleVirt host |
Internal. Do not call. | The controller-to-host channel. Its shapes change with the pair of builds at either end and carry no compatibility promise. |
/api/v1/... on a SimpleVirt host |
Internal. | The host's own recovery console. A host is meant to be operated through the controller. |
/api/v1/cluster/... on a SimpleVirt host |
Internal. | Loopback-only, token-guarded cluster-services control surface. |
The rule for a client is simple: talk to SimpleVirt-Command, over /api/v1, and nothing
else. If an operation appears to need a host directly, it is available on the
controller — every host-backed capability is proxied under /api/v1/hosts/{id}/....
Resources
Session and discovery
| Method | Path | Privilege | Description |
|---|---|---|---|
POST |
/api/v1/auth/login |
none | Sign in. Body {username, password}. Sets the session cookie, returns {username, csrf_token}. Rate-limited per source address. |
POST |
/api/v1/auth/logout |
session | Destroy the session and clear the cookie. Requires the CSRF header. |
GET |
/api/v1/auth/me |
session | The caller's own {username, csrf_token, privileges[], rbac_active, can_manage_access}. The one call that recovers a CSRF token without re-authenticating. |
GET |
/api/v1/version |
none | The controller's build: {version, flavor, release, commit, build_date, dirty}. Readable with no session — the sign-in page renders it before anyone has authenticated. |
GET |
/api/v1/settings/features |
session | Which preview features this controller offers: a name, a label and a boolean for each. No fleet data. |
Inventory and dashboard
| Method | Path | Privilege | Description |
|---|---|---|---|
GET |
/api/v1/dashboard |
vm.view |
Fleet totals: hosts, hosts up, clusters, VMs, VMs running, cores, memory total/free, average CPU percent, and provisioned vCPU/memory. |
GET |
/api/v1/vms |
vm.view |
Every virtual machine across every host — see virtual machines. |
GET |
/api/v1/hosts |
host.view |
Every host — see hosts. |
GET |
/api/v1/clusters |
cluster.view |
Every cluster — see clusters. |
GET |
/api/v1/datastores |
datastore.view |
Every datastore across the fleet — see datastores. |
GET |
/api/v1/networking |
network.view |
The fleet networking rollup: switches, networks and port groups per host, with health. |
GET |
/api/v1/placement |
vm.view |
Which datastore each VM's disks actually sit on, fleet-wide. Answers from cache and rebuilds in the background, so it never blocks on a slow host. Join to datastores on key. |
GET |
/api/v1/guest-tools/catalog |
vm.view |
The SimpleVirt Guest Tools version bundled with this release, its protocol version and supported-OS matrix. Derived from the build; never fetched from the internet. |
Aggregated inventory is read from the controller's poll cache, not from the hosts. A read never blocks on an unreachable host, and objects on a host that has stopped answering are still returned — carried forward from the last successful poll and flagged. This is deliberate: a VM must not appear to vanish because its host went quiet. Two fields tell you what you are looking at:
stale: true— this reading is a memory, not a measurement.host_reachable/host_last_seen— whose memory, and how old.
Treat a stale object as present-but-unconfirmed, and say so to your user.
Virtual machines
GET /api/v1/vms returns one object per VM, deduplicated to exactly one row per
UUID even mid-failover:
[
{
"uuid": "c0ffee…", "name": "web-01", "state": "running",
"vcpus": 4, "memory_mib": 8192,
"persistent": true, "is_template": false, "os_variant": "debian",
"usage": {"cpu_mhz": 412, "memory_bytes": 5100273664, "storage_bytes": 21474836480},
"lease_guarded": true,
"host_id": "h-1", "host_name": "simplevirt-01", "cluster_id": "c-1",
"folder_id": "f-2",
"host_reachable": true, "host_liveness": "online"
}
]state is one of running, stopped, paused, shutting_down, crashed,
suspended, unknown. usage is a live sample and is absent when there is no
current reading (a stopped VM, or before the first sample) — absent means unknown, and
a client must render it as unknown rather than as zero.
Individual VMs are addressed through their host. {id} is the host id, {uuid} the
VM's UUID.
| Method | Path | Privilege | Description |
|---|---|---|---|
GET |
/api/v1/hosts/{id}/vms/{uuid} |
vm.view |
Full detail: disks, NICs, current media, VNC port, passed-through PCI/USB devices, CPU topology and pinning, the VM's actual guest CPU definition, and the two guest-side views described below. |
POST |
/api/v1/hosts/{id}/vms |
vm.create |
Create a VM on this host. Body is the create request described below. |
POST |
/api/v1/clusters/{id}/vms |
vm.create |
Create a VM in a cluster, letting the controller place it on an eligible member. Same body. |
PATCH |
/api/v1/hosts/{id}/vms/{uuid} |
vm.edit |
Reconfigure: name, vCPUs, memory, description, autostart, boot order, CPU topology, pinning. |
DELETE |
/api/v1/hosts/{id}/vms/{uuid} |
vm.delete |
Delete the VM. Disk deletion is requested explicitly; read the response. |
POST |
/api/v1/hosts/{id}/vms/{uuid}/actions |
vm.power |
Power operations. Body {"action": "…"}. |
POST |
/api/v1/hosts/{id}/vms/{uuid}/deploy |
vm.create |
Deploy a new VM from this template. Body {name, datastore?, start}. |
POST |
/api/v1/hosts/{id}/vms/{uuid}/template |
vm.edit |
Mark or unmark the VM as a template. |
GET |
/api/v1/vms/{uuid}/cpu-profile |
vm.view |
The CPU model this VM is running under and how it relates to its cluster's compatibility profile. |
GET |
/api/v1/hosts/{id}/vms/{uuid}/os/detect |
vm.view |
Best-effort guest OS detection for the icon and for guest-tools eligibility. |
Two guest-side facts on a VM's detail, easily confused. guest_agent is the
QEMU guest agent: connected, disconnected, or absent when the VM is stopped or has
no agent channel — absent means unknown, not no. It is the single fact that decides
what a graceful shutdown can actually do, because without it libvirt presses the virtual
ACPI power button and a guest that ignores ACPI (no OS installed yet, a minimal image, a
Windows lock screen) simply keeps running. guest_tools is the separate first-party
SimpleVirt Guest Tools view: whether simplevirt-guestd is running and, when it is, the guest's
own report of its OS, hostname, uptime, capabilities and IP addresses. It is
best-effort and absent for a stopped VM. The two coexist and neither implies the other.
configured_vcpus and configured_memory_mib appear only when the persistent config
differs from what the guest is currently running — CPU and memory can be changed while a
VM runs, and the change takes effect on the next boot. Without these, a just-saved
change would look as though it had been silently discarded.
Power actions — the action field takes exactly one of:
| Action | Effect |
|---|---|
start |
Power on |
shutdown |
Graceful shutdown via ACPI or the QEMU guest agent |
forcestop |
Immediate power off — the equivalent of pulling the cord |
reboot |
Graceful restart |
reset |
Immediate reset |
suspend |
Suspend to host memory |
resume |
Resume from suspend |
guest-shutdown |
OS-native shutdown coordinated by SimpleVirt Guest Tools. Never escalates to a forced power off. |
guest-restart |
OS-native restart coordinated by SimpleVirt Guest Tools |
Anything else is 400 with the accepted list in the message.
Creating a VM — POST /api/v1/hosts/{id}/vms. The commonly used fields:
{
"name": "web-01",
"vcpus": 4, "memory_mib": 8192, "disk_gib": 80,
"datastore": "shared-nfs", "disk_type": "thin",
"network": "vm-network",
"os_variant": "debian",
"start": true, "autostart": false
}Install media is attached either as iso (a basename from the host's ISO library)
or as iso_datastore + iso_path (a path inside a datastore) — the two are
mutually exclusive. A VM can instead be cloned from an existing volume with
source_datastore + source_volume, which is how a prepared cloud image is used as a
base. cloud_init_user_data and cloud_init_network attach a NoCloud seed for a
cloud-init-capable guest. mac fixes the NIC's hardware address when something
outside the VM already knows it — a DHCP reservation, a MAC-bound licence — and SimpleVirt
does not check it is unique on the network, because it cannot see the network.
boot_order takes an ordered list of hd, cdrom, network; cpu_sockets ×
cpu_cores × cpu_threads must equal vcpus when all three are given; cpu_set
pins vCPUs to a host CPU range.
Create is refused with 409 when cluster admission control cannot reserve the
capacity — the HA failover reserve is enforced at create and at power-on.
Disks, NICs, media and snapshots
| Method | Path | Privilege | Description |
|---|---|---|---|
POST |
/api/v1/hosts/{id}/vms/{uuid}/disks |
vm.edit |
Attach a new or existing disk. |
POST |
/api/v1/hosts/{id}/vms/{uuid}/disks/{dev}/resize |
vm.edit |
Grow a disk. {dev} is the guest target, e.g. vda. |
DELETE |
/api/v1/hosts/{id}/vms/{uuid}/disks/{dev} |
vm.edit |
Detach a disk. On a running VM the detach is asynchronous and the response reports what was actually confirmed. |
POST |
/api/v1/hosts/{id}/vms/{uuid}/nics |
vm.edit |
Attach a NIC to a network or port group. |
DELETE |
/api/v1/hosts/{id}/vms/{uuid}/nics/{mac} |
vm.edit |
Detach the NIC with this MAC address. |
POST |
/api/v1/hosts/{id}/vms/{uuid}/cdrom |
vm.edit |
Change or eject the CD/DVD medium. Attaches a drive if the VM has none. |
GET |
/api/v1/hosts/{id}/vms/{uuid}/snapshots |
vm.view |
List snapshots with their ages. |
POST |
/api/v1/hosts/{id}/vms/{uuid}/snapshots |
vm.edit |
Create a snapshot. |
POST |
/api/v1/hosts/{id}/vms/{uuid}/snapshots/{name}/revert |
vm.edit |
Revert to a snapshot. |
DELETE |
/api/v1/hosts/{id}/vms/{uuid}/snapshots/{name} |
vm.edit |
Delete a snapshot. |
POST |
/api/v1/hosts/{id}/vms/{uuid}/guest-tools/install |
vm.edit |
Attach the SimpleVirt Guest Tools medium and begin installation. |
Snapshots are a rollback convenience, not a backup — see backup responsibility.
Migration
| Method | Path | Privilege | Description |
|---|---|---|---|
GET |
/api/v1/hosts/{id}/vms/{uuid}/migrate-targets |
vm.view |
Every other member of the VM's cluster, each with a compatibility verdict and a reason when it is not eligible. Call this to populate a destination picker. |
POST |
/api/v1/hosts/{id}/vms/{uuid}/migrate |
vm.migrate |
Live-migrate a running VM to another host. |
POST |
/api/v1/hosts/{id}/vms/{uuid}/cold-migrate |
vm.migrate |
Move a stopped VM, copying disks when storage is not shared. |
POST |
/api/v1/hosts/{id}/vms/{uuid}/storage-migrate |
vm.migrate |
Move the VM's disks to another datastore, with the VM running or stopped. |
POST |
/api/v1/hosts/{id}/evacuate |
vm.migrate |
Live-migrate every running VM off a host onto surviving members. |
All four run as tracked tasks with progress. The call returns once the operation is accepted; watch the task for completion.
Virtual machine console
The console is a two-step, ticketed WebSocket proxy: the controller mints a short-lived ticket bound to your session, the VM and the host, and the browser (or client) then opens the WebSocket with it.
| Method | Path | Privilege | Description |
|---|---|---|---|
GET |
/api/v1/hosts/{id}/vms/{uuid}/console |
vm.console |
Mint a console ticket. Returns {"ticket": "…"}. |
GET |
/api/v1/hosts/{id}/vms/{uuid}/console/ws |
vm.console |
WebSocket: the VM's graphical (VNC) console, proxied to the host. |
GET |
/api/v1/hosts/{id}/vms/{uuid}/serial/ws |
vm.console |
WebSocket: the VM's serial (text) console on ttyS0. Requires a getty in the guest. |
Tickets are single-use and expire in seconds. Both sockets carry a keepalive ping on each leg, so a half-open client tab cannot leak a connection to the host.
Folders
Folders are a controller-side organizational grouping for virtual machines: a flat set of named buckets, not a hierarchy — a folder has no parent and cannot contain another folder. They exist only in the controller and change nothing on any host. Membership is keyed by the VM's UUID, so it survives the VM migrating between hosts.
| Method | Path | Privilege | Description |
|---|---|---|---|
GET |
/api/v1/folders |
vm.view |
Every folder, name-sorted. |
POST |
/api/v1/folders |
vm.edit |
Create a folder. |
PATCH |
/api/v1/folders/{id} |
vm.edit |
Rename a folder. |
DELETE |
/api/v1/folders/{id} |
vm.edit |
Delete a folder. VMs in it become unfiled; nothing is deleted. |
PUT |
/api/v1/vms/{uuid}/folder |
vm.edit |
File a VM into a folder, or unfile it. |
Hosts
GET /api/v1/hosts returns one object per registered host:
[
{
"id": "h-1", "name": "simplevirt-01", "address": "10.0.0.11:8443",
"cluster_id": "c-1", "cluster_name": "production",
"status": "online", "liveness": "online", "reachable": true,
"last_seen": "2026-09-17T09:14:22Z", "info_at": "2026-09-17T09:14:22Z",
"vms": 12, "vms_running": 9,
"cpu_cores": 48, "cpu_percent": 18.4,
"memory_total_bytes": 412316860416, "memory_free_bytes": 180388626432,
"version": "1.0.0+…", "cpu_model": "…", "domain_type": "kvm",
"maintenance_mode": false
}
]status is connecting, online, degraded or unreachable. While connecting,
connect_phase narrates progress (contacting, waiting-agent, cert) without
leaking a raw error the operator cannot act on; once a host escalates to
unreachable, fail_class, error and terminal say what is wrong and whether it
will clear on its own. Capacity fields carry omitempty, so absent means never
read — gate them on info_at, not on the value.
| Method | Path | Privilege | Description |
|---|---|---|---|
GET |
/api/v1/hosts |
host.view |
Every registered host. |
GET |
/api/v1/hosts/{id} |
host.view |
One host. |
POST |
/api/v1/hosts |
host.manage |
Add a host. Body {name?, address, username, password, cluster_id?}. The controller pairs with it, pins its certificate on first contact, and installs an agent token; the password is used for the pairing exchange and is not stored. |
PATCH |
/api/v1/hosts/{id} |
host.manage |
Correct the address, the display name, or both. A name-only correction touches nothing on the wire. An address correction must first prove the same host answers there. |
DELETE |
/api/v1/hosts/{id} |
host.manage |
Remove a host from the controller. |
POST |
/api/v1/hosts/{id}/cluster |
host.manage |
Assign the host to a cluster, or remove it from one. |
POST |
/api/v1/hosts/{id}/repoll |
host.view |
Ask the controller to poll this host now instead of waiting for the next cycle. |
POST |
/api/v1/hosts/{id}/maintenance |
host.manage |
Enter or leave maintenance mode. Entering blocks VM create and power-on and can gracefully stop or suspend running VMs. |
GET |
/api/v1/hosts/{id}/maintenance/preview |
host.view |
Read-only: plans the evacuation of every running VM and reports the storage impact, so the consequences are visible before the act. |
POST |
/api/v1/hosts/{id}/planned-reboot |
host.manage |
Reboot the host as a planned outage, so HA treats the absence as expected rather than as a failure. |
GET |
/api/v1/hosts/{id}/offline-impact |
host.view |
What taking this host offline would cost right now — proxied live from the host. |
Host certificates and identity
| Method | Path | Privilege | Description |
|---|---|---|---|
GET |
/api/v1/hosts/{id}/certificate |
host.manage |
The host's current serving certificate and whether SimpleVirt manages it. |
PUT |
/api/v1/hosts/{id}/certificate |
host.manage |
Install an operator-supplied certificate and key on the host. |
POST |
/api/v1/hosts/{id}/certificate/regenerate |
host.manage |
Have the host regenerate its self-signed certificate. |
GET |
/api/v1/hosts/{id}/certificate/pin |
host.manage |
Read the fingerprint the host is presenting now, over a bare handshake that sends no token. It pins nothing — an operator compares it against the host's own certificate before approving. |
POST |
/api/v1/hosts/{id}/certificate/pin |
host.manage |
Re-pin the host to a new fingerprint, optionally correcting the address in the same act. |
The controller pins each host's certificate on first contact and refuses to talk to a changed one. That is the intended behaviour after a certificate is reissued: read the new fingerprint, verify it against the host, then re-pin.
Host hardware, services and time
| Method | Path | Privilege | Description |
|---|---|---|---|
GET |
/api/v1/hosts/{id}/health |
host.view |
Live health: services, storage, networking, with an overall verdict. |
GET |
/api/v1/hosts/{id}/services |
host.view |
The host's managed system services and their state. |
POST |
/api/v1/hosts/{id}/services/{unit} |
host.manage |
Start, stop or restart one service. |
GET |
/api/v1/hosts/{id}/disks |
host.view |
Physical block devices, with what each is used for. |
GET |
/api/v1/hosts/{id}/pci |
host.view |
PCI devices, for the passthrough picker. |
GET |
/api/v1/hosts/{id}/usb |
host.view |
USB devices, for the passthrough picker. |
POST |
/api/v1/hosts/{id}/pci/passthrough |
host.manage |
Bind or unbind a PCI device for passthrough. |
GET |
/api/v1/hosts/{id}/cpu-capabilities |
host.view |
The host's CPU model and feature set, as the cluster compatibility profile uses it. |
GET |
/api/v1/hosts/{id}/time |
host.view |
Clock, timezone and NTP synchronization state. |
POST |
/api/v1/hosts/{id}/ntp |
host.manage |
Set the host's NTP servers. |
POST |
/api/v1/hosts/{id}/power |
host.manage |
Reboot or power off the host. |
GET |
/api/v1/hosts/{id}/events |
host.manage |
The host's own audit tail. |
GET |
/api/v1/hosts/{id}/users |
host.manage |
The host's local operating-system accounts. |
POST |
/api/v1/hosts/{id}/users |
host.manage |
Add a host account. |
DELETE |
/api/v1/hosts/{id}/users/{name} |
host.manage |
Remove a host account. |
GET |
/api/v1/hosts/{id}/license |
host.view |
The subscription entitlement recorded on this host. |
POST |
/api/v1/hosts/{id}/license |
host.manage |
Activate a subscription token on the host. |
DELETE |
/api/v1/hosts/{id}/license |
host.manage |
Deactivate it. |
The subscription is a commercial entitlement, not a software licence, and it gates nothing in the product — see licensing.
Host-local storage and media
These act on one host's view. Fleet-wide and cluster storage is under datastores.
| Method | Path | Privilege | Description |
|---|---|---|---|
GET |
/api/v1/hosts/{id}/datastores |
datastore.view |
Datastores this host has mounted, with capacity. Raw and complete; the two pools of a SimpleVirt HCI datastore each carry the additive hci_role / hci_name — see one HCI datastore. |
POST |
/api/v1/hosts/{id}/datastores |
datastore.manage |
Add a datastore to this host. Block types (iSCSI, FC) are refused unless that preview is enabled. |
DELETE |
/api/v1/hosts/{id}/datastores/{name} |
datastore.manage |
Remove a datastore from this host. Refused with 409 for SimpleVirt HCI storage (the RBD pool and the CephFS files pool): remove that from the SimpleVirt HCI tab. |
GET |
/api/v1/hosts/{id}/datastores/{name}/files |
datastore.view |
Browse a directory. |
PUT |
/api/v1/hosts/{id}/datastores/{name}/files |
datastore.manage |
Upload a file. Streams; body is the file, not JSON. |
DELETE |
/api/v1/hosts/{id}/datastores/{name}/files |
datastore.manage |
Delete a file. |
POST |
/api/v1/hosts/{id}/datastores/{name}/mkdir |
datastore.manage |
Create a directory. |
GET |
/api/v1/hosts/{id}/datastores/{name}/download |
datastore.view |
Download a file. Streams raw bytes — this hands back the contents of a virtual disk, which is why it carries the same privilege as any other view of that datastore. |
GET |
/api/v1/hosts/{id}/isos |
datastore.view |
The host's ISO library. |
POST |
/api/v1/hosts/{id}/isos |
datastore.manage |
Upload an ISO. Streams. |
DELETE |
/api/v1/hosts/{id}/isos/{name} |
datastore.manage |
Delete an ISO. |
Host networking
| Method | Path | Privilege | Description |
|---|---|---|---|
GET |
/api/v1/hosts/{id}/networks |
network.view |
Networks available to VMs on this host. |
POST |
/api/v1/hosts/{id}/switches |
network.manage |
Create a logical switch. |
PATCH |
/api/v1/hosts/{id}/switches/{name} |
network.manage |
Modify a switch — uplinks, MTU, teaming. |
DELETE |
/api/v1/hosts/{id}/switches/{name} |
network.manage |
Delete a switch. |
POST |
/api/v1/hosts/{id}/portgroups |
network.manage |
Create a port group on a switch. |
DELETE |
/api/v1/hosts/{id}/portgroups/{name} |
network.manage |
Delete a port group. |
POST |
/api/v1/hosts/{id}/serviceadapters |
network.manage |
Create a service adapter (a host IP interface for management, migration or storage). |
POST |
/api/v1/hosts/{id}/serviceadapters/{name}/test |
network.manage |
Probe an adapter's connectivity. |
DELETE |
/api/v1/hosts/{id}/serviceadapters/{name} |
network.manage |
Delete a service adapter. |
Host updates
| Method | Path | Privilege | Description |
|---|---|---|---|
GET |
/api/v1/hosts/{id}/update/status |
host.view |
What is staged on this host, and the state of an upgrade in progress. |
POST |
/api/v1/hosts/{id}/update |
host.manage |
Stage a signed image on the host by streaming it through the controller on this request; body is the file. Kept for API clients — the console uploads once to /api/v1/lifecycle/hypervisor instead. 409 while a background staging job for this host is running. |
DELETE |
/api/v1/hosts/{id}/update |
host.manage |
Clear the host's staged image without applying it. 409 while a background staging job for this host is running. |
POST |
/api/v1/hosts/{id}/update/apply |
host.manage |
Apply the staged image. The host reboots into its own in-place upgrade. 409 while a background staging job for this host is running. |
An image is the only upgrade artifact. Stage first, apply second: applying verifies the staged image before it touches the running system.
Clusters
| Method | Path | Privilege | Description |
|---|---|---|---|
GET |
/api/v1/clusters |
cluster.view |
Every cluster with pooled capacity, member counts, HA and distributor state. |
GET |
/api/v1/clusters/{id} |
cluster.view |
One cluster. |
POST |
/api/v1/clusters |
cluster.manage |
Create a cluster. |
PATCH |
/api/v1/clusters/{id} |
cluster.manage |
Rename or reconfigure. |
DELETE |
/api/v1/clusters/{id} |
cluster.manage |
Delete a cluster. Members become unclustered; nothing is destroyed. |
A cluster object carries pooled cpu_cores, memory_total_bytes,
memory_free_bytes, a member-average cpu_percent, and
vcpus_provisioned / memory_provisioned_bytes — the configured resources of
running VMs on reachable members. Provisioned figures may exceed physical
capacity; that excess is the overcommit ratio. A member that could not be read
contributes nothing, which makes these a floor rather than a total.
datastores on a cluster object is desired state — what every member is supposed
to mount. It says nothing about what any host has actually mounted; that costs a live
call per host and lives at GET /api/v1/clusters/{id}/datastores. Do not render the
desired list as "the cluster has these datastores".
Cluster storage
| Method | Path | Privilege | Description |
|---|---|---|---|
GET |
/api/v1/clusters/{id}/datastores |
datastore.view |
What each member has actually mounted, per host, live. |
POST |
/api/v1/clusters/{id}/datastores |
datastore.manage |
Declare a datastore for the cluster and fan the mount out to every member. |
POST |
/api/v1/clusters/{id}/datastores/{name}/mount |
datastore.manage |
Re-drive the mount on members that do not have it. |
DELETE |
/api/v1/clusters/{id}/datastores/{name} |
datastore.manage |
Remove a cluster datastore from every member. |
GET |
/api/v1/clusters/{id}/storage/backends |
datastore.view |
The storage backends this cluster can use, with capabilities and default failures-to-tolerate. |
GET |
/api/v1/clusters/{id}/storage/protection |
datastore.view |
Backend-authoritative: what a chosen ?ftt= resolves to on ?hosts= contributing hosts — copies, minimum writes, reduced-resilience flag, usable-capacity divisor. Read-only, and the source of truth a UI should render rather than recomputing the policy itself. |
Cluster networking
| Method | Path | Privilege | Description |
|---|---|---|---|
GET |
/api/v1/clusters/{id}/switches |
network.view |
Logical distributed switches declared for this cluster, with per-host realization state. |
POST |
/api/v1/clusters/{id}/switches |
network.manage |
Declare a distributed switch. |
PATCH |
/api/v1/clusters/{id}/switches/{name} |
network.manage |
Modify it. |
DELETE |
/api/v1/clusters/{id}/switches/{name} |
network.manage |
Delete it. |
POST |
/api/v1/clusters/{id}/switches/{name}/hosts/{host} |
network.manage |
Assign a member to the switch — realize it there. |
DELETE |
/api/v1/clusters/{id}/switches/{name}/hosts/{host} |
network.manage |
Unassign a member. |
POST |
/api/v1/clusters/{id}/switches/{name}/remediate |
network.manage |
Re-converge members whose realization has drifted from the declaration. |
CPU compatibility
A cluster can be given a CPU compatibility profile so a VM started on one member can migrate to any other — the guest is presented a common feature baseline instead of whatever its current host happens to have.
| Method | Path | Privilege | Description |
|---|---|---|---|
GET |
/api/v1/clusters/{id}/cpu-compatibility |
cluster.view |
The active profile, or none. |
PUT |
/api/v1/clusters/{id}/cpu-compatibility |
cluster.manage |
Apply a profile. |
DELETE |
/api/v1/clusters/{id}/cpu-compatibility |
cluster.manage |
Disable it. |
POST |
/api/v1/clusters/{id}/cpu-compatibility/precheck |
cluster.view |
Dry-run a profile change: what it would mean for every member and every VM. Read-only despite being a POST — it takes a candidate profile in the body. |
GET |
/api/v1/clusters/{id}/cpu-compatibility/hosts |
cluster.view |
Per-member capability and whether each satisfies the profile. |
GET |
/api/v1/clusters/{id}/cpu-compatibility/vms |
cluster.view |
Per-VM: which profile each running VM is actually on, and which need a power cycle to pick up a change. |
High availability
HA restarts a dead host's VMs on surviving members. It fires only on real host death, never on a planned reboot, and it is gated on a readiness check plus a storage fence — see the operations guide.
| Method | Path | Privilege | Description |
|---|---|---|---|
POST |
/api/v1/clusters/{id}/ha |
cluster.ha |
Arm or disarm HA on a cluster, and set its mode and admission-control reserve. |
GET |
/api/v1/clusters/{id}/ha/readiness |
cluster.view |
Whether the cluster actually satisfies what HA requires — shared storage, fencing, network eligibility — with a reason per failed check. Read this before arming; arming a cluster that is not ready is how HA becomes a surprise. |
GET |
/api/v1/clusters/{id}/ha/protection |
cluster.view |
Computed protection status per evaluated VM, plus whether the protection loop is running at all (so a client can show "evaluating" rather than an empty state that reads as "nothing to protect"). |
GET |
/api/v1/clusters/{id}/ha/pending |
cluster.view |
Failover plans a confirm-mode cluster has computed and is waiting for an operator to approve. Empty in automatic mode, and empty when nothing is failing. |
POST |
/api/v1/clusters/{id}/ha/pending/{pid}/approve |
cluster.ha |
Approve a pending failover plan. |
POST |
/api/v1/clusters/{id}/ha/pending/{pid}/reject |
cluster.ha |
Reject it. |
GET |
/api/v1/ha/state |
cluster.view |
Fleet-wide HA state: authority, per-host runtime ownership, fencing. |
GET |
/api/v1/ha/vms/{uuid}/state |
cluster.view |
One VM's HA state: who owns its runtime, whether it is lease-guarded, where it was recovered. |
GET |
/api/v1/ha/events |
cluster.view |
The HA event history — every transition, durable across a controller restart. Takes ?limit=. |
GET |
/api/v1/ha/events/stream |
cluster.view |
The same events as Server-Sent Events, live. |
These four /api/v1/ha/... endpoints are observability only. Reading them drives
no recovery decision; they report transitions the backend has already committed.
Datastores and files
GET /api/v1/datastores returns the fleet's datastores, merged across the hosts that
mount them:
{
"datastores": [
{
"key": "ds-a1b2", "id": "a1b2…", "grouped": true,
"name": "shared-nfs", "type": "nfs", "source": "10.0.0.5:/export/vm",
"shared": true, "host_count": 3,
"capacity_bytes": 10995116277760,
"allocated_bytes": 4398046511104,
"available_bytes": 6597069766656
}
],
"hosts": [{"host_id": "h-1", "host_name": "simplevirt-01", "…": "…"}]
}Two things matter for a client:
keyis the join field. It is the datastore's stable address — the storage-stampedidwhen the hosts could report one, otherwise a host-private key. Other feeds, notablyGET /api/v1/placement, join onkey. Never join onname: a datastore can be renamed, and two hosts sharing one datastore can call it different things (names_differ).- Capacity is one host's report, never a sum. Summing a shared filesystem across
the hosts that mount it would multiply one datastore's size by its popularity.
grouped: falsemeans the entry describes exactly one host's pool and was not merged with anything.
| Method | Path | Privilege | Description |
|---|---|---|---|
GET |
/api/v1/datastores |
datastore.view |
Every datastore across the fleet, merged by identity. A SimpleVirt HCI cluster's two pools are presented as one composite datastore; ?flat=1 returns the unmerged list. |
GET |
/api/v1/datastores/{key}/files |
datastore.view |
Browse a directory. ?path= is datastore-relative. |
PUT |
/api/v1/datastores/{key}/files |
datastore.manage |
Upload a file. Streams. |
DELETE |
/api/v1/datastores/{key}/files |
datastore.manage |
Delete a file. |
POST |
/api/v1/datastores/{key}/mkdir |
datastore.manage |
Create a directory. |
GET |
/api/v1/datastores/{key}/download |
datastore.view |
Download a file. Streams raw bytes. |
GET |
/api/v1/datastores/{key}/orphans |
datastore.view |
Disk files on this datastore that no VM in the fleet references. A report, not an instruction. |
POST |
/api/v1/datastores/{key}/orphans/reclaim |
datastore.manage |
Delete a reported orphan. Every gate is re-run at the instant of deletion — a fresh fleet probe, the age and type qualification, and the cluster-wide in-use question — because the report the operator is looking at was computed earlier and a disk can have been attached since. |
One HCI datastore
SimpleVirt HCI provisioning creates two storage pools per cluster: an RBD pool that holds VM
disks (libvirt name hci-<ceph pool>, for example hci-simplevirt-hci) and a CephFS pool
that holds ISOs and other files (hci-fs). Operators see one datastore, named after the
Ceph pool (simplevirt-hci). The merge is presentation only, performed by the controller at
the very end of GET /api/v1/datastores: no pool, mount path, Ceph object or libvirt name is
renamed or moved, and every host's own list (GET /api/v1/hosts/{id}/datastores) stays raw.
A merged row is the RBD member's row plus these additive fields (a row that is not merged carries none of them):
{
"key": "ceph-rbd:…", "id": "ceph-rbd:…", "name": "hci-simplevirt-hci", "type": "rbd",
"shared": true, "host_count": 3,
"capacity_bytes": 161061273600, "allocated_bytes": 32212254720, "available_bytes": 128849018880,
"composite": true,
"display_name": "simplevirt-hci",
"has_disks": true, "has_files": true,
"disks_name": "hci-simplevirt-hci",
"files_key": "ceph-fs:…", "files_name": "hci-fs", "files_state": "ok",
"member_keys": ["ceph-rbd:…", "ceph-fs:…"],
"members": [
{"role": "disks", "key": "ceph-rbd:…", "id": "ceph-rbd:…", "name": "hci-simplevirt-hci", "type": "rbd",
"keys": ["ceph-rbd:…"], "hosts_present": 3, "hosts_active": 3, "hosts_expected": 3, "state": "ok"},
{"role": "files", "key": "ceph-fs:…", "id": "ceph-fs:…", "name": "hci-fs", "type": "cephfs",
"keys": ["ceph-fs:…"], "hosts_present": 3, "hosts_active": 3, "hosts_expected": 3, "state": "ok"}
],
"hosts": [
{"host_id": "…", "host_name": "…", "name": "hci-simplevirt-hci", "role": "disks", "active": true, "…": "…"},
{"host_id": "…", "host_name": "…", "name": "hci-fs", "role": "files", "active": true, "target_path": "/var/lib/simplevirt-hcifs", "…": "…"}
]
}key,idandtypeare the RBD member's, so every existing join (the placement feed, a VM disk's recorded datastore, HA, deep links,#/storage/datastore/<key>bookmarks) keeps working.namestays the RBD libvirt pool name; showdisplay_namewhen present.member_keyslists every key that resolves to this datastore (the RBD key, the CephFS key, and the host-privatelocal:keys of inactive member pools), so a link made against any of them still resolves.hostscarries one entry per (host, member), andhost_countcounts distinct hosts. Each entry hasrole("disks"for the RBD pool,"files"for the CephFS pool; the field exists only on a composite's entries), the pool's own libvirtnameon that host, and its ownactiveflag, so a console can say which host lacks WHICH pool. An inactive member pool has no identity; its host is folded in as an entry withactive: false(and counted inmembers[].hosts_presentversushosts_active), never shown as a third row and never counted inhost_count: a host that has only inactive pools is listed, not counted.members[].keysis every key a VM disk of that member may be recorded under: the member's ownkeyplus the host-privatelocal:keys of its absorbed inactive pools. It is always an array, nevernull; the members'keystogether are exactlymember_keys.- Capacity is the RBD member's usable figure only (
ceph df), never a sum with — and never a fallback to — the CephFS filesystem figures, which an unmounted host fills with a bare local directory's. - All file operations use
files_key, notkey: browse, upload, mkdir, download and delete go to/api/v1/datastores/{files_key}/…. VM disks are created onkey. The value an ISO picker sends as the datastore isfiles_name(hci-fs). files_stateisok(the files pool is active on every member host),partial(not active everywhere, including a pool that is defined but down on every host),absent(no file member at all) orunknown(a host of the cluster could not be read, so neither "ok" nor "absent" can be claimed).members[].stateisok,partial,inactive,absentorunknownon the same terms.- When rows are NOT merged. Rows fold only within one registry cluster, only when the
cluster has exactly one RBD identity (one Ceph pool name) and at most one CephFS identity,
only when the CephFS pool is mounted at the fixed HCI path (an operator's own CephFS pool
elsewhere is never swallowed), and never when the two report different Ceph cluster ids.
Any ambiguity leaves today's separate rows. A cluster with only an RBD pool is a composite
with
has_files: false; one with only a CephFS pool is a single row named after the file member withhas_disks: false. When the RBD pool is inactive on every host it has no identity, so nothing is merged: the rows stay exactly as before the merge existed (never a files-only composite beside phantom block rows). ?flat=1returns the unmerged list (one row per storage identity, as before). The response'shosts[]status entries additionally carrycluster_id.
On GET /api/v1/hosts/{id}/datastores, the entries that belong to an HCI pair are annotated
additively with hci_role ("disks" for the RBD pool, "files" for the CephFS pool) and
hci_name (the logical name). Both fields are omitted for every other entry, and no entry is
merged, renamed or dropped.
DELETE /api/v1/hosts/{id}/datastores/{name} is refused with 409 ("This is SimpleVirt HCI
storage. Manage or remove it from the SimpleVirt HCI tab.") for the RBD pool and the CephFS
files pool, by both the controller and the host. NFS, local-directory and default-pool rules
are unchanged. The host's datastore in-use check now also counts a VM whose disk is an RBD
network disk in the pool's Ceph pool.
Content library
The content library is the catalog of base images, ISOs and templates that VM creation and provisioning deploy from. It is backed by a shared datastore so items stay reachable cluster-wide.
| Method | Path | Privilege | Description |
|---|---|---|---|
GET |
/api/v1/library |
datastore.view |
Library configuration and an overview. |
PUT |
/api/v1/library |
datastore.manage |
Set the shared datastore that backs the library. |
GET |
/api/v1/library/items |
datastore.view |
The catalog. |
POST |
/api/v1/library/items/upload |
datastore.manage |
Upload an item. Streams. |
POST |
/api/v1/library/items/import |
datastore.manage |
Import an item from a URL the controller can reach. |
DELETE |
/api/v1/library/items/{id} |
datastore.manage |
Delete an item. |
The "ISO (install media)" item type is behind the library_iso_items preview: no code
in this release can mount a library ISO into a VM, so importing one produces a catalog
row and nothing more. See preview features.
Fleet networking
| Method | Path | Privilege | Description |
|---|---|---|---|
GET |
/api/v1/networking |
network.view |
The whole fleet's switches, port groups, uplinks and service adapters, with a health rollup per host and per switch. |
Per-host and per-cluster network mutation is under host networking and cluster networking.
Tasks
Every mutating operation the controller performs runs as a task with a durable record, so the list survives a page reload and shows work started by other operators and by the controller itself.
| Method | Path | Privilege | Description |
|---|---|---|---|
GET |
/api/v1/tasks |
vm.view |
Recent tasks, newest first. ?limit= defaults to 100 and caps at 500. A malformed limit is 400 rather than being quietly replaced with the default. |
GET |
/api/v1/tasks/stream |
vm.view |
Server-Sent Events: an initial snapshot oldest-first, then one event per transition — begin, progress tick, terminal. Each data: line is one task record as JSON. |
A task record:
{
"id": "t-9", "task": "Migrate virtual machine", "target": "web-01",
"target_kind": "vm", "host_id": "h-1", "initiator": "alice",
"started": "2026-09-17T09:14:22Z", "finished": null,
"status": "running", "progress": 42, "detail": "42% · 1.2 GiB left"
}progress is absent for work with no measurable progress. target_kind is vm,
host, cluster, datastore and so on. Prefer the stream: a long migration's real
percent ticks at the host's cadence rather than aliasing to your poll interval, and a
client that cannot hold the stream open still has GET /api/v1/tasks.
Events and audit
Three distinct feeds, easily confused:
| Method | Path | Privilege | Description |
|---|---|---|---|
GET |
/api/v1/events |
host.manage |
The live merged fleet tail: recent activity from the controller and every host, in order. ?limit=. The response carries as_of, the contributing sources, and refreshing while a sweep is in flight. |
GET |
/api/v1/audit |
host.manage |
The controller's own audit archive, paged. Every authenticated action, every authorization denial, every sign-in. |
GET |
/api/v1/audit/export.csv |
host.manage |
The same archive as CSV, for an external log store. |
GET |
/api/v1/hosts/{id}/events |
host.manage |
One host's audit tail, proxied from that host. |
Alarms
Alarms are computed conditions — capacity thresholds, unreachable hosts, degraded storage, network-health rollups — that persist until the condition clears.
| Method | Path | Privilege | Description |
|---|---|---|---|
GET |
/api/v1/alarms |
host.view |
Active alarms, a summary (total, critical, warning, unacknowledged) and notifications delivery statistics. |
GET |
/api/v1/alarms/history |
host.view |
Alarm transitions, newest first. ?limit= defaults to 200. The answer to "was anything wrong last night?". |
POST |
/api/v1/alarms/{id}/acknowledge |
host.manage |
Record that a named operator owns the alarm. |
Acknowledging does not clear an alarm — only the condition going away does that,
so an acknowledgement can never be mistaken for a fix. The notifications block
reports whether a webhook receiver is configured and whether deliveries are being
dropped; it never carries the endpoint URL or its secret.
Utilization metrics
Retained server-side history, so a client gets a series rather than a single live sample.
| Method | Path | Privilege | Description |
|---|---|---|---|
GET |
/api/v1/hosts/{id}/metrics |
host.view |
This host's retained CPU and memory utilization history. |
GET |
/api/v1/vms/{uuid}/metrics |
vm.view |
This VM's retained CPU, memory and storage history. |
The live per-VM sample also rides the inventory poll, as usage on each object in
GET /api/v1/vms — no extra request needed for a current reading.
Access control
| Method | Path | Privilege | Description |
|---|---|---|---|
GET |
/api/v1/authz/catalog |
admin.users |
The full privilege catalog, so a role editor never hard-codes the list. |
GET |
/api/v1/authz/roles |
admin.users |
All roles, built-in and custom, each with its privileges expanded to strings. |
POST |
/api/v1/authz/roles |
admin.roles |
Create a custom role. |
PUT |
/api/v1/authz/roles/{id} |
admin.roles |
Update a custom role. |
DELETE |
/api/v1/authz/roles/{id} |
admin.roles |
Delete a custom role. |
GET |
/api/v1/authz/bindings |
admin.users |
Which users and which directory groups are bound to which roles. |
PUT |
/api/v1/authz/bindings/{name} |
admin.users |
Bind a user to a role. |
PUT |
/api/v1/authz/group-bindings/{name} |
admin.users |
Bind a directory group to a role. |
GET |
/api/v1/settings/users |
admin.users |
The controller's local accounts. |
POST |
/api/v1/settings/users |
admin.users |
Create a local account. |
DELETE |
/api/v1/settings/users/{name} |
admin.users |
Delete a local account. |
Access control is default-deny: a principal with no binding has no privileges. The controller keeps at least one administrator binding at all times, so it cannot be locked out of itself. Local accounts remain the break-glass path — a directory outage never strands the controller.
Controller settings
| Method | Path | Privilege | Description |
|---|---|---|---|
GET |
/api/v1/settings/certificate |
admin.users |
The controller's own serving certificate. |
PUT |
/api/v1/settings/certificate |
admin.users |
Install a certificate and key. |
POST |
/api/v1/settings/certificate/csr |
admin.users |
Generate a certificate signing request, including every name the controller answers to. |
POST |
/api/v1/settings/certificate/reset |
admin.users |
Return to a self-signed certificate. |
GET |
/api/v1/settings/directory |
admin.directory |
The LDAP/AD configuration. Bind credentials are never returned. |
PUT |
/api/v1/settings/directory |
admin.directory |
Configure or disable the directory. |
POST |
/api/v1/settings/directory/test |
admin.directory |
Test a directory configuration without saving it. |
GET |
/api/v1/settings/security |
admin.users |
The controller's security posture: what is configured, what is default, what an operator should address. Admin-gated deliberately — the operators who can act on it are the ones who see it. |
GET |
/api/v1/settings/features |
session | Which previews are enabled. See preview features. |
Lifecycle and updates
| Method | Path | Privilege | Description |
|---|---|---|---|
GET |
/api/v1/lifecycle |
host.view |
The controller's own version and staged update, plus every host's version and reachability — what is current and what an update would move. jobs lists background uploads and stagings (active ones, and those finished in the last 30 minutes, newest first); hypervisor_image describes the retained hypervisor image, when there is one. |
POST |
/api/v1/lifecycle/command |
admin.users |
Upload a controller image; the body is the file, and X-Upload-Filename (URI-encoded) names it. Answers 202 {"job": …} as soon as the whole body has arrived; verification against the controller flavor and staging then run on the controller, followed in jobs and Recent Tasks. Never applies. 409 while another controller upload is being received or verified; 507 when the controller's data disk cannot hold the image. |
DELETE |
/api/v1/lifecycle/command |
admin.users |
Clear the staged controller image without applying it. 409 while a controller upload is being received or verified. |
POST |
/api/v1/lifecycle/command/apply |
admin.users |
Apply the staged controller image. Answers 202: the controller cannot overlay its own running root, so it reboots into an unattended in-place upgrade. Hosts keep running throughout. 409 while a controller upload is being received or verified. |
POST |
/api/v1/lifecycle/hypervisor |
host.manage |
Upload the hypervisor image once; the body is the file, X-Upload-Filename names it. Answers 202 {"job": …} when the body has arrived. The controller then verifies it, keeps it as the retained image, and stages it on every host that needs it — reachable, on an older version the image can upgrade from, and not already holding it — or only on the hosts named by repeated ?host=<id>. 409 while another hypervisor upload is being received or verified; 507 when the data disk cannot hold it. |
POST |
/api/v1/lifecycle/hypervisor/stage |
host.manage |
Stage the retained hypervisor image on {"hosts": ["<id>", …]} without uploading it again — a retry, a newly added host, or a deliberate reinstall. Answers 202 {"jobs": […]}. Hosts already staging are skipped; 409 if every named host is. 404 when no image is retained. |
DELETE |
/api/v1/lifecycle/hypervisor |
host.manage |
Remove the retained hypervisor image. 409 while an upload or a staging job could still be reading it. Images already staged on hosts are not touched. |
Host-side staging and apply are under host updates.
Backup, restore and support
| Method | Path | Privilege | Description |
|---|---|---|---|
GET |
/api/v1/backup |
admin.users |
Download a controller backup: a tar stream of the controller's state, including a database dump when running on PostgreSQL. |
POST |
/api/v1/restore |
admin.users |
Restore from a backup archive. Streams the upload. |
GET |
/api/v1/support-bundle |
admin.users |
Download a redacted diagnostic bundle (tar.gz): configuration, inventory and logs, built entirely in memory so a failure is a clean 500 rather than a half-downloaded archive. |
This backs up the controller, not your virtual machines. What SimpleVirt protects and what you must protect yourself is set out in backup responsibility.
SimpleVirt HCI storage endpoints
SimpleVirt HCI is a supported, always-on storage tier — these endpoints are not gated by a
preview flag and never answer 403 {"experimental": …}. (The …-preview paths are dry-run
cost previews of a destructive operation, not experimental features.)
| Method | Path | Privilege | Description |
|---|---|---|---|
GET |
/api/v1/clusters/{id}/hci/status |
datastore.view |
Pool health, capacity and protection state, plus the maintenance-aware rollup (state, headline, expected findings, hosts in maintenance, redundancy margin) and resilience ("what if this host goes offline", per host). A finding is marked expected only when planned maintenance explains the whole of it. |
GET |
/api/v1/clusters/{id}/hci/objects |
datastore.view |
Virtual Objects: every virtual disk and the file store, the VM that owns each disk, where its copies live and whether they are all in place. Served from a cache; never a live storage call. |
GET |
/api/v1/clusters/{id}/hci/objects/placement |
datastore.view |
One disk's sampled placement (?pool=&image=, validated against the cached inventory; cached 60 s, at most two samples at once per cluster). |
POST |
/api/v1/clusters/{id}/hci/retest |
datastore.view |
Take a fresh storage sample and objects reading now (one per 3 s per cluster; read-only). An unreachable cluster is an error, not a quiet success. |
GET |
/api/v1/clusters/{id}/hci/performance |
datastore.view |
Throughput and latency. |
GET |
/api/v1/clusters/{id}/hci/provision-state |
datastore.view |
Where provisioning has got to. |
GET |
/api/v1/clusters/{id}/hci/protection/plan |
datastore.view |
What the current protection policy resolves to. |
POST |
/api/v1/clusters/{id}/hci/protection/remediate |
datastore.manage |
Re-converge protection. |
GET |
/api/v1/clusters/{id}/hci/osd/{osd}/remove-preview |
datastore.view |
What removing one storage device would cost, before doing it. |
POST |
/api/v1/clusters/{id}/hci/osd/{osd}/remove |
datastore.manage |
Safely remove one storage device: drain it, wait for the pool to re-converge, then purge the disk. |
GET |
/api/v1/clusters/{id}/hci/host/{host}/decommission-preview |
datastore.view |
What decommissioning a host would cost, before doing it. |
POST |
/api/v1/clusters/{id}/hci/host/{host}/decommission |
datastore.manage |
Safely retire a host: drain every disk it carries, then leave quorum. |
POST |
/api/v1/clusters/{id}/hci/precheck |
datastore.manage |
Validate a cluster for HCI. |
POST |
/api/v1/clusters/{id}/hci/remount-fs |
datastore.manage |
Re-drive the shared filesystem mount on members that lost it. |
GET |
/api/v1/clusters/{id}/hci/network |
network.view |
The storage network HCI is using. |
GET |
/api/v1/clusters/{id}/hci/network/candidates |
network.view |
Networks eligible to carry it. |
POST |
/api/v1/clusters/{id}/hci/network/precheck |
network.manage |
Validate a candidate storage network. |
POST |
/api/v1/clusters/{id}/san |
datastore.manage |
Provision pooled storage for the cluster. Changing the replication of a pool that already exists (a grow, "add disks") obeys the same refusal as protection/remediate: nothing runs while a member is in a fresh planned window or any group is short of copies, and the failed phase says why. |
DELETE |
/api/v1/clusters/{id}/san |
datastore.manage |
Destroy it. |
HCI status envelope
Everything below is additive: an older controller simply omits it, and clients must tolerate
its absence. GET /api/v1/clusters/{id}/hci/status is served from the background-sampled cache,
never a live storage call.
| Field | Meaning |
|---|---|
stale |
RAW and fail-closed: the last sample did not answer, or is older than 20 s. Every safety gate reads this, never unreachable. |
unreachable |
CONFIRMED outage: two consecutive failed sample ticks, or a sample older than 30 s. Only this turns the header to "unknown"; one failed tick is stale: true, unreachable: false. |
pools_carried |
The datastore list is the last complete read (this sample's pool sub-read failed). |
pools_unreadable |
Pool details could not be read for over two minutes and nothing is left to carry; show "could not read datastore details", never "no datastores yet". |
datastores[] |
One user-facing datastore per kind: kind (block VM disks, files ISOs), label, usable_bytes, used_bytes, pools, protection (copies, min_copies, failure_domain, host_separated, proven, tolerated_host_failures, state: healthy | reduced | checking | unknown), compliance (compliant | below_recommended | reconciling | reduced | checking | unknown) and rechecking. checking means "cannot be proven right now" (placement groups re-peering, host layout not read yet): neutral, not a fault. A worse verdict is shown only after it is seen on three consecutive samples; rechecking: true marks a card that still shows the held verdict while a worse one is being confirmed. |
rollup |
The header verdict: state (ok | maintenance | warn | err | unknown), headline, expected (health-check code to the reason it is expected), hosts_in_maintenance, margin (one_more_failure | none | unknown). unknown only for a confirmed outage; a single stale tick keeps the held state but the margin is unknown. |
resilience |
"What if this host goes offline?" per storage host: as_of, stale, margin, hosts[] (host, in_maintenance, safe, newly_below, already_below, at_risk_pools, affected_disks, affected_vms (at most 10 names), is_mon, blockers) and separation (pgs_checked, violations, pools). safe is the enter-maintenance gate's own decision from a reading under 90 s old and complete; on any stale or partial reading stale is true, margin is unknown and no host is safe. |
health_checks[].expected, .explained_by |
Set only when the WHOLE check is explained by hosts in planned maintenance (with the plain reason). A mixed picture keeps its checks raised. Planned maintenance never explains data-integrity codes or inaccessible data, a host in maintenance for over 24 hours (or whose planned restart did not finish cleanly) stops explaining anything, and a host in maintenance that is up but has only SOME of its disks down is a real failure, not the plan. The alarm engine still raises every check. |
status.recovery.kind |
waiting (planned maintenance explains the missing copies and nothing is moving: see waiting_for), resync, rebuild or rebalance (only while data is really moving). waiting_for names the hosts whose return the cluster is waiting for. progress_pct is 1 − outstanding/peak since the last time nothing was outstanding. |
Virtual Objects
GET /api/v1/clusters/{id}/hci/objects answers from a cache (collecting: true on the very
first read, while the reading is taken in the background). It carries as_of, age_seconds,
stale, source_host, objects_supported (false, with unsupported_hosts, when members run a
SimpleVirt without the read), partial (sub-reads that failed), refresh_error, and:
owners{complete, reason}: an image is reportedunattachedonly when every cluster host is reachable and every VM's state is known; otherwise itsowner_stateisunknown.datastores[]{kind, label, pools, compliance, reason, copies, min_copies, failure_domain, host_separated, below_recommended}:host_separatedis the LIVE fact "enough healthy hosts right now", not the pool's policy (failure_domainis).objects[]{id, kind (disk|files), name, pool, image, size_bytes, owner {vm_uuid, vm_name, host_id, host_name, state}|null, owner_state (known|unattached|unknown), datastore, compliance (compliant|reconciling|reduced|inaccessible|unknown), reason, expected, expected_why}. An image is striped over about every placement group of its pool, so its compliance is its pool's.expectedis true only while the whole picture is planned maintenance.pools[],hosts[]{host, state (online|maintenance|down), kind, since, osds}andseparation.
GET /api/v1/clusters/{id}/hci/objects/placement?pool=&image= returns one disk's sampled
placement (hosts, sites, copies, min_copies, health, pool_compliance); 409 while the
inventory is still being collected, 404 for a disk not in it. POST /api/v1/clusters/{id}/hci/retest returns {tested_at, took_ms, throttled?, objects_error?}.
HCI safety refusals and previews
A destructive storage change is refused, with a plain reason, while a member is in a fresh
planned window (operator Maintenance Mode or an armed planned reboot/update): raising
protection (protection/remediate, and any provisioning step that changes an existing pool's
replication) answers 409; removing a disk or decommissioning a host answers 409 (and the
preview shows a blocking Maintenance check). A member that no longer explains anything — in
maintenance over 24 hours, silent longer than the anti-strand window, or whose restart did not
finish cleanly — does not block; the previews list it as a warn Maintenance check instead,
and the operation's own target host never blocks itself. Raising protection is also refused
whenever the LIVE status shows a disk down, any group not active and clean, or no health
reading, whatever an older placement reading says.
GET /api/v1/hosts/{id}/maintenance/preview gains, inside storage, source (snapshot | live | estimate; an estimate never says ok), label
(plain provenance such as "as of 12s ago"), blockers (the gate's reasons), and
affected_disks / affected_vms (what would stop serving I/O). The host's own gate now also
refuses (overridable with force, like every other refusal) when the cluster's disk layout
could not be read.
Preview feature endpoints
Each group below answers 403 with {"experimental": "<flag>"} unless its flag is
enabled on the controller. Read preview features before
integrating against any of them.
Kubernetes (preview: kubernetes)
| Method | Path | Privilege | Description |
|---|---|---|---|
GET |
/api/v1/k8s |
vm.view |
Provisioned clusters. |
GET |
/api/v1/k8s/{id} |
vm.view |
One cluster. |
POST |
/api/v1/k8s |
vm.create |
Provision a cluster. |
DELETE |
/api/v1/k8s/{id} |
vm.delete |
Delete a cluster and its nodes. |
GET |
/api/v1/k8s/{id}/kubeconfig |
admin.users |
The cluster-admin kubeconfig. |
GET |
/api/v1/k8s/{id}/joincommand |
admin.users |
The node bootstrap token and join command. |
The last two hand back credentials, which is why they require admin.users and not
vm.view — a read privilege that lets a viewer see a cluster must not also hand them
its administrative key.
Stretched cluster
These endpoints need no experimental flag — the stretched cluster has graduated out of the
preview gate. Safety is unchanged: enable runs a fail-closed readiness gate and refuses on any
cluster that is not a genuine two-site topology with a witness. Full two-datacenter hardware
validation is pending — see SIMPLEVIRT_STRETCHED_CLUSTER.md.
| Method | Path | Privilege | Description |
|---|---|---|---|
GET |
/api/v1/clusters/{id}/stretch |
cluster.view |
The two-site topology and its lifecycle state. |
PUT |
/api/v1/clusters/{id}/stretch |
cluster.ha |
Declare the topology: site membership and the witness. |
DELETE |
/api/v1/clusters/{id}/stretch |
cluster.ha |
Clear the declaration. |
POST |
/api/v1/clusters/{id}/stretch/preview |
cluster.view |
Dry-run a topology, read-only. |
POST |
/api/v1/clusters/{id}/stretch/enable |
cluster.ha |
Activate, gated on readiness. |
POST |
/api/v1/clusters/{id}/stretch/disable |
cluster.ha |
Deactivate. |
GET |
/api/v1/clusters/{id}/stretch/maintenance/precheck |
cluster.view |
What taking a whole site offline would cost. |
Workload distributor
Graduated out of preview — these routes need no flag. The distributor defaults to recommend-only, and every move it applies passes the same fail-closed migration precheck a manual live-migration does.
| Method | Path | Privilege | Description |
|---|---|---|---|
POST |
/api/v1/clusters/{id}/drs |
cluster.manage |
Set the cluster's balancing mode and aggressiveness. |
GET |
/api/v1/clusters/{id}/drs/recommendations |
cluster.view |
Proposed moves, each with its reason and expected effect. |
POST |
/api/v1/clusters/{id}/drs/recommendations/{rid}/apply |
cluster.manage |
Apply one recommendation. |
POST |
/api/v1/clusters/{id}/drs/rules |
cluster.manage |
Set a per-VM placement rule. |
DELETE |
/api/v1/clusters/{id}/drs/rules/{vmuuid} |
cluster.manage |
Clear a per-VM rule. |
File services (preview: file_services)
| Method | Path | Privilege | Description |
|---|---|---|---|
GET |
/api/v1/clusters/{id}/file-services |
cluster.view |
The File Services view: policy, declared shares, and an honest readiness signal. |
POST |
/api/v1/clusters/{id}/file-services |
cluster.manage |
Enable/disable and set the floating address and protocols. |
POST |
/api/v1/clusters/{id}/file-services/shares |
cluster.manage |
Declare a share (name, protocol, quota, read-only, allowed clients, SMB valid users). |
PUT |
/api/v1/clusters/{id}/file-services/shares/{sid} |
cluster.manage |
Edit a share in place (quota, read-only, allowed clients, SMB valid users); keeps its id and data. |
DELETE |
/api/v1/clusters/{id}/file-services/shares/{sid} |
cluster.manage |
Remove a declared share. Data is retained by default; ?delete_data=true also reclaims it. |
POST |
/api/v1/clusters/{id}/file-services/smb-accounts |
cluster.manage |
Mint a local SMB service account; the password is returned once. |
POST |
/api/v1/clusters/{id}/file-services/smb-accounts/{user}/rotate |
cluster.manage |
Rotate an SMB account's password (returned once). |
DELETE |
/api/v1/clusters/{id}/file-services/smb-accounts/{user} |
cluster.manage |
Remove an SMB service account. |
Cluster services (preview: cluster_services)
| Method | Path | Privilege | Description |
|---|---|---|---|
GET |
/api/v1/clusters/{id}/cluster-services |
cluster.view |
The cluster-services view: membership, authority and per-host state. Returns an enabled: false shell when nothing has been sampled or no member runs the service, so a client shows "not present" rather than an error. |
Block datastores (preview: block_datastores)
This preview gates a type, not a path. POST /api/v1/hosts/{id}/datastores refuses
an iSCSI or Fibre Channel datastore unless the flag is on; every other datastore type
is unaffected.
The host agent API
Each SimpleVirt host runs simplevirtd, which serves its own HTTP API on three surfaces. None
of them is a client integration surface. They are documented here so that a reader
who finds them on the wire knows what they are and why not to call them.
| Surface | Authentication | Routes | What it is |
|---|---|---|---|
/api/v1/agent/... |
Authorization: Bearer <agent token> |
117 | The controller-to-host channel. SimpleVirt-Command holds a per-host token installed at pairing and calls these to read inventory and drive the host. The shapes are negotiated between the two builds at either end and change without notice. |
/api/v1/... |
Host session cookie + CSRF | 85 | The host's own recovery console — the local web UI an operator uses when the controller is unavailable. It mirrors the agent surface with a session instead of a token. |
/api/v1/cluster/... |
Loopback only + a cluster-control token | 10 | The cluster-services control surface. Refused outright from any non-loopback address. |
/healthz, GET /api/v1/version, POST /api/v1/auth/login, /, /api/ |
none | 5 | Liveness, build string, host sign-in, the host's embedded console, and the API 404 catch-all. |
Everything a client legitimately needs from a host is already proxied by the
controller under /api/v1/hosts/{id}/..., with the controller's privilege model
applied and the action recorded in the audit log. Calling a host directly bypasses
both. Use the controller.
Coverage of this document
Every route registered by SimpleVirt-Command appears in exactly one section above: 216
paths under /api/v1, plus /healthz and the console catch-all — 218 registrations
in total. There is no separate flat index, deliberately: a second copy of the route
table is a second thing to keep true.
Two things are stated once rather than per endpoint, and apply everywhere:
- Authentication — session cookie on everything,
X-CSRF-Tokenon every mutating request. See authentication. - Errors —
{"error": "…"}with a meaningful status code. See conventions.
Where an endpoint's request or response shape is not obvious from its name, it is described in its section. Where it is obvious — a list endpoint returning the objects it lists — it is not padded out.