TinyModule CRD

TinyModule is the Custom Resource used for module discovery. It allows modules to find each other for cross-module communication.

Full Specification

apiVersion: operator.tinysystems.io/v1alpha1
kind: TinyModule
metadata:
  name: common
  namespace: tinysystems
spec:
  # Module name and version (image reference)
  image: common:1.0.0

status:
  # Address for cross-module communication:
  # - NATS transport: informational subject, e.g. "nats://tinymodule.common.msg"
  # - gRPC fallback:  host:port the module's gRPC server listens on
  addr: "nats://tinymodule.common.msg"

  # Module name, version, and SDK version
  name: common
  version: "1.0.0"
  sdkVersion: "0.13.59"

  # Available components (flat fields + per-port schemas)
  components:
    - name: router
      description: "Routes messages based on conditions"
      info: "Configurable message router"
      tags: ["routing", "conditional"]
      ports:
        - name: input
          label: "Input"
          source: false
          position: 3
          schema: <base64 JSON Schema>
        - name: out_success
          label: "Success"
          source: true
          position: 1
          schema: <base64 JSON Schema>
    - name: ticker
      description: "Emits at regular intervals"
      tags: ["timer", "periodic"]

Publishing per-component port schemas at the module level lets tooling (MCP servers, the hosted platform) discover a component's shape before placing any TinyNode. System ports (_reconcile, _client, _identity) are filtered out.

How Discovery Works

+-----------------------------------------------------------------------------+
|                         MODULE DISCOVERY FLOW                                |
+-----------------------------------------------------------------------------+

1. MODULE STARTUP
   +------------------------------------------------------------------------+
   |  Each pod creates the module's TinyModule CR (idempotent —             |
   |  AlreadyExists is ignored):                                            |
   |                                                                         |
   |  apiVersion: operator.tinysystems.io/v1alpha1                          |
   |  kind: TinyModule                                                       |
   |  metadata:                                                              |
   |    name: my-module                                                      |
   |  spec:                                                                  |
   |    image: my-module:1.0.0                                               |
   +------------------------------------------------------------------------+
                                     |
                                     v
2. LEADER UPDATES STATUS
   +------------------------------------------------------------------------+
   |  Only the leader pod updates TinyModule.Status:                        |
   |                                                                         |
   |  status:                                                                |
   |    addr: "nats://tinymodule.my-module.msg"  # or gRPC host:port        |
   |    name: my-module                                                      |
   |    version: "1.0.0"                                                     |
   |    sdkVersion: "0.13.59"                                                |
   |    components: [...]                        # incl. port schemas       |
   +------------------------------------------------------------------------+
                                     |
                                     v
3. OTHER MODULES WATCH
   +------------------------------------------------------------------------+
   |  TinyModuleReconciler watches ALL TinyModule CRs                       |
   |                                                                         |
   |  For remote modules (not own module):                                  |
   |    - Read status.addr                                                   |
   |    - Register in the gRPC ClientPool (fallback transport)               |
   +------------------------------------------------------------------------+
                                     |
                                     v
4. CROSS-MODULE COMMUNICATION
   +------------------------------------------------------------------------+
   |  When a node needs to send to a different module:                      |
   |                                                                         |
   |  - NATS transport (TINY_NATS_URL set): publish straight to the         |
   |    target's subject tinymodule.<module>.msg — no address lookup        |
   |  - gRPC fallback: look up the module in ClientPool and send to         |
   |    status.addr                                                          |
   +------------------------------------------------------------------------+

TinyModule Controller

// tinymodule_controller.go
func (r *TinyModuleReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
    instance := &v1alpha1.TinyModule{}
    if err := r.Get(ctx, req.NamespacedName, instance); err != nil {
        return ctrl.Result{}, client.IgnoreNotFound(err)
    }

    // Is this a REMOTE module? (not our own)
    if req.Name != r.Module.GetNameSanitised() {
        // Register for cross-module communication
        if instance.Status.Addr != "" {
            r.ClientPool.Register(req.Name, instance.Status.Addr)
        }
        return ctrl.Result{}, nil
    }

    // This is OUR module - only leader updates status
    if !r.IsLeader.Load() {
        return ctrl.Result{RequeueAfter: 5 * time.Second}, nil
    }

    // Update our module's status
    instance.Status.Addr = r.Module.Addr
    instance.Status.Version = r.Module.Version
    instance.Status.Name = r.Module.Name
    instance.Status.SDKVersion = r.Module.SDKVersion
    instance.Status.Components = r.buildComponentStatus(l)

    if err := r.Status().Update(ctx, instance); err != nil {
        return ctrl.Result{}, err
    }

    return ctrl.Result{}, nil
}

