Compare commits
37 Commits
b0ac044035
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| 336095c052 | |||
| c53a49e540 | |||
| b6bc9dc2f2 | |||
| 3133e50e09 | |||
| 550ec06bc8 | |||
| 9c1f2685a7 | |||
| 088063d9cd | |||
| a6fa4e7c7c | |||
| 519c1f2df4 | |||
| 3ddffc14d7 | |||
| e76865d132 | |||
| 5012511306 | |||
| 44c8f98e01 | |||
| 05b06d64a4 | |||
| cb4e81beb2 | |||
| 9d9e538a90 | |||
| 9d48292976 | |||
| 91661485ae | |||
| 603574f86f | |||
| b82e8852b3 | |||
| 8f50bc2498 | |||
| 062bb44dba | |||
| f776de378f | |||
| 5578bceea2 | |||
| 2f40a9d1a0 | |||
| 3b8c6540e1 | |||
| 774e28453b | |||
| e4e67ee0c2 | |||
| cf9da3df0a | |||
| 999a1510d4 | |||
| 6f7c90cc98 | |||
| c0f7e662be | |||
| 11120bedca | |||
| ac24011487 | |||
| 73fcbe7b28 | |||
| 113e5a0fe8 | |||
| 04d31a15c4 |
+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 |
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
# 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:
|
||||
@@ -11,61 +14,129 @@
|
||||
- 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...
|
||||
- [ ] user can apply and save configuration instances from manager
|
||||
- [ ] in logic editor and control loop add nodes to read/write/create/apply config instances
|
||||
- [ ] support for all supported types (number, bools, enums, arrays, string etc)
|
||||
- [ ] ux elements for config set
|
||||
- the configuration editor should automatically get type and info from signal when possible
|
||||
- a slick tree drag and drop editor to order elements and organize in group and sub-groups
|
||||
- possibility to customise unit, max, min etc
|
||||
- [ ] ux elements for config instances:
|
||||
- [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
|
||||
- [ ] add advanced validation / transformation framework to configurations using custom CUE rules:
|
||||
- backend should run validation / transformation rules
|
||||
- user can create/edit/delete/compare rules from webui:
|
||||
- integrate syntax highlight
|
||||
- integrate autocomplete for signals names and cue grammars
|
||||
- integrate cue lsp
|
||||
- when validation fail error should be propagated to user in the webui
|
||||
- all action should be tracked via history management and audit
|
||||
- [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:
|
||||
- [x] Synthetic editor:
|
||||
- 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
|
||||
- [ ] add container widgets: labelled/title pane, tab panes, collapsable panes etc
|
||||
- [ ] plot pane:
|
||||
- add toolbar
|
||||
- [ ] clean ui:
|
||||
- create small statusbar where connection widget and other status related info will be placed
|
||||
- group advanced items (audit/control loops/config manager etc) in a tool menu or something similar to not fill the toolbar
|
||||
- make the toolbar as clean as possible
|
||||
- [ ] Logic editor:
|
||||
- add full support to local array values: dynamic, dynamic but capped max, fixed size etc:
|
||||
- array functions should work with new local array
|
||||
- [ ] Control loop:
|
||||
- add full support to server side array values
|
||||
- [ ] Implement git style versioning for: synthetic variable, panels, control logic:
|
||||
- possibility to fork any version
|
||||
- click to view the version
|
||||
- possibility to view graphical diff between versions (side by side or unified diff)
|
||||
- simple slick versioning pane:
|
||||
- vertical tree like
|
||||
- each version represented by a circle
|
||||
- active (the one currently view/edited) version has circle bigger then rest
|
||||
- selected (the one that will be executed/showed by user) version has circle full, not active only border
|
||||
- unsaved / new version appear with connection line dashed
|
||||
- [ ] Implement admin pane: create / manage groups, set users permits, manage auditors etc
|
||||
- [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`
|
||||
- [x] 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
|
||||
- [x] add full suppor to local array values: dynamic, dynamic but capped max, fixed size etc:
|
||||
- [x] array functions should work with new local array
|
||||
- NOTE: **Phase 1 (panel logic, client-side TS) is DONE** — array statevars (dynamic/capped/fixed), value-polymorphic expression engine with indexing + array functions, `action.array.*` mutation nodes (legacy accumulate/export/clear unified + auto-migrated), panel-XML round-trip, editor declaration form, and widget array modes (plot/table/multi-LED). **Phase 2 (server-side `internal/controllogic` Go port, see below) has now landed**, so this feature is complete across both engines.
|
||||
- [x] 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
|
||||
- [x] add full support to server side array values — DONE (Phase 2): `Value = float64 | []Value` model (`internal/controllogic/value.go`), `Graph.StateVars` (number/bool/array with dynamic/capped/fixed sizing) persisted in store JSON, value-polymorphic `expr.go` (array literals, negative-wrap indexing, full array-function table), and `action.array.push|set|remove|pop|clear` nodes. Lua block stays scalar-only (array local → NaN); CSV export remains panel-logic-only
|
||||
- [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
|
||||
- [ ] 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
|
||||
|
||||
+151
-12
@@ -13,6 +13,7 @@ import (
|
||||
"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"
|
||||
@@ -20,10 +21,14 @@ import (
|
||||
"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/modbus"
|
||||
"github.com/uopi/uopi/internal/datasource/pva"
|
||||
"github.com/uopi/uopi/internal/datasource/scpi"
|
||||
"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"
|
||||
@@ -143,6 +148,35 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Modbus TCP data source: polls configured holding/input/coil/discrete
|
||||
// registers on one or more devices. Disabled unless [datasource.modbus] is
|
||||
// configured with devices.
|
||||
if cfg.Datasource.Modbus.Enabled {
|
||||
mbDS, err := modbus.New(cfg.Datasource.Modbus)
|
||||
if err != nil {
|
||||
log.Error("modbus init", "err", err)
|
||||
} else if err := mbDS.Connect(ctx); err != nil {
|
||||
log.Error("modbus connect", "err", err)
|
||||
} else {
|
||||
brk.Register(mbDS)
|
||||
context.AfterFunc(ctx, mbDS.Close)
|
||||
}
|
||||
}
|
||||
|
||||
// SCPI data source: polls instrument channels over raw TCP sockets.
|
||||
// Disabled unless [datasource.scpi] is configured with instruments.
|
||||
if cfg.Datasource.SCPI.Enabled {
|
||||
scpiDS, err := scpi.New(cfg.Datasource.SCPI)
|
||||
if err != nil {
|
||||
log.Error("scpi init", "err", err)
|
||||
} else if err := scpiDS.Connect(ctx); err != nil {
|
||||
log.Error("scpi connect", "err", err)
|
||||
} else {
|
||||
brk.Register(scpiDS)
|
||||
context.AfterFunc(ctx, scpiDS.Close)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -152,18 +186,32 @@ func main() {
|
||||
}
|
||||
brk.Register(srvVars)
|
||||
|
||||
// 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
|
||||
}
|
||||
groups := make(map[string][]string, len(cfg.Groups))
|
||||
// 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)
|
||||
}
|
||||
policy := access.New(cfg.Server.DefaultUser, blacklist, groups, cfg.Server.LogicEditors, cfg.Audit.Readers)
|
||||
|
||||
// Audit log: when enabled, every system-affecting action (user/automated
|
||||
// signal writes, interface and control-logic mutations) is recorded to SQLite.
|
||||
@@ -192,16 +240,107 @@ func main() {
|
||||
log.Error("failed to open control logic store", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
ctrlEngine := controllogic.NewEngine(ctx, brk, ctrlStore, recorder, 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, cfgStore, policy, aclStore, ctrlStore, ctrlEngine, dialogs, recorder, 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)
|
||||
|
||||
+101
-3
@@ -236,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).
|
||||
@@ -279,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
|
||||
@@ -296,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).
|
||||
|
||||
---
|
||||
@@ -305,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.
|
||||
@@ -345,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 |
|
||||
|-------------|--------|
|
||||
|
||||
+324
-28
@@ -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
|
||||
|
||||
@@ -305,10 +346,40 @@ mount and `clear()` on unmount. The engine subscribes to every referenced signal
|
||||
cache, then on each trigger activation walks the wired graph (gates, if/loop, actions),
|
||||
evaluating expression fields via a small safe recursive-descent evaluator (no `eval`).
|
||||
Triggers include button/threshold/change/timer/loop and On-open/On-close lifecycle; actions
|
||||
include signal writes, delay, log, in-memory data arrays (accumulate/export-CSV/clear) and
|
||||
include signal writes, delay, log, array mutations (see below) and
|
||||
user dialogs (info/error/set-point). The engine runs entirely in the browser — no backend
|
||||
component.
|
||||
|
||||
**Array-valued local variables.** Panel-local variables (`<statevar>`) may be declared with
|
||||
`type="array"`. An array statevar carries `elem` (`number`|`bool`|`array`, the element kind —
|
||||
`array` gives a 2-D array), `sizing`, and `capacity`. The three sizing policies
|
||||
(`web/src/lib/arraypolicy.ts`) are: **dynamic** (unbounded up to `ARRAY_MAX = 1_000_000`,
|
||||
oldest dropped past the cap), **capped** (length kept ≤ `capacity`, oldest dropped FIFO), and
|
||||
**fixed** (exactly `capacity` elements — truncated or zero-padded). Sizing is enforced only at
|
||||
store time (`writeLocalState` → `applySizing`); expressions themselves are pure and produce
|
||||
unbounded values. The value model is the tagged union `ArrVal = number | ArrVal[]` with
|
||||
booleans represented as `1`/`0` at the leaves.
|
||||
|
||||
The expression evaluator (`web/src/lib/expr.ts`) is value-polymorphic: `evalValue` returns an
|
||||
`ArrVal`, while `evalExpr` returns a `number` (`NaN` if the result is an array). It supports
|
||||
array literals `[a, b, c]`, indexing `a[i]` (negative indices count from the end), and a table
|
||||
of array functions: `len`, `sum`, `mean`, `slice`, `concat`, `reverse`, `sort`, `scale`, `add`,
|
||||
`sub`, `push`, `set`, `insert`, `remove`, `pop`, `shift`, `indexOf`, `contains`, `fill` (plus
|
||||
`min`/`max`, which accept either scalars or an array). These are pure — they return new values
|
||||
and never mutate a stored variable.
|
||||
|
||||
Array mutation nodes write back to a declared array statevar: `action.array.push`, `…set`,
|
||||
`…remove`, `…pop`, and `…clear`. The legacy `action.accumulate` and `action.clear` nodes are
|
||||
retained as aliases over `action.array.push` / `action.array.clear`, and legacy graphs are
|
||||
auto-migrated to declare their backing arrays on load (`ensureArrayDecls`). `action.export`
|
||||
now produces **index-aligned** CSV: each configured array becomes a column (custom per-column
|
||||
labels supported), rows aligned by element index. Array statevars and all array nodes
|
||||
round-trip through the panel XML (`<statevar type="array" elem=… sizing=… capacity=…>`).
|
||||
|
||||
> **Note:** The above is Phase 1 (panel logic, client-side TypeScript). The equivalent array
|
||||
> support in the server-side control-logic engine (`internal/controllogic`) is Phase 2 and has
|
||||
> now landed — see the **Array-valued local variables** subsection of §3.9 below.
|
||||
|
||||
### 3.9 Control Logic Engine
|
||||
|
||||
Control logic is server-side (`internal/controllogic`): always-on flow graphs that run under
|
||||
@@ -318,16 +389,199 @@ 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.
|
||||
|
||||
**Array-valued local variables (Phase 2).** Engine locals now carry a `Value` — the tagged
|
||||
union `Value = float64 | []Value` (`internal/controllogic/value.go`), the Go port of the
|
||||
panel-logic `ArrVal` model, with booleans represented as `1`/`0` at the leaves. A graph may
|
||||
declare `statevars`: each has a `Name`, a `Type` (`number` | `bool` | `array`), an `Initial`
|
||||
expression, and — for arrays — a `Sizing` policy and `Capacity`. The three sizing policies
|
||||
mirror the frontend: **dynamic** (unbounded up to `ARRAY_MAX = 1_000_000`, oldest dropped
|
||||
FIFO past the cap), **capped** (length kept ≤ `Capacity`, oldest dropped FIFO), and **fixed**
|
||||
(exactly `Capacity` elements — truncated or zero-padded). Sizing is enforced on write. State
|
||||
vars are persisted in the graph's store JSON and seeded into locals at compile time.
|
||||
|
||||
The expression evaluator (`internal/controllogic/expr.go`) is value-polymorphic: it supports
|
||||
array literals `[a, b, c]`, indexing `arr[i]` (negative indices wrap from the end), and a
|
||||
table of array functions: `len`, `sum`, `mean`, `slice`, `concat`, `reverse`, `sort`, `scale`,
|
||||
`add`, `sub`, `push`, `set`, `insert`, `remove`, `pop`, `shift`, `indexOf`, `contains`, `fill`
|
||||
(plus `min`/`max`, which accept either scalars or an array). These are pure — they produce new
|
||||
values and never mutate a stored local. Five action nodes write back to a declared array
|
||||
statevar: `action.array.push`, `…set`, `…remove`, `…pop`, and `…clear`. The embedded Lua block
|
||||
remains **scalar-only**: reading an array local from Lua yields `NaN`. CSV export
|
||||
(`action.export`) is panel-logic-only and is **not** available in control logic.
|
||||
|
||||
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).
|
||||
|
||||
---
|
||||
|
||||
@@ -398,6 +652,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`)
|
||||
@@ -485,15 +740,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
|
||||
@@ -506,7 +801,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`).
|
||||
|
||||
---
|
||||
|
||||
@@ -531,11 +826,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')`).
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,779 @@
|
||||
# Local Array Values — Phase 1 (Panel Logic, TypeScript) Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add first-class array-valued local variables to the panel-logic (client TS) flow engine — declaration with dynamic/capped/fixed sizing, an array-aware expression language, mutation nodes (unifying the legacy accumulate/export/clear arrays), persistence, and binding from the plot / table / multi-LED widgets.
|
||||
|
||||
**Architecture:** The expression evaluator (`expr.ts`) becomes value-polymorphic — `EvalValue = number | EvalValue[]` (tagged union, "Approach 1"). Array locals are declared as a new `type:'array'` `StateVar` and held per-panel in `localstate.ts`, which enforces the sizing policy on every write. Mutation is performed by new `action.array.*` nodes; reads/transforms are pure functions in the expression language. Widgets read array locals through the existing `ds:'local'` plumbing.
|
||||
|
||||
**Tech Stack:** Preact 10 + TypeScript, bundled by esbuild via the Go tool `tools/buildfrontend/main.go` (no npm/node). Stores are the hand-rolled `web/src/lib/store.ts` writable/readable primitives.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- No npm / no Node.js. Frontend builds **only** via `make frontend` (esbuild, pure Go). There is **no JS test runner and no typecheck gate** — esbuild strips types without checking them. Phase-1 verification = `make frontend` builds clean + the manual smoke checklist in Task 9.
|
||||
- The authoritative automated tests for the shared array semantics live in **Phase 2 (Go `expr.go`)**, not here. Keep `expr.ts` semantics documented precisely so the Go port can mirror them exactly.
|
||||
- Booleans are numbers (`1`/`0`) at array leaves; `elem:'bool'` is display metadata only.
|
||||
- Expressions are **pure** (no side effects). Mutator-named functions (`push/set/insert/remove/pop/shift`) return **new** arrays; the sizing policy is enforced only at store time in `localstate.ts`.
|
||||
- Dynamic arrays are guarded by a global safety cap `ARRAY_MAX = 1_000_000` elements.
|
||||
- Preserve backward compatibility: existing `accumulate`/`clear`/`export` nodes keep working via aliasing + auto-declared locals.
|
||||
- Follow existing file conventions; commit frequently with `Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>`.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- `web/src/lib/types.ts` — extend `StateVar` with array fields. (Modify)
|
||||
- `web/src/lib/expr.ts` — polymorphic value model, array literals, indexing, array functions. (Modify — the core change)
|
||||
- `web/src/lib/localstate.ts` — array init from `initial`, sizing-policy enforcement on write, array metadata. (Modify)
|
||||
- `web/src/lib/arraypolicy.ts` — **new**: pure helpers `applySizing(value, sv)` and `parseInitialArray(sv)` shared by `localstate.ts` and `logic.ts`. (Create)
|
||||
- `web/src/lib/logic.ts` — new `action.array.*` node handlers; accumulate/clear aliasing; export-by-index with custom headers; auto-declare migration in `load()`. (Modify)
|
||||
- `web/src/lib/types.ts` `LogicNodeKind` — add the new node kinds. (Modify, same file as above)
|
||||
- `web/src/lib/xml.ts` — round-trip the new `<statevar>` array attributes. (Modify)
|
||||
- `web/src/LogicEditor.tsx` — `LocalVars` array declaration form + array node palette entries + inspectors. (Modify)
|
||||
- `web/src/lib/flowDebug.ts` — compact array stringify for node value badges. (Modify)
|
||||
- `web/src/widgets/PlotWidget.tsx`, `web/src/widgets/TableWidget.tsx`, `web/src/widgets/MultiLed.tsx` (exact filenames verified in Task 7) — array source modes. (Modify)
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Extend `StateVar` with array fields
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/lib/types.ts` (the `StateVar` interface, ~lines 69-76)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `StateVar` with new optional fields `elem?: 'number'|'bool'|'array'`, `sizing?: 'dynamic'|'capped'|'fixed'`, `capacity?: number`, and `type` union extended with `'array'`.
|
||||
|
||||
- [ ] **Step 1: Extend the interface**
|
||||
|
||||
In `web/src/lib/types.ts`, change the `StateVar` interface to:
|
||||
|
||||
```ts
|
||||
export interface StateVar {
|
||||
name: string;
|
||||
type?: 'number' | 'bool' | 'string' | 'array';
|
||||
initial: string;
|
||||
unit?: string;
|
||||
low?: number;
|
||||
high?: number;
|
||||
// array-only (present when type === 'array'):
|
||||
elem?: 'number' | 'bool' | 'array';
|
||||
sizing?: 'dynamic' | 'capped' | 'fixed';
|
||||
capacity?: number;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build**
|
||||
|
||||
Run: `make frontend`
|
||||
Expected: `Frontend built successfully → …/web/dist`
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add web/src/lib/types.ts
|
||||
git commit -m "feat(logic): add array fields to StateVar type"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Array sizing-policy helpers (`arraypolicy.ts`)
|
||||
|
||||
**Files:**
|
||||
- Create: `web/src/lib/arraypolicy.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `StateVar`, `EvalValue` (Task 3 finalizes `EvalValue`; here use `type EvalValue = number | EvalValue[]` locally re-exported from `expr.ts` once Task 3 lands — for ordering, define the alias in this file and have `expr.ts` import it).
|
||||
- Produces:
|
||||
- `export type ArrVal = number | ArrVal[];`
|
||||
- `export const ARRAY_MAX = 1_000_000;`
|
||||
- `export function parseInitialArray(sv: StateVar): ArrVal[]` — initial contents for an array local.
|
||||
- `export function applySizing(arr: ArrVal[], sv: StateVar): ArrVal[]` — enforce dynamic/capped/fixed on a candidate array value.
|
||||
|
||||
- [ ] **Step 1: Create the module**
|
||||
|
||||
```ts
|
||||
// web/src/lib/arraypolicy.ts
|
||||
// Pure helpers for array-valued local state: parse the declared initial value
|
||||
// and enforce the declared sizing policy (dynamic / capped / fixed). Shared by
|
||||
// localstate.ts (write path) and logic.ts (node handlers + migration).
|
||||
|
||||
import type { StateVar } from './types';
|
||||
|
||||
export type ArrVal = number | ArrVal[];
|
||||
export const ARRAY_MAX = 1_000_000;
|
||||
|
||||
// zeroFill builds a length-n array of zeros (flat; fixed nested init must come
|
||||
// from an explicit `initial` literal).
|
||||
function zeroFill(n: number): ArrVal[] {
|
||||
return new Array(Math.max(0, n)).fill(0);
|
||||
}
|
||||
|
||||
// parseInitialArray returns the starting contents of an array local.
|
||||
export function parseInitialArray(sv: StateVar): ArrVal[] {
|
||||
const cap = sv.capacity ?? 0;
|
||||
const raw = (sv.initial ?? '').trim();
|
||||
let parsed: ArrVal[] | null = null;
|
||||
if (raw) {
|
||||
try {
|
||||
const j = JSON.parse(raw);
|
||||
if (Array.isArray(j)) parsed = j as ArrVal[];
|
||||
} catch { parsed = null; }
|
||||
}
|
||||
if (sv.sizing === 'fixed') {
|
||||
if (!parsed) return zeroFill(cap);
|
||||
// truncate / zero-pad to capacity
|
||||
const out = parsed.slice(0, cap);
|
||||
while (out.length < cap) out.push(0);
|
||||
return out;
|
||||
}
|
||||
return parsed ?? [];
|
||||
}
|
||||
|
||||
// applySizing returns arr clamped to the declared policy.
|
||||
// dynamic → unchanged (but globally capped at ARRAY_MAX, dropping oldest)
|
||||
// capped → keep at most capacity elements, dropping oldest (ring/FIFO)
|
||||
// fixed → exactly capacity elements (truncate / zero-pad); never grow/shrink
|
||||
export function applySizing(arr: ArrVal[], sv: StateVar): ArrVal[] {
|
||||
const cap = sv.capacity ?? 0;
|
||||
switch (sv.sizing) {
|
||||
case 'fixed': {
|
||||
const out = arr.slice(0, cap);
|
||||
while (out.length < cap) out.push(0);
|
||||
return out;
|
||||
}
|
||||
case 'capped':
|
||||
return arr.length > cap ? arr.slice(arr.length - cap) : arr;
|
||||
default:
|
||||
return arr.length > ARRAY_MAX ? arr.slice(arr.length - ARRAY_MAX) : arr;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Build**
|
||||
|
||||
Run: `make frontend`
|
||||
Expected: builds clean (module compiles; not yet imported anywhere).
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add web/src/lib/arraypolicy.ts
|
||||
git commit -m "feat(logic): add array sizing-policy helpers"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Make `expr.ts` value-polymorphic
|
||||
|
||||
This is the core change. `Resolver` and the evaluator move from `number` to `ArrVal = number | ArrVal[]`. Add array-literal and indexing syntax plus the array function set. Keep all existing scalar behavior identical (numbers, booleans-as-1/0, operators, ternary, the existing math funcs).
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/lib/expr.ts` (whole file — see current contents)
|
||||
- Modify import in: any caller of `Resolver`/`evalExpr` that assumed a `number` return — audit in Step 5.
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `ArrVal` from `./arraypolicy`.
|
||||
- Produces:
|
||||
- `export type Resolver = (ds: string, name: string) => ArrVal;`
|
||||
- `export function evalValue(src: string, resolve: Resolver): ArrVal` — full value (number or array).
|
||||
- `export function evalExpr(src: string, resolve: Resolver): number` — **kept** for scalar callers; returns `NaN` if the value is an array or unparseable.
|
||||
- `export function evalBool(src, resolve): boolean` — unchanged signature.
|
||||
- `export function collectRefs(src): RefLite[]` — now also walks array-literal / index AST nodes.
|
||||
- `export function checkExpr(src): string|null` — unchanged signature; parser now accepts the new syntax.
|
||||
|
||||
- [ ] **Step 1: Add AST nodes for array literal and indexing**
|
||||
|
||||
In the `Node` union add:
|
||||
|
||||
```ts
|
||||
| { t: 'arr'; items: Node[] }
|
||||
| { t: 'index'; a: Node; i: Node }
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Tokenizer — add `[` and `]`**
|
||||
|
||||
In `tokenize`, extend the single-char punctuation set to include brackets:
|
||||
|
||||
```ts
|
||||
if ('+-*/%<>!()?:,[]'.includes(c)) { toks.push({ k: c }); i++; continue; }
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Parser — array literals + postfix indexing**
|
||||
|
||||
Replace `primary()` so that after producing a base node it consumes any chain of `[ expr ]`, and add the `[ … ]` literal:
|
||||
|
||||
```ts
|
||||
function atom(): Node {
|
||||
const t = peek();
|
||||
if (!t) throw new Error('unexpected end of expression');
|
||||
if (t.k === 'num') { eat(); return { t: 'num', v: parseFloat(t.v!) }; }
|
||||
if (t.k === '[') {
|
||||
eat('[');
|
||||
const items: Node[] = [];
|
||||
if (peek()?.k !== ']') {
|
||||
items.push(ternary());
|
||||
while (peek()?.k === ',') { eat(','); items.push(ternary()); }
|
||||
}
|
||||
eat(']');
|
||||
return { t: 'arr', items };
|
||||
}
|
||||
if (t.k === 'sig') {
|
||||
eat();
|
||||
const raw = t.v!;
|
||||
const idx = raw.indexOf(':');
|
||||
const ds = idx < 0 ? raw : raw.slice(0, idx);
|
||||
const name = idx < 0 ? '' : raw.slice(idx + 1);
|
||||
return { t: 'sig', ds, name };
|
||||
}
|
||||
if (t.k === 'ident') {
|
||||
eat();
|
||||
const id = t.v!;
|
||||
if (id === 'true') return { t: 'num', v: 1 };
|
||||
if (id === 'false') return { t: 'num', v: 0 };
|
||||
if (peek()?.k === '(') {
|
||||
eat('(');
|
||||
const args: Node[] = [];
|
||||
if (peek()?.k !== ')') {
|
||||
args.push(ternary());
|
||||
while (peek()?.k === ',') { eat(','); args.push(ternary()); }
|
||||
}
|
||||
eat(')');
|
||||
return { t: 'call', fn: id, args };
|
||||
}
|
||||
return { t: 'var', name: id };
|
||||
}
|
||||
if (t.k === '(') { eat('('); const e = ternary(); eat(')'); return e; }
|
||||
throw new Error(`unexpected token '${t.k}' in expression`);
|
||||
}
|
||||
|
||||
function primary(): Node {
|
||||
let n = atom();
|
||||
while (peek()?.k === '[') {
|
||||
eat('[');
|
||||
const i = ternary();
|
||||
eat(']');
|
||||
n = { t: 'index', a: n, i };
|
||||
}
|
||||
return n;
|
||||
}
|
||||
```
|
||||
|
||||
(`unary` still calls `primary`; no other parser change.)
|
||||
|
||||
- [ ] **Step 4: Evaluator — return `ArrVal`, add array funcs + indexing**
|
||||
|
||||
Replace the evaluation section. Helpers `asNum` (coerce to number, throw on array) and `asArr` (require array) gate type errors.
|
||||
|
||||
```ts
|
||||
import type { ArrVal } from './arraypolicy';
|
||||
|
||||
export type Resolver = (ds: string, name: string) => ArrVal;
|
||||
|
||||
function asNum(v: ArrVal): number {
|
||||
if (typeof v !== 'number') throw new Error('expected a number, got an array');
|
||||
return v;
|
||||
}
|
||||
function asArr(v: ArrVal): ArrVal[] {
|
||||
if (!Array.isArray(v)) throw new Error('expected an array, got a number');
|
||||
return v;
|
||||
}
|
||||
// resolve a possibly-negative index against length
|
||||
function idx(i: number, len: number): number {
|
||||
const k = Math.trunc(i) < 0 ? len + Math.trunc(i) : Math.trunc(i);
|
||||
if (k < 0 || k >= len) throw new Error(`index ${i} out of range (len ${len})`);
|
||||
return k;
|
||||
}
|
||||
|
||||
const ARR_FUNCS: Record<string, (a: ArrVal[]) => ArrVal> = {
|
||||
len: a => asArr(a[0]).length,
|
||||
sum: a => asArr(a[0]).reduce((s, x) => s + asNum(x), 0),
|
||||
mean: a => { const r = asArr(a[0]); return r.length ? r.reduce((s, x) => s + asNum(x), 0) / r.length : 0; },
|
||||
// min/max: scalar-variadic OR single-array — see ev() dispatch below
|
||||
slice: a => { const r = asArr(a[0]); const s = a[1] === undefined ? 0 : asNum(a[1]); const e = a[2] === undefined ? r.length : asNum(a[2]); return r.slice(s, e); },
|
||||
concat: a => asArr(a[0]).concat(asArr(a[1])),
|
||||
reverse: a => asArr(a[0]).slice().reverse(),
|
||||
sort: a => asArr(a[0]).slice().sort((x, y) => asNum(x) - asNum(y)),
|
||||
scale: a => asArr(a[0]).map(x => asNum(x) * asNum(a[1])),
|
||||
add: a => { const x = asArr(a[0]), y = asArr(a[1]); const n = Math.min(x.length, y.length); const o: ArrVal[] = []; for (let k = 0; k < n; k++) o.push(asNum(x[k]) + asNum(y[k])); return o; },
|
||||
sub: a => { const x = asArr(a[0]), y = asArr(a[1]); const n = Math.min(x.length, y.length); const o: ArrVal[] = []; for (let k = 0; k < n; k++) o.push(asNum(x[k]) - asNum(y[k])); return o; },
|
||||
push: a => asArr(a[0]).concat([a[1]]),
|
||||
set: a => { const r = asArr(a[0]).slice(); r[idx(asNum(a[1]), r.length)] = a[2]; return r; },
|
||||
insert: a => { const r = asArr(a[0]).slice(); const k = Math.max(0, Math.min(r.length, Math.trunc(asNum(a[1])))); r.splice(k, 0, a[2]); return r; },
|
||||
remove: a => { const r = asArr(a[0]).slice(); r.splice(idx(asNum(a[1]), r.length), 1); return r; },
|
||||
pop: a => { const r = asArr(a[0]).slice(); r.pop(); return r; },
|
||||
shift: a => { const r = asArr(a[0]).slice(); r.shift(); return r; },
|
||||
indexOf: a => { const r = asArr(a[0]); for (let k = 0; k < r.length; k++) if (r[k] === a[1]) return k; return -1; },
|
||||
contains: a => { const r = asArr(a[0]); for (let k = 0; k < r.length; k++) if (r[k] === a[1]) return 1; return 0; },
|
||||
fill: a => new Array(Math.max(0, Math.trunc(asNum(a[0])))).fill(a[1]),
|
||||
};
|
||||
|
||||
function ev(n: Node, R: Resolver): ArrVal {
|
||||
switch (n.t) {
|
||||
case 'num': return n.v;
|
||||
case 'arr': return n.items.map(it => ev(it, R));
|
||||
case 'sig': return R(n.ds, n.name);
|
||||
case 'var': return R('local', n.name);
|
||||
case 'index': return asArr(ev(n.a, R))[idx(asNum(ev(n.i, R)), asArr(ev(n.a, R)).length)];
|
||||
case 'un': return n.op === '-' ? -asNum(ev(n.a, R)) : (asNum(ev(n.a, R)) === 0 ? 1 : 0);
|
||||
case 'tern': return asNum(ev(n.c, R)) !== 0 ? ev(n.a, R) : ev(n.b, R);
|
||||
case 'call': {
|
||||
const args = n.args.map(a => ev(a, R));
|
||||
// min/max keep scalar-variadic form, plus 1-arg array form
|
||||
if ((n.fn === 'min' || n.fn === 'max') && !(args.length === 1 && Array.isArray(args[0]))) {
|
||||
const nums = args.map(asNum);
|
||||
return n.fn === 'min' ? Math.min(...nums) : Math.max(...nums);
|
||||
}
|
||||
if (n.fn === 'min' || n.fn === 'max') {
|
||||
const r = asArr(args[0]).map(asNum);
|
||||
return n.fn === 'min' ? Math.min(...r) : Math.max(...r);
|
||||
}
|
||||
const af = ARR_FUNCS[n.fn];
|
||||
if (af) return af(args);
|
||||
const sf = SCALAR_FUNCS[n.fn];
|
||||
if (sf) return sf(args.map(asNum));
|
||||
throw new Error(`unknown function '${n.fn}'`);
|
||||
}
|
||||
case 'bin': {
|
||||
const a = asNum(ev(n.a, R)), b = asNum(ev(n.b, R));
|
||||
switch (n.op) {
|
||||
case '+': return a + b; case '-': return a - b; case '*': return a * b;
|
||||
case '/': return a / b; case '%': return a % b;
|
||||
case '<': return a < b ? 1 : 0; case '<=': return a <= b ? 1 : 0;
|
||||
case '>': return a > b ? 1 : 0; case '>=': return a >= b ? 1 : 0;
|
||||
case '==': return a === b ? 1 : 0; case '!=': return a !== b ? 1 : 0;
|
||||
case '&&': return (a !== 0 && b !== 0) ? 1 : 0;
|
||||
case '||': return (a !== 0 || b !== 0) ? 1 : 0;
|
||||
default: throw new Error(`unknown operator '${n.op}'`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Rename the existing `FUNCS` table to `SCALAR_FUNCS` (same entries: abs/min/max/sqrt/floor/ceil/round/sign/pow/log/exp/sin/cos) — but **remove** `min`/`max` from it since they are now handled in the `call` dispatch above.
|
||||
|
||||
- [ ] **Step 5: Public functions — split `evalValue` / `evalExpr`**
|
||||
|
||||
```ts
|
||||
export function evalValue(src: string, resolve: Resolver): ArrVal {
|
||||
return ev(parseCached(src), resolve);
|
||||
}
|
||||
|
||||
export function evalExpr(src: string, resolve: Resolver): number {
|
||||
try {
|
||||
const v = ev(parseCached(src), resolve);
|
||||
return typeof v === 'number' ? v : NaN;
|
||||
} catch {
|
||||
return NaN;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`evalBool` keeps calling `evalExpr` (array → NaN → false, acceptable). Update `collectRefs`'s `walk` switch to also recurse the new nodes:
|
||||
|
||||
```ts
|
||||
case 'arr': n.items.forEach(walk); break;
|
||||
case 'index': walk(n.a); walk(n.i); break;
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Audit callers of `Resolver`/`evalExpr`**
|
||||
|
||||
Run: `grep -rn "Resolver\|evalExpr\|evalValue" web/src` and confirm every resolver implementation can return `ArrVal` (returning a plain `number` still satisfies `ArrVal`). The write/condition callers that need a number keep using `evalExpr`; only array-targeting nodes (Task 4) use `evalValue`.
|
||||
|
||||
- [ ] **Step 7: Build**
|
||||
|
||||
Run: `make frontend`
|
||||
Expected: builds clean.
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add web/src/lib/expr.ts
|
||||
git commit -m "feat(logic): array-aware expression engine (literals, indexing, array funcs)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Array values + sizing in `localstate.ts`
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/lib/localstate.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `parseInitialArray`, `applySizing`, `ArrVal` from `./arraypolicy`; `StateVar` from `./types`.
|
||||
- Produces: `writeLocalState(name, value, sv?)` — when `sv` is an array declaration, `applySizing` is enforced; `initLocalState` instantiates array locals from `parseInitialArray`; metadata carries `elem`/`sizing`/`capacity`.
|
||||
- Produces: `export function declaredVar(name): StateVar | undefined` — lookup used by `logic.ts` node handlers to know a local's sizing policy.
|
||||
|
||||
- [ ] **Step 1: Track declarations + array init**
|
||||
|
||||
Add a `decls = new Map<string, StateVar>()` populated in `initLocalState`; extend `coerce` for `type==='array'`:
|
||||
|
||||
```ts
|
||||
import { parseInitialArray, applySizing, type ArrVal } from './arraypolicy';
|
||||
|
||||
const decls = new Map<string, StateVar>();
|
||||
export function declaredVar(name: string): StateVar | undefined { return decls.get(name); }
|
||||
|
||||
function coerce(v: StateVar): any {
|
||||
switch (v.type) {
|
||||
case 'bool': return v.initial === 'true' || v.initial === '1';
|
||||
case 'string': return v.initial;
|
||||
case 'array': return parseInitialArray(v);
|
||||
default: { const n = parseFloat(v.initial); return isNaN(n) ? 0 : n; }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
In `initLocalState`, `decls.set(v.name, v)` before publishing, and extend the metadata object with `elem: v.elem, sizing: v.sizing, capacity: v.capacity` (add these optional fields to `SignalMeta` in `types.ts`).
|
||||
|
||||
- [ ] **Step 2: Enforce sizing on write**
|
||||
|
||||
```ts
|
||||
export function writeLocalState(name: string, value: any): void {
|
||||
const sv = decls.get(name);
|
||||
let v = value;
|
||||
if (sv?.type === 'array' && Array.isArray(value)) v = applySizing(value as ArrVal[], sv);
|
||||
valueW(name).set({ value: v, quality: 'good', ts: new Date().toISOString() });
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build**
|
||||
|
||||
Run: `make frontend`
|
||||
Expected: builds clean.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add web/src/lib/localstate.ts web/src/lib/types.ts
|
||||
git commit -m "feat(logic): array local init + sizing enforcement in localstate"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Array action nodes + accumulate/export unification in `logic.ts`
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/lib/types.ts` (`LogicNodeKind` union — add `'action.array.push' | 'action.array.set' | 'action.array.remove' | 'action.array.pop' | 'action.array.clear'`)
|
||||
- Modify: `web/src/lib/logic.ts`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `evalValue`, `evalExpr` from `./expr`; `writeLocalState`, `getLocalValueStore`, `declaredVar` from `./localstate`; `applySizing` from `./arraypolicy`.
|
||||
- Produces: node handlers for the five `action.array.*` kinds; `accumulate`→push and `clear`→array.clear aliasing; export-by-index with custom header labels; `ensureArrayDecls(graph)` auto-declare migration called from `load()`.
|
||||
|
||||
- [ ] **Step 1: Read current array machinery**
|
||||
|
||||
Run: `grep -n "arrays\|accumulate\|action.export\|action.clear\|runNode\|case 'action" web/src/lib/logic.ts` to locate the dispatch switch and the legacy `arrays:Map` (around the lines noted in the design's code map: store ~225, accumulate ~614, export ~626, clear ~632, exportArrays ~733).
|
||||
|
||||
- [ ] **Step 2: Replace the `{t,v}` store with array-local reads/writes**
|
||||
|
||||
Array locals are the single source of truth. Implement a helper to read the current array value of a local:
|
||||
|
||||
```ts
|
||||
import { get } from './store';
|
||||
import { getLocalValueStore, writeLocalState, declaredVar } from './localstate';
|
||||
import { applySizing, type ArrVal } from './arraypolicy';
|
||||
|
||||
function curArray(name: string): ArrVal[] {
|
||||
const v = get(getLocalValueStore(name)).value;
|
||||
return Array.isArray(v) ? (v as ArrVal[]) : [];
|
||||
}
|
||||
```
|
||||
|
||||
(If `store.ts` lacks a synchronous `get`, read via a one-shot subscribe; confirm in Step 1.)
|
||||
|
||||
- [ ] **Step 3: Implement the five node handlers**
|
||||
|
||||
In the node dispatch switch:
|
||||
|
||||
```ts
|
||||
case 'action.array.push': {
|
||||
const arr = curArray(p.array);
|
||||
writeLocalState(p.array, [...arr, evalValue(p.expr, ctx.resolve)]);
|
||||
break;
|
||||
}
|
||||
case 'action.array.set': {
|
||||
const arr = curArray(p.array).slice();
|
||||
const path = String(p.index ?? '').split(',').map(s => Math.trunc(evalExpr(s, ctx.resolve)));
|
||||
setPath(arr, path, evalValue(p.expr, ctx.resolve)); // setPath: nested index assignment, defined below
|
||||
writeLocalState(p.array, arr);
|
||||
break;
|
||||
}
|
||||
case 'action.array.remove': {
|
||||
const arr = curArray(p.array).slice();
|
||||
const i = Math.trunc(evalExpr(p.index, ctx.resolve));
|
||||
const k = i < 0 ? arr.length + i : i;
|
||||
if (k >= 0 && k < arr.length) arr.splice(k, 1);
|
||||
writeLocalState(p.array, arr);
|
||||
break;
|
||||
}
|
||||
case 'action.array.pop': {
|
||||
const arr = curArray(p.array).slice(); arr.pop();
|
||||
writeLocalState(p.array, arr);
|
||||
break;
|
||||
}
|
||||
case 'action.array.clear': {
|
||||
const sv = declaredVar(p.array);
|
||||
writeLocalState(p.array, sv ? applySizing([], sv) : []); // fixed → zero-refill via applySizing
|
||||
break;
|
||||
}
|
||||
```
|
||||
|
||||
Add `setPath`:
|
||||
|
||||
```ts
|
||||
function setPath(arr: ArrVal[], path: number[], v: ArrVal): void {
|
||||
let cur: ArrVal[] = arr;
|
||||
for (let d = 0; d < path.length - 1; d++) {
|
||||
let k = path[d]; if (k < 0) k = cur.length + k;
|
||||
if (!Array.isArray(cur[k])) cur[k] = [];
|
||||
cur = cur[k] as ArrVal[];
|
||||
}
|
||||
let last = path[path.length - 1]; if (last < 0) last = cur.length + last;
|
||||
cur[last] = v;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Alias accumulate/clear and rewrite export**
|
||||
|
||||
In the dispatch switch, make the legacy kinds delegate:
|
||||
|
||||
```ts
|
||||
case 'action.accumulate': // legacy alias → push
|
||||
{ const arr = curArray(p.array); writeLocalState(p.array, [...arr, evalValue(p.expr, ctx.resolve)]); }
|
||||
break;
|
||||
case 'action.clear': // legacy alias → array.clear
|
||||
{ const sv = declaredVar(p.array); writeLocalState(p.array, sv ? applySizing([], sv) : []); }
|
||||
break;
|
||||
```
|
||||
|
||||
Rewrite `action.export` to read array locals by column and emit index-aligned CSV with custom headers. Parse `p.columns` as `[{array, label}]`; header row uses `label || array`; row `r` joins `col[r] ?? ''`:
|
||||
|
||||
```ts
|
||||
case 'action.export': {
|
||||
const cols = JSON.parse(p.columns || '[]') as { array: string; label?: string }[];
|
||||
const data = cols.map(c => curArray(c.array));
|
||||
const rows = Math.max(0, ...data.map(d => d.length));
|
||||
const header = cols.map(c => csvCell(c.label || c.array)).join(',');
|
||||
const lines = [header];
|
||||
for (let r = 0; r < rows; r++) {
|
||||
lines.push(data.map(d => (r < d.length ? csvCell(String(d[r])) : '')).join(','));
|
||||
}
|
||||
downloadCsv(lines.join('\n'), p.filename || 'export.csv'); // reuse existing blob-download helper
|
||||
break;
|
||||
}
|
||||
```
|
||||
|
||||
(Keep/rename the existing CSV-escape and blob-download helpers as `csvCell`/`downloadCsv`; drop `exportArrays`/`interpAt` and the `align` param handling.)
|
||||
|
||||
- [ ] **Step 5: Auto-declare migration in `load()`**
|
||||
|
||||
After the graph is loaded but before subscriptions, ensure any array referenced by an array node / accumulate / export but **not** declared gets a dynamic numeric array local:
|
||||
|
||||
```ts
|
||||
function ensureArrayDecls(graph: LogicGraph, vars: StateVar[]): StateVar[] {
|
||||
const have = new Set(vars.map(v => v.name));
|
||||
const out = vars.slice();
|
||||
const need = (name: string) => {
|
||||
if (name && !have.has(name)) { have.add(name); out.push({ name, type: 'array', elem: 'number', sizing: 'dynamic', initial: '' }); }
|
||||
};
|
||||
for (const n of graph.nodes) {
|
||||
if (n.kind === 'action.accumulate' || n.kind === 'action.clear' || n.kind.startsWith('action.array.')) need(n.params.array);
|
||||
if (n.kind === 'action.export') { try { (JSON.parse(n.params.columns || '[]') as any[]).forEach(c => need(c.array)); } catch {} }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
```
|
||||
|
||||
Call this so the resulting list is passed to `initLocalState` (wherever `load()` currently calls it; if `load()` doesn't own statevars, thread the merged list through the same path the panel uses to init local state).
|
||||
|
||||
- [ ] **Step 6: Build**
|
||||
|
||||
Run: `make frontend`
|
||||
Expected: builds clean.
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add web/src/lib/logic.ts web/src/lib/types.ts
|
||||
git commit -m "feat(logic): array action nodes + accumulate/export unification + migration"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Persist array `<statevar>` attributes (`xml.ts`)
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/lib/xml.ts` (statevar read ~lines 67-77 and the corresponding write path)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `<statevar>` round-trips `elem`, `sizing`, `capacity` in addition to existing attrs, only emitting them when present.
|
||||
|
||||
- [ ] **Step 1: Write path — emit array attrs**
|
||||
|
||||
Where statevars are serialized, append the optional attrs:
|
||||
|
||||
```ts
|
||||
function statevarXml(v: StateVar): string {
|
||||
const a = [`name="${esc(v.name)}"`, `type="${v.type ?? 'number'}"`, `initial="${esc(v.initial)}"`];
|
||||
if (v.unit) a.push(`unit="${esc(v.unit)}"`);
|
||||
if (v.low !== undefined) a.push(`low="${v.low}"`);
|
||||
if (v.high !== undefined) a.push(`high="${v.high}"`);
|
||||
if (v.type === 'array') {
|
||||
if (v.elem) a.push(`elem="${v.elem}"`);
|
||||
if (v.sizing) a.push(`sizing="${v.sizing}"`);
|
||||
if (v.capacity !== undefined) a.push(`capacity="${v.capacity}"`);
|
||||
}
|
||||
return `<statevar ${a.join(' ')}/>`;
|
||||
}
|
||||
```
|
||||
|
||||
(Adapt to the file's existing serialization style — match how `unit`/`low`/`high` are currently emitted.)
|
||||
|
||||
- [ ] **Step 2: Read path — parse array attrs**
|
||||
|
||||
Where `<statevar>` is parsed into a `StateVar`, add:
|
||||
|
||||
```ts
|
||||
elem: el.getAttribute('elem') as any || undefined,
|
||||
sizing: el.getAttribute('sizing') as any || undefined,
|
||||
capacity: el.hasAttribute('capacity') ? Number(el.getAttribute('capacity')) : undefined,
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build + round-trip sanity (manual)**
|
||||
|
||||
Run: `make frontend`. Then in Task 9's smoke test confirm an array statevar survives save→reload.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add web/src/lib/xml.ts
|
||||
git commit -m "feat(logic): round-trip array statevar attributes in panel XML"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Editor — array declaration form + array nodes
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/LogicEditor.tsx` (the `LocalVars` subcomponent + the palette node list + the inspector)
|
||||
- Modify: `web/src/lib/flowDebug.ts` (compact array badge formatting)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `StateVar` shape (Task 1), the new node kinds (Task 5).
|
||||
- Produces: UI to declare array locals and place/inspect `action.array.*` nodes; debug badges that stringify arrays compactly.
|
||||
|
||||
- [ ] **Step 1: `LocalVars` array form**
|
||||
|
||||
In the `LocalVars` subcomponent, when the type select value is `array`, reveal: an `elem` select (number/bool/array), a `sizing` select (dynamic/capped/fixed), a `capacity` number input (shown for capped/fixed), and the existing `initial` text field repurposed as a JSON literal with inline `JSON.parse` validation (red hint on parse error). Persist via the existing `onStateVarsChange` path.
|
||||
|
||||
- [ ] **Step 2: Palette + inspector for array nodes**
|
||||
|
||||
Add the five `action.array.*` kinds to the Actions palette group (label/icon consistent with existing entries). In the inspector `switch`, add cases rendering: `array` (a select of declared array-local names), and `expr`/`index` fields using the existing `ExprField` (which already runs `checkExpr`). `action.array.set` shows both `index` (path, comma-separated) and `expr`.
|
||||
|
||||
- [ ] **Step 3: Compact array badges**
|
||||
|
||||
In `flowDebug.ts`, where a node value is stringified for the badge, format arrays as e.g. `[1, 2, 3, …](n=N)` truncated to the first ~3 elements:
|
||||
|
||||
```ts
|
||||
export function fmtBadge(v: unknown): string {
|
||||
if (Array.isArray(v)) {
|
||||
const head = v.slice(0, 3).map(x => Array.isArray(x) ? '[…]' : String(x)).join(', ');
|
||||
return `[${head}${v.length > 3 ? ', …' : ''}](n=${v.length})`;
|
||||
}
|
||||
return typeof v === 'number' ? String(+v.toFixed(4)) : String(v);
|
||||
}
|
||||
```
|
||||
|
||||
Wire `fmtBadge` into the existing badge render site (replace the inline number formatting).
|
||||
|
||||
- [ ] **Step 4: Build**
|
||||
|
||||
Run: `make frontend`
|
||||
Expected: builds clean.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add web/src/LogicEditor.tsx web/src/lib/flowDebug.ts
|
||||
git commit -m "feat(logic): array local declaration form + array nodes + array debug badges"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Widgets read array locals
|
||||
|
||||
**Files (verify exact paths first):**
|
||||
- Run: `ls web/src/widgets` and `grep -rln "bitset\|MultiLed\|multi-led\|TableWidget\|PlotWidget" web/src/widgets`
|
||||
- Modify: the plot widget, table widget, multi-LED/bitset widget.
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: a bound local whose `SignalValue.value` may be `ArrVal[]`; metadata `elem`/`sizing`/`capacity` from `getLocalMetaStore`.
|
||||
|
||||
- [ ] **Step 1: Plot — accept a 1-D numeric array local as a waveform**
|
||||
|
||||
In the plot widget's value-ingest path, when a bound signal's value is a numeric array, feed it through the **same** code path already used for EPICS `float64[]` waveform samples (multidimensional/FFT/waterfall). Nested arrays → render the existing "unsupported" placeholder. (Locate the waveform branch via `grep -n "Array.isArray\|waveform\|float64" web/src/widgets/PlotWidget.tsx`.)
|
||||
|
||||
- [ ] **Step 2: Table — array source mode**
|
||||
|
||||
Add an `array` source mode (config flag) that, when the bound value is an array, renders one row per element (index + value with the per-signal value format). For `elem:'array'` (2-D), rows = outer index, configured columns map to inner positions. Scalars → existing multi-signal behavior.
|
||||
|
||||
- [ ] **Step 3: Multi-LED — array source mode**
|
||||
|
||||
Add an `array` source mode: render one LED per element, lit by element truthiness; LED count tracks array length live; when meta `elem==='bool'`, use the declared on/off labels.
|
||||
|
||||
- [ ] **Step 4: Build**
|
||||
|
||||
Run: `make frontend`
|
||||
Expected: builds clean.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add web/src/widgets
|
||||
git commit -m "feat(widgets): array source modes for plot, table, multi-LED"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Manual smoke verification + docs
|
||||
|
||||
**Files:**
|
||||
- Modify: `TODO.md` (do NOT check the box yet — Phase 2 Go port still pending; add a sub-note that panel-logic arrays are done)
|
||||
- Modify: `docs/TECHNICAL_SPEC.md` (document array statevars + array expression functions + the export change)
|
||||
|
||||
- [ ] **Step 1: Build the whole app**
|
||||
|
||||
Run: `make all`
|
||||
Expected: frontend + backend build clean.
|
||||
|
||||
- [ ] **Step 2: Manual smoke checklist** (run `go run ./cmd/uopi`, open a panel in edit mode → Logic tab)
|
||||
|
||||
- Declare a `capped` array `hist` capacity 5; add a `trigger.timer` → `action.array.push{array:hist, expr:{ds:stub:sine_1hz}}`; in view mode confirm `hist` rings at 5 elements (debug badge shows `[…](n=5)`).
|
||||
- Add `action.write{target: avg, expr: mean(hist)}` (scalar local `avg`); confirm it tracks.
|
||||
- Indexing: `action.write{target: last, expr: hist[-1]}` updates to the newest sample.
|
||||
- `fixed` array `grid` capacity 4 initial `[0,0,0,0]`; `action.array.set{array:grid, index:"2", expr:42}`; confirm element 2 = 42 and length stays 4.
|
||||
- Save the panel, reload it, confirm the array statevars + nodes persist (Task 6).
|
||||
- Legacy: open/confirm an existing panel using `action.accumulate`/`action.export` still records and exports CSV (now index-aligned; header uses custom labels).
|
||||
- Widgets: bind `hist` to a plot (waveform), a table (one row per element), and a multi-LED (one LED per element) and confirm they render and update live.
|
||||
|
||||
- [ ] **Step 3: Commit docs**
|
||||
|
||||
```bash
|
||||
git add TODO.md docs/TECHNICAL_SPEC.md
|
||||
git commit -m "docs(logic): document panel-logic array locals"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review Notes (spec coverage)
|
||||
|
||||
- Data model (spec §2) → Task 1 + Task 2 (`arraypolicy`).
|
||||
- Expression engine (spec §3) → Task 3 (all functions, indexing, literals, negative index, errors, ref-collection).
|
||||
- Mutation nodes + accumulate/export unification + migration (spec §4) → Task 5.
|
||||
- Persistence panel XML (spec §5) → Task 6. (Control-logic Go persistence = Phase 2.)
|
||||
- Editor UI + debug badges (spec §6, panel side) → Task 7. (Control editor = Phase 2.)
|
||||
- Widgets (spec §7) → Task 8.
|
||||
- Testing (spec §8): TS has no runner → manual smoke (Task 9); the authoritative automated suite + cross-engine parity table is **Phase 2 (Go)**.
|
||||
|
||||
**Deferred to Phase 2 (separate plan):** all of `internal/controllogic` (boxed `value` in `expr.go`, `Graph.StateVars`, `getLocal`/`setLocal` policy, JSON round-trip, config-apply/snapshot boxing), `ControlLogicEditor.tsx` LocalVars parity, the Go debug-event array payload, and the full Go test + parity suite.
|
||||
@@ -0,0 +1,266 @@
|
||||
# Design: Local array values for the node-editor flow engines
|
||||
|
||||
**Date:** 2026-06-24
|
||||
**Status:** Approved (design phase)
|
||||
**TODO refs:** "Logic editor → add full support to local array values: dynamic,
|
||||
dynamic but capped max, fixed size etc; array functions should work with new
|
||||
local array" and "Control loop → add full support to server side array values".
|
||||
|
||||
## 1. Goal & scope
|
||||
|
||||
Add first-class **array-valued local variables** to both flow engines:
|
||||
|
||||
- **Panel logic** (client TS): `web/src/lib/logic.ts`, `LogicEditor.tsx`,
|
||||
`web/src/lib/types.ts`, `web/src/lib/expr.ts`, `web/src/lib/localstate.ts`,
|
||||
`web/src/lib/xml.ts`.
|
||||
- **Control logic** (server Go): `internal/controllogic/` (`model.go`,
|
||||
`engine.go`, `expr.go`), editor `web/src/ControlLogicEditor.tsx`.
|
||||
|
||||
**Build order:** design both together; implement **panel logic (TS) first**,
|
||||
then port the same design to control logic (Go). The panel `StateVar` schema is
|
||||
the richer reference and doing TS first de-risks the expression-language change
|
||||
before the Go port.
|
||||
|
||||
**In scope for v1:** declaration + sizing policies, an array-aware expression
|
||||
language (shared by both engines), array mutation nodes, persistence, **and
|
||||
widget binding** (multi-LED/bitset, table, plot read array locals).
|
||||
|
||||
## 2. Data model — array local declaration
|
||||
|
||||
A new array kind on the existing `StateVar` (TS `types.ts`; mirrored as a Go
|
||||
struct in control logic, which gains state-var declarations for the first time):
|
||||
|
||||
```
|
||||
StateVar {
|
||||
name: string
|
||||
type: 'number' | 'bool' | 'string' | 'array' // 'array' is new
|
||||
initial: string // arrays: JSON literal e.g. "[0,0,0]" / "[[1,2],[3,4]]", or "" = empty/zero-fill
|
||||
unit?, low?, high? // existing scalar fields
|
||||
|
||||
// present only when type === 'array':
|
||||
elem?: 'number' | 'bool' | 'array' // element type; 'array' ⇒ nested (recursive, arbitrary depth, jagged allowed)
|
||||
sizing?: 'dynamic' | 'capped' | 'fixed' // default 'dynamic'
|
||||
capacity?: number // required for capped/fixed
|
||||
}
|
||||
```
|
||||
|
||||
Semantics:
|
||||
|
||||
- **dynamic** — unbounded, guarded by a global safety cap (1e6 elements) to
|
||||
prevent runaway growth.
|
||||
- **capped** — `capacity` max; pushing past full **drops the oldest** element
|
||||
(ring / FIFO).
|
||||
- **fixed** — exactly `capacity` slots. Initialised from the `initial` literal
|
||||
(truncated / zero-padded to `capacity`), else **zero-filled**. `push` is a
|
||||
no-op (write via index/set); `clear` zero-refills rather than emptying.
|
||||
- **initial** — non-empty JSON literal is parsed and used; empty ⇒ dynamic and
|
||||
capped start `[]`, fixed starts zero-filled.
|
||||
- `elem: 'bool'` is **display metadata only**. Runtime leaves are numeric
|
||||
`1`/`0` (consistent with the engine's existing "booleans are 1/0" rule);
|
||||
widgets use the declaration to render on/off.
|
||||
|
||||
**Declaration-time validation:** capped/fixed require `capacity >= 1`;
|
||||
`initial`, if non-empty, must parse as JSON and match the declared element
|
||||
type / nesting. Errors surface inline in the editor.
|
||||
|
||||
## 3. Expression engine (`expr.ts` + `expr.go`)
|
||||
|
||||
**Value model (tagged union — "Approach 1"):**
|
||||
|
||||
- TS: `type EvalValue = number | EvalValue[]`. The evaluator returns
|
||||
`EvalValue`; functions/indexing type-check at runtime and throw on misuse
|
||||
(caught → node error badge).
|
||||
- Go: a boxed `value{ num float64; arr []value; isArr bool }`. `Resolver`
|
||||
returns `value`; every `*Node.eval` returns `value` (a contained, mechanical
|
||||
refactor of `expr.go`, which today returns `float64`). The `float64` leaf
|
||||
stays the fast path.
|
||||
|
||||
**New syntax (both parsers):**
|
||||
|
||||
- **Array literal:** `[a, b, c]`, nested `[[1,2],[3,4]]`.
|
||||
- **Indexing:** postfix `expr[expr]`, chainable `a[i][j]`. Index rounds to int;
|
||||
**negative index counts from the end** (`a[-1]` = last); out-of-range → node
|
||||
error.
|
||||
|
||||
**Functions** — added to the existing `abs/min/max/sqrt/...` table. All the
|
||||
read/transform functions are **pure** (return new values; never mutate a local):
|
||||
|
||||
| Function | Result | Meaning / notes |
|
||||
|---|---|---|
|
||||
| `len(a)` | number | element count (top level) |
|
||||
| `sum(a)`, `mean(a)`, `min(a)`, `max(a)` | number | over a 1-D numeric array; error if elements are arrays |
|
||||
| `slice(a, s, e)` | array | subrange, `e` exclusive, negative indices allowed |
|
||||
| `concat(a, b)` | array | join |
|
||||
| `reverse(a)` | array | |
|
||||
| `sort(a)` | array | ascending numeric |
|
||||
| `scale(a, k)` | array | element-wise `a[i]*k` |
|
||||
| `add(a, b)`, `sub(a, b)` | array | element-wise pairwise; length = min(len a, len b) |
|
||||
| `push(a, v)` | array | copy with `v` appended |
|
||||
| `set(a, i, v)` | array | copy with element `i` replaced (negative `i` ok) |
|
||||
| `insert(a, i, v)` | array | copy with `v` inserted at `i` |
|
||||
| `remove(a, i)` | array | copy without element `i` |
|
||||
| `pop(a)` | array | copy without the last element (read it with `a[-1]`) |
|
||||
| `shift(a)` | array | copy without the first element (read with `a[0]`) |
|
||||
| `indexOf(a, v)` | number | first index of `v`, else `-1` |
|
||||
| `contains(a, v)` | number | `1`/`0` |
|
||||
| `fill(n, v)` | array | new length-`n` array of `v` |
|
||||
|
||||
- `min`/`max` keep their existing **scalar variadic** form (`min(x,y,z)`) and
|
||||
gain a 1-arg **array** form (`min(a)`) — dispatch on arg count + type.
|
||||
- **Resolution:** a bare identifier naming an array local returns the whole
|
||||
array value; `{ds:sig}` waveform signals (EPICS `float64[]`) become first-class
|
||||
array values usable by every function above.
|
||||
|
||||
**Purity & persistence interplay:** the mutator-named functions
|
||||
(`push/set/insert/remove/pop/shift`) are **immutable transforms** — they return a
|
||||
new array and do not touch the local. You persist a result by writing it back;
|
||||
the **sizing policy is enforced at store time** in `writeLocalState` (TS) /
|
||||
`setLocal` (Go) whenever the write target is a typed array local (ring-drop for
|
||||
capped, clamp / no-op for fixed). The mutation nodes (§4) are convenient, visible
|
||||
sugar for "store with policy".
|
||||
|
||||
**Errors** (e.g. `sum` of nested array, indexing a scalar, `add` of non-arrays)
|
||||
throw in the evaluator and surface as the node's error reason via the existing
|
||||
`checkExpr` validation + runtime-catch / badge path.
|
||||
|
||||
**Ref-collection** (`collectRefs` / `CollectRefs`) walks the new literal/index
|
||||
AST so subscriptions still discover every `{ds:sig}` inside array expressions.
|
||||
|
||||
## 4. Mutation nodes + accumulate/export unification
|
||||
|
||||
**New action nodes** (panel `LogicNodeKind`, mirrored in Go control-logic kinds):
|
||||
|
||||
- `action.array.push{array, expr}` — append `eval(expr)`; sizing-policy aware
|
||||
(ring-drop if capped; no-op if fixed).
|
||||
- `action.array.set{array, index, expr}` — store at `eval(index)`. Supports
|
||||
**nested targets via an index path**: `index = "i, j"` ⇒ `a[i][j]`. Negative
|
||||
indices allowed; out-of-range → node error. (This is the imperative
|
||||
path-assignment style.)
|
||||
- `action.array.remove{array, index}` — remove element at index (in place).
|
||||
- `action.array.pop{array}` — remove last element (in place).
|
||||
- `action.array.clear{array}` — empty (dynamic/capped) or zero-refill (fixed).
|
||||
|
||||
These are the sizing-policy-aware, in-place counterparts to the pure expression
|
||||
functions.
|
||||
|
||||
**Unification of the existing `{t,v}` array system (decision: unify):**
|
||||
|
||||
- `action.accumulate{array, expr}` → reframed as `action.array.push` (append,
|
||||
policy-enforced). Old kind **kept as a compile alias** so saved panels run.
|
||||
- `action.clear{array}` → `action.array.clear` (alias retained).
|
||||
- `action.export{columns, align, filename}` → serializes **array locals** by
|
||||
column. Array locals are plain numeric (no per-sample `t`), so **time-based
|
||||
alignment (`common`/`any`/`interpolate`) is dropped**; columns are emitted
|
||||
**side-by-side by index** (ragged columns padded blank). The `align` param is
|
||||
ignored and hidden in the inspector. To keep a timestamp column, push
|
||||
`{sys:time}` into a parallel array local and add it as a column.
|
||||
- **Custom column names:** each export column keeps its `label`, surfaced as an
|
||||
**editable header name** in the inspector (default = array-local name); the CSV
|
||||
header row uses the chosen names.
|
||||
|
||||
**Migration (non-destructive, at engine `load()`):** any
|
||||
`accumulate`/`clear`/`export` node referencing an array name with **no** matching
|
||||
`StateVar` declaration triggers an **auto-declared dynamic numeric array local**
|
||||
of that name. No file rewrite.
|
||||
|
||||
## 5. Persistence
|
||||
|
||||
**Panel logic (XML, `xml.ts`):** `<statevar>` gains optional array attributes,
|
||||
written only for arrays:
|
||||
|
||||
```xml
|
||||
<statevar name="hist" type="array" elem="number" sizing="capped" capacity="100" initial=""/>
|
||||
<statevar name="grid" type="array" elem="array" sizing="fixed" capacity="4" initial="[[0,0],[0,0]]"/>
|
||||
```
|
||||
|
||||
Round-trips through the existing verbatim-body store (no Go change for panels).
|
||||
New nodes serialize via the existing `<node><param/></node>` mechanism.
|
||||
|
||||
**Control logic (Go, `model.go`):** control logic has **no** state-var
|
||||
declarations today (`locals` is an untyped `map[string]float64`). Additions:
|
||||
|
||||
- `Graph` gains `StateVars []StateVar` (Go struct mirroring the TS shape:
|
||||
`Name, Type, Elem, Sizing string; Capacity int; Initial, Unit string; Low,
|
||||
High float64`), serialized in `controllogic.json`.
|
||||
- `compiledGraph.locals` changes from `map[string]float64` to
|
||||
`map[string]value`, initialised from `StateVars` (applying sizing/initial) at
|
||||
compile time.
|
||||
- `getLocal`/`setLocal` operate on `value`; `setLocal` enforces sizing policy.
|
||||
Config-apply / snapshot paths that read/write locals as `float64` box/unbox.
|
||||
|
||||
**Versioning:** control-logic graphs are already git-style versioned; the new
|
||||
`StateVars` field rides along in each revision (no diff-engine change — just more
|
||||
JSON).
|
||||
|
||||
## 6. Editor UI + debug/live badges
|
||||
|
||||
**Panel `LogicEditor.tsx`:** the `LocalVars` palette subcomponent gains an array
|
||||
declaration form (type=array reveals element-type / sizing / capacity / `initial`
|
||||
JSON with inline validation). New array action nodes added to the Actions palette
|
||||
group with inspectors (array name + expr/index fields, reusing `ExprField` with
|
||||
array-aware `checkExpr`).
|
||||
|
||||
**Control `ControlLogicEditor.tsx`:** control logic has **no** local-var
|
||||
declaration UI today. Add a `LocalVars` panel mirroring the panel editor (same
|
||||
component, driven by `Graph.StateVars`) plus the array nodes in its palette. This
|
||||
brings control-logic locals to parity — scalars *and* arrays become declarable
|
||||
there for the first time.
|
||||
|
||||
**Debug/live badges (`flowDebug.ts` + Go `DebugObserver` / synthetic trace):**
|
||||
array node values render as a truncated literal, e.g. `[1, 2, 3, …](n=100)`. The
|
||||
Go debug event payload (`debugNode`) and the synthetic trace already serialize a
|
||||
value — extended to carry array JSON; the badge formatter stringifies arrays
|
||||
compactly.
|
||||
|
||||
## 7. Widgets reading array locals
|
||||
|
||||
The `ds:'local'` plumbing already routes through `stores.ts`/`ws.ts`; the change
|
||||
is that a local's `SignalValue.value` can be an array (number / nested), held and
|
||||
initialised per panel instance by `localstate.ts`. No new widget types — new
|
||||
source modes on three existing widgets:
|
||||
|
||||
- **Plot** — a 1-D numeric array local binds as a **waveform sample** (same path
|
||||
EPICS `float64[]` waveforms already use for multidimensional/FFT/waterfall).
|
||||
Each engine tick that rewrites the array updates the trace. Nested arrays show
|
||||
"unsupported shape".
|
||||
- **Table widget** — gains an **array source mode**: bound to one array local,
|
||||
renders **one row per element** (index + value, per-signal value-format applied).
|
||||
For an `elem:'array'` (2-D) local, rows are indices and the configured columns
|
||||
map to inner-array positions. Falls back to multi-signal mode for scalars.
|
||||
- **Multi-LED / bitset** — gains an **array source mode**: one LED per element,
|
||||
lit per element truthiness; when `elem:'bool'`, on/off labels come from the
|
||||
declaration. Existing integer-bitset mode unchanged.
|
||||
|
||||
`getLocalMetaStore` carries `elem`/`sizing`/`capacity` so widgets self-configure
|
||||
(e.g. multi-LED LED count = array length, growing/shrinking live for dynamic
|
||||
arrays).
|
||||
|
||||
## 8. Testing
|
||||
|
||||
- **TS unit (expr):** literals; indexing (negative, nested, out-of-range error);
|
||||
every new function incl. type-error cases; sizing-policy enforcement on store
|
||||
(ring drop-oldest, fixed no-op / zero-refill); accumulate→push migration; CSV
|
||||
export by-index with custom headers.
|
||||
- **Go unit (`internal/controllogic`):** port of the expr suite (boxed `value`,
|
||||
all functions/indexing/errors); `StateVars` init from declarations; `setLocal`
|
||||
policy enforcement; JSON round-trip of `StateVars`; config-apply/snapshot with
|
||||
boxed locals.
|
||||
- **Cross-engine parity:** a `(expr, expected)` table asserted identical in both
|
||||
`expr.ts` and `expr.go` to keep them in lockstep.
|
||||
- **Widgets:** light component tests for the new array source modes if widget
|
||||
tests exist; else manual verification.
|
||||
- **Gates:** `gofmt`, `go vet`, `go test ./... -race`, frontend typecheck/build.
|
||||
|
||||
## 9. Risks
|
||||
|
||||
- **Go `expr.go` refactor (`float64` → boxed `value`):** touches every node's
|
||||
`eval` and the `Resolver`. Mechanical but broad; the parity test guards
|
||||
behavioral drift from `expr.ts`.
|
||||
- **Backward compatibility of `accumulate`/`export`:** aliasing + auto-declared
|
||||
locals keep old panels running, but the **dropped time-alignment in export** is
|
||||
a behavioral change for any panel relying on `interpolate`/`common` align. Call
|
||||
this out in release notes.
|
||||
- **Two engines staying in lockstep:** the function set and semantics must match
|
||||
exactly across TS and Go; the cross-engine parity fixture is the safeguard.
|
||||
- **Hot-path purity:** array expression functions are evaluated repeatedly; they
|
||||
must remain allocation-light and side-effect-free (mutation only at store time).
|
||||
@@ -3,6 +3,7 @@ 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
|
||||
@@ -10,13 +11,20 @@ require (
|
||||
)
|
||||
|
||||
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.36.0 // 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
|
||||
|
||||
@@ -1,28 +1,97 @@
|
||||
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=
|
||||
|
||||
+602
-136
@@ -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,88 +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
|
||||
auditReaders map[string]bool // users + group names allowed to view the audit log
|
||||
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, auditReaders []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),
|
||||
auditReaders: make(map[string]bool),
|
||||
groups: make(map[string]*group),
|
||||
}
|
||||
for _, e := range logicEditors {
|
||||
e = strings.TrimSpace(e)
|
||||
if e != "" {
|
||||
p.logicEditors[e] = true
|
||||
}
|
||||
}
|
||||
for _, e := range auditReaders {
|
||||
e = strings.TrimSpace(e)
|
||||
if e != "" {
|
||||
p.auditReaders[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
|
||||
}
|
||||
p.userGroups[m] = append(p.userGroups[m], g)
|
||||
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
|
||||
}
|
||||
}
|
||||
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 {
|
||||
@@ -130,81 +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
|
||||
}
|
||||
|
||||
// CanViewAudit reports whether a user may view the audit log. When no reader
|
||||
// allowlist is configured everyone with read access qualifies; otherwise the
|
||||
// user (or one of their groups) must be listed. Anonymous/trusted-LAN callers
|
||||
// (user=="") are always permitted.
|
||||
// CanViewAudit reports whether a user may view the audit log (auditor or admin).
|
||||
func (p *Policy) CanViewAudit(user string) bool {
|
||||
user = strings.TrimSpace(user)
|
||||
if user == "" {
|
||||
return true
|
||||
}
|
||||
if len(p.auditReaders) == 0 {
|
||||
return true
|
||||
}
|
||||
if p.auditReaders[user] {
|
||||
return true
|
||||
}
|
||||
for _, g := range p.userGroups[user] {
|
||||
if p.auditReaders[g] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
p.mu.RLock()
|
||||
defer p.mu.RUnlock()
|
||||
return p.effectiveRoleLocked(user) >= RoleAuditor
|
||||
}
|
||||
|
||||
// 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)
|
||||
// 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, 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"}, nil)
|
||||
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")
|
||||
}
|
||||
}
|
||||
+495
-18
@@ -2,15 +2,19 @@
|
||||
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"
|
||||
@@ -22,6 +26,7 @@ import (
|
||||
"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"
|
||||
)
|
||||
@@ -39,12 +44,13 @@ type Handler struct {
|
||||
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.
|
||||
// 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, log *slog.Logger) *Handler {
|
||||
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()
|
||||
}
|
||||
@@ -60,6 +66,7 @@ func New(b *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, cfg
|
||||
audit: rec,
|
||||
channelFinderURL: channelFinderURL,
|
||||
archiverURL: archiverURL,
|
||||
uiDefaultZoom: uiDefaultZoom,
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
@@ -96,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)
|
||||
@@ -105,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)
|
||||
@@ -112,6 +134,11 @@ 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.
|
||||
@@ -124,6 +151,7 @@ func (h *Handler) Register(mux *http.ServeMux, prefix string) {
|
||||
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)
|
||||
@@ -135,7 +163,23 @@ func (h *Handler) Register(mux *http.ServeMux, prefix string) {
|
||||
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 ─────────────────────────────────────────────────────────────────────
|
||||
@@ -150,12 +194,18 @@ 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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -257,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)))
|
||||
}
|
||||
@@ -321,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 {
|
||||
@@ -353,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
|
||||
@@ -386,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
|
||||
@@ -552,6 +609,29 @@ type interfaceListItem struct {
|
||||
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) {
|
||||
@@ -568,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))
|
||||
@@ -1087,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
|
||||
@@ -1194,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)
|
||||
}
|
||||
@@ -1236,6 +1628,7 @@ 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
|
||||
@@ -1256,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
|
||||
}
|
||||
@@ -1266,6 +1660,7 @@ 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
|
||||
@@ -1294,6 +1689,88 @@ func (h *Handler) deleteControlLogic(w http.ResponseWriter, r *http.Request) {
|
||||
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
|
||||
|
||||
+149
-2
@@ -52,15 +52,16 @@ func setup(t *testing.T) (*httptest.Server, func()) {
|
||||
if err != nil {
|
||||
t.Fatal("controllogic.NewStore:", err)
|
||||
}
|
||||
clEngine := controllogic.NewEngine(ctx, brk, clStore, audit.Nop(), 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, cfgStore, access.New("", nil, nil, nil, nil), acl, clStore, clEngine, audit.Nop(), "", "", 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() {
|
||||
@@ -451,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
|
||||
|
||||
+378
-4
@@ -6,8 +6,10 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/uopi/uopi/internal/broker"
|
||||
"github.com/uopi/uopi/internal/confmgr"
|
||||
)
|
||||
|
||||
@@ -35,7 +37,7 @@ func configStatus(err error) int {
|
||||
|
||||
// ── config sets ─────────────────────────────────────────────────────────────
|
||||
|
||||
func (h *Handler) listConfigSets(w http.ResponseWriter, _ *http.Request) {
|
||||
func (h *Handler) listConfigSets(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.configEnabled(w) {
|
||||
return
|
||||
}
|
||||
@@ -44,7 +46,7 @@ func (h *Handler) listConfigSets(w http.ResponseWriter, _ *http.Request) {
|
||||
jsonError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
jsonOK(w, sets)
|
||||
jsonOK(w, h.filterConfigMetas(r, sets))
|
||||
}
|
||||
|
||||
func (h *Handler) getConfigSet(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -90,6 +92,9 @@ func (h *Handler) updateConfigSet(w http.ResponseWriter, r *http.Request) {
|
||||
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())
|
||||
@@ -225,7 +230,7 @@ func (h *Handler) resolveSet(id, version string) (confmgr.ConfigSet, error) {
|
||||
|
||||
// ── config instances ────────────────────────────────────────────────────────
|
||||
|
||||
func (h *Handler) listConfigInstances(w http.ResponseWriter, _ *http.Request) {
|
||||
func (h *Handler) listConfigInstances(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.configEnabled(w) {
|
||||
return
|
||||
}
|
||||
@@ -234,7 +239,20 @@ func (h *Handler) listConfigInstances(w http.ResponseWriter, _ *http.Request) {
|
||||
jsonError(w, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
jsonOK(w, insts)
|
||||
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) {
|
||||
@@ -280,6 +298,9 @@ func (h *Handler) updateConfigInstance(w http.ResponseWriter, r *http.Request) {
|
||||
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())
|
||||
@@ -444,3 +465,356 @@ func (h *Handler) resolveInstance(id, version string) (confmgr.ConfigInstance, e
|
||||
}
|
||||
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,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,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")
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+189
-29
@@ -7,40 +7,54 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/BurntSushi/toml"
|
||||
|
||||
"github.com/uopi/uopi/internal/datasource/modbus"
|
||||
"github.com/uopi/uopi/internal/datasource/scpi"
|
||||
)
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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
|
||||
// Readers lists users or group names allowed to view the audit log. Empty
|
||||
// leaves viewing unrestricted (any caller with read access). Anonymous /
|
||||
// trusted-LAN callers are always permitted.
|
||||
Readers []string `toml:"readers"`
|
||||
}
|
||||
|
||||
// GroupDef is a named set of users defined as [[groups]] in the config file.
|
||||
// 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"`
|
||||
Members []string `toml:"members"`
|
||||
}
|
||||
|
||||
// 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"`
|
||||
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 {
|
||||
@@ -58,18 +72,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 {
|
||||
@@ -77,6 +197,8 @@ type DatasourceConfig struct {
|
||||
EPICS EPICSConfig `toml:"epics"`
|
||||
PVA PVAConfig `toml:"pva"`
|
||||
Synthetic SyntheticConfig `toml:"synthetic"`
|
||||
Modbus modbus.Config `toml:"modbus"`
|
||||
SCPI scpi.Config `toml:"scpi"`
|
||||
}
|
||||
|
||||
type StubConfig struct {
|
||||
@@ -151,8 +273,44 @@ 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")
|
||||
@@ -160,9 +318,6 @@ func applyEnv(cfg *Config) {
|
||||
if v := env("UOPI_AUDIT_DB_PATH"); v != "" {
|
||||
cfg.Audit.DBPath = v
|
||||
}
|
||||
if v := env("UOPI_AUDIT_READERS"); v != "" {
|
||||
cfg.Audit.Readers = strings.Fields(v)
|
||||
}
|
||||
if v := env("UOPI_EPICS_CA_ADDR_LIST"); v != "" {
|
||||
cfg.Datasource.EPICS.CAAddrList = v
|
||||
}
|
||||
@@ -181,6 +336,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("")
|
||||
|
||||
@@ -41,6 +41,7 @@ func Apply(set ConfigSet, inst ConfigInstance, write WriteFunc) ApplyResult {
|
||||
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()
|
||||
|
||||
@@ -51,6 +51,37 @@ func TestApplyUsesDefaultWhenNoValue(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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",
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -21,11 +21,12 @@ const (
|
||||
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:
|
||||
case TypeFloat, TypeInt, TypeBool, TypeString, TypeEnum, TypeFloatArray:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
@@ -60,6 +61,8 @@ type ConfigSet struct {
|
||||
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"`
|
||||
}
|
||||
|
||||
@@ -74,6 +77,8 @@ type ConfigInstance struct {
|
||||
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"`
|
||||
}
|
||||
|
||||
@@ -197,10 +202,57 @@ func (p Parameter) checkValue(v any) error {
|
||||
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) {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
+154
-4
@@ -22,13 +22,18 @@ type Kind int
|
||||
const (
|
||||
KindSet Kind = iota
|
||||
KindInstance
|
||||
KindRule
|
||||
)
|
||||
|
||||
func (k Kind) sub() string {
|
||||
if k == KindInstance {
|
||||
switch k {
|
||||
case KindInstance:
|
||||
return "instances"
|
||||
}
|
||||
case KindRule:
|
||||
return "rules"
|
||||
default:
|
||||
return "sets"
|
||||
}
|
||||
}
|
||||
|
||||
// Meta is the lightweight listing representation.
|
||||
@@ -36,6 +41,19 @@ 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.
|
||||
@@ -59,7 +77,7 @@ type Store struct {
|
||||
// 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} {
|
||||
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)
|
||||
@@ -75,6 +93,11 @@ type header struct {
|
||||
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 {
|
||||
@@ -139,7 +162,7 @@ func (s *Store) List(k Kind) ([]Meta, error) {
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, Meta{ID: id, Name: h.Name, Version: h.Version})
|
||||
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
|
||||
}
|
||||
@@ -462,6 +485,9 @@ func (s *Store) CreateInstance(inst ConfigInstance, tag string) (ConfigInstance,
|
||||
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
|
||||
@@ -483,6 +509,9 @@ func (s *Store) UpdateInstance(id string, inst ConfigInstance, tag string) (Conf
|
||||
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
|
||||
@@ -500,6 +529,127 @@ 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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,96 @@ func TestCreateAndGetSet(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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(), "")
|
||||
|
||||
@@ -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]Value{}}
|
||||
|
||||
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]Value{}}
|
||||
|
||||
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]Value{}}
|
||||
|
||||
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]Value{}}
|
||||
|
||||
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]Value{}}
|
||||
|
||||
// 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,7 +3,7 @@
|
||||
// Fields, in order: minute hour day-of-month month day-of-week.
|
||||
// Each field supports:
|
||||
//
|
||||
// * any value
|
||||
// - any value
|
||||
// */n every n (step over the whole range)
|
||||
// a-b inclusive range
|
||||
// a-b/n range with step
|
||||
|
||||
@@ -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 any `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 Value, 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.0 {
|
||||
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.0 {
|
||||
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.0 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
+506
-13
@@ -2,6 +2,8 @@ package controllogic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"regexp"
|
||||
@@ -13,8 +15,25 @@ import (
|
||||
|
||||
"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
|
||||
@@ -27,6 +46,7 @@ const (
|
||||
type Engine struct {
|
||||
broker *broker.Broker
|
||||
store *Store
|
||||
cfg *confmgr.Store
|
||||
audit audit.Recorder
|
||||
log *slog.Logger
|
||||
root context.Context
|
||||
@@ -40,6 +60,20 @@ type Engine struct {
|
||||
// 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
|
||||
@@ -59,17 +93,19 @@ func (e *Engine) SetNotifier(n Notifier) {
|
||||
|
||||
// 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, rec audit.Recorder, log *slog.Logger) *Engine {
|
||||
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,
|
||||
cfg: cfg,
|
||||
audit: rec,
|
||||
log: log,
|
||||
root: root,
|
||||
live: map[string]float64{},
|
||||
fireChs: map[string]chan string{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,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
|
||||
@@ -188,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.
|
||||
@@ -236,17 +276,54 @@ func (e *Engine) liveGet(ds, name string) float64 {
|
||||
return v
|
||||
}
|
||||
|
||||
// setPath assigns v at the nested index path within arr, growing (zero-filling)
|
||||
// as needed and resolving negative indices against length. Adapted from logic.ts;
|
||||
// unlike the single-threaded TS source it copies on descent rather than mutating in
|
||||
// place, because a graph-local slice (and its nested sub-slices) may be read
|
||||
// concurrently by other flow goroutines — in-place mutation of shared backing would
|
||||
// race. Every level returns a freshly allocated slice.
|
||||
func setPath(arr []Value, path []int, v Value) []Value {
|
||||
if len(path) == 0 {
|
||||
return arr
|
||||
}
|
||||
k := path[0]
|
||||
if k < 0 {
|
||||
k = len(arr) + k
|
||||
}
|
||||
if k < 0 {
|
||||
return arr
|
||||
}
|
||||
out := append([]Value{}, arr...) // copy: backing may be shared with the live local
|
||||
for len(out) <= k {
|
||||
out = append(out, 0.0)
|
||||
}
|
||||
if len(path) == 1 {
|
||||
out[k] = v
|
||||
return out
|
||||
}
|
||||
sub, _ := out[k].([]Value)
|
||||
out[k] = setPath(sub, path[1:], v)
|
||||
return out
|
||||
}
|
||||
|
||||
// write applies an action.write/lua-set to a target: a bare name updates a
|
||||
// graph-local var; a ds:name target writes to the data source.
|
||||
func (e *Engine) write(cg *compiledGraph, target string, val float64) {
|
||||
func (e *Engine) write(cg *compiledGraph, target string, val Value) {
|
||||
ds, name, ok := parseRef(target)
|
||||
if !ok || math.IsNaN(val) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if ds == "local" {
|
||||
cg.setLocal(name, val)
|
||||
return
|
||||
}
|
||||
f, isNum := val.(float64)
|
||||
if !isNum || math.IsNaN(f) {
|
||||
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)
|
||||
@@ -258,11 +335,11 @@ func (e *Engine) write(cg *compiledGraph, target string, val float64) {
|
||||
Action: "signal.write",
|
||||
DS: ds,
|
||||
Signal: name,
|
||||
Value: strconv.FormatFloat(val, 'g', -1, 64),
|
||||
Value: strconv.FormatFloat(f, 'g', -1, 64),
|
||||
Detail: "control logic: " + cg.name,
|
||||
Outcome: audit.OutcomeOK,
|
||||
}
|
||||
if err := src.Write(e.root, name, val); err != nil {
|
||||
if err := src.Write(e.root, name, f); err != nil {
|
||||
e.log.Warn("control logic: write failed", "ds", ds, "signal", name, "err", err)
|
||||
ev.Outcome = audit.OutcomeError
|
||||
ev.Error = err.Error()
|
||||
@@ -270,6 +347,237 @@ func (e *Engine) write(cg *compiledGraph, target string, val float64) {
|
||||
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.
|
||||
@@ -306,6 +614,14 @@ type compiledGraph struct {
|
||||
genCtx context.Context
|
||||
wg *sync.WaitGroup
|
||||
|
||||
// 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
|
||||
@@ -315,17 +631,24 @@ type compiledGraph struct {
|
||||
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
|
||||
prevVal map[string]float64 // last value for change triggers
|
||||
hasVal map[string]bool
|
||||
lastFire map[string]int64 // ns wall clock each trigger last fired
|
||||
locals map[string]float64
|
||||
locals map[string]Value
|
||||
decls map[string]StateVar
|
||||
}
|
||||
|
||||
func compile(g Graph) *compiledGraph {
|
||||
cg := &compiledGraph{
|
||||
id: g.ID,
|
||||
name: g.Name,
|
||||
byId: map[string]Node{},
|
||||
out: map[string][]wireOut{},
|
||||
@@ -338,11 +661,21 @@ func compile(g Graph) *compiledGraph {
|
||||
prevVal: map[string]float64{},
|
||||
hasVal: map[string]bool{},
|
||||
lastFire: map[string]int64{},
|
||||
locals: map[string]float64{},
|
||||
locals: map[string]Value{},
|
||||
decls: map[string]StateVar{},
|
||||
fireCh: make(chan string, 16),
|
||||
}
|
||||
for _, n := range g.Nodes {
|
||||
cg.byId[n.ID] = n
|
||||
}
|
||||
for _, sv := range g.StateVars {
|
||||
cg.decls[sv.Name] = sv
|
||||
if sv.Type == "array" {
|
||||
cg.locals[sv.Name] = applySizing(parseInitialArray(sv), sv)
|
||||
} else {
|
||||
cg.locals[sv.Name] = parseScalarInitial(sv)
|
||||
}
|
||||
}
|
||||
for _, w := range g.Wires {
|
||||
port := w.FromPort
|
||||
if port == "" {
|
||||
@@ -383,6 +716,13 @@ func compile(g Graph) *compiledGraph {
|
||||
}
|
||||
case "action.write", "action.log":
|
||||
wantExpr(n.param("expr"))
|
||||
case "action.array.push":
|
||||
wantExpr(n.param("expr"))
|
||||
case "action.array.set":
|
||||
wantExpr(n.param("expr"))
|
||||
wantExpr(n.param("index"))
|
||||
case "action.array.remove":
|
||||
wantExpr(n.param("index"))
|
||||
case "action.lua":
|
||||
cg.luaNodes[n.ID] = newLuaRuntime(n.param("script"))
|
||||
for _, r := range luaGetRefs(n.param("script")) {
|
||||
@@ -393,24 +733,62 @@ func compile(g Graph) *compiledGraph {
|
||||
return cg
|
||||
}
|
||||
|
||||
func (cg *compiledGraph) setLocal(name string, v float64) {
|
||||
func parseScalarInitial(sv StateVar) float64 {
|
||||
s := strings.TrimSpace(sv.Initial)
|
||||
switch s {
|
||||
case "true":
|
||||
return 1
|
||||
case "false":
|
||||
return 0
|
||||
}
|
||||
f, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
func (cg *compiledGraph) setLocal(name string, v Value) {
|
||||
cg.stateMu.Lock()
|
||||
if sv, ok := cg.decls[name]; ok && sv.Type == "array" {
|
||||
if arr, isArr := v.([]Value); isArr {
|
||||
v = applySizing(arr, sv)
|
||||
}
|
||||
}
|
||||
cg.locals[name] = v
|
||||
cg.stateMu.Unlock()
|
||||
}
|
||||
|
||||
func (cg *compiledGraph) getLocal(name string) float64 {
|
||||
func (cg *compiledGraph) getLocal(name string) Value {
|
||||
cg.stateMu.Lock()
|
||||
defer cg.stateMu.Unlock()
|
||||
v, ok := cg.locals[name]
|
||||
if !ok {
|
||||
return 0
|
||||
return 0.0
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -590,7 +968,7 @@ func (cg *compiledGraph) activate(triggerID string) {
|
||||
dt = float64(now-last) / 1e9
|
||||
}
|
||||
|
||||
resolve := func(ds, name string) float64 {
|
||||
resolve := func(ds, name string) Value {
|
||||
switch ds {
|
||||
case "sys":
|
||||
if name == "dt" {
|
||||
@@ -604,6 +982,8 @@ func (cg *compiledGraph) activate(triggerID string) {
|
||||
}
|
||||
}
|
||||
|
||||
cg.emitDebug(triggerID, 0, false)
|
||||
|
||||
cg.wg.Add(1)
|
||||
go func() {
|
||||
defer cg.wg.Done()
|
||||
@@ -636,6 +1016,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) {
|
||||
@@ -644,9 +1026,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":
|
||||
@@ -669,10 +1057,44 @@ func (cg *compiledGraph) run(nodeID string, ctx *runCtx) {
|
||||
cg.follow(node.ID, "done", ctx)
|
||||
|
||||
case "action.write":
|
||||
val := EvalExpr(node.param("expr"), ctx.resolve)
|
||||
val := EvalValue(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 {
|
||||
@@ -691,6 +1113,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)
|
||||
@@ -700,7 +1123,77 @@ func (cg *compiledGraph) run(nodeID string, ctx *runCtx) {
|
||||
cg.follow(node.ID, "out", ctx)
|
||||
|
||||
case "action.dialog":
|
||||
if !cg.dryRun {
|
||||
cg.engine.emitDialog(node)
|
||||
}
|
||||
cg.follow(node.ID, "out", ctx)
|
||||
|
||||
case "action.array.push":
|
||||
name := strings.TrimSpace(node.param("array"))
|
||||
if name != "" {
|
||||
val := EvalValue(node.param("expr"), ctx.resolve)
|
||||
cur, _ := cg.getLocal(name).([]Value)
|
||||
cg.setLocal(name, append(append([]Value{}, cur...), val))
|
||||
}
|
||||
cg.follow(node.ID, "out", ctx)
|
||||
|
||||
case "action.array.set":
|
||||
name := strings.TrimSpace(node.param("array"))
|
||||
if name != "" {
|
||||
cur, _ := cg.getLocal(name).([]Value)
|
||||
arr := append([]Value{}, cur...)
|
||||
var path []int
|
||||
ok := true
|
||||
for _, s := range strings.Split(node.param("index"), ",") {
|
||||
f := EvalExpr(strings.TrimSpace(s), ctx.resolve)
|
||||
if math.IsNaN(f) {
|
||||
ok = false
|
||||
break
|
||||
}
|
||||
path = append(path, int(f))
|
||||
}
|
||||
val := EvalValue(node.param("expr"), ctx.resolve)
|
||||
if ok && len(path) > 0 {
|
||||
arr = setPath(arr, path, val)
|
||||
}
|
||||
cg.setLocal(name, arr)
|
||||
}
|
||||
cg.follow(node.ID, "out", ctx)
|
||||
|
||||
case "action.array.remove":
|
||||
name := strings.TrimSpace(node.param("array"))
|
||||
if name != "" {
|
||||
cur, _ := cg.getLocal(name).([]Value)
|
||||
arr := append([]Value{}, cur...)
|
||||
i := int(EvalExpr(node.param("index"), ctx.resolve))
|
||||
k := i
|
||||
if k < 0 {
|
||||
k = len(arr) + k
|
||||
}
|
||||
if k >= 0 && k < len(arr) {
|
||||
arr = append(arr[:k], arr[k+1:]...)
|
||||
}
|
||||
cg.setLocal(name, arr)
|
||||
}
|
||||
cg.follow(node.ID, "out", ctx)
|
||||
|
||||
case "action.array.pop":
|
||||
name := strings.TrimSpace(node.param("array"))
|
||||
if name != "" {
|
||||
cur, _ := cg.getLocal(name).([]Value)
|
||||
arr := append([]Value{}, cur...)
|
||||
if len(arr) > 0 {
|
||||
arr = arr[:len(arr)-1]
|
||||
}
|
||||
cg.setLocal(name, arr)
|
||||
}
|
||||
cg.follow(node.ID, "out", ctx)
|
||||
|
||||
case "action.array.clear":
|
||||
name := strings.TrimSpace(node.param("array"))
|
||||
if name != "" {
|
||||
cg.setLocal(name, []Value{}) // setLocal applies sizing (fixed → zero-pad)
|
||||
}
|
||||
cg.follow(node.ID, "out", ctx)
|
||||
|
||||
default:
|
||||
|
||||
@@ -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 signOf(5) != 1 || signOf(-5) != -1 || signOf(0) != 0 {
|
||||
t.Errorf("sign mismatch: %d %d %d", signOf(5), signOf(-5), signOf(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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
package controllogic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// runFlowOnce compiles g, wires a minimal engine/context onto the compiled graph
|
||||
// (enough for the run/follow path and the lock-free emitDebug guard), then drives
|
||||
// the flow from triggerID synchronously and returns the compiled graph so callers
|
||||
// can inspect locals.
|
||||
func runFlowOnce(t *testing.T, g Graph, triggerID string) *compiledGraph {
|
||||
t.Helper()
|
||||
cg := compile(g)
|
||||
cg.engine = &Engine{}
|
||||
cg.genCtx = context.Background()
|
||||
R := func(ds, name string) Value {
|
||||
switch ds {
|
||||
case "local":
|
||||
return cg.getLocal(name)
|
||||
default:
|
||||
return 0.0
|
||||
}
|
||||
}
|
||||
ctx := &runCtx{fired: triggerID, resolve: R}
|
||||
cg.follow(triggerID, "out", ctx)
|
||||
return cg
|
||||
}
|
||||
|
||||
func TestArrayPushNode(t *testing.T) {
|
||||
g := Graph{
|
||||
ID: "g", Name: "n",
|
||||
StateVars: []StateVar{{Name: "buf", Type: "array", Initial: "[1]", Sizing: "dynamic"}},
|
||||
Nodes: []Node{
|
||||
{ID: "t", Kind: "trigger.timer", Params: map[string]string{"interval": "1000"}},
|
||||
{ID: "p", Kind: "action.array.push", Params: map[string]string{"array": "buf", "expr": "5"}},
|
||||
},
|
||||
Wires: []Wire{{From: "t", To: "p"}},
|
||||
}
|
||||
cg := runFlowOnce(t, g, "t")
|
||||
if got := cg.getLocal("buf"); !reflect.DeepEqual(got, []Value{1.0, 5.0}) {
|
||||
t.Fatalf("after push buf = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArrayClearNode(t *testing.T) {
|
||||
g := Graph{
|
||||
ID: "g", Name: "n",
|
||||
StateVars: []StateVar{{Name: "buf", Type: "array", Initial: "[1,2,3]", Sizing: "fixed", Capacity: 3}},
|
||||
Nodes: []Node{
|
||||
{ID: "t", Kind: "trigger.timer", Params: map[string]string{"interval": "1000"}},
|
||||
{ID: "c", Kind: "action.array.clear", Params: map[string]string{"array": "buf"}},
|
||||
},
|
||||
Wires: []Wire{{From: "t", To: "c"}},
|
||||
}
|
||||
cg := runFlowOnce(t, g, "t")
|
||||
if got := cg.getLocal("buf"); !reflect.DeepEqual(got, []Value{0.0, 0.0, 0.0}) {
|
||||
t.Fatalf("after clear buf = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArraySetNode(t *testing.T) {
|
||||
g := Graph{
|
||||
ID: "g", Name: "n",
|
||||
StateVars: []StateVar{{Name: "buf", Type: "array", Initial: "[0,0,0]", Sizing: "dynamic"}},
|
||||
Nodes: []Node{
|
||||
{ID: "t", Kind: "trigger.timer", Params: map[string]string{"interval": "1000"}},
|
||||
{ID: "s", Kind: "action.array.set", Params: map[string]string{"array": "buf", "index": "1", "expr": "9"}},
|
||||
},
|
||||
Wires: []Wire{{From: "t", To: "s"}},
|
||||
}
|
||||
cg := runFlowOnce(t, g, "t")
|
||||
if got := cg.getLocal("buf"); !reflect.DeepEqual(got, []Value{0.0, 9.0, 0.0}) {
|
||||
t.Fatalf("after set buf = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArrayRemoveNode(t *testing.T) {
|
||||
g := Graph{
|
||||
ID: "g", Name: "n",
|
||||
StateVars: []StateVar{{Name: "buf", Type: "array", Initial: "[10,20,30]", Sizing: "dynamic"}},
|
||||
Nodes: []Node{
|
||||
{ID: "t", Kind: "trigger.timer", Params: map[string]string{"interval": "1000"}},
|
||||
{ID: "r", Kind: "action.array.remove", Params: map[string]string{"array": "buf", "index": "-1"}},
|
||||
},
|
||||
Wires: []Wire{{From: "t", To: "r"}},
|
||||
}
|
||||
cg := runFlowOnce(t, g, "t")
|
||||
if got := cg.getLocal("buf"); !reflect.DeepEqual(got, []Value{10.0, 20.0}) {
|
||||
t.Fatalf("after remove buf = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArrayPopNode(t *testing.T) {
|
||||
g := Graph{
|
||||
ID: "g", Name: "n",
|
||||
StateVars: []StateVar{{Name: "buf", Type: "array", Initial: "[1,2,3]", Sizing: "dynamic"}},
|
||||
Nodes: []Node{
|
||||
{ID: "t", Kind: "trigger.timer", Params: map[string]string{"interval": "1000"}},
|
||||
{ID: "p", Kind: "action.array.pop", Params: map[string]string{"array": "buf"}},
|
||||
},
|
||||
Wires: []Wire{{From: "t", To: "p"}},
|
||||
}
|
||||
cg := runFlowOnce(t, g, "t")
|
||||
if got := cg.getLocal("buf"); !reflect.DeepEqual(got, []Value{1.0, 2.0}) {
|
||||
t.Fatalf("after pop buf = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestArraySetNodeNested verifies set with a comma-separated path mutates a nested
|
||||
// sub-array, and (crucially) that setPath does not mutate the previously stored slice
|
||||
// in place — the stored value must be replaced by a fresh tree.
|
||||
func TestArraySetNodeNested(t *testing.T) {
|
||||
g := Graph{
|
||||
ID: "g", Name: "n",
|
||||
StateVars: []StateVar{{Name: "grid", Type: "array", Initial: "[[1,2],[3,4]]", Sizing: "dynamic"}},
|
||||
Nodes: []Node{
|
||||
{ID: "t", Kind: "trigger.timer", Params: map[string]string{"interval": "1000"}},
|
||||
{ID: "s", Kind: "action.array.set", Params: map[string]string{"array": "grid", "index": "0,1", "expr": "9"}},
|
||||
},
|
||||
Wires: []Wire{{From: "t", To: "s"}},
|
||||
}
|
||||
cg := compile(g)
|
||||
before := cg.getLocal("grid")
|
||||
cg.engine = &Engine{}
|
||||
cg.genCtx = context.Background()
|
||||
R := func(ds, name string) Value {
|
||||
if ds == "local" {
|
||||
return cg.getLocal(name)
|
||||
}
|
||||
return 0.0
|
||||
}
|
||||
cg.follow("t", "out", &runCtx{fired: "t", resolve: R})
|
||||
want := []Value{[]Value{1.0, 9.0}, []Value{3.0, 4.0}}
|
||||
if got := cg.getLocal("grid"); !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("after nested set grid = %#v", got)
|
||||
}
|
||||
// The pre-mutation snapshot must be untouched (no shared-backing in-place write).
|
||||
if !reflect.DeepEqual(before, []Value{[]Value{1.0, 2.0}, []Value{3.0, 4.0}}) {
|
||||
t.Fatalf("setPath mutated the prior stored value in place: %#v", before)
|
||||
}
|
||||
}
|
||||
|
||||
// TestArraySetNodeConcurrentNoRace drives many concurrent set+read flows against the
|
||||
// same nested-array local; with -race it guards the setPath copy-on-descend fix.
|
||||
func TestArraySetNodeConcurrentNoRace(t *testing.T) {
|
||||
g := Graph{
|
||||
ID: "g", Name: "n",
|
||||
StateVars: []StateVar{{Name: "grid", Type: "array", Initial: "[[0,0],[0,0]]", Sizing: "dynamic"}},
|
||||
Nodes: []Node{
|
||||
{ID: "t", Kind: "trigger.timer", Params: map[string]string{"interval": "1000"}},
|
||||
{ID: "s", Kind: "action.array.set", Params: map[string]string{"array": "grid", "index": "0,0", "expr": "1"}},
|
||||
},
|
||||
Wires: []Wire{{From: "t", To: "s"}},
|
||||
}
|
||||
cg := compile(g)
|
||||
cg.engine = &Engine{}
|
||||
cg.genCtx = context.Background()
|
||||
R := func(ds, name string) Value {
|
||||
if ds == "local" {
|
||||
return cg.getLocal(name)
|
||||
}
|
||||
return 0.0
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
for i := 0; i < 32; i++ {
|
||||
wg.Add(2)
|
||||
go func() { defer wg.Done(); cg.follow("t", "out", &runCtx{fired: "t", resolve: R}) }()
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
// Read the inner element concurrently; with the in-place setPath bug the
|
||||
// writer mutates this same sub-array backing, producing a data race.
|
||||
if arr, ok := cg.getLocal("grid").([]Value); ok && len(arr) > 0 {
|
||||
if sub, ok := arr[0].([]Value); ok && len(sub) > 0 {
|
||||
_ = sub[0]
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestCompileInitsLocalsFromDecls(t *testing.T) {
|
||||
g := Graph{
|
||||
ID: "g", Name: "n",
|
||||
StateVars: []StateVar{
|
||||
{Name: "count", Type: "number", Initial: "7"},
|
||||
{Name: "flag", Type: "bool", Initial: "true"},
|
||||
{Name: "buf", Type: "array", Initial: "[1,2,3]", Sizing: "capped", Capacity: 4},
|
||||
},
|
||||
}
|
||||
cg := compile(g)
|
||||
if got := cg.getLocal("count"); got != Value(7.0) {
|
||||
t.Fatalf("count = %#v", got)
|
||||
}
|
||||
if got := cg.getLocal("flag"); got != Value(1.0) {
|
||||
t.Fatalf("flag = %#v", got)
|
||||
}
|
||||
if got := cg.getLocal("buf"); !reflect.DeepEqual(got, []Value{1.0, 2.0, 3.0}) {
|
||||
t.Fatalf("buf = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetLocalAppliesSizing(t *testing.T) {
|
||||
g := Graph{ID: "g", Name: "n", StateVars: []StateVar{
|
||||
{Name: "buf", Type: "array", Initial: "[]", Sizing: "capped", Capacity: 2},
|
||||
}}
|
||||
cg := compile(g)
|
||||
cg.setLocal("buf", []Value{1.0, 2.0, 3.0, 4.0})
|
||||
if got := cg.getLocal("buf"); !reflect.DeepEqual(got, []Value{3.0, 4.0}) {
|
||||
t.Fatalf("sized buf = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolverReturnsLocalValue(t *testing.T) {
|
||||
g := Graph{ID: "g", Name: "n", StateVars: []StateVar{
|
||||
{Name: "buf", Type: "array", Initial: "[10,20]", Sizing: "dynamic"},
|
||||
}}
|
||||
cg := compile(g)
|
||||
R := func(ds, name string) Value {
|
||||
if ds == "local" {
|
||||
return cg.getLocal(name)
|
||||
}
|
||||
return 0.0
|
||||
}
|
||||
if v := EvalExpr("buf[1]", R); v != 20 {
|
||||
t.Fatalf("buf[1] = %v", v)
|
||||
}
|
||||
if v := EvalExpr("sum(buf)", R); v != 30 {
|
||||
t.Fatalf("sum(buf) = %v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmitDebugAcceptsArray(t *testing.T) {
|
||||
g := Graph{ID: "g", Name: "n"}
|
||||
cg := compile(g)
|
||||
cg.engine = &Engine{} // no observer installed; emitDebug must not panic
|
||||
cg.emitDebug("x", []Value{1.0, 2.0}, true)
|
||||
cg.emitDebug("x", 3.0, true)
|
||||
}
|
||||
+365
-59
@@ -2,16 +2,15 @@
|
||||
//
|
||||
// Supports numbers, booleans (true/false → 1/0), arithmetic (+ - * / %),
|
||||
// comparison (< <= > >= == !=), boolean (&& || !), ternary (a ? b : c),
|
||||
// parentheses, and a handful of math functions. Two kinds of variable
|
||||
// reference are resolved live at evaluation time:
|
||||
// parentheses, array literals ([a, b, c]), postfix indexing (arr[i]), and a set
|
||||
// of math + array functions. Two kinds of variable reference are resolved live:
|
||||
//
|
||||
// {ds:name} a data-source signal value (the brace content is split on the
|
||||
// FIRST ':' so EPICS PV names like "MY:PV:NAME" work).
|
||||
// {ds:name} a data-source signal value (brace content split on FIRST ':').
|
||||
// bareIdent a graph-local state variable (data source "local").
|
||||
//
|
||||
// Booleans are represented as numbers: comparisons / logical ops yield 1 or 0,
|
||||
// and any nonzero value is truthy. The evaluator never uses reflection or eval;
|
||||
// it walks a parsed AST against a caller-supplied Resolver.
|
||||
// Values are either a scalar (float64; booleans 1/0) or an array ([]Value). The
|
||||
// evaluator never uses reflection or eval; it walks a parsed AST against a
|
||||
// caller-supplied Resolver.
|
||||
package controllogic
|
||||
|
||||
import (
|
||||
@@ -22,8 +21,8 @@ import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Resolver returns the current numeric value of a signal/local reference.
|
||||
type Resolver func(ds, name string) float64
|
||||
// Resolver returns the current value of a signal/local reference.
|
||||
type Resolver func(ds, name string) Value
|
||||
|
||||
// RefLite identifies one signal/local reference read by an expression.
|
||||
type RefLite struct {
|
||||
@@ -33,11 +32,13 @@ type RefLite struct {
|
||||
|
||||
// ── AST ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
type exprNode interface{ eval(R Resolver) float64 }
|
||||
type exprNode interface{ eval(R Resolver) Value }
|
||||
|
||||
type numNode struct{ v float64 }
|
||||
type sigNode struct{ ds, name string }
|
||||
type varNode struct{ name string }
|
||||
type arrNode struct{ items []exprNode }
|
||||
type indexNode struct{ a, i exprNode }
|
||||
type unNode struct {
|
||||
op string
|
||||
a exprNode
|
||||
@@ -52,33 +53,84 @@ 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 unNode) eval(R Resolver) float64 {
|
||||
if n.op == "-" {
|
||||
return -n.a.eval(R)
|
||||
func mustNum(v Value) float64 {
|
||||
f, err := asNum(v)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
if n.a.eval(R) == 0 {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
return f
|
||||
}
|
||||
func (n ternNode) eval(R Resolver) float64 {
|
||||
if n.c.eval(R) != 0 {
|
||||
func mustArr(v Value) []Value {
|
||||
a, err := asArr(v)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func (n numNode) eval(R Resolver) Value { return n.v }
|
||||
func (n sigNode) eval(R Resolver) Value { return R(n.ds, n.name) }
|
||||
func (n varNode) eval(R Resolver) Value { return R("local", n.name) }
|
||||
func (n arrNode) eval(R Resolver) Value {
|
||||
out := make([]Value, len(n.items))
|
||||
for i, it := range n.items {
|
||||
out[i] = it.eval(R)
|
||||
}
|
||||
return out
|
||||
}
|
||||
func (n indexNode) eval(R Resolver) Value {
|
||||
arr := mustArr(n.a.eval(R))
|
||||
k, err := idxResolve(mustNum(n.i.eval(R)), len(arr))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return arr[k]
|
||||
}
|
||||
func (n unNode) eval(R Resolver) Value {
|
||||
if n.op == "-" {
|
||||
return -mustNum(n.a.eval(R))
|
||||
}
|
||||
if mustNum(n.a.eval(R)) == 0 {
|
||||
return 1.0
|
||||
}
|
||||
return 0.0
|
||||
}
|
||||
func (n ternNode) eval(R Resolver) Value {
|
||||
if mustNum(n.c.eval(R)) != 0 {
|
||||
return n.a.eval(R)
|
||||
}
|
||||
return n.b.eval(R)
|
||||
}
|
||||
func (n callNode) eval(R Resolver) float64 {
|
||||
args := make([]float64, len(n.args))
|
||||
func (n callNode) eval(R Resolver) Value {
|
||||
args := make([]Value, len(n.args))
|
||||
for i, a := range n.args {
|
||||
args[i] = a.eval(R)
|
||||
}
|
||||
return funcs[n.fn](args)
|
||||
// min/max: scalar-variadic OR single-array form.
|
||||
if n.fn == "min" || n.fn == "max" {
|
||||
if len(args) == 1 {
|
||||
if arr, ok := args[0].([]Value); ok {
|
||||
return reduceMinMax(n.fn, arr)
|
||||
}
|
||||
}
|
||||
nums := make([]Value, len(args))
|
||||
copy(nums, args)
|
||||
return reduceMinMax(n.fn, nums)
|
||||
}
|
||||
if af, ok := arrFuncs[n.fn]; ok {
|
||||
return af(args)
|
||||
}
|
||||
if sf, ok := scalarFuncs[n.fn]; ok {
|
||||
nums := make([]float64, len(args))
|
||||
for i, a := range args {
|
||||
nums[i] = mustNum(a)
|
||||
}
|
||||
return sf(nums)
|
||||
}
|
||||
panic(fmt.Errorf("unknown function %q", n.fn))
|
||||
}
|
||||
func (n binNode) eval(R Resolver) float64 {
|
||||
a, b := n.a.eval(R), n.b.eval(R)
|
||||
func (n binNode) eval(R Resolver) Value {
|
||||
a, b := mustNum(n.a.eval(R)), mustNum(n.b.eval(R))
|
||||
switch n.op {
|
||||
case "+":
|
||||
return a + b
|
||||
@@ -107,7 +159,7 @@ func (n binNode) eval(R Resolver) float64 {
|
||||
case "||":
|
||||
return boolf(a != 0 || b != 0)
|
||||
}
|
||||
return math.NaN()
|
||||
panic(fmt.Errorf("unknown operator %q", n.op))
|
||||
}
|
||||
|
||||
func boolf(b bool) float64 {
|
||||
@@ -117,15 +169,34 @@ func boolf(b bool) float64 {
|
||||
return 0
|
||||
}
|
||||
|
||||
var funcs = map[string]func([]float64) float64{
|
||||
func reduceMinMax(fn string, arr []Value) Value {
|
||||
if len(arr) == 0 {
|
||||
if fn == "min" {
|
||||
return math.Inf(1)
|
||||
}
|
||||
return math.Inf(-1)
|
||||
}
|
||||
m := mustNum(arr[0])
|
||||
for _, x := range arr[1:] {
|
||||
v := mustNum(x)
|
||||
if fn == "min" {
|
||||
m = math.Min(m, v)
|
||||
} else {
|
||||
m = math.Max(m, v)
|
||||
}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// ── Functions ────────────────────────────────────────────────────────────────
|
||||
|
||||
var scalarFuncs = map[string]func([]float64) float64{
|
||||
"abs": func(a []float64) float64 { return math.Abs(a[0]) },
|
||||
"min": func(a []float64) float64 { return minSlice(a) },
|
||||
"max": func(a []float64) float64 { return maxSlice(a) },
|
||||
"sqrt": func(a []float64) float64 { return math.Sqrt(a[0]) },
|
||||
"floor": func(a []float64) float64 { return math.Floor(a[0]) },
|
||||
"ceil": func(a []float64) float64 { return math.Ceil(a[0]) },
|
||||
"round": func(a []float64) float64 { return math.Round(a[0]) },
|
||||
"sign": func(a []float64) float64 { return float64(sign(a[0])) },
|
||||
"sign": func(a []float64) float64 { return float64(signOf(a[0])) },
|
||||
"pow": func(a []float64) float64 { return math.Pow(a[0], a[1]) },
|
||||
"log": func(a []float64) float64 { return math.Log(a[0]) },
|
||||
"exp": func(a []float64) float64 { return math.Exp(a[0]) },
|
||||
@@ -133,27 +204,145 @@ var funcs = map[string]func([]float64) float64{
|
||||
"cos": func(a []float64) float64 { return math.Cos(a[0]) },
|
||||
}
|
||||
|
||||
func minSlice(a []float64) float64 {
|
||||
if len(a) == 0 {
|
||||
return math.Inf(1)
|
||||
var arrFuncs = map[string]func([]Value) Value{
|
||||
"len": func(a []Value) Value { return float64(len(mustArr(a[0]))) },
|
||||
"sum": func(a []Value) Value {
|
||||
s := 0.0
|
||||
for _, x := range mustArr(a[0]) {
|
||||
s += mustNum(x)
|
||||
}
|
||||
m := a[0]
|
||||
for _, x := range a[1:] {
|
||||
m = math.Min(m, x)
|
||||
return s
|
||||
},
|
||||
"mean": func(a []Value) Value {
|
||||
r := mustArr(a[0])
|
||||
if len(r) == 0 {
|
||||
return 0.0
|
||||
}
|
||||
return m
|
||||
s := 0.0
|
||||
for _, x := range r {
|
||||
s += mustNum(x)
|
||||
}
|
||||
return s / float64(len(r))
|
||||
},
|
||||
"slice": func(a []Value) Value {
|
||||
r := mustArr(a[0])
|
||||
s := 0
|
||||
e := len(r)
|
||||
if len(a) > 1 {
|
||||
s = clampIdx(int(mustNum(a[1])), len(r))
|
||||
}
|
||||
if len(a) > 2 {
|
||||
e = clampIdx(int(mustNum(a[2])), len(r))
|
||||
}
|
||||
if s > e {
|
||||
s = e
|
||||
}
|
||||
out := make([]Value, 0, e-s)
|
||||
out = append(out, r[s:e]...)
|
||||
return out
|
||||
},
|
||||
"concat": func(a []Value) Value { return append(append([]Value{}, mustArr(a[0])...), mustArr(a[1])...) },
|
||||
"reverse": func(a []Value) Value { r := append([]Value{}, mustArr(a[0])...); reverse(r); return r },
|
||||
"sort": func(a []Value) Value {
|
||||
r := append([]Value{}, mustArr(a[0])...)
|
||||
sortNum(r)
|
||||
return r
|
||||
},
|
||||
"scale": func(a []Value) Value {
|
||||
r := mustArr(a[0])
|
||||
k := mustNum(a[1])
|
||||
out := make([]Value, len(r))
|
||||
for i, x := range r {
|
||||
out[i] = mustNum(x) * k
|
||||
}
|
||||
return out
|
||||
},
|
||||
"add": func(a []Value) Value {
|
||||
return zipNum(mustArr(a[0]), mustArr(a[1]), func(x, y float64) float64 { return x + y })
|
||||
},
|
||||
"sub": func(a []Value) Value {
|
||||
return zipNum(mustArr(a[0]), mustArr(a[1]), func(x, y float64) float64 { return x - y })
|
||||
},
|
||||
"push": func(a []Value) Value {
|
||||
return append(append([]Value{}, mustArr(a[0])...), a[1])
|
||||
},
|
||||
"set": func(a []Value) Value {
|
||||
r := append([]Value{}, mustArr(a[0])...)
|
||||
k, err := idxResolve(mustNum(a[1]), len(r))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
r[k] = a[2]
|
||||
return r
|
||||
},
|
||||
"insert": func(a []Value) Value {
|
||||
r := append([]Value{}, mustArr(a[0])...)
|
||||
k := int(mustNum(a[1]))
|
||||
if k < 0 {
|
||||
k = 0
|
||||
}
|
||||
if k > len(r) {
|
||||
k = len(r)
|
||||
}
|
||||
r = append(r, nil)
|
||||
copy(r[k+1:], r[k:])
|
||||
r[k] = a[2]
|
||||
return r
|
||||
},
|
||||
"remove": func(a []Value) Value {
|
||||
r := append([]Value{}, mustArr(a[0])...)
|
||||
k, err := idxResolve(mustNum(a[1]), len(r))
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return append(r[:k], r[k+1:]...)
|
||||
},
|
||||
"pop": func(a []Value) Value {
|
||||
r := mustArr(a[0])
|
||||
if len(r) == 0 {
|
||||
return []Value{}
|
||||
}
|
||||
return append([]Value{}, r[:len(r)-1]...)
|
||||
},
|
||||
"shift": func(a []Value) Value {
|
||||
r := mustArr(a[0])
|
||||
if len(r) == 0 {
|
||||
return []Value{}
|
||||
}
|
||||
return append([]Value{}, r[1:]...)
|
||||
},
|
||||
"indexOf": func(a []Value) Value {
|
||||
r := mustArr(a[0])
|
||||
for i, x := range r {
|
||||
if valEq(x, a[1]) {
|
||||
return float64(i)
|
||||
}
|
||||
}
|
||||
return -1.0
|
||||
},
|
||||
"contains": func(a []Value) Value {
|
||||
r := mustArr(a[0])
|
||||
for _, x := range r {
|
||||
if valEq(x, a[1]) {
|
||||
return 1.0
|
||||
}
|
||||
}
|
||||
return 0.0
|
||||
},
|
||||
"fill": func(a []Value) Value {
|
||||
n := int(mustNum(a[0]))
|
||||
if n < 0 {
|
||||
n = 0
|
||||
}
|
||||
out := make([]Value, n)
|
||||
for i := range out {
|
||||
out[i] = a[1]
|
||||
}
|
||||
return out
|
||||
},
|
||||
}
|
||||
func maxSlice(a []float64) float64 {
|
||||
if len(a) == 0 {
|
||||
return math.Inf(-1)
|
||||
}
|
||||
m := a[0]
|
||||
for _, x := range a[1:] {
|
||||
m = math.Max(m, x)
|
||||
}
|
||||
return m
|
||||
}
|
||||
func sign(x float64) int {
|
||||
|
||||
func signOf(x float64) int {
|
||||
switch {
|
||||
case x > 0:
|
||||
return 1
|
||||
@@ -164,6 +353,47 @@ func sign(x float64) int {
|
||||
}
|
||||
}
|
||||
|
||||
func clampIdx(i, length int) int {
|
||||
if i < 0 {
|
||||
i = length + i
|
||||
}
|
||||
if i < 0 {
|
||||
i = 0
|
||||
}
|
||||
if i > length {
|
||||
i = length
|
||||
}
|
||||
return i
|
||||
}
|
||||
func reverse(r []Value) {
|
||||
for i, j := 0, len(r)-1; i < j; i, j = i+1, j-1 {
|
||||
r[i], r[j] = r[j], r[i]
|
||||
}
|
||||
}
|
||||
func sortNum(r []Value) {
|
||||
for i := 1; i < len(r); i++ {
|
||||
for j := i; j > 0 && mustNum(r[j-1]) > mustNum(r[j]); j-- {
|
||||
r[j-1], r[j] = r[j], r[j-1]
|
||||
}
|
||||
}
|
||||
}
|
||||
func zipNum(x, y []Value, f func(a, b float64) float64) []Value {
|
||||
n := len(x)
|
||||
if len(y) < n {
|
||||
n = len(y)
|
||||
}
|
||||
out := make([]Value, 0, n)
|
||||
for i := 0; i < n; i++ {
|
||||
out = append(out, f(mustNum(x[i]), mustNum(y[i])))
|
||||
}
|
||||
return out
|
||||
}
|
||||
func valEq(a, b Value) bool {
|
||||
af, aok := a.(float64)
|
||||
bf, bok := b.(float64)
|
||||
return aok && bok && af == bf
|
||||
}
|
||||
|
||||
// ── Tokenizer ────────────────────────────────────────────────────────────────
|
||||
|
||||
type tok struct {
|
||||
@@ -223,7 +453,7 @@ func tokenize(src string) ([]tok, error) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
if strings.ContainsRune("+-*/%<>!()?:,", c) {
|
||||
if strings.ContainsRune("+-*/%<>!()?:,[]", c) {
|
||||
toks = append(toks, tok{k: string(c)})
|
||||
i++
|
||||
continue
|
||||
@@ -279,7 +509,7 @@ func parse(src string) (exprNode, error) {
|
||||
return root, nil
|
||||
}
|
||||
|
||||
func (ps *parser) primary() (exprNode, error) {
|
||||
func (ps *parser) atom() (exprNode, error) {
|
||||
t, ok := ps.peek()
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected end of expression")
|
||||
@@ -292,6 +522,32 @@ func (ps *parser) primary() (exprNode, error) {
|
||||
return nil, fmt.Errorf("bad number %q", t.v)
|
||||
}
|
||||
return numNode{v: v}, nil
|
||||
case "[":
|
||||
ps.eat("[")
|
||||
var items []exprNode
|
||||
if nx, ok := ps.peek(); ok && nx.k != "]" {
|
||||
a, err := ps.ternary()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, a)
|
||||
for {
|
||||
nx2, ok := ps.peek()
|
||||
if !ok || nx2.k != "," {
|
||||
break
|
||||
}
|
||||
ps.eat(",")
|
||||
a, err := ps.ternary()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items = append(items, a)
|
||||
}
|
||||
}
|
||||
if _, err := ps.eat("]"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return arrNode{items: items}, nil
|
||||
case "sig":
|
||||
ps.eat("")
|
||||
idx := strings.IndexByte(t.v, ':')
|
||||
@@ -333,7 +589,7 @@ func (ps *parser) primary() (exprNode, error) {
|
||||
if _, err := ps.eat(")"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, ok := funcs[id]; !ok {
|
||||
if !knownFunc(id) {
|
||||
return nil, fmt.Errorf("unknown function %q", id)
|
||||
}
|
||||
return callNode{fn: id, args: args}, nil
|
||||
@@ -353,6 +609,39 @@ func (ps *parser) primary() (exprNode, error) {
|
||||
return nil, fmt.Errorf("unexpected token %q in expression", t.k)
|
||||
}
|
||||
|
||||
func knownFunc(id string) bool {
|
||||
if id == "min" || id == "max" {
|
||||
return true
|
||||
}
|
||||
if _, ok := arrFuncs[id]; ok {
|
||||
return true
|
||||
}
|
||||
_, ok := scalarFuncs[id]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (ps *parser) primary() (exprNode, error) {
|
||||
n, err := ps.atom()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for {
|
||||
nx, ok := ps.peek()
|
||||
if !ok || nx.k != "[" {
|
||||
return n, nil
|
||||
}
|
||||
ps.eat("[")
|
||||
i, err := ps.ternary()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := ps.eat("]"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
n = indexNode{a: n, i: i}
|
||||
}
|
||||
}
|
||||
|
||||
func (ps *parser) unary() (exprNode, error) {
|
||||
if t, ok := ps.peek(); ok && (t.k == "-" || t.k == "!") {
|
||||
ps.eat("")
|
||||
@@ -449,8 +738,9 @@ func parseCached(src string) (exprNode, error) {
|
||||
return n, err
|
||||
}
|
||||
|
||||
// EvalExpr evaluates an expression string, returning NaN on parse/eval failure.
|
||||
func EvalExpr(src string, resolve Resolver) float64 {
|
||||
// EvalValue evaluates an expression, returning the full Value (number or array).
|
||||
// Returns NaN on parse/eval failure.
|
||||
func EvalValue(src string, resolve Resolver) Value {
|
||||
n, err := parseCached(src)
|
||||
if err != nil {
|
||||
return math.NaN()
|
||||
@@ -458,7 +748,7 @@ func EvalExpr(src string, resolve Resolver) float64 {
|
||||
return safeEval(n, resolve)
|
||||
}
|
||||
|
||||
func safeEval(n exprNode, resolve Resolver) (out float64) {
|
||||
func safeEval(n exprNode, resolve Resolver) (out Value) {
|
||||
defer func() {
|
||||
if recover() != nil {
|
||||
out = math.NaN()
|
||||
@@ -467,14 +757,23 @@ func safeEval(n exprNode, resolve Resolver) (out float64) {
|
||||
return n.eval(resolve)
|
||||
}
|
||||
|
||||
// EvalBool reports whether the expression evaluates to a nonzero, non-NaN value.
|
||||
// EvalExpr evaluates an expression to a scalar; returns NaN on parse/eval
|
||||
// failure OR when the result is an array.
|
||||
func EvalExpr(src string, resolve Resolver) float64 {
|
||||
v := EvalValue(src, resolve)
|
||||
if f, ok := v.(float64); ok {
|
||||
return f
|
||||
}
|
||||
return math.NaN()
|
||||
}
|
||||
|
||||
// EvalBool reports whether the expression evaluates to a nonzero, non-NaN scalar.
|
||||
func EvalBool(src string, resolve Resolver) bool {
|
||||
v := EvalExpr(src, resolve)
|
||||
return !math.IsNaN(v) && v != 0
|
||||
}
|
||||
|
||||
// CollectRefs returns every signal/local reference an expression reads, for
|
||||
// subscription. Returns nil for an unparseable expression.
|
||||
// CollectRefs returns every signal/local reference an expression reads.
|
||||
func CollectRefs(src string) []RefLite {
|
||||
root, err := parseCached(src)
|
||||
if err != nil {
|
||||
@@ -496,6 +795,13 @@ func CollectRefs(src string) []RefLite {
|
||||
add(t.ds, t.name)
|
||||
case varNode:
|
||||
add("local", t.name)
|
||||
case arrNode:
|
||||
for _, it := range t.items {
|
||||
walk(it)
|
||||
}
|
||||
case indexNode:
|
||||
walk(t.a)
|
||||
walk(t.i)
|
||||
case unNode:
|
||||
walk(t.a)
|
||||
case binNode:
|
||||
|
||||
@@ -2,11 +2,12 @@ package controllogic
|
||||
|
||||
import (
|
||||
"math"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEvalExpr(t *testing.T) {
|
||||
resolve := func(ds, name string) float64 {
|
||||
resolve := func(ds, name string) Value {
|
||||
switch {
|
||||
case ds == "stub" && name == "x":
|
||||
return 10
|
||||
@@ -45,7 +46,7 @@ func TestEvalExpr(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestEvalExprErrors(t *testing.T) {
|
||||
r := func(ds, name string) float64 { return 0 }
|
||||
r := func(ds, name string) Value { return 0 }
|
||||
for _, bad := range []string{"1 +", "(1", "1 2", "{unterminated"} {
|
||||
if v := EvalExpr(bad, r); !math.IsNaN(v) {
|
||||
t.Errorf("EvalExpr(%q) = %v, want NaN", bad, v)
|
||||
@@ -84,3 +85,72 @@ func TestEpicsRefSplitFirstColon(t *testing.T) {
|
||||
t.Errorf("got %+v, want epics / SR:BPM:01:X", refs)
|
||||
}
|
||||
}
|
||||
|
||||
// ── New tests for value-polymorphic evaluator ─────────────────────────────────
|
||||
|
||||
func numResolver(vals map[string]Value) Resolver {
|
||||
return func(ds, name string) Value {
|
||||
if v, ok := vals[ds+":"+name]; ok {
|
||||
return v
|
||||
}
|
||||
return math.NaN()
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalValueScalar(t *testing.T) {
|
||||
R := numResolver(nil)
|
||||
if got := EvalExpr("2 + 3 * 4", R); got != 14 {
|
||||
t.Fatalf("scalar = %v", got)
|
||||
}
|
||||
if !EvalBool("1 < 2 && 3 >= 3", R) {
|
||||
t.Fatal("bool expr should be true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalValueArrayLiteralAndIndex(t *testing.T) {
|
||||
R := numResolver(nil)
|
||||
got := EvalValue("[1, 2, 3]", R)
|
||||
if !reflect.DeepEqual(got, []Value{1.0, 2.0, 3.0}) {
|
||||
t.Fatalf("array literal = %#v", got)
|
||||
}
|
||||
if v := EvalExpr("[10,20,30][-1]", R); v != 30 {
|
||||
t.Fatalf("index -1 = %v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalArrayFuncs(t *testing.T) {
|
||||
R := numResolver(map[string]Value{"local:buf": []Value{3.0, 1.0, 2.0}})
|
||||
if v := EvalExpr("len(buf)", R); v != 3 {
|
||||
t.Fatalf("len = %v", v)
|
||||
}
|
||||
if v := EvalExpr("sum(buf)", R); v != 6 {
|
||||
t.Fatalf("sum = %v", v)
|
||||
}
|
||||
if v := EvalExpr("max(buf)", R); v != 3 {
|
||||
t.Fatalf("max(array) = %v", v)
|
||||
}
|
||||
if v := EvalExpr("max(1, 9, 4)", R); v != 9 {
|
||||
t.Fatalf("max(scalars) = %v", v)
|
||||
}
|
||||
got := EvalValue("push(buf, 7)", R)
|
||||
if !reflect.DeepEqual(got, []Value{3.0, 1.0, 2.0, 7.0}) {
|
||||
t.Fatalf("push = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEvalExprArrayYieldsNaN(t *testing.T) {
|
||||
if v := EvalExpr("[1,2]", numResolver(nil)); !math.IsNaN(v) {
|
||||
t.Fatalf("array via EvalExpr should be NaN, got %v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectRefsArray(t *testing.T) {
|
||||
refs := CollectRefs("[{ds:a}, b[0]] ")
|
||||
keys := map[string]bool{}
|
||||
for _, r := range refs {
|
||||
keys[r.DS+":"+r.Name] = true
|
||||
}
|
||||
if !keys["ds:a"] || !keys["local:b"] {
|
||||
t.Fatalf("refs = %#v", refs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package controllogic
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sync"
|
||||
|
||||
lua "github.com/yuin/gopher-lua"
|
||||
@@ -60,9 +61,11 @@ func (lr *luaRuntime) ensure() error {
|
||||
L.SetGlobal("get", L.NewFunction(func(s *lua.LState) int {
|
||||
target := s.CheckString(1)
|
||||
ds, name, ok := parseRef(target)
|
||||
var v float64
|
||||
v := math.NaN()
|
||||
if ok && lr.curResolve != nil {
|
||||
v = lr.curResolve(ds, name)
|
||||
if f, isNum := lr.curResolve(ds, name).(float64); isNum {
|
||||
v = f
|
||||
}
|
||||
}
|
||||
s.Push(lua.LNumber(v))
|
||||
return 1
|
||||
|
||||
@@ -50,13 +50,32 @@ 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"`
|
||||
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"`
|
||||
// StateVars declares graph-local variables (scalar or array). Live values are
|
||||
// instantiated in memory per generation from these declarations; only the
|
||||
// declarations persist. Mirrors the panel-logic statevars feature.
|
||||
StateVars []StateVar `json:"statevars,omitempty"`
|
||||
}
|
||||
|
||||
func (n Node) param(key string) string {
|
||||
|
||||
@@ -17,10 +17,15 @@ 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
|
||||
trashDir string
|
||||
versionsDir string
|
||||
items map[string]Graph
|
||||
}
|
||||
|
||||
@@ -29,6 +34,7 @@ func NewStore(storageDir string) (*Store, error) {
|
||||
s := &Store{
|
||||
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 {
|
||||
@@ -94,14 +100,46 @@ 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()
|
||||
}
|
||||
|
||||
// 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).
|
||||
|
||||
@@ -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,33 @@
|
||||
package controllogic
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestStoreRoundTripStateVars(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
st, err := NewStore(dir) // NewStore takes the storage DIRECTORY
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g := Graph{
|
||||
ID: "g1",
|
||||
Name: "with-vars",
|
||||
StateVars: []StateVar{
|
||||
{Name: "count", Type: "number", Initial: "0"},
|
||||
{Name: "buf", Type: "array", Initial: "[1,2]", Elem: "number", Sizing: "capped", Capacity: 5},
|
||||
},
|
||||
}
|
||||
if err := st.Save(g); err != nil { // Save returns only error
|
||||
t.Fatal(err)
|
||||
}
|
||||
st2, err := NewStore(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := st2.Get("g1") // Get returns (Graph, error); ErrNotFound if absent
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got.StateVars) != 2 || got.StateVars[1].Name != "buf" || got.StateVars[1].Capacity != 5 {
|
||||
t.Fatalf("statevars not round-tripped: %#v", got.StateVars)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
// Value model for control-logic locals/expressions. A Value is either a scalar
|
||||
// (float64; booleans are 1/0) or an array ([]Value). This is the Go port of
|
||||
// web/src/lib/arraypolicy.ts (sizing) plus the asNum/asArr/idx narrowing from
|
||||
// web/src/lib/expr.ts. Pure, dependency-free.
|
||||
package controllogic
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Value is a scalar (float64) or an array ([]Value).
|
||||
type Value = any
|
||||
|
||||
// ARRAY_MAX is the global hard cap on dynamic array length (drops oldest).
|
||||
const ARRAY_MAX = 1_000_000
|
||||
|
||||
// StateVar declares a graph-local variable. Mirrors web/src/lib/types.ts StateVar.
|
||||
type StateVar struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type,omitempty"` // number|bool|string|array (default number)
|
||||
Initial string `json:"initial"` // initial value, stored as a string
|
||||
Unit string `json:"unit,omitempty"`
|
||||
Low float64 `json:"low,omitempty"`
|
||||
High float64 `json:"high,omitempty"`
|
||||
Elem string `json:"elem,omitempty"` // array-only: number|bool|array
|
||||
Sizing string `json:"sizing,omitempty"` // array-only: dynamic|capped|fixed
|
||||
Capacity int `json:"capacity,omitempty"` // array-only
|
||||
}
|
||||
|
||||
func asNum(v Value) (float64, error) {
|
||||
f, ok := v.(float64)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("expected a number, got an array")
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func asArr(v Value) ([]Value, error) {
|
||||
a, ok := v.([]Value)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("expected an array, got a number")
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// idxResolve resolves a possibly-negative index against length; range-checked.
|
||||
func idxResolve(i float64, length int) (int, error) {
|
||||
k := int(i) // truncates toward zero, matching Math.trunc
|
||||
if k < 0 {
|
||||
k = length + k
|
||||
}
|
||||
if k < 0 || k >= length {
|
||||
return 0, fmt.Errorf("index %v out of range (len %d)", i, length)
|
||||
}
|
||||
return k, nil
|
||||
}
|
||||
|
||||
// normalizeValue coerces an arbitrary decoded value (e.g. from JSON: float64,
|
||||
// bool, []interface{}) into a canonical Value (float64 leaves, []Value arrays).
|
||||
func normalizeValue(v any) Value {
|
||||
switch t := v.(type) {
|
||||
case float64:
|
||||
return t
|
||||
case float32:
|
||||
return float64(t)
|
||||
case int:
|
||||
return float64(t)
|
||||
case int64:
|
||||
return float64(t)
|
||||
case bool:
|
||||
if t {
|
||||
return 1.0
|
||||
}
|
||||
return 0.0
|
||||
case []interface{}:
|
||||
out := make([]Value, len(t))
|
||||
for i, e := range t {
|
||||
out[i] = normalizeValue(e)
|
||||
}
|
||||
return out
|
||||
default:
|
||||
return 0.0
|
||||
}
|
||||
}
|
||||
|
||||
func zeroFill(n int) []Value {
|
||||
if n < 0 {
|
||||
n = 0
|
||||
}
|
||||
out := make([]Value, n)
|
||||
for i := range out {
|
||||
out[i] = 0.0
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// parseInitialArray returns the starting contents of an array local. Mirrors
|
||||
// arraypolicy.ts parseInitialArray.
|
||||
func parseInitialArray(sv StateVar) []Value {
|
||||
cap := sv.Capacity
|
||||
raw := strings.TrimSpace(sv.Initial)
|
||||
var parsed []Value
|
||||
if raw != "" {
|
||||
var j interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &j); err == nil {
|
||||
if arr, ok := j.([]interface{}); ok {
|
||||
parsed = normalizeValue(arr).([]Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
if sv.Sizing == "fixed" {
|
||||
if parsed == nil {
|
||||
return zeroFill(cap)
|
||||
}
|
||||
out := make([]Value, 0, cap)
|
||||
for i := 0; i < len(parsed) && i < cap; i++ {
|
||||
out = append(out, parsed[i])
|
||||
}
|
||||
for len(out) < cap {
|
||||
out = append(out, 0.0)
|
||||
}
|
||||
return out
|
||||
}
|
||||
if parsed == nil {
|
||||
return []Value{}
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
// applySizing clamps arr to the declared sizing policy. Mirrors arraypolicy.ts.
|
||||
func applySizing(arr []Value, sv StateVar) []Value {
|
||||
cap := sv.Capacity
|
||||
switch sv.Sizing {
|
||||
case "fixed":
|
||||
out := make([]Value, 0, cap)
|
||||
for i := 0; i < len(arr) && i < cap; i++ {
|
||||
out = append(out, arr[i])
|
||||
}
|
||||
for len(out) < cap {
|
||||
out = append(out, 0.0)
|
||||
}
|
||||
return out
|
||||
case "capped":
|
||||
if len(arr) > cap {
|
||||
return arr[len(arr)-cap:]
|
||||
}
|
||||
return arr
|
||||
default:
|
||||
if len(arr) > ARRAY_MAX {
|
||||
return arr[len(arr)-ARRAY_MAX:]
|
||||
}
|
||||
return arr
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package controllogic
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAsNumAsArr(t *testing.T) {
|
||||
if n, err := asNum(3.0); err != nil || n != 3 {
|
||||
t.Fatalf("asNum(3)=%v,%v", n, err)
|
||||
}
|
||||
if _, err := asNum([]Value{1.0}); err == nil {
|
||||
t.Fatal("asNum(array) should error")
|
||||
}
|
||||
if a, err := asArr([]Value{1.0, 2.0}); err != nil || len(a) != 2 {
|
||||
t.Fatalf("asArr=%v,%v", a, err)
|
||||
}
|
||||
if _, err := asArr(3.0); err == nil {
|
||||
t.Fatal("asArr(number) should error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdxResolve(t *testing.T) {
|
||||
if k, err := idxResolve(-1, 3); err != nil || k != 2 {
|
||||
t.Fatalf("idx(-1,3)=%v,%v", k, err)
|
||||
}
|
||||
if _, err := idxResolve(3, 3); err == nil {
|
||||
t.Fatal("idx(3,3) should be out of range")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeValue(t *testing.T) {
|
||||
got := normalizeValue([]interface{}{1.0, true, []interface{}{2.0}})
|
||||
want := []Value{1.0, 1.0, []Value{2.0}}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("normalize=%#v want %#v", got, want)
|
||||
}
|
||||
if normalizeValue(5) != Value(5.0) {
|
||||
t.Fatalf("normalize(int) = %#v", normalizeValue(5))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInitialArray(t *testing.T) {
|
||||
fixed := parseInitialArray(StateVar{Type: "array", Sizing: "fixed", Capacity: 3, Initial: "[1,2]"})
|
||||
if !reflect.DeepEqual(fixed, []Value{1.0, 2.0, 0.0}) {
|
||||
t.Fatalf("fixed init = %#v", fixed)
|
||||
}
|
||||
dyn := parseInitialArray(StateVar{Type: "array", Sizing: "dynamic", Initial: "[5,6,7]"})
|
||||
if !reflect.DeepEqual(dyn, []Value{5.0, 6.0, 7.0}) {
|
||||
t.Fatalf("dynamic init = %#v", dyn)
|
||||
}
|
||||
empty := parseInitialArray(StateVar{Type: "array", Sizing: "dynamic", Initial: ""})
|
||||
if len(empty) != 0 {
|
||||
t.Fatalf("empty init = %#v", empty)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySizing(t *testing.T) {
|
||||
capped := applySizing([]Value{1.0, 2.0, 3.0, 4.0}, StateVar{Sizing: "capped", Capacity: 2})
|
||||
if !reflect.DeepEqual(capped, []Value{3.0, 4.0}) {
|
||||
t.Fatalf("capped = %#v", capped)
|
||||
}
|
||||
fixed := applySizing([]Value{1.0}, StateVar{Sizing: "fixed", Capacity: 3})
|
||||
if !reflect.DeepEqual(fixed, []Value{1.0, 0.0, 0.0}) {
|
||||
t.Fatalf("fixed = %#v", fixed)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -73,8 +73,7 @@ type EPICS struct {
|
||||
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.
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
package modbus
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Modbus function codes.
|
||||
const (
|
||||
fcReadCoils = 0x01
|
||||
fcReadDiscrete = 0x02
|
||||
fcReadHolding = 0x03
|
||||
fcReadInput = 0x04
|
||||
fcWriteSingleCoil = 0x05
|
||||
fcWriteSingleReg = 0x06
|
||||
fcWriteMultipleRegs = 0x10
|
||||
)
|
||||
|
||||
// client is a minimal Modbus TCP master for a single device address. Requests
|
||||
// are serialised by mu (Modbus TCP is request/response and the connection is
|
||||
// shared by all of a device's polled registers). The connection is dialled
|
||||
// lazily and dropped on any I/O error so the next request reconnects.
|
||||
type client struct {
|
||||
addr string
|
||||
timeout time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
conn net.Conn
|
||||
txID uint16
|
||||
}
|
||||
|
||||
func newClient(addr string, timeout time.Duration) *client {
|
||||
if timeout <= 0 {
|
||||
timeout = 3 * time.Second
|
||||
}
|
||||
return &client{addr: addr, timeout: timeout}
|
||||
}
|
||||
|
||||
func (c *client) close() {
|
||||
c.mu.Lock()
|
||||
c.closeLocked()
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
func (c *client) closeLocked() {
|
||||
if c.conn != nil {
|
||||
_ = c.conn.Close()
|
||||
c.conn = nil
|
||||
}
|
||||
}
|
||||
|
||||
// request sends a PDU to unitID and returns the response PDU (function code +
|
||||
// data). It dials on demand and tears the connection down on error.
|
||||
func (c *client) request(unitID byte, pdu []byte) ([]byte, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.conn == nil {
|
||||
conn, err := net.DialTimeout("tcp", c.addr, c.timeout)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("modbus: dial %s: %w", c.addr, err)
|
||||
}
|
||||
c.conn = conn
|
||||
}
|
||||
|
||||
resp, err := c.transact(unitID, pdu)
|
||||
if err != nil {
|
||||
c.closeLocked()
|
||||
return nil, err
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// transact performs one MBAP-framed exchange. Caller holds mu.
|
||||
func (c *client) transact(unitID byte, pdu []byte) ([]byte, error) {
|
||||
c.txID++
|
||||
tx := c.txID
|
||||
|
||||
frame := make([]byte, 7+len(pdu))
|
||||
binary.BigEndian.PutUint16(frame[0:], tx) // transaction id
|
||||
binary.BigEndian.PutUint16(frame[2:], 0) // protocol id (0 = Modbus)
|
||||
binary.BigEndian.PutUint16(frame[4:], uint16(1+len(pdu))) // length: unit id + PDU
|
||||
frame[6] = unitID
|
||||
copy(frame[7:], pdu)
|
||||
|
||||
_ = c.conn.SetDeadline(time.Now().Add(c.timeout))
|
||||
if _, err := c.conn.Write(frame); err != nil {
|
||||
return nil, fmt.Errorf("modbus: write: %w", err)
|
||||
}
|
||||
|
||||
head := make([]byte, 7)
|
||||
if _, err := io.ReadFull(c.conn, head); err != nil {
|
||||
return nil, fmt.Errorf("modbus: read header: %w", err)
|
||||
}
|
||||
if binary.BigEndian.Uint16(head[0:]) != tx {
|
||||
return nil, fmt.Errorf("modbus: transaction id mismatch")
|
||||
}
|
||||
length := binary.BigEndian.Uint16(head[4:])
|
||||
if length < 2 { // unit id + at least a function code
|
||||
return nil, fmt.Errorf("modbus: short frame length %d", length)
|
||||
}
|
||||
body := make([]byte, length-1) // header already consumed the unit id
|
||||
if _, err := io.ReadFull(c.conn, body); err != nil {
|
||||
return nil, fmt.Errorf("modbus: read body: %w", err)
|
||||
}
|
||||
|
||||
fc := body[0]
|
||||
if fc&0x80 != 0 { // exception response
|
||||
var ex byte
|
||||
if len(body) >= 2 {
|
||||
ex = body[1]
|
||||
}
|
||||
return nil, fmt.Errorf("modbus: exception 0x%02x (%s)", ex, exceptionText(ex))
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// readRegisters reads `quantity` 16-bit registers via fc (holding or input).
|
||||
func (c *client) readRegisters(unitID, fc byte, addr, quantity uint16) ([]uint16, error) {
|
||||
pdu := []byte{fc, byte(addr >> 8), byte(addr), byte(quantity >> 8), byte(quantity)}
|
||||
resp, err := c.request(unitID, pdu)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(resp) < 2 {
|
||||
return nil, fmt.Errorf("modbus: short register response")
|
||||
}
|
||||
byteCount := int(resp[1])
|
||||
if byteCount != int(quantity)*2 || len(resp) < 2+byteCount {
|
||||
return nil, fmt.Errorf("modbus: register byte count %d (want %d)", byteCount, quantity*2)
|
||||
}
|
||||
regs := make([]uint16, quantity)
|
||||
for i := range regs {
|
||||
regs[i] = binary.BigEndian.Uint16(resp[2+i*2:])
|
||||
}
|
||||
return regs, nil
|
||||
}
|
||||
|
||||
// readBits reads `quantity` bits via fc (coils or discrete inputs).
|
||||
func (c *client) readBits(unitID, fc byte, addr, quantity uint16) ([]bool, error) {
|
||||
pdu := []byte{fc, byte(addr >> 8), byte(addr), byte(quantity >> 8), byte(quantity)}
|
||||
resp, err := c.request(unitID, pdu)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(resp) < 2 {
|
||||
return nil, fmt.Errorf("modbus: short bit response")
|
||||
}
|
||||
byteCount := int(resp[1])
|
||||
if len(resp) < 2+byteCount {
|
||||
return nil, fmt.Errorf("modbus: bit byte count %d exceeds frame", byteCount)
|
||||
}
|
||||
bits := make([]bool, quantity)
|
||||
for i := range bits {
|
||||
idx := 2 + i/8
|
||||
if idx >= len(resp) {
|
||||
break
|
||||
}
|
||||
bits[i] = resp[idx]&(1<<(uint(i)%8)) != 0
|
||||
}
|
||||
return bits, nil
|
||||
}
|
||||
|
||||
func (c *client) writeSingleRegister(unitID byte, addr, value uint16) error {
|
||||
pdu := []byte{fcWriteSingleReg, byte(addr >> 8), byte(addr), byte(value >> 8), byte(value)}
|
||||
_, err := c.request(unitID, pdu)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *client) writeMultipleRegisters(unitID byte, addr uint16, values []uint16) error {
|
||||
pdu := make([]byte, 6+len(values)*2)
|
||||
pdu[0] = fcWriteMultipleRegs
|
||||
binary.BigEndian.PutUint16(pdu[1:], addr)
|
||||
binary.BigEndian.PutUint16(pdu[3:], uint16(len(values)))
|
||||
pdu[5] = byte(len(values) * 2)
|
||||
for i, v := range values {
|
||||
binary.BigEndian.PutUint16(pdu[6+i*2:], v)
|
||||
}
|
||||
_, err := c.request(unitID, pdu)
|
||||
return err
|
||||
}
|
||||
|
||||
func (c *client) writeSingleCoil(unitID byte, addr uint16, on bool) error {
|
||||
var v uint16
|
||||
if on {
|
||||
v = 0xFF00
|
||||
}
|
||||
pdu := []byte{fcWriteSingleCoil, byte(addr >> 8), byte(addr), byte(v >> 8), byte(v)}
|
||||
_, err := c.request(unitID, pdu)
|
||||
return err
|
||||
}
|
||||
|
||||
func exceptionText(code byte) string {
|
||||
switch code {
|
||||
case 0x01:
|
||||
return "illegal function"
|
||||
case 0x02:
|
||||
return "illegal data address"
|
||||
case 0x03:
|
||||
return "illegal data value"
|
||||
case 0x04:
|
||||
return "server device failure"
|
||||
case 0x05:
|
||||
return "acknowledge"
|
||||
case 0x06:
|
||||
return "server device busy"
|
||||
case 0x0B:
|
||||
return "gateway target failed to respond"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package modbus
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/uopi/uopi/internal/datasource"
|
||||
)
|
||||
|
||||
// Config is the [datasource.modbus] section. Devices share no connection state;
|
||||
// each is polled independently over its own TCP socket.
|
||||
type Config struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
// PollIntervalMs is the default polling period for every register that does
|
||||
// not override it. Zero → 1000 ms.
|
||||
PollIntervalMs int `toml:"poll_interval_ms"`
|
||||
Devices []Device `toml:"devices"`
|
||||
}
|
||||
|
||||
// Device is one Modbus TCP slave. Address is "host:port" (default port 502 is
|
||||
// appended if absent). UnitID is the Modbus unit/slave identifier (0–255).
|
||||
type Device struct {
|
||||
Name string `toml:"name"`
|
||||
Address string `toml:"address"`
|
||||
UnitID uint8 `toml:"unit_id"`
|
||||
TimeoutMs int `toml:"timeout_ms"`
|
||||
Registers []Register `toml:"registers"`
|
||||
}
|
||||
|
||||
// Register describes one logical signal mapped onto a Modbus address.
|
||||
//
|
||||
// - Kind selects the address space / function code:
|
||||
// "holding" (FC03/06/10), "input" (FC04, read-only),
|
||||
// "coil" (FC01/05, bool), "discrete" (FC02, read-only bool).
|
||||
// - Encoding selects how holding/input words are decoded:
|
||||
// "uint16", "int16", "uint32", "int32", "float32", "float64".
|
||||
// Ignored for coil/discrete (always bool).
|
||||
// - WordOrder is "big" (default, high word first) or "little" for the
|
||||
// multi-word encodings.
|
||||
// - Scale/Offset transform the raw numeric value: value*Scale + Offset.
|
||||
// Scale 0 is treated as 1.
|
||||
type Register struct {
|
||||
Name string `toml:"name"`
|
||||
Kind string `toml:"kind"`
|
||||
Address uint16 `toml:"address"`
|
||||
Encoding string `toml:"encoding"`
|
||||
WordOrder string `toml:"word_order"`
|
||||
Unit string `toml:"unit"`
|
||||
Scale float64 `toml:"scale"`
|
||||
Offset float64 `toml:"offset"`
|
||||
Min float64 `toml:"min"`
|
||||
Max float64 `toml:"max"`
|
||||
Writable bool `toml:"writable"`
|
||||
Description string `toml:"description"`
|
||||
}
|
||||
|
||||
// register kinds.
|
||||
const (
|
||||
kindHolding = "holding"
|
||||
kindInput = "input"
|
||||
kindCoil = "coil"
|
||||
kindDiscrete = "discrete"
|
||||
)
|
||||
|
||||
// isBool reports whether the register addresses a single-bit space.
|
||||
func (r Register) isBool() bool {
|
||||
return r.kind() == kindCoil || r.kind() == kindDiscrete
|
||||
}
|
||||
|
||||
func (r Register) kind() string {
|
||||
if r.Kind == "" {
|
||||
return kindHolding
|
||||
}
|
||||
return strings.ToLower(r.Kind)
|
||||
}
|
||||
|
||||
func (r Register) encoding() string {
|
||||
if r.Encoding == "" {
|
||||
return "uint16"
|
||||
}
|
||||
return strings.ToLower(r.Encoding)
|
||||
}
|
||||
|
||||
func (r Register) littleWordOrder() bool {
|
||||
return strings.ToLower(r.WordOrder) == "little"
|
||||
}
|
||||
|
||||
func (r Register) scale() float64 {
|
||||
if r.Scale == 0 {
|
||||
return 1
|
||||
}
|
||||
return r.Scale
|
||||
}
|
||||
|
||||
// wordCount returns the number of 16-bit registers the encoding occupies.
|
||||
func (r Register) wordCount() (int, error) {
|
||||
switch r.encoding() {
|
||||
case "uint16", "int16":
|
||||
return 1, nil
|
||||
case "uint32", "int32", "float32":
|
||||
return 2, nil
|
||||
case "float64":
|
||||
return 4, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("modbus: unknown encoding %q", r.Encoding)
|
||||
}
|
||||
}
|
||||
|
||||
// dataType maps the register to a datasource value type.
|
||||
func (r Register) dataType() datasource.DataType {
|
||||
if r.isBool() {
|
||||
return datasource.TypeBool
|
||||
}
|
||||
// Integer encodings with no fractional scaling stay integers; anything
|
||||
// scaled, offset, or float-encoded becomes a float64.
|
||||
switch r.encoding() {
|
||||
case "float32", "float64":
|
||||
return datasource.TypeFloat64
|
||||
default:
|
||||
if r.scale() == 1 && r.Offset == 0 {
|
||||
return datasource.TypeInt64
|
||||
}
|
||||
return datasource.TypeFloat64
|
||||
}
|
||||
}
|
||||
|
||||
// writable reports whether writes are permitted. Input registers and discrete
|
||||
// inputs are read-only regardless of the Writable flag.
|
||||
func (r Register) writable() bool {
|
||||
switch r.kind() {
|
||||
case kindInput, kindDiscrete:
|
||||
return false
|
||||
default:
|
||||
return r.Writable
|
||||
}
|
||||
}
|
||||
|
||||
// metadata builds the datasource.Metadata for this register under signal name
|
||||
// "device:register".
|
||||
func (r Register) metadata(device string) datasource.Metadata {
|
||||
return datasource.Metadata{
|
||||
Name: device + ":" + r.Name,
|
||||
Type: r.dataType(),
|
||||
Unit: r.Unit,
|
||||
Description: r.Description,
|
||||
DisplayLow: r.Min,
|
||||
DisplayHigh: r.Max,
|
||||
DriveLow: r.Min,
|
||||
DriveHigh: r.Max,
|
||||
Writable: r.writable(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package modbus
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
// orderWords returns the registers in big-word-first order, reversing them when
|
||||
// the register declares little word order. The slice is copied so the caller's
|
||||
// data is left untouched.
|
||||
func (r Register) orderWords(words []uint16) []uint16 {
|
||||
if !r.littleWordOrder() {
|
||||
return words
|
||||
}
|
||||
out := make([]uint16, len(words))
|
||||
for i, w := range words {
|
||||
out[len(words)-1-i] = w
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// decode converts the raw registers into the register's numeric value and
|
||||
// applies scale/offset. The result type is int64 or float64 per dataType.
|
||||
func (r Register) decode(words []uint16) (any, error) {
|
||||
w := r.orderWords(words)
|
||||
var raw float64
|
||||
var rawInt int64
|
||||
switch r.encoding() {
|
||||
case "uint16":
|
||||
rawInt = int64(w[0])
|
||||
raw = float64(w[0])
|
||||
case "int16":
|
||||
rawInt = int64(int16(w[0]))
|
||||
raw = float64(int16(w[0]))
|
||||
case "uint32":
|
||||
u := uint32(w[0])<<16 | uint32(w[1])
|
||||
rawInt = int64(u)
|
||||
raw = float64(u)
|
||||
case "int32":
|
||||
u := uint32(w[0])<<16 | uint32(w[1])
|
||||
rawInt = int64(int32(u))
|
||||
raw = float64(int32(u))
|
||||
case "float32":
|
||||
u := uint32(w[0])<<16 | uint32(w[1])
|
||||
raw = float64(math.Float32frombits(u))
|
||||
case "float64":
|
||||
u := uint64(w[0])<<48 | uint64(w[1])<<32 | uint64(w[2])<<16 | uint64(w[3])
|
||||
raw = math.Float64frombits(u)
|
||||
default:
|
||||
return nil, fmt.Errorf("modbus: unknown encoding %q", r.Encoding)
|
||||
}
|
||||
|
||||
// Integer fast-path: no scaling/offset, integer encoding.
|
||||
if r.scale() == 1 && r.Offset == 0 {
|
||||
switch r.encoding() {
|
||||
case "uint16", "int16", "uint32", "int32":
|
||||
return rawInt, nil
|
||||
}
|
||||
}
|
||||
return raw*r.scale() + r.Offset, nil
|
||||
}
|
||||
|
||||
// encode converts a value destined for a Write back into raw registers,
|
||||
// inverting scale/offset. Only the holding-register encodings are writable.
|
||||
func (r Register) encode(value float64) ([]uint16, error) {
|
||||
v := (value - r.Offset) / r.scale()
|
||||
var words []uint16
|
||||
switch r.encoding() {
|
||||
case "uint16":
|
||||
words = []uint16{uint16(int64(math.Round(v)))}
|
||||
case "int16":
|
||||
words = []uint16{uint16(int16(int64(math.Round(v))))}
|
||||
case "uint32":
|
||||
u := uint32(int64(math.Round(v)))
|
||||
words = []uint16{uint16(u >> 16), uint16(u)}
|
||||
case "int32":
|
||||
u := uint32(int32(int64(math.Round(v))))
|
||||
words = []uint16{uint16(u >> 16), uint16(u)}
|
||||
case "float32":
|
||||
u := math.Float32bits(float32(v))
|
||||
words = []uint16{uint16(u >> 16), uint16(u)}
|
||||
case "float64":
|
||||
u := math.Float64bits(v)
|
||||
words = []uint16{uint16(u >> 48), uint16(u >> 32), uint16(u >> 16), uint16(u)}
|
||||
default:
|
||||
return nil, fmt.Errorf("modbus: unknown encoding %q", r.Encoding)
|
||||
}
|
||||
return r.orderWords(words), nil
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package modbus
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/uopi/uopi/internal/datasource"
|
||||
)
|
||||
|
||||
func TestDecodeEncodings(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
reg Register
|
||||
words []uint16
|
||||
want any
|
||||
}{
|
||||
{"uint16", Register{Encoding: "uint16"}, []uint16{42}, int64(42)},
|
||||
{"int16 neg", Register{Encoding: "int16"}, []uint16{0xFFFF}, int64(-1)},
|
||||
{"uint32", Register{Encoding: "uint32"}, []uint16{0x0001, 0x0000}, int64(65536)},
|
||||
{"int32 neg", Register{Encoding: "int32"}, []uint16{0xFFFF, 0xFFFF}, int64(-1)},
|
||||
{"scaled", Register{Encoding: "int16", Scale: 0.1}, []uint16{235}, 23.5},
|
||||
{"float32", Register{Encoding: "float32"}, f32Words(3.5), 3.5},
|
||||
{"float64", Register{Encoding: "float64"}, f64Words(2.25), 2.25},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got, err := tc.reg.decode(tc.words)
|
||||
if err != nil {
|
||||
t.Errorf("%s: decode error %v", tc.name, err)
|
||||
continue
|
||||
}
|
||||
switch w := tc.want.(type) {
|
||||
case int64:
|
||||
if got != w {
|
||||
t.Errorf("%s: got %v (%T), want %v", tc.name, got, got, w)
|
||||
}
|
||||
case float64:
|
||||
gf, ok := got.(float64)
|
||||
if !ok || math.Abs(gf-w) > 1e-6 {
|
||||
t.Errorf("%s: got %v (%T), want %v", tc.name, got, got, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWordOrder(t *testing.T) {
|
||||
big := Register{Encoding: "uint32", WordOrder: "big"}
|
||||
little := Register{Encoding: "uint32", WordOrder: "little"}
|
||||
words := []uint16{0x0001, 0x0002} // big: 0x00010002, little reverses to 0x00020001
|
||||
gb, _ := big.decode(words)
|
||||
gl, _ := little.decode(words)
|
||||
if gb != int64(0x00010002) {
|
||||
t.Errorf("big = %v, want %d", gb, 0x00010002)
|
||||
}
|
||||
if gl != int64(0x00020001) {
|
||||
t.Errorf("little = %v, want %d", gl, 0x00020001)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeRoundTrip(t *testing.T) {
|
||||
for _, enc := range []string{"uint16", "int16", "uint32", "int32", "float32", "float64"} {
|
||||
reg := Register{Encoding: enc}
|
||||
words, err := reg.encode(123)
|
||||
if err != nil {
|
||||
t.Fatalf("%s encode: %v", enc, err)
|
||||
}
|
||||
got, err := reg.decode(words)
|
||||
if err != nil {
|
||||
t.Fatalf("%s decode: %v", enc, err)
|
||||
}
|
||||
var f float64
|
||||
switch v := got.(type) {
|
||||
case int64:
|
||||
f = float64(v)
|
||||
case float64:
|
||||
f = v
|
||||
}
|
||||
if math.Abs(f-123) > 1e-3 {
|
||||
t.Errorf("%s round-trip = %v, want 123", enc, f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDataTypeAndWritable(t *testing.T) {
|
||||
if got := (Register{Kind: "coil"}).dataType(); got != datasource.TypeBool {
|
||||
t.Errorf("coil type = %v, want bool", got)
|
||||
}
|
||||
if got := (Register{Encoding: "float32"}).dataType(); got != datasource.TypeFloat64 {
|
||||
t.Errorf("float type = %v, want float64", got)
|
||||
}
|
||||
if got := (Register{Encoding: "uint16", Scale: 0.5}).dataType(); got != datasource.TypeFloat64 {
|
||||
t.Errorf("scaled int type = %v, want float64", got)
|
||||
}
|
||||
if got := (Register{Encoding: "uint16"}).dataType(); got != datasource.TypeInt64 {
|
||||
t.Errorf("plain int type = %v, want int64", got)
|
||||
}
|
||||
if (Register{Kind: "input", Writable: true}).writable() {
|
||||
t.Error("input register must be read-only")
|
||||
}
|
||||
if (Register{Kind: "discrete", Writable: true}).writable() {
|
||||
t.Error("discrete input must be read-only")
|
||||
}
|
||||
if !(Register{Kind: "holding", Writable: true}).writable() {
|
||||
t.Error("writable holding should be writable")
|
||||
}
|
||||
}
|
||||
|
||||
func f32Words(v float32) []uint16 {
|
||||
u := math.Float32bits(v)
|
||||
return []uint16{uint16(u >> 16), uint16(u)}
|
||||
}
|
||||
|
||||
func f64Words(v float64) []uint16 {
|
||||
u := math.Float64bits(v)
|
||||
return []uint16{uint16(u >> 48), uint16(u >> 32), uint16(u >> 16), uint16(u)}
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
// Package modbus implements a Modbus TCP data source. Each configured device is
|
||||
// polled over its own TCP connection; registers are exposed as signals named
|
||||
// "device:register". Reads use the holding/input/coil/discrete function codes;
|
||||
// writable holding registers and coils accept Write.
|
||||
package modbus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/uopi/uopi/internal/datasource"
|
||||
)
|
||||
|
||||
const defaultPollInterval = time.Second
|
||||
|
||||
// deviceClient bundles a parsed device with its wire client and register lookup.
|
||||
type deviceClient struct {
|
||||
dev Device
|
||||
cli *client
|
||||
byName map[string]Register
|
||||
}
|
||||
|
||||
// Modbus is a datasource.DataSource backed by one or more Modbus TCP devices.
|
||||
type Modbus struct {
|
||||
pollInterval time.Duration
|
||||
devices map[string]*deviceClient // device name → client
|
||||
signals map[string]signalRef // "device:register" → ref
|
||||
}
|
||||
|
||||
type signalRef struct {
|
||||
device string
|
||||
reg Register
|
||||
}
|
||||
|
||||
// New builds a Modbus source from config. It does not dial; connections are
|
||||
// established lazily on first poll/write.
|
||||
func New(cfg Config) (*Modbus, error) {
|
||||
poll := time.Duration(cfg.PollIntervalMs) * time.Millisecond
|
||||
if poll <= 0 {
|
||||
poll = defaultPollInterval
|
||||
}
|
||||
m := &Modbus{
|
||||
pollInterval: poll,
|
||||
devices: make(map[string]*deviceClient),
|
||||
signals: make(map[string]signalRef),
|
||||
}
|
||||
for _, dev := range cfg.Devices {
|
||||
if dev.Name == "" || dev.Address == "" {
|
||||
return nil, fmt.Errorf("modbus: device needs name and address")
|
||||
}
|
||||
if _, dup := m.devices[dev.Name]; dup {
|
||||
return nil, fmt.Errorf("modbus: duplicate device %q", dev.Name)
|
||||
}
|
||||
addr := dev.Address
|
||||
if !strings.Contains(addr, ":") {
|
||||
addr += ":502"
|
||||
}
|
||||
dc := &deviceClient{
|
||||
dev: dev,
|
||||
cli: newClient(addr, time.Duration(dev.TimeoutMs)*time.Millisecond),
|
||||
byName: make(map[string]Register),
|
||||
}
|
||||
for _, reg := range dev.Registers {
|
||||
if reg.Name == "" {
|
||||
return nil, fmt.Errorf("modbus: device %q has a register with no name", dev.Name)
|
||||
}
|
||||
if _, err := reg.wordCount(); !reg.isBool() && err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, dup := dc.byName[reg.Name]; dup {
|
||||
return nil, fmt.Errorf("modbus: device %q duplicate register %q", dev.Name, reg.Name)
|
||||
}
|
||||
dc.byName[reg.Name] = reg
|
||||
m.signals[dev.Name+":"+reg.Name] = signalRef{device: dev.Name, reg: reg}
|
||||
}
|
||||
m.devices[dev.Name] = dc
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// Name implements datasource.DataSource.
|
||||
func (m *Modbus) Name() string { return "modbus" }
|
||||
|
||||
// Connect is a no-op; TCP connections are dialled lazily per device.
|
||||
func (m *Modbus) Connect(_ context.Context) error { return nil }
|
||||
|
||||
// ListSignals returns metadata for every configured register.
|
||||
func (m *Modbus) ListSignals(_ context.Context) ([]datasource.Metadata, error) {
|
||||
out := make([]datasource.Metadata, 0, len(m.signals))
|
||||
for _, ref := range m.signals {
|
||||
out = append(out, ref.reg.metadata(ref.device))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetMetadata returns metadata for one signal.
|
||||
func (m *Modbus) GetMetadata(_ context.Context, signal string) (datasource.Metadata, error) {
|
||||
ref, ok := m.signals[signal]
|
||||
if !ok {
|
||||
return datasource.Metadata{}, datasource.ErrNotFound
|
||||
}
|
||||
return ref.reg.metadata(ref.device), nil
|
||||
}
|
||||
|
||||
// readSignal performs one synchronous read of a register's current value.
|
||||
func (m *Modbus) readSignal(ref signalRef) (datasource.Value, error) {
|
||||
dc := m.devices[ref.device]
|
||||
reg := ref.reg
|
||||
now := time.Now()
|
||||
|
||||
if reg.isBool() {
|
||||
fc := byte(fcReadCoils)
|
||||
if reg.kind() == kindDiscrete {
|
||||
fc = fcReadDiscrete
|
||||
}
|
||||
bits, err := dc.cli.readBits(dc.dev.UnitID, fc, reg.Address, 1)
|
||||
if err != nil {
|
||||
return datasource.Value{}, err
|
||||
}
|
||||
return datasource.Value{Timestamp: now, Data: bits[0], Quality: datasource.QualityGood}, nil
|
||||
}
|
||||
|
||||
count, err := reg.wordCount()
|
||||
if err != nil {
|
||||
return datasource.Value{}, err
|
||||
}
|
||||
fc := byte(fcReadHolding)
|
||||
if reg.kind() == kindInput {
|
||||
fc = fcReadInput
|
||||
}
|
||||
words, err := dc.cli.readRegisters(dc.dev.UnitID, fc, reg.Address, uint16(count))
|
||||
if err != nil {
|
||||
return datasource.Value{}, err
|
||||
}
|
||||
data, err := reg.decode(words)
|
||||
if err != nil {
|
||||
return datasource.Value{}, err
|
||||
}
|
||||
return datasource.Value{Timestamp: now, Data: data, Quality: datasource.QualityGood}, nil
|
||||
}
|
||||
|
||||
// Subscribe polls the register at the configured interval and pushes values
|
||||
// into ch. On a read error a QualityBad value is emitted and polling continues.
|
||||
func (m *Modbus) Subscribe(ctx context.Context, signal string, ch chan<- datasource.Value) (datasource.CancelFunc, error) {
|
||||
ref, ok := m.signals[signal]
|
||||
if !ok {
|
||||
return nil, datasource.ErrNotFound
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
go func() {
|
||||
ticker := time.NewTicker(m.pollInterval)
|
||||
defer ticker.Stop()
|
||||
emit := func() {
|
||||
v, err := m.readSignal(ref)
|
||||
if err != nil {
|
||||
v = datasource.Value{Timestamp: time.Now(), Quality: datasource.QualityBad}
|
||||
}
|
||||
select {
|
||||
case ch <- v:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}
|
||||
emit() // first reading without waiting a full interval
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
emit()
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
return datasource.CancelFunc(cancel), nil
|
||||
}
|
||||
|
||||
// Write sets a writable holding register or coil.
|
||||
func (m *Modbus) Write(_ context.Context, signal string, value any) error {
|
||||
ref, ok := m.signals[signal]
|
||||
if !ok {
|
||||
return datasource.ErrNotFound
|
||||
}
|
||||
reg := ref.reg
|
||||
if !reg.writable() {
|
||||
return datasource.ErrNotWritable
|
||||
}
|
||||
dc := m.devices[ref.device]
|
||||
|
||||
if reg.isBool() {
|
||||
on, err := toBool(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return dc.cli.writeSingleCoil(dc.dev.UnitID, reg.Address, on)
|
||||
}
|
||||
|
||||
f, err := toFloat(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
words, err := reg.encode(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(words) == 1 {
|
||||
return dc.cli.writeSingleRegister(dc.dev.UnitID, reg.Address, words[0])
|
||||
}
|
||||
return dc.cli.writeMultipleRegisters(dc.dev.UnitID, reg.Address, words)
|
||||
}
|
||||
|
||||
// History is unavailable for Modbus devices.
|
||||
func (m *Modbus) History(_ context.Context, _ string, _, _ time.Time, _ int) ([]datasource.Value, error) {
|
||||
return nil, datasource.ErrHistoryUnavailable
|
||||
}
|
||||
|
||||
// Close tears down every device connection.
|
||||
func (m *Modbus) Close() {
|
||||
for _, dc := range m.devices {
|
||||
dc.cli.close()
|
||||
}
|
||||
}
|
||||
|
||||
func toFloat(v any) (float64, error) {
|
||||
switch x := v.(type) {
|
||||
case float64:
|
||||
return x, nil
|
||||
case float32:
|
||||
return float64(x), nil
|
||||
case int:
|
||||
return float64(x), nil
|
||||
case int64:
|
||||
return float64(x), nil
|
||||
case bool:
|
||||
if x {
|
||||
return 1, nil
|
||||
}
|
||||
return 0, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("modbus: cannot write value of type %T", v)
|
||||
}
|
||||
}
|
||||
|
||||
func toBool(v any) (bool, error) {
|
||||
switch x := v.(type) {
|
||||
case bool:
|
||||
return x, nil
|
||||
case float64:
|
||||
return x != 0, nil
|
||||
case int:
|
||||
return x != 0, nil
|
||||
case int64:
|
||||
return x != 0, nil
|
||||
default:
|
||||
return false, fmt.Errorf("modbus: cannot write bool value of type %T", v)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
package modbus
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"io"
|
||||
"net"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/uopi/uopi/internal/datasource"
|
||||
)
|
||||
|
||||
// mockServer is an in-process Modbus TCP slave for tests. It serves a small
|
||||
// register/coil store and records writes. Only the function codes exercised by
|
||||
// the data source are implemented.
|
||||
type mockServer struct {
|
||||
ln net.Listener
|
||||
|
||||
mu sync.Mutex
|
||||
holding map[uint16]uint16
|
||||
input map[uint16]uint16
|
||||
coils map[uint16]bool
|
||||
discrete map[uint16]bool
|
||||
lastWrite []uint16 // registers from the most recent write
|
||||
}
|
||||
|
||||
func newMockServer(t *testing.T) *mockServer {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
s := &mockServer{
|
||||
ln: ln,
|
||||
holding: map[uint16]uint16{},
|
||||
input: map[uint16]uint16{},
|
||||
coils: map[uint16]bool{},
|
||||
discrete: map[uint16]bool{},
|
||||
}
|
||||
go s.serve()
|
||||
t.Cleanup(func() { ln.Close() })
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *mockServer) addr() string { return s.ln.Addr().String() }
|
||||
|
||||
func (s *mockServer) serve() {
|
||||
for {
|
||||
conn, err := s.ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go s.handle(conn)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *mockServer) handle(conn net.Conn) {
|
||||
defer conn.Close()
|
||||
for {
|
||||
head := make([]byte, 7)
|
||||
if _, err := io.ReadFull(conn, head); err != nil {
|
||||
return
|
||||
}
|
||||
tx := binary.BigEndian.Uint16(head[0:])
|
||||
length := binary.BigEndian.Uint16(head[4:])
|
||||
body := make([]byte, length-1)
|
||||
if _, err := io.ReadFull(conn, body); err != nil {
|
||||
return
|
||||
}
|
||||
resp := s.respond(body)
|
||||
out := make([]byte, 7+len(resp))
|
||||
binary.BigEndian.PutUint16(out[0:], tx)
|
||||
binary.BigEndian.PutUint16(out[2:], 0)
|
||||
binary.BigEndian.PutUint16(out[4:], uint16(1+len(resp)))
|
||||
out[6] = head[6]
|
||||
copy(out[7:], resp)
|
||||
if _, err := conn.Write(out); err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *mockServer) respond(pdu []byte) []byte {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
fc := pdu[0]
|
||||
switch fc {
|
||||
case fcReadHolding, fcReadInput:
|
||||
addr := binary.BigEndian.Uint16(pdu[1:])
|
||||
qty := binary.BigEndian.Uint16(pdu[3:])
|
||||
out := []byte{fc, byte(qty * 2)}
|
||||
src := s.holding
|
||||
if fc == fcReadInput {
|
||||
src = s.input
|
||||
}
|
||||
for i := uint16(0); i < qty; i++ {
|
||||
out = binary.BigEndian.AppendUint16(out, src[addr+i])
|
||||
}
|
||||
return out
|
||||
case fcReadCoils, fcReadDiscrete:
|
||||
addr := binary.BigEndian.Uint16(pdu[1:])
|
||||
qty := binary.BigEndian.Uint16(pdu[3:])
|
||||
nbytes := (int(qty) + 7) / 8
|
||||
out := []byte{fc, byte(nbytes)}
|
||||
bits := make([]byte, nbytes)
|
||||
src := s.coils
|
||||
if fc == fcReadDiscrete {
|
||||
src = s.discrete
|
||||
}
|
||||
for i := uint16(0); i < qty; i++ {
|
||||
if src[addr+i] {
|
||||
bits[i/8] |= 1 << (i % 8)
|
||||
}
|
||||
}
|
||||
return append(out, bits...)
|
||||
case fcWriteSingleReg:
|
||||
addr := binary.BigEndian.Uint16(pdu[1:])
|
||||
val := binary.BigEndian.Uint16(pdu[3:])
|
||||
s.holding[addr] = val
|
||||
s.lastWrite = []uint16{val}
|
||||
return pdu // echo
|
||||
case fcWriteSingleCoil:
|
||||
addr := binary.BigEndian.Uint16(pdu[1:])
|
||||
s.coils[addr] = binary.BigEndian.Uint16(pdu[3:]) == 0xFF00
|
||||
return pdu
|
||||
case fcWriteMultipleRegs:
|
||||
addr := binary.BigEndian.Uint16(pdu[1:])
|
||||
qty := binary.BigEndian.Uint16(pdu[3:])
|
||||
s.lastWrite = nil
|
||||
for i := uint16(0); i < qty; i++ {
|
||||
v := binary.BigEndian.Uint16(pdu[6+i*2:])
|
||||
s.holding[addr+i] = v
|
||||
s.lastWrite = append(s.lastWrite, v)
|
||||
}
|
||||
return append([]byte{fc}, pdu[1:5]...)
|
||||
default:
|
||||
return []byte{fc | 0x80, 0x01}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *mockServer) setHolding(addr, val uint16) {
|
||||
s.mu.Lock()
|
||||
s.holding[addr] = val
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *mockServer) setInput(addr, val uint16) {
|
||||
s.mu.Lock()
|
||||
s.input[addr] = val
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *mockServer) setDiscrete(addr uint16, on bool) {
|
||||
s.mu.Lock()
|
||||
s.discrete[addr] = on
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func testConfig(addr string) Config {
|
||||
return Config{
|
||||
Enabled: true,
|
||||
PollIntervalMs: 20,
|
||||
Devices: []Device{{
|
||||
Name: "dev",
|
||||
Address: addr,
|
||||
UnitID: 1,
|
||||
Registers: []Register{
|
||||
{Name: "temp", Kind: "holding", Address: 10, Encoding: "int16", Scale: 0.1, Unit: "C", Writable: true},
|
||||
{Name: "count", Kind: "holding", Address: 20, Encoding: "uint16"},
|
||||
{Name: "big", Kind: "input", Address: 30, Encoding: "uint32"},
|
||||
{Name: "flag", Kind: "discrete", Address: 5},
|
||||
{Name: "relay", Kind: "coil", Address: 6, Writable: true},
|
||||
{Name: "sp", Kind: "holding", Address: 40, Encoding: "float32", Writable: true},
|
||||
},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadRegisters(t *testing.T) {
|
||||
srv := newMockServer(t)
|
||||
srv.setHolding(10, 235) // int16, scale 0.1 → 23.5
|
||||
srv.setHolding(20, 7)
|
||||
srv.setInput(30, 0)
|
||||
srv.setInput(31, 1000) // uint32 big-word-first: low word at 31
|
||||
srv.setDiscrete(5, true)
|
||||
|
||||
m, err := New(testConfig(srv.addr()))
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
defer m.Close()
|
||||
|
||||
temp, err := m.readSignal(m.signals["dev:temp"])
|
||||
if err != nil {
|
||||
t.Fatalf("read temp: %v", err)
|
||||
}
|
||||
if f, ok := temp.Data.(float64); !ok || f < 23.49 || f > 23.51 {
|
||||
t.Errorf("temp = %v (%T), want 23.5", temp.Data, temp.Data)
|
||||
}
|
||||
|
||||
count, err := m.readSignal(m.signals["dev:count"])
|
||||
if err != nil {
|
||||
t.Fatalf("read count: %v", err)
|
||||
}
|
||||
if v, ok := count.Data.(int64); !ok || v != 7 {
|
||||
t.Errorf("count = %v (%T), want int64 7", count.Data, count.Data)
|
||||
}
|
||||
|
||||
big, err := m.readSignal(m.signals["dev:big"])
|
||||
if err != nil {
|
||||
t.Fatalf("read big: %v", err)
|
||||
}
|
||||
if v, ok := big.Data.(int64); !ok || v != 1000 {
|
||||
t.Errorf("big = %v (%T), want int64 1000", big.Data, big.Data)
|
||||
}
|
||||
|
||||
flag, err := m.readSignal(m.signals["dev:flag"])
|
||||
if err != nil {
|
||||
t.Fatalf("read flag: %v", err)
|
||||
}
|
||||
if b, ok := flag.Data.(bool); !ok || !b {
|
||||
t.Errorf("flag = %v, want true", flag.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteRoundTrip(t *testing.T) {
|
||||
srv := newMockServer(t)
|
||||
m, err := New(testConfig(srv.addr()))
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
defer m.Close()
|
||||
ctx := context.Background()
|
||||
|
||||
// Scaled int16: writing 23.5 with scale 0.1 should store raw 235.
|
||||
if err := m.Write(ctx, "dev:temp", 23.5); err != nil {
|
||||
t.Fatalf("write temp: %v", err)
|
||||
}
|
||||
srv.mu.Lock()
|
||||
raw := srv.holding[10]
|
||||
srv.mu.Unlock()
|
||||
if raw != 235 {
|
||||
t.Errorf("holding[10] = %d, want 235", raw)
|
||||
}
|
||||
|
||||
// Coil write.
|
||||
if err := m.Write(ctx, "dev:relay", true); err != nil {
|
||||
t.Fatalf("write relay: %v", err)
|
||||
}
|
||||
srv.mu.Lock()
|
||||
on := srv.coils[6]
|
||||
srv.mu.Unlock()
|
||||
if !on {
|
||||
t.Error("coil 6 not set")
|
||||
}
|
||||
|
||||
// float32 multi-register write.
|
||||
if err := m.Write(ctx, "dev:sp", 12.5); err != nil {
|
||||
t.Fatalf("write sp: %v", err)
|
||||
}
|
||||
got, err := m.readSignal(m.signals["dev:sp"])
|
||||
if err != nil {
|
||||
t.Fatalf("read sp: %v", err)
|
||||
}
|
||||
if f, ok := got.Data.(float64); !ok || f < 12.49 || f > 12.51 {
|
||||
t.Errorf("sp = %v, want 12.5", got.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteErrors(t *testing.T) {
|
||||
srv := newMockServer(t)
|
||||
m, _ := New(testConfig(srv.addr()))
|
||||
defer m.Close()
|
||||
ctx := context.Background()
|
||||
|
||||
if err := m.Write(ctx, "dev:missing", 1); err != datasource.ErrNotFound {
|
||||
t.Errorf("missing write err = %v, want ErrNotFound", err)
|
||||
}
|
||||
// Input register is read-only.
|
||||
if err := m.Write(ctx, "dev:big", 1); err != datasource.ErrNotWritable {
|
||||
t.Errorf("input write err = %v, want ErrNotWritable", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscribe(t *testing.T) {
|
||||
srv := newMockServer(t)
|
||||
srv.setHolding(20, 42)
|
||||
m, _ := New(testConfig(srv.addr()))
|
||||
defer m.Close()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
ch := make(chan datasource.Value, 4)
|
||||
stop, err := m.Subscribe(ctx, "dev:count", ch)
|
||||
if err != nil {
|
||||
t.Fatalf("subscribe: %v", err)
|
||||
}
|
||||
defer stop()
|
||||
|
||||
select {
|
||||
case v := <-ch:
|
||||
if v.Quality != datasource.QualityGood {
|
||||
t.Errorf("quality = %v, want good", v.Quality)
|
||||
}
|
||||
if iv, ok := v.Data.(int64); !ok || iv != 42 {
|
||||
t.Errorf("first value = %v, want 42", v.Data)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out waiting for first value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscribeBadQualityOnError(t *testing.T) {
|
||||
// Point at a closed port so reads fail; expect QualityBad, not a hang.
|
||||
cfg := testConfig("127.0.0.1:1") // port 1: connection refused
|
||||
m, _ := New(cfg)
|
||||
defer m.Close()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
ch := make(chan datasource.Value, 1)
|
||||
stop, err := m.Subscribe(ctx, "dev:count", ch)
|
||||
if err != nil {
|
||||
t.Fatalf("subscribe: %v", err)
|
||||
}
|
||||
defer stop()
|
||||
|
||||
select {
|
||||
case v := <-ch:
|
||||
if v.Quality != datasource.QualityBad {
|
||||
t.Errorf("quality = %v, want bad", v.Quality)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewValidation(t *testing.T) {
|
||||
if _, err := New(Config{Devices: []Device{{Name: "", Address: "x"}}}); err == nil {
|
||||
t.Error("expected error for missing device name")
|
||||
}
|
||||
if _, err := New(Config{Devices: []Device{{Name: "a", Address: "x"}, {Name: "a", Address: "y"}}}); err == nil {
|
||||
t.Error("expected error for duplicate device")
|
||||
}
|
||||
if _, err := New(Config{Devices: []Device{{Name: "a", Address: "x", Registers: []Register{{Name: "r", Encoding: "bogus"}}}}}); err == nil {
|
||||
t.Error("expected error for bad encoding")
|
||||
}
|
||||
if _, err := New(Config{Devices: []Device{{Name: "a", Address: "x", Registers: []Register{{Name: "r"}, {Name: "r"}}}}}); err == nil {
|
||||
t.Error("expected error for duplicate register")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscribeUnknownSignal(t *testing.T) {
|
||||
m, _ := New(testConfig("127.0.0.1:502"))
|
||||
defer m.Close()
|
||||
if _, err := m.Subscribe(context.Background(), "nope", nil); err != datasource.ErrNotFound {
|
||||
t.Errorf("err = %v, want ErrNotFound", err)
|
||||
}
|
||||
if _, err := m.GetMetadata(context.Background(), "nope"); err != datasource.ErrNotFound {
|
||||
t.Errorf("meta err = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package scpi
|
||||
|
||||
import "github.com/uopi/uopi/internal/datasource"
|
||||
|
||||
// Config is the [datasource.scpi] section. Each instrument is polled
|
||||
// independently over its own TCP socket.
|
||||
type Config struct {
|
||||
Enabled bool `toml:"enabled"`
|
||||
// PollIntervalMs is the default polling period for channels that do not
|
||||
// override it. Zero → 1000 ms.
|
||||
PollIntervalMs int `toml:"poll_interval_ms"`
|
||||
Instruments []Instrument `toml:"instruments"`
|
||||
}
|
||||
|
||||
// Instrument is one SCPI device reachable over a raw TCP socket. Address is
|
||||
// "host:port"; the conventional SCPI-raw port 5025 is appended if absent.
|
||||
type Instrument struct {
|
||||
Name string `toml:"name"`
|
||||
// Transport selects the link type. "raw" (default) is line-based SCPI over
|
||||
// TCP. Reserved: "vxi11" (not yet implemented).
|
||||
Transport string `toml:"transport"`
|
||||
Address string `toml:"address"`
|
||||
TimeoutMs int `toml:"timeout_ms"`
|
||||
// Terminator is appended to every command. Empty → "\n".
|
||||
Terminator string `toml:"terminator"`
|
||||
Channels []Channel `toml:"channels"`
|
||||
}
|
||||
|
||||
// Channel maps a SCPI query/command pair onto a signal named
|
||||
// "instrument:channel".
|
||||
type Channel struct {
|
||||
Name string `toml:"name"`
|
||||
// Query is the SCPI command whose response is the channel value,
|
||||
// e.g. "MEAS:VOLT?". Required.
|
||||
Query string `toml:"query"`
|
||||
// WriteCmd is a printf-style template used by Write; "%v" is replaced with
|
||||
// the value, e.g. "VOLT %v". Empty → channel is read-only.
|
||||
WriteCmd string `toml:"write_cmd"`
|
||||
// Type is the value type: "float" (default), "string", "int", or "bool".
|
||||
Type string `toml:"type"`
|
||||
Unit string `toml:"unit"`
|
||||
Min float64 `toml:"min"`
|
||||
Max float64 `toml:"max"`
|
||||
PollIntervalMs int `toml:"poll_interval_ms"`
|
||||
Description string `toml:"description"`
|
||||
}
|
||||
|
||||
func (c Channel) dataType() datasource.DataType {
|
||||
switch c.Type {
|
||||
case "string":
|
||||
return datasource.TypeString
|
||||
case "int":
|
||||
return datasource.TypeInt64
|
||||
case "bool":
|
||||
return datasource.TypeBool
|
||||
default:
|
||||
return datasource.TypeFloat64
|
||||
}
|
||||
}
|
||||
|
||||
func (c Channel) writable() bool { return c.WriteCmd != "" }
|
||||
|
||||
func (c Channel) metadata(instrument string) datasource.Metadata {
|
||||
return datasource.Metadata{
|
||||
Name: instrument + ":" + c.Name,
|
||||
Type: c.dataType(),
|
||||
Unit: c.Unit,
|
||||
Description: c.Description,
|
||||
DisplayLow: c.Min,
|
||||
DisplayHigh: c.Max,
|
||||
DriveLow: c.Min,
|
||||
DriveHigh: c.Max,
|
||||
Writable: c.writable(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
// Package scpi implements a SCPI instrument data source. Each configured
|
||||
// instrument is reached over a raw TCP socket (line-based SCPI, the "SCPI raw" /
|
||||
// port 5025 convention); a VXI-11 transport is reserved for later. Channels are
|
||||
// exposed as signals named "instrument:channel" and polled at a configurable
|
||||
// interval. Channels with a write_cmd template accept Write.
|
||||
package scpi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/uopi/uopi/internal/datasource"
|
||||
)
|
||||
|
||||
const defaultPollInterval = time.Second
|
||||
|
||||
type instrumentConn struct {
|
||||
inst Instrument
|
||||
tr transport
|
||||
}
|
||||
|
||||
// Scpi is a datasource.DataSource backed by one or more SCPI instruments.
|
||||
type Scpi struct {
|
||||
pollInterval time.Duration
|
||||
instruments map[string]*instrumentConn
|
||||
signals map[string]signalRef // "instrument:channel" → ref
|
||||
}
|
||||
|
||||
type signalRef struct {
|
||||
instrument string
|
||||
ch Channel
|
||||
}
|
||||
|
||||
// New builds a SCPI source from config. It does not dial; connections are
|
||||
// established lazily on first poll/write.
|
||||
func New(cfg Config) (*Scpi, error) {
|
||||
poll := time.Duration(cfg.PollIntervalMs) * time.Millisecond
|
||||
if poll <= 0 {
|
||||
poll = defaultPollInterval
|
||||
}
|
||||
s := &Scpi{
|
||||
pollInterval: poll,
|
||||
instruments: make(map[string]*instrumentConn),
|
||||
signals: make(map[string]signalRef),
|
||||
}
|
||||
for _, inst := range cfg.Instruments {
|
||||
if inst.Name == "" || inst.Address == "" {
|
||||
return nil, fmt.Errorf("scpi: instrument needs name and address")
|
||||
}
|
||||
if _, dup := s.instruments[inst.Name]; dup {
|
||||
return nil, fmt.Errorf("scpi: duplicate instrument %q", inst.Name)
|
||||
}
|
||||
tr, err := newTransport(inst)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ic := &instrumentConn{inst: inst, tr: tr}
|
||||
for _, ch := range inst.Channels {
|
||||
if ch.Name == "" || ch.Query == "" {
|
||||
return nil, fmt.Errorf("scpi: instrument %q channel needs name and query", inst.Name)
|
||||
}
|
||||
key := inst.Name + ":" + ch.Name
|
||||
if _, dup := s.signals[key]; dup {
|
||||
return nil, fmt.Errorf("scpi: instrument %q duplicate channel %q", inst.Name, ch.Name)
|
||||
}
|
||||
s.signals[key] = signalRef{instrument: inst.Name, ch: ch}
|
||||
}
|
||||
s.instruments[inst.Name] = ic
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// newTransport selects the transport implementation for an instrument.
|
||||
func newTransport(inst Instrument) (transport, error) {
|
||||
addr := inst.Address
|
||||
switch strings.ToLower(inst.Transport) {
|
||||
case "", "raw":
|
||||
if !strings.Contains(addr, ":") {
|
||||
addr += ":5025"
|
||||
}
|
||||
return newRawSocket(addr, time.Duration(inst.TimeoutMs)*time.Millisecond, inst.Terminator), nil
|
||||
case "vxi11":
|
||||
return nil, fmt.Errorf("scpi: vxi11 transport not yet implemented")
|
||||
default:
|
||||
return nil, fmt.Errorf("scpi: unknown transport %q", inst.Transport)
|
||||
}
|
||||
}
|
||||
|
||||
// Name implements datasource.DataSource.
|
||||
func (s *Scpi) Name() string { return "scpi" }
|
||||
|
||||
// Connect is a no-op; connections are dialled lazily per instrument.
|
||||
func (s *Scpi) Connect(_ context.Context) error { return nil }
|
||||
|
||||
// ListSignals returns metadata for every configured channel.
|
||||
func (s *Scpi) ListSignals(_ context.Context) ([]datasource.Metadata, error) {
|
||||
out := make([]datasource.Metadata, 0, len(s.signals))
|
||||
for _, ref := range s.signals {
|
||||
out = append(out, ref.ch.metadata(ref.instrument))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// GetMetadata returns metadata for one signal.
|
||||
func (s *Scpi) GetMetadata(_ context.Context, signal string) (datasource.Metadata, error) {
|
||||
ref, ok := s.signals[signal]
|
||||
if !ok {
|
||||
return datasource.Metadata{}, datasource.ErrNotFound
|
||||
}
|
||||
return ref.ch.metadata(ref.instrument), nil
|
||||
}
|
||||
|
||||
// readSignal performs one synchronous query of a channel.
|
||||
func (s *Scpi) readSignal(ref signalRef) (datasource.Value, error) {
|
||||
ic := s.instruments[ref.instrument]
|
||||
resp, err := ic.tr.query(ref.ch.Query)
|
||||
if err != nil {
|
||||
return datasource.Value{}, err
|
||||
}
|
||||
data, err := parseValue(ref.ch.dataType(), resp)
|
||||
if err != nil {
|
||||
return datasource.Value{}, err
|
||||
}
|
||||
return datasource.Value{Timestamp: time.Now(), Data: data, Quality: datasource.QualityGood}, nil
|
||||
}
|
||||
|
||||
// Subscribe polls the channel at its configured interval (falling back to the
|
||||
// source default) and pushes values into ch. A query error emits QualityBad and
|
||||
// polling continues.
|
||||
func (s *Scpi) Subscribe(ctx context.Context, signal string, ch chan<- datasource.Value) (datasource.CancelFunc, error) {
|
||||
ref, ok := s.signals[signal]
|
||||
if !ok {
|
||||
return nil, datasource.ErrNotFound
|
||||
}
|
||||
interval := s.pollInterval
|
||||
if ref.ch.PollIntervalMs > 0 {
|
||||
interval = time.Duration(ref.ch.PollIntervalMs) * time.Millisecond
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
go func() {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
emit := func() {
|
||||
v, err := s.readSignal(ref)
|
||||
if err != nil {
|
||||
v = datasource.Value{Timestamp: time.Now(), Quality: datasource.QualityBad}
|
||||
}
|
||||
select {
|
||||
case ch <- v:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}
|
||||
emit()
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
emit()
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
return datasource.CancelFunc(cancel), nil
|
||||
}
|
||||
|
||||
// Write sends the channel's write_cmd template with the value substituted.
|
||||
func (s *Scpi) Write(_ context.Context, signal string, value any) error {
|
||||
ref, ok := s.signals[signal]
|
||||
if !ok {
|
||||
return datasource.ErrNotFound
|
||||
}
|
||||
if !ref.ch.writable() {
|
||||
return datasource.ErrNotWritable
|
||||
}
|
||||
ic := s.instruments[ref.instrument]
|
||||
cmd := formatWrite(ref.ch.WriteCmd, value)
|
||||
return ic.tr.write(cmd)
|
||||
}
|
||||
|
||||
// History is unavailable for SCPI instruments.
|
||||
func (s *Scpi) History(_ context.Context, _ string, _, _ time.Time, _ int) ([]datasource.Value, error) {
|
||||
return nil, datasource.ErrHistoryUnavailable
|
||||
}
|
||||
|
||||
// Close tears down every instrument connection.
|
||||
func (s *Scpi) Close() {
|
||||
for _, ic := range s.instruments {
|
||||
ic.tr.close()
|
||||
}
|
||||
}
|
||||
|
||||
// formatWrite substitutes value into the write template. A "%" in the template
|
||||
// is treated as a printf verb; otherwise the value is appended after a space.
|
||||
func formatWrite(tmpl string, value any) string {
|
||||
if strings.Contains(tmpl, "%") {
|
||||
return fmt.Sprintf(tmpl, value)
|
||||
}
|
||||
return fmt.Sprintf("%s %v", tmpl, value)
|
||||
}
|
||||
|
||||
// parseValue converts a raw SCPI response string into the channel's data type.
|
||||
func parseValue(t datasource.DataType, resp string) (any, error) {
|
||||
resp = strings.TrimSpace(resp)
|
||||
switch t {
|
||||
case datasource.TypeString:
|
||||
return resp, nil
|
||||
case datasource.TypeInt64:
|
||||
// Accept "12", "12.0", or scientific notation by going through float.
|
||||
f, err := strconv.ParseFloat(resp, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scpi: parse int %q: %w", resp, err)
|
||||
}
|
||||
return int64(f), nil
|
||||
case datasource.TypeBool:
|
||||
switch strings.ToUpper(resp) {
|
||||
case "1", "ON", "TRUE":
|
||||
return true, nil
|
||||
case "0", "OFF", "FALSE":
|
||||
return false, nil
|
||||
}
|
||||
f, err := strconv.ParseFloat(resp, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scpi: parse bool %q: %w", resp, err)
|
||||
}
|
||||
return f != 0, nil
|
||||
default:
|
||||
f, err := strconv.ParseFloat(resp, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scpi: parse float %q: %w", resp, err)
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
package scpi
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/uopi/uopi/internal/datasource"
|
||||
)
|
||||
|
||||
// mockInstrument is an in-process line-based SCPI server. It answers queries
|
||||
// from a fixed table and records commands that produce no response (writes).
|
||||
type mockInstrument struct {
|
||||
ln net.Listener
|
||||
|
||||
mu sync.Mutex
|
||||
answers map[string]string // query → response
|
||||
writes []string
|
||||
}
|
||||
|
||||
func newMockInstrument(t *testing.T) *mockInstrument {
|
||||
t.Helper()
|
||||
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
m := &mockInstrument{ln: ln, answers: map[string]string{}}
|
||||
go m.serve()
|
||||
t.Cleanup(func() { ln.Close() })
|
||||
return m
|
||||
}
|
||||
|
||||
func (m *mockInstrument) addr() string { return m.ln.Addr().String() }
|
||||
|
||||
func (m *mockInstrument) setAnswer(q, a string) {
|
||||
m.mu.Lock()
|
||||
m.answers[q] = a
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *mockInstrument) writeLog() []string {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return append([]string(nil), m.writes...)
|
||||
}
|
||||
|
||||
func (m *mockInstrument) serve() {
|
||||
for {
|
||||
conn, err := m.ln.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
go m.handle(conn)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *mockInstrument) handle(conn net.Conn) {
|
||||
defer conn.Close()
|
||||
br := bufio.NewReader(conn)
|
||||
for {
|
||||
line, err := br.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
cmd := strings.TrimRight(line, "\r\n")
|
||||
m.mu.Lock()
|
||||
if strings.HasSuffix(cmd, "?") {
|
||||
resp, ok := m.answers[cmd]
|
||||
if !ok {
|
||||
resp = "0"
|
||||
}
|
||||
m.mu.Unlock()
|
||||
conn.Write([]byte(resp + "\n"))
|
||||
continue
|
||||
}
|
||||
m.writes = append(m.writes, cmd)
|
||||
m.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func testCfg(addr string) Config {
|
||||
return Config{
|
||||
Enabled: true,
|
||||
PollIntervalMs: 20,
|
||||
Instruments: []Instrument{{
|
||||
Name: "dmm",
|
||||
Address: addr,
|
||||
Channels: []Channel{
|
||||
{Name: "volt", Query: "MEAS:VOLT?", WriteCmd: "VOLT %v", Type: "float", Unit: "V"},
|
||||
{Name: "id", Query: "*IDN?", Type: "string"},
|
||||
{Name: "n", Query: "COUNT?", Type: "int"},
|
||||
{Name: "out", Query: "OUTP?", WriteCmd: "OUTP", Type: "bool"},
|
||||
},
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryTypes(t *testing.T) {
|
||||
srv := newMockInstrument(t)
|
||||
srv.setAnswer("MEAS:VOLT?", "12.34")
|
||||
srv.setAnswer("*IDN?", "ACME,DMM,1,2.0")
|
||||
srv.setAnswer("COUNT?", "7")
|
||||
srv.setAnswer("OUTP?", "ON")
|
||||
|
||||
s, err := New(testCfg(srv.addr()))
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
defer s.Close()
|
||||
|
||||
v, err := s.readSignal(s.signals["dmm:volt"])
|
||||
if err != nil {
|
||||
t.Fatalf("read volt: %v", err)
|
||||
}
|
||||
if f, ok := v.Data.(float64); !ok || f < 12.33 || f > 12.35 {
|
||||
t.Errorf("volt = %v (%T), want 12.34", v.Data, v.Data)
|
||||
}
|
||||
|
||||
id, _ := s.readSignal(s.signals["dmm:id"])
|
||||
if id.Data != "ACME,DMM,1,2.0" {
|
||||
t.Errorf("id = %v", id.Data)
|
||||
}
|
||||
|
||||
n, _ := s.readSignal(s.signals["dmm:n"])
|
||||
if iv, ok := n.Data.(int64); !ok || iv != 7 {
|
||||
t.Errorf("n = %v (%T), want 7", n.Data, n.Data)
|
||||
}
|
||||
|
||||
out, _ := s.readSignal(s.signals["dmm:out"])
|
||||
if b, ok := out.Data.(bool); !ok || !b {
|
||||
t.Errorf("out = %v, want true", out.Data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrite(t *testing.T) {
|
||||
srv := newMockInstrument(t)
|
||||
s, _ := New(testCfg(srv.addr()))
|
||||
defer s.Close()
|
||||
ctx := context.Background()
|
||||
|
||||
if err := s.Write(ctx, "dmm:volt", 3.3); err != nil {
|
||||
t.Fatalf("write volt: %v", err)
|
||||
}
|
||||
// bool write uses a template with no verb → "OUTP <val>".
|
||||
if err := s.Write(ctx, "dmm:out", true); err != nil {
|
||||
t.Fatalf("write out: %v", err)
|
||||
}
|
||||
|
||||
// Give the server a moment to record both writes.
|
||||
deadline := time.Now().Add(time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if len(srv.writeLog()) >= 2 {
|
||||
break
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
got := srv.writeLog()
|
||||
if len(got) != 2 || got[0] != "VOLT 3.3" || got[1] != "OUTP true" {
|
||||
t.Errorf("writes = %v, want [VOLT 3.3, OUTP true]", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteErrors(t *testing.T) {
|
||||
srv := newMockInstrument(t)
|
||||
s, _ := New(testCfg(srv.addr()))
|
||||
defer s.Close()
|
||||
ctx := context.Background()
|
||||
|
||||
if err := s.Write(ctx, "dmm:missing", 1); err != datasource.ErrNotFound {
|
||||
t.Errorf("missing = %v, want ErrNotFound", err)
|
||||
}
|
||||
if err := s.Write(ctx, "dmm:id", 1); err != datasource.ErrNotWritable {
|
||||
t.Errorf("read-only = %v, want ErrNotWritable", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscribe(t *testing.T) {
|
||||
srv := newMockInstrument(t)
|
||||
srv.setAnswer("MEAS:VOLT?", "5.0")
|
||||
s, _ := New(testCfg(srv.addr()))
|
||||
defer s.Close()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
ch := make(chan datasource.Value, 4)
|
||||
stop, err := s.Subscribe(ctx, "dmm:volt", ch)
|
||||
if err != nil {
|
||||
t.Fatalf("subscribe: %v", err)
|
||||
}
|
||||
defer stop()
|
||||
|
||||
select {
|
||||
case v := <-ch:
|
||||
if v.Quality != datasource.QualityGood {
|
||||
t.Errorf("quality = %v", v.Quality)
|
||||
}
|
||||
if f, ok := v.Data.(float64); !ok || f != 5.0 {
|
||||
t.Errorf("value = %v, want 5.0", v.Data)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscribeBadQuality(t *testing.T) {
|
||||
cfg := testCfg("127.0.0.1:1") // refused
|
||||
s, _ := New(cfg)
|
||||
defer s.Close()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
ch := make(chan datasource.Value, 1)
|
||||
stop, _ := s.Subscribe(ctx, "dmm:volt", ch)
|
||||
defer stop()
|
||||
|
||||
select {
|
||||
case v := <-ch:
|
||||
if v.Quality != datasource.QualityBad {
|
||||
t.Errorf("quality = %v, want bad", v.Quality)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("timed out")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidation(t *testing.T) {
|
||||
if _, err := New(Config{Instruments: []Instrument{{Name: "", Address: "x"}}}); err == nil {
|
||||
t.Error("want error for missing name")
|
||||
}
|
||||
if _, err := New(Config{Instruments: []Instrument{{Name: "a", Address: "x"}, {Name: "a", Address: "y"}}}); err == nil {
|
||||
t.Error("want error for duplicate instrument")
|
||||
}
|
||||
if _, err := New(Config{Instruments: []Instrument{{Name: "a", Address: "x", Transport: "vxi11"}}}); err == nil {
|
||||
t.Error("want error for unimplemented vxi11 transport")
|
||||
}
|
||||
if _, err := New(Config{Instruments: []Instrument{{Name: "a", Address: "x", Transport: "bogus"}}}); err == nil {
|
||||
t.Error("want error for unknown transport")
|
||||
}
|
||||
if _, err := New(Config{Instruments: []Instrument{{Name: "a", Address: "x", Channels: []Channel{{Name: "c"}}}}}); err == nil {
|
||||
t.Error("want error for channel without query")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatWrite(t *testing.T) {
|
||||
if got := formatWrite("VOLT %v", 3.3); got != "VOLT 3.3" {
|
||||
t.Errorf("verb template = %q", got)
|
||||
}
|
||||
if got := formatWrite("OUTP", true); got != "OUTP true" {
|
||||
t.Errorf("plain template = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseValue(t *testing.T) {
|
||||
if v, _ := parseValue(datasource.TypeBool, "OFF"); v != false {
|
||||
t.Errorf("OFF = %v, want false", v)
|
||||
}
|
||||
if v, _ := parseValue(datasource.TypeBool, "2.0"); v != true {
|
||||
t.Errorf("2.0 bool = %v, want true", v)
|
||||
}
|
||||
if _, err := parseValue(datasource.TypeFloat64, "notnum"); err == nil {
|
||||
t.Error("want parse error for non-numeric float")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package scpi
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// transport is the request/response channel to an instrument. The raw-socket
|
||||
// implementation below speaks line-oriented SCPI over TCP (the common
|
||||
// "SCPI raw" / port 5025 convention). The interface is kept narrow so a VXI-11
|
||||
// (ONC-RPC) transport can be added later without touching the data source.
|
||||
type transport interface {
|
||||
// query sends cmd and returns the instrument's single-line response.
|
||||
query(cmd string) (string, error)
|
||||
// write sends cmd and does not wait for a response.
|
||||
write(cmd string) error
|
||||
close()
|
||||
}
|
||||
|
||||
// rawSocket is a line-based SCPI transport over a single TCP connection. The
|
||||
// connection is dialled lazily and dropped on any I/O error so the next call
|
||||
// reconnects. Calls are serialised by mu because SCPI is request/response.
|
||||
type rawSocket struct {
|
||||
addr string
|
||||
timeout time.Duration
|
||||
terminator string
|
||||
|
||||
mu sync.Mutex
|
||||
conn net.Conn
|
||||
br *bufio.Reader
|
||||
}
|
||||
|
||||
func newRawSocket(addr string, timeout time.Duration, terminator string) *rawSocket {
|
||||
if timeout <= 0 {
|
||||
timeout = 3 * time.Second
|
||||
}
|
||||
if terminator == "" {
|
||||
terminator = "\n"
|
||||
}
|
||||
return &rawSocket{addr: addr, timeout: timeout, terminator: terminator}
|
||||
}
|
||||
|
||||
func (s *rawSocket) dialLocked() error {
|
||||
if s.conn != nil {
|
||||
return nil
|
||||
}
|
||||
conn, err := net.DialTimeout("tcp", s.addr, s.timeout)
|
||||
if err != nil {
|
||||
return fmt.Errorf("scpi: dial %s: %w", s.addr, err)
|
||||
}
|
||||
s.conn = conn
|
||||
s.br = bufio.NewReader(conn)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *rawSocket) closeLocked() {
|
||||
if s.conn != nil {
|
||||
_ = s.conn.Close()
|
||||
s.conn = nil
|
||||
s.br = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (s *rawSocket) close() {
|
||||
s.mu.Lock()
|
||||
s.closeLocked()
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *rawSocket) sendLocked(cmd string) error {
|
||||
_ = s.conn.SetDeadline(time.Now().Add(s.timeout))
|
||||
if _, err := s.conn.Write([]byte(cmd + s.terminator)); err != nil {
|
||||
s.closeLocked()
|
||||
return fmt.Errorf("scpi: write: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *rawSocket) write(cmd string) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if err := s.dialLocked(); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.sendLocked(cmd)
|
||||
}
|
||||
|
||||
func (s *rawSocket) query(cmd string) (string, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if err := s.dialLocked(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := s.sendLocked(cmd); err != nil {
|
||||
return "", err
|
||||
}
|
||||
line, err := s.br.ReadString('\n')
|
||||
if err != nil {
|
||||
s.closeLocked()
|
||||
return "", fmt.Errorf("scpi: read: %w", err)
|
||||
}
|
||||
return strings.TrimRight(line, "\r\n"), nil
|
||||
}
|
||||
@@ -15,12 +15,20 @@ type SignalDef struct {
|
||||
// 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)
|
||||
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.
|
||||
@@ -43,6 +51,17 @@ type NodeDef struct {
|
||||
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.
|
||||
|
||||
@@ -52,7 +52,20 @@ func (rg *runtimeGraph) sourceRefs() []broker.SignalRef {
|
||||
// - 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 := make(map[string]dsp.Sample, len(rg.order))
|
||||
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
|
||||
}
|
||||
@@ -65,7 +78,7 @@ func (rg *runtimeGraph) evalSample(sourceVals map[string]dsp.Sample) (dsp.Sample
|
||||
}
|
||||
r, err := evalOp(n, in)
|
||||
if err != nil {
|
||||
return dsp.Sample{}, fmt.Errorf("node %s (%s): %w", n.id, n.op.Type(), err)
|
||||
return vals, fmt.Errorf("node %s (%s): %w", n.id, n.op.Type(), err)
|
||||
}
|
||||
vals[n.id] = r
|
||||
case "output":
|
||||
@@ -74,7 +87,7 @@ func (rg *runtimeGraph) evalSample(sourceVals map[string]dsp.Sample) (dsp.Sample
|
||||
}
|
||||
}
|
||||
}
|
||||
return vals[rg.outputID], nil
|
||||
return vals, nil
|
||||
}
|
||||
|
||||
// evalOp runs a single op node over its Sample inputs, choosing the right
|
||||
|
||||
@@ -263,6 +263,9 @@ 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
|
||||
}
|
||||
|
||||
rg, err := compileGraph(def)
|
||||
if err != nil {
|
||||
@@ -340,6 +343,16 @@ 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()
|
||||
}
|
||||
@@ -449,6 +462,65 @@ func outTypeOf(st *signalState) dsp.ValType {
|
||||
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 {
|
||||
|
||||
@@ -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,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")
|
||||
}
|
||||
}
|
||||
@@ -22,8 +22,20 @@ var (
|
||||
"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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 any `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,223 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/uopi/uopi/internal/access"
|
||||
)
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// accessMiddleware //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
// okHandler records that it ran and returns 200.
|
||||
func okHandler(ran *bool) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
*ran = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAccessMiddlewareWriteUserPassesMutation(t *testing.T) {
|
||||
// A configured policy with an operator (write) user.
|
||||
policy := access.New("", []access.GroupSpec{
|
||||
{Name: "ops", Members: map[string]access.Role{"alice": access.RoleOperator}},
|
||||
})
|
||||
var ran bool
|
||||
h := accessMiddleware(policy, testUserHeader, okHandler(&ran))
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/interfaces", nil)
|
||||
req.Header.Set(testUserHeader, "alice")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK || !ran {
|
||||
t.Errorf("write user POST: code=%d ran=%v, want 200/true", rec.Code, ran)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessMiddlewareReadUserBlockedFromMutation(t *testing.T) {
|
||||
// Configured policy → an unlisted user is a read-only viewer.
|
||||
policy := access.New("", []access.GroupSpec{
|
||||
{Name: "ops", Members: map[string]access.Role{"alice": access.RoleOperator}},
|
||||
})
|
||||
var ran bool
|
||||
h := accessMiddleware(policy, testUserHeader, okHandler(&ran))
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/interfaces", nil)
|
||||
req.Header.Set(testUserHeader, "bob") // viewer
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("read-only POST: code=%d, want 403", rec.Code)
|
||||
}
|
||||
if ran {
|
||||
t.Error("handler must not run for a forbidden mutation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessMiddlewareReadUserAllowedToGet(t *testing.T) {
|
||||
policy := access.New("", []access.GroupSpec{
|
||||
{Name: "ops", Members: map[string]access.Role{"alice": access.RoleOperator}},
|
||||
})
|
||||
var ran bool
|
||||
h := accessMiddleware(policy, testUserHeader, okHandler(&ran))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/interfaces", nil)
|
||||
req.Header.Set(testUserHeader, "bob") // viewer
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK || !ran {
|
||||
t.Errorf("read-only GET: code=%d ran=%v, want 200/true", rec.Code, ran)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessMiddlewareMeAlwaysReachable(t *testing.T) {
|
||||
policy := access.New("", []access.GroupSpec{
|
||||
{Name: "ops", Members: map[string]access.Role{"alice": access.RoleOperator}},
|
||||
})
|
||||
var ran bool
|
||||
h := accessMiddleware(policy, testUserHeader, okHandler(&ran))
|
||||
|
||||
// Even a mutating method on /me is allowed through (identity discovery).
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/me", nil)
|
||||
req.Header.Set(testUserHeader, "bob")
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK || !ran {
|
||||
t.Errorf("/me must always be reachable: code=%d ran=%v", rec.Code, ran)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccessMiddlewareStoresUserOnContext(t *testing.T) {
|
||||
policy := access.New("", nil) // unconfigured → everyone write
|
||||
var got string
|
||||
h := accessMiddleware(policy, testUserHeader, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
got = access.UserFrom(r.Context())
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/foo", nil)
|
||||
req.Header.Set(testUserHeader, "carol")
|
||||
h.ServeHTTP(httptest.NewRecorder(), req)
|
||||
|
||||
if got != "carol" {
|
||||
t.Errorf("context user = %q, want carol", got)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// httpsRedirectHandler //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func TestHTTPSRedirectHandler(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
tlsAddr string
|
||||
host string
|
||||
target string
|
||||
want string
|
||||
}{
|
||||
{"default port omitted", ":443", "example.com", "/panels?x=1", "https://example.com/panels?x=1"},
|
||||
{"custom port preserved", ":8443", "host.local:80", "/a", "https://host.local:8443/a"},
|
||||
{"root path", "0.0.0.0:443", "h", "/", "https://h/"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
h := httpsRedirectHandler(c.tlsAddr)
|
||||
req := httptest.NewRequest(http.MethodGet, c.target, nil)
|
||||
req.Host = c.host
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusMovedPermanently {
|
||||
t.Fatalf("code = %d, want 301", rec.Code)
|
||||
}
|
||||
if loc := rec.Header().Get("Location"); loc != c.want {
|
||||
t.Errorf("Location = %q, want %q", loc, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// parseDialogTarget //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func TestParseDialogTarget(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
ds, name string
|
||||
ok bool
|
||||
}{
|
||||
{"epics:SR:CURRENT", "epics", "SR:CURRENT", true}, // splits on first ':' only
|
||||
{"bareName", "srv", "bareName", true}, // defaults to srv
|
||||
{" spaced ", "srv", "spaced", true},
|
||||
{"", "", "", false},
|
||||
{" ", "", "", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
ds, name, ok := parseDialogTarget(c.in)
|
||||
if ds != c.ds || name != c.name || ok != c.ok {
|
||||
t.Errorf("parseDialogTarget(%q) = (%q,%q,%v), want (%q,%q,%v)",
|
||||
c.in, ds, name, ok, c.ds, c.name, c.ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// formatAuditValue //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func TestFormatAuditValue(t *testing.T) {
|
||||
cases := []struct {
|
||||
in any
|
||||
want string
|
||||
}{
|
||||
{"hello", "hello"},
|
||||
{float64(3.5), "3.5"},
|
||||
{float64(42), "42"},
|
||||
{true, "true"},
|
||||
{false, "false"},
|
||||
{nil, ""},
|
||||
{[]any{1.0, 2.0}, "[1,2]"}, // default → JSON
|
||||
{map[string]any{"a": 1.0}, `{"a":1}`},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := formatAuditValue(c.in); got != c.want {
|
||||
t.Errorf("formatAuditValue(%v) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// clientIP //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func TestClientIP(t *testing.T) {
|
||||
t.Run("X-Forwarded-For first hop", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Header.Set("X-Forwarded-For", "203.0.113.7, 10.0.0.1")
|
||||
if got := clientIP(req); got != "203.0.113.7" {
|
||||
t.Errorf("got %q, want 203.0.113.7", got)
|
||||
}
|
||||
})
|
||||
t.Run("X-Real-IP", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.Header.Set("X-Real-IP", "198.51.100.4")
|
||||
if got := clientIP(req); got != "198.51.100.4" {
|
||||
t.Errorf("got %q, want 198.51.100.4", got)
|
||||
}
|
||||
})
|
||||
t.Run("RemoteAddr fallback", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.RemoteAddr = "192.0.2.9:54321"
|
||||
if got := clientIP(req); got != "192.0.2.9" {
|
||||
t.Errorf("got %q, want 192.0.2.9", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
stdlog "log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/jcmturner/goidentity/v6"
|
||||
"github.com/jcmturner/gokrb5/v8/keytab"
|
||||
"github.com/jcmturner/gokrb5/v8/service"
|
||||
"github.com/jcmturner/gokrb5/v8/spnego"
|
||||
)
|
||||
|
||||
// internalUserHeader is the request header the built-in authentication
|
||||
// middlewares (Kerberos, Basic) use to hand the validated username to the
|
||||
// downstream access pipeline when no external TrustedUserHeader is configured. It
|
||||
// is always stripped from inbound requests before validation, so a client cannot
|
||||
// spoof it.
|
||||
const internalUserHeader = "X-Uopi-User"
|
||||
|
||||
// kerberosAuth wraps next with SPNEGO/Kerberos ("Negotiate") authentication. On a
|
||||
// successful handshake the authenticated principal's short username (realm
|
||||
// stripped) is written into userHeader, so the existing access pipeline
|
||||
// (accessMiddleware / wsHandler, which both read userHeader) resolves identity
|
||||
// uniformly whether it originated from a trusted proxy header or a Kerberos
|
||||
// ticket.
|
||||
//
|
||||
// challenge controls behaviour when a request carries no valid Negotiate
|
||||
// credentials:
|
||||
// - challenge=true (REST/page requests): delegate to gokrb5, which replies
|
||||
// 401 + WWW-Authenticate: Negotiate so the browser performs SPNEGO.
|
||||
// - challenge=false (WebSocket upgrades): fall through unauthenticated.
|
||||
// Browsers cannot attach an Authorization header when opening a WebSocket;
|
||||
// they only send Negotiate proactively to trusted URIs. When the header is
|
||||
// present we still validate it, otherwise the session resolves to
|
||||
// default_user exactly as before.
|
||||
//
|
||||
// Any inbound value of userHeader is always discarded before validation: with
|
||||
// native Kerberos there is no trusted proxy stripping client-supplied headers, so
|
||||
// only the SPNEGO-validated identity may set it.
|
||||
func kerberosAuth(kt *keytab.Keytab, spn, userHeader string, challenge bool, log *slog.Logger, next http.Handler) http.Handler {
|
||||
opts := []func(*service.Settings){
|
||||
service.Logger(stdlog.New(slogWriter{log: log}, "", 0)),
|
||||
}
|
||||
if spn != "" {
|
||||
opts = append(opts, service.KeytabPrincipal(spn))
|
||||
}
|
||||
|
||||
// inner runs only after a successful SPNEGO handshake: it copies the resolved
|
||||
// identity into userHeader and continues down the chain.
|
||||
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
id := goidentity.FromHTTPRequestContext(r)
|
||||
if id == nil {
|
||||
http.Error(w, "kerberos: missing identity", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
user := id.UserName()
|
||||
if i := strings.IndexByte(user, '@'); i >= 0 {
|
||||
user = user[:i]
|
||||
}
|
||||
r.Header.Set(userHeader, user)
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
validate := spnego.SPNEGOKRB5Authenticate(inner, kt, opts...)
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// Never trust a client-supplied identity header under native Kerberos.
|
||||
r.Header.Del(userHeader)
|
||||
|
||||
negotiate := strings.HasPrefix(r.Header.Get("Authorization"), spnego.HTTPHeaderAuthResponseValueKey)
|
||||
if !negotiate && !challenge {
|
||||
// Best-effort path (WebSocket without proactive credentials): continue
|
||||
// unauthenticated; downstream resolves the session to default_user.
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
// Credentials are present (validate them) or a challenge is required.
|
||||
validate.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// slogWriter adapts the std logger gokrb5 expects to slog at debug level so SPNEGO
|
||||
// validation diagnostics surface without polluting normal output.
|
||||
type slogWriter struct{ log *slog.Logger }
|
||||
|
||||
func (s slogWriter) Write(p []byte) (int, error) {
|
||||
if s.log != nil {
|
||||
s.log.Debug("kerberos", "msg", strings.TrimRight(string(p), "\n"))
|
||||
}
|
||||
return len(p), nil
|
||||
}
|
||||
+102
-7
@@ -4,10 +4,12 @@ import (
|
||||
"context"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jcmturner/gokrb5/v8/keytab"
|
||||
"github.com/uopi/uopi/internal/access"
|
||||
"github.com/uopi/uopi/internal/api"
|
||||
"github.com/uopi/uopi/internal/audit"
|
||||
@@ -24,17 +26,34 @@ const apiPrefix = "/api/v1"
|
||||
|
||||
type Server struct {
|
||||
httpServer *http.Server
|
||||
tlsCert string
|
||||
tlsKey string
|
||||
tlsRedirect string // plain-HTTP addr that 301-redirects to HTTPS; empty = off
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// New creates the HTTP server, registers all routes, and returns a ready-to-start Server.
|
||||
// synth may be nil if the synthetic data source is not enabled.
|
||||
func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, cfgStore *confmgr.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, dialogs *DialogHub, rec audit.Recorder, channelFinderURL, archiverURL, trustedUserHeader string, log *slog.Logger) *Server {
|
||||
//
|
||||
// basicAuthFn, when non-nil, enables built-in HTTP Basic authentication validated
|
||||
// by that function (typically PAM): REST requests are challenged (401 Basic) and
|
||||
// WebSocket upgrades validate proactively-resent credentials best-effort. It is
|
||||
// mutually exclusive with Kerberos (krbKeytab). When tlsCert and tlsKey are both
|
||||
// set the server serves HTTPS via ListenAndServeTLS.
|
||||
func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Synthetic, store *storage.Store, cfgStore *confmgr.Store, policy *access.Policy, acl *panelacl.Store, ctrlLogic *controllogic.Store, ctrlEngine *controllogic.Engine, dialogs *DialogHub, debug *DebugHub, rec audit.Recorder, channelFinderURL, archiverURL, trustedUserHeader string, krbKeytab *keytab.Keytab, krbSPN string, basicAuthFn func(user, pass string) error, basicAuthRealm, tlsCert, tlsKey, tlsRedirect string, uiDefaultZoom float64, log *slog.Logger) *Server {
|
||||
if rec == nil {
|
||||
rec = audit.Nop()
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// When built-in authentication (Kerberos or Basic) is enabled but no external
|
||||
// proxy header is configured, use an internal header to carry the validated
|
||||
// username downstream.
|
||||
userHeader := trustedUserHeader
|
||||
if (krbKeytab != nil || basicAuthFn != nil) && userHeader == "" {
|
||||
userHeader = internalUserHeader
|
||||
}
|
||||
|
||||
// Health check
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
@@ -42,7 +61,7 @@ func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Syntheti
|
||||
})
|
||||
|
||||
// WebSocket endpoint
|
||||
mux.Handle("/ws", &wsHandler{broker: brk, log: log, userHeader: trustedUserHeader, policy: policy, audit: rec, dialogs: dialogs})
|
||||
var wsHandlerH http.Handler = &wsHandler{broker: brk, log: log, userHeader: userHeader, policy: policy, audit: rec, dialogs: dialogs, debug: debug}
|
||||
|
||||
// Prometheus-format metrics
|
||||
mux.HandleFunc("/metrics", metrics.Handler(brk.ActiveSubscriptions))
|
||||
@@ -50,11 +69,37 @@ func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Syntheti
|
||||
// REST API — registered on a dedicated mux so it can be wrapped with the
|
||||
// access-control middleware (identity resolution + global level enforcement).
|
||||
apiMux := http.NewServeMux()
|
||||
api.New(brk, synth, store, cfgStore, policy, acl, ctrlLogic, ctrlEngine, rec, channelFinderURL, archiverURL, log).Register(apiMux, apiPrefix)
|
||||
mux.Handle(apiPrefix+"/", accessMiddleware(policy, trustedUserHeader, apiMux))
|
||||
api.New(brk, synth, store, cfgStore, policy, acl, ctrlLogic, ctrlEngine, rec, channelFinderURL, archiverURL, uiDefaultZoom, log).Register(apiMux, apiPrefix)
|
||||
var apiHandler http.Handler = accessMiddleware(policy, userHeader, apiMux)
|
||||
|
||||
// Native SPNEGO/Kerberos authentication (optional). REST/page requests are
|
||||
// challenged (401 Negotiate); WebSocket upgrades validate proactively-sent
|
||||
// credentials best-effort. Both stash the validated username in userHeader.
|
||||
var frontendHandler http.Handler = http.FileServerFS(webFS)
|
||||
|
||||
if krbKeytab != nil {
|
||||
apiHandler = kerberosAuth(krbKeytab, krbSPN, userHeader, true, log, apiHandler)
|
||||
wsHandlerH = kerberosAuth(krbKeytab, krbSPN, userHeader, false, log, wsHandlerH)
|
||||
} else if basicAuthFn != nil {
|
||||
// Built-in HTTP Basic authentication (validated by basicAuthFn, e.g. PAM).
|
||||
// A shared positive-result cache spares a validation round-trip on every
|
||||
// browser request. REST is challenged; WebSocket upgrades are best-effort.
|
||||
cache := newCredCache(5 * time.Minute)
|
||||
apiHandler = basicAuth(basicAuthFn, userHeader, basicAuthRealm, true, cache, log, apiHandler)
|
||||
wsHandlerH = basicAuth(basicAuthFn, userHeader, basicAuthRealm, false, cache, log, wsHandlerH)
|
||||
// Challenge the top-level page load too. Browsers only show the native
|
||||
// Basic login dialog in response to a 401 on a navigation, not on the
|
||||
// SPA's background fetch('/api/v1/me'); without this the user is never
|
||||
// prompted and silently resolves to default_user. Once credentials are
|
||||
// entered the browser caches them for the origin and resends them on
|
||||
// every asset, API call and the WebSocket upgrade.
|
||||
frontendHandler = basicAuth(basicAuthFn, userHeader, basicAuthRealm, true, cache, log, frontendHandler)
|
||||
}
|
||||
mux.Handle("/ws", wsHandlerH)
|
||||
mux.Handle(apiPrefix+"/", apiHandler)
|
||||
|
||||
// Embedded frontend — must be last (catch-all)
|
||||
mux.Handle("/", http.FileServerFS(webFS))
|
||||
mux.Handle("/", frontendHandler)
|
||||
|
||||
return &Server{
|
||||
httpServer: &http.Server{
|
||||
@@ -64,6 +109,9 @@ func New(addr string, webFS fs.FS, brk *broker.Broker, synth *synthetic.Syntheti
|
||||
WriteTimeout: 30 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
},
|
||||
tlsCert: tlsCert,
|
||||
tlsKey: tlsKey,
|
||||
tlsRedirect: tlsRedirect,
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
@@ -108,15 +156,40 @@ func accessMiddleware(policy *access.Policy, userHeader string, next http.Handle
|
||||
|
||||
// Start listens and serves until ctx is cancelled.
|
||||
func (s *Server) Start(ctx context.Context) error {
|
||||
s.log.Info("listening", "addr", s.httpServer.Addr)
|
||||
tls := s.tlsCert != "" && s.tlsKey != ""
|
||||
s.log.Info("listening", "addr", s.httpServer.Addr, "tls", tls)
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
if err := s.httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
var err error
|
||||
if tls {
|
||||
err = s.httpServer.ListenAndServeTLS(s.tlsCert, s.tlsKey)
|
||||
} else {
|
||||
err = s.httpServer.ListenAndServe()
|
||||
}
|
||||
if err != nil && err != http.ErrServerClosed {
|
||||
errCh <- err
|
||||
}
|
||||
}()
|
||||
|
||||
// Optional plain-HTTP redirector: upgrade http:// visitors to the HTTPS
|
||||
// service instead of letting them hit the TLS port with a cleartext request.
|
||||
var redirectSrv *http.Server
|
||||
if tls && s.tlsRedirect != "" {
|
||||
redirectSrv = &http.Server{
|
||||
Addr: s.tlsRedirect,
|
||||
Handler: httpsRedirectHandler(s.httpServer.Addr),
|
||||
ReadTimeout: 10 * time.Second,
|
||||
WriteTimeout: 10 * time.Second,
|
||||
}
|
||||
s.log.Info("http→https redirect listening", "addr", s.tlsRedirect)
|
||||
go func() {
|
||||
if err := redirectSrv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
errCh <- err
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
return err
|
||||
@@ -124,6 +197,28 @@ func (s *Server) Start(ctx context.Context) error {
|
||||
shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
s.log.Info("shutting down")
|
||||
if redirectSrv != nil {
|
||||
_ = redirectSrv.Shutdown(shutCtx)
|
||||
}
|
||||
return s.httpServer.Shutdown(shutCtx)
|
||||
}
|
||||
}
|
||||
|
||||
// httpsRedirectHandler 301-redirects any plain-HTTP request to the HTTPS service.
|
||||
// It preserves the requested hostname and path, swapping in the TLS listener's
|
||||
// port (omitted when 443).
|
||||
func httpsRedirectHandler(tlsAddr string) http.Handler {
|
||||
_, tlsPort, _ := net.SplitHostPort(tlsAddr)
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
host := r.Host
|
||||
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||
host = h
|
||||
}
|
||||
target := "https://" + host
|
||||
if tlsPort != "" && tlsPort != "443" {
|
||||
target += ":" + tlsPort
|
||||
}
|
||||
target += r.URL.RequestURI()
|
||||
http.Redirect(w, r, target, http.StatusMovedPermanently)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"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"
|
||||
"github.com/uopi/uopi/internal/metrics"
|
||||
)
|
||||
@@ -37,6 +38,14 @@ type inMsg struct {
|
||||
// dialogResponse — id of the control-logic dialog being answered.
|
||||
ID string `json:"id,omitempty"`
|
||||
|
||||
// debugSubscribe — live observation / dry-run of a control-logic graph.
|
||||
Mode string `json:"mode,omitempty"` // "live" | "simulate"
|
||||
GraphID string `json:"graphId,omitempty"` // live: id of the running graph
|
||||
Graph json.RawMessage `json:"graph,omitempty"` // simulate: the unsaved graph
|
||||
|
||||
// fireTrigger — force a trigger node of the current debug session to run.
|
||||
NodeID string `json:"nodeId,omitempty"`
|
||||
|
||||
// history
|
||||
Start time.Time `json:"start"`
|
||||
End time.Time `json:"end"`
|
||||
@@ -106,6 +115,8 @@ type wsHandler struct {
|
||||
audit audit.Recorder
|
||||
// dialogs fans control-logic dialogs to clients and routes responses.
|
||||
dialogs *DialogHub
|
||||
// debug fans control-logic node-execution events to watching editors.
|
||||
debug *DebugHub
|
||||
}
|
||||
|
||||
func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -149,6 +160,7 @@ func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
policy: h.policy,
|
||||
audit: rec,
|
||||
dialogs: h.dialogs,
|
||||
debug: h.debug,
|
||||
outCh: make(chan []byte, 512),
|
||||
updateCh: make(chan broker.Update, 1024),
|
||||
subs: make(map[broker.SignalRef]func()),
|
||||
@@ -160,6 +172,10 @@ func (h *wsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
h.dialogs.add(c)
|
||||
defer h.dialogs.remove(c)
|
||||
}
|
||||
// Tear down any debug subscription (and its simulate sandbox) on disconnect.
|
||||
if h.debug != nil {
|
||||
defer h.debug.unsubscribe(c)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(3)
|
||||
@@ -189,6 +205,7 @@ type wsClient struct {
|
||||
policy *access.Policy // global access-level enforcement
|
||||
audit audit.Recorder // signal-write audit recorder (never nil)
|
||||
dialogs *DialogHub // control-logic dialog fan-out (nil if disabled)
|
||||
debug *DebugHub // control-logic debug fan-out (nil if disabled)
|
||||
|
||||
outCh chan []byte // serialised outgoing messages
|
||||
updateCh chan broker.Update // raw updates from the broker
|
||||
@@ -287,6 +304,16 @@ func (c *wsClient) handleMessage(ctx context.Context, data []byte) {
|
||||
c.handleHistory(ctx, msg)
|
||||
case "dialogResponse":
|
||||
c.handleDialogResponse(ctx, msg)
|
||||
case "debugSubscribe":
|
||||
c.handleDebugSubscribe(ctx, msg)
|
||||
case "debugUnsubscribe":
|
||||
if c.debug != nil {
|
||||
c.debug.unsubscribe(c)
|
||||
}
|
||||
case "fireTrigger":
|
||||
if c.debug != nil {
|
||||
c.debug.fire(c, msg.NodeID)
|
||||
}
|
||||
default:
|
||||
c.sendError(ctx, "UNKNOWN_TYPE", "unknown message type: "+msg.Type)
|
||||
}
|
||||
@@ -423,6 +450,26 @@ func (c *wsClient) handleDialogResponse(ctx context.Context, msg inMsg) {
|
||||
c.dialogs.respond(ctx, c, msg.ID, num)
|
||||
}
|
||||
|
||||
// handleDebugSubscribe starts a control-logic debug session for this client.
|
||||
// mode "live" observes the running graph identified by GraphID; mode "simulate"
|
||||
// dry-runs the unsaved Graph payload in a sandbox (no real writes). Either way
|
||||
// the previous session (if any) is replaced; node events arrive as "debugNode".
|
||||
func (c *wsClient) handleDebugSubscribe(ctx context.Context, msg inMsg) {
|
||||
if c.debug == nil {
|
||||
return
|
||||
}
|
||||
if msg.Mode == "simulate" {
|
||||
var g controllogic.Graph
|
||||
if err := json.Unmarshal(msg.Graph, &g); err != nil {
|
||||
c.sendError(ctx, "DEBUG_ERROR", "invalid graph: "+err.Error())
|
||||
return
|
||||
}
|
||||
c.debug.subscribeSimulate(c, g)
|
||||
return
|
||||
}
|
||||
c.debug.subscribeLive(c, msg.GraphID)
|
||||
}
|
||||
|
||||
func (c *wsClient) handleHistory(ctx context.Context, msg inMsg) {
|
||||
metrics.IncHistoryReqs()
|
||||
ds, ok := c.broker.Source(msg.DS)
|
||||
|
||||
@@ -22,6 +22,8 @@ type InterfaceMeta struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Version int `json:"version"`
|
||||
// Kind is "plot" for split-layout plot panels, empty for free-form panels.
|
||||
Kind string `json:"kind,omitempty"`
|
||||
}
|
||||
|
||||
// VersionMeta describes a single persisted revision of an interface.
|
||||
@@ -134,6 +136,7 @@ type rootAttrs struct {
|
||||
Name string `xml:"name,attr"`
|
||||
Version int `xml:"version,attr"`
|
||||
Tag string `xml:"tag,attr"`
|
||||
Kind string `xml:"kind,attr"`
|
||||
}
|
||||
|
||||
func (s *Store) readMeta(id string) (InterfaceMeta, error) {
|
||||
@@ -145,7 +148,7 @@ func (s *Store) readMeta(id string) (InterfaceMeta, error) {
|
||||
if err := xml.Unmarshal(data, &root); err != nil {
|
||||
return InterfaceMeta{}, err
|
||||
}
|
||||
return InterfaceMeta{ID: id, Name: root.Name, Version: root.Version}, nil
|
||||
return InterfaceMeta{ID: id, Name: root.Name, Version: root.Version, Kind: root.Kind}, nil
|
||||
}
|
||||
|
||||
// Get returns the raw XML bytes for the interface with the given ID.
|
||||
|
||||
@@ -0,0 +1,374 @@
|
||||
package storage_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/uopi/uopi/internal/storage"
|
||||
)
|
||||
|
||||
// mkVersioned creates an interface and pushes it to version 3 by updating
|
||||
// twice, returning the id. After this the store holds: current (v3) plus
|
||||
// backups id.v1.xml and id.v2.xml.
|
||||
func mkVersioned(t *testing.T) (*storage.Store, string) {
|
||||
t.Helper()
|
||||
s := newStore(t)
|
||||
id, err := s.Create([]byte(`<interface id="vers" name="V1" version="1"><widget/></interface>`), "")
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if err := s.Update(id, []byte(`<interface id="vers" name="V2" version="1"><widget/></interface>`), "second"); err != nil {
|
||||
t.Fatalf("Update→v2: %v", err)
|
||||
}
|
||||
if err := s.Update(id, []byte(`<interface id="vers" name="V3" version="1"><widget/></interface>`), ""); err != nil {
|
||||
t.Fatalf("Update→v3: %v", err)
|
||||
}
|
||||
return s, id
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// Groups //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func TestReadGroupsDefaultsEmptyArray(t *testing.T) {
|
||||
s := newStore(t)
|
||||
data, err := s.ReadGroups()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadGroups: %v", err)
|
||||
}
|
||||
if string(data) != "[]" {
|
||||
t.Errorf("ReadGroups default = %q, want %q", data, "[]")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteThenReadGroups(t *testing.T) {
|
||||
s := newStore(t)
|
||||
want := `[{"id":"a","name":"Area A"}]`
|
||||
if err := s.WriteGroups([]byte(want)); err != nil {
|
||||
t.Fatalf("WriteGroups: %v", err)
|
||||
}
|
||||
got, err := s.ReadGroups()
|
||||
if err != nil {
|
||||
t.Fatalf("ReadGroups: %v", err)
|
||||
}
|
||||
if string(got) != want {
|
||||
t.Errorf("ReadGroups = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// Versions //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func TestVersionsNewestFirstWithCurrentFlag(t *testing.T) {
|
||||
s, id := mkVersioned(t)
|
||||
vs, err := s.Versions(id)
|
||||
if err != nil {
|
||||
t.Fatalf("Versions: %v", err)
|
||||
}
|
||||
if len(vs) != 3 {
|
||||
t.Fatalf("expected 3 versions, got %d", len(vs))
|
||||
}
|
||||
if vs[0].Version != 3 || vs[1].Version != 2 || vs[2].Version != 1 {
|
||||
t.Errorf("not newest-first: %d, %d, %d", vs[0].Version, vs[1].Version, vs[2].Version)
|
||||
}
|
||||
if !vs[0].Current {
|
||||
t.Error("v3 should be flagged Current")
|
||||
}
|
||||
if vs[1].Current || vs[2].Current {
|
||||
t.Error("backup revisions must not be flagged Current")
|
||||
}
|
||||
// The "second" tag was stamped on the revision that became backup v2.
|
||||
if vs[1].Tag != "second" {
|
||||
t.Errorf("v2 tag = %q, want %q", vs[1].Tag, "second")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionsNotFound(t *testing.T) {
|
||||
s := newStore(t)
|
||||
if _, err := s.Versions("ghost"); !errors.Is(err, storage.ErrNotFound) {
|
||||
t.Errorf("Versions missing: want ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionsInvalidID(t *testing.T) {
|
||||
s := newStore(t)
|
||||
if _, err := s.Versions("../x"); !errors.Is(err, storage.ErrNotFound) {
|
||||
t.Errorf("Versions bad ID: want ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// GetVersion //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func TestGetVersionCurrentAndBackup(t *testing.T) {
|
||||
s, id := mkVersioned(t)
|
||||
|
||||
cur, err := s.GetVersion(id, 3)
|
||||
if err != nil {
|
||||
t.Fatalf("GetVersion(3): %v", err)
|
||||
}
|
||||
if !strings.Contains(string(cur), `name="V3"`) {
|
||||
t.Errorf("GetVersion(3) wrong content: %s", cur)
|
||||
}
|
||||
|
||||
old, err := s.GetVersion(id, 1)
|
||||
if err != nil {
|
||||
t.Fatalf("GetVersion(1): %v", err)
|
||||
}
|
||||
if !strings.Contains(string(old), `name="V1"`) {
|
||||
t.Errorf("GetVersion(1) wrong content: %s", old)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetVersionMissingRevision(t *testing.T) {
|
||||
s, id := mkVersioned(t)
|
||||
if _, err := s.GetVersion(id, 99); !errors.Is(err, storage.ErrNotFound) {
|
||||
t.Errorf("GetVersion(99): want ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetVersionMissingInterface(t *testing.T) {
|
||||
s := newStore(t)
|
||||
if _, err := s.GetVersion("ghost", 1); !errors.Is(err, storage.ErrNotFound) {
|
||||
t.Errorf("GetVersion missing iface: want ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// SetVersionTag //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func TestSetVersionTagOnBackupAndClear(t *testing.T) {
|
||||
s, id := mkVersioned(t)
|
||||
|
||||
// Tag a backup revision in place (no new revision created).
|
||||
if err := s.SetVersionTag(id, 1, "milestone"); err != nil {
|
||||
t.Fatalf("SetVersionTag(1): %v", err)
|
||||
}
|
||||
vs, _ := s.Versions(id)
|
||||
if len(vs) != 3 {
|
||||
t.Fatalf("tagging must not add a revision: got %d", len(vs))
|
||||
}
|
||||
var v1 *storage.VersionMeta
|
||||
for i := range vs {
|
||||
if vs[i].Version == 1 {
|
||||
v1 = &vs[i]
|
||||
}
|
||||
}
|
||||
if v1 == nil || v1.Tag != "milestone" {
|
||||
t.Fatalf("v1 tag not set: %+v", v1)
|
||||
}
|
||||
|
||||
// Clearing the tag.
|
||||
if err := s.SetVersionTag(id, 1, ""); err != nil {
|
||||
t.Fatalf("SetVersionTag clear: %v", err)
|
||||
}
|
||||
vs, _ = s.Versions(id)
|
||||
for _, v := range vs {
|
||||
if v.Version == 1 && v.Tag != "" {
|
||||
t.Errorf("v1 tag should be cleared, got %q", v.Tag)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetVersionTagCurrent(t *testing.T) {
|
||||
s, id := mkVersioned(t)
|
||||
if err := s.SetVersionTag(id, 3, "live"); err != nil {
|
||||
t.Fatalf("SetVersionTag(3): %v", err)
|
||||
}
|
||||
vs, _ := s.Versions(id)
|
||||
if vs[0].Version != 3 || vs[0].Tag != "live" {
|
||||
t.Errorf("current tag not applied: %+v", vs[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSetVersionTagMissing(t *testing.T) {
|
||||
s, id := mkVersioned(t)
|
||||
if err := s.SetVersionTag(id, 99, "x"); !errors.Is(err, storage.ErrNotFound) {
|
||||
t.Errorf("SetVersionTag missing rev: want ErrNotFound, got %v", err)
|
||||
}
|
||||
if err := s.SetVersionTag("../bad", 1, "x"); !errors.Is(err, storage.ErrNotFound) {
|
||||
t.Errorf("SetVersionTag bad ID: want ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// Promote //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func TestPromoteIsNonDestructive(t *testing.T) {
|
||||
s, id := mkVersioned(t)
|
||||
|
||||
// Promote v1 — its content becomes the new current revision (v4), while
|
||||
// every prior revision is preserved.
|
||||
if err := s.Promote(id, 1); err != nil {
|
||||
t.Fatalf("Promote(1): %v", err)
|
||||
}
|
||||
vs, err := s.Versions(id)
|
||||
if err != nil {
|
||||
t.Fatalf("Versions after promote: %v", err)
|
||||
}
|
||||
if len(vs) != 4 {
|
||||
t.Fatalf("expected 4 versions after promote, got %d", len(vs))
|
||||
}
|
||||
if vs[0].Version != 4 || !vs[0].Current {
|
||||
t.Errorf("new current should be v4: %+v", vs[0])
|
||||
}
|
||||
if vs[0].Name != "V1" {
|
||||
t.Errorf("promoted content should be V1's, got name %q", vs[0].Name)
|
||||
}
|
||||
if vs[0].Tag != "restored from v1" {
|
||||
t.Errorf("promote tag = %q", vs[0].Tag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromoteMissing(t *testing.T) {
|
||||
s, id := mkVersioned(t)
|
||||
if err := s.Promote(id, 99); !errors.Is(err, storage.ErrNotFound) {
|
||||
t.Errorf("Promote missing rev: want ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// Fork //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func TestForkResetsVersionAndClearsTag(t *testing.T) {
|
||||
s, id := mkVersioned(t)
|
||||
if err := s.SetVersionTag(id, 2, "tagged"); err != nil {
|
||||
t.Fatalf("SetVersionTag: %v", err)
|
||||
}
|
||||
|
||||
newID, err := s.Fork(id, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("Fork(2): %v", err)
|
||||
}
|
||||
if newID == id {
|
||||
t.Fatal("Fork must produce a distinct ID")
|
||||
}
|
||||
|
||||
data, err := s.Get(newID)
|
||||
if err != nil {
|
||||
t.Fatalf("Get(fork): %v", err)
|
||||
}
|
||||
str := string(data)
|
||||
if !strings.Contains(str, `name="V2"`) {
|
||||
t.Errorf("fork should carry V2 content: %s", str)
|
||||
}
|
||||
if !strings.Contains(str, `version="1"`) {
|
||||
t.Errorf("fork version should reset to 1: %s", str)
|
||||
}
|
||||
if !strings.Contains(str, `id="`+newID+`"`) {
|
||||
t.Errorf("fork id attr should be stamped: %s", str)
|
||||
}
|
||||
if strings.Contains(str, "tagged") {
|
||||
t.Errorf("fork should clear the tag: %s", str)
|
||||
}
|
||||
|
||||
// The fork is an independent, listable interface at v1.
|
||||
vs, err := s.Versions(newID)
|
||||
if err != nil {
|
||||
t.Fatalf("Versions(fork): %v", err)
|
||||
}
|
||||
if len(vs) != 1 || vs[0].Version != 1 {
|
||||
t.Errorf("fork should have a single v1 revision, got %+v", vs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForkMissing(t *testing.T) {
|
||||
s := newStore(t)
|
||||
if _, err := s.Fork("ghost", 1); !errors.Is(err, storage.ErrNotFound) {
|
||||
t.Errorf("Fork missing: want ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// Create: slug derivation + attribute stamping //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func TestCreateSlugifiesNameWhenNoID(t *testing.T) {
|
||||
s := newStore(t)
|
||||
id, err := s.Create([]byte(`<interface name="My Cool Panel!" version="1"/>`), "")
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if id != "my-cool-panel" {
|
||||
t.Errorf("slug id = %q, want %q", id, "my-cool-panel")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateEmptyNameFallsBackToInterface(t *testing.T) {
|
||||
s := newStore(t)
|
||||
id, err := s.Create([]byte(`<interface name="" version="1"/>`), "")
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
if id != "interface" {
|
||||
t.Errorf("fallback id = %q, want %q", id, "interface")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateStampsMissingVersionAndID(t *testing.T) {
|
||||
s := newStore(t)
|
||||
// XML carries neither id nor version attributes — Create must insert both.
|
||||
id, err := s.Create([]byte(`<interface name="Stamp Me"><widget/></interface>`), "label")
|
||||
if err != nil {
|
||||
t.Fatalf("Create: %v", err)
|
||||
}
|
||||
data, err := s.Get(id)
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
str := string(data)
|
||||
if !strings.Contains(str, `version="1"`) {
|
||||
t.Errorf("Create should stamp version=1: %s", str)
|
||||
}
|
||||
if !strings.Contains(str, `id="`+id+`"`) {
|
||||
t.Errorf("Create should stamp id: %s", str)
|
||||
}
|
||||
if !strings.Contains(str, `tag="label"`) {
|
||||
t.Errorf("Create should stamp tag: %s", str)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------- //
|
||||
// Update tagging + backup chain //
|
||||
// -------------------------------------------------------------------------- //
|
||||
|
||||
func TestDeleteMovesVersionedBackups(t *testing.T) {
|
||||
s, id := mkVersioned(t) // current + id.v1.xml + id.v2.xml
|
||||
if err := s.Delete(id); err != nil {
|
||||
t.Fatalf("Delete: %v", err)
|
||||
}
|
||||
if _, err := s.Get(id); !errors.Is(err, storage.ErrNotFound) {
|
||||
t.Errorf("Get after delete: want ErrNotFound, got %v", err)
|
||||
}
|
||||
if _, err := s.Versions(id); !errors.Is(err, storage.ErrNotFound) {
|
||||
t.Errorf("Versions after delete: want ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListSkipsVersionedBackups(t *testing.T) {
|
||||
s, _ := mkVersioned(t) // leaves id.v1.xml and id.v2.xml on disk
|
||||
list, err := s.List()
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("List should skip versioned backups, got %d entries: %+v", len(list), list)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateStampsIncrementingVersion(t *testing.T) {
|
||||
s, id := mkVersioned(t)
|
||||
data, err := s.Get(id)
|
||||
if err != nil {
|
||||
t.Fatalf("Get: %v", err)
|
||||
}
|
||||
if !strings.Contains(string(data), `version="3"`) {
|
||||
t.Errorf("current should be version 3 after two updates: %s", data)
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/uopi/goca"
|
||||
"github.com/uopi/goca/testca"
|
||||
"github.com/uopi/goca/proto"
|
||||
"github.com/uopi/goca/testca"
|
||||
)
|
||||
|
||||
// newTestClient creates a Client pointing only at the fake server's addresses.
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package ca
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"github.com/uopi/goca/proto"
|
||||
)
|
||||
|
||||
// ---- encodePut: every DBF branch + error paths -----------------------
|
||||
|
||||
func TestEncodePut(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
dbf int
|
||||
v any
|
||||
wantDBR uint16
|
||||
}{
|
||||
{"double", proto.DBFDouble, 1.5, proto.DBRDouble},
|
||||
{"float", proto.DBFFloat, float32(2.0), proto.DBRDouble},
|
||||
{"long", proto.DBFLong, int(3), proto.DBRLong},
|
||||
{"short", proto.DBFShort, int16(4), proto.DBRShort},
|
||||
{"char", proto.DBFChar, int(5), proto.DBRShort},
|
||||
{"enum", proto.DBFEnum, int(1), proto.DBRShort},
|
||||
{"string", proto.DBFString, "hi", proto.DBRString},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
dbr, payload, err := encodePut(c.dbf, c.v)
|
||||
if err != nil {
|
||||
t.Fatalf("encodePut: %v", err)
|
||||
}
|
||||
if dbr != c.wantDBR {
|
||||
t.Errorf("dbr = %d, want %d", dbr, c.wantDBR)
|
||||
}
|
||||
if len(payload) == 0 {
|
||||
t.Error("empty payload")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Coercion failures propagate.
|
||||
if _, _, err := encodePut(proto.DBFDouble, "nope"); err == nil {
|
||||
t.Error("double from string: want error")
|
||||
}
|
||||
if _, _, err := encodePut(proto.DBFLong, "nope"); err == nil {
|
||||
t.Error("long from string: want error")
|
||||
}
|
||||
if _, _, err := encodePut(proto.DBFShort, "nope"); err == nil {
|
||||
t.Error("short from string: want error")
|
||||
}
|
||||
// Unsupported field type.
|
||||
if _, _, err := encodePut(9999, 1); err == nil {
|
||||
t.Error("unsupported DBF: want error")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- NewClient: error path + default name/host -----------------------
|
||||
|
||||
func TestNewClientNoAddrs(t *testing.T) {
|
||||
if _, err := NewClient(context.Background(), Config{AutoAddrList: false}); err == nil {
|
||||
t.Error("NewClient with no addresses: want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClientDefaultsNameHost(t *testing.T) {
|
||||
// AddrList present but ClientName/HostName empty → default branches run.
|
||||
cli, err := NewClient(context.Background(), Config{
|
||||
AddrList: []string{"127.0.0.1:5064"},
|
||||
AutoAddrList: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient: %v", err)
|
||||
}
|
||||
defer cli.Close()
|
||||
if cli.cfg.ClientName == "" {
|
||||
t.Error("ClientName should be defaulted")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- EncodeSearchReply with an explicit server IP --------------------
|
||||
|
||||
func TestEncodeSearchReplyExplicitIP(t *testing.T) {
|
||||
pkt := EncodeSearchReply(7, net.ParseIP("10.1.2.3"), 5064)
|
||||
h, _, err := proto.DecodeHeader(newBytesReader(pkt))
|
||||
if err != nil {
|
||||
t.Fatalf("DecodeHeader: %v", err)
|
||||
}
|
||||
if h.Command != proto.CmdSearch {
|
||||
t.Errorf("command = %d, want CmdSearch", h.Command)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- parseReply robustness -------------------------------------------
|
||||
|
||||
func TestParseReplyTruncated(t *testing.T) {
|
||||
se := &searchEngine{waiters: make(map[uint32]*searchWaiter)}
|
||||
src := &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 5064}
|
||||
// Must not panic on a too-short datagram.
|
||||
se.parseReply([]byte{0x00, 0x01}, src)
|
||||
}
|
||||
|
||||
func TestParseReplyUnknownSearchID(t *testing.T) {
|
||||
se := &searchEngine{waiters: make(map[uint32]*searchWaiter)}
|
||||
src := &net.UDPAddr{IP: net.ParseIP("127.0.0.1"), Port: 5064}
|
||||
// Valid reply but no waiter is registered for this search ID → dropped.
|
||||
se.parseReply(EncodeSearchReply(12345, nil, 5064), src)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package ca_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestGetCtrlNotFound covers the resolve-failure branch of GetCtrl.
|
||||
func TestGetCtrlNotFound(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
cli := newTestClient(t, srv)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
if _, err := cli.GetCtrl(ctx, "NO:SUCH:PV"); err == nil {
|
||||
t.Fatal("GetCtrl on missing PV: want error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPutNotFound covers the resolve-failure branch of Put.
|
||||
func TestPutNotFound(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
cli := newTestClient(t, srv)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
if err := cli.Put(ctx, "NO:SUCH:PV", 1.0); err == nil {
|
||||
t.Fatal("Put on missing PV: want error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestPutUncoercibleValue covers the encodePut-failure branch of Put: the PV
|
||||
// resolves but the supplied value cannot be coerced to the native type.
|
||||
func TestPutUncoercibleValue(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
cli := newTestClient(t, srv)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := cli.Put(ctx, "TEST:DOUBLE", []int{1, 2, 3}); err == nil {
|
||||
t.Fatal("Put with uncoercible value: want error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package ca_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/uopi/goca/proto"
|
||||
"github.com/uopi/goca/testca"
|
||||
)
|
||||
|
||||
// TestGetDisconnectMidRequest covers the "circuit disconnected during GET" path
|
||||
// in conn.go: the server drops the connection while a READ_NOTIFY is in flight,
|
||||
// so the in-flight reply channel is closed by the reconnect loop and the waiter
|
||||
// unblocks with ok=false.
|
||||
func TestGetDisconnectMidRequest(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
cli := newTestClient(t, srv)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// First GET establishes the channel/circuit successfully.
|
||||
if _, err := cli.Get(ctx, "TEST:DOUBLE"); err != nil {
|
||||
t.Fatalf("initial Get: %v", err)
|
||||
}
|
||||
|
||||
// Arm a mid-request disconnect for the next READ_NOTIFY.
|
||||
srv.SetGetFault(testca.GetFaultDisconnect)
|
||||
|
||||
if _, err := cli.Get(ctx, "TEST:DOUBLE"); err == nil {
|
||||
t.Fatal("Get with mid-request disconnect: want error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetCorruptReply covers the "failed to decode GET reply" path: the server
|
||||
// answers READ_NOTIFY with a payload too short for DecodeTimeValue.
|
||||
func TestGetCorruptReply(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
cli := newTestClient(t, srv)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Establish the channel first so resolution succeeds.
|
||||
if _, err := cli.Get(ctx, "TEST:DOUBLE"); err != nil {
|
||||
t.Fatalf("initial Get: %v", err)
|
||||
}
|
||||
|
||||
srv.SetGetFault(testca.GetFaultCorrupt)
|
||||
|
||||
if _, err := cli.Get(ctx, "TEST:DOUBLE"); err == nil {
|
||||
t.Fatal("Get with corrupt reply: want error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestServerDisconnectReconnects covers the CmdServerDisc dispatch branch and the
|
||||
// reconnect loop: after an orderly server disconnect the client transparently
|
||||
// reconnects, re-creates its channel, re-subscribes the monitor, and continues to
|
||||
// receive updates.
|
||||
func TestServerDisconnectReconnects(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
cli := newTestClient(t, srv)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
ch := make(chan proto.TimeValue, 16)
|
||||
unsub, err := cli.Subscribe(ctx, "TEST:DOUBLE", ch)
|
||||
if err != nil {
|
||||
t.Fatalf("Subscribe: %v", err)
|
||||
}
|
||||
defer unsub()
|
||||
|
||||
// Drain the initial value.
|
||||
select {
|
||||
case <-ch:
|
||||
case <-ctx.Done():
|
||||
t.Fatal("timeout waiting for initial value")
|
||||
}
|
||||
|
||||
// Force an orderly disconnect; the client must reconnect on its own.
|
||||
srv.Disconnect()
|
||||
|
||||
// After the reconnect settles, push a fresh value and expect to see it.
|
||||
// The reconnect back-off is ~1s, so poll generously and re-arm the value
|
||||
// until it is observed (the re-subscribe also delivers an initial value).
|
||||
deadline := time.After(8 * time.Second)
|
||||
tick := time.NewTicker(300 * time.Millisecond)
|
||||
defer tick.Stop()
|
||||
for {
|
||||
select {
|
||||
case tv := <-ch:
|
||||
if math.Abs(tv.Double-88.8) < 1e-6 {
|
||||
return // reconnect succeeded and the update flowed through
|
||||
}
|
||||
case <-tick.C:
|
||||
_ = srv.SetValue("TEST:DOUBLE", 88.8)
|
||||
case <-deadline:
|
||||
t.Fatal("timeout waiting for post-reconnect update")
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user