Zudoku
Extending the platform

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

TerminalCode
go get unikraft.com/cloud/pluginsdk

Requires Go 1.26.4 or later.

Quickstart

GoCode
package main import ( "context" "github.com/gin-gonic/gin" "unikraft.com/cloud/pluginsdk" ) // Config takes its values from the platform `config` JSON on STDIN, through // each field's `json` tag. A --greeting flag overrides that value; an `env` // tag would bind an environment variable as well. type Config struct { Greeting string `json:"greeting" default:"Hello"` } func main() { pluginsdk.Main(&pluginsdk.Plugin[Config]{ Name: "example", Register: func(ctx context.Context, cfg *Config, engine *gin.Engine) error { engine.GET("/hello", func(g *gin.Context) { data := gin.H{"message": cfg.Greeting + ", world!"} env, code, _ := pluginsdk.OK(&data) g.JSON(code, env) }) return nil }, }) }

That covers the whole plugin. pluginsdk.Main takes these steps in order:

  1. Parses the command line: --api_fd, --api-addr, --log-level, --log-type, and one flag per field of Config.
  2. Reads the JSON config from STDIN and decodes it into Config.
  3. Configures logging, then runs the optional Setup hook.
  4. Adopts the --api_fd socket as its listener.
  5. Builds the gin engine with the default middleware and calls Register.
  6. Serves until SIGINT or SIGTERM arrives, then drains and shuts down.

Run the plugin locally with --api-addr in place of --api_fd:

TerminalCode
echo '{"greeting":"Hey"}' | go run . --api-addr :8080 --log-type text curl -s localhost:8080/hello
JSONCode
{"status":"success","data":{"message":"Hey, world!"}}

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 < platform config on STDIN < environment < command-line flag
  • Struct default: the fallback that the default tag 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 env tag.
  • 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.

TagPurposeExample
json:"…"Key in the platform config on STDINjson:"source_url"
name:"…"Flag name, in kebab-casename:"source-url"
env:"…"Environment variable to bind the field toenv:"SOURCE_URL"
help:"…"Help texthelp:"Repository URL."
default:"…"Default valuedefault:"/tmp"
enum:"a,b,c"Restrict the value to a setenum:"git,tar"
placeholder:"…"Help placeholderplaceholder:"dir"
required:""Make the flag mandatoryrequired:""
hidden:""Hide the flag from --helphidden:""
kong:"-"Ignore the field entirelykong:"-"

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.

