From 7ad07593b650e17a67b2a608ff982e9ac2db12ec Mon Sep 17 00:00:00 2001 From: Gnani Rahul Date: Fri, 10 Jul 2026 15:07:46 -0500 Subject: [PATCH 01/14] feat(connector): add the source-abstract contract Extend the fleet model and add the capability-checked seven-verb registry plus the Reader-to-Source bridge that local and hub adapters share. GSTACK-Checkpoint: 2026-07-10/slice-1-source-adapter#1 Signed-off-by: Gnani Rahul --- internal/connector/contract.go | 197 +++++++++++ internal/connector/registry.go | 317 ++++++++++++++++++ internal/connector/registry_test.go | 226 +++++++++++++ internal/connector/source.go | 104 ++++++ internal/connector/source_test.go | 62 ++++ internal/fleet/model.go | 6 + internal/fleet/resource.go | 190 +++++++++++ internal/fleet/resource_test.go | 123 +++++++ sessions/2026-07-10-slice-1-source-adapter.md | 30 ++ 9 files changed, 1255 insertions(+) create mode 100644 internal/connector/contract.go create mode 100644 internal/connector/registry.go create mode 100644 internal/connector/registry_test.go create mode 100644 internal/connector/source.go create mode 100644 internal/connector/source_test.go create mode 100644 internal/fleet/resource.go create mode 100644 internal/fleet/resource_test.go create mode 100644 sessions/2026-07-10-slice-1-source-adapter.md diff --git a/internal/connector/contract.go b/internal/connector/contract.go new file mode 100644 index 0000000..4c022af --- /dev/null +++ b/internal/connector/contract.go @@ -0,0 +1,197 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package connector defines Sith's capability-scoped source-adapter contract. +package connector + +import ( + "context" + "encoding/json" + "time" + + "github.com/ArdurAI/sith/internal/fleet" +) + +// Connector identifies one canonical integration and its declared capabilities. +type Connector interface { + Kind() string + Capabilities() []Capability + Descriptor() Descriptor +} + +// Descriptor is static registry, taxonomy, ownership, and version metadata. +type Descriptor struct { + Kind string `json:"kind"` + ConnKind ConnectorKind `json:"connector_kind"` + ProtocolV string `json:"protocol_version"` + Owner string `json:"owner"` + Capabilities []Capability `json:"capabilities"` + Verbs []string `json:"verbs,omitempty"` +} + +// ConnectorKind is the closed integration taxonomy. +// +//nolint:revive // ConnectorKind is the locked cross-connector contract name from issue #38. +type ConnectorKind string + +// Supported connector kinds. +const ( + KindReadAdapter ConnectorKind = "read-adapter" + KindBrokeredRead ConnectorKind = "brokered-read-through" + KindTypedAction ConnectorKind = "typed-action" +) + +// Valid reports whether the connector kind belongs to the closed taxonomy. +func (kind ConnectorKind) Valid() bool { + switch kind { + case KindReadAdapter, KindBrokeredRead, KindTypedAction: + return true + default: + return false + } +} + +// Capability names one verb a connector explicitly opts into. +type Capability string + +// Supported connector capabilities. +const ( + CapDiscover Capability = "discover" + CapRead Capability = "read" + CapQuery Capability = "query" + CapDiff Capability = "diff" + CapPlan Capability = "plan" + CapExecute Capability = "execute" + CapVerify Capability = "verify" +) + +// Valid reports whether the capability belongs to the seven-verb contract. +func (capability Capability) Valid() bool { + switch capability { + case CapDiscover, CapRead, CapQuery, CapDiff, CapPlan, CapExecute, CapVerify: + return true + default: + return false + } +} + +// Reader implements the discover, read, and query half of the connector contract. +type Reader interface { + Connector + Discover(ctx context.Context) (Discovery, error) + Read(ctx context.Context, ref fleet.ResourceRef) (fleet.Evidence, error) + Query(ctx context.Context, query fleet.Query) (fleet.QueryResult, error) +} + +// Discovery describes the scopes a reader can currently address. +type Discovery struct { + Scopes []Scope `json:"scopes"` + Unreachable []string `json:"unreachable,omitempty"` +} + +// Scope is one cluster, context, or spoke exposed by a reader. +type Scope struct { + Name string `json:"name"` + Kinds []string `json:"kinds"` + Reachable bool `json:"reachable"` + ObservedAt time.Time `json:"observed_at,omitempty"` +} + +// Differ computes desired-versus-observed state without mutation. +type Differ interface { + Connector + Diff(ctx context.Context, request DiffRequest) (fleet.Diff, error) +} + +// Planner converts a validated typed intent into an inspectable dry-run plan. +type Planner interface { + Connector + Plan(ctx context.Context, intent Intent) (ActionPlan, error) +} + +// Executor applies a previously approved action plan through the governed path. +type Executor interface { + Connector + Execute(ctx context.Context, plan ActionPlan) (ExecutionResult, error) +} + +// Verifier checks post-conditions after an execution. +type Verifier interface { + Connector + Verify(ctx context.Context, request VerifyRequest) (Verification, error) +} + +// Intent is a validated, signed request from the closed action vocabulary. +type Intent struct { + ID string `json:"id"` + Workspace string `json:"workspace"` + Actor string `json:"actor"` + Verb string `json:"verb"` + Target fleet.ResourceRef `json:"target"` + Args json.RawMessage `json:"args"` + Justification string `json:"justification"` + EvidenceRefs []fleet.ResourceRef `json:"evidence_refs,omitempty"` + Signature string `json:"signature"` +} + +// ActionPlan is the non-mutating, inspectable result of planning an intent. +type ActionPlan struct { + IntentID string `json:"intent_id"` + Verb string `json:"verb"` + Target fleet.ResourceRef `json:"target"` + Diff fleet.Diff `json:"diff"` + Steps []PlanStep `json:"steps"` + Reversible bool `json:"reversible"` + Warnings []string `json:"warnings,omitempty"` +} + +// PlanStep is one ordered typed API call; it is never a shell command. +type PlanStep struct { + Description string `json:"description"` + API string `json:"api"` + Params json.RawMessage `json:"params"` +} + +// ExecutionResult records the observed outcome of an approved plan. +type ExecutionResult struct { + IntentID string `json:"intent_id"` + Applied bool `json:"applied"` + StepsDone int `json:"steps_done"` + Observed fleet.Evidence `json:"observed"` + StartedAt time.Time `json:"started_at"` + FinishedAt time.Time `json:"finished_at"` + Err string `json:"err,omitempty"` +} + +// VerifyRequest is a typed post-condition assertion. +type VerifyRequest struct { + IntentID string `json:"intent_id"` + Target fleet.ResourceRef `json:"target"` + Expect fleet.Selector `json:"expect"` +} + +// Verification is the observed verdict for a post-condition. +type Verification struct { + Satisfied bool `json:"satisfied"` + Observed fleet.Evidence `json:"observed"` + Detail string `json:"detail,omitempty"` + CheckedAt time.Time `json:"checked_at"` +} + +// DiffRequest asks a connector to compare desired and observed state. +type DiffRequest struct { + Target fleet.ResourceRef `json:"target"` + Desired json.RawMessage `json:"desired,omitempty"` +} + +// ValidVerb reports whether a verb belongs to the reviewed initial action vocabulary. +func ValidVerb(verb string) bool { + switch verb { + case "argocd.sync", "argocd.rollback", + "rollout.promote", "rollout.abort", + "deployment.scale", "deployment.restart", + "gitops.open-pr": + return true + default: + return false + } +} diff --git a/internal/connector/registry.go b/internal/connector/registry.go new file mode 100644 index 0000000..6966d9e --- /dev/null +++ b/internal/connector/registry.go @@ -0,0 +1,317 @@ +// SPDX-License-Identifier: Apache-2.0 + +package connector + +import ( + "errors" + "fmt" + "reflect" + "sort" + "sync" +) + +// ErrNotRegistered reports a lookup for an unknown connector kind. +var ErrNotRegistered = errors.New("connector is not registered") + +// ErrCapability reports a lookup for a capability the connector did not opt into. +var ErrCapability = errors.New("connector capability is unavailable") + +// Factory constructs a configured connector for fail-safe registration. +type Factory func() (Connector, error) + +type registryEntry struct { + connector Connector + descriptor Descriptor + declared map[Capability]struct{} +} + +// Registry stores one canonical, capability-checked connector per kind. +type Registry struct { + mu sync.RWMutex + entries map[string]registryEntry +} + +// NewRegistry returns an empty connector registry. +func NewRegistry() *Registry { + return &Registry{entries: make(map[string]registryEntry)} +} + +// Register builds and validates a connector before atomically adding it. +func (registry *Registry) Register(factory Factory) error { + if factory == nil { + return fmt.Errorf("register connector: factory is nil") + } + + candidate, err := factory() + if err != nil { + return fmt.Errorf("register connector: construct: %w", err) + } + if connectorIsNil(candidate) { + return fmt.Errorf("register connector: factory returned nil") + } + + entry, err := validateConnector(candidate) + if err != nil { + return fmt.Errorf("register connector %q: %w", candidate.Kind(), err) + } + + registry.mu.Lock() + defer registry.mu.Unlock() + if _, exists := registry.entries[entry.descriptor.Kind]; exists { + return fmt.Errorf("register connector %q: kind already registered", entry.descriptor.Kind) + } + registry.entries[entry.descriptor.Kind] = entry + return nil +} + +// ByKind returns the canonical connector registered for kind. +func (registry *Registry) ByKind(kind string) (Connector, bool) { + registry.mu.RLock() + defer registry.mu.RUnlock() + entry, ok := registry.entries[kind] + return entry.connector, ok +} + +// WithCapability lists connectors that both declare and implement a capability. +func (registry *Registry) WithCapability(capability Capability) []Connector { + if !capability.Valid() { + return []Connector{} + } + + registry.mu.RLock() + entries := make([]registryEntry, 0, len(registry.entries)) + for _, entry := range registry.entries { + if _, declared := entry.declared[capability]; declared && implementsCapability(entry.connector, capability) { + entries = append(entries, entry) + } + } + registry.mu.RUnlock() + + sort.Slice(entries, func(left, right int) bool { + return entries[left].descriptor.Kind < entries[right].descriptor.Kind + }) + connectors := make([]Connector, 0, len(entries)) + for _, entry := range entries { + connectors = append(connectors, entry.connector) + } + return connectors +} + +// Descriptors returns deterministically ordered copies of registered metadata. +func (registry *Registry) Descriptors() []Descriptor { + registry.mu.RLock() + descriptors := make([]Descriptor, 0, len(registry.entries)) + for _, entry := range registry.entries { + descriptors = append(descriptors, cloneDescriptor(entry.descriptor)) + } + registry.mu.RUnlock() + + sort.Slice(descriptors, func(left, right int) bool { + return descriptors[left].Kind < descriptors[right].Kind + }) + return descriptors +} + +// ReaderFor returns a registered connector that declared read and implements Reader. +func (registry *Registry) ReaderFor(kind string) (Reader, error) { + entry, err := registry.entryFor(kind, CapRead, false) + if err != nil { + return nil, err + } + reader, ok := entry.connector.(Reader) + if !ok { + return nil, fmt.Errorf("%w: %s does not implement reader", ErrCapability, kind) + } + return reader, nil +} + +// DifferFor returns a registered connector that declared and implements diff. +func (registry *Registry) DifferFor(kind string) (Differ, error) { + entry, err := registry.entryFor(kind, CapDiff, false) + if err != nil { + return nil, err + } + differ, ok := entry.connector.(Differ) + if !ok { + return nil, fmt.Errorf("%w: %s does not implement diff", ErrCapability, kind) + } + return differ, nil +} + +// PlannerFor returns a typed-action connector that declared and implements plan. +func (registry *Registry) PlannerFor(kind string) (Planner, error) { + entry, err := registry.entryFor(kind, CapPlan, true) + if err != nil { + return nil, err + } + planner, ok := entry.connector.(Planner) + if !ok { + return nil, fmt.Errorf("%w: %s does not implement plan", ErrCapability, kind) + } + return planner, nil +} + +// ExecutorFor returns a typed-action connector that declared and implements execute. +func (registry *Registry) ExecutorFor(kind string) (Executor, error) { + entry, err := registry.entryFor(kind, CapExecute, true) + if err != nil { + return nil, err + } + executor, ok := entry.connector.(Executor) + if !ok { + return nil, fmt.Errorf("%w: %s does not implement execute", ErrCapability, kind) + } + return executor, nil +} + +// VerifierFor returns a typed-action connector that declared and implements verify. +func (registry *Registry) VerifierFor(kind string) (Verifier, error) { + entry, err := registry.entryFor(kind, CapVerify, true) + if err != nil { + return nil, err + } + verifier, ok := entry.connector.(Verifier) + if !ok { + return nil, fmt.Errorf("%w: %s does not implement verify", ErrCapability, kind) + } + return verifier, nil +} + +func (registry *Registry) entryFor(kind string, capability Capability, typedAction bool) (registryEntry, error) { + registry.mu.RLock() + entry, exists := registry.entries[kind] + registry.mu.RUnlock() + if !exists { + return registryEntry{}, fmt.Errorf("%w: %s", ErrNotRegistered, kind) + } + if _, declared := entry.declared[capability]; !declared { + return registryEntry{}, fmt.Errorf("%w: %s did not declare %s", ErrCapability, kind, capability) + } + if typedAction && entry.descriptor.ConnKind != KindTypedAction { + return registryEntry{}, fmt.Errorf("%w: %s is not a typed-action connector", ErrCapability, kind) + } + return entry, nil +} + +func validateConnector(candidate Connector) (registryEntry, error) { + descriptor := cloneDescriptor(candidate.Descriptor()) + if descriptor.Kind == "" || candidate.Kind() == "" { + return registryEntry{}, fmt.Errorf("kind must not be empty") + } + if descriptor.Kind != candidate.Kind() { + return registryEntry{}, fmt.Errorf("descriptor kind %q does not match connector kind %q", descriptor.Kind, candidate.Kind()) + } + if !descriptor.ConnKind.Valid() { + return registryEntry{}, fmt.Errorf("invalid connector kind %q", descriptor.ConnKind) + } + if descriptor.ProtocolV == "" { + return registryEntry{}, fmt.Errorf("protocol version must not be empty") + } + if descriptor.Owner == "" { + return registryEntry{}, fmt.Errorf("owner must not be empty") + } + + declared, err := capabilitySet(candidate.Capabilities()) + if err != nil { + return registryEntry{}, err + } + descriptorSet, err := capabilitySet(descriptor.Capabilities) + if err != nil { + return registryEntry{}, fmt.Errorf("descriptor: %w", err) + } + if !sameCapabilities(declared, descriptorSet) { + return registryEntry{}, fmt.Errorf("descriptor capabilities do not match connector declaration") + } + for capability := range declared { + if !implementsCapability(candidate, capability) { + return registryEntry{}, fmt.Errorf("declares %s without implementing its interface", capability) + } + } + + if descriptor.ConnKind == KindTypedAction { + if len(descriptor.Verbs) == 0 { + return registryEntry{}, fmt.Errorf("typed-action connector must declare at least one verb") + } + seen := make(map[string]struct{}, len(descriptor.Verbs)) + for _, verb := range descriptor.Verbs { + if !ValidVerb(verb) { + return registryEntry{}, fmt.Errorf("invalid action verb %q", verb) + } + if _, duplicate := seen[verb]; duplicate { + return registryEntry{}, fmt.Errorf("duplicate action verb %q", verb) + } + seen[verb] = struct{}{} + } + } else if len(descriptor.Verbs) != 0 { + return registryEntry{}, fmt.Errorf("non-action connector must not declare action verbs") + } + + return registryEntry{connector: candidate, descriptor: descriptor, declared: declared}, nil +} + +func capabilitySet(capabilities []Capability) (map[Capability]struct{}, error) { + set := make(map[Capability]struct{}, len(capabilities)) + for _, capability := range capabilities { + if !capability.Valid() { + return nil, fmt.Errorf("invalid capability %q", capability) + } + if _, duplicate := set[capability]; duplicate { + return nil, fmt.Errorf("duplicate capability %q", capability) + } + set[capability] = struct{}{} + } + return set, nil +} + +func sameCapabilities(left, right map[Capability]struct{}) bool { + if len(left) != len(right) { + return false + } + for capability := range left { + if _, exists := right[capability]; !exists { + return false + } + } + return true +} + +func implementsCapability(candidate Connector, capability Capability) bool { + switch capability { + case CapDiscover, CapRead, CapQuery: + _, ok := candidate.(Reader) + return ok + case CapDiff: + _, ok := candidate.(Differ) + return ok + case CapPlan: + _, ok := candidate.(Planner) + return ok + case CapExecute: + _, ok := candidate.(Executor) + return ok + case CapVerify: + _, ok := candidate.(Verifier) + return ok + default: + return false + } +} + +func connectorIsNil(candidate Connector) bool { + if candidate == nil { + return true + } + value := reflect.ValueOf(candidate) + switch value.Kind() { + case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice: + return value.IsNil() + default: + return false + } +} + +func cloneDescriptor(descriptor Descriptor) Descriptor { + descriptor.Capabilities = append([]Capability(nil), descriptor.Capabilities...) + descriptor.Verbs = append([]string(nil), descriptor.Verbs...) + return descriptor +} diff --git a/internal/connector/registry_test.go b/internal/connector/registry_test.go new file mode 100644 index 0000000..028c3cf --- /dev/null +++ b/internal/connector/registry_test.go @@ -0,0 +1,226 @@ +// SPDX-License-Identifier: Apache-2.0 + +package connector + +import ( + "context" + "errors" + "testing" + + "github.com/ArdurAI/sith/internal/fleet" +) + +type testReader struct { + kind string + descriptor Descriptor + discovery Discovery + query fleet.QueryResult +} + +func (reader testReader) Kind() string { + return reader.kind +} + +func (reader testReader) Capabilities() []Capability { + return append([]Capability(nil), reader.descriptor.Capabilities...) +} + +func (reader testReader) Descriptor() Descriptor { + return cloneDescriptor(reader.descriptor) +} + +func (reader testReader) Discover(_ context.Context) (Discovery, error) { + return reader.discovery, nil +} + +func (testReader) Read(_ context.Context, ref fleet.ResourceRef) (fleet.Evidence, error) { + return fleet.Evidence{Ref: ref}, nil +} + +func (reader testReader) Query(_ context.Context, _ fleet.Query) (fleet.QueryResult, error) { + return reader.query, nil +} + +type identityOnlyConnector struct { + descriptor Descriptor +} + +func (connector identityOnlyConnector) Kind() string { + return connector.descriptor.Kind +} + +func (connector identityOnlyConnector) Capabilities() []Capability { + return append([]Capability(nil), connector.descriptor.Capabilities...) +} + +func (connector identityOnlyConnector) Descriptor() Descriptor { + return cloneDescriptor(connector.descriptor) +} + +type testExecutor struct { + descriptor Descriptor +} + +func (connector testExecutor) Kind() string { + return connector.descriptor.Kind +} + +func (connector testExecutor) Capabilities() []Capability { + return append([]Capability(nil), connector.descriptor.Capabilities...) +} + +func (connector testExecutor) Descriptor() Descriptor { + return cloneDescriptor(connector.descriptor) +} + +func (testExecutor) Execute(_ context.Context, plan ActionPlan) (ExecutionResult, error) { + return ExecutionResult{IntentID: plan.IntentID, Applied: true}, nil +} + +func TestRegistryRegisterAndLookupReader(t *testing.T) { + t.Parallel() + + registry := NewRegistry() + reader := newTestReader("zeta") + if err := registry.Register(func() (Connector, error) { return reader, nil }); err != nil { + t.Fatalf("Register() error = %v", err) + } + + got, ok := registry.ByKind("zeta") + if !ok || got.Kind() != "zeta" { + t.Fatalf("ByKind() = %v/%t", got, ok) + } + if _, err := registry.ReaderFor("zeta"); err != nil { + t.Fatalf("ReaderFor() error = %v", err) + } + if _, err := registry.ExecutorFor("zeta"); !errors.Is(err, ErrCapability) { + t.Fatalf("ExecutorFor() error = %v, want ErrCapability", err) + } +} + +func TestRegistryRejectsInvalidConnectors(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + connector Connector + }{ + {name: "unknown taxonomy", connector: identityOnlyConnector{descriptor: Descriptor{Kind: "bad", ConnKind: "other", ProtocolV: "1.0.0", Owner: "test"}}}, + {name: "declared but not implemented", connector: identityOnlyConnector{descriptor: Descriptor{Kind: "bad", ConnKind: KindReadAdapter, ProtocolV: "1.0.0", Owner: "test", Capabilities: []Capability{CapRead}}}}, + {name: "unknown capability", connector: identityOnlyConnector{descriptor: Descriptor{Kind: "bad", ConnKind: KindReadAdapter, ProtocolV: "1.0.0", Owner: "test", Capabilities: []Capability{"shell"}}}}, + {name: "read adapter with verbs", connector: identityOnlyConnector{descriptor: Descriptor{Kind: "bad", ConnKind: KindReadAdapter, ProtocolV: "1.0.0", Owner: "test", Verbs: []string{"gitops.open-pr"}}}}, + {name: "action without verbs", connector: testExecutor{descriptor: Descriptor{Kind: "bad", ConnKind: KindTypedAction, ProtocolV: "1.0.0", Owner: "test", Capabilities: []Capability{CapExecute}}}}, + {name: "action with unknown verb", connector: testExecutor{descriptor: Descriptor{Kind: "bad", ConnKind: KindTypedAction, ProtocolV: "1.0.0", Owner: "test", Capabilities: []Capability{CapExecute}, Verbs: []string{"shell.exec"}}}}, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + registry := NewRegistry() + if err := registry.Register(func() (Connector, error) { return test.connector, nil }); err == nil { + t.Fatal("Register() error = nil, want rejection") + } + if len(registry.Descriptors()) != 0 { + t.Fatal("invalid connector was partially registered") + } + }) + } +} + +func TestRegistryRejectsDuplicateKind(t *testing.T) { + t.Parallel() + + registry := NewRegistry() + reader := newTestReader("duplicate") + if err := registry.Register(func() (Connector, error) { return reader, nil }); err != nil { + t.Fatalf("first Register() error = %v", err) + } + if err := registry.Register(func() (Connector, error) { return reader, nil }); err == nil { + t.Fatal("second Register() error = nil") + } +} + +func TestRegistryWithCapabilityIsDeterministic(t *testing.T) { + t.Parallel() + + registry := NewRegistry() + for _, kind := range []string{"zeta", "alpha"} { + reader := newTestReader(kind) + if err := registry.Register(func() (Connector, error) { return reader, nil }); err != nil { + t.Fatalf("Register(%s) error = %v", kind, err) + } + } + + got := registry.WithCapability(CapQuery) + if len(got) != 2 || got[0].Kind() != "alpha" || got[1].Kind() != "zeta" { + t.Fatalf("WithCapability() = %#v, want alpha then zeta", got) + } + if got := registry.WithCapability("unknown"); got == nil || len(got) != 0 { + t.Fatalf("unknown WithCapability() = %#v, want allocated empty slice", got) + } +} + +func TestRegistryExecutorRequiresTypedAction(t *testing.T) { + t.Parallel() + + registry := NewRegistry() + executor := testExecutor{descriptor: Descriptor{ + Kind: "argocd", + ConnKind: KindTypedAction, + ProtocolV: "1.0.0", + Owner: "test", + Capabilities: []Capability{CapExecute}, + Verbs: []string{"argocd.sync"}, + }} + if err := registry.Register(func() (Connector, error) { return executor, nil }); err != nil { + t.Fatalf("Register() error = %v", err) + } + + got, err := registry.ExecutorFor("argocd") + if err != nil { + t.Fatalf("ExecutorFor() error = %v", err) + } + result, err := got.Execute(context.Background(), ActionPlan{IntentID: "intent-1"}) + if err != nil || !result.Applied { + t.Fatalf("Execute() = %#v, %v", result, err) + } +} + +func TestRegistryFactoryFailuresAreAtomic(t *testing.T) { + t.Parallel() + + registry := NewRegistry() + wantErr := errors.New("construction failed") + if err := registry.Register(func() (Connector, error) { return nil, wantErr }); !errors.Is(err, wantErr) { + t.Fatalf("Register() error = %v, want wrapped construction error", err) + } + if err := registry.Register(nil); err == nil { + t.Fatal("Register(nil) error = nil") + } + if len(registry.Descriptors()) != 0 { + t.Fatal("failed factory modified registry") + } +} + +func TestValidVerb(t *testing.T) { + t.Parallel() + + if !ValidVerb("gitops.open-pr") || ValidVerb("shell.exec") { + t.Fatal("ValidVerb() does not enforce the closed vocabulary") + } +} + +func newTestReader(kind string) testReader { + capabilities := []Capability{CapDiscover, CapRead, CapQuery} + return testReader{ + kind: kind, + descriptor: Descriptor{ + Kind: kind, + ConnKind: KindReadAdapter, + ProtocolV: "1.0.0", + Owner: "test", + Capabilities: capabilities, + }, + } +} diff --git a/internal/connector/source.go b/internal/connector/source.go new file mode 100644 index 0000000..46cadb0 --- /dev/null +++ b/internal/connector/source.go @@ -0,0 +1,104 @@ +// SPDX-License-Identifier: Apache-2.0 + +package connector + +import ( + "context" + "fmt" + "sort" + + "github.com/ArdurAI/sith/internal/fleet" +) + +var _ fleet.Source = readerSource{} + +// AsSource adapts a Reader to the stable fleet snapshot seam used by the CLI. +func AsSource(reader Reader) fleet.Source { + return readerSource{reader: reader} +} + +type readerSource struct { + reader Reader +} + +func (source readerSource) Kind() string { + if source.reader == nil { + return "invalid" + } + return source.reader.Kind() +} + +func (source readerSource) Fleet(ctx context.Context) (fleet.FleetResult, error) { + if source.reader == nil { + return fleet.FleetResult{}, fmt.Errorf("adapt reader: reader is nil") + } + + discovery, err := source.reader.Discover(ctx) + if err != nil { + return fleet.FleetResult{}, fmt.Errorf("discover %s scopes: %w", source.reader.Kind(), err) + } + queryResult, err := source.reader.Query(ctx, fleet.Query{Kinds: []fleet.FactKind{fleet.FactInventory, fleet.FactHealth}}) + if err != nil { + return fleet.FleetResult{}, fmt.Errorf("query %s snapshot: %w", source.reader.Kind(), err) + } + + clusters := make([]fleet.Cluster, 0, len(discovery.Scopes)+len(discovery.Unreachable)) + seen := make(map[string]struct{}, len(discovery.Scopes)) + for _, scope := range discovery.Scopes { + clusters = append(clusters, fleet.Cluster{ + Name: scope.Name, + Context: scope.Name, + SourceKind: source.reader.Kind(), + Reachable: scope.Reachable, + ObservedAt: scope.ObservedAt, + }) + seen[scope.Name] = struct{}{} + } + for _, name := range discovery.Unreachable { + if _, exists := seen[name]; exists { + continue + } + clusters = append(clusters, fleet.Cluster{ + Name: name, + Context: name, + SourceKind: source.reader.Kind(), + }) + } + sort.Slice(clusters, func(left, right int) bool { + return clusters[left].Name < clusters[right].Name + }) + + coverage := queryResult.Coverage + if coverage.Requested == 0 && len(clusters) != 0 { + coverage.Requested = len(clusters) + for _, cluster := range clusters { + if cluster.Reachable { + coverage.Reachable++ + } else { + coverage.Unreachable = append(coverage.Unreachable, cluster.Name) + } + } + } + coverage.Unreachable = sortedUnique(coverage.Unreachable) + coverage.Stale = sortedUnique(coverage.Stale) + + return fleet.FleetResult{Clusters: clusters, Coverage: coverage}, nil +} + +func sortedUnique(values []string) []string { + if len(values) == 0 { + return nil + } + set := make(map[string]struct{}, len(values)) + for _, value := range values { + if value != "" { + set[value] = struct{}{} + } + } + result := make([]string, 0, len(set)) + for value := range set { + result = append(result, value) + } + sort.Strings(result) + return result +} diff --git a/internal/connector/source_test.go b/internal/connector/source_test.go new file mode 100644 index 0000000..134db90 --- /dev/null +++ b/internal/connector/source_test.go @@ -0,0 +1,62 @@ +// SPDX-License-Identifier: Apache-2.0 + +package connector + +import ( + "context" + "testing" + "time" +) + +func TestAsSourcePreservesCoverageAndScopes(t *testing.T) { + t.Parallel() + + observed := time.Date(2026, 7, 10, 19, 0, 0, 0, time.UTC) + reader := newTestReader("memory") + reader.discovery = Discovery{ + Scopes: []Scope{ + {Name: "prod", Reachable: true, ObservedAt: observed}, + {Name: "lab", Reachable: false}, + }, + Unreachable: []string{"lab"}, + } + reader.query.Coverage.Requested = 2 + reader.query.Coverage.Reachable = 1 + reader.query.Coverage.Unreachable = []string{"lab", "lab"} + + source := AsSource(reader) + result, err := source.Fleet(context.Background()) + if err != nil { + t.Fatalf("Fleet() error = %v", err) + } + if source.Kind() != "memory" { + t.Fatalf("Kind() = %q", source.Kind()) + } + if len(result.Clusters) != 2 || result.Clusters[0].Name != "lab" || result.Clusters[1].Name != "prod" { + t.Fatalf("Clusters = %#v", result.Clusters) + } + if result.Clusters[1].ObservedAt != observed || !result.Clusters[1].Reachable { + t.Fatalf("prod cluster = %#v", result.Clusters[1]) + } + if len(result.Coverage.Unreachable) != 1 || result.Coverage.Unreachable[0] != "lab" { + t.Fatalf("Coverage = %#v", result.Coverage) + } +} + +func TestAsSourceFallsBackToDiscoveryCoverage(t *testing.T) { + t.Parallel() + + reader := newTestReader("memory") + reader.discovery = Discovery{ + Scopes: []Scope{{Name: "prod", Reachable: true}}, + Unreachable: []string{"missing"}, + } + + result, err := AsSource(reader).Fleet(context.Background()) + if err != nil { + t.Fatalf("Fleet() error = %v", err) + } + if result.Coverage.Requested != 2 || result.Coverage.Reachable != 1 { + t.Fatalf("Coverage = %#v", result.Coverage) + } +} diff --git a/internal/fleet/model.go b/internal/fleet/model.go index b1b9552..d1a6be1 100644 --- a/internal/fleet/model.go +++ b/internal/fleet/model.go @@ -27,4 +27,10 @@ type Coverage struct { Requested int `json:"requested"` Reachable int `json:"reachable"` Unreachable []string `json:"unreachable,omitempty"` + Stale []string `json:"stale,omitempty"` +} + +// Complete reports whether every requested scope answered with fresh data. +func (c Coverage) Complete() bool { + return c.Requested == c.Reachable && len(c.Stale) == 0 } diff --git a/internal/fleet/resource.go b/internal/fleet/resource.go new file mode 100644 index 0000000..7906593 --- /dev/null +++ b/internal/fleet/resource.go @@ -0,0 +1,190 @@ +// SPDX-License-Identifier: Apache-2.0 + +package fleet + +import ( + "encoding/json" + "fmt" + "strings" + "time" +) + +// LocalWorkspace is the implicit single-user workspace used by local mode. +const LocalWorkspace = "local" + +// ResourceRef is a source-abstract address for one fleet resource. +type ResourceRef struct { + SourceKind string `json:"source_kind"` + Scope string `json:"scope"` + Kind string `json:"kind"` + Namespace string `json:"namespace,omitempty"` + Name string `json:"name"` + Attributes map[string]string `json:"attributes,omitempty"` +} + +// Equal compares source-abstract identity while ignoring adapter-specific attributes. +func (r ResourceRef) Equal(other ResourceRef) bool { + return r.SourceKind == other.SourceKind && + r.Scope == other.Scope && + r.Kind == other.Kind && + r.Namespace == other.Namespace && + r.Name == other.Name +} + +// String returns a stable address suitable for logs and audit records. +func (r ResourceRef) String() string { + parts := []string{r.SourceKind + ":" + r.Scope, r.Kind} + if r.Namespace != "" { + parts = append(parts, r.Namespace) + } + parts = append(parts, r.Name) + return strings.Join(parts, "/") +} + +// FactKind is the closed taxonomy of normalized fleet observations. +type FactKind string + +// Supported fact kinds. +const ( + FactInventory FactKind = "inventory" + FactHealth FactKind = "health" + FactAlert FactKind = "alert" + FactDrift FactKind = "drift" + FactCVE FactKind = "cve" + FactCost FactKind = "cost" +) + +// Valid reports whether the fact kind belongs to the closed taxonomy. +func (kind FactKind) Valid() bool { + switch kind { + case FactInventory, FactHealth, FactAlert, FactDrift, FactCVE, FactCost: + return true + default: + return false + } +} + +// Evidence is observed state plus source and collection provenance. +type Evidence struct { + Ref ResourceRef `json:"ref"` + Kind FactKind `json:"kind"` + Observed json.RawMessage `json:"observed"` + ObservedAt time.Time `json:"observed_at"` + Source string `json:"source"` + Provenance Provenance `json:"provenance"` +} + +// Provenance identifies how to trace an observation back to its native source. +type Provenance struct { + Adapter string `json:"adapter"` + ProtocolV string `json:"protocol_version"` + NativeID string `json:"native_id,omitempty"` + DeepLink string `json:"deep_link,omitempty"` + Collector string `json:"collector,omitempty"` +} + +// Fact is evidence stamped with workspace and derived freshness. +type Fact struct { + Evidence + Workspace string `json:"workspace"` + Stale bool `json:"stale"` + StaleFor string `json:"stale_for,omitempty"` +} + +// Query expresses a typed selection over normalized fleet facts. +type Query struct { + Kinds []FactKind `json:"kinds,omitempty"` + Scopes []string `json:"scopes,omitempty"` + Selector Selector `json:"selector,omitempty"` + Limit int `json:"limit,omitempty"` +} + +// Validate rejects unknown or unsafe query values. +func (query Query) Validate() error { + if query.Limit < 0 { + return fmt.Errorf("query limit must not be negative") + } + for _, kind := range query.Kinds { + if !kind.Valid() { + return fmt.Errorf("invalid fact kind %q", kind) + } + } + for key := range query.Selector.Labels { + if strings.TrimSpace(key) == "" { + return fmt.Errorf("label selector key must not be empty") + } + } + if query.Selector.Health != "" { + switch query.Selector.Health { + case "Healthy", "Degraded", "Progressing", "Unknown": + default: + return fmt.Errorf("invalid health selector %q", query.Selector.Health) + } + } + + return nil +} + +// Selector is the fail-safe, typed predicate set supported by fleet queries. +type Selector struct { + ResourceKind string `json:"resource_kind,omitempty"` + Namespace string `json:"namespace,omitempty"` + NamePrefix string `json:"name_prefix,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + Health string `json:"health,omitempty"` + Image string `json:"image,omitempty"` + CVE string `json:"cve,omitempty"` +} + +// QueryResult contains normalized facts and honest scope coverage. +type QueryResult struct { + Facts []Fact `json:"facts"` + Coverage Coverage `json:"coverage"` +} + +// Diff is a structured desired-versus-observed result. +type Diff struct { + Ref ResourceRef `json:"ref"` + Drifted bool `json:"drifted"` + Hunks []DiffHunk `json:"hunks,omitempty"` +} + +// DiffHunk is one field-level desired-versus-observed change. +type DiffHunk struct { + Path string `json:"path"` + Observed string `json:"observed"` + Desired string `json:"desired"` +} + +// Graph is the source-abstract operational graph assembled from facts. +type Graph struct { + Nodes []Node `json:"nodes"` + Edges []Edge `json:"edges"` +} + +// Node is one addressed resource and its latest fact. +type Node struct { + Ref ResourceRef `json:"ref"` + Fact Fact `json:"fact"` +} + +// Relation is the closed taxonomy of cross-resource graph edges. +type Relation string + +// Supported graph relations. +const ( + RelOwns Relation = "owns" + RelRoutesTo Relation = "routes_to" + RelBackedBy Relation = "backed_by" + RelDeployedFrom Relation = "deployed_from" + RelRunsImage Relation = "runs_image" + RelAlertsOn Relation = "alerts_on" + RelCostsFor Relation = "costs_for" +) + +// Edge is one typed relationship between fleet resources. +type Edge struct { + From ResourceRef `json:"from"` + To ResourceRef `json:"to"` + Rel Relation `json:"rel"` +} diff --git a/internal/fleet/resource_test.go b/internal/fleet/resource_test.go new file mode 100644 index 0000000..c298026 --- /dev/null +++ b/internal/fleet/resource_test.go @@ -0,0 +1,123 @@ +// SPDX-License-Identifier: Apache-2.0 + +package fleet + +import "testing" + +func TestResourceRefEqualIgnoresAttributes(t *testing.T) { + t.Parallel() + + left := ResourceRef{ + SourceKind: "local-kubeconfig", + Scope: "prod", + Kind: "Pod", + Namespace: "payments", + Name: "api-123", + Attributes: map[string]string{"uid": "one"}, + } + right := left + right.Attributes = map[string]string{"uid": "two"} + if !left.Equal(right) { + t.Fatal("Equal() = false for identical source-abstract identity") + } + + right.Name = "api-456" + if left.Equal(right) { + t.Fatal("Equal() = true for different resource names") + } +} + +func TestResourceRefString(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + ref ResourceRef + want string + }{ + { + name: "namespaced", + ref: ResourceRef{SourceKind: "local-kubeconfig", Scope: "prod", Kind: "Pod", Namespace: "payments", Name: "api"}, + want: "local-kubeconfig:prod/Pod/payments/api", + }, + { + name: "cluster scoped", + ref: ResourceRef{SourceKind: "local-kubeconfig", Scope: "prod", Kind: "Node", Name: "worker-1"}, + want: "local-kubeconfig:prod/Node/worker-1", + }, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + if got := test.ref.String(); got != test.want { + t.Fatalf("String() = %q, want %q", got, test.want) + } + }) + } +} + +func TestFactKindValid(t *testing.T) { + t.Parallel() + + for _, kind := range []FactKind{FactInventory, FactHealth, FactAlert, FactDrift, FactCVE, FactCost} { + if !kind.Valid() { + t.Errorf("Valid() = false for %q", kind) + } + } + if FactKind("unknown").Valid() { + t.Fatal("Valid() = true for unknown fact kind") + } +} + +func TestQueryValidate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + query Query + wantErr bool + }{ + {name: "valid", query: Query{Kinds: []FactKind{FactInventory}, Selector: Selector{Health: "Healthy"}, Limit: 10}}, + {name: "negative limit", query: Query{Limit: -1}, wantErr: true}, + {name: "unknown fact", query: Query{Kinds: []FactKind{"mystery"}}, wantErr: true}, + {name: "empty label", query: Query{Selector: Selector{Labels: map[string]string{"": "x"}}}, wantErr: true}, + {name: "unknown health", query: Query{Selector: Selector{Health: "Fine"}}, wantErr: true}, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + err := test.query.Validate() + if (err != nil) != test.wantErr { + t.Fatalf("Validate() error = %v, wantErr %t", err, test.wantErr) + } + }) + } +} + +func TestCoverageComplete(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + coverage Coverage + want bool + }{ + {name: "complete", coverage: Coverage{Requested: 2, Reachable: 2}, want: true}, + {name: "unreachable", coverage: Coverage{Requested: 2, Reachable: 1}}, + {name: "stale", coverage: Coverage{Requested: 2, Reachable: 2, Stale: []string{"prod"}}}, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + if got := test.coverage.Complete(); got != test.want { + t.Fatalf("Complete() = %t, want %t", got, test.want) + } + }) + } +} diff --git a/sessions/2026-07-10-slice-1-source-adapter.md b/sessions/2026-07-10-slice-1-source-adapter.md new file mode 100644 index 0000000..5e811f3 --- /dev/null +++ b/sessions/2026-07-10-slice-1-source-adapter.md @@ -0,0 +1,30 @@ +# Session — 2026-07-10 — slice-1-source-adapter + +**Builder:** Gnani Rahul · **Model/effort:** engineering, max · **Branch:** feat/fleet-source-adapter +**Slice(s):** Slice 1 / #38 + #32 · **Status:** in-progress + +--- + +[G] Goal: Implement the source-abstract fleet model, seven-verb connector contract, local-kubeconfig +adapter, independent fan-out, and a real two-kind-cluster proof for Slice 1. +[S] Scope: additive `internal/fleet` types, `internal/connector`, the kubeconfig read adapter, +`fleet.Source` bridge, the one CLI injection point, unit tests, and kind e2e. Cache-first TUI, +per-pod operations, web UI, MCP, keychain, OCM transport, and governed writes are out of scope. +[A] Action: Merged Slice 0 and authoritative specification PRs into `dev`, promoted the tested +foundation to `main` through release PR #51, and branched `feat/fleet-source-adapter` from tested +`dev` tip `a9bf340`. +[A] Action: Verified client-go v0.36.2 as the current upstream module and kind v0.32.0 with the +digest-pinned Kubernetes v1.36.1 node image. ExecCredential v1 behavior remains delegated to +client-go so plugins execute locally and tokens are never persisted by Sith. +[A] Action: Added the source-abstract resource/fact/query/diff/graph model, additive stale coverage, +the seven capability interfaces, closed connector taxonomy/action vocabulary, atomic registry, and +the `connector.Reader` to `fleet.Source` bridge. +[T] Test: Race-enabled fleet/connector unit tests and the strict linter pass. Tests prove identity +equality, fail-safe query validation, capability declaration+implementation checks, atomic invalid +registration, deterministic lookup, typed-action isolation, and coverage-preserving source parity. +[C] Checkpoint #1: this commit — additive fleet and connector contract; next: local-kubeconfig +adapter and client-go fan-out. + +--- + +**Session close:** in progress · **Open questions touched:** none From a53d262d00884265ea5bf70ca286d9a5a5750636 Mon Sep 17 00:00:00 2001 From: Gnani Rahul Date: Fri, 10 Jul 2026 15:10:18 -0500 Subject: [PATCH 02/14] build(deps): adopt the Go 1.26 toolchain Use the supported Go line required by current client-go v0.36.2 and keep local, CI, and documented toolchain expectations aligned. GSTACK-Checkpoint: 2026-07-10/slice-1-source-adapter#2 Signed-off-by: Gnani Rahul --- .github/workflows/ci.yml | 2 +- README.md | 2 +- go.mod | 2 +- sessions/2026-07-10-slice-1-source-adapter.md | 8 +++++++- 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 372c21d..2942003 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ concurrency: cancel-in-progress: true env: - GO_VERSION: "1.25.x" + GO_VERSION: "1.26.x" GOLANGCI_VERSION: "v2.12.2" jobs: diff --git a/README.md b/README.md index 1280e1e..b3a18e9 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ governed hub. ## Build and run -Sith requires a supported Go 1.25 toolchain. +Sith requires a supported Go 1.26 toolchain. ```bash make build diff --git a/go.mod b/go.mod index da62d11..ebe529a 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/ArdurAI/sith -go 1.25.0 +go 1.26.0 require ( github.com/spf13/cobra v1.10.2 diff --git a/sessions/2026-07-10-slice-1-source-adapter.md b/sessions/2026-07-10-slice-1-source-adapter.md index 5e811f3..a2fc4eb 100644 --- a/sessions/2026-07-10-slice-1-source-adapter.md +++ b/sessions/2026-07-10-slice-1-source-adapter.md @@ -22,8 +22,14 @@ the `connector.Reader` to `fleet.Source` bridge. [T] Test: Race-enabled fleet/connector unit tests and the strict linter pass. Tests prove identity equality, fail-safe query validation, capability declaration+implementation checks, atomic invalid registration, deterministic lookup, typed-action isolation, and coverage-preserving source parity. -[C] Checkpoint #1: this commit — additive fleet and connector contract; next: local-kubeconfig +[C] Checkpoint #1: 7ad0759 — additive fleet and connector contract; next: local-kubeconfig adapter and client-go fan-out. +[A] Action: Current client-go v0.36.2 requires Go 1.26, so raised the module and CI toolchain from +Go 1.25 to the supported Go 1.26 line instead of pinning an older Kubernetes client. +[T] Test: Rebuilt golangci-lint v2.12.2 with Go 1.26.5; the complete `make ci` gate passes on the +new toolchain with no code or output changes. +[C] Checkpoint #2: this commit — adopt the supported Go 1.26 toolchain required by current +client-go; next: implement the adapter. --- From bad1a1fc942acfe8256207158e6dbd7aa8bc587d Mon Sep 17 00:00:00 2001 From: Gnani Rahul Date: Fri, 10 Jul 2026 15:36:10 -0500 Subject: [PATCH 03/14] feat(connector): implement local kubeconfig reads GSTACK-Checkpoint: 2026-07-10/slice-1-source-adapter#3 Signed-off-by: Gnani Rahul --- .gitignore | 2 +- go.mod | 37 ++ go.sum | 111 +++++- internal/connector/kubeconfig/adapter.go | 376 ++++++++++++++++++ internal/connector/kubeconfig/adapter_test.go | 321 +++++++++++++++ internal/connector/kubeconfig/resources.go | 370 +++++++++++++++++ sessions/2026-07-10-slice-1-source-adapter.md | 11 +- 7 files changed, 1225 insertions(+), 3 deletions(-) create mode 100644 internal/connector/kubeconfig/adapter.go create mode 100644 internal/connector/kubeconfig/adapter_test.go create mode 100644 internal/connector/kubeconfig/resources.go diff --git a/.gitignore b/.gitignore index 6e5ea02..5481b22 100644 --- a/.gitignore +++ b/.gitignore @@ -15,7 +15,7 @@ Thumbs.db *.key *.pem *.pfx -kubeconfig +/kubeconfig *.kubeconfig secrets/ .secrets/ diff --git a/go.mod b/go.mod index ebe529a..2b5734c 100644 --- a/go.mod +++ b/go.mod @@ -5,9 +5,46 @@ go 1.26.0 require ( github.com/spf13/cobra v1.10.2 go.yaml.in/yaml/v3 v3.0.4 + k8s.io/apimachinery v0.36.2 + k8s.io/client-go v0.36.2 ) require ( + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/emicklei/go-restful/v3 v3.13.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.20.2 // indirect + github.com/go-openapi/swag v0.23.0 // indirect + github.com/google/gnostic-models v0.7.0 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/spf13/pflag v1.0.9 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/oauth2 v0.34.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/term v0.39.0 // indirect + golang.org/x/text v0.33.0 // indirect + golang.org/x/time v0.14.0 // indirect + google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect + gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/api v0.36.2 // indirect + k8s.io/klog/v2 v2.140.0 // indirect + k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a // indirect + k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 // indirect + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/go.sum b/go.sum index e63b363..4194dde 100644 --- a/go.sum +++ b/go.sum @@ -1,12 +1,121 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= +github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= +github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= +github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= +golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= +golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= +google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= +gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.36.2 h1:TF6YDLIzKfccK7cq9YpTcGX8TJmEkHVRv78DM51fRYY= +k8s.io/api v0.36.2/go.mod h1:F4LbMO4brjZYh7yFkXWhynSvtB7YauxV4c+HHkNRGNg= +k8s.io/apimachinery v0.36.2 h1:0PE/W/WNy1UX61NLbXY5TMbJ6UwLL6E6lAPkYrKFxbQ= +k8s.io/apimachinery v0.36.2/go.mod h1:fvf/HOLXq9RId0rnDIbN1OEBvHXdQbLMM8nu0LcBUf4= +k8s.io/client-go v0.36.2 h1:bfgxmFKc9CgqsgX4xKLAAdmTQlWee7Ob/HlDOrJ5TBI= +k8s.io/client-go v0.36.2/go.mod h1:1vgO4OAlfPnoLcb+Rze2GF5rAr14w8qjrYMoyXJzQj0= +k8s.io/klog/v2 v2.140.0 h1:Tf+J3AH7xnUzZyVVXhTgGhEKnFqye14aadWv7bzXdzc= +k8s.io/klog/v2 v2.140.0/go.mod h1:o+/RWfJ6PwpnFn7OyAG3QnO47BFsymfEfrz6XyYSSp0= +k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a h1:xCeOEAOoGYl2jnJoHkC3hkbPJgdATINPMAxaynU2Ovg= +k8s.io/kube-openapi v0.0.0-20260317180543-43fb72c5454a/go.mod h1:uGBT7iTA6c6MvqUvSXIaYZo9ukscABYi2btjhvgKGZ0= +k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2 h1:AZYQSJemyQB5eRxqcPky+/7EdBj0xi3g0ZcxxJ7vbWU= +k8s.io/utils v0.0.0-20260210185600-b8788abfbbc2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2 h1:kwVWMx5yS1CrnFWA/2QHyRVJ8jM6dBA80uLmm0wJkk8= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/internal/connector/kubeconfig/adapter.go b/internal/connector/kubeconfig/adapter.go new file mode 100644 index 0000000..fa3b50a --- /dev/null +++ b/internal/connector/kubeconfig/adapter.go @@ -0,0 +1,376 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package kubeconfig implements the local kubeconfig source adapter. +package kubeconfig + +import ( + "context" + "fmt" + "sort" + "sync" + "time" + + "k8s.io/client-go/discovery" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" + + "github.com/ArdurAI/sith/internal/connector" +) + +const ( + // Kind is the stable registry identifier for the local kubeconfig adapter. + Kind = "local-kubeconfig" + + protocolVersion = "1.0.0" + defaultProbeTimeout = 2 * time.Second + defaultRequestTimeout = 10 * time.Second + defaultStaleAfter = 2 * time.Minute + defaultConcurrency = 16 +) + +var supportedKinds = []string{ + "Deployment", + "ReplicaSet", + "Pod", + "Rollout", + "Node", + "Service", + "Namespace", + "Event", +} + +type probeFunc func(ctx context.Context, config *rest.Config) error +type dynamicFactory func(config *rest.Config) (dynamic.Interface, error) + +type options struct { + loadingRules *clientcmd.ClientConfigLoadingRules + probeTimeout time.Duration + requestTimeout time.Duration + staleAfter time.Duration + maxConcurrency int + now func() time.Time + probe probeFunc + dynamic dynamicFactory +} + +// Option configures the local kubeconfig adapter. +type Option func(*options) error + +// WithLoadingRules replaces client-go's default kubeconfig loading rules. +func WithLoadingRules(rules *clientcmd.ClientConfigLoadingRules) Option { + return func(settings *options) error { + if rules == nil { + return fmt.Errorf("kubeconfig loading rules must not be nil") + } + copyRules := *rules + settings.loadingRules = ©Rules + return nil + } +} + +// WithExplicitPath reads one explicitly selected kubeconfig path. +func WithExplicitPath(path string) Option { + return func(settings *options) error { + if path != "" { + settings.loadingRules.ExplicitPath = path + } + return nil + } +} + +// WithProbeTimeout sets the independent reachability deadline for each context. +func WithProbeTimeout(timeout time.Duration) Option { + return func(settings *options) error { + if timeout <= 0 { + return fmt.Errorf("probe timeout must be positive") + } + settings.probeTimeout = timeout + return nil + } +} + +// WithRequestTimeout sets the deadline for resource reads and queries. +func WithRequestTimeout(timeout time.Duration) Option { + return func(settings *options) error { + if timeout <= 0 { + return fmt.Errorf("request timeout must be positive") + } + settings.requestTimeout = timeout + return nil + } +} + +// WithMaxConcurrency bounds simultaneous context operations. +func WithMaxConcurrency(limit int) Option { + return func(settings *options) error { + if limit <= 0 { + return fmt.Errorf("maximum concurrency must be positive") + } + settings.maxConcurrency = limit + return nil + } +} + +func withClock(now func() time.Time) Option { + return func(settings *options) error { + if now == nil { + return fmt.Errorf("clock must not be nil") + } + settings.now = now + return nil + } +} + +func withProbe(probe probeFunc) Option { + return func(settings *options) error { + if probe == nil { + return fmt.Errorf("probe must not be nil") + } + settings.probe = probe + return nil + } +} + +func withDynamicFactory(factory dynamicFactory) Option { + return func(settings *options) error { + if factory == nil { + return fmt.Errorf("dynamic client factory must not be nil") + } + settings.dynamic = factory + return nil + } +} + +// Adapter discovers contexts and performs independent local client-go reads. +type Adapter struct { + settings options + + mu sync.RWMutex + discovered bool + scopes map[string]connector.Scope + clients map[string]dynamic.Interface + lastSeen map[string]time.Time +} + +var _ connector.Reader = (*Adapter)(nil) + +// New constructs a local kubeconfig adapter without performing network I/O. +func New(opts ...Option) (*Adapter, error) { + settings := options{ + loadingRules: clientcmd.NewDefaultClientConfigLoadingRules(), + probeTimeout: defaultProbeTimeout, + requestTimeout: defaultRequestTimeout, + staleAfter: defaultStaleAfter, + maxConcurrency: defaultConcurrency, + now: time.Now, + probe: defaultProbe, + dynamic: func(config *rest.Config) (dynamic.Interface, error) { + return dynamic.NewForConfig(config) + }, + } + for _, option := range opts { + if option == nil { + return nil, fmt.Errorf("configure local kubeconfig adapter: option is nil") + } + if err := option(&settings); err != nil { + return nil, fmt.Errorf("configure local kubeconfig adapter: %w", err) + } + } + + return &Adapter{ + settings: settings, + scopes: make(map[string]connector.Scope), + clients: make(map[string]dynamic.Interface), + lastSeen: make(map[string]time.Time), + }, nil +} + +// Kind identifies this connector in the registry and resource address space. +func (*Adapter) Kind() string { + return Kind +} + +// Capabilities declares the read-only verbs implemented by local kubeconfig. +func (*Adapter) Capabilities() []connector.Capability { + return []connector.Capability{connector.CapDiscover, connector.CapRead, connector.CapQuery} +} + +// Descriptor returns immutable registration metadata for this adapter. +func (adapter *Adapter) Descriptor() connector.Descriptor { + return connector.Descriptor{ + Kind: adapter.Kind(), + ConnKind: connector.KindReadAdapter, + ProtocolV: protocolVersion, + Owner: "sith-core", + Capabilities: adapter.Capabilities(), + } +} + +// Discover enumerates every context and probes each independently. +func (adapter *Adapter) Discover(ctx context.Context) (connector.Discovery, error) { + rawConfig, err := adapter.settings.loadingRules.Load() + if err != nil { + return connector.Discovery{}, fmt.Errorf("load kubeconfig: %w", err) + } + + names := make([]string, 0, len(rawConfig.Contexts)) + for name := range rawConfig.Contexts { + names = append(names, name) + } + sort.Strings(names) + priorLastSeen := adapter.lastSeenSnapshot() + + results := make([]contextResult, len(names)) + adapter.runBounded(len(names), func(index int) { + results[index] = adapter.probeContext(ctx, *rawConfig, names[index], priorLastSeen[names[index]]) + }) + if err := ctx.Err(); err != nil { + return connector.Discovery{}, fmt.Errorf("discover kubeconfig contexts: %w", err) + } + + scopes := make([]connector.Scope, 0, len(results)) + unreachable := make([]string, 0) + clients := make(map[string]dynamic.Interface, len(results)) + lastSeen := make(map[string]time.Time, len(results)) + for _, result := range results { + scopes = append(scopes, result.scope) + if result.scope.Reachable { + clients[result.scope.Name] = result.client + } else { + unreachable = append(unreachable, result.scope.Name) + } + if !result.scope.ObservedAt.IsZero() { + lastSeen[result.scope.Name] = result.scope.ObservedAt + } + } + + adapter.mu.Lock() + adapter.discovered = true + adapter.scopes = make(map[string]connector.Scope, len(scopes)) + for _, scope := range scopes { + adapter.scopes[scope.Name] = cloneScope(scope) + } + adapter.clients = clients + adapter.lastSeen = lastSeen + adapter.mu.Unlock() + + return connector.Discovery{Scopes: cloneScopes(scopes), Unreachable: append([]string(nil), unreachable...)}, nil +} + +type contextResult struct { + scope connector.Scope + client dynamic.Interface +} + +func (adapter *Adapter) probeContext( + ctx context.Context, + rawConfig clientcmdapi.Config, + name string, + lastSeen time.Time, +) contextResult { + scope := connector.Scope{ + Name: name, + Kinds: append([]string(nil), supportedKinds...), + ObservedAt: lastSeen, + } + clientConfig := clientcmd.NewNonInteractiveClientConfig( + rawConfig, + name, + &clientcmd.ConfigOverrides{}, + adapter.settings.loadingRules, + ) + restConfig, err := clientConfig.ClientConfig() + if err != nil { + return contextResult{scope: scope} + } + restConfig.UserAgent = "sith/" + protocolVersion + + probeConfig := rest.CopyConfig(restConfig) + probeConfig.Timeout = adapter.settings.probeTimeout + probeCtx, cancel := context.WithTimeout(ctx, adapter.settings.probeTimeout) + defer cancel() + if err := adapter.settings.probe(probeCtx, probeConfig); err != nil { + return contextResult{scope: scope} + } + + requestConfig := rest.CopyConfig(restConfig) + requestConfig.Timeout = adapter.settings.requestTimeout + client, err := adapter.settings.dynamic(requestConfig) + if err != nil { + return contextResult{scope: scope} + } + + scope.Reachable = true + scope.ObservedAt = adapter.settings.now().UTC() + return contextResult{scope: scope, client: client} +} + +func (adapter *Adapter) runBounded(count int, operation func(index int)) { + if count == 0 { + return + } + workers := min(adapter.settings.maxConcurrency, count) + jobs := make(chan int) + var waitGroup sync.WaitGroup + waitGroup.Add(workers) + for range workers { + go func() { + defer waitGroup.Done() + for index := range jobs { + operation(index) + } + }() + } + for index := range count { + jobs <- index + } + close(jobs) + waitGroup.Wait() +} + +func (adapter *Adapter) ensureDiscovered(ctx context.Context) error { + adapter.mu.RLock() + discovered := adapter.discovered + adapter.mu.RUnlock() + if discovered { + return nil + } + _, err := adapter.Discover(ctx) + return err +} + +func (adapter *Adapter) lastSeenSnapshot() map[string]time.Time { + adapter.mu.RLock() + defer adapter.mu.RUnlock() + result := make(map[string]time.Time, len(adapter.lastSeen)) + for name, observed := range adapter.lastSeen { + result[name] = observed + } + return result +} + +func defaultProbe(ctx context.Context, config *rest.Config) error { + client, err := discovery.NewDiscoveryClientForConfig(config) + if err != nil { + return fmt.Errorf("create discovery client: %w", err) + } + if err := client.RESTClient().Get().AbsPath("/version").Do(ctx).Error(); err != nil { + return fmt.Errorf("query API version: %w", err) + } + return nil +} + +func cloneScope(scope connector.Scope) connector.Scope { + scope.Kinds = append([]string(nil), scope.Kinds...) + return scope +} + +func cloneScopes(scopes []connector.Scope) []connector.Scope { + result := make([]connector.Scope, 0, len(scopes)) + for _, scope := range scopes { + result = append(result, cloneScope(scope)) + } + return result +} diff --git a/internal/connector/kubeconfig/adapter_test.go b/internal/connector/kubeconfig/adapter_test.go new file mode 100644 index 0000000..d741ce3 --- /dev/null +++ b/internal/connector/kubeconfig/adapter_test.go @@ -0,0 +1,321 @@ +// SPDX-License-Identifier: Apache-2.0 + +package kubeconfig + +import ( + "context" + "encoding/json" + "encoding/pem" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "slices" + "sync" + "testing" + "time" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" + dynamicfake "k8s.io/client-go/dynamic/fake" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" + + "github.com/ArdurAI/sith/internal/fleet" +) + +func TestNewRejectsInvalidOptions(t *testing.T) { + t.Parallel() + tests := []struct { + name string + option Option + }{ + {name: "nil option", option: nil}, + {name: "nil loading rules", option: WithLoadingRules(nil)}, + {name: "zero probe timeout", option: WithProbeTimeout(0)}, + {name: "zero request timeout", option: WithRequestTimeout(0)}, + {name: "zero concurrency", option: WithMaxConcurrency(0)}, + {name: "nil clock", option: withClock(nil)}, + {name: "nil probe", option: withProbe(nil)}, + {name: "nil dynamic factory", option: withDynamicFactory(nil)}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + if _, err := New(test.option); err == nil { + t.Fatal("New() error = nil, want invalid option error") + } + }) + } +} + +func TestDiscoverIsIndependentAndPreservesLastSeen(t *testing.T) { + t.Parallel() + firstObserved := time.Date(2026, time.July, 10, 12, 0, 0, 0, time.UTC) + secondObserved := firstObserved.Add(5 * time.Minute) + currentTime := firstObserved + var stateMu sync.Mutex + failures := map[string]bool{} + clients := map[string]*dynamicfake.FakeDynamicClient{ + "https://alpha.invalid": fakeClient(), + "https://beta.invalid": fakeClient(), + } + + adapter, err := New( + WithLoadingRules(testLoadingRules(t, testConfig("alpha", "beta"))), + WithMaxConcurrency(2), + withClock(func() time.Time { + stateMu.Lock() + defer stateMu.Unlock() + return currentTime + }), + withProbe(func(_ context.Context, config *rest.Config) error { + stateMu.Lock() + defer stateMu.Unlock() + if failures[config.Host] { + return errors.New("synthetic reachability failure") + } + return nil + }), + withDynamicFactory(func(config *rest.Config) (dynamic.Interface, error) { + return clients[config.Host], nil + }), + ) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + discovery, err := adapter.Discover(context.Background()) + if err != nil { + t.Fatalf("first Discover() error = %v", err) + } + if len(discovery.Scopes) != 2 || len(discovery.Unreachable) != 0 { + t.Fatalf("first Discover() = %#v, want two reachable scopes", discovery) + } + + stateMu.Lock() + currentTime = secondObserved + failures["https://beta.invalid"] = true + stateMu.Unlock() + discovery, err = adapter.Discover(context.Background()) + if err != nil { + t.Fatalf("second Discover() error = %v", err) + } + if !slices.Equal(discovery.Unreachable, []string{"beta"}) { + t.Fatalf("Unreachable = %v, want [beta]", discovery.Unreachable) + } + if !discovery.Scopes[0].Reachable || discovery.Scopes[0].ObservedAt != secondObserved { + t.Fatalf("alpha scope = %#v, want newly observed reachable scope", discovery.Scopes[0]) + } + if discovery.Scopes[1].Reachable || discovery.Scopes[1].ObservedAt != firstObserved { + t.Fatalf("beta scope = %#v, want unreachable scope preserving last seen", discovery.Scopes[1]) + } +} + +func TestQueryAndReadReturnSourceStampedEvidenceWithPartialCoverage(t *testing.T) { + t.Parallel() + observedAt := time.Date(2026, time.July, 10, 13, 0, 0, 0, time.UTC) + alphaClient := fakeClient( + pod("api-0", "apps", "registry.example/api:v2", map[string]string{"app": "api"}), + pod("worker-0", "apps", "registry.example/worker:v1", map[string]string{"app": "worker"}), + ) + adapter, err := New( + WithLoadingRules(testLoadingRules(t, testConfig("alpha", "beta"))), + withClock(func() time.Time { return observedAt }), + withProbe(func(_ context.Context, config *rest.Config) error { + if config.Host == "https://beta.invalid" { + return errors.New("offline") + } + return nil + }), + withDynamicFactory(func(_ *rest.Config) (dynamic.Interface, error) { return alphaClient, nil }), + ) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + result, err := adapter.Query(context.Background(), fleet.Query{ + Kinds: []fleet.FactKind{fleet.FactInventory}, + Scopes: []string{"alpha", "beta", "missing"}, + Selector: fleet.Selector{ + ResourceKind: "Pod", + Namespace: "apps", + NamePrefix: "api-", + Labels: map[string]string{"app": "api"}, + Image: "api:v2", + }, + }) + if err != nil { + t.Fatalf("Query() error = %v", err) + } + if result.Coverage.Requested != 3 || result.Coverage.Reachable != 1 { + t.Fatalf("Coverage = %#v, want one of three reachable", result.Coverage) + } + if !slices.Equal(result.Coverage.Unreachable, []string{"beta", "missing"}) { + t.Fatalf("Unreachable = %v, want [beta missing]", result.Coverage.Unreachable) + } + if len(result.Facts) != 1 { + t.Fatalf("Facts = %#v, want one selected pod", result.Facts) + } + fact := result.Facts[0] + if fact.Ref.SourceKind != Kind || fact.Ref.Scope != "alpha" || fact.Ref.Name != "api-0" { + t.Fatalf("Fact ref = %#v, want source-stamped alpha/api-0", fact.Ref) + } + if fact.Workspace != fleet.LocalWorkspace || fact.Provenance.Adapter != Kind { + t.Fatalf("Fact provenance = %#v, workspace = %q", fact.Provenance, fact.Workspace) + } + + evidence, err := adapter.Read(context.Background(), fact.Ref) + if err != nil { + t.Fatalf("Read() error = %v", err) + } + if evidence.Ref.Name != "api-0" || evidence.ObservedAt != observedAt { + t.Fatalf("Read() evidence = %#v", evidence) + } + var object map[string]any + if err := json.Unmarshal(evidence.Observed, &object); err != nil { + t.Fatalf("unmarshal observed evidence: %v", err) + } + metadata, _ := object["metadata"].(map[string]any) + if metadata["name"] != "api-0" { + t.Fatalf("observed metadata = %#v, want api-0", metadata) + } + + _, err = adapter.Read(context.Background(), fleet.ResourceRef{Scope: "missing", Kind: "Pod", Name: "x"}) + if !errors.Is(err, ErrUnknownScope) { + t.Fatalf("Read(unknown) error = %v, want ErrUnknownScope", err) + } + _, err = adapter.Read(context.Background(), fleet.ResourceRef{Scope: "beta", Kind: "Pod", Name: "x"}) + if !errors.Is(err, ErrUnreachableScope) { + t.Fatalf("Read(unreachable) error = %v, want ErrUnreachableScope", err) + } +} + +func TestDefaultProbeExecutesExecCredentialLocally(t *testing.T) { + if os.Getenv("SITH_EXEC_HELPER") == "1" { + runExecCredentialHelper() + } + + const token = "ephemeral-test-token" + marker := filepath.Join(t.TempDir(), "exec-called") + server := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/version" { + http.NotFound(writer, request) + return + } + if request.Header.Get("Authorization") != "Bearer "+token { + http.Error(writer, "unauthorized", http.StatusUnauthorized) + return + } + writer.Header().Set("Content-Type", "application/json") + _, _ = writer.Write([]byte(`{"gitVersion":"v1.36.1"}`)) + })) + t.Cleanup(server.Close) + + certificate := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw}) + config := clientcmdapi.Config{ + Clusters: map[string]*clientcmdapi.Cluster{ + "exec": {Server: server.URL, CertificateAuthorityData: certificate}, + }, + AuthInfos: map[string]*clientcmdapi.AuthInfo{ + "exec": {Exec: &clientcmdapi.ExecConfig{ + Command: os.Args[0], + Args: []string{"-test.run=TestDefaultProbeExecutesExecCredentialLocally"}, + APIVersion: "client.authentication.k8s.io/v1", + InteractiveMode: clientcmdapi.NeverExecInteractiveMode, + Env: []clientcmdapi.ExecEnvVar{ + {Name: "SITH_EXEC_HELPER", Value: "1"}, + {Name: "SITH_EXEC_MARKER", Value: marker}, + {Name: "SITH_EXEC_TOKEN", Value: token}, + }, + }}, + }, + Contexts: map[string]*clientcmdapi.Context{ + "exec": {Cluster: "exec", AuthInfo: "exec"}, + }, + CurrentContext: "exec", + } + adapter, err := New( + WithLoadingRules(testLoadingRules(t, config)), + withDynamicFactory(func(_ *rest.Config) (dynamic.Interface, error) { return fakeClient(), nil }), + ) + if err != nil { + t.Fatalf("New() error = %v", err) + } + discovery, err := adapter.Discover(context.Background()) + if err != nil { + t.Fatalf("Discover() error = %v", err) + } + if len(discovery.Scopes) != 1 || !discovery.Scopes[0].Reachable { + t.Fatalf("Discover() = %#v, want reachable exec context", discovery) + } + if _, err := os.Stat(marker); err != nil { + t.Fatalf("exec marker: %v", err) + } +} + +func runExecCredentialHelper() { + marker := os.Getenv("SITH_EXEC_MARKER") + if marker == "" || os.WriteFile(marker, []byte("called"), 0o600) != nil { + os.Exit(2) + } + credential := map[string]any{ + "apiVersion": "client.authentication.k8s.io/v1", + "kind": "ExecCredential", + "status": map[string]any{"token": os.Getenv("SITH_EXEC_TOKEN")}, + } + if json.NewEncoder(os.Stdout).Encode(credential) != nil { + os.Exit(2) + } + os.Exit(0) +} + +func testLoadingRules(t *testing.T, config clientcmdapi.Config) *clientcmd.ClientConfigLoadingRules { + t.Helper() + path := filepath.Join(t.TempDir(), "config") + if err := clientcmd.WriteToFile(config, path); err != nil { + t.Fatalf("write test kubeconfig: %v", err) + } + return &clientcmd.ClientConfigLoadingRules{ExplicitPath: path} +} + +func testConfig(contexts ...string) clientcmdapi.Config { + config := clientcmdapi.NewConfig() + for _, name := range contexts { + config.Clusters[name] = &clientcmdapi.Cluster{Server: "https://" + name + ".invalid"} + config.AuthInfos[name] = &clientcmdapi.AuthInfo{} + config.Contexts[name] = &clientcmdapi.Context{Cluster: name, AuthInfo: name} + } + return *config +} + +func fakeClient(objects ...runtime.Object) *dynamicfake.FakeDynamicClient { + listKinds := map[schema.GroupVersionResource]string{ + {Version: "v1", Resource: "pods"}: "PodList", + } + return dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), listKinds, objects...) +} + +func pod(name, namespace, image string, labels map[string]string) *unstructured.Unstructured { + unstructuredLabels := make(map[string]any, len(labels)) + for key, value := range labels { + unstructuredLabels[key] = value + } + return &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "v1", + "kind": "Pod", + "metadata": map[string]any{ + "name": name, + "namespace": namespace, + "uid": name + "-uid", + "labels": unstructuredLabels, + }, + "spec": map[string]any{ + "containers": []any{map[string]any{"name": "app", "image": image}}, + }, + }} +} diff --git a/internal/connector/kubeconfig/resources.go b/internal/connector/kubeconfig/resources.go new file mode 100644 index 0000000..cf74b95 --- /dev/null +++ b/internal/connector/kubeconfig/resources.go @@ -0,0 +1,370 @@ +// SPDX-License-Identifier: Apache-2.0 + +package kubeconfig + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + "time" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" + + "github.com/ArdurAI/sith/internal/connector" + "github.com/ArdurAI/sith/internal/fleet" +) + +// ErrUnknownScope reports a context that was not present during discovery. +var ErrUnknownScope = errors.New("kubeconfig scope is unknown") + +// ErrUnreachableScope reports a discovered context without a live client. +var ErrUnreachableScope = errors.New("kubeconfig scope is unreachable") + +// ErrUnsupportedResource reports a resource kind outside this adapter's typed map. +var ErrUnsupportedResource = errors.New("resource kind is unsupported") + +// ErrUnsupportedSelector reports a selector not yet expressible by this adapter. +var ErrUnsupportedSelector = errors.New("query selector is unsupported") + +type resourceSpec struct { + kind string + gvr schema.GroupVersionResource + namespaced bool +} + +var resourceSpecs = map[string]resourceSpec{ + "deployment": {kind: "Deployment", gvr: schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments"}, namespaced: true}, + "deployments": {kind: "Deployment", gvr: schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments"}, namespaced: true}, + "replicaset": {kind: "ReplicaSet", gvr: schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "replicasets"}, namespaced: true}, + "replicasets": {kind: "ReplicaSet", gvr: schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "replicasets"}, namespaced: true}, + "pod": {kind: "Pod", gvr: schema.GroupVersionResource{Version: "v1", Resource: "pods"}, namespaced: true}, + "pods": {kind: "Pod", gvr: schema.GroupVersionResource{Version: "v1", Resource: "pods"}, namespaced: true}, + "node": {kind: "Node", gvr: schema.GroupVersionResource{Version: "v1", Resource: "nodes"}}, + "nodes": {kind: "Node", gvr: schema.GroupVersionResource{Version: "v1", Resource: "nodes"}}, + "service": {kind: "Service", gvr: schema.GroupVersionResource{Version: "v1", Resource: "services"}, namespaced: true}, + "services": {kind: "Service", gvr: schema.GroupVersionResource{Version: "v1", Resource: "services"}, namespaced: true}, + "namespace": {kind: "Namespace", gvr: schema.GroupVersionResource{Version: "v1", Resource: "namespaces"}}, + "namespaces": {kind: "Namespace", gvr: schema.GroupVersionResource{Version: "v1", Resource: "namespaces"}}, + "event": {kind: "Event", gvr: schema.GroupVersionResource{Version: "v1", Resource: "events"}, namespaced: true}, + "events": {kind: "Event", gvr: schema.GroupVersionResource{Version: "v1", Resource: "events"}, namespaced: true}, + "rollout": {kind: "Rollout", gvr: schema.GroupVersionResource{Group: "argoproj.io", Version: "v1alpha1", Resource: "rollouts"}, namespaced: true}, + "rollouts": {kind: "Rollout", gvr: schema.GroupVersionResource{Group: "argoproj.io", Version: "v1alpha1", Resource: "rollouts"}, namespaced: true}, +} + +// Read fetches one resource from its explicitly addressed context. +func (adapter *Adapter) Read(ctx context.Context, ref fleet.ResourceRef) (fleet.Evidence, error) { + if err := adapter.ensureDiscovered(ctx); err != nil { + return fleet.Evidence{}, err + } + if ref.SourceKind != "" && ref.SourceKind != Kind { + return fleet.Evidence{}, fmt.Errorf("%w: source kind %q", ErrUnsupportedResource, ref.SourceKind) + } + + spec, ok := lookupResource(ref.Kind) + if !ok { + return fleet.Evidence{}, fmt.Errorf("%w: %q", ErrUnsupportedResource, ref.Kind) + } + if expected := ref.Attributes["gvr"]; expected != "" && expected != spec.gvr.String() { + return fleet.Evidence{}, fmt.Errorf("%w: GVR %q does not match kind %q", ErrUnsupportedResource, expected, ref.Kind) + } + + scope, client, ok := adapter.scopeClient(ref.Scope) + if !ok { + return fleet.Evidence{}, fmt.Errorf("%w: %s", ErrUnknownScope, ref.Scope) + } + if !scope.Reachable || client == nil { + return fleet.Evidence{}, fmt.Errorf("%w: %s", ErrUnreachableScope, ref.Scope) + } + + resource := resourceInterface(client, spec, ref.Namespace) + object, err := resource.Get(ctx, ref.Name, metav1.GetOptions{}) + if err != nil { + return fleet.Evidence{}, fmt.Errorf("read %s: %w", ref.String(), err) + } + observedAt := adapter.settings.now().UTC() + evidence, err := evidenceFromObject(*object, spec, ref.Scope, observedAt) + if err != nil { + return fleet.Evidence{}, err + } + adapter.recordLastSeen(ref.Scope, observedAt) + return evidence, nil +} + +// Query fans a typed resource selection out across independent contexts. +func (adapter *Adapter) Query(ctx context.Context, query fleet.Query) (fleet.QueryResult, error) { + if err := query.Validate(); err != nil { + return fleet.QueryResult{}, fmt.Errorf("validate fleet query: %w", err) + } + if query.Selector.CVE != "" || query.Selector.Health != "" { + return fleet.QueryResult{}, fmt.Errorf("%w: health and CVE predicates arrive in later slices", ErrUnsupportedSelector) + } + if err := adapter.ensureDiscovered(ctx); err != nil { + return fleet.QueryResult{}, err + } + + var spec resourceSpec + if query.Selector.ResourceKind != "" { + var ok bool + spec, ok = lookupResource(query.Selector.ResourceKind) + if !ok { + return fleet.QueryResult{}, fmt.Errorf("%w: %q", ErrUnsupportedResource, query.Selector.ResourceKind) + } + if !spec.namespaced && query.Selector.Namespace != "" { + return fleet.QueryResult{}, fmt.Errorf("%w: namespace cannot select cluster-scoped %s", ErrUnsupportedSelector, spec.kind) + } + } + + scopes, clients, lastSeen := adapter.stateSnapshot() + targets := targetScopeNames(query.Scopes, scopes) + results := make([]scopeQueryResult, len(targets)) + adapter.runBounded(len(targets), func(index int) { + name := targets[index] + results[index] = adapter.queryScope(ctx, name, clients[name], spec, query) + }) + if err := ctx.Err(); err != nil { + return fleet.QueryResult{}, fmt.Errorf("query kubeconfig contexts: %w", err) + } + + now := adapter.settings.now().UTC() + coverage := fleet.Coverage{Requested: len(targets)} + facts := make([]fleet.Fact, 0) + for _, result := range results { + if result.err != nil { + coverage.Unreachable = append(coverage.Unreachable, result.name) + if isStale(now, lastSeen[result.name], adapter.settings.staleAfter) { + coverage.Stale = append(coverage.Stale, result.name) + } + continue + } + coverage.Reachable++ + facts = append(facts, result.facts...) + if !result.observedAt.IsZero() { + adapter.recordLastSeen(result.name, result.observedAt) + } else if isStale(now, lastSeen[result.name], adapter.settings.staleAfter) { + coverage.Stale = append(coverage.Stale, result.name) + } + } + + sort.Slice(facts, func(left, right int) bool { + return facts[left].Ref.String() < facts[right].Ref.String() + }) + if query.Limit > 0 && len(facts) > query.Limit { + facts = facts[:query.Limit] + } + if facts == nil { + facts = []fleet.Fact{} + } + sort.Strings(coverage.Unreachable) + sort.Strings(coverage.Stale) + return fleet.QueryResult{Facts: facts, Coverage: coverage}, nil +} + +type scopeQueryResult struct { + name string + facts []fleet.Fact + observedAt time.Time + err error +} + +func (adapter *Adapter) queryScope( + ctx context.Context, + name string, + client dynamic.Interface, + spec resourceSpec, + query fleet.Query, +) scopeQueryResult { + result := scopeQueryResult{name: name} + if client == nil { + result.err = ErrUnreachableScope + return result + } + if query.Selector.ResourceKind == "" { + return result + } + + resource := resourceInterface(client, spec, query.Selector.Namespace) + list, err := resource.List(ctx, metav1.ListOptions{LabelSelector: labels.SelectorFromSet(query.Selector.Labels).String()}) + if err != nil { + if spec.kind == "Rollout" && apierrors.IsNotFound(err) { + return result + } + result.err = fmt.Errorf("list %s in %s: %w", spec.kind, name, err) + return result + } + + result.observedAt = adapter.settings.now().UTC() + if !wantsInventory(query.Kinds) { + result.facts = []fleet.Fact{} + return result + } + result.facts = make([]fleet.Fact, 0, len(list.Items)) + for _, object := range list.Items { + if query.Selector.NamePrefix != "" && !strings.HasPrefix(object.GetName(), query.Selector.NamePrefix) { + continue + } + if query.Selector.Image != "" && !objectUsesImage(object, query.Selector.Image) { + continue + } + evidence, err := evidenceFromObject(object, spec, name, result.observedAt) + if err != nil { + result.err = err + return result + } + result.facts = append(result.facts, fleet.Fact{Evidence: evidence, Workspace: fleet.LocalWorkspace}) + } + return result +} + +func evidenceFromObject( + object unstructured.Unstructured, + spec resourceSpec, + scope string, + observedAt time.Time, +) (fleet.Evidence, error) { + payload, err := json.Marshal(object.Object) + if err != nil { + return fleet.Evidence{}, fmt.Errorf("marshal %s/%s: %w", spec.kind, object.GetName(), err) + } + return fleet.Evidence{ + Ref: fleet.ResourceRef{ + SourceKind: Kind, + Scope: scope, + Kind: spec.kind, + Namespace: object.GetNamespace(), + Name: object.GetName(), + Attributes: map[string]string{"gvr": spec.gvr.String()}, + }, + Kind: fleet.FactInventory, + Observed: payload, + ObservedAt: observedAt, + Source: scope, + Provenance: fleet.Provenance{ + Adapter: Kind, + ProtocolV: protocolVersion, + NativeID: string(object.GetUID()), + }, + }, nil +} + +func lookupResource(kind string) (resourceSpec, bool) { + spec, ok := resourceSpecs[strings.ToLower(strings.TrimSpace(kind))] + return spec, ok +} + +func resourceInterface(client dynamic.Interface, spec resourceSpec, namespace string) dynamic.ResourceInterface { + resource := client.Resource(spec.gvr) + if spec.namespaced { + return resource.Namespace(namespace) + } + return resource +} + +func wantsInventory(kinds []fleet.FactKind) bool { + if len(kinds) == 0 { + return true + } + for _, kind := range kinds { + if kind == fleet.FactInventory { + return true + } + } + return false +} + +func objectUsesImage(object unstructured.Unstructured, image string) bool { + paths := [][]string{ + {"spec", "containers"}, + {"spec", "initContainers"}, + {"spec", "template", "spec", "containers"}, + {"spec", "template", "spec", "initContainers"}, + } + for _, path := range paths { + containers, found, err := unstructured.NestedSlice(object.Object, path...) + if err != nil || !found { + continue + } + for _, raw := range containers { + container, ok := raw.(map[string]any) + if !ok { + continue + } + value, ok := container["image"].(string) + if ok && strings.Contains(value, image) { + return true + } + } + } + return false +} + +func targetScopeNames(requested []string, scopes map[string]connector.Scope) []string { + set := make(map[string]struct{}) + if len(requested) == 0 { + for name := range scopes { + set[name] = struct{}{} + } + } else { + for _, name := range requested { + if name != "" { + set[name] = struct{}{} + } + } + } + result := make([]string, 0, len(set)) + for name := range set { + result = append(result, name) + } + sort.Strings(result) + return result +} + +func (adapter *Adapter) scopeClient(name string) (connector.Scope, dynamic.Interface, bool) { + adapter.mu.RLock() + defer adapter.mu.RUnlock() + scope, exists := adapter.scopes[name] + return cloneScope(scope), adapter.clients[name], exists +} + +func (adapter *Adapter) stateSnapshot() ( + map[string]connector.Scope, + map[string]dynamic.Interface, + map[string]time.Time, +) { + adapter.mu.RLock() + defer adapter.mu.RUnlock() + scopes := make(map[string]connector.Scope, len(adapter.scopes)) + for name, scope := range adapter.scopes { + scopes[name] = cloneScope(scope) + } + clients := make(map[string]dynamic.Interface, len(adapter.clients)) + for name, client := range adapter.clients { + clients[name] = client + } + lastSeen := make(map[string]time.Time, len(adapter.lastSeen)) + for name, observed := range adapter.lastSeen { + lastSeen[name] = observed + } + return scopes, clients, lastSeen +} + +func (adapter *Adapter) recordLastSeen(name string, observed time.Time) { + adapter.mu.Lock() + adapter.lastSeen[name] = observed + scope := adapter.scopes[name] + scope.ObservedAt = observed + scope.Reachable = true + adapter.scopes[name] = scope + adapter.mu.Unlock() +} + +func isStale(now, observed time.Time, threshold time.Duration) bool { + return !observed.IsZero() && now.Sub(observed) > threshold +} diff --git a/sessions/2026-07-10-slice-1-source-adapter.md b/sessions/2026-07-10-slice-1-source-adapter.md index a2fc4eb..0a6565a 100644 --- a/sessions/2026-07-10-slice-1-source-adapter.md +++ b/sessions/2026-07-10-slice-1-source-adapter.md @@ -28,8 +28,17 @@ adapter and client-go fan-out. Go 1.25 to the supported Go 1.26 line instead of pinning an older Kubernetes client. [T] Test: Rebuilt golangci-lint v2.12.2 with Go 1.26.5; the complete `make ci` gate passes on the new toolchain with no code or output changes. -[C] Checkpoint #2: this commit — adopt the supported Go 1.26 toolchain required by current +[C] Checkpoint #2: a53d262 — adopt the supported Go 1.26 toolchain required by current client-go; next: implement the adapter. +[A] Action: Implemented the read-only local-kubeconfig adapter with independent bounded context +probes, dynamic clients, typed inventory reads/queries, explicit partial coverage, and preserved +last-seen timestamps when a previously reachable context becomes unavailable. +[T] Test: Adapter tests exercise concurrent success/failure, stale observation preservation, +typed label/name/image selectors, source-stamped evidence, unknown/unreachable reads, and an actual +ExecCredential v1 subprocess authenticated request to a TLS test API. Focused race tests, lint, +and 81.5% statement coverage pass. +[C] Checkpoint #3: this commit — local-kubeconfig discovery/read/query adapter; next: bridge the +adapter into `sith clusters` and validate the real CLI path. --- From 87053cae7a54c07fb7be625275a85b058b12443c Mon Sep 17 00:00:00 2001 From: Gnani Rahul Date: Fri, 10 Jul 2026 15:36:10 -0500 Subject: [PATCH 04/14] feat(cli): discover local kubeconfig contexts GSTACK-Checkpoint: 2026-07-10/slice-1-source-adapter#4 Signed-off-by: Gnani Rahul --- README.md | 11 ++++--- internal/cli/root.go | 4 ++- internal/connector/kubeconfig/adapter.go | 33 +++++++++++++------ sessions/2026-07-10-slice-1-source-adapter.md | 9 ++++- tests/e2e/smoke_test.go | 8 +++-- 5 files changed, 47 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index b3a18e9..ec395c7 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Sith -**Status: Slice 0 foundation.** The local-first CLI walking skeleton is runnable; Kubernetes -context discovery arrives in Slice 1. +**Status: Slice 1 local fleet source.** The CLI discovers every context resolved by client-go, +probes them independently, and reports reachable and unreachable clusters without a hub. Sith is ArdurAI's single-binary, local-first Kubernetes fleet tool: **k9s for your whole fleet**. It is designed to aggregate every kubeconfig context without an account, telemetry, or cluster @@ -19,8 +19,11 @@ make build ./bin/sith clusters ``` -Slice 0 intentionally returns a typed empty fleet through the stubbed `fleet.Source` seam. Run the -full local quality gate with a pinned golangci-lint v2.12.2 on `PATH`: +`sith clusters` follows standard client-go loading rules: set `KUBECONFIG` to an OS path-list or +use the default `~/.kube/config`. Exec-credential helpers run locally, exactly as they do for +`kubectl`; Sith does not copy kubeconfigs or credentials elsewhere. + +Run the full local quality gate with a pinned golangci-lint v2.12.2 on `PATH`: ```bash make ci diff --git a/internal/cli/root.go b/internal/cli/root.go index bea3098..2e89479 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -13,6 +13,8 @@ import ( "github.com/spf13/cobra" "github.com/ArdurAI/sith/internal/config" + "github.com/ArdurAI/sith/internal/connector" + "github.com/ArdurAI/sith/internal/connector/kubeconfig" "github.com/ArdurAI/sith/internal/fleet" "github.com/ArdurAI/sith/internal/logging" ) @@ -33,7 +35,7 @@ type rootOptions struct { // Execute builds and runs the command tree, returning a process exit code. func Execute() int { - return execute(os.Args[1:], fleet.StubSource{}, os.Stdout, os.Stderr) + return execute(os.Args[1:], connector.AsSource(kubeconfig.Default()), os.Stdout, os.Stderr) } func execute(args []string, source fleet.Source, stdout, stderr io.Writer) int { diff --git a/internal/connector/kubeconfig/adapter.go b/internal/connector/kubeconfig/adapter.go index fa3b50a..17a24fe 100644 --- a/internal/connector/kubeconfig/adapter.go +++ b/internal/connector/kubeconfig/adapter.go @@ -156,9 +156,28 @@ type Adapter struct { var _ connector.Reader = (*Adapter)(nil) +// Default constructs an adapter using client-go's KUBECONFIG and home-directory rules. +func Default() *Adapter { + return newAdapter(defaultOptions()) +} + // New constructs a local kubeconfig adapter without performing network I/O. func New(opts ...Option) (*Adapter, error) { - settings := options{ + settings := defaultOptions() + for _, option := range opts { + if option == nil { + return nil, fmt.Errorf("configure local kubeconfig adapter: option is nil") + } + if err := option(&settings); err != nil { + return nil, fmt.Errorf("configure local kubeconfig adapter: %w", err) + } + } + + return newAdapter(settings), nil +} + +func defaultOptions() options { + return options{ loadingRules: clientcmd.NewDefaultClientConfigLoadingRules(), probeTimeout: defaultProbeTimeout, requestTimeout: defaultRequestTimeout, @@ -170,21 +189,15 @@ func New(opts ...Option) (*Adapter, error) { return dynamic.NewForConfig(config) }, } - for _, option := range opts { - if option == nil { - return nil, fmt.Errorf("configure local kubeconfig adapter: option is nil") - } - if err := option(&settings); err != nil { - return nil, fmt.Errorf("configure local kubeconfig adapter: %w", err) - } - } +} +func newAdapter(settings options) *Adapter { return &Adapter{ settings: settings, scopes: make(map[string]connector.Scope), clients: make(map[string]dynamic.Interface), lastSeen: make(map[string]time.Time), - }, nil + } } // Kind identifies this connector in the registry and resource address space. diff --git a/sessions/2026-07-10-slice-1-source-adapter.md b/sessions/2026-07-10-slice-1-source-adapter.md index 0a6565a..c74383a 100644 --- a/sessions/2026-07-10-slice-1-source-adapter.md +++ b/sessions/2026-07-10-slice-1-source-adapter.md @@ -37,8 +37,15 @@ last-seen timestamps when a previously reachable context becomes unavailable. typed label/name/image selectors, source-stamped evidence, unknown/unreachable reads, and an actual ExecCredential v1 subprocess authenticated request to a TLS test API. Focused race tests, lint, and 81.5% statement coverage pass. -[C] Checkpoint #3: this commit — local-kubeconfig discovery/read/query adapter; next: bridge the +[C] Checkpoint #3: 3463c1b — local-kubeconfig discovery/read/query adapter; next: bridge the adapter into `sith clusters` and validate the real CLI path. +[A] Action: Replaced the Slice-0 stub at the single CLI injection point with +`connector.AsSource(kubeconfig.Default())`; default construction follows client-go's standard +`KUBECONFIG` path-list and `~/.kube/config` resolution without doing startup network I/O. +[A] Action: Updated the public README from the Slice-0 stub behavior to the real local-fleet +discovery and credential-locality contract. +[C] Checkpoint #4: this commit — production CLI bridge; next: prove two reachable kind clusters +plus one unreachable context through the built binary. --- diff --git a/tests/e2e/smoke_test.go b/tests/e2e/smoke_test.go index 1f198e7..cd5a4c8 100644 --- a/tests/e2e/smoke_test.go +++ b/tests/e2e/smoke_test.go @@ -19,6 +19,10 @@ import ( func TestBinarySmoke(t *testing.T) { root := repositoryRoot(t) binary := filepath.Join(t.TempDir(), "sith") + kubeconfig := filepath.Join(t.TempDir(), "kubeconfig") + if err := os.WriteFile(kubeconfig, []byte("apiVersion: v1\nkind: Config\n"), 0o600); err != nil { + t.Fatalf("write empty kubeconfig: %v", err) + } ctx, cancel := context.WithTimeout(context.Background(), time.Minute) defer cancel() @@ -36,7 +40,7 @@ func TestBinarySmoke(t *testing.T) { }{ {name: "version text", args: []string{"version"}, contains: "sith dev"}, {name: "version JSON", args: []string{"version", "-o", "json"}, validJSON: true}, - {name: "clusters text", args: []string{"clusters"}, contains: "No clusters found"}, + {name: "clusters text", args: []string{"clusters"}, contains: "No clusters found (source: local-kubeconfig)."}, {name: "clusters JSON", args: []string{"clusters", "-o", "json"}, validJSON: true}, {name: "ui stub", args: []string{"ui"}, contains: "not yet implemented"}, {name: "hub stub", args: []string{"hub"}, contains: "phase-1+"}, @@ -48,7 +52,7 @@ func TestBinarySmoke(t *testing.T) { test := test t.Run(test.name, func(t *testing.T) { command := exec.CommandContext(ctx, binary, test.args...) - command.Env = append(os.Environ(), "XDG_CONFIG_HOME="+t.TempDir()) + command.Env = append(os.Environ(), "XDG_CONFIG_HOME="+t.TempDir(), "KUBECONFIG="+kubeconfig) output, err := command.CombinedOutput() if err != nil { t.Fatalf("run %v: %v\n%s", test.args, err, output) From 7e3c594571134ab1d7e19048154a563b0f91b3c6 Mon Sep 17 00:00:00 2001 From: Gnani Rahul Date: Fri, 10 Jul 2026 15:36:26 -0500 Subject: [PATCH 05/14] test(connector): prove real multi-cluster fan-out Add the race-enabled two-kind-cluster gate, harden request timeouts and validation, and keep reachable dependency vulnerabilities out of CI. GSTACK-Checkpoint: 2026-07-10/slice-1-source-adapter#5 Signed-off-by: Gnani Rahul --- .github/workflows/ci.yml | 12 ++ .golangci.yml | 3 + Makefile | 15 +- README.md | 10 +- go.mod | 8 +- go.sum | 16 +- internal/connector/kubeconfig/adapter.go | 35 +++- internal/connector/kubeconfig/adapter_test.go | 92 +++++++++ internal/connector/kubeconfig/resources.go | 38 +++- sessions/2026-07-10-slice-1-source-adapter.md | 21 +- tests/e2e/kind_fanout_test.go | 191 ++++++++++++++++++ 11 files changed, 409 insertions(+), 32 deletions(-) create mode 100644 tests/e2e/kind_fanout_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2942003..1adfb3b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,6 +16,7 @@ concurrency: env: GO_VERSION: "1.26.x" GOLANGCI_VERSION: "v2.12.2" + GOVULNCHECK_VERSION: "v1.6.0" jobs: build-test-lint: @@ -59,6 +60,11 @@ jobs: - name: Format and imports check run: golangci-lint fmt --diff ./... + - name: Vulnerability scan + run: | + go install golang.org/x/vuln/cmd/govulncheck@${GOVULNCHECK_VERSION} + govulncheck ./... + - name: Build run: go build -trimpath ./... @@ -67,3 +73,9 @@ jobs: - name: Binary integration smoke test run: go test -race -count=1 -tags=e2e ./tests/e2e + + - name: Install pinned kind + run: go install sigs.k8s.io/kind@v0.32.0 + + - name: Real two-cluster fan-out test + run: make e2e-kind KIND="$(go env GOPATH)/bin/kind" diff --git a/.golangci.yml b/.golangci.yml index 1bf0774..9e6bc5f 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -3,6 +3,9 @@ version: "2" run: timeout: 5m tests: true + build-tags: + - e2e + - kind linters: default: none diff --git a/Makefile b/Makefile index f36024d..f326495 100644 --- a/Makefile +++ b/Makefile @@ -6,6 +6,10 @@ PKG := github.com/ArdurAI/sith CMD := ./cmd/sith BIN_DIR := bin GOLANGCI ?= golangci-lint +GOVULNCHECK ?= govulncheck +KIND ?= kind + +KIND_NODE_IMAGE ?= kindest/node:v1.36.1@sha256:3489c7674813ba5d8b1a9977baea8a6e553784dab7b84759d1014dbd78f7ebd5 VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo none) @@ -16,7 +20,7 @@ LDFLAGS := -s -w \ -X $(PKG)/internal/buildinfo.Commit=$(COMMIT) \ -X $(PKG)/internal/buildinfo.Date=$(DATE) -.PHONY: all build test e2e lint fmt fmt-check vet tidy clean run ci help +.PHONY: all build test e2e e2e-kind lint vuln fmt fmt-check vet tidy clean run ci help all: build @@ -30,9 +34,16 @@ test: ## Run unit tests with the race detector and report coverage e2e: ## Build and exercise the real binary as a subprocess go test -race -count=1 -tags=e2e ./tests/e2e +e2e-kind: ## Exercise adapter and binary against two real kind clusters + KIND_BIN="$(KIND)" KIND_NODE_IMAGE="$(KIND_NODE_IMAGE)" \ + go test -race -count=1 -timeout=15m -tags='e2e kind' -run '^TestKindFleetFanout$$' ./tests/e2e + lint: ## Run golangci-lint (v2) $(GOLANGCI) run ./... +vuln: ## Scan reachable Go call paths for known vulnerabilities + $(GOVULNCHECK) ./... + fmt: ## Format code (gofmt + goimports via golangci-lint v2 formatters) $(GOLANGCI) fmt ./... @@ -53,7 +64,7 @@ clean: ## Remove build and coverage artifacts run: build ## Build then run sith version $(BIN_DIR)/$(BINARY) version -ci: fmt-check vet lint test e2e build ## Run the full CI gate locally +ci: fmt-check vet lint vuln test e2e build ## Run the full CI gate locally help: ## List targets @grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | \ diff --git a/README.md b/README.md index ec395c7..a79690d 100644 --- a/README.md +++ b/README.md @@ -23,11 +23,19 @@ make build use the default `~/.kube/config`. Exec-credential helpers run locally, exactly as they do for `kubectl`; Sith does not copy kubeconfigs or credentials elsewhere. -Run the full local quality gate with a pinned golangci-lint v2.12.2 on `PATH`: +Run the full local quality gate with golangci-lint v2.12.2 and govulncheck v1.6.0 on `PATH`: ```bash make ci ``` +The real multi-cluster gate creates two temporary kind clusters with a digest-pinned node image, +checks one additional unreachable context, and removes both clusters afterward. It requires a +running Docker engine and kind v0.32.0, and consumes additional CI time, disk, and memory: + +```bash +make e2e-kind +``` + The architecture, threat model, ADRs, and roadmap live under [`docs/`](docs/). Build-session checkpoints are recorded under [`sessions/`](sessions/). diff --git a/go.mod b/go.mod index 2b5734c..d4d5d13 100644 --- a/go.mod +++ b/go.mod @@ -29,11 +29,11 @@ require ( github.com/spf13/pflag v1.0.9 // indirect github.com/x448/float16 v0.8.4 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect - golang.org/x/net v0.49.0 // indirect + golang.org/x/net v0.55.0 // indirect golang.org/x/oauth2 v0.34.0 // indirect - golang.org/x/sys v0.40.0 // indirect - golang.org/x/term v0.39.0 // indirect - golang.org/x/text v0.33.0 // indirect + golang.org/x/sys v0.45.0 // indirect + golang.org/x/term v0.43.0 // indirect + golang.org/x/text v0.37.0 // indirect golang.org/x/time v0.14.0 // indirect google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect diff --git a/go.sum b/go.sum index 4194dde..f30273e 100644 --- a/go.sum +++ b/go.sum @@ -75,16 +75,16 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= -golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= -golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/term v0.39.0 h1:RclSuaJf32jOqZz74CkPA9qFuVTX7vhLlpfj/IGWlqY= -golang.org/x/term v0.39.0/go.mod h1:yxzUCTP/U+FzoxfdKmLaA0RV1WgE0VY7hXBwKtY/4ww= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= +golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI= diff --git a/internal/connector/kubeconfig/adapter.go b/internal/connector/kubeconfig/adapter.go index 17a24fe..574099c 100644 --- a/internal/connector/kubeconfig/adapter.go +++ b/internal/connector/kubeconfig/adapter.go @@ -302,9 +302,10 @@ func (adapter *Adapter) probeContext( probeConfig := rest.CopyConfig(restConfig) probeConfig.Timeout = adapter.settings.probeTimeout - probeCtx, cancel := context.WithTimeout(ctx, adapter.settings.probeTimeout) - defer cancel() - if err := adapter.settings.probe(probeCtx, probeConfig); err != nil { + _, err = callWithTimeout(ctx, adapter.settings.probeTimeout, func(probeCtx context.Context) (struct{}, error) { + return struct{}{}, adapter.settings.probe(probeCtx, probeConfig) + }) + if err != nil { return contextResult{scope: scope} } @@ -343,6 +344,34 @@ func (adapter *Adapter) runBounded(count int, operation func(index int)) { waitGroup.Wait() } +type operationResult[T any] struct { + value T + err error +} + +func callWithTimeout[T any]( + ctx context.Context, + timeout time.Duration, + operation func(context.Context) (T, error), +) (T, error) { + operationCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + result := make(chan operationResult[T], 1) + // client-go's exec authenticator uses exec.Command rather than CommandContext. Isolating the + // call keeps one auth helper that ignores cancellation from stalling the rest of the fleet. + go func() { + value, err := operation(operationCtx) + result <- operationResult[T]{value: value, err: err} + }() + select { + case completed := <-result: + return completed.value, completed.err + case <-operationCtx.Done(): + var zero T + return zero, operationCtx.Err() + } +} + func (adapter *Adapter) ensureDiscovered(ctx context.Context) error { adapter.mu.RLock() discovered := adapter.discovered diff --git a/internal/connector/kubeconfig/adapter_test.go b/internal/connector/kubeconfig/adapter_test.go index d741ce3..104f372 100644 --- a/internal/connector/kubeconfig/adapter_test.go +++ b/internal/connector/kubeconfig/adapter_test.go @@ -22,6 +22,7 @@ import ( "k8s.io/client-go/dynamic" dynamicfake "k8s.io/client-go/dynamic/fake" "k8s.io/client-go/rest" + k8stesting "k8s.io/client-go/testing" "k8s.io/client-go/tools/clientcmd" clientcmdapi "k8s.io/client-go/tools/clientcmd/api" @@ -116,6 +117,34 @@ func TestDiscoverIsIndependentAndPreservesLastSeen(t *testing.T) { } } +func TestDiscoverTimesOutProbeThatIgnoresContext(t *testing.T) { + t.Parallel() + release := make(chan struct{}) + adapter, err := New( + WithLoadingRules(testLoadingRules(t, testConfig("blocked"))), + WithProbeTimeout(20*time.Millisecond), + withProbe(func(_ context.Context, _ *rest.Config) error { + <-release + return nil + }), + ) + if err != nil { + t.Fatalf("New() error = %v", err) + } + started := time.Now() + discovery, err := adapter.Discover(context.Background()) + close(release) + if err != nil { + t.Fatalf("Discover() error = %v", err) + } + if elapsed := time.Since(started); elapsed > time.Second { + t.Fatalf("Discover() took %s, want bounded probe timeout", elapsed) + } + if !slices.Equal(discovery.Unreachable, []string{"blocked"}) { + t.Fatalf("Unreachable = %v, want [blocked]", discovery.Unreachable) + } +} + func TestQueryAndReadReturnSourceStampedEvidenceWithPartialCoverage(t *testing.T) { t.Parallel() observedAt := time.Date(2026, time.July, 10, 13, 0, 0, 0, time.UTC) @@ -195,6 +224,69 @@ func TestQueryAndReadReturnSourceStampedEvidenceWithPartialCoverage(t *testing.T } } +func TestInvalidInputsFailBeforeDiscovery(t *testing.T) { + t.Parallel() + probeCalls := 0 + adapter, err := New( + WithLoadingRules(testLoadingRules(t, testConfig("alpha"))), + withProbe(func(_ context.Context, _ *rest.Config) error { + probeCalls++ + return nil + }), + ) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + _, err = adapter.Read(context.Background(), fleet.ResourceRef{Scope: "alpha", Kind: "Pod"}) + if !errors.Is(err, ErrInvalidReference) { + t.Fatalf("Read(invalid) error = %v, want ErrInvalidReference", err) + } + _, err = adapter.Query(context.Background(), fleet.Query{ + Selector: fleet.Selector{ResourceKind: "Pod", Labels: map[string]string{"bad key": "value"}}, + }) + if !errors.Is(err, ErrUnsupportedSelector) { + t.Fatalf("Query(invalid label) error = %v, want ErrUnsupportedSelector", err) + } + if probeCalls != 0 { + t.Fatalf("probe calls = %d, want invalid inputs rejected before credential/network work", probeCalls) + } +} + +func TestQueryTimesOutClientThatIgnoresContext(t *testing.T) { + t.Parallel() + release := make(chan struct{}) + finished := make(chan struct{}) + client := fakeClient() + client.PrependReactor("list", "pods", func(_ k8stesting.Action) (bool, runtime.Object, error) { + <-release + close(finished) + return true, &unstructured.UnstructuredList{}, nil + }) + adapter, err := New( + WithLoadingRules(testLoadingRules(t, testConfig("alpha"))), + WithRequestTimeout(20*time.Millisecond), + withProbe(func(_ context.Context, _ *rest.Config) error { return nil }), + withDynamicFactory(func(_ *rest.Config) (dynamic.Interface, error) { return client, nil }), + ) + if err != nil { + t.Fatalf("New() error = %v", err) + } + started := time.Now() + result, err := adapter.Query(context.Background(), fleet.Query{Selector: fleet.Selector{ResourceKind: "Pod"}}) + close(release) + <-finished + if err != nil { + t.Fatalf("Query() error = %v", err) + } + if elapsed := time.Since(started); elapsed > time.Second { + t.Fatalf("Query() took %s, want bounded request timeout", elapsed) + } + if result.Coverage.Reachable != 0 || !slices.Equal(result.Coverage.Unreachable, []string{"alpha"}) { + t.Fatalf("Coverage = %#v, want timed-out alpha surfaced as unreachable", result.Coverage) + } +} + func TestDefaultProbeExecutesExecCredentialLocally(t *testing.T) { if os.Getenv("SITH_EXEC_HELPER") == "1" { runExecCredentialHelper() diff --git a/internal/connector/kubeconfig/resources.go b/internal/connector/kubeconfig/resources.go index cf74b95..09927c9 100644 --- a/internal/connector/kubeconfig/resources.go +++ b/internal/connector/kubeconfig/resources.go @@ -34,6 +34,9 @@ var ErrUnsupportedResource = errors.New("resource kind is unsupported") // ErrUnsupportedSelector reports a selector not yet expressible by this adapter. var ErrUnsupportedSelector = errors.New("query selector is unsupported") +// ErrInvalidReference reports a resource address that is incomplete or inconsistent. +var ErrInvalidReference = errors.New("resource reference is invalid") + type resourceSpec struct { kind string gvr schema.GroupVersionResource @@ -61,12 +64,12 @@ var resourceSpecs = map[string]resourceSpec{ // Read fetches one resource from its explicitly addressed context. func (adapter *Adapter) Read(ctx context.Context, ref fleet.ResourceRef) (fleet.Evidence, error) { - if err := adapter.ensureDiscovered(ctx); err != nil { - return fleet.Evidence{}, err - } if ref.SourceKind != "" && ref.SourceKind != Kind { return fleet.Evidence{}, fmt.Errorf("%w: source kind %q", ErrUnsupportedResource, ref.SourceKind) } + if strings.TrimSpace(ref.Scope) == "" || strings.TrimSpace(ref.Name) == "" { + return fleet.Evidence{}, fmt.Errorf("%w: scope and name are required", ErrInvalidReference) + } spec, ok := lookupResource(ref.Kind) if !ok { @@ -75,6 +78,9 @@ func (adapter *Adapter) Read(ctx context.Context, ref fleet.ResourceRef) (fleet. if expected := ref.Attributes["gvr"]; expected != "" && expected != spec.gvr.String() { return fleet.Evidence{}, fmt.Errorf("%w: GVR %q does not match kind %q", ErrUnsupportedResource, expected, ref.Kind) } + if err := adapter.ensureDiscovered(ctx); err != nil { + return fleet.Evidence{}, err + } scope, client, ok := adapter.scopeClient(ref.Scope) if !ok { @@ -85,7 +91,9 @@ func (adapter *Adapter) Read(ctx context.Context, ref fleet.ResourceRef) (fleet. } resource := resourceInterface(client, spec, ref.Namespace) - object, err := resource.Get(ctx, ref.Name, metav1.GetOptions{}) + object, err := callWithTimeout(ctx, adapter.settings.requestTimeout, func(requestCtx context.Context) (*unstructured.Unstructured, error) { + return resource.Get(requestCtx, ref.Name, metav1.GetOptions{}) + }) if err != nil { return fleet.Evidence{}, fmt.Errorf("read %s: %w", ref.String(), err) } @@ -106,10 +114,6 @@ func (adapter *Adapter) Query(ctx context.Context, query fleet.Query) (fleet.Que if query.Selector.CVE != "" || query.Selector.Health != "" { return fleet.QueryResult{}, fmt.Errorf("%w: health and CVE predicates arrive in later slices", ErrUnsupportedSelector) } - if err := adapter.ensureDiscovered(ctx); err != nil { - return fleet.QueryResult{}, err - } - var spec resourceSpec if query.Selector.ResourceKind != "" { var ok bool @@ -121,13 +125,26 @@ func (adapter *Adapter) Query(ctx context.Context, query fleet.Query) (fleet.Que return fleet.QueryResult{}, fmt.Errorf("%w: namespace cannot select cluster-scoped %s", ErrUnsupportedSelector, spec.kind) } } + labelSelector, err := labels.ValidatedSelectorFromSet(query.Selector.Labels) + if err != nil { + return fleet.QueryResult{}, fmt.Errorf("%w: invalid Kubernetes label selector: %v", ErrUnsupportedSelector, err) + } + if err := adapter.ensureDiscovered(ctx); err != nil { + return fleet.QueryResult{}, err + } scopes, clients, lastSeen := adapter.stateSnapshot() targets := targetScopeNames(query.Scopes, scopes) results := make([]scopeQueryResult, len(targets)) adapter.runBounded(len(targets), func(index int) { name := targets[index] - results[index] = adapter.queryScope(ctx, name, clients[name], spec, query) + result, err := callWithTimeout(ctx, adapter.settings.requestTimeout, func(requestCtx context.Context) (scopeQueryResult, error) { + return adapter.queryScope(requestCtx, name, clients[name], spec, labelSelector.String(), query), nil + }) + if err != nil { + result = scopeQueryResult{name: name, err: err} + } + results[index] = result }) if err := ctx.Err(); err != nil { return fleet.QueryResult{}, fmt.Errorf("query kubeconfig contexts: %w", err) @@ -179,6 +196,7 @@ func (adapter *Adapter) queryScope( name string, client dynamic.Interface, spec resourceSpec, + labelSelector string, query fleet.Query, ) scopeQueryResult { result := scopeQueryResult{name: name} @@ -191,7 +209,7 @@ func (adapter *Adapter) queryScope( } resource := resourceInterface(client, spec, query.Selector.Namespace) - list, err := resource.List(ctx, metav1.ListOptions{LabelSelector: labels.SelectorFromSet(query.Selector.Labels).String()}) + list, err := resource.List(ctx, metav1.ListOptions{LabelSelector: labelSelector}) if err != nil { if spec.kind == "Rollout" && apierrors.IsNotFound(err) { return result diff --git a/sessions/2026-07-10-slice-1-source-adapter.md b/sessions/2026-07-10-slice-1-source-adapter.md index c74383a..5d75ba0 100644 --- a/sessions/2026-07-10-slice-1-source-adapter.md +++ b/sessions/2026-07-10-slice-1-source-adapter.md @@ -1,7 +1,7 @@ # Session — 2026-07-10 — slice-1-source-adapter **Builder:** Gnani Rahul · **Model/effort:** engineering, max · **Branch:** feat/fleet-source-adapter -**Slice(s):** Slice 1 / #38 + #32 · **Status:** in-progress +**Slice(s):** Slice 1 / #38 + #32 · **Status:** done --- @@ -37,16 +37,29 @@ last-seen timestamps when a previously reachable context becomes unavailable. typed label/name/image selectors, source-stamped evidence, unknown/unreachable reads, and an actual ExecCredential v1 subprocess authenticated request to a TLS test API. Focused race tests, lint, and 81.5% statement coverage pass. -[C] Checkpoint #3: 3463c1b — local-kubeconfig discovery/read/query adapter; next: bridge the +[C] Checkpoint #3: bad1a1f — local-kubeconfig discovery/read/query adapter; next: bridge the adapter into `sith clusters` and validate the real CLI path. [A] Action: Replaced the Slice-0 stub at the single CLI injection point with `connector.AsSource(kubeconfig.Default())`; default construction follows client-go's standard `KUBECONFIG` path-list and `~/.kube/config` resolution without doing startup network I/O. [A] Action: Updated the public README from the Slice-0 stub behavior to the real local-fleet discovery and credential-locality contract. -[C] Checkpoint #4: this commit — production CLI bridge; next: prove two reachable kind clusters +[C] Checkpoint #4: 87053ca — production CLI bridge; next: prove two reachable kind clusters plus one unreachable context through the built binary. +[A] Action: Added a hermetic real-cluster gate that creates two uniquely named kind clusters from +the digest-pinned Kubernetes v1.36.1 node image, merges their kubeconfigs with one deliberately +dead context, and cleans up only the clusters it created. +[T] Test: The gate asserts adapter discovery, a real namespace query returning source-stamped +facts from both API servers, honest 2/3 partial coverage, and the built `sith clusters --output +json` process over the same merged kubeconfig. CI installs pinned kind v0.32.0 before running it. +[R] Review: Red-team analysis added hard wall-clock isolation around client-go operations because +its exec authenticator does not itself bind helper-process lifetime to request context, rejected +invalid references/selectors before credential work, and made partial kind cleanup observable. +[R] Review: govulncheck v1.6.0 found two reachable `x/net` call-path vulnerabilities inherited +through client-go. Raised `x/net` to the fixed v0.55.0 floor and added a pinned CI/local scan; +the follow-up scan reports no reachable vulnerabilities. +[C] Checkpoint #5: this commit — reviewed real two-cluster fan-out gate; next: publish and merge. --- -**Session close:** in progress · **Open questions touched:** none +**Session close:** implementation and review complete; publication pending · **Open questions touched:** none diff --git a/tests/e2e/kind_fanout_test.go b/tests/e2e/kind_fanout_test.go new file mode 100644 index 0000000..54ed6ef --- /dev/null +++ b/tests/e2e/kind_fanout_test.go @@ -0,0 +1,191 @@ +// SPDX-License-Identifier: Apache-2.0 +//go:build e2e && kind + +package e2e_test + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "testing" + "time" + + "k8s.io/client-go/tools/clientcmd" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" + + "github.com/ArdurAI/sith/internal/connector/kubeconfig" + "github.com/ArdurAI/sith/internal/fleet" +) + +const defaultKindNodeImage = "kindest/node:v1.36.1@sha256:3489c7674813ba5d8b1a9977baea8a6e553784dab7b84759d1014dbd78f7ebd5" + +func TestKindFleetFanout(t *testing.T) { + kindBinary := environmentOr("KIND_BIN", "kind") + if _, err := exec.LookPath(kindBinary); err != nil { + t.Fatalf("find kind binary %q: %v", kindBinary, err) + } + if _, err := exec.LookPath("docker"); err != nil { + t.Fatalf("find docker: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 12*time.Minute) + defer cancel() + version := runCommand(ctx, t, "", kindBinary, "version") + if !strings.Contains(version, "v0.32.0") { + t.Fatalf("kind version = %q, want v0.32.0", version) + } + + suffix := fmt.Sprintf("%d", time.Now().UnixNano()) + clusterNames := []string{"sith-e2e-a-" + suffix, "sith-e2e-b-" + suffix} + image := environmentOr("KIND_NODE_IMAGE", defaultKindNodeImage) + created := make([]string, 0, len(clusterNames)) + t.Cleanup(func() { + for _, name := range created { + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 2*time.Minute) + command := exec.CommandContext(cleanupCtx, kindBinary, "delete", "cluster", "--name", name) + output, err := command.CombinedOutput() + cleanupCancel() + if err != nil { + t.Errorf("delete kind cluster %s: %v\n%s", name, err, output) + } + } + }) + + for _, name := range clusterNames { + created = append(created, name) + runCommand(ctx, t, "", kindBinary, "create", "cluster", "--name", name, "--image", image, "--wait", "180s") + } + + kubeconfigPath := mergedKindKubeconfig(ctx, t, kindBinary, clusterNames) + adapter, err := kubeconfig.New( + kubeconfig.WithExplicitPath(kubeconfigPath), + kubeconfig.WithProbeTimeout(5*time.Second), + kubeconfig.WithRequestTimeout(15*time.Second), + ) + if err != nil { + t.Fatalf("construct kubeconfig adapter: %v", err) + } + + discovery, err := adapter.Discover(ctx) + if err != nil { + t.Fatalf("discover real kind contexts: %v", err) + } + deadContext := "kind-sith-e2e-unreachable" + if len(discovery.Scopes) != 3 || !slices.Equal(discovery.Unreachable, []string{deadContext}) { + t.Fatalf("discovery = %#v, want two reachable kind contexts and %q unreachable", discovery, deadContext) + } + + result, err := adapter.Query(ctx, fleet.Query{ + Kinds: []fleet.FactKind{fleet.FactInventory}, + Selector: fleet.Selector{ + ResourceKind: "Namespace", + NamePrefix: "kube-", + }, + }) + if err != nil { + t.Fatalf("query namespaces across kind contexts: %v", err) + } + if result.Coverage.Requested != 3 || result.Coverage.Reachable != 2 || + !slices.Equal(result.Coverage.Unreachable, []string{deadContext}) { + t.Fatalf("query coverage = %#v, want two of three reachable", result.Coverage) + } + liveScopes := map[string]bool{ + "kind-" + clusterNames[0]: false, + "kind-" + clusterNames[1]: false, + } + for _, fact := range result.Facts { + if fact.Ref.Kind == "Namespace" && strings.HasPrefix(fact.Ref.Name, "kube-") { + liveScopes[fact.Ref.Scope] = true + } + } + for scope, seen := range liveScopes { + if !seen { + t.Errorf("query did not return a source-stamped namespace from %s", scope) + } + } + + root := repositoryRoot(t) + binary := filepath.Join(t.TempDir(), "sith") + runCommand(ctx, t, root, "go", "build", "-trimpath", "-o", binary, "./cmd/sith") + command := exec.CommandContext(ctx, binary, "clusters", "--output", "json") + command.Env = append(os.Environ(), "KUBECONFIG="+kubeconfigPath, "XDG_CONFIG_HOME="+t.TempDir()) + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("run sith clusters against kind: %v\n%s", err, output) + } + var fleetResult fleet.FleetResult + if err := json.Unmarshal(output, &fleetResult); err != nil { + t.Fatalf("decode sith clusters output %q: %v", output, err) + } + if len(fleetResult.Clusters) != 3 || fleetResult.Coverage.Requested != 3 || + fleetResult.Coverage.Reachable != 2 || !slices.Equal(fleetResult.Coverage.Unreachable, []string{deadContext}) { + t.Fatalf("sith clusters = %#v, want two live and one unreachable context", fleetResult) + } +} + +func mergedKindKubeconfig(ctx context.Context, t *testing.T, kindBinary string, clusters []string) string { + t.Helper() + merged := clientcmdapi.NewConfig() + for _, cluster := range clusters { + data := runCommandBytes(ctx, t, "", kindBinary, "get", "kubeconfig", "--name", cluster) + config, err := clientcmd.Load(data) + if err != nil { + t.Fatalf("decode kind kubeconfig for %s: %v", cluster, err) + } + mergeConfigMaps(merged, config) + if merged.CurrentContext == "" { + merged.CurrentContext = config.CurrentContext + } + } + + const deadContext = "kind-sith-e2e-unreachable" + merged.Clusters[deadContext] = &clientcmdapi.Cluster{Server: "https://127.0.0.1:1"} + merged.AuthInfos[deadContext] = &clientcmdapi.AuthInfo{} + merged.Contexts[deadContext] = &clientcmdapi.Context{Cluster: deadContext, AuthInfo: deadContext} + + path := filepath.Join(t.TempDir(), "kubeconfig") + if err := clientcmd.WriteToFile(*merged, path); err != nil { + t.Fatalf("write merged kind kubeconfig: %v", err) + } + return path +} + +func mergeConfigMaps(destination, source *clientcmdapi.Config) { + for name, cluster := range source.Clusters { + destination.Clusters[name] = cluster + } + for name, authInfo := range source.AuthInfos { + destination.AuthInfos[name] = authInfo + } + for name, contextConfig := range source.Contexts { + destination.Contexts[name] = contextConfig + } +} + +func environmentOr(name, fallback string) string { + if value := os.Getenv(name); value != "" { + return value + } + return fallback +} + +func runCommand(ctx context.Context, t *testing.T, directory, name string, args ...string) string { + t.Helper() + return string(runCommandBytes(ctx, t, directory, name, args...)) +} + +func runCommandBytes(ctx context.Context, t *testing.T, directory, name string, args ...string) []byte { + t.Helper() + command := exec.CommandContext(ctx, name, args...) + command.Dir = directory + output, err := command.CombinedOutput() + if err != nil { + t.Fatalf("run %s %s: %v\n%s", name, strings.Join(args, " "), err, output) + } + return output +} From 09d5470c2c78a7db064ebcb86a9e666497fe8ddc Mon Sep 17 00:00:00 2001 From: Gnani Rahul Date: Fri, 10 Jul 2026 15:57:28 -0500 Subject: [PATCH 06/14] feat(cache): add the local fleet store Normalize Tier-1 facts once, preserve last-known rows across failures, and serve immutable coverage-honest cache queries without network access. GSTACK-Checkpoint: 2026-07-10/slice-2-cache-first-fleet#1 Signed-off-by: Gnani Rahul --- internal/fleetcache/query.go | 179 ++++++++ internal/fleetcache/record.go | 264 ++++++++++++ internal/fleetcache/store.go | 391 ++++++++++++++++++ internal/fleetcache/store_test.go | 356 ++++++++++++++++ .../2026-07-10-slice-2-cache-first-fleet.md | 31 ++ 5 files changed, 1221 insertions(+) create mode 100644 internal/fleetcache/query.go create mode 100644 internal/fleetcache/record.go create mode 100644 internal/fleetcache/store.go create mode 100644 internal/fleetcache/store_test.go create mode 100644 sessions/2026-07-10-slice-2-cache-first-fleet.md diff --git a/internal/fleetcache/query.go b/internal/fleetcache/query.go new file mode 100644 index 0000000..d635515 --- /dev/null +++ b/internal/fleetcache/query.go @@ -0,0 +1,179 @@ +// SPDX-License-Identifier: Apache-2.0 + +package fleetcache + +import ( + "fmt" + "path" + "strconv" + "strings" +) + +// Query is a cache-only filter over normalized records. +type Query struct { + Kind string + Name string + Namespace string + Scopes []string + Text []string + Status string + StatusNot string + Image string + Node string + Labels map[string]string + MinRestarts *int64 + Limit int +} + +// ParseSearch parses the composable cache-served search grammar. +func ParseSearch(expression string) (Query, error) { + query := Query{Labels: map[string]string{}} + for _, token := range strings.Fields(expression) { + key, value, structured := strings.Cut(token, ":") + if !structured { + query.Text = append(query.Text, strings.ToLower(token)) + continue + } + if value == "" { + return Query{}, fmt.Errorf("search token %q has an empty value", token) + } + switch strings.ToLower(key) { + case "ctx", "cluster": + query.Scopes = append(query.Scopes, value) + case "ns", "namespace": + query.Namespace = value + case "kind": + query.Kind = canonicalKind(value) + case "status": + if strings.HasPrefix(value, "!") { + query.StatusNot = strings.TrimPrefix(value, "!") + } else { + query.Status = value + } + case "image": + query.Image = value + case "node": + query.Node = value + case "label": + label, labelValue, ok := strings.Cut(value, "=") + if !ok || label == "" || labelValue == "" { + return Query{}, fmt.Errorf("label token %q must be label:key=value", token) + } + query.Labels[label] = labelValue + case "restarts": + minimum := strings.TrimPrefix(value, ">") + parsed, err := strconv.ParseInt(minimum, 10, 64) + if err != nil || parsed < 0 { + return Query{}, fmt.Errorf("restarts token %q must be a non-negative integer comparison", token) + } + query.MinRestarts = &parsed + default: + return Query{}, fmt.Errorf("unsupported search token %q", key) + } + } + return query, nil +} + +func (query Query) matches(record Record) bool { + if query.Kind != "" && canonicalKind(record.Kind) != canonicalKind(query.Kind) { + return false + } + if query.Name != "" && record.Name != query.Name { + return false + } + if query.Namespace != "" && record.Namespace != query.Namespace { + return false + } + if len(query.Scopes) > 0 && !matchesAnyGlob(record.Cluster, query.Scopes) { + return false + } + if query.Status != "" && !equalFold(record.Status, query.Status) { + return false + } + if query.StatusNot != "" && equalFold(record.Status, query.StatusNot) { + return false + } + if query.Image != "" && !matchesImages(record.Images, query.Image) { + return false + } + if query.Node != "" && !matchesGlob(record.Node, query.Node) { + return false + } + for key, value := range query.Labels { + if record.Labels[key] != value { + return false + } + } + if query.MinRestarts != nil && record.Restarts <= *query.MinRestarts { + return false + } + haystack := strings.ToLower(strings.Join([]string{ + record.Cluster, + record.Namespace, + record.Kind, + record.Name, + record.Status, + record.Reason, + strings.Join(record.Images, " "), + labelsText(record.Labels), + }, " ")) + for _, text := range query.Text { + if !fuzzyContains(haystack, text) { + return false + } + } + return true +} + +func matchesImages(images []string, pattern string) bool { + for _, image := range images { + if matchesGlob(image, pattern) || strings.Contains(strings.ToLower(image), strings.ToLower(strings.Trim(pattern, "*"))) { + return true + } + } + return false +} + +func matchesAnyGlob(value string, patterns []string) bool { + for _, pattern := range patterns { + if matchesGlob(value, pattern) { + return true + } + } + return false +} + +func matchesGlob(value, pattern string) bool { + matched, err := path.Match(strings.ToLower(pattern), strings.ToLower(value)) + if err == nil && matched { + return true + } + return equalFold(value, pattern) +} + +func equalFold(left, right string) bool { + return strings.EqualFold(strings.TrimSpace(left), strings.TrimSpace(right)) +} + +func fuzzyContains(haystack, needle string) bool { + needle = strings.ToLower(strings.TrimSpace(needle)) + if needle == "" || strings.Contains(haystack, needle) { + return true + } + needleRunes := []rune(needle) + index := 0 + for _, character := range haystack { + if index < len(needleRunes) && character == needleRunes[index] { + index++ + } + } + return index == len(needleRunes) +} + +func labelsText(labels map[string]string) string { + parts := make([]string, 0, len(labels)) + for key, value := range labels { + parts = append(parts, key+"="+value) + } + return strings.Join(parts, " ") +} diff --git a/internal/fleetcache/record.go b/internal/fleetcache/record.go new file mode 100644 index 0000000..5b5bdd8 --- /dev/null +++ b/internal/fleetcache/record.go @@ -0,0 +1,264 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package fleetcache provides the interaction-safe local fleet store. +package fleetcache + +import ( + "encoding/json" + "fmt" + "sort" + "strconv" + "strings" + "time" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + "github.com/ArdurAI/sith/internal/fleet" +) + +// Record is a render-ready projection of one cached fleet fact. +type Record struct { + Fact fleet.Fact `json:"fact"` + Kind string `json:"kind"` + Cluster string `json:"cluster"` + Namespace string `json:"namespace,omitempty"` + Name string `json:"name"` + Ready string `json:"ready,omitempty"` + Status string `json:"status,omitempty"` + Reason string `json:"reason,omitempty"` + Message string `json:"message,omitempty"` + Node string `json:"node,omitempty"` + Version string `json:"version,omitempty"` + Restarts int64 `json:"restarts,omitempty"` + Images []string `json:"images,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + CreatedAt time.Time `json:"created_at,omitempty"` + ObservedAt time.Time `json:"observed_at"` + Stale bool `json:"stale"` + StaleFor time.Duration `json:"stale_for,omitempty"` +} + +func normalize(fact fleet.Fact) (Record, error) { + object := &unstructured.Unstructured{} + if err := json.Unmarshal(fact.Observed, &object.Object); err != nil { + return Record{}, fmt.Errorf("decode %s: %w", fact.Ref.String(), err) + } + record := Record{ + Fact: cloneFact(fact), + Kind: canonicalKind(fact.Ref.Kind), + Cluster: fact.Ref.Scope, + Namespace: fact.Ref.Namespace, + Name: fact.Ref.Name, + Labels: object.GetLabels(), + CreatedAt: object.GetCreationTimestamp().Time, + ObservedAt: fact.ObservedAt, + Images: objectImages(*object), + Stale: fact.Stale, + } + if record.Labels == nil { + record.Labels = map[string]string{} + } + + switch record.Kind { + case "Pod": + normalizePod(&record, *object) + case "Deployment": + normalizeDeployment(&record, *object) + case "Event": + normalizeEvent(&record, *object) + case "Node": + normalizeNode(&record, *object) + default: + record.Status, _, _ = unstructured.NestedString(object.Object, "status", "phase") + } + return record, nil +} + +func normalizePod(record *Record, object unstructured.Unstructured) { + record.Status, _, _ = unstructured.NestedString(object.Object, "status", "phase") + record.Node, _, _ = unstructured.NestedString(object.Object, "spec", "nodeName") + statuses, _, _ := unstructured.NestedSlice(object.Object, "status", "containerStatuses") + ready := 0 + for _, value := range statuses { + status, ok := value.(map[string]any) + if !ok { + continue + } + if isTrue(status["ready"]) { + ready++ + } + record.Restarts += number(status["restartCount"]) + if reason := nestedString(status, "state", "waiting", "reason"); reason != "" { + record.Status = reason + } + if reason := nestedString(status, "state", "terminated", "reason"); reason != "" { + record.Status = reason + } + } + desired, _, _ := unstructured.NestedSlice(object.Object, "spec", "containers") + record.Ready = fmt.Sprintf("%d/%d", ready, len(desired)) +} + +func normalizeDeployment(record *Record, object unstructured.Unstructured) { + desired := nestedNumber(object.Object, "spec", "replicas") + if desired == 0 { + if _, found, _ := unstructured.NestedFieldNoCopy(object.Object, "spec", "replicas"); !found { + desired = 1 + } + } + available := nestedNumber(object.Object, "status", "availableReplicas") + updated := nestedNumber(object.Object, "status", "updatedReplicas") + record.Ready = fmt.Sprintf("%d/%d", available, desired) + switch { + case available >= desired && updated >= desired: + record.Status = "Healthy" + case available > 0 || updated > 0: + record.Status = "Progressing" + default: + record.Status = "Degraded" + } +} + +func normalizeEvent(record *Record, object unstructured.Unstructured) { + eventType, _, _ := unstructured.NestedString(object.Object, "type") + record.Reason, _, _ = unstructured.NestedString(object.Object, "reason") + record.Message, _, _ = unstructured.NestedString(object.Object, "message") + involvedKind, _, _ := unstructured.NestedString(object.Object, "involvedObject", "kind") + involvedName, _, _ := unstructured.NestedString(object.Object, "involvedObject", "name") + record.Status = eventType + if involvedKind != "" || involvedName != "" { + record.Ready = strings.Trim(involvedKind+"/"+involvedName, "/") + } +} + +func normalizeNode(record *Record, object unstructured.Unstructured) { + record.Status = "Unknown" + conditions, _, _ := unstructured.NestedSlice(object.Object, "status", "conditions") + for _, value := range conditions { + condition, ok := value.(map[string]any) + if !ok || condition["type"] != "Ready" { + continue + } + if condition["status"] == "True" { + record.Status = "Ready" + } else { + record.Status = "NotReady" + } + record.Reason, _ = condition["reason"].(string) + break + } + record.Version, _, _ = unstructured.NestedString(object.Object, "status", "nodeInfo", "kubeletVersion") +} + +func objectImages(object unstructured.Unstructured) []string { + paths := [][]string{ + {"spec", "containers"}, + {"spec", "initContainers"}, + {"spec", "template", "spec", "containers"}, + {"spec", "template", "spec", "initContainers"}, + } + set := make(map[string]struct{}) + for _, path := range paths { + containers, found, err := unstructured.NestedSlice(object.Object, path...) + if err != nil || !found { + continue + } + for _, raw := range containers { + container, ok := raw.(map[string]any) + if !ok { + continue + } + if image, ok := container["image"].(string); ok && image != "" { + set[image] = struct{}{} + } + } + } + result := make([]string, 0, len(set)) + for image := range set { + result = append(result, image) + } + sort.Strings(result) + return result +} + +func nestedNumber(object map[string]any, fields ...string) int64 { + value, found, _ := unstructured.NestedFieldNoCopy(object, fields...) + if !found { + return 0 + } + return number(value) +} + +func number(value any) int64 { + switch typed := value.(type) { + case int: + return int64(typed) + case int32: + return int64(typed) + case int64: + return typed + case float64: + return int64(typed) + case json.Number: + parsed, _ := typed.Int64() + return parsed + case string: + parsed, _ := strconv.ParseInt(typed, 10, 64) + return parsed + default: + return 0 + } +} + +func nestedString(object map[string]any, fields ...string) string { + value := any(object) + for _, field := range fields { + mapping, ok := value.(map[string]any) + if !ok { + return "" + } + value = mapping[field] + } + result, _ := value.(string) + return result +} + +func isTrue(value any) bool { + result, _ := value.(bool) + return result +} + +func canonicalKind(kind string) string { + trimmed := strings.TrimSpace(kind) + switch strings.ToLower(trimmed) { + case "pod", "pods", "po": + return "Pod" + case "deployment", "deployments", "deploy": + return "Deployment" + case "event", "events", "ev": + return "Event" + case "node", "nodes", "no": + return "Node" + default: + if trimmed == "" { + return "" + } + return strings.ToUpper(trimmed[:1]) + trimmed[1:] + } +} + +func cloneFact(fact fleet.Fact) fleet.Fact { + fact.Observed = append(json.RawMessage(nil), fact.Observed...) + if fact.Ref.Attributes != nil { + fact.Ref.Attributes = cloneMap(fact.Ref.Attributes) + } + return fact +} + +func cloneMap(values map[string]string) map[string]string { + result := make(map[string]string, len(values)) + for key, value := range values { + result[key] = value + } + return result +} diff --git a/internal/fleetcache/store.go b/internal/fleetcache/store.go new file mode 100644 index 0000000..5dfe99d --- /dev/null +++ b/internal/fleetcache/store.go @@ -0,0 +1,391 @@ +// SPDX-License-Identifier: Apache-2.0 + +package fleetcache + +import ( + "context" + "fmt" + "sort" + "strings" + "sync" + "time" + + "github.com/ArdurAI/sith/internal/connector" + "github.com/ArdurAI/sith/internal/fleet" +) + +const defaultFreshFor = 15 * time.Second + +// State describes the user-visible lifecycle of the local store. +type State string + +// Store lifecycle states. +const ( + StateCold State = "cold" + StateWarming State = "warming" + StateWarm State = "warm" + StateDegraded State = "degraded" + StateOffline State = "offline" + StatePaused State = "paused" +) + +// Snapshot is an immutable cache-only answer for one render interaction. +type Snapshot struct { + Version uint64 `json:"version"` + State State `json:"state"` + Syncing bool `json:"syncing"` + Paused bool `json:"paused"` + Records []Record `json:"records"` + Coverage fleet.Coverage `json:"coverage"` + UpdatedAt time.Time `json:"updated_at,omitempty"` + LastError string `json:"last_error,omitempty"` +} + +// Store owns normalized last-known fleet state and never performs network I/O. +type Store struct { + mu sync.RWMutex + + records map[string]map[string]Record + coverage map[string]fleet.Coverage + scopes map[string]connector.Scope + warmed map[string]bool + syncing bool + paused bool + lastError string + updatedAt time.Time + version uint64 + changed chan struct{} + now func() time.Time + freshFor time.Duration +} + +// New creates an empty cold store. +func New() *Store { + return newStore(time.Now, defaultFreshFor) +} + +func newStore(now func() time.Time, freshFor time.Duration) *Store { + return &Store{ + records: make(map[string]map[string]Record), + coverage: make(map[string]fleet.Coverage), + scopes: make(map[string]connector.Scope), + warmed: make(map[string]bool), + changed: make(chan struct{}), + now: now, + freshFor: freshFor, + } +} + +// BeginSync marks background reconciliation as active without blocking readers. +func (store *Store) BeginSync() bool { + store.mu.Lock() + defer store.mu.Unlock() + if store.paused || store.syncing { + return false + } + store.syncing = true + store.lastError = "" + store.notifyLocked() + return true +} + +// SetDiscovery refreshes the known context set while preserving last-known facts. +func (store *Store) SetDiscovery(discovery connector.Discovery) { + store.mu.Lock() + defer store.mu.Unlock() + if store.paused { + return + } + known := make(map[string]connector.Scope, len(discovery.Scopes)+len(discovery.Unreachable)) + for _, scope := range discovery.Scopes { + known[scope.Name] = cloneScope(scope) + } + for _, name := range discovery.Unreachable { + if _, exists := known[name]; !exists { + known[name] = connector.Scope{Name: name} + } + } + store.scopes = known + store.notifyLocked() +} + +// Replace reconciles one resource kind while preserving last-known rows for failed scopes. +func (store *Store) Replace(kind string, result fleet.QueryResult) error { + canonical := canonicalKind(kind) + if canonical == "" { + return fmt.Errorf("replace cache records: resource kind is required") + } + normalized := make([]Record, 0, len(result.Facts)) + for _, fact := range result.Facts { + record, err := normalize(fact) + if err != nil { + return err + } + normalized = append(normalized, record) + } + + store.mu.Lock() + defer store.mu.Unlock() + if store.paused { + return nil + } + if store.records[canonical] == nil { + store.records[canonical] = make(map[string]Record) + } + unreachable := stringSet(result.Coverage.Unreachable) + for key, record := range store.records[canonical] { + if _, failed := unreachable[record.Cluster]; !failed { + delete(store.records[canonical], key) + } + } + for _, record := range normalized { + store.records[canonical][recordKey(record)] = record + } + store.coverage[canonical] = cloneCoverage(result.Coverage) + store.warmed[canonical] = true + store.updatedAt = store.now().UTC() + store.notifyLocked() + return nil +} + +// EndSync marks reconciliation complete and retains any prior data on failure. +func (store *Store) EndSync(err error) { + store.mu.Lock() + defer store.mu.Unlock() + store.syncing = false + if err != nil { + store.lastError = err.Error() + } else { + store.lastError = "" + } + store.notifyLocked() +} + +// SetPaused freezes or resumes background mutations while keeping snapshots available. +func (store *Store) SetPaused(paused bool) { + store.mu.Lock() + defer store.mu.Unlock() + if store.paused == paused { + return + } + store.paused = paused + store.notifyLocked() +} + +// Paused reports whether background reconciliation is frozen. +func (store *Store) Paused() bool { + store.mu.RLock() + defer store.mu.RUnlock() + return store.paused +} + +// Query returns a deterministic immutable answer without connector or network access. +func (store *Store) Query(query Query) Snapshot { + store.mu.RLock() + defer store.mu.RUnlock() + now := store.now().UTC() + records := make([]Record, 0) + for kind, byKey := range store.records { + if query.Kind != "" && canonicalKind(query.Kind) != kind { + continue + } + for _, cached := range byKey { + record := cloneRecord(cached) + age := now.Sub(record.ObservedAt) + if age > store.freshFor { + record.Stale = true + record.StaleFor = age + record.Fact.Stale = true + record.Fact.StaleFor = age.Round(time.Second).String() + } + if query.matches(record) { + records = append(records, record) + } + } + } + sort.Slice(records, func(left, right int) bool { + return recordKey(records[left]) < recordKey(records[right]) + }) + if query.Limit > 0 && len(records) > query.Limit { + records = records[:query.Limit] + } + coverage := store.coverageLocked(query, records, now) + pending := canonicalKind(query.Kind) != "" && !store.warmed[canonicalKind(query.Kind)] + return Snapshot{ + Version: store.version, + State: store.stateLocked(coverage, store.recordCountLocked(), pending), + Syncing: store.syncing, + Paused: store.paused, + Records: records, + Coverage: coverage, + UpdatedAt: store.updatedAt, + LastError: store.lastError, + } +} + +// WaitForChange blocks until the store advances beyond a known version or the context ends. +func (store *Store) WaitForChange(ctx context.Context, after uint64) (uint64, error) { + for { + store.mu.RLock() + if store.version > after { + version := store.version + store.mu.RUnlock() + return version, nil + } + changed := store.changed + store.mu.RUnlock() + select { + case <-ctx.Done(): + return 0, ctx.Err() + case <-changed: + } + } +} + +func (store *Store) coverageLocked(query Query, records []Record, now time.Time) fleet.Coverage { + targets := store.targetScopesLocked(query.Scopes) + unreachable := make(map[string]struct{}) + stale := make(map[string]struct{}) + kind := canonicalKind(query.Kind) + if kind != "" { + if !store.warmed[kind] { + return fleet.Coverage{Requested: len(targets)} + } + for _, name := range store.coverage[kind].Unreachable { + unreachable[name] = struct{}{} + } + for _, name := range store.coverage[kind].Stale { + stale[name] = struct{}{} + } + } else { + for _, coverage := range store.coverage { + for _, name := range coverage.Unreachable { + unreachable[name] = struct{}{} + } + for _, name := range coverage.Stale { + stale[name] = struct{}{} + } + } + } + for _, name := range targets { + scope, exists := store.scopes[name] + if !exists || !scope.Reachable { + unreachable[name] = struct{}{} + } + if !scope.ObservedAt.IsZero() && now.Sub(scope.ObservedAt) > store.freshFor { + stale[name] = struct{}{} + } + } + for _, record := range records { + if record.Stale { + stale[record.Cluster] = struct{}{} + } + } + coverage := fleet.Coverage{Requested: len(targets)} + for _, name := range targets { + if _, failed := unreachable[name]; failed { + coverage.Unreachable = append(coverage.Unreachable, name) + continue + } + coverage.Reachable++ + if _, aged := stale[name]; aged { + coverage.Stale = append(coverage.Stale, name) + } + } + return coverage +} + +func (store *Store) targetScopesLocked(patterns []string) []string { + set := make(map[string]struct{}) + if len(patterns) == 0 { + for name := range store.scopes { + set[name] = struct{}{} + } + } else { + for _, pattern := range patterns { + matched := false + for name := range store.scopes { + if matchesGlob(name, pattern) { + set[name] = struct{}{} + matched = true + } + } + if !matched && !strings.ContainsAny(pattern, "*?[") { + set[pattern] = struct{}{} + } + } + } + result := make([]string, 0, len(set)) + for name := range set { + result = append(result, name) + } + sort.Strings(result) + return result +} + +func (store *Store) stateLocked(coverage fleet.Coverage, recordCount int, pending bool) State { + switch { + case store.paused: + return StatePaused + case pending && store.syncing: + return StateWarming + case pending && store.lastError == "": + return StateCold + case recordCount == 0 && store.syncing: + return StateWarming + case recordCount == 0 && len(store.warmed) == 0: + return StateCold + case coverage.Reachable == 0 && (recordCount > 0 || store.lastError != ""): + return StateOffline + case len(coverage.Unreachable) > 0 || len(coverage.Stale) > 0 || store.lastError != "": + return StateDegraded + case store.syncing: + return StateWarming + default: + return StateWarm + } +} + +func (store *Store) recordCountLocked() int { + count := 0 + for _, records := range store.records { + count += len(records) + } + return count +} + +func (store *Store) notifyLocked() { + store.version++ + close(store.changed) + store.changed = make(chan struct{}) +} + +func recordKey(record Record) string { + return strings.Join([]string{record.Kind, record.Cluster, record.Namespace, record.Name}, "\x00") +} + +func stringSet(values []string) map[string]struct{} { + result := make(map[string]struct{}, len(values)) + for _, value := range values { + result[value] = struct{}{} + } + return result +} + +func cloneRecord(record Record) Record { + record.Fact = cloneFact(record.Fact) + record.Images = append([]string(nil), record.Images...) + record.Labels = cloneMap(record.Labels) + return record +} + +func cloneCoverage(coverage fleet.Coverage) fleet.Coverage { + coverage.Unreachable = append([]string(nil), coverage.Unreachable...) + coverage.Stale = append([]string(nil), coverage.Stale...) + return coverage +} + +func cloneScope(scope connector.Scope) connector.Scope { + scope.Kinds = append([]string(nil), scope.Kinds...) + return scope +} diff --git a/internal/fleetcache/store_test.go b/internal/fleetcache/store_test.go new file mode 100644 index 0000000..645534f --- /dev/null +++ b/internal/fleetcache/store_test.go @@ -0,0 +1,356 @@ +// SPDX-License-Identifier: Apache-2.0 + +package fleetcache + +import ( + "context" + "encoding/json" + "slices" + "sync" + "testing" + "time" + + "github.com/ArdurAI/sith/internal/connector" + "github.com/ArdurAI/sith/internal/fleet" +) + +func TestStorePreservesLastKnownRowsAndSurfacesCoverage(t *testing.T) { + t.Parallel() + now := time.Date(2026, time.July, 10, 20, 0, 0, 0, time.UTC) + store := newStore(func() time.Time { return now }, 15*time.Second) + if !store.BeginSync() { + t.Fatal("BeginSync() = false, want initial sync") + } + store.SetDiscovery(connector.Discovery{Scopes: []connector.Scope{ + {Name: "alpha", Reachable: true, ObservedAt: now}, + {Name: "beta", Reachable: true, ObservedAt: now}, + }}) + initial := fleet.QueryResult{ + Facts: []fleet.Fact{ + podFact(t, "alpha", "api-0", "Running", "registry/api:v1", now), + podFact(t, "beta", "api-0", "Running", "registry/api:v1", now), + }, + Coverage: fleet.Coverage{Requested: 2, Reachable: 2}, + } + if err := store.Replace("pods", initial); err != nil { + t.Fatalf("Replace(initial) error = %v", err) + } + store.EndSync(nil) + + snapshot := store.Query(Query{Kind: "Pod"}) + if snapshot.State != StateWarm || len(snapshot.Records) != 2 || !snapshot.Coverage.Complete() { + t.Fatalf("initial snapshot = %#v, want two complete warm records", snapshot) + } + + now = now.Add(20 * time.Second) + if !store.BeginSync() { + t.Fatal("BeginSync(second) = false") + } + store.SetDiscovery(connector.Discovery{ + Scopes: []connector.Scope{ + {Name: "alpha", Reachable: true, ObservedAt: now}, + {Name: "beta", ObservedAt: now.Add(-20 * time.Second)}, + }, + Unreachable: []string{"beta"}, + }) + partial := fleet.QueryResult{ + Facts: []fleet.Fact{podFact(t, "alpha", "api-1", "Running", "registry/api:v2", now)}, + Coverage: fleet.Coverage{Requested: 2, Reachable: 1, Unreachable: []string{"beta"}}, + } + if err := store.Replace("Pod", partial); err != nil { + t.Fatalf("Replace(partial) error = %v", err) + } + store.EndSync(nil) + + snapshot = store.Query(Query{Kind: "pods"}) + if snapshot.State != StateDegraded || len(snapshot.Records) != 2 { + t.Fatalf("partial snapshot = %#v, want degraded with last-known beta", snapshot) + } + if !slices.Equal(snapshot.Coverage.Unreachable, []string{"beta"}) { + t.Fatalf("unreachable = %v, want [beta]", snapshot.Coverage.Unreachable) + } + if snapshot.Records[0].Name != "api-1" || snapshot.Records[1].Cluster != "beta" || !snapshot.Records[1].Stale { + t.Fatalf("records = %#v, want fresh alpha replacement and stale beta last-known", snapshot.Records) + } +} + +func TestStoreSearchGrammarRunsOnlyOnNormalizedCache(t *testing.T) { + t.Parallel() + now := time.Date(2026, time.July, 10, 20, 0, 0, 0, time.UTC) + store := newStore(func() time.Time { return now }, time.Minute) + store.SetDiscovery(connector.Discovery{Scopes: []connector.Scope{ + {Name: "prod-eu", Reachable: true, ObservedAt: now}, + {Name: "dev-us", Reachable: true, ObservedAt: now}, + }}) + result := fleet.QueryResult{ + Facts: []fleet.Fact{ + podFact(t, "prod-eu", "payments-0", "CrashLoopBackOff", "registry/payments:log4j-fix", now), + podFact(t, "dev-us", "worker-0", "Running", "registry/worker:v1", now), + }, + Coverage: fleet.Coverage{Requested: 2, Reachable: 2}, + } + if err := store.Replace("Pod", result); err != nil { + t.Fatalf("Replace() error = %v", err) + } + + query, err := ParseSearch("pay ctx:prod-* ns:apps status:!Running image:*log4j* label:app=payments restarts:>5") + if err != nil { + t.Fatalf("ParseSearch() error = %v", err) + } + snapshot := store.Query(query) + if len(snapshot.Records) != 1 || snapshot.Records[0].Name != "payments-0" { + t.Fatalf("records = %#v, want payments pod", snapshot.Records) + } + if snapshot.Coverage.Requested != 1 || snapshot.Coverage.Reachable != 1 { + t.Fatalf("coverage = %#v, want prod scope only", snapshot.Coverage) + } +} + +func TestStorePauseAndChangeNotification(t *testing.T) { + t.Parallel() + store := New() + initial := store.Query(Query{}).Version + store.SetPaused(true) + version, err := store.WaitForChange(context.Background(), initial) + if err != nil || version <= initial { + t.Fatalf("WaitForChange() = %d, %v", version, err) + } + if store.BeginSync() { + t.Fatal("BeginSync() while paused = true") + } + if state := store.Query(Query{}).State; state != StatePaused { + t.Fatalf("state = %q, want paused", state) + } + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := store.WaitForChange(ctx, version); err == nil { + t.Fatal("WaitForChange(canceled) error = nil") + } +} + +func TestStoreConcurrentSnapshotsAreImmutable(t *testing.T) { + t.Parallel() + now := time.Now().UTC() + store := newStore(func() time.Time { return now }, time.Minute) + store.SetDiscovery(connector.Discovery{Scopes: []connector.Scope{{Name: "alpha", Reachable: true, ObservedAt: now}}}) + var waitGroup sync.WaitGroup + for index := range 20 { + waitGroup.Add(2) + go func(index int) { + defer waitGroup.Done() + _ = store.Replace("Pod", fleet.QueryResult{ + Facts: []fleet.Fact{podFact(t, "alpha", "pod-"+string(rune('a'+index)), "Running", "image:v1", now)}, + Coverage: fleet.Coverage{Requested: 1, Reachable: 1}, + }) + }(index) + go func() { + defer waitGroup.Done() + snapshot := store.Query(Query{Kind: "Pod"}) + if len(snapshot.Records) > 0 { + snapshot.Records[0].Labels["mutated"] = "yes" + } + }() + } + waitGroup.Wait() + for _, record := range store.Query(Query{Kind: "Pod"}).Records { + if record.Labels["mutated"] != "" { + t.Fatal("snapshot mutation escaped into store") + } + } +} + +func TestReplaceRejectsInvalidEvidenceAtomically(t *testing.T) { + t.Parallel() + store := New() + result := fleet.QueryResult{Facts: []fleet.Fact{{Evidence: fleet.Evidence{ + Ref: fleet.ResourceRef{Scope: "alpha", Kind: "Pod", Name: "bad"}, + Observed: json.RawMessage(`{"broken"`), + }}}} + if err := store.Replace("Pod", result); err == nil { + t.Fatal("Replace(invalid) error = nil") + } + if snapshot := store.Query(Query{}); len(snapshot.Records) != 0 { + t.Fatalf("records = %#v, want atomic rejection", snapshot.Records) + } +} + +func TestNormalizeTierOneRecords(t *testing.T) { + t.Parallel() + now := time.Now().UTC() + tests := []struct { + name string + kind string + object map[string]any + wantStatus string + wantReady string + }{ + { + name: "deployment progressing", + kind: "Deployment", + object: map[string]any{ + "spec": map[string]any{ + "replicas": 3, + "template": map[string]any{"spec": map[string]any{ + "containers": []any{map[string]any{"image": "registry/payments:v2"}}, + }}, + }, + "status": map[string]any{"availableReplicas": 1, "updatedReplicas": 2}, + }, + wantStatus: "Progressing", + wantReady: "1/3", + }, + { + name: "warning event", + kind: "Event", + object: map[string]any{ + "type": "Warning", + "reason": "BackOff", + "message": "container is backing off", + "involvedObject": map[string]any{"kind": "Pod", "name": "api-0"}, + }, + wantStatus: "Warning", + wantReady: "Pod/api-0", + }, + { + name: "not ready node", + kind: "Node", + object: map[string]any{ + "status": map[string]any{ + "conditions": []any{map[string]any{"type": "Ready", "status": "False", "reason": "KubeletDown"}}, + "nodeInfo": map[string]any{"kubeletVersion": "v1.36.1"}, + }, + }, + wantStatus: "NotReady", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + test.object["apiVersion"] = "v1" + test.object["kind"] = test.kind + test.object["metadata"] = map[string]any{"name": "object", "uid": "uid"} + fact := objectFact(t, test.kind, test.object, now) + record, err := normalize(fact) + if err != nil { + t.Fatalf("normalize() error = %v", err) + } + if record.Status != test.wantStatus || record.Ready != test.wantReady { + t.Fatalf("record = %#v, want status=%q ready=%q", record, test.wantStatus, test.wantReady) + } + if test.kind == "Deployment" && !slices.Equal(record.Images, []string{"registry/payments:v2"}) { + t.Fatalf("images = %v", record.Images) + } + if test.kind == "Node" && record.Version != "v1.36.1" { + t.Fatalf("version = %q", record.Version) + } + }) + } +} + +func TestStoreReportsOfflineLastKnownAndPendingLens(t *testing.T) { + t.Parallel() + now := time.Now().UTC() + store := newStore(func() time.Time { return now }, time.Minute) + store.SetDiscovery(connector.Discovery{Scopes: []connector.Scope{{Name: "alpha", Reachable: true, ObservedAt: now}}}) + if pending := store.Query(Query{Kind: "Node"}); pending.Coverage.Reachable != 0 || pending.State != StateCold { + t.Fatalf("pending snapshot = %#v, want zero covered cold lens", pending) + } + if err := store.Replace("Pod", fleet.QueryResult{ + Facts: []fleet.Fact{podFact(t, "alpha", "api-0", "Running", "image:v1", now)}, + Coverage: fleet.Coverage{Requested: 1, Reachable: 1}, + }); err != nil { + t.Fatalf("Replace() error = %v", err) + } + store.SetDiscovery(connector.Discovery{Scopes: []connector.Scope{{Name: "alpha", ObservedAt: now}}, Unreachable: []string{"alpha"}}) + store.EndSync(context.DeadlineExceeded) + offline := store.Query(Query{Kind: "Pod", Text: []string{"no-match"}}) + if offline.State != StateOffline || len(offline.Records) != 0 { + t.Fatalf("offline snapshot = %#v, want zero matches over retained offline data", offline) + } +} + +func TestParseSearchRejectsUnsafeGrammar(t *testing.T) { + t.Parallel() + for _, expression := range []string{"status:", "label:broken", "restarts:many", "unknown:value"} { + if _, err := ParseSearch(expression); err == nil { + t.Errorf("ParseSearch(%q) error = nil", expression) + } + } +} + +func podFact(t *testing.T, cluster, name, status, image string, observed time.Time) fleet.Fact { + t.Helper() + object := map[string]any{ + "apiVersion": "v1", + "kind": "Pod", + "metadata": map[string]any{ + "name": name, + "namespace": "apps", + "labels": map[string]any{"app": stringsBefore(name, "-")}, + }, + "spec": map[string]any{ + "nodeName": "node-1", + "containers": []any{map[string]any{"name": "app", "image": image}}, + }, + "status": map[string]any{ + "phase": "Running", + "containerStatuses": []any{map[string]any{ + "ready": status == "Running", + "restartCount": 7, + "state": map[string]any{ + "waiting": map[string]any{"reason": status}, + }, + }}, + }, + } + payload, err := json.Marshal(object) + if err != nil { + t.Fatalf("marshal pod: %v", err) + } + return fleet.Fact{ + Evidence: fleet.Evidence{ + Ref: fleet.ResourceRef{ + SourceKind: "test", + Scope: cluster, + Kind: "Pod", + Namespace: "apps", + Name: name, + }, + Kind: fleet.FactInventory, + Observed: payload, + ObservedAt: observed, + Source: cluster, + }, + Workspace: fleet.LocalWorkspace, + } +} + +func objectFact(t *testing.T, kind string, object map[string]any, observed time.Time) fleet.Fact { + t.Helper() + payload, err := json.Marshal(object) + if err != nil { + t.Fatalf("marshal object: %v", err) + } + return fleet.Fact{Evidence: fleet.Evidence{ + Ref: fleet.ResourceRef{ + SourceKind: "test", + Scope: "alpha", + Kind: kind, + Name: "object", + }, + Kind: fleet.FactInventory, + Observed: payload, + ObservedAt: observed, + Source: "alpha", + }, Workspace: fleet.LocalWorkspace} +} + +func stringsBefore(value, separator string) string { + for index := range len(value) { + if string(value[index]) == separator { + return value[:index] + } + } + return value +} diff --git a/sessions/2026-07-10-slice-2-cache-first-fleet.md b/sessions/2026-07-10-slice-2-cache-first-fleet.md new file mode 100644 index 0000000..65acd6d --- /dev/null +++ b/sessions/2026-07-10-slice-2-cache-first-fleet.md @@ -0,0 +1,31 @@ +# Session — 2026-07-10 — slice-2-cache-first-fleet + +**Builder:** Gnani Rahul · **Model/effort:** engineering, max · **Branch:** feat/cache-first-fleet +**Slice(s):** Slice 2 / #33 + local portion of #10 · **Status:** in-progress + +--- + +[G] Goal: Implement a cache-first Tier-1 fleet view, shared CLI/TUI render path, and +coverage-honest cross-cluster search over at least two contexts. +[S] Scope: in-memory fleet store, background reconciliation over the Slice-1 Reader seam, +Tier-1 normalization, scriptable get/search/correlate commands, Bubble Tea TUI, latency/parity/ +offline tests, and real two-cluster proof. Per-pod operations, local web, MCP, disk persistence, +keychain custody, OCM, and governed writes are out of scope. +[A] Action: Started from post-merge `dev` commit `7767bf4` after PR #52 and both its PR and +post-merge CI runs passed the real two-kind-cluster gate; closed completed Slice-1 issues #32/#38. +[A] Action: Selected an in-memory immutable-snapshot store as the only interaction/render source, +with connector access isolated in a background hydrator. Raw object persistence is deferred until +Slice 5 defines encryption/custody so pod specifications do not become a new plaintext local cache. +[A] Action: Verified current upstream Bubble Tea v2.0.8; use only its core runtime, with local +table/search rendering to avoid unnecessary component/style dependencies. +[A] Action: Added a concurrency-safe fleet store with immutable snapshots, per-lens coverage, +last-known preservation, dynamic freshness, pause/offline state, and change notification. Tier-1 +objects normalize once on ingest into render/search fields. +[T] Test: Race tests cover atomic reconciliation, failed-scope last-known retention, structured and +fuzzy search, Tier-1 normalization, pending/offline/paused states, immutable concurrent snapshots, +and change cancellation. Focused lint passes at 86.6% statement coverage. +[C] Checkpoint #1: this commit — cache model and normalized query engine; next: background hydrator. + +--- + +**Session close:** in progress · **Open questions touched:** Q12 keeps the roadmap TUI/CLI-first default From ef8f8288932a0c687205ae5d1d37e678fd5ea46a Mon Sep 17 00:00:00 2001 From: Gnani Rahul Date: Fri, 10 Jul 2026 16:01:10 -0500 Subject: [PATCH 07/14] feat(cache): hydrate Tier-1 lenses in the background Isolate connector I/O behind a bounded reconciler and publish immutable cache updates without coupling interactions to the Kubernetes API. GSTACK-Checkpoint: 2026-07-10/slice-2-cache-first-fleet#2 Signed-off-by: Gnani Rahul --- internal/fleetcache/store.go | 9 +- internal/hydrate/hydrator.go | 192 +++++++++++++ internal/hydrate/hydrator_test.go | 254 ++++++++++++++++++ .../2026-07-10-slice-2-cache-first-fleet.md | 9 +- 4 files changed, 462 insertions(+), 2 deletions(-) create mode 100644 internal/hydrate/hydrator.go create mode 100644 internal/hydrate/hydrator_test.go diff --git a/internal/fleetcache/store.go b/internal/fleetcache/store.go index 5dfe99d..ba5bef3 100644 --- a/internal/fleetcache/store.go +++ b/internal/fleetcache/store.go @@ -49,6 +49,7 @@ type Store struct { coverage map[string]fleet.Coverage scopes map[string]connector.Scope warmed map[string]bool + expected map[string]bool syncing bool paused bool lastError string @@ -70,6 +71,7 @@ func newStore(now func() time.Time, freshFor time.Duration) *Store { coverage: make(map[string]fleet.Coverage), scopes: make(map[string]connector.Scope), warmed: make(map[string]bool), + expected: make(map[string]bool), changed: make(chan struct{}), now: now, freshFor: freshFor, @@ -77,7 +79,7 @@ func newStore(now func() time.Time, freshFor time.Duration) *Store { } // BeginSync marks background reconciliation as active without blocking readers. -func (store *Store) BeginSync() bool { +func (store *Store) BeginSync(kinds ...string) bool { store.mu.Lock() defer store.mu.Unlock() if store.paused || store.syncing { @@ -85,6 +87,11 @@ func (store *Store) BeginSync() bool { } store.syncing = true store.lastError = "" + for _, kind := range kinds { + if canonical := canonicalKind(kind); canonical != "" { + store.expected[canonical] = true + } + } store.notifyLocked() return true } diff --git a/internal/hydrate/hydrator.go b/internal/hydrate/hydrator.go new file mode 100644 index 0000000..987ebad --- /dev/null +++ b/internal/hydrate/hydrator.go @@ -0,0 +1,192 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package hydrate owns connector access and reconciles background data into the local store. +package hydrate + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + + "github.com/ArdurAI/sith/internal/connector" + "github.com/ArdurAI/sith/internal/fleet" + "github.com/ArdurAI/sith/internal/fleetcache" +) + +var tierOneKinds = []string{"Pod", "Deployment", "Event", "Node"} + +// TierOneKinds returns the frequency-ordered daily-loop lenses for the Slice-2 fleet view. +func TierOneKinds() []string { + return append([]string(nil), tierOneKinds...) +} + +// ErrPaused reports a sync request while the store is deliberately frozen. +var ErrPaused = errors.New("fleet cache is paused") + +// ErrSyncInProgress reports a duplicate background sync request. +var ErrSyncInProgress = errors.New("fleet cache sync is already in progress") + +// Option configures background hydration. +type Option func(*options) error + +type options struct { + kinds []string + maxConcurrency int +} + +// WithKinds limits hydration to an explicit resource-kind set. +func WithKinds(kinds ...string) Option { + return func(settings *options) error { + if len(kinds) == 0 { + return fmt.Errorf("at least one hydration kind is required") + } + settings.kinds = append([]string(nil), kinds...) + return nil + } +} + +// WithMaxConcurrency bounds simultaneous connector queries. +func WithMaxConcurrency(limit int) Option { + return func(settings *options) error { + if limit <= 0 { + return fmt.Errorf("maximum concurrency must be positive") + } + settings.maxConcurrency = limit + return nil + } +} + +// Hydrator is the only interaction-layer component allowed to call a connector. +type Hydrator struct { + reader connector.Reader + store *fleetcache.Store + kinds []string + limit int +} + +// New validates and constructs a background hydrator. +func New(reader connector.Reader, store *fleetcache.Store, opts ...Option) (*Hydrator, error) { + if reader == nil { + return nil, fmt.Errorf("construct hydrator: reader is nil") + } + if store == nil { + return nil, fmt.Errorf("construct hydrator: store is nil") + } + settings := options{kinds: TierOneKinds(), maxConcurrency: len(tierOneKinds)} + for _, option := range opts { + if option == nil { + return nil, fmt.Errorf("construct hydrator: option is nil") + } + if err := option(&settings); err != nil { + return nil, fmt.Errorf("construct hydrator: %w", err) + } + } + kinds, err := normalizeKinds(settings.kinds) + if err != nil { + return nil, fmt.Errorf("construct hydrator: %w", err) + } + return &Hydrator{reader: reader, store: store, kinds: kinds, limit: settings.maxConcurrency}, nil +} + +// Kinds returns the deterministic resource-kind set this hydrator reconciles. +func (hydrator *Hydrator) Kinds() []string { + return append([]string(nil), hydrator.kinds...) +} + +// SyncOnce discovers contexts and independently reconciles each configured lens. +func (hydrator *Hydrator) SyncOnce(ctx context.Context) error { + if hydrator.store.Paused() { + return ErrPaused + } + if !hydrator.store.BeginSync(hydrator.kinds...) { + if hydrator.store.Paused() { + return ErrPaused + } + return ErrSyncInProgress + } + + var syncErr error + defer func() { + hydrator.store.EndSync(syncErr) + }() + discovery, err := hydrator.reader.Discover(ctx) + if err != nil { + syncErr = fmt.Errorf("discover hydration scopes: %w", err) + return syncErr + } + hydrator.store.SetDiscovery(discovery) + + errorsByKind := make([]error, len(hydrator.kinds)) + workers := min(hydrator.limit, len(hydrator.kinds)) + jobs := make(chan int) + var waitGroup sync.WaitGroup + waitGroup.Add(workers) + for range workers { + go func() { + defer waitGroup.Done() + for index := range jobs { + kind := hydrator.kinds[index] + result, queryErr := hydrator.reader.Query(ctx, fleet.Query{ + Kinds: []fleet.FactKind{fleet.FactInventory}, + Selector: fleet.Selector{ResourceKind: kind}, + }) + if queryErr != nil { + errorsByKind[index] = fmt.Errorf("query %s cache: %w", kind, queryErr) + continue + } + if replaceErr := hydrator.store.Replace(kind, result); replaceErr != nil { + errorsByKind[index] = fmt.Errorf("replace %s cache: %w", kind, replaceErr) + } + } + }() + } + for index := range hydrator.kinds { + select { + case jobs <- index: + case <-ctx.Done(): + close(jobs) + waitGroup.Wait() + syncErr = errors.Join(errorsByKind...) + if syncErr == nil { + syncErr = ctx.Err() + } + return syncErr + } + } + close(jobs) + waitGroup.Wait() + syncErr = errors.Join(errorsByKind...) + return syncErr +} + +func normalizeKinds(kinds []string) ([]string, error) { + set := make(map[string]struct{}, len(kinds)) + result := make([]string, 0, len(kinds)) + for _, kind := range kinds { + trimmed := strings.TrimSpace(kind) + if trimmed == "" { + return nil, fmt.Errorf("hydration kind must not be empty") + } + var canonical string + switch strings.ToLower(trimmed) { + case "pod", "pods", "po": + canonical = "Pod" + case "deployment", "deployments", "deploy": + canonical = "Deployment" + case "event", "events", "ev": + canonical = "Event" + case "node", "nodes", "no": + canonical = "Node" + default: + canonical = strings.ToUpper(trimmed[:1]) + trimmed[1:] + } + if _, exists := set[canonical]; exists { + continue + } + set[canonical] = struct{}{} + result = append(result, canonical) + } + return result, nil +} diff --git a/internal/hydrate/hydrator_test.go b/internal/hydrate/hydrator_test.go new file mode 100644 index 0000000..8a11c9e --- /dev/null +++ b/internal/hydrate/hydrator_test.go @@ -0,0 +1,254 @@ +// SPDX-License-Identifier: Apache-2.0 + +package hydrate + +import ( + "context" + "encoding/json" + "errors" + "slices" + "sync" + "testing" + "time" + + "github.com/ArdurAI/sith/internal/connector" + "github.com/ArdurAI/sith/internal/fleet" + "github.com/ArdurAI/sith/internal/fleetcache" +) + +func TestNewValidatesOptionsAndPreservesLensOrder(t *testing.T) { + t.Parallel() + reader := &fakeReader{} + store := fleetcache.New() + for _, test := range []struct { + name string + reader connector.Reader + store *fleetcache.Store + option Option + }{ + {name: "nil reader", store: store}, + {name: "nil store", reader: reader}, + {name: "nil option", reader: reader, store: store, option: nil}, + {name: "empty kinds", reader: reader, store: store, option: WithKinds()}, + {name: "blank kind", reader: reader, store: store, option: WithKinds(" ")}, + {name: "zero concurrency", reader: reader, store: store, option: WithMaxConcurrency(0)}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + options := []Option{} + if test.option != nil || test.name == "nil option" { + options = append(options, test.option) + } + if _, err := New(test.reader, test.store, options...); err == nil { + t.Fatal("New() error = nil") + } + }) + } + + hydrator, err := New(reader, store, WithKinds("pods", "deploy", "pods", "events")) + if err != nil { + t.Fatalf("New() error = %v", err) + } + if !slices.Equal(hydrator.Kinds(), []string{"Pod", "Deployment", "Event"}) { + t.Fatalf("Kinds() = %v", hydrator.Kinds()) + } +} + +func TestSyncOnceReconcilesKindsConcurrently(t *testing.T) { + t.Parallel() + reader := &fakeReader{delay: 20 * time.Millisecond} + store := fleetcache.New() + hydrator, err := New(reader, store, WithMaxConcurrency(2)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + if err := hydrator.SyncOnce(context.Background()); err != nil { + t.Fatalf("SyncOnce() error = %v", err) + } + if reader.maximumActive() != 2 { + t.Fatalf("maximum active queries = %d, want 2", reader.maximumActive()) + } + for _, kind := range TierOneKinds() { + snapshot := store.Query(fleetcache.Query{Kind: kind}) + if len(snapshot.Records) != 2 || !snapshot.Coverage.Complete() { + t.Errorf("%s snapshot = %#v, want two complete records", kind, snapshot) + } + } +} + +func TestSyncOnceKeepsSuccessfulLensesOnPartialFailure(t *testing.T) { + t.Parallel() + reader := &fakeReader{failures: map[string]error{"Event": errors.New("events forbidden")}} + store := fleetcache.New() + hydrator, err := New(reader, store, WithKinds("Pod", "Event")) + if err != nil { + t.Fatalf("New() error = %v", err) + } + err = hydrator.SyncOnce(context.Background()) + if err == nil || !errors.Is(err, reader.failures["Event"]) { + t.Fatalf("SyncOnce() error = %v, want event failure", err) + } + if snapshot := store.Query(fleetcache.Query{Kind: "Pod"}); len(snapshot.Records) != 2 { + t.Fatalf("pod records = %#v, want successful lens retained", snapshot.Records) + } + if snapshot := store.Query(fleetcache.Query{Kind: "Event"}); snapshot.LastError == "" { + t.Fatalf("event snapshot = %#v, want visible sync error", snapshot) + } +} + +func TestSyncOnceRejectsPauseAndDuplicateRun(t *testing.T) { + t.Parallel() + reader := &fakeReader{block: make(chan struct{}), started: make(chan struct{}, 1)} + store := fleetcache.New() + hydrator, err := New(reader, store, WithKinds("Pod")) + if err != nil { + t.Fatalf("New() error = %v", err) + } + firstDone := make(chan error, 1) + go func() { + firstDone <- hydrator.SyncOnce(context.Background()) + }() + <-reader.started + if err := hydrator.SyncOnce(context.Background()); !errors.Is(err, ErrSyncInProgress) { + t.Fatalf("second SyncOnce() error = %v, want ErrSyncInProgress", err) + } + close(reader.block) + if err := <-firstDone; err != nil { + t.Fatalf("first SyncOnce() error = %v", err) + } + + store.SetPaused(true) + if err := hydrator.SyncOnce(context.Background()); !errors.Is(err, ErrPaused) { + t.Fatalf("paused SyncOnce() error = %v, want ErrPaused", err) + } +} + +type fakeReader struct { + mu sync.Mutex + active int + max int + delay time.Duration + block chan struct{} + started chan struct{} + failures map[string]error +} + +func (*fakeReader) Kind() string { return "fake" } + +func (*fakeReader) Capabilities() []connector.Capability { + return []connector.Capability{connector.CapDiscover, connector.CapRead, connector.CapQuery} +} + +func (reader *fakeReader) Descriptor() connector.Descriptor { + return connector.Descriptor{ + Kind: reader.Kind(), + ConnKind: connector.KindReadAdapter, + ProtocolV: "1.0.0", + Owner: "test", + Capabilities: reader.Capabilities(), + } +} + +func (*fakeReader) Discover(_ context.Context) (connector.Discovery, error) { + now := time.Now().UTC() + return connector.Discovery{Scopes: []connector.Scope{ + {Name: "alpha", Reachable: true, ObservedAt: now}, + {Name: "beta", Reachable: true, ObservedAt: now}, + }}, nil +} + +func (*fakeReader) Read(_ context.Context, _ fleet.ResourceRef) (fleet.Evidence, error) { + return fleet.Evidence{}, errors.New("not used") +} + +func (reader *fakeReader) Query(ctx context.Context, query fleet.Query) (fleet.QueryResult, error) { + kind := query.Selector.ResourceKind + reader.mu.Lock() + reader.active++ + if reader.active > reader.max { + reader.max = reader.active + } + delay := reader.delay + block := reader.block + started := reader.started + failure := reader.failures[kind] + reader.mu.Unlock() + defer func() { + reader.mu.Lock() + reader.active-- + reader.mu.Unlock() + }() + if started != nil { + select { + case started <- struct{}{}: + default: + } + } + if delay > 0 { + timer := time.NewTimer(delay) + defer timer.Stop() + select { + case <-ctx.Done(): + return fleet.QueryResult{}, ctx.Err() + case <-timer.C: + } + } + if block != nil { + select { + case <-ctx.Done(): + return fleet.QueryResult{}, ctx.Err() + case <-block: + } + } + if failure != nil { + return fleet.QueryResult{}, failure + } + now := time.Now().UTC() + return fleet.QueryResult{ + Facts: []fleet.Fact{ + fakeFact(kind, "alpha", now), + fakeFact(kind, "beta", now), + }, + Coverage: fleet.Coverage{Requested: 2, Reachable: 2}, + }, nil +} + +func (reader *fakeReader) maximumActive() int { + reader.mu.Lock() + defer reader.mu.Unlock() + return reader.max +} + +func fakeFact(kind, scope string, observed time.Time) fleet.Fact { + object := map[string]any{ + "apiVersion": "v1", + "kind": kind, + "metadata": map[string]any{"name": stringsLower(kind), "namespace": "apps"}, + "status": map[string]any{"phase": "Running"}, + } + payload, _ := json.Marshal(object) + return fleet.Fact{Evidence: fleet.Evidence{ + Ref: fleet.ResourceRef{ + SourceKind: "fake", + Scope: scope, + Kind: kind, + Namespace: "apps", + Name: stringsLower(kind), + }, + Kind: fleet.FactInventory, + Observed: payload, + ObservedAt: observed, + Source: scope, + }, Workspace: fleet.LocalWorkspace} +} + +func stringsLower(value string) string { + result := make([]rune, 0, len(value)) + for _, character := range value { + if character >= 'A' && character <= 'Z' { + character += 'a' - 'A' + } + result = append(result, character) + } + return string(result) +} diff --git a/sessions/2026-07-10-slice-2-cache-first-fleet.md b/sessions/2026-07-10-slice-2-cache-first-fleet.md index 65acd6d..122e901 100644 --- a/sessions/2026-07-10-slice-2-cache-first-fleet.md +++ b/sessions/2026-07-10-slice-2-cache-first-fleet.md @@ -24,7 +24,14 @@ objects normalize once on ingest into render/search fields. [T] Test: Race tests cover atomic reconciliation, failed-scope last-known retention, structured and fuzzy search, Tier-1 normalization, pending/offline/paused states, immutable concurrent snapshots, and change cancellation. Focused lint passes at 86.6% statement coverage. -[C] Checkpoint #1: this commit — cache model and normalized query engine; next: background hydrator. +[C] Checkpoint #1: 09d5470 — cache model and normalized query engine; next: background hydrator. +[A] Action: Added the background hydrator as the sole connector caller. It discovers once per +cycle, fans Tier-1 lens queries out with bounded concurrency, and publishes successful lenses +incrementally while retaining them if a peer lens fails. +[T] Test: Race tests prove frequency-ordered lens selection, concurrency bounds, partial-success +retention, duplicate-sync exclusion, pause behavior, and constructor fail-safety. Focused lint +passes at 87.2% statement coverage. +[C] Checkpoint #2: this commit — connector-isolated background hydration; next: shared renderer and CLI. --- From 322738769a387923508eafc05c87fcf162cd596a Mon Sep 17 00:00:00 2001 From: Gnani Rahul Date: Fri, 10 Jul 2026 16:07:53 -0500 Subject: [PATCH 08/14] feat(cli): add cache-backed fleet reads Share one Tier-1 renderer across explicit-scope get, fuzzy search, and coverage-honest correlation commands. GSTACK-Checkpoint: 2026-07-10/slice-2-cache-first-fleet#3 Signed-off-by: Gnani Rahul --- README.md | 14 +- internal/cli/cached.go | 175 ++++++++++++++ internal/cli/cached_test.go | 217 ++++++++++++++++++ internal/cli/root.go | 40 +++- internal/fleetcache/query.go | 32 +++ internal/fleetcache/store_test.go | 17 ++ internal/fleetrender/table.go | 204 ++++++++++++++++ internal/fleetrender/table_test.go | 122 ++++++++++ .../2026-07-10-slice-2-cache-first-fleet.md | 9 +- 9 files changed, 818 insertions(+), 12 deletions(-) create mode 100644 internal/cli/cached.go create mode 100644 internal/cli/cached_test.go create mode 100644 internal/fleetrender/table.go create mode 100644 internal/fleetrender/table_test.go diff --git a/README.md b/README.md index a79690d..c9a72de 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ # Sith -**Status: Slice 1 local fleet source.** The CLI discovers every context resolved by client-go, -probes them independently, and reports reachable and unreachable clusters without a hub. +**Status: Slice 2 cache-first fleet client.** The CLI discovers every context resolved by +client-go, hydrates a local in-memory fleet cache, and serves Tier-1 reads and cross-cluster search +from normalized snapshots with explicit freshness and coverage. Sith is ArdurAI's single-binary, local-first Kubernetes fleet tool: **k9s for your whole fleet**. It is designed to aggregate every kubeconfig context without an account, telemetry, or cluster @@ -17,12 +18,21 @@ make build ./bin/sith version ./bin/sith version --output json ./bin/sith clusters +./bin/sith get pods -A --all-clusters +./bin/sith search 'image:*log4j*' +./bin/sith correlate 'deploy/payments status!=Healthy' ``` `sith clusters` follows standard client-go loading rules: set `KUBECONFIG` to an OS path-list or use the default `~/.kube/config`. Exec-credential helpers run locally, exactly as they do for `kubectl`; Sith does not copy kubeconfigs or credentials elsewhere. +Scripted `get` calls require either `--all-clusters` or one explicit `--context`. Text, JSON, +wide, and source-abstract name outputs are supported. Search and correlation run over the same +normalized in-memory records; partial results name stale/unreachable contexts. The cache is not +persisted to disk, so raw workload specifications do not become a new plaintext credential-adjacent +artifact. + Run the full local quality gate with golangci-lint v2.12.2 and govulncheck v1.6.0 on `PATH`: ```bash diff --git a/internal/cli/cached.go b/internal/cli/cached.go new file mode 100644 index 0000000..ba0e91b --- /dev/null +++ b/internal/cli/cached.go @@ -0,0 +1,175 @@ +// SPDX-License-Identifier: Apache-2.0 + +package cli + +import ( + "encoding/json" + "errors" + "fmt" + "strings" + + "github.com/spf13/cobra" + + "github.com/ArdurAI/sith/internal/connector" + "github.com/ArdurAI/sith/internal/fleetcache" + "github.com/ArdurAI/sith/internal/fleetrender" + "github.com/ArdurAI/sith/internal/hydrate" +) + +type cacheCommandOptions struct { + allClusters bool + allNamespaces bool + context string + namespace string +} + +func newGetCommand(root *rootOptions, reader connector.Reader) *cobra.Command { + options := &cacheCommandOptions{} + command := &cobra.Command{ + Use: "get [name]", + Short: "Read a resource lens from the local fleet cache", + Args: cobra.RangeArgs(1, 2), + RunE: func(command *cobra.Command, args []string) error { + query := fleetcache.Query{Kind: args[0]} + if len(args) == 2 { + query.Name = args[1] + } + if err := applyExplicitScope(query.Kind, options, &query); err != nil { + return err + } + return runCacheCommand(command, root, reader, []string{query.Kind}, query) + }, + } + addCacheScopeFlags(command, options, true) + return command +} + +func newSearchCommand(root *rootOptions, reader connector.Reader) *cobra.Command { + options := &cacheCommandOptions{} + command := &cobra.Command{ + Use: "search ", + Short: "Search normalized records across the local fleet cache", + Args: cobra.MinimumNArgs(1), + RunE: func(command *cobra.Command, args []string) error { + query, err := fleetcache.ParseSearch(strings.Join(args, " ")) + if err != nil { + return err + } + if options.context != "" { + query.Scopes = []string{options.context} + } + return runCacheCommand(command, root, reader, hydrate.TierOneKinds(), query) + }, + } + command.Flags().StringVar(&options.context, "context", "", "limit search to one kubeconfig context") + return command +} + +func newCorrelateCommand(root *rootOptions, reader connector.Reader) *cobra.Command { + options := &cacheCommandOptions{} + command := &cobra.Command{ + Use: "correlate ", + Short: "Answer a coverage-honest cross-cluster correlation", + Args: cobra.MinimumNArgs(1), + RunE: func(command *cobra.Command, args []string) error { + query, err := fleetcache.ParseCorrelation(strings.Join(args, " ")) + if err != nil { + return err + } + if options.context != "" { + query.Scopes = []string{options.context} + } + return runCacheCommand(command, root, reader, []string{query.Kind}, query) + }, + } + command.Flags().StringVar(&options.context, "context", "", "limit correlation to one kubeconfig context") + return command +} + +func addCacheScopeFlags(command *cobra.Command, options *cacheCommandOptions, namespaceFlags bool) { + command.Flags().BoolVar(&options.allClusters, "all-clusters", false, "query every discovered context") + command.Flags().StringVar(&options.context, "context", "", "query one kubeconfig context") + if namespaceFlags { + command.Flags().BoolVarP(&options.allNamespaces, "all-namespaces", "A", false, "query every namespace") + command.Flags().StringVarP(&options.namespace, "namespace", "n", "", "query one namespace (default default)") + } +} + +func applyExplicitScope(kind string, options *cacheCommandOptions, query *fleetcache.Query) error { + if options.allClusters == (options.context != "") { + return fmt.Errorf("choose exactly one of --all-clusters or --context") + } + if options.context != "" { + query.Scopes = []string{options.context} + } + if options.allNamespaces && options.namespace != "" { + return fmt.Errorf("--all-namespaces and --namespace are mutually exclusive") + } + switch strings.ToLower(kind) { + case "node", "nodes", "no", "namespace", "namespaces": + if options.allNamespaces || options.namespace != "" { + return fmt.Errorf("namespace flags cannot select cluster-scoped %s", kind) + } + default: + if !options.allNamespaces { + query.Namespace = options.namespace + if query.Namespace == "" { + query.Namespace = "default" + } + } + } + return nil +} + +func runCacheCommand( + command *cobra.Command, + root *rootOptions, + reader connector.Reader, + kinds []string, + query fleetcache.Query, +) error { + store := fleetcache.New() + hydrator, err := hydrate.New(reader, store, hydrate.WithKinds(kinds...)) + if err != nil { + return err + } + syncErr := hydrator.SyncOnce(command.Context()) + snapshot := store.Query(query) + if err := writeCacheSnapshot(command, root.output, query.Kind, snapshot); err != nil { + return err + } + if snapshot.Coverage.Requested == 0 { + return fmt.Errorf("no kubeconfig contexts discovered") + } + if snapshot.Coverage.Reachable == 0 { + if syncErr != nil { + return fmt.Errorf("fleet cache sync failed: %w", syncErr) + } + return fmt.Errorf("fleet cache query reached 0/%d contexts", snapshot.Coverage.Requested) + } + if syncErr != nil || !snapshot.Coverage.Complete() { + warning := fleetrender.CoverageLine(snapshot.Coverage) + if syncErr != nil && !errors.Is(syncErr, hydrate.ErrPaused) { + warning += ": " + syncErr.Error() + } + if _, err := fmt.Fprintln(command.ErrOrStderr(), "warning: "+warning); err != nil { + return fmt.Errorf("write partial coverage warning: %w", err) + } + } + return nil +} + +func writeCacheSnapshot(command *cobra.Command, format, lens string, snapshot fleetcache.Snapshot) error { + switch format { + case "json": + if err := json.NewEncoder(command.OutOrStdout()).Encode(snapshot); err != nil { + return fmt.Errorf("write cache JSON: %w", err) + } + case "name": + return fleetrender.WriteNames(command.OutOrStdout(), snapshot) + default: + table := fleetrender.Build(snapshot, fleetrender.Options{Lens: lens, Wide: format == "wide"}) + return fleetrender.WriteText(command.OutOrStdout(), table) + } + return nil +} diff --git a/internal/cli/cached_test.go b/internal/cli/cached_test.go new file mode 100644 index 0000000..1af1dee --- /dev/null +++ b/internal/cli/cached_test.go @@ -0,0 +1,217 @@ +// SPDX-License-Identifier: Apache-2.0 + +package cli + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "strings" + "sync" + "testing" + "time" + + "github.com/ArdurAI/sith/internal/connector" + "github.com/ArdurAI/sith/internal/fleet" + "github.com/ArdurAI/sith/internal/fleetcache" +) + +func TestGetRequiresExplicitFleetScope(t *testing.T) { + reader := &cacheReader{} + _, stderr, exitCode := runCLIWithReader(t, []string{"get", "pods", "-A"}, reader) + if exitCode == 0 || !strings.Contains(stderr, "choose exactly one") { + t.Fatalf("exit/stderr = %d/%q", exitCode, stderr) + } + if reader.queryCount() != 0 { + t.Fatalf("query count = %d, want validation before connector access", reader.queryCount()) + } +} + +func TestGetRendersCacheWithPartialCoverageWarning(t *testing.T) { + reader := &cacheReader{unreachable: "beta"} + stdout, stderr, exitCode := runCLIWithReader(t, []string{"get", "pods", "-A", "--all-clusters"}, reader) + if exitCode != 0 { + t.Fatalf("exit code = %d, stderr = %q", exitCode, stderr) + } + for _, want := range []string{"CLUSTER", "payments-0", "covered 1/2 clusters", "1 unreachable (beta)"} { + if !strings.Contains(stdout, want) { + t.Errorf("stdout = %q, want %q", stdout, want) + } + } + if !strings.Contains(stderr, "warning: covered 1/2 clusters") { + t.Fatalf("stderr = %q, want partial warning", stderr) + } +} + +func TestGetJSONUsesStableCacheSchema(t *testing.T) { + reader := &cacheReader{} + stdout, stderr, exitCode := runCLIWithReader(t, []string{"get", "pods", "-A", "--context", "alpha", "-o", "json"}, reader) + if exitCode != 0 { + t.Fatalf("exit/stderr = %d/%q", exitCode, stderr) + } + var snapshot fleetcache.Snapshot + if err := json.Unmarshal([]byte(stdout), &snapshot); err != nil { + t.Fatalf("unmarshal output %q: %v", stdout, err) + } + if len(snapshot.Records) != 1 || snapshot.Records[0].Cluster != "alpha" || snapshot.Coverage.Requested != 1 { + t.Fatalf("snapshot = %#v", snapshot) + } +} + +func TestSearchAndCorrelateUseNormalizedCrossClusterCache(t *testing.T) { + reader := &cacheReader{} + stdout, stderr, exitCode := runCLIWithReader(t, []string{"search", "image:*log4j*"}, reader) + if exitCode != 0 { + t.Fatalf("search exit/stderr = %d/%q", exitCode, stderr) + } + if !strings.Contains(stdout, "payments-0") || strings.Contains(stdout, "worker-0") || !strings.Contains(stdout, "covered 2/2") { + t.Fatalf("search stdout = %q", stdout) + } + + stdout, stderr, exitCode = runCLIWithReader(t, []string{"correlate", "deploy/payments", "status!=Healthy"}, reader) + if exitCode != 0 { + t.Fatalf("correlate exit/stderr = %d/%q", exitCode, stderr) + } + if !strings.Contains(stdout, "beta") || strings.Contains(stdout, "alpha apps") || !strings.Contains(stdout, "Degraded") { + t.Fatalf("correlate stdout = %q", stdout) + } +} + +func TestGetTotalFailureIsNonZeroAfterCoverageOutput(t *testing.T) { + reader := &cacheReader{unreachable: "all"} + stdout, stderr, exitCode := runCLIWithReader(t, []string{"get", "pods", "-A", "--all-clusters"}, reader) + if exitCode == 0 { + t.Fatalf("exit = 0, stdout=%q stderr=%q", stdout, stderr) + } + if !strings.Contains(stdout, "covered 0/2") || !strings.Contains(stderr, "reached 0/2") { + t.Fatalf("stdout/stderr = %q/%q", stdout, stderr) + } +} + +func runCLIWithReader(t *testing.T, args []string, reader connector.Reader) (stdout, stderr string, exitCode int) { + t.Helper() + t.Setenv("XDG_CONFIG_HOME", t.TempDir()) + t.Setenv("SITH_LOG_LEVEL", "") + t.Setenv("SITH_LOG_FORMAT", "") + var stdoutBuffer bytes.Buffer + var stderrBuffer bytes.Buffer + exitCode = executeWithReader(args, reader, &stdoutBuffer, &stderrBuffer) + return stdoutBuffer.String(), stderrBuffer.String(), exitCode +} + +type cacheReader struct { + mu sync.Mutex + queries int + unreachable string +} + +func (*cacheReader) Kind() string { return "cache-test" } + +func (*cacheReader) Capabilities() []connector.Capability { + return []connector.Capability{connector.CapDiscover, connector.CapRead, connector.CapQuery} +} + +func (reader *cacheReader) Descriptor() connector.Descriptor { + return connector.Descriptor{ + Kind: reader.Kind(), ConnKind: connector.KindReadAdapter, ProtocolV: "1.0.0", Owner: "test", + Capabilities: reader.Capabilities(), + } +} + +func (reader *cacheReader) Discover(_ context.Context) (connector.Discovery, error) { + now := time.Now().UTC() + alpha := connector.Scope{Name: "alpha", Reachable: reader.unreachable != "all", ObservedAt: now} + beta := connector.Scope{Name: "beta", Reachable: reader.unreachable == "", ObservedAt: now} + unreachable := []string{} + if !alpha.Reachable { + unreachable = append(unreachable, "alpha") + } + if !beta.Reachable { + unreachable = append(unreachable, "beta") + } + return connector.Discovery{Scopes: []connector.Scope{alpha, beta}, Unreachable: unreachable}, nil +} + +func (*cacheReader) Read(_ context.Context, _ fleet.ResourceRef) (fleet.Evidence, error) { + return fleet.Evidence{}, errors.New("not used") +} + +func (reader *cacheReader) Query(_ context.Context, query fleet.Query) (fleet.QueryResult, error) { + reader.mu.Lock() + reader.queries++ + reader.mu.Unlock() + unreachable := []string{} + live := []string{"alpha", "beta"} + switch reader.unreachable { + case "all": + unreachable = []string{"alpha", "beta"} + live = nil + case "beta": + unreachable = []string{"beta"} + live = []string{"alpha"} + } + facts := make([]fleet.Fact, 0, len(live)) + for _, scope := range live { + switch query.Selector.ResourceKind { + case "Pod": + name, image, status := "worker-0", "registry/worker:v1", "Running" + if scope == "alpha" { + name, image, status = "payments-0", "registry/payments:log4j-fix", "CrashLoopBackOff" + } + facts = append(facts, cacheObjectFact("Pod", scope, name, image, status, 7)) + case "Deployment": + available := 3 + if scope == "beta" { + available = 0 + } + facts = append(facts, cacheDeploymentFact(scope, available)) + } + } + return fleet.QueryResult{ + Facts: facts, + Coverage: fleet.Coverage{ + Requested: 2, Reachable: len(live), Unreachable: unreachable, + }, + }, nil +} + +func (reader *cacheReader) queryCount() int { + reader.mu.Lock() + defer reader.mu.Unlock() + return reader.queries +} + +func cacheObjectFact(kind, scope, name, image, status string, restarts int) fleet.Fact { + object := map[string]any{ + "apiVersion": "v1", "kind": kind, + "metadata": map[string]any{"name": name, "namespace": "apps", "creationTimestamp": time.Now().Add(-time.Hour).UTC().Format(time.RFC3339)}, + "spec": map[string]any{"containers": []any{map[string]any{"name": "app", "image": image}}}, + "status": map[string]any{ + "phase": "Running", + "containerStatuses": []any{map[string]any{ + "ready": status == "Running", "restartCount": restarts, + "state": map[string]any{"waiting": map[string]any{"reason": status}}, + }}, + }, + } + return cacheFact(kind, scope, name, object) +} + +func cacheDeploymentFact(scope string, available int) fleet.Fact { + object := map[string]any{ + "apiVersion": "apps/v1", "kind": "Deployment", + "metadata": map[string]any{"name": "payments", "namespace": "apps"}, + "spec": map[string]any{"replicas": 3}, + "status": map[string]any{"availableReplicas": available, "updatedReplicas": available}, + } + return cacheFact("Deployment", scope, "payments", object) +} + +func cacheFact(kind, scope, name string, object map[string]any) fleet.Fact { + payload, _ := json.Marshal(object) + return fleet.Fact{Evidence: fleet.Evidence{ + Ref: fleet.ResourceRef{SourceKind: "cache-test", Scope: scope, Kind: kind, Namespace: "apps", Name: name}, + Kind: fleet.FactInventory, Observed: payload, ObservedAt: time.Now().UTC(), Source: scope, + }, Workspace: fleet.LocalWorkspace} +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 2e89479..ee84555 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -33,13 +33,27 @@ type rootOptions struct { output string } +type backend struct { + source fleet.Source + reader connector.Reader +} + // Execute builds and runs the command tree, returning a process exit code. func Execute() int { - return execute(os.Args[1:], connector.AsSource(kubeconfig.Default()), os.Stdout, os.Stderr) + adapter := kubeconfig.Default() + return executeBackend(os.Args[1:], backend{source: connector.AsSource(adapter), reader: adapter}, os.Stdout, os.Stderr) } func execute(args []string, source fleet.Source, stdout, stderr io.Writer) int { - command := newRootCommand(source, stdout, stderr) + return executeBackend(args, backend{source: source}, stdout, stderr) +} + +func executeWithReader(args []string, reader connector.Reader, stdout, stderr io.Writer) int { + return executeBackend(args, backend{source: connector.AsSource(reader), reader: reader}, stdout, stderr) +} + +func executeBackend(args []string, runtime backend, stdout, stderr io.Writer) int { + command := newRootCommand(runtime, stdout, stderr) command.SetArgs(args) if err := command.Execute(); err != nil { if _, writeErr := fmt.Fprintln(stderr, err); writeErr != nil { @@ -51,7 +65,7 @@ func execute(args []string, source fleet.Source, stdout, stderr io.Writer) int { return 0 } -func newRootCommand(source fleet.Source, stdout, stderr io.Writer) *cobra.Command { +func newRootCommand(runtime backend, stdout, stderr io.Writer) *cobra.Command { options := &rootOptions{output: "text"} command := &cobra.Command{ Use: "sith", @@ -63,8 +77,8 @@ func newRootCommand(source fleet.Source, stdout, stderr io.Writer) *cobra.Comman return command.Help() }, PersistentPreRunE: func(command *cobra.Command, _ []string) error { - if options.output != "text" && options.output != "json" { - return fmt.Errorf("invalid output format %q: expected text or json", options.output) + if options.output != "text" && options.output != "json" && options.output != "wide" && options.output != "name" { + return fmt.Errorf("invalid output format %q: expected text, json, wide, or name", options.output) } resolved, err := config.Load(options.configPath, config.Overrides{ @@ -93,14 +107,22 @@ func newRootCommand(source fleet.Source, stdout, stderr io.Writer) *cobra.Comman flags.StringVar(&options.configPath, "config", "", "path to the YAML configuration file") flags.StringVar(&options.logLevel, "log-level", "", "logging level: debug, info, warn, or error (default info)") flags.StringVar(&options.logFormat, "log-format", "", "logging format: text or json (default text)") - flags.StringVarP(&options.output, "output", "o", "text", "output format: text or json") + flags.StringVarP(&options.output, "output", "o", "text", "output format: text, json, wide, or name") - command.AddCommand( + commands := []*cobra.Command{ newVersionCommand(options), - newClustersCommand(options, source), + newClustersCommand(options, runtime.source), newUICommand(), newHubCommand(), - ) + } + if runtime.reader != nil { + commands = append(commands, + newGetCommand(options, runtime.reader), + newSearchCommand(options, runtime.reader), + newCorrelateCommand(options, runtime.reader), + ) + } + command.AddCommand(commands...) return command } diff --git a/internal/fleetcache/query.go b/internal/fleetcache/query.go index d635515..0c93c70 100644 --- a/internal/fleetcache/query.go +++ b/internal/fleetcache/query.go @@ -74,6 +74,38 @@ func ParseSearch(expression string) (Query, error) { return query, nil } +// ParseCorrelation parses the initial deployment-health and image correlation forms. +func ParseCorrelation(expression string) (Query, error) { + fields := strings.Fields(strings.TrimSpace(expression)) + if len(fields) == 0 { + return Query{}, fmt.Errorf("correlation expression is empty") + } + if strings.Contains(fields[0], ":") { + return ParseSearch(expression) + } + kind, name, ok := strings.Cut(fields[0], "/") + if !ok || kind == "" || name == "" { + return Query{}, fmt.Errorf("correlation target %q must be kind/name", fields[0]) + } + query := Query{Kind: canonicalKind(kind), Name: name, Labels: map[string]string{}} + for _, predicate := range fields[1:] { + switch { + case strings.HasPrefix(predicate, "status!="): + query.StatusNot = strings.TrimPrefix(predicate, "status!=") + case strings.HasPrefix(predicate, "status="): + query.Status = strings.TrimPrefix(predicate, "status=") + case strings.HasPrefix(predicate, "image:"): + query.Image = strings.TrimPrefix(predicate, "image:") + default: + return Query{}, fmt.Errorf("unsupported correlation predicate %q", predicate) + } + } + if query.Status == "" && query.StatusNot == "" && query.Image == "" { + return Query{}, fmt.Errorf("correlation expression requires a status or image predicate") + } + return query, nil +} + func (query Query) matches(record Record) bool { if query.Kind != "" && canonicalKind(record.Kind) != canonicalKind(query.Kind) { return false diff --git a/internal/fleetcache/store_test.go b/internal/fleetcache/store_test.go index 645534f..d9646b2 100644 --- a/internal/fleetcache/store_test.go +++ b/internal/fleetcache/store_test.go @@ -279,6 +279,23 @@ func TestParseSearchRejectsUnsafeGrammar(t *testing.T) { } } +func TestParseCorrelationSupportsHealthAndImageForms(t *testing.T) { + t.Parallel() + query, err := ParseCorrelation("deploy/payments status!=Healthy") + if err != nil || query.Kind != "Deployment" || query.Name != "payments" || query.StatusNot != "Healthy" { + t.Fatalf("ParseCorrelation(health) = %#v, %v", query, err) + } + query, err = ParseCorrelation("image:*log4j*") + if err != nil || query.Image != "*log4j*" { + t.Fatalf("ParseCorrelation(image) = %#v, %v", query, err) + } + for _, expression := range []string{"", "payments", "deploy/payments", "deploy/payments in:1h"} { + if _, err := ParseCorrelation(expression); err == nil { + t.Errorf("ParseCorrelation(%q) error = nil", expression) + } + } +} + func podFact(t *testing.T, cluster, name, status, image string, observed time.Time) fleet.Fact { t.Helper() object := map[string]any{ diff --git a/internal/fleetrender/table.go b/internal/fleetrender/table.go new file mode 100644 index 0000000..cd13f10 --- /dev/null +++ b/internal/fleetrender/table.go @@ -0,0 +1,204 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package fleetrender builds the shared cache-backed tables used by CLI and TUI surfaces. +package fleetrender + +import ( + "bytes" + "fmt" + "io" + "strconv" + "strings" + "text/tabwriter" + "time" + + "github.com/ArdurAI/sith/internal/fleet" + "github.com/ArdurAI/sith/internal/fleetcache" +) + +// Options selects a lens and render shape without changing the underlying cache query. +type Options struct { + Lens string + Wide bool + MaxRows int + Now time.Time +} + +// Table is the stable surface-neutral representation shared by CLI and TUI. +type Table struct { + Columns []string `json:"columns"` + Rows [][]string `json:"rows"` + Coverage fleet.Coverage `json:"coverage"` + State fleetcache.State `json:"state"` +} + +// Build projects one immutable store snapshot into deterministic columns and rows. +func Build(snapshot fleetcache.Snapshot, options Options) Table { + lens := canonicalLens(options.Lens) + if lens == "" && len(snapshot.Records) > 0 { + lens = canonicalLens(snapshot.Records[0].Kind) + } + columns := columnsFor(lens, options.Wide) + rows := make([][]string, 0, len(snapshot.Records)) + now := options.Now + if now.IsZero() { + now = time.Now().UTC() + } + for _, record := range snapshot.Records { + rows = append(rows, rowFor(record, lens, options.Wide, now)) + if options.MaxRows > 0 && len(rows) == options.MaxRows { + break + } + } + return Table{Columns: columns, Rows: rows, Coverage: snapshot.Coverage, State: snapshot.State} +} + +// WriteText writes a pipe-friendly table followed by the mandatory coverage line. +func WriteText(output io.Writer, table Table) error { + var rendered bytes.Buffer + tabular := tabwriter.NewWriter(&rendered, 0, 4, 2, ' ', 0) + if _, err := fmt.Fprintln(tabular, strings.Join(table.Columns, "\t")); err != nil { + return fmt.Errorf("write table header: %w", err) + } + for _, row := range table.Rows { + if _, err := fmt.Fprintln(tabular, strings.Join(row, "\t")); err != nil { + return fmt.Errorf("write table row: %w", err) + } + } + if err := tabular.Flush(); err != nil { + return fmt.Errorf("flush table: %w", err) + } + if _, err := io.Copy(output, &rendered); err != nil { + return fmt.Errorf("write table: %w", err) + } + if _, err := fmt.Fprintln(output, CoverageLine(table.Coverage)); err != nil { + return fmt.Errorf("write coverage: %w", err) + } + return nil +} + +// WriteNames writes stable source-abstract resource addresses and a coverage line. +func WriteNames(output io.Writer, snapshot fleetcache.Snapshot) error { + for _, record := range snapshot.Records { + if _, err := fmt.Fprintln(output, record.Fact.Ref.String()); err != nil { + return fmt.Errorf("write resource name: %w", err) + } + } + if _, err := fmt.Fprintln(output, CoverageLine(snapshot.Coverage)); err != nil { + return fmt.Errorf("write coverage: %w", err) + } + return nil +} + +// CoverageLine renders coverage honesty identically on every text surface. +func CoverageLine(coverage fleet.Coverage) string { + parts := []string{fmt.Sprintf("covered %d/%d clusters", coverage.Reachable, coverage.Requested)} + if len(coverage.Stale) == 0 { + parts = append(parts, "0 stale") + } else { + parts = append(parts, fmt.Sprintf("%d stale (%s)", len(coverage.Stale), strings.Join(coverage.Stale, ", "))) + } + if len(coverage.Unreachable) == 0 { + parts = append(parts, "0 unreachable") + } else { + parts = append(parts, fmt.Sprintf("%d unreachable (%s)", len(coverage.Unreachable), strings.Join(coverage.Unreachable, ", "))) + } + return strings.Join(parts, " · ") +} + +func columnsFor(lens string, wide bool) []string { + var columns []string + switch lens { + case "Deployment": + columns = []string{"CLUSTER", "NAMESPACE", "NAME", "READY", "STATUS", "AGE"} + if wide { + columns = append(columns, "IMAGE") + } + case "Event": + columns = []string{"CLUSTER", "NAMESPACE", "LAST-SEEN", "TYPE", "REASON", "OBJECT", "MESSAGE"} + case "Node": + columns = []string{"CLUSTER", "NAME", "STATUS", "AGE", "VERSION"} + case "Pod": + columns = []string{"CLUSTER", "NAMESPACE", "NAME", "READY", "STATUS", "RESTARTS", "AGE"} + if wide { + columns = append(columns, "NODE", "IMAGE") + } + default: + columns = []string{"CLUSTER", "NAMESPACE", "KIND", "NAME", "STATUS", "AGE"} + } + return columns +} + +func rowFor(record fleetcache.Record, lens string, wide bool, now time.Time) []string { + cluster := record.Cluster + if record.Stale { + cluster = "~" + cluster + } + age := humanAge(now, record.CreatedAt) + switch lens { + case "Deployment": + row := []string{cluster, record.Namespace, record.Name, record.Ready, record.Status, age} + if wide { + row = append(row, strings.Join(record.Images, ",")) + } + return row + case "Event": + return []string{cluster, record.Namespace, humanAge(now, record.ObservedAt), record.Status, record.Reason, record.Ready, truncate(record.Message, 72)} + case "Node": + return []string{cluster, record.Name, record.Status, age, record.Version} + case "Pod": + row := []string{cluster, record.Namespace, record.Name, record.Ready, record.Status, strconv.FormatInt(record.Restarts, 10), age} + if wide { + row = append(row, record.Node, strings.Join(record.Images, ",")) + } + return row + default: + return []string{cluster, record.Namespace, record.Kind, record.Name, record.Status, age} + } +} + +func humanAge(now, then time.Time) string { + if then.IsZero() { + return "-" + } + age := now.Sub(then) + if age < 0 { + age = 0 + } + switch { + case age < time.Minute: + return fmt.Sprintf("%ds", int(age.Seconds())) + case age < time.Hour: + return fmt.Sprintf("%dm", int(age.Minutes())) + case age < 24*time.Hour: + return fmt.Sprintf("%dh", int(age.Hours())) + default: + return fmt.Sprintf("%dd", int(age.Hours()/24)) + } +} + +func truncate(value string, limit int) string { + runes := []rune(value) + if len(runes) <= limit { + return value + } + if limit <= 1 { + return string(runes[:limit]) + } + return string(runes[:limit-1]) + "…" +} + +func canonicalLens(lens string) string { + switch strings.ToLower(strings.TrimSpace(lens)) { + case "pod", "pods", "po": + return "Pod" + case "deployment", "deployments", "deploy": + return "Deployment" + case "event", "events", "ev": + return "Event" + case "node", "nodes", "no": + return "Node" + default: + return strings.TrimSpace(lens) + } +} diff --git a/internal/fleetrender/table_test.go b/internal/fleetrender/table_test.go new file mode 100644 index 0000000..de1f90e --- /dev/null +++ b/internal/fleetrender/table_test.go @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: Apache-2.0 + +package fleetrender + +import ( + "bytes" + "slices" + "strings" + "testing" + "time" + + "github.com/ArdurAI/sith/internal/fleet" + "github.com/ArdurAI/sith/internal/fleetcache" +) + +func TestBuildSharesStableTierOneRows(t *testing.T) { + t.Parallel() + now := time.Date(2026, time.July, 10, 21, 0, 0, 0, time.UTC) + tests := []struct { + lens string + record fleetcache.Record + wide bool + wantColumns []string + wantRow []string + }{ + { + lens: "pods", + record: fleetcache.Record{ + Cluster: "prod", Namespace: "apps", Name: "api-0", Ready: "0/1", Status: "CrashLoopBackOff", + Restarts: 7, Node: "node-1", Images: []string{"registry/api:v2"}, CreatedAt: now.Add(-time.Hour), Stale: true, + }, + wide: true, + wantColumns: []string{"CLUSTER", "NAMESPACE", "NAME", "READY", "STATUS", "RESTARTS", "AGE", "NODE", "IMAGE"}, + wantRow: []string{"~prod", "apps", "api-0", "0/1", "CrashLoopBackOff", "7", "1h", "node-1", "registry/api:v2"}, + }, + { + lens: "deploy", + record: fleetcache.Record{ + Cluster: "prod", Namespace: "apps", Name: "api", Ready: "2/3", Status: "Progressing", CreatedAt: now.Add(-48 * time.Hour), + }, + wantColumns: []string{"CLUSTER", "NAMESPACE", "NAME", "READY", "STATUS", "AGE"}, + wantRow: []string{"prod", "apps", "api", "2/3", "Progressing", "2d"}, + }, + { + lens: "events", + record: fleetcache.Record{ + Cluster: "prod", Namespace: "apps", Status: "Warning", Reason: "BackOff", Ready: "Pod/api-0", + Message: "container is backing off", ObservedAt: now.Add(-30 * time.Second), + }, + wantColumns: []string{"CLUSTER", "NAMESPACE", "LAST-SEEN", "TYPE", "REASON", "OBJECT", "MESSAGE"}, + wantRow: []string{"prod", "apps", "30s", "Warning", "BackOff", "Pod/api-0", "container is backing off"}, + }, + { + lens: "nodes", + record: fleetcache.Record{Cluster: "prod", Name: "node-1", Status: "Ready", CreatedAt: now.Add(-10 * time.Minute), Version: "v1.36.1"}, + wantColumns: []string{"CLUSTER", "NAME", "STATUS", "AGE", "VERSION"}, + wantRow: []string{"prod", "node-1", "Ready", "10m", "v1.36.1"}, + }, + } + for _, test := range tests { + t.Run(test.lens, func(t *testing.T) { + t.Parallel() + table := Build(fleetcache.Snapshot{Records: []fleetcache.Record{test.record}}, Options{ + Lens: test.lens, Wide: test.wide, Now: now, + }) + if !slices.Equal(table.Columns, test.wantColumns) || len(table.Rows) != 1 || !slices.Equal(table.Rows[0], test.wantRow) { + t.Fatalf("table = %#v, want columns=%v row=%v", table, test.wantColumns, test.wantRow) + } + }) + } +} + +func TestWriteTextAlwaysIncludesCoverage(t *testing.T) { + t.Parallel() + table := Table{ + Columns: []string{"CLUSTER", "NAME"}, + Rows: [][]string{{"alpha", "api"}}, + Coverage: fleet.Coverage{ + Requested: 3, Reachable: 2, Stale: []string{"beta"}, Unreachable: []string{"gamma"}, + }, + } + var output bytes.Buffer + if err := WriteText(&output, table); err != nil { + t.Fatalf("WriteText() error = %v", err) + } + for _, want := range []string{"CLUSTER", "alpha", "covered 2/3 clusters", "1 stale (beta)", "1 unreachable (gamma)"} { + if !strings.Contains(output.String(), want) { + t.Errorf("output = %q, want %q", output.String(), want) + } + } +} + +func TestWriteNamesUsesSourceAbstractIdentity(t *testing.T) { + t.Parallel() + snapshot := fleetcache.Snapshot{ + Records: []fleetcache.Record{ + {Fact: fleet.Fact{Evidence: fleet.Evidence{Ref: fleet.ResourceRef{ + SourceKind: "local-kubeconfig", Scope: "alpha", Kind: "Pod", Namespace: "apps", Name: "api-0", + }}}}, + }, + Coverage: fleet.Coverage{Requested: 1, Reachable: 1}, + } + var output bytes.Buffer + if err := WriteNames(&output, snapshot); err != nil { + t.Fatalf("WriteNames() error = %v", err) + } + if !strings.Contains(output.String(), "local-kubeconfig:alpha/Pod/apps/api-0") || !strings.Contains(output.String(), "covered 1/1") { + t.Fatalf("output = %q", output.String()) + } +} + +func TestBuildRespectsRowLimitAndGenericLens(t *testing.T) { + t.Parallel() + snapshot := fleetcache.Snapshot{Records: []fleetcache.Record{ + {Cluster: "a", Kind: "Service", Name: "one"}, + {Cluster: "b", Kind: "Service", Name: "two"}, + }} + table := Build(snapshot, Options{Lens: "Service", MaxRows: 1}) + if len(table.Rows) != 1 || !slices.Equal(table.Columns, []string{"CLUSTER", "NAMESPACE", "KIND", "NAME", "STATUS", "AGE"}) { + t.Fatalf("table = %#v", table) + } +} diff --git a/sessions/2026-07-10-slice-2-cache-first-fleet.md b/sessions/2026-07-10-slice-2-cache-first-fleet.md index 122e901..bbbd16f 100644 --- a/sessions/2026-07-10-slice-2-cache-first-fleet.md +++ b/sessions/2026-07-10-slice-2-cache-first-fleet.md @@ -31,7 +31,14 @@ incrementally while retaining them if a peer lens fails. [T] Test: Race tests prove frequency-ordered lens selection, concurrency bounds, partial-success retention, duplicate-sync exclusion, pause behavior, and constructor fail-safety. Focused lint passes at 87.2% statement coverage. -[C] Checkpoint #2: this commit — connector-isolated background hydration; next: shared renderer and CLI. +[C] Checkpoint #2: ef8f828 — connector-isolated background hydration; next: shared renderer and CLI. +[A] Action: Added the shared Tier-1 table/coverage renderer and cache-backed `get`, `search`, and +`correlate` commands. Scripted get requires an explicit context or `--all-clusters`; partial +coverage warns with exit 0 and total failure is non-zero after coverage output. +[T] Test: Renderer golden tests cover every Tier-1 lens, wide/name modes, truncation, and mandatory +coverage. CLI tests prove pre-I/O validation, JSON schema, partial/total exit semantics, image +search, and deployment-health correlation across two contexts. Focused lint and race tests pass. +[C] Checkpoint #3: this commit — shared cache renderer and scriptable fleet reads; next: Bubble Tea TUI. --- From 25c14e2be0443b7336d63ec9752c14ffe268b88a Mon Sep 17 00:00:00 2001 From: Gnani Rahul Date: Fri, 10 Jul 2026 16:21:15 -0500 Subject: [PATCH 09/14] feat(tui): add the cache-first fleet view Launch a terminal-safe Bubble Tea fleet view with Tier-1 lenses, cache-only interactions, explicit coverage, and a measured warm-render budget. GSTACK-Checkpoint: 2026-07-10/slice-2-cache-first-fleet#4 Signed-off-by: Gnani Rahul --- .github/workflows/ci.yml | 3 + Makefile | 7 +- README.md | 9 + go.mod | 19 +- go.sum | 40 +- internal/cli/root.go | 46 +- internal/fleetcache/query.go | 25 +- internal/fleetcache/store.go | 41 +- internal/tui/model.go | 473 ++++++++++++++++++ internal/tui/model_test.go | 330 ++++++++++++ internal/tui/race_disabled_test.go | 6 + internal/tui/race_enabled_test.go | 6 + .../2026-07-10-slice-2-cache-first-fleet.md | 11 +- 13 files changed, 983 insertions(+), 33 deletions(-) create mode 100644 internal/tui/model.go create mode 100644 internal/tui/model_test.go create mode 100644 internal/tui/race_disabled_test.go create mode 100644 internal/tui/race_enabled_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1adfb3b..0c6ee68 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -71,6 +71,9 @@ jobs: - name: Unit tests with race detector run: go test -race -count=1 -coverprofile=coverage.out ./... + - name: Warm-cache TUI p95 latency + run: make perf + - name: Binary integration smoke test run: go test -race -count=1 -tags=e2e ./tests/e2e diff --git a/Makefile b/Makefile index f326495..f78c818 100644 --- a/Makefile +++ b/Makefile @@ -20,7 +20,7 @@ LDFLAGS := -s -w \ -X $(PKG)/internal/buildinfo.Commit=$(COMMIT) \ -X $(PKG)/internal/buildinfo.Date=$(DATE) -.PHONY: all build test e2e e2e-kind lint vuln fmt fmt-check vet tidy clean run ci help +.PHONY: all build test perf e2e e2e-kind lint vuln fmt fmt-check vet tidy clean run ci help all: build @@ -31,6 +31,9 @@ build: ## Build the sith binary into bin/ test: ## Run unit tests with the race detector and report coverage go test -race -count=1 -coverprofile=coverage.out ./... +perf: ## Enforce the warm-cache TUI p95 latency budget without race overhead + go test -count=1 -run '^TestWarmViewP95UnderOneHundredMilliseconds$$' ./internal/tui + e2e: ## Build and exercise the real binary as a subprocess go test -race -count=1 -tags=e2e ./tests/e2e @@ -64,7 +67,7 @@ clean: ## Remove build and coverage artifacts run: build ## Build then run sith version $(BIN_DIR)/$(BINARY) version -ci: fmt-check vet lint vuln test e2e build ## Run the full CI gate locally +ci: fmt-check vet lint vuln test perf e2e build ## Run the full CI gate locally help: ## List targets @grep -E '^[a-zA-Z_-]+:.*?## ' $(MAKEFILE_LIST) | \ diff --git a/README.md b/README.md index c9a72de..5516d87 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,8 @@ Sith requires a supported Go 1.26 toolchain. ```bash make build +./bin/sith # interactive terminal: cache-first fleet view +./bin/sith tui # explicit equivalent ./bin/sith version ./bin/sith version --output json ./bin/sith clusters @@ -33,6 +35,13 @@ normalized in-memory records; partial results name stale/unreachable contexts. T persisted to disk, so raw workload specifications do not become a new plaintext credential-adjacent artifact. +The TUI opens only when stdin and stdout are terminals; redirected bare invocations remain +script-safe and print help. Tier-1 lenses are Pods, Deployments, Events, and Nodes. Use `:` for +lens/context commands, `/` to filter the current lens, `Ctrl-K` for whole-fleet fuzzy/structured +search, number keys for cluster scope, `c` for coverage, and `Ctrl-R` for a non-blocking refresh. +The UI uses Bubble Tea v2.0.8 core only; tables and search remain local so no optional styling or +component dependency enters the binary. + Run the full local quality gate with golangci-lint v2.12.2 and govulncheck v1.6.0 on `PATH`: ```bash diff --git a/go.mod b/go.mod index d4d5d13..8563baf 100644 --- a/go.mod +++ b/go.mod @@ -3,13 +3,23 @@ module github.com/ArdurAI/sith go 1.26.0 require ( + charm.land/bubbletea/v2 v2.0.8 github.com/spf13/cobra v1.10.2 go.yaml.in/yaml/v3 v3.0.4 + golang.org/x/term v0.43.0 k8s.io/apimachinery v0.36.2 k8s.io/client-go v0.36.2 ) require ( + github.com/charmbracelet/colorprofile v0.4.3 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 // indirect + github.com/charmbracelet/x/ansi v0.11.7 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/charmbracelet/x/termios v0.1.1 // indirect + github.com/charmbracelet/x/windows v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.11.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect @@ -22,17 +32,22 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect + github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/mailru/easyjson v0.7.7 // indirect + github.com/mattn/go-runewidth v0.0.23 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/muesli/cancelreader v0.2.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.9 // indirect github.com/x448/float16 v0.8.4 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect golang.org/x/net v0.55.0 // indirect golang.org/x/oauth2 v0.34.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/term v0.43.0 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/sys v0.46.0 // indirect golang.org/x/text v0.37.0 // indirect golang.org/x/time v0.14.0 // indirect google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect diff --git a/go.sum b/go.sum index f30273e..5570b0d 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,25 @@ +charm.land/bubbletea/v2 v2.0.8 h1:SxTJMhCAI3lbPmy4SgX5LWZ24AdINr4I6UEqzZvYJuY= +charm.land/bubbletea/v2 v2.0.8/go.mod h1:2SkdgoTXluXJHOUwAoRlRXF/28vklb1rFl6GcgV1/ss= +github.com/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8= +github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= +github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= +github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= +github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 h1:3FmWoGNWK4STvqg0O0Aeav2T7rodWJAPeF0QpH+8gFw= +github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7/go.mod h1:f/jRa757WUmaOZrbPspXymbg/GnbF+rwe4OLsG7aXYo= +github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= +github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= +github.com/charmbracelet/x/exp/golden v0.0.0-20241212170349-ad4b7ae0f25f h1:UytXHv0UxnsDFmL/7Z9Q5SBYPwSuRLXHbwx+6LycZ2w= +github.com/charmbracelet/x/exp/golden v0.0.0-20241212170349-ad4b7ae0f25f/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= +github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= +github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= +github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= +github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8= +github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -38,19 +60,27 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= +github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= +github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -71,16 +101,22 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= +golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw= golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= diff --git a/internal/cli/root.go b/internal/cli/root.go index ee84555..3e78f6f 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -11,12 +11,16 @@ import ( "os" "github.com/spf13/cobra" + "golang.org/x/term" "github.com/ArdurAI/sith/internal/config" "github.com/ArdurAI/sith/internal/connector" "github.com/ArdurAI/sith/internal/connector/kubeconfig" "github.com/ArdurAI/sith/internal/fleet" + "github.com/ArdurAI/sith/internal/fleetcache" + "github.com/ArdurAI/sith/internal/hydrate" "github.com/ArdurAI/sith/internal/logging" + "github.com/ArdurAI/sith/internal/tui" ) type runtimeKey struct{} @@ -34,14 +38,17 @@ type rootOptions struct { } type backend struct { - source fleet.Source - reader connector.Reader + source fleet.Source + reader connector.Reader + tuiInput io.Reader } // Execute builds and runs the command tree, returning a process exit code. func Execute() int { adapter := kubeconfig.Default() - return executeBackend(os.Args[1:], backend{source: connector.AsSource(adapter), reader: adapter}, os.Stdout, os.Stderr) + return executeBackend(os.Args[1:], backend{ + source: connector.AsSource(adapter), reader: adapter, tuiInput: os.Stdin, + }, os.Stdout, os.Stderr) } func execute(args []string, source fleet.Source, stdout, stderr io.Writer) int { @@ -74,6 +81,9 @@ func newRootCommand(runtime backend, stdout, stderr io.Writer) *cobra.Command { SilenceUsage: true, SilenceErrors: true, RunE: func(command *cobra.Command, _ []string) error { + if runtime.reader != nil && terminalIO(runtime.tuiInput, stdout) { + return runFleetTUI(command.Context(), runtime.reader, runtime.tuiInput, stdout) + } return command.Help() }, PersistentPreRunE: func(command *cobra.Command, _ []string) error { @@ -117,6 +127,7 @@ func newRootCommand(runtime backend, stdout, stderr io.Writer) *cobra.Command { } if runtime.reader != nil { commands = append(commands, + newTUICommand(runtime.reader, runtime.tuiInput, stdout), newGetCommand(options, runtime.reader), newSearchCommand(options, runtime.reader), newCorrelateCommand(options, runtime.reader), @@ -126,3 +137,32 @@ func newRootCommand(runtime backend, stdout, stderr io.Writer) *cobra.Command { return command } + +func newTUICommand(reader connector.Reader, input io.Reader, output io.Writer) *cobra.Command { + return &cobra.Command{ + Use: "tui", + Short: "Open the cache-first interactive fleet view", + Args: cobra.NoArgs, + RunE: func(command *cobra.Command, _ []string) error { + if input == nil { + return fmt.Errorf("TUI input is unavailable") + } + return runFleetTUI(command.Context(), reader, input, output) + }, + } +} + +func runFleetTUI(ctx context.Context, reader connector.Reader, input io.Reader, output io.Writer) error { + store := fleetcache.New() + hydrator, err := hydrate.New(reader, store) + if err != nil { + return err + } + return tui.Run(ctx, store, hydrator, input, output) +} + +func terminalIO(input io.Reader, output io.Writer) bool { + inputFile, inputOK := input.(*os.File) + outputFile, outputOK := output.(*os.File) + return inputOK && outputOK && term.IsTerminal(int(inputFile.Fd())) && term.IsTerminal(int(outputFile.Fd())) +} diff --git a/internal/fleetcache/query.go b/internal/fleetcache/query.go index 0c93c70..3c3c856 100644 --- a/internal/fleetcache/query.go +++ b/internal/fleetcache/query.go @@ -11,18 +11,19 @@ import ( // Query is a cache-only filter over normalized records. type Query struct { - Kind string - Name string - Namespace string - Scopes []string - Text []string - Status string - StatusNot string - Image string - Node string - Labels map[string]string - MinRestarts *int64 - Limit int + Kind string + Name string + Namespace string + Scopes []string + Text []string + Status string + StatusNot string + Image string + Node string + Labels map[string]string + MinRestarts *int64 + Limit int + MetadataOnly bool } // ParseSearch parses the composable cache-served search grammar. diff --git a/internal/fleetcache/store.go b/internal/fleetcache/store.go index ba5bef3..77368b1 100644 --- a/internal/fleetcache/store.go +++ b/internal/fleetcache/store.go @@ -31,14 +31,15 @@ const ( // Snapshot is an immutable cache-only answer for one render interaction. type Snapshot struct { - Version uint64 `json:"version"` - State State `json:"state"` - Syncing bool `json:"syncing"` - Paused bool `json:"paused"` - Records []Record `json:"records"` - Coverage fleet.Coverage `json:"coverage"` - UpdatedAt time.Time `json:"updated_at,omitempty"` - LastError string `json:"last_error,omitempty"` + Version uint64 `json:"version"` + State State `json:"state"` + Syncing bool `json:"syncing"` + Paused bool `json:"paused"` + Records []Record `json:"records"` + Coverage fleet.Coverage `json:"coverage"` + UpdatedAt time.Time `json:"updated_at,omitempty"` + LastError string `json:"last_error,omitempty"` + Scopes []connector.Scope `json:"scopes"` } // Store owns normalized last-known fleet state and never performs network I/O. @@ -197,7 +198,7 @@ func (store *Store) Query(query Query) Snapshot { continue } for _, cached := range byKey { - record := cloneRecord(cached) + record := cloneRecord(cached, !query.MetadataOnly) age := now.Sub(record.ObservedAt) if age > store.freshFor { record.Stale = true @@ -227,6 +228,7 @@ func (store *Store) Query(query Query) Snapshot { Coverage: coverage, UpdatedAt: store.updatedAt, LastError: store.lastError, + Scopes: store.scopesLocked(query.Scopes), } } @@ -330,6 +332,19 @@ func (store *Store) targetScopesLocked(patterns []string) []string { return result } +func (store *Store) scopesLocked(patterns []string) []connector.Scope { + names := store.targetScopesLocked(patterns) + result := make([]connector.Scope, 0, len(names)) + for _, name := range names { + scope, exists := store.scopes[name] + if !exists { + scope = connector.Scope{Name: name} + } + result = append(result, cloneScope(scope)) + } + return result +} + func (store *Store) stateLocked(coverage fleet.Coverage, recordCount int, pending bool) State { switch { case store.paused: @@ -379,8 +394,12 @@ func stringSet(values []string) map[string]struct{} { return result } -func cloneRecord(record Record) Record { - record.Fact = cloneFact(record.Fact) +func cloneRecord(record Record, includeEvidence bool) Record { + if includeEvidence { + record.Fact = cloneFact(record.Fact) + } else { + record.Fact = fleet.Fact{} + } record.Images = append([]string(nil), record.Images...) record.Labels = cloneMap(record.Labels) return record diff --git a/internal/tui/model.go b/internal/tui/model.go new file mode 100644 index 0000000..05f68d9 --- /dev/null +++ b/internal/tui/model.go @@ -0,0 +1,473 @@ +// SPDX-License-Identifier: Apache-2.0 + +// Package tui implements Sith's cache-first terminal fleet view. +package tui + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "strings" + "text/tabwriter" + "time" + "unicode/utf8" + + tea "charm.land/bubbletea/v2" + + "github.com/ArdurAI/sith/internal/connector" + "github.com/ArdurAI/sith/internal/fleet" + "github.com/ArdurAI/sith/internal/fleetcache" + "github.com/ArdurAI/sith/internal/fleetrender" + "github.com/ArdurAI/sith/internal/hydrate" +) + +const defaultRefreshInterval = 15 * time.Second + +type inputMode uint8 + +const ( + modeNormal inputMode = iota + modeFilter + modeCommand + modeSearch +) + +// Syncer is the narrow background-I/O seam consumed by the TUI runtime. +type Syncer interface { + SyncOnce(ctx context.Context) error +} + +// Model is a Bubble Tea model whose interaction path reads only fleetcache snapshots. +type Model struct { + ctx context.Context + store *fleetcache.Store + syncer Syncer + lenses []string + lens int + scopes []string + input string + filter string + mode inputMode + inputAll bool + filterAll bool + cursor int + width int + height int + coverage bool + version uint64 + lastError string + refresh time.Duration + now func() time.Time +} + +type syncDoneMsg struct{ err error } +type cacheChangedMsg struct { + version uint64 + err error +} +type refreshTickMsg time.Time + +// NewModel validates and constructs the cold first-paint model. +func NewModel(ctx context.Context, store *fleetcache.Store, syncer Syncer) (*Model, error) { + if ctx == nil { + return nil, fmt.Errorf("construct TUI model: context is nil") + } + if store == nil { + return nil, fmt.Errorf("construct TUI model: store is nil") + } + if syncer == nil { + return nil, fmt.Errorf("construct TUI model: syncer is nil") + } + return &Model{ + ctx: ctx, + store: store, + syncer: syncer, + lenses: hydrate.TierOneKinds(), + width: 120, + height: 30, + refresh: defaultRefreshInterval, + now: time.Now, + }, nil +} + +// Run starts the interactive alternate-screen fleet view. +func Run(ctx context.Context, store *fleetcache.Store, syncer Syncer, input io.Reader, output io.Writer) error { + model, err := NewModel(ctx, store, syncer) + if err != nil { + return err + } + program := tea.NewProgram(model, tea.WithContext(ctx), tea.WithInput(input), tea.WithOutput(output)) + if _, err := program.Run(); err != nil { + return fmt.Errorf("run fleet TUI: %w", err) + } + return nil +} + +// Init starts background hydration, store notifications, and the refresh clock independently. +func (model *Model) Init() tea.Cmd { + return tea.Batch(model.syncCommand(), model.waitCommand(model.version), model.tickCommand()) +} + +// Update handles interaction entirely against cache state; only explicit sync commands call I/O. +func (model *Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { + switch typed := message.(type) { + case tea.WindowSizeMsg: + model.width = max(typed.Width, 40) + model.height = max(typed.Height, 10) + case cacheChangedMsg: + if typed.err == nil { + model.version = typed.version + model.clampCursor() + return model, model.waitCommand(model.version) + } + case syncDoneMsg: + if typed.err != nil && !errors.Is(typed.err, hydrate.ErrPaused) && !errors.Is(typed.err, hydrate.ErrSyncInProgress) { + model.lastError = typed.err.Error() + } else if typed.err == nil { + model.lastError = "" + } + case refreshTickMsg: + return model, tea.Batch(model.syncCommand(), model.tickCommand()) + case tea.KeyPressMsg: + return model.handleKey(typed) + } + return model, nil +} + +// View renders an alternate-screen frame from immutable cache snapshots only. +func (model *Model) View() tea.View { + snapshot := model.snapshot() + allSnapshot := model.store.Query(fleetcache.Query{Kind: model.currentLens(), MetadataOnly: true}) + allScopes := allSnapshot.Scopes + renderLens := model.currentLens() + if model.filterAll || (model.mode == modeSearch && model.inputAll) { + renderLens = "Search" + } + table := fleetrender.Build(snapshot, fleetrender.Options{ + Lens: renderLens, MaxRows: model.maxRows(), Now: model.now().UTC(), + }) + var content strings.Builder + fmt.Fprintf(&content, "sith ⎈ fleet: %s scope: %s lens: %s%s\n", + fleetSummary(allScopes, allSnapshot.Coverage), model.scopeLabel(), model.currentLens(), syncGlyph(snapshot.Syncing)) + content.WriteString(contextStrip(allScopes)) + content.WriteString("\n") + fmt.Fprintf(&content, "%s · %s · filter:%s\n", model.currentLens(), model.scopeLabel(), model.filterLabel()) + content.WriteString(model.renderRows(table)) + switch snapshot.State { + case fleetcache.StateOffline: + content.WriteString("\noffline — showing last-known fleet data\n") + case fleetcache.StatePaused: + content.WriteString("\nPAUSED — data frozen\n") + case fleetcache.StateCold, fleetcache.StateWarming: + content.WriteString("\nwarming contexts — ready cache rows render immediately\n") + } + if model.coverage { + content.WriteString("\nCOVERAGE\n") + for _, scope := range snapshot.Scopes { + status := "unreachable" + if scope.Reachable { + status = "reachable" + } + fmt.Fprintf(&content, " %-24s %s last=%s\n", scope.Name, status, ageLabel(model.now, scope.ObservedAt)) + } + } + content.WriteString("\n") + content.WriteString(fleetrender.CoverageLine(snapshot.Coverage)) + content.WriteString(" [c]overage [/]filter [:]cmd [ctrl-k]search [ctrl-r]refresh [q]quit\n") + if prompt := model.prompt(); prompt != "" { + content.WriteString(prompt) + content.WriteString("\n") + } + if model.lastError != "" { + content.WriteString("warning: ") + content.WriteString(model.lastError) + content.WriteString("\n") + } + view := tea.NewView(limitWidth(content.String(), model.width)) + view.AltScreen = true + view.WindowTitle = "sith fleet" + return view +} + +func (model *Model) handleKey(message tea.KeyPressMsg) (tea.Model, tea.Cmd) { + key := message.String() + if model.mode != modeNormal { + return model.handleInput(message) + } + switch key { + case "q", "ctrl+c": + return model, tea.Quit + case ":": + model.mode, model.input = modeCommand, "" + case "/": + model.mode, model.input, model.inputAll = modeFilter, model.filter, false + case "ctrl+k": + model.mode, model.input, model.inputAll = modeSearch, model.filter, true + case "c": + model.coverage = !model.coverage + case "ctrl+r": + return model, model.syncCommand() + case "up", "k": + model.cursor = max(0, model.cursor-1) + case "down", "j": + model.cursor++ + model.clampCursor() + case "0": + model.scopes = nil + case "esc": + model.filter, model.filterAll = "", false + default: + if len(key) == 1 && key[0] >= '1' && key[0] <= '9' { + model.selectScope(int(key[0] - '1')) + } + } + return model, nil +} + +func (model *Model) handleInput(message tea.KeyPressMsg) (tea.Model, tea.Cmd) { + switch message.String() { + case "esc": + model.mode, model.input, model.inputAll = modeNormal, "", false + return model, nil + case "enter": + if model.mode == modeCommand { + return model.applyCommand() + } + model.filter, model.filterAll = model.input, model.inputAll + model.mode, model.input, model.inputAll = modeNormal, "", false + return model, nil + case "backspace": + model.input = trimLastRune(model.input) + model.cursor = 0 + return model, nil + } + if text := message.Key().Text; text != "" && message.Key().Mod == 0 { + model.input += text + model.cursor = 0 + } + return model, nil +} + +func (model *Model) applyCommand() (tea.Model, tea.Cmd) { + command := strings.TrimSpace(model.input) + model.mode, model.input, model.inputAll = modeNormal, "", false + fields := strings.Fields(command) + if len(fields) == 0 { + return model, nil + } + switch strings.ToLower(fields[0]) { + case "q", "quit": + return model, tea.Quit + case "pause": + model.store.SetPaused(true) + case "resume": + model.store.SetPaused(false) + return model, model.syncCommand() + case "refresh": + return model, model.syncCommand() + case "ctx", "context": + if len(fields) != 2 { + model.lastError = "usage: :ctx " + } else { + model.scopes = []string{fields[1]} + } + default: + if !model.setLens(fields[0]) { + model.lastError = "unknown command: " + fields[0] + } else { + model.lastError = "" + } + } + return model, nil +} + +func (model *Model) snapshot() fleetcache.Snapshot { + query := fleetcache.Query{Kind: model.currentLens(), Scopes: append([]string(nil), model.scopes...), MetadataOnly: true} + expression := model.filter + allKinds := model.filterAll + if model.mode == modeFilter || model.mode == modeSearch { + expression = model.input + allKinds = model.inputAll + } + if allKinds { + query.Kind = "" + } + if expression != "" { + parsed, err := fleetcache.ParseSearch(expression) + if err == nil { + if !allKinds { + parsed.Kind = query.Kind + } + parsed.Scopes = query.Scopes + parsed.MetadataOnly = true + query = parsed + } else { + query.Text = []string{strings.ToLower(expression)} + } + } + return model.store.Query(query) +} + +func (model *Model) renderRows(table fleetrender.Table) string { + var rendered bytes.Buffer + tabular := tabwriter.NewWriter(&rendered, 0, 3, 2, ' ', 0) + _, _ = fmt.Fprintln(tabular, " \t"+strings.Join(table.Columns, "\t")) + for index, row := range table.Rows { + cursor := " " + if index == model.cursor { + cursor = ">" + } + _, _ = fmt.Fprintln(tabular, cursor+" \t"+strings.Join(row, "\t")) + } + if len(table.Rows) == 0 { + _, _ = fmt.Fprintln(tabular, " \t— no cached matches —") + } + _ = tabular.Flush() + return rendered.String() +} + +func (model *Model) syncCommand() tea.Cmd { + return func() tea.Msg { return syncDoneMsg{err: model.syncer.SyncOnce(model.ctx)} } +} + +func (model *Model) waitCommand(after uint64) tea.Cmd { + return func() tea.Msg { + version, err := model.store.WaitForChange(model.ctx, after) + return cacheChangedMsg{version: version, err: err} + } +} + +func (model *Model) tickCommand() tea.Cmd { + return tea.Tick(model.refresh, func(value time.Time) tea.Msg { return refreshTickMsg(value) }) +} + +func (model *Model) currentLens() string { + return model.lenses[model.lens] +} + +func (model *Model) setLens(value string) bool { + for index, lens := range model.lenses { + if strings.HasPrefix(strings.ToLower(lens), strings.ToLower(value)) || + strings.HasPrefix(strings.ToLower(value), strings.ToLower(lens)) { + model.lens = index + model.cursor = 0 + model.filterAll = false + return true + } + } + return false +} + +func (model *Model) clampCursor() { + records := model.snapshot().Records + if len(records) == 0 { + model.cursor = 0 + } else if model.cursor >= len(records) { + model.cursor = len(records) - 1 + } +} + +func (model *Model) selectScope(index int) { + scopes := model.store.Query(fleetcache.Query{MetadataOnly: true}).Scopes + if index >= 0 && index < len(scopes) { + model.scopes = []string{scopes[index].Name} + model.cursor = 0 + } +} + +func (model *Model) scopeLabel() string { + if len(model.scopes) == 0 { + return "all-clusters" + } + return strings.Join(model.scopes, ",") +} + +func (model *Model) maxRows() int { + rows := model.height - 9 + if model.coverage { + rows -= 5 + } + return max(rows, 1) +} + +func (model *Model) prompt() string { + switch model.mode { + case modeFilter: + return "/" + model.input + case modeCommand: + return ":" + model.input + case modeSearch: + return "search> " + model.input + default: + return "" + } +} + +func fleetSummary(scopes []connector.Scope, coverage fleet.Coverage) string { + stale := len(coverage.Stale) + unreachable := len(coverage.Unreachable) + fresh := max(coverage.Reachable-stale, 0) + return fmt.Sprintf("%d ctx (%d✓ %d~ %d✗)", len(scopes), fresh, stale, unreachable) +} + +func contextStrip(scopes []connector.Scope) string { + parts := []string{"contexts: [0] all"} + for index, scope := range scopes { + if index == 9 { + parts = append(parts, "…") + break + } + parts = append(parts, fmt.Sprintf("[%d] %s", index+1, scope.Name)) + } + return strings.Join(parts, " ") +} + +func syncGlyph(syncing bool) string { + if syncing { + return " ⟳" + } + return "" +} + +func (model *Model) filterLabel() string { + value := model.filter + if model.mode == modeFilter || model.mode == modeSearch { + value = model.input + } + if value == "" { + return "(none)" + } + return value +} + +func ageLabel(now func() time.Time, observed time.Time) string { + if observed.IsZero() { + return "never" + } + age := now().Sub(observed) + if age < time.Minute { + return fmt.Sprintf("%ds", int(age.Seconds())) + } + return fmt.Sprintf("%dm", int(age.Minutes())) +} + +func trimLastRune(value string) string { + if value == "" { + return "" + } + _, size := utf8.DecodeLastRuneInString(value) + return value[:len(value)-size] +} + +func limitWidth(value string, width int) string { + lines := strings.Split(value, "\n") + for index, line := range lines { + runes := []rune(line) + if len(runes) > width { + lines[index] = string(runes[:width]) + } + } + return strings.Join(lines, "\n") +} diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go new file mode 100644 index 0000000..e6d7c0f --- /dev/null +++ b/internal/tui/model_test.go @@ -0,0 +1,330 @@ +// SPDX-License-Identifier: Apache-2.0 + +package tui + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "slices" + "strings" + "sync/atomic" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + + "github.com/ArdurAI/sith/internal/connector" + "github.com/ArdurAI/sith/internal/fleet" + "github.com/ArdurAI/sith/internal/fleetcache" + "github.com/ArdurAI/sith/internal/fleetrender" +) + +func TestNewModelValidatesDependencies(t *testing.T) { + t.Parallel() + syncer := &countingSyncer{} + store := fleetcache.New() + if _, err := NewModel(nil, store, syncer); err == nil { //nolint:staticcheck // nil is the invalid input under test. + t.Fatal("NewModel(nil context) error = nil") + } + if _, err := NewModel(context.Background(), nil, syncer); err == nil { + t.Fatal("NewModel(nil store) error = nil") + } + if _, err := NewModel(context.Background(), store, nil); err == nil { + t.Fatal("NewModel(nil syncer) error = nil") + } +} + +func TestColdFirstPaintAndInteractionsDoNotCallSyncer(t *testing.T) { + t.Parallel() + syncer := &countingSyncer{} + model, err := NewModel(context.Background(), fleetcache.New(), syncer) + if err != nil { + t.Fatalf("NewModel() error = %v", err) + } + started := time.Now() + view := model.View() + if elapsed := time.Since(started); elapsed > 250*time.Millisecond { + t.Fatalf("cold View() took %s, want <250ms", elapsed) + } + if !strings.Contains(view.Content, "warming contexts") || !view.AltScreen { + t.Fatalf("cold view = %q", view.Content) + } + + _, _ = model.Update(keyMessage("/")) + _, _ = model.Update(keyMessage("payments")) + _, _ = model.Update(specialKey(tea.KeyEnter)) + _, _ = model.Update(tea.WindowSizeMsg{Width: 80, Height: 20}) + _ = model.View() + if syncer.calls.Load() != 0 { + t.Fatalf("sync calls = %d, want no I/O on interaction path", syncer.calls.Load()) + } +} + +func TestModelNavigationFilterPauseAndCoverage(t *testing.T) { + t.Parallel() + store := populatedStore(t, 2) + syncer := &countingSyncer{} + model, err := NewModel(context.Background(), store, syncer) + if err != nil { + t.Fatalf("NewModel() error = %v", err) + } + model.now = func() time.Time { return time.Date(2026, time.July, 10, 21, 0, 0, 0, time.UTC) } + + _, _ = model.Update(keyMessage(":")) + _, _ = model.Update(keyMessage("deploy")) + _, _ = model.Update(specialKey(tea.KeyEnter)) + if model.currentLens() != "Deployment" { + t.Fatalf("lens = %q", model.currentLens()) + } + _, _ = model.Update(keyMessage("2")) + if !slices.Equal(model.scopes, []string{"beta"}) { + t.Fatalf("scopes = %v, want beta", model.scopes) + } + _, _ = model.Update(keyMessage("c")) + if !strings.Contains(model.View().Content, "COVERAGE") { + t.Fatal("coverage popover missing") + } + + _, _ = model.Update(keyMessage(":")) + _, _ = model.Update(keyMessage("pause")) + _, _ = model.Update(specialKey(tea.KeyEnter)) + if !store.Paused() || !strings.Contains(strings.ToLower(model.View().Content), "paused") { + t.Fatalf("paused state/view = %v/%q", store.Paused(), model.View().Content) + } + _, command := model.Update(keyMessage("ctrl+r")) + if command == nil { + t.Fatal("ctrl-r command = nil") + } + message := command() + if _, ok := message.(syncDoneMsg); !ok || syncer.calls.Load() != 1 { + t.Fatalf("refresh message/calls = %#v/%d", message, syncer.calls.Load()) + } +} + +func TestFuzzyFleetSearchSpansLensesAndCanBeCleared(t *testing.T) { + t.Parallel() + model, err := NewModel(context.Background(), populatedStore(t, 2), &countingSyncer{}) + if err != nil { + t.Fatalf("NewModel() error = %v", err) + } + _, _ = model.Update(keyMessage("ctrl+k")) + _, _ = model.Update(keyMessage("status:Degraded")) + if snapshot := model.snapshot(); len(snapshot.Records) != 1 || snapshot.Records[0].Kind != "Deployment" { + t.Fatalf("live search snapshot = %#v", snapshot) + } + _, _ = model.Update(specialKey(tea.KeyEnter)) + if !strings.Contains(model.View().Content, "KIND") || !strings.Contains(model.View().Content, "payments") { + t.Fatalf("search view = %q", model.View().Content) + } + _, _ = model.Update(specialKey(tea.KeyEscape)) + if model.filter != "" || model.filterAll { + t.Fatalf("filter after esc = %q/%t", model.filter, model.filterAll) + } +} + +func TestWarmViewP95UnderOneHundredMilliseconds(t *testing.T) { + if testing.Short() { + t.Skip("performance acceptance test") + } + store := populatedStore(t, 3000) + model, err := NewModel(context.Background(), store, &countingSyncer{}) + if err != nil { + t.Fatalf("NewModel() error = %v", err) + } + model.height = 30 + durations := make([]time.Duration, 40) + for index := range durations { + started := time.Now() + _ = model.View() + durations[index] = time.Since(started) + } + slices.Sort(durations) + p95 := durations[37] + budget := 100 * time.Millisecond + if raceDetectorEnabled { + budget = 250 * time.Millisecond + } + if p95 >= budget { + t.Fatalf("warm View() p95 = %s, want <%s (samples=%v)", p95, budget, durations) + } +} + +func TestTUIAndCLIBuildIdenticalSharedTable(t *testing.T) { + t.Parallel() + store := populatedStore(t, 2) + model, err := NewModel(context.Background(), store, &countingSyncer{}) + if err != nil { + t.Fatalf("NewModel() error = %v", err) + } + snapshot := model.snapshot() + fromTUI := fleetrender.Build(snapshot, fleetrender.Options{Lens: model.currentLens(), MaxRows: model.maxRows()}) + fromCLI := fleetrender.Build(store.Query(fleetcache.Query{Kind: "Pod"}), fleetrender.Options{Lens: "Pod", MaxRows: model.maxRows()}) + if !slices.EqualFunc(fromTUI.Rows, fromCLI.Rows, func(left, right []string) bool { return slices.Equal(left, right) }) || + !slices.Equal(fromTUI.Columns, fromCLI.Columns) { + t.Fatalf("TUI table = %#v, CLI table = %#v", fromTUI, fromCLI) + } +} + +func TestUpdateHandlesBackgroundMessagesAndCommands(t *testing.T) { + t.Parallel() + store := populatedStore(t, 4) + syncer := &countingSyncer{} + model, err := NewModel(context.Background(), store, syncer) + if err != nil { + t.Fatalf("NewModel() error = %v", err) + } + if model.Init() == nil { + t.Fatal("Init() command = nil") + } + _, command := model.Update(cacheChangedMsg{version: store.Query(fleetcache.Query{}).Version}) + if command == nil { + t.Fatal("cache change did not resubscribe") + } + _, _ = model.Update(syncDoneMsg{err: errors.New("sync broke")}) + if !strings.Contains(model.View().Content, "sync broke") { + t.Fatal("sync error missing from view") + } + _, _ = model.Update(syncDoneMsg{}) + if model.lastError != "" { + t.Fatalf("last error = %q, want cleared", model.lastError) + } + _, command = model.Update(refreshTickMsg(time.Now())) + if command == nil { + t.Fatal("refresh tick command = nil") + } + + for _, commandText := range []string{"ctx", "unknown", "ctx alpha", "refresh", "resume"} { + _, _ = model.Update(keyMessage(":")) + _, _ = model.Update(keyMessage(commandText)) + _, _ = model.Update(specialKey(tea.KeyEnter)) + } + if !slices.Equal(model.scopes, []string{"alpha"}) { + t.Fatalf("scopes = %v", model.scopes) + } + _, _ = model.Update(keyMessage("down")) + _, _ = model.Update(keyMessage("up")) + _, _ = model.Update(keyMessage("0")) + if len(model.scopes) != 0 { + t.Fatalf("scopes = %v, want all", model.scopes) + } + _, _ = model.Update(keyMessage(":")) + _, _ = model.Update(keyMessage("quit")) + _, command = model.Update(specialKey(tea.KeyEnter)) + if command == nil { + t.Fatal(":quit command = nil") + } +} + +func TestRenderHelpersHandleUnicodeAndBounds(t *testing.T) { + t.Parallel() + if got := trimLastRune("fleet✓"); got != "fleet" { + t.Fatalf("trimLastRune() = %q", got) + } + if got := limitWidth("abcdef\nxy", 3); got != "abc\nxy" { + t.Fatalf("limitWidth() = %q", got) + } + if got := ageLabel(time.Now, time.Time{}); got != "never" { + t.Fatalf("ageLabel(zero) = %q", got) + } + if got := contextStrip([]connector.Scope{{Name: "a"}, {Name: "b"}}); !strings.Contains(got, "[2] b") { + t.Fatalf("contextStrip() = %q", got) + } + if got := fleetSummary([]connector.Scope{{Name: "a"}, {Name: "b"}}, fleet.Coverage{ + Requested: 2, Reachable: 1, Stale: []string{"a"}, Unreachable: []string{"b"}, + }); got != "2 ctx (0✓ 1~ 1✗)" { + t.Fatalf("fleetSummary() = %q", got) + } +} + +func TestRunHonorsCanceledContext(t *testing.T) { + t.Parallel() + ctx, cancel := context.WithCancel(context.Background()) + cancel() + err := Run(ctx, fleetcache.New(), &countingSyncer{}, strings.NewReader("q"), io.Discard) + if err == nil { + t.Fatal("Run(canceled) error = nil") + } +} + +type countingSyncer struct{ calls atomic.Int64 } + +func (syncer *countingSyncer) SyncOnce(_ context.Context) error { + syncer.calls.Add(1) + return nil +} + +func populatedStore(t *testing.T, pods int) *fleetcache.Store { + t.Helper() + now := time.Date(2026, time.July, 10, 21, 0, 0, 0, time.UTC) + store := fleetcache.New() + store.SetDiscovery(connector.Discovery{Scopes: []connector.Scope{ + {Name: "alpha", Reachable: true, ObservedAt: now}, + {Name: "beta", Reachable: true, ObservedAt: now}, + }}) + podFacts := make([]fleet.Fact, 0, pods) + for index := range pods { + scope := "alpha" + if index%2 == 1 { + scope = "beta" + } + podFacts = append(podFacts, tuiFact("Pod", scope, fmt.Sprintf("pod-%04d", index), map[string]any{ + "spec": map[string]any{"containers": []any{map[string]any{"image": "registry/api:v1"}}}, + "status": map[string]any{ + "phase": "Running", + "containerStatuses": []any{map[string]any{ + "ready": true, "restartCount": 0, "state": map[string]any{}, + }}, + }, + }, now)) + } + if err := store.Replace("Pod", fleet.QueryResult{Facts: podFacts, Coverage: fleet.Coverage{Requested: 2, Reachable: 2}}); err != nil { + t.Fatalf("Replace(Pod) error = %v", err) + } + deployFacts := []fleet.Fact{ + tuiFact("Deployment", "alpha", "payments", map[string]any{ + "spec": map[string]any{"replicas": 3}, "status": map[string]any{"availableReplicas": 3, "updatedReplicas": 3}, + }, now), + tuiFact("Deployment", "beta", "payments", map[string]any{ + "spec": map[string]any{"replicas": 3}, "status": map[string]any{"availableReplicas": 0, "updatedReplicas": 0}, + }, now), + } + if err := store.Replace("Deployment", fleet.QueryResult{Facts: deployFacts, Coverage: fleet.Coverage{Requested: 2, Reachable: 2}}); err != nil { + t.Fatalf("Replace(Deployment) error = %v", err) + } + return store +} + +func tuiFact(kind, scope, name string, body map[string]any, observed time.Time) fleet.Fact { + body["apiVersion"] = "v1" + body["kind"] = kind + body["metadata"] = map[string]any{ + "name": name, "namespace": "apps", "creationTimestamp": observed.Add(-time.Hour).Format(time.RFC3339), + } + payload, _ := json.Marshal(body) + return fleet.Fact{Evidence: fleet.Evidence{ + Ref: fleet.ResourceRef{SourceKind: "test", Scope: scope, Kind: kind, Namespace: "apps", Name: name}, + Kind: fleet.FactInventory, Observed: payload, ObservedAt: observed, Source: scope, + }, Workspace: fleet.LocalWorkspace} +} + +func keyMessage(text string) tea.KeyPressMsg { + if text == "ctrl+r" { + return tea.KeyPressMsg(tea.Key{Code: 'r', Mod: tea.ModCtrl}) + } + if text == "ctrl+k" { + return tea.KeyPressMsg(tea.Key{Code: 'k', Mod: tea.ModCtrl}) + } + runes := []rune(text) + code := rune(0) + if len(runes) == 1 { + code = runes[0] + } + return tea.KeyPressMsg(tea.Key{Code: code, Text: text}) +} + +func specialKey(code rune) tea.KeyPressMsg { + return tea.KeyPressMsg(tea.Key{Code: code}) +} diff --git a/internal/tui/race_disabled_test.go b/internal/tui/race_disabled_test.go new file mode 100644 index 0000000..31764be --- /dev/null +++ b/internal/tui/race_disabled_test.go @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: Apache-2.0 +//go:build !race + +package tui + +const raceDetectorEnabled = false diff --git a/internal/tui/race_enabled_test.go b/internal/tui/race_enabled_test.go new file mode 100644 index 0000000..3f0ebbf --- /dev/null +++ b/internal/tui/race_enabled_test.go @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: Apache-2.0 +//go:build race + +package tui + +const raceDetectorEnabled = true diff --git a/sessions/2026-07-10-slice-2-cache-first-fleet.md b/sessions/2026-07-10-slice-2-cache-first-fleet.md index bbbd16f..cdb6cf8 100644 --- a/sessions/2026-07-10-slice-2-cache-first-fleet.md +++ b/sessions/2026-07-10-slice-2-cache-first-fleet.md @@ -38,7 +38,16 @@ coverage warns with exit 0 and total failure is non-zero after coverage output. [T] Test: Renderer golden tests cover every Tier-1 lens, wide/name modes, truncation, and mandatory coverage. CLI tests prove pre-I/O validation, JSON schema, partial/total exit semantics, image search, and deployment-health correlation across two contexts. Focused lint and race tests pass. -[C] Checkpoint #3: this commit — shared cache renderer and scriptable fleet reads; next: Bubble Tea TUI. +[C] Checkpoint #3: 3227387 — shared cache renderer and scriptable fleet reads; next: Bubble Tea TUI. +[A] Action: Added the Bubble Tea v2.0.8 cache-first TUI. Bare terminal launches enter the fleet +view while redirected invocations remain help-only; `sith tui` is the explicit entrypoint. The +view supports Tier-1 lens commands, live filter, whole-fleet structured/fuzzy search, numeric +cluster scopes, pause/resume, async refresh, navigation, and coverage detail. +[T] Test: Model tests prove cold first paint under 250 ms, no syncer calls on interactions, +incremental/background message handling, pause/coverage/scope/search behavior, CLI/TUI table +parity, Unicode/bounds safety, and cancellation. TUI coverage is 87.7%; a dedicated non-race CI +gate measures 3,000 cached pods at p95 <100 ms while race tests validate concurrency separately. +[C] Checkpoint #4: this commit — interactive cache-first fleet view; next: real two-cluster parity and staleness proof. --- From 399b3bd3ae39e65b598c3e40c6e92844bee57b17 Mon Sep 17 00:00:00 2001 From: Gnani Rahul Date: Fri, 10 Jul 2026 16:26:54 -0500 Subject: [PATCH 10/14] test(cache): prove real fleet parity and staleness Exercise cache-backed reads and correlations over two live clusters, then retain stale last-known rows when one cluster disappears. GSTACK-Checkpoint: 2026-07-10/slice-2-cache-first-fleet#5 Signed-off-by: Gnani Rahul --- internal/cli/cached_test.go | 12 ++ internal/fleetcache/store.go | 10 ++ .../2026-07-10-slice-2-cache-first-fleet.md | 12 +- tests/e2e/kind_fanout_test.go | 162 ++++++++++++++++++ 4 files changed, 195 insertions(+), 1 deletion(-) diff --git a/internal/cli/cached_test.go b/internal/cli/cached_test.go index 1af1dee..208b59a 100644 --- a/internal/cli/cached_test.go +++ b/internal/cli/cached_test.go @@ -89,6 +89,18 @@ func TestGetTotalFailureIsNonZeroAfterCoverageOutput(t *testing.T) { } } +func TestNonTerminalRootStaysScriptSafe(t *testing.T) { + reader := &cacheReader{} + stdout, stderr, exitCode := runCLIWithReader(t, nil, reader) + if exitCode != 0 || !strings.Contains(stdout, "Usage:") || stderr != "" { + t.Fatalf("root exit/stdout/stderr = %d/%q/%q", exitCode, stdout, stderr) + } + _, stderr, exitCode = runCLIWithReader(t, []string{"tui"}, reader) + if exitCode == 0 || !strings.Contains(stderr, "TUI input is unavailable") { + t.Fatalf("tui exit/stderr = %d/%q", exitCode, stderr) + } +} + func runCLIWithReader(t *testing.T, args []string, reader connector.Reader) (stdout, stderr string, exitCode int) { t.Helper() t.Setenv("XDG_CONFIG_HOME", t.TempDir()) diff --git a/internal/fleetcache/store.go b/internal/fleetcache/store.go index 77368b1..1d3c581 100644 --- a/internal/fleetcache/store.go +++ b/internal/fleetcache/store.go @@ -218,6 +218,16 @@ func (store *Store) Query(query Query) Snapshot { records = records[:query.Limit] } coverage := store.coverageLocked(query, records, now) + unreachable := stringSet(coverage.Unreachable) + for index := range records { + if _, failed := unreachable[records[index].Cluster]; failed { + records[index].Stale = true + records[index].Fact.Stale = true + if records[index].Fact.StaleFor == "" { + records[index].Fact.StaleFor = "unreachable" + } + } + } pending := canonicalKind(query.Kind) != "" && !store.warmed[canonicalKind(query.Kind)] return Snapshot{ Version: store.version, diff --git a/sessions/2026-07-10-slice-2-cache-first-fleet.md b/sessions/2026-07-10-slice-2-cache-first-fleet.md index cdb6cf8..a5ab845 100644 --- a/sessions/2026-07-10-slice-2-cache-first-fleet.md +++ b/sessions/2026-07-10-slice-2-cache-first-fleet.md @@ -47,7 +47,17 @@ cluster scopes, pause/resume, async refresh, navigation, and coverage detail. incremental/background message handling, pause/coverage/scope/search behavior, CLI/TUI table parity, Unicode/bounds safety, and cancellation. TUI coverage is 87.7%; a dedicated non-race CI gate measures 3,000 cached pods at p95 <100 ms while race tests validate concurrency separately. -[C] Checkpoint #4: this commit — interactive cache-first fleet view; next: real two-cluster parity and staleness proof. +[C] Checkpoint #4: 25c14e2 — interactive cache-first fleet view; next: real two-cluster parity and staleness proof. +[A] Action: Expanded the digest-pinned kind gate to seed deterministic Pods and Deployments in two +clusters, exercise built-binary cache commands, and then delete one previously live cluster during +the same in-memory session. +[T] Test: Race-enabled real APIs prove get/search/correlate answers over 2/3 contexts, partial +warning/JSON coverage, image and unhealthy-deployment correctness, CLI/cache parity, and immediate +last-known stale retention after the second cluster disappears. The gate passes in 68 seconds. +[R] Review: The regular GitHub security audit reports zero open Dependabot alerts. Code scanning +has no analysis configured and secret scanning is disabled; schedule a narrow post-slice security +lane for CodeQL and repository secret-scanning/push-protection enablement. Local govulncheck is clean. +[C] Checkpoint #5: this commit — real cache/search/staleness proof; next: generic resource lens and final review. --- diff --git a/tests/e2e/kind_fanout_test.go b/tests/e2e/kind_fanout_test.go index 54ed6ef..35a186d 100644 --- a/tests/e2e/kind_fanout_test.go +++ b/tests/e2e/kind_fanout_test.go @@ -4,6 +4,7 @@ package e2e_test import ( + "bytes" "context" "encoding/json" "fmt" @@ -15,11 +16,17 @@ import ( "testing" "time" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" "k8s.io/client-go/tools/clientcmd" clientcmdapi "k8s.io/client-go/tools/clientcmd/api" "github.com/ArdurAI/sith/internal/connector/kubeconfig" "github.com/ArdurAI/sith/internal/fleet" + "github.com/ArdurAI/sith/internal/fleetcache" + "github.com/ArdurAI/sith/internal/hydrate" ) const defaultKindNodeImage = "kindest/node:v1.36.1@sha256:3489c7674813ba5d8b1a9977baea8a6e553784dab7b84759d1014dbd78f7ebd5" @@ -62,6 +69,7 @@ func TestKindFleetFanout(t *testing.T) { } kubeconfigPath := mergedKindKubeconfig(ctx, t, kindBinary, clusterNames) + seedKindResources(ctx, t, kubeconfigPath, clusterNames) adapter, err := kubeconfig.New( kubeconfig.WithExplicitPath(kubeconfigPath), kubeconfig.WithProbeTimeout(5*time.Second), @@ -126,6 +134,160 @@ func TestKindFleetFanout(t *testing.T) { fleetResult.Coverage.Reachable != 2 || !slices.Equal(fleetResult.Coverage.Unreachable, []string{deadContext}) { t.Fatalf("sith clusters = %#v, want two live and one unreachable context", fleetResult) } + + getOutput, getStderr, err := runSith(ctx, binary, kubeconfigPath, "get", "pods", "-A", "--all-clusters", "--output", "json") + if err != nil { + t.Fatalf("run sith get against kind: %v\nstdout=%s\nstderr=%s", err, getOutput, getStderr) + } + var getSnapshot fleetcache.Snapshot + if err := json.Unmarshal(getOutput, &getSnapshot); err != nil { + t.Fatalf("decode sith get output %q: %v", getOutput, err) + } + if getSnapshot.Coverage.Requested != 3 || getSnapshot.Coverage.Reachable != 2 || + !slices.Equal(getSnapshot.Coverage.Unreachable, []string{deadContext}) { + t.Fatalf("get coverage = %#v, want two of three", getSnapshot.Coverage) + } + assertCachedRecord(t, getSnapshot, "kind-"+clusterNames[0], "sith-vuln-sample", false) + assertCachedRecord(t, getSnapshot, "kind-"+clusterNames[1], "sith-worker-sample", false) + if !strings.Contains(getStderr, "warning: covered 2/3 clusters") { + t.Fatalf("get stderr = %q, want partial coverage warning", getStderr) + } + + searchOutput, searchStderr, err := runSith(ctx, binary, kubeconfigPath, "search", "image:*log4j*", "--output", "json") + if err != nil { + t.Fatalf("run sith search against kind: %v\nstdout=%s\nstderr=%s", err, searchOutput, searchStderr) + } + var searchSnapshot fleetcache.Snapshot + if err := json.Unmarshal(searchOutput, &searchSnapshot); err != nil { + t.Fatalf("decode sith search output: %v", err) + } + if len(searchSnapshot.Records) != 1 || searchSnapshot.Records[0].Name != "sith-vuln-sample" || + searchSnapshot.Records[0].Cluster != "kind-"+clusterNames[0] { + t.Fatalf("search records = %#v", searchSnapshot.Records) + } + + correlateOutput, correlateStderr, err := runSith( + ctx, binary, kubeconfigPath, "correlate", "deploy/sith-payments", "status!=Healthy", "--output", "json", + ) + if err != nil { + t.Fatalf("run sith correlate against kind: %v\nstdout=%s\nstderr=%s", err, correlateOutput, correlateStderr) + } + var correlateSnapshot fleetcache.Snapshot + if err := json.Unmarshal(correlateOutput, &correlateSnapshot); err != nil { + t.Fatalf("decode sith correlate output: %v", err) + } + if len(correlateSnapshot.Records) != 1 || correlateSnapshot.Records[0].Cluster != "kind-"+clusterNames[1] || + correlateSnapshot.Records[0].Status == "Healthy" { + t.Fatalf("correlation records = %#v, want unhealthy beta deployment", correlateSnapshot.Records) + } + + store := fleetcache.New() + hydrator, err := hydrate.New(adapter, store) + if err != nil { + t.Fatalf("construct real hydrator: %v", err) + } + if err := hydrator.SyncOnce(ctx); err != nil { + t.Fatalf("initial real hydration: %v", err) + } + initialCache := store.Query(fleetcache.Query{Kind: "Pod"}) + assertCachedRecord(t, initialCache, "kind-"+clusterNames[1], "sith-worker-sample", false) + + runCommand(ctx, t, "", kindBinary, "delete", "cluster", "--name", clusterNames[1]) + created = created[:1] + if err := hydrator.SyncOnce(ctx); err != nil { + t.Fatalf("degraded real hydration: %v", err) + } + degraded := store.Query(fleetcache.Query{Kind: "Pod"}) + assertCachedRecord(t, degraded, "kind-"+clusterNames[1], "sith-worker-sample", true) + if degraded.Coverage.Reachable != 1 || !slices.Contains(degraded.Coverage.Unreachable, "kind-"+clusterNames[1]) || + !slices.Contains(degraded.Coverage.Unreachable, deadContext) { + t.Fatalf("degraded coverage = %#v, want beta and dead context unreachable", degraded.Coverage) + } +} + +func seedKindResources(ctx context.Context, t *testing.T, kubeconfigPath string, clusters []string) { + t.Helper() + rawConfig, err := clientcmd.LoadFromFile(kubeconfigPath) + if err != nil { + t.Fatalf("load merged kubeconfig: %v", err) + } + for index, cluster := range clusters { + contextName := "kind-" + cluster + clientConfig := clientcmd.NewNonInteractiveClientConfig( + *rawConfig, contextName, &clientcmd.ConfigOverrides{}, &clientcmd.ClientConfigLoadingRules{ExplicitPath: kubeconfigPath}, + ) + restConfig, err := clientConfig.ClientConfig() + if err != nil { + t.Fatalf("build client config for %s: %v", contextName, err) + } + client, err := dynamic.NewForConfig(restConfig) + if err != nil { + t.Fatalf("build dynamic client for %s: %v", contextName, err) + } + podName, podImage := "sith-vuln-sample", "registry.example/log4j-demo:v1" + if index == 1 { + podName, podImage = "sith-worker-sample", "registry.example/worker:v1" + } + pod := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "v1", + "kind": "Pod", + "metadata": map[string]any{"name": podName, "namespace": "default", "labels": map[string]any{"app": podName}}, + "spec": map[string]any{ + "containers": []any{map[string]any{"name": "app", "image": podImage}}, + }, + }} + if _, err := client.Resource(schema.GroupVersionResource{Version: "v1", Resource: "pods"}). + Namespace("default").Create(ctx, pod, metav1.CreateOptions{}); err != nil { + t.Fatalf("create pod in %s: %v", contextName, err) + } + replicas := int64(index) + deployment := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": map[string]any{"name": "sith-payments", "namespace": "default"}, + "spec": map[string]any{ + "replicas": replicas, + "selector": map[string]any{"matchLabels": map[string]any{"app": "sith-payments"}}, + "template": map[string]any{ + "metadata": map[string]any{"labels": map[string]any{"app": "sith-payments"}}, + "spec": map[string]any{"containers": []any{map[string]any{ + "name": "app", "image": "registry.example/does-not-exist:v1", + }}}, + }, + }, + }} + if _, err := client.Resource(schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments"}). + Namespace("default").Create(ctx, deployment, metav1.CreateOptions{}); err != nil { + t.Fatalf("create deployment in %s: %v", contextName, err) + } + } +} + +func runSith(ctx context.Context, binary, kubeconfigPath string, args ...string) ([]byte, string, error) { + command := exec.CommandContext(ctx, binary, args...) + command.Env = append(os.Environ(), + "KUBECONFIG="+kubeconfigPath, + "XDG_CONFIG_HOME="+filepath.Join(filepath.Dir(kubeconfigPath), "config-home"), + ) + var stdout bytes.Buffer + var stderr bytes.Buffer + command.Stdout = &stdout + command.Stderr = &stderr + err := command.Run() + return stdout.Bytes(), stderr.String(), err +} + +func assertCachedRecord(t *testing.T, snapshot fleetcache.Snapshot, cluster, name string, stale bool) { + t.Helper() + for _, record := range snapshot.Records { + if record.Cluster == cluster && record.Name == name { + if record.Stale != stale { + t.Fatalf("record %s/%s stale = %t, want %t", cluster, name, record.Stale, stale) + } + return + } + } + t.Fatalf("record %s/%s missing from %#v", cluster, name, snapshot.Records) } func mergedKindKubeconfig(ctx context.Context, t *testing.T, kindBinary string, clusters []string) string { From eec935f243ba85b2bec54013ee06689d5706106b Mon Sep 17 00:00:00 2001 From: Gnani Rahul Date: Fri, 10 Jul 2026 16:41:40 -0500 Subject: [PATCH 11/14] feat(tui): discover generic resource lenses GSTACK-Checkpoint: 2026-07-10/slice-2-cache-first-fleet#6 Signed-off-by: Gnani Rahul --- README.md | 5 +- internal/connector/kubeconfig/adapter.go | 32 +++- internal/connector/kubeconfig/adapter_test.go | 64 ++++++++ internal/connector/kubeconfig/resources.go | 148 +++++++++++++++--- internal/fleetcache/store.go | 35 ++++- internal/fleetcache/store_test.go | 23 +++ internal/hydrate/hydrator.go | 26 ++- internal/hydrate/hydrator_test.go | 20 +++ internal/tui/model.go | 45 +++++- internal/tui/model_test.go | 35 ++++- .../2026-07-10-slice-2-cache-first-fleet.md | 12 +- tests/e2e/kind_fanout_test.go | 38 +++++ 12 files changed, 443 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 5516d87..7a94ea7 100644 --- a/README.md +++ b/README.md @@ -37,8 +37,9 @@ artifact. The TUI opens only when stdin and stdout are terminals; redirected bare invocations remain script-safe and print help. Tier-1 lenses are Pods, Deployments, Events, and Nodes. Use `:` for -lens/context commands, `/` to filter the current lens, `Ctrl-K` for whole-fleet fuzzy/structured -search, number keys for cluster scope, `c` for coverage, and `Ctrl-R` for a non-blocking refresh. +lens/context commands (including `:` for an API-discovered generic resource), `/` to filter +the current lens, `Ctrl-K` for whole-fleet fuzzy/structured search, number keys for cluster scope, +`c` for coverage, and `Ctrl-R` for a non-blocking refresh. The UI uses Bubble Tea v2.0.8 core only; tables and search remain local so no optional styling or component dependency enters the binary. diff --git a/internal/connector/kubeconfig/adapter.go b/internal/connector/kubeconfig/adapter.go index 574099c..c91f766 100644 --- a/internal/connector/kubeconfig/adapter.go +++ b/internal/connector/kubeconfig/adapter.go @@ -43,6 +43,7 @@ var supportedKinds = []string{ type probeFunc func(ctx context.Context, config *rest.Config) error type dynamicFactory func(config *rest.Config) (dynamic.Interface, error) +type resourceResolver func(ctx context.Context, config *rest.Config, kind string) (resourceSpec, error) type options struct { loadingRules *clientcmd.ClientConfigLoadingRules @@ -53,6 +54,7 @@ type options struct { now func() time.Time probe probeFunc dynamic dynamicFactory + resolve resourceResolver } // Option configures the local kubeconfig adapter. @@ -143,6 +145,16 @@ func withDynamicFactory(factory dynamicFactory) Option { } } +func withResourceResolver(resolver resourceResolver) Option { + return func(settings *options) error { + if resolver == nil { + return fmt.Errorf("resource resolver must not be nil") + } + settings.resolve = resolver + return nil + } +} + // Adapter discovers contexts and performs independent local client-go reads. type Adapter struct { settings options @@ -151,6 +163,8 @@ type Adapter struct { discovered bool scopes map[string]connector.Scope clients map[string]dynamic.Interface + configs map[string]*rest.Config + resources map[string]map[string]resourceSpec lastSeen map[string]time.Time } @@ -188,15 +202,18 @@ func defaultOptions() options { dynamic: func(config *rest.Config) (dynamic.Interface, error) { return dynamic.NewForConfig(config) }, + resolve: defaultResourceResolver, } } func newAdapter(settings options) *Adapter { return &Adapter{ - settings: settings, - scopes: make(map[string]connector.Scope), - clients: make(map[string]dynamic.Interface), - lastSeen: make(map[string]time.Time), + settings: settings, + scopes: make(map[string]connector.Scope), + clients: make(map[string]dynamic.Interface), + configs: make(map[string]*rest.Config), + resources: make(map[string]map[string]resourceSpec), + lastSeen: make(map[string]time.Time), } } @@ -246,11 +263,13 @@ func (adapter *Adapter) Discover(ctx context.Context) (connector.Discovery, erro scopes := make([]connector.Scope, 0, len(results)) unreachable := make([]string, 0) clients := make(map[string]dynamic.Interface, len(results)) + configs := make(map[string]*rest.Config, len(results)) lastSeen := make(map[string]time.Time, len(results)) for _, result := range results { scopes = append(scopes, result.scope) if result.scope.Reachable { clients[result.scope.Name] = result.client + configs[result.scope.Name] = rest.CopyConfig(result.config) } else { unreachable = append(unreachable, result.scope.Name) } @@ -266,6 +285,8 @@ func (adapter *Adapter) Discover(ctx context.Context) (connector.Discovery, erro adapter.scopes[scope.Name] = cloneScope(scope) } adapter.clients = clients + adapter.configs = configs + adapter.resources = make(map[string]map[string]resourceSpec) adapter.lastSeen = lastSeen adapter.mu.Unlock() @@ -275,6 +296,7 @@ func (adapter *Adapter) Discover(ctx context.Context) (connector.Discovery, erro type contextResult struct { scope connector.Scope client dynamic.Interface + config *rest.Config } func (adapter *Adapter) probeContext( @@ -318,7 +340,7 @@ func (adapter *Adapter) probeContext( scope.Reachable = true scope.ObservedAt = adapter.settings.now().UTC() - return contextResult{scope: scope, client: client} + return contextResult{scope: scope, client: client, config: requestConfig} } func (adapter *Adapter) runBounded(count int, operation func(index int)) { diff --git a/internal/connector/kubeconfig/adapter_test.go b/internal/connector/kubeconfig/adapter_test.go index 104f372..c5d871d 100644 --- a/internal/connector/kubeconfig/adapter_test.go +++ b/internal/connector/kubeconfig/adapter_test.go @@ -7,12 +7,14 @@ import ( "encoding/json" "encoding/pem" "errors" + "fmt" "net/http" "net/http/httptest" "os" "path/filepath" "slices" "sync" + "sync/atomic" "testing" "time" @@ -43,6 +45,7 @@ func TestNewRejectsInvalidOptions(t *testing.T) { {name: "nil clock", option: withClock(nil)}, {name: "nil probe", option: withProbe(nil)}, {name: "nil dynamic factory", option: withDynamicFactory(nil)}, + {name: "nil resource resolver", option: withResourceResolver(nil)}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { @@ -54,6 +57,60 @@ func TestNewRejectsInvalidOptions(t *testing.T) { } } +func TestGenericResourceResolutionIsCached(t *testing.T) { + t.Parallel() + gvr := schema.GroupVersionResource{Group: "example.io", Version: "v1", Resource: "widgets"} + client := fakeClientWithKinds( + map[schema.GroupVersionResource]string{gvr: "WidgetList"}, + &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "example.io/v1", + "kind": "Widget", + "metadata": map[string]any{ + "name": "sample", "namespace": "apps", "uid": "sample-uid", + }, + }}, + ) + var resolveCalls atomic.Int32 + adapter, err := New( + WithLoadingRules(testLoadingRules(t, testConfig("alpha"))), + withProbe(func(_ context.Context, _ *rest.Config) error { return nil }), + withDynamicFactory(func(_ *rest.Config) (dynamic.Interface, error) { return client, nil }), + withResourceResolver(func(_ context.Context, _ *rest.Config, kind string) (resourceSpec, error) { + resolveCalls.Add(1) + if kind != "widgets" { + return resourceSpec{}, fmt.Errorf("unexpected kind %q", kind) + } + return resourceSpec{kind: "Widget", gvr: gvr, namespaced: true}, nil + }), + ) + if err != nil { + t.Fatalf("New() error = %v", err) + } + + query := fleet.Query{Selector: fleet.Selector{ResourceKind: "widgets", Namespace: "apps"}} + var result fleet.QueryResult + for range 2 { + result, err = adapter.Query(context.Background(), query) + if err != nil { + t.Fatalf("Query() error = %v", err) + } + if len(result.Facts) != 1 || result.Facts[0].Ref.Kind != "Widget" { + t.Fatalf("Facts = %#v, want one Widget", result.Facts) + } + } + if resolveCalls.Load() != 1 { + t.Fatalf("resolver calls = %d, want one cached resolution", resolveCalls.Load()) + } + + evidence, err := adapter.Read(context.Background(), result.Facts[0].Ref) + if err != nil { + t.Fatalf("Read() error = %v", err) + } + if evidence.Ref.Name != "sample" || resolveCalls.Load() != 1 { + t.Fatalf("Read() = %#v, resolver calls = %d", evidence.Ref, resolveCalls.Load()) + } +} + func TestDiscoverIsIndependentAndPreservesLastSeen(t *testing.T) { t.Parallel() firstObserved := time.Date(2026, time.July, 10, 12, 0, 0, 0, time.UTC) @@ -389,6 +446,13 @@ func fakeClient(objects ...runtime.Object) *dynamicfake.FakeDynamicClient { listKinds := map[schema.GroupVersionResource]string{ {Version: "v1", Resource: "pods"}: "PodList", } + return fakeClientWithKinds(listKinds, objects...) +} + +func fakeClientWithKinds( + listKinds map[schema.GroupVersionResource]string, + objects ...runtime.Object, +) *dynamicfake.FakeDynamicClient { return dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), listKinds, objects...) } diff --git a/internal/connector/kubeconfig/resources.go b/internal/connector/kubeconfig/resources.go index 09927c9..1e49f32 100644 --- a/internal/connector/kubeconfig/resources.go +++ b/internal/connector/kubeconfig/resources.go @@ -16,7 +16,9 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/labels" "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/discovery" "k8s.io/client-go/dynamic" + "k8s.io/client-go/rest" "github.com/ArdurAI/sith/internal/connector" "github.com/ArdurAI/sith/internal/fleet" @@ -71,24 +73,28 @@ func (adapter *Adapter) Read(ctx context.Context, ref fleet.ResourceRef) (fleet. return fleet.Evidence{}, fmt.Errorf("%w: scope and name are required", ErrInvalidReference) } - spec, ok := lookupResource(ref.Kind) - if !ok { - return fleet.Evidence{}, fmt.Errorf("%w: %q", ErrUnsupportedResource, ref.Kind) - } - if expected := ref.Attributes["gvr"]; expected != "" && expected != spec.gvr.String() { - return fleet.Evidence{}, fmt.Errorf("%w: GVR %q does not match kind %q", ErrUnsupportedResource, expected, ref.Kind) - } + spec, known := lookupResource(ref.Kind) if err := adapter.ensureDiscovered(ctx); err != nil { return fleet.Evidence{}, err } - scope, client, ok := adapter.scopeClient(ref.Scope) + scope, client, config, ok := adapter.scopeClient(ref.Scope) if !ok { return fleet.Evidence{}, fmt.Errorf("%w: %s", ErrUnknownScope, ref.Scope) } if !scope.Reachable || client == nil { return fleet.Evidence{}, fmt.Errorf("%w: %s", ErrUnreachableScope, ref.Scope) } + if !known { + var err error + spec, err = adapter.resolveResource(ctx, ref.Scope, config, ref.Kind) + if err != nil { + return fleet.Evidence{}, err + } + } + if expected := ref.Attributes["gvr"]; expected != "" && expected != spec.gvr.String() { + return fleet.Evidence{}, fmt.Errorf("%w: GVR %q does not match kind %q", ErrUnsupportedResource, expected, ref.Kind) + } resource := resourceInterface(client, spec, ref.Namespace) object, err := callWithTimeout(ctx, adapter.settings.requestTimeout, func(requestCtx context.Context) (*unstructured.Unstructured, error) { @@ -116,12 +122,10 @@ func (adapter *Adapter) Query(ctx context.Context, query fleet.Query) (fleet.Que } var spec resourceSpec if query.Selector.ResourceKind != "" { - var ok bool - spec, ok = lookupResource(query.Selector.ResourceKind) - if !ok { - return fleet.QueryResult{}, fmt.Errorf("%w: %q", ErrUnsupportedResource, query.Selector.ResourceKind) + if known, ok := lookupResource(query.Selector.ResourceKind); ok { + spec = known } - if !spec.namespaced && query.Selector.Namespace != "" { + if spec.gvr.Resource != "" && !spec.namespaced && query.Selector.Namespace != "" { return fleet.QueryResult{}, fmt.Errorf("%w: namespace cannot select cluster-scoped %s", ErrUnsupportedSelector, spec.kind) } } @@ -133,13 +137,13 @@ func (adapter *Adapter) Query(ctx context.Context, query fleet.Query) (fleet.Que return fleet.QueryResult{}, err } - scopes, clients, lastSeen := adapter.stateSnapshot() + scopes, clients, configs, lastSeen := adapter.stateSnapshot() targets := targetScopeNames(query.Scopes, scopes) results := make([]scopeQueryResult, len(targets)) adapter.runBounded(len(targets), func(index int) { name := targets[index] result, err := callWithTimeout(ctx, adapter.settings.requestTimeout, func(requestCtx context.Context) (scopeQueryResult, error) { - return adapter.queryScope(requestCtx, name, clients[name], spec, labelSelector.String(), query), nil + return adapter.queryScope(requestCtx, name, clients[name], configs[name], spec, labelSelector.String(), query), nil }) if err != nil { result = scopeQueryResult{name: name, err: err} @@ -195,6 +199,7 @@ func (adapter *Adapter) queryScope( ctx context.Context, name string, client dynamic.Interface, + config *rest.Config, spec resourceSpec, labelSelector string, query fleet.Query, @@ -207,6 +212,18 @@ func (adapter *Adapter) queryScope( if query.Selector.ResourceKind == "" { return result } + if spec.gvr.Resource == "" { + var err error + spec, err = adapter.resolveResource(ctx, name, config, query.Selector.ResourceKind) + if err != nil { + result.err = err + return result + } + } + if !spec.namespaced && query.Selector.Namespace != "" { + result.err = fmt.Errorf("%w: namespace cannot select cluster-scoped %s", ErrUnsupportedSelector, spec.kind) + return result + } resource := resourceInterface(client, spec, query.Selector.Namespace) list, err := resource.List(ctx, metav1.ListOptions{LabelSelector: labelSelector}) @@ -277,6 +294,92 @@ func lookupResource(kind string) (resourceSpec, bool) { return spec, ok } +func (adapter *Adapter) resolveResource( + ctx context.Context, + scope string, + config *rest.Config, + kind string, +) (resourceSpec, error) { + key := strings.ToLower(strings.TrimSpace(kind)) + adapter.mu.RLock() + if cached, ok := adapter.resources[scope][key]; ok { + adapter.mu.RUnlock() + return cached, nil + } + adapter.mu.RUnlock() + if config == nil { + return resourceSpec{}, fmt.Errorf("%w: no client config for %s", ErrUnreachableScope, scope) + } + resolved, err := callWithTimeout(ctx, adapter.settings.requestTimeout, func(resolveCtx context.Context) (resourceSpec, error) { + return adapter.settings.resolve(resolveCtx, rest.CopyConfig(config), kind) + }) + if err != nil { + return resourceSpec{}, fmt.Errorf("%w: resolve %q in %s: %v", ErrUnsupportedResource, kind, scope, err) + } + adapter.mu.Lock() + if adapter.resources[scope] == nil { + adapter.resources[scope] = make(map[string]resourceSpec) + } + for _, alias := range []string{key, strings.ToLower(resolved.kind), strings.ToLower(resolved.gvr.Resource)} { + adapter.resources[scope][alias] = resolved + } + adapter.mu.Unlock() + return resolved, nil +} + +func defaultResourceResolver(_ context.Context, config *rest.Config, kind string) (resourceSpec, error) { + client, err := discovery.NewDiscoveryClientForConfig(config) + if err != nil { + return resourceSpec{}, fmt.Errorf("create discovery client: %w", err) + } + lists, discoveryErr := client.ServerPreferredResources() + wanted := strings.ToLower(strings.TrimSpace(kind)) + for _, list := range lists { + groupVersion, err := schema.ParseGroupVersion(list.GroupVersion) + if err != nil { + continue + } + for _, resource := range list.APIResources { + if strings.Contains(resource.Name, "/") || !supportsVerb(resource.Verbs, "list") { + continue + } + if !strings.EqualFold(resource.Kind, kind) && strings.ToLower(resource.Name) != wanted && + strings.ToLower(resource.SingularName) != wanted && !containsShortName(resource.ShortNames, wanted) { + continue + } + return resourceSpec{ + kind: resource.Kind, + gvr: schema.GroupVersionResource{ + Group: groupVersion.Group, Version: groupVersion.Version, Resource: resource.Name, + }, + namespaced: resource.Namespaced, + }, nil + } + } + if discoveryErr != nil { + return resourceSpec{}, fmt.Errorf("discover API resources: %w", discoveryErr) + } + return resourceSpec{}, fmt.Errorf("resource %q was not advertised by the API server", kind) +} + +func supportsVerb(verbs metav1.Verbs, wanted string) bool { + for _, verb := range verbs { + if verb == wanted { + return true + } + } + return false +} + +func containsShortName(names []string, wanted string) bool { + for _, name := range names { + if strings.EqualFold(name, wanted) { + return true + } + } + return false +} + func resourceInterface(client dynamic.Interface, spec resourceSpec, namespace string) dynamic.ResourceInterface { resource := client.Resource(spec.gvr) if spec.namespaced { @@ -344,16 +447,21 @@ func targetScopeNames(requested []string, scopes map[string]connector.Scope) []s return result } -func (adapter *Adapter) scopeClient(name string) (connector.Scope, dynamic.Interface, bool) { +func (adapter *Adapter) scopeClient(name string) (connector.Scope, dynamic.Interface, *rest.Config, bool) { adapter.mu.RLock() defer adapter.mu.RUnlock() scope, exists := adapter.scopes[name] - return cloneScope(scope), adapter.clients[name], exists + var config *rest.Config + if adapter.configs[name] != nil { + config = rest.CopyConfig(adapter.configs[name]) + } + return cloneScope(scope), adapter.clients[name], config, exists } func (adapter *Adapter) stateSnapshot() ( map[string]connector.Scope, map[string]dynamic.Interface, + map[string]*rest.Config, map[string]time.Time, ) { adapter.mu.RLock() @@ -366,11 +474,15 @@ func (adapter *Adapter) stateSnapshot() ( for name, client := range adapter.clients { clients[name] = client } + configs := make(map[string]*rest.Config, len(adapter.configs)) + for name, config := range adapter.configs { + configs[name] = rest.CopyConfig(config) + } lastSeen := make(map[string]time.Time, len(adapter.lastSeen)) for name, observed := range adapter.lastSeen { lastSeen[name] = observed } - return scopes, clients, lastSeen + return scopes, clients, configs, lastSeen } func (adapter *Adapter) recordLastSeen(name string, observed time.Time) { diff --git a/internal/fleetcache/store.go b/internal/fleetcache/store.go index 1d3c581..0613229 100644 --- a/internal/fleetcache/store.go +++ b/internal/fleetcache/store.go @@ -48,6 +48,7 @@ type Store struct { records map[string]map[string]Record coverage map[string]fleet.Coverage + aliases map[string]string scopes map[string]connector.Scope warmed map[string]bool expected map[string]bool @@ -70,6 +71,7 @@ func newStore(now func() time.Time, freshFor time.Duration) *Store { return &Store{ records: make(map[string]map[string]Record), coverage: make(map[string]fleet.Coverage), + aliases: make(map[string]string), scopes: make(map[string]connector.Scope), warmed: make(map[string]bool), expected: make(map[string]bool), @@ -91,6 +93,7 @@ func (store *Store) BeginSync(kinds ...string) bool { for _, kind := range kinds { if canonical := canonicalKind(kind); canonical != "" { store.expected[canonical] = true + store.aliases[kindAlias(kind)] = canonical } } store.notifyLocked() @@ -140,6 +143,10 @@ func (store *Store) Replace(kind string, result fleet.QueryResult) error { if store.records[canonical] == nil { store.records[canonical] = make(map[string]Record) } + store.aliases[kindAlias(kind)] = canonical + for _, record := range normalized { + store.aliases[kindAlias(record.Kind)] = canonical + } unreachable := stringSet(result.Coverage.Unreachable) for key, record := range store.records[canonical] { if _, failed := unreachable[record.Cluster]; !failed { @@ -193,8 +200,13 @@ func (store *Store) Query(query Query) Snapshot { defer store.mu.RUnlock() now := store.now().UTC() records := make([]Record, 0) + selectedKind := store.resolveKindLocked(query.Kind) + matchQuery := query + if selectedKind != "" { + matchQuery.Kind = "" + } for kind, byKey := range store.records { - if query.Kind != "" && canonicalKind(query.Kind) != kind { + if selectedKind != "" && selectedKind != kind { continue } for _, cached := range byKey { @@ -206,7 +218,7 @@ func (store *Store) Query(query Query) Snapshot { record.Fact.Stale = true record.Fact.StaleFor = age.Round(time.Second).String() } - if query.matches(record) { + if matchQuery.matches(record) { records = append(records, record) } } @@ -228,7 +240,7 @@ func (store *Store) Query(query Query) Snapshot { } } } - pending := canonicalKind(query.Kind) != "" && !store.warmed[canonicalKind(query.Kind)] + pending := selectedKind != "" && !store.warmed[selectedKind] return Snapshot{ Version: store.version, State: store.stateLocked(coverage, store.recordCountLocked(), pending), @@ -265,7 +277,7 @@ func (store *Store) coverageLocked(query Query, records []Record, now time.Time) targets := store.targetScopesLocked(query.Scopes) unreachable := make(map[string]struct{}) stale := make(map[string]struct{}) - kind := canonicalKind(query.Kind) + kind := store.resolveKindLocked(query.Kind) if kind != "" { if !store.warmed[kind] { return fleet.Coverage{Requested: len(targets)} @@ -314,6 +326,21 @@ func (store *Store) coverageLocked(query Query, records []Record, now time.Time) return coverage } +func (store *Store) resolveKindLocked(kind string) string { + canonical := canonicalKind(kind) + if canonical == "" { + return "" + } + if resolved := store.aliases[kindAlias(kind)]; resolved != "" { + return resolved + } + return canonical +} + +func kindAlias(kind string) string { + return strings.ToLower(canonicalKind(kind)) +} + func (store *Store) targetScopesLocked(patterns []string) []string { set := make(map[string]struct{}) if len(patterns) == 0 { diff --git a/internal/fleetcache/store_test.go b/internal/fleetcache/store_test.go index d9646b2..e6130ed 100644 --- a/internal/fleetcache/store_test.go +++ b/internal/fleetcache/store_test.go @@ -106,6 +106,29 @@ func TestStoreSearchGrammarRunsOnlyOnNormalizedCache(t *testing.T) { } } +func TestStoreMapsGenericResourceAliasToAdvertisedKind(t *testing.T) { + t.Parallel() + now := time.Now().UTC() + store := newStore(func() time.Time { return now }, time.Minute) + store.SetDiscovery(connector.Discovery{Scopes: []connector.Scope{{Name: "alpha", Reachable: true, ObservedAt: now}}}) + fact := objectFact(t, "ConfigMap", map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]any{"name": "settings", "namespace": "apps"}, + }, now) + if err := store.Replace("configmaps", fleet.QueryResult{ + Facts: []fleet.Fact{fact}, Coverage: fleet.Coverage{Requested: 1, Reachable: 1}, + }); err != nil { + t.Fatalf("Replace() error = %v", err) + } + for _, kind := range []string{"configmaps", "ConfigMap"} { + snapshot := store.Query(Query{Kind: kind}) + if len(snapshot.Records) != 1 || snapshot.Records[0].Kind != "ConfigMap" || !snapshot.Coverage.Complete() { + t.Fatalf("Query(%q) = %#v, want advertised ConfigMap", kind, snapshot) + } + } +} + func TestStorePauseAndChangeNotification(t *testing.T) { t.Parallel() store := New() diff --git a/internal/hydrate/hydrator.go b/internal/hydrate/hydrator.go index 987ebad..7f8b154 100644 --- a/internal/hydrate/hydrator.go +++ b/internal/hydrate/hydrator.go @@ -97,10 +97,23 @@ func (hydrator *Hydrator) Kinds() []string { // SyncOnce discovers contexts and independently reconciles each configured lens. func (hydrator *Hydrator) SyncOnce(ctx context.Context) error { + return hydrator.sync(ctx, hydrator.kinds) +} + +// SyncKinds discovers contexts and reconciles an on-demand resource-kind set. +func (hydrator *Hydrator) SyncKinds(ctx context.Context, kinds ...string) error { + normalized, err := normalizeKinds(kinds) + if err != nil { + return fmt.Errorf("sync hydration kinds: %w", err) + } + return hydrator.sync(ctx, normalized) +} + +func (hydrator *Hydrator) sync(ctx context.Context, kinds []string) error { if hydrator.store.Paused() { return ErrPaused } - if !hydrator.store.BeginSync(hydrator.kinds...) { + if !hydrator.store.BeginSync(kinds...) { if hydrator.store.Paused() { return ErrPaused } @@ -118,8 +131,8 @@ func (hydrator *Hydrator) SyncOnce(ctx context.Context) error { } hydrator.store.SetDiscovery(discovery) - errorsByKind := make([]error, len(hydrator.kinds)) - workers := min(hydrator.limit, len(hydrator.kinds)) + errorsByKind := make([]error, len(kinds)) + workers := min(hydrator.limit, len(kinds)) jobs := make(chan int) var waitGroup sync.WaitGroup waitGroup.Add(workers) @@ -127,7 +140,7 @@ func (hydrator *Hydrator) SyncOnce(ctx context.Context) error { go func() { defer waitGroup.Done() for index := range jobs { - kind := hydrator.kinds[index] + kind := kinds[index] result, queryErr := hydrator.reader.Query(ctx, fleet.Query{ Kinds: []fleet.FactKind{fleet.FactInventory}, Selector: fleet.Selector{ResourceKind: kind}, @@ -142,7 +155,7 @@ func (hydrator *Hydrator) SyncOnce(ctx context.Context) error { } }() } - for index := range hydrator.kinds { + for index := range kinds { select { case jobs <- index: case <-ctx.Done(): @@ -162,6 +175,9 @@ func (hydrator *Hydrator) SyncOnce(ctx context.Context) error { } func normalizeKinds(kinds []string) ([]string, error) { + if len(kinds) == 0 { + return nil, fmt.Errorf("at least one hydration kind is required") + } set := make(map[string]struct{}, len(kinds)) result := make([]string, 0, len(kinds)) for _, kind := range kinds { diff --git a/internal/hydrate/hydrator_test.go b/internal/hydrate/hydrator_test.go index 8a11c9e..0271d60 100644 --- a/internal/hydrate/hydrator_test.go +++ b/internal/hydrate/hydrator_test.go @@ -96,6 +96,26 @@ func TestSyncOnceKeepsSuccessfulLensesOnPartialFailure(t *testing.T) { } } +func TestSyncKindsReconcilesGenericLens(t *testing.T) { + t.Parallel() + reader := &fakeReader{} + store := fleetcache.New() + hydrator, err := New(reader, store) + if err != nil { + t.Fatalf("New() error = %v", err) + } + if err := hydrator.SyncKinds(context.Background(), "widgets", "widgets"); err != nil { + t.Fatalf("SyncKinds() error = %v", err) + } + snapshot := store.Query(fleetcache.Query{Kind: "Widgets"}) + if len(snapshot.Records) != 2 || !snapshot.Coverage.Complete() { + t.Fatalf("generic snapshot = %#v, want two complete records", snapshot) + } + if err := hydrator.SyncKinds(context.Background()); err == nil { + t.Fatal("SyncKinds() error = nil, want empty-kind validation") + } +} + func TestSyncOnceRejectsPauseAndDuplicateRun(t *testing.T) { t.Parallel() reader := &fakeReader{block: make(chan struct{}), started: make(chan struct{}, 1)} diff --git a/internal/tui/model.go b/internal/tui/model.go index 05f68d9..8abc604 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -37,6 +37,7 @@ const ( // Syncer is the narrow background-I/O seam consumed by the TUI runtime. type Syncer interface { SyncOnce(ctx context.Context) error + SyncKinds(ctx context.Context, kinds ...string) error } // Model is a Bubble Tea model whose interaction path reads only fleetcache snapshots. @@ -274,10 +275,18 @@ func (model *Model) applyCommand() (tea.Model, tea.Cmd) { model.scopes = []string{fields[1]} } default: - if !model.setLens(fields[0]) { + if len(fields) != 1 { + model.lastError = "unknown command: " + fields[0] + return model, nil + } + valid, added := model.setLens(fields[0]) + if !valid { model.lastError = "unknown command: " + fields[0] } else { model.lastError = "" + if added { + return model, model.syncLensCommand() + } } } return model, nil @@ -332,6 +341,11 @@ func (model *Model) syncCommand() tea.Cmd { return func() tea.Msg { return syncDoneMsg{err: model.syncer.SyncOnce(model.ctx)} } } +func (model *Model) syncLensCommand() tea.Cmd { + kind := model.currentLens() + return func() tea.Msg { return syncDoneMsg{err: model.syncer.SyncKinds(model.ctx, kind)} } +} + func (model *Model) waitCommand(after uint64) tea.Cmd { return func() tea.Msg { version, err := model.store.WaitForChange(model.ctx, after) @@ -347,17 +361,40 @@ func (model *Model) currentLens() string { return model.lenses[model.lens] } -func (model *Model) setLens(value string) bool { +func (model *Model) setLens(value string) (bool, bool) { for index, lens := range model.lenses { if strings.HasPrefix(strings.ToLower(lens), strings.ToLower(value)) || strings.HasPrefix(strings.ToLower(value), strings.ToLower(lens)) { model.lens = index model.cursor = 0 model.filterAll = false - return true + return true, false + } + } + if !validResourceToken(value) { + return false, false + } + canonical := strings.ToUpper(value[:1]) + strings.ToLower(value[1:]) + model.lenses = append(model.lenses, canonical) + model.lens = len(model.lenses) - 1 + model.cursor = 0 + model.filterAll = false + return true, true +} + +func validResourceToken(value string) bool { + if value == "" { + return false + } + for index := range len(value) { + character := value[index] + if character >= 'a' && character <= 'z' || character >= 'A' && character <= 'Z' || + character >= '0' && character <= '9' || character == '.' || character == '-' || character == '_' { + continue } + return false } - return false + return value[0] != '.' && value[0] != '-' && value[0] != '_' } func (model *Model) clampCursor() { diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index e6d7c0f..a051d71 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -125,6 +125,29 @@ func TestFuzzyFleetSearchSpansLensesAndCanBeCleared(t *testing.T) { } } +func TestGenericLensHydratesOnDemand(t *testing.T) { + t.Parallel() + syncer := &countingSyncer{} + model, err := NewModel(context.Background(), populatedStore(t, 2), syncer) + if err != nil { + t.Fatalf("NewModel() error = %v", err) + } + _, _ = model.Update(keyMessage(":")) + _, _ = model.Update(keyMessage("configmaps")) + _, command := model.Update(specialKey(tea.KeyEnter)) + if model.currentLens() != "Configmaps" || command == nil { + t.Fatalf("generic lens/command = %q/%v", model.currentLens(), command) + } + message := command() + if _, ok := message.(syncDoneMsg); !ok || syncer.kindCalls.Load() != 1 { + t.Fatalf("generic sync message/calls = %#v/%d", message, syncer.kindCalls.Load()) + } + kinds, _ := syncer.lastKinds.Load().([]string) + if !slices.Equal(kinds, []string{"Configmaps"}) { + t.Fatalf("generic sync kinds = %v", kinds) + } +} + func TestWarmViewP95UnderOneHundredMilliseconds(t *testing.T) { if testing.Short() { t.Skip("performance acceptance test") @@ -249,13 +272,23 @@ func TestRunHonorsCanceledContext(t *testing.T) { } } -type countingSyncer struct{ calls atomic.Int64 } +type countingSyncer struct { + calls atomic.Int64 + kindCalls atomic.Int64 + lastKinds atomic.Value +} func (syncer *countingSyncer) SyncOnce(_ context.Context) error { syncer.calls.Add(1) return nil } +func (syncer *countingSyncer) SyncKinds(_ context.Context, kinds ...string) error { + syncer.kindCalls.Add(1) + syncer.lastKinds.Store(append([]string(nil), kinds...)) + return nil +} + func populatedStore(t *testing.T, pods int) *fleetcache.Store { t.Helper() now := time.Date(2026, time.July, 10, 21, 0, 0, 0, time.UTC) diff --git a/sessions/2026-07-10-slice-2-cache-first-fleet.md b/sessions/2026-07-10-slice-2-cache-first-fleet.md index a5ab845..78b38d5 100644 --- a/sessions/2026-07-10-slice-2-cache-first-fleet.md +++ b/sessions/2026-07-10-slice-2-cache-first-fleet.md @@ -57,7 +57,17 @@ last-known stale retention after the second cluster disappears. The gate passes [R] Review: The regular GitHub security audit reports zero open Dependabot alerts. Code scanning has no analysis configured and secret scanning is disabled; schedule a narrow post-slice security lane for CodeQL and repository secret-scanning/push-protection enablement. Local govulncheck is clean. -[C] Checkpoint #5: this commit — real cache/search/staleness proof; next: generic resource lens and final review. +[C] Checkpoint #5: 399b3bd — real cache/search/staleness proof; next: generic resource lens and final review. +[A] Action: Added on-demand generic resource discovery through each context's Kubernetes discovery +API, cached GVR resolution, generic-kind cache aliases, and `:` TUI hydration without adding +network calls to ordinary interaction paths. +[T] Test: Unit and race tests cover plural-to-advertised-Kind aliases, cached custom-resource +resolution, and generic TUI hydration. The real two-cluster gate seeds ConfigMaps and proves the +built CLI discovers and renders them across 2/3 contexts with honest partial coverage in 61 seconds. +[R] Review: CodeRabbit CLI was unavailable, so the required diff review used the documented local +fallback after a changed-file secret scan. The review found and fixed the generic alias mismatch; +it also found that bounded background polling does not satisfy #33's explicit watch-stream contract. +[C] Checkpoint #6: this commit — generic discovery and lens proof; next: watch-backed delta hydration. --- diff --git a/tests/e2e/kind_fanout_test.go b/tests/e2e/kind_fanout_test.go index 35a186d..5124d0a 100644 --- a/tests/e2e/kind_fanout_test.go +++ b/tests/e2e/kind_fanout_test.go @@ -153,6 +153,34 @@ func TestKindFleetFanout(t *testing.T) { t.Fatalf("get stderr = %q, want partial coverage warning", getStderr) } + genericOutput, genericStderr, err := runSith( + ctx, binary, kubeconfigPath, "get", "configmaps", "-A", "--all-clusters", "--output", "json", + ) + if err != nil { + t.Fatalf("run generic sith get against kind: %v\nstdout=%s\nstderr=%s", err, genericOutput, genericStderr) + } + var genericSnapshot fleetcache.Snapshot + if err := json.Unmarshal(genericOutput, &genericSnapshot); err != nil { + t.Fatalf("decode generic sith get output %q: %v", genericOutput, err) + } + genericScopes := map[string]bool{ + "kind-" + clusterNames[0]: false, + "kind-" + clusterNames[1]: false, + } + for _, record := range genericSnapshot.Records { + if record.Kind == "ConfigMap" && record.Name == "sith-generic-sample" { + genericScopes[record.Cluster] = true + } + } + for scope, seen := range genericScopes { + if !seen { + t.Errorf("generic lens did not return a ConfigMap from %s", scope) + } + } + if genericSnapshot.Coverage.Reachable != 2 || !strings.Contains(genericStderr, "warning: covered 2/3 clusters") { + t.Fatalf("generic coverage/stderr = %#v/%q, want partial two-of-three", genericSnapshot.Coverage, genericStderr) + } + searchOutput, searchStderr, err := runSith(ctx, binary, kubeconfigPath, "search", "image:*log4j*", "--output", "json") if err != nil { t.Fatalf("run sith search against kind: %v\nstdout=%s\nstderr=%s", err, searchOutput, searchStderr) @@ -240,6 +268,16 @@ func seedKindResources(ctx context.Context, t *testing.T, kubeconfigPath string, Namespace("default").Create(ctx, pod, metav1.CreateOptions{}); err != nil { t.Fatalf("create pod in %s: %v", contextName, err) } + configMap := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]any{"name": "sith-generic-sample", "namespace": "default"}, + "data": map[string]any{"cluster": contextName}, + }} + if _, err := client.Resource(schema.GroupVersionResource{Version: "v1", Resource: "configmaps"}). + Namespace("default").Create(ctx, configMap, metav1.CreateOptions{}); err != nil { + t.Fatalf("create configmap in %s: %v", contextName, err) + } replicas := int64(index) deployment := &unstructured.Unstructured{Object: map[string]any{ "apiVersion": "apps/v1", From 998d26e6baa5630b8af7d88d92f18168cac09668 Mon Sep 17 00:00:00 2001 From: Gnani Rahul Date: Fri, 10 Jul 2026 16:54:37 -0500 Subject: [PATCH 12/14] feat(cache): stream live fleet deltas GSTACK-Checkpoint: 2026-07-10/slice-2-cache-first-fleet#7 Signed-off-by: Gnani Rahul --- README.md | 9 +- internal/connector/contract.go | 31 ++ internal/connector/kubeconfig/adapter.go | 26 +- internal/connector/kubeconfig/adapter_test.go | 80 +++++ internal/connector/kubeconfig/watch.go | 286 ++++++++++++++++++ internal/fleetcache/store.go | 144 +++++++++ internal/fleetcache/store_test.go | 64 ++++ internal/hydrate/hydrator.go | 160 +++++++++- internal/hydrate/hydrator_test.go | 98 ++++++ internal/tui/model.go | 38 ++- internal/tui/model_test.go | 17 +- .../2026-07-10-slice-2-cache-first-fleet.md | 15 + tests/e2e/kind_fanout_test.go | 87 ++++++ 13 files changed, 1012 insertions(+), 43 deletions(-) create mode 100644 internal/connector/kubeconfig/watch.go diff --git a/README.md b/README.md index 7a94ea7..23d3a45 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # Sith **Status: Slice 2 cache-first fleet client.** The CLI discovers every context resolved by -client-go, hydrates a local in-memory fleet cache, and serves Tier-1 reads and cross-cluster search -from normalized snapshots with explicit freshness and coverage. +client-go, hydrates a local in-memory fleet cache through per-context watches, and serves Tier-1 +reads and cross-cluster search from normalized snapshots with explicit freshness and coverage. Sith is ArdurAI's single-binary, local-first Kubernetes fleet tool: **k9s for your whole fleet**. It is designed to aggregate every kubeconfig context without an account, telemetry, or cluster @@ -43,6 +43,11 @@ the current lens, `Ctrl-K` for whole-fleet fuzzy/structured search, number keys The UI uses Bubble Tea v2.0.8 core only; tables and search remain local so no optional styling or component dependency enters the binary. +Each active lens holds one Kubernetes watch per reachable context after its initial list. A +two-minute safety rediscovery recovers contexts that were offline at launch; it is not the primary +resource refresh path. Very large context/lens counts therefore trade API-server connection and +relist cost for continuous low-latency deltas. + Run the full local quality gate with golangci-lint v2.12.2 and govulncheck v1.6.0 on `PATH`: ```bash diff --git a/internal/connector/contract.go b/internal/connector/contract.go index 4c022af..f9b7980 100644 --- a/internal/connector/contract.go +++ b/internal/connector/contract.go @@ -82,6 +82,37 @@ type Reader interface { Query(ctx context.Context, query fleet.Query) (fleet.QueryResult, error) } +// Watcher is an optional live-read extension to Reader. It is deliberately not an eighth +// registry capability: watch accelerates query-backed cache reconciliation without changing the +// locked seven-verb connector taxonomy. +type Watcher interface { + Reader + Watch(ctx context.Context, kinds ...string) (<-chan WatchEvent, error) +} + +// WatchEventType describes one cache reconciliation delta from a live reader. +type WatchEventType string + +// Live-reader reconciliation event types. +const ( + WatchSnapshot WatchEventType = "snapshot" + WatchUpsert WatchEventType = "upsert" + WatchDelete WatchEventType = "delete" + WatchError WatchEventType = "error" +) + +// WatchEvent carries a source-stamped, per-scope reconciliation delta. +type WatchEvent struct { + Type WatchEventType + Kind string + Scope string + Facts []fleet.Fact + Fact fleet.Fact + Ref fleet.ResourceRef + ObservedAt time.Time + Err error +} + // Discovery describes the scopes a reader can currently address. type Discovery struct { Scopes []Scope `json:"scopes"` diff --git a/internal/connector/kubeconfig/adapter.go b/internal/connector/kubeconfig/adapter.go index c91f766..8123962 100644 --- a/internal/connector/kubeconfig/adapter.go +++ b/internal/connector/kubeconfig/adapter.go @@ -28,6 +28,7 @@ const ( defaultRequestTimeout = 10 * time.Second defaultStaleAfter = 2 * time.Minute defaultConcurrency = 16 + defaultWatchTimeout = 6 * time.Minute ) var supportedKinds = []string{ @@ -163,12 +164,16 @@ type Adapter struct { discovered bool scopes map[string]connector.Scope clients map[string]dynamic.Interface + watchers map[string]dynamic.Interface configs map[string]*rest.Config resources map[string]map[string]resourceSpec lastSeen map[string]time.Time } -var _ connector.Reader = (*Adapter)(nil) +var ( + _ connector.Reader = (*Adapter)(nil) + _ connector.Watcher = (*Adapter)(nil) +) // Default constructs an adapter using client-go's KUBECONFIG and home-directory rules. func Default() *Adapter { @@ -211,6 +216,7 @@ func newAdapter(settings options) *Adapter { settings: settings, scopes: make(map[string]connector.Scope), clients: make(map[string]dynamic.Interface), + watchers: make(map[string]dynamic.Interface), configs: make(map[string]*rest.Config), resources: make(map[string]map[string]resourceSpec), lastSeen: make(map[string]time.Time), @@ -263,12 +269,14 @@ func (adapter *Adapter) Discover(ctx context.Context) (connector.Discovery, erro scopes := make([]connector.Scope, 0, len(results)) unreachable := make([]string, 0) clients := make(map[string]dynamic.Interface, len(results)) + watchers := make(map[string]dynamic.Interface, len(results)) configs := make(map[string]*rest.Config, len(results)) lastSeen := make(map[string]time.Time, len(results)) for _, result := range results { scopes = append(scopes, result.scope) if result.scope.Reachable { clients[result.scope.Name] = result.client + watchers[result.scope.Name] = result.watcher configs[result.scope.Name] = rest.CopyConfig(result.config) } else { unreachable = append(unreachable, result.scope.Name) @@ -285,6 +293,7 @@ func (adapter *Adapter) Discover(ctx context.Context) (connector.Discovery, erro adapter.scopes[scope.Name] = cloneScope(scope) } adapter.clients = clients + adapter.watchers = watchers adapter.configs = configs adapter.resources = make(map[string]map[string]resourceSpec) adapter.lastSeen = lastSeen @@ -294,9 +303,10 @@ func (adapter *Adapter) Discover(ctx context.Context) (connector.Discovery, erro } type contextResult struct { - scope connector.Scope - client dynamic.Interface - config *rest.Config + scope connector.Scope + client dynamic.Interface + watcher dynamic.Interface + config *rest.Config } func (adapter *Adapter) probeContext( @@ -337,10 +347,16 @@ func (adapter *Adapter) probeContext( if err != nil { return contextResult{scope: scope} } + watchConfig := rest.CopyConfig(requestConfig) + watchConfig.Timeout = defaultWatchTimeout + watcher, err := adapter.settings.dynamic(watchConfig) + if err != nil { + return contextResult{scope: scope} + } scope.Reachable = true scope.ObservedAt = adapter.settings.now().UTC() - return contextResult{scope: scope, client: client, config: requestConfig} + return contextResult{scope: scope, client: client, watcher: watcher, config: requestConfig} } func (adapter *Adapter) runBounded(count int, operation func(index int)) { diff --git a/internal/connector/kubeconfig/adapter_test.go b/internal/connector/kubeconfig/adapter_test.go index c5d871d..3644897 100644 --- a/internal/connector/kubeconfig/adapter_test.go +++ b/internal/connector/kubeconfig/adapter_test.go @@ -18,6 +18,7 @@ import ( "testing" "time" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" @@ -28,6 +29,7 @@ import ( "k8s.io/client-go/tools/clientcmd" clientcmdapi "k8s.io/client-go/tools/clientcmd/api" + "github.com/ArdurAI/sith/internal/connector" "github.com/ArdurAI/sith/internal/fleet" ) @@ -111,6 +113,48 @@ func TestGenericResourceResolutionIsCached(t *testing.T) { } } +func TestWatchStreamsSnapshotUpsertAndDelete(t *testing.T) { + t.Parallel() + client := fakeClient(pod("api-0", "apps", "registry/api:v1", nil)) + adapter, err := New( + WithLoadingRules(testLoadingRules(t, testConfig("alpha"))), + withProbe(func(_ context.Context, _ *rest.Config) error { return nil }), + withDynamicFactory(func(_ *rest.Config) (dynamic.Interface, error) { return client, nil }), + ) + if err != nil { + t.Fatalf("New() error = %v", err) + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + events, err := adapter.Watch(ctx, "Pod") + if err != nil { + t.Fatalf("Watch() error = %v", err) + } + snapshot := receiveWatchEvent(ctx, t, events) + if snapshot.Type != connector.WatchSnapshot || snapshot.Scope != "alpha" || len(snapshot.Facts) != 1 { + t.Fatalf("snapshot = %#v", snapshot) + } + waitForWatchAction(ctx, t, client) + + created := pod("api-1", "apps", "registry/api:v2", nil) + if _, err := client.Resource(schema.GroupVersionResource{Version: "v1", Resource: "pods"}). + Namespace("apps").Create(ctx, created, metav1.CreateOptions{}); err != nil { + t.Fatalf("create watched pod: %v", err) + } + upsert := receiveWatchEvent(ctx, t, events) + if upsert.Type != connector.WatchUpsert || upsert.Fact.Ref.Name != "api-1" { + t.Fatalf("upsert = %#v", upsert) + } + if err := client.Resource(schema.GroupVersionResource{Version: "v1", Resource: "pods"}). + Namespace("apps").Delete(ctx, "api-1", metav1.DeleteOptions{}); err != nil { + t.Fatalf("delete watched pod: %v", err) + } + deleted := receiveWatchEvent(ctx, t, events) + if deleted.Type != connector.WatchDelete || deleted.Ref.Name != "api-1" { + t.Fatalf("delete = %#v", deleted) + } +} + func TestDiscoverIsIndependentAndPreservesLastSeen(t *testing.T) { t.Parallel() firstObserved := time.Date(2026, time.July, 10, 12, 0, 0, 0, time.UTC) @@ -456,6 +500,42 @@ func fakeClientWithKinds( return dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), listKinds, objects...) } +func receiveWatchEvent( + ctx context.Context, + t *testing.T, + events <-chan connector.WatchEvent, +) connector.WatchEvent { + t.Helper() + select { + case event, open := <-events: + if !open { + t.Fatal("watch event channel closed") + } + return event + case <-ctx.Done(): + t.Fatalf("wait for watch event: %v", ctx.Err()) + return connector.WatchEvent{} + } +} + +func waitForWatchAction(ctx context.Context, t *testing.T, client *dynamicfake.FakeDynamicClient) { + t.Helper() + ticker := time.NewTicker(time.Millisecond) + defer ticker.Stop() + for { + for _, action := range client.Actions() { + if action.GetVerb() == "watch" { + return + } + } + select { + case <-ctx.Done(): + t.Fatalf("wait for watch action: %v", ctx.Err()) + case <-ticker.C: + } + } +} + func pod(name, namespace, image string, labels map[string]string) *unstructured.Unstructured { unstructuredLabels := make(map[string]any, len(labels)) for key, value := range labels { diff --git a/internal/connector/kubeconfig/watch.go b/internal/connector/kubeconfig/watch.go new file mode 100644 index 0000000..019448a --- /dev/null +++ b/internal/connector/kubeconfig/watch.go @@ -0,0 +1,286 @@ +// SPDX-License-Identifier: Apache-2.0 + +package kubeconfig + +import ( + "context" + "errors" + "fmt" + "strings" + "sync" + "time" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/rest" + + "github.com/ArdurAI/sith/internal/connector" + "github.com/ArdurAI/sith/internal/fleet" +) + +const ( + watchBuffer = 256 + initialWatchBackoff = 250 * time.Millisecond + maximumWatchBackoff = 5 * time.Second + watchTimeoutSeconds = int64(300) +) + +// Watch opens independent list-watch loops for every reachable scope and requested kind. +func (adapter *Adapter) Watch(ctx context.Context, kinds ...string) (<-chan connector.WatchEvent, error) { + normalized, err := normalizeWatchKinds(kinds) + if err != nil { + return nil, err + } + if err := adapter.ensureDiscovered(ctx); err != nil { + return nil, err + } + scopes, clients, configs := adapter.watchStateSnapshot() + events := make(chan connector.WatchEvent, watchBuffer) + var waitGroup sync.WaitGroup + for name, scope := range scopes { + for _, kind := range normalized { + waitGroup.Add(1) + if !scope.Reachable || clients[name] == nil { + go func(scopeName, resourceKind string) { + defer waitGroup.Done() + sendWatchEvent(ctx, events, connector.WatchEvent{ + Type: connector.WatchError, Kind: resourceKind, Scope: scopeName, Err: ErrUnreachableScope, + }) + }(name, kind) + continue + } + go func(scopeName, resourceKind string, client dynamic.Interface, config *rest.Config) { + defer waitGroup.Done() + adapter.watchScope(ctx, events, scopeName, resourceKind, client, config) + }(name, kind, clients[name], configs[name]) + } + } + go func() { + waitGroup.Wait() + close(events) + }() + return events, nil +} + +func (adapter *Adapter) watchScope( + ctx context.Context, + events chan<- connector.WatchEvent, + scope, kind string, + client dynamic.Interface, + config *rest.Config, +) { + backoff := initialWatchBackoff + for ctx.Err() == nil { + spec, known := lookupResource(kind) + if !known { + var err error + spec, err = adapter.resolveResource(ctx, scope, config, kind) + if err != nil { + if !adapter.reportWatchError(ctx, events, kind, scope, err) || !waitForWatchRetry(ctx, backoff) { + return + } + backoff = min(backoff*2, maximumWatchBackoff) + continue + } + } + resource := resourceInterface(client, spec, "") + list, err := callWithTimeout(ctx, adapter.settings.requestTimeout, func(requestCtx context.Context) (*unstructured.UnstructuredList, error) { + return resource.List(requestCtx, metav1.ListOptions{}) + }) + if err != nil { + if !adapter.reportWatchError(ctx, events, kind, scope, fmt.Errorf("list before watch: %w", err)) || + !waitForWatchRetry(ctx, backoff) { + return + } + backoff = min(backoff*2, maximumWatchBackoff) + continue + } + observedAt := adapter.settings.now().UTC() + facts, err := factsFromObjects(list.Items, spec, scope, observedAt) + if err != nil { + if !adapter.reportWatchError(ctx, events, kind, scope, err) { + return + } + return + } + if !sendWatchEvent(ctx, events, connector.WatchEvent{ + Type: connector.WatchSnapshot, Kind: kind, Scope: scope, Facts: facts, ObservedAt: observedAt, + }) { + return + } + adapter.recordLastSeen(scope, observedAt) + backoff = initialWatchBackoff + + stream, err := resource.Watch(ctx, metav1.ListOptions{ + ResourceVersion: list.GetResourceVersion(), AllowWatchBookmarks: true, TimeoutSeconds: pointer(watchTimeoutSeconds), + }) + if err != nil { + if !adapter.reportWatchError(ctx, events, kind, scope, fmt.Errorf("open watch: %w", err)) || + !waitForWatchRetry(ctx, backoff) { + return + } + backoff = min(backoff*2, maximumWatchBackoff) + continue + } + watchErr := adapter.consumeWatch(ctx, events, stream, kind, scope, spec) + stream.Stop() + if ctx.Err() != nil { + return + } + if watchErr != nil && !adapter.reportWatchError(ctx, events, kind, scope, watchErr) { + return + } + if !waitForWatchRetry(ctx, backoff) { + return + } + if watchErr != nil { + backoff = min(backoff*2, maximumWatchBackoff) + } + } +} + +func (adapter *Adapter) consumeWatch( + ctx context.Context, + events chan<- connector.WatchEvent, + stream watch.Interface, + kind, scope string, + spec resourceSpec, +) error { + for { + select { + case <-ctx.Done(): + return ctx.Err() + case event, open := <-stream.ResultChan(): + if !open { + return nil + } + object, ok := event.Object.(*unstructured.Unstructured) + if event.Type == watch.Error { + if err := apierrors.FromObject(event.Object); err != nil { + return fmt.Errorf("watch API error: %w", err) + } + return errors.New("watch API returned an unknown error") + } + if event.Type == watch.Bookmark || !ok { + continue + } + observedAt := adapter.settings.now().UTC() + evidence, err := evidenceFromObject(*object, spec, scope, observedAt) + if err != nil { + return err + } + watchEvent := connector.WatchEvent{ + Kind: kind, Scope: scope, ObservedAt: observedAt, Ref: evidence.Ref, + } + switch event.Type { + case watch.Added, watch.Modified: + watchEvent.Type = connector.WatchUpsert + watchEvent.Fact = fleet.Fact{Evidence: evidence, Workspace: fleet.LocalWorkspace} + case watch.Deleted: + watchEvent.Type = connector.WatchDelete + default: + continue + } + if !sendWatchEvent(ctx, events, watchEvent) { + return ctx.Err() + } + adapter.recordLastSeen(scope, observedAt) + } + } +} + +func (adapter *Adapter) reportWatchError( + ctx context.Context, + events chan<- connector.WatchEvent, + kind, scope string, + err error, +) bool { + return sendWatchEvent(ctx, events, connector.WatchEvent{ + Type: connector.WatchError, Kind: kind, Scope: scope, Err: err, + }) +} + +func (adapter *Adapter) watchStateSnapshot() ( + map[string]connector.Scope, + map[string]dynamic.Interface, + map[string]*rest.Config, +) { + adapter.mu.RLock() + defer adapter.mu.RUnlock() + scopes := make(map[string]connector.Scope, len(adapter.scopes)) + for name, scope := range adapter.scopes { + scopes[name] = cloneScope(scope) + } + clients := make(map[string]dynamic.Interface, len(adapter.watchers)) + for name, client := range adapter.watchers { + clients[name] = client + } + configs := make(map[string]*rest.Config, len(adapter.configs)) + for name, config := range adapter.configs { + configs[name] = rest.CopyConfig(config) + } + return scopes, clients, configs +} + +func normalizeWatchKinds(kinds []string) ([]string, error) { + if len(kinds) == 0 { + return nil, fmt.Errorf("watch resource kind is required") + } + seen := make(map[string]struct{}, len(kinds)) + result := make([]string, 0, len(kinds)) + for _, kind := range kinds { + trimmed := strings.TrimSpace(kind) + if trimmed == "" { + return nil, fmt.Errorf("watch resource kind must not be empty") + } + key := strings.ToLower(trimmed) + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + result = append(result, trimmed) + } + return result, nil +} + +func factsFromObjects( + objects []unstructured.Unstructured, + spec resourceSpec, + scope string, + observedAt time.Time, +) ([]fleet.Fact, error) { + facts := make([]fleet.Fact, 0, len(objects)) + for _, object := range objects { + evidence, err := evidenceFromObject(object, spec, scope, observedAt) + if err != nil { + return nil, err + } + facts = append(facts, fleet.Fact{Evidence: evidence, Workspace: fleet.LocalWorkspace}) + } + return facts, nil +} + +func sendWatchEvent(ctx context.Context, output chan<- connector.WatchEvent, event connector.WatchEvent) bool { + select { + case output <- event: + return true + case <-ctx.Done(): + return false + } +} + +func waitForWatchRetry(ctx context.Context, delay time.Duration) bool { + timer := time.NewTimer(delay) + defer timer.Stop() + select { + case <-ctx.Done(): + return false + case <-timer.C: + return true + } +} + +func pointer[T any](value T) *T { return &value } diff --git a/internal/fleetcache/store.go b/internal/fleetcache/store.go index 0613229..4e0d692 100644 --- a/internal/fleetcache/store.go +++ b/internal/fleetcache/store.go @@ -163,6 +163,129 @@ func (store *Store) Replace(kind string, result fleet.QueryResult) error { return nil } +// ApplyWatchEvent atomically reconciles one live-reader delta without network access. +func (store *Store) ApplyWatchEvent(event connector.WatchEvent) error { + canonical := canonicalKind(event.Kind) + if canonical == "" || strings.TrimSpace(event.Scope) == "" { + return fmt.Errorf("apply watch event: kind and scope are required") + } + if event.Type == connector.WatchError && event.Err == nil { + return fmt.Errorf("apply watch event: error event has no error") + } + + var normalized []Record + switch event.Type { + case connector.WatchSnapshot: + normalized = make([]Record, 0, len(event.Facts)) + for _, fact := range event.Facts { + record, err := normalize(fact) + if err != nil { + return err + } + if record.Cluster != event.Scope { + return fmt.Errorf("apply watch event: fact scope %q does not match stream scope %q", record.Cluster, event.Scope) + } + normalized = append(normalized, record) + } + case connector.WatchUpsert: + record, err := normalize(event.Fact) + if err != nil { + return err + } + if record.Cluster != event.Scope { + return fmt.Errorf("apply watch event: fact scope %q does not match stream scope %q", record.Cluster, event.Scope) + } + normalized = []Record{record} + case connector.WatchDelete: + if event.Ref.Scope != "" && event.Ref.Scope != event.Scope { + return fmt.Errorf("apply watch event: delete scope %q does not match stream scope %q", event.Ref.Scope, event.Scope) + } + case connector.WatchError: + default: + return fmt.Errorf("apply watch event: unsupported type %q", event.Type) + } + + store.mu.Lock() + defer store.mu.Unlock() + if store.paused { + return nil + } + store.aliases[kindAlias(event.Kind)] = canonical + if store.records[canonical] == nil { + store.records[canonical] = make(map[string]Record) + } + for _, record := range normalized { + store.aliases[kindAlias(record.Kind)] = canonical + } + + switch event.Type { + case connector.WatchSnapshot: + for key, record := range store.records[canonical] { + if record.Cluster == event.Scope { + delete(store.records[canonical], key) + } + } + for _, record := range normalized { + store.records[canonical][recordKey(record)] = record + } + store.markScopeReachableLocked(canonical, event.Scope, event.ObservedAt) + case connector.WatchUpsert: + store.records[canonical][recordKey(normalized[0])] = normalized[0] + store.markScopeReachableLocked(canonical, event.Scope, event.ObservedAt) + case connector.WatchDelete: + for key, record := range store.records[canonical] { + if record.Cluster == event.Scope && record.Namespace == event.Ref.Namespace && record.Name == event.Ref.Name { + delete(store.records[canonical], key) + } + } + store.markScopeReachableLocked(canonical, event.Scope, event.ObservedAt) + case connector.WatchError: + store.markScopeUnreachableLocked(canonical, event.Scope, event.Err) + } + store.warmed[canonical] = true + store.updatedAt = store.now().UTC() + store.notifyLocked() + return nil +} + +func (store *Store) markScopeReachableLocked(kind, scope string, observedAt time.Time) { + current := store.scopes[scope] + current.Name = scope + current.Reachable = true + if !observedAt.IsZero() { + current.ObservedAt = observedAt + } + store.scopes[scope] = current + coverage := store.coverage[kind] + coverage.Requested = max(coverage.Requested, len(store.scopes)) + coverage.Unreachable = removeString(coverage.Unreachable, scope) + coverage.Stale = removeString(coverage.Stale, scope) + coverage.Reachable = max(coverage.Requested-len(coverage.Unreachable), 0) + store.coverage[kind] = coverage + if store.allCoverageCompleteLocked() { + store.lastError = "" + } +} + +func (store *Store) markScopeUnreachableLocked(kind, scope string, watchErr error) { + coverage := store.coverage[kind] + coverage.Requested = max(coverage.Requested, len(store.scopes)) + coverage.Unreachable = appendUniqueSorted(coverage.Unreachable, scope) + coverage.Stale = appendUniqueSorted(coverage.Stale, scope) + coverage.Reachable = max(coverage.Requested-len(coverage.Unreachable), 0) + store.coverage[kind] = coverage + store.lastError = fmt.Sprintf("watch %s in %s: %v", kind, scope, watchErr) +} + +func (store *Store) allCoverageCompleteLocked() bool { + for _, coverage := range store.coverage { + if len(coverage.Unreachable) > 0 { + return false + } + } + return true +} + // EndSync marks reconciliation complete and retains any prior data on failure. func (store *Store) EndSync(err error) { store.mu.Lock() @@ -431,6 +554,27 @@ func stringSet(values []string) map[string]struct{} { return result } +func removeString(values []string, unwanted string) []string { + result := make([]string, 0, len(values)) + for _, value := range values { + if value != unwanted { + result = append(result, value) + } + } + return result +} + +func appendUniqueSorted(values []string, added string) []string { + for _, value := range values { + if value == added { + return values + } + } + result := append(append([]string(nil), values...), added) + sort.Strings(result) + return result +} + func cloneRecord(record Record, includeEvidence bool) Record { if includeEvidence { record.Fact = cloneFact(record.Fact) diff --git a/internal/fleetcache/store_test.go b/internal/fleetcache/store_test.go index e6130ed..ab804cb 100644 --- a/internal/fleetcache/store_test.go +++ b/internal/fleetcache/store_test.go @@ -5,7 +5,9 @@ package fleetcache import ( "context" "encoding/json" + "errors" "slices" + "strings" "sync" "testing" "time" @@ -129,6 +131,68 @@ func TestStoreMapsGenericResourceAliasToAdvertisedKind(t *testing.T) { } } +func TestStoreAppliesWatchDeltasAndPreservesFailedScopeRows(t *testing.T) { + t.Parallel() + now := time.Now().UTC() + store := newStore(func() time.Time { return now }, time.Minute) + store.SetDiscovery(connector.Discovery{Scopes: []connector.Scope{ + {Name: "alpha", Reachable: true, ObservedAt: now}, + {Name: "beta", Reachable: true, ObservedAt: now}, + }}) + alpha := podFact(t, "alpha", "api-0", "Running", "image:v1", now) + beta := podFact(t, "beta", "api-0", "Running", "image:v1", now) + if err := store.Replace("Pod", fleet.QueryResult{ + Facts: []fleet.Fact{alpha, beta}, Coverage: fleet.Coverage{Requested: 2, Reachable: 2}, + }); err != nil { + t.Fatalf("Replace() error = %v", err) + } + + now = now.Add(time.Second) + updated := podFact(t, "alpha", "api-1", "Running", "image:v2", now) + if err := store.ApplyWatchEvent(connector.WatchEvent{ + Type: connector.WatchSnapshot, Kind: "pods", Scope: "alpha", Facts: []fleet.Fact{updated}, ObservedAt: now, + }); err != nil { + t.Fatalf("ApplyWatchEvent(snapshot) error = %v", err) + } + if err := store.ApplyWatchEvent(connector.WatchEvent{ + Type: connector.WatchError, Kind: "Pod", Scope: "beta", Err: errors.New("watch disconnected"), + }); err != nil { + t.Fatalf("ApplyWatchEvent(error) error = %v", err) + } + snapshot := store.Query(Query{Kind: "Pod"}) + if len(snapshot.Records) != 2 || snapshot.Records[0].Name != "api-1" || !snapshot.Records[1].Stale { + t.Fatalf("snapshot after deltas = %#v", snapshot) + } + if !slices.Equal(snapshot.Coverage.Unreachable, []string{"beta"}) { + t.Fatalf("unreachable = %v, want beta", snapshot.Coverage.Unreachable) + } + + if err := store.ApplyWatchEvent(connector.WatchEvent{ + Type: connector.WatchDelete, Kind: "pods", Scope: "alpha", + Ref: fleet.ResourceRef{Scope: "alpha", Kind: "Pod", Namespace: "apps", Name: "api-1"}, ObservedAt: now, + }); err != nil { + t.Fatalf("ApplyWatchEvent(delete) error = %v", err) + } + if records := store.Query(Query{Kind: "Pod"}).Records; len(records) != 1 || records[0].Cluster != "beta" { + t.Fatalf("records after delete = %#v", records) + } +} + +func TestStoreRejectsCrossScopeWatchFacts(t *testing.T) { + t.Parallel() + store := New() + wrongScope := podFact(t, "beta", "api-0", "Running", "image:v1", time.Now().UTC()) + err := store.ApplyWatchEvent(connector.WatchEvent{ + Type: connector.WatchUpsert, Kind: "Pod", Scope: "alpha", Fact: wrongScope, + }) + if err == nil || !strings.Contains(err.Error(), "does not match stream scope") { + t.Fatalf("ApplyWatchEvent() error = %v, want scope rejection", err) + } + if snapshot := store.Query(Query{}); len(snapshot.Records) != 0 { + t.Fatalf("records = %#v, want atomic rejection", snapshot.Records) + } +} + func TestStorePauseAndChangeNotification(t *testing.T) { t.Parallel() store := New() diff --git a/internal/hydrate/hydrator.go b/internal/hydrate/hydrator.go index 7f8b154..c466ee5 100644 --- a/internal/hydrate/hydrator.go +++ b/internal/hydrate/hydrator.go @@ -9,6 +9,7 @@ import ( "fmt" "strings" "sync" + "time" "github.com/ArdurAI/sith/internal/connector" "github.com/ArdurAI/sith/internal/fleet" @@ -17,6 +18,11 @@ import ( var tierOneKinds = []string{"Pod", "Deployment", "Event", "Node"} +const ( + defaultResyncInterval = 2 * time.Minute + watchReconnectDelay = 2 * time.Second +) + // TierOneKinds returns the frequency-ordered daily-loop lenses for the Slice-2 fleet view. func TierOneKinds() []string { return append([]string(nil), tierOneKinds...) @@ -34,6 +40,18 @@ type Option func(*options) error type options struct { kinds []string maxConcurrency int + resyncInterval time.Duration +} + +// WithResyncInterval sets the slow safety resync used around primary watch streams. +func WithResyncInterval(interval time.Duration) Option { + return func(settings *options) error { + if interval <= 0 { + return fmt.Errorf("resync interval must be positive") + } + settings.resyncInterval = interval + return nil + } } // WithKinds limits hydration to an explicit resource-kind set. @@ -60,10 +78,13 @@ func WithMaxConcurrency(limit int) Option { // Hydrator is the only interaction-layer component allowed to call a connector. type Hydrator struct { - reader connector.Reader - store *fleetcache.Store - kinds []string - limit int + reader connector.Reader + store *fleetcache.Store + mu sync.RWMutex + kinds []string + changed chan struct{} + limit int + resync time.Duration } // New validates and constructs a background hydrator. @@ -74,7 +95,9 @@ func New(reader connector.Reader, store *fleetcache.Store, opts ...Option) (*Hyd if store == nil { return nil, fmt.Errorf("construct hydrator: store is nil") } - settings := options{kinds: TierOneKinds(), maxConcurrency: len(tierOneKinds)} + settings := options{ + kinds: TierOneKinds(), maxConcurrency: len(tierOneKinds), resyncInterval: defaultResyncInterval, + } for _, option := range opts { if option == nil { return nil, fmt.Errorf("construct hydrator: option is nil") @@ -87,17 +110,22 @@ func New(reader connector.Reader, store *fleetcache.Store, opts ...Option) (*Hyd if err != nil { return nil, fmt.Errorf("construct hydrator: %w", err) } - return &Hydrator{reader: reader, store: store, kinds: kinds, limit: settings.maxConcurrency}, nil + return &Hydrator{ + reader: reader, store: store, kinds: kinds, changed: make(chan struct{}), + limit: settings.maxConcurrency, resync: settings.resyncInterval, + }, nil } // Kinds returns the deterministic resource-kind set this hydrator reconciles. func (hydrator *Hydrator) Kinds() []string { + hydrator.mu.RLock() + defer hydrator.mu.RUnlock() return append([]string(nil), hydrator.kinds...) } // SyncOnce discovers contexts and independently reconciles each configured lens. func (hydrator *Hydrator) SyncOnce(ctx context.Context) error { - return hydrator.sync(ctx, hydrator.kinds) + return hydrator.sync(ctx, hydrator.Kinds()) } // SyncKinds discovers contexts and reconciles an on-demand resource-kind set. @@ -106,7 +134,123 @@ func (hydrator *Hydrator) SyncKinds(ctx context.Context, kinds ...string) error if err != nil { return fmt.Errorf("sync hydration kinds: %w", err) } - return hydrator.sync(ctx, normalized) + syncErr := hydrator.sync(ctx, normalized) + hydrator.registerKinds(normalized) + return syncErr +} + +// Run keeps the configured cache warm, preferring live watch deltas with a slow safety resync. +func (hydrator *Hydrator) Run(ctx context.Context) error { + if err := hydrator.SyncOnce(ctx); err != nil && ctx.Err() != nil { + return ctx.Err() + } + watcher, supportsWatch := hydrator.reader.(connector.Watcher) + if !supportsWatch { + return hydrator.runPollingFallback(ctx) + } + return hydrator.runWatch(ctx, watcher) +} + +func (hydrator *Hydrator) runWatch(ctx context.Context, watcher connector.Watcher) error { + resync := time.NewTimer(hydrator.resync) + defer resync.Stop() + for ctx.Err() == nil { + kinds, changed := hydrator.watchKinds() + watchCtx, cancel := context.WithCancel(ctx) + events, err := watcher.Watch(watchCtx, kinds...) + if err != nil { + cancel() + if !waitForRetry(ctx, watchReconnectDelay) { + return nil + } + _ = hydrator.SyncOnce(ctx) + continue + } + restart := false + for !restart { + select { + case <-ctx.Done(): + cancel() + return nil + case <-changed: + cancel() + restart = true + case <-resync.C: + cancel() + _ = hydrator.SyncOnce(ctx) + resync.Reset(hydrator.resync) + restart = true + case event, open := <-events: + if !open { + cancel() + if !waitForRetry(ctx, watchReconnectDelay) { + return nil + } + _ = hydrator.SyncOnce(ctx) + restart = true + continue + } + if err := hydrator.store.ApplyWatchEvent(event); err != nil { + cancel() + return fmt.Errorf("apply live cache delta: %w", err) + } + } + } + cancel() + } + return nil +} + +func (hydrator *Hydrator) runPollingFallback(ctx context.Context) error { + ticker := time.NewTicker(hydrator.resync) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return nil + case <-ticker.C: + _ = hydrator.SyncOnce(ctx) + } + } +} + +func (hydrator *Hydrator) registerKinds(kinds []string) { + hydrator.mu.Lock() + defer hydrator.mu.Unlock() + known := make(map[string]struct{}, len(hydrator.kinds)) + for _, kind := range hydrator.kinds { + known[kind] = struct{}{} + } + changed := false + for _, kind := range kinds { + if _, exists := known[kind]; exists { + continue + } + hydrator.kinds = append(hydrator.kinds, kind) + known[kind] = struct{}{} + changed = true + } + if changed { + close(hydrator.changed) + hydrator.changed = make(chan struct{}) + } +} + +func (hydrator *Hydrator) watchKinds() ([]string, <-chan struct{}) { + hydrator.mu.RLock() + defer hydrator.mu.RUnlock() + return append([]string(nil), hydrator.kinds...), hydrator.changed +} + +func waitForRetry(ctx context.Context, delay time.Duration) bool { + timer := time.NewTimer(delay) + defer timer.Stop() + select { + case <-ctx.Done(): + return false + case <-timer.C: + return true + } } func (hydrator *Hydrator) sync(ctx context.Context, kinds []string) error { diff --git a/internal/hydrate/hydrator_test.go b/internal/hydrate/hydrator_test.go index 0271d60..e15bf80 100644 --- a/internal/hydrate/hydrator_test.go +++ b/internal/hydrate/hydrator_test.go @@ -32,6 +32,7 @@ func TestNewValidatesOptionsAndPreservesLensOrder(t *testing.T) { {name: "empty kinds", reader: reader, store: store, option: WithKinds()}, {name: "blank kind", reader: reader, store: store, option: WithKinds(" ")}, {name: "zero concurrency", reader: reader, store: store, option: WithMaxConcurrency(0)}, + {name: "zero resync", reader: reader, store: store, option: WithResyncInterval(0)}, } { t.Run(test.name, func(t *testing.T) { t.Parallel() @@ -143,6 +144,53 @@ func TestSyncOnceRejectsPauseAndDuplicateRun(t *testing.T) { } } +func TestRunAppliesWatchDeltasAndAddsGenericKinds(t *testing.T) { + t.Parallel() + reader := &watchingReader{ + fakeReader: &fakeReader{}, + calls: make(chan []string, 4), + events: make(chan connector.WatchEvent, 4), + } + store := fleetcache.New() + hydrator, err := New(reader, store, WithResyncInterval(time.Hour)) + if err != nil { + t.Fatalf("New() error = %v", err) + } + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- hydrator.Run(ctx) }() + if kinds := receiveKinds(t, reader.calls); !slices.Equal(kinds, TierOneKinds()) { + t.Fatalf("initial watch kinds = %v", kinds) + } + + now := time.Now().UTC() + reader.events <- connector.WatchEvent{ + Type: connector.WatchUpsert, Kind: "Pod", Scope: "alpha", + Fact: fakeFact("Pod", "alpha", now), ObservedAt: now, + } + waitForCondition(t, func() bool { + return len(store.Query(fleetcache.Query{Kind: "Pod"}).Records) == 2 + }) + + if err := hydrator.SyncKinds(ctx, "widgets"); err != nil { + t.Fatalf("SyncKinds() error = %v", err) + } + if kinds := receiveKinds(t, reader.calls); !slices.Contains(kinds, "Widgets") { + t.Fatalf("updated watch kinds = %v, want Widgets", kinds) + } + reader.events <- connector.WatchEvent{ + Type: connector.WatchError, Kind: "Pod", Scope: "beta", Err: errors.New("connection reset"), + } + waitForCondition(t, func() bool { + return slices.Contains(store.Query(fleetcache.Query{Kind: "Pod"}).Coverage.Unreachable, "beta") + }) + + cancel() + if err := <-done; err != nil { + t.Fatalf("Run() error = %v", err) + } +} + type fakeReader struct { mu sync.Mutex active int @@ -153,6 +201,33 @@ type fakeReader struct { failures map[string]error } +type watchingReader struct { + *fakeReader + calls chan []string + events chan connector.WatchEvent +} + +func (reader *watchingReader) Watch(ctx context.Context, kinds ...string) (<-chan connector.WatchEvent, error) { + reader.calls <- append([]string(nil), kinds...) + output := make(chan connector.WatchEvent) + go func() { + defer close(output) + for { + select { + case <-ctx.Done(): + return + case event := <-reader.events: + select { + case output <- event: + case <-ctx.Done(): + return + } + } + } + }() + return output, nil +} + func (*fakeReader) Kind() string { return "fake" } func (*fakeReader) Capabilities() []connector.Capability { @@ -272,3 +347,26 @@ func stringsLower(value string) string { } return string(result) } + +func receiveKinds(t *testing.T, calls <-chan []string) []string { + t.Helper() + select { + case kinds := <-calls: + return kinds + case <-time.After(2 * time.Second): + t.Fatal("timed out waiting for watch call") + return nil + } +} + +func waitForCondition(t *testing.T, condition func() bool) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if condition() { + return + } + time.Sleep(time.Millisecond) + } + t.Fatal("condition did not become true") +} diff --git a/internal/tui/model.go b/internal/tui/model.go index 8abc604..73f485c 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -23,8 +23,6 @@ import ( "github.com/ArdurAI/sith/internal/hydrate" ) -const defaultRefreshInterval = 15 * time.Second - type inputMode uint8 const ( @@ -36,6 +34,7 @@ const ( // Syncer is the narrow background-I/O seam consumed by the TUI runtime. type Syncer interface { + Run(ctx context.Context) error SyncOnce(ctx context.Context) error SyncKinds(ctx context.Context, kinds ...string) error } @@ -59,7 +58,6 @@ type Model struct { coverage bool version uint64 lastError string - refresh time.Duration now func() time.Time } @@ -68,7 +66,6 @@ type cacheChangedMsg struct { version uint64 err error } -type refreshTickMsg time.Time // NewModel validates and constructs the cold first-paint model. func NewModel(ctx context.Context, store *fleetcache.Store, syncer Syncer) (*Model, error) { @@ -82,14 +79,13 @@ func NewModel(ctx context.Context, store *fleetcache.Store, syncer Syncer) (*Mod return nil, fmt.Errorf("construct TUI model: syncer is nil") } return &Model{ - ctx: ctx, - store: store, - syncer: syncer, - lenses: hydrate.TierOneKinds(), - width: 120, - height: 30, - refresh: defaultRefreshInterval, - now: time.Now, + ctx: ctx, + store: store, + syncer: syncer, + lenses: hydrate.TierOneKinds(), + width: 120, + height: 30, + now: time.Now, }, nil } @@ -106,9 +102,9 @@ func Run(ctx context.Context, store *fleetcache.Store, syncer Syncer, input io.R return nil } -// Init starts background hydration, store notifications, and the refresh clock independently. +// Init starts watch-backed hydration and store notifications independently. func (model *Model) Init() tea.Cmd { - return tea.Batch(model.syncCommand(), model.waitCommand(model.version), model.tickCommand()) + return tea.Batch(model.watchCommand(), model.waitCommand(model.version)) } // Update handles interaction entirely against cache state; only explicit sync commands call I/O. @@ -129,8 +125,6 @@ func (model *Model) Update(message tea.Msg) (tea.Model, tea.Cmd) { } else if typed.err == nil { model.lastError = "" } - case refreshTickMsg: - return model, tea.Batch(model.syncCommand(), model.tickCommand()) case tea.KeyPressMsg: return model.handleKey(typed) } @@ -185,6 +179,10 @@ func (model *Model) View() tea.View { content.WriteString("warning: ") content.WriteString(model.lastError) content.WriteString("\n") + } else if snapshot.LastError != "" { + content.WriteString("warning: ") + content.WriteString(snapshot.LastError) + content.WriteString("\n") } view := tea.NewView(limitWidth(content.String(), model.width)) view.AltScreen = true @@ -341,6 +339,10 @@ func (model *Model) syncCommand() tea.Cmd { return func() tea.Msg { return syncDoneMsg{err: model.syncer.SyncOnce(model.ctx)} } } +func (model *Model) watchCommand() tea.Cmd { + return func() tea.Msg { return syncDoneMsg{err: model.syncer.Run(model.ctx)} } +} + func (model *Model) syncLensCommand() tea.Cmd { kind := model.currentLens() return func() tea.Msg { return syncDoneMsg{err: model.syncer.SyncKinds(model.ctx, kind)} } @@ -353,10 +355,6 @@ func (model *Model) waitCommand(after uint64) tea.Cmd { } } -func (model *Model) tickCommand() tea.Cmd { - return tea.Tick(model.refresh, func(value time.Time) tea.Msg { return refreshTickMsg(value) }) -} - func (model *Model) currentLens() string { return model.lenses[model.lens] } diff --git a/internal/tui/model_test.go b/internal/tui/model_test.go index a051d71..7a00fbe 100644 --- a/internal/tui/model_test.go +++ b/internal/tui/model_test.go @@ -214,11 +214,6 @@ func TestUpdateHandlesBackgroundMessagesAndCommands(t *testing.T) { if model.lastError != "" { t.Fatalf("last error = %q, want cleared", model.lastError) } - _, command = model.Update(refreshTickMsg(time.Now())) - if command == nil { - t.Fatal("refresh tick command = nil") - } - for _, commandText := range []string{"ctx", "unknown", "ctx alpha", "refresh", "resume"} { _, _ = model.Update(keyMessage(":")) _, _ = model.Update(keyMessage(commandText)) @@ -273,9 +268,15 @@ func TestRunHonorsCanceledContext(t *testing.T) { } type countingSyncer struct { - calls atomic.Int64 - kindCalls atomic.Int64 - lastKinds atomic.Value + calls atomic.Int64 + watchCalls atomic.Int64 + kindCalls atomic.Int64 + lastKinds atomic.Value +} + +func (syncer *countingSyncer) Run(_ context.Context) error { + syncer.watchCalls.Add(1) + return nil } func (syncer *countingSyncer) SyncOnce(_ context.Context) error { diff --git a/sessions/2026-07-10-slice-2-cache-first-fleet.md b/sessions/2026-07-10-slice-2-cache-first-fleet.md index 78b38d5..ff11617 100644 --- a/sessions/2026-07-10-slice-2-cache-first-fleet.md +++ b/sessions/2026-07-10-slice-2-cache-first-fleet.md @@ -68,6 +68,21 @@ built CLI discovers and renders them across 2/3 contexts with honest partial cov fallback after a changed-file secret scan. The review found and fixed the generic alias mismatch; it also found that bounded background polling does not satisfy #33's explicit watch-stream contract. [C] Checkpoint #6: this commit — generic discovery and lens proof; next: watch-backed delta hydration. +[A] Action: Added the source-abstract optional live-reader extension without changing the locked +seven-verb registry. The kubeconfig adapter now runs independent list-watch loops per reachable +context/lens with bounded reconnects, a five-minute server watch timeout, and a two-minute safety +rediscovery; non-watch readers retain a slow fallback. Generic lenses join the active watch set. +[A] Action: Added atomic per-scope snapshot/upsert/delete/error application to the store. Disconnects +retain and immediately stale last-known rows; pause drops incoming mutations and resume forces a +full reconciliation. TUI startup now owns the long-running hydrator rather than a 15-second poll. +[T] Test: Race tests cover adapter snapshot/upsert/delete streams, cache delta lifecycle, source- +scope rejection, disconnect staleness, dynamic generic-kind watch restart, and shutdown. The real +two-cluster gate creates and deletes a Pod and observes both cache deltas without manual resync; the +complete gate passes in 72 seconds. +[R] Review: Manual red-team review found and fixed cross-scope fact injection at the live-reader +boundary. Continuous cost is explicit: one watch per active lens per reachable context plus bounded +relist/recovery traffic; no credential, object, or telemetry leaves the machine. +[C] Checkpoint #7: this commit — watch-backed fleet deltas; next: generic server-print columns and final review. --- diff --git a/tests/e2e/kind_fanout_test.go b/tests/e2e/kind_fanout_test.go index 5124d0a..e6513bf 100644 --- a/tests/e2e/kind_fanout_test.go +++ b/tests/e2e/kind_fanout_test.go @@ -181,6 +181,39 @@ func TestKindFleetFanout(t *testing.T) { t.Fatalf("generic coverage/stderr = %#v/%q, want partial two-of-three", genericSnapshot.Coverage, genericStderr) } + watchStore := fleetcache.New() + watchHydrator, err := hydrate.New(adapter, watchStore, hydrate.WithResyncInterval(10*time.Minute)) + if err != nil { + t.Fatalf("construct watch hydrator: %v", err) + } + watchCtx, watchCancel := context.WithCancel(ctx) + watchDone := make(chan error, 1) + go func() { watchDone <- watchHydrator.Run(watchCtx) }() + waitForCacheRecord(ctx, t, watchStore, "kind-"+clusterNames[0], "sith-vuln-sample", true) + watchClient := dynamicClientForContext(t, kubeconfigPath, "kind-"+clusterNames[0]) + watchPod := &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "v1", + "kind": "Pod", + "metadata": map[string]any{"name": "sith-watch-sample", "namespace": "default"}, + "spec": map[string]any{ + "containers": []any{map[string]any{"name": "app", "image": "registry.example/watch:v1"}}, + }, + }} + if _, err := watchClient.Resource(schema.GroupVersionResource{Version: "v1", Resource: "pods"}). + Namespace("default").Create(ctx, watchPod, metav1.CreateOptions{}); err != nil { + t.Fatalf("create watched pod: %v", err) + } + waitForCacheRecord(ctx, t, watchStore, "kind-"+clusterNames[0], "sith-watch-sample", true) + if err := watchClient.Resource(schema.GroupVersionResource{Version: "v1", Resource: "pods"}). + Namespace("default").Delete(ctx, "sith-watch-sample", metav1.DeleteOptions{}); err != nil { + t.Fatalf("delete watched pod: %v", err) + } + waitForCacheRecord(ctx, t, watchStore, "kind-"+clusterNames[0], "sith-watch-sample", false) + watchCancel() + if err := <-watchDone; err != nil { + t.Fatalf("watch hydrator shutdown: %v", err) + } + searchOutput, searchStderr, err := runSith(ctx, binary, kubeconfigPath, "search", "image:*log4j*", "--output", "json") if err != nil { t.Fatalf("run sith search against kind: %v\nstdout=%s\nstderr=%s", err, searchOutput, searchStderr) @@ -301,6 +334,60 @@ func seedKindResources(ctx context.Context, t *testing.T, kubeconfigPath string, } } +func dynamicClientForContext(t *testing.T, kubeconfigPath, contextName string) dynamic.Interface { + t.Helper() + rawConfig, err := clientcmd.LoadFromFile(kubeconfigPath) + if err != nil { + t.Fatalf("load kubeconfig for %s: %v", contextName, err) + } + clientConfig := clientcmd.NewNonInteractiveClientConfig( + *rawConfig, contextName, &clientcmd.ConfigOverrides{}, + &clientcmd.ClientConfigLoadingRules{ExplicitPath: kubeconfigPath}, + ) + restConfig, err := clientConfig.ClientConfig() + if err != nil { + t.Fatalf("build client config for %s: %v", contextName, err) + } + client, err := dynamic.NewForConfig(restConfig) + if err != nil { + t.Fatalf("build dynamic client for %s: %v", contextName, err) + } + return client +} + +func waitForCacheRecord( + ctx context.Context, + t *testing.T, + store *fleetcache.Store, + cluster, name string, + want bool, +) { + t.Helper() + ticker := time.NewTicker(10 * time.Millisecond) + defer ticker.Stop() + deadline := time.NewTimer(20 * time.Second) + defer deadline.Stop() + for { + found := false + for _, record := range store.Query(fleetcache.Query{Kind: "Pod"}).Records { + if record.Cluster == cluster && record.Name == name { + found = true + break + } + } + if found == want { + return + } + select { + case <-ctx.Done(): + t.Fatalf("wait for cached record %s/%s: %v", cluster, name, ctx.Err()) + case <-deadline.C: + t.Fatalf("cached record %s/%s presence = %t, want %t", cluster, name, found, want) + case <-ticker.C: + } + } +} + func runSith(ctx context.Context, binary, kubeconfigPath string, args ...string) ([]byte, string, error) { command := exec.CommandContext(ctx, binary, args...) command.Env = append(os.Environ(), From ec2f089c66676f1a9d7733ab92b619bd9f527c3f Mon Sep 17 00:00:00 2001 From: Gnani Rahul Date: Fri, 10 Jul 2026 17:02:57 -0500 Subject: [PATCH 13/14] feat(tui): render generic server columns GSTACK-Checkpoint: 2026-07-10/slice-2-cache-first-fleet#8 Signed-off-by: Gnani Rahul --- README.md | 7 +- internal/connector/kubeconfig/adapter.go | 31 +++- internal/connector/kubeconfig/adapter_test.go | 12 +- internal/connector/kubeconfig/resources.go | 31 +++- internal/connector/kubeconfig/table.go | 160 ++++++++++++++++++ internal/connector/kubeconfig/table_test.go | 65 +++++++ internal/connector/kubeconfig/watch.go | 56 +++++- internal/fleet/resource.go | 8 + internal/fleetcache/record.go | 39 +++-- internal/fleetcache/store.go | 1 + internal/fleetrender/table.go | 86 +++++++++- internal/fleetrender/table_test.go | 33 ++++ .../2026-07-10-slice-2-cache-first-fleet.md | 12 +- tests/e2e/kind_fanout_test.go | 10 +- 14 files changed, 513 insertions(+), 38 deletions(-) create mode 100644 internal/connector/kubeconfig/table.go create mode 100644 internal/connector/kubeconfig/table_test.go diff --git a/README.md b/README.md index 23d3a45..0d349d1 100644 --- a/README.md +++ b/README.md @@ -37,9 +37,10 @@ artifact. The TUI opens only when stdin and stdout are terminals; redirected bare invocations remain script-safe and print help. Tier-1 lenses are Pods, Deployments, Events, and Nodes. Use `:` for -lens/context commands (including `:` for an API-discovered generic resource), `/` to filter -the current lens, `Ctrl-K` for whole-fleet fuzzy/structured search, number keys for cluster scope, -`c` for coverage, and `Ctrl-R` for a non-blocking refresh. +lens/context commands (including `:` for an API-discovered generic resource rendered with +the server's print columns), `/` to filter the current lens, `Ctrl-K` for whole-fleet fuzzy/ +structured search, number keys for cluster scope, `c` for coverage, and `Ctrl-R` for a non-blocking +refresh. The UI uses Bubble Tea v2.0.8 core only; tables and search remain local so no optional styling or component dependency enters the binary. diff --git a/internal/connector/kubeconfig/adapter.go b/internal/connector/kubeconfig/adapter.go index 8123962..571cc33 100644 --- a/internal/connector/kubeconfig/adapter.go +++ b/internal/connector/kubeconfig/adapter.go @@ -17,6 +17,7 @@ import ( clientcmdapi "k8s.io/client-go/tools/clientcmd/api" "github.com/ArdurAI/sith/internal/connector" + "github.com/ArdurAI/sith/internal/fleet" ) const ( @@ -45,6 +46,12 @@ var supportedKinds = []string{ type probeFunc func(ctx context.Context, config *rest.Config) error type dynamicFactory func(config *rest.Config) (dynamic.Interface, error) type resourceResolver func(ctx context.Context, config *rest.Config, kind string) (resourceSpec, error) +type tablePrinter func( + ctx context.Context, + spec resourceSpec, + namespace, name, labelSelector string, +) (map[string][]fleet.DisplayField, error) +type tableFactory func(config *rest.Config) (tablePrinter, error) type options struct { loadingRules *clientcmd.ClientConfigLoadingRules @@ -56,6 +63,7 @@ type options struct { probe probeFunc dynamic dynamicFactory resolve resourceResolver + table tableFactory } // Option configures the local kubeconfig adapter. @@ -156,6 +164,16 @@ func withResourceResolver(resolver resourceResolver) Option { } } +func withTableFactory(factory tableFactory) Option { + return func(settings *options) error { + if factory == nil { + return fmt.Errorf("table factory must not be nil") + } + settings.table = factory + return nil + } +} + // Adapter discovers contexts and performs independent local client-go reads. type Adapter struct { settings options @@ -167,6 +185,7 @@ type Adapter struct { watchers map[string]dynamic.Interface configs map[string]*rest.Config resources map[string]map[string]resourceSpec + tables map[string]tablePrinter lastSeen map[string]time.Time } @@ -208,6 +227,7 @@ func defaultOptions() options { return dynamic.NewForConfig(config) }, resolve: defaultResourceResolver, + table: newTablePrinter, } } @@ -219,6 +239,7 @@ func newAdapter(settings options) *Adapter { watchers: make(map[string]dynamic.Interface), configs: make(map[string]*rest.Config), resources: make(map[string]map[string]resourceSpec), + tables: make(map[string]tablePrinter), lastSeen: make(map[string]time.Time), } } @@ -271,6 +292,7 @@ func (adapter *Adapter) Discover(ctx context.Context) (connector.Discovery, erro clients := make(map[string]dynamic.Interface, len(results)) watchers := make(map[string]dynamic.Interface, len(results)) configs := make(map[string]*rest.Config, len(results)) + tables := make(map[string]tablePrinter, len(results)) lastSeen := make(map[string]time.Time, len(results)) for _, result := range results { scopes = append(scopes, result.scope) @@ -278,6 +300,7 @@ func (adapter *Adapter) Discover(ctx context.Context) (connector.Discovery, erro clients[result.scope.Name] = result.client watchers[result.scope.Name] = result.watcher configs[result.scope.Name] = rest.CopyConfig(result.config) + tables[result.scope.Name] = result.table } else { unreachable = append(unreachable, result.scope.Name) } @@ -296,6 +319,7 @@ func (adapter *Adapter) Discover(ctx context.Context) (connector.Discovery, erro adapter.watchers = watchers adapter.configs = configs adapter.resources = make(map[string]map[string]resourceSpec) + adapter.tables = tables adapter.lastSeen = lastSeen adapter.mu.Unlock() @@ -307,6 +331,7 @@ type contextResult struct { client dynamic.Interface watcher dynamic.Interface config *rest.Config + table tablePrinter } func (adapter *Adapter) probeContext( @@ -353,10 +378,14 @@ func (adapter *Adapter) probeContext( if err != nil { return contextResult{scope: scope} } + table, err := adapter.settings.table(requestConfig) + if err != nil { + return contextResult{scope: scope} + } scope.Reachable = true scope.ObservedAt = adapter.settings.now().UTC() - return contextResult{scope: scope, client: client, watcher: watcher, config: requestConfig} + return contextResult{scope: scope, client: client, watcher: watcher, config: requestConfig, table: table} } func (adapter *Adapter) runBounded(count int, operation func(index int)) { diff --git a/internal/connector/kubeconfig/adapter_test.go b/internal/connector/kubeconfig/adapter_test.go index 3644897..502099b 100644 --- a/internal/connector/kubeconfig/adapter_test.go +++ b/internal/connector/kubeconfig/adapter_test.go @@ -48,6 +48,7 @@ func TestNewRejectsInvalidOptions(t *testing.T) { {name: "nil probe", option: withProbe(nil)}, {name: "nil dynamic factory", option: withDynamicFactory(nil)}, {name: "nil resource resolver", option: withResourceResolver(nil)}, + {name: "nil table factory", option: withTableFactory(nil)}, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { @@ -84,6 +85,15 @@ func TestGenericResourceResolutionIsCached(t *testing.T) { } return resourceSpec{kind: "Widget", gvr: gvr, namespaced: true}, nil }), + withTableFactory(func(_ *rest.Config) (tablePrinter, error) { + return func( + _ context.Context, _ resourceSpec, _, _, _ string, + ) (map[string][]fleet.DisplayField, error) { + return map[string][]fleet.DisplayField{ + tableObjectKey("apps", "sample"): {{Name: "Name", Value: "sample"}, {Name: "Ready", Value: "1/1"}}, + }, nil + }, nil + }), ) if err != nil { t.Fatalf("New() error = %v", err) @@ -96,7 +106,7 @@ func TestGenericResourceResolutionIsCached(t *testing.T) { if err != nil { t.Fatalf("Query() error = %v", err) } - if len(result.Facts) != 1 || result.Facts[0].Ref.Kind != "Widget" { + if len(result.Facts) != 1 || result.Facts[0].Ref.Kind != "Widget" || len(result.Facts[0].Display) != 2 { t.Fatalf("Facts = %#v, want one Widget", result.Facts) } } diff --git a/internal/connector/kubeconfig/resources.go b/internal/connector/kubeconfig/resources.go index 1e49f32..f6173b1 100644 --- a/internal/connector/kubeconfig/resources.go +++ b/internal/connector/kubeconfig/resources.go @@ -137,13 +137,15 @@ func (adapter *Adapter) Query(ctx context.Context, query fleet.Query) (fleet.Que return fleet.QueryResult{}, err } - scopes, clients, configs, lastSeen := adapter.stateSnapshot() + scopes, clients, configs, tables, lastSeen := adapter.stateSnapshot() targets := targetScopeNames(query.Scopes, scopes) results := make([]scopeQueryResult, len(targets)) adapter.runBounded(len(targets), func(index int) { name := targets[index] result, err := callWithTimeout(ctx, adapter.settings.requestTimeout, func(requestCtx context.Context) (scopeQueryResult, error) { - return adapter.queryScope(requestCtx, name, clients[name], configs[name], spec, labelSelector.String(), query), nil + return adapter.queryScope( + requestCtx, name, clients[name], configs[name], tables[name], spec, labelSelector.String(), query, + ), nil }) if err != nil { result = scopeQueryResult{name: name, err: err} @@ -200,6 +202,7 @@ func (adapter *Adapter) queryScope( name string, client dynamic.Interface, config *rest.Config, + table tablePrinter, spec resourceSpec, labelSelector string, query fleet.Query, @@ -212,7 +215,8 @@ func (adapter *Adapter) queryScope( if query.Selector.ResourceKind == "" { return result } - if spec.gvr.Resource == "" { + generic := spec.gvr.Resource == "" + if generic { var err error spec, err = adapter.resolveResource(ctx, name, config, query.Selector.ResourceKind) if err != nil { @@ -241,6 +245,19 @@ func (adapter *Adapter) queryScope( return result } result.facts = make([]fleet.Fact, 0, len(list.Items)) + display := map[string][]fleet.DisplayField{} + if generic { + if table == nil { + result.err = fmt.Errorf("server table client is unavailable for %s", name) + return result + } + var err error + display, err = table(ctx, spec, query.Selector.Namespace, "", labelSelector) + if err != nil { + result.err = err + return result + } + } for _, object := range list.Items { if query.Selector.NamePrefix != "" && !strings.HasPrefix(object.GetName(), query.Selector.NamePrefix) { continue @@ -253,6 +270,7 @@ func (adapter *Adapter) queryScope( result.err = err return result } + evidence.Display = append([]fleet.DisplayField(nil), display[tableObjectKey(object.GetNamespace(), object.GetName())]...) result.facts = append(result.facts, fleet.Fact{Evidence: evidence, Workspace: fleet.LocalWorkspace}) } return result @@ -462,6 +480,7 @@ func (adapter *Adapter) stateSnapshot() ( map[string]connector.Scope, map[string]dynamic.Interface, map[string]*rest.Config, + map[string]tablePrinter, map[string]time.Time, ) { adapter.mu.RLock() @@ -478,11 +497,15 @@ func (adapter *Adapter) stateSnapshot() ( for name, config := range adapter.configs { configs[name] = rest.CopyConfig(config) } + tables := make(map[string]tablePrinter, len(adapter.tables)) + for name, table := range adapter.tables { + tables[name] = table + } lastSeen := make(map[string]time.Time, len(adapter.lastSeen)) for name, observed := range adapter.lastSeen { lastSeen[name] = observed } - return scopes, clients, configs, lastSeen + return scopes, clients, configs, tables, lastSeen } func (adapter *Adapter) recordLastSeen(name string, observed time.Time) { diff --git a/internal/connector/kubeconfig/table.go b/internal/connector/kubeconfig/table.go new file mode 100644 index 0000000..45f364d --- /dev/null +++ b/internal/connector/kubeconfig/table.go @@ -0,0 +1,160 @@ +// SPDX-License-Identifier: Apache-2.0 + +package kubeconfig + +import ( + "context" + "encoding/json" + "fmt" + "strconv" + "strings" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/rest" + + "github.com/ArdurAI/sith/internal/fleet" +) + +const tableAccept = "application/json;as=Table;g=meta.k8s.io;v=v1" + +func newTablePrinter(config *rest.Config) (tablePrinter, error) { + tableConfig := dynamic.ConfigFor(config) + tableConfig.GroupVersion = nil + tableConfig.APIPath = "/if-you-see-this-search-for-the-break" + client, err := rest.UnversionedRESTClientFor(tableConfig) + if err != nil { + return nil, fmt.Errorf("create table client: %w", err) + } + return func( + ctx context.Context, + spec resourceSpec, + namespace, name, labelSelector string, + ) (map[string][]fleet.DisplayField, error) { + return requestTable(ctx, client, spec, namespace, name, labelSelector) + }, nil +} + +func requestTable( + ctx context.Context, + client rest.Interface, + spec resourceSpec, + namespace, name, labelSelector string, +) (map[string][]fleet.DisplayField, error) { + segments := tableURLSegments(spec, namespace, name) + request := client.Get().AbsPath(segments...).SetHeader("Accept", tableAccept) + if name == "" { + request = request.VersionedParams(&metav1.ListOptions{LabelSelector: labelSelector}, metav1.ParameterCodec) + } else { + request = request.VersionedParams(&metav1.GetOptions{}, metav1.ParameterCodec) + } + payload, err := request.Do(ctx).Raw() + if err != nil { + return nil, fmt.Errorf("request server table for %s: %w", spec.kind, err) + } + var table metav1.Table + if err := json.Unmarshal(payload, &table); err != nil { + return nil, fmt.Errorf("decode server table for %s: %w", spec.kind, err) + } + if len(table.ColumnDefinitions) == 0 { + return nil, fmt.Errorf("server table for %s has no column definitions", spec.kind) + } + result := make(map[string][]fleet.DisplayField, len(table.Rows)) + for _, row := range table.Rows { + rowNamespace, rowName := tableRowIdentity(row, table.ColumnDefinitions, namespace) + if rowName == "" { + continue + } + fields := make([]fleet.DisplayField, 0, min(len(row.Cells), len(table.ColumnDefinitions))) + for index, cell := range row.Cells { + if index >= len(table.ColumnDefinitions) { + break + } + column := table.ColumnDefinitions[index] + fields = append(fields, fleet.DisplayField{ + Name: column.Name, Value: tableCellString(cell), Priority: column.Priority, + }) + } + result[tableObjectKey(rowNamespace, rowName)] = fields + } + return result, nil +} + +func tableURLSegments(spec resourceSpec, namespace, name string) []string { + segments := make([]string, 0, 7) + if spec.gvr.Group == "" { + segments = append(segments, "api", spec.gvr.Version) + } else { + segments = append(segments, "apis", spec.gvr.Group, spec.gvr.Version) + } + if spec.namespaced && namespace != "" { + segments = append(segments, "namespaces", namespace) + } + segments = append(segments, spec.gvr.Resource) + if name != "" { + segments = append(segments, name) + } + return segments +} + +func tableRowIdentity( + row metav1.TableRow, + columns []metav1.TableColumnDefinition, + defaultNamespace string, +) (string, string) { + namespace := defaultNamespace + name := "" + if len(row.Object.Raw) > 0 { + var object struct { + Metadata struct { + Name string `json:"name"` + Namespace string `json:"namespace"` + } `json:"metadata"` + } + if json.Unmarshal(row.Object.Raw, &object) == nil { + name = object.Metadata.Name + if object.Metadata.Namespace != "" { + namespace = object.Metadata.Namespace + } + } + } + for index, column := range columns { + if index >= len(row.Cells) { + break + } + switch strings.ToLower(column.Name) { + case "name": + if name == "" { + name = tableCellString(row.Cells[index]) + } + case "namespace": + if namespace == "" { + namespace = tableCellString(row.Cells[index]) + } + } + } + return namespace, name +} + +func tableCellString(value any) string { + switch typed := value.(type) { + case nil: + return "" + case string: + return typed + case float64: + return strconv.FormatFloat(typed, 'f', -1, 64) + case bool: + return strconv.FormatBool(typed) + default: + encoded, err := json.Marshal(typed) + if err == nil { + return string(encoded) + } + return fmt.Sprint(typed) + } +} + +func tableObjectKey(namespace, name string) string { + return namespace + "\x00" + name +} diff --git a/internal/connector/kubeconfig/table_test.go b/internal/connector/kubeconfig/table_test.go new file mode 100644 index 0000000..08121bb --- /dev/null +++ b/internal/connector/kubeconfig/table_test.go @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: Apache-2.0 + +package kubeconfig + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "slices" + "testing" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/rest" +) + +func TestTablePrinterRequestsAndDecodesServerColumns(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if request.URL.Path != "/api/v1/namespaces/apps/configmaps" { + http.NotFound(writer, request) + return + } + if request.Header.Get("Accept") != tableAccept { + http.Error(writer, "missing table accept", http.StatusNotAcceptable) + return + } + if request.URL.Query().Get("labelSelector") != "app=sample" { + http.Error(writer, "missing selector", http.StatusBadRequest) + return + } + writer.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(writer).Encode(metav1.Table{ + TypeMeta: metav1.TypeMeta{APIVersion: "meta.k8s.io/v1", Kind: "Table"}, + ColumnDefinitions: []metav1.TableColumnDefinition{ + {Name: "Name", Type: "string"}, + {Name: "Data", Type: "integer"}, + {Name: "Age", Type: "string", Priority: 1}, + }, + Rows: []metav1.TableRow{{ + Cells: []any{"settings", int64(2), "5m"}, + Object: runtime.RawExtension{Raw: []byte(`{"metadata":{"name":"settings","namespace":"apps"}}`)}, + }}, + }) + })) + defer server.Close() + + printer, err := newTablePrinter(&rest.Config{Host: server.URL}) + if err != nil { + t.Fatalf("newTablePrinter() error = %v", err) + } + fields, err := printer(context.Background(), resourceSpec{ + kind: "ConfigMap", gvr: schema.GroupVersionResource{Version: "v1", Resource: "configmaps"}, namespaced: true, + }, "apps", "", "app=sample") + if err != nil { + t.Fatalf("printer() error = %v", err) + } + got := fields[tableObjectKey("apps", "settings")] + if len(got) != 3 || got[1].Value != "2" || got[2].Priority != 1 || + !slices.Equal([]string{got[0].Name, got[1].Name, got[2].Name}, []string{"Name", "Data", "Age"}) { + t.Fatalf("fields = %#v", got) + } +} diff --git a/internal/connector/kubeconfig/watch.go b/internal/connector/kubeconfig/watch.go index 019448a..1720052 100644 --- a/internal/connector/kubeconfig/watch.go +++ b/internal/connector/kubeconfig/watch.go @@ -37,7 +37,7 @@ func (adapter *Adapter) Watch(ctx context.Context, kinds ...string) (<-chan conn if err := adapter.ensureDiscovered(ctx); err != nil { return nil, err } - scopes, clients, configs := adapter.watchStateSnapshot() + scopes, clients, configs, tables := adapter.watchStateSnapshot() events := make(chan connector.WatchEvent, watchBuffer) var waitGroup sync.WaitGroup for name, scope := range scopes { @@ -52,10 +52,15 @@ func (adapter *Adapter) Watch(ctx context.Context, kinds ...string) (<-chan conn }(name, kind) continue } - go func(scopeName, resourceKind string, client dynamic.Interface, config *rest.Config) { + go func( + scopeName, resourceKind string, + client dynamic.Interface, + config *rest.Config, + table tablePrinter, + ) { defer waitGroup.Done() - adapter.watchScope(ctx, events, scopeName, resourceKind, client, config) - }(name, kind, clients[name], configs[name]) + adapter.watchScope(ctx, events, scopeName, resourceKind, client, config, table) + }(name, kind, clients[name], configs[name], tables[name]) } } go func() { @@ -71,10 +76,12 @@ func (adapter *Adapter) watchScope( scope, kind string, client dynamic.Interface, config *rest.Config, + table tablePrinter, ) { backoff := initialWatchBackoff for ctx.Err() == nil { spec, known := lookupResource(kind) + generic := !known if !known { var err error spec, err = adapter.resolveResource(ctx, scope, config, kind) @@ -99,7 +106,24 @@ func (adapter *Adapter) watchScope( continue } observedAt := adapter.settings.now().UTC() - facts, err := factsFromObjects(list.Items, spec, scope, observedAt) + display := map[string][]fleet.DisplayField{} + if generic { + if table == nil { + if !adapter.reportWatchError(ctx, events, kind, scope, errors.New("server table client is unavailable")) { + return + } + return + } + display, err = table(ctx, spec, "", "", "") + if err != nil { + if !adapter.reportWatchError(ctx, events, kind, scope, err) || !waitForWatchRetry(ctx, backoff) { + return + } + backoff = min(backoff*2, maximumWatchBackoff) + continue + } + } + facts, err := factsFromObjects(list.Items, spec, scope, observedAt, display) if err != nil { if !adapter.reportWatchError(ctx, events, kind, scope, err) { return @@ -125,7 +149,7 @@ func (adapter *Adapter) watchScope( backoff = min(backoff*2, maximumWatchBackoff) continue } - watchErr := adapter.consumeWatch(ctx, events, stream, kind, scope, spec) + watchErr := adapter.consumeWatch(ctx, events, stream, kind, scope, spec, table, generic) stream.Stop() if ctx.Err() != nil { return @@ -148,6 +172,8 @@ func (adapter *Adapter) consumeWatch( stream watch.Interface, kind, scope string, spec resourceSpec, + table tablePrinter, + generic bool, ) error { for { select { @@ -177,6 +203,15 @@ func (adapter *Adapter) consumeWatch( } switch event.Type { case watch.Added, watch.Modified: + if generic { + display, tableErr := table(ctx, spec, object.GetNamespace(), object.GetName(), "") + if tableErr != nil { + return tableErr + } + evidence.Display = append( + []fleet.DisplayField(nil), display[tableObjectKey(object.GetNamespace(), object.GetName())]..., + ) + } watchEvent.Type = connector.WatchUpsert watchEvent.Fact = fleet.Fact{Evidence: evidence, Workspace: fleet.LocalWorkspace} case watch.Deleted: @@ -207,6 +242,7 @@ func (adapter *Adapter) watchStateSnapshot() ( map[string]connector.Scope, map[string]dynamic.Interface, map[string]*rest.Config, + map[string]tablePrinter, ) { adapter.mu.RLock() defer adapter.mu.RUnlock() @@ -222,7 +258,11 @@ func (adapter *Adapter) watchStateSnapshot() ( for name, config := range adapter.configs { configs[name] = rest.CopyConfig(config) } - return scopes, clients, configs + tables := make(map[string]tablePrinter, len(adapter.tables)) + for name, table := range adapter.tables { + tables[name] = table + } + return scopes, clients, configs, tables } func normalizeWatchKinds(kinds []string) ([]string, error) { @@ -251,6 +291,7 @@ func factsFromObjects( spec resourceSpec, scope string, observedAt time.Time, + display map[string][]fleet.DisplayField, ) ([]fleet.Fact, error) { facts := make([]fleet.Fact, 0, len(objects)) for _, object := range objects { @@ -258,6 +299,7 @@ func factsFromObjects( if err != nil { return nil, err } + evidence.Display = append([]fleet.DisplayField(nil), display[tableObjectKey(object.GetNamespace(), object.GetName())]...) facts = append(facts, fleet.Fact{Evidence: evidence, Workspace: fleet.LocalWorkspace}) } return facts, nil diff --git a/internal/fleet/resource.go b/internal/fleet/resource.go index 7906593..a923b96 100644 --- a/internal/fleet/resource.go +++ b/internal/fleet/resource.go @@ -69,11 +69,19 @@ type Evidence struct { Ref ResourceRef `json:"ref"` Kind FactKind `json:"kind"` Observed json.RawMessage `json:"observed"` + Display []DisplayField `json:"display,omitempty"` ObservedAt time.Time `json:"observed_at"` Source string `json:"source"` Provenance Provenance `json:"provenance"` } +// DisplayField is a source-provided, read-only tabular presentation hint. +type DisplayField struct { + Name string `json:"name"` + Value string `json:"value"` + Priority int32 `json:"priority,omitempty"` +} + // Provenance identifies how to trace an observation back to its native source. type Provenance struct { Adapter string `json:"adapter"` diff --git a/internal/fleetcache/record.go b/internal/fleetcache/record.go index 5b5bdd8..018aff9 100644 --- a/internal/fleetcache/record.go +++ b/internal/fleetcache/record.go @@ -18,24 +18,25 @@ import ( // Record is a render-ready projection of one cached fleet fact. type Record struct { - Fact fleet.Fact `json:"fact"` - Kind string `json:"kind"` - Cluster string `json:"cluster"` - Namespace string `json:"namespace,omitempty"` - Name string `json:"name"` - Ready string `json:"ready,omitempty"` - Status string `json:"status,omitempty"` - Reason string `json:"reason,omitempty"` - Message string `json:"message,omitempty"` - Node string `json:"node,omitempty"` - Version string `json:"version,omitempty"` - Restarts int64 `json:"restarts,omitempty"` - Images []string `json:"images,omitempty"` - Labels map[string]string `json:"labels,omitempty"` - CreatedAt time.Time `json:"created_at,omitempty"` - ObservedAt time.Time `json:"observed_at"` - Stale bool `json:"stale"` - StaleFor time.Duration `json:"stale_for,omitempty"` + Fact fleet.Fact `json:"fact"` + Kind string `json:"kind"` + Cluster string `json:"cluster"` + Namespace string `json:"namespace,omitempty"` + Name string `json:"name"` + Ready string `json:"ready,omitempty"` + Status string `json:"status,omitempty"` + Reason string `json:"reason,omitempty"` + Message string `json:"message,omitempty"` + Node string `json:"node,omitempty"` + Version string `json:"version,omitempty"` + Restarts int64 `json:"restarts,omitempty"` + Images []string `json:"images,omitempty"` + Labels map[string]string `json:"labels,omitempty"` + Display []fleet.DisplayField `json:"display,omitempty"` + CreatedAt time.Time `json:"created_at,omitempty"` + ObservedAt time.Time `json:"observed_at"` + Stale bool `json:"stale"` + StaleFor time.Duration `json:"stale_for,omitempty"` } func normalize(fact fleet.Fact) (Record, error) { @@ -53,6 +54,7 @@ func normalize(fact fleet.Fact) (Record, error) { CreatedAt: object.GetCreationTimestamp().Time, ObservedAt: fact.ObservedAt, Images: objectImages(*object), + Display: append([]fleet.DisplayField(nil), fact.Display...), Stale: fact.Stale, } if record.Labels == nil { @@ -249,6 +251,7 @@ func canonicalKind(kind string) string { func cloneFact(fact fleet.Fact) fleet.Fact { fact.Observed = append(json.RawMessage(nil), fact.Observed...) + fact.Display = append([]fleet.DisplayField(nil), fact.Display...) if fact.Ref.Attributes != nil { fact.Ref.Attributes = cloneMap(fact.Ref.Attributes) } diff --git a/internal/fleetcache/store.go b/internal/fleetcache/store.go index 4e0d692..608e566 100644 --- a/internal/fleetcache/store.go +++ b/internal/fleetcache/store.go @@ -582,6 +582,7 @@ func cloneRecord(record Record, includeEvidence bool) Record { record.Fact = fleet.Fact{} } record.Images = append([]string(nil), record.Images...) + record.Display = append([]fleet.DisplayField(nil), record.Display...) record.Labels = cloneMap(record.Labels) return record } diff --git a/internal/fleetrender/table.go b/internal/fleetrender/table.go index cd13f10..ed6b931 100644 --- a/internal/fleetrender/table.go +++ b/internal/fleetrender/table.go @@ -11,6 +11,7 @@ import ( "strings" "text/tabwriter" "time" + "unicode" "github.com/ArdurAI/sith/internal/fleet" "github.com/ArdurAI/sith/internal/fleetcache" @@ -39,17 +40,27 @@ func Build(snapshot fleetcache.Snapshot, options Options) Table { lens = canonicalLens(snapshot.Records[0].Kind) } columns := columnsFor(lens, options.Wide) + if !isTierOneLens(lens) { + columns = genericColumns(snapshot.Records, options.Wide) + } rows := make([][]string, 0, len(snapshot.Records)) now := options.Now if now.IsZero() { now = time.Now().UTC() } for _, record := range snapshot.Records { - rows = append(rows, rowFor(record, lens, options.Wide, now)) + row := rowFor(record, lens, columns, options.Wide, now) + for index := range row { + row[index] = safeCell(row[index]) + } + rows = append(rows, row) if options.MaxRows > 0 && len(rows) == options.MaxRows { break } } + for index := range columns { + columns[index] = safeCell(columns[index]) + } return Table{Columns: columns, Rows: rows, Coverage: snapshot.Coverage, State: snapshot.State} } @@ -129,7 +140,7 @@ func columnsFor(lens string, wide bool) []string { return columns } -func rowFor(record fleetcache.Record, lens string, wide bool, now time.Time) []string { +func rowFor(record fleetcache.Record, lens string, columns []string, wide bool, now time.Time) []string { cluster := record.Cluster if record.Stale { cluster = "~" + cluster @@ -153,10 +164,65 @@ func rowFor(record fleetcache.Record, lens string, wide bool, now time.Time) []s } return row default: + if len(record.Display) > 0 { + return genericRow(record, columns, cluster) + } return []string{cluster, record.Namespace, record.Kind, record.Name, record.Status, age} } } +func isTierOneLens(lens string) bool { + switch lens { + case "Pod", "Deployment", "Event", "Node": + return true + default: + return false + } +} + +func genericColumns(records []fleetcache.Record, wide bool) []string { + columns := []string{"CLUSTER"} + hasNamespace := false + seen := map[string]bool{"cluster": true} + for _, record := range records { + if record.Namespace != "" { + hasNamespace = true + } + for _, field := range record.Display { + if field.Name == "" || field.Priority > 0 && !wide { + continue + } + key := strings.ToLower(field.Name) + if key == "namespace" || seen[key] { + continue + } + seen[key] = true + columns = append(columns, strings.ToUpper(field.Name)) + } + } + if hasNamespace { + columns = append(columns[:1], append([]string{"NAMESPACE"}, columns[1:]...)...) + } + if len(columns) == 1 || len(columns) == 2 && hasNamespace { + return columnsFor("", wide) + } + return columns +} + +func genericRow(record fleetcache.Record, columns []string, cluster string) []string { + values := make(map[string]string, len(record.Display)+2) + values["cluster"] = cluster + values["namespace"] = record.Namespace + for _, field := range record.Display { + values[strings.ToLower(field.Name)] = field.Value + } + row := make([]string, len(columns)) + for index, column := range columns { + row[index] = values[strings.ToLower(column)] + } + return row +} + func humanAge(now, then time.Time) string { if then.IsZero() { return "-" @@ -188,6 +254,22 @@ func truncate(value string, limit int) string { return string(runes[:limit-1]) + "…" } +func safeCell(value string) string { + var result strings.Builder + result.Grow(len(value)) + for _, character := range value { + switch { + case character == '\n' || character == '\r' || character == '\t': + result.WriteByte(' ') + case unicode.IsControl(character): + continue + default: + result.WriteRune(character) + } + } + return result.String() +} + func canonicalLens(lens string) string { switch strings.ToLower(strings.TrimSpace(lens)) { case "pod", "pods", "po": diff --git a/internal/fleetrender/table_test.go b/internal/fleetrender/table_test.go index de1f90e..ef4614c 100644 --- a/internal/fleetrender/table_test.go +++ b/internal/fleetrender/table_test.go @@ -120,3 +120,36 @@ func TestBuildRespectsRowLimitAndGenericLens(t *testing.T) { t.Fatalf("table = %#v", table) } } + +func TestBuildUsesServerDisplayFieldsForGenericLens(t *testing.T) { + t.Parallel() + snapshot := fleetcache.Snapshot{Records: []fleetcache.Record{{ + Cluster: "alpha", Namespace: "apps", Kind: "Widget", Name: "sample", + Display: []fleet.DisplayField{ + {Name: "Name", Value: "sample"}, + {Name: "Ready", Value: "3/3"}, + {Name: "Image", Value: "registry/widget:v1", Priority: 1}, + }, + }}} + table := Build(snapshot, Options{Lens: "Widget"}) + if !slices.Equal(table.Columns, []string{"CLUSTER", "NAMESPACE", "NAME", "READY"}) || + !slices.Equal(table.Rows[0], []string{"alpha", "apps", "sample", "3/3"}) { + t.Fatalf("generic table = %#v", table) + } + wide := Build(snapshot, Options{Lens: "Widget", Wide: true}) + if !slices.Equal(wide.Columns, []string{"CLUSTER", "NAMESPACE", "NAME", "READY", "IMAGE"}) { + t.Fatalf("wide columns = %v", wide.Columns) + } +} + +func TestBuildStripsTerminalControlSequences(t *testing.T) { + t.Parallel() + table := Build(fleetcache.Snapshot{Records: []fleetcache.Record{{ + Cluster: "alpha\x1b[31m", Kind: "Widget", + Display: []fleet.DisplayField{{Name: "Message\n", Value: "unsafe\x1b[2J\nnext"}}, + }}}, Options{Lens: "Widget"}) + if strings.ContainsAny(table.Columns[1], "\n\x1b") || strings.ContainsAny(table.Rows[0][0], "\n\x1b") || + strings.ContainsAny(table.Rows[0][1], "\n\x1b") { + t.Fatalf("table contains terminal controls: %#v", table) + } +} diff --git a/sessions/2026-07-10-slice-2-cache-first-fleet.md b/sessions/2026-07-10-slice-2-cache-first-fleet.md index ff11617..d2b1b71 100644 --- a/sessions/2026-07-10-slice-2-cache-first-fleet.md +++ b/sessions/2026-07-10-slice-2-cache-first-fleet.md @@ -82,7 +82,17 @@ complete gate passes in 72 seconds. [R] Review: Manual red-team review found and fixed cross-scope fact injection at the live-reader boundary. Continuous cost is explicit: one watch per active lens per reachable context plus bounded relist/recovery traffic; no credential, object, or telemetry leaves the machine. -[C] Checkpoint #7: this commit — watch-backed fleet deltas; next: generic server-print columns and final review. +[C] Checkpoint #7: 998d26e — watch-backed fleet deltas; next: generic server-print columns and final review. +[A] Action: Added source-abstract display fields backed by Kubernetes `meta.k8s.io/v1` Table +responses. Generic list/watch evidence now carries the API server's column names, priorities, and +cells into the normalized store; the shared renderer adds cluster/namespace identity and honors +priority columns in wide mode. Tier-1 bespoke lenses are unchanged. +[T] Test: HTTP contract tests verify the Table Accept header, URL, selector, identity, priorities, +and cells. Renderer tests prove normal/wide server columns. The real two-cluster ConfigMap path +proves `Name/Data/Age` reach JSON and shared text rendering; the complete gate passes in 70 seconds. +[R] Review: Red-team review treats cluster-provided print cells as untrusted terminal input. Shared +rendering now removes escape/control characters and folds line breaks before CLI/TUI output. +[C] Checkpoint #8: this commit — server-print generic renderer; next: final CI/security/PR review. --- diff --git a/tests/e2e/kind_fanout_test.go b/tests/e2e/kind_fanout_test.go index e6513bf..46f8e4a 100644 --- a/tests/e2e/kind_fanout_test.go +++ b/tests/e2e/kind_fanout_test.go @@ -169,7 +169,11 @@ func TestKindFleetFanout(t *testing.T) { } for _, record := range genericSnapshot.Records { if record.Kind == "ConfigMap" && record.Name == "sith-generic-sample" { - genericScopes[record.Cluster] = true + for _, field := range record.Display { + if field.Name == "Data" { + genericScopes[record.Cluster] = true + } + } } } for scope, seen := range genericScopes { @@ -180,6 +184,10 @@ func TestKindFleetFanout(t *testing.T) { if genericSnapshot.Coverage.Reachable != 2 || !strings.Contains(genericStderr, "warning: covered 2/3 clusters") { t.Fatalf("generic coverage/stderr = %#v/%q, want partial two-of-three", genericSnapshot.Coverage, genericStderr) } + genericText, _, err := runSith(ctx, binary, kubeconfigPath, "get", "configmaps", "-A", "--all-clusters") + if err != nil || !strings.Contains(string(genericText), "DATA") || !strings.Contains(string(genericText), "sith-generic-sample") { + t.Fatalf("generic server-column text/error = %q/%v", genericText, err) + } watchStore := fleetcache.New() watchHydrator, err := hydrate.New(adapter, watchStore, hydrate.WithResyncInterval(10*time.Minute)) From e452841a26a5f8b13fe7a2b0e349eaf4535afbac Mon Sep 17 00:00:00 2001 From: Gnani Rahul Date: Fri, 10 Jul 2026 17:08:10 -0500 Subject: [PATCH 14/14] feat(cli): add stable YAML output GSTACK-Checkpoint: 2026-07-10/slice-2-cache-first-fleet#9 Signed-off-by: Gnani Rahul --- README.md | 2 +- go.mod | 2 +- internal/cli/cached.go | 2 ++ internal/cli/cached_test.go | 19 +++++++++++++++++ internal/cli/cli_test.go | 21 +++++++++++++++++++ internal/cli/clusters.go | 3 +++ internal/cli/output.go | 21 +++++++++++++++++++ internal/cli/root.go | 7 ++++--- internal/cli/version.go | 3 +++ .../2026-07-10-slice-2-cache-first-fleet.md | 17 ++++++++++++--- 10 files changed, 89 insertions(+), 8 deletions(-) create mode 100644 internal/cli/output.go diff --git a/README.md b/README.md index 0d349d1..1440123 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ make build use the default `~/.kube/config`. Exec-credential helpers run locally, exactly as they do for `kubectl`; Sith does not copy kubeconfigs or credentials elsewhere. -Scripted `get` calls require either `--all-clusters` or one explicit `--context`. Text, JSON, +Scripted `get` calls require either `--all-clusters` or one explicit `--context`. Text, JSON, YAML, wide, and source-abstract name outputs are supported. Search and correlation run over the same normalized in-memory records; partial results name stale/unreachable contexts. The cache is not persisted to disk, so raw workload specifications do not become a new plaintext credential-adjacent diff --git a/go.mod b/go.mod index 8563baf..8cfe75e 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( golang.org/x/term v0.43.0 k8s.io/apimachinery v0.36.2 k8s.io/client-go v0.36.2 + sigs.k8s.io/yaml v1.6.0 ) require ( @@ -61,5 +62,4 @@ require ( sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.3.2 // indirect - sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/internal/cli/cached.go b/internal/cli/cached.go index ba0e91b..60d7002 100644 --- a/internal/cli/cached.go +++ b/internal/cli/cached.go @@ -165,6 +165,8 @@ func writeCacheSnapshot(command *cobra.Command, format, lens string, snapshot fl if err := json.NewEncoder(command.OutOrStdout()).Encode(snapshot); err != nil { return fmt.Errorf("write cache JSON: %w", err) } + case "yaml": + return writeYAML(command.OutOrStdout(), snapshot, "cache") case "name": return fleetrender.WriteNames(command.OutOrStdout(), snapshot) default: diff --git a/internal/cli/cached_test.go b/internal/cli/cached_test.go index 208b59a..76d3b24 100644 --- a/internal/cli/cached_test.go +++ b/internal/cli/cached_test.go @@ -12,6 +12,8 @@ import ( "testing" "time" + "sigs.k8s.io/yaml" + "github.com/ArdurAI/sith/internal/connector" "github.com/ArdurAI/sith/internal/fleet" "github.com/ArdurAI/sith/internal/fleetcache" @@ -59,6 +61,23 @@ func TestGetJSONUsesStableCacheSchema(t *testing.T) { } } +func TestGetYAMLUsesStableCacheSchema(t *testing.T) { + reader := &cacheReader{} + stdout, stderr, exitCode := runCLIWithReader( + t, []string{"get", "pods", "-A", "--context", "alpha", "-o", "yaml"}, reader, + ) + if exitCode != 0 { + t.Fatalf("exit/stderr = %d/%q", exitCode, stderr) + } + var snapshot fleetcache.Snapshot + if err := yaml.Unmarshal([]byte(stdout), &snapshot); err != nil { + t.Fatalf("unmarshal YAML output %q: %v", stdout, err) + } + if len(snapshot.Records) != 1 || snapshot.Records[0].Cluster != "alpha" { + t.Fatalf("snapshot = %#v", snapshot) + } +} + func TestSearchAndCorrelateUseNormalizedCrossClusterCache(t *testing.T) { reader := &cacheReader{} stdout, stderr, exitCode := runCLIWithReader(t, []string{"search", "image:*log4j*"}, reader) diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index e6d13e0..a6a45cd 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -12,6 +12,8 @@ import ( "testing" "time" + "sigs.k8s.io/yaml" + "github.com/ArdurAI/sith/internal/fleet" ) @@ -66,6 +68,25 @@ func TestVersionJSON(t *testing.T) { } } +func TestVersionAndClustersYAML(t *testing.T) { + stdout, stderr, exitCode := runCLI(t, []string{"version", "-o", "yaml"}, fleet.StubSource{}) + if exitCode != 0 { + t.Fatalf("version exit/stderr = %d/%q", exitCode, stderr) + } + var version map[string]any + if err := yaml.Unmarshal([]byte(stdout), &version); err != nil || version["platform"] == nil { + t.Fatalf("version YAML = %q, error = %v", stdout, err) + } + stdout, stderr, exitCode = runCLI(t, []string{"clusters", "-o", "yaml"}, fleet.StubSource{}) + if exitCode != 0 { + t.Fatalf("clusters exit/stderr = %d/%q", exitCode, stderr) + } + var result fleet.FleetResult + if err := yaml.Unmarshal([]byte(stdout), &result); err != nil || result.Clusters == nil { + t.Fatalf("clusters YAML = %q, result = %#v, error = %v", stdout, result, err) + } +} + func TestClustersEmptyText(t *testing.T) { stdout, _, exitCode := runCLI(t, []string{"clusters"}, fleet.StubSource{}) if exitCode != 0 { diff --git a/internal/cli/clusters.go b/internal/cli/clusters.go index 9e7e194..288ecaf 100644 --- a/internal/cli/clusters.go +++ b/internal/cli/clusters.go @@ -32,6 +32,9 @@ func newClustersCommand(options *rootOptions, source fleet.Source) *cobra.Comman if result.Clusters == nil { result.Clusters = []fleet.Cluster{} } + if options.output == "yaml" { + return writeYAML(command.OutOrStdout(), result, "clusters") + } if options.output == "json" { if err := json.NewEncoder(command.OutOrStdout()).Encode(result); err != nil { diff --git a/internal/cli/output.go b/internal/cli/output.go new file mode 100644 index 0000000..f03a243 --- /dev/null +++ b/internal/cli/output.go @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: Apache-2.0 + +package cli + +import ( + "fmt" + "io" + + "sigs.k8s.io/yaml" +) + +func writeYAML(output io.Writer, value any, label string) error { + payload, err := yaml.Marshal(value) + if err != nil { + return fmt.Errorf("marshal %s YAML: %w", label, err) + } + if _, err := output.Write(payload); err != nil { + return fmt.Errorf("write %s YAML: %w", label, err) + } + return nil +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 3e78f6f..453b476 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -87,8 +87,9 @@ func newRootCommand(runtime backend, stdout, stderr io.Writer) *cobra.Command { return command.Help() }, PersistentPreRunE: func(command *cobra.Command, _ []string) error { - if options.output != "text" && options.output != "json" && options.output != "wide" && options.output != "name" { - return fmt.Errorf("invalid output format %q: expected text, json, wide, or name", options.output) + if options.output != "text" && options.output != "json" && options.output != "yaml" && + options.output != "wide" && options.output != "name" { + return fmt.Errorf("invalid output format %q: expected text, json, yaml, wide, or name", options.output) } resolved, err := config.Load(options.configPath, config.Overrides{ @@ -117,7 +118,7 @@ func newRootCommand(runtime backend, stdout, stderr io.Writer) *cobra.Command { flags.StringVar(&options.configPath, "config", "", "path to the YAML configuration file") flags.StringVar(&options.logLevel, "log-level", "", "logging level: debug, info, warn, or error (default info)") flags.StringVar(&options.logFormat, "log-format", "", "logging format: text or json (default text)") - flags.StringVarP(&options.output, "output", "o", "text", "output format: text, json, wide, or name") + flags.StringVarP(&options.output, "output", "o", "text", "output format: text, json, yaml, wide, or name") commands := []*cobra.Command{ newVersionCommand(options), diff --git a/internal/cli/version.go b/internal/cli/version.go index cc0a51f..b2a8fef 100644 --- a/internal/cli/version.go +++ b/internal/cli/version.go @@ -17,6 +17,9 @@ func newVersionCommand(options *rootOptions) *cobra.Command { Args: cobra.NoArgs, RunE: func(command *cobra.Command, _ []string) error { info := buildinfo.Get() + if options.output == "yaml" { + return writeYAML(command.OutOrStdout(), info, "version") + } if options.output == "json" { encoded, err := info.JSON() if err != nil { diff --git a/sessions/2026-07-10-slice-2-cache-first-fleet.md b/sessions/2026-07-10-slice-2-cache-first-fleet.md index d2b1b71..2545e85 100644 --- a/sessions/2026-07-10-slice-2-cache-first-fleet.md +++ b/sessions/2026-07-10-slice-2-cache-first-fleet.md @@ -1,7 +1,7 @@ # Session — 2026-07-10 — slice-2-cache-first-fleet **Builder:** Gnani Rahul · **Model/effort:** engineering, max · **Branch:** feat/cache-first-fleet -**Slice(s):** Slice 2 / #33 + local portion of #10 · **Status:** in-progress +**Slice(s):** Slice 2 / #33 + local portion of #10 · **Status:** ready-for-PR --- @@ -92,8 +92,19 @@ and cells. Renderer tests prove normal/wide server columns. The real two-cluster proves `Name/Data/Age` reach JSON and shared text rendering; the complete gate passes in 70 seconds. [R] Review: Red-team review treats cluster-provided print cells as untrusted terminal input. Shared rendering now removes escape/control characters and folds line breaks before CLI/TUI output. -[C] Checkpoint #8: this commit — server-print generic renderer; next: final CI/security/PR review. +[C] Checkpoint #8: ec2f089 — server-print generic renderer; next: final CI/security/PR review. +[A] Action: Added stable YAML output beside text/JSON/wide/name for version, clusters, and all +cache-backed read commands, closing the final scripting-format mismatch in the locked UX contract. +[T] Test: YAML round-trip tests cover build metadata, allocated empty fleet results, and cache +snapshots. Final `make ci` passes format, vet, lint, reachable-vulnerability scan, race/coverage, +warm p95, binary e2e, and build gates; the digest-pinned two-cluster gate passed separately. +[R] Review: Final GitHub security audit found zero open Dependabot alerts. Enabled Dependabot +security updates plus secret scanning, push protection, validity checks, and non-provider patterns. +Enabled CodeQL default setup; Actions and Go analysis run 29126732288 completed successfully. +Changed-file secret/SPDX checks are clean and all nine commits are signed, DCO-compliant, and carry +the exact GSTACK checkpoint trailer. +[C] Checkpoint #9: this commit — contract and security closure; next: publish and review PR. --- -**Session close:** in progress · **Open questions touched:** Q12 keeps the roadmap TUI/CLI-first default +**Session close:** ready for PR · **Open questions touched:** Q12 keeps the roadmap TUI/CLI-first default