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, add |
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 is "http", "network", "parse", or "fanout" (a multi-metro
operation that partly failed, or an unusable scope β see MetroFanoutError
above).
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",
});fetch is used from the global scope. On Node.js 18+ it is built in. For older
runtimes, or to customise 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.