System Ports
System ports are special ports the SDK uses to deliver framework-level messages to components. They follow an underscore-prefix naming convention.
System ports never reach Handle. The runner dispatches each one to a typed capability interface; a component that doesn't implement the interface simply receives nothing (a no-op). There is no legacy fallback — a switch port { case v1alpha1.SettingsPort: ... } inside Handle is dead code.
System Port Overview
| Port | Constant | Capability interface | Purpose |
|---|---|---|---|
_settings | v1alpha1.SettingsPort | module.SettingsHandler | User configuration |
_control | v1alpha1.ControlPort | module.ControlHandler | Dashboard widget interactions |
_reconcile | v1alpha1.ReconcilePort | module.ReconcileHandler | TinyNode reconcile events; metadata patches |
_client | v1alpha1.ClientPort | module.ClientAware | Kubernetes client wrapper injection |
_identity | v1alpha1.IdentityPort | module.IdentityAware | Node identity (name, namespace, flow, project) |
package v1alpha1
const (
ReconcilePort = "_reconcile"
ControlPort = "_control"
SettingsPort = "_settings"
ClientPort = "_client"
IdentityPort = "_identity"
)
Dispatch Order
On a fresh runner, the framework dispatches capabilities in a fixed order:
+-----------------------------------------------------------------------------+
| CAPABILITY DISPATCH ORDER |
+-----------------------------------------------------------------------------+
1. OnIdentity — node knows who it is (IdentityAware)
2. OnClient — K8s client wired up (ClientAware)
3. OnNATS — JetStream handle wired up (NATSAware)
4. OnState — state backend wired up (Stateful)
5. OnReconcile — restore from metadata/spec (ReconcileHandler)
6. OnSettings — apply user settings (SettingsHandler)
Reconcile runs before settings, so user settings always win over state restored from metadata. On subsequent reconciles, OnReconcile fires again; OnSettings only re-fires when the stored configuration changes.
_settings Port
Delivers user configuration, already deserialized into the Go type declared as the _settings port's Configuration.
type SettingsHandler interface {
OnSettings(ctx context.Context, settings any) error
}
func (c *Component) OnSettings(_ context.Context, msg any) error {
in, ok := msg.(Settings)
if !ok {
return fmt.Errorf("invalid settings")
}
c.settings = in
return nil
}
Port definition (the current value is the Configuration):
{
Name: v1alpha1.SettingsPort,
Label: "Settings",
Configuration: c.settings,
}
Stored settings live in the TinyNode's spec.ports (entry with port: _settings). See Settings and Configuration.
_control Port
Delivers dashboard widget interactions (button clicks, form submissions).
type ControlHandler interface {
OnControl(ctx context.Context, control any) error
}
func (t *Component) OnControl(ctx context.Context, msg any) error {
if !utils.IsLeader(ctx) {
return nil // only the leader acts on control clicks
}
switch ctrl := msg.(type) {
case ControlRunning:
if ctrl.Stop {
return t.stop()
}
case ControlStopped:
go t.run(context.Background())
}
return nil
}
To refresh the widget after a state change, emit the new control value (via module.Base):
t.Emit(context.Background(), v1alpha1.ControlPort, t.getControl())
See Control Ports.
_reconcile Port
Receiving reconciles
type ReconcileHandler interface {
OnReconcile(ctx context.Context, node v1alpha1.TinyNode) error
}
Called on every TinyNode reconcile. Components use it to restore state from node.Status.Metadata or react to spec changes:
func (t *Component) OnReconcile(ctx context.Context, node v1alpha1.TinyNode) error {
if node.Status.Metadata == nil {
return nil
}
if _, running := node.Status.Metadata["ticker-running"]; !running {
return nil
}
if !utils.IsLeader(ctx) {
return nil
}
go t.run(ctx) // restore background work after pod restart
return nil
}
Writing node metadata
Emitting on _reconcile patches the TinyNode. The payload is a node-updater callback of type func(node *v1alpha1.TinyNode) error; updaters are accumulated and applied in a debounced patch to protect the K8s API:
t.Emit(context.Background(), v1alpha1.ReconcilePort, func(n *v1alpha1.TinyNode) error {
if n.Status.Metadata == nil {
n.Status.Metadata = make(map[string]string)
}
n.Status.Metadata["ticker-running"] = "true"
return nil
})
For key-value state, prefer the higher-level State backend (module.Stateful / module.Base.State()), which is built on the same mechanism.
_client Port
Injects a Kubernetes client wrapper — not HTTP requests. Components that need direct access to cluster resources implement ClientAware:
type ClientAware interface {
OnClient(client K8sClient)
}
type K8sClient interface {
GetK8sClient() client.WithWatch // controller-runtime client
GetNamespace() string
}
type Component struct {
client module.K8sClient
}
func (c *Component) OnClient(k8sClient module.K8sClient) {
c.client = k8sClient
}
// later:
func (c *Component) patchConfigMap(ctx context.Context) error {
cl := c.client.GetK8sClient()
ns := c.client.GetNamespace()
// use cl to Get/Update/Watch resources in ns
...
}
Embedding module.Base provides this for free — call c.Client().
_identity Port
Delivers the node's identity once during setup, before OnReconcile:
type IdentityAware interface {
OnIdentity(id v1alpha1.NodeIdentity)
}
type NodeIdentity struct {
NodeName string `json:"nodeName"`
Namespace string `json:"namespace"`
FlowName string `json:"flowName"`
ProjectName string `json:"projectName"`
}
Components that need to namespace local resources (PVC paths, key prefixes) stash the identity for later use. With module.Base embedded, call c.Identity().
Other Capability Interfaces
Not port-backed, but part of the same dispatch:
| Interface | Method | Purpose |
|---|---|---|
module.Stateful | OnState(s State) | Durable key-value state backend |
module.NATSAware | OnNATS(js jetstream.JetStream) | JetStream handle (nil if TINY_NATS_URL unset — handle that case) |
module.EmitterAware | OnEmitter(emit Handler) | Long-lived emit handler for background goroutines |
module.Destroyer | OnDestroy(metadata map[string]string) | Cleanup when the node is destroyed |
module.Base
Embed module.Base to satisfy Stateful, IdentityAware, ClientAware, and EmitterAware in one line, with accessors:
type Component struct {
module.Base
}
// c.State() — injected State backend
// c.Identity() — v1alpha1.NodeIdentity
// c.Client() — module.K8sClient
// c.Emit(ctx, port, data) — emit from any goroutine
If you override one of Base's On* methods, call the embedded method too so the accessors keep working.
Complete Example
A component using several capabilities:
package mycomponent
import (
"context"
"fmt"
"github.com/tiny-systems/module/api/v1alpha1"
"github.com/tiny-systems/module/module"
"github.com/tiny-systems/module/pkg/utils"
"github.com/tiny-systems/module/registry"
)
type Settings struct {
Interval int `json:"interval" title:"Interval (ms)" default:"1000"`
}
type Input struct {
Data string `json:"data" title:"Data" required:"true"`
}
type Output struct {
Result string `json:"result" title:"Result"`
}
type Component struct {
module.Base // Stateful + IdentityAware + ClientAware + EmitterAware
settings Settings
}
func (c *Component) Instance() module.Component {
return &Component{settings: Settings{Interval: 1000}}
}
func (c *Component) GetInfo() module.ComponentInfo {
return module.ComponentInfo{
Name: "my-component",
Description: "Demonstrates system ports",
Info: "Receives Input, emits Output. Restores state on reconcile.",
Tags: []string{"example"},
}
}
func (c *Component) OnSettings(_ context.Context, msg any) error {
in, ok := msg.(Settings)
if !ok {
return fmt.Errorf("invalid settings")
}
c.settings = in
return nil
}
func (c *Component) OnReconcile(ctx context.Context, node v1alpha1.TinyNode) error {
// React to spec changes / restore from node.Status.Metadata.
// Runs BEFORE OnSettings on a fresh runner.
_ = utils.IsLeader(ctx) // gate leader-only restoration
return nil
}
func (c *Component) Handle(ctx context.Context, output module.Handler, port string, msg any) module.Result {
if port == "input" {
in, ok := msg.(Input)
if !ok {
return module.Fail(fmt.Errorf("invalid message"))
}
return output(ctx, "output", Output{Result: "Processed: " + in.Data})
}
return module.Fail(fmt.Errorf("unknown port %q", port))
}
func (c *Component) Ports() []module.Port {
return []module.Port{
{Name: v1alpha1.SettingsPort, Label: "Settings", Configuration: c.settings},
{Name: "input", Label: "Input", Position: module.Left, Configuration: Input{}},
{Name: "output", Label: "Output", Source: true, Position: module.Right, Configuration: new(Output)},
}
}
var (
_ module.Component = (*Component)(nil)
_ module.SettingsHandler = (*Component)(nil)
_ module.ReconcileHandler = (*Component)(nil)
)
func init() {
registry.Register((&Component{}).Instance())
}
Best Practices
1. Assert Your Capabilities at Compile Time
var (
_ module.Component = (*Component)(nil)
_ module.SettingsHandler = (*Component)(nil)
_ module.ControlHandler = (*Component)(nil)
_ module.ReconcileHandler = (*Component)(nil)
)
2. Leader Check for Side Effects
func (c *Component) OnControl(ctx context.Context, msg any) error {
if !utils.IsLeader(ctx) {
return nil
}
// process control
}
3. Keep OnSettings and OnReconcile Idempotent
Both can fire repeatedly; write them so a re-delivery with identical data is harmless.
4. Handle nil Injections
OnNATS receives nil when the runtime starts without TINY_NATS_URL; Base.Client() is nil before OnClient fires. Guard accordingly.
Next Steps
- Component Patterns - Common patterns
- Settings and Configuration - Settings details
- Control Ports - Control details