cf_secrets

package module
v0.0.1 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 19, 2026 License: Apache-2.0 Imports: 28 Imported by: 0

README

caerus-framework-secrets

CI codecov License

Caerus Framework secrets chassis. One component holds several named providers. Callers ask for a secret by provider name plus a path/key; they do not switch on Vault vs AWS vs GCP themselves.

This module is declared in main like postgres/valkey. It is not auto-registered with logs/configuration/observability. It initializes in the bootstrap secrets stage (after configuration, before data) so data clients can read credentials during their own Init if they hold this component pointer.

Design (why named providers)

Wrong vs right:

Wrong: GetSecret("db_password") and the chassis guesses the backend
Right: Get(ctx, "openbao", Ref{Path: "app/db", Key: "password"})

Wrong: app code `switch cfg.Kind { case "aws": … case "vault": … }`
Right: app code always calls Get; kind is only in secrets.json

Provider name (map key, Get’s first argument) and kind (which API) are two different strings. Prefer matching them when you have one backend ("openbao" + kind: "openbao"). They may differ ("prod-kv" + kind: "vault").

OpenBao vs Vault are two kinds that share the same KV v2 HTTP client. They are not two SDKs. Use kind: openbao or kind: vault so metrics and docs say which product you pointed address at. Kubernetes auth (k8s_role) and token file (token_path) are the same for both.

This is not External Secrets Operator. ESO copies Bao/Vault into a Kubernetes Secret for the pod. This chassis is for in-process fetches (apps that call Get at Init or per use). You can use both: ESO for the GitHub App PEM, this module for app-level secrets later.

Credentials for talking to the backends (Bao token, AWS chain, GCP ADC) are still a bootstrap problem: they live in this component’s config file or the cloud default chain. We will grow AppRole / extra AWS/GCP settings when the file shape below is wrong for a real deploy.

Wiring

Two wiring shapes. Prefer the app-owned shape.

App-owned consumer (golden — demoapp pattern)

main declares secrets as chassis. The app stores *CFSecrets and calls Get / GetString per use (do not snapshot the bytes at Init if the owner must see rotations — call Get again, or reload via config).

fw := cf.New(&cf.FrameworkOptions{
	Logs:          &cf.LogsSettings{Format: "json", Level: "info", ConfigSource: "logs"},
	Observability: &cf.ObservabilitySettings{Bind: ":9090", ConfigSource: "observability"},
	Components: []cf.CaerusComponent{
		cf_secrets.New(cf_secrets.WithConfigSource("secrets", "config/secrets.json")),
		cf_postgres.New(cf_postgres.WithConfigSource("postgresql", "config/postgresql.json")),
		app.New(),
	},
})
type App struct {
	secrets *cf_secrets.CFSecrets
}

func (a *App) GetDependencies() []string {
	return []string{cf_secrets.ComponentName} // "secrets", not the source nickname
}

func (a *App) Init(ctx context.Context, fw *cf.CaerusFramework) error {
	sec, ok := cf.Get[*cf_secrets.CFSecrets](fw)
	if !ok {
		return errors.New("app: secrets component missing")
	}
	a.secrets = sec
	return nil
}

func (a *App) webhookHMAC(ctx context.Context) (string, error) {
	return a.secrets.GetString(ctx, "openbao", cf_secrets.Ref{
		Path: "caerus-framework/release-train-gh-app",
		Key:  "webhook-secret",
	})
}
Simple main-level wiring
fw := cf.New()
fw.AddComponent(cf_logs.New(cf_logs.WithWriter(os.Stdout)))
fw.AddComponent(cf_secrets.New(cf_secrets.WithConfigSource("secrets", "config/secrets.json")))

Then cf.MustGet[*cf_secrets.CFSecrets](fw). Same Get API.

Configuration

File is canonical. Example config/secrets.json:

