2
0
Files
gitcaddy-server/PLUGINS.md
logikonline e932582f54 docs(plugins): add comprehensive plugin development guide
Add PLUGINS.md with complete documentation for building external GitCaddy plugins using the gRPC-based plugin protocol.

Documentation includes:
- Protocol overview and service definition
- Lifecycle diagram (Initialize → HealthCheck → OnEvent/HandleHTTP → Shutdown)
- Complete message reference for all 6 RPC methods
- Plugin manifest specification (routes, events, permissions, license tiers)
- Health monitoring and auto-restart behavior
- Configuration guide for external vs managed mode
- Transport details (h2c/HTTP2, gRPC wire format)
- Full working examples in Go, C#, and Python
- Debugging tips and common issues

Also updates README.md to reference the plugin guide and removes outdated Chinese translations (zh-cn, zh-tw) that were not being maintained.

This provides plugin developers with everything needed to build and deploy external services that integrate with GitCaddy's plugin framework.
2026-02-13 01:54:25 -05:00

17 KiB

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

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.

Service Definition

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<string, string> Server-provided configuration key-value pairs

InitializeResponse

Field Type Description
success bool Whether initialization succeeded
error string Error message if success is false
manifest PluginManifest The plugin's capability manifest

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<string, string> 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<string, string> HTTP headers
body bytes Request/response body
query_params map<string, string> 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.

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.

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

Configuration

Plugins are configured in the server's app.ini.

External Mode

The plugin runs independently. The server connects to its gRPC endpoint.

[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.

[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.<name>] 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.

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

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", req.Msg.ServerVersion)
    return connect.NewResponse(&pluginv1.InitializeResponse{
        Success: true,
        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:

[plugins.my-plugin]
ENABLED = true
ADDRESS = localhost:9090
SUBSCRIBED_EVENTS = repo:push

Example: C# Plugin

using Grpc.Core;
// Assumes plugin.proto is included in the .csproj with GrpcServices="Server"

public class MyPlugin : PluginService.PluginServiceBase
{
    public override Task<InitializeResponse> 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,
            Manifest = manifest
        });
    }

    public override Task<HealthCheckResponse> HealthCheck(
        HealthCheckRequest request, ServerCallContext context)
    {
        return Task.FromResult(new HealthCheckResponse
        {
            Healthy = true,
            Status = "operational"
        });
    }

    public override Task<ShutdownResponse> Shutdown(
        ShutdownRequest request, ServerCallContext context)
    {
        return Task.FromResult(new ShutdownResponse { Success = true });
    }

    public override Task<EventResponse> OnEvent(
        PluginEvent request, ServerCallContext context)
    {
        Console.WriteLine($"Event: {request.EventType} for repo {request.RepoId}");
        return Task.FromResult(new EventResponse { Handled = true });
    }
}

Program.cs:

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<MyPlugin>();
app.Run();

Example: Python Plugin

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, 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:
# 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