Skip to content

GraphQL API (Director)

The director serves the console’s API at /graphql (with an interactive playground at /graphql/playground). It is the only origin a tenant-facing console needs: every read comes from the director’s resource store, every write becomes a task, and nothing on this surface reaches an agent or a host directly.

The schema is authoritative in pivirt-director/internal/graph/schema.graphqls; the console package @pilab/pivirt-api carries a verbatim copy and a small client (GraphQLClient, directorApi).

Send the SSO access token as a bearer header. Browsers opening a subscription WebSocket put it in the connection-init payload instead.

Terminal window
curl -s https://director.pilab.hu/graphql \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"query":"{ viewer { subject isOperator projects { id } } }"}'
{ "type": "connection_init", "payload": { "token": "<access token>" } }

capabilities and introspection answer without a token, so a console can check the director’s version and feature set before logging in. Everything else returns UNAUTHENTICATED.

type Query {
viewer: Viewer! # who you are and what you can reach
project(id: ID!): Project # "org/project"; null unless a binding covers it
admin: Admin # null unless the caller is an operator
capabilities: Capabilities!
}

The project id is the only place a namespace crosses in. It is checked against the caller’s RoleBindings before any field beneath it resolves; a project the caller cannot reach is null, never an error, so the namespace set cannot be enumerated. Inside a project, vms, tasks, quota, plans and images are already scoped.

query {
project(id: "acme/prod") {
quota { limits { vms vcpu } used { vms vcpu } }
vms(first: 20) {
totalCount
pageInfo { hasNextPage endCursor }
edges { node { id name state phase generation observedGeneration addresses { addresses } } }
}
}
}

A VM is settled when observedGeneration == generation and phase == "ready" — the same convergence rule the node reconciler uses.

mutation($tok: ID!) {
createVm(projectId: "acme/prod",
input: { hostname: "web-01", plan: "s-2vcpu-4gb", image: "ubuntu-24-04" },
clientToken: $tok) {
task { id type phase }
affected { kind uid name }
}
}
Rule Why
Every mutation takes a clientToken (a UUID you generate). A replay within 24 hours returns the original payload and does no work. A retrying client or a double-clicked button must not create two VMs.
Updates take ifMatchGeneration. A stale value is a CONFLICT and nothing is written. A client that read generation 3 must not clobber a generation-4 change it never saw.
The response is a Task plus the resources it affects. Subscribe to taskProgress(id) or re-query the resource. The resource is Pending until the node reconciles it; the API does not pretend otherwise.

Power operations (powerVm with START, STOP, FORCE_STOP, RESTART), renameVm and deleteVm follow the same shape. A suspended organisation refuses all of them with TERMINAL; a full quota refuses createVm with QUOTA naming the meter.

mutation { consoleTicket(vmId: "…", kind: VNC) { ticket path expiresAt } }

The director authorises the caller for that VM, then mints an HMAC-signed ticket bound to it, valid for five minutes and carrying no access token. Open path on the director with ?ticket=<ticket>; the console WebSocket route verifies the signature, checks the VM in the path matches, and acts as the ticket’s subject through the same namespace check as the API. A leaked ticket opens one console for a few minutes and nothing else.

Field Delivers
vmChanged(projectId) every VM in the project whose state the director observes changing, as the full VirtualMachine
taskProgress(id) the task on every phase or progress change, until it reaches a terminal phase

Transport is graphql-transport-ws (the graphql-ws subprotocol is also accepted).

Every error carries extensions.code:

Code Meaning Client should
RETRYABLE transient — a node was unreachable retry the same request
TERMINAL the request cannot succeed as sent — invalid input, org suspended fix and resend; never retry unchanged
CONFLICT ifMatchGeneration did not match re-read, decide, resend with the new generation
QUOTA the namespace’s quota refuses it; the message names the meter and numbers show the tenant the numbers
NOT_FOUND does not exist, or is not yours — deliberately indistinguishable treat as absent
UNAUTHENTICATED no or invalid token log in

A project’s private networks live under it: project(id) { vpcs { … subnets { … } } floatingIps { … } securityGroups { … } }, and vpc(name) for one. A VM lists its VPC interfaces under networks { interface mac vpc subnet address securityGroups }; createVm takes networks: [{ vpc, subnet, securityGroups, address }] and the director allocates each interface’s address before the VM record exists.

mutation {
createVpc(projectId: "acme/prod", input: { name: "main", cidr: "10.42.0.0/16" }, clientToken: "…") { vni bridge phase }
createSubnet(projectId: "acme/prod", vpc: "main", input: { name: "web", cidr: "10.42.1.0/24" }, clientToken: "…") { gateway }
applySecurityGroup(projectId: "acme/prod", input: { name: "web", ingress: [{ proto: "tcp", ports: [443], from: ["0.0.0.0/0"] }] }) { generation }
claimFloatingIp(projectId: "acme/prod", name: "www", clientToken: "…") { address phase }
}

Then createVm(…, input: { …, networks: [{ vpc: "main", subnet: "web", securityGroups: ["web"] }] }) and, once the VM has an address, attachFloatingIp(projectId, name: "www", vmId, interface: 0). detachFloatingIp keeps the address claimed; releaseFloatingIp refuses while attached. These mutations are synchronous — the object is stored and returned — and the host work runs behind them: Vpc.phase is Pending until the hosts have been programmed, Ready, or Degraded when a host refused an object or was unreachable (the director retries on the next change). createVpc and claimFloatingIp count against the project’s vpcs and floatingIps quota and refuse with QUOTA.

admin exposes organizations, nodes, plans, images, all tasks, roleBindings(scope) and publicPools. Operator mutations: createOrganization (creates the default project too), createProject, setQuota, bindRole / unbindRole, suspendOrganization / resumeOrganization, and createPublicPool(input: { name, cidr, gateway, exclude, zone }) — the range floating IPs and VPC router uplinks come from.

The director’s vpc config block says where tenant networks meet the underlay: uplink_bridge (the host bridge carrying the public network on gateway nodes, default br-ex), public_pool (the IPAMPool router uplinks are allocated from), gateway_label (hosts allowed to carry routers), vni_start / vni_end.

import { GraphQLClient, directorApi, isSettled, terminalPhases } from "@pilab/pivirt-api";
const api = directorApi(new GraphQLClient({ getToken: () => localStorage.getItem("token") }));
const { createVm } = await api.createVm("acme/prod", { hostname: "web-01", plan: "s-2vcpu-4gb", image: "ubuntu-24-04" });
const stop = api.onTaskProgress(createVm.task.id, (t) => {
if (terminalPhases.has(t.phase)) stop();
});
const { consoleTicket } = await api.consoleTicket(vmId, "VNC");
new WebSocket(`${consoleTicket.path}?ticket=${consoleTicket.ticket}`);
// Networking
const { createVpc } = await api.createVpc("acme/prod", { name: "main", cidr: "10.42.0.0/16" });
await api.createSubnet("acme/prod", "main", { name: "web", cidr: "10.42.1.0/24" });
const { claimFloatingIp } = await api.claimFloatingIp("acme/prod", "www");
await api.attachFloatingIp("acme/prod", "www", vmId);