Skip to content

Writing extensions

yuriy edited this page Oct 15, 2026 · 3 revisions

Writing extensions

This page is for authors. An extension is a folder (or a .sextant-ext archive of it) with a manifest, sextant-extension.json, and a web page: HTML, JavaScript and CSS. The manifest says what the extension adds to Sextant and what it may do in clusters; the page runs in an isolated frame and talks to Sextant by messages. How users install and control extensions is on Extensions.

The SDK lives in the Sextant repository, in sdk/: the package @sextant/extension-sdk (types, the client for the frame, the theme helper, a test host), the command sextant-ext (build, pack, check), a template and two examples. It is not published to npm; you use it from a checkout of the repository.

Use the SDK build. It bundles your TypeScript and puts the scripts and styles inside the HTML page. A hand-written page may also load its own files with <script src> and <link rel="stylesheet"> — only from its own package.

Contents: Quick start · The package · The manifest · Contributions · Views and the context · The API · Watching objects · Errors · Theme and language · Storage · Permissions and confirmations · Development mode and debugging · Testing · Packaging and publishing · API versions · The wire protocol · Example: hello-page · Example: crd-view

Quick start

You need a checkout of the Sextant repository and Node.js 22 or newer (see Building from source); npm ci at the root of the checkout installs what the SDK uses (Vite, TypeScript, Vitest).

The template, sdk/template, is an extension with one page:

sdk/template/
├── sextant-extension.json   the manifest
├── index.html               the page every view opens
├── package.json             build, pack, typecheck, test scripts
├── tsconfig.json
└── src/
    ├── main.ts              connects to Sextant and draws the page
    ├── hello.ts             the page itself
    └── hello.test.ts        a test against the SDK's test host

Build it and load it into Sextant:

npm ci
npm run sdk:build -- sdk/template          # writes sdk/template/dist/

In Sextant: Settings → Extensions → Load unpacked folder…, choose sdk/template/dist, and Install. A section Hello appears in the cluster's sidebar with the page Hello; it shows the cluster and the theme, and its button shows a notice. Change src/hello.ts, build again, and Sextant reloads the page within a second.

To start a project of your own, copy sdk/template anywhere and point its dependency at the SDK of your checkout:

"devDependencies": {
  "@sextant/extension-sdk": "file:/path/to/sextant/sdk",
  …
}

Then, in your project:

npm install
npm run build        # sextant-ext build .            → dist/
npm test             # vitest with jsdom
npm run typecheck    # tsc --noEmit
npm run pack         # build, then sextant-ext pack dist → <id>-<version>.sextant-ext

