Plugin SDK
The Unikraft Cloud Plugin SDK is a small framework for building plugins in Go. A plugin runs as a sidecar HTTP server next to your app inside an instance, and loads from its own ROM image. It answers the requests that the platform forwards to it over a per-instance, authenticated endpoint.
Every plugin meets the same platform contract.
It parses the init command line, adopts the socket the platform hands it, and decodes the JSON config from STDIN.
It also stands up a router with middleware and drains in-flight requests on shutdown.
The SDK takes care of each step.
You write a configuration struct and a route registration function, and pluginsdk.Main does the rest.
A complete plugin takes about 25 lines.
Installation
Code
Requires Go 1.26.4 or later.
Quickstart
main.go
That covers the whole plugin.
pluginsdk.Main takes these steps in order:
- Parses the command line:
--api_fd,--api-addr,--log-level,--log-type, and one flag per field ofConfig. - Reads the JSON
configfromSTDINand decodes it intoConfig. - Configures logging, then runs the optional
Setuphook. - Adopts the
--api_fdsocket as its listener. - Builds the gin engine with the default middleware and calls
Register. - Serves until
SIGINTorSIGTERMarrives, then drains and shuts down.
Run the plugin locally with --api-addr in place of --api_fd:
Code
Code
Configuration
A plugin declares its configuration once, as a plain Go struct, and that declaration acts as the single source of truth.
It validates the platform config, supplies the defaults, and derives the command-line flags and environment variables.
Where values come from
Four sources feed a plugin's configuration. The SDK merges them in this order, from lowest precedence to highest:
Code
- Struct default: the fallback that the
defaulttag bakes into the binary. - Platform
config: the JSON you attach to the plugin when you create the instance, which is the production path. - Environment: any field that carries an
envtag. - Command-line flag: an explicit flag for local development and debugging.
A field that defaults to /tmp stays /tmp unless the platform config sets "workdir":"/data", unless WORKDIR=/srv sits in the environment, unless --workdir /mnt appears on the command line.
Struct tags
A config field carries two kinds of tag.
A json tag names the field's key in the platform config, and kong tags govern the command-line flag and the environment override.
The two work independently: JSON keys conventionally use snake_case while flags use kebab-case, so json:"source_type" and name:"source-type" routinely sit on the same field.
| Tag | Purpose | Example |
|---|---|---|
json:"…" | Key in the platform config on STDIN | json:"source_url" |
name:"…" | Flag name, in kebab-case | name:"source-url" |
env:"…" | Environment variable to bind the field to | env:"SOURCE_URL" |
help:"…" | Help text | help:"Repository URL." |
default:"…" | Default value | default:"/tmp" |
enum:"a,b,c" | Restrict the value to a set | enum:"git,tar" |
placeholder:"…" | Help placeholder | placeholder:"dir" |
required:"" | Make the flag mandatory | required:"" |
hidden:"" | Hide the flag from --help | hidden:"" |
kong:"-" | Ignore the field entirely | kong:"-" |
The json tag belongs to the SDK rather than to kong.
The SDK reads it to map each config key onto a field, and kong owns every other tag.
A field without a json tag takes its exact Go field name as the key, so Greeting matches "Greeting" and misses "greeting".
That lookup runs case-sensitively, unlike encoding/json, so give every field a json tag.
The map covers a struct's own exported fields, so a field inside an embedded struct gets no config key at all.
Code
The platform config
Attach a config to the plugin in the create instance request, and it reaches the plugin's init on STDIN:
Code
The platform accepts a non-object config as well: a bare string or a number counts as valid JSON.
A value of that shape maps onto no fields, so reach for the raw bytes instead:
Code
Routing
Register receives the base context, your typed configuration, and the gin engine:
Code
Define your routes relative to /.
The platform strips the plugins/<plugin_name>/ prefix before the request reaches the plugin, so a call to .../plugins/example/files/list arrives as GET /files/list.
A plugin never needs to know its own name to route correctly.
The SDK builds the engine with gin.New() in release mode, turns on HandleMethodNotAllowed, and rejects unknown JSON fields, which keeps generated types strict.
Default middleware
Ahead of your routes, the SDK installs a global middleware stack from unikraft.com/x/middleware:
| Middleware | Effect |
|---|---|
CORS() | Cross-origin resource sharing headers |
ExtraHeaders() | Static response headers |
Logger(ctx) | Structured per-request logging |
DefaultCacheControl() | Sensible Cache-Control defaults |
Append your own middleware, or replace the defaults outright:
Code
Generated services
Plugins describe their API in TypeSpec and generate a typed gin service interface from it.
Register that generated service from inside Register:
Code
pluginsdk.OK and pluginsdk.Error return the (payload, status, error) triple that a generated handler method produces, so a handler body often comes down to a single return pluginsdk.OK(&data).
Responses
The platform wraps every response in a standard envelope, and two helpers build it for you:
Code
A handler that calls OK produces the same shape as the rest of the API:
Code
The status field holds success, partial_success, or error.
In Go the envelope type is platform.Response[T] from unikraft.com/cloud/sdk/platform, which the Go SDK uses as well.
Error derives the HTTP status from its status argument.
A non-positive status yields 0, which gin rejects, so always pass a real status code.
Global flags
Every plugin accepts these four flags, whatever its own configuration declares:
| Flag | Environment | Meaning |
|---|---|---|
--api_fd <n> | API_FD | Serve on file descriptor n, which the platform supplies |
--api-addr <addr> | API_ADDR | Serve on a TCP address such as :8080, for local development |
--log-level | LOG_LEVEL | One of trace, debug, info, warn, error, fatal |
--log-type | LOG_TYPE | Either text or json |
The platform spells --api_fd with an underscore, and every other flag uses a hyphen.
Lifecycle
Main runs this sequence:
- Read the
configfromSTDIN, then parse the command line. - Configure logging from
--log-leveland--log-type. - Run the optional
Setuphook. - Bind the listener:
--api_fdin production,--api-addrlocally. - Build the router with the default and user middleware, then call the registration function.
- Serve on the listener.
- Block until a signal arrives or the context cancels.
- Drain: stop accepting connections, finish in-flight requests, then shut down.
Serve covers step 2, then steps 4 through 8.
It takes an already-resolved configuration rather than reading the command line or STDIN, and it runs no Setup hook.
--api_fd wins whenever the command line carries it, and otherwise the SDK falls back to --api-addr.
At least one of the two has to resolve, or startup fails with ErrNoListener.
Serve can also take a listener directly through WithListener, which outranks both flags.
A shutdown gives in-flight requests a 30-second window to drain.
Scale-to-zero
Scale-to-zero puts an idle instance to sleep. A plugin request wakes the instance and keeps it up for the length of that request. Background work that outlives the request has to hold the instance awake on its own.
The scaletozero sub-package keeps a counter for exactly that:
Code
A counter rather than a flag means many independent workers increment and decrement without coordinating.
Scale-to-zero stays suspended while the count sits above zero and resumes at zero.
Set(n), IncrementBy(n), and DecrementBy(n) cover the remaining cases.
Logging
The SDK configures a structured logger from --log-level and --log-type, which default to info and text, and makes it available to your handlers through the request context.
The logger comes from unikraft.com/x/log.
Pass --log-type json in production.
Project layout
A plugin keeps its API description and its image recipes next to the source:
Code
A small plugin needs no more than main.go, go.mod, a Dockerfile, and a Kraftfile.
Building the plugin image
The deliverable is a ROM image with a single init executable at its root.
The platform appends --api_fd <n> and pipes the config JSON to STDIN at launch, so /init has to be the SDK-based binary that understands both.
A plugin runs as a process inside the host instance rather than as its own unikernel, so init has to stand on its own.
A static Go build produces that.
Dockerfile
A build stage produces init, and a scratch image carries it:
Dockerfile
Kraftfile
A plugin ships as a ROM, which carries no kernel of its own.
The platform mounts it at /uk/plugins/<plugin_name> inside the host instance and runs its init.
Declare the image under roms:
Kraftfile
A ROM has no runtime to take an architecture from, so it names one under targets, or you pass --arch on the command line.
A build that does neither fails, and each plugin needs the architecture of the instance that hosts it.
Set no runtime.
A runtime produces a bootable unikernel that the plugin loader can't mount, and the host instance then fails to boot.
The platform runs the image's init and appends --api_fd on its own, so set no cmd and never hard-code --api_fd.
Package and push the ROM image:
Deploying
Attach the plugin when you create the instance, naming the ROM image and an optional config:
Then call the plugin through the instance's authenticated endpoint:
Code
The plugins page covers the platform side in full: authorization, adding a plugin to an existing instance, and the limits.
API reference
Entrypoints
Main suits the common case of one plugin and one server.
It parses the command line, reads STDIN, serves, blocks, and exits with a non-zero status on error.
Code
Serve takes an already-resolved configuration, reads neither the command line nor STDIN, and returns an error instead of exiting.
Cancelling ctx triggers the graceful shutdown.
Reach for it in tests, when you embed a plugin in a larger program, or when you bring your own command grammar.
Code
The plugin declaration
Code
| Field | Description |
|---|---|
Name | The plugin name, for logs and diagnostics only, since routing ignores it |
Version | An optional version string that the startup logs carry |
Register | Attaches the routes, and a non-nil error aborts startup (required) |
Setup | An optional hook that runs once after the SDK resolves the configuration and before the server accepts requests |
Middleware | Middleware to append to the default stack |
DisableDefaultMiddleware | Drops the built-in stack and leaves the plugin in control |
Options
An Option customizes Serve.
A later option overwrites an earlier one that sets the same field.
WithMiddleware appends instead of overwriting, and the listener keeps a fixed priority whatever the order: WithListener, then WithAPIFD, then WithAddr.
| Option | Effect |
|---|---|
WithOptions(o Options) | Seeds the runtime from the parsed global flags |
WithAPIFD(fd int) | Forces the listener to adopt a file descriptor |
WithAddr(addr string) | Forces a TCP listen address |
WithListener(ln net.Listener) | Serves on a listener you supply |
WithMiddleware(mw ...gin.HandlerFunc) | Appends global middleware |
WithoutDefaultMiddleware() | Drops the default middleware stack |
WithRawConfig(raw []byte) | Supplies the raw STDIN config for RawConfig |
Options itself holds the four global flags as an embeddable kong struct: APIFd, APIAddr, LogLevel, and LogType.
Embed it in your own command grammar to keep the standard flags.
Context and config helpers
Every request context descends from the base context, so these helpers work inside a handler through g.Request.Context().
| Symbol | Description |
|---|---|
FromContext[C any](ctx) *C | Retrieves the typed configuration from a request context |
RawConfig(ctx) []byte | The raw platform config bytes from STDIN |
APIFD(ctx) (int, bool) | The adopted file descriptor, and whether one exists |
ReadConfig(stdin io.Reader) []byte | Reads the STDIN config verbatim |
NewConfigResolver[C any](raw []byte) (kong.Resolver, bool) | Builds the kong resolver for a custom grammar |
Response helpers
| Symbol | Description |
|---|---|
OK[T any](data *T) (*platform.Response[T], int, error) | A success envelope that wraps data |
Error[T any](status int, msg string) (platform.Response[T], int, error) | An error envelope |
Other exported symbols
| Symbol | Description |
|---|---|
ShutdownTimeout | The graceful-drain bound, at 30 seconds |
ReadHeaderTimeout | The bound on waiting for request headers, at 30 seconds |
ErrNoListener | Neither --api_fd nor --api-addr resolved |
ErrNoRegister | A plugin reached Serve without a registration function |
ErrInvalidAPIFD | The --api_fd descriptor yielded no usable file |
Bring your own command grammar
Main wires one command line for you.
A plugin that needs subcommands or extra pre-serve work defines its own kong grammar and calls Serve from the subcommand:
Code
Two exported building blocks tie it together in main.
ReadConfig reads the platform config off STDIN, and NewConfigResolver turns those bytes into the kong resolver that gives you the standard precedence:
Code
pluginsdk.Options contributes the standard global flags and WithOptions feeds them to Serve, so the listener selection and the logging match Main with your own command tree on top.
Extra middleware alone calls for no custom grammar, since Middleware and DisableDefaultMiddleware on Plugin already cover that.
Source
The SDK source lives at github.com/unikraft-cloud/plugin-sdk, and the API reference at pkg.go.dev/unikraft.com/cloud/pluginsdk.
Learn more
- Plugins: the platform feature that this SDK targets.
- ROMs: the image format that a plugin ships as.
- Scale-to-zero: how an idle instance wakes to serve a plugin request.
- Go SDK: the client library for the platform API itself.
- JavaScript SDK: the same platform API from JavaScript and TypeScript.
- Instances: create and manage the instances that host plugins.