krply/internal/watch/collector.go
lakshit verma 44fbd878a1
fix(core): storage time ordering, watch state machine, replay safety, API pagination
Storage: fixed-width millisecond timestamp format with a one-time migration
so lexicographic comparisons stay chronological; snapshot auto-IDs get
nanosecond precision; Close is synchronized; commit failures roll back; DSN
paths are URI-escaped; the dedup index is no longer rebuilt every startup;
the in-memory store now dedups by event_id and honors Limit/Offset/SinceSeq.

Event/discovery: stream IDs URL-escape the selector so slash-containing label
selectors round-trip and cannot collide; alias resolution fills empty fields
only and no longer clobbers explicit group/version/kind; rc maps to
replicationcontrollers and rs to replicasets.

Watch: the label selector is now actually applied to List and Watch; the 410
relist path is backoff-throttled, backoff resets after a healthy watch, an
empty list resourceVersion backs off instead of looping forever, non-410
watch errors reconnect without writing a spurious permanent gap, an idle
watch timer forces reconnects, and Run cancels sibling streams and recovers
from panics. Synthetic relist events carry a distinct event_id so unchanged
objects re-listed after a gap survive dedup.

Materialize: a baseline now resets object state (objects deleted during a gap
disappear) and heals open gaps; diff ignores server metadata by path, not by
key name, so user fields named status/uid are kept; nested add/remove carry
Added/Removed flags; dotted paths are escaped; Diff validates the window and
treats a zero before as empty; Snapshot records per-stream watermarks and
writes a TypeSnapshot journal record.

Replay: apply requires a successful dry run; gvrFor covers every policy kind
so Include* toggles work; unsupported kinds are reported as skipped instead
of silently dropped; NodePort services lose clusterIP and nodePort while
headless keeps clusterIP: None; targetNS is always honored; namespace mapping
collisions are detected; plan IDs and field managers are collision-safe and
plans are mutex-guarded against concurrent dry-runs/applies.

API: cursor pagination pushes ingest_seq into the SQL filter (export --server
no longer truncates); gap records expose their payload; /v1/diff requires a
cluster_id; dry-run/apply no longer accept a client-supplied kubeconfig;
plans carry target_context; HTTP server gets timeouts.

Metrics: the watch collector and replay planner now bump the registered
counters, and the server refreshes store-derived gauges on a ticker.

CLI: replay apply works locally and refuses to apply after a failed dry run;
coverage --server follows cursors and shows gap details; timeline
auto-detects namespaces and resolves cluster-scoped objects; diff validates
the window; export writes 0600 files; PVCs are no longer treated as
cluster-scoped; krply-server reports the build version.
2026-08-06 06:50:30 +05:30

179 lines
4.8 KiB
Go

