Deploy MQTT test backend with private viewer and Gitea CI/CD
Test and deploy MQTT backend / test (push) Successful in 2m8s
Test and deploy MQTT backend / deploy (push) Successful in 18s

This commit is contained in:
2026-10-08 14:48:53 +03:30
commit 71ef622b4f
70 changed files with 7123 additions and 0 deletions
+138
View File
@@ -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
}
+55
View File
@@ -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")
}
}
}
+144
View File
@@ -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: &copyEvent}:
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() }) }
}
+82
View File
@@ -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)
}
}