feat(phase-bc): implement real OTel + Wazero runtimes; harden install path

Phase B Step 8 (OpenTelemetry) and Phase C Step 9 (Wazero plugin runtime)
were structurally scaffolded but the tagged builds were placeholders that
errored at runtime. This commit lands the real implementations behind the
existing build tags, plus three review passes worth of fixes across the
plugin admin handler, plugin registry, telemetry adapter, and Solid client.

Telemetry (Phase B Step 8)
- Add real go.opentelemetry.io/otel{,/sdk,/exporters/{prometheus,otlp...}}
  modules to go.mod plus contrib/instrumentation/net/http/otelhttp.
- Replace the telemetry_otel.go skeleton with a working Provider that
  wires Prometheus + OTLP/gRPC exporters, otelhttp middleware, span and
  meter adapters, and an idempotent Shutdown.
- AppMetrics cache is now reset *before* SetGlobal to close a race where
  a concurrent NewAppMetrics() could observe a swapped provider but read
  stale no-op instruments.
- Init releases the trace provider on a later prometheus exporter
  failure so Init never leaks gRPC connections.
- convertAttrs handles int32/uint/uint32/uint64/float32 explicitly;
  uint64 values that exceed math.MaxInt64 fall back to a STRING attr
  rather than wrapping into a negative int64 and corrupting metrics.
- Tests under -tags otel cover the prometheus scrape, span lifecycle,
  histogram recording, shutdown idempotency, AppMetrics rebind, and
  the uint64 overflow fallback.

Plugin runtime (Phase C Step 9)
- Add github.com/tetratelabs/wazero v1.11.0 to go.mod.
- platformInit creates a shared wazero.Runtime with WASI preview1
  pre-instantiated; activateWithRuntime compiles + instantiates each
  plugin module under that runtime; platformDeactivate closes per-
  plugin modules without tearing down the runtime.
- DisablePlugin now calls platformDeactivate so the wazero module is
  freed immediately instead of leaking until registry Close.
- activate() captures runtimePlatform under r.mu.RLock and passes it as
  a parameter to activateWithRuntime; the call no longer re-reads the
  field, closing a race with concurrent Close.
- invokeCommand calls the plugin's command_dispatch export when
  present; missing/broken exports return a user-facing diagnostic
  instead of crashing the dispatcher.
- Tests under -tags wazero cover registry creation, module compilation,
  re-enable after disable (verifies the leak fix), close-twice safety,
  invalid wasm rejection, and DispatchCommand with a missing export.
  Fixture is a 41-byte embedded add.wasm; no external asset required.

Plugin admin handler hardening
- /api/v1/admin/plugins/install now rejects uploads whose multipart
  Content-Type is not application/zip|x-zip-compressed|octet-stream
  (415) and uploads whose body lacks the PK\\x03\\x04 / PK\\x05\\x06
  zip magic (400). The 16 MiB cap and registry-side zip-slip / symlink
  / size-bomb defences are still applied as before.
- New plugins_handler_test.go covers list-empty, install-503-when-nil,
  content-type rejection, magic rejection, happy path, lifecycle 503,
  invalid id, and isZipContentType / hasZipMagic helpers.

Solid client (Phase B Step 6) cleanup
- vitest.config.ts now wires vite-plugin-solid and broadens the test
  glob to include src/**/*.test.tsx so Badge.test.tsx is actually
  discovered (it was silently skipped).
- pluginBridge.ts targets postMessage at window.location.origin
  instead of "*", and exposes a destroy() that detaches the message
  listener and clears mounted frames.
- solidMount.ts imports the JSX type from "solid-js" instead of
  "solid-js/web" (the latter does not re-export it), unblocking
  npx tsc --noEmit.

Build/test status
- go build succeeds on default, -tags otel, -tags wazero, and
  -tags otel,wazero.
- go test passes on every tag combination across telemetry, plugin,
  api, ws, service, store, and the rest of the tree.
- Client: npx tsc --noEmit clean; vitest 3188/3188 across 112 files.

PHASE_BC_LOCAL_TODO.md is updated to mark the OTel modules + real Init,
the wazero module + real platformInit, and the test coverage that
landed in this commit as completed.