// Package watch implements the krply collector: it lists and watches
// Kubernetes collections with exact resource-version semantics, bookmarks,
// reconnects, 410 Gone relists, and baseline handling.
package watch
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"sync"
"time"
"k8s.io/client-go/dynamic"
"github.com/krply/krply/internal/discovery"
"github.com/krply/krply/internal/metrics"
"github.com/krply/krply/internal/storage"
"github.com/krply/krply/internal/version"
)
// Config configures a Collector.
type Config struct {
// KubeConfig is the path to a kubeconfig file. Empty means in-cluster.
KubeConfig string
// Context is the kubeconfig context to use (empty means the current one).
Context string
// ClusterID is the stable cluster identifier (see ClusterID).
ClusterID string
// Resources are the collections to watch. Empty defaults to
// discovery.DefaultResources().
Resources []discovery.ResourceSpec
// Selector is an optional label selector applied to every stream.
Selector string
// Store is the durable journal. It is required.
Store storage.Store
// Log is the collector logger. Empty defaults to a discard handler.
Log *slog.Logger
// Bookmarks enables allowWatchBookmarks on watch requests. Bookmarks
// advance checkpoints only.
Bookmarks bool
// SendInitial is accepted for API compatibility; the collector uses the
// conventional list-plus-watch path with synthetic baseline events.
SendInitial bool
// AgentName is set as the client-go User-Agent when building the client.
AgentName string
// MinBackoff and MaxBackoff bound the reconnect backoff. Defaults are
// 500ms and 30s when zero.
MinBackoff time.Duration
MaxBackoff time.Duration
// WatchIdleTimeout forces a reconnect when no watch event or bookmark has
// arrived within the window. Zero uses a 10 minute default. It prevents a
// silently dead connection from stalling the stream forever.
WatchIdleTimeout time.Duration
// Metrics, when non-nil, receives ingest counters for this collector.
Metrics *metrics.Metrics
// DynamicClient, when non-nil, is used instead of building a client from
// KubeConfig. It exists to make the collector testable with fake clients.
DynamicClient dynamic.Interface
}
// Collector runs one list-and-watch stream per configured resource.
type Collector struct {
cfg Config
dyn dynamic.Interface
}
// NewCollector validates the configuration and builds the dynamic client.
func NewCollector(cfg Config) (*Collector, error) {
if cfg.Store == nil {
return nil, errors.New("watch: Store is required")
}
applyConfigDefaults(&cfg)
var dyn dynamic.Interface
if cfg.DynamicClient != nil {
dyn = cfg.DynamicClient
} else {
restCfg, err := clientConfig(cfg.KubeConfig, cfg.Context)
if err != nil {
return nil, fmt.Errorf("watch: build rest config: %w", err)
}
restCfg.UserAgent = userAgent(cfg)
dyn, err = dynamic.NewForConfig(restCfg)
if err != nil {
return nil, fmt.Errorf("watch: new dynamic client: %w", err)
}
}
if len(cfg.Resources) == 0 {
cfg.Resources = discovery.DefaultResources()
}
return &Collector{cfg: cfg, dyn: dyn}, nil
}
func applyConfigDefaults(cfg *Config) {
if cfg.MinBackoff <= 0 {
cfg.MinBackoff = 500 * time.Millisecond
}
if cfg.MaxBackoff <= 0 {
cfg.MaxBackoff = 30 * time.Second
}
if cfg.MinBackoff > cfg.MaxBackoff {
cfg.MinBackoff = cfg.MaxBackoff
}
if cfg.WatchIdleTimeout <= 0 {
cfg.WatchIdleTimeout = 10 * time.Minute
}
if cfg.ClusterID == "" {
cfg.ClusterID = "cluster-unknown"
}
if cfg.Log == nil {
cfg.Log = slog.New(slog.NewTextHandler(io.Discard, nil))
}
}
func userAgent(cfg Config) string {
if cfg.AgentName != "" {
return cfg.AgentName
}
return "krply/" + version.Version
}
// Run starts one stream goroutine per configured resource and waits for all of
// them to stop. It returns nil when the context is cancelled and the first
// unrecoverable error (for example a closed store) otherwise. When one stream
// fails, the remaining streams are cancelled so no goroutine keeps writing to
// the store after Run has returned.
func (c *Collector) Run(ctx context.Context) error {
ctx, cancel := context.WithCancel(ctx)
defer cancel()
var wg sync.WaitGroup
errCh := make(chan error, len(c.cfg.Resources))
for _, spec := range c.cfg.Resources {
spec := spec
wg.Add(1)
go func() {
defer wg.Done()
defer func() {
if r := recover(); r != nil {
select {
case errCh <- fmt.Errorf("watch stream panic: %v", r):
case <-ctx.Done():
}
}
}()
if err := c.runStream(ctx, spec); err != nil {
select {
case errCh <- err:
case <-ctx.Done():
}
}
}()
}
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
select {
case <-ctx.Done():
<-done
return nil
case err := <-errCh:
cancel()
<-done
return err
case <-done:
return nil
}
}