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
- Signals (NATS wire) - Triggering execution
- Cross-Module Communication - Transport details
- Module Discovery - Discovery patterns