Console integration
How a Fundament plugin renders inside the Console: the iframe boundary, the postMessage SDK, the Kubernetes-call broker, and the role of the kube-api-proxy in mock and real mode.
This document is the architecture reference for anyone working on the Console-plugin boundary. For a plugin author’s how-to, start with Writing a plugin and Custom UI. For the container lifecycle (controller, RBAC, install/uninstall), see the Plugins overview.
Overview
Section titled “Overview”Four moving parts collaborate to render a plugin’s UI in the Console:
- Console frontend (
console-frontend/) — the host Angular app. Discovers installed plugins, renders the sidebar, mounts plugin iframes, and brokers Kubernetes API calls. - Plugin SDK (
console-frontend/src/plugin-sdk/) — a tiny TypeScript library compiled toplugin-sdk.js/plugin-sdk.cssand served by the Console at/plugin-ui/. Plugins load it inside their iframe to talk to the host. - kube-api-proxy (
kube-api-proxy/) — the gateway every cluster request goes through. Has a mock mode (in-memory fixtures, used for local dev) and a real mode (Gardener-managed shoots, used in staging/prod). - Plugin runtime (
plugin-sdk/pluginruntime/) — the Go framework each plugin embeds. Serves itsPluginMetadataServiceand its embeddedconsole/HTML/JS/CSS assets.
The plugin’s UI never runs on the Console’s origin and never gets the Console
user’s cookies. Everything crosses the iframe boundary as postMessage calls,
and every Kubernetes read is brokered by the Console host on behalf of the
logged-in user.
Discovery and registration
Section titled “Discovery and registration”When the Console boots for a cluster, PluginRegistryService.loadPlugins():
- Fetches the cluster’s
PluginInstallationlist:GET /clusters/{clusterId}/apis/plugins.fundament.io/v1/plugininstallations. - Keeps only items whose
status.phase === 'Running' && status.ready. - For each running plugin, calls the plugin’s
PluginMetadataService.GetDefinitionConnect RPC through the Kubernetes apiserver service proxy:GET /clusters/{clusterId}/api/v1/namespaces/plugin-{name}/services/http:plugin-{name}:8080/proxy/pluginmetadata.v1.PluginMetadataService/GetDefinition. - Stores the parsed definitions in a signal that the sidebar and routes subscribe to.
The definition advertises:
menu— which CRDs appear at organization and project level.customComponents— aKind→{ list?, detail? }map of relative paths (e.g.Certificate→certificates-list.html). Optional: any CRD without an entry falls back to the generated read-only UI described below.allowedResources— the Kubernetes resources the plugin’s iframe may read, with explicit verbs (get,list). This is the authoritative list the host enforces on every K8s broker call.crds— the CRDs the plugin manages.
Routing and rendering
Section titled “Routing and rendering”The plugin routes live under /plugin-resources/ (also mirrored under
/projects/:id/plugin-resources/):
| Route | Component |
|---|---|
/plugin-resources/:pluginName/:resourceKind | ResourceListComponent |
/plugin-resources/:pluginName/:resourceKind/:resourceId | ResourceDetailComponent |
Each component looks up the matching customComponents.<Kind>.list or
.detail entry in the plugin definition. If present, it builds the iframe URL
and mounts the iframe component pointed at it. If absent, it renders the
generated fallback view instead.
Generated fallback UI
Section titled “Generated fallback UI”When a plugin provides no customComponents entry for a CRD kind, the console
renders a generated, read-only view from the CRD’s OpenAPI v3 schema (loaded
from apiextensions.k8s.io/v1/customresourcedefinitions):
- List — a table whose columns come from the CRD’s
additionalPrinterColumns(falling back to Name + Age), with a row per object and a link to the detail view. - Detail — object metadata, the
specfields rendered from the schema, and astatussection (including a conditions table when present).
The generated UI is read-only: it never creates, edits, or deletes resources. Reach for a custom UI (above) when you need write actions or a bespoke layout.
The iframe boundary
Section titled “The iframe boundary”URL construction
Section titled “URL construction”The console turns the relative path from customComponents into the
iframe src:
{kubeApiProxyUrl}/clusters/{clusterId} /api/v1/namespaces/plugin-{name} /services/http:plugin-{name}:8080 /proxy/console/{file}?host={consoleOrigin}Two things to note:
- The asset is fetched through the kube-api-proxy and then through the
apiserver’s service proxy — never directly from the plugin’s pod. In real
mode that means the apiserver tunnels to the plugin’s HTTP server (port
8080,
/console/<file>); in mock mode the kube-api-proxy serves the file from disk and never talks to a pod. host={consoleOrigin}is appended so the iframe can load the SDK (plugin-sdk.js/.css) from the Console origin. The iframe itself lives on the kube-api-proxy origin and has no other way to learn where to fetch the SDK from.
Absolute paths and paths starting with /plugin-ui/ are passed through
unchanged.
Sandbox
Section titled “Sandbox”The iframe is created with sandbox="allow-scripts" and nothing else.
allow-same-origin is deliberately omitted, which has two consequences
plugin authors must keep in mind:
- The iframe runs with an opaque origin. It cannot send Console cookies or
read the Console’s storage. It also cannot do its own credentialed
fetchagainst the kube-api-proxy. - All cluster data must therefore flow through the host-mediated broker
(
plugin:k8s:list/plugin:k8s:get). This is the security boundary: the host validates every request againstallowedResourcesbefore forwarding.
The postMessage protocol
Section titled “The postMessage protocol”The reference SDK takes care of most messages automatically.
Plugin → host
Section titled “Plugin → host”| Type | When | Payload | Sent by SDK |
|---|---|---|---|
plugin:ready | Immediately after the SDK loads. | (none) | Yes — auto. |
plugin:resize | Content height changes (debounced 50 ms; tracked via ResizeObserver). | { height: number } | Yes — auto. |
plugin:navigate | Plugin wants Console to navigate to another resource. | { name: string, namespace?: string } | No — call from your code. |
plugin:k8s:list | fundament.k8s.list(args) is called. | { requestId, group, version, resource, namespace? } | Yes — via SDK. |
plugin:k8s:get | fundament.k8s.get(args) is called. | { requestId, group, version, resource, name, namespace? } | Yes — via SDK. |
Host → plugin
Section titled “Host → plugin”| Type | When | Payload |
|---|---|---|
fundament:init | After plugin:ready arrives. First message; carries everything the plugin needs to render. | { theme, pluginName, crdKind, view, resource? } |
fundament:theme-changed | User toggles the Console theme. Watched via a MutationObserver on <html> class. | { theme: 'light' | 'dark' } |
fundament:k8s:result | Reply to a plugin:k8s:list or plugin:k8s:get. Matched by requestId. | Success: { requestId, ok: true, items?, item? }. Error: { requestId, ok: false, error, status? }. |
Init payload fields
Section titled “Init payload fields”| Field | Description |
|---|---|
theme | 'light' or 'dark'; the SDK applies it as a class on <body> automatically. |
pluginName | The installed plugin’s name. |
crdKind | The CRD kind being rendered (e.g. Certificate). |
view | 'list' or 'detail'. |
resource | Only on detail views: { name, namespace? }. |
Origin pinning
Section titled “Origin pinning”The SDK posts plugin:ready with target origin * (the host origin is not
yet known). On the first incoming message — which must be
fundament:init — the SDK captures event.origin and refuses any further
message whose origin doesn’t match. After that, the SDK targets the
captured origin on outbound messages.
Request lifecycle
Section titled “Request lifecycle”fundament.k8s.list / fundament.k8s.get generate a requestId, post the
plugin:k8s:* message, and return a promise. The promise resolves when the
matching fundament:k8s:result arrives, rejects with SdkError if ok: false, and rejects with code: 'timeout' after 10 seconds.
The host validates each request against the plugin’s own allowedResources
before forwarding:
- Allowed → host
fetches the kube-api-proxy with the user’s session cookie and forwards the result. - Not allowed → host replies
{ ok: false, error: 'forbidden' }and logs a warning; the SDK rejects withSdkError('forbidden', ...).
The SDK surface
Section titled “The SDK surface”The SDK sets a single global, window.fundament:
interface FundamentSdk { init: Promise<InitContext>; k8s: { list<T>(args: { group; version; resource; namespace? }): Promise<{ items: T[] }>; get<T>(args: { group; version; resource; name; namespace? }): Promise<T>; }; onThemeChange(cb: (theme: 'light' | 'dark') => void): () => void;}The SDK also does these things on its own so plugins don’t have to:
- Applies
light/darkas a class on<body>onfundament:initand on everyfundament:theme-changed. - Reports
plugin:resizeafter stylesheets finish loading, and on everyResizeObservercallback (debounced 50 ms). - Pins the parent origin on the first message and validates all subsequent messages.
The console serves the compiled bundle at the stable path
/plugin-ui/plugin-sdk.js (and .css).
How a plugin loads the SDK
Section titled “How a plugin loads the SDK”Because the iframe is sandboxed and lives on the kube-api-proxy origin, it
can’t statically link to /plugin-ui/plugin-sdk.js — that path is on the
Console origin. The Console solves this by appending ?host={consoleOrigin}
to the iframe URL; plugin pages read it and inject <script> / <link> tags
themselves.
The cert-manager plugin’s _shared.js is the reference implementation:
export function hostOrigin() { return new URLSearchParams(location.search).get('host') ?? '';}
export function loadSdk() { const host = hostOrigin(); const link = document.createElement('link'); link.rel = 'stylesheet'; link.href = `${host}/plugin-ui/plugin-sdk.css`; document.head.appendChild(link);
return new Promise((resolve, reject) => { const script = document.createElement('script'); script.src = `${host}/plugin-ui/plugin-sdk.js`; script.onload = () => resolve(window.fundament); script.onerror = () => reject(new Error('failed to load plugin-sdk.js')); document.head.appendChild(script); });}Every cert-manager template starts with await loadSdk(); await fundament.init;
and then fetches its data through fundament.k8s.list / .get.
Kubernetes call path
Section titled “Kubernetes call path”Putting the pieces together for a fundament.k8s.list({ group: 'cert-manager.io', version: 'v1', resource: 'certificates' }) call from inside the iframe:
- SDK posts
plugin:k8s:list { requestId, group, version, resource }towindow.parent. - The console validates the request against the plugin’s
allowedResources(group + version + resource + verblist). - If allowed, the console builds
{kubeApiProxyUrl}/clusters/{clusterId}/apis/{group}/{version}/{resource}(or/api/{version}/...for core resources, plus/namespaces/{ns}when namespaced) and fetches it with the user’s session cookie. - The kube-api-proxy authenticates the request (JWT cookie), checks
can_viewon the cluster via OpenFGA, exchanges that for a 15-minute ServiceAccount token (real mode) or skips straight to the in-memory store (mock mode), and returns the response. - The host posts
fundament:k8s:result { requestId, ok: true, items }back to the iframe. The SDK matchesrequestId, resolves the promise.
The plugin’s iframe never sees the user’s JWT and never talks directly to the kube-api-proxy. Everything runs as the user, with the user’s RBAC — the plugin’s own ServiceAccount is irrelevant to UI reads.
kube-api-proxy: mock vs real
Section titled “kube-api-proxy: mock vs real”The proxy supports two modes selected at startup by the
KUBE_API_PROXY_MODE env var (default mock). Real mode additionally
requires GARDENER_KUBECONFIG.
Shared behavior
Section titled “Shared behavior”Both modes expose the same external surface:
/clusters/{clusterID}/{api|apis|openapi/...}— Kubernetes API proxy, the only path forwarded to the cluster handler. Paths outside that allowlist return 404./livez,/readyz— health probes.- Plugin console asset paths (
/clusters/{id}/api/v1/namespaces/plugin-{name}/services/http:plugin-{name}:8080/proxy/console/...) are treated as public static assets: the auth/authz check is skipped because the sandboxed iframe runs with an opaque origin and cannot send the JWT cookie anyway. The assets themselves carry no secrets — they are HTML/JS templates.
All other paths run the full pipeline: JWT validation → OpenFGA can_view on
the cluster → (real mode) per-user SA-token exchange → proxy to the cluster
handler.
Mock mode
Section titled “Mock mode”In mock mode the proxy answers Kubernetes calls from hardcoded fixtures instead of talking to a cluster:
- Resources: hardcoded JSON for cert-manager, CloudNativePG, and the
demo plugin.
GETrequests for those groups/versions/resources return the fixture; everything else returns an empty list. PluginInstallationCRUD: supportsGET,POST,DELETEon/apis/plugins.fundament.io/v1/plugininstallations. State is held in-memory, partitioned per cluster ID.- Persistence: none. Restart loses all installations created through the UI and any state written through the proxy.
- Plugin metadata RPC:
GetDefinitioncalls are answered with hardcoded definition JSON. The real plugin binary is not running. - Console assets: served from the local filesystem at
${MOCK_PLUGIN_TEMPLATES_DIR}/{pluginName}/console/{asset}(default./plugins). Responses includeCache-Control: no-storeso iframe reloads always pick up edits — refresh the browser and your edits toplugins/cert-manager/console/certificates-list.htmlare live. CORS is overridden toAccess-Control-Allow-Origin: *(necessary because the sandboxed iframe’sOrigin: nullis not on the proxy’s normal allowlist). - No Gardener, no OpenFGA, no SA tokens. JWT validation still runs on non-asset paths.
Real mode
Section titled “Real mode”Real mode wires up the full production stack:
- Cluster discovery: shoots are looked up in the Gardener hub by label
fundament.io/cluster-id={clusterID}. The admin kubeconfig is fetched on-demand and cached with singleflight deduplication; entries refresh at 70 % of TTL. - Per-user authentication: each request, the proxy fetches a 15-minute
ServiceAccount token for
fundament-{userID}in thefundament-systemnamespace via the Kubernetes TokenRequest API. Tokens are cached per(userID, clusterID)and proactively refreshed at 80 % of TTL; a 401 from the shoot triggers a forced refresh. - Authorization: every cluster API call requires
can_viewon the cluster in OpenFGA. The plugin’s ownallowedResourcesis a second layer enforced client-side in the Console — the real authorization gate is the shoot’s RBAC on the user’s SA, which is what ultimately answers403. - Console assets: instead of reading from disk, the proxy forwards the
/proxy/console/...request to the plugin pod’s HTTP server (port 8080). The plugin’sConsoleProvider.ConsoleAssets()serves the embeddedconsole/filesystem from inside the binary.
Implications
Section titled “Implications”For frontend iteration on a plugin’s UI (HTML/CSS/JS edits), prefer mock
mode. The on-disk asset serving with Cache-Control: no-store plus the
fixture data gives you a tight reload loop without a running cluster.
Anything that writes state will, however, vanish on restart.
For plugin runtime work (install logic, RBAC, Helm steps, status reporting), use real mode. It is the only mode where the plugin’s own container is actually running, where RBAC is genuinely enforced, and where the metadata RPC is answered by the plugin instead of a hardcoded fixture.
For end-to-end tests that depend on persistence or cross-pod interaction, only real mode is meaningful.
Local dev shortcuts
Section titled “Local dev shortcuts”just dev # mock mode (default)just dev -p local-gardener # real mode against a local GardenerThe hot-reload dev image rebuilds and restarts the binary on every Go
source change; the debug variant additionally runs Delve on :2345.
Plugin author’s quick guide
Section titled “Plugin author’s quick guide”Once you understand the boundary above, writing a plugin’s Console integration is mostly four steps. See Writing a plugin and Custom UI for the full guides; this is the short version that ties Console integration together.
-
Declare it in
definition.yaml. Map your custom HTML files to CRD kinds inspec.customComponents, and list the Kubernetes resources your UI may read inspec.allowedResources(group + version + resource + verbs). Anything not listed will returnforbiddenfrom the broker. -
Embed the assets. Put your HTML/JS/CSS under
console/in the plugin module, embed it with//go:embed console, and return it fromConsoleAssets()://go:embed consolevar consoleFS embed.FSfunc (p *Plugin) ConsoleAssets() http.FileSystem {return console.MustNewFileSystem(consoleFS)} -
Load the SDK from the host origin. In each HTML page, read the
?host=query parameter and inject<script src="${host}/plugin-ui/plugin-sdk.js">plus the matching stylesheet. Copy theloadSdk()helper from the cert-manager plugin’s_shared.jsas a starting point. -
Render.
await fundament.initto get context, callfundament.k8s.list/.getfor data, and postplugin:navigateto follow a row into a detail view. See Example: cert-manager for a worked example.
Verifying the integration end-to-end
Section titled “Verifying the integration end-to-end”When changing anything on the Console-plugin boundary, walk through the full path in both modes:
-
Mock mode —
just devfrom the repo root. Open the Console, switch to a cluster that has the cert-manager plugin mock installed, and navigate to the Certificates list. In browser devtools:- Confirm the iframe loads (the asset comes from the kube-api-proxy with
Cache-Control: no-store). - In the Console window, observe
plugin:readyarriving from the iframe and the Console responding withfundament:init. - Click a row, confirm
plugin:navigatefollowed by the detail view’splugin:k8s:getand afundament:k8s:resultwithok: true. - Edit
plugins/cert-manager/console/certificates-list.html, refresh the browser, confirm the edit is live without rebuilding.
- Confirm the iframe loads (the asset comes from the kube-api-proxy with
-
Real mode —
just dev -p local-gardener. Install a realPluginInstallationagainst a shoot, wait forstatus.phase = Running, and repeat the trace above. Same protocol messages, but data now comes from the shoot’s apiserver via the per-user SA token. -
Authorization spot-check — temporarily remove an entry from a plugin’s
allowedResources, redeploy, and confirm the SDK call rejects withSdkError('forbidden', ...)and the host logs[PluginIframe] rejected list request not in allowlist.