From d1759af7e74b0e30fff1b67440413d1f3db30397 Mon Sep 17 00:00:00 2001 From: Tri Lam Date: Sun, 31 May 2026 01:44:11 -0700 Subject: [PATCH] =?UTF-8?q?feat(pivot):=20PR-K.1=20=E2=80=94=20sever=20pat?= =?UTF-8?q?terns=20lib=20+=20replay=20runner=20from=20k8sevents?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a local model for the pattern library so detectors no longer import components/receivers/k8sevents for their record types. Per RFC-0013 §migration (line 252), this is the hard gate for PR-I.2's patterndetectorprocessor + rankjoinprocessor — those processors cannot ship into module/ while patterns/ still drags an in-tree receiver into its compile graph. Mechanics: - new internal/synthesis/patterns/model.go: local Record, NodeRecord, ObjectRef, Hint, NodePressureKind types with the same JSON tags as the k8sevents equivalents, so fixture corpora unmarshal unchanged - new HintPodEvicted / HintNodePressure / HintNodeUnhealthy / HintScheduleFailure constants (only the values patterns switch on) - new PressureMemory / PressureDisk / PressurePID / PressureNotReady - new EventTypeNormal / EventTypeWarning - pod_evicted.go drops the k8sevents import; switches signatures and switch arms to the local types (doc comment updated) - pod_evicted_test.go + pod_evicted_bench_test.go + replay/runner.go swap their k8sevents.* references for patterns.* - pressure_from_note_test.go uses the local NodePressureKind constants - replay/pod_evicted/_real_world/README.md updates the contract pointer from components/receivers/k8sevents/record.go to internal/synthesis/patterns/model.go No deletions in this PR (k8sevents still ships, k8sevents emitters still produce these wire shapes). PR-K.2 will delete the in-tree k8sevents receiver after the chart-default flip lands. Verification: - go build ./... — clean - go vet ./internal/synthesis/... — clean - go test -race -count=1 ./internal/synthesis/... — pass - go test -bench=BenchmarkPodEvictedDetector -benchtime=1x — pass - grep -r components/receivers/k8sevents internal/synthesis/ — zero hits Co-Authored-By: Claude Opus 4.7 (1M context) Signed-off-by: Tri Lam --- internal/synthesis/patterns/model.go | 158 ++++++++++++++++++ internal/synthesis/patterns/pod_evicted.go | 54 +++--- .../patterns/pod_evicted_bench_test.go | 17 +- .../synthesis/patterns/pod_evicted_test.go | 75 ++++----- .../patterns/pressure_from_note_test.go | 32 ++-- .../replay/pod_evicted/_real_world/README.md | 2 +- internal/synthesis/replay/runner.go | 14 +- 7 files changed, 254 insertions(+), 98 deletions(-) create mode 100644 internal/synthesis/patterns/model.go diff --git a/internal/synthesis/patterns/model.go b/internal/synthesis/patterns/model.go new file mode 100644 index 00000000..901ff3af --- /dev/null +++ b/internal/synthesis/patterns/model.go @@ -0,0 +1,158 @@ +// SPDX-License-Identifier: Apache-2.0 + +package patterns + +import "time" + +// The types in this file are the pattern library's own model — the +// shape pattern detectors (and the replay fixture corpus) read from. +// Per RFC-0013, the pattern library is the v0.3 moat; it travels with +// the project regardless of which receivers are in the active build, +// so its input types live next to the detectors instead of being +// re-imported from any one upstream receiver. +// +// Field names and tags mirror the OTel attribute keys a Kubernetes- +// event source would stamp on its emitted log records — so a fixture +// JSON authored against the upstream k8sevents receiver schema +// unmarshals into these types unchanged. + +// Record is the typed representation of a single Kubernetes Event +// consumed by a pattern detector. Detectors read Record values +// directly (no plog.LogRecord grep) so a schema rename in an upstream +// emitter surfaces as a compile error here, not as a silent +// pattern-evaluation regression. +type Record struct { + // EventUID is the upstream Event object's metadata.uid — globally + // unique per Event, even across resyncs. + EventUID string `json:"event_uid,omitempty"` + + // Action is the events.k8s.io/v1 Event.Action field — what the + // reporter did ("Binding", "Killing", "Pulled", ...). Empty for + // "synthetic" Events the kubelet/controllers emit without a + // distinct action. + Action string `json:"action,omitempty"` + + // Reason is the short, machine-readable cause ("Evicted", + // "FailedScheduling", "SystemOOM", ...). Drives Hint. + Reason string `json:"reason,omitempty"` + + // Hint is the tracecore-canonical `k8s.event.hint` value. The + // named type rejects raw string-literal comparisons at compile + // time — detectors must use the exported `Hint*` constants. + Hint Hint `json:"hint,omitempty"` + + // Regarding identifies the object the Event is about + // (events.k8s.io/v1 Event.Regarding). + Regarding ObjectRef `json:"regarding"` + + // ReportingController is the controller name that wrote the + // Event ("kubelet", "default-scheduler", "deployment-controller"). + ReportingController string `json:"reporting_controller,omitempty"` + + // ReportingInstance is the controller-instance identifier. For + // kubelet-emitted Events (Evicted, NodeHasDiskPressure, etc.) + // this is the node name — load-bearing for the pod-evicted + // detector, which uses it to join an Evicted pod against its + // node's recent pressure transitions. + ReportingInstance string `json:"reporting_instance,omitempty"` + + // Note is the human-readable message body. Bounded by the + // upstream API server's 1KiB limit; we don't trim further. + Note string `json:"note,omitempty"` + + // SeriesCount is the number of times this Event has fired since + // the upstream API server started compressing repeats. 0 when + // the Event is not in a Series. + SeriesCount int32 `json:"series_count,omitempty"` + + // EventTime is the events.k8s.io/v1 Event.EventTime, falling back + // to DeprecatedFirstTimestamp / DeprecatedLastTimestamp on + // kubelet builds that haven't switched to EventTime. + EventTime time.Time `json:"event_time,omitempty"` + + // Type is `Normal` or `Warning`. Preserved on the record for + // downstream detectors and the min_event_type filter on the + // emitter side. + Type string `json:"type,omitempty"` +} + +// ObjectRef mirrors the events.k8s.io/v1 ObjectReference subset +// pattern detectors need. Kept distinct from upstream +// k8s.io/api/core/v1.ObjectReference so the pattern library does not +// drag the full client-go API surface into its compile graph. +type ObjectRef struct { + Kind string `json:"kind,omitempty"` + Namespace string `json:"namespace,omitempty"` + Name string `json:"name,omitempty"` + UID string `json:"uid,omitempty"` +} + +// NodeRecord is the typed representation of a single node-condition +// transition. Sibling to Record; pattern detectors read NodeRecord +// values directly. +type NodeRecord struct { + // NodeName is the upstream Node object's metadata.name. Stable + // across condition transitions on the same Node. + NodeName string `json:"node_name,omitempty"` + + // NodeUID is the Node's metadata.uid — globally unique. + NodeUID string `json:"node_uid,omitempty"` + + // Hint is the canonical k8s.event.hint value. For NodeRecord + // the value is always HintNodePressure (memory/disk/pid) or + // HintNodeUnhealthy (NotReady); the Pressure field carries the + // finer-grained kind. + Hint Hint `json:"hint,omitempty"` + + // Pressure is the specific condition kind that transitioned + // True. Named-type so a switch arm typo is a compile error. + Pressure NodePressureKind `json:"pressure,omitempty"` + + // TransitionAt is the LastTransitionTime of the condition. + // Used by the pod-evicted detector to bound the join window. + TransitionAt time.Time `json:"transition_at,omitempty"` + + // Message is the condition's Message field — human-readable + // detail of why the pressure tripped (e.g. + // "imagefs.available<15%"). Bounded by upstream apiserver + // limits; we don't trim further. + Message string `json:"message,omitempty"` +} + +// Hint is the typed `k8s.event.hint` value. The named type means a +// string literal in a downstream `case` is a type error — detectors +// must use the exported `Hint*` constants. +type Hint string + +// Canonical Hint values. Mirrors the taxonomy emitters stamp on +// records. Only the values pattern detectors switch on are listed +// here — adding a new pattern that needs another Hint adds the +// constant in the same change. +const ( + HintPodEvicted Hint = "pod_evicted" + HintNodePressure Hint = "node_pressure" + HintNodeUnhealthy Hint = "node_unhealthy" + HintScheduleFailure Hint = "schedule_failure" +) + +// NodePressureKind enumerates the four node-condition transitions +// pattern detectors recognize. Named type so a downstream +// `case "memory"` is a compile error; consumers must use the exported +// constants. +type NodePressureKind string + +// Canonical NodePressureKind values. +const ( + PressureMemory NodePressureKind = "memory" + PressureDisk NodePressureKind = "disk" + PressurePID NodePressureKind = "pid" + PressureNotReady NodePressureKind = "notready" +) + +// Event Type values mirror the upstream events.k8s.io/v1 Event.Type +// enum — `Normal` or `Warning`. Hoisted here so fixture authors and +// detector tests share one source of truth. +const ( + EventTypeNormal = "Normal" + EventTypeWarning = "Warning" +) diff --git a/internal/synthesis/patterns/pod_evicted.go b/internal/synthesis/patterns/pod_evicted.go index c57908c5..ef7b363f 100644 --- a/internal/synthesis/patterns/pod_evicted.go +++ b/internal/synthesis/patterns/pod_evicted.go @@ -7,8 +7,6 @@ import ( "sort" "strings" "time" - - "github.com/tracecoreai/tracecore/components/receivers/k8sevents" ) // DefaultJoinWindow is the maximum gap between a node-pressure @@ -22,8 +20,8 @@ import ( const DefaultJoinWindow = 30 * time.Second // PodEvictedDetector is the M19 pattern-#14 detector. Cross-receiver -// query is structured (typed accessor against M10's exported Record -// and NodeRecord types per MILESTONES §M19); no string grep against +// query is structured (typed accessor against the local Record and +// NodeRecord types in model.go); no string grep against // plog.LogRecord attributes. // // Zero-value usage is permitted — JoinWindow defaults to @@ -35,9 +33,9 @@ type PodEvictedDetector struct { JoinWindow time.Duration } -// Evaluate scans events for k8sevents Pod-Evicted records and joins -// each against the most recent matching node-condition record within -// the JoinWindow. Returns one PodEvictedVerdict per evicted pod, in +// Evaluate scans events for Pod-Evicted records and joins each +// against the most recent matching node-condition record within the +// JoinWindow. Returns one PodEvictedVerdict per evicted pod, in // EventTime ascending order so the slice is deterministic for golden // tests. // @@ -49,7 +47,7 @@ type PodEvictedDetector struct { // at the Hint check and never appear in the output. This satisfies // the rubric's negative-fixture gate: Killing/Preempted/FailedScheduling // map to non-pod_evicted Hints and so emit zero verdicts. -func (d PodEvictedDetector) Evaluate(events []k8sevents.Record, nodeConds []k8sevents.NodeRecord) []PodEvictedVerdict { +func (d PodEvictedDetector) Evaluate(events []Record, nodeConds []NodeRecord) []PodEvictedVerdict { window := d.JoinWindow if window <= 0 { window = DefaultJoinWindow @@ -63,7 +61,7 @@ func (d PodEvictedDetector) Evaluate(events []k8sevents.Record, nodeConds []k8se verdicts := make([]PodEvictedVerdict, 0) for i := range events { ev := events[i] - if ev.Hint != k8sevents.HintPodEvicted { + if ev.Hint != HintPodEvicted { continue } verdicts = append(verdicts, buildVerdict(ev, condIdx, window)) @@ -78,8 +76,8 @@ func (d PodEvictedDetector) Evaluate(events []k8sevents.Record, nodeConds []k8se // indexNodeConds groups records by node name and sorts each bucket // by TransitionAt ascending so the detector can binary-search for // the most-recent transition before an eviction. -func indexNodeConds(recs []k8sevents.NodeRecord) map[string][]k8sevents.NodeRecord { - idx := map[string][]k8sevents.NodeRecord{} +func indexNodeConds(recs []NodeRecord) map[string][]NodeRecord { + idx := map[string][]NodeRecord{} for _, r := range recs { idx[r.NodeName] = append(idx[r.NodeName], r) } @@ -97,7 +95,7 @@ func indexNodeConds(recs []k8sevents.NodeRecord) map[string][]k8sevents.NodeReco // condition index and produces the verdict. Confidence is Full iff a // node-condition record was found within the window; Partial otherwise // with MissingLayers=["node_condition"]. -func buildVerdict(ev k8sevents.Record, condIdx map[string][]k8sevents.NodeRecord, window time.Duration) PodEvictedVerdict { +func buildVerdict(ev Record, condIdx map[string][]NodeRecord, window time.Duration) PodEvictedVerdict { v := PodEvictedVerdict{ PatternID: PatternIDPodEvicted, EvidenceTrail: []EvidenceRef{ @@ -152,19 +150,19 @@ func annotateRemediationWithNode(remediation, nodeName string) string { // the per-node-sorted slice whose TransitionAt is <= evTime and // >= evTime-window. Bucket is sorted ascending; binary-search finds // the rightmost element <= evTime, then we verify the window bound. -func mostRecentConditionWithin(bucket []k8sevents.NodeRecord, evTime time.Time, window time.Duration) (k8sevents.NodeRecord, bool) { +func mostRecentConditionWithin(bucket []NodeRecord, evTime time.Time, window time.Duration) (NodeRecord, bool) { if len(bucket) == 0 { - return k8sevents.NodeRecord{}, false + return NodeRecord{}, false } i := sort.Search(len(bucket), func(i int) bool { return bucket[i].TransitionAt.After(evTime) }) if i == 0 { - return k8sevents.NodeRecord{}, false + return NodeRecord{}, false } candidate := bucket[i-1] if evTime.Sub(candidate.TransitionAt) > window { - return k8sevents.NodeRecord{}, false + return NodeRecord{}, false } return candidate, true } @@ -173,7 +171,7 @@ func mostRecentConditionWithin(bucket []k8sevents.NodeRecord, evTime time.Time, // joined NodeRecord. Omits the ": " suffix when the // upstream Message is empty so the description never ends with a // dangling colon. -func nodeConditionDescription(cond k8sevents.NodeRecord) string { +func nodeConditionDescription(cond NodeRecord) string { base := fmt.Sprintf("Node %s entered %s pressure", cond.NodeName, cond.Pressure) if cond.Message == "" { return base @@ -183,7 +181,7 @@ func nodeConditionDescription(cond k8sevents.NodeRecord) string { // displayPodName returns "namespace/name" for a Pod-shaped Record; // falls back to just the name (or "") otherwise. -func displayPodName(ev k8sevents.Record) string { +func displayPodName(ev Record) string { if ev.Regarding.Namespace != "" && ev.Regarding.Name != "" { return ev.Regarding.Namespace + "/" + ev.Regarding.Name } @@ -209,7 +207,7 @@ func formatTimestamp(t time.Time) string { // vocabulary verbatim (signals + nodeConditionMessageFmt + the // ephemeral-storage / EmptyDir paths). Non-kubelet drivers // (descheduler, custom controllers) intentionally land in "unknown". -func pressureFromNote(note string) k8sevents.NodePressureKind { +func pressureFromNote(note string) NodePressureKind { low := strings.ToLower(note) for _, m := range pressureMatchers { for _, anchor := range m.anchors { @@ -226,11 +224,11 @@ func pressureFromNote(note string) k8sevents.NodePressureKind { // "ephemeral storage" beats a hypothetical message that mentions // both ephemeral-storage and memory. var pressureMatchers = []struct { - kind k8sevents.NodePressureKind + kind NodePressureKind anchors []string }{ { - kind: k8sevents.PressureDisk, + kind: PressureDisk, anchors: []string{ "nodefs", "imagefs", @@ -243,7 +241,7 @@ var pressureMatchers = []struct { }, }, { - kind: k8sevents.PressureMemory, + kind: PressureMemory, anchors: []string{ "memory.available", "memory pressure", @@ -252,7 +250,7 @@ var pressureMatchers = []struct { }, }, { - kind: k8sevents.PressurePID, + kind: PressurePID, anchors: []string{ "pid.available", "pid pressure", @@ -267,15 +265,15 @@ var pressureMatchers = []struct { // memory or add headroom; pid → cap fork rate; unknown → the // documented "investigate" fallback that satisfies the rubric's // partial-verdict path. -func remediationFor(p k8sevents.NodePressureKind) string { +func remediationFor(p NodePressureKind) string { switch p { - case k8sevents.PressureDisk: + case PressureDisk: return "Free imagefs or relocate the training write path to NVMe; tighten kubelet --eviction-hard nodefs.available." - case k8sevents.PressureMemory: + case PressureMemory: return "Reduce per-rank memory footprint or add node headroom; review pod requests vs allocatable." - case k8sevents.PressurePID: + case PressurePID: return "Cap fork rate (training-process spawn) or raise the node pid limit." - case k8sevents.PressureNotReady: + case PressureNotReady: return "Inspect kubelet health, network plumbing, and CSI mounts on the node." } return "Inspect kubelet eviction logs on the node; the pressure root was not captured in the join window." diff --git a/internal/synthesis/patterns/pod_evicted_bench_test.go b/internal/synthesis/patterns/pod_evicted_bench_test.go index abe28c36..9da18fba 100644 --- a/internal/synthesis/patterns/pod_evicted_bench_test.go +++ b/internal/synthesis/patterns/pod_evicted_bench_test.go @@ -7,7 +7,6 @@ import ( "testing" "time" - "github.com/tracecoreai/tracecore/components/receivers/k8sevents" "github.com/tracecoreai/tracecore/internal/synthesis/patterns" ) @@ -19,14 +18,14 @@ func BenchmarkPodEvictedDetector_1kEventWindow(b *testing.B) { now := time.Unix(1_700_000_000, 0).UTC() const n = 1024 - events := make([]k8sevents.Record, 0, n) - nodes := make([]k8sevents.NodeRecord, 0, 64) + events := make([]patterns.Record, 0, n) + nodes := make([]patterns.NodeRecord, 0, 64) for i := 0; i < 64; i++ { - nodes = append(nodes, k8sevents.NodeRecord{ + nodes = append(nodes, patterns.NodeRecord{ NodeName: "node-" + strconv.Itoa(i), NodeUID: "uid-" + strconv.Itoa(i), - Pressure: k8sevents.PressureDisk, + Pressure: patterns.PressureDisk, TransitionAt: now.Add(time.Duration(-i) * time.Second), Message: "imagefs.available<15%", }) @@ -34,21 +33,21 @@ func BenchmarkPodEvictedDetector_1kEventWindow(b *testing.B) { for i := 0; i < n; i++ { nodeIdx := i % 64 if i%5 == 0 { - events = append(events, k8sevents.Record{ + events = append(events, patterns.Record{ EventUID: "noise-" + strconv.Itoa(i), Reason: "Killing", EventTime: now, }) continue } - events = append(events, k8sevents.Record{ + events = append(events, patterns.Record{ EventUID: "evict-" + strconv.Itoa(i), Reason: "Evicted", - Hint: k8sevents.HintPodEvicted, + Hint: patterns.HintPodEvicted, Note: "The node was low on resource: imagefs.available.", ReportingInstance: "node-" + strconv.Itoa(nodeIdx), EventTime: now, - Regarding: k8sevents.ObjectRef{Kind: "Pod", Name: "p-" + strconv.Itoa(i)}, + Regarding: patterns.ObjectRef{Kind: "Pod", Name: "p-" + strconv.Itoa(i)}, }) } diff --git a/internal/synthesis/patterns/pod_evicted_test.go b/internal/synthesis/patterns/pod_evicted_test.go index 7ad2d2c1..4158cee7 100644 --- a/internal/synthesis/patterns/pod_evicted_test.go +++ b/internal/synthesis/patterns/pod_evicted_test.go @@ -9,7 +9,6 @@ import ( "github.com/stretchr/testify/require" - "github.com/tracecoreai/tracecore/components/receivers/k8sevents" "github.com/tracecoreai/tracecore/internal/synthesis/patterns" ) @@ -20,16 +19,16 @@ func TestPodEvictedDetector_CanonicalDiskPressure(t *testing.T) { t.Parallel() now := time.Unix(1_700_000_000, 0).UTC() - events := []k8sevents.Record{ + events := []patterns.Record{ { EventUID: "evict-1", Reason: "Evicted", - Hint: k8sevents.HintPodEvicted, - Type: k8sevents.EventTypeWarning, + Hint: patterns.HintPodEvicted, + Type: patterns.EventTypeWarning, Note: "The node was low on resource: ephemeral-storage.", ReportingInstance: "gpu-node-0001", EventTime: now, - Regarding: k8sevents.ObjectRef{ + Regarding: patterns.ObjectRef{ Kind: "Pod", Namespace: "training", Name: "job-rank-3", @@ -37,12 +36,12 @@ func TestPodEvictedDetector_CanonicalDiskPressure(t *testing.T) { }, }, } - nodeConds := []k8sevents.NodeRecord{ + nodeConds := []patterns.NodeRecord{ { NodeName: "gpu-node-0001", NodeUID: "node-uid-1", - Hint: k8sevents.HintNodePressure, - Pressure: k8sevents.PressureDisk, + Hint: patterns.HintNodePressure, + Pressure: patterns.PressureDisk, TransitionAt: now.Add(-5 * time.Second), Message: "imagefs.available<15%", }, @@ -76,15 +75,15 @@ func TestPodEvictedDetector_PartialNoNodeCondition(t *testing.T) { t.Parallel() now := time.Unix(1_700_000_000, 0).UTC() - events := []k8sevents.Record{ + events := []patterns.Record{ { EventUID: "evict-2", Reason: "Evicted", - Hint: k8sevents.HintPodEvicted, + Hint: patterns.HintPodEvicted, Note: "The node was low on resource: memory.", ReportingInstance: "gpu-node-0002", EventTime: now, - Regarding: k8sevents.ObjectRef{Kind: "Pod", Namespace: "t", Name: "p", UID: "u"}, + Regarding: patterns.ObjectRef{Kind: "Pod", Namespace: "t", Name: "p", UID: "u"}, }, } @@ -104,21 +103,21 @@ func TestPodEvictedDetector_OutOfWindow(t *testing.T) { t.Parallel() now := time.Unix(1_700_000_000, 0).UTC() - events := []k8sevents.Record{ + events := []patterns.Record{ { EventUID: "evict-3", Reason: "Evicted", - Hint: k8sevents.HintPodEvicted, + Hint: patterns.HintPodEvicted, Note: "evicted", ReportingInstance: "n", EventTime: now, - Regarding: k8sevents.ObjectRef{Kind: "Pod", Name: "p"}, + Regarding: patterns.ObjectRef{Kind: "Pod", Name: "p"}, }, } - nodeConds := []k8sevents.NodeRecord{ + nodeConds := []patterns.NodeRecord{ { NodeName: "n", - Pressure: k8sevents.PressureDisk, + Pressure: patterns.PressureDisk, TransitionAt: now.Add(-2 * time.Hour), }, } @@ -135,10 +134,10 @@ func TestPodEvictedDetector_NegativeFixtures(t *testing.T) { t.Parallel() now := time.Unix(1_700_000_000, 0).UTC() - events := []k8sevents.Record{ + events := []patterns.Record{ {EventUID: "k", Reason: "Killing", Hint: "", EventTime: now}, {EventUID: "p", Reason: "Preempted", Hint: "", EventTime: now}, - {EventUID: "f", Reason: "FailedScheduling", Hint: k8sevents.HintScheduleFailure, EventTime: now}, + {EventUID: "f", Reason: "FailedScheduling", Hint: patterns.HintScheduleFailure, EventTime: now}, } verdicts := patterns.PodEvictedDetector{}.Evaluate(events, nil) @@ -154,21 +153,21 @@ func TestPodEvictedDetector_RemediationPinsNodeName(t *testing.T) { t.Parallel() now := time.Unix(1_700_000_000, 0).UTC() - events := []k8sevents.Record{ + events := []patterns.Record{ { EventUID: "evict-pin-1", Reason: "Evicted", - Hint: k8sevents.HintPodEvicted, + Hint: patterns.HintPodEvicted, Note: "The node was low on resource: imagefs.available.", ReportingInstance: "gpu-node-pin-test", EventTime: now, - Regarding: k8sevents.ObjectRef{Kind: "Pod", Namespace: "t", Name: "p"}, + Regarding: patterns.ObjectRef{Kind: "Pod", Namespace: "t", Name: "p"}, }, } - nodeConds := []k8sevents.NodeRecord{ + nodeConds := []patterns.NodeRecord{ { NodeName: "gpu-node-pin-test", - Pressure: k8sevents.PressureDisk, + Pressure: patterns.PressureDisk, TransitionAt: now.Add(-5 * time.Second), Message: "imagefs.available<15%", }, @@ -199,21 +198,21 @@ func TestPodEvictedDetector_FutureTransitionExcluded(t *testing.T) { t.Parallel() now := time.Unix(1_700_000_000, 0).UTC() - events := []k8sevents.Record{ + events := []patterns.Record{ { EventUID: "evict-future-cond", Reason: "Evicted", - Hint: k8sevents.HintPodEvicted, + Hint: patterns.HintPodEvicted, Note: "The node was low on resource: imagefs.available.", ReportingInstance: "n", EventTime: now, - Regarding: k8sevents.ObjectRef{Kind: "Pod", Namespace: "t", Name: "p"}, + Regarding: patterns.ObjectRef{Kind: "Pod", Namespace: "t", Name: "p"}, }, } - nodeConds := []k8sevents.NodeRecord{ + nodeConds := []patterns.NodeRecord{ { NodeName: "n", - Pressure: k8sevents.PressureDisk, + Pressure: patterns.PressureDisk, TransitionAt: now.Add(1 * time.Second), // 1s AFTER eviction Message: "imagefs.available<15%", }, @@ -236,21 +235,21 @@ func TestPodEvictedDetector_EmptyNodeMessage(t *testing.T) { t.Parallel() now := time.Unix(1_700_000_000, 0).UTC() - events := []k8sevents.Record{ + events := []patterns.Record{ { EventUID: "evict-empty-msg", Reason: "Evicted", - Hint: k8sevents.HintPodEvicted, + Hint: patterns.HintPodEvicted, Note: "The node was low on resource: imagefs.available.", ReportingInstance: "n", EventTime: now, - Regarding: k8sevents.ObjectRef{Kind: "Pod", Namespace: "t", Name: "p"}, + Regarding: patterns.ObjectRef{Kind: "Pod", Namespace: "t", Name: "p"}, }, } - nodeConds := []k8sevents.NodeRecord{ + nodeConds := []patterns.NodeRecord{ { NodeName: "n", - Pressure: k8sevents.PressureDisk, + Pressure: patterns.PressureDisk, TransitionAt: now.Add(-3 * time.Second), Message: "", // empty — the falsifier }, @@ -272,18 +271,18 @@ func TestPodEvictedDetector_DeterministicOrder(t *testing.T) { t.Parallel() base := time.Unix(1_700_000_000, 0).UTC() - mk := func(uid string, dt time.Duration) k8sevents.Record { - return k8sevents.Record{ + mk := func(uid string, dt time.Duration) patterns.Record { + return patterns.Record{ EventUID: uid, Reason: "Evicted", - Hint: k8sevents.HintPodEvicted, + Hint: patterns.HintPodEvicted, Note: "evicted", ReportingInstance: "n", EventTime: base.Add(dt), - Regarding: k8sevents.ObjectRef{Kind: "Pod", Name: "p-" + uid}, + Regarding: patterns.ObjectRef{Kind: "Pod", Name: "p-" + uid}, } } - events := []k8sevents.Record{mk("c", 5*time.Second), mk("a", 0), mk("b", 1*time.Second)} + events := []patterns.Record{mk("c", 5*time.Second), mk("a", 0), mk("b", 1*time.Second)} verdicts := patterns.PodEvictedDetector{}.Evaluate(events, nil) require.Len(t, verdicts, 3) diff --git a/internal/synthesis/patterns/pressure_from_note_test.go b/internal/synthesis/patterns/pressure_from_note_test.go index 83a65cb9..f29895f2 100644 --- a/internal/synthesis/patterns/pressure_from_note_test.go +++ b/internal/synthesis/patterns/pressure_from_note_test.go @@ -6,8 +6,6 @@ import ( "testing" "github.com/stretchr/testify/require" - - "github.com/tracecoreai/tracecore/components/receivers/k8sevents" ) // TestPressureFromNote_KubeletEvictionSignals exercises pressureFromNote @@ -36,31 +34,31 @@ func TestPressureFromNote_KubeletEvictionSignals(t *testing.T) { cases := []struct { name string note string - want k8sevents.NodePressureKind + want NodePressureKind }{ // nodeLowMessageFmt path — the six canonical eviction signals. - {"memory_available", "The node was low on resource: memory.available.", k8sevents.PressureMemory}, - {"nodefs_available", "The node was low on resource: nodefs.available.", k8sevents.PressureDisk}, - {"nodefs_inodes", "The node was low on resource: nodefs.inodesFree.", k8sevents.PressureDisk}, - {"imagefs_available", "The node was low on resource: imagefs.available.", k8sevents.PressureDisk}, - {"imagefs_inodes", "The node was low on resource: imagefs.inodesFree.", k8sevents.PressureDisk}, - {"ephemeral_storage_alias", "The node was low on resource: ephemeral-storage.", k8sevents.PressureDisk}, - {"pid_available", "The node was low on resource: pid.available.", k8sevents.PressurePID}, + {"memory_available", "The node was low on resource: memory.available.", PressureMemory}, + {"nodefs_available", "The node was low on resource: nodefs.available.", PressureDisk}, + {"nodefs_inodes", "The node was low on resource: nodefs.inodesFree.", PressureDisk}, + {"imagefs_available", "The node was low on resource: imagefs.available.", PressureDisk}, + {"imagefs_inodes", "The node was low on resource: imagefs.inodesFree.", PressureDisk}, + {"ephemeral_storage_alias", "The node was low on resource: ephemeral-storage.", PressureDisk}, + {"pid_available", "The node was low on resource: pid.available.", PressurePID}, // nodeConditionMessageFmt path — Condition.Type names are // CamelCase, no internal space (DiskPressure, not "Disk Pressure"). - {"node_had_condition_disk", "The node had condition: DiskPressure.", k8sevents.PressureDisk}, - {"node_had_condition_memory", "The node had condition: MemoryPressure.", k8sevents.PressureMemory}, - {"node_had_condition_pid", "The node had condition: PIDPressure.", k8sevents.PressurePID}, + {"node_had_condition_disk", "The node had condition: DiskPressure.", PressureDisk}, + {"node_had_condition_memory", "The node had condition: MemoryPressure.", PressureMemory}, + {"node_had_condition_pid", "The node had condition: PIDPressure.", PressurePID}, // Container/Pod ephemeral-storage paths — note the space- // separated wording ("ephemeral storage", "ephemeral local storage") // which my hyphenated matcher would miss. - {"container_ephemeral_storage", "Container app exceeded its local ephemeral storage limit \"5Gi\".", k8sevents.PressureDisk}, - {"pod_ephemeral_local_storage", "Pod ephemeral local storage usage exceeds the total limit of containers app.", k8sevents.PressureDisk}, + {"container_ephemeral_storage", "Container app exceeded its local ephemeral storage limit \"5Gi\".", PressureDisk}, + {"pod_ephemeral_local_storage", "Pod ephemeral local storage usage exceeds the total limit of containers app.", PressureDisk}, // EmptyDir-driven eviction — no resource-name token at all. - {"emptydir_volume", "Usage of EmptyDir volume \"cache\" exceeds the limit \"1Gi\".", k8sevents.PressureDisk}, + {"emptydir_volume", "Usage of EmptyDir volume \"cache\" exceeds the limit \"1Gi\".", PressureDisk}, } for _, tc := range cases { @@ -99,7 +97,7 @@ func TestPressureFromNote_NoFalsePositives(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() got := pressureFromNote(tc.note) - require.NotEqual(t, k8sevents.PressurePID, got, + require.NotEqual(t, PressurePID, got, "%q must not falsely route to PressurePID via 3-letter substring match", tc.note) }) } diff --git a/internal/synthesis/replay/pod_evicted/_real_world/README.md b/internal/synthesis/replay/pod_evicted/_real_world/README.md index 872ef36d..8d371463 100644 --- a/internal/synthesis/replay/pod_evicted/_real_world/README.md +++ b/internal/synthesis/replay/pod_evicted/_real_world/README.md @@ -62,7 +62,7 @@ No contributed fixtures yet; this README is the contract. Copy these four files into `_real_world//`, fill in the placeholders, and the loader picks the fixture up automatically (no code change needed). Field shapes are pinned -by `components/receivers/k8sevents/record.go` (`Record`, +by `internal/synthesis/patterns/model.go` (`Record`, `NodeRecord`) and `internal/synthesis/patterns/pod_evicted.go` (`PodEvictedVerdict`). diff --git a/internal/synthesis/replay/runner.go b/internal/synthesis/replay/runner.go index 6ecb9ebb..5cc720da 100644 --- a/internal/synthesis/replay/runner.go +++ b/internal/synthesis/replay/runner.go @@ -14,7 +14,7 @@ import ( "os" "path/filepath" - "github.com/tracecoreai/tracecore/components/receivers/k8sevents" + "github.com/tracecoreai/tracecore/internal/synthesis/patterns" ) // Fixture is one replay scenario: detector inputs plus the expected @@ -31,11 +31,15 @@ type Fixture struct { // to label failures. Manifest Manifest - // Events is the M10 Pod-event input slice. - Events []k8sevents.Record + // Events is the Pod-event input slice. Typed against the pattern + // library's local Record model so fixtures depend only on the + // pattern lib, not on any specific upstream receiver package. + Events []patterns.Record - // NodeConditions is the M10 Node-condition input slice. - NodeConditions []k8sevents.NodeRecord + // NodeConditions is the Node-condition input slice. Typed + // against the pattern library's local NodeRecord model for the + // same reason as Events above. + NodeConditions []patterns.NodeRecord // GoldenBytes is the raw on-disk golden JSON. Caller (detector // test) unmarshals into the detector-specific verdict type and