10 changed files with 224 additions and 6 deletions
@ -0,0 +1,103 @@ |
|||
package mock |
|||
|
|||
import ( |
|||
"context" |
|||
"encoding/json" |
|||
"fmt" |
|||
"strings" |
|||
"time" |
|||
|
|||
"tera-clone/services/tera-api/internal/alarmdelivery" |
|||
"tera-clone/services/tera-api/internal/domain" |
|||
"tera-clone/services/tera-api/internal/logservice" |
|||
"tera-clone/services/tera-api/internal/logstorage" |
|||
) |
|||
|
|||
type portKeywordTail struct { |
|||
trigger string |
|||
text string |
|||
at time.Time |
|||
} |
|||
|
|||
// seriald writes RX directly to logd, bypassing RecordPortLog. Consume that
|
|||
// journal with an independent cursor so viewing logs cannot consume alarms.
|
|||
func (s *Store) evaluateReceivedPortKeywords(ctx context.Context, snapshot logstorage.Snapshot) error { |
|||
journal, ok := s.logJournal.(logservice.SequencedJournal) |
|||
if !ok { |
|||
return nil |
|||
} |
|||
s.mu.Lock() |
|||
if !s.portKeywordStarted { |
|||
s.portKeywordStarted = true |
|||
s.portKeywordTails = make(map[string]portKeywordTail) |
|||
// Existing history must not be mailed again on service restart.
|
|||
if next := snapshot.NextSequence[logstorage.ClassPort]; next > 0 { |
|||
s.portKeywordCursor = next - 1 |
|||
s.mu.Unlock() |
|||
return nil |
|||
} |
|||
records, err := journal.ReadAfter(logstorage.ClassPort, 0) |
|||
if err != nil { |
|||
s.portKeywordStarted = false |
|||
s.mu.Unlock() |
|||
return err |
|||
} |
|||
for _, record := range records { |
|||
if record.Sequence > s.portKeywordCursor { |
|||
s.portKeywordCursor = record.Sequence |
|||
} |
|||
} |
|||
s.mu.Unlock() |
|||
return nil |
|||
} |
|||
records, err := journal.ReadAfter(logstorage.ClassPort, s.portKeywordCursor) |
|||
if err != nil { |
|||
s.mu.Unlock() |
|||
return fmt.Errorf("read serial keyword events: %w", err) |
|||
} |
|||
var requests []alarmdelivery.Request |
|||
for _, record := range records { |
|||
if record.Sequence <= s.portKeywordCursor { |
|||
continue |
|||
} |
|||
var event domain.PortLogEvent |
|||
if err := json.Unmarshal(record.Payload, &event); err != nil { |
|||
s.mu.Unlock() |
|||
return err |
|||
} |
|||
s.portKeywordCursor = record.Sequence |
|||
if event.EventType != "traffic" || event.Direction != "RX" || len(event.Payload) == 0 || event.AlarmTriggered { |
|||
continue |
|||
} |
|||
for id, rule := range s.runningAlarms { |
|||
if !rule.Enabled || rule.Source != "port_log" || rule.Trigger == "" || (rule.Channel != "" && rule.Channel != event.Channel) { |
|||
continue |
|||
} |
|||
key := id + ":" + event.Channel |
|||
tail := s.portKeywordTails[key] |
|||
if tail.trigger != rule.Trigger || event.Timestamp.Sub(tail.at) > 5*time.Second || event.Timestamp.Before(tail.at) { |
|||
tail.text = "" |
|||
} |
|||
content := tail.text + string(event.Payload) |
|||
if strings.Contains(strings.ToLower(content), strings.ToLower(rule.Trigger)) { |
|||
message := fmt.Sprintf("%s received keyword matched: %s", event.Channel, rule.Trigger) |
|||
requests = append(requests, s.alarmRequestsForRulesLocked([]string{id}, message)...) |
|||
} |
|||
// Keep only a suffix shorter than the keyword: an old match cannot fire
|
|||
// on the following chunk, but a keyword split between reads still can.
|
|||
keep := len(rule.Trigger) - 1 |
|||
if keep > 1024 { |
|||
keep = 1024 |
|||
} |
|||
if len(content) > keep { |
|||
content = content[len(content)-keep:] |
|||
} |
|||
s.portKeywordTails[key] = portKeywordTail{trigger: rule.Trigger, text: content, at: event.Timestamp} |
|||
} |
|||
} |
|||
s.mu.Unlock() |
|||
for i := range requests { |
|||
s.deliverBestEffort(ctx, &requests[i]) |
|||
} |
|||
return nil |
|||
} |
|||
@ -0,0 +1,88 @@ |
|||
package mock |
|||
|
|||
import ( |
|||
"context" |
|||
"testing" |
|||
"time" |
|||
|
|||
"tera-clone/services/tera-api/internal/domain" |
|||
"tera-clone/services/tera-api/internal/logstorage" |
|||
) |
|||
|
|||
func TestReceivedKeywordFromSerialJournalNotDependentOnLogViewer(t *testing.T) { |
|||
journal := &sequencedMemoryJournal{} |
|||
delivery := &recordingAlarmDeliveryController{} |
|||
store, err := NewStoreWithOptions(fixedClock, StoreOptions{LogJournal: journal, AlarmDeliveryController: delivery}) |
|||
if err != nil { |
|||
t.Fatal(err) |
|||
} |
|||
rule := store.runningAlarms["port_ch20_keyword"] |
|||
rule.Enabled = true |
|||
rule.Trigger = "Invalid interface" |
|||
rule.Delivery = []string{"web"} |
|||
store.runningAlarms["port_ch20_keyword"] = rule |
|||
now := fixedClock() |
|||
appendRX := func(channel, direction, text string, matched bool) { |
|||
t.Helper() |
|||
now = now.Add(time.Millisecond) |
|||
event := domain.PortLogEvent{Timestamp: now, Channel: channel, EventType: "traffic", Direction: direction, Actor: "tera-port-service", Message: "serial payload", Payload: []byte(text), PayloadBytes: len(text), PayloadEncoding: "base64", DisplayFormat: "ascii", AlarmTriggered: matched} |
|||
if err := journal.Append(logstorage.ClassPort, now, event); err != nil { |
|||
t.Fatal(err) |
|||
} |
|||
} |
|||
evaluate := func() { |
|||
t.Helper() |
|||
if err := store.EvaluateOperationalAlarms(context.Background()); err != nil { |
|||
t.Fatal(err) |
|||
} |
|||
} |
|||
appendRX("CH20", "RX", "Invalid interface", false) |
|||
evaluate() |
|||
if len(delivery.requests) != 0 { |
|||
t.Fatal("replayed old history on startup") |
|||
} |
|||
appendRX("CH20", "TX", "Invalid interface", false) |
|||
appendRX("CH19", "RX", "Invalid interface", false) |
|||
evaluate() |
|||
if len(delivery.requests) != 0 { |
|||
t.Fatal("matched wrong channel or TX") |
|||
} |
|||
appendRX("CH20", "RX", "\r\nInvalid inter", false) |
|||
evaluate() |
|||
if len(delivery.requests) != 0 { |
|||
t.Fatal("matched incomplete keyword") |
|||
} |
|||
appendRX("CH20", "RX", "face\r\nEnter interface name", false) |
|||
// A UI read advances its own journal cursor before the alarm evaluator.
|
|||
if _, err := store.PortLogEvents(context.Background(), domain.PortLogFilter{Channel: "CH20"}); err != nil { |
|||
t.Fatal(err) |
|||
} |
|||
evaluate() |
|||
evaluate() |
|||
if len(delivery.requests) != 1 || delivery.requests[0].RuleID != "port_ch20_keyword" { |
|||
t.Fatalf("requests=%+v", delivery.requests) |
|||
} |
|||
appendRX("CH20", "RX", "more prompt output", false) |
|||
appendRX("CH20", "RX", "Invalid interface", true) |
|||
evaluate() |
|||
if len(delivery.requests) != 1 { |
|||
t.Fatal("duplicate match or already processed API event") |
|||
} |
|||
rule.Enabled = false |
|||
store.runningAlarms["port_ch20_keyword"] = rule |
|||
appendRX("CH20", "RX", "Invalid interface", false) |
|||
evaluate() |
|||
if len(delivery.requests) != 1 { |
|||
t.Fatal("disabled rule fired") |
|||
} |
|||
rule.Enabled = true |
|||
rule.Trigger = "NEW KEYWORD" |
|||
store.runningAlarms["port_ch20_keyword"] = rule |
|||
appendRX("CH20", "RX", "Invalid interface", false) |
|||
evaluate() |
|||
appendRX("CH20", "RX", "new keyword", false) |
|||
evaluate() |
|||
if len(delivery.requests) != 2 { |
|||
t.Fatal("updated case insensitive keyword not observed") |
|||
} |
|||
} |
|||
Binary file not shown.
Loading…
Reference in new issue