Client Pool

The ClientPool manages gRPC connections to other modules on the fallback transport (it is bypassed entirely when TINY_NATS_URL is set). Simplified:

type Pool struct {
    clients map[string]*grpc.ClientConn
    mu      sync.RWMutex
}

func (p *Pool) Register(moduleName, address string) {
    p.mu.Lock()
    defer p.mu.Unlock()

    // Skip if already registered
    if _, exists := p.clients[moduleName]; exists {
        return
    }

    // Create gRPC connection
    conn, err := grpc.Dial(address,
        grpc.WithTransportCredentials(insecure.NewCredentials()),
        grpc.WithKeepaliveParams(keepalive.ClientParameters{
            Time:                10 * time.Second,
            Timeout:             3 * time.Second,
            PermitWithoutStream: true,
        }),
    )
    if err != nil {
        log.Error("failed to connect", "module", moduleName, "error", err)
        return
    }

    p.clients[moduleName] = conn
}

func (p *Pool) Get(moduleName string) (*grpc.ClientConn, bool) {
    p.mu.RLock()
    defer p.mu.RUnlock()
    conn, ok := p.clients[moduleName]
    return conn, ok
}

Creating TinyModule

Modules automatically create their TinyModule CR on startup:

// pkg/resource/manager.go
func (m Manager) CreateModule(ctx context.Context, mod module.Info) error {
    node := &v1alpha1.TinyModule{
        ObjectMeta: metav1.ObjectMeta{
            Namespace: m.namespace,
            Name:      mod.GetNameSanitised(),
        },
        Spec: v1alpha1.TinyModuleSpec{
            Image: mod.GetNameAndVersion(),
        },
    }

    err := m.client.Create(ctx, node)
    if errors.IsAlreadyExists(err) {
        return nil // idempotent — every pod calls this at startup
    }
    return err
}

Component Status

The status includes component information from the registry, including each component's ports with their JSON schemas:

func (r *TinyModuleReconciler) buildComponentStatus(l logr.Logger) []v1alpha1.TinyModuleComponentStatus {
    components := registry.Get()
    status := make([]v1alpha1.TinyModuleComponentStatus, len(components))
    for i, cmp := range components {
        info := cmp.GetInfo()
        status[i] = v1alpha1.TinyModuleComponentStatus{
            Name:        info.Name,
            Description: info.Description,
            Info:        info.Info,
            Tags:        info.Tags,
            Ports:       buildComponentPorts(l, cmp, info.Name), // name, label, source, position, schema
        }
    }
    return status
}

Service Discovery Pattern

+---------------+    Watch     +-----------------------------------+
|               |------------->|         TinyModule CRs            |
|   Module A    |              |                                   |
|               |<-------------|  - common                         |
|               |   Updates    |    status.addr: "nats://..."      |
+---------------+              |  - http                           |
                               |    status.addr: "nats://..."      |
+---------------+    Watch     |  - my-module                      |
|               |------------->|    status.addr: "nats://..."      |
|   Module B    |              |                                   |
|               |<-------------+-----------------------------------+
|               |   Updates
+---------------+

Each module:
1. Creates its own TinyModule CR
2. Leader updates status (addr, version, sdkVersion, components)
3. Watches all TinyModule CRs
4. With the NATS transport, delivery needs no address lookup at all —
   the subject is derived from the module name
5. On the gRPC fallback, remote addrs are registered in ClientPool

Kubernetes Service (gRPC fallback)

When running without NATS, the gRPC address typically points to a Kubernetes Service (the chart exposes port 8483):

apiVersion: v1
kind: Service
metadata:
  name: common
spec:
  selector:
    app: common
  ports:
    - name: grpc
      port: 8483
      targetPort: 8483

Next Steps