# GitCaddy Plugin Development Guide This guide explains how to build external plugins for GitCaddy. Plugins are standalone services that communicate with the server over gRPC (HTTP/2) using a well-defined protocol. ## Table of Contents - [Overview](#overview) - [Protocol](#protocol) - [Service Definition](#service-definition) - [Lifecycle](#lifecycle) - [Messages](#messages) - [Plugin Manifest](#plugin-manifest) - [Routes](#routes) - [Events](#events) - [Permissions](#permissions) - [Health Monitoring](#health-monitoring) - [Protocol Versioning](#protocol-versioning) - [Configuration](#configuration) - [External Mode](#external-mode) - [Managed Mode](#managed-mode) - [Configuration Reference](#configuration-reference) - [Transport](#transport) - [Example: Go Plugin](#example-go-plugin) - [Example: C# Plugin](#example-c-plugin) - [Example: Python Plugin](#example-python-plugin) - [Debugging](#debugging) ## Overview A GitCaddy plugin is any process that implements the `PluginService` gRPC interface. The server connects to the plugin on startup, calls `Initialize` to get its manifest, and then: - **Health checks** the plugin periodically (default: every 30 seconds) - **Dispatches events** the plugin has subscribed to (e.g., `license:updated`) - **Proxies HTTP requests** to routes the plugin has declared - **Shuts down** the plugin gracefully when the server stops Plugins can run in two modes: - **External mode** - The plugin runs independently (Docker, systemd, etc.). The server connects to it. - **Managed mode** - The server launches the plugin binary and manages its process lifecycle. ## Protocol The protocol is defined in [`modules/plugins/pluginv1/plugin.proto`](modules/plugins/pluginv1/plugin.proto). ### Service Definition ```protobuf service PluginService { rpc Initialize(InitializeRequest) returns (InitializeResponse); rpc Shutdown(ShutdownRequest) returns (ShutdownResponse); rpc HealthCheck(HealthCheckRequest) returns (HealthCheckResponse); rpc GetManifest(GetManifestRequest) returns (PluginManifest); rpc OnEvent(PluginEvent) returns (EventResponse); rpc HandleHTTP(HTTPRequest) returns (HTTPResponse); } ``` All 6 RPCs are unary (request-response). The server is the client; the plugin is the server. ### Lifecycle ``` Server starts │ ▼ Initialize(server_version, config) │ Plugin returns: success + PluginManifest │ ▼ Plugin is ONLINE │ ├──► HealthCheck() every 30s │ Plugin returns: healthy, status, details │ ├──► OnEvent(event_type, payload, repo_id, org_id) │ Dispatched for subscribed events (fire-and-forget with 30s timeout) │ ├──► HandleHTTP(method, path, headers, body) │ Proxied when an incoming request matches a declared route │ ▼ Server shutting down │ ▼ Shutdown(reason) │ Plugin returns: success │ ▼ Plugin process is sent SIGINT (managed mode only) ``` ### Messages #### InitializeRequest | Field | Type | Description | |-------|------|-------------| | `server_version` | string | The GitCaddy server version (e.g., `"3.0.0"`) | | `config` | map | Server-provided configuration key-value pairs | | `protocol_version` | int32 | Plugin protocol version the server supports (current: `1`). `0` means pre-versioning. | #### InitializeResponse | Field | Type | Description | |-------|------|-------------| | `success` | bool | Whether initialization succeeded | | `error` | string | Error message if `success` is false | | `manifest` | PluginManifest | The plugin's capability manifest | | `protocol_version` | int32 | Plugin protocol version the plugin supports (current: `1`). `0` means pre-versioning, treated as `1`. | #### HealthCheckRequest Empty message. No fields. #### HealthCheckResponse | Field | Type | Description | |-------|------|-------------| | `healthy` | bool | Whether the plugin considers itself healthy | | `status` | string | Human-readable status (e.g., `"operational"`, `"degraded"`) | | `details` | map | Arbitrary key-value details (version, uptime, etc.) | #### PluginEvent | Field | Type | Description | |-------|------|-------------| | `event_type` | string | The event name (e.g., `"license:updated"`, `"repo:push"`) | | `payload` | google.protobuf.Struct | Event-specific data as a JSON-like structure | | `timestamp` | google.protobuf.Timestamp | When the event occurred | | `repo_id` | int64 | Repository ID (0 if not repo-specific) | | `org_id` | int64 | Organization ID (0 if not org-specific) | #### EventResponse | Field | Type | Description | |-------|------|-------------| | `handled` | bool | Whether the plugin handled the event | | `error` | string | Error message if handling failed | #### HTTPRequest / HTTPResponse | Field | Type | Description | |-------|------|-------------| | `method` | string | HTTP method (`GET`, `POST`, etc.) | | `path` | string | Request path (e.g., `/api/v1/health`) | | `headers` | map | HTTP headers | | `body` | bytes | Request/response body | | `query_params` | map | Query parameters (request only) | | `status_code` | int32 | HTTP status code (response only) | ## Plugin Manifest The manifest declares what your plugin does. It is returned during `Initialize` and can be re-fetched via `GetManifest`. ```protobuf message PluginManifest { string name = 1; string version = 2; string description = 3; repeated string subscribed_events = 4; repeated PluginRoute routes = 5; repeated string required_permissions = 6; string license_tier = 7; } ``` | Field | Description | Example | |-------|-------------|---------| | `name` | Display name | `"My Analytics Plugin"` | | `version` | Semver version | `"1.2.0"` | | `description` | What the plugin does | `"Tracks repository analytics"` | | `subscribed_events` | Events to receive | `["repo:push", "issue:created"]` | | `routes` | HTTP routes the plugin handles | See below | | `required_permissions` | Permissions the plugin needs | `["repo:read", "issue:write"]` | | `license_tier` | Minimum license tier required | `"standard"`, `"professional"`, `"enterprise"` | ### Routes Routes declare which HTTP paths your plugin handles. When the server receives a request matching a plugin's route, it proxies the request via `HandleHTTP`. ```protobuf message PluginRoute { string method = 1; // "GET", "POST", etc. string path = 2; // "/api/v1/my-plugin/endpoint" string description = 3; } ``` Route matching uses prefix matching: a declared path of `/api/v1/analytics` will match `/api/v1/analytics/events`. ### Events Subscribe to server events by listing them in `subscribed_events`. Use `"*"` to subscribe to all events. Available events include: - `license:updated` - License key changed - `repo:push` - Code pushed to a repository - `repo:created` - New repository created - `issue:created` - New issue opened - `issue:comment` - Comment added to an issue - `pull_request:opened` - New pull request opened - `pull_request:merged` - Pull request merged Events are dispatched asynchronously (fire-and-forget) with a 30-second timeout per plugin. ### Permissions The `required_permissions` field declares what server resources your plugin needs access to. The server logs these at startup for admin review. ## Health Monitoring The server health-checks every registered plugin at a configurable interval (default: 30 seconds). **Behavior:** | Consecutive Failures | Action | |---------------------|--------| | 1-2 | Warning logged, plugin stays online | | 3+ | Plugin marked **offline**, error logged | | 3+ (managed mode) | Automatic restart attempted | When a previously offline plugin responds to a health check, it is marked **online** and an info log is emitted. If `HealthCheckResponse.healthy` is `false` (the RPC succeeds but the plugin reports unhealthy), the plugin is marked as **error** status. This allows plugins to report degraded operation (e.g., missing API key, expired license) without being treated as crashed. **Health check timeout** is configured per-plugin via `HEALTH_TIMEOUT` (default: 5 seconds). ## Protocol Versioning The plugin protocol uses explicit version negotiation to ensure forward compatibility. Both the server and plugin exchange their supported protocol version during `Initialize`: 1. The server sends `protocol_version = 1` in `InitializeRequest` 2. The plugin returns `protocol_version = 1` in `InitializeResponse` 3. The server stores the plugin's version and checks it before calling any RPCs added in later versions **What this means for plugin developers:** - **You don't need to recompile** when the server adds new fields to existing messages. Protobuf handles this automatically — unknown fields are ignored, missing fields use zero-value defaults. - **You don't need to recompile** when the server adds new event types. Your plugin only receives events it subscribed to. - **You only need to update** if you want to use features from a newer protocol version (e.g., new RPCs added in protocol v2). **Version history:** | Version | RPCs | Notes | |---------|------|-------| | 1 | Initialize, Shutdown, HealthCheck, GetManifest, OnEvent, HandleHTTP | Initial release | **Pre-versioning plugins** (those that don't set `protocol_version` in their response) return `0`, which the server treats as version `1`. This means all existing plugins are compatible without changes. ## Configuration Plugins are configured in the server's `app.ini`. ### External Mode The plugin runs independently. The server connects to its gRPC endpoint. ```ini [plugins] ENABLED = true HEALTH_CHECK_INTERVAL = 30s [plugins.my-plugin] ENABLED = true ADDRESS = localhost:9090 HEALTH_TIMEOUT = 5s SUBSCRIBED_EVENTS = repo:push, issue:created ``` ### Managed Mode The server launches the plugin binary and manages its lifecycle. If the plugin crashes, the server restarts it automatically. ```ini [plugins.my-plugin] ENABLED = true BINARY = /opt/plugins/my-plugin ARGS = --port 9090 --log-level info ADDRESS = localhost:9090 HEALTH_TIMEOUT = 5s ``` When `BINARY` is set, the server: 1. Starts the process with the specified arguments 2. Waits 2 seconds for the process to initialize 3. Calls `Initialize` via gRPC 4. Sends `SIGINT` on server shutdown 5. Auto-restarts the process if health checks fail 3 consecutive times ### Configuration Reference #### `[plugins]` Section | Key | Type | Default | Description | |-----|------|---------|-------------| | `ENABLED` | bool | `true` | Master switch for the plugin framework | | `PATH` | string | `data/plugins` | Directory for plugin data | | `HEALTH_CHECK_INTERVAL` | duration | `30s` | How often to health-check plugins | #### `[plugins.]` Section | Key | Type | Default | Description | |-----|------|---------|-------------| | `ENABLED` | bool | `true` | Whether this plugin is active | | `ADDRESS` | string | (required) | gRPC endpoint (e.g., `localhost:9090`) | | `BINARY` | string | (optional) | Path to plugin binary (enables managed mode) | | `ARGS` | string | (optional) | Arguments for the binary | | `HEALTH_TIMEOUT` | duration | `5s` | Timeout for health check RPCs | | `SUBSCRIBED_EVENTS` | string | (optional) | Comma-separated event names | A plugin must have either `BINARY` or `ADDRESS` (or both for managed mode). Entries with neither are skipped with a warning. ## Transport Plugins communicate over **cleartext HTTP/2 (h2c)** by default. The server uses the gRPC wire protocol via [Connect RPC](https://connectrpc.com/). **Requirements for your plugin's gRPC server:** - Listen on a TCP port - Support HTTP/2 (standard for any gRPC server) - No TLS required for local communication (h2c) The server constructs its gRPC client with `connect.WithGRPC()`, which uses the standard gRPC binary protocol. This means your plugin can use **any** gRPC server implementation: | Language | gRPC Library | |----------|-------------| | Go | `google.golang.org/grpc` or `connectrpc.com/connect` | | C# | `Grpc.AspNetCore` | | Python | `grpcio` | | Java | `io.grpc` | | Rust | `tonic` | | Node.js | `@grpc/grpc-js` | ## Example: Go Plugin ```go package main import ( "context" "log" "net/http" "connectrpc.com/connect" pluginv1 "code.gitcaddy.com/server/v3/modules/plugins/pluginv1" "code.gitcaddy.com/server/v3/modules/plugins/pluginv1/pluginv1connect" "golang.org/x/net/http2" "golang.org/x/net/http2/h2c" ) type myPlugin struct { pluginv1connect.UnimplementedPluginServiceHandler } func (p *myPlugin) Initialize( ctx context.Context, req *connect.Request[pluginv1.InitializeRequest], ) (*connect.Response[pluginv1.InitializeResponse], error) { log.Printf("Initialized by server %s (protocol v%d)", req.Msg.ServerVersion, req.Msg.ProtocolVersion) return connect.NewResponse(&pluginv1.InitializeResponse{ Success: true, ProtocolVersion: 1, Manifest: &pluginv1.PluginManifest{ Name: "My Plugin", Version: "1.0.0", Description: "Does something useful", SubscribedEvents: []string{"repo:push"}, Routes: []*pluginv1.PluginRoute{ {Method: "GET", Path: "/api/v1/my-plugin/status", Description: "Plugin status"}, }, }, }), nil } func (p *myPlugin) HealthCheck( ctx context.Context, req *connect.Request[pluginv1.HealthCheckRequest], ) (*connect.Response[pluginv1.HealthCheckResponse], error) { return connect.NewResponse(&pluginv1.HealthCheckResponse{ Healthy: true, Status: "operational", Details: map[string]string{"version": "1.0.0"}, }), nil } func (p *myPlugin) Shutdown( ctx context.Context, req *connect.Request[pluginv1.ShutdownRequest], ) (*connect.Response[pluginv1.ShutdownResponse], error) { log.Printf("Shutdown requested: %s", req.Msg.Reason) return connect.NewResponse(&pluginv1.ShutdownResponse{Success: true}), nil } func (p *myPlugin) OnEvent( ctx context.Context, req *connect.Request[pluginv1.PluginEvent], ) (*connect.Response[pluginv1.EventResponse], error) { log.Printf("Event: %s for repo %d", req.Msg.EventType, req.Msg.RepoId) return connect.NewResponse(&pluginv1.EventResponse{Handled: true}), nil } func main() { mux := http.NewServeMux() path, handler := pluginv1connect.NewPluginServiceHandler(&myPlugin{}) mux.Handle(path, handler) server := &http.Server{ Addr: ":9090", Handler: h2c.NewHandler(mux, &http2.Server{}), } log.Println("Plugin listening on :9090") log.Fatal(server.ListenAndServe()) } ``` **app.ini:** ```ini [plugins.my-plugin] ENABLED = true ADDRESS = localhost:9090 SUBSCRIBED_EVENTS = repo:push ``` ## Example: C# Plugin ```csharp using Grpc.Core; // Assumes plugin.proto is included in the .csproj with GrpcServices="Server" public class MyPlugin : PluginService.PluginServiceBase { public override Task Initialize( InitializeRequest request, ServerCallContext context) { var manifest = new PluginManifest { Name = "My C# Plugin", Version = "1.0.0", Description = "A C# plugin for GitCaddy" }; manifest.SubscribedEvents.Add("issue:created"); return Task.FromResult(new InitializeResponse { Success = true, ProtocolVersion = 1, Manifest = manifest }); } public override Task HealthCheck( HealthCheckRequest request, ServerCallContext context) { return Task.FromResult(new HealthCheckResponse { Healthy = true, Status = "operational" }); } public override Task Shutdown( ShutdownRequest request, ServerCallContext context) { return Task.FromResult(new ShutdownResponse { Success = true }); } public override Task OnEvent( PluginEvent request, ServerCallContext context) { Console.WriteLine($"Event: {request.EventType} for repo {request.RepoId}"); return Task.FromResult(new EventResponse { Handled = true }); } } ``` **Program.cs:** ```csharp var builder = WebApplication.CreateBuilder(args); builder.Services.AddGrpc(); builder.WebHost.ConfigureKestrel(options => { options.ListenAnyIP(9090, o => o.Protocols = Microsoft.AspNetCore.Server.Kestrel.Core.HttpProtocols.Http2); }); var app = builder.Build(); app.MapGrpcService(); app.Run(); ``` ## Example: Python Plugin ```python import grpc from concurrent import futures # Generated from plugin.proto using grpcio-tools: # python -m grpc_tools.protoc -I. --python_out=. --grpc_python_out=. plugin.proto import plugin_pb2 import plugin_pb2_grpc class MyPlugin(plugin_pb2_grpc.PluginServiceServicer): def Initialize(self, request, context): manifest = plugin_pb2.PluginManifest( name="My Python Plugin", version="1.0.0", description="A Python plugin for GitCaddy", subscribed_events=["repo:push"], ) return plugin_pb2.InitializeResponse(success=True, protocol_version=1, manifest=manifest) def HealthCheck(self, request, context): return plugin_pb2.HealthCheckResponse( healthy=True, status="operational", details={"version": "1.0.0"}, ) def Shutdown(self, request, context): print(f"Shutdown: {request.reason}") return plugin_pb2.ShutdownResponse(success=True) def OnEvent(self, request, context): print(f"Event: {request.event_type} for repo {request.repo_id}") return plugin_pb2.EventResponse(handled=True) def HandleHTTP(self, request, context): return plugin_pb2.HTTPResponse(status_code=501) def GetManifest(self, request, context): return plugin_pb2.PluginManifest( name="My Python Plugin", version="1.0.0", ) def serve(): server = grpc.server(futures.ThreadPoolExecutor(max_workers=4)) plugin_pb2_grpc.add_PluginServiceServicer_to_server(MyPlugin(), server) server.add_insecure_port("[::]:9090") server.start() print("Plugin listening on :9090") server.wait_for_termination() if __name__ == "__main__": serve() ``` ## Debugging **Server logs** show plugin lifecycle events: ``` [I] Loaded external plugin config: my-plugin (managed=false) [I] External plugin my-plugin is online (managed=false) [W] Health check failed for plugin my-plugin: connection refused [E] Plugin my-plugin is now offline after 3 consecutive health check failures [I] Plugin my-plugin is back online [I] Shutting down external plugin: my-plugin ``` **Tips:** - Use `HEALTH_TIMEOUT = 10s` during development to avoid false positives - Set `HEALTH_CHECK_INTERVAL = 5s` for faster feedback during testing - Check that your plugin supports cleartext HTTP/2 (h2c) - this is the most common connection issue - Use `grpcurl` to test your plugin's gRPC service independently: ```bash # List services grpcurl -plaintext localhost:9090 list # Call HealthCheck grpcurl -plaintext localhost:9090 plugin.v1.PluginService/HealthCheck # Call Initialize grpcurl -plaintext -d '{"server_version": "3.0.0"}' \ localhost:9090 plugin.v1.PluginService/Initialize ```