diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7b7be1df..f5a9d8ea 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,6 @@ on: push: branches: [master, main] pull_request: - branches: [master, main] jobs: test-coordinator: diff --git a/console-ui/README.md b/console-ui/README.md index bab015fa..42eb21e3 100644 --- a/console-ui/README.md +++ b/console-ui/README.md @@ -20,7 +20,7 @@ Client-side variables used by the app: - `NEXT_PUBLIC_SOLANA_RPC_URL` - Solana RPC endpoint - `NEXT_PUBLIC_GA_MEASUREMENT_ID` - optional public Google Analytics 4 measurement ID -Analytics stays disabled unless `NEXT_PUBLIC_GA_MEASUREMENT_ID` is set **and** consent is granted. When a measurement ID is configured, the app shows a small in-app prompt so users can allow or decline privacy-filtered usage analytics. Consent is persisted in `localStorage` under `darkbloom_ga_consent` (`granted` or `denied`), and a declined choice keeps analytics disabled until the user explicitly changes it later from Settings. +Analytics stays disabled unless `NEXT_PUBLIC_GA_MEASUREMENT_ID` is set **and** consent is granted. When a measurement ID is configured, the app shows a small in-app prompt so users can allow or decline privacy-filtered usage analytics. Consent is persisted in `localStorage` under `darkbloom_ga_consent` (`granted` or `denied`) and mirrored to a `.darkbloom.dev` cookie so the landing page and console share the same choice. A declined choice keeps analytics disabled until the user explicitly changes it later from Settings. ### Google Analytics setup diff --git a/console-ui/__tests__/api.test.ts b/console-ui/__tests__/api.test.ts index c0cc5aa6..10f40335 100644 --- a/console-ui/__tests__/api.test.ts +++ b/console-ui/__tests__/api.test.ts @@ -34,17 +34,7 @@ beforeEach(() => { fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); - // Provide a minimal localStorage so getConfig() works - const store: Record = {}; - vi.stubGlobal("localStorage", { - getItem: (k: string) => store[k] ?? null, - setItem: (k: string, v: string) => { - store[k] = v; - }, - removeItem: (k: string) => { - delete store[k]; - }, - }); + localStorage.clear(); }); afterEach(() => { @@ -66,7 +56,7 @@ describe("fetchBalance", () => { const [url, opts] = fetchMock.mock.calls[0]; expect(url).toBe("/api/payments/balance"); expect(opts.headers["Content-Type"]).toBe("application/json"); - expect(opts.headers["x-api-key"]).toBeDefined(); + expect(opts.headers["x-api-key"]).toBeUndefined(); expect(result).toEqual(payload); }); diff --git a/console-ui/__tests__/google-analytics.test.ts b/console-ui/__tests__/google-analytics.test.ts index 68079b3d..13b60ac2 100644 --- a/console-ui/__tests__/google-analytics.test.ts +++ b/console-ui/__tests__/google-analytics.test.ts @@ -23,6 +23,20 @@ declare global { } } +function normalizeDataLayer() { + return (window.dataLayer ?? []).map((entry) => { + if ( + typeof entry === "object" && + entry !== null && + "length" in entry + ) { + return Array.from(entry as ArrayLike); + } + + return entry; + }); +} + describe("google analytics helpers", () => { beforeEach(() => { vi.restoreAllMocks(); @@ -33,6 +47,7 @@ describe("google analytics helpers", () => { window.dataLayer = []; window.gtag = undefined; localStorage.removeItem("darkbloom_ga_consent"); + document.cookie = "darkbloom_ga_consent=; path=/; max-age=0"; window.history.replaceState({}, "", "/?token=secret&utm_source=search&gclid=abc123"); document.title = "Darkbloom"; }); @@ -54,6 +69,13 @@ describe("google analytics helpers", () => { expect(window.__googleAnalyticsInitialized).toBe(false); }); + it("falls back to the shared consent cookie when local storage is unset", () => { + document.cookie = "darkbloom_ga_consent=granted; path=/; max-age=60"; + + expect(getGoogleAnalyticsConsentStatus()).toBe("granted"); + expect(hasGoogleAnalyticsConsent()).toBe(true); + }); + it("syncs runtime state when consent changes externally", () => { grantGoogleAnalyticsConsent(); initializeGoogleAnalytics(); @@ -126,7 +148,8 @@ describe("google analytics helpers", () => { initializeGoogleAnalytics(); trackRouteChange("/billing"); - expect(window.dataLayer).toEqual([ + expect(window.dataLayer?.[0]).not.toBeInstanceOf(Array); + expect(normalizeDataLayer()).toEqual([ ["js", expect.any(Date)], ["config", "G-TEST123", { send_page_view: false }], [ @@ -158,7 +181,7 @@ describe("google analytics helpers", () => { trackRouteChange("/billing"); trackRouteChange("/settings"); - expect(window.dataLayer).toEqual([ + expect(normalizeDataLayer()).toEqual([ ["js", expect.any(Date)], ["config", "G-TEST123", { send_page_view: false }], [ @@ -204,7 +227,7 @@ describe("google analytics helpers", () => { source: "login_page", }); - expect(window.dataLayer).toEqual([ + expect(normalizeDataLayer()).toEqual([ ["js", expect.any(Date)], ["config", "G-TEST123", { send_page_view: false }], [ @@ -240,7 +263,7 @@ describe("google analytics helpers", () => { model: "mlx-community/gemma-4-26b-a4b-it-8bit", }); - expect(window.dataLayer).toEqual([ + expect(normalizeDataLayer()).toEqual([ ["js", expect.any(Date)], ["config", "G-TEST123", { send_page_view: false }], [ diff --git a/console-ui/__tests__/pages.test.tsx b/console-ui/__tests__/pages.test.tsx index fc67c710..916afcbe 100644 --- a/console-ui/__tests__/pages.test.tsx +++ b/console-ui/__tests__/pages.test.tsx @@ -149,16 +149,7 @@ beforeEach(() => { }); vi.stubGlobal("fetch", fetchMock); - const store: Record = {}; - vi.stubGlobal("localStorage", { - getItem: (k: string) => store[k] ?? null, - setItem: (k: string, v: string) => { - store[k] = v; - }, - removeItem: (k: string) => { - delete store[k]; - }, - }); + localStorage.clear(); }); afterEach(() => { @@ -180,8 +171,9 @@ describe("BillingPage", () => { // TopBar is mocked and should show "Billing" expect(screen.getByTestId("topbar")).toHaveTextContent("Billing"); - // Research preview banner — purchases disabled, Buy Credits button present - expect(screen.getByText("Available Credits")).toBeInTheDocument(); + // Balance card — starts in loading state and exposes Buy Credits action. + expect(screen.getByText("Balance")).toBeInTheDocument(); + expect(screen.getByText("Loading...")).toBeInTheDocument(); expect(screen.getByRole("button", { name: /Buy Credits/i })).toBeInTheDocument(); // Invite code section @@ -240,17 +232,16 @@ describe("ProvidersPage", () => { render(); await screen.findByRole("heading", { name: "Provider Dashboard" }); - expect(screen.getByText(/Earnings, device health/)).toBeInTheDocument(); + expect(screen.getByText("Your linked provider machines.")).toBeInTheDocument(); }); it("shows provider summary stats", async () => { const ProvidersPage = (await import("@/app/providers/page")).default; render(); - await screen.findByText("Devices online"); - expect(screen.getByText("Needs attention")).toBeInTheDocument(); - expect(screen.getByText("Available earnings")).toBeInTheDocument(); - expect(screen.getByText("Lifetime earnings")).toBeInTheDocument(); + await screen.findByRole("heading", { name: "Provider Dashboard" }); + expect(screen.getByText("We're rebuilding this page")).toBeInTheDocument(); + expect(screen.getByText("Earnings page")).toBeInTheDocument(); }); it("shows onboarding actions when no devices are linked", async () => { diff --git a/console-ui/__tests__/setup.ts b/console-ui/__tests__/setup.ts index f149f27a..da6a0adc 100644 --- a/console-ui/__tests__/setup.ts +++ b/console-ui/__tests__/setup.ts @@ -1 +1,29 @@ import "@testing-library/jest-dom/vitest"; + +let testStorage: Record = {}; + +const localStorageMock = { + getItem: (key: string) => testStorage[key] ?? null, + setItem: (key: string, value: string) => { + testStorage[key] = String(value); + }, + removeItem: (key: string) => { + delete testStorage[key]; + }, + clear: () => { + testStorage = {}; + }, + key: (index: number) => Object.keys(testStorage)[index] ?? null, + get length() { + return Object.keys(testStorage).length; + }, +} as Storage; + +Object.defineProperty(window, "localStorage", { + value: localStorageMock, + configurable: true, +}); +Object.defineProperty(globalThis, "localStorage", { + value: localStorageMock, + configurable: true, +}); diff --git a/console-ui/__tests__/stripe-payouts.test.ts b/console-ui/__tests__/stripe-payouts.test.ts index 07112fab..a9229215 100644 --- a/console-ui/__tests__/stripe-payouts.test.ts +++ b/console-ui/__tests__/stripe-payouts.test.ts @@ -28,16 +28,7 @@ beforeEach(() => { fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); - const store: Record = {}; - vi.stubGlobal("localStorage", { - getItem: (k: string) => store[k] ?? null, - setItem: (k: string, v: string) => { - store[k] = v; - }, - removeItem: (k: string) => { - delete store[k]; - }, - }); + localStorage.clear(); }); afterEach(() => { diff --git a/console-ui/package-lock.json b/console-ui/package-lock.json index 35803162..2607716f 100644 --- a/console-ui/package-lock.json +++ b/console-ui/package-lock.json @@ -10,7 +10,6 @@ "dependencies": { "@datadog/browser-rum": "^6.32.0", "@privy-io/react-auth": "^3.18.0", - "@privy-io/server-auth": "^1.32.5", "asn1js": "^3.0.7", "lucide-react": "^1.0.1", "next": "^16.2.2", @@ -18,27 +17,23 @@ "react": "^19.2.4", "react-dom": "^19.2.4", "react-markdown": "^10.1.0", - "rehype-highlight": "^7.0.2", "remark-gfm": "^4.0.1", "tweetnacl": "^1.0.3", "zustand": "^5.0.12" }, "devDependencies": { - "@eslint/eslintrc": "^3", "@tailwindcss/postcss": "^4", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@types/node": "^20", "@types/react": "^19", - "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "^16.2.2", "eslint-plugin-promise": "^7.2.1", "eslint-plugin-security": "^4.0.0", "eslint-plugin-sonarjs": "^4.0.2", "jsdom": "^29.0.1", - "tailwindcss": "^4", "typescript": "^5", "vitest": "^4.1.2" } @@ -1084,39 +1079,6 @@ "react": ">= 16 || ^19.0.0-rc" } }, - "node_modules/@hpke/chacha20poly1305": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@hpke/chacha20poly1305/-/chacha20poly1305-1.8.0.tgz", - "integrity": "sha512-FcBfAQ+Y99vMNJP2yrZ9wpL8V0GOwp1+zMyzvc6alasrBygfFjFm1yeUtyADJCu/27C3Lm5mJzx6u7pwg+cX5w==", - "license": "MIT", - "dependencies": { - "@hpke/common": "^1.10.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@hpke/common": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/@hpke/common/-/common-1.10.1.tgz", - "integrity": "sha512-moJwhmtLtuxiUzzNp1jpfBfx8yefKoO9D/RCR9dmwrnc7qjJqId1rEtQz+lSlU5cabX8daToMSx/7HayXOiaFw==", - "license": "MIT", - "engines": { - "node": ">=16.0.0" - } - }, - "node_modules/@hpke/core": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@hpke/core/-/core-1.9.0.tgz", - "integrity": "sha512-pFxWl1nNJeQCSUFs7+GAblHvXBCjn9EPN65vdKlYQil2aURaRxfGMO6vBKGqm1YHTKwiAxJQNEI70PbSowMP9Q==", - "license": "MIT", - "dependencies": { - "@hpke/common": "^1.10.0" - }, - "engines": { - "node": ">=16.0.0" - } - }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -2605,42 +2567,6 @@ "integrity": "sha512-kkxzZ5TFseqnZRDVhBR1Si9mTHc1jW+hLSbYviauK80enJOGsOGmxeeC/U0t0kLZGLpn0Jj5v5lNS2bmQ4as/Q==", "license": "Apache-2.0" }, - "node_modules/@privy-io/public-api": { - "version": "2.45.2", - "resolved": "https://registry.npmjs.org/@privy-io/public-api/-/public-api-2.45.2.tgz", - "integrity": "sha512-TSuaFq65PPInb0CBJ9dO/vLh4u4oWXVpv5KxShbVsuaArZ8lig+Orl/HUR1Hri6643zXoBqWlWx9wDt4MQvz9A==", - "license": "Apache-2.0", - "dependencies": { - "@privy-io/api-base": "1.7.0", - "bs58": "^5.0.0", - "libphonenumber-js": "^1.10.31", - "viem": "^2", - "zod": "^3.24.3" - } - }, - "node_modules/@privy-io/public-api/node_modules/@privy-io/api-base": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/@privy-io/api-base/-/api-base-1.7.0.tgz", - "integrity": "sha512-ji6ARQAAuW/FzRTgft9NCjRuouWX9t+J7JMmvLPsnXQJDFEV9mqh4sWXZhQ8ddTq/iDZ4z/yz1ORJqN8bYAi4Q==", - "dependencies": { - "zod": "^3.24.3" - } - }, - "node_modules/@privy-io/public-api/node_modules/base-x": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/base-x/-/base-x-4.0.1.tgz", - "integrity": "sha512-uAZ8x6r6S3aUM9rbHGVOIsR15U/ZSc82b3ymnCPsT45Gk1DDvhDPdIgB5MrhirZWt+5K0EEPQH985kNqZgNPFw==", - "license": "MIT" - }, - "node_modules/@privy-io/public-api/node_modules/bs58": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/bs58/-/bs58-5.0.0.tgz", - "integrity": "sha512-r+ihvQJvahgYT50JD05dyJNKlmmSlMoOGwn1lCcEzanPglg7TxYjioQUYehQ9mAR/+hOSd2jRc/Z2y5UxBymvQ==", - "license": "MIT", - "dependencies": { - "base-x": "^4.0.0" - } - }, "node_modules/@privy-io/react-auth": { "version": "3.18.0", "resolved": "https://registry.npmjs.org/@privy-io/react-auth/-/react-auth-3.18.0.tgz", @@ -2741,54 +2667,6 @@ "@privy-io/api-types": "0.7.0" } }, - "node_modules/@privy-io/server-auth": { - "version": "1.32.5", - "resolved": "https://registry.npmjs.org/@privy-io/server-auth/-/server-auth-1.32.5.tgz", - "integrity": "sha512-e087vImrFHP4yAMx8alyCeCm6gk2JG/q7eSklFoST5mPTUV9TGKx7XNIyKOQQHxwKMpk5SBVIlkyJcqg3CuxSw==", - "deprecated": "This package is deprecated. If you are looking for the latest features and support, use @privy-io/node instead.", - "license": "Apache-2.0", - "dependencies": { - "@hpke/chacha20poly1305": "^1.6.2", - "@hpke/core": "^1.7.2", - "@noble/curves": "^1.6.0", - "@noble/hashes": "^1.5.0", - "@privy-io/public-api": "2.45.2", - "@scure/base": "^1.2.6", - "@solana/web3.js": "^1.95.8", - "canonicalize": "^2.0.0", - "dotenv": "^16.0.3", - "jose": "^4.10.4", - "node-fetch-native": "^1.4.0", - "redaxios": "^0.5.1", - "svix": ">=1.29.0 <= 1.37.0 || ^1.40.0", - "ts-case-convert": "^2.0.2", - "type-fest": "^3.6.1" - }, - "peerDependencies": { - "ethers": "^6", - "viem": "^2.24.1" - }, - "peerDependenciesMeta": { - "ethers": { - "optional": true - }, - "viem": { - "optional": true - } - } - }, - "node_modules/@privy-io/server-auth/node_modules/@noble/hashes": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", - "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", - "license": "MIT", - "engines": { - "node": "^14.21.3 || >=16" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - } - }, "node_modules/@privy-io/urls": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/@privy-io/urls/-/urls-0.0.3.tgz", @@ -4709,18 +4587,6 @@ } } }, - "node_modules/@solana/buffer-layout": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@solana/buffer-layout/-/buffer-layout-4.0.1.tgz", - "integrity": "sha512-E1ImOIAD1tBZFRdjeM4/pzTiTApC0AOBGwyAMS4fwIodCWArzJ3DWdoh8cKxeFM2fElkxBh2Aqts1BPC373rHA==", - "license": "MIT", - "dependencies": { - "buffer": "~6.0.3" - }, - "engines": { - "node": ">=5.10" - } - }, "node_modules/@solana/codecs": { "version": "5.5.1", "resolved": "https://registry.npmjs.org/@solana/codecs/-/codecs-5.5.1.tgz", @@ -5625,124 +5491,6 @@ "node": ">=16" } }, - "node_modules/@solana/web3.js": { - "version": "1.98.4", - "resolved": "https://registry.npmjs.org/@solana/web3.js/-/web3.js-1.98.4.tgz", - "integrity": "sha512-vv9lfnvjUsRiq//+j5pBdXig0IQdtzA0BRZ3bXEP4KaIyF1CcaydWqgyzQgfZMNIsWNWmG+AUHwPy4AHOD6gpw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.25.0", - "@noble/curves": "^1.4.2", - "@noble/hashes": "^1.4.0", - "@solana/buffer-layout": "^4.0.1", - "@solana/codecs-numbers": "^2.1.0", - "agentkeepalive": "^4.5.0", - "bn.js": "^5.2.1", - "borsh": "^0.7.0", - "bs58": "^4.0.1", - "buffer": "6.0.3", - "fast-stable-stringify": "^1.0.0", - "jayson": "^4.1.1", - "node-fetch": "^2.7.0", - "rpc-websockets": "^9.0.2", - "superstruct": "^2.0.2" - } - }, - "node_modules/@solana/web3.js/node_modules/@solana/codecs-core": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@solana/codecs-core/-/codecs-core-2.3.0.tgz", - "integrity": "sha512-oG+VZzN6YhBHIoSKgS5ESM9VIGzhWjEHEGNPSibiDTxFhsFWxNaz8LbMDPjBUE69r9wmdGLkrQ+wVPbnJcZPvw==", - "license": "MIT", - "dependencies": { - "@solana/errors": "2.3.0" - }, - "engines": { - "node": ">=20.18.0" - }, - "peerDependencies": { - "typescript": ">=5.3.3" - } - }, - "node_modules/@solana/web3.js/node_modules/@solana/codecs-numbers": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@solana/codecs-numbers/-/codecs-numbers-2.3.0.tgz", - "integrity": "sha512-jFvvwKJKffvG7Iz9dmN51OGB7JBcy2CJ6Xf3NqD/VP90xak66m/Lg48T01u5IQ/hc15mChVHiBm+HHuOFDUrQg==", - "license": "MIT", - "dependencies": { - "@solana/codecs-core": "2.3.0", - "@solana/errors": "2.3.0" - }, - "engines": { - "node": ">=20.18.0" - }, - "peerDependencies": { - "typescript": ">=5.3.3" - } - }, - "node_modules/@solana/web3.js/node_modules/@solana/errors": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@solana/errors/-/errors-2.3.0.tgz", - "integrity": "sha512-66RI9MAbwYV0UtP7kGcTBVLxJgUxoZGm8Fbc0ah+lGiAw17Gugco6+9GrJCV83VyF2mDWyYnYM9qdI3yjgpnaQ==", - "license": "MIT", - "dependencies": { - "chalk": "^5.4.1", - "commander": "^14.0.0" - }, - "bin": { - "errors": "bin/cli.mjs" - }, - "engines": { - "node": ">=20.18.0" - }, - "peerDependencies": { - "typescript": ">=5.3.3" - } - }, - "node_modules/@solana/web3.js/node_modules/base-x": { - "version": "3.0.11", - "resolved": "https://registry.npmjs.org/base-x/-/base-x-3.0.11.tgz", - "integrity": "sha512-xz7wQ8xDhdyP7tQxwdteLYeFfS68tSMNCZ/Y37WJ4bhGfKPpqEIlmIyueQHqOyoPhE6xNUqjzRr8ra0eF9VRvA==", - "license": "MIT", - "dependencies": { - "safe-buffer": "^5.0.1" - } - }, - "node_modules/@solana/web3.js/node_modules/bs58": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/bs58/-/bs58-4.0.1.tgz", - "integrity": "sha512-Ok3Wdf5vOIlBrgCvTq96gBkJw+JUEzdBgyaza5HLtPm7yTHkjRy8+JzNyHF7BHa0bNWOQIp3m5YF0nnFcOIKLw==", - "license": "MIT", - "dependencies": { - "base-x": "^3.0.2" - } - }, - "node_modules/@solana/web3.js/node_modules/chalk": { - "version": "5.6.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", - "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", - "license": "MIT", - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/@solana/web3.js/node_modules/superstruct": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/superstruct/-/superstruct-2.0.2.tgz", - "integrity": "sha512-uV+TFRZdXsqXTL2pRvujROjdZQ4RAlBUS5BTh9IGm+jTqQntYThciG/qu57Gs69yjnVUSqdxF9YLmSnpupBW9A==", - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/@stablelib/base64": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", - "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", - "license": "MIT" - }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -5982,6 +5730,70 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.8.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.8.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", @@ -6171,15 +5983,6 @@ "assertion-error": "^2.0.1" } }, - "node_modules/@types/connect": { - "version": "3.4.38", - "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", - "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -6259,6 +6062,7 @@ "version": "20.19.37", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz", "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==", + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -6274,16 +6078,6 @@ "csstype": "^3.2.2" } }, - "node_modules/@types/react-dom": { - "version": "19.2.3", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", - "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^19.2.0" - } - }, "node_modules/@types/stylis": { "version": "4.2.7", "resolved": "https://registry.npmjs.org/@types/stylis/-/stylis-4.2.7.tgz", @@ -6302,21 +6096,6 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, - "node_modules/@types/uuid": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", - "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", - "license": "MIT" - }, - "node_modules/@types/ws": { - "version": "7.4.7", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-7.4.7.tgz", - "integrity": "sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.58.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.58.0.tgz", @@ -8856,18 +8635,6 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/agentkeepalive": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", - "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", - "license": "MIT", - "dependencies": { - "humanize-ms": "^1.2.1" - }, - "engines": { - "node": ">= 8.0.0" - } - }, "node_modules/ajv": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", @@ -9312,35 +9079,6 @@ "integrity": "sha512-EAcmnPkxpntVL+DS7bO1zhcZNvCkxqtkd0ZY53h06GNQ3DEkkGZ/gKgmDv6DdZQGj9BgfSPKtJJ7Dp1GPP8f7w==", "license": "MIT" }, - "node_modules/borsh": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/borsh/-/borsh-0.7.0.tgz", - "integrity": "sha512-CLCsZGIBCFnPtkNnieW/a8wmreDmfUtjU2m9yHrzPXIlNbqVs0AQrSatSG6vdNYUqdc83tkQi2eHfF98ubzQLA==", - "license": "Apache-2.0", - "dependencies": { - "bn.js": "^5.2.0", - "bs58": "^4.0.0", - "text-encoding-utf-8": "^1.0.2" - } - }, - "node_modules/borsh/node_modules/base-x": { - "version": "3.0.11", - "resolved": "https://registry.npmjs.org/base-x/-/base-x-3.0.11.tgz", - "integrity": "sha512-xz7wQ8xDhdyP7tQxwdteLYeFfS68tSMNCZ/Y37WJ4bhGfKPpqEIlmIyueQHqOyoPhE6xNUqjzRr8ra0eF9VRvA==", - "license": "MIT", - "dependencies": { - "safe-buffer": "^5.0.1" - } - }, - "node_modules/borsh/node_modules/bs58": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/bs58/-/bs58-4.0.1.tgz", - "integrity": "sha512-Ok3Wdf5vOIlBrgCvTq96gBkJw+JUEzdBgyaza5HLtPm7yTHkjRy8+JzNyHF7BHa0bNWOQIp3m5YF0nnFcOIKLw==", - "license": "MIT", - "dependencies": { - "base-x": "^3.0.2" - } - }, "node_modules/bowser": { "version": "2.14.1", "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", @@ -10179,18 +9917,6 @@ "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", "license": "MIT" }, - "node_modules/delay": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/delay/-/delay-5.0.0.tgz", - "integrity": "sha512-ReEBKkIfe4ya47wlPYf/gu5ib6yUG0/Aez0JQZQz94kiWtRQvZIQbTiehsnwHvLSWJnQdhVeqYue7Id1dKr0qw==", - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -10279,18 +10005,6 @@ "dev": true, "license": "MIT" }, - "node_modules/dotenv": { - "version": "16.6.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", - "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://dotenvx.com" - } - }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -10665,21 +10379,6 @@ "benchmarks" ] }, - "node_modules/es6-promise": { - "version": "4.2.8", - "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz", - "integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==", - "license": "MIT" - }, - "node_modules/es6-promisify": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/es6-promisify/-/es6-promisify-5.0.0.tgz", - "integrity": "sha512-C+d6UdsYDk0lMebHNR4S2NybQMMngAOnOwYBQjTOiv0MkoJMP0Myw2mgpDLBcpfCmRLxyFqYhS/CfOENq4SJhQ==", - "license": "MIT", - "dependencies": { - "es6-promise": "^4.0.3" - } - }, "node_modules/escalade": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", @@ -11482,14 +11181,6 @@ "node": ">=12.0.0" } }, - "node_modules/eyes": { - "version": "0.1.8", - "resolved": "https://registry.npmjs.org/eyes/-/eyes-0.1.8.tgz", - "integrity": "sha512-GipyPsXO1anza0AOZdy69Im7hGFCNB7Y/NGjDlZGJ3GJJLtwNSb2vrzYrTYJRrRloVx7pl+bhUaTB8yiccPvFQ==", - "engines": { - "node": "> 0.1.90" - } - }, "node_modules/fast-copy": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-3.0.2.tgz", @@ -11567,18 +11258,6 @@ "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", "license": "MIT" }, - "node_modules/fast-sha256": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", - "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", - "license": "Unlicense" - }, - "node_modules/fast-stable-stringify": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fast-stable-stringify/-/fast-stable-stringify-1.0.0.tgz", - "integrity": "sha512-wpYMUmFu5f00Sm0cj2pfivpmawLZ0NKdviQ4w9zJeR8JVtOpOxHmLaJuj0vxvGqMJQWyP/COUkF75/57OKyRag==", - "license": "MIT" - }, "node_modules/fastq": { "version": "1.20.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", @@ -12046,19 +11725,6 @@ "node": ">= 0.4" } }, - "node_modules/hast-util-is-element": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", - "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/hast-util-to-jsx-runtime": { "version": "2.3.6", "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", @@ -12086,22 +11752,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/hast-util-to-text": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", - "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "@types/unist": "^3.0.0", - "hast-util-is-element": "^3.0.0", - "unist-util-find-after": "^5.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/hast-util-whitespace": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", @@ -12138,15 +11788,6 @@ "hermes-estree": "0.25.1" } }, - "node_modules/highlight.js": { - "version": "11.11.1", - "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", - "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/hono": { "version": "4.12.12", "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.12.tgz", @@ -12197,15 +11838,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/humanize-ms": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", - "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", - "license": "MIT", - "dependencies": { - "ms": "^2.0.0" - } - }, "node_modules/idb-keyval": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.1.tgz", @@ -12844,15 +12476,6 @@ "dev": true, "license": "ISC" }, - "node_modules/isomorphic-ws": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-4.0.1.tgz", - "integrity": "sha512-BhBvN2MBpWTaSHdWRb/bwdZJ1WaehQ2L1KngkCkfLUGF0mAWAT1sQUQacEmQ0jXkFw/czDXPNQSL5u2/Krsz1w==", - "license": "MIT", - "peerDependencies": { - "ws": "*" - } - }, "node_modules/isows": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/isows/-/isows-1.0.7.tgz", @@ -12886,53 +12509,6 @@ "node": ">= 0.4" } }, - "node_modules/jayson": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/jayson/-/jayson-4.3.0.tgz", - "integrity": "sha512-AauzHcUcqs8OBnCHOkJY280VaTiCm57AbuO7lqzcw7JapGj50BisE3xhksye4zlTSR1+1tAz67wLTl8tEH1obQ==", - "license": "MIT", - "dependencies": { - "@types/connect": "^3.4.33", - "@types/node": "^12.12.54", - "@types/ws": "^7.4.4", - "commander": "^2.20.3", - "delay": "^5.0.0", - "es6-promisify": "^5.0.0", - "eyes": "^0.1.8", - "isomorphic-ws": "^4.0.1", - "json-stringify-safe": "^5.0.1", - "stream-json": "^1.9.1", - "uuid": "^8.3.2", - "ws": "^7.5.10" - }, - "bin": { - "jayson": "bin/jayson.js" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/jayson/node_modules/@types/node": { - "version": "12.20.55", - "resolved": "https://registry.npmjs.org/@types/node/-/node-12.20.55.tgz", - "integrity": "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==", - "license": "MIT" - }, - "node_modules/jayson/node_modules/commander": { - "version": "2.20.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "license": "MIT" - }, - "node_modules/jayson/node_modules/uuid": { - "version": "8.3.2", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", - "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -13146,12 +12722,6 @@ "dev": true, "license": "MIT" }, - "node_modules/json-stringify-safe": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", - "license": "ISC" - }, "node_modules/json5": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", @@ -13620,21 +13190,6 @@ "loose-envify": "cli.js" } }, - "node_modules/lowlight": { - "version": "3.3.0", - "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz", - "integrity": "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "devlop": "^1.0.0", - "highlight.js": "~11.11.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, "node_modules/lru-cache": { "version": "11.2.7", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", @@ -15968,12 +15523,6 @@ "node": ">= 12.13.0" } }, - "node_modules/redaxios": { - "version": "0.5.1", - "resolved": "https://registry.npmjs.org/redaxios/-/redaxios-0.5.1.tgz", - "integrity": "sha512-FSD2AmfdbkYwl7KDExYQlVvIrFz6Yd83pGfaGjBzM9F6rpq8g652Q4Yq5QD4c+nf4g2AgeElv1y+8ajUPiOYMg==", - "license": "Apache-2.0" - }, "node_modules/redent": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", @@ -16069,23 +15618,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/rehype-highlight": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/rehype-highlight/-/rehype-highlight-7.0.2.tgz", - "integrity": "sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA==", - "license": "MIT", - "dependencies": { - "@types/hast": "^3.0.0", - "hast-util-to-text": "^4.0.0", - "lowlight": "^3.0.0", - "unist-util-visit": "^5.0.0", - "vfile": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/remark-gfm": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", @@ -16263,86 +15795,6 @@ "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.15" } }, - "node_modules/rpc-websockets": { - "version": "9.3.7", - "resolved": "https://registry.npmjs.org/rpc-websockets/-/rpc-websockets-9.3.7.tgz", - "integrity": "sha512-dQal1U0yKH2umW0DgqSecP4G1jNxyPUGY60uUMB8bLoXabC2aWT3Cag9hOhZXsH/52QJEcggxNNWhF+Fp48ykw==", - "license": "LGPL-3.0-only", - "dependencies": { - "@swc/helpers": "^0.5.11", - "@types/uuid": "^10.0.0", - "@types/ws": "^8.2.2", - "buffer": "^6.0.3", - "eventemitter3": "^5.0.1", - "uuid": "^11.0.0", - "ws": "^8.5.0" - }, - "funding": { - "type": "paypal", - "url": "https://paypal.me/kozjak" - }, - "optionalDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": "^6.0.0" - } - }, - "node_modules/rpc-websockets/node_modules/@types/ws": { - "version": "8.18.1", - "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", - "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/rpc-websockets/node_modules/utf-8-validate": { - "version": "6.0.6", - "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-6.0.6.tgz", - "integrity": "sha512-q3l3P9UtEEiAHcsgsqTgf9PPjctrDWoIXW3NpOHFdRDbLvu4DLIcxHangJ4RLrWkBcKjmcs/6NkerI8T/rE4LA==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, - "node_modules/rpc-websockets/node_modules/uuid": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", - "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/esm/bin/uuid" - } - }, - "node_modules/rpc-websockets/node_modules/ws": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz", - "integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -16849,16 +16301,6 @@ "dev": true, "license": "MIT" }, - "node_modules/standardwebhooks": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz", - "integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==", - "license": "MIT", - "dependencies": { - "@stablelib/base64": "^1.0.0", - "fast-sha256": "^1.3.0" - } - }, "node_modules/std-env": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.0.0.tgz", @@ -16880,21 +16322,6 @@ "node": ">= 0.4" } }, - "node_modules/stream-chain": { - "version": "2.2.5", - "resolved": "https://registry.npmjs.org/stream-chain/-/stream-chain-2.2.5.tgz", - "integrity": "sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA==", - "license": "BSD-3-Clause" - }, - "node_modules/stream-json": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/stream-json/-/stream-json-1.9.1.tgz", - "integrity": "sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw==", - "license": "BSD-3-Clause", - "dependencies": { - "stream-chain": "^2.2.5" - } - }, "node_modules/stream-shift": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", @@ -17256,29 +16683,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/svix": { - "version": "1.89.0", - "resolved": "https://registry.npmjs.org/svix/-/svix-1.89.0.tgz", - "integrity": "sha512-Yg/5OtdjN3zjWoQSoX+mkauUtLKiW/+DQWKcG2VaEfWoBBB9NmZoKMw7rQQVU5Nn/Wko3kNbo4cvo/ms48d2KQ==", - "license": "MIT", - "dependencies": { - "standardwebhooks": "1.0.0", - "uuid": "^10.0.0" - } - }, - "node_modules/svix/node_modules/uuid": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", - "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -17313,11 +16717,6 @@ "url": "https://opencollective.com/webpack" } }, - "node_modules/text-encoding-utf-8": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/text-encoding-utf-8/-/text-encoding-utf-8-1.0.2.tgz", - "integrity": "sha512-8bw4MY9WjdsD2aMtO0OzOCY3pXGYNx2d2FfHRVUKkiCPDWjKuOlhLVASS+pD7VkLTVjW268LYJHwsnPFlBpbAg==" - }, "node_modules/thread-stream": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", @@ -17507,12 +16906,6 @@ "typescript": ">=4.8.4" } }, - "node_modules/ts-case-convert": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/ts-case-convert/-/ts-case-convert-2.1.0.tgz", - "integrity": "sha512-Ye79el/pHYXfoew6kqhMwCoxp4NWjKNcm2kBzpmEMIU9dd9aBmHNNFtZ+WTm0rz1ngyDmfqDXDlyUnBXayiD0w==", - "license": "Apache-2.0" - }, "node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", @@ -17551,18 +16944,6 @@ "node": ">= 0.8.0" } }, - "node_modules/type-fest": { - "version": "3.13.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-3.13.1.tgz", - "integrity": "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==", - "license": "(MIT OR CC0-1.0)", - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/typed-array-buffer": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", @@ -17758,6 +17139,7 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, "license": "MIT" }, "node_modules/unified": { @@ -17779,20 +17161,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/unist-util-find-after": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz", - "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==", - "license": "MIT", - "dependencies": { - "@types/unist": "^3.0.0", - "unist-util-is": "^6.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/unified" - } - }, "node_modules/unist-util-is": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", diff --git a/console-ui/package.json b/console-ui/package.json index 508aebc2..e69de6ef 100644 --- a/console-ui/package.json +++ b/console-ui/package.json @@ -16,7 +16,6 @@ "dependencies": { "@datadog/browser-rum": "^6.32.0", "@privy-io/react-auth": "^3.18.0", - "@privy-io/server-auth": "^1.32.5", "asn1js": "^3.0.7", "lucide-react": "^1.0.1", "next": "^16.2.2", @@ -24,27 +23,23 @@ "react": "^19.2.4", "react-dom": "^19.2.4", "react-markdown": "^10.1.0", - "rehype-highlight": "^7.0.2", "remark-gfm": "^4.0.1", "tweetnacl": "^1.0.3", "zustand": "^5.0.12" }, "devDependencies": { - "@eslint/eslintrc": "^3", "@tailwindcss/postcss": "^4", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@types/node": "^20", "@types/react": "^19", - "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "^16.2.2", "eslint-plugin-promise": "^7.2.1", "eslint-plugin-security": "^4.0.0", "eslint-plugin-sonarjs": "^4.0.2", "jsdom": "^29.0.1", - "tailwindcss": "^4", "typescript": "^5", "vitest": "^4.1.2" } diff --git a/console-ui/src/lib/google-analytics.ts b/console-ui/src/lib/google-analytics.ts index 326b2829..c4aee42d 100644 --- a/console-ui/src/lib/google-analytics.ts +++ b/console-ui/src/lib/google-analytics.ts @@ -26,6 +26,7 @@ const UTM_QUERY_PARAMS = new Set([ ]); const GA_CONSENT_STORAGE_KEY = "darkbloom_ga_consent"; +const GA_CONSENT_COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365; declare global { interface Window { @@ -62,6 +63,45 @@ function setGoogleAnalyticsDisabled(disabled: boolean) { )[`ga-disable-${measurementId}`] = disabled; } +function getCookieDomain() { + if (typeof window === "undefined") { + return ""; + } + + const hostname = window.location.hostname; + if (hostname === "darkbloom.dev" || hostname.endsWith(".darkbloom.dev")) { + return "; domain=.darkbloom.dev"; + } + + return ""; +} + +function getGoogleAnalyticsConsentCookie(): GoogleAnalyticsConsentStatus { + if (typeof document === "undefined") { + return "unset"; + } + + const prefix = `${GA_CONSENT_STORAGE_KEY}=`; + const cookie = document.cookie + .split(";") + .map((part) => part.trim()) + .find((part) => part.startsWith(prefix)); + + const value = cookie ? decodeURIComponent(cookie.slice(prefix.length)) : ""; + return value === "granted" || value === "denied" ? value : "unset"; +} + +function setGoogleAnalyticsConsentCookie(status: Exclude) { + if (typeof document === "undefined") { + return; + } + + const secure = window.location.protocol === "https:" ? "; secure" : ""; + document.cookie = `${GA_CONSENT_STORAGE_KEY}=${encodeURIComponent( + status, + )}; path=/; max-age=${GA_CONSENT_COOKIE_MAX_AGE_SECONDS}; samesite=lax${secure}${getCookieDomain()}`; +} + export function getGoogleAnalyticsConsentStatus(): GoogleAnalyticsConsentStatus { if (typeof window === "undefined") { return "unset"; @@ -72,7 +112,7 @@ export function getGoogleAnalyticsConsentStatus(): GoogleAnalyticsConsentStatus return stored; } - return "unset"; + return getGoogleAnalyticsConsentCookie(); } export function applyGoogleAnalyticsConsentState(): GoogleAnalyticsConsentStatus { @@ -102,6 +142,7 @@ export function grantGoogleAnalyticsConsent() { } window.localStorage.setItem(GA_CONSENT_STORAGE_KEY, "granted"); + setGoogleAnalyticsConsentCookie("granted"); applyGoogleAnalyticsConsentState(); window.dispatchEvent(new Event("darkbloom-ga-consent-changed")); } @@ -112,6 +153,7 @@ export function revokeGoogleAnalyticsConsent() { } window.localStorage.setItem(GA_CONSENT_STORAGE_KEY, "denied"); + setGoogleAnalyticsConsentCookie("denied"); applyGoogleAnalyticsConsentState(); window.dispatchEvent(new Event("darkbloom-ga-consent-changed")); } @@ -129,9 +171,9 @@ function getGtag() { window.dataLayer = window.dataLayer || []; window.gtag = window.gtag || - ((...args: unknown[]) => { - window.dataLayer?.push(args); - }); + function gtag() { + window.dataLayer?.push(arguments); + }; return { gtag: window.gtag, diff --git a/coordinator/api/cache.go b/coordinator/api/cache.go index 4038ee42..70a1eb48 100644 --- a/coordinator/api/cache.go +++ b/coordinator/api/cache.go @@ -69,8 +69,8 @@ func (c *ttlCache) PurgeExpired() { // writeCachedJSON writes pre-serialized JSON bytes with the standard // Content-Type header. Used on cache hit to skip json.Marshal. -func writeCachedJSON(w http.ResponseWriter, status int, body []byte) { +func writeCachedJSON(w http.ResponseWriter, body []byte) { w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) + w.WriteHeader(http.StatusOK) _, _ = w.Write(body) } diff --git a/coordinator/api/consumer.go b/coordinator/api/consumer.go index 817ed8e8..d267d330 100644 --- a/coordinator/api/consumer.go +++ b/coordinator/api/consumer.go @@ -678,7 +678,7 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) { // "fleet over-subscribed for this model size". outcome = "over_capacity" } - s.ddIncr("routing.decisions", []string{"model:" + model, "outcome:" + outcome}) + s.ddIncr("routing.decisions", []string{"model:" + model, "model_type:" + s.registry.ModelType(model), "outcome:" + outcome}) break } // No idle provider — try queueing. @@ -690,12 +690,12 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) { } pr.Timing.QueuedAt = time.Now() if err := s.registry.Queue().Enqueue(queuedReq); err != nil { - s.ddIncr("routing.decisions", []string{"model:" + model, "outcome:over_capacity"}) +s.ddIncr("routing.decisions", []string{"model:" + model, "model_type:" + s.registry.ModelType(model), "outcome:over_capacity"}) refundReservation() writeJSON(w, http.StatusServiceUnavailable, errorResponse("model_not_available", fmt.Sprintf("no hardware-trusted provider available for model %q and queue is full", model))) return } - s.ddIncr("routing.decisions", []string{"model:" + model, "outcome:queued"}) +s.ddIncr("routing.decisions", []string{"model:" + model, "model_type:" + s.registry.ModelType(model), "outcome:queued"}) s.logger.Info("request queued, waiting for provider", "model", model, @@ -710,7 +710,7 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) { return } refundReservation() - s.ddIncr("request_queue.timeout", []string{"model:" + model}) + s.ddIncr("request_queue.timeout", []string{"model:" + model, "model_type:" + s.registry.ModelType(model)}) writeJSON(w, http.StatusServiceUnavailable, errorResponse("model_not_available", fmt.Sprintf("no hardware-trusted provider became available for model %q (queue timeout)", model))) return } @@ -839,7 +839,7 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) { "attempt", attempt+1, "error", errMsg.Error, ) - s.emitRequest(r.Context(), protocol.SeverityWarn, protocol.KindInferenceError, requestID, + s.emitRequest(r.Context(), protocol.SeverityWarn, requestID, "provider failed, retrying", map[string]any{ "provider_id": provider.ID, @@ -875,7 +875,7 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) { "provider_id", provider.ID, "attempt", attempt+1, ) - s.emitRequest(r.Context(), protocol.SeverityWarn, protocol.KindInferenceError, requestID, + s.emitRequest(r.Context(), protocol.SeverityWarn, requestID, "provider first-chunk timeout", map[string]any{ "provider_id": provider.ID, @@ -924,7 +924,7 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) { "attempt", attempt+1, "error", errMsg.Error, ) - s.emitRequest(r.Context(), protocol.SeverityWarn, protocol.KindInferenceError, requestID, + s.emitRequest(r.Context(), protocol.SeverityWarn, requestID, "provider failed after accepting request, retrying", map[string]any{ "provider_id": provider.ID, @@ -956,7 +956,7 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) { "attempt", attempt+1, "error", errMsg.Error, ) - s.emitRequest(r.Context(), protocol.SeverityWarn, protocol.KindInferenceError, requestID, + s.emitRequest(r.Context(), protocol.SeverityWarn, requestID, "provider failed after accepting request, retrying", map[string]any{ "provider_id": provider.ID, @@ -983,7 +983,7 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) { "provider_id", provider.ID, "attempt", attempt+1, ) - s.emitRequest(r.Context(), protocol.SeverityWarn, protocol.KindInferenceError, requestID, + s.emitRequest(r.Context(), protocol.SeverityWarn, requestID, "provider accepted timeout", map[string]any{ "provider_id": provider.ID, @@ -1015,7 +1015,7 @@ func (s *Server) handleChatCompletions(w http.ResponseWriter, r *http.Request) { if statusCode == 0 { statusCode = http.StatusServiceUnavailable } - s.emitRequest(r.Context(), protocol.SeverityError, protocol.KindInferenceError, requestID, + s.emitRequest(r.Context(), protocol.SeverityError, requestID, fmt.Sprintf("inference failed after %d attempt(s)", maxDispatchAttempts), map[string]any{ "reason": "dispatch_exhausted", @@ -2223,7 +2223,7 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) { const cacheKey = "api_version:v1" if cached, ok := s.readCache.Get(cacheKey); ok { - writeCachedJSON(w, http.StatusOK, cached) + writeCachedJSON(w, cached) return } @@ -2258,7 +2258,7 @@ func (s *Server) handleVersion(w http.ResponseWriter, r *http.Request) { return } s.readCache.Set(cacheKey, body, time.Minute) - writeCachedJSON(w, http.StatusOK, body) + writeCachedJSON(w, body) } // --- payment handlers --- @@ -2494,12 +2494,12 @@ func (s *Server) handleGenericInference(w http.ResponseWriter, r *http.Request, } if err := s.registry.Queue().Enqueue(queuedReq); err != nil { refundReservation() - s.ddIncr("routing.decisions", []string{"model:" + model, "outcome:over_capacity"}) + s.ddIncr("routing.decisions", []string{"model:" + model, "model_type:" + s.registry.ModelType(model), "outcome:over_capacity"}) writeJSON(w, http.StatusServiceUnavailable, errorResponse("model_not_available", fmt.Sprintf("no provider available for model %q", model))) return } - s.ddIncr("routing.decisions", []string{"model:" + model, "outcome:queued"}) + s.ddIncr("routing.decisions", []string{"model:" + model, "model_type:" + s.registry.ModelType(model), "outcome:queued"}) provider, err = s.registry.Queue().WaitForProviderContext(r.Context(), queuedReq) if err != nil { if errors.Is(err, context.Canceled) { @@ -2513,7 +2513,7 @@ func (s *Server) handleGenericInference(w http.ResponseWriter, r *http.Request, } decision = queuedReq.Decision } - s.ddIncr("routing.decisions", []string{"model:" + model, "outcome:selected"}) + s.ddIncr("routing.decisions", []string{"model:" + model, "model_type:" + s.registry.ModelType(model), "outcome:selected"}) s.ddIncr("routing.provider_selected", []string{"provider_id:" + provider.ID, "model:" + model}) s.ddHistogram("routing.cost_ms", decision.CostMs, []string{"model:" + model}) if decision.EffectiveTPS > 0 { diff --git a/coordinator/api/edge_case_test.go b/coordinator/api/edge_case_test.go index b762bf09..d0bfee0e 100644 --- a/coordinator/api/edge_case_test.go +++ b/coordinator/api/edge_case_test.go @@ -7,9 +7,14 @@ package api // (no real backends needed) and run in CI. import ( + "archive/tar" + "bytes" + "compress/gzip" "context" "crypto/rand" + "crypto/sha256" "encoding/base64" + "encoding/hex" "encoding/json" "fmt" "io" @@ -769,9 +774,18 @@ func TestEdge_ReleaseRegisterAndRetrieve(t *testing.T) { srv, st := testServer(t) srv.SetReleaseKey("release-key") - // Register a release - body := fmt.Sprintf(`{"version":"1.0.0","platform":"macos-arm64","backend":"mlx-swift","binary_hash":%q,"bundle_hash":%q,"metallib_hash":%q,"url":"http://example.com/bundle.tar.gz","changelog":"First release"}`, - strings.Repeat("a", 64), strings.Repeat("b", 64), strings.Repeat("c", 64)) + bundle, binaryHash, bundleHash := buildReleaseBundleForTest(t, []byte("provider-binary")) + cdn := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/releases/v1.0.0/eigeninference-bundle-macos-arm64.tar.gz" { + http.NotFound(w, r) + return + } + w.Write(bundle) + })) + defer cdn.Close() + srv.SetR2CDNURL(cdn.URL + "/") + + body := fmt.Sprintf(`{"version":"1.0.0","platform":"macos-arm64","backend":"mlx-swift","binary_hash":%q,"bundle_hash":%q,"metallib_hash":%q,"url":%q,"changelog":"First release"}`, binaryHash, bundleHash, strings.Repeat("c", 64), cdn.URL+"/releases/v1.0.0/eigeninference-bundle-macos-arm64.tar.gz") req := httptest.NewRequest(http.MethodPost, "/v1/releases", strings.NewReader(body)) req.Header.Set("Authorization", "Bearer release-key") w := httptest.NewRecorder() @@ -803,6 +817,351 @@ func TestEdge_ReleaseRegisterAndRetrieve(t *testing.T) { } } +func TestEdge_ReleaseRegisterRejectsInvalidHashMetadata(t *testing.T) { + srv, _ := testServer(t) + srv.SetReleaseKey("release-key") + + body := `{"version":"1.0.0","platform":"macos-arm64","binary_hash":"abc123","bundle_hash":"def456","url":"http://example.com/bundle.tar.gz"}` + req := httptest.NewRequest(http.MethodPost, "/v1/releases", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer release-key") + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("release register with invalid hashes: status = %d, want 400, body = %s", w.Code, w.Body.String()) + } +} + +func TestEdge_ReleaseRegisterRejectsStoreOnlyFields(t *testing.T) { + srv, _ := testServer(t) + srv.SetReleaseKey("release-key") + + binaryHash := strings.Repeat("a", 64) + bundleHash := strings.Repeat("b", 64) + body := fmt.Sprintf(`{"version":"1.0.0","platform":"macos-arm64","binary_hash":%q,"bundle_hash":%q,"url":"https://r2.example.com/releases/v1.0.0/eigeninference-bundle-macos-arm64.tar.gz","active":true,"created_at":"2099-01-01T00:00:00Z"}`, binaryHash, bundleHash) + req := httptest.NewRequest(http.MethodPost, "/v1/releases", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer release-key") + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("release register with store-only fields: status = %d, want 400, body = %s", w.Code, w.Body.String()) + } +} + +func TestEdge_ReleaseRegisterRejectsOffOriginURLWhenR2Configured(t *testing.T) { + srv, _ := testServer(t) + srv.SetReleaseKey("release-key") + srv.SetR2CDNURL("https://r2.example.com") + + binaryHash := strings.Repeat("a", 64) + bundleHash := strings.Repeat("b", 64) + body := fmt.Sprintf(`{"version":"1.0.0","platform":"macos-arm64","binary_hash":%q,"bundle_hash":%q,"url":"https://evil.example.com/releases/v1.0.0/eigeninference-bundle-macos-arm64.tar.gz"}`, binaryHash, bundleHash) + req := httptest.NewRequest(http.MethodPost, "/v1/releases", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer release-key") + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("release register with off-origin URL: status = %d, want 400, body = %s", w.Code, w.Body.String()) + } +} + +func TestEdge_ReleaseRegisterRejectsHTTPArtifactOrigin(t *testing.T) { + srv, _ := testServer(t) + srv.SetReleaseKey("release-key") + srv.SetR2CDNURL("http://r2.example.com") + + binaryHash := strings.Repeat("a", 64) + bundleHash := strings.Repeat("b", 64) + body := fmt.Sprintf(`{"version":"1.0.0","platform":"macos-arm64","binary_hash":%q,"bundle_hash":%q,"url":"http://r2.example.com/releases/v1.0.0/eigeninference-bundle-macos-arm64.tar.gz"}`, binaryHash, bundleHash) + req := httptest.NewRequest(http.MethodPost, "/v1/releases", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer release-key") + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("release register with http artifact origin: status = %d, want 400, body = %s", w.Code, w.Body.String()) + } +} + +func TestEdge_ReleaseRegisterRejectsCredentialedArtifactURL(t *testing.T) { + srv, _ := testServer(t) + srv.SetReleaseKey("release-key") + srv.SetR2CDNURL("https://r2.example.com") + + binaryHash := strings.Repeat("a", 64) + bundleHash := strings.Repeat("b", 64) + body := fmt.Sprintf(`{"version":"1.0.0","platform":"macos-arm64","binary_hash":%q,"bundle_hash":%q,"url":"https://user:pass@r2.example.com/releases/v1.0.0/eigeninference-bundle-macos-arm64.tar.gz"}`, binaryHash, bundleHash) + req := httptest.NewRequest(http.MethodPost, "/v1/releases", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer release-key") + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("release register with credentialed artifact URL: status = %d, want 400, body = %s", w.Code, w.Body.String()) + } +} + +func TestEdge_ReleaseRegisterVerifiesBundleArtifact(t *testing.T) { + srv, st := testServer(t) + srv.SetReleaseKey("release-key") + + bundle, binaryHash, bundleHash := buildReleaseBundleForTest(t, []byte("provider-binary")) + cdn := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/releases/v1.0.0/eigeninference-bundle-macos-arm64.tar.gz" { + http.NotFound(w, r) + return + } + w.Write(bundle) + })) + defer cdn.Close() + srv.SetR2CDNURL(cdn.URL) + + body := fmt.Sprintf(`{"version":"1.0.0","platform":"macos-arm64","binary_hash":%q,"bundle_hash":%q,"url":%q}`, binaryHash, bundleHash, cdn.URL+"/releases/v1.0.0/eigeninference-bundle-macos-arm64.tar.gz") + req := httptest.NewRequest(http.MethodPost, "/v1/releases", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer release-key") + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("release register with verified artifact: status = %d, want 200, body = %s", w.Code, w.Body.String()) + } + releases := st.ListReleases() + if len(releases) != 1 || releases[0].BinaryHash != binaryHash { + t.Fatalf("release was not stored with verified binary hash: %+v", releases) + } +} + +func TestEdge_ReleaseRegisterAcceptsLegacyRegularBundleEntry(t *testing.T) { + srv, st := testServer(t) + srv.SetReleaseKey("release-key") + + bundle, binaryHash, bundleHash := buildReleaseBundleWithEntryForTest(t, "bin/darkbloom", tar.TypeRegA, []byte("provider-binary"), "") + cdn := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/releases/v1.0.0/eigeninference-bundle-macos-arm64.tar.gz" { + http.NotFound(w, r) + return + } + w.Write(bundle) + })) + defer cdn.Close() + srv.SetR2CDNURL(cdn.URL) + + body := fmt.Sprintf(`{"version":"1.0.0","platform":"macos-arm64","binary_hash":%q,"bundle_hash":%q,"url":%q}`, binaryHash, bundleHash, cdn.URL+"/releases/v1.0.0/eigeninference-bundle-macos-arm64.tar.gz") + req := httptest.NewRequest(http.MethodPost, "/v1/releases", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer release-key") + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("release register with legacy regular bundle entry: status = %d, want 200, body = %s", w.Code, w.Body.String()) + } + releases := st.ListReleases() + if len(releases) != 1 || releases[0].BinaryHash != binaryHash { + t.Fatalf("release was not stored with legacy regular bundle entry: %+v", releases) + } +} + +func TestEdge_ReleaseRegisterRejectsBundledBinaryHashMismatch(t *testing.T) { + srv, _ := testServer(t) + srv.SetReleaseKey("release-key") + + bundle, _, bundleHash := buildReleaseBundleForTest(t, []byte("provider-binary")) + cdn := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/releases/v1.0.0/eigeninference-bundle-macos-arm64.tar.gz" { + http.NotFound(w, r) + return + } + w.Write(bundle) + })) + defer cdn.Close() + srv.SetR2CDNURL(cdn.URL) + + wrongBinaryHash := strings.Repeat("c", 64) + body := fmt.Sprintf(`{"version":"1.0.0","platform":"macos-arm64","binary_hash":%q,"bundle_hash":%q,"url":%q}`, wrongBinaryHash, bundleHash, cdn.URL+"/releases/v1.0.0/eigeninference-bundle-macos-arm64.tar.gz") + req := httptest.NewRequest(http.MethodPost, "/v1/releases", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer release-key") + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("release register with mismatched binary hash: status = %d, want 400, body = %s", w.Code, w.Body.String()) + } +} + +func TestEdge_ReleaseRegisterRejectsOversizedBundledBinary(t *testing.T) { + srv, _ := testServer(t) + srv.SetReleaseKey("release-key") + + bundle, bundleHash := buildOversizedBinaryReleaseBundleForTest(t) + cdn := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/releases/v1.0.0/eigeninference-bundle-macos-arm64.tar.gz" { + http.NotFound(w, r) + return + } + w.Write(bundle) + })) + defer cdn.Close() + srv.SetR2CDNURL(cdn.URL) + + binaryHash := strings.Repeat("d", 64) + body := fmt.Sprintf(`{"version":"1.0.0","platform":"macos-arm64","binary_hash":%q,"bundle_hash":%q,"url":%q}`, binaryHash, bundleHash, cdn.URL+"/releases/v1.0.0/eigeninference-bundle-macos-arm64.tar.gz") + req := httptest.NewRequest(http.MethodPost, "/v1/releases", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer release-key") + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("release register with oversized bundled binary: status = %d, want 400, body = %s", w.Code, w.Body.String()) + } +} + +func TestEdge_ReleaseRegisterRejectsRedirectedBundleDownload(t *testing.T) { + srv, _ := testServer(t) + srv.SetReleaseKey("release-key") + + bundle, binaryHash, bundleHash := buildReleaseBundleForTest(t, []byte("provider-binary")) + target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Write(bundle) + })) + defer target.Close() + + cdn := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, target.URL+"/bundle.tar.gz", http.StatusFound) + })) + defer cdn.Close() + srv.SetR2CDNURL(cdn.URL) + + body := fmt.Sprintf(`{"version":"1.0.0","platform":"macos-arm64","binary_hash":%q,"bundle_hash":%q,"url":%q}`, binaryHash, bundleHash, cdn.URL+"/releases/v1.0.0/eigeninference-bundle-macos-arm64.tar.gz") + req := httptest.NewRequest(http.MethodPost, "/v1/releases", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer release-key") + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("release register with redirected bundle: status = %d, want 400, body = %s", w.Code, w.Body.String()) + } +} + +func TestEdge_ReleaseRegisterRejectsUnsafeBundlePath(t *testing.T) { + srv, _ := testServer(t) + srv.SetReleaseKey("release-key") + + bundle, binaryHash, bundleHash := buildReleaseBundleWithEntryForTest(t, "../bin/darkbloom", tar.TypeReg, []byte("provider-binary"), "") + cdn := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/releases/v1.0.0/eigeninference-bundle-macos-arm64.tar.gz" { + http.NotFound(w, r) + return + } + w.Write(bundle) + })) + defer cdn.Close() + srv.SetR2CDNURL(cdn.URL) + + body := fmt.Sprintf(`{"version":"1.0.0","platform":"macos-arm64","binary_hash":%q,"bundle_hash":%q,"url":%q}`, binaryHash, bundleHash, cdn.URL+"/releases/v1.0.0/eigeninference-bundle-macos-arm64.tar.gz") + req := httptest.NewRequest(http.MethodPost, "/v1/releases", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer release-key") + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("release register with unsafe bundle path: status = %d, want 400, body = %s", w.Code, w.Body.String()) + } +} + +func TestEdge_ReleaseRegisterRejectsNonRegularProviderBinary(t *testing.T) { + srv, _ := testServer(t) + srv.SetReleaseKey("release-key") + + bundle, _, bundleHash := buildReleaseBundleWithEntryForTest(t, "bin/darkbloom", tar.TypeSymlink, nil, "darkbloom.real") + cdn := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/releases/v1.0.0/eigeninference-bundle-macos-arm64.tar.gz" { + http.NotFound(w, r) + return + } + w.Write(bundle) + })) + defer cdn.Close() + srv.SetR2CDNURL(cdn.URL) + + binaryHash := strings.Repeat("e", 64) + body := fmt.Sprintf(`{"version":"1.0.0","platform":"macos-arm64","binary_hash":%q,"bundle_hash":%q,"url":%q}`, binaryHash, bundleHash, cdn.URL+"/releases/v1.0.0/eigeninference-bundle-macos-arm64.tar.gz") + req := httptest.NewRequest(http.MethodPost, "/v1/releases", strings.NewReader(body)) + req.Header.Set("Authorization", "Bearer release-key") + w := httptest.NewRecorder() + srv.Handler().ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("release register with non-regular provider binary: status = %d, want 400, body = %s", w.Code, w.Body.String()) + } +} + +func buildReleaseBundleForTest(t *testing.T, binary []byte) ([]byte, string, string) { + t.Helper() + + return buildReleaseBundleWithEntryForTest(t, "bin/darkbloom", tar.TypeReg, binary, "") +} + +func buildReleaseBundleWithEntryForTest(t *testing.T, name string, typeflag byte, binary []byte, linkname string) ([]byte, string, string) { + t.Helper() + + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + + header := &tar.Header{ + Name: name, + Mode: 0o755, + Typeflag: typeflag, + Linkname: linkname, + } + if typeflag == tar.TypeReg || typeflag == tar.TypeRegA { + header.Size = int64(len(binary)) + } + if err := tw.WriteHeader(header); err != nil { + t.Fatalf("write tar header: %v", err) + } + if len(binary) > 0 { + if _, err := tw.Write(binary); err != nil { + t.Fatalf("write binary: %v", err) + } + } + if err := tw.Close(); err != nil { + t.Fatalf("close tar: %v", err) + } + if err := gz.Close(); err != nil { + t.Fatalf("close gzip: %v", err) + } + + return buf.Bytes(), sha256HexBytesForReleaseTest(binary), sha256HexBytesForReleaseTest(buf.Bytes()) +} + +func buildOversizedBinaryReleaseBundleForTest(t *testing.T) ([]byte, string) { + t.Helper() + + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + if err := tw.WriteHeader(&tar.Header{ + Name: "bin/darkbloom", + Mode: 0o755, + Size: maxReleaseProviderBinBytes + 1, + }); err != nil { + t.Fatalf("write oversized tar header: %v", err) + } + if err := gz.Close(); err != nil { + t.Fatalf("close gzip: %v", err) + } + + return buf.Bytes(), sha256HexBytesForReleaseTest(buf.Bytes()) +} + +func sha256HexBytesForReleaseTest(data []byte) string { + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]) +} + // --------------------------------------------------------------------------- // Error response format // --------------------------------------------------------------------------- diff --git a/coordinator/api/leaderboard.go b/coordinator/api/leaderboard.go index 0d2e11b4..ee94e4d5 100644 --- a/coordinator/api/leaderboard.go +++ b/coordinator/api/leaderboard.go @@ -66,7 +66,7 @@ func (s *Server) handleLeaderboard(w http.ResponseWriter, r *http.Request) { cacheKey := fmt.Sprintf("leaderboard:%s:%s:%d", metric, windowParam, limit) if cached, ok := s.readCache.Get(cacheKey); ok { - writeCachedJSON(w, http.StatusOK, cached) + writeCachedJSON(w, cached) return } @@ -102,7 +102,7 @@ func (s *Server) handleLeaderboard(w http.ResponseWriter, r *http.Request) { return } s.readCache.Set(cacheKey, body, 5*time.Minute) - writeCachedJSON(w, http.StatusOK, body) + writeCachedJSON(w, body) } func windowParamOrDefault(s string) string { @@ -126,7 +126,7 @@ func (s *Server) handleNetworkTotals(w http.ResponseWriter, r *http.Request) { cacheKey := "network_totals:" + windowParamOrDefault(windowParam) if cached, ok := s.readCache.Get(cacheKey); ok { - writeCachedJSON(w, http.StatusOK, cached) + writeCachedJSON(w, cached) return } @@ -145,5 +145,5 @@ func (s *Server) handleNetworkTotals(w http.ResponseWriter, r *http.Request) { return } s.readCache.Set(cacheKey, body, time.Minute) - writeCachedJSON(w, http.StatusOK, body) + writeCachedJSON(w, body) } diff --git a/coordinator/api/provider.go b/coordinator/api/provider.go index 756ecb3a..f10d7c54 100644 --- a/coordinator/api/provider.go +++ b/coordinator/api/provider.go @@ -265,6 +265,7 @@ func (s *Server) providerReadLoop(ctx context.Context, conn *websocket.Conn, pro "version", regMsg.Version, "min_version", s.minProviderVersion, ) + s.ddIncr("provider_version_below_minimum", []string{"gate:registration", "version:" + regMsg.Version}) provider.Mu().Lock() provider.RuntimeVerified = false provider.RuntimeManifestChecked = false @@ -284,7 +285,7 @@ func (s *Server) providerReadLoop(ctx context.Context, conn *websocket.Conn, pro case protocol.TypeInferenceAccepted: acceptMsg := msg.Payload.(*protocol.InferenceAcceptedMessage) - s.handleInferenceAccepted(providerID, provider, acceptMsg) + s.handleInferenceAccepted(provider, acceptMsg) case protocol.TypeInferenceResponseChunk: chunkMsg := msg.Payload.(*protocol.InferenceResponseChunkMessage) @@ -475,7 +476,7 @@ func (s *Server) sendChallenge(ctx context.Context, conn *websocket.Conn, provid tracker.remove(nonce) return } - s.ddIncr("attestation.challenges", []string{"outcome:sent"}) + s.ddIncr("attestation.challenges_sent", nil) s.logger.Debug("sent attestation challenge", "provider_id", providerID, "nonce", nonce[:8]+"...") @@ -814,6 +815,7 @@ func (s *Server) verifyChallengeResponse(providerID string, provider *registry.P "version", version, "min_version", s.minProviderVersion, ) + s.ddIncr("provider_version_below_minimum", []string{"gate:challenge_revalidation", "version:" + version}) provider.Mu().Lock() provider.RuntimeVerified = false provider.RuntimeManifestChecked = false @@ -965,7 +967,7 @@ func (e *textChunkViolationError) Error() string { return e.reason } -func (s *Server) handleInferenceAccepted(providerID string, provider *registry.Provider, msg *protocol.InferenceAcceptedMessage) { +func (s *Server) handleInferenceAccepted(provider *registry.Provider, msg *protocol.InferenceAcceptedMessage) { if provider == nil { return } @@ -1066,6 +1068,7 @@ func (s *Server) handleComplete(providerID string, provider *registry.Provider, }) s.store.RecordUsageWithCost(providerID, pr.ConsumerKey, pr.Model, msg.RequestID, msg.Usage.PromptTokens, msg.Usage.CompletionTokens, totalCost) s.ddIncr("inference.completions", []string{"model:" + pr.Model}) + s.ddCount("inference.completion_tokens_total", int64(msg.Usage.CompletionTokens), []string{"model:" + pr.Model}) s.ddHistogram("inference.completion_tokens", float64(msg.Usage.CompletionTokens), []string{"model:" + pr.Model}) // Credit the provider's pending payout. diff --git a/coordinator/api/provider_test.go b/coordinator/api/provider_test.go index 1d25f43a..37e31a1d 100644 --- a/coordinator/api/provider_test.go +++ b/coordinator/api/provider_test.go @@ -9,6 +9,7 @@ import ( "encoding/asn1" "encoding/base64" "encoding/json" + "fmt" "io" "log/slog" "math/big" @@ -420,6 +421,252 @@ func TestProviderRegistrationWithValidAttestation(t *testing.T) { } } +func TestProviderRegistrationRequiresBinaryHashWhenPolicyConfigured(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) + st := store.NewMemory("test-key") + reg := registry.New(logger) + srv := NewServer(reg, st, logger) + srv.SetKnownBinaryHashes([]string{knownGoodBinaryHashForTest}) + + pubKey := testPublicKeyB64() + regMsg := &protocol.RegisterMessage{ + Type: protocol.TypeRegister, + Hardware: protocol.Hardware{ChipName: "Apple M3 Max", MemoryGB: 64}, + Models: []protocol.ModelInfo{{ID: "missing-binary-hash-model", ModelType: "chat", Quantization: "4bit"}}, + Backend: "inprocess-mlx", + PublicKey: pubKey, + EncryptedResponseChunks: true, + PrivacyCapabilities: testPrivacyCaps(), + Attestation: createTestAttestationJSON(t, pubKey), + } + p := reg.Register("provider-1", nil, regMsg) + + srv.verifyProviderAttestation("provider-1", p, regMsg) + + if p.AttestationResult == nil { + t.Fatal("expected attestation result") + } + if p.AttestationResult.Valid { + t.Fatal("attestation should be invalid when binary hash policy is configured and hash is missing") + } + if p.AttestationResult.Error != "binary hash missing" { + t.Fatalf("attestation error = %q, want %q", p.AttestationResult.Error, "binary hash missing") + } + p.Mu().Lock() + defer p.Mu().Unlock() + if p.Status != registry.StatusUntrusted { + t.Fatalf("provider status = %q, want %q", p.Status, registry.StatusUntrusted) + } + if p.TrustLevel != registry.TrustNone { + t.Fatalf("provider trust = %q, want %q", p.TrustLevel, registry.TrustNone) + } +} + +func TestProviderRegistrationAcceptsKnownBinaryHash(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) + st := store.NewMemory("test-key") + reg := registry.New(logger) + srv := NewServer(reg, st, logger) + srv.SetKnownBinaryHashes([]string{knownGoodBinaryHashForTest}) + + pubKey := testPublicKeyB64() + regMsg := &protocol.RegisterMessage{ + Type: protocol.TypeRegister, + Hardware: protocol.Hardware{ChipName: "Apple M3 Max", MemoryGB: 64}, + Models: []protocol.ModelInfo{{ID: "known-binary-hash-model", ModelType: "chat", Quantization: "4bit"}}, + Backend: "inprocess-mlx", + PublicKey: pubKey, + EncryptedResponseChunks: true, + PrivacyCapabilities: testPrivacyCaps(), + Attestation: createTestAttestationJSONWithBinaryHash(t, pubKey, knownGoodBinaryHashForTest), + } + p := reg.Register("provider-1", nil, regMsg) + + srv.verifyProviderAttestation("provider-1", p, regMsg) + + if p.AttestationResult == nil { + t.Fatal("expected attestation result") + } + if !p.AttestationResult.Valid { + t.Fatalf("attestation should be valid with a known binary hash, got %q", p.AttestationResult.Error) + } + p.Mu().Lock() + defer p.Mu().Unlock() + if p.Status == registry.StatusUntrusted { + t.Fatal("provider should not be marked untrusted with a known binary hash") + } + if p.TrustLevel != registry.TrustSelfSigned { + t.Fatalf("provider trust = %q, want %q", p.TrustLevel, registry.TrustSelfSigned) + } +} + +func TestProviderRegistrationRejectsInvalidConfiguredBinaryHash(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) + st := store.NewMemory("test-key") + reg := registry.New(logger) + srv := NewServer(reg, st, logger) + srv.SetKnownBinaryHashes([]string{"not-a-sha256"}) + + pubKey := testPublicKeyB64() + regMsg := &protocol.RegisterMessage{ + Type: protocol.TypeRegister, + Hardware: protocol.Hardware{ChipName: "Apple M3 Max", MemoryGB: 64}, + Models: []protocol.ModelInfo{{ID: "invalid-configured-hash-model", ModelType: "chat", Quantization: "4bit"}}, + Backend: "inprocess-mlx", + PublicKey: pubKey, + EncryptedResponseChunks: true, + PrivacyCapabilities: testPrivacyCaps(), + Attestation: createTestAttestationJSONWithBinaryHash(t, pubKey, "not-a-sha256"), + } + p := reg.Register("provider-1", nil, regMsg) + + srv.verifyProviderAttestation("provider-1", p, regMsg) + + policyConfigured, knownHashes := srv.binaryHashPolicySnapshot() + if !policyConfigured { + t.Fatal("binary hash policy should remain configured even when configured hashes are invalid") + } + if len(knownHashes) != 0 { + t.Fatalf("known binary hashes = %d, want 0 valid hashes", len(knownHashes)) + } + if p.AttestationResult == nil { + t.Fatal("expected attestation result") + } + if p.AttestationResult.Valid { + t.Fatal("attestation should be invalid when configured hash and reported hash are invalid") + } + p.Mu().Lock() + defer p.Mu().Unlock() + if p.Status != registry.StatusUntrusted { + t.Fatalf("provider status = %q, want %q", p.Status, registry.StatusUntrusted) + } +} + +func TestSyncBinaryHashesRejectsInvalidStoredReleaseHashWithoutFailingOpen(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) + st := store.NewMemory("test-key") + reg := registry.New(logger) + srv := NewServer(reg, st, logger) + if err := st.SetRelease(&store.Release{ + Version: "1.0.0", + Platform: "macos-arm64", + BinaryHash: "not-a-sha256", + BundleHash: strings.Repeat("b", 64), + URL: "https://r2.example.com/releases/v1.0.0/eigeninference-bundle-macos-arm64.tar.gz", + }); err != nil { + t.Fatalf("SetRelease: %v", err) + } + + srv.SyncBinaryHashes() + + policyConfigured, knownHashes := srv.binaryHashPolicySnapshot() + if !policyConfigured { + t.Fatal("binary hash policy should remain configured when an active release has an invalid hash") + } + if len(knownHashes) != 0 { + t.Fatalf("known binary hashes = %d, want 0 valid hashes", len(knownHashes)) + } +} + +func TestSyncBinaryHashesPreservesAdditionalConfiguredHashes(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) + st := store.NewMemory("test-key") + reg := registry.New(logger) + srv := NewServer(reg, st, logger) + + manualHash := strings.Repeat("a", 64) + releaseHash := strings.Repeat("b", 64) + srv.AddKnownBinaryHashes([]string{manualHash}) + if err := st.SetRelease(&store.Release{ + Version: "1.0.0", + Platform: "macos-arm64", + BinaryHash: releaseHash, + BundleHash: strings.Repeat("c", 64), + URL: "https://r2.example.com/releases/v1.0.0/eigeninference-bundle-macos-arm64.tar.gz", + }); err != nil { + t.Fatalf("SetRelease: %v", err) + } + + srv.SyncBinaryHashes() + policyConfigured, knownHashes := srv.binaryHashPolicySnapshot() + if !policyConfigured { + t.Fatal("binary hash policy should be configured after manual hash and active release") + } + if !knownHashes[manualHash] { + t.Fatal("manual binary hash was dropped during release sync") + } + if !knownHashes[releaseHash] { + t.Fatal("release binary hash was not synced") + } + + if err := st.DeleteRelease("1.0.0", "macos-arm64"); err != nil { + t.Fatalf("DeleteRelease: %v", err) + } + srv.SyncBinaryHashes() + policyConfigured, knownHashes = srv.binaryHashPolicySnapshot() + if !policyConfigured { + t.Fatal("binary hash policy should remain configured after release deletion because manual hash remains") + } + if !knownHashes[manualHash] { + t.Fatal("manual binary hash was dropped during release deletion sync") + } + if knownHashes[releaseHash] { + t.Fatal("inactive release binary hash should not remain after sync") + } +} + +func TestBinaryHashPolicySnapshotConcurrentSync(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) + st := store.NewMemory("test-key") + reg := registry.New(logger) + srv := NewServer(reg, st, logger) + manualHash := strings.Repeat("a", 64) + srv.AddKnownBinaryHashes([]string{manualHash}) + + done := make(chan struct{}) + var wg sync.WaitGroup + for i := 0; i < 4; i++ { + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-done: + return + default: + policyConfigured, knownHashes := srv.binaryHashPolicySnapshot() + if policyConfigured && !knownHashes[manualHash] { + t.Errorf("manual hash missing from policy snapshot") + return + } + } + } + }() + } + + for i := 0; i < 50; i++ { + version := fmt.Sprintf("1.0.%d", i) + releaseHash := fmt.Sprintf("%064x", i+1) + if err := st.SetRelease(&store.Release{ + Version: version, + Platform: "macos-arm64", + BinaryHash: releaseHash, + BundleHash: strings.Repeat("c", 64), + URL: "https://r2.example.com/releases/v" + version + "/eigeninference-bundle-macos-arm64.tar.gz", + }); err != nil { + t.Fatalf("SetRelease: %v", err) + } + srv.SyncBinaryHashes() + if err := st.DeleteRelease(version, "macos-arm64"); err != nil { + t.Fatalf("DeleteRelease: %v", err) + } + srv.SyncBinaryHashes() + } + + close(done) + wg.Wait() +} + // TestProviderRegistrationWithInvalidAttestation verifies that a provider // with an invalid attestation is still registered but not marked as attested. func TestProviderRegistrationWithInvalidAttestation(t *testing.T) { @@ -513,82 +760,6 @@ func TestProviderRegistrationWithoutAttestation(t *testing.T) { } } -func TestProviderRegistrationRequiresBinaryHashWhenPolicyConfigured(t *testing.T) { - logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) - st := store.NewMemory("test-key") - reg := registry.New(logger) - srv := NewServer(reg, st, logger) - srv.SetKnownBinaryHashes([]string{knownGoodBinaryHashForTest}) - - pubKey := testPublicKeyB64() - regMsg := &protocol.RegisterMessage{ - Type: protocol.TypeRegister, - Hardware: protocol.Hardware{ChipName: "Apple M3 Max", MemoryGB: 64}, - Models: []protocol.ModelInfo{{ID: "missing-binary-hash-model", ModelType: "chat", Quantization: "4bit"}}, - Backend: registry.BackendMLXSwift, - PublicKey: pubKey, - EncryptedResponseChunks: true, - PrivacyCapabilities: testPrivacyCaps(), - Attestation: createTestAttestationJSON(t, pubKey), - } - p := reg.Register("provider-1", nil, regMsg) - - srv.verifyProviderAttestation("provider-1", p, regMsg) - - if p.AttestationResult == nil { - t.Fatal("expected attestation result") - } - if p.AttestationResult.Valid { - t.Fatal("attestation should be invalid when binary hash policy is configured and hash is missing") - } - if p.AttestationResult.Error != "binary hash missing" { - t.Fatalf("attestation error = %q, want %q", p.AttestationResult.Error, "binary hash missing") - } - p.Mu().Lock() - defer p.Mu().Unlock() - if p.Status != registry.StatusUntrusted { - t.Fatalf("provider status = %q, want %q", p.Status, registry.StatusUntrusted) - } -} - -func TestProviderRegistrationAcceptsKnownBinaryHash(t *testing.T) { - logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) - st := store.NewMemory("test-key") - reg := registry.New(logger) - srv := NewServer(reg, st, logger) - srv.SetKnownBinaryHashes([]string{knownGoodBinaryHashForTest}) - - pubKey := testPublicKeyB64() - regMsg := &protocol.RegisterMessage{ - Type: protocol.TypeRegister, - Hardware: protocol.Hardware{ChipName: "Apple M3 Max", MemoryGB: 64}, - Models: []protocol.ModelInfo{{ID: "known-binary-hash-model", ModelType: "chat", Quantization: "4bit"}}, - Backend: registry.BackendMLXSwift, - PublicKey: pubKey, - EncryptedResponseChunks: true, - PrivacyCapabilities: testPrivacyCaps(), - Attestation: createTestAttestationJSONWithBinaryHash(t, pubKey, knownGoodBinaryHashForTest), - } - p := reg.Register("provider-1", nil, regMsg) - - srv.verifyProviderAttestation("provider-1", p, regMsg) - - if p.AttestationResult == nil { - t.Fatal("expected attestation result") - } - if !p.AttestationResult.Valid { - t.Fatalf("attestation should be valid with a known binary hash, got %q", p.AttestationResult.Error) - } - p.Mu().Lock() - defer p.Mu().Unlock() - if p.Status == registry.StatusUntrusted { - t.Fatal("provider should not be marked untrusted with a known binary hash") - } - if p.TrustLevel != registry.TrustSelfSigned { - t.Fatalf("provider trust = %q, want %q", p.TrustLevel, registry.TrustSelfSigned) - } -} - // TestListModelsWithAttestationInfo verifies that /v1/models includes // attestation metadata. func TestListModelsWithAttestationInfo(t *testing.T) { @@ -1186,6 +1357,54 @@ func TestChallengeResponseRejectsMissingSIPStatus(t *testing.T) { } } +func TestChallengeResponseRejectsUnsignedBinaryHashWhenPolicyConfigured(t *testing.T) { + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) + st := store.NewMemory("test-key") + reg := registry.New(logger) + srv := NewServer(reg, st, logger) + srv.SetKnownBinaryHashes([]string{knownGoodBinaryHashForTest}) + + pubKey := testPublicKeyB64() + p := reg.Register("provider-1", nil, &protocol.RegisterMessage{ + Type: protocol.TypeRegister, + Hardware: protocol.Hardware{ChipName: "Apple M3 Max", MemoryGB: 64}, + Models: []protocol.ModelInfo{{ID: "unsigned-challenge-binary-hash-model", ModelType: "chat", Quantization: "4bit"}}, + Backend: "inprocess-mlx", + PublicKey: pubKey, + EncryptedResponseChunks: true, + PrivacyCapabilities: testPrivacyCaps(), + }) + sipEnabled := true + secureBootEnabled := true + rdmaDisabled := true + + srv.verifyChallengeResponse("provider-1", p, &pendingChallenge{ + nonce: "nonce-1", + timestamp: "2026-04-24T12:00:00Z", + }, &protocol.AttestationResponseMessage{ + Type: protocol.TypeAttestationResponse, + Nonce: "nonce-1", + Signature: "dGVzdHNpZ25hdHVyZQ==", + PublicKey: pubKey, + SIPEnabled: &sipEnabled, + SecureBootEnabled: &secureBootEnabled, + RDMADisabled: &rdmaDisabled, + BinaryHash: knownGoodBinaryHashForTest, + }) + + p.Mu().Lock() + defer p.Mu().Unlock() + if p.Status != registry.StatusUntrusted { + t.Fatalf("provider status = %q, want %q", p.Status, registry.StatusUntrusted) + } + if p.FailedChallenges != 1 { + t.Fatalf("failed challenges = %d, want 1", p.FailedChallenges) + } + if !p.LastChallengeVerified.IsZero() { + t.Fatal("provider should not record challenge success for an unsigned binary hash") + } +} + func TestChallengeResponseMissingSIPClearsExistingRoutingEligibility(t *testing.T) { logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) st := store.NewMemory("test-key") diff --git a/coordinator/api/release_handlers.go b/coordinator/api/release_handlers.go index 32f766dd..5a91d764 100644 --- a/coordinator/api/release_handlers.go +++ b/coordinator/api/release_handlers.go @@ -1,72 +1,122 @@ package api import ( + "archive/tar" + "compress/gzip" + "context" + "crypto/sha256" "crypto/subtle" + "encoding/hex" "encoding/json" + "fmt" + "io" + "net" "net/http" + "net/url" + "os" + "path" + "regexp" + "strings" "time" "github.com/eigeninference/d-inference/coordinator/auth" "github.com/eigeninference/d-inference/coordinator/store" ) +const ( + maxReleaseRegisterBodyBytes = 64 * 1024 + maxReleaseArtifactBytes = 2 << 30 // 2 GiB + maxReleaseProviderBinBytes = 512 << 20 + releaseArtifactTimeout = 2 * time.Minute +) + +var ( + releaseVersionPattern = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]+)?$`) + releasePlatformPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,63}$`) + releaseTemplateNamePattern = regexp.MustCompile(`^[A-Za-z0-9._-]+$`) +) + +type registerReleaseRequest struct { + Version string `json:"version"` + Platform string `json:"platform"` + Backend string `json:"backend,omitempty"` + BinaryHash string `json:"binary_hash"` + BundleHash string `json:"bundle_hash"` + MetallibHash string `json:"metallib_hash,omitempty"` + PythonHash string `json:"python_hash,omitempty"` + RuntimeHash string `json:"runtime_hash,omitempty"` + TemplateHashes string `json:"template_hashes,omitempty"` + URL string `json:"url"` + Changelog string `json:"changelog"` +} + +func (req registerReleaseRequest) toRelease() store.Release { + return store.Release{ + Version: req.Version, + Platform: req.Platform, + Backend: req.Backend, + BinaryHash: req.BinaryHash, + BundleHash: req.BundleHash, + MetallibHash: req.MetallibHash, + PythonHash: req.PythonHash, + RuntimeHash: req.RuntimeHash, + TemplateHashes: req.TemplateHashes, + URL: req.URL, + Changelog: req.Changelog, + } +} + // handleRegisterRelease handles POST /v1/releases. // Called by GitHub Actions to register a new provider binary release. // Authenticated with a scoped release key (NOT admin credentials). func (s *Server) handleRegisterRelease(w http.ResponseWriter, r *http.Request) { // Verify scoped release key. token := extractBearerToken(r) - if s.releaseKey == "" || token != s.releaseKey { + if !s.releaseKeyAuthorized(token) { writeJSON(w, http.StatusUnauthorized, errorResponse("unauthorized", "invalid release key")) return } - var release store.Release - if err := json.NewDecoder(r.Body).Decode(&release); err != nil { + var req registerReleaseRequest + r.Body = http.MaxBytesReader(w, r.Body, maxReleaseRegisterBodyBytes) + dec := json.NewDecoder(r.Body) + dec.DisallowUnknownFields() + if err := dec.Decode(&req); err != nil { writeJSON(w, http.StatusBadRequest, errorResponse("invalid_request_error", "invalid JSON: "+err.Error())) return } - if release.Version == "" { - writeJSON(w, http.StatusBadRequest, errorResponse("invalid_request_error", "version is required")) + if err := dec.Decode(&struct{}{}); err != io.EOF { + writeJSON(w, http.StatusBadRequest, errorResponse("invalid_request_error", "invalid JSON: multiple JSON values")) return } + release := req.toRelease() if release.Platform == "" { release.Platform = "macos-arm64" // default } - if release.BinaryHash == "" { - writeJSON(w, http.StatusBadRequest, errorResponse("invalid_request_error", "binary_hash is required")) - return - } - if release.BundleHash == "" { - writeJSON(w, http.StatusBadRequest, errorResponse("invalid_request_error", "bundle_hash is required")) - return - } - if normalized, err := normalizeSHA256Hex(release.BinaryHash, "binary_hash"); err != nil { - writeJSON(w, http.StatusBadRequest, errorResponse("invalid_request_error", err.Error())) - return - } else { - release.BinaryHash = normalized - } - if normalized, err := normalizeSHA256Hex(release.BundleHash, "bundle_hash"); err != nil { + + if err := s.validateReleaseMetadata(&release); err != nil { writeJSON(w, http.StatusBadRequest, errorResponse("invalid_request_error", err.Error())) return - } else { - release.BundleHash = normalized } - if release.MetallibHash != "" { - if normalized, err := normalizeSHA256Hex(release.MetallibHash, "metallib_hash"); err != nil { - writeJSON(w, http.StatusBadRequest, errorResponse("invalid_request_error", err.Error())) - return - } else { - release.MetallibHash = normalized - } - } - if release.Backend == "mlx-swift" && release.MetallibHash == "" { - writeJSON(w, http.StatusBadRequest, errorResponse("invalid_request_error", "metallib_hash is required for mlx-swift releases")) + + if s.r2CDNURL == "" { + s.logger.Error("release: artifact verification unavailable because R2 CDN URL is not configured", + "version", release.Version, + "platform", release.Platform, + ) + writeJSON(w, http.StatusServiceUnavailable, errorResponse("not_configured", "release artifact verification requires R2 CDN URL")) return } - if release.URL == "" { - writeJSON(w, http.StatusBadRequest, errorResponse("invalid_request_error", "url is required")) + + ctx, cancel := context.WithTimeout(r.Context(), releaseArtifactTimeout) + defer cancel() + if err := s.verifyReleaseArtifact(ctx, &release); err != nil { + s.logger.Warn("release: artifact verification failed", + "version", release.Version, + "platform", release.Platform, + "error", err, + ) + writeJSON(w, http.StatusBadRequest, errorResponse("invalid_request_error", "release artifact verification failed: "+err.Error())) return } @@ -99,6 +149,308 @@ func (s *Server) handleRegisterRelease(w http.ResponseWriter, r *http.Request) { }) } +func (s *Server) releaseKeyAuthorized(token string) bool { + if s.releaseKey == "" || token == "" { + return false + } + return subtle.ConstantTimeCompare([]byte(token), []byte(s.releaseKey)) == 1 +} + +func (s *Server) validateReleaseMetadata(release *store.Release) error { + release.Version = strings.TrimSpace(release.Version) + release.Platform = strings.TrimSpace(release.Platform) + release.Backend = strings.TrimSpace(release.Backend) + release.BinaryHash = strings.TrimSpace(release.BinaryHash) + release.BundleHash = strings.TrimSpace(release.BundleHash) + release.MetallibHash = strings.TrimSpace(release.MetallibHash) + release.PythonHash = strings.TrimSpace(release.PythonHash) + release.RuntimeHash = strings.TrimSpace(release.RuntimeHash) + release.TemplateHashes = strings.TrimSpace(release.TemplateHashes) + release.URL = strings.TrimSpace(release.URL) + + if release.Version == "" { + return fmt.Errorf("version is required") + } + if !releaseVersionPattern.MatchString(release.Version) { + return fmt.Errorf("version must be semver, e.g. 1.2.3 or 1.2.3-dev.1") + } + if release.Platform == "" { + return fmt.Errorf("platform is required") + } + if !releasePlatformPattern.MatchString(release.Platform) { + return fmt.Errorf("platform contains invalid characters") + } + + var err error + if release.BinaryHash, err = normalizeSHA256Hex(release.BinaryHash, "binary_hash"); err != nil { + return err + } + if release.BundleHash, err = normalizeSHA256Hex(release.BundleHash, "bundle_hash"); err != nil { + return err + } + if release.MetallibHash != "" { + if release.MetallibHash, err = normalizeSHA256Hex(release.MetallibHash, "metallib_hash"); err != nil { + return err + } + } + if release.Backend == "mlx-swift" && release.MetallibHash == "" { + return fmt.Errorf("metallib_hash is required for mlx-swift releases") + } + if release.PythonHash != "" { + if release.PythonHash, err = normalizeSHA256Hex(release.PythonHash, "python_hash"); err != nil { + return err + } + } + if release.RuntimeHash != "" { + if release.RuntimeHash, err = normalizeSHA256Hex(release.RuntimeHash, "runtime_hash"); err != nil { + return err + } + } + if release.TemplateHashes != "" { + if release.TemplateHashes, err = normalizeTemplateHashes(release.TemplateHashes); err != nil { + return err + } + } + if release.URL == "" { + return fmt.Errorf("url is required") + } + if s.r2CDNURL != "" { + if _, err := s.trustedReleaseArtifactURL(release); err != nil { + return err + } + } + return nil +} + +func (s *Server) trustedReleaseArtifactURL(release *store.Release) (*url.URL, error) { + expectedURL, err := expectedReleaseArtifactURL(s.r2CDNURL, release.Version, release.Platform) + if err != nil { + return nil, err + } + if !sameReleaseArtifactURL(release.URL, expectedURL) { + return nil, fmt.Errorf("url must match configured release artifact path") + } + parsed, err := url.Parse(expectedURL) + if err != nil { + return nil, fmt.Errorf("configured release artifact URL is invalid") + } + return parsed, nil +} + +func expectedReleaseArtifactURL(baseURL, version, platform string) (string, error) { + version = strings.TrimSpace(version) + platform = strings.TrimSpace(platform) + if !releaseVersionPattern.MatchString(version) { + return "", fmt.Errorf("version must be semver, e.g. 1.2.3 or 1.2.3-dev.1") + } + if !releasePlatformPattern.MatchString(platform) { + return "", fmt.Errorf("platform contains invalid characters") + } + + u, err := url.Parse(strings.TrimSpace(baseURL)) + if err != nil { + return "", fmt.Errorf("configured R2 CDN URL is invalid") + } + if u.User != nil || u.RawQuery != "" || u.Fragment != "" { + return "", fmt.Errorf("configured R2 CDN URL must not include credentials, query, or fragment") + } + if u.Host == "" { + return "", fmt.Errorf("configured R2 CDN URL must include a host") + } + if u.Scheme != "https" && u.Scheme != "http" { + return "", fmt.Errorf("configured R2 CDN URL must be absolute") + } + if u.Scheme == "http" && !isLoopbackHost(u.Hostname()) { + return "", fmt.Errorf("configured R2 CDN URL must use https") + } + u.Path = path.Join(u.Path, "releases", "v"+version, "eigeninference-bundle-"+platform+".tar.gz") + u.RawQuery = "" + u.Fragment = "" + return u.String(), nil +} + +func isLoopbackHost(host string) bool { + if strings.EqualFold(host, "localhost") { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + +func sameReleaseArtifactURL(actual, expected string) bool { + actualURL, err := url.Parse(strings.TrimSpace(actual)) + if err != nil { + return false + } + expectedURL, err := url.Parse(expected) + if err != nil { + return false + } + if actualURL.User != nil || expectedURL.User != nil { + return false + } + return strings.EqualFold(actualURL.Scheme, expectedURL.Scheme) && + strings.EqualFold(actualURL.Host, expectedURL.Host) && + path.Clean(actualURL.EscapedPath()) == path.Clean(expectedURL.EscapedPath()) && + actualURL.RawQuery == "" && + actualURL.Fragment == "" +} + +func normalizeSHA256Hex(value, field string) (string, error) { + value = strings.ToLower(strings.TrimSpace(value)) + if len(value) != sha256.Size*2 { + return "", fmt.Errorf("%s must be a 64-character SHA-256 hex digest", field) + } + if _, err := hex.DecodeString(value); err != nil { + return "", fmt.Errorf("%s must be a valid SHA-256 hex digest", field) + } + return value, nil +} + +func normalizeTemplateHashes(raw string) (string, error) { + entries := strings.Split(raw, ",") + normalized := make([]string, 0, len(entries)) + for _, entry := range entries { + entry = strings.TrimSpace(entry) + if entry == "" { + continue + } + name, hash, ok := strings.Cut(entry, "=") + if !ok { + return "", fmt.Errorf("template_hashes entries must be name=sha256") + } + name = strings.TrimSpace(name) + if name == "" || !releaseTemplateNamePattern.MatchString(name) { + return "", fmt.Errorf("template_hashes contains an invalid template name") + } + hash, err := normalizeSHA256Hex(hash, "template_hashes") + if err != nil { + return "", err + } + normalized = append(normalized, name+"="+hash) + } + return strings.Join(normalized, ","), nil +} + +func (s *Server) verifyReleaseArtifact(ctx context.Context, release *store.Release) error { + downloadURL, err := s.trustedReleaseArtifactURL(release) + if err != nil { + return err + } + req := &http.Request{ + Method: http.MethodGet, + URL: downloadURL, + Header: make(http.Header), + } + req = req.WithContext(ctx) + + client := &http.Client{ + CheckRedirect: func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + }, + } + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("download bundle: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("download bundle returned status %d", resp.StatusCode) + } + + tmp, err := os.CreateTemp("", "darkbloom-release-*.tar.gz") + if err != nil { + return fmt.Errorf("create temp bundle: %w", err) + } + defer func() { + tmp.Close() + os.Remove(tmp.Name()) + }() + + bundleHash := sha256.New() + limited := io.LimitReader(resp.Body, maxReleaseArtifactBytes+1) + n, err := io.Copy(io.MultiWriter(tmp, bundleHash), limited) + if err != nil { + return fmt.Errorf("read bundle: %w", err) + } + if n > maxReleaseArtifactBytes { + return fmt.Errorf("bundle exceeds maximum size") + } + actualBundleHash := hex.EncodeToString(bundleHash.Sum(nil)) + if actualBundleHash != release.BundleHash { + return fmt.Errorf("bundle_hash does not match release artifact") + } + + if _, err := tmp.Seek(0, io.SeekStart); err != nil { + return fmt.Errorf("rewind bundle: %w", err) + } + + gz, err := gzip.NewReader(tmp) + if err != nil { + return fmt.Errorf("open bundle gzip: %w", err) + } + defer gz.Close() + + tarReader := tar.NewReader(gz) + binaryHash := sha256.New() + foundBinary := false + for { + header, err := tarReader.Next() + if err == io.EOF { + break + } + if err != nil { + return fmt.Errorf("read bundle tar: %w", err) + } + cleanName, err := cleanReleaseTarPath(header.Name) + if err != nil { + return err + } + if cleanName != "bin/darkbloom" { + continue + } + if header.Typeflag != tar.TypeReg && header.Typeflag != tar.TypeRegA { + return fmt.Errorf("bundled provider binary is not a regular file") + } + if foundBinary { + return fmt.Errorf("bundle contains multiple provider binaries") + } + if header.Size < 0 || header.Size > maxReleaseProviderBinBytes { + return fmt.Errorf("provider binary exceeds maximum size") + } + n, err := io.Copy(binaryHash, io.LimitReader(tarReader, maxReleaseProviderBinBytes+1)) + if err != nil { + return fmt.Errorf("read provider binary: %w", err) + } + if n > maxReleaseProviderBinBytes { + return fmt.Errorf("provider binary exceeds maximum size") + } + foundBinary = true + } + if !foundBinary { + return fmt.Errorf("bundle is missing bin/darkbloom") + } + + actualBinaryHash := hex.EncodeToString(binaryHash.Sum(nil)) + if actualBinaryHash != release.BinaryHash { + return fmt.Errorf("binary_hash does not match bundled provider binary") + } + return nil +} + +func cleanReleaseTarPath(name string) (string, error) { + if name == "" || strings.HasPrefix(name, "/") { + return "", fmt.Errorf("bundle contains unsafe path") + } + for _, part := range strings.Split(name, "/") { + if part == ".." { + return "", fmt.Errorf("bundle contains unsafe path") + } + } + return strings.TrimPrefix(path.Clean(name), "./"), nil +} + // handleLatestRelease handles GET /v1/releases/latest. // Public endpoint — returns the latest active release for a platform. // Used by install.sh to get the download URL and expected hash. @@ -110,7 +462,7 @@ func (s *Server) handleLatestRelease(w http.ResponseWriter, r *http.Request) { cacheKey := "latest_release:v1:" + platform if cached, ok := s.readCache.Get(cacheKey); ok { - writeCachedJSON(w, http.StatusOK, cached) + writeCachedJSON(w, cached) return } @@ -126,7 +478,7 @@ func (s *Server) handleLatestRelease(w http.ResponseWriter, r *http.Request) { return } s.readCache.Set(cacheKey, body, time.Minute) - writeCachedJSON(w, http.StatusOK, body) + writeCachedJSON(w, body) } // handleAdminListReleases handles GET /v1/admin/releases. diff --git a/coordinator/api/routing_metrics_test.go b/coordinator/api/routing_metrics_test.go index 96df419e..4a176692 100644 --- a/coordinator/api/routing_metrics_test.go +++ b/coordinator/api/routing_metrics_test.go @@ -415,18 +415,22 @@ func TestAttestationMetrics_AllOutcomes(t *testing.T) { defer ddClient.Close() srv.SetDatadog(ddClient) - for _, outcome := range []string{"sent", "passed", "failed", "status_sig_missing"} { + for _, outcome := range []string{"passed", "failed", "status_sig_missing"} { srv.ddIncr("attestation.challenges", []string{"outcome:" + outcome}) } + srv.ddIncr("attestation.challenges_sent", nil) _ = ddClient.Statsd.Flush() packets := collector.drain() - for _, outcome := range []string{"sent", "passed", "failed", "status_sig_missing"} { + for _, outcome := range []string{"passed", "failed", "status_sig_missing"} { if !hasMetric(packets, "outcome:"+outcome) { t.Errorf("missing attestation.challenges{outcome:%s}; got packets: %v", outcome, packets) } } + if !hasMetric(packets, "attestation.challenges_sent") { + t.Errorf("missing attestation.challenges_sent; got packets: %v", packets) + } } func TestInferenceMetrics_CompletionCounters(t *testing.T) { diff --git a/coordinator/api/server.go b/coordinator/api/server.go index 53714010..af1e2699 100644 --- a/coordinator/api/server.go +++ b/coordinator/api/server.go @@ -17,11 +17,9 @@ import ( "bufio" "context" "crypto/rand" - "crypto/sha256" "crypto/subtle" "crypto/x509" _ "embed" - "encoding/hex" "encoding/json" "errors" "fmt" @@ -320,13 +318,13 @@ func (s *Server) emit(ctx context.Context, severity protocol.TelemetrySeverity, } // emitRequest is like emit but preserves a request_id for correlation. -func (s *Server) emitRequest(ctx context.Context, severity protocol.TelemetrySeverity, kind protocol.TelemetryKind, requestID, message string, fields map[string]any) { +func (s *Server) emitRequest(ctx context.Context, severity protocol.TelemetrySeverity, requestID, message string, fields map[string]any) { if s.emitter == nil { return } s.emitter.Emit(ctx, telemetry.Event{ Severity: severity, - Kind: kind, + Kind: protocol.KindInferenceError, Message: message, Fields: fields, RequestID: requestID, @@ -340,6 +338,13 @@ func (s *Server) ddIncr(name string, tags []string) { } } +// ddCount increments a DogStatsD counter by the given value. No-op if DD is not configured. +func (s *Server) ddCount(name string, value int64, tags []string) { + if s.dd != nil { + s.dd.Count(name, value, tags) + } +} + // ddHistogram records a DogStatsD histogram value. No-op if DD is not configured. func (s *Server) ddHistogram(name string, value float64, tags []string) { if s.dd != nil { @@ -354,6 +359,13 @@ func (s *Server) ddGauge(name string, value float64, tags []string) { } } +// modelTypeTag returns a DogStatsD tag "model_type:" for the given +// model ID, resolved from the registry. Returns "model_type:unknown" if +// the model is not found. +func (s *Server) modelTypeTag(model string) string { + return "model_type:" + s.registry.ModelType(model) +} + // emitPanic is the panic-specific emit helper. Captures stack separately. func (s *Server) emitPanic(ctx context.Context, message, stack string, fields map[string]any) { if s.emitter == nil { @@ -484,17 +496,6 @@ func hasConfiguredHashInput(hashes []string) bool { return false } -func normalizeSHA256Hex(value, field string) (string, error) { - value = strings.ToLower(strings.TrimSpace(value)) - if len(value) != sha256.Size*2 { - return "", fmt.Errorf("%s must be a 64-character SHA-256 hex digest", field) - } - if _, err := hex.DecodeString(value); err != nil { - return "", fmt.Errorf("%s must be a valid SHA-256 hex digest", field) - } - return value, nil -} - // SetConsoleURL sets the frontend URL for device auth verification links. func (s *Server) SetConsoleURL(url string) { s.consoleURL = url @@ -672,6 +673,7 @@ func (s *Server) revalidateConnectedProvidersAgainstRuntimePolicy() { semverLess(version, s.minProviderVersion): provider.RuntimeVerified = false provider.RuntimeManifestChecked = false + s.ddIncr("provider_version_below_minimum", []string{"gate:manifest_sync", "version:" + version}) default: runtimeOK, _ := s.verifyRuntimeHashesForBackend( backend, @@ -861,7 +863,7 @@ func (s *Server) verifyRuntimeHashesAgainstManifest(manifest *RuntimeManifest, p func (s *Server) handleRuntimeManifest(w http.ResponseWriter, r *http.Request) { const cacheKey = "runtime_manifest:v1" if cached, ok := s.readCache.Get(cacheKey); ok { - writeCachedJSON(w, http.StatusOK, cached) + writeCachedJSON(w, cached) return } var resp map[string]any @@ -881,7 +883,7 @@ func (s *Server) handleRuntimeManifest(w http.ResponseWriter, r *http.Request) { return } s.readCache.Set(cacheKey, body, time.Minute) - writeCachedJSON(w, http.StatusOK, body) + writeCachedJSON(w, body) } // HandleMDMWebhook processes a MicroMDM webhook callback. @@ -1101,6 +1103,12 @@ func (s *Server) registerDefaultGauges() { s.metrics.RegisterGauge("providers_online", func() float64 { return float64(s.registry.ProviderCount()) }) + s.metrics.RegisterGauge("min_provider_version_set", func() float64 { + if s.minProviderVersion != "" { + return 1 + } + return 0 + }) } // StartDDGaugeLoop periodically pushes gauge values to DogStatsD. Gauges @@ -1117,7 +1125,19 @@ func (s *Server) StartDDGaugeLoop(ctx context.Context) { case <-ctx.Done(): return case <-ticker.C: - s.ddGauge("providers.online", float64(s.registry.ProviderCount()), nil) + s.ddGauge("providers.online", float64(s.registry.OnlineCount()), nil) + for model, count := range s.registry.ModelProviderSnapshot() { + s.ddGauge("providers.per_model", float64(count), []string{"model:" + model}) + } + for ver, count := range s.registry.ProviderCountByVersion() { + s.ddGauge("providers.per_version", float64(count), []string{"version:" + ver}) + } + for hash, count := range s.registry.ProviderCountByBinaryHash() { + s.ddGauge("providers.per_binary_hash", float64(count), []string{"binary_hash:" + hash}) + } + if s.minProviderVersion != "" { + s.ddGauge("coordinator.min_provider_version_set", 1, []string{"min_version:" + s.minProviderVersion}) + } if q := s.registry.Queue(); q != nil { s.ddGauge("request_queue.depth", float64(q.TotalSize()), nil) } @@ -1443,14 +1463,15 @@ func (s *Server) loggingMiddleware(next http.Handler) http.Handler { } // httpPathLabel returns a bounded label for HTTP metrics. -// We use the mux route pattern (e.g. "POST /v1/chat/completions") +// We use the mux route pattern (e.g. "POST-/v1/chat/completions") // instead of URL.Path so attacker-controlled unmatched paths cannot create -// unbounded metric cardinality. +// unbounded metric cardinality. Dashes replace spaces so DogStatsD tags +// parse cleanly (spaces break tag parsing). func httpPathLabel(route string) string { if route == "" { return "unmatched" } - return route + return strings.ReplaceAll(route, " ", "-") } // strconvItoa is a shim to avoid pulling strconv into every middleware file. diff --git a/coordinator/api/server_metrics_label_test.go b/coordinator/api/server_metrics_label_test.go index f2cb9083..24b2977f 100644 --- a/coordinator/api/server_metrics_label_test.go +++ b/coordinator/api/server_metrics_label_test.go @@ -8,7 +8,7 @@ func TestHTTPPathLabel_UsesBoundedRouteLabel(t *testing.T) { route string want string }{ - {name: "matched route", route: "POST /v1/chat/completions", want: "POST /v1/chat/completions"}, + {name: "matched route", route: "POST /v1/chat/completions", want: "POST-/v1/chat/completions"}, {name: "empty route", route: "", want: "unmatched"}, } diff --git a/coordinator/api/stats.go b/coordinator/api/stats.go index fbefbe4f..2034d567 100644 --- a/coordinator/api/stats.go +++ b/coordinator/api/stats.go @@ -15,7 +15,7 @@ import ( func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) { const cacheKey = "stats:v1" if cached, ok := s.readCache.Get(cacheKey); ok { - writeCachedJSON(w, http.StatusOK, cached) + writeCachedJSON(w, cached) return } var ( @@ -142,5 +142,5 @@ func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) { return } s.readCache.Set(cacheKey, body, time.Minute) - writeCachedJSON(w, http.StatusOK, body) + writeCachedJSON(w, body) } diff --git a/coordinator/datadog/datadog.go b/coordinator/datadog/datadog.go index e22d4e05..e9f4bfd5 100644 --- a/coordinator/datadog/datadog.go +++ b/coordinator/datadog/datadog.go @@ -147,6 +147,14 @@ func (c *Client) Incr(name string, tags []string) { _ = c.Statsd.Incr(name, tags, 1) } +// Count increments a DogStatsD counter by the given value. +func (c *Client) Count(name string, value int64, tags []string) { + if c == nil || c.Statsd == nil { + return + } + _ = c.Statsd.Count(name, value, tags, 1) +} + // Histogram records a histogram value. func (c *Client) Histogram(name string, value float64, tags []string) { if c == nil || c.Statsd == nil { diff --git a/coordinator/registry/registry.go b/coordinator/registry/registry.go index 77261cbb..cf0cd6f2 100644 --- a/coordinator/registry/registry.go +++ b/coordinator/registry/registry.go @@ -25,6 +25,7 @@ import ( "math" "math/rand" "sync" + "sync/atomic" "time" "github.com/eigeninference/d-inference/coordinator/attestation" @@ -365,32 +366,29 @@ type Registry struct { mu sync.RWMutex providers map[string]*Provider - // queue manages requests waiting for a provider to become available. queue *RequestQueue - // MinTrustLevel is the minimum trust level required for routing. - // Defaults to TrustHardware. Set to TrustNone for testing. MinTrustLevel TrustLevel - // modelCatalog maps active model IDs to their catalog metadata (including - // expected weight hashes). When non-empty, only models in this map are - // accepted from providers and routable by consumers. Updated via SetModelCatalog. modelCatalog map[string]CatalogEntry - // store provides persistence for provider fleet state. When non-nil, - // provider records and reputation are persisted across coordinator restarts. store store.Store logger *slog.Logger + + onlineCount atomic.Int64 + modelProviders map[string]*atomic.Int64 + modelProvidersMu sync.Mutex } // New creates a new Registry. func New(logger *slog.Logger) *Registry { return &Registry{ - providers: make(map[string]*Provider), - queue: NewRequestQueue(10, 120*time.Second), - MinTrustLevel: TrustHardware, - logger: logger, + providers: make(map[string]*Provider), + queue: NewRequestQueue(10, 120*time.Second), + MinTrustLevel: TrustHardware, + modelProviders: make(map[string]*atomic.Int64), + logger: logger, } } @@ -632,6 +630,27 @@ func (r *Registry) SetModelCatalog(entries []CatalogEntry) { r.modelCatalog = catalog } +// ModelType returns the model type string for the given model ID, or +// "unknown" if no provider is currently serving it. +func (r *Registry) ModelType(model string) string { + r.mu.RLock() + defer r.mu.RUnlock() + for _, p := range r.providers { + if p.Status == StatusOffline || p.Status == StatusUntrusted { + continue + } + p.mu.Lock() + for _, m := range p.Models { + if m.ID == model && m.ModelType != "" { + p.mu.Unlock() + return m.ModelType + } + } + p.mu.Unlock() + } + return "unknown" +} + // IsModelInCatalog returns true if the model is in the active catalog, // or if no catalog is configured (all models allowed). func (r *Registry) IsModelInCatalog(model string) bool { @@ -854,6 +873,10 @@ func (r *Registry) Register(id string, conn *websocket.Conn, msg *protocol.Regis r.mu.Lock() r.providers[id] = p + r.onlineCount.Add(1) + for _, m := range models { + r.modelProviderInc(m.ID) + } r.mu.Unlock() r.logger.Info("provider registered", @@ -998,6 +1021,14 @@ func (r *Registry) Disconnect(id string) { p, ok := r.providers[id] if ok { delete(r.providers, id) + p.mu.Lock() + if p.Status != StatusUntrusted { + r.onlineCount.Add(-1) + for _, m := range p.Models { + r.modelProviderDec(m.ID) + } + } + p.mu.Unlock() } r.mu.Unlock() @@ -1043,6 +1074,12 @@ func (r *Registry) MarkUntrusted(providerID string) { } p.mu.Lock() + if p.Status != StatusUntrusted { + r.onlineCount.Add(-1) + for _, m := range p.Models { + r.modelProviderDec(m.ID) + } + } p.Status = StatusUntrusted p.mu.Unlock() @@ -1601,12 +1638,130 @@ func (r *Registry) RecordJobFailure(providerID string) { } // ProviderCount returns the number of registered providers. +// modelProviderInc increments the provider count for a model. Must be called +// with r.mu held. +func (r *Registry) modelProviderInc(model string) { + r.modelProvidersMu.Lock() + c, ok := r.modelProviders[model] + if !ok { + c = &atomic.Int64{} + r.modelProviders[model] = c + } + r.modelProvidersMu.Unlock() + c.Add(1) +} + +// modelProviderDec decrements the provider count for a model. Must be called +// with r.mu held. +func (r *Registry) modelProviderDec(model string) { + r.modelProvidersMu.Lock() + c, ok := r.modelProviders[model] + r.modelProvidersMu.Unlock() + if ok { + v := c.Add(-1) + if v <= 0 { + r.modelProvidersMu.Lock() + delete(r.modelProviders, model) + r.modelProvidersMu.Unlock() + } + } +} + +// OnlineCount returns the number of online providers. +func (r *Registry) OnlineCount() int64 { + return r.onlineCount.Load() +} + +// ModelProviderSnapshot returns a snapshot of model_id -> provider count. +func (r *Registry) ModelProviderSnapshot() map[string]int64 { + r.modelProvidersMu.Lock() + snap := make(map[string]int64, len(r.modelProviders)) + for model, c := range r.modelProviders { + if v := c.Load(); v > 0 { + snap[model] = v + } + } + r.modelProvidersMu.Unlock() + return snap +} + +// ProviderCountByChip returns a map of chip_name -> count of online providers. +func (r *Registry) ProviderCountByChip() map[string]int { + r.mu.RLock() + defer r.mu.RUnlock() + counts := make(map[string]int) + for _, p := range r.providers { + p.mu.Lock() + online := p.Status != StatusOffline && p.Status != StatusUntrusted + p.mu.Unlock() + if online { + chip := p.Hardware.ChipName + if chip == "" { + chip = "unknown" + } + counts[chip]++ + } + } + return counts +} + +// ModelProviderCounts returns a map of model_id -> count of online providers +// serving that model. +func (r *Registry) ModelProviderCounts() map[string]int { + snap := r.ModelProviderSnapshot() + out := make(map[string]int, len(snap)) + for k, v := range snap { + out[k] = int(v) + } + return out +} + func (r *Registry) ProviderCount() int { r.mu.RLock() defer r.mu.RUnlock() return len(r.providers) } +func (r *Registry) ProviderCountByVersion() map[string]int { + r.mu.RLock() + defer r.mu.RUnlock() + counts := make(map[string]int) + for _, p := range r.providers { + p.mu.Lock() + online := p.Status != StatusOffline && p.Status != StatusUntrusted + p.mu.Unlock() + if !online { + continue + } + ver := p.Version + if ver == "" { + ver = "unknown" + } + counts[ver]++ + } + return counts +} + +func (r *Registry) ProviderCountByBinaryHash() map[string]int { + r.mu.RLock() + defer r.mu.RUnlock() + counts := make(map[string]int) + for _, p := range r.providers { + p.mu.Lock() + online := p.Status != StatusOffline && p.Status != StatusUntrusted + p.mu.Unlock() + if !online { + continue + } + hash := "unknown" + if p.AttestationResult != nil && p.AttestationResult.BinaryHash != "" { + hash = p.AttestationResult.BinaryHash + } + counts[hash]++ + } + return counts +} + // FleetSnapshot is the read-only summary used by metrics polling. We // don't lock individual providers — counts may be off-by-one under // heavy churn — that's acceptable for gauges. diff --git a/coordinator/registry/scheduler.go b/coordinator/registry/scheduler.go index 00dac4a1..b408cf1c 100644 --- a/coordinator/registry/scheduler.go +++ b/coordinator/registry/scheduler.go @@ -181,7 +181,7 @@ func (r *Registry) ReserveProviderEx(model string, pr *PendingRequest, excludeID // Re-check capacity under the provider lock in case another goroutine // changed the pending set between snapshot and reservation. - if !r.providerCanAdmitLocked(p, model, pr) { + if !r.providerCanAdmitLocked(p, model) { return nil, RoutingDecision{ Model: model, CandidateCount: candidateCount, @@ -249,7 +249,7 @@ func (r *Registry) selectBestCandidateLockedFull(model string, pr *PendingReques if _, excluded := excludeSet[p.ID]; excluded { continue } - snap, ok := r.snapshotProviderLocked(p, model, pr) + snap, ok := r.snapshotProviderLocked(p, model) if !ok { continue } @@ -355,7 +355,7 @@ func (r *Registry) logRoutingDecision(model string, pr *PendingRequest, winner * ) } -func (r *Registry) snapshotProviderLocked(p *Provider, model string, pr *PendingRequest) (routingSnapshot, bool) { +func (r *Registry) snapshotProviderLocked(p *Provider, model string) (routingSnapshot, bool) { now := time.Now() p.mu.Lock() @@ -643,7 +643,7 @@ func providerModelIDs(p *Provider) []string { return ids } -func (r *Registry) providerCanAdmitLocked(p *Provider, model string, pr *PendingRequest) bool { +func (r *Registry) providerCanAdmitLocked(p *Provider, model string) bool { if p.Status == StatusOffline || p.Status == StatusUntrusted { return false } diff --git a/deploy/datadog/dev-network-dashboard.json b/deploy/datadog/dev-network-dashboard.json index bc518947..482a3ca9 100644 --- a/deploy/datadog/dev-network-dashboard.json +++ b/deploy/datadog/dev-network-dashboard.json @@ -1,6 +1,6 @@ { "title": "d-inference Dev", - "description": "Log-first operational dashboard for the d-inference dev coordinator. Provider telemetry is forwarded through the coordinator and indexed with source:provider; regular coordinator runtime logs are indexed with kind:coordinator_log.", + "description": "Full observability dashboard for the d-inference dev coordinator. Metrics via DogStatsD (d_inference.*), logs via DD Agent journald collection + direct Logs API, traces via DD Agent APM, and system metrics from the host-level agent.", "layout_type": "ordered", "notify_list": [], "template_variables": [ @@ -13,13 +13,18 @@ "name": "service", "prefix": "service", "default": "d-inference-coordinator" + }, + { + "name": "model", + "prefix": "model", + "default": "*" } ], "widgets": [ { "definition": { "type": "note", - "content": "Dev d-inference observability. This dashboard is log-backed so it works without a Datadog Agent on the VM. Queries use `$env $service`. Provider telemetry appears as `source:provider`; coordinator runtime logs appear as `source:coordinator kind:coordinator_log`.", + "content": "## d-inference Dev Observability\nMetrics: `d_inference.*` via DogStatsD (agent on 8125) | Logs: journald + direct Logs API | Traces: APM agent on 8126 | System: host-level DD agent checks\n\nTemplate vars: `$env`, `$service`, `$model`. Provider telemetry = `source:provider`; coordinator events = `source:coordinator`.", "background_color": "blue", "font_size": "14", "text_align": "left", @@ -28,316 +33,1203 @@ }, { "definition": { - "type": "query_value", - "title": "All Dev Logs", - "autoscale": true, - "precision": 0, - "requests": [ - { - "response_format": "scalar", - "queries": [ - { - "name": "query1", - "data_source": "logs", - "search": { - "query": "$env $service" - }, - "indexes": [ - "*" - ], - "compute": { - "aggregation": "count" - } - } - ], - "formulas": [ - { - "formula": "query1" - } - ] - } - ] - } - }, - { - "definition": { - "type": "query_value", - "title": "Provider Telemetry", - "autoscale": true, - "precision": 0, - "requests": [ - { - "response_format": "scalar", - "queries": [ - { - "name": "query1", - "data_source": "logs", - "search": { - "query": "$env $service source:provider" - }, - "indexes": [ - "*" - ], - "compute": { - "aggregation": "count" - } - } - ], - "formulas": [ - { - "formula": "query1" - } - ] + "type": "group", + "title": "Overview", + "layout_type": "ordered", + "show_title": true, + "widgets": [ + { + "definition": { + "type": "timeseries", + "title": "Providers Online", + "show_legend": true, + "legend_layout": "auto", + "requests": [ + { + "response_format": "timeseries", + "queries": [ + { + "name": "query1", + "data_source": "metrics", + "query": "avg:d_inference.providers.online{$env}" + } + ], + "formulas": [ + { + "formula": "query1" + } + ] + } + ] + } + }, + { + "definition": { + "type": "timeseries", + "title": "Queue Depth", + "show_legend": true, + "legend_layout": "auto", + "requests": [ + { + "response_format": "timeseries", + "queries": [ + { + "name": "query1", + "data_source": "metrics", + "query": "avg:d_inference.request_queue.depth{$env}" + } + ], + "formulas": [ + { + "formula": "query1" + } + ] + } + ] + } + }, + { + "definition": { + "type": "query_table", + "title": "Providers per Model", + "has_search_bar": "auto", + "requests": [ + { + "response_format": "scalar", + "queries": [ + { + "name": "query1", + "data_source": "metrics", + "query": "avg:d_inference.providers.per_model{$env} by {model}", + "aggregator": "last" + } + ], + "formulas": [ + { + "formula": "query1" + } + ] + } + ] + } + }, + { + "definition": { + "type": "query_value", + "title": "All Dev Logs", + "autoscale": true, + "precision": 0, + "requests": [ + { + "response_format": "scalar", + "queries": [ + { + "name": "query1", + "data_source": "logs", + "search": { + "query": "$env $service" + }, + "indexes": ["*"], + "compute": { + "aggregation": "count" + } + } + ], + "formulas": [ + { + "formula": "query1" + } + ] + } + ] + } + }, + { + "definition": { + "type": "query_value", + "title": "Warnings + Errors", + "autoscale": true, + "precision": 0, + "requests": [ + { + "response_format": "scalar", + "queries": [ + { + "name": "query1", + "data_source": "logs", + "search": { + "query": "$env $service (severity:warn OR severity:error OR severity:fatal OR status:warn OR status:error)" + }, + "indexes": ["*"], + "compute": { + "aggregation": "count" + } + } + ], + "formulas": [ + { + "formula": "query1" + } + ] + } + ] + } } ] } }, { "definition": { - "type": "query_value", - "title": "Warnings + Errors", - "autoscale": true, - "precision": 0, - "requests": [ - { - "response_format": "scalar", - "queries": [ - { - "name": "query1", - "data_source": "logs", - "search": { - "query": "$env $service (severity:warn OR severity:error OR severity:fatal OR status:warn OR status:error)" - }, - "indexes": [ - "*" - ], - "compute": { - "aggregation": "count" - } - } - ], - "formulas": [ - { - "formula": "query1" - } - ] + "type": "group", + "title": "Inference & Request Flow", + "layout_type": "ordered", + "show_title": true, + "widgets": [ + { + "definition": { + "type": "timeseries", + "title": "Inference Dispatches by Status", + "show_legend": true, + "legend_layout": "auto", + "requests": [ + { + "response_format": "timeseries", + "queries": [ + { + "name": "query1", + "data_source": "metrics", + "query": "sum:d_inference.inference.dispatches{$env} by {status}.as_count()" + } + ], + "formulas": [ + { + "formula": "query1" + } + ], + "display_type": "bars" + } + ] + } + }, + { + "definition": { + "type": "timeseries", + "title": "HTTP Request Latency by Route (p50/p95/p99)", + "show_legend": true, + "legend_layout": "auto", + "requests": [ + { + "response_format": "timeseries", + "queries": [ + { + "name": "p50", + "data_source": "metrics", + "query": "avg:d_inference.http.latency_ms.50percentile{$env} by {path}" + }, + { + "name": "p95", + "data_source": "metrics", + "query": "avg:d_inference.http.latency_ms.95percentile{$env} by {path}" + }, + { + "name": "p99", + "data_source": "metrics", + "query": "avg:d_inference.http.latency_ms.99percentile{$env} by {path}" + } + ], + "formulas": [ + { + "formula": "p50" + }, + { + "formula": "p95" + }, + { + "formula": "p99" + } + ], + "style": { + "palette": "warm" + } + } + ] + } + }, + { + "definition": { + "type": "timeseries", + "title": "HTTP Requests by Route", + "show_legend": true, + "legend_layout": "auto", + "requests": [ + { + "response_format": "timeseries", + "queries": [ + { + "name": "query1", + "data_source": "metrics", + "query": "sum:d_inference.http.requests{$env} by {path}.as_count()" + } + ], + "formulas": [ + { + "formula": "query1" + } + ], + "display_type": "bars" + } + ] + } + }, + { + "definition": { + "type": "timeseries", + "title": "Routing Decisions by Outcome", + "show_legend": true, + "legend_layout": "auto", + "requests": [ + { + "response_format": "timeseries", + "queries": [ + { + "name": "query1", + "data_source": "metrics", + "query": "sum:d_inference.routing.decisions{$env} by {outcome}.as_count()" + } + ], + "formulas": [ + { + "formula": "query1" + } + ], + "display_type": "bars" + } + ] + } + }, + { + "definition": { + "type": "timeseries", + "title": "Completion Tokens by Model", + "show_legend": true, + "legend_layout": "auto", + "requests": [ + { + "response_format": "timeseries", + "queries": [ + { + "name": "query1", + "data_source": "metrics", + "query": "sum:d_inference.inference.completion_tokens_total{$env,model:$model}.as_count()" + } + ], + "formulas": [ + { + "formula": "query1" + } + ], + "display_type": "bars" + } + ] + } + }, + { + "definition": { + "type": "timeseries", + "title": "Queue Timeouts by Model", + "show_legend": true, + "legend_layout": "auto", + "requests": [ + { + "response_format": "timeseries", + "queries": [ + { + "name": "query1", + "data_source": "metrics", + "query": "sum:d_inference.request_queue.timeout{$env,model:$model}.as_count()" + } + ], + "formulas": [ + { + "formula": "query1" + } + ], + "style": { + "palette": "warm" + } + } + ] + } + }, + { + "definition": { + "type": "timeseries", + "title": "Routing Cost (p95) by Model", + "show_legend": true, + "legend_layout": "auto", + "requests": [ + { + "response_format": "timeseries", + "queries": [ + { + "name": "query1", + "data_source": "metrics", + "query": "avg:d_inference.routing.cost_ms.95percentile{$env,model:$model}" + } + ], + "formulas": [ + { + "formula": "query1" + } + ] + } + ] + } + }, + { + "definition": { + "type": "sunburst", + "title": "Requests by Model Type", + "requests": [ + { + "response_format": "scalar", + "queries": [ + { + "name": "query1", + "data_source": "metrics", + "query": "sum:d_inference.routing.decisions{$env} by {model_type}.as_count()" + } + ], + "formulas": [ + { + "formula": "query1" + } + ] + } + ] + } } ] } }, { "definition": { - "type": "timeseries", - "title": "Log Volume by Source", - "show_legend": true, - "legend_layout": "auto", - "requests": [ - { - "response_format": "timeseries", - "queries": [ - { - "name": "query1", - "data_source": "logs", - "search": { - "query": "$env $service" - }, - "indexes": [ - "*" - ], - "compute": { - "aggregation": "count" - }, - "group_by": [ - { - "facet": "source", - "limit": 10, - "sort": { - "aggregation": "count", - "order": "desc" + "type": "group", + "title": "Attestation & Security", + "layout_type": "ordered", + "show_title": true, + "widgets": [ + { + "definition": { + "type": "timeseries", + "title": "Attestation Challenges Sent", + "show_legend": true, + "legend_layout": "auto", + "requests": [ + { + "response_format": "timeseries", + "queries": [ + { + "name": "query1", + "data_source": "metrics", + "query": "sum:d_inference.attestation.challenges_sent{$env}.as_count()" + } + ], + "formulas": [ + { + "formula": "query1" + } + ], + "display_type": "bars" + } + ] + } + }, + { + "definition": { + "type": "timeseries", + "title": "Attestation Challenge Outcomes", + "show_legend": true, + "legend_layout": "auto", + "requests": [ + { + "response_format": "timeseries", + "queries": [ + { + "name": "query1", + "data_source": "metrics", + "query": "sum:d_inference.attestation.challenges{$env} by {outcome}.as_count()" } + ], + "formulas": [ + { + "formula": "query1" + } + ], + "display_type": "bars" + } + ] + } + }, + { + "definition": { + "type": "timeseries", + "title": "Attestation Failures by Reason", + "show_legend": true, + "legend_layout": "auto", + "requests": [ + { + "response_format": "timeseries", + "queries": [ + { + "name": "query1", + "data_source": "metrics", + "query": "sum:d_inference.attestation.failures{$env} by {reason}.as_count()" + } + ], + "formulas": [ + { + "formula": "query1" + } + ], + "style": { + "palette": "warm" } - ] - } - ], - "formulas": [ - { - "formula": "query1" - } - ], - "display_type": "bars" + } + ] + } + }, + { + "definition": { + "type": "timeseries", + "title": "Provider Registrations by Trust Level", + "show_legend": true, + "legend_layout": "auto", + "requests": [ + { + "response_format": "timeseries", + "queries": [ + { + "name": "query1", + "data_source": "metrics", + "query": "sum:d_inference.providers.registrations{$env} by {trust_level}.as_count()" + } + ], + "formulas": [ + { + "formula": "query1" + } + ], + "display_type": "bars" + } + ] + } } ] } }, { "definition": { - "type": "timeseries", - "title": "Warnings + Errors by Kind", - "show_legend": true, - "legend_layout": "auto", - "requests": [ - { - "response_format": "timeseries", - "queries": [ - { - "name": "query1", - "data_source": "logs", - "search": { - "query": "$env $service (severity:warn OR severity:error OR severity:fatal OR status:warn OR status:error)" - }, - "indexes": [ - "*" - ], - "compute": { - "aggregation": "count" - }, - "group_by": [ - { - "facet": "kind", - "limit": 10, - "sort": { - "aggregation": "count", - "order": "desc" + "type": "group", + "title": "Rate Limiting & Errors", + "layout_type": "ordered", + "show_title": true, + "widgets": [ + { + "definition": { + "type": "timeseries", + "title": "Rate Limit Rejections by Tier", + "show_legend": true, + "legend_layout": "auto", + "requests": [ + { + "response_format": "timeseries", + "queries": [ + { + "name": "query1", + "data_source": "metrics", + "query": "sum:d_inference.ratelimit.rejections{$env} by {tier}.as_count()" } + ], + "formulas": [ + { + "formula": "query1" + } + ], + "style": { + "palette": "warm" } - ] - } - ], - "formulas": [ - { - "formula": "query1" - } - ], - "display_type": "bars" + } + ] + } + }, + { + "definition": { + "type": "timeseries", + "title": "WebSocket Disconnects by Reason", + "show_legend": true, + "legend_layout": "auto", + "requests": [ + { + "response_format": "timeseries", + "queries": [ + { + "name": "query1", + "data_source": "metrics", + "query": "sum:d_inference.ws.disconnects{$env} by {reason}.as_count()" + } + ], + "formulas": [ + { + "formula": "query1" + } + ] + } + ] + } + }, + { + "definition": { + "type": "timeseries", + "title": "Telemetry Events Ingested by Source & Severity", + "show_legend": true, + "legend_layout": "auto", + "requests": [ + { + "response_format": "timeseries", + "queries": [ + { + "name": "query1", + "data_source": "metrics", + "query": "sum:d_inference.telemetry.events_ingested{$env} by {source,severity}.as_count()" + } + ], + "formulas": [ + { + "formula": "query1" + } + ], + "display_type": "bars" + } + ] + } } ] } }, { "definition": { - "type": "list_stream", - "title": "Provider Telemetry Events", - "requests": [ - { - "query": { - "data_source": "logs_stream", - "query_string": "$env $service source:provider" - }, - "response_format": "event_list", - "columns": [ - { - "field": "timestamp", - "width": "auto" - }, - { - "field": "status", - "width": "auto" - }, - { - "field": "message", - "width": "compact" - } - ] + "type": "group", + "title": "Logs", + "layout_type": "ordered", + "show_title": true, + "widgets": [ + { + "definition": { + "type": "timeseries", + "title": "Log Volume by Source", + "show_legend": true, + "legend_layout": "auto", + "requests": [ + { + "response_format": "timeseries", + "queries": [ + { + "name": "query1", + "data_source": "logs", + "search": { + "query": "$env $service" + }, + "indexes": ["*"], + "compute": { + "aggregation": "count" + }, + "group_by": [ + { + "facet": "source", + "limit": 10, + "sort": { + "aggregation": "count", + "order": "desc" + } + } + ] + } + ], + "formulas": [ + { + "formula": "query1" + } + ], + "display_type": "bars" + } + ] + } + }, + { + "definition": { + "type": "timeseries", + "title": "Warnings + Errors by Kind", + "show_legend": true, + "legend_layout": "auto", + "requests": [ + { + "response_format": "timeseries", + "queries": [ + { + "name": "query1", + "data_source": "logs", + "search": { + "query": "$env $service (severity:warn OR severity:error OR severity:fatal OR status:warn OR status:error)" + }, + "indexes": ["*"], + "compute": { + "aggregation": "count" + }, + "group_by": [ + { + "facet": "kind", + "limit": 10, + "sort": { + "aggregation": "count", + "order": "desc" + } + } + ] + } + ], + "formulas": [ + { + "formula": "query1" + } + ], + "display_type": "bars" + } + ] + } + }, + { + "definition": { + "type": "list_stream", + "title": "Provider Telemetry Events", + "requests": [ + { + "query": { + "data_source": "logs_stream", + "query_string": "$env $service source:provider" + }, + "response_format": "event_list", + "columns": [ + { + "field": "timestamp", + "width": "auto" + }, + { + "field": "status", + "width": "auto" + }, + { + "field": "message", + "width": "compact" + } + ] + } + ] + } + }, + { + "definition": { + "type": "list_stream", + "title": "Coordinator Warnings + Errors", + "requests": [ + { + "query": { + "data_source": "logs_stream", + "query_string": "$env $service source:coordinator (severity:warn OR severity:error OR severity:fatal OR status:warn OR status:error)" + }, + "response_format": "event_list", + "columns": [ + { + "field": "timestamp", + "width": "auto" + }, + { + "field": "status", + "width": "auto" + }, + { + "field": "message", + "width": "compact" + } + ] + } + ] + } + }, + { + "definition": { + "type": "list_stream", + "title": "All Recent Dev Logs", + "requests": [ + { + "query": { + "data_source": "logs_stream", + "query_string": "$env $service" + }, + "response_format": "event_list", + "columns": [ + { + "field": "timestamp", + "width": "auto" + }, + { + "field": "status", + "width": "auto" + }, + { + "field": "message", + "width": "compact" + } + ] + } + ] + } } ] } }, { "definition": { - "type": "list_stream", - "title": "Coordinator Warnings + Errors", - "requests": [ - { - "query": { - "data_source": "logs_stream", - "query_string": "$env $service source:coordinator (severity:warn OR severity:error OR severity:fatal OR status:warn OR status:error)" - }, - "response_format": "event_list", - "columns": [ - { - "field": "timestamp", - "width": "auto" - }, - { - "field": "status", - "width": "auto" - }, - { - "field": "message", - "width": "compact" - } - ] + "type": "group", + "title": "APM / Traces", + "layout_type": "ordered", + "show_title": true, + "widgets": [ + { + "definition": { + "type": "timeseries", + "title": "Trace Hits by Operation", + "show_legend": true, + "legend_layout": "auto", + "requests": [ + { + "response_format": "timeseries", + "queries": [ + { + "name": "query1", + "data_source": "metrics", + "query": "sum:trace.http.request.hits{$env} by {operation_name}.as_count()" + } + ], + "formulas": [ + { + "formula": "query1" + } + ], + "display_type": "bars" + } + ] + } + }, + { + "definition": { + "type": "timeseries", + "title": "Trace Latency (p50/p95/p99)", + "show_legend": true, + "legend_layout": "auto", + "requests": [ + { + "response_format": "timeseries", + "queries": [ + { + "name": "p50", + "data_source": "metrics", + "query": "avg:trace.http.request.latency.50percentile{$env}" + }, + { + "name": "p95", + "data_source": "metrics", + "query": "avg:trace.http.request.latency.95percentile{$env}" + }, + { + "name": "p99", + "data_source": "metrics", + "query": "avg:trace.http.request.latency.99percentile{$env}" + } + ], + "formulas": [ + { + "formula": "p50" + }, + { + "formula": "p95" + }, + { + "formula": "p99" + } + ], + "style": { + "palette": "warm" + } + } + ] + } + }, + { + "definition": { + "type": "query_value", + "title": "Trace Errors", + "autoscale": true, + "precision": 0, + "requests": [ + { + "response_format": "scalar", + "queries": [ + { + "name": "query1", + "data_source": "metrics", + "query": "sum:trace.http.request.errors{$env}.as_count()" + } + ], + "formulas": [ + { + "formula": "query1" + } + ] + } + ] + } + }, + { + "definition": { + "type": "toplist", + "title": "Slowest Operations (p99)", + "requests": [ + { + "response_format": "scalar", + "queries": [ + { + "name": "query1", + "data_source": "metrics", + "query": "avg:trace.http.request.latency.99percentile{$env} by {operation_name}" + } + ], + "formulas": [ + { + "formula": "query1" + } + ] + } + ] + } } ] } }, { "definition": { - "type": "list_stream", - "title": "Recent Coordinator Runtime Logs", - "requests": [ - { - "query": { - "data_source": "logs_stream", - "query_string": "$env $service source:coordinator kind:coordinator_log" - }, - "response_format": "event_list", - "columns": [ - { - "field": "timestamp", - "width": "auto" + "type": "group", + "title": "System Metrics (Host)", + "layout_type": "ordered", + "show_title": true, + "widgets": [ + { + "definition": { + "type": "timeseries", + "title": "CPU Usage", + "show_legend": true, + "legend_layout": "auto", + "yaxis": { + "label": "%", + "min": "0", + "max": "100" }, - { - "field": "status", - "width": "auto" + "requests": [ + { + "response_format": "timeseries", + "queries": [ + { + "name": "idle", + "data_source": "metrics", + "query": "avg:system.cpu.idle{host:d-inference-dev}" + } + ], + "formulas": [ + { + "formula": "100 - idle", + "limit": { + "count": 1, + "order": "desc" + } + } + ], + "style": { + "palette": "cool" + } + } + ] + } + }, + { + "definition": { + "type": "timeseries", + "title": "Memory Usage", + "show_legend": true, + "legend_layout": "auto", + "requests": [ + { + "response_format": "timeseries", + "queries": [ + { + "name": "used", + "data_source": "metrics", + "query": "avg:system.mem.used{host:d-inference-dev}" + }, + { + "name": "total", + "data_source": "metrics", + "query": "avg:system.mem.total{host:d-inference-dev}" + } + ], + "formulas": [ + { + "formula": "used" + }, + { + "formula": "total" + } + ] + } + ] + } + }, + { + "definition": { + "type": "timeseries", + "title": "Disk Usage", + "show_legend": true, + "legend_layout": "auto", + "yaxis": { + "label": "%", + "min": "0", + "max": "100" }, - { - "field": "message", - "width": "compact" - } - ] + "requests": [ + { + "response_format": "timeseries", + "queries": [ + { + "name": "query1", + "data_source": "metrics", + "query": "avg:system.disk.in_use{host:d-inference-dev} by {device}" + } + ], + "formulas": [ + { + "formula": "query1 * 100" + } + ] + } + ] + } + }, + { + "definition": { + "type": "timeseries", + "title": "Network I/O", + "show_legend": true, + "legend_layout": "auto", + "requests": [ + { + "response_format": "timeseries", + "queries": [ + { + "name": "rcvd", + "data_source": "metrics", + "query": "avg:system.net.bytes_rcvd{host:d-inference-dev} by {device}" + }, + { + "name": "sent", + "data_source": "metrics", + "query": "avg:system.net.bytes_sent{host:d-inference-dev} by {device}" + } + ], + "formulas": [ + { + "formula": "rcvd" + }, + { + "formula": "sent" + } + ] + } + ] + } + }, + { + "definition": { + "type": "query_value", + "title": "Load Average (1m)", + "autoscale": true, + "precision": 2, + "requests": [ + { + "response_format": "scalar", + "queries": [ + { + "name": "query1", + "data_source": "metrics", + "query": "avg:system.load.1{host:d-inference-dev}" + } + ], + "formulas": [ + { + "formula": "query1" + } + ] + } + ] + } } ] } }, { "definition": { - "type": "list_stream", - "title": "All Recent Dev Logs", - "requests": [ - { - "query": { - "data_source": "logs_stream", - "query_string": "$env $service" - }, - "response_format": "event_list", - "columns": [ - { - "field": "timestamp", - "width": "auto" - }, - { - "field": "status", - "width": "auto" - }, - { - "field": "message", - "width": "compact" - } - ] + "type": "group", + "title": "Fleet Version & Binary Hash", + "layout_type": "ordered", + "show_title": true, + "widgets": [ + { + "definition": { + "type": "timeseries", + "title": "Providers by Version", + "show_legend": true, + "legend_layout": "auto", + "requests": [ + { + "response_format": "timeseries", + "queries": [ + { + "name": "query1", + "data_source": "metrics", + "query": "sum:d_inference.providers.per_version{$env} by {version}" + } + ], + "formulas": [ + { + "formula": "query1" + } + ] + } + ] + } + }, + { + "definition": { + "type": "timeseries", + "title": "Providers by Binary Hash", + "show_legend": true, + "legend_layout": "auto", + "requests": [ + { + "response_format": "timeseries", + "queries": [ + { + "name": "query1", + "data_source": "metrics", + "query": "sum:d_inference.providers.per_binary_hash{$env} by {binary_hash}" + } + ], + "formulas": [ + { + "formula": "query1" + } + ] + } + ] + } + }, + { + "definition": { + "type": "query_value", + "title": "Min Provider Version", + "autoscale": false, + "precision": 0, + "requests": [ + { + "response_format": "scalar", + "queries": [ + { + "name": "query1", + "data_source": "metrics", + "query": "sum:d_inference.coordinator.min_provider_version_set{$env} by {min_version}" + } + ], + "formulas": [ + { + "formula": "query1" + } + ] + } + ] + } + }, + { + "definition": { + "type": "timeseries", + "title": "Providers Below Minimum Version", + "show_legend": true, + "legend_layout": "auto", + "requests": [ + { + "response_format": "timeseries", + "queries": [ + { + "name": "query1", + "data_source": "metrics", + "query": "sum:d_inference.provider_version_below_minimum{$env} by {gate,version}.as_count()" + } + ], + "formulas": [ + { + "formula": "query1" + } + ], + "display_type": "bars", + "style": { + "palette": "warm" + } + } + ] + } + }, + { + "definition": { + "type": "toplist", + "title": "Top Binary Hashes", + "requests": [ + { + "response_format": "scalar", + "queries": [ + { + "name": "query1", + "data_source": "metrics", + "query": "sum:d_inference.providers.per_binary_hash{$env} by {binary_hash}" + } + ], + "formulas": [ + { + "formula": "query1" + } + ] + } + ] + } } ] } - }, - { - "definition": { - "type": "note", - "content": "APM and DogStatsD panels are intentionally omitted here until the dev VM runs a Datadog Agent on 8126/8125. The coordinator currently forwards logs directly via the Datadog Logs API, which is why this dashboard uses log analytics instead of `d_inference.*` metrics.", - "background_color": "gray", - "font_size": "12", - "text_align": "left", - "show_tick": false - } } ] } diff --git a/deploy/gcp/refresh-env.sh b/deploy/gcp/refresh-env.sh index 176aa825..54e959c3 100644 --- a/deploy/gcp/refresh-env.sh +++ b/deploy/gcp/refresh-env.sh @@ -57,6 +57,7 @@ DD_API_KEY=$(fetch eigeninference-dd-api-key) DD_SITE=$(fetch eigeninference-dd-site) DD_ENV=development DD_SERVICE=d-inference-coordinator +DD_AGENT_HOST=localhost EOF # Validate critical secrets before overwriting the live env file. diff --git a/deploy/gcp/vm-startup.sh b/deploy/gcp/vm-startup.sh index f54d3686..033de585 100755 --- a/deploy/gcp/vm-startup.sh +++ b/deploy/gcp/vm-startup.sh @@ -9,6 +9,7 @@ # 3. Install a systemd unit for cloud-sql-proxy (Cloud SQL on 127.0.0.1:5432) # 4. Install a systemd unit for the coordinator container # 5. Fetch secrets from Secret Manager, write /etc/d-inference/env +# 6. Install Datadog Agent (metrics + traces + journald log collection) # # On subsequent boots: # - Re-fetch secrets (picks up rotations) @@ -138,6 +139,7 @@ DD_API_KEY=$(fetch eigeninference-dd-api-key) DD_SITE=$(fetch eigeninference-dd-site) DD_ENV=development DD_SERVICE=d-inference-coordinator +DD_AGENT_HOST=localhost EOF chmod 600 "$ENV_FILE" @@ -190,8 +192,9 @@ chmod +x /usr/local/bin/d-inference-run.sh cat > /etc/systemd/system/d-inference-coordinator.service </dev/null 2>&1 && ! dpkg -l datadog-agent >/dev/null 2>&1; then + DD_API_KEY="$DD_API_KEY_VAL" \ + DD_SITE="${DD_SITE_VAL:-datadoghq.com}" \ + bash -c "$(curl -fsSL https://s3.amazonaws.com/dd-agent/scripts/install_script_agent7.sh)" + fi + + # Ensure the agent is configured for this environment. + mkdir -p /etc/datadog-agent + cat > /etc/datadog-agent/datadog.yaml < /etc/datadog-agent/conf.d/journald.d/conf.yaml < MicroMDM (127.0.0.1:9002, HTTPS self-signed) # /acme/* -> step-ca (127.0.0.1:9000, HTTPS self-signed) @@ -261,8 +310,9 @@ api.dev.darkbloom.xyz { CADDYFILE systemctl daemon-reload -systemctl enable cloud-sql-proxy.service d-inference-coordinator.service caddy.service +systemctl enable cloud-sql-proxy.service datadog-agent.service d-inference-coordinator.service caddy.service systemctl restart cloud-sql-proxy.service +systemctl restart datadog-agent.service systemctl restart d-inference-coordinator.service systemctl restart caddy.service diff --git a/landing/index.html b/landing/index.html index c411fdbe..a7548815 100644 --- a/landing/index.html +++ b/landing/index.html @@ -55,6 +55,116 @@ } + + +