The official JavaScript/TypeScript SDK for the Unikraft Cloud API.
npm install @unikraft/cloudimport { UnikraftCloud } from "@unikraft/cloud";
const ukc = new UnikraftCloud({ token: process.env.UKC_TOKEN });
// By default the client is account-wide: every metro is asked in parallel and
// the results are merged, each tagged with the metro it came from.
for await (const inst of ukc.instances.list({ details: true })) {
console.log(inst.metro, inst.name, inst.state);
}
// Creating a resource happens in one metro, so name it.
const fra = ukc.metro("fra");
const instance = await fra.instances.create({
image: "nginx:latest",
autostart: true,
memory_mb: 256,
service_group: {
services: [{ port: 443, handlers: ["tls", "http"], destination_port: 80 }],
},
});
// Operations chain off a reference, so a ref is written once.
await fra.instances.get({ name: instance.name! }).wait({ state: "running" });
await ukc.instances.get({ name: instance.name! }).suspend();
await fra.instances.delete([{ uuid: instance.uuid! }]);Pass a bearer token to the constructor, or set the UKC_TOKEN environment
variable. Create a token in the Unikraft Cloud dashboard.
The SDK is explicitly two layers, and you choose per call which one you are in:
| Layer | Where | What you get |
|---|---|---|
| Porcelain | @unikraft/cloud β ukc.instances, ukc.volumes, β¦ |
Short verbs, no envelope, auto-pagination, metro fan-out, chainable handles |
| Plumbing | @unikraft/cloud/api/platform, .../api/controlplane |
The OpenAPI specification as written: operationId methods, raw envelope, one metro per call |
The porcelain layer holds a plumbing client rather than extending one, so the two never blur together β and the raw client is always one property away:
// Porcelain: unwrapped, metro-aware.
const inst = await ukc.instances.get({ name: "web" });
// Plumbing, from the same client and credentials.
const res = await ukc.api.platform.instances.getInstances({ count: 10, details: true });
res.status; // "success"
res.data?.instances;
// Plumbing, per resource (the escape hatch for anything not wrapped yet).
await ukc.instances.api.getInstanceLogs({ name: ["web"] });
// Plumbing, standalone β no porcelain involved.
import { InstancesApi, PlatformApi } from "@unikraft/cloud/api/platform";
import { ControlPlaneApi } from "@unikraft/cloud/api/controlplane";
const api = new PlatformApi({
baseUrl: "https://api.fra.unikraft.cloud",
token: process.env.UKC_TOKEN,
});
await api.instances.getInstances({ count: 10 });Plumbing clients are metro-scoped by construction: they talk to whatever
baseUrl names, and a single call can be redirected with { baseUrl }. Fanning
out across metros is the porcelain layer's job.
Both Unikraft Cloud APIs defined by our OpenAPI specification are wrapped:
- Platform β metro-scoped resources (instances, volumes, services,
certificates, autoscale, images). Wrapped idiomatically at the top level; raw
under
@unikraft/cloud/api/platformandukc.api.platform. - Control plane β the global (non-metro) API for account, metros, images and
self-hosted nodes. Raw only, under
@unikraft/cloud/api/controlplaneandukc.api.controlplane.
for (const metro of (await ukc.api.controlplane.metros.listMetros()).data?.metros ?? []) {
console.log(metro.iata_code, metro.endpoint);
}The platform API is per-metro, but an account's instances are spread across them.
The porcelain layer treats the metros in scope as one namespace: reads fan
out concurrently and are merged, and every result carries the metro it came
from.
const ukc = new UnikraftCloud({ token });
// Every metro the account can reach (discovered once, then cached).
for await (const inst of ukc.instances.list()) console.log(inst.metro, inst.name);
// Explicit scopes β these also skip metro discovery entirely.
ukc.metro("fra").instances.list(); // one metro
ukc.metros(["fra", "dal"]).instances.list(); // several
ukc.instances.list({ metros: ["fra", "dal"] }); // just this call
ukc.instances.list({ metros: "all" }); // back to everything
// What the account can reach.
for (const { metro, baseUrl } of await ukc.availableMetros()) console.log(metro, baseUrl);Set the default scope at construction, too:
new UnikraftCloud({ token }); // all metros (default)
new UnikraftCloud({ token, metro: "fra" }); // one metro
new UnikraftCloud({ token, metros: ["fra", "dal"] }); // severalUKC_METRO acts like metro: β setting it pins the client to that metro.
| Code | Location |
|---|---|
fra |
Frankfurt, DE |
dal |
Dallas, TX, USA |
sin |
Singapore |
was |
Washington, DC, USA |
sfo |
San Francisco, USA |
Metro discovery asks the control plane and trusts the endpoint it reports, so new
metros work without an SDK upgrade. KNOWN_METROS lists the ones known when this
version was published.
- Reads cover the whole scope. Pages are interleaved in arrival order, so a slow metro never holds up a fast one.
- A name identifies a resource within a metro. The same name can exist in several metros at once β usually because you deployed the same thing everywhere β so a name plus a wide scope may match more than one resource. See Names across metros.
createnever fans out. It needs one metro: the client's, or the default metro (metro:/UKC_METRO/fra) when the scope is wider.- Bulk operations are bounded by the scope. Refs are located first and one
call goes to each metro that matched, so
delete([{ name: "web" }])under a wide scope deletes everywebin scope. Narrow the scope or qualify the ref to act on one. - The control plane is global, so it is unaffected by scope.
Names are scoped to a metro, so the same name can name a different resource in every metro. Three ways to say what you mean:
// 1. Qualify the ref. No search, no discovery β one request.
await ukc.instances.get({ name: "web", metro: "fra" }).suspend();
// 2. Narrow the scope, which qualifies every ref through it.
await ukc.metro("fra").instances.get({ name: "web" }).suspend();
// 3. Address every metro holding it, on purpose.
await ukc.instances.each({ name: "web" }).suspend(); // one result per metro{ uuid } refs never need qualifying: a UUID identifies one resource wherever it
lives.
get() insists on exactly one match, because the next thing you write might be a
mutation. When a name matches in several metros it throws an AmbiguousRefError
carrying the matches, so recovering costs no further requests:
import { AmbiguousRefError } from "@unikraft/cloud";
try {
await ukc.instances.get({ name: "web" }).suspend();
} catch (err) {
if (err instanceof AmbiguousRefError) {
err.metros; // ["fra", "dal", "sin"]
err.matches; // the instances themselves, each tagged with .metro
}
}each(ref) is the deliberate plural. It resolves the matches once, then runs each
operation in the metro that holds it:
const web = ukc.instances.each({ name: "web" });
await web.where(); // ["fra", "dal", "sin"]
await web.size(); // 3
for (const inst of await web) β¦ // the instances
await web.suspend(); // one result per metro
await web.edit().set({ memory_mb: 512 }).apply();Set operations return arrays and follow the same partial-failure rule as reads:
successes are returned on the thrown MetroFanoutError as err.results.
each() exists on instances, volumes, services and certificates.
A metro that is unreachable does not throw away the rest of the answer. Healthy
metros are drained first, then a MetroFanoutError naming the failures is
thrown:
import { MetroFanoutError } from "@unikraft/cloud";
try {
for await (const inst of ukc.instances.list()) use(inst); // fra, dal, was delivered
} catch (err) {
if (err instanceof MetroFanoutError) {
err.message; // "1 of 4 metros failed: sin (503)"
err.failures; // [{ metro: "sin", error: UnikraftCloudError }]
}
}For a bulk operation, which cannot yield as it goes, the results that did succeed
are attached to the thrown error as err.results.
Single-resource operations return a handle: a lazily-evaluated reference to one resource in one metro. Awaiting a handle gives the resource; calling an operation on it returns another handle:
await ukc.instances.get({ name: "web" }); // -> Instance
await ukc.instances.get({ name: "web" }).suspend(); // -> the suspended instance
await ukc.instances.get({ name: "web" }).update({ memory_mb: 512 });
await ukc.metro("fra").instances
.create({ image: "nginx:latest" })
.wait({ state: "running", timeoutSeconds: 30 })
.logs({ offset: -4096 });
await ukc.volumes.get({ name: "data" }).attach({ attach_to: { name: "web" }, at: "/data" });Nothing is sent until a handle is awaited or chained onto, and each step runs at most once however many times you await it. What that costs depends on the scope:
| Ref and scope | get(ref).suspend() |
|---|---|
{ name, metro }, any scope |
1 request β the suspend. The ref says where. |
{ name }, one metro in scope |
1 request β the scope says where. |
{ name }, many metros |
Locate first (one concurrent read per metro), then suspend where it lives β or throw AmbiguousRefError if several match. |
Handles also answer where they landed, and mutating steps return what that
endpoint reports (suspend() resolves to { uuid, name, state, previous_state },
not a full instance):
const web = ukc.instances.get({ name: "web" });
await web.where(); // "dal"
await web.resolve(); // { ref: { name: "web" }, metro: "dal", baseUrl: "..." }Every idiomatic method is also available in non-chained form
(ukc.instances.logs({ name: "web" }, { offset: -4096 })), which is exactly
shorthand for get(ref).logs(...).
The API models an update as a list of { prop, op, value } triples, with value
typed unknown. That is the plumbing. Idiomatically you write a patch object
and the op is worked out for you β a value sets it, null removes it, and an
omitted (or undefined) property is left alone, following JSON Merge Patch:
await ukc.instances.get({ name: "web" }).update({ memory_mb: 512, vcpus: 2 });
await ukc.instances.get({ name: "web" }).update({ env: { LOG_LEVEL: "debug" } });
await ukc.instances.get({ name: "web" }).update({ autokill: null }); // remove it
await ukc.volumes.get({ name: "data" }).update({ size_mb: 2048 });
await ukc.services.get({ name: "web" }).update({ soft_limit: 5, hard_limit: 20 });Every property is typed, so memory_mb: "512" and tags: "prod" no longer
compile. image takes the same string shorthand as create.
When set is not what you mean β merging into a property, or removing individual
members β stage the operations with edit() and send them as one request:
await ukc.instances.get({ name: "web" }).edit()
.set({ memory_mb: 512 })
.add({ env: { LOG_LEVEL: "debug" }, tags: ["prod"] })
.del({ env: ["OLD_FLAG"], tags: ["staging"] })
.apply();
// `null` in del() removes the property outright, not just some members.
await ukc.services.edit({ name: "web" }).del({ domains: null }).apply();apply() returns a handle like any other operation, so chaining continues:
await ukc.metro("fra").instances
.edit({ name: "web" })
.set({ memory_mb: 1024 })
.apply()
.wait({ state: "running" });Both forms are one request, and both are available with a ref instead of a handle
(ukc.instances.update({ name: "web" }, { memory_mb: 512 }),
ukc.instances.edit({ name: "web" })). The raw triples still work as an escape
hatch: update({ name: "web" }, [{ prop: "memory_mb", op: "set", value: 512 }]).
Every operation on an existing resource takes a ref: either { name } or
{ uuid }, never both. The API validates each identifier it is given, so a name
sent in the uuid filter fails with Invalid uuid '<name>' β the ref makes you
state which kind you hold, and only that field is sent.
await ukc.instances.get({ name: "web" });
await ukc.instances.get({ uuid: "550e8400-e29b-41d4-a716-446655440000" });
// A name belongs to a metro, so it can be qualified β see "Names across metros".
await ukc.instances.get({ name: "web", metro: "fra" });
// Bulk operations take one ref or an array of them.
await ukc.instances.stop([{ name: "web" }, { uuid: "550e8400-e29b-41d4-a716-446655440000" }]);| Resource | Methods |
|---|---|
ukc.instances |
create, get, each, list, update, edit, delete, start, stop, suspend, wait, metrics, history, logs |
ukc.volumes |
create, get, each, list, update, edit, delete, attach, detach |
ukc.services |
create, get, each, list, update, edit, delete |
ukc.certificates |
create, get, each, list, update, delete |
ukc.users |
quotas, quotasByUuid |
Handles add the per-resource operations: refresh, start, stop, suspend,
delete, update, edit, wait, logs, metrics, history on an instance;
attach, detach, update, edit, delete on a volume; update, edit and
delete on a service group.
Anything not listed β autoscale, the image registry, node information, the whole
control plane β is reachable raw via ukc.api.platform.* and
ukc.api.controlplane.*:
await ukc.api.platform.autoscale.getAutoscaleConfigurations({ uuid: ["sg1"] });
await ukc.api.platform.images.getImages({});See examples/ for complete programs.
Any network failure or non-2xx response throws an UnikraftCloudError:
import { UnikraftCloudError } from "@unikraft/cloud";
try {
await ukc.metro("fra").instances.get({ name: "does-not-exist" });
} catch (err) {
if (err instanceof UnikraftCloudError) {
console.error(err.kind, err.status, err.message, err.errors);
}
}err.kind says which layer failed:
kind |
Meaning |
|---|---|
"http" |
The server answered with a non-2xx status. err.status and err.errors carry the detail. |
"network" |
The request never got an answer: a refused connection, a reset, a DNS miss, an unreachable proxy. |
"parse" |
The server answered, but the body is not the JSON the operation expects. |
"fanout" |
A multi-metro operation that partly failed, or an unusable scope β see MetroFanoutError above. |
"config" |
The call could never be sent as configured: a missing token, or two options that contradict each other. |
"timeout" |
A wait ran out of time. It carries no status, because no single request failed; the last failure is in err.cause. |
The transport sends each request exactly once. It has no retries, and nothing in the SDK retries a call you made.
Waiting is a separate, explicit step, and it takes one of two shapes:
- The server waits.
instance.wait({ state: "running" })forwards the deadline to the platform, which holds the connection open. One request. - The SDK polls. Where the platform has nothing to hold open, the SDK asks
again on a schedule.
waitUntilReadyowns that loop: the delay doubles from 100 ms to 2 s with jitter, and the deadline is 60 s. The deadline and asignalboth end the probe in flight, not only the loop around it. Only a failure that can still change is worth another probe β a network fault, or a404/502/503/504. A401or a403is rethrown at once, so a rejected token reports itself instead of timing out.
Both reject on failure rather than returning. A poll that runs out of time
throws kind: "timeout".
metro (and UKC_METRO) also accepts a full http(s):// base URL, which is
used verbatim instead of being expanded into https://api.<metro>.unikraft.cloud.
A trailing /v1 is dropped, since every operation path already carries it:
export UKC_METRO=https://api.staging.example.internalA named endpoint is the only endpoint there is: no metro discovery is attempted
and no hostnames are invented, whatever the scope says. Point the control plane
at a matching deployment with controlPlaneUrl:
const ukc = new UnikraftCloud({
token,
metro: "https://api.staging.example.internal",
controlPlaneUrl: "https://controlplane.staging.example.internal",
});The SDK needs Node.js 22.12 or later, and ships only ESM. From that version
Node.js can require() an ESM package, so CommonJS code can load it too.
fetch is used from the global scope, where Node.js supplies it. To use a
different runtime, or to customise the transport, pass your own:
import { UnikraftCloud } from "@unikraft/cloud";
const ukc = new UnikraftCloud({ token, fetch: myFetch });On Node, the client honours the standard proxy environment variables so you can route traffic through a man-in-the-middle proxy such as mitmproxy, Charles, or Proxyman without any code change:
npm install undici # optional peer dependency, enables proxy support
export HTTPS_PROXY=http://127.0.0.1:8080
export NODE_EXTRA_CA_CERTS=~/.mitmproxy/mitmproxy-ca-cert.pem # trust the proxy CA
node your-script.jsHTTP_PROXY, HTTPS_PROXY, ALL_PROXY and NO_PROXY are all recognised, in
upper- or lower-case. Node's global fetch ignores these by itself, so the SDK
applies them via undici's EnvHttpProxyAgent, imported lazily only when a proxy
variable is set. If undici is not installed, requests proceed unproxied and a
one-time warning is logged.
Opt out per client with new UnikraftCloud({ proxyFromEnv: false }). Proxy
support is Node-only; browsers and Deno ignore these variables.
The plumbing layer is generated β see CONTRIBUTING.md for how to regenerate it and work on the SDK.
BSD-3-Clause. See LICENSE.md.