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:

  • CreateNode sets the owning TinyFlow as controller of the TinyNode (via the tinysystems.io/flow-name label)
  • CreateFlow, CreatePage, and CreateScenario set 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