Skip to content

Writing a plugin

functl plugin create generates a complete, buildable plugin project. It works offline and does not require a Fundament account.

Terminal window
functl plugin create my-plugin

It prompts for the details it needs, then writes the project to ./my-plugin, runs git init and go mod tidy. Every prompt has a matching flag, so it is equally usable unattended:

Terminal window
functl plugin create my-plugin \
--template=helm \
--console=vanilla \
--crd myresources.my-api.io \
--kind MyResource \
--module github.com/my-org/my-plugin \
--yes
Flag Values What it changes
--template minimal, helm minimal is a bare Start/Shutdown. helm adds install/upgrade/uninstall of an upstream chart, plus a reconcile loop that verifies its CRDs.
--console none, vanilla, vite vanilla is plain HTML + ES modules. vite is a TypeScript app with the NLDD design system, built with bun.
--crd / --kind <plural>.<group> / Kind The custom resource the generated console pages and CRD checks refer to.
--module Go module path The generated go.mod module path.
--license SPDX id metadata.license, and the LICENSE file. MIT, Apache-2.0, GPL-3.0-only and EUPL-1.2 are written in full; any other id gets a placeholder to fill in.
--dir path Where to write the project (default ./<name>).
--no-git / --no-tidy Skip git init / go mod tidy.
--yes Accept every default without prompting.

The name must be a lowercase DNS label of at most 56 characters: the controller derives the plugin’s namespace and other resource names from it by prefixing plugin-, and those have to fit in Kubernetes’ 63-character limit.

my-plugin/
├── definition.yaml the plugin manifest
├── plugin.go the plugin itself
├── main.go hands the plugin to the SDK runtime
├── console.go embeds the console UI (--console != none)
├── console/ console pages or build output (--console != none)
├── console-ui/ console UI source (--console=vite)
├── Dockerfile
├── Justfile
├── go.mod
├── mise.toml pinned tool versions (Go, just, bun)
├── LICENSE the text of --license
└── README.md

Tool versions are pinned in mise.toml, the same way this repository pins its own: mise install in the new project gets the Go toolchain its go.mod asks for, plus just and, with --console=vite, bun.

The manifest describes the plugin to the platform. The scaffolder writes a valid one with TODO markers; these are the parts worth understanding.

apiVersion: fundament.io/v1
kind: PluginDefinition
metadata:
name: my-plugin
displayName: My Plugin
version: v1.0.0
description: Does something useful
author: My Team
license: Apache-2.0
icon: puzzle-piece # a built-in icon name, or a file: see Icons
tags:
- example
spec:
permissions:
capabilities:
- internet_access
rbac:
- apiGroups: ["my-api.io"]
resources: ["myresources"]
verbs: ["get", "list", "watch"]
menu:
project:
- crd: myresources.my-api.io
list: true
detail: true
icon: pencil-on-square
customComponents:
MyResource:
list: myresources-list.html
detail: myresources-detail.html
allowedResources:
- group: my-api.io
version: v1
resource: myresources
verbs: [get, list]
uiHints:
myresources.my-api.io:
statusMapping:
jsonPath: ".status.phase"
values:
"Ready":
badge: success
label: Ready
"Failed":
badge: danger
label: Failed

spec.permissions.rbac is materialised verbatim as a ClusterRole bound to the plugin’s ServiceAccount, so grant only what the plugin actually uses. The helm template starts from the least privilege that can work: the three rules Helm itself cannot install a release without (namespaces, its release Secrets, and Pods for --wait). Everything a chart might additionally create – Deployments, Services, ServiceAccounts, ConfigMaps, CRDs, RBAC – is there as commented-out rules: uncomment what your chart needs.

Without labels, the console names a menu entry after its CRD: the sidebar uses the resource name, the list page and its “New …” button the kind. Set label (plural, for the sidebar and page title) and singularLabel (for the button) to choose the words yourself, for example when the CRD’s name means something else to your users:

- crd: clusters.postgresql.cnpg.io
label: Databases
singularLabel: Database

