Writing a plugin
Scaffold the project
Section titled “Scaffold the project”functl plugin create generates a complete, buildable plugin project. It works
offline and does not require a Fundament account.
functl plugin create my-pluginIt 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:
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.mdTool 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.
Fill in definition.yaml
Section titled “Fill in definition.yaml”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/v1kind: PluginDefinitionmetadata: 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: - examplespec: 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: Failedspec.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: DatabasecustomComponents 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.svgspec: menu: project: - crd: myresources.my-api.io icon: assets/menu.svgfunctl 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’ssodipodiorrdfmetadata are rejected: export as plain SVG. - Scripts, event handlers,
foreignObject, external references and aDOCTYPEare 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.
Declaring config
Section titled “Declaring config”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.
Implement the plugin
Section titled “Implement the plugin”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.
Ship the console UI
Section titled “Ship the console UI”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.
Build a container image
Section titled “Build a container image”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 buildARG TARGETOS TARGETARCHWORKDIR /srcCOPY go.mod go.sum ./RUN go mod downloadCOPY . .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-pluginCOPY definition.yaml /app/definition.yamlWORKDIR /appENTRYPOINT ["my-plugin-plugin"]Publish and install
Section titled “Publish and install”Publish the image and definition, install the plugin and check it in the console: Testing plugins locally.