| title | Build a Tier-2 storage plugin | |||
|---|---|---|---|---|
| description | Author and ship an external storage plugin — discovery, probe handshake, JSON-RPC over stdio, putting bytes. | |||
| tags |
|
Walks through writing a working external storage plugin from scratch, in Go: probe handshake, JSON-RPC over stdio, registering a custom URL scheme, smoke-testing it against a real
pg_hardstorageinstall. About 45 minutes if you have Go on your machine; the same protocol works in any language that can read stdin and write stdout.
pg_hardstorage ships with two plugin tiers:
- Tier-1 — in-tree Go interfaces, statically linked. The fast path: chunk I/O during a backup never crosses a process boundary. S3, FS, Azure, GCS all live here.
- Tier-2 — separate executables discovered at startup, talking one-shot JSON-RPC over stdio. Crash-isolated, language-agnostic, no shared-library ABI to break across Go versions.
You author Tier-2. The host launches your binary on demand for each RPC; persistence and concurrency are not your problem.
For the precise contract — every method signature, every error code, the full handler set — see the Tier-2 plugin reference (Agent F). This page is the guided "build one" walkthrough.
- Go 1.22 or later.
pg_hardstoragev0.2 or later on$PATH.- A scratch directory (
/tmp/hs-plugin-tutorialis fine). - One terminal.
The plugin we build is pg-hardstorage-plugin-mem — an in-memory
"storage" backend that stores objects in a JSON file on disk so the
test surface is observable. Useful as a teaching example, not for
real workloads.
The host walks $HSPLUGIN_PATH (default
/usr/local/lib/pg_hardstorage/plugins:/usr/lib/pg_hardstorage/plugins)
for executables prefixed pg-hardstorage-plugin-. Each candidate is
launched once with --probe and PG_HARDSTORAGE_PLUGIN=1 in the
environment; it must write exactly one JSON object to stdout
declaring its protocol, name, kind, and schemes, then exit. For
every operation thereafter, the host re-launches the binary, writes
one JSON-RPC request line to stdin, reads one response line
from stdout, and reaps the process.
That is the entire contract.
mkdir -p /tmp/hs-plugin-tutorial
cd /tmp/hs-plugin-tutorial
go mod init example.com/hs-plugin-memgo get github.com/cybertec-postgresql/pg_hardstorage@v0.2.0Use the in-repo helpers (external.IsPluginInvocation,
external.EmitProbeResponse, external.ServeRPC) — they keep the
JSON shapes correct and let the host evolve the protocol without you
re-rolling boilerplate.
// pg-hardstorage-plugin-mem — minimal Tier-2 storage plugin.
package main
import (
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/cybertec-postgresql/pg_hardstorage/internal/plugin/external"
)
const (
name = "mem"
kind = "storage"
scheme = "mem"
version = "0.1.0"
backing = "/tmp/hs-plugin-tutorial/store.json"
)
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, name, kind,
[]string{scheme}, version)
return
}
if err := external.ServeRPC(os.Stdin, os.Stdout, handlers()); err != nil {
fmt.Fprintf(os.Stderr, "plugin: %v\n", err)
os.Exit(1)
}
}
func handlers() map[string]external.Handler {
return map[string]external.Handler{
"Storage.Put": put,
"Storage.Get": get,
"Storage.Stat": stat,
"Storage.List": list,
}
}
// --- store --------------------------------------------------------
type store struct {
Objects map[string][]byte `json:"objects"`
}
func load() (*store, error) {
s := &store{Objects: map[string][]byte{}}
b, err := os.ReadFile(backing)
if err != nil {
if os.IsNotExist(err) {
_ = os.MkdirAll(filepath.Dir(backing), 0o755)
return s, nil
}
return nil, err
}
return s, json.Unmarshal(b, s)
}
func (s *store) save() error {
b, err := json.Marshal(s)
if err != nil {
return err
}
return os.WriteFile(backing, b, 0o600)
}
// --- handlers -----------------------------------------------------
type putParams struct {
Key string `json:"key"`
Body []byte `json:"body"` // base64-encoded by the JSON encoder
}
func put(params json.RawMessage) (any, error) {
var p putParams
if err := json.Unmarshal(params, &p); err != nil {
return nil, err
}
s, err := load()
if err != nil {
return nil, err
}
s.Objects[p.Key] = p.Body
if err := s.save(); err != nil {
return nil, err
}
return map[string]any{"size": len(p.Body)}, nil
}
func get(params json.RawMessage) (any, error) {
var p struct{ Key string }
if err := json.Unmarshal(params, &p); err != nil {
return nil, err
}
s, err := load()
if err != nil {
return nil, err
}
body, ok := s.Objects[p.Key]
if !ok {
return nil, &external.RPCError{
Code: "storage.not_found", Message: p.Key,
}
}
return map[string]any{"body": body}, nil
}
func stat(params json.RawMessage) (any, error) {
var p struct{ Key string }
if err := json.Unmarshal(params, &p); err != nil {
return nil, err
}
s, err := load()
if err != nil {
return nil, err
}
body, ok := s.Objects[p.Key]
if !ok {
return nil, &external.RPCError{
Code: "storage.not_found", Message: p.Key,
}
}
return map[string]any{"size": len(body)}, nil
}
func list(params json.RawMessage) (any, error) {
var p struct{ Prefix string }
_ = json.Unmarshal(params, &p)
s, err := load()
if err != nil {
return nil, err
}
var keys []string
for k := range s.Objects {
if strings.HasPrefix(k, p.Prefix) {
keys = append(keys, k)
}
}
return map[string]any{"keys": keys}, nil
}
// silence unused-import warnings on tiny builds
var _ io.Writer = os.Stdout# RUNNABLE skip-in-ci="needs scaffolded Go plugin in /tmp/hs-plugin-tutorial"
cd /tmp/hs-plugin-tutorial
go build -o pg-hardstorage-plugin-mem .The host walks two default directories. For development, point it at your build directory instead:
export HSPLUGIN_PATH=/tmp/hs-plugin-tutorial
chmod +x /tmp/hs-plugin-tutorial/pg-hardstorage-plugin-memThe probe is what the host will run; if your hand-run doesn't produce a single JSON line and exit cleanly, neither will the host's:
PG_HARDSTORAGE_PLUGIN=1 \
/tmp/hs-plugin-tutorial/pg-hardstorage-plugin-mem --probe{"protocol":"pg_hardstorage.plugin.v1","name":"mem","kind":"storage","schemes":["mem"],"version":"0.1.0"}Five-second timeout on the host side: any plugin that hangs at probe time is dropped from the registry with a logged warning.
# RUNNABLE skip-in-ci="needs scaffolded Go plugin in /tmp/hs-plugin-tutorial"
HSPLUGIN_PATH=/tmp/hs-plugin-tutorial \
pg_hardstorage doctor...
Plugins
✓ mem (storage, scheme=mem://) v0.1.0 · /tmp/hs-plugin-tutorial/pg-hardstorage-plugin-memYour scheme (mem://) is now valid wherever --repo is accepted.
A real backup against mem:// requires the storage interface
methods this tutorial does not implement (Delete,
RenameIfNotExists, SetRetention, Capabilities,
Close). The minimal set above is enough for pg_hardstorage repo init and pg_hardstorage repo audit — the surface that exercises
discovery and the put/get/stat path:
HSPLUGIN_PATH=/tmp/hs-plugin-tutorial \
pg_hardstorage repo init mem:///hs-tutorialHSPLUGIN_PATH=/tmp/hs-plugin-tutorial \
pg_hardstorage repo audit mem:///hs-tutorialInspect the backing JSON:
cat /tmp/hs-plugin-tutorial/store.json | jq 'keys'[
"HSREPO",
"config/repo.json"
]Two objects — the magic file and the repo config. The plugin moved real bytes through your handler.
Tail the plugin's stderr to watch every RPC:
HSPLUGIN_PATH=/tmp/hs-plugin-tutorial \
pg_hardstorage repo audit mem:///hs-tutorial 2>>/tmp/plugin-stderr.logEach call to your binary is one process: launch, read one request, write one response, exit. There is no daemon to supervise, no shutdown protocol to misimplement, no leaked file descriptor on crash. The trade-off is SDK init cost on every call — fine for repo admin, never on the hot chunk path. If you need long-lived state (a warmed S3 client, a connection pool), that is what Tier-1 is for.
The full storage interface — the one a production plugin
implements — is in internal/plugin/storage/.
The Tier-2 protocol mirrors it method-for-method; see the
storage plugin contract
for the authoritative list, the param schemas, and the error codes.
When you have a real plugin:
- Drop the binary at
/usr/local/lib/pg_hardstorage/plugins/(Linux) or under$HSPLUGIN_PATH. - Sign the binary with cosign — the host honours signatures when the
config has
plugins.require_signed: true. - Publish to
registry.pghardstorage.orgpost-v1.0 for discovery by other operators.
You built and registered an external plugin that the host discovered, probed, and dispatched against. The two ideas to take away:
- One-shot processes are the protocol. Every RPC is a fresh invocation; the only state you keep is on disk or via the host passing it back in the next request. This is what makes Tier-2 plugins crash-isolated and language-agnostic.
- Probe is the contract. Get the probe response right and
registration "just works"; get it wrong (missing
protocolfield, wrong version, slow exit) and your plugin silently doesn't show up indoctor.
- Tier-2 plugin reference — the full method set, param schemas, error codes (Agent F).
- Architecture tour — where Tier-1 vs Tier-2 sits in the data plane.
- Operator guide — running with third-party plugins in production.