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:

PortConstantDelivered viaPurpose
_settingsv1alpha1.SettingsPortSettingsHandler.OnSettingsComponent configuration
_controlv1alpha1.ControlPortControlHandler.OnControlDashboard widgets, Start/Stop
_reconcilev1alpha1.ReconcilePortReconcileHandler.OnReconcileNode reconciliation
_clientv1alpha1.ClientPortClientAware.OnClientKubernetes client access
_identityv1alpha1.IdentityPortIdentityAware.OnIdentityNode 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):

  1. OnIdentity — node knows who it is
  2. OnClient — K8s client wired up
  3. OnNATS — JetStream handle (NATSAware, no port; nil without TINY_NATS_URL)
  4. OnState — state backend (Stateful, no port)
  5. OnReconcile — restore from metadata, react to spec
  6. OnSettings — 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 OnReconcile on 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.K8sClientnot 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
}