diff --git a/Client/tauri-client/package-lock.json b/Client/tauri-client/package-lock.json index 0d017493..6b687f8d 100644 --- a/Client/tauri-client/package-lock.json +++ b/Client/tauri-client/package-lock.json @@ -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", diff --git a/Client/tauri-client/src/lib/pluginBridge.ts b/Client/tauri-client/src/lib/pluginBridge.ts index dc4a4ce8..9629299a 100644 --- a/Client/tauri-client/src/lib/pluginBridge.ts +++ b/Client/tauri-client/src/lib/pluginBridge.ts @@ -33,9 +33,39 @@ class PluginBridge { private frames = new Map(); private listeners = new Set(); private themeVars: Record = {}; + 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, ); } diff --git a/Client/tauri-client/src/lib/solidMount.ts b/Client/tauri-client/src/lib/solidMount.ts index 24c32590..a4c1ad79 100644 --- a/Client/tauri-client/src/lib/solidMount.ts +++ b/Client/tauri-client/src/lib/solidMount.ts @@ -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. */ diff --git a/Client/tauri-client/vitest.config.ts b/Client/tauri-client/vitest.config.ts index f0af448a..895f0009 100644 --- a/Client/tauri-client/vitest.config.ts +++ b/Client/tauri-client/vitest.config.ts @@ -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"], diff --git a/PHASE_BC_LOCAL_TODO.md b/PHASE_BC_LOCAL_TODO.md index c748c35e..67ff9847 100644 --- a/PHASE_BC_LOCAL_TODO.md +++ b/PHASE_BC_LOCAL_TODO.md @@ -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 diff --git a/Server/api/plugins_handler.go b/Server/api/plugins_handler.go index 6fa750c1..374e0bb1 100644 --- a/Server/api/plugins_handler.go +++ b/Server/api/plugins_handler.go @@ -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) diff --git a/Server/api/plugins_handler_test.go b/Server/api/plugins_handler_test.go new file mode 100644 index 00000000..cad708d0 --- /dev/null +++ b/Server/api/plugins_handler_test.go @@ -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() +} diff --git a/Server/go.mod b/Server/go.mod index a8865883..ba066fc2 100644 --- a/Server/go.mod +++ b/Server/go.mod @@ -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 diff --git a/Server/go.sum b/Server/go.sum index f1a4e8e9..95df4429 100644 --- a/Server/go.sum +++ b/Server/go.sum @@ -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= diff --git a/Server/plugin/registry.go b/Server/plugin/registry.go index b3362a70..a1219a3e 100644 --- a/Server/plugin/registry.go +++ b/Server/plugin/registry.go @@ -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 } diff --git a/Server/plugin/sandbox_default.go b/Server/plugin/sandbox_default.go index 1ae343d9..8bb7a506 100644 --- a/Server/plugin/sandbox_default.go +++ b/Server/plugin/sandbox_default.go @@ -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 diff --git a/Server/plugin/sandbox_wazero.go b/Server/plugin/sandbox_wazero.go index 0811e483..9d412be0 100644 --- a/Server/plugin/sandbox_wazero.go +++ b/Server/plugin/sandbox_wazero.go @@ -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 } diff --git a/Server/plugin/sandbox_wazero_test.go b/Server/plugin/sandbox_wazero_test.go new file mode 100644 index 00000000..1309559b --- /dev/null +++ b/Server/plugin/sandbox_wazero_test.go @@ -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") + } +} diff --git a/Server/telemetry/metrics.go b/Server/telemetry/metrics.go index 39aeecc8..799fa92c 100644 --- a/Server/telemetry/metrics.go +++ b/Server/telemetry/metrics.go @@ -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 +} diff --git a/Server/telemetry/telemetry_otel.go b/Server/telemetry/telemetry_otel.go index c62bf55e..9ed75e03 100644 --- a/Server/telemetry/telemetry_otel.go +++ b/Server/telemetry/telemetry_otel.go @@ -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 +} diff --git a/Server/telemetry/telemetry_otel_test.go b/Server/telemetry/telemetry_otel_test.go new file mode 100644 index 00000000..c91c2375 --- /dev/null +++ b/Server/telemetry/telemetry_otel_test.go @@ -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") + } +}