customComponents maps a CRD kind to the HTML files your plugin ships under console/. It is optional: any menu entry without a custom component renders the console’s generated read-only list and detail views from the CRD schema, so add customComponents only for kinds that need write actions or a bespoke layout. allowedResources lists the resources the plugin’s pages read. Page calls are authorized by the user’s and the plugin’s RBAC (permissions.rbac): Custom UI.

menu, customComponents, allowedResources and the files under console/ are one contract with nothing to enforce it at build time. The generated definition_test.go checks that every file named in customComponents is actually embedded; the rest is on you, so change them together.

definition.yaml has no spec.image: functl plugin publish --image=<repo>@sha256:<digest> injects the pushed digest, so a published definition pins immutable code and its hash binds that code. A manifest that names a mutable tag is rejected.

metadata.icon is a built-in icon name or a path to a logo file. metadata.iconDark is an optional dark-mode logo and needs metadata.icon to be a file. Each menu entry’s icon is a built-in icon name or a path to a menu icon file.

metadata:
icon: assets/logo.svg
iconDark: assets/logo-dark.svg
spec:
menu:
project:
- crd: myresources.my-api.io
icon: assets/menu.svg

functl plugin publish reads the files and stores them with the definition. Paths are relative to the directory containing definition.yaml and cannot escape it. A value that contains a / or ends in .svg or .png is read as a path; any other value must be a lowercase built-in icon name such as shield-check-mark, so logo.jpg is rejected.

Role Formats Limit
Logo, dark logo SVG or PNG, PNGs at most 1024×1024 pixels 16 KiB
Menu icon SVG only 8 KiB
  • A version has at most 64 icon files. A file used in both roles must meet the stricter limit.
  • Menu icons are tinted to follow the theme, so draw them in a single colour.
  • The root element must carry xmlns="http://www.w3.org/2000/svg" and every element must be in the SVG namespace. Editor elements such as Inkscape’s sodipodi or rdf metadata are rejected: export as plain SVG.
  • Scripts, event handlers, foreignObject, external references and a DOCTYPE are rejected, as are backslashes in attribute values and <style> blocks.
  • A plugin without a logo shows a generic icon. Icons are part of the definition, so changing one needs a new version. A draft can be republished in place with --replace, but a replaced version can never be submitted for review.
  • Installed plugins and the browse screens show the logo of the latest published version, so a plugin that is not published shows the generic icon on console screens.

spec.configSchema declares the install-time config keys the console should prompt for and the controller should validate:

spec:
configSchema:
- name: MON_COUNT
displayName: Monitor count
type: int
default: "3"
description: Ceph monitors to run. A single-node cluster needs 1 (with ALLOW_MULTIPLE_PER_NODE), or mons never reach quorum.
- name: ALLOW_MULTIPLE_PER_NODE
type: bool
default: "false"
description: Allow multiple mons/mgrs on one node. Required on a single-node cluster.
- name: CEPH_IMAGE
type: string
default: "quay.io/ceph/ceph:v19.2.3"
advanced: true
description: Ceph container image. v19+ is required on arm64 (v18 segfaults on Apple Silicon).

Each entry’s type is one of string, int, bool or enum (enum requires a values list). name must be uppercase and becomes the FUNP_<name> env var at install time; it is never displayed as-is. displayName is an optional human-readable label the console shows instead — when omitted, the console derives one from name (e.g. MON_COUNT becomes “Mon count”). required and default are mutually exclusive: a required key has no default and must be supplied. A bool key must always declare a default and can never be required — a checkbox has no “untouched” state, so an unprompted false would otherwise be submitted on the admin’s behalf; that default must be spelled exactly "true" or "false". A required entry can never also be advanced (a required choice cannot default to hidden), and a required value must be non-empty after trimming whitespace — so a required enum must list at least one non-blank value. int values never carry an explicit + (the console form and the controller share one int grammar: optional -, digits, within int64). string, int and enum keys should declare a default when a sensible one exists, but may omit both default and required: an omitted key falls back to whatever default the plugin binary’s own env parsing applies. advanced: true collapses the key under an “advanced” toggle in the install form instead of showing it by default.

