Authoring Plugins¶
Imbi plugins extend the platform with integrations against third-party
services. A plugin is an installable Python distribution that exposes a
handler class through the imbi.plugins entry point group. The host
service (imbi-api) discovers, validates, and instantiates plugins at
runtime; plugins themselves carry no global state and receive everything
they need — context, credentials, and options — on every call.
This guide walks through the plugin variations supported by the v1 API and the contract each must satisfy.
Plugin Variations¶
PluginManifest.plugin_type selects the variation. The contract for a
plugin is defined by the abstract base class it inherits from:
plugin_type |
Base class | Purpose |
|---|---|---|
configuration |
ConfigurationPlugin |
List, read, write, and delete typed configuration keys for a project (e.g. feature flags, secrets) |
logs |
LogsPlugin |
Search log entries and describe the available query schema for a project |
identity |
IdentityPlugin |
Map external identity subjects (GitHub usernames, OIDC sub) to Imbi users |
deployment |
DeploymentPlugin |
Dispatch a deployment workflow and report back its status to the host |
lifecycle |
LifecyclePlugin |
React to project state transitions (archive / unarchive) by performing third-party side effects |
webhook |
WebhookActionPlugin |
Run a named action in response to an inbound webhook payload routed by a host such as imbi-gateway |
The class hierarchy and plugin_type must agree — a class that subclasses
ConfigurationPlugin declared with plugin_type='logs' is rejected at
load time, and vice versa.
This guide covers Configuration, Logs, and Webhook plugins in detail. Identity, Deployment, and Lifecycle plugins follow the same conventions (per-request instances, manifest-driven credentials, no global state); their method contracts are documented inline in the API reference.
Anatomy of a Plugin Package¶
A minimum plugin distribution contains:
- A handler class subclassing one of the two base classes.
- A class-level
manifest: PluginManifestdescribing slug, name, options, credential fields, and (for configuration plugins) the data types it understands. - An
imbi.pluginsentry point in the package metadata pointing at the handler class.
A trimmed pyproject.toml:
[project]
name = "imbi-plugin-vault"
version = "0.1.0"
dependencies = ["imbi-common>=2.0", "httpx"]
[project.entry-points."imbi.plugins"]
vault = "imbi_plugin_vault.plugin:VaultPlugin"
The entry-point name is informational — the canonical identifier is
manifest.slug. Two plugins with the same slug cannot coexist; the
second is dropped with an error during load.
The Manifest¶
from imbi_common.plugins import (
CredentialField,
DataType,
PluginManifest,
PluginOption,
)
manifest = PluginManifest(
slug='vault',
name='HashiCorp Vault',
description='Read and write project secrets stored in Vault.',
plugin_type='configuration',
api_version=1,
cacheable=False,
options=[
PluginOption(
name='mount_path',
label='Mount Path',
type='string',
required=True,
default='secret',
),
PluginOption(
name='kv_version',
label='KV Engine Version',
type='string',
choices=['1', '2'],
default='2',
),
],
credentials=[
CredentialField(
name='token',
label='Vault Token',
description='Vault token with read/write on the mount.',
),
],
data_types=[
DataType(name='string', label='String'),
DataType(name='secret', label='Secret', secret=True),
],
)
Notes:
auth_typedeclares the credential flow expected by the plugin:'api_token'(default) or'oauth2'. The host uses this to determine how to present and validate the credentials form.supports_histogramdefaults toFalse. Set it toTrueonly when the plugin implementsLogsPlugin.histogram. The host uses this flag to decide whether to show the histogram panel for a source.api_versionmust be an integer the host advertises as supported. Today only1is accepted; plugins declaring other versions are skipped and reported as such, not errored.cacheableis a hint to the host — setFalsefor plugins whose reads must always be live (token-based providers, audit-sensitive systems).optionsare configured at assignment time and are validated against thePluginOptionschema. Choices, defaults, and the four scalar types (string,integer,boolean,secret) are enforced by the host UI; plugin code receives the resolved values viaPluginContext.assignment_options.credentialsdescribe what the host must collect for a service application using this plugin. Values are encrypted at rest onServiceApplication.encrypted_credentialsand are decrypted into thecredentialsdict the host passes to each call.data_typesapply only to configuration plugins. They tell the host whichConfigValue.data_typestrings are valid, and which represent secret material (and therefore should be redacted in UI and audit logs).
Variation 1: Configuration Plugins¶
ConfigurationPlugin is the abstract base for integrations that present
a typed key/value store scoped to a project. All four methods receive a
PluginContext (project identity + assignment options) and a
credentials dict resolved from the linked ServiceApplication.
from imbi_common.plugins import (
ConfigKey,
ConfigKeyWithValue,
ConfigurationPlugin,
ConfigValue,
PluginContext,
)
class VaultPlugin(ConfigurationPlugin):
manifest = manifest # the PluginManifest defined above
async def list_keys(
self,
ctx: PluginContext,
credentials: dict[str, str],
) -> list[ConfigKey]:
...
async def get_values(
self,
ctx: PluginContext,
credentials: dict[str, str],
keys: list[str] | None = None,
) -> list[ConfigKeyWithValue]:
...
async def set_value(
self,
ctx: PluginContext,
credentials: dict[str, str],
key: str,
value: ConfigValue,
) -> ConfigKey:
...
async def delete_key(
self,
ctx: PluginContext,
credentials: dict[str, str],
key: str,
) -> None:
...
Method contracts:
list_keys— return every key visible to the project. Do not inline values; useget_valuesfor that. Populatelast_modifiedwhen the upstream system exposes it.get_values— whenkeys is None, return values for every visible key (mirroringlist_keys); otherwise return only the requested subset. Missing keys should be omitted, not raised.set_value— create or update. The returnedConfigKeyshould reflect the persisted state, includinglast_modified.delete_key— idempotent; deleting an absent key should not raise.
Mark a ConfigKey/ConfigKeyWithValue as secret=True when the key's
data type is one of the manifest's secret types. The host uses this to
gate read access and to redact values in UI and logs.
Variation 2: Logs Plugins¶
LogsPlugin is the abstract base for log-search integrations. Required
methods are search and schema. Histogram support is optional via
histogram() plus manifest.supports_histogram = True.
from imbi_common.plugins import (
LogHistogramBucket,
LogQuery,
LogResult,
LogsPlugin,
PluginContext,
)
class LokiPlugin(LogsPlugin):
manifest = manifest # plugin_type='logs'
async def search(
self,
ctx: PluginContext,
credentials: dict[str, str],
query: LogQuery,
) -> LogResult:
...
async def schema(
self,
ctx: PluginContext,
credentials: dict[str, str],
) -> list[dict]:
...
async def histogram(
self,
ctx: PluginContext,
credentials: dict[str, str],
query: LogQuery,
bucket_count: int = 60,
) -> list[LogHistogramBucket]:
...
Method contracts:
search— return aLogResultwithentriesordered most-recent first. Honorquery.limitandquery.cursor. When more results are available, populatenext_cursorwith an opaque token the upstream system can decode on the next call. If a cursor has expired or become invalid, raiseCursorExpiredErrorrather than silently returning empty results.schema— return a list of field descriptors. The shape is intentionally loose (list[dict]) so plugins can surface vendor- specific metadata; at minimum include anameand a human-readablelabelfor each field exposed to filters.histogram— optional. Implement this method and setmanifest.supports_histogram = Trueto enable the histogram panel in the host UI. Return oneLogHistogramBucketper time bucket spanning the query's time range. The base class raisesNotImplementedErrorby default; the host checkssupports_histogrambefore calling it.
LogQuery.filters are (field, op, value) triples with five operators
(eq, ne, contains, starts_with, regex). Translate them into
the upstream provider's query language; raise a domain-appropriate
exception if a filter cannot be satisfied so the host can surface a
clear error.
Variation 3: Webhook Action Plugins¶
WebhookActionPlugin is the abstract base for plugins that react to
inbound webhook payloads. The host (typically imbi-gateway) is
responsible for receiving the webhook, resolving the matching
project(s) from the payload, and routing each match to a plugin
action. A plugin exposes one or more named actions; the rule wiring
selects which one runs.
from imbi_common.plugins import (
PluginContext,
PluginManifest,
CredentialField,
WebhookActionPlugin,
)
class SonarqubePlugin(WebhookActionPlugin):
manifest = PluginManifest(
slug='sonarqube',
name='SonarQube',
plugin_type='webhook',
credentials=[
CredentialField(name='api_token', label='SonarQube API Token'),
],
)
async def run_action(
self,
ctx: PluginContext,
credentials: dict[str, str],
external_identifier: str,
action: str,
action_config: str,
payload: object,
) -> None:
if action == 'update_project_from_webhook':
await update_project_from_webhook(
ctx=ctx,
credentials=credentials,
external_identifier=external_identifier,
action_config=action_config,
)
return
raise ValueError(f'Unknown action: {action!r}')
How the arguments flow in from the host:
ctx— a regularPluginContextcarrying the resolved project's identity (org_slug,project_id,project_slug,team_slug, ...) plus anyassignment_optionsthe host wants to share. The gateway, for example, stashesservice_slugandservice_endpointfrom the matchedThirdPartyServiceso plugins can reach the upstream API.credentials— decrypted plugin credentials keyed by the manifest'sCredentialField.name. Plugins that declare no credentials always receive{}; plugins with credentials are skipped (warning logged) when noPluginnode is attached to the matched third-party service.external_identifier— the value the host extracted from the payload using the webhook'sIMPLEMENTED_BY.identifier_selector(for example a SonarQube/project/keyJSON pointer). Plugins use this to address the upstream system.action— the action name parsed from the rule'shandlerfield after the:separator. A rule whose handler issonarqube:update_project_from_webhookdispatches withaction='update_project_from_webhook'. Dispatch on this value; raiseValueErrorfor unknown actions so the host can surface the problem.action_config— opaque per-rule configuration shipped as a JSON string. Operators set this on the rule when wiring the webhook; the plugin is responsible for parsing and validating it (Pydantic models work well here).payload— the raw inbound webhook body. Most plugins do not consume it directly — the host already used it to resolve the project andexternal_identifier— but it is forwarded verbatim for cases that need to.
Rules of thumb:
- Keep one action per public verb (
update_project_from_webhook,notify_release, ...) rather than overloading a single action with branching config. Action names become part of the operator-facing rule string and benefit from being self-describing. - Treat the host's "best effort" guarantee seriously: a
run_actioncall may run after a relatedevents-table insert has failed, and the host will not retry on its own. Make actions idempotent so manual rerun is safe.
Search Templates¶
For plugins that build provider-specific query strings from project
context, use expand_template. It substitutes only the whitelisted
variables project_slug, org_slug, environment, and project_id,
and rejects anything else:
from imbi_common.plugins import expand_template
label_query = expand_template(
template,
{
'project_slug': ctx.project_slug,
'org_slug': ctx.org_slug,
'environment': ctx.environment,
'project_id': ctx.project_id,
},
)
Validate templates at assignment time with validate_template so
configuration errors surface early instead of during a search.
Errors¶
Plugins should raise exceptions from imbi_common.plugins.errors when
the failure mode maps onto one of them; otherwise let the host wrap the
exception. The relevant ones for plugin authors are:
| Exception | When to raise |
|---|---|
PluginCredentialsMissing |
A required credential is absent or empty in the credentials dict. |
PluginTimeoutError |
An upstream call exceeded the plugin's internal timeout budget. |
PluginUnavailableError |
The upstream service is reachable but cannot serve the request (degraded, locked). |
CursorExpiredError |
A logs query.cursor is no longer valid and the caller must restart paging. |
PluginNotFoundError is host-side only — plugin code should not raise it.
Lifecycle and State¶
A new handler instance is created per request. Do not stash
connection state, cached credentials, or per-project data on
self. If you need a connection pool or rate-limited HTTP client,
construct it inside the method, scoped to the call:
async def list_keys(self, ctx, credentials):
async with httpx.AsyncClient(timeout=10.0) as client:
...
The host treats manifest as immutable once registered; do not mutate
it at runtime.
Testing¶
Plugins can be unit-tested in isolation. The host machinery is
straightforward to fake — instantiate the plugin class directly and
call its methods with synthesized PluginContext objects and a
credentials dict. For registry-level integration tests, see
tests/test_plugins/test_registry.py in this repository for examples
of mocking importlib.metadata.entry_points to inject a plugin without
having to install a real distribution.
Related Reference¶
- Plugins API reference — generated signatures and
field-level documentation for every public class and function in
imbi_common.plugins.