A service that runs protobuf code-generation plugins so that developers and CI
do not have to install them. A client sends a
google.protobuf.compiler.CodeGeneratorRequest over gRPC and names a plugin;
the service executes that plugin as a local child process — bounded in time,
concurrency and output size, but not sandboxed — and returns the
CodeGeneratorResponse. One registry of plugin versions replaces one
installation per machine.
Module: github.com/easyp-tech/service · Docs for operators:
easyp.tech/docs/api-service ·
Client: easyp CLI, or the Go SDK in
sdk/
Every release publishes an image, a Helm chart and the binaries. Pick one.
Image — ghcr.io/easyp-tech/service, multi-arch (amd64, arm64):
| Tag | Meaning |
|---|---|
v1.0.5 |
a release; immutable |
latest |
the newest release — only moves on a release tag |
edge |
the tip of master; moves on every push |
sha-<short> |
one commit; immutable |
Helm — the chart is in the same registry. It refuses to install without a database DSN, so bring a secret first:
kubectl create secret generic easyp-env \
--from-literal=DB_POSTGRES_DSN='postgres://user:pass@host:5432/easyp?sslmode=require'
helm install easyp oci://ghcr.io/easyp-tech/charts/easyp-service \
--version 1.0.5 \
--set secrets.existingSecret=easyp-env \
--set tls.enabled=falseThat is a running service with no transport security and no writes enabled —
enough to confirm it works, not enough to expose. The chart's
README covers TLS, mTLS, the licence,
ServiceMonitor, PrometheusRule and what each secret key means.
Compose — deploy/docker-compose.yml is the full development stack:
Postgres, an S3-compatible store, the observability suite and traefik in front
of the service. Quick start walks through it.
Binaries — easyp-svc for linux and darwin, amd64 and arm64, on the
releases page. The same binary
is the server (service start) and the operator's CLI (plugins, config,
auth, api, health).
- Docker with the compose plugin (
docker compose, notdocker-compose) - Task
- Go 1.26+ to run the CLI from source; otherwise a release binary on
PATH - easyp to generate code from the client side
Plugins are built from the Dockerfiles in registry/, pushed to the stack's
object store, then registered with the service. The whole catalogue is 80
plugins in several hundred versions and takes hours; a filter builds one:
# 1. Build plugin binaries — the two that easyp.local.yaml asks for.
# `task build-plugins` builds the whole registry.
FILTER='protocolbuffers/go:v1.36.10' task build-plugins-filter
FILTER='grpc/go:v1.6.2' task build-plugins-filter
# 2. Generate development certificates and start everything.
task up
# 3. Upload the archives, then register them.
task push-plugins
task register-plugins
# Or all four in one go, tailing the service log at the end:
task runThe gRPC port is not published on the host: the way in is traefik at
easyp.api.localhost:4443, which terminates TLS and speaks mutual TLS to the
service. task register-plugins already knows that. To see what got
registered:
easyp-svc api descriptor -o api.protoset
grpcurl -protoset api.protoset -cacert deploy/certs/ca.crt \
easyp.api.localhost:4443 easyp.generator.v1.GeneratorAPI/PluginsGrafana is on localhost:3000 (admin / admin).
For working on the service itself: no traefik, no TLS, no object store.
task run-local starts the service from deploy/config/config.local.yml,
plaintext on 8080, and registers whatever is in plugins/ once it is ready.
FILTER='protocolbuffers/go:v1.36.10' task build-plugins-filter
FILTER='grpc/go:v1.6.2' task build-plugins-filter
task up-minimal # Postgres only; on 5433 if 5432 is taken (EASYP_POSTGRES_PORT)
task run-local
easyp --cfg easyp.local.yaml generate # in another terminal
go run ./cmd/mcp-smoke --endpoint http://localhost:8083/mcpOn macOS this registers plugins but cannot run them. plugins build is a
Docker build, so the binaries it extracts are Linux binaries, and a service
running natively on macOS gets exec format error from the first
GenerateCode. Use the compose stack there — the service runs in a Linux
container — and keep the from-source path for Linux hosts.
curl -i http://localhost:8082/live # liveness: 200 as soon as the listener is bound
curl -i http://localhost:8082/ # readiness: 200 once Postgres answers, 503 before
easyp-svc health --addr localhost:8082 # the same probe as a command; exit 0 or 1
curl http://localhost:8081/metricseasyp-svc health is what the image's HEALTHCHECK runs. See
Health and probes for why it asks /live and not /.
A plugin becomes remote by naming the service in front of it:
generate:
plugins:
- remote: "plugins.example.com/protocolbuffers/go:v1.36.10"
out: gen/go
opts:
paths: source_relative
- remote: "plugins.example.com/grpc/go:latest"
out: gen/go
opts:
paths: source_relativeeasyp.local.yaml in this repository does exactly that against a service run
from source.
import "github.com/easyp-tech/service/sdk"
// The SDK defaults to TLS with the system trust store. Add
// sdk.WithTransportCredentials for a private CA, or sdk.WithInsecure() for a
// plaintext local service.
client, err := sdk.NewClient(
"localhost:8080",
sdk.WithInsecure(),
sdk.WithMaxRetries(3),
sdk.WithRetryBaseDelay(time.Second),
sdk.WithHealthCheck(30*time.Second),
)
if err != nil {
return err
}
defer client.Close()
// codeGenRequest is a *pluginpb.CodeGeneratorRequest.
response, err := client.GenerateCode(ctx, "protocolbuffers/go:v1.36.10", codeGenRequest)sdk/ is its own module under Apache-2.0; see License for why.
The contract is easyp.generator.v1.GeneratorAPI in
api/easyp/generator/v1/generator.proto:
service GeneratorAPI {
rpc GenerateCode(GenerateCodeRequest) returns (GenerateCodeResponse);
rpc Plugins(PluginsRequest) returns (PluginsResponse);
rpc CreatePlugin(CreatePluginRequest) returns (CreatePluginResponse);
rpc UpdatePlugin(UpdatePluginRequest) returns (UpdatePluginResponse);
rpc DeletePlugin(DeletePluginRequest) returns (DeletePluginResponse);
}
message GenerateCodeRequest {
google.protobuf.compiler.CodeGeneratorRequest code_generator_request = 1;
string plugin_name = 2; // "group/name:version" or "group/name:latest"
}The server does not serve reflection. easyp-svc api descriptor writes a
FileDescriptorSet for grpcurl -protoset, as in the quick start.
Every non-OK status carries a google.rpc.ErrorInfo with domain easyp.tech
and a reason a client can branch on — NOT_FOUND, INVALID_PLUGIN_NAME,
INVALID_CONFIG, GENERATION_FAILED, SERVER_OVERLOADED, ALREADY_EXISTS,
MAX_PLUGINS_EXCEEDED, SHUTTING_DOWN, STORAGE_UNAVAILABLE,
BINARY_NOT_UPLOADED, FEATURE_DENIED, DEADLINE_EXCEEDED. The gRPC code is
a category and the message is prose; the reason is the part that is promised
to stay. They are documented in the proto next to the RPC that returns them.
An HTTP endpoint at /mcp (streamable transport) for AI tooling, with one tool:
plugins_list, the catalogue with optional group, name, version and
tags filters, paginated.
Opt-in. The listener is off unless mcp.enabled: true (env MCP_ENABLED):
it serves plain HTTP outside the gRPC interceptor chain — no TLS, no rate limit,
no audit — so a deployment decides whether that surface exists. It is read-only
and exposes nothing the anonymous gRPC reads do not. easyp-tech's own configs
under deploy/config/ enable it; the Helm chart ships it off.
{
"mcpServers": {
"easyp": { "url": "http://localhost:8083/mcp" }
}
}Handler tests: task test-mcp. Live check against a running endpoint:
task smoke-mcp.
Settings resolve in one order, whether the service was started with --cfg or
without it:
- the YAML file, if one was given;
- the environment, which overrides the file;
- the
default=on the field, which fills only what neither supplied.
The file can also be named by EASYP_CONFIG instead of --cfg. The image's
HEALTHCHECK depends on that: the probe runs without a command line of its own
and finds the health port through the same variable.
A variable that is set but empty counts as not set, so the "${VAR:-}" form used
throughout deploy/ leaves the file's value alone when the variable is not
exported. This is what lets a secret — DB_POSTGRES_DSN, AUTH_WRITE_TOKENS,
LICENSE_KEY, REGISTRY_S3_SECRET_ACCESS_KEY — stay out of a committed config.
One name does not follow the field it sets: db.postgres is DB_POSTGRES_DSN.
Everything else is the dotted key upper-cased, with . becoming _.
telemetry.otlp_endpoint also accepts the standard OTEL_EXPORTER_OTLP_ENDPOINT
when TELEMETRY_OTLP_ENDPOINT is unset; config print --origin names whichever
one supplied the value.
An unrecognised key is an error, not a warning: the service refuses to start and
config validate exits non-zero. It used to be a warning next to a successful
start, which made "I configured this" and "I mistyped this" produce the same
running service. The same goes for a key copied from the chart's values.yaml —
those are camelCase and this file is snake_case, and the error says which key was
meant.
An unrecognised environment variable carrying a section prefix is reported as a warning, and named on startup.
# The variables an operator actually sets. For the rest — every setting has one —
# ask the binary: `easyp-svc config print --origin`.
LOG_LEVEL=info
DB_POSTGRES_DSN="postgres://user:pass@localhost/db"
# Credentials for the mutating RPCs: "<name>=<64 hex>,<name>=<64 hex>".
# Generate with `easyp-svc auth new-token`.
AUTH_WRITE_TOKENS="ci=<64 hex>"
# Licence. Absent means community mode.
LICENSE_KEY=
LICENSE_PUBLIC_KEYS="<kid>:<64 hex>"
# Object storage; enabled by the bucket being set.
REGISTRY_S3_ENDPOINT="http://rustfs:9000"
REGISTRY_S3_BUCKET="easyp-plugins"
REGISTRY_S3_ACCESS_KEY_ID="rustfsadmin"
REGISTRY_S3_SECRET_ACCESS_KEY="rustfsadmin"
# Telemetry; empty means no exporter is built.
TELEMETRY_OTLP_ENDPOINT="easyp-alloy:4317"Two commands answer the two questions a config file raises, and neither needs the service to be running:
# Would the service start on this? Exits non-zero if not.
easyp-svc config validate --cfg deploy/config/config.yml
# What will actually apply, and which layer supplied each value?
easyp-svc config print --cfg deploy/config/config.yml --origin
# What does this deployment change from the built-in defaults? The output is
# exactly what the file needs to contain.
easyp-svc config print --cfg deploy/config/config.yml --origin --changedSecrets print as *** unless --show-secrets is given. With no --cfg, both
commands read the environment alone, which is the shape a Helm deployment had
before the chart began rendering a file.
The service prints the same summary itself, at info, on every start — so the
question is answerable inside a container where these commands are not to hand.
The binary's defaults are 23410 (gRPC), 23411 (metrics), 23412 (health)
and 23413 (MCP). Every configuration under deploy/ moves them to
8080–8083, and so do the examples in this file — a port seen in a compose
file or a log line is a deployment's choice, not the default. plugins register
dials localhost:23410 unless told otherwise.
| File | Purpose |
|---|---|
deploy/config/config.yml |
Docker-compose service config (internal hostnames) |
deploy/config/config.local.yml |
Local development config (localhost, port 5433) |
deploy/config/config.community.dev.yml |
Two-tier dev stack, unlicensed container |
deploy/config/config.enterprise.dev.yml |
Two-tier dev stack, licensed container |
The two tier configs ship no write tokens and no telemetry endpoints on purpose:
that stack is what deploy/docker-compose.public.yml puts on the internet, and a
committed credential is a published one. Supply them through deploy/.env.dev.
An example, not the defaults — config print --changed against any of the
files above prints exactly what that deployment changes:
server:
host: "0.0.0.0"
port:
grpc: 8080
metric: 8081
health: 8082
mcp: 8083
db:
postgres: "postgres://easyp_svc:easyp_pass@localhost:5433/easyp_db?sslmode=disable"
registry:
plugins_dir: "./plugins"
max_output_size: 67108864
# Optional S3-compatible binary storage. When enabled, plugin archives
# pushed by `easyp-svc plugins push` are lazily downloaded into plugins_dir
# (acting as a local cache) and sha256-verified before unpacking.
s3:
endpoint: "http://localhost:9000"
bucket: "easyp-plugins"
region: "us-east-1"
access_key_id: "rustfsadmin"
secret_access_key: "rustfsadmin"
force_path_style: true
worker_pool:
workers: 4
queue_size: 16
generation_timeout: 120s
max_retries: 3
shutdown_timeout: 30s
rate_limit:
requests_per_second: 10.0
burst: 20
cleanup_interval: 10mThe gRPC listener is configured by server.tls:
server:
tls:
cert_file: "/certs/server.crt"
key_file: "/certs/server.key"
# Present ⇒ mutual TLS. Every certificate this CA issues for client
# authentication is accepted — see the warning below before choosing one.
client_ca_file: "/certs/ca.crt"Leaving cert_file empty serves plaintext; the service logs a warning on every
start so that never happens unnoticed. cert_file and key_file must be set
together, and client_ca_file alone is rejected at startup.
Name a CA that exists for this service, not your corporate root. The
listener checks that the client certificate chains to client_ca_file and
carries the clientAuth extended key usage. It does not check whose
certificate it is: there is no subject or SAN allow-list. An internal PKI issues
clientAuth certificates to many workloads — that is what it is for — so naming
one here makes every one of those workloads able to create, replace and delete
plugins. A CA scoped to this service keeps the set to the clients you issued.
In the compose stack traefik is the only client holding a certificate, and the
gRPC port is not published to the host — the way in is easyp.api.localhost on
EASYP_TRAEFIK_TLS_PORT (4443 by default), where traefik terminates the edge
certificate and re-establishes mutual TLS toward the service.
# Generate a development CA plus the server, client and edge certificates.
# `task up` runs this for you; FORCE=1 regenerates.
task certs
# Talk to the service through traefik
easyp-svc plugins register plugins \
--addr easyp.api.localhost:4443 --tls-ca deploy/certs/ca.crt --cfg deploy/config/config.ymlClient-side flags: --tls-ca overrides the trust store, --tls-cert/--tls-key
supply a client certificate for a server that enforces mTLS, and --insecure
is the explicit opt-out used against a plaintext local service. TLS is the
default — plaintext is never reached by omitting a flag.
certs/ is gitignored and holds development material only. In production the
paths point at certificates issued by your own CA.
Reads are anonymous. The three mutating methods — CreatePlugin,
UpdatePlugin, DeletePlugin — require a write token:
# Generates the token and prints the config entry that authorises it
easyp-svc auth new-token --name ciThe command prints the token once and a sha256 digest. Only the digest goes
into the configuration, so deploy/config/config.yml stays safe to commit; the token belongs
in your secret manager:
auth:
write_tokens:
- name: "ci"
sha256: "…"Clients pass it with --token, via EASYP_TOKEN, or sdk.WithToken(...). It
travels in the authorization header, so it is only as protected as the
connection — use it over TLS.
Two properties worth knowing:
- An empty token list denies every write. A forgotten configuration breaks plugin registration rather than leaving the registry open.
- Any method not explicitly anonymous requires a token. A new RPC is protected until someone decides otherwise.
The token's name appears in the audit log, so SELECT metadata FROM audit_log
shows which credential performed an operation. Multiple tokens let you rotate
without downtime: add the new one, deploy, remove the old.
auth:
# Demand a credential for reads as well — GenerateCode, Plugins and the MCP
# endpoint. Health is never covered: a probe carries none, and a listener that
# fails its own readiness check never serves anything.
#
# Off by default, because the same binary serves the public plugin catalogue.
# Turn it on for a private registry, where "readable by anything that can
# reach the pod" is not a property anyone chose.
require_authentication: trueWithout a token the service runs in community mode: no audit log, and three ceilings that a licence lifts —
| Setting | Community | Enterprise |
|---|---|---|
worker_pool.workers |
4 | as configured |
worker_pool.max_concurrent_generations |
16 | as configured |
| registered plugins | 10 | unlimited |
Each community ceiling is the shipped default of the setting it caps, so a deployment that never changed one is not affected by the ceiling existing. A configuration above it is lowered at startup, and the service logs which setting was lowered and to what — it is a ceiling, not a substitution, so asking for less than the tier permits gives you less.
The two worker-pool ceilings are read once, at startup. A licence that lapses
past its grace period while the service runs turns audit off and the plugin cap
on at the next licence refresh, but the pool keeps the capacity it started with
until the process restarts — and the restart then lowers it. The service logs
licence tier changed while running; the worker pool keeps the ceilings it started with and applies the new ones on restart at the moment the tier changes,
so the drop does not arrive unannounced with an unrelated deploy.
Enterprise needs two things — a token and the public key it is verified against:
LICENSE_PUBLIC_KEYS=<kid>:<hex> LICENSE_KEY=<paseto-token> task upBoth are read at runtime. The token comes from license.key, then
license.file, then LICENSE_KEY; the public keys from license.public_keys
or LICENSE_PUBLIC_KEYS. Without a public key no token is honoured.
license.public_keys is keyed by the key id in the token's footer. The reserved
key id "*" verifies any token the other entries do not cover, which is what the
removed license.public_key setting used to do.
This service only verifies licences; it does not issue them. Issuing lives in the
licence registry (easyp-tech/licenses), which holds the private signing key,
the record of who was given what, and the easyp-license tool that signs.
Several keys can be configured at once, keyed by the key id in the token footer:
LICENSE_PUBLIC_KEYS="2026-08:<hex>,2026-09:<hex>". That is what lets a signing
key be rotated without every deployment having to change key on the same day.
A key that is not a valid hex Ed25519 key stops startup rather than quietly
dropping the service to community mode.
Because the verification key is configuration, whoever can edit deploy/config/config.yml can
point the service at a different signing authority — protect that file the way
you protect the database password next to it.
Verification is offline and happens in-process: the PASETO v4.public signature is checked against the configured public key, and a token that fails — expired, signed by an unknown key, or malformed — leaves the deployment in community mode rather than stopping it.
A plugin is {group}/{name}:{version}: protocolbuffers/go:v1.36.10,
grpc/go:latest. Group and name are [a-z][a-z0-9-]*; the version is vX.Y
or vX.Y.Z — the patch is optional because protobuf ships versions like
v33.1 — or latest, which resolves to the newest registered version at
call time.
The unit of delivery is the plugin version directory, not a single file: the contract requires plugins/{group}/{name}/{version}/plugin as the entrypoint and allows sidecars next to it (jars, shared libraries, scripts). It is packed as a tar.gz and stored at {group}/{name}/{version}/plugin.tgz.
build machine / CI service
────────────────── ───────
plugins build → plugins/{g}/{n}/{v}/…
plugins push → s3://…/plugin.tgz
plugins register ──── CreatePlugin ────→ streams the archive from S3,
(metadata only) computes sha256, stores it in the DB
GenerateCode ────→ entrypoint missing locally?
download archive → verify sha256
→ unpack → execute
Key properties:
- Push before register. Registering a plugin whose archive is absent fails with
FAILED_PRECONDITION— a registered plugin always has its artifact. - The service computes the checksum, reading the object itself, so a client cannot register a bogus hash. It is re-verified after every download, before anything is executed.
- Credentials split: the build pipeline needs S3 write access; the service only needs read (plus delete for
DeletePlugin). Clients of the gRPC API need no S3 access at all. - Concurrent misses collapse into a single download (singleflight);
plugins_diracts as a local cache. - With S3 disabled, nothing changes from the classic flow: artifacts are read straight from
plugins_dir.
plugins pack --out <dir> writes the same {group}/{name}/{version}/plugin.tgz layout to disk, which plugins push --packed <dir> uploads as it is. Packing on the build machine and uploading later — or from elsewhere — is then two commands instead of one repeated:
# Build machine: pack once.
easyp-svc plugins pack plugins --out plugin-archives
# Anywhere with the archives and S3 credentials.
export AWS_ACCESS_KEY_ID=… AWS_SECRET_ACCESS_KEY=…
easyp-svc plugins push plugin-archives --packed \
--endpoint https://storage.example.com --bucket easyp-plugins --force-path-style \
--parallel 24Uploads run --parallel at a time (8 by default). Object storage commonly rate-limits a single connection far below the link it arrives on, so throughput comes from streams rather than from any one of them: measure one stream, then set --parallel to about the ratio between your uplink and that figure. An interrupted run is resumed by re-running it — archives already in storage are skipped without being re-read.
The S3 settings can also come from a config file: plugins push --cfg accepts a full server configuration and runs it through the same validation as service start — a fragment holding only registry.s3 is refused. That is deliberate: a config the server would reject must not quietly keep working for push, or the two stop agreeing about which store they talk to. To push without a server config, pass the storage settings as flags, as above.
registry/ holds the recipe for every plugin easyp-tech publishes: 80 plugins
in ten groups — protocolbuffers, grpc, grpc-ecosystem, connectrpc,
bufbuild, pluginrpc, apple, anthropics, googlecloudplatform and
community for everything maintained outside those projects. ls registry/ is
the current list; the README does not repeat it.
One directory per plugin, no version directories:
registry/grpc/go/
├── Dockerfile # takes ARG VERSION, produces /plugin
├── plugin.yaml # the versions to build, newest first
└── .dockerignore
# registry/grpc/go/plugin.yaml
versions:
- v1.6.2
- v1.6.1
- v1.5.1easyp-svc plugins build registry reads every plugin.yaml, builds each listed
version with --build-arg VERSION=…, and extracts the image's filesystem to
plugins/{group}/{name}/{version}/ — that directory is the artifact,
plugin inside it the entrypoint. Optional keys: build_args (a map passed to
every build), dockerfile (a different file), args (arguments the entrypoint
is run with); a version may be a mapping that overrides any of those for itself
or sets skip: true.
Filters are globs on group/name, optionally with a version:
--filter 'protocolbuffers/*', --filter 'grpc/go:v1.6.2'. --parallel sets
how many build at once, --force rebuilds what is already in plugins/, and
--dry-run prints the plan. task build-plugins is the whole registry;
FILTER=… task build-plugins-filter is a slice.
Add a directory under the group it belongs to — community/<author>-<tool> if
the project is not one of the named groups — with a Dockerfile and a
plugin.yaml. This is the grpc/go recipe, and it is the template:
# syntax=docker/dockerfile:1.23
FROM --platform=$BUILDPLATFORM golang:1.26.3-trixie@sha256:d08bf3ed2bd263088ca8e23fefaf10f1b71769f6932f0a4017ba28d2a5baf001 AS build
ARG VERSION
ARG TARGETOS TARGETARCH
WORKDIR /tmp
RUN git clone --depth 1 --branch cmd/protoc-gen-go-grpc/${VERSION} https://github.com/grpc/grpc-go.git
WORKDIR /tmp/grpc-go/cmd/protoc-gen-go-grpc
RUN --mount=type=cache,target=/go/pkg/mod \
CGO_ENABLED=0 GOOS=$TARGETOS GOARCH=$TARGETARCH \
go build -o protoc-gen-go-grpc -ldflags "-s -w" -trimpath
FROM scratch
ARG VERSION
COPY --from=build --link /etc/passwd /etc/passwd
COPY --from=build --link --chown=root:root /tmp/grpc-go/cmd/protoc-gen-go-grpc/protoc-gen-go-grpc /plugin
USER nobody
ENTRYPOINT ["/plugin"]What the service needs from the result:
- The entrypoint is
/plugin. It reads aCodeGeneratorRequeston stdin, writes aCodeGeneratorResponseon stdout, and exits non-zero on failure. Sidecars next to it are fine — a jar, anode_modules, a shared library — and are packed with it. ARG VERSIONselects what to build. The Dockerfile is one recipe for every version inplugin.yaml; a version that needs a different recipe gets its owndockerfile:entry.- The final stage is
scratchor distroless, and runs asnobody. The build stage's/etc/passwdis copied across for that. Interpreted plugins (Node, Python, the JVM) use the distroless runtime images —bufbuild/es,community/nipunn1313-mypyandapple/servicetalkshow the shape. - Base images are pinned by digest. A tag moves; the digest is what was built and reviewed.
- Static where the language allows it.
CGO_ENABLED=0,-trimpath,-ldflags "-s -w"for Go.
Then prove it:
FILTER='grpc/go:v1.6.2' task build-plugins-filter
task up-minimal && task run-local
task register-plugins
easyp --cfg easyp.local.yaml generateOpen a pull request with the two files. A request for a plugin someone else should package is the plugin request issue template.
The health listener (server.port.health) serves two paths:
| Path | Answers | Use for |
|---|---|---|
/live |
200 as soon as the listener is bound | liveness: restart the process if this fails |
/ |
200 once Postgres answers, 503 while starting or while the database is unreachable | readiness: take the pod out of rotation |
easyp-svc health probes /live and exits 0 or 1. It backs the image's
HEALTHCHECK and, having no command line of its own, finds the health port
through EASYP_CONFIG — set that on the container to the same file the
service starts with, or the probe falls back to the default port and reports a
container unhealthy while it serves normally. It deliberately probes liveness,
not readiness: readiness checks the database, and restarting a container on
every database blip turns a recoverable outage into a crash loop. The Helm
chart wires its own probes to the two paths and ignores the HEALTHCHECK, as
Kubernetes does.
Every metric carries the easyp namespace; the gRPC ones an api subsystem.
The ones an operator watches:
| Metric | What it says |
|---|---|
easyp_api_grpc_server_handled_total{grpc_code} |
request count by outcome |
easyp_generation_duration_seconds{plugin} |
how long plugins take |
easyp_generation_errors_total{plugin,error_type} |
failed generations; the error-rate alert divides this by easyp_pool_jobs_total |
easyp_pool_active_workers, easyp_pool_queue_depth, easyp_pool_rejected_total |
the worker pool: busy, waiting, turned away |
easyp_plugin_cache_bytes, easyp_plugin_cache_limit_bytes, easyp_plugin_cache_evictions_total |
the local archive cache against registry.cache_max_bytes |
easyp_audit_events_lost_total{reason} |
audit entries dropped — a gap in something Enterprise sells, hence its alert |
easyp_license_valid, easyp_license_expiry_timestamp_seconds, easyp_license_in_grace |
the licence, and when it runs out |
easyp_auth_failures_total{reason}, easyp_rate_limit_requests_total, easyp_concurrency_rejected_total |
who was refused, and why |
easyp_panics_total |
recovered panics; any value but zero is a bug report |
The chart's PrometheusRule alerts on these, and every alert has a section in
the Runbooks.
From v1.0.0 the wire contract, the configuration keys, the error reasons and
the public API of the api and sdk modules are frozen: changing any of them
is a major version. The full list, and what remains possible without one, is at
the top of Upgrading.
Releases that need more than a new image are described there, newest first.
v0.14.0 is the one to read if you are coming from anything older: the gRPC
service was renamed from ServiceAPI to GeneratorAPI, which broke every
client at once — easyp before v0.17.0 included — and the api and sdk
modules were split out. v0.13.0 renamed two environment variables and made the
MCP endpoint opt-in; v1.0.2 added EASYP_CONFIG for the image's health probe.
What a backup has to contain and how to restore it is in Backup and restore.
go build -o bin/easyp-svc ./cmd/easyp-svc
./bin/easyp-svc service start --cfg deploy/config/config.local.yml
# The level is a setting, so it needs no flag; --log_level still overrides it.
LOG_LEVEL=debug ./bin/easyp-svc service start --cfg deploy/config/config.local.yml
go test ./... # unit tests, all three modules from the root
go test -p 1 -tags integration ./test/... ./internal/database/... # needs a Postgres; -p 1 as in CI
golangci-lint run ./... # CI pins the latest v2deploy/docker-compose.dev.yml runs a community container and an enterprise
container side by side, from the same image, so a licence change can be seen
rather than assumed. It needs deploy/.env.dev — copy .env.dev.example and
fill in the licence and the storage keys it explains.
task up-dev # the two tiers
task up-dev-full # plus the observability overlay
task tier-dev # assert the two containers really are different tiers
task logs-dev
task down-devtask deploy-dev HOST=user@host ships deploy/ to a stand over rsync — never
the certificates or env files — and rolls the stack to the version this
repository's compose file names, waiting for both containers to report healthy.
The stand keeps its own .env and certificates; it is not a git checkout.
api/ is generated from the proto by easyp itself, through the public stand,
and CI fails if the committed code differs from a fresh run:
easyp --cfg easyp.yaml generate
go run -tags mcpgen ./test/mcpgenThe generator's version is part of the generated header, so bumping the easyp
CI uses means regenerating even when the proto did not change.
api/ # The wire contract. Its own Go module, Apache-2.0.
easyp/generator/v1/ # .proto, generated stubs, MCP bindings
cmd/easyp-svc/ # The service and its CLI (service, plugins, auth, api, config, health)
cmd/mcp-smoke/ # MCP smoke test client
internal/
core/ # Domain types, interfaces, sentinel errors, worker pool
api/ # gRPC handlers, audit & license interceptors, MCP handler
adapters/ # audit/ metrics/ registry/ storage/ — implement core interfaces
auth/ # Authenticator interface, static write-token digests, Actor
config/ # Loader, diagnostics, env binding, retired keys, aliases
database/ # sqlx wrapper (metrics/tracing), connectors
goosemigrate/ # Embedded SQL migrations (goose v3, advisory-locked)
grpchelper/ # gRPC server/client factories, middleware
license/ # PASETO v4 management, FeatureGate, claims
plugarchive/ # tar.gz pack/unpack with the path and symlink rules
ratelimiter/ # Per-IP token bucket and concurrency cap, both gated
safe/ # Panic-guarded goroutine helper
serve/ # HTTP and gRPC listener lifecycles
telemetry/ # OTLP + Pyroscope, tracing decorators
monitor/ # Context-aware slog logger
flags/ # CLI flag types
sdk/ # Go client SDK. Its own module, Apache-2.0.
registry/ # Plugin recipes: one directory per plugin, Dockerfile + plugin.yaml
plugins/ # Built plugin binaries (gitignored)
test/ # Integration tests (build tag `integration`) and CI helpers
test_registry/ # Plugin recipes the tests build against
deploy/
docker-compose.yml # Full dev stack
docker-compose.dev.yml # Community and enterprise side by side
docker-compose.observability.yml # Overlay for the two-tier stack
docker-compose.public.yml # Overlay that puts the two-tier stack on the internet
.env.example, .env.dev.example # Templates for the two stacks
config/ # Service configs, one per way of running it
observability/ # Alloy, Grafana, Loki, Tempo, Mimir, Pyroscope, Traefik
charts/easyp-service/ # Helm chart
scripts/ # gen-dev-certs.sh, check-tiers.sh, deploy-dev.sh
certs/ # Throwaway dev TLS material (gitignored)
easyp.yaml, easyp.local.yaml, easyp.*.dev.yaml # easyp configs: CI, from-source, the two tiers
Taskfile.yml
Things this service does not do, written down because a version number invites people to assume otherwise. None of these is a bug report; they are the shape of the thing as it stands.
A plugin is a binary the service runs as a local child process. Not a container.
The service bounds its time (worker_pool.generation_timeout), its
concurrency (worker_pool.max_concurrent_generations), the size of its
output (registry.max_output_size), its environment (only what the
plugin's own config declares — the service's database and object-storage
credentials are not inherited), and where its executable may live (inside
registry.plugins_dir).
It does not bound memory, CPU, filesystem access beyond the service account's own, or network access: a plugin reaches whatever the pod reaches.
So registering a plugin is as privileged as running code on the host. Treat the write token accordingly, and treat a shared registry as a set of people who all trust each other.
One list of write tokens (auth.write_tokens), stored as digests. No tenants,
no roles, no per-plugin ownership. The audit trail records a token's label and
the caller's IP, so two engineers sharing a CI token are indistinguishable in it.
GenerateCode and Plugins are anonymous by default, because the same
binary serves the public catalogue, where demanding a credential to fetch a
well-known plugin would break every client. Set
auth.require_authentication: true and every RPC but health needs a write
token — including the MCP endpoint, which is plain HTTP outside the interceptor
chain and is wrapped separately.
That is a single shared credential, not identity: it decides whether a caller may read, never which caller is reading.
The database side is safe: migrations take a Postgres session lock and audit partition maintenance takes an advisory lock, so several processes cannot collide there.
The plugin cache cannot. Unpacking finishes with a remove followed by a rename —
two steps, not atomic — and the only thing serialising it is an in-process
lock. Two pods on one ReadWriteMany volume race: one can delete a directory
the other is reading, and a rename onto a directory recreated in between fails.
Each pod also keeps its own in-memory accounting of the same shared bytes, so
registry.cache_max_bytes is applied once per pod to one volume.
The chart's defaults are honest about this — one replica, ReadWriteOnce,
Recreate. It permits replicaCount > 1 with a ReadWriteMany volume, and
that combination is not supported: it will appear to work and corrupt the
cache under concurrent misses for the same plugin. Scale by making the single
pod bigger — maxConcurrentGenerations and CPU — not by adding pods.
Migrations run forward automatically at startup. The down-migrations are written
and tested, and nothing can run them: there is no migrate subcommand.
Rolling back to an older image is permitted rather than blocked — an older
binary starts against a newer database and applies nothing. Its limit is time:
it has no maintainer for partitions introduced by a migration it does not know,
so once the pre-created months run out, audit rows land in the default
partition, which then blocks creating those months on the way forward. Treat a
rollback as bounded by audit.pre_create_months (three by default). See
Backup and restore.
Licence verification is real — PASETO v4 public-key signatures, issuer and
audience checked, expiry with the grace period the token carries. The public
half is supplied by license.public_keys, which is part of the deployment's
configuration. Whoever can edit those values decides which authority may issue
licences for that installation.
Mutual TLS is real: with server.tls.client_ca_file set, the listener requires a
client certificate, verifies it chains to that CA, and — through Go's TLS stack —
requires the clientAuth extended key usage, so a server or edge certificate
from the same CA is refused.
What it does not do is look at who the certificate says it is. No common name, no SAN allow-list, no mapping to an identity: the audit trail still records the write token's label, not the certificate's subject. So the CA named there is the entire check, and every certificate it issues for client authentication is a write credential for this installation.
sdk.Client.ListPlugins returns []*generator.PluginInfo — the generated type
from the api module, not a type the SDK owns. That is the usual shape for a
gRPC client and it avoids a mirror of every message, but the consequence is
permanent: a v2 of the wire contract is a v2 of the client, whatever else the
client's own surface does. The two modules version separately and are released
in lockstep for that reason.
ListPlugins also walks every page and returns the whole registry. Each request
gets its own timeout, so the walk finishes on a large registry, but there is no
way to ask for one page — a caller that wants to stream results needs a method
that does not exist yet.
sdk depends on api, and the generated MCP tool registration lives in the
same Go package as the wire types — protoc-gen-mcp writes into the proto's own
package and offers no way to put it elsewhere. So an SDK consumer links the MCP
SDK, a JSON-schema library and OAuth2 whether or not they use any of it. Moving
that registration into a library both this service and easyp can depend on is
planned, and is a change to api's dependencies rather than to its API.
The repository holds three Go modules — the service, api/ and sdk/ — tagged
in lockstep as vX.Y.Z, api/vX.Y.Z and sdk/vX.Y.Z, so that one version
number means one state of all three. Only the service module is under the
Elastic License; see License.
A release tag produces, in this order:
- binaries for linux and darwin, amd64 and arm64, each with an SBOM
(
*.sbom.json) beside it; - the image, built for both architectures and scanned by Trivy before anything is pushed — a HIGH or CRITICAL finding stops the release rather than getting pulled after the fact;
- the image and its manifest list, signed with cosign in keyless mode — the signature names the workflow that produced it, not a key anyone holds;
- the Helm chart, pushed to
oci://ghcr.io/easyp-tech/charts/easyp-service; - the GitHub release with
checksums.txt.
To verify an image before running it:
cosign verify ghcr.io/easyp-tech/service:v1.0.5 \
--certificate-identity-regexp '^https://github.com/easyp-tech/service/\.github/workflows/release\.yml@refs/tags/v' \
--certificate-oidc-issuer https://token.actions.githubusercontent.comedge and sha-<short> tags come from a separate workflow on every push to
master; they are built, not released — no SBOM, no signature, no chart.
# The stack
docker compose -f deploy/docker-compose.yml ps
docker compose -f deploy/docker-compose.yml logs service
# What the service resolved its configuration to, and from where
easyp-svc config print --cfg deploy/config/config.yml --origin
# Plugins: what is built, what is registered
ls plugins/
easyp-svc api descriptor -o api.protoset
grpcurl -protoset api.protoset -cacert deploy/certs/ca.crt \
easyp.api.localhost:4443 easyp.generator.v1.GeneratorAPI/Plugins
grpcurl -protoset api.protoset -plaintext \
localhost:8080 easyp.generator.v1.GeneratorAPI/Plugins # from-source service
# The database
docker exec -it easyp-postgres psql -U easyp_svc -d easyp_db -c 'SELECT group_name, name, version FROM plugins;'task up failing with ports are not available … 9001 means something on the
host already listens there; EASYP_RUSTFS_CONSOLE_PORT=9011 task up moves the
object store's console aside. Nothing on the host needs it.
A CreatePlugin that fails with FAILED_PRECONDITION means the archive was
never pushed: task push-plugins comes before task register-plugins.
plugins register paces itself when the server's rate limit refuses a burst,
but its default --parallel of 8 against a server left at the default
rate_limit.max_concurrent_per_ip of 2 still loses most of the batch — the
configs under deploy/ raise the cap; against any other server pass
--parallel 2. A
GenerateCode that fails with GENERATION_FAILED and a plugin's stderr in the
message is the plugin refusing the input — a proto without go_package, for
instance — not the service.
EasyP Service is source available, not open source.
| Part | License |
|---|---|
api/ — the generated gRPC contract |
Apache License 2.0 |
sdk/ — the Go client library |
Apache License 2.0 |
| Everything else — the service itself | Elastic License 2.0 |
The Elastic License 2.0 lets you use, copy, modify and redistribute the service free of charge, production included. It forbids three things: offering the service to third parties as a hosted or managed service, circumventing the license key mechanism that gates Enterprise features (see Licensing), and removing license notices.
Community mode needs no license key and stays free under those terms. What Enterprise adds today is the audit log and the removal of the three community ceilings — 4 workers, 16 concurrent generations, 10 registered plugins — as the Licensing table says.
The client SDK and the API contract it is generated from are both Apache 2.0, so
they can be imported into your own code without inheriting any of the above. The
two go together deliberately: sdk/ imports api/, and licensing only the
client would leave anyone writing one compiling Elastic-licensed code anyway.
Talking to this service is not restricted; running it is.
Releases up to and including v0.8.0 were published under Apache 2.0 and remain
available under those terms; the Elastic License 2.0 applies from the next
release onward.
Issues in this repository, with a template for each kind: a bug (it asks for the version, the licence tier and how the service is deployed — the three things every diagnosis starts with), a feature, or a plugin you would like packaged.