Resource Manager
The Resource Manager (pkg/resource.Manager) is the SDK's gateway to Kubernetes resources. The runtime uses it to register the module (TinyModule), patch node status, and manage projects, flows, and widget pages. Components reach it through the module.ClientAware capability.
Overview
+-----------------------------------------------------------------------------+
| RESOURCE MANAGER |
+-----------------------------------------------------------------------------+
Component Resource Manager Kubernetes
| | |
| ExposePort() | |
| -----------------------> | |
| | Add port to release Service |
| | ---------------------------> |
| | |
| | Add rule to release Ingress |
| | ---------------------------> |
| | |
| <----------------------- | |
| Hostnames returned | |
| | |
Accessing the Resource Manager
Components implement module.ClientAware. During node update the framework calls OnClient once, handing over the resource manager as a module.K8sClient:
type Component struct {
k8s module.K8sClient // raw client access
client module.Client // ExposePort / DisclosePort (type-asserted)
}
func (c *Component) OnClient(k module.K8sClient) {
c.k8s = k
if pc, ok := k.(module.Client); ok {
c.client = pc
}
}
module.K8sClient exposes:
type K8sClient interface {
GetK8sClient() client.WithWatch // controller-runtime client
GetNamespace() string
}
With GetK8sClient() a component can read or manage any cluster resource its module's RBAC permits.
Core Operations
ExposePort
Expose a port via the module release's Service and Ingress:
// ExposePort adds the port to the release Service and appends an Ingress
// rule + TLS entry per hostname. autoHostName (optional) is combined with
// the ingress's tinysystems.io/ingress-hostname-suffix annotation to
// produce an auto-generated hostname. Returns the final hostname list.
ExposePort(ctx context.Context, autoHostName string, hostnames []string, port int) ([]string, error)
func (c *HTTPServer) exposePort(ctx context.Context, port int) error {
hostnames, err := c.client.ExposePort(ctx, "my-api", c.settings.Hostnames, port)
if err != nil {
return err
}
c.hostnames = hostnames
return nil
}
DisclosePort
Remove port exposure (Service port, Ingress rules and TLS entries pointing at it):
func (c *HTTPServer) disclosePort(ctx context.Context) error {
return c.client.DisclosePort(ctx, c.currentPort)
}
Node Metadata Updates
There is no UpdateStatus call. Components persist runtime state into status.metadata by emitting an updater function to the _reconcile system port; the runner debounces and applies it via PatchNode:
func (c *Component) storeMetadata(ctx context.Context, output module.Handler) module.Result {
return output(ctx, v1alpha1.ReconcilePort, func(node *v1alpha1.TinyNode) error {
if node.Status.Metadata == nil {
node.Status.Metadata = make(map[string]string)
}
node.Status.Metadata["processed"] = "1000"
node.Status.Metadata["errors"] = "5"
return nil
})
}
HTTP Server Pattern
Complete pattern for an HTTP server with ingress. Settings, reconcile, and client access all arrive through capability interfaces — never through Handle:
type Server struct {
settings Settings
client module.Client // set in OnClient
emit module.Handler // set in OnEmitter
nodeName string
currentPort int
listener net.Listener
}
type Settings struct {
Hostnames []string `json:"hostnames" title:"Hostnames"`
}
func (s *Server) OnClient(k module.K8sClient) {
if pc, ok := k.(module.Client); ok {
s.client = pc
}
}
func (s *Server) OnEmitter(emit module.Handler) {
s.emit = emit
}
func (s *Server) OnSettings(ctx context.Context, settings any) error {
conf, ok := settings.(Settings)
if !ok {
return fmt.Errorf("unexpected settings type %T", settings)
}
s.settings = conf
return nil
}
func (s *Server) OnReconcile(ctx context.Context, node v1alpha1.TinyNode) error {
s.nodeName = node.Name
// Check if port already assigned
configuredPort := 0
if portStr, ok := node.Status.Metadata["http-server-port"]; ok {
configuredPort, _ = strconv.Atoi(portStr)
}
if configuredPort == s.currentPort && s.currentPort > 0 {
return nil // Already running
}
// No port yet - leader assigns
if configuredPort == 0 {
if utils.IsLeader(ctx) {
return s.startAndExpose(ctx)
}
return nil
}
// Port assigned - all pods start
return s.startOnPort(ctx, configuredPort)
}
func (s *Server) startAndExpose(ctx context.Context) error {
// Start on random port
listener, err := net.Listen("tcp", ":0")
if err != nil {
return err
}
port := listener.Addr().(*net.TCPAddr).Port
s.listener = listener
s.currentPort = port
// Expose via ingress
if len(s.settings.Hostnames) > 0 {
if _, err := s.client.ExposePort(ctx, s.nodeName, s.settings.Hostnames, port); err != nil {
listener.Close()
return err
}
}
// Publish port to metadata via the injected emitter
s.emit(ctx, v1alpha1.ReconcilePort, func(node *v1alpha1.TinyNode) error {
if node.Status.Metadata == nil {
node.Status.Metadata = make(map[string]string)
}
node.Status.Metadata["http-server-port"] = strconv.Itoa(port)
return nil
})
// Start serving
go http.Serve(listener, s.handler())
return nil
}
Kubernetes Resources Modified
ExposePort creates no new objects. It mutates the module release's existing Service and Ingress (located by their Helm release labels, app.kubernetes.io/instance + app.kubernetes.io/name: tinysystems-operator):
Service — a port<N> entry is appended:
spec:
ports:
# existing ports ...
- name: port8080
port: 8080
targetPort: 8080
Ingress — one rule (path /, pathType: Prefix) and one TLS entry per hostname:
spec:
tls:
- hosts:
- api.example.com
secretName: api.example.com-tls
rules:
- host: api.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: <module-release-service>
port:
number: 8080
Resource Lifecycle
+-----------------------------------------------------------------------------+
| RESOURCE LIFECYCLE |
+-----------------------------------------------------------------------------+
1. Component starts
|
v
2. Leader calls ExposePort()
|
+-> Port added to release Service
+-> Rule + TLS entry added to release Ingress
+-> TLS certificate requested (cert-manager, if configured)
|
v
3. Resources ready
|
+-> Traffic flows to component
|
v
4. Settings change (hostnames)
|
v
5. Leader calls ExposePort() again
|
+-> Ingress rules updated / appended
|
v
6. Component stops or node is destroyed
|
v
7. Component calls DisclosePort()
|
+-> Port removed from release Service
+-> Rules + TLS entries removed from release Ingress
Cleanup is the component's responsibility (typically in OnDestroy via the module.Destroyer interface) — exposure is not garbage-collected when a TinyNode is deleted, because the Service and Ingress belong to the module release, not the node.
Owner References
The Resource Manager does set owner references, but between the platform CRs, not on Services/Ingresses:
CreateNodesets the owning TinyFlow as controller of the TinyNode (via thetinysystems.io/flow-namelabel)CreateFlow,CreatePage, andCreateScenarioset the owning TinyProject as controller
Deleting a project therefore garbage-collects its flows, pages, and scenarios; deleting a flow garbage-collects its nodes.
Error Handling
func (c *Server) exposeWithRetry(ctx context.Context, hostnames []string, port int) error {
var lastErr error
for attempt := 0; attempt < 3; attempt++ {
_, err := c.client.ExposePort(ctx, c.nodeName, hostnames, port)
if err == nil {
return nil
}
lastErr = err
// Check if retryable
if errors.IsConflict(err) || errors.IsServerTimeout(err) {
time.Sleep(time.Duration(attempt+1) * time.Second)
continue
}
// Not retryable
return err
}
return fmt.Errorf("failed after 3 attempts: %w", lastErr)
}
Best Practices
1. Leader-Only Resource Mutation
if utils.IsLeader(ctx) {
_, err = c.client.ExposePort(ctx, c.nodeName, hostnames, port)
}
2. Idempotent Operations
if s.currentPort == configuredPort {
return nil // Already exposed
}
3. Cleanup on Error
listener, err := net.Listen("tcp", ":0")
if err != nil {
return err
}
if _, err = c.client.ExposePort(ctx, c.nodeName, hostnames, port); err != nil {
listener.Close() // Cleanup on failure
return err
}
4. Update Metadata After Exposure
// After successful exposure — the updater must return error
output(ctx, v1alpha1.ReconcilePort, func(node *v1alpha1.TinyNode) error {
node.Status.Metadata["exposed-port"] = strconv.Itoa(port)
node.Status.Metadata["exposed-at"] = time.Now().Format(time.RFC3339)
return nil
})
Next Steps
- Ingress Exposure - Ingress details
- Multi-Replica Coordination - Leader patterns
- TinyNode CRD - Node specification