| title | Tier-2 plugin protocol | ||||
|---|---|---|---|---|---|
| description | The wire contract for separately-shipped pg_hardstorage plugins — discovery, handshake, RPC. | ||||
| tags |
|
Tier-2 plugins are separate executables that
pg_hardstorage discovers at startup and invokes per
operation. Crash-isolated, language-agnostic, no shared
library ABI to break across Go versions.
This page documents the wire contract. The Go-language
helper for plugin authors lives in
internal/plugin/external/protocol.go (host side and
plugin-side dispatcher both); the gRPC-shaped contract
that v1.1 will move to is at proto/plugin/v1/plugin.proto.
!!! note "Two protocol shapes"
pg_hardstorage v1.0 ships a stdio JSON-RPC
protocol (pg_hardstorage.plugin.v1) for Tier-2
plugins. The SPEC and the in-repo .proto file
describe the gRPC-over-hashicorp/go-plugin contract
that v1.1 will adopt. Both are versioned v1
intentionally — the gRPC shape is a strict superset
and the host will negotiate which one a plugin
speaks via the --probe response.
This page documents the **shipped** stdio JSON-RPC
protocol. The gRPC contract is documented at the
section heading below; reference your `.proto` file
for the canonical message definitions.
The host walks every directory in $HSPLUGIN_PATH (or the
default /usr/local/lib/pg_hardstorage/plugins:/usr/lib/pg_hardstorage/plugins),
looks at every executable file whose name starts with
pg-hardstorage-plugin-, and invokes each with the
single argument --probe and the env var
PG_HARDSTORAGE_PLUGIN=1.
The plugin writes one JSON object on stdout and exits:
{
"protocol": "pg_hardstorage.plugin.v1",
"name": "my-storage",
"kind": "storage",
"schemes": ["myproto"],
"version": "1.2.3"
}| Field | Required | Notes |
|---|---|---|
protocol |
Yes | Must equal "pg_hardstorage.plugin.v1". Mismatched protocol = refusal. |
name |
Yes | Unique within kind. Used by the registry; surfaces in pg_hardstorage doctor. |
kind |
Yes | One of storage, sink, kms, compression, renderer. |
schemes |
For storage and kms |
URL schemes the plugin claims (["myproto"] for storage, ["my-kms"] for kms). |
version |
Optional | Plugin's own SemVer. Surfaces in audit events and pg_hardstorage doctor. |
- 5-second timeout. A hung plugin can't stall startup; the host kills it after 5 s.
- Probe failures are non-fatal. A bad plugin warns via the host's logger but never blocks startup.
- One probe per process lifetime. The probe
response is cached in the in-memory
external.Registryfor the rest of the run.
// In your plugin's main():
import "github.com/cybertec-postgresql/pg_hardstorage/internal/plugin/external"
func main() {
if !external.IsPluginInvocation() {
fmt.Fprintln(os.Stderr, "this binary is a pg_hardstorage plugin; do not run directly")
os.Exit(2)
}
if len(os.Args) > 1 && os.Args[1] == "--probe" {
_ = external.EmitProbeResponse(os.Stdout,
"my-storage", "storage", []string{"myproto"}, "1.2.3")
return
}
// ... handle RPC ...
}For each operation the host wants to run, it spawns a
fresh process (no --probe flag) and speaks JSON-RPC
over stdio: one request line in, one response line out,
process exits.
{"method":"Storage.Put","params":{"key":"...","data":"...","if_not_exists":true}}A single line of JSON on stdin, terminated with \n.
Method names mirror the Tier-1 plugin interface methods:
| Tier | Methods |
|---|---|
Storage |
Storage.Put, Storage.Get, Storage.Stat, Storage.List, Storage.Delete, Storage.Rename, Storage.SetRetention, Storage.FreeSpace |
Sink |
Sink.Open, Sink.Emit, Sink.Close |
KMS |
KMS.Wrap, KMS.Unwrap, KMS.Shred, KMS.FIPSMode |
Compression |
Compression.Compress, Compression.Decompress |
Renderer |
Renderer.Render |
Method params shapes match the Tier-1 method
signatures, marshalled as JSON. Bytes are
base64-encoded. See proto/plugin/v1/plugin.proto for
the canonical message shapes — the JSON-RPC marshalling
follows the same field names.
{"result":{"etag":"abc","already_existed":false}}…or on error:
{"error":{"code":"storage.not_found","message":"object not found: foo"}}A single line of JSON on stdout, terminated with \n.
Exactly one of result / error is set. The plugin
exits after writing the line.
error.code follows the v1 schema's error.code
namespace:
| Code | Meaning |
|---|---|
storage.not_found |
Key absent (mapped to ErrNotFound) |
storage.already_exists |
IfNotExists violated (mapped to ErrAlreadyExists) |
storage.checksum_mismatch |
ContentSHA256 disagreed |
storage.unsupported |
Backend doesn't support the requested op |
auth.permission_denied |
Backend refused auth |
kms.unwrap_failed |
DEK unwrap failed (mapped to ErrUnwrap) |
kms.shred_failed |
Shred refused / failed |
plugin.parse_request |
Plugin couldn't parse the request line |
plugin.unknown_method |
Plugin doesn't implement this method |
plugin.method_error |
Generic handler error |
plugin.marshal_result |
Plugin couldn't marshal its result |
import (
"encoding/json"
"github.com/cybertec-postgresql/pg_hardstorage/internal/plugin/external"
)
func main() {
if external.IsPluginInvocation() && len(os.Args) > 1 && os.Args[1] == "--probe" {
_ = external.EmitProbeResponse(os.Stdout, "my-storage", "storage",
[]string{"myproto"}, "1.2.3")
return
}
handlers := map[string]external.Handler{
"Storage.Put": func(params json.RawMessage) (any, error) {
var req PutRequest
if err := json.Unmarshal(params, &req); err != nil {
return nil, err
}
// ... do the work ...
return PutResponse{ETag: etag, AlreadyExisted: false}, nil
},
"Storage.Get": func(params json.RawMessage) (any, error) { /* ... */ },
// ... rest of the methods ...
}
if err := external.ServeRPC(os.Stdin, os.Stdout, handlers); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}external.ServeRPC reads one request, dispatches to the
named handler, writes one response, returns.
Long-lived plugin daemons need:
- a supervisor (lifecycle, restart-on-crash)
- a concurrency model (request multiplexing)
- a shutdown protocol
One-shot exec-per-call avoids all three. Cost: TLS handshake / SDK init runs once per call rather than once per process. Acceptable for the operations Tier-2 plugins do (init repo, refresh state, probe credentials); the hot path (chunk I/O during a backup) stays Tier-1.
A future v1.1+ may layer a long-lived mode on top of this protocol; the v1 contract is the one-shot shape.
Default RPC timeout is 30 seconds. Configurable per-
plugin via pg_hardstorage.yaml:
plugins:
- name: my-storage
timeout: 5mSlow plugins (cloud SDK init, network round-trip) push
this up. No upper bound enforced by the host — the
per-plugin timeout: field (above) is the override path.
proto/plugin/v1/plugin.proto defines the gRPC contract
v1.1 will move to. The shape:
- One service per plugin tier (
StoragePlugin,SinkPlugin,EncryptionPlugin, ...). - Every service has a
HandshakeRPC that exchangesPluginInfo+Capabilities. - Streaming RPCs for
GetandList(vs the JSON-RPC's send-everything-at-once shape). - Transport:
hashicorp/go-plugin(gRPC-over-stdin, with the lib's mTLS handshake).
Plugin authors who want forward compatibility can
generate stubs from the proto today and gate the
implementation behind a build tag; the host will accept
either protocol via --probe's protocol field.
Tier-2 plugins are an operator-trust decision. The host:
- Logs the plugin path, name, version, and probe response in audit events at startup.
- Records the binary's SHA-256 in
pg_hardstorage doctoroutput. - Refuses to launch a plugin not in
$HSPLUGIN_PATH(so a poisoned binary in~/Downloadscan't be invoked accidentally). - Honours
--no-tier2-pluginsto disable Tier-2 discovery entirely (FIPS-strict environments).
pg_hardstorage does NOT verify plugin signatures
against a registry today. When the public registry
(registry.pghardstorage.org) lands post-v1.0, the
binary will gain --require-signed-plugins to verify
cosign signatures against the registry root.
$ pg_hardstorage plugin list
NAME KIND VERSION PATH
my-storage storage 1.2.3 /usr/local/lib/pg_hardstorage/plugins/pg-hardstorage-plugin-my-storage
example-sink sink 0.1.0 /usr/local/lib/pg_hardstorage/plugins/pg-hardstorage-plugin-example-sinkWhen no plugins are present (or HSPLUGIN_PATH is empty and the
default dirs don't exist), the command exits 0 with the body
no Tier-2 plugins discovered (...) — operators can wire it into
a doctor check without special-casing "nothing installed."
Probe failures (a binary on the path that exits non-zero or
emits a non-v1 protocol) surface as warning events during the
host's discovery pass, so they appear in the agent's normal
event stream as well as in audit query.
- Per-tier interface contracts: Storage, Sink, Encryption, Compression, Renderer.
- The gRPC contract:
proto/plugin/v1/plugin.proto. - Host-side discovery & client:
internal/plugin/external/protocol.go. - The
pg_hardstorage pluginCLI reference. - Tier-1 vs Tier-2: choosing a plugin tier.