GoCode
type Config struct { Workdir string `json:"workdir" name:"workdir" env:"WORKDIR" help:"Working directory." default:"/tmp"` SourceType string `json:"source_type" name:"source-type" env:"SOURCE_TYPE" help:"Source type." enum:"git,tar" default:"git"` Verbose bool `json:"verbose" name:"verbose" env:"VERBOSE" help:"Enable verbose output."` // Never surfaces as a flag or a config key; code populates it. internalToken string `json:"-" kong:"-"` }

The platform config

Attach a config to the plugin in the create instance request, and it reaches the plugin's init on STDIN:

JSONCode
{ "name": "example", "image": "user/example:latest", "config": { "greeting": "Bonjour" } }

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:

GoCode
raw := pluginsdk.RawConfig(ctx) // []byte, exactly as STDIN delivered it

Routing

Register receives the base context, your typed configuration, and the gin engine:

GoCode
Register: func(ctx context.Context, cfg *Config, engine *gin.Engine) error { engine.GET("/files/:name", handleGetFile) engine.POST("/files", handleWriteFile) v1 := engine.Group("/v1") v1.GET("/status", handleStatus) return nil }

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:

MiddlewareEffect
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:

GoCode
pluginsdk.Main(&pluginsdk.Plugin[Config]{ Name: "example", Register: register, Middleware: []gin.HandlerFunc{myMiddleware()}, // appended to the defaults // ...or drop the defaults and bring your own stack: // DisableDefaultMiddleware: true, })

Generated services

Plugins describe their API in TypeSpec and generate a typed gin service interface from it. Register that generated service from inside Register:

GoCode
Register: func(ctx context.Context, cfg *Config, engine *gin.Engine) error { api.RegisterX(engine, handler, nil) return nil }

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:

GoCode
// Success. OK takes a pointer and returns three values. env, code, _ := pluginsdk.OK(&data) g.JSON(code, env) // Error. env, code, _ := pluginsdk.Error[any](http.StatusBadRequest, "bad input") g.JSON(code, env)

A handler that calls OK produces the same shape as the rest of the API:

JSONCode
{ "status": "success", "data": { "message": "Bonjour, world!" } }

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:

FlagEnvironmentMeaning
--api_fd <n>API_FDServe on file descriptor n, which the platform supplies
--api-addr <addr>API_ADDRServe on a TCP address such as :8080, for local development
--log-levelLOG_LEVELOne of trace, debug, info, warn, error, fatal
--log-typeLOG_TYPEEither text or json

The platform spells --api_fd with an underscore, and every other flag uses a hyphen.

Lifecycle

Main runs this sequence:

  1. Read the config from STDIN, then parse the command line.
  2. Configure logging from --log-level and --log-type.
  3. Run the optional Setup hook.
  4. Bind the listener: --api_fd in production, --api-addr locally.
  5. Build the router with the default and user middleware, then call the registration function.
  6. Serve on the listener.
  7. Block until a signal arrives or the context cancels.
  8. 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:

GoCode
import "unikraft.com/cloud/pluginsdk/scaletozero" // Hold the instance awake while the work runs. if err := scaletozero.Increment(); err != nil { return err } defer scaletozero.Decrement()

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
plugins/<plugin_name>/ ├── api.tsp # TypeSpec API description (optional) ├── openapi.yaml # generated from api.tsp ├── main.go # the entrypoint ├── api/ # generated service: interface, models, register function ├── Dockerfile # builds the single `init` executable into a scratch image ├── Kraftfile # ROM image recipe └── go.mod

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:

Code
FROM golang:1.26-bookworm AS build WORKDIR /plugin COPY . . RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /plugin/dist/init . FROM scratch COPY --from=build /plugin/dist/init /init ENTRYPOINT ["/init"]

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:

YAMLCode
spec: v0.7 targets: - kraftcloud/x86_64 roms: - ./Dockerfile

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:

TerminalCode
unikraft build . --output <my-org>/example:latest

Deploying

Attach the plugin when you create the instance, naming the ROM image and an optional config:

TerminalCode
unikraft instance create \ --name my-instance \ --metro fra \ --image my-app:latest \ --plugin 'name=example,image=<my-org>/example:latest,config={"greeting":"Bonjour"}'

Then call the plugin through the instance's authenticated endpoint:

TerminalCode
curl -H "Authorization: Bearer $UKC_TOKEN" \ https://api.fra.unikraft.cloud/v1/instances/<uuid>/plugins/example/hello

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.

GoCode
func Main[C any](p *Plugin[C])

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.

GoCode
func Serve[C any](ctx context.Context, cfg *C, register RegisterFunc[C], opts ...Option) error

The plugin declaration

GoCode
type Plugin[C any] struct { Name string Version string Register RegisterFunc[C] Setup SetupFunc[C] Middleware []gin.HandlerFunc DisableDefaultMiddleware bool } type RegisterFunc[C any] func(ctx context.Context, cfg *C, engine *gin.Engine) error type SetupFunc[C any] func(ctx context.Context, cfg *C) error
FieldDescription
NameThe plugin name, for logs and diagnostics only, since routing ignores it
VersionAn optional version string that the startup logs carry
RegisterAttaches the routes, and a non-nil error aborts startup (required)
SetupAn optional hook that runs once after the SDK resolves the configuration and before the server accepts requests
MiddlewareMiddleware to append to the default stack
DisableDefaultMiddlewareDrops 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.

OptionEffect
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().

SymbolDescription
FromContext[C any](ctx) *CRetrieves the typed configuration from a request context
RawConfig(ctx) []byteThe raw platform config bytes from STDIN
APIFD(ctx) (int, bool)The adopted file descriptor, and whether one exists
ReadConfig(stdin io.Reader) []byteReads the STDIN config verbatim
NewConfigResolver[C any](raw []byte) (kong.Resolver, bool)Builds the kong resolver for a custom grammar

Response helpers

SymbolDescription
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

SymbolDescription
ShutdownTimeoutThe graceful-drain bound, at 30 seconds
ReadHeaderTimeoutThe bound on waiting for request headers, at 30 seconds
ErrNoListenerNeither --api_fd nor --api-addr resolved
ErrNoRegisterA plugin reached Serve without a registration function
ErrInvalidAPIFDThe --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:

GoCode
type CLI struct { pluginsdk.Options // --api_fd, --api-addr, --log-level, --log-type Config Config `embed:""` Run RunCmd `cmd:"" help:"Fetch source, then serve."` } type RunCmd struct { SourceURL string `name:"source-url" env:"SOURCE_URL" help:"Repository to clone."` } func (c *RunCmd) Run(ctx context.Context, cli *CLI, raw rawConfig) error { if err := fetchSource(ctx, c.SourceURL); err != nil { return err } return pluginsdk.Serve(ctx, &cli.Config, register, pluginsdk.WithOptions(cli.Options), pluginsdk.WithRawConfig(raw), ) }

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:

GoCode
type rawConfig []byte func main() { ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer cancel() raw := pluginsdk.ReadConfig(os.Stdin) var cli CLI opts := []kong.Option{ kong.Name("example"), kong.UsageOnError(), kong.BindTo(ctx, (*context.Context)(nil)), kong.Bind(rawConfig(raw)), } if resolver, ok := pluginsdk.NewConfigResolver[Config](raw); ok { opts = append(opts, kong.Resolvers(resolver)) } k := kong.Parse(&cli, opts...) k.FatalIfErrorf(k.Run()) }

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.
  • Python SDK: the same platform API from Python.
  • Instances: create and manage the instances that host plugins.
Last modified on