Behavision: face recognition for retail, edge to head office
Five components that ship as one product:
- behavision/ the recognition engine. RTSP ingest, YuNet detection, IoU
tracking, ArcFace embeddings, a FAISS/SQLite gallery, and a
FastAPI dashboard. Identity is decided once per TRACK from an
average of at least three embeddings, never per frame.
- agent/ the Go edge agent: supervises the engine, holds a durable
spool, and drains it to MQTT. Nothing is acked before the
broker confirms.
- desktop/ the shop PC application (Wails + React + tray).
- server/ the cloud API, MQTT consumer, reports and assistant.
- web/ platform.loyaly.ai, the head-office app, embedded in the
server binary.
The gallery stores 512-float embeddings and timestamps - no images unless
`app.store_faces` is switched on. Those embeddings are biometric personal
data under GDPR and India's DPDP: template inversion reconstructs a
recognisable face from an ArcFace vector, so data/behavision.db is treated
as a biometric database and DELETE /api/visitors/{id} is a real erasure.
CLAUDE.md carries the reasoning behind every non-obvious decision here,
including the ones that were measured and the ones that were wrong first.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn
This commit is contained in:
279
agent/pkg/bridge/bridge.go
Normal file
279
agent/pkg/bridge/bridge.go
Normal file
@@ -0,0 +1,279 @@
|
||||
// Package bridge turns engine detections into queued MQTT messages.
|
||||
//
|
||||
// This is the link that was missing: the engine detects a person and fires an
|
||||
// event onto its own bus; nothing turned that into something the server would
|
||||
// ever see. The engine already has a webhook sink, so the agent listens on
|
||||
// loopback and points `events.webhook_url` at itself.
|
||||
//
|
||||
// A webhook rather than the agent polling the engine: polling would either miss
|
||||
// events between polls or need cursor state the engine does not keep, and the
|
||||
// sink already exists and already runs off the hot path.
|
||||
package bridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Queue is the durable spool, reduced to what the bridge needs.
|
||||
type Queue interface {
|
||||
Append(topic string, payload any) error
|
||||
}
|
||||
|
||||
// Embeddings fetches an identity's template from the engine.
|
||||
//
|
||||
// The event bus deliberately does not carry embeddings — a 512-float template
|
||||
// on the bus would reach the log sink and the email sink too — so the bridge
|
||||
// asks for it separately, once per identity.
|
||||
type Embeddings interface {
|
||||
Embedding(ctx context.Context, identityID int64) (vector []float32, model string, err error)
|
||||
}
|
||||
|
||||
// Event is the engine's wire shape (behavision/events.py).
|
||||
type Event struct {
|
||||
Type string `json:"type"`
|
||||
CameraID string `json:"camera_id"`
|
||||
TS float64 `json:"ts"`
|
||||
Data map[string]any `json:"data"`
|
||||
}
|
||||
|
||||
type Bridge struct {
|
||||
Queue Queue
|
||||
Embeddings Embeddings
|
||||
// TopicPrefix is "bv/<client>.<site>". The broker enforces that a site can
|
||||
// only publish under its own, so an empty one means this PC is not claimed
|
||||
// yet and events stay on disk rather than being addressed to nowhere.
|
||||
TopicPrefix string
|
||||
Log *log.Logger
|
||||
// Uploader sends face images to object storage. Nil when the engine is not
|
||||
// writing them, which is the default.
|
||||
Uploader Uploader
|
||||
// Wake, when set, is rung after a visit reaches the queue so the pump
|
||||
// drains it now instead of on its next idle tick. That tick is two seconds,
|
||||
// and it sits squarely on the path between a person walking in and their
|
||||
// face appearing on a screen - the one delay in this chain that costs
|
||||
// nothing to remove.
|
||||
//
|
||||
// Must not block: it runs on the engine's webhook request, so a slow pump
|
||||
// would apply backpressure all the way into the recognition loop.
|
||||
Wake func()
|
||||
|
||||
mu sync.Mutex
|
||||
// Templates are fetched once per identity, not once per sighting. A
|
||||
// returning customer seen forty times a day would otherwise pull the same
|
||||
// 512 floats out of SQLite forty times.
|
||||
seen map[int64]cached
|
||||
|
||||
Accepted uint64
|
||||
Skipped uint64
|
||||
Failed uint64
|
||||
}
|
||||
|
||||
type cached struct {
|
||||
vector []float32
|
||||
model string
|
||||
at time.Time
|
||||
}
|
||||
|
||||
const cacheTTL = 30 * time.Minute
|
||||
|
||||
// Handler is the HTTP endpoint the engine posts to.
|
||||
func (b *Bridge) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/events", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "post only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var ev Event
|
||||
if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&ev); err != nil {
|
||||
// 400, not 500: the engine must not retry a payload that will
|
||||
// never parse, and its sink logs failures without blocking.
|
||||
http.Error(w, "bad json", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := b.Handle(r.Context(), ev); err != nil {
|
||||
b.logf("event %s: %v", ev.Type, err)
|
||||
http.Error(w, "queue failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
})
|
||||
return mux
|
||||
}
|
||||
|
||||
// Handle converts one engine event and queues it.
|
||||
func (b *Bridge) Handle(ctx context.Context, ev Event) error {
|
||||
switch ev.Type {
|
||||
case "person.new", "person.seen":
|
||||
default:
|
||||
// camera.up/down and person.missed are local diagnostics. They belong
|
||||
// in the heartbeat, not in the footfall stream, where they would be
|
||||
// counted as visits.
|
||||
b.bump(&b.Skipped)
|
||||
return nil
|
||||
}
|
||||
if b.TopicPrefix == "" {
|
||||
b.bump(&b.Skipped)
|
||||
return nil
|
||||
}
|
||||
|
||||
identityID := asInt(ev.Data["identity_id"])
|
||||
visit := map[string]any{
|
||||
// Deterministic from what identifies the sighting, so the SAME event
|
||||
// redelivered after a crash carries the SAME id and the server's
|
||||
// idempotency check catches it. A random uuid here would defeat the
|
||||
// entire at-least-once design.
|
||||
"event_id": eventID(b.TopicPrefix, ev.CameraID, identityID, ev.TS),
|
||||
"occurred_at": time.Unix(0, int64(ev.TS*float64(time.Second))).UTC(),
|
||||
"camera_id": ev.CameraID,
|
||||
"is_new": ev.Type == "person.new",
|
||||
"similarity": asFloat(ev.Data["similarity"]),
|
||||
"quality": asFloat(ev.Data["quality"]),
|
||||
"local_visitor_id": identityID,
|
||||
"attributes": attributes(ev.Data),
|
||||
}
|
||||
|
||||
if identityID > 0 && b.Embeddings != nil {
|
||||
vec, model, err := b.embedding(ctx, identityID)
|
||||
if err != nil {
|
||||
// Queue the visit anyway. A footfall count without a template is
|
||||
// still a real visit; dropping it would lose the one number the
|
||||
// customer is paying for over an optional field.
|
||||
b.logf("no embedding for identity %d, sending counts only: %v",
|
||||
identityID, err)
|
||||
} else {
|
||||
visit["embedding"] = vec
|
||||
visit["model"] = model
|
||||
}
|
||||
}
|
||||
|
||||
// After the embedding, before the queue: the key has to be on the event
|
||||
// that gets queued, and the local file is removed either way so a failed
|
||||
// upload cannot leave a picture of a customer on a shop PC forever.
|
||||
if path, _ := ev.Data["image_path"].(string); path != "" {
|
||||
b.attachImage(ctx, visit, path)
|
||||
}
|
||||
|
||||
if err := b.Queue.Append(b.TopicPrefix+"/visit", visit); err != nil {
|
||||
b.bump(&b.Failed)
|
||||
return fmt.Errorf("queue visit: %w", err)
|
||||
}
|
||||
b.bump(&b.Accepted)
|
||||
// After the append, never before: waking a pump for an event that is not
|
||||
// on disk yet is a drain that finds nothing and an event that then waits
|
||||
// out the full idle interval anyway.
|
||||
if b.Wake != nil {
|
||||
b.Wake()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Bridge) embedding(ctx context.Context, id int64) ([]float32, string, error) {
|
||||
b.mu.Lock()
|
||||
if c, ok := b.seen[id]; ok && time.Since(c.at) < cacheTTL {
|
||||
b.mu.Unlock()
|
||||
return c.vector, c.model, nil
|
||||
}
|
||||
b.mu.Unlock()
|
||||
|
||||
vec, model, err := b.Embeddings.Embedding(ctx, id)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
b.mu.Lock()
|
||||
if b.seen == nil {
|
||||
b.seen = map[int64]cached{}
|
||||
}
|
||||
b.seen[id] = cached{vector: vec, model: model, at: time.Now()}
|
||||
b.mu.Unlock()
|
||||
return vec, model, nil
|
||||
}
|
||||
|
||||
// Listen serves the webhook on loopback and returns the URL to configure in
|
||||
// the engine. Port 0 so two instances on one machine cannot collide.
|
||||
func (b *Bridge) Listen(ctx context.Context) (url string, stop func(), err error) {
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
srv := &http.Server{
|
||||
Handler: b.Handler(),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
go func() {
|
||||
if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
b.logf("bridge server stopped: %v", err)
|
||||
}
|
||||
}()
|
||||
return fmt.Sprintf("http://%s/events", ln.Addr().String()), func() {
|
||||
c, cancel := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
defer cancel()
|
||||
_ = srv.Shutdown(c)
|
||||
}, nil
|
||||
}
|
||||
|
||||
// eventID is stable for one sighting: same camera, same identity, same second.
|
||||
//
|
||||
// The engine's sighting cooldown is 30 s, so two genuinely different visits by
|
||||
// one person at one camera cannot share a second. Truncating to the second
|
||||
// rather than using the raw float also survives the engine re-sending after a
|
||||
// restart with a marginally different timestamp.
|
||||
func eventID(prefix, camera string, identity int64, ts float64) string {
|
||||
return fmt.Sprintf("%s|%s|%d|%d",
|
||||
strings.TrimPrefix(prefix, "bv/"), camera, identity, int64(ts))
|
||||
}
|
||||
|
||||
// attributes keeps the estimator output and drops the bookkeeping fields the
|
||||
// server already has as columns.
|
||||
func attributes(data map[string]any) map[string]any {
|
||||
out := map[string]any{}
|
||||
for k, v := range data {
|
||||
switch k {
|
||||
case "identity_id", "label", "similarity", "quality", "frame_quality":
|
||||
continue
|
||||
}
|
||||
out[k] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func asInt(v any) int64 {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return int64(n)
|
||||
case int64:
|
||||
return n
|
||||
case int:
|
||||
return int64(n)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func asFloat(v any) float64 {
|
||||
if f, ok := v.(float64); ok {
|
||||
return f
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (b *Bridge) bump(p *uint64) {
|
||||
b.mu.Lock()
|
||||
*p++
|
||||
b.mu.Unlock()
|
||||
}
|
||||
|
||||
func (b *Bridge) logf(format string, args ...any) {
|
||||
if b.Log != nil {
|
||||
b.Log.Printf(format, args...)
|
||||
}
|
||||
}
|
||||
288
agent/pkg/bridge/bridge_test.go
Normal file
288
agent/pkg/bridge/bridge_test.go
Normal file
@@ -0,0 +1,288 @@
|
||||
package bridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type fakeQueue struct {
|
||||
topics []string
|
||||
payloads []map[string]any
|
||||
err error
|
||||
}
|
||||
|
||||
func (q *fakeQueue) Append(topic string, payload any) error {
|
||||
if q.err != nil {
|
||||
return q.err
|
||||
}
|
||||
b, _ := json.Marshal(payload)
|
||||
var m map[string]any
|
||||
json.Unmarshal(b, &m)
|
||||
q.topics = append(q.topics, topic)
|
||||
q.payloads = append(q.payloads, m)
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeEmb struct {
|
||||
calls int
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeEmb) Embedding(_ context.Context, id int64) ([]float32, string, error) {
|
||||
f.calls++
|
||||
if f.err != nil {
|
||||
return nil, "", f.err
|
||||
}
|
||||
return make([]float32, 512), "w600k_r50.onnx", nil
|
||||
}
|
||||
|
||||
func newBridge() (*Bridge, *fakeQueue, *fakeEmb) {
|
||||
q, e := &fakeQueue{}, &fakeEmb{}
|
||||
return &Bridge{Queue: q, Embeddings: e, TopicPrefix: "bv/acme.store1"}, q, e
|
||||
}
|
||||
|
||||
func seen(id int64, ts float64) Event {
|
||||
return Event{Type: "person.seen", CameraID: "entrance", TS: ts,
|
||||
Data: map[string]any{"identity_id": float64(id), "similarity": 0.58,
|
||||
"quality": 0.74, "gender": "Male", "age": float64(34)}}
|
||||
}
|
||||
|
||||
func TestADetectionIsQueuedForTheRightTopic(t *testing.T) {
|
||||
b, q, _ := newBridge()
|
||||
if err := b.Handle(context.Background(), seen(7, 1787996491)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(q.topics) != 1 || q.topics[0] != "bv/acme.store1/visit" {
|
||||
t.Fatalf("topics=%v", q.topics)
|
||||
}
|
||||
p := q.payloads[0]
|
||||
if p["camera_id"] != "entrance" || p["is_new"] != false {
|
||||
t.Fatalf("%+v", p)
|
||||
}
|
||||
if p["quality"] != 0.74 || p["similarity"] != 0.58 {
|
||||
t.Fatalf("measurements lost: %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheEventIDIsStableForTheSameSighting(t *testing.T) {
|
||||
// This is what makes at-least-once delivery safe. A random id here would
|
||||
// defeat the server's idempotency check and double the store's footfall
|
||||
// after every reconnect.
|
||||
b, q, _ := newBridge()
|
||||
ctx := context.Background()
|
||||
b.Handle(ctx, seen(7, 1787996491.20))
|
||||
b.Handle(ctx, seen(7, 1787996491.86)) // same second, redelivered
|
||||
|
||||
if q.payloads[0]["event_id"] != q.payloads[1]["event_id"] {
|
||||
t.Fatalf("ids differ: %v vs %v",
|
||||
q.payloads[0]["event_id"], q.payloads[1]["event_id"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDifferentPeopleAndCamerasGetDifferentIDs(t *testing.T) {
|
||||
b, q, _ := newBridge()
|
||||
ctx := context.Background()
|
||||
b.Handle(ctx, seen(7, 1787996491))
|
||||
b.Handle(ctx, seen(8, 1787996491)) // different person, same instant
|
||||
other := seen(7, 1787996491)
|
||||
other.CameraID = "till"
|
||||
b.Handle(ctx, other) // same person, different camera
|
||||
|
||||
ids := map[any]bool{}
|
||||
for _, p := range q.payloads {
|
||||
ids[p["event_id"]] = true
|
||||
}
|
||||
if len(ids) != 3 {
|
||||
t.Fatalf("collided: %d distinct ids from 3 sightings", len(ids))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalDiagnosticsAreNotCountedAsVisits(t *testing.T) {
|
||||
// person.missed and camera.up are real events, but sending them down the
|
||||
// footfall stream would inflate the headcount with things that are not
|
||||
// people.
|
||||
b, q, _ := newBridge()
|
||||
for _, typ := range []string{"person.missed", "camera.up", "camera.down",
|
||||
"identity.merged"} {
|
||||
b.Handle(context.Background(), Event{Type: typ, CameraID: "entrance"})
|
||||
}
|
||||
if len(q.payloads) != 0 {
|
||||
t.Fatalf("queued %d non-visits", len(q.payloads))
|
||||
}
|
||||
if b.Skipped != 4 {
|
||||
t.Fatalf("skipped=%d", b.Skipped)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUnclaimedPCQueuesNothing(t *testing.T) {
|
||||
// Without a tenant prefix an event would be addressed to nowhere, and the
|
||||
// broker would refuse it anyway.
|
||||
b, q, _ := newBridge()
|
||||
b.TopicPrefix = ""
|
||||
b.Handle(context.Background(), seen(7, 1))
|
||||
if len(q.payloads) != 0 {
|
||||
t.Fatal("queued an event with no tenant")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheTemplateIsFetchedOncePerIdentity(t *testing.T) {
|
||||
// A returning customer seen forty times a day would otherwise pull the
|
||||
// same 512 floats out of SQLite forty times.
|
||||
b, _, e := newBridge()
|
||||
ctx := context.Background()
|
||||
for i := 0; i < 5; i++ {
|
||||
b.Handle(ctx, seen(7, float64(1787996491+i*60)))
|
||||
}
|
||||
if e.calls != 1 {
|
||||
t.Fatalf("fetched the template %d times", e.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMissingTemplateStillQueuesTheVisit(t *testing.T) {
|
||||
// A footfall count without a template is still a real visit. Dropping it
|
||||
// would lose the number the customer is paying for over an optional field.
|
||||
b, q, e := newBridge()
|
||||
e.err = errors.New("no embedding")
|
||||
if err := b.Handle(context.Background(), seen(7, 1)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(q.payloads) != 1 {
|
||||
t.Fatal("visit dropped because the template was missing")
|
||||
}
|
||||
if _, has := q.payloads[0]["embedding"]; has {
|
||||
t.Fatal("queued an embedding key with no embedding")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAttributesSurviveButBookkeepingIsDropped(t *testing.T) {
|
||||
b, q, _ := newBridge()
|
||||
b.Handle(context.Background(), seen(7, 1))
|
||||
attrs := q.payloads[0]["attributes"].(map[string]any)
|
||||
if attrs["gender"] != "Male" || attrs["age"] != float64(34) {
|
||||
t.Fatalf("attributes lost: %+v", attrs)
|
||||
}
|
||||
for _, k := range []string{"identity_id", "similarity", "quality"} {
|
||||
if _, dup := attrs[k]; dup {
|
||||
t.Errorf("%q duplicated into attributes; it is already a column", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMalformedJSONIsRejectedNotRetried(t *testing.T) {
|
||||
b, _, _ := newBridge()
|
||||
srv := httptest.NewServer(b.Handler())
|
||||
defer srv.Close()
|
||||
resp, err := http.Post(srv.URL+"/events", "application/json",
|
||||
strings.NewReader("{ truncated"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status %d — the engine would retry this forever", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheWebhookQueuesARealPost(t *testing.T) {
|
||||
b, q, _ := newBridge()
|
||||
srv := httptest.NewServer(b.Handler())
|
||||
defer srv.Close()
|
||||
body, _ := json.Marshal(seen(7, 1787996491))
|
||||
resp, err := http.Post(srv.URL+"/events", "application/json",
|
||||
strings.NewReader(string(body)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNoContent {
|
||||
t.Fatalf("status %d", resp.StatusCode)
|
||||
}
|
||||
if len(q.payloads) != 1 {
|
||||
t.Fatal("nothing queued")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListenBindsLoopbackOnly(t *testing.T) {
|
||||
// The webhook accepts unauthenticated posts that become footfall rows;
|
||||
// it must not be reachable from the network.
|
||||
b, _, _ := newBridge()
|
||||
url, stop, err := b.Listen(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer stop()
|
||||
if !strings.HasPrefix(url, "http://127.0.0.1:") {
|
||||
t.Fatalf("bridge listening on %s", url)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- waking
|
||||
|
||||
// Waking BEFORE the append would send the pump to look at a queue the event
|
||||
// has not reached yet: it finds nothing, goes back to sleep, and the visit then
|
||||
// waits out the full idle interval anyway - the exact delay the wake exists to
|
||||
// remove, with an extra wasted drain on top.
|
||||
func TestTheQueueIsWokenOnlyAfterTheVisitIsOnDisk(t *testing.T) {
|
||||
b, q, _ := newBridge()
|
||||
var depthWhenWoken = -1
|
||||
b.Wake = func() { depthWhenWoken = len(q.payloads) }
|
||||
|
||||
if err := b.Handle(context.Background(), seen(7, 1_700_000_000)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if depthWhenWoken != 1 {
|
||||
t.Fatalf("woken with %d events queued, want 1 - the event must be durable first",
|
||||
depthWhenWoken)
|
||||
}
|
||||
}
|
||||
|
||||
// A visit that never reached the queue must not wake anything: there is nothing
|
||||
// to drain, and the pump would spin on an empty spool.
|
||||
func TestAFailedAppendDoesNotWakeThePump(t *testing.T) {
|
||||
b, q, _ := newBridge()
|
||||
q.err = errors.New("disk full")
|
||||
woken := 0
|
||||
b.Wake = func() { woken++ }
|
||||
|
||||
if err := b.Handle(context.Background(), seen(7, 1_700_000_000)); err == nil {
|
||||
t.Fatal("a failed append should surface as an error")
|
||||
}
|
||||
if woken != 0 {
|
||||
t.Fatalf("woke the pump %d times for an event that was never queued", woken)
|
||||
}
|
||||
}
|
||||
|
||||
// Diagnostics are not visits. They are skipped before the queue, so they must
|
||||
// not wake a pump either.
|
||||
func TestASkippedEventDoesNotWakeThePump(t *testing.T) {
|
||||
b, _, _ := newBridge()
|
||||
woken := 0
|
||||
b.Wake = func() { woken++ }
|
||||
|
||||
ev := seen(7, 1_700_000_000)
|
||||
ev.Type = "person.missed"
|
||||
if err := b.Handle(context.Background(), ev); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if woken != 0 {
|
||||
t.Fatalf("a diagnostic event woke the pump %d times", woken)
|
||||
}
|
||||
}
|
||||
|
||||
// The bridge must run unchanged with no waker wired, which is what an agent
|
||||
// built before this existed looks like.
|
||||
func TestNoWakerIsFine(t *testing.T) {
|
||||
b, q, _ := newBridge()
|
||||
b.Wake = nil
|
||||
if err := b.Handle(context.Background(), seen(7, 1_700_000_000)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(q.payloads) != 1 {
|
||||
t.Fatalf("queued %d visits, want 1", len(q.payloads))
|
||||
}
|
||||
}
|
||||
56
agent/pkg/bridge/engine_client.go
Normal file
56
agent/pkg/bridge/engine_client.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package bridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// EngineEmbeddings reads templates from the local engine's API.
|
||||
type EngineEmbeddings struct {
|
||||
Base string
|
||||
User string
|
||||
Password string
|
||||
Client *http.Client
|
||||
}
|
||||
|
||||
func NewEngineEmbeddings(base, user, password string) *EngineEmbeddings {
|
||||
return &EngineEmbeddings{
|
||||
Base: strings.TrimRight(base, "/"), User: user, Password: password,
|
||||
Client: &http.Client{Timeout: 10 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func (e *EngineEmbeddings) Embedding(ctx context.Context, id int64) ([]float32, string, error) {
|
||||
url := fmt.Sprintf("%s/api/identities/%d/embedding", e.Base, id)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if e.User != "" {
|
||||
req.SetBasicAuth(e.User, e.Password)
|
||||
}
|
||||
resp, err := e.Client.Do(req)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, "", fmt.Errorf("engine returned %s", resp.Status)
|
||||
}
|
||||
var body struct {
|
||||
Model string `json:"model"`
|
||||
Embedding []float32 `json:"embedding"`
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&body); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
if len(body.Embedding) == 0 {
|
||||
return nil, "", fmt.Errorf("engine returned an empty embedding")
|
||||
}
|
||||
return body.Embedding, body.Model, nil
|
||||
}
|
||||
205
agent/pkg/bridge/images.go
Normal file
205
agent/pkg/bridge/images.go
Normal file
@@ -0,0 +1,205 @@
|
||||
package bridge
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Uploader sends one face image to object storage.
|
||||
//
|
||||
// It is an interface because the bridge must work identically when images are
|
||||
// off, when the PC is not enrolled yet, and when the server has no bucket
|
||||
// configured - three states that are normal rather than exceptional.
|
||||
type Uploader interface {
|
||||
// Upload returns the object key the server assigned.
|
||||
Upload(ctx context.Context, path string) (key string, err error)
|
||||
}
|
||||
|
||||
// SpacesUploader uploads through a URL the server mints.
|
||||
//
|
||||
// The shop PC holds no bucket credentials, only its own agent token. That is
|
||||
// the point: the bucket is shared with other applications and a counter-top PC
|
||||
// is the least trustworthy machine in the estate, so a stolen one gives up a
|
||||
// few minutes of write access to one key rather than a bucket password.
|
||||
type SpacesUploader struct {
|
||||
// BaseURL is the server, e.g. https://mcp.loyaly.ai
|
||||
BaseURL string
|
||||
// Token is the agent's own API credential, issued at enrolment. Separate
|
||||
// from the broker password so rotating either does not break the other.
|
||||
Token string
|
||||
Client *http.Client
|
||||
}
|
||||
|
||||
// ErrImagesOff means the server stores no images. Distinct from a failure: the
|
||||
// agent should stop trying and carry on sending visits, not retry forever.
|
||||
var ErrImagesOff = errors.New("server does not store images")
|
||||
|
||||
// maxImageBytes bounds what will be read off disk and sent. The engine writes
|
||||
// ~20 KB crops; anything near this is a bug or a different file that landed in
|
||||
// the outbox, and a shop uplink should not spend minutes discovering that.
|
||||
const maxImageBytes = 2 << 20
|
||||
|
||||
func (u *SpacesUploader) httpClient() *http.Client {
|
||||
if u.Client != nil {
|
||||
return u.Client
|
||||
}
|
||||
// Long enough for a slow shop uplink, bounded so a half-open connection
|
||||
// cannot stall the queue behind it.
|
||||
return &http.Client{Timeout: 60 * time.Second}
|
||||
}
|
||||
|
||||
type uploadTarget struct {
|
||||
Key string `json:"key"`
|
||||
URL string `json:"url"`
|
||||
Headers map[string]string `json:"headers"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
|
||||
func (u *SpacesUploader) Upload(ctx context.Context, path string) (string, error) {
|
||||
if u.BaseURL == "" || u.Token == "" {
|
||||
// Not claimed yet. The visit still queues; it simply has no photo.
|
||||
return "", ErrImagesOff
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if info.Size() == 0 {
|
||||
return "", errors.New("image file is empty")
|
||||
}
|
||||
if info.Size() > maxImageBytes {
|
||||
return "", fmt.Errorf("image is %d bytes, over the %d limit",
|
||||
info.Size(), maxImageBytes)
|
||||
}
|
||||
body, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return u.UploadBytes(ctx, body)
|
||||
}
|
||||
|
||||
// UploadBytes puts an image already in memory.
|
||||
//
|
||||
// Split out for camera snapshots, which the engine hands over as bytes. The
|
||||
// alternative - writing each frame to a temp file so Upload could read it back
|
||||
// - would put a picture of a shop floor on disk once a minute per camera, on
|
||||
// the one machine in the estate least worth trusting with it.
|
||||
func (u *SpacesUploader) UploadBytes(ctx context.Context, body []byte) (string, error) {
|
||||
if u.BaseURL == "" || u.Token == "" {
|
||||
return "", ErrImagesOff
|
||||
}
|
||||
if len(body) == 0 {
|
||||
return "", errors.New("image is empty")
|
||||
}
|
||||
if int64(len(body)) > maxImageBytes {
|
||||
return "", fmt.Errorf("image is %d bytes, over the %d limit",
|
||||
len(body), maxImageBytes)
|
||||
}
|
||||
|
||||
target, err := u.target(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPut, target.URL,
|
||||
bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Sent exactly as handed back. The ACL is inside the server's signature, so
|
||||
// changing or dropping it does not publish the image - it fails the upload,
|
||||
// which is the safe direction.
|
||||
for k, v := range target.Headers {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
req.ContentLength = int64(len(body))
|
||||
|
||||
resp, err := u.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("upload: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
|
||||
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||
return "", fmt.Errorf("upload returned %s: %s",
|
||||
resp.Status, strings.TrimSpace(string(msg)))
|
||||
}
|
||||
return target.Key, nil
|
||||
}
|
||||
|
||||
func (u *SpacesUploader) target(ctx context.Context) (uploadTarget, error) {
|
||||
var out uploadTarget
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||
strings.TrimRight(u.BaseURL, "/")+"/api/agent/upload-url", nil)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+u.Token)
|
||||
|
||||
resp, err := u.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return out, fmt.Errorf("ask for an upload url: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode == http.StatusNotImplemented {
|
||||
return out, ErrImagesOff
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10))
|
||||
return out, fmt.Errorf("upload url returned %s: %s",
|
||||
resp.Status, strings.TrimSpace(string(body)))
|
||||
}
|
||||
if err := json.NewDecoder(io.LimitReader(resp.Body, 64<<10)).Decode(&out); err != nil {
|
||||
return out, err
|
||||
}
|
||||
if out.URL == "" || out.Key == "" {
|
||||
return out, errors.New("server returned an incomplete upload target")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// attachImage uploads the engine's face image and returns the object key.
|
||||
//
|
||||
// Every failure is non-fatal and the local file is removed regardless. A visit
|
||||
// without a photo is a real visit and the number the customer pays for; a
|
||||
// visit stuck behind a failed upload is lost footfall. Keeping the file for a
|
||||
// retry would also mean an outbox that grows for as long as the failure lasts,
|
||||
// full of pictures of customers.
|
||||
func (b *Bridge) attachImage(ctx context.Context, visit map[string]any, path string) {
|
||||
if path == "" {
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||
b.logf("could not remove %s after upload: %v", filepath.Base(path), err)
|
||||
}
|
||||
}()
|
||||
if b.Uploader == nil {
|
||||
return
|
||||
}
|
||||
// Bounded separately from the caller: an upload that hangs must not hold
|
||||
// up the visit it belongs to.
|
||||
uctx, cancel := context.WithTimeout(ctx, 90*time.Second)
|
||||
defer cancel()
|
||||
|
||||
key, err := b.Uploader.Upload(uctx, path)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrImagesOff) {
|
||||
// Normal for a deployment that stores no images, and for a PC that
|
||||
// has not been claimed yet. Not worth a line per visitor.
|
||||
return
|
||||
}
|
||||
b.logf("image upload failed for %s, sending the visit without it: %v",
|
||||
filepath.Base(path), err)
|
||||
return
|
||||
}
|
||||
visit["image_key"] = key
|
||||
}
|
||||
202
agent/pkg/bridge/images_test.go
Normal file
202
agent/pkg/bridge/images_test.go
Normal file
@@ -0,0 +1,202 @@
|
||||
package bridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func writeImage(t *testing.T, dir, name string, body []byte) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(path, body, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
// fakeServer plays both halves: the API that mints an upload URL and the
|
||||
// bucket that receives the PUT.
|
||||
func fakeServer(t *testing.T, uploaded *[]byte, sentACL *string) *httptest.Server {
|
||||
t.Helper()
|
||||
mux := http.NewServeMux()
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
mux.HandleFunc("/api/agent/upload-url", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Header.Get("Authorization") != "Bearer agent-token" {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
json.NewEncoder(w).Encode(uploadTarget{ //nolint:errcheck
|
||||
Key: "behavision/acme/store1/2026/08/31/abc.jpg",
|
||||
URL: srv.URL + "/bucket/abc.jpg",
|
||||
Headers: map[string]string{
|
||||
"x-amz-acl": "private", "content-type": "image/jpeg",
|
||||
},
|
||||
ExpiresIn: 600,
|
||||
})
|
||||
})
|
||||
mux.HandleFunc("/bucket/", func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
*uploaded = body
|
||||
*sentACL = r.Header.Get("x-amz-acl")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
return srv
|
||||
}
|
||||
|
||||
func TestUploadSendsTheFileAndTheSignedACL(t *testing.T) {
|
||||
var got []byte
|
||||
var acl string
|
||||
srv := fakeServer(t, &got, &acl)
|
||||
dir := t.TempDir()
|
||||
path := writeImage(t, dir, "face.jpg", []byte("jpeg bytes"))
|
||||
|
||||
u := &SpacesUploader{BaseURL: srv.URL, Token: "agent-token"}
|
||||
key, err := u.Upload(context.Background(), path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if key != "behavision/acme/store1/2026/08/31/abc.jpg" {
|
||||
t.Fatalf("key = %q", key)
|
||||
}
|
||||
if string(got) != "jpeg bytes" {
|
||||
t.Fatalf("uploaded %q", got)
|
||||
}
|
||||
// The ACL is inside the server's signature. Sending it exactly as handed
|
||||
// back is what keeps the shop PC from deciding to publish the image.
|
||||
if acl != "private" {
|
||||
t.Fatalf("x-amz-acl = %q, want private", acl)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnclaimedPCReportsImagesOffRatherThanFailing(t *testing.T) {
|
||||
u := &SpacesUploader{} // no base url, no token: not enrolled yet
|
||||
_, err := u.Upload(context.Background(), "/nonexistent")
|
||||
if !errors.Is(err, ErrImagesOff) {
|
||||
t.Fatalf("got %v, want ErrImagesOff", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServerWithoutABucketIsNotARetryableFailure(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusNotImplemented)
|
||||
}))
|
||||
defer srv.Close()
|
||||
dir := t.TempDir()
|
||||
path := writeImage(t, dir, "face.jpg", []byte("x"))
|
||||
|
||||
u := &SpacesUploader{BaseURL: srv.URL, Token: "agent-token"}
|
||||
// 501 means "this deployment stores no images". The agent must stop trying
|
||||
// rather than retry every visitor forever.
|
||||
if _, err := u.Upload(context.Background(), path); !errors.Is(err, ErrImagesOff) {
|
||||
t.Fatalf("got %v, want ErrImagesOff", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOversizedFilesAreRefusedBeforeTheUplink(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := writeImage(t, dir, "huge.jpg", make([]byte, maxImageBytes+1))
|
||||
u := &SpacesUploader{BaseURL: "http://example.invalid", Token: "t"}
|
||||
// A shop uplink should not spend minutes discovering that something other
|
||||
// than a face crop landed in the outbox.
|
||||
if _, err := u.Upload(context.Background(), path); err == nil ||
|
||||
!strings.Contains(err.Error(), "limit") {
|
||||
t.Fatalf("got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// -- the bridge's use of it -------------------------------------------------
|
||||
|
||||
type stubUploader struct {
|
||||
key string
|
||||
err error
|
||||
sent []string
|
||||
}
|
||||
|
||||
func (s *stubUploader) Upload(_ context.Context, path string) (string, error) {
|
||||
s.sent = append(s.sent, path)
|
||||
return s.key, s.err
|
||||
}
|
||||
|
||||
func TestVisitCarriesTheImageKeyAndTheLocalFileIsRemoved(t *testing.T) {
|
||||
q := &fakeQueue{}
|
||||
up := &stubUploader{key: "behavision/acme/store1/2026/08/31/abc.jpg"}
|
||||
b := &Bridge{Queue: q, TopicPrefix: "bv/acme.store1", Uploader: up}
|
||||
path := writeImage(t, t.TempDir(), "face.jpg", []byte("jpeg"))
|
||||
|
||||
err := b.Handle(context.Background(), Event{
|
||||
Type: "person.new", CameraID: "entrance", TS: 1756_000_000,
|
||||
Data: map[string]any{"identity_id": float64(7), "image_path": path},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(q.payloads) != 1 {
|
||||
t.Fatalf("expected one queued visit, got %d", len(q.payloads))
|
||||
}
|
||||
visit := q.payloads[0]
|
||||
if visit["image_key"] != up.key {
|
||||
t.Fatalf("image_key = %v", visit["image_key"])
|
||||
}
|
||||
// The outbox is transient. Leaving files behind means a shop PC slowly
|
||||
// filling with pictures of its customers.
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Fatal("the local image was not removed after upload")
|
||||
}
|
||||
}
|
||||
|
||||
// A footfall count without a photo is a real visit and the number the customer
|
||||
// pays for. Losing it over an optional field would be the wrong trade - the
|
||||
// same rule the bridge already follows for a missing embedding.
|
||||
func TestAFailedUploadStillQueuesTheVisit(t *testing.T) {
|
||||
q := &fakeQueue{}
|
||||
up := &stubUploader{err: errors.New("bucket unreachable")}
|
||||
b := &Bridge{Queue: q, TopicPrefix: "bv/acme.store1", Uploader: up}
|
||||
path := writeImage(t, t.TempDir(), "face.jpg", []byte("jpeg"))
|
||||
|
||||
if err := b.Handle(context.Background(), Event{
|
||||
Type: "person.seen", CameraID: "entrance", TS: 1756_000_001,
|
||||
Data: map[string]any{"identity_id": float64(7), "image_path": path},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(q.payloads) != 1 {
|
||||
t.Fatalf("the visit was dropped because its photo failed")
|
||||
}
|
||||
if _, ok := q.payloads[0]["image_key"]; ok {
|
||||
t.Fatal("a key was attached despite the upload failing")
|
||||
}
|
||||
// Removed anyway: keeping it for a retry means an outbox that grows for as
|
||||
// long as the failure lasts.
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Fatal("the local image survived a failed upload")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoUploaderMeansNoImageAndNoLeftovers(t *testing.T) {
|
||||
q := &fakeQueue{}
|
||||
b := &Bridge{Queue: q, TopicPrefix: "bv/acme.store1"} // images off
|
||||
path := writeImage(t, t.TempDir(), "face.jpg", []byte("jpeg"))
|
||||
|
||||
if err := b.Handle(context.Background(), Event{
|
||||
Type: "person.new", CameraID: "entrance", TS: 1756_000_002,
|
||||
Data: map[string]any{"identity_id": float64(7), "image_path": path},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := q.payloads[0]["image_key"]; ok {
|
||||
t.Fatal("an image key appeared with no uploader configured")
|
||||
}
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Fatal("the local image was left on disk")
|
||||
}
|
||||
}
|
||||
291
agent/pkg/cameras/cameras.go
Normal file
291
agent/pkg/cameras/cameras.go
Normal file
@@ -0,0 +1,291 @@
|
||||
// Package cameras keeps a shop PC's cameras in step with head office.
|
||||
//
|
||||
// The split is forced by the network, not by taste: only this PC is on the
|
||||
// camera's LAN, so only this PC can connect to it — but the person onboarding a
|
||||
// camera is often in an office somewhere else. So head office holds the DESIRED
|
||||
// configuration and the agent pulls it.
|
||||
//
|
||||
// Pull, never push. A shop PC sits behind a router with no inbound route, so it
|
||||
// has to ask; and asking makes the whole thing idempotent — a sync that fails
|
||||
// halfway is fixed by the next one rather than leaving two systems disagreeing.
|
||||
package cameras
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Engine is the local recognition engine's camera API.
|
||||
type Engine interface {
|
||||
List(ctx context.Context) ([]Local, error)
|
||||
Add(ctx context.Context, cam Local) error
|
||||
Update(ctx context.Context, id string, cam Local) error
|
||||
Remove(ctx context.Context, id string) error
|
||||
Snapshot(ctx context.Context, id string) ([]byte, error)
|
||||
}
|
||||
|
||||
// Cloud is head office.
|
||||
type Cloud interface {
|
||||
Desired(ctx context.Context) ([]Desired, error)
|
||||
Report(ctx context.Context, rep Report) error
|
||||
UploadSnapshot(ctx context.Context, jpeg []byte) (key string, err error)
|
||||
}
|
||||
|
||||
// Local is a camera as the engine holds it.
|
||||
type Local struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Host string `json:"host,omitempty"`
|
||||
Port int `json:"port,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Username string `json:"username,omitempty"`
|
||||
Password string `json:"password,omitempty"`
|
||||
MaxWidth int `json:"max_width,omitempty"`
|
||||
Tuning map[string]any `json:"tuning,omitempty"`
|
||||
Connected bool `json:"connected"`
|
||||
}
|
||||
|
||||
// Desired is a camera as head office holds it.
|
||||
type Desired struct {
|
||||
CameraID string `json:"camera_id"`
|
||||
Label string `json:"label"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
Path string `json:"path"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
MaxWidth int `json:"max_width"`
|
||||
Tuning map[string]any `json:"tuning"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Revision int64 `json:"revision"`
|
||||
Deleted bool `json:"deleted"`
|
||||
}
|
||||
|
||||
type State struct {
|
||||
CameraID string `json:"camera_id"`
|
||||
Connected bool `json:"connected"`
|
||||
SnapshotKey string `json:"snapshot_key,omitempty"`
|
||||
}
|
||||
|
||||
type Report struct {
|
||||
State []State `json:"state,omitempty"`
|
||||
Adopt []Desired `json:"adopt,omitempty"`
|
||||
}
|
||||
|
||||
// Syncer reconciles the two, on a timer.
|
||||
type Syncer struct {
|
||||
Engine Engine
|
||||
Cloud Cloud
|
||||
Log *log.Logger
|
||||
|
||||
// Every how often to reconcile configuration. Cameras change rarely, and
|
||||
// each sync is a database read on the server for every site in the estate,
|
||||
// so this is minutes rather than seconds.
|
||||
Interval time.Duration
|
||||
// How often to send a fresh picture of each camera. A shop floor does not
|
||||
// change much, and each frame is a few tens of kilobytes uploaded over the
|
||||
// same connection the visits have to travel on.
|
||||
SnapshotEvery time.Duration
|
||||
|
||||
// Checks and Prober are the "prove this camera works" half. Both nil on a
|
||||
// PC that has never been claimed, and the syncer simply skips that work
|
||||
// rather than treating it as a failure.
|
||||
Checks Checks
|
||||
Prober Prober
|
||||
|
||||
// applied remembers the revision last pushed into the engine, so an
|
||||
// unchanged site costs one request and no engine calls at all.
|
||||
applied map[string]int64
|
||||
}
|
||||
|
||||
const (
|
||||
DefaultInterval = 2 * time.Minute
|
||||
DefaultSnapshotEvery = 60 * time.Second
|
||||
)
|
||||
|
||||
// New builds a fully wired Syncer from the two clients every caller already
|
||||
// has.
|
||||
//
|
||||
// It exists because the four fields were assembled by hand at each call site
|
||||
// and both of them - the headless agent and the desktop app - set Engine and
|
||||
// Cloud and forgot Checks and Prober. runChecks returns silently when either
|
||||
// is nil (correct: an unclaimed PC has neither), so pressing "Test connection"
|
||||
// at head office left the camera saying "checking..." until the five-minute
|
||||
// stale release, and then said nothing at all. No error, on either side.
|
||||
//
|
||||
// The same two objects satisfy all four interfaces, so there was never a
|
||||
// reason for a caller to choose.
|
||||
func New(eng *EngineClient, cloud *CloudClient, log *log.Logger) *Syncer {
|
||||
return &Syncer{Engine: eng, Cloud: cloud, Checks: cloud, Prober: eng, Log: log}
|
||||
}
|
||||
|
||||
// Run reconciles until ctx is cancelled.
|
||||
func (s *Syncer) Run(ctx context.Context) {
|
||||
interval, snapEvery := s.Interval, s.SnapshotEvery
|
||||
if interval <= 0 {
|
||||
interval = DefaultInterval
|
||||
}
|
||||
if snapEvery <= 0 {
|
||||
snapEvery = DefaultSnapshotEvery
|
||||
}
|
||||
// Immediately on start, so a PC that has just been claimed picks up its
|
||||
// cameras now rather than in two minutes.
|
||||
s.Once(ctx)
|
||||
|
||||
config := time.NewTicker(interval)
|
||||
defer config.Stop()
|
||||
snaps := time.NewTicker(snapEvery)
|
||||
defer snaps.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-config.C:
|
||||
s.Once(ctx)
|
||||
case <-snaps.C:
|
||||
s.report(ctx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Once performs one full reconcile: pull desired, apply, then report back.
|
||||
func (s *Syncer) Once(ctx context.Context) {
|
||||
if s.applied == nil {
|
||||
s.applied = map[string]int64{}
|
||||
}
|
||||
desired, err := s.Cloud.Desired(ctx)
|
||||
if err != nil {
|
||||
// Not fatal and not even unusual: an unclaimed PC has no credentials
|
||||
// and a disconnected one has no network. The engine keeps running the
|
||||
// cameras it already has, which is the whole point of the local store.
|
||||
s.logf("camera sync: %v", err)
|
||||
return
|
||||
}
|
||||
local, err := s.Engine.List(ctx)
|
||||
if err != nil {
|
||||
s.logf("camera sync: engine unavailable: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
have := map[string]Local{}
|
||||
for _, c := range local {
|
||||
have[c.ID] = c
|
||||
}
|
||||
known := map[string]bool{}
|
||||
|
||||
for _, d := range desired {
|
||||
known[d.CameraID] = true
|
||||
_, exists := have[d.CameraID]
|
||||
|
||||
switch {
|
||||
case d.Deleted || !d.Enabled:
|
||||
if exists {
|
||||
if err := s.Engine.Remove(ctx, d.CameraID); err != nil {
|
||||
s.logf("camera %s: remove failed: %v", d.CameraID, err)
|
||||
continue
|
||||
}
|
||||
s.logf("camera %s removed (head office)", d.CameraID)
|
||||
}
|
||||
delete(s.applied, d.CameraID)
|
||||
|
||||
case !exists:
|
||||
if err := s.Engine.Add(ctx, toLocal(d)); err != nil {
|
||||
s.logf("camera %s: add failed: %v", d.CameraID, err)
|
||||
continue
|
||||
}
|
||||
s.applied[d.CameraID] = d.Revision
|
||||
s.logf("camera %s added from head office", d.CameraID)
|
||||
|
||||
case s.applied[d.CameraID] != d.Revision:
|
||||
// The revision is what keeps this cheap. Without it every sync
|
||||
// would PATCH every camera, and a PATCH restarts the connection —
|
||||
// so a healthy site would drop its own video every two minutes.
|
||||
if err := s.Engine.Update(ctx, d.CameraID, toLocal(d)); err != nil {
|
||||
s.logf("camera %s: update failed: %v", d.CameraID, err)
|
||||
continue
|
||||
}
|
||||
s.applied[d.CameraID] = d.Revision
|
||||
s.logf("camera %s updated to revision %d", d.CameraID, d.Revision)
|
||||
}
|
||||
}
|
||||
|
||||
// Anything running here that head office has never heard of gets offered
|
||||
// up. Without this, switching the feature on would delete every camera an
|
||||
// existing site is already running — including the one it was commissioned
|
||||
// with. The server refuses to overwrite its own config with these, and
|
||||
// keeps tombstones, so a deleted camera is not resurrected.
|
||||
var adopt []Desired
|
||||
for id, c := range have {
|
||||
if known[id] {
|
||||
continue
|
||||
}
|
||||
adopt = append(adopt, Desired{
|
||||
CameraID: id, Label: orElse(c.Label, id), Host: c.Host,
|
||||
Port: c.Port, Path: c.Path, Username: c.Username,
|
||||
Password: c.Password, MaxWidth: c.MaxWidth, Tuning: c.Tuning,
|
||||
Enabled: true,
|
||||
})
|
||||
}
|
||||
s.reportWith(ctx, adopt)
|
||||
// Last, so a check requested against a camera added in the same breath
|
||||
// finds it already applied. Inside Once() rather than beside it in the
|
||||
// loop, so it also runs on startup and cannot be called twice a tick.
|
||||
s.runChecks(ctx, desired)
|
||||
}
|
||||
|
||||
func (s *Syncer) report(ctx context.Context) { s.reportWith(ctx, nil) }
|
||||
|
||||
// reportWith sends observed state, and a fresh picture from each camera.
|
||||
func (s *Syncer) reportWith(ctx context.Context, adopt []Desired) {
|
||||
local, err := s.Engine.List(ctx)
|
||||
if err != nil {
|
||||
s.logf("camera report: engine unavailable: %v", err)
|
||||
return
|
||||
}
|
||||
rep := Report{Adopt: adopt}
|
||||
for _, c := range local {
|
||||
st := State{CameraID: c.ID, Connected: c.Connected}
|
||||
if c.Connected {
|
||||
// A snapshot failure never blocks the state report. Knowing a
|
||||
// camera is down matters far more than having a picture of it,
|
||||
// and the picture is the part most likely to fail.
|
||||
if jpeg, err := s.Engine.Snapshot(ctx, c.ID); err == nil && len(jpeg) > 0 {
|
||||
if key, err := s.Cloud.UploadSnapshot(ctx, jpeg); err == nil {
|
||||
st.SnapshotKey = key
|
||||
} else {
|
||||
s.logf("camera %s: snapshot upload failed: %v", c.ID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
rep.State = append(rep.State, st)
|
||||
}
|
||||
if len(rep.State) == 0 && len(rep.Adopt) == 0 {
|
||||
return
|
||||
}
|
||||
if err := s.Cloud.Report(ctx, rep); err != nil {
|
||||
s.logf("camera report: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func toLocal(d Desired) Local {
|
||||
return Local{
|
||||
ID: d.CameraID, Label: d.Label, Host: d.Host, Port: d.Port,
|
||||
Path: d.Path, Username: d.Username, Password: d.Password,
|
||||
MaxWidth: d.MaxWidth, Tuning: d.Tuning,
|
||||
}
|
||||
}
|
||||
|
||||
func orElse(s, fallback string) string {
|
||||
if s == "" {
|
||||
return fallback
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Syncer) logf(format string, args ...any) {
|
||||
if s.Log != nil {
|
||||
s.Log.Printf(format, args...)
|
||||
}
|
||||
}
|
||||
241
agent/pkg/cameras/cameras_test.go
Normal file
241
agent/pkg/cameras/cameras_test.go
Normal file
@@ -0,0 +1,241 @@
|
||||
package cameras
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type fakeEngine struct {
|
||||
cams map[string]Local
|
||||
added []string
|
||||
updated []string
|
||||
removed []string
|
||||
listErr error
|
||||
snapshot []byte
|
||||
}
|
||||
|
||||
func newEngine(cams ...Local) *fakeEngine {
|
||||
m := map[string]Local{}
|
||||
for _, c := range cams {
|
||||
m[c.ID] = c
|
||||
}
|
||||
return &fakeEngine{cams: m, snapshot: []byte("\xff\xd8jpeg")}
|
||||
}
|
||||
|
||||
func (f *fakeEngine) List(context.Context) ([]Local, error) {
|
||||
if f.listErr != nil {
|
||||
return nil, f.listErr
|
||||
}
|
||||
var out []Local
|
||||
for _, c := range f.cams {
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func (f *fakeEngine) Add(_ context.Context, c Local) error {
|
||||
f.cams[c.ID] = c
|
||||
f.added = append(f.added, c.ID)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeEngine) Update(_ context.Context, id string, c Local) error {
|
||||
c.ID = id
|
||||
f.cams[id] = c
|
||||
f.updated = append(f.updated, id)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeEngine) Remove(_ context.Context, id string) error {
|
||||
delete(f.cams, id)
|
||||
f.removed = append(f.removed, id)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeEngine) Snapshot(context.Context, string) ([]byte, error) {
|
||||
return f.snapshot, nil
|
||||
}
|
||||
|
||||
type fakeCloud struct {
|
||||
desired []Desired
|
||||
desiredErr error
|
||||
reports []Report
|
||||
uploads int
|
||||
uploadErr error
|
||||
}
|
||||
|
||||
func (f *fakeCloud) Desired(context.Context) ([]Desired, error) {
|
||||
return f.desired, f.desiredErr
|
||||
}
|
||||
func (f *fakeCloud) Report(_ context.Context, r Report) error {
|
||||
f.reports = append(f.reports, r)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeCloud) UploadSnapshot(context.Context, []byte) (string, error) {
|
||||
if f.uploadErr != nil {
|
||||
return "", f.uploadErr
|
||||
}
|
||||
f.uploads++
|
||||
return "snap/key.jpg", nil
|
||||
}
|
||||
|
||||
func syncer(e *fakeEngine, c *fakeCloud) *Syncer {
|
||||
return &Syncer{Engine: e, Cloud: c}
|
||||
}
|
||||
|
||||
func TestACameraAddedAtHeadOfficeAppearsOnTheShopPC(t *testing.T) {
|
||||
e, c := newEngine(), &fakeCloud{desired: []Desired{{
|
||||
CameraID: "entrance", Label: "Entrance", Host: "192.168.0.138",
|
||||
Port: 554, Path: "/ch0_0.264", Username: "admin", Password: "s3cret",
|
||||
MaxWidth: 1280, Enabled: true, Revision: 1,
|
||||
}}}
|
||||
syncer(e, c).Once(context.Background())
|
||||
|
||||
got, ok := e.cams["entrance"]
|
||||
if !ok {
|
||||
t.Fatal("the camera was never created on the PC")
|
||||
}
|
||||
if got.Host != "192.168.0.138" || got.Password != "s3cret" {
|
||||
t.Fatalf("connection details did not travel: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The whole reason adoption exists. Every existing site is already running
|
||||
// cameras configured locally - including the office camera this was tested with
|
||||
// - and a reconcile that only pushed downwards would delete all of them the
|
||||
// first time it ran.
|
||||
func TestACameraAlreadyRunningLocallyIsOfferedToHeadOfficeNotDeleted(t *testing.T) {
|
||||
e := newEngine(Local{ID: "office", Label: "Office", Host: "192.168.0.138",
|
||||
Port: 554, Username: "admin", Password: "s3cret", Connected: true})
|
||||
c := &fakeCloud{}
|
||||
syncer(e, c).Once(context.Background())
|
||||
|
||||
if _, ok := e.cams["office"]; !ok {
|
||||
t.Fatal("an existing camera was deleted by the first sync")
|
||||
}
|
||||
if len(c.reports) == 0 || len(c.reports[0].Adopt) != 1 {
|
||||
t.Fatalf("the camera was not offered for adoption: %+v", c.reports)
|
||||
}
|
||||
if got := c.reports[0].Adopt[0]; got.CameraID != "office" || got.Password != "s3cret" {
|
||||
t.Fatalf("adoption dropped details the camera needs: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A tombstone must win over adoption, or a deleted camera comes straight back
|
||||
// on the next sync and the operator cannot work out why.
|
||||
func TestADeletedCameraIsRemovedAndNotReadopted(t *testing.T) {
|
||||
e := newEngine(Local{ID: "entrance", Host: "10.0.0.5", Connected: true})
|
||||
c := &fakeCloud{desired: []Desired{
|
||||
{CameraID: "entrance", Enabled: true, Revision: 3, Deleted: true},
|
||||
}}
|
||||
s := syncer(e, c)
|
||||
s.Once(context.Background())
|
||||
|
||||
if _, ok := e.cams["entrance"]; ok {
|
||||
t.Fatal("a camera deleted at head office is still running")
|
||||
}
|
||||
for _, r := range c.reports {
|
||||
for _, a := range r.Adopt {
|
||||
if a.CameraID == "entrance" {
|
||||
t.Fatal("the deleted camera was offered back for adoption")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A PATCH restarts the camera connection, so re-applying unchanged config every
|
||||
// two minutes would make a healthy site drop its own video permanently.
|
||||
func TestUnchangedConfigurationTouchesTheEngineOnce(t *testing.T) {
|
||||
e := newEngine()
|
||||
c := &fakeCloud{desired: []Desired{{CameraID: "entrance", Host: "10.0.0.5",
|
||||
Enabled: true, Revision: 7}}}
|
||||
s := syncer(e, c)
|
||||
|
||||
s.Once(context.Background())
|
||||
s.Once(context.Background())
|
||||
s.Once(context.Background())
|
||||
|
||||
if len(e.added) != 1 {
|
||||
t.Fatalf("added %d times, want 1", len(e.added))
|
||||
}
|
||||
if len(e.updated) != 0 {
|
||||
t.Fatalf("updated %d times with no change - every one restarts the stream", len(e.updated))
|
||||
}
|
||||
}
|
||||
|
||||
func TestANewRevisionIsApplied(t *testing.T) {
|
||||
e := newEngine()
|
||||
c := &fakeCloud{desired: []Desired{{CameraID: "entrance", Host: "10.0.0.5",
|
||||
Enabled: true, Revision: 1}}}
|
||||
s := syncer(e, c)
|
||||
s.Once(context.Background())
|
||||
|
||||
c.desired[0].Host = "10.0.0.9"
|
||||
c.desired[0].Revision = 2
|
||||
s.Once(context.Background())
|
||||
|
||||
if len(e.updated) != 1 {
|
||||
t.Fatalf("updated %d times, want 1", len(e.updated))
|
||||
}
|
||||
if e.cams["entrance"].Host != "10.0.0.9" {
|
||||
t.Fatalf("the new address was not applied: %+v", e.cams["entrance"])
|
||||
}
|
||||
}
|
||||
|
||||
// An unclaimed PC, or one with no internet, must keep running the cameras it
|
||||
// already has. Wiping local config because head office is unreachable would
|
||||
// stop a shop recognising anybody for the duration of an outage.
|
||||
func TestAnUnreachableHeadOfficeChangesNothingLocally(t *testing.T) {
|
||||
e := newEngine(Local{ID: "entrance", Host: "10.0.0.5", Connected: true})
|
||||
c := &fakeCloud{desiredErr: errors.New("this PC is not claimed by a company yet")}
|
||||
syncer(e, c).Once(context.Background())
|
||||
|
||||
if _, ok := e.cams["entrance"]; !ok {
|
||||
t.Fatal("local cameras were removed because the cloud was unreachable")
|
||||
}
|
||||
if len(e.removed) != 0 {
|
||||
t.Fatalf("removed %v", e.removed)
|
||||
}
|
||||
}
|
||||
|
||||
// Knowing a camera is down matters far more than having a picture of it, and
|
||||
// the picture is the part most likely to fail.
|
||||
func TestAFailedSnapshotStillReportsWhetherTheCameraIsUp(t *testing.T) {
|
||||
e := newEngine(Local{ID: "entrance", Connected: true})
|
||||
c := &fakeCloud{uploadErr: errors.New("bucket unreachable")}
|
||||
syncer(e, c).Once(context.Background())
|
||||
|
||||
if len(c.reports) == 0 || len(c.reports[0].State) != 1 {
|
||||
t.Fatalf("no state was reported: %+v", c.reports)
|
||||
}
|
||||
st := c.reports[0].State[0]
|
||||
if !st.Connected {
|
||||
t.Error("connected state was lost with the snapshot")
|
||||
}
|
||||
if st.SnapshotKey != "" {
|
||||
t.Error("a failed upload reported a key anyway")
|
||||
}
|
||||
}
|
||||
|
||||
// No point photographing a camera that is not producing frames, and the attempt
|
||||
// costs a request per sync per dead camera.
|
||||
func TestADisconnectedCameraIsNotPhotographed(t *testing.T) {
|
||||
e := newEngine(Local{ID: "entrance", Connected: false})
|
||||
c := &fakeCloud{}
|
||||
syncer(e, c).Once(context.Background())
|
||||
|
||||
if c.uploads != 0 {
|
||||
t.Fatalf("uploaded %d snapshots of a disconnected camera", c.uploads)
|
||||
}
|
||||
if c.reports[0].State[0].Connected {
|
||||
t.Error("a disconnected camera was reported as up")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnEngineThatIsNotRunningIsNotAnError(t *testing.T) {
|
||||
e := newEngine()
|
||||
e.listErr = errors.New("connection refused")
|
||||
c := &fakeCloud{desired: []Desired{{CameraID: "entrance", Enabled: true, Revision: 1}}}
|
||||
syncer(e, c).Once(context.Background()) // must not panic
|
||||
|
||||
if len(c.reports) != 0 {
|
||||
t.Fatal("reported state it could not have observed")
|
||||
}
|
||||
}
|
||||
247
agent/pkg/cameras/checks.go
Normal file
247
agent/pkg/cameras/checks.go
Normal file
@@ -0,0 +1,247 @@
|
||||
package cameras
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Running the checks head office asks for.
|
||||
//
|
||||
// Both answers come from the engine, which already knows how to give them and
|
||||
// already phrases them for whoever is standing next to the camera. Nothing here
|
||||
// re-words a verdict; it carries one.
|
||||
|
||||
// Job is one check the shop PC has been asked to run.
|
||||
type Job struct {
|
||||
CameraID string `json:"camera_id"`
|
||||
Kind string `json:"kind"`
|
||||
Seconds int `json:"seconds"`
|
||||
}
|
||||
|
||||
// Result is what it found.
|
||||
type Result struct {
|
||||
CameraID string `json:"camera_id"`
|
||||
OK bool `json:"ok"`
|
||||
Verdict string `json:"verdict,omitempty"`
|
||||
Headline string `json:"headline,omitempty"`
|
||||
Advice []string `json:"advice,omitempty"`
|
||||
Detail map[string]any `json:"detail,omitempty"`
|
||||
ImageKey string `json:"image_key,omitempty"`
|
||||
}
|
||||
|
||||
// Prober is the half of the engine that answers "does this camera work".
|
||||
type Prober interface {
|
||||
// Test opens the stream once and lets go, returning a frame. The engine's
|
||||
// probe checks TCP reachability first, so a wrong address answers in
|
||||
// milliseconds instead of the ~75 s an FFmpeg connect would take.
|
||||
Test(ctx context.Context, cam Local) (TestResult, error)
|
||||
// Placement watches for `seconds` and judges whether a person walking past
|
||||
// produced a view worth enrolling.
|
||||
Placement(ctx context.Context, cameraID string, seconds int) (map[string]any, error)
|
||||
}
|
||||
|
||||
type TestResult struct {
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Width int `json:"width,omitempty"`
|
||||
Height int `json:"height,omitempty"`
|
||||
// Snapshot is base64 JPEG, the operator's proof that the camera is
|
||||
// pointing where they think it is.
|
||||
Snapshot string `json:"snapshot_jpeg_b64,omitempty"`
|
||||
}
|
||||
|
||||
// Checks is the client for the job queue.
|
||||
type Checks interface {
|
||||
Pending(ctx context.Context) ([]Job, error)
|
||||
Submit(ctx context.Context, res Result) error
|
||||
}
|
||||
|
||||
// runChecks picks up whatever head office has asked for and answers it.
|
||||
//
|
||||
// Called from the same sync loop as configuration, so a check requested at head
|
||||
// office is picked up on the next tick. Deliberately not its own faster poll: a
|
||||
// placement check needs a human to walk about anyway, so shaving a minute off
|
||||
// the request buys nothing an operator would notice.
|
||||
func (s *Syncer) runChecks(ctx context.Context, desired []Desired) {
|
||||
if s.Checks == nil || s.Prober == nil {
|
||||
return
|
||||
}
|
||||
jobs, err := s.Checks.Pending(ctx)
|
||||
if err != nil {
|
||||
s.logf("camera checks: %v", err)
|
||||
return
|
||||
}
|
||||
for _, job := range jobs {
|
||||
res := s.runOne(ctx, job, desired)
|
||||
if err := s.Checks.Submit(ctx, res); err != nil {
|
||||
// Nothing to retry against: the server released the claim on a
|
||||
// timeout, so the operator's next press starts a fresh one. Losing
|
||||
// a result is better than a queue of stale verdicts.
|
||||
s.logf("camera %s: could not report the check: %v", job.CameraID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Syncer) runOne(ctx context.Context, job Job, desired []Desired) Result {
|
||||
res := Result{CameraID: job.CameraID}
|
||||
|
||||
local, err := s.Engine.List(ctx)
|
||||
if err != nil {
|
||||
res.Headline = "the recognition software on this PC is not responding"
|
||||
res.Advice = []string{"Open Behavision on the shop's PC and make sure it is started."}
|
||||
return res
|
||||
}
|
||||
var cam Local
|
||||
var found bool
|
||||
for _, c := range local {
|
||||
if c.ID == job.CameraID {
|
||||
cam, found = c, true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
// The camera exists at head office but the PC has not applied it yet.
|
||||
// Honest, and it tells the operator to wait rather than to go and look
|
||||
// at the cabling.
|
||||
res.Headline = "this PC has not set up that camera yet"
|
||||
res.Advice = []string{"It is applied within a couple of minutes of being added. Try again shortly."}
|
||||
return res
|
||||
}
|
||||
|
||||
// The engine never returns a camera password - by design, it reports
|
||||
// `has_password` and nothing else - so probing with what it hands back
|
||||
// dials the camera with an empty credential. That failed, and reported
|
||||
// "could not open stream - check the host, port, path and credentials"
|
||||
// about a camera the same PC had been streaming for an hour, with advice
|
||||
// sending the installer to check the very credential that was never sent.
|
||||
//
|
||||
// Head office has the real one, and this sync already fetched it.
|
||||
if cam.Password == "" {
|
||||
for _, d := range desired {
|
||||
if d.CameraID == job.CameraID {
|
||||
cam.Password = d.Password
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
switch job.Kind {
|
||||
case "placement":
|
||||
return s.runPlacement(ctx, job, cam)
|
||||
default:
|
||||
return s.runConnection(ctx, job, cam)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Syncer) runConnection(ctx context.Context, job Job, cam Local) Result {
|
||||
res := Result{CameraID: job.CameraID}
|
||||
|
||||
out, err := s.Prober.Test(ctx, cam)
|
||||
if err != nil {
|
||||
res.Headline = "could not test the camera: " + err.Error()
|
||||
return res
|
||||
}
|
||||
if !out.OK {
|
||||
res.Verdict = "unreachable"
|
||||
// The engine's own sentence. It distinguishes a refused connection from
|
||||
// a wrong path from a stream that opens and never sends a frame, and
|
||||
// those need three different things done about them.
|
||||
res.Headline = out.Error
|
||||
res.Advice = adviceFor(out.Error)
|
||||
return res
|
||||
}
|
||||
|
||||
res.OK = true
|
||||
res.Verdict = "reachable"
|
||||
res.Headline = fmt.Sprintf("connected — %d×%d", out.Width, out.Height)
|
||||
res.Detail = map[string]any{"width": out.Width, "height": out.Height}
|
||||
res.Advice = []string{
|
||||
"Check the picture below is the view you expect.",
|
||||
"Then run a walk-past check to prove faces here can actually be recognised.",
|
||||
}
|
||||
if out.Snapshot != "" {
|
||||
if jpeg, err := base64.StdEncoding.DecodeString(out.Snapshot); err == nil {
|
||||
if key, err := s.Cloud.UploadSnapshot(ctx, jpeg); err == nil {
|
||||
res.ImageKey = key
|
||||
} else {
|
||||
// A missing picture does not invalidate the result: the camera
|
||||
// still connected, which is what was asked.
|
||||
s.logf("camera %s: check snapshot upload failed: %v", job.CameraID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func (s *Syncer) runPlacement(ctx context.Context, job Job, cam Local) Result {
|
||||
res := Result{CameraID: job.CameraID}
|
||||
|
||||
seconds := job.Seconds
|
||||
if seconds <= 0 {
|
||||
seconds = 25
|
||||
}
|
||||
// Room for the watch itself plus the engine's own overhead. Without the
|
||||
// margin the context dies at the exact moment the verdict is computed.
|
||||
ctx, cancel := context.WithTimeout(ctx, time.Duration(seconds+30)*time.Second)
|
||||
defer cancel()
|
||||
|
||||
report, err := s.Prober.Placement(ctx, job.CameraID, seconds)
|
||||
if err != nil {
|
||||
res.Headline = "the walk-past check could not be run: " + err.Error()
|
||||
return res
|
||||
}
|
||||
res.Detail = report
|
||||
res.Verdict, _ = report["verdict"].(string)
|
||||
res.Headline, _ = report["headline"].(string)
|
||||
if adv, ok := report["advice"].([]any); ok {
|
||||
for _, a := range adv {
|
||||
if str, ok := a.(string); ok {
|
||||
res.Advice = append(res.Advice, str)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Only `good` is a pass. `marginal` means half the visitors are silently
|
||||
// discarded, which is not a working camera - calling it one is how a site
|
||||
// gets signed off and discovered three weeks later from a footfall report
|
||||
// that was always zero.
|
||||
res.OK = res.Verdict == "good"
|
||||
return res
|
||||
}
|
||||
|
||||
// adviceFor turns the engine's diagnosis into the next thing to do.
|
||||
//
|
||||
// Matched on the engine's own wording rather than an error code, because the
|
||||
// engine returns prose - and prose that is already correct. This adds the
|
||||
// action, it does not restate the problem.
|
||||
func adviceFor(engineError string) []string {
|
||||
msg := strings.ToLower(engineError)
|
||||
switch {
|
||||
case strings.Contains(msg, "refused"):
|
||||
return []string{
|
||||
"Something answered at that address but refused the connection.",
|
||||
"The port is usually 554 for an RTSP camera. Check the port first.",
|
||||
}
|
||||
case strings.Contains(msg, "unreachable"), strings.Contains(msg, "timed out"),
|
||||
strings.Contains(msg, "no route"):
|
||||
return []string{
|
||||
"Nothing answered at that address from the shop's PC.",
|
||||
"Check the camera is powered on and plugged into the same network as the PC.",
|
||||
"Confirm the address in the camera's own app or on its label.",
|
||||
}
|
||||
case strings.Contains(msg, "could not open"):
|
||||
return []string{
|
||||
"The address is reachable but the stream would not open.",
|
||||
"This is usually the stream path or the camera's username and password.",
|
||||
"Pick your camera's make above to fill in the usual path for it.",
|
||||
}
|
||||
case strings.Contains(msg, "no frame"):
|
||||
return []string{
|
||||
"The camera accepted the connection but sent no picture.",
|
||||
"Some cameras only allow one viewer at a time — close any app watching it.",
|
||||
}
|
||||
}
|
||||
return []string{"Check the address, port, stream path, username and password."}
|
||||
}
|
||||
247
agent/pkg/cameras/checks_test.go
Normal file
247
agent/pkg/cameras/checks_test.go
Normal file
@@ -0,0 +1,247 @@
|
||||
package cameras
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type fakeProber struct {
|
||||
test TestResult
|
||||
testErr error
|
||||
placement map[string]any
|
||||
placeErr error
|
||||
placedFor int
|
||||
// testedWith records the camera the probe was actually handed, which is
|
||||
// where the credential either arrives or does not.
|
||||
testedWith Local
|
||||
}
|
||||
|
||||
func (f *fakeProber) Test(_ context.Context, cam Local) (TestResult, error) {
|
||||
f.testedWith = cam
|
||||
return f.test, f.testErr
|
||||
}
|
||||
func (f *fakeProber) Placement(_ context.Context, _ string, seconds int) (map[string]any, error) {
|
||||
f.placedFor = seconds
|
||||
return f.placement, f.placeErr
|
||||
}
|
||||
|
||||
type fakeChecks struct {
|
||||
jobs []Job
|
||||
submitted []Result
|
||||
pendErr error
|
||||
}
|
||||
|
||||
func (f *fakeChecks) Pending(context.Context) ([]Job, error) { return f.jobs, f.pendErr }
|
||||
func (f *fakeChecks) Submit(_ context.Context, r Result) error {
|
||||
f.submitted = append(f.submitted, r)
|
||||
return nil
|
||||
}
|
||||
|
||||
func checker(e *fakeEngine, p *fakeProber, c *fakeChecks) *Syncer {
|
||||
return &Syncer{Engine: e, Cloud: &fakeCloud{}, Prober: p, Checks: c}
|
||||
}
|
||||
|
||||
func TestAReachableCameraReportsItsResolutionAndAPicture(t *testing.T) {
|
||||
e := newEngine(Local{ID: "entrance", Host: "192.168.0.138", Connected: true})
|
||||
p := &fakeProber{test: TestResult{OK: true, Width: 1920, Height: 1080,
|
||||
Snapshot: "/9j/4AAQSkZJRg=="}}
|
||||
c := &fakeChecks{jobs: []Job{{CameraID: "entrance", Kind: "connection"}}}
|
||||
|
||||
checker(e, p, c).runChecks(context.Background(), nil)
|
||||
|
||||
if len(c.submitted) != 1 {
|
||||
t.Fatalf("submitted %d results", len(c.submitted))
|
||||
}
|
||||
got := c.submitted[0]
|
||||
if !got.OK {
|
||||
t.Fatalf("a working camera reported as failing: %+v", got)
|
||||
}
|
||||
if !strings.Contains(got.Headline, "1920") {
|
||||
t.Errorf("headline does not say what was found: %q", got.Headline)
|
||||
}
|
||||
if got.ImageKey == "" {
|
||||
t.Error("no picture uploaded, so the operator cannot see what it is pointing at")
|
||||
}
|
||||
}
|
||||
|
||||
// The engine already tells three different failures apart, and each needs a
|
||||
// different thing done about it. Carrying its sentence and adding the action is
|
||||
// the whole design; re-wording it here would be a fourth description of the
|
||||
// same fault.
|
||||
func TestEachConnectionFailureGetsItsOwnAdvice(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"connection refused": "port",
|
||||
"host unreachable": "powered on",
|
||||
"could not open stream - check the host, port, path and credentials": "stream path",
|
||||
"connected but no frame arrived within 12s": "one viewer at a time",
|
||||
}
|
||||
for engineErr, want := range cases {
|
||||
e := newEngine(Local{ID: "entrance", Connected: true})
|
||||
p := &fakeProber{test: TestResult{OK: false, Error: engineErr}}
|
||||
c := &fakeChecks{jobs: []Job{{CameraID: "entrance", Kind: "connection"}}}
|
||||
|
||||
checker(e, p, c).runChecks(context.Background(), nil)
|
||||
|
||||
got := c.submitted[0]
|
||||
if got.OK {
|
||||
t.Errorf("%q reported as a pass", engineErr)
|
||||
}
|
||||
// The engine's own sentence must survive intact.
|
||||
if got.Headline != engineErr {
|
||||
t.Errorf("headline %q, want the engine's own words %q", got.Headline, engineErr)
|
||||
}
|
||||
joined := strings.ToLower(strings.Join(got.Advice, " "))
|
||||
if !strings.Contains(joined, want) {
|
||||
t.Errorf("%q -> advice %q, expected it to mention %q", engineErr, joined, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only `good` is a pass. `marginal` means half the visitors are silently
|
||||
// discarded, and signing that off as working is exactly how a site runs for
|
||||
// weeks recognising almost nobody.
|
||||
func TestOnlyAGoodPlacementCounts(t *testing.T) {
|
||||
for verdict, wantOK := range map[string]bool{
|
||||
"good": true, "marginal": false, "poor": false,
|
||||
"no_faces": false, "artifact": false, "inconclusive": false,
|
||||
"no_completed_passes": false,
|
||||
} {
|
||||
e := newEngine(Local{ID: "entrance", Connected: true})
|
||||
p := &fakeProber{placement: map[string]any{
|
||||
"verdict": verdict, "headline": "h",
|
||||
"advice": []any{"do the thing"},
|
||||
}}
|
||||
c := &fakeChecks{jobs: []Job{{CameraID: "entrance", Kind: "placement", Seconds: 25}}}
|
||||
|
||||
checker(e, p, c).runChecks(context.Background(), nil)
|
||||
|
||||
if got := c.submitted[0].OK; got != wantOK {
|
||||
t.Errorf("verdict %q -> ok=%v, want %v", verdict, got, wantOK)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The engine's advice is written for the person standing next to the camera.
|
||||
// It must reach them.
|
||||
func TestThePlacementAdviceIsCarriedThroughVerbatim(t *testing.T) {
|
||||
e := newEngine(Local{ID: "entrance", Connected: true})
|
||||
p := &fakeProber{placement: map[string]any{
|
||||
"verdict": "poor",
|
||||
"headline": "most visitors here cannot be recognised",
|
||||
"advice": []any{
|
||||
"Face the camera the way people walk in, at about head height.",
|
||||
"Re-run this check after moving it.",
|
||||
},
|
||||
"faces": float64(11),
|
||||
}}
|
||||
c := &fakeChecks{jobs: []Job{{CameraID: "entrance", Kind: "placement", Seconds: 25}}}
|
||||
|
||||
checker(e, p, c).runChecks(context.Background(), nil)
|
||||
|
||||
got := c.submitted[0]
|
||||
if got.Headline != "most visitors here cannot be recognised" {
|
||||
t.Errorf("headline changed: %q", got.Headline)
|
||||
}
|
||||
if len(got.Advice) != 2 || !strings.Contains(got.Advice[0], "head height") {
|
||||
t.Errorf("advice did not survive: %+v", got.Advice)
|
||||
}
|
||||
// Everything else the engine said travels too, so a new field reaches the
|
||||
// UI without a schema change on the way.
|
||||
if got.Detail["faces"] != float64(11) {
|
||||
t.Errorf("detail was dropped: %+v", got.Detail)
|
||||
}
|
||||
if p.placedFor != 25 {
|
||||
t.Errorf("watched for %ds, want 25", p.placedFor)
|
||||
}
|
||||
}
|
||||
|
||||
// A camera added at head office 30 seconds ago has not reached the PC yet.
|
||||
// Telling the operator to check the cabling would send them to the wrong place.
|
||||
func TestACameraTheShopPCHasNotAppliedYetSaysSo(t *testing.T) {
|
||||
e := newEngine() // engine knows nothing about it
|
||||
p := &fakeProber{}
|
||||
c := &fakeChecks{jobs: []Job{{CameraID: "entrance", Kind: "connection"}}}
|
||||
|
||||
checker(e, p, c).runChecks(context.Background(), nil)
|
||||
|
||||
got := c.submitted[0]
|
||||
if got.OK {
|
||||
t.Fatal("reported a pass for a camera that does not exist here")
|
||||
}
|
||||
if !strings.Contains(got.Headline, "not set up that camera yet") {
|
||||
t.Errorf("headline blames the wrong thing: %q", got.Headline)
|
||||
}
|
||||
if !strings.Contains(strings.Join(got.Advice, " "), "Try again shortly") {
|
||||
t.Errorf("advice does not tell them to wait: %+v", got.Advice)
|
||||
}
|
||||
}
|
||||
|
||||
// "The engine is not running" and "the camera is broken" need opposite actions.
|
||||
func TestAStoppedEngineIsNotReportedAsABrokenCamera(t *testing.T) {
|
||||
e := newEngine()
|
||||
e.listErr = errors.New("connection refused")
|
||||
p := &fakeProber{}
|
||||
c := &fakeChecks{jobs: []Job{{CameraID: "entrance", Kind: "connection"}}}
|
||||
|
||||
checker(e, p, c).runChecks(context.Background(), nil)
|
||||
|
||||
got := c.submitted[0]
|
||||
if !strings.Contains(got.Headline, "not responding") {
|
||||
t.Fatalf("headline blames the camera: %q", got.Headline)
|
||||
}
|
||||
if !strings.Contains(strings.Join(got.Advice, " "), "make sure it is started") {
|
||||
t.Errorf("advice: %+v", got.Advice)
|
||||
}
|
||||
}
|
||||
|
||||
// An unclaimed PC has neither, and must not treat that as a fault.
|
||||
func TestASyncerWithNoCheckSupportSkipsQuietly(t *testing.T) {
|
||||
s := &Syncer{Engine: newEngine(), Cloud: &fakeCloud{}}
|
||||
s.runChecks(context.Background(), nil) // must not panic
|
||||
}
|
||||
|
||||
func TestNoPendingChecksSubmitsNothing(t *testing.T) {
|
||||
c := &fakeChecks{}
|
||||
checker(newEngine(), &fakeProber{}, c).runChecks(context.Background(), nil)
|
||||
if len(c.submitted) != 0 {
|
||||
t.Fatalf("submitted %d results with no jobs", len(c.submitted))
|
||||
}
|
||||
}
|
||||
|
||||
// The engine deliberately never returns a camera password - it reports
|
||||
// has_password and nothing else - so probing with what the engine hands back
|
||||
// dials the camera with an empty credential. That reported "could not open
|
||||
// stream - check the host, port, path and credentials" about a camera the very
|
||||
// same PC had been streaming for an hour, and sent the installer to check the
|
||||
// one thing that had never been sent.
|
||||
func TestTheProbeIsGivenThePasswordHeadOfficeHolds(t *testing.T) {
|
||||
e := newEngine(Local{ID: "entrance", Host: "192.168.0.138", Username: "admin",
|
||||
Connected: true}) // no Password: the engine does not return one
|
||||
p := &fakeProber{test: TestResult{OK: true, Width: 1920, Height: 1080}}
|
||||
c := &fakeChecks{jobs: []Job{{CameraID: "entrance", Kind: "connection"}}}
|
||||
desired := []Desired{{CameraID: "entrance", Username: "admin", Password: "hunter2"}}
|
||||
|
||||
checker(e, p, c).runChecks(context.Background(), desired)
|
||||
|
||||
if p.testedWith.Password != "hunter2" {
|
||||
t.Fatalf("the probe was handed password %q - a working camera would be "+
|
||||
"reported unreachable", p.testedWith.Password)
|
||||
}
|
||||
}
|
||||
|
||||
// A password the engine DOES have is not overwritten by head office's copy:
|
||||
// the local one is what the camera is actually being streamed with.
|
||||
func TestALocalPasswordWins(t *testing.T) {
|
||||
e := newEngine(Local{ID: "entrance", Password: "local", Connected: true})
|
||||
p := &fakeProber{test: TestResult{OK: true}}
|
||||
c := &fakeChecks{jobs: []Job{{CameraID: "entrance", Kind: "connection"}}}
|
||||
|
||||
checker(e, p, c).runChecks(context.Background(),
|
||||
[]Desired{{CameraID: "entrance", Password: "remote"}})
|
||||
|
||||
if p.testedWith.Password != "local" {
|
||||
t.Fatalf("probe used %q, want the engine's own", p.testedWith.Password)
|
||||
}
|
||||
}
|
||||
263
agent/pkg/cameras/clients.go
Normal file
263
agent/pkg/cameras/clients.go
Normal file
@@ -0,0 +1,263 @@
|
||||
package cameras
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// EngineClient talks to the recognition engine on this PC's loopback.
|
||||
type EngineClient struct {
|
||||
Base string
|
||||
User string
|
||||
Password string
|
||||
Client *http.Client
|
||||
}
|
||||
|
||||
func NewEngineClient(base, user, password string) *EngineClient {
|
||||
return &EngineClient{
|
||||
Base: strings.TrimRight(base, "/"), User: user, Password: password,
|
||||
// Generous, because adding a camera makes the engine dial it, and a
|
||||
// wrong address takes the full RTSP timeout to fail. Shorter than that
|
||||
// and every genuinely-bad camera looks like an engine fault instead.
|
||||
Client: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func (e *EngineClient) do(ctx context.Context, method, path string, body, out any) error {
|
||||
var rdr io.Reader
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rdr = bytes.NewReader(b)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, e.Base+path, rdr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
if e.User != "" {
|
||||
req.SetBasicAuth(e.User, e.Password)
|
||||
}
|
||||
resp, err := e.Client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
// The engine's message, not just a status. "camera stored but failed to
|
||||
// start: connection refused" is something an operator can act on;
|
||||
// "500" is not, and this string ends up in the agent log a support
|
||||
// engineer reads.
|
||||
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||
return fmt.Errorf("engine %s: %s", resp.Status, strings.TrimSpace(string(msg)))
|
||||
}
|
||||
if out == nil {
|
||||
return nil
|
||||
}
|
||||
return json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(out)
|
||||
}
|
||||
|
||||
func (e *EngineClient) List(ctx context.Context) ([]Local, error) {
|
||||
var out []Local
|
||||
return out, e.do(ctx, http.MethodGet, "/api/cameras", nil, &out)
|
||||
}
|
||||
|
||||
func (e *EngineClient) Add(ctx context.Context, cam Local) error {
|
||||
return e.do(ctx, http.MethodPost, "/api/cameras", cam, nil)
|
||||
}
|
||||
|
||||
func (e *EngineClient) Update(ctx context.Context, id string, cam Local) error {
|
||||
// The engine takes the id from the path on PATCH and refuses it in the
|
||||
// body, so it is cleared here rather than at the call site.
|
||||
cam.ID = ""
|
||||
return e.do(ctx, http.MethodPatch, "/api/cameras/"+url.PathEscape(id), cam, nil)
|
||||
}
|
||||
|
||||
func (e *EngineClient) Remove(ctx context.Context, id string) error {
|
||||
return e.do(ctx, http.MethodDelete, "/api/cameras/"+url.PathEscape(id), nil, nil)
|
||||
}
|
||||
|
||||
// Snapshot fetches the most recent frame the engine holds.
|
||||
//
|
||||
// Not a fresh capture: the engine already keeps the latest frame in memory for
|
||||
// its own MJPEG stream, so this costs a memory copy rather than a camera round
|
||||
// trip. A camera that has not produced a frame yet answers 503, which is a
|
||||
// normal state on a just-added camera and not an error worth logging loudly.
|
||||
func (e *EngineClient) Snapshot(ctx context.Context, id string) ([]byte, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
|
||||
e.Base+"/api/cameras/"+url.PathEscape(id)+"/frame.jpg", nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if e.User != "" {
|
||||
req.SetBasicAuth(e.User, e.Password)
|
||||
}
|
||||
resp, err := e.Client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("engine %s", resp.Status)
|
||||
}
|
||||
// Bounded. A frame is tens of kilobytes; anything near this cap means
|
||||
// something other than a JPEG is on the other end.
|
||||
return io.ReadAll(io.LimitReader(resp.Body, 8<<20))
|
||||
}
|
||||
|
||||
// CloudClient talks to head office with this agent's own token.
|
||||
type CloudClient struct {
|
||||
Base string
|
||||
Token string
|
||||
Client *http.Client
|
||||
// Upload is the existing image path: the server mints a presigned URL and
|
||||
// the agent PUTs to it. Reused rather than reimplemented, so a shop PC
|
||||
// still never holds bucket credentials — the reason that path exists.
|
||||
Upload func(ctx context.Context, jpeg []byte) (string, error)
|
||||
}
|
||||
|
||||
func NewCloudClient(base, token string) *CloudClient {
|
||||
return &CloudClient{
|
||||
Base: strings.TrimRight(base, "/"), Token: token,
|
||||
Client: &http.Client{Timeout: 20 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
func (c *CloudClient) do(ctx context.Context, method, path string, body, out any) error {
|
||||
if c.Token == "" {
|
||||
// An unclaimed PC. Said plainly, because this is the normal state
|
||||
// between installing the software and typing an enrolment code, and it
|
||||
// must not read as a fault in the log.
|
||||
return fmt.Errorf("this PC is not claimed by a company yet")
|
||||
}
|
||||
var rdr io.Reader
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rdr = bytes.NewReader(b)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, c.Base+path, rdr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.Token)
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := c.Client.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("head office: %s", resp.Status)
|
||||
}
|
||||
if out == nil || resp.StatusCode == http.StatusNoContent {
|
||||
return nil
|
||||
}
|
||||
return json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(out)
|
||||
}
|
||||
|
||||
func (c *CloudClient) Desired(ctx context.Context) ([]Desired, error) {
|
||||
var body struct {
|
||||
Cameras []Desired `json:"cameras"`
|
||||
}
|
||||
if err := c.do(ctx, http.MethodGet, "/api/agent/cameras", nil, &body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return body.Cameras, nil
|
||||
}
|
||||
|
||||
func (c *CloudClient) Report(ctx context.Context, rep Report) error {
|
||||
return c.do(ctx, http.MethodPost, "/api/agent/cameras", rep, nil)
|
||||
}
|
||||
|
||||
func (c *CloudClient) UploadSnapshot(ctx context.Context, jpeg []byte) (string, error) {
|
||||
if c.Upload == nil {
|
||||
return "", fmt.Errorf("images are not enabled for this deployment")
|
||||
}
|
||||
return c.Upload(ctx, jpeg)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- probing
|
||||
|
||||
// Test opens the candidate stream once, without saving it.
|
||||
//
|
||||
// The engine does this as a sync handler in its threadpool because
|
||||
// cv2.VideoCapture blocks hard, and it checks TCP reachability first - so a
|
||||
// wrong address, which is the single most likely thing anybody types, answers
|
||||
// in milliseconds rather than the ~75 s an FFmpeg connect takes to give up.
|
||||
func (e *EngineClient) Test(ctx context.Context, cam Local) (TestResult, error) {
|
||||
var out TestResult
|
||||
// A generous ceiling: the engine's own deadline is 12 s for the frame plus
|
||||
// 3 s to connect, and cutting it off earlier would report a timeout of our
|
||||
// own making as if it were the camera's.
|
||||
ctx, cancel := context.WithTimeout(ctx, 45*time.Second)
|
||||
defer cancel()
|
||||
return out, e.do(ctx, http.MethodPost, "/api/cameras/test", cam, &out)
|
||||
}
|
||||
|
||||
// Placement runs the engine's commissioning watch and returns its report whole.
|
||||
//
|
||||
// Polled rather than awaited: the engine starts the watch and answers
|
||||
// immediately, so the run survives this request being retried, and the report
|
||||
// arrives with `running: true` until it does not.
|
||||
func (e *EngineClient) Placement(ctx context.Context, cameraID string, seconds int) (
|
||||
map[string]any, error) {
|
||||
|
||||
path := "/api/cameras/" + url.PathEscape(cameraID) + "/commission"
|
||||
var report map[string]any
|
||||
if err := e.do(ctx, http.MethodPost, path,
|
||||
map[string]any{"seconds": seconds}, &report); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(time.Duration(seconds+20) * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return report, ctx.Err()
|
||||
case <-time.After(2 * time.Second):
|
||||
}
|
||||
var latest map[string]any
|
||||
if err := e.do(ctx, http.MethodGet, path, nil, &latest); err != nil {
|
||||
// Keep the last good report rather than losing the whole run to
|
||||
// one failed poll - the engine may simply have been busy.
|
||||
continue
|
||||
}
|
||||
report = latest
|
||||
if running, _ := latest["running"].(bool); !running {
|
||||
return report, nil
|
||||
}
|
||||
}
|
||||
return report, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- check jobs
|
||||
|
||||
func (c *CloudClient) Pending(ctx context.Context) ([]Job, error) {
|
||||
var body struct {
|
||||
Checks []Job `json:"checks"`
|
||||
}
|
||||
if err := c.do(ctx, http.MethodGet, "/api/agent/checks", nil, &body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return body.Checks, nil
|
||||
}
|
||||
|
||||
func (c *CloudClient) Submit(ctx context.Context, res Result) error {
|
||||
return c.do(ctx, http.MethodPost, "/api/agent/checks", res, nil)
|
||||
}
|
||||
29
agent/pkg/cameras/wiring_test.go
Normal file
29
agent/pkg/cameras/wiring_test.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package cameras
|
||||
|
||||
import (
|
||||
"log"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The bug this guards: both callers built the Syncer as a struct literal, set
|
||||
// Engine and Cloud, and left Checks and Prober nil. runChecks returns silently
|
||||
// when either is nil - correct for an unclaimed PC - so a camera check
|
||||
// requested at head office was claimed by nobody and sat at "checking..." until
|
||||
// the server released it five minutes later. Nothing logged, on either side.
|
||||
func TestNewWiresEveryHalfOfTheSyncer(t *testing.T) {
|
||||
eng := NewEngineClient("http://127.0.0.1:8010", "u", "p")
|
||||
cloud := NewCloudClient("https://example.invalid", "token")
|
||||
s := New(eng, cloud, log.Default())
|
||||
|
||||
if s.Engine == nil || s.Cloud == nil {
|
||||
t.Fatal("configuration half not wired")
|
||||
}
|
||||
// The half that proves a camera works. A Syncer without these is not a
|
||||
// broken Syncer, which is exactly why the omission was invisible.
|
||||
if s.Checks == nil {
|
||||
t.Error("Checks is nil: head office's camera checks would never be claimed")
|
||||
}
|
||||
if s.Prober == nil {
|
||||
t.Error("Prober is nil: a claimed check could never be answered")
|
||||
}
|
||||
}
|
||||
201
agent/pkg/config/config.go
Normal file
201
agent/pkg/config/config.go
Normal file
@@ -0,0 +1,201 @@
|
||||
// Package config holds the agent's own settings: which tenant and site this
|
||||
// install belongs to, how to reach the broker, and how to launch the engine.
|
||||
//
|
||||
// Kept separate from the engine's YAML on purpose. That file describes
|
||||
// recognition — thresholds, cameras, gates — and is edited by whoever tunes a
|
||||
// site. This one describes identity and connectivity, is written by the
|
||||
// installer and the login flow, and holds a secret.
|
||||
package config
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// protectedPrefix marks a value that went through DPAPI, so a config written
|
||||
// on Windows is never mistaken for a plaintext dev one and vice versa.
|
||||
const protectedPrefix = "dpapi:"
|
||||
|
||||
// Config is the agent's on-disk settings.
|
||||
type Config struct {
|
||||
// Tenant identity. The server keys everything on these.
|
||||
ClientID string `json:"client_id"`
|
||||
SiteID string `json:"site_id"`
|
||||
SiteName string `json:"site_name"`
|
||||
|
||||
// Broker.
|
||||
BrokerURL string `json:"broker_url"`
|
||||
BrokerUsername string `json:"broker_username"`
|
||||
BrokerPassword string `json:"broker_password"` // protected at rest
|
||||
// Pins the broker's issuer. Empty uses the system roots, which is what a
|
||||
// Let's Encrypt certificate needs; a private CA is pinned by path.
|
||||
BrokerCAFile string `json:"broker_ca_file"`
|
||||
|
||||
// Engine process.
|
||||
EngineExe string `json:"engine_exe"`
|
||||
EngineArgs []string `json:"engine_args"`
|
||||
APIBase string `json:"api_base"`
|
||||
APIUser string `json:"api_user"`
|
||||
APIPassword string `json:"api_password"` // protected at rest
|
||||
|
||||
// Session, so a shop PC that reboots overnight is not a login every
|
||||
// morning. Protected at rest like every other secret here.
|
||||
SessionToken string `json:"session_token"`
|
||||
SessionRefresh string `json:"session_refresh"`
|
||||
SessionEmail string `json:"session_email"`
|
||||
|
||||
// CloudBase is the server this site reports to; AgentToken is this PC's
|
||||
// own credential there, issued once at enrolment.
|
||||
//
|
||||
// Deliberately not the same secret as BrokerPassword: they authenticate
|
||||
// different things - one says this site may publish events, the other that
|
||||
// it may ask the API for something - so rotating either must not break the
|
||||
// other. Protected at rest like every other secret here.
|
||||
CloudBase string `json:"cloud_base"`
|
||||
AgentToken string `json:"agent_token"`
|
||||
|
||||
// Standalone marks a PC deliberately run on its own: cameras, recognition
|
||||
// and the local gallery, with nothing reported to head office.
|
||||
//
|
||||
// It exists so that "not linked yet" and "not going to be linked" are
|
||||
// different states. Without it every install was blocked on an enrolment
|
||||
// code, so a shop with one PC and no head office could not add a camera at
|
||||
// all - the software refused to do the thing it is for until a server it
|
||||
// does not need had issued it a credential.
|
||||
Standalone bool `json:"standalone"`
|
||||
|
||||
// Queue.
|
||||
SpoolMax int `json:"spool_max"`
|
||||
|
||||
path string
|
||||
}
|
||||
|
||||
// Defaults returns a config that runs a locally installed engine.
|
||||
//
|
||||
// EngineExe is relative to the install root - the directory holding this
|
||||
// executable - and names the installed layout: the engine is a PyInstaller
|
||||
// one-FOLDER build, so it brings its own DLLs and cannot simply sit beside the
|
||||
// app. Windows filenames are case-insensitive too, so `Behavision.exe` (the
|
||||
// app) and `behavision.exe` (the engine) could not share a directory even if
|
||||
// it were tidy to.
|
||||
func Defaults() Config {
|
||||
exe := filepath.Join("engine", "behavision")
|
||||
if runtime.GOOS == "windows" {
|
||||
exe += ".exe"
|
||||
}
|
||||
return Config{
|
||||
EngineExe: exe,
|
||||
EngineArgs: []string{"run"},
|
||||
APIBase: "http://127.0.0.1:8010",
|
||||
SpoolMax: 50000,
|
||||
}
|
||||
}
|
||||
|
||||
// Load reads the config, decrypting secrets. A missing file is not an error:
|
||||
// a fresh install has none until the operator logs in, and failing to start
|
||||
// because of that would leave them with no UI to log in from.
|
||||
func Load(path string) (Config, error) {
|
||||
cfg := Defaults()
|
||||
cfg.path = path
|
||||
blob, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return cfg, nil
|
||||
}
|
||||
if err != nil {
|
||||
return cfg, err
|
||||
}
|
||||
if err := json.Unmarshal(blob, &cfg); err != nil {
|
||||
return cfg, fmt.Errorf("config %s: %w", path, err)
|
||||
}
|
||||
cfg.path = path
|
||||
for _, field := range []*string{&cfg.BrokerPassword, &cfg.APIPassword,
|
||||
&cfg.SessionToken, &cfg.SessionRefresh, &cfg.AgentToken} {
|
||||
plain, err := reveal(*field)
|
||||
if err != nil {
|
||||
// A secret that cannot be decrypted usually means the config was
|
||||
// copied from another machine - DPAPI is machine-scoped. Blank it
|
||||
// rather than failing: the operator can log in again, but they
|
||||
// cannot fix a process that will not start.
|
||||
*field = ""
|
||||
continue
|
||||
}
|
||||
*field = plain
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// Save writes the config atomically, protecting secrets on the way out.
|
||||
func (c Config) Save(path string) error {
|
||||
if path == "" {
|
||||
path = c.path
|
||||
}
|
||||
if path == "" {
|
||||
return fmt.Errorf("config: no path to save to")
|
||||
}
|
||||
out := c
|
||||
out.path = ""
|
||||
for _, field := range []*string{&out.BrokerPassword, &out.APIPassword,
|
||||
&out.SessionToken, &out.SessionRefresh, &out.AgentToken} {
|
||||
hidden, err := conceal(*field)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*field = hidden
|
||||
}
|
||||
blob, err := json.MarshalIndent(out, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
// Temp-then-rename: a crash mid-write must not leave a config that parses
|
||||
// as valid but is half old and half new.
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, blob, 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, path)
|
||||
}
|
||||
|
||||
// Configured reports whether this install has been claimed by a tenant yet.
|
||||
// The UI shows a login screen until it has.
|
||||
func (c Config) Configured() bool {
|
||||
return c.ClientID != "" && c.SiteID != "" && c.BrokerURL != ""
|
||||
}
|
||||
|
||||
// SecretsProtected is false on a dev machine, where secrets are stored as-is.
|
||||
// Surfaced rather than hidden so nobody ships a build believing otherwise.
|
||||
func SecretsProtected() bool { return protectionAvailable() }
|
||||
|
||||
func conceal(plain string) (string, error) {
|
||||
if plain == "" || !protectionAvailable() {
|
||||
return plain, nil
|
||||
}
|
||||
blob, err := protect([]byte(plain))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return protectedPrefix + base64.StdEncoding.EncodeToString(blob), nil
|
||||
}
|
||||
|
||||
func reveal(stored string) (string, error) {
|
||||
if !strings.HasPrefix(stored, protectedPrefix) {
|
||||
return stored, nil
|
||||
}
|
||||
blob, err := base64.StdEncoding.DecodeString(
|
||||
strings.TrimPrefix(stored, protectedPrefix))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
plain, err := unprotect(blob)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(plain), nil
|
||||
}
|
||||
179
agent/pkg/config/config_test.go
Normal file
179
agent/pkg/config/config_test.go
Normal file
@@ -0,0 +1,179 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAFreshInstallLoadsDefaultsInsteadOfFailing(t *testing.T) {
|
||||
// There is no config until the operator logs in, and refusing to start
|
||||
// would leave them with no UI to log in from.
|
||||
cfg, err := Load(filepath.Join(t.TempDir(), "nope.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("missing config treated as an error: %v", err)
|
||||
}
|
||||
if cfg.Configured() {
|
||||
t.Fatal("a blank install reported itself as configured")
|
||||
}
|
||||
if cfg.APIBase == "" || cfg.EngineExe == "" {
|
||||
t.Fatal("defaults were not applied")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoundTrip(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "agent.json")
|
||||
cfg := Defaults()
|
||||
cfg.ClientID, cfg.SiteID, cfg.BrokerURL = "acme", "store-1", "tls://b:8883"
|
||||
cfg.BrokerPassword, cfg.APIPassword = "broker-secret", "api-secret"
|
||||
if err := cfg.Save(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
back, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if back.BrokerPassword != "broker-secret" || back.APIPassword != "api-secret" {
|
||||
t.Fatalf("secrets did not survive the round trip: %+v", back)
|
||||
}
|
||||
if !back.Configured() {
|
||||
t.Fatal("a claimed install reported itself unconfigured")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveIsAtomic(t *testing.T) {
|
||||
// A crash mid-write must not leave a config that parses but is half old
|
||||
// and half new.
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "agent.json")
|
||||
cfg := Defaults()
|
||||
cfg.ClientID = "acme"
|
||||
if err := cfg.Save(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
entries, _ := os.ReadDir(dir)
|
||||
for _, e := range entries {
|
||||
if strings.HasSuffix(e.Name(), ".tmp") {
|
||||
t.Fatalf("temp file left behind: %s", e.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUndecryptableSecretBlanksRatherThanBlocksStartup(t *testing.T) {
|
||||
// DPAPI is machine-scoped, so a config copied between PCs cannot be read.
|
||||
// Refusing to start would be unrecoverable without a UI; blanking it means
|
||||
// the operator just logs in again.
|
||||
path := filepath.Join(t.TempDir(), "agent.json")
|
||||
os.WriteFile(path, []byte(`{"client_id":"acme","site_id":"s1",
|
||||
"broker_url":"tls://b","broker_password":"dpapi:!!!not-base64!!!"}`), 0o600)
|
||||
|
||||
cfg, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("unreadable secret blocked startup: %v", err)
|
||||
}
|
||||
if cfg.BrokerPassword != "" {
|
||||
t.Fatal("a secret that could not be decrypted was kept")
|
||||
}
|
||||
if cfg.ClientID != "acme" {
|
||||
t.Fatal("the rest of the config was discarded too")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaintextSecretsAreMarkedDifferentlyFromProtectedOnes(t *testing.T) {
|
||||
// So a dev config is never mistaken for a protected one on inspection.
|
||||
stored, err := conceal("secret")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if SecretsProtected() && !strings.HasPrefix(stored, protectedPrefix) {
|
||||
t.Fatal("protected value is not marked")
|
||||
}
|
||||
if !SecretsProtected() && strings.HasPrefix(stored, protectedPrefix) {
|
||||
t.Fatal("plaintext value claims to be protected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveDoesNotLeakThePathFieldIntoJSON(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "agent.json")
|
||||
Defaults().Save(path)
|
||||
blob, _ := os.ReadFile(path)
|
||||
if strings.Contains(string(blob), t.TempDir()) {
|
||||
t.Fatal("internal path field was serialised")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheSessionSurvivesARestart(t *testing.T) {
|
||||
// A shop PC reboots overnight. Without this someone logs in every morning
|
||||
// before the store can record anything.
|
||||
path := filepath.Join(t.TempDir(), "agent.json")
|
||||
cfg := Defaults()
|
||||
cfg.SessionToken, cfg.SessionRefresh = "access-tok", "refresh-tok"
|
||||
cfg.SessionEmail = "manager@acme.test"
|
||||
if err := cfg.Save(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
back, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if back.SessionToken != "access-tok" || back.SessionRefresh != "refresh-tok" {
|
||||
t.Fatalf("session lost: %+v", back)
|
||||
}
|
||||
if back.SessionEmail != "manager@acme.test" {
|
||||
t.Fatal("email not kept")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionTokensAreProtectedLikeOtherSecrets(t *testing.T) {
|
||||
// A bearer token in plaintext on disk is a credential anyone with the file
|
||||
// can replay.
|
||||
path := filepath.Join(t.TempDir(), "agent.json")
|
||||
cfg := Defaults()
|
||||
cfg.SessionToken = "super-secret-jwt"
|
||||
cfg.Save(path)
|
||||
raw, _ := os.ReadFile(path)
|
||||
if SecretsProtected() && strings.Contains(string(raw), "super-secret-jwt") {
|
||||
t.Fatal("session token written in plaintext")
|
||||
}
|
||||
}
|
||||
|
||||
// Standalone has to survive a restart. It is a setup choice made once at a
|
||||
// counter, and a flag that only lives in memory would put the enrolment-code
|
||||
// screen back in front of a shop that already answered "we have no head
|
||||
// office" - which reads as the app forgetting the setup step was ever done.
|
||||
func TestStandaloneSurvivesSaveAndLoad(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "agent.json")
|
||||
cfg := Defaults()
|
||||
cfg.Standalone = true
|
||||
if err := cfg.Save(path); err != nil {
|
||||
t.Fatalf("save: %v", err)
|
||||
}
|
||||
back, err := Load(path)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if !back.Standalone {
|
||||
t.Fatal("standalone was not persisted")
|
||||
}
|
||||
// Independent of being claimed: a standalone PC has no tenant, and a
|
||||
// claimed one is not standalone even if the flag was once set.
|
||||
if back.Configured() {
|
||||
t.Fatal("a standalone config must not report itself as claimed")
|
||||
}
|
||||
}
|
||||
|
||||
// The engine is a PyInstaller one-FOLDER build living in its own subdirectory,
|
||||
// and on Windows `Behavision.exe` (the app) could not share a directory with
|
||||
// `behavision.exe` (the engine) anyway. Asserted here because the installer
|
||||
// lays the tree out to match, and a rename would otherwise fail only inside
|
||||
// the package - the one place nothing is tested.
|
||||
func TestDefaultEngineExeIsInTheEngineFolder(t *testing.T) {
|
||||
got := Defaults().EngineExe
|
||||
if dir := filepath.Dir(got); dir != "engine" {
|
||||
t.Fatalf("engine exe %q is not under engine/, got dir %q", got, dir)
|
||||
}
|
||||
if filepath.IsAbs(got) {
|
||||
t.Fatalf("engine exe %q must be relative to the install root", got)
|
||||
}
|
||||
}
|
||||
62
agent/pkg/config/credentials.go
Normal file
62
agent/pkg/config/credentials.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// EngineCredentials reads the Basic credentials the engine generated for
|
||||
// itself, from the file it writes them to.
|
||||
//
|
||||
// The engine only invents a credential when none is configured and its API
|
||||
// listens on a routable address - which is the DEFAULT configuration, so this
|
||||
// is the ordinary case and not an edge one. `paths.APICredentials` has existed
|
||||
// since the agent was written, with a comment saying the agent reads the file
|
||||
// "rather than storing a second copy, so a regenerated credential does not
|
||||
// silently break the tray". Nothing read it. On a stock install the agent's
|
||||
// api_user was therefore empty and every call it makes to the engine - health,
|
||||
// stats, camera sync, embeddings for a visit - came back 401: the tray red, the
|
||||
// cameras never reconciled, and no error anywhere saying why.
|
||||
//
|
||||
// A missing or unreadable file is not an error. A PC where the operator set
|
||||
// BEHAVISION_API_USER has no such file and needs none.
|
||||
func EngineCredentials(path string) (user, password string) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
sc := bufio.NewScanner(f)
|
||||
for sc.Scan() {
|
||||
// `key=value`, and `key: value` too: the file is also read by people,
|
||||
// and which separator the engine used is not worth a support call.
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
k, v, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
k, v, ok = strings.Cut(line, ":")
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
switch strings.TrimSpace(k) {
|
||||
case "username":
|
||||
user = strings.TrimSpace(v)
|
||||
case "password":
|
||||
password = strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
return user, password
|
||||
}
|
||||
|
||||
// WithEngineCredentials fills in the engine's Basic credentials from the file
|
||||
// when the config carries none. Configured values always win: an operator who
|
||||
// set BEHAVISION_API_USER means it.
|
||||
func (c Config) WithEngineCredentials(path string) Config {
|
||||
if c.APIUser != "" || c.APIPassword != "" {
|
||||
return c
|
||||
}
|
||||
c.APIUser, c.APIPassword = EngineCredentials(path)
|
||||
return c
|
||||
}
|
||||
58
agent/pkg/config/credentials_test.go
Normal file
58
agent/pkg/config/credentials_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func writeCreds(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
p := filepath.Join(t.TempDir(), "api_credentials.txt")
|
||||
if err := os.WriteFile(p, []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// The shape the engine actually writes. This is the whole point of the file:
|
||||
// on a stock install it is the ONLY place the credential exists.
|
||||
func TestItReadsWhatTheEngineWrites(t *testing.T) {
|
||||
p := writeCreds(t, "username=behavision\npassword=qQTGFpetJ5Py613XwcbARQ\n")
|
||||
u, pw := EngineCredentials(p)
|
||||
if u != "behavision" || pw != "qQTGFpetJ5Py613XwcbARQ" {
|
||||
t.Fatalf("got %q / %q", u, pw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestColonSeparatedIsReadToo(t *testing.T) {
|
||||
p := writeCreds(t, " username: behavision\n password: hunter2\n")
|
||||
if u, pw := EngineCredentials(p); u != "behavision" || pw != "hunter2" {
|
||||
t.Fatalf("got %q / %q", u, pw)
|
||||
}
|
||||
}
|
||||
|
||||
// A missing file is normal - an operator who set BEHAVISION_API_USER has none.
|
||||
func TestAMissingFileIsNotAnError(t *testing.T) {
|
||||
if u, pw := EngineCredentials("/nope/nothing.txt"); u != "" || pw != "" {
|
||||
t.Fatalf("got %q / %q", u, pw)
|
||||
}
|
||||
}
|
||||
|
||||
// Configured values win. Reading the file over an operator's own credential
|
||||
// would silently ignore what they set.
|
||||
func TestAConfiguredCredentialIsNotOverwritten(t *testing.T) {
|
||||
p := writeCreds(t, "username=generated\npassword=generated\n")
|
||||
c := Config{APIUser: "mine", APIPassword: "secret"}.WithEngineCredentials(p)
|
||||
if c.APIUser != "mine" || c.APIPassword != "secret" {
|
||||
t.Fatalf("configured credential was replaced: %q / %q", c.APIUser, c.APIPassword)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnEmptyCredentialIsFilledIn(t *testing.T) {
|
||||
p := writeCreds(t, "username=behavision\npassword=abc\n")
|
||||
c := Config{}.WithEngineCredentials(p)
|
||||
if c.APIUser != "behavision" || c.APIPassword != "abc" {
|
||||
t.Fatalf("not filled in: %q / %q", c.APIUser, c.APIPassword)
|
||||
}
|
||||
}
|
||||
13
agent/pkg/config/protect.go
Normal file
13
agent/pkg/config/protect.go
Normal file
@@ -0,0 +1,13 @@
|
||||
//go:build !windows
|
||||
|
||||
package config
|
||||
|
||||
// On non-Windows hosts secrets are stored as-is. This exists so the rest of
|
||||
// the agent compiles and tests on a developer machine; the shipping platform
|
||||
// is Windows, where protect.go's DPAPI implementation is used instead.
|
||||
//
|
||||
// It is a passthrough, NOT encryption, and Save() marks such values plainly so
|
||||
// nobody can mistake a dev config for a protected one.
|
||||
func protect(plain []byte) ([]byte, error) { return plain, nil }
|
||||
func unprotect(blob []byte) ([]byte, error) { return blob, nil }
|
||||
func protectionAvailable() bool { return false }
|
||||
68
agent/pkg/config/protect_windows.go
Normal file
68
agent/pkg/config/protect_windows.go
Normal file
@@ -0,0 +1,68 @@
|
||||
//go:build windows
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// Windows DPAPI, reached through crypt32.dll directly rather than pulling in
|
||||
// golang.org/x/sys. Machine scope, matching how the Python side already
|
||||
// protects camera passwords: the agent and the engine may run as different
|
||||
// users on the same PC, and a user-scoped blob written by one cannot be read
|
||||
// by the other.
|
||||
var (
|
||||
crypt32 = syscall.NewLazyDLL("crypt32.dll")
|
||||
kernel32 = syscall.NewLazyDLL("kernel32.dll")
|
||||
procProtectData = crypt32.NewProc("CryptProtectData")
|
||||
procUnprotectData = crypt32.NewProc("CryptUnprotectData")
|
||||
procLocalFree = kernel32.NewProc("LocalFree")
|
||||
)
|
||||
|
||||
const cryptprotectLocalMachine = 0x4
|
||||
|
||||
type dataBlob struct {
|
||||
cbData uint32
|
||||
pbData *byte
|
||||
}
|
||||
|
||||
func newBlob(d []byte) dataBlob {
|
||||
if len(d) == 0 {
|
||||
return dataBlob{}
|
||||
}
|
||||
return dataBlob{cbData: uint32(len(d)), pbData: &d[0]}
|
||||
}
|
||||
|
||||
func (b *dataBlob) bytes() []byte {
|
||||
out := make([]byte, b.cbData)
|
||||
copy(out, unsafe.Slice(b.pbData, b.cbData))
|
||||
return out
|
||||
}
|
||||
|
||||
func protect(plain []byte) ([]byte, error) {
|
||||
in, out := newBlob(plain), dataBlob{}
|
||||
r, _, err := procProtectData.Call(
|
||||
uintptr(unsafe.Pointer(&in)), 0, 0, 0, 0,
|
||||
cryptprotectLocalMachine, uintptr(unsafe.Pointer(&out)))
|
||||
if r == 0 {
|
||||
return nil, fmt.Errorf("CryptProtectData: %w", err)
|
||||
}
|
||||
defer procLocalFree.Call(uintptr(unsafe.Pointer(out.pbData)))
|
||||
return out.bytes(), nil
|
||||
}
|
||||
|
||||
func unprotect(blob []byte) ([]byte, error) {
|
||||
in, out := newBlob(blob), dataBlob{}
|
||||
r, _, err := procUnprotectData.Call(
|
||||
uintptr(unsafe.Pointer(&in)), 0, 0, 0, 0,
|
||||
cryptprotectLocalMachine, uintptr(unsafe.Pointer(&out)))
|
||||
if r == 0 {
|
||||
return nil, fmt.Errorf("CryptUnprotectData: %w", err)
|
||||
}
|
||||
defer procLocalFree.Call(uintptr(unsafe.Pointer(out.pbData)))
|
||||
return out.bytes(), nil
|
||||
}
|
||||
|
||||
func protectionAvailable() bool { return true }
|
||||
44
agent/pkg/engine/health_test.go
Normal file
44
agent/pkg/engine/health_test.go
Normal file
@@ -0,0 +1,44 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The engine's REAL reply, copied from a running instance. The point of this
|
||||
// test is the `"frozen": false` inside `paths`: Health.Paths was
|
||||
// map[string]string, so decoding failed on that one bool, and because a failed
|
||||
// decode fails the whole document a working engine was reported unreachable -
|
||||
// red tray, and a heartbeat carrying neither the model nor the cameras, so head
|
||||
// office showed "0 of 0 cameras" for a site that was watching one.
|
||||
const realHealthBody = `{"status":"ok","recognition_model":"w600k_r50",
|
||||
"paths":{"frozen":false,"install_root":"/opt/behavision","state_root":"/var/behavision",
|
||||
"config":"/var/behavision/config/default.yaml","data_dir":"/var/behavision/data",
|
||||
"models_dir":"/var/behavision/models"},"cameras":{"cam1":true},"uptime_seconds":42.5}`
|
||||
|
||||
func TestHealthDecodesWhatTheEngineActuallySends(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(realHealthBody))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
s := New(Options{HealthURL: srv.URL})
|
||||
h, err := s.Health(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("the engine's own reply did not decode: %v", err)
|
||||
}
|
||||
if h.RecognitionModel != "w600k_r50" {
|
||||
t.Errorf("model = %q", h.RecognitionModel)
|
||||
}
|
||||
// The two fields the heartbeat carries. Losing these is what made a
|
||||
// working site look empty at head office.
|
||||
if up, ok := h.Cameras["cam1"]; !ok || !up {
|
||||
t.Errorf("cameras = %v, want cam1 connected", h.Cameras)
|
||||
}
|
||||
if h.Paths.StateRoot != "/var/behavision" || h.Paths.Frozen {
|
||||
t.Errorf("paths = %+v", h.Paths)
|
||||
}
|
||||
}
|
||||
355
agent/pkg/engine/supervisor.go
Normal file
355
agent/pkg/engine/supervisor.go
Normal file
@@ -0,0 +1,355 @@
|
||||
// Package engine starts, watches and stops the Python recognition engine.
|
||||
//
|
||||
// Go cannot run ONNX, OpenCV or FAISS, so the engine stays Python and ships
|
||||
// frozen. What Go owns is its lifecycle: start it, keep it up, capture its
|
||||
// output, and stop it when the user asks — which is what the tray's start/stop
|
||||
// buttons actually drive.
|
||||
//
|
||||
// Deliberately not a Windows service. A service runs in session 0 and cannot
|
||||
// draw a tray icon, and spawning a child process needs no elevation while
|
||||
// controlling a service does. A service wrapper can be layered on later
|
||||
// without touching anything here.
|
||||
package engine
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// State is what the tray icon colours itself from.
|
||||
type State string
|
||||
|
||||
const (
|
||||
Stopped State = "stopped" // not running, and not meant to be
|
||||
Starting State = "starting" // process spawned, not yet answering
|
||||
Running State = "running" // answering /api/health
|
||||
Backoff State = "backoff" // crashed, waiting to retry
|
||||
Failed State = "failed" // gave up
|
||||
)
|
||||
|
||||
const (
|
||||
minBackoff = 1 * time.Second
|
||||
maxBackoff = 30 * time.Second
|
||||
// A run that lasted this long counts as healthy, so the next crash starts
|
||||
// its backoff from the bottom again. Without this a process that runs fine
|
||||
// for hours and then crashes once waits the full 30s to come back.
|
||||
stableRun = 60 * time.Second
|
||||
// How long a stopping process gets to exit on its own before it is killed.
|
||||
stopGrace = 10 * time.Second
|
||||
)
|
||||
|
||||
// Options configures a Supervisor.
|
||||
type Options struct {
|
||||
// Command builds the process to run. Injected rather than hardcoded so
|
||||
// tests can supervise /bin/sh instead of a 200 MB frozen engine.
|
||||
Command func(ctx context.Context) *exec.Cmd
|
||||
// LogWriter receives the engine's stdout and stderr. A crashed engine with
|
||||
// no captured output is undiagnosable, which on a customer site means a
|
||||
// site visit.
|
||||
LogWriter io.Writer
|
||||
// HealthURL, StatsURL, User, Password address the engine's own API.
|
||||
HealthURL string
|
||||
StatsURL string
|
||||
User string
|
||||
Password string
|
||||
// MaxRestarts of 0 means never give up. Non-zero is for tests.
|
||||
MaxRestarts int
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// Supervisor keeps one engine process running. Safe for concurrent use.
|
||||
type Supervisor struct {
|
||||
opts Options
|
||||
|
||||
mu sync.Mutex
|
||||
state State
|
||||
lastErr error
|
||||
restarts int
|
||||
cancel context.CancelFunc
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
func New(opts Options) *Supervisor {
|
||||
if opts.LogWriter == nil {
|
||||
opts.LogWriter = io.Discard
|
||||
}
|
||||
if opts.now == nil {
|
||||
opts.now = time.Now
|
||||
}
|
||||
return &Supervisor{opts: opts, state: Stopped}
|
||||
}
|
||||
|
||||
// Start launches the engine and keeps it running until Stop. Calling it while
|
||||
// already running is a no-op rather than a second process — two engines on one
|
||||
// SQLite WAL and one camera is exactly the failure this package exists to
|
||||
// avoid.
|
||||
func (s *Supervisor) Start() {
|
||||
s.mu.Lock()
|
||||
if s.cancel != nil {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s.cancel = cancel
|
||||
s.done = make(chan struct{})
|
||||
s.state = Starting
|
||||
s.restarts = 0
|
||||
done := s.done
|
||||
s.mu.Unlock()
|
||||
|
||||
go s.supervise(ctx, done)
|
||||
}
|
||||
|
||||
// Stop asks the engine to exit and waits for it.
|
||||
func (s *Supervisor) Stop() {
|
||||
s.mu.Lock()
|
||||
cancel, done := s.cancel, s.done
|
||||
s.cancel = nil
|
||||
s.mu.Unlock()
|
||||
if cancel == nil {
|
||||
return
|
||||
}
|
||||
cancel()
|
||||
if done != nil {
|
||||
<-done
|
||||
}
|
||||
s.setState(Stopped, nil)
|
||||
}
|
||||
|
||||
// State reports what the supervisor is doing, plus the last error if any.
|
||||
func (s *Supervisor) State() (State, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.state, s.lastErr
|
||||
}
|
||||
|
||||
// Restarts counts crash-restarts since Start.
|
||||
func (s *Supervisor) Restarts() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.restarts
|
||||
}
|
||||
|
||||
// -- the loop --------------------------------------------------------------
|
||||
|
||||
func (s *Supervisor) supervise(ctx context.Context, done chan struct{}) {
|
||||
defer close(done)
|
||||
backoff := minBackoff
|
||||
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
s.setState(Starting, nil)
|
||||
started := s.opts.now()
|
||||
err := s.runOnce(ctx)
|
||||
ran := s.opts.now().Sub(started)
|
||||
|
||||
// A cancelled context means the user pressed Stop. Exiting then is
|
||||
// success, not a crash, and restarting would be the single most
|
||||
// annoying bug a tray app can have.
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.restarts++
|
||||
restarts := s.restarts
|
||||
s.mu.Unlock()
|
||||
|
||||
if s.opts.MaxRestarts > 0 && restarts >= s.opts.MaxRestarts {
|
||||
s.setState(Failed, err)
|
||||
return
|
||||
}
|
||||
if ran >= stableRun {
|
||||
backoff = minBackoff
|
||||
}
|
||||
s.setState(Backoff, err)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(backoff):
|
||||
}
|
||||
if backoff < maxBackoff {
|
||||
backoff *= 2
|
||||
if backoff > maxBackoff {
|
||||
backoff = maxBackoff
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Supervisor) runOnce(ctx context.Context) error {
|
||||
cmd := s.opts.Command(ctx)
|
||||
stdout, err := cmd.StdoutPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cmd.Stderr = cmd.Stdout
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("engine failed to start: %w", err)
|
||||
}
|
||||
|
||||
pumped := make(chan struct{})
|
||||
go func() {
|
||||
defer close(pumped)
|
||||
sc := bufio.NewScanner(stdout)
|
||||
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
for sc.Scan() {
|
||||
fmt.Fprintln(s.opts.LogWriter, sc.Text())
|
||||
}
|
||||
}()
|
||||
|
||||
s.setState(Running, nil)
|
||||
waitErr := cmd.Wait()
|
||||
<-pumped
|
||||
|
||||
// A context cancel terminates the child through exec's own handling; the
|
||||
// resulting error is expected, not a fault.
|
||||
if ctx.Err() != nil {
|
||||
return nil
|
||||
}
|
||||
if waitErr != nil {
|
||||
return fmt.Errorf("engine exited: %w", waitErr)
|
||||
}
|
||||
return errors.New("engine exited unexpectedly with status 0")
|
||||
}
|
||||
|
||||
func (s *Supervisor) setState(st State, err error) {
|
||||
s.mu.Lock()
|
||||
s.state = st
|
||||
if err != nil {
|
||||
s.lastErr = err
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
// -- health ----------------------------------------------------------------
|
||||
|
||||
// Health is the subset of /api/health the tray and the server care about.
|
||||
type Health struct {
|
||||
Status string `json:"status"`
|
||||
RecognitionModel string `json:"recognition_model"`
|
||||
Cameras map[string]bool `json:"cameras"`
|
||||
// Paths is a STRUCT, not map[string]string, because `frozen` is a bool.
|
||||
// It was a map of strings, so decoding the engine's real reply failed with
|
||||
// "cannot unmarshal bool into Go struct field Health.paths" - and because
|
||||
// one bad field fails the whole document, a perfectly healthy engine was
|
||||
// reported unreachable: red tray, and a heartbeat carrying neither the
|
||||
// model nor the camera list, so head office showed 0 of 0 cameras for a
|
||||
// site that was watching one.
|
||||
Paths EnginePaths `json:"paths"`
|
||||
}
|
||||
|
||||
// EnginePaths mirrors what `behavision paths` and /api/health report. Unknown
|
||||
// fields are ignored by encoding/json, so the engine can add to it freely.
|
||||
type EnginePaths struct {
|
||||
Frozen bool `json:"frozen"`
|
||||
InstallRoot string `json:"install_root"`
|
||||
StateRoot string `json:"state_root"`
|
||||
Config string `json:"config"`
|
||||
DataDir string `json:"data_dir"`
|
||||
ModelsDir string `json:"models_dir"`
|
||||
}
|
||||
|
||||
// Health polls the engine's own API. A running process is not the same as a
|
||||
// working engine: the model can fail to load and the process stays up.
|
||||
func (s *Supervisor) Health(ctx context.Context) (*Health, error) {
|
||||
if s.opts.HealthURL == "" {
|
||||
return nil, errors.New("no health url configured")
|
||||
}
|
||||
var h Health
|
||||
if err := s.getJSON(ctx, s.opts.HealthURL, &h); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &h, nil
|
||||
}
|
||||
|
||||
// Stats is the slice of /api/stats the heartbeat carries.
|
||||
//
|
||||
// Only fraction_below_gate, because that is the number that decides whether a
|
||||
// site's footfall can be believed at all - the share of faces its cameras saw
|
||||
// and discarded before they ever became a visit. Everything else in /api/stats
|
||||
// is a local diagnostic and belongs on the local dashboard, not on the wire
|
||||
// every thirty seconds.
|
||||
type Stats struct {
|
||||
Cameras []struct {
|
||||
CameraID string `json:"camera_id"`
|
||||
Pipeline struct {
|
||||
BestQuality struct {
|
||||
N int `json:"n"`
|
||||
FractionBelowGate float64 `json:"fraction_below_gate"`
|
||||
} `json:"best_quality"`
|
||||
} `json:"pipeline"`
|
||||
} `json:"cameras"`
|
||||
}
|
||||
|
||||
// WorstBelowGate returns the worst camera's figure, and whether any camera has
|
||||
// measured enough faces to have an opinion.
|
||||
//
|
||||
// Worst rather than average: one badly placed camera is a hole in the report,
|
||||
// and averaging it against three good ones hides the only camera anyone needs
|
||||
// to move. The sample floor is there because three faces is an anecdote -
|
||||
// reporting 1.00 from a single below-gate track would raise an alarm about a
|
||||
// camera nobody has walked past yet.
|
||||
func (s *Stats) WorstBelowGate() (float64, bool) {
|
||||
const minSamples = 10
|
||||
worst, found := 0.0, false
|
||||
for _, c := range s.Cameras {
|
||||
if c.Pipeline.BestQuality.N < minSamples {
|
||||
continue
|
||||
}
|
||||
if !found || c.Pipeline.BestQuality.FractionBelowGate > worst {
|
||||
worst, found = c.Pipeline.BestQuality.FractionBelowGate, true
|
||||
}
|
||||
}
|
||||
return worst, found
|
||||
}
|
||||
|
||||
// Stats polls the engine's pipeline counters.
|
||||
func (s *Supervisor) Stats(ctx context.Context) (*Stats, error) {
|
||||
if s.opts.StatsURL == "" {
|
||||
return nil, errors.New("no stats url configured")
|
||||
}
|
||||
var out Stats
|
||||
if err := s.getJSON(ctx, s.opts.StatsURL, &out); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
func (s *Supervisor) getJSON(ctx context.Context, url string, out any) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if s.opts.User != "" {
|
||||
req.SetBasicAuth(s.opts.User, s.opts.Password)
|
||||
}
|
||||
resp, err := (&http.Client{Timeout: 5 * time.Second}).Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("%s returned %s", url, resp.Status)
|
||||
}
|
||||
return json.NewDecoder(io.LimitReader(resp.Body, 4<<20)).Decode(out)
|
||||
}
|
||||
|
||||
// LogFile opens the engine log, rotating aside anything already there so one
|
||||
// run's output cannot be mistaken for another's.
|
||||
func LogFile(path string) (*os.File, error) {
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
os.Rename(path, path+".1")
|
||||
}
|
||||
return os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
|
||||
}
|
||||
240
agent/pkg/engine/supervisor_test.go
Normal file
240
agent/pkg/engine/supervisor_test.go
Normal file
@@ -0,0 +1,240 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os/exec"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// sh supervises /bin/sh instead of a 200 MB frozen engine. The Command hook
|
||||
// exists for exactly this.
|
||||
func sh(script string) func(context.Context) *exec.Cmd {
|
||||
return func(ctx context.Context) *exec.Cmd {
|
||||
return exec.CommandContext(ctx, "/bin/sh", "-c", script)
|
||||
}
|
||||
}
|
||||
|
||||
func waitFor(t *testing.T, s *Supervisor, want State, within time.Duration) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(within)
|
||||
for time.Now().Before(deadline) {
|
||||
if got, _ := s.State(); got == want {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
got, err := s.State()
|
||||
t.Fatalf("state %q (err %v), want %q within %s", got, err, want, within)
|
||||
}
|
||||
|
||||
func TestItRunsAndReportsRunning(t *testing.T) {
|
||||
s := New(Options{Command: sh("sleep 5")})
|
||||
s.Start()
|
||||
defer s.Stop()
|
||||
waitFor(t, s, Running, 2*time.Second)
|
||||
}
|
||||
|
||||
func TestStopDoesNotTriggerARestart(t *testing.T) {
|
||||
// The classic supervisor bug: the user presses Stop, the child exits, the
|
||||
// loop reads that as a crash and starts it again.
|
||||
s := New(Options{Command: sh("sleep 30")})
|
||||
s.Start()
|
||||
waitFor(t, s, Running, 2*time.Second)
|
||||
s.Stop()
|
||||
|
||||
if got, _ := s.State(); got != Stopped {
|
||||
t.Fatalf("state after Stop is %q", got)
|
||||
}
|
||||
if n := s.Restarts(); n != 0 {
|
||||
t.Fatalf("Stop counted as %d crash-restarts", n)
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
if got, _ := s.State(); got != Stopped {
|
||||
t.Fatalf("it restarted itself after Stop: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStopIsSynchronous(t *testing.T) {
|
||||
// Stop must not return while the child still holds the SQLite WAL, or the
|
||||
// next Start races the previous process.
|
||||
s := New(Options{Command: sh("sleep 30")})
|
||||
s.Start()
|
||||
waitFor(t, s, Running, 2*time.Second)
|
||||
done := make(chan struct{})
|
||||
go func() { s.Stop(); close(done) }()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("Stop did not return")
|
||||
}
|
||||
}
|
||||
|
||||
func TestACrashIsRestarted(t *testing.T) {
|
||||
s := New(Options{Command: sh("exit 1")})
|
||||
s.Start()
|
||||
defer s.Stop()
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if s.Restarts() >= 2 {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("only %d restarts - is it backing off correctly?", s.Restarts())
|
||||
}
|
||||
|
||||
func TestItDoesNotSpinOnAProcessThatCannotStart(t *testing.T) {
|
||||
// A tight restart loop on a broken install pins a core and fills the disk
|
||||
// with log lines. Backoff must space the attempts out.
|
||||
s := New(Options{Command: sh("exit 1")})
|
||||
s.Start()
|
||||
defer s.Stop()
|
||||
time.Sleep(1500 * time.Millisecond)
|
||||
// 1s + 2s backoff means at most ~2 attempts in 1.5s; a spin would be
|
||||
// thousands.
|
||||
if n := s.Restarts(); n > 4 {
|
||||
t.Fatalf("%d restarts in 1.5s - not backing off", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestItGivesUpAfterMaxRestarts(t *testing.T) {
|
||||
s := New(Options{Command: sh("exit 1"), MaxRestarts: 2})
|
||||
s.Start()
|
||||
defer s.Stop()
|
||||
waitFor(t, s, Failed, 5*time.Second)
|
||||
if _, err := s.State(); err == nil {
|
||||
t.Fatal("Failed state carries no reason")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngineOutputIsCaptured(t *testing.T) {
|
||||
// A crashed engine with no captured output means a site visit to diagnose.
|
||||
var mu sync.Mutex
|
||||
buf := &lockedBuf{mu: &mu}
|
||||
s := New(Options{Command: sh("echo model-load-failed; exit 1"),
|
||||
LogWriter: buf, MaxRestarts: 1})
|
||||
s.Start()
|
||||
defer s.Stop()
|
||||
waitFor(t, s, Failed, 5*time.Second)
|
||||
if got := buf.String(); !bytes.Contains([]byte(got), []byte("model-load-failed")) {
|
||||
t.Fatalf("engine output not captured, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStartTwiceDoesNotRunTwoEngines(t *testing.T) {
|
||||
// Two engines on one SQLite WAL and one camera is the failure this whole
|
||||
// package exists to prevent.
|
||||
s := New(Options{Command: sh("sleep 5")})
|
||||
s.Start()
|
||||
s.Start()
|
||||
defer s.Stop()
|
||||
waitFor(t, s, Running, 2*time.Second)
|
||||
if n := s.Restarts(); n != 0 {
|
||||
t.Fatalf("second Start disturbed the first: %d restarts", n)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthReportsTheModelThatActuallyLoaded(t *testing.T) {
|
||||
// A running process is not a working engine: on a memory-starved box the
|
||||
// big model loses the fallback chain and the process stays up regardless.
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
user, pass, ok := r.BasicAuth()
|
||||
if !ok || user != "u" || pass != "p" {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
w.Write([]byte(`{"status":"ok","recognition_model":"w600k_mbf.onnx",
|
||||
"cameras":{"entrance":true}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
s := New(Options{Command: sh("sleep 1"), HealthURL: srv.URL,
|
||||
User: "u", Password: "p"})
|
||||
h, err := s.Health(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if h.RecognitionModel != "w600k_mbf.onnx" || !h.Cameras["entrance"] {
|
||||
t.Fatalf("bad health: %+v", h)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHealthFailsClosedOnBadCredentials(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
}))
|
||||
defer srv.Close()
|
||||
s := New(Options{Command: sh("true"), HealthURL: srv.URL, User: "u", Password: "wrong"})
|
||||
if _, err := s.Health(context.Background()); err == nil {
|
||||
t.Fatal("401 reported as healthy")
|
||||
}
|
||||
}
|
||||
|
||||
type lockedBuf struct {
|
||||
mu *sync.Mutex
|
||||
buf bytes.Buffer
|
||||
}
|
||||
|
||||
func (l *lockedBuf) Write(p []byte) (int, error) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return l.buf.Write(p)
|
||||
}
|
||||
func (l *lockedBuf) String() string {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
return l.buf.String()
|
||||
}
|
||||
|
||||
// The engine has to be TOLD where to post detections, and the only place that
|
||||
// can happen is when the child is launched: the bridge picks a random loopback
|
||||
// port after the supervisor is built, and a restarted engine has to be told
|
||||
// again. This pins that the Command hook is consulted per launch rather than
|
||||
// captured once - the wiring that was missing while the bridge's own doc
|
||||
// comment claimed it existed.
|
||||
func TestTheChildIsBuiltFreshOnEveryLaunch(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
url := "http://127.0.0.1:1111/e"
|
||||
var seen []string
|
||||
|
||||
s := New(Options{Command: func(ctx context.Context) *exec.Cmd {
|
||||
mu.Lock()
|
||||
seen = append(seen, url)
|
||||
mu.Unlock()
|
||||
return exec.CommandContext(ctx, "/bin/sh", "-c", "exit 1")
|
||||
}})
|
||||
s.Start()
|
||||
waitFor(t, s, Backoff, 2*time.Second)
|
||||
|
||||
// The port changes, exactly as it does when the bridge restarts.
|
||||
mu.Lock()
|
||||
url = "http://127.0.0.1:2222/e"
|
||||
mu.Unlock()
|
||||
|
||||
// One backoff (1s) plus room for the relaunch.
|
||||
deadline := time.Now().Add(4 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
mu.Lock()
|
||||
n := len(seen)
|
||||
mu.Unlock()
|
||||
if n >= 2 {
|
||||
break
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
s.Stop()
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if len(seen) < 2 {
|
||||
t.Fatalf("the command hook ran %d times, so a restart could not be told a new URL", len(seen))
|
||||
}
|
||||
if seen[len(seen)-1] != "http://127.0.0.1:2222/e" {
|
||||
t.Fatalf("the last launch used %q - the hook captured a stale value", seen[len(seen)-1])
|
||||
}
|
||||
}
|
||||
217
agent/pkg/mqtt/client.go
Normal file
217
agent/pkg/mqtt/client.go
Normal file
@@ -0,0 +1,217 @@
|
||||
// Broker client: the thin adapter behind the Publisher interface.
|
||||
//
|
||||
// Everything that decides *what to send and when* is in pump.go and is tested
|
||||
// without a broker. This file only knows how to put bytes on a topic, which is
|
||||
// why it is the one part that needs a real connection to exercise.
|
||||
//
|
||||
// Targets Mosquitto. No clustering, no shared subscriptions, no broker-side
|
||||
// rules — a store publishes its own events under its own prefix and that is
|
||||
// the whole interaction.
|
||||
package mqtt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
neturl "net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
paho "github.com/eclipse/paho.mqtt.golang"
|
||||
)
|
||||
|
||||
// ClientOptions configures a broker connection.
|
||||
type ClientOptions struct {
|
||||
// BrokerURL is tls://host:8883 in production, tcp://host:1883 for local
|
||||
// testing only. Credentials and footfall must never cross the internet in
|
||||
// the clear, so Connect refuses tcp:// to a non-loopback host.
|
||||
BrokerURL string
|
||||
ClientID string
|
||||
Username string
|
||||
Password string
|
||||
// CAFile pins a private CA. Empty uses the system roots, which is what a
|
||||
// Let's Encrypt certificate on the broker needs.
|
||||
CAFile string
|
||||
// InsecureSkipVerify disables certificate checking. Only ever for a
|
||||
// self-signed staging box, and it is logged loudly when set, because a
|
||||
// forgotten one silently removes the protection TLS was added for.
|
||||
InsecureSkipVerify bool
|
||||
// PublishTimeout bounds a single publish. Without it a half-open
|
||||
// connection blocks the pump indefinitely and the queue grows behind it.
|
||||
PublishTimeout time.Duration
|
||||
Log *log.Logger
|
||||
}
|
||||
|
||||
// Client implements Publisher.
|
||||
type Client struct {
|
||||
opts ClientOptions
|
||||
client paho.Client
|
||||
}
|
||||
|
||||
// NewClient dials the broker. It returns as soon as the connection is
|
||||
// established; reconnection afterwards is automatic and the pump reads
|
||||
// Connected() to decide whether to try.
|
||||
func NewClient(opts ClientOptions) (*Client, error) {
|
||||
if opts.BrokerURL == "" {
|
||||
return nil, errors.New("mqtt: no broker url")
|
||||
}
|
||||
if opts.PublishTimeout <= 0 {
|
||||
opts.PublishTimeout = 10 * time.Second
|
||||
}
|
||||
if err := checkTransport(opts.BrokerURL); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
po := paho.NewClientOptions().
|
||||
AddBroker(opts.BrokerURL).
|
||||
SetClientID(opts.ClientID).
|
||||
SetUsername(opts.Username).
|
||||
SetPassword(opts.Password).
|
||||
// The broker holds no state for us: every event is already durable on
|
||||
// our own disk, so a clean session avoids the broker queueing a
|
||||
// second copy we would then have to de-duplicate.
|
||||
SetCleanSession(true).
|
||||
SetAutoReconnect(true).
|
||||
SetConnectRetry(true).
|
||||
SetConnectRetryInterval(5 * time.Second).
|
||||
SetMaxReconnectInterval(2 * time.Minute).
|
||||
SetKeepAlive(30 * time.Second).
|
||||
SetConnectTimeout(15 * time.Second).
|
||||
// Publishes must fail fast rather than pile up in memory while the
|
||||
// link is down; the spool is what holds them, not the client.
|
||||
SetMessageChannelDepth(1).
|
||||
SetOrderMatters(true)
|
||||
|
||||
if strings.HasPrefix(opts.BrokerURL, "tls://") ||
|
||||
strings.HasPrefix(opts.BrokerURL, "ssl://") {
|
||||
cfg, err := tlsConfig(opts)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
po.SetTLSConfig(cfg)
|
||||
}
|
||||
|
||||
c := &Client{opts: opts}
|
||||
po.OnConnect = func(paho.Client) { c.logf("broker connected: %s", opts.BrokerURL) }
|
||||
po.OnConnectionLost = func(_ paho.Client, err error) {
|
||||
c.logf("broker connection lost: %v", err)
|
||||
}
|
||||
c.client = paho.NewClient(po)
|
||||
|
||||
tok := c.client.Connect()
|
||||
if !tok.WaitTimeout(20 * time.Second) {
|
||||
return c, fmt.Errorf("mqtt: connect to %s timed out", opts.BrokerURL)
|
||||
}
|
||||
if err := tok.Error(); err != nil {
|
||||
return c, fmt.Errorf("mqtt: connect to %s: %w", opts.BrokerURL, err)
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Publish sends one message at QoS 1 and waits for the broker's PUBACK.
|
||||
//
|
||||
// QoS 1, not 0 or 2. At QoS 0 the broker never confirms, so the pump would ack
|
||||
// and delete an event that was dropped on the wire. QoS 2 costs two extra
|
||||
// round trips to remove a duplicate the server can drop itself from the event
|
||||
// id — at-least-once with idempotent consumers is the cheaper contract.
|
||||
func (c *Client) Publish(ctx context.Context, topic string, payload []byte) error {
|
||||
if c.client == nil {
|
||||
return errors.New("mqtt: no client")
|
||||
}
|
||||
if !c.client.IsConnected() {
|
||||
return errors.New("mqtt: not connected")
|
||||
}
|
||||
tok := c.client.Publish(topic, 1, false, payload)
|
||||
|
||||
// Honour both the caller's context and a hard timeout: a half-open TCP
|
||||
// connection can leave a token that never completes, which would stall the
|
||||
// pump forever with the queue growing behind it.
|
||||
done := make(chan struct{})
|
||||
go func() { tok.Wait(); close(done) }()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(c.opts.PublishTimeout):
|
||||
return fmt.Errorf("mqtt: publish to %s timed out", topic)
|
||||
case <-done:
|
||||
return tok.Error()
|
||||
}
|
||||
}
|
||||
|
||||
// Connected reports whether the broker link is up.
|
||||
func (c *Client) Connected() bool {
|
||||
return c.client != nil && c.client.IsConnected()
|
||||
}
|
||||
|
||||
// Close disconnects cleanly, giving in-flight publishes a moment to land.
|
||||
func (c *Client) Close() {
|
||||
if c.client != nil && c.client.IsConnected() {
|
||||
c.client.Disconnect(1000)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) logf(format string, args ...any) {
|
||||
if c.opts.Log != nil {
|
||||
c.opts.Log.Printf(format, args...)
|
||||
}
|
||||
}
|
||||
|
||||
// checkTransport refuses plaintext MQTT to anywhere but the local machine.
|
||||
//
|
||||
// The payloads carry customer visit records and the connection carries the
|
||||
// tenant's broker password. A tcp:// URL to a public host is not a
|
||||
// configuration choice, it is a mistake, and it is one that works — which is
|
||||
// exactly why it has to be rejected here rather than noticed later.
|
||||
func checkTransport(raw string) error {
|
||||
if !strings.HasPrefix(raw, "tcp://") && !strings.HasPrefix(raw, "mqtt://") {
|
||||
return nil
|
||||
}
|
||||
if os.Getenv("BEHAVISION_ALLOW_PLAINTEXT_MQTT") == "1" {
|
||||
return nil
|
||||
}
|
||||
// url.Parse, not hand-rolled splitting: an IPv6 literal is bracketed and
|
||||
// full of colons, so scanning for the first ":" turns "[::1]:1883" into
|
||||
// "[" and refuses a perfectly good loopback address.
|
||||
u, err := neturl.Parse(raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mqtt: cannot parse broker url %q: %w", raw, err)
|
||||
}
|
||||
switch u.Hostname() {
|
||||
case "localhost", "127.0.0.1", "::1", "":
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("mqtt: refusing plaintext connection to %q - use tls:// "+
|
||||
"(set BEHAVISION_ALLOW_PLAINTEXT_MQTT=1 only for local testing)",
|
||||
u.Hostname())
|
||||
}
|
||||
|
||||
func tlsConfig(opts ClientOptions) (*tls.Config, error) {
|
||||
cfg := &tls.Config{MinVersion: tls.VersionTLS12}
|
||||
if opts.InsecureSkipVerify {
|
||||
cfg.InsecureSkipVerify = true
|
||||
if opts.Log != nil {
|
||||
opts.Log.Print("WARNING: MQTT certificate verification is DISABLED")
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
if opts.CAFile == "" {
|
||||
return cfg, nil // system roots
|
||||
}
|
||||
pem, err := os.ReadFile(opts.CAFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("mqtt: ca file: %w", err)
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
if !pool.AppendCertsFromPEM(pem) {
|
||||
return nil, fmt.Errorf("mqtt: no certificates found in %s", opts.CAFile)
|
||||
}
|
||||
cfg.RootCAs = pool
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// osWriteFile is indirected so tests can build without importing os twice.
|
||||
var osWriteFile = os.WriteFile
|
||||
104
agent/pkg/mqtt/client_test.go
Normal file
104
agent/pkg/mqtt/client_test.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package mqtt
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPlaintextToAPublicHostIsRefused(t *testing.T) {
|
||||
// The payloads carry customer visit records and the connection carries the
|
||||
// tenant's broker password. A tcp:// URL to a public host is not a config
|
||||
// choice, it is a mistake — and one that WORKS, which is exactly why it
|
||||
// has to fail here rather than be noticed after a year of traffic.
|
||||
for _, url := range []string{
|
||||
"tcp://broker.example.com:1883",
|
||||
"mqtt://66.116.226.234:1883",
|
||||
"tcp://10.0.0.5:1883",
|
||||
"tcp://[2001:db8::1]:1883",
|
||||
} {
|
||||
if _, err := NewClient(ClientOptions{BrokerURL: url}); err == nil ||
|
||||
!strings.Contains(err.Error(), "refusing plaintext") {
|
||||
t.Errorf("%s was not refused (err=%v)", url, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaintextToLocalhostIsAllowed(t *testing.T) {
|
||||
// Local testing against a Mosquitto on the same box crosses no network.
|
||||
// Checked at the transport gate rather than through NewClient: dialling a
|
||||
// port nothing is listening on burns the full 20s connect timeout, and a
|
||||
// slow test is a test people start skipping.
|
||||
for _, url := range []string{"tcp://127.0.0.1:1883", "tcp://localhost:1883",
|
||||
"mqtt://[::1]:1883"} {
|
||||
if err := checkTransport(url); err != nil {
|
||||
t.Errorf("loopback %s was refused: %v", url, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaintextEscapeHatchIsExplicit(t *testing.T) {
|
||||
// An override must exist for a lab, but it has to be a deliberate act,
|
||||
// not a config field someone leaves set.
|
||||
t.Setenv("BEHAVISION_ALLOW_PLAINTEXT_MQTT", "1")
|
||||
if err := checkTransport("tcp://broker.example.com:1883"); err != nil {
|
||||
t.Fatalf("escape hatch did not apply: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTLSUrlsSkipTheTransportCheck(t *testing.T) {
|
||||
for _, url := range []string{"tls://b:8883", "ssl://b:8883", "wss://b:443"} {
|
||||
if err := checkTransport(url); err != nil {
|
||||
t.Errorf("%s rejected: %v", url, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnEmptyBrokerUrlIsAnError(t *testing.T) {
|
||||
if _, err := NewClient(ClientOptions{}); err == nil {
|
||||
t.Fatal("empty broker url accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTLSConfigRejectsAnUnreadableCA(t *testing.T) {
|
||||
// Silently falling back to system roots when a pinned CA is missing would
|
||||
// quietly undo the pinning.
|
||||
if _, err := tlsConfig(ClientOptions{CAFile: "/nonexistent/ca.pem"}); err == nil {
|
||||
t.Fatal("missing CA file accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTLSConfigRejectsAFileWithNoCertificates(t *testing.T) {
|
||||
f := t.TempDir() + "/not-a-cert.pem"
|
||||
if err := writeFile(f, "hello"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tlsConfig(ClientOptions{CAFile: f}); err == nil {
|
||||
t.Fatal("a file with no PEM certificates was accepted as a CA")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTLSFloorIsTLS12(t *testing.T) {
|
||||
cfg, err := tlsConfig(ClientOptions{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg.MinVersion < 0x0303 {
|
||||
t.Fatalf("MinVersion %#x allows TLS below 1.2", cfg.MinVersion)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishOnADeadClientErrorsRatherThanPanics(t *testing.T) {
|
||||
// The pump calls this on every tick; a nil-client panic would take the
|
||||
// whole agent down instead of backing off.
|
||||
c := &Client{}
|
||||
if err := c.Publish(nil, "t", []byte("{}")); err == nil { //nolint:staticcheck
|
||||
t.Fatal("publish on an unconnected client reported success")
|
||||
}
|
||||
if c.Connected() {
|
||||
t.Fatal("an unconnected client reported Connected")
|
||||
}
|
||||
}
|
||||
|
||||
func writeFile(path, content string) error {
|
||||
return osWriteFile(path, []byte(content), 0o600)
|
||||
}
|
||||
218
agent/pkg/mqtt/pump.go
Normal file
218
agent/pkg/mqtt/pump.go
Normal file
@@ -0,0 +1,218 @@
|
||||
// Package mqtt moves queued events to the broker.
|
||||
//
|
||||
// Split from the broker client on purpose: everything that decides *what to
|
||||
// send and when* lives here and is testable without a broker, while the paho
|
||||
// binding is a thin adapter that only knows how to put bytes on a topic.
|
||||
package mqtt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/loyaly/behavision-agent/pkg/spool"
|
||||
)
|
||||
|
||||
// Publisher is the broker, reduced to what the pump needs.
|
||||
type Publisher interface {
|
||||
// Publish must return nil only once the broker has confirmed receipt.
|
||||
// Returning early would let the pump ack an event that never arrived.
|
||||
Publish(ctx context.Context, topic string, payload []byte) error
|
||||
Connected() bool
|
||||
}
|
||||
|
||||
// Queue is the durable side, reduced likewise.
|
||||
type Queue interface {
|
||||
Peek(n int) ([]spool.Entry, error)
|
||||
Ack(seqs ...uint64) error
|
||||
Len() int
|
||||
Dropped() uint64
|
||||
}
|
||||
|
||||
const (
|
||||
batchSize = 32
|
||||
idleInterval = 2 * time.Second
|
||||
minRetry = 1 * time.Second
|
||||
maxRetry = 30 * time.Second
|
||||
defaultHeartbe = 60 * time.Second
|
||||
)
|
||||
|
||||
// Pump drains the queue into the broker and emits a heartbeat.
|
||||
type Pump struct {
|
||||
Queue Queue
|
||||
Publisher Publisher
|
||||
// Heartbeat topic. Without it "the site is offline" and "nobody visited"
|
||||
// are indistinguishable on the server, which for a footfall product is a
|
||||
// silent hole in the customer's report.
|
||||
HeartbeatTopic string
|
||||
HeartbeatPayload func() []byte
|
||||
HeartbeatInterval time.Duration
|
||||
Log *log.Logger
|
||||
|
||||
// Wake, when set, makes the pump drain immediately instead of waiting out
|
||||
// idleInterval. Without it a visit that lands one millisecond after a drain
|
||||
// sits on disk for two seconds before anyone is told - and that delay is on
|
||||
// the path a shop screen or a mobile app sees as "how long after someone
|
||||
// walks in does their face appear".
|
||||
//
|
||||
// A doorbell, not a queue: it carries nothing, because the pump re-reads
|
||||
// the spool either way. Buffered by one and written non-blockingly, so a
|
||||
// burst of arrivals cannot stall the recognition pipeline behind a pump
|
||||
// that is mid-publish.
|
||||
Wake <-chan struct{}
|
||||
}
|
||||
|
||||
// Waker is the writing end of the Wake channel, held by whatever appends to the
|
||||
// queue. NewWaker returns both halves so a caller cannot accidentally build one
|
||||
// that blocks its own producer.
|
||||
type Waker struct{ ch chan struct{} }
|
||||
|
||||
func NewWaker() *Waker { return &Waker{ch: make(chan struct{}, 1)} }
|
||||
|
||||
// Wake rings the pump. Never blocks: a full slot already means "there is work",
|
||||
// which is the entire message, so a second ring adds nothing.
|
||||
func (w *Waker) Wake() {
|
||||
if w == nil {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case w.ch <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// C is the channel to hand the pump.
|
||||
func (w *Waker) C() <-chan struct{} {
|
||||
if w == nil {
|
||||
return nil
|
||||
}
|
||||
return w.ch
|
||||
}
|
||||
|
||||
// Run drains until ctx is cancelled.
|
||||
func (p *Pump) Run(ctx context.Context) {
|
||||
interval := p.HeartbeatInterval
|
||||
if interval <= 0 {
|
||||
interval = defaultHeartbe
|
||||
}
|
||||
beat := time.NewTicker(interval)
|
||||
defer beat.Stop()
|
||||
retry := minRetry
|
||||
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
// Non-blocking, for the case where there is a backlog and the loop
|
||||
// never reaches the waiting select below.
|
||||
select {
|
||||
case <-beat.C:
|
||||
p.heartbeat(ctx)
|
||||
default:
|
||||
}
|
||||
|
||||
sent, err := p.drainOnce(ctx)
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var wait time.Duration
|
||||
switch {
|
||||
case err != nil:
|
||||
// The broker is down or refusing. Back off rather than spinning:
|
||||
// a store with no internet would otherwise burn a core all night.
|
||||
p.logf("publish failed, retrying in %s: %v", retry, err)
|
||||
wait = retry
|
||||
if retry < maxRetry {
|
||||
retry *= 2
|
||||
if retry > maxRetry {
|
||||
retry = maxRetry
|
||||
}
|
||||
}
|
||||
case sent == 0:
|
||||
retry = minRetry
|
||||
wait = idleInterval
|
||||
default:
|
||||
// Something went through; there may be more waiting, so loop
|
||||
// immediately rather than sleeping through a backlog.
|
||||
retry = minRetry
|
||||
}
|
||||
if wait == 0 {
|
||||
continue
|
||||
}
|
||||
// The heartbeat must be able to interrupt this wait. Sleeping through
|
||||
// it would delay every beat by the idle interval, and on a quiet site
|
||||
// the pump is idle essentially always.
|
||||
// A nil Wake channel blocks forever in a select, which is exactly the
|
||||
// right behaviour: an agent with no waker falls back to the timer.
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-beat.C:
|
||||
p.heartbeat(ctx)
|
||||
case <-p.Wake:
|
||||
// Something was queued. Loop straight round and drain it rather
|
||||
// than sleeping out the rest of the idle interval.
|
||||
case <-time.After(wait):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// drainOnce sends at most one batch and returns how many were acked.
|
||||
func (p *Pump) drainOnce(ctx context.Context) (int, error) {
|
||||
if !p.Publisher.Connected() {
|
||||
return 0, errors.New("broker not connected")
|
||||
}
|
||||
entries, err := p.Queue.Peek(batchSize)
|
||||
if err != nil || len(entries) == 0 {
|
||||
return 0, err
|
||||
}
|
||||
sent := 0
|
||||
for _, e := range entries {
|
||||
if err := p.Publisher.Publish(ctx, e.Topic, e.Payload); err != nil {
|
||||
// Stop at the first failure instead of skipping past it. Events
|
||||
// are a per-visitor timeline and the server reads them in order;
|
||||
// publishing around a stuck one would reorder a customer's visits.
|
||||
return sent, err
|
||||
}
|
||||
// Acked one at a time, immediately after its own confirmation. A batch
|
||||
// ack would re-send everything before a mid-batch failure on restart.
|
||||
if err := p.Queue.Ack(e.Seq); err != nil {
|
||||
return sent, err
|
||||
}
|
||||
sent++
|
||||
}
|
||||
return sent, nil
|
||||
}
|
||||
|
||||
func (p *Pump) heartbeat(ctx context.Context) {
|
||||
if p.HeartbeatTopic == "" || p.HeartbeatPayload == nil {
|
||||
return
|
||||
}
|
||||
if !p.Publisher.Connected() {
|
||||
return
|
||||
}
|
||||
// Not queued: a heartbeat is only meaningful now. Spooling them would
|
||||
// replay a week of "I am alive" the moment a site reconnects.
|
||||
if err := p.Publisher.Publish(ctx, p.HeartbeatTopic, p.HeartbeatPayload()); err != nil {
|
||||
p.logf("heartbeat failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Pump) logf(format string, args ...any) {
|
||||
if p.Log != nil {
|
||||
p.Log.Printf(format, args...)
|
||||
}
|
||||
}
|
||||
|
||||
func sleep(ctx context.Context, d time.Duration) bool {
|
||||
t := time.NewTimer(d)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-t.C:
|
||||
return true
|
||||
}
|
||||
}
|
||||
288
agent/pkg/mqtt/pump_test.go
Normal file
288
agent/pkg/mqtt/pump_test.go
Normal file
@@ -0,0 +1,288 @@
|
||||
package mqtt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/loyaly/behavision-agent/pkg/spool"
|
||||
)
|
||||
|
||||
type fakeBroker struct {
|
||||
mu sync.Mutex
|
||||
connected bool
|
||||
sent []string
|
||||
failAfter int // fail every publish once this many have succeeded
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeBroker) Publish(ctx context.Context, topic string, payload []byte) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.failAfter > 0 && len(f.sent) >= f.failAfter {
|
||||
if f.err != nil {
|
||||
return f.err
|
||||
}
|
||||
return errors.New("broker refused")
|
||||
}
|
||||
f.sent = append(f.sent, string(payload))
|
||||
return nil
|
||||
}
|
||||
func (f *fakeBroker) Connected() bool {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.connected
|
||||
}
|
||||
func (f *fakeBroker) delivered() []string {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return append([]string(nil), f.sent...)
|
||||
}
|
||||
|
||||
func queue(t *testing.T, payloads ...string) *spool.Spool {
|
||||
t.Helper()
|
||||
s, err := spool.Open(t.TempDir(), 100)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, p := range payloads {
|
||||
if err := s.Append("visit", p); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func TestItDrainsInOrderAndAcks(t *testing.T) {
|
||||
q := queue(t, "a", "b", "c")
|
||||
b := &fakeBroker{connected: true}
|
||||
p := &Pump{Queue: q, Publisher: b}
|
||||
|
||||
sent, err := p.drainOnce(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sent != 3 || q.Len() != 0 {
|
||||
t.Fatalf("sent %d, %d left in queue", sent, q.Len())
|
||||
}
|
||||
got := b.delivered()
|
||||
if len(got) != 3 || got[0] != `"a"` || got[2] != `"c"` {
|
||||
t.Fatalf("wrong order: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNothingIsAckedWhileTheBrokerIsDown(t *testing.T) {
|
||||
// Acking an event the broker never took is how footfall disappears.
|
||||
q := queue(t, "a", "b")
|
||||
b := &fakeBroker{connected: false}
|
||||
p := &Pump{Queue: q, Publisher: b}
|
||||
|
||||
if _, err := p.drainOnce(context.Background()); err == nil {
|
||||
t.Fatal("a disconnected broker was treated as success")
|
||||
}
|
||||
if q.Len() != 2 {
|
||||
t.Fatalf("events were dropped while offline: %d left", q.Len())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFailureStopsTheBatchInsteadOfSkippingPast(t *testing.T) {
|
||||
// Events are a per-visitor timeline read in order; publishing around a
|
||||
// stuck one would reorder a customer's visits on the server.
|
||||
q := queue(t, "a", "b", "c")
|
||||
b := &fakeBroker{connected: true, failAfter: 1}
|
||||
p := &Pump{Queue: q, Publisher: b}
|
||||
|
||||
sent, err := p.drainOnce(context.Background())
|
||||
if err == nil {
|
||||
t.Fatal("failure not reported")
|
||||
}
|
||||
if sent != 1 {
|
||||
t.Fatalf("sent %d, want 1 before stopping", sent)
|
||||
}
|
||||
if q.Len() != 2 {
|
||||
t.Fatalf("%d left in queue, want the 2 unsent", q.Len())
|
||||
}
|
||||
// And the survivors are the RIGHT two, still in order.
|
||||
rest, _ := q.Peek(10)
|
||||
if string(rest[0].Payload) != `"b"` {
|
||||
t.Fatalf("queue head is %s, want b", rest[0].Payload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirmedEventsSurviveAMidBatchFailure(t *testing.T) {
|
||||
// Acking per-event rather than per-batch: a batch ack would re-send
|
||||
// everything before the failure after a restart, duplicating footfall.
|
||||
q := queue(t, "a", "b", "c")
|
||||
b := &fakeBroker{connected: true, failAfter: 2}
|
||||
p := &Pump{Queue: q, Publisher: b}
|
||||
p.drainOnce(context.Background())
|
||||
|
||||
if q.Len() != 1 {
|
||||
t.Fatalf("%d left, want only the unsent one", q.Len())
|
||||
}
|
||||
b.failAfter = 0
|
||||
sent, err := p.drainOnce(context.Background())
|
||||
if err != nil || sent != 1 {
|
||||
t.Fatalf("recovery sent %d (%v)", sent, err)
|
||||
}
|
||||
got := b.delivered()
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("delivered %v - duplicates or losses", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRecoversWhenTheBrokerComesBack(t *testing.T) {
|
||||
q := queue(t, "a")
|
||||
b := &fakeBroker{connected: false}
|
||||
p := &Pump{Queue: q, Publisher: b}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go p.Run(ctx)
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
if len(b.delivered()) != 0 {
|
||||
t.Fatal("published while disconnected")
|
||||
}
|
||||
b.mu.Lock()
|
||||
b.connected = true
|
||||
b.mu.Unlock()
|
||||
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if len(b.delivered()) == 1 {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("queue never drained after the broker returned")
|
||||
}
|
||||
|
||||
func TestHeartbeatIsSentSeparatelyFromTheQueue(t *testing.T) {
|
||||
// "Site offline" and "nobody visited" must be distinguishable on the
|
||||
// server. And a heartbeat is only meaningful now, so it is never spooled -
|
||||
// otherwise a reconnecting site replays a week of "I am alive".
|
||||
q := queue(t)
|
||||
b := &fakeBroker{connected: true}
|
||||
p := &Pump{Queue: q, Publisher: b,
|
||||
HeartbeatTopic: "site/alive",
|
||||
HeartbeatPayload: func() []byte { return []byte(`{"up":true}`) },
|
||||
HeartbeatInterval: 20 * time.Millisecond}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go p.Run(ctx)
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
if len(b.delivered()) == 0 {
|
||||
t.Fatal("no heartbeat was sent")
|
||||
}
|
||||
if q.Len() != 0 {
|
||||
t.Fatal("heartbeats were written to the durable queue")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunStopsPromptlyOnCancel(t *testing.T) {
|
||||
q := queue(t)
|
||||
b := &fakeBroker{connected: true}
|
||||
p := &Pump{Queue: q, Publisher: b}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() { p.Run(ctx); close(done) }()
|
||||
cancel()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("Run ignored cancellation")
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- waking
|
||||
|
||||
// The delay this removes is on the path between a person walking in and their
|
||||
// face reaching a screen, so the test asserts a real wall-clock bound rather
|
||||
// than that a channel was read.
|
||||
func TestAWakeDrainsWithoutWaitingOutTheIdleInterval(t *testing.T) {
|
||||
q := queue(t)
|
||||
pub := &fakeBroker{connected: true}
|
||||
waker := NewWaker()
|
||||
p := &Pump{Queue: q, Publisher: pub, Wake: waker.C(),
|
||||
HeartbeatInterval: time.Hour}
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go p.Run(ctx)
|
||||
|
||||
// Let it reach the idle wait with an empty queue first, so what follows is
|
||||
// genuinely the wake path and not the drain it does on startup.
|
||||
waitUntil(t, func() bool { return len(pub.delivered()) == 0 }, time.Second)
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
start := time.Now()
|
||||
if err := q.Append("visit", "e1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waker.Wake()
|
||||
|
||||
waitUntil(t, func() bool { return len(pub.delivered()) == 1 }, 2*time.Second)
|
||||
if took := time.Since(start); took >= idleInterval {
|
||||
t.Fatalf("took %s - the wake did not beat the %s idle tick", took, idleInterval)
|
||||
}
|
||||
}
|
||||
|
||||
// A pump with no waker must behave exactly as it did before: a nil channel
|
||||
// blocks forever in a select, which is the correct fallback, not a hang.
|
||||
func TestAPumpWithNoWakerStillDrainsOnItsTimer(t *testing.T) {
|
||||
q := queue(t)
|
||||
pub := &fakeBroker{connected: true}
|
||||
p := &Pump{Queue: q, Publisher: pub, HeartbeatInterval: time.Hour}
|
||||
|
||||
if err := q.Append("visit", "e1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
go p.Run(ctx)
|
||||
|
||||
waitUntil(t, func() bool { return len(pub.delivered()) == 1 }, 3*time.Second)
|
||||
}
|
||||
|
||||
// The waker runs on the engine's webhook request. If it could ever block, a
|
||||
// burst of arrivals would apply backpressure into the recognition loop.
|
||||
func TestWakingNeverBlocksEvenWithNobodyListening(t *testing.T) {
|
||||
waker := NewWaker()
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
for i := 0; i < 10000; i++ {
|
||||
waker.Wake()
|
||||
}
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("Wake blocked with no pump reading - this would stall recognition")
|
||||
}
|
||||
}
|
||||
|
||||
func TestANilWakerIsSafe(t *testing.T) {
|
||||
var w *Waker
|
||||
w.Wake() // an agent assembled without one must still run
|
||||
if w.C() != nil {
|
||||
t.Fatal("a nil waker handed out a channel")
|
||||
}
|
||||
}
|
||||
|
||||
func waitUntil(t *testing.T, cond func() bool, within time.Duration) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(within)
|
||||
for time.Now().Before(deadline) {
|
||||
if cond() {
|
||||
return
|
||||
}
|
||||
time.Sleep(2 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("condition not met within %s", within)
|
||||
}
|
||||
79
agent/pkg/paths/paths.go
Normal file
79
agent/pkg/paths/paths.go
Normal file
@@ -0,0 +1,79 @@
|
||||
// Package paths mirrors behavision/paths.py.
|
||||
//
|
||||
// The two processes must agree on where state lives or they will quietly use
|
||||
// different databases: the engine would write footfall into one file while the
|
||||
// agent reads another and reports an empty store. The rule is the same on both
|
||||
// sides — BEHAVISION_DATA_DIR wins, then %PROGRAMDATA%\Behavision on Windows —
|
||||
// and `behavision paths` prints the engine's answer so the two can be compared
|
||||
// on a real machine rather than assumed equal.
|
||||
package paths
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
const AppName = "Behavision"
|
||||
|
||||
// StateRoot is the writable root: database, logs, spool, agent config.
|
||||
func StateRoot() string {
|
||||
if v := os.Getenv("BEHAVISION_DATA_DIR"); v != "" {
|
||||
if abs, err := filepath.Abs(v); err == nil {
|
||||
return abs
|
||||
}
|
||||
return v
|
||||
}
|
||||
if runtime.GOOS == "windows" {
|
||||
base := os.Getenv("PROGRAMDATA")
|
||||
if base == "" {
|
||||
base = `C:\ProgramData`
|
||||
}
|
||||
return filepath.Join(base, AppName)
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "."
|
||||
}
|
||||
if runtime.GOOS == "darwin" {
|
||||
return filepath.Join(home, "Library", "Application Support", AppName)
|
||||
}
|
||||
if v := os.Getenv("XDG_DATA_HOME"); v != "" {
|
||||
return filepath.Join(v, "behavision")
|
||||
}
|
||||
return filepath.Join(home, ".local", "share", "behavision")
|
||||
}
|
||||
|
||||
// InstallRoot is the directory holding this executable.
|
||||
func InstallRoot() string {
|
||||
exe, err := os.Executable()
|
||||
if err != nil {
|
||||
return "."
|
||||
}
|
||||
if resolved, err := filepath.EvalSymlinks(exe); err == nil {
|
||||
exe = resolved
|
||||
}
|
||||
return filepath.Dir(exe)
|
||||
}
|
||||
|
||||
func AgentConfig() string { return filepath.Join(StateRoot(), "agent.json") }
|
||||
func SpoolDir() string { return filepath.Join(StateRoot(), "spool") }
|
||||
func EngineLog() string { return filepath.Join(StateRoot(), "engine.log") }
|
||||
|
||||
// APICredentials is the file the engine writes when it generates its own
|
||||
// Basic credentials. The agent reads it rather than storing a second copy,
|
||||
// so a regenerated credential does not silently break the tray.
|
||||
func APICredentials() string {
|
||||
return filepath.Join(StateRoot(), "data", "api_credentials.txt")
|
||||
}
|
||||
|
||||
// EnsureState creates the writable tree. Called before anything opens a file
|
||||
// under it, so a first run on a fresh machine does not fail on a missing dir.
|
||||
func EnsureState() error {
|
||||
for _, d := range []string{StateRoot(), SpoolDir()} {
|
||||
if err := os.MkdirAll(d, 0o700); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
43
agent/pkg/paths/paths_test.go
Normal file
43
agent/pkg/paths/paths_test.go
Normal file
@@ -0,0 +1,43 @@
|
||||
package paths
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDataDirEnvWins(t *testing.T) {
|
||||
// The override is what lets one machine run two instances, and what makes
|
||||
// the installed layout testable from a checkout - on both sides.
|
||||
dir := t.TempDir()
|
||||
t.Setenv("BEHAVISION_DATA_DIR", dir)
|
||||
if got := StateRoot(); got != dir {
|
||||
t.Fatalf("StateRoot() = %q, want %q", got, dir)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEverythingLivesUnderTheStateRoot(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("BEHAVISION_DATA_DIR", dir)
|
||||
for name, got := range map[string]string{
|
||||
"agent config": AgentConfig(),
|
||||
"spool": SpoolDir(),
|
||||
"engine log": EngineLog(),
|
||||
"credentials": APICredentials(),
|
||||
} {
|
||||
if !strings.HasPrefix(got, dir) {
|
||||
t.Errorf("%s resolved outside the state root: %s", name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureStateIsIdempotent(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "fresh")
|
||||
t.Setenv("BEHAVISION_DATA_DIR", dir)
|
||||
if err := EnsureState(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := EnsureState(); err != nil {
|
||||
t.Fatalf("second call failed: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user