The schema is closed: spec.config in the PluginInstallation may only set keys declared here. The one exception is the SDK’s own keys — LOG_LEVEL and RECONCILE_INTERVAL, which every plugin binary reads through the SDK’s base config — those are always accepted (and validated with the SDK’s own parsers) without being declared; declare one only to constrain it further. functl plugin publish validates the schema itself; the plugin-controller validates spec.config against it at install/reconcile time and fails with ConfigValid=False before touching any resources. A definition with no configSchema accepts any config, unchanged from before this feature. Publishing a manifest that declares configSchema requires a plugin-controller (and catalog) built with schema support; an older controller rejects the manifest at parse time (strict decoding), so upgrade controllers before publishing schema-bearing versions.

Secret values do not belong in spec.config — it is a plain CR field readable by anyone who can get plugininstallations; a secretRef mechanism is a separate design.

Plugin is the only required interface. Installer, Reconciler and ConsoleProvider are optional and detected at runtime.

package main
import (
"context"
"github.com/fundament-oss/fundament/plugin-sdk/pluginruntime"
)
type MyPluginPlugin struct{}
func NewMyPluginPlugin() *MyPluginPlugin { return &MyPluginPlugin{} }
func (p *MyPluginPlugin) Start(ctx context.Context, host pluginruntime.Host) error {
host.ReportStatus(pluginruntime.PluginStatus{
Phase: pluginruntime.PhaseInstalling,
Message: "setting up",
})
// Do setup work...
host.ReportReady()
host.ReportStatus(pluginruntime.PluginStatus{
Phase: pluginruntime.PhaseRunning,
Message: "operational",
})
<-ctx.Done()
return nil
}
func (p *MyPluginPlugin) Shutdown(_ context.Context) error {
return nil
}
func main() {
pluginruntime.Run(NewMyPluginPlugin())
}

Start blocks until the context is cancelled, and must be idempotent: the container is restarted on upgrades, evictions and crashes, so it runs again from scratch every time. Check what already exists before creating it. Shutdown runs on every restart too, so it must not uninstall anything.

Classify failures so the platform knows whether to retry: pluginerrors.NewTransient(err) reports the plugin as degraded and retries; pluginerrors.NewPermanent(err) reports it as failed and does not.

Plugins serve their own list and detail HTML from an embedded filesystem mounted at /console/ by the runtime. Implement ConsoleProvider:

package main
import (
"embed"
"net/http"
"github.com/fundament-oss/fundament/plugin-sdk/pluginruntime/console"
)
//go:embed console/*
var consoleFiles embed.FS
func (p *MyPluginPlugin) ConsoleAssets() http.FileSystem {
return console.NewFileSystem(consoleFiles, "console")
}

Put one HTML file per customComponents entry under console/ (e.g. console/myresources-list.html, console/myresources-detail.html). See Custom UI for what those pages need to do and plugins/cert-manager/console/ for a working set.

With --console=vite, console/ is build output: pass console.RequireHTML() as well, so a binary built without the UI build fails at startup.

The scaffolder writes a self-contained Dockerfile that builds with the project directory as its context. Go cross-compiles for the target platform, so on an Apple Silicon Mac the Go build of the linux/amd64 image just docker asks for does not run under emulation. Only the final stage’s RUN steps do, such as the helm template’s apk add helm:

FROM --platform=$BUILDPLATFORM golang:1.27.3-alpine AS build
ARG TARGETOS TARGETARCH
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH go build -o /my-plugin-plugin .
FROM alpine:3.21
# Add any CLI tools your plugin needs; the helm template installs helm here.
COPY --from=build /my-plugin-plugin /usr/local/bin/my-plugin-plugin
COPY definition.yaml /app/definition.yaml
WORKDIR /app
ENTRYPOINT ["my-plugin-plugin"]

Publish the image and definition, install the plugin and check it in the console: Testing plugins locally.