Secrets in Settings

Components reference Kubernetes Secrets from their settings with [[secret:<name>/<key>]] placeholders. The placeholder is stored in the node's _settings as-is; the component resolves it in memory with pkg/secret.Resolve when settings arrive. The resolved value is never written back to the TinyNode CR.

Overview

+-----------------------------------------------------------------------------+
|                        SECRET PLACEHOLDER FLOW                               |
+-----------------------------------------------------------------------------+

  Flow Editor                     TinyNode CR                  Component
       |                              |                             |
       |  User enters:                |                             |
       |  [[secret:api-creds/key]]    |                             |
       |          |                   |                             |
       |          +------------------->  Placeholder stored         |
       |                              |  verbatim in _settings      |
       |                              |          |                  |
       |                              |          v                  |
       |                              |  Runner delivers settings   |
       |                              |          |                  |
       |                              |          +------------------>
       |                              |                    OnSettings calls
       |                              |                    secret.Resolve —
       |                              |                    fetches the Secret,
       |                              |                    substitutes in memory
       v                              v                             v

Placeholder Format

[[secret:<name>/<key>]]
  • <name> is the Kubernetes Secret resource name, <key> is a key in its data map. Both must match [A-Za-z0-9._-]+.
  • The placeholder must be the entire string value of the field — no substring substitution, no partial matches. This avoids the "value looks like a placeholder by coincidence" failure mode.
  • The Secret is read from the module pod's own namespace.
  • Malformed placeholders (e.g. [[secret:bad]], missing the slash) are left unchanged.

The syntax uses [[...]] rather than {{...}} because the platform's ajson expression evaluator runs over settings JSON first; it would try to parse {{secret:foo/bar}} as an expression, fail on the colon, and replace the field with nil before the resolver ever saw it.

Resolving in OnSettings

Call secret.Resolve on your settings struct before reading any field that might carry a secret. The resolver walks the struct via reflection (including nested structs, slices, maps and any fields) and substitutes every matching string in place.

import (
    "context"
    "fmt"

    "github.com/tiny-systems/module/module"
    "github.com/tiny-systems/module/pkg/secret"
)

type Settings struct {
    Endpoint string `json:"endpoint" title:"API Endpoint" format:"uri" required:"true"`
    APIKey   string `json:"apiKey" title:"API Key" format:"password" description:"Value or [[secret:<name>/<key>]] placeholder"`
}

type Component struct {
    settings Settings
    client   module.K8sClient
}

// OnClient (module.ClientAware) — the framework injects the K8s client
// once during Update, before OnReconcile/OnSettings.
func (c *Component) OnClient(client module.K8sClient) {
    c.client = client
}

// OnSettings (module.SettingsHandler) — resolve before use.
func (c *Component) OnSettings(ctx context.Context, msg any) error {
    in, ok := msg.(Settings)
    if !ok {
        return fmt.Errorf("invalid settings")
    }
    if err := secret.Resolve(ctx, &in, c.client); err != nil {
        // Fail loud: better at OnSettings than at request time.
        return err
    }
    c.settings = in
    return nil
}

Why resolution is explicit at the call site rather than hidden in the runner:

  • Readers can grep secret.Resolve to find every place a secret enters component memory.
  • No hidden K8s API calls inside the runner — the component owns the lifecycle: when to resolve, when to re-resolve, what to do on failure.
  • The injection point (ClientAware.OnClient) already exists; no extra SDK hook is needed.

Error Contract

SituationBehavior
Secret not found / RBAC deniedError bubbles up — fail loud (recommended)
Key missing inside the SecretError — fail at OnSettings, not at request time
Malformed placeholderIgnored — field stays unchanged
settings not a non-nil pointerError — programmer error, surfaces in tests
client is nilError — implement ClientAware and wait for OnClient

Rotation: TTL Re-delivery

Resolve reads whatever the Secret holds at call time — it does not watch for changes. Rotation pickup is driven by the runner instead: settings whose values contain a placeholder (detected via secret.ContainsPlaceholder) are re-delivered on a TTL (currently 60 seconds, effectively once per reconcile) even when the raw settings bytes are unchanged. OnSettings re-runs, re-resolves, and picks up a created or rotated Secret without a pod restart.

This is why OnSettings must stay idempotent — it can run repeatedly with identical input.

Declaring Secret Requirements

Modules declare which Secrets they may read via module.Requirements, set once in main.go:

import (
    "github.com/tiny-systems/module/module"
    "github.com/tiny-systems/module/registry"
)

registry.SetRequirements(module.Requirements{
    Secrets: module.SecretRequirements{
        // Secret names this module may reference. The install UI prompts
        // the user for the actual names; empty means no Secrets consumed
        // and no Role is created.
        Names: []string{"api-credentials"},
    },
})

At install time the platform renders a Role whose resourceNames is pinned to the names the user supplies — the module's ServiceAccount gets get/list/watch on those exact Secrets in the release namespace, nothing broader.

Creating the Secret

The user creates Secrets out of band, e.g. with kubectl:

kubectl create secret generic api-credentials \
    --namespace tinysystems \
    --from-literal=api-key=sk-12345

Then references it in the node's settings form:

API Key: [[secret:api-credentials/api-key]]

Best Practices

  1. Resolve into a local copy, then assign — keep the placeholder out of any state you might log or publish.
  2. Fail loud on resolution errors — a missing Secret at OnSettings is a configuration bug; silently falling back hides it until a request fails.
  3. Use format:"password" on secret-bearing fields so the editor masks the input.
  4. Document the expected Secret layout in the field's description tag (which keys the Secret must contain).

Next Steps