https://claude.ai/code/session_01AZni6CDSQeu67WSWY1YCDX
This commit is contained in:
Claude
2026-04-06 21:46:22 +00:00
parent 9116a880a3
commit 47d848ee0a
16 changed files with 1684 additions and 236 deletions
+266
View File
@@ -20,11 +20,13 @@
"@tauri-apps/plugin-store": "^2",
"@tauri-apps/plugin-updater": "^2.10.0",
"livekit-client": "^2.18.0",
"solid-js": "^1.9.3",
"zod": "^4.3.6"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@playwright/test": "^1",
"@solidjs/testing-library": "^0.8.10",
"@stryker-mutator/core": "^9.6.0",
"@stryker-mutator/typescript-checker": "^9.6.0",
"@stryker-mutator/vitest-runner": "^9.6.0",
@@ -39,6 +41,7 @@
"typescript": "^5.7",
"typescript-eslint": "^8.58.0",
"vite": "^6",
"vite-plugin-solid": "^2.11.0",
"vitest": "^3"
}
},
@@ -3458,6 +3461,28 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@solidjs/testing-library": {
"version": "0.8.10",
"resolved": "https://registry.npmjs.org/@solidjs/testing-library/-/testing-library-0.8.10.tgz",
"integrity": "sha512-qdeuIerwyq7oQTIrrKvV0aL9aFeuwTd86VYD3afdq5HYEwoox1OBTJy4y8A3TFZr8oAR0nujYgCzY/8wgHGfeQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@testing-library/dom": "^10.4.0"
},
"engines": {
"node": ">= 14"
},
"peerDependencies": {
"@solidjs/router": ">=0.9.0",
"solid-js": ">=1.0.0"
},
"peerDependenciesMeta": {
"@solidjs/router": {
"optional": true
}
}
},
"node_modules/@stryker-mutator/api": {
"version": "9.6.0",
"resolved": "https://registry.npmjs.org/@stryker-mutator/api/-/api-9.6.0.tgz",
@@ -3988,6 +4013,51 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
"integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.20.7",
"@babel/types": "^7.20.7",
"@types/babel__generator": "*",
"@types/babel__template": "*",
"@types/babel__traverse": "*"
}
},
"node_modules/@types/babel__generator": {
"version": "7.27.0",
"resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
"integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.0.0"
}
},
"node_modules/@types/babel__template": {
"version": "7.4.4",
"resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
"integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.1.0",
"@babel/types": "^7.0.0"
}
},
"node_modules/@types/babel__traverse": {
"version": "7.28.0",
"resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
"integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.28.2"
}
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
@@ -4570,6 +4640,68 @@
"js-tokens": "^10.0.0"
}
},
"node_modules/babel-plugin-jsx-dom-expressions": {
"version": "0.40.6",
"resolved": "https://registry.npmjs.org/babel-plugin-jsx-dom-expressions/-/babel-plugin-jsx-dom-expressions-0.40.6.tgz",
"integrity": "sha512-v3P1MW46Lm7VMpAkq0QfyzLWWkC8fh+0aE5Km4msIgDx5kjenHU0pF2s+4/NH8CQn/kla6+Hvws+2AF7bfV5qQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/helper-module-imports": "7.18.6",
"@babel/plugin-syntax-jsx": "^7.18.6",
"@babel/types": "^7.20.7",
"html-entities": "2.3.3",
"parse5": "^7.1.2"
},
"peerDependencies": {
"@babel/core": "^7.20.12"
}
},
"node_modules/babel-plugin-jsx-dom-expressions/node_modules/@babel/helper-module-imports": {
"version": "7.18.6",
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.18.6.tgz",
"integrity": "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/types": "^7.18.6"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/babel-plugin-jsx-dom-expressions/node_modules/parse5": {
"version": "7.3.0",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz",
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"entities": "^6.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/babel-preset-solid": {
"version": "1.9.12",
"resolved": "https://registry.npmjs.org/babel-preset-solid/-/babel-preset-solid-1.9.12.tgz",
"integrity": "sha512-LLqnuKVDlKpyBlMPcH6qEvs/wmS9a+NczppxJ3ryS/c0O5IiSFOIBQi9GzyiGDSbcJpx4Gr87jyFTos1MyEuWg==",
"dev": true,
"license": "MIT",
"dependencies": {
"babel-plugin-jsx-dom-expressions": "^0.40.6"
},
"peerDependencies": {
"@babel/core": "^7.0.0",
"solid-js": "^1.9.12"
},
"peerDependenciesMeta": {
"solid-js": {
"optional": true
}
}
},
"node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
@@ -4885,6 +5017,12 @@
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
}
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
"node_modules/data-urls": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
@@ -5864,6 +6002,13 @@
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/html-entities": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.3.3.tgz",
"integrity": "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA==",
"dev": true,
"license": "MIT"
},
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
@@ -6031,6 +6176,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-what": {
"version": "4.1.16",
"resolved": "https://registry.npmjs.org/is-what/-/is-what-4.1.16.tgz",
"integrity": "sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.13"
},
"funding": {
"url": "https://github.com/sponsors/mesqueeb"
}
},
"node_modules/isexe": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@@ -6479,6 +6637,22 @@
"dev": true,
"license": "CC0-1.0"
},
"node_modules/merge-anything": {
"version": "5.1.7",
"resolved": "https://registry.npmjs.org/merge-anything/-/merge-anything-5.1.7.tgz",
"integrity": "sha512-eRtbOb1N5iyH0tkQDAoQ4Ipsp/5qSR79Dzrz8hEPxRX10RWWR/iQXdoKmBSRCThY1Fh5EhISDtpSc93fpxUniQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-what": "^4.1.8"
},
"engines": {
"node": ">=12.13"
},
"funding": {
"url": "https://github.com/sponsors/mesqueeb"
}
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@@ -7372,6 +7546,27 @@
"node": ">=10"
}
},
"node_modules/seroval": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.2.tgz",
"integrity": "sha512-xcRN39BdsnO9Tf+VzsE7b3JyTJASItIV1FVFewJKCFcW4s4haIKS3e6vj8PGB9qBwC7tnuOywQMdv5N4qkzi7Q==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/seroval-plugins": {
"version": "1.5.2",
"resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.5.2.tgz",
"integrity": "sha512-qpY0Cl+fKYFn4GOf3cMiq6l72CpuVaawb6ILjubOQ+diJ54LfOWaSSPsaswN8DRPIPW4Yq+tE1k5aKd7ILyaFg==",
"license": "MIT",
"engines": {
"node": ">=10"
},
"peerDependencies": {
"seroval": "^1.0"
}
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -7519,6 +7714,32 @@
"url": "https://github.com/sponsors/cyyynthia"
}
},
"node_modules/solid-js": {
"version": "1.9.12",
"resolved": "https://registry.npmjs.org/solid-js/-/solid-js-1.9.12.tgz",
"integrity": "sha512-QzKaSJq2/iDrWR1As6MHZQ8fQkdOBf8GReYb7L5iKwMGceg7HxDcaOHk0at66tNgn9U2U7dXo8ZZpLIAmGMzgw==",
"license": "MIT",
"dependencies": {
"csstype": "^3.1.0",
"seroval": "~1.5.0",
"seroval-plugins": "~1.5.0"
}
},
"node_modules/solid-refresh": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/solid-refresh/-/solid-refresh-0.6.3.tgz",
"integrity": "sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/generator": "^7.23.6",
"@babel/helper-module-imports": "^7.22.15",
"@babel/types": "^7.23.6"
},
"peerDependencies": {
"solid-js": "^1.3"
}
},
"node_modules/source-map": {
"version": "0.7.6",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz",
@@ -8173,6 +8394,51 @@
"url": "https://opencollective.com/vitest"
}
},
"node_modules/vite-plugin-solid": {
"version": "2.11.12",
"resolved": "https://registry.npmjs.org/vite-plugin-solid/-/vite-plugin-solid-2.11.12.tgz",
"integrity": "sha512-FgjPcx2OwX9h6f28jli7A4bG7PP3te8uyakE5iqsmpq3Jqi1TWLgSroC9N6cMfGRU2zXsl4Q6ISvTr2VL0QHpA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/core": "^7.23.3",
"@types/babel__core": "^7.20.4",
"babel-preset-solid": "^1.8.4",
"merge-anything": "^5.1.7",
"solid-refresh": "^0.6.3",
"vitefu": "^1.0.4"
},
"peerDependencies": {
"@testing-library/jest-dom": "^5.16.6 || ^5.17.0 || ^6.*",
"solid-js": "^1.7.2",
"vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"@testing-library/jest-dom": {
"optional": true
}
}
},
"node_modules/vitefu": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.3.tgz",
"integrity": "sha512-ub4okH7Z5KLjb6hDyjqrGXqWtWvoYdU3IGm/NorpgHncKoLTCfRIbvlhBm7r0YstIaQRYlp4yEbFqDcKSzXSSg==",
"dev": true,
"license": "MIT",
"workspaces": [
"tests/deps/*",
"tests/projects/*",
"tests/projects/workspace/packages/*"
],
"peerDependencies": {
"vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"vite": {
"optional": true
}
}
},
"node_modules/vitest": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz",
+37 -2
View File
@@ -33,9 +33,39 @@ class PluginBridge {
private frames = new Map<number, HTMLIFrameElement>();
private listeners = new Set<Listener>();
private themeVars: Record<string, string> = {};
private hostOrigin: string;
constructor() {
window.addEventListener("message", this.onMessage);
// Plugin iframes are served from /api/v1/plugins/... on the same origin
// as the host page, so postMessage targets that origin explicitly. Using
// "*" as the target origin is unsafe — any frame the user navigates to
// would receive host messages. window.location.origin is undefined in
// some test runners (jsdom prior to 16); fall back to "/" which still
// restricts to same-origin under the strict postMessage matching rules.
this.hostOrigin =
typeof window !== "undefined" && window.location && window.location.origin
? window.location.origin
: "/";
if (typeof window !== "undefined") {
window.addEventListener("message", this.onMessage);
}
}
/**
* destroy unhooks the global message listener and clears all mounted
* frames. Intended for tests that create disposable bridge instances; the
* exported `pluginBridge` singleton lives for the lifetime of the page and
* does not need explicit teardown.
*/
destroy(): void {
if (typeof window !== "undefined") {
window.removeEventListener("message", this.onMessage);
}
for (const frame of this.frames.values()) {
frame.remove();
}
this.frames.clear();
this.listeners.clear();
}
/** Replace the theme variables broadcast to plugin iframes. */
@@ -80,9 +110,14 @@ class PluginBridge {
}
private postToFrame(pluginId: number, frame: HTMLIFrameElement, msg: { type: string; payload: unknown }): void {
// Restrict the postMessage target origin to the host page origin so a
// navigated-away iframe (or one whose contentWindow has been swapped)
// cannot receive host messages intended for a sandboxed plugin. The
// plugin asset endpoint is same-origin with the host page, so this
// matches every legitimate plugin iframe.
frame.contentWindow?.postMessage(
{ source: HOST_ORIGIN_PREFIX, pluginId, ...msg },
"*",
this.hostOrigin,
);
}
+2 -1
View File
@@ -14,7 +14,8 @@
* handle.destroy();
*/
import { render, type JSX } from "solid-js/web";
import { render } from "solid-js/web";
import type { JSX } from "solid-js";
export interface SolidMount {
/** The DOM element the Solid root is rendered into. */
+18 -1
View File
@@ -1,7 +1,16 @@
import { defineConfig } from "vitest/config";
import { resolve } from "path";
import solidPlugin from "vite-plugin-solid";
export default defineConfig({
// The Solid plugin must be applied here in addition to vite.config.ts so
// Vitest can transform `.tsx` test files. Without it, JSX in component
// tests is parsed as TypeScript and fails on the angle brackets.
plugins: [
solidPlugin({
include: ["src/components/solid/**/*.{ts,tsx,js,jsx}"],
}),
],
resolve: {
alias: {
"@lib": resolve(__dirname, "src/lib"),
@@ -13,7 +22,15 @@ export default defineConfig({
},
test: {
environment: "jsdom",
include: ["tests/**/*.test.ts"],
// Both legacy `tests/**/*.test.ts` files and component-local
// `src/**/*.test.{ts,tsx}` files are picked up. The latter is required
// for Phase B Step 6 Solid components, whose tests live alongside the
// component file (see src/components/solid/README.md).
include: [
"tests/**/*.test.ts",
"src/**/*.test.ts",
"src/**/*.test.tsx",
],
coverage: {
provider: "v8",
include: ["src/**/*.ts"],
+38 -25
View File
@@ -131,22 +131,27 @@ The session landed:
Still TODO locally:
- [ ] Add the OTel modules to `go.mod`:
```sh
cd Server
go get go.opentelemetry.io/otel@latest \
go.opentelemetry.io/otel/sdk@latest \
go.opentelemetry.io/otel/exporters/prometheus@latest \
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc@latest \
go.opentelemetry.io/contrib/instrumentation/github.com/go-chi/chi/v5/otelchi@latest
go mod tidy
```
- [ ] Replace the placeholder body of `telemetry/telemetry_otel.go`'s
`Init` with the real tracer + meter provider construction and the
`otelchi.Middleware` wiring (see the inline TODO comment with the
call graph).
- [ ] Build with `-tags otel` once the SDK is in `go.mod` and add a CI
job that exercises the tagged build.
- [x] Add the OTel modules to `go.mod` — landed on
`claude/review-phase-completion-PBExk`. go.mod now carries
`go.opentelemetry.io/otel/sdk`, `.../exporters/prometheus`,
`.../exporters/otlp/otlptrace/otlptracegrpc`, and
`go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp`.
(otelhttp replaces the unmaintained otelchi wrapper referenced by
the original plan; otelhttp is upstream-supported and wraps any
`http.Handler` including a Chi router.)
- [x] Replace the placeholder body of `telemetry/telemetry_otel.go`'s
`Init` with the real tracer + meter provider construction. The
tagged build wires an OTel Prometheus exporter (pull), an OTLP/gRPC
trace exporter when `exporter=otlp`, `otelhttp.NewHandler` as the
HTTP middleware, and a real provider that re-binds `AppMetrics`
instruments via `resetAppMetricsForInit`. Tests in
`Server/telemetry/telemetry_otel_test.go`
(`TestOtelInitPrometheusExporter`, `TestOtelTracerRecordsSpan`,
`TestOtelHistogramRecordsSeconds`, `TestOtelShutdownIdempotent`)
run under `go test -tags otel ./telemetry/...`.
- [ ] Add a CI job that exercises `go build -tags otel ./...` and
`go test -tags otel ./telemetry/...`. Both pass locally against
Go 1.25.1.
- [x] Add spans to the remaining service-layer entry points
(`DMService`, `VoiceService`, `InviteService`, `ModerationService`,
`BlockService`, `UserService`) — landed in Pass 3, one entrypoint
@@ -191,15 +196,23 @@ The session landed:
Still TODO locally:
- [ ] Add wazero to `go.mod`:
```sh
cd Server
go get github.com/tetratelabs/wazero@latest
go mod tidy
```
- [ ] Replace the placeholder body in `Server/plugin/sandbox_wazero.go`
with real wazero runtime construction. The file contains an inline
TODO with the exact API call graph.
- [x] Add wazero to `go.mod` — landed on
`claude/review-phase-completion-PBExk`. `go.mod` now requires
`github.com/tetratelabs/wazero v1.11.0`.
- [x] Replace the placeholder body in `Server/plugin/sandbox_wazero.go`
with real wazero runtime construction. The tagged build now owns a
shared `wazero.Runtime` (created in `platformInit`, with WASI
preview-1 imports pre-instantiated), compiles + instantiates each
plugin's `.wasm` entrypoint in `activateWithRuntime`, and tears the
modules + runtime down in `platformDeactivate` / `Close`. Tests in
`Server/plugin/sandbox_wazero_test.go`
(`TestWazeroRegistryCreatesRuntime`,
`TestWazeroActivateCompilesModule`,
`TestWazeroDispatchCommandMissingExport`,
`TestWazeroCloseTearsDownRuntime`,
`TestWazeroInvalidWASMFailsActivation`) run under
`go test -tags wazero ./plugin/...` using a 41-byte embedded WASM
fixture — no external WASM asset required.
- [ ] Replace JSON-only manifest parsing with TOML support behind the
`wazero` build tag (the design doc names `plugin.toml`). Add
`github.com/BurntSushi/toml` and a `parseTOML` shim that falls back
+52 -1
View File
@@ -9,6 +9,7 @@ import (
"io"
"net/http"
"strconv"
"strings"
"github.com/go-chi/chi/v5"
"github.com/owncord/server/plugin"
@@ -56,13 +57,25 @@ func (h *PluginAdminHandler) install(w http.ResponseWriter, r *http.Request) {
http.Error(w, "invalid multipart upload: "+err.Error(), http.StatusBadRequest)
return
}
file, _, err := r.FormFile("plugin")
file, header, err := r.FormFile("plugin")
if err != nil {
http.Error(w, "missing 'plugin' file part", http.StatusBadRequest)
return
}
defer file.Close() //nolint:errcheck
// Reject obviously-wrong uploads early. The real defence is the zip
// reader inside InstallFromZip (content-type is client-supplied and must
// never be trusted for authorisation), but rejecting non-zip MIME types
// here returns a cleaner 400 than a "not a valid zip" error from deep
// inside the registry.
if header != nil {
if ct := header.Header.Get("Content-Type"); ct != "" && !isZipContentType(ct) {
http.Error(w, "plugin upload must be a .zip archive", http.StatusUnsupportedMediaType)
return
}
}
// Read the entire zip into memory — InstallFromZip needs an io.ReaderAt
// for archive/zip and the cap is small enough to be safe.
body, err := io.ReadAll(io.LimitReader(file, maxPluginUploadBytes+1))
@@ -74,6 +87,13 @@ func (h *PluginAdminHandler) install(w http.ResponseWriter, r *http.Request) {
http.Error(w, "plugin upload too large", http.StatusRequestEntityTooLarge)
return
}
// Magic-byte check: a real .zip starts with "PK\x03\x04" (local file
// header) or "PK\x05\x06" (empty archive). Anything else is definitively
// not a zip regardless of what the client labelled it.
if !hasZipMagic(body) {
http.Error(w, "plugin upload is not a valid zip archive", http.StatusBadRequest)
return
}
name, err := h.registry.InstallFromZip(r.Context(), body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
@@ -144,6 +164,37 @@ func (h *PluginAdminHandler) uninstall(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// isZipContentType reports whether ct looks like a zip MIME type. Both the
// IANA-registered application/zip and the legacy application/x-zip-compressed
// (used by some Windows clients) are accepted. The comparison is case-
// insensitive and strips any parameters after a semicolon.
func isZipContentType(ct string) bool {
for i := 0; i < len(ct); i++ {
if ct[i] == ';' {
ct = ct[:i]
break
}
}
switch strings.ToLower(strings.TrimSpace(ct)) {
case "application/zip", "application/x-zip-compressed", "application/octet-stream":
return true
}
return false
}
// hasZipMagic reports whether b begins with the PK signature used by every
// .zip archive. Empty archives use 0x50,0x4b,0x05,0x06; non-empty archives
// start with a local file header 0x50,0x4b,0x03,0x04. Both are accepted.
func hasZipMagic(b []byte) bool {
if len(b) < 4 {
return false
}
if b[0] != 'P' || b[1] != 'K' {
return false
}
return (b[2] == 0x03 && b[3] == 0x04) || (b[2] == 0x05 && b[3] == 0x06)
}
func parsePluginID(w http.ResponseWriter, r *http.Request) (int64, bool) {
idStr := chi.URLParam(r, "id")
id, err := strconv.ParseInt(idStr, 10, 64)
+241
View File
@@ -0,0 +1,241 @@
// Phase C Step 9 — PluginAdminHandler tests.
//
// The handler is covered at the HTTP boundary so the fixtures do not depend
// on the Wazero runtime. A nil Registry exercises the "plugin runtime
// disabled" branch; a real Registry wired against a MemStore exercises the
// happy path.
package api
import (
"archive/zip"
"bytes"
"context"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"github.com/owncord/server/plugin"
"github.com/owncord/server/store"
)
func TestPluginsHandlerListEmptyWhenRegistryNil(t *testing.T) {
h := NewPluginAdminHandler(nil, nil)
req := httptest.NewRequest("GET", "/", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status: got %d, want 200", rec.Code)
}
if strings.TrimSpace(rec.Body.String()) != "[]" {
t.Fatalf("expected empty JSON array, got %q", rec.Body.String())
}
}
func TestPluginsHandlerInstallRejectsWhenRegistryNil(t *testing.T) {
h := NewPluginAdminHandler(nil, nil)
body, contentType := buildZipUpload(t, validPluginZip(t))
req := httptest.NewRequest("POST", "/install", body)
req.Header.Set("Content-Type", contentType)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("status: got %d, want 503", rec.Code)
}
}
func TestPluginsHandlerInstallRejectsNonZipContentType(t *testing.T) {
reg := newTestPluginRegistry(t)
h := NewPluginAdminHandler(reg, nil)
// Build a multipart body whose file part is labelled as text/plain.
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
partHeader := make(map[string][]string)
partHeader["Content-Disposition"] = []string{`form-data; name="plugin"; filename="evil.txt"`}
partHeader["Content-Type"] = []string{"text/plain"}
part, err := mw.CreatePart(partHeader)
if err != nil {
t.Fatal(err)
}
if _, err := part.Write(validPluginZip(t)); err != nil {
t.Fatal(err)
}
_ = mw.Close()
req := httptest.NewRequest("POST", "/install", &buf)
req.Header.Set("Content-Type", mw.FormDataContentType())
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusUnsupportedMediaType {
t.Fatalf("status: got %d, want 415; body=%s", rec.Code, rec.Body.String())
}
}
func TestPluginsHandlerInstallRejectsNonZipMagic(t *testing.T) {
reg := newTestPluginRegistry(t)
h := NewPluginAdminHandler(reg, nil)
body, contentType := buildZipUpload(t, []byte("this is definitely not a zip"))
req := httptest.NewRequest("POST", "/install", body)
req.Header.Set("Content-Type", contentType)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status: got %d, want 400; body=%s", rec.Code, rec.Body.String())
}
}
func TestPluginsHandlerInstallHappyPath(t *testing.T) {
reg := newTestPluginRegistry(t)
mem := store.NewMemStore()
// Wire the store into the handler so /list can show the new row. The
// registry already writes via its own PluginStore.
h := NewPluginAdminHandler(reg, mem)
body, contentType := buildZipUpload(t, validPluginZip(t))
req := httptest.NewRequest("POST", "/install", body)
req.Header.Set("Content-Type", contentType)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusCreated {
t.Fatalf("status: got %d, want 201; body=%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "hello") {
t.Fatalf("expected plugin name in response, got %q", rec.Body.String())
}
}
func TestPluginsHandlerEnableDisableUninstallReturn503WhenRegistryNil(t *testing.T) {
h := NewPluginAdminHandler(nil, nil)
for _, tc := range []struct{ method, path string }{
{"POST", "/1/enable"},
{"POST", "/1/disable"},
{"DELETE", "/1"},
} {
req := httptest.NewRequest(tc.method, tc.path, nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusServiceUnavailable {
t.Fatalf("%s %s status: got %d, want 503", tc.method, tc.path, rec.Code)
}
}
}
func TestPluginsHandlerLifecycleInvalidID(t *testing.T) {
reg := newTestPluginRegistry(t)
h := NewPluginAdminHandler(reg, nil)
req := httptest.NewRequest("POST", "/not-an-int/enable", nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status: got %d, want 400", rec.Code)
}
}
func TestIsZipContentType(t *testing.T) {
cases := map[string]bool{
"application/zip": true,
"application/zip; charset=binary": true,
"APPLICATION/ZIP": true,
"application/x-zip-compressed": true,
"application/octet-stream": true,
"text/plain": false,
"image/png": false,
"": false,
"application/json; charset=utf-8": false,
}
for ct, want := range cases {
if got := isZipContentType(ct); got != want {
t.Errorf("isZipContentType(%q) = %v, want %v", ct, got, want)
}
}
}
func TestHasZipMagic(t *testing.T) {
cases := map[string]bool{
"PK\x03\x04rest": true,
"PK\x05\x06": true,
"PK\x07\x08rest": false, // spanned-archive signature; not accepted here
"not a zip": false,
"": false,
"PK": false,
}
for body, want := range cases {
if got := hasZipMagic([]byte(body)); got != want {
t.Errorf("hasZipMagic(%q) = %v, want %v", body, got, want)
}
}
}
// ── helpers ────────────────────────────────────────────────────────────────
func newTestPluginRegistry(t *testing.T) *plugin.Registry {
t.Helper()
dir := t.TempDir()
mem := store.NewMemStore()
reg, err := plugin.NewRegistry(plugin.Config{
Directory: filepath.Join(dir, "plugins"),
Store: mem,
})
if err != nil {
t.Fatalf("plugin.NewRegistry: %v", err)
}
t.Cleanup(func() { _ = reg.Close(context.Background()) })
return reg
}
// validPluginZip returns a minimal but structurally valid plugin package:
// a plugin.json manifest at the root plus a near-empty hello.wasm that is
// large enough to pass the entrypoint stat but small enough to fly well
// under the zip-bomb cap.
func validPluginZip(t *testing.T) []byte {
t.Helper()
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
mj, err := zw.Create("plugin.json")
if err != nil {
t.Fatal(err)
}
if _, err := mj.Write([]byte(`{"name":"hello","version":"0.1.0","entrypoint":"hello.wasm","permissions":["commands"]}`)); err != nil {
t.Fatal(err)
}
w, err := zw.Create("hello.wasm")
if err != nil {
t.Fatal(err)
}
// Minimal "placeholder" wasm magic bytes. Default build does not attempt
// to compile the module, so any bytes with the wasm magic suffice for
// InstallFromZip's on-disk validation.
if _, err := w.Write([]byte("\x00asm\x01\x00\x00\x00")); err != nil {
t.Fatal(err)
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
// buildZipUpload wraps bodyBytes in a multipart form with a single "plugin"
// file part labelled as application/zip.
func buildZipUpload(t *testing.T, bodyBytes []byte) (io.Reader, string) {
t.Helper()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
partHeader := make(map[string][]string)
partHeader["Content-Disposition"] = []string{`form-data; name="plugin"; filename="hello.zip"`}
partHeader["Content-Type"] = []string{"application/zip"}
part, err := mw.CreatePart(partHeader)
if err != nil {
t.Fatal(err)
}
if _, err := part.Write(bodyBytes); err != nil {
t.Fatal(err)
}
if err := mw.Close(); err != nil {
t.Fatal(err)
}
return &buf, mw.FormDataContentType()
}
+28 -17
View File
@@ -4,8 +4,10 @@ go 1.25.0
require (
aead.dev/minisign v0.3.0
github.com/corazawaf/coraza/v3 v3.6.0
github.com/go-chi/chi/v5 v5.2.5
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.9.1
github.com/knadh/koanf/parsers/yaml v1.1.0
github.com/knadh/koanf/providers/env v1.1.0
github.com/knadh/koanf/providers/file v1.2.1
@@ -14,7 +16,17 @@ require (
github.com/livekit/protocol v1.45.1
github.com/livekit/server-sdk-go/v2 v2.16.0
github.com/microcosm-cc/bluemonday v1.0.27
github.com/prometheus/client_golang v1.23.2
github.com/sasha-s/go-deadlock v0.3.9
github.com/tetratelabs/wazero v1.11.0
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0
go.opentelemetry.io/otel v1.43.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0
go.opentelemetry.io/otel/exporters/prometheus v0.65.0
go.opentelemetry.io/otel/metric v1.43.0
go.opentelemetry.io/otel/sdk v1.43.0
go.opentelemetry.io/otel/sdk/metric v1.43.0
go.opentelemetry.io/otel/trace v1.43.0
go.uber.org/goleak v1.3.0
go.yaml.in/yaml/v3 v3.0.4
golang.org/x/crypto v0.49.0
@@ -28,19 +40,19 @@ require (
buf.build/go/protovalidate v1.1.2 // indirect
buf.build/go/protoyaml v0.6.0 // indirect
cel.dev/expr v0.25.1 // indirect
github.com/Shopify/toxiproxy/v2 v2.12.0 // indirect
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/benbjohnson/clock v1.3.5 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bep/debounce v1.2.1 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/corazawaf/coraza/v3 v3.6.0 // indirect
github.com/corazawaf/libinjection-go v0.3.2 // indirect
github.com/dennwc/iters v1.2.2 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fatih/structs v1.1.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/frostbyte73/core v0.1.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gammazero/deque v1.2.1 // indirect
@@ -52,23 +64,24 @@ require (
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/google/cel-go v0.27.0 // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/gorilla/mux v1.8.1 // indirect
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // indirect
github.com/gotnospirit/makeplural v0.0.0-20180622080156-a5f48d94d976 // indirect
github.com/gotnospirit/messageformat v0.0.0-20221001023931-dfe49f1eb092 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jxskiss/base62 v1.1.0 // indirect
github.com/kaptinlin/go-i18n v0.1.4 // indirect
github.com/kaptinlin/jsonschema v0.4.6 // indirect
github.com/klauspost/compress v1.18.4 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/knadh/koanf/maps v0.1.2 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/lithammer/shortuuid/v4 v4.2.0 // indirect
github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 // indirect
github.com/livekit/mediatransportutil v0.0.0-20251128105421-19c7a7b81c22 // indirect
github.com/livekit/psrpc v0.7.1 // indirect
github.com/magefile/mage v1.15.1-0.20250615140142-78acbaf2e3ae // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
@@ -96,16 +109,13 @@ require (
github.com/pion/transport/v4 v4.0.1 // indirect
github.com/pion/turn/v4 v4.1.4 // indirect
github.com/pion/webrtc/v4 v4.2.9 // indirect
github.com/prometheus/client_golang v1.22.0 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.64.0 // indirect
github.com/prometheus/procfs v0.19.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/otlptranslator v1.0.0 // indirect
github.com/prometheus/procfs v0.20.1 // indirect
github.com/puzpuzpuz/xsync/v3 v3.5.1 // indirect
github.com/redis/go-redis/v9 v9.17.2 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rogpeppe/go-internal v1.10.0 // indirect
github.com/rs/xid v1.5.0 // indirect
github.com/rs/zerolog v1.33.0 // indirect
github.com/tidwall/gjson v1.18.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect
@@ -113,23 +123,24 @@ require (
github.com/valllabh/ocsf-schema-golang v1.0.3 // indirect
github.com/wlynxg/anet v0.0.5 // indirect
github.com/zeebo/xxh3 v1.1.0 // indirect
go.opentelemetry.io/otel v1.40.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.27.1 // indirect
go.uber.org/zap/exp v0.3.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect
golang.org/x/time v0.14.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 // indirect
google.golang.org/grpc v1.79.3 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
google.golang.org/grpc v1.80.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.70.0 // indirect
modernc.org/mathutil v1.7.1 // indirect
+67 -50
View File
@@ -16,8 +16,6 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5 h1:TngWCqHvy9oXAN6lEVMRuU21PR1EtLVZJmdB18Gu3Rw=
github.com/Nvveen/Gotty v0.0.0-20120604004816-cd527374f1e5/go.mod h1:lmUJ/7eu/Q8D7ML55dXQrVaamCz2vxCfdQBasLZfHKk=
github.com/Shopify/toxiproxy/v2 v2.12.0 h1:d1x++lYZg/zijXPPcv7PH0MvHMzEI5aX/YuUi/Sw+yg=
github.com/Shopify/toxiproxy/v2 v2.12.0/go.mod h1:R9Z38Pw6k2cGZWXHe7tbxjGW9azmY1KbDQJ1kd+h7Tk=
github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk=
@@ -36,6 +34,8 @@ github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/containerd/continuity v0.4.5 h1:ZRoN1sXq9u7V6QoHMcVWGhOwDFqZ4B9i5H6un1Wh0x4=
@@ -44,12 +44,12 @@ github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/corazawaf/coraza-coreruleset v0.0.0-20240226094324-415b1017abdc h1:OlJhrgI3I+FLUCTI3JJW8MoqyM78WbqJjecqMnqG+wc=
github.com/corazawaf/coraza-coreruleset v0.0.0-20240226094324-415b1017abdc/go.mod h1:7rsocqNDkTCira5T0M7buoKR2ehh7YZiPkzxRuAgvVU=
github.com/corazawaf/coraza/v3 v3.6.0 h1:rfsGl6eRBzzUAyADFcpuO7qXLt0DZtYWhfTIuhcyAjQ=
github.com/corazawaf/coraza/v3 v3.6.0/go.mod h1:q7gszZCSufoHIy9jV2NCgk+glYwZpP2mIKgbu2dZkvE=
github.com/corazawaf/libinjection-go v0.3.2 h1:9rrKt0lpg4WvUXt+lwS06GywfqRXXsa/7JcOw5cQLwI=
github.com/corazawaf/libinjection-go v0.3.2/go.mod h1:Ik/+w3UmTWH9yn366RgS9D95K3y7Atb5m/H/gXzzPCk=
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -71,6 +71,8 @@ github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/foxcpp/go-mockdns v1.1.0 h1:jI0rD8M0wuYAxL7r/ynTrCQQq0BVqfB99Vgk7DlmewI=
github.com/foxcpp/go-mockdns v1.1.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk=
github.com/frostbyte73/core v0.1.1 h1:ChhJOR7bAKOCPbA+lqDLE2cGKlCG5JXsDvvQr4YaJIA=
github.com/frostbyte73/core v0.1.1/go.mod h1:mhfOtR+xWAvwXiwor7jnqPMnu4fxbv1F2MwZ0BEpzZo=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
@@ -92,7 +94,6 @@ github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/cel-go v0.27.0 h1:e7ih85+4qVrBuqQWTW4FKSqZYokVuc3HnhH5keboFTo=
@@ -108,16 +109,26 @@ 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/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo=
github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA=
github.com/gotnospirit/makeplural v0.0.0-20180622080156-a5f48d94d976 h1:b70jEaX2iaJSPZULSUxKtm73LBfsCrMsIlYCUgNGSIs=
github.com/gotnospirit/makeplural v0.0.0-20180622080156-a5f48d94d976/go.mod h1:ZGQeOwybjD8lkCjIyJfqR5LD2wMVHJ31d6GdPxoTsWY=
github.com/gotnospirit/messageformat v0.0.0-20221001023931-dfe49f1eb092 h1:c7gcNWTSr1gtLp6PyYi3wzvFCEcHJ4YRobDgqmIgf7Q=
github.com/gotnospirit/messageformat v0.0.0-20221001023931-dfe49f1eb092/go.mod h1:ZZAN4fkkful3l1lpJwF8JbW41ZiG9TwJ2ZlqzQovBNU=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc=
github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jcchavezs/mergefs v0.1.0 h1:7oteO7Ocl/fnfFMkoVLJxTveCjrsd//UB0j89xmnpec=
github.com/jcchavezs/mergefs v0.1.0/go.mod h1:eRLTrsA+vFwQZ48hj8p8gki/5v9C2bFtHH5Mnn4bcGk=
github.com/jxskiss/base62 v1.1.0 h1:A5zbF8v8WXx2xixnAKD2w+abC+sIzYJX+nxmhA6HWFw=
github.com/jxskiss/base62 v1.1.0/go.mod h1:HhWAlUXvxKThfOlZbcuFzsqwtF5TcqS9ru3y5GfjWAc=
github.com/kaptinlin/go-i18n v0.1.4 h1:wCiwAn1LOcvymvWIVAM4m5dUAMiHunTdEubLDk4hTGs=
@@ -140,13 +151,12 @@ github.com/knadh/koanf/providers/structs v1.0.0 h1:DznjB7NQykhqCar2LvNug3MuxEQsZ
github.com/knadh/koanf/providers/structs v1.0.0/go.mod h1:kjo5TFtgpaZORlpoJqcbeLowM2cINodv8kX+oFAeQ1w=
github.com/knadh/koanf/v2 v2.3.3 h1:jLJC8XCRfLC7n4F+ZKKdBsbq1bfXTpuFhf4L7t94D94=
github.com/knadh/koanf/v2 v2.3.3/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/lithammer/shortuuid/v4 v4.2.0 h1:LMFOzVB3996a7b8aBuEXxqOBflbfPQAiVzkIcHO0h8c=
github.com/lithammer/shortuuid/v4 v4.2.0/go.mod h1:D5noHZ2oFw/YaKCfGy0YxyE7M0wMbezmMjPdhyEFe6Y=
github.com/livekit/mageutil v0.0.0-20250511045019-0f1ff63f7731 h1:9x+U2HGLrSw5ATTo469PQPkqzdoU7be46ryiCDO3boc=
@@ -159,18 +169,14 @@ github.com/livekit/psrpc v0.7.1 h1:ms37az0QTD3UXIWuUC5D/SkmKOlRMVRsI261eBWu/Vw=
github.com/livekit/psrpc v0.7.1/go.mod h1:bZ4iHFQptTkbPnB0LasvRNu/OBYXEu1NA6O5BMFo9kk=
github.com/livekit/server-sdk-go/v2 v2.16.0 h1:xbr6PLprgasruzEk4Qv2sHVcK6r+cebUvaHxeE4UsZs=
github.com/livekit/server-sdk-go/v2 v2.16.0/go.mod h1:+HCKTpzV21b/jvBtu+OmWbquUxaL74kHLI9ZwKmdhKU=
github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg=
github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
github.com/magefile/mage v1.15.1-0.20250615140142-78acbaf2e3ae h1:yyMUG1VUd6IjV5jonMKpLXgwm9AzkfRsYisdCXc5OVI=
github.com/magefile/mage v1.15.1-0.20250615140142-78acbaf2e3ae/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
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/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/miekg/dns v1.1.57 h1:Jzi7ApEIzwEPLHWRcafCN9LZSBbqQpxjt/wpgvg7wcM=
github.com/miekg/dns v1.1.57/go.mod h1:uqRjCRUuEAA6qsOiJvDd+CFo/vW+y5WR6SNmHE55hZk=
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
@@ -203,6 +209,8 @@ github.com/opencontainers/runc v1.3.3 h1:qlmBbbhu+yY0QM7jqfuat7M1H3/iXjju3VkP9lk
github.com/opencontainers/runc v1.3.3/go.mod h1:D7rL72gfWxVs9cJ2/AayxB0Hlvn9g0gaF1R7uunumSI=
github.com/ory/dockertest/v3 v3.12.0 h1:3oV9d0sDzlSQfHtIaB5k6ghUCVMVLpAY8hwrqoCyRCw=
github.com/ory/dockertest/v3 v3.12.0/go.mod h1:aKNDTva3cp8dwOWwb9cWuX84aH5akkxXRvO7KCwWVjE=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/petar-dambovaliev/aho-corasick v0.0.0-20250424160509-463d218d4745 h1:Vpr4VgAizEgEZsaMohpw6JYDP+i9Of9dmdY4ufNP6HI=
github.com/petar-dambovaliev/aho-corasick v0.0.0-20250424160509-463d218d4745/go.mod h1:EHPiTAKtiFmrMldLUNswFwfZ2eJIYBHktdaUTZxYWRw=
github.com/petermattis/goid v0.0.0-20250813065127-a731cc31b4fe h1:vHpqOnPlnkba8iSxU4j/CvDSS9J4+F4473esQsYLGoE=
@@ -241,20 +249,21 @@ github.com/pion/turn/v4 v4.1.4 h1:EU11yMXKIsK43FhcUnjLlrhE4nboHZq+TXBIi3QpcxQ=
github.com/pion/turn/v4 v4.1.4/go.mod h1:ES1DXVFKnOhuDkqn9hn5VJlSWmZPaRJLyBXoOeO/BmQ=
github.com/pion/webrtc/v4 v4.2.9 h1:DZIh1HAhPIL3RvwEDFsmL5hfPSLEpxsQk9/Jir2vkJE=
github.com/pion/webrtc/v4 v4.2.9/go.mod h1:9EmLZve0H76eTzf8v2FmchZ6tcBXtDgpfTEu+drW6SY=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.64.0 h1:pdZeA+g617P7oGv1CzdTzyeShxAGrTBsolKNOLQPGO4=
github.com/prometheus/common v0.64.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos=
github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM=
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
github.com/puzpuzpuz/xsync/v3 v3.5.1 h1:GJYJZwO6IdxN/IKbneznS6yPkVC+c3zyY/j19c++5Fg=
github.com/puzpuzpuz/xsync/v3 v3.5.1/go.mod h1:VjzYrABPabuM4KyBh1Ftq6u8nhwY5tBPKP9jpmh0nnA=
github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
@@ -263,13 +272,8 @@ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rodaine/protogofakeit v0.1.1 h1:ZKouljuRM3A+TArppfBqnH8tGZHOwM/pjvtXe9DaXH8=
github.com/rodaine/protogofakeit v0.1.1/go.mod h1:pXn/AstBYMaSfc1/RqH3N82pBuxtWgejz1AlYpY1mI0=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sasha-s/go-deadlock v0.3.9 h1:fiaT9rB7g5sr5ddNZvlwheclN9IP86eFW9WgqlEQV+w=
github.com/sasha-s/go-deadlock v0.3.9/go.mod h1:KuZj51ZFmx42q/mPaYbRk0P1xcwe697zsJKE03vD4/Y=
github.com/shoenig/test v1.7.0 h1:eWcHtTXa6QLnBvm0jgEabMRN/uJ4DMV3M8xUGgRkZmk=
@@ -277,9 +281,12 @@ github.com/shoenig/test v1.7.0/go.mod h1:UxJ6u/x2v/TNs/LoLxBNJRV9DiwBBKYxXSyczsB
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tetratelabs/wazero v1.11.0 h1:+gKemEuKCTevU4d7ZTzlsvgd1uaToIDtlQlmNbwqYhA=
github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU=
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
@@ -306,14 +313,26 @@ github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+JPfvEmvy+0LzyUA25/SGawPwIUbZ6u0Wug54sLc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk=
go.opentelemetry.io/otel/exporters/prometheus v0.65.0 h1:jOveH/b4lU9HT7y+Gfamf18BqlOuz2PWEvs8yM7Q6XE=
go.opentelemetry.io/otel/exporters/prometheus v0.65.0/go.mod h1:i1P8pcumauPtUI4YNopea1dhzEMuEqWP1xoUZDylLHo=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
@@ -324,6 +343,8 @@ go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.uber.org/zap/exp v0.3.0 h1:6JYzdifzYkGmTdRR59oYH+Ng7k49H9qVpWwNSsGJj3U=
go.uber.org/zap/exp v0.3.0/go.mod h1:5I384qq7XGxYyByIhHm6jg5CHkGY0nsTfbDLgDDlgJQ=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
@@ -342,8 +363,6 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
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.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -356,11 +375,9 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
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-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/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.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
@@ -386,19 +403,19 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57 h1:JLQynH/LBHfCTSbDWl+py8C+Rg/k1OVH3xfcaiANuF0=
google.golang.org/genproto/googleapis/api v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:kSJwQxqmFXeo79zOmbrALdflXQeAYcUbgS7PbpMknCY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57 h1:mWPCjDEyshlQYzBpMNHaEof6UX1PmHcaUODUywQ0uac=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260209200024-4cfbd4190f57/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+46 -10
View File
@@ -48,10 +48,12 @@ type Registry struct {
// Subscribe; the WS hub calls sink.Dispatch on each broadcast.
sink *EventSink
// runtimePlatform is set by the wazero-tagged build's NewRegistry to a
// concrete *wazero.Runtime. The default build leaves it nil and falls
// back to manifest-only behaviour.
// runtimePlatform is populated by platformInit in the wazero-tagged build
// with a concrete *wazero.Runtime. The default build leaves it nil and
// falls back to manifest-only behaviour. platformClose tears the runtime
// down; both fields are set by platformInit atomically.
runtimePlatform any
platformClose func(context.Context) error
}
// Instance is a single loaded plugin.
@@ -81,20 +83,32 @@ func NewRegistry(cfg Config) (*Registry, error) {
if cfg.Store == nil {
return nil, fmt.Errorf("plugin: NewRegistry requires a non-nil PluginStore")
}
return &Registry{
r := &Registry{
cfg: cfg,
plugins: make(map[int64]*Instance),
byName: make(map[string]*Instance),
commands: make(map[string]*Instance),
sink: NewEventSink(),
}, nil
}
// platformInit is supplied by sandbox_default.go (no-op) or
// sandbox_wazero.go (real Wazero runtime). Either way it owns the
// runtimePlatform + platformClose pair on the Registry.
platform, closeFn, err := platformInit(cfg)
if err != nil {
return nil, fmt.Errorf("plugin: platform init: %w", err)
}
r.runtimePlatform = platform
r.platformClose = closeFn
return r, nil
}
// Close shuts the registry down. In the wazero-tagged build it tears the
// runtime down and frees module memory.
func (r *Registry) Close(ctx context.Context) error {
r.mu.Lock()
defer r.mu.Unlock()
for _, inst := range r.plugins {
r.platformDeactivate(inst)
}
for id := range r.plugins {
delete(r.plugins, id)
}
@@ -105,6 +119,13 @@ func (r *Registry) Close(ctx context.Context) error {
delete(r.commands, c)
}
r.uiTabs = nil
closeFn := r.platformClose
r.platformClose = nil
r.runtimePlatform = nil
r.mu.Unlock()
if closeFn != nil {
return closeFn(ctx)
}
return nil
}
@@ -378,12 +399,21 @@ func (r *Registry) activateAll(ctx context.Context) error {
// activate compiles and starts a single plugin module. Default build returns
// ErrRuntimeUnavailable; the wazero-tagged build replaces this with the real
// implementation via the runtimePlatform field.
// implementation via activateWithRuntime.
//
// The runtimePlatform read is guarded by r.mu so a concurrent Close() that
// nil-s the field cannot be observed mid-activation. The captured platform
// value is then passed into activateWithRuntime as a parameter so the actual
// compile uses the snapshot rather than re-reading r.runtimePlatform — this
// closes the race window between the nil check and the wazero call.
func (r *Registry) activate(ctx context.Context, inst *Instance) error {
if r.runtimePlatform == nil {
r.mu.RLock()
platform := r.runtimePlatform
r.mu.RUnlock()
if platform == nil {
return ErrRuntimeUnavailable
}
return r.activateWithRuntime(ctx, inst)
return r.activateWithRuntime(ctx, platform, inst)
}
// EnablePlugin marks a plugin enabled in the store, then attempts to load it.
@@ -407,7 +437,9 @@ func (r *Registry) EnablePlugin(ctx context.Context, id int64) error {
return nil
}
// DisablePlugin marks a plugin disabled and tears its module down.
// DisablePlugin marks a plugin disabled and tears its module down. The
// wazero-tagged build frees the compiled module via platformDeactivate so
// re-enabling recompiles from disk; the default build is a no-op.
func (r *Registry) DisablePlugin(ctx context.Context, id int64) error {
if err := r.cfg.Store.DisablePlugin(ctx, id); err != nil {
return err
@@ -422,6 +454,10 @@ func (r *Registry) DisablePlugin(ctx context.Context, id int64) error {
delete(r.commands, cmd)
}
}
// Free the wazero module so memory is returned to the runtime
// immediately rather than waiting for registry Close. Safe to call
// on an instance that was never activated.
r.platformDeactivate(inst)
}
return nil
}
+12 -2
View File
@@ -9,15 +9,25 @@ import (
"context"
)
// platformInit is a no-op in the default build — there is no Wazero runtime
// to stand up, and NewRegistry leaves runtimePlatform nil so the lifecycle
// methods fall through to ErrRuntimeUnavailable.
func platformInit(_ Config) (any, func(context.Context) error, error) {
return nil, nil, nil
}
// activateWithRuntime is a no-op in the default build. It is only called from
// Registry.activate when runtimePlatform is non-nil, which never happens here.
func (r *Registry) activateWithRuntime(ctx context.Context, inst *Instance) error {
func (r *Registry) activateWithRuntime(_ context.Context, _ any, _ *Instance) error {
return ErrRuntimeUnavailable
}
// platformDeactivate is called from Close on each plugin; a no-op here.
func (r *Registry) platformDeactivate(_ *Instance) {}
// invokeCommand returns an error result instructing the operator to enable
// the wazero build tag. Default build only.
func (r *Registry) invokeCommand(ctx context.Context, inst *Instance, userID, channelID int64, cmd string, args []string) (*CommandResult, bool) {
func (r *Registry) invokeCommand(_ context.Context, _ *Instance, _, _ int64, _ string, _ []string) (*CommandResult, bool) {
return &CommandResult{
Reply: "plugin runtime disabled — rebuild server with -tags wazero to execute plugin commands",
}, true
+137 -53
View File
@@ -1,79 +1,163 @@
//go:build wazero
// Real Wazero-backed plugin runtime. Compiled only with `-tags wazero`,
// matching the postgres / otel build-tag pattern used elsewhere in the repo.
// Phase C Step 9 — Real Wazero-backed plugin runtime. Compiled only with
// `-tags wazero`; matches the postgres / otel build-tag pattern used
// elsewhere in the repo so the default sqlite-only build does not pull
// wazero into go.mod at runtime.
//
// IMPORTANT: This file is a structural skeleton — it will fail to compile
// until github.com/tetratelabs/wazero is added to go.mod. To finish wiring it
// on a machine with network access:
// Architecture
//
// cd Server
// go get github.com/tetratelabs/wazero@latest
// go mod tidy
// go build -tags wazero ./...
// The wazero-tagged build provides:
//
// The skeleton documents the intended call graph so the implementation work
// is mechanical: each TODO marker maps to a wazero API call.
// platformInit — creates the shared wazero.Runtime and returns a
// teardown closure consumed by Registry.Close.
// activateWithRuntime — compiles the plugin's .wasm entrypoint, instantiates
// it against the shared runtime with WASI enabled,
// and stores the resulting api.Module on the Instance.
// platformDeactivate — closes the per-plugin module without tearing down
// the shared runtime.
// invokeCommand — calls the plugin's `command_dispatch` export when
// present. The initial wiring keeps the host/guest
// protocol intentionally small: `command_dispatch()`
// takes no parameters and returns a single i32 status
// code. A future iteration will extend this to pass
// command text via guest memory and return a reply.
//
// Any .wasm that does not export command_dispatch is still valid — DispatchCommand
// reports a user-facing "no command_dispatch export" message so operators can
// diagnose mis-built plugins without crashing the server.
package plugin
import (
"context"
"fmt"
"os"
"github.com/tetratelabs/wazero"
"github.com/tetratelabs/wazero/api"
"github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1"
)
// activateWithRuntime compiles inst.WASMPath into a wazero module, applies
// the per-plugin resource caps, and registers exported functions for each
// declared capability.
func (r *Registry) activateWithRuntime(ctx context.Context, inst *Instance) error {
// platformInit stands up the shared wazero runtime for this Registry. The
// runtime is the top-level handle that owns compiled modules, host modules,
// and per-instance linear memory; every plugin in this registry shares it.
func platformInit(cfg Config) (any, func(context.Context) error, error) {
ctx := context.Background()
rt := wazero.NewRuntimeWithConfig(ctx,
wazero.NewRuntimeConfig().
WithCloseOnContextDone(true),
)
// WASI is required for TinyGo/Rust plugins that link against the
// standard library; without it even a `main` entrypoint that prints
// anything will fail to instantiate.
if _, err := wasi_snapshot_preview1.Instantiate(ctx, rt); err != nil {
_ = rt.Close(ctx)
return nil, nil, fmt.Errorf("wazero: wasi snapshot_preview1: %w", err)
}
_ = cfg // HTTPAllowlist / resource caps are applied per-module in activateWithRuntime
closeFn := func(shutCtx context.Context) error {
return rt.Close(shutCtx)
}
return rt, closeFn, nil
}
// activateWithRuntime compiles inst.WASMPath into a wazero module and
// instantiates it under the shared runtime. The resulting api.Module is
// stashed on inst.module so lifecycle teardown (Close, DisablePlugin) can
// free it without walking the registry again.
//
// The runtime is passed in by Registry.activate as a captured snapshot so
// this function never re-reads r.runtimePlatform — that field can be nil-ed
// concurrently by Close, but the snapshot remains valid (the wazero runtime
// itself returns an error gracefully if it has been closed underneath us).
func (r *Registry) activateWithRuntime(ctx context.Context, platform any, inst *Instance) error {
rt, ok := platform.(wazero.Runtime)
if !ok || rt == nil {
return fmt.Errorf("plugin %q: wazero runtime unavailable", inst.Manifest.Name)
}
wasmBytes, err := os.ReadFile(inst.WASMPath)
if err != nil {
return fmt.Errorf("plugin %q: read wasm: %w", inst.Manifest.Name, err)
}
_ = wasmBytes
_ = ctx
// TODO(wazero): replace with the real wiring once go.mod has wazero:
//
// runtime := r.runtimePlatform.(wazero.Runtime)
// compiled, err := runtime.CompileModule(ctx, wasmBytes)
// if err != nil { return fmt.Errorf("compile: %w", err) }
//
// modCfg := wazero.NewModuleConfig().
// WithName(inst.Manifest.Name).
// WithStdout(io.Discard).
// WithStderr(io.Discard)
//
// memBytes := uint32(inst.Manifest.Resources.MaxMemoryMB)
// if memBytes == 0 { memBytes = uint32(r.cfg.MaxMemoryMB) }
// // wazero pages are 64 KiB; the runtime config caps via WithMemoryLimitPages.
//
// module, err := runtime.InstantiateModule(ctx, compiled, modCfg)
// if err != nil { return fmt.Errorf("instantiate: %w", err) }
//
// inst.module = module
//
// // Walk inst.Manifest.Permissions and call host_*.Register* for each
// // capability so the runtime knows what exports to look for.
compiled, err := rt.CompileModule(ctx, wasmBytes)
if err != nil {
return fmt.Errorf("plugin %q: compile: %w", inst.Manifest.Name, err)
}
return fmt.Errorf("plugin %q: wazero runtime skeleton incomplete (see sandbox_wazero.go)", inst.Manifest.Name)
// Each plugin gets its own module name so multiple instances can coexist
// without colliding in the runtime's global module namespace. Output is
// swallowed to keep misbehaving plugins from flooding server logs.
modCfg := wazero.NewModuleConfig().
WithName(inst.Manifest.Name).
WithStdout(discardWriter{}).
WithStderr(discardWriter{})
module, err := rt.InstantiateModule(ctx, compiled, modCfg)
if err != nil {
return fmt.Errorf("plugin %q: instantiate: %w", inst.Manifest.Name, err)
}
inst.module = module
return nil
}
// invokeCommand calls the plugin's `command_dispatch` exported function with
// the marshalled command + args, and decodes the response into a CommandResult.
// platformDeactivate closes the wazero module held by inst without touching
// the shared runtime. Safe to call on an instance that was never activated.
func (r *Registry) platformDeactivate(inst *Instance) {
if inst == nil || inst.module == nil {
return
}
if mod, ok := inst.module.(api.Module); ok {
_ = mod.Close(context.Background())
}
inst.module = nil
}
// invokeCommand is the command-capability entrypoint. The host-guest protocol
// is deliberately minimal in this first iteration:
//
// - If the plugin exports `command_dispatch` with signature `() -> i32`, the
// host calls it. A return value of 0 is treated as success; any non-zero
// value becomes an error reply.
// - If the export is absent, the host returns a user-facing diagnostic.
//
// The plan is to extend this to pass the command string + args through guest
// memory (alloc/free host-side helpers) once the first real plugin needs it.
func (r *Registry) invokeCommand(ctx context.Context, inst *Instance, userID, channelID int64, cmd string, args []string) (*CommandResult, bool) {
_ = ctx
_ = inst
_ = userID
_ = channelID
_ = cmd
_ = args
// TODO(wazero): real call:
// fn := module.ExportedFunction("command_dispatch")
// payload := encodeCommand(userID, channelID, cmd, args)
// result, err := fn.Call(ctx, ...)
// ...
return &CommandResult{
Reply: "plugin runtime: command dispatch not yet implemented in skeleton",
}, true
if inst == nil || inst.module == nil {
return &CommandResult{Reply: fmt.Sprintf("plugin %q is not activated", cmd)}, true
}
mod, ok := inst.module.(api.Module)
if !ok {
return &CommandResult{Reply: fmt.Sprintf("plugin %q: module type mismatch", inst.Manifest.Name)}, true
}
fn := mod.ExportedFunction("command_dispatch")
if fn == nil {
return &CommandResult{
Reply: fmt.Sprintf("plugin %q does not export command_dispatch (rebuild the plugin to handle /%s)", inst.Manifest.Name, cmd),
}, true
}
res, err := fn.Call(ctx)
if err != nil {
return &CommandResult{Reply: fmt.Sprintf("plugin %q: command_dispatch errored: %v", inst.Manifest.Name, err)}, true
}
status := uint64(0)
if len(res) > 0 {
status = res[0]
}
if status != 0 {
return &CommandResult{Reply: fmt.Sprintf("plugin %q: command_dispatch returned status %d", inst.Manifest.Name, status)}, true
}
return &CommandResult{Reply: fmt.Sprintf("plugin %q: /%s ok", inst.Manifest.Name, cmd)}, true
}
// discardWriter is a tiny io.Writer that throws everything away. Wazero's
// ModuleConfig accepts any io.Writer for stdout/stderr; using io.Discard would
// pull the extra import just to satisfy two calls.
type discardWriter struct{}
func (discardWriter) Write(p []byte) (int, error) { return len(p), nil }
+228
View File
@@ -0,0 +1,228 @@
//go:build wazero
// Phase C Step 9 — Wazero runtime integration tests. Only compiled with
// `-tags wazero`, alongside sandbox_wazero.go. These tests exercise the
// behaviours the default build cannot:
//
// - NewRegistry stands up a real wazero.Runtime
// - activateWithRuntime compiles a real .wasm module and tracks the
// resulting instance on the Instance struct
// - EnablePlugin takes a manifest from a PluginStore row through
// activation end-to-end
// - invokeCommand gracefully returns a user-facing error when the plugin
// does not export command_dispatch
// - Close tears the runtime down without panicking
//
// Test fixture: the 41-byte `add.wasm` module from the wazero examples, a
// trivial module that exports `add(i32,i32) -> i32`. It does NOT export
// `command_dispatch`, which is intentional — the command path must handle
// that case.
package plugin
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/owncord/server/store"
)
// addWASM is the bytes of a minimal (module (func (export "add") ... )).
// Verified against the wazero examples fixture; 41 bytes. Using a literal
// here avoids dragging a binary asset into the repo.
var addWASM = []byte{
0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00,
0x01, 0x07, 0x01, 0x60, 0x02, 0x7f, 0x7f, 0x01,
0x7f, 0x03, 0x02, 0x01, 0x00, 0x07, 0x07, 0x01,
0x03, 0x61, 0x64, 0x64, 0x00, 0x00, 0x0a, 0x09,
0x01, 0x07, 0x00, 0x20, 0x00, 0x20, 0x01, 0x6a,
0x0b,
}
func writeTestPlugin(t *testing.T, root, name string, manifest string, wasmBytes []byte) {
t.Helper()
pluginDir := filepath.Join(root, name)
if err := os.MkdirAll(pluginDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(pluginDir, "plugin.json"), []byte(manifest), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(pluginDir, "hello.wasm"), wasmBytes, 0o644); err != nil {
t.Fatal(err)
}
}
func newWazeroTestRegistry(t *testing.T, dir string) (*Registry, store.PluginStore) {
t.Helper()
mem := store.NewMemStore()
reg, err := NewRegistry(Config{
Directory: dir,
MaxMemoryMB: 16,
CPUBudgetMs: 100,
Store: mem,
})
if err != nil {
t.Fatalf("NewRegistry: %v", err)
}
t.Cleanup(func() { _ = reg.Close(context.Background()) })
return reg, mem
}
func TestWazeroRegistryCreatesRuntime(t *testing.T) {
reg, _ := newWazeroTestRegistry(t, t.TempDir())
if reg.runtimePlatform == nil {
t.Fatal("expected wazero runtime to be wired in tagged build")
}
if reg.platformClose == nil {
t.Fatal("expected platformClose to be set")
}
}
func TestWazeroActivateCompilesModule(t *testing.T) {
dir := t.TempDir()
manifest := `{"name":"hello","version":"0.1.0","entrypoint":"hello.wasm","permissions":["commands"]}`
writeTestPlugin(t, dir, "hello", manifest, addWASM)
reg, mem := newWazeroTestRegistry(t, dir)
ctx := context.Background()
if err := reg.LoadAll(ctx); err != nil {
t.Fatalf("LoadAll: %v", err)
}
rows, err := mem.ListPlugins(ctx)
if err != nil || len(rows) != 1 {
t.Fatalf("ListPlugins: rows=%+v err=%v", rows, err)
}
// The plugin starts disabled; enable it and confirm the module compiles.
if err := reg.EnablePlugin(ctx, rows[0].ID); err != nil {
t.Fatalf("EnablePlugin: %v", err)
}
reg.mu.RLock()
inst := reg.plugins[rows[0].ID]
reg.mu.RUnlock()
if inst == nil {
t.Fatal("instance not registered after enable")
}
if !inst.Enabled {
t.Fatal("instance should be enabled after EnablePlugin")
}
if inst.module == nil {
t.Fatal("expected inst.module to be populated after activation")
}
}
func TestWazeroDispatchCommandMissingExport(t *testing.T) {
dir := t.TempDir()
manifest := `{"name":"hello","version":"0.1.0","entrypoint":"hello.wasm","permissions":["commands"]}`
writeTestPlugin(t, dir, "hello", manifest, addWASM)
reg, mem := newWazeroTestRegistry(t, dir)
ctx := context.Background()
if err := reg.LoadAll(ctx); err != nil {
t.Fatal(err)
}
rows, _ := mem.ListPlugins(ctx)
if err := reg.EnablePlugin(ctx, rows[0].ID); err != nil {
t.Fatalf("EnablePlugin: %v", err)
}
reg.mu.RLock()
inst := reg.plugins[rows[0].ID]
reg.mu.RUnlock()
if err := reg.RegisterCommand("hello", inst); err != nil {
t.Fatalf("RegisterCommand: %v", err)
}
result, ok := reg.DispatchCommand(ctx, 1, 2, "hello", nil)
if !ok {
t.Fatal("expected DispatchCommand to return a result")
}
if result == nil || result.Reply == "" {
t.Fatal("expected a non-empty reply when export is missing")
}
}
func TestWazeroCloseTearsDownRuntime(t *testing.T) {
dir := t.TempDir()
manifest := `{"name":"hello","version":"0.1.0","entrypoint":"hello.wasm","permissions":["commands"]}`
writeTestPlugin(t, dir, "hello", manifest, addWASM)
reg, mem := newWazeroTestRegistry(t, dir)
ctx := context.Background()
if err := reg.LoadAll(ctx); err != nil {
t.Fatal(err)
}
rows, _ := mem.ListPlugins(ctx)
if err := reg.EnablePlugin(ctx, rows[0].ID); err != nil {
t.Fatalf("EnablePlugin: %v", err)
}
if err := reg.Close(ctx); err != nil {
t.Fatalf("Close: %v", err)
}
if reg.runtimePlatform != nil || reg.platformClose != nil {
t.Fatal("Close should clear platform fields")
}
// Calling Close twice must not panic.
if err := reg.Close(ctx); err != nil {
t.Fatalf("Close (second): %v", err)
}
}
func TestWazeroDisablePluginFreesModule(t *testing.T) {
dir := t.TempDir()
manifest := `{"name":"hello","version":"0.1.0","entrypoint":"hello.wasm","permissions":["commands"]}`
writeTestPlugin(t, dir, "hello", manifest, addWASM)
reg, mem := newWazeroTestRegistry(t, dir)
ctx := context.Background()
if err := reg.LoadAll(ctx); err != nil {
t.Fatal(err)
}
rows, _ := mem.ListPlugins(ctx)
if err := reg.EnablePlugin(ctx, rows[0].ID); err != nil {
t.Fatalf("EnablePlugin: %v", err)
}
reg.mu.RLock()
inst := reg.plugins[rows[0].ID]
reg.mu.RUnlock()
if inst.module == nil {
t.Fatal("pre-condition: module should be populated after EnablePlugin")
}
if err := reg.DisablePlugin(ctx, rows[0].ID); err != nil {
t.Fatalf("DisablePlugin: %v", err)
}
if inst.module != nil {
t.Fatal("DisablePlugin must release the wazero module (inst.module != nil)")
}
if inst.Enabled {
t.Fatal("DisablePlugin must clear inst.Enabled")
}
// Re-enabling must rebuild a new module.
if err := reg.EnablePlugin(ctx, rows[0].ID); err != nil {
t.Fatalf("re-EnablePlugin: %v", err)
}
if inst.module == nil {
t.Fatal("re-Enable should repopulate inst.module")
}
}
func TestWazeroInvalidWASMFailsActivation(t *testing.T) {
dir := t.TempDir()
manifest := `{"name":"brokey","version":"0.1.0","entrypoint":"hello.wasm","permissions":["commands"]}`
writeTestPlugin(t, dir, "brokey", manifest, []byte("not a wasm"))
reg, mem := newWazeroTestRegistry(t, dir)
ctx := context.Background()
if err := reg.LoadAll(ctx); err != nil {
t.Fatal(err)
}
rows, _ := mem.ListPlugins(ctx)
if err := reg.EnablePlugin(ctx, rows[0].ID); err == nil {
t.Fatal("expected EnablePlugin to fail on invalid WASM")
}
}
+35 -22
View File
@@ -34,33 +34,46 @@ type AppMetrics struct {
}
var (
appMetricsOnce sync.Once
appMetricsMu sync.Mutex
appMetricsInst *AppMetrics
)
// NewAppMetrics returns a process-wide AppMetrics, lazily constructed against
// the current global provider. Calling it multiple times returns the same
// instance — the metrics are tied to the global provider, not to a specific
// caller.
// instance until resetAppMetricsForInit() is called (which Init uses after
// swapping the global provider so instruments re-bind to the new meter).
func NewAppMetrics() *AppMetrics {
appMetricsOnce.Do(func() {
ws := GlobalMeter(scopeWS)
svc := GlobalMeter(scopeService)
db := GlobalMeter(scopeDB)
voice := GlobalMeter(scopeVoice)
appMetricsInst = &AppMetrics{
WSMessagesTotal: ws.Counter("ws_messages_total", "WebSocket messages broadcast"),
WSActiveConnections: ws.Gauge("ws_active_connections", "Currently connected WebSocket clients"),
WSBroadcastLatency: ws.Histogram("ws_broadcast_latency_seconds", "Wall-clock seconds from enqueue to fanout completion", "s"),
WSReconnectTierTotal: ws.Counter("ws_reconnect_tier_total", "Reconnection replay tier hits, attribute tier=buffer|db|full"),
WSEventsPersisted: ws.Counter("ws_events_persisted_total", "Events written to the cold-tier event log"),
WSEventsDropped: ws.Counter("ws_events_dropped_total", "Events dropped because the persister queue was full"),
WSEventsPersistErrors: ws.Counter("ws_events_persist_errors_total", "PersistEvent calls that returned an error from the underlying store"),
DBQueryDurationSec: db.Histogram("db_query_duration_seconds", "Per-query wall time", "s"),
VoiceActiveSessions: voice.Gauge("voice_active_sessions", "Active LiveKit rooms"),
VoiceParticipants: voice.Gauge("voice_participants", "Connected LiveKit participants across all rooms"),
ServiceCallDurationSec: svc.Histogram("service_call_duration_seconds", "Service-layer method execution time", "s"),
}
})
appMetricsMu.Lock()
defer appMetricsMu.Unlock()
if appMetricsInst != nil {
return appMetricsInst
}
ws := GlobalMeter(scopeWS)
svc := GlobalMeter(scopeService)
db := GlobalMeter(scopeDB)
voice := GlobalMeter(scopeVoice)
appMetricsInst = &AppMetrics{
WSMessagesTotal: ws.Counter("ws_messages_total", "WebSocket messages broadcast"),
WSActiveConnections: ws.Gauge("ws_active_connections", "Currently connected WebSocket clients"),
WSBroadcastLatency: ws.Histogram("ws_broadcast_latency_seconds", "Wall-clock seconds from enqueue to fanout completion", "s"),
WSReconnectTierTotal: ws.Counter("ws_reconnect_tier_total", "Reconnection replay tier hits, attribute tier=buffer|db|full"),
WSEventsPersisted: ws.Counter("ws_events_persisted_total", "Events written to the cold-tier event log"),
WSEventsDropped: ws.Counter("ws_events_dropped_total", "Events dropped because the persister queue was full"),
WSEventsPersistErrors: ws.Counter("ws_events_persist_errors_total", "PersistEvent calls that returned an error from the underlying store"),
DBQueryDurationSec: db.Histogram("db_query_duration_seconds", "Per-query wall time", "s"),
VoiceActiveSessions: voice.Gauge("voice_active_sessions", "Active LiveKit rooms"),
VoiceParticipants: voice.Gauge("voice_participants", "Connected LiveKit participants across all rooms"),
ServiceCallDurationSec: svc.Histogram("service_call_duration_seconds", "Service-layer method execution time", "s"),
}
return appMetricsInst
}
// resetAppMetricsForInit drops the cached AppMetrics bundle so the next
// NewAppMetrics() call re-binds instruments against whatever provider is now
// global. The real OTel Init uses this to migrate from the no-op provider
// installed by the package-level init() to the SDK-backed one.
func resetAppMetricsForInit() {
appMetricsMu.Lock()
defer appMetricsMu.Unlock()
appMetricsInst = nil
}
+263 -52
View File
@@ -1,78 +1,289 @@
//go:build otel
// Real OpenTelemetry-backed implementation. Compiled only with `-tags otel`,
// which keeps the OTel SDK out of the default sqlite-only build (matching the
// pattern used by Server/store/postgres.go).
// Phase B Step 8 — Real OpenTelemetry-backed implementation. Compiled only
// with `-tags otel`, matching the postgres / wazero build-tag pattern used
// elsewhere in the repo. The default build ships telemetry_default.go with a
// no-op provider so sqlite-only binaries do not pull the OTel SDK in.
//
// IMPORTANT: This file currently compiles under `-tags otel` because the
// skeleton deliberately avoids importing any upstream OTel packages. `Init`
// returns a runtime error until the real SDK wiring lands; `Shutdown` is a
// no-op. The CI matrix step that builds with `-tags otel` therefore passes
// today but does NOT exercise real telemetry. To finish wiring it, run on
// a machine with network access:
// Exporter modes (config.TelemetryConfig.Exporter):
//
// cd Server
// go get go.opentelemetry.io/otel@latest \
// go.opentelemetry.io/otel/sdk@latest \
// go.opentelemetry.io/otel/exporters/prometheus@latest \
// go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc@latest \
// go.opentelemetry.io/contrib/instrumentation/github.com/go-chi/chi/v5/otelchi@latest
// go mod tidy
// go build -tags otel ./...
// "none" — no-op provider; same as the default build.
// "prometheus" — pull-based metrics via a /metrics handler, spans are still
// processed by a batching tracer provider with no exporter.
// "otlp" — push-based traces over OTLP/gRPC to cfg.OTLPEndpoint AND
// pull-based metrics via the Prometheus exporter.
//
// Until that runs, the default build (no `-tags otel`) uses telemetry_default.go.
// The concrete Provider adapts our tiny telemetry.* API onto the upstream
// OTel SDK so the rest of the codebase never depends on the SDK directly.
package telemetry
import (
"context"
"errors"
"fmt"
"math"
"net/http"
"sync"
"github.com/owncord/server/config"
promclient "github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/client_golang/prometheus/promhttp"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
otelprom "go.opentelemetry.io/otel/exporters/prometheus"
"go.opentelemetry.io/otel/metric"
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.40.0"
"go.opentelemetry.io/otel/trace"
)
// otelProvider is the placeholder for the real OTel-backed Provider. The
// fields and methods will be filled in once the OTel modules are in go.mod;
// for now this file exists so reviewers can see the intended shape and the
// `otel` build tag has a target.
// otelProvider adapts the OTel SDK to the telemetry.Provider interface.
type otelProvider struct {
cfg config.TelemetryConfig
promHandler http.Handler
httpMiddleware func(http.Handler) http.Handler
shutdown ShutdownFunc
cfg config.TelemetryConfig
promHandler http.Handler
tp *sdktrace.TracerProvider
mp *sdkmetric.MeterProvider
serviceName string
}
// Init wires the OTel SDK exporters according to cfg.Exporter:
//
// "none" — no-op (matches the default build)
// "prometheus" — pull-based Prometheus exporter mounted at /metrics
// "otlp" — push-based OTLP/gRPC exporter to cfg.OTLPEndpoint
//
// All exporters share the same resource (service.name = cfg.ServiceName).
// Init wires the OTel SDK according to cfg and installs it as the global
// Provider. The returned ShutdownFunc flushes the batchers and releases
// exporter resources; it is safe to call more than once.
func Init(ctx context.Context, cfg config.TelemetryConfig) (ShutdownFunc, error) {
if !cfg.Enabled || cfg.Exporter == "" || cfg.Exporter == "none" {
SetGlobal(noopProvider{})
return func(context.Context) error { return nil }, nil
}
// TODO(otel-build-tag): replace the panic below with the real OTel
// initialisation once go.mod has the otel modules. The structural call
// graph is:
//
// resource = sdkresource.NewWithAttributes(...)
// tp = sdktrace.NewTracerProvider(WithBatcher(otlptracegrpc...))
// mp = sdkmetric.NewMeterProvider(WithReader(prometheus.New()))
// otel.SetTracerProvider(tp); otel.SetMeterProvider(mp)
// handler = promhttp.HandlerFor(prometheusReg, promhttp.HandlerOpts{})
// mw = otelchi.Middleware(serviceName, otelchi.WithChiRoutes(...))
//
// then wrap them in a Provider implementation and SetGlobal it.
_ = ctx
return nil, fmt.Errorf("telemetry: otel build tag is set but the SDK skeleton in telemetry_otel.go is incomplete; finish wiring after `go get go.opentelemetry.io/otel...`")
svcName := cfg.ServiceName
if svcName == "" {
svcName = "owncord-server"
}
res, err := resource.New(ctx,
resource.WithAttributes(
semconv.ServiceName(svcName),
),
)
if err != nil {
return nil, fmt.Errorf("telemetry: resource: %w", err)
}
provider := &otelProvider{cfg: cfg, serviceName: svcName}
// ── Tracing ────────────────────────────────────────────────────────────
tpOpts := []sdktrace.TracerProviderOption{sdktrace.WithResource(res)}
if cfg.Exporter == "otlp" {
endpoint := cfg.OTLPEndpoint
if endpoint == "" {
endpoint = "localhost:4317"
}
traceExp, err := otlptracegrpc.New(ctx,
otlptracegrpc.WithEndpoint(endpoint),
otlptracegrpc.WithInsecure(),
)
if err != nil {
return nil, fmt.Errorf("telemetry: otlp trace exporter: %w", err)
}
tpOpts = append(tpOpts, sdktrace.WithBatcher(traceExp))
}
provider.tp = sdktrace.NewTracerProvider(tpOpts...)
otel.SetTracerProvider(provider.tp)
// ── Metrics ────────────────────────────────────────────────────────────
// Both prometheus and otlp modes surface metrics via a pull-based
// Prometheus endpoint. OTLP tracing does not preclude Prometheus metrics
// — operators usually want both — so we wire the exporter unconditionally
// for the two real modes.
reg := promclient.NewRegistry()
promExp, err := otelprom.New(otelprom.WithRegisterer(reg))
if err != nil {
// Shut the trace provider down so the OTLP exporter's gRPC
// connection (if any) is torn down. Init returning an error must
// not leak resources.
_ = provider.tp.Shutdown(ctx)
return nil, fmt.Errorf("telemetry: prometheus exporter: %w", err)
}
provider.mp = sdkmetric.NewMeterProvider(
sdkmetric.WithResource(res),
sdkmetric.WithReader(promExp),
)
otel.SetMeterProvider(provider.mp)
provider.promHandler = promhttp.HandlerFor(reg, promhttp.HandlerOpts{})
// Drop the cached AppMetrics bundle BEFORE publishing the new global
// provider. A concurrent NewAppMetrics() caller that observes the old
// no-op provider and the stale cache is harmless (it just re-populates
// once); the failure mode we avoid is a caller seeing the new provider
// but still reading the old cached (no-op) instruments.
resetAppMetricsForInit()
SetGlobal(provider)
var once sync.Once
return func(shutCtx context.Context) error {
var rerr error
once.Do(func() {
var errs []error
if provider.tp != nil {
if err := provider.tp.Shutdown(shutCtx); err != nil {
errs = append(errs, err)
}
}
if provider.mp != nil {
if err := provider.mp.Shutdown(shutCtx); err != nil {
errs = append(errs, err)
}
}
if len(errs) > 0 {
rerr = fmt.Errorf("telemetry shutdown: %w", errors.Join(errs...))
}
SetGlobal(noopProvider{})
})
return rerr
}, nil
}
// otelProvider satisfies Provider once the SDK is wired.
func (p *otelProvider) Tracer(name string) Tracer { _ = name; return noopTracer{} }
func (p *otelProvider) Meter(name string) Meter { _ = name; return noopMeter{} }
func (p *otelProvider) HTTPMiddleware(next http.Handler) http.Handler { return p.httpMiddleware(next) }
func (p *otelProvider) PrometheusHandler() http.Handler { return p.promHandler }
// Tracer returns an otel-backed tracer for the given instrumentation scope.
func (p *otelProvider) Tracer(name string) Tracer {
return &otelTracer{inner: p.tp.Tracer(name)}
}
// Meter returns an otel-backed meter for the given instrumentation scope.
func (p *otelProvider) Meter(name string) Meter {
return &otelMeter{inner: p.mp.Meter(name)}
}
// HTTPMiddleware wraps next with otelhttp so every REST request becomes a span
// named after its route pattern.
func (p *otelProvider) HTTPMiddleware(next http.Handler) http.Handler {
return otelhttp.NewHandler(next, "http.server",
otelhttp.WithServerName(p.serviceName),
)
}
// PrometheusHandler returns the /metrics handler backed by the active
// exporter registry.
func (p *otelProvider) PrometheusHandler() http.Handler { return p.promHandler }
// ── Tracer / Span adapters ─────────────────────────────────────────────────
type otelTracer struct{ inner trace.Tracer }
func (t *otelTracer) Start(ctx context.Context, name string, attrs ...Attr) (context.Context, Span) {
ctx, span := t.inner.Start(ctx, name, trace.WithAttributes(convertAttrs(attrs)...))
return ctx, &otelSpan{inner: span}
}
type otelSpan struct{ inner trace.Span }
func (s *otelSpan) End() { s.inner.End() }
func (s *otelSpan) SetAttributes(attrs ...Attr) { s.inner.SetAttributes(convertAttrs(attrs)...) }
func (s *otelSpan) RecordError(err error) {
if err == nil {
return
}
s.inner.RecordError(err)
}
// ── Meter / instrument adapters ────────────────────────────────────────────
type otelMeter struct{ inner metric.Meter }
func (m *otelMeter) Counter(name, description string) Counter {
c, err := m.inner.Int64Counter(name, metric.WithDescription(description))
if err != nil {
return noopCounter{}
}
return &otelCounter{inner: c}
}
func (m *otelMeter) Histogram(name, description, unit string) Histogram {
opts := []metric.Float64HistogramOption{metric.WithDescription(description)}
if unit != "" {
opts = append(opts, metric.WithUnit(unit))
}
h, err := m.inner.Float64Histogram(name, opts...)
if err != nil {
return noopHistogram{}
}
return &otelHistogram{inner: h}
}
func (m *otelMeter) Gauge(name, description string) Gauge {
g, err := m.inner.Float64Gauge(name, metric.WithDescription(description))
if err != nil {
return noopGauge{}
}
return &otelGauge{inner: g}
}
type otelCounter struct{ inner metric.Int64Counter }
func (c *otelCounter) Add(ctx context.Context, delta int64, attrs ...Attr) {
c.inner.Add(ctx, delta, metric.WithAttributes(convertAttrs(attrs)...))
}
type otelHistogram struct{ inner metric.Float64Histogram }
func (h *otelHistogram) Record(ctx context.Context, value float64, attrs ...Attr) {
h.inner.Record(ctx, value, metric.WithAttributes(convertAttrs(attrs)...))
}
type otelGauge struct{ inner metric.Float64Gauge }
func (g *otelGauge) Set(ctx context.Context, value float64, attrs ...Attr) {
g.inner.Record(ctx, value, metric.WithAttributes(convertAttrs(attrs)...))
}
// convertAttrs maps telemetry.Attr values to attribute.KeyValue. Unknown
// types are rendered via fmt.Sprint so callers never panic on exotic values.
// The uint64 / uint32 / uint cases matter: sequence numbers and ID fields in
// OwnCord are unsigned, and routing them through the default fmt.Sprint path
// would encode them as string attributes and break metric aggregation.
func convertAttrs(in []Attr) []attribute.KeyValue {
if len(in) == 0 {
return nil
}
out := make([]attribute.KeyValue, 0, len(in))
for _, a := range in {
switch v := a.Value.(type) {
case string:
out = append(out, attribute.String(a.Key, v))
case int:
out = append(out, attribute.Int(a.Key, v))
case int32:
out = append(out, attribute.Int64(a.Key, int64(v)))
case int64:
out = append(out, attribute.Int64(a.Key, v))
case uint:
out = append(out, attribute.Int64(a.Key, int64(v)))
case uint32:
out = append(out, attribute.Int64(a.Key, int64(v)))
case uint64:
// Most uint64 values in OwnCord (ids, seqs) fit comfortably
// within int64 range. A wrapped negative would corrupt
// metric aggregation, so we fall back to a string for the
// pathological case rather than silently misreporting.
if v <= math.MaxInt64 {
out = append(out, attribute.Int64(a.Key, int64(v)))
} else {
out = append(out, attribute.String(a.Key, fmt.Sprint(v)))
}
case float32:
out = append(out, attribute.Float64(a.Key, float64(v)))
case float64:
out = append(out, attribute.Float64(a.Key, v))
case bool:
out = append(out, attribute.Bool(a.Key, v))
default:
out = append(out, attribute.String(a.Key, fmt.Sprint(v)))
}
}
return out
}
+214
View File
@@ -0,0 +1,214 @@
//go:build otel
// Phase B Step 8 — tests for the real OpenTelemetry provider. Only compiled
// under `-tags otel`, alongside telemetry_otel.go. These tests exercise the
// behavioural contract the no-op build cannot: that Init actually wires a
// Prometheus exporter, that spans created via the adapter reach the SDK, and
// that Shutdown flushes cleanly.
package telemetry
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/owncord/server/config"
)
func TestOtelInitPrometheusExporter(t *testing.T) {
resetGlobalForTest(t)
shutdown, err := Init(context.Background(), config.TelemetryConfig{
Enabled: true,
Exporter: "prometheus",
ServiceName: "owncord-test",
})
if err != nil {
t.Fatalf("Init: %v", err)
}
t.Cleanup(func() { _ = shutdown(context.Background()) })
p := Global()
if p == nil {
t.Fatal("Global is nil after Init")
}
handler := p.PrometheusHandler()
if handler == nil {
t.Fatal("PrometheusHandler is nil after prometheus Init")
}
// Record one metric and scrape the exporter; the metric must appear in
// the /metrics response body.
ctx := context.Background()
meter := p.Meter("telemetry_test")
counter := meter.Counter("otel_test_counter_total", "test counter")
counter.Add(ctx, 3, String("fixture", "init"))
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, httptest.NewRequest("GET", "/metrics", nil))
body := rec.Body.String()
if rec.Code != http.StatusOK {
t.Fatalf("prometheus handler status: got %d, body=%s", rec.Code, body)
}
if !strings.Contains(body, "otel_test_counter_total") {
t.Fatalf("expected otel_test_counter_total in exporter body:\n%s", body)
}
}
func TestOtelInitNoneReturnsNoopProvider(t *testing.T) {
resetGlobalForTest(t)
shutdown, err := Init(context.Background(), config.TelemetryConfig{
Enabled: true,
Exporter: "none",
})
if err != nil {
t.Fatalf("Init: %v", err)
}
t.Cleanup(func() { _ = shutdown(context.Background()) })
if _, ok := Global().(noopProvider); !ok {
t.Fatalf("expected noopProvider, got %T", Global())
}
}
func TestOtelInitDisabledReturnsNoopProvider(t *testing.T) {
resetGlobalForTest(t)
shutdown, err := Init(context.Background(), config.TelemetryConfig{Enabled: false})
if err != nil {
t.Fatalf("Init: %v", err)
}
t.Cleanup(func() { _ = shutdown(context.Background()) })
if _, ok := Global().(noopProvider); !ok {
t.Fatalf("expected noopProvider, got %T", Global())
}
}
func TestOtelTracerRecordsSpan(t *testing.T) {
resetGlobalForTest(t)
shutdown, err := Init(context.Background(), config.TelemetryConfig{
Enabled: true,
Exporter: "prometheus",
ServiceName: "owncord-test",
})
if err != nil {
t.Fatalf("Init: %v", err)
}
t.Cleanup(func() { _ = shutdown(context.Background()) })
tracer := Global().Tracer("telemetry_test")
ctx, span := tracer.Start(context.Background(), "unit_test",
String("attr_string", "v"),
Int64("attr_int", 42),
Float64("attr_float", 1.5),
)
span.SetAttributes(String("late", "ok"))
span.RecordError(errors.New("boom"))
span.End()
_ = ctx
}
func TestOtelHistogramRecordsSeconds(t *testing.T) {
resetGlobalForTest(t)
shutdown, err := Init(context.Background(), config.TelemetryConfig{
Enabled: true,
Exporter: "prometheus",
ServiceName: "owncord-test",
})
if err != nil {
t.Fatalf("Init: %v", err)
}
t.Cleanup(func() { _ = shutdown(context.Background()) })
h := Global().Meter("telemetry_test").Histogram("otel_test_latency_seconds", "test", "s")
TimeSince(context.Background(), h, time.Now().Add(-250*time.Millisecond))
}
func TestOtelShutdownIdempotent(t *testing.T) {
resetGlobalForTest(t)
shutdown, err := Init(context.Background(), config.TelemetryConfig{
Enabled: true,
Exporter: "prometheus",
ServiceName: "owncord-test",
})
if err != nil {
t.Fatalf("Init: %v", err)
}
if err := shutdown(context.Background()); err != nil {
t.Fatalf("first shutdown: %v", err)
}
// Second call must not panic or error.
if err := shutdown(context.Background()); err != nil {
t.Fatalf("second shutdown: %v", err)
}
}
// resetGlobalForTest restores a fresh no-op provider between tests that each
// call Init. We cannot cleanly tear down and re-register the otel globals in
// all cases, so tests that run back-to-back must start from the no-op state.
func resetGlobalForTest(t *testing.T) {
t.Helper()
SetGlobal(noopProvider{})
resetAppMetricsForInit()
}
func TestOtelConvertAttrsUint64OverflowFallsBackToString(t *testing.T) {
const tooBig = uint64(1<<63) + 7 // > math.MaxInt64
attrs := convertAttrs([]Attr{{Key: "huge", Value: tooBig}})
if len(attrs) != 1 {
t.Fatalf("expected 1 attr, got %d", len(attrs))
}
if got := attrs[0].Value.Type().String(); got != "STRING" {
t.Fatalf("overflowing uint64 should become STRING attr to avoid wrap, got %s", got)
}
}
func TestOtelConvertAttrsHandlesUnsignedInts(t *testing.T) {
attrs := convertAttrs([]Attr{
{Key: "u64", Value: uint64(1 << 40)},
{Key: "u32", Value: uint32(7)},
{Key: "u", Value: uint(42)},
{Key: "i", Value: int(-1)},
{Key: "i64", Value: int64(-1 << 40)},
{Key: "f32", Value: float32(2.5)},
{Key: "b", Value: true},
{Key: "s", Value: "hello"},
})
if len(attrs) != 8 {
t.Fatalf("expected 8 attrs, got %d", len(attrs))
}
// uint64 must become an Int64 attribute, not a string.
if got := attrs[0].Value.Type().String(); got != "INT64" {
t.Fatalf("u64 attr type = %s, want INT64", got)
}
if got := attrs[0].Value.AsInt64(); got != int64(1<<40) {
t.Fatalf("u64 attr value = %d, want %d", got, int64(1<<40))
}
}
func TestOtelAppMetricsRebindsAfterInit(t *testing.T) {
resetGlobalForTest(t)
// Build once against the noop global; verify the bundle is the no-op
// counter type, then swap providers and verify NewAppMetrics returns a
// re-bound bundle.
before := NewAppMetrics()
if _, ok := before.WSMessagesTotal.(noopCounter); !ok {
t.Fatalf("expected noopCounter before Init, got %T", before.WSMessagesTotal)
}
shutdown, err := Init(context.Background(), config.TelemetryConfig{
Enabled: true,
Exporter: "prometheus",
ServiceName: "owncord-test",
})
if err != nil {
t.Fatalf("Init: %v", err)
}
t.Cleanup(func() { _ = shutdown(context.Background()) })
after := NewAppMetrics()
if _, ok := after.WSMessagesTotal.(noopCounter); ok {
t.Fatalf("WSMessagesTotal is still a noopCounter after OTel Init — AppMetrics cache was not reset")
}
}