Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,13 @@ jobs:
run: go install github.com/securego/gosec/v2/cmd/gosec@v2.22.4

- name: Run GoSec
run: gosec -fmt sarif -out gosec-results.sarif -severity medium -confidence medium -exclude-dir k8s ./...
run: gosec -fmt sarif -out gosec-results.sarif -severity medium -confidence medium -exclude-dir k8s/example ./...

- name: Run GoSec (k8s submodule)
working-directory: k8s
- name: Run GoSec (k8s example adapter)
working-directory: k8s/example
run: |
go mod download
gosec -fmt sarif -out ../gosec-k8s-results.sarif -severity medium -confidence medium ./...
gosec -fmt sarif -out ../../gosec-k8s-example-results.sarif -severity medium -confidence medium ./...

- name: Upload SARIF to GitHub Security
uses: github/codeql-action/upload-sarif@e46ed2cbd01164d986452f91f178727624ae40d7 # v3
Expand All @@ -62,9 +62,9 @@ jobs:
sarif_file: gosec-results.sarif
category: gosec

- name: Upload SARIF (k8s) to GitHub Security
- name: Upload SARIF (k8s example) to GitHub Security
uses: github/codeql-action/upload-sarif@e46ed2cbd01164d986452f91f178727624ae40d7 # v3
if: always()
with:
sarif_file: gosec-k8s-results.sarif
category: gosec-k8s
sarif_file: gosec-k8s-example-results.sarif
category: gosec-k8s-example
29 changes: 26 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,11 +70,33 @@ config/
│ ├── store.go # Fallback, ReadThrough, WriteThrough strategies
│ └── options.go
└── live/ # Live config binding
├── ref.go # Atomic live reference (Ref[T])
└── binding.go # Mutex-based live binding
├── live/ # Live config binding
│ ├── ref.go # Atomic live reference (Ref[T])
│ └── binding.go # Mutex-based live binding
└── k8s/ # Kubernetes ConfigMap/Secret store (no k8s.io deps)
├── client.go # Client interface, Resource, Event, ErrNotFound
├── store.go # Store implementation built on Client
├── options.go # Store options
├── watch.go # Watch fan-out
└── example/ # Reference adapter (separate go.mod, depends on k8s.io/*)
├── adapter.go # kubernetes.Interface → Client adapter
└── main/ # Runnable demo
```

### k8s store

Unlike the postgres or mongodb stores, the k8s store does NOT import `k8s.io/*`.
Instead it depends on a narrow `k8s.Client` interface (Get/Upsert/Watch/Health)
and the caller supplies an adapter — see `k8s/example/` for a reference
implementation built on `kubernetes.Interface`. This keeps the heavy kubernetes
transitive dependency out of `github.com/rbaliyan/config` and lets the store
ride the same release cadence as the rest of the module.

The store does not maintain a local resource cache: reads call straight through
to the Client and Manager-level caching handles repeats, mirroring postgres /
mongodb behavior.

## Key Design Decisions

### Value vs Entry
Expand Down Expand Up @@ -331,6 +353,7 @@ Optional (for specific backends):

## Recent Changes

- **k8s store decoupled from k8s.io/***: The `k8s` package now depends only on a `Client` interface (Get/Upsert/Watch/Health) instead of `kubernetes.Interface`. The previous `k8s/go.mod` is gone — k8s ships in the main module. A reference adapter using `kubernetes.Interface` lives at `k8s/example/` with its own `go.mod`. The store no longer maintains an informer cache; reads call straight through to the Client.
- **Removed deprecated `live.Binding`**: Use `live.Ref[T]` instead for lock-free atomic reads. `DefaultPollInterval` and `ErrInvalidTarget` remain in the `live` package.
- **Unexported `otel.Metrics`**: The metrics struct and fields are now unexported (`metrics`, `operationCount`, `errorCount`, `operationLatency`)
- **Benchmarks**: `benchmark_test.go` provides benchmarks for Manager.Get, Value operations, MarkStale, and Store operations
Expand Down
115 changes: 115 additions & 0 deletions k8s/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package k8s

import (
"context"
"errors"
)

// Kind identifies which Kubernetes resource type a Resource represents.
// Store routes ConfigMap reads/writes for non-secret keys and Secret reads/writes
// for keys matching the configured secret prefix.
type Kind int

const (
// KindConfigMap represents a Kubernetes ConfigMap.
KindConfigMap Kind = iota
// KindSecret represents a Kubernetes Secret.
KindSecret
)

// String returns the human-readable name of the kind.
func (k Kind) String() string {
switch k {
case KindConfigMap:
return "configmap"
case KindSecret:
return "secret"
default:
return "unknown"
}
}

// Resource is the kubernetes-agnostic projection of a ConfigMap or Secret used
// by Store. ConfigMap string data and Secret byte data are both represented as
// []byte here; adapters convert between this form and the wire types.
type Resource struct {
// Name is the resource name (e.g., "config-prod" or "config-secrets-prod").
Name string
// ResourceVersion is the Kubernetes ResourceVersion, used as the value version.
ResourceVersion string
// Annotations carries codec-name annotations populated by the Store.
// Adapters must round-trip annotations exactly.
Annotations map[string]string
// Data is the resource payload keyed by Kubernetes data key (slash converted to dot).
Data map[string][]byte
}

// EventType describes the type of change observed in a Watch stream.
type EventType int

const (
// EventAdd is emitted when a watched resource is created or first observed.
EventAdd EventType = iota
// EventUpdate is emitted when a watched resource is modified.
EventUpdate
// EventDelete is emitted when a watched resource is removed.
EventDelete
)

// Event is a single change notification produced by Client.Watch.
type Event struct {
// Type is the kind of change.
Type EventType
// Kind identifies whether the change applies to a ConfigMap or a Secret.
Kind Kind
// Namespace is the Kubernetes namespace of the resource.
Namespace string
// Old is the previous resource state. Best-effort: adapters may set it on
// EventUpdate and EventDelete to enable minimal-diff dispatch by the Store,
// but a nil Old is supported — the Store treats every key in New as
// changed and skips delete inference, which is safe but noisier.
Old *Resource
// New is the new resource state. Set on EventAdd and EventUpdate.
New *Resource
}

// ErrNotFound is returned by Client.Get when the requested resource does not
// exist. Adapters must translate Kubernetes "not found" API errors to this
// sentinel so the Store can surface config.ErrNotFound to callers.
var ErrNotFound = errors.New("k8s resource not found")

// Client is the kubernetes-facing surface required by Store. It is intentionally
// narrow so that adapters only need to translate a handful of operations and
// the main config module does not depend on k8s.io/* packages.
//
// Implementations must be safe for concurrent use by multiple goroutines.
//
// See k8s/example for a reference adapter built on top of kubernetes.Interface.
type Client interface {
// Get fetches a resource by namespace and name. Implementations must return
// ErrNotFound when the resource does not exist. Other errors are wrapped
// and surfaced verbatim by the Store.
Get(ctx context.Context, kind Kind, namespace, name string) (*Resource, error)

// Upsert creates the resource if it does not exist, or updates it otherwise.
// The returned Resource carries the post-write ResourceVersion.
Upsert(ctx context.Context, kind Kind, namespace string, r *Resource) (*Resource, error)

// Watch starts a watch over both ConfigMaps and Secrets in namespace
// (or all namespaces when namespace is "").
//
// Required: emit one Event per observed add/update/delete; close the
// returned channel when ctx is cancelled or the underlying watch
// terminates. The Store ignores events for resources whose names do not
// match its managed prefixes ("config-" and "config-secrets-"), so
// adapters do not need to filter.
//
// Recommended: reconnect transparently on transient API errors (e.g., via
// k8s.io/client-go/tools/watch.NewRetryWatcher). The bundled reference
// adapter does not, and closes the channel on the first upstream error.
Watch(ctx context.Context, namespace string) (<-chan Event, error)

// Health performs a lightweight liveness check (e.g., listing namespaces).
// Used by Store.Health.
Health(ctx context.Context) error
}
62 changes: 62 additions & 0 deletions k8s/example/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Kubernetes adapter example

This module is a reference implementation of the
[`k8s.Client`](../client.go) interface defined by `github.com/rbaliyan/config/k8s`.
It is kept in a separate Go module so the main `config` module never depends on
`k8s.io/*` packages — config and the k8s store can be released on a single
cadence, while this adapter follows the kubernetes client release cycle.

## What the adapter provides

| `k8s.Client` method | Implementation |
|---|---|
| `Get` | `CoreV1().{ConfigMaps,Secrets}.Get` |
| `Upsert` | Create-or-Update on conflict (`IsAlreadyExists` → Update) |
| `Watch` | `CoreV1().{ConfigMaps,Secrets}.Watch` merged into one channel |
| `Health` | `CoreV1().Namespaces.List(Limit: 1)` |

There is **no informer cache** — reads go straight to the API server and the
config Manager handles repeat-read caching. This mirrors how the postgres and
mongodb stores behave.

## Wiring it up

```go
import (
"context"

"github.com/rbaliyan/config/k8s"
kubeclient "github.com/rbaliyan/config/k8s/example"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/rest"
)

ctx := context.Background()

cfg, _ := rest.InClusterConfig() // or clientcmd.BuildConfigFromFlags(...)
cs, _ := kubernetes.NewForConfig(cfg)

store := k8s.NewStore(kubeclient.New(cs),
k8s.WithK8sNamespace("config-system"), // pin to one k8s namespace
k8s.WithSecretKeyPrefix("secret/"), // route secret/* keys to k8s Secrets
)

if err := store.Connect(ctx); err != nil { ... }
defer store.Close(ctx)
```

See [`main/main.go`](./main/main.go) for a runnable end-to-end demo.

## Notes for production use

- **Reconnect on watch loss.** The adapter does not automatically resume a
dropped watch. Wrap the upstream `watch.Interface` with
[`watch.NewRetryWatcher`](https://pkg.go.dev/k8s.io/client-go/tools/watch#NewRetryWatcher)
if you need that behavior; the rest of the code remains unchanged.
- **RBAC.** The service account needs `get`, `list`, `watch`, `create`, `update`
on `configmaps` and `secrets` in the target namespace, plus `list` on
`namespaces` for the health check.
- **Annotations.** The store records the codec for each key in an annotation
(`config.rbaliyan.dev/codec-<key>`). The adapter must round-trip annotations
exactly; this implementation does so via the standard ConfigMap/Secret
metadata.
Loading