Help us shape the future of Go! Take the 2026 Go Developer Survey

plugins

package
v0.2.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 16, 2026 License: GPL-3.0 Imports: 23 Imported by: 0

Documentation

Overview

Package plugins runs the server's plugins.

A plugin is a separate process. The host in this package finds them, starts them, speaks to them over a local channel, restarts them when they crash and gives up when they keep crashing. It implements the contract in the github.com/pacenote-sim/plugin module and adds nothing to it: everything a plugin author sees is published there, and this package is one host among the two there will eventually be.

Why a separate process

Three reasons, and the first is the whole argument on a server that is meant to run for a season: a crashing plugin does not take the server down. The second is that the same design works for a closed host, which the enterprise edition will be. The third is licensing — a plugin talking to a host over a local channel is not a derivative work of it, which is what makes a closed commercial plugin on top of this GPL-3 server possible at all.

The cost is a process per plugin and a round trip per call, which is irrelevant at the rate a team generates events.

What this host provides, and what it does not

Five things, which are the five the first demanding plugin needs:

  • Events, fire and forget, carrying derived facts and never raw traces.
  • Requests, with a deadline, where the caller is told plainly when there was no answer so it can fall back.
  • Sealed credentials, held by the core and lent one call at a time.
  • Metering, with the daily cap enforced here and not in the plugin.
  • Settings, declared by the plugin and configured in the panel.

Serving an endpoint, adding a page, enriching data on the way in and the marketplace are all in the plan and none of them are here. They wait until something asks for them.

What it refuses to do

It does not run a plugin built against a different interface version, and it does not offer a degraded mode for one. It does not retry an event. It does not let a plugin decide how much of the operator's money to spend. And it does not write a credential into a log line or a database column, which is enforced rather than promised: everything a plugin says passes through a scrubber that knows the credentials it was lent.

Index

Constants

View Source
const (
	// DefaultCallTimeout is how long a request waits when the caller names no
	// deadline of its own. It is short because the caller is usually a driver
	// at speed: an answer that arrives after the corner is worse than none.
	DefaultCallTimeout = 5 * time.Second

	// DefaultEventTimeout bounds a fire-and-forget delivery. Nothing is
	// waiting on it, so it is generous — but it is not unbounded, because a
	// wedged plugin would otherwise hold a goroutine and a process for ever.
	DefaultEventTimeout = 30 * time.Second

	// DefaultStartTimeout is how long a plugin has to complete its handshake.
	DefaultStartTimeout = 10 * time.Second

	// DefaultMaxRestarts is how many times a plugin is restarted before the
	// host gives up on it. Five is enough to ride out a transient cause — a
	// vendor that was down, a file that was being replaced — and few enough
	// that a genuinely broken plugin is marked failed within a minute rather
	// than restarting for the life of the server.
	DefaultMaxRestarts = 5

	// DefaultRestartBackoff is the first wait after a crash. It doubles each
	// time, up to MaxRestartBackoff.
	DefaultRestartBackoff = 500 * time.Millisecond

	// MaxRestartBackoff is the longest wait between restarts.
	MaxRestartBackoff = 30 * time.Second
)

The host's own numbers. They are constants rather than settings because an operator has no way to choose them well, and because every one of them is a promise this server makes to the rest of itself rather than a preference.

View Source
const (
	// MaxServeRequestBytes is the largest request body forwarded to a plugin.
	MaxServeRequestBytes int64 = 1 << 20
	// MaxServeResponseBytes is the largest answer taken back from one.
	MaxServeResponseBytes = 8 << 20
	// MaxServeHeaders is how many headers may cross in either direction.
	MaxServeHeaders = 64
	// ServeTimeout is how long a plugin has to answer one request.
	ServeTimeout = 15 * time.Second
)

The limits on one request. They are small on purpose: this is a page or an endpoint on a team's server, not a file transfer, and a plugin that needs to move more than this should be handing out a link rather than the bytes.

