Compare commits
15 Commits
main
...
999a1510d4
| Author | SHA1 | Date | |
|---|---|---|---|
| 999a1510d4 | |||
| 6f7c90cc98 | |||
| c0f7e662be | |||
| 11120bedca | |||
| ac24011487 | |||
| 73fcbe7b28 | |||
| 113e5a0fe8 | |||
| 04d31a15c4 | |||
| b0ac044035 | |||
| 3fc7c1b546 | |||
| 206d5b541d | |||
| f7f297c3df | |||
| 446de7f1ee | |||
| 901b87d407 | |||
| 8f6dbcba49 |
+13
@@ -12,3 +12,16 @@ resources
|
||||
.iocsh_history
|
||||
go.work.sum
|
||||
*.log
|
||||
|
||||
# Test output
|
||||
coverage.out
|
||||
*.coverprofile
|
||||
|
||||
# SQLite runtime databases (sidecar WAL/SHM)
|
||||
*.db-shm
|
||||
*.db-wal
|
||||
|
||||
# uopi dev runtime storage — ignore generated state, keep curated fixtures
|
||||
workspace/data/*
|
||||
!workspace/data/demo.xml
|
||||
!workspace/data/epics_test.xml
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
.PHONY: all frontend backend backend-debug release catools test bench race lint clean run
|
||||
.PHONY: all frontend backend backend-debug backend-pam release catools test cover bench race lint fmt clean run
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Sources — adding any file here triggers a frontend or backend rebuild #
|
||||
@@ -51,6 +51,14 @@ $(CATOOLS): $(GO_SRCS)
|
||||
@mkdir -p dist
|
||||
CGO_ENABLED=0 go build -ldflags="-s -w" -o $(CATOOLS) ./cmd/catools
|
||||
|
||||
# PAM-enabled binary: adds built-in HTTP Basic authentication validated against
|
||||
# the host PAM stack (server.basic_auth). Requires cgo + libpam (-dev headers),
|
||||
# so the resulting binary is NOT fully static — use only when basic_auth is needed.
|
||||
backend-pam: $(FRONTEND_OUT)
|
||||
@mkdir -p dist
|
||||
CGO_ENABLED=1 go build -tags pam -ldflags="-s -w" -o $(BINARY) ./cmd/uopi
|
||||
CGO_ENABLED=0 go build -ldflags="-s -w" -o $(CATOOLS) ./cmd/catools
|
||||
|
||||
# Unstripped binary for debugging (pure-Go CA, CGO_ENABLED=0)
|
||||
backend-debug: $(FRONTEND_OUT)
|
||||
@mkdir -p dist
|
||||
@@ -81,11 +89,18 @@ release: $(FRONTEND_OUT)
|
||||
test:
|
||||
go test ./...
|
||||
cd pkg/ca && go test ./...
|
||||
cd pkg/pva && go test ./...
|
||||
|
||||
# Run tests with the race detector enabled.
|
||||
race:
|
||||
go test -race ./...
|
||||
cd pkg/ca && go test -race ./...
|
||||
cd pkg/pva && go test -race ./...
|
||||
|
||||
# Run the main module's tests with coverage and print the total.
|
||||
cover:
|
||||
go test -coverprofile=coverage.out ./...
|
||||
go tool cover -func=coverage.out | tail -1
|
||||
|
||||
# Run all benchmarks and print memory allocations.
|
||||
bench:
|
||||
@@ -94,6 +109,10 @@ bench:
|
||||
lint:
|
||||
go vet ./...
|
||||
|
||||
# Rewrite all Go sources in canonical gofmt form (matches the CI gofmt gate).
|
||||
fmt:
|
||||
gofmt -w $(shell git ls-files '*.go')
|
||||
|
||||
run: $(BINARY)
|
||||
$(BINARY)
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ uopi runs as a **single portable binary** with no runtime dependencies. Point an
|
||||
| **Plot panels** | Dedicated chart panels with a recursive split layout (tmux/IDE-style) where plots fill the viewport |
|
||||
| **Panel logic** | In-editor node-graph flow editor (triggers → actions, dialogs) that runs client-side in view mode |
|
||||
| **Control logic** | Server-side always-on flow graphs with cron/alarm triggers and Lua blocks |
|
||||
| **Configuration manager** | Versioned configuration **sets** (typed signal schemas, grouped) and **instances** (values) with validation, array/CSV editing, apply-to-signals, and JSON import/export |
|
||||
| **Version history & diff** | Git-style history for panels, synthetic signals, control logic and config sets/instances: view, fork, promote any revision, with unified / side-by-side diff |
|
||||
| **Local variables** | Panel-scoped state variables for set-points, toggles and counters used by logic |
|
||||
| **Historical data** | EPICS Archive Appliance integration via REST; time range picker in UI |
|
||||
| **Synthetic signals** | Compose, filter, and transform signals via a wizard or visual node-graph editor (gain, offset, moving average, lowpass, highpass, derivative, integral, clamp, formula); panel/user/global visibility scopes |
|
||||
@@ -138,12 +140,20 @@ The REST API is available under `/api/v1`. Key endpoints:
|
||||
| `POST` | `/api/v1/interfaces/{id}/clone` | Duplicate an interface |
|
||||
| `POST` | `/api/v1/interfaces/reorder` | Reorder panels / move them between folders |
|
||||
| `GET/PUT` | `/api/v1/interfaces/{id}/acl` | Read or set a panel's sharing rules |
|
||||
| `GET` | `/api/v1/interfaces/{id}/versions` | List saved versions of a panel |
|
||||
| `GET/POST` | `/api/v1/folders` | List or create panel folders |
|
||||
| `PUT/DELETE` | `/api/v1/folders/{id}` | Rename/reparent or delete a folder |
|
||||
| `GET/POST/DELETE` | `/api/v1/synthetic` | Manage synthetic signal definitions |
|
||||
| `GET/POST` | `/api/v1/controllogic` | List or create server-side control-logic graphs |
|
||||
| `GET/PUT/DELETE` | `/api/v1/controllogic/{id}` | Read, update, or delete a control-logic graph |
|
||||
| `GET/POST` | `/api/v1/config/sets` | List or create configuration sets (schemas) |
|
||||
| `GET/PUT/DELETE` | `/api/v1/config/sets/{id}` | Read, update, or delete a config set |
|
||||
| `GET/POST` | `/api/v1/config/instances` | List or create configuration instances (values) |
|
||||
| `GET/PUT/DELETE` | `/api/v1/config/instances/{id}` | Read, update, or delete a config instance |
|
||||
| `POST` | `/api/v1/config/instances/{id}/apply` | Write an instance's values to their target signals |
|
||||
| `GET` | `/api/v1/config/{sets\|instances}/diff` | Structural diff between two revisions |
|
||||
| `GET` | `/api/v1/{interfaces\|synthetic\|controllogic\|config/sets\|config/instances}/{id}/versions` | List revisions; `…/{version}` fetches one |
|
||||
| `POST` | `…/{id}/versions/{v}/promote` | Promote a revision to current |
|
||||
| `POST` | `…/{id}/versions/{v}/fork` | Fork a revision into a new document |
|
||||
| `GET` | `/api/v1/me` | Caller identity, access level, groups, logic-edit permission |
|
||||
| `GET` | `/api/v1/usergroups` | List configured users and groups (for sharing) |
|
||||
| `GET` | `/metrics` | Prometheus-format server metrics |
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
# TODO
|
||||
|
||||
- **BUG FIX**:
|
||||
- [x] connecting from firefox always get default user — native SPNEGO/Kerberos auth (`[server.kerberos]`, `internal/server/kerberos.go`): uopi challenges with `401 Negotiate` and resolves the user from the validated ticket instead of relying on a proxy header Firefox never triggers; on non-Kerberos hosts (SSSD/LDAP) use standalone built-in HTTP Basic auth — either validated via PAM (`[server.basic_auth]`, `internal/pamauth`, build with `make backend-pam`) or, keeping the static binary, pure-Go LDAP search-then-bind (`[server.ldap]`, `internal/ldapauth`) — sharing `internal/server/basicauth.go` (which also challenges the page load so the browser shows its login dialog) with optional built-in TLS (`[server.tls]`)
|
||||
- [x] dpi scaling seems not to work on firefox — root font-size now folds in `window.devicePixelRatio` (`applyZoom` = 16×zoom×dpr) with `watchDpr()` re-applying on monitor change
|
||||
- [ ] **MAJOR** Implement configuration manager:
|
||||
- configuration is a set of signals (that can be organized in group and subgroup) that are used to configure a system or a sub system
|
||||
- with configuration manager user should be able to:
|
||||
- Define and manage configuration sets: delete (keep server side copy of deleted config), create (specifying default to parameters, mandatory, optional etc), edit (with versioning git style) and compare set
|
||||
- user can fork an existing config instance to create a new one
|
||||
- user can compare two instances: show unified diff or side by side diff
|
||||
- etc...
|
||||
- Create, delete (keep server cache), edit (git style versioning) and compare configuration instances: meaning set actual value to a configuration set
|
||||
- user can fork an existing config instance to create a new one
|
||||
- user can compare two instances: show unified diff or side by side diff
|
||||
- etc...
|
||||
- [x] user can apply and save configuration instances from manager
|
||||
- [x] **instance snapshot**: create a new instance from the current live value of all of a set's target signals (optional label, else auto name) — exposed in the config editor (⎙ Snapshot), control loop (`action.config.snapshot`) and logic editor (`action.config.snapshot`)
|
||||
- [x] **live diff**: compare a stored instance against current signal values ("Diff vs current" button → `/config/instances/{id}/livediff`)
|
||||
- [x] git-style versioning for config sets and instances: fork any revision, click to view, promote to current, with unified / side-by-side diff (shared `VersionTree`)
|
||||
- [x] in logic editor and control loop add nodes to read/write/create/apply config instances
|
||||
- [x] **apply** and **read** config-instance nodes in both control logic (server Go) and panel logic (client TS)
|
||||
- [x] **create / write** nodes (mutate versioned instances from automation) in both engines
|
||||
- [x] **Config Selector** panel widget: operator picks an instance of a chosen set (optionally a subset) from a combo, writing its id to a panel-local string variable; apply/read/write panel-logic nodes can read their instance dynamically from that variable ("From variable" source)
|
||||
- [x] support for all supported types (number, bools, enums, arrays, string etc)
|
||||
- [x] ux elements for config set
|
||||
- [x] the configuration editor should automatically get type and info from signal when possible (type is read-only, auto-derived from the bound signal)
|
||||
- [x] a slick tree drag and drop editor to order elements and organize in group and sub-groups
|
||||
- [x] possibility to customise unit, max, min etc
|
||||
- [x] export / import a config set as JSON
|
||||
- [x] full-screen config window
|
||||
- [x] ux elements for config instances:
|
||||
- automatically use the correct setting widget depending on the type (e.g. combo for enum, nubmer input for number):
|
||||
- for arrays: import csv option, display points as mini plot (+ open dialog plot with more info), manual enter points via table like interface
|
||||
- automatically fill with default or existing values (when forking an existing instance)
|
||||
- explicity show errors/missing info and give additional info on hover
|
||||
- [x] add advanced validation / transformation framework to configurations using custom CUE rules:
|
||||
- [x] backend runs validation / transformation rules (real CUE via `cuelang.org/go`; `internal/confmgr/cue.go`). Rules are a third versioned `Kind` bound to a set; evaluated on instance create/update — violations block the save, concrete derivations transform & persist the values
|
||||
- [x] user can create/edit/delete/compare rules from webui (Rules tab in ConfigManager; git-style versioning + side-by-side/unified source diff)
|
||||
- [x] integrate syntax highlight (hand-rolled `CueEditor.tsx`, mirroring `LuaEditor`)
|
||||
- [x] integrate autocomplete for signals names and cue grammars (param keys + target signals + CUE keywords/types; Ctrl+Space)
|
||||
- [ ] ~~integrate cue lsp~~ — descoped: a separate language-server process does not fit the no-npm / single portable-binary architecture
|
||||
- [x] when validation fails the error is propagated to the user (live `/config/rules/check` panel while editing; save returns the structured violation)
|
||||
- [x] all actions tracked via history (versioned rules) + audit (`config.rule.create/update/delete/promote/fork`)
|
||||
- [ ] Improve UX:
|
||||
- Config editor:
|
||||
- [x] Instance should be filtered by config set (combo box) (`ConfigManager.tsx` InstancesManager: `setFilter` combo in the instance list head, shown when >1 set; empty-set hint)
|
||||
- [x] Validation rules should also be filtered by config set (combo box) (`ConfigManager.tsx` RulesManager: `setFilter` combo mirroring instances, shown when >1 set; empty-set hint; `Meta.setId` surfaced via `List`)
|
||||
- [x] Validation rules can be enabled / disabled — disabled rules are skipped when an instance is created/updated/applied (`Enabled *bool`, nil=enabled for legacy; `IsEnabled()`; `rulesForSet` skips disabled; UI checkbox + list `off` badge)
|
||||
- [x] Rule editor preview button — runs the working (unsaved) source against a live signal snapshot of the set without persisting it, showing the input snapshot JSON and processed output JSON (`POST /config/rules/preview` → `previewConfigRule`; `RulePreviewView`)
|
||||
- [ ] Synthetic editor:
|
||||
- [x] color code the node link by type
|
||||
- [x] edges color depends on type (e.g. float) including arrays (scalar = slate, array = purple)
|
||||
- [x] input / outputs are color coded (out port = node's type; in port = accepted type, gray when it accepts either)
|
||||
- [x] can not connect input / output that are not compatible (definite scalar↔array mismatch blocked; unknown/any always allowed)
|
||||
- [x] hover on a block in error should show the reason
|
||||
- [x] add proper array functionality
|
||||
- [x] add shortcut to add Signals (S) and nodes (N) with HUD
|
||||
- [ ] Panels:
|
||||
- [x] in view mode the widgets should have no border/bg but blend with background
|
||||
- [ ] add widgets such as toggle switch, table and other industrial hmi widgets
|
||||
- [x] toggle switch (`web/src/widgets/Toggle.tsx`; read+write bool control, configurable on/off value+label, confirm dialog)
|
||||
- [x] table widget (`web/src/widgets/TableWidget.tsx`; multi-signal value table, one row per bound signal, configurable columns name/value/unit/status/time, optional header + title, per-signal value format + row-label overrides; blends in view mode)
|
||||
- [ ] other industrial hmi widgets
|
||||
- [x] widget panel exposes an editable Widget ID field (default = generated uuid) for easier logic interaction (`PropertiesPane.tsx` `IdInput`; `EditMode.renameWidgetId` propagates the rename to `action.widget` logic refs + plot-layout leaves; rejects empty/duplicate ids)
|
||||
- [x] add container widgets: labelled/title pane, tab panes, collapsable panes (`web/src/widgets/Container.tsx`; decorative grouping frame rendered behind widgets, accent/bg options; `pane` variant = title + view-mode collapse; `tabs` variant = tab bar where each contained widget is assigned a tab via its "Tab" field and only the active tab shows; geometric membership via `web/src/lib/containers.ts`)
|
||||
- [x] moving container widget should move the widgets on it — starting a move (drag or arrow nudge) on a container snapshots the widgets geometrically inside it (centre-inside, transitive through nested containers) and moves them by the same delta; membership is captured at move start so widgets never re-parent mid-move (`withContainedWidgets` in `web/src/lib/containers.ts`, used by `EditCanvas` drag-start + `EditMode` arrow nudge)
|
||||
- [x] plot pane:
|
||||
- [x] add toolbar (hover toolbar in `PlotWidget`, `.plot-toolbar`)
|
||||
- [x] add time window selector to toolbar (Auto/15s/30s/1m/5m/15m/1h rolling-window override, live windowed plot types)
|
||||
- [x] plot x-axis synchronised (shared uPlot cursor crosshair across all linked timeseries plots + zoom/pan range sync in historical mode; per-plot 🔗 link/unlink toolbar toggle; `web/src/lib/plotSync.ts`)
|
||||
- [x] cursors and measuraments (📏 measure mode: click cursor A then B → on-canvas A/B lines + readout panel with Δt/rate and per-signal value@A→value@B and Δ)
|
||||
- [x] pause/resume (local toolbar pause, OR'd with panel-logic pause; buffers persist across plot-type/window changes)
|
||||
- [x] save screenshot (PNG export — uPlot canvas / ECharts getDataURL)
|
||||
- [x] clean ui:
|
||||
- [x] create small statusbar where connection widget and other status related info will be placed (bottom `.statusbar`: connection chip + current panel name + history indicator)
|
||||
- [x] group advanced items (audit/control loops/config manager etc) in a tool menu or something similar to not fill the toolbar (⋯ Tools ▾ dropdown)
|
||||
- [x] make the toolbar as clean as possible (toolbar-right now: History · 📖 · zoom · Tools · Edit)
|
||||
- [x] panel-selection list: the whole row is clickable to open a panel (not just the name text); `onClick` moved to the `<li>`, action buttons `stopPropagation` (`InterfaceList.tsx`)
|
||||
- [x] panel-selection list: plot vs panel entries shown with distinguishing monochrome inline-SVG icons (line-chart = plot / control-panel = HMI; `KindIcon`, currentColor); backend `InterfaceMeta.Kind` surfaced from the XML `kind` attr, `InterfaceListItem.kind` in the frontend type (preserved through the list normalizer)
|
||||
- [x] implement proper grouping strategy, in all selector tree the options should be filtered by user (private config), group (combo to select group if user in multiple groups), global (public config). Uniform scope model: each item carries owner + scope ∈ {private,group,global} + scope-groups; empty/unknown scope = global (legacy-safe). Shared Go helper `access.CanSee` (`internal/access/scope.go`) filters list endpoints by the caller; shared frontend `web/src/lib/scope.tsx` provides `bucketOf`/`filterByScope`, the segmented `[Mine | Group ▾ | Global]` `ScopeFilter`, and the `ScopePicker` create/save visibility editor. Visibility is a selector filter, not a hard security boundary (owner always sees own items)
|
||||
- [x] panel tree by user / group / global — scope bucket derived server-side from the panel ACL (`panelScope` in `internal/api/api.go`: public→global, group grant→group, else→private; unmanaged→global), surfaced on `InterfaceListItem.scope`/`groups`; `InterfaceList.tsx` filters panels by bucket and hides folders with no in-scope descendants; visibility is edited via the existing Share dialog
|
||||
- [x] signal tree by user / group / global — synthetic `SignalDef` gained a `group` visibility mode + `Groups[]`; `synVisible` (`api.go`) routes it through `access.CanSee`; `SyntheticGraphEditor.tsx` wizard offers panel/user/group/global with group checkboxes
|
||||
- [x] config tree by user / group / global — `ConfigSet`/`ConfigInstance` carry owner+scope+groups (`internal/confmgr`), list endpoints filter via `filterConfigMetas`/`CanSee`, owner preserved across updates; `ConfigManager.tsx` adds `ScopeFilter` + `ScopePicker` to both Sets and Instances managers
|
||||
- [x] control sequence by user / group / global — control-logic `Graph` gained owner+scope+scopeGroups (`internal/controllogic/model.go`; named `scopeGroups` to avoid the existing cosmetic `Groups []NodeGroup`); `listControlLogic` filters via `CanSee`, create/update stamp/preserve owner; `ControlLogicEditor.tsx` adds `ScopeFilter` + `ScopePicker` (`filterByScope(..., g => g.scopeGroups ?? [])`)
|
||||
- [ ] Node editors:
|
||||
- [x] In all editors, implement node grouping and collapsing feature (with optional group label) (Shift+click multi-select → G/⊞ to group; editable label; ▾/▸ collapse to a compact box that reroutes crossing wires; Delete ungroups; shared `web/src/lib/nodeGroups.ts`; persisted in panel-logic XML + control-logic/synthetic JSON via Go `NodeGroup` structs — cosmetic editor metadata, ignored at eval)
|
||||
- [x] opened group should be on top of other nodes (group frame lifted to z-index 1 above loose nodes; member nodes get `.flow-node-grouped` at z-index 2 so they stay above their own frame and clickable; applied in all three editors)
|
||||
- [x] node counter should be better padded (`.flow-group-count` gained vertical padding so the "N nodes" line isn't cramped against the collapsed-box header)
|
||||
- [x] In all editors: undo / redo + copy / paste with shortcuts (Ctrl+Z / Ctrl+Shift+Z / Ctrl+Y undo-redo; Ctrl+C / Ctrl+V copy-paste of the selection, multi-select aware, internal wires preserved, ids remapped, pasted +offset; ref-based 50-deep history + clipboard scoped per editor; toolbar ↩/↪ buttons). Panel `LogicEditor` already had this; added to `ControlLogicEditor` (undo/redo + copy/paste) and `SyntheticGraphEditor` (copy/paste — it already had undo/redo). Synthetic paste never copies the permanent output node
|
||||
- [x] In all editors: implement zoom in / out, home zoom and fit zoom to help user navigate complex graph (shared `web/src/lib/flowZoom.ts` `useFlowZoom` hook; CSS `transform: scale()` on a `.flow-canvas-zoom` layer inside the scaled `.flow-canvas-inner`; toolbar row −/%/+/⤢ in all three editors; `toCanvas` divides pointer offsets by zoom)
|
||||
- [x] fix: the fit-zoom (⤢) button was clipped outside the fixed-width palette — toolbar buttons now shrink to fit (`min-width:0`, zero side padding in `.flow-palette-toolbar .toolbar-btn`)
|
||||
- [x] fix: zoom value (e.g. 100%) is overflowing, reduce padding and increase the zoom value widget width (`flow-zoom-pct` class on the zoom-value button → `flex:1.9` so it gets ~2× the width of the single-glyph icon buttons; toolbar font dropped to 0.75rem, gap to 0.25rem, `tabular-nums` to stop width jitter; all three editors)
|
||||
- [x] Live / debug mode in all three node editors — evaluate the graph online and badge each node's value at human speed (~0.5 s). Shared frontend `web/src/lib/flowDebug.ts` (badge/active classes, `useFlowDebug` poller) + CSS `.flow-node-active`/`.flow-node-badge`/`.flow-wire-active`; a green ◉ toggle in each `.flow-palette-toolbar`. Per-editor backends:
|
||||
- [ ] Syntetic signal editor:
|
||||
- [x] Implement live / debug mode where value are evaluated online and visualized at human speed (e.g. 0.5s) — server-side stateless single-shot trace: `POST /api/v1/synthetic/trace` snapshots live source signals (broker `ReadNow`) and runs `evalSampleTrace` with fresh state, returning every node's value; stateful ops (moving_average/rms/lowpass/derivative/integrate/lua) badged `~approx`
|
||||
- [ ] Logic editor:
|
||||
- [x] Implement live / debug mode where value are evaluated online and visualized at human speed (e.g. 0.5s) — editor spins up a second client-side `LogicEngine` in new `dryRun` mode (real writes/config/dialogs suppressed) loaded with the edited graph; per-node values polled into the shared badges; the view-mode singleton is untouched
|
||||
- add full suppor to local array values: dynamic, dynamic but capped max, fixed size etc:
|
||||
- array functions should work with new local array
|
||||
- [ ] Control loop:
|
||||
- [x] Implement live / debug mode where value are evaluated online and visualized at human speed (e.g. 0.5s) — server pushes per-node events over the WS (`debugSubscribe`/`debugNode`) via a new `DebugObserver` on the engine (lock-free `atomic.Value`, gated by a per-graph watch set) + `internal/server/debughub.go` (drop-on-full fan-out). Two modes share one message shape: **Live** observes the running enabled graph; **Simulate** dry-runs the unsaved edits in a throwaway sandbox (`StartSimulate`, side effects suppressed). Live/Sim sub-toggle in the editor
|
||||
- add full support to server side array values
|
||||
- [x] Implement git style versioning for: synthetic variable, panels, control logic:
|
||||
- [x] possibility to fork any version
|
||||
- [x] click to view the version
|
||||
- [x] possibility to view graphical diff between versions (side by side or unified diff)
|
||||
- [x] simple slick versioning pane:
|
||||
- [x] vertical tree like
|
||||
- [x] each version represented by a circle
|
||||
- [x] active (the one currently view/edited) version has circle bigger then rest
|
||||
- [x] selected (the one that will be executed/showed by user) version has circle full, not active only border
|
||||
- [x] unsaved / new version appear with connection line dashed
|
||||
- [x] Implement admin pane: create / manage groups, set users permits, manage auditors etc
|
||||
- [x] user manager
|
||||
- [x] group manager
|
||||
- [x] server statistics: load, conenctions, observed signals, average latency, other statistics
|
||||
- [ ] Implement new datasources:
|
||||
- [ ] Finalize alarm service
|
||||
- [ ] modbus tcp
|
||||
- [ ] scpi tcp / VXI-11 protocol
|
||||
- [ ] udp? other?
|
||||
- [ ] **MAJOR** Implement proper distributed server side nodes to balance load and have redundancy (if a node is not available anymore all its clients migrate seamelessly to another)
|
||||
- [ ] clients should be distributed to balance load\
|
||||
- same user should be (if possible) connect to only one service to simplify synch issue
|
||||
- [ ] control sequences should be executed in only in one server instance but with backups ones to take over if the active service stop or die
|
||||
- [ ] sync config between service
|
||||
- [ ] manage conflict
|
||||
- [ ] avoid incorrect
|
||||
- [ ] ensure that the system can survive with up to only one instance alive
|
||||
- [ ] advance admin panel:
|
||||
- [ ] should have info about service topology and statistics of each server
|
||||
- [ ] admin should deploy new instances via ssh to target machines directly from the admin panel
|
||||
- [ ] for phisical connect datasource (e.g. modbus) pin the service to a specific machine or deploy specific datasource only service to a machine: user can setup backup instances (e.g. machine in the same sub-network that can be switched on in case primary fail)
|
||||
- [ ] QA and CI
|
||||
- [ ] coverage of service 90+%
|
||||
- [ ] coverage of client 80+%
|
||||
- [ ] add integrated tests with interface simulations
|
||||
- [ ] update doc and keep user manual up to date
|
||||
- [ ] add code example for lua scripts
|
||||
- [ ] add tutorial
|
||||
+174
-11
@@ -9,16 +9,24 @@ import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"os/user"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
|
||||
"github.com/jcmturner/gokrb5/v8/keytab"
|
||||
"github.com/uopi/uopi/internal/access"
|
||||
"github.com/uopi/uopi/internal/audit"
|
||||
"github.com/uopi/uopi/internal/broker"
|
||||
"github.com/uopi/uopi/internal/config"
|
||||
"github.com/uopi/uopi/internal/confmgr"
|
||||
"github.com/uopi/uopi/internal/controllogic"
|
||||
"github.com/uopi/uopi/internal/datasource/epics"
|
||||
"github.com/uopi/uopi/internal/datasource/pva"
|
||||
"github.com/uopi/uopi/internal/datasource/servervar"
|
||||
"github.com/uopi/uopi/internal/datasource/stub"
|
||||
"github.com/uopi/uopi/internal/datasource/synthetic"
|
||||
"github.com/uopi/uopi/internal/ldapauth"
|
||||
"github.com/uopi/uopi/internal/pamauth"
|
||||
"github.com/uopi/uopi/internal/panelacl"
|
||||
"github.com/uopi/uopi/internal/server"
|
||||
"github.com/uopi/uopi/internal/storage"
|
||||
@@ -41,6 +49,16 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// In an unproxied/local deployment (no trusted_user_header) without an explicit
|
||||
// default_user, attribute actions to the OS user running the process so the UI
|
||||
// and audit log show a real identity instead of anonymous.
|
||||
if cfg.Server.TrustedUserHeader == "" && cfg.Server.DefaultUser == "" {
|
||||
if u, err := user.Current(); err == nil && u.Username != "" {
|
||||
cfg.Server.DefaultUser = u.Username
|
||||
log.Info("no default_user configured; defaulting to OS user", "user", u.Username)
|
||||
}
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(cfg.Server.StorageDir, 0o755); err != nil {
|
||||
log.Error("failed to create storage dir", "dir", cfg.Server.StorageDir, "err", err)
|
||||
os.Exit(1)
|
||||
@@ -58,6 +76,12 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
cfgStore, err := confmgr.New(cfg.Server.StorageDir)
|
||||
if err != nil {
|
||||
log.Error("failed to open configuration store", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
webFS, err := fs.Sub(web.FS, "dist")
|
||||
if err != nil {
|
||||
log.Error("failed to sub web dist", "err", err)
|
||||
@@ -122,18 +146,60 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Build the global access policy from config: every user is trusted with
|
||||
// full write access unless downgraded by the blacklist; groups are named
|
||||
// sets of users referenced by per-panel sharing.
|
||||
blacklist := make(map[string]string, len(cfg.Server.Blacklist))
|
||||
for _, e := range cfg.Server.Blacklist {
|
||||
blacklist[e.User] = e.Level
|
||||
// Server variables: a small persistent key/value source ("srv") that the
|
||||
// control-logic engine writes (e.g. sequence state) and panels can read.
|
||||
srvVars, err := servervar.New(cfg.Server.StorageDir)
|
||||
if err != nil {
|
||||
log.Error("server variables init", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
groups := make(map[string][]string, len(cfg.Groups))
|
||||
brk.Register(srvVars)
|
||||
|
||||
// Build the role-based access policy from config: each [[groups]] entry lists
|
||||
// members by role (viewer/operator/logiceditor/auditor/admin) and an optional
|
||||
// parent for nesting. Every user is an implicit viewer of the built-in public
|
||||
// group; a config with no roles at all is fully open (everyone admin).
|
||||
specs := make([]access.GroupSpec, 0, len(cfg.Groups))
|
||||
for _, g := range cfg.Groups {
|
||||
groups[g.Name] = g.Members
|
||||
members := make(map[string]access.Role)
|
||||
assign := func(users []string, role access.Role) {
|
||||
for _, u := range users {
|
||||
members[u] = role
|
||||
}
|
||||
}
|
||||
assign(g.Viewers, access.RoleViewer)
|
||||
assign(g.Operators, access.RoleOperator)
|
||||
assign(g.LogicEditors, access.RoleLogic)
|
||||
assign(g.Auditors, access.RoleAuditor)
|
||||
assign(g.Admins, access.RoleAdmin)
|
||||
specs = append(specs, access.GroupSpec{Name: g.Name, Parent: g.Parent, Members: members})
|
||||
}
|
||||
policy := access.New(cfg.Server.DefaultUser, specs)
|
||||
// Once enabled, runtime admin-pane changes persist to {storage_dir}/access.json,
|
||||
// which (when present on a later startup) supersedes the TOML access config.
|
||||
if err := policy.EnablePersistence(cfg.Server.StorageDir); err != nil {
|
||||
log.Error("failed to load access store", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Audit log: when enabled, every system-affecting action (user/automated
|
||||
// signal writes, interface and control-logic mutations) is recorded to SQLite.
|
||||
// When disabled a no-op recorder is used so call sites need no guards.
|
||||
recorder := audit.Nop()
|
||||
if cfg.Audit.Enabled {
|
||||
dbPath := cfg.Audit.DBPath
|
||||
if dbPath == "" {
|
||||
dbPath = filepath.Join(cfg.Server.StorageDir, "audit.db")
|
||||
}
|
||||
r, err := audit.NewSQLite(dbPath, log)
|
||||
if err != nil {
|
||||
log.Error("failed to open audit log", "path", dbPath, "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer r.Close()
|
||||
recorder = r
|
||||
log.Info("audit log enabled", "path", dbPath)
|
||||
}
|
||||
policy := access.New(cfg.Server.DefaultUser, blacklist, groups, cfg.Server.LogicEditors)
|
||||
|
||||
// Server-side control logic: flow graphs that run continuously under the root
|
||||
// context, independent of any panel. The store is loaded from disk and the
|
||||
@@ -143,10 +209,107 @@ func main() {
|
||||
log.Error("failed to open control logic store", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
ctrlEngine := controllogic.NewEngine(ctx, brk, ctrlStore, log)
|
||||
ctrlEngine := controllogic.NewEngine(ctx, brk, ctrlStore, cfgStore, recorder, log)
|
||||
|
||||
// Dialog hub: control-logic action.dialog nodes push notifications/inputs to
|
||||
// connected clients (filtered by user/group) and route input responses back
|
||||
// to server variables. Installed before Reload so running graphs can emit.
|
||||
dialogs := server.NewDialogHub(brk, policy, recorder, log)
|
||||
ctrlEngine.SetNotifier(dialogs)
|
||||
|
||||
// Debug hub: control-logic editors observe live graphs or dry-run unsaved
|
||||
// edits; the engine pushes per-node execution events through it.
|
||||
debugHub := server.NewDebugHub(ctrlEngine, log)
|
||||
ctrlEngine.SetDebugObserver(debugHub)
|
||||
ctrlEngine.Reload()
|
||||
|
||||
srv := server.New(cfg.Server.Listen, webFS, brk, synthDS, store, policy, aclStore, ctrlStore, ctrlEngine, cfg.Datasource.EPICS.ChannelFinderURL, cfg.Datasource.EPICS.ArchiveURL, cfg.Server.TrustedUserHeader, log)
|
||||
// Native SPNEGO/Kerberos authentication (optional): load the service keytab so
|
||||
// the server can validate browser Negotiate tickets and resolve users directly.
|
||||
var krbKeytab *keytab.Keytab
|
||||
if cfg.Server.Kerberos.Enabled {
|
||||
if cfg.Server.Kerberos.Keytab == "" {
|
||||
log.Error("kerberos enabled but no keytab configured (server.kerberos.keytab)")
|
||||
os.Exit(1)
|
||||
}
|
||||
kt, err := keytab.Load(cfg.Server.Kerberos.Keytab)
|
||||
if err != nil {
|
||||
log.Error("failed to load kerberos keytab", "path", cfg.Server.Kerberos.Keytab, "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
krbKeytab = kt
|
||||
log.Info("native SPNEGO/Kerberos authentication enabled",
|
||||
"keytab", cfg.Server.Kerberos.Keytab, "service_principal", cfg.Server.Kerberos.ServicePrincipal)
|
||||
}
|
||||
|
||||
// Built-in HTTP Basic authentication (optional): challenge the browser with a
|
||||
// login dialog and validate credentials against either the host PAM stack
|
||||
// (basic_auth) or an LDAP directory (ldap), so users log in with their normal
|
||||
// accounts. The three built-in auth methods (kerberos, basic_auth, ldap) are
|
||||
// mutually exclusive.
|
||||
var basicAuthFn func(user, pass string) error
|
||||
var basicAuthRealm string
|
||||
enabledAuth := 0
|
||||
for _, on := range []bool{cfg.Server.Kerberos.Enabled, cfg.Server.BasicAuth.Enabled, cfg.Server.LDAP.Enabled} {
|
||||
if on {
|
||||
enabledAuth++
|
||||
}
|
||||
}
|
||||
if enabledAuth > 1 {
|
||||
log.Error("server.kerberos, server.basic_auth and server.ldap are mutually exclusive; enable only one")
|
||||
os.Exit(1)
|
||||
}
|
||||
if cfg.Server.BasicAuth.Enabled {
|
||||
if !pamauth.Available {
|
||||
log.Error("basic_auth enabled but this binary lacks PAM support; rebuild with: make backend-pam (or use server.ldap, which needs no cgo)")
|
||||
os.Exit(1)
|
||||
}
|
||||
service := cfg.Server.BasicAuth.PAMService
|
||||
if service == "" {
|
||||
service = "uopi"
|
||||
}
|
||||
basicAuthFn = func(user, pass string) error {
|
||||
return pamauth.Authenticate(service, user, pass)
|
||||
}
|
||||
basicAuthRealm = "uopi"
|
||||
log.Info("built-in HTTP Basic authentication enabled (PAM)", "pam_service", service)
|
||||
}
|
||||
if cfg.Server.LDAP.Enabled {
|
||||
ldapAuth, err := ldapauth.New(ldapauth.Config{
|
||||
URIs: cfg.Server.LDAP.URIs,
|
||||
SearchBase: cfg.Server.LDAP.SearchBase,
|
||||
UserAttr: cfg.Server.LDAP.UserAttr,
|
||||
UserObjectClass: cfg.Server.LDAP.UserObjectClass,
|
||||
BindDN: cfg.Server.LDAP.BindDN,
|
||||
BindPassword: cfg.Server.LDAP.BindPassword,
|
||||
StartTLS: cfg.Server.LDAP.StartTLS,
|
||||
CACertFile: cfg.Server.LDAP.CACert,
|
||||
InsecureSkipVerify: cfg.Server.LDAP.InsecureSkipVerify,
|
||||
})
|
||||
if err != nil {
|
||||
log.Error("ldap auth misconfigured", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
basicAuthFn = ldapAuth.Authenticate
|
||||
basicAuthRealm = "uopi"
|
||||
log.Info("built-in HTTP Basic authentication enabled (LDAP)", "uri", cfg.Server.LDAP.URIs, "search_base", cfg.Server.LDAP.SearchBase)
|
||||
}
|
||||
if basicAuthFn != nil && !cfg.Server.TLS.Enabled {
|
||||
log.Warn("HTTP Basic authentication enabled without TLS; credentials are sent in clear text — enable server.tls unless on a fully isolated network")
|
||||
}
|
||||
|
||||
// Built-in TLS (optional): terminate HTTPS directly, recommended whenever
|
||||
// Basic auth is enabled.
|
||||
var tlsCert, tlsKey string
|
||||
if cfg.Server.TLS.Enabled {
|
||||
if cfg.Server.TLS.Cert == "" || cfg.Server.TLS.Key == "" {
|
||||
log.Error("server.tls enabled but cert/key not configured (server.tls.cert, server.tls.key)")
|
||||
os.Exit(1)
|
||||
}
|
||||
tlsCert, tlsKey = cfg.Server.TLS.Cert, cfg.Server.TLS.Key
|
||||
log.Info("built-in TLS enabled", "cert", tlsCert)
|
||||
}
|
||||
|
||||
srv := server.New(cfg.Server.Listen, webFS, brk, synthDS, store, cfgStore, policy, aclStore, ctrlStore, ctrlEngine, dialogs, debugHub, recorder, cfg.Datasource.EPICS.ChannelFinderURL, cfg.Datasource.EPICS.ArchiveURL, cfg.Server.TrustedUserHeader, krbKeytab, cfg.Server.Kerberos.ServicePrincipal, basicAuthFn, basicAuthRealm, tlsCert, tlsKey, cfg.Server.TLS.RedirectFrom, cfg.UI.DefaultZoom, log)
|
||||
|
||||
if err := srv.Start(ctx); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "server error: %v\n", err)
|
||||
|
||||
+102
-3
@@ -192,6 +192,7 @@ When multiple widgets are selected:
|
||||
| Histogram | numeric scalar(s) |
|
||||
| Bar chart | numeric scalar(s) |
|
||||
| Logic analyser | boolean / integer (bitset) |
|
||||
| Waveform | 1-D numeric array (latest sample, x-vs-index) |
|
||||
|
||||
### 4.5 Widget Properties (Properties Pane)
|
||||
|
||||
@@ -235,6 +236,9 @@ A signal defined by composing one or more input signals through a chain of proce
|
||||
processing node, set parameters, Create.
|
||||
- **Node-graph editor** — a visual editor that wires one or more inputs through a chain of
|
||||
DSP blocks, for multi-input pipelines. It compiles to the same inputs + pipeline model.
|
||||
It supports undo/redo (Ctrl+Z / Ctrl+Shift+Z) and copy/paste (Ctrl+C / Ctrl+V) of the
|
||||
selection; paste remaps ids and preserves internal wires (the permanent output node is
|
||||
never duplicated).
|
||||
|
||||
**Visibility scope:** each synthetic signal is scoped as *panel* (visible only to the panel
|
||||
that created it), *user*, or *global* (shared with everyone).
|
||||
@@ -278,7 +282,7 @@ copy/paste.
|
||||
|----------|-------|
|
||||
| Triggers | Button press, threshold crossing, value change, timer/interval, panel loop, On-open / On-close lifecycle |
|
||||
| Logic | AND gate, If (then/else), Loop (count or while) |
|
||||
| Actions | Write to signal/variable, Delay, Log; Accumulate / Export-CSV / Clear for in-memory data arrays |
|
||||
| Actions | Write to signal/variable, Delay, Log; Accumulate / Export-CSV / Clear for in-memory data arrays; Apply config / Read config / Write config / Create config / Snapshot config (see §11) |
|
||||
| Dialogs | Info and Error pop-ups; Set-point prompt (asks the user for a number and writes it) |
|
||||
|
||||
**System helpers in expressions:** `{sys:time}` (epoch seconds) and `{sys:dt}` (seconds
|
||||
@@ -295,7 +299,16 @@ managed through the REST API.
|
||||
|
||||
- Triggers include *cron* schedules and signal *alarm*/threshold conditions.
|
||||
- A **Lua** block provides custom logic; results are written back to signals.
|
||||
- **Apply / Read / Write / Create config** action nodes drive the configuration manager
|
||||
(§11): *Apply config* writes every value of a chosen instance to its bound signals
|
||||
(audited); *Read config* reads one parameter's value into a target signal or variable;
|
||||
*Write config* stores a value into a parameter (creating a new instance revision); *Create
|
||||
config* makes a new instance for a set, optionally seeded from another instance. Both
|
||||
mutating nodes are audited.
|
||||
- Each graph can be enabled/disabled independently; saving reloads the engine live.
|
||||
- The editor has its own undo/redo and copy/paste (Ctrl+Z / Ctrl+Shift+Z, Ctrl+C / Ctrl+V;
|
||||
also ↩/↪ toolbar buttons). Copy/paste is multi-select aware and preserves wires internal
|
||||
to the selection.
|
||||
- Editing is gated by the logic-edit restriction (§2).
|
||||
|
||||
---
|
||||
@@ -304,7 +317,7 @@ managed through the REST API.
|
||||
|
||||
- Interfaces are saved to the server in XML format and are available to all connected clients.
|
||||
- Per-panel access rules and panel-folder placement are stored server-side (sidecar JSON).
|
||||
- Saved versions are retained; a panel's version history can be listed, tagged and promoted.
|
||||
- Saved versions are retained; a panel's version history can be listed, tagged, viewed, promoted, forked and diffed — the same git-style versioning shared by all versioned documents (§12).
|
||||
- Export/Import allows local file exchange of XML files.
|
||||
- The XML schema records: interface kind (panel/plot) and split layout, widget type,
|
||||
position, size, signal bindings, all property values, local variables, and panel logic.
|
||||
@@ -344,7 +357,93 @@ When the server has archive access configured:
|
||||
|
||||
---
|
||||
|
||||
## 11. Non-functional Requirements
|
||||
## 11. Configuration Manager
|
||||
|
||||
The **Configuration manager** (opened from the View-mode toolbar) manages three kinds of
|
||||
versioned documents, on **Sets**, **Instances** and **Rules** tabs, in a full-screen modal.
|
||||
|
||||
**Configuration sets** are schemas: an ordered list of typed parameters, each binding a
|
||||
target signal. Parameters can be organised into **groups** and **subgroups** via a
|
||||
drag-and-drop tree.
|
||||
- A parameter's **type** (number, integer, boolean, string, enum, array/waveform) and its
|
||||
unit/range/enum values are auto-derived from the bound signal's metadata when available;
|
||||
the type is read-only.
|
||||
- Each parameter may declare a **default**, a **mandatory** flag, and **min/max** /
|
||||
**enum** constraints.
|
||||
- Sets can be **exported / imported** as JSON.
|
||||
|
||||
**Configuration instances** assign concrete values to a chosen set's parameters.
|
||||
- The editor renders the right control per type (number input, enum/boolean combo, and a
|
||||
waveform editor with live sparkline, table editing, CSV import and a pop-out plot for
|
||||
arrays); empty fields fall back to the parameter default.
|
||||
- Validation mirrors the backend (required-but-unset, out-of-range, wrong type, bad enum)
|
||||
and is surfaced inline with hover detail.
|
||||
- **Apply** writes every value to its target signal and reports a per-parameter
|
||||
applied / failed / skipped result.
|
||||
- **Snapshot** (⎙ in the Instances tab) captures the *current live value* of every target
|
||||
signal of a chosen set into a new instance — an optional label, otherwise an auto name. This
|
||||
records "what the hardware holds right now" as a reusable configuration.
|
||||
- **Diff vs current** compares a stored instance (resolved with defaults) against the current
|
||||
live signal values, so an operator can see how the saved configuration differs from what the
|
||||
system currently has.
|
||||
|
||||
The Sets and Instances tabs support the shared version history (§12): view, fork and promote
|
||||
revisions, plus a **structural diff** between any two revisions (per-parameter added / removed
|
||||
/ changed / unchanged).
|
||||
|
||||
**Validation / transformation rules** (Rules tab) attach **CUE** logic to a set. Each rule is
|
||||
CUE source describing constraints and/or derivations over the parameter values: a field like
|
||||
`voltage: >=0 & <=24` validates a value, while a concrete derivation like
|
||||
`power: voltage * current` computes one. When an instance of the bound set is saved, every
|
||||
rule runs — a failed constraint **blocks the save** and surfaces the violation, and derived
|
||||
values are written into the stored instance. The rule editor is a CUE-aware code editor with
|
||||
syntax highlighting and autocomplete (parameter keys, target signal names, and CUE
|
||||
keywords/types via Ctrl+Space); a live panel re-evaluates the rule against the set's default
|
||||
values on every keystroke, showing compile errors, violations, or the derived values. Rules
|
||||
are versioned like the other documents, with a side-by-side / unified source diff, and every
|
||||
create / edit / delete is audited. (A full CUE language server is intentionally out of scope:
|
||||
it does not fit the no-dependency, single portable-binary design.)
|
||||
|
||||
**Automation:** both the panel-logic (§6) and control-logic (§7) editors expose **Apply
|
||||
config**, **Read config**, **Write config**, **Create config** and **Snapshot config** action
|
||||
nodes. *Apply config* applies a chosen instance exactly like the Apply button; *Read config*
|
||||
reads a single numeric parameter into a target signal or variable; *Write config* stores a
|
||||
value into a parameter, creating a new instance revision; *Create config* makes a new instance
|
||||
for a set, optionally seeded from another instance; *Snapshot config* captures every target
|
||||
signal's current live value of a set into a new instance (like the ⎙ Snapshot button). The
|
||||
mutating nodes are audited.
|
||||
|
||||
**Config Selector widget:** a panel widget that lets an operator pick which configuration a
|
||||
flow operates on at run time. It lists the instances of a chosen set — all of them or a
|
||||
defined subset — in a combo and writes the selected instance id to a panel-local string
|
||||
variable. The panel-logic Apply / Read / Write nodes can take their instance "From variable"
|
||||
(reading that id) instead of a fixed instance, so the operator's choice drives the flow.
|
||||
Control-logic nodes use fixed instances only (their variables are numeric).
|
||||
|
||||
---
|
||||
|
||||
## 12. Version History & Diff
|
||||
|
||||
All versioned documents — panels, synthetic signals, control-logic graphs, and
|
||||
configuration sets/instances — share one git-style version model and a common history
|
||||
pane:
|
||||
|
||||
- A **vertical version tree** lists every revision (one node each); the **current**
|
||||
(executed) revision is filled, the **viewed** revision is enlarged, and unsaved edits show
|
||||
a dashed connector.
|
||||
- **View** loads any past revision read-only into the editor — viewing alone is not an edit
|
||||
and does not mark the document dirty; saving from a viewed revision creates a new revision
|
||||
on top (history is never destroyed).
|
||||
- **Fork** copies a revision into a brand-new document (version reset to 1).
|
||||
- **Promote** re-saves an older revision as a new current revision.
|
||||
- **Diff** compares two revisions, defaulting to *current-vs-selected*, shown either
|
||||
**unified** or **side-by-side**. Panels, synthetic and control-logic use a generic line
|
||||
diff of the serialized document; configuration sets/instances use a richer per-parameter
|
||||
structural diff.
|
||||
|
||||
---
|
||||
|
||||
## 13. Non-functional Requirements
|
||||
|
||||
| Requirement | Target |
|
||||
|-------------|--------|
|
||||
|
||||
+299
-38
@@ -41,7 +41,7 @@
|
||||
| ---------------- | --------------------------------------------------------------- |
|
||||
| `preact` 10 | Virtual DOM UI framework |
|
||||
| `uPlot` | Extremely fast time-series/line plot (canvas-based, < 40 kB) |
|
||||
| `Apache ECharts` | FFT, waterfall, histogram, bar, logic analyser plots |
|
||||
| `Apache ECharts` | FFT, waterfall, histogram, bar, logic analyser, waveform plots |
|
||||
| `uplot.css` | uPlot default stylesheet |
|
||||
|
||||
**Intentionally excluded:** React, Vue, Svelte, Konva, WebGPU, jQuery, npm at runtime.
|
||||
@@ -164,6 +164,22 @@ Framing: JSON messages over a single persistent WebSocket connection per client.
|
||||
|
||||
// Request historical data
|
||||
{ "type": "history", "signal": "EPICS:PV1", "start": "2026-01-01T00:00:00Z", "end": "2026-01-02T00:00:00Z", "maxPoints": 5000 }
|
||||
|
||||
// Start a control-logic live-debug session (one per client; replaces any prior).
|
||||
// mode "live" — observe the running, enabled graph identified by graphId.
|
||||
// mode "simulate" — dry-run the unsaved `graph` in a server sandbox (no real
|
||||
// writes/config/dialogs); re-send on each edit to refresh it.
|
||||
{ "type": "debugSubscribe", "mode": "live", "graphId": "g1" }
|
||||
{ "type": "debugSubscribe", "mode": "simulate", "graph": { /* unsaved control-logic graph */ } }
|
||||
|
||||
// Stop the current debug session (tears down any simulate sandbox).
|
||||
{ "type": "debugUnsubscribe" }
|
||||
|
||||
// Force a trigger node of the current debug session (live or simulate) to run
|
||||
// now, as if it had fired. nodeId must be a trigger node of the watched graph;
|
||||
// best-effort (dropped if the session is gone). Drives the editor's
|
||||
// double-click-to-fire gesture on trigger nodes in debug mode.
|
||||
{ "type": "fireTrigger", "nodeId": "t" }
|
||||
```
|
||||
|
||||
**Server → Client messages:**
|
||||
@@ -178,6 +194,11 @@ Framing: JSON messages over a single persistent WebSocket connection per client.
|
||||
// Historical data response
|
||||
{ "type": "history", "signal": "EPICS:PV1", "points": [ { "ts": "...", "value": 1.2 }, ... ] }
|
||||
|
||||
// Control-logic node execution during a live-debug session. Emitted ~per node
|
||||
// run for the watched graph (live) or sandbox (simulate); value is meaningful
|
||||
// only when hasValue is true (e.g. an action.write's value, a flow.if branch 0/1).
|
||||
{ "type": "debugNode", "graphId": "g1", "nodeId": "w", "value": 42, "hasValue": true, "ts": 1750000000000 }
|
||||
|
||||
// Error
|
||||
{ "type": "error", "code": "NOT_FOUND", "message": "Signal not found" }
|
||||
```
|
||||
@@ -198,10 +219,6 @@ Base path: `/api/v1`
|
||||
| POST | `/interfaces/reorder` | Reorder panels / move between folders |
|
||||
| GET, PUT, DELETE| `/interfaces/{id}` | Download, update, or delete an interface |
|
||||
| POST | `/interfaces/{id}/clone` | Clone an interface |
|
||||
| GET | `/interfaces/{id}/versions` | List saved versions; `…/{version}` to fetch one |
|
||||
| PUT | `/interfaces/{id}/versions/{v}/tag` | Tag a version |
|
||||
| POST | `/interfaces/{id}/versions/{v}/promote` | Promote a version to current |
|
||||
| POST | `/interfaces/{id}/versions/{v}/fork` | Fork a version into a new panel |
|
||||
| GET, PUT | `/interfaces/{id}/acl` | Read or set a panel's sharing rules |
|
||||
| GET, POST | `/folders` | List or create panel folders |
|
||||
| PUT, DELETE | `/folders/{id}` | Rename/reparent or delete a folder |
|
||||
@@ -209,12 +226,36 @@ Base path: `/api/v1`
|
||||
| GET, PUT | `/groups` | Read or set group definitions |
|
||||
| GET, POST | `/synthetic` | List or create synthetic signal definitions |
|
||||
| GET, PUT, DELETE| `/synthetic/{name}` | Read, update, or delete a synthetic definition |
|
||||
| POST | `/synthetic/trace` | Stateless single-shot trace of an unsaved graph for the live-debug view — every node's value; stateful ops flagged `approx` |
|
||||
| GET, POST | `/controllogic` | List or create server-side control-logic graphs |
|
||||
| GET, PUT, DELETE| `/controllogic/{id}` | Read, update, or delete a control-logic graph |
|
||||
| GET, POST | `/config/sets` | List or create configuration sets (schemas) |
|
||||
| GET, PUT, DELETE| `/config/sets/{id}` | Read, update, or delete a config set |
|
||||
| GET, POST | `/config/instances` | List or create configuration instances (values) |
|
||||
| GET, PUT, DELETE| `/config/instances/{id}` | Read, update, or delete a config instance |
|
||||
| POST | `/config/instances/{id}/apply` | Write an instance's values to their target signals |
|
||||
| POST | `/config/instances/{id}/validate` | Run the set's CUE rules over the stored values; returns a structured `RuleResult` (no save) |
|
||||
| GET | `/config/instances/{id}/livediff` | Diff the stored instance (resolved with defaults) against the current live signal values |
|
||||
| POST | `/config/sets/{id}/snapshot` | Capture every target signal's current live value into a new instance (body: optional `{name}`) |
|
||||
| GET | `/config/{sets\|instances}/diff` | Structural diff between two revisions (`a,av,b,bv`)|
|
||||
| GET, POST | `/config/rules` | List or create CUE validation/transformation rules |
|
||||
| GET, PUT, DELETE| `/config/rules/{id}` | Read, update, or delete a rule |
|
||||
| POST | `/config/rules/check` | Compile an (unsaved) CUE source and evaluate it against sample values — powers the live editor |
|
||||
|
||||
Mutating requests are gated by the access middleware (§8): global level for writes,
|
||||
**Versioning (shared).** Interfaces, synthetic signals, control-logic graphs and config
|
||||
sets/instances all expose the same revision endpoints under their `{base}`:
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | ---- | ----------- |
|
||||
| GET | `{base}/{id}/versions` | List revisions; `…/{version}` fetches one |
|
||||
| POST | `{base}/{id}/versions/{v}/promote` | Promote a revision to current |
|
||||
| POST | `{base}/{id}/versions/{v}/fork` | Fork a revision into a new document |
|
||||
| PUT | `/interfaces/{id}/versions/{v}/tag` | Tag a panel version |
|
||||
|
||||
Mutating requests are gated by the access middleware (§3.10): global level for writes,
|
||||
per-panel ACL for interface endpoints, and the logic-editor allowlist for control-logic
|
||||
endpoints and for any change to a panel's `<logic>` block.
|
||||
endpoints and for any change to a panel's `<logic>` block. Config-set/instance
|
||||
promote/fork are gated by the same write policy.
|
||||
|
||||
### 3.5 EPICS Data Source
|
||||
|
||||
@@ -234,15 +275,30 @@ endpoints and for any change to a panel's `<logic>` block.
|
||||
|
||||
**Built-in node types:**
|
||||
|
||||
| Node type | Parameters | Description |
|
||||
| ------------ | ----------------------------------- | ------------------------------------------------ |
|
||||
| `source` | `ds`, `name` | Reads a signal from any data source |
|
||||
| `gain` | `factor` | Multiplies by a constant |
|
||||
| `offset` | `value` | Adds a constant |
|
||||
| `moving_avg` | `window` (samples) | Rolling mean |
|
||||
| `lowpass` | `freq` (Hz), `order` (1–8) | Cascaded IIR Butterworth-style low-pass filter |
|
||||
| `formula` | `expr` | Inline math expression (variables: `a`, `b`, …) |
|
||||
| `lua` | `script` | Arbitrary Lua 5.1 code with persistent state |
|
||||
| Node type | Parameters | In→Out | Description |
|
||||
| ---------------- | --------------------------- | -------------- | ----------------------------------------------- |
|
||||
| `source` | `ds`, `name` | — | Reads a signal from any data source |
|
||||
| `gain` | `gain` | elementwise | Multiplies by a constant |
|
||||
| `offset` | `offset` | elementwise | Adds a constant |
|
||||
| `add`/`subtract` | — | elementwise | Sum of inputs / `a − b` |
|
||||
| `multiply`/`divide` | — | elementwise | Product of inputs / `a ÷ b` |
|
||||
| `clamp` | `min`, `max` | elementwise | Constrains to a range |
|
||||
| `threshold` | `threshold`, `high`, `low` | elementwise | Comparator output |
|
||||
| `moving_average` | `window` (samples) | scalar-only | Rolling mean |
|
||||
| `rms` | `window` (samples) | scalar-only | Rolling RMS |
|
||||
| `derivative` | — | scalar-only | Time derivative (per-sample `dt`) |
|
||||
| `integrate` | — | scalar-only | Trapezoidal integral |
|
||||
| `lowpass` | `freq` (Hz), `order` (1–8) | scalar-only | Cascaded IIR Butterworth-style low-pass filter |
|
||||
| `expr` | `expr`, `vars` | elementwise | Inline math expression (named inputs) |
|
||||
| `lua` | `script`, `vars` | scalar-only | Arbitrary Lua 5.1 code with persistent state |
|
||||
| `index` | `i` | array→scalar | Element `i` of a waveform (bounds-checked) |
|
||||
| `slice` | `start`, `end` | array→array | Sub-range of a waveform (clamped) |
|
||||
| `sum`/`mean` | — | array→scalar | Σ / average of a waveform |
|
||||
| `min`/`max` | — | array→scalar | Reduction of a waveform |
|
||||
| `length` | — | array→scalar | Element count of a waveform |
|
||||
| `fft` | — | array→array | Magnitude spectrum (zero-padded to next pow-2) |
|
||||
|
||||
**Scalar vs waveform values:** A value flowing through the graph is a `dsp.Sample` — either a scalar `float64` or a `[]float64` waveform (the array-aware counterpart of EPICS `TypeFloat64Array`). *Elementwise* ops broadcast over arrays (scalar inputs act as constants; array inputs must share a length). *Reduction*/*producer* ops (`index`/`slice`/`sum`/…/`fft`) operate natively on waveforms. *Scalar-only* ops (stateful filters + `lua`) reject array inputs, since their per-evaluation state cannot be split across array lanes. `OpOutputType` (`internal/dsp/types.go`) propagates types statically at compile time to reject invalid wirings and to report the synthetic's metadata type; the editor mirrors these rules in `web/src/lib/synthTypes.ts` to colour wires by data type (scalar vs array) and flag type-incompatible links. Runtime `Sample` typing is authoritative.
|
||||
|
||||
**Low-pass filter implementation:** Cascaded first-order IIR sections. Each stage computes `y = y_prev + α·(x − y_prev)` where `α = dt / (RC + dt)` and `RC = 1/(2π·fc)`. `dt` is computed per sample from source timestamps so the filter is correct for event-driven (non-uniform) data.
|
||||
|
||||
@@ -303,16 +359,179 @@ and writes results back to signals. Graphs are persisted by a store and managed
|
||||
`/api/v1/controllogic`; each mutation calls `Engine.Reload()` to apply changes live. Graphs
|
||||
can be individually enabled/disabled.
|
||||
|
||||
The engine also holds a `*confmgr.Store` (injected via `NewEngine`) for five config action
|
||||
nodes: `action.config.apply` resolves an instance + its set and runs `confmgr.Apply` with a
|
||||
broker-backed, audited write closure; `action.config.read` resolves a single parameter value
|
||||
(`ConfigInstance.Resolve`), coerces it to float64 and writes it to the node's target;
|
||||
`action.config.write` evaluates an expression and stores the (type-coerced, via
|
||||
`coerceParamValue`) value into a parameter, creating a new instance revision;
|
||||
`action.config.create` creates a new instance for a set, optionally seeding its values from
|
||||
another instance; and `action.config.snapshot` reads every target signal's current live value
|
||||
(via the one-shot `Broker.ReadNow`, type-coerced by `confmgr.Snapshot`) and stores them as a
|
||||
new instance. All mutating nodes are audited. The panel-logic engine
|
||||
(`web/src/lib/logic.ts`) mirrors all five client-side via the REST endpoints
|
||||
(`/config/instances/{id}/apply`, GET/PUT `/config/instances/{id}`, POST `/config/instances`,
|
||||
POST `/config/sets/{id}/snapshot`).
|
||||
Panel-logic apply/read/write nodes additionally support an `instanceSource: 'var'` mode that
|
||||
reads the target instance id (a string) from a panel-local variable rather than a fixed id —
|
||||
fed by the **Config Selector** widget (`web/src/widgets/ConfigSelect.tsx`), which lists a
|
||||
set's instances in a combo and writes the chosen id to that variable. Control-logic nodes use
|
||||
fixed ids only (its variables are numeric, instance ids are strings). To let the selector
|
||||
filter instances by set without a GET per instance, `confmgr.Store.List` now returns each
|
||||
instance's `setId` in its `Meta`.
|
||||
|
||||
### 3.10 Access Control
|
||||
|
||||
Identity and global policy live in `internal/access` (`Policy`, `Level`). The user identity
|
||||
is read per request from `server.trusted_user_header` (with a `default_user` fallback) and
|
||||
stored on the request context. `accessMiddleware` gates mutating HTTP methods by the user's
|
||||
global level. Per-panel ownership and ACL evaluation (with folder inheritance) live in
|
||||
`internal/panelacl`, backed by the `acl.json` sidecar. `Policy.CanEditLogic(user)` enforces
|
||||
the optional `server.logic_editors` allowlist over control-logic endpoints and over any
|
||||
change to a panel's `<logic>` block; it is surfaced to the frontend through `/api/v1/me`
|
||||
(`canEditLogic`) so the UI can hide logic-editing affordances.
|
||||
Identity and global policy live in `internal/access` (`Policy`, `Role`, `Level`). The user
|
||||
identity is read per request from `server.trusted_user_header` (with a `default_user`
|
||||
fallback) and stored on the request context. Alternatively, **native SPNEGO/Kerberos**
|
||||
authentication (`[server.kerberos]`) lets uopi identify users directly from their Kerberos
|
||||
ticket without a reverse proxy: `internal/server/kerberos.go` wraps the REST and WebSocket
|
||||
handlers, challenges API requests with `401 WWW-Authenticate: Negotiate`, validates the
|
||||
ticket against the configured service keytab (`github.com/jcmturner/gokrb5`), and writes the
|
||||
short principal name (realm stripped) into the same `userHeader` the access pipeline reads
|
||||
— so downstream identity resolution is identical. Any client-supplied header value is
|
||||
discarded before validation to prevent spoofing. WebSocket upgrades (where browsers cannot
|
||||
attach an `Authorization` header) validate proactively-sent credentials best-effort and
|
||||
otherwise fall back to `default_user`. Browsers must be configured to perform SPNEGO for the
|
||||
server origin (Firefox: `network.negotiate-auth.trusted-uris`), which fixes the case where
|
||||
Firefox would otherwise resolve to `default_user`. A third standalone option is **built-in
|
||||
HTTP Basic authentication** (`[server.basic_auth]`, mutually exclusive with Kerberos):
|
||||
`internal/server/basicauth.go` challenges with `401 WWW-Authenticate: Basic` and validates
|
||||
credentials against the host PAM stack (`internal/pamauth`, `/etc/pam.d/<pam_service>`), so
|
||||
on an SSSD/LDAP-joined host users authenticate with their normal login password and no
|
||||
directory schema is needed in uopi. Successful logins are memoised in a short-TTL salted-hash
|
||||
cache (`credCache`) to avoid a PAM round-trip per request, and the validated username is
|
||||
written into the same `userHeader`. PAM requires a cgo build (`make backend-pam`, build tag
|
||||
`pam`); the default static binary uses a stub that refuses Basic auth. As a **pure-Go**
|
||||
alternative that keeps the fully-static (`CGO_ENABLED=0`) binary, `[server.ldap]`
|
||||
(`internal/ldapauth`) validates the same Basic credentials against an LDAP directory with a
|
||||
"search then bind" — anonymously (or via a service `bind_dn`) locate the user entry under
|
||||
`search_base`, then bind as its DN with the supplied password — mirroring an SSSD/LDAP
|
||||
client (defaults: `user_attr=uid`, `user_object_class=posixAccount`). Empty passwords are
|
||||
rejected before any bind to avoid LDAP "unauthenticated bind", and the login name is
|
||||
filter-escaped against injection. The challenge front-end is shared: both PAM and LDAP feed
|
||||
the same `basicAuth` middleware, and the page load (`/`) is challenged too so the browser's
|
||||
native login dialog actually appears (a background `fetch('/me')` 401 does not prompt).
|
||||
Because Basic credentials are sent on every request, uopi can terminate **TLS** itself
|
||||
(`[server.tls]` → `ListenAndServeTLS`) without a reverse proxy; an optional
|
||||
`redirect_from` plain-HTTP listener 301-redirects `http://` visitors to the HTTPS service
|
||||
so they are upgraded instead of hitting the TLS port with cleartext ("client sent an HTTP
|
||||
request to an HTTPS server"). The four identity sources
|
||||
(proxy header, Kerberos, PAM-Basic, LDAP-Basic) all resolve to the same `userHeader` and are
|
||||
mutually exclusive where they overlap. Access is **role-based** through group
|
||||
memberships: each `[[groups]]` block lists members by role along the cumulative ladder
|
||||
`viewer < operator < logiceditor < auditor < admin`, and a user's **effective** global
|
||||
capability is the highest role across all their memberships. Roles map to capabilities as:
|
||||
operator+ → write (`Level` is derived, `LevelWrite` for operator+, else `LevelRead`);
|
||||
logiceditor+ → `CanEditLogic`; auditor+ → `CanViewAudit`; admin → `CanAdmin`.
|
||||
`accessMiddleware` gates mutating HTTP methods by the derived global level. Per-panel
|
||||
ownership and ACL evaluation (with folder inheritance) live in `internal/panelacl`, backed
|
||||
by the `acl.json` sidecar.
|
||||
|
||||
A built-in **public** group is always present: every user (and anonymous) is an implicit
|
||||
viewer member, so an identified caller is at least read-only. Groups may **nest** via a
|
||||
`parent` pointer (a forest rooted at top-level groups); a member of a parent group inherits
|
||||
its role on every descendant group too, unless overridden lower. Cycles are rejected
|
||||
(offending parent dropped to root), and the public group is protected from rename, reparent,
|
||||
and delete. As a bootstrap convenience, a `Policy` with **no roles assigned anywhere** is
|
||||
treated as unconfigured → fully open (everyone is admin), matching trusted-LAN/dev use;
|
||||
assigning any role switches to strict mode where unlisted users are read-only viewers.
|
||||
|
||||
The capability checks (`CanEditLogic`, `CanViewAudit`, `CanAdmin`) are surfaced to the
|
||||
frontend through `/api/v1/me` (`canEditLogic`, `canViewAudit`, `canAdmin`) so the UI can
|
||||
hide affordances. The `Policy` is seeded from the TOML config at startup but is
|
||||
**runtime-mutable** through the admin pane. `Policy.EnablePersistence(storageDir)` points
|
||||
it at an `access.json` sidecar; once any admin mutation is made, that file is written (tmp +
|
||||
atomic rename) and, on a later startup, supersedes the TOML access config (which then only
|
||||
bootstraps an empty install). All policy state is guarded by an `RWMutex` (reads share,
|
||||
mutations are exclusive and persist), keeping the shared `*Policy` pointer wiring intact.
|
||||
|
||||
The admin REST routes live under `/api/v1/admin/*` (all `requireAdmin`-gated):
|
||||
`GET /admin/access` returns an `AccessSnapshot` (users with effective role + per-group
|
||||
roles, groups with members and parents, the role ladder, and the configured flag);
|
||||
`PUT /admin/users/{user}` replaces a user's full set of per-group roles (body
|
||||
`{roles: {group: role}}`, creating missing groups); `POST|PUT|DELETE /admin/groups[/{name}]`
|
||||
create (name + optional parent), rename + set parent and member roles, and delete groups;
|
||||
and `GET /admin/stats` reports live server statistics (the `internal/metrics` counters via
|
||||
`metrics.Snapshot`, the broker's observed-signal count and data-source list, Go runtime
|
||||
stats, and the Linux `/proc/loadavg` load average). The frontend `AdminPane.tsx` (a modal
|
||||
opened from the view-mode Tools dropdown when `canAdmin`) presents Users (per-group role
|
||||
assignment with an effective-role badge), Groups (nesting + per-member roles), and
|
||||
Server-stats tabs.
|
||||
|
||||
#### Visibility scope (selector-tree filtering)
|
||||
|
||||
Every user-owned, list-able object — panels, synthetic signals, config sets/instances, and
|
||||
control-logic graphs — carries a uniform **visibility scope** so each selector tree can be
|
||||
filtered by **Mine / Group / Global**. The model is `owner` (stamped server-side from the
|
||||
trusted identity on create, immutable across updates) plus a scope token
|
||||
`∈ {private, group, global}` and, for group scope, a list of group names. An empty or
|
||||
unknown token resolves to **global**, so legacy objects with no scope stay visible to
|
||||
everyone. This is a *visibility filter*, not a hard security boundary: an owner always sees
|
||||
their own objects regardless of scope, and the per-panel ACL (§3.10) remains the real
|
||||
access-control mechanism for panels.
|
||||
|
||||
The shared backend helper is `access.CanSee(user, owner, scope, itemGroups, userGroups)`
|
||||
(`internal/access/scope.go`), with a `(*Policy).CanSee` method that resolves the caller's
|
||||
groups via `GroupsOf`. Each list endpoint filters its results through it:
|
||||
`internal/confmgr` stores `owner/scope/groups` on sets and instances (filtered by
|
||||
`filterConfigMetas`); synthetic `SignalDef` gained a `group` visibility mode routed through
|
||||
`synVisible`; control-logic `Graph` carries `owner/scope/scopeGroups` (named to avoid the
|
||||
pre-existing cosmetic `Groups []NodeGroup`) filtered in `listControlLogic`. Panels reuse the
|
||||
existing ACL rather than a new field: `panelScope` (`internal/api/api.go`) derives the
|
||||
bucket from the ACL record (public → global, a group grant → group, otherwise → private;
|
||||
unmanaged → global) and surfaces it on `InterfaceListItem.scope`/`groups`.
|
||||
|
||||
The shared frontend lib is `web/src/lib/scope.tsx`: `bucketOf` assigns an item to exactly
|
||||
one bucket (owned → mine, else group-scoped → group, else global), `filterByScope` narrows a
|
||||
list to the active bucket (with an optional groups-accessor for control-logic's
|
||||
`scopeGroups`), `ScopeFilter` is the segmented `[Mine | Group ▾ | Global]` selector shown
|
||||
above each tree (the Group segment carries a combo to pick a group when the user is in
|
||||
several), and `ScopePicker` is the create/save visibility editor. `ConfigManager.tsx`,
|
||||
`SyntheticGraphEditor.tsx`, and `ControlLogicEditor.tsx` use the picker to set scope on save;
|
||||
panel visibility is instead edited through the existing Share dialog. `InterfaceList.tsx`
|
||||
applies the filter to its folder tree, hiding folders that have no in-scope descendant.
|
||||
|
||||
### 3.11 Configuration Manager
|
||||
|
||||
The configuration manager is `internal/confmgr`: a two-tier model of **sets** (typed
|
||||
parameter schemas binding target signals) and **instances** (values for a chosen set).
|
||||
`model.go` defines `ConfigSet`/`Parameter`/`ConfigInstance`; `apply.go` validates an
|
||||
instance against its set (`checkValue`) and writes each value to its target signal,
|
||||
returning a per-parameter apply report; `diff.go` produces the structural per-parameter
|
||||
diff used by the `/config/{sets,instances}/diff` endpoints.
|
||||
|
||||
`store.go` persists each object as `configs/{sets,instances,rules}/{id}.json`, with superseded
|
||||
revisions backed up as `{id}.vN.json` alongside — the same git-style scheme as panels and
|
||||
synthetic/control-logic, so `Versions`/`GetVersion`/`Promote`/`Fork` behave identically.
|
||||
`Delete` is non-destructive: the object and all its backups are moved to a timestamped
|
||||
`trash/configs/…` folder.
|
||||
|
||||
**CUE rules (`cue.go`, `KindRule`).** A third versioned object type carries a CUE source
|
||||
(`cuelang.org/go`) bound to a set via `SetID`. `EvaluateRule` compiles the source, unifies it
|
||||
with the instance values (`ctx.Encode`), and validates with `cue.Concrete(true)`: regular
|
||||
fields whose key matches a parameter constrain its value (failures become `RuleViolation`s),
|
||||
while concrete derivations whose value differs from the input are reported (and persisted) as
|
||||
**transformations**; hidden fields `_x` and definitions `#X` are helpers, excluded from both.
|
||||
`CreateInstance`/`UpdateInstance` call `applyRules` after the structural `ValidateAgainst`:
|
||||
all rules bound to the set are run in order (`evaluateRules`), a violation aborts the save as
|
||||
a `*RuleError`, and successful transformations are merged back into the stored values (set
|
||||
parameters only). `ValidateInstanceRules` is the read-only counterpart behind
|
||||
`/config/instances/{id}/validate`; `EvaluateRule` is exposed directly via
|
||||
`/config/rules/check` for the live editor.
|
||||
|
||||
### 3.12 Document Versioning
|
||||
|
||||
Versioning is implemented per storage layer but follows one shared contract: the live
|
||||
revision lives in the primary file; each save backs up the previous revision as
|
||||
`{id}.v{N}.{ext}`; `VersionMeta{Version,Name,Tag,Current,SavedAt}` describes each. `Promote`
|
||||
re-saves an older revision on top (creating a new current revision, never destroying
|
||||
history); `Fork` writes the revision out under a fresh id with its version reset to 1. The
|
||||
frontend `web/src/VersionHistory.tsx` (`VersionTree` + `DiffViewer`) consumes these
|
||||
generically; line diffs are computed client-side in `web/src/lib/linediff.ts`, while
|
||||
config sets/instances use the backend structural diff instead. Config **rules** reuse the
|
||||
generic client-side `DiffViewer` (line diff over the source).
|
||||
|
||||
---
|
||||
|
||||
@@ -353,7 +572,7 @@ The edit canvas is a free-form HTML div with absolutely positioned widget compon
|
||||
View mode renders widgets as absolutely positioned Preact components on a scrollable canvas div:
|
||||
|
||||
- Each widget subscribes to its signal store(s) in a `useEffect` and re-renders only when values change.
|
||||
- uPlot (time series) and ECharts (histogram, bar, FFT, waterfall, logic analyser) manage their own canvas elements inside their widget component.
|
||||
- uPlot (time series) and ECharts (histogram, bar, FFT, waterfall, logic analyser, waveform) manage their own canvas elements inside their widget component. The `waveform` plot renders a waveform (array) signal's latest `[]float64` as an x-vs-index trace, replacing the trace on each update.
|
||||
- Plot widgets maintain a rolling ring buffer of 200,000 samples per signal for smooth long-window display.
|
||||
- Step-hold interpolation: when multiple signals at different update rates share a plot, the most recent value is carried forward to fill the shared time axis correctly.
|
||||
|
||||
@@ -383,6 +602,7 @@ Both edit and view modes support mouse-drag panel resizing:
|
||||
- `html { font-size: clamp(13px, 1.5vh, 18px); }` — base font scales with viewport height, making the UI naturally larger on 4K screens where the browser zoom level is 100%.
|
||||
- Key structural heights (toolbar, panel headers, tab bar, plot toolbar) are expressed in `rem` so they scale with the base font.
|
||||
- **ZoomControl** (A− / % / A+) in the toolbar lets users manually override the zoom level in 11 steps from 50% to 250%. The preference is persisted in `localStorage` (`uopi:ui-zoom`) and applied by setting `document.documentElement.style.fontSize` on load.
|
||||
- The root font-size also folds in `window.devicePixelRatio` (`applyZoom` → `16 × zoom × dpr`) so high-DPI displays auto-scale the UI by default; Firefox in particular does not enlarge the root px on its own, so the DPR must be applied explicitly. `watchDpr()` re-applies the zoom when the ratio changes (e.g. the window moves to a differently-scaled monitor).
|
||||
- Canvas pixel rendering (uPlot, ECharts) reads `window.devicePixelRatio` and sizes canvases accordingly.
|
||||
|
||||
### 4.8 Lua Editor (`LuaEditor.tsx`)
|
||||
@@ -470,15 +690,55 @@ storage_dir = "./interfaces"
|
||||
# Access control (all optional)
|
||||
trusted_user_header = "" # header carrying the proxy-authenticated user
|
||||
default_user = "" # identity when the header is absent (LAN/dev)
|
||||
logic_editors = [] # users/groups allowed to edit panel & control logic
|
||||
|
||||
# [[server.blacklist]] # downgrade users: level = "readonly" | "noaccess"
|
||||
# user = "guest"
|
||||
# level = "readonly"
|
||||
# Native SPNEGO/Kerberos auth — alternative to a proxy; identifies users from
|
||||
# their Kerberos ticket. Recommended when some browsers (e.g. Firefox) would
|
||||
# otherwise fall through to default_user.
|
||||
# [server.kerberos]
|
||||
# enabled = true
|
||||
# keytab = "/etc/uopi/http.keytab" # HTTP/host@REALM service key
|
||||
# service_principal = "HTTP/host.example.com" # optional; empty = keytab default
|
||||
|
||||
# [[groups]] # named user sets for per-panel sharing
|
||||
# name = "operators"
|
||||
# members = ["alice", "bob"]
|
||||
# Built-in HTTP Basic auth (PAM) — standalone, mutually exclusive with Kerberos.
|
||||
# Requires a PAM build: `make backend-pam`. Enable TLS below for production.
|
||||
# [server.basic_auth]
|
||||
# enabled = true
|
||||
# pam_service = "uopi" # /etc/pam.d/<name>; empty = "uopi"
|
||||
|
||||
# Built-in HTTP Basic auth (LDAP) — pure-Go, works in the static binary; search
|
||||
# then bind against the directory. Mutually exclusive with kerberos/basic_auth.
|
||||
# [server.ldap]
|
||||
# enabled = true
|
||||
# uri = ["ldaps://ldap.example.com"]
|
||||
# search_base = "dc=example,dc=com"
|
||||
# user_attr = "uid" # "sAMAccountName" for AD; empty = uid
|
||||
# bind_dn = "" # empty = anonymous search
|
||||
|
||||
# Built-in TLS/HTTPS — terminate HTTPS without a reverse proxy. Recommended
|
||||
# whenever basic_auth is enabled. Both cert and key required when enabled.
|
||||
# [server.tls]
|
||||
# enabled = true
|
||||
# cert = "/etc/uopi/tls/cert.pem"
|
||||
# key = "/etc/uopi/tls/key.pem"
|
||||
|
||||
# Role-based access through group memberships. Roles (low→high):
|
||||
# viewer < operator < logiceditor < auditor < admin
|
||||
# Effective capability = highest role across all memberships. The built-in
|
||||
# "public" group makes every user an implicit viewer. Groups may nest via
|
||||
# "parent" (members inherit their role on descendants). No roles anywhere = open
|
||||
# (everyone admin); once set, unlisted users are read-only viewers.
|
||||
# [[groups]]
|
||||
# name = "public"
|
||||
# admins = ["alice"] # alice is a global admin
|
||||
# [[groups]]
|
||||
# name = "operations"
|
||||
# operators = ["bob"]
|
||||
# auditors = ["carol"]
|
||||
# [[groups]]
|
||||
# name = "engineers"
|
||||
# parent = "operations" # bob inherits operator here
|
||||
# logiceditors = ["dave"]
|
||||
# viewers = ["erin"]
|
||||
|
||||
[datasource.epics]
|
||||
enabled = true
|
||||
@@ -491,7 +751,7 @@ enabled = true
|
||||
```
|
||||
|
||||
All settings can also be overridden with `UOPI_*` environment variables (e.g.
|
||||
`UOPI_SERVER_LISTEN`, `UOPI_SERVER_LOGIC_EDITORS`, `UOPI_EPICS_CA_ADDR_LIST`).
|
||||
`UOPI_SERVER_LISTEN`, `UOPI_EPICS_CA_ADDR_LIST`).
|
||||
|
||||
---
|
||||
|
||||
@@ -516,11 +776,12 @@ All settings can also be overridden with `UOPI_*` environment variables (e.g.
|
||||
- Interface XML parsing: use strict schema validation to prevent XXE.
|
||||
- **Identity & access control:** the end-user identity is taken from a header set by a
|
||||
trusted authenticating reverse proxy (`trusted_user_header`), never from client-supplied
|
||||
values — the proxy MUST strip any inbound copy of that header or it can be spoofed. A
|
||||
global blacklist downgrades users (read-only/no-access); per-panel ACLs and the optional
|
||||
`logic_editors` allowlist provide finer control. An unidentified caller (no header, no
|
||||
`default_user`) is treated as a trusted-LAN user with full write access, preserving the
|
||||
unproxied/SSH-tunnel deployment model.
|
||||
values — the proxy MUST strip any inbound copy of that header or it can be spoofed.
|
||||
Authorisation is role-based through group memberships (viewer/operator/logiceditor/
|
||||
auditor/admin), with the highest role across memberships deciding global capability;
|
||||
per-panel ACLs provide finer per-panel control. When **no** roles are assigned anywhere
|
||||
the deployment is fully open (everyone admin), preserving the unproxied/SSH-tunnel/dev
|
||||
model; once any role is set, unlisted and anonymous callers are read-only viewers.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
# Test Coverage Report — uopi
|
||||
|
||||
**Date:** 2026-06-24
|
||||
**Branch:** develop
|
||||
**Task:** #167 — Raise backend coverage toward 90%
|
||||
**Status:** All suites green — `go test ./... -race`, `go vet ./...`, and `gofmt -l` clean.
|
||||
|
||||
## Overall
|
||||
|
||||
- **Main module total coverage: 67.7%** of statements.
|
||||
- **38 test files, 218 test/bench/fuzz functions** in the main module.
|
||||
- **27 new test files** added across the coverage initiative.
|
||||
- 3-module `go.work` workspace — each module is tested independently (`go test ./...`
|
||||
from the root only exercises the main module; `pkg/ca` and `pkg/pva` must be tested
|
||||
by `cd`-ing into them).
|
||||
|
||||
## Main module (`github.com/uopi/uopi`) — by package
|
||||
|
||||
| Coverage | Package | Notes |
|
||||
|---|---|---|
|
||||
| 100.0% | `internal/config` | |
|
||||
| 100.0% | `internal/pamauth` | stub (non-PAM build) |
|
||||
| 97.1% | `internal/metrics` | |
|
||||
| 96.3% | `internal/broker` | signal fan-out core |
|
||||
| 89.2% | `internal/panelacl` | |
|
||||
| 89.0% | `internal/audit` | |
|
||||
| 85.5% | `internal/access` | |
|
||||
| 82.6% | `internal/storage` | |
|
||||
| 82.4% | `internal/datasource/stub` | |
|
||||
| 81.7% | `internal/confmgr` | |
|
||||
| 81.0% | `internal/dsp` | |
|
||||
| 79.1% | `internal/datasource/servervar` | |
|
||||
| 72.9% | `internal/datasource/synthetic` | |
|
||||
| 67.2% | `internal/api` | large handler surface |
|
||||
| 66.6% | `internal/controllogic` | engine/Lua/cron uncovered |
|
||||
| 55.6% | `internal/datasource` | iface + ctx helpers |
|
||||
| 34.1% | `internal/server` | HTTP/WebSocket |
|
||||
| 33.9% | `internal/ldapauth` | network-bound |
|
||||
| 33.6% | `internal/datasource/epics` | CGo/libca-bound |
|
||||
| 0.0% | `internal/datasource/pva` | network-bound |
|
||||
| 0.0% | `cmd/uopi`, `cmd/catools`, `cmd/pvtools`, `tools/buildfrontend` | mains — no tests |
|
||||
|
||||
## Workspace modules
|
||||
|
||||
| Module | Coverage |
|
||||
|---|---|
|
||||
| `pkg/ca` (goca) | **83.9%** root · 90.0% `proto` · `testca` 0% (test harness) |
|
||||
| `pkg/pva` (gopva) | 85.5% `pvdata` · 12.1% root (network client) |
|
||||
|
||||
## Remaining gaps toward 90%
|
||||
|
||||
The lowest packages are all **I/O- or platform-bound**, needing integration harnesses
|
||||
rather than unit tests:
|
||||
|
||||
- `server` (34%) — HTTP/WebSocket handlers.
|
||||
- `ldapauth` (34%) — needs a mock LDAP server.
|
||||
- `datasource/epics` (34%) — CGo `libca` linkage.
|
||||
- `datasource/pva` (0%) — needs a PVA test server (analogous to `testca` for CA).
|
||||
- `cmd/*` mains — typically excluded from coverage targets.
|
||||
|
||||
Pure-logic packages are now in the 80–100% range. The biggest realistic remaining
|
||||
wins are `api` (67%) and `controllogic` (67%), where the uncovered code is the
|
||||
control-logic **engine** (Lua runtime, cron scheduling, dialog emission) and the
|
||||
network-dependent API handlers (channelFinder, archiverSearch).
|
||||
|
||||
## Coverage gains (this initiative)
|
||||
|
||||
| Package | Before | After |
|
||||
|---|---|---|
|
||||
| `internal/api` | 53.8% | 67.2% |
|
||||
| `internal/audit` | 75.3% | 89.0% |
|
||||
| `internal/broker` | 78.9% | 96.3% |
|
||||
| `internal/panelacl` | 65.9% | 89.2% |
|
||||
| `internal/confmgr` | 73.4% | 81.7% |
|
||||
| `internal/dsp` | 66.3% | 81.0% |
|
||||
| `internal/datasource` | 0% | 55.6% |
|
||||
| `internal/pamauth` | 0% | 100% |
|
||||
| `pkg/ca` | 82.2% | 83.9% |
|
||||
|
||||
## Notes
|
||||
|
||||
- Two inherently racy assertions were deliberately dropped (broker cancelled-context
|
||||
`ReadNow`; audit closed-channel synchronous fallback): both depended on
|
||||
nondeterministic `select` ordering and flaked under `-race`. The surrounding code
|
||||
paths remain covered by other tests.
|
||||
- All new test files are `gofmt`-clean, matching the CI gate
|
||||
(`gofmt -l $(git ls-files '*.go')`).
|
||||
@@ -3,10 +3,30 @@ module github.com/uopi/uopi
|
||||
go 1.26.2
|
||||
|
||||
require (
|
||||
cuelang.org/go v0.16.1
|
||||
github.com/BurntSushi/toml v1.6.0
|
||||
github.com/coder/websocket v1.8.14
|
||||
github.com/evanw/esbuild v0.28.0
|
||||
github.com/yuin/gopher-lua v1.1.2
|
||||
)
|
||||
|
||||
require golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect
|
||||
require (
|
||||
github.com/Azure/go-ntlmssp v0.1.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
|
||||
github.com/go-ldap/ldap/v3 v3.4.13 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/hashicorp/go-uuid v1.0.3 // indirect
|
||||
github.com/jcmturner/goidentity/v6 v6.0.1 // indirect
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/crypto v0.48.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
modernc.org/libc v1.66.10 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.42.2 // indirect
|
||||
)
|
||||
|
||||
@@ -1,10 +1,102 @@
|
||||
cuelang.org/go v0.16.1 h1:iPN1lHZd2J0hjcr8hfq9PnIGk7VfPkKFfxH4de+m9sE=
|
||||
cuelang.org/go v0.16.1/go.mod h1:/aW3967FeWC5Hc1cDrN4Z4ICVApdMi83wO5L3uF/1hM=
|
||||
github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A=
|
||||
github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
|
||||
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
|
||||
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
|
||||
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
|
||||
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/evanw/esbuild v0.28.0 h1:V96ghtc5p5JnNUQIUsc5H3kr+AcFcMqOJll2ZmJW6Lo=
|
||||
github.com/evanw/esbuild v0.28.0/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||
github.com/go-ldap/ldap/v3 v3.4.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ=
|
||||
github.com/go-ldap/ldap/v3 v3.4.13/go.mod h1:LxsGZV6vbaK0sIvYfsv47rfh4ca0JXokCoKjZxsszv0=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
||||
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
|
||||
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
||||
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
|
||||
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
|
||||
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
|
||||
github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
|
||||
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
|
||||
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yuin/gopher-lua v1.1.2 h1:yF/FjE3hD65tBbt0VXLE13HWS9h34fdzJmrWRXwobGA=
|
||||
github.com/yuin/gopher-lua v1.1.2/go.mod h1:7aRmXIWl37SqRf0koeyylBEzJ+aPt8A+mmkQ4f1ntR8=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
|
||||
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/sqlite v1.42.2 h1:7hkZUNJvJFN2PgfUdjni9Kbvd4ef4mNLOu0B9FGxM74=
|
||||
modernc.org/sqlite v1.42.2/go.mod h1:+VkC6v3pLOAE0A0uVucQEcbVW0I5nHCeDaBf+DpsQT8=
|
||||
|
||||
+605
-108
@@ -1,32 +1,114 @@
|
||||
// Package access implements uopi's global user-access policy: every user is
|
||||
// trusted (full write) by default, while a configured blacklist can downgrade
|
||||
// specific users to read-only or no access. It also resolves the per-request
|
||||
// user identity and the user→group memberships defined in config.
|
||||
// Package access implements uopi's role-based access policy. Access is granted
|
||||
// through group memberships: every user belongs implicitly to the built-in
|
||||
// "public" group (granting the baseline viewer role), and may additionally be
|
||||
// assigned a higher role in any number of named groups. Roles form a cumulative
|
||||
// ladder — viewer < operator < logic-editor < auditor < admin — where each level
|
||||
// includes all powers below it. A user's effective capability is the highest
|
||||
// role they hold across all their groups.
|
||||
//
|
||||
// This is the foundation layer (Phase 1). Per-panel ownership/ACL evaluation is
|
||||
// layered on top of this in a later phase.
|
||||
// Groups can be nested: a member of a parent group holds that role on every
|
||||
// descendant group too (unless the descendant assigns them a different role),
|
||||
// so an admin of an organisation group administers its sub-teams.
|
||||
//
|
||||
// As a bootstrap convenience, a policy with no role assignments at all is treated
|
||||
// as fully open (everyone is admin), matching an unconfigured/dev deployment.
|
||||
// Assigning any role switches to strict mode where unlisted users are viewers.
|
||||
//
|
||||
// The policy is seeded from the TOML config at startup but is runtime-mutable
|
||||
// through the admin pane: once EnablePersistence is called, every mutation is
|
||||
// written to a JSON sidecar ({storageDir}/access.json) which, when present on a
|
||||
// later startup, becomes the source of truth (the TOML config is then only a
|
||||
// bootstrap seed). All access is guarded by an RWMutex and safe for concurrent
|
||||
// use; the *Policy pointer is stable so existing shared-pointer wiring is
|
||||
// preserved.
|
||||
package access
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Level is a global access level. Higher levels include lower ones.
|
||||
// PublicGroup is the built-in group every user implicitly belongs to. It cannot
|
||||
// be renamed or deleted and is always a top-level (parentless) group.
|
||||
const PublicGroup = "public"
|
||||
|
||||
// Role is a cumulative access level granted by a group membership. Higher roles
|
||||
// include every power of the lower ones.
|
||||
type Role int
|
||||
|
||||
const (
|
||||
// RoleViewer permits reads only (no signal writes). It is the baseline every
|
||||
// user receives via the public group.
|
||||
RoleViewer Role = iota
|
||||
// RoleOperator adds signal writes (full HMI interaction).
|
||||
RoleOperator
|
||||
// RoleLogic adds editing panel and server-side control logic.
|
||||
RoleLogic
|
||||
// RoleAuditor adds viewing the audit log.
|
||||
RoleAuditor
|
||||
// RoleAdmin adds managing users, groups and access, and viewing server stats.
|
||||
RoleAdmin
|
||||
)
|
||||
|
||||
// RoleNames lists the role tokens from lowest to highest, for the admin UI.
|
||||
var RoleNames = []string{"viewer", "operator", "logiceditor", "auditor", "admin"}
|
||||
|
||||
// String renders the role using the tokens accepted by ParseRole.
|
||||
func (r Role) String() string {
|
||||
switch r {
|
||||
case RoleOperator:
|
||||
return "operator"
|
||||
case RoleLogic:
|
||||
return "logiceditor"
|
||||
case RoleAuditor:
|
||||
return "auditor"
|
||||
case RoleAdmin:
|
||||
return "admin"
|
||||
default:
|
||||
return "viewer"
|
||||
}
|
||||
}
|
||||
|
||||
// ParseRole maps a config/JSON token to a Role. Unknown values fall back to the
|
||||
// least-privileged viewer.
|
||||
func ParseRole(s string) Role {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "operator", "write", "operate", "rw":
|
||||
return RoleOperator
|
||||
case "logiceditor", "logic", "logic_editor", "editor":
|
||||
return RoleLogic
|
||||
case "auditor", "audit":
|
||||
return RoleAuditor
|
||||
case "admin", "administrator":
|
||||
return RoleAdmin
|
||||
default:
|
||||
return RoleViewer
|
||||
}
|
||||
}
|
||||
|
||||
// Level is a coarse global access level retained for the rest of the codebase
|
||||
// (WebSocket auth, middleware, per-panel ACL capping). It is derived from a
|
||||
// user's effective role: viewer → read-only, operator and above → write.
|
||||
type Level int
|
||||
|
||||
const (
|
||||
// LevelNone denies all access.
|
||||
// LevelNone denies all access. Never produced by the role model, but kept so
|
||||
// existing switch statements remain exhaustive.
|
||||
LevelNone Level = iota
|
||||
// LevelRead permits reads only (no create/update/delete/share, no signal writes).
|
||||
// LevelRead permits reads only.
|
||||
LevelRead
|
||||
// LevelWrite permits full access. This is the default for any user not blacklisted.
|
||||
// LevelWrite permits full write access.
|
||||
LevelWrite
|
||||
)
|
||||
|
||||
// String renders the level using the same tokens accepted by ParseLevel and
|
||||
// surfaced to the frontend via /api/v1/me.
|
||||
// String renders the level using the tokens surfaced to the frontend via
|
||||
// /api/v1/me.
|
||||
func (l Level) String() string {
|
||||
switch l {
|
||||
case LevelNone:
|
||||
@@ -38,80 +120,218 @@ func (l Level) String() string {
|
||||
}
|
||||
}
|
||||
|
||||
// ParseLevel maps a config string to a Level. Unknown values restrict to
|
||||
// read-only, since the only reason to list a user is to limit them.
|
||||
func ParseLevel(s string) Level {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "noaccess", "none", "no":
|
||||
return LevelNone
|
||||
case "readonly", "read", "ro":
|
||||
return LevelRead
|
||||
case "write", "readwrite", "rw", "full":
|
||||
func roleToLevel(r Role) Level {
|
||||
if r >= RoleOperator {
|
||||
return LevelWrite
|
||||
default:
|
||||
return LevelRead
|
||||
}
|
||||
return LevelRead
|
||||
}
|
||||
|
||||
// Policy holds the resolved global access configuration. It is immutable after
|
||||
// construction and safe for concurrent use.
|
||||
// group holds a group's parent (for nesting) and explicit per-user role
|
||||
// assignments.
|
||||
type group struct {
|
||||
parent string
|
||||
members map[string]Role
|
||||
}
|
||||
|
||||
// Policy holds the resolved role-based access configuration. It is safe for
|
||||
// concurrent use; reads take a shared lock and admin mutations take an exclusive
|
||||
// lock and persist to disk.
|
||||
type Policy struct {
|
||||
defaultUser string
|
||||
blacklist map[string]Level // user → downgraded level
|
||||
userGroups map[string][]string // user → groups they belong to
|
||||
groupNames []string // all configured group names (sorted)
|
||||
logicEditors map[string]bool // users + group names allowed to edit logic
|
||||
mu sync.RWMutex
|
||||
path string // access.json path; "" disables persistence
|
||||
defaultUser string // immutable after construction/load
|
||||
groups map[string]*group
|
||||
}
|
||||
|
||||
// New builds a Policy. blacklist maps a username to a config level string;
|
||||
// groups maps a group name to its member usernames. logicEditors optionally
|
||||
// restricts who may edit panel/control logic (usernames or group names); empty
|
||||
// means no restriction.
|
||||
func New(defaultUser string, blacklist map[string]string, groups map[string][]string, logicEditors []string) *Policy {
|
||||
// GroupSpec seeds one group at construction time: its name, optional parent, and
|
||||
// explicit user→role assignments.
|
||||
type GroupSpec struct {
|
||||
Name string
|
||||
Parent string
|
||||
Members map[string]Role
|
||||
}
|
||||
|
||||
// New builds a Policy from group specs. The built-in public group is always
|
||||
// created. A spec listing the same user in multiple roles keeps the last one;
|
||||
// callers should pass the highest intended role.
|
||||
func New(defaultUser string, specs []GroupSpec) *Policy {
|
||||
p := &Policy{
|
||||
defaultUser: strings.TrimSpace(defaultUser),
|
||||
blacklist: make(map[string]Level),
|
||||
userGroups: make(map[string][]string),
|
||||
logicEditors: make(map[string]bool),
|
||||
defaultUser: strings.TrimSpace(defaultUser),
|
||||
groups: make(map[string]*group),
|
||||
}
|
||||
for _, e := range logicEditors {
|
||||
e = strings.TrimSpace(e)
|
||||
if e != "" {
|
||||
p.logicEditors[e] = true
|
||||
}
|
||||
}
|
||||
for user, lvl := range blacklist {
|
||||
u := strings.TrimSpace(user)
|
||||
if u == "" {
|
||||
for _, s := range specs {
|
||||
name := strings.TrimSpace(s.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
p.blacklist[u] = ParseLevel(lvl)
|
||||
}
|
||||
for g, members := range groups {
|
||||
g = strings.TrimSpace(g)
|
||||
if g == "" {
|
||||
continue
|
||||
}
|
||||
p.groupNames = append(p.groupNames, g)
|
||||
for _, m := range members {
|
||||
m = strings.TrimSpace(m)
|
||||
if m == "" {
|
||||
continue
|
||||
g := p.ensureGroupLocked(name)
|
||||
g.parent = strings.TrimSpace(s.Parent)
|
||||
for u, r := range s.Members {
|
||||
if u = strings.TrimSpace(u); u != "" {
|
||||
g.members[u] = r
|
||||
}
|
||||
p.userGroups[m] = append(p.userGroups[m], g)
|
||||
}
|
||||
}
|
||||
sort.Strings(p.groupNames)
|
||||
p.ensureGroupLocked(PublicGroup).parent = ""
|
||||
p.normalizeParentsLocked()
|
||||
return p
|
||||
}
|
||||
|
||||
// GroupNames returns a copy of every configured user-group name, sorted.
|
||||
func (p *Policy) GroupNames() []string {
|
||||
out := make([]string, len(p.groupNames))
|
||||
copy(out, p.groupNames)
|
||||
return out
|
||||
// ensureGroupLocked returns the named group, creating an empty one if needed.
|
||||
// The caller must hold p.mu for writing (or be in construction).
|
||||
func (p *Policy) ensureGroupLocked(name string) *group {
|
||||
g, ok := p.groups[name]
|
||||
if !ok {
|
||||
g = &group{members: make(map[string]Role)}
|
||||
p.groups[name] = g
|
||||
}
|
||||
return g
|
||||
}
|
||||
|
||||
// normalizeParentsLocked drops parent references to missing groups or that would
|
||||
// form a cycle, and forces the public group to be a root.
|
||||
func (p *Policy) normalizeParentsLocked() {
|
||||
for name, g := range p.groups {
|
||||
if name == PublicGroup {
|
||||
g.parent = ""
|
||||
continue
|
||||
}
|
||||
if g.parent == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := p.groups[g.parent]; !ok || p.hasCycleLocked(name) {
|
||||
g.parent = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// hasCycleLocked reports whether following parent links from start loops back.
|
||||
func (p *Policy) hasCycleLocked(start string) bool {
|
||||
seen := make(map[string]bool)
|
||||
for cur := start; cur != ""; {
|
||||
if seen[cur] {
|
||||
return true
|
||||
}
|
||||
seen[cur] = true
|
||||
g, ok := p.groups[cur]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
cur = g.parent
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ── effective role ─────────────────────────────────────────────────────────
|
||||
|
||||
// configuredLocked reports whether any explicit role is assigned anywhere. An
|
||||
// unconfigured policy is treated as fully open (bootstrap-safe).
|
||||
func (p *Policy) configuredLocked() bool {
|
||||
for _, g := range p.groups {
|
||||
if len(g.members) > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// effectiveRoleLocked returns a user's highest role across all groups. Unlisted
|
||||
// users (and anonymous callers) get the viewer baseline once the policy is
|
||||
// configured; an unconfigured policy grants everyone admin.
|
||||
func (p *Policy) effectiveRoleLocked(user string) Role {
|
||||
if !p.configuredLocked() {
|
||||
return RoleAdmin
|
||||
}
|
||||
best := RoleViewer
|
||||
if user = strings.TrimSpace(user); user == "" {
|
||||
return best
|
||||
}
|
||||
for _, g := range p.groups {
|
||||
if r, ok := g.members[user]; ok && r > best {
|
||||
best = r
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// ── persistence ────────────────────────────────────────────────────────────
|
||||
|
||||
// persisted is the on-disk schema for access.json.
|
||||
type persisted struct {
|
||||
DefaultUser string `json:"defaultUser"`
|
||||
Groups map[string]persistedGroup `json:"groups"`
|
||||
}
|
||||
|
||||
type persistedGroup struct {
|
||||
Parent string `json:"parent"`
|
||||
Members map[string]string `json:"members"` // user → role token
|
||||
}
|
||||
|
||||
// EnablePersistence points the policy at {storageDir}/access.json. If the file
|
||||
// exists its contents replace the TOML-seeded state (the file is the source of
|
||||
// truth once written); otherwise the current state is kept and the file is only
|
||||
// created on the first mutation.
|
||||
func (p *Policy) EnablePersistence(storageDir string) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.path = filepath.Join(storageDir, "access.json")
|
||||
data, err := os.ReadFile(p.path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var ps persisted
|
||||
if err := json.Unmarshal(data, &ps); err != nil {
|
||||
return err
|
||||
}
|
||||
p.defaultUser = strings.TrimSpace(ps.DefaultUser)
|
||||
p.groups = make(map[string]*group)
|
||||
for name, pg := range ps.Groups {
|
||||
if name = strings.TrimSpace(name); name == "" {
|
||||
continue
|
||||
}
|
||||
g := p.ensureGroupLocked(name)
|
||||
g.parent = strings.TrimSpace(pg.Parent)
|
||||
for u, token := range pg.Members {
|
||||
if u = strings.TrimSpace(u); u != "" {
|
||||
g.members[u] = ParseRole(token)
|
||||
}
|
||||
}
|
||||
}
|
||||
p.ensureGroupLocked(PublicGroup).parent = ""
|
||||
p.normalizeParentsLocked()
|
||||
return nil
|
||||
}
|
||||
|
||||
// saveLocked atomically persists the current state when persistence is enabled.
|
||||
func (p *Policy) saveLocked() error {
|
||||
if p.path == "" {
|
||||
return nil
|
||||
}
|
||||
ps := persisted{DefaultUser: p.defaultUser, Groups: make(map[string]persistedGroup, len(p.groups))}
|
||||
for name, g := range p.groups {
|
||||
pg := persistedGroup{Parent: g.parent, Members: make(map[string]string, len(g.members))}
|
||||
for u, r := range g.members {
|
||||
pg.Members[u] = r.String()
|
||||
}
|
||||
ps.Groups[name] = pg
|
||||
}
|
||||
data, err := json.MarshalIndent(ps, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmp := p.path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.Rename(tmp, p.path)
|
||||
}
|
||||
|
||||
// ── reads ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// ResolveUser trims the proxy-provided header value and falls back to the
|
||||
// configured default_user when it is empty (e.g. unproxied/dev deployments).
|
||||
func (p *Policy) ResolveUser(headerValue string) string {
|
||||
@@ -122,58 +342,335 @@ func (p *Policy) ResolveUser(headerValue string) string {
|
||||
return u
|
||||
}
|
||||
|
||||
// Level returns the global access level for a user. Users that are neither
|
||||
// blacklisted nor anonymous get full write access.
|
||||
// Level returns the coarse global access level for a user.
|
||||
func (p *Policy) Level(user string) Level {
|
||||
user = strings.TrimSpace(user)
|
||||
if user == "" {
|
||||
// No identity at all (no proxy header, no default_user): trusted LAN.
|
||||
return LevelWrite
|
||||
}
|
||||
if lvl, ok := p.blacklist[user]; ok {
|
||||
return lvl
|
||||
}
|
||||
return LevelWrite
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return roleToLevel(p.effectiveRoleLocked(user))
|
||||
}
|
||||
|
||||
// LogicRestricted reports whether a logic-editor allowlist is configured. When
|
||||
// false, any write-capable user may edit panel/control logic.
|
||||
func (p *Policy) LogicRestricted() bool {
|
||||
return len(p.logicEditors) > 0
|
||||
// EffectiveRole returns the highest role a user holds across all groups.
|
||||
func (p *Policy) EffectiveRole(user string) Role {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return p.effectiveRoleLocked(user)
|
||||
}
|
||||
|
||||
// CanEditLogic reports whether a user may add or edit panel logic and
|
||||
// server-side control logic. When no allowlist is configured everyone with
|
||||
// write access qualifies; otherwise the user (or one of their groups) must be
|
||||
// listed. Anonymous/trusted-LAN callers (user=="") are always permitted.
|
||||
// CanEditLogic reports whether a user may add or edit panel and control logic
|
||||
// (logic-editor role or higher).
|
||||
func (p *Policy) CanEditLogic(user string) bool {
|
||||
user = strings.TrimSpace(user)
|
||||
if user == "" {
|
||||
return true
|
||||
}
|
||||
if !p.LogicRestricted() {
|
||||
return true
|
||||
}
|
||||
if p.logicEditors[user] {
|
||||
return true
|
||||
}
|
||||
for _, g := range p.userGroups[user] {
|
||||
if p.logicEditors[g] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return p.effectiveRoleLocked(user) >= RoleLogic
|
||||
}
|
||||
|
||||
// GroupsOf returns a copy of the groups a user belongs to.
|
||||
func (p *Policy) GroupsOf(user string) []string {
|
||||
src := p.userGroups[strings.TrimSpace(user)]
|
||||
out := make([]string, len(src))
|
||||
copy(out, src)
|
||||
// CanViewAudit reports whether a user may view the audit log (auditor or admin).
|
||||
func (p *Policy) CanViewAudit(user string) bool {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return p.effectiveRoleLocked(user) >= RoleAuditor
|
||||
}
|
||||
|
||||
// CanAdmin reports whether a user may use the admin pane (admin role).
|
||||
func (p *Policy) CanAdmin(user string) bool {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return p.effectiveRoleLocked(user) >= RoleAdmin
|
||||
}
|
||||
|
||||
// GroupNames returns a copy of every group name, sorted.
|
||||
func (p *Policy) GroupNames() []string {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return p.groupNamesLocked()
|
||||
}
|
||||
|
||||
func (p *Policy) groupNamesLocked() []string {
|
||||
out := make([]string, 0, len(p.groups))
|
||||
for n := range p.groups {
|
||||
out = append(out, n)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// ── request-scoped user identity ────────────────────────────────────────────
|
||||
// GroupsOf returns the groups a user effectively belongs to: every group they
|
||||
// are an explicit member of, plus that group's descendants (membership flows
|
||||
// parent→child). The implicit public-group viewer baseline is not included.
|
||||
// Used by per-panel/folder sharing rules.
|
||||
func (p *Policy) GroupsOf(user string) []string {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
if user = strings.TrimSpace(user); user == "" {
|
||||
return nil
|
||||
}
|
||||
set := make(map[string]bool)
|
||||
for name, g := range p.groups {
|
||||
if _, ok := g.members[user]; ok {
|
||||
set[name] = true
|
||||
p.addDescendantsLocked(name, set)
|
||||
}
|
||||
}
|
||||
out := make([]string, 0, len(set))
|
||||
for n := range set {
|
||||
out = append(out, n)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// addDescendantsLocked adds every transitive child of parent into set.
|
||||
func (p *Policy) addDescendantsLocked(parent string, set map[string]bool) {
|
||||
for name, g := range p.groups {
|
||||
if g.parent == parent && !set[name] {
|
||||
set[name] = true
|
||||
p.addDescendantsLocked(name, set)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── admin snapshot ─────────────────────────────────────────────────────────
|
||||
|
||||
// MemberInfo is one user's role within a group.
|
||||
type MemberInfo struct {
|
||||
User string `json:"user"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
// GroupInfo describes one group for the admin pane.
|
||||
type GroupInfo struct {
|
||||
Name string `json:"name"`
|
||||
Parent string `json:"parent"`
|
||||
Members []MemberInfo `json:"members"`
|
||||
}
|
||||
|
||||
// UserInfo describes one user's memberships and resulting effective role.
|
||||
type UserInfo struct {
|
||||
Name string `json:"name"`
|
||||
EffectiveRole string `json:"effectiveRole"`
|
||||
Roles map[string]string `json:"roles"` // group → role token
|
||||
}
|
||||
|
||||
// AccessSnapshot is the full mutable access state, rendered for the admin pane.
|
||||
type AccessSnapshot struct {
|
||||
DefaultUser string `json:"defaultUser"`
|
||||
PublicGroup string `json:"publicGroup"`
|
||||
Roles []string `json:"roles"` // role ladder, low→high
|
||||
Configured bool `json:"configured"`
|
||||
Users []UserInfo `json:"users"`
|
||||
Groups []GroupInfo `json:"groups"`
|
||||
}
|
||||
|
||||
// Snapshot returns a copy of the full access configuration for the admin pane.
|
||||
func (p *Policy) Snapshot() AccessSnapshot {
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
|
||||
snap := AccessSnapshot{
|
||||
DefaultUser: p.defaultUser,
|
||||
PublicGroup: PublicGroup,
|
||||
Roles: append([]string(nil), RoleNames...),
|
||||
Configured: p.configuredLocked(),
|
||||
}
|
||||
|
||||
userSet := make(map[string]bool)
|
||||
for _, name := range p.groupNamesLocked() {
|
||||
g := p.groups[name]
|
||||
gi := GroupInfo{Name: name, Parent: g.parent}
|
||||
users := make([]string, 0, len(g.members))
|
||||
for u := range g.members {
|
||||
users = append(users, u)
|
||||
userSet[u] = true
|
||||
}
|
||||
sort.Strings(users)
|
||||
for _, u := range users {
|
||||
gi.Members = append(gi.Members, MemberInfo{User: u, Role: g.members[u].String()})
|
||||
}
|
||||
snap.Groups = append(snap.Groups, gi)
|
||||
}
|
||||
|
||||
users := make([]string, 0, len(userSet))
|
||||
for u := range userSet {
|
||||
users = append(users, u)
|
||||
}
|
||||
sort.Strings(users)
|
||||
for _, u := range users {
|
||||
ui := UserInfo{Name: u, EffectiveRole: p.effectiveRoleLocked(u).String(), Roles: make(map[string]string)}
|
||||
for name, g := range p.groups {
|
||||
if r, ok := g.members[u]; ok {
|
||||
ui.Roles[name] = r.String()
|
||||
}
|
||||
}
|
||||
snap.Users = append(snap.Users, ui)
|
||||
}
|
||||
return snap
|
||||
}
|
||||
|
||||
// ── mutations (admin pane) ─────────────────────────────────────────────────
|
||||
|
||||
// ErrNotFound is returned when a named group does not exist.
|
||||
var ErrNotFound = errors.New("group not found")
|
||||
|
||||
// SetMemberRole assigns a user a role within an existing group.
|
||||
func (p *Policy) SetMemberRole(groupName, user string, role Role) error {
|
||||
groupName, user = strings.TrimSpace(groupName), strings.TrimSpace(user)
|
||||
if groupName == "" {
|
||||
return errors.New("empty group name")
|
||||
}
|
||||
if user == "" {
|
||||
return errors.New("empty user")
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
g, ok := p.groups[groupName]
|
||||
if !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
g.members[user] = role
|
||||
return p.saveLocked()
|
||||
}
|
||||
|
||||
// RemoveMember drops a user's explicit role in a group.
|
||||
func (p *Policy) RemoveMember(groupName, user string) error {
|
||||
groupName, user = strings.TrimSpace(groupName), strings.TrimSpace(user)
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
g, ok := p.groups[groupName]
|
||||
if !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
delete(g.members, user)
|
||||
return p.saveLocked()
|
||||
}
|
||||
|
||||
// SetUserRoles replaces a user's full set of memberships: the user is placed in
|
||||
// exactly the listed groups with the given roles and removed from all others.
|
||||
// Listed groups that do not exist are created.
|
||||
func (p *Policy) SetUserRoles(user string, roles map[string]Role) error {
|
||||
user = strings.TrimSpace(user)
|
||||
if user == "" {
|
||||
return errors.New("empty user")
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
want := make(map[string]Role, len(roles))
|
||||
for g, r := range roles {
|
||||
if g = strings.TrimSpace(g); g != "" {
|
||||
want[g] = r
|
||||
p.ensureGroupLocked(g)
|
||||
}
|
||||
}
|
||||
for name, g := range p.groups {
|
||||
if r, ok := want[name]; ok {
|
||||
g.members[user] = r
|
||||
} else {
|
||||
delete(g.members, user)
|
||||
}
|
||||
}
|
||||
p.normalizeParentsLocked()
|
||||
return p.saveLocked()
|
||||
}
|
||||
|
||||
// CreateGroup adds an empty group with an optional parent if it does not exist.
|
||||
func (p *Policy) CreateGroup(name, parent string) error {
|
||||
name, parent = strings.TrimSpace(name), strings.TrimSpace(parent)
|
||||
if name == "" {
|
||||
return errors.New("empty group name")
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if _, ok := p.groups[name]; ok {
|
||||
return nil
|
||||
}
|
||||
g := p.ensureGroupLocked(name)
|
||||
g.parent = parent
|
||||
p.normalizeParentsLocked()
|
||||
return p.saveLocked()
|
||||
}
|
||||
|
||||
// SetGroup replaces an existing group's parent and members. The public group's
|
||||
// parent is always forced to root.
|
||||
func (p *Policy) SetGroup(name, parent string, members map[string]Role) error {
|
||||
name, parent = strings.TrimSpace(name), strings.TrimSpace(parent)
|
||||
if name == "" {
|
||||
return errors.New("empty group name")
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
g, ok := p.groups[name]
|
||||
if !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
if name != PublicGroup {
|
||||
g.parent = parent
|
||||
}
|
||||
g.members = make(map[string]Role, len(members))
|
||||
for u, r := range members {
|
||||
if u = strings.TrimSpace(u); u != "" {
|
||||
g.members[u] = r
|
||||
}
|
||||
}
|
||||
p.normalizeParentsLocked()
|
||||
return p.saveLocked()
|
||||
}
|
||||
|
||||
// RenameGroup renames a group, re-pointing any children at the new name. The
|
||||
// public group cannot be renamed.
|
||||
func (p *Policy) RenameGroup(oldName, newName string) error {
|
||||
oldName, newName = strings.TrimSpace(oldName), strings.TrimSpace(newName)
|
||||
if oldName == "" || newName == "" {
|
||||
return errors.New("empty group name")
|
||||
}
|
||||
if oldName == PublicGroup {
|
||||
return errors.New("cannot rename the public group")
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
g, ok := p.groups[oldName]
|
||||
if !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
if oldName == newName {
|
||||
return nil
|
||||
}
|
||||
if _, exists := p.groups[newName]; exists {
|
||||
return errors.New("group already exists: " + newName)
|
||||
}
|
||||
delete(p.groups, oldName)
|
||||
p.groups[newName] = g
|
||||
for _, other := range p.groups {
|
||||
if other.parent == oldName {
|
||||
other.parent = newName
|
||||
}
|
||||
}
|
||||
return p.saveLocked()
|
||||
}
|
||||
|
||||
// DeleteGroup removes a group, reparenting its children to the deleted group's
|
||||
// parent. The public group cannot be deleted; member users keep other groups.
|
||||
func (p *Policy) DeleteGroup(name string) error {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == PublicGroup {
|
||||
return errors.New("cannot delete the public group")
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
g, ok := p.groups[name]
|
||||
if !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
parent := g.parent
|
||||
delete(p.groups, name)
|
||||
for _, other := range p.groups {
|
||||
if other.parent == name {
|
||||
other.parent = parent
|
||||
}
|
||||
}
|
||||
p.normalizeParentsLocked()
|
||||
return p.saveLocked()
|
||||
}
|
||||
|
||||
// ── request-scoped user identity ───────────────────────────────────────────
|
||||
|
||||
type ctxKey struct{}
|
||||
|
||||
|
||||
+257
-26
@@ -1,35 +1,266 @@
|
||||
package access
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCanEditLogic(t *testing.T) {
|
||||
groups := map[string][]string{"ops": {"carol"}}
|
||||
// spec is a small helper to build a GroupSpec.
|
||||
func spec(name, parent string, members map[string]Role) GroupSpec {
|
||||
return GroupSpec{Name: name, Parent: parent, Members: members}
|
||||
}
|
||||
|
||||
// No allowlist configured: everyone with write access may edit logic.
|
||||
open := New("", nil, groups, nil)
|
||||
func TestUnconfiguredIsOpen(t *testing.T) {
|
||||
// No roles assigned anywhere → fully open (everyone is admin), matching a
|
||||
// fresh/dev deployment.
|
||||
p := New("", nil)
|
||||
for _, u := range []string{"", "alice", "carol"} {
|
||||
if !open.CanEditLogic(u) {
|
||||
t.Errorf("unrestricted: CanEditLogic(%q) = false, want true", u)
|
||||
if !p.CanAdmin(u) {
|
||||
t.Errorf("unconfigured: CanAdmin(%q) = false, want true", u)
|
||||
}
|
||||
}
|
||||
if open.LogicRestricted() {
|
||||
t.Error("LogicRestricted() = true with no allowlist")
|
||||
}
|
||||
|
||||
// Allowlist by username and by group name.
|
||||
p := New("", nil, groups, []string{"alice", "ops"})
|
||||
if !p.LogicRestricted() {
|
||||
t.Error("LogicRestricted() = false with allowlist set")
|
||||
}
|
||||
cases := map[string]bool{
|
||||
"": true, // anonymous / trusted LAN
|
||||
"alice": true, // listed user
|
||||
"carol": true, // member of listed group "ops"
|
||||
"bob": false, // not listed
|
||||
}
|
||||
for u, want := range cases {
|
||||
if got := p.CanEditLogic(u); got != want {
|
||||
t.Errorf("CanEditLogic(%q) = %v, want %v", u, got, want)
|
||||
if p.Level(u) != LevelWrite {
|
||||
t.Errorf("unconfigured: Level(%q) = %v, want write", u, p.Level(u))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoleLadder(t *testing.T) {
|
||||
p := New("", []GroupSpec{
|
||||
spec("public", "", map[string]Role{"adm": RoleAdmin}),
|
||||
spec("ops", "", map[string]Role{
|
||||
"viewer": RoleViewer,
|
||||
"op": RoleOperator,
|
||||
"logic": RoleLogic,
|
||||
"aud": RoleAuditor,
|
||||
}),
|
||||
})
|
||||
|
||||
// Configured now → unlisted users (and anonymous) are viewers (read-only).
|
||||
if p.Level("") != LevelRead {
|
||||
t.Errorf("anonymous Level = %v, want read", p.Level(""))
|
||||
}
|
||||
if p.Level("nobody") != LevelRead {
|
||||
t.Errorf("unlisted Level = %v, want read", p.Level("nobody"))
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
user string
|
||||
level Level
|
||||
editLogic bool
|
||||
audit bool
|
||||
admin bool
|
||||
}{
|
||||
{"viewer", LevelRead, false, false, false},
|
||||
{"op", LevelWrite, false, false, false},
|
||||
{"logic", LevelWrite, true, false, false},
|
||||
{"aud", LevelWrite, true, true, false},
|
||||
{"adm", LevelWrite, true, true, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if p.Level(c.user) != c.level {
|
||||
t.Errorf("%s: Level = %v, want %v", c.user, p.Level(c.user), c.level)
|
||||
}
|
||||
if p.CanEditLogic(c.user) != c.editLogic {
|
||||
t.Errorf("%s: CanEditLogic = %v, want %v", c.user, p.CanEditLogic(c.user), c.editLogic)
|
||||
}
|
||||
if p.CanViewAudit(c.user) != c.audit {
|
||||
t.Errorf("%s: CanViewAudit = %v, want %v", c.user, p.CanViewAudit(c.user), c.audit)
|
||||
}
|
||||
if p.CanAdmin(c.user) != c.admin {
|
||||
t.Errorf("%s: CanAdmin = %v, want %v", c.user, p.CanAdmin(c.user), c.admin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEffectiveRoleIsMaxAcrossGroups(t *testing.T) {
|
||||
p := New("", []GroupSpec{
|
||||
spec("public", "", map[string]Role{"x": RoleViewer}),
|
||||
spec("a", "", map[string]Role{"x": RoleOperator}),
|
||||
spec("b", "", map[string]Role{"x": RoleAuditor}),
|
||||
})
|
||||
if got := p.EffectiveRole("x"); got != RoleAuditor {
|
||||
t.Errorf("EffectiveRole(x) = %v, want auditor (max across groups)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNestingInheritsMembership(t *testing.T) {
|
||||
// org → team-a → squad-1. A member of org is effectively in the descendants
|
||||
// too (membership flows parent→child), surfaced via GroupsOf for panel ACLs.
|
||||
p := New("", []GroupSpec{
|
||||
spec("org", "", map[string]Role{"boss": RoleAdmin}),
|
||||
spec("team-a", "org", nil),
|
||||
spec("squad-1", "team-a", nil),
|
||||
})
|
||||
groups := p.GroupsOf("boss")
|
||||
for _, want := range []string{"org", "team-a", "squad-1"} {
|
||||
if !containsStr(groups, want) {
|
||||
t.Errorf("GroupsOf(boss) = %v, missing %q", groups, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMutationsRoundTrip(t *testing.T) {
|
||||
p := New("", []GroupSpec{
|
||||
spec("public", "", map[string]Role{"root": RoleAdmin}),
|
||||
spec("ops", "", map[string]Role{"carol": RoleOperator}),
|
||||
})
|
||||
|
||||
// Set a user's full membership set: dave operator in ops, admin in eng (new).
|
||||
if err := p.SetUserRoles("dave", map[string]Role{"ops": RoleOperator, "eng": RoleAdmin}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if g := p.GroupsOf("dave"); !containsStr(g, "ops") || !containsStr(g, "eng") {
|
||||
t.Errorf("GroupsOf(dave) = %v, want ops+eng", g)
|
||||
}
|
||||
if !p.CanAdmin("dave") { // admin in eng
|
||||
t.Error("CanAdmin(dave) = false after admin role in eng")
|
||||
}
|
||||
|
||||
// Replacing membership removes dave from ops.
|
||||
if err := p.SetUserRoles("dave", map[string]Role{"eng": RoleAdmin}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if containsStr(p.GroupsOf("dave"), "ops") {
|
||||
t.Error("dave still in ops after membership replaced")
|
||||
}
|
||||
|
||||
// Rename carries dave's admin grant from eng → engineering.
|
||||
if err := p.RenameGroup("eng", "engineering"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !p.CanAdmin("dave") {
|
||||
t.Error("CanAdmin(dave) = false after eng renamed to engineering")
|
||||
}
|
||||
if !containsStr(p.GroupNames(), "engineering") || containsStr(p.GroupNames(), "eng") {
|
||||
t.Errorf("GroupNames after rename = %v", p.GroupNames())
|
||||
}
|
||||
|
||||
// Delete drops the group; dave loses admin but root (in public) keeps it.
|
||||
if err := p.DeleteGroup("engineering"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.CanAdmin("dave") {
|
||||
t.Error("CanAdmin(dave) = true after engineering deleted")
|
||||
}
|
||||
if !p.CanAdmin("root") {
|
||||
t.Error("CanAdmin(root) = false; root grant in public should survive")
|
||||
}
|
||||
if err := p.DeleteGroup("nope"); err != ErrNotFound {
|
||||
t.Errorf("DeleteGroup(nope) = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicGroupProtected(t *testing.T) {
|
||||
p := New("", []GroupSpec{spec("public", "", map[string]Role{"a": RoleAdmin})})
|
||||
if err := p.DeleteGroup(PublicGroup); err == nil {
|
||||
t.Error("DeleteGroup(public) = nil, want error")
|
||||
}
|
||||
if err := p.RenameGroup(PublicGroup, "other"); err == nil {
|
||||
t.Error("RenameGroup(public) = nil, want error")
|
||||
}
|
||||
if !containsStr(p.GroupNames(), PublicGroup) {
|
||||
t.Error("public group missing after protected mutations")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParentCycleRejected(t *testing.T) {
|
||||
p := New("", []GroupSpec{
|
||||
spec("a", "", map[string]Role{"x": RoleViewer}),
|
||||
spec("b", "a", nil),
|
||||
})
|
||||
// Making a's parent b would create a cycle a→b→a; it must be dropped to root.
|
||||
if err := p.SetGroup("a", "b", map[string]Role{"x": RoleViewer}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
snap := p.Snapshot()
|
||||
for _, g := range snap.Groups {
|
||||
if g.Name == "a" && g.Parent != "" {
|
||||
t.Errorf("group a parent = %q, want root (cycle should be dropped)", g.Parent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersistenceRoundTrip(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
|
||||
p := New("admin", []GroupSpec{
|
||||
spec("public", "", map[string]Role{"alice": RoleLogic}),
|
||||
spec("ops", "", map[string]Role{"carol": RoleAdmin}),
|
||||
})
|
||||
if err := p.EnablePersistence(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A mutation triggers the first write of access.json.
|
||||
if err := p.SetUserRoles("dave", map[string]Role{"ops": RoleOperator}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(dir, "access.json")); err != nil {
|
||||
t.Fatalf("access.json not written: %v", err)
|
||||
}
|
||||
|
||||
// A fresh policy loading the same dir should see the persisted state, not the
|
||||
// (different) seed it was constructed with.
|
||||
p2 := New("seed", []GroupSpec{spec("public", "", map[string]Role{"seeduser": RoleViewer})})
|
||||
if err := p2.EnablePersistence(dir); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p2.ResolveUser("") != "admin" {
|
||||
t.Errorf("default user = %q, want admin", p2.ResolveUser(""))
|
||||
}
|
||||
if !p2.CanEditLogic("alice") || p2.CanEditLogic("zed") {
|
||||
t.Error("logic-editor role not persisted")
|
||||
}
|
||||
if !p2.CanAdmin("carol") {
|
||||
t.Error("admin role not persisted")
|
||||
}
|
||||
if !containsStr(p2.GroupsOf("dave"), "ops") {
|
||||
t.Error("dave membership not persisted")
|
||||
}
|
||||
if p2.EffectiveRole("seeduser") != RoleViewer || p2.Level("seeduser") != LevelRead {
|
||||
// seeduser came from the discarded seed; should be an unlisted viewer now.
|
||||
t.Error("persisted state did not supersede the seed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentAccess(t *testing.T) {
|
||||
p := New("", []GroupSpec{
|
||||
spec("public", "", map[string]Role{"root": RoleAdmin}),
|
||||
spec("ops", "", map[string]Role{"carol": RoleOperator}),
|
||||
})
|
||||
if err := p.EnablePersistence(t.TempDir()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 8; i++ {
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < 100; j++ {
|
||||
p.CanAdmin("carol")
|
||||
p.Level("carol")
|
||||
p.GroupsOf("carol")
|
||||
p.Snapshot()
|
||||
}
|
||||
}()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := 0; j < 100; j++ {
|
||||
p.SetMemberRole("ops", "carol", RoleAuditor)
|
||||
p.SetUserRoles("carol", map[string]Role{"ops": RoleOperator, "eng": RoleAdmin})
|
||||
p.RemoveMember("ops", "carol")
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func containsStr(xs []string, v string) bool {
|
||||
for _, x := range xs {
|
||||
if x == v {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package access
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Visibility scope tokens shared by user-owned, filterable objects across uopi
|
||||
// (config sets/instances, control-logic graphs, synthetic signals). They drive
|
||||
// the per-tree "Mine / Group / Global" selector. Storage carries the scope as a
|
||||
// plain string so each subsystem can embed it in its own JSON without depending
|
||||
// on this package's types.
|
||||
const (
|
||||
// ScopePrivate: visible only to the owner.
|
||||
ScopePrivate = "private"
|
||||
// ScopeGroup: visible to the owner and members of any listed group.
|
||||
ScopeGroup = "group"
|
||||
// ScopeGlobal: visible to everyone. This is also the legacy default — an
|
||||
// empty/unknown scope is treated as global so objects created before scopes
|
||||
// existed stay visible to all.
|
||||
ScopeGlobal = "global"
|
||||
)
|
||||
|
||||
// CanSee reports whether user (a member of userGroups) may see an object with the
|
||||
// given owner, scope and itemGroups. An empty or unrecognised scope is treated as
|
||||
// global, so legacy objects without a scope remain visible to everyone.
|
||||
//
|
||||
// This is a visibility filter for selector trees, not a hard security boundary:
|
||||
// it governs which objects are offered in listings, and intentionally always
|
||||
// shows an object to its owner regardless of scope.
|
||||
func CanSee(user, owner, scope string, itemGroups, userGroups []string) bool {
|
||||
switch strings.ToLower(strings.TrimSpace(scope)) {
|
||||
case ScopePrivate:
|
||||
return owner != "" && owner == user
|
||||
case ScopeGroup:
|
||||
if owner != "" && owner == user {
|
||||
return true
|
||||
}
|
||||
for _, g := range itemGroups {
|
||||
if slices.Contains(userGroups, g) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
default: // global / empty / unknown
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// CanSee reports whether user may see an object with the given owner, scope and
|
||||
// itemGroups, resolving the user's group memberships from the policy. It is the
|
||||
// convenience wrapper list handlers use.
|
||||
func (p *Policy) CanSee(user, owner, scope string, itemGroups []string) bool {
|
||||
return CanSee(user, owner, scope, itemGroups, p.GroupsOf(user))
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package access
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCanSee(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
user string
|
||||
owner string
|
||||
scope string
|
||||
itemGroups []string
|
||||
userGroups []string
|
||||
want bool
|
||||
}{
|
||||
{"global visible to anyone", "bob", "alice", ScopeGlobal, nil, nil, true},
|
||||
{"empty scope = global", "bob", "alice", "", nil, nil, true},
|
||||
{"unknown scope = global", "bob", "alice", "weird", nil, nil, true},
|
||||
{"private hidden from others", "bob", "alice", ScopePrivate, nil, nil, false},
|
||||
{"private visible to owner", "alice", "alice", ScopePrivate, nil, nil, true},
|
||||
{"private with empty owner hidden", "bob", "", ScopePrivate, nil, nil, false},
|
||||
{"group visible to member", "bob", "alice", ScopeGroup, []string{"ops"}, []string{"ops"}, true},
|
||||
{"group hidden from non-member", "bob", "alice", ScopeGroup, []string{"ops"}, []string{"eng"}, false},
|
||||
{"group visible to owner even if not a member", "alice", "alice", ScopeGroup, []string{"ops"}, nil, true},
|
||||
{"group with multiple item groups", "bob", "alice", ScopeGroup, []string{"ops", "eng"}, []string{"eng"}, true},
|
||||
{"case-insensitive scope token", "bob", "alice", "PRIVATE", nil, nil, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := CanSee(c.user, c.owner, c.scope, c.itemGroups, c.userGroups); got != c.want {
|
||||
t.Errorf("CanSee(%q,%q,%q,%v,%v) = %v, want %v",
|
||||
c.user, c.owner, c.scope, c.itemGroups, c.userGroups, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPolicyCanSeeResolvesGroups(t *testing.T) {
|
||||
p := New("", []GroupSpec{{Name: "ops", Members: map[string]Role{"bob": RoleOperator}}})
|
||||
// bob is a member of ops; a group-scoped item shared with ops is visible.
|
||||
if !p.CanSee("bob", "alice", ScopeGroup, []string{"ops"}) {
|
||||
t.Errorf("expected bob to see an ops-scoped item")
|
||||
}
|
||||
// carol is in no group; the same item is hidden.
|
||||
if p.CanSee("carol", "alice", ScopeGroup, []string{"ops"}) {
|
||||
t.Errorf("expected carol not to see an ops-scoped item")
|
||||
}
|
||||
}
|
||||
+625
-26
@@ -2,22 +2,31 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/uopi/uopi/internal/access"
|
||||
"github.com/uopi/uopi/internal/audit"
|
||||
"github.com/uopi/uopi/internal/broker"
|
||||
"github.com/uopi/uopi/internal/confmgr"
|
||||
"github.com/uopi/uopi/internal/controllogic"
|
||||
"github.com/uopi/uopi/internal/datasource"
|
||||
"github.com/uopi/uopi/internal/datasource/synthetic"
|
||||
"github.com/uopi/uopi/internal/metrics"
|
||||
"github.com/uopi/uopi/internal/panelacl"
|
||||
"github.com/uopi/uopi/internal/storage"
|
||||
)
|
||||
@@ -27,27 +36,37 @@ type Handler struct {
|
||||
broker *broker.Broker
|
||||
synthetic *synthetic.Synthetic // nil if not enabled
|
||||
store *storage.Store
|
||||
cfg *confmgr.Store
|
||||
policy *access.Policy
|
||||
acl *panelacl.Store
|
||||
ctrlLogic *controllogic.Store
|
||||
ctrlEngine *controllogic.Engine
|
||||
channelFinderURL string // empty if not configured
|
||||
archiverURL string // empty if not configured
|
||||
audit audit.Recorder // never nil; audit.Nop when disabled
|
||||
channelFinderURL string // empty if not configured
|
||||
archiverURL string // empty if not configured
|
||||
uiDefaultZoom float64 // base UI scale sent to clients; 0 means 1.0
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// New creates an API Handler. synth may be nil if the synthetic DS is disabled.
|
||||
func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, channelFinderURL, archiverURL string, log *slog.Logger) *Handler {
|
||||
// rec records system-affecting mutations; pass audit.Nop() to disable auditing.
|
||||
func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, cfg *confmgr.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, rec audit.Recorder, channelFinderURL, archiverURL string, uiDefaultZoom float64, log *slog.Logger) *Handler {
|
||||
if rec == nil {
|
||||
rec = audit.Nop()
|
||||
}
|
||||
return &Handler{
|
||||
broker: b,
|
||||
synthetic: synth,
|
||||
store: store,
|
||||
cfg: cfg,
|
||||
policy: policy,
|
||||
acl: acl,
|
||||
ctrlLogic: ctrlLogic,
|
||||
ctrlEngine: ctrlEngine,
|
||||
audit: rec,
|
||||
channelFinderURL: channelFinderURL,
|
||||
archiverURL: archiverURL,
|
||||
uiDefaultZoom: uiDefaultZoom,
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
@@ -56,6 +75,7 @@ func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, pol
|
||||
// Requires Go 1.22+ for method+path routing.
|
||||
func (h *Handler) Register(mux *http.ServeMux, prefix string) {
|
||||
mux.HandleFunc("GET "+prefix+"/me", h.getMe)
|
||||
mux.HandleFunc("GET "+prefix+"/audit", h.getAudit)
|
||||
mux.HandleFunc("GET "+prefix+"/datasources", h.listDataSources)
|
||||
mux.HandleFunc("GET "+prefix+"/signals", h.listSignals)
|
||||
mux.HandleFunc("GET "+prefix+"/signals/search", h.searchSignals)
|
||||
@@ -83,6 +103,14 @@ func (h *Handler) Register(mux *http.ServeMux, prefix string) {
|
||||
mux.HandleFunc("DELETE "+prefix+"/folders/{id}", h.deleteFolder)
|
||||
// User groups defined in config (read-only; for the sharing UI)
|
||||
mux.HandleFunc("GET "+prefix+"/usergroups", h.listUserGroups)
|
||||
// Admin pane: runtime access management + server statistics. Every route is
|
||||
// gated by CanAdmin (the admins allowlist).
|
||||
mux.HandleFunc("GET "+prefix+"/admin/access", h.getAdminAccess)
|
||||
mux.HandleFunc("PUT "+prefix+"/admin/users/{user}", h.putAdminUser)
|
||||
mux.HandleFunc("POST "+prefix+"/admin/groups", h.createAdminGroup)
|
||||
mux.HandleFunc("PUT "+prefix+"/admin/groups/{name}", h.updateAdminGroup)
|
||||
mux.HandleFunc("DELETE "+prefix+"/admin/groups/{name}", h.deleteAdminGroup)
|
||||
mux.HandleFunc("GET "+prefix+"/admin/stats", h.getAdminStats)
|
||||
// Signal group tree
|
||||
mux.HandleFunc("GET "+prefix+"/groups", h.getGroups)
|
||||
mux.HandleFunc("PUT "+prefix+"/groups", h.putGroups)
|
||||
@@ -92,6 +120,13 @@ func (h *Handler) Register(mux *http.ServeMux, prefix string) {
|
||||
mux.HandleFunc("GET "+prefix+"/synthetic/{name}", h.getSynthetic)
|
||||
mux.HandleFunc("PUT "+prefix+"/synthetic/{name}", h.updateSynthetic)
|
||||
mux.HandleFunc("DELETE "+prefix+"/synthetic/{name}", h.deleteSynthetic)
|
||||
// Single-shot debug trace of an unsaved synthetic graph (live editor overlay).
|
||||
mux.HandleFunc("POST "+prefix+"/synthetic/trace", h.traceSynthetic)
|
||||
// Synthetic signal git-style versioning (list / view / promote / fork).
|
||||
mux.HandleFunc("GET "+prefix+"/synthetic/{name}/versions", h.listSyntheticVersions)
|
||||
mux.HandleFunc("GET "+prefix+"/synthetic/{name}/versions/{version}", h.getSyntheticVersion)
|
||||
mux.HandleFunc("POST "+prefix+"/synthetic/{name}/versions/{version}/promote", h.promoteSyntheticVersion)
|
||||
mux.HandleFunc("POST "+prefix+"/synthetic/{name}/versions/{version}/fork", h.forkSyntheticVersion)
|
||||
// Server-side control logic CRUD (mutations are write-gated by the access
|
||||
// middleware; each mutation reloads the running engine).
|
||||
mux.HandleFunc("GET "+prefix+"/controllogic", h.listControlLogic)
|
||||
@@ -99,6 +134,52 @@ func (h *Handler) Register(mux *http.ServeMux, prefix string) {
|
||||
mux.HandleFunc("GET "+prefix+"/controllogic/{id}", h.getControlLogic)
|
||||
mux.HandleFunc("PUT "+prefix+"/controllogic/{id}", h.updateControlLogic)
|
||||
mux.HandleFunc("DELETE "+prefix+"/controllogic/{id}", h.deleteControlLogic)
|
||||
// Control-logic git-style versioning (list / view / promote / fork).
|
||||
mux.HandleFunc("GET "+prefix+"/controllogic/{id}/versions", h.listControlLogicVersions)
|
||||
mux.HandleFunc("GET "+prefix+"/controllogic/{id}/versions/{version}", h.getControlLogicVersion)
|
||||
mux.HandleFunc("POST "+prefix+"/controllogic/{id}/versions/{version}/promote", h.promoteControlLogicVersion)
|
||||
mux.HandleFunc("POST "+prefix+"/controllogic/{id}/versions/{version}/fork", h.forkControlLogicVersion)
|
||||
// Configuration manager — config sets (schemas) and instances (values),
|
||||
// both versioned git-style. Mutations are write-gated by the access
|
||||
// middleware; "apply" writes each value to its target signal.
|
||||
mux.HandleFunc("GET "+prefix+"/config/sets", h.listConfigSets)
|
||||
mux.HandleFunc("POST "+prefix+"/config/sets", h.createConfigSet)
|
||||
mux.HandleFunc("GET "+prefix+"/config/sets/{id}", h.getConfigSet)
|
||||
mux.HandleFunc("PUT "+prefix+"/config/sets/{id}", h.updateConfigSet)
|
||||
mux.HandleFunc("DELETE "+prefix+"/config/sets/{id}", h.deleteConfigSet)
|
||||
mux.HandleFunc("GET "+prefix+"/config/sets/{id}/versions", h.listConfigSetVersions)
|
||||
mux.HandleFunc("GET "+prefix+"/config/sets/{id}/versions/{version}", h.getConfigSetVersion)
|
||||
mux.HandleFunc("POST "+prefix+"/config/sets/{id}/versions/{version}/promote", h.promoteConfigSet)
|
||||
mux.HandleFunc("POST "+prefix+"/config/sets/{id}/versions/{version}/fork", h.forkConfigSet)
|
||||
mux.HandleFunc("POST "+prefix+"/config/sets/{id}/snapshot", h.snapshotConfigSet)
|
||||
mux.HandleFunc("GET "+prefix+"/config/sets/diff", h.diffConfigSets)
|
||||
mux.HandleFunc("GET "+prefix+"/config/instances", h.listConfigInstances)
|
||||
mux.HandleFunc("POST "+prefix+"/config/instances", h.createConfigInstance)
|
||||
mux.HandleFunc("GET "+prefix+"/config/instances/{id}", h.getConfigInstance)
|
||||
mux.HandleFunc("PUT "+prefix+"/config/instances/{id}", h.updateConfigInstance)
|
||||
mux.HandleFunc("DELETE "+prefix+"/config/instances/{id}", h.deleteConfigInstance)
|
||||
mux.HandleFunc("GET "+prefix+"/config/instances/{id}/versions", h.listConfigInstanceVersions)
|
||||
mux.HandleFunc("GET "+prefix+"/config/instances/{id}/versions/{version}", h.getConfigInstanceVersion)
|
||||
mux.HandleFunc("POST "+prefix+"/config/instances/{id}/versions/{version}/promote", h.promoteConfigInstance)
|
||||
mux.HandleFunc("POST "+prefix+"/config/instances/{id}/versions/{version}/fork", h.forkConfigInstance)
|
||||
mux.HandleFunc("POST "+prefix+"/config/instances/{id}/apply", h.applyConfigInstance)
|
||||
mux.HandleFunc("POST "+prefix+"/config/instances/{id}/validate", h.validateConfigInstance)
|
||||
mux.HandleFunc("GET "+prefix+"/config/instances/{id}/livediff", h.diffConfigInstanceLive)
|
||||
mux.HandleFunc("GET "+prefix+"/config/instances/diff", h.diffConfigInstances)
|
||||
// Config rules — CUE validation/transformation logic bound to a set, run
|
||||
// when instances of that set are saved. Versioned git-style; "check"
|
||||
// evaluates an unsaved source for the live editor.
|
||||
mux.HandleFunc("GET "+prefix+"/config/rules", h.listConfigRules)
|
||||
mux.HandleFunc("POST "+prefix+"/config/rules", h.createConfigRule)
|
||||
mux.HandleFunc("POST "+prefix+"/config/rules/check", h.checkConfigRule)
|
||||
mux.HandleFunc("POST "+prefix+"/config/rules/preview", h.previewConfigRule)
|
||||
mux.HandleFunc("GET "+prefix+"/config/rules/{id}", h.getConfigRule)
|
||||
mux.HandleFunc("PUT "+prefix+"/config/rules/{id}", h.updateConfigRule)
|
||||
mux.HandleFunc("DELETE "+prefix+"/config/rules/{id}", h.deleteConfigRule)
|
||||
mux.HandleFunc("GET "+prefix+"/config/rules/{id}/versions", h.listConfigRuleVersions)
|
||||
mux.HandleFunc("GET "+prefix+"/config/rules/{id}/versions/{version}", h.getConfigRuleVersion)
|
||||
mux.HandleFunc("POST "+prefix+"/config/rules/{id}/versions/{version}/promote", h.promoteConfigRule)
|
||||
mux.HandleFunc("POST "+prefix+"/config/rules/{id}/versions/{version}/fork", h.forkConfigRule)
|
||||
}
|
||||
|
||||
// ── /me ─────────────────────────────────────────────────────────────────────
|
||||
@@ -113,20 +194,104 @@ func (h *Handler) getMe(w http.ResponseWriter, r *http.Request) {
|
||||
if groups == nil {
|
||||
groups = []string{}
|
||||
}
|
||||
defaultZoom := h.uiDefaultZoom
|
||||
if defaultZoom <= 0 {
|
||||
defaultZoom = 1.0
|
||||
}
|
||||
jsonOK(w, map[string]any{
|
||||
"user": user,
|
||||
"level": h.policy.Level(user).String(),
|
||||
"groups": groups,
|
||||
"canEditLogic": h.policy.CanEditLogic(user),
|
||||
"canViewAudit": h.policy.CanViewAudit(user),
|
||||
"canAdmin": h.policy.CanAdmin(user),
|
||||
"defaultZoom": defaultZoom,
|
||||
})
|
||||
}
|
||||
|
||||
// ── /audit ────────────────────────────────────────────────────────────────────
|
||||
|
||||
// getAudit returns audit-log entries matching the query filters. Access is
|
||||
// restricted to users permitted by CanViewAudit (the audit-readers allowlist).
|
||||
// Filters: start, end (RFC3339), user, action, ds, signal, limit.
|
||||
func (h *Handler) getAudit(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.policy.CanViewAudit(caller(r)) {
|
||||
jsonError(w, http.StatusForbidden, "you are not permitted to view the audit log")
|
||||
return
|
||||
}
|
||||
q := r.URL.Query()
|
||||
f := audit.Filter{
|
||||
Actor: q.Get("user"),
|
||||
Action: q.Get("action"),
|
||||
DS: q.Get("ds"),
|
||||
Signal: q.Get("signal"),
|
||||
}
|
||||
if v := q.Get("start"); v != "" {
|
||||
t, err := time.Parse(time.RFC3339, v)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "invalid start time (want RFC3339): "+v)
|
||||
return
|
||||
}
|
||||
f.Start = t
|
||||
}
|
||||
if v := q.Get("end"); v != "" {
|
||||
t, err := time.Parse(time.RFC3339, v)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "invalid end time (want RFC3339): "+v)
|
||||
return
|
||||
}
|
||||
f.End = t
|
||||
}
|
||||
if v := q.Get("limit"); v != "" {
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
f.Limit = n
|
||||
}
|
||||
}
|
||||
events, err := h.audit.Query(f)
|
||||
if err != nil {
|
||||
h.log.Error("audit query", "err", err)
|
||||
jsonError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
jsonOK(w, events)
|
||||
}
|
||||
|
||||
// ── access helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
// caller returns the resolved end-user identity for the request (set by the
|
||||
// access middleware).
|
||||
func caller(r *http.Request) string { return access.UserFrom(r.Context()) }
|
||||
|
||||
// clientIP returns the best-effort client address for audit attribution,
|
||||
// preferring the proxy-set X-Forwarded-For / X-Real-IP headers.
|
||||
func clientIP(r *http.Request) string {
|
||||
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
|
||||
if i := strings.IndexByte(xff, ','); i >= 0 {
|
||||
return strings.TrimSpace(xff[:i])
|
||||
}
|
||||
return strings.TrimSpace(xff)
|
||||
}
|
||||
if xr := r.Header.Get("X-Real-IP"); xr != "" {
|
||||
return strings.TrimSpace(xr)
|
||||
}
|
||||
if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
|
||||
return host
|
||||
}
|
||||
return r.RemoteAddr
|
||||
}
|
||||
|
||||
// recordMutation appends a successful configuration change to the audit log.
|
||||
func (h *Handler) recordMutation(r *http.Request, action, detail string) {
|
||||
h.audit.Record(audit.Event{
|
||||
Actor: caller(r),
|
||||
ActorType: audit.ActorUser,
|
||||
Action: action,
|
||||
Detail: detail,
|
||||
IP: clientIP(r),
|
||||
Outcome: audit.OutcomeOK,
|
||||
})
|
||||
}
|
||||
|
||||
// capByGlobal lowers a per-panel/folder permission to what the user's global
|
||||
// access level allows: read-only users can never exceed read, no-access users
|
||||
// get nothing.
|
||||
@@ -142,23 +307,25 @@ func (h *Handler) capByGlobal(user string, p panelacl.Perm) panelacl.Perm {
|
||||
return p
|
||||
}
|
||||
|
||||
// panelPerm returns the caller's effective permission on a panel. A request
|
||||
// with no resolved identity (no proxy header and no default_user) is a trusted
|
||||
// LAN deployment with no per-user enforcement, so it gets full write.
|
||||
// panelPerm returns the caller's effective permission on a panel. A request with
|
||||
// no resolved identity (no proxy header and no default_user) has no per-user ACL,
|
||||
// so it is governed purely by the global level: full write on an unconfigured
|
||||
// (open) policy, read-only once roles are configured.
|
||||
func (h *Handler) panelPerm(r *http.Request, id string) panelacl.Perm {
|
||||
user := caller(r)
|
||||
if user == "" {
|
||||
return panelacl.PermWrite
|
||||
return h.capByGlobal("", panelacl.PermWrite)
|
||||
}
|
||||
return h.capByGlobal(user, h.acl.PanelPerm(id, user, h.policy.GroupsOf(user)))
|
||||
}
|
||||
|
||||
// folderPerm returns the caller's effective permission on a folder. As with
|
||||
// panelPerm, an unidentified caller is trusted with full write.
|
||||
// panelPerm, an unidentified caller is governed by the global level (full write
|
||||
// on an unconfigured/open policy, read-only once roles are configured).
|
||||
func (h *Handler) folderPerm(r *http.Request, id string) panelacl.Perm {
|
||||
user := caller(r)
|
||||
if user == "" {
|
||||
return panelacl.PermWrite
|
||||
return h.capByGlobal("", panelacl.PermWrite)
|
||||
}
|
||||
return h.capByGlobal(user, h.acl.FolderPerm(id, user, h.policy.GroupsOf(user)))
|
||||
}
|
||||
@@ -206,8 +373,9 @@ func (h *Handler) listSignals(w http.ResponseWriter, r *http.Request) {
|
||||
if dsName == "synthetic" && h.synthetic != nil {
|
||||
user := caller(r)
|
||||
panel := r.URL.Query().Get("panel")
|
||||
userGroups := h.policy.GroupsOf(user)
|
||||
metas := h.synthetic.FilteredMetadata(func(d synthetic.SignalDef) bool {
|
||||
return synVisible(d, user, panel)
|
||||
return synVisible(d, user, panel, userGroups)
|
||||
})
|
||||
out := make([]signalInfo, len(metas))
|
||||
for i, m := range metas {
|
||||
@@ -238,12 +406,15 @@ func (h *Handler) listSignals(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// synVisible reports whether a synthetic signal should be listed for the given
|
||||
// caller while editing the given panel. An empty Visibility is treated as
|
||||
// "global" so legacy definitions remain visible everywhere.
|
||||
func synVisible(d synthetic.SignalDef, user, panel string) bool {
|
||||
// caller (a member of userGroups) while editing the given panel. An empty
|
||||
// Visibility is treated as "global" so legacy definitions remain visible
|
||||
// everywhere.
|
||||
func synVisible(d synthetic.SignalDef, user, panel string, userGroups []string) bool {
|
||||
switch d.Visibility {
|
||||
case "user":
|
||||
return user != "" && d.Owner == user
|
||||
case "group":
|
||||
return access.CanSee(user, d.Owner, access.ScopeGroup, d.Groups, userGroups)
|
||||
case "panel":
|
||||
return panel != "" && d.Panel == panel
|
||||
default: // "global" or legacy empty
|
||||
@@ -271,13 +442,14 @@ func (h *Handler) searchSignals(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
user := caller(r)
|
||||
panel := r.URL.Query().Get("panel")
|
||||
userGroups := h.policy.GroupsOf(user)
|
||||
|
||||
var out []signalInfo
|
||||
for _, ds := range sources {
|
||||
var metas []datasource.Metadata
|
||||
if ds.Name() == "synthetic" && h.synthetic != nil {
|
||||
metas = h.synthetic.FilteredMetadata(func(d synthetic.SignalDef) bool {
|
||||
return synVisible(d, user, panel)
|
||||
return synVisible(d, user, panel, userGroups)
|
||||
})
|
||||
} else {
|
||||
var err error
|
||||
@@ -433,10 +605,33 @@ func (h *Handler) putGroups(w http.ResponseWriter, r *http.Request) {
|
||||
// render sharing affordances and filter the visible set.
|
||||
type interfaceListItem struct {
|
||||
storage.InterfaceMeta
|
||||
Owner string `json:"owner,omitempty"`
|
||||
Folder string `json:"folder,omitempty"`
|
||||
Order float64 `json:"order,omitempty"`
|
||||
Perm string `json:"perm"`
|
||||
Owner string `json:"owner,omitempty"`
|
||||
Folder string `json:"folder,omitempty"`
|
||||
Order float64 `json:"order,omitempty"`
|
||||
Perm string `json:"perm"`
|
||||
Scope string `json:"scope,omitempty"` // derived visibility bucket: private|group|global
|
||||
Groups []string `json:"groups,omitempty"` // groups a group-scoped panel is shared with
|
||||
}
|
||||
|
||||
// panelScope derives a uniform visibility token (and, for group scope, the
|
||||
// shared group names) from a panel's ACL record so the selector tree can bucket
|
||||
// it as Mine/Group/Global like the other scoped subsystems. An unmanaged panel
|
||||
// (nil record) or any panel exposed publicly is global; otherwise a panel shared
|
||||
// with one or more user-groups is group-scoped; everything else is private.
|
||||
func panelScope(acl *panelacl.PanelACL) (string, []string) {
|
||||
if acl == nil || acl.Public != "" {
|
||||
return access.ScopeGlobal, nil
|
||||
}
|
||||
var groups []string
|
||||
for _, g := range acl.Grants {
|
||||
if g.Kind == "group" {
|
||||
groups = append(groups, g.Name)
|
||||
}
|
||||
}
|
||||
if len(groups) > 0 {
|
||||
return access.ScopeGroup, groups
|
||||
}
|
||||
return access.ScopePrivate, nil
|
||||
}
|
||||
|
||||
func (h *Handler) listInterfaces(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -453,11 +648,13 @@ func (h *Handler) listInterfaces(w http.ResponseWriter, r *http.Request) {
|
||||
continue // hide panels the caller cannot see
|
||||
}
|
||||
item := interfaceListItem{InterfaceMeta: m, Perm: perm.String()}
|
||||
if acl := h.acl.GetPanel(m.ID); acl != nil {
|
||||
acl := h.acl.GetPanel(m.ID)
|
||||
if acl != nil {
|
||||
item.Owner = acl.Owner
|
||||
item.Folder = acl.Folder
|
||||
item.Order = acl.Order
|
||||
}
|
||||
item.Scope, item.Groups = panelScope(acl)
|
||||
out = append(out, item)
|
||||
}
|
||||
h.log.Info("list interfaces", "count", len(out))
|
||||
@@ -490,6 +687,7 @@ func (h *Handler) createInterface(w http.ResponseWriter, r *http.Request) {
|
||||
h.log.Error("record panel ownership", "id", id, "err", err)
|
||||
}
|
||||
}
|
||||
h.recordMutation(r, "interface.create", id)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"id": id})
|
||||
@@ -580,6 +778,7 @@ func (h *Handler) updateInterface(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
return
|
||||
}
|
||||
h.recordMutation(r, "interface.update", id)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -721,6 +920,7 @@ func (h *Handler) deleteInterface(w http.ResponseWriter, r *http.Request) {
|
||||
if err := h.acl.DeletePanel(id); err != nil {
|
||||
h.log.Error("delete panel ACL", "id", id, "err", err)
|
||||
}
|
||||
h.recordMutation(r, "interface.delete", id)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
@@ -969,6 +1169,209 @@ func (h *Handler) listUserGroups(w http.ResponseWriter, _ *http.Request) {
|
||||
jsonOK(w, names)
|
||||
}
|
||||
|
||||
// ── /admin ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// requireAdmin writes a 403 and returns false unless the caller may use the
|
||||
// admin pane (CanAdmin). Anonymous/trusted-LAN callers and, when no admins
|
||||
// allowlist is configured, everyone, are permitted.
|
||||
func (h *Handler) requireAdmin(w http.ResponseWriter, r *http.Request) bool {
|
||||
if h.policy.CanAdmin(caller(r)) {
|
||||
return true
|
||||
}
|
||||
jsonError(w, http.StatusForbidden, "you are not permitted to administer this server")
|
||||
return false
|
||||
}
|
||||
|
||||
// getAdminAccess returns the full mutable access configuration (users, groups,
|
||||
// allowlists) for the admin pane.
|
||||
func (h *Handler) getAdminAccess(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
jsonOK(w, h.policy.Snapshot())
|
||||
}
|
||||
|
||||
type adminUserReq struct {
|
||||
// Roles maps each group the user should belong to → their role token. The
|
||||
// user is removed from any group not listed. Unknown groups are created.
|
||||
Roles map[string]string `json:"roles"`
|
||||
}
|
||||
|
||||
// putAdminUser replaces a user's full set of (group → role) memberships.
|
||||
func (h *Handler) putAdminUser(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
user := strings.TrimSpace(r.PathValue("user"))
|
||||
if user == "" {
|
||||
jsonError(w, http.StatusBadRequest, "empty user")
|
||||
return
|
||||
}
|
||||
var req adminUserReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||
return
|
||||
}
|
||||
roles := make(map[string]access.Role, len(req.Roles))
|
||||
for g, token := range req.Roles {
|
||||
if g = strings.TrimSpace(g); g != "" {
|
||||
roles[g] = access.ParseRole(token)
|
||||
}
|
||||
}
|
||||
if err := h.policy.SetUserRoles(user, roles); err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
h.recordMutation(r, "admin.user", user)
|
||||
jsonOK(w, h.policy.Snapshot())
|
||||
}
|
||||
|
||||
type adminGroupReq struct {
|
||||
Name string `json:"name"` // for update: the (possibly new) group name
|
||||
Parent string `json:"parent"` // parent group for nesting ("" = root)
|
||||
Members map[string]string `json:"members"`
|
||||
}
|
||||
|
||||
// createAdminGroup creates an empty group with an optional parent.
|
||||
func (h *Handler) createAdminGroup(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
var req adminGroupReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Name) == "" {
|
||||
jsonError(w, http.StatusBadRequest, "empty group name")
|
||||
return
|
||||
}
|
||||
if err := h.policy.CreateGroup(req.Name, req.Parent); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
h.recordMutation(r, "admin.group.create", req.Name)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
jsonOK(w, h.policy.Snapshot())
|
||||
}
|
||||
|
||||
// updateAdminGroup renames a group (when the body name differs from the path)
|
||||
// and replaces its parent and member roles.
|
||||
func (h *Handler) updateAdminGroup(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
old := strings.TrimSpace(r.PathValue("name"))
|
||||
var req adminGroupReq
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
name = old
|
||||
}
|
||||
if name != old {
|
||||
if err := h.policy.RenameGroup(old, name); err != nil {
|
||||
if errors.Is(err, access.ErrNotFound) {
|
||||
jsonError(w, http.StatusNotFound, "group not found: "+old)
|
||||
return
|
||||
}
|
||||
jsonError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
members := make(map[string]access.Role, len(req.Members))
|
||||
for u, token := range req.Members {
|
||||
if u = strings.TrimSpace(u); u != "" {
|
||||
members[u] = access.ParseRole(token)
|
||||
}
|
||||
}
|
||||
if err := h.policy.SetGroup(name, req.Parent, members); err != nil {
|
||||
if errors.Is(err, access.ErrNotFound) {
|
||||
jsonError(w, http.StatusNotFound, "group not found: "+name)
|
||||
return
|
||||
}
|
||||
jsonError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
h.recordMutation(r, "admin.group.update", name)
|
||||
jsonOK(w, h.policy.Snapshot())
|
||||
}
|
||||
|
||||
// deleteAdminGroup removes a group (members keep their other groups; children
|
||||
// are reparented). The built-in public group cannot be deleted.
|
||||
func (h *Handler) deleteAdminGroup(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(r.PathValue("name"))
|
||||
if err := h.policy.DeleteGroup(name); err != nil {
|
||||
if errors.Is(err, access.ErrNotFound) {
|
||||
jsonError(w, http.StatusNotFound, "group not found: "+name)
|
||||
return
|
||||
}
|
||||
jsonError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
h.recordMutation(r, "admin.group.delete", name)
|
||||
jsonOK(w, h.policy.Snapshot())
|
||||
}
|
||||
|
||||
// getAdminStats reports live server statistics: the in-process metrics counters,
|
||||
// observed signals and data sources from the broker, Go runtime stats, and the
|
||||
// system load average on Linux.
|
||||
func (h *Handler) getAdminStats(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.requireAdmin(w, r) {
|
||||
return
|
||||
}
|
||||
m := metrics.Snapshot()
|
||||
var ms runtime.MemStats
|
||||
runtime.ReadMemStats(&ms)
|
||||
sources := h.broker.DataSources()
|
||||
dsNames := make([]string, len(sources))
|
||||
for i, ds := range sources {
|
||||
dsNames[i] = ds.Name()
|
||||
}
|
||||
jsonOK(w, map[string]any{
|
||||
"uptimeSeconds": m.UptimeSeconds,
|
||||
"wsConnections": m.WsConnections,
|
||||
"observedSignals": h.broker.ActiveSubscriptions(),
|
||||
"msgIn": m.MsgIn,
|
||||
"msgOut": m.MsgOut,
|
||||
"writeOps": m.WriteOps,
|
||||
"historyReqs": m.HistoryReqs,
|
||||
"goroutines": runtime.NumGoroutine(),
|
||||
"memAllocBytes": ms.Alloc,
|
||||
"memSysBytes": ms.Sys,
|
||||
"heapInuseBytes": ms.HeapInuse,
|
||||
"loadAvg": readLoadAvg(),
|
||||
"dataSources": dsNames,
|
||||
})
|
||||
}
|
||||
|
||||
// readLoadAvg returns the 1/5/15-minute system load averages from
|
||||
// /proc/loadavg, or nil on non-Linux systems or any read/parse error.
|
||||
func readLoadAvg() []float64 {
|
||||
data, err := os.ReadFile("/proc/loadavg")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
fields := strings.Fields(string(data))
|
||||
if len(fields) < 3 {
|
||||
return nil
|
||||
}
|
||||
out := make([]float64, 3)
|
||||
for i := 0; i < 3; i++ {
|
||||
v, err := strconv.ParseFloat(fields[i], 64)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
out[i] = v
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// genID returns a short unique id with the given prefix (e.g. "fld-1a2b3c4d").
|
||||
func genID(prefix string) string {
|
||||
var b [8]byte
|
||||
@@ -1076,16 +1479,123 @@ func (h *Handler) deleteSynthetic(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// traceSynthetic evaluates an unsaved synthetic graph once against the current
|
||||
// live value of each source signal and returns every node's computed value. It
|
||||
// persists nothing; it powers the editor's live/debug overlay.
|
||||
func (h *Handler) traceSynthetic(w http.ResponseWriter, r *http.Request) {
|
||||
if h.synthetic == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "synthetic data source not enabled")
|
||||
return
|
||||
}
|
||||
var def synthetic.SignalDef
|
||||
if err := json.NewDecoder(r.Body).Decode(&def); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
read := func(ds, name string) (any, error) {
|
||||
v, err := h.broker.ReadNow(ctx, broker.SignalRef{DS: ds, Name: name})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return v.Data, nil
|
||||
}
|
||||
res, err := h.synthetic.Trace(def, read)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
jsonOK(w, res)
|
||||
}
|
||||
|
||||
func (h *Handler) listSyntheticVersions(w http.ResponseWriter, r *http.Request) {
|
||||
if h.synthetic == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "synthetic data source not enabled")
|
||||
return
|
||||
}
|
||||
name := r.PathValue("name")
|
||||
versions, err := h.synthetic.Versions(name)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusNotFound, "synthetic signal not found: "+name)
|
||||
return
|
||||
}
|
||||
jsonOK(w, versions)
|
||||
}
|
||||
|
||||
func (h *Handler) getSyntheticVersion(w http.ResponseWriter, r *http.Request) {
|
||||
if h.synthetic == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "synthetic data source not enabled")
|
||||
return
|
||||
}
|
||||
name := r.PathValue("name")
|
||||
version, err := strconv.Atoi(r.PathValue("version"))
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "invalid version: "+r.PathValue("version"))
|
||||
return
|
||||
}
|
||||
def, err := h.synthetic.GetVersion(name, version)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusNotFound, "version not found")
|
||||
return
|
||||
}
|
||||
jsonOK(w, def)
|
||||
}
|
||||
|
||||
func (h *Handler) promoteSyntheticVersion(w http.ResponseWriter, r *http.Request) {
|
||||
if h.synthetic == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "synthetic data source not enabled")
|
||||
return
|
||||
}
|
||||
name := r.PathValue("name")
|
||||
version, err := strconv.Atoi(r.PathValue("version"))
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "invalid version: "+r.PathValue("version"))
|
||||
return
|
||||
}
|
||||
def, err := h.synthetic.PromoteVersion(name, version)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusNotFound, "version not found")
|
||||
return
|
||||
}
|
||||
h.recordMutation(r, "synthetic.promote", fmt.Sprintf("%s v%d", name, version))
|
||||
jsonOK(w, def)
|
||||
}
|
||||
|
||||
func (h *Handler) forkSyntheticVersion(w http.ResponseWriter, r *http.Request) {
|
||||
if h.synthetic == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "synthetic data source not enabled")
|
||||
return
|
||||
}
|
||||
name := r.PathValue("name")
|
||||
version, err := strconv.Atoi(r.PathValue("version"))
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "invalid version: "+r.PathValue("version"))
|
||||
return
|
||||
}
|
||||
def, err := h.synthetic.ForkVersion(name, version)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusNotFound, "version not found")
|
||||
return
|
||||
}
|
||||
h.recordMutation(r, "synthetic.fork", fmt.Sprintf("%s v%d -> %s", name, version, def.Name))
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
jsonOK(w, map[string]string{"id": def.Name})
|
||||
}
|
||||
|
||||
// ── Control logic ──────────────────────────────────────────────────────────────
|
||||
|
||||
func (h *Handler) listControlLogic(w http.ResponseWriter, _ *http.Request) {
|
||||
func (h *Handler) listControlLogic(w http.ResponseWriter, r *http.Request) {
|
||||
if h.ctrlLogic == nil {
|
||||
jsonOK(w, []any{})
|
||||
return
|
||||
}
|
||||
graphs := h.ctrlLogic.List()
|
||||
if graphs == nil {
|
||||
graphs = []controllogic.Graph{}
|
||||
user := caller(r)
|
||||
graphs := []controllogic.Graph{}
|
||||
for _, g := range h.ctrlLogic.List() {
|
||||
if h.policy.CanSee(user, g.Owner, g.Scope, g.ScopeGroups) {
|
||||
graphs = append(graphs, g)
|
||||
}
|
||||
}
|
||||
jsonOK(w, graphs)
|
||||
}
|
||||
@@ -1118,11 +1628,13 @@ func (h *Handler) createControlLogic(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
g.ID = genID("cl")
|
||||
g.Owner = caller(r) // stamp ownership from the trusted identity
|
||||
if err := h.ctrlLogic.Save(g); err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
h.ctrlEngine.Reload()
|
||||
h.recordMutation(r, "controllogic.create", g.ID+" "+g.Name)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
jsonOK(w, g)
|
||||
}
|
||||
@@ -1137,7 +1649,8 @@ func (h *Handler) updateControlLogic(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
if _, err := h.ctrlLogic.Get(id); err != nil {
|
||||
prev, err := h.ctrlLogic.Get(id)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusNotFound, "control logic graph not found: "+id)
|
||||
return
|
||||
}
|
||||
@@ -1147,11 +1660,13 @@ func (h *Handler) updateControlLogic(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
g.ID = id
|
||||
g.Owner = prev.Owner // owner is immutable across revisions
|
||||
if err := h.ctrlLogic.Save(g); err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
h.ctrlEngine.Reload()
|
||||
h.recordMutation(r, "controllogic.update", g.ID+" "+g.Name)
|
||||
jsonOK(w, g)
|
||||
}
|
||||
|
||||
@@ -1164,14 +1679,98 @@ func (h *Handler) deleteControlLogic(w http.ResponseWriter, r *http.Request) {
|
||||
jsonError(w, http.StatusForbidden, "you are not permitted to edit logic")
|
||||
return
|
||||
}
|
||||
if err := h.ctrlLogic.Delete(r.PathValue("id")); err != nil {
|
||||
jsonError(w, http.StatusNotFound, "control logic graph not found: "+r.PathValue("id"))
|
||||
id := r.PathValue("id")
|
||||
if err := h.ctrlLogic.Delete(id); err != nil {
|
||||
jsonError(w, http.StatusNotFound, "control logic graph not found: "+id)
|
||||
return
|
||||
}
|
||||
h.ctrlEngine.Reload()
|
||||
h.recordMutation(r, "controllogic.delete", id)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) listControlLogicVersions(w http.ResponseWriter, r *http.Request) {
|
||||
if h.ctrlLogic == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "control logic not enabled")
|
||||
return
|
||||
}
|
||||
versions, err := h.ctrlLogic.Versions(r.PathValue("id"))
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusNotFound, "control logic graph not found: "+r.PathValue("id"))
|
||||
return
|
||||
}
|
||||
jsonOK(w, versions)
|
||||
}
|
||||
|
||||
func (h *Handler) getControlLogicVersion(w http.ResponseWriter, r *http.Request) {
|
||||
if h.ctrlLogic == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "control logic not enabled")
|
||||
return
|
||||
}
|
||||
version, err := strconv.Atoi(r.PathValue("version"))
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "invalid version: "+r.PathValue("version"))
|
||||
return
|
||||
}
|
||||
g, err := h.ctrlLogic.GetVersion(r.PathValue("id"), version)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusNotFound, "version not found")
|
||||
return
|
||||
}
|
||||
jsonOK(w, g)
|
||||
}
|
||||
|
||||
func (h *Handler) promoteControlLogicVersion(w http.ResponseWriter, r *http.Request) {
|
||||
if h.ctrlLogic == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "control logic not enabled")
|
||||
return
|
||||
}
|
||||
if !h.policy.CanEditLogic(caller(r)) {
|
||||
jsonError(w, http.StatusForbidden, "you are not permitted to edit logic")
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
version, err := strconv.Atoi(r.PathValue("version"))
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "invalid version: "+r.PathValue("version"))
|
||||
return
|
||||
}
|
||||
g, err := h.ctrlLogic.Promote(id, version)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusNotFound, "version not found")
|
||||
return
|
||||
}
|
||||
h.ctrlEngine.Reload()
|
||||
h.recordMutation(r, "controllogic.promote", fmt.Sprintf("%s v%d", id, version))
|
||||
jsonOK(w, g)
|
||||
}
|
||||
|
||||
func (h *Handler) forkControlLogicVersion(w http.ResponseWriter, r *http.Request) {
|
||||
if h.ctrlLogic == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "control logic not enabled")
|
||||
return
|
||||
}
|
||||
if !h.policy.CanEditLogic(caller(r)) {
|
||||
jsonError(w, http.StatusForbidden, "you are not permitted to edit logic")
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
version, err := strconv.Atoi(r.PathValue("version"))
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "invalid version: "+r.PathValue("version"))
|
||||
return
|
||||
}
|
||||
g, err := h.ctrlLogic.Fork(id, version)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusNotFound, "version not found")
|
||||
return
|
||||
}
|
||||
h.ctrlEngine.Reload()
|
||||
h.recordMutation(r, "controllogic.fork", fmt.Sprintf("%s v%d -> %s", id, version, g.ID))
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
jsonOK(w, map[string]string{"id": g.ID})
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// extractLogicBlock returns the verbatim <logic>…</logic> section of an
|
||||
|
||||
+165
-5
@@ -14,7 +14,9 @@ import (
|
||||
|
||||
"github.com/uopi/uopi/internal/access"
|
||||
"github.com/uopi/uopi/internal/api"
|
||||
"github.com/uopi/uopi/internal/audit"
|
||||
"github.com/uopi/uopi/internal/broker"
|
||||
"github.com/uopi/uopi/internal/confmgr"
|
||||
"github.com/uopi/uopi/internal/controllogic"
|
||||
"github.com/uopi/uopi/internal/datasource/stub"
|
||||
"github.com/uopi/uopi/internal/panelacl"
|
||||
@@ -50,10 +52,16 @@ func setup(t *testing.T) (*httptest.Server, func()) {
|
||||
if err != nil {
|
||||
t.Fatal("controllogic.NewStore:", err)
|
||||
}
|
||||
clEngine := controllogic.NewEngine(ctx, brk, clStore, log)
|
||||
|
||||
cfgStore, err := confmgr.New(dir)
|
||||
if err != nil {
|
||||
t.Fatal("confmgr.New:", err)
|
||||
}
|
||||
|
||||
clEngine := controllogic.NewEngine(ctx, brk, clStore, cfgStore, audit.Nop(), log)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
api.New(brk, nil, store, access.New("", nil, nil, nil), acl, clStore, clEngine, "", "", log).Register(mux, "/api/v1")
|
||||
api.New(brk, nil, store, cfgStore, access.New("", nil), acl, clStore, clEngine, audit.Nop(), "", "", 0, log).Register(mux, "/api/v1")
|
||||
|
||||
srv := httptest.NewServer(mux)
|
||||
return srv, func() {
|
||||
@@ -214,7 +222,9 @@ func TestSearchSignals(t *testing.T) {
|
||||
resp := get(t, srv, "/api/v1/signals/search?q=sine")
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
|
||||
var signals []struct{ Name string `json:"name"` }
|
||||
var signals []struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
readJSON(t, resp, &signals)
|
||||
|
||||
for _, s := range signals {
|
||||
@@ -259,7 +269,9 @@ func TestInterfaceCRUD(t *testing.T) {
|
||||
// Create
|
||||
resp = postRaw(t, srv, "/api/v1/interfaces", "application/xml", []byte(sampleXML))
|
||||
assertStatus(t, resp, http.StatusCreated)
|
||||
var created struct{ ID string `json:"id"` }
|
||||
var created struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
readJSON(t, resp, &created)
|
||||
if created.ID == "" {
|
||||
t.Fatal("expected non-empty ID from create")
|
||||
@@ -303,7 +315,9 @@ func TestInterfaceCRUD(t *testing.T) {
|
||||
t.Fatal("clone:", err)
|
||||
}
|
||||
assertStatus(t, resp, http.StatusCreated)
|
||||
var cloned struct{ ID string `json:"id"` }
|
||||
var cloned struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
readJSON(t, resp, &cloned)
|
||||
if cloned.ID == created.ID {
|
||||
t.Error("clone produced same ID as original")
|
||||
@@ -438,6 +452,152 @@ func TestStorageValidateID(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── /api/v1/admin ─────────────────────────────────────────────────────────────
|
||||
|
||||
// adminSetup builds a server whose mux injects the user named by the
|
||||
// "X-Test-User" header into the request context (mirroring the real access
|
||||
// middleware), so admin-gating can be exercised. The policy restricts admin to
|
||||
// the "ops" group, of which "alice" is a member.
|
||||
func adminSetup(t *testing.T) (*httptest.Server, func()) {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
brk := broker.New(ctx, log)
|
||||
ds := stub.New()
|
||||
if err := ds.Connect(ctx); err != nil {
|
||||
t.Fatal("stub connect:", err)
|
||||
}
|
||||
brk.Register(ds)
|
||||
|
||||
dir := t.TempDir()
|
||||
store, _ := storage.New(dir)
|
||||
acl, _ := panelacl.New(dir)
|
||||
clStore, _ := controllogic.NewStore(dir)
|
||||
cfgStore, _ := confmgr.New(dir)
|
||||
clEngine := controllogic.NewEngine(ctx, brk, clStore, cfgStore, audit.Nop(), log)
|
||||
|
||||
// "alice" is an admin (via the ops group); everyone else is a viewer.
|
||||
policy := access.New("", []access.GroupSpec{
|
||||
{Name: "ops", Members: map[string]access.Role{"alice": access.RoleAdmin}},
|
||||
})
|
||||
if err := policy.EnablePersistence(dir); err != nil {
|
||||
t.Fatal("EnablePersistence:", err)
|
||||
}
|
||||
|
||||
inner := http.NewServeMux()
|
||||
api.New(brk, nil, store, cfgStore, policy, acl, clStore, clEngine, audit.Nop(), "", "", 0, log).Register(inner, "/api/v1")
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
if u := r.Header.Get("X-Test-User"); u != "" {
|
||||
r = r.WithContext(access.WithUser(r.Context(), u))
|
||||
}
|
||||
inner.ServeHTTP(w, r)
|
||||
})
|
||||
|
||||
srv := httptest.NewServer(mux)
|
||||
return srv, func() { srv.Close(); cancel() }
|
||||
}
|
||||
|
||||
func reqAs(t *testing.T, srv *httptest.Server, method, path, user string, body []byte) *http.Response {
|
||||
t.Helper()
|
||||
var rdr io.Reader
|
||||
if body != nil {
|
||||
rdr = bytes.NewReader(body)
|
||||
}
|
||||
req, err := http.NewRequest(method, srv.URL+path, rdr)
|
||||
if err != nil {
|
||||
t.Fatal("NewRequest:", err)
|
||||
}
|
||||
if user != "" {
|
||||
req.Header.Set("X-Test-User", user)
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(method, path, err)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
func TestAdminForbiddenForNonAdmin(t *testing.T) {
|
||||
srv, teardown := adminSetup(t)
|
||||
defer teardown()
|
||||
|
||||
// "bob" is only a viewer → 403 on every admin route.
|
||||
assertStatus(t, reqAs(t, srv, http.MethodGet, "/api/v1/admin/access", "bob", nil), http.StatusForbidden)
|
||||
assertStatus(t, reqAs(t, srv, http.MethodGet, "/api/v1/admin/stats", "bob", nil), http.StatusForbidden)
|
||||
assertStatus(t, reqAs(t, srv, http.MethodPut, "/api/v1/admin/users/carol", "bob",
|
||||
[]byte(`{"roles":{"public":"operator"}}`)), http.StatusForbidden)
|
||||
}
|
||||
|
||||
func TestAdminUserAndGroupMutations(t *testing.T) {
|
||||
srv, teardown := adminSetup(t)
|
||||
defer teardown()
|
||||
|
||||
// alice (admin) assigns carol an auditor role in a new "team" group.
|
||||
resp := reqAs(t, srv, http.MethodPut, "/api/v1/admin/users/carol", "alice",
|
||||
[]byte(`{"roles":{"team":"auditor"}}`))
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
|
||||
var snap access.AccessSnapshot
|
||||
readJSON(t, resp, &snap)
|
||||
var carol *access.UserInfo
|
||||
for i := range snap.Users {
|
||||
if snap.Users[i].Name == "carol" {
|
||||
carol = &snap.Users[i]
|
||||
}
|
||||
}
|
||||
if carol == nil {
|
||||
t.Fatal("carol missing from snapshot")
|
||||
}
|
||||
if carol.EffectiveRole != "auditor" || carol.Roles["team"] != "auditor" {
|
||||
t.Errorf("carol = %+v, want auditor in team", carol)
|
||||
}
|
||||
|
||||
// Create (with parent), rename, and delete a group.
|
||||
assertStatus(t, reqAs(t, srv, http.MethodPost, "/api/v1/admin/groups", "alice",
|
||||
[]byte(`{"name":"eng","parent":"public"}`)), http.StatusCreated)
|
||||
resp = reqAs(t, srv, http.MethodPut, "/api/v1/admin/groups/eng", "alice",
|
||||
[]byte(`{"name":"engineering","members":{"carol":"admin"}}`))
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
readJSON(t, resp, &snap)
|
||||
if !hasGroup(snap, "engineering") || hasGroup(snap, "eng") {
|
||||
t.Errorf("groups after rename = %+v", snap.Groups)
|
||||
}
|
||||
|
||||
assertStatus(t, reqAs(t, srv, http.MethodDelete, "/api/v1/admin/groups/engineering", "alice", nil), http.StatusOK)
|
||||
assertStatus(t, reqAs(t, srv, http.MethodDelete, "/api/v1/admin/groups/nope", "alice", nil), http.StatusNotFound)
|
||||
// The built-in public group cannot be deleted.
|
||||
assertStatus(t, reqAs(t, srv, http.MethodDelete, "/api/v1/admin/groups/public", "alice", nil), http.StatusBadRequest)
|
||||
}
|
||||
|
||||
func hasGroup(s access.AccessSnapshot, name string) bool {
|
||||
for _, g := range s.Groups {
|
||||
if g.Name == name {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestAdminStats(t *testing.T) {
|
||||
srv, teardown := adminSetup(t)
|
||||
defer teardown()
|
||||
|
||||
resp := reqAs(t, srv, http.MethodGet, "/api/v1/admin/stats", "alice", nil)
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
var stats map[string]any
|
||||
readJSON(t, resp, &stats)
|
||||
for _, k := range []string{"uptimeSeconds", "wsConnections", "observedSignals", "goroutines", "dataSources"} {
|
||||
if _, ok := stats[k]; !ok {
|
||||
t.Errorf("stats missing key %q", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Ensure os is used (blank import guard) ─────────────────────────────────────
|
||||
|
||||
var _ = os.DevNull
|
||||
|
||||
@@ -0,0 +1,820 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/uopi/uopi/internal/broker"
|
||||
"github.com/uopi/uopi/internal/confmgr"
|
||||
)
|
||||
|
||||
// configEnabled guards every config-manager handler; the store is always
|
||||
// constructed today, but the nil check keeps the handlers safe if it is ever
|
||||
// made optional.
|
||||
func (h *Handler) configEnabled(w http.ResponseWriter) bool {
|
||||
if h.cfg == nil {
|
||||
jsonError(w, http.StatusServiceUnavailable, "configuration manager not enabled")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func pathVersion(r *http.Request) (int, error) {
|
||||
return strconv.Atoi(r.PathValue("version"))
|
||||
}
|
||||
|
||||
func configStatus(err error) int {
|
||||
if errors.Is(err, confmgr.ErrNotFound) {
|
||||
return http.StatusNotFound
|
||||
}
|
||||
return http.StatusBadRequest
|
||||
}
|
||||
|
||||
// ── config sets ─────────────────────────────────────────────────────────────
|
||||
|
||||
func (h *Handler) listConfigSets(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.configEnabled(w) {
|
||||
return
|
||||
}
|
||||
sets, err := h.cfg.List(confmgr.KindSet)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
jsonOK(w, h.filterConfigMetas(r, sets))
|
||||
}
|
||||
|
||||
func (h *Handler) getConfigSet(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.configEnabled(w) {
|
||||
return
|
||||
}
|
||||
set, err := h.cfg.GetSet(r.PathValue("id"))
|
||||
if err != nil {
|
||||
jsonError(w, configStatus(err), err.Error())
|
||||
return
|
||||
}
|
||||
jsonOK(w, set)
|
||||
}
|
||||
|
||||
func (h *Handler) createConfigSet(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.configEnabled(w) {
|
||||
return
|
||||
}
|
||||
var set confmgr.ConfigSet
|
||||
if err := json.NewDecoder(r.Body).Decode(&set); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||
return
|
||||
}
|
||||
set.ID = ""
|
||||
set.Owner = caller(r)
|
||||
out, err := h.cfg.CreateSet(set, r.URL.Query().Get("tag"))
|
||||
if err != nil {
|
||||
jsonError(w, configStatus(err), err.Error())
|
||||
return
|
||||
}
|
||||
h.recordMutation(r, "config.set.create", out.ID+" "+out.Name)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
jsonOK(w, out)
|
||||
}
|
||||
|
||||
func (h *Handler) updateConfigSet(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.configEnabled(w) {
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
var set confmgr.ConfigSet
|
||||
if err := json.NewDecoder(r.Body).Decode(&set); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||
return
|
||||
}
|
||||
if prev, err := h.cfg.GetSet(id); err == nil {
|
||||
set.Owner = prev.Owner // owner is immutable across revisions
|
||||
}
|
||||
out, err := h.cfg.UpdateSet(id, set, r.URL.Query().Get("tag"))
|
||||
if err != nil {
|
||||
jsonError(w, configStatus(err), err.Error())
|
||||
return
|
||||
}
|
||||
h.recordMutation(r, "config.set.update", out.ID+" v"+strconv.Itoa(out.Version))
|
||||
jsonOK(w, out)
|
||||
}
|
||||
|
||||
func (h *Handler) deleteConfigSet(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.configEnabled(w) {
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
if err := h.cfg.Delete(confmgr.KindSet, id); err != nil {
|
||||
jsonError(w, configStatus(err), err.Error())
|
||||
return
|
||||
}
|
||||
h.recordMutation(r, "config.set.delete", id)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) listConfigSetVersions(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.configEnabled(w) {
|
||||
return
|
||||
}
|
||||
versions, err := h.cfg.Versions(confmgr.KindSet, r.PathValue("id"))
|
||||
if err != nil {
|
||||
jsonError(w, configStatus(err), err.Error())
|
||||
return
|
||||
}
|
||||
jsonOK(w, versions)
|
||||
}
|
||||
|
||||
func (h *Handler) getConfigSetVersion(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.configEnabled(w) {
|
||||
return
|
||||
}
|
||||
version, err := pathVersion(r)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "invalid version")
|
||||
return
|
||||
}
|
||||
set, err := h.cfg.GetSetVersion(r.PathValue("id"), version)
|
||||
if err != nil {
|
||||
jsonError(w, configStatus(err), err.Error())
|
||||
return
|
||||
}
|
||||
jsonOK(w, set)
|
||||
}
|
||||
|
||||
func (h *Handler) promoteConfigSet(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.configEnabled(w) {
|
||||
return
|
||||
}
|
||||
version, err := pathVersion(r)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "invalid version")
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
if err := h.cfg.Promote(confmgr.KindSet, id, version); err != nil {
|
||||
jsonError(w, configStatus(err), err.Error())
|
||||
return
|
||||
}
|
||||
h.recordMutation(r, "config.set.promote", id+" v"+strconv.Itoa(version))
|
||||
set, err := h.cfg.GetSet(id)
|
||||
if err != nil {
|
||||
jsonError(w, configStatus(err), err.Error())
|
||||
return
|
||||
}
|
||||
jsonOK(w, set)
|
||||
}
|
||||
|
||||
func (h *Handler) forkConfigSet(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.configEnabled(w) {
|
||||
return
|
||||
}
|
||||
version, err := pathVersion(r)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "invalid version")
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
newID, err := h.cfg.Fork(confmgr.KindSet, id, version)
|
||||
if err != nil {
|
||||
jsonError(w, configStatus(err), err.Error())
|
||||
return
|
||||
}
|
||||
h.recordMutation(r, "config.set.fork", id+" v"+strconv.Itoa(version)+" -> "+newID)
|
||||
set, err := h.cfg.GetSet(newID)
|
||||
if err != nil {
|
||||
jsonError(w, configStatus(err), err.Error())
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
jsonOK(w, set)
|
||||
}
|
||||
|
||||
// diffConfigSets compares two set revisions. Query params: a, b (ids);
|
||||
// optional av, bv (versions, default current).
|
||||
func (h *Handler) diffConfigSets(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.configEnabled(w) {
|
||||
return
|
||||
}
|
||||
q := r.URL.Query()
|
||||
left, err := h.resolveSet(q.Get("a"), q.Get("av"))
|
||||
if err != nil {
|
||||
jsonError(w, configStatus(err), "left set: "+err.Error())
|
||||
return
|
||||
}
|
||||
right, err := h.resolveSet(q.Get("b"), q.Get("bv"))
|
||||
if err != nil {
|
||||
jsonError(w, configStatus(err), "right set: "+err.Error())
|
||||
return
|
||||
}
|
||||
jsonOK(w, confmgr.DiffSets(left, right))
|
||||
}
|
||||
|
||||
func (h *Handler) resolveSet(id, version string) (confmgr.ConfigSet, error) {
|
||||
if id == "" {
|
||||
return confmgr.ConfigSet{}, errors.New("missing set id")
|
||||
}
|
||||
if version == "" {
|
||||
return h.cfg.GetSet(id)
|
||||
}
|
||||
v, err := strconv.Atoi(version)
|
||||
if err != nil {
|
||||
return confmgr.ConfigSet{}, errors.New("invalid version")
|
||||
}
|
||||
return h.cfg.GetSetVersion(id, v)
|
||||
}
|
||||
|
||||
// ── config instances ────────────────────────────────────────────────────────
|
||||
|
||||
func (h *Handler) listConfigInstances(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.configEnabled(w) {
|
||||
return
|
||||
}
|
||||
insts, err := h.cfg.List(confmgr.KindInstance)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
jsonOK(w, h.filterConfigMetas(r, insts))
|
||||
}
|
||||
|
||||
// filterConfigMetas drops entries the caller may not see per their scope
|
||||
// (private/group/global). Owner always sees their own; empty scope = global.
|
||||
func (h *Handler) filterConfigMetas(r *http.Request, metas []confmgr.Meta) []confmgr.Meta {
|
||||
user := caller(r)
|
||||
out := make([]confmgr.Meta, 0, len(metas))
|
||||
for _, m := range metas {
|
||||
if h.policy.CanSee(user, m.Owner, m.Scope, m.Groups) {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (h *Handler) getConfigInstance(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.configEnabled(w) {
|
||||
return
|
||||
}
|
||||
inst, err := h.cfg.GetInstance(r.PathValue("id"))
|
||||
if err != nil {
|
||||
jsonError(w, configStatus(err), err.Error())
|
||||
return
|
||||
}
|
||||
jsonOK(w, inst)
|
||||
}
|
||||
|
||||
func (h *Handler) createConfigInstance(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.configEnabled(w) {
|
||||
return
|
||||
}
|
||||
var inst confmgr.ConfigInstance
|
||||
if err := json.NewDecoder(r.Body).Decode(&inst); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||
return
|
||||
}
|
||||
inst.ID = ""
|
||||
inst.Owner = caller(r)
|
||||
out, err := h.cfg.CreateInstance(inst, r.URL.Query().Get("tag"))
|
||||
if err != nil {
|
||||
jsonError(w, configStatus(err), err.Error())
|
||||
return
|
||||
}
|
||||
h.recordMutation(r, "config.instance.create", out.ID+" "+out.Name)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
jsonOK(w, out)
|
||||
}
|
||||
|
||||
func (h *Handler) updateConfigInstance(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.configEnabled(w) {
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
var inst confmgr.ConfigInstance
|
||||
if err := json.NewDecoder(r.Body).Decode(&inst); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||
return
|
||||
}
|
||||
if prev, err := h.cfg.GetInstance(id); err == nil {
|
||||
inst.Owner = prev.Owner // owner is immutable across revisions
|
||||
}
|
||||
out, err := h.cfg.UpdateInstance(id, inst, r.URL.Query().Get("tag"))
|
||||
if err != nil {
|
||||
jsonError(w, configStatus(err), err.Error())
|
||||
return
|
||||
}
|
||||
h.recordMutation(r, "config.instance.update", out.ID+" v"+strconv.Itoa(out.Version))
|
||||
jsonOK(w, out)
|
||||
}
|
||||
|
||||
func (h *Handler) deleteConfigInstance(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.configEnabled(w) {
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
if err := h.cfg.Delete(confmgr.KindInstance, id); err != nil {
|
||||
jsonError(w, configStatus(err), err.Error())
|
||||
return
|
||||
}
|
||||
h.recordMutation(r, "config.instance.delete", id)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) listConfigInstanceVersions(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.configEnabled(w) {
|
||||
return
|
||||
}
|
||||
versions, err := h.cfg.Versions(confmgr.KindInstance, r.PathValue("id"))
|
||||
if err != nil {
|
||||
jsonError(w, configStatus(err), err.Error())
|
||||
return
|
||||
}
|
||||
jsonOK(w, versions)
|
||||
}
|
||||
|
||||
func (h *Handler) getConfigInstanceVersion(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.configEnabled(w) {
|
||||
return
|
||||
}
|
||||
version, err := pathVersion(r)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "invalid version")
|
||||
return
|
||||
}
|
||||
inst, err := h.cfg.GetInstanceVersion(r.PathValue("id"), version)
|
||||
if err != nil {
|
||||
jsonError(w, configStatus(err), err.Error())
|
||||
return
|
||||
}
|
||||
jsonOK(w, inst)
|
||||
}
|
||||
|
||||
func (h *Handler) promoteConfigInstance(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.configEnabled(w) {
|
||||
return
|
||||
}
|
||||
version, err := pathVersion(r)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "invalid version")
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
if err := h.cfg.Promote(confmgr.KindInstance, id, version); err != nil {
|
||||
jsonError(w, configStatus(err), err.Error())
|
||||
return
|
||||
}
|
||||
h.recordMutation(r, "config.instance.promote", id+" v"+strconv.Itoa(version))
|
||||
inst, err := h.cfg.GetInstance(id)
|
||||
if err != nil {
|
||||
jsonError(w, configStatus(err), err.Error())
|
||||
return
|
||||
}
|
||||
jsonOK(w, inst)
|
||||
}
|
||||
|
||||
func (h *Handler) forkConfigInstance(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.configEnabled(w) {
|
||||
return
|
||||
}
|
||||
version, err := pathVersion(r)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "invalid version")
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
newID, err := h.cfg.Fork(confmgr.KindInstance, id, version)
|
||||
if err != nil {
|
||||
jsonError(w, configStatus(err), err.Error())
|
||||
return
|
||||
}
|
||||
h.recordMutation(r, "config.instance.fork", id+" v"+strconv.Itoa(version)+" -> "+newID)
|
||||
inst, err := h.cfg.GetInstance(newID)
|
||||
if err != nil {
|
||||
jsonError(w, configStatus(err), err.Error())
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
jsonOK(w, inst)
|
||||
}
|
||||
|
||||
// applyConfigInstance writes every resolvable parameter value of an instance to
|
||||
// its target signal via the broker. Per-parameter outcomes are returned so a
|
||||
// partial apply is reported faithfully.
|
||||
func (h *Handler) applyConfigInstance(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.configEnabled(w) {
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
inst, err := h.cfg.GetInstance(id)
|
||||
if err != nil {
|
||||
jsonError(w, configStatus(err), err.Error())
|
||||
return
|
||||
}
|
||||
set, err := h.cfg.SetForInstance(inst)
|
||||
if err != nil {
|
||||
jsonError(w, configStatus(err), "load set: "+err.Error())
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
||||
defer cancel()
|
||||
write := func(ds, signal string, value any) error {
|
||||
src, ok := h.broker.Source(ds)
|
||||
if !ok {
|
||||
return errors.New("unknown data source: " + ds)
|
||||
}
|
||||
return src.Write(ctx, signal, value)
|
||||
}
|
||||
res := confmgr.Apply(set, inst, write)
|
||||
h.recordMutation(r, "config.instance.apply", id+" applied="+strconv.Itoa(res.Applied)+" failed="+strconv.Itoa(res.Failed))
|
||||
jsonOK(w, res)
|
||||
}
|
||||
|
||||
// diffConfigInstances compares two instance revisions. Query params: a, b
|
||||
// (ids); optional av, bv (versions, default current).
|
||||
func (h *Handler) diffConfigInstances(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.configEnabled(w) {
|
||||
return
|
||||
}
|
||||
q := r.URL.Query()
|
||||
left, err := h.resolveInstance(q.Get("a"), q.Get("av"))
|
||||
if err != nil {
|
||||
jsonError(w, configStatus(err), "left instance: "+err.Error())
|
||||
return
|
||||
}
|
||||
right, err := h.resolveInstance(q.Get("b"), q.Get("bv"))
|
||||
if err != nil {
|
||||
jsonError(w, configStatus(err), "right instance: "+err.Error())
|
||||
return
|
||||
}
|
||||
jsonOK(w, confmgr.DiffInstances(left, right))
|
||||
}
|
||||
|
||||
func (h *Handler) resolveInstance(id, version string) (confmgr.ConfigInstance, error) {
|
||||
if id == "" {
|
||||
return confmgr.ConfigInstance{}, errors.New("missing instance id")
|
||||
}
|
||||
if version == "" {
|
||||
return h.cfg.GetInstance(id)
|
||||
}
|
||||
v, err := strconv.Atoi(version)
|
||||
if err != nil {
|
||||
return confmgr.ConfigInstance{}, errors.New("invalid version")
|
||||
}
|
||||
return h.cfg.GetInstanceVersion(id, v)
|
||||
}
|
||||
|
||||
// validateConfigInstance evaluates the CUE rules bound to an instance's set
|
||||
// against its stored values, without persisting anything. The structured
|
||||
// RuleResult (violations + any transformed values) lets the UI surface
|
||||
// per-parameter failures.
|
||||
func (h *Handler) validateConfigInstance(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.configEnabled(w) {
|
||||
return
|
||||
}
|
||||
inst, err := h.cfg.GetInstance(r.PathValue("id"))
|
||||
if err != nil {
|
||||
jsonError(w, configStatus(err), err.Error())
|
||||
return
|
||||
}
|
||||
res, err := h.cfg.ValidateInstanceRules(inst)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
jsonOK(w, res)
|
||||
}
|
||||
|
||||
// ── config snapshot / live diff ─────────────────────────────────────────────
|
||||
|
||||
// readResult is the per-signal outcome of a parallel live read.
|
||||
type readResult struct {
|
||||
val any
|
||||
err error
|
||||
}
|
||||
|
||||
// readSetSignals reads the current value of every distinct target signal of a
|
||||
// set in parallel (deduplicated by ds+signal), bounded by ctx. The returned map
|
||||
// is keyed by {ds, signal}.
|
||||
func (h *Handler) readSetSignals(ctx context.Context, set confmgr.ConfigSet) map[[2]string]readResult {
|
||||
jobs := make(map[[2]string]struct{}, len(set.Parameters))
|
||||
for _, p := range set.Parameters {
|
||||
jobs[[2]string{p.DS, p.Signal}] = struct{}{}
|
||||
}
|
||||
results := make(map[[2]string]readResult, len(jobs))
|
||||
var mu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
for k := range jobs {
|
||||
wg.Add(1)
|
||||
go func(ds, signal string) {
|
||||
defer wg.Done()
|
||||
v, err := h.broker.ReadNow(ctx, broker.SignalRef{DS: ds, Name: signal})
|
||||
mu.Lock()
|
||||
results[[2]string{ds, signal}] = readResult{val: v.Data, err: err}
|
||||
mu.Unlock()
|
||||
}(k[0], k[1])
|
||||
}
|
||||
wg.Wait()
|
||||
return results
|
||||
}
|
||||
|
||||
// snapshotReader builds a confmgr.ReadFunc backed by a pre-read results map.
|
||||
func snapshotReader(results map[[2]string]readResult) confmgr.ReadFunc {
|
||||
return func(ds, signal string) (any, error) {
|
||||
r, ok := results[[2]string{ds, signal}]
|
||||
if !ok {
|
||||
return nil, errors.New("signal not read")
|
||||
}
|
||||
return r.val, r.err
|
||||
}
|
||||
}
|
||||
|
||||
// snapshotConfigSet captures the current value of every target signal of a set
|
||||
// and stores them as a new config instance. Body: optional {name}. Returns the
|
||||
// created instance plus the per-parameter capture result.
|
||||
func (h *Handler) snapshotConfigSet(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.configEnabled(w) {
|
||||
return
|
||||
}
|
||||
set, err := h.cfg.GetSet(r.PathValue("id"))
|
||||
if err != nil {
|
||||
jsonError(w, configStatus(err), err.Error())
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if r.Body != nil {
|
||||
_ = json.NewDecoder(r.Body).Decode(&body) // body is optional
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
results := h.readSetSignals(ctx, set)
|
||||
snap := confmgr.Snapshot(set, snapshotReader(results))
|
||||
|
||||
name := body.Name
|
||||
if name == "" {
|
||||
name = set.Name + " snapshot " + time.Now().Format("2006-01-02 15:04:05")
|
||||
}
|
||||
inst := confmgr.ConfigInstance{
|
||||
Name: name,
|
||||
SetID: set.ID,
|
||||
Owner: caller(r),
|
||||
Values: snap.Values,
|
||||
}
|
||||
out, err := h.cfg.CreateInstance(inst, r.URL.Query().Get("tag"))
|
||||
if err != nil {
|
||||
jsonError(w, configStatus(err), err.Error())
|
||||
return
|
||||
}
|
||||
h.recordMutation(r, "config.instance.snapshot", out.ID+" "+out.Name+" captured="+strconv.Itoa(snap.Captured)+" failed="+strconv.Itoa(snap.Failed))
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
jsonOK(w, struct {
|
||||
Instance confmgr.ConfigInstance `json:"instance"`
|
||||
Snapshot confmgr.SnapshotResult `json:"snapshot"`
|
||||
}{out, snap})
|
||||
}
|
||||
|
||||
// diffConfigInstanceLive compares a stored instance against the current live
|
||||
// values of its set's target signals, so an operator can see how the saved
|
||||
// config differs from what the hardware currently holds. The stored side uses
|
||||
// resolved values (instance value or parameter default) so default-backed
|
||||
// parameters are not reported as spurious additions.
|
||||
func (h *Handler) diffConfigInstanceLive(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.configEnabled(w) {
|
||||
return
|
||||
}
|
||||
inst, err := h.cfg.GetInstance(r.PathValue("id"))
|
||||
if err != nil {
|
||||
jsonError(w, configStatus(err), err.Error())
|
||||
return
|
||||
}
|
||||
set, err := h.cfg.SetForInstance(inst)
|
||||
if err != nil {
|
||||
jsonError(w, configStatus(err), "load set: "+err.Error())
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
results := h.readSetSignals(ctx, set)
|
||||
snap := confmgr.Snapshot(set, snapshotReader(results))
|
||||
|
||||
left := confmgr.ConfigInstance{ID: inst.ID, Name: inst.Name, Values: map[string]any{}}
|
||||
for _, p := range set.Parameters {
|
||||
if v, ok := inst.Resolve(p); ok {
|
||||
left.Values[p.Key] = v
|
||||
}
|
||||
}
|
||||
current := confmgr.ConfigInstance{Name: "current", Values: snap.Values}
|
||||
jsonOK(w, confmgr.DiffInstances(left, current))
|
||||
}
|
||||
|
||||
// ── config rules (CUE validation/transformation) ────────────────────────────
|
||||
|
||||
func (h *Handler) listConfigRules(w http.ResponseWriter, _ *http.Request) {
|
||||
if !h.configEnabled(w) {
|
||||
return
|
||||
}
|
||||
rules, err := h.cfg.List(confmgr.KindRule)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
jsonOK(w, rules)
|
||||
}
|
||||
|
||||
func (h *Handler) getConfigRule(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.configEnabled(w) {
|
||||
return
|
||||
}
|
||||
rule, err := h.cfg.GetRule(r.PathValue("id"))
|
||||
if err != nil {
|
||||
jsonError(w, configStatus(err), err.Error())
|
||||
return
|
||||
}
|
||||
jsonOK(w, rule)
|
||||
}
|
||||
|
||||
func (h *Handler) createConfigRule(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.configEnabled(w) {
|
||||
return
|
||||
}
|
||||
var rule confmgr.ConfigRule
|
||||
if err := json.NewDecoder(r.Body).Decode(&rule); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||
return
|
||||
}
|
||||
rule.ID = ""
|
||||
rule.Owner = caller(r)
|
||||
out, err := h.cfg.CreateRule(rule, r.URL.Query().Get("tag"))
|
||||
if err != nil {
|
||||
jsonError(w, configStatus(err), err.Error())
|
||||
return
|
||||
}
|
||||
h.recordMutation(r, "config.rule.create", out.ID+" "+out.Name)
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
jsonOK(w, out)
|
||||
}
|
||||
|
||||
func (h *Handler) updateConfigRule(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.configEnabled(w) {
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
var rule confmgr.ConfigRule
|
||||
if err := json.NewDecoder(r.Body).Decode(&rule); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||
return
|
||||
}
|
||||
out, err := h.cfg.UpdateRule(id, rule, r.URL.Query().Get("tag"))
|
||||
if err != nil {
|
||||
jsonError(w, configStatus(err), err.Error())
|
||||
return
|
||||
}
|
||||
h.recordMutation(r, "config.rule.update", out.ID+" v"+strconv.Itoa(out.Version))
|
||||
jsonOK(w, out)
|
||||
}
|
||||
|
||||
func (h *Handler) deleteConfigRule(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.configEnabled(w) {
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
if err := h.cfg.Delete(confmgr.KindRule, id); err != nil {
|
||||
jsonError(w, configStatus(err), err.Error())
|
||||
return
|
||||
}
|
||||
h.recordMutation(r, "config.rule.delete", id)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (h *Handler) listConfigRuleVersions(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.configEnabled(w) {
|
||||
return
|
||||
}
|
||||
versions, err := h.cfg.Versions(confmgr.KindRule, r.PathValue("id"))
|
||||
if err != nil {
|
||||
jsonError(w, configStatus(err), err.Error())
|
||||
return
|
||||
}
|
||||
jsonOK(w, versions)
|
||||
}
|
||||
|
||||
func (h *Handler) getConfigRuleVersion(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.configEnabled(w) {
|
||||
return
|
||||
}
|
||||
version, err := pathVersion(r)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "invalid version")
|
||||
return
|
||||
}
|
||||
rule, err := h.cfg.GetRuleVersion(r.PathValue("id"), version)
|
||||
if err != nil {
|
||||
jsonError(w, configStatus(err), err.Error())
|
||||
return
|
||||
}
|
||||
jsonOK(w, rule)
|
||||
}
|
||||
|
||||
func (h *Handler) promoteConfigRule(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.configEnabled(w) {
|
||||
return
|
||||
}
|
||||
version, err := pathVersion(r)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "invalid version")
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
if err := h.cfg.Promote(confmgr.KindRule, id, version); err != nil {
|
||||
jsonError(w, configStatus(err), err.Error())
|
||||
return
|
||||
}
|
||||
h.recordMutation(r, "config.rule.promote", id+" v"+strconv.Itoa(version))
|
||||
rule, err := h.cfg.GetRule(id)
|
||||
if err != nil {
|
||||
jsonError(w, configStatus(err), err.Error())
|
||||
return
|
||||
}
|
||||
jsonOK(w, rule)
|
||||
}
|
||||
|
||||
func (h *Handler) forkConfigRule(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.configEnabled(w) {
|
||||
return
|
||||
}
|
||||
version, err := pathVersion(r)
|
||||
if err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "invalid version")
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
newID, err := h.cfg.Fork(confmgr.KindRule, id, version)
|
||||
if err != nil {
|
||||
jsonError(w, configStatus(err), err.Error())
|
||||
return
|
||||
}
|
||||
h.recordMutation(r, "config.rule.fork", id+" v"+strconv.Itoa(version)+" -> "+newID)
|
||||
rule, err := h.cfg.GetRule(newID)
|
||||
if err != nil {
|
||||
jsonError(w, configStatus(err), err.Error())
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
jsonOK(w, rule)
|
||||
}
|
||||
|
||||
// checkConfigRule compiles a (possibly unsaved) CUE source and, when sample
|
||||
// values are supplied, evaluates it against them. It powers the editor's live
|
||||
// validation panel; nothing is persisted.
|
||||
func (h *Handler) checkConfigRule(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.configEnabled(w) {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Source string `json:"source"`
|
||||
Values map[string]any `json:"values"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||
return
|
||||
}
|
||||
jsonOK(w, confmgr.EvaluateRule(body.Source, body.Values))
|
||||
}
|
||||
|
||||
// previewConfigRule evaluates a (possibly unsaved) CUE source against a live
|
||||
// snapshot of the bound set's target signals, without storing anything. Unlike
|
||||
// check (which uses sample/default values), preview reads the current hardware
|
||||
// values so the operator sees exactly what the rule would derive from the real
|
||||
// configuration. Body: {setId, source}. Returns the captured snapshot plus the
|
||||
// rule result (violations + transformed values).
|
||||
func (h *Handler) previewConfigRule(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.configEnabled(w) {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
SetID string `json:"setId"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
jsonError(w, http.StatusBadRequest, "invalid JSON: "+err.Error())
|
||||
return
|
||||
}
|
||||
set, err := h.cfg.GetSet(body.SetID)
|
||||
if err != nil {
|
||||
jsonError(w, configStatus(err), err.Error())
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
results := h.readSetSignals(ctx, set)
|
||||
snap := confmgr.Snapshot(set, snapshotReader(results))
|
||||
res := confmgr.EvaluateRule(body.Source, snap.Values)
|
||||
jsonOK(w, struct {
|
||||
Snapshot confmgr.SnapshotResult `json:"snapshot"`
|
||||
Result confmgr.RuleResult `json:"result"`
|
||||
}{snap, res})
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestConfigRuleCRUD exercises the full config-rule REST surface: list, create,
|
||||
// get, update, versioning (list/get/promote/fork), check, preview, and delete,
|
||||
// plus the principal error paths.
|
||||
func TestConfigRuleCRUD(t *testing.T) {
|
||||
srv, teardown := setup(t)
|
||||
defer teardown()
|
||||
|
||||
// A set the rule (and preview) can bind to.
|
||||
resp := postJSON(t, srv, "/api/v1/config/sets", map[string]any{
|
||||
"name": "S",
|
||||
"parameters": []map[string]any{
|
||||
{"key": "voltage", "ds": "stub", "signal": "setpoint", "type": "float64", "default": 12.0},
|
||||
},
|
||||
})
|
||||
assertStatus(t, resp, http.StatusCreated)
|
||||
var set struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
readJSON(t, resp, &set)
|
||||
|
||||
// Empty list initially.
|
||||
resp = get(t, srv, "/api/v1/config/rules")
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
var list []map[string]any
|
||||
readJSON(t, resp, &list)
|
||||
if len(list) != 0 {
|
||||
t.Fatalf("expected no rules, got %d", len(list))
|
||||
}
|
||||
|
||||
// Create.
|
||||
resp = postJSON(t, srv, "/api/v1/config/rules", map[string]any{
|
||||
"name": "cap",
|
||||
"setId": set.ID,
|
||||
"source": "voltage: <=24",
|
||||
})
|
||||
assertStatus(t, resp, http.StatusCreated)
|
||||
var rule struct {
|
||||
ID string `json:"id"`
|
||||
Version int `json:"version"`
|
||||
}
|
||||
readJSON(t, resp, &rule)
|
||||
if rule.ID == "" {
|
||||
t.Fatal("rule missing id")
|
||||
}
|
||||
|
||||
// Create with invalid CUE → 400.
|
||||
resp = postJSON(t, srv, "/api/v1/config/rules", map[string]any{
|
||||
"name": "bad",
|
||||
"setId": set.ID,
|
||||
"source": "voltage: <=",
|
||||
})
|
||||
assertStatus(t, resp, http.StatusBadRequest)
|
||||
|
||||
// Create with malformed JSON → 400.
|
||||
resp = postRaw(t, srv, "/api/v1/config/rules", "application/json", []byte(`{not json`))
|
||||
assertStatus(t, resp, http.StatusBadRequest)
|
||||
|
||||
// Get.
|
||||
resp = get(t, srv, "/api/v1/config/rules/"+rule.ID)
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
resp.Body.Close()
|
||||
|
||||
// Get missing → 404.
|
||||
resp = get(t, srv, "/api/v1/config/rules/nope")
|
||||
assertStatus(t, resp, http.StatusNotFound)
|
||||
|
||||
// Update (bumps version, creates backup).
|
||||
resp = putRaw(t, srv, "/api/v1/config/rules/"+rule.ID, "application/json",
|
||||
[]byte(`{"name":"cap2","setId":"`+set.ID+`","source":"voltage: <=30"}`))
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
|
||||
// List versions.
|
||||
resp = get(t, srv, "/api/v1/config/rules/"+rule.ID+"/versions")
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
var versions []map[string]any
|
||||
readJSON(t, resp, &versions)
|
||||
if len(versions) < 2 {
|
||||
t.Fatalf("expected >=2 rule versions, got %d", len(versions))
|
||||
}
|
||||
|
||||
// Get v1.
|
||||
resp = get(t, srv, "/api/v1/config/rules/"+rule.ID+"/versions/1")
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
resp.Body.Close()
|
||||
|
||||
// Bad version path → 400.
|
||||
resp = get(t, srv, "/api/v1/config/rules/"+rule.ID+"/versions/xyz")
|
||||
assertStatus(t, resp, http.StatusBadRequest)
|
||||
|
||||
// Promote v1.
|
||||
resp = postRaw(t, srv, "/api/v1/config/rules/"+rule.ID+"/versions/1/promote", "application/json", nil)
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
|
||||
// Fork v1 → new id.
|
||||
resp = postRaw(t, srv, "/api/v1/config/rules/"+rule.ID+"/versions/1/fork", "application/json", nil)
|
||||
assertStatus(t, resp, http.StatusCreated)
|
||||
var fork struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
readJSON(t, resp, &fork)
|
||||
if fork.ID == "" || fork.ID == rule.ID {
|
||||
t.Errorf("fork id = %q, want fresh id", fork.ID)
|
||||
}
|
||||
|
||||
// Check (live validation of an unsaved source against sample values).
|
||||
resp = postJSON(t, srv, "/api/v1/config/rules/check", map[string]any{
|
||||
"source": "voltage: <=24",
|
||||
"values": map[string]any{"voltage": 20.0},
|
||||
})
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
resp.Body.Close()
|
||||
|
||||
// Check with malformed JSON → 400.
|
||||
resp = postRaw(t, srv, "/api/v1/config/rules/check", "application/json", []byte(`{`))
|
||||
assertStatus(t, resp, http.StatusBadRequest)
|
||||
|
||||
// Preview against the bound set's live signals.
|
||||
resp = postJSON(t, srv, "/api/v1/config/rules/preview", map[string]any{
|
||||
"setId": set.ID,
|
||||
"source": "voltage: <=24",
|
||||
})
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
resp.Body.Close()
|
||||
|
||||
// Preview with an unknown set → 404.
|
||||
resp = postJSON(t, srv, "/api/v1/config/rules/preview", map[string]any{
|
||||
"setId": "does-not-exist",
|
||||
"source": "voltage: <=24",
|
||||
})
|
||||
assertStatus(t, resp, http.StatusNotFound)
|
||||
|
||||
// Delete.
|
||||
resp = deleteReq(t, srv, "/api/v1/config/rules/"+rule.ID)
|
||||
assertStatus(t, resp, http.StatusNoContent)
|
||||
|
||||
// Delete missing → 404.
|
||||
resp = deleteReq(t, srv, "/api/v1/config/rules/"+rule.ID)
|
||||
assertStatus(t, resp, http.StatusNotFound)
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestConfigSetCRUD exercises create → get → version → apply over HTTP, using
|
||||
// the stub data source's writable "setpoint" signal as the apply target.
|
||||
func TestConfigManagerEndToEnd(t *testing.T) {
|
||||
srv, teardown := setup(t)
|
||||
defer teardown()
|
||||
|
||||
// Create a config set targeting the stub's writable setpoint.
|
||||
setBody := map[string]any{
|
||||
"name": "Stub PSU",
|
||||
"parameters": []map[string]any{
|
||||
{"key": "sp", "ds": "stub", "signal": "setpoint", "type": "float64", "default": 42.0, "mandatory": true, "min": 0.0, "max": 100.0},
|
||||
},
|
||||
}
|
||||
resp := postJSON(t, srv, "/api/v1/config/sets", setBody)
|
||||
assertStatus(t, resp, http.StatusCreated)
|
||||
var set struct {
|
||||
ID string `json:"id"`
|
||||
Version int `json:"version"`
|
||||
}
|
||||
readJSON(t, resp, &set)
|
||||
if set.ID == "" || set.Version != 1 {
|
||||
t.Fatalf("unexpected created set: %+v", set)
|
||||
}
|
||||
|
||||
// List sets includes it.
|
||||
resp = get(t, srv, "/api/v1/config/sets")
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
var sets []map[string]any
|
||||
readJSON(t, resp, &sets)
|
||||
if len(sets) != 1 {
|
||||
t.Fatalf("want 1 set, got %d", len(sets))
|
||||
}
|
||||
|
||||
// Create an instance assigning a concrete value.
|
||||
instBody := map[string]any{
|
||||
"name": "nominal",
|
||||
"setId": set.ID,
|
||||
"values": map[string]any{"sp": 73.0},
|
||||
}
|
||||
resp = postJSON(t, srv, "/api/v1/config/instances", instBody)
|
||||
assertStatus(t, resp, http.StatusCreated)
|
||||
var inst struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
readJSON(t, resp, &inst)
|
||||
if inst.ID == "" {
|
||||
t.Fatal("instance missing id")
|
||||
}
|
||||
|
||||
// Apply writes the value to the target signal.
|
||||
resp = postJSON(t, srv, "/api/v1/config/instances/"+inst.ID+"/apply", nil)
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
var res struct {
|
||||
Applied int `json:"applied"`
|
||||
Failed int `json:"failed"`
|
||||
Entries []struct {
|
||||
Signal string `json:"signal"`
|
||||
OK bool `json:"ok"`
|
||||
} `json:"entries"`
|
||||
}
|
||||
readJSON(t, resp, &res)
|
||||
if res.Applied != 1 || res.Failed != 0 {
|
||||
t.Fatalf("apply summary: applied=%d failed=%d", res.Applied, res.Failed)
|
||||
}
|
||||
if len(res.Entries) != 1 || res.Entries[0].Signal != "setpoint" || !res.Entries[0].OK {
|
||||
t.Errorf("unexpected apply entries: %+v", res.Entries)
|
||||
}
|
||||
|
||||
// Delete the set soft-deletes it.
|
||||
resp = deleteReq(t, srv, "/api/v1/config/sets/"+set.ID)
|
||||
assertStatus(t, resp, http.StatusNoContent)
|
||||
resp = get(t, srv, "/api/v1/config/sets/"+set.ID)
|
||||
assertStatus(t, resp, http.StatusNotFound)
|
||||
}
|
||||
|
||||
// TestConfigInstanceRejectsInvalidValue verifies server-side validation against
|
||||
// the bound set (value above the parameter's max).
|
||||
func TestConfigInstanceRejectsInvalidValue(t *testing.T) {
|
||||
srv, teardown := setup(t)
|
||||
defer teardown()
|
||||
|
||||
resp := postJSON(t, srv, "/api/v1/config/sets", map[string]any{
|
||||
"name": "S",
|
||||
"parameters": []map[string]any{
|
||||
{"key": "sp", "ds": "stub", "signal": "setpoint", "type": "float64", "max": 10.0},
|
||||
},
|
||||
})
|
||||
assertStatus(t, resp, http.StatusCreated)
|
||||
var set struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
readJSON(t, resp, &set)
|
||||
|
||||
resp = postJSON(t, srv, "/api/v1/config/instances", map[string]any{
|
||||
"name": "bad",
|
||||
"setId": set.ID,
|
||||
"values": map[string]any{"sp": 999.0},
|
||||
})
|
||||
assertStatus(t, resp, http.StatusBadRequest)
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestConfigSetVersioning exercises the set update/version/promote/fork/diff and
|
||||
// snapshot endpoints that the base end-to-end test does not reach.
|
||||
func TestConfigSetVersioning(t *testing.T) {
|
||||
srv, teardown := setup(t)
|
||||
defer teardown()
|
||||
|
||||
mk := func(name string) string {
|
||||
resp := postJSON(t, srv, "/api/v1/config/sets", map[string]any{
|
||||
"name": name,
|
||||
"parameters": []map[string]any{
|
||||
{"key": "sp", "ds": "stub", "signal": "setpoint", "type": "float64", "default": 1.0},
|
||||
},
|
||||
})
|
||||
assertStatus(t, resp, http.StatusCreated)
|
||||
var s struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
readJSON(t, resp, &s)
|
||||
return s.ID
|
||||
}
|
||||
|
||||
id := mk("Set A")
|
||||
|
||||
// Update → version bump + backup.
|
||||
resp := putRaw(t, srv, "/api/v1/config/sets/"+id, "application/json",
|
||||
[]byte(`{"name":"Set A v2","parameters":[{"key":"sp","ds":"stub","signal":"setpoint","type":"float64","default":2.0}]}`))
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
|
||||
// List versions.
|
||||
resp = get(t, srv, "/api/v1/config/sets/"+id+"/versions")
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
var versions []map[string]any
|
||||
readJSON(t, resp, &versions)
|
||||
if len(versions) < 2 {
|
||||
t.Fatalf("expected >=2 set versions, got %d", len(versions))
|
||||
}
|
||||
|
||||
// Get a specific version.
|
||||
resp = get(t, srv, "/api/v1/config/sets/"+id+"/versions/1")
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
resp.Body.Close()
|
||||
|
||||
// Promote v1.
|
||||
resp = postRaw(t, srv, "/api/v1/config/sets/"+id+"/versions/1/promote", "application/json", nil)
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
|
||||
// Fork v1 → new set id.
|
||||
resp = postRaw(t, srv, "/api/v1/config/sets/"+id+"/versions/1/fork", "application/json", nil)
|
||||
assertStatus(t, resp, http.StatusCreated)
|
||||
var fork struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
readJSON(t, resp, &fork)
|
||||
if fork.ID == "" || fork.ID == id {
|
||||
t.Errorf("fork id = %q, want fresh id", fork.ID)
|
||||
}
|
||||
|
||||
// Diff the original against the fork.
|
||||
resp = get(t, srv, "/api/v1/config/sets/diff?a="+id+"&b="+fork.ID)
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
resp.Body.Close()
|
||||
|
||||
// Diff with a missing left id → 400.
|
||||
resp = get(t, srv, "/api/v1/config/sets/diff?a=&b="+fork.ID)
|
||||
assertStatus(t, resp, http.StatusBadRequest)
|
||||
|
||||
// Snapshot the set into a new instance.
|
||||
resp = postJSON(t, srv, "/api/v1/config/sets/"+id+"/snapshot", map[string]any{"name": "snap-1"})
|
||||
assertStatus(t, resp, http.StatusCreated)
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
// TestConfigInstanceVersioning exercises the instance update/version/promote/
|
||||
// fork/validate and live-diff endpoints.
|
||||
func TestConfigInstanceVersioning(t *testing.T) {
|
||||
srv, teardown := setup(t)
|
||||
defer teardown()
|
||||
|
||||
// A set to bind instances to.
|
||||
resp := postJSON(t, srv, "/api/v1/config/sets", map[string]any{
|
||||
"name": "Set B",
|
||||
"parameters": []map[string]any{
|
||||
{"key": "sp", "ds": "stub", "signal": "setpoint", "type": "float64", "default": 1.0},
|
||||
},
|
||||
})
|
||||
assertStatus(t, resp, http.StatusCreated)
|
||||
var set struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
readJSON(t, resp, &set)
|
||||
|
||||
// Create an instance.
|
||||
resp = postJSON(t, srv, "/api/v1/config/instances", map[string]any{
|
||||
"name": "inst", "setId": set.ID, "values": map[string]any{"sp": 5.0},
|
||||
})
|
||||
assertStatus(t, resp, http.StatusCreated)
|
||||
var inst struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
readJSON(t, resp, &inst)
|
||||
|
||||
// Get it.
|
||||
resp = get(t, srv, "/api/v1/config/instances/"+inst.ID)
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
resp.Body.Close()
|
||||
|
||||
// List instances.
|
||||
resp = get(t, srv, "/api/v1/config/instances")
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
resp.Body.Close()
|
||||
|
||||
// Update → version bump.
|
||||
resp = putRaw(t, srv, "/api/v1/config/instances/"+inst.ID, "application/json",
|
||||
[]byte(`{"name":"inst v2","setId":"`+set.ID+`","values":{"sp":7.0}}`))
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
|
||||
// List versions.
|
||||
resp = get(t, srv, "/api/v1/config/instances/"+inst.ID+"/versions")
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
var versions []map[string]any
|
||||
readJSON(t, resp, &versions)
|
||||
if len(versions) < 2 {
|
||||
t.Fatalf("expected >=2 instance versions, got %d", len(versions))
|
||||
}
|
||||
|
||||
// Get a version.
|
||||
resp = get(t, srv, "/api/v1/config/instances/"+inst.ID+"/versions/1")
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
resp.Body.Close()
|
||||
|
||||
// Promote + fork.
|
||||
resp = postRaw(t, srv, "/api/v1/config/instances/"+inst.ID+"/versions/1/promote", "application/json", nil)
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
resp = postRaw(t, srv, "/api/v1/config/instances/"+inst.ID+"/versions/1/fork", "application/json", nil)
|
||||
assertStatus(t, resp, http.StatusCreated)
|
||||
resp.Body.Close()
|
||||
|
||||
// Validate against the (empty) rule set → 200.
|
||||
resp = postJSON(t, srv, "/api/v1/config/instances/"+inst.ID+"/validate", nil)
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
resp.Body.Close()
|
||||
|
||||
// Live diff against current signal values → 200.
|
||||
resp = get(t, srv, "/api/v1/config/instances/"+inst.ID+"/livediff")
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
resp.Body.Close()
|
||||
|
||||
// Delete the instance.
|
||||
resp = deleteReq(t, srv, "/api/v1/config/instances/"+inst.ID)
|
||||
assertStatus(t, resp, http.StatusNoContent)
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// These tests exercise the many handlers reachable through the default setup()
|
||||
// harness, whose policy is unconfigured (anonymous caller → write/admin), so
|
||||
// permission gates default to "allow" and the focus is handler behaviour.
|
||||
|
||||
// ── /me ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestGetMe(t *testing.T) {
|
||||
srv, teardown := setup(t)
|
||||
defer teardown()
|
||||
|
||||
resp := get(t, srv, "/api/v1/me")
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
var me map[string]any
|
||||
readJSON(t, resp, &me)
|
||||
for _, k := range []string{"user", "level", "groups", "canEditLogic", "canViewAudit", "canAdmin", "defaultZoom"} {
|
||||
if _, ok := me[k]; !ok {
|
||||
t.Errorf("/me missing key %q", k)
|
||||
}
|
||||
}
|
||||
// Unconfigured policy → anonymous caller has write level.
|
||||
if me["level"] != "write" {
|
||||
t.Errorf("level = %v, want write", me["level"])
|
||||
}
|
||||
}
|
||||
|
||||
// ── /groups (signal group tree) ───────────────────────────────────────────────
|
||||
|
||||
func TestGroupsRoundTrip(t *testing.T) {
|
||||
srv, teardown := setup(t)
|
||||
defer teardown()
|
||||
|
||||
// Initially valid JSON.
|
||||
resp := get(t, srv, "/api/v1/groups")
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
resp.Body.Close()
|
||||
|
||||
// Replace with a JSON array → 204.
|
||||
resp = putRaw(t, srv, "/api/v1/groups", "application/json", []byte(`[{"name":"A"},{"name":"B"}]`))
|
||||
assertStatus(t, resp, http.StatusNoContent)
|
||||
|
||||
// Read back what we wrote.
|
||||
resp = get(t, srv, "/api/v1/groups")
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
var tree []map[string]any
|
||||
if err := json.Unmarshal(body, &tree); err != nil {
|
||||
t.Fatalf("groups body not a JSON array: %v (%s)", err, body)
|
||||
}
|
||||
if len(tree) != 2 {
|
||||
t.Fatalf("expected 2 groups, got %d", len(tree))
|
||||
}
|
||||
|
||||
// A non-array body is rejected.
|
||||
resp = putRaw(t, srv, "/api/v1/groups", "application/json", []byte(`{"not":"array"}`))
|
||||
assertStatus(t, resp, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
// ── /usergroups ───────────────────────────────────────────────────────────────
|
||||
|
||||
func TestListUserGroups(t *testing.T) {
|
||||
srv, teardown := setup(t)
|
||||
defer teardown()
|
||||
|
||||
resp := get(t, srv, "/api/v1/usergroups")
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
var names []string
|
||||
readJSON(t, resp, &names)
|
||||
// Unconfigured policy has no named groups; the handler must still return [].
|
||||
if names == nil {
|
||||
t.Error("expected non-nil (possibly empty) group list")
|
||||
}
|
||||
}
|
||||
|
||||
// ── /folders ──────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestFolderCRUD(t *testing.T) {
|
||||
srv, teardown := setup(t)
|
||||
defer teardown()
|
||||
|
||||
// List — initially empty.
|
||||
resp := get(t, srv, "/api/v1/folders")
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
var list []any
|
||||
readJSON(t, resp, &list)
|
||||
if len(list) != 0 {
|
||||
t.Fatalf("expected no folders, got %d", len(list))
|
||||
}
|
||||
|
||||
// Missing name → 400.
|
||||
resp = postJSON(t, srv, "/api/v1/folders", map[string]any{"name": ""})
|
||||
assertStatus(t, resp, http.StatusBadRequest)
|
||||
|
||||
// Create.
|
||||
resp = postJSON(t, srv, "/api/v1/folders", map[string]any{"name": "Reactor"})
|
||||
assertStatus(t, resp, http.StatusCreated)
|
||||
var created struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
readJSON(t, resp, &created)
|
||||
if created.ID == "" {
|
||||
t.Fatal("expected folder id")
|
||||
}
|
||||
|
||||
// Update.
|
||||
resp = putRaw(t, srv, "/api/v1/folders/"+created.ID, "application/json",
|
||||
[]byte(`{"name":"Reactor Hall"}`))
|
||||
assertStatus(t, resp, http.StatusNoContent)
|
||||
|
||||
// Update with empty name → 400.
|
||||
resp = putRaw(t, srv, "/api/v1/folders/"+created.ID, "application/json", []byte(`{"name":""}`))
|
||||
assertStatus(t, resp, http.StatusBadRequest)
|
||||
|
||||
// Update unknown folder → 404.
|
||||
resp = putRaw(t, srv, "/api/v1/folders/fld-nope", "application/json", []byte(`{"name":"x"}`))
|
||||
assertStatus(t, resp, http.StatusNotFound)
|
||||
|
||||
// It now appears in the list.
|
||||
resp = get(t, srv, "/api/v1/folders")
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
readJSON(t, resp, &list)
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("expected 1 folder, got %d", len(list))
|
||||
}
|
||||
|
||||
// Delete.
|
||||
resp = deleteReq(t, srv, "/api/v1/folders/"+created.ID)
|
||||
assertStatus(t, resp, http.StatusNoContent)
|
||||
|
||||
// Delete unknown → 404.
|
||||
resp = deleteReq(t, srv, "/api/v1/folders/fld-nope")
|
||||
assertStatus(t, resp, http.StatusNotFound)
|
||||
}
|
||||
|
||||
// ── /interfaces/{id}/acl ──────────────────────────────────────────────────────
|
||||
|
||||
func TestPanelACL(t *testing.T) {
|
||||
srv, teardown := setup(t)
|
||||
defer teardown()
|
||||
|
||||
resp := postRaw(t, srv, "/api/v1/interfaces", "application/xml", []byte(sampleXML))
|
||||
assertStatus(t, resp, http.StatusCreated)
|
||||
var created struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
readJSON(t, resp, &created)
|
||||
|
||||
// GET ACL of a fresh panel.
|
||||
resp = get(t, srv, "/api/v1/interfaces/"+created.ID+"/acl")
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
var acl map[string]any
|
||||
readJSON(t, resp, &acl)
|
||||
if _, ok := acl["perm"]; !ok {
|
||||
t.Error("ACL response missing perm")
|
||||
}
|
||||
|
||||
// PUT ACL — set a public read level.
|
||||
resp = putRaw(t, srv, "/api/v1/interfaces/"+created.ID+"/acl", "application/json",
|
||||
[]byte(`{"public":"read","grants":[]}`))
|
||||
assertStatus(t, resp, http.StatusNoContent)
|
||||
|
||||
// PUT ACL on a non-existent panel → 404.
|
||||
resp = putRaw(t, srv, "/api/v1/interfaces/nope/acl", "application/json",
|
||||
[]byte(`{"public":"read"}`))
|
||||
assertStatus(t, resp, http.StatusNotFound)
|
||||
|
||||
// PUT ACL referencing an unknown folder → 400.
|
||||
resp = putRaw(t, srv, "/api/v1/interfaces/"+created.ID+"/acl", "application/json",
|
||||
[]byte(`{"folder":"fld-nope"}`))
|
||||
assertStatus(t, resp, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
// ── /interfaces/reorder ───────────────────────────────────────────────────────
|
||||
|
||||
func TestReorderInterfaces(t *testing.T) {
|
||||
srv, teardown := setup(t)
|
||||
defer teardown()
|
||||
|
||||
mk := func() string {
|
||||
resp := postRaw(t, srv, "/api/v1/interfaces", "application/xml", []byte(sampleXML))
|
||||
assertStatus(t, resp, http.StatusCreated)
|
||||
var c struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
readJSON(t, resp, &c)
|
||||
return c.ID
|
||||
}
|
||||
a, b := mk(), mk()
|
||||
|
||||
// Reorder at root (no folder).
|
||||
resp := postJSON(t, srv, "/api/v1/interfaces/reorder", map[string]any{"ids": []string{b, a}})
|
||||
assertStatus(t, resp, http.StatusNoContent)
|
||||
|
||||
// Reorder into a non-existent folder → 404.
|
||||
resp = postJSON(t, srv, "/api/v1/interfaces/reorder",
|
||||
map[string]any{"folder": "fld-nope", "ids": []string{a}})
|
||||
assertStatus(t, resp, http.StatusNotFound)
|
||||
|
||||
// Malformed body → 400.
|
||||
resp = postRaw(t, srv, "/api/v1/interfaces/reorder", "application/json", []byte(`{not json`))
|
||||
assertStatus(t, resp, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
// ── /interfaces/{id}/versions ─────────────────────────────────────────────────
|
||||
|
||||
func TestInterfaceVersioning(t *testing.T) {
|
||||
srv, teardown := setup(t)
|
||||
defer teardown()
|
||||
|
||||
resp := postRaw(t, srv, "/api/v1/interfaces", "application/xml", []byte(sampleXML))
|
||||
assertStatus(t, resp, http.StatusCreated)
|
||||
var created struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
readJSON(t, resp, &created)
|
||||
id := created.ID
|
||||
|
||||
// Update once to create a backup (v1) and bump current to v2.
|
||||
upd := `<interface id="" name="Test Panel" version="2" w="800" h="600"></interface>`
|
||||
resp = putRaw(t, srv, "/api/v1/interfaces/"+id, "application/xml", []byte(upd))
|
||||
assertStatus(t, resp, http.StatusNoContent)
|
||||
|
||||
// List versions — at least the backup.
|
||||
resp = get(t, srv, "/api/v1/interfaces/"+id+"/versions")
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
var versions []map[string]any
|
||||
readJSON(t, resp, &versions)
|
||||
if len(versions) < 2 {
|
||||
t.Fatalf("expected >=2 versions, got %d", len(versions))
|
||||
}
|
||||
|
||||
// Get the v1 backup.
|
||||
resp = get(t, srv, "/api/v1/interfaces/"+id+"/versions/1")
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
resp.Body.Close()
|
||||
|
||||
// Bad version path → 400.
|
||||
resp = get(t, srv, "/api/v1/interfaces/"+id+"/versions/abc")
|
||||
assertStatus(t, resp, http.StatusBadRequest)
|
||||
|
||||
// Tag v1.
|
||||
resp = putRaw(t, srv, "/api/v1/interfaces/"+id+"/versions/1/tag?tag=golden", "application/json", nil)
|
||||
assertStatus(t, resp, http.StatusNoContent)
|
||||
|
||||
// Promote v1 → becomes new current.
|
||||
resp = postRaw(t, srv, "/api/v1/interfaces/"+id+"/versions/1/promote", "application/json", nil)
|
||||
assertStatus(t, resp, http.StatusNoContent)
|
||||
|
||||
// Fork v1 → brand-new panel.
|
||||
resp = postRaw(t, srv, "/api/v1/interfaces/"+id+"/versions/1/fork", "application/json", nil)
|
||||
assertStatus(t, resp, http.StatusCreated)
|
||||
var fork struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
readJSON(t, resp, &fork)
|
||||
if fork.ID == "" || fork.ID == id {
|
||||
t.Errorf("fork id = %q, want fresh id", fork.ID)
|
||||
}
|
||||
|
||||
// Version ops on a missing panel → 404.
|
||||
resp = get(t, srv, "/api/v1/interfaces/missing/versions")
|
||||
assertStatus(t, resp, http.StatusNotFound)
|
||||
}
|
||||
|
||||
// ── /controllogic ─────────────────────────────────────────────────────────────
|
||||
|
||||
func TestControlLogicCRUD(t *testing.T) {
|
||||
srv, teardown := setup(t)
|
||||
defer teardown()
|
||||
|
||||
// Empty list initially.
|
||||
resp := get(t, srv, "/api/v1/controllogic")
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
var list []any
|
||||
readJSON(t, resp, &list)
|
||||
if len(list) != 0 {
|
||||
t.Fatalf("expected no control logic, got %d", len(list))
|
||||
}
|
||||
|
||||
// Create.
|
||||
resp = postJSON(t, srv, "/api/v1/controllogic", map[string]any{"name": "Watchdog", "enabled": true})
|
||||
assertStatus(t, resp, http.StatusCreated)
|
||||
var g struct {
|
||||
ID string `json:"id"`
|
||||
Version int `json:"version"`
|
||||
}
|
||||
readJSON(t, resp, &g)
|
||||
if g.ID == "" {
|
||||
t.Fatal("expected control logic id")
|
||||
}
|
||||
|
||||
// Get.
|
||||
resp = get(t, srv, "/api/v1/controllogic/"+g.ID)
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
resp.Body.Close()
|
||||
|
||||
// Get missing → 404.
|
||||
resp = get(t, srv, "/api/v1/controllogic/nope")
|
||||
assertStatus(t, resp, http.StatusNotFound)
|
||||
|
||||
// Update (bumps version, creates a backup).
|
||||
resp = putRaw(t, srv, "/api/v1/controllogic/"+g.ID, "application/json",
|
||||
[]byte(`{"name":"Watchdog v2","enabled":false}`))
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
|
||||
// List versions.
|
||||
resp = get(t, srv, "/api/v1/controllogic/"+g.ID+"/versions")
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
var versions []map[string]any
|
||||
readJSON(t, resp, &versions)
|
||||
if len(versions) < 2 {
|
||||
t.Fatalf("expected >=2 control-logic versions, got %d", len(versions))
|
||||
}
|
||||
|
||||
// Promote v1.
|
||||
resp = postRaw(t, srv, "/api/v1/controllogic/"+g.ID+"/versions/1/promote", "application/json", nil)
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
|
||||
// Fork v1 → new graph id.
|
||||
resp = postRaw(t, srv, "/api/v1/controllogic/"+g.ID+"/versions/1/fork", "application/json", nil)
|
||||
assertStatus(t, resp, http.StatusCreated)
|
||||
var fork struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
readJSON(t, resp, &fork)
|
||||
if fork.ID == "" || fork.ID == g.ID {
|
||||
t.Errorf("fork id = %q, want fresh id", fork.ID)
|
||||
}
|
||||
|
||||
// Delete.
|
||||
resp = deleteReq(t, srv, "/api/v1/controllogic/"+g.ID)
|
||||
assertStatus(t, resp, http.StatusNoContent)
|
||||
|
||||
// Delete missing → 404.
|
||||
resp = deleteReq(t, srv, "/api/v1/controllogic/"+g.ID)
|
||||
assertStatus(t, resp, http.StatusNotFound)
|
||||
}
|
||||
|
||||
// ── /audit (Nop audit log, anonymous can view on an open policy) ───────────────
|
||||
|
||||
func TestGetAuditOpen(t *testing.T) {
|
||||
srv, teardown := setup(t)
|
||||
defer teardown()
|
||||
|
||||
resp := get(t, srv, "/api/v1/audit")
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
resp.Body.Close()
|
||||
|
||||
// Bad start time → 400.
|
||||
resp = get(t, srv, "/api/v1/audit?start=not-a-time")
|
||||
assertStatus(t, resp, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
// ── /synthetic disabled guards ────────────────────────────────────────────────
|
||||
|
||||
// Synthetic is nil in the default harness, so every route must report 503
|
||||
// (service unavailable) rather than panicking.
|
||||
func TestSyntheticDisabledRoutes(t *testing.T) {
|
||||
srv, teardown := setup(t)
|
||||
defer teardown()
|
||||
|
||||
cases := []struct {
|
||||
method, path string
|
||||
body []byte
|
||||
}{
|
||||
{http.MethodGet, "/api/v1/synthetic/x", nil},
|
||||
{http.MethodPut, "/api/v1/synthetic/x", []byte(`{}`)},
|
||||
{http.MethodDelete, "/api/v1/synthetic/x", nil},
|
||||
{http.MethodPost, "/api/v1/synthetic/trace", []byte(`{}`)},
|
||||
{http.MethodGet, "/api/v1/synthetic/x/versions", nil},
|
||||
{http.MethodGet, "/api/v1/synthetic/x/versions/1", nil},
|
||||
{http.MethodPost, "/api/v1/synthetic/x/versions/1/promote", nil},
|
||||
{http.MethodPost, "/api/v1/synthetic/x/versions/1/fork", nil},
|
||||
}
|
||||
for _, c := range cases {
|
||||
var resp *http.Response
|
||||
switch c.method {
|
||||
case http.MethodGet:
|
||||
resp = get(t, srv, c.path)
|
||||
case http.MethodPut:
|
||||
resp = putRaw(t, srv, c.path, "application/json", c.body)
|
||||
case http.MethodDelete:
|
||||
resp = deleteReq(t, srv, c.path)
|
||||
case http.MethodPost:
|
||||
resp = postRaw(t, srv, c.path, "application/json", c.body)
|
||||
}
|
||||
if resp.StatusCode != http.StatusServiceUnavailable {
|
||||
t.Errorf("%s %s: status %d, want 503", c.method, c.path, resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// ── /controllogic/{id}/versions/{version} ─────────────────────────────────────
|
||||
|
||||
func TestControlLogicGetVersion(t *testing.T) {
|
||||
srv, teardown := setup(t)
|
||||
defer teardown()
|
||||
|
||||
resp := postJSON(t, srv, "/api/v1/controllogic", map[string]any{"name": "G"})
|
||||
assertStatus(t, resp, http.StatusCreated)
|
||||
var g struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
readJSON(t, resp, &g)
|
||||
|
||||
// Bump so a v1 backup exists.
|
||||
resp = putRaw(t, srv, "/api/v1/controllogic/"+g.ID, "application/json", []byte(`{"name":"G2"}`))
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
|
||||
resp = get(t, srv, "/api/v1/controllogic/"+g.ID+"/versions/1")
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
resp.Body.Close()
|
||||
|
||||
// Bad version number → 400.
|
||||
resp = get(t, srv, "/api/v1/controllogic/"+g.ID+"/versions/xyz")
|
||||
assertStatus(t, resp, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
// ── /config/instances/diff ────────────────────────────────────────────────────
|
||||
|
||||
func TestDiffConfigInstancesMissing(t *testing.T) {
|
||||
srv, teardown := setup(t)
|
||||
defer teardown()
|
||||
|
||||
// Missing left id → 400.
|
||||
resp := get(t, srv, "/api/v1/config/instances/diff?a=&b=")
|
||||
assertStatus(t, resp, http.StatusBadRequest)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/uopi/uopi/internal/access"
|
||||
"github.com/uopi/uopi/internal/datasource"
|
||||
"github.com/uopi/uopi/internal/datasource/synthetic"
|
||||
"github.com/uopi/uopi/internal/panelacl"
|
||||
)
|
||||
|
||||
// TestExtractLogicBlock covers the three branches of the <logic> extractor.
|
||||
func TestExtractLogicBlock(t *testing.T) {
|
||||
if got := extractLogicBlock([]byte(`<i><logic><n/></logic></i>`)); got != "<logic><n/></logic>" {
|
||||
t.Errorf("extract = %q", got)
|
||||
}
|
||||
if got := extractLogicBlock([]byte(`<i></i>`)); got != "" {
|
||||
t.Errorf("no logic: want empty, got %q", got)
|
||||
}
|
||||
if got := extractLogicBlock([]byte(`<i><logic>unterminated`)); got != "" {
|
||||
t.Errorf("unterminated: want empty, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDataTypeName covers every DataType→token mapping plus the default.
|
||||
func TestDataTypeName(t *testing.T) {
|
||||
cases := map[datasource.DataType]string{
|
||||
datasource.TypeFloat64: "float64",
|
||||
datasource.TypeFloat64Array: "float64[]",
|
||||
datasource.TypeString: "string",
|
||||
datasource.TypeInt64: "int64",
|
||||
datasource.TypeBool: "bool",
|
||||
datasource.TypeEnum: "enum",
|
||||
datasource.DataType(255): "unknown",
|
||||
}
|
||||
for typ, want := range cases {
|
||||
if got := dataTypeName(typ); got != want {
|
||||
t.Errorf("dataTypeName(%v) = %q, want %q", typ, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSynVisible covers each visibility branch of synVisible.
|
||||
func TestSynVisible(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
def synthetic.SignalDef
|
||||
user string
|
||||
panel string
|
||||
groups []string
|
||||
want bool
|
||||
}{
|
||||
{"user own", synthetic.SignalDef{Visibility: "user", Owner: "alice"}, "alice", "", nil, true},
|
||||
{"user other", synthetic.SignalDef{Visibility: "user", Owner: "alice"}, "bob", "", nil, false},
|
||||
{"panel match", synthetic.SignalDef{Visibility: "panel", Panel: "p1"}, "bob", "p1", nil, true},
|
||||
{"panel mismatch", synthetic.SignalDef{Visibility: "panel", Panel: "p1"}, "bob", "p2", nil, false},
|
||||
{"global", synthetic.SignalDef{Visibility: "global"}, "", "", nil, true},
|
||||
{"legacy empty", synthetic.SignalDef{}, "", "", nil, true},
|
||||
{"group member", synthetic.SignalDef{Visibility: "group", Owner: "alice", Groups: []string{"ops"}}, "bob", "", []string{"ops"}, true},
|
||||
{"group outsider", synthetic.SignalDef{Visibility: "group", Owner: "alice", Groups: []string{"ops"}}, "bob", "", []string{"hr"}, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := synVisible(tc.def, tc.user, tc.panel, tc.groups); got != tc.want {
|
||||
t.Errorf("%s: synVisible = %v, want %v", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPanelScope covers the nil/public→global, group, and private branches.
|
||||
func TestPanelScope(t *testing.T) {
|
||||
if sc, _ := panelScope(nil); sc != access.ScopeGlobal {
|
||||
t.Errorf("nil acl: scope = %q, want global", sc)
|
||||
}
|
||||
if sc, _ := panelScope(&panelacl.PanelACL{Public: "read"}); sc != access.ScopeGlobal {
|
||||
t.Errorf("public acl: scope = %q, want global", sc)
|
||||
}
|
||||
sc, groups := panelScope(&panelacl.PanelACL{
|
||||
Grants: []panelacl.Grant{{Kind: "group", Name: "ops"}, {Kind: "user", Name: "x"}},
|
||||
})
|
||||
if sc != access.ScopeGroup || len(groups) != 1 || groups[0] != "ops" {
|
||||
t.Errorf("group acl: scope=%q groups=%v", sc, groups)
|
||||
}
|
||||
if sc, _ := panelScope(&panelacl.PanelACL{Owner: "alice"}); sc != access.ScopePrivate {
|
||||
t.Errorf("private acl: scope = %q, want private", sc)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClientIP covers the XFF, X-Real-IP, host:port, and bare-RemoteAddr cases.
|
||||
func TestClientIP(t *testing.T) {
|
||||
mk := func(set func(*http.Request)) *http.Request {
|
||||
r := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
set(r)
|
||||
return r
|
||||
}
|
||||
if got := clientIP(mk(func(r *http.Request) { r.Header.Set("X-Forwarded-For", "1.2.3.4, 5.6.7.8") })); got != "1.2.3.4" {
|
||||
t.Errorf("XFF list: got %q", got)
|
||||
}
|
||||
if got := clientIP(mk(func(r *http.Request) { r.Header.Set("X-Forwarded-For", "9.9.9.9") })); got != "9.9.9.9" {
|
||||
t.Errorf("XFF single: got %q", got)
|
||||
}
|
||||
if got := clientIP(mk(func(r *http.Request) { r.Header.Set("X-Real-IP", "8.8.8.8") })); got != "8.8.8.8" {
|
||||
t.Errorf("X-Real-IP: got %q", got)
|
||||
}
|
||||
if got := clientIP(mk(func(r *http.Request) { r.RemoteAddr = "10.0.0.1:5555" })); got != "10.0.0.1" {
|
||||
t.Errorf("host:port: got %q", got)
|
||||
}
|
||||
if got := clientIP(mk(func(r *http.Request) { r.RemoteAddr = "bare-addr" })); got != "bare-addr" {
|
||||
t.Errorf("bare addr: got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/uopi/uopi/internal/access"
|
||||
"github.com/uopi/uopi/internal/api"
|
||||
"github.com/uopi/uopi/internal/audit"
|
||||
"github.com/uopi/uopi/internal/broker"
|
||||
"github.com/uopi/uopi/internal/confmgr"
|
||||
"github.com/uopi/uopi/internal/controllogic"
|
||||
"github.com/uopi/uopi/internal/datasource/stub"
|
||||
"github.com/uopi/uopi/internal/datasource/synthetic"
|
||||
"github.com/uopi/uopi/internal/panelacl"
|
||||
"github.com/uopi/uopi/internal/storage"
|
||||
)
|
||||
|
||||
// setupSynthetic builds an API server whose synthetic data source is enabled, so
|
||||
// the synthetic CRUD/versioning/trace handlers run their real bodies.
|
||||
func setupSynthetic(t *testing.T) (*httptest.Server, func()) {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
brk := broker.New(ctx, log)
|
||||
ds := stub.New()
|
||||
if err := ds.Connect(ctx); err != nil {
|
||||
t.Fatal("stub connect:", err)
|
||||
}
|
||||
brk.Register(ds)
|
||||
|
||||
dir := t.TempDir()
|
||||
store, _ := storage.New(dir)
|
||||
acl, _ := panelacl.New(dir)
|
||||
clStore, _ := controllogic.NewStore(dir)
|
||||
cfgStore, _ := confmgr.New(dir)
|
||||
clEngine := controllogic.NewEngine(ctx, brk, clStore, cfgStore, audit.Nop(), log)
|
||||
|
||||
synthDS := synthetic.New(dir, brk, log)
|
||||
if err := synthDS.Connect(ctx); err != nil {
|
||||
t.Fatal("synthetic connect:", err)
|
||||
}
|
||||
brk.Register(synthDS)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
api.New(brk, synthDS, store, cfgStore, access.New("", nil), acl, clStore, clEngine, audit.Nop(), "", "", 0, log).Register(mux, "/api/v1")
|
||||
|
||||
srv := httptest.NewServer(mux)
|
||||
return srv, func() { srv.Close(); cancel() }
|
||||
}
|
||||
|
||||
// putJSON marshals body and issues a PUT, mirroring postJSON.
|
||||
func putJSON(t *testing.T, srv *httptest.Server, path string, body any) *http.Response {
|
||||
t.Helper()
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
t.Fatal("json.Marshal:", err)
|
||||
}
|
||||
return putRaw(t, srv, path, "application/json", b)
|
||||
}
|
||||
|
||||
// syntheticBody returns a minimal valid graph signal sourced from the stub's
|
||||
// "sine" signal through a gain op.
|
||||
func syntheticBody(name string) map[string]any {
|
||||
return map[string]any{
|
||||
"name": name,
|
||||
"visibility": "global",
|
||||
"graph": map[string]any{
|
||||
"output": "out",
|
||||
"nodes": []map[string]any{
|
||||
{"id": "a", "kind": "source", "ds": "stub", "signal": "sine"},
|
||||
{"id": "g", "kind": "op", "op": "gain", "inputs": []string{"a"},
|
||||
"params": map[string]any{"k": 2.0}},
|
||||
{"id": "out", "kind": "output", "inputs": []string{"g"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyntheticCRUDEnabled(t *testing.T) {
|
||||
srv, teardown := setupSynthetic(t)
|
||||
defer teardown()
|
||||
|
||||
// List — initially empty.
|
||||
resp := get(t, srv, "/api/v1/synthetic")
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
var list []any
|
||||
readJSON(t, resp, &list)
|
||||
if len(list) != 0 {
|
||||
t.Fatalf("expected no synthetic signals, got %d", len(list))
|
||||
}
|
||||
|
||||
// Create.
|
||||
resp = postJSON(t, srv, "/api/v1/synthetic", syntheticBody("doubled"))
|
||||
assertStatus(t, resp, http.StatusCreated)
|
||||
resp.Body.Close()
|
||||
|
||||
// Duplicate create → 400 (AddSignal rejects an existing name).
|
||||
resp = postJSON(t, srv, "/api/v1/synthetic", syntheticBody("doubled"))
|
||||
assertStatus(t, resp, http.StatusBadRequest)
|
||||
|
||||
// Invalid JSON → 400.
|
||||
resp = postRaw(t, srv, "/api/v1/synthetic", "application/json", []byte(`{bad`))
|
||||
assertStatus(t, resp, http.StatusBadRequest)
|
||||
|
||||
// Get.
|
||||
resp = get(t, srv, "/api/v1/synthetic/doubled")
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
resp.Body.Close()
|
||||
|
||||
// Get missing → 404.
|
||||
resp = get(t, srv, "/api/v1/synthetic/nope")
|
||||
assertStatus(t, resp, http.StatusNotFound)
|
||||
|
||||
// Update (changes gain factor).
|
||||
upd := syntheticBody("doubled")
|
||||
upd["graph"].(map[string]any)["nodes"].([]map[string]any)[1]["params"] = map[string]any{"k": 3.0}
|
||||
resp = putJSON(t, srv, "/api/v1/synthetic/doubled", upd)
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
resp.Body.Close()
|
||||
|
||||
// Update missing → 404.
|
||||
resp = putJSON(t, srv, "/api/v1/synthetic/nope", syntheticBody("nope"))
|
||||
assertStatus(t, resp, http.StatusNotFound)
|
||||
|
||||
// Versions list (the update created a backup).
|
||||
resp = get(t, srv, "/api/v1/synthetic/doubled/versions")
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
var versions []map[string]any
|
||||
readJSON(t, resp, &versions)
|
||||
if len(versions) < 2 {
|
||||
t.Fatalf("expected >=2 synthetic versions, got %d", len(versions))
|
||||
}
|
||||
|
||||
// Get v1.
|
||||
resp = get(t, srv, "/api/v1/synthetic/doubled/versions/1")
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
resp.Body.Close()
|
||||
|
||||
// Bad version → 400.
|
||||
resp = get(t, srv, "/api/v1/synthetic/doubled/versions/xyz")
|
||||
assertStatus(t, resp, http.StatusBadRequest)
|
||||
|
||||
// Promote v1.
|
||||
resp = postRaw(t, srv, "/api/v1/synthetic/doubled/versions/1/promote", "application/json", nil)
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
resp.Body.Close()
|
||||
|
||||
// Fork v1 → new signal.
|
||||
resp = postRaw(t, srv, "/api/v1/synthetic/doubled/versions/1/fork", "application/json", nil)
|
||||
assertStatus(t, resp, http.StatusCreated)
|
||||
resp.Body.Close()
|
||||
|
||||
// Trace an unsaved graph.
|
||||
resp = postJSON(t, srv, "/api/v1/synthetic/trace", syntheticBody("scratch"))
|
||||
assertStatus(t, resp, http.StatusOK)
|
||||
resp.Body.Close()
|
||||
|
||||
// Trace with invalid JSON → 400.
|
||||
resp = postRaw(t, srv, "/api/v1/synthetic/trace", "application/json", []byte(`{bad`))
|
||||
assertStatus(t, resp, http.StatusBadRequest)
|
||||
|
||||
// Delete.
|
||||
resp = deleteReq(t, srv, "/api/v1/synthetic/doubled")
|
||||
assertStatus(t, resp, http.StatusNoContent)
|
||||
|
||||
// Delete missing → 404.
|
||||
resp = deleteReq(t, srv, "/api/v1/synthetic/doubled")
|
||||
assertStatus(t, resp, http.StatusNotFound)
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
// Package audit records system-affecting actions (signal writes by users or the
|
||||
// control-logic engine, interface and control-logic mutations) to an append-only
|
||||
// SQLite log that audit staff can query later. It is enabled via [audit] in the
|
||||
// config; when disabled a no-op Recorder is used so call sites need no guards.
|
||||
package audit
|
||||
|
||||
import "time"
|
||||
|
||||
// Actor types distinguish human-initiated actions from automated ones.
|
||||
const (
|
||||
ActorUser = "user" // action attributed to an authenticated end-user
|
||||
ActorSystem = "system" // action performed by the control-logic engine
|
||||
)
|
||||
|
||||
// Outcomes record whether the action succeeded.
|
||||
const (
|
||||
OutcomeOK = "ok"
|
||||
OutcomeError = "error"
|
||||
)
|
||||
|
||||
// Event is a single audit record. Optional fields are omitted from the database
|
||||
// when empty. Time defaults to the current time when zero.
|
||||
type Event struct {
|
||||
Time time.Time `json:"time"`
|
||||
Actor string `json:"actor"` // username, or graph name for system actions
|
||||
ActorType string `json:"actorType"` // ActorUser | ActorSystem
|
||||
Action string `json:"action"` // e.g. "signal.write", "interface.update"
|
||||
DS string `json:"ds,omitempty"` // data source (signal writes)
|
||||
Signal string `json:"signal,omitempty"` // signal / target name
|
||||
Value string `json:"value,omitempty"` // serialised written value
|
||||
Detail string `json:"detail,omitempty"` // free-form context (id, graph, trigger)
|
||||
IP string `json:"ip,omitempty"` // client address (user actions)
|
||||
Outcome string `json:"outcome"` // OutcomeOK | OutcomeError
|
||||
Error string `json:"error,omitempty"` // message when Outcome==OutcomeError
|
||||
}
|
||||
|
||||
// Filter selects a subset of events for Query. Zero-valued fields are ignored.
|
||||
type Filter struct {
|
||||
Start time.Time
|
||||
End time.Time
|
||||
Actor string
|
||||
Action string
|
||||
DS string
|
||||
Signal string
|
||||
Limit int // <=0 means a default cap is applied
|
||||
}
|
||||
|
||||
// Recorder appends events and answers queries. Implementations must be safe for
|
||||
// concurrent use and Record must never block the caller for long.
|
||||
type Recorder interface {
|
||||
Record(Event)
|
||||
Query(Filter) ([]Event, error)
|
||||
Close() error
|
||||
}
|
||||
|
||||
// nopRecorder is used when auditing is disabled. It discards every event.
|
||||
type nopRecorder struct{}
|
||||
|
||||
func (nopRecorder) Record(Event) {}
|
||||
func (nopRecorder) Query(Filter) ([]Event, error) { return []Event{}, nil }
|
||||
func (nopRecorder) Close() error { return nil }
|
||||
|
||||
// Nop returns a Recorder that discards everything. Call sites can hold a Recorder
|
||||
// unconditionally and call Record without checking whether auditing is enabled.
|
||||
func Nop() Recorder { return nopRecorder{} }
|
||||
@@ -0,0 +1,91 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestNopRecorder covers the disabled-auditing no-op implementation.
|
||||
func TestNopRecorder(t *testing.T) {
|
||||
rec := Nop()
|
||||
rec.Record(Event{Action: "x"}) // must not panic
|
||||
got, err := rec.Query(Filter{})
|
||||
if err != nil {
|
||||
t.Fatalf("Nop Query: %v", err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Errorf("Nop Query returned %d events, want 0", len(got))
|
||||
}
|
||||
if err := rec.Close(); err != nil {
|
||||
t.Errorf("Nop Close: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestQueryFilters covers the End, DS, and Signal (LIKE) filter branches plus
|
||||
// the default-outcome and zero-time stamping in Record.
|
||||
func TestQueryFilters(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "audit.db")
|
||||
rec, err := NewSQLite(path, slog.Default())
|
||||
if err != nil {
|
||||
t.Fatal("NewSQLite:", err)
|
||||
}
|
||||
defer rec.Close()
|
||||
|
||||
// Anchor base in the past so the zero-Time 'c' record (stamped with now)
|
||||
// sorts after a/b and stays out of the End window below.
|
||||
base := time.Now().Add(-time.Hour)
|
||||
rec.Record(Event{Time: base, Actor: "a", ActorType: ActorUser, Action: "signal.write", DS: "epics", Signal: "PV:TEMP"})
|
||||
rec.Record(Event{Time: base.Add(time.Second), Actor: "b", ActorType: ActorUser, Action: "signal.write", DS: "synthetic", Signal: "PV:FLOW"})
|
||||
// Zero Time and empty Outcome exercise the defaulting branches in Record.
|
||||
rec.Record(Event{Actor: "c", ActorType: ActorUser, Action: "interface.update"})
|
||||
|
||||
if err := rec.Close(); err != nil {
|
||||
t.Fatal("Close:", err)
|
||||
}
|
||||
rec, err = NewSQLite(path, slog.Default())
|
||||
if err != nil {
|
||||
t.Fatal("reopen:", err)
|
||||
}
|
||||
defer rec.Close()
|
||||
|
||||
// End filter: only events at or before base.
|
||||
upTo, err := rec.Query(Filter{End: base.Add(500 * time.Millisecond)})
|
||||
if err != nil {
|
||||
t.Fatal("Query end:", err)
|
||||
}
|
||||
if len(upTo) != 1 || upTo[0].Actor != "a" {
|
||||
t.Errorf("end filter = %+v, want one 'a' event", upTo)
|
||||
}
|
||||
|
||||
// DS filter.
|
||||
byDS, err := rec.Query(Filter{DS: "synthetic"})
|
||||
if err != nil {
|
||||
t.Fatal("Query ds:", err)
|
||||
}
|
||||
if len(byDS) != 1 || byDS[0].Signal != "PV:FLOW" {
|
||||
t.Errorf("ds filter = %+v, want one PV:FLOW event", byDS)
|
||||
}
|
||||
|
||||
// Signal LIKE filter (substring match).
|
||||
bySignal, err := rec.Query(Filter{Signal: "TEMP"})
|
||||
if err != nil {
|
||||
t.Fatal("Query signal:", err)
|
||||
}
|
||||
if len(bySignal) != 1 || bySignal[0].DS != "epics" {
|
||||
t.Errorf("signal filter = %+v, want one epics event", bySignal)
|
||||
}
|
||||
|
||||
// The zero-time/empty-outcome record was persisted with a default outcome.
|
||||
def, err := rec.Query(Filter{Actor: "c"})
|
||||
if err != nil {
|
||||
t.Fatal("Query actor c:", err)
|
||||
}
|
||||
if len(def) != 1 || def[0].Outcome != OutcomeOK {
|
||||
t.Errorf("defaulted record = %+v, want one event with outcome ok", def)
|
||||
}
|
||||
if def[0].Time.IsZero() {
|
||||
t.Error("zero Time should have been stamped with now")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
_ "modernc.org/sqlite" // pure-Go SQLite driver (no CGo, keeps the single static binary)
|
||||
)
|
||||
|
||||
// defaultQueryLimit caps how many rows Query returns when the filter does not
|
||||
// specify a smaller limit, protecting the API and UI from unbounded result sets.
|
||||
const defaultQueryLimit = 1000
|
||||
|
||||
// writeBuffer is the depth of the async insert queue. When full, Record falls
|
||||
// back to a synchronous insert so events are never silently dropped.
|
||||
const writeBuffer = 4096
|
||||
|
||||
// sqliteRecorder appends events to a SQLite database. Inserts are normally
|
||||
// handled by a background goroutine so Record does not block the calling write
|
||||
// path; the queue has a synchronous fallback to guarantee durability under load.
|
||||
type sqliteRecorder struct {
|
||||
db *sql.DB
|
||||
log *slog.Logger
|
||||
|
||||
ch chan Event
|
||||
wg sync.WaitGroup
|
||||
closed chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
// NewSQLite opens (creating if needed) the audit database at path and starts the
|
||||
// background writer. The returned Recorder must be Closed on shutdown.
|
||||
func NewSQLite(path string, log *slog.Logger) (Recorder, error) {
|
||||
// WAL + a busy timeout let the async writer and synchronous query/fallback
|
||||
// paths share the file without "database is locked" errors.
|
||||
dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)", path)
|
||||
db, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open audit db: %w", err)
|
||||
}
|
||||
if _, err := db.Exec(schema); err != nil {
|
||||
_ = db.Close()
|
||||
return nil, fmt.Errorf("init audit schema: %w", err)
|
||||
}
|
||||
r := &sqliteRecorder{
|
||||
db: db,
|
||||
log: log,
|
||||
ch: make(chan Event, writeBuffer),
|
||||
closed: make(chan struct{}),
|
||||
}
|
||||
r.wg.Add(1)
|
||||
go r.writeLoop()
|
||||
return r, nil
|
||||
}
|
||||
|
||||
const schema = `
|
||||
CREATE TABLE IF NOT EXISTS audit_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts_ns INTEGER NOT NULL,
|
||||
actor TEXT NOT NULL,
|
||||
actor_type TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
ds TEXT NOT NULL DEFAULT '',
|
||||
signal TEXT NOT NULL DEFAULT '',
|
||||
value TEXT NOT NULL DEFAULT '',
|
||||
detail TEXT NOT NULL DEFAULT '',
|
||||
ip TEXT NOT NULL DEFAULT '',
|
||||
outcome TEXT NOT NULL DEFAULT 'ok',
|
||||
error TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_ts ON audit_log(ts_ns);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_actor ON audit_log(actor);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_action ON audit_log(action);
|
||||
`
|
||||
|
||||
func (r *sqliteRecorder) Record(e Event) {
|
||||
if e.Time.IsZero() {
|
||||
e.Time = time.Now()
|
||||
}
|
||||
if e.Outcome == "" {
|
||||
e.Outcome = OutcomeOK
|
||||
}
|
||||
select {
|
||||
case <-r.closed:
|
||||
// Recorder is shutting down; best-effort synchronous insert.
|
||||
r.insert(e)
|
||||
case r.ch <- e:
|
||||
default:
|
||||
// Queue full: write synchronously rather than drop an audit record.
|
||||
r.insert(e)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *sqliteRecorder) writeLoop() {
|
||||
defer r.wg.Done()
|
||||
for e := range r.ch {
|
||||
r.insert(e)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *sqliteRecorder) insert(e Event) {
|
||||
_, err := r.db.Exec(
|
||||
`INSERT INTO audit_log (ts_ns, actor, actor_type, action, ds, signal, value, detail, ip, outcome, error)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
e.Time.UnixNano(), e.Actor, e.ActorType, e.Action,
|
||||
e.DS, e.Signal, e.Value, e.Detail, e.IP, e.Outcome, e.Error,
|
||||
)
|
||||
if err != nil {
|
||||
r.log.Error("audit: insert failed", "action", e.Action, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *sqliteRecorder) Query(f Filter) ([]Event, error) {
|
||||
var where []string
|
||||
var args []any
|
||||
if !f.Start.IsZero() {
|
||||
where = append(where, "ts_ns >= ?")
|
||||
args = append(args, f.Start.UnixNano())
|
||||
}
|
||||
if !f.End.IsZero() {
|
||||
where = append(where, "ts_ns <= ?")
|
||||
args = append(args, f.End.UnixNano())
|
||||
}
|
||||
if f.Actor != "" {
|
||||
where = append(where, "actor = ?")
|
||||
args = append(args, f.Actor)
|
||||
}
|
||||
if f.Action != "" {
|
||||
where = append(where, "action = ?")
|
||||
args = append(args, f.Action)
|
||||
}
|
||||
if f.DS != "" {
|
||||
where = append(where, "ds = ?")
|
||||
args = append(args, f.DS)
|
||||
}
|
||||
if f.Signal != "" {
|
||||
where = append(where, "signal LIKE ?")
|
||||
args = append(args, "%"+f.Signal+"%")
|
||||
}
|
||||
limit := f.Limit
|
||||
if limit <= 0 || limit > defaultQueryLimit {
|
||||
limit = defaultQueryLimit
|
||||
}
|
||||
|
||||
q := "SELECT ts_ns, actor, actor_type, action, ds, signal, value, detail, ip, outcome, error FROM audit_log"
|
||||
if len(where) > 0 {
|
||||
q += " WHERE " + strings.Join(where, " AND ")
|
||||
}
|
||||
q += " ORDER BY ts_ns DESC LIMIT ?"
|
||||
args = append(args, limit)
|
||||
|
||||
rows, err := r.db.Query(q, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("query audit log: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []Event{}
|
||||
for rows.Next() {
|
||||
var e Event
|
||||
var tsNs int64
|
||||
if err := rows.Scan(&tsNs, &e.Actor, &e.ActorType, &e.Action,
|
||||
&e.DS, &e.Signal, &e.Value, &e.Detail, &e.IP, &e.Outcome, &e.Error); err != nil {
|
||||
return nil, fmt.Errorf("scan audit row: %w", err)
|
||||
}
|
||||
e.Time = time.Unix(0, tsNs)
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *sqliteRecorder) Close() error {
|
||||
r.once.Do(func() {
|
||||
close(r.closed)
|
||||
close(r.ch)
|
||||
})
|
||||
r.wg.Wait()
|
||||
return r.db.Close()
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package audit
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSQLiteRoundTrip(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "audit.db")
|
||||
rec, err := NewSQLite(path, slog.Default())
|
||||
if err != nil {
|
||||
t.Fatal("NewSQLite:", err)
|
||||
}
|
||||
defer rec.Close()
|
||||
|
||||
base := time.Now()
|
||||
rec.Record(Event{Time: base, Actor: "alice", ActorType: ActorUser, Action: "signal.write", DS: "epics", Signal: "PV:A", Value: "1.5"})
|
||||
rec.Record(Event{Time: base.Add(time.Second), Actor: "flow1", ActorType: ActorSystem, Action: "signal.write", DS: "epics", Signal: "PV:B", Value: "0"})
|
||||
rec.Record(Event{Time: base.Add(2 * time.Second), Actor: "bob", ActorType: ActorUser, Action: "interface.update", Detail: "panel-1", Outcome: OutcomeError, Error: "denied"})
|
||||
|
||||
// Flush the async writer.
|
||||
if err := rec.Close(); err != nil {
|
||||
t.Fatal("Close:", err)
|
||||
}
|
||||
rec, err = NewSQLite(path, slog.Default())
|
||||
if err != nil {
|
||||
t.Fatal("reopen:", err)
|
||||
}
|
||||
defer rec.Close()
|
||||
|
||||
all, err := rec.Query(Filter{})
|
||||
if err != nil {
|
||||
t.Fatal("Query:", err)
|
||||
}
|
||||
if len(all) != 3 {
|
||||
t.Fatalf("got %d events, want 3", len(all))
|
||||
}
|
||||
// Newest first.
|
||||
if all[0].Actor != "bob" {
|
||||
t.Errorf("first actor = %q, want bob", all[0].Actor)
|
||||
}
|
||||
|
||||
byActor, err := rec.Query(Filter{Actor: "alice"})
|
||||
if err != nil {
|
||||
t.Fatal("Query actor:", err)
|
||||
}
|
||||
if len(byActor) != 1 || byActor[0].Signal != "PV:A" {
|
||||
t.Errorf("actor filter = %+v, want one PV:A event", byActor)
|
||||
}
|
||||
|
||||
byAction, err := rec.Query(Filter{Action: "signal.write"})
|
||||
if err != nil {
|
||||
t.Fatal("Query action:", err)
|
||||
}
|
||||
if len(byAction) != 2 {
|
||||
t.Errorf("action filter returned %d, want 2", len(byAction))
|
||||
}
|
||||
|
||||
since, err := rec.Query(Filter{Start: base.Add(1500 * time.Millisecond)})
|
||||
if err != nil {
|
||||
t.Fatal("Query start:", err)
|
||||
}
|
||||
if len(since) != 1 || since[0].Actor != "bob" {
|
||||
t.Errorf("time filter = %+v, want one bob event", since)
|
||||
}
|
||||
}
|
||||
@@ -251,6 +251,31 @@ func (b *Broker) fanOut(ref SignalRef, sub *signalSub, rawCh <-chan datasource.V
|
||||
}
|
||||
}
|
||||
|
||||
// ReadNow performs a one-shot synchronous read of a signal's current value.
|
||||
// It starts a dedicated upstream subscription, returns the first value the data
|
||||
// source delivers, then tears the subscription down. The read is bounded by ctx
|
||||
// (callers should pass a timeout). This bypasses the shared fan-out cache because
|
||||
// not every signal of interest (e.g. arbitrary config-set targets) is otherwise
|
||||
// subscribed.
|
||||
func (b *Broker) ReadNow(ctx context.Context, ref SignalRef) (datasource.Value, error) {
|
||||
ds, ok := b.Source(ref.DS)
|
||||
if !ok {
|
||||
return datasource.Value{}, fmt.Errorf("unknown data source %q", ref.DS)
|
||||
}
|
||||
ch := make(chan datasource.Value, 1)
|
||||
cancel, err := ds.Subscribe(ctx, ref.Name, ch)
|
||||
if err != nil {
|
||||
return datasource.Value{}, fmt.Errorf("read %s/%s: %w", ref.DS, ref.Name, err)
|
||||
}
|
||||
defer cancel()
|
||||
select {
|
||||
case v := <-ch:
|
||||
return v, nil
|
||||
case <-ctx.Done():
|
||||
return datasource.Value{}, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
// ActiveSubscriptions returns the number of currently active upstream signal
|
||||
// subscriptions. Useful for diagnostics and tests.
|
||||
func (b *Broker) ActiveSubscriptions() int {
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
// BenchmarkFanOut measures the end-to-end latency and throughput of the broker
|
||||
// fan-out with varying numbers of downstream clients.
|
||||
|
||||
func BenchmarkFanOut1Client(b *testing.B) { benchFanOut(b, 1) }
|
||||
func BenchmarkFanOut1Client(b *testing.B) { benchFanOut(b, 1) }
|
||||
func BenchmarkFanOut10Clients(b *testing.B) { benchFanOut(b, 10) }
|
||||
func BenchmarkFanOut20Clients(b *testing.B) { benchFanOut(b, 20) }
|
||||
func BenchmarkFanOut100Clients(b *testing.B) { benchFanOut(b, 100) }
|
||||
|
||||
@@ -141,9 +141,9 @@ func TestStress_RapidSubscribeUnsubscribe(t *testing.T) {
|
||||
}
|
||||
|
||||
const (
|
||||
nSignals = 20
|
||||
nSignals = 20
|
||||
nGoroutines = 50
|
||||
duration = 2 * time.Second
|
||||
duration = 2 * time.Second
|
||||
)
|
||||
|
||||
brk, cancel := newBrokerN(t, nSignals)
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package broker_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/uopi/uopi/internal/broker"
|
||||
)
|
||||
|
||||
// TestDataSourcesAndSource covers the registry accessors.
|
||||
func TestDataSourcesAndSource(t *testing.T) {
|
||||
b, cancel := newBroker(t)
|
||||
defer cancel()
|
||||
|
||||
all := b.DataSources()
|
||||
if len(all) != 1 || all[0].Name() != "stub" {
|
||||
t.Fatalf("DataSources = %v, want one 'stub'", all)
|
||||
}
|
||||
if ds, ok := b.Source("stub"); !ok || ds.Name() != "stub" {
|
||||
t.Errorf("Source(stub) = %v,%v want stub,true", ds, ok)
|
||||
}
|
||||
if _, ok := b.Source("nope"); ok {
|
||||
t.Error("Source(nope): want ok=false")
|
||||
}
|
||||
}
|
||||
|
||||
// TestReadNow covers the one-shot read happy path plus the unknown-DS and
|
||||
// context-timeout error branches.
|
||||
func TestReadNow(t *testing.T) {
|
||||
b, cancel := newBroker(t)
|
||||
defer cancel()
|
||||
|
||||
ctx, c := context.WithTimeout(context.Background(), time.Second)
|
||||
defer c()
|
||||
v, err := b.ReadNow(ctx, broker.SignalRef{DS: "stub", Name: "sine_1hz"})
|
||||
if err != nil {
|
||||
t.Fatalf("ReadNow: %v", err)
|
||||
}
|
||||
if v.Timestamp.IsZero() {
|
||||
t.Error("ReadNow returned a zero-timestamp value")
|
||||
}
|
||||
|
||||
// Unknown data source.
|
||||
if _, err := b.ReadNow(ctx, broker.SignalRef{DS: "ghost", Name: "x"}); err == nil {
|
||||
t.Error("ReadNow(unknown ds): want error")
|
||||
}
|
||||
}
|
||||
+202
-25
@@ -12,22 +12,46 @@ import (
|
||||
type Config struct {
|
||||
Server ServerConfig `toml:"server"`
|
||||
Datasource DatasourceConfig `toml:"datasource"`
|
||||
Audit AuditConfig `toml:"audit"`
|
||||
UI UIConfig `toml:"ui"`
|
||||
// Groups are named sets of users, referenced by panel sharing rules.
|
||||
Groups []GroupDef `toml:"groups"`
|
||||
}
|
||||
|
||||
// GroupDef is a named set of users defined as [[groups]] in the config file.
|
||||
type GroupDef struct {
|
||||
Name string `toml:"name"`
|
||||
Members []string `toml:"members"`
|
||||
// UIConfig carries client-side presentation defaults sent to the browser at
|
||||
// startup (via /api/v1/me).
|
||||
type UIConfig struct {
|
||||
// DefaultZoom is the base UI scale multiplier applied when a browser has no
|
||||
// per-machine zoom override saved. Useful to enlarge the UI by default on
|
||||
// HiDPI screens whose OS scaling is left at 100% (where the browser reports
|
||||
// devicePixelRatio=1 and the UI would otherwise render small). The in-app
|
||||
// A+/A− control still overrides it locally. 0 or unset means 1.0 (no scaling).
|
||||
DefaultZoom float64 `toml:"default_zoom"`
|
||||
}
|
||||
|
||||
// BlacklistEntry downgrades a specific user's global access level. Levels:
|
||||
// "readonly" (no writes) or "noaccess" (denied). Users not listed are trusted
|
||||
// with full access.
|
||||
type BlacklistEntry struct {
|
||||
User string `toml:"user"`
|
||||
Level string `toml:"level"`
|
||||
// AuditConfig controls the audit trail. When enabled, every user and automated
|
||||
// action that could affect the controlled system (signal writes, control-logic
|
||||
// changes) is recorded to a SQLite database for later review by audit staff. Who
|
||||
// may *view* the log is governed by the auditor role (see GroupDef).
|
||||
type AuditConfig struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
DBPath string `toml:"db_path"` // SQLite file; default {storage_dir}/audit.db
|
||||
}
|
||||
|
||||
// GroupDef defines one access group as [[groups]] in the config file. Each group
|
||||
// has an optional parent (for nesting) and lists its members by role. Roles form
|
||||
// a cumulative ladder: viewer < operator < logiceditor < auditor < admin. A user
|
||||
// listed in several role buckets keeps the highest. The built-in "public" group
|
||||
// (every user is an implicit viewer member) may be configured by name to raise
|
||||
// specific users globally.
|
||||
type GroupDef struct {
|
||||
Name string `toml:"name"`
|
||||
Parent string `toml:"parent"`
|
||||
Viewers []string `toml:"viewers"`
|
||||
Operators []string `toml:"operators"`
|
||||
LogicEditors []string `toml:"logiceditors"`
|
||||
Auditors []string `toml:"auditors"`
|
||||
Admins []string `toml:"admins"`
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
@@ -45,18 +69,124 @@ type ServerConfig struct {
|
||||
|
||||
// DefaultUser is the identity used when the trusted user header is absent or
|
||||
// empty (e.g. unproxied/dev/LAN deployments). Empty leaves the user anonymous.
|
||||
//
|
||||
// Access levels (write/readonly), logic-edit, audit-view and admin rights are
|
||||
// all granted via group roles (see GroupDef / [[groups]]). A config with no
|
||||
// group roles at all is treated as fully open (everyone is admin), so an
|
||||
// unconfigured deployment behaves like trusted LAN. Once the admin pane writes
|
||||
// {storage_dir}/access.json, that file — not this config — is the source of
|
||||
// truth for access.
|
||||
DefaultUser string `toml:"default_user"`
|
||||
|
||||
// Blacklist downgrades specific users' global access level. Everyone not
|
||||
// listed is trusted with full write access.
|
||||
Blacklist []BlacklistEntry `toml:"blacklist"`
|
||||
// Kerberos enables native SPNEGO authentication (see KerberosConfig).
|
||||
Kerberos KerberosConfig `toml:"kerberos"`
|
||||
|
||||
// LogicEditors optionally restricts who may add or edit panel logic (the
|
||||
// <logic> block of interfaces) and server-side control logic. Entries are
|
||||
// usernames or group names. When empty, no restriction applies (any user
|
||||
// with write access may edit logic). Anonymous/trusted-LAN callers are
|
||||
// always permitted.
|
||||
LogicEditors []string `toml:"logic_editors"`
|
||||
// BasicAuth enables built-in HTTP Basic authentication validated against PAM
|
||||
// (see BasicAuthConfig). Mutually exclusive with Kerberos.
|
||||
BasicAuth BasicAuthConfig `toml:"basic_auth"`
|
||||
|
||||
// LDAP enables built-in HTTP Basic authentication validated against an LDAP
|
||||
// directory (see LDAPConfig). Pure-Go alternative to BasicAuth/PAM that keeps
|
||||
// the static binary. Mutually exclusive with Kerberos and BasicAuth.
|
||||
LDAP LDAPConfig `toml:"ldap"`
|
||||
|
||||
// TLS enables built-in HTTPS (see TLSConfig). Strongly recommended whenever
|
||||
// BasicAuth is enabled, since Basic credentials are sent on every request.
|
||||
TLS TLSConfig `toml:"tls"`
|
||||
}
|
||||
|
||||
// KerberosConfig enables native SPNEGO/Kerberos ("Negotiate") authentication so
|
||||
// uopi identifies users directly from their Kerberos ticket, without depending on
|
||||
// a separate auth proxy to set TrustedUserHeader. This is the recommended setup
|
||||
// for browsers like Firefox that do not silently fall back to a proxy default:
|
||||
// uopi answers API requests with a 401 WWW-Authenticate: Negotiate challenge, the
|
||||
// browser performs the SPNEGO handshake, and uopi resolves the user from the
|
||||
// validated ticket (short principal name, realm stripped). That username feeds
|
||||
// the same access pipeline as TrustedUserHeader.
|
||||
//
|
||||
// Browsers must be told to perform SPNEGO for this server's origin (Firefox:
|
||||
// network.negotiate-auth.trusted-uris; Chrome/Edge: AuthServerAllowlist policy or
|
||||
// OS integrated auth). When enabled, any inbound TrustedUserHeader value is
|
||||
// ignored in favour of the Kerberos identity to prevent spoofing.
|
||||
type KerberosConfig struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
// Keytab is the path to the service keytab holding the HTTP service
|
||||
// principal's long-term key (e.g. HTTP/host.example.com@REALM). Required when
|
||||
// Enabled.
|
||||
Keytab string `toml:"keytab"`
|
||||
// ServicePrincipal optionally selects which principal in the keytab to accept
|
||||
// tickets for (e.g. "HTTP/host.example.com"). Empty accepts the keytab's
|
||||
// entries by default.
|
||||
ServicePrincipal string `toml:"service_principal"`
|
||||
}
|
||||
|
||||
// BasicAuthConfig enables uopi's built-in HTTP Basic authentication: uopi
|
||||
// answers API requests with 401 WWW-Authenticate: Basic, the browser prompts for
|
||||
// a username/password, and uopi validates them through the host PAM stack
|
||||
// (/etc/pam.d/<PAMService>). On hosts that are SSSD/LDAP clients this reuses the
|
||||
// users' normal login credentials with no directory configuration in uopi. The
|
||||
// validated username feeds the same access pipeline as TrustedUserHeader.
|
||||
//
|
||||
// PAM support requires a cgo build with the `pam` tag (make backend-pam); the
|
||||
// default fully-static binary cannot validate and will refuse to start with
|
||||
// BasicAuth enabled. Because Basic credentials travel on every request, enable
|
||||
// TLS (see TLSConfig) unless uopi sits on a fully isolated network.
|
||||
type BasicAuthConfig struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
// PAMService is the PAM service name under /etc/pam.d/ to authenticate
|
||||
// against. Empty defaults to "uopi".
|
||||
PAMService string `toml:"pam_service"`
|
||||
}
|
||||
|
||||
// LDAPConfig enables uopi's built-in HTTP Basic authentication validated against
|
||||
// an LDAP directory via the "search then bind" pattern — the same flow an
|
||||
// SSSD/LDAP client uses. Because it speaks LDAP over the wire with no cgo, it
|
||||
// works in the default fully-static binary (unlike the PAM backend), while still
|
||||
// authenticating users against the same directory the host logs in with. The
|
||||
// validated username feeds the same access pipeline as TrustedUserHeader. Enable
|
||||
// TLS (ldaps:// or StartTLS) so passwords are not sent in clear text.
|
||||
//
|
||||
// Defaults mirror SSSD: empty UserAttr → "uid", empty UserObjectClass →
|
||||
// "posixAccount", empty BindDN → anonymous search. Mutually exclusive with
|
||||
// Kerberos and BasicAuth.
|
||||
type LDAPConfig struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
// URIs are the directory endpoints (SSSD ldap_uri), tried in order, e.g.
|
||||
// "ldaps://ldap.example.com". Required when Enabled.
|
||||
URIs []string `toml:"uri"`
|
||||
// SearchBase is the subtree user entries live under (SSSD ldap_search_base).
|
||||
// Required when Enabled.
|
||||
SearchBase string `toml:"search_base"`
|
||||
// UserAttr is the attribute matched against the login name. Empty → "uid".
|
||||
UserAttr string `toml:"user_attr"`
|
||||
// UserObjectClass restricts the search. Empty → "posixAccount".
|
||||
UserObjectClass string `toml:"user_object_class"`
|
||||
// BindDN / BindPassword optionally authenticate the search (service account).
|
||||
// Empty BindDN performs an anonymous search.
|
||||
BindDN string `toml:"bind_dn"`
|
||||
BindPassword string `toml:"bind_password"`
|
||||
// StartTLS upgrades an ldap:// connection to TLS before binding. Ignored for
|
||||
// ldaps://.
|
||||
StartTLS bool `toml:"start_tls"`
|
||||
// CACert is an optional PEM CA bundle to trust (private CA).
|
||||
CACert string `toml:"ca_cert"`
|
||||
// InsecureSkipVerify disables TLS certificate verification. Testing only.
|
||||
InsecureSkipVerify bool `toml:"insecure_skip_verify"`
|
||||
}
|
||||
|
||||
// TLSConfig enables built-in HTTPS so uopi can terminate TLS itself (e.g. for
|
||||
// Basic auth) without a reverse proxy. When Enabled, Cert and Key are required.
|
||||
type TLSConfig struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
// Cert and Key are paths to the PEM certificate and private key.
|
||||
Cert string `toml:"cert"`
|
||||
Key string `toml:"key"`
|
||||
// RedirectFrom, when set (e.g. ":8080"), starts an additional plain-HTTP
|
||||
// listener on that address that 301-redirects every request to the HTTPS
|
||||
// service. Without it, a browser that connects with http:// gets the opaque
|
||||
// "client sent an HTTP request to an HTTPS server" error instead of being
|
||||
// upgraded. Empty disables the redirector.
|
||||
RedirectFrom string `toml:"redirect_from"`
|
||||
}
|
||||
|
||||
type DatasourceConfig struct {
|
||||
@@ -71,10 +201,10 @@ type StubConfig struct {
|
||||
}
|
||||
|
||||
type EPICSConfig struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
CAAddrList string `toml:"ca_addr_list"`
|
||||
ArchiveURL string `toml:"archive_url"`
|
||||
ChannelFinderURL string `toml:"channel_finder_url"`
|
||||
Enabled bool `toml:"enabled"`
|
||||
CAAddrList string `toml:"ca_addr_list"`
|
||||
ArchiveURL string `toml:"archive_url"`
|
||||
ChannelFinderURL string `toml:"channel_finder_url"`
|
||||
AutoSyncFilter string `toml:"auto_sync_filter"`
|
||||
AutoSyncFromArchiver bool `toml:"auto_sync_from_archiver"`
|
||||
PVNames []string `toml:"pv_names"`
|
||||
@@ -138,8 +268,50 @@ func applyEnv(cfg *Config) {
|
||||
if v := env("UOPI_SERVER_DEFAULT_USER"); v != "" {
|
||||
cfg.Server.DefaultUser = v
|
||||
}
|
||||
if v := env("UOPI_SERVER_LOGIC_EDITORS"); v != "" {
|
||||
cfg.Server.LogicEditors = strings.Fields(v)
|
||||
if v := env("UOPI_SERVER_KERBEROS_ENABLED"); v != "" {
|
||||
cfg.Server.Kerberos.Enabled = (v == "true" || v == "YES")
|
||||
}
|
||||
if v := env("UOPI_SERVER_KERBEROS_KEYTAB"); v != "" {
|
||||
cfg.Server.Kerberos.Keytab = v
|
||||
}
|
||||
if v := env("UOPI_SERVER_KERBEROS_SERVICE_PRINCIPAL"); v != "" {
|
||||
cfg.Server.Kerberos.ServicePrincipal = v
|
||||
}
|
||||
if v := env("UOPI_SERVER_BASIC_AUTH_ENABLED"); v != "" {
|
||||
cfg.Server.BasicAuth.Enabled = (v == "true" || v == "YES")
|
||||
}
|
||||
if v := env("UOPI_SERVER_BASIC_AUTH_PAM_SERVICE"); v != "" {
|
||||
cfg.Server.BasicAuth.PAMService = v
|
||||
}
|
||||
if v := env("UOPI_SERVER_TLS_ENABLED"); v != "" {
|
||||
cfg.Server.TLS.Enabled = (v == "true" || v == "YES")
|
||||
}
|
||||
if v := env("UOPI_SERVER_TLS_CERT"); v != "" {
|
||||
cfg.Server.TLS.Cert = v
|
||||
}
|
||||
if v := env("UOPI_SERVER_TLS_KEY"); v != "" {
|
||||
cfg.Server.TLS.Key = v
|
||||
}
|
||||
if v := env("UOPI_SERVER_LDAP_ENABLED"); v != "" {
|
||||
cfg.Server.LDAP.Enabled = (v == "true" || v == "YES")
|
||||
}
|
||||
if v := env("UOPI_SERVER_LDAP_URI"); v != "" {
|
||||
cfg.Server.LDAP.URIs = strings.Fields(v)
|
||||
}
|
||||
if v := env("UOPI_SERVER_LDAP_SEARCH_BASE"); v != "" {
|
||||
cfg.Server.LDAP.SearchBase = v
|
||||
}
|
||||
if v := env("UOPI_SERVER_LDAP_BIND_DN"); v != "" {
|
||||
cfg.Server.LDAP.BindDN = v
|
||||
}
|
||||
if v := env("UOPI_SERVER_LDAP_BIND_PASSWORD"); v != "" {
|
||||
cfg.Server.LDAP.BindPassword = v
|
||||
}
|
||||
if v := env("UOPI_AUDIT_ENABLED"); v != "" {
|
||||
cfg.Audit.Enabled = (v == "true" || v == "YES")
|
||||
}
|
||||
if v := env("UOPI_AUDIT_DB_PATH"); v != "" {
|
||||
cfg.Audit.DBPath = v
|
||||
}
|
||||
if v := env("UOPI_EPICS_CA_ADDR_LIST"); v != "" {
|
||||
cfg.Datasource.EPICS.CAAddrList = v
|
||||
@@ -159,6 +331,11 @@ func applyEnv(cfg *Config) {
|
||||
if v := env("EPICS_PVA_ADDR_LIST"); v != "" {
|
||||
cfg.Datasource.PVA.AddrList = strings.Fields(v)
|
||||
}
|
||||
if v := env("UOPI_UI_DEFAULT_ZOOM"); v != "" {
|
||||
if z, err := strconv.ParseFloat(v, 64); err == nil {
|
||||
cfg.UI.DefaultZoom = z
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func env(key string) string {
|
||||
|
||||
@@ -112,6 +112,88 @@ func TestEnvOverrides(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvOverridesAuthTLSAndMisc(t *testing.T) {
|
||||
t.Setenv("UOPI_SERVER_MAX_UPDATE_RATE_HZ", "25.5")
|
||||
t.Setenv("UOPI_SERVER_TRUSTED_USER_HEADER", "X-Forwarded-User")
|
||||
t.Setenv("UOPI_SERVER_DEFAULT_USER", "svc")
|
||||
t.Setenv("UOPI_SERVER_KERBEROS_ENABLED", "true")
|
||||
t.Setenv("UOPI_SERVER_KERBEROS_KEYTAB", "/etc/krb.keytab")
|
||||
t.Setenv("UOPI_SERVER_KERBEROS_SERVICE_PRINCIPAL", "HTTP/host")
|
||||
t.Setenv("UOPI_SERVER_BASIC_AUTH_ENABLED", "YES")
|
||||
t.Setenv("UOPI_SERVER_BASIC_AUTH_PAM_SERVICE", "login")
|
||||
t.Setenv("UOPI_SERVER_TLS_ENABLED", "true")
|
||||
t.Setenv("UOPI_SERVER_TLS_CERT", "/c.pem")
|
||||
t.Setenv("UOPI_SERVER_TLS_KEY", "/k.pem")
|
||||
t.Setenv("UOPI_SERVER_LDAP_ENABLED", "true")
|
||||
t.Setenv("UOPI_SERVER_LDAP_URI", "ldaps://a ldaps://b")
|
||||
t.Setenv("UOPI_SERVER_LDAP_SEARCH_BASE", "dc=x,dc=y")
|
||||
t.Setenv("UOPI_SERVER_LDAP_BIND_DN", "cn=svc")
|
||||
t.Setenv("UOPI_SERVER_LDAP_BIND_PASSWORD", "secret")
|
||||
t.Setenv("UOPI_AUDIT_ENABLED", "true")
|
||||
t.Setenv("UOPI_AUDIT_DB_PATH", "/audit.db")
|
||||
t.Setenv("UOPI_EPICS_AUTO_SYNC_FILTER", "area=SR")
|
||||
t.Setenv("UOPI_EPICS_AUTO_SYNC_FROM_ARCHIVER", "true")
|
||||
t.Setenv("EPICS_PVA_ADDR_LIST", "10.1.1.1 10.1.1.2")
|
||||
t.Setenv("UOPI_UI_DEFAULT_ZOOM", "1.5")
|
||||
|
||||
cfg, err := config.Load("")
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
|
||||
if cfg.Server.MaxUpdateRateHz != 25.5 {
|
||||
t.Errorf("MaxUpdateRateHz = %v, want 25.5", cfg.Server.MaxUpdateRateHz)
|
||||
}
|
||||
if cfg.Server.TrustedUserHeader != "X-Forwarded-User" {
|
||||
t.Errorf("TrustedUserHeader = %q", cfg.Server.TrustedUserHeader)
|
||||
}
|
||||
if cfg.Server.DefaultUser != "svc" {
|
||||
t.Errorf("DefaultUser = %q", cfg.Server.DefaultUser)
|
||||
}
|
||||
if !cfg.Server.Kerberos.Enabled || cfg.Server.Kerberos.Keytab != "/etc/krb.keytab" || cfg.Server.Kerberos.ServicePrincipal != "HTTP/host" {
|
||||
t.Errorf("Kerberos = %+v", cfg.Server.Kerberos)
|
||||
}
|
||||
if !cfg.Server.BasicAuth.Enabled || cfg.Server.BasicAuth.PAMService != "login" {
|
||||
t.Errorf("BasicAuth = %+v", cfg.Server.BasicAuth)
|
||||
}
|
||||
if !cfg.Server.TLS.Enabled || cfg.Server.TLS.Cert != "/c.pem" || cfg.Server.TLS.Key != "/k.pem" {
|
||||
t.Errorf("TLS = %+v", cfg.Server.TLS)
|
||||
}
|
||||
if !cfg.Server.LDAP.Enabled || len(cfg.Server.LDAP.URIs) != 2 ||
|
||||
cfg.Server.LDAP.SearchBase != "dc=x,dc=y" || cfg.Server.LDAP.BindDN != "cn=svc" ||
|
||||
cfg.Server.LDAP.BindPassword != "secret" {
|
||||
t.Errorf("LDAP = %+v", cfg.Server.LDAP)
|
||||
}
|
||||
if !cfg.Audit.Enabled || cfg.Audit.DBPath != "/audit.db" {
|
||||
t.Errorf("Audit = %+v", cfg.Audit)
|
||||
}
|
||||
if cfg.Datasource.EPICS.AutoSyncFilter != "area=SR" || !cfg.Datasource.EPICS.AutoSyncFromArchiver {
|
||||
t.Errorf("EPICS sync = %+v", cfg.Datasource.EPICS)
|
||||
}
|
||||
if len(cfg.Datasource.PVA.AddrList) != 2 {
|
||||
t.Errorf("PVA AddrList = %+v", cfg.Datasource.PVA.AddrList)
|
||||
}
|
||||
if cfg.UI.DefaultZoom != 1.5 {
|
||||
t.Errorf("UI.DefaultZoom = %v, want 1.5", cfg.UI.DefaultZoom)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvInvalidNumbersIgnored(t *testing.T) {
|
||||
t.Setenv("UOPI_SERVER_MAX_UPDATE_RATE_HZ", "not-a-number")
|
||||
t.Setenv("UOPI_UI_DEFAULT_ZOOM", "xyz")
|
||||
cfg, err := config.Load("")
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
// Invalid floats are ignored, leaving the defaults intact.
|
||||
if cfg.Server.MaxUpdateRateHz != config.Default().Server.MaxUpdateRateHz {
|
||||
t.Errorf("invalid MaxUpdateRateHz should be ignored, got %v", cfg.Server.MaxUpdateRateHz)
|
||||
}
|
||||
if cfg.UI.DefaultZoom != config.Default().UI.DefaultZoom {
|
||||
t.Errorf("invalid DefaultZoom should be ignored, got %v", cfg.UI.DefaultZoom)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnvTrimsWhitespace(t *testing.T) {
|
||||
t.Setenv("UOPI_SERVER_LISTEN", " :8888 ")
|
||||
cfg, err := config.Load("")
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package confmgr
|
||||
|
||||
// WriteFunc writes a resolved value to a target signal. The API layer supplies
|
||||
// a closure backed by the broker/datasource so confmgr stays decoupled from the
|
||||
// transport.
|
||||
type WriteFunc func(ds, signal string, value any) error
|
||||
|
||||
// ApplyEntry records the outcome of writing one parameter.
|
||||
type ApplyEntry struct {
|
||||
Key string `json:"key"`
|
||||
DS string `json:"ds"`
|
||||
Signal string `json:"signal"`
|
||||
Value any `json:"value,omitempty"`
|
||||
OK bool `json:"ok"`
|
||||
Skipped bool `json:"skipped,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// ApplyResult summarises an apply run.
|
||||
type ApplyResult struct {
|
||||
InstanceID string `json:"instanceId"`
|
||||
SetID string `json:"setId"`
|
||||
Entries []ApplyEntry `json:"entries"`
|
||||
Applied int `json:"applied"`
|
||||
Failed int `json:"failed"`
|
||||
Skipped int `json:"skipped"`
|
||||
}
|
||||
|
||||
// Apply writes every resolvable parameter value of an instance to its target
|
||||
// signal via write. Optional parameters with no value and no default are
|
||||
// skipped. Individual write failures are recorded per-entry rather than
|
||||
// aborting the whole apply, so a partial apply is reported faithfully.
|
||||
func Apply(set ConfigSet, inst ConfigInstance, write WriteFunc) ApplyResult {
|
||||
res := ApplyResult{InstanceID: inst.ID, SetID: set.ID, Entries: make([]ApplyEntry, 0, len(set.Parameters))}
|
||||
for _, p := range set.Parameters {
|
||||
e := ApplyEntry{Key: p.Key, DS: p.DS, Signal: p.Signal}
|
||||
v, ok := inst.Resolve(p)
|
||||
if !ok {
|
||||
e.Skipped = true
|
||||
res.Skipped++
|
||||
res.Entries = append(res.Entries, e)
|
||||
continue
|
||||
}
|
||||
v = p.normalize(v)
|
||||
e.Value = v
|
||||
if err := write(p.DS, p.Signal, v); err != nil {
|
||||
e.Error = err.Error()
|
||||
res.Failed++
|
||||
} else {
|
||||
e.OK = true
|
||||
res.Applied++
|
||||
}
|
||||
res.Entries = append(res.Entries, e)
|
||||
}
|
||||
return res
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package confmgr
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestApplyWritesValues(t *testing.T) {
|
||||
set := ConfigSet{
|
||||
ID: "set1",
|
||||
Name: "s",
|
||||
Parameters: []Parameter{
|
||||
{Key: "v", DS: "epics", Signal: "PSU:V", Type: TypeFloat, Default: 12.0},
|
||||
{Key: "en", DS: "epics", Signal: "PSU:EN", Type: TypeBool},
|
||||
{Key: "opt", DS: "epics", Signal: "PSU:OPT", Type: TypeFloat}, // no value, no default → skipped
|
||||
},
|
||||
}
|
||||
inst := ConfigInstance{ID: "i1", SetID: "set1", Values: map[string]any{"v": 24.0, "en": true}}
|
||||
|
||||
type write struct {
|
||||
ds, sig string
|
||||
val any
|
||||
}
|
||||
var writes []write
|
||||
res := Apply(set, inst, func(ds, signal string, value any) error {
|
||||
writes = append(writes, write{ds, signal, value})
|
||||
return nil
|
||||
})
|
||||
|
||||
if res.Applied != 2 || res.Skipped != 1 || res.Failed != 0 {
|
||||
t.Fatalf("summary: applied=%d skipped=%d failed=%d", res.Applied, res.Skipped, res.Failed)
|
||||
}
|
||||
if len(writes) != 2 {
|
||||
t.Fatalf("want 2 writes, got %d", len(writes))
|
||||
}
|
||||
if writes[0].sig != "PSU:V" || writes[0].val != 24.0 {
|
||||
t.Errorf("first write: %+v", writes[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyUsesDefaultWhenNoValue(t *testing.T) {
|
||||
set := ConfigSet{
|
||||
ID: "set1", Name: "s",
|
||||
Parameters: []Parameter{{Key: "v", DS: "d", Signal: "S", Type: TypeFloat, Default: 7.0}},
|
||||
}
|
||||
inst := ConfigInstance{ID: "i", SetID: "set1", Values: map[string]any{}}
|
||||
var got any
|
||||
res := Apply(set, inst, func(_, _ string, value any) error { got = value; return nil })
|
||||
if res.Applied != 1 || got != 7.0 {
|
||||
t.Errorf("want default 7.0 applied, got %v (applied=%d)", got, res.Applied)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyArrayNormalizes(t *testing.T) {
|
||||
set := ConfigSet{ID: "s", Name: "s", Parameters: []Parameter{
|
||||
{Key: "wf", DS: "d", Signal: "WF", Type: TypeFloatArray},
|
||||
}}
|
||||
// JSON decoding yields []any of float64 — apply must hand the datasource a []float64.
|
||||
inst := ConfigInstance{ID: "i", SetID: "s", Values: map[string]any{"wf": []any{1.0, 2.0, 3.0}}}
|
||||
var got any
|
||||
res := Apply(set, inst, func(_, _ string, value any) error { got = value; return nil })
|
||||
if res.Applied != 1 {
|
||||
t.Fatalf("applied=%d", res.Applied)
|
||||
}
|
||||
arr, ok := got.([]float64)
|
||||
if !ok || len(arr) != 3 || arr[0] != 1 || arr[2] != 3 {
|
||||
t.Fatalf("want []float64{1,2,3}, got %T %v", got, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArrayValidation(t *testing.T) {
|
||||
lo := 0.0
|
||||
p := Parameter{Key: "wf", DS: "d", Signal: "S", Type: TypeFloatArray, Min: &lo}
|
||||
if err := p.checkValue([]any{1.0, 2.0}); err != nil {
|
||||
t.Fatalf("valid array rejected: %v", err)
|
||||
}
|
||||
if err := p.checkValue([]any{1.0, -5.0}); err == nil {
|
||||
t.Fatal("expected out-of-range element to be rejected")
|
||||
}
|
||||
if err := p.checkValue("notarray"); err == nil {
|
||||
t.Fatal("expected non-array value to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyRecordsFailures(t *testing.T) {
|
||||
set := ConfigSet{
|
||||
ID: "set1", Name: "s",
|
||||
Parameters: []Parameter{
|
||||
{Key: "a", DS: "d", Signal: "A", Type: TypeFloat, Default: 1.0},
|
||||
{Key: "b", DS: "d", Signal: "B", Type: TypeFloat, Default: 2.0},
|
||||
},
|
||||
}
|
||||
inst := ConfigInstance{ID: "i", SetID: "set1", Values: map[string]any{}}
|
||||
res := Apply(set, inst, func(_, signal string, _ any) error {
|
||||
if signal == "B" {
|
||||
return errors.New("write rejected")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if res.Applied != 1 || res.Failed != 1 {
|
||||
t.Fatalf("want applied=1 failed=1, got applied=%d failed=%d", res.Applied, res.Failed)
|
||||
}
|
||||
for _, e := range res.Entries {
|
||||
if e.Key == "b" && (e.OK || e.Error == "") {
|
||||
t.Errorf("entry b should record failure: %+v", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package confmgr
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestCoerceSnapshot covers the alternate and error branches of coerceSnapshot
|
||||
// that the happy-path Snapshot tests do not reach.
|
||||
func TestCoerceSnapshot(t *testing.T) {
|
||||
enum := Parameter{Type: TypeEnum, EnumValues: []string{"off", "low", "high"}}
|
||||
cases := []struct {
|
||||
name string
|
||||
p Parameter
|
||||
raw any
|
||||
want any
|
||||
wantErr bool
|
||||
}{
|
||||
{"float err", Parameter{Type: TypeFloat}, struct{}{}, nil, true},
|
||||
{"int round", Parameter{Type: TypeInt}, 2.6, int64(3), false},
|
||||
{"int err", Parameter{Type: TypeInt}, struct{}{}, nil, true},
|
||||
{"bool from string", Parameter{Type: TypeBool}, "true", true, false},
|
||||
{"bool bad string", Parameter{Type: TypeBool}, "maybe", nil, true},
|
||||
{"bool from numeric", Parameter{Type: TypeBool}, 0.0, false, false},
|
||||
{"bool unconvertible", Parameter{Type: TypeBool}, struct{}{}, nil, true},
|
||||
{"string passthrough", Parameter{Type: TypeString}, "x", "x", false},
|
||||
{"string from numeric", Parameter{Type: TypeString}, 42.0, "42", false},
|
||||
{"enum string in range", enum, "low", "low", false},
|
||||
{"enum string out of range", enum, "nope", nil, true},
|
||||
{"enum index", enum, int64(2), "high", false},
|
||||
{"enum index out of range", enum, 9.0, nil, true},
|
||||
{"enum non-numeric", enum, struct{}{}, nil, true},
|
||||
{"array native", Parameter{Type: TypeFloatArray}, []float64{1, 2}, []float64{1, 2}, false},
|
||||
{"array err", Parameter{Type: TypeFloatArray}, 1.0, nil, true},
|
||||
{"default passthrough", Parameter{Type: ParamType("weird")}, "asis", "asis", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := tc.p.coerceSnapshot(tc.raw)
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("coerceSnapshot(%v): want error", tc.raw)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("coerceSnapshot(%v): %v", tc.raw, err)
|
||||
}
|
||||
if !reflect.DeepEqual(got, tc.want) {
|
||||
t.Errorf("coerceSnapshot(%v) = %v (%T), want %v (%T)", tc.raw, got, got, tc.want, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSnapshotCoerceFailureRecorded ensures a coercion failure (vs read failure)
|
||||
// is counted in res.Failed and kept out of Values.
|
||||
func TestSnapshotCoerceFailureRecorded(t *testing.T) {
|
||||
set := ConfigSet{
|
||||
ID: "s",
|
||||
Name: "s",
|
||||
Parameters: []Parameter{
|
||||
{Key: "n", DS: "d", Signal: "N", Type: TypeInt},
|
||||
},
|
||||
}
|
||||
res := Snapshot(set, func(_, _ string) (any, error) {
|
||||
return "not-a-number", nil // reads fine, fails coercion
|
||||
})
|
||||
if res.Captured != 0 || res.Failed != 1 {
|
||||
t.Fatalf("summary: captured=%d failed=%d", res.Captured, res.Failed)
|
||||
}
|
||||
if _, ok := res.Values["n"]; ok {
|
||||
t.Error("coercion-failed parameter must not appear in values")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
package confmgr
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"cuelang.org/go/cue"
|
||||
"cuelang.org/go/cue/cuecontext"
|
||||
cueerrors "cuelang.org/go/cue/errors"
|
||||
)
|
||||
|
||||
// ConfigRule is a CUE-based validation/transformation rule bound to a config
|
||||
// set. Its Source is CUE describing constraints and/or derivations over the
|
||||
// instance's parameter values. Regular CUE fields whose key matches a set
|
||||
// parameter are unified with the instance value; constraints that fail produce
|
||||
// violations, and concrete fields that differ from the instance value are
|
||||
// reported (and persisted) as transformations. Hidden fields (_x) and
|
||||
// definitions (#X) are available for helpers and are excluded from both
|
||||
// concreteness checks and transformation output. Rules are versioned git-style,
|
||||
// exactly like sets and instances.
|
||||
type ConfigRule struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
SetID string `json:"setId"`
|
||||
Description string `json:"description,omitempty"`
|
||||
// Enabled gates whether the rule runs when instances of the bound set are
|
||||
// saved/applied. A nil pointer (legacy rules created before the flag) is
|
||||
// treated as enabled, so existing rules keep their behaviour.
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
Version int `json:"version"`
|
||||
Tag string `json:"tag,omitempty"`
|
||||
Owner string `json:"owner,omitempty"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
// IsEnabled reports whether the rule participates in instance evaluation. A nil
|
||||
// Enabled flag (legacy rules) is treated as enabled.
|
||||
func (r ConfigRule) IsEnabled() bool { return r.Enabled == nil || *r.Enabled }
|
||||
|
||||
// RuleViolation is a single constraint failure from evaluating a rule.
|
||||
type RuleViolation struct {
|
||||
Rule string `json:"rule,omitempty"` // rule ID that produced it (aggregate runs)
|
||||
Path string `json:"path,omitempty"` // dotted parameter path, when known
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// RuleResult is the outcome of evaluating one or more rules against a set of
|
||||
// instance values. OK is true only when there is no compile error and no
|
||||
// violation. Transformed holds the parameter values the rule(s) derived or
|
||||
// overrode (keys are parameter keys; values are the new concrete values).
|
||||
type RuleResult struct {
|
||||
OK bool `json:"ok"`
|
||||
Violations []RuleViolation `json:"violations,omitempty"`
|
||||
Transformed map[string]any `json:"transformed,omitempty"`
|
||||
CompileError string `json:"compileError,omitempty"`
|
||||
}
|
||||
|
||||
// RuleError wraps a failing RuleResult so it can flow through the store's
|
||||
// error-returning API while preserving the structured violations.
|
||||
type RuleError struct {
|
||||
Result RuleResult
|
||||
}
|
||||
|
||||
func (e *RuleError) Error() string {
|
||||
if e.Result.CompileError != "" {
|
||||
return "rule compile error: " + e.Result.CompileError
|
||||
}
|
||||
msgs := make([]string, 0, len(e.Result.Violations))
|
||||
for _, v := range e.Result.Violations {
|
||||
if v.Path != "" {
|
||||
msgs = append(msgs, v.Path+": "+v.Message)
|
||||
} else {
|
||||
msgs = append(msgs, v.Message)
|
||||
}
|
||||
}
|
||||
if len(msgs) == 0 {
|
||||
return "rule validation failed"
|
||||
}
|
||||
return "rule validation failed: " + strings.Join(msgs, "; ")
|
||||
}
|
||||
|
||||
// Validate compiles the rule's CUE source and checks basic metadata. It does
|
||||
// not require concrete values — only that the source is syntactically and
|
||||
// structurally well-formed CUE.
|
||||
func (r ConfigRule) Validate() error {
|
||||
if strings.TrimSpace(r.Name) == "" {
|
||||
return fmt.Errorf("rule name must not be empty")
|
||||
}
|
||||
if strings.TrimSpace(r.SetID) == "" {
|
||||
return fmt.Errorf("rule must reference a config set")
|
||||
}
|
||||
ctx := cuecontext.New()
|
||||
v := ctx.CompileString(r.Source)
|
||||
if err := v.Err(); err != nil {
|
||||
return fmt.Errorf("invalid CUE: %s", firstError(err))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EvaluateRule unifies a single CUE source with the given instance values and
|
||||
// reports violations plus any transformed values. A compile error yields a
|
||||
// non-OK result with CompileError populated (and no violations), so callers can
|
||||
// distinguish "the rule is broken" from "the values are invalid".
|
||||
func EvaluateRule(source string, values map[string]any) RuleResult {
|
||||
ctx := cuecontext.New()
|
||||
schema := ctx.CompileString(source)
|
||||
if err := schema.Err(); err != nil {
|
||||
return RuleResult{OK: false, CompileError: firstError(err)}
|
||||
}
|
||||
data := ctx.Encode(values)
|
||||
if err := data.Err(); err != nil {
|
||||
return RuleResult{OK: false, CompileError: "cannot encode values: " + firstError(err)}
|
||||
}
|
||||
unified := schema.Unify(data)
|
||||
|
||||
res := RuleResult{OK: true}
|
||||
if err := unified.Validate(cue.Concrete(true), cue.All()); err != nil {
|
||||
res.OK = false
|
||||
for _, e := range cueerrors.Errors(err) {
|
||||
res.Violations = append(res.Violations, RuleViolation{
|
||||
Path: strings.Join(e.Path(), "."),
|
||||
Message: cleanMessage(e),
|
||||
})
|
||||
}
|
||||
if len(res.Violations) == 0 {
|
||||
res.Violations = append(res.Violations, RuleViolation{Message: firstError(err)})
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// Extract transformations: regular concrete fields whose value differs from
|
||||
// the supplied input. Definitions and hidden fields are skipped by Fields().
|
||||
iter, err := unified.Fields()
|
||||
if err != nil {
|
||||
return res
|
||||
}
|
||||
for iter.Next() {
|
||||
key := iter.Selector().Unquoted()
|
||||
if key == "" {
|
||||
key = strings.Trim(iter.Selector().String(), `"`)
|
||||
}
|
||||
var v any
|
||||
if err := iter.Value().Decode(&v); err != nil {
|
||||
continue
|
||||
}
|
||||
if !reflect.DeepEqual(v, values[key]) {
|
||||
if res.Transformed == nil {
|
||||
res.Transformed = map[string]any{}
|
||||
}
|
||||
res.Transformed[key] = v
|
||||
}
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// evaluateRules runs several rules against the same values and aggregates the
|
||||
// outcome. Violations are tagged with the rule ID. Transformations are applied
|
||||
// cumulatively in order; a later rule sees earlier rules' outputs.
|
||||
func evaluateRules(rules []ConfigRule, values map[string]any) RuleResult {
|
||||
agg := RuleResult{OK: true}
|
||||
cur := make(map[string]any, len(values))
|
||||
for k, v := range values {
|
||||
cur[k] = v
|
||||
}
|
||||
for _, r := range rules {
|
||||
one := EvaluateRule(r.Source, cur)
|
||||
if one.CompileError != "" {
|
||||
agg.OK = false
|
||||
agg.Violations = append(agg.Violations, RuleViolation{Rule: r.ID, Message: "compile error: " + one.CompileError})
|
||||
continue
|
||||
}
|
||||
if !one.OK {
|
||||
agg.OK = false
|
||||
for _, v := range one.Violations {
|
||||
v.Rule = r.ID
|
||||
agg.Violations = append(agg.Violations, v)
|
||||
}
|
||||
continue
|
||||
}
|
||||
for k, v := range one.Transformed {
|
||||
if agg.Transformed == nil {
|
||||
agg.Transformed = map[string]any{}
|
||||
}
|
||||
agg.Transformed[k] = v
|
||||
cur[k] = v
|
||||
}
|
||||
}
|
||||
return agg
|
||||
}
|
||||
|
||||
// firstError renders the first CUE error as a single line.
|
||||
func firstError(err error) string {
|
||||
errs := cueerrors.Errors(err)
|
||||
if len(errs) == 0 {
|
||||
return err.Error()
|
||||
}
|
||||
return cleanMessage(errs[0])
|
||||
}
|
||||
|
||||
// cleanMessage formats a CUE error to a compact single-line string without the
|
||||
// noisy file:line position prefix that cuecontext synthesises for anonymous
|
||||
// sources.
|
||||
func cleanMessage(e cueerrors.Error) string {
|
||||
format, args := e.Msg()
|
||||
return fmt.Sprintf(format, args...)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package confmgr
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestEvaluateRule_Valid(t *testing.T) {
|
||||
src := `
|
||||
current_limit: >=0 & <=max
|
||||
max: 100
|
||||
`
|
||||
res := EvaluateRule(src, map[string]any{"current_limit": 50.0})
|
||||
if !res.OK {
|
||||
t.Fatalf("expected OK, got violations: %+v compile=%q", res.Violations, res.CompileError)
|
||||
}
|
||||
if res.CompileError != "" {
|
||||
t.Fatalf("unexpected compile error: %s", res.CompileError)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateRule_Violation(t *testing.T) {
|
||||
src := `current_limit: >=0 & <=100`
|
||||
res := EvaluateRule(src, map[string]any{"current_limit": 150.0})
|
||||
if res.OK {
|
||||
t.Fatalf("expected violation, got OK")
|
||||
}
|
||||
if len(res.Violations) == 0 {
|
||||
t.Fatalf("expected at least one violation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateRule_Transform(t *testing.T) {
|
||||
// power is derived from the supplied current & voltage.
|
||||
src := `
|
||||
current: number
|
||||
voltage: number
|
||||
power: current * voltage
|
||||
`
|
||||
res := EvaluateRule(src, map[string]any{"current": 2.0, "voltage": 3.0})
|
||||
if !res.OK {
|
||||
t.Fatalf("expected OK, got %+v", res.Violations)
|
||||
}
|
||||
got, ok := res.Transformed["power"]
|
||||
if !ok {
|
||||
t.Fatalf("expected transformed power, got %+v", res.Transformed)
|
||||
}
|
||||
if f, _ := toFloat(got); f != 6 {
|
||||
t.Fatalf("expected power=6, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateRule_DefaultFillsMissing(t *testing.T) {
|
||||
src := `mode: *"auto" | "manual"`
|
||||
res := EvaluateRule(src, map[string]any{})
|
||||
if !res.OK {
|
||||
t.Fatalf("expected OK, got %+v", res.Violations)
|
||||
}
|
||||
if res.Transformed["mode"] != "auto" {
|
||||
t.Fatalf("expected mode default auto, got %v", res.Transformed["mode"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvaluateRule_CompileError(t *testing.T) {
|
||||
res := EvaluateRule(`this is : : not cue`, map[string]any{})
|
||||
if res.OK || res.CompileError == "" {
|
||||
t.Fatalf("expected compile error, got %+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigRule_Validate(t *testing.T) {
|
||||
r := ConfigRule{Name: "r", SetID: "s", Source: `x: >=0`}
|
||||
if err := r.Validate(); err != nil {
|
||||
t.Fatalf("expected valid rule, got %v", err)
|
||||
}
|
||||
bad := ConfigRule{Name: "r", SetID: "s", Source: `x: : :`}
|
||||
if err := bad.Validate(); err == nil {
|
||||
t.Fatalf("expected compile error for bad source")
|
||||
}
|
||||
if err := (ConfigRule{Source: `x: 1`}).Validate(); err == nil {
|
||||
t.Fatalf("expected error for missing name/setID")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package confmgr
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// ChangeStatus classifies a single diff entry.
|
||||
type ChangeStatus string
|
||||
|
||||
const (
|
||||
StatusAdded ChangeStatus = "added"
|
||||
StatusRemoved ChangeStatus = "removed"
|
||||
StatusChanged ChangeStatus = "changed"
|
||||
StatusUnchanged ChangeStatus = "unchanged"
|
||||
)
|
||||
|
||||
// SetDiffEntry is one parameter-level difference between two config sets.
|
||||
type SetDiffEntry struct {
|
||||
Key string `json:"key"`
|
||||
Status ChangeStatus `json:"status"`
|
||||
Left *Parameter `json:"left,omitempty"`
|
||||
Right *Parameter `json:"right,omitempty"`
|
||||
}
|
||||
|
||||
// InstanceDiffEntry is one value-level difference between two config instances.
|
||||
type InstanceDiffEntry struct {
|
||||
Key string `json:"key"`
|
||||
Status ChangeStatus `json:"status"`
|
||||
Left any `json:"left,omitempty"`
|
||||
Right any `json:"right,omitempty"`
|
||||
}
|
||||
|
||||
// DiffSets compares two config sets parameter-by-parameter (keyed by Key),
|
||||
// returning entries sorted by key. Both sides are included so the frontend can
|
||||
// render either unified or side-by-side.
|
||||
func DiffSets(left, right ConfigSet) []SetDiffEntry {
|
||||
keys := map[string]bool{}
|
||||
li := map[string]Parameter{}
|
||||
ri := map[string]Parameter{}
|
||||
for _, p := range left.Parameters {
|
||||
li[p.Key] = p
|
||||
keys[p.Key] = true
|
||||
}
|
||||
for _, p := range right.Parameters {
|
||||
ri[p.Key] = p
|
||||
keys[p.Key] = true
|
||||
}
|
||||
out := make([]SetDiffEntry, 0, len(keys))
|
||||
for k := range keys {
|
||||
l, lok := li[k]
|
||||
r, rok := ri[k]
|
||||
e := SetDiffEntry{Key: k}
|
||||
switch {
|
||||
case lok && !rok:
|
||||
e.Status = StatusRemoved
|
||||
lp := l
|
||||
e.Left = &lp
|
||||
case !lok && rok:
|
||||
e.Status = StatusAdded
|
||||
rp := r
|
||||
e.Right = &rp
|
||||
default:
|
||||
lp, rp := l, r
|
||||
e.Left, e.Right = &lp, &rp
|
||||
if paramEqual(l, r) {
|
||||
e.Status = StatusUnchanged
|
||||
} else {
|
||||
e.Status = StatusChanged
|
||||
}
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key })
|
||||
return out
|
||||
}
|
||||
|
||||
// DiffInstances compares two config instances value-by-value (keyed by
|
||||
// parameter key), returning entries sorted by key.
|
||||
func DiffInstances(left, right ConfigInstance) []InstanceDiffEntry {
|
||||
keys := map[string]bool{}
|
||||
for k := range left.Values {
|
||||
keys[k] = true
|
||||
}
|
||||
for k := range right.Values {
|
||||
keys[k] = true
|
||||
}
|
||||
out := make([]InstanceDiffEntry, 0, len(keys))
|
||||
for k := range keys {
|
||||
lv, lok := left.Values[k]
|
||||
rv, rok := right.Values[k]
|
||||
e := InstanceDiffEntry{Key: k, Left: lv, Right: rv}
|
||||
switch {
|
||||
case lok && !rok:
|
||||
e.Status = StatusRemoved
|
||||
case !lok && rok:
|
||||
e.Status = StatusAdded
|
||||
case valueEqual(lv, rv):
|
||||
e.Status = StatusUnchanged
|
||||
default:
|
||||
e.Status = StatusChanged
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key })
|
||||
return out
|
||||
}
|
||||
|
||||
func paramEqual(a, b Parameter) bool {
|
||||
if a.Label != b.Label || a.Group != b.Group || a.Subgroup != b.Subgroup ||
|
||||
a.DS != b.DS || a.Signal != b.Signal || a.Type != b.Type ||
|
||||
a.Mandatory != b.Mandatory || a.Unit != b.Unit || a.Description != b.Description {
|
||||
return false
|
||||
}
|
||||
if !valueEqual(a.Default, b.Default) || !floatPtrEqual(a.Min, b.Min) || !floatPtrEqual(a.Max, b.Max) {
|
||||
return false
|
||||
}
|
||||
if len(a.EnumValues) != len(b.EnumValues) {
|
||||
return false
|
||||
}
|
||||
for i := range a.EnumValues {
|
||||
if a.EnumValues[i] != b.EnumValues[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func floatPtrEqual(a, b *float64) bool {
|
||||
if a == nil || b == nil {
|
||||
return a == b
|
||||
}
|
||||
return *a == *b
|
||||
}
|
||||
|
||||
// valueEqual compares two JSON-decoded scalar values for diff purposes by
|
||||
// rendering them to a canonical string.
|
||||
func valueEqual(a, b any) bool {
|
||||
return fmt.Sprintf("%v", a) == fmt.Sprintf("%v", b)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package confmgr
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestDiffSets(t *testing.T) {
|
||||
left := ConfigSet{Parameters: []Parameter{
|
||||
{Key: "a", DS: "d", Signal: "A", Type: TypeFloat, Default: 1.0},
|
||||
{Key: "b", DS: "d", Signal: "B", Type: TypeFloat},
|
||||
}}
|
||||
right := ConfigSet{Parameters: []Parameter{
|
||||
{Key: "a", DS: "d", Signal: "A", Type: TypeFloat, Default: 2.0}, // changed default
|
||||
{Key: "c", DS: "d", Signal: "C", Type: TypeFloat}, // added
|
||||
}}
|
||||
diff := DiffSets(left, right)
|
||||
got := map[string]ChangeStatus{}
|
||||
for _, e := range diff {
|
||||
got[e.Key] = e.Status
|
||||
}
|
||||
if got["a"] != StatusChanged {
|
||||
t.Errorf("a: want changed, got %s", got["a"])
|
||||
}
|
||||
if got["b"] != StatusRemoved {
|
||||
t.Errorf("b: want removed, got %s", got["b"])
|
||||
}
|
||||
if got["c"] != StatusAdded {
|
||||
t.Errorf("c: want added, got %s", got["c"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffSetsUnchanged(t *testing.T) {
|
||||
p := Parameter{Key: "a", DS: "d", Signal: "A", Type: TypeFloat, Default: 1.0}
|
||||
diff := DiffSets(ConfigSet{Parameters: []Parameter{p}}, ConfigSet{Parameters: []Parameter{p}})
|
||||
if len(diff) != 1 || diff[0].Status != StatusUnchanged {
|
||||
t.Errorf("want single unchanged entry, got %+v", diff)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffInstances(t *testing.T) {
|
||||
left := ConfigInstance{Values: map[string]any{"a": 1.0, "b": "x"}}
|
||||
right := ConfigInstance{Values: map[string]any{"a": 2.0, "c": true}}
|
||||
diff := DiffInstances(left, right)
|
||||
got := map[string]ChangeStatus{}
|
||||
for _, e := range diff {
|
||||
got[e.Key] = e.Status
|
||||
}
|
||||
if got["a"] != StatusChanged {
|
||||
t.Errorf("a: want changed, got %s", got["a"])
|
||||
}
|
||||
if got["b"] != StatusRemoved {
|
||||
t.Errorf("b: want removed, got %s", got["b"])
|
||||
}
|
||||
if got["c"] != StatusAdded {
|
||||
t.Errorf("c: want added, got %s", got["c"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
// Package confmgr implements the configuration manager: versioned, git-style
|
||||
// configuration Sets (schemas of parameters bound to target signals) and
|
||||
// configuration Instances (concrete values for a set), persisted as versioned
|
||||
// JSON files. Applying an instance writes each value to its target signal.
|
||||
package confmgr
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ParamType enumerates the value kinds a parameter may hold.
|
||||
type ParamType string
|
||||
|
||||
const (
|
||||
TypeFloat ParamType = "float64"
|
||||
TypeInt ParamType = "int64"
|
||||
TypeBool ParamType = "bool"
|
||||
TypeString ParamType = "string"
|
||||
TypeEnum ParamType = "enum"
|
||||
TypeFloatArray ParamType = "float64[]" // waveform; value is a list of numbers
|
||||
)
|
||||
|
||||
func (t ParamType) valid() bool {
|
||||
switch t {
|
||||
case TypeFloat, TypeInt, TypeBool, TypeString, TypeEnum, TypeFloatArray:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Parameter is one entry in a configuration set's schema. It names a target
|
||||
// signal (DS + Signal), a value type, an optional default, and validation
|
||||
// metadata. Parameters may be grouped for presentation via Group/Subgroup.
|
||||
type Parameter struct {
|
||||
Key string `json:"key"` // unique within the set
|
||||
Label string `json:"label,omitempty"` // human-friendly name
|
||||
Group string `json:"group,omitempty"` // top-level grouping
|
||||
Subgroup string `json:"subgroup,omitempty"`
|
||||
DS string `json:"ds"` // target data source
|
||||
Signal string `json:"signal"` // target signal name
|
||||
Type ParamType `json:"type"` // value kind
|
||||
Default any `json:"default,omitempty"`
|
||||
Mandatory bool `json:"mandatory,omitempty"`
|
||||
Min *float64 `json:"min,omitempty"` // numeric lower bound
|
||||
Max *float64 `json:"max,omitempty"` // numeric upper bound
|
||||
EnumValues []string `json:"enumValues,omitempty"` // allowed values for enum
|
||||
Unit string `json:"unit,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
// ConfigSet is the schema half of the two-tier model: an ordered list of
|
||||
// parameters bound to target signals. It is versioned git-style.
|
||||
type ConfigSet struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Version int `json:"version"`
|
||||
Tag string `json:"tag,omitempty"`
|
||||
Owner string `json:"owner,omitempty"`
|
||||
Scope string `json:"scope,omitempty"` // access.Scope* visibility token
|
||||
Groups []string `json:"groups,omitempty"` // groups for ScopeGroup visibility
|
||||
Parameters []Parameter `json:"parameters"`
|
||||
}
|
||||
|
||||
// ConfigInstance is the value half: concrete values for a set's parameters,
|
||||
// keyed by parameter key. SetID pins the schema it belongs to; SetVersion pins
|
||||
// the schema revision (0 means "track current"). It is versioned git-style.
|
||||
type ConfigInstance struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
SetID string `json:"setId"`
|
||||
SetVersion int `json:"setVersion,omitempty"` // 0 = current set version
|
||||
Version int `json:"version"`
|
||||
Tag string `json:"tag,omitempty"`
|
||||
Owner string `json:"owner,omitempty"`
|
||||
Scope string `json:"scope,omitempty"` // access.Scope* visibility token
|
||||
Groups []string `json:"groups,omitempty"` // groups for ScopeGroup visibility
|
||||
Values map[string]any `json:"values"`
|
||||
}
|
||||
|
||||
// Validate checks structural invariants of a config set: non-empty name,
|
||||
// unique non-empty parameter keys, valid types, a target signal per parameter,
|
||||
// and well-formed enum/default metadata.
|
||||
func (s ConfigSet) Validate() error {
|
||||
if strings.TrimSpace(s.Name) == "" {
|
||||
return fmt.Errorf("config set name must not be empty")
|
||||
}
|
||||
seen := make(map[string]bool, len(s.Parameters))
|
||||
for i, p := range s.Parameters {
|
||||
if strings.TrimSpace(p.Key) == "" {
|
||||
return fmt.Errorf("parameter %d: key must not be empty", i)
|
||||
}
|
||||
if seen[p.Key] {
|
||||
return fmt.Errorf("duplicate parameter key %q", p.Key)
|
||||
}
|
||||
seen[p.Key] = true
|
||||
if !p.Type.valid() {
|
||||
return fmt.Errorf("parameter %q: invalid type %q", p.Key, p.Type)
|
||||
}
|
||||
if strings.TrimSpace(p.DS) == "" || strings.TrimSpace(p.Signal) == "" {
|
||||
return fmt.Errorf("parameter %q: target ds and signal are required", p.Key)
|
||||
}
|
||||
if p.Type == TypeEnum && len(p.EnumValues) == 0 {
|
||||
return fmt.Errorf("parameter %q: enum type requires enumValues", p.Key)
|
||||
}
|
||||
if p.Min != nil && p.Max != nil && *p.Min > *p.Max {
|
||||
return fmt.Errorf("parameter %q: min %v greater than max %v", p.Key, *p.Min, *p.Max)
|
||||
}
|
||||
if p.Default != nil {
|
||||
if err := p.checkValue(p.Default); err != nil {
|
||||
return fmt.Errorf("parameter %q default: %w", p.Key, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// param returns the parameter with the given key, or false.
|
||||
func (s ConfigSet) param(key string) (Parameter, bool) {
|
||||
for _, p := range s.Parameters {
|
||||
if p.Key == key {
|
||||
return p, true
|
||||
}
|
||||
}
|
||||
return Parameter{}, false
|
||||
}
|
||||
|
||||
// ValidateAgainst checks an instance against its set: every value references a
|
||||
// known parameter and is type/range valid; every mandatory parameter without a
|
||||
// default has a value.
|
||||
func (inst ConfigInstance) ValidateAgainst(set ConfigSet) error {
|
||||
for key, v := range inst.Values {
|
||||
p, ok := set.param(key)
|
||||
if !ok {
|
||||
return fmt.Errorf("value for unknown parameter %q", key)
|
||||
}
|
||||
if err := p.checkValue(v); err != nil {
|
||||
return fmt.Errorf("parameter %q: %w", key, err)
|
||||
}
|
||||
}
|
||||
for _, p := range set.Parameters {
|
||||
if !p.Mandatory {
|
||||
continue
|
||||
}
|
||||
if _, ok := inst.Values[p.Key]; ok {
|
||||
continue
|
||||
}
|
||||
if p.Default == nil {
|
||||
return fmt.Errorf("mandatory parameter %q has no value", p.Key)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Resolve returns the effective value for a parameter: the instance value when
|
||||
// present, otherwise the parameter default. The second result is false when no
|
||||
// value is available (optional parameter, no default).
|
||||
func (inst ConfigInstance) Resolve(p Parameter) (any, bool) {
|
||||
if v, ok := inst.Values[p.Key]; ok {
|
||||
return v, true
|
||||
}
|
||||
if p.Default != nil {
|
||||
return p.Default, true
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// checkValue verifies a value matches the parameter's type and constraints.
|
||||
func (p Parameter) checkValue(v any) error {
|
||||
switch p.Type {
|
||||
case TypeFloat, TypeInt:
|
||||
f, err := toFloat(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if p.Type == TypeInt && f != math.Trunc(f) {
|
||||
return fmt.Errorf("value %v is not an integer", f)
|
||||
}
|
||||
if p.Min != nil && f < *p.Min {
|
||||
return fmt.Errorf("value %v below minimum %v", f, *p.Min)
|
||||
}
|
||||
if p.Max != nil && f > *p.Max {
|
||||
return fmt.Errorf("value %v above maximum %v", f, *p.Max)
|
||||
}
|
||||
case TypeBool:
|
||||
if _, ok := v.(bool); !ok {
|
||||
return fmt.Errorf("value %v is not a bool", v)
|
||||
}
|
||||
case TypeString:
|
||||
if _, ok := v.(string); !ok {
|
||||
return fmt.Errorf("value %v is not a string", v)
|
||||
}
|
||||
case TypeEnum:
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("enum value %v is not a string", v)
|
||||
}
|
||||
if !slices.Contains(p.EnumValues, s) {
|
||||
return fmt.Errorf("value %q is not an allowed enum value", s)
|
||||
}
|
||||
case TypeFloatArray:
|
||||
arr, err := toFloatArray(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i, f := range arr {
|
||||
if p.Min != nil && f < *p.Min {
|
||||
return fmt.Errorf("element %d value %v below minimum %v", i, f, *p.Min)
|
||||
}
|
||||
if p.Max != nil && f > *p.Max {
|
||||
return fmt.Errorf("element %d value %v above maximum %v", i, f, *p.Max)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalize coerces a JSON-decoded value into the canonical Go type the
|
||||
// datasource write path expects. Array parameters become []float64 (JSON yields
|
||||
// []any); scalar values are returned unchanged.
|
||||
func (p Parameter) normalize(v any) any {
|
||||
if p.Type == TypeFloatArray {
|
||||
if arr, err := toFloatArray(v); err == nil {
|
||||
return arr
|
||||
}
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// toFloatArray coerces a JSON-decoded array value to []float64. JSON
|
||||
// unmarshalling yields []any of float64, but a native []float64 is also
|
||||
// accepted.
|
||||
func toFloatArray(v any) ([]float64, error) {
|
||||
switch a := v.(type) {
|
||||
case []float64:
|
||||
return a, nil
|
||||
case []any:
|
||||
out := make([]float64, len(a))
|
||||
for i, e := range a {
|
||||
f, err := toFloat(e)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("element %d: %w", i, err)
|
||||
}
|
||||
out[i] = f
|
||||
}
|
||||
return out, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("value %v is not an array", v)
|
||||
}
|
||||
}
|
||||
|
||||
// toFloat coerces a JSON-decoded numeric value to float64. JSON unmarshalling
|
||||
// yields float64 for numbers, but values may also arrive as int or string.
|
||||
func toFloat(v any) (float64, error) {
|
||||
switch n := v.(type) {
|
||||
case float64:
|
||||
return n, nil
|
||||
case float32:
|
||||
return float64(n), nil
|
||||
case int:
|
||||
return float64(n), nil
|
||||
case int64:
|
||||
return float64(n), nil
|
||||
case string:
|
||||
f, err := strconv.ParseFloat(n, 64)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("value %q is not numeric", n)
|
||||
}
|
||||
return f, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("value %v is not numeric", v)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package confmgr
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestParamTypeValid covers the valid/invalid branches of ParamType.valid.
|
||||
func TestParamTypeValid(t *testing.T) {
|
||||
for _, ok := range []ParamType{TypeFloat, TypeInt, TypeBool, TypeString, TypeEnum, TypeFloatArray} {
|
||||
if !ok.valid() {
|
||||
t.Errorf("%q should be valid", ok)
|
||||
}
|
||||
}
|
||||
if ParamType("bogus").valid() {
|
||||
t.Error("bogus type should be invalid")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckValue exercises every type branch of Parameter.checkValue, including
|
||||
// the type-mismatch and range-violation error paths.
|
||||
func TestCheckValue(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
p Parameter
|
||||
v any
|
||||
wantErr bool
|
||||
}{
|
||||
{"float ok", Parameter{Type: TypeFloat}, 1.5, false},
|
||||
{"float from string", Parameter{Type: TypeFloat}, "2.5", false},
|
||||
{"float not numeric", Parameter{Type: TypeFloat}, true, true},
|
||||
{"float below min", Parameter{Type: TypeFloat, Min: fptr(0)}, -1.0, true},
|
||||
{"float above max", Parameter{Type: TypeFloat, Max: fptr(10)}, 11.0, true},
|
||||
{"int ok", Parameter{Type: TypeInt}, 4.0, false},
|
||||
{"int non-integer", Parameter{Type: TypeInt}, 4.5, true},
|
||||
{"bool ok", Parameter{Type: TypeBool}, true, false},
|
||||
{"bool wrong type", Parameter{Type: TypeBool}, 1.0, true},
|
||||
{"string ok", Parameter{Type: TypeString}, "hi", false},
|
||||
{"string wrong type", Parameter{Type: TypeString}, 1.0, true},
|
||||
{"enum ok", Parameter{Type: TypeEnum, EnumValues: []string{"a", "b"}}, "b", false},
|
||||
{"enum not string", Parameter{Type: TypeEnum, EnumValues: []string{"a"}}, 1.0, true},
|
||||
{"enum not allowed", Parameter{Type: TypeEnum, EnumValues: []string{"a"}}, "z", true},
|
||||
{"array ok", Parameter{Type: TypeFloatArray}, []any{1.0, 2.0}, false},
|
||||
{"array native", Parameter{Type: TypeFloatArray}, []float64{1, 2}, false},
|
||||
{"array not array", Parameter{Type: TypeFloatArray}, 1.0, true},
|
||||
{"array elem below min", Parameter{Type: TypeFloatArray, Min: fptr(0)}, []any{-1.0}, true},
|
||||
{"array elem above max", Parameter{Type: TypeFloatArray, Max: fptr(5)}, []any{9.0}, true},
|
||||
{"array bad elem", Parameter{Type: TypeFloatArray}, []any{"nope"}, true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := tc.p.checkValue(tc.v)
|
||||
if tc.wantErr && err == nil {
|
||||
t.Errorf("checkValue(%v): want error", tc.v)
|
||||
}
|
||||
if !tc.wantErr && err != nil {
|
||||
t.Errorf("checkValue(%v): unexpected error %v", tc.v, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateInvalidType covers the invalid-type branch of ConfigSet.Validate.
|
||||
func TestValidateInvalidType(t *testing.T) {
|
||||
set := ConfigSet{Name: "x", Parameters: []Parameter{
|
||||
{Key: "a", DS: "d", Signal: "s", Type: ParamType("weird")},
|
||||
}}
|
||||
if err := set.Validate(); err == nil {
|
||||
t.Error("invalid parameter type: want error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateEnumRequiresValues covers the enum-without-values branch.
|
||||
func TestValidateEnumRequiresValues(t *testing.T) {
|
||||
set := ConfigSet{Name: "x", Parameters: []Parameter{
|
||||
{Key: "a", DS: "d", Signal: "s", Type: TypeEnum},
|
||||
}}
|
||||
if err := set.Validate(); err == nil {
|
||||
t.Error("enum without values: want error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateMinGreaterThanMax covers the min>max branch.
|
||||
func TestValidateMinGreaterThanMax(t *testing.T) {
|
||||
set := ConfigSet{Name: "x", Parameters: []Parameter{
|
||||
{Key: "a", DS: "d", Signal: "s", Type: TypeFloat, Min: fptr(10), Max: fptr(1)},
|
||||
}}
|
||||
if err := set.Validate(); err == nil {
|
||||
t.Error("min>max: want error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateAgainstMandatoryNoDefault covers the mandatory-without-default
|
||||
// branch of ValidateAgainst.
|
||||
func TestValidateAgainstMandatoryNoDefault(t *testing.T) {
|
||||
set := ConfigSet{Name: "x", Parameters: []Parameter{
|
||||
{Key: "req", DS: "d", Signal: "s", Type: TypeFloat, Mandatory: true},
|
||||
}}
|
||||
inst := ConfigInstance{SetID: "x", Values: map[string]any{}}
|
||||
if err := inst.ValidateAgainst(set); err == nil {
|
||||
t.Error("missing mandatory value: want error")
|
||||
}
|
||||
// Providing the value clears the error.
|
||||
inst.Values["req"] = 1.0
|
||||
if err := inst.ValidateAgainst(set); err != nil {
|
||||
t.Errorf("with value: unexpected error %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveAndNormalize covers Resolve fallbacks and array normalization.
|
||||
func TestResolveAndNormalize(t *testing.T) {
|
||||
p := Parameter{Key: "v", Type: TypeFloat, Default: 7.0}
|
||||
inst := ConfigInstance{Values: map[string]any{}}
|
||||
if got, ok := inst.Resolve(p); !ok || got != 7.0 {
|
||||
t.Errorf("Resolve default: got %v,%v want 7,true", got, ok)
|
||||
}
|
||||
inst.Values["v"] = 3.0
|
||||
if got, ok := inst.Resolve(p); !ok || got != 3.0 {
|
||||
t.Errorf("Resolve value: got %v,%v want 3,true", got, ok)
|
||||
}
|
||||
// Optional param without default → no value.
|
||||
if _, ok := inst.Resolve(Parameter{Key: "none", Type: TypeFloat}); ok {
|
||||
t.Error("Resolve no-default: want ok=false")
|
||||
}
|
||||
|
||||
arr := Parameter{Type: TypeFloatArray}
|
||||
out := arr.normalize([]any{1.0, 2.0, 3.0})
|
||||
if got, ok := out.([]float64); !ok || len(got) != 3 {
|
||||
t.Errorf("normalize array: got %T %v", out, out)
|
||||
}
|
||||
// Non-array passthrough.
|
||||
if got := arr.normalize("scalar"); got != "scalar" {
|
||||
t.Errorf("normalize passthrough: got %v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package confmgr
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"slices"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// ReadFunc reads the current raw value of a target signal. The API/engine layer
|
||||
// supplies a closure backed by the broker (ReadNow) so confmgr stays decoupled
|
||||
// from the transport. The returned value mirrors datasource.Value.Data
|
||||
// (float64 | []float64 | string | int64 | bool; enums arrive as an int64 index).
|
||||
type ReadFunc func(ds, signal string) (any, error)
|
||||
|
||||
// SnapshotEntry records the outcome of reading one parameter's target signal.
|
||||
type SnapshotEntry struct {
|
||||
Key string `json:"key"`
|
||||
DS string `json:"ds"`
|
||||
Signal string `json:"signal"`
|
||||
Value any `json:"value,omitempty"`
|
||||
OK bool `json:"ok"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// SnapshotResult summarises a snapshot run. Values holds the captured,
|
||||
// type-coerced values ready to populate a new ConfigInstance.
|
||||
type SnapshotResult struct {
|
||||
SetID string `json:"setId"`
|
||||
Entries []SnapshotEntry `json:"entries"`
|
||||
Captured int `json:"captured"`
|
||||
Failed int `json:"failed"`
|
||||
Values map[string]any `json:"-"`
|
||||
}
|
||||
|
||||
// Snapshot reads the current value of every parameter's target signal via read
|
||||
// and builds a value map for a new instance. Read failures and values that
|
||||
// cannot be coerced to the parameter's type are recorded per-entry rather than
|
||||
// aborting the whole snapshot, so a partial capture is reported faithfully.
|
||||
func Snapshot(set ConfigSet, read ReadFunc) SnapshotResult {
|
||||
res := SnapshotResult{
|
||||
SetID: set.ID,
|
||||
Entries: make([]SnapshotEntry, 0, len(set.Parameters)),
|
||||
Values: make(map[string]any, len(set.Parameters)),
|
||||
}
|
||||
for _, p := range set.Parameters {
|
||||
e := SnapshotEntry{Key: p.Key, DS: p.DS, Signal: p.Signal}
|
||||
raw, err := read(p.DS, p.Signal)
|
||||
if err != nil {
|
||||
e.Error = err.Error()
|
||||
res.Failed++
|
||||
res.Entries = append(res.Entries, e)
|
||||
continue
|
||||
}
|
||||
v, err := p.coerceSnapshot(raw)
|
||||
if err != nil {
|
||||
e.Error = err.Error()
|
||||
res.Failed++
|
||||
res.Entries = append(res.Entries, e)
|
||||
continue
|
||||
}
|
||||
e.Value = v
|
||||
e.OK = true
|
||||
res.Values[p.Key] = v
|
||||
res.Captured++
|
||||
res.Entries = append(res.Entries, e)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// coerceSnapshot converts a raw datasource value into the canonical Go value the
|
||||
// parameter's type expects, so the result passes checkValue and serialises
|
||||
// cleanly. Enum signals arrive as an int64 index and are mapped to their string.
|
||||
func (p Parameter) coerceSnapshot(raw any) (any, error) {
|
||||
switch p.Type {
|
||||
case TypeFloat:
|
||||
return toFloat(raw)
|
||||
case TypeInt:
|
||||
f, err := toFloat(raw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return int64(math.Round(f)), nil
|
||||
case TypeBool:
|
||||
switch b := raw.(type) {
|
||||
case bool:
|
||||
return b, nil
|
||||
case string:
|
||||
pb, err := strconv.ParseBool(b)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("value %q is not a bool", b)
|
||||
}
|
||||
return pb, nil
|
||||
default:
|
||||
f, err := toFloat(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("value %v is not a bool", raw)
|
||||
}
|
||||
return f != 0, nil
|
||||
}
|
||||
case TypeString:
|
||||
if s, ok := raw.(string); ok {
|
||||
return s, nil
|
||||
}
|
||||
return fmt.Sprintf("%v", raw), nil
|
||||
case TypeEnum:
|
||||
// Enum signals deliver an int64 index into the metadata strings; map it
|
||||
// to this parameter's enum value. A string already in range is kept.
|
||||
if s, ok := raw.(string); ok {
|
||||
if slices.Contains(p.EnumValues, s) {
|
||||
return s, nil
|
||||
}
|
||||
return nil, fmt.Errorf("value %q is not an allowed enum value", s)
|
||||
}
|
||||
f, err := toFloat(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("enum value %v is not an index", raw)
|
||||
}
|
||||
idx := int(math.Round(f))
|
||||
if idx < 0 || idx >= len(p.EnumValues) {
|
||||
return nil, fmt.Errorf("enum index %d out of range", idx)
|
||||
}
|
||||
return p.EnumValues[idx], nil
|
||||
case TypeFloatArray:
|
||||
return toFloatArray(raw)
|
||||
default:
|
||||
return raw, nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package confmgr
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSnapshotCapturesAndCoerces(t *testing.T) {
|
||||
set := ConfigSet{
|
||||
ID: "set1",
|
||||
Name: "s",
|
||||
Parameters: []Parameter{
|
||||
{Key: "v", DS: "epics", Signal: "PSU:V", Type: TypeFloat},
|
||||
{Key: "n", DS: "epics", Signal: "PSU:N", Type: TypeInt},
|
||||
{Key: "en", DS: "epics", Signal: "PSU:EN", Type: TypeBool},
|
||||
{Key: "mode", DS: "epics", Signal: "PSU:MODE", Type: TypeEnum, EnumValues: []string{"off", "low", "high"}},
|
||||
{Key: "wf", DS: "epics", Signal: "PSU:WF", Type: TypeFloatArray},
|
||||
{Key: "lbl", DS: "epics", Signal: "PSU:LBL", Type: TypeString},
|
||||
},
|
||||
}
|
||||
live := map[string]any{
|
||||
"PSU:V": 24.5,
|
||||
"PSU:N": 3.0, // float reading → coerced to int64
|
||||
"PSU:EN": int64(1), // numeric → bool true
|
||||
"PSU:MODE": int64(2), // enum index → "high"
|
||||
"PSU:WF": []float64{1, 2, 3},
|
||||
"PSU:LBL": "ready",
|
||||
}
|
||||
res := Snapshot(set, func(_, signal string) (any, error) {
|
||||
v, ok := live[signal]
|
||||
if !ok {
|
||||
return nil, errors.New("not read")
|
||||
}
|
||||
return v, nil
|
||||
})
|
||||
|
||||
if res.Captured != 6 || res.Failed != 0 {
|
||||
t.Fatalf("summary: captured=%d failed=%d", res.Captured, res.Failed)
|
||||
}
|
||||
if res.Values["v"] != 24.5 {
|
||||
t.Errorf("v = %v, want 24.5", res.Values["v"])
|
||||
}
|
||||
if res.Values["n"] != int64(3) {
|
||||
t.Errorf("n = %v (%T), want int64(3)", res.Values["n"], res.Values["n"])
|
||||
}
|
||||
if res.Values["en"] != true {
|
||||
t.Errorf("en = %v, want true", res.Values["en"])
|
||||
}
|
||||
if res.Values["mode"] != "high" {
|
||||
t.Errorf("mode = %v, want high", res.Values["mode"])
|
||||
}
|
||||
if res.Values["lbl"] != "ready" {
|
||||
t.Errorf("lbl = %v, want ready", res.Values["lbl"])
|
||||
}
|
||||
// The captured values must validate against the set.
|
||||
inst := ConfigInstance{SetID: set.ID, Values: res.Values}
|
||||
if err := inst.ValidateAgainst(set); err != nil {
|
||||
t.Errorf("snapshot values fail validation: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotReadFailureRecorded(t *testing.T) {
|
||||
set := ConfigSet{
|
||||
ID: "set1",
|
||||
Name: "s",
|
||||
Parameters: []Parameter{
|
||||
{Key: "a", DS: "epics", Signal: "A", Type: TypeFloat},
|
||||
{Key: "b", DS: "epics", Signal: "B", Type: TypeFloat},
|
||||
},
|
||||
}
|
||||
res := Snapshot(set, func(_, signal string) (any, error) {
|
||||
if signal == "B" {
|
||||
return nil, errors.New("offline")
|
||||
}
|
||||
return 1.0, nil
|
||||
})
|
||||
if res.Captured != 1 || res.Failed != 1 {
|
||||
t.Fatalf("summary: captured=%d failed=%d", res.Captured, res.Failed)
|
||||
}
|
||||
if _, ok := res.Values["b"]; ok {
|
||||
t.Errorf("failed parameter must not appear in values")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,700 @@
|
||||
package confmgr
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// ErrNotFound is returned when the requested set or instance does not exist.
|
||||
var ErrNotFound = errors.New("config object not found")
|
||||
|
||||
// Kind selects which collection a store operation targets.
|
||||
type Kind int
|
||||
|
||||
const (
|
||||
KindSet Kind = iota
|
||||
KindInstance
|
||||
KindRule
|
||||
)
|
||||
|
||||
func (k Kind) sub() string {
|
||||
switch k {
|
||||
case KindInstance:
|
||||
return "instances"
|
||||
case KindRule:
|
||||
return "rules"
|
||||
default:
|
||||
return "sets"
|
||||
}
|
||||
}
|
||||
|
||||
// Meta is the lightweight listing representation.
|
||||
type Meta struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Version int `json:"version"`
|
||||
// SetID is only populated for instances (the schema they belong to); it is
|
||||
// empty for sets. Lets clients group/filter instances by set without a GET
|
||||
// per instance.
|
||||
SetID string `json:"setId,omitempty"`
|
||||
// Enabled is only populated for rules: nil for sets/instances and for legacy
|
||||
// rules without the flag. Lets the rule list show enabled/disabled status
|
||||
// without a GET per rule.
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
// Owner/Scope/Groups carry the visibility metadata so list handlers can filter
|
||||
// by the caller without a GET per object. Empty scope = global (legacy-safe).
|
||||
Owner string `json:"owner,omitempty"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
Groups []string `json:"groups,omitempty"`
|
||||
}
|
||||
|
||||
// VersionMeta describes a single persisted revision.
|
||||
type VersionMeta struct {
|
||||
Version int `json:"version"`
|
||||
Name string `json:"name"`
|
||||
Tag string `json:"tag,omitempty"`
|
||||
Current bool `json:"current"`
|
||||
SavedAt time.Time `json:"savedAt"`
|
||||
}
|
||||
|
||||
// Store persists configuration sets and instances as versioned JSON files
|
||||
// under {storageDir}/configs/{sets,instances}/. Each object lives in
|
||||
// {id}.json with prior revisions preserved as {id}.v{N}.json backups, exactly
|
||||
// mirroring the panel storage versioning scheme.
|
||||
type Store struct {
|
||||
rootDir string
|
||||
dirs map[Kind]string
|
||||
}
|
||||
|
||||
// New opens (and creates, if needed) the configs storage directories.
|
||||
func New(storageDir string) (*Store, error) {
|
||||
s := &Store{rootDir: storageDir, dirs: map[Kind]string{}}
|
||||
for _, k := range []Kind{KindSet, KindInstance, KindRule} {
|
||||
dir := filepath.Join(storageDir, "configs", k.sub())
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create %s dir: %w", k.sub(), err)
|
||||
}
|
||||
s.dirs[k] = dir
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// header parses the metadata fields shared by sets and instances.
|
||||
type header struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Version int `json:"version"`
|
||||
Tag string `json:"tag"`
|
||||
SetID string `json:"setId"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Owner string `json:"owner"`
|
||||
Scope string `json:"scope"`
|
||||
Groups []string `json:"groups"`
|
||||
}
|
||||
|
||||
func validateID(id string) error {
|
||||
if id == "" {
|
||||
return fmt.Errorf("config ID must not be empty")
|
||||
}
|
||||
for _, r := range id {
|
||||
if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '-' && r != '_' {
|
||||
return fmt.Errorf("invalid character %q in config ID", r)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) filePath(k Kind, id string) string {
|
||||
return filepath.Join(s.dirs[k], id+".json")
|
||||
}
|
||||
|
||||
func (s *Store) backupPath(k Kind, id string, version int) string {
|
||||
return filepath.Join(s.dirs[k], fmt.Sprintf("%s.v%d.json", id, version))
|
||||
}
|
||||
|
||||
// isVersioned reports whether name matches <id>.v<number>.json.
|
||||
func isVersioned(name string) bool {
|
||||
parts := strings.Split(name, ".")
|
||||
if len(parts) != 3 || parts[2] != "json" {
|
||||
return false
|
||||
}
|
||||
if !strings.HasPrefix(parts[1], "v") {
|
||||
return false
|
||||
}
|
||||
_, err := strconv.Atoi(parts[1][1:])
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func readHeader(data []byte) (header, error) {
|
||||
var h header
|
||||
if err := json.Unmarshal(data, &h); err != nil {
|
||||
return header{}, err
|
||||
}
|
||||
return h, nil
|
||||
}
|
||||
|
||||
// List returns metadata for every stored object of the given kind.
|
||||
func (s *Store) List(k Kind) ([]Meta, error) {
|
||||
entries, err := os.ReadDir(s.dirs[k])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := []Meta{}
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if e.IsDir() || !strings.HasSuffix(name, ".json") || isVersioned(name) {
|
||||
continue
|
||||
}
|
||||
id := strings.TrimSuffix(name, ".json")
|
||||
data, err := os.ReadFile(s.filePath(k, id))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
h, err := readHeader(data)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, Meta{ID: id, Name: h.Name, Version: h.Version, SetID: h.SetID, Enabled: h.Enabled, Owner: h.Owner, Scope: h.Scope, Groups: h.Groups})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// get returns the raw JSON bytes for the current revision.
|
||||
func (s *Store) get(k Kind, id string) ([]byte, error) {
|
||||
if err := validateID(id); err != nil {
|
||||
return nil, fmt.Errorf("%w: %v", ErrNotFound, err)
|
||||
}
|
||||
data, err := os.ReadFile(s.filePath(k, id))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return data, err
|
||||
}
|
||||
|
||||
// create writes a new object, deriving a unique ID from name when obj has none,
|
||||
// stamping version=1 and the given tag. It returns the raw stored bytes.
|
||||
func (s *Store) create(k Kind, obj map[string]any, tag string) (string, []byte, error) {
|
||||
id, _ := obj["id"].(string)
|
||||
if id == "" {
|
||||
id = slugify(name(obj))
|
||||
}
|
||||
if err := validateID(id); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
if _, err := os.Stat(s.filePath(k, id)); err == nil {
|
||||
id = id + "-" + strconv.FormatInt(time.Now().UnixMilli(), 10)
|
||||
}
|
||||
obj["id"] = id
|
||||
obj["version"] = 1
|
||||
if tag != "" {
|
||||
obj["tag"] = tag
|
||||
}
|
||||
data, err := marshal(obj)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return id, data, os.WriteFile(s.filePath(k, id), data, 0o644)
|
||||
}
|
||||
|
||||
// update replaces the current revision, preserving the prior one as a backup
|
||||
// and incrementing the version. It returns the raw stored bytes.
|
||||
func (s *Store) update(k Kind, id string, obj map[string]any, tag string) ([]byte, error) {
|
||||
if err := validateID(id); err != nil {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
oldData, err := os.ReadFile(s.filePath(k, id))
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
oldHdr, err := readHeader(oldData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse existing JSON: %w", err)
|
||||
}
|
||||
if oldHdr.Version < 1 {
|
||||
oldHdr.Version = 1
|
||||
}
|
||||
if err := os.WriteFile(s.backupPath(k, id, oldHdr.Version), oldData, 0o644); err != nil {
|
||||
return nil, fmt.Errorf("create backup: %w", err)
|
||||
}
|
||||
obj["id"] = id
|
||||
obj["version"] = oldHdr.Version + 1
|
||||
if tag != "" {
|
||||
obj["tag"] = tag
|
||||
}
|
||||
data, err := marshal(obj)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return data, os.WriteFile(s.filePath(k, id), data, 0o644)
|
||||
}
|
||||
|
||||
// Versions returns metadata for every persisted revision, newest-first.
|
||||
func (s *Store) Versions(k Kind, id string) ([]VersionMeta, error) {
|
||||
if err := validateID(id); err != nil {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
curInfo, err := os.Stat(s.filePath(k, id))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, ErrNotFound
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
curData, err := os.ReadFile(s.filePath(k, id))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
curHdr, err := readHeader(curData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := []VersionMeta{{
|
||||
Version: curHdr.Version,
|
||||
Name: curHdr.Name,
|
||||
Tag: curHdr.Tag,
|
||||
Current: true,
|
||||
SavedAt: curInfo.ModTime(),
|
||||
}}
|
||||
|
||||
entries, err := os.ReadDir(s.dirs[k])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prefix := id + ".v"
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if e.IsDir() || !strings.HasPrefix(name, prefix) || !isVersioned(name) {
|
||||
continue
|
||||
}
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
data, err := os.ReadFile(filepath.Join(s.dirs[k], name))
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
h, err := readHeader(data)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, VersionMeta{
|
||||
Version: h.Version,
|
||||
Name: h.Name,
|
||||
Tag: h.Tag,
|
||||
SavedAt: info.ModTime(),
|
||||
})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Version > out[j].Version })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// getVersion returns the raw JSON bytes for a specific revision.
|
||||
func (s *Store) getVersion(k Kind, id string, version int) ([]byte, error) {
|
||||
if err := validateID(id); err != nil {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
curData, err := os.ReadFile(s.filePath(k, id))
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if h, err := readHeader(curData); err == nil && h.Version == version {
|
||||
return curData, nil
|
||||
}
|
||||
data, err := os.ReadFile(s.backupPath(k, id, version))
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return data, err
|
||||
}
|
||||
|
||||
// Promote re-saves a past revision as a new revision on top of history.
|
||||
func (s *Store) Promote(k Kind, id string, version int) error {
|
||||
data, err := s.getVersion(k, id, version)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
obj, err := unmarshal(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.update(k, id, obj, fmt.Sprintf("restored from v%d", version))
|
||||
return err
|
||||
}
|
||||
|
||||
// Fork creates a brand-new object from a revision, with a fresh ID and version 1.
|
||||
func (s *Store) Fork(k Kind, id string, version int) (string, error) {
|
||||
data, err := s.getVersion(k, id, version)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
obj, err := unmarshal(data)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
newID := id + "-fork-" + strconv.FormatInt(time.Now().UnixMilli(), 10)
|
||||
obj["id"] = newID
|
||||
obj["version"] = 1
|
||||
delete(obj, "tag")
|
||||
out, err := marshal(obj)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return newID, os.WriteFile(s.filePath(k, newID), out, 0o644)
|
||||
}
|
||||
|
||||
// Delete moves an object and all its backups to a timestamped trash folder so
|
||||
// it can be recovered. Nothing is destroyed.
|
||||
func (s *Store) Delete(k Kind, id string) error {
|
||||
if err := validateID(id); err != nil {
|
||||
return ErrNotFound
|
||||
}
|
||||
if _, err := os.Stat(s.filePath(k, id)); err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return ErrNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
trashDir := filepath.Join(s.rootDir, "trash", "configs", k.sub(), fmt.Sprintf("%s.%d", id, time.Now().UnixMilli()))
|
||||
if err := os.MkdirAll(trashDir, 0o755); err != nil {
|
||||
return fmt.Errorf("create trash dir: %w", err)
|
||||
}
|
||||
entries, err := os.ReadDir(s.dirs[k])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
if name == id+".json" || (strings.HasPrefix(name, id+".v") && isVersioned(name)) {
|
||||
if err := os.Rename(filepath.Join(s.dirs[k], name), filepath.Join(trashDir, name)); err != nil {
|
||||
return fmt.Errorf("move %s to trash: %w", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ── typed wrappers ──────────────────────────────────────────────────────────
|
||||
|
||||
// GetSet returns the current revision of a config set.
|
||||
func (s *Store) GetSet(id string) (ConfigSet, error) {
|
||||
data, err := s.get(KindSet, id)
|
||||
if err != nil {
|
||||
return ConfigSet{}, err
|
||||
}
|
||||
var set ConfigSet
|
||||
return set, json.Unmarshal(data, &set)
|
||||
}
|
||||
|
||||
// GetSetVersion returns a specific revision of a config set.
|
||||
func (s *Store) GetSetVersion(id string, version int) (ConfigSet, error) {
|
||||
data, err := s.getVersion(KindSet, id, version)
|
||||
if err != nil {
|
||||
return ConfigSet{}, err
|
||||
}
|
||||
var set ConfigSet
|
||||
return set, json.Unmarshal(data, &set)
|
||||
}
|
||||
|
||||
// CreateSet validates and stores a new config set, returning it with its
|
||||
// assigned ID and version.
|
||||
func (s *Store) CreateSet(set ConfigSet, tag string) (ConfigSet, error) {
|
||||
if err := set.Validate(); err != nil {
|
||||
return ConfigSet{}, err
|
||||
}
|
||||
obj, err := toMap(set)
|
||||
if err != nil {
|
||||
return ConfigSet{}, err
|
||||
}
|
||||
_, data, err := s.create(KindSet, obj, tag)
|
||||
if err != nil {
|
||||
return ConfigSet{}, err
|
||||
}
|
||||
var out ConfigSet
|
||||
return out, json.Unmarshal(data, &out)
|
||||
}
|
||||
|
||||
// UpdateSet validates and stores a new revision of an existing config set.
|
||||
func (s *Store) UpdateSet(id string, set ConfigSet, tag string) (ConfigSet, error) {
|
||||
if err := set.Validate(); err != nil {
|
||||
return ConfigSet{}, err
|
||||
}
|
||||
obj, err := toMap(set)
|
||||
if err != nil {
|
||||
return ConfigSet{}, err
|
||||
}
|
||||
data, err := s.update(KindSet, id, obj, tag)
|
||||
if err != nil {
|
||||
return ConfigSet{}, err
|
||||
}
|
||||
var out ConfigSet
|
||||
return out, json.Unmarshal(data, &out)
|
||||
}
|
||||
|
||||
// GetInstance returns the current revision of a config instance.
|
||||
func (s *Store) GetInstance(id string) (ConfigInstance, error) {
|
||||
data, err := s.get(KindInstance, id)
|
||||
if err != nil {
|
||||
return ConfigInstance{}, err
|
||||
}
|
||||
var inst ConfigInstance
|
||||
return inst, json.Unmarshal(data, &inst)
|
||||
}
|
||||
|
||||
// GetInstanceVersion returns a specific revision of a config instance.
|
||||
func (s *Store) GetInstanceVersion(id string, version int) (ConfigInstance, error) {
|
||||
data, err := s.getVersion(KindInstance, id, version)
|
||||
if err != nil {
|
||||
return ConfigInstance{}, err
|
||||
}
|
||||
var inst ConfigInstance
|
||||
return inst, json.Unmarshal(data, &inst)
|
||||
}
|
||||
|
||||
// setForInstance loads the schema an instance is bound to, honouring a pinned
|
||||
// SetVersion when set.
|
||||
func (s *Store) setForInstance(inst ConfigInstance) (ConfigSet, error) {
|
||||
if inst.SetVersion > 0 {
|
||||
return s.GetSetVersion(inst.SetID, inst.SetVersion)
|
||||
}
|
||||
return s.GetSet(inst.SetID)
|
||||
}
|
||||
|
||||
// CreateInstance validates an instance against its set and stores it.
|
||||
func (s *Store) CreateInstance(inst ConfigInstance, tag string) (ConfigInstance, error) {
|
||||
set, err := s.setForInstance(inst)
|
||||
if err != nil {
|
||||
return ConfigInstance{}, fmt.Errorf("load set %q: %w", inst.SetID, err)
|
||||
}
|
||||
if err := inst.ValidateAgainst(set); err != nil {
|
||||
return ConfigInstance{}, err
|
||||
}
|
||||
if err := s.applyRules(&inst, set); err != nil {
|
||||
return ConfigInstance{}, err
|
||||
}
|
||||
obj, err := toMap(inst)
|
||||
if err != nil {
|
||||
return ConfigInstance{}, err
|
||||
}
|
||||
_, data, err := s.create(KindInstance, obj, tag)
|
||||
if err != nil {
|
||||
return ConfigInstance{}, err
|
||||
}
|
||||
var out ConfigInstance
|
||||
return out, json.Unmarshal(data, &out)
|
||||
}
|
||||
|
||||
// UpdateInstance validates and stores a new revision of an existing instance.
|
||||
func (s *Store) UpdateInstance(id string, inst ConfigInstance, tag string) (ConfigInstance, error) {
|
||||
set, err := s.setForInstance(inst)
|
||||
if err != nil {
|
||||
return ConfigInstance{}, fmt.Errorf("load set %q: %w", inst.SetID, err)
|
||||
}
|
||||
if err := inst.ValidateAgainst(set); err != nil {
|
||||
return ConfigInstance{}, err
|
||||
}
|
||||
if err := s.applyRules(&inst, set); err != nil {
|
||||
return ConfigInstance{}, err
|
||||
}
|
||||
obj, err := toMap(inst)
|
||||
if err != nil {
|
||||
return ConfigInstance{}, err
|
||||
}
|
||||
data, err := s.update(KindInstance, id, obj, tag)
|
||||
if err != nil {
|
||||
return ConfigInstance{}, err
|
||||
}
|
||||
var out ConfigInstance
|
||||
return out, json.Unmarshal(data, &out)
|
||||
}
|
||||
|
||||
// SetForInstance is the exported loader used by apply/diff callers.
|
||||
func (s *Store) SetForInstance(inst ConfigInstance) (ConfigSet, error) {
|
||||
return s.setForInstance(inst)
|
||||
}
|
||||
|
||||
// ── rules ────────────────────────────────────────────────────────────────────
|
||||
|
||||
// GetRule returns the current revision of a CUE validation/transformation rule.
|
||||
func (s *Store) GetRule(id string) (ConfigRule, error) {
|
||||
data, err := s.get(KindRule, id)
|
||||
if err != nil {
|
||||
return ConfigRule{}, err
|
||||
}
|
||||
var rule ConfigRule
|
||||
return rule, json.Unmarshal(data, &rule)
|
||||
}
|
||||
|
||||
// GetRuleVersion returns a specific revision of a rule.
|
||||
func (s *Store) GetRuleVersion(id string, version int) (ConfigRule, error) {
|
||||
data, err := s.getVersion(KindRule, id, version)
|
||||
if err != nil {
|
||||
return ConfigRule{}, err
|
||||
}
|
||||
var rule ConfigRule
|
||||
return rule, json.Unmarshal(data, &rule)
|
||||
}
|
||||
|
||||
// CreateRule validates the rule's CUE source and stores it.
|
||||
func (s *Store) CreateRule(rule ConfigRule, tag string) (ConfigRule, error) {
|
||||
if err := rule.Validate(); err != nil {
|
||||
return ConfigRule{}, err
|
||||
}
|
||||
obj, err := toMap(rule)
|
||||
if err != nil {
|
||||
return ConfigRule{}, err
|
||||
}
|
||||
_, data, err := s.create(KindRule, obj, tag)
|
||||
if err != nil {
|
||||
return ConfigRule{}, err
|
||||
}
|
||||
var out ConfigRule
|
||||
return out, json.Unmarshal(data, &out)
|
||||
}
|
||||
|
||||
// UpdateRule validates and stores a new revision of an existing rule.
|
||||
func (s *Store) UpdateRule(id string, rule ConfigRule, tag string) (ConfigRule, error) {
|
||||
if err := rule.Validate(); err != nil {
|
||||
return ConfigRule{}, err
|
||||
}
|
||||
obj, err := toMap(rule)
|
||||
if err != nil {
|
||||
return ConfigRule{}, err
|
||||
}
|
||||
data, err := s.update(KindRule, id, obj, tag)
|
||||
if err != nil {
|
||||
return ConfigRule{}, err
|
||||
}
|
||||
var out ConfigRule
|
||||
return out, json.Unmarshal(data, &out)
|
||||
}
|
||||
|
||||
// rulesForSet loads every current-revision rule bound to the given set.
|
||||
func (s *Store) rulesForSet(setID string) ([]ConfigRule, error) {
|
||||
metas, err := s.List(KindRule)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []ConfigRule
|
||||
for _, m := range metas {
|
||||
if m.SetID != setID {
|
||||
continue
|
||||
}
|
||||
rule, err := s.GetRule(m.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if !rule.IsEnabled() {
|
||||
continue
|
||||
}
|
||||
out = append(out, rule)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// applyRules runs every rule bound to inst's set against its values. On
|
||||
// success it merges rule-derived values back into inst (parameter keys only) so
|
||||
// the stored instance reflects the canonical, transformed configuration. A
|
||||
// violation is returned as *RuleError so the caller can surface the structured
|
||||
// details.
|
||||
func (s *Store) applyRules(inst *ConfigInstance, set ConfigSet) error {
|
||||
rules, err := s.rulesForSet(inst.SetID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(rules) == 0 {
|
||||
return nil
|
||||
}
|
||||
res := evaluateRules(rules, inst.Values)
|
||||
if !res.OK {
|
||||
return &RuleError{Result: res}
|
||||
}
|
||||
for k, v := range res.Transformed {
|
||||
if _, ok := set.param(k); ok {
|
||||
if inst.Values == nil {
|
||||
inst.Values = map[string]any{}
|
||||
}
|
||||
inst.Values[k] = v
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateInstanceRules evaluates the stored rules for an instance without
|
||||
// persisting anything. It is the read-only counterpart used by the validate
|
||||
// endpoint.
|
||||
func (s *Store) ValidateInstanceRules(inst ConfigInstance) (RuleResult, error) {
|
||||
rules, err := s.rulesForSet(inst.SetID)
|
||||
if err != nil {
|
||||
return RuleResult{}, err
|
||||
}
|
||||
if len(rules) == 0 {
|
||||
return RuleResult{OK: true}, nil
|
||||
}
|
||||
return evaluateRules(rules, inst.Values), nil
|
||||
}
|
||||
|
||||
// ── JSON helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
func name(obj map[string]any) string {
|
||||
n, _ := obj["name"].(string)
|
||||
return n
|
||||
}
|
||||
|
||||
func marshal(obj map[string]any) ([]byte, error) {
|
||||
return json.MarshalIndent(obj, "", " ")
|
||||
}
|
||||
|
||||
func unmarshal(data []byte) (map[string]any, error) {
|
||||
var obj map[string]any
|
||||
if err := json.Unmarshal(data, &obj); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return obj, nil
|
||||
}
|
||||
|
||||
// toMap round-trips a typed value through JSON into a generic map so the store
|
||||
// can stamp id/version/tag without knowing the concrete type.
|
||||
func toMap(v any) (map[string]any, error) {
|
||||
data, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return unmarshal(data)
|
||||
}
|
||||
|
||||
// slugify converts a human-readable name into a URL-safe identifier.
|
||||
func slugify(s string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range strings.ToLower(s) {
|
||||
switch {
|
||||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
|
||||
b.WriteRune(r)
|
||||
default:
|
||||
if b.Len() > 0 && b.String()[b.Len()-1] != '-' {
|
||||
b.WriteByte('-')
|
||||
}
|
||||
}
|
||||
}
|
||||
result := strings.Trim(b.String(), "-")
|
||||
if result == "" {
|
||||
return "config"
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package confmgr
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestValidateIDRejectsBadChars covers the invalid-character branch of
|
||||
// validateID, surfaced through the typed getters as ErrNotFound.
|
||||
func TestValidateIDRejectsBadChars(t *testing.T) {
|
||||
s, _ := New(t.TempDir())
|
||||
for _, bad := range []string{"", "bad/slash", "has space", "dot.dot"} {
|
||||
if _, err := s.GetSet(bad); err == nil {
|
||||
t.Errorf("GetSet(%q): want error", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNotFoundPaths covers the ErrNotFound branches across the revision API.
|
||||
func TestNotFoundPaths(t *testing.T) {
|
||||
s, _ := New(t.TempDir())
|
||||
|
||||
if _, err := s.GetSet("missing"); err != ErrNotFound {
|
||||
t.Errorf("GetSet missing: want ErrNotFound, got %v", err)
|
||||
}
|
||||
if _, err := s.GetSetVersion("missing", 1); err != ErrNotFound {
|
||||
t.Errorf("GetSetVersion missing: want ErrNotFound, got %v", err)
|
||||
}
|
||||
if _, err := s.Versions(KindSet, "missing"); err != ErrNotFound {
|
||||
t.Errorf("Versions missing: want ErrNotFound, got %v", err)
|
||||
}
|
||||
if err := s.Promote(KindSet, "missing", 1); err != ErrNotFound {
|
||||
t.Errorf("Promote missing: want ErrNotFound, got %v", err)
|
||||
}
|
||||
if _, err := s.Fork(KindSet, "missing", 1); err != ErrNotFound {
|
||||
t.Errorf("Fork missing: want ErrNotFound, got %v", err)
|
||||
}
|
||||
if _, err := s.UpdateSet("missing", sampleSet(), ""); err != ErrNotFound {
|
||||
t.Errorf("UpdateSet missing: want ErrNotFound, got %v", err)
|
||||
}
|
||||
|
||||
// A valid id that exists but a version that was never written.
|
||||
created, _ := s.CreateSet(sampleSet(), "")
|
||||
if _, err := s.GetSetVersion(created.ID, 99); err != ErrNotFound {
|
||||
t.Errorf("GetSetVersion bogus version: want ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInstancePinnedSetVersion covers the SetVersion>0 branch of setForInstance:
|
||||
// the instance is validated against a specific (pinned) revision of its set.
|
||||
func TestInstancePinnedSetVersion(t *testing.T) {
|
||||
s, _ := New(t.TempDir())
|
||||
set, _ := s.CreateSet(sampleSet(), "")
|
||||
|
||||
// Bump the set to v2 so a distinct earlier revision exists.
|
||||
if _, err := s.UpdateSet(set.ID, sampleSet(), "v2"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
inst := ConfigInstance{
|
||||
Name: "pinned",
|
||||
SetID: set.ID,
|
||||
SetVersion: 1, // pin to the original schema revision
|
||||
Values: map[string]any{"voltage": 24.0},
|
||||
}
|
||||
out, err := s.CreateInstance(inst, "")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateInstance pinned: %v", err)
|
||||
}
|
||||
if out.SetVersion != 1 {
|
||||
t.Errorf("SetVersion: want 1, got %d", out.SetVersion)
|
||||
}
|
||||
|
||||
// Updating the pinned instance also exercises the pinned UpdateInstance path.
|
||||
out.Values["voltage"] = 30.0
|
||||
v2, err := s.UpdateInstance(out.ID, out, "bump")
|
||||
if err != nil {
|
||||
t.Fatalf("UpdateInstance pinned: %v", err)
|
||||
}
|
||||
if v2.Version != 2 {
|
||||
t.Errorf("instance version: want 2, got %d", v2.Version)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpdateInstanceMissingSet covers the load-set error branch of
|
||||
// UpdateInstance (set referenced by the instance does not exist).
|
||||
func TestUpdateInstanceMissingSet(t *testing.T) {
|
||||
s, _ := New(t.TempDir())
|
||||
inst := ConfigInstance{Name: "x", SetID: "nope", Values: map[string]any{}}
|
||||
if _, err := s.UpdateInstance("anything", inst, ""); err == nil {
|
||||
t.Error("UpdateInstance with missing set: want error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestListSkipsVersionedFiles checks List returns only current revisions and
|
||||
// reflects metadata after updates.
|
||||
func TestListSkipsVersionedFiles(t *testing.T) {
|
||||
s, _ := New(t.TempDir())
|
||||
a, _ := s.CreateSet(sampleSet(), "")
|
||||
b, _ := s.CreateSet(sampleSet(), "")
|
||||
// Create backups for a so the dir holds versioned files too.
|
||||
_, _ = s.UpdateSet(a.ID, sampleSet(), "")
|
||||
_, _ = s.UpdateSet(a.ID, sampleSet(), "")
|
||||
|
||||
metas, err := s.List(KindSet)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(metas) != 2 {
|
||||
t.Fatalf("List: want 2 current sets, got %d", len(metas))
|
||||
}
|
||||
ids := map[string]bool{}
|
||||
for _, m := range metas {
|
||||
ids[m.ID] = true
|
||||
}
|
||||
if !ids[a.ID] || !ids[b.ID] {
|
||||
t.Errorf("List missing expected ids: %v", ids)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
package confmgr
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func fptr(f float64) *float64 { return &f }
|
||||
|
||||
func sampleSet() ConfigSet {
|
||||
return ConfigSet{
|
||||
Name: "PSU",
|
||||
Description: "power supply config",
|
||||
Parameters: []Parameter{
|
||||
{Key: "voltage", DS: "epics", Signal: "PSU:V", Type: TypeFloat, Default: 12.0, Mandatory: true, Min: fptr(0), Max: fptr(48)},
|
||||
{Key: "enabled", DS: "epics", Signal: "PSU:EN", Type: TypeBool, Default: false},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateAndGetSet(t *testing.T) {
|
||||
s, err := New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := s.CreateSet(sampleSet(), "initial")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.ID == "" {
|
||||
t.Fatal("expected generated ID")
|
||||
}
|
||||
if out.Version != 1 {
|
||||
t.Errorf("version: want 1, got %d", out.Version)
|
||||
}
|
||||
got, err := s.GetSet(out.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Name != "PSU" || len(got.Parameters) != 2 {
|
||||
t.Errorf("round-trip mismatch: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuleBlocksInvalidInstance(t *testing.T) {
|
||||
s, _ := New(t.TempDir())
|
||||
set, _ := s.CreateSet(sampleSet(), "")
|
||||
if _, err := s.CreateRule(ConfigRule{
|
||||
Name: "voltage cap",
|
||||
SetID: set.ID,
|
||||
Source: "voltage: <=24",
|
||||
}, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// In-range value passes.
|
||||
if _, err := s.CreateInstance(ConfigInstance{
|
||||
Name: "ok", SetID: set.ID, Values: map[string]any{"voltage": 20.0},
|
||||
}, ""); err != nil {
|
||||
t.Fatalf("expected in-range instance to save, got %v", err)
|
||||
}
|
||||
|
||||
// Out-of-range value is rejected by the rule.
|
||||
_, err := s.CreateInstance(ConfigInstance{
|
||||
Name: "bad", SetID: set.ID, Values: map[string]any{"voltage": 40.0},
|
||||
}, "")
|
||||
if err == nil {
|
||||
t.Fatalf("expected rule violation to block save")
|
||||
}
|
||||
var re *RuleError
|
||||
if !asRuleError(err, &re) {
|
||||
t.Fatalf("expected *RuleError, got %T: %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuleTransformPersists(t *testing.T) {
|
||||
s, _ := New(t.TempDir())
|
||||
set := sampleSet()
|
||||
max := 48.0
|
||||
set.Parameters = append(set.Parameters, Parameter{Key: "vmax", DS: "epics", Signal: "PSU:VMAX", Type: TypeFloat, Min: &max})
|
||||
created, _ := s.CreateSet(set, "")
|
||||
// Rule derives vmax = voltage * 2 (within range for voltage=20 -> 40).
|
||||
if _, err := s.CreateRule(ConfigRule{
|
||||
Name: "vmax derive", SetID: created.ID, Source: "voltage: number\nvmax: voltage * 2",
|
||||
}, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
inst, err := s.CreateInstance(ConfigInstance{
|
||||
Name: "x", SetID: created.ID, Values: map[string]any{"voltage": 20.0},
|
||||
}, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f, _ := toFloat(inst.Values["vmax"]); f != 40 {
|
||||
t.Fatalf("expected derived vmax=40 to persist, got %v", inst.Values["vmax"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisabledRuleSkipped(t *testing.T) {
|
||||
s, _ := New(t.TempDir())
|
||||
set, _ := s.CreateSet(sampleSet(), "")
|
||||
disabled := false
|
||||
if _, err := s.CreateRule(ConfigRule{
|
||||
Name: "voltage cap",
|
||||
SetID: set.ID,
|
||||
Enabled: &disabled,
|
||||
Source: "voltage: <=24",
|
||||
}, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A value that would violate the rule still saves because the rule is off.
|
||||
if _, err := s.CreateInstance(ConfigInstance{
|
||||
Name: "bad", SetID: set.ID, Values: map[string]any{"voltage": 40.0},
|
||||
}, ""); err != nil {
|
||||
t.Fatalf("expected disabled rule to be skipped, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func asRuleError(err error, target **RuleError) bool {
|
||||
for err != nil {
|
||||
if re, ok := err.(*RuleError); ok {
|
||||
*target = re
|
||||
return true
|
||||
}
|
||||
type unwrapper interface{ Unwrap() error }
|
||||
u, ok := err.(unwrapper)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
err = u.Unwrap()
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func TestSetVersioning(t *testing.T) {
|
||||
s, _ := New(t.TempDir())
|
||||
created, _ := s.CreateSet(sampleSet(), "")
|
||||
id := created.ID
|
||||
|
||||
// Update -> v2.
|
||||
upd := sampleSet()
|
||||
upd.Description = "edited"
|
||||
v2, err := s.UpdateSet(id, upd, "edit")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v2.Version != 2 {
|
||||
t.Fatalf("version after update: want 2, got %d", v2.Version)
|
||||
}
|
||||
|
||||
versions, err := s.Versions(KindSet, id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(versions) != 2 {
|
||||
t.Fatalf("want 2 versions, got %d", len(versions))
|
||||
}
|
||||
if !versions[0].Current || versions[0].Version != 2 {
|
||||
t.Errorf("newest should be current v2: %+v", versions[0])
|
||||
}
|
||||
|
||||
// Old version still retrievable.
|
||||
old, err := s.GetSetVersion(id, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if old.Description != "power supply config" {
|
||||
t.Errorf("v1 description changed: %q", old.Description)
|
||||
}
|
||||
|
||||
// Promote v1 -> becomes v3 with original content.
|
||||
if err := s.Promote(KindSet, id, 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cur, _ := s.GetSet(id)
|
||||
if cur.Version != 3 || cur.Description != "power supply config" {
|
||||
t.Errorf("after promote: want v3 original desc, got v%d %q", cur.Version, cur.Description)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForkSet(t *testing.T) {
|
||||
s, _ := New(t.TempDir())
|
||||
created, _ := s.CreateSet(sampleSet(), "")
|
||||
_, _ = s.UpdateSet(created.ID, sampleSet(), "")
|
||||
|
||||
newID, err := s.Fork(KindSet, created.ID, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
forked, err := s.GetSet(newID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if forked.ID == created.ID {
|
||||
t.Error("fork should have a new ID")
|
||||
}
|
||||
if forked.Version != 1 {
|
||||
t.Errorf("fork version: want 1, got %d", forked.Version)
|
||||
}
|
||||
if forked.Tag != "" {
|
||||
t.Errorf("fork tag should be cleared, got %q", forked.Tag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteSetSoftDeletes(t *testing.T) {
|
||||
s, _ := New(t.TempDir())
|
||||
created, _ := s.CreateSet(sampleSet(), "")
|
||||
if err := s.Delete(KindSet, created.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.GetSet(created.ID); err == nil {
|
||||
t.Error("expected set to be gone after delete")
|
||||
}
|
||||
// Deleting again is a not-found.
|
||||
if err := s.Delete(KindSet, created.ID); err != ErrNotFound {
|
||||
t.Errorf("want ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetValidation(t *testing.T) {
|
||||
s, _ := New(t.TempDir())
|
||||
bad := ConfigSet{Name: "", Parameters: nil}
|
||||
if _, err := s.CreateSet(bad, ""); err == nil {
|
||||
t.Error("expected empty-name validation error")
|
||||
}
|
||||
dup := ConfigSet{Name: "x", Parameters: []Parameter{
|
||||
{Key: "a", DS: "d", Signal: "s", Type: TypeFloat},
|
||||
{Key: "a", DS: "d", Signal: "s2", Type: TypeFloat},
|
||||
}}
|
||||
if _, err := s.CreateSet(dup, ""); err == nil {
|
||||
t.Error("expected duplicate-key validation error")
|
||||
}
|
||||
defaultOOR := ConfigSet{Name: "x", Parameters: []Parameter{
|
||||
{Key: "a", DS: "d", Signal: "s", Type: TypeFloat, Default: 100.0, Max: fptr(10)},
|
||||
}}
|
||||
if _, err := s.CreateSet(defaultOOR, ""); err == nil {
|
||||
t.Error("expected out-of-range default validation error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstanceLifecycle(t *testing.T) {
|
||||
s, _ := New(t.TempDir())
|
||||
set, _ := s.CreateSet(sampleSet(), "")
|
||||
|
||||
inst := ConfigInstance{
|
||||
Name: "nominal",
|
||||
SetID: set.ID,
|
||||
Values: map[string]any{"voltage": 24.0, "enabled": true},
|
||||
}
|
||||
out, err := s.CreateInstance(inst, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Version != 1 {
|
||||
t.Errorf("instance version: want 1, got %d", out.Version)
|
||||
}
|
||||
|
||||
// Update value -> v2.
|
||||
out.Values["voltage"] = 36.0
|
||||
v2, err := s.UpdateInstance(out.ID, out, "bump")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if v2.Version != 2 {
|
||||
t.Errorf("instance version after update: want 2, got %d", v2.Version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstanceValidation(t *testing.T) {
|
||||
s, _ := New(t.TempDir())
|
||||
set, _ := s.CreateSet(sampleSet(), "")
|
||||
|
||||
// voltage is mandatory with a default, so omitting it is OK; but an
|
||||
// out-of-range value must fail.
|
||||
bad := ConfigInstance{Name: "x", SetID: set.ID, Values: map[string]any{"voltage": 999.0}}
|
||||
if _, err := s.CreateInstance(bad, ""); err == nil {
|
||||
t.Error("expected out-of-range value error")
|
||||
}
|
||||
|
||||
// Unknown parameter key must fail.
|
||||
unknown := ConfigInstance{Name: "x", SetID: set.ID, Values: map[string]any{"nope": 1.0}}
|
||||
if _, err := s.CreateInstance(unknown, ""); err == nil {
|
||||
t.Error("expected unknown-parameter error")
|
||||
}
|
||||
|
||||
// Unknown set must fail.
|
||||
missing := ConfigInstance{Name: "x", SetID: "does-not-exist", Values: map[string]any{}}
|
||||
if _, err := s.CreateInstance(missing, ""); err == nil {
|
||||
t.Error("expected missing-set error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
package controllogic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/uopi/uopi/internal/audit"
|
||||
"github.com/uopi/uopi/internal/broker"
|
||||
"github.com/uopi/uopi/internal/confmgr"
|
||||
"github.com/uopi/uopi/internal/datasource"
|
||||
)
|
||||
|
||||
// writableSource is a minimal in-memory DataSource that records the last value
|
||||
// written to each signal, so config-apply/read nodes can be tested end-to-end.
|
||||
type writableSource struct {
|
||||
mu sync.Mutex
|
||||
written map[string]any
|
||||
}
|
||||
|
||||
func newWritableSource() *writableSource { return &writableSource{written: map[string]any{}} }
|
||||
|
||||
func (s *writableSource) Name() string { return "tgt" }
|
||||
func (s *writableSource) Connect(context.Context) error { return nil }
|
||||
func (s *writableSource) ListSignals(context.Context) ([]datasource.Metadata, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *writableSource) GetMetadata(_ context.Context, sig string) (datasource.Metadata, error) {
|
||||
return datasource.Metadata{Name: sig, Writable: true}, nil
|
||||
}
|
||||
|
||||
// Subscribe delivers the signal's last-written value once (if any), so a
|
||||
// one-shot ReadNow (used by config snapshot) resolves immediately.
|
||||
func (s *writableSource) Subscribe(_ context.Context, sig string, ch chan<- datasource.Value) (datasource.CancelFunc, error) {
|
||||
s.mu.Lock()
|
||||
v, ok := s.written[sig]
|
||||
s.mu.Unlock()
|
||||
if ok {
|
||||
select {
|
||||
case ch <- datasource.Value{Data: v, Timestamp: time.Now()}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
return func() {}, nil
|
||||
}
|
||||
func (s *writableSource) Write(_ context.Context, signal string, value any) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.written[signal] = value
|
||||
return nil
|
||||
}
|
||||
func (s *writableSource) History(context.Context, string, time.Time, time.Time, int) ([]datasource.Value, error) {
|
||||
return nil, datasource.ErrHistoryUnavailable
|
||||
}
|
||||
|
||||
func (s *writableSource) get(signal string) (any, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
v, ok := s.written[signal]
|
||||
return v, ok
|
||||
}
|
||||
|
||||
// setupConfigEngine wires a broker (with a writable "tgt" source), a confmgr
|
||||
// store seeded with a set + instance, and an Engine bound to both.
|
||||
func setupConfigEngine(t *testing.T) (*Engine, *writableSource, string) {
|
||||
t.Helper()
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
ctx := context.Background()
|
||||
|
||||
src := newWritableSource()
|
||||
brk := broker.New(ctx, log)
|
||||
brk.Register(src)
|
||||
|
||||
cfg, err := confmgr.New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal("confmgr.New:", err)
|
||||
}
|
||||
set, err := cfg.CreateSet(confmgr.ConfigSet{
|
||||
Name: "tuning",
|
||||
Parameters: []confmgr.Parameter{
|
||||
{Key: "gain", DS: "tgt", Signal: "GAIN", Type: confmgr.TypeFloat, Default: 1.0},
|
||||
{Key: "offset", DS: "tgt", Signal: "OFFSET", Type: confmgr.TypeFloat, Default: 0.0},
|
||||
},
|
||||
}, "")
|
||||
if err != nil {
|
||||
t.Fatal("CreateSet:", err)
|
||||
}
|
||||
inst, err := cfg.CreateInstance(confmgr.ConfigInstance{
|
||||
Name: "warm",
|
||||
SetID: set.ID,
|
||||
Values: map[string]any{"gain": 2.5, "offset": 10.0},
|
||||
}, "")
|
||||
if err != nil {
|
||||
t.Fatal("CreateInstance:", err)
|
||||
}
|
||||
|
||||
e := NewEngine(ctx, brk, nil, cfg, audit.Nop(), log)
|
||||
return e, src, inst.ID
|
||||
}
|
||||
|
||||
func TestApplyConfigWritesEveryParam(t *testing.T) {
|
||||
e, src, instID := setupConfigEngine(t)
|
||||
cg := &compiledGraph{name: "flow", engine: e, locals: map[string]float64{}}
|
||||
|
||||
e.applyConfig(cg, instID)
|
||||
|
||||
if got, ok := src.get("GAIN"); !ok || toNum(got) != 2.5 {
|
||||
t.Errorf("GAIN = %v (ok=%v), want 2.5", got, ok)
|
||||
}
|
||||
if got, ok := src.get("OFFSET"); !ok || toNum(got) != 10.0 {
|
||||
t.Errorf("OFFSET = %v (ok=%v), want 10.0", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyConfigUnknownInstanceNoop(t *testing.T) {
|
||||
e, src, _ := setupConfigEngine(t)
|
||||
cg := &compiledGraph{name: "flow", engine: e, locals: map[string]float64{}}
|
||||
|
||||
e.applyConfig(cg, "does-not-exist")
|
||||
|
||||
if len(src.written) != 0 {
|
||||
t.Errorf("expected no writes, got %v", src.written)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadConfigParam(t *testing.T) {
|
||||
e, _, instID := setupConfigEngine(t)
|
||||
|
||||
if v, ok := e.readConfigParam(instID, "gain"); !ok || v != 2.5 {
|
||||
t.Errorf("readConfigParam(gain) = %v (ok=%v), want 2.5", v, ok)
|
||||
}
|
||||
// Missing param key.
|
||||
if _, ok := e.readConfigParam(instID, "nope"); ok {
|
||||
t.Errorf("readConfigParam(nope) returned ok=true, want false")
|
||||
}
|
||||
// Missing instance.
|
||||
if _, ok := e.readConfigParam("does-not-exist", "gain"); ok {
|
||||
t.Errorf("readConfigParam(missing instance) returned ok=true, want false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteConfigParam(t *testing.T) {
|
||||
e, _, instID := setupConfigEngine(t)
|
||||
cg := &compiledGraph{name: "flow", engine: e, locals: map[string]float64{}}
|
||||
|
||||
e.writeConfigParam(cg, instID, "gain", 7.5)
|
||||
|
||||
// A new revision should now resolve to the written value.
|
||||
if v, ok := e.readConfigParam(instID, "gain"); !ok || v != 7.5 {
|
||||
t.Errorf("after write, gain = %v (ok=%v), want 7.5", v, ok)
|
||||
}
|
||||
inst, err := e.cfg.GetInstance(instID)
|
||||
if err != nil {
|
||||
t.Fatal("GetInstance:", err)
|
||||
}
|
||||
if inst.Version < 2 {
|
||||
t.Errorf("instance version = %d, want >= 2 (a new revision)", inst.Version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateConfigInstance(t *testing.T) {
|
||||
e, _, srcID := setupConfigEngine(t)
|
||||
cg := &compiledGraph{name: "flow", engine: e, locals: map[string]float64{}}
|
||||
|
||||
src, err := e.cfg.GetInstance(srcID)
|
||||
if err != nil {
|
||||
t.Fatal("GetInstance:", err)
|
||||
}
|
||||
|
||||
e.createConfigInstance(cg, src.SetID, "cold", srcID)
|
||||
|
||||
insts, err := e.cfg.List(confmgr.KindInstance)
|
||||
if err != nil {
|
||||
t.Fatal("List:", err)
|
||||
}
|
||||
var found *confmgr.ConfigInstance
|
||||
for i := range insts {
|
||||
if insts[i].Name == "cold" {
|
||||
full, err := e.cfg.GetInstance(insts[i].ID)
|
||||
if err != nil {
|
||||
t.Fatal("GetInstance:", err)
|
||||
}
|
||||
found = &full
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
t.Fatal("created instance 'cold' not found")
|
||||
}
|
||||
// Values copied from the source instance.
|
||||
if got := toNum(found.Values["gain"]); got != 2.5 {
|
||||
t.Errorf("copied gain = %v, want 2.5", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnapshotConfig(t *testing.T) {
|
||||
e, src, instID := setupConfigEngine(t)
|
||||
cg := &compiledGraph{name: "flow", engine: e, locals: map[string]float64{}}
|
||||
|
||||
// Seed current live values for the set's target signals.
|
||||
_ = src.Write(context.Background(), "GAIN", 3.5)
|
||||
_ = src.Write(context.Background(), "OFFSET", -2.0)
|
||||
|
||||
seed, err := e.cfg.GetInstance(instID)
|
||||
if err != nil {
|
||||
t.Fatal("GetInstance:", err)
|
||||
}
|
||||
e.snapshotConfig(cg, seed.SetID, "snap1")
|
||||
|
||||
insts, err := e.cfg.List(confmgr.KindInstance)
|
||||
if err != nil {
|
||||
t.Fatal("List:", err)
|
||||
}
|
||||
var found *confmgr.ConfigInstance
|
||||
for i := range insts {
|
||||
if insts[i].Name == "snap1" {
|
||||
full, err := e.cfg.GetInstance(insts[i].ID)
|
||||
if err != nil {
|
||||
t.Fatal("GetInstance:", err)
|
||||
}
|
||||
found = &full
|
||||
}
|
||||
}
|
||||
if found == nil {
|
||||
t.Fatal("snapshot instance 'snap1' not found")
|
||||
}
|
||||
if got := toNum(found.Values["gain"]); got != 3.5 {
|
||||
t.Errorf("snapshot gain = %v, want 3.5 (current live value)", got)
|
||||
}
|
||||
if got := toNum(found.Values["offset"]); got != -2.0 {
|
||||
t.Errorf("snapshot offset = %v, want -2.0 (current live value)", got)
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,12 @@
|
||||
// Fields, in order: minute hour day-of-month month day-of-week.
|
||||
// Each field supports:
|
||||
//
|
||||
// * any value
|
||||
// */n every n (step over the whole range)
|
||||
// a-b inclusive range
|
||||
// a-b/n range with step
|
||||
// a,b,c comma-separated list of the above
|
||||
// N a single value
|
||||
// - any value
|
||||
// */n every n (step over the whole range)
|
||||
// a-b inclusive range
|
||||
// a-b/n range with step
|
||||
// a,b,c comma-separated list of the above
|
||||
// N a single value
|
||||
//
|
||||
// Day-of-week is 0-6 with 0 = Sunday (7 is also accepted as Sunday). When both
|
||||
// day-of-month and day-of-week are restricted (neither is "*"), the schedule
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
package controllogic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/uopi/uopi/internal/broker"
|
||||
)
|
||||
|
||||
// DebugEvent reports a single node execution for the live debug view. Value is
|
||||
// only meaningful when HasValue is true (e.g. an action.write's written value or
|
||||
// a flow.if branch as 0/1); otherwise the event just marks the node as active.
|
||||
type DebugEvent struct {
|
||||
GraphID string `json:"graphId"`
|
||||
NodeID string `json:"nodeId"`
|
||||
Value float64 `json:"value"`
|
||||
HasValue bool `json:"hasValue"`
|
||||
TS int64 `json:"ts"` // unix millis
|
||||
}
|
||||
|
||||
// DebugObserver receives node-execution events from running (and simulated)
|
||||
// graphs. The server implements it; Observe must not block (the hub fans out
|
||||
// drop-on-full so a slow editor never stalls the engine).
|
||||
type DebugObserver interface {
|
||||
Observe(DebugEvent)
|
||||
}
|
||||
|
||||
// debugObsBox wraps a DebugObserver so atomic.Value always sees one type.
|
||||
type debugObsBox struct{ o DebugObserver }
|
||||
|
||||
// SetDebugObserver installs the sink for node-execution events. Safe to call
|
||||
// once at startup; read lock-free by running flows.
|
||||
func (e *Engine) SetDebugObserver(o DebugObserver) {
|
||||
e.debugObs.Store(debugObsBox{o: o})
|
||||
}
|
||||
|
||||
// SetDebugWatch replaces the set of graph ids with at least one live debug
|
||||
// subscriber. emitDebug short-circuits for graphs absent from this set, so the
|
||||
// common (nobody watching) case costs a single atomic load. The hub owns the
|
||||
// map and must not mutate it after publishing (it is read without a lock).
|
||||
func (e *Engine) SetDebugWatch(ids map[string]bool) {
|
||||
e.debugWatch.Store(ids)
|
||||
}
|
||||
|
||||
// registerFire records a compiled graph's manual-fire channel under its route id
|
||||
// (live graph id or simulate sandbox id).
|
||||
func (e *Engine) registerFire(id string, ch chan string) {
|
||||
e.fireMu.Lock()
|
||||
e.fireChs[id] = ch
|
||||
e.fireMu.Unlock()
|
||||
}
|
||||
|
||||
// unregisterFire removes id's fire channel, but only if it still points at ch —
|
||||
// so a newer generation that reused the same id (live reload) is not clobbered
|
||||
// by the old generation's teardown.
|
||||
func (e *Engine) unregisterFire(id string, ch chan string) {
|
||||
e.fireMu.Lock()
|
||||
if e.fireChs[id] == ch {
|
||||
delete(e.fireChs, id)
|
||||
}
|
||||
e.fireMu.Unlock()
|
||||
}
|
||||
|
||||
// FireTrigger asks the graph behind a debug route (graphID) to run triggerID's
|
||||
// flow now, as if the trigger had fired. Non-blocking and best-effort: returns
|
||||
// false if the route is gone or its fire buffer is full. The receiving graph
|
||||
// validates that triggerID is actually one of its trigger nodes.
|
||||
func (e *Engine) FireTrigger(graphID, triggerID string) bool {
|
||||
e.fireMu.Lock()
|
||||
ch := e.fireChs[graphID]
|
||||
e.fireMu.Unlock()
|
||||
if ch == nil || triggerID == "" {
|
||||
return false
|
||||
}
|
||||
select {
|
||||
case ch <- triggerID:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// emitDebug reports a node execution to the observer when the graph is watched
|
||||
// (or the graph is a simulate sandbox, which is always its own subscriber).
|
||||
func (cg *compiledGraph) emitDebug(nodeID string, value float64, hasValue bool) {
|
||||
if !cg.alwaysDebug {
|
||||
w, _ := cg.engine.debugWatch.Load().(map[string]bool)
|
||||
if !w[cg.id] {
|
||||
return
|
||||
}
|
||||
}
|
||||
box, _ := cg.engine.debugObs.Load().(debugObsBox)
|
||||
if box.o == nil {
|
||||
return
|
||||
}
|
||||
box.o.Observe(DebugEvent{
|
||||
GraphID: cg.id,
|
||||
NodeID: nodeID,
|
||||
Value: value,
|
||||
HasValue: hasValue,
|
||||
TS: time.Now().UnixMilli(),
|
||||
})
|
||||
}
|
||||
|
||||
// StartSimulate runs g in a throwaway sandbox generation: real side effects are
|
||||
// suppressed (data-source writes, config mutations, dialogs) but local vars,
|
||||
// triggers, timers and the flow itself execute normally, emitting debug events
|
||||
// the editor can visualise. It is independent of Reload's live generation. The
|
||||
// returned stop func cancels the sandbox and waits for its goroutines to drain;
|
||||
// it is safe to call more than once.
|
||||
func (e *Engine) StartSimulate(g Graph) func() {
|
||||
cg := compile(g)
|
||||
cg.engine = e
|
||||
cg.dryRun = true
|
||||
cg.alwaysDebug = true
|
||||
|
||||
ctx, cancel := context.WithCancel(e.root)
|
||||
wg := &sync.WaitGroup{}
|
||||
cg.genCtx = ctx
|
||||
cg.wg = wg
|
||||
|
||||
// Sandbox-local live cache so simulate reads don't disturb the live engine.
|
||||
updates := make(chan broker.Update, 128)
|
||||
var unsubs []func()
|
||||
for _, r := range cg.refs {
|
||||
unsub, err := e.broker.Subscribe(broker.SignalRef{DS: r.DS, Name: r.Name}, updates)
|
||||
if err != nil {
|
||||
e.log.Warn("control logic simulate: subscribe failed", "ds", r.DS, "signal", r.Name, "err", err)
|
||||
continue
|
||||
}
|
||||
unsubs = append(unsubs, unsub)
|
||||
}
|
||||
|
||||
e.registerFire(cg.id, cg.fireCh)
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
<-ctx.Done()
|
||||
for _, u := range unsubs {
|
||||
u()
|
||||
}
|
||||
e.unregisterFire(cg.id, cg.fireCh)
|
||||
}()
|
||||
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for {
|
||||
select {
|
||||
case u := <-updates:
|
||||
val := toNum(u.Value.Data)
|
||||
key := refKey(u.Ref.DS, u.Ref.Name)
|
||||
e.liveMu.Lock()
|
||||
e.live[key] = val
|
||||
e.liveMu.Unlock()
|
||||
cg.onSignal(key, val)
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
cg.startTriggers()
|
||||
|
||||
var once sync.Once
|
||||
return func() {
|
||||
once.Do(func() {
|
||||
cancel()
|
||||
wg.Wait()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package controllogic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// fakeObserver records every DebugEvent it receives, in order.
|
||||
type fakeObserver struct {
|
||||
mu sync.Mutex
|
||||
events []DebugEvent
|
||||
}
|
||||
|
||||
func (f *fakeObserver) Observe(ev DebugEvent) {
|
||||
f.mu.Lock()
|
||||
f.events = append(f.events, ev)
|
||||
f.mu.Unlock()
|
||||
}
|
||||
|
||||
func (f *fakeObserver) snapshot() []DebugEvent {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return append([]DebugEvent(nil), f.events...)
|
||||
}
|
||||
|
||||
func (f *fakeObserver) last(nodeID string) (DebugEvent, bool) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
for i := len(f.events) - 1; i >= 0; i-- {
|
||||
if f.events[i].NodeID == nodeID {
|
||||
return f.events[i], true
|
||||
}
|
||||
}
|
||||
return DebugEvent{}, false
|
||||
}
|
||||
|
||||
// thresholdWriteGraph is a 2-node flow: a timer trigger → an action.write of 42
|
||||
// to "tgt:OUT". Used by both the observe and simulate tests.
|
||||
func thresholdWriteGraph(id string) Graph {
|
||||
return Graph{
|
||||
ID: id,
|
||||
Name: "flow",
|
||||
Nodes: []Node{
|
||||
{ID: "t", Kind: "trigger.timer", Params: map[string]string{"interval": "100"}},
|
||||
{ID: "w", Kind: "action.write", Params: map[string]string{"target": "tgt:OUT", "expr": "42"}},
|
||||
},
|
||||
Wires: []Wire{{From: "t", To: "w"}},
|
||||
}
|
||||
}
|
||||
|
||||
// TestDebugObserverCapturesNodeValues drives a flow directly and asserts the
|
||||
// observer saw the trigger fire and the write node's value — and that the real
|
||||
// data-source write still happened (live mode, not dry-run).
|
||||
func TestDebugObserverCapturesNodeValues(t *testing.T) {
|
||||
e, src, _ := setupConfigEngine(t)
|
||||
obs := &fakeObserver{}
|
||||
e.SetDebugObserver(obs)
|
||||
e.SetDebugWatch(map[string]bool{"g1": true})
|
||||
|
||||
cg := compile(thresholdWriteGraph("g1"))
|
||||
cg.engine = e
|
||||
cg.genCtx = context.Background()
|
||||
cg.wg = &sync.WaitGroup{}
|
||||
|
||||
cg.activate("t")
|
||||
cg.wg.Wait()
|
||||
|
||||
events := obs.snapshot()
|
||||
if len(events) == 0 {
|
||||
t.Fatal("observer captured no events")
|
||||
}
|
||||
// Trigger then write must both be reported.
|
||||
if _, ok := obs.last("t"); !ok {
|
||||
t.Error("no event for trigger node 't'")
|
||||
}
|
||||
wEv, ok := obs.last("w")
|
||||
if !ok {
|
||||
t.Fatal("no event for write node 'w'")
|
||||
}
|
||||
if !wEv.HasValue || wEv.Value != 42 {
|
||||
t.Errorf("write node event = %+v, want value 42 hasValue true", wEv)
|
||||
}
|
||||
if wEv.GraphID != "g1" {
|
||||
t.Errorf("event GraphID = %q, want g1", wEv.GraphID)
|
||||
}
|
||||
// Live mode: the real write went through.
|
||||
if got, ok := src.get("OUT"); !ok || toNum(got) != 42 {
|
||||
t.Errorf("OUT = %v (ok=%v), want 42 written", got, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// manualWriteGraph is a flow whose trigger never fires on its own (a threshold
|
||||
// with no signal wired) → an action.write of 42 to "tgt:OUT". Used to prove
|
||||
// FireTrigger forces a run that would otherwise never happen.
|
||||
func manualWriteGraph(id string) Graph {
|
||||
return Graph{
|
||||
ID: id,
|
||||
Name: "flow",
|
||||
Nodes: []Node{
|
||||
{ID: "t", Kind: "trigger.threshold", Params: map[string]string{"op": ">", "value": "0"}},
|
||||
{ID: "w", Kind: "action.write", Params: map[string]string{"target": "tgt:OUT", "expr": "42"}},
|
||||
},
|
||||
Wires: []Wire{{From: "t", To: "w"}},
|
||||
}
|
||||
}
|
||||
|
||||
// TestFireTriggerForcesRun verifies FireTrigger drives a flow whose trigger
|
||||
// never fires on its own, routed through the simulate sandbox.
|
||||
func TestFireTriggerForcesRun(t *testing.T) {
|
||||
e, _, _ := setupConfigEngine(t)
|
||||
obs := &fakeObserver{}
|
||||
e.SetDebugObserver(obs)
|
||||
e.SetDebugWatch(map[string]bool{})
|
||||
|
||||
stop := e.StartSimulate(manualWriteGraph("sim"))
|
||||
defer stop()
|
||||
|
||||
// Nothing should have run yet — the threshold trigger has no signal.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
if _, ok := obs.last("w"); ok {
|
||||
t.Fatal("write node ran before any manual fire")
|
||||
}
|
||||
|
||||
if !e.FireTrigger("sim", "t") {
|
||||
t.Fatal("FireTrigger returned false for an active simulate route")
|
||||
}
|
||||
|
||||
// Poll for the write node event (the flow runs on its own goroutine).
|
||||
var wEv DebugEvent
|
||||
for i := 0; i < 100; i++ {
|
||||
if ev, ok := obs.last("w"); ok {
|
||||
wEv = ev
|
||||
break
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
if !wEv.HasValue || wEv.Value != 42 {
|
||||
t.Errorf("write node event = %+v, want value 42 hasValue true", wEv)
|
||||
}
|
||||
|
||||
// An unknown route id must be a no-op (best-effort, returns false).
|
||||
if e.FireTrigger("does-not-exist", "t") {
|
||||
t.Error("FireTrigger returned true for an unknown route")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDebugWatchGatesEmission verifies emitDebug is silent for graphs absent
|
||||
// from the watch set, so unwatched live graphs cost nothing.
|
||||
func TestDebugWatchGatesEmission(t *testing.T) {
|
||||
e, _, _ := setupConfigEngine(t)
|
||||
obs := &fakeObserver{}
|
||||
e.SetDebugObserver(obs)
|
||||
e.SetDebugWatch(map[string]bool{}) // nobody watching
|
||||
|
||||
cg := compile(thresholdWriteGraph("g1"))
|
||||
cg.engine = e
|
||||
cg.genCtx = context.Background()
|
||||
cg.wg = &sync.WaitGroup{}
|
||||
|
||||
cg.activate("t")
|
||||
cg.wg.Wait()
|
||||
|
||||
if got := obs.snapshot(); len(got) != 0 {
|
||||
t.Errorf("observer received %d events for an unwatched graph, want 0", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// TestSimulateSuppressesWrites runs the graph through the dry-run sandbox and
|
||||
// asserts node events are still emitted (alwaysDebug) but NO real write occurs.
|
||||
func TestSimulateSuppressesWrites(t *testing.T) {
|
||||
e, src, _ := setupConfigEngine(t)
|
||||
obs := &fakeObserver{}
|
||||
e.SetDebugObserver(obs)
|
||||
// Deliberately leave the watch set empty: simulate must emit regardless.
|
||||
e.SetDebugWatch(map[string]bool{})
|
||||
|
||||
stop := e.StartSimulate(thresholdWriteGraph("sim"))
|
||||
time.Sleep(250 * time.Millisecond) // let the 100 ms timer fire at least once
|
||||
stop()
|
||||
|
||||
if _, ok := src.get("OUT"); ok {
|
||||
t.Errorf("simulate performed a real write to OUT, want none")
|
||||
}
|
||||
wEv, ok := obs.last("w")
|
||||
if !ok {
|
||||
t.Fatal("simulate produced no event for write node 'w'")
|
||||
}
|
||||
if !wEv.HasValue || wEv.Value != 42 {
|
||||
t.Errorf("simulate write event = %+v, want value 42 hasValue true", wEv)
|
||||
}
|
||||
if wEv.GraphID != "sim" {
|
||||
t.Errorf("simulate event GraphID = %q, want sim", wEv.GraphID)
|
||||
}
|
||||
}
|
||||
+435
-16
@@ -2,17 +2,38 @@ package controllogic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/uopi/uopi/internal/audit"
|
||||
"github.com/uopi/uopi/internal/broker"
|
||||
"github.com/uopi/uopi/internal/confmgr"
|
||||
)
|
||||
|
||||
// errUnknownSource is returned by config-apply writes targeting a data source
|
||||
// that isn't registered with the broker.
|
||||
var errUnknownSource = errors.New("unknown data source")
|
||||
|
||||
// formatAny renders a config value for the audit log.
|
||||
func formatAny(v any) string {
|
||||
switch x := v.(type) {
|
||||
case float64:
|
||||
return strconv.FormatFloat(x, 'g', -1, 64)
|
||||
case string:
|
||||
return x
|
||||
default:
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// Guards against runaway flows (cycles / pathological loops).
|
||||
const (
|
||||
maxSteps = 100000
|
||||
@@ -25,26 +46,66 @@ const (
|
||||
type Engine struct {
|
||||
broker *broker.Broker
|
||||
store *Store
|
||||
cfg *confmgr.Store
|
||||
audit audit.Recorder
|
||||
log *slog.Logger
|
||||
root context.Context
|
||||
|
||||
mu sync.Mutex
|
||||
cancel context.CancelFunc // cancels the current generation
|
||||
wg *sync.WaitGroup // tracks the current generation's goroutines
|
||||
mu sync.Mutex
|
||||
cancel context.CancelFunc // cancels the current generation
|
||||
wg *sync.WaitGroup // tracks the current generation's goroutines
|
||||
|
||||
// notifier delivers action.dialog requests to connected clients. Stored in
|
||||
// an atomic so flow goroutines can read it without taking e.mu (which Reload
|
||||
// holds while it waits for those same goroutines to drain).
|
||||
notifier atomic.Value // notifierBox
|
||||
|
||||
// debugObs receives per-node execution events for the live debug view, and
|
||||
// debugWatch is the set of graph ids with at least one live subscriber. Both
|
||||
// are read lock-free by flow goroutines (same trick as notifier); emitDebug
|
||||
// short-circuits cheaply when the firing graph has no watcher.
|
||||
debugObs atomic.Value // debugObsBox
|
||||
debugWatch atomic.Value // map[string]bool (immutable snapshot)
|
||||
|
||||
// fireChs maps a debug route id (live graph id or simulate sandbox id) to its
|
||||
// compiled graph's manual-fire channel, so the debug UI can force a trigger to
|
||||
// run. Entries are added/removed as generations (and simulate sandboxes) come
|
||||
// and go; FireTrigger does a non-blocking send so a torn-down graph drops.
|
||||
fireMu sync.Mutex
|
||||
fireChs map[string]chan string
|
||||
|
||||
// Shared live signal cache for the current generation (key "ds\0name").
|
||||
liveMu sync.RWMutex
|
||||
live map[string]float64
|
||||
}
|
||||
|
||||
// NewEngine creates an engine bound to root. Call Reload to start it.
|
||||
func NewEngine(root context.Context, brk *broker.Broker, store *Store, log *slog.Logger) *Engine {
|
||||
// notifierBox wraps a Notifier so atomic.Value always sees one concrete type.
|
||||
type notifierBox struct{ n Notifier }
|
||||
|
||||
// dialogSeq generates unique action.dialog ids across all graphs.
|
||||
var dialogSeq uint64
|
||||
|
||||
// SetNotifier installs the sink for action.dialog requests. Safe to call once
|
||||
// at startup before or after Reload; it is read lock-free by running flows.
|
||||
func (e *Engine) SetNotifier(n Notifier) {
|
||||
e.notifier.Store(notifierBox{n: n})
|
||||
}
|
||||
|
||||
// NewEngine creates an engine bound to root. Call Reload to start it. rec records
|
||||
// the writes performed by flows; pass audit.Nop() to disable auditing.
|
||||
func NewEngine(root context.Context, brk *broker.Broker, store *Store, cfg *confmgr.Store, rec audit.Recorder, log *slog.Logger) *Engine {
|
||||
if rec == nil {
|
||||
rec = audit.Nop()
|
||||
}
|
||||
return &Engine{
|
||||
broker: brk,
|
||||
store: store,
|
||||
log: log,
|
||||
root: root,
|
||||
live: map[string]float64{},
|
||||
broker: brk,
|
||||
store: store,
|
||||
cfg: cfg,
|
||||
audit: rec,
|
||||
log: log,
|
||||
root: root,
|
||||
live: map[string]float64{},
|
||||
fireChs: map[string]chan string{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,6 +202,7 @@ func (e *Engine) Reload() {
|
||||
cg.engine = e
|
||||
cg.genCtx = genCtx
|
||||
cg.wg = wg
|
||||
e.registerFire(cg.id, cg.fireCh)
|
||||
}
|
||||
|
||||
// One shared updates channel feeds a single dispatch goroutine; every
|
||||
@@ -163,6 +225,9 @@ func (e *Engine) Reload() {
|
||||
for _, u := range unsubs {
|
||||
u()
|
||||
}
|
||||
for _, cg := range compiled {
|
||||
e.unregisterFire(cg.id, cg.fireCh)
|
||||
}
|
||||
}()
|
||||
|
||||
// Dispatch goroutine: keep the live cache fresh and drive level/edge triggers.
|
||||
@@ -222,14 +287,284 @@ func (e *Engine) write(cg *compiledGraph, target string, val float64) {
|
||||
cg.setLocal(name, val)
|
||||
return
|
||||
}
|
||||
if cg.dryRun {
|
||||
return // simulate: no real data-source write
|
||||
}
|
||||
src, ok := e.broker.Source(ds)
|
||||
if !ok {
|
||||
e.log.Warn("control logic: write to unknown data source", "ds", ds, "signal", name)
|
||||
return
|
||||
}
|
||||
ev := audit.Event{
|
||||
Actor: cg.name,
|
||||
ActorType: audit.ActorSystem,
|
||||
Action: "signal.write",
|
||||
DS: ds,
|
||||
Signal: name,
|
||||
Value: strconv.FormatFloat(val, 'g', -1, 64),
|
||||
Detail: "control logic: " + cg.name,
|
||||
Outcome: audit.OutcomeOK,
|
||||
}
|
||||
if err := src.Write(e.root, name, val); err != nil {
|
||||
e.log.Warn("control logic: write failed", "ds", ds, "signal", name, "err", err)
|
||||
ev.Outcome = audit.OutcomeError
|
||||
ev.Error = err.Error()
|
||||
}
|
||||
e.audit.Record(ev)
|
||||
}
|
||||
|
||||
// applyConfig resolves a config instance + its set and writes every parameter
|
||||
// to its target signal via the owning data source (confmgr.Apply). Each write is
|
||||
// audited. A bare instance id or a missing config store is a no-op (logged).
|
||||
func (e *Engine) applyConfig(cg *compiledGraph, instanceID string) {
|
||||
if e.cfg == nil || instanceID == "" {
|
||||
return
|
||||
}
|
||||
inst, err := e.cfg.GetInstance(instanceID)
|
||||
if err != nil {
|
||||
e.log.Warn("control logic: config apply: unknown instance", "instance", instanceID, "err", err)
|
||||
return
|
||||
}
|
||||
set, err := e.cfg.SetForInstance(inst)
|
||||
if err != nil {
|
||||
e.log.Warn("control logic: config apply: set lookup failed", "instance", instanceID, "err", err)
|
||||
return
|
||||
}
|
||||
write := func(ds, signal string, value any) error {
|
||||
ev := audit.Event{
|
||||
Actor: cg.name,
|
||||
ActorType: audit.ActorSystem,
|
||||
Action: "signal.write",
|
||||
DS: ds,
|
||||
Signal: signal,
|
||||
Value: formatAny(value),
|
||||
Detail: "control logic config apply: " + inst.Name,
|
||||
Outcome: audit.OutcomeOK,
|
||||
}
|
||||
var werr error
|
||||
if ds == "local" {
|
||||
cg.setLocal(signal, toNum(value))
|
||||
} else if src, ok := e.broker.Source(ds); ok {
|
||||
werr = src.Write(e.root, signal, value)
|
||||
} else {
|
||||
werr = errUnknownSource
|
||||
}
|
||||
if werr != nil {
|
||||
ev.Outcome = audit.OutcomeError
|
||||
ev.Error = werr.Error()
|
||||
}
|
||||
e.audit.Record(ev)
|
||||
return werr
|
||||
}
|
||||
confmgr.Apply(set, inst, write)
|
||||
}
|
||||
|
||||
// readConfigParam resolves a single parameter's value from a config instance,
|
||||
// coercing it to float64 for use as a control-logic value. Returns ok=false if
|
||||
// the store/instance/param is missing or the value isn't numeric.
|
||||
func (e *Engine) readConfigParam(instanceID, key string) (float64, bool) {
|
||||
if e.cfg == nil || instanceID == "" || key == "" {
|
||||
return 0, false
|
||||
}
|
||||
inst, err := e.cfg.GetInstance(instanceID)
|
||||
if err != nil {
|
||||
e.log.Warn("control logic: config read: unknown instance", "instance", instanceID, "err", err)
|
||||
return 0, false
|
||||
}
|
||||
set, err := e.cfg.SetForInstance(inst)
|
||||
if err != nil {
|
||||
e.log.Warn("control logic: config read: set lookup failed", "instance", instanceID, "err", err)
|
||||
return 0, false
|
||||
}
|
||||
for _, p := range set.Parameters {
|
||||
if p.Key != key {
|
||||
continue
|
||||
}
|
||||
v, ok := inst.Resolve(p)
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
f := toNum(v)
|
||||
if math.IsNaN(f) {
|
||||
return 0, false
|
||||
}
|
||||
return f, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// writeConfigParam sets a single parameter's value on a config instance and
|
||||
// saves a new revision (git-style). The value is coerced to the parameter's
|
||||
// declared type (numeric/bool/string) so it validates against the set. A
|
||||
// missing store/instance/param is a no-op (logged). Audited.
|
||||
func (e *Engine) writeConfigParam(cg *compiledGraph, instanceID, key string, val float64) {
|
||||
if e.cfg == nil || instanceID == "" || key == "" {
|
||||
return
|
||||
}
|
||||
inst, err := e.cfg.GetInstance(instanceID)
|
||||
if err != nil {
|
||||
e.log.Warn("control logic: config write: unknown instance", "instance", instanceID, "err", err)
|
||||
return
|
||||
}
|
||||
set, err := e.cfg.SetForInstance(inst)
|
||||
if err != nil {
|
||||
e.log.Warn("control logic: config write: set lookup failed", "instance", instanceID, "err", err)
|
||||
return
|
||||
}
|
||||
var param *confmgr.Parameter
|
||||
for i := range set.Parameters {
|
||||
if set.Parameters[i].Key == key {
|
||||
param = &set.Parameters[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if param == nil {
|
||||
e.log.Warn("control logic: config write: unknown parameter", "instance", instanceID, "key", key)
|
||||
return
|
||||
}
|
||||
if inst.Values == nil {
|
||||
inst.Values = map[string]any{}
|
||||
}
|
||||
inst.Values[key] = coerceParamValue(*param, val)
|
||||
ev := audit.Event{
|
||||
Actor: cg.name,
|
||||
ActorType: audit.ActorSystem,
|
||||
Action: "config.instance.update",
|
||||
Detail: "control logic config write: " + inst.Name + " " + key + "=" + formatAny(inst.Values[key]),
|
||||
Outcome: audit.OutcomeOK,
|
||||
}
|
||||
if _, err := e.cfg.UpdateInstance(instanceID, inst, ""); err != nil {
|
||||
e.log.Warn("control logic: config write failed", "instance", instanceID, "key", key, "err", err)
|
||||
ev.Outcome = audit.OutcomeError
|
||||
ev.Error = err.Error()
|
||||
}
|
||||
e.audit.Record(ev)
|
||||
}
|
||||
|
||||
// createConfigInstance creates a new config instance for setID, optionally
|
||||
// copying values from an existing instance (fromID). A missing store/set is a
|
||||
// no-op (logged). Audited. The new instance's id is logged but not written back
|
||||
// to a flow variable (control-logic variables are numeric, ids are strings).
|
||||
func (e *Engine) createConfigInstance(cg *compiledGraph, setID, name, fromID string) {
|
||||
if e.cfg == nil || setID == "" {
|
||||
return
|
||||
}
|
||||
if name == "" {
|
||||
name = "auto"
|
||||
}
|
||||
values := map[string]any{}
|
||||
if fromID != "" {
|
||||
if src, err := e.cfg.GetInstance(fromID); err == nil {
|
||||
for k, v := range src.Values {
|
||||
values[k] = v
|
||||
}
|
||||
} else {
|
||||
e.log.Warn("control logic: config create: copy source missing", "from", fromID, "err", err)
|
||||
}
|
||||
}
|
||||
inst := confmgr.ConfigInstance{Name: name, SetID: setID, Values: values}
|
||||
ev := audit.Event{
|
||||
Actor: cg.name,
|
||||
ActorType: audit.ActorSystem,
|
||||
Action: "config.instance.create",
|
||||
Detail: "control logic config create: set=" + setID + " name=" + name,
|
||||
Outcome: audit.OutcomeOK,
|
||||
}
|
||||
out, err := e.cfg.CreateInstance(inst, "")
|
||||
if err != nil {
|
||||
e.log.Warn("control logic: config create failed", "set", setID, "err", err)
|
||||
ev.Outcome = audit.OutcomeError
|
||||
ev.Error = err.Error()
|
||||
} else {
|
||||
ev.Detail += " -> " + out.ID
|
||||
}
|
||||
e.audit.Record(ev)
|
||||
}
|
||||
|
||||
// snapshotConfig captures the current value of every target signal of a set and
|
||||
// stores them as a new config instance. A missing store/set is a no-op (logged).
|
||||
// Audited. The new instance's id is logged but not written back to a flow
|
||||
// variable (control-logic variables are numeric, ids are strings).
|
||||
func (e *Engine) snapshotConfig(cg *compiledGraph, setID, name string) {
|
||||
if e.cfg == nil || setID == "" {
|
||||
return
|
||||
}
|
||||
set, err := e.cfg.GetSet(setID)
|
||||
if err != nil {
|
||||
e.log.Warn("control logic: config snapshot: unknown set", "set", setID, "err", err)
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(e.root, 5*time.Second)
|
||||
defer cancel()
|
||||
read := func(ds, signal string) (any, error) {
|
||||
if ds == "local" {
|
||||
return cg.getLocal(signal), nil
|
||||
}
|
||||
v, err := e.broker.ReadNow(ctx, broker.SignalRef{DS: ds, Name: signal})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return v.Data, nil
|
||||
}
|
||||
snap := confmgr.Snapshot(set, read)
|
||||
if name == "" {
|
||||
name = set.Name + " snapshot"
|
||||
}
|
||||
inst := confmgr.ConfigInstance{Name: name, SetID: setID, Values: snap.Values}
|
||||
ev := audit.Event{
|
||||
Actor: cg.name,
|
||||
ActorType: audit.ActorSystem,
|
||||
Action: "config.instance.snapshot",
|
||||
Detail: "control logic config snapshot: set=" + setID + " name=" + name,
|
||||
Outcome: audit.OutcomeOK,
|
||||
}
|
||||
out, err := e.cfg.CreateInstance(inst, "")
|
||||
if err != nil {
|
||||
e.log.Warn("control logic: config snapshot failed", "set", setID, "err", err)
|
||||
ev.Outcome = audit.OutcomeError
|
||||
ev.Error = err.Error()
|
||||
} else {
|
||||
ev.Detail += " -> " + out.ID + " captured=" + strconv.Itoa(snap.Captured) + " failed=" + strconv.Itoa(snap.Failed)
|
||||
}
|
||||
e.audit.Record(ev)
|
||||
}
|
||||
|
||||
// coerceParamValue converts a numeric flow value to the Go type a config
|
||||
// parameter expects, so the resulting instance validates against its set.
|
||||
func coerceParamValue(p confmgr.Parameter, val float64) any {
|
||||
switch p.Type {
|
||||
case confmgr.TypeInt:
|
||||
return int64(val)
|
||||
case confmgr.TypeBool:
|
||||
return val != 0
|
||||
case confmgr.TypeString, confmgr.TypeEnum:
|
||||
return strconv.FormatFloat(val, 'g', -1, 64)
|
||||
default:
|
||||
return val
|
||||
}
|
||||
}
|
||||
|
||||
// emitDialog delivers an action.dialog node's request to the installed Notifier
|
||||
// (the WebSocket dialog hub). It is lock-free so it never deadlocks against a
|
||||
// concurrent Reload that is waiting for this flow goroutine to finish.
|
||||
func (e *Engine) emitDialog(n Node) {
|
||||
box, _ := e.notifier.Load().(notifierBox)
|
||||
if box.n == nil {
|
||||
return
|
||||
}
|
||||
kind := strings.TrimSpace(n.param("kind"))
|
||||
if kind != "error" && kind != "input" {
|
||||
kind = "info"
|
||||
}
|
||||
box.n.Notify(Dialog{
|
||||
ID: strconv.FormatUint(atomic.AddUint64(&dialogSeq, 1), 10),
|
||||
Kind: kind,
|
||||
Title: n.param("title"),
|
||||
Message: n.param("message"),
|
||||
Target: strings.TrimSpace(n.param("target")),
|
||||
Users: splitCSV(n.param("users")),
|
||||
Groups: splitCSV(n.param("groups")),
|
||||
})
|
||||
}
|
||||
|
||||
// ── compiled graph ─────────────────────────────────────────────────────────────
|
||||
@@ -245,15 +580,28 @@ type compiledGraph struct {
|
||||
genCtx context.Context
|
||||
wg *sync.WaitGroup
|
||||
|
||||
name string
|
||||
byId map[string]Node
|
||||
out map[string][]wireOut
|
||||
inc map[string][]string // incoming source ids per node (for gates)
|
||||
refs map[string]RefLite // unique signals to subscribe (excl. sys/local)
|
||||
// dryRun suppresses real side effects (data-source writes, config mutations,
|
||||
// dialogs) — used by the debug "simulate" sandbox. Local-variable writes stay
|
||||
// live so the flow still computes correctly. alwaysDebug forces emitDebug to
|
||||
// fire regardless of debugWatch (the sandbox is its own subscriber).
|
||||
dryRun bool
|
||||
alwaysDebug bool
|
||||
|
||||
id string
|
||||
name string
|
||||
byId map[string]Node
|
||||
out map[string][]wireOut
|
||||
inc map[string][]string // incoming source ids per node (for gates)
|
||||
refs map[string]RefLite // unique signals to subscribe (excl. sys/local)
|
||||
|
||||
watchers map[string][]string // signal key → trigger node ids
|
||||
luaNodes map[string]*luaRuntime
|
||||
|
||||
// fireCh receives node ids of triggers the debug UI wants to fire manually.
|
||||
// Drained by a generation goroutine (started in startTriggers) so the wg.Add
|
||||
// in activate always happens on a tracked goroutine.
|
||||
fireCh chan string
|
||||
|
||||
stateMu sync.Mutex
|
||||
levelState map[string]bool // current truth of level triggers (threshold/alarm)
|
||||
prevBool map[string]bool // edge detection for threshold/alarm
|
||||
@@ -265,6 +613,7 @@ type compiledGraph struct {
|
||||
|
||||
func compile(g Graph) *compiledGraph {
|
||||
cg := &compiledGraph{
|
||||
id: g.ID,
|
||||
name: g.Name,
|
||||
byId: map[string]Node{},
|
||||
out: map[string][]wireOut{},
|
||||
@@ -278,6 +627,7 @@ func compile(g Graph) *compiledGraph {
|
||||
hasVal: map[string]bool{},
|
||||
lastFire: map[string]int64{},
|
||||
locals: map[string]float64{},
|
||||
fireCh: make(chan string, 16),
|
||||
}
|
||||
for _, n := range g.Nodes {
|
||||
cg.byId[n.ID] = n
|
||||
@@ -350,6 +700,24 @@ func (cg *compiledGraph) getLocal(name string) float64 {
|
||||
|
||||
// startTriggers launches timer and cron trigger goroutines for the generation.
|
||||
func (cg *compiledGraph) startTriggers() {
|
||||
// Manual-fire listener: drain fireCh on a tracked goroutine so activate's
|
||||
// wg.Add never races the generation teardown's wg.Wait. Only fires nodes that
|
||||
// are actual triggers in this graph.
|
||||
cg.wg.Add(1)
|
||||
go func() {
|
||||
defer cg.wg.Done()
|
||||
for {
|
||||
select {
|
||||
case id := <-cg.fireCh:
|
||||
if n, ok := cg.byId[id]; ok && strings.HasPrefix(n.Kind, "trigger.") {
|
||||
cg.activate(id)
|
||||
}
|
||||
case <-cg.genCtx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
hasCron := false
|
||||
for _, n := range cg.byId {
|
||||
switch n.Kind {
|
||||
@@ -543,6 +911,8 @@ func (cg *compiledGraph) activate(triggerID string) {
|
||||
}
|
||||
}
|
||||
|
||||
cg.emitDebug(triggerID, 0, false)
|
||||
|
||||
cg.wg.Add(1)
|
||||
go func() {
|
||||
defer cg.wg.Done()
|
||||
@@ -575,6 +945,8 @@ func (cg *compiledGraph) run(nodeID string, ctx *runCtx) {
|
||||
default:
|
||||
}
|
||||
|
||||
cg.emitDebug(node.ID, 0, false)
|
||||
|
||||
switch node.Kind {
|
||||
case "gate.and":
|
||||
if cg.gateSatisfied(node.ID, ctx.fired) {
|
||||
@@ -583,9 +955,15 @@ func (cg *compiledGraph) run(nodeID string, ctx *runCtx) {
|
||||
|
||||
case "flow.if":
|
||||
branch := "else"
|
||||
if EvalBool(node.param("cond"), ctx.resolve) {
|
||||
pass := EvalBool(node.param("cond"), ctx.resolve)
|
||||
if pass {
|
||||
branch = "then"
|
||||
}
|
||||
v := 0.0
|
||||
if pass {
|
||||
v = 1
|
||||
}
|
||||
cg.emitDebug(node.ID, v, true)
|
||||
cg.follow(node.ID, branch, ctx)
|
||||
|
||||
case "flow.loop":
|
||||
@@ -609,9 +987,43 @@ func (cg *compiledGraph) run(nodeID string, ctx *runCtx) {
|
||||
|
||||
case "action.write":
|
||||
val := EvalExpr(node.param("expr"), ctx.resolve)
|
||||
cg.emitDebug(node.ID, val, true)
|
||||
cg.engine.write(cg, node.param("target"), val)
|
||||
cg.follow(node.ID, "out", ctx)
|
||||
|
||||
case "action.config.apply":
|
||||
if !cg.dryRun {
|
||||
cg.engine.applyConfig(cg, strings.TrimSpace(node.param("instance")))
|
||||
}
|
||||
cg.follow(node.ID, "out", ctx)
|
||||
|
||||
case "action.config.read":
|
||||
v, ok := cg.engine.readConfigParam(strings.TrimSpace(node.param("instance")), strings.TrimSpace(node.param("key")))
|
||||
if ok {
|
||||
cg.engine.write(cg, node.param("target"), v)
|
||||
}
|
||||
cg.follow(node.ID, "out", ctx)
|
||||
|
||||
case "action.config.write":
|
||||
val := EvalExpr(node.param("expr"), ctx.resolve)
|
||||
cg.emitDebug(node.ID, val, true)
|
||||
if !cg.dryRun {
|
||||
cg.engine.writeConfigParam(cg, strings.TrimSpace(node.param("instance")), strings.TrimSpace(node.param("key")), val)
|
||||
}
|
||||
cg.follow(node.ID, "out", ctx)
|
||||
|
||||
case "action.config.create":
|
||||
if !cg.dryRun {
|
||||
cg.engine.createConfigInstance(cg, strings.TrimSpace(node.param("set")), strings.TrimSpace(node.param("name")), strings.TrimSpace(node.param("from")))
|
||||
}
|
||||
cg.follow(node.ID, "out", ctx)
|
||||
|
||||
case "action.config.snapshot":
|
||||
if !cg.dryRun {
|
||||
cg.engine.snapshotConfig(cg, strings.TrimSpace(node.param("set")), strings.TrimSpace(node.param("name")))
|
||||
}
|
||||
cg.follow(node.ID, "out", ctx)
|
||||
|
||||
case "action.delay":
|
||||
ms := 0
|
||||
if v, err := strconv.Atoi(strings.TrimSpace(node.param("ms"))); err == nil && v > 0 {
|
||||
@@ -630,6 +1042,7 @@ func (cg *compiledGraph) run(nodeID string, ctx *runCtx) {
|
||||
|
||||
case "action.log":
|
||||
val := EvalExpr(node.param("expr"), ctx.resolve)
|
||||
cg.emitDebug(node.ID, val, true)
|
||||
label := strings.TrimSpace(node.param("label"))
|
||||
cg.engine.log.Info("control logic log", "graph", cg.name, "label", label, "value", val)
|
||||
cg.follow(node.ID, "out", ctx)
|
||||
@@ -638,6 +1051,12 @@ func (cg *compiledGraph) run(nodeID string, ctx *runCtx) {
|
||||
cg.runLua(node.ID, ctx)
|
||||
cg.follow(node.ID, "out", ctx)
|
||||
|
||||
case "action.dialog":
|
||||
if !cg.dryRun {
|
||||
cg.engine.emitDialog(node)
|
||||
}
|
||||
cg.follow(node.ID, "out", ctx)
|
||||
|
||||
default:
|
||||
cg.follow(node.ID, "out", ctx)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package controllogic
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/uopi/uopi/internal/confmgr"
|
||||
)
|
||||
|
||||
func TestFormatAny(t *testing.T) {
|
||||
cases := []struct {
|
||||
in any
|
||||
want string
|
||||
}{
|
||||
{3.5, "3.5"},
|
||||
{float64(42), "42"},
|
||||
{"hello", "hello"},
|
||||
{true, "true"},
|
||||
{int64(7), "7"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := formatAny(c.in); got != c.want {
|
||||
t.Errorf("formatAny(%v) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTestThreshold(t *testing.T) {
|
||||
cases := []struct {
|
||||
val float64
|
||||
op string
|
||||
cmp float64
|
||||
want bool
|
||||
}{
|
||||
{1, "<", 2, true},
|
||||
{3, "<", 2, false},
|
||||
{2, ">=", 2, true},
|
||||
{1, ">=", 2, false},
|
||||
{2, "<=", 2, true},
|
||||
{3, "<=", 2, false},
|
||||
{2, "==", 2, true},
|
||||
{2, "!=", 3, true},
|
||||
{5, ">", 2, true}, // default branch
|
||||
{1, "", 0, true}, // empty op → ">"
|
||||
{math.NaN(), "<", 1, false}, // NaN never satisfies
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := testThreshold(c.val, c.op, c.cmp); got != c.want {
|
||||
t.Errorf("testThreshold(%v,%q,%v) = %v, want %v", c.val, c.op, c.cmp, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseFloat(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want float64
|
||||
}{
|
||||
{"3.14", 3.14},
|
||||
{" 10 ", 10},
|
||||
{"", 0},
|
||||
{"not-a-number", 0},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := parseFloat(c.in); got != c.want {
|
||||
t.Errorf("parseFloat(%q) = %v, want %v", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCoerceParamValue(t *testing.T) {
|
||||
if v := coerceParamValue(confmgr.Parameter{Type: confmgr.TypeInt}, 3.9); v != int64(3) {
|
||||
t.Errorf("int coerce = %v, want 3", v)
|
||||
}
|
||||
if v := coerceParamValue(confmgr.Parameter{Type: confmgr.TypeBool}, 0); v != false {
|
||||
t.Errorf("bool 0 = %v, want false", v)
|
||||
}
|
||||
if v := coerceParamValue(confmgr.Parameter{Type: confmgr.TypeBool}, 1); v != true {
|
||||
t.Errorf("bool 1 = %v, want true", v)
|
||||
}
|
||||
if v := coerceParamValue(confmgr.Parameter{Type: confmgr.TypeString}, 2.5); v != "2.5" {
|
||||
t.Errorf("string coerce = %v, want \"2.5\"", v)
|
||||
}
|
||||
if v := coerceParamValue(confmgr.Parameter{Type: confmgr.TypeFloat}, 1.25); v != 1.25 {
|
||||
t.Errorf("float coerce = %v, want 1.25", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSign(t *testing.T) {
|
||||
if sign(5) != 1 || sign(-5) != -1 || sign(0) != 0 {
|
||||
t.Errorf("sign mismatch: %d %d %d", sign(5), sign(-5), sign(0))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package controllogic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/uopi/uopi/internal/audit"
|
||||
"github.com/uopi/uopi/internal/broker"
|
||||
"github.com/uopi/uopi/internal/datasource"
|
||||
)
|
||||
|
||||
// pushSource is a DataSource whose Subscribe captures the broker's delivery
|
||||
// channel per signal, letting a test push successive values to drive level/edge
|
||||
// triggers. Writes are recorded so action.write effects can be asserted.
|
||||
type pushSource struct {
|
||||
mu sync.Mutex
|
||||
chans map[string]chan<- datasource.Value
|
||||
written map[string]any
|
||||
}
|
||||
|
||||
func newPushSource() *pushSource {
|
||||
return &pushSource{chans: map[string]chan<- datasource.Value{}, written: map[string]any{}}
|
||||
}
|
||||
|
||||
func (s *pushSource) Name() string { return "tgt" }
|
||||
func (s *pushSource) Connect(context.Context) error { return nil }
|
||||
func (s *pushSource) ListSignals(context.Context) ([]datasource.Metadata, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (s *pushSource) GetMetadata(_ context.Context, sig string) (datasource.Metadata, error) {
|
||||
return datasource.Metadata{Name: sig, Writable: true}, nil
|
||||
}
|
||||
func (s *pushSource) Subscribe(_ context.Context, sig string, ch chan<- datasource.Value) (datasource.CancelFunc, error) {
|
||||
s.mu.Lock()
|
||||
s.chans[sig] = ch
|
||||
s.mu.Unlock()
|
||||
return func() {}, nil
|
||||
}
|
||||
func (s *pushSource) Write(_ context.Context, signal string, value any) error {
|
||||
s.mu.Lock()
|
||||
s.written[signal] = value
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
func (s *pushSource) History(context.Context, string, time.Time, time.Time, int) ([]datasource.Value, error) {
|
||||
return nil, datasource.ErrHistoryUnavailable
|
||||
}
|
||||
|
||||
func (s *pushSource) chanFor(sig string) (chan<- datasource.Value, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
ch, ok := s.chans[sig]
|
||||
return ch, ok
|
||||
}
|
||||
|
||||
func (s *pushSource) get(sig string) (any, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
v, ok := s.written[sig]
|
||||
return v, ok
|
||||
}
|
||||
|
||||
// TestEngineReloadThresholdTrigger drives a real Reload generation: a
|
||||
// trigger.threshold on tgt:IN (>5) wired to an action.write of 42 to tgt:OUT.
|
||||
// Pushing IN below then above the threshold must fire exactly the rising edge.
|
||||
func TestEngineReloadThresholdTrigger(t *testing.T) {
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
ctx := t.Context()
|
||||
|
||||
src := newPushSource()
|
||||
brk := broker.New(ctx, log)
|
||||
brk.Register(src)
|
||||
|
||||
store, err := NewStore(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal("NewStore:", err)
|
||||
}
|
||||
g := Graph{
|
||||
Name: "watchdog",
|
||||
Enabled: true,
|
||||
Nodes: []Node{
|
||||
{ID: "t1", Kind: "trigger.threshold", Params: map[string]string{
|
||||
"signal": "tgt:IN", "op": ">", "value": "5",
|
||||
}},
|
||||
{ID: "a1", Kind: "action.write", Params: map[string]string{
|
||||
"target": "tgt:OUT", "expr": "42",
|
||||
}},
|
||||
},
|
||||
Wires: []Wire{{From: "t1", To: "a1"}},
|
||||
}
|
||||
if err := store.Save(g); err != nil {
|
||||
t.Fatal("Save:", err)
|
||||
}
|
||||
|
||||
e := NewEngine(ctx, brk, store, nil, audit.Nop(), log)
|
||||
e.Reload()
|
||||
// t.Context() is cancelled at test cleanup, tearing the generation down.
|
||||
|
||||
// Wait until the engine has subscribed to tgt:IN.
|
||||
var ch chan<- datasource.Value
|
||||
waitFor(t, time.Second, func() bool {
|
||||
c, ok := src.chanFor("IN")
|
||||
if ok {
|
||||
ch = c
|
||||
}
|
||||
return ok
|
||||
})
|
||||
|
||||
// Below threshold: no fire (prev state seeds to false).
|
||||
ch <- datasource.Value{Data: 0.0, Timestamp: time.Now()}
|
||||
// Rising edge above threshold: fires the action.
|
||||
ch <- datasource.Value{Data: 10.0, Timestamp: time.Now()}
|
||||
|
||||
waitFor(t, 2*time.Second, func() bool {
|
||||
v, ok := src.get("OUT")
|
||||
return ok && toNum(v) == 42
|
||||
})
|
||||
if v, ok := src.get("OUT"); !ok || toNum(v) != 42 {
|
||||
t.Fatalf("OUT = %v (ok=%v), want 42 after rising-edge trigger", v, ok)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEngineReloadTimerIfWrite covers the timer trigger, startTriggers, and a
|
||||
// flow.if then-branch driving an action.write.
|
||||
func TestEngineReloadTimerIfWrite(t *testing.T) {
|
||||
log := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
ctx := t.Context()
|
||||
|
||||
src := newPushSource()
|
||||
brk := broker.New(ctx, log)
|
||||
brk.Register(src)
|
||||
|
||||
store, err := NewStore(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal("NewStore:", err)
|
||||
}
|
||||
g := Graph{
|
||||
Name: "ticker",
|
||||
Enabled: true,
|
||||
Nodes: []Node{
|
||||
{ID: "t1", Kind: "trigger.timer", Params: map[string]string{"interval": "50"}},
|
||||
{ID: "if1", Kind: "flow.if", Params: map[string]string{"cond": "2 > 1"}},
|
||||
{ID: "a1", Kind: "action.write", Params: map[string]string{"target": "tgt:OUT2", "expr": "7"}},
|
||||
},
|
||||
Wires: []Wire{
|
||||
{From: "t1", To: "if1"},
|
||||
{From: "if1", FromPort: "then", To: "a1"},
|
||||
},
|
||||
}
|
||||
if err := store.Save(g); err != nil {
|
||||
t.Fatal("Save:", err)
|
||||
}
|
||||
|
||||
e := NewEngine(ctx, brk, store, nil, audit.Nop(), log)
|
||||
e.Reload()
|
||||
|
||||
waitFor(t, 2*time.Second, func() bool {
|
||||
v, ok := src.get("OUT2")
|
||||
return ok && toNum(v) == 7
|
||||
})
|
||||
}
|
||||
|
||||
// waitFor polls cond until it returns true or the deadline elapses.
|
||||
func waitFor(t *testing.T, d time.Duration, cond func() bool) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(d)
|
||||
for time.Now().Before(deadline) {
|
||||
if cond() {
|
||||
return
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
if !cond() {
|
||||
t.Fatalf("condition not met within %s", d)
|
||||
}
|
||||
}
|
||||
@@ -52,9 +52,9 @@ type callNode struct {
|
||||
args []exprNode
|
||||
}
|
||||
|
||||
func (n numNode) eval(R Resolver) float64 { return n.v }
|
||||
func (n sigNode) eval(R Resolver) float64 { return R(n.ds, n.name) }
|
||||
func (n varNode) eval(R Resolver) float64 { return R("local", n.name) }
|
||||
func (n numNode) eval(R Resolver) float64 { return n.v }
|
||||
func (n sigNode) eval(R Resolver) float64 { return R(n.ds, n.name) }
|
||||
func (n varNode) eval(R Resolver) float64 { return R("local", n.name) }
|
||||
func (n unNode) eval(R Resolver) float64 {
|
||||
if n.op == "-" {
|
||||
return -n.a.eval(R)
|
||||
|
||||
@@ -29,6 +29,8 @@ package controllogic
|
||||
// action.delay — waits `ms` before continuing.
|
||||
// action.log — logs an expression value to the server log.
|
||||
// action.lua — runs a sandboxed Lua script with get/set/log host funcs.
|
||||
// action.dialog — pushes an info/error/input dialog to connected clients
|
||||
// filtered by user/group; input responses write `target`.
|
||||
|
||||
// Node is a single node in a control-logic graph. Params are stored as strings
|
||||
// (matching the panel logic model) and parsed per-kind by the engine.
|
||||
@@ -48,13 +50,28 @@ type Wire struct {
|
||||
To string `json:"to"`
|
||||
}
|
||||
|
||||
// NodeGroup is a cosmetic, editor-side grouping of nodes. The engine ignores it;
|
||||
// it is stored and round-tripped so the editor keeps its visual organisation.
|
||||
type NodeGroup struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Members []string `json:"members"`
|
||||
Collapsed bool `json:"collapsed,omitempty"`
|
||||
}
|
||||
|
||||
// Graph is a named, independently-enableable control-logic flow.
|
||||
type Graph struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Nodes []Node `json:"nodes"`
|
||||
Wires []Wire `json:"wires"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Version int `json:"version,omitempty"` // git-style revision; bumped on each Save
|
||||
Tag string `json:"tag,omitempty"` // optional revision label (e.g. "restored from v3")
|
||||
Owner string `json:"owner,omitempty"` // creator identity (stamped server-side)
|
||||
Scope string `json:"scope,omitempty"` // access.Scope* visibility token
|
||||
ScopeGroups []string `json:"scopeGroups,omitempty"` // groups for ScopeGroup visibility
|
||||
Nodes []Node `json:"nodes"`
|
||||
Wires []Wire `json:"wires"`
|
||||
Groups []NodeGroup `json:"groups,omitempty"`
|
||||
}
|
||||
|
||||
func (n Node) param(key string) string {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package controllogic
|
||||
|
||||
import "strings"
|
||||
|
||||
// Dialog is a user-facing notification or input request emitted by an
|
||||
// action.dialog node. It is delivered to connected clients whose identity
|
||||
// matches Users/Groups (both empty = everyone). For an "input" dialog the
|
||||
// client's response is written back to Target (a "ds:name" reference, e.g.
|
||||
// "srv:approved") so control logic can read it on a later activation.
|
||||
type Dialog struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"` // "info" | "error" | "input"
|
||||
Title string `json:"title"`
|
||||
Message string `json:"message"`
|
||||
Target string `json:"target,omitempty"`
|
||||
Users []string `json:"users,omitempty"`
|
||||
Groups []string `json:"groups,omitempty"`
|
||||
}
|
||||
|
||||
// Notifier delivers control-logic dialogs to connected clients. The server
|
||||
// implements it; the engine calls Notify when an action.dialog node runs.
|
||||
// Notify must not block (the hub fans out without waiting on slow clients).
|
||||
type Notifier interface {
|
||||
Notify(Dialog)
|
||||
}
|
||||
|
||||
// splitCSV parses a comma-separated user/group filter into trimmed,
|
||||
// non-empty tokens. An empty string yields a nil slice (no filter).
|
||||
func splitCSV(s string) []string {
|
||||
var out []string
|
||||
for _, p := range strings.Split(s, ",") {
|
||||
if t := strings.TrimSpace(p); t != "" {
|
||||
out = append(out, t)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const definitionsFile = "controllogic.json"
|
||||
@@ -16,17 +17,25 @@ var ErrNotFound = errors.New("control logic graph not found")
|
||||
|
||||
// Store persists control-logic graphs as a single JSON file in the storage dir.
|
||||
// Writes are atomic (tmp file + rename); all access is mutex-guarded.
|
||||
//
|
||||
// Git-style versioning: the live graph lives in controllogic.json (the current
|
||||
// revision), while every superseded revision is preserved as a backup file
|
||||
// {id}.vN.json under versionsDir. Promote/Fork build on these backups.
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
path string
|
||||
items map[string]Graph
|
||||
mu sync.RWMutex
|
||||
path string
|
||||
trashDir string
|
||||
versionsDir string
|
||||
items map[string]Graph
|
||||
}
|
||||
|
||||
// NewStore opens (or initialises) the control-logic store under storageDir.
|
||||
func NewStore(storageDir string) (*Store, error) {
|
||||
s := &Store{
|
||||
path: filepath.Join(storageDir, definitionsFile),
|
||||
items: map[string]Graph{},
|
||||
path: filepath.Join(storageDir, definitionsFile),
|
||||
trashDir: filepath.Join(storageDir, "trash", "controllogic"),
|
||||
versionsDir: filepath.Join(storageDir, "controllogic_versions"),
|
||||
items: map[string]Graph{},
|
||||
}
|
||||
if err := s.load(); err != nil {
|
||||
return nil, err
|
||||
@@ -91,21 +100,72 @@ func (s *Store) Get(id string) (Graph, error) {
|
||||
return g, nil
|
||||
}
|
||||
|
||||
// Save inserts or replaces a graph and persists the store.
|
||||
// Save inserts or replaces a graph and persists the store. When replacing an
|
||||
// existing graph the superseded revision is backed up as {id}.vN.json and the
|
||||
// new graph's Version is bumped; a brand-new graph starts at version 1.
|
||||
func (s *Store) Save(g Graph) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if old, ok := s.items[g.ID]; ok {
|
||||
oldV := old.Version
|
||||
if oldV < 1 {
|
||||
oldV = 1
|
||||
old.Version = 1
|
||||
}
|
||||
if err := s.backupLocked(old); err != nil {
|
||||
return fmt.Errorf("back up control logic revision: %w", err)
|
||||
}
|
||||
g.Version = oldV + 1
|
||||
} else if g.Version < 1 {
|
||||
g.Version = 1
|
||||
}
|
||||
s.items[g.ID] = g
|
||||
return s.saveLocked()
|
||||
}
|
||||
|
||||
// Delete removes a graph by id.
|
||||
// backupLocked writes a single graph revision to versionsDir as {id}.vN.json.
|
||||
// Caller must hold s.mu.
|
||||
func (s *Store) backupLocked(g Graph) error {
|
||||
if err := os.MkdirAll(s.versionsDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(g, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(s.versionPath(g.ID, g.Version), data, 0o644)
|
||||
}
|
||||
|
||||
func (s *Store) versionPath(id string, version int) string {
|
||||
return filepath.Join(s.versionsDir, fmt.Sprintf("%s.v%d.json", id, version))
|
||||
}
|
||||
|
||||
// Delete removes a graph by id, first writing a copy into the trash folder so it
|
||||
// can be recovered if needed. The trash backup is best-effort: a failure to write
|
||||
// it does not prevent the delete (but is reported).
|
||||
func (s *Store) Delete(id string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if _, ok := s.items[id]; !ok {
|
||||
g, ok := s.items[id]
|
||||
if !ok {
|
||||
return ErrNotFound
|
||||
}
|
||||
if err := s.trash(g); err != nil {
|
||||
return fmt.Errorf("move control logic to trash: %w", err)
|
||||
}
|
||||
delete(s.items, id)
|
||||
return s.saveLocked()
|
||||
}
|
||||
|
||||
// trash writes a single graph as a timestamped JSON file under the trash folder.
|
||||
func (s *Store) trash(g Graph) error {
|
||||
if err := os.MkdirAll(s.trashDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(g, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
dst := filepath.Join(s.trashDir, fmt.Sprintf("%s.%d.json", g.ID, time.Now().UnixMilli()))
|
||||
return os.WriteFile(dst, data, 0o644)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package controllogic
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestStoreDeleteAndReload covers Delete (with trash backup), the ErrNotFound
|
||||
// branches, List, and load() re-reading a persisted store from disk.
|
||||
func TestStoreDeleteAndReload(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s, err := NewStore(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
g := Graph{ID: "g1", Name: "one", Enabled: true,
|
||||
Nodes: []Node{{ID: "n1", Kind: "trigger.timer", Params: map[string]string{"interval": "1000"}}}}
|
||||
if err := s.Save(g); err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
if err := s.Save(Graph{ID: "g2", Name: "two"}); err != nil {
|
||||
t.Fatalf("Save g2: %v", err)
|
||||
}
|
||||
|
||||
if got := s.List(); len(got) != 2 {
|
||||
t.Fatalf("List: want 2, got %d", len(got))
|
||||
}
|
||||
|
||||
// Reload from disk: a fresh Store over the same dir must see both graphs.
|
||||
s2, err := NewStore(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("reopen: %v", err)
|
||||
}
|
||||
if got, err := s2.Get("g1"); err != nil || got.Name != "one" {
|
||||
t.Errorf("reloaded g1 = %+v, %v", got, err)
|
||||
}
|
||||
|
||||
// Delete writes a trash backup then removes the item.
|
||||
if err := s.Delete("g1"); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
if _, err := s.Get("g1"); err != ErrNotFound {
|
||||
t.Errorf("Get after delete: want ErrNotFound, got %v", err)
|
||||
}
|
||||
if err := s.Delete("g1"); err != ErrNotFound {
|
||||
t.Errorf("Delete missing: want ErrNotFound, got %v", err)
|
||||
}
|
||||
|
||||
// A trash file should now exist for g1.
|
||||
trashDir := filepath.Join(dir, "trash", "controllogic")
|
||||
entries, err := os.ReadDir(trashDir)
|
||||
if err != nil || len(entries) == 0 {
|
||||
t.Errorf("trash dir = %v entries, err %v; want >=1", len(entries), err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSplitCSV covers the comma-filter parser, including trimming and the
|
||||
// empty-input (nil) case.
|
||||
func TestSplitCSV(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
want []string
|
||||
}{
|
||||
{"", nil},
|
||||
{" ", nil},
|
||||
{"a", []string{"a"}},
|
||||
{" a , b ,, c ", []string{"a", "b", "c"}},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := splitCSV(tc.in); !reflect.DeepEqual(got, tc.want) {
|
||||
t.Errorf("splitCSV(%q) = %v, want %v", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package controllogic
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// VersionMeta describes a single persisted revision of a control-logic graph,
|
||||
// mirroring storage.VersionMeta so the frontend can treat all versioned
|
||||
// document types uniformly.
|
||||
type VersionMeta struct {
|
||||
Version int `json:"version"`
|
||||
Name string `json:"name"`
|
||||
Tag string `json:"tag,omitempty"`
|
||||
Current bool `json:"current"`
|
||||
SavedAt time.Time `json:"savedAt"`
|
||||
}
|
||||
|
||||
// Versions returns metadata for every persisted revision of the graph, newest
|
||||
// first. The live revision (held in controllogic.json) is flagged Current.
|
||||
func (s *Store) Versions(id string) ([]VersionMeta, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
cur, ok := s.items[id]
|
||||
if !ok {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
curV := cur.Version
|
||||
if curV < 1 {
|
||||
curV = 1
|
||||
}
|
||||
|
||||
var savedAt time.Time
|
||||
if info, err := os.Stat(s.path); err == nil {
|
||||
savedAt = info.ModTime()
|
||||
}
|
||||
out := []VersionMeta{{Version: curV, Name: cur.Name, Tag: cur.Tag, Current: true, SavedAt: savedAt}}
|
||||
|
||||
entries, err := os.ReadDir(s.versionsDir)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
prefix := id + ".v"
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if e.IsDir() || !strings.HasPrefix(name, prefix) || !strings.HasSuffix(name, ".json") {
|
||||
continue
|
||||
}
|
||||
vStr := strings.TrimSuffix(strings.TrimPrefix(name, prefix), ".json")
|
||||
v, err := strconv.Atoi(vStr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
g, err := s.readVersion(id, v)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, VersionMeta{Version: v, Name: g.Name, Tag: g.Tag, SavedAt: info.ModTime()})
|
||||
}
|
||||
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Version > out[j].Version })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetVersion returns a specific revision of the graph. The current revision is
|
||||
// served from the live store; older revisions come from their backup file.
|
||||
func (s *Store) GetVersion(id string, version int) (Graph, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
cur, ok := s.items[id]
|
||||
if !ok {
|
||||
return Graph{}, ErrNotFound
|
||||
}
|
||||
curV := cur.Version
|
||||
if curV < 1 {
|
||||
curV = 1
|
||||
}
|
||||
if version == curV {
|
||||
return cur, nil
|
||||
}
|
||||
return s.readVersion(id, version)
|
||||
}
|
||||
|
||||
// readVersion loads a backup revision file. Caller must hold s.mu.
|
||||
func (s *Store) readVersion(id string, version int) (Graph, error) {
|
||||
data, err := os.ReadFile(s.versionPath(id, version))
|
||||
if os.IsNotExist(err) {
|
||||
return Graph{}, ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return Graph{}, err
|
||||
}
|
||||
var g Graph
|
||||
if err := json.Unmarshal(data, &g); err != nil {
|
||||
return Graph{}, fmt.Errorf("parse revision: %w", err)
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
|
||||
// Promote makes a past revision current by re-saving it on top of history. The
|
||||
// existing current revision is preserved as a backup, so promotion is
|
||||
// non-destructive. Returns the resulting (new current) graph.
|
||||
func (s *Store) Promote(id string, version int) (Graph, error) {
|
||||
g, err := s.GetVersion(id, version)
|
||||
if err != nil {
|
||||
return Graph{}, err
|
||||
}
|
||||
g.Tag = fmt.Sprintf("restored from v%d", version)
|
||||
if err := s.Save(g); err != nil {
|
||||
return Graph{}, err
|
||||
}
|
||||
return s.Get(id)
|
||||
}
|
||||
|
||||
// Fork creates a brand-new graph from a specific revision, assigning a fresh id
|
||||
// and resetting its version to 1. Returns the new graph.
|
||||
func (s *Store) Fork(id string, version int) (Graph, error) {
|
||||
g, err := s.GetVersion(id, version)
|
||||
if err != nil {
|
||||
return Graph{}, err
|
||||
}
|
||||
g.ID = fmt.Sprintf("%s-fork-%d", id, time.Now().UnixMilli())
|
||||
g.Version = 1
|
||||
g.Tag = ""
|
||||
if g.Name != "" {
|
||||
g.Name = g.Name + " (fork)"
|
||||
}
|
||||
if err := s.Save(g); err != nil {
|
||||
return Graph{}, err
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package controllogic
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestControlLogicVersioning(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s, err := NewStore(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
g := Graph{ID: "cl-1", Name: "loop", Enabled: true,
|
||||
Nodes: []Node{{ID: "n1", Kind: "trigger.timer", Params: map[string]string{"interval": "1000"}}}}
|
||||
if err := s.Save(g); err != nil {
|
||||
t.Fatalf("Save v1: %v", err)
|
||||
}
|
||||
if got, _ := s.Get("cl-1"); got.Version != 1 {
|
||||
t.Fatalf("after create: version=%d", got.Version)
|
||||
}
|
||||
|
||||
// Two edits → v2, v3.
|
||||
g.Name = "loop-2"
|
||||
if err := s.Save(g); err != nil {
|
||||
t.Fatalf("Save v2: %v", err)
|
||||
}
|
||||
g.Name = "loop-3"
|
||||
if err := s.Save(g); err != nil {
|
||||
t.Fatalf("Save v3: %v", err)
|
||||
}
|
||||
if got, _ := s.Get("cl-1"); got.Version != 3 || got.Name != "loop-3" {
|
||||
t.Fatalf("current: version=%d name=%q", got.Version, got.Name)
|
||||
}
|
||||
|
||||
versions, err := s.Versions("cl-1")
|
||||
if err != nil {
|
||||
t.Fatalf("Versions: %v", err)
|
||||
}
|
||||
if len(versions) != 3 {
|
||||
t.Fatalf("want 3 versions, got %d", len(versions))
|
||||
}
|
||||
if !versions[0].Current || versions[0].Version != 3 {
|
||||
t.Errorf("newest should be current v3: %+v", versions[0])
|
||||
}
|
||||
|
||||
v1, err := s.GetVersion("cl-1", 1)
|
||||
if err != nil || v1.Name != "loop" {
|
||||
t.Fatalf("GetVersion v1: name=%q err=%v", v1.Name, err)
|
||||
}
|
||||
|
||||
// Promote v1 → v4.
|
||||
promoted, err := s.Promote("cl-1", 1)
|
||||
if err != nil {
|
||||
t.Fatalf("Promote: %v", err)
|
||||
}
|
||||
if promoted.Version != 4 || promoted.Name != "loop" {
|
||||
t.Errorf("promote: version=%d name=%q", promoted.Version, promoted.Name)
|
||||
}
|
||||
|
||||
// Fork v3 → new id, version 1.
|
||||
forked, err := s.Fork("cl-1", 3)
|
||||
if err != nil {
|
||||
t.Fatalf("Fork: %v", err)
|
||||
}
|
||||
if forked.Version != 1 || forked.ID == "cl-1" {
|
||||
t.Errorf("fork: id=%q version=%d", forked.ID, forked.Version)
|
||||
}
|
||||
if got, err := s.Get(forked.ID); err != nil || got.Name != forked.Name {
|
||||
t.Errorf("forked graph not stored: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -36,11 +36,11 @@ type archiveResponse []struct {
|
||||
}
|
||||
|
||||
type archivePoint struct {
|
||||
Secs int64 `json:"secs"`
|
||||
Nanos int64 `json:"nanos"`
|
||||
Val any `json:"val"`
|
||||
Severity int `json:"severity"`
|
||||
Status int `json:"status"`
|
||||
Secs int64 `json:"secs"`
|
||||
Nanos int64 `json:"nanos"`
|
||||
Val any `json:"val"`
|
||||
Severity int `json:"severity"`
|
||||
Status int `json:"status"`
|
||||
}
|
||||
|
||||
// fetchArchiveHistory queries the EPICS Archive Appliance JSON API for
|
||||
@@ -112,6 +112,8 @@ func fetchArchiveHistory(ctx context.Context, archiveURL, signal string, start,
|
||||
Timestamp: ts,
|
||||
Data: data,
|
||||
Quality: q,
|
||||
Severity: p.Severity,
|
||||
Status: p.Status,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import (
|
||||
//
|
||||
//export goCAMonitorCallback
|
||||
func goCAMonitorCallback(handle C.uintptr_t, dbrType C.int, count C.long,
|
||||
dbr unsafe.Pointer, severity C.int, epicsTimeSecs C.double) {
|
||||
dbr unsafe.Pointer, severity C.int, status C.int, epicsTimeSecs C.double) {
|
||||
|
||||
h := uintptr(handle)
|
||||
|
||||
@@ -79,7 +79,13 @@ func goCAMonitorCallback(handle C.uintptr_t, dbrType C.int, count C.long,
|
||||
data = float64(0)
|
||||
}
|
||||
|
||||
v := datasource.Value{Timestamp: ts, Data: data, Quality: q}
|
||||
v := datasource.Value{
|
||||
Timestamp: ts,
|
||||
Data: data,
|
||||
Quality: q,
|
||||
Severity: int(severity),
|
||||
Status: int(status),
|
||||
}
|
||||
|
||||
// Look up the subscription channel under the global handle table lock and
|
||||
// perform a non-blocking send so we never stall the CA callback thread.
|
||||
@@ -129,6 +135,7 @@ func goCAConnectionCallback(handle C.uintptr_t, connected C.int) {
|
||||
Timestamp: time.Now(),
|
||||
Data: float64(0),
|
||||
Quality: datasource.QualityBad,
|
||||
Severity: 3, // INVALID
|
||||
}
|
||||
select {
|
||||
case ch <- v:
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* goCAConnectionCallback is called when a channel connects or disconnects.
|
||||
*/
|
||||
extern void goCAMonitorCallback(uintptr_t handle, int dbrType, long count,
|
||||
void *dbr, int severity,
|
||||
void *dbr, int severity, int status,
|
||||
double epicsTimeSecs);
|
||||
extern void goCAConnectionCallback(uintptr_t handle, int connected);
|
||||
|
||||
@@ -30,9 +30,10 @@ static void caMonitorCallbackShim(struct event_handler_args args) {
|
||||
|
||||
double timeSecs = 0.0;
|
||||
int severity = 0;
|
||||
int status = 0;
|
||||
|
||||
/*
|
||||
* Determine the timestamp and alarm severity from the DBR type.
|
||||
* Determine the timestamp and alarm severity/status from the DBR type.
|
||||
* We request DBR_TIME_* types so the timestamp is embedded in the value
|
||||
* buffer right after the alarm fields (struct dbr_time_double et al.).
|
||||
*/
|
||||
@@ -41,36 +42,42 @@ static void caMonitorCallbackShim(struct event_handler_args args) {
|
||||
const struct dbr_time_double *p = (const struct dbr_time_double *)args.dbr;
|
||||
timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9;
|
||||
severity = (int)p->severity;
|
||||
status = (int)p->status;
|
||||
break;
|
||||
}
|
||||
case DBR_TIME_FLOAT: {
|
||||
const struct dbr_time_float *p = (const struct dbr_time_float *)args.dbr;
|
||||
timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9;
|
||||
severity = (int)p->severity;
|
||||
status = (int)p->status;
|
||||
break;
|
||||
}
|
||||
case DBR_TIME_LONG: {
|
||||
const struct dbr_time_long *p = (const struct dbr_time_long *)args.dbr;
|
||||
timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9;
|
||||
severity = (int)p->severity;
|
||||
status = (int)p->status;
|
||||
break;
|
||||
}
|
||||
case DBR_TIME_SHORT: {
|
||||
const struct dbr_time_short *p = (const struct dbr_time_short *)args.dbr;
|
||||
timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9;
|
||||
severity = (int)p->severity;
|
||||
status = (int)p->status;
|
||||
break;
|
||||
}
|
||||
case DBR_TIME_STRING: {
|
||||
const struct dbr_time_string *p = (const struct dbr_time_string *)args.dbr;
|
||||
timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9;
|
||||
severity = (int)p->severity;
|
||||
status = (int)p->status;
|
||||
break;
|
||||
}
|
||||
case DBR_TIME_ENUM: {
|
||||
const struct dbr_time_enum *p = (const struct dbr_time_enum *)args.dbr;
|
||||
timeSecs = (double)p->stamp.secPastEpoch + (double)p->stamp.nsec / 1e9;
|
||||
severity = (int)p->severity;
|
||||
status = (int)p->status;
|
||||
break;
|
||||
}
|
||||
default:
|
||||
@@ -78,7 +85,7 @@ static void caMonitorCallbackShim(struct event_handler_args args) {
|
||||
}
|
||||
|
||||
goCAMonitorCallback((uintptr_t)args.usr, (int)args.type, (long)args.count,
|
||||
(void *)args.dbr, severity, timeSecs);
|
||||
(void *)args.dbr, severity, status, timeSecs);
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -67,14 +67,13 @@ type caChannel struct {
|
||||
|
||||
// EPICS is the Channel Access data source.
|
||||
type EPICS struct {
|
||||
caAddrList string
|
||||
archiveURL string
|
||||
cfURL string
|
||||
caAddrList string
|
||||
archiveURL string
|
||||
cfURL string
|
||||
autoSyncFilter string
|
||||
autoSyncFromArchiver bool
|
||||
|
||||
// caCtx is the CA context created in Connect().
|
||||
Stored as unsafe.Pointer
|
||||
// caCtx is the CA context created in Connect(). Stored as unsafe.Pointer
|
||||
// because the C type (ca_client_context *) is opaque. Every goroutine
|
||||
// that calls CA functions must call caAttachContext(caCtx) first, because
|
||||
// Go goroutines can run on any OS thread and CA contexts are thread-local.
|
||||
|
||||
@@ -28,9 +28,9 @@ func Available() bool { return true }
|
||||
|
||||
// EPICS is the pure-Go Channel Access data source.
|
||||
type EPICS struct {
|
||||
caAddrList string
|
||||
archiveURL string
|
||||
cfURL string
|
||||
caAddrList string
|
||||
archiveURL string
|
||||
cfURL string
|
||||
autoSyncFilter string
|
||||
autoSyncFromArchiver bool
|
||||
pvNames []string // pre-fetched at connect time for ListSignals
|
||||
@@ -275,6 +275,8 @@ func (e *EPICS) timeValueToDS(signal string, tv proto.TimeValue) datasource.Valu
|
||||
Timestamp: tv.Timestamp,
|
||||
Data: data,
|
||||
Quality: quality,
|
||||
Severity: int(tv.Severity),
|
||||
Status: int(tv.Status),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,8 @@ type Value struct {
|
||||
Timestamp time.Time
|
||||
Data any // float64 | []float64 | string | int64 | bool
|
||||
Quality Quality
|
||||
Severity int // raw EPICS alarm severity (0=NO_ALARM,1=MINOR,2=MAJOR,3=INVALID); 0 for sources without alarm info
|
||||
Status int // raw EPICS alarm status (.STAT); 0 for sources without alarm info
|
||||
MetaUpdate bool // if true, metadata was refreshed — dispatcher should re-send meta
|
||||
}
|
||||
|
||||
|
||||
@@ -180,6 +180,7 @@ func structToValue(sv pvdata.StructValue) datasource.Value {
|
||||
val := fieldByName(sv, "value")
|
||||
ts := extractTimestamp(sv)
|
||||
quality := extractQuality(sv)
|
||||
severity, status := extractSeverityStatus(sv)
|
||||
|
||||
var data any
|
||||
if val != nil {
|
||||
@@ -190,6 +191,8 @@ func structToValue(sv pvdata.StructValue) datasource.Value {
|
||||
Timestamp: ts,
|
||||
Data: data,
|
||||
Quality: quality,
|
||||
Severity: severity,
|
||||
Status: status,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -329,6 +332,26 @@ func extractQuality(sv pvdata.StructValue) datasource.Quality {
|
||||
}
|
||||
}
|
||||
|
||||
// extractSeverityStatus pulls the raw EPICS alarm severity and status from the
|
||||
// NTScalar "alarm" struct. Returns (0, 0) when no alarm struct is present.
|
||||
func extractSeverityStatus(sv pvdata.StructValue) (severity, status int) {
|
||||
alarm := structByName(sv, "alarm")
|
||||
if alarm == nil {
|
||||
return 0, 0
|
||||
}
|
||||
if v := fieldByName(*alarm, "severity"); v != nil {
|
||||
if s, ok := v.(int32); ok {
|
||||
severity = int(s)
|
||||
}
|
||||
}
|
||||
if v := fieldByName(*alarm, "status"); v != nil {
|
||||
if s, ok := v.(int32); ok {
|
||||
status = int(s)
|
||||
}
|
||||
}
|
||||
return severity, status
|
||||
}
|
||||
|
||||
func toFloat64(v any) (float64, bool) {
|
||||
switch x := v.(type) {
|
||||
case float64:
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
// Package servervar provides a small persistent key/value data source for
|
||||
// "server variables": named scalar values that the server-side control-logic
|
||||
// engine writes (e.g. the state of a sequence) and that interface panels can
|
||||
// read live. Panels may read any variable; writes from panels are gated to
|
||||
// control-logic editors in the WebSocket write handler.
|
||||
package servervar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/uopi/uopi/internal/datasource"
|
||||
)
|
||||
|
||||
const fileName = "servervars.json"
|
||||
|
||||
type variable struct {
|
||||
value float64
|
||||
ts time.Time
|
||||
}
|
||||
|
||||
// Source is the "srv" data source. Variables are created on first write and
|
||||
// persisted so their last value survives a restart.
|
||||
type Source struct {
|
||||
path string
|
||||
|
||||
mu sync.RWMutex
|
||||
vars map[string]*variable
|
||||
subs map[string]map[int]chan<- datasource.Value
|
||||
nextID int
|
||||
}
|
||||
|
||||
// New opens (or initialises) the server-variable store under storageDir.
|
||||
func New(storageDir string) (*Source, error) {
|
||||
s := &Source{
|
||||
path: filepath.Join(storageDir, fileName),
|
||||
vars: map[string]*variable{},
|
||||
subs: map[string]map[int]chan<- datasource.Value{},
|
||||
}
|
||||
if err := s.load(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Source) load() error {
|
||||
data, err := os.ReadFile(s.path)
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var raw map[string]float64
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now()
|
||||
for name, v := range raw {
|
||||
s.vars[name] = &variable{value: v, ts: now}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// saveLocked persists the current values atomically. Caller holds s.mu.
|
||||
func (s *Source) saveLocked() {
|
||||
raw := make(map[string]float64, len(s.vars))
|
||||
for name, v := range s.vars {
|
||||
raw[name] = v.value
|
||||
}
|
||||
data, err := json.MarshalIndent(raw, "", " ")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
tmp := s.path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o644); err != nil {
|
||||
return
|
||||
}
|
||||
_ = os.Rename(tmp, s.path)
|
||||
}
|
||||
|
||||
func meta(name string) datasource.Metadata {
|
||||
return datasource.Metadata{
|
||||
Name: name,
|
||||
Type: datasource.TypeFloat64,
|
||||
Description: "Server variable",
|
||||
Writable: true,
|
||||
}
|
||||
}
|
||||
|
||||
// Name implements datasource.DataSource.
|
||||
func (s *Source) Name() string { return "srv" }
|
||||
|
||||
// Connect is a no-op — the store is opened in New.
|
||||
func (s *Source) Connect(_ context.Context) error { return nil }
|
||||
|
||||
// ListSignals returns metadata for every defined server variable.
|
||||
func (s *Source) ListSignals(_ context.Context) ([]datasource.Metadata, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
names := make([]string, 0, len(s.vars))
|
||||
for name := range s.vars {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
out := make([]datasource.Metadata, 0, len(names))
|
||||
for _, name := range names {
|
||||
out = append(out, meta(name))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetMetadata returns metadata for a single variable. Unknown names still report
|
||||
// writable metadata so that control logic / authorised panels may create them.
|
||||
func (s *Source) GetMetadata(_ context.Context, signal string) (datasource.Metadata, error) {
|
||||
return meta(signal), nil
|
||||
}
|
||||
|
||||
// Subscribe registers ch for updates and immediately delivers the current value.
|
||||
func (s *Source) Subscribe(ctx context.Context, signal string, ch chan<- datasource.Value) (datasource.CancelFunc, error) {
|
||||
s.mu.Lock()
|
||||
if s.subs[signal] == nil {
|
||||
s.subs[signal] = map[int]chan<- datasource.Value{}
|
||||
}
|
||||
id := s.nextID
|
||||
s.nextID++
|
||||
s.subs[signal][id] = ch
|
||||
cur, ok := s.vars[signal]
|
||||
var first datasource.Value
|
||||
if ok {
|
||||
first = datasource.Value{Timestamp: cur.ts, Data: cur.value, Quality: datasource.QualityGood}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
if ok {
|
||||
go func() {
|
||||
select {
|
||||
case ch <- first:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
return func() {
|
||||
s.mu.Lock()
|
||||
if m := s.subs[signal]; m != nil {
|
||||
delete(m, id)
|
||||
if len(m) == 0 {
|
||||
delete(s.subs, signal)
|
||||
}
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}, nil
|
||||
}
|
||||
|
||||
func toFloat64(v any) float64 {
|
||||
switch x := v.(type) {
|
||||
case float64:
|
||||
return x
|
||||
case float32:
|
||||
return float64(x)
|
||||
case int:
|
||||
return float64(x)
|
||||
case int64:
|
||||
return float64(x)
|
||||
case bool:
|
||||
if x {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
case json.Number:
|
||||
f, _ := x.Float64()
|
||||
return f
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// Write sets a variable (creating it if needed), persists, and fans the new value
|
||||
// out to all subscribers.
|
||||
func (s *Source) Write(_ context.Context, signal string, value any) error {
|
||||
v := toFloat64(value)
|
||||
|
||||
s.mu.Lock()
|
||||
now := time.Now()
|
||||
s.vars[signal] = &variable{value: v, ts: now}
|
||||
s.saveLocked()
|
||||
subs := make([]chan<- datasource.Value, 0, len(s.subs[signal]))
|
||||
for _, ch := range s.subs[signal] {
|
||||
subs = append(subs, ch)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
upd := datasource.Value{Timestamp: now, Data: v, Quality: datasource.QualityGood}
|
||||
for _, ch := range subs {
|
||||
select {
|
||||
case ch <- upd:
|
||||
default:
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// History is not supported.
|
||||
func (s *Source) History(_ context.Context, _ string, _, _ time.Time, _ int) ([]datasource.Value, error) {
|
||||
return nil, datasource.ErrHistoryUnavailable
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package servervar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/uopi/uopi/internal/datasource"
|
||||
)
|
||||
|
||||
func TestWriteSubscribePersist(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s, err := New(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
if err := s.Write(ctx, "seq_state", 3.0); err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
|
||||
// Subscribe should deliver the current value immediately.
|
||||
ch := make(chan datasource.Value, 4)
|
||||
cancel, err := s.Subscribe(ctx, "seq_state", ch)
|
||||
if err != nil {
|
||||
t.Fatalf("Subscribe: %v", err)
|
||||
}
|
||||
defer cancel()
|
||||
select {
|
||||
case v := <-ch:
|
||||
if got := v.Data.(float64); got != 3.0 {
|
||||
t.Fatalf("initial value = %v, want 3", got)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("no initial value delivered")
|
||||
}
|
||||
|
||||
// A later write fans out to the subscriber.
|
||||
if err := s.Write(ctx, "seq_state", 7.0); err != nil {
|
||||
t.Fatalf("Write 2: %v", err)
|
||||
}
|
||||
select {
|
||||
case v := <-ch:
|
||||
if got := v.Data.(float64); got != 7.0 {
|
||||
t.Fatalf("updated value = %v, want 7", got)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("no update delivered")
|
||||
}
|
||||
|
||||
// ListSignals reports the variable.
|
||||
sigs, err := s.ListSignals(ctx)
|
||||
if err != nil || len(sigs) != 1 || sigs[0].Name != "seq_state" {
|
||||
t.Fatalf("ListSignals = %+v, err %v", sigs, err)
|
||||
}
|
||||
|
||||
// A fresh store over the same dir recovers the last value.
|
||||
s2, err := New(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("reopen: %v", err)
|
||||
}
|
||||
ch2 := make(chan datasource.Value, 1)
|
||||
cancel2, _ := s2.Subscribe(ctx, "seq_state", ch2)
|
||||
defer cancel2()
|
||||
select {
|
||||
case v := <-ch2:
|
||||
if got := v.Data.(float64); got != 7.0 {
|
||||
t.Fatalf("persisted value = %v, want 7", got)
|
||||
}
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("persisted value not delivered")
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ const updateInterval = 100 * time.Millisecond // 10 Hz default
|
||||
|
||||
type signalDef struct {
|
||||
meta datasource.Metadata
|
||||
interval time.Duration // 0 → updateInterval
|
||||
interval time.Duration // 0 → updateInterval
|
||||
fn func(t time.Time) any
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
package synthetic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"math"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/uopi/uopi/internal/broker"
|
||||
"github.com/uopi/uopi/internal/datasource"
|
||||
"github.com/uopi/uopi/internal/dsp"
|
||||
)
|
||||
|
||||
// evalSampleDef compiles a SignalDef and evaluates it against per-source
|
||||
// Samples keyed by source node id, returning the output Sample.
|
||||
func evalSampleDef(t *testing.T, def SignalDef, srcVals map[string]dsp.Sample) dsp.Sample {
|
||||
t.Helper()
|
||||
rg, err := compileGraph(def)
|
||||
if err != nil {
|
||||
t.Fatalf("compileGraph: %v", err)
|
||||
}
|
||||
out, err := rg.evalSample(srcVals)
|
||||
if err != nil {
|
||||
t.Fatalf("evalSample: %v", err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestArrayElementwiseChain runs an array source through an elementwise op
|
||||
// (gain) and asserts the output stays an array, broadcast per element.
|
||||
func TestArrayElementwiseChain(t *testing.T) {
|
||||
def := SignalDef{
|
||||
Name: "scaled",
|
||||
Graph: &Graph{
|
||||
Output: "out",
|
||||
Nodes: []GraphNode{
|
||||
{ID: "a", Kind: "source", DS: "x", Signal: "wave"},
|
||||
{ID: "g", Kind: "op", Op: "gain", Inputs: []string{"a"}, Params: map[string]any{"gain": 2.0}},
|
||||
{ID: "out", Kind: "output", Inputs: []string{"g"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
out := evalSampleDef(t, def, map[string]dsp.Sample{"a": dsp.Array([]float64{1, 2, 3})})
|
||||
if !out.IsArray {
|
||||
t.Fatalf("want array output, got %v", out)
|
||||
}
|
||||
want := []float64{2, 4, 6}
|
||||
for i, v := range want {
|
||||
if out.Arr[i] != v {
|
||||
t.Errorf("scaled[%d]: want %v, got %v", i, v, out.Arr[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestArrayReductionToScalar runs an array source into mean (array→scalar) and
|
||||
// asserts a scalar output and an array-output compile type for the producer.
|
||||
func TestArrayReductionToScalar(t *testing.T) {
|
||||
def := SignalDef{
|
||||
Name: "avg",
|
||||
Graph: &Graph{
|
||||
Output: "out",
|
||||
Nodes: []GraphNode{
|
||||
{ID: "a", Kind: "source", DS: "x", Signal: "wave"},
|
||||
{ID: "m", Kind: "op", Op: "mean", Inputs: []string{"a"}},
|
||||
{ID: "out", Kind: "output", Inputs: []string{"m"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
out := evalSampleDef(t, def, map[string]dsp.Sample{"a": dsp.Array([]float64{2, 4, 6, 8})})
|
||||
if out.IsArray {
|
||||
t.Fatalf("want scalar output, got array %v", out.Arr)
|
||||
}
|
||||
if math.Abs(out.F-5) > 1e-9 {
|
||||
t.Errorf("mean: want 5, got %v", out.F)
|
||||
}
|
||||
}
|
||||
|
||||
// TestArrayOutTypeMetadata verifies compileGraph reports an array output type
|
||||
// for a pure-elementwise array graph and scalar for a reduction graph.
|
||||
func TestArrayOutTypeMetadata(t *testing.T) {
|
||||
arrayGraph := SignalDef{
|
||||
Name: "fftout",
|
||||
Graph: &Graph{
|
||||
Output: "out",
|
||||
Nodes: []GraphNode{
|
||||
{ID: "a", Kind: "source", DS: "x", Signal: "wave"},
|
||||
{ID: "f", Kind: "op", Op: "fft", Inputs: []string{"a"}},
|
||||
{ID: "out", Kind: "output", Inputs: []string{"f"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
rg, err := compileGraph(arrayGraph)
|
||||
if err != nil {
|
||||
t.Fatalf("compileGraph: %v", err)
|
||||
}
|
||||
if rg.outType != dsp.ValArray {
|
||||
t.Errorf("fft graph outType: want ValArray, got %v", rg.outType)
|
||||
}
|
||||
|
||||
reduction := SignalDef{
|
||||
Name: "sumout",
|
||||
Graph: &Graph{
|
||||
Output: "out",
|
||||
Nodes: []GraphNode{
|
||||
{ID: "a", Kind: "source", DS: "x", Signal: "wave"},
|
||||
{ID: "s", Kind: "op", Op: "sum", Inputs: []string{"a"}},
|
||||
{ID: "out", Kind: "output", Inputs: []string{"s"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
rg2, err := compileGraph(reduction)
|
||||
if err != nil {
|
||||
t.Fatalf("compileGraph: %v", err)
|
||||
}
|
||||
if rg2.outType != dsp.ValScalar {
|
||||
t.Errorf("sum graph outType: want ValScalar, got %v", rg2.outType)
|
||||
}
|
||||
}
|
||||
|
||||
// TestStatefulRejectsArray verifies a stateful op (moving_average) errors when
|
||||
// fed an array input at runtime.
|
||||
func TestStatefulRejectsArray(t *testing.T) {
|
||||
def := SignalDef{
|
||||
Name: "ma",
|
||||
Graph: &Graph{
|
||||
Output: "out",
|
||||
Nodes: []GraphNode{
|
||||
{ID: "a", Kind: "source", DS: "x", Signal: "wave"},
|
||||
{ID: "m", Kind: "op", Op: "moving_average", Inputs: []string{"a"}, Params: map[string]any{"window": 3.0}},
|
||||
{ID: "out", Kind: "output", Inputs: []string{"m"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
rg, err := compileGraph(def)
|
||||
if err != nil {
|
||||
t.Fatalf("compileGraph: %v", err)
|
||||
}
|
||||
if _, err := rg.evalSample(map[string]dsp.Sample{"a": dsp.Array([]float64{1, 2, 3})}); err == nil {
|
||||
t.Error("expected moving_average to reject an array input")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSubscribeArrayPassthrough is an end-to-end check that a synthetic with an
|
||||
// array-valued source and an elementwise op emits a []float64 over the broker,
|
||||
// and that GetMetadata reports the waveform type.
|
||||
func TestSubscribeArrayPassthrough(t *testing.T) {
|
||||
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
base := time.Date(2026, 6, 19, 10, 0, 0, 0, time.UTC)
|
||||
src := &seqSource{name: "src", seq: []datasource.Value{
|
||||
{Timestamp: base.Add(1 * time.Second), Data: []float64{1, 2, 3}, Quality: datasource.QualityGood},
|
||||
{Timestamp: base.Add(2 * time.Second), Data: []float64{4, 5, 6}, Quality: datasource.QualityGood},
|
||||
}}
|
||||
|
||||
brk := broker.New(ctx, log)
|
||||
brk.Register(src)
|
||||
syn := New(t.TempDir(), brk, log)
|
||||
if err := syn.Connect(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// gain x2 elementwise keeps the value an array.
|
||||
if err := syn.AddSignal(SignalDef{
|
||||
Name: "scaled",
|
||||
Graph: &Graph{Output: "out", Nodes: []GraphNode{
|
||||
{ID: "a", Kind: "source", DS: "src", Signal: "x"},
|
||||
{ID: "g", Kind: "op", Op: "gain", Inputs: []string{"a"}, Params: map[string]any{"gain": 2.0}},
|
||||
{ID: "out", Kind: "output", Inputs: []string{"g"}},
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// An fft-based signal has a statically-known array output type (the source's
|
||||
// runtime type need not be known), so its metadata reports the waveform type.
|
||||
if err := syn.AddSignal(SignalDef{
|
||||
Name: "spectrum",
|
||||
Graph: &Graph{Output: "out", Nodes: []GraphNode{
|
||||
{ID: "a", Kind: "source", DS: "src", Signal: "x"},
|
||||
{ID: "f", Kind: "op", Op: "fft", Inputs: []string{"a"}},
|
||||
{ID: "out", Kind: "output", Inputs: []string{"f"}},
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
meta, err := syn.GetMetadata(ctx, "spectrum")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if meta.Type != datasource.TypeFloat64Array {
|
||||
t.Errorf("metadata type: want TypeFloat64Array, got %v", meta.Type)
|
||||
}
|
||||
|
||||
ch := make(chan datasource.Value, 8)
|
||||
if _, err := syn.Subscribe(ctx, "scaled", ch); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
want := [][]float64{{2, 4, 6}, {8, 10, 12}}
|
||||
for i, w := range want {
|
||||
select {
|
||||
case v := <-ch:
|
||||
arr, ok := v.Data.([]float64)
|
||||
if !ok {
|
||||
t.Fatalf("emit #%d: want []float64, got %T", i, v.Data)
|
||||
}
|
||||
if len(arr) != len(w) {
|
||||
t.Fatalf("emit #%d: want len %d, got %d", i, len(w), len(arr))
|
||||
}
|
||||
for k, val := range w {
|
||||
if arr[k] != val {
|
||||
t.Errorf("emit #%d [%d]: want %v, got %v", i, k, val, arr[k])
|
||||
}
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatalf("timeout waiting for emit #%d", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,22 +4,31 @@ package synthetic
|
||||
|
||||
// SignalDef describes one synthetic signal.
|
||||
type SignalDef struct {
|
||||
Name string `json:"name"`
|
||||
DS string `json:"ds"` // upstream data source name
|
||||
Signal string `json:"signal"` // upstream signal name (or "" for constant)
|
||||
Inputs []InputRef `json:"inputs"` // alternative multi-input format
|
||||
Pipeline []NodeDef `json:"pipeline"` // ordered list of DSP nodes
|
||||
Meta MetaOverride `json:"meta"` // optional metadata overrides
|
||||
Name string `json:"name"`
|
||||
DS string `json:"ds"` // upstream data source name
|
||||
Signal string `json:"signal"` // upstream signal name (or "" for constant)
|
||||
Inputs []InputRef `json:"inputs"` // alternative multi-input format
|
||||
Pipeline []NodeDef `json:"pipeline"` // ordered list of DSP nodes
|
||||
Graph *Graph `json:"graph,omitempty"` // DAG form (preferred when present)
|
||||
Meta MetaOverride `json:"meta"` // optional metadata overrides
|
||||
|
||||
// Visibility controls who sees this signal in the signal tree:
|
||||
// "global" — listed in every panel's edit mode
|
||||
// "user" — listed in every panel owned by Owner
|
||||
// "group" — listed for Owner and members of any group in Groups
|
||||
// "panel" — listed only when editing the bound Panel
|
||||
// An empty value is treated as "global" for backward compatibility with
|
||||
// definitions created before this field existed.
|
||||
Visibility string `json:"visibility,omitempty"`
|
||||
Owner string `json:"owner,omitempty"` // creator identity (stamped server-side)
|
||||
Panel string `json:"panel,omitempty"` // bound interface id for "panel" visibility
|
||||
Visibility string `json:"visibility,omitempty"`
|
||||
Owner string `json:"owner,omitempty"` // creator identity (stamped server-side)
|
||||
Groups []string `json:"groups,omitempty"` // groups for "group" visibility
|
||||
Panel string `json:"panel,omitempty"` // bound interface id for "panel" visibility
|
||||
|
||||
// Version and Tag implement git-style revisioning. Version is bumped on
|
||||
// every UpdateSignal; superseded revisions are kept as backup files. Tag is
|
||||
// an optional human label for a revision (e.g. "restored from v3").
|
||||
Version int `json:"version,omitempty"`
|
||||
Tag string `json:"tag,omitempty"`
|
||||
}
|
||||
|
||||
// InputRef names one upstream signal used as input to the pipeline.
|
||||
@@ -34,6 +43,48 @@ type NodeDef struct {
|
||||
Params map[string]any `json:"params,omitempty"`
|
||||
}
|
||||
|
||||
// Graph is the DAG form of a synthetic signal: a set of nodes (sources, ops and
|
||||
// one output) wired together by explicit per-node ordered input lists. It
|
||||
// supersedes the linear Inputs+Pipeline form: when SignalDef.Graph is set it is
|
||||
// authoritative; otherwise the legacy linear fields are converted into an
|
||||
// equivalent graph at load time (see toGraph).
|
||||
type Graph struct {
|
||||
Nodes []GraphNode `json:"nodes"`
|
||||
Output string `json:"output"` // id of the output node
|
||||
// Groups is cosmetic editor metadata (node grouping/collapsing). It has no
|
||||
// effect on evaluation; stored and round-tripped so the editor keeps shape.
|
||||
Groups []NodeGroup `json:"groups,omitempty"`
|
||||
}
|
||||
|
||||
// NodeGroup is a cosmetic, editor-side grouping of graph nodes (no eval effect).
|
||||
type NodeGroup struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label,omitempty"`
|
||||
Members []string `json:"members"`
|
||||
Collapsed bool `json:"collapsed,omitempty"`
|
||||
}
|
||||
|
||||
// GraphNode is one node in a Graph.
|
||||
//
|
||||
// kind=="source": carries DS+Signal; has no Inputs (a graph root).
|
||||
// kind=="op": carries Op + Params; Inputs lists upstream node IDs in the
|
||||
// order the op receives them (input 0, 1, … e.g. a−b, a÷b).
|
||||
// kind=="output": Inputs has a single upstream node whose value is the result.
|
||||
//
|
||||
// X/Y are the editor layout coordinates, persisted so a reloaded graph keeps its
|
||||
// shape; they have no effect on evaluation.
|
||||
type GraphNode struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Op string `json:"op,omitempty"`
|
||||
Params map[string]any `json:"params,omitempty"`
|
||||
DS string `json:"ds,omitempty"`
|
||||
Signal string `json:"signal,omitempty"`
|
||||
Inputs []string `json:"inputs,omitempty"`
|
||||
X float64 `json:"x,omitempty"`
|
||||
Y float64 `json:"y,omitempty"`
|
||||
}
|
||||
|
||||
// MetaOverride allows the synthetic signal to override display metadata.
|
||||
type MetaOverride struct {
|
||||
Unit string `json:"unit,omitempty"`
|
||||
|
||||
@@ -38,21 +38,32 @@ func stringParam(params map[string]any, key string) string {
|
||||
return s
|
||||
}
|
||||
|
||||
// BuildPipeline converts a []NodeDef (from JSON) into a []dsp.Node ready for
|
||||
// execution. JSON numbers are float64, so all numeric params are handled as
|
||||
// float64 regardless of the final type needed.
|
||||
func BuildPipeline(defs []NodeDef) ([]dsp.Node, error) {
|
||||
nodes := make([]dsp.Node, 0, len(defs))
|
||||
for i, d := range defs {
|
||||
n, err := buildNode(d)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pipeline node %d (%q): %w", i, d.Type, err)
|
||||
}
|
||||
nodes = append(nodes, n)
|
||||
// stringSliceParam extracts a []string from params; JSON arrays decode to []any,
|
||||
// so each element is coerced via its string value. Returns nil if missing.
|
||||
func stringSliceParam(params map[string]any, key string) []string {
|
||||
if params == nil {
|
||||
return nil
|
||||
}
|
||||
return nodes, nil
|
||||
v, ok := params[key]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
arr, ok := v.([]any)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, 0, len(arr))
|
||||
for _, e := range arr {
|
||||
if s, ok := e.(string); ok && s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// buildNode converts a single NodeDef (from JSON) into a dsp.Node ready for
|
||||
// execution. JSON numbers are float64, so all numeric params are handled as
|
||||
// float64 regardless of the final type needed.
|
||||
func buildNode(d NodeDef) (dsp.Node, error) {
|
||||
p := d.Params
|
||||
switch d.Type {
|
||||
@@ -100,7 +111,7 @@ func buildNode(d NodeDef) (dsp.Node, error) {
|
||||
}, nil
|
||||
|
||||
case "expr":
|
||||
return &dsp.ExprNode{Expr: stringParam(p, "expr")}, nil
|
||||
return &dsp.ExprNode{Expr: stringParam(p, "expr"), Vars: stringSliceParam(p, "vars")}, nil
|
||||
|
||||
case "lowpass":
|
||||
order := int(floatParam(p, "order"))
|
||||
@@ -113,7 +124,31 @@ func buildNode(d NodeDef) (dsp.Node, error) {
|
||||
}, nil
|
||||
|
||||
case "lua":
|
||||
return &dsp.LuaNode{Script: stringParam(p, "script")}, nil
|
||||
return &dsp.LuaNode{Script: stringParam(p, "script"), Vars: stringSliceParam(p, "vars")}, nil
|
||||
|
||||
case "index":
|
||||
return &dsp.IndexNode{I: int(floatParam(p, "i"))}, nil
|
||||
|
||||
case "slice":
|
||||
return &dsp.SliceNode{Start: int(floatParam(p, "start")), End: int(floatParam(p, "end"))}, nil
|
||||
|
||||
case "sum":
|
||||
return &dsp.SumNode{}, nil
|
||||
|
||||
case "mean":
|
||||
return &dsp.MeanNode{}, nil
|
||||
|
||||
case "min":
|
||||
return &dsp.MinNode{}, nil
|
||||
|
||||
case "max":
|
||||
return &dsp.MaxNode{}, nil
|
||||
|
||||
case "length":
|
||||
return &dsp.LengthNode{}, nil
|
||||
|
||||
case "fft":
|
||||
return &dsp.FFTNode{}, nil
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown node type %q", d.Type)
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
package synthetic
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/uopi/uopi/internal/broker"
|
||||
"github.com/uopi/uopi/internal/dsp"
|
||||
)
|
||||
|
||||
// runtimeGraph is the executable form of a synthetic signal's DAG. Nodes are
|
||||
// held in topological order so a single forward pass computes every value with
|
||||
// each node's inputs already resolved. Op-node state maps persist across
|
||||
// evaluations (for stateful nodes like moving_average / lua).
|
||||
type runtimeGraph struct {
|
||||
order []*rtNode // topological order (sources first, output last)
|
||||
sources []rtSource // source nodes, in topological order
|
||||
outputID string // id of the output node
|
||||
outType dsp.ValType // best-effort output type (scalar/array/unknown)
|
||||
}
|
||||
|
||||
type rtNode struct {
|
||||
id string
|
||||
kind string // source | op | output
|
||||
op dsp.Node // set for kind==op
|
||||
state map[string]any // persistent per-node state (op only)
|
||||
inputs []string // upstream node ids, in input order
|
||||
}
|
||||
|
||||
type rtSource struct {
|
||||
id string
|
||||
ref broker.SignalRef
|
||||
}
|
||||
|
||||
// sourceRefs returns the broker references for every source node, in a stable
|
||||
// order matching rg.sources.
|
||||
func (rg *runtimeGraph) sourceRefs() []broker.SignalRef {
|
||||
refs := make([]broker.SignalRef, len(rg.sources))
|
||||
for i, s := range rg.sources {
|
||||
refs[i] = s.ref
|
||||
}
|
||||
return refs
|
||||
}
|
||||
|
||||
// evalSample computes the output Sample (scalar or array) given the latest
|
||||
// value for each source node (keyed by source node id). Nodes are visited in
|
||||
// topological order so every input is present by the time a node is processed.
|
||||
//
|
||||
// Op dispatch:
|
||||
// - ArrayNode ops (reductions/producers) run natively on Samples.
|
||||
// - stateless elementwise ops broadcast over array inputs.
|
||||
// - stateful ops (filters) and lua are scalar-only; an array input errors,
|
||||
// since their per-evaluation state cannot be split across array lanes.
|
||||
func (rg *runtimeGraph) evalSample(sourceVals map[string]dsp.Sample) (dsp.Sample, error) {
|
||||
vals, err := rg.evalSampleTrace(sourceVals)
|
||||
if err != nil {
|
||||
return dsp.Sample{}, err
|
||||
}
|
||||
return vals[rg.outputID], nil
|
||||
}
|
||||
|
||||
// evalSampleTrace runs a full forward pass like evalSample but returns the value
|
||||
// computed for *every* node (sources, ops and the output), keyed by node id. It
|
||||
// is used by the editor's live/debug trace to show each node's current value.
|
||||
// On an op error it returns the values computed so far together with the error,
|
||||
// so partial results can still be displayed.
|
||||
func (rg *runtimeGraph) evalSampleTrace(sourceVals map[string]dsp.Sample) (map[string]dsp.Sample, error) {
|
||||
vals := make(map[string]dsp.Sample, len(rg.order)+len(sourceVals))
|
||||
for id, v := range sourceVals {
|
||||
vals[id] = v
|
||||
}
|
||||
for _, n := range rg.order {
|
||||
switch n.kind {
|
||||
case "op":
|
||||
in := make([]dsp.Sample, len(n.inputs))
|
||||
for i, id := range n.inputs {
|
||||
in[i] = vals[id]
|
||||
}
|
||||
r, err := evalOp(n, in)
|
||||
if err != nil {
|
||||
return vals, fmt.Errorf("node %s (%s): %w", n.id, n.op.Type(), err)
|
||||
}
|
||||
vals[n.id] = r
|
||||
case "output":
|
||||
if len(n.inputs) > 0 {
|
||||
vals[n.id] = vals[n.inputs[0]]
|
||||
}
|
||||
}
|
||||
}
|
||||
return vals, nil
|
||||
}
|
||||
|
||||
// evalOp runs a single op node over its Sample inputs, choosing the right
|
||||
// execution path for the node type.
|
||||
func evalOp(n *rtNode, in []dsp.Sample) (dsp.Sample, error) {
|
||||
if an, ok := n.op.(dsp.ArrayNode); ok {
|
||||
return an.ProcessSample(in, n.state)
|
||||
}
|
||||
if dsp.StatelessElementwise(n.op.Type()) {
|
||||
return dsp.ApplyElementwise(n.op, in, n.state)
|
||||
}
|
||||
// Stateful / lua: scalar-only.
|
||||
row := make([]float64, len(in))
|
||||
for i, s := range in {
|
||||
if s.IsArray {
|
||||
return dsp.Sample{}, fmt.Errorf("does not accept an array input")
|
||||
}
|
||||
row[i] = s.F
|
||||
}
|
||||
r, err := n.op.Process(row, n.state)
|
||||
if err != nil {
|
||||
return dsp.Sample{}, err
|
||||
}
|
||||
return dsp.Scalar(r), nil
|
||||
}
|
||||
|
||||
// eval is the scalar wrapper around evalSample, kept so callers and tests that
|
||||
// deal purely in float64 (legacy linear graphs, scalar sources) are unchanged.
|
||||
func (rg *runtimeGraph) eval(sourceVals map[string]float64) (float64, error) {
|
||||
sv := make(map[string]dsp.Sample, len(sourceVals))
|
||||
for id, v := range sourceVals {
|
||||
sv[id] = dsp.Scalar(v)
|
||||
}
|
||||
out, err := rg.evalSample(sv)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return out.F, nil
|
||||
}
|
||||
|
||||
// compileGraph converts a SignalDef into an executable runtimeGraph. When the
|
||||
// def carries an explicit Graph it is used directly; otherwise the legacy
|
||||
// Inputs+Pipeline form is converted to an equivalent linear graph (see toGraph).
|
||||
func compileGraph(def SignalDef) (*runtimeGraph, error) {
|
||||
g := toGraph(def)
|
||||
if g == nil || len(g.Nodes) == 0 {
|
||||
return &runtimeGraph{}, nil
|
||||
}
|
||||
order, err := topoOrder(g)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rg := &runtimeGraph{outputID: g.Output}
|
||||
// nodeType tracks each node's best-effort output type for static
|
||||
// propagation. Sources are unknown at compile time (their real type is
|
||||
// only known once data flows), so type errors here are advisory; runtime
|
||||
// Sample typing is authoritative.
|
||||
nodeType := make(map[string]dsp.ValType, len(order))
|
||||
for _, gn := range order {
|
||||
switch gn.Kind {
|
||||
case "source":
|
||||
rg.sources = append(rg.sources, rtSource{id: gn.ID, ref: broker.SignalRef{DS: gn.DS, Name: gn.Signal}})
|
||||
nodeType[gn.ID] = dsp.ValUnknown
|
||||
case "op":
|
||||
node, err := buildNode(NodeDef{Type: gn.Op, Params: gn.Params})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("node %q: %w", gn.ID, err)
|
||||
}
|
||||
inTypes := make([]dsp.ValType, len(gn.Inputs))
|
||||
for i, id := range gn.Inputs {
|
||||
inTypes[i] = nodeType[id]
|
||||
}
|
||||
ot, terr := dsp.OpOutputType(gn.Op, inTypes)
|
||||
if terr != nil {
|
||||
return nil, fmt.Errorf("node %q: %w", gn.ID, terr)
|
||||
}
|
||||
nodeType[gn.ID] = ot
|
||||
rg.order = append(rg.order, &rtNode{id: gn.ID, kind: "op", op: node, state: map[string]any{}, inputs: gn.Inputs})
|
||||
case "output":
|
||||
rg.outputID = gn.ID
|
||||
if len(gn.Inputs) > 0 {
|
||||
nodeType[gn.ID] = nodeType[gn.Inputs[0]]
|
||||
}
|
||||
rg.order = append(rg.order, &rtNode{id: gn.ID, kind: "output", inputs: gn.Inputs})
|
||||
default:
|
||||
return nil, fmt.Errorf("node %q: unknown kind %q", gn.ID, gn.Kind)
|
||||
}
|
||||
}
|
||||
rg.outType = nodeType[rg.outputID]
|
||||
return rg, nil
|
||||
}
|
||||
|
||||
// topoOrder returns the graph's nodes in a topological (dependency-first) order,
|
||||
// treating each node's Inputs as its predecessors. It errors on dangling input
|
||||
// references or cycles.
|
||||
func topoOrder(g *Graph) ([]GraphNode, error) {
|
||||
byID := make(map[string]GraphNode, len(g.Nodes))
|
||||
for _, n := range g.Nodes {
|
||||
byID[n.ID] = n
|
||||
}
|
||||
indeg := make(map[string]int, len(g.Nodes))
|
||||
succ := make(map[string][]string, len(g.Nodes))
|
||||
for _, n := range g.Nodes {
|
||||
if _, ok := indeg[n.ID]; !ok {
|
||||
indeg[n.ID] = 0
|
||||
}
|
||||
for _, in := range n.Inputs {
|
||||
if _, ok := byID[in]; !ok {
|
||||
return nil, fmt.Errorf("node %q references unknown input %q", n.ID, in)
|
||||
}
|
||||
indeg[n.ID]++
|
||||
succ[in] = append(succ[in], n.ID)
|
||||
}
|
||||
}
|
||||
// Seed the queue with roots, preserving the node slice order for determinism.
|
||||
queue := make([]string, 0, len(g.Nodes))
|
||||
for _, n := range g.Nodes {
|
||||
if indeg[n.ID] == 0 {
|
||||
queue = append(queue, n.ID)
|
||||
}
|
||||
}
|
||||
order := make([]GraphNode, 0, len(g.Nodes))
|
||||
for len(queue) > 0 {
|
||||
id := queue[0]
|
||||
queue = queue[1:]
|
||||
order = append(order, byID[id])
|
||||
for _, s := range succ[id] {
|
||||
indeg[s]--
|
||||
if indeg[s] == 0 {
|
||||
queue = append(queue, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(order) != len(g.Nodes) {
|
||||
return nil, errors.New("graph contains a cycle")
|
||||
}
|
||||
return order, nil
|
||||
}
|
||||
|
||||
// toGraph returns the DAG for a SignalDef. If def.Graph is set it is returned
|
||||
// as-is. Otherwise the legacy linear form is converted: each input signal
|
||||
// becomes a source node, the pipeline becomes a chain of op nodes (the first op
|
||||
// receiving every source, each later op the previous op's output), terminated by
|
||||
// an output node. With no pipeline the output takes the first source directly,
|
||||
// matching the old runPipeline behaviour.
|
||||
func toGraph(def SignalDef) *Graph {
|
||||
if def.Graph != nil && len(def.Graph.Nodes) > 0 {
|
||||
return def.Graph
|
||||
}
|
||||
|
||||
inputs := def.Inputs
|
||||
if len(inputs) == 0 && def.DS != "" && def.Signal != "" {
|
||||
inputs = []InputRef{{DS: def.DS, Signal: def.Signal}}
|
||||
}
|
||||
|
||||
nodes := make([]GraphNode, 0, len(inputs)+len(def.Pipeline)+1)
|
||||
srcIDs := make([]string, 0, len(inputs))
|
||||
for i, inp := range inputs {
|
||||
id := fmt.Sprintf("s%d", i)
|
||||
nodes = append(nodes, GraphNode{ID: id, Kind: "source", DS: inp.DS, Signal: inp.Signal})
|
||||
srcIDs = append(srcIDs, id)
|
||||
}
|
||||
|
||||
opIDs := make([]string, 0, len(def.Pipeline))
|
||||
for i, nd := range def.Pipeline {
|
||||
id := fmt.Sprintf("p%d", i)
|
||||
var ins []string
|
||||
if i == 0 {
|
||||
ins = srcIDs
|
||||
} else {
|
||||
ins = []string{opIDs[i-1]}
|
||||
}
|
||||
nodes = append(nodes, GraphNode{ID: id, Kind: "op", Op: nd.Type, Params: nd.Params, Inputs: ins})
|
||||
opIDs = append(opIDs, id)
|
||||
}
|
||||
|
||||
var outInputs []string
|
||||
if len(opIDs) > 0 {
|
||||
outInputs = []string{opIDs[len(opIDs)-1]}
|
||||
} else if len(srcIDs) > 0 {
|
||||
outInputs = []string{srcIDs[0]}
|
||||
}
|
||||
nodes = append(nodes, GraphNode{ID: "out", Kind: "output", Inputs: outInputs})
|
||||
|
||||
return &Graph{Nodes: nodes, Output: "out"}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package synthetic
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// evalDef compiles a SignalDef and evaluates it against per-source values keyed
|
||||
// by source node id.
|
||||
func evalDef(t *testing.T, def SignalDef, srcVals map[string]float64) float64 {
|
||||
t.Helper()
|
||||
rg, err := compileGraph(def)
|
||||
if err != nil {
|
||||
t.Fatalf("compileGraph: %v", err)
|
||||
}
|
||||
out, err := rg.eval(srcVals)
|
||||
if err != nil {
|
||||
t.Fatalf("eval: %v", err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestGraphMultiInputDAG verifies that an intermediate op can take two
|
||||
// independently-wired sources — the capability the old linear pipeline lacked.
|
||||
func TestGraphMultiInputDAG(t *testing.T) {
|
||||
def := SignalDef{
|
||||
Name: "diff",
|
||||
Graph: &Graph{
|
||||
Output: "out",
|
||||
Nodes: []GraphNode{
|
||||
{ID: "a", Kind: "source", DS: "x", Signal: "left"},
|
||||
{ID: "b", Kind: "source", DS: "x", Signal: "right"},
|
||||
{ID: "sub", Kind: "op", Op: "subtract", Inputs: []string{"a", "b"}},
|
||||
{ID: "out", Kind: "output", Inputs: []string{"sub"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
got := evalDef(t, def, map[string]float64{"a": 10, "b": 3})
|
||||
if got != 7 {
|
||||
t.Errorf("subtract DAG: want 7, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGraphExprNamedInputs verifies expr nodes bind arbitrary named inputs in
|
||||
// wired order.
|
||||
func TestGraphExprNamedInputs(t *testing.T) {
|
||||
def := SignalDef{
|
||||
Name: "formula",
|
||||
Graph: &Graph{
|
||||
Output: "out",
|
||||
Nodes: []GraphNode{
|
||||
{ID: "a", Kind: "source", DS: "x", Signal: "p"},
|
||||
{ID: "b", Kind: "source", DS: "x", Signal: "q"},
|
||||
{ID: "e", Kind: "op", Op: "expr", Inputs: []string{"a", "b"},
|
||||
Params: map[string]any{"expr": "price * qty", "vars": []any{"price", "qty"}}},
|
||||
{ID: "out", Kind: "output", Inputs: []string{"e"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
got := evalDef(t, def, map[string]float64{"a": 4, "b": 2.5})
|
||||
if math.Abs(got-10) > 1e-9 {
|
||||
t.Errorf("expr named inputs: want 10, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGraphFanInToExpr exercises a non-trivial DAG: two ops feeding one expr.
|
||||
func TestGraphFanInToExpr(t *testing.T) {
|
||||
def := SignalDef{
|
||||
Name: "combo",
|
||||
Graph: &Graph{
|
||||
Output: "out",
|
||||
Nodes: []GraphNode{
|
||||
{ID: "a", Kind: "source", DS: "x", Signal: "p"},
|
||||
{ID: "b", Kind: "source", DS: "x", Signal: "q"},
|
||||
{ID: "g", Kind: "op", Op: "gain", Inputs: []string{"a"}, Params: map[string]any{"gain": 2.0}},
|
||||
{ID: "o", Kind: "op", Op: "offset", Inputs: []string{"b"}, Params: map[string]any{"offset": 1.0}},
|
||||
{ID: "e", Kind: "op", Op: "expr", Inputs: []string{"g", "o"}, Params: map[string]any{"expr": "a + b"}},
|
||||
{ID: "out", Kind: "output", Inputs: []string{"e"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
// g = 5*2 = 10 ; o = 4+1 = 5 ; a+b = 15
|
||||
got := evalDef(t, def, map[string]float64{"a": 5, "b": 4})
|
||||
if math.Abs(got-15) > 1e-9 {
|
||||
t.Errorf("fan-in DAG: want 15, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGraphLegacyConversion verifies the linear Inputs+Pipeline form still
|
||||
// evaluates correctly via the graph runtime.
|
||||
func TestGraphLegacyConversion(t *testing.T) {
|
||||
def := SignalDef{
|
||||
Name: "legacy",
|
||||
DS: "x",
|
||||
Signal: "p",
|
||||
Pipeline: []NodeDef{{Type: "gain", Params: map[string]any{"gain": 3.0}}},
|
||||
}
|
||||
rg, err := compileGraph(def)
|
||||
if err != nil {
|
||||
t.Fatalf("compileGraph: %v", err)
|
||||
}
|
||||
if len(rg.sources) != 1 {
|
||||
t.Fatalf("want 1 source, got %d", len(rg.sources))
|
||||
}
|
||||
got, err := rg.eval(map[string]float64{rg.sources[0].id: 4})
|
||||
if err != nil {
|
||||
t.Fatalf("eval: %v", err)
|
||||
}
|
||||
if got != 12 {
|
||||
t.Errorf("legacy gain: want 12, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGraphCycleRejected ensures a cyclic graph is refused at compile time.
|
||||
func TestGraphCycleRejected(t *testing.T) {
|
||||
def := SignalDef{
|
||||
Name: "cyclic",
|
||||
Graph: &Graph{
|
||||
Output: "out",
|
||||
Nodes: []GraphNode{
|
||||
{ID: "a", Kind: "op", Op: "gain", Inputs: []string{"b"}, Params: map[string]any{"gain": 1.0}},
|
||||
{ID: "b", Kind: "op", Op: "gain", Inputs: []string{"a"}, Params: map[string]any{"gain": 1.0}},
|
||||
{ID: "out", Kind: "output", Inputs: []string{"a"}},
|
||||
},
|
||||
},
|
||||
}
|
||||
if _, err := compileGraph(def); err == nil {
|
||||
t.Error("expected cycle to be rejected")
|
||||
}
|
||||
}
|
||||
@@ -20,9 +20,8 @@ const definitionsFile = "synthetic.json"
|
||||
|
||||
// signalState holds everything needed to run one synthetic signal.
|
||||
type signalState struct {
|
||||
def SignalDef
|
||||
nodes []dsp.Node
|
||||
states []map[string]any // one map per node, persistent across calls
|
||||
def SignalDef
|
||||
rg *runtimeGraph // compiled DAG; op-node state persists across evaluations
|
||||
|
||||
// cancel stops the goroutine driving this signal.
|
||||
cancel context.CancelFunc
|
||||
@@ -80,7 +79,7 @@ func (s *Synthetic) ListSignals(_ context.Context) ([]datasource.Metadata, error
|
||||
|
||||
out := make([]datasource.Metadata, 0, len(s.signals))
|
||||
for _, st := range s.signals {
|
||||
out = append(out, defToMetadata(st.def))
|
||||
out = append(out, defToMetadata(st.def, outTypeOf(st)))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -95,7 +94,7 @@ func (s *Synthetic) FilteredMetadata(keep func(SignalDef) bool) []datasource.Met
|
||||
out := make([]datasource.Metadata, 0, len(s.signals))
|
||||
for _, st := range s.signals {
|
||||
if keep(st.def) {
|
||||
out = append(out, defToMetadata(st.def))
|
||||
out = append(out, defToMetadata(st.def, outTypeOf(st)))
|
||||
}
|
||||
}
|
||||
return out
|
||||
@@ -110,7 +109,7 @@ func (s *Synthetic) GetMetadata(_ context.Context, signal string) (datasource.Me
|
||||
if !ok {
|
||||
return datasource.Metadata{}, datasource.ErrNotFound
|
||||
}
|
||||
return defToMetadata(st.def), nil
|
||||
return defToMetadata(st.def, outTypeOf(st)), nil
|
||||
}
|
||||
|
||||
// Subscribe registers ch to receive computed values for the named signal.
|
||||
@@ -123,19 +122,25 @@ func (s *Synthetic) Subscribe(ctx context.Context, signal string, ch chan<- data
|
||||
return nil, datasource.ErrNotFound
|
||||
}
|
||||
|
||||
// Collect the upstream references for this signal.
|
||||
refs := upstreamRefs(st.def)
|
||||
// Collect the source node references for this signal's DAG.
|
||||
refs := st.rg.sourceRefs()
|
||||
if len(refs) == 0 {
|
||||
return nil, fmt.Errorf("synthetic: signal %q has no upstream inputs", signal)
|
||||
}
|
||||
// Source node ids, index-aligned with refs, so updates map to graph inputs.
|
||||
srcIDs := make([]string, len(st.rg.sources))
|
||||
for i, s := range st.rg.sources {
|
||||
srcIDs[i] = s.id
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
|
||||
go func() {
|
||||
defer cancel()
|
||||
|
||||
// Latest value per upstream input index.
|
||||
latest := make([]float64, len(refs))
|
||||
// Latest value and timestamp per source node id.
|
||||
latest := make(map[string]dsp.Sample, len(refs))
|
||||
latestTs := make([]time.Time, len(refs))
|
||||
ready := make([]bool, len(refs))
|
||||
|
||||
// Subscribe to every upstream ref via the broker.
|
||||
@@ -159,7 +164,7 @@ func (s *Synthetic) Subscribe(ctx context.Context, signal string, ch chan<- data
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
val := toFloat64(u.Value.Data)
|
||||
val := toSample(u.Value.Data)
|
||||
select {
|
||||
case updateCh <- indexedUpdate{idx: idx, val: val, ts: u.Value.Timestamp}:
|
||||
default:
|
||||
@@ -184,7 +189,8 @@ func (s *Synthetic) Subscribe(ctx context.Context, signal string, ch chan<- data
|
||||
return
|
||||
|
||||
case upd := <-updateCh:
|
||||
latest[upd.idx] = upd.val
|
||||
latest[srcIDs[upd.idx]] = upd.val
|
||||
latestTs[upd.idx] = upd.ts
|
||||
ready[upd.idx] = true
|
||||
|
||||
// Only compute once we have at least one value for every input.
|
||||
@@ -199,7 +205,19 @@ func (s *Synthetic) Subscribe(ctx context.Context, signal string, ch chan<- data
|
||||
continue
|
||||
}
|
||||
|
||||
// Run the pipeline.
|
||||
// The output is computed from the latest value of every input, so
|
||||
// its timestamp is the most recent contributing sample time. Using
|
||||
// the triggering update's timestamp instead would drag the output
|
||||
// back in time whenever a slow/stale input fired, producing
|
||||
// non-monotonic or duplicated timestamps on plots.
|
||||
outTs := latestTs[0]
|
||||
for _, ts := range latestTs[1:] {
|
||||
if ts.After(outTs) {
|
||||
outTs = ts
|
||||
}
|
||||
}
|
||||
|
||||
// Evaluate the DAG.
|
||||
s.mu.RLock()
|
||||
cur, stillExists := s.signals[signal]
|
||||
s.mu.RUnlock()
|
||||
@@ -207,15 +225,15 @@ func (s *Synthetic) Subscribe(ctx context.Context, signal string, ch chan<- data
|
||||
return
|
||||
}
|
||||
|
||||
result, err := runPipeline(cur.nodes, cur.states, latest)
|
||||
result, err := cur.rg.evalSample(latest)
|
||||
if err != nil {
|
||||
s.log.Warn("synthetic: pipeline error", "signal", signal, "err", err)
|
||||
continue
|
||||
}
|
||||
|
||||
v := datasource.Value{
|
||||
Timestamp: upd.ts,
|
||||
Data: result,
|
||||
Timestamp: outTs,
|
||||
Data: result.AsAny(),
|
||||
Quality: datasource.QualityGood,
|
||||
}
|
||||
select {
|
||||
@@ -245,10 +263,13 @@ func (s *Synthetic) AddSignal(def SignalDef) error {
|
||||
if def.Name == "" {
|
||||
return errors.New("signal name must not be empty")
|
||||
}
|
||||
if def.Version < 1 {
|
||||
def.Version = 1
|
||||
}
|
||||
|
||||
nodes, err := BuildPipeline(def.Pipeline)
|
||||
rg, err := compileGraph(def)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build pipeline: %w", err)
|
||||
return fmt.Errorf("compile graph: %w", err)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
@@ -257,16 +278,7 @@ func (s *Synthetic) AddSignal(def SignalDef) error {
|
||||
return fmt.Errorf("signal %q already exists", def.Name)
|
||||
}
|
||||
|
||||
states := make([]map[string]any, len(nodes))
|
||||
for i := range states {
|
||||
states[i] = make(map[string]any)
|
||||
}
|
||||
|
||||
st := &signalState{
|
||||
def: def,
|
||||
nodes: nodes,
|
||||
states: states,
|
||||
}
|
||||
st := &signalState{def: def, rg: rg}
|
||||
s.signals[def.Name] = st
|
||||
s.mu.Unlock()
|
||||
|
||||
@@ -320,14 +332,9 @@ func (s *Synthetic) UpdateSignal(def SignalDef) error {
|
||||
return errors.New("signal name must not be empty")
|
||||
}
|
||||
|
||||
nodes, err := BuildPipeline(def.Pipeline)
|
||||
rg, err := compileGraph(def)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build pipeline: %w", err)
|
||||
}
|
||||
|
||||
states := make([]map[string]any, len(nodes))
|
||||
for i := range states {
|
||||
states[i] = make(map[string]any)
|
||||
return fmt.Errorf("compile graph: %w", err)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
@@ -336,10 +343,20 @@ func (s *Synthetic) UpdateSignal(def SignalDef) error {
|
||||
s.mu.Unlock()
|
||||
return datasource.ErrNotFound
|
||||
}
|
||||
// Preserve the superseded revision as a backup and bump the version.
|
||||
oldDef := old.def
|
||||
if oldDef.Version < 1 {
|
||||
oldDef.Version = 1
|
||||
}
|
||||
if err := s.backupVersion(oldDef); err != nil {
|
||||
s.mu.Unlock()
|
||||
return fmt.Errorf("back up revision: %w", err)
|
||||
}
|
||||
def.Version = oldDef.Version + 1
|
||||
if old.cancel != nil {
|
||||
old.cancel()
|
||||
}
|
||||
s.signals[def.Name] = &signalState{def: def, nodes: nodes, states: states}
|
||||
s.signals[def.Name] = &signalState{def: def, rg: rg}
|
||||
s.mu.Unlock()
|
||||
|
||||
if err := s.saveDefs(); err != nil {
|
||||
@@ -402,78 +419,33 @@ func (s *Synthetic) saveDefs() error {
|
||||
return os.WriteFile(s.defsFilePath(), data, 0o644)
|
||||
}
|
||||
|
||||
// startSignal builds the pipeline for def and registers the signalState.
|
||||
// startSignal compiles the DAG for def and registers the signalState.
|
||||
// The actual goroutines are started lazily by Subscribe.
|
||||
func (s *Synthetic) startSignal(def SignalDef) error {
|
||||
nodes, err := BuildPipeline(def.Pipeline)
|
||||
rg, err := compileGraph(def)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build pipeline for %q: %w", def.Name, err)
|
||||
}
|
||||
|
||||
states := make([]map[string]any, len(nodes))
|
||||
for i := range states {
|
||||
states[i] = make(map[string]any)
|
||||
return fmt.Errorf("compile graph for %q: %w", def.Name, err)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.signals[def.Name] = &signalState{
|
||||
def: def,
|
||||
nodes: nodes,
|
||||
states: states,
|
||||
}
|
||||
s.signals[def.Name] = &signalState{def: def, rg: rg}
|
||||
s.mu.Unlock()
|
||||
|
||||
s.log.Info("synthetic: signal registered", "name", def.Name)
|
||||
return nil
|
||||
}
|
||||
|
||||
// runPipeline executes all nodes in sequence. The output of node N becomes
|
||||
// input[0] of node N+1. For the first node, inputs is the full upstream slice.
|
||||
func runPipeline(nodes []dsp.Node, states []map[string]any, inputs []float64) (float64, error) {
|
||||
if len(nodes) == 0 {
|
||||
if len(inputs) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
return inputs[0], nil
|
||||
// defToMetadata converts a SignalDef into a datasource.Metadata. outType is the
|
||||
// compiled graph's best-effort output type; an array output is reported as a
|
||||
// waveform (TypeFloat64Array) so widgets can pick a compatible view.
|
||||
func defToMetadata(def SignalDef, outType dsp.ValType) datasource.Metadata {
|
||||
dt := datasource.TypeFloat64
|
||||
if outType == dsp.ValArray {
|
||||
dt = datasource.TypeFloat64Array
|
||||
}
|
||||
|
||||
// First node receives all upstream inputs.
|
||||
cur := inputs
|
||||
var result float64
|
||||
var err error
|
||||
|
||||
for i, node := range nodes {
|
||||
result, err = node.Process(cur, states[i])
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("node %d (%s): %w", i, node.Type(), err)
|
||||
}
|
||||
// Subsequent nodes receive only the single output of the previous node.
|
||||
cur = []float64{result}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// upstreamRefs returns the broker.SignalRef list for a SignalDef.
|
||||
// If Inputs is set, those take precedence; otherwise DS+Signal is used.
|
||||
func upstreamRefs(def SignalDef) []broker.SignalRef {
|
||||
if len(def.Inputs) > 0 {
|
||||
refs := make([]broker.SignalRef, len(def.Inputs))
|
||||
for i, inp := range def.Inputs {
|
||||
refs[i] = broker.SignalRef{DS: inp.DS, Name: inp.Signal}
|
||||
}
|
||||
return refs
|
||||
}
|
||||
if def.DS != "" && def.Signal != "" {
|
||||
return []broker.SignalRef{{DS: def.DS, Name: def.Signal}}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// defToMetadata converts a SignalDef into a datasource.Metadata.
|
||||
func defToMetadata(def SignalDef) datasource.Metadata {
|
||||
return datasource.Metadata{
|
||||
Name: def.Name,
|
||||
Type: datasource.TypeFloat64,
|
||||
Type: dt,
|
||||
Unit: def.Meta.Unit,
|
||||
Description: def.Meta.Description,
|
||||
DisplayLow: def.Meta.DisplayLow,
|
||||
@@ -482,7 +454,97 @@ func defToMetadata(def SignalDef) datasource.Metadata {
|
||||
}
|
||||
}
|
||||
|
||||
// toFloat64 coerces any numeric value from a datasource.Value.Data to float64.
|
||||
// outTypeOf returns the compiled output type for a signal state, or unknown.
|
||||
func outTypeOf(st *signalState) dsp.ValType {
|
||||
if st == nil || st.rg == nil {
|
||||
return dsp.ValUnknown
|
||||
}
|
||||
return st.rg.outType
|
||||
}
|
||||
|
||||
// TraceNode is a single node's computed value in a debug trace.
|
||||
type TraceNode struct {
|
||||
Value any `json:"value"`
|
||||
Type string `json:"type"` // "scalar" | "array"
|
||||
Approx bool `json:"approx,omitempty"`
|
||||
}
|
||||
|
||||
// TraceResult is the per-node outcome of a single-shot evaluation of an
|
||||
// (unsaved) synthetic graph, used by the editor's live/debug overlay.
|
||||
type TraceResult struct {
|
||||
Nodes map[string]TraceNode `json:"nodes"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// Trace compiles def and evaluates it once with fresh state, returning the value
|
||||
// computed for every node. Source node values are obtained via read(ds, name)
|
||||
// (typically a broker ReadNow snapshot); a source that cannot be read defaults to
|
||||
// scalar 0. Stateful ops are flagged Approx since their true running value
|
||||
// depends on accumulated state this single-shot pass does not have. A per-node
|
||||
// evaluation error is returned in Error with the partial values still populated.
|
||||
func (s *Synthetic) Trace(def SignalDef, read func(ds, name string) (any, error)) (TraceResult, error) {
|
||||
rg, err := compileGraph(def)
|
||||
if err != nil {
|
||||
return TraceResult{}, err
|
||||
}
|
||||
sourceVals := make(map[string]dsp.Sample, len(rg.sources))
|
||||
for _, src := range rg.sources {
|
||||
raw, rerr := read(src.ref.DS, src.ref.Name)
|
||||
if rerr != nil || raw == nil {
|
||||
sourceVals[src.id] = dsp.Scalar(0)
|
||||
continue
|
||||
}
|
||||
sourceVals[src.id] = toSample(raw)
|
||||
}
|
||||
vals, evalErr := rg.evalSampleTrace(sourceVals)
|
||||
|
||||
opType := make(map[string]string, len(rg.order))
|
||||
for _, n := range rg.order {
|
||||
if n.kind == "op" && n.op != nil {
|
||||
opType[n.id] = n.op.Type()
|
||||
}
|
||||
}
|
||||
res := TraceResult{Nodes: make(map[string]TraceNode, len(vals))}
|
||||
for id, sample := range vals {
|
||||
tn := TraceNode{Value: sample.AsAny(), Type: "scalar"}
|
||||
if sample.IsArray {
|
||||
tn.Type = "array"
|
||||
}
|
||||
if ot, ok := opType[id]; ok && dsp.IsStateful(ot) {
|
||||
tn.Approx = true
|
||||
}
|
||||
res.Nodes[id] = tn
|
||||
}
|
||||
if evalErr != nil {
|
||||
res.Error = evalErr.Error()
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// toSample coerces a datasource.Value.Data into a dsp.Sample: arrays become
|
||||
// array Samples (waveforms), everything else a scalar Sample.
|
||||
func toSample(v any) dsp.Sample {
|
||||
switch val := v.(type) {
|
||||
case []float64:
|
||||
return dsp.Array(val)
|
||||
case []float32:
|
||||
out := make([]float64, len(val))
|
||||
for i, e := range val {
|
||||
out[i] = float64(e)
|
||||
}
|
||||
return dsp.Array(out)
|
||||
case []int:
|
||||
out := make([]float64, len(val))
|
||||
for i, e := range val {
|
||||
out[i] = float64(e)
|
||||
}
|
||||
return dsp.Array(out)
|
||||
default:
|
||||
return dsp.Scalar(toFloat64(v))
|
||||
}
|
||||
}
|
||||
|
||||
// toFloat64 coerces any numeric scalar value from a datasource.Value.Data to float64.
|
||||
func toFloat64(v any) float64 {
|
||||
switch val := v.(type) {
|
||||
case float64:
|
||||
@@ -508,6 +570,6 @@ func toFloat64(v any) float64 {
|
||||
// indexedUpdate carries a value from one upstream goroutine to the pipeline runner.
|
||||
type indexedUpdate struct {
|
||||
idx int
|
||||
val float64
|
||||
val dsp.Sample
|
||||
ts time.Time
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
package synthetic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/uopi/uopi/internal/broker"
|
||||
"github.com/uopi/uopi/internal/datasource"
|
||||
)
|
||||
|
||||
// seqSource is a test DataSource that emits a fixed sequence of values, each
|
||||
// carrying its own timestamp, so tests can control upstream sample times.
|
||||
type seqSource struct {
|
||||
name string
|
||||
seq []datasource.Value
|
||||
}
|
||||
|
||||
func (s *seqSource) Name() string { return s.name }
|
||||
func (s *seqSource) Connect(context.Context) error { return nil }
|
||||
func (s *seqSource) ListSignals(context.Context) ([]datasource.Metadata, error) { return nil, nil }
|
||||
func (s *seqSource) GetMetadata(context.Context, string) (datasource.Metadata, error) {
|
||||
return datasource.Metadata{Name: "x", Type: datasource.TypeFloat64}, nil
|
||||
}
|
||||
func (s *seqSource) Write(context.Context, string, any) error { return datasource.ErrNotWritable }
|
||||
func (s *seqSource) History(context.Context, string, time.Time, time.Time, int) ([]datasource.Value, error) {
|
||||
return nil, datasource.ErrHistoryUnavailable
|
||||
}
|
||||
func (s *seqSource) Subscribe(ctx context.Context, _ string, ch chan<- datasource.Value) (datasource.CancelFunc, error) {
|
||||
go func() {
|
||||
for _, v := range s.seq {
|
||||
select {
|
||||
case ch <- v:
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
time.Sleep(8 * time.Millisecond)
|
||||
}
|
||||
}()
|
||||
return func() {}, nil
|
||||
}
|
||||
|
||||
// TestSubscribePreservesUpstreamTimestamp verifies a single-source synthetic
|
||||
// emits each computed value with the upstream sample's timestamp.
|
||||
func TestSubscribePreservesUpstreamTimestamp(t *testing.T) {
|
||||
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
base := time.Date(2026, 6, 19, 10, 0, 0, 0, time.UTC)
|
||||
src := &seqSource{name: "src", seq: []datasource.Value{
|
||||
{Timestamp: base.Add(1 * time.Second), Data: 1.0, Quality: datasource.QualityGood},
|
||||
{Timestamp: base.Add(2 * time.Second), Data: 2.0, Quality: datasource.QualityGood},
|
||||
{Timestamp: base.Add(3 * time.Second), Data: 3.0, Quality: datasource.QualityGood},
|
||||
}}
|
||||
|
||||
brk := broker.New(ctx, log)
|
||||
brk.Register(src)
|
||||
syn := New(t.TempDir(), brk, log)
|
||||
if err := syn.Connect(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := syn.AddSignal(SignalDef{
|
||||
Name: "g", DS: "src", Signal: "x",
|
||||
Pipeline: []NodeDef{{Type: "gain", Params: map[string]any{"gain": 10.0}}},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ch := make(chan datasource.Value, 8)
|
||||
if _, err := syn.Subscribe(ctx, "g", ch); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
want := []time.Time{base.Add(1 * time.Second), base.Add(2 * time.Second), base.Add(3 * time.Second)}
|
||||
for i, w := range want {
|
||||
select {
|
||||
case v := <-ch:
|
||||
if !v.Timestamp.Equal(w) {
|
||||
t.Errorf("emit #%d timestamp: want %s, got %s", i, w, v.Timestamp)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatalf("timeout waiting for emit #%d", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSubscribeMultiSourceUsesLatestTimestamp verifies that a synthetic combining
|
||||
// two independent sources stamps each output with the MOST RECENT contributing
|
||||
// sample time — not the timestamp of whichever source happened to trigger the
|
||||
// computation. A slow source carrying a stale timestamp must not drag the output
|
||||
// backwards in time (which previously produced wrong/non-monotonic plot points).
|
||||
func TestSubscribeMultiSourceUsesLatestTimestamp(t *testing.T) {
|
||||
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
now := time.Date(2026, 6, 19, 12, 0, 0, 0, time.UTC)
|
||||
// Fast source A with current timestamps.
|
||||
a := &seqSource{name: "A", seq: []datasource.Value{
|
||||
{Timestamp: now.Add(10 * time.Second), Data: 1.0, Quality: datasource.QualityGood},
|
||||
{Timestamp: now.Add(11 * time.Second), Data: 2.0, Quality: datasource.QualityGood},
|
||||
{Timestamp: now.Add(12 * time.Second), Data: 3.0, Quality: datasource.QualityGood},
|
||||
{Timestamp: now.Add(13 * time.Second), Data: 4.0, Quality: datasource.QualityGood},
|
||||
}}
|
||||
// Slow source B: a single sample with a much older timestamp.
|
||||
b := &seqSource{name: "B", seq: []datasource.Value{
|
||||
{Timestamp: now.Add(1 * time.Second), Data: 100.0, Quality: datasource.QualityGood},
|
||||
}}
|
||||
|
||||
brk := broker.New(ctx, log)
|
||||
brk.Register(a)
|
||||
brk.Register(b)
|
||||
syn := New(t.TempDir(), brk, log)
|
||||
if err := syn.Connect(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := syn.AddSignal(SignalDef{
|
||||
Name: "diff",
|
||||
Graph: &Graph{Output: "out", Nodes: []GraphNode{
|
||||
{ID: "sa", Kind: "source", DS: "A", Signal: "x"},
|
||||
{ID: "sb", Kind: "source", DS: "B", Signal: "x"},
|
||||
{ID: "sub", Kind: "op", Op: "subtract", Inputs: []string{"sa", "sb"}},
|
||||
{ID: "out", Kind: "output", Inputs: []string{"sub"}},
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
ch := make(chan datasource.Value, 16)
|
||||
if _, err := syn.Subscribe(ctx, "diff", ch); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var last time.Time
|
||||
for i := 0; i < 4; i++ {
|
||||
select {
|
||||
case v := <-ch:
|
||||
// The stale source-B timestamp (t=1s) must never be used: every output
|
||||
// is stamped with the newest input time, so emits stay monotonic.
|
||||
if v.Timestamp.Equal(now.Add(1 * time.Second)) {
|
||||
t.Errorf("emit #%d used the stale source-B timestamp %s", i, v.Timestamp)
|
||||
}
|
||||
if !last.IsZero() && v.Timestamp.Before(last) {
|
||||
t.Errorf("emit #%d went backwards: %s before previous %s", i, v.Timestamp, last)
|
||||
}
|
||||
last = v.Timestamp
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatalf("timeout waiting for emit #%d", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package synthetic
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/uopi/uopi/internal/datasource"
|
||||
)
|
||||
|
||||
// VersionMeta describes a single persisted revision of a synthetic signal,
|
||||
// mirroring storage.VersionMeta so the frontend treats every versioned document
|
||||
// type uniformly.
|
||||
type VersionMeta struct {
|
||||
Version int `json:"version"`
|
||||
Name string `json:"name"`
|
||||
Tag string `json:"tag,omitempty"`
|
||||
Current bool `json:"current"`
|
||||
SavedAt time.Time `json:"savedAt"`
|
||||
}
|
||||
|
||||
func (s *Synthetic) versionsDir() string {
|
||||
return filepath.Join(s.storePath, "synthetic_versions")
|
||||
}
|
||||
|
||||
// slugForFile maps an arbitrary signal name to a filesystem-safe stem. A short
|
||||
// hash of the full name is appended so distinct names that sanitise to the same
|
||||
// stem (e.g. "A:B" and "A_B") never share backup files.
|
||||
func slugForFile(name string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range name {
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' || r == '_' {
|
||||
b.WriteRune(r)
|
||||
} else {
|
||||
b.WriteByte('_')
|
||||
}
|
||||
}
|
||||
h := fnv.New32a()
|
||||
_, _ = h.Write([]byte(name))
|
||||
return fmt.Sprintf("%s-%08x", b.String(), h.Sum32())
|
||||
}
|
||||
|
||||
func (s *Synthetic) versionPath(name string, version int) string {
|
||||
return filepath.Join(s.versionsDir(), fmt.Sprintf("%s.v%d.json", slugForFile(name), version))
|
||||
}
|
||||
|
||||
// backupVersion writes a single signal revision to the versions directory.
|
||||
func (s *Synthetic) backupVersion(def SignalDef) error {
|
||||
if err := os.MkdirAll(s.versionsDir(), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.MarshalIndent(def, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(s.versionPath(def.Name, def.Version), data, 0o644)
|
||||
}
|
||||
|
||||
// Versions returns metadata for every persisted revision of the named signal,
|
||||
// newest first. The live revision is flagged Current.
|
||||
func (s *Synthetic) Versions(name string) ([]VersionMeta, error) {
|
||||
cur, err := s.GetSignal(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
curV := cur.Version
|
||||
if curV < 1 {
|
||||
curV = 1
|
||||
}
|
||||
|
||||
var savedAt time.Time
|
||||
if info, err := os.Stat(s.defsFilePath()); err == nil {
|
||||
savedAt = info.ModTime()
|
||||
}
|
||||
out := []VersionMeta{{Version: curV, Name: cur.Name, Tag: cur.Tag, Current: true, SavedAt: savedAt}}
|
||||
|
||||
entries, err := os.ReadDir(s.versionsDir())
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return nil, err
|
||||
}
|
||||
prefix := slugForFile(name) + ".v"
|
||||
for _, e := range entries {
|
||||
fn := e.Name()
|
||||
if e.IsDir() || !strings.HasPrefix(fn, prefix) || !strings.HasSuffix(fn, ".json") {
|
||||
continue
|
||||
}
|
||||
vStr := strings.TrimSuffix(strings.TrimPrefix(fn, prefix), ".json")
|
||||
v, err := strconv.Atoi(vStr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
def, err := s.readVersion(name, v)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
info, err := e.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, VersionMeta{Version: v, Name: def.Name, Tag: def.Tag, SavedAt: info.ModTime()})
|
||||
}
|
||||
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].Version > out[j].Version })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetVersion returns a specific revision of the named signal. The current
|
||||
// revision comes from the live store; older revisions from their backup file.
|
||||
func (s *Synthetic) GetVersion(name string, version int) (SignalDef, error) {
|
||||
cur, err := s.GetSignal(name)
|
||||
if err != nil {
|
||||
return SignalDef{}, err
|
||||
}
|
||||
curV := cur.Version
|
||||
if curV < 1 {
|
||||
curV = 1
|
||||
}
|
||||
if version == curV {
|
||||
return cur, nil
|
||||
}
|
||||
return s.readVersion(name, version)
|
||||
}
|
||||
|
||||
func (s *Synthetic) readVersion(name string, version int) (SignalDef, error) {
|
||||
data, err := os.ReadFile(s.versionPath(name, version))
|
||||
if os.IsNotExist(err) {
|
||||
return SignalDef{}, datasource.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return SignalDef{}, err
|
||||
}
|
||||
var def SignalDef
|
||||
if err := json.Unmarshal(data, &def); err != nil {
|
||||
return SignalDef{}, fmt.Errorf("parse revision: %w", err)
|
||||
}
|
||||
return def, nil
|
||||
}
|
||||
|
||||
// PromoteVersion makes a past revision current by re-saving it on top of
|
||||
// history (non-destructive). Returns the resulting current definition.
|
||||
func (s *Synthetic) PromoteVersion(name string, version int) (SignalDef, error) {
|
||||
def, err := s.GetVersion(name, version)
|
||||
if err != nil {
|
||||
return SignalDef{}, err
|
||||
}
|
||||
def.Tag = fmt.Sprintf("restored from v%d", version)
|
||||
if err := s.UpdateSignal(def); err != nil {
|
||||
return SignalDef{}, err
|
||||
}
|
||||
return s.GetSignal(name)
|
||||
}
|
||||
|
||||
// ForkVersion creates a brand-new synthetic signal from a specific revision,
|
||||
// assigning a fresh unique name and resetting its version to 1.
|
||||
func (s *Synthetic) ForkVersion(name string, version int) (SignalDef, error) {
|
||||
def, err := s.GetVersion(name, version)
|
||||
if err != nil {
|
||||
return SignalDef{}, err
|
||||
}
|
||||
def.Name = fmt.Sprintf("%s_fork_%d", name, time.Now().UnixMilli())
|
||||
def.Version = 1
|
||||
def.Tag = ""
|
||||
if err := s.AddSignal(def); err != nil {
|
||||
return SignalDef{}, err
|
||||
}
|
||||
return def, nil
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package synthetic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSyntheticVersioning(t *testing.T) {
|
||||
syn, _, cancel := newTestSynthetic(t)
|
||||
defer cancel()
|
||||
if err := syn.Connect(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// defWithGain builds a fresh def each time, mirroring how the REST handler
|
||||
// decodes a new SignalDef per request (no aliasing of slices/maps).
|
||||
defWithGain := func(g float64) SignalDef {
|
||||
return SignalDef{Name: "wf", DS: "stub", Signal: "sine_1hz",
|
||||
Pipeline: []NodeDef{{Type: "gain", Params: map[string]any{"gain": g}}}}
|
||||
}
|
||||
|
||||
if err := syn.AddSignal(defWithGain(1.0)); err != nil {
|
||||
t.Fatalf("AddSignal: %v", err)
|
||||
}
|
||||
|
||||
cur, err := syn.GetSignal("wf")
|
||||
if err != nil || cur.Version != 1 {
|
||||
t.Fatalf("after add: version=%d err=%v", cur.Version, err)
|
||||
}
|
||||
|
||||
// Two edits → v2, v3.
|
||||
if err := syn.UpdateSignal(defWithGain(2.0)); err != nil {
|
||||
t.Fatalf("UpdateSignal: %v", err)
|
||||
}
|
||||
if err := syn.UpdateSignal(defWithGain(3.0)); err != nil {
|
||||
t.Fatalf("UpdateSignal: %v", err)
|
||||
}
|
||||
|
||||
cur, _ = syn.GetSignal("wf")
|
||||
if cur.Version != 3 {
|
||||
t.Fatalf("want current v3, got v%d", cur.Version)
|
||||
}
|
||||
|
||||
versions, err := syn.Versions("wf")
|
||||
if err != nil {
|
||||
t.Fatalf("Versions: %v", err)
|
||||
}
|
||||
if len(versions) != 3 {
|
||||
t.Fatalf("want 3 versions, got %d", len(versions))
|
||||
}
|
||||
if !versions[0].Current || versions[0].Version != 3 {
|
||||
t.Errorf("newest should be current v3: %+v", versions[0])
|
||||
}
|
||||
|
||||
// v1 backup retrievable with original gain.
|
||||
v1, err := syn.GetVersion("wf", 1)
|
||||
if err != nil {
|
||||
t.Fatalf("GetVersion v1: %v", err)
|
||||
}
|
||||
if v1.Pipeline[0].Params["gain"] != 1.0 {
|
||||
t.Errorf("v1 gain: want 1.0, got %v", v1.Pipeline[0].Params["gain"])
|
||||
}
|
||||
|
||||
// Promote v1 → becomes v4 (non-destructive).
|
||||
promoted, err := syn.PromoteVersion("wf", 1)
|
||||
if err != nil {
|
||||
t.Fatalf("Promote: %v", err)
|
||||
}
|
||||
if promoted.Version != 4 || promoted.Pipeline[0].Params["gain"] != 1.0 {
|
||||
t.Errorf("promote: version=%d gain=%v", promoted.Version, promoted.Pipeline[0].Params["gain"])
|
||||
}
|
||||
|
||||
// Fork v3 → fresh signal, version 1.
|
||||
forked, err := syn.ForkVersion("wf", 3)
|
||||
if err != nil {
|
||||
t.Fatalf("Fork: %v", err)
|
||||
}
|
||||
if forked.Version != 1 || forked.Name == "wf" {
|
||||
t.Errorf("fork: name=%q version=%d", forked.Name, forked.Version)
|
||||
}
|
||||
if forked.Pipeline[0].Params["gain"] != 3.0 {
|
||||
t.Errorf("fork gain: want 3.0, got %v", forked.Pipeline[0].Params["gain"])
|
||||
}
|
||||
if _, err := syn.GetSignal(forked.Name); err != nil {
|
||||
t.Errorf("forked signal not registered: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package datasource
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestWithUserAndUserFrom covers the context identity round-trip, including the
|
||||
// empty-user passthrough and the missing-identity fallback.
|
||||
func TestWithUserAndUserFrom(t *testing.T) {
|
||||
base := context.Background()
|
||||
|
||||
// No identity present.
|
||||
if u, ok := UserFrom(base); ok || u != "" {
|
||||
t.Errorf("UserFrom(empty) = %q,%v want \"\",false", u, ok)
|
||||
}
|
||||
|
||||
// Empty user must not attach a value.
|
||||
if ctx := WithUser(base, ""); ctx != base {
|
||||
t.Error("WithUser with empty user should return the original context")
|
||||
}
|
||||
|
||||
// Real identity round-trips.
|
||||
ctx := WithUser(base, "alice")
|
||||
if u, ok := UserFrom(ctx); !ok || u != "alice" {
|
||||
t.Errorf("UserFrom = %q,%v want alice,true", u, ok)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package dsp
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
// This file holds ArrayNode ops: those that operate natively on waveform
|
||||
// (float64 array) Samples — reductions (array→scalar), producers (array→array),
|
||||
// and element access. Each also implements the legacy scalar Node interface
|
||||
// (treating a scalar as a single-element array) so it remains usable from the
|
||||
// scalar eval path.
|
||||
|
||||
// reductionProcess adapts a scalar Process call to a reduction ArrayNode.
|
||||
func reductionProcess(n ArrayNode, in []float64, st map[string]any) (float64, error) {
|
||||
s, err := n.ProcessSample(scalarInputs(in), st)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return s.F, nil
|
||||
}
|
||||
|
||||
// ── IndexNode ───────────────────────────────────────────────────────────────
|
||||
|
||||
// IndexNode extracts element I of an array input (array→scalar).
|
||||
type IndexNode struct{ I int }
|
||||
|
||||
func (n *IndexNode) Type() string { return "index" }
|
||||
func (n *IndexNode) Process(in []float64, st map[string]any) (float64, error) {
|
||||
return reductionProcess(n, in, st)
|
||||
}
|
||||
func (n *IndexNode) ProcessSample(in []Sample, _ map[string]any) (Sample, error) {
|
||||
if len(in) == 0 {
|
||||
return Sample{}, errors.New("index: no inputs")
|
||||
}
|
||||
arr := in[0].AsArray()
|
||||
if n.I < 0 || n.I >= len(arr) {
|
||||
return Sample{}, fmt.Errorf("index: %d out of range [0,%d)", n.I, len(arr))
|
||||
}
|
||||
return Scalar(arr[n.I]), nil
|
||||
}
|
||||
|
||||
// ── SliceNode ───────────────────────────────────────────────────────────────
|
||||
|
||||
// SliceNode returns a sub-range [Start,End) of an array input (array→array),
|
||||
// clamped to the array bounds. End <= 0 means "to the end".
|
||||
type SliceNode struct{ Start, End int }
|
||||
|
||||
func (n *SliceNode) Type() string { return "slice" }
|
||||
func (n *SliceNode) Process(in []float64, st map[string]any) (float64, error) {
|
||||
s, err := n.ProcessSample(scalarInputs(in), st)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(s.Arr) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
return s.Arr[0], nil
|
||||
}
|
||||
func (n *SliceNode) ProcessSample(in []Sample, _ map[string]any) (Sample, error) {
|
||||
if len(in) == 0 {
|
||||
return Sample{}, errors.New("slice: no inputs")
|
||||
}
|
||||
arr := in[0].AsArray()
|
||||
start := n.Start
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
if start > len(arr) {
|
||||
start = len(arr)
|
||||
}
|
||||
end := n.End
|
||||
if end <= 0 || end > len(arr) {
|
||||
end = len(arr)
|
||||
}
|
||||
if end < start {
|
||||
end = start
|
||||
}
|
||||
out := make([]float64, end-start)
|
||||
copy(out, arr[start:end])
|
||||
return Array(out), nil
|
||||
}
|
||||
|
||||
// ── reductions ────────────────────────────────────────────────────────────────
|
||||
|
||||
// SumNode sums an array input (array→scalar).
|
||||
type SumNode struct{}
|
||||
|
||||
func (n *SumNode) Type() string { return "sum" }
|
||||
func (n *SumNode) Process(in []float64, st map[string]any) (float64, error) {
|
||||
return reductionProcess(n, in, st)
|
||||
}
|
||||
func (n *SumNode) ProcessSample(in []Sample, _ map[string]any) (Sample, error) {
|
||||
if len(in) == 0 {
|
||||
return Sample{}, errors.New("sum: no inputs")
|
||||
}
|
||||
var s float64
|
||||
for _, v := range in[0].AsArray() {
|
||||
s += v
|
||||
}
|
||||
return Scalar(s), nil
|
||||
}
|
||||
|
||||
// MeanNode averages an array input (array→scalar).
|
||||
type MeanNode struct{}
|
||||
|
||||
func (n *MeanNode) Type() string { return "mean" }
|
||||
func (n *MeanNode) Process(in []float64, st map[string]any) (float64, error) {
|
||||
return reductionProcess(n, in, st)
|
||||
}
|
||||
func (n *MeanNode) ProcessSample(in []Sample, _ map[string]any) (Sample, error) {
|
||||
if len(in) == 0 {
|
||||
return Sample{}, errors.New("mean: no inputs")
|
||||
}
|
||||
arr := in[0].AsArray()
|
||||
if len(arr) == 0 {
|
||||
return Scalar(0), nil
|
||||
}
|
||||
var s float64
|
||||
for _, v := range arr {
|
||||
s += v
|
||||
}
|
||||
return Scalar(s / float64(len(arr))), nil
|
||||
}
|
||||
|
||||
// MinNode returns the minimum element of an array input (array→scalar).
|
||||
type MinNode struct{}
|
||||
|
||||
func (n *MinNode) Type() string { return "min" }
|
||||
func (n *MinNode) Process(in []float64, st map[string]any) (float64, error) {
|
||||
return reductionProcess(n, in, st)
|
||||
}
|
||||
func (n *MinNode) ProcessSample(in []Sample, _ map[string]any) (Sample, error) {
|
||||
if len(in) == 0 {
|
||||
return Sample{}, errors.New("min: no inputs")
|
||||
}
|
||||
arr := in[0].AsArray()
|
||||
if len(arr) == 0 {
|
||||
return Scalar(0), nil
|
||||
}
|
||||
m := arr[0]
|
||||
for _, v := range arr[1:] {
|
||||
if v < m {
|
||||
m = v
|
||||
}
|
||||
}
|
||||
return Scalar(m), nil
|
||||
}
|
||||
|
||||
// MaxNode returns the maximum element of an array input (array→scalar).
|
||||
type MaxNode struct{}
|
||||
|
||||
func (n *MaxNode) Type() string { return "max" }
|
||||
func (n *MaxNode) Process(in []float64, st map[string]any) (float64, error) {
|
||||
return reductionProcess(n, in, st)
|
||||
}
|
||||
func (n *MaxNode) ProcessSample(in []Sample, _ map[string]any) (Sample, error) {
|
||||
if len(in) == 0 {
|
||||
return Sample{}, errors.New("max: no inputs")
|
||||
}
|
||||
arr := in[0].AsArray()
|
||||
if len(arr) == 0 {
|
||||
return Scalar(0), nil
|
||||
}
|
||||
m := arr[0]
|
||||
for _, v := range arr[1:] {
|
||||
if v > m {
|
||||
m = v
|
||||
}
|
||||
}
|
||||
return Scalar(m), nil
|
||||
}
|
||||
|
||||
// LengthNode returns the element count of an array input (array→scalar).
|
||||
type LengthNode struct{}
|
||||
|
||||
func (n *LengthNode) Type() string { return "length" }
|
||||
func (n *LengthNode) Process(in []float64, st map[string]any) (float64, error) {
|
||||
return reductionProcess(n, in, st)
|
||||
}
|
||||
func (n *LengthNode) ProcessSample(in []Sample, _ map[string]any) (Sample, error) {
|
||||
if len(in) == 0 {
|
||||
return Sample{}, errors.New("length: no inputs")
|
||||
}
|
||||
return Scalar(float64(len(in[0].AsArray()))), nil
|
||||
}
|
||||
|
||||
// ── FFTNode ────────────────────────────────────────────────────────────────
|
||||
|
||||
// FFTNode computes the magnitude spectrum of an array input (array→array). The
|
||||
// input is zero-padded to the next power of two; the output has that length and
|
||||
// holds |X[k]| for each frequency bin.
|
||||
type FFTNode struct{}
|
||||
|
||||
func (n *FFTNode) Type() string { return "fft" }
|
||||
func (n *FFTNode) Process(in []float64, st map[string]any) (float64, error) {
|
||||
s, err := n.ProcessSample(scalarInputs(in), st)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(s.Arr) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
return s.Arr[0], nil
|
||||
}
|
||||
func (n *FFTNode) ProcessSample(in []Sample, _ map[string]any) (Sample, error) {
|
||||
if len(in) == 0 {
|
||||
return Sample{}, errors.New("fft: no inputs")
|
||||
}
|
||||
return Array(fftMagnitude(in[0].AsArray())), nil
|
||||
}
|
||||
|
||||
// fftMagnitude returns the magnitude spectrum of x, zero-padded to the next
|
||||
// power of two. Returns an empty slice for empty input.
|
||||
func fftMagnitude(x []float64) []float64 {
|
||||
if len(x) == 0 {
|
||||
return nil
|
||||
}
|
||||
n := nextPow2(len(x))
|
||||
re := make([]float64, n)
|
||||
im := make([]float64, n)
|
||||
copy(re, x)
|
||||
fftRadix2(re, im)
|
||||
mag := make([]float64, n)
|
||||
for i := range mag {
|
||||
mag[i] = math.Hypot(re[i], im[i])
|
||||
}
|
||||
return mag
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package dsp
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSampleRoundTrip(t *testing.T) {
|
||||
s := Scalar(3.5)
|
||||
if s.IsArray {
|
||||
t.Error("Scalar should not be an array")
|
||||
}
|
||||
if s.Type() != ValScalar {
|
||||
t.Errorf("Scalar type: want ValScalar, got %v", s.Type())
|
||||
}
|
||||
if s.AsAny() != 3.5 {
|
||||
t.Errorf("Scalar AsAny: want 3.5, got %v", s.AsAny())
|
||||
}
|
||||
if got := s.AsArray(); len(got) != 1 || got[0] != 3.5 {
|
||||
t.Errorf("Scalar AsArray: want [3.5], got %v", got)
|
||||
}
|
||||
|
||||
a := Array([]float64{1, 2, 3})
|
||||
if !a.IsArray {
|
||||
t.Error("Array should be an array")
|
||||
}
|
||||
if a.Type() != ValArray {
|
||||
t.Errorf("Array type: want ValArray, got %v", a.Type())
|
||||
}
|
||||
got, ok := a.AsAny().([]float64)
|
||||
if !ok || len(got) != 3 {
|
||||
t.Errorf("Array AsAny: want []float64 len 3, got %v", a.AsAny())
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyElementwiseAllScalar(t *testing.T) {
|
||||
n := &AddNode{}
|
||||
out, err := ApplyElementwise(n, []Sample{Scalar(2), Scalar(3)}, map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.IsArray || out.F != 5 {
|
||||
t.Errorf("all-scalar add: want scalar 5, got %v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyElementwiseBroadcast(t *testing.T) {
|
||||
// array ⊕ scalar: scalar is a constant broadcast across the array.
|
||||
n := &AddNode{}
|
||||
out, err := ApplyElementwise(n, []Sample{Array([]float64{1, 2, 3}), Scalar(10)}, map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !out.IsArray {
|
||||
t.Fatalf("array+scalar: want array, got %v", out)
|
||||
}
|
||||
want := []float64{11, 12, 13}
|
||||
for i, v := range want {
|
||||
if out.Arr[i] != v {
|
||||
t.Errorf("array+scalar[%d]: want %v, got %v", i, v, out.Arr[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyElementwiseArrayArray(t *testing.T) {
|
||||
n := &MultiplyNode{}
|
||||
out, err := ApplyElementwise(n, []Sample{Array([]float64{1, 2, 3}), Array([]float64{4, 5, 6})}, map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []float64{4, 10, 18}
|
||||
for i, v := range want {
|
||||
if out.Arr[i] != v {
|
||||
t.Errorf("array*array[%d]: want %v, got %v", i, v, out.Arr[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyElementwiseLengthMismatch(t *testing.T) {
|
||||
n := &AddNode{}
|
||||
_, err := ApplyElementwise(n, []Sample{Array([]float64{1, 2}), Array([]float64{1, 2, 3})}, map[string]any{})
|
||||
if err == nil {
|
||||
t.Error("expected length-mismatch error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReductionNodes(t *testing.T) {
|
||||
arr := []Sample{Array([]float64{2, 4, 6, 8})}
|
||||
cases := []struct {
|
||||
name string
|
||||
node ArrayNode
|
||||
want float64
|
||||
}{
|
||||
{"sum", &SumNode{}, 20},
|
||||
{"mean", &MeanNode{}, 5},
|
||||
{"min", &MinNode{}, 2},
|
||||
{"max", &MaxNode{}, 8},
|
||||
{"length", &LengthNode{}, 4},
|
||||
{"index", &IndexNode{I: 2}, 6},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
out, err := tc.node.ProcessSample(arr, map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.IsArray || out.F != tc.want {
|
||||
t.Errorf("%s: want scalar %v, got %v", tc.name, tc.want, out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIndexNodeOutOfRange(t *testing.T) {
|
||||
n := &IndexNode{I: 9}
|
||||
_, err := n.ProcessSample([]Sample{Array([]float64{1, 2, 3})}, map[string]any{})
|
||||
if err == nil {
|
||||
t.Error("expected out-of-range error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSliceNode(t *testing.T) {
|
||||
n := &SliceNode{Start: 1, End: 3}
|
||||
out, err := n.ProcessSample([]Sample{Array([]float64{10, 20, 30, 40})}, map[string]any{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []float64{20, 30}
|
||||
if len(out.Arr) != len(want) {
|
||||
t.Fatalf("slice: want len %d, got %d", len(want), len(out.Arr))
|
||||
}
|
||||
for i, v := range want {
|
||||
if out.Arr[i] != v {
|
||||
t.Errorf("slice[%d]: want %v, got %v", i, v, out.Arr[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFFTMagnitude(t *testing.T) {
|
||||
// A constant signal has all energy in bin 0 (the DC term equals the sum).
|
||||
x := []float64{1, 1, 1, 1}
|
||||
mag := fftMagnitude(x)
|
||||
if len(mag) != 4 {
|
||||
t.Fatalf("fft len: want 4, got %d", len(mag))
|
||||
}
|
||||
if math.Abs(mag[0]-4) > 1e-9 {
|
||||
t.Errorf("fft DC bin: want 4, got %v", mag[0])
|
||||
}
|
||||
for k := 1; k < len(mag); k++ {
|
||||
if math.Abs(mag[k]) > 1e-9 {
|
||||
t.Errorf("fft bin %d: want ~0, got %v", k, mag[k])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFFTSingleTone(t *testing.T) {
|
||||
// One full cycle of a cosine over 8 samples → energy in bins 1 and N-1.
|
||||
n := 8
|
||||
x := make([]float64, n)
|
||||
for i := range x {
|
||||
x[i] = math.Cos(2 * math.Pi * float64(i) / float64(n))
|
||||
}
|
||||
mag := fftMagnitude(x)
|
||||
if math.Abs(mag[1]-float64(n)/2) > 1e-6 {
|
||||
t.Errorf("fft tone bin 1: want %v, got %v", float64(n)/2, mag[1])
|
||||
}
|
||||
if math.Abs(mag[n-1]-float64(n)/2) > 1e-6 {
|
||||
t.Errorf("fft tone bin %d: want %v, got %v", n-1, float64(n)/2, mag[n-1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpOutputType(t *testing.T) {
|
||||
cases := []struct {
|
||||
op string
|
||||
in []ValType
|
||||
want ValType
|
||||
wantErr bool
|
||||
}{
|
||||
// reductions → scalar regardless of input
|
||||
{"sum", []ValType{ValArray}, ValScalar, false},
|
||||
{"mean", []ValType{ValScalar}, ValScalar, false},
|
||||
{"index", []ValType{ValUnknown}, ValScalar, false},
|
||||
// array producers require array, yield array
|
||||
{"fft", []ValType{ValArray}, ValArray, false},
|
||||
{"slice", []ValType{ValUnknown}, ValArray, false},
|
||||
{"fft", []ValType{ValScalar}, ValUnknown, true},
|
||||
// scalar-only reject arrays
|
||||
{"moving_average", []ValType{ValScalar}, ValScalar, false},
|
||||
{"lua", []ValType{ValArray}, ValUnknown, true},
|
||||
{"rms", []ValType{ValUnknown}, ValScalar, false},
|
||||
// elementwise: array if any array, scalar if all scalar, else unknown
|
||||
{"add", []ValType{ValScalar, ValScalar}, ValScalar, false},
|
||||
{"add", []ValType{ValArray, ValScalar}, ValArray, false},
|
||||
{"gain", []ValType{ValUnknown}, ValUnknown, false},
|
||||
{"expr", []ValType{ValArray, ValScalar}, ValArray, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got, err := OpOutputType(tc.op, tc.in)
|
||||
if tc.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("OpOutputType(%q,%v): expected error", tc.op, tc.in)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("OpOutputType(%q,%v): unexpected error %v", tc.op, tc.in, err)
|
||||
continue
|
||||
}
|
||||
if got != tc.want {
|
||||
t.Errorf("OpOutputType(%q,%v): want %v, got %v", tc.op, tc.in, tc.want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package dsp
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestExprFunctions exercises every built-in function branch of parseCall plus
|
||||
// the two-argument forms and the right-associative power operator.
|
||||
func TestExprFunctions(t *testing.T) {
|
||||
st := map[string]any{}
|
||||
cases := []struct {
|
||||
expr string
|
||||
inputs []float64
|
||||
want float64
|
||||
}{
|
||||
{"exp(a)", []float64{1}, math.E},
|
||||
{"log(a)", []float64{math.E}, 1},
|
||||
{"ln(a)", []float64{math.E}, 1},
|
||||
{"log2(a)", []float64{8}, 3},
|
||||
{"log10(a)", []float64{1000}, 3},
|
||||
{"sqrt(a)", []float64{9}, 3},
|
||||
{"abs(a)", []float64{-4}, 4},
|
||||
{"sin(a)", []float64{0}, 0},
|
||||
{"cos(a)", []float64{0}, 1},
|
||||
{"tan(a)", []float64{0}, 0},
|
||||
{"asin(a)", []float64{1}, math.Pi / 2},
|
||||
{"acos(a)", []float64{1}, 0},
|
||||
{"atan(a)", []float64{1}, math.Pi / 4},
|
||||
{"atan2(a, b)", []float64{1, 1}, math.Pi / 4},
|
||||
{"pow(a, b)", []float64{2, 10}, 1024},
|
||||
{"floor(a)", []float64{2.9}, 2},
|
||||
{"ceil(a)", []float64{2.1}, 3},
|
||||
{"round(a)", []float64{2.5}, 3},
|
||||
{"min(a, b)", []float64{3, 7}, 3},
|
||||
{"max(a, b)", []float64{3, 7}, 7},
|
||||
{"a ^ b", []float64{2, 3}, 8},
|
||||
{"2 ^ 3 ^ 2", []float64{}, 512}, // right-associative: 2^(3^2)
|
||||
{"-a ^ 2", []float64{3}, -9}, // unary minus binds outside power
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.expr, func(t *testing.T) {
|
||||
n := &ExprNode{Expr: tc.expr}
|
||||
got, err := n.Process(tc.inputs, st)
|
||||
if err != nil {
|
||||
t.Fatalf("Process(%q): %v", tc.expr, err)
|
||||
}
|
||||
if math.Abs(got-tc.want) > 1e-9 {
|
||||
t.Errorf("Process(%q) = %v, want %v", tc.expr, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestExprFunctionErrors covers the error branches of parseCall/parseFactor.
|
||||
func TestExprFunctionErrors(t *testing.T) {
|
||||
st := map[string]any{}
|
||||
cases := []string{
|
||||
"bogus(a)", // unknown function
|
||||
"sqrt(a", // missing ')'
|
||||
"sqrt(", // empty / unexpected end inside call
|
||||
"@", // unexpected character
|
||||
"(a + 1", // missing closing parenthesis
|
||||
"1.2.3", // invalid number
|
||||
}
|
||||
for _, expr := range cases {
|
||||
t.Run(expr, func(t *testing.T) {
|
||||
n := &ExprNode{Expr: expr}
|
||||
if _, err := n.Process([]float64{1}, st); err == nil {
|
||||
t.Errorf("Process(%q): want error", expr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestArrayNodeScalarAdapters covers the legacy scalar Node interface
|
||||
// (Type + Process) on the array nodes, which the array-path tests skip.
|
||||
func TestArrayNodeScalarAdapters(t *testing.T) {
|
||||
st := map[string]any{}
|
||||
|
||||
// Reductions: Process treats its float64 inputs as a single-element array,
|
||||
// so each reduction over one value returns that value.
|
||||
reductions := []struct {
|
||||
node ArrayNode
|
||||
typ string
|
||||
}{
|
||||
{&SumNode{}, "sum"},
|
||||
{&MeanNode{}, "mean"},
|
||||
{&MinNode{}, "min"},
|
||||
{&MaxNode{}, "max"},
|
||||
{&IndexNode{I: 0}, "index"},
|
||||
}
|
||||
for _, r := range reductions {
|
||||
t.Run(r.typ, func(t *testing.T) {
|
||||
if r.node.Type() != r.typ {
|
||||
t.Errorf("Type() = %q, want %q", r.node.Type(), r.typ)
|
||||
}
|
||||
got, err := r.node.Process([]float64{42}, st)
|
||||
if err != nil {
|
||||
t.Fatalf("Process: %v", err)
|
||||
}
|
||||
if got != 42 {
|
||||
t.Errorf("Process = %v, want 42", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// LengthNode over a single scalar input → length 1.
|
||||
ln := &LengthNode{}
|
||||
if ln.Type() != "length" {
|
||||
t.Errorf("LengthNode.Type() = %q", ln.Type())
|
||||
}
|
||||
if got, err := ln.Process([]float64{7}, st); err != nil || got != 1 {
|
||||
t.Errorf("LengthNode.Process = %v, %v; want 1", got, err)
|
||||
}
|
||||
|
||||
// SliceNode.Process returns the first element of the resulting slice.
|
||||
sn := &SliceNode{Start: 0, End: 0}
|
||||
if sn.Type() != "slice" {
|
||||
t.Errorf("SliceNode.Type() = %q", sn.Type())
|
||||
}
|
||||
if got, err := sn.Process([]float64{5}, st); err != nil || got != 5 {
|
||||
t.Errorf("SliceNode.Process = %v, %v; want 5", got, err)
|
||||
}
|
||||
|
||||
// FFTNode.Process returns the first magnitude bin (DC term = the value).
|
||||
fn := &FFTNode{}
|
||||
if fn.Type() != "fft" {
|
||||
t.Errorf("FFTNode.Type() = %q", fn.Type())
|
||||
}
|
||||
if got, err := fn.Process([]float64{3}, st); err != nil || math.Abs(got-3) > 1e-9 {
|
||||
t.Errorf("FFTNode.Process = %v, %v; want 3", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestArrayNodeProcessErrors covers the error propagation through the scalar
|
||||
// Process adapters.
|
||||
func TestArrayNodeProcessErrors(t *testing.T) {
|
||||
st := map[string]any{}
|
||||
// IndexNode with an out-of-range index propagates the reduction error.
|
||||
n := &IndexNode{I: 5}
|
||||
if _, err := n.Process([]float64{1}, st); err == nil {
|
||||
t.Error("IndexNode.Process out of range: want error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package dsp
|
||||
|
||||
import "math"
|
||||
|
||||
// nextPow2 returns the smallest power of two >= n (and at least 1).
|
||||
func nextPow2(n int) int {
|
||||
p := 1
|
||||
for p < n {
|
||||
p <<= 1
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// fftRadix2 computes the in-place iterative radix-2 Cooley-Tukey FFT of the
|
||||
// complex signal held in re/im. len(re) == len(im) must be a power of two. The
|
||||
// transform overwrites re/im with the frequency-domain result. This is a small
|
||||
// self-contained implementation (no external dependency) used by the synthetic
|
||||
// fft op.
|
||||
func fftRadix2(re, im []float64) {
|
||||
n := len(re)
|
||||
if n <= 1 {
|
||||
return
|
||||
}
|
||||
|
||||
// Bit-reversal permutation.
|
||||
for i, j := 1, 0; i < n; i++ {
|
||||
bit := n >> 1
|
||||
for ; j&bit != 0; bit >>= 1 {
|
||||
j ^= bit
|
||||
}
|
||||
j ^= bit
|
||||
if i < j {
|
||||
re[i], re[j] = re[j], re[i]
|
||||
im[i], im[j] = im[j], im[i]
|
||||
}
|
||||
}
|
||||
|
||||
// Danielson-Lanczos butterflies.
|
||||
for length := 2; length <= n; length <<= 1 {
|
||||
ang := -2 * math.Pi / float64(length)
|
||||
wReal, wImag := math.Cos(ang), math.Sin(ang)
|
||||
for i := 0; i < n; i += length {
|
||||
curReal, curImag := 1.0, 0.0
|
||||
half := length >> 1
|
||||
for k := 0; k < half; k++ {
|
||||
a := i + k
|
||||
b := i + k + half
|
||||
tReal := curReal*re[b] - curImag*im[b]
|
||||
tImag := curReal*im[b] + curImag*re[b]
|
||||
re[b] = re[a] - tReal
|
||||
im[b] = im[a] - tImag
|
||||
re[a] += tReal
|
||||
im[a] += tImag
|
||||
curReal, curImag = curReal*wReal-curImag*wImag, curReal*wImag+curImag*wReal
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+21
-10
@@ -277,16 +277,28 @@ func (n *ThresholdNode) Process(inputs []float64, _ map[string]any) (float64, er
|
||||
|
||||
// ── ExprNode ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// ExprNode evaluates a simple arithmetic expression with variables a, b, c, d
|
||||
// bound to inputs[0..3]. It uses a hand-written recursive descent parser.
|
||||
// ExprNode evaluates a simple arithmetic expression. Inputs are bound to named
|
||||
// variables: Vars[i] -> inputs[i]. When Vars is empty it defaults to a, b, c, d
|
||||
// (bound to inputs[0..3]) for backward compatibility. It uses a hand-written
|
||||
// recursive descent parser.
|
||||
type ExprNode struct {
|
||||
Expr string
|
||||
Vars []string
|
||||
}
|
||||
|
||||
// defaultVarNames returns the variable names for an expr/lua node: the explicit
|
||||
// list when set, otherwise the legacy a,b,c,d.
|
||||
func defaultVarNames(vars []string) []string {
|
||||
if len(vars) > 0 {
|
||||
return vars
|
||||
}
|
||||
return []string{"a", "b", "c", "d"}
|
||||
}
|
||||
|
||||
func (n *ExprNode) Type() string { return "expr" }
|
||||
func (n *ExprNode) Process(inputs []float64, _ map[string]any) (float64, error) {
|
||||
vars := map[string]float64{}
|
||||
names := []string{"a", "b", "c", "d"}
|
||||
names := defaultVarNames(n.Vars)
|
||||
for i, name := range names {
|
||||
if i < len(inputs) {
|
||||
vars[name] = inputs[i]
|
||||
@@ -507,13 +519,10 @@ func (p *exprParser) parseCall() (float64, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Not a function call — must be a single-letter variable.
|
||||
if len(name) != 1 {
|
||||
return 0, fmt.Errorf("unknown identifier %q (use a–d for variables, or a known function name)", name)
|
||||
}
|
||||
// Not a function call — must be a declared input variable.
|
||||
val, ok := p.vars[name]
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("unknown variable %q (allowed: a, b, c, d)", name)
|
||||
return 0, fmt.Errorf("unknown variable %q (declare it as a named input)", name)
|
||||
}
|
||||
return val, nil
|
||||
}
|
||||
@@ -628,10 +637,12 @@ func (n *LowPassNode) Process(inputs []float64, state map[string]any) (float64,
|
||||
// ── LuaNode ───────────────────────────────────────────────────────────────────
|
||||
|
||||
// LuaNode runs a Lua script in a sandboxed gopher-lua VM.
|
||||
// Inputs are bound to globals a, b, c, d. The script's return value is the output.
|
||||
// Inputs are bound to globals named by Vars (Vars[i] -> inputs[i]); when Vars is
|
||||
// empty it defaults to a, b, c, d. The script's return value is the output.
|
||||
// The os, io, package, and debug libraries are disabled.
|
||||
type LuaNode struct {
|
||||
Script string
|
||||
Vars []string
|
||||
}
|
||||
|
||||
func (n *LuaNode) Type() string { return "lua" }
|
||||
@@ -682,7 +693,7 @@ func (n *LuaNode) Process(inputs []float64, state map[string]any) (result float6
|
||||
L.SetTop(0)
|
||||
|
||||
// Bind inputs.
|
||||
names := []string{"a", "b", "c", "d"}
|
||||
names := defaultVarNames(n.Vars)
|
||||
for i, name := range names {
|
||||
if i < len(inputs) {
|
||||
L.SetGlobal(name, lua.LNumber(inputs[i]))
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
package dsp
|
||||
|
||||
import "fmt"
|
||||
|
||||
// ValType is the data type of a Sample: a scalar float, a float array
|
||||
// (waveform), or — at graph-compile time, before a source's real type is
|
||||
// known — unknown.
|
||||
type ValType uint8
|
||||
|
||||
const (
|
||||
ValUnknown ValType = iota
|
||||
ValScalar
|
||||
ValArray
|
||||
)
|
||||
|
||||
func (t ValType) String() string {
|
||||
switch t {
|
||||
case ValScalar:
|
||||
return "scalar"
|
||||
case ValArray:
|
||||
return "array"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// Sample is a value flowing through the synthetic DSP graph: either a scalar
|
||||
// float64 or a float64 array (waveform). It is the array-aware counterpart of
|
||||
// the bare float64 the legacy scalar Node interface uses.
|
||||
type Sample struct {
|
||||
F float64
|
||||
Arr []float64
|
||||
IsArray bool
|
||||
}
|
||||
|
||||
// Scalar wraps a float64 as a scalar Sample.
|
||||
func Scalar(f float64) Sample { return Sample{F: f} }
|
||||
|
||||
// Array wraps a []float64 as an array Sample.
|
||||
func Array(a []float64) Sample { return Sample{Arr: a, IsArray: true} }
|
||||
|
||||
// Type reports whether the sample is a scalar or an array.
|
||||
func (s Sample) Type() ValType {
|
||||
if s.IsArray {
|
||||
return ValArray
|
||||
}
|
||||
return ValScalar
|
||||
}
|
||||
|
||||
// AsAny returns the value in the form datasource.Value.Data expects: a
|
||||
// []float64 for arrays, a float64 for scalars.
|
||||
func (s Sample) AsAny() any {
|
||||
if s.IsArray {
|
||||
return s.Arr
|
||||
}
|
||||
return s.F
|
||||
}
|
||||
|
||||
// AsArray returns the sample's data as a slice: the array itself, or a
|
||||
// single-element slice for a scalar. Used by reductions that accept either.
|
||||
func (s Sample) AsArray() []float64 {
|
||||
if s.IsArray {
|
||||
return s.Arr
|
||||
}
|
||||
return []float64{s.F}
|
||||
}
|
||||
|
||||
// ArrayNode is an optional extension of Node implemented by ops that operate
|
||||
// natively on Samples (reductions array→scalar, producers array→array, etc.).
|
||||
// eval prefers ProcessSample when a node implements it.
|
||||
type ArrayNode interface {
|
||||
Node
|
||||
ProcessSample(inputs []Sample, state map[string]any) (Sample, error)
|
||||
}
|
||||
|
||||
// statelessElementwise lists scalar ops that are safe to broadcast element-wise
|
||||
// over array inputs: they hold no per-evaluation state, so running the legacy
|
||||
// Process once per array lane is well-defined. Stateful ops (moving_average,
|
||||
// rms, lowpass, derivative, integrate) and lua are excluded — a single shared
|
||||
// state map cannot be meaningfully split across lanes.
|
||||
var statelessElementwise = map[string]bool{
|
||||
"gain": true, "offset": true, "add": true, "subtract": true,
|
||||
"multiply": true, "divide": true, "clamp": true, "threshold": true,
|
||||
"expr": true,
|
||||
}
|
||||
|
||||
// StatelessElementwise reports whether a scalar op type may be broadcast over
|
||||
// array inputs via ApplyElementwise.
|
||||
func StatelessElementwise(nodeType string) bool { return statelessElementwise[nodeType] }
|
||||
|
||||
// scalarInputs wraps a legacy float64 input slice as scalar Samples.
|
||||
func scalarInputs(in []float64) []Sample {
|
||||
out := make([]Sample, len(in))
|
||||
for i, v := range in {
|
||||
out[i] = Scalar(v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ApplyElementwise runs a stateless scalar Node over Sample inputs. If every
|
||||
// input is scalar it calls Process once and wraps the result. If any input is
|
||||
// an array it broadcasts: scalar inputs act as constants, all array inputs must
|
||||
// share a common length (else an error), and Process is invoked once per index.
|
||||
//
|
||||
// The node MUST be stateless (see StatelessElementwise) — a shared state map
|
||||
// cannot be split across array lanes.
|
||||
func ApplyElementwise(n Node, inputs []Sample, state map[string]any) (Sample, error) {
|
||||
// Determine the array length, if any input is an array.
|
||||
length := -1
|
||||
for _, s := range inputs {
|
||||
if !s.IsArray {
|
||||
continue
|
||||
}
|
||||
if length == -1 {
|
||||
length = len(s.Arr)
|
||||
} else if len(s.Arr) != length {
|
||||
return Sample{}, fmt.Errorf("%s: array length mismatch (%d vs %d)", n.Type(), length, len(s.Arr))
|
||||
}
|
||||
}
|
||||
|
||||
if length == -1 {
|
||||
// All scalar — single legacy call.
|
||||
row := make([]float64, len(inputs))
|
||||
for i, s := range inputs {
|
||||
row[i] = s.F
|
||||
}
|
||||
r, err := n.Process(row, state)
|
||||
if err != nil {
|
||||
return Sample{}, err
|
||||
}
|
||||
return Scalar(r), nil
|
||||
}
|
||||
|
||||
out := make([]float64, length)
|
||||
row := make([]float64, len(inputs))
|
||||
for i := 0; i < length; i++ {
|
||||
for j, s := range inputs {
|
||||
if s.IsArray {
|
||||
row[j] = s.Arr[i]
|
||||
} else {
|
||||
row[j] = s.F
|
||||
}
|
||||
}
|
||||
r, err := n.Process(row, state)
|
||||
if err != nil {
|
||||
return Sample{}, err
|
||||
}
|
||||
out[i] = r
|
||||
}
|
||||
return Array(out), nil
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package dsp
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Op type categories for static (compile-time / editor) type propagation.
|
||||
// These mirror the runtime dispatch in the synthetic graph evaluator and the
|
||||
// frontend's inferNodeTypes (web/src/lib/synthTypes.ts) — keep the three in
|
||||
// sync; a parity test guards the Go/TS pair.
|
||||
var (
|
||||
// reductionOps collapse an array (or scalar) to a single scalar.
|
||||
reductionOps = map[string]bool{
|
||||
"index": true, "length": true, "sum": true,
|
||||
"mean": true, "min": true, "max": true,
|
||||
}
|
||||
// arrayProducerOps require an array input and yield an array.
|
||||
arrayProducerOps = map[string]bool{
|
||||
"fft": true, "slice": true,
|
||||
}
|
||||
// scalarOnlyOps reject array inputs and yield a scalar. Stateful filters
|
||||
// plus lua (whose state/closure cannot be broadcast per array lane).
|
||||
scalarOnlyOps = map[string]bool{
|
||||
"moving_average": true, "rms": true, "lowpass": true,
|
||||
"derivative": true, "integrate": true, "lua": true,
|
||||
}
|
||||
// statefulOps accumulate state across evaluations, so a single-shot trace
|
||||
// (fresh state) only approximates their true running value. lua is included
|
||||
// because a script may persist state in its state table.
|
||||
statefulOps = map[string]bool{
|
||||
"moving_average": true, "rms": true, "lowpass": true,
|
||||
"derivative": true, "integrate": true, "lua": true,
|
||||
}
|
||||
)
|
||||
|
||||
// IsStateful reports whether an op accumulates state across evaluations. The
|
||||
// editor's live/debug trace flags such nodes as "approx" since it evaluates a
|
||||
// single tick with fresh state.
|
||||
func IsStateful(op string) bool { return statefulOps[op] }
|
||||
|
||||
// OpOutputType reports the output ValType of an op given its input types, and
|
||||
// an error if the inputs are definitely incompatible with the op. Inputs may be
|
||||
// ValUnknown (a source whose real type is not yet known at compile time); such
|
||||
// inputs never trigger an error — runtime Sample typing is authoritative.
|
||||
func OpOutputType(op string, in []ValType) (ValType, error) {
|
||||
switch {
|
||||
case reductionOps[op]:
|
||||
return ValScalar, nil
|
||||
|
||||
case arrayProducerOps[op]:
|
||||
for _, t := range in {
|
||||
if t == ValScalar {
|
||||
return ValUnknown, fmt.Errorf("%s requires an array input", op)
|
||||
}
|
||||
}
|
||||
return ValArray, nil
|
||||
|
||||
case scalarOnlyOps[op]:
|
||||
for _, t := range in {
|
||||
if t == ValArray {
|
||||
return ValUnknown, fmt.Errorf("%s does not accept an array input", op)
|
||||
}
|
||||
}
|
||||
return ValScalar, nil
|
||||
|
||||
default:
|
||||
// Elementwise stateless ops (gain, offset, add, subtract, multiply,
|
||||
// divide, clamp, threshold, expr): array if any input is an array,
|
||||
// scalar if all inputs are definitely scalar, otherwise unknown.
|
||||
anyArray, anyUnknown := false, false
|
||||
for _, t := range in {
|
||||
switch t {
|
||||
case ValArray:
|
||||
anyArray = true
|
||||
case ValUnknown:
|
||||
anyUnknown = true
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case anyArray:
|
||||
return ValArray, nil
|
||||
case anyUnknown:
|
||||
return ValUnknown, nil
|
||||
default:
|
||||
return ValScalar, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
// Package ldapauth validates a username/password pair against an LDAP directory
|
||||
// using the standard "search then bind" pattern, exactly as an SSSD/LDAP client
|
||||
// would: connect to the directory, find the user's entry under the configured
|
||||
// search base, then attempt a bind as that entry's DN with the supplied password.
|
||||
//
|
||||
// It is a pure-Go alternative to the PAM backend (internal/pamauth): because it
|
||||
// speaks LDAP over the wire with no cgo, uopi keeps its fully-static
|
||||
// (CGO_ENABLED=0) release binary while still authenticating against the same
|
||||
// directory the host logs in with. It feeds the same HTTP Basic pipeline
|
||||
// (internal/server/basicauth.go) as PAM.
|
||||
//
|
||||
// Unlike PAM it only verifies the password; it does not run the rest of the PAM
|
||||
// stack (account expiry, access.conf, MFA). For a monitoring HMI that is normally
|
||||
// sufficient.
|
||||
package ldapauth
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
)
|
||||
|
||||
// Config configures the LDAP authenticator. URIs and SearchBase are required; the
|
||||
// remaining fields mirror SSSD defaults so an unconfigured directory (anonymous
|
||||
// search, RFC2307 schema) works out of the box.
|
||||
type Config struct {
|
||||
// URIs are the directory endpoints, tried in order until one connects, e.g.
|
||||
// "ldaps://ldap.example.com" or "ldap://ldap.example.com". Mirrors SSSD's
|
||||
// ldap_uri.
|
||||
URIs []string
|
||||
// SearchBase is the subtree under which user entries are searched. Mirrors
|
||||
// SSSD's ldap_search_base.
|
||||
SearchBase string
|
||||
// UserAttr is the attribute matched against the login name. Empty defaults to
|
||||
// "uid" (SSSD's ldap_user_name default for RFC2307).
|
||||
UserAttr string
|
||||
// UserObjectClass restricts the search to this objectClass. Empty defaults to
|
||||
// "posixAccount" (SSSD's ldap_user_object_class default).
|
||||
UserObjectClass string
|
||||
// BindDN / BindPassword optionally authenticate the *search* (service
|
||||
// account). Empty BindDN performs an anonymous search, matching a directory
|
||||
// configured without ldap_default_bind_dn.
|
||||
BindDN string
|
||||
BindPassword string
|
||||
// StartTLS upgrades a plain ldap:// connection to TLS before any credentials
|
||||
// are sent. Ignored for ldaps:// (already TLS).
|
||||
StartTLS bool
|
||||
// CACertFile is an optional PEM file of CA certs to trust for the TLS
|
||||
// connection (for a directory using a private CA).
|
||||
CACertFile string
|
||||
// InsecureSkipVerify disables TLS certificate verification. Insecure; use only
|
||||
// for testing against a self-signed directory.
|
||||
InsecureSkipVerify bool
|
||||
// Timeout bounds each connection attempt. Zero defaults to 10s.
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
// ErrInvalidCredentials is returned when the directory rejects the user's bind.
|
||||
var ErrInvalidCredentials = errors.New("ldap: invalid credentials")
|
||||
|
||||
// Authenticator validates credentials against a fixed directory configuration.
|
||||
// It is safe for concurrent use: each Authenticate call opens and closes its own
|
||||
// connection.
|
||||
type Authenticator struct {
|
||||
cfg Config
|
||||
tlsConfig *tls.Config
|
||||
}
|
||||
|
||||
// New validates cfg and returns an Authenticator. It fails fast on missing
|
||||
// required fields or an unreadable CA file so misconfiguration surfaces at
|
||||
// startup rather than on the first login.
|
||||
func New(cfg Config) (*Authenticator, error) {
|
||||
if len(cfg.URIs) == 0 {
|
||||
return nil, errors.New("ldap: at least one uri is required")
|
||||
}
|
||||
if cfg.SearchBase == "" {
|
||||
return nil, errors.New("ldap: search_base is required")
|
||||
}
|
||||
if cfg.UserAttr == "" {
|
||||
cfg.UserAttr = "uid"
|
||||
}
|
||||
if cfg.UserObjectClass == "" {
|
||||
cfg.UserObjectClass = "posixAccount"
|
||||
}
|
||||
if cfg.Timeout <= 0 {
|
||||
cfg.Timeout = 10 * time.Second
|
||||
}
|
||||
|
||||
tlsConfig := &tls.Config{InsecureSkipVerify: cfg.InsecureSkipVerify}
|
||||
if cfg.CACertFile != "" {
|
||||
pem, err := os.ReadFile(cfg.CACertFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ldap: reading ca_cert: %w", err)
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
if !pool.AppendCertsFromPEM(pem) {
|
||||
return nil, fmt.Errorf("ldap: ca_cert %q contained no certificates", cfg.CACertFile)
|
||||
}
|
||||
tlsConfig.RootCAs = pool
|
||||
}
|
||||
|
||||
return &Authenticator{cfg: cfg, tlsConfig: tlsConfig}, nil
|
||||
}
|
||||
|
||||
// Authenticate verifies username/password against the directory. It returns nil
|
||||
// on success, ErrInvalidCredentials when the directory rejects the bind, or
|
||||
// another error on connection/search failure.
|
||||
func (a *Authenticator) Authenticate(username, password string) error {
|
||||
// A bind with a non-empty DN but an empty password is an "unauthenticated
|
||||
// bind" that many servers accept as success — which would let anyone in with a
|
||||
// blank password. Reject empty passwords before we ever bind.
|
||||
if password == "" {
|
||||
return ErrInvalidCredentials
|
||||
}
|
||||
|
||||
conn, err := a.dial()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Bind for the search: service account if configured, else anonymous.
|
||||
if a.cfg.BindDN != "" {
|
||||
if err := conn.Bind(a.cfg.BindDN, a.cfg.BindPassword); err != nil {
|
||||
return fmt.Errorf("ldap: search bind failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Locate the user's entry. The login name is escaped to prevent LDAP filter
|
||||
// injection.
|
||||
filter := fmt.Sprintf("(&(objectClass=%s)(%s=%s))",
|
||||
ldap.EscapeFilter(a.cfg.UserObjectClass),
|
||||
a.cfg.UserAttr,
|
||||
ldap.EscapeFilter(username))
|
||||
req := ldap.NewSearchRequest(
|
||||
a.cfg.SearchBase,
|
||||
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases,
|
||||
2, int(a.cfg.Timeout.Seconds()), false,
|
||||
filter,
|
||||
[]string{"dn"}, nil,
|
||||
)
|
||||
res, err := conn.Search(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("ldap: search failed: %w", err)
|
||||
}
|
||||
if len(res.Entries) == 0 {
|
||||
return ErrInvalidCredentials // unknown user — do not distinguish from bad password
|
||||
}
|
||||
if len(res.Entries) > 1 {
|
||||
return fmt.Errorf("ldap: %q matched %d entries; refusing ambiguous bind", username, len(res.Entries))
|
||||
}
|
||||
userDN := res.Entries[0].DN
|
||||
|
||||
// Verify the password by binding as the user. Use a fresh connection so the
|
||||
// search identity is fully dropped first.
|
||||
userConn, err := a.dial()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer userConn.Close()
|
||||
if err := userConn.Bind(userDN, password); err != nil {
|
||||
if ldap.IsErrorWithCode(err, ldap.LDAPResultInvalidCredentials) {
|
||||
return ErrInvalidCredentials
|
||||
}
|
||||
return fmt.Errorf("ldap: user bind failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// dial connects to the first reachable URI and applies StartTLS when requested.
|
||||
func (a *Authenticator) dial() (*ldap.Conn, error) {
|
||||
var lastErr error
|
||||
for _, uri := range a.cfg.URIs {
|
||||
conn, err := ldap.DialURL(uri, ldap.DialWithTLSConfig(a.tlsConfig))
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
conn.SetTimeout(a.cfg.Timeout)
|
||||
if a.cfg.StartTLS {
|
||||
if err := conn.StartTLS(a.tlsConfig); err != nil {
|
||||
conn.Close()
|
||||
lastErr = fmt.Errorf("ldap: starttls on %q: %w", uri, err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
return nil, fmt.Errorf("ldap: could not connect to any uri: %w", lastErr)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package ldapauth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewRequiresURIAndBase(t *testing.T) {
|
||||
if _, err := New(Config{SearchBase: "dc=x"}); err == nil {
|
||||
t.Fatal("want error for missing uri")
|
||||
}
|
||||
if _, err := New(Config{URIs: []string{"ldap://x"}}); err == nil {
|
||||
t.Fatal("want error for missing search_base")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewAppliesSSSDDefaults(t *testing.T) {
|
||||
a, err := New(Config{URIs: []string{"ldap://x"}, SearchBase: "dc=x"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if a.cfg.UserAttr != "uid" {
|
||||
t.Errorf("UserAttr default = %q, want uid", a.cfg.UserAttr)
|
||||
}
|
||||
if a.cfg.UserObjectClass != "posixAccount" {
|
||||
t.Errorf("UserObjectClass default = %q, want posixAccount", a.cfg.UserObjectClass)
|
||||
}
|
||||
if a.cfg.Timeout <= 0 {
|
||||
t.Errorf("Timeout default not applied: %v", a.cfg.Timeout)
|
||||
}
|
||||
}
|
||||
|
||||
// Empty passwords must be rejected before any bind: a non-empty DN + empty
|
||||
// password is an "unauthenticated bind" many servers accept as success.
|
||||
func TestAuthenticateRejectsEmptyPasswordWithoutDialing(t *testing.T) {
|
||||
// An unreachable URI guarantees the test fails loudly if it ever tries to dial.
|
||||
a, err := New(Config{URIs: []string{"ldap://127.0.0.1:1"}, SearchBase: "dc=x"})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if err := a.Authenticate("alice", ""); !errors.Is(err, ErrInvalidCredentials) {
|
||||
t.Fatalf("want ErrInvalidCredentials for empty password, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewRejectsBadCACert(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
bad := filepath.Join(dir, "ca.pem")
|
||||
if err := os.WriteFile(bad, []byte("not a certificate"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := New(Config{URIs: []string{"ldaps://x"}, SearchBase: "dc=x", CACertFile: bad}); err == nil {
|
||||
t.Fatal("want error for CA file with no certificates")
|
||||
}
|
||||
if _, err := New(Config{URIs: []string{"ldaps://x"}, SearchBase: "dc=x", CACertFile: filepath.Join(dir, "missing.pem")}); err == nil {
|
||||
t.Fatal("want error for missing CA file")
|
||||
}
|
||||
}
|
||||
@@ -13,11 +13,11 @@ var startTime = time.Now()
|
||||
|
||||
// Counters and gauges — updated by callers in ws.go and api.go.
|
||||
var (
|
||||
wsConns atomic.Int64 // current open WebSocket connections (gauge)
|
||||
msgIn atomic.Int64 // total WS messages received (counter)
|
||||
msgOut atomic.Int64 // total WS messages sent (counter)
|
||||
writeOps atomic.Int64 // total signal write operations (counter)
|
||||
historyReqs atomic.Int64 // total history requests served (counter)
|
||||
wsConns atomic.Int64 // current open WebSocket connections (gauge)
|
||||
msgIn atomic.Int64 // total WS messages received (counter)
|
||||
msgOut atomic.Int64 // total WS messages sent (counter)
|
||||
writeOps atomic.Int64 // total signal write operations (counter)
|
||||
historyReqs atomic.Int64 // total history requests served (counter)
|
||||
)
|
||||
|
||||
// IncWsConns increments the active WebSocket connection gauge.
|
||||
@@ -38,6 +38,30 @@ func IncWrites() { writeOps.Add(1) }
|
||||
// IncHistoryReqs increments the history request counter.
|
||||
func IncHistoryReqs() { historyReqs.Add(1) }
|
||||
|
||||
// Stats is a point-in-time snapshot of the in-process counters, for rendering
|
||||
// as JSON in the admin pane (the Prometheus Handler renders the same data as
|
||||
// text).
|
||||
type Stats struct {
|
||||
UptimeSeconds float64 `json:"uptimeSeconds"`
|
||||
WsConnections int64 `json:"wsConnections"`
|
||||
MsgIn int64 `json:"msgIn"`
|
||||
MsgOut int64 `json:"msgOut"`
|
||||
WriteOps int64 `json:"writeOps"`
|
||||
HistoryReqs int64 `json:"historyReqs"`
|
||||
}
|
||||
|
||||
// Snapshot returns the current values of all counters and gauges.
|
||||
func Snapshot() Stats {
|
||||
return Stats{
|
||||
UptimeSeconds: time.Since(startTime).Seconds(),
|
||||
WsConnections: wsConns.Load(),
|
||||
MsgIn: msgIn.Load(),
|
||||
MsgOut: msgOut.Load(),
|
||||
WriteOps: writeOps.Load(),
|
||||
HistoryReqs: historyReqs.Load(),
|
||||
}
|
||||
}
|
||||
|
||||
// Handler returns an http.HandlerFunc that renders Prometheus-format metrics.
|
||||
// activeSubs is called each request to read the current number of unique signal
|
||||
// subscriptions from the broker; pass nil to omit the metric.
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
//go:build pam
|
||||
|
||||
// Package pamauth authenticates a username/password pair against the host's PAM
|
||||
// stack (/etc/pam.d/<service>). It is the backend for uopi's built-in HTTP Basic
|
||||
// authentication: because the uopi host is typically already an SSSD/LDAP client,
|
||||
// validating through PAM reuses the exact same login path as `login`/`ssh`
|
||||
// (pam_sss → the site directory) without uopi needing any directory schema.
|
||||
//
|
||||
// This file is the real implementation, compiled only with the `pam` build tag
|
||||
// (which also requires cgo + libpam). The default fully-static CGO_ENABLED=0
|
||||
// build uses stub.go instead, where Authenticate reports PAM is unavailable.
|
||||
package pamauth
|
||||
|
||||
/*
|
||||
#cgo LDFLAGS: -lpam
|
||||
#include <security/pam_appl.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
// uopiPamConv answers every password-style PAM prompt with the password passed
|
||||
// through appdata_ptr. Informational/error messages get a NULL response. The PAM
|
||||
// library takes ownership of the returned responses and frees them.
|
||||
static int uopiPamConv(int num_msg, const struct pam_message **msg,
|
||||
struct pam_response **resp, void *appdata_ptr) {
|
||||
if (num_msg <= 0 || num_msg > PAM_MAX_NUM_MSG) {
|
||||
return PAM_CONV_ERR;
|
||||
}
|
||||
struct pam_response *r = calloc((size_t)num_msg, sizeof(struct pam_response));
|
||||
if (r == NULL) {
|
||||
return PAM_BUF_ERR;
|
||||
}
|
||||
for (int i = 0; i < num_msg; i++) {
|
||||
int style = msg[i]->msg_style;
|
||||
if (style == PAM_PROMPT_ECHO_OFF || style == PAM_PROMPT_ECHO_ON) {
|
||||
r[i].resp = strdup((const char *)appdata_ptr);
|
||||
if (r[i].resp == NULL) {
|
||||
for (int j = 0; j < i; j++) {
|
||||
free(r[j].resp);
|
||||
}
|
||||
free(r);
|
||||
return PAM_BUF_ERR;
|
||||
}
|
||||
}
|
||||
r[i].resp_retcode = 0;
|
||||
}
|
||||
*resp = r;
|
||||
return PAM_SUCCESS;
|
||||
}
|
||||
|
||||
// uopiPamAuth runs authentication + account management for service/user using
|
||||
// pass. Returns PAM_SUCCESS or the failing PAM error code.
|
||||
static int uopiPamAuth(const char *service, const char *user, char *pass) {
|
||||
struct pam_conv conv;
|
||||
conv.conv = uopiPamConv;
|
||||
conv.appdata_ptr = (void *)pass;
|
||||
|
||||
pam_handle_t *pamh = NULL;
|
||||
int ret = pam_start(service, user, &conv, &pamh);
|
||||
if (ret != PAM_SUCCESS) {
|
||||
return ret;
|
||||
}
|
||||
ret = pam_authenticate(pamh, PAM_DISALLOW_NULL_AUTHTOK);
|
||||
if (ret == PAM_SUCCESS) {
|
||||
ret = pam_acct_mgmt(pamh, PAM_DISALLOW_NULL_AUTHTOK);
|
||||
}
|
||||
pam_end(pamh, ret);
|
||||
return ret;
|
||||
}
|
||||
|
||||
// uopiStrerror maps a PAM error code to a human-readable string. Linux-PAM
|
||||
// ignores the handle, so NULL is fine after pam_end.
|
||||
static const char *uopiStrerror(int code) {
|
||||
return pam_strerror(NULL, code);
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"unsafe"
|
||||
)
|
||||
|
||||
// Available reports whether this build includes PAM support. True here.
|
||||
const Available = true
|
||||
|
||||
// ErrUnavailable is returned by Authenticate in builds without PAM support. It
|
||||
// is declared in both build variants so callers can compare against it.
|
||||
var ErrUnavailable = errors.New("pamauth: PAM support not compiled in")
|
||||
|
||||
// Authenticate verifies username/password against the named PAM service
|
||||
// (/etc/pam.d/<service>). It returns nil on success or an error describing the
|
||||
// PAM failure. It is safe for concurrent use.
|
||||
func Authenticate(service, username, password string) error {
|
||||
cService := C.CString(service)
|
||||
cUser := C.CString(username)
|
||||
cPass := C.CString(password)
|
||||
defer C.free(unsafe.Pointer(cService))
|
||||
defer C.free(unsafe.Pointer(cUser))
|
||||
defer C.free(unsafe.Pointer(cPass))
|
||||
|
||||
if ret := C.uopiPamAuth(cService, cUser, cPass); ret != C.PAM_SUCCESS {
|
||||
return fmt.Errorf("pam: %s", C.GoString(C.uopiStrerror(ret)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//go:build !pam
|
||||
|
||||
// Package pamauth authenticates a username/password pair against the host's PAM
|
||||
// stack. This is the stub compiled into the default fully-static
|
||||
// (CGO_ENABLED=0) build, which has no PAM/libpam linkage: Authenticate always
|
||||
// reports PAM is unavailable. Build with `make backend-pam` (CGO_ENABLED=1
|
||||
// -tags pam) to get the real implementation in pam.go.
|
||||
package pamauth
|
||||
|
||||
import "errors"
|
||||
|
||||
// Available reports whether this build includes PAM support. False here.
|
||||
const Available = false
|
||||
|
||||
// ErrUnavailable is returned by Authenticate because this build lacks PAM.
|
||||
var ErrUnavailable = errors.New("pamauth: PAM support not compiled in (rebuild with: make backend-pam)")
|
||||
|
||||
// Authenticate always fails in the non-PAM build.
|
||||
func Authenticate(service, username, password string) error {
|
||||
return ErrUnavailable
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
//go:build !pam
|
||||
|
||||
package pamauth
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestStubUnavailable verifies the default (non-PAM) build reports PAM as
|
||||
// unavailable and Authenticate always fails with ErrUnavailable.
|
||||
func TestStubUnavailable(t *testing.T) {
|
||||
if Available {
|
||||
t.Error("Available should be false in the non-PAM build")
|
||||
}
|
||||
if err := Authenticate("login", "alice", "secret"); !errors.Is(err, ErrUnavailable) {
|
||||
t.Errorf("Authenticate = %v, want ErrUnavailable", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package panelacl
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestPermString covers the Perm→token rendering.
|
||||
func TestPermString(t *testing.T) {
|
||||
cases := map[Perm]string{PermNone: "none", PermRead: "read", PermWrite: "write"}
|
||||
for p, want := range cases {
|
||||
if got := p.String(); got != want {
|
||||
t.Errorf("Perm(%d).String() = %q, want %q", p, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlaceAndDeletePanel covers PlacePanel (organizational-only record that
|
||||
// stays open) and DeletePanel (present + absent).
|
||||
func TestPlaceAndDeletePanel(t *testing.T) {
|
||||
s := newStore(t)
|
||||
if _, err := s.CreateFolder("f1", "Folder 1", "", "alice"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Placing a legacy panel into a folder must not lock it (no owner record).
|
||||
if err := s.PlacePanel("legacy", "f1", 2.5); err != nil {
|
||||
t.Fatalf("PlacePanel: %v", err)
|
||||
}
|
||||
if got := s.PanelPerm("legacy", "bob", nil); got != PermWrite {
|
||||
t.Errorf("placed legacy panel perm = %v, want write", got)
|
||||
}
|
||||
|
||||
// DeletePanel on a missing record is a no-op success.
|
||||
if err := s.DeletePanel("never"); err != nil {
|
||||
t.Errorf("DeletePanel missing: %v", err)
|
||||
}
|
||||
// DeletePanel on the placed record removes it.
|
||||
if err := s.DeletePanel("legacy"); err != nil {
|
||||
t.Fatalf("DeletePanel: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFoldersAndGetFolder covers the folder accessors, FolderPerm on an owner
|
||||
// vs a stranger, and reload from the persisted index.
|
||||
func TestFoldersAndGetFolder(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
s, err := New(dir)
|
||||
if err != nil {
|
||||
t.Fatal("New:", err)
|
||||
}
|
||||
if _, err := s.CreateFolder("root", "Root", "", "alice"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
all := s.Folders()
|
||||
if len(all) != 1 {
|
||||
t.Fatalf("Folders: want 1, got %d", len(all))
|
||||
}
|
||||
if f, err := s.GetFolder("root"); err != nil || f.Name != "Root" {
|
||||
t.Errorf("GetFolder = %+v, %v", f, err)
|
||||
}
|
||||
if _, err := s.GetFolder("ghost"); err != ErrNotFound {
|
||||
t.Errorf("GetFolder missing: want ErrNotFound, got %v", err)
|
||||
}
|
||||
|
||||
// Owner has write on the folder; an unrelated user has none.
|
||||
if got := s.FolderPerm("root", "alice", nil); got != PermWrite {
|
||||
t.Errorf("owner FolderPerm = %v, want write", got)
|
||||
}
|
||||
if got := s.FolderPerm("root", "bob", nil); got != PermNone {
|
||||
t.Errorf("stranger FolderPerm = %v, want none", got)
|
||||
}
|
||||
|
||||
// Reload: a fresh Store over the same dir still sees the folder.
|
||||
s2, err := New(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("reopen: %v", err)
|
||||
}
|
||||
if _, err := s2.GetFolder("root"); err != nil {
|
||||
t.Errorf("reloaded GetFolder: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -74,7 +74,7 @@ type PanelACL struct {
|
||||
Folder string `json:"folder,omitempty"` // folder id the panel belongs to ("" = root)
|
||||
Public string `json:"public,omitempty"` // "" | "read" | "write"
|
||||
Grants []Grant `json:"grants,omitempty"`
|
||||
Order float64 `json:"order,omitempty"` // sort position within its folder
|
||||
Order float64 `json:"order,omitempty"` // sort position within its folder
|
||||
}
|
||||
|
||||
// Folder is a node in the panel-organisation hierarchy. Permissions set on a
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// credCache memoises successful Basic-auth validations for a short TTL. Browsers
|
||||
// resend the Authorization header on every request, so without this each one
|
||||
// would trigger a full PAM round-trip (slow, and a brute-force/lockout risk).
|
||||
// Only positive results are cached, keyed by a salted SHA-256 of user+password
|
||||
// so the cache never holds a recoverable secret. A fresh random salt per process
|
||||
// keeps keys from being precomputable.
|
||||
type credCache struct {
|
||||
mu sync.Mutex
|
||||
ttl time.Duration
|
||||
salt []byte
|
||||
entries map[string]time.Time
|
||||
}
|
||||
|
||||
func newCredCache(ttl time.Duration) *credCache {
|
||||
salt := make([]byte, 16)
|
||||
_, _ = rand.Read(salt)
|
||||
return &credCache{ttl: ttl, salt: salt, entries: map[string]time.Time{}}
|
||||
}
|
||||
|
||||
func (c *credCache) key(user, pass string) string {
|
||||
h := sha256.New()
|
||||
h.Write(c.salt)
|
||||
h.Write([]byte(user))
|
||||
h.Write([]byte{0})
|
||||
h.Write([]byte(pass))
|
||||
return hex.EncodeToString(h.Sum(nil))
|
||||
}
|
||||
|
||||
// valid reports whether user/pass was validated within the TTL.
|
||||
func (c *credCache) valid(user, pass string) bool {
|
||||
if c.ttl <= 0 {
|
||||
return false
|
||||
}
|
||||
k := c.key(user, pass)
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
exp, ok := c.entries[k]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if time.Now().After(exp) {
|
||||
delete(c.entries, k)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// store records a successful validation, opportunistically pruning expired keys.
|
||||
func (c *credCache) store(user, pass string) {
|
||||
if c.ttl <= 0 {
|
||||
return
|
||||
}
|
||||
k := c.key(user, pass)
|
||||
now := time.Now()
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.entries[k] = now.Add(c.ttl)
|
||||
if len(c.entries) > 1024 {
|
||||
for kk, exp := range c.entries {
|
||||
if now.After(exp) {
|
||||
delete(c.entries, kk)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// basicAuth wraps next with HTTP Basic authentication validated by authFn (PAM,
|
||||
// see internal/pamauth). It mirrors kerberosAuth: a successful login writes the
|
||||
// username into userHeader for the existing access pipeline (accessMiddleware /
|
||||
// wsHandler), and any inbound value of userHeader is always discarded first so a
|
||||
// client cannot spoof an identity.
|
||||
//
|
||||
// challenge controls the no/invalid-credentials case:
|
||||
// - challenge=true (REST/page API): reply 401 + WWW-Authenticate: Basic so the
|
||||
// browser prompts for credentials.
|
||||
// - challenge=false (WebSocket upgrade): fall through unauthenticated; the
|
||||
// session resolves to default_user. Browsers cannot show a login dialog for a
|
||||
// WebSocket, but once they have cached credentials from the page's API calls
|
||||
// they resend them on the upgrade, so the validated path is still taken.
|
||||
func basicAuth(authFn func(user, pass string) error, userHeader, realm string, challenge bool, cache *credCache, log *slog.Logger, next http.Handler) http.Handler {
|
||||
if realm == "" {
|
||||
realm = "uopi"
|
||||
}
|
||||
challengeValue := `Basic realm="` + realm + `", charset="UTF-8"`
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Never trust a client-supplied identity header; only a validated login sets it.
|
||||
r.Header.Del(userHeader)
|
||||
|
||||
if user, pass, ok := r.BasicAuth(); ok && user != "" {
|
||||
if cache.valid(user, pass) {
|
||||
r.Header.Set(userHeader, user)
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if err := authFn(user, pass); err == nil {
|
||||
cache.store(user, pass)
|
||||
r.Header.Set(userHeader, user)
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
} else {
|
||||
log.Warn("basic auth failed", "user", user, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// No or invalid credentials.
|
||||
if challenge {
|
||||
w.Header().Set("WWW-Authenticate", challengeValue)
|
||||
http.Error(w, "authentication required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r) // best-effort (WebSocket): downstream → default_user
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const testUserHeader = "X-Uopi-User"
|
||||
|
||||
func quietLogger() *slog.Logger {
|
||||
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
// echoUser is a terminal handler that reports the resolved identity header so a
|
||||
// test can assert what basicAuth stamped.
|
||||
func echoUser() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = io.WriteString(w, r.Header.Get(testUserHeader))
|
||||
})
|
||||
}
|
||||
|
||||
func basicReq(t *testing.T, user, pass string, setHeader string) *http.Request {
|
||||
t.Helper()
|
||||
r := httptest.NewRequest(http.MethodGet, "/api/v1/me", nil)
|
||||
if user != "" || pass != "" {
|
||||
r.SetBasicAuth(user, pass)
|
||||
}
|
||||
if setHeader != "" {
|
||||
r.Header.Set(testUserHeader, setHeader) // a spoof attempt
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// authFn that accepts only alice/secret.
|
||||
func aliceAuth(calls *int32) func(string, string) error {
|
||||
return func(user, pass string) error {
|
||||
atomic.AddInt32(calls, 1)
|
||||
if user == "alice" && pass == "secret" {
|
||||
return nil
|
||||
}
|
||||
return pamErr{}
|
||||
}
|
||||
}
|
||||
|
||||
type pamErr struct{}
|
||||
|
||||
func (pamErr) Error() string { return "bad credentials" }
|
||||
|
||||
func TestBasicAuthChallengesWithoutCredentials(t *testing.T) {
|
||||
cache := newCredCache(time.Minute)
|
||||
h := basicAuth(aliceAuth(new(int32)), testUserHeader, "uopi", true, cache, quietLogger(), echoUser())
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, basicReq(t, "", "", ""))
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("want 401, got %d", rec.Code)
|
||||
}
|
||||
if got := rec.Header().Get("WWW-Authenticate"); got == "" {
|
||||
t.Fatalf("missing WWW-Authenticate challenge header")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBasicAuthValidCredentialsStampUser(t *testing.T) {
|
||||
cache := newCredCache(time.Minute)
|
||||
h := basicAuth(aliceAuth(new(int32)), testUserHeader, "uopi", true, cache, quietLogger(), echoUser())
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
// Client also tries to spoof the identity header; it must be ignored.
|
||||
h.ServeHTTP(rec, basicReq(t, "alice", "secret", "root"))
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("want 200, got %d", rec.Code)
|
||||
}
|
||||
if got := rec.Body.String(); got != "alice" {
|
||||
t.Fatalf("want resolved user alice, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBasicAuthInvalidCredentialsRejected(t *testing.T) {
|
||||
cache := newCredCache(time.Minute)
|
||||
h := basicAuth(aliceAuth(new(int32)), testUserHeader, "uopi", true, cache, quietLogger(), echoUser())
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, basicReq(t, "alice", "wrong", ""))
|
||||
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("want 401, got %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBasicAuthBestEffortFallsThrough(t *testing.T) {
|
||||
cache := newCredCache(time.Minute)
|
||||
// challenge=false (WebSocket path): no creds → pass through unauthenticated.
|
||||
h := basicAuth(aliceAuth(new(int32)), testUserHeader, "uopi", false, cache, quietLogger(), echoUser())
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, basicReq(t, "", "", ""))
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("want 200 pass-through, got %d", rec.Code)
|
||||
}
|
||||
if got := rec.Body.String(); got != "" {
|
||||
t.Fatalf("want empty identity, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBasicAuthCachesSuccess(t *testing.T) {
|
||||
var calls int32
|
||||
cache := newCredCache(time.Minute)
|
||||
h := basicAuth(aliceAuth(&calls), testUserHeader, "uopi", true, cache, quietLogger(), echoUser())
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, basicReq(t, "alice", "secret", ""))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("req %d: want 200, got %d", i, rec.Code)
|
||||
}
|
||||
}
|
||||
if got := atomic.LoadInt32(&calls); got != 1 {
|
||||
t.Fatalf("want authFn called once (cached), got %d", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/uopi/uopi/internal/controllogic"
|
||||
)
|
||||
|
||||
// DebugHub fans control-logic node-execution events out to the editors watching
|
||||
// them, and owns the per-client debug subscriptions. It implements
|
||||
// controllogic.DebugObserver; the engine (and simulate sandboxes) call Observe
|
||||
// on every node execution.
|
||||
//
|
||||
// Two subscription modes share one event shape:
|
||||
// - "live" — observe the running, enabled graph by its id.
|
||||
// - "simulate" — dry-run an unsaved graph in a throwaway sandbox. Each
|
||||
// simulate sub gets a unique route id so its events never mix with the live
|
||||
// graph's (which may share the same persisted id).
|
||||
//
|
||||
// Routing is by event GraphID: live events carry the real graph id (only
|
||||
// emitted while that id is in the engine's debugWatch set, which the hub keeps
|
||||
// in sync with its live subs), simulate events carry the sandbox's unique id.
|
||||
type DebugHub struct {
|
||||
engine *controllogic.Engine
|
||||
log *slog.Logger
|
||||
|
||||
seq uint64 // unique simulate route ids
|
||||
|
||||
mu sync.Mutex
|
||||
subs map[*wsClient]*debugSub // one debug sub per client
|
||||
routes map[string]map[*wsClient]struct{} // route id → watching clients
|
||||
liveCount map[string]int // live-subscribed graph id → count
|
||||
}
|
||||
|
||||
type debugSub struct {
|
||||
route string // graph id (live) or unique sandbox id (simulate)
|
||||
live bool // true for "live", false for "simulate"
|
||||
stop func() // simulate sandbox teardown (nil for live)
|
||||
}
|
||||
|
||||
// NewDebugHub builds an empty hub bound to the engine it observes.
|
||||
func NewDebugHub(engine *controllogic.Engine, log *slog.Logger) *DebugHub {
|
||||
return &DebugHub{
|
||||
engine: engine,
|
||||
log: log,
|
||||
subs: map[*wsClient]*debugSub{},
|
||||
routes: map[string]map[*wsClient]struct{}{},
|
||||
liveCount: map[string]int{},
|
||||
}
|
||||
}
|
||||
|
||||
// debugOut is the client-bound JSON form of a node-execution event.
|
||||
type debugOut struct {
|
||||
Type string `json:"type"` // always "debugNode"
|
||||
GraphID string `json:"graphId"`
|
||||
NodeID string `json:"nodeId"`
|
||||
Value float64 `json:"value"`
|
||||
HasValue bool `json:"hasValue"`
|
||||
TS int64 `json:"ts"`
|
||||
}
|
||||
|
||||
// Observe implements controllogic.DebugObserver: push the event to every client
|
||||
// watching its route (drop-on-full so a slow editor never stalls the engine).
|
||||
func (h *DebugHub) Observe(ev controllogic.DebugEvent) {
|
||||
h.mu.Lock()
|
||||
watchers := h.routes[ev.GraphID]
|
||||
if len(watchers) == 0 {
|
||||
h.mu.Unlock()
|
||||
return
|
||||
}
|
||||
targets := make([]*wsClient, 0, len(watchers))
|
||||
for c := range watchers {
|
||||
targets = append(targets, c)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
b, err := json.Marshal(debugOut{
|
||||
Type: "debugNode",
|
||||
GraphID: ev.GraphID,
|
||||
NodeID: ev.NodeID,
|
||||
Value: ev.Value,
|
||||
HasValue: ev.HasValue,
|
||||
TS: ev.TS,
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for _, c := range targets {
|
||||
select {
|
||||
case c.outCh <- b:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// subscribeLive starts (or replaces) a client's live observation of graphID.
|
||||
func (h *DebugHub) subscribeLive(c *wsClient, graphID string) {
|
||||
h.unsubscribe(c)
|
||||
if graphID == "" {
|
||||
return
|
||||
}
|
||||
sub := &debugSub{route: graphID, live: true}
|
||||
h.mu.Lock()
|
||||
h.addRoute(c, sub)
|
||||
h.liveCount[graphID]++
|
||||
watch := h.snapshotWatch()
|
||||
h.mu.Unlock()
|
||||
h.engine.SetDebugWatch(watch)
|
||||
}
|
||||
|
||||
// subscribeSimulate starts (or replaces) a client's dry-run of an unsaved graph.
|
||||
func (h *DebugHub) subscribeSimulate(c *wsClient, g controllogic.Graph) {
|
||||
h.unsubscribe(c)
|
||||
id := "sim-" + strconv.FormatUint(atomic.AddUint64(&h.seq, 1), 10)
|
||||
g.ID = id // route sandbox events under a unique id (never collides with live)
|
||||
stop := h.engine.StartSimulate(g)
|
||||
sub := &debugSub{route: id, live: false, stop: stop}
|
||||
h.mu.Lock()
|
||||
h.addRoute(c, sub)
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
// fire forces nodeID's trigger to run in the graph the client is currently
|
||||
// debugging (live or simulate), routed by that session's id. No-op if the
|
||||
// client has no active debug session.
|
||||
func (h *DebugHub) fire(c *wsClient, nodeID string) {
|
||||
h.mu.Lock()
|
||||
sub := h.subs[c]
|
||||
h.mu.Unlock()
|
||||
if sub == nil {
|
||||
return
|
||||
}
|
||||
h.engine.FireTrigger(sub.route, nodeID)
|
||||
}
|
||||
|
||||
// addRoute records sub for c; caller holds h.mu.
|
||||
func (h *DebugHub) addRoute(c *wsClient, sub *debugSub) {
|
||||
h.subs[c] = sub
|
||||
if h.routes[sub.route] == nil {
|
||||
h.routes[sub.route] = map[*wsClient]struct{}{}
|
||||
}
|
||||
h.routes[sub.route][c] = struct{}{}
|
||||
}
|
||||
|
||||
// unsubscribe tears down a client's debug sub (stopping its sandbox if any) and
|
||||
// refreshes the engine's watch set. Safe when the client has no sub.
|
||||
func (h *DebugHub) unsubscribe(c *wsClient) {
|
||||
h.mu.Lock()
|
||||
sub := h.subs[c]
|
||||
if sub == nil {
|
||||
h.mu.Unlock()
|
||||
return
|
||||
}
|
||||
delete(h.subs, c)
|
||||
if m := h.routes[sub.route]; m != nil {
|
||||
delete(m, c)
|
||||
if len(m) == 0 {
|
||||
delete(h.routes, sub.route)
|
||||
}
|
||||
}
|
||||
if sub.live {
|
||||
if h.liveCount[sub.route]--; h.liveCount[sub.route] <= 0 {
|
||||
delete(h.liveCount, sub.route)
|
||||
}
|
||||
}
|
||||
watch := h.snapshotWatch()
|
||||
h.mu.Unlock()
|
||||
|
||||
if sub.stop != nil {
|
||||
sub.stop()
|
||||
}
|
||||
h.engine.SetDebugWatch(watch)
|
||||
}
|
||||
|
||||
// snapshotWatch builds a fresh immutable set of live-watched graph ids. Caller
|
||||
// holds h.mu; the result is published to the engine which reads it lock-free.
|
||||
func (h *DebugHub) snapshotWatch() map[string]bool {
|
||||
w := make(map[string]bool, len(h.liveCount))
|
||||
for k := range h.liveCount {
|
||||
w[k] = true
|
||||
}
|
||||
return w
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/uopi/uopi/internal/access"
|
||||
"github.com/uopi/uopi/internal/audit"
|
||||
"github.com/uopi/uopi/internal/broker"
|
||||
"github.com/uopi/uopi/internal/controllogic"
|
||||
"github.com/uopi/uopi/internal/datasource"
|
||||
)
|
||||
|
||||
// DialogHub fans control-logic dialog requests out to connected WebSocket
|
||||
// clients whose identity matches the dialog's user/group filter, and routes
|
||||
// input responses back to the dialog's target server variable.
|
||||
//
|
||||
// It implements controllogic.Notifier; the engine calls Notify when an
|
||||
// action.dialog node runs. Input dialogs are remembered as pending so a later
|
||||
// dialogResponse can be correlated by id and validated against its recipient
|
||||
// filter — this is why panels can write the response target even though direct
|
||||
// srv writes are otherwise gated to control-logic editors.
|
||||
type DialogHub struct {
|
||||
broker *broker.Broker
|
||||
policy *access.Policy
|
||||
audit audit.Recorder
|
||||
log *slog.Logger
|
||||
|
||||
mu sync.Mutex
|
||||
clients map[*wsClient]struct{}
|
||||
pending map[string]controllogic.Dialog // input dialogs awaiting a response
|
||||
}
|
||||
|
||||
// NewDialogHub builds an empty hub. rec is never nil after construction.
|
||||
func NewDialogHub(brk *broker.Broker, policy *access.Policy, rec audit.Recorder, log *slog.Logger) *DialogHub {
|
||||
if rec == nil {
|
||||
rec = audit.Nop()
|
||||
}
|
||||
return &DialogHub{
|
||||
broker: brk,
|
||||
policy: policy,
|
||||
audit: rec,
|
||||
log: log,
|
||||
clients: map[*wsClient]struct{}{},
|
||||
pending: map[string]controllogic.Dialog{},
|
||||
}
|
||||
}
|
||||
|
||||
func (h *DialogHub) add(c *wsClient) {
|
||||
h.mu.Lock()
|
||||
h.clients[c] = struct{}{}
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
func (h *DialogHub) remove(c *wsClient) {
|
||||
h.mu.Lock()
|
||||
delete(h.clients, c)
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
// dialogOut is the client-bound JSON form of a dialog request.
|
||||
type dialogOut struct {
|
||||
Type string `json:"type"` // always "dialog"
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"` // "info" | "error" | "input"
|
||||
Title string `json:"title,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
}
|
||||
|
||||
// Notify implements controllogic.Notifier: serialise the dialog and push it to
|
||||
// every connected client matching its user/group filter.
|
||||
func (h *DialogHub) Notify(d controllogic.Dialog) {
|
||||
b, err := json.Marshal(dialogOut{
|
||||
Type: "dialog",
|
||||
ID: d.ID,
|
||||
Kind: d.Kind,
|
||||
Title: d.Title,
|
||||
Message: d.Message,
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
h.mu.Lock()
|
||||
if d.Kind == "input" && strings.TrimSpace(d.Target) != "" {
|
||||
h.pending[d.ID] = d
|
||||
}
|
||||
var targets []*wsClient
|
||||
for c := range h.clients {
|
||||
if h.matches(c.user, d) {
|
||||
targets = append(targets, c)
|
||||
}
|
||||
}
|
||||
h.mu.Unlock()
|
||||
|
||||
for _, c := range targets {
|
||||
select {
|
||||
case c.outCh <- b:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// matches reports whether user is a recipient of d. An empty user+group filter
|
||||
// targets everyone; otherwise the user must be named or in a named group.
|
||||
func (h *DialogHub) matches(user string, d controllogic.Dialog) bool {
|
||||
if len(d.Users) == 0 && len(d.Groups) == 0 {
|
||||
return true
|
||||
}
|
||||
if h.policy != nil {
|
||||
user = h.policy.ResolveUser(user)
|
||||
}
|
||||
for _, u := range d.Users {
|
||||
if u == user {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if len(d.Groups) > 0 && h.policy != nil {
|
||||
groups := map[string]bool{}
|
||||
for _, g := range h.policy.GroupsOf(user) {
|
||||
groups[g] = true
|
||||
}
|
||||
for _, g := range d.Groups {
|
||||
if groups[g] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// cancel drops a pending input dialog without writing (user dismissed it).
|
||||
func (h *DialogHub) cancel(id string) {
|
||||
h.mu.Lock()
|
||||
delete(h.pending, id)
|
||||
h.mu.Unlock()
|
||||
}
|
||||
|
||||
// respond writes an input dialog's response to its target server variable. The
|
||||
// dialog must be pending and the responding client must have been a recipient;
|
||||
// this gate replaces the usual srv write-permission check for sanctioned
|
||||
// control-logic responses.
|
||||
func (h *DialogHub) respond(ctx context.Context, c *wsClient, id string, value float64) {
|
||||
h.mu.Lock()
|
||||
d, ok := h.pending[id]
|
||||
if ok {
|
||||
delete(h.pending, id)
|
||||
}
|
||||
h.mu.Unlock()
|
||||
if !ok || !h.matches(c.user, d) {
|
||||
return
|
||||
}
|
||||
|
||||
ds, name, ok := parseDialogTarget(d.Target)
|
||||
if !ok || ds == "local" {
|
||||
return
|
||||
}
|
||||
src, ok := h.broker.Source(ds)
|
||||
if !ok {
|
||||
h.log.Warn("dialog response: unknown data source", "ds", ds, "target", d.Target)
|
||||
return
|
||||
}
|
||||
|
||||
ev := audit.Event{
|
||||
Actor: c.user,
|
||||
ActorType: audit.ActorUser,
|
||||
Action: "signal.write",
|
||||
DS: ds,
|
||||
Signal: name,
|
||||
Value: strconv.FormatFloat(value, 'g', -1, 64),
|
||||
Detail: "control logic dialog response",
|
||||
IP: c.ip,
|
||||
Outcome: audit.OutcomeOK,
|
||||
}
|
||||
wctx := datasource.WithUser(ctx, c.user)
|
||||
if err := src.Write(wctx, name, value); err != nil {
|
||||
h.log.Warn("dialog response: write failed", "ds", ds, "signal", name, "err", err)
|
||||
ev.Outcome = audit.OutcomeError
|
||||
ev.Error = err.Error()
|
||||
}
|
||||
h.audit.Record(ev)
|
||||
}
|
||||
|
||||
// parseDialogTarget splits a "ds:name" dialog target on the first ':'. A bare
|
||||
// name (no ':') defaults to the persistent server-variable source "srv".
|
||||
func parseDialogTarget(t string) (ds, name string, ok bool) {
|
||||
t = strings.TrimSpace(t)
|
||||
if t == "" {
|
||||
return "", "", false
|
||||
}
|
||||
if i := strings.IndexByte(t, ':'); i >= 0 {
|
||||
return t[:i], t[i+1:], true
|
||||
}
|
||||
return "srv", t, true
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user