{
  "degraded_mode": false,
  "health_when_degraded": "not_ready",
  "providers": {
    "openbao": {
      "kind": "openbao",
      "address": "https://openbao.example.com:8200",
      "kv_mount": "secret",
      "token_path": "/var/run/secrets/openbao/token"
    },
    "vault": {
      "kind": "vault",
      "address": "https://vault.example.com:8200",
      "kv_mount": "secret",
      "k8s_role": "release-train",
      "k8s_mount": "kubernetes"
    },
    "aws": {
      "kind": "aws",
      "region": "eu-central-1"
    },
    "gcp": {
      "kind": "gcp",
      "project": "my-gcp-project"
    }
  }
}

You only list the providers this process uses. An unused kind is omitted.

Kind Get Path Get Key Auth in v1
openbao / vault KV path under kv_mount property in the KV data map token / token_path, or k8s_role (+ JWT file)
aws Secrets Manager name or ARN JSON object key if the secret string is JSON AWS default credential chain (region required)
gcp secret id, or full projects/…/secrets/… JSON object key if payload is JSON Application Default Credentials (project required)
file path under root JSON object key if the file is JSON local / tests only

kind: file is for laptop tests. It is not a production secret store.

Auth paths for Vault/OpenBao (pick one per provider)

Path Token-file (typical in Kubernetes): token_path points at a mounted file; the process reads it at Init (and again if you later add reload-from-file).

Path Kubernetes auth: set k8s_role; the driver POSTs the service-account JWT (k8s_jwt_path, default the in-cluster token path) to /v1/auth/<k8s_mount>/login.

Path Inline token (dev / break-glass): token in the config file. Do not use this in production files that are not a Secret.

Lifecycle

  • Init builds clients and pings each provider (Vault/OpenBao sys/health, AWS ListSecrets, GCP list, file stat of root). Hard failure by default.
  • DegradedMode lets Init finish when a ping fails; logs and metrics scream; /readyz stays red unless health_when_degraded: ready.
  • Get always hits the live driver (no snapshot of secret bytes on the chassis).
  • OnConfigReload rebuilds drivers; last-good stays if rebuild/ping fails (unless DegradedMode already allowed a partial set).
