The configuration plugin API lets a zhi plugin manage a set of typed, validated configuration values. Each plugin owns a slice of the global configuration tree and communicates with the host over gRPC using hashicorp/go-plugin.
pkg/zhiplugin/
plugin.go shared Handshake (all plugin types)
config/
config.go Value, Tree, TreeReader, Severity, validation types
plugin.go Plugin interface, PluginMap, GRPCPlugin wiring
grpc_client.go host-side gRPC client
grpc_server.go plugin-side gRPC server
proto/ generated protobuf / gRPC stubs
A single node in the configuration tree.
type Value struct {
Val any `json:"value" yaml:"value"`
Metadata map[string]any `json:"metadata,omitempty" yaml:"metadata,omitempty"`
Validators []ValidateFunc `json:"-" yaml:"-"`
}
Tree is a flat map keyed by slash-delimited paths. It implements the
read-only TreeReader interface:
type TreeReader interface {
Get(path string) (Value, bool)
List() []string
}
Get returns a copy of the value, so callers cannot mutate tree
internals.
Paths use / as the hierarchy separator. Each segment must match:
[a-z][a-z0-9._-]*[a-z0-9]
Single-character segments like a are valid. Examples:
pokedex/trainer.name
pokedex/region
database/host
app/tls/cert.pem
This scheme maps naturally to storage backends:
| Backend | Mapping |
|---|---|
| Vault | use path as-is under the secret mount |
| K8s Secret | replace / with - |
| Env variable | uppercase, replace / and . with _ |
Validation produces results with three severity levels:
| Severity | Meaning |
|---|---|
Info |
informational, does not block |
Warning |
potential problem, does not block |
Blocking |
prevents the configuration from being used |
type ValidationResult struct {
Severity Severity `json:"severity"`
Message string `json:"message"`
Metadata map[string]any `json:"metadata,omitempty"`
}
type ValidateFunc func(value any, tree TreeReader) []ValidationResult
Receives the value under validation and a read-only view of the full configuration tree. This enables both single-value checks and cross-value validation (e.g. “port must be > 1024 when host is localhost”). Return an empty or nil slice when the value is valid.
To create a configuration plugin, implement config.Plugin:
type Plugin interface {
List(ctx context.Context) ([]string, error)
Get(ctx context.Context, path string) (Value, bool, error)
Set(ctx context.Context, path string, v Value) error
Validate(ctx context.Context, path string, tree TreeReader) ([]ValidationResult, error)
}
| Method | Purpose |
|---|---|
List |
return every path this plugin manages |
Get |
retrieve a single value by path |
Set |
store a value at the given path |
Validate |
check one value, with full tree access for cross-value rules |
A plugin binary needs a main function that calls plugin.Serve:
package main
import (
"github.com/hashicorp/go-plugin"
"github.com/MrWong99/zhi/pkg/zhiplugin"
"github.com/MrWong99/zhi/pkg/zhiplugin/config"
)
func main() {
plugin.Serve(&plugin.ServeConfig{
HandshakeConfig: zhiplugin.Handshake,
Plugins: map[string]plugin.Plugin{
"config": &config.GRPCPlugin{Impl: &myPlugin{}},
},
GRPCServer: plugin.DefaultGRPCServer,
})
}
See the Pokedex example for a complete, runnable plugin.
Validate with the
path and the snapshot.TreeEntry messages.Tree and passes it to the
implementation’s Validate method.ValidateFunc closures internally (they never cross
the wire) and returns the results.This keeps things simple and works well for configuration data, which is typically small.