Resources & the State Model
A resource is the atom of COSI: a strictly-typed metadata envelope wrapped around an opaque spec. Master this one type and the rest of the system falls into place — every controller, every watch, every gRPC call is just resources moving around.
#Anatomy of a resource
The base interface is deliberately tiny. A Resource is anything that can hand back its metadata, its spec, and a deep copy of itself.
// Resource is uniquely identified by the tuple (Namespace, Type, ID).
type Resource interface {
// Metadata for the resource. Version changes each time Spec changes.
Metadata() *Metadata
// Opaque data the resource contains.
Spec() any
// Deep copy of the resource.
DeepCopy() Resource
}
That split is the whole philosophy: metadata is strict and known to the runtime; the spec is opaque and known only to your code. The runtime can route, version, and reconcile any resource without ever understanding what's inside the spec.
#The Metadata envelope
Metadata is a value type (copied, not shared) carrying everything the runtime needs to address and coordinate a resource. You rarely build it by hand — typed resources do it for you — but it helps to see the fields.
| Field | Meaning | Who writes it |
|---|---|---|
namespace | Logical partition (e.g. default, vms) | you, at construction |
type | Resource kind, e.g. VirtualMachine.example.io | you, at construction |
id | Unique ID within (namespace, type) | you, at construction |
version | Monotonic counter; bumped on every spec change | state, on Update |
owner | Name of the controller that created it (write-once) | runtime, on Create |
phase | running or tearingDown | state, on Teardown |
finalizers | Names blocking destruction until removed | dependent controllers |
labels / annotations | Queryable key/values · free-form metadata | you & controllers |
created / updated | Timestamps | state |
Unlike annotations, labels are queryable. state.WithLabelQuery(...) filters lists and watches server-side — this is how you build "all VMs on host-7" or "all pods in namespace X" relationships without a join engine. We'll use this heavily in the controllers chapter.
#Identity, version & phase
Three metadata fields drive the reconciliation machinery, so they're worth dwelling on.
Version — optimistic concurrency
Every resource carries a Version (an opaque incrementing counter; VersionUndefined before first write). Update requires the version you pass to match what's stored, or you get a conflict error. This is how COSI avoids lost updates without locks — exactly like an HTTP ETag / Kubernetes resourceVersion.
// Next returns a new incremented version. The state calls this on Update;
// you never set versions by hand.
func (v Version) Next() Version {
return Version{ uint64: new(pointer.SafeDeref(v.uint64) + 1) }
}
In practice you avoid the conflict dance entirely with state.UpdateWithConflicts / Modify, which retry the read-modify-write loop for you. More on that next chapter.
Phase — the two-state lifecycle
A resource is either running or tearingDown. There is no "deleting" event that races your cleanup logic: deletion is a two-step protocol mediated by finalizers.
Teardown flips the phase, finalizers drain, then Destroy removes it.#Finalizers & owners
These two fields are how COSI does safe, ordered teardown across a controller graph — the single most important pattern to internalize.
owner
Set once when a controller creates a resource. The runtime enforces that only the owner may update or destroy it. Try to set a second owner and you get an error. This is "no conflicts by design" made concrete.
finalizers
A set of strings. While any finalizer is present, Destroy fails. A downstream controller adds its finalizer to an upstream input it depends on, so the input can't vanish until the dependent has cleaned up and removed its mark.
The choreography for "delete a VM that a network controller depends on":
- Network controller adds finalizer
"net-cleanup"to theVirtualMachineit watches. - Someone calls Teardown on the VM → phase becomes
tearingDown. The VM still exists. - The network controller is woken (it watches that VM), sees
tearingDown, deletes the VM's network, then removes its finalizer. - With finalizers now empty, Destroy succeeds and the VM is gone — networking already cleaned up. No races, no orphans.
The transform / qtransform generic controllers (chapter 03) manage finalizers automatically, and State.TeardownAndDestroy runs the whole Teardown→wait→Destroy sequence in one call. But knowing the protocol underneath is what lets you debug a "stuck in tearingDown" resource — it always means a finalizer that nobody removed.
#Defining a typed resource
You never implement the bare Resource interface yourself. Instead you instantiate the generic typed.Resource[Spec, Extension], which gives you a typed TypedSpec(), automatic deep-copy, and a resource definition. Here is the complete pattern — a real-world VirtualMachine resource:
const (
Namespace = resource.Namespace("vms")
VMType = resource.Type("VirtualMachine.example.io")
)
// 1. The spec — your opaque payload. Must be DeepCopyable.
type VMSpec struct {
CPUs int `protobuf:"1"`
MemMiB int `protobuf:"2"`
Image string `protobuf:"3"`
State string `protobuf:"4"` // desired: "running" | "stopped"
}
func (s VMSpec) DeepCopy() VMSpec { return s } // value spec → trivial
// 2. The extension — declares the resource definition (type + default ns).
type VMExtension struct{}
func (VMExtension) ResourceDefinition() meta.ResourceDefinitionSpec {
return meta.ResourceDefinitionSpec{
Type: VMType,
DefaultNamespace: Namespace,
Aliases: []resource.Type{"vm"},
PrintColumns: []meta.PrintColumn{
{Name: "State", JSONPath: "{.state}"},
},
}
}
// 3. Alias the generic, and a constructor.
type VM = typed.Resource[VMSpec, VMExtension]
func NewVM(id resource.ID, spec VMSpec) *VM {
return typed.NewResource[VMSpec, VMExtension](
resource.NewMetadata(Namespace, VMType, id, resource.VersionUndefined),
spec,
)
}
That's it — vm.NewVM("web-1", VMSpec{CPUs: 4, ...}) now produces a fully-formed resource. v.TypedSpec() returns a *VMSpec (no casts), and v.DeepCopy() / equality / YAML marshaling all work automatically.
typed.Resource calls spec.DeepCopy() internally whenever it needs a snapshot — most visibly in v.DeepCopy() (the whole-resource copy controllers use for before/after comparison). The required signature is DeepCopy() T where T is the spec type itself.
For a value-only spec (no pointer fields, no slices, no maps), Go's struct copy is already a deep copy, so the method is one line: func (s VMSpec) DeepCopy() VMSpec { return s }. For specs with reference fields you must clone each one — a shallow return would let the snapshot alias the live spec, silently corrupting controller diffs.
#Crossing the wire: protobuf registration
The spec is opaque to the runtime — but to send a resource over gRPC, the transport needs to marshal it. You register each type once at startup. With the struct tags above, the dynamic registrar handles encoding via protoenc; no hand-written .proto for your spec.
func init() {
// Dynamic: marshals struct fields by their `protobuf:"N"` tags.
err := protobuf.RegisterDynamic[VMSpec](VMType, &VM{})
if err != nil { panic(err) }
}
For specs that already are protobuf messages (implementing MarshalProto/UnmarshalProto), use the static protobuf.RegisterResource(VMType, &VM{}) instead. Either way, registration is what lets a concrete *VM survive a round-trip through the gRPC State service in chapter 05.
A resource = strict Metadata (the runtime's view) + opaque Spec (your view). Identity is (namespace, type, id); version gives optimistic concurrency; phase + finalizers give cooperative deletion; owner gives single-writer safety. Everything else builds on these.