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
- Variables
- func Reserved(name string) bool
- type Host
- func (h *Host) Access(name, path string) (plugin.Access, bool)
- func (h *Host) All(ctx context.Context) ([]Status, error)
- func (h *Host) Answering(kind plugin.RequestKind) []string
- func (h *Host) Ask(ctx context.Context, name string, r plugin.Request) (plugin.Response, error)
- func (h *Host) Close()
- func (h *Host) DailyCap(ctx context.Context, name string) (int64, error)
- func (h *Host) Disable(ctx context.Context, name string) error
- func (h *Host) Discover(ctx context.Context) error
- func (h *Host) Enable(ctx context.Context, name string) error
- func (h *Host) InterfaceVersion() int
- func (h *Host) List() []Status
- func (h *Host) Notify(ctx context.Context, e plugin.Event)
- func (h *Host) Remove(ctx context.Context, name string) error
- func (h *Host) Restart(ctx context.Context, name string) error
- func (h *Host) Running() []plugin.Manifest
- func (h *Host) Serve(ctx context.Context, name string, r plugin.HTTPRequest) (plugin.HTTPResponse, error)
- func (h *Host) SetDailyCap(ctx context.Context, name string, tokens int64) error
- func (h *Host) Spending(ctx context.Context, name string) (Spending, error)
- func (h *Host) Status(ctx context.Context, name string) (Status, error)
- type Options
- type Spending
- type State
- type Status
- type Store
Constants ¶
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.
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.
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.
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 ¶
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") // 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".
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.
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 ¶
Types ¶
type Host ¶
type Host struct {
// contains filtered or unexported fields
}
Host runs the plugins.
func New ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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
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 ¶
SetDailyCap records what a plugin may spend. Zero removes the cap.
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.