Give the extension its own id, name, author and description in sextant-extension.json (the template's id is example.hello). The $schema line of the template points to the schema by a path inside the repository; in a copy, fix the path or remove the line (Sextant ignores it).

The package

A package is a folder with sextant-extension.json at its top, the HTML file named by entry, and whatever that page loads:

dist/
├── sextant-extension.json
└── index.html          (the SDK build puts the JavaScript and CSS inside it)

Rules (checked by Sextant at install and by sextant-ext pack):

  • at most 2000 files and 50 MB in all; the manifest at most 256 KB;
  • plain relative paths only: no .., no absolute paths, no \ : * ? " < > |, no names ending with . or a space, no Windows device names (nul, con.txt, com1, …), no two names that differ only by case, no symbolic links;
  • entries whose name starts with . and node_modules folders are left out of a package and never served;
  • the files are served by Sextant at sextant-ext://<id>/<path> (Windows: http://sextant-ext.localhost/<id>/<path>), with Content-Type by extension (.html, .js, .mjs, .css, .json, .svg, .png, .jpg, .gif, .webp, .avif, .ico, .woff, .woff2, .ttf, .otf, .txt, .md; anything else is application/octet-stream) and never cached;
  • every response carries the content policy default-src 'none'; script-src <base> 'unsafe-inline'; style-src <base> 'unsafe-inline'; img-src <base> data:; font-src <base> data:; connect-src 'none'; form-action 'none'; sandbox allow-scripts allow-forms allow-popups-to-escape-sandbox, where <base> is the extension's own address (sextant-ext://<id>/, on Windows http://sextant-ext.localhost/<id>/): no network at all (fetch, XMLHttpRequest and WebSockets fail), no scripts, styles, images or fonts from elsewhere. Images and fonts may be data: URLs (the SDK build turns small ones, up to 64 KB, into data: URLs).

The manifest

sextant-extension.json is checked strictly against a JSON schema, crates/sextant-core/src/extensions/manifest.schema.json: any unknown field or wrong value refuses the install with the path of the field, for example

sextant-extension.json: contributes.pages[0].id: …

Check a manifest without installing it: node sdk/scripts/cli.mjs check <folder or manifest> (in a project: npx sextant-ext check .). An example with every top-level field:

{
  "$schema": "https://raw.githubusercontent.com/FriendsOfKubernetes/sextant/main/crates/sextant-core/src/extensions/manifest.schema.json",
  "id": "acme.widgets",
  "name": "Widgets",
  "version": "1.2.0",
  "description": "Pages and actions for Acme's Widget resources.",
  "author": "Acme platform team",
  "sextantApi": 1,
  "entry": "index.html",
  "icon": "customResource",
  "permissions": {
    "clusters": { "type": "all" },
    "rules": [
      {
        "namespaces": { "type": "all" },
        "kinds": { "type": "only", "kinds": [{ "group": "acme.example.com", "kind": "Widget" }] },
        "actions": ["read", "modify"]
      }
    ]
  },
  "contributes": {
    "pages": [{ "id": "board", "title": "Widget board", "section": "customResources" }]
  }
}
Field Required Rules
$schema no Any string (up to 2048 characters); lets an editor find the schema; Sextant ignores it.
id yes 3–64 characters: lowercase letters, digits, . and -; starts with a letter or digit, does not end with ., no .., not a Windows device name. Unique: it names the extension's folder, its data and its contributions.
name yes 1–80 characters, not only spaces. Shown in the list, the consent window, confirmations and the log.
version yes A semantic version: 1.2.0, 1.2.0-beta.1, 1.2.0+build.5 (up to 64 characters).
description yes Up to 500 characters (may be empty).
author yes 1–100 characters, not only spaces.
sextantApi yes The major version of the extension API: 1.
entry yes The HTML file (.html or .htm) every view opens, relative to the package root, such as index.html or app/index.html; up to 255 characters; no part may start with . or be node_modules. Must be in the package.
icon no A name of Sextant's icon set (letters, digits and -, starting with a letter, up to 40), such as customResource, pod, deployment, workloads, config, network, storage, overview, scale, logs. The names are those of src/components/icons.tsx; an unknown name shows the default icon.
permissions yes What the extension may do in clusters, see below.
contributes yes What it adds, see Contributions; may be {}.

Sextant fills what may be left out: contribution lists default to empty, confirm of a rule to true, danger and multiple of an action to false.

permissions

"permissions": {
  "clusters": { "type": "only", "names": ["kind-sextant-test", "staging"] },
  "rules": [
    {
      "namespaces": { "type": "only", "namespaces": ["team-a"] },
      "kinds": { "type": "only", "kinds": [{ "group": "", "kind": "ConfigMap" }, { "group": "apps", "kind": "Deployment" }] },
      "actions": ["read", "modify"],
      "confirm": true
    },
    {
      "namespaces": { "type": "all" },
      "kinds": { "type": "all" },
      "actions": ["read"]
    }
  ]
}
Field Rules
clusters {"type": "all"}, or {"type": "only", "names": [...]} with up to 100 kubeconfig context names (unique, not blank). A name covers every context with that name from every kubeconfig source of the user. {"type": "only", "names": []} reaches no cluster.
rules Up to 100 rules. An empty list gives no access to any cluster data (the extension can still list its clusters, show pages and use its storage).
rules[].namespaces {"type": "all"} or {"type": "only", "namespaces": [...]} (1–100 names, up to 63 characters each). Objects without a namespace (Nodes, Namespaces, CRDs, …) are covered only by all.
rules[].kinds {"type": "all"} or {"type": "only", "kinds": [{"group", "kind"}, ...]} (1–100). group is the API group without a version, "" for the core group (Pod, ConfigMap, Secret, …); kinds are compared ignoring case.
rules[].actions 1 or more of read, modify, delete, logs, secretValues, no repeats.
rules[].confirm Ask the user before every modify and delete the rule allows. Default true; reads, logs and Secret values are never asked about.

A call is allowed when any rule covers it; it asks when any covering rule has confirm: true. The user grants exactly what the manifest asks for, at install (see Permissions and consent).

Contributions

All titles are 1–80 characters (not only spaces) and shown as written; ids are 1–40 characters of lowercase letters, digits and -, unique among contributions of the same type. A kind in kinds is {"group": "", "kind": "Pod"} as in rules. Every contribution shows the extension's entry page with ?view=<view>&id=<contribution id> in its URL; the page learns the same from the context. Inside Sextant a contribution's id becomes ext:<extension id>:<id>.

pages

A sidebar item that opens a page filling the main area (view page). Up to 50.

"pages": [
  { "id": "board", "title": "Widget board", "section": "customResources", "icon": "customResource", "order": 5 },
  { "id": "report", "title": "Weekly report", "section": "acme", "sectionTitle": "Acme" }
]
Field Rules
id, title As above.
section One of Sextant's sections, cluster, nodes, workloads, config, network, storage, namespaces, events, helm, accessControl, flux, argocd, customResources, or a section of the extension's own (a letter, then letters, digits and -, up to 40). Own sections come after Sextant's.
sectionTitle The header of an own section (the first page that gives one wins); without it the section id is shown. Ignored for Sextant's sections.
icon Icon of the item; default: the extension's icon, else the default icon.
order −10000 to 10000, default 0; smaller comes first among the section's items.

detailsSections

A section or a tab in the details panel of objects of the named kinds (view details). Up to 50.

"detailsSections": [
  { "id": "summary", "title": "Widget summary", "kinds": [{ "group": "demo.sextant.dev", "kind": "Widget" }], "placement": "section" },
  { "id": "history", "title": "History", "kinds": [{ "group": "apps", "kind": "Deployment" }], "placement": "tab", "order": 10 }
]
  • placement: "section": a section with the title as its header, among Sextant's sections (default order 50: after the conditions, before the YAML). The frame is as tall as the page asks (the SDK does it for you; 120 px until the first resize, at most 5000 px).
  • placement: "tab": the details panel gets a tab bar, Overview (Sextant's sections) plus one tab per such contribution; the frame fills the tab.
  • kinds: 1–100 kinds. order: −10000 to 10000.

The context's selection holds the shown object (an ObjectRef) and resourceVersion its metadata.resourceVersion, which changes whenever the object changes: read the object again with resources.get (or watch it).

A details section of the crd-view example

actions

A button in the details panel and an item in the table's row menu for objects of the named kinds. Running it opens a window with the extension's page (view action); ui.close() closes it. Up to 50.

"actions": [
  { "id": "scale", "title": "Scale widget", "icon": "scale", "kinds": [{ "group": "demo.sextant.dev", "kind": "Widget" }] },
  { "id": "purge", "title": "Purge", "kinds": [{ "group": "", "kind": "ConfigMap" }], "danger": true, "multiple": true }
]
  • danger: true shows it in red; multiple: true offers it when several rows are selected (otherwise only for one object).
  • The context's selection holds the objects it runs on.

The window of the "Scale widget" action

columns

A column in the table of one kind, read with a JSONPath from each object; no extension code runs. Up to 50.

"columns": [
  { "kind": { "group": "demo.sextant.dev", "kind": "Widget" }, "id": "generation", "title": "Generation", "jsonPath": ".metadata.generation", "after": "Replicas" },
  { "kind": { "group": "", "kind": "Pod" }, "id": "ready", "title": "Ready cond.", "jsonPath": ".status.conditions[?(@.type==\"Ready\")].status" }
]
  • jsonPath (up to 500 characters) is the JSONPath subset of CRD printer columns: .spec.size, .items[0].name, .items[*].name, .metadata.labels['app\.kubernetes\.io/name'], .status.conditions[?(@.type=="Ready")].status; {…} and a leading $ are accepted. A path that does not parse refuses the install; a path that finds nothing shows an empty cell; several values are joined.
  • after: the title of the column it goes after (compared ignoring case); at the end when it is missing or not found.

The "Generation" column

dockActions

A button in the dock bar, after Sextant's own, that opens a dock tab with the extension's page (view dock); pressing it again brings the same tab forward, and ui.close() closes it. Up to 20.

"dockActions": [{ "id": "console", "title": "Widget console", "icon": "logs" }]

Without icon the button shows its title. The context's clusterId is the cluster shown in the window.

settings

A section of the extension in Settings → Extensions, under its row (view settings, contribution id settings). The frame is as tall as its content.

"settings": { "title": "Widgets" }

clusterId in its context is the cluster shown in the window, or null.

Views and the context

One HTML page serves every view; the context tells it where it is. With the SDK it is sextant.context after connect(), and onContextChange follows it.

Field Meaning
apiVersion 1
extensionId The extension's id
view page, details, action, dock or settings
contribution The manifest id of the contribution shown (settings for the settings section)
clusterId The cluster the frame is about; pass it as clusterId (the SDK does). null when none is open
namespaces The namespaces selected in Sextant's namespace filter for that cluster; null for all namespaces (and when no cluster is open). Read-only: an extension cannot change the filter
theme light or dark
language The UI language, such as en
tokens Sextant's CSS variables and their values (see Theme and language)
selection Details: the shown object; action: the objects it runs on; otherwise empty. ObjectRefs only: {kind: {group, version, kind}, namespace, name}
resourceVersion Details: the shown object's metadata.resourceVersion; otherwise null

Sextant sends the context when the page says it is ready, and again whenever any of it changes (theme, language, cluster, namespace filter, selection, a changed object). Sextant never sends object data on its own: read objects through the API, which checks the extension's permissions.

A typical main.ts:

import { connect } from "@sextant/extension-sdk";

const sextant = await connect();
const root = document.getElementById("app")!;
switch (sextant.context.view) {
  case "details": showSummary(sextant, root); break;
  case "action": showScale(sextant, root); break;
  case "settings": showSettings(sextant, root); break;
  default: root.textContent = `Nothing to show in the ${sextant.context.view} view.`;
}

The API

connect(options?) connects the page to Sextant and resolves with a Sextant object once the first context arrives (it fails with timeout after 10 seconds, or invalid when the page is not inside a Sextant frame). Options: theme (apply Sextant's theme, default true), reportErrors (report uncaught errors, default true), autoResize (default true for details and settings), logFailures (write failed calls to the console, default true), connectTimeoutMs, timeoutMs (answer timeout of reads, default 60 000), changeTimeoutMs (of apply, patch and delete, which may wait for the user, default 180 000). Every call also takes {timeoutMs} as its last argument.

Every call on a cluster takes an optional clusterId (default: the context's cluster). It may be a cluster id from clusters.list or a context name (when only one cluster has it). A cluster that is not connected is connected first, as if the user pressed Connect.

Methods, the permission each needs, and what they return:

SDK Method Needs Returns
clusters.list() clusters.list nothing (lists the clusters in the extension's clusters) [{id, name, context, server, state}]
kinds({clusterId?}) kinds the cluster ResourceKind[]: the kinds the cluster serves that some rule names
resources.list(params) resources.list Read {items, truncated, namespaces?}
resources.get({ref}) resources.get Read the object
resources.watch(params, handlers) resources.watch Read a Watch (see Watching objects)
resources.apply({yaml}) resources.apply Modify, for every object the ObjectRefs applied
resources.patch({ref, patch, patchType}) resources.patch Modify (and Secret values, see below) the object as saved
resources.delete({ref}) resources.delete Delete nothing
logs.tail(params) logs.tail Logs on the Pod the log text
secret.value({namespace, name, key}) secret.value Secret values on the Secret the decoded value
storage.get(key) / set(key, value) / remove(key) storage.* nothing the value or null / nothing
ui.openDetails(ref) ui.openDetails nothing nothing
ui.navigate(page) ui.navigate nothing nothing
ui.notify(text) ui.notify nothing nothing
ui.close() ui.close nothing nothing
refreshContext() context.get nothing the context
call(method, params) any of the above, by name

"Needs" is checked for the cluster, namespace and kind of each object. Every method also needs the extension to be installed, enabled and not paused; otherwise it fails with denied. Parameters are read strictly: an unknown field is invalid (the SDK leaves out undefined fields).

Kinds and objects

A kind is {group?, version?, kind}: group defaults to "" (the core group); without version the version the cluster serves is used. An object (ref) is {kind, namespace?, name}; namespace is required for a namespaced kind and ignored for others. ObjectRefs from the context fit as they are.

clusters.list

const clusters = await sextant.clusters.list();
// [{ id: "57a83729d8067720", name: "kind-sextant-test", context: "kind-sextant-test",
//    server: "https://127.0.0.1:41871", state: { state: "connected" } }]

name is the display name the user gave the cluster (its context name otherwise); state is {state: "disconnected" | "connecting" | "connected"} or {state: "error", code, message}. In the activity log this call has no cluster.

kinds

const kinds = await sextant.kinds();   // [{ kindRef: {group, version, kind}, plural, namespaced, verbs, printerColumns }]

Only kinds some rule of the extension covers (all of them with "kinds": {"type": "all"}). printerColumns is always empty.

resources.list

const { items, truncated, namespaces } = await sextant.resources.list({
  kind: { group: "apps", kind: "Deployment" },
  namespace: "team-a",              // optional
  labelSelector: "app=web",         // optional
  fieldSelector: "metadata.name=web", // optional
  limit: 100,                        // optional: default 500, at most 5000
});

Without namespace, the extension gets every namespace it may read: all of them when a rule for all namespaces covers the kind, otherwise each namespace its rules name, one after another, and namespaces says which. truncated is true when there was more than limit. Secrets come without their values.

resources.get

const widget = await sextant.resources.get<Widget>({
  ref: { kind: { group: "demo.sextant.dev", kind: "Widget" }, namespace: "sextant-demo", name: "demo-widget" },
});

A Secret comes without its values (every value is "" and the annotation sextant.dev/values-hidden is set); use secret.value for one value.

resources.apply

const applied = await sextant.resources.apply({ yaml: "apiVersion: v1\nkind: ConfigMap\nmetadata:\n  name: settings\n  namespace: team-a\ndata:\n  mode: fast\n" });
// [{ kind: { group: "", version: "v1", kind: "ConfigMap" }, namespace: "team-a", name: "settings" }]

Server-side apply (field manager sextant) of one or more YAML documents (---). Every document is read and every object checked (Modify) before anything is sent, and the user is asked once for all of them when a rule says so; documents are then applied in order, each within 30 seconds. A Secret read through the API (with the sextant.dev/values-hidden annotation) is refused; pods of a node shell are refused.

resources.patch

await sextant.resources.patch({
  ref,
  patch: { spec: { replicas: 3 } },
  patchType: "merge",          // "merge" (an object, RFC 7386) or "json" (a list of operations, RFC 6902)
});

A JSON patch must be a list whose operations each have a path (and from, where used) starting with /. On a core Secret, a JSON patch with operations other than add, replace and remove (such as copy, move, test) also needs Secret values, because it could read them. Pods of a node shell are never patched. The change is limited to 30 seconds.

resources.delete

await sextant.resources.delete({ ref });

Limited to 30 seconds. Pods of a node shell are never deleted.

logs.tail

const text = await sextant.logs.tail({ namespace: "team-a", pod: "web-1", container: "app", lines: 500, previous: false });

The last lines lines (default 200, at most 2000) of a container (container may be left out for a pod with one container); previous: true reads the previous run of the container.

secret.value

const password = await sextant.secret.value({ namespace: "team-a", name: "db", key: "password" });

One key of one Secret, decoded; needs a rule with secretValues covering Secrets in that namespace. Use it only when the user expects it: the consent window warns about this permission.

ui

await sextant.ui.openDetails({ kind: { group: "", version: "v1", kind: "Pod" }, namespace: "team-a", name: "web-1" });
await sextant.ui.navigate("report");          // an own page by its manifest id
await sextant.ui.navigate("kind:apps/Deployment"); // a table: kind:<group>/<Kind>, kind:/Pod for the core group
await sextant.ui.navigate("overview");        // a page of Sextant: overview, events, helm-releases, helm-charts, portforward, settings
await sextant.ui.notify("Scaled demo-widget to 3 replicas");
await sextant.ui.close();
  • openDetails opens the details panel of the object in the cluster shown in the window; version may be left out for kinds Sextant knows itself, otherwise it is required.
  • navigate opens a page in the cluster shown; an own page id comes first, never another extension's page; an unknown page is notFound.
  • notify shows a short notice with the extension's name (at most 500 characters; it stays 8 seconds).
  • close closes the action's window or the dock tab; elsewhere it does nothing.
  • Without an open cluster, openDetails and navigate fail with invalid.

Watching objects

import { trackObjects } from "@sextant/extension-sdk";

const watch = sextant.resources.watch(
  { kind: { group: "", kind: "Pod" }, namespaces: ["team-a"], labelSelector: "app=web" },
  {
    onUpdate: trackObjects<Pod>((pods) => render(pods), (other) => console.log(other.type)),
    onError: (err) => showError(err.message),
  },
);
await watch.started;   // rejects when the watch is refused
// … later
await watch.unwatch();

Parameters: kind, namespaces (a list; without it, every namespace the extension may read), labelSelector, fieldSelector, metadataOnly (only {apiVersion, kind, metadata} of each object). The updates (WatchUpdate) are:

type Fields Meaning
snapshot items All current objects; comes first and again after a reconnect
delta upserted, deleted Objects added or changed; uids of objects deleted
reconnecting The connection to the cluster dropped; a new snapshot follows
error code, message The watch failed
forbidden namespaces The cluster refused the listed namespaces (Kubernetes RBAC)

Updates come in batches at most every 250 ms. trackObjects(onChange, onOther?) keeps the current objects for you (by metadata.uid) and calls onChange with them sorted by namespace and name. A Watch is also an async iterable (for await (const update of watch); leaving the loop stops it). A page may keep 32 watches open at once; a watch stops when the page reloads or goes away, when the cluster is disconnected, and when the extension is disabled, removed or given other permissions.

Errors

A failed call rejects with a SextantError (code, message, method); isSextantError(err, "denied") tests for one. The SDK writes failed calls to the console as [sextant] <method> failed: <code>: <message>. The codes:

Code When
denied Outside the extension's permissions, the user pressed Deny or did not answer in 2 minutes, the extension is disabled, paused or removed, or the object is a node shell pod. The message says what was refused and where the user can allow it.
invalid Bad parameters (an unknown field, a missing namespace or name, a malformed patch), an unknown method, a 33rd watch, storage over 1 MB, YAML without objects or with a Secret read without its values
notFound No such cluster, kind (the cluster does not serve it), object or page
forbidden The cluster's own access control (RBAC) refused the user's credentials
unauthorized The cluster rejected the user's credentials
conflict The cluster reported a conflict (for example a field another manager owns, in an apply)
timeout No answer in time: Sextant's limit (30 seconds for a read or a change) or the SDK's (60 seconds for reads, 180 for changes)
unreachable The cluster cannot be reached
authPlugin, kubeconfig The user's kubeconfig or its credential plugin failed
other Anything else

Theme and language

connect() applies Sextant's look to your page and follows its changes: it sets the context's tokens as CSS variables on <html>, data-theme (light or dark) and the matching color-scheme (so native controls and scrollbars follow the theme), data-view (the view) and lang, and adds a few base styles (body font and colour, buttons, inputs, tables, links; classes primary, danger, muted, error) first in <head>, so your own styles win. connect({theme: false}) turns it off; applyTheme(context, options) and addBaseStyles() do it by hand; sextant.onThemeChange(listener) is called when the theme, language or tokens change.

The variables: --color-bg, --color-rail, --color-sidebar, --color-main, --color-panel, --color-dock, --color-dock-header, --color-text, --color-text-muted, --color-border, --color-hover, --color-selected, --color-accent, --color-accent-text, --color-danger, --color-warning, --color-success, --shadow-panel, --control-height, --control-height-compact, --control-font-size, --icon-size, --font-family, --font-mono.

.card { background: var(--color-panel); border: 1px solid var(--color-border); color: var(--color-text); }
.card .hint { color: var(--color-text-muted); }

Give the page a background (the base styles do: --color-main for a page, --color-panel for details, action and settings, --color-dock for the dock); a transparent page shows white. The UI language is context.language (Sextant's interface is in English for now, en); the extension's texts are its own.

Storage

Each extension has a small store of its own JSON values on the user's computer, kept by Sextant (extensions-data/<id>.json in the data folder):

await sextant.storage.set("scale", { step: 2, maxReplicas: 20 });
const settings = await sextant.storage.get<{ step: number; maxReplicas: number }>("scale"); // null when not set
await sextant.storage.remove("scale");
  • keys are 1–256 characters; values are any JSON;
  • the whole store, as JSON, holds at most 1 MB; a set that would make it larger fails with invalid and changes nothing;
  • it needs no permission, works without a cluster, and is not in the activity log;
  • it is deleted when the extension is removed, and kept across updates.

Do not keep secrets there: it is a plain file.

Permissions and confirmations

From the author's side:

  • Ask for what you use. The user sees every rule before installing, in plain words; a narrow request is easier to accept. Name kinds ({"type": "only", ...}) and namespaces when you can.
  • Objects without a namespace (Nodes, Namespaces, CRDs, PersistentVolumes, …) need a rule with "namespaces": {"type": "all"}.
  • Secret values is its own action that nothing else implies; the consent window warns about it. Without it, Secrets come without values everywhere.
  • Changes ask by default. With confirm left out (or true), every apply, patch and delete waits for the user's Allow in Sextant's window (up to 2 minutes). Your promise stays pending meanwhile: show that something is waiting, and handle denied (the user said no). "confirm": false runs without asking, and the consent window shows the rule as Runs without asking.
  • A YAML with several objects is checked whole and confirmed once.
  • Granted, not requested. Calls are checked against what the user granted. A development folder whose manifest asks for more is paused until the user agrees; one that asks for less keeps what was granted until it is loaded again.
  • Never available: commands in containers, shells, port forwards, Helm, changing or deleting node shell pods.

Development mode and debugging

  • Build your project into a folder and load it with Load unpacked folder…; it runs from the folder, marked Development.
  • Sextant looks at the folder every second: a changed file reloads every frame of the extension; a changed manifest also registers its contributions again. Rebuild (npm run build) and look; Reload in its row forces it.
  • When the manifest asks for more permissions than were granted, the extension pauses and Sextant offers Review permissions. Loading the same folder again grants exactly what the manifest asks for now.
  • An uncaught error or unhandled rejection in your page (reported by the SDK), or a page that fails to load, replaces the frame with Extension failed: and a Reload button. Report your own with sextant.reportError(err).
  • Failed calls are logged to the console by the SDK. In a development build of Sextant (npm run tauri dev) the web inspector is available (right-click → Inspect Element); your frame is a separate frame in it. Release builds have no inspector.
  • The Activity of your extension (in its row) shows every cluster call with its decision and result.
  • Your page cannot be opened in a normal browser: connect() fails outside Sextant. Test the logic with the test host instead.

Testing

@sextant/extension-sdk/testing has a stand-in for Sextant's window that the real client talks to (in a DOM environment such as jsdom):

import { startTestHost, type TestHost } from "@sextant/extension-sdk/testing";
import { afterEach, expect, it } from "vitest";
import { startScale } from "./scale";

let host: TestHost;
afterEach(() => host.close());

it("patches the replicas", async () => {
  const widget = { metadata: { name: "demo-widget", namespace: "sextant-demo" }, spec: { replicas: 2 } };
  host = await startTestHost({
    context: { view: "action", selection: [{ kind: { group: "demo.sextant.dev", version: "v1", kind: "Widget" }, namespace: "sextant-demo", name: "demo-widget" }] },
    handlers: {
      "resources.get": () => widget,
      "resources.patch": ({ patch }) => ({ ...widget, ...patch }),
    },
  });
  // … drive the page …
  expect(host.calls.at(-1)).toMatchObject({ method: "resources.patch" });
});

startTestHost({context, handlers}) answers each method from handlers (throw a SextantError or {code, message} to refuse; a method without a handler fails with notFound; storage, ui and watch have defaults). The host records calls, watches (with emit(update) to send updates), storage, notices, closedByFrame, errors and heights; setContext(patch) sends a new context. The template and the examples have tests to copy.

Packaging and publishing

npm run pack                                           # in a project from the template
npx sextant-ext pack dist --out releases               # the same, another output folder
npm run sdk:pack -- path/to/dist                       # from the Sextant checkout
npm run sdk:pack -- path/to/dist --dry-run             # check and list only

sextant-ext pack <folder> checks the folder as Sextant checks a package (manifest, entry, paths, links, file count and size) and writes <id>-<version>.sextant-ext next to the folder (or into --out). The command line of the SDK:

Command Does
sextant-ext build <project> [--out <dir>] [--no-inline] Checks the manifest, bundles the project's entry page and what it imports (TypeScript included) with Vite into <project>/dist, puts scripts and styles inside the HTML (unless --no-inline, which keeps them as separate files), turns small assets into data: URLs, copies public/ and the manifest
sextant-ext pack <folder> [--out <dir>] [--dry-run] Checks and packs a folder
sextant-ext check <folder or manifest> Checks a manifest as Sextant does

In the Sextant checkout the same commands are npm run sdk:build -- …, npm run sdk:pack -- … and node sdk/scripts/cli.mjs check ….

To publish, attach the .sextant-ext file to a GitHub release of a public repository (Sextant downloads without credentials), for example:

gh release create v1.2.0 acme.widgets-1.2.0.sextant-ext --title "Widgets 1.2.0"

Users install it with Install from URL… and the asset's link, https://github.com/<owner>/<repo>/releases/download/v1.2.0/acme.widgets-1.2.0.sextant-ext (at most 20 MB), or download the file and use Install from file…. A new version is a new release with the same id and a higher version; if it asks for more permissions, users see New permissions before they update.

API versions and compatibility

  • sextantApi in the manifest is the major version of the extension API the extension is written for; this Sextant supports 1 and refuses to install anything else ("extension API N is not supported; this Sextant supports 1").
  • Every message carries sextant: 1, and the context has apiVersion: 1.
  • A method Sextant does not know fails with invalid, and so does a parameter it does not know; an extension that wants to use something newer can try the call and fall back.
  • The SDK in the repository speaks the API of the Sextant next to it; build your extension with the SDK of the Sextant version you target.

The wire protocol

The SDK hides this; read it if you write the frame side yourself. The frame talks to window.parent with postMessage(message, "*"); Sextant answers with messages to the frame. Every message has sextant: 1 and doc: an id the page makes up once per document (16–128 characters of letters, digits, _ and -; the SDK uses 128 random bits in hex). Sextant ignores messages without them and messages from any window but the frame's own; the page should ignore messages that are not from window.parent or carry another doc.

Direction Message Meaning
frame → Sextant {sextant: 1, doc, type: "ready"} The page listens. Send it first, as early as possible; Sextant answers with the context. The SDK repeats it (after 100, 250, 500, 1000 ms, then every 2 s) until a context comes.
frame → Sextant {sextant: 1, doc, id, method, params?} A call; id is a string (1–128 characters) or a number chosen by the page.
Sextant → frame {sextant: 1, doc, id, result} Its answer.
Sextant → frame {sextant: 1, doc, id, error: {code, message}} Or its failure.
Sextant → frame {sextant: 1, doc, stream, event} An update (WatchUpdate) of a resources.watch; stream is the id of the watch call, so listen before you send it. The call itself answers {stream: id}; stop it with resources.unwatch and {stream: id}.
Sextant → frame {sextant: 1, doc, type: "context", context} The context, on ready, on every load of the frame and whenever it changes.
frame → Sextant {sextant: 1, doc, type: "resize", height} The content's height in CSS pixels (details sections and the settings section; at most 5000).
frame → Sextant {sextant: 1, doc, type: "error", message} An uncaught error: Sextant shows "Extension failed".

The method names and parameters are those of The API (clusterId is required on the wire for cluster methods). A new document in the frame (a reload, another page of the package) has its own calls and watches; Sextant closes those of a document that is gone. A frame may have 8 documents going at once; a ninth ends the oldest one that is neither the current document nor the one the last load kept.

doc keeps well-behaved documents apart; it is not a secret between the documents of one frame. Answers and watch updates still on their way to a document that unloads can reach the next document in the same frame until the frame's next load ends the old one. Ignore messages for another doc (the SDK does) and do not rely on doc for anything else.

A page without the SDK, with its script inline:

<!doctype html>
<html lang="en">
  <head><meta charset="utf-8" /><title>Clusters</title></head>
  <body>
    <pre id="out">Connecting…</pre>
    <script>
      const doc = Array.from(crypto.getRandomValues(new Uint8Array(16)), (b) => b.toString(16).padStart(2, "0")).join("");
      const send = (message) => parent.postMessage({ sextant: 1, doc, ...message }, "*");
      const pending = new Map();
      let context = null;
      let nextId = 1;
      const call = (method, params) =>
        new Promise((resolve, reject) => {
          const id = nextId++;
          pending.set(id, { resolve, reject });
          send(params === undefined ? { id, method } : { id, method, params });
        });
      addEventListener("message", (event) => {
        const m = event.data;
        if (event.source !== parent || !m || m.sextant !== 1 || m.doc !== doc) return;
        if (m.type === "context") {
          const first = context === null;
          context = m.context;
          if (first) start();
          return;
        }
        const waiting = pending.get(m.id);
        if (!waiting) return;
        pending.delete(m.id);
        if ("error" in m) waiting.reject(m.error);
        else waiting.resolve(m.result);
      });
      send({ type: "ready" });
      async function start() {
        const clusters = await call("clusters.list");
        document.getElementById("out").textContent = JSON.stringify(clusters, null, 2);
      }
    </script>
  </body>
</html>

Example: hello-page

sdk/examples/hello-page adds a page Hello pods to the Workloads section: pods of the namespaces selected in Sextant's namespace filter (or of one namespace picked on the page), kept live by a watch, in Sextant's theme.

The "Hello pods" page

Its manifest asks to read Pods and Namespaces everywhere and adds one page:

"permissions": {
  "clusters": { "type": "all" },
  "rules": [
    {
      "namespaces": { "type": "all" },
      "kinds": { "type": "only", "kinds": [{ "group": "", "kind": "Pod" }, { "group": "", "kind": "Namespace" }] },
      "actions": ["read"]
    }
  ]
},
"contributes": {
  "pages": [{ "id": "pods", "title": "Hello pods", "section": "workloads", "icon": "pod", "order": 100 }]
}

src/pods.ts, step by step:

  1. By default it shows the namespaces of Sextant's filter, context.namespaces (null: every namespace). A namespace picked on the page overrides the filter; it is read back from storage (storage.get("namespace")).
  2. Opens resources.watch({kind: {group: "", kind: "Pod"}, namespaces}) (without namespaces for all of them) and draws the table with trackObjects; reconnecting, error and forbidden updates become a status line.
  3. Lists namespaces with resources.list({kind: {group: "", kind: "Namespace"}}) for the selector, whose first entry is "Sextant's filter"; picking a namespace saves it with storage.set, picking "Sextant's filter" forgets it with storage.remove, and either starts a new watch (after unwatch() of the old one).
  4. A pod's name is a button: ui.openDetails({kind: {group: "", version: "v1", kind: "Pod"}, namespace, name}).
  5. onContextChange restarts the watch when the user switches to another cluster, and when the namespace filter changes while no namespace is picked on the page.

src/pods.test.ts drives it with the test host. Build and load it:

npm run sdk:build -- sdk/examples/hello-page    # → sdk/examples/hello-page/dist, then Load unpacked folder…

Example: crd-view

sdk/examples/crd-view ("Widgets") works with the Widget custom resource of the test cluster (demo.sextant.dev/v1, the object demo-widget in sextant-demo, created by scripts/test-cluster.sh up). It has no page; it adds a column, a details section, an action and a settings section, all served by one index.html that switches on context.view (src/main.ts).

"permissions": {
  "clusters": { "type": "all" },
  "rules": [
    { "namespaces": { "type": "all" }, "kinds": { "type": "only", "kinds": [{ "group": "demo.sextant.dev", "kind": "Widget" }] }, "actions": ["read"] },
    { "namespaces": { "type": "all" }, "kinds": { "type": "only", "kinds": [{ "group": "demo.sextant.dev", "kind": "Widget" }] }, "actions": ["modify"], "confirm": true }
  ]
},
"contributes": {
  "columns": [{ "kind": { "group": "demo.sextant.dev", "kind": "Widget" }, "id": "generation", "title": "Generation", "jsonPath": ".metadata.generation", "after": "Replicas" }],
  "detailsSections": [{ "id": "summary", "title": "Widget summary", "kinds": [{ "group": "demo.sextant.dev", "kind": "Widget" }], "placement": "section" }],
  "actions": [{ "id": "scale", "title": "Scale widget", "icon": "scale", "kinds": [{ "group": "demo.sextant.dev", "kind": "Widget" }] }],
  "settings": { "title": "Widgets" }
}
  • Column "Generation": .metadata.generation, after the CRD's own "Replicas" column. No code.

  • Details section (src/summary.ts): reads the widget with resources.get({ref: context.selection[0]}) and shows its size, replicas and generation; it reads again whenever context.resourceVersion changes, so it follows edits made anywhere.

  • Action "Scale widget" (src/scale.ts): reads the widget, lets the user pick a number of replicas (step and maximum from storage), and sends a merge patch {"spec": {"replicas": n}}. Because the Modify rule has confirm: true, Sextant asks the user first:

    Sextant asks the user to allow the patch

    On Allow the page calls ui.notify("Scaled demo-widget to 3 replicas") and ui.close(); on Deny the denied error is shown in the window, which stays open.

  • Settings section (src/settings.ts): the step and maximum of the action, saved with storage.set("scale", …) and reset with storage.remove("scale").

src/widgets.test.ts tests every view with the test host. Build it with npm run sdk:build -- sdk/examples/crd-view.

Clone this wiki locally