System Ports Reference
Complete reference for system ports in TinySystems components.
Source of truth: api/v1alpha1/consts.go, module/lifecycle.go in the SDK (github.com/tiny-systems/module).
Overview
System ports are special ports (names starting with _) that carry infrastructure concerns instead of flow data. There are five:
| Port | Constant | Delivered via | Purpose |
|---|---|---|---|
_settings | v1alpha1.SettingsPort | SettingsHandler.OnSettings | Component configuration |
_control | v1alpha1.ControlPort | ControlHandler.OnControl | Dashboard widgets, Start/Stop |
_reconcile | v1alpha1.ReconcilePort | ReconcileHandler.OnReconcile | Node reconciliation |
_client | v1alpha1.ClientPort | ClientAware.OnClient | Kubernetes client access |
_identity | v1alpha1.IdentityPort | IdentityAware.OnIdentity | Node identity |
System ports never reach Component.Handle. The runner dispatches them exclusively through the capability interfaces above; a component that does not implement the interface simply never sees the message — there is no fallback into Handle.
Dispatch order on a fresh runner (with the non-port capabilities interleaved):
OnIdentity— node knows who it isOnClient— K8s client wired upOnNATS— JetStream handle (NATSAware, no port; nil withoutTINY_NATS_URL)OnState— state backend (Stateful, no port)OnReconcile— restore from metadata, react to specOnSettings— user settings applied last, so they win over reconcile-restored state
On subsequent reconciles OnReconcile fires again; OnSettings only re-fires when the configured value changes.
_settings Port
Receives component configuration when a TinyNode is created or its settings change.
Constant
v1alpha1.SettingsPort = "_settings"
Port Definition
An input port (Source false/omitted); its Configuration struct becomes the settings form:
{
Name: v1alpha1.SettingsPort,
Label: "Settings",
Configuration: Settings{},
}
Handler
Implement SettingsHandler. The argument is the concrete type declared as the port's Configuration, already deserialized:
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
}
When Triggered
- After
OnReconcileon a fresh runner (settings always win over reconcile-restored state) - When the stored settings value changes (deliveries are deduplicated otherwise)
_control Port
Carries dashboard/control interactions (button clicks, widget input).
Constant
v1alpha1.ControlPort = "_control"
Port Definition
Typically declared Source: true so the current state renders as a widget. Returning a different Go type per state gives state-dependent forms:
{
Name: v1alpha1.ControlPort,
Label: "Control",
Source: true,
Configuration: c.getControl(), // e.g. ControlRunning{} or ControlStopped{}
}
type ControlStopped struct {
Start bool `json:"start" format:"button" title:"Start" required:"true"`
}
type ControlRunning struct {
Status string `json:"status" readonly:"true" title:"Status"`
Stop bool `json:"stop" format:"button" title:"Stop" required:"true"`
}
Handler
type ControlHandler interface {
OnControl(ctx context.Context, control any) error
}
func (t *Component) OnControl(ctx context.Context, msg any) error {
if msg == nil {
return nil
}
if !utils.IsLeader(ctx) {
return nil // only the leader acts on control input
}
switch ctrl := msg.(type) {
case ControlStopped:
if ctrl.Start {
return t.start(ctx)
}
case ControlRunning:
if ctrl.Stop {
return t.stop(ctx)
}
}
return nil
}
Refreshing the Widget
Emit to the control port (from Handle or a Base.Emit goroutine) to push new state to the UI:
t.Emit(ctx, v1alpha1.ControlPort, t.getControl())
When Triggered
- User clicks a button / edits a widget in the UI or a dashboard page
_reconcile Port
Connects the component to the node's reconciliation loop.
Constant
v1alpha1.ReconcilePort = "_reconcile"
Handler
Implement ReconcileHandler. The node is passed by value:
type ReconcileHandler interface {
OnReconcile(ctx context.Context, node v1alpha1.TinyNode) error
}
func (c *Component) OnReconcile(_ context.Context, node v1alpha1.TinyNode) error {
// Restore state persisted in metadata (e.g. by another replica)
if v, ok := node.Status.Metadata["server-port"]; ok {
c.serverPort = v
}
return nil
}
The framework guarantees OnReconcile fires before OnSettings on a fresh runner, so settings always win over reconcile-restored state.
Emitting Updates
To patch the node (persist metadata, trigger a status refresh), emit an updater function on the reconcile port. The updater signature is func(node *v1alpha1.TinyNode) error:
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["running"] = "true"
return nil
})
Patches are debounced and applied through the reconcile-port. For plain key-value persistence prefer the module.State interface (Stateful capability, or Base.State()), which wraps this mechanism with read-your-writes semantics.
When Triggered
- TinyNode created or updated
- Periodic reconciliation
- A component-emitted reconcile patch
_client Port
Provides Kubernetes API access.
Constant
v1alpha1.ClientPort = "_client"
Handler
Implement ClientAware. The delivered value is a module.K8sClient — not an HTTP client, and not the full resource.Manager type:
type ClientAware interface {
OnClient(client module.K8sClient)
}
type K8sClient interface {
GetK8sClient() client.WithWatch // sigs.k8s.io/controller-runtime client
GetNamespace() string
}
func (c *Component) OnClient(client module.K8sClient) {
c.k8s = client
}
// later
pods := &corev1.PodList{}
err := c.k8s.GetK8sClient().List(ctx, pods, client.InNamespace(c.k8s.GetNamespace()))
Embedding module.Base gives you this for free via b.Client().
resource.Manager
The platform object that satisfies module.K8sClient is pkg/resource.Manager. Besides the K8s client accessors it offers node/module CRUD helpers and port exposure:
// Create a Service (+ Ingress when hostnames resolve) for a listening port.
// Returns the public hostnames.
func (m Manager) ExposePort(ctx context.Context, autoHostName string, hostnames []string, port int) ([]string, error)
// Remove the exposure for a port.
func (m Manager) DisclosePort(ctx context.Context, port int) error
The deprecated module.Client interface (ExposePort/DisclosePort) still exists for compatibility; new components should use K8sClient and manage resources directly.
There is no CreateSignal method — the TinySignal CRD was removed. External triggers go through pkg/wire.Publish over NATS.
When Triggered
- Once during runner setup, before
OnReconcile
_identity Port
Tells the component its own resource identity.
Constant
v1alpha1.IdentityPort = "_identity"
Handler
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"`
}
func (c *Component) OnIdentity(id v1alpha1.NodeIdentity) {
c.prefix = fmt.Sprintf("%s/%s", id.Namespace, id.NodeName)
}
Use it to namespace local resources (PVC paths, cache prefixes, queue names). Embedding module.Base provides b.Identity().
When Triggered
- Once during runner setup, first in the dispatch order
Port Declaration Pattern
A component declaring system ports alongside data ports:
func (c *Component) Ports() []module.Port {
return []module.Port{
// System ports (only those with a form/widget need declaring)
{
Name: v1alpha1.SettingsPort,
Label: "Settings",
Configuration: Settings{},
},
{
Name: v1alpha1.ControlPort,
Label: "Control",
Source: true,
Configuration: c.getControl(),
},
// Data ports
{
Name: "input",
Label: "Input",
Position: module.Left,
Configuration: Input{},
},
{
Name: "output",
Label: "Output",
Source: true,
Position: module.Right,
Configuration: Output{},
},
}
}
_reconcile, _client and _identity need no port declaration — implementing the capability interface is enough.
Best Practices
1. Embed module.Base
type Component struct {
module.Base // Stateful + IdentityAware + ClientAware + EmitterAware
settings Settings
}
2. Check Leadership for Cluster Writes
if utils.IsLeader(ctx) {
// Only one replica acts
}
3. Handle Missing Dependencies
if c.Client() == nil {
return module.Fail(errors.New("k8s client not available"))
}
4. Let Settings Win
Restore state in OnReconcile, apply configuration in OnSettings — the framework orders them so settings always apply last.
5. Guard Type Assertions
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
}