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).
Authentication
Section titled “Authentication”Send the SSO access token as a bearer header. Browsers opening a subscription WebSocket put it in the connection-init payload instead.
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.
Two roots
Section titled “Two roots”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.
Mutations return a task
Section titled “Mutations return a task”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.
Console tickets
Section titled “Console tickets”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.
Subscriptions
Section titled “Subscriptions”| 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).
Errors
Section titled “Errors”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 |
Networking (PIVIRT-186)
Section titled “Networking (PIVIRT-186)”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.
Operator surface
Section titled “Operator surface”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.
Console client
Section titled “Console client”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}`);
// Networkingconst { 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);