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 itsdatamap. 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.Resolveto 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
| Situation | Behavior |
|---|---|
| Secret not found / RBAC denied | Error bubbles up — fail loud (recommended) |
| Key missing inside the Secret | Error — fail at OnSettings, not at request time |
| Malformed placeholder | Ignored — field stays unchanged |
settings not a non-nil pointer | Error — programmer error, surfaces in tests |
client is nil | Error — 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
- Resolve into a local copy, then assign — keep the placeholder out of any state you might log or publish.
- Fail loud on resolution errors — a missing Secret at
OnSettingsis a configuration bug; silently falling back hides it until a request fails. - Use
format:"password"on secret-bearing fields so the editor masks the input. - Document the expected Secret layout in the field's
descriptiontag (which keys the Secret must contain).
Next Steps
- Schema from Go - Struct-based schemas
- Dynamic Schemas - Runtime schema generation
- Settings and Configuration - Settings patterns