Deploy MQTT test backend with private viewer and Gitea CI/CD
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
// Package events defines safe inspection records shared by logs and the future viewer.
|
||||
package events
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"robot-market/test-mqtt-5/internal/contracts"
|
||||
)
|
||||
|
||||
type Direction string
|
||||
|
||||
const (
|
||||
In Direction = "in"
|
||||
Out Direction = "out"
|
||||
Internal Direction = "internal"
|
||||
)
|
||||
|
||||
type Kind string
|
||||
|
||||
const (
|
||||
PublishAttempt Kind = "publish_attempt"
|
||||
TransportAck Kind = "transport_ack"
|
||||
MessageReceived Kind = "message_received"
|
||||
ValidationFailed Kind = "validation_failed"
|
||||
CommandAccepted Kind = "command_accepted"
|
||||
CommandCompleted Kind = "command_completed"
|
||||
CommandTimeout Kind = "command_timeout"
|
||||
LateAck Kind = "late_ack"
|
||||
DuplicateRequest Kind = "duplicate_request"
|
||||
ConnectionUp Kind = "connection_up"
|
||||
ConnectionDown Kind = "connection_down"
|
||||
EventGap Kind = "event_gap"
|
||||
)
|
||||
|
||||
type Properties struct {
|
||||
ResponseTopic string `json:"response_topic,omitempty"`
|
||||
CorrelationData []byte `json:"correlation_data,omitempty"`
|
||||
MessageExpirySeconds *uint32 `json:"message_expiry_seconds,omitempty"`
|
||||
}
|
||||
|
||||
// Event contains inspection data only. Configuration and connection credentials
|
||||
// are never attached. Raw MQTT payloads require the explicit diagnostics opt-in.
|
||||
type Event struct {
|
||||
Scenario *ScenarioResult `json:"scenario,omitempty"`
|
||||
ID uint64 `json:"id"`
|
||||
ObservedAt time.Time `json:"observed_at"`
|
||||
Kind Kind `json:"kind"`
|
||||
Direction Direction `json:"direction"`
|
||||
ClientID string `json:"client_id,omitempty"`
|
||||
DeviceID string `json:"device_id,omitempty"`
|
||||
Topic string `json:"topic,omitempty"`
|
||||
QoS byte `json:"qos"`
|
||||
Retain bool `json:"retain"`
|
||||
Duplicate *bool `json:"duplicate,omitempty"`
|
||||
Payload Preview `json:"payload"`
|
||||
Properties Properties `json:"properties"`
|
||||
RequestID string `json:"request_id,omitempty"`
|
||||
Outcome string `json:"outcome,omitempty"`
|
||||
LatencyMS *int64 `json:"latency_ms,omitempty"`
|
||||
}
|
||||
|
||||
// ScenarioResult is reported test evidence, never inferred hardware compatibility.
|
||||
type ScenarioResult struct {
|
||||
Feature string `json:"feature"`
|
||||
Environment string `json:"environment"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Version string `json:"version"`
|
||||
Status string `json:"status"`
|
||||
Observation string `json:"observation"`
|
||||
Time time.Time `json:"time"`
|
||||
}
|
||||
type Preview struct {
|
||||
Bytes int `json:"bytes"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Omitted bool `json:"omitted"`
|
||||
Truncated bool `json:"truncated"`
|
||||
}
|
||||
|
||||
// SafePreview only renders validated contract fields. Unknown fields (including
|
||||
// credentials) cause the entire payload to be omitted. Free-form error messages
|
||||
// are always redacted. The original payload is never retained in this value.
|
||||
func SafePreview(kind, device string, payload []byte, limit int) Preview {
|
||||
p := Preview{Bytes: len(payload), Omitted: true}
|
||||
if limit < 1 {
|
||||
return p
|
||||
}
|
||||
if limit > 4096 {
|
||||
limit = 4096
|
||||
}
|
||||
msg, err := contracts.Decode(kind, payload, device)
|
||||
if err != nil {
|
||||
return p
|
||||
}
|
||||
if ack, ok := msg.(*contracts.Ack); ok && ack.Error != nil {
|
||||
ack.Error.Message = "[redacted]"
|
||||
}
|
||||
encoded, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return p
|
||||
}
|
||||
if len(encoded) > limit {
|
||||
encoded = encoded[:limit]
|
||||
for len(encoded) > 0 && !utf8.Valid(encoded) {
|
||||
encoded = encoded[:len(encoded)-1]
|
||||
}
|
||||
p.Truncated = true
|
||||
}
|
||||
p.Text = string(encoded)
|
||||
p.Omitted = false
|
||||
return p
|
||||
}
|
||||
|
||||
// RawPreview is opt-in diagnostics for this isolated test harness. It displays
|
||||
// received bytes without treating them as a valid contract or execution evidence.
|
||||
func RawPreview(payload []byte, limit int) Preview {
|
||||
p := Preview{Bytes: len(payload)}
|
||||
if limit < 1 {
|
||||
p.Omitted = true
|
||||
return p
|
||||
}
|
||||
limit = min(limit, 4096)
|
||||
text := string(payload)
|
||||
if !utf8.Valid(payload) {
|
||||
text = "[base64] " + base64.StdEncoding.EncodeToString(payload)
|
||||
}
|
||||
if len(text) > limit {
|
||||
text = text[:limit]
|
||||
for !utf8.ValidString(text) {
|
||||
text = text[:len(text)-1]
|
||||
}
|
||||
p.Truncated = true
|
||||
}
|
||||
p.Text = text
|
||||
return p
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const ping = `{"schema_version":1,"device_id":"sim-001","request_id":"req-001","issued_at":"2026-10-06T10:00:00Z","expires_at":"2026-10-06T10:00:15Z","command":"ping"}`
|
||||
|
||||
func TestSafePreviews(t *testing.T) {
|
||||
p := SafePreview("commands", "sim-001", []byte(ping), 4096)
|
||||
if p.Omitted || !strings.Contains(p.Text, `"command":"ping"`) || p.Bytes != len(ping) {
|
||||
t.Fatal("lost useful preview")
|
||||
}
|
||||
for _, raw := range []string{`{"password":"SECRET"}`, `{"x":"SECRET`, string([]byte{255, 0}), strings.Replace(ping, `"command":"ping"`, `"command":"ping","token":"SECRET"`, 1)} {
|
||||
preview := SafePreview("commands", "sim-001", []byte(raw), 4096)
|
||||
encoded, _ := json.Marshal(preview)
|
||||
if !preview.Omitted || preview.Text != "" || strings.Contains(string(encoded), "SECRET") {
|
||||
t.Fatal("unsafe payload exposed")
|
||||
}
|
||||
}
|
||||
ack := `{"schema_version":1,"device_id":"sim-001","request_id":"req-001","timestamp":"2026-10-06T10:00:00Z","stage":"failed","ok":false,"error":{"code":"error","message":"password=SECRET"}}`
|
||||
p = SafePreview("acks", "sim-001", []byte(ack), 4096)
|
||||
if p.Omitted || strings.Contains(p.Text, "SECRET") || !strings.Contains(p.Text, "[redacted]") {
|
||||
t.Fatal("free-form error not redacted")
|
||||
}
|
||||
}
|
||||
func TestPreviewBounds(t *testing.T) {
|
||||
for _, n := range []int{-1, 0, 1, 64, 100, 4096, 99999} {
|
||||
p := SafePreview("commands", "sim-001", []byte(ping), n)
|
||||
if n <= 0 {
|
||||
if !p.Omitted {
|
||||
t.Fatal("disabled preview not omitted")
|
||||
}
|
||||
continue
|
||||
}
|
||||
if len(p.Text) > n || !utf8.ValidString(p.Text) {
|
||||
t.Fatal("invalid preview bounds")
|
||||
}
|
||||
if n < len(ping) && !p.Truncated {
|
||||
t.Fatal("truncation not labeled")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRawPreviewIsBounded(t *testing.T) {
|
||||
for _, data := range [][]byte{[]byte("hello"), []byte("éééé"), {0xff, 0x00}} {
|
||||
preview := RawPreview(data, 5)
|
||||
if preview.Omitted || len(preview.Text) > 5 || preview.Bytes != len(data) {
|
||||
t.Fatal("invalid bounded raw preview")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Store retains bounded inspection events; the transport selects safe or opt-in raw previews.
|
||||
type Notice struct {
|
||||
Event *Event `json:"event,omitempty"`
|
||||
Gap uint64 `json:"gap,omitempty"`
|
||||
ResetID uint64 `json:"reset_id,omitempty"`
|
||||
}
|
||||
type Subscriber struct {
|
||||
channel chan Notice
|
||||
lost uint64
|
||||
}
|
||||
type Store struct {
|
||||
mu sync.Mutex
|
||||
history []Event
|
||||
capacity int
|
||||
next uint64
|
||||
counts map[Kind]uint64
|
||||
log *slog.Logger
|
||||
nextSub uint64
|
||||
subs map[uint64]*Subscriber
|
||||
}
|
||||
|
||||
func NewStore(capacity int, log *slog.Logger) *Store {
|
||||
return &Store{capacity: capacity, counts: make(map[Kind]uint64), log: log, subs: make(map[uint64]*Subscriber)}
|
||||
}
|
||||
func (s *Store) Add(e Event) {
|
||||
s.mu.Lock()
|
||||
s.next++
|
||||
e.ID = s.next
|
||||
e.ObservedAt = time.Now().UTC()
|
||||
s.counts[e.Kind]++
|
||||
if len(s.history) == s.capacity {
|
||||
copy(s.history, s.history[1:])
|
||||
s.history = s.history[:len(s.history)-1]
|
||||
}
|
||||
s.history = append(s.history, cloneEvent(e))
|
||||
for _, sub := range s.subs {
|
||||
if sub.lost > 0 {
|
||||
select {
|
||||
// Advance the cursor through the current event with the gap notice.
|
||||
// Leaving the cursor before it would cause a queue-of-one viewer to
|
||||
// receive a gap forever, since every notice would fill its queue.
|
||||
case sub.channel <- Notice{Gap: sub.lost + 1, ResetID: e.ID}:
|
||||
sub.lost = 0
|
||||
continue
|
||||
default:
|
||||
sub.lost++
|
||||
continue
|
||||
}
|
||||
}
|
||||
copyEvent := cloneEvent(e)
|
||||
select {
|
||||
case sub.channel <- Notice{Event: ©Event}:
|
||||
default:
|
||||
sub.lost++
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
s.log.Info(string(e.Kind), "event", e)
|
||||
}
|
||||
func (s *Store) Snapshot() ([]Event, map[Kind]uint64) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
history := make([]Event, len(s.history))
|
||||
for i, event := range s.history {
|
||||
history[i] = cloneEvent(event)
|
||||
}
|
||||
counts := make(map[Kind]uint64, len(s.counts))
|
||||
for k, v := range s.counts {
|
||||
counts[k] = v
|
||||
}
|
||||
return history, counts
|
||||
}
|
||||
|
||||
// Clone pointer fields so snapshot consumers cannot mutate retained history.
|
||||
func cloneEvent(e Event) Event {
|
||||
if e.Scenario != nil {
|
||||
result := *e.Scenario
|
||||
e.Scenario = &result
|
||||
}
|
||||
e.Properties.CorrelationData = append([]byte(nil), e.Properties.CorrelationData...)
|
||||
if e.Properties.MessageExpirySeconds != nil {
|
||||
n := *e.Properties.MessageExpirySeconds
|
||||
e.Properties.MessageExpirySeconds = &n
|
||||
}
|
||||
if e.Duplicate != nil {
|
||||
n := *e.Duplicate
|
||||
e.Duplicate = &n
|
||||
}
|
||||
if e.LatencyMS != nil {
|
||||
n := *e.LatencyMS
|
||||
e.LatencyMS = &n
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
// Subscribe atomically captures bounded replay and registers a bounded live queue.
|
||||
// A cursor outside retained history creates an explicit reset to the oldest event.
|
||||
func (s *Store) Subscribe(after uint64, queue, maxReplay int) (replay []Event, initialGap *Notice, stream <-chan Notice, unsubscribe func()) {
|
||||
s.mu.Lock()
|
||||
s.nextSub++
|
||||
id := s.nextSub
|
||||
sub := &Subscriber{channel: make(chan Notice, queue)}
|
||||
s.subs[id] = sub
|
||||
if len(s.history) > 0 {
|
||||
oldest := s.history[0].ID
|
||||
newest := s.history[len(s.history)-1].ID
|
||||
if after > newest {
|
||||
initialGap = &Notice{Gap: after - newest, ResetID: newest}
|
||||
after = newest
|
||||
} else if after < oldest-1 {
|
||||
initialGap = &Notice{Gap: oldest - 1 - after, ResetID: oldest - 1}
|
||||
after = oldest - 1
|
||||
}
|
||||
if maxReplay > 0 && newest-after > uint64(maxReplay) {
|
||||
reset := newest - uint64(maxReplay)
|
||||
missed := reset - after
|
||||
if initialGap == nil {
|
||||
initialGap = &Notice{Gap: missed, ResetID: reset}
|
||||
} else {
|
||||
initialGap.Gap += missed
|
||||
initialGap.ResetID = reset
|
||||
}
|
||||
after = reset
|
||||
}
|
||||
for _, event := range s.history {
|
||||
if event.ID > after {
|
||||
replay = append(replay, cloneEvent(event))
|
||||
}
|
||||
}
|
||||
} else if after > 0 {
|
||||
initialGap = &Notice{Gap: after, ResetID: 0}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
var once sync.Once
|
||||
return replay, initialGap, sub.channel, func() { once.Do(func() { s.mu.Lock(); delete(s.subs, id); close(sub.channel); s.mu.Unlock() }) }
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package events
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestStoreBoundsCopiesAndConcurrentCounters(t *testing.T) {
|
||||
s := NewStore(4, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 10; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for n := 0; n < 10; n++ {
|
||||
s.Add(Event{Kind: MessageReceived, Properties: Properties{CorrelationData: []byte("id")}})
|
||||
s.Snapshot()
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
history, counts := s.Snapshot()
|
||||
if len(history) != 4 || counts[MessageReceived] != 100 {
|
||||
t.Fatal("history/counter bound incorrect")
|
||||
}
|
||||
for i := 1; i < len(history); i++ {
|
||||
if history[i].ID <= history[i-1].ID {
|
||||
t.Fatal("history IDs out of order")
|
||||
}
|
||||
}
|
||||
history[0].Properties.CorrelationData[0] = 'X'
|
||||
again, _ := s.Snapshot()
|
||||
if string(again[0].Properties.CorrelationData) != "id" {
|
||||
t.Fatal("snapshot mutated internal history")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscribeReplayGapLiveFanoutAndCleanup(t *testing.T) {
|
||||
s := NewStore(3, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
for i := 0; i < 5; i++ {
|
||||
s.Add(Event{Kind: MessageReceived})
|
||||
}
|
||||
replay, gap, stream, unsubscribe := s.Subscribe(0, 1, 1)
|
||||
if gap == nil || gap.ResetID != 4 || gap.Gap != 4 || len(replay) != 1 || replay[0].ID != 5 {
|
||||
t.Fatalf("bounded replay gap: %#v %#v", gap, replay)
|
||||
}
|
||||
s.Add(Event{Kind: MessageReceived})
|
||||
if n := <-stream; n.Event == nil || n.Event.ID != 6 {
|
||||
t.Fatal("live event fanout failed")
|
||||
}
|
||||
unsubscribe()
|
||||
unsubscribe()
|
||||
if len(s.subs) != 0 {
|
||||
t.Fatal("subscriber cleanup failed")
|
||||
}
|
||||
_, gap, _, closeSub := s.Subscribe(999, 1, 1)
|
||||
defer closeSub()
|
||||
if gap == nil || gap.ResetID != 6 {
|
||||
t.Fatal("future cursor did not request reset")
|
||||
}
|
||||
}
|
||||
func TestSlowViewerGetsGapWithoutBlockingEvents(t *testing.T) {
|
||||
s := NewStore(8, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
s.Add(Event{Kind: MessageReceived})
|
||||
_, _, stream, unsubscribe := s.Subscribe(1, 1, 8)
|
||||
defer unsubscribe()
|
||||
s.Add(Event{Kind: MessageReceived})
|
||||
s.Add(Event{Kind: MessageReceived})
|
||||
if n := <-stream; n.Event == nil || n.Event.ID != 2 {
|
||||
t.Fatal("first bounded event missing")
|
||||
}
|
||||
s.Add(Event{Kind: MessageReceived})
|
||||
if n := <-stream; n.Gap != 2 || n.ResetID != 4 {
|
||||
t.Fatalf("missing explicit overflow gap: %#v", n)
|
||||
}
|
||||
s.Add(Event{Kind: MessageReceived})
|
||||
if n := <-stream; n.Event == nil || n.Event.ID != 5 {
|
||||
t.Fatalf("unexpected event after overflow gap: %#v", n)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user