View Source
const CookiePrefix = "pacenote_p_"

CookiePrefix is what a plugin's own cookies are called. A plugin may read and set cookies whose names begin with this and its own name; everything else on the way in is hidden from it and everything else on the way out is refused.

It is a prefix rather than a list so that a plugin can have as many as it needs without asking, and so that the host can tell one plugin's cookies from another's by looking at the name.

View Source
const (
	// SettingDailyCap is the most tokens this plugin may spend in a day, as a
	// decimal string. Absent or zero means no cap.
	SettingDailyCap = "_daily_token_cap"
)

The settings a plugin does not own. They live in the same table as the ones it declares, and they are told apart by the leading underscore: the plugin interface refuses a setting name that starts with one, so a plugin cannot declare a name that collides with these however hard it tries.

Variables

View Source
var (
	// ErrNoPlugin is a name nothing is installed under. It is a mistake in the
	// caller, not a runtime condition.
	ErrNoPlugin = errors.New("plugins: no plugin of that name is installed")

	// ErrUnavailable is a plugin that is installed but not running — starting,
	// stopped, disabled by the operator, or failed. The caller falls back and
	// says nothing to the driver about it; the panel is where a plugin's state
	// is explained.
	ErrUnavailable = errors.New("plugins: the plugin is not running")

	// ErrOverCap is the operator's daily token cap having been reached. The
	// plugin is not called at all — not called and truncated, not called and
	// refused by the vendor — and the caller is told so that it falls back to
	// whatever it does without that plugin, and nothing else breaks.
	ErrOverCap = errors.New("plugins: the daily token cap has been reached")

	// ErrClosed is the host having been shut down.
	ErrClosed = errors.New("plugins: the plugin host is closed")
)

What a caller gets when a plugin does not answer. Each one is a different thing for the caller to do, which is why they are separate values and not one "the plugin did not work".

View Source
var ErrNoDataKey = errors.New("plugins: the server has no data key, so a plugin database cannot be created")

ErrNoDataKey is a plugin asking for a database on a server with no data key. There is nothing to seal the role's password with, and storing it in clear to get past that would be worse than refusing.

View Source
var ErrNoRoute = errors.New("plugins: this plugin does not serve a route")

ErrNoRoute reports that a plugin was asked to serve a request and did not ask for a route. It is not an error about this request: it is an operator following a link to a plugin that does not have pages.

Functions

func Reserved

func Reserved(name string) bool

Reserved reports whether a setting name belongs to the host rather than to the plugin. The panel uses it to keep these off the form a plugin declares, and it is a prefix test rather than a list so that adding one needs no second edit.

Types

type Host

type Host struct {
	// contains filtered or unexported fields
}

Host runs the plugins.

func New

func New(opts Options) (*Host, error)

New builds a host. It does not start anything: Host.Discover does, once the caller is ready for plugins to begin receiving events.

func (*Host) Access

func (h *Host) Access(name, path string) (plugin.Access, bool)

Access is what this plugin requires of a caller at this path, and whether it serves the path at all.

A path no route covers is not served. That is the rule the whole declaration rests on: a plugin puts on the operator's server exactly what its manifest lists, and an address nobody thought about is refused here without the plugin being asked.

func (*Host) All

func (h *Host) All(ctx context.Context) ([]Status, error)

All is every plugin this server knows about: the ones it is supervising, and the ones it has a row for but no process — disabled, or with its files gone.

Host.List is the live view and is what the request path uses. This is the panel's view, and it is a different question: an operator looking for the plugin they installed last week needs to find it whether or not it is running, and a plugin that has vanished from the directory has to be visible or its settings and its database are unreachable.

func (*Host) Answering

func (h *Host) Answering(kind plugin.RequestKind) []string

Answering names the plugins that say they answer this kind of request, so a caller can find one without knowing what the operator installed. The order is stable.

func (*Host) Ask

func (h *Host) Ask(ctx context.Context, name string, r plugin.Request) (plugin.Response, error)