flowchart TD
  A[Caller Get provider+Ref] --> B{Provider name in map?}
  B -->|no| C[error unknown provider]
  B -->|yes| D[kind from that provider's config]
  D -->|vault / openbao| E[KV v2 HTTP]
  D -->|aws| F[Secrets Manager]
  D -->|gcp| G[Secret Manager]
  D -->|file| H[read root/path]

Health and metrics

Health re-pings every provider. Metrics (cf_secrets_*) include provider count, Get totals, ping failures, DegradedMode.

License

Apache License 2.0 — see LICENSE and NOTICE.

Documentation

Index

Constants

View Source
const (
	// ComponentName is the framework registry identity. Peers list this in
	// GetDependencies. It is not the configuration source name (that comes
	// from WithConfigSource).
	ComponentName = "secrets"
)
View Source
const ComponentStage = cf.SecretsStage

ComponentStage is the bootstrap secrets stage (after logs, configuration, observability; before data). The framework already registers this stage; this component is still declared in main — it is not auto-inserted like logs.

Variables

This section is empty.

Functions

This section is empty.

Types

type CFSecrets

type CFSecrets struct {
	// contains filtered or unexported fields
}

CFSecrets is the secrets chassis: named providers, one Get API.

func New

func New(opts ...Option) *CFSecrets

New constructs the component. Providers are opened at Init.

func (*CFSecrets) Get

func (c *CFSecrets) Get(ctx context.Context, provider string, ref Ref) ([]byte, error)

Get fetches a secret from the named provider. The name is the map key in config (e.g. "openbao"), not the kind. Callers must not switch on kind.

func (*CFSecrets) GetDependencies

func (c *CFSecrets) GetDependencies() []string

GetDependencies implements cf.Dependencies.

func (*CFSecrets) GetInitOrderStage

func (c *CFSecrets) GetInitOrderStage() cf.Stage

GetInitOrderStage implements cf.CaerusComponent.

func (*CFSecrets) GetString

func (c *CFSecrets) GetString(ctx context.Context, provider string, ref Ref) (string, error)

GetString is Get decoded as text (not trimmed — callers own whitespace).

func (*CFSecrets) Health

func (c *CFSecrets) Health(ctx context.Context) error

Health implements cf.HealthProvider. Unhealthy before Init, after Shutdown, and when a provider ping last failed (unless health_when_degraded=ready).

func (*CFSecrets) Init

func (c *CFSecrets) Init(ctx context.Context, fw *cf.CaerusFramework) error

Init opens every configured provider and pings it.

func (*CFSecrets) Metrics

func (c *CFSecrets) Metrics() []cf_observability.Metric

Metrics implements cf_observability.MetricsProvider.

func (*CFSecrets) Name

func (c *CFSecrets) Name() string

Name implements cf.CaerusComponent.

func (*CFSecrets) OnConfigReload

func (c *CFSecrets) OnConfigReload(source string, cfg any)

OnConfigReload implements cf.ConfigReloader. Failed rebuild keeps last-good drivers.

func (*CFSecrets) RegisterConfigSources

func (c *CFSecrets) RegisterConfigSources(conf any) error

RegisterConfigSources implements cf.ConfigSourceRegistrar.

func (*CFSecrets) Shutdown

func (c *CFSecrets) Shutdown(ctx context.Context) error

Shutdown closes provider clients.

type Option

type Option func(*options)

Option configures the secrets component at construction time.

func WithConfig

func WithConfig(cfg SecretsConfig) Option

WithConfig sets a static configuration snapshot (tests, embedded use).

func WithConfigSource

func WithConfigSource(name, path string, opts ...SourceOption) Option

WithConfigSource binds this component to a named configuration source and registers that source with the configuration component during argv absorption.

cf_secrets.New(cf_secrets.WithConfigSource("secrets", "config/secrets.json"))

func WithDegradedMode

func WithDegradedMode() Option

WithDegradedMode lets Init succeed when a provider ping fails.

func WithLogger

func WithLogger(logger *slog.Logger) Option

WithLogger overrides the framework logger (tests / embedded).

func WithName

func WithName(name string) Option

WithName sets a custom component name (multiple secrets instances).

type ProviderConfig

type ProviderConfig struct {
	// Kind is the driver: vault, openbao, aws, gcp, file.
	Kind string `json:"kind" yaml:"kind"`

	// Address is the Vault/OpenBao API base URL (https://host:8200).
	Address string `json:"address,omitempty" yaml:"address,omitempty"`
	// Namespace is a Vault Enterprise namespace. Unused for OpenBao.
	Namespace string `json:"namespace,omitempty" yaml:"namespace,omitempty"`
	// KVMount is the KV secrets engine mount (default "secret").
	KVMount string `json:"kv_mount,omitempty" yaml:"kv_mount,omitempty"`
	// Token is a Vault/OpenBao token (dev/break-glass). Prefer TokenPath.
	Token string `json:"token,omitempty" yaml:"token,omitempty" secret:"redact"`
	// TokenPath is a file whose contents are the Vault/OpenBao token
	// (Kubernetes-rotated file).
	TokenPath string `json:"token_path,omitempty" yaml:"token_path,omitempty"`
	// K8sRole is the Vault/OpenBao Kubernetes auth role. When set, Init
	// logs in with the JWT at K8sJWTPath instead of Token/TokenPath.
	K8sRole string `json:"k8s_role,omitempty" yaml:"k8s_role,omitempty"`
	// K8sMount is the Kubernetes auth mount (default "kubernetes").
	K8sMount string `json:"k8s_mount,omitempty" yaml:"k8s_mount,omitempty"`
	// K8sJWTPath is the service-account token file (default
	// /var/run/secrets/kubernetes.io/serviceaccount/token).
	K8sJWTPath string `json:"k8s_jwt_path,omitempty" yaml:"k8s_jwt_path,omitempty"`
	// TLSCAFile is an optional PEM CA for Vault/OpenBao HTTPS.
	TLSCAFile string `json:"tls_ca_file,omitempty" yaml:"tls_ca_file,omitempty"`
	// TLSInsecureSkipVerify skips TLS verify (lab only).
	TLSInsecureSkipVerify *bool `json:"tls_insecure_skip_verify,omitempty" yaml:"tls_insecure_skip_verify,omitempty"`

	// Region is the AWS region (required for kind aws).
	Region string `json:"region,omitempty" yaml:"region,omitempty"`
	// Endpoint overrides the AWS Secrets Manager endpoint (LocalStack / tests).
	Endpoint string `json:"endpoint,omitempty" yaml:"endpoint,omitempty"`

	// Project is the GCP project id (required for kind gcp).
	Project string `json:"project,omitempty" yaml:"project,omitempty"`
	// CredentialsFile is an optional Google service-account JSON path.
	// Empty uses Application Default Credentials.
	CredentialsFile string `json:"credentials_file,omitempty" yaml:"credentials_file,omitempty"`

	// Root is the directory for kind file (local / tests).
	Root string `json:"root,omitempty" yaml:"root,omitempty"`

	// TimeoutSec bounds each provider HTTP/SDK call (default 10s).
	TimeoutSec float64 `json:"timeout_sec,omitempty" yaml:"timeout_sec,omitempty"`
}

ProviderConfig is one named backend. Kind selects the API; the rest of the fields are interpreted by that kind. Unused fields for another kind are ignored (so one struct can hold vault, aws, gcp, and file settings).

Credential fields will grow (AppRole, IRSA-specific, GCP SA JSON path). v1: token/token_path or Kubernetes JWT for vault/openbao; AWS default credential chain; GCP Application Default Credentials.

type Ref

type Ref struct {
	Path    string
	Key     string
	Version string
}

Ref names one secret inside a provider. Callers always pass a provider name plus a Ref; they never choose the API (vault vs aws) themselves.

Path meanings by kind:

  • vault / openbao: KV path under kv_mount (e.g. "caerus-framework/release-train-gh-app")
  • aws: Secrets Manager name or ARN
  • gcp: secret id (short name, or full "projects/…/secrets/…" resource)
  • file: path relative to the provider root

Key, when set, selects a field: Vault/OpenBao KV property, or a JSON object key for AWS/GCP string payloads. Empty Key returns the whole payload.

Version is optional (GCP version id, AWS version id/stage, Vault KV version). Empty means the provider default (usually latest).

type SecretsConfig

type SecretsConfig struct {
	// Providers is the named set of backends. The map key is the provider
	// name callers pass to Get (e.g. "openbao", "aws-prod"). It is not the
	// kind.
	Providers map[string]ProviderConfig `json:"providers" yaml:"providers"`
	// DegradedMode — when true, a failed Init ping of a provider does not
	// abort the process. Get still fails until that backend is reachable.
	// Default off (hard Init). Pointer so omitted ≠ explicit false.
	DegradedMode *bool `json:"degraded_mode,omitempty" yaml:"degraded_mode,omitempty" env:"DEGRADED_MODE"`
	// HealthWhenDegraded: "not_ready" (default) or "ready". Controls Health()
	// (and thus /readyz) while a configured provider cannot ping.
	HealthWhenDegraded string `json:"health_when_degraded,omitempty" yaml:"health_when_degraded,omitempty" env:"HEALTH_WHEN_DEGRADED"`
}

SecretsConfig is the file/env-drivable configuration for the secrets chassis. Load it through the configuration component and pass it via WithConfigSource. The file is the canonical place to declare providers (Kubernetes: a mounted ConfigMap/Secret). Env overlay of the providers map is not the rotation plane.

type SourceOption

type SourceOption func(*sourceOptions)

SourceOption configures the self-registered configuration source created by WithConfigSource.

func WithSourceEnvPrefix

func WithSourceEnvPrefix(prefix string) SourceOption

WithSourceEnvPrefix sets the environment overlay prefix for the source (default: the uppercase source name with "-" replaced by "_", plus "_"). An empty prefix disables env overlay.

func WithSourceFormat

func WithSourceFormat(f cf_configuration.Format) SourceOption

WithSourceFormat forces the file format instead of inferring it from the path extension.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL