gRPC & Distribution
The single most leverage-giving fact about COSI: local and remote state are the same interface. A state.State backed by an in-process map and a state.State backed by a gRPC connection to another process are indistinguishable to your controllers. This chapter shows the transport that makes that true — and what it unlocks for distributed control planes.
#The big idea: state as a service
Because controllers only ever talk to state.State, you can put a network in the middle of that interface and nothing upstream notices. The server adapter exposes a local CoreState as a gRPC service; the client adapter implements CoreState by calling that service. Wrap the client in WrapCore and you have a fully-featured remote state.State.
#The State gRPC service
The service in api/v1alpha1/state.proto mirrors CoreState almost method-for-method, with a couple of additions that exist purely to save round-trips:
| RPC | Kind | Maps to |
|---|---|---|
Get | unary | CoreState.Get |
List | server-stream | CoreState.List (streams resources) |
Create / Update / Destroy | unary | the CRUD writes |
Watch | server-stream | both Watch & WatchKind (events streamed) |
Teardown | unary | native Teardowner — phase flip in one call |
TeardownAndDestroy | unary | server-side wait for finalizers, then destroy — one call instead of teardown+watch+destroy |
#Server & client adapters
server.NewState(core)
Wraps any local CoreState as a gRPC StateServer. Marshals resources to the wire format, maps Go errors to gRPC status codes (NotFound, FailedPrecondition for conflicts, …), and delegates Teardown/TeardownAndDestroy natively if the underlying state supports them.
client.NewAdapter(stateClient)
Implements CoreState (plus the optional Teardowner interfaces) on top of a generated StateClient. Adds resilience: exponential-backoff retry on Watch, and transparent fallbacks if the server returns Unimplemented for the newer RPCs.
#Crossing the wire: why registration matters
Recall the spec is opaque. To serialize a concrete *VM the transport consults the protobuf registry you populated in chapter 01. On the way out, protobuf.FromResource turns your resource into a wire v1alpha1.Resource (metadata + marshaled spec). On the way in, the registry looks up the type and reconstructs the concrete *VM — not a generic blob — so the receiving controllers get typed specs via TypedSpec() exactly as if it were local.
// Without this, the gRPC client can't reconstruct your type → runtime error.
func init() {
protobuf.RegisterDynamic[vm.VMSpec](vm.VMType, &vm.VM{})
}
// Both processes must register the same types — share a package that does it in init().
#Deployment topologies
Monolith
One process: inmem state, all controllers, no gRPC. This is the default and the right starting point. Tests run this way in milliseconds.
Central store + agents
One process owns the bolt-backed state and serves gRPC. Many agent processes connect via client.NewAdapter and run their own controllers against the shared store. The store is the only stateful component.
API surface
Expose server.NewState as your system's API — CLIs, web UIs, and other services do typed CRUD & Watch over the same gRPC, with a state.Filter RBAC rule guarding access.
Talos runs a monolith on each node; Omni runs the central-store topology with a persistent backend and many connected workers. You get to pick per-deployment, and migrate between them, without rewriting a single controller — only the line that constructs state.State changes.
#keystorage — keys for encryption at rest
If you use the encryption marshaler from chapter 02, pkg/keystorage manages the master key. It stores one symmetric master key, encrypted independently into multiple key slots — each slot a PGP-encrypted copy under a different public key. That gives you:
- Multi-party access — several keyholders can each decrypt the same state, each with their own private key.
- Rotation — add a new slot from an existing one (
AddKeySlot) without re-encrypting the data; revoke by dropping a slot. - Integrity — an HMAC of the master key detects tampering.
It's serializable to a single blob (protobuf in api/key_storage), so the encrypted key store itself can live next to the encrypted data. This is the mechanism behind Talos's encrypted machine state.
#A full distributed wiring
Server side exposes the store; client side runs controllers against it. Both are a handful of lines:
core := state.WrapCore(namespaced.NewState(inmem.Build)) // or bolt-backed
grpcServer := grpc.NewServer()
v1alpha1.RegisterStateServer(grpcServer, server.NewState(core))
lis, _ := net.Listen("tcp", ":4001")
go grpcServer.Serve(lis)
conn, _ := grpc.NewClient("dns:///state.svc:4001", grpc.WithTransportCredentials(creds))
// the remote store, behind the SAME state.State interface
st := state.WrapCore(client.NewAdapter(
v1alpha1.NewStateClient(conn),
client.WithRetryLogger(logger),
))
rt, _ := runtime.NewRuntime(st, logger)
rt.RegisterQController(vmnet.NewVMNetController()) // identical to the monolith
rt.Run(ctx)
State-as-a-service means one interface from laptop test to fleet. The server/client adapters bridge it over gRPC; the protobuf registry keeps specs typed across the wire; keystorage secures it at rest. The application chapters cash all of this in: greenfield recipes (ch. 06), then the Talos source-grounded case study (ch. 07).