Ask puts a request to one plugin and waits for the answer. It is what the broker calls when one plugin asks another; the server itself asks nothing.

The deadline is the request's own, or Options.CallTimeout when it carries none. A plugin that misses it is skipped and reported, and the caller gets an error rather than a late answer, so that it can use its own fallback.

The caller is told plainly which of the things that can go wrong did. ErrNoPlugin, ErrUnavailable and ErrOverCap are the host's; the plugin's own plugin.ErrNoAnswer and plugin.ErrNotConfigured pass through.

func (*Host) Close

func (h *Host) Close()

Close stops every plugin and waits for them. A host that is closed does not start anything again.

func (*Host) DailyCap

func (h *Host) DailyCap(ctx context.Context, name string) (int64, error)

DailyCap is what this plugin may spend in a day, in tokens. Zero is no cap, which is the operator saying so rather than a default nobody chose.

func (*Host) Disable

func (h *Host) Disable(ctx context.Context, name string) error

Disable stops a plugin and records that the operator wants it stopped. Its settings, its row and its database are all left alone: disabling is not removing, and an operator turning something off for an evening must not lose what they configured.

func (*Host) Discover

func (h *Host) Discover(ctx context.Context) error

Discover reads the plugin directory and reconciles it with what is installed: new directories are recorded and started, known ones are updated, and a plugin whose files have gone is stopped and marked so.

It is safe to call again — that is what a rescan is.

func (*Host) Enable

func (h *Host) Enable(ctx context.Context, name string) error

Enable turns a plugin on and starts it. It is safe on one that is already enabled, which is an operator pressing a button twice.

func (*Host) InterfaceVersion

func (h *Host) InterfaceVersion() int

InterfaceVersion is the contract this host speaks. It is exposed so the panel and the startup log can name it, and so a test cannot assert against a version the host does not actually implement.

func (*Host) List

func (h *Host) List() []Status

List is every plugin the host is supervising, by name. It is what a panel page will render; nothing renders it yet.

func (*Host) Notify

func (h *Host) Notify(ctx context.Context, e plugin.Event)

Notify tells every plugin that asked for this kind of event that it happened, and returns immediately.

The caller's context is deliberately not the event's. An upload finishing must not cancel the delivery it triggered, and a plugin that is slow, wedged or dead must not delay the upload — which together are the whole reason this is fire and forget rather than a call.

func (*Host) Remove

func (h *Host) Remove(ctx context.Context, name string) error

Remove uninstalls a plugin: it stops the process, drops the database it owned, and forgets the row.

It is not what happens when a plugin's files disappear — that is [Host.retire], which stops the process and keeps everything, because a directory deleted by accident or moved during an upgrade must not destroy a season of results. Remove is the operator saying so, and it is the only path that destroys data.

The order is deliberate. The process is stopped first, so nothing is holding a connection to the schema about to be dropped; the database goes next; the row last, so that a failure anywhere leaves a plugin the next call can finish removing rather than a row pointing at a role that is already gone.

Removing the files is the caller's: this host owns processes and rows, and a panel that deletes a directory should do it where it can report what happened.

func (*Host) Restart

func (h *Host) Restart(ctx context.Context, name string) error

Restart stops a plugin and starts it again. It is what an operator presses when a plugin has wedged against something outside it — a vendor that was down, a credential that has just been corrected — and it is the one action here that changes nothing about what was chosen or stored.

func (*Host) Running added in v0.2.0

func (h *Host) Running() []plugin.Manifest

Running is the manifest of every plugin that is running right now, by name.

It is what a client is told about: not what is installed and not what the operator configured, but what is answering. A plugin that is stopped, failed or switched off has routes nothing serves, and telling a client about them would be telling it to show a button that returns an error.

The manifests come back as the plugins declared them. Which routes a client may be told about is the caller's to decide, because it is a question about the caller's audience and not about the plugin.

func (*Host) Serve

func (h *Host) Serve(ctx context.Context, name string, r plugin.HTTPRequest) (plugin.HTTPResponse, error)

Serve hands one request to a plugin and returns what to answer with.

The caller has already decided that whoever made the request is allowed to reach this plugin. What is decided here is everything else: what the plugin is shown, how long it has, and whether what it said back is something the host is willing to write.

func (*Host) SetDailyCap

func (h *Host) SetDailyCap(ctx context.Context, name string, tokens int64) error

SetDailyCap records what a plugin may spend. Zero removes the cap.

func (*Host) Spending

func (h *Host) Spending(ctx context.Context, name string) (Spending, error)

Spending reads what a plugin has spent and what it may.

func (*Host) Status

func (h *Host) Status(ctx context.Context, name string) (Status, error)

Status is one plugin, for the panel. A plugin with no row is not found, which is what an operator following a stale link gets.

type Options

type Options struct {
	// Dir is the plugin directory: one subdirectory per plugin, each with a
	// manifest and a binary. It is created if it is not there, so that an
	// operator dropping a plugin into it has somewhere to drop it.
	Dir string
	// Log receives the host's own lines. A credential is never one of them,
	// which is enforced by the scrubber rather than promised.
	Log *slog.Logger
	// Store is where installed plugins, their settings and their spending
	// live.
	Store Store
	// Keyring holds the data key that opens a plugin's sealed credentials. It
	// is a keyring rather than a key because an operator can regenerate the
	// data key while the server runs. A nil keyring, or one holding no key, is
	// every plugin credential being unreadable, which is reported as the
	// plugin needing configuring rather than as the plugin being broken.
	Keyring *auth.Keyring
	// Now is the clock, injectable for tests.
	Now func() time.Time
	// CallTimeout, EventTimeout and StartTimeout override the defaults above.
	CallTimeout  time.Duration
	EventTimeout time.Duration
	StartTimeout time.Duration
	// MaxRestarts and RestartBackoff override the restart policy.
	MaxRestarts    int
	RestartBackoff time.Duration
}

Options is what the host needs from the rest of the server.

type Spending

type Spending struct {
	// Cap is the daily cap in tokens, zero for none.
	Cap int64
	// Today and Month are what has been spent in each window.
	Today db.TokenUse
	Month db.TokenUse
	// OverCap reports that the cap has been reached, which is a plugin that is
	// installed, running, configured and quietly not being called.
	OverCap bool
}

Spending is what one plugin has cost, over the two windows an operator asks about: today, because that is what the cap is measured against, and this month, because that is what the bill is.

type State

type State string

State is what a plugin is doing. The spellings are the ones the plugins table accepts, in one place so the constant and the check constraint cannot drift.

const (
	// StateDiscovered is a plugin found on disk and not yet started.
	StateDiscovered State = "discovered"
	// StateStarting is a process started and not yet answering.
	StateStarting State = "starting"
	// StateRunning is a plugin that answered, which is the only state it can
	// be called in.
	StateRunning State = "running"
	// StateStopped is a plugin the host stopped: a shutdown, or files that are
	// no longer there.
	StateStopped State = "stopped"
	// StateDisabled is a plugin the operator turned off. It is separate from
	// stopped because it is a choice rather than a consequence, and because a
	// restart must not undo it.
	StateDisabled State = "disabled"
	// StateFailed is a plugin that will not be started again: a version
	// mismatch, a manifest that does not parse, or one crash too many. The
	// panel shows it with its last error and its last output, which is the
	// only thing an operator can act on.
	StateFailed State = "failed"
)

The states a plugin can be in.

type Status

type Status struct {
	// Name, Version, Author and Description come from the manifest.
	Name        string
	Version     string
	Author      string
	Description string
	// InterfaceVersion is what it was built against.
	InterfaceVersion int
	// Capabilities is what it declared, and Settings what it asked to have
	// configured. Settings is empty for a plugin that has never started.
	Capabilities plugin.Capabilities
	Settings     []plugin.Setting
	// State is what it is doing, LastError why it is not running, and
	// LastOutput the last of what it printed — scrubbed of credentials.
	State      State
	LastError  string
	LastOutput string
	// Restarts is how many times it has been restarted since it last ran
	// cleanly.
	Restarts int
	// Enabled is whether the operator wants it running at all. A disabled
	// plugin has no process, so everything above it comes from the row rather
	// than from anything live.
	Enabled bool
	// Installed is whether its files are still in the plugin directory. A
	// plugin whose directory was deleted keeps its row, its settings and its
	// database, and this is how the panel says so rather than showing it as
	// simply broken.
	Installed bool
}

Status is one plugin as the host sees it right now.

type Store

type Store interface {
	// SavePlugin records a plugin found on disk.
	SavePlugin(ctx context.Context, p db.PluginRecord) error
	// SavePluginDeclaredSettings records what it said it needs configured.
	SavePluginDeclaredSettings(ctx context.Context, name string, declared []byte) error
	// SavePluginState records what it is doing and why.
	SavePluginState(ctx context.Context, name, state, lastError, lastOutput string, restarts int) error
	// Plugins is every plugin this server has seen.
	Plugins(ctx context.Context) ([]db.PluginRecord, error)
	// Plugin is one of them, by name.
	Plugin(ctx context.Context, name string) (db.PluginRecord, error)
	// SavePluginEnabled records whether the operator wants it running. It is
	// separate from the state: disabled is a choice and stopped is a
	// consequence, and a plugin the operator turned off must stay off across a
	// restart.
	SavePluginEnabled(ctx context.Context, name string, enabled bool) error
	// PluginSettings is what the operator configured for one.
	PluginSettings(ctx context.Context, name string) ([]db.PluginSettingRow, error)
	// RecordPluginUsage stores what a call cost.
	RecordPluginUsage(ctx context.Context, u db.PluginUsageWrite) error
	// PluginTokensSince is what every plugin has spent since an instant, for
	// the whole-server figures.
	PluginTokensSince(ctx context.Context, since time.Time) (db.TokenUse, error)
	// PluginTokensSinceFor is the same for one plugin, which is what its own
	// daily cap is measured against.
	PluginTokensSinceFor(ctx context.Context, name string, since time.Time) (db.TokenUse, error)
	// SavePluginSetting stores one plain value. The host writes the settings a
	// plugin does not own — its daily cap — through this as well.
	SavePluginSetting(ctx context.Context, plugin, name, value string) error

	// PluginDatabase is what is known about a plugin's database: the sealed
	// password for its role, whether it exists, and how far it is migrated.
	PluginDatabase(ctx context.Context, name string) (db.PluginDatabaseRow, error)
	// SetPluginProvisioned records that the role and schema exist and stores
	// the sealed password that reaches them.
	SetPluginProvisioned(ctx context.Context, name string, sealed []byte, migration string) error
	// DeletePlugin forgets a plugin entirely. It is the last step of removing
	// one, after its process is stopped and its database dropped.
	DeletePlugin(ctx context.Context, name string) error
	// ClearPluginDatabase forgets a password whose role has been dropped.
	ClearPluginDatabase(ctx context.Context, name string) error
	// ProvisionPluginDatabase creates the role, the schema and the grants, or
	// brings an existing one back to that state.
	ProvisionPluginDatabase(ctx context.Context, name, password string) error
	// DropPluginDatabase removes the role, the schema and everything in it.
	DropPluginDatabase(ctx context.Context, name string) error
	// PluginDSN is the connection string for a plugin's role.
	PluginDSN(name, password string) (string, error)
}

Store is what the host needs from the database. It is an interface rather than the concrete store so that the host's failure paths — a plugin that crashes, one that hangs, one built against the wrong version — can be tested on a machine with no PostgreSQL on it. *db.Store implements it.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL