diff --git a/.github/workflows/dashboard-checks.yml b/.github/workflows/dashboard-checks.yml index dad9a0e3b..a1631ac72 100644 --- a/.github/workflows/dashboard-checks.yml +++ b/.github/workflows/dashboard-checks.yml @@ -25,6 +25,9 @@ jobs: with: persist-credentials: false + - name: Isolate the future root from legacy checks + run: mv greenfield "${RUNNER_TEMP}/mira-dashboard-greenfield" + - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: @@ -78,6 +81,9 @@ jobs: with: persist-credentials: false + - name: Isolate the future root from legacy checks + run: mv greenfield "${RUNNER_TEMP}/mira-dashboard-greenfield" + - name: Set up Bun uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: @@ -159,6 +165,14 @@ jobs: working-directory: ${{ runner.temp }}/mira-dashboard-greenfield run: bun run format:check + - name: Build isolated browser bundle + working-directory: ${{ runner.temp }}/mira-dashboard-greenfield + run: bun run build:browser + + - name: Build isolated process bundles + working-directory: ${{ runner.temp }}/mira-dashboard-greenfield + run: bun run build:processes + - name: Test isolated future root with coverage working-directory: ${{ runner.temp }}/mira-dashboard-greenfield run: bun run test:coverage @@ -166,12 +180,17 @@ jobs: - name: Prefix isolated LCOV paths for the coexistence checkout run: | lcov_path="${RUNNER_TEMP}/mira-dashboard-greenfield/coverage/lcov.info" - perl -0pi -e 's{^SF:(?:\./)?src/}{SF:greenfield/src/}mg' "${lcov_path}" - if grep '^SF:' "${lcov_path}" | grep --quiet --invert-match '^SF:greenfield/src/'; then - echo "LCOV contains a source path outside greenfield/src" >&2 + perl -0pi -e 's{^SF:(?:\./)?(scripts|src)/}{SF:greenfield/$1/}mg' "${lcov_path}" + perl -0pi -e 's{^SF:(?:\./)?drizzle\.config\.ts$}{SF:greenfield/drizzle.config.ts}mg' "${lcov_path}" + perl -0pi -e 's{^SF:(?:\./)?tailwind\.config\.ts$}{SF:greenfield/tailwind.config.ts}mg' "${lcov_path}" + if grep '^SF:' "${lcov_path}" | grep --quiet --invert-match --extended-regexp '^SF:greenfield/((scripts|src)/|(drizzle|tailwind)\.config\.ts$)'; then + echo "LCOV contains a source path outside the exact greenfield production inventory" >&2 exit 1 fi + grep --quiet '^SF:greenfield/scripts/' "${lcov_path}" grep --quiet '^SF:greenfield/src/' "${lcov_path}" + grep --quiet '^SF:greenfield/drizzle\.config\.ts$' "${lcov_path}" + grep --quiet '^SF:greenfield/tailwind\.config\.ts$' "${lcov_path}" - name: Upload isolated coverage artifact uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/.oxfmtrc.json b/.oxfmtrc.json index b799431b1..91e730581 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -5,12 +5,9 @@ "**/coverage/**", "**/data/**", "**/dist/**", - "docs/generated/**", - "migrations/**", "**/node_modules/**", "**/*.min.css", - "**/*.min.js", - "greenfield/**" + "**/*.min.js" ], "printWidth": 90, "semi": true, diff --git a/.oxlintrc.json b/.oxlintrc.json index 7ff230b45..19b21568e 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -31,8 +31,7 @@ "**/*.tsbuildinfo", ".git/**", ".vscode/**", - "build/**", - "greenfield/**" + "build/**" ], "options": { "denyWarnings": true, diff --git a/README.md b/README.md index 875195452..89db78729 100644 --- a/README.md +++ b/README.md @@ -43,22 +43,16 @@ dist/ Bun production frontend build output ## Local development -The repository selects Bun Canary through `.bun-version`. Install or update that channel before -installing dependencies: - -```bash -bun upgrade --canary -``` - -Then install all frontend, backend, and tooling dependencies from the repo root: +Install all frontend, backend, and tooling dependencies from the repo root: ```bash bun install --frozen-lockfile ``` -The application targets the Bun 1.4 runtime API. Immutable release manifests still record the -exact Bun revision used for each build. The committed Bun config keeps the runtime-only -`bun-plugin-tailwind` peer from installing a second, stale Bun executable into `node_modules`. +The repository tracks Bun's moving Canary channel in `.bun-version`. Install or +upgrade that runtime before working in the repository; the committed Bun config +keeps the runtime-only `bun-plugin-tailwind` peer from installing a second, +stale Bun executable into `node_modules`. Run the complete local dev stack: diff --git a/bun.lock b/bun.lock index 7a86bf2c1..693494094 100644 --- a/bun.lock +++ b/bun.lock @@ -10,13 +10,13 @@ "@dnd-kit/react": "^0.5.0", "@dnd-kit/sortable": "^10.0.0", "@headlessui/react": "^2.2.10", - "@microlink/react-json-view": "^1.31.26", + "@microlink/react-json-view": "^1.31.25", "@simplewebauthn/browser": "13.3.0", "@simplewebauthn/server": "13.3.2", "@tailwindcss/typography": "^0.5.20", "@tanstack/query-core": "5.101.4", - "@tanstack/query-db-collection": "1.2.1", - "@tanstack/react-db": "0.1.95", + "@tanstack/query-db-collection": "^1.2.1", + "@tanstack/react-db": "^0.1.95", "@tanstack/react-form": "^1.33.3", "@tanstack/react-query": "^5.101.4", "@tanstack/react-router": "^1.170.18", @@ -60,11 +60,11 @@ "@types/react-syntax-highlighter": "^15.5.13", "babel-plugin-react-compiler": "^1.0.0", "bun-plugin-tailwind": "^0.1.2", - "bun-types": "1.4.0-canary.20260519T150915", + "bun-types": "canary", "happy-dom": "^20.11.1", "oxfmt": "^0.62.0", "oxlint": "^1.77.0", - "oxlint-config-presets": "^0.1.18", + "oxlint-config-presets": "^0.1.17", "oxlint-tailwindcss": "^1.6.0", "oxlint-tsgolint": "^7.0.2001", "tailwindcss": "^4.3.3", @@ -157,7 +157,7 @@ "@levischuck/tiny-cbor": ["@levischuck/tiny-cbor@0.2.11", "", {}, "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow=="], - "@microlink/react-json-view": ["@microlink/react-json-view@1.31.26", "", { "dependencies": { "react-base16-styling": "~0.10.0", "react-lifecycles-compat": "~3.0.4", "react-textarea-autosize": "~8.5.9" }, "peerDependencies": { "react": ">= 15", "react-dom": ">= 15" } }, "sha512-2sYpys438sineOJDkqk70F0rFRnZ0hpNgv0k8sEpUg3qafP5j7HrZWApj8DGi5rG7dlSRKdzgzRk4LNCsxk2MQ=="], + "@microlink/react-json-view": ["@microlink/react-json-view@1.31.25", "", { "dependencies": { "react-base16-styling": "~0.10.0", "react-lifecycles-compat": "~3.0.4", "react-textarea-autosize": "~8.5.9" }, "peerDependencies": { "react": ">= 15", "react-dom": ">= 15" } }, "sha512-eVRAzd6GpIWhUwZkk1BHVtyS3Sgcw9eVJYvV/RGxahmV10TD3Z50cZNg4EQOzlEWDw+FeitTtr/Aj1yp7fRYXA=="], "@noble/hashes": ["@noble/hashes@2.2.0", "", {}, "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg=="], @@ -817,7 +817,7 @@ "oxlint": ["oxlint@1.77.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.77.0", "@oxlint/binding-android-arm64": "1.77.0", "@oxlint/binding-darwin-arm64": "1.77.0", "@oxlint/binding-darwin-x64": "1.77.0", "@oxlint/binding-freebsd-x64": "1.77.0", "@oxlint/binding-linux-arm-gnueabihf": "1.77.0", "@oxlint/binding-linux-arm-musleabihf": "1.77.0", "@oxlint/binding-linux-arm64-gnu": "1.77.0", "@oxlint/binding-linux-arm64-musl": "1.77.0", "@oxlint/binding-linux-ppc64-gnu": "1.77.0", "@oxlint/binding-linux-riscv64-gnu": "1.77.0", "@oxlint/binding-linux-riscv64-musl": "1.77.0", "@oxlint/binding-linux-s390x-gnu": "1.77.0", "@oxlint/binding-linux-x64-gnu": "1.77.0", "@oxlint/binding-linux-x64-musl": "1.77.0", "@oxlint/binding-openharmony-arm64": "1.77.0", "@oxlint/binding-win32-arm64-msvc": "1.77.0", "@oxlint/binding-win32-ia32-msvc": "1.77.0", "@oxlint/binding-win32-x64-msvc": "1.77.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2001", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-qnGh8XJHaQ0dprrDXNQZgS0FgjI6v+V3+X8DwmaV++5Aamy6jGKfDdQ1TUvhUxtmKFAbEf4/WeO5QZX+5WSngg=="], - "oxlint-config-presets": ["oxlint-config-presets@0.1.18", "", { "peerDependencies": { "oxlint": ">=0.15.0" }, "optionalPeers": ["oxlint"] }, "sha512-f0Iie2XIjvAopd5kv5xuVUPspCpWy3pJVLeQ1yWfFpBl1QAkiQEcPCOOqpkUKm+k6zqWJL+PyNCI+TimnBUFvw=="], + "oxlint-config-presets": ["oxlint-config-presets@0.1.17", "", { "peerDependencies": { "oxlint": ">=0.15.0" }, "optionalPeers": ["oxlint"] }, "sha512-LMLU+yweNquiQ74koTOn0rP/9FXjkgK9yp6dkFpwTM9lau6oVks2WupOjjrQUB4PGu92UoHu/R7MH9a0mY0Flw=="], "oxlint-tailwindcss": ["oxlint-tailwindcss@1.6.0", "", { "dependencies": { "@tailwindcss/node": "^4.3.3", "tailwindcss": "^4.3.3" } }, "sha512-Y1ZWJLI8Szu4Bl2OuZWH1lUv9VfDzD14CqlZBCU3Qxiz5AsthRqXWaYTshUPBFF1WXazsFfRBkWHRO+tIezfQA=="], diff --git a/bunfig.toml b/bunfig.toml index 822bc082e..4de6cdc6e 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -14,5 +14,5 @@ coveragePathIgnorePatterns = [ ] [serve.static] -plugins = ["./scripts/reactCompilerPlugin.ts", "bun-plugin-tailwind"] +plugins = ["bun-plugin-tailwind", "./scripts/reactCompilerPlugin.ts"] environment = "PUBLIC_*" diff --git a/codecov.yml b/codecov.yml index e6eb12f54..e1407bd6e 100644 --- a/codecov.yml +++ b/codecov.yml @@ -13,7 +13,10 @@ coverage: flags: - dashboard paths: + - greenfield/scripts/ - greenfield/src/ + - greenfield/drizzle.config.ts + - greenfield/tailwind.config.ts backend: target: 85% threshold: 0% @@ -42,7 +45,10 @@ coverage: flags: - dashboard paths: + - greenfield/scripts/ - greenfield/src/ + - greenfield/drizzle.config.ts + - greenfield/tailwind.config.ts backend: target: 85% threshold: 0% @@ -70,7 +76,10 @@ comment: flags: dashboard: paths: + - greenfield/scripts/ - greenfield/src/ + - greenfield/drizzle.config.ts + - greenfield/tailwind.config.ts frontend: paths: - frontend/src/ diff --git a/docs/api/endpoints.md b/docs/api/endpoints.md index dd29cc583..16b3c1b38 100644 --- a/docs/api/endpoints.md +++ b/docs/api/endpoints.md @@ -1,10 +1,5 @@ # Endpoint Reference -> **Current-production parity input — not the greenfield API contract.** This route inventory -> describes the legacy backend and exists so the rewrite does not lose operator-visible behavior. -> Greenfield contracts are defined by their explicit tRPC/raw-HTTP registries and current generated -> references; legacy route names and payloads are not compatibility requirements. - This page lists the backend route table. It is intentionally concise: use the route files under `backend/src/routes/` for exact validation details. @@ -13,9 +8,7 @@ route files under `backend/src/routes/` for exact validation details. | Method | Path | Purpose | | ------ | ------------------------- | ------------------------------------------------------ | | `GET` | `/api/health/live` | Public web-process liveness. | -| `HEAD` | `/api/health/live` | Bodyless public web-process liveness probe. | | `GET` | `/api/health/ready` | Public activation readiness; `503` when not ready. | -| `HEAD` | `/api/health/ready` | Bodyless readiness probe with the same status as GET. | | `GET` | `/api/health/diagnostics` | Authenticated readiness details and dependency status. | | `GET` | `/api/sessions` | Normalized session snapshot from Gateway. | @@ -202,13 +195,6 @@ already-running execution finishes cooperatively. | `POST` | `/api/backup` | Creates config backup. | | `POST` | `/api/restart` | Queues an OpenClaw Gateway restart and waits for its persisted result. | -## Dashboard Settings - -| Method | Path | Purpose | -| ------ | --------------- | ------------------------------------------------------------- | -| `GET` | `/api/settings` | Reads Dashboard preferences plus current Gateway connection. | -| `PUT` | `/api/settings` | Updates the validated Dashboard preference subset atomically. | - ## Files, Config Files, Logs, Media | Method | Path | Purpose | @@ -219,9 +205,8 @@ already-running execution finishes cooperatively. | `GET` | `/api/config-files` | Lists OpenClaw config files. | | `GET` | `/api/config-files/*` | Reads a config file under OpenClaw root. | | `PUT` | `/api/config-files/*` | Writes a config file under OpenClaw root. | -| `GET` | `/api/logs/dashboard` | Reads the bounded Dashboard service log tail. | -| `GET` | `/api/logs/openclaw/files` | Lists readable OpenClaw log files and metadata. | -| `GET` | `/api/logs/openclaw/content` | Reads a bounded tail from one allowlisted OpenClaw log file. | +| `GET` | `/api/logs/info` | Lists log files/metadata. | +| `GET` | `/api/logs/content` | Reads log content. | | `GET` | `/api/media` | Serves or safely previews media bytes from OpenClaw media roots. | | `GET` | `/api/chat/media/outgoing/*` | Proxies an exact managed Gateway media path with backend-held auth. | @@ -242,33 +227,31 @@ upstream download metadata. ## Docker -| Method | Path | Purpose | -| -------- | ------------------------------------------------ | ------------------------------------------- | -| `GET` | `/api/docker/containers` | Lists containers. | -| `GET` | `/api/docker/containers/stats` | Reads the current container stats snapshot. | -| `GET` | `/api/docker/containers/:containerId` | Reads container details. | -| `POST` | `/api/docker/containers/:containerId/action` | Queues a container start/stop/restart. | -| `GET` | `/api/docker/containers/:containerId/logs` | Reads container logs. | -| `POST` | `/api/docker/exec/start` | Queues a worker-owned container exec job. | -| `GET` | `/api/docker/exec/:jobId` | Reads persisted exec output/state. | -| `POST` | `/api/docker/exec/:jobId/stop` | Requests exec cancellation. | -| `GET` | `/api/docker/images` | Lists images. | -| `DELETE` | `/api/docker/images/:imageId` | Queues image deletion. | -| `GET` | `/api/docker/volumes` | Lists volumes. | -| `DELETE` | `/api/docker/volumes/:volumeName` | Queues volume deletion. | -| `POST` | `/api/docker/prune` | Queues a Docker prune target. | -| `POST` | `/api/docker/stack/action` | Queues a Compose stack action. | -| `GET` | `/api/docker/updater/services` | Lists managed update services. | -| `GET` | `/api/docker/updater/events` | Lists update events. | -| `POST` | `/api/docker/updater/run` | Queues an updater scan. | -| `POST` | `/api/docker/updater/services/:serviceId/update` | Queues one managed service update. | +| Method | Path | Purpose | +| -------- | ------------------------------------------------ | ----------------------------------------- | +| `GET` | `/api/docker/containers` | Lists containers. | +| `GET` | `/api/docker/containers/:containerId` | Reads container details. | +| `POST` | `/api/docker/containers/:containerId/action` | Queues a container start/stop/restart. | +| `GET` | `/api/docker/containers/:containerId/logs` | Reads container logs. | +| `POST` | `/api/docker/exec/start` | Queues a worker-owned container exec job. | +| `GET` | `/api/docker/exec/:jobId` | Reads persisted exec output/state. | +| `POST` | `/api/docker/exec/:jobId/stop` | Requests exec cancellation. | +| `GET` | `/api/docker/images` | Lists images. | +| `DELETE` | `/api/docker/images/:imageId` | Queues image deletion. | +| `GET` | `/api/docker/volumes` | Lists volumes. | +| `DELETE` | `/api/docker/volumes/:volumeName` | Queues volume deletion. | +| `POST` | `/api/docker/prune` | Queues a Docker prune target. | +| `POST` | `/api/docker/stack/action` | Queues a Compose stack action. | +| `GET` | `/api/docker/updater/services` | Lists managed update services. | +| `GET` | `/api/docker/updater/events` | Lists update events. | +| `POST` | `/api/docker/updater/run` | Queues an updater scan. | +| `POST` | `/api/docker/updater/services/:serviceId/update` | Queues one managed service update. | ## Pull Requests And Deployments | Method | Path | Purpose | | ------ | -------------------------------------------- | ---------------------------------------------------------- | | `GET` | `/api/pull-requests` | Lists Dashboard PRs. | -| `POST` | `/api/pull-requests/stacks` | Creates one reviewed native GitHub PR stack. | | `POST` | `/api/pull-requests/:number/approve` | Queues merge, optionally followed by deploy. | | `POST` | `/api/pull-requests/:number/reject` | Queues reject/close. | | `POST` | `/api/pull-requests/:number/review-approval` | Queues review approval. | diff --git a/docs/architecture/gateway-and-chat.md b/docs/architecture/gateway-and-chat.md index 8aace5225..955271496 100644 --- a/docs/architecture/gateway-and-chat.md +++ b/docs/architecture/gateway-and-chat.md @@ -1,12 +1,5 @@ # Gateway And Chat Runtime -> **Current-production parity input — not the greenfield protocol specification.** This document -> describes the legacy Dashboard implementation that must be accounted for during parity work. -> Before any greenfield OpenClaw integration, inspect the source and protocol of the OpenClaw -> version currently installed; that installed behavior is authoritative and this implementation -> may be replaced. The Phase 2 greenfield slice qualifies only a one-shot bootstrap credential -> verifier, not the persistent Gateway/chat runtime described below. - Dashboard has two WebSocket layers: ```text diff --git a/docs/development/local-dev.md b/docs/development/local-dev.md index 498916c70..1cbb45e1a 100644 --- a/docs/development/local-dev.md +++ b/docs/development/local-dev.md @@ -6,22 +6,17 @@ and chat all run through the same application paths. ## Install -`.bun-version` selects Bun Canary. Install or update that channel before dependencies: - -```bash -bun upgrade --canary -``` - -Then install from the repository or PR worktree: +From the repository or PR worktree: ```bash bun install --frozen-lockfile ``` -Dashboard targets Bun 1.4 and accepts newer Canary revisions within that runtime version. The -root Bun config deliberately does not auto-install peer dependencies: required library peers are -direct dependencies, while the `bun-plugin-tailwind` Bun peer is supplied by the selected -runtime. This prevents a stable npm Bun executable from shadowing Canary in repository scripts. +`.bun-version` selects Bun Canary. The root Bun config deliberately does not +auto-install peer dependencies: required library peers are direct dependencies, +while the `bun-plugin-tailwind` Bun peer is supplied by the selected runtime. +This prevents a stable npm Bun executable from shadowing Canary in repository +scripts. When Bun 1.4 is stable, re-test installs with peer auto-installation enabled. Remove `[install] peer = false` once `bun-plugin-tailwind` no longer installs a diff --git a/docs/setup/new-vps.md b/docs/setup/new-vps.md index 6144b72ec..938d35d46 100644 --- a/docs/setup/new-vps.md +++ b/docs/setup/new-vps.md @@ -38,16 +38,13 @@ git clone https://github.com/rajohan/Mira-Dashboard.git \ cd /home/ubuntu/projects/mira-dashboard/production/checkout ``` -Select the repository runtime channel before bootstrapping a release: +Select the repository runtime channel: ```bash bun upgrade --canary bun --revision ``` -The repository targets Bun 1.4 while `.bun-version` remains on Canary. Each immutable Dashboard -release records and reuses the exact Bun revision that built it. - ## Configure Secrets Dashboard reads production secrets through Doppler project/config diff --git a/greenfield/.bun-browser-test-timings.json b/greenfield/.bun-browser-test-timings.json index 9487a89dc..718a94e20 100644 --- a/greenfield/.bun-browser-test-timings.json +++ b/greenfield/.bun-browser-test-timings.json @@ -1,166 +1,171 @@ { "files": { - "src/browser/ui/Select.test.tsx": 27813, - "src/browser/chat/ChatBrowser.test.tsx": 16087, - "src/browser/security/AccountSecurityRoute.test.tsx": 14781, - "src/browser/settings/SettingsRoute.test.tsx": 14096, - "src/browser/jobs/JobsRoute.test.tsx": 12664, - "src/browser/jobs/JobRunBrowser.test.tsx": 7372, - "src/browser/ui/JsonViewer.test.tsx": 4824, - "src/browser/tasks/TaskBoardRoute.test.tsx": 4567, - "src/browser/jobs/ScheduleDetailForm.test.tsx": 4237, - "src/browser/notifications/NotificationCenter.test.tsx": 4179, - "src/browser/chat/ChatWorkspace.test.tsx": 4016, - "src/browser/monitoring/MonitoringRoutes.test.tsx": 3763, - "src/browser/logs/LogsView.test.tsx": 3746, - "src/browser/openClawCron/OpenClawCronSection.test.tsx": 3666, - "src/browser/openClawCron/OpenClawCronBrowser.test.tsx": 3573, - "src/browser/auth/LoginRoute.test.tsx": 3474, - "src/browser/overview/OverviewRoute.test.tsx": 3463, - "src/browser/logs/LogsBrowser.test.tsx": 3314, - "src/browser/files/WorkspaceFilesBrowser.test.tsx": 3294, - "src/browser/ui/Virtualizer.test.tsx": 2943, - "src/browser/files/WorkspaceFilesView.test.tsx": 2789, - "src/browser/overview/OverviewAgentsSection.test.tsx": 2432, - "src/browser/agents/AgentsRoute.test.tsx": 2282, - "src/browser/sessions/GatewaySessionsView.test.tsx": 1997, - "src/browser/auth/AuthenticatedSessionActivity.test.tsx": 1845, - "src/browser/sessions/GatewaySessionsBrowser.test.tsx": 1837, - "src/browser/terminal/TerminalBrowser.test.tsx": 1829, - "src/browser/chat/ChatAttachmentPicker.test.tsx": 1596, - "src/browser/chat/ChatComposer.test.tsx": 1594, - "src/browser/files/WorkspaceFilesRoute.test.tsx": 1469, - "src/browser/jobs/ScheduleDetailStateDisable.test.tsx": 1417, - "src/browser/ui/TimePicker.test.tsx": 1404, - "src/browser/application.test.tsx": 1392, - "src/browser/files/WorkspaceFileEditorPane.test.tsx": 1361, - "src/browser/ui/DatePicker.test.tsx": 1340, - "src/browser/jobs/ScheduleDetailStateVersion.test.tsx": 1332, - "src/browser/chat/ChatLiveProjection.test.tsx": 1323, - "src/browser/ui/DateTimePicker.test.tsx": 1323, - "src/browser/ui/SourceViewer.test.tsx": 1287, - "src/browser/auth/AuthenticationBoundary.test.tsx": 1267, - "src/browser/jobs/ScheduleDetailStateReplay.test.tsx": 1247, - "src/browser/security/issuedAutomationToken.test.ts": 1203, - "src/browser/ui/CopyTextButton.test.tsx": 1190, - "src/browser/auth/useAuthenticatedMutationBoundary.test.tsx": 1170, - "src/browser/auth/AuthenticatedBrowserCacheBoundary.test.tsx": 1169, - "src/browser/jobs/jobMutations.test.tsx": 1151, - "src/browser/tasks/taskQueries.test.ts": 1151, - "src/browser/terminal/terminalProtocol.test.ts": 1112, - "src/browser/ui/ExpandableCard.test.tsx": 1065, - "src/browser/jobs/ScheduleEditor.test.tsx": 1014, - "src/browser/moltbook/MoltbookRoute.test.tsx": 1011, - "src/browser/chat/ChatMessageBubble.test.tsx": 1004, - "src/browser/overview/OverviewNotificationsSection.test.tsx": 998, - "src/browser/overview/OverviewReportsCard.test.tsx": 985, - "src/browser/overview/OverviewJobsSection.test.tsx": 975, - "src/browser/overview/OverviewTasksSection.test.tsx": 974, - "src/browser/security/AutomationPrincipalCard.test.tsx": 961, - "src/browser/ui/Combobox.test.tsx": 956, - "src/browser/openClawCron/useOpenClawCronRealtimeInvalidation.test.tsx": 948, - "src/browser/cache/cacheMutations.test.tsx": 911, - "src/browser/overview/OverviewReportsSection.test.tsx": 880, - "src/browser/jobs/ScheduleDetailStateErrors.test.tsx": 849, - "src/browser/ui/Popover.test.tsx": 833, - "src/browser/jobs/ScheduleTable.test.tsx": 823, - "src/browser/overview/OverviewIncidentsSection.test.tsx": 823, - "src/browser/overview/OverviewNotificationsCard.test.tsx": 814, - "src/browser/jobs/JobRunDetail.test.tsx": 808, - "src/browser/api/useObservedQueryState.test.tsx": 803, - "src/browser/chat/useChatRuntimeProjection.test.tsx": 798, - "src/browser/chat/ChatTranscript.test.tsx": 785, - "src/browser/ui/Button.test.tsx": 782, - "src/browser/files/WorkspaceFileTable.test.tsx": 763, - "src/browser/chat/useChatSpeech.test.tsx": 759, - "src/browser/ui/Switch.test.tsx": 729, - "src/browser/logs/LogsRoute.test.tsx": 725, - "src/browser/monitoring/IncidentTable.test.tsx": 724, - "src/browser/tasks/useTaskRealtimeInvalidation.test.tsx": 724, - "src/browser/terminal/TerminalWorkspace.test.tsx": 712, - "src/browser/chat/ChatBrowserState.test.ts": 708, - "src/browser/chat/chatQueries.test.ts": 704, - "src/browser/chat/chatPostMutationObservations.test.ts": 682, - "src/browser/ui/MetricCard.test.tsx": 675, - "src/browser/jobs/ScheduleDetailStateCopy.test.tsx": 673, - "src/browser/ui/Alert.test.tsx": 665, - "src/browser/cache/CacheStatusTable.test.tsx": 616, - "src/browser/sessions/GatewaySessionsRoute.test.tsx": 613, - "src/browser/routes/logs.lazy.test.tsx": 605, - "src/browser/main.test.tsx": 602, - "src/browser/files/workspaceFileTransfers.test.ts": 593, - "src/browser/tasks/taskEditorForm.test.ts": 592, - "src/browser/jobs/JobQueuePanel.test.tsx": 576, - "src/browser/sessions/gatewaySessionQueries.test.ts": 572, - "src/browser/jobs/JobRunTable.test.tsx": 569, - "src/browser/overview/OverviewIncidentsCard.test.tsx": 561, - "src/browser/sessions/useGatewaySessionRealtimeInvalidation.test.tsx": 561, - "src/browser/chat/ChatRuntimeStoreProvider.test.tsx": 555, - "src/browser/notifications/useNotificationRealtimeInvalidation.test.tsx": 554, - "src/browser/jobs/useJobRealtimeInvalidation.test.tsx": 545, - "src/browser/overview/OverviewTasksCard.test.tsx": 545, - "src/browser/ui/DropdownMenu.test.tsx": 539, - "src/browser/overview/OverviewJobsCard.test.tsx": 530, - "src/browser/cache/cacheQueries.test.ts": 526, - "src/browser/logs/logQueries.test.ts": 525, - "src/browser/terminal/TerminalRoute.test.tsx": 521, - "src/browser/overview/OverviewAgentsCard.test.tsx": 519, - "src/browser/chat/ChatAttachmentPreview.test.tsx": 515, - "src/browser/data/dashboardCollections.test.ts": 507, - "src/browser/ui/LoadingState.test.tsx": 503, - "src/browser/chat/chatAttachments.test.ts": 499, - "src/browser/monitoring/useMonitoringRealtimeInvalidation.test.tsx": 496, - "src/browser/ui/Tabs.test.tsx": 495, - "src/browser/notifications/notificationQueries.test.ts": 484, - "src/browser/ui/DataTable.test.tsx": 482, - "src/browser/logs/LogLevelFilter.test.tsx": 480, - "src/browser/router.test.ts": 473, - "src/browser/api/realtimeHub.test.ts": 472, - "src/browser/auth/authQueries.test.ts": 472, - "src/browser/overview/systemMetricsQueries.test.ts": 466, - "src/browser/ui/RadioGroup.test.tsx": 465, - "src/browser/overview/SystemMetricsCards.test.tsx": 464, - "src/browser/files/workspaceFileQueries.test.ts": 452, - "src/browser/chat/chatViewProjection.test.ts": 445, - "src/browser/ui/Fieldset.test.tsx": 432, - "src/browser/security/webauthn/webauthnClient.test.ts": 429, - "src/browser/chat/useChatRealtimeInvalidation.test.tsx": 427, - "src/browser/settings/settingsRouteSearch.test.ts": 426, - "src/browser/api/realtimeClient.test.ts": 424, - "src/browser/chat/ChatRoute.test.tsx": 420, - "src/browser/chat/chatMutations.test.ts": 414, - "src/browser/logs/logLinePresentation.test.ts": 412, - "src/browser/chat/chatRuntimeStore.test.ts": 410, - "src/browser/api/trpcClient.test.ts": 408, - "src/browser/ui/ExternalLink.test.tsx": 404, - "src/browser/jobs/jobQueries.test.ts": 402, - "src/browser/chat/chatRouteSearch.test.ts": 395, - "src/browser/notifications/notificationCollection.test.ts": 389, - "src/browser/chat/chatSessionPicker.test.ts": 386, - "src/browser/cache/SystemHostCard.test.tsx": 383, - "src/browser/jobs/scheduleEditorForm.test.ts": 380, - "src/browser/test/setup.test.ts": 376, - "src/browser/logs/logClient.test.ts": 374, - "src/browser/jobs/jobRouteSearch.test.ts": 371, - "src/browser/cache/useCacheRealtimeInvalidation.test.tsx": 367, - "src/browser/terminal/terminalEmulator.test.ts": 367, - "src/browser/chat/chatContractAdapter.test.ts": 354, - "src/browser/chat/chatLocalPreferences.test.ts": 345, - "src/browser/monitoring/monitoringQueries.test.ts": 343, - "src/browser/api/useRealtimeQueryInvalidation.test.tsx": 339, - "src/browser/tasks/TaskBoard.test.ts": 319, - "src/browser/lib/formatDateTime.test.ts": 318, - "src/browser/chat/chatSessionTokenPresentation.test.ts": 306, - "src/browser/notifications/notificationMutations.test.ts": 297, - "src/browser/cache/cachePresentation.test.ts": 293, - "src/browser/overview/systemMetricsPresentation.test.ts": 293, - "src/browser/lib/formatMeasurements.test.ts": 292, - "src/browser/moltbook/moltbookPresentation.test.ts": 291, - "src/browser/files/workspaceFilePresentation.test.ts": 284, - "src/browser/api/trpcError.test.ts": 282, - "src/browser/sessions/gatewaySessionPresentation.test.ts": 271, - "src/browser/layout/dashboardSystemStatus.test.ts": 270, - "src/browser/layout/DashboardShell.test.ts": 266 + "src/browser/security/AccountSecurityRoute.test.tsx": 14679, + "src/browser/settings/SettingsRoute.test.tsx": 13758, + "src/browser/chat/ChatBrowser.test.tsx": 11814, + "src/browser/jobs/JobsRoute.test.tsx": 11018, + "src/browser/jobs/JobRunBrowser.test.tsx": 6904, + "src/browser/chat/ChatWorkspace.test.tsx": 6580, + "src/browser/logs/LogsView.test.tsx": 6411, + "src/browser/files/WorkspaceFileEditorPane.test.tsx": 4436, + "src/browser/openClawCron/OpenClawCronSection.test.tsx": 4022, + "src/browser/tasks/TaskBoardRoute.test.tsx": 3833, + "src/browser/monitoring/MonitoringRoutes.test.tsx": 3613, + "src/browser/notifications/NotificationCenter.test.tsx": 3509, + "src/browser/auth/LoginRoute.test.tsx": 3099, + "src/browser/files/WorkspaceFilesView.test.tsx": 3037, + "src/browser/logs/LogsBrowser.test.tsx": 2847, + "src/browser/overview/OverviewRoute.test.tsx": 2829, + "src/browser/openClawCron/OpenClawCronBrowser.test.tsx": 2638, + "src/browser/jobs/JobQueuePanel.test.tsx": 2466, + "src/browser/jobs/jobRouteSearch.test.ts": 2327, + "src/browser/jobs/ScheduleDetailStateCopy.test.tsx": 2240, + "src/browser/overview/OverviewServiceActionsSection.test.tsx": 2234, + "src/browser/terminal/TerminalBrowser.test.tsx": 2038, + "src/browser/sessions/GatewaySessionsBrowser.test.tsx": 1978, + "src/browser/files/WorkspaceFilesBrowser.test.tsx": 1958, + "src/browser/jobs/ScheduleDetailForm.test.tsx": 1893, + "src/browser/agents/AgentsRoute.test.tsx": 1863, + "src/browser/ui/DropdownMenu.test.tsx": 1851, + "src/browser/ui/JsonViewer.test.tsx": 1834, + "src/browser/ui/SourceViewer.test.tsx": 1820, + "src/browser/ui/ExpandableCard.test.tsx": 1791, + "src/browser/chat/chatPostMutationObservations.test.ts": 1763, + "src/browser/overview/OverviewAgentsCard.test.tsx": 1712, + "src/browser/ui/Popover.test.tsx": 1694, + "src/browser/database/DatabaseRoute.test.tsx": 1638, + "src/browser/ui/MetricCard.test.tsx": 1546, + "src/browser/api/trpcClient.test.ts": 1499, + "src/browser/ui/Button.test.tsx": 1400, + "src/browser/application.test.tsx": 1361, + "src/browser/overview/OverviewIncidentsCard.test.tsx": 1348, + "src/browser/ui/CopyTextButton.test.tsx": 1329, + "src/browser/ui/DateTimePicker.test.tsx": 1327, + "src/browser/monitoring/IncidentTable.test.tsx": 1306, + "src/browser/overview/OverviewAgentsSection.test.tsx": 1300, + "src/browser/ui/LoadingState.test.tsx": 1232, + "src/browser/sessions/GatewaySessionsView.test.tsx": 1216, + "src/browser/ui/RadioGroup.test.tsx": 1177, + "src/browser/chat/ChatComposer.test.tsx": 1175, + "src/browser/files/WorkspaceFileTable.test.tsx": 1147, + "src/browser/auth/AuthenticatedBrowserCacheBoundary.test.tsx": 1133, + "src/browser/files/WorkspaceFilesRoute.test.tsx": 1122, + "src/browser/chat/chatQueries.test.ts": 1101, + "src/browser/jobs/ScheduleDetailStateVersion.test.tsx": 1090, + "src/browser/jobs/JobRunTable.test.tsx": 1079, + "src/browser/overview/OverviewJobsSection.test.tsx": 1061, + "src/browser/auth/AuthenticatedSessionActivity.test.tsx": 1056, + "src/browser/chat/chatRuntimeStore.test.ts": 1050, + "src/browser/routes/logs.lazy.test.tsx": 1050, + "src/browser/routes/database.lazy.test.tsx": 1025, + "src/browser/jobs/ScheduleDetailStateDisable.test.tsx": 1024, + "src/browser/ui/Select.test.tsx": 1013, + "src/browser/terminal/TerminalRoute.test.tsx": 986, + "src/browser/overview/OverviewReportsSection.test.tsx": 984, + "src/browser/moltbook/MoltbookRoute.test.tsx": 974, + "src/browser/chat/ChatMessageBubble.test.tsx": 964, + "src/browser/chat/useChatRuntimeProjection.test.tsx": 961, + "src/browser/jobs/jobMutations.test.tsx": 955, + "src/browser/files/workspaceFileTransfers.test.ts": 946, + "src/browser/terminal/TerminalWorkspace.test.tsx": 943, + "src/browser/overview/OverviewIncidentsSection.test.tsx": 937, + "src/browser/ui/Combobox.test.tsx": 937, + "src/browser/ui/Virtualizer.test.tsx": 932, + "src/browser/overview/OverviewNotificationsCard.test.tsx": 928, + "src/browser/jobs/ScheduleEditor.test.tsx": 920, + "src/browser/cache/CacheStatusTable.test.tsx": 904, + "src/browser/chat/ChatRuntimeStoreProvider.test.tsx": 895, + "src/browser/security/AutomationPrincipalCard.test.tsx": 895, + "src/browser/jobs/JobRunDetail.test.tsx": 887, + "src/browser/ui/TimePicker.test.tsx": 857, + "src/browser/notifications/notificationCollection.test.ts": 854, + "src/browser/overview/OverviewTasksSection.test.tsx": 834, + "src/browser/tasks/useTaskRealtimeInvalidation.test.tsx": 827, + "src/browser/monitoring/monitoringQueries.test.ts": 823, + "src/browser/api/useRealtimeQueryInvalidation.test.tsx": 821, + "src/browser/cache/SystemHostCard.test.tsx": 819, + "src/browser/auth/AuthenticationBoundary.test.tsx": 794, + "src/browser/ui/DatePicker.test.tsx": 769, + "src/browser/chat/ChatAttachmentPicker.test.tsx": 768, + "src/browser/jobs/ScheduleDetailStateErrors.test.tsx": 743, + "src/browser/jobs/useJobRealtimeInvalidation.test.tsx": 730, + "src/browser/notifications/notificationQueries.test.ts": 727, + "src/browser/chat/ChatTranscript.test.tsx": 717, + "src/browser/jobs/ScheduleDetailStateReplay.test.tsx": 700, + "src/browser/auth/useAuthenticatedMutationBoundary.test.tsx": 694, + "src/browser/logs/LogsRoute.test.tsx": 688, + "src/browser/main.test.tsx": 657, + "src/browser/openClawCron/useOpenClawCronRealtimeInvalidation.test.tsx": 652, + "src/browser/cache/useCacheRealtimeInvalidation.test.tsx": 645, + "src/browser/chat/useChatRealtimeInvalidation.test.tsx": 644, + "src/browser/ui/Alert.test.tsx": 635, + "src/browser/overview/OverviewReportsCard.test.tsx": 634, + "src/browser/chat/ChatRoute.test.tsx": 620, + "src/browser/api/realtimeClient.test.ts": 601, + "src/browser/api/useObservedQueryState.test.tsx": 596, + "src/browser/chat/useChatSpeech.test.tsx": 595, + "src/browser/ui/Fieldset.test.tsx": 594, + "src/browser/sessions/useGatewaySessionRealtimeInvalidation.test.tsx": 590, + "src/browser/router.test.ts": 589, + "src/browser/jobs/ScheduleTable.test.tsx": 587, + "src/browser/monitoring/useMonitoringRealtimeInvalidation.test.tsx": 580, + "src/browser/ui/Tabs.test.tsx": 579, + "src/browser/overview/OverviewJobsCard.test.tsx": 572, + "src/browser/sessions/GatewaySessionsRoute.test.tsx": 567, + "src/browser/logs/LogLevelFilter.test.tsx": 566, + "src/browser/chat/ChatLiveProjection.test.tsx": 560, + "src/browser/notifications/useNotificationRealtimeInvalidation.test.tsx": 558, + "src/browser/overview/OverviewNotificationsSection.test.tsx": 553, + "src/browser/cache/cacheMutations.test.tsx": 547, + "src/browser/layout/DashboardShell.test.ts": 540, + "src/browser/chat/ChatAttachmentPreview.test.tsx": 525, + "src/browser/terminal/terminalProtocol.test.ts": 522, + "src/browser/tasks/taskQueries.test.ts": 516, + "src/browser/jobs/jobQueries.test.ts": 514, + "src/browser/cache/cacheQueries.test.ts": 510, + "src/browser/logs/logLinePresentation.test.ts": 490, + "src/browser/logs/logClient.test.ts": 482, + "src/browser/api/realtimeHub.test.ts": 480, + "src/browser/chat/chatViewProjection.test.ts": 480, + "src/browser/overview/OverviewTasksCard.test.tsx": 480, + "src/browser/notifications/notificationMutations.test.ts": 466, + "src/browser/ui/Switch.test.tsx": 462, + "src/browser/moltbook/moltbookPresentation.test.ts": 436, + "src/browser/jobs/scheduleEditorForm.test.ts": 429, + "src/browser/chat/chatAttachments.test.ts": 414, + "src/browser/auth/authQueries.test.ts": 413, + "src/browser/database/databaseRouteSearch.test.ts": 404, + "src/browser/chat/chatLocalPreferences.test.ts": 400, + "src/browser/terminal/terminalEmulator.test.ts": 393, + "src/browser/chat/chatMutations.test.ts": 389, + "src/browser/settings/settingsRouteSearch.test.ts": 386, + "src/browser/logs/logQueries.test.ts": 385, + "src/browser/layout/dashboardSystemStatus.test.ts": 380, + "src/browser/overview/systemMetricsQueries.test.ts": 376, + "src/browser/chat/chatContractAdapter.test.ts": 372, + "src/browser/chat/chatSessionTokenPresentation.test.ts": 369, + "src/browser/security/issuedAutomationToken.test.ts": 364, + "src/browser/ui/DataTable.test.tsx": 364, + "src/browser/overview/systemMetricsPresentation.test.ts": 357, + "src/browser/ui/ExternalLink.test.tsx": 354, + "src/browser/api/trpcError.test.ts": 353, + "src/browser/chat/ChatBrowserState.test.ts": 345, + "src/browser/overview/SystemMetricsCards.test.tsx": 345, + "src/browser/sessions/gatewaySessionQueries.test.ts": 337, + "src/browser/tasks/taskEditorForm.test.ts": 336, + "src/browser/chat/chatSessionPicker.test.ts": 333, + "src/browser/lib/formatMeasurements.test.ts": 332, + "src/browser/chat/chatRouteSearch.test.ts": 331, + "src/browser/files/workspaceFileQueries.test.ts": 331, + "src/browser/data/dashboardCollections.test.ts": 327, + "src/browser/files/workspaceFilePresentation.test.ts": 322, + "src/browser/lib/formatDateTime.test.ts": 322, + "src/browser/cache/cachePresentation.test.ts": 298, + "src/browser/tasks/TaskBoard.test.ts": 289, + "src/browser/sessions/gatewaySessionPresentation.test.ts": 288, + "src/browser/overview/serviceActionsOperations.test.ts": 281, + "src/browser/security/webauthn/webauthnClient.test.ts": 277, + "src/browser/test/setup.test.ts": 276 }, "version": 1 } diff --git a/greenfield/.bun-test-timings.json b/greenfield/.bun-test-timings.json index a57fe8430..8a96ffb90 100644 --- a/greenfield/.bun-test-timings.json +++ b/greenfield/.bun-test-timings.json @@ -1,403 +1,431 @@ { "files": { - "scripts/delivery/productionReleaseActivation.test.ts": 61261, - "src/test/integration/delivery/productionReleaseLifecycle.test.ts": 46775, - "scripts/sourceBoundaries/policy.test.ts": 29858, - "src/server/domains/chat/service.test.ts": 29558, - "scripts/delivery/buildBrowser.test.ts": 25095, - "scripts/development/developmentFrontend.test.ts": 17502, - "scripts/sourceBoundaries/lintConfiguration.test.ts": 17104, - "src/worker/files/descriptorWorkspaceFileStructuralWriter.test.ts": 13593, - "scripts/delivery/systemdProductionServices.test.ts": 12209, - "src/server/test/system/serverMfaAuthentication.test.ts": 11243, - "scripts/delivery/installProductionSystemdUnits.test.ts": 11164, - "scripts/delivery/productionReleasePublication.test.ts": 8927, - "src/server/database/migrations/jobsSchema.test.ts": 6936, - "scripts/delivery/releaseIdentity.test.ts": 5669, - "src/server/test/system/serverAutomationSecurityLeaseInvalidation.test.ts": 5430, - "src/server/test/system/serverWebAuthnAuthentication.test.ts": 4799, - "src/test/integration/transport/topology/rollingReleaseSse.test.ts": 4458, - "src/server/domains/security/mfa/loginLifecycle.webAuthn.test.ts": 4437, - "src/server/domains/jobs/repository.test.ts": 3990, - "src/server/database/runtime/databaseService.test.ts": 3979, - "src/server/domains/chat/repository.test.ts": 3916, - "src/test/integration/transport/trpc/trpcFetchSse.test.ts": 3559, - "src/server/test/system/serverGatewayCredentialVerification.test.ts": 3503, - "src/server/platform/realtime/eventPumpSubscriptionBackpressure.test.ts": 3269, - "src/test/integration/build/frontendBuildScenario.test.ts": 3253, - "scripts/delivery/buildRelease.test.ts": 3047, - "src/server/domains/security/automation/lifecyclePrincipal.test.ts": 2969, - "src/test/integration/transport/topology/httpsReverseProxy.test.ts": 2755, - "src/server/domains/security/authenticationLifecycle.sessions.test.ts": 2483, - "src/app/dashboardServer.test.ts": 2414, - "scripts/development/developmentState.test.ts": 2283, - "src/server/domains/security/requestAuthenticationAutomation.test.ts": 2248, - "src/server/domains/security/securityAuditLifecycle.test.ts": 2169, - "src/server/domains/jobs/service.test.ts": 2137, - "src/server/test/system/serverAutomationSecurity.test.ts": 2123, - "src/test/integration/shutdown/completeShutdownScenario.test.ts": 2107, - "src/server/domains/monitoring/catalogService.test.ts": 2057, - "src/server/domains/security/mfa/loginLifecycle.totp.test.ts": 2023, - "src/worker/logs/managedLogRotation.test.ts": 1995, - "src/test/integration/openclaw/sourceAudit.test.ts": 1951, - "scripts/delivery/databaseTransitionFilesystem.test.ts": 1905, - "src/server/domains/security/automation/lifecycleCredential.test.ts": 1875, - "src/server/domains/tasks/service.test.ts": 1872, - "src/server/database/runtime/databaseSnapshot.test.ts": 1807, - "src/server/database/migrations/migrationLedgerValidation.test.ts": 1796, - "src/server/domains/security/requestAuthenticationSession.test.ts": 1775, - "src/server/domains/cache/repository.test.ts": 1771, - "src/server/domains/security/mfa/accountLifecycle.webAuthn.test.ts": 1712, - "src/server/platform/realtime/eventPumpSubscriptionReplay.test.ts": 1660, - "src/test/integration/resources/pausedTlsSseClient.test.ts": 1641, - "src/server/domains/security/password.test.ts": 1584, - "src/server/database/migrations/migrationGraph.test.ts": 1565, - "src/server/platform/gateway/persistentGatewayTransport.test.ts": 1522, - "src/server/domains/security/authenticationLifecycle.bootstrap.test.ts": 1466, - "src/server/platform/runtime/applicationRuntime.test.ts": 1434, - "src/server/domains/security/authenticationLifecycle.rateLimit.test.ts": 1421, - "src/server/domains/monitoring/procedures.test.ts": 1328, - "src/server/test/system/serverAutomationSecurityLostResponse.test.ts": 1204, - "src/server/domains/agents/procedures.test.ts": 1194, - "src/test/integration/websocket/nativeWebSocketTransport.test.ts": 1188, - "src/server/domains/security/automation/lifecycleRepository.test.ts": 1176, - "src/server/platform/runtime/dashboardApplicationRuntime.test.ts": 1170, - "src/server/domains/security/authenticationLifecycle.password.test.ts": 1154, - "src/server/domains/security/mfa/accountLifecycle.proofs.test.ts": 1040, - "src/test/integration/outbox/sqliteOutboxScenario.test.ts": 1008, - "src/server/database/migrations/auditEventsSchema.test.ts": 996, - "src/server/database/migrations/authenticationRateLimitSchema.test.ts": 904, - "src/server/domains/chat/reducer.test.ts": 882, - "src/server/test/system/serverRealtime.test.ts": 872, - "src/server/database/migrations/monitoringSchema.test.ts": 814, - "src/server/domains/security/mfa/accountLifecycle.factors.test.ts": 804, - "src/server/domains/agents/service.test.ts": 768, - "src/server/domains/security/authenticationWorkGate.webAuthn.test.ts": 728, - "src/server/domains/security/mfa/loginLifecycle.recovery.test.ts": 717, - "src/server/database/validation/users.test.ts": 682, - "src/server/domains/security/mfa/lifecycleRepository.webAuthn.test.ts": 678, - "src/server/domains/monitoring/serviceBoundary.test.ts": 670, - "scripts/delivery/buildProcesses.test.ts": 659, - "src/server/domains/jobs/coordinator.test.ts": 649, - "src/server/domains/tasks/procedures.test.ts": 645, - "scripts/development/developmentRuntime.test.ts": 631, - "src/app/developmentProcesses.test.ts": 630, - "src/server/domains/monitoring/serviceOrdering.test.ts": 630, - "src/server/domains/security/requestAuthenticationRepository.test.ts": 619, - "src/server/test/system/serverFoundation.test.ts": 597, - "scripts/delivery/installLogMaintenanceProvisioning.test.ts": 590, - "src/server/domains/jobs/workerSystem.test.ts": 589, - "src/server/platform/realtime/eventStore.test.ts": 574, - "src/app/dashboardServerProcess.test.ts": 559, - "src/server/platform/realtime/eventPumpSubscriptionCancellation.test.ts": 558, - "src/server/platform/gateway/persistentGatewayProtocol.test.ts": 556, - "src/server/domains/cache/service.test.ts": 555, - "src/server/database/migrations/taskSchema.test.ts": 549, - "src/server/domains/security/mfa/lifecycleRepository.test.ts": 539, - "src/server/platform/realtime/eventPumpSubscriptionRetention.test.ts": 537, - "src/server/database/migrations/securityIdentitySchema.baseline.test.ts": 536, - "src/server/platform/logs/safeLogReader.test.ts": 532, - "src/server/domains/monitoring/serviceLifecycle.test.ts": 507, - "src/server/domains/security/mfa/accountLifecycle.maintenance.test.ts": 498, - "src/app/trpcHttpHandler.test.ts": 496, - "scripts/sourceBoundaries/sourceDiscovery.test.ts": 478, - "src/server/domains/logs/operationAudit.test.ts": 476, - "src/server/domains/tasks/repositoryReader.test.ts": 475, - "src/server/platform/realtime/eventPumpServiceFairness.test.ts": 473, - "src/server/database/migrations/securityIdentitySchema.browser.test.ts": 468, - "src/server/platform/files/descriptorWorkspaceFileReader.test.ts": 456, - "src/server/trpc/procedureErrorPolicy.test.ts": 451, - "src/server/domains/security/authenticationLifecycle.login.test.ts": 448, - "src/server/platform/realtime/eventPumpPolling.test.ts": 447, - "src/server/test/system/serverShutdown.test.ts": 445, - "src/server/database/migrations/webauthnLifecycleSchema.test.ts": 430, - "src/server/platform/files/descriptorWorkspaceFileUploadSpool.test.ts": 429, - "scripts/sourceBoundaries/checkerIntegration.test.ts": 428, - "src/server/test/system/serverAuthenticationTransport.test.ts": 420, - "src/server/domains/security/authenticationWorkGate.test.ts": 417, - "src/test/integration/shutdown/shutdownServiceResources.test.ts": 415, - "scripts/sourceBoundaries/boundaryConfiguration.test.ts": 407, - "src/server/database/migrations/loadVerifiedMigrations.test.ts": 404, - "src/server/trpc/appRouter.test.ts": 404, + "scripts/delivery/buildBrowser.test.ts": 27116, + "scripts/sourceBoundaries/lintConfiguration.test.ts": 18279, + "scripts/delivery/productionReleaseActivation.test.ts": 18155, + "src/test/integration/delivery/productionReleaseLifecycle.test.ts": 17762, + "src/worker/files/descriptorWorkspaceFileStructuralWriter.test.ts": 13734, + "scripts/development/developmentFrontend.test.ts": 11856, + "src/server/test/system/serverMfaAuthentication.test.ts": 10926, + "src/server/database/runtime/databaseSnapshot.test.ts": 8857, + "scripts/delivery/systemdProductionServices.test.ts": 8369, + "scripts/delivery/releaseIdentity.test.ts": 6273, + "scripts/delivery/installProductionSystemdUnits.test.ts": 6049, + "src/server/test/system/serverAutomationSecurityLeaseInvalidation.test.ts": 5340, + "src/server/test/system/serverWebAuthnAuthentication.test.ts": 4824, + "src/app/dashboardServer.test.ts": 4285, + "src/test/integration/transport/topology/rollingReleaseSse.test.ts": 4020, + "scripts/delivery/productionArtifactRetention.test.ts": 3749, + "scripts/delivery/productionReleasePublication.test.ts": 3690, + "src/test/integration/transport/trpc/trpcFetchSse.test.ts": 3466, + "src/server/database/migrations/jobsSchema.test.ts": 3239, + "scripts/development/developmentState.test.ts": 3234, + "scripts/delivery/buildRelease.test.ts": 3177, + "src/test/integration/openclaw/sourceAudit.test.ts": 2965, + "src/server/database/runtime/databaseService.test.ts": 2652, + "scripts/delivery/activateProductionReleaseAdmission.test.ts": 2647, + "src/server/domains/chat/service.test.ts": 2393, + "src/test/integration/build/frontendBuildScenario.test.ts": 2137, + "scripts/delivery/databaseTransitionFilesystem.test.ts": 2021, + "src/server/test/system/serverAutomationSecurity.test.ts": 1935, + "src/server/domains/jobs/repository.test.ts": 1851, + "src/worker/database/dockerDatabaseObservabilityEndpointResolver.test.ts": 1706, + "src/server/platform/runtime/applicationRuntime.test.ts": 1697, + "src/test/integration/transport/topology/httpsReverseProxy.test.ts": 1637, + "src/server/test/system/serverGatewayCredentialVerification.test.ts": 1549, + "src/test/integration/shutdown/completeShutdownScenario.test.ts": 1456, + "src/server/domains/chat/repository.test.ts": 1443, + "scripts/development/developmentRuntime.test.ts": 1423, + "src/server/platform/gateway/persistentGatewayTransport.test.ts": 1332, + "src/test/integration/resources/pausedTlsSseClient.test.ts": 1284, + "src/server/platform/runtime/dashboardApplicationRuntime.test.ts": 1223, + "scripts/delivery/installHostOperationsProvisioning.test.ts": 1211, + "src/test/integration/websocket/nativeWebSocketTransport.test.ts": 1185, + "src/server/database/migrations/migrationGraph.test.ts": 1079, + "src/test/integration/outbox/sqliteOutboxScenario.test.ts": 1079, + "src/server/domains/security/mfa/loginLifecycle.webAuthn.test.ts": 1075, + "src/server/test/system/serverFoundation.test.ts": 1060, + "src/worker/logs/managedLogRotation.test.ts": 1051, + "src/server/test/system/serverRealtime.test.ts": 1010, + "src/server/domains/security/authenticationLifecycle.bootstrap.test.ts": 978, + "src/server/domains/security/mfa/accountLifecycle.webAuthn.test.ts": 959, + "src/server/test/system/serverAutomationSecurityLostResponse.test.ts": 950, + "src/server/domains/security/password.test.ts": 946, + "src/server/trpc/procedureErrorPolicy.test.ts": 933, + "src/server/domains/chat/reducer.test.ts": 920, + "scripts/delivery/buildProcesses.test.ts": 903, + "src/server/domains/jobs/service.test.ts": 877, + "scripts/delivery/installLogMaintenanceProvisioning.test.ts": 834, + "scripts/delivery/databaseSnapshotRetention.test.ts": 796, + "src/server/domains/security/requestAuthenticationAutomation.test.ts": 765, + "src/worker/logs/logMaintenanceAvailabilityPublisher.test.ts": 756, + "src/server/test/system/serverAuthenticationResponses.test.ts": 755, + "scripts/delivery/productionActivationJournal.test.ts": 689, + "src/server/test/system/serverShutdown.test.ts": 615, + "src/server/domains/jobs/coordinator.test.ts": 614, + "src/server/domains/security/authenticationLifecycle.rateLimit.test.ts": 611, + "src/server/platform/realtime/eventPumpSubscriptionBackpressure.test.ts": 608, + "src/server/platform/logs/safeLogReader.test.ts": 605, + "src/server/domains/agents/service.test.ts": 604, + "src/server/database/migrations/loadVerifiedMigrations.test.ts": 602, + "src/server/domains/security/mfa/accountLifecycle.proofs.test.ts": 594, + "scripts/development/developmentMigrationIdentity.test.ts": 587, + "src/app/developmentProcesses.test.ts": 582, + "src/server/platform/files/descriptorWorkspaceFileReader.test.ts": 571, + "src/server/trpc/trpc.test.ts": 535, + "src/server/domains/security/mfa/accountLifecycle.factors.test.ts": 533, + "src/test/integration/shutdown/shutdownServiceResources.test.ts": 532, + "src/server/trpc/appRouter.test.ts": 515, + "src/server/test/system/serverAuthenticationTransport.test.ts": 510, + "src/server/domains/agents/procedures.test.ts": 509, + "scripts/delivery/databaseObservabilityProvisioning.test.ts": 507, + "src/server/domains/security/securityAuditLifecycle.test.ts": 507, + "scripts/delivery/productionActivationState.test.ts": 505, + "src/server/domains/security/authenticationLifecycle.sessions.test.ts": 500, + "src/server/domains/security/mfa/loginLifecycle.totp.test.ts": 489, + "scripts/sourceBoundaries/importTargetValidation.test.ts": 488, + "src/server/domains/security/authenticationWorkGate.webAuthn.test.ts": 486, + "src/server/platform/realtime/eventStore.test.ts": 478, + "src/server/domains/monitoring/catalogService.test.ts": 477, + "src/server/domains/tasks/procedures.test.ts": 475, + "src/server/domains/files/service.test.ts": 474, + "src/server/domains/jobs/workerSystem.test.ts": 473, + "src/server/platform/realtime/eventPumpSubscriptionReplay.test.ts": 473, + "scripts/development/developmentFileRoots.test.ts": 454, + "src/server/domains/security/authenticationLifecycle.password.test.ts": 454, + "src/server/domains/monitoring/procedures.test.ts": 449, + "src/server/platform/gateway/persistentGatewayProtocol.test.ts": 445, + "src/test/parity/parityInventory.test.ts": 441, + "src/server/domains/monitoring/serviceOrdering.test.ts": 436, + "src/server/platform/realtime/eventPumpSubscriptionRetention.test.ts": 434, + "src/server/domains/monitoring/normalization.test.ts": 432, + "src/server/test/contracts/trpcErrors.test.ts": 432, + "src/server/domains/cache/service.test.ts": 426, + "scripts/sourceBoundaries/boundaryConfiguration.test.ts": 424, + "src/app/databaseMaintenance.test.ts": 419, + "src/server/database/migrations/monitoringSchema.test.ts": 417, + "src/server/domains/cache/repository.test.ts": 417, + "src/server/platform/configuration/webConfiguration.test.ts": 413, + "src/server/platform/realtime/eventPumpSubscriptionCancellation.test.ts": 413, + "src/server/domains/security/mfa/accountLifecycle.maintenance.test.ts": 410, + "src/server/domains/security/automation/lifecyclePrincipal.test.ts": 403, "src/server/trpc/appRouterOptionalFeatureServices.test.ts": 403, - "src/server/domains/jobs/procedures.test.ts": 400, - "src/server/domains/security/authPendingMfaRoutes.webAuthn.test.ts": 400, - "src/server/database/migrations/realtimeSchema.test.ts": 390, - "src/server/domains/security/procedures.test.ts": 389, - "src/app/trpcRequestPolicy.test.ts": 386, - "src/server/test/system/serverAuthenticationResponses.test.ts": 384, - "scripts/sourceBoundaries/importGraph.test.ts": 376, - "src/server/domains/system/procedures.test.ts": 367, - "src/server/database/migrations/agentTaskRunsSchema.test.ts": 365, - "src/server/domains/realtime/procedures.test.ts": 362, - "src/server/domains/jobs/actionExecutors.test.ts": 361, - "src/contracts/auth.test.ts": 352, - "src/server/domains/security/mfa/procedures.test.ts": 352, - "src/server/platform/gateway/persistentGatewayOpenClawSettingsProvider.test.ts": 352, - "src/server/domains/security/mfa/loginLifecycle.pending.test.ts": 347, - "src/server/test/contracts/trpcErrors.test.ts": 347, - "src/server/test/contracts/superjsonTransport.test.ts": 345, - "src/server/domains/security/automation/procedures.test.ts": 343, - "src/server/domains/security/authenticationLifecycleRepository.test.ts": 341, - "src/server/domains/tasks/taskNotificationQueue.test.ts": 338, - "src/server/database/migrations/jsonObjectConstraints.test.ts": 337, - "src/server/database/migrations/securityIdentitySchema.automation.test.ts": 337, - "src/contracts/openClawTasks.test.ts": 330, - "src/server/database/validation/auditEvents.test.ts": 328, - "src/server/domains/cache/procedures.test.ts": 327, - "src/server/domains/files/service.test.ts": 326, - "src/server/platform/gateway/gatewayCredentialVerifier.test.ts": 321, - "src/server/database/migrations/mfaLifecycleSchema.test.ts": 318, - "scripts/development/developmentFileRoots.test.ts": 314, - "src/server/trpc/context.test.ts": 314, - "scripts/delivery/productionActivationJournal.test.ts": 310, - "src/server/platform/gateway/persistentOpenClawCronProvider.test.ts": 307, - "src/server/domains/moltbook/procedures.test.ts": 303, - "src/server/domains/openClawSettings/operationAudit.test.ts": 297, - "src/server/rawHttp/chatSpeech.test.ts": 294, - "src/server/domains/jobs/actionRegistry.test.ts": 291, - "src/server/domains/files/procedures.test.ts": 284, - "src/server/domains/openClawCron/operationAudit.test.ts": 279, - "src/server/domains/realtime/transport.test.ts": 279, - "src/contracts/openClawCron.test.ts": 277, - "src/server/domains/chat/procedures.test.ts": 275, - "src/server/database/migrations/migrationApplicationTime.test.ts": 274, - "src/server/trpc/trpc.test.ts": 274, - "src/server/platform/realtime/renewableStreamLease.test.ts": 273, - "src/server/platform/observability/effectLogger.test.ts": 270, - "scripts/testOutputPolicy.test.ts": 269, - "src/server/platform/configuration/webConfiguration.test.ts": 268, - "src/server/platform/gateway/persistentGatewayRealtimeBridge.test.ts": 268, - "src/test/parity/parityInventory.test.ts": 265, - "src/server/database/validation/monitorRuns.test.ts": 262, - "src/server/database/runtime/databasePath.test.ts": 261, - "src/server/domains/openClawCron/service.test.ts": 260, - "src/server/domains/security/securityAuditProcedures.test.ts": 256, - "src/server/rawHttp/chatMedia.test.ts": 250, - "src/server/database/validation/rowSchemaIntegration.test.ts": 249, - "src/server/platform/realtime/eventPumpServiceLifecycle.test.ts": 249, - "src/server/domains/chat/coalescer.test.ts": 247, - "src/server/domains/openClawCron/procedures.test.ts": 247, - "src/server/platform/gateway/persistentGatewayChatProvider.test.ts": 245, - "scripts/sourceBoundaries/importTargetValidation.test.ts": 244, + "src/server/domains/tasks/service.test.ts": 400, + "src/server/domains/security/automation/procedures.test.ts": 397, + "src/server/domains/database/procedures.test.ts": 388, + "src/server/domains/security/requestAuthenticationRepository.test.ts": 387, + "src/server/domains/security/automation/lifecycleRepository.test.ts": 385, + "scripts/sourceBoundaries/checkerIntegration.test.ts": 376, + "src/server/test/contracts/superjsonTransport.test.ts": 376, + "src/server/test/support/freshDatabase.test.ts": 374, + "src/server/domains/security/authenticationWorkGate.test.ts": 368, + "scripts/sourceBoundaries/sourceDiscovery.test.ts": 365, + "src/server/database/migrations/auditEventsSchema.test.ts": 362, + "scripts/runTestSuite.test.ts": 355, + "src/server/rawHttp/chatSpeech.test.ts": 353, + "src/server/domains/security/automation/lifecycleCredential.test.ts": 350, + "src/server/platform/realtime/eventPumpPolling.test.ts": 344, + "scripts/delivery/deploymentLease.test.ts": 343, + "src/server/trpc/context.test.ts": 343, + "src/worker/database/bunSqlDatabaseObservabilityCollector.test.ts": 343, + "src/server/platform/gateway/persistentOpenClawCronProvider.test.ts": 342, + "scripts/generateDocs.test.ts": 341, + "src/app/dashboardServerProcess.test.ts": 341, + "src/server/database/migrations/migrationLedgerValidation.test.ts": 340, + "src/server/domains/cache/procedures.test.ts": 340, + "src/server/domains/monitoring/testSupport/services.test.ts": 338, + "src/server/domains/security/mfa/loginLifecycle.recovery.test.ts": 338, + "scripts/delivery/productionRuntime.test.ts": 337, + "src/server/domains/security/mfa/lifecycleRepository.test.ts": 336, + "src/server/domains/database/sqliteLifecycle.test.ts": 334, + "src/server/database/migrations/migrationApplicationTime.test.ts": 332, + "src/server/domains/tasks/taskNotificationQueue.test.ts": 331, + "src/server/domains/monitoring/serviceBoundary.test.ts": 329, + "src/server/platform/gateway/persistentGatewaySessionsProvider.test.ts": 326, + "src/test/integration/resources/runSseMemoryEvidence.test.ts": 325, + "src/server/platform/realtime/eventPumpServiceSubscription.test.ts": 321, + "src/server/domains/jobs/logMaintenanceQueue.test.ts": 320, + "src/server/domains/security/securityAuditProcedures.test.ts": 319, + "src/server/domains/security/mfa/lifecycleRepository.webAuthn.test.ts": 315, + "src/server/platform/realtime/eventPumpServiceFairness.test.ts": 315, + "src/server/domains/logs/operationAudit.test.ts": 310, + "src/server/domains/monitoring/serviceLifecycle.test.ts": 310, + "src/server/domains/moltbook/procedures.test.ts": 308, + "src/contracts/automationSecurity.test.ts": 307, + "src/server/domains/moltbook/provider.test.ts": 306, + "src/app/trpcHttpHandler.test.ts": 303, + "src/server/domains/jobs/workerRuntime.test.ts": 303, + "src/server/platform/gateway/persistentGatewayOpenClawSettingsProvider.test.ts": 300, + "src/server/domains/security/authenticationLifecycle.login.test.ts": 299, + "src/server/platform/files/descriptorWorkspaceFileUploadSpool.test.ts": 298, + "scripts/sourceBoundaries/importGraph.test.ts": 297, + "src/server/domains/openClawCron/operationAudit.test.ts": 290, + "src/server/domains/system/procedures.test.ts": 290, + "src/server/rawHttp/chatMedia.test.ts": 289, + "src/server/database/migrations/webauthnLifecycleSchema.test.ts": 286, + "src/server/domains/tasks/repositoryReader.test.ts": 285, + "src/server/database/migrations/jsonObjectConstraints.test.ts": 284, + "src/server/domains/security/authenticationLifecycleRepository.test.ts": 282, + "src/server/platform/chat/elevenLabsSpeechProvider.test.ts": 279, + "src/worker/taskNotifications.test.ts": 274, + "src/server/platform/errors/safeFailure.test.ts": 272, + "src/server/domains/security/mfa/procedures.test.ts": 267, + "src/server/database/migrations/taskSchema.test.ts": 264, + "src/server/database/migrations/mfaLifecycleSchema.test.ts": 261, + "src/server/domains/files/procedures.test.ts": 261, + "src/server/domains/gatewaySessions/controlAuditStore.test.ts": 260, + "src/server/platform/gateway/persistentGatewayChatProvider.test.ts": 260, + "src/server/domains/openClawCron/procedures.test.ts": 258, + "src/server/domains/security/procedures.test.ts": 257, + "scripts/buildSourceIdentity.test.ts": 255, + "src/server/domains/security/mfa/loginLifecycle.pending.test.ts": 255, + "src/contracts/events.test.ts": 253, + "src/server/database/migrations/agentTaskRunsSchema.test.ts": 253, + "src/server/domains/openClawSettings/procedures.test.ts": 253, + "src/server/database/migrations/authenticationRateLimitSchema.test.ts": 248, + "src/server/domains/realtime/procedures.test.ts": 246, "src/app/worker.test.ts": 244, - "src/server/platform/realtime/eventPumpServicePolling.test.ts": 244, - "src/server/platform/realtime/eventPumpServiceSubscription.test.ts": 241, - "scripts/delivery/productionActivationState.test.ts": 238, - "src/server/domains/openClawCron/sqliteIntentStore.test.ts": 235, - "scripts/delivery/deploymentLease.test.ts": 232, - "src/server/domains/security/mfa/webauthn/adapter.test.ts": 226, - "src/server/domains/openClawSettings/procedures.test.ts": 224, - "src/contracts/openClawSettings.test.ts": 223, - "src/worker/logs/logMaintenanceAvailabilityPublisher.test.ts": 222, - "scripts/documentation/artifacts.test.ts": 221, - "src/contracts/cache.test.ts": 218, - "src/app/databaseMaintenance.test.ts": 215, - "src/server/platform/chat/descriptorOpenClawLocalHistoryMediaFetcher.test.ts": 214, - "src/test/support/asyncCleanupStack.test.ts": 204, - "src/server/platform/realtime/eventPumpContract.test.ts": 201, - "src/server/domains/gatewaySessions/procedures.test.ts": 200, - "scripts/runTestSuite.test.ts": 199, - "src/test/integration/resourceBudgets/resourceBudgetOrchestration.test.ts": 198, - "src/server/domains/gatewaySessions/controlAudit.test.ts": 195, - "src/server/domains/gatewaySessions/controlAuditStore.test.ts": 195, - "src/server/domains/moltbook/provider.test.ts": 195, - "src/server/platform/errors/safeFailure.test.ts": 195, - "src/server/platform/chat/chatSpeechRecording.test.ts": 193, - "src/server/platform/configuration/workerConfiguration.test.ts": 191, - "src/server/domains/security/mfa/totp.test.ts": 185, - "scripts/development/developmentRemoteProxy.test.ts": 184, - "src/server/domains/gatewayConnection/procedures.test.ts": 183, - "src/server/domains/openClawSettings/configurationBackupRawHttp.test.ts": 182, - "src/server/domains/jobs/workerRuntime.test.ts": 181, - "src/server/domains/realtime/authenticationLeaseStream.test.ts": 181, - "src/server/domains/monitoring/testSupport/services.test.ts": 180, - "src/server/domains/openClawTasks/realtime.test.ts": 179, - "src/server/domains/realtime/errors.test.ts": 176, - "src/server/platform/gateway/persistentGatewaySessionsProvider.test.ts": 176, - "src/test/integration/resources/runSseMemoryEvidence.test.ts": 172, - "src/server/platform/chat/inMemoryChatAttachmentStore.test.ts": 170, - "src/worker/logs/developmentLogMaintenance.test.ts": 168, - "src/server/platform/gateway/persistentGatewayOpenClawTasksProvider.test.ts": 164, - "scripts/delivery/productionRuntime.test.ts": 159, - "src/server/database/validation/rowSchemas.test.ts": 159, - "src/server/database/schema/webauthnPersistence.test.ts": 156, - "scripts/delivery/activateProductionRelease.test.ts": 155, - "src/contracts/gatewaySessions.test.ts": 150, - "src/worker/developmentTaskNotifications.test.ts": 150, - "src/server/platform/release/runtimeRelease.test.ts": 148, - "src/worker/files/workspaceFileReplaceIntent.test.ts": 141, - "src/server/database/validation/incidents.test.ts": 139, - "src/server/domains/jobs/logMaintenanceQueue.test.ts": 139, - "scripts/buildSourceIdentity.test.ts": 138, - "src/server/database/validation/realtimeEvents.test.ts": 138, - "scripts/delivery/productionStateFilesystem.test.ts": 135, - "src/server/database/validation/authChallenges.test.ts": 135, - "src/worker/taskNotifications.test.ts": 135, - "src/contracts/files.test.ts": 132, - "src/server/domains/cache/heartbeatProjection.test.ts": 132, - "scripts/documentation/jsonSchema.test.ts": 129, - "src/contracts/webauthn.test.ts": 129, - "src/server/domains/system/systemMetricsCollector.test.ts": 129, - "src/server/domains/monitoring/normalization.test.ts": 128, - "src/server/domains/gatewaySessions/service.test.ts": 126, - "src/server/domains/files/rawHttp.test.ts": 124, - "src/server/domains/agents/gatewayAvailability.test.ts": 120, - "src/server/domains/jobs/scheduleTime.test.ts": 119, - "src/server/domains/openClawSettings/restartQueue.test.ts": 118, - "src/server/platform/chat/elevenLabsSpeechProvider.test.ts": 118, - "src/server/database/validation/reports.test.ts": 117, - "src/contracts/jobRealtime.test.ts": 116, - "src/server/domains/gatewayConnection/service.test.ts": 116, - "src/server/domains/chat/history.test.ts": 112, - "src/server/rawHttp/frontendAssets.test.ts": 112, - "src/contracts/automationSecurity.test.ts": 110, - "scripts/delivery/buildAdmission.test.ts": 108, - "src/server/platform/logs/sourceCatalog.test.ts": 106, - "scripts/development/developmentTailscale.test.ts": 105, - "src/server/domains/files/jobScheduler.test.ts": 105, - "scripts/delivery/productionDeliveryFilesystem.test.ts": 103, - "src/server/platform/observability/structuredLogger.test.ts": 103, - "src/server/domains/logs/service.test.ts": 102, - "src/contracts/reports.test.ts": 101, - "src/worker/terminal/terminalBrokerServer.test.ts": 101, - "src/server/database/validation/automationPrincipals.test.ts": 98, - "src/contracts/contractRegistry.test.ts": 97, - "src/server/database/validation/authPendingLogins.test.ts": 96, - "src/server/domains/openClawCron/expiryReconciler.test.ts": 94, - "src/server/domains/openClawSettings/service.test.ts": 94, - "scripts/delivery/releaseArtifactInventory.test.ts": 93, - "src/contracts/schedules.test.ts": 92, - "src/contracts/cacheRealtime.test.ts": 90, - "src/server/platform/chat/inMemoryChatMediaReferences.test.ts": 90, - "src/server/database/validation/incidentObservations.test.ts": 89, - "src/server/database/validation/userRecoveryCodes.test.ts": 89, - "src/server/platform/logs/redaction.test.ts": 88, - "src/server/platform/observability/projectFileLogSink.test.ts": 88, - "src/server/rawHttp/terminalSocket.test.ts": 87, - "src/test/integration/files/boundedFile.test.ts": 86, - "src/contracts/agents.test.ts": 84, - "src/contracts/jobModel.test.ts": 84, - "src/app/dashboardChatRuntimeMaintenance.test.ts": 83, - "src/server/domains/security/authenticationPolicy.test.ts": 83, - "src/contracts/events.test.ts": 82, - "src/server/platform/logs/logRotationEpochProbe.test.ts": 82, - "src/contracts/tasks.test.ts": 81, - "src/server/database/validation/userTotpFactors.test.ts": 81, - "src/contracts/jobs.test.ts": 80, - "src/server/database/validation/cacheEntries.test.ts": 78, - "src/server/domains/security/mfa/recoveryCodes.test.ts": 78, - "src/server/database/schema/cacheEntries.test.ts": 77, - "src/server/platform/terminal/terminalBrokerClient.test.ts": 77, - "scripts/development/developmentProxy.test.ts": 76, - "src/contracts/notifications.test.ts": 76, - "src/server/domains/security/mfa/totpSecretCipher.test.ts": 76, - "src/contracts/agentModel.test.ts": 75, - "src/contracts/moltbook.test.ts": 75, - "src/contracts/taskModel.test.ts": 74, - "src/server/domains/system/healthDiagnosticsService.test.ts": 74, - "src/worker/terminal/workerTerminalBrokerLifecycle.test.ts": 74, - "src/server/database/schema/automationPersistence.test.ts": 73, - "src/server/domains/openClawSettings/configurationBackup.test.ts": 73, - "src/server/platform/files/workspaceFileRootConfiguration.test.ts": 73, - "src/worker/terminal/ptyProcess.test.ts": 73, - "src/contracts/chatModel.test.ts": 72, - "src/shared/validation.test.ts": 72, - "src/server/shared/crypto.test.ts": 71, - "src/contracts/logs.test.ts": 70, - "src/server/database/validation/userWebAuthnCredentials.test.ts": 70, - "src/server/domains/terminal/service.test.ts": 70, - "src/server/platform/logs/logMaintenanceAvailability.test.ts": 70, - "src/test/integration/resourceBudgets/resourceBudgetPolicy.test.ts": 70, - "scripts/runCoverage.test.ts": 69, - "src/server/domains/cache/systemHostProvider.test.ts": 69, - "src/test/integration/resources/cgroupV2.test.ts": 69, - "src/worker/terminal/terminalSessionBroker.test.ts": 69, - "src/contracts/accountSecurity.test.ts": 68, - "src/server/domains/security/audit.test.ts": 68, - "src/server/domains/jobs/sideEffects.test.ts": 67, - "src/server/domains/security/mfa/webauthn/credentialState.test.ts": 66, - "src/shared/dateTime.test.ts": 66, - "src/worker/files/openClawFileRootConfiguration.test.ts": 66, - "src/worker/logs/logMaintenanceExecutor.test.ts": 66, - "src/server/domains/tasks/taskNotification.test.ts": 65, - "src/server/platform/files/openClawFileRootConfiguration.test.ts": 65, - "src/test/integration/resources/sseMemoryEvidence.test.ts": 65, - "src/server/database/validation/authSessions.test.ts": 64, - "src/server/database/validation/automationCredentials.test.ts": 64, - "src/server/database/validation/automationPrincipalCapabilities.test.ts": 64, - "src/server/database/validation/notifications.test.ts": 64, + "src/server/domains/realtime/transport.test.ts": 243, + "src/server/domains/security/requestAuthenticationSession.test.ts": 240, + "src/server/platform/realtime/renewableStreamLease.test.ts": 240, + "src/server/domains/files/rawHttp.test.ts": 237, + "src/server/domains/jobs/actionExecutors.test.ts": 237, + "scripts/delivery/productionStateFilesystem.test.ts": 235, + "src/server/database/migrations/securityIdentitySchema.baseline.test.ts": 234, + "src/server/domains/files/jobScheduler.test.ts": 233, + "src/server/domains/security/authPendingMfaRoutes.webAuthn.test.ts": 232, + "src/server/database/migrations/securityIdentitySchema.automation.test.ts": 227, + "src/server/database/migrations/realtimeSchema.test.ts": 226, + "src/server/domains/jobs/procedures.test.ts": 225, + "src/server/domains/jobs/serviceActionQueue.test.ts": 225, + "src/server/domains/chat/procedures.test.ts": 224, + "src/server/domains/serviceActions/procedures.test.ts": 222, + "src/server/database/migrations/securityIdentitySchema.browser.test.ts": 219, + "src/server/domains/logs/service.test.ts": 218, + "src/server/domains/gatewayConnection/procedures.test.ts": 217, + "src/server/platform/gateway/gatewayCredentialVerifier.test.ts": 214, + "src/test/support/asyncCleanupStack.test.ts": 212, + "scripts/documentation/artifacts.test.ts": 202, + "src/server/domains/serviceActions/service.test.ts": 199, + "src/contracts/serviceActions.test.ts": 196, + "src/server/database/validation/rowSchemaIntegration.test.ts": 196, + "src/server/platform/gateway/persistentGatewayRealtimeBridge.test.ts": 193, + "src/server/database/runtime/databasePath.test.ts": 192, + "src/server/platform/realtime/eventPumpServicePolling.test.ts": 192, + "src/server/platform/gateway/persistentGatewayOpenClawTasksProvider.test.ts": 188, + "scripts/delivery/activateProductionRelease.test.ts": 187, + "src/server/domains/realtime/errors.test.ts": 187, + "src/server/platform/chat/descriptorOpenClawLocalHistoryMediaFetcher.test.ts": 186, + "src/worker/system/systemHostOperationsProvisioning.test.ts": 186, + "src/server/platform/release/developmentRuntimeRelease.test.ts": 183, + "src/server/platform/realtime/eventPumpServiceLifecycle.test.ts": 182, + "src/server/domains/openClawCron/sqliteIntentStore.test.ts": 177, + "src/server/platform/configuration/workerConfiguration.test.ts": 177, + "src/server/domains/openClawCron/service.test.ts": 176, + "src/server/domains/chat/history.test.ts": 175, + "scripts/documentation/jsonSchema.test.ts": 172, + "src/server/domains/openClawSettings/operationAudit.test.ts": 172, + "src/server/platform/observability/effectLogger.test.ts": 171, + "src/server/domains/openClawCron/expiryReconciler.test.ts": 168, + "src/server/database/schema/webauthnPersistence.test.ts": 165, + "src/server/domains/security/mfa/webauthn/adapter.test.ts": 164, + "src/contracts/openClawSettings.test.ts": 163, + "src/server/domains/jobs/sideEffects.test.ts": 163, + "src/server/domains/openClawTasks/realtime.test.ts": 163, + "src/server/domains/openClawSettings/service.test.ts": 160, + "src/server/platform/observability/structuredLogger.test.ts": 158, + "src/server/database/validation/automationCredentials.test.ts": 156, + "src/app/trpcRequestPolicy.test.ts": 155, + "src/server/domains/jobs/scheduleTime.test.ts": 152, + "src/server/domains/agents/gatewayAvailability.test.ts": 150, + "src/contracts/taskModel.test.ts": 149, + "src/server/platform/chat/inMemoryChatAttachmentStore.test.ts": 149, + "src/test/integration/transport/topology/proxyTransport.test.ts": 146, + "src/server/database/validation/rowSchemas.test.ts": 143, + "scripts/development/developmentRemoteProxy.test.ts": 141, + "src/server/database/validation/cacheEntries.test.ts": 139, + "src/server/domains/openClawSettings/restartQueue.test.ts": 139, + "src/test/integration/resourceBudgets/resourceBudgetOrchestration.test.ts": 139, + "src/contracts/notifications.test.ts": 133, + "scripts/development/developmentTailscale.test.ts": 132, + "src/server/platform/realtime/eventPumpContract.test.ts": 131, + "src/server/platform/gateway/persistentGatewayOpenClawServiceActionsProvider.test.ts": 130, + "src/contracts/openClawCron.test.ts": 128, + "src/server/database/validation/monitorRuns.test.ts": 128, + "src/worker/files/workspaceFileReplaceIntent.test.ts": 128, + "scripts/delivery/buildAdmission.test.ts": 126, + "src/worker/developmentTaskNotifications.test.ts": 126, + "src/contracts/terminal.test.ts": 123, + "src/server/domains/gatewaySessions/procedures.test.ts": 122, + "src/server/database/validation/authPendingLogins.test.ts": 121, + "src/server/domains/jobs/actionRegistry.test.ts": 120, + "scripts/delivery/productionDeliveryFilesystem.test.ts": 118, + "src/worker/files/openClawFileRootConfiguration.test.ts": 117, + "src/server/database/schema/cacheEntries.test.ts": 116, + "src/server/domains/serviceActions/statusReader.test.ts": 116, + "src/server/platform/chat/chatSpeechRecording.test.ts": 116, + "src/test/integration/files/boundedFile.test.ts": 115, + "src/server/domains/gatewaySessions/service.test.ts": 114, + "src/server/domains/realtime/authenticationLeaseStream.test.ts": 113, + "src/server/platform/logs/sourceCatalog.test.ts": 111, + "src/server/database/validation/automationPrincipals.test.ts": 110, + "scripts/checkCoverage.test.ts": 109, + "src/contracts/cache.test.ts": 109, + "src/server/platform/logs/logMaintenanceAvailability.test.ts": 109, + "scripts/development/developmentProxy.test.ts": 107, + "src/server/platform/logs/redaction.test.ts": 106, + "src/server/platform/observability/projectFileLogSink.test.ts": 106, + "src/server/rawHttp/terminalSocket.test.ts": 106, + "src/contracts/contractRegistry.test.ts": 104, + "src/contracts/jobModel.test.ts": 104, + "src/server/domains/cache/heartbeatProjection.test.ts": 99, + "src/server/domains/openClawSettings/configurationBackup.test.ts": 99, + "src/server/database/validation/users.test.ts": 98, + "src/server/database/validation/taskRows.test.ts": 97, + "src/server/rawHttp/sessionCookie.test.ts": 97, + "src/server/platform/runtime/processSignals.test.ts": 96, + "src/shared/developmentProcessSupport.test.ts": 96, + "src/server/database/validation/userTotpFactors.test.ts": 93, + "src/server/database/validation/reports.test.ts": 92, + "src/worker/logs/developmentLogMaintenance.test.ts": 92, + "src/server/domains/gatewaySessions/controlAudit.test.ts": 90, + "src/worker/system/fixedHostOperationsBroker.test.ts": 88, + "src/server/database/validation/auditEvents.test.ts": 87, + "src/worker/logs/logMaintenanceExecutor.test.ts": 87, + "src/contracts/openClawTasks.test.ts": 86, + "src/server/database/validation/notifications.test.ts": 86, + "src/server/domains/system/healthDiagnosticsService.test.ts": 86, + "scripts/delivery/releaseArtifactInventory.test.ts": 85, + "src/server/platform/release/runtimeRelease.test.ts": 84, + "src/contracts/files.test.ts": 83, + "src/server/platform/chat/inMemoryChatMediaReferences.test.ts": 83, + "src/worker/terminal/workerTerminalBrokerLifecycle.test.ts": 83, + "src/server/database/validation/incidentObservations.test.ts": 82, + "src/server/domains/terminal/service.test.ts": 82, + "src/test/integration/resources/resourcePolicy.test.ts": 82, + "src/test/integration/resources/sseMemoryEvidence.test.ts": 81, + "src/worker/terminal/terminalSessionBroker.test.ts": 81, + "src/contracts/auth.test.ts": 80, + "src/server/domains/chat/coalescer.test.ts": 80, + "src/server/platform/files/workspaceFileRootConfiguration.test.ts": 80, + "src/worker/terminal/terminalBrokerServer.test.ts": 80, + "src/server/database/validation/incidents.test.ts": 79, + "src/server/domains/cache/systemHostProvider.test.ts": 79, + "src/contracts/gatewaySessions.test.ts": 78, + "src/contracts/jobRealtime.test.ts": 78, + "src/contracts/jobs.test.ts": 78, + "src/contracts/system.test.ts": 78, + "src/server/rawHttp/frontendAssets.test.ts": 78, + "scripts/delivery/prepareProductionState.test.ts": 77, + "src/test/integration/resources/pausedTlsSseHandshake.test.ts": 77, + "src/worker/terminal/ptyProcess.test.ts": 76, + "src/contracts/logs.test.ts": 75, + "src/server/domains/database/service.test.ts": 74, + "src/server/domains/security/audit.test.ts": 73, + "scripts/documentation/files.test.ts": 72, + "src/contracts/chatSpeech.test.ts": 72, + "src/contracts/reports.test.ts": 72, + "src/server/domains/security/mfa/webauthn/credentialState.test.ts": 72, + "src/server/domains/security/recentAuthentication.test.ts": 71, + "src/server/platform/terminal/rootRegistry.test.ts": 71, + "scripts/development/developmentPrivateFile.test.ts": 70, + "src/contracts/database.test.ts": 70, + "src/server/platform/logs/logRotationEpochProbe.test.ts": 70, + "src/server/domains/database/snapshotRepository.test.ts": 69, + "src/shared/validation.test.ts": 69, + "scripts/delivery/systemctlProcess.test.ts": 68, + "src/server/database/validation/authRateLimitBuckets.test.ts": 68, + "src/server/database/validation/automationPrincipalCapabilities.test.ts": 68, + "src/contracts/monitoring.test.ts": 67, + "src/contracts/chatModel.test.ts": 66, + "src/server/database/validation/authSessions.test.ts": 66, + "src/server/domains/openClawSettings/configurationBackupRawHttp.test.ts": 66, + "src/test/integration/transport/realtime/eventFeed.test.ts": 66, + "src/contracts/security.test.ts": 65, + "src/server/database/validation/authChallenges.test.ts": 65, + "src/server/database/validation/userWebAuthnCredentials.test.ts": 65, + "src/server/domains/security/authenticationResolution.test.ts": 65, + "src/server/domains/security/authenticationSession.test.ts": 65, + "src/server/platform/terminal/terminalBrokerClient.test.ts": 65, "src/server/platform/runtime/readRuntimeIdentity.test.ts": 64, - "src/server/rawHttp/pendingLoginCookie.test.ts": 64, - "scripts/development/developmentPrivateFile.test.ts": 63, - "src/worker/files/workspaceFileRootConfiguration.test.ts": 63, - "src/server/database/validation/taskRows.test.ts": 62, - "src/server/domains/security/authenticationSession.test.ts": 62, - "src/server/platform/terminal/rootRegistry.test.ts": 62, - "src/server/database/validation/authRateLimitBuckets.test.ts": 61, - "src/contracts/gatewayConnection.test.ts": 60, - "src/shared/chatStreamText.test.ts": 60, - "src/server/domains/openClawTasks/service.test.ts": 59, - "src/test/integration/transport/topology/proxyTransport.test.ts": 59, - "src/contracts/chatMedia.test.ts": 55, - "src/shared/configRedaction.test.ts": 55, - "src/server/platform/gateway/chatTranscriptLifecycleSupervisor.test.ts": 54, - "src/worker/logs/systemLogrotateProvisioning.test.ts": 54, - "src/test/integration/resources/resourcePolicy.test.ts": 53, - "src/contracts/monitoring.test.ts": 52, - "src/contracts/system.test.ts": 52, - "scripts/checkCoverage.test.ts": 51, - "src/server/domains/chat/subscriptionManager.test.ts": 51, - "src/server/domains/system/systemMetricsService.test.ts": 51, - "src/server/rawHttp/authenticationCredentials.test.ts": 51, - "src/test/integration/resources/unitIdentity.test.ts": 51, - "src/server/platform/realtime/boundedAsyncQueue.test.ts": 50, - "src/server/rawHttp/authenticationClientSource.test.ts": 50, - "src/contracts/securityAudit.test.ts": 49, - "src/server/platform/filesystem/projectLayout.test.ts": 49, - "src/shared/json.test.ts": 49, - "src/server/domains/security/recentAuthentication.test.ts": 48, - "src/test/integration/resources/pausedTlsSseHandshake.test.ts": 47, - "scripts/development/developmentFrontendConfig.test.ts": 46, - "src/server/rawHttp/requestSecurity.test.ts": 46, - "scripts/runStorybookTests.test.ts": 45, - "src/server/platform/configuration/configurationRegistry.test.ts": 45, - "src/test/integration/resources/processMemory.test.ts": 45, - "src/contracts/terminal.test.ts": 44, - "src/server/platform/terminal/bunUnixTerminalBrokerTransport.test.ts": 44, - "src/test/integration/transport/realtime/eventFeed.test.ts": 44, - "scripts/packageIdentity.test.ts": 43, - "src/server/domains/security/authenticationResolution.test.ts": 43, - "src/server/platform/gateway/gatewayCredentialProtocol.test.ts": 43, - "src/server/rawHttp/sessionCookie.test.ts": 43, - "src/test/integration/resources/cgroupV2Hierarchy.test.ts": 43, - "src/server/shared/opaqueToken.test.ts": 42, - "src/test/integration/resources/systemdLauncher.test.ts": 42, - "src/contracts/chatSpeech.test.ts": 40, - "src/server/platform/release/developmentRuntimeRelease.test.ts": 40, - "scripts/delivery/systemctlProcess.test.ts": 39, - "scripts/development/developmentTailscaleLock.test.ts": 39, - "src/server/database/migrations/verifyDatabaseIntegrity.test.ts": 38, - "src/server/platform/runtime/processSignals.test.ts": 38, - "src/shared/encoding.test.ts": 38, - "src/shared/releaseManifest.test.ts": 38, - "src/server/domains/files/uploadContentGuard.test.ts": 36, + "src/server/rawHttp/authenticationClientSource.test.ts": 64, + "src/contracts/accountSecurity.test.ts": 63, + "src/contracts/cacheRealtime.test.ts": 62, + "src/server/domains/system/systemMetricsService.test.ts": 62, + "src/test/integration/resources/cgroupV2.test.ts": 61, + "src/contracts/schedules.test.ts": 60, + "src/server/domains/chat/subscriptionManager.test.ts": 60, + "src/server/database/validation/userRecoveryCodes.test.ts": 59, + "src/server/rawHttp/pendingLoginCookie.test.ts": 59, + "src/server/shared/crypto.test.ts": 59, + "src/shared/chatStreamText.test.ts": 59, + "src/contracts/gatewayConnection.test.ts": 58, + "src/contracts/tasks.test.ts": 58, + "src/test/integration/resources/unitIdentity.test.ts": 58, + "scripts/runStorybookTests.test.ts": 57, + "src/server/domains/security/mfa/totp.test.ts": 57, + "src/server/shared/opaqueToken.test.ts": 57, + "src/shared/dateTime.test.ts": 56, + "src/contracts/moltbook.test.ts": 55, + "src/contracts/agentModel.test.ts": 54, + "src/server/database/schema/automationPersistence.test.ts": 52, + "src/contracts/chatMedia.test.ts": 51, + "src/contracts/securityAudit.test.ts": 50, + "src/server/domains/openClawTasks/service.test.ts": 49, + "src/server/platform/configuration/configurationRegistry.test.ts": 49, + "src/server/platform/files/openClawFileRootConfiguration.test.ts": 48, + "src/server/platform/gateway/gatewayCredentialProtocol.test.ts": 48, + "src/test/integration/resources/cgroupV2Hierarchy.test.ts": 48, + "src/worker/database/fixedSqliteLifecycleMaintenance.test.ts": 48, + "src/server/platform/terminal/bunUnixTerminalBrokerTransport.test.ts": 47, + "src/server/database/validation/realtimeEvents.test.ts": 46, + "src/shared/releaseManifest.test.ts": 46, + "src/test/integration/resources/processMemory.test.ts": 46, + "scripts/development/developmentTailscaleLock.test.ts": 45, + "src/server/rawHttp/authenticationCredentials.test.ts": 45, + "src/test/integration/runtime/runtimeCandidate.test.ts": 45, + "scripts/checkDatabaseSchema.test.ts": 44, + "src/server/domains/system/systemMetricsCollector.test.ts": 44, + "src/worker/database/fixedDatabaseObservabilityReconciler.test.ts": 44, + "src/test/integration/resourceBudgets/resourceBudgetPolicy.test.ts": 43, + "scripts/development/tanStackRouterHmrWorkaroundPlugin.test.ts": 41, + "src/server/domains/security/authenticationPolicy.test.ts": 41, + "src/server/domains/tasks/taskNotification.test.ts": 40, + "src/worker/files/workspaceFileRootConfiguration.test.ts": 40, + "scripts/development/developmentFrontendConfig.test.ts": 39, + "src/shared/configRedaction.test.ts": 39, + "src/shared/json.test.ts": 39, + "src/test/integration/resources/systemdLauncher.test.ts": 39, + "src/contracts/agents.test.ts": 38, + "src/server/domains/security/mfa/totpSecretCipher.test.ts": 38, + "src/server/platform/realtime/boundedAsyncQueue.test.ts": 38, + "src/server/rawHttp/requestSecurity.test.ts": 37, + "src/worker/logs/fixedSystemLogrotateBroker.test.ts": 37, + "src/worker/openClaw/gatewayLifecycle.test.ts": 37, "src/test/setup.test.ts": 36, - "src/shared/developmentProcessSupport.test.ts": 35, - "src/server/domains/security/authenticationWorkBudget.test.ts": 34, - "src/worker/openClaw/gatewayLifecycle.test.ts": 33, - "src/test/integration/runtime/runtimeCandidate.test.ts": 29, - "src/worker/terminal/bunTerminalBrokerIpc.test.ts": 29, - "scripts/documentation/routesMarkdown.test.ts": 27, - "src/worker/logs/fixedSystemLogrotateBroker.test.ts": 27, - "scripts/delivery/prepareProductionState.test.ts": 26, - "src/contracts/security.test.ts": 25, - "src/server/domains/security/mfa/webauthn/relyingPartyConfiguration.test.ts": 24, - "scripts/checkDatabaseSchema.test.ts": 20, - "scripts/delivery/logMaintenanceProvisioningPolicy.test.ts": 14, - "scripts/development/developmentStateAcquisitionLock.test.ts": 13, - "scripts/documentation/configurationMarkdown.test.ts": 13, - "src/server/platform/gateway/openClawTasksSubscriptionSupervisor.test.ts": 13 + "scripts/documentation/routesMarkdown.test.ts": 35, + "scripts/sourceBoundaries/policy.test.ts": 35, + "src/server/domains/gatewayConnection/service.test.ts": 35, + "src/server/domains/security/mfa/recoveryCodes.test.ts": 34, + "src/server/platform/filesystem/projectLayout.test.ts": 33, + "src/server/platform/gateway/chatTranscriptLifecycleSupervisor.test.ts": 32, + "src/shared/encoding.test.ts": 32, + "src/worker/terminal/bunTerminalBrokerIpc.test.ts": 32, + "scripts/packageIdentity.test.ts": 31, + "src/server/platform/gateway/openClawTasksSubscriptionSupervisor.test.ts": 31, + "src/worker/logs/systemLogrotateProvisioning.test.ts": 31, + "scripts/runCoverage.test.ts": 30, + "src/contracts/webauthn.test.ts": 30, + "scripts/development/developmentStateAcquisitionLock.test.ts": 29, + "src/server/database/migrations/verifyDatabaseIntegrity.test.ts": 29, + "scripts/delivery/hostOperationsProvisioningPolicy.test.ts": 27, + "scripts/delivery/logMaintenanceProvisioningPolicy.test.ts": 25, + "scripts/testOutputPolicy.test.ts": 25, + "src/app/dashboardChatRuntimeMaintenance.test.ts": 24, + "src/server/domains/security/authenticationWorkBudget.test.ts": 22, + "src/server/domains/security/mfa/webauthn/relyingPartyConfiguration.test.ts": 21, + "scripts/documentation/configurationMarkdown.test.ts": 20, + "src/server/domains/files/uploadContentGuard.test.ts": 20 }, "version": 1 } diff --git a/greenfield/bun.lock b/greenfield/bun.lock index 4d8d5347d..c55681996 100644 --- a/greenfield/bun.lock +++ b/greenfield/bun.lock @@ -16,11 +16,11 @@ "@tanstack/query-core": "5.101.4", "@tanstack/query-db-collection": "1.2.1", "@tanstack/react-db": "0.1.95", - "@tanstack/react-form": "^1.33.4", + "@tanstack/react-form": "^1.33.5", "@tanstack/react-query": "^5.101.4", - "@tanstack/react-router": "^1.170.23", + "@tanstack/react-router": "^1.170.27", "@tanstack/react-store": "0.11.1", - "@tanstack/react-table": "^9.1.1", + "@tanstack/react-table": "^9.1.2", "@tanstack/react-virtual": "^3.14.9", "@trpc/client": "11.18.0", "@trpc/server": "11.18.0", @@ -31,9 +31,9 @@ "clsx": "^2.1.1", "date-fns": "^4.4.0", "drizzle-orm": "1.0.0-rc.4", - "effect": "4.0.0-beta.106", + "effect": "4.0.0-beta.107", "json5": "^2.2.3", - "lucide-react": "^1.30.0", + "lucide-react": "^1.31.0", "otplib": "13.4.1", "qrcode.react": "4.2.0", "react": "^19.2.8", @@ -53,19 +53,19 @@ "devDependencies": { "@babel/core": "^8.0.1", "@happy-dom/global-registrator": "^20.11.2", - "@storybook/addon-a11y": "10.6.0-alpha.4", - "@storybook/addon-docs": "10.6.0-alpha.4", - "@storybook/addon-vitest": "10.6.0-alpha.4", - "@storybook/tanstack-react": "10.6.0-alpha.4", + "@storybook/addon-a11y": "10.6.0-alpha.5", + "@storybook/addon-docs": "10.6.0-alpha.5", + "@storybook/addon-vitest": "10.6.0-alpha.5", + "@storybook/tanstack-react": "10.6.0-alpha.5", "@tailwindcss/vite": "4.3.3", - "@tanstack/react-devtools": "^0.10.9", - "@tanstack/react-form-devtools": "^0.2.33", + "@tanstack/react-devtools": "^0.10.10", + "@tanstack/react-form-devtools": "^0.2.34", "@tanstack/react-query-devtools": "^5.101.4", "@tanstack/react-router-devtools": "1.167.1", "@testing-library/dom": "^10.4.1", - "@testing-library/jest-dom": "^7.0.0", + "@testing-library/jest-dom": "^7.0.1", "@testing-library/react": "^16.3.2", - "@testing-library/user-event": "^14.6.3", + "@testing-library/user-event": "^14.6.4", "@types/babel__core": "^7.20.5", "@types/node": "26.2.0", "@types/react": "^19.2.18", @@ -77,18 +77,18 @@ "bun-plugin-tailwind": "^0.1.2", "bun-types": "1.4.0-canary.20260519T150915", "drizzle-kit": "1.0.0-rc.4", - "eventsource": "4.1.1", + "eventsource": "5.0.0", "happy-dom": "^20.11.2", "jsdom-testing-mocks": "^1.16.0", "jsonc-parser": "3.3.1", "lcov-result-merger": "^6.0.0", - "oxfmt": "^0.62.0", - "oxlint": "^1.77.0", - "oxlint-config-presets": "^0.1.18", - "oxlint-tailwindcss": "^1.7.1", + "oxfmt": "^0.63.0", + "oxlint": "^1.78.0", + "oxlint-config-presets": "^0.1.19", + "oxlint-tailwindcss": "^1.9.0", "oxlint-tsgolint": "^7.0.2001", "playwright": "1.62.1", - "storybook": "10.6.0-alpha.4", + "storybook": "10.6.0-alpha.5", "tailwindcss": "^4.3.3", "typescript": "^7.0.2", "vite": "8.2.1", @@ -267,6 +267,10 @@ "@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.2.2", "", { "dependencies": { "@tybys/wasm-util": "^0.10.3" }, "peerDependencies": { "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.3", "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.3" } }, "sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw=="], + "@neodrag/core": ["@neodrag/core@3.0.0-next.11", "", {}, "sha512-3WQWxyrbxiaK9zS5JU2wJsW2gpoQlZBXVghduBh61JpqaeE0T0cte8R0qYK2RuJo3J2TYQYqxO19CpG/C1i5eg=="], + + "@neodrag/solid": ["@neodrag/solid@3.0.0-next.11", "", { "peerDependencies": { "@neodrag/core": "3.0.0-next.11", "solid-js": "^1.0.0" } }, "sha512-vCBIn/pimjWMQ6vhTS2/O1XNAwzVtc4eUhdbQ91WykbZWWqQ5NocDXt/1OdYrEkeRzJcpCv8wEz5PnMkgKP81Q=="], + "@noble/hashes": ["@noble/hashes@2.3.0", "", {}, "sha512-oN+QwyX7VSHotibwubG3kpzbwKrfnyR6OOO+3Nk/53ADL7FmgHHz4TgrbaYKvvOw09u6QTx0oiH1cNCIOuN0CQ=="], "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], @@ -399,43 +403,43 @@ "@oxc-resolver/binding-win32-x64-msvc": ["@oxc-resolver/binding-win32-x64-msvc@11.24.2", "", { "os": "win32", "cpu": "x64" }, "sha512-UqGPmo56KDfLlfXFAFIrNflHT8tFxWGEivWg3Zeyp4Uy2NlKN1FGPr6/BxcLGG3+kZ6Wp14g5Uj+n71boqZfiw=="], - "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.62.0", "", { "os": "android", "cpu": "arm" }, "sha512-pdsv0C4gPjJ8H1+sd8u0BDx+yLACTL+rgeMIOL1ln4ihSnhw8CWXtYWgvcSkyTfgGBIzFKab+d8rx9Xl4en/Kw=="], + "@oxfmt/binding-android-arm-eabi": ["@oxfmt/binding-android-arm-eabi@0.63.0", "", { "os": "android", "cpu": "arm" }, "sha512-YmRth4ZPGgEXcgmkhvANbC9uD67dxmSobW7DQuyt5tOBOKvPnIpk5SVHBj88E+7wMNRI2FhqaDbOhQFBix+b8A=="], - "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.62.0", "", { "os": "android", "cpu": "arm64" }, "sha512-WC3YQ7uS/KtDrjmqwBviwFKe9qeoi+eXx8aX1z/ffG23Md75myjrJaQqTuJvdOLPoa4EYTjDWH0dHXfwulCVog=="], + "@oxfmt/binding-android-arm64": ["@oxfmt/binding-android-arm64@0.63.0", "", { "os": "android", "cpu": "arm64" }, "sha512-icbahX8X2X3sRamOMecvdYeZXWjPDazRDIfvWfy7Ca1nc/ZDT2Y9k5Nt7s46EqFd7NQPdgk+CM3/SgIT5LPCaQ=="], - "@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.62.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-GM8Yf3LjjaR1I8PD0SfeoIlwhsh9GvSF+cQ8sf624Yxnjsyumn95aFzYfKJVefblfDIiOAnZ7QVm2sa21Er/0Q=="], + "@oxfmt/binding-darwin-arm64": ["@oxfmt/binding-darwin-arm64@0.63.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-WV+Ze5v5gI2qoj8jpAovt8KBTW8pjEz/AiMXXjeTQS+Bmf/MmZXTS40S8xNPDszX+W8WDv2Bbk6qKrMTtUGu1A=="], - "@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.62.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-d5THp7F8bCxLqNogEXDORRsQD6dosf3EyFtnXfBer6v+8tGdcWIjoDX9WaXrrF/26zOmL8qHpPTKCEvpBDmZkQ=="], + "@oxfmt/binding-darwin-x64": ["@oxfmt/binding-darwin-x64@0.63.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-CJGSBdDxXOWIpoFXHpverimCvz084KA7L483rqJ44c3jDtzv6d4qOSoR/V9ywSHfV+Ks1lwIj2P49BFhunLNAA=="], - "@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.62.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-1DnrtXGZooOZ0fHgAXZUaDQzBVh1CM2MNW4oBXyQ2aWKvCHjyljvT9fgBkOM0fEOb96X5eqtcfJ0YUVt9jj66g=="], + "@oxfmt/binding-freebsd-x64": ["@oxfmt/binding-freebsd-x64@0.63.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-BDfKY+KhL2078cgswBBFQPAYuxCy93bS/iC5frdSeSbTLcGrR6VC2hsuPTanoJmg84+wSyWl0wWC1eR+uTnkRg=="], - "@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.62.0", "", { "os": "linux", "cpu": "arm" }, "sha512-4pQDHOYRH+Huqe0StIaWyvk2CVl/aTaqSrbZpA3/pLS2xH24ME7lBgYprhQF2fRkHBzhGGGKliwxFsDdHwx59g=="], + "@oxfmt/binding-linux-arm-gnueabihf": ["@oxfmt/binding-linux-arm-gnueabihf@0.63.0", "", { "os": "linux", "cpu": "arm" }, "sha512-Ov1cQEXT4mj7cojAokWSS1eoxkoyvbDfAbxNsGIKY2o36kvdAaFzPxRN6NxFRk9fD72B8oCoTTX/NuYTUWlpsg=="], - "@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.62.0", "", { "os": "linux", "cpu": "arm" }, "sha512-X0jAaZJFMCVKhB6YyWVTQ/wN2DLsBcZKSMqTS76bF6riT+XZdtg2FPEdjDvdVbunO9cG+tWiVaEs4Zs38lxYog=="], + "@oxfmt/binding-linux-arm-musleabihf": ["@oxfmt/binding-linux-arm-musleabihf@0.63.0", "", { "os": "linux", "cpu": "arm" }, "sha512-0LE7ro3+6L79jcMANycAZfRaC7zxr9YZ2+vEL5uMD9QlEep+rS/r1kSJsnuLl991NXJZD60euh0PC1GHrR20vw=="], - "@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.62.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-682Z8T5s8T5ATArYtsejKvbIfd8LEAXyyDkKkoZVq8HND7Vx8TYLlrDjDSeYfodMeVwHOgkj13lJYR8cj6vUSg=="], + "@oxfmt/binding-linux-arm64-gnu": ["@oxfmt/binding-linux-arm64-gnu@0.63.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-izPk+2Z4gjuZK32Fqh5qXoMpT/2NXzLh++ob57HiEiVSQZ1iYXu8EKMzb+K5AvWyIEXhdDIt7ADjGGtFhkT9Bw=="], - "@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.62.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-lk25fAl7KWaLWVJcW0CHEXB7QlQZtx5eDkjpaGMK0hzXTjUe0Wmlu8IKuFHoviSOcEJedRTs4VE/506VqGxGew=="], + "@oxfmt/binding-linux-arm64-musl": ["@oxfmt/binding-linux-arm64-musl@0.63.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-alPmbOuWXFXiSo+lOtv6X71C7SYMEDW2WVvywOvf9BwKgEhSNGhMTLeFVSjKUMCamcjbbgVdsWF8GN1uy8xshg=="], - "@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.62.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-SFyNqHQLwySceWNLhiSldx7wPXRAzP0L0WcW9GegP3uWrpZGJiZlQO85NbHAFPEfxR9PhZ9qSnZryEh7+v+4Gw=="], + "@oxfmt/binding-linux-ppc64-gnu": ["@oxfmt/binding-linux-ppc64-gnu@0.63.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-BdzCPvolJc4AWZ+YMzgUDJcDzbQWrFjYuqBHoNHNqP1aCaluQRJNs4k3vNU5IG7vTpjf9zeD73D7MFM1TecZpg=="], - "@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.62.0", "", { "os": "linux", "cpu": "none" }, "sha512-KYj55C1ywJfHo6+aKDuEmUtVEdJALsC5GwayDGsI6FGz2GxFqNr/mA8nxVsNbJzm7sE5MRqTQ9ziImSzhYXysA=="], + "@oxfmt/binding-linux-riscv64-gnu": ["@oxfmt/binding-linux-riscv64-gnu@0.63.0", "", { "os": "linux", "cpu": "none" }, "sha512-7sIgfLzqtNKSkMGsGVyRpHwpjNezRg2XONvUOheFZs95TSZpM0JAuPpA8KrQFsWc4wPU95roX2O69JgH8igOgw=="], - "@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.62.0", "", { "os": "linux", "cpu": "none" }, "sha512-BhZDNo5GOU5nC378RhD0/XpvaEBHsH3HLgJp8YZX3A0InC7oivzA63HsRmiXFLtLSHAstEVrDf6fbC7Rs8Jh/A=="], + "@oxfmt/binding-linux-riscv64-musl": ["@oxfmt/binding-linux-riscv64-musl@0.63.0", "", { "os": "linux", "cpu": "none" }, "sha512-9Tcg0y0WcVa6Mm9AgcgFMseDS+VkFJZpKZ8We9SpDY4gg5jewSwln+0sO04QLcTS1BtfDl9MwR+NfID8L7PUTg=="], - "@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.62.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-UyAFmyHkgSgUJ/wOM4p3U8AC2yAFvRH5PNBs7TnK0fObTT/XSWcdr/lAzPSWaekHaZFaMeFZyk9n93Joq3J93A=="], + "@oxfmt/binding-linux-s390x-gnu": ["@oxfmt/binding-linux-s390x-gnu@0.63.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-qWKC1pEOpx1qYhXaugPhHUeXwSfqEOk2wJH2LqVXGPV5iQYfdAZdt+d2XDiX4DTSWA2QDMUcFB+wEORh3Xn/sA=="], - "@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.62.0", "", { "os": "linux", "cpu": "x64" }, "sha512-1iYMP0leytWazFubD/WnINJuIrzRPuoL1aWEJdlGezEzDbTxcd29R4r8IUzP2oWeKst5V02uMJgR2NILlPlG6w=="], + "@oxfmt/binding-linux-x64-gnu": ["@oxfmt/binding-linux-x64-gnu@0.63.0", "", { "os": "linux", "cpu": "x64" }, "sha512-S9wXYOiGSqYGS4Fx/TFsY+xDd/7dE5s+rUgbA4TsHiVF9e8J3ZcKmP7dsP/7iqLI9Wz7Ic7TzEr3mdthRCTdrA=="], - "@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.62.0", "", { "os": "linux", "cpu": "x64" }, "sha512-4rA/URtJSTVNVAQz6Q8wf7SaRvOXVy+TizriT9hs/Y1XhLR/R+92uWKRQG8yFWRAIEBbFHJ6WevQcl/G9SXEfw=="], + "@oxfmt/binding-linux-x64-musl": ["@oxfmt/binding-linux-x64-musl@0.63.0", "", { "os": "linux", "cpu": "x64" }, "sha512-5eGyTJuMZNwBSHCivXt8Yuta6GeTYksOPXRk2MIhajiyFGQx7bjaHIwY+ZusAoFHhT157A9x6sktLjYo9D5oMQ=="], - "@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.62.0", "", { "os": "none", "cpu": "arm64" }, "sha512-mSZuFHU2ar1KLUjXpI2QBQcJ1VsOB3mOCgQXuXCpKs19dgh4u+OaovNfrWDfiJb+ihJ2+f7YFcaO9bS2dlTCXA=="], + "@oxfmt/binding-openharmony-arm64": ["@oxfmt/binding-openharmony-arm64@0.63.0", "", { "os": "none", "cpu": "arm64" }, "sha512-Rz7hx+Dv3DoW/S6pwVAyjfFXp7/trdQ1zg+vNmsdsdDNlUccugp4XNqambSuEAeP0DaG9k72AtNyfDXCEg0AGw=="], - "@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.62.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-OfwuhkcjDlqC4EgDojtiV9mzpLqeB9KqTOWPOjLEYBVdDCVSxqW3qzp/xcIxsbtI0UgGCnKvAqYKyY25kf5JZw=="], + "@oxfmt/binding-win32-arm64-msvc": ["@oxfmt/binding-win32-arm64-msvc@0.63.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-T/IuizKN9mr4Xw6YYnptkXRNdLkyIlUZ7c8zfTOBpoytZyJ1BAsMUvsMDEx0X4YvSMpaivm+DR8112rQfzC25g=="], - "@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.62.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-P9uDDNFRzghO3X8QAzhkjKhK7JvtABsVn8UYtFX7uor12IAnwNt8nNIctvfWj1JkQU/kE+fmLRPiw7XlrIHsZw=="], + "@oxfmt/binding-win32-ia32-msvc": ["@oxfmt/binding-win32-ia32-msvc@0.63.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-XjrO5FJ5Wl9vsAxtCP1G/eaeT6y1K2s9CICUHGE42cEjou32/J6S+B1KnrOAboj6E7uhJnwPbRSvznWcxNdA0g=="], - "@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.62.0", "", { "os": "win32", "cpu": "x64" }, "sha512-dlI5SY7XYQCiCBafntWagCR6HcAJB/NpsLtdlPx8x08+Osz8Ok1HHz1GZuusegCe/VoJ6pAnF5a4pd5OZAq7qQ=="], + "@oxfmt/binding-win32-x64-msvc": ["@oxfmt/binding-win32-x64-msvc@0.63.0", "", { "os": "win32", "cpu": "x64" }, "sha512-sgsHCQy432OTQH4Ikk3tZptp3GqwnhwUDuY0loBH41zyHWfMZY9v8Dy78wsnSofHejvFozZGgJgBB1A0LQRwMQ=="], "@oxlint-tsgolint/darwin-arm64": ["@oxlint-tsgolint/darwin-arm64@7.0.2001", "", { "os": "darwin", "cpu": "arm64" }, "sha512-CUJEdbSZ54+Xy9OXqOhWLTKZKV0BBiV7C2i/ygyVmXtkUNXx5YCzN8DpSSshTAKktoL7S+tnQ/ftFG/i7X896w=="], @@ -449,43 +453,43 @@ "@oxlint-tsgolint/win32-x64": ["@oxlint-tsgolint/win32-x64@7.0.2001", "", { "os": "win32", "cpu": "x64" }, "sha512-FkDRm8hx9OwzGQqyWG1tO5QrTLRApff9DzSgpz9QZau37BR8d1VYKOxMLGf6shPZntJFoTwIIJYT68VndYDCog=="], - "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.77.0", "", { "os": "android", "cpu": "arm" }, "sha512-E06sKWS6PiI6HRxS1wyQg22HvApt01hI7fV+T3wUk3OSbaaP4a3hYGY/MIQDmASqCiRjBdpRQYkgMkqH82cWmQ=="], + "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.78.0", "", { "os": "android", "cpu": "arm" }, "sha512-Bu819lmAfZMUHErrpe0cEWj3iaefuUODHSU8+UbXy67V/r7/7f4K3FL0NmbD85E+wiFLDYuhP8Zlv0XnVeXshw=="], - "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.77.0", "", { "os": "android", "cpu": "arm64" }, "sha512-NvsKz0KZxTp9cYWPLf+FXaSZwB3oO3peAjtukpOMBgse2vhQSoIIVqeO1yR0lEo/UcdZIDL18uq+kL0LzQ0ytA=="], + "@oxlint/binding-android-arm64": ["@oxlint/binding-android-arm64@1.78.0", "", { "os": "android", "cpu": "arm64" }, "sha512-CDfxZgB61B7buRdY2FJoAYYPPXCZ1EoC1LKscnC5dg3kjobdxiconvAvvN1BmHyW4PyFT3jRLDag/BY/roSNBQ=="], - "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.77.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bgjTn6nW4bQCFBvSvuHCpDD+sONvmpo4lGI4PxzMt1quBA+xYxhczk6RiCn3GZ9gY8uhaBbwhj9MdKGfu6T9DA=="], + "@oxlint/binding-darwin-arm64": ["@oxlint/binding-darwin-arm64@1.78.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-2Y2U9Ahrz+OO0Ej88f9SJYq51/jUBp1Mc7iZu0ukrbeeZ3gpRGfzIFnoqfHDY96xr0GEfNrPUBFEy0nN5aD7HA=="], - "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.77.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-aotaIttH1R6j1Rwhx0M0htgeZyGtVQqYNTVEYMN/UcgHPquGA6kmk9OyuDc3a2GKUQBC+3C3GVQCcrRPMYqAFA=="], + "@oxlint/binding-darwin-x64": ["@oxlint/binding-darwin-x64@1.78.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-rpych6eJq6m9jDRypTEaPD1xysaEW5h9+xuxhGK/QhOg+/xaqPZrCrTNoIl/f3nEjuJeCEmstNDlrE9rJi/3/g=="], - "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.77.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-nNx/wta7ksRAdYvq+l4AWjXkLxEXHALhENxjj2cYbQAIR4ybaA5L+hCbE63HOmft5czQ6ks+hb8vmEAnn7YGPg=="], + "@oxlint/binding-freebsd-x64": ["@oxlint/binding-freebsd-x64@1.78.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-IcMGrQT3QizkOESUJd5et+rOhVqSkNDfNik1cvrKDqIbzqx9KMtRswpFgkCuNTSwylCFLKhGUu8KmqY1ZnC0Dg=="], - "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.77.0", "", { "os": "linux", "cpu": "arm" }, "sha512-tMLLjM7xXtzXisVCzkOTXNCy9bZVId2wteNwjohlFDR/jY6WagpEDA1c1wu4xRc20Hojaxj+V6DSR7gbKxijWA=="], + "@oxlint/binding-linux-arm-gnueabihf": ["@oxlint/binding-linux-arm-gnueabihf@1.78.0", "", { "os": "linux", "cpu": "arm" }, "sha512-/uLdoJ0IXE6vo/0f0LKjinQAp+re+VMaCWaNT8ENIv2EOCkSsc8SGaflXAuW0Jua2dq5+GLVWm1NQK7P3UFSNQ=="], - "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.77.0", "", { "os": "linux", "cpu": "arm" }, "sha512-MiAFDFaqR0tmHTAyo0YDcZ5hyLREdYw/RQhc2R3cbT+8O3tB+zqPM2th9TTQ+Uo3jn/embS+DO+HyX9ztCPkOQ=="], + "@oxlint/binding-linux-arm-musleabihf": ["@oxlint/binding-linux-arm-musleabihf@1.78.0", "", { "os": "linux", "cpu": "arm" }, "sha512-7xi4Wb/O8NRJhLoUXmDJMUVpNYvB5kefdhFU1Jb8rtae4QoXlTiLwI14X4YvAXVZLNZChP8m5qO9SQAlWQTbkQ=="], - "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.77.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-/xqQ3B16i1T4cyt/9Mn+4CpzhUXoBXp7kVpIwzOXNFLj5JmK1bIjsbSnX296Gg8A/o7oDtKWikFgBx0SLwztkw=="], + "@oxlint/binding-linux-arm64-gnu": ["@oxlint/binding-linux-arm64-gnu@1.78.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-4hFW0+fVXa3OIh1Y4A5SPkmvI4wuuBSrCVKzOyE7PTjhc7yEqZ1pmvEEeS5Lj/MaqvegFxXyF33N+6jkehxdyg=="], - "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.77.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-LSbwuRKiNCenPDcbARqAZ5RfBy7gmj7vOvfJRLeCDU3gFtSxWbhv/+VTlaUqzUhNj1gFLHB8h7ALnxa/Az6z6g=="], + "@oxlint/binding-linux-arm64-musl": ["@oxlint/binding-linux-arm64-musl@1.78.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-oC0mvsgBJjlMijSDEhx9KuvR9zYeHXceA9MjbuXB1F8NSR78Yj2unOBrstEvTVaq+pko+kuue6DajC00eqvTdg=="], - "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.77.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-QWdcH31mXEUe5Nq1s0CfCpceaKjIo9uZtwDjAuL681g1axf+5x8xrg/eXWaw//4NCxYZ4V4e5Hu5tvdR+pTBlg=="], + "@oxlint/binding-linux-ppc64-gnu": ["@oxlint/binding-linux-ppc64-gnu@1.78.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-XAllT5SUZS+ohjuZ3/5S0cwe0r7eboiuigeStCZ5DXRYx/2KVM2UvQXvAfyzXEimtQjAB7cDQ2YxDe2Zl2WNQQ=="], - "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.77.0", "", { "os": "linux", "cpu": "none" }, "sha512-GnOfYgJxbcElOiPZaDFDl406ONddwvOWk2jvAAAEjwAl4GofNoHF+/HHUIBYa6bFCArlcGPi0XjC4cU1pkgF/Q=="], + "@oxlint/binding-linux-riscv64-gnu": ["@oxlint/binding-linux-riscv64-gnu@1.78.0", "", { "os": "linux", "cpu": "none" }, "sha512-trucMER/0QtecoXvc1y/UVqE3kwJipDwrx4oHfj+nNm3dq2zjP44WT0CfHNDPM3G1DXIkx/gY6lAD21NSCZVhA=="], - "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.77.0", "", { "os": "linux", "cpu": "none" }, "sha512-AyEMTUCf0xY+hHF+IxqXFQIX0yQOIR8ykpY0lJNOw9xYqOzUX8dyZfRvlG0RfXwuQn2eonf/8NrMmDSZJjdqsA=="], + "@oxlint/binding-linux-riscv64-musl": ["@oxlint/binding-linux-riscv64-musl@1.78.0", "", { "os": "linux", "cpu": "none" }, "sha512-cm3O4F/HQbdzOUX5mKHqG5KDL6E5w0pnlZ+fbBy2rmLryPOowkuLagFHTopQsEIpjcaZoPOrL+BmmAytAG9HFg=="], - "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.77.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-sPLzEcNvxd/oyVQ5oZo92CiHkFkpBeRop13E/P3TPY+hZfXHKCOWKI70TE2RYwMKFJDc20EMjH16L7NZICtKTw=="], + "@oxlint/binding-linux-s390x-gnu": ["@oxlint/binding-linux-s390x-gnu@1.78.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-33wRf6HqGNsybJ3qX4cGaQN2ODPxNmc1rMa0mrTmx3eFq1VzOnvQooi9bIGVYakW8a/wmqVx1mgsUm8R2xfTiw=="], - "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.77.0", "", { "os": "linux", "cpu": "x64" }, "sha512-1Oh2ssH2L7lwyvkdSqaMUfsGfwU2Wfvew+obBUYjRVqhpBcUpwnsPSEr1IzVi9XqkuY10geiLsNKecqaZC34Dw=="], + "@oxlint/binding-linux-x64-gnu": ["@oxlint/binding-linux-x64-gnu@1.78.0", "", { "os": "linux", "cpu": "x64" }, "sha512-rRdISSYegj6VganMZ9tjRjijowfHJ09IZU01i0toBAqr6n5LEtwHq2IeS4FjW2RoskOHlb6efB26H5izYb3GEQ=="], - "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.77.0", "", { "os": "linux", "cpu": "x64" }, "sha512-0j/2wRgNGO+Qj/M1uu/p57h/hFTTWWcfie0ufkbabeus2s5+/QqkCflnMOwLLN5m2GsNeWp4xdl4cPa4n7QCOQ=="], + "@oxlint/binding-linux-x64-musl": ["@oxlint/binding-linux-x64-musl@1.78.0", "", { "os": "linux", "cpu": "x64" }, "sha512-GmsP4rW0xTL6u5CVdcDsaN5Fbc7hBc382Wmar1kttbnwSEviM+rSINKOMQ+UQ6iH+AGwC+8gaAiwu134Tgh6Lg=="], - "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.77.0", "", { "os": "none", "cpu": "arm64" }, "sha512-BJ/j54qS0usEnyDkLYURMj2iiD9h5Cyy+ppzeMSXBGRXaGRNWnj1Mw14NqWMR5E/PzdgB30OOCCzLzbRoduafw=="], + "@oxlint/binding-openharmony-arm64": ["@oxlint/binding-openharmony-arm64@1.78.0", "", { "os": "none", "cpu": "arm64" }, "sha512-sy9yeYuADc8a+n4TLBayzMCZiHPW78DcIFVpOXTmdKHWQeM9xe5uzkqIIZmi326D5hY9XVwacipEB1p7tQjPAg=="], - "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.77.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-Yh8w+g2Lpx7StrvtYkoz9JJvXjB9wxgFChFNb85nrXm/wj/XTwGWS1hve9+900HL7llrntYB3YP+y32E3tRqzA=="], + "@oxlint/binding-win32-arm64-msvc": ["@oxlint/binding-win32-arm64-msvc@1.78.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-rjc2hF1KfMi8fZj1X/m3AmnHbdsF3rL0v6KQg0Uc880Yb2khjz+3U14sfdZ7jWTpRnN1m1NQa/TT7uU9lJWPrA=="], - "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.77.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-zja5b7+6a7UsRFgAQSrnax5vrzliEyNPLCjfXONu/vTWswaIVZGFajJZptaeRvPE4LghtFdAzVFlexTm7MVTGA=="], + "@oxlint/binding-win32-ia32-msvc": ["@oxlint/binding-win32-ia32-msvc@1.78.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-zcuXFVrEFHIafRfkCQT8w/Xe41o07ozl/vwHq7p94vB29xVzsB0sZGYORU1jhcYKv3Lr0J3HbJ2T4fHH5rWmvA=="], - "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.77.0", "", { "os": "win32", "cpu": "x64" }, "sha512-+teyvPDZ2RjUvo+SuCqS/UhaJl1QtdW5fWT5NJTV61V5MIuIS90Db9LixmtEGvXixyttiK62P96MSu3UlpviBw=="], + "@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.78.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Sb5ocmLSuYeOuXd+CFOToGKp/gjXUEWDnvIGwhnh8aq8wY4TMmEnKnvbogSW7RdMZv77JSARduS7/gv+khYEjA=="], "@peculiar/asn1-android": ["@peculiar/asn1-android@2.8.0", "", { "dependencies": { "@peculiar/asn1-schema": "^2.8.0", "asn1js": "^3.0.10", "tslib": "^2.8.1" } }, "sha512-skLbS+IOGv1lUgDqtChr8xvtvEr3HMse/JGBaL2r1J1o/n7a8wqOrovMtlRq/UXLhxvmLaONP67hwtshgzwfzA=="], @@ -575,27 +579,27 @@ "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "@storybook/addon-a11y": ["@storybook/addon-a11y@10.6.0-alpha.4", "", { "dependencies": { "@storybook/global": "^5.0.0", "axe-core": "^4.2.0" }, "peerDependencies": { "storybook": "^10.6.0-alpha.4" } }, "sha512-lm3S4gDSJnOKUztqtaH+6VM3dc2T2jwNFE2FYXKrigmU1G7c8efMJs/H+d9aomCgmfwlbsrskDdXdDg/1Je5fQ=="], + "@storybook/addon-a11y": ["@storybook/addon-a11y@10.6.0-alpha.5", "", { "dependencies": { "@storybook/global": "^5.0.0", "axe-core": "^4.2.0" }, "peerDependencies": { "storybook": "^10.6.0-alpha.5" } }, "sha512-PstbAihdMRR/UcDXCor9ncsX89ZDrmNvHUIsky1dnp+bhWJIHed8Q5n7xVZ9OmSrc6J3/T375OWkzb/DH8UxFA=="], - "@storybook/addon-docs": ["@storybook/addon-docs@10.6.0-alpha.4", "", { "dependencies": { "@mdx-js/react": "^3.0.0", "@storybook/csf-plugin": "10.6.0-alpha.4", "@storybook/icons": "^2.0.2", "@storybook/react-dom-shim": "10.6.0-alpha.4", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "ts-dedent": "^2.0.0" }, "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.6.0-alpha.4" }, "optionalPeers": ["@types/react"] }, "sha512-uoFj1ynseULaSJgpWXzdX1CgevWdbROHsMxevnesLyjGeXWiX/5GcHrtMqaGAj0kTFfcUSvSa9Kug+UgEGRw/g=="], + "@storybook/addon-docs": ["@storybook/addon-docs@10.6.0-alpha.5", "", { "dependencies": { "@mdx-js/react": "^3.0.0", "@storybook/csf-plugin": "10.6.0-alpha.5", "@storybook/icons": "^2.0.2", "@storybook/react-dom-shim": "10.6.0-alpha.5", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "ts-dedent": "^2.0.0" }, "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.6.0-alpha.5" }, "optionalPeers": ["@types/react"] }, "sha512-ODGrM6on+ASRwk54NsvO1kKZLKnkuFn2h7Db/ou2NJvuJMHDhCqFPRkN7TT1RGDqG9HRqZLKIh1VD5ol+uFpFw=="], - "@storybook/addon-vitest": ["@storybook/addon-vitest@10.6.0-alpha.4", "", { "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^2.0.2" }, "peerDependencies": { "@vitest/browser": "^3.0.0 || ^4.0.0", "@vitest/browser-playwright": "^4.0.0", "@vitest/runner": "^3.0.0 || ^4.0.0", "storybook": "^10.6.0-alpha.4", "vitest": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["@vitest/browser", "@vitest/browser-playwright", "@vitest/runner", "vitest"] }, "sha512-t6uVb9Flm5Y81WPMoXLgPMSGJ6HAN1a2yHzGnmmX9YSp07wsYn9dFbqn4TqmilUrgjdi9OP663WQ204KFy+h5Q=="], + "@storybook/addon-vitest": ["@storybook/addon-vitest@10.6.0-alpha.5", "", { "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^2.0.2" }, "peerDependencies": { "@vitest/browser": "^3.0.0 || ^4.0.0", "@vitest/browser-playwright": "^4.0.0", "@vitest/runner": "^3.0.0 || ^4.0.0", "storybook": "^10.6.0-alpha.5", "vitest": "^3.0.0 || ^4.0.0" }, "optionalPeers": ["@vitest/browser", "@vitest/browser-playwright", "@vitest/runner", "vitest"] }, "sha512-GBX50ztTxJdowM/3iB0LI75QZhK1YNmXo+spjQs3C2rWaitc/egIEL/v1CiV/R7F6gQyHbOq/oqC+DKFlVCpyg=="], - "@storybook/builder-vite": ["@storybook/builder-vite@10.6.0-alpha.4", "", { "dependencies": { "@storybook/csf-plugin": "10.6.0-alpha.4", "ts-dedent": "^2.0.0" }, "peerDependencies": { "storybook": "^10.6.0-alpha.4", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-tRlT8Qrn1Brc8JZT4c3GQJRkQPuX4q2B+3nky+uqvNzBpswwzzykOdzecE9ST/ZEo47pNlgSbTuAKNDHMR0uIw=="], + "@storybook/builder-vite": ["@storybook/builder-vite@10.6.0-alpha.5", "", { "dependencies": { "@storybook/csf-plugin": "10.6.0-alpha.5", "ts-dedent": "^2.0.0" }, "peerDependencies": { "storybook": "^10.6.0-alpha.5", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-JLqu4MDL2jX5X0zn41eTN3yXfwT71oHo/1CxnNVjXGTtyweoaP5PVMNQYINvh3Zb9NkhA40+YRQiFu9H0F/9Qg=="], - "@storybook/csf-plugin": ["@storybook/csf-plugin@10.6.0-alpha.4", "", { "dependencies": { "unplugin": "^2.3.5" }, "peerDependencies": { "esbuild": "*", "rollup": "*", "storybook": "^10.6.0-alpha.4", "vite": "*", "webpack": "*" }, "optionalPeers": ["esbuild", "rollup", "vite", "webpack"] }, "sha512-2B0TKn3A50eoaSdyII6CiOytVGXPoqdsGap23q9xH2AQ7D/sHKjUA16vLHuJVaJWbj+9q5VD1Idqp8Xeg4iJKQ=="], + "@storybook/csf-plugin": ["@storybook/csf-plugin@10.6.0-alpha.5", "", { "dependencies": { "unplugin": "^2.3.5" }, "peerDependencies": { "esbuild": "*", "rollup": "*", "storybook": "^10.6.0-alpha.5", "vite": "*", "webpack": "*" }, "optionalPeers": ["esbuild", "rollup", "vite", "webpack"] }, "sha512-5yVPmwFJ2BKluzkxdJu1K+cCY/TW+BXdBnQNv152LQ+A1Id5VYRTkbuu20O9fSc+PwAGjL3FLfdjnfuX7FyJWw=="], "@storybook/global": ["@storybook/global@5.0.0", "", {}, "sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ=="], "@storybook/icons": ["@storybook/icons@2.1.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Fxh9vYpX9bQqFeHRiY8h2ApeRGDzRSMLwJwNZ/AIRqnyOKHxRKL+yFe+ctEkVJmuptRE9u1Hrn8ZZNHyfDKKNg=="], - "@storybook/react": ["@storybook/react@10.6.0-alpha.4", "", { "dependencies": { "@storybook/global": "^5.0.0", "@storybook/react-dom-shim": "10.6.0-alpha.4", "react-docgen": "^8.0.2", "react-docgen-typescript": "^2.2.2" }, "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.6.0-alpha.4", "typescript": ">= 4.9.x" }, "optionalPeers": ["@types/react", "@types/react-dom", "typescript"] }, "sha512-88P5MvvL0Lt7NWAuQCIV36lwHoDAaBfYg11iyScTEsuozmzEOMGvQ9pks89VJq9oxK4evgU73F7s+uLTDytA0A=="], + "@storybook/react": ["@storybook/react@10.6.0-alpha.5", "", { "dependencies": { "@storybook/global": "^5.0.0", "@storybook/react-dom-shim": "10.6.0-alpha.5", "react-docgen": "^8.0.2", "react-docgen-typescript": "^2.2.2" }, "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.6.0-alpha.5", "typescript": ">= 4.9.x" }, "optionalPeers": ["@types/react", "@types/react-dom", "typescript"] }, "sha512-Op+Z+gKOK7X+AmAn/EVzmGlXqrqOG1yMaufUwSnm5+BitgWnAfWrla9/Lx8iiz6BqSbf5sprNr6xr45dFB/Dig=="], - "@storybook/react-dom-shim": ["@storybook/react-dom-shim@10.6.0-alpha.4", "", { "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.6.0-alpha.4" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-JcdKKM2VAGwoCN1m7IiiiXYu7pl77gbZcOEr8JxcWiP+K5a/KMKFVedVbj7NXmF2qdZUnk7bWCDK3GZxFh6e4Q=="], + "@storybook/react-dom-shim": ["@storybook/react-dom-shim@10.6.0-alpha.5", "", { "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "@types/react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.6.0-alpha.5" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Zwo6arui3FfR3W1D9rMYF7Ciwomk7XXjHGKVw93XDWYONkk/oXa8L1EFqsu0m1VbSQr+5cvnj8LEuOVzuRrjeQ=="], - "@storybook/react-vite": ["@storybook/react-vite@10.6.0-alpha.4", "", { "dependencies": { "@joshwooding/vite-plugin-react-docgen-typescript": "^0.7.0", "@rollup/pluginutils": "^5.0.2", "@storybook/builder-vite": "10.6.0-alpha.4", "@storybook/react": "10.6.0-alpha.4", "empathic": "^2.0.0", "magic-string": "^1.1.0", "react-docgen": "^8.0.2", "resolve": "^1.22.8", "tsconfig-paths": "^4.2.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.6.0-alpha.4", "typescript": ">= 4.9.x", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["typescript"] }, "sha512-j6eL5ssEDEcFtrYeZz6UgYkTtyHiPYtuqX6zd1ZN2hOsjc/3qwfqLcJNUEYWrvqkGL/1cTuv6bKzlksaPwBE1w=="], + "@storybook/react-vite": ["@storybook/react-vite@10.6.0-alpha.5", "", { "dependencies": { "@joshwooding/vite-plugin-react-docgen-typescript": "^0.7.0", "@rollup/pluginutils": "^5.0.2", "@storybook/builder-vite": "10.6.0-alpha.5", "@storybook/react": "10.6.0-alpha.5", "empathic": "^2.0.0", "magic-string": "^1.1.0", "react-docgen": "^8.0.2", "resolve": "^1.22.8", "tsconfig-paths": "^4.2.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.6.0-alpha.5", "typescript": ">= 4.9.x", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["typescript"] }, "sha512-FeCs2e8b6f1Pwb0DCfhtXqTCYZvfxjyFKnrdUVY1E5a2yrG92dFQrzZbTraDfFKYRJx4q2i4ffpYCvBuqDh4JQ=="], - "@storybook/tanstack-react": ["@storybook/tanstack-react@10.6.0-alpha.4", "", { "dependencies": { "@storybook/builder-vite": "10.6.0-alpha.4", "@storybook/react": "10.6.0-alpha.4", "@storybook/react-vite": "10.6.0-alpha.4" }, "peerDependencies": { "@tanstack/react-router": "^1.168.10", "@tanstack/react-start": "^1.167.16", "@tanstack/router-core": "^1.168.9", "@tanstack/start-client-core": "^1.167.9", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.6.0-alpha.4", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@tanstack/react-start", "@tanstack/start-client-core"] }, "sha512-RczIS5cH1KxRsLNrAIhNpxeHPcj4NkNT3ncuGTP3vXr6v06grIqutpEq/n3NmVrYCZoB2vibdlKYwC5gI63tXQ=="], + "@storybook/tanstack-react": ["@storybook/tanstack-react@10.6.0-alpha.5", "", { "dependencies": { "@storybook/builder-vite": "10.6.0-alpha.5", "@storybook/react": "10.6.0-alpha.5", "@storybook/react-vite": "10.6.0-alpha.5" }, "peerDependencies": { "@tanstack/react-router": "^1.168.10", "@tanstack/react-start": "^1.167.16", "@tanstack/router-core": "^1.168.9", "@tanstack/start-client-core": "^1.167.9", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "storybook": "^10.6.0-alpha.5", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@tanstack/react-start", "@tanstack/start-client-core"] }, "sha512-UFuZsBhERXeQldrWVHblfI7AHzzTS6cYnwimXdPD/qAKXnYLBHpeDeAjbw1XPAnF4VA+o4ehm/+R8a5fMoOxCA=="], "@swc/helpers": ["@swc/helpers@0.5.23", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw=="], @@ -635,7 +639,7 @@ "@tanstack/db-ivm": ["@tanstack/db-ivm@0.1.18", "", { "dependencies": { "fractional-indexing": "^3.2.0", "sorted-btree": "^1.8.1" }, "peerDependencies": { "typescript": ">=4.7" } }, "sha512-+pZJiRKdoKRM5Epq9T7otD9ZJl82pRFauo7LKuJGrarjVKQ7r+QQlPe3kGdN9LEKSnuNGIWjX9OOY4M8kH4eLw=="], - "@tanstack/devtools": ["@tanstack/devtools@0.13.0", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/keyboard": "^1.3.3", "@solid-primitives/resize-observer": "^2.1.3", "@tanstack/devtools-client": "0.0.8", "@tanstack/devtools-event-bus": "0.4.2", "@tanstack/devtools-ui": "0.6.0", "clsx": "^2.1.1", "goober": "^2.1.16", "solid-js": "^1.9.9" }, "bin": { "intent": "./bin/intent.js" } }, "sha512-p/nOH9bS/OO/u3402zPjoGu+Mz6Fzi/iRqJuYghuuYRUY32kZt+C0/d+pP/bi6/2JTi1FdT6oEXI2lWlA5tXxw=="], + "@tanstack/devtools": ["@tanstack/devtools@0.14.0", "", { "dependencies": { "@neodrag/solid": "3.0.0-next.11", "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/keyboard": "^1.3.3", "@solid-primitives/resize-observer": "^2.1.3", "@tanstack/devtools-client": "0.0.8", "@tanstack/devtools-event-bus": "0.4.2", "@tanstack/devtools-ui": "0.7.0", "clsx": "^2.1.1", "goober": "^2.1.16", "solid-js": "^1.9.9" }, "bin": { "intent": "./bin/intent.js" } }, "sha512-tN0SEi1BVaJYtkZbgYpvLsInjwNRAZkR3r6slSvz9Jm+bfkhcdjuOSrZp0cAwOGwdnauHZ1mYgtfe2AfC/V1NQ=="], "@tanstack/devtools-client": ["@tanstack/devtools-client@0.0.8", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.5.0" } }, "sha512-cG3iZkGWCwN330bLBKa8+9r4Of2AXNoz2zUqcsy/4XsD3105ghVBx78cGyvJj9fSclNomPxoqAnDGXXhg1WLvA=="], @@ -643,13 +647,13 @@ "@tanstack/devtools-event-client": ["@tanstack/devtools-event-client@0.4.4", "", { "bin": { "intent": "./bin/intent.js" } }, "sha512-6T5Yop/793YI+H+5J8Hsyj4kCih9sl4t3ElLgKioW5hk3ocn+ZdSJ94tT7vL7uabxSugWYBZlOTMPzEw2puvQw=="], - "@tanstack/devtools-ui": ["@tanstack/devtools-ui@0.6.0", "", { "dependencies": { "clsx": "^2.1.1", "dayjs": "^1.11.19", "goober": "^2.1.16", "solid-js": "^1.9.9" } }, "sha512-CVaM6rT6Nl5ijo83vJYFa2SjofvpuOl/uOvbYGhBrRgUhhelNHhx8zZX+hnZCHmIr0/lzM65hsocnZ72592Rvg=="], + "@tanstack/devtools-ui": ["@tanstack/devtools-ui@0.7.0", "", { "dependencies": { "clsx": "^2.1.1", "dayjs": "^1.11.19", "goober": "^2.1.16", "solid-js": "^1.9.9" } }, "sha512-px/a+JgRSVHDj/hID1cwfsIi+ly5l7t3Yw7fLw4G556HXCNgDri36fupt9h7oBeEKntpsx3ep/XtZn51rrCv2g=="], "@tanstack/devtools-utils": ["@tanstack/devtools-utils@0.4.0", "", { "peerDependencies": { "@types/react": ">=17.0.0", "preact": ">=10.0.0", "react": ">=17.0.0", "solid-js": ">=1.9.7", "vue": ">=3.2.0" }, "optionalPeers": ["@types/react", "preact", "react", "solid-js", "vue"], "bin": { "intent": "bin/intent.js" } }, "sha512-KsGzYhA8L/fCNgyyMyoUy+TKtx+DjNbzWwqH6wXL48Llzo7kvV9RynYJlaO8Qkzwm+NdHXSgsljQNjQ3CKPpZA=="], - "@tanstack/form-core": ["@tanstack/form-core@1.33.4", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.4.1", "@tanstack/pacer-lite": "^0.1.1", "@tanstack/store": "^0.11.0" } }, "sha512-nqNaVAS+FfdWq6pfVqLC7SkgvRFyQEM9UEruIMTQULQ5SGF2X6+7RduFgr5ofpHftAHw/2yVs2vvIafx4WCVGQ=="], + "@tanstack/form-core": ["@tanstack/form-core@1.33.5", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.4.1", "@tanstack/pacer-lite": "^0.1.1", "@tanstack/store": "^0.11.0" } }, "sha512-3dfx9MBP0aq5sXKteikG629X9oviptrQj0IFRk9YGcb+lB7Kv5x8S17oOSk1wUWgjQZ4xVJEMbKwOAODymocgA=="], - "@tanstack/form-devtools": ["@tanstack/form-devtools@0.2.33", "", { "dependencies": { "@tanstack/devtools-ui": "^0.5.1", "@tanstack/devtools-utils": "^0.4.0", "@tanstack/form-core": "1.33.4", "clsx": "^2.1.1", "dayjs": "^1.11.18", "goober": "^2.1.16" }, "peerDependencies": { "solid-js": ">=1.9.9" } }, "sha512-iGGGamy38Fj46V86F/i9YWJoS6X98+i0Lg/dgtheoYwvd3g1u/ZLZ36hOJ8/dtgeZ5Ucm5MIxs4lnsUXKsO1Cg=="], + "@tanstack/form-devtools": ["@tanstack/form-devtools@0.2.34", "", { "dependencies": { "@tanstack/devtools-ui": "^0.5.1", "@tanstack/devtools-utils": "^0.4.0", "@tanstack/form-core": "1.33.5", "clsx": "^2.1.1", "dayjs": "^1.11.18", "goober": "^2.1.16" }, "peerDependencies": { "solid-js": ">=1.9.9" } }, "sha512-AqKQDO9lr8dCYh4cp6bYNhqlut8dH6bQFFXWEhX1+0EI4lsom1scO+L6yapQQAy/K/KS3EvjXRLrGu2cHFMTPg=="], "@tanstack/history": ["@tanstack/history@1.162.1", "", {}, "sha512-DR9t6lfLVdrjgCwpglrR9DR7Ok8/HlXjcOE+goWXF3zyuLUO/ug7vMbSFxTqrQTtbRghJfyhmIZ0S6LhPIy44w=="], @@ -663,43 +667,43 @@ "@tanstack/react-db": ["@tanstack/react-db@0.1.95", "", { "dependencies": { "@tanstack/db": "0.6.17", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-Om2qgKtoK+iTcE3nR+MaPQsM4JUsDWB8LqFt4yFjwuSawwMJ7bcPnc7gP0V06YKOtm5MzrIu8IV/OexMJbraKQ=="], - "@tanstack/react-devtools": ["@tanstack/react-devtools@0.10.9", "", { "dependencies": { "@tanstack/devtools": "0.13.0" }, "peerDependencies": { "@types/react": ">=16.8", "@types/react-dom": ">=16.8", "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-lS6mtccEmUaodsWiRORGM/MGKT0jgzcy5v+eY6pzOPxEgzTHUDhca+WGxShFqKxmF4oneRxXjww1gkvMrWq6uw=="], + "@tanstack/react-devtools": ["@tanstack/react-devtools@0.10.10", "", { "dependencies": { "@tanstack/devtools": "0.14.0" }, "peerDependencies": { "@types/react": ">=16.8", "@types/react-dom": ">=16.8", "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-hYH34MSVbajs1pUc22ftSapyKQp2gOh0w34a7ouYOdIcbXD6YPckSR2YpAWGq1rrs6N0l/rvV6LM/G1pgN5ARg=="], - "@tanstack/react-form": ["@tanstack/react-form@1.33.4", "", { "dependencies": { "@tanstack/form-core": "1.33.4", "@tanstack/react-store": "^0.11.0" }, "peerDependencies": { "@tanstack/react-start": "*", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@tanstack/react-start"] }, "sha512-EnPxwYq+QPmW+BovL58IW9ioSGPmpkLZk2zpEZKj11n2TleonJ/ASZAEb6tsdzTkHTX2JFKwJv8cpYozYnNZXw=="], + "@tanstack/react-form": ["@tanstack/react-form@1.33.5", "", { "dependencies": { "@tanstack/form-core": "1.33.5", "@tanstack/react-store": "^0.11.0" }, "peerDependencies": { "@tanstack/react-start": "*", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@tanstack/react-start"] }, "sha512-LlRB28qJwO/QCGaHvWnbdh4haBgTFiZVmzA2uzxSBS3YA7/IqrQ6HOBK70CkFQ+DbflZ7NawsmSln13h5iIdTA=="], - "@tanstack/react-form-devtools": ["@tanstack/react-form-devtools@0.2.33", "", { "dependencies": { "@tanstack/devtools-utils": "^0.4.0", "@tanstack/form-devtools": "0.2.33" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-aMvvJH14v1Dwslx3ayksgqgs9K2/mylBrU7dyiKpWQhWls46KMkpFhgW6q0GExPDqv6wzXeO8Y+m3M7EEKHNbw=="], + "@tanstack/react-form-devtools": ["@tanstack/react-form-devtools@0.2.34", "", { "dependencies": { "@tanstack/devtools-utils": "^0.4.0", "@tanstack/form-devtools": "0.2.34" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Sg+6nuTNqXnODPtUJDn/WZmLznNxilIK3hFqeXzKVnzfdzUKczJr5PnXXh3J6SZj+QjveJdvwC2M6Rm2oIypoA=="], "@tanstack/react-query": ["@tanstack/react-query@5.101.4", "", { "dependencies": { "@tanstack/query-core": "5.101.4" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA=="], "@tanstack/react-query-devtools": ["@tanstack/react-query-devtools@5.101.4", "", { "dependencies": { "@tanstack/query-devtools": "5.101.4" }, "peerDependencies": { "@tanstack/react-query": "^5.101.4", "react": "^18 || ^19" } }, "sha512-VeK2gtmfj7kvRBjtxS7TKxt/6qKhn8VzabY4UiYMr7NV9CddjSRYRgeYyld+NpjAkgMV9dd+2Qdr8ah5I03NeA=="], - "@tanstack/react-router": ["@tanstack/react-router@1.170.23", "", { "dependencies": { "@tanstack/history": "1.162.1", "@tanstack/react-store": "^0.9.3", "@tanstack/router-core": "1.171.19", "isbot": "^5.1.22" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-iKyHk7vGVaTdk7wukFZLjzlOs4TQbQJiRMkvFsphpysOlxzLaqWSlWHKU4gVPW3WjJ19k7EjVUCD4q3DJqZpZg=="], + "@tanstack/react-router": ["@tanstack/react-router@1.170.27", "", { "dependencies": { "@tanstack/history": "1.162.1", "@tanstack/react-store": "^0.9.3", "@tanstack/router-core": "1.171.22", "isbot": "^5.1.22" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-Hxl49xzd8ffWd2ZMigqfXZmpySpixWGvjq5zfh2nK2DbzzDH6IGVh+iUuwarK9MJNcnhWPZ85tOoy6o/OmNlww=="], "@tanstack/react-router-devtools": ["@tanstack/react-router-devtools@1.167.1", "", { "dependencies": { "@tanstack/router-devtools-core": "1.168.1" }, "peerDependencies": { "@tanstack/react-router": "^1.170.19", "@tanstack/router-core": "^1.171.16", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" }, "optionalPeers": ["@tanstack/router-core"] }, "sha512-pjfGrmjj4d7naEPM7oshqFfwBoxDPNo/UxltlHH5ePbHsJ+plBhd+JaAewm1ueYOjZ0js9hckjWWDYXpCrSfKw=="], "@tanstack/react-store": ["@tanstack/react-store@0.11.1", "", { "dependencies": { "@tanstack/store": "0.11.1", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-HaIGKI3YLmjBYIvy5DFDY23oNaYZIsTZfngey07Uh5iLVJgM3bIGCnZeOFOqzjFld9JHWcaHJnasD/bKoGKwJQ=="], - "@tanstack/react-table": ["@tanstack/react-table@9.1.1", "", { "dependencies": { "@tanstack/react-store": "^0.11.0", "@tanstack/table-core": "9.1.1" }, "peerDependencies": { "react": ">=18" } }, "sha512-djHwPDsoC8SxKhk/MT6gl59U4/jW0zF8YbDmvDlAd3sMQJQ6/B4iW54b86JqU1g255nChUxb0QT5n5+EXtGQbg=="], + "@tanstack/react-table": ["@tanstack/react-table@9.1.2", "", { "dependencies": { "@tanstack/react-store": "^0.11.0", "@tanstack/table-core": "9.1.2" }, "peerDependencies": { "react": ">=18" } }, "sha512-YQPZFJ1nIi/bjjwsPZVouABgahDcl7Gdm33CdTStUJBn0DjEVJ2uhSTVmIoWt9MVKdQziXGAsXipSzy949Hygg=="], "@tanstack/react-virtual": ["@tanstack/react-virtual@3.14.9", "", { "dependencies": { "@tanstack/virtual-core": "3.17.7" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-qZyr0FZDP8rDC4WBhsryIZmAd9bveJvFGUJJtskWaew6/0dTRS6wZxnR6VQ5bY2KwL3LjerrHqQLk3a0GKcPXQ=="], - "@tanstack/router-core": ["@tanstack/router-core@1.171.19", "", { "dependencies": { "@tanstack/history": "1.162.1", "cookie-es": "^3.0.0", "seroval": "^1.6.2", "seroval-plugins": "^1.6.2" } }, "sha512-uCZhgnfmuBA3PoRLIVSjUhQpJQd/HA7p0XxG1IFKnvXU9lIMZ7gIqx45hyuf9JopxXZI8k7qaz9/6PC7mZLdfQ=="], + "@tanstack/router-core": ["@tanstack/router-core@1.171.22", "", { "dependencies": { "@tanstack/history": "1.162.1", "cookie-es": "^3.0.0", "seroval": "^1.6.2", "seroval-plugins": "^1.6.2" } }, "sha512-sitsuRkz4qpTjIAV97S5zFCoeGv0OFd6+VWg3ZcIlQbi5R4NIXfz6ogg51n5w6rvTwSODz/lKWb5dl5tvh2bQw=="], "@tanstack/router-devtools-core": ["@tanstack/router-devtools-core@1.168.1", "", { "dependencies": { "clsx": "^2.1.1", "goober": "^2.1.16" }, "peerDependencies": { "@tanstack/router-core": "^1.171.16", "csstype": "^3.0.10" }, "optionalPeers": ["csstype"] }, "sha512-qr4voa4cpSMwQvS3867xkU3AB3MtJbTuovKIy+btjJ/Faju6er9w0nDylmD+005Mk/3YKw9/iueZJl2JAB7JOA=="], "@tanstack/store": ["@tanstack/store@0.11.1", "", {}, "sha512-mzTOBhypOuDJAy/D8n2MfUZ1HFkXnmSETviRyhqEC8LUE7/IZQExOTxMANj3KjTofYTkFNpBY67qaVrT41YccA=="], - "@tanstack/table-core": ["@tanstack/table-core@9.1.1", "", { "dependencies": { "@tanstack/store": "^0.11.0" } }, "sha512-6O3oy43CgjhdhaCtFXulXc9wYF1JRtDUAN24IGhzSrshIlT7sRhNYigmQPrfWY6EMavQNHXEfxL17PetLJqfGA=="], + "@tanstack/table-core": ["@tanstack/table-core@9.1.2", "", { "dependencies": { "@tanstack/store": "^0.11.0" } }, "sha512-ONpWQeass1sfg80CWF1NSwQ8r3GiqxA2lT/EdqIcrDEPZ0Z+0mM94eQoFYLPN0Kztzj8TQVb2+PrSZSItqA61g=="], "@tanstack/virtual-core": ["@tanstack/virtual-core@3.17.7", "", {}, "sha512-bp+v10y65sp2H7WpWfIMyxTNfl8ZVfxFTLRjPIFRryi6FV/J33z4IS53WO4pTk36KlvJ4iLiQz+oaydDC1xbcA=="], "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], - "@testing-library/jest-dom": ["@testing-library/jest-dom@7.0.0", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" }, "peerDependencies": { "@testing-library/dom": ">=10 <11" } }, "sha512-HKAH9C6mBo5yBG6yRO5i43L2iisencAo5z+o5P/saHUoY+miC5ivXRxHBJcFyB5ypPNxHJdK3BoF/3O4DIptMg=="], + "@testing-library/jest-dom": ["@testing-library/jest-dom@7.0.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" }, "peerDependencies": { "@testing-library/dom": ">=10 <11", "vitest": ">= 0.32" }, "optionalPeers": ["vitest"] }, "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw=="], "@testing-library/react": ["@testing-library/react@16.3.2", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g=="], - "@testing-library/user-event": ["@testing-library/user-event@14.6.3", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-6dBq67jT8lE+JTE8Exm02Kt6ze43hz1jdiSpSJwtTZiT1xQQ6b7nZYTTQ9njdArdU8XklOwaDp/AbT/eYSKF4g=="], + "@testing-library/user-event": ["@testing-library/user-event@14.6.4", "", { "peerDependencies": { "@testing-library/dom": ">=7.21.4" } }, "sha512-QCGwP6QrjypBLwyj5cuyfVamkaIEy/XGY+1VDehbtbQqOggYmTFpFOdWR5mPz14vX8vXLMVjDHlRNBcClyO9ew=="], "@trpc/client": ["@trpc/client@11.18.0", "", { "peerDependencies": { "@trpc/server": "11.18.0", "typescript": ">=5.7.2" }, "bin": { "intent": "bin/intent.js" } }, "sha512-wOqeg3Fvl25V1ZisQhUD3K8G60ZJDlSGJNSyeXrLH24xAo5w6GSR2Kzb1cSNY9Y+IQ2YZvYGZstBU+V/ulo/ow=="], @@ -951,7 +955,7 @@ "drizzle-orm": ["drizzle-orm@1.0.0-rc.4", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@effect/sql-d1": ">=4.0.0-beta.83 || >=4.0.0", "@effect/sql-libsql": ">=4.0.0-beta.83 || >=4.0.0", "@effect/sql-mysql2": ">=4.0.0-beta.83 || >=4.0.0", "@effect/sql-pg": ">=4.0.0-beta.83 || >=4.0.0", "@effect/sql-pglite": ">=4.0.0-beta.83 || >=4.0.0", "@effect/sql-sqlite-bun": ">=4.0.0-beta.83 || >=4.0.0", "@effect/sql-sqlite-do": ">=4.0.0-beta.83 || >=4.0.0", "@effect/sql-sqlite-node": ">=4.0.0-beta.83 || >=4.0.0", "@effect/sql-sqlite-wasm": ">=4.0.0-beta.83 || >=4.0.0", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@sinclair/typebox": ">=0.34.8", "@sqlitecloud/drivers": ">=1.0.653", "@tidbcloud/serverless": "*", "@tursodatabase/database": ">=0.6.0-pre.28 || >=0.6.0", "@tursodatabase/database-common": ">=0.6.0-pre.28 || >=0.6.0", "@tursodatabase/database-wasm": ">=0.6.0-pre.28 || >=0.6.0", "@tursodatabase/serverless": ">=1.1.3", "@tursodatabase/sync": ">=0.6.0-pre.28 || >=0.6.0", "@types/better-sqlite3": "*", "@types/mssql": "^9.1.4", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "arktype": ">=2.0.0", "better-sqlite3": ">=9.3.0", "bun-types": "*", "effect": ">=4.0.0-beta.83 || >=4.0.0", "expo-sqlite": ">=14.0.0", "mssql": "^11.0.1", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5", "typebox": ">=1.0.0", "valibot": ">=1.0.0-beta.7", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@effect/sql-d1", "@effect/sql-libsql", "@effect/sql-mysql2", "@effect/sql-pg", "@effect/sql-pglite", "@effect/sql-sqlite-bun", "@effect/sql-sqlite-do", "@effect/sql-sqlite-node", "@effect/sql-sqlite-wasm", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@sinclair/typebox", "@sqlitecloud/drivers", "@tidbcloud/serverless", "@tursodatabase/database", "@tursodatabase/database-common", "@tursodatabase/database-wasm", "@tursodatabase/serverless", "@tursodatabase/sync", "@types/better-sqlite3", "@types/mssql", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "arktype", "better-sqlite3", "bun-types", "effect", "expo-sqlite", "mssql", "mysql2", "pg", "postgres", "sql.js", "sqlite3", "typebox", "valibot", "zod"] }, "sha512-BT+pf+qoiYHqltoA88Jmf6ilGMXPlpfE0hEJKc2adRtMCAl25Swk/t5gXcWxZNAwdtf3F5gCd2FpeOyP/pT0Hw=="], - "effect": ["effect@4.0.0-beta.106", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.4", "uuid": "^14.0.1" } }, "sha512-Sb1eNPYP8UdkE6xDOEEnrrOh/Vu2ocdRTPSeZTjZXukUrEYT2NtuQpbS90sEWaa7b6qLt8dfEYPpa6IVe5giHw=="], + "effect": ["effect@4.0.0-beta.107", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.9.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.4", "uuid": "^14.0.1" } }, "sha512-OoBAv8eF+yanc+C6xhgEUnWeXUSHA6ynnscYqpkAY9GSnzZWystsIjBowVqCkLpHGlnRtdIqYT3wHwpOY6JDnQ=="], "electron-to-chromium": ["electron-to-chromium@1.5.402", "", {}, "sha512-/oOpMaPT6Yg+6/1XQhyIPlzgj7Ye9zf+nNM2Uh6OcE2G2oNptWazFa+qB2Pdqqbsc9KnIDzgAntoYN0dbwOXwA=="], @@ -981,9 +985,9 @@ "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], - "eventsource": ["eventsource@4.1.1", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-D6bTRWh6KahHTK/m4WnjPQyEinNPf9eFLEZSEoj7d6fTibspnAVYfzHvirL7u/aoX5d9YYfIkBVAhmigUELk9w=="], + "eventsource": ["eventsource@5.0.0", "", { "dependencies": { "eventsource-parser": "^4.0.0" } }, "sha512-iNd5IgeR56Om1Aje0ACZTd5oRQ+u5i6RfRkKdIuhlykeH3o+f0vIiiT0i9t1SXABfTnsfxVPadAB2T4cLS32Sw=="], - "eventsource-parser": ["eventsource-parser@3.1.0", "", {}, "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg=="], + "eventsource-parser": ["eventsource-parser@4.0.0", "", {}, "sha512-aIpvYxbqTx4KYWw73KpCt1Rh22tEAJ144F1qR76CT3Ex+LNMEPKf5HFrK5Yz9fMP1EVT0GrfA35VZvWQhMPuSQ=="], "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], @@ -1141,7 +1145,7 @@ "lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], - "lucide-react": ["lucide-react@1.30.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-tUIr2jXLbWpCkdtH8XP7P7YppM9ueWgTky99lpWDY6z5REs6B+O6ZQ3U5tHkUUY59ANyOv/PBcs8E4Fe3KO3eA=="], + "lucide-react": ["lucide-react@1.31.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-G8u2eEtoHUnUa9f8lbvqDhCiORMnYLdUEo06EEG9MQvHQrInKcX3Pa2TH39MM5qyzRcWETxB0+aOwAPI1g1kEg=="], "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], @@ -1275,13 +1279,13 @@ "oxc-resolver": ["oxc-resolver@11.24.2", "", { "optionalDependencies": { "@oxc-resolver/binding-android-arm-eabi": "11.24.2", "@oxc-resolver/binding-android-arm64": "11.24.2", "@oxc-resolver/binding-darwin-arm64": "11.24.2", "@oxc-resolver/binding-darwin-x64": "11.24.2", "@oxc-resolver/binding-freebsd-x64": "11.24.2", "@oxc-resolver/binding-linux-arm-gnueabihf": "11.24.2", "@oxc-resolver/binding-linux-arm-musleabihf": "11.24.2", "@oxc-resolver/binding-linux-arm64-gnu": "11.24.2", "@oxc-resolver/binding-linux-arm64-musl": "11.24.2", "@oxc-resolver/binding-linux-ppc64-gnu": "11.24.2", "@oxc-resolver/binding-linux-riscv64-gnu": "11.24.2", "@oxc-resolver/binding-linux-riscv64-musl": "11.24.2", "@oxc-resolver/binding-linux-s390x-gnu": "11.24.2", "@oxc-resolver/binding-linux-x64-gnu": "11.24.2", "@oxc-resolver/binding-linux-x64-musl": "11.24.2", "@oxc-resolver/binding-openharmony-arm64": "11.24.2", "@oxc-resolver/binding-wasm32-wasi": "11.24.2", "@oxc-resolver/binding-win32-arm64-msvc": "11.24.2", "@oxc-resolver/binding-win32-x64-msvc": "11.24.2" } }, "sha512-FY91FiDBj7ls5MsFS9jN3tjz2o0/zsdSsymlakySaBwVJZorHhkWyICLZMKxlu1R9vYo+sd3z1jwb4J8x7bNDw=="], - "oxfmt": ["oxfmt@0.62.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.62.0", "@oxfmt/binding-android-arm64": "0.62.0", "@oxfmt/binding-darwin-arm64": "0.62.0", "@oxfmt/binding-darwin-x64": "0.62.0", "@oxfmt/binding-freebsd-x64": "0.62.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.62.0", "@oxfmt/binding-linux-arm-musleabihf": "0.62.0", "@oxfmt/binding-linux-arm64-gnu": "0.62.0", "@oxfmt/binding-linux-arm64-musl": "0.62.0", "@oxfmt/binding-linux-ppc64-gnu": "0.62.0", "@oxfmt/binding-linux-riscv64-gnu": "0.62.0", "@oxfmt/binding-linux-riscv64-musl": "0.62.0", "@oxfmt/binding-linux-s390x-gnu": "0.62.0", "@oxfmt/binding-linux-x64-gnu": "0.62.0", "@oxfmt/binding-linux-x64-musl": "0.62.0", "@oxfmt/binding-openharmony-arm64": "0.62.0", "@oxfmt/binding-win32-arm64-msvc": "0.62.0", "@oxfmt/binding-win32-ia32-msvc": "0.62.0", "@oxfmt/binding-win32-x64-msvc": "0.62.0" }, "peerDependencies": { "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-vxgGHTmnDU9j4CX7dDBLzxgmHxfda/yPcgJkGCMUSCwRmz+euo/V08xXLNgXTeqAB9Fhf3Pe2nO1RNKLCVgphQ=="], + "oxfmt": ["oxfmt@0.63.0", "", { "dependencies": { "tinypool": "2.1.0" }, "optionalDependencies": { "@oxfmt/binding-android-arm-eabi": "0.63.0", "@oxfmt/binding-android-arm64": "0.63.0", "@oxfmt/binding-darwin-arm64": "0.63.0", "@oxfmt/binding-darwin-x64": "0.63.0", "@oxfmt/binding-freebsd-x64": "0.63.0", "@oxfmt/binding-linux-arm-gnueabihf": "0.63.0", "@oxfmt/binding-linux-arm-musleabihf": "0.63.0", "@oxfmt/binding-linux-arm64-gnu": "0.63.0", "@oxfmt/binding-linux-arm64-musl": "0.63.0", "@oxfmt/binding-linux-ppc64-gnu": "0.63.0", "@oxfmt/binding-linux-riscv64-gnu": "0.63.0", "@oxfmt/binding-linux-riscv64-musl": "0.63.0", "@oxfmt/binding-linux-s390x-gnu": "0.63.0", "@oxfmt/binding-linux-x64-gnu": "0.63.0", "@oxfmt/binding-linux-x64-musl": "0.63.0", "@oxfmt/binding-openharmony-arm64": "0.63.0", "@oxfmt/binding-win32-arm64-msvc": "0.63.0", "@oxfmt/binding-win32-ia32-msvc": "0.63.0", "@oxfmt/binding-win32-x64-msvc": "0.63.0" }, "peerDependencies": { "svelte": "^5.0.0", "vite-plus": "*" }, "optionalPeers": ["svelte", "vite-plus"], "bin": { "oxfmt": "bin/oxfmt" } }, "sha512-kgdDwv35wvVf6554U2Ab8Jnd0zTM+TsEQWwaB70RAjK3gICFAFGO+2Hd3Be27GMoXj3XRL9IKSNRVl7KBQL6iw=="], - "oxlint": ["oxlint@1.77.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.77.0", "@oxlint/binding-android-arm64": "1.77.0", "@oxlint/binding-darwin-arm64": "1.77.0", "@oxlint/binding-darwin-x64": "1.77.0", "@oxlint/binding-freebsd-x64": "1.77.0", "@oxlint/binding-linux-arm-gnueabihf": "1.77.0", "@oxlint/binding-linux-arm-musleabihf": "1.77.0", "@oxlint/binding-linux-arm64-gnu": "1.77.0", "@oxlint/binding-linux-arm64-musl": "1.77.0", "@oxlint/binding-linux-ppc64-gnu": "1.77.0", "@oxlint/binding-linux-riscv64-gnu": "1.77.0", "@oxlint/binding-linux-riscv64-musl": "1.77.0", "@oxlint/binding-linux-s390x-gnu": "1.77.0", "@oxlint/binding-linux-x64-gnu": "1.77.0", "@oxlint/binding-linux-x64-musl": "1.77.0", "@oxlint/binding-openharmony-arm64": "1.77.0", "@oxlint/binding-win32-arm64-msvc": "1.77.0", "@oxlint/binding-win32-ia32-msvc": "1.77.0", "@oxlint/binding-win32-x64-msvc": "1.77.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2001", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-qnGh8XJHaQ0dprrDXNQZgS0FgjI6v+V3+X8DwmaV++5Aamy6jGKfDdQ1TUvhUxtmKFAbEf4/WeO5QZX+5WSngg=="], + "oxlint": ["oxlint@1.78.0", "", { "optionalDependencies": { "@oxlint/binding-android-arm-eabi": "1.78.0", "@oxlint/binding-android-arm64": "1.78.0", "@oxlint/binding-darwin-arm64": "1.78.0", "@oxlint/binding-darwin-x64": "1.78.0", "@oxlint/binding-freebsd-x64": "1.78.0", "@oxlint/binding-linux-arm-gnueabihf": "1.78.0", "@oxlint/binding-linux-arm-musleabihf": "1.78.0", "@oxlint/binding-linux-arm64-gnu": "1.78.0", "@oxlint/binding-linux-arm64-musl": "1.78.0", "@oxlint/binding-linux-ppc64-gnu": "1.78.0", "@oxlint/binding-linux-riscv64-gnu": "1.78.0", "@oxlint/binding-linux-riscv64-musl": "1.78.0", "@oxlint/binding-linux-s390x-gnu": "1.78.0", "@oxlint/binding-linux-x64-gnu": "1.78.0", "@oxlint/binding-linux-x64-musl": "1.78.0", "@oxlint/binding-openharmony-arm64": "1.78.0", "@oxlint/binding-win32-arm64-msvc": "1.78.0", "@oxlint/binding-win32-ia32-msvc": "1.78.0", "@oxlint/binding-win32-x64-msvc": "1.78.0" }, "peerDependencies": { "oxlint-tsgolint": ">=7.0.2001", "vite-plus": "*" }, "optionalPeers": ["oxlint-tsgolint", "vite-plus"], "bin": { "oxlint": "bin/oxlint" } }, "sha512-QgQePuxIqKOzo1KSjG2EnITEeWvWnKAm77eq8nrMtf6AGoA+zyGc4PFYtDNJSD25g/ibOwfQ851hZ4/SPkMVoA=="], - "oxlint-config-presets": ["oxlint-config-presets@0.1.18", "", { "peerDependencies": { "oxlint": ">=0.15.0" }, "optionalPeers": ["oxlint"] }, "sha512-f0Iie2XIjvAopd5kv5xuVUPspCpWy3pJVLeQ1yWfFpBl1QAkiQEcPCOOqpkUKm+k6zqWJL+PyNCI+TimnBUFvw=="], + "oxlint-config-presets": ["oxlint-config-presets@0.1.19", "", { "peerDependencies": { "oxlint": ">=0.15.0" }, "optionalPeers": ["oxlint"] }, "sha512-EGOOXG2ra0j3b1tFrAISXYx//fmmzr7lhQHm9I3eo6BfzljQKsjvOI5h5ThmtumEX8Rma9E9cctVxlQ0Jj6GzQ=="], - "oxlint-tailwindcss": ["oxlint-tailwindcss@1.7.1", "", { "dependencies": { "@tailwindcss/node": "^4.3.3", "tailwindcss": "^4.3.3" } }, "sha512-b1020t2FGEaqAubB9TF2+unoCqllj8Uo1NTh6JX1uR9Ht/s4Vmunc0Nh+UdobUFH55y059SlRMhy4ljdslXdUw=="], + "oxlint-tailwindcss": ["oxlint-tailwindcss@1.9.0", "", { "dependencies": { "@tailwindcss/node": "^4.3.3", "tailwindcss": "^4.3.3" } }, "sha512-6Zp5AiKPWpkt/HOtvHP3VtmWO2vrl5PAY3DPNker771RUwp5TEHZKRrQ3yCQnSEbCRWrqaVbkBf1pWiIVpms+w=="], "oxlint-tsgolint": ["oxlint-tsgolint@7.0.2001", "", { "optionalDependencies": { "@oxlint-tsgolint/darwin-arm64": "7.0.2001", "@oxlint-tsgolint/darwin-x64": "7.0.2001", "@oxlint-tsgolint/linux-arm64": "7.0.2001", "@oxlint-tsgolint/linux-x64": "7.0.2001", "@oxlint-tsgolint/win32-arm64": "7.0.2001", "@oxlint-tsgolint/win32-x64": "7.0.2001" }, "bin": { "tsgolint": "./bin/tsgolint.js" } }, "sha512-KjK/XLcXr1DSyonKhsuFqJRiuKqcyG9j3LJ8nkOsrLzGvodBPqzHOKauy10asLMDI0sUpvb+1sxlzff3udZvfg=="], @@ -1417,7 +1421,7 @@ "std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="], - "storybook": ["storybook@10.6.0-alpha.4", "", { "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^2.0.2", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "6.9.1", "@testing-library/user-event": "^14.6.1", "@vitest/expect": "3.2.4", "@vitest/spy": "3.2.4", "@webcontainer/env": "^1.1.1", "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0 || ^0.28.0", "jsonc-parser": "^3.3.1", "open": "^10.2.0", "oxc-parser": "^0.127.0", "oxc-resolver": "^11.19.1", "recast": "^0.23.5", "semver": "^7.7.3", "use-sync-external-store": "^1.5.0", "ws": "^8.21.1" }, "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "prettier": "^2 || ^3", "vite-plus": "^0.1.15 || ^0.2.0" }, "optionalPeers": ["@types/react", "prettier", "vite-plus"], "bin": "./dist/bin/dispatcher.js" }, "sha512-p2rnQ9UaIexFZ1b4OsWUSOnvnozidIklbN01zz3E3yCoTH0rafzBkyLmcrUvOHmhbyEKMDRGzrLPYA8e8I1ENw=="], + "storybook": ["storybook@10.6.0-alpha.5", "", { "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^2.0.2", "@testing-library/dom": "^10.4.1", "@testing-library/jest-dom": "6.9.1", "@testing-library/user-event": "^14.6.1", "@vitest/expect": "3.2.4", "@vitest/spy": "3.2.4", "@webcontainer/env": "^1.1.1", "esbuild": "^0.18.0 || ^0.19.0 || ^0.20.0 || ^0.21.0 || ^0.22.0 || ^0.23.0 || ^0.24.0 || ^0.25.0 || ^0.26.0 || ^0.27.0 || ^0.28.0", "jsonc-parser": "^3.3.1", "open": "^10.2.0", "oxc-parser": "^0.127.0", "oxc-resolver": "^11.19.1", "recast": "^0.23.5", "semver": "^7.7.3", "use-sync-external-store": "^1.5.0", "ws": "^8.21.1" }, "peerDependencies": { "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "prettier": "^2 || ^3", "vite-plus": "^0.1.15 || ^0.2.0" }, "optionalPeers": ["@types/react", "prettier", "vite-plus"], "bin": "./dist/bin/dispatcher.js" }, "sha512-gMpiVcLdLLtST+b0W1mQAjp5cSKQ05CgyEcpTB15FAhhw5RuYOc4EjrFiYSBhVXuIRtP9k6g+5Ej12vXPeFMvw=="], "string-width": ["string-width@8.2.2", "", { "dependencies": { "get-east-asian-width": "^1.5.0", "strip-ansi": "^7.1.2" } }, "sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg=="], @@ -1557,7 +1561,7 @@ "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], - "@storybook/react-vite/magic-string": ["magic-string@1.1.0", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-kS3VHe0nEPST2saQV4Rbkchcd3UBRkVTQHo1D3h/ZTwFDhai/mfKkmtPAtD129EOI7K3HlHIsFOt0WrI2/oU9g=="], + "@storybook/react-vite/magic-string": ["magic-string@1.1.1", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-qFemKPzc3ttrYVaMmnSkGtGc5nE6Ncl4bj7c9IE6C9OUIRXjf6PzJ+UZ1xhVIjYc7dolHq3qKzpAJPZUbvNj+A=="], "@tailwindcss/node/lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], @@ -1635,6 +1639,8 @@ "storybook/@testing-library/jest-dom": ["@testing-library/jest-dom@6.9.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" } }, "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA=="], + "storybook/esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="], + "strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], "tsyringe/tslib": ["tslib@1.14.1", "", {}, "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg=="], @@ -1757,6 +1763,58 @@ "storybook/@testing-library/jest-dom/dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], + "storybook/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="], + + "storybook/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.28.2", "", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="], + + "storybook/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.2", "", { "os": "android", "cpu": "arm64" }, "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A=="], + + "storybook/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.28.2", "", { "os": "android", "cpu": "x64" }, "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q=="], + + "storybook/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw=="], + + "storybook/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw=="], + + "storybook/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw=="], + + "storybook/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg=="], + + "storybook/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.2", "", { "os": "linux", "cpu": "arm" }, "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w=="], + + "storybook/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug=="], + + "storybook/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ=="], + + "storybook/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ=="], + + "storybook/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA=="], + + "storybook/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ=="], + + "storybook/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA=="], + + "storybook/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg=="], + + "storybook/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.2", "", { "os": "linux", "cpu": "x64" }, "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ=="], + + "storybook/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw=="], + + "storybook/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.2", "", { "os": "none", "cpu": "x64" }, "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw=="], + + "storybook/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ=="], + + "storybook/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw=="], + + "storybook/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q=="], + + "storybook/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g=="], + + "storybook/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ=="], + + "storybook/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA=="], + + "storybook/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="], + "vitest/@vitest/expect/chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], "@babel/helper-module-imports/@babel/traverse/@babel/code-frame/@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], diff --git a/greenfield/bunfig.toml b/greenfield/bunfig.toml index 6520cff04..9fea0934a 100644 --- a/greenfield/bunfig.toml +++ b/greenfield/bunfig.toml @@ -5,7 +5,9 @@ peer = false preload = ["./src/test/setup.ts"] coverageSkipTestFiles = true coveragePathIgnorePatterns = [ - "scripts/**", + "scripts/**/testSupport/**", + "scripts/**/testSupport.ts", + "scripts/**/testSupport.tsx", "src/**/*.d.ts", "src/**/*.stories.tsx", "src/**/storySupport/**", diff --git a/greenfield/codecov.yml b/greenfield/codecov.yml index ddb593ac7..dd000aaaa 100644 --- a/greenfield/codecov.yml +++ b/greenfield/codecov.yml @@ -13,7 +13,10 @@ coverage: flags: - dashboard paths: + - scripts/ - src/ + - drizzle.config.ts + - tailwind.config.ts patch: dashboard: target: 85% @@ -24,7 +27,10 @@ coverage: flags: - dashboard paths: + - scripts/ - src/ + - drizzle.config.ts + - tailwind.config.ts comment: layout: "reach,diff,flags,tree" @@ -34,4 +40,7 @@ comment: flags: dashboard: paths: + - scripts/ - src/ + - drizzle.config.ts + - tailwind.config.ts diff --git a/greenfield/docs/architecture/greenfield-rewrite/application-architecture.md b/greenfield/docs/architecture/greenfield-rewrite/application-architecture.md index 911fd09ee..217fbf148 100644 --- a/greenfield/docs/architecture/greenfield-rewrite/application-architecture.md +++ b/greenfield/docs/architecture/greenfield-rewrite/application-architecture.md @@ -958,7 +958,7 @@ the following behavior is covered by automated tests and a manual parity checkli | `/delivery` | PR review queues, trusted PR development, previews, release records, deploy/rollback actions, progress, blocking reasons, and retention. | | `/files` | Safe workspace browsing, edit/save, upload/download/preview, Markdown/code rendering, path policy, and conflict/error handling. | | `/docker` | Inventory, independently refreshed live stats, managed update policy, checks/actions, history, console commands, and duplicate-submit prevention. | -| `/database` | PostgreSQL/PgBouncer and Dashboard SQLite views, source picker, metrics, maintenance assessment/actions, cached fallback, and balanced layout. | +| `/database` | PostgreSQL/PgBouncer and Dashboard SQLite views, source picker, bounded metrics, maintenance assessment, cached fallback, and balanced layout. Privileged backup, restore, and maintenance actions remain owned by their purpose-built domains. | | `/moltbook` | Cached/API data, refresh behavior, status and error presentation, and existing actions. | | `/settings` | Persistent OpenClaw/Dashboard tab, OpenClaw configuration, password, WebAuthn/passkeys, TOTP, recovery codes, browser sessions, secret handling, and restart actions. | | `/terminal` | Real PTY input/output, ANSI/UTF-8, resize, signals, bounded reconnect replay, cancellation, backpressure, and narrow-screen interaction. The selected workspace root is a starting location, not a filesystem sandbox. | @@ -968,3 +968,78 @@ the following behavior is covered by automated tests and a manual parity checkli The existing API endpoint list is an input to the parity inventory, not a contract to preserve. Each old endpoint must map to a new procedure, a raw protocol route, or an explicit removal reason showing that no current frontend or automation behavior depends on it. + +The implemented Database vertical is one read-only slice rather than an implicit backup or Docker +control plane. `database.overview` exposes a session-only, bounded projection: live +Dashboard-owned SQLite lifecycle facts from the retained database runtime plus a worker-owned +last-known-good PostgreSQL/PgBouncer snapshot. `/database` preserves the reviewed source +picker, responsive summary/table layout, explicit unavailable/stale states, and non-blocking +refresh warnings. It must not expose credentials, host paths, raw provider failures, arbitrary +SQL, or mutation authority. The six Kopia/WAL-G status/control rows and all database +backup/restore operations remain separately planned. The contract, provider, production +composition, browser route, and acceptance tests now land together. Its SQLite inventory +recognizes only two canonical greenfield +provenances: scheduled maintenance snapshots below `backups/sqlite-maintenance/` and activation +snapshots below `backups/` classified as `cutover`. The activation snapshot securely +consolidates the recovery purpose of legacy pre-deploy and pre-migration copies; those legacy +artifact kinds are not imported or fabricated. Each row reports whether it is only immutable- +manifest verified or was also opened and migration-checked through an isolated restore copy. +The scheduled namespace retains at most fourteen published snapshots. Activation owns cutover +retention under the deployment lease: at most five snapshots and two days of unreferenced age, +while current, previous, and in-flight journal transition identities are always preserved. +Deletion first atomically renames the selected immutable directory to an owned `.retire-*` name, +fsyncs the parent, and then uses descriptor-anchored, resumable reaping with final inode +revalidation. The deployment lease serializes that pathname unlink against other authorized writes +by the trusted application UID; a hostile process with that UID requires the planned root-owned +handoff instead. + +The external provider uses a dedicated PostgreSQL/PgBouncer observer with zero role memberships. +It receives only direct database `CONNECT`, capability-schema `USAGE`, and `EXECUTE` on four exact +no-input sanitized functions. An isolated `NOLOGIN` capability owner holds exactly +`pg_read_all_stats` plus direct per-database `SELECT` on `pg_catalog.pg_statistic`; that authority +is exposed only through bounded `connection_metrics()`, identity-free `statement_metrics()`, +`table_health()`, and `maintenance_metrics()` interfaces. Raw `pg_stat_statements` views and +routines are unavailable to `PUBLIC` and the observer. The provider derives +the bounded, sorted database inventory from the live PostgreSQL catalog on every refresh and uses +that same discovered set for database metrics, connection totals, statement aggregation, +PgBouncer projection, and admitted per-database detail. No application database, container, or +Compose service name is part of the generic inventory contract. Additions, removals, and renames +therefore reconcile without a Dashboard release or manual inventory edit. The sole named +application exception is the optional Comet/Bitmagnet torrent-count capability: each card may read +one fixed, count-only `mira_dashboard_observability.torrent_count` view when its database is +present, but its absence or failure cannot filter or fail the remaining observation. +The worker also re-discovers the database endpoint for every snapshot through bounded Docker +inventory plus one batched fixed-template inspect that excludes environment, mounts, and unrelated +labels. Exactly one healthy, running container must opt in with +`mira.dashboard.database-observability=pgbouncer-v1` and expose exactly one TCP port on an IPv4 or +IPv6 loopback binding. That single capability owns the fixed +`mira_dashboard_observability` PgBouncer control alias and dedicated same-named physical database. +Approved provisioning creates that database from `template0`; PgBouncer's existing wildcard route +preserves the client database name without a mapping or stack database environment lookup. +Standard Compose project/service labels are retained only as observed identity; container, +service, project, image, host-port, and application database membership names are not allowlists. +The observer password is the only Dashboard credential, and no database-name label or +configuration exists. +The existing hourly `cache.refresh.database-observability` action composes a separate privileged +collection-lease port only when this provider is configured. Between attempts the observer is +`NOLOGIN`, has an expired `VALID UNTIL`, and has zero PostgreSQL sessions. Each attempt first +closes leftovers, then `open-approved-collection` verifies the PostgreSQL identity and an +activation-owned approval bound to the exact current and previous immutable-release policy +digests. The policy version alone is not authorization. Every open performs the full bounded, +idempotent ACL-and-capability reconcile, keeps the role closed, and prepares a one-use token bound +to the exact catalog digest. A separate `enable-approved-collection` rechecks the approval, +identity, policy, and digest, then atomically consumes the token while setting `LOGIN` and a short +`VALID UNTIL`. The observer collects once, after +which a shielded mandatory close restores `NOLOGIN`, expires the role, terminates sessions, and +rechecks the exact closed state. Only then does the port return the payload to the generic cache +executor for commit. Any open, collection, or close failure preserves last-known-good and settles +as a retryable redacted failure without a fresh commit. No additional action, schedule, polling +loop, sidecar, systemd unit, PostgreSQL login, or exclusive admission is introduced. PostgreSQL's +closed-state proof cannot establish that PgBouncer has no already-authenticated waiting client; +such interference fails the attempt, while the closed role cannot grant that client a new backend. +A drifting application database is quarantined by revoking observer `CONNECT`, so its catalog row +remains visible with unavailable details while other databases collect. A new database is +reconciled by the next approved open before that attempt can collect it. +Query-performance triage remains snapshot-local ranked aggregates; legacy raw query text, +database/user identity, `queryid`, and copy are deliberately omitted because identifiers, +comments, or utility text can disclose database contents. diff --git a/greenfield/docs/architecture/greenfield-rewrite/data-and-security.md b/greenfield/docs/architecture/greenfield-rewrite/data-and-security.md index 83c9623a8..ebae4afee 100644 --- a/greenfield/docs/architecture/greenfield-rewrite/data-and-security.md +++ b/greenfield/docs/architecture/greenfield-rewrite/data-and-security.md @@ -122,6 +122,121 @@ Moltbook remain external systems. Dashboard persists only configuration, bounded audit/history, job state, or recovery state that it owns. It does not mirror entire external databases. +Membership in an external topology is always source-derived. PostgreSQL catalogs and Docker +Engine inventory are the membership sources; configured endpoints, trust roots, credentials, +Compose files, labels, and policy limits constrain access or enrich records but are not manual +allowlists. Each refresh is bounded and deterministically reconciles additions, removals, and +renames. A source-wide failure retains last-known-good state, while a safe per-item failure leaves +that discovered item visible with unavailable details. Neither case may be projected as a fresh +empty inventory. The sole named application-data exception is the optional Comet/Bitmagnet +count-only view capability; it cannot filter or fail generic database discovery. + +Database observability follows that rule. The web process may read Dashboard SQLite only through +the retained runtime's narrow read port; it does not open another native handle or accept SQL from +the browser. External PostgreSQL/PgBouncer collection is worker-owned and uses fixed reviewed +queries under a deadline, row budget, payload budget, and exact resource key. Only a validated +last-known-good projection crosses into Dashboard SQLite. Public rows omit connection strings, +credentials, host/container paths, raw errors, provider output, and query literals; transient +collection failure is represented as stale or unavailable instead of an empty healthy snapshot. +Dashboard SQLite backup inventory is descriptor-anchored and bounded across exactly the scheduled +maintenance and activation/cutover namespaces. It never infers legacy provenance from a filename: +the single immutable activation/cutover snapshot subsumes the former pre-deploy and pre-migration +recovery purposes. Scheduled snapshots receive a separate temporary-copy `quick_check` and +migration verification before publication; cutover snapshots honestly remain manifest-verified +until an equivalent restore-copy verification exists. +Scheduled retention is capped at fourteen. Cutover retention is enforced on every committed, +recovered, and same-candidate activation success: no more than five snapshots and no unreferenced +snapshot older than two days, while current/previous activation and active journal references are +protected. Both namespaces use an atomic parent-descriptor `.retire-*` handoff followed by a +bounded resumable reaper; published immutable directories are never chmoded or unlinked in place. +The reapers reject untrusted shapes and observed path/inode drift, while the exact deployment lease +serializes all authorized mutation by the trusted application UID. Because Linux unlink remains +pathname based and that UID can already rewrite the application-owned namespace, malicious +concurrent same-UID mutation is outside this boundary. The planned root-owned immutable handoff and +different-principal garbage collector are required before treating that principal as hostile. +Snapshot capacity is admitted from SQLite's bounded logical page count, not only the lagging main +file. Scheduled maintenance reserves two logical copies plus the free-space floor before creating +its snapshot; cutover reserves possible WAL-checkpoint growth plus one logical snapshot before the +checkpoint. The restore-copy phase is checked again against the immutable snapshot size. A large +WAL held by a reader therefore cannot make a small main-file stat understate peak disk demand. +Production collection requires a dedicated login monitoring principal rather than an application +or superuser credential. The login has no built-in monitoring membership: broad +`pg_monitor`/`pg_read_all_stats` access would reveal raw activity and statement text even when +source views were revoked. One isolated `NOLOGIN` capability owner has exactly direct +`pg_read_all_stats` membership plus direct `SELECT` on `pg_catalog.pg_statistic` in each +provisioned database. The observer has zero role memberships and receives only direct database +`CONNECT`, `USAGE` on the private capability schema, and `EXECUTE` on four exact, no-input, +bounded, sanitized `SECURITY DEFINER` functions: `connection_metrics()`, identity-free +`statement_metrics()`, `table_health()`, and `maintenance_metrics()`. `PUBLIC` and the observer +receive no raw `pg_stat_statements` source-view or extension-routine access. The statement +capability calls `pg_stat_statements(false)` internally and returns no query text, `queryid`, +database identity, or user identity. Exact source, dependency, shape, owner, ACL, and output-column +verification fails closed on drift. The observer also retains read-only transactions and a fixed +statement timeout; +PgBouncer grants only `stats_users`, never `admin_users`. Direct/inherited observer routine grants +and effective access to user-schema `SECURITY DEFINER` routines are forbidden; ordinary `PUBLIC` +invoker routines remain compatible. The code-owned PgBouncer alias and dedicated physical control +database are both named `mira_dashboard_observability`; the existing wildcard route preserves that +name without a second label, mapping, or stack database environment value. Dynamic application +inventory remains catalog-derived. The existing hourly +`cache.refresh.database-observability` job calls a separate worker-only privileged +collection-lease port only while the provider is configured. The observer is `NOLOGIN`, expired, +and has zero PostgreSQL sessions between attempts. A fixed attempt first closes leftovers. Its +`open-approved-collection` mode verifies the exact approval and identities, performs the full +bounded idempotent ACL-and-capability reconcile, keeps `NOLOGIN`, and prepares a one-use token +bound to the exact catalog digest. `enable-approved-collection` rechecks approval, identity, +policy, and digest before it atomically consumes that token and sets `LOGIN` plus a short +`VALID UNTIL`. The collector +runs once with observer authority, then a shielded mandatory close restores `NOLOGIN`, expires the +role, terminates sessions, and proves zero sessions again. Only after that proof can the lease +return a payload to the generic cache executor for commit. The observer and collector receive no +administrative credential or mutation authority. + +Explicit activation is the sole writer of the approval binding to +`pg_control_system().system_identifier` and the exact current and previous immutable-release policy +digests. The version `sanitized-capabilities-v1` is not sufficient authorization; lease operations +may read but never create or update that marker. Every approved open performs and verifies the +complete bounded, idempotent ACL-and-capability reconcile; no persisted catalog fingerprint, +verification-age state, or reduced path authorizes access. Reconciliation removes all +`PUBLIC` database privileges, so database owners retain their implicit authority while every +non-owner application role needs explicit reviewed `CONNECT`/`TEMP` grants. A newly discovered database +is reconciled before the next approved collection can expose its details. If an application +database still drifts, observer `CONNECT` is revoked for that database and only its details are +unavailable; cluster or control drift still fails the attempt. The `comet` and +`bitmagnet` count cards +read one exact row from `mira_dashboard_observability.torrent_count`. Each database uses a +non-login view owner; `PUBLIC` receives no schema, view, or base-table authority, while the +monitoring principal receives only `USAGE` on that schema and `SELECT` on that view. It receives +no direct `SELECT` on the torrent base tables. Until these grants and views are provisioned and +verified, the affected source or count card remains explicitly unavailable. + +The privileged port adds no job action, schedule, systemd unit, sidecar, polling loop, PostgreSQL +login, reusable executor credential, or exclusive admission. Any open, collection, or close +failure preserves last-known-good, blocks a fresh cache commit, and settles the hourly attempt as a +retryable failure containing only a generic redacted reason. PostgreSQL's closed-state proof does +not prove that PgBouncer has no already-authenticated waiting client because the two admission +boundaries are not transactional. Such interference fails the attempt; once the role is +`NOLOGIN` and expired and its PostgreSQL sessions are terminated, a waiting client cannot obtain a +new backend. Administrative output, database names, and provider details never cross that +settlement boundary. + +Legacy query text and its browser copy action are a reviewed security narrowing. Even +`pg_stat_statements` text can retain identifiers, comments, or utility-statement data that should +not cross the browser, audit, logging, or durable-cache boundaries. The replacement preserves +ranked calls, rows, execution time, and block metrics for performance triage, but exposes no query +literal or stable reversible query identity. +Maintenance parity is not weakened by that boundary. PostgreSQL retains bounded dead/live tuple, +last autovacuum/autoanalyze, physical-size, conservative reclaimability, and slow aggregate +signals. The browser presents explicit reasons when bloat, dead tuples, slow aggregates, missing +capabilities, or incomplete database/table assessment need review. SQLite independently reports +material reusable pages, backup age, maintenance schedule/run health, and unavailable lifecycle +facts. Absolute and relative thresholds avoid noisy warnings on small databases while still +surfacing material issues; standard PostgreSQL `VACUUM` is described as internal reuse, and host +disk reclamation as a separately planned compaction/rebuild operation. +The session-only `database.overview` query grants no backup, restore, compaction, vacuum, or +maintenance authority. The six Kopia/WAL-G status/control inventory rows and later database +backup/restore workflows remain separate capability, worker, audit, and recovery boundaries. + ### Task and agent ownership The reviewed agent directory is application configuration rather than database or Gateway @@ -266,11 +381,11 @@ match the reviewed schema, immutable checksum ledger, strictly increasing non-fu times, connection policy, and integrity checks. Unknown history fails closed. A reviewed pending prefix raises `DatabaseRuntimeSnapshotRequiredError`; it is not migrated in place. -Post-cutover delivery must add the missing snapshot/promotion protocol before enabling forward -migrations: quiesce writers, acquire the deployment lease, create and verify a WAL-safe snapshot, -apply to a copy, and atomically promote the matching release/database pair. The future web and -worker executable roots may start concurrently, but only one may own that protocol while the other -waits with a bounded deadline and validates the final schema. Neither process may contain +Post-cutover delivery uses the implemented snapshot/promotion protocol for forward migrations: +quiesce writers, acquire the deployment lease, create and verify a WAL-safe snapshot, apply to a +copy, and atomically promote the matching release/database pair. Web and worker executable roots +may start concurrently, but only activation owns that protocol while the other process waits with +a bounded deadline and validates the final schema. Neither process may contain table/column existence fallbacks. Retention remains explicit per append-only table. The later maintenance job removes bounded @@ -530,6 +645,14 @@ proxy mode names exact proxies and requires them to overwrite forwarded identity ### Secrets and dangerous boundaries - Doppler or systemd credentials remain the source for infrastructure secrets. +- PostgreSQL/PgBouncer observability uses a distinct Doppler-provided observer password. Existing + stack-wide `DATABASE_USERNAME`/`DATABASE_PASSWORD` values are never a runtime fallback, and the + worker never receives an application role, PgBouncer admin role, or PostgreSQL superuser. A + PgBouncer SCRAM verifier is credential material even when stored in a private repository: the + current tracked `/opt/docker/apps/pgbouncer/userlist.txt` must be replaced by a non-versioned, + runtime-provisioned auth input with least-readable permissions, followed by credential rotation + and a redaction-safe authentication smoke test before cutover. No auth-file content, resolved + Compose secret, or credential value may enter Dashboard state, logs, artifacts, or browser data. - A secret that must be editable through Dashboard is stored in `secret_envelopes` using a versioned AES-GCM envelope whose master key never enters SQLite. - Configuration APIs return presence/status metadata, never recoverable secret values. diff --git a/greenfield/docs/architecture/greenfield-rewrite/implementation-plan.md b/greenfield/docs/architecture/greenfield-rewrite/implementation-plan.md index c0c5d444f..16f27b67e 100644 --- a/greenfield/docs/architecture/greenfield-rewrite/implementation-plan.md +++ b/greenfield/docs/architecture/greenfield-rewrite/implementation-plan.md @@ -92,6 +92,44 @@ including restart during streaming. - keep `/opt/docker` as the separate Docker-stack project and source of truth. Dashboard is its control plane: reviewed worker adapters may inspect or queue bounded operations, but compose files, application data, and deployment ownership do not move into Dashboard state. +- treat external topology as runtime data, never as a source-code or configuration allowlist. + PostgreSQL inventory must be re-enumerated from the server catalog on every observation and + Docker inventory must later be re-enumerated from the Docker Engine with batched inspect data. + Both projections are bounded, deterministically sorted, and reconcile additions, removals, and + renames without a Dashboard release or an operator-maintained name list. Approved Compose roots + and resource ceilings define authority boundaries; they + must not become inventories. Standard Compose labels may enrich observed Docker identity but may + not gate discovery. Reuse the existing `mira.updater.enabled`, `mira.updater.autoUpdate`, + `mira.updater.track`, `mira.updater.tagPattern`, and + `mira.updater.tagPatternIsRegex` Compose labels as update-policy input. Greenfield may migrate or + tighten those labels in `/opt/docker` when needed, but mutation must require explicit valid + opt-in; missing or invalid policy leaves a discovered service inventory-only. A transient source + or per-item failure remains explicit and preserves the last known good projection instead of + fabricating an empty topology. +- database observability already applies that Engine rule narrowly for endpoint discovery: every + snapshot uses bounded ID-only `docker ps -a` plus one fixed-template batched inspect, accepts one + healthy `mira.dashboard.database-observability=pgbouncer-v1` capability, resolves its loopback + binding, and keeps only the observer password in Dashboard configuration. That single opt-in + capability owns the fixed `mira_dashboard_observability` PgBouncer control alias. Approved + provisioning creates a dedicated same-named physical database from `template0`; PgBouncer's + existing wildcard route preserves that name without an explicit mapping or environment lookup. + No second database-name label or setting exists. The inspect template + excludes container environment, mounts, unrelated labels, and resolved Compose output. The later + Docker slice expands this same source-derived pattern to the full inventory and updater without + copying endpoint identities into code or configuration. +- preserve `/opt/docker/compose.yaml` as the canonical whole-stack Compose root and resolve its + bounded include graph beneath `/opt/docker` for update targeting. All Compose start/stop/apply + mutations must execute the fixed `/opt/docker/bin/docker-compose-doppler` wrapper with + worker-built argument vectors; Dashboard must not invoke an alternate Compose command or accept + caller-supplied paths/arguments. `/opt/docker/.env` and app-local `.env` files remain opaque + Compose/Doppler inputs: validate containment, ownership, and mode where required, but never read + them into contracts, logs, audit payloads, or browser state and never edit them from this slice. + Resolve each update target to the one canonical included app Compose file that owns both the + service `image` field and its `mira.updater.*` labels. Apply a compare-and-swap image edit to that + file only, preserve unrelated formatting/content, validate the full root Compose project, and + invoke the root wrapper for the resolved service. A missing, duplicated, moved, or concurrently + changed image definition fails closed and is rediscovered; never patch the root include list or + infer an app filename from a container name. - use Dashboard's worker-owned rotation engine for an exact manifest of reviewed Dashboard, OpenClaw, and application/container regular-file logs, including the selected files beneath `/opt/docker/data`. Use Ubuntu's system logrotate only through a fixed broker for the exact @@ -116,6 +154,94 @@ including restart during streaming. principal. Separately approved provisioning must bind only that principal, exclude the web principal, validate immutable artifacts, and preserve explicit rollback; a shared-user/group grant is forbidden. +- keep the implemented `database.overview` and `/database` read-only vertical bounded: compose live + Dashboard-SQLite lifecycle facts with a worker-owned, bounded last-known-good + PostgreSQL/PgBouncer projection; preserve the source picker, maintenance assessment, responsive + tables, freshness, and failure states without exposing SQL, paths, credentials, or mutations. + Both sources must surface explicit actionable maintenance reasons: SQLite reusable space plus + backup/schedule/run health, and PostgreSQL conservative bloat, dead-tuple/autovacuum, statement, + capability, and incomplete-assessment signals. Preserve the legacy thresholds as a reviewed + baseline while allowing additional bounded evidence; do not silently downgrade assessment when + broad monitoring roles are removed. + Provision one dedicated PostgreSQL/PgBouncer observer with zero role memberships plus an isolated + `NOLOGIN` capability owner whose exact authority is direct `pg_read_all_stats` membership and + direct per-database `SELECT` on `pg_catalog.pg_statistic`. The observer receives only direct + `CONNECT`, capability-schema `USAGE`, and `EXECUTE` on the exact no-input, bounded + `connection_metrics()`, identity-free `statement_metrics()`, `table_health()`, and + `maintenance_metrics()` functions. Revoke raw `pg_stat_statements` source-view and routine access + from both `PUBLIC` and the observer; do not expose query text, `queryid`, database identity, or + user identity. The existing hourly `cache.refresh.database-observability` job must compose a + separate privileged collection-lease port only when the provider is configured. Between runs the + observer must be `NOLOGIN`, expired, and have zero PostgreSQL sessions. Each attempt must close + leftovers; run `open-approved-collection`, which verifies identity and activation approval, + performs the full bounded idempotent reconcile, and prepares a one-use token while retaining + `NOLOGIN`; run `enable-approved-collection`, which rechecks identity, approval, policy, and the + exact catalog digest before atomically consuming the token and setting `LOGIN` plus a short + `VALID UNTIL`; + collect once; run shielded mandatory close to restore and prove the exact closed state; and only + then return the payload to the generic cache executor for commit. The port spawns only the exact + immutable-release Bun runner; the collector retains only observer authority. Activation alone + creates or refreshes an approval marker bound to the PostgreSQL `system_identifier` and the exact + current and previous immutable-release policy digests. A policy version is descriptive and is + never sufficient authorization. Lease operations may only read that approval. Every approved + open performs the full bounded, idempotent reconcile before a separate one-use enable; there is no + persisted fingerprint, verification-age state, or reduced path. Reconciliation removes `PUBLIC` + database authority, grants direct observer `CONNECT` only to current non-template connectable databases, + denies template access, and requires explicit privileges for non-owner application roles while + the observer retains no mutation authority. Do not encode application database names, container + names, or Compose service names in the general inventory. Newly created or removed databases + must appear or disappear on the next bounded observation without source, manifest, or manual + Dashboard configuration changes. A new or drifted database is reconciled before the next + approved collection can expose its details, and access never widens. Any open, + collection, or close failure must preserve last-known-good, prevent a fresh cache commit, and + settle as a retryable redacted failure. PostgreSQL's closed-state proof cannot prove the absence + of already-authenticated PgBouncer waiting clients; add no exclusive admission, and treat any + interference as a failed attempt while the closed role prevents a new backend. Add no second + action, schedule, loop, sidecar, systemd unit, or PostgreSQL login. The sole + named application exception is the + optional, count-only `mira_dashboard_observability.torrent_count` projection in Comet and + Bitmagnet; those probes may use fixed database/view identifiers but must remain independently + unavailable when absent and must never filter or fail the general inventory. Treat legacy raw + query text/copy as a reviewed security narrowing while retaining generic ranked aggregate + performance metrics. + The dedicated observer password is the only Dashboard configuration input for this provider. + Discover host and published port from the worker-validated single Docker capability on every + refresh. Route the capability-owned fixed PgBouncer alias through the existing wildcard to the + dedicated same-named physical control database, so it never becomes a manually synchronized + label, environment value, or machine-specific default. Keep dynamic application inventory + catalog-derived; the code-owned control database is a capability, not an application allowlist. + Run approval-gated provisioning from the immutable current release with the exact selected Bun + runtime; pin the local Docker socket and root Compose project, resolve the healthy PostgreSQL + dependency, and use container-local psql through a scrubbed fixed launcher. Revalidate the + probed superuser role OID and PostgreSQL system identity before every SQL payload; never depend + on host psql or ambient host/container `PG*` endpoint variables. + On initial provisioning, run explicit `activate-current-catalog --approved` after those manual + prerequisites and before `verify-current-catalog --approved`, because verification requires an + existing matching approval. On later releases, verification may run first only when the retained + current or previous policy digest already approves that release; otherwise activation runs first. + Before Phase 5 can close, finish the production credential cutover rather than inheriting + legacy defaults: provision a distinct `MIRA_DASHBOARD_DATABASE_OBSERVABILITY_PASSWORD` through + Doppler, apply it to `mira_dashboard_observer` through a reviewed non-logging activation path, + and never fall back to `DATABASE_USERNAME`/`DATABASE_PASSWORD`, `postgres/postgres`, or a + superuser credential. `/opt/docker/apps/pgbouncer/userlist.txt` currently contains a tracked + SCRAM verifier. A private repository limits distribution but is not secret storage. Replace the + tracked file with a runtime-generated or equivalently secret-mounted PgBouncer auth file, keep + it out of Git and out of group/world-readable storage, then rotate the affected credential so + the verifier retained in Git history is no longer current. Verify the cutover through the + Doppler Compose wrapper without printing resolved configuration, auth-file contents, or secret + values. This remediation, the single PgBouncer capability label, its fixed control alias, and the + existing hourly job's separate privileged collection-lease port is mandatory cutover work, + even if their final Compose implementation lands with the Docker slice. + Compose only canonical scheduled and activation/cutover SQLite snapshots. Treat one immutable + activation snapshot as the reviewed secure consolidation of the legacy pre-deploy and + pre-migration recovery purposes; never synthesize unsupported provenance. Retain at most + fourteen scheduled snapshots and at most five cutover snapshots/two days of unreferenced + cutover age, protecting current, previous, and active-journal identities through descriptor- + anchored atomic-retire cleanup under the trusted same-UID deployment-lease boundary. A future + root-owned immutable handoff and different-principal garbage collector are required to defend + against malicious concurrent mutation by that UID. + Keep the six Kopia/WAL-G status/control rows and database backup/restore in their separate + privileged slices. **Exit gate:** capability, step-up, audit, cancellation, resource-limit, and failure-recovery tests pass for every privileged operation. diff --git a/greenfield/docs/architecture/greenfield-rewrite/progress.md b/greenfield/docs/architecture/greenfield-rewrite/progress.md index 86720dd69..44a1e2c8b 100644 --- a/greenfield/docs/architecture/greenfield-rewrite/progress.md +++ b/greenfield/docs/architecture/greenfield-rewrite/progress.md @@ -7,15 +7,15 @@ This matrix is the living phase status. Update it in the same change that materially advances or closes a phase; dated entries below provide the evidence, not a second status source. -| Phase | Status | Current evidence and remaining gate | -| ----------------------------------- | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 0 — Evidence and qualification | Complete | All eight mandatory spikes pass on exact Bun revision `17d6843606d76620cb55d31424d7fb0aed51c367`: build, transport, cross-process SQLite/outbox, Drizzle/Bun SQLite, browser data, chat batching, shutdown, and capped resources. Source-derived parity and the OpenClaw source audit pass as additional evidence. | -| 1 — Foundation | Complete | The self-contained future root builds immutable browser/web/worker artifacts, protects project-local production state, installs exact Bun and systemd artifacts, migrates a database copy, atomically promotes the release/database pair, serves readiness/browser assets, writes project-local logs, and proves crash-safe rollback and shutdown in a disposable lifecycle. | -| 2 — Trust and transport | Complete for the stated server scope | Authentication, MFA, WebAuthn, automation credentials, audit, authenticated renewable SSE, one-shot native Gateway bootstrap verification, and the consolidated [threat model](../../security/greenfield-phase-two-threat-model.md) have executable evidence. Browser UI and production cutover remain later gates. | -| 3 — Core operator domains | Started | Task and agent-directory parity are implemented with durable history, realtime invalidation, and browser workflows. Monitoring ingestion plus report, incident, and notification server/browser parity are implemented. Dashboard-local durable schedules/jobs, real worker execution, their `/jobs` operator UI, the first claim-fenced `system.host` cache provider, its cache browser, and bounded system metrics are implemented. Root composition covers every implemented Phase 3 operator domain, and Phase 4A now supplies the OpenClaw-cron half of `/jobs`. Full root parity and privileged/external providers remain later gates, so the Phase 3 exit stays open. | -| 4 — Gateway and chat | Started | The current installed OpenClaw source is hash-pinned for the persistent sessions, cron, chat, companion, task, and media surfaces. Process-owned Gateway lifecycle, durable realtime invalidation, sessions and agent availability, OpenClaw cron/tasks, the compact heartbeat, the durable chat journal/runtime, bounded history and reconciliation, managed and descriptor-rooted local-history media through one transcript-authorized proxy, and the `/chat` frontend are implemented. Recorded contract, protocol, service, browser, restart, load-boundary, and security tests cover the slice; live Gateway smoke/restart evidence and the Phase 4 exit gate remain open. | -| 5 — Privileged and external domains | Started | Files and Logs have closed reviewed `/files` and `/logs` parity. Moltbook now has a fixed-host worker-only provider, one claim-fenced durable last-known-good snapshot, four session-only read procedures, and the reviewed `/moltbook` browser workflow. Terminal is a worker-owned interactive PTY over a hardened WebSocket with bounded reconnect replay. `/settings` exposes bounded, redacted OpenClaw configuration and skill controls plus a one-shot exact configuration export and a durable worker-owned Gateway restart. The legacy local-media path API is securely narrowed to opaque transcript-bound Chat media references without a new browser route. Overview Service Actions expose six fixed intents; OpenClaw cleanup/restart/update are worker-owned, while bounded system cleanup preserves the reviewed package/journal/Docker effects behind a foundation that remains unavailable until the distinct-worker topology and separately approved root provisioning exist. `POST /api/exec/start` therefore stays planned. Docker control, database, GitHub, deployment, database backup/restore, production host provisioning, and the remaining privileged adapters stay open; the Phase 5 exit gate is not claimed. | -| 6 — Parity, hardening, and cutover | Not started | Full UI parity, generated `/docs`, load/resource/restore evidence, cutover rehearsal, fresh production database, and legacy removal remain open. | +| Phase | Status | Current evidence and remaining gate | +| ----------------------------------- | ------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 0 — Evidence and qualification | Complete | All eight mandatory spikes pass on exact Bun revision `17d6843606d76620cb55d31424d7fb0aed51c367`: build, transport, cross-process SQLite/outbox, Drizzle/Bun SQLite, browser data, chat batching, shutdown, and capped resources. Source-derived parity and the OpenClaw source audit pass as additional evidence. | +| 1 — Foundation | Complete | The self-contained future root builds immutable browser/web/worker artifacts, protects project-local production state, installs exact Bun and systemd artifacts, migrates a database copy, atomically promotes the release/database pair, serves readiness/browser assets, writes project-local logs, and proves crash-safe rollback and shutdown in a disposable lifecycle. | +| 2 — Trust and transport | Complete for the stated server scope | Authentication, MFA, WebAuthn, automation credentials, audit, authenticated renewable SSE, one-shot native Gateway bootstrap verification, and the consolidated [threat model](../../security/greenfield-phase-two-threat-model.md) have executable evidence. Browser UI and production cutover remain later gates. | +| 3 — Core operator domains | Started | Task and agent-directory parity are implemented with durable history, realtime invalidation, and browser workflows. Monitoring ingestion plus report, incident, and notification server/browser parity are implemented. Dashboard-local durable schedules/jobs, real worker execution, their `/jobs` operator UI, the first claim-fenced `system.host` cache provider, its cache browser, and bounded system metrics are implemented. Root composition covers every implemented Phase 3 operator domain, and Phase 4A now supplies the OpenClaw-cron half of `/jobs`. Full root parity and privileged/external providers remain later gates, so the Phase 3 exit stays open. | +| 4 — Gateway and chat | Started | The current installed OpenClaw source is hash-pinned for the persistent sessions, cron, chat, companion, task, and media surfaces. Process-owned Gateway lifecycle, durable realtime invalidation, sessions and agent availability, OpenClaw cron/tasks, the compact heartbeat, the durable chat journal/runtime, bounded history and reconciliation, managed and descriptor-rooted local-history media through one transcript-authorized proxy, and the `/chat` frontend are implemented. Recorded contract, protocol, service, browser, restart, load-boundary, and security tests cover the slice; live Gateway smoke/restart evidence and the Phase 4 exit gate remain open. | +| 5 — Privileged and external domains | Started | Files and Logs have closed reviewed `/files` and `/logs` parity. Moltbook now has a fixed-host worker-only provider, one claim-fenced durable last-known-good snapshot, four session-only read procedures, and the reviewed `/moltbook` browser workflow. Terminal is a worker-owned interactive PTY over a hardened WebSocket with bounded reconnect replay. `/settings` exposes bounded, redacted OpenClaw configuration and skill controls plus a one-shot exact configuration export and a durable worker-owned Gateway restart. The legacy local-media path API is securely narrowed to opaque transcript-bound Chat media references without a new browser route. Overview Service Actions expose six fixed intents; OpenClaw cleanup/restart/update are worker-owned, while bounded system cleanup preserves the reviewed package/journal/Docker effects behind a foundation that remains unavailable until the distinct-worker topology and separately approved root provisioning exist. `POST /api/exec/start` therefore stays planned. The read-only `database.overview` procedure and `/database` workflow are implemented with independent SQLite and PostgreSQL/PgBouncer states; privileged backup/restore remains separate. Docker control, GitHub, deployment, database backup/restore, production host provisioning, and the remaining privileged adapters stay open; the Phase 5 exit gate is not claimed. | +| 6 — Parity, hardening, and cutover | Not started | Full UI parity, generated `/docs`, load/resource/restore evidence, cutover rehearsal, fresh production database, and legacy removal remain open. | ### 2026-08-03 — Phase 0 started @@ -1597,7 +1597,128 @@ full-browser parity, production rehearsal, cutover, and legacy deletion remain o three consumed cleanup effects without restoring their unsafe shared shell boundary. Because the host executor remains unavailable in production, `POST /api/exec/start` stays planned until the distinct-worker topology and separately approved root provisioning are complete. -- The living inventory remains **112 implemented, 42 planned, and three reviewed removals** out of - 157 legacy endpoints. Browser routes remain **12 implemented and four planned**. This advances - Phase 5 without claiming complete exec, host-operation, Docker/database/delivery parity, or the +- The living inventory is now **113 implemented, 41 planned, and three reviewed removals** out of + 157 legacy endpoints. Browser routes are **13 implemented and three planned**. This advances + Phase 5 without claiming complete exec, host-operation, Docker/delivery or database + backup/restore parity, or the aggregate Phase 5 exit gate. + +### 2026-08-12 — Database observability vertical implemented; production credential cutover open + +- Legacy evidence identifies one read-only `GET /api/database/overview` consumer and the stable + session route `/database`. The reviewed replacement is `database.overview`: live bounded + Dashboard-SQLite lifecycle facts composed with a worker-owned last-known-good + PostgreSQL/PgBouncer projection. The page retains the source picker, responsive summaries and + tables, maintenance assessment, freshness, stale-data warning, and explicit unavailable state. +- This review does not grant arbitrary SQL, Docker/exec, backup, restore, vacuum, or maintenance + authority. Credentials, connection strings, host/container paths, provider output, raw errors, + and query literals do not cross the contract. The six Kopia/WAL-G status/control rows and later + database backup/restore remain separate planned privileged work. +- The slice now carries a permanent dynamic-topology invariant for this database work and the + future Docker slice. Database names are discovered from the live bounded PostgreSQL catalog; + Docker containers and Compose identity will be discovered from Docker Engine inventory and + inspect metadata. Endpoint credentials and approved roots constrain authority but never become + operator-maintained topology lists. Additions, removals, and renames must reconcile without a + Dashboard release or manual source/configuration/manifest edits. +- The future Docker updater will reuse the deployed `mira.updater.*` Compose labels. Inventory does + not depend on those labels; they are an explicit mutation-policy capability. Greenfield will + require valid opt-in for updates and may migrate the label shape in `/opt/docker` during that + slice instead of copying service names into Dashboard code. +- Docker delivery ownership is also fixed for the later slice: `/opt/docker/compose.yaml` is the + canonical root/include graph and `/opt/docker/bin/docker-compose-doppler` is the sole Compose + mutation executor. Root and app-local `.env` files stay opaque; no secret-bearing environment or + resolved Compose output may enter Dashboard state, logs, audit records, or browser payloads. +- Per-container update labels and image ownership remain in each included app Compose file. The + later updater must derive that exact file/field through the root include graph, compare-and-swap + only the image scalar, validate the whole root project, and fail closed on concurrent or + ambiguous ownership rather than maintaining or guessing a filename map. +- Delivery provisions a dedicated PostgreSQL/PgBouncer observer with zero role memberships and an + isolated `NOLOGIN` capability owner with exactly direct `pg_read_all_stats` membership plus + direct per-database `SELECT` on `pg_catalog.pg_statistic`. The observer receives only direct + `CONNECT`, capability-schema `USAGE`, and `EXECUTE` on exact no-input, bounded + `connection_metrics()`, identity-free `statement_metrics()`, `table_health()`, and + `maintenance_metrics()` functions. Raw `pg_stat_statements` source views and extension routines + are revoked from `PUBLIC` and the observer; statement output contains no query text, `queryid`, + database identity, or user identity. Full convergence is part of a separate privileged + collection-lease port invoked only by the existing hourly + `cache.refresh.database-observability` job and only when the provider is configured. The + observer is `NOLOGIN`, expired, and has zero PostgreSQL sessions between attempts. Each attempt + closes leftovers; prepares a one-use token only after exact approval/identity verification and + full reconcile; rechecks the exact catalog digest and atomically consumes that token while + enabling `LOGIN` with a short `VALID UNTIL`; collects once; and runs + a shielded mandatory close that restores and proves the exact closed state. Only after that proof + can the port return a payload to the generic cache executor for commit. The observer and + collector never receive mutation authority. Explicit activation alone creates or refreshes an + approval marker bound to the PostgreSQL `system_identifier` and the exact current and previous + immutable-release policy digests; the policy version alone is not authorization, and lease + operations cannot mutate the approval. Every approved open performs and verifies the complete + bounded, idempotent ACL-and-capability reconcile before one-use enable; no persisted + fingerprint, verification-age state, or reduced path is used. A newly created database is + reconciled before the next approved collection. Any open, collection, or close failure retains last-known-good, prevents a fresh + cache commit, and settles as a retryable redacted failure. PostgreSQL close cannot prove the + absence of already-authenticated PgBouncer waiting clients; no exclusive admission is added, + interference fails the attempt, and the closed role prevents a new backend. No second action, + schedule, polling loop, sidecar, systemd unit, or PostgreSQL login is added. Generic + verification also rejects unrelated direct/inherited observer routine grants and effective + user-schema `SECURITY DEFINER` execution without rejecting ordinary `PUBLIC` invoker routines. + The optional, count-only Comet/Bitmagnet torrent views and cards are the sole named + application exception; they never define the general database set, and their absence makes only + the matching card unavailable. Legacy raw query text/copy is a reviewed security narrowing; + ranked aggregate statement metrics remain available without literals or reversible query + identity. +- Production enablement remains intentionally open until the Docker source of truth carries the + single `mira.dashboard.database-observability=pgbouncer-v1` capability label, its code-owned + `mira_dashboard_observability` control alias, the existing hourly job's privileged + collection-lease port, and the migrated credential + boundary. Approved provisioning creates a dedicated same-named physical database from + `template0`, and PgBouncer's existing wildcard route reaches it without an explicit mapping, + stack database environment lookup, database-name label, or Dashboard setting. The fixed control + database remains code-owned while application inventory remains catalog-derived. The worker + must receive a distinct + `MIRA_DASHBOARD_DATABASE_OBSERVABILITY_PASSWORD` from Doppler, never the existing + `DATABASE_USERNAME`/`DATABASE_PASSWORD` pair or legacy `postgres/postgres` fallback. The current + private Docker repository still tracks `apps/pgbouncer/userlist.txt`; its SCRAM verifier is not + cleartext but remains credential material. Before the rewrite is declared complete, replace it + with a runtime-generated or equivalently secret-mounted, non-versioned auth input, restrict its + readability, rotate the affected credential so Git history is obsolete, and pass rollback-safe + PgBouncer/Dashboard smoke checks without printing resolved Compose or secret values. No current + production credential is changed by this implementation slice. +- The approval-gated provisioning runner is release-self-contained. It pins the local Docker + socket and root Compose project, resolves the one healthy PostgreSQL dependency, and uses + container-local psql through a fixed `env -i` launcher over the local Unix socket. Each bounded + stdin pass verifies the probed superuser role OID and PostgreSQL system identity. The documented + entrypoint selects exact current-release and production-Bun pointers; host psql and ambient + `PG*` endpoint configuration are outside the authority boundary. +- Bookkeeping is now **113 implemented, 41 planned, and three reviewed removals**; browser routes + are **13 implemented and three planned**. `database.overview` and `/database` are implemented + because the contract, worker provider, database-runtime read port, production composition, + browser route, and acceptance evidence land together. +- SQLite lifecycle evidence now composes the bounded scheduled-maintenance namespace with the + immutable activation/cutover namespace. Only `scheduled` and `cutover` are canonical kinds. + The activation snapshot securely consolidates the recovery purpose of legacy pre-deploy and + pre-migration copies; no old artifact kind is claimed or imported. Scheduled publication now + requires a separate temporary restore copy to pass `quick_check` and verified migrations, and + the exclusive job reconciles bounded crash-left stage/verification/retire directories before + work. Scheduled retention is capped at fourteen. Every committed/recovered/same-candidate + activation enforces cutover retention at five snapshots and two days of unreferenced age while + preserving current, previous, and active-journal transition IDs. Both namespaces atomically + rename selected artifacts to `.retire-*`, fsync the parent, and resume descriptor-anchored + reaping with final inode revalidation after interruption; published immutable snapshots are + never mutated in place. +- Production activation now applies the same bounded-lifecycle invariant to the code and runtime + roots. It preserves only the authoritative current/rollback release-runtime pairs and the + candidate during admission, verifies the complete bounded inventory before mutation, and then + atomically retires and reaps every unreferenced manifest-verified release and revision-probed Bun + runtime. Crash-left stage/retire trees reconcile on the next pass; unknown entries, pointer drift, + invalid trees, or path replacement fail closed before authoritative artifacts are removed. + Admission now recovers any active journal and completes that verified pruning before measuring + conservatively rounded destination allocation blocks, directory metadata, and inode demand while + preserving a 64 MiB byte reserve and 64 free inodes. Copied files and directories are fsynced + bottom-up, and the parent of each immutable stage rename is fsynced before publication returns. + Failed install/publication attempts repeat the pass under the same lease, while a later attempt + reaps crash-left candidates before admitting another copy. + Linux has no inode-conditional unlink, so physical reclamation relies on the exact deployment + lease serializing every authorized mutation by the trusted application UID. The race checks cover + accidental/stale drift, not a malicious concurrent same-UID process that can already rewrite the + application-owned namespace; the planned root-owned immutable handoff and different-principal GC + are required to change that threat boundary. diff --git a/greenfield/docs/architecture/greenfield-rewrite/runtime-and-delivery.md b/greenfield/docs/architecture/greenfield-rewrite/runtime-and-delivery.md index 794c2cae1..faa7d8976 100644 --- a/greenfield/docs/architecture/greenfield-rewrite/runtime-and-delivery.md +++ b/greenfield/docs/architecture/greenfield-rewrite/runtime-and-delivery.md @@ -303,6 +303,135 @@ Minimum operational signals include: The Dashboard displays the last known good operational data with a freshness marker when a refresh fails. It never converts a dependency outage into an empty healthy-looking screen. +The implemented Database page applies this operational model without adding a privileged request +path. One worker refresh produces the bounded PostgreSQL/PgBouncer snapshot and persists it through +the existing claim-fenced cache protocol; refresh work is not repeated per browser request. +Dashboard SQLite lifecycle facts come from the already-retained process database runtime and are +joined with that cached external projection by `database.overview`. Collection is sequential or +low-concurrency, abortable, deadline-bound, and constrained by a database-specific 128 KiB payload +limit beneath the generic 256 KiB cache ceiling, plus contract row limits. Missing or failed external data remains explicitly unavailable +or last-known-good. The `/database` route is lazy and session-gated, retains validated data during +background failure, and offers no mutation controls. Backup/restore and Kopia/WAL-G operations are +not enabled by this observability composition. The read-only SQLite inventory composes scheduled +and activation/cutover snapshots only. The activation snapshot is the reviewed greenfield +consolidation of legacy pre-deploy and pre-migration recovery purposes, not an import of those old +artifact kinds. A scheduled snapshot is not published until a separate temporary copy passes +SQLite integrity and migration checks; crash-left staging/verification directories are reconciled +under the exclusive maintenance job. Fourteen scheduled snapshots are retained. The deployment +lease enforces cutover retention on every committed/recovered/same-candidate success: at most five +snapshots, at most two days for unreferenced snapshots, and unconditional preservation of current, +previous, and active-journal identities. Both namespaces use parent-descriptor atomic `.retire-*` +rename plus fsync before bounded resumable descriptor-anchored reaping under the trusted same-UID +deployment lease, so a crash never requires mutating or recursively deleting a published immutable +snapshot in place. +Before either snapshot path writes, capacity uses `page_count * page_size` under the validated +SQLite connection. Scheduled maintenance budgets the simultaneously resident VACUUM snapshot and +restore-verification copy; activation budgets possible checkpoint expansion plus its VACUUM +snapshot before issuing the truncate checkpoint. Both retain the fixed free-space reserve and a +post-snapshot restore-copy recheck. + +Database-observability provisioning is a separate fail-closed delivery step. It creates one +dedicated monitoring login with zero role memberships and grants PgBouncer `stats_users` without +admin authority. An isolated `NOLOGIN` capability owner holds exactly direct +`pg_read_all_stats` membership plus direct per-database `SELECT` on +`pg_catalog.pg_statistic`. The observer receives only direct database `CONNECT`, private-schema +`USAGE`, and `EXECUTE` on the exact no-input, bounded `connection_metrics()`, identity-free +`statement_metrics()`, `table_health()`, and `maintenance_metrics()` functions. `PUBLIC` and the +observer have no raw `pg_stat_statements` source-view or extension-routine access. Database names +are not release inventory: each +refresh derives the bounded, sorted set from `pg_catalog.pg_database`, and generic per-database +policy checks admit detail reads. The existing hourly +`cache.refresh.database-observability` job composes a separate privileged collection-lease port +only when the provider is configured. Administrative psql authority never enters Dashboard +configuration or the collector. Between attempts the observer is `NOLOGIN`, has expired +`VALID UNTIL`, and has zero PostgreSQL sessions. The fixed attempt closes leftovers; invokes +`open-approved-collection` to verify approval and identity, perform the full bounded idempotent +ACL-and-capability reconcile, retain `NOLOGIN`, and prepare a one-use token bound to the exact +catalog digest; `enable-approved-collection` then rechecks approval, identity, policy, and digest +before atomically consuming the token and setting `LOGIN` plus a short `VALID UNTIL`; +collects once; and invokes a shielded +mandatory close that restores and proves the exact closed state. The port returns a fresh payload +only after close proof, so the generic cache executor commits afterward. Any open, collection, or +close failure instead preserves last-known-good and settles as a retryable redacted failure without +a fresh commit. + +Explicit activation alone creates or refreshes the approval marker bound to the PostgreSQL +`system_identifier` and the exact current and previous immutable-release policy digests; the policy +version alone is not authorization, and lease operations cannot mutate the approval. Every open +checks that binding and performs and verifies the full bounded, idempotent reconcile before the +separate one-use enable; no persisted +fingerprint, verification-age state, or reduced path is used. Reconciliation removes `PUBLIC` +database privileges, grants direct observer `CONNECT` only to non-template connectable databases, +applies and verifies every sanitized capability, and rejects catalog, policy-digest, or endpoint +races. No additional action, +schedule, loop, sidecar, systemd unit, function-executor login, credential, or exclusive admission +is introduced. Adding, removing, or renaming a database therefore requires no Dashboard source, +manifest, or configuration edit. A new database is reconciled by the next approved open before +that attempt collects it; the pass removes unsafe database authority, grants the exact observer +ACL, and installs the verified interfaces. +After the manual first-install prerequisites, explicit `activate-current-catalog --approved` must +be the first runner operation because `verify-current-catalog --approved` requires an existing +matching approval. Activation finishes closed; verification then proves the approved state. On a +later release, verification may precede activation only when the retained current or previous +policy digest already approves that release. Otherwise activation must run first. +PostgreSQL close proof cannot prove that PgBouncer has no already-authenticated waiting client; a +waiting client that interferes causes a failed attempt, while `NOLOGIN`, expiry, and termination +prevent it from obtaining a new backend after close. The observer password is the sole Dashboard +credential input. On every snapshot +attempt the worker runs bounded `docker ps -a` followed by one batched, fixed-template +`docker inspect`, then requires exactly one running, healthy container with +the explicit `mira.dashboard.database-observability=pgbouncer-v1` capability label and exactly one +loopback-published TCP binding. That single capability owns the fixed +`mira_dashboard_observability` PgBouncer control alias. Approved provisioning creates the +dedicated same-named physical database from `template0`, and PgBouncer's existing wildcard route +preserves the client database name without an explicit mapping or environment interpolation. No +database-name label or Dashboard setting exists. Container, Compose project/service, image, and +host-port values are observations, never configured authority; application-database renames and +port changes therefore reconcile without an application or secret edit. Credential absence, +discovery absence or ambiguity, privilege drift, catalog overflow, or an unexpected row shape +causes an explicit unavailable/last-known-good state and never widens authority or falls back to +an application credential. App-specific relations and cards, including the legacy Comet and +Bitmagnet torrent counts, cannot define the generic inventory. The sole named exception is an +optional, count-only `mira_dashboard_observability.torrent_count` probe in those two databases. +Its reviewed fixed identifiers are isolated capability metadata: a missing database or view makes +only that card unavailable and never hides or fails unrelated dynamically discovered databases. +The inspect template admits only ID, state/health, the one database capability label, standard +Compose project/service identity, and structured published ports; it never ingests container +environment, mounts, or the remaining labels into the Dashboard process. + +The approval-gated provisioning runner has a separate Docker execution boundary. It pins the +local Engine socket and root Compose file/project directory, resolves the one healthy PostgreSQL +dependency of that capability, and runs container-local psql over the fixed Unix socket. A fixed +launcher carries only the existing non-secret administrative username into `env -i`; it discards +host/container endpoint variables and passwords. Every bounded stdin execution verifies the +probed superuser role OID and PostgreSQL system identifier before SQL runs. Provisioning artifacts +are allowlisted, descriptor-pinned, bounded immutable-release children with contained `\ir` +expansion. The operator command uses the exact production Bun/current-release pointers, not host +psql, ambient `PG*`, or PATH-selected Bun. + +Production credential cutover remains a release gate for this provider. The existing +`DATABASE_USERNAME`/`DATABASE_PASSWORD` Doppler pair is administrative/application-stack input; +it must not become a Dashboard runtime fallback or grant the worker PostgreSQL/PgBouncer admin +authority. Doppler must instead supply the distinct observer password named by the application +configuration registry, while host and port remain discovery-owned and the control alias, physical +control database, and role name remain code/capability-owned as described above. The observer credential is +applied by a reviewed activation +path that withholds it from argv, output, logs, manifests, and generated documentation. + +The currently deployed `/opt/docker/apps/pgbouncer/userlist.txt` is a Git-tracked SCRAM auth file. +Its verifier is not a cleartext password, and the repository is private, but it remains +credential material: repository access and Git history widen its lifetime and permit offline +guessing of a weak password. Before Phase 5 closure or production cutover, the Docker source of +truth must stop tracking that file, provision an equivalent PgBouncer auth input from Doppler at +runtime with no group/world-readable copy, and rotate the affected credential after the new path +has passed a rollback-capable PgBouncer authentication smoke test. Historical Git rewriting is a +separate destructive decision and is not implied; rotation makes the retained historical verifier +obsolete. Validation uses `/opt/docker/bin/docker-compose-doppler` in a mode that emits no resolved +Compose document or secret values. The app-owned PgBouncer Compose file must carry the exact single +database-observability capability label and fixed control alias, and the worker must compose the +separate privileged collection-lease port into the existing hourly cache action, before this +provider is enabled. + ## Resource Safety At this audit, the production web service used about 247 MiB with a recorded peak of 379 MiB; @@ -407,6 +536,45 @@ repository or state tree. Dashboard is the control plane: browser requests selec operations, durable jobs and audit records preserve intent/outcome, and worker-only adapters touch the Docker project within explicit policy and resource bounds. +The Docker slice must discover its topology at runtime rather than ship a service catalog. A +worker-owned, read-only adapter enumerates Docker Engine containers and collects batched inspect +data; standard Compose project/service labels are observed metadata, not required names. The +configured Compose roots constrain where later mutation policy may resolve files, but they do not +decide which running containers are visible. Bounded reconciliation must add, remove, and rename +containers, services, projects, images, networks, and volumes without manual Dashboard handling. +Ambiguous or disappearing items fail closed individually, while a source-wide Engine failure keeps +the last known good inventory with an explicit freshness state. + +Update discovery reuses the Compose policy already deployed under `/opt/docker`: +`mira.updater.enabled`, `mira.updater.autoUpdate`, `mira.updater.track`, +`mira.updater.tagPattern`, and `mira.updater.tagPatternIsRegex`. Both list and map label syntax are +normalized, tag regexes are safety-checked, and Compose project/service identity is joined against +Engine labels rather than container names. Greenfield tightens the legacy default: inventory is +automatic, but an update mutation requires an explicit valid `mira.updater.enabled=true`; absent, +ambiguous, or invalid labels remain visible and non-mutable. Necessary label-schema changes are +versioned and applied to the Compose source of truth as part of the Docker slice, not maintained as +a parallel Dashboard service catalog. + +`/opt/docker/compose.yaml` is the canonical whole-stack project entrypoint. The worker resolves its +bounded recursive include graph with canonical regular-file containment beneath `/opt/docker`, so +root-level start/stop and service-level update operations use the same project definition. The only +admitted Compose executor is `/opt/docker/bin/docker-compose-doppler`, invoked from `/opt/docker` +with a fixed executable and worker-constructed argv. Browser input cannot select an executable, +working directory, Compose path, environment file, or free-form flag. Root `/opt/docker/.env` and +app-local `.env` files are opaque inputs consumed by Compose/Doppler; Dashboard may verify metadata +needed for a fail-closed preflight but never returns, logs, diffs, rewrites, or persists their +contents. Resolved Compose output that could contain injected secret values is likewise forbidden +from contracts and logs. + +Each managed container's update labels and editable `services..image` source live in its +included app Compose file. Discovery joins Engine project/config/service labels to the canonical +root include graph and records the exact defining file/field under the Docker trust root. An update +re-resolves that ownership under the worker lease, verifies the expected old scalar, atomically +edits only that image field while preserving unrelated YAML, and validates the complete root +project before calling the Doppler wrapper for the resolved service. If includes, labels, image +ownership, or source text changed concurrently, the attempt does not guess: it aborts, restores the +pre-edit file when necessary, refreshes discovery, and requires a new intent. + The future repository root ships new `systemd/` web and worker units as part of the immutable release. The legacy units were deliberately not copied: they change into a `backend` working directory, execute retired `dist/*Start.js` entrypoints, and retain pre-measurement multi-gigabyte @@ -525,9 +693,10 @@ Recommended layout: systemd/ release-manifest.json releases/current -> - releases/previous -> runtimes/bun//bun + runtimes/bun/current -> state/ + activation.json # authoritative current/previous release-runtime pairs mira-dashboard.db backups/ job-output/ @@ -550,12 +719,21 @@ and required process roles. It contains no secrets. Deployment flow: 1. Build and test one artifact using the same resolved Bun runtime throughout the build. -2. Transfer or materialize it into a new immutable release directory and verify every hash. +2. Verify every source artifact hash and the exact runtime identity without copying into the + production artifact roots yet. 3. Prepare and verify `/production/state` plus its protected ancestor chain before changing the active release pointer. -4. Acquire the deployment lease, install/reload the verified stop-owner units, drain active jobs, - enter maintenance mode, durably journal the exact stop intent, and only then quiesce all - database writers. Recovery treats this pre-snapshot phase as database-unmodified and +4. Acquire the deployment lease, recover any durable activation journal, and run verified + release/runtime retention before copying. Admit the missing source-tree and Bun runtime using + destination allocation blocks, conservative directory metadata, free-inode capacity, a fixed + 64 MiB byte reserve, and 64 free reserve inodes. Each copied file and the directory tree are + fsynced bottom-up before the immutable stage is renamed, and that rename's parent is fsynced + before publication or runtime installation returns. Install/publication failure repeats the + same journal-aware retention pass immediately; a crash between those operations is reconciled + before the next attempt, so distinct failed candidates cannot accumulate indefinitely or consume + space needed by the authoritative pair. Install/reload the verified stop-owner units, drain + active jobs, enter maintenance mode, durably journal the exact stop intent, and only then quiesce + all database writers. Recovery treats this pre-snapshot phase as database-unmodified and idempotently restores the previous service owner before clearing the journal. 5. Snapshot and verify the current database while writers remain stopped. 6. Apply migrations to a copy, run schema/preflight checks, then atomically promote the @@ -565,7 +743,24 @@ Deployment flow: deadlines. A rollback reinstalls the previous release's units before restarting its paired release/database state. 8. Run authenticated smoke checks, including tRPC, SSE, Gateway, docs, and one safe queued job. -9. Atomically record current/previous and prune only releases whose manifests verify. +9. Atomically record current/previous and run release/runtime retention only after the complete + managed inventories verify. Retention preserves the authoritative current and rollback pairs + plus the candidate while a transition is being prepared, so successful activation converges to + at most two immutable releases and at most two Bun revisions. Every other commit-addressed + release must pass its manifest/tree verification, and every other runtime must pass its exact + revision probe, before either root is mutated. Selected directories are atomically renamed to + `.retire-*`, the parent is synced, and bounded descriptor-rooted, same-owner/same-mount, + no-symlink reaping resumes after interruption. Files move through private tombstones and are + checked against held descriptors and observed inodes immediately before pathname-based unlink. + Linux provides no inode-conditional unlink. These checks fail closed for accidental or stale + path/rename drift; every authorized mutation by the trusted application UID is instead + serialized by the exact deployment lease. A malicious concurrent process with that same UID is + outside the current application-owned threat boundary because it can already rewrite manifests, + pointers, and roots. Defending against it requires the planned root-owned immutable handoff and + different-principal garbage collection. Bounded crash-left `.stage-*` trees and `.current-*` + pointer stages are reconciled by the same pass. An + unknown entry, pointer/reference mismatch, invalid artifact, path replacement, or oversized + inventory fails activation closed without deleting the authoritative pairs. Because the new application carries no schema compatibility code, rollback is a **release and database pair**. If activation crosses a non-backward-compatible migration, rollback restores diff --git a/greenfield/docs/development/testing-and-prs.md b/greenfield/docs/development/testing-and-prs.md index a8b56b678..cda22c522 100644 --- a/greenfield/docs/development/testing-and-prs.md +++ b/greenfield/docs/development/testing-and-prs.md @@ -22,8 +22,8 @@ git diff --check Use focused Bun tests while iterating, then run the full affected suite before handoff. The coverage gate requires at least 85% aggregate production line coverage, rejects executable -`src/` modules missing entirely from LCOV, and publishes the same report for Codecov's 85% patch -gate. +`scripts/` or `src/` modules missing entirely from LCOV, and publishes the same report for +Codecov's 85% patch gate. The non-browser coverage partition uses three isolated Bun worker processes. The checked-in `.bun-test-timings.json` file is only a scheduling hint: it starts the slowest files first without diff --git a/greenfield/docs/generated/configuration.md b/greenfield/docs/generated/configuration.md index e13427d67..b57a57ba9 100644 --- a/greenfield/docs/generated/configuration.md +++ b/greenfield/docs/generated/configuration.md @@ -7,6 +7,7 @@ Configuration metadata is generated from the immutable application registry. For | Environment | Typed field | Type / enumerated values | Validation constraints | Default behavior | Process roles | Secret | Browser exposure | Operational effect | Restart | Development/test overrides | Description | | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | | `ELEVENLABS_API_KEY` | `elevenLabsApiKey` | `opaque-secret`; values withheld | When present, a trimmed nonblank control-safe secret at most 4096 code units; never persisted, logged, or browser-exposed. | Optional; no default | `web` | Yes | None | Enables capability-scoped Chat speech endpoints; when absent both controls remain unavailable. | Required | Development and test | Optional server-only ElevenLabs credential for ephemeral Chat transcription and speech generation. | +| `MIRA_DASHBOARD_DATABASE_OBSERVABILITY_PASSWORD` | `databaseObservabilityPassword` | `opaque-secret`; values withheld | When present, a trimmed nonblank opaque credential at most 4096 code units; no database, container, service, project, image, host, or port value is accepted, and the credential is never persisted, logged, or browser-exposed. | Optional; no default | `worker` | Yes | None | Authenticates the hourly bounded database snapshot after the worker discovers the loopback PgBouncer endpoint from Docker and connects through the fixed code-owned mira_dashboard_observability control capability. | Required | Development and test | Optional worker-only password for the dedicated database-observability principal. | | `MIRA_DASHBOARD_LOG_LEVEL` | `logLevel` | `log-level`; `debug`, `error`, `info`, `warn` | Exactly one enumerated structured-log level. | `info` | `web`, `worker`, `script` | No | Value | Changes structured diagnostic verbosity. | Required | Development and test | Minimum structured application log severity. | | `MIRA_DASHBOARD_OPENCLAW_ROOT` | `openClawRoot` | `absolute-path` | Non-root normalized absolute path, at most 4096 code units; startup requires a canonical owner-controlled mode-0700 directory disjoint from Dashboard production state. | Required | `web`, `worker` | No | None | Selects the descriptor-rooted OpenClaw tree; only reviewed config can be explicitly revealed, only two files can be replaced, and local media is readable only beneath its fixed media directory through opaque transcript-bound references. | Required | Development and test | Explicit reviewed OpenClaw home used by the fixed Files manifest, worker replacement allowlist, exact configuration export, and transcript-authorized local media reader. | | `MIRA_DASHBOARD_PROJECT_ROOT` | `projectRoot` | `absolute-path` | Non-root normalized absolute path, at most 4096 code units; realpath validation is staged for startup. | Required | `web`, `worker`, `build`, `script` | No | None | Selects the stable development, production-state, runtime, release, preview, and worktree hierarchy; it is not a checkout path. | Required | Development and test | Lexically normalized absolute Dashboard host-layout root; startup must resolve and validate its real directory before deriving managed paths. | diff --git a/greenfield/docs/generated/packages-and-runtime.md b/greenfield/docs/generated/packages-and-runtime.md index a3e3f67a8..ee0d6bc3d 100644 --- a/greenfield/docs/generated/packages-and-runtime.md +++ b/greenfield/docs/generated/packages-and-runtime.md @@ -25,11 +25,11 @@ | `@tanstack/query-core` | `5.101.4` | `5.101.4` | runtime | | `@tanstack/query-db-collection` | `1.2.1` | `1.2.1` | runtime | | `@tanstack/react-db` | `0.1.95` | `0.1.95` | runtime | -| `@tanstack/react-form` | `^1.33.4` | `1.33.4` | runtime | +| `@tanstack/react-form` | `^1.33.5` | `1.33.5` | runtime | | `@tanstack/react-query` | `^5.101.4` | `5.101.4` | runtime | -| `@tanstack/react-router` | `^1.170.23` | `1.170.23` | runtime | +| `@tanstack/react-router` | `^1.170.27` | `1.170.27` | runtime | | `@tanstack/react-store` | `0.11.1` | `0.11.1` | runtime | -| `@tanstack/react-table` | `^9.1.1` | `9.1.1` | runtime | +| `@tanstack/react-table` | `^9.1.2` | `9.1.2` | runtime | | `@tanstack/react-virtual` | `^3.14.9` | `3.14.9` | runtime | | `@trpc/client` | `11.18.0` | `11.18.0` | runtime | | `@trpc/server` | `11.18.0` | `11.18.0` | runtime | @@ -40,9 +40,9 @@ | `clsx` | `^2.1.1` | `2.1.1` | runtime | | `date-fns` | `^4.4.0` | `4.4.0` | runtime | | `drizzle-orm` | `1.0.0-rc.4` | `1.0.0-rc.4` | runtime | -| `effect` | `4.0.0-beta.106` | `4.0.0-beta.106` | runtime | +| `effect` | `4.0.0-beta.107` | `4.0.0-beta.107` | runtime | | `json5` | `^2.2.3` | `2.2.3` | runtime | -| `lucide-react` | `^1.30.0` | `1.30.0` | runtime | +| `lucide-react` | `^1.31.0` | `1.31.0` | runtime | | `otplib` | `13.4.1` | `13.4.1` | runtime | | `qrcode.react` | `4.2.0` | `4.2.0` | runtime | | `react` | `^19.2.8` | `19.2.8` | runtime | @@ -60,19 +60,19 @@ | `valibot` | `^1.4.2` | `1.4.2` | runtime | | `@babel/core` | `^8.0.1` | `8.0.1` | development | | `@happy-dom/global-registrator` | `^20.11.2` | `20.11.2` | development | -| `@storybook/addon-a11y` | `10.6.0-alpha.4` | `10.6.0-alpha.4` | development | -| `@storybook/addon-docs` | `10.6.0-alpha.4` | `10.6.0-alpha.4` | development | -| `@storybook/addon-vitest` | `10.6.0-alpha.4` | `10.6.0-alpha.4` | development | -| `@storybook/tanstack-react` | `10.6.0-alpha.4` | `10.6.0-alpha.4` | development | +| `@storybook/addon-a11y` | `10.6.0-alpha.5` | `10.6.0-alpha.5` | development | +| `@storybook/addon-docs` | `10.6.0-alpha.5` | `10.6.0-alpha.5` | development | +| `@storybook/addon-vitest` | `10.6.0-alpha.5` | `10.6.0-alpha.5` | development | +| `@storybook/tanstack-react` | `10.6.0-alpha.5` | `10.6.0-alpha.5` | development | | `@tailwindcss/vite` | `4.3.3` | `4.3.3` | development | -| `@tanstack/react-devtools` | `^0.10.9` | `0.10.9` | development | -| `@tanstack/react-form-devtools` | `^0.2.33` | `0.2.33` | development | +| `@tanstack/react-devtools` | `^0.10.10` | `0.10.10` | development | +| `@tanstack/react-form-devtools` | `^0.2.34` | `0.2.34` | development | | `@tanstack/react-query-devtools` | `^5.101.4` | `5.101.4` | development | | `@tanstack/react-router-devtools` | `1.167.1` | `1.167.1` | development | | `@testing-library/dom` | `^10.4.1` | `10.4.1` | development | -| `@testing-library/jest-dom` | `^7.0.0` | `7.0.0` | development | +| `@testing-library/jest-dom` | `^7.0.1` | `7.0.1` | development | | `@testing-library/react` | `^16.3.2` | `16.3.2` | development | -| `@testing-library/user-event` | `^14.6.3` | `14.6.3` | development | +| `@testing-library/user-event` | `^14.6.4` | `14.6.4` | development | | `@types/babel__core` | `^7.20.5` | `7.20.5` | development | | `@types/node` | `26.2.0` | `26.2.0` | development | | `@types/react` | `^19.2.18` | `19.2.18` | development | @@ -84,18 +84,18 @@ | `bun-plugin-tailwind` | `^0.1.2` | `0.1.2` | development | | `bun-types` | `1.4.0-canary.20260519T150915` | `1.4.0-canary.20260519T150915` | development | | `drizzle-kit` | `1.0.0-rc.4` | `1.0.0-rc.4` | development | -| `eventsource` | `4.1.1` | `4.1.1` | development | +| `eventsource` | `5.0.0` | `5.0.0` | development | | `happy-dom` | `^20.11.2` | `20.11.2` | development | | `jsdom-testing-mocks` | `^1.16.0` | `1.16.0` | development | | `jsonc-parser` | `3.3.1` | `3.3.1` | development | | `lcov-result-merger` | `^6.0.0` | `6.0.0` | development | -| `oxfmt` | `^0.62.0` | `0.62.0` | development | -| `oxlint` | `^1.77.0` | `1.77.0` | development | -| `oxlint-config-presets` | `^0.1.18` | `0.1.18` | development | -| `oxlint-tailwindcss` | `^1.7.1` | `1.7.1` | development | +| `oxfmt` | `^0.63.0` | `0.63.0` | development | +| `oxlint` | `^1.78.0` | `1.78.0` | development | +| `oxlint-config-presets` | `^0.1.19` | `0.1.19` | development | +| `oxlint-tailwindcss` | `^1.9.0` | `1.9.0` | development | | `oxlint-tsgolint` | `^7.0.2001` | `7.0.2001` | development | | `playwright` | `1.62.1` | `1.62.1` | development | -| `storybook` | `10.6.0-alpha.4` | `10.6.0-alpha.4` | development | +| `storybook` | `10.6.0-alpha.5` | `10.6.0-alpha.5` | development | | `tailwindcss` | `^4.3.3` | `4.3.3` | development | | `typescript` | `^7.0.2` | `7.0.2` | development | | `vite` | `8.2.1` | `8.2.1` | development | diff --git a/greenfield/docs/generated/procedures.md b/greenfield/docs/generated/procedures.md index f840e972a..5d1f455f4 100644 --- a/greenfield/docs/generated/procedures.md +++ b/greenfield/docs/generated/procedures.md @@ -60,6 +60,7 @@ | `chat.runtime` | query | chat | Authenticated: chat:read | [input](./schemas/chat.runtime.input.schema.json) | [output](./schemas/chat.runtime.output.schema.json) | `FORBIDDEN`, `SERVICE_UNAVAILABLE`, `TOO_MANY_REQUESTS`, `UNAUTHORIZED` | None | Reads cursor-ordered durable runtime events and restart snapshots. | | `chat.send` | mutation | chat | Authenticated: chat:write | [input](./schemas/chat.send.input.schema.json) | [output](./schemas/chat.send.output.schema.json) | `BAD_REQUEST`, `CONFLICT`, `FORBIDDEN`, `SERVICE_UNAVAILABLE`, `TOO_MANY_REQUESTS`, `UNAUTHORIZED` | `operation_outcome_unknown` | Durably admits one idempotent chat run before Gateway dispatch. | | `chat.updateSessionSettings` | mutation | chat | Authenticated: chat:write | [input](./schemas/chat.updateSessionSettings.input.schema.json) | [output](./schemas/chat.updateSessionSettings.output.schema.json) | `BAD_REQUEST`, `CONFLICT`, `FORBIDDEN`, `NOT_FOUND`, `SERVICE_UNAVAILABLE`, `UNAUTHORIZED` | `operation_outcome_unknown` | Updates reviewed Gateway-backed chat session controls with readback. | +| `database.overview` | query | database | Authenticated browser session: database:read | [input](./schemas/database.overview.input.schema.json) | [output](./schemas/database.overview.output.schema.json) | `FORBIDDEN`, `UNAUTHORIZED` | None | Returns bounded independent SQLite and PostgreSQL/PgBouncer observations without users, connection identities, SQL text, paths, credentials, or raw failures. | | `events.stream` | subscription | events | Authenticated; per-topic: agents:read, cache:read, chat:read, gateway-sessions:read, jobs:read, notifications:read, openclaw-tasks:read, reports:read, tasks:read | [input](./schemas/events.stream.input.schema.json) | [output](./schemas/events.stream.output.schema.json) | `BAD_REQUEST`, `FORBIDDEN`, `SERVICE_UNAVAILABLE`, `TOO_MANY_REQUESTS`, `UNAUTHORIZED` | None | Streams authorized durable changes with tracked resume cursors. | | `files.getWriteStatus` | query | files | Authenticated browser session: files:read | [input](./schemas/files.getWriteStatus.input.schema.json) | [output](./schemas/files.getWriteStatus.output.schema.json) | `FORBIDDEN`, `NOT_FOUND`, `SERVICE_UNAVAILABLE`, `UNAUTHORIZED` | None | Reconciles a prepared write without permitting blind redispatch. | | `files.list` | query | files | Authenticated browser session: files:read | [input](./schemas/files.list.input.schema.json) | [output](./schemas/files.list.output.schema.json) | `BAD_REQUEST`, `CONFLICT`, `FORBIDDEN`, `NOT_FOUND`, `SERVICE_UNAVAILABLE`, `UNAUTHORIZED` | None | Lists one stable bounded page beneath an opaque directory reference. | diff --git a/greenfield/docs/generated/routes-and-features.md b/greenfield/docs/generated/routes-and-features.md index 96d211db8..2989015a4 100644 --- a/greenfield/docs/generated/routes-and-features.md +++ b/greenfield/docs/generated/routes-and-features.md @@ -8,6 +8,7 @@ | `/account-security` | Browser session | Hidden | `security` | Manages password, MFA factors, recovery codes, and browser sessions. | | `/agents` | Browser session | Agents | `agents` | Shows the reviewed agent directory, task state, and durable history. | | `/chat` | Browser session | Chat | `chat` | Runs the bounded persistent Gateway chat and attachment workflow. | +| `/database` | Browser session | Database | `database` | Shows bounded read-only health and lifecycle observations for Dashboard databases. | | `/files` | Browser session | Files | `files` | Browses, previews, downloads, uploads, and CAS-replaces workspace files. | | `/incidents` | Browser session | Hidden | `monitoring` | Lists and inspects persisted monitoring incident generations. | | `/jobs` | Browser session | Jobs | `jobs` | Shows Dashboard jobs, schedules, worker state, and OpenClaw cron. | diff --git a/greenfield/docs/generated/schemas/automationSecurity.createPrincipal.input.schema.json b/greenfield/docs/generated/schemas/automationSecurity.createPrincipal.input.schema.json index c9501ca31..e27f8fb4f 100644 --- a/greenfield/docs/generated/schemas/automationSecurity.createPrincipal.input.schema.json +++ b/greenfield/docs/generated/schemas/automationSecurity.createPrincipal.input.schema.json @@ -12,6 +12,7 @@ "cache:write", "chat:read", "chat:write", + "database:read", "files:read", "files:write", "gateway-sessions:read", @@ -38,7 +39,7 @@ ], "type": "string" }, - "maxItems": 29, + "maxItems": 30, "uniqueItems": true }, "id": { diff --git a/greenfield/docs/generated/schemas/automationSecurity.createPrincipal.output.schema.json b/greenfield/docs/generated/schemas/automationSecurity.createPrincipal.output.schema.json index e1324de12..7f5cd732b 100644 --- a/greenfield/docs/generated/schemas/automationSecurity.createPrincipal.output.schema.json +++ b/greenfield/docs/generated/schemas/automationSecurity.createPrincipal.output.schema.json @@ -81,6 +81,7 @@ "cache:write", "chat:read", "chat:write", + "database:read", "files:read", "files:write", "gateway-sessions:read", @@ -107,7 +108,7 @@ ], "type": "string" }, - "maxItems": 29, + "maxItems": 30, "uniqueItems": true }, "createdAtMs": { @@ -177,6 +178,7 @@ "cache:write", "chat:read", "chat:write", + "database:read", "files:read", "files:write", "gateway-sessions:read", @@ -203,7 +205,7 @@ ], "type": "string" }, - "maxItems": 29, + "maxItems": 30, "uniqueItems": true }, "createdAtMs": { diff --git a/greenfield/docs/generated/schemas/automationSecurity.disablePrincipal.output.schema.json b/greenfield/docs/generated/schemas/automationSecurity.disablePrincipal.output.schema.json index 054771927..521830615 100644 --- a/greenfield/docs/generated/schemas/automationSecurity.disablePrincipal.output.schema.json +++ b/greenfield/docs/generated/schemas/automationSecurity.disablePrincipal.output.schema.json @@ -30,6 +30,7 @@ "cache:write", "chat:read", "chat:write", + "database:read", "files:read", "files:write", "gateway-sessions:read", @@ -56,7 +57,7 @@ ], "type": "string" }, - "maxItems": 29, + "maxItems": 30, "uniqueItems": true }, "createdAtMs": { @@ -126,6 +127,7 @@ "cache:write", "chat:read", "chat:write", + "database:read", "files:read", "files:write", "gateway-sessions:read", @@ -152,7 +154,7 @@ ], "type": "string" }, - "maxItems": 29, + "maxItems": 30, "uniqueItems": true }, "createdAtMs": { diff --git a/greenfield/docs/generated/schemas/automationSecurity.listPrincipals.output.schema.json b/greenfield/docs/generated/schemas/automationSecurity.listPrincipals.output.schema.json index a029c75fe..8adbee764 100644 --- a/greenfield/docs/generated/schemas/automationSecurity.listPrincipals.output.schema.json +++ b/greenfield/docs/generated/schemas/automationSecurity.listPrincipals.output.schema.json @@ -55,6 +55,7 @@ "cache:write", "chat:read", "chat:write", + "database:read", "files:read", "files:write", "gateway-sessions:read", @@ -81,7 +82,7 @@ ], "type": "string" }, - "maxItems": 29, + "maxItems": 30, "uniqueItems": true }, "createdAtMs": { @@ -151,6 +152,7 @@ "cache:write", "chat:read", "chat:write", + "database:read", "files:read", "files:write", "gateway-sessions:read", @@ -177,7 +179,7 @@ ], "type": "string" }, - "maxItems": 29, + "maxItems": 30, "uniqueItems": true }, "createdAtMs": { diff --git a/greenfield/docs/generated/schemas/automationSecurity.replaceCapabilities.input.schema.json b/greenfield/docs/generated/schemas/automationSecurity.replaceCapabilities.input.schema.json index cb91fda8a..ecaa1727f 100644 --- a/greenfield/docs/generated/schemas/automationSecurity.replaceCapabilities.input.schema.json +++ b/greenfield/docs/generated/schemas/automationSecurity.replaceCapabilities.input.schema.json @@ -23,6 +23,7 @@ "cache:write", "chat:read", "chat:write", + "database:read", "files:read", "files:write", "gateway-sessions:read", @@ -49,7 +50,7 @@ ], "type": "string" }, - "maxItems": 29, + "maxItems": 30, "uniqueItems": true } }, diff --git a/greenfield/docs/generated/schemas/automationSecurity.replaceCapabilities.output.schema.json b/greenfield/docs/generated/schemas/automationSecurity.replaceCapabilities.output.schema.json index f9a6dbd53..ac1a9bd17 100644 --- a/greenfield/docs/generated/schemas/automationSecurity.replaceCapabilities.output.schema.json +++ b/greenfield/docs/generated/schemas/automationSecurity.replaceCapabilities.output.schema.json @@ -30,6 +30,7 @@ "cache:write", "chat:read", "chat:write", + "database:read", "files:read", "files:write", "gateway-sessions:read", @@ -56,7 +57,7 @@ ], "type": "string" }, - "maxItems": 29, + "maxItems": 30, "uniqueItems": true }, "createdAtMs": { @@ -126,6 +127,7 @@ "cache:write", "chat:read", "chat:write", + "database:read", "files:read", "files:write", "gateway-sessions:read", @@ -152,7 +154,7 @@ ], "type": "string" }, - "maxItems": 29, + "maxItems": 30, "uniqueItems": true }, "createdAtMs": { diff --git a/greenfield/docs/generated/schemas/database.overview.input.schema.json b/greenfield/docs/generated/schemas/database.overview.input.schema.json new file mode 100644 index 000000000..615e3b115 --- /dev/null +++ b/greenfield/docs/generated/schemas/database.overview.input.schema.json @@ -0,0 +1,9 @@ +{ + "$id": "urn:mira-dashboard:database.overview.input", + "type": "object", + "properties": {}, + "required": [], + "additionalProperties": false, + "default": {}, + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/greenfield/docs/generated/schemas/database.overview.output.schema.json b/greenfield/docs/generated/schemas/database.overview.output.schema.json new file mode 100644 index 000000000..34b2f50b4 --- /dev/null +++ b/greenfield/docs/generated/schemas/database.overview.output.schema.json @@ -0,0 +1,2533 @@ +{ + "$id": "urn:mira-dashboard:database.overview.output", + "type": "object", + "properties": { + "checkedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "postgresql": { + "oneOf": [ + { + "type": "object", + "properties": { + "state": { + "const": "unavailable" + } + }, + "required": [ + "state" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "databases": { + "type": "array", + "items": { + "type": "object", + "properties": { + "blocksHit": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "blocksRead": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "cacheHitRatio": { + "type": "number", + "minimum": 0, + "maximum": 100 + }, + "committedTransactions": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "connections": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "detailsState": { + "enum": [ + "available", + "unavailable" + ], + "type": "string" + }, + "name": { + "type": "string", + "maxLength": 63, + "minLength": 1, + "pattern": "\\S", + "allOf": [ + { + "pattern": "^[^\\u0000]*$" + }, + { + "pattern": "^(?![\\s\\S]*(?:[\\u0000-\\u001F\\u007F-\\u009F\\u00AD\\u0600-\\u0605\\u061C\\u06DD\\u070F\\u0890-\\u0891\\u08E2\\u180E\\u200B-\\u200F\\u2028-\\u202E\\u2060-\\u2064\\u2066-\\u206F\\uFEFF\\uFFF9-\\uFFFB]|\\uD804[\\uDCBD\\uDCCD]|\\uD80D[\\uDC30-\\uDC3F]|\\uD82F[\\uDCA0-\\uDCA3]|\\uD834[\\uDD73-\\uDD7A]|\\uDB40(?:\\uDC01|[\\uDC20-\\uDC7F])))[\\s\\S]*$" + } + ], + "$comment": "Live Valibot validation additionally limits PostgreSQL identifiers to 63 UTF-8 bytes." + }, + "pool": { + "type": "object", + "properties": { + "activeClients": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "activeServers": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "averageQueryMs": { + "type": "number", + "minimum": 0, + "maximum": 9007199254740991 + }, + "averageTransactionMs": { + "type": "number", + "minimum": 0, + "maximum": 9007199254740991 + }, + "idleServers": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "totalQueries": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "usedServers": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "waitingClients": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "activeClients", + "activeServers", + "averageQueryMs", + "averageTransactionMs", + "idleServers", + "totalQueries", + "usedServers", + "waitingClients" + ], + "additionalProperties": false + }, + "rolledBackTransactions": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "sizeBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "blocksHit", + "blocksRead", + "cacheHitRatio", + "committedTransactions", + "connections", + "detailsState", + "name", + "rolledBackTransactions", + "sizeBytes" + ], + "additionalProperties": false + }, + "minItems": 1, + "maxItems": 64 + }, + "pgbouncer": { + "type": "object", + "properties": { + "averageQueryMs": { + "type": "number", + "minimum": 0, + "maximum": 9007199254740991 + }, + "averageTransactionMs": { + "type": "number", + "minimum": 0, + "maximum": 9007199254740991 + }, + "clientConnections": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "maxWaitSeconds": { + "type": "number", + "minimum": 0, + "maximum": 9007199254740991 + }, + "serverConnections": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "waitingClients": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "averageQueryMs", + "averageTransactionMs", + "clientConnections", + "maxWaitSeconds", + "serverConnections", + "waitingClients" + ], + "additionalProperties": false + }, + "statements": { + "type": "array", + "items": { + "type": "object", + "properties": { + "calls": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "meanExecutionMs": { + "type": "number", + "minimum": 0, + "maximum": 9007199254740991 + }, + "rank": { + "type": "integer", + "minimum": 1, + "maximum": 20 + }, + "rows": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "sharedBlocksHit": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "sharedBlocksRead": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "totalExecutionMs": { + "type": "number", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "calls", + "meanExecutionMs", + "rank", + "rows", + "sharedBlocksHit", + "sharedBlocksRead", + "totalExecutionMs" + ], + "additionalProperties": false + }, + "maxItems": 20 + }, + "summary": { + "type": "object", + "properties": { + "activeConnections": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "averageCacheHitRatio": { + "type": "number", + "minimum": 0, + "maximum": 100 + }, + "idleConnections": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "maintenance": { + "type": "object", + "properties": { + "assessedPhysicalBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "assessmentComplete": { + "type": "boolean" + }, + "estimatedReclaimableBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "estimatedReclaimablePercent": { + "type": "number", + "minimum": 0, + "maximum": 100 + }, + "highDeadTupleTableCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "requiresBloatReview": { + "type": "boolean" + }, + "slowStatementCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "status": { + "enum": [ + "healthy", + "not-assessed", + "review" + ], + "type": "string" + }, + "unassessedPhysicalBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "unassessedTableCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "assessedPhysicalBytes", + "assessmentComplete", + "estimatedReclaimableBytes", + "estimatedReclaimablePercent", + "highDeadTupleTableCount", + "requiresBloatReview", + "slowStatementCount", + "status", + "unassessedPhysicalBytes", + "unassessedTableCount" + ], + "additionalProperties": false + }, + "pgStatStatementsEnabled": { + "type": "boolean" + }, + "totalConnections": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "totalDatabaseSizeBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "unavailableDatabaseCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "activeConnections", + "averageCacheHitRatio", + "idleConnections", + "maintenance", + "pgStatStatementsEnabled", + "totalConnections", + "totalDatabaseSizeBytes", + "unavailableDatabaseCount" + ], + "additionalProperties": false + }, + "tableHealth": { + "type": "array", + "items": { + "type": "object", + "properties": { + "assessment": { + "enum": [ + "assessed", + "unavailable" + ], + "type": "string" + }, + "database": { + "type": "string", + "maxLength": 63, + "minLength": 1, + "pattern": "\\S", + "allOf": [ + { + "pattern": "^[^\\u0000]*$" + }, + { + "pattern": "^(?![\\s\\S]*(?:[\\u0000-\\u001F\\u007F-\\u009F\\u00AD\\u0600-\\u0605\\u061C\\u06DD\\u070F\\u0890-\\u0891\\u08E2\\u180E\\u200B-\\u200F\\u2028-\\u202E\\u2060-\\u2064\\u2066-\\u206F\\uFEFF\\uFFF9-\\uFFFB]|\\uD804[\\uDCBD\\uDCCD]|\\uD80D[\\uDC30-\\uDC3F]|\\uD82F[\\uDCA0-\\uDCA3]|\\uD834[\\uDD73-\\uDD7A]|\\uDB40(?:\\uDC01|[\\uDC20-\\uDC7F])))[\\s\\S]*$" + } + ], + "$comment": "Live Valibot validation additionally limits PostgreSQL identifiers to 63 UTF-8 bytes." + }, + "deadTuplePercent": { + "type": "number", + "minimum": 0, + "maximum": 100 + }, + "deadTuples": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "estimatedReclaimableBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "lastAutoanalyzeAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "lastAutovacuumAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "liveTuples": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "physicalBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "schema": { + "type": "string", + "maxLength": 63, + "minLength": 1, + "pattern": "\\S", + "allOf": [ + { + "pattern": "^[^\\u0000]*$" + }, + { + "pattern": "^(?![\\s\\S]*(?:[\\u0000-\\u001F\\u007F-\\u009F\\u00AD\\u0600-\\u0605\\u061C\\u06DD\\u070F\\u0890-\\u0891\\u08E2\\u180E\\u200B-\\u200F\\u2028-\\u202E\\u2060-\\u2064\\u2066-\\u206F\\uFEFF\\uFFF9-\\uFFFB]|\\uD804[\\uDCBD\\uDCCD]|\\uD80D[\\uDC30-\\uDC3F]|\\uD82F[\\uDCA0-\\uDCA3]|\\uD834[\\uDD73-\\uDD7A]|\\uDB40(?:\\uDC01|[\\uDC20-\\uDC7F])))[\\s\\S]*$" + } + ], + "$comment": "Live Valibot validation additionally limits PostgreSQL identifiers to 63 UTF-8 bytes." + }, + "table": { + "type": "string", + "maxLength": 63, + "minLength": 1, + "pattern": "\\S", + "allOf": [ + { + "pattern": "^[^\\u0000]*$" + }, + { + "pattern": "^(?![\\s\\S]*(?:[\\u0000-\\u001F\\u007F-\\u009F\\u00AD\\u0600-\\u0605\\u061C\\u06DD\\u070F\\u0890-\\u0891\\u08E2\\u180E\\u200B-\\u200F\\u2028-\\u202E\\u2060-\\u2064\\u2066-\\u206F\\uFEFF\\uFFF9-\\uFFFB]|\\uD804[\\uDCBD\\uDCCD]|\\uD80D[\\uDC30-\\uDC3F]|\\uD82F[\\uDCA0-\\uDCA3]|\\uD834[\\uDD73-\\uDD7A]|\\uDB40(?:\\uDC01|[\\uDC20-\\uDC7F])))[\\s\\S]*$" + } + ], + "$comment": "Live Valibot validation additionally limits PostgreSQL identifiers to 63 UTF-8 bytes." + } + }, + "required": [ + "assessment", + "database", + "deadTuplePercent", + "deadTuples", + "liveTuples", + "physicalBytes", + "schema", + "table" + ], + "additionalProperties": false + }, + "maxItems": 25 + }, + "torrentCounts": { + "type": "object", + "properties": { + "bitmagnet": { + "oneOf": [ + { + "type": "object", + "properties": { + "count": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "state": { + "const": "available" + } + }, + "required": [ + "count", + "state" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "state": { + "const": "unavailable" + } + }, + "required": [ + "state" + ], + "additionalProperties": false + } + ] + }, + "comet": { + "oneOf": [ + { + "type": "object", + "properties": { + "count": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "state": { + "const": "available" + } + }, + "required": [ + "count", + "state" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "state": { + "const": "unavailable" + } + }, + "required": [ + "state" + ], + "additionalProperties": false + } + ] + } + }, + "required": [ + "bitmagnet", + "comet" + ], + "additionalProperties": false + }, + "observedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "state": { + "const": "fresh" + } + }, + "required": [ + "databases", + "pgbouncer", + "statements", + "summary", + "tableHealth", + "torrentCounts", + "observedAtMs", + "state" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "databases": { + "type": "array", + "items": { + "type": "object", + "properties": { + "blocksHit": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "blocksRead": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "cacheHitRatio": { + "type": "number", + "minimum": 0, + "maximum": 100 + }, + "committedTransactions": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "connections": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "detailsState": { + "enum": [ + "available", + "unavailable" + ], + "type": "string" + }, + "name": { + "type": "string", + "maxLength": 63, + "minLength": 1, + "pattern": "\\S", + "allOf": [ + { + "pattern": "^[^\\u0000]*$" + }, + { + "pattern": "^(?![\\s\\S]*(?:[\\u0000-\\u001F\\u007F-\\u009F\\u00AD\\u0600-\\u0605\\u061C\\u06DD\\u070F\\u0890-\\u0891\\u08E2\\u180E\\u200B-\\u200F\\u2028-\\u202E\\u2060-\\u2064\\u2066-\\u206F\\uFEFF\\uFFF9-\\uFFFB]|\\uD804[\\uDCBD\\uDCCD]|\\uD80D[\\uDC30-\\uDC3F]|\\uD82F[\\uDCA0-\\uDCA3]|\\uD834[\\uDD73-\\uDD7A]|\\uDB40(?:\\uDC01|[\\uDC20-\\uDC7F])))[\\s\\S]*$" + } + ], + "$comment": "Live Valibot validation additionally limits PostgreSQL identifiers to 63 UTF-8 bytes." + }, + "pool": { + "type": "object", + "properties": { + "activeClients": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "activeServers": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "averageQueryMs": { + "type": "number", + "minimum": 0, + "maximum": 9007199254740991 + }, + "averageTransactionMs": { + "type": "number", + "minimum": 0, + "maximum": 9007199254740991 + }, + "idleServers": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "totalQueries": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "usedServers": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "waitingClients": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "activeClients", + "activeServers", + "averageQueryMs", + "averageTransactionMs", + "idleServers", + "totalQueries", + "usedServers", + "waitingClients" + ], + "additionalProperties": false + }, + "rolledBackTransactions": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "sizeBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "blocksHit", + "blocksRead", + "cacheHitRatio", + "committedTransactions", + "connections", + "detailsState", + "name", + "rolledBackTransactions", + "sizeBytes" + ], + "additionalProperties": false + }, + "minItems": 1, + "maxItems": 64 + }, + "pgbouncer": { + "type": "object", + "properties": { + "averageQueryMs": { + "type": "number", + "minimum": 0, + "maximum": 9007199254740991 + }, + "averageTransactionMs": { + "type": "number", + "minimum": 0, + "maximum": 9007199254740991 + }, + "clientConnections": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "maxWaitSeconds": { + "type": "number", + "minimum": 0, + "maximum": 9007199254740991 + }, + "serverConnections": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "waitingClients": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "averageQueryMs", + "averageTransactionMs", + "clientConnections", + "maxWaitSeconds", + "serverConnections", + "waitingClients" + ], + "additionalProperties": false + }, + "statements": { + "type": "array", + "items": { + "type": "object", + "properties": { + "calls": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "meanExecutionMs": { + "type": "number", + "minimum": 0, + "maximum": 9007199254740991 + }, + "rank": { + "type": "integer", + "minimum": 1, + "maximum": 20 + }, + "rows": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "sharedBlocksHit": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "sharedBlocksRead": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "totalExecutionMs": { + "type": "number", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "calls", + "meanExecutionMs", + "rank", + "rows", + "sharedBlocksHit", + "sharedBlocksRead", + "totalExecutionMs" + ], + "additionalProperties": false + }, + "maxItems": 20 + }, + "summary": { + "type": "object", + "properties": { + "activeConnections": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "averageCacheHitRatio": { + "type": "number", + "minimum": 0, + "maximum": 100 + }, + "idleConnections": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "maintenance": { + "type": "object", + "properties": { + "assessedPhysicalBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "assessmentComplete": { + "type": "boolean" + }, + "estimatedReclaimableBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "estimatedReclaimablePercent": { + "type": "number", + "minimum": 0, + "maximum": 100 + }, + "highDeadTupleTableCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "requiresBloatReview": { + "type": "boolean" + }, + "slowStatementCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "status": { + "enum": [ + "healthy", + "not-assessed", + "review" + ], + "type": "string" + }, + "unassessedPhysicalBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "unassessedTableCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "assessedPhysicalBytes", + "assessmentComplete", + "estimatedReclaimableBytes", + "estimatedReclaimablePercent", + "highDeadTupleTableCount", + "requiresBloatReview", + "slowStatementCount", + "status", + "unassessedPhysicalBytes", + "unassessedTableCount" + ], + "additionalProperties": false + }, + "pgStatStatementsEnabled": { + "type": "boolean" + }, + "totalConnections": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "totalDatabaseSizeBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "unavailableDatabaseCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "activeConnections", + "averageCacheHitRatio", + "idleConnections", + "maintenance", + "pgStatStatementsEnabled", + "totalConnections", + "totalDatabaseSizeBytes", + "unavailableDatabaseCount" + ], + "additionalProperties": false + }, + "tableHealth": { + "type": "array", + "items": { + "type": "object", + "properties": { + "assessment": { + "enum": [ + "assessed", + "unavailable" + ], + "type": "string" + }, + "database": { + "type": "string", + "maxLength": 63, + "minLength": 1, + "pattern": "\\S", + "allOf": [ + { + "pattern": "^[^\\u0000]*$" + }, + { + "pattern": "^(?![\\s\\S]*(?:[\\u0000-\\u001F\\u007F-\\u009F\\u00AD\\u0600-\\u0605\\u061C\\u06DD\\u070F\\u0890-\\u0891\\u08E2\\u180E\\u200B-\\u200F\\u2028-\\u202E\\u2060-\\u2064\\u2066-\\u206F\\uFEFF\\uFFF9-\\uFFFB]|\\uD804[\\uDCBD\\uDCCD]|\\uD80D[\\uDC30-\\uDC3F]|\\uD82F[\\uDCA0-\\uDCA3]|\\uD834[\\uDD73-\\uDD7A]|\\uDB40(?:\\uDC01|[\\uDC20-\\uDC7F])))[\\s\\S]*$" + } + ], + "$comment": "Live Valibot validation additionally limits PostgreSQL identifiers to 63 UTF-8 bytes." + }, + "deadTuplePercent": { + "type": "number", + "minimum": 0, + "maximum": 100 + }, + "deadTuples": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "estimatedReclaimableBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "lastAutoanalyzeAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "lastAutovacuumAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "liveTuples": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "physicalBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "schema": { + "type": "string", + "maxLength": 63, + "minLength": 1, + "pattern": "\\S", + "allOf": [ + { + "pattern": "^[^\\u0000]*$" + }, + { + "pattern": "^(?![\\s\\S]*(?:[\\u0000-\\u001F\\u007F-\\u009F\\u00AD\\u0600-\\u0605\\u061C\\u06DD\\u070F\\u0890-\\u0891\\u08E2\\u180E\\u200B-\\u200F\\u2028-\\u202E\\u2060-\\u2064\\u2066-\\u206F\\uFEFF\\uFFF9-\\uFFFB]|\\uD804[\\uDCBD\\uDCCD]|\\uD80D[\\uDC30-\\uDC3F]|\\uD82F[\\uDCA0-\\uDCA3]|\\uD834[\\uDD73-\\uDD7A]|\\uDB40(?:\\uDC01|[\\uDC20-\\uDC7F])))[\\s\\S]*$" + } + ], + "$comment": "Live Valibot validation additionally limits PostgreSQL identifiers to 63 UTF-8 bytes." + }, + "table": { + "type": "string", + "maxLength": 63, + "minLength": 1, + "pattern": "\\S", + "allOf": [ + { + "pattern": "^[^\\u0000]*$" + }, + { + "pattern": "^(?![\\s\\S]*(?:[\\u0000-\\u001F\\u007F-\\u009F\\u00AD\\u0600-\\u0605\\u061C\\u06DD\\u070F\\u0890-\\u0891\\u08E2\\u180E\\u200B-\\u200F\\u2028-\\u202E\\u2060-\\u2064\\u2066-\\u206F\\uFEFF\\uFFF9-\\uFFFB]|\\uD804[\\uDCBD\\uDCCD]|\\uD80D[\\uDC30-\\uDC3F]|\\uD82F[\\uDCA0-\\uDCA3]|\\uD834[\\uDD73-\\uDD7A]|\\uDB40(?:\\uDC01|[\\uDC20-\\uDC7F])))[\\s\\S]*$" + } + ], + "$comment": "Live Valibot validation additionally limits PostgreSQL identifiers to 63 UTF-8 bytes." + } + }, + "required": [ + "assessment", + "database", + "deadTuplePercent", + "deadTuples", + "liveTuples", + "physicalBytes", + "schema", + "table" + ], + "additionalProperties": false + }, + "maxItems": 25 + }, + "torrentCounts": { + "type": "object", + "properties": { + "bitmagnet": { + "oneOf": [ + { + "type": "object", + "properties": { + "count": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "state": { + "const": "available" + } + }, + "required": [ + "count", + "state" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "state": { + "const": "unavailable" + } + }, + "required": [ + "state" + ], + "additionalProperties": false + } + ] + }, + "comet": { + "oneOf": [ + { + "type": "object", + "properties": { + "count": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "state": { + "const": "available" + } + }, + "required": [ + "count", + "state" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "state": { + "const": "unavailable" + } + }, + "required": [ + "state" + ], + "additionalProperties": false + } + ] + } + }, + "required": [ + "bitmagnet", + "comet" + ], + "additionalProperties": false + }, + "observedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "staleSinceMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "state": { + "const": "last-known-good" + } + }, + "required": [ + "databases", + "pgbouncer", + "statements", + "summary", + "tableHealth", + "torrentCounts", + "observedAtMs", + "staleSinceMs", + "state" + ], + "additionalProperties": false + } + ] + }, + "sqlite": { + "oneOf": [ + { + "type": "object", + "properties": { + "state": { + "const": "unavailable" + } + }, + "required": [ + "state" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "connection": { + "type": "object", + "properties": { + "busyPolicy": { + "const": "non-blocking" + }, + "checksEnforced": { + "const": true + }, + "foreignKeysEnabled": { + "const": true + }, + "journalMode": { + "const": "wal" + }, + "synchronousMode": { + "const": "full" + }, + "trustedSchemaEnabled": { + "const": false + }, + "walAutoCheckpointPages": { + "type": "integer", + "minimum": 0, + "maximum": 1000000 + } + }, + "required": [ + "busyPolicy", + "checksEnforced", + "foreignKeysEnabled", + "journalMode", + "synchronousMode", + "trustedSchemaEnabled", + "walAutoCheckpointPages" + ], + "additionalProperties": false + }, + "fileName": { + "const": "mira-dashboard.db" + }, + "lifecycle": { + "type": "object", + "properties": { + "backupInventory": { + "anyOf": [ + { + "oneOf": [ + { + "type": "object", + "properties": { + "backups": { + "type": "array", + "items": { + "type": "object", + "properties": { + "bytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "createdAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "kind": { + "enum": [ + "cutover", + "scheduled" + ], + "type": "string" + }, + "restoreVerifiedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "verificationLevel": { + "enum": [ + "manifest-verified", + "restore-copy-verified" + ], + "type": "string" + } + }, + "required": [ + "bytes", + "createdAtMs", + "kind", + "verificationLevel" + ], + "additionalProperties": false + }, + "maxItems": 32 + }, + "observedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "totalBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "state": { + "const": "available" + } + }, + "required": [ + "backups", + "observedAtMs", + "totalBytes", + "state" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "backups": { + "type": "array", + "items": { + "type": "object", + "properties": { + "bytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "createdAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "kind": { + "enum": [ + "cutover", + "scheduled" + ], + "type": "string" + }, + "restoreVerifiedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "verificationLevel": { + "enum": [ + "manifest-verified", + "restore-copy-verified" + ], + "type": "string" + } + }, + "required": [ + "bytes", + "createdAtMs", + "kind", + "verificationLevel" + ], + "additionalProperties": false + }, + "maxItems": 32 + }, + "observedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "totalBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "staleSinceMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "state": { + "const": "last-known-good" + } + }, + "required": [ + "backups", + "observedAtMs", + "totalBytes", + "staleSinceMs", + "state" + ], + "additionalProperties": false + } + ] + }, + { + "type": "object", + "properties": { + "reason": { + "const": "inventory-unavailable" + }, + "state": { + "const": "unavailable" + } + }, + "required": [ + "reason", + "state" + ], + "additionalProperties": false + } + ] + }, + "maintenance": { + "oneOf": [ + { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "latestSuccessfulAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "nextRunAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "observedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "runs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "finishedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "queuedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "startedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "state": { + "enum": [ + "cancelled", + "failed", + "queued", + "running", + "succeeded", + "timed-out" + ], + "type": "string" + } + }, + "required": [ + "queuedAtMs", + "state" + ], + "additionalProperties": false + }, + "maxItems": 10 + }, + "schedule": { + "type": "object", + "properties": { + "timeOfDay": { + "const": "02:40" + }, + "timeZone": { + "const": "Europe/Oslo" + } + }, + "required": [ + "timeOfDay", + "timeZone" + ], + "additionalProperties": false + }, + "state": { + "const": "available" + } + }, + "required": [ + "enabled", + "observedAtMs", + "runs", + "schedule", + "state" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "latestSuccessfulAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "nextRunAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "observedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "runs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "finishedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "queuedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "startedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "state": { + "enum": [ + "cancelled", + "failed", + "queued", + "running", + "succeeded", + "timed-out" + ], + "type": "string" + } + }, + "required": [ + "queuedAtMs", + "state" + ], + "additionalProperties": false + }, + "maxItems": 10 + }, + "schedule": { + "type": "object", + "properties": { + "timeOfDay": { + "const": "02:40" + }, + "timeZone": { + "const": "Europe/Oslo" + } + }, + "required": [ + "timeOfDay", + "timeZone" + ], + "additionalProperties": false + }, + "staleSinceMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "state": { + "const": "last-known-good" + } + }, + "required": [ + "enabled", + "observedAtMs", + "runs", + "schedule", + "staleSinceMs", + "state" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "reason": { + "const": "maintenance-unavailable" + }, + "state": { + "const": "unavailable" + } + }, + "required": [ + "reason", + "state" + ], + "additionalProperties": false + } + ] + }, + "restoreVerification": { + "oneOf": [ + { + "type": "object", + "properties": { + "backupBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "backupCreatedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "observedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "verifiedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "state": { + "const": "verified" + } + }, + "required": [ + "backupBytes", + "backupCreatedAtMs", + "observedAtMs", + "verifiedAtMs", + "state" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "backupBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "backupCreatedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "observedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "verifiedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "staleSinceMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "state": { + "const": "last-known-good" + } + }, + "required": [ + "backupBytes", + "backupCreatedAtMs", + "observedAtMs", + "verifiedAtMs", + "staleSinceMs", + "state" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "reason": { + "enum": [ + "no-verified-backup", + "verification-unavailable" + ], + "type": "string" + }, + "state": { + "const": "unavailable" + } + }, + "required": [ + "reason", + "state" + ], + "additionalProperties": false + } + ] + } + }, + "required": [ + "backupInventory", + "maintenance", + "restoreVerification" + ], + "additionalProperties": false + }, + "migrations": { + "type": "object", + "properties": { + "applied": { + "type": "integer", + "minimum": 0, + "maximum": 64 + }, + "available": { + "type": "integer", + "minimum": 0, + "maximum": 64 + }, + "current": { + "type": "boolean" + } + }, + "required": [ + "applied", + "available", + "current" + ], + "additionalProperties": false, + "$comment": "Live Valibot validation additionally requires applied migrations not to exceed the bundled graph and current status to agree with the counts." + }, + "storage": { + "type": "object", + "properties": { + "databaseBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "freeBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "freePages": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "freePercent": { + "type": "number", + "minimum": 0, + "maximum": 100 + }, + "pageCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "pageSizeBytes": { + "type": "integer", + "minimum": 512, + "maximum": 65536, + "$comment": "Live Valibot validation additionally requires the SQLite page size to be a power of two." + }, + "permissions": { + "type": "object", + "properties": { + "dataDirectory": { + "type": "string", + "pattern": "^0[0-7]{3}$" + }, + "database": { + "type": "string", + "pattern": "^0[0-7]{3}$" + }, + "secure": { + "type": "boolean" + }, + "shm": { + "type": "string", + "pattern": "^0[0-7]{3}$" + }, + "wal": { + "type": "string", + "pattern": "^0[0-7]{3}$" + } + }, + "required": [ + "dataDirectory", + "database", + "secure" + ], + "additionalProperties": false + }, + "requiresVacuumReview": { + "type": "boolean" + }, + "shmBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "storageBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "walBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "databaseBytes", + "freeBytes", + "freePages", + "freePercent", + "pageCount", + "pageSizeBytes", + "permissions", + "requiresVacuumReview", + "shmBytes", + "storageBytes", + "walBytes" + ], + "additionalProperties": false, + "$comment": "Live Valibot validation additionally requires SQLite file totals, reusable pages and bytes, percentages, permission security, and VACUUM review status to agree exactly." + }, + "observedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "state": { + "const": "fresh" + } + }, + "required": [ + "connection", + "fileName", + "lifecycle", + "migrations", + "storage", + "observedAtMs", + "state" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "connection": { + "type": "object", + "properties": { + "busyPolicy": { + "const": "non-blocking" + }, + "checksEnforced": { + "const": true + }, + "foreignKeysEnabled": { + "const": true + }, + "journalMode": { + "const": "wal" + }, + "synchronousMode": { + "const": "full" + }, + "trustedSchemaEnabled": { + "const": false + }, + "walAutoCheckpointPages": { + "type": "integer", + "minimum": 0, + "maximum": 1000000 + } + }, + "required": [ + "busyPolicy", + "checksEnforced", + "foreignKeysEnabled", + "journalMode", + "synchronousMode", + "trustedSchemaEnabled", + "walAutoCheckpointPages" + ], + "additionalProperties": false + }, + "fileName": { + "const": "mira-dashboard.db" + }, + "lifecycle": { + "type": "object", + "properties": { + "backupInventory": { + "anyOf": [ + { + "oneOf": [ + { + "type": "object", + "properties": { + "backups": { + "type": "array", + "items": { + "type": "object", + "properties": { + "bytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "createdAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "kind": { + "enum": [ + "cutover", + "scheduled" + ], + "type": "string" + }, + "restoreVerifiedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "verificationLevel": { + "enum": [ + "manifest-verified", + "restore-copy-verified" + ], + "type": "string" + } + }, + "required": [ + "bytes", + "createdAtMs", + "kind", + "verificationLevel" + ], + "additionalProperties": false + }, + "maxItems": 32 + }, + "observedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "totalBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "state": { + "const": "available" + } + }, + "required": [ + "backups", + "observedAtMs", + "totalBytes", + "state" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "backups": { + "type": "array", + "items": { + "type": "object", + "properties": { + "bytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "createdAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "kind": { + "enum": [ + "cutover", + "scheduled" + ], + "type": "string" + }, + "restoreVerifiedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "verificationLevel": { + "enum": [ + "manifest-verified", + "restore-copy-verified" + ], + "type": "string" + } + }, + "required": [ + "bytes", + "createdAtMs", + "kind", + "verificationLevel" + ], + "additionalProperties": false + }, + "maxItems": 32 + }, + "observedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "totalBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "staleSinceMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "state": { + "const": "last-known-good" + } + }, + "required": [ + "backups", + "observedAtMs", + "totalBytes", + "staleSinceMs", + "state" + ], + "additionalProperties": false + } + ] + }, + { + "type": "object", + "properties": { + "reason": { + "const": "inventory-unavailable" + }, + "state": { + "const": "unavailable" + } + }, + "required": [ + "reason", + "state" + ], + "additionalProperties": false + } + ] + }, + "maintenance": { + "oneOf": [ + { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "latestSuccessfulAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "nextRunAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "observedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "runs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "finishedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "queuedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "startedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "state": { + "enum": [ + "cancelled", + "failed", + "queued", + "running", + "succeeded", + "timed-out" + ], + "type": "string" + } + }, + "required": [ + "queuedAtMs", + "state" + ], + "additionalProperties": false + }, + "maxItems": 10 + }, + "schedule": { + "type": "object", + "properties": { + "timeOfDay": { + "const": "02:40" + }, + "timeZone": { + "const": "Europe/Oslo" + } + }, + "required": [ + "timeOfDay", + "timeZone" + ], + "additionalProperties": false + }, + "state": { + "const": "available" + } + }, + "required": [ + "enabled", + "observedAtMs", + "runs", + "schedule", + "state" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "enabled": { + "type": "boolean" + }, + "latestSuccessfulAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "nextRunAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "observedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "runs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "finishedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "queuedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "startedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "state": { + "enum": [ + "cancelled", + "failed", + "queued", + "running", + "succeeded", + "timed-out" + ], + "type": "string" + } + }, + "required": [ + "queuedAtMs", + "state" + ], + "additionalProperties": false + }, + "maxItems": 10 + }, + "schedule": { + "type": "object", + "properties": { + "timeOfDay": { + "const": "02:40" + }, + "timeZone": { + "const": "Europe/Oslo" + } + }, + "required": [ + "timeOfDay", + "timeZone" + ], + "additionalProperties": false + }, + "staleSinceMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "state": { + "const": "last-known-good" + } + }, + "required": [ + "enabled", + "observedAtMs", + "runs", + "schedule", + "staleSinceMs", + "state" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "reason": { + "const": "maintenance-unavailable" + }, + "state": { + "const": "unavailable" + } + }, + "required": [ + "reason", + "state" + ], + "additionalProperties": false + } + ] + }, + "restoreVerification": { + "oneOf": [ + { + "type": "object", + "properties": { + "backupBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "backupCreatedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "observedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "verifiedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "state": { + "const": "verified" + } + }, + "required": [ + "backupBytes", + "backupCreatedAtMs", + "observedAtMs", + "verifiedAtMs", + "state" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "backupBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "backupCreatedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "observedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "verifiedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "staleSinceMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "state": { + "const": "last-known-good" + } + }, + "required": [ + "backupBytes", + "backupCreatedAtMs", + "observedAtMs", + "verifiedAtMs", + "staleSinceMs", + "state" + ], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "reason": { + "enum": [ + "no-verified-backup", + "verification-unavailable" + ], + "type": "string" + }, + "state": { + "const": "unavailable" + } + }, + "required": [ + "reason", + "state" + ], + "additionalProperties": false + } + ] + } + }, + "required": [ + "backupInventory", + "maintenance", + "restoreVerification" + ], + "additionalProperties": false + }, + "migrations": { + "type": "object", + "properties": { + "applied": { + "type": "integer", + "minimum": 0, + "maximum": 64 + }, + "available": { + "type": "integer", + "minimum": 0, + "maximum": 64 + }, + "current": { + "type": "boolean" + } + }, + "required": [ + "applied", + "available", + "current" + ], + "additionalProperties": false, + "$comment": "Live Valibot validation additionally requires applied migrations not to exceed the bundled graph and current status to agree with the counts." + }, + "storage": { + "type": "object", + "properties": { + "databaseBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "freeBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "freePages": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "freePercent": { + "type": "number", + "minimum": 0, + "maximum": 100 + }, + "pageCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "pageSizeBytes": { + "type": "integer", + "minimum": 512, + "maximum": 65536, + "$comment": "Live Valibot validation additionally requires the SQLite page size to be a power of two." + }, + "permissions": { + "type": "object", + "properties": { + "dataDirectory": { + "type": "string", + "pattern": "^0[0-7]{3}$" + }, + "database": { + "type": "string", + "pattern": "^0[0-7]{3}$" + }, + "secure": { + "type": "boolean" + }, + "shm": { + "type": "string", + "pattern": "^0[0-7]{3}$" + }, + "wal": { + "type": "string", + "pattern": "^0[0-7]{3}$" + } + }, + "required": [ + "dataDirectory", + "database", + "secure" + ], + "additionalProperties": false + }, + "requiresVacuumReview": { + "type": "boolean" + }, + "shmBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "storageBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "walBytes": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + "required": [ + "databaseBytes", + "freeBytes", + "freePages", + "freePercent", + "pageCount", + "pageSizeBytes", + "permissions", + "requiresVacuumReview", + "shmBytes", + "storageBytes", + "walBytes" + ], + "additionalProperties": false, + "$comment": "Live Valibot validation additionally requires SQLite file totals, reusable pages and bytes, percentages, permission security, and VACUUM review status to agree exactly." + }, + "observedAtMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "staleSinceMs": { + "type": "integer", + "minimum": 0, + "maximum": 8640000000000000 + }, + "state": { + "const": "last-known-good" + } + }, + "required": [ + "connection", + "fileName", + "lifecycle", + "migrations", + "storage", + "observedAtMs", + "staleSinceMs", + "state" + ], + "additionalProperties": false + } + ] + } + }, + "required": [ + "checkedAtMs", + "postgresql", + "sqlite" + ], + "additionalProperties": false, + "$comment": "Live Valibot validation additionally requires SQLite observation and stale timestamps to remain causally ordered within the response check time, and enforces the PostgreSQL 128 KiB UTF-8 payload budget, canonical unique rows, contiguous statement ranks, exact aggregate sizes and connection bounds, and coherent maintenance assessment.", + "$schema": "https://json-schema.org/draft/2020-12/schema" +} diff --git a/greenfield/docs/generated/schemas/securityAudit.listEvents.output.schema.json b/greenfield/docs/generated/schemas/securityAudit.listEvents.output.schema.json index ec5796218..4af5fb82b 100644 --- a/greenfield/docs/generated/schemas/securityAudit.listEvents.output.schema.json +++ b/greenfield/docs/generated/schemas/securityAudit.listEvents.output.schema.json @@ -139,6 +139,7 @@ "cache:write", "chat:read", "chat:write", + "database:read", "files:read", "files:write", "gateway-sessions:read", @@ -165,7 +166,7 @@ ], "type": "string" }, - "maxItems": 29, + "maxItems": 30, "uniqueItems": true }, "method": { @@ -214,6 +215,7 @@ "cache:write", "chat:read", "chat:write", + "database:read", "files:read", "files:write", "gateway-sessions:read", @@ -240,7 +242,7 @@ ], "type": "string" }, - "maxItems": 29, + "maxItems": 30, "uniqueItems": true }, "replacementCredentialId": { diff --git a/greenfield/migrations/20260804022252_dashboard-foundation/migration.sql b/greenfield/migrations/20260804022252_dashboard-foundation/migration.sql index e63a17b09..3044052e2 100644 --- a/greenfield/migrations/20260804022252_dashboard-foundation/migration.sql +++ b/greenfield/migrations/20260804022252_dashboard-foundation/migration.sql @@ -139,7 +139,7 @@ CREATE TABLE `automation_principal_capabilities` ( `principal_id` text NOT NULL, CONSTRAINT `automation_principal_capabilities_pk` PRIMARY KEY(`principal_id`, `capability`), CONSTRAINT `fk_automation_principal_capabilities_principal_id_automation_principals_id_fk` FOREIGN KEY (`principal_id`) REFERENCES `automation_principals`(`id`) ON DELETE CASCADE, - CONSTRAINT "automation_principal_capabilities_capability_check" CHECK("capability" IN ('agents:read', 'agents:write', 'cache:read', 'cache:write', 'chat:read', 'chat:write', 'files:read', 'files:write', 'gateway-sessions:read', 'gateway-sessions:write', 'jobs:read', 'jobs:write', 'logs:read', 'logs:write', 'monitoring:write', 'notifications:read', 'notifications:write', 'openclaw-settings:read', 'openclaw-settings:write', 'openclaw-tasks:read', 'openclaw-tasks:write', 'reports:read', 'reports:write', 'service-actions:read', 'service-actions:write', 'tasks:read', 'tasks:write', 'terminal:read', 'terminal:write')), + CONSTRAINT "automation_principal_capabilities_capability_check" CHECK("capability" IN ('agents:read', 'agents:write', 'cache:read', 'cache:write', 'chat:read', 'chat:write', 'database:read', 'files:read', 'files:write', 'gateway-sessions:read', 'gateway-sessions:write', 'jobs:read', 'jobs:write', 'logs:read', 'logs:write', 'monitoring:write', 'notifications:read', 'notifications:write', 'openclaw-settings:read', 'openclaw-settings:write', 'openclaw-tasks:read', 'openclaw-tasks:write', 'reports:read', 'reports:write', 'service-actions:read', 'service-actions:write', 'tasks:read', 'tasks:write', 'terminal:read', 'terminal:write')), CONSTRAINT "automation_principal_capabilities_granted_at_check" CHECK("granted_at" BETWEEN 0 AND 8640000000000000) ) STRICT; --> statement-breakpoint diff --git a/greenfield/migrations/20260804022252_dashboard-foundation/snapshot.json b/greenfield/migrations/20260804022252_dashboard-foundation/snapshot.json index 4e8383d07..80c8530d8 100644 --- a/greenfield/migrations/20260804022252_dashboard-foundation/snapshot.json +++ b/greenfield/migrations/20260804022252_dashboard-foundation/snapshot.json @@ -7252,7 +7252,7 @@ "table": "automation_credentials" }, { - "value": "\"capability\" IN ('agents:read', 'agents:write', 'cache:read', 'cache:write', 'chat:read', 'chat:write', 'files:read', 'files:write', 'gateway-sessions:read', 'gateway-sessions:write', 'jobs:read', 'jobs:write', 'logs:read', 'logs:write', 'monitoring:write', 'notifications:read', 'notifications:write', 'openclaw-settings:read', 'openclaw-settings:write', 'openclaw-tasks:read', 'openclaw-tasks:write', 'reports:read', 'reports:write', 'service-actions:read', 'service-actions:write', 'tasks:read', 'tasks:write', 'terminal:read', 'terminal:write')", + "value": "\"capability\" IN ('agents:read', 'agents:write', 'cache:read', 'cache:write', 'chat:read', 'chat:write', 'database:read', 'files:read', 'files:write', 'gateway-sessions:read', 'gateway-sessions:write', 'jobs:read', 'jobs:write', 'logs:read', 'logs:write', 'monitoring:write', 'notifications:read', 'notifications:write', 'openclaw-settings:read', 'openclaw-settings:write', 'openclaw-tasks:read', 'openclaw-tasks:write', 'reports:read', 'reports:write', 'service-actions:read', 'service-actions:write', 'tasks:read', 'tasks:write', 'terminal:read', 'terminal:write')", "name": "automation_principal_capabilities_capability_check", "entityType": "checks", "table": "automation_principal_capabilities" diff --git a/greenfield/package.json b/greenfield/package.json index 59d1b81e1..6643a21da 100644 --- a/greenfield/package.json +++ b/greenfield/package.json @@ -63,11 +63,11 @@ "@tanstack/query-core": "5.101.4", "@tanstack/query-db-collection": "1.2.1", "@tanstack/react-db": "0.1.95", - "@tanstack/react-form": "^1.33.4", + "@tanstack/react-form": "^1.33.5", "@tanstack/react-query": "^5.101.4", - "@tanstack/react-router": "^1.170.23", + "@tanstack/react-router": "^1.170.27", "@tanstack/react-store": "0.11.1", - "@tanstack/react-table": "^9.1.1", + "@tanstack/react-table": "^9.1.2", "@tanstack/react-virtual": "^3.14.9", "@trpc/client": "11.18.0", "@trpc/server": "11.18.0", @@ -78,9 +78,9 @@ "clsx": "^2.1.1", "date-fns": "^4.4.0", "drizzle-orm": "1.0.0-rc.4", - "effect": "4.0.0-beta.106", + "effect": "4.0.0-beta.107", "json5": "^2.2.3", - "lucide-react": "^1.30.0", + "lucide-react": "^1.31.0", "otplib": "13.4.1", "qrcode.react": "4.2.0", "react": "^19.2.8", @@ -100,19 +100,19 @@ "devDependencies": { "@babel/core": "^8.0.1", "@happy-dom/global-registrator": "^20.11.2", - "@storybook/addon-a11y": "10.6.0-alpha.4", - "@storybook/addon-docs": "10.6.0-alpha.4", - "@storybook/addon-vitest": "10.6.0-alpha.4", - "@storybook/tanstack-react": "10.6.0-alpha.4", + "@storybook/addon-a11y": "10.6.0-alpha.5", + "@storybook/addon-docs": "10.6.0-alpha.5", + "@storybook/addon-vitest": "10.6.0-alpha.5", + "@storybook/tanstack-react": "10.6.0-alpha.5", "@tailwindcss/vite": "4.3.3", - "@tanstack/react-devtools": "^0.10.9", - "@tanstack/react-form-devtools": "^0.2.33", + "@tanstack/react-devtools": "^0.10.10", + "@tanstack/react-form-devtools": "^0.2.34", "@tanstack/react-query-devtools": "^5.101.4", "@tanstack/react-router-devtools": "1.167.1", "@testing-library/dom": "^10.4.1", - "@testing-library/jest-dom": "^7.0.0", + "@testing-library/jest-dom": "^7.0.1", "@testing-library/react": "^16.3.2", - "@testing-library/user-event": "^14.6.3", + "@testing-library/user-event": "^14.6.4", "@types/babel__core": "^7.20.5", "@types/node": "26.2.0", "@types/react": "^19.2.18", @@ -124,18 +124,18 @@ "bun-plugin-tailwind": "^0.1.2", "bun-types": "1.4.0-canary.20260519T150915", "drizzle-kit": "1.0.0-rc.4", - "eventsource": "4.1.1", + "eventsource": "5.0.0", "happy-dom": "^20.11.2", "jsdom-testing-mocks": "^1.16.0", "jsonc-parser": "3.3.1", "lcov-result-merger": "^6.0.0", - "oxfmt": "^0.62.0", - "oxlint": "^1.77.0", - "oxlint-config-presets": "^0.1.18", - "oxlint-tailwindcss": "^1.7.1", + "oxfmt": "^0.63.0", + "oxlint": "^1.78.0", + "oxlint-config-presets": "^0.1.19", + "oxlint-tailwindcss": "^1.9.0", "oxlint-tsgolint": "^7.0.2001", "playwright": "1.62.1", - "storybook": "10.6.0-alpha.4", + "storybook": "10.6.0-alpha.5", "tailwindcss": "^4.3.3", "typescript": "^7.0.2", "vite": "8.2.1", diff --git a/greenfield/scripts/buildSourceIdentity.test.ts b/greenfield/scripts/buildSourceIdentity.test.ts index 8b380b21e..2e1154483 100644 --- a/greenfield/scripts/buildSourceIdentity.test.ts +++ b/greenfield/scripts/buildSourceIdentity.test.ts @@ -5,6 +5,7 @@ import path from "node:path"; import { resolveBuildSourceIdentity } from "./buildSourceIdentity.ts"; +const maximumGitFixtureOutputBytes = 1024 * 1024; const temporaryRepositories: string[] = []; afterEach(async () => { @@ -15,41 +16,47 @@ afterEach(async () => { ); }); -function runGit(repositoryRoot: string, ...arguments_: string[]): string { - const result = Bun.spawnSync( +async function runGit(repositoryRoot: string, ...arguments_: string[]): Promise { + const child = Bun.spawn( ["git", "--no-optional-locks", "-C", repositoryRoot, ...arguments_], { + maxBuffer: maximumGitFixtureOutputBytes, stderr: "pipe", stdin: "ignore", stdout: "pipe", } ); - if (result.exitCode !== 0) { - throw new Error(result.stderr.toString() || "Git fixture command failed"); + const [exitCode, stderr, stdout] = await Promise.all([ + child.exited, + new Response(child.stderr).text(), + new Response(child.stdout).text(), + ]); + if (exitCode !== 0) { + throw new Error(stderr || "Git fixture command failed"); } - return result.stdout.toString().trim(); + return stdout.trim(); } async function createRepository(): Promise<{ commitSha: string; root: string }> { const root = await mkdtemp(path.join(tmpdir(), "mira-source-identity-")); temporaryRepositories.push(root); - runGit(root, "init", "--quiet"); - runGit(root, "config", "user.name", "Mira Test"); - runGit(root, "config", "user.email", "mira-test@example.invalid"); + await runGit(root, "init", "--quiet"); + await runGit(root, "config", "user.name", "Mira Test"); + await runGit(root, "config", "user.email", "mira-test@example.invalid"); await writeFile(path.join(root, "tracked.txt"), "initial\n", { encoding: "utf8", mode: 0o600, }); - runGit(root, "add", "tracked.txt"); - runGit(root, "commit", "--quiet", "--message", "initial"); - return { commitSha: runGit(root, "rev-parse", "HEAD"), root }; + await runGit(root, "add", "tracked.txt"); + await runGit(root, "commit", "--quiet", "--message", "initial"); + return { commitSha: await runGit(root, "rev-parse", "HEAD"), root }; } describe("build source identity", () => { test("returns the full commit for a clean repository", async () => { const { commitSha, root } = await createRepository(); - const identity = resolveBuildSourceIdentity(root); + const identity = await resolveBuildSourceIdentity(root); expect(identity).toEqual({ commitSha, state: "clean" }); expect(Object.isFrozen(identity)).toBe(true); @@ -61,19 +68,19 @@ describe("build source identity", () => { const untrackedPath = path.join(root, "untracked.txt"); await writeFile(trackedPath, "changed\n", { encoding: "utf8", mode: 0o600 }); - expect(resolveBuildSourceIdentity(root)).toEqual({ + expect(await resolveBuildSourceIdentity(root)).toEqual({ commitSha, state: "dirty", }); - runGit(root, "add", "tracked.txt"); - expect(resolveBuildSourceIdentity(root)).toEqual({ + await runGit(root, "add", "tracked.txt"); + expect(await resolveBuildSourceIdentity(root)).toEqual({ commitSha, state: "dirty", }); await writeFile(untrackedPath, "new\n", { encoding: "utf8", mode: 0o600 }); - expect(resolveBuildSourceIdentity(root)).toEqual({ + expect(await resolveBuildSourceIdentity(root)).toEqual({ commitSha, state: "dirty", }); @@ -84,8 +91,8 @@ describe("build source identity", () => { const root = await mkdtemp(path.join(tmpdir(), "mira-source-identity-")); temporaryRepositories.push(root); - expect(resolveBuildSourceIdentity(root)).toEqual({ state: "unknown" }); - expect(resolveBuildSourceIdentity("relative/path")).toEqual({ + expect(await resolveBuildSourceIdentity(root)).toEqual({ state: "unknown" }); + expect(await resolveBuildSourceIdentity("relative/path")).toEqual({ state: "unknown", }); }); diff --git a/greenfield/scripts/buildSourceIdentity.ts b/greenfield/scripts/buildSourceIdentity.ts index 9cf0594db..d17dad53e 100644 --- a/greenfield/scripts/buildSourceIdentity.ts +++ b/greenfield/scripts/buildSourceIdentity.ts @@ -12,12 +12,13 @@ export type BuildSourceIdentity = const maximumGitOutputBytes = 1024 * 1024; const commitShaSchema = fullCommitShaSchema(); -function gitOutput( +async function gitOutput( repositoryRoot: string, arguments_: readonly string[] -): string | undefined { +): Promise { + let child: Bun.Subprocess<"ignore", "pipe", "ignore">; try { - const result = Bun.spawnSync( + child = Bun.spawn( ["git", "--no-optional-locks", "-C", repositoryRoot, ...arguments_], { maxBuffer: maximumGitOutputBytes, @@ -26,11 +27,22 @@ function gitOutput( stdout: "pipe", } ); - if (result.exitCode !== 0) return undefined; - return result.stdout.toString().trim(); } catch { return undefined; } + + try { + const [exitCode, stdout] = await Promise.all([ + child.exited, + new Response(child.stdout).text(), + ]); + if (exitCode !== 0) return undefined; + return stdout.trim(); + } catch { + child.kill(); + await child.exited.catch(() => null); + return undefined; + } } /** @@ -39,16 +51,22 @@ function gitOutput( * @param repositoryRoot Absolute repository root to inspect. * @returns Clean, dirty, or unknown source identity. */ -export function resolveBuildSourceIdentity(repositoryRoot: string): BuildSourceIdentity { +export async function resolveBuildSourceIdentity( + repositoryRoot: string +): Promise { if (!path.isAbsolute(repositoryRoot) || repositoryRoot.includes("\0")) { return Object.freeze({ state: "unknown" }); } - const commitOutput = gitOutput(repositoryRoot, ["rev-parse", "--verify", "HEAD"]); + const commitOutput = await gitOutput(repositoryRoot, [ + "rev-parse", + "--verify", + "HEAD", + ]); const commit = v.safeParse(commitShaSchema, commitOutput, { abortEarly: true }); if (!commit.success) return Object.freeze({ state: "unknown" }); - const status = gitOutput(repositoryRoot, [ + const status = await gitOutput(repositoryRoot, [ "status", "--porcelain=v1", "--untracked-files=all", diff --git a/greenfield/scripts/checkCoverage.test.ts b/greenfield/scripts/checkCoverage.test.ts index 8aaad7893..fb1ea2147 100644 --- a/greenfield/scripts/checkCoverage.test.ts +++ b/greenfield/scripts/checkCoverage.test.ts @@ -75,9 +75,85 @@ describe("coverage threshold", () => { } }); - test("keeps Bun coverage from counting stories and story support", async () => { + test("includes exact executable root configurations without treating them as directories", async () => { + const projectRoot = await temporaryProject(); + try { + await Promise.all([ + writeFile( + path.join(projectRoot, "drizzle.config.ts"), + "export default { dialect: 'sqlite' };" + ), + writeFile( + path.join(projectRoot, "tailwind.config.ts"), + "export default { plugins: [] };" + ), + ]); + + expect( + await discoverExecutableCoverageSources(projectRoot, [ + "scripts", + "src", + "drizzle.config.ts", + "tailwind.config.ts", + ]) + ).toEqual(["drizzle.config.ts", "tailwind.config.ts"]); + expect( + summarizeLineCoverage( + [ + record("drizzle.config.ts", 2, 1), + record("drizzle.config.ts/injected.ts", 100, 100), + record("tailwind.config.ts", 2, 1), + record("tailwind.config.ts/injected.ts", 100, 100), + ].join("\n"), + ["drizzle.config.ts", "tailwind.config.ts"] + ) + ).toEqual({ foundLines: 4, hitLines: 2, percent: 50 }); + } finally { + await rm(projectRoot, { force: true, recursive: true }); + } + }); + + test("excludes script test support without hiding similarly named production files", async () => { + const projectRoot = await temporaryProject(); + try { + await mkdir(path.join(projectRoot, "scripts", "nested", "testSupport"), { + recursive: true, + }); + await Promise.all([ + writeFile( + path.join(projectRoot, "scripts", "productionTestSupport.ts"), + "export const production = true;" + ), + writeFile( + path.join(projectRoot, "scripts", "testSupport.ts"), + "export const fixture = true;" + ), + writeFile( + path.join( + projectRoot, + "scripts", + "nested", + "testSupport", + "fixture.ts" + ), + "export const nestedFixture = true;" + ), + ]); + + expect( + await discoverExecutableCoverageSources(projectRoot, ["scripts"]) + ).toEqual(["scripts/productionTestSupport.ts"]); + } finally { + await rm(projectRoot, { force: true, recursive: true }); + } + }); + + test("keeps Bun coverage from counting test support, stories and story support", async () => { const bunfig = await Bun.file(new URL("../bunfig.toml", import.meta.url)).text(); + expect(bunfig).toContain('"scripts/**/testSupport/**"'); + expect(bunfig).toContain('"scripts/**/testSupport.ts"'); + expect(bunfig).toContain('"scripts/**/testSupport.tsx"'); expect(bunfig).toContain('"src/**/*.stories.tsx"'); expect(bunfig).toContain('"src/**/storySupport/**"'); }); diff --git a/greenfield/scripts/checkCoverage.ts b/greenfield/scripts/checkCoverage.ts index 7395224cd..c1ef22fc4 100644 --- a/greenfield/scripts/checkCoverage.ts +++ b/greenfield/scripts/checkCoverage.ts @@ -15,6 +15,10 @@ export interface LineCoverageSummary { } const maximumLcovBytes = 64 * 1024 * 1024; +const exactCoverageSourceFiles: ReadonlySet = new Set([ + "drizzle.config.ts", + "tailwind.config.ts", +]); const typeScriptModuleExtensions: ReadonlySet = new Set([".cts", ".mts", ".ts"]); function normalizeCoveragePath(value: string): string { @@ -22,7 +26,11 @@ function normalizeCoveragePath(value: string): string { } function belongsToRoot(sourcePath: string, sourceRoot: string): boolean { - return sourcePath === sourceRoot || sourcePath.startsWith(`${sourceRoot}/`); + return ( + sourcePath === sourceRoot || + (!exactCoverageSourceFiles.has(sourceRoot) && + sourcePath.startsWith(`${sourceRoot}/`)) + ); } function isStorybookSource(sourcePath: string): boolean { diff --git a/greenfield/scripts/checkDatabaseSchema.test.ts b/greenfield/scripts/checkDatabaseSchema.test.ts index 54cf488c2..305b1c88d 100644 --- a/greenfield/scripts/checkDatabaseSchema.test.ts +++ b/greenfield/scripts/checkDatabaseSchema.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; +import drizzleConfiguration from "../drizzle.config.ts"; import { assertDrizzleKitOutput, checkDatabaseSchema, @@ -19,6 +20,18 @@ function commandResult( } describe("database schema gate", () => { + test("keeps the executable Drizzle root configuration under coverage", () => { + expect(drizzleConfiguration).toMatchObject({ + breakpoints: true, + dbCredentials: { url: "./data/drizzle-kit.db" }, + dialect: "sqlite", + out: "./migrations", + schema: "./src/server/database/schema/drizzleSchema.ts", + strict: true, + verbose: true, + }); + }); + test("checks history before a non-writing schema drift comparison", () => { const commands: string[][] = []; const results = [commandResult("ok"), commandResult("no_changes")]; diff --git a/greenfield/scripts/delivery/activateProductionRelease.ts b/greenfield/scripts/delivery/activateProductionRelease.ts index 0719a00fd..211c64ea2 100644 --- a/greenfield/scripts/delivery/activateProductionRelease.ts +++ b/greenfield/scripts/delivery/activateProductionRelease.ts @@ -5,12 +5,29 @@ import * as v from "valibot"; import { healthReadinessPath } from "../../src/contracts/system.ts"; import type { ProductionActivationRecord } from "../../src/shared/productionActivationRecord.ts"; +import type { ReleaseManifest } from "../../src/shared/releaseManifest.ts"; import { fullCommitShaSchema } from "../../src/shared/validation.ts"; +import type { DashboardDeploymentLease } from "./deploymentLease.ts"; import { withDeploymentLease } from "./deploymentLease.ts"; -import { prepareProductionDeliveryDirectories } from "./productionDeliveryFilesystem.ts"; -import { activatePublishedProductionRelease } from "./productionReleaseActivation.ts"; -import { publishProductionRelease } from "./productionReleasePublication.ts"; -import { installProductionRuntime } from "./productionRuntime.ts"; +import { assertProductionArtifactCapacity } from "./productionArtifactCapacity.ts"; +import { + prepareProductionDeliveryDirectories, + type PreparedProductionDeliveryPaths, +} from "./productionDeliveryFilesystem.ts"; +import { + activatePublishedProductionRelease, + prepareProductionArtifactAdmission, + type ProductionReleaseActivationDependencies, + type ProductionServiceController, +} from "./productionReleaseActivation.ts"; +import { + publishProductionRelease, + type PublishedProductionRelease, +} from "./productionReleasePublication.ts"; +import { + installProductionRuntime, + type InstalledProductionRuntime, +} from "./productionRuntime.ts"; import { prepareProtectedProductionStatePath } from "./productionStateFilesystem.ts"; import { verifyReleaseArtifactIdentity } from "./releaseIdentity.ts"; import { createSystemdProductionServiceController } from "./systemdProductionServices.ts"; @@ -85,6 +102,15 @@ export interface ActivateProductionReleaseCliDependencies { ) => Promise; } +/** Install/publication boundaries exposed to focused admission-lifecycle tests. */ +export interface ProductionArtifactDeliveryDependencies { + readonly activateRelease?: typeof activatePublishedProductionRelease; + readonly artifactAdmission?: typeof prepareProductionArtifactAdmission; + readonly capacityAdmission?: typeof assertProductionArtifactCapacity; + readonly installRuntime?: typeof installProductionRuntime; + readonly publishRelease?: typeof publishProductionRelease; +} + function readNamedArguments(arguments_: readonly string[]): Record { const values = Object.create(null) as Record; for (const argument of arguments_) { @@ -130,34 +156,81 @@ export function parseActivateProductionReleaseArguments( return Object.freeze(parsed.output); } -async function activateProductionRelease( - options: ActivateProductionReleaseArguments +/** + * Runs pre-admission recovery/retention, capacity admission, copy, and activation under one lease. + * A failed runtime install or publication immediately repeats journal-aware retention so repeated + * attempts with distinct identities cannot accumulate immutable artifacts. + * @returns The authoritative activation record after candidate readiness commits. + */ +export async function deliverProductionReleaseUnderLease( + lease: DashboardDeploymentLease, + paths: PreparedProductionDeliveryPaths, + options: ActivateProductionReleaseArguments, + sourceManifest: ReleaseManifest, + services: ProductionServiceController, + dependencies: ProductionArtifactDeliveryDependencies = {} ): Promise { - const state = await prepareProtectedProductionStatePath(options.projectRoot); - const sourceManifest = await verifyReleaseArtifactIdentity(options.releaseRoot); - return withDeploymentLease(state.stateDirectory, async (lease) => { - const paths = await prepareProductionDeliveryDirectories(state); - const runtime = await installProductionRuntime( + const artifactAdmission = + dependencies.artifactAdmission ?? prepareProductionArtifactAdmission; + const activationDependencies: ProductionReleaseActivationDependencies = { + services, + }; + const runtimeSource = options.runtimeSource ?? process.execPath; + await artifactAdmission(lease, paths, activationDependencies); + await (dependencies.capacityAdmission ?? assertProductionArtifactCapacity)( + lease, + paths, + options.releaseRoot, + sourceManifest, + runtimeSource + ); + + let runtime: InstalledProductionRuntime; + let release: PublishedProductionRelease; + try { + runtime = await (dependencies.installRuntime ?? installProductionRuntime)( lease, paths, sourceManifest.runtime, - options.runtimeSource === undefined - ? undefined - : { sourceExecutable: options.runtimeSource } + { sourceExecutable: runtimeSource } ); - const release = await publishProductionRelease( + release = await (dependencies.publishRelease ?? publishProductionRelease)( lease, paths, options.releaseRoot, sourceManifest.runtime ); + } catch { + await artifactAdmission(lease, paths, activationDependencies); + throw new Error(activationCliFailureMessage); + } + return Effect.runPromise( + (dependencies.activateRelease ?? activatePublishedProductionRelease)( + lease, + paths, + release, + runtime, + activationDependencies + ) + ); +} + +async function activateProductionRelease( + options: ActivateProductionReleaseArguments +): Promise { + const state = await prepareProtectedProductionStatePath(options.projectRoot); + const sourceManifest = await verifyReleaseArtifactIdentity(options.releaseRoot); + return withDeploymentLease(state.stateDirectory, async (lease) => { + const paths = await prepareProductionDeliveryDirectories(state); const services = createSystemdProductionServiceController(lease, paths, { readinessUrl: options.readinessUrl, }); - return Effect.runPromise( - activatePublishedProductionRelease(lease, paths, release, runtime, { - services, - }) + return deliverProductionReleaseUnderLease( + lease, + paths, + options, + sourceManifest, + services ); }); } diff --git a/greenfield/scripts/delivery/activateProductionReleaseAdmission.test.ts b/greenfield/scripts/delivery/activateProductionReleaseAdmission.test.ts new file mode 100644 index 000000000..7505408e8 --- /dev/null +++ b/greenfield/scripts/delivery/activateProductionReleaseAdmission.test.ts @@ -0,0 +1,510 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { chmod, lstat, mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { Effect } from "effect"; + +import type { ReleaseManifest } from "../../src/shared/releaseManifest.ts"; +import { rejectionError } from "../testSupport/rejection.ts"; +import { + deliverProductionReleaseUnderLease, + type ActivateProductionReleaseArguments, +} from "./activateProductionRelease.ts"; +import { withDeploymentLease } from "./deploymentLease.ts"; +import { + assertProductionArtifactCapacity, + assertProductionArtifactCopyCapacity, + productionArtifactCapacityReserveBytes, + productionArtifactCapacityReserveInodes, +} from "./productionArtifactCapacity.ts"; +import { retainProductionArtifacts } from "./productionArtifactRetention.ts"; +import { prepareProductionDeliveryDirectories } from "./productionDeliveryFilesystem.ts"; +import { + prepareProductionArtifactAdmission, + type ProductionServiceController, +} from "./productionReleaseActivation.ts"; +import type { PublishedProductionRelease } from "./productionReleasePublication.ts"; +import type { InstalledProductionRuntime } from "./productionRuntime.ts"; +import { prepareProtectedProductionStatePath } from "./productionStateFilesystem.ts"; +import type { ReleaseRuntimeIdentity } from "./releaseIdentity.ts"; + +const temporaryDirectories: string[] = []; +const releaseA = "a".repeat(40); +const releaseB = "b".repeat(40); +const staleRelease = "c".repeat(40); +const runtimeA = "d".repeat(40); +const runtimeB = "e".repeat(40); +const staleRuntime = "f".repeat(40); +const runtimeVersion = "1.4.0"; + +function filesystemCapacity(availableBytes: bigint, availableInodes = 1_000_000n) { + return Object.freeze({ availableBytes, availableInodes, blockSize: 4096n }); +} + +const services: ProductionServiceController = Object.freeze({ + prepare: () => Promise.resolve(), + start: () => Promise.resolve(), + stop: () => Promise.resolve(), + verifyReady: () => Promise.resolve(), +}); + +afterEach(async () => { + for (const directory of temporaryDirectories.splice(0)) { + await restoreOwnerWrite(directory); + await rm(directory, { force: true, recursive: true }); + } +}); + +async function restoreOwnerWrite(directory: string): Promise { + const status = await lstat(directory).catch(() => null); + if (!status?.isDirectory()) return; + await chmod(directory, 0o700).catch(() => {}); + for (const entry of await readdir(directory, { withFileTypes: true })) { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + await restoreOwnerWrite(entryPath); + } else if (entry.isFile()) { + await chmod(entryPath, 0o600).catch(() => {}); + } + } +} + +async function createFixture() { + const projectRoot = await mkdtemp(path.join(tmpdir(), "mira-production-admission-")); + const sourceRoot = await mkdtemp(path.join(tmpdir(), "mira-release-source-")); + const runtimeRoot = await mkdtemp(path.join(tmpdir(), "mira-runtime-source-")); + temporaryDirectories.push(projectRoot, sourceRoot, runtimeRoot); + const releaseRoot = path.join(sourceRoot, "release"); + const runtimeSource = path.join(runtimeRoot, "bun"); + await mkdir(releaseRoot, { mode: 0o700 }); + await writeFile(path.join(releaseRoot, "artifact"), "release", { mode: 0o600 }); + await writeFile(runtimeSource, "runtime", { mode: 0o500 }); + await chmod(runtimeSource, 0o500); + const state = await prepareProtectedProductionStatePath(projectRoot); + return { projectRoot, releaseRoot, runtimeSource, state }; +} + +function manifest(releaseId: string, runtimeRevision: string): ReleaseManifest { + return { + runtime: { revision: runtimeRevision, version: runtimeVersion }, + source: { commitSha: releaseId }, + } as unknown as ReleaseManifest; +} + +function options( + projectRoot: string, + releaseRoot: string, + runtimeSource: string +): ActivateProductionReleaseArguments { + return Object.freeze({ + projectRoot, + readinessUrl: "http://127.0.0.1:3100/api/health/ready", + releaseRoot, + runtimeSource, + }); +} + +async function immutableTree( + directory: string, + fileName: string, + fileMode: number +): Promise { + await mkdir(directory, { mode: 0o700, recursive: true }); + const file = path.join(directory, fileName); + await writeFile(file, "fixture", { mode: 0o600 }); + await chmod(file, fileMode); + await chmod(directory, 0o500); +} + +describe("production artifact pre-admission lifecycle", () => { + test("activates only after admission, install, and publication settle", async () => { + const fixture = await createFixture(); + await withDeploymentLease(fixture.state.stateDirectory, async (lease) => { + const paths = await prepareProductionDeliveryDirectories(fixture.state); + const candidateManifest = manifest(releaseA, runtimeA); + const events: string[] = []; + const transitionId = "019fd974-54a2-74dd-a64b-d4186f8d8828"; + const activation = { + current: { releaseId: releaseA, runtimeRevision: runtimeA }, + formatVersion: 1 as const, + previous: null, + transitionId, + }; + + const result = await deliverProductionReleaseUnderLease( + lease, + paths, + options(fixture.projectRoot, fixture.releaseRoot, fixture.runtimeSource), + candidateManifest, + services, + { + activateRelease: () => { + events.push("activate"); + return Effect.succeed(activation); + }, + artifactAdmission: () => { + events.push("retain"); + return Promise.resolve(); + }, + capacityAdmission: () => { + events.push("capacity"); + return Promise.resolve(); + }, + installRuntime: () => { + events.push("install"); + return Promise.resolve({ + executable: path.join( + paths.runtimesDirectory, + "bun", + runtimeA, + "bun" + ), + identity: candidateManifest.runtime, + }); + }, + publishRelease: () => { + events.push("publish"); + return Promise.resolve({ + manifest: candidateManifest, + releaseRoot: path.join(paths.releasesDirectory, releaseA), + }); + }, + } + ); + + expect(result).toEqual(activation); + expect(events).toEqual([ + "retain", + "capacity", + "install", + "publish", + "activate", + ]); + }); + }); + + test("cleans every distinct installed runtime after repeated publication failure", async () => { + const fixture = await createFixture(); + await withDeploymentLease(fixture.state.stateDirectory, async (lease) => { + const paths = await prepareProductionDeliveryDirectories(fixture.state); + const installed = new Set([staleRuntime]); + const events: string[] = []; + const dependencies = { + artifactAdmission: () => { + events.push("retain"); + installed.clear(); + return Promise.resolve(); + }, + capacityAdmission: () => { + events.push("capacity"); + expect(installed.size).toBe(0); + return Promise.resolve(); + }, + installRuntime: ( + _lease: typeof lease, + _paths: typeof paths, + identity: ReleaseRuntimeIdentity + ) => { + events.push(`install:${identity.revision}`); + installed.add(identity.revision); + return Promise.resolve({ + executable: path.join( + paths.runtimesDirectory, + "bun", + identity.revision, + "bun" + ), + identity, + } satisfies InstalledProductionRuntime); + }, + publishRelease: () => { + events.push("publish-failed"); + return Promise.reject(new Error("simulated publication failure")); + }, + }; + + for (const [releaseId, runtimeRevision] of [ + [releaseA, runtimeA], + [releaseB, runtimeB], + ] as const) { + const failure = await rejectionError( + deliverProductionReleaseUnderLease( + lease, + paths, + options( + fixture.projectRoot, + fixture.releaseRoot, + fixture.runtimeSource + ), + manifest(releaseId, runtimeRevision), + services, + dependencies + ) + ); + expect(failure.message).toBe("Production release activation failed"); + expect(installed.size).toBe(0); + } + + expect(events).toEqual([ + "retain", + "capacity", + `install:${runtimeA}`, + "publish-failed", + "retain", + "retain", + "capacity", + `install:${runtimeB}`, + "publish-failed", + "retain", + ]); + }); + }); + + test("reaps verified stale artifacts before evaluating low-space capacity", async () => { + const fixture = await createFixture(); + await withDeploymentLease(fixture.state.stateDirectory, async (lease) => { + const paths = await prepareProductionDeliveryDirectories(fixture.state); + await immutableTree( + path.join(paths.releasesDirectory, staleRelease), + "artifact", + 0o400 + ); + await immutableTree( + path.join(paths.runtimesDirectory, "bun", staleRuntime), + "bun", + 0o500 + ); + const staleReleasePath = path.join(paths.releasesDirectory, staleRelease); + const staleRuntimePath = path.join( + paths.runtimesDirectory, + "bun", + staleRuntime + ); + const candidateManifest = manifest(releaseA, runtimeA); + const availableCapacity = async () => { + const staleExists = + (await lstat(staleReleasePath).catch(() => null)) !== null || + (await lstat(staleRuntimePath).catch(() => null)) !== null; + return filesystemCapacity( + staleExists + ? 0n + : productionArtifactCapacityReserveBytes + 1024n * 1024n + ); + }; + const capacityDependencies = { + availableCapacity, + verifySourceRelease: () => Promise.resolve(candidateManifest), + }; + + const preRetentionFailure = await rejectionError( + assertProductionArtifactCapacity( + lease, + paths, + fixture.releaseRoot, + candidateManifest, + fixture.runtimeSource, + capacityDependencies + ) + ); + expect(preRetentionFailure.message).toBe( + "Production artifact capacity admission failed" + ); + + const artifactAdmission: typeof prepareProductionArtifactAdmission = ( + activeLease, + activePaths, + dependencies + ) => + prepareProductionArtifactAdmission(activeLease, activePaths, { + ...dependencies, + artifactRetention: (retentionLease, retentionPaths, references) => + retainProductionArtifacts( + retentionLease, + retentionPaths, + references, + { + verifyRelease: (_verifiedPaths, releaseId) => + Promise.resolve({ + manifest: manifest(releaseId, staleRuntime), + releaseRoot: path.join( + retentionPaths.releasesDirectory, + releaseId + ), + } as PublishedProductionRelease), + verifyRuntime: (_verifiedPaths, revision) => + Promise.resolve({ + executable: path.join( + retentionPaths.runtimesDirectory, + "bun", + revision, + "bun" + ), + identity: { + revision, + version: runtimeVersion, + }, + }), + } + ), + }); + let capacityChecks = 0; + const failure = await rejectionError( + deliverProductionReleaseUnderLease( + lease, + paths, + options( + fixture.projectRoot, + fixture.releaseRoot, + fixture.runtimeSource + ), + candidateManifest, + services, + { + artifactAdmission, + capacityAdmission: ( + activeLease, + activePaths, + sourceReleaseRoot, + sourceManifest, + sourceExecutable + ) => { + capacityChecks += 1; + return assertProductionArtifactCapacity( + activeLease, + activePaths, + sourceReleaseRoot, + sourceManifest, + sourceExecutable, + { + availableCapacity, + verifySourceRelease: () => + Promise.resolve(sourceManifest), + } + ); + }, + installRuntime: () => + Promise.reject(new Error("simulated install failure")), + } + ) + ); + + expect(failure.message).toBe("Production release activation failed"); + expect(capacityChecks).toBe(1); + expect(await lstat(staleReleasePath).catch(() => null)).toBeNull(); + expect(await lstat(staleRuntimePath).catch(() => null)).toBeNull(); + }); + }); + + test("does not charge current immutable slots a second time", async () => { + const fixture = await createFixture(); + await withDeploymentLease(fixture.state.stateDirectory, async (lease) => { + const paths = await prepareProductionDeliveryDirectories(fixture.state); + await immutableTree( + path.join(paths.releasesDirectory, releaseA), + "artifact", + 0o400 + ); + await immutableTree( + path.join(paths.runtimesDirectory, "bun", runtimeA), + "bun", + 0o500 + ); + let capacityMeasurements = 0; + + await assertProductionArtifactCapacity( + lease, + paths, + fixture.releaseRoot, + manifest(releaseA, runtimeA), + fixture.runtimeSource, + { + availableCapacity: () => { + capacityMeasurements += 1; + return Promise.resolve(filesystemCapacity(0n)); + }, + verifySourceRelease: () => + Promise.resolve(manifest(releaseA, runtimeA)), + } + ); + + expect(capacityMeasurements).toBe(0); + }); + }); + + test("fails closed when the supplied identity does not match the source tree", async () => { + const fixture = await createFixture(); + await withDeploymentLease(fixture.state.stateDirectory, async (lease) => { + const paths = await prepareProductionDeliveryDirectories(fixture.state); + const failure = await rejectionError( + assertProductionArtifactCapacity( + lease, + paths, + fixture.releaseRoot, + manifest(releaseA, runtimeA), + fixture.runtimeSource, + { + availableCapacity: () => + Promise.resolve( + filesystemCapacity( + productionArtifactCapacityReserveBytes + 1024n * 1024n + ) + ), + verifySourceRelease: () => + Promise.resolve(manifest(releaseB, runtimeB)), + } + ) + ); + + expect(failure.message).toBe("Production artifact capacity admission failed"); + }); + }); + + test("charges allocation blocks for many tiny files instead of logical bytes", async () => { + const fixture = await createFixture(); + const paths = await prepareProductionDeliveryDirectories(fixture.state); + const fileBytes = Object.freeze(Array.from({ length: 100 }, () => 1n)); + const failure = await rejectionError( + assertProductionArtifactCopyCapacity( + paths.productionDirectory, + Object.freeze({ fileBytes, newDirectoryCount: 1n }), + { + availableCapacity: () => + Promise.resolve( + filesystemCapacity( + productionArtifactCapacityReserveBytes + 100n + ) + ), + } + ) + ); + + expect(failure.message).toBe("Production artifact capacity admission failed"); + }); + + test("preserves a fixed free-inode reserve", async () => { + const fixture = await createFixture(); + const paths = await prepareProductionDeliveryDirectories(fixture.state); + const objectCount = 3n; + const failure = await rejectionError( + assertProductionArtifactCopyCapacity( + paths.productionDirectory, + Object.freeze({ + fileBytes: Object.freeze([4096n, 4096n]), + newDirectoryCount: 1n, + }), + { + availableCapacity: () => + Promise.resolve( + filesystemCapacity( + productionArtifactCapacityReserveBytes + 1024n * 1024n, + objectCount + productionArtifactCapacityReserveInodes - 1n + ) + ), + } + ) + ); + + expect(failure.message).toBe("Production artifact capacity admission failed"); + }); +}); diff --git a/greenfield/scripts/delivery/buildAdmission.test.ts b/greenfield/scripts/delivery/buildAdmission.test.ts index 30ad85753..293d32232 100644 --- a/greenfield/scripts/delivery/buildAdmission.test.ts +++ b/greenfield/scripts/delivery/buildAdmission.test.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -22,6 +22,21 @@ async function repositoryFixture(): Promise { return root; } +async function currentProcessIdentity() { + const processStat = await readFile(`/proc/${process.pid}/stat`, "utf8"); + const processFields = processStat + .slice(processStat.lastIndexOf(")") + 1) + .trim() + .split(/\s+/u); + const bootId = await readFile("/proc/sys/kernel/random/boot_id", "utf8"); + const processStartTicks = processFields[19]; + if (processStartTicks === undefined) throw new Error("Missing process identity"); + return Object.freeze({ + bootId: bootId.trim(), + processStartTicks, + }); +} + describe("Bun build admission", () => { test("runs competing operations one at a time", async () => { const repositoryRoot = await repositoryFixture(); @@ -64,10 +79,13 @@ describe("Bun build admission", () => { const recoveredRoot = await repositoryFixture(); const recoveredDist = path.join(recoveredRoot, "dist"); await mkdir(recoveredDist); + const identity = await currentProcessIdentity(); await writeFile( path.join(recoveredDist, ".bun-build.lock"), `${JSON.stringify({ + bootId: identity.bootId, pid: 1_999_999_999, + processStartTicks: "1", token: Bun.randomUUIDv7(), })}\n`, { mode: 0o600 } @@ -90,4 +108,37 @@ describe("Bun build admission", () => { ); expect(failure.message).toBe("Bun build admission failed"); }); + + test("recovers owners invalidated by a reboot or PID reuse", async () => { + const identity = await currentProcessIdentity(); + for (const owner of [ + { + bootId: "11111111-1111-4111-8111-111111111111", + pid: process.pid, + processStartTicks: identity.processStartTicks, + }, + { + bootId: identity.bootId, + pid: process.pid, + processStartTicks: identity.processStartTicks === "1" ? "2" : "1", + }, + ]) { + const repositoryRoot = await repositoryFixture(); + const dist = path.join(repositoryRoot, "dist"); + await mkdir(dist); + await writeFile( + path.join(dist, ".bun-build.lock"), + `${JSON.stringify({ ...owner, token: Bun.randomUUIDv7() })}\n`, + { mode: 0o600 } + ); + let ran = false; + + await withBunBuildAdmission(repositoryRoot, () => { + ran = true; + return Promise.resolve(); + }); + + expect(ran).toBeTrue(); + } + }); }); diff --git a/greenfield/scripts/delivery/buildBrowser.test.ts b/greenfield/scripts/delivery/buildBrowser.test.ts index 8242660a3..7e5ece169 100644 --- a/greenfield/scripts/delivery/buildBrowser.test.ts +++ b/greenfield/scripts/delivery/buildBrowser.test.ts @@ -2,6 +2,8 @@ import { afterEach, describe, expect, test } from "bun:test"; import { readFile, readdir, rm } from "node:fs/promises"; import path from "node:path"; +import { buildBrowserArtifact } from "./buildBrowser.ts"; + const repositoryRoot = path.resolve(import.meta.dir, "../.."); const scriptPath = path.join(import.meta.dir, "buildBrowser.ts"); const outputDirectories: string[] = []; @@ -110,10 +112,14 @@ describe("Dashboard browser artifact", () => { }, 60_000); test("rejects output outside the repository build boundary", async () => { - const execution = await runBuild(path.join(repositoryRoot, "dist")); + const outputDirectory = path.join(repositoryRoot, "dist"); + const execution = await runBuild(outputDirectory); expect(execution.exitCode).toBe(1); expect(execution.stdout).toBe(""); expect(execution.stderr).toBe("Browser build paths are invalid\n"); + expect(buildBrowserArtifact(repositoryRoot, outputDirectory)).rejects.toThrow( + "Browser build paths are invalid" + ); }); }); diff --git a/greenfield/scripts/delivery/buildRelease.test.ts b/greenfield/scripts/delivery/buildRelease.test.ts index dcba0d2e4..1436de4ac 100644 --- a/greenfield/scripts/delivery/buildRelease.test.ts +++ b/greenfield/scripts/delivery/buildRelease.test.ts @@ -68,13 +68,8 @@ async function repositoryFixture(): Promise { { recursive: true } ), cp( - path.join(sourceProjectRoot, "scripts/delivery/provisioning/host-operations"), - path.join(repositoryRoot, "scripts/delivery/provisioning/host-operations"), - { recursive: true } - ), - cp( - path.join(sourceProjectRoot, "scripts/delivery/provisioning/log-maintenance"), - path.join(repositoryRoot, "scripts/delivery/provisioning/log-maintenance"), + path.join(sourceProjectRoot, "scripts/delivery/provisioning"), + path.join(repositoryRoot, "scripts/delivery/provisioning"), { recursive: true } ), cp( @@ -127,7 +122,7 @@ describe("Dashboard release build", () => { const repositoryRoot = await repositoryFixture(); const commands: ReleaseBuildCommand[] = []; const result = await buildDashboardRelease(repositoryRoot, { - resolveSourceIdentity: () => cleanSource, + resolveSourceIdentity: () => Promise.resolve(cleanSource), runCommand: async (command, root) => { commands.push(command); await materializeCommandOutput(command, root); @@ -155,7 +150,8 @@ describe("Dashboard release build", () => { let dirtyCommandRan = false; const dirtyFailure = await rejectionError( buildDashboardRelease(dirtyRoot, { - resolveSourceIdentity: () => ({ commitSha, state: "dirty" }), + resolveSourceIdentity: () => + Promise.resolve({ commitSha, state: "dirty" }), runCommand: () => { dirtyCommandRan = true; return Promise.resolve(); @@ -172,9 +168,11 @@ describe("Dashboard release build", () => { buildDashboardRelease(changingRoot, { resolveSourceIdentity: () => { sourceReadCount += 1; - return sourceReadCount === 1 - ? cleanSource - : { commitSha, state: "dirty" }; + return Promise.resolve( + sourceReadCount === 1 + ? cleanSource + : { commitSha, state: "dirty" } + ); }, runCommand: materializeCommandOutput, runtimeIdentity, @@ -192,7 +190,7 @@ describe("Dashboard release build", () => { let existingCommandRan = false; const existingFailure = await rejectionError( buildDashboardRelease(existingRoot, { - resolveSourceIdentity: () => cleanSource, + resolveSourceIdentity: () => Promise.resolve(cleanSource), runCommand: () => { existingCommandRan = true; return Promise.resolve(); @@ -206,7 +204,7 @@ describe("Dashboard release build", () => { const failedRoot = await repositoryFixture(); const commandFailure = await rejectionError( buildDashboardRelease(failedRoot, { - resolveSourceIdentity: () => cleanSource, + resolveSourceIdentity: () => Promise.resolve(cleanSource), runCommand: async (command, root) => { if (command === "bun run docs:check") throw new Error("failed"); await materializeCommandOutput(command, root); diff --git a/greenfield/scripts/delivery/buildRelease.ts b/greenfield/scripts/delivery/buildRelease.ts index 327359ee9..1018554e7 100644 --- a/greenfield/scripts/delivery/buildRelease.ts +++ b/greenfield/scripts/delivery/buildRelease.ts @@ -30,7 +30,9 @@ export type ReleaseBuildCommand = (typeof releaseBuildCommands)[number]; /** Injectable command/source boundaries used by focused build orchestration tests. */ export interface DashboardReleaseBuildDependencies { - readonly resolveSourceIdentity?: (repositoryRoot: string) => BuildSourceIdentity; + readonly resolveSourceIdentity?: ( + repositoryRoot: string + ) => Promise; readonly runCommand?: ( command: ReleaseBuildCommand, repositoryRoot: string @@ -139,7 +141,7 @@ export async function buildDashboardRelease( let candidateRoot: string | undefined; try { await assertRepositoryRoot(repositoryRoot); - const source = sourceResolver(repositoryRoot); + const source = await sourceResolver(repositoryRoot); requireCleanSource(source); const paths = await createReleaseStagingPaths(repositoryRoot, source.commitSha); candidateRoot = paths.stagingRoot; @@ -147,7 +149,7 @@ export async function buildDashboardRelease( for (const command of releaseBuildCommands) { await commandRunner(command, repositoryRoot); } - requireSameCleanSource(source, sourceResolver(repositoryRoot)); + requireSameCleanSource(source, await sourceResolver(repositoryRoot)); await stageReleaseArtifacts({ browserRoot: path.join(repositoryRoot, "dist/browser"), @@ -155,7 +157,7 @@ export async function buildDashboardRelease( repositoryRoot, stagingRoot: paths.stagingRoot, }); - requireSameCleanSource(source, sourceResolver(repositoryRoot)); + requireSameCleanSource(source, await sourceResolver(repositoryRoot)); const manifest = await writeReleaseIdentity({ releaseRoot: paths.stagingRoot, repositoryRoot, @@ -163,7 +165,7 @@ export async function buildDashboardRelease( sourceIdentity: source, }); await verifyReleaseIdentity(paths.stagingRoot, manifest.runtime); - requireSameCleanSource(source, sourceResolver(repositoryRoot)); + requireSameCleanSource(source, await sourceResolver(repositoryRoot)); await makeReleaseTreeImmutable(repositoryRoot, paths.stagingRoot); await promoteStagedRelease(repositoryRoot, paths); diff --git a/greenfield/scripts/delivery/databaseObservabilityProvisioning.test.ts b/greenfield/scripts/delivery/databaseObservabilityProvisioning.test.ts new file mode 100644 index 000000000..b01dce52e --- /dev/null +++ b/greenfield/scripts/delivery/databaseObservabilityProvisioning.test.ts @@ -0,0 +1,1348 @@ +import { describe, expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { + chmod, + cp, + lstat, + mkdtemp, + readFile, + readdir, + rename, + rm, + symlink, + unlink, + writeFile, +} from "node:fs/promises"; +import path from "node:path"; + +import { + databaseObservabilityDatabaseMaximum, + databaseObservabilityObserverClientConnectionLimit, + databaseObservabilityObserverConnectionLimit, + databaseObservabilityObserverPoolSize, + databaseObservabilityObserverReservePoolSize, + databaseObservabilityObserverRole, + databaseObservabilityPgBouncerVirtualDatabase, + databaseObservabilityTorrentCountDatabases, + databaseObservabilityViewOwnerRole, +} from "../../src/shared/databaseObservabilityPolicy.ts"; +import { databaseObservabilityProvisioningReleaseArtifactPaths } from "./databaseObservabilityProvisioningPolicy.ts"; +import { + type DatabaseObservabilityProvisioningProcess, + type DatabaseObservabilityProvisioningProcessRequest, + provisioningControlDatabase, + provisioningDatabaseMaximum, + provisioningDockerContainerMaximum, + provisioningDockerInspectFormat, + provisioningProcessEnvironment, + provisioningSqlIncludeCountMaximum, + provisioningSqlIncludeDepthMaximum, + provisioningSqlInputMaximumBytes, + runDatabaseObservabilityProvisioning, +} from "./provisioning/database-observability/runProvisioning.ts"; + +const provisioningRoot = path.join( + import.meta.dir, + "provisioning/database-observability" +); + +interface ProvisioningManifest { + readonly activationOrder: readonly string[]; + readonly formatVersion: number; + readonly observerRole: string; + readonly pgBouncer: unknown; + readonly postgresql: { + readonly catalogDiscovery: unknown; + readonly controlPoint: unknown; + readonly observerConnectionLimit: number; + readonly privilegedCollectionLease: unknown; + readonly provisioningExecution: unknown; + readonly reviewedDatabases?: unknown; + readonly statementStatistics: unknown; + }; + readonly torrentViews: readonly { readonly database: string }[]; + readonly verifyOrder: readonly string[]; + readonly viewOwnerRole: string; +} + +async function readProvisioningFile(fileName: string): Promise { + return readFile(path.join(provisioningRoot, fileName), "utf8"); +} + +function catalogJson( + names: readonly string[], + options: { readonly templateNames?: ReadonlySet } = {} +): string { + return JSON.stringify( + names.map((name, index) => [ + String(index + 10), + name, + "1", + options.templateNames?.has(name) ?? false, + true, + ]) + ); +} + +function catalogDigest(catalog: string): string { + const parsed = JSON.parse(catalog) as readonly (readonly [ + string, + string, + string, + boolean, + boolean, + ])[]; + const normalized = parsed.map( + ([oid, name, ownerOid, isTemplate, allowsConnections]) => ({ + allowsConnections, + isTemplate, + name, + oid, + ownerOid, + }) + ); + return createHash("sha256") + .update("mira-dashboard-database-catalog-v1\0", "utf8") + .update(JSON.stringify(normalized), "utf8") + .digest("hex"); +} + +interface ProvisioningDockerFixtureRow { + readonly capability: string | null; + readonly configFiles: string | null; + readonly containerNumber: string | null; + readonly dependsOn: string | null; + readonly health: string | null; + readonly id: string; + readonly project: string | null; + readonly service: string | null; + readonly state: string; + readonly oneOff: string | null; + readonly workingDirectory: string | null; +} + +function provisioningContainerId(index: number): string { + return index.toString(16).padStart(64, "0"); +} + +function provisioningDockerRow( + index: number, + overrides: Partial = {} +): ProvisioningDockerFixtureRow { + return Object.freeze({ + capability: null, + configFiles: "/opt/docker/compose.yaml", + containerNumber: "1", + dependsOn: "", + health: "healthy", + id: provisioningContainerId(index), + oneOff: "False", + project: "docker", + service: `unrelated-${String(index)}`, + state: "running", + workingDirectory: "/opt/docker", + ...overrides, + }); +} + +function validProvisioningDockerRows(): readonly ProvisioningDockerFixtureRow[] { + return Object.freeze([ + provisioningDockerRow(1, { + capability: "pgbouncer-v1", + dependsOn: "postgres:service_healthy:false", + service: "pgbouncer", + }), + provisioningDockerRow(2, { service: "postgres" }), + provisioningDockerRow(3), + ]); +} + +function projectedProvisioningInspectLine(row: ProvisioningDockerFixtureRow): string { + return [ + row.id, + row.capability, + row.project, + row.service, + row.dependsOn, + row.workingDirectory, + row.configFiles, + row.containerNumber, + row.oneOff, + row.state, + row.health, + ] + .map((value) => JSON.stringify(value)) + .join("\t"); +} + +function successfulProvisioningProcessResult(stdout = "") { + return Promise.resolve({ exitCode: 0, stderr: "", stdout }); +} + +const noop = () => {}; + +async function rejectionText(operation: Promise): Promise { + try { + await operation; + return ""; + } catch (error) { + return String(error); + } +} + +function provisioningProcessFixture(options: { + readonly administrativeDatabase?: string; + readonly catalogs: readonly string[]; + readonly databaseUser?: string; + readonly dockerSnapshots?: readonly (readonly ProvisioningDockerFixtureRow[])[]; + readonly roleOids?: readonly string[]; + readonly psqlServices?: ReadonlySet; + readonly systemIdentifiers?: readonly string[]; +}): DatabaseObservabilityProvisioningProcess & { + readonly requests: DatabaseObservabilityProvisioningProcessRequest[]; +} { + const requests: DatabaseObservabilityProvisioningProcessRequest[] = []; + const snapshots = options.dockerSnapshots ?? [validProvisioningDockerRows()]; + let snapshotIndex = 0; + let catalogIndex = 0; + let identityIndex = 0; + const run = ((request: DatabaseObservabilityProvisioningProcessRequest) => { + requests.push(request); + if (request.executable === "/usr/bin/docker") { + const snapshot = snapshots[Math.min(snapshotIndex, snapshots.length - 1)]; + if (snapshot === undefined) throw new Error("Missing Docker snapshot"); + expect(request.argv.slice(0, 2)).toEqual([ + "--host", + "unix:///var/run/docker.sock", + ]); + if (request.argv[2] === "ps") { + return successfulProvisioningProcessResult( + snapshot.map(({ id }) => JSON.stringify(id)).join("\n") + ); + } + if (request.argv[2] !== "inspect") { + throw new Error("Unexpected Docker request"); + } + snapshotIndex += 1; + return successfulProvisioningProcessResult( + snapshot.map((row) => projectedProvisioningInspectLine(row)).join("\n") + ); + } + if (request.stdin?.includes("server_version_num") === true) { + const shellIndex = request.argv.indexOf("/bin/sh"); + const service = request.argv[shellIndex - 1]; + if (!(options.psqlServices ?? new Set(["postgres"])).has(service ?? "")) { + return Promise.resolve({ exitCode: 127, stderr: "", stdout: "" }); + } + const selectedIdentityIndex = identityIndex++; + const sequenceIndex = (values: readonly string[] | undefined) => + Math.min(selectedIdentityIndex, (values?.length ?? 1) - 1); + return successfulProvisioningProcessResult( + `${JSON.stringify([ + options.databaseUser ?? "postgres", + options.databaseUser ?? "postgres", + true, + options.roleOids?.[sequenceIndex(options.roleOids)] ?? "10", + options.administrativeDatabase ?? "template1", + options.systemIdentifiers?.[ + sequenceIndex(options.systemIdentifiers) + ] ?? "7600974291849326629", + "180004", + ])}\n` + ); + } + if (request.stdin?.includes("pg_catalog.json_agg(discovered.entry") === true) { + const catalog = options.catalogs[catalogIndex++]; + if (catalog === undefined) throw new Error("Missing catalog output"); + return successfulProvisioningProcessResult(`${catalog}\n`); + } + return successfulProvisioningProcessResult(); + }) as DatabaseObservabilityProvisioningProcess & { + readonly requests: typeof requests; + }; + Object.defineProperty(run, "requests", { value: requests }); + return run; +} + +describe("database observability provisioning", () => { + test("inventories one deterministic approval-gated artifact set", async () => { + const sourceEntries = await readdir(provisioningRoot); + const entries = sourceEntries.toSorted(); + expect(databaseObservabilityProvisioningReleaseArtifactPaths).toEqual( + entries.map( + (fileName) => + `scripts/delivery/provisioning/database-observability/${fileName}` + ) + ); + for (const entry of entries) { + const status = await lstat(path.join(provisioningRoot, entry), { + bigint: true, + }); + expect(status.isFile()).toBe(true); + expect(status.isSymbolicLink()).toBe(false); + expect(status.nlink).toBe(1n); + expect(status.mode & 0o111n).toBe(0n); + expect(status.size).toBeGreaterThan(0n); + expect(status.size).toBeLessThanOrEqual(64n * 1024n); + } + }); + + test("declares bounded catalog discovery and topology-independent ceilings", async () => { + const manifest = JSON.parse( + await readProvisioningFile("manifest.json") + ) as ProvisioningManifest; + expect(provisioningDatabaseMaximum).toBe(databaseObservabilityDatabaseMaximum); + expect(manifest.formatVersion).toBe(8); + expect(manifest.observerRole).toBe(databaseObservabilityObserverRole); + expect(manifest.viewOwnerRole).toBe(databaseObservabilityViewOwnerRole); + expect(manifest.postgresql.catalogDiscovery).toEqual({ + maximumDatabases: databaseObservabilityDatabaseMaximum, + order: "name-ascending", + predicate: "datistemplate=false and datallowconn=true", + }); + expect(manifest.postgresql.reviewedDatabases).toBeUndefined(); + expect(manifest.postgresql.controlPoint).toEqual({ + pgBouncerAlias: "mira_dashboard_observability", + physicalDatabase: "mira_dashboard_observability", + routing: "pgbouncer-wildcard-same-name", + source: "code-owned-single-endpoint-capability", + }); + expect(manifest.postgresql.privilegedCollectionLease).toEqual({ + actionKey: "cache.refresh.database-observability", + scheduleId: "cache.database-observability", + scheduleIntervalSeconds: 3600, + providerGate: "configured-only", + port: "separate worker-only privileged collection-lease port", + commands: { + open: "runProvisioning.ts open-approved-collection --approved", + enable: "runProvisioning.ts enable-approved-collection --approved --collection-lease-token --catalog-digest ", + close: "runProvisioning.ts close-approved-collection --approved", + }, + runner: "exact immutable release Bun runtime", + processSupervision: + "Linux parent-death signal plus isolated process-group TERM/KILL reap before settlement", + approval: { + artifact: "mira_dashboard_observability_control.reconciliation_approval", + createdOrUpdatedBy: "explicit activate-current-catalog only", + binding: [ + "pg_control_system.system_identifier", + "exact current immutable-release policy digest", + "exact previous immutable-release policy digest", + ], + policyVersion: + "sanitized-capabilities-v1 is descriptive and never sufficient authorization", + leaseMutation: "forbidden", + }, + closedStateBetweenRuns: { + login: false, + validUntil: "expired", + postgresqlSessions: 0, + collectionLeaseComment: null, + }, + phases: [ + "privileged close leftovers: set NOLOGIN, expire VALID UNTIL, terminate observer sessions, and recheck closed state", + "open-approved-collection: verify exact approval and identity, perform the full bounded idempotent ACL-and-capability reconcile, keep NOLOGIN, and prepare one use collection token bound to the exact catalog digest", + "enable-approved-collection: recheck approval, policy, PostgreSQL/Docker identity, and exact catalog digest, then atomically consume the token and set LOGIN with a short VALID UNTIL", + "collect once through the least-privilege observer", + "shielded mandatory close: atomically invalidate every prepared token, set NOLOGIN, expire VALID UNTIL, terminate observer sessions, and recheck closed state", + "return the fresh payload to the generic cache executor only after exact close proof", + ], + reconciliation: { + frequency: "every approved prepared open before one-use enable", + operations: [ + "reconcile exact database ACLs through the pinned administrative boundary", + "strictly apply and verify cluster and control capabilities; isolate application-database apply or policy drift for collector-level unavailable details", + "reject catalog, approval, policy-digest, PostgreSQL identity, or Docker identity races", + ], + persistedFingerprintOrAgeState: false, + reducedPath: false, + }, + failureSemantics: + "cluster, control, approval, identity, catalog-race, enable, collection, or close failure prevents a fresh payload and cache commit; isolated application-database drift remains visible with unavailable details while other databases collect", + pgBouncerWaitingClientLimitation: + "closed PostgreSQL role and zero-session proof cannot prove that PgBouncer has no already-authenticated waiting client; no exclusive admission is added, and interference fails the attempt while NOLOGIN and expired VALID UNTIL prevent a new backend", + additionalRuntimeSurfaces: { + jobAction: false, + schedule: false, + systemdUnit: false, + postgresqlLogin: false, + exclusiveAdmission: false, + }, + }); + expect(manifest.postgresql.provisioningExecution).toEqual({ + composeFile: "/opt/docker/compose.yaml", + composeProjectDirectory: "/opt/docker", + containerPsql: "/usr/local/bin/psql over /var/run/postgresql", + dockerHost: "unix:///var/run/docker.sock", + identityGuard: + "superuser-role-oid, postgresql-system-identifier, activation approval, and exact current/previous policy digests", + target: "single healthy PostgreSQL service_healthy dependency of the opted-in PgBouncer capability", + }); + expect(manifest.postgresql.observerConnectionLimit).toBe( + databaseObservabilityObserverConnectionLimit + ); + expect(manifest.postgresql.statementStatistics).toEqual({ + database: "mira_dashboard_observability", + extension: "pg_stat_statements", + source: "catalog-resolved pg_stat_statements(false) behind a NOLOGIN capability owner", + target: "mira_dashboard_observability_capabilities.statement_metrics()", + }); + expect(manifest.pgBouncer).toEqual({ + adminUserForbidden: databaseObservabilityObserverRole, + observerUserPolicy: { + max_user_client_connections: + databaseObservabilityObserverClientConnectionLimit, + max_user_connections: databaseObservabilityObserverConnectionLimit, + pool_size: databaseObservabilityObserverPoolSize, + reserve_pool_size: databaseObservabilityObserverReservePoolSize, + }, + statsUserRequired: databaseObservabilityObserverRole, + virtualDatabase: databaseObservabilityPgBouncerVirtualDatabase, + }); + expect(manifest.torrentViews.map(({ database }) => database)).toEqual( + databaseObservabilityTorrentCountDatabases + ); + expect(JSON.stringify(manifest)).not.toMatch( + /aiomanager|aiometadata|aiostreams|authelia|crowdsec|metabase|speedtest_tracker/u + ); + expect(manifest.verifyOrder).toContain( + "runProvisioning.ts verify-current-catalog --approved after initial activation" + ); + expect(manifest.activationOrder[0]).toBe( + "runProvisioning.ts activate-current-catalog --approved" + ); + expect(JSON.stringify(manifest)).not.toContain( + "mira_dashboard_database_access_reconciler" + ); + expect(JSON.stringify(manifest)).not.toMatch(/15-second|reconciliation-loop/u); + }); + + test("keeps names dynamic across all provisioning artifacts except torrent views", async () => { + const entries = await readdir(provisioningRoot); + const fileContents = await Promise.all( + entries.map((entry) => readProvisioningFile(entry)) + ); + const contents = fileContents.join("\n"); + expect(contents).not.toMatch( + /aiomanager|aiometadata|aiostreams|authelia|crowdsec|metabase|speedtest_tracker/u + ); + expect(contents).not.toContain(String.raw`\connect`); + expect(contents).not.toContain("127.0.0.1"); + expect(contents).not.toContain(":6432"); + for (const name of databaseObservabilityTorrentCountDatabases) { + expect(contents).toContain(name); + } + }); + + test("quarantines exact roles and refuses role-GUC, membership, and default-ACL drift", async () => { + const apply = await readProvisioningFile("apply-cluster.sql"); + const accessApply = await readProvisioningFile( + "apply-database-access-reconciler.sql" + ); + const accessVerify = await readProvisioningFile( + "verify-database-access-reconciler.sql" + ); + const cluster = await readProvisioningFile("verify-cluster.sql"); + const database = await readProvisioningFile("verify-database.sql"); + expect(apply).toContain( + `NOREPLICATION NOBYPASSRLS CONNECTION LIMIT ${String(databaseObservabilityObserverConnectionLimit)}` + ); + expect(apply).toContain("ALTER ROLE mira_dashboard_observer RESET ALL;"); + expect(apply).toContain("SET default_transaction_read_only = on;"); + expect(apply).toContain("SET statement_timeout = '5s';"); + expect(apply).toContain("pg_terminate_backend(reserved_session.pid, 5000)"); + expect(apply).toContain("observer_inbound_membership_count <> 0"); + expect(apply).toContain("admin_option OR NOT inherit_option OR NOT set_option"); + expect(cluster).toContain("settings.setdatabase <> 0"); + expect(database).toContain("pg_catalog.pg_default_acl"); + expect(database).toContain("defaults.defaclrole = observer_oid"); + expect(database).toContain("grants.grantee <> 0"); + expect(database).toContain( + "pg_catalog.pg_has_role(observer_oid, grants.grantee, 'USAGE')" + ); + expect(database).toContain("routines.prosecdef"); + expect(database).toContain("pg_catalog.has_function_privilege("); + expect(database).toContain("Database observability routine grants are invalid"); + expect(accessApply).toContain("REVOKE ALL PRIVILEGES ON DATABASE %I FROM PUBLIC"); + expect(accessApply).toContain( + "GRANT CONNECT ON DATABASE %I TO mira_dashboard_observer" + ); + expect(accessVerify).toContain("grants.grantee = 0"); + expect(accessVerify).toContain("databases.datistemplate"); + expect(apply).not.toContain("GRANT CONNECT ON DATABASE"); + expect(apply).not.toContain("REVOKE ALL PRIVILEGES ON DATABASE"); + }); + + test("installs one exact bounded database-owned access reconciler", async () => { + const apply = await readProvisioningFile("apply-database-access-reconciler.sql"); + const verify = await readProvisioningFile( + "verify-database-access-reconciler.sql" + ); + const activation = await readProvisioningFile("activate-observer.sql"); + const body = apply.match(/AS \$reconcile\$(?[\s\S]*?)\$reconcile\$/u) + ?.groups?.body; + expect(body).toBeDefined(); + if (body === undefined) throw new Error("Reconciler body is absent"); + const digest = createHash("sha256").update(body).digest("hex"); + + expect(verify).toContain(digest); + expect(apply.indexOf("$administrator_boundary$")).toBeLessThan( + apply.indexOf("CREATE SCHEMA IF NOT EXISTS") + ); + expect(apply).not.toContain("mira_dashboard_database_access_reconciler LOGIN"); + expect( + body.indexOf("observed_database_count > maximum_observed_databases") + ).toBeLessThan(body.indexOf("FOR database_record IN")); + expect(body).toContain( + "pg_catalog.pg_advisory_xact_lock(1296646465, 1128351300)" + ); + expect(body).toContain("pg_catalog.format("); + expect(body).toContain("DATABASE %I"); + expect(body).toContain("final_owner_oids IS DISTINCT FROM initial_owner_oids"); + expect(apply).toContain("SECURITY DEFINER"); + expect(apply).toContain("SET search_path TO pg_catalog"); + expect(verify).toContain("routine.proconfig IS DISTINCT FROM"); + expect(verify).toContain( + "pg_catalog.count(*) FROM pg_catalog.pg_proc AS routines" + ); + expect(activation).not.toContain( + "ALTER ROLE mira_dashboard_database_access_reconciler LOGIN;" + ); + expect( + activation.match(/\\ir verify-control-database-capability\.sql/gu) + ).toHaveLength(1); + expect(activation.match(/\\ir verify-control-database\.sql/gu)).toHaveLength(1); + expect(activation).toContain( + "ALTER ROLE mira_dashboard_observer NOLOGIN\n VALID UNTIL '1970-01-01 00:00:00+00';" + ); + expect(activation).toContain("observer.rolpassword NOT LIKE 'SCRAM-SHA-256$%'"); + }); + + test("projects one stable capability from the catalog-resolved extension relation", async () => { + const apply = await readProvisioningFile("apply-control-database.sql"); + const verify = await readProvisioningFile("verify-control-database.sql"); + expect(apply).toContain("extensions.extname = 'pg_stat_statements'"); + expect(apply).toContain("classes.relnamespace = extensions.extnamespace"); + expect(apply).toContain("pg_catalog.format("); + expect(apply).toContain( + "CREATE FUNCTION mira_dashboard_observability_capabilities.statement_metrics()" + ); + expect(apply).toContain("SECURITY DEFINER"); + expect(apply).toContain("public.pg_stat_statements(false)"); + expect(apply).toContain("total_exec_time::double precision"); + expect(apply).toContain("FROM PUBLIC"); + expect(apply).toContain("FROM mira_dashboard_observer"); + expect(apply).toContain("member_count NOT BETWEEN 1 AND 64"); + expect(apply).toContain("'$libdir/pg_stat_statements'"); + expect(apply).toContain("source_routine.proallargtypes"); + expect(verify).toContain("member_count NOT BETWEEN 1 AND 64"); + expect(verify).toContain("WHERE grants.grantee = 0"); + expect(verify).toContain("source_routine.proallargtypes"); + expect(verify).toContain("dependencies.refobjid = source_routine.oid"); + expect(verify).not.toMatch(/extversion|1\.12/u); + }); + + test("creates and safely retains one exact physical control database capability", async () => { + const applyCapability = await readProvisioningFile( + "apply-control-database-capability.sql" + ); + const applyControl = await readProvisioningFile("apply-control-database.sql"); + const verifyCapability = await readProvisioningFile( + "verify-control-database-capability.sql" + ); + const rollbackCapability = await readProvisioningFile( + "rollback-control-database-capability.sql" + ); + + expect(provisioningControlDatabase).toBe("mira_dashboard_observability"); + expect(applyCapability).toContain( + "CREATE DATABASE mira_dashboard_observability OWNER %I TEMPLATE template0 CONNECTION LIMIT 4 STRATEGY WAL_LOG" + ); + expect(applyCapability).toContain(String.raw`\gexec`); + expect(applyCapability).toContain("TO :'apply_control_database_capability';"); + expect(applyCapability).toContain( + "pg_catalog.current_setting(\n 'mira_dashboard.apply_control_database_capability'" + ); + expect(applyControl).toContain("TO :'apply_statement_capability';"); + expect( + applyCapability.slice(applyCapability.indexOf("DO $approval_and_preflight$")) + ).not.toContain(":'apply_control_database_capability'"); + expect( + applyControl.slice(applyControl.indexOf("DO $approval_guard$")) + ).not.toContain(":'apply_statement_capability'"); + expect(applyCapability.indexOf("$approval_and_preflight$")).toBeLessThan( + applyCapability.indexOf("CREATE DATABASE mira_dashboard_observability") + ); + expect(applyCapability).toContain("catalog_database_count > 80"); + expect(applyCapability).toContain("observed_database_count > 64"); + expect(applyControl.match(/CREATE EXTENSION/gu)).toHaveLength(1); + expect(applyControl).toContain( + "CREATE EXTENSION IF NOT EXISTS pg_stat_statements WITH SCHEMA public;" + ); + expect(verifyCapability).toContain("'pg_stat_statements:public'"); + expect(verifyCapability).toContain("'plpgsql:pg_catalog'"); + expect(verifyCapability).toContain( + "extensions.extowner IS DISTINCT FROM database_owner_oid" + ); + expect(rollbackCapability).toContain( + "physical capability database is deliberately retained" + ); + expect(rollbackCapability).not.toMatch(/^\s*DROP\s+DATABASE/imu); + }); + + test("runner discovers one PostgreSQL dependency, executes fixed psql, and rechecks activation races", async () => { + const catalog = catalogJson( + ["template1", "app-b", provisioningControlDatabase, "app-a"], + { + templateNames: new Set(["template1"]), + } + ); + const run = provisioningProcessFixture({ catalogs: [catalog, catalog] }); + const result = await runDatabaseObservabilityProvisioning( + "activate-current-catalog", + { run } + ); + expect(result).toEqual({ + databaseCount: 3, + mode: "activate-current-catalog", + status: "ACTIVATED", + }); + const composeRequests = run.requests.filter( + ({ executable, stdin }) => + executable === "/opt/docker/bin/docker-compose-doppler" && stdin !== null + ); + const psqlRequests = composeRequests.filter( + ({ stdin }) => !stdin?.includes("server_version_num") + ); + const requestSql = psqlRequests.map(({ stdin }) => stdin ?? ""); + const acceptedLaunchers = new Set([ + ': "${POSTGRES_USER:?}"; exec /usr/bin/env -i HOME=/var/lib/postgresql LANG=C LC_ALL=C PATH=/usr/local/bin:/usr/bin:/bin PGUSER="$POSTGRES_USER" /usr/bin/timeout -s TERM -k 2 45 /usr/local/bin/psql --host=/var/run/postgresql --username="$POSTGRES_USER" --no-psqlrc --set=ON_ERROR_STOP=1 "$@"', + ': "${POSTGRES_USER:?}"; exec /usr/bin/env -i HOME=/var/lib/postgresql LANG=C LC_ALL=C PATH=/usr/local/bin:/usr/bin:/bin PGUSER="$POSTGRES_USER" /usr/bin/timeout -s TERM -k 1 3 /usr/local/bin/psql --host=/var/run/postgresql --username="$POSTGRES_USER" --no-psqlrc --set=ON_ERROR_STOP=1 "$@"', + ]); + expect( + composeRequests.every( + ({ argv, cwd, environment, executable }) => + executable === "/opt/docker/bin/docker-compose-doppler" && + cwd === "/opt/docker" && + environment === provisioningProcessEnvironment && + JSON.stringify(argv.slice(0, 15)) === + JSON.stringify([ + "--file", + "/opt/docker/compose.yaml", + "--project-directory", + "/opt/docker", + "--project-name", + "docker", + "exec", + "-T", + "--index", + "1", + "--user", + "postgres", + "postgres", + "/bin/sh", + "-ceu", + ]) && + acceptedLaunchers.has(argv[15] ?? "") && + argv[16] === "mira-dashboard-psql" + ) + ).toBe(true); + expect( + composeRequests.every( + ({ argv }) => + argv[0] === "--file" && + argv[1] === "/opt/docker/compose.yaml" && + argv[2] === "--project-directory" && + argv[3] === "/opt/docker" + ) + ).toBe(true); + expect(psqlRequests.every(({ argv }) => !argv.includes("--command"))).toBe(true); + expect( + psqlRequests.every( + ({ stdin }) => + stdin?.includes("controls.system_identifier::text") && + stdin.includes("roles.oid = '10'::pg_catalog.oid") && + stdin.includes("CURRENT_USER IS DISTINCT FROM SESSION_USER") + ) + ).toBe(true); + expect(requestSql.some((sql) => sql.includes("$approval_and_preflight$"))).toBe( + true + ); + expect(requestSql.some((sql) => sql.includes("CREATE EXTENSION"))).toBe(true); + expect(requestSql.every((sql) => !/^[ \t]*\\ir[ \t]/mu.test(sql))).toBe(true); + const reconcileIndex = requestSql.findIndex((sql) => + sql.includes("mira_dashboard_database_access.reconcile()") + ); + const clusterVerifyIndex = requestSql.findIndex((sql) => + sql.includes("Database observability view owner is invalid") + ); + expect(reconcileIndex).toBeGreaterThan(-1); + expect(reconcileIndex).toBeLessThan(clusterVerifyIndex); + const capabilityApplyIndex = requestSql.findIndex((sql) => + sql.includes("$approval_and_preflight$") + ); + const initialCatalogIndex = requestSql.findIndex((sql) => + sql.includes("pg_catalog.json_agg(discovered.entry") + ); + expect(capabilityApplyIndex).toBeGreaterThan(-1); + expect(capabilityApplyIndex).toBeLessThan(initialCatalogIndex); + expect( + requestSql.some((sql) => sql.includes("$verify_database_access_reconciler$")) + ).toBe(true); + expect( + requestSql.some( + (sql) => + sql.includes("ALTER ROLE mira_dashboard_observer LOGIN") && + sql.includes("ALTER ROLE mira_dashboard_observer NOLOGIN") && + sql.includes("VALID UNTIL '1970-01-01 00:00:00+00'") && + sql.includes("$verify_reconciliation_approval$") + ) + ).toBe(true); + expect( + run.requests.filter(({ executable }) => executable === "/usr/bin/docker") + ).toHaveLength(6); + }); + + test("opens only after approved reconciliation and closes independently", async () => { + const catalog = catalogJson([provisioningControlDatabase, "app"]); + const collectionLeaseToken = "12345678-1234-4123-8123-123456789abc"; + const openRun = provisioningProcessFixture({ catalogs: [catalog, catalog] }); + expect( + await runDatabaseObservabilityProvisioning("open-approved-collection", { + collectionLeaseTokenFactory: () => collectionLeaseToken, + run: openRun, + }) + ).toEqual({ + catalogDigest: catalogDigest(catalog), + collectionLeaseToken, + databaseCount: 2, + mode: "open-approved-collection", + status: "RECONCILED", + }); + const openSql = openRun.requests + .filter(({ stdin }) => stdin !== null) + .map(({ stdin }) => stdin ?? ""); + const firstMutationIndex = openSql.findIndex((sql) => + sql.includes("$administrator_boundary$") + ); + const firstApprovalIndex = openSql.findIndex((sql) => + sql.includes("$verify_reconciliation_approval$") + ); + const finalPrepareIndex = openSql.findIndex((sql) => + sql.includes("$prepare_approved_collection$") + ); + expect(firstApprovalIndex).toBeGreaterThan(-1); + expect(firstApprovalIndex).toBeLessThan(firstMutationIndex); + expect(finalPrepareIndex).toBeGreaterThan(firstMutationIndex); + expect(finalPrepareIndex).toBe(openSql.length - 1); + expect(openSql[finalPrepareIndex]).toContain( + "LOCK TABLE mira_dashboard_observability_control.reconciliation_approval\n IN SHARE MODE" + ); + expect(openSql[finalPrepareIndex]).toContain("$verify_reconciliation_approval$"); + expect(openSql[finalPrepareIndex]).toContain( + "'mira-dashboard-collection-lease:' ||" + ); + expect(openRun.requests.at(-1)?.argv).toContain( + `--set=collection_lease_token=${collectionLeaseToken}` + ); + expect(openSql[finalPrepareIndex]).not.toContain( + "ALTER ROLE mira_dashboard_observer LOGIN" + ); + + const enableRun = provisioningProcessFixture({ catalogs: [catalog] }); + expect( + await runDatabaseObservabilityProvisioning("enable-approved-collection", { + catalogDigest: catalogDigest(catalog), + collectionLeaseToken, + run: enableRun, + }) + ).toEqual({ + databaseCount: 2, + mode: "enable-approved-collection", + status: "OPENED", + }); + const enableSql = enableRun.requests + .filter(({ stdin }) => stdin !== null) + .map(({ stdin }) => stdin ?? ""); + const finalOpenIndex = enableSql.findIndex((sql) => + sql.includes("$open_approved_collection$") + ); + expect(finalOpenIndex).toBe(enableSql.length - 1); + expect(enableSql[finalOpenIndex]).toContain("$verify_reconciliation_approval$"); + expect(enableSql[finalOpenIndex]).toContain( + "SELECT pg_catalog.pg_advisory_xact_lock(1835623521, 1668048243)" + ); + expect(enableSql[finalOpenIndex]).toContain( + "ALTER ROLE mira_dashboard_observer LOGIN VALID UNTIL %L" + ); + expect(enableSql[finalOpenIndex]).toContain("IS DISTINCT FROM expected_comment"); + expect(enableRun.requests.at(-1)?.argv).toContain( + `--set=collection_lease_token=${collectionLeaseToken}` + ); + expect( + enableSql[finalOpenIndex]!.indexOf("$verify_reconciliation_approval$") + ).toBeLessThan(enableSql[finalOpenIndex]!.indexOf("$open_approved_collection$")); + expect( + enableSql + .slice(finalOpenIndex + 1) + .some((sql) => sql.includes("$administrator_boundary$")) + ).toBeFalse(); + + const closeRun = provisioningProcessFixture({ catalogs: [] }); + expect( + await runDatabaseObservabilityProvisioning("close-approved-collection", { + run: closeRun, + }) + ).toEqual({ + databaseCount: 0, + mode: "close-approved-collection", + status: "CLOSED", + }); + const closeSql = closeRun.requests + .filter(({ stdin }) => stdin !== null) + .map(({ stdin }) => stdin ?? ""); + expect(closeSql.some((sql) => sql.includes("$close_approved_collection$"))).toBe( + true + ); + expect( + closeSql.some((sql) => + sql.includes("COMMENT ON ROLE mira_dashboard_observer IS NULL") + ) + ).toBe(true); + expect(closeSql.some((sql) => sql.includes("pg_advisory_xact_lock"))).toBe(true); + expect(closeSql.every((sql) => !sql.includes("reconciliation_approval"))).toBe( + true + ); + expect(closeSql.every((sql) => !sql.includes("PASSWORD"))).toBe(true); + }); + + test("isolates application-database drift during prepared open but fails control drift", async () => { + const catalog = catalogJson([provisioningControlDatabase, "app", "healthy"]); + const applicationBase = provisioningProcessFixture({ + catalogs: [catalog, catalog], + }); + const applicationDrift: DatabaseObservabilityProvisioningProcess = (request) => { + if ( + request.argv.includes("postgresql:///%61%70%70") && + request.stdin?.includes( + "Database observability routine grants are invalid" + ) === true + ) { + return Promise.resolve({ exitCode: 1, stderr: "", stdout: "" }); + } + return applicationBase(request); + }; + const prepared = await runDatabaseObservabilityProvisioning( + "open-approved-collection", + { + collectionLeaseTokenFactory: () => "12345678-1234-4123-8123-123456789abc", + run: applicationDrift, + } + ); + expect(prepared.status).toBe("RECONCILED"); + const quarantineSql = applicationBase.requests + .map(({ stdin }) => stdin ?? "") + .filter((sql) => sql.includes("$quarantine_drifted_database$")); + expect(quarantineSql.length).toBeGreaterThanOrEqual(1); + expect( + quarantineSql.every( + (sql) => + sql.includes("databases.oid = '11'::pg_catalog.oid") && + sql.includes( + "REVOKE ALL PRIVILEGES ON DATABASE %I FROM mira_dashboard_observer" + ) && + !sql.includes("databases.oid = '12'::pg_catalog.oid") + ) + ).toBe(true); + expect( + applicationBase.requests.some( + ({ argv, stdin }) => + argv.includes("postgresql:///%68%65%61%6C%74%68%79") && + stdin?.includes( + "Database observability routine grants are invalid" + ) === true + ) + ).toBe(true); + expect( + applicationBase.requests.some(({ stdin }) => + stdin?.includes("$prepare_approved_collection$") + ) + ).toBe(true); + + const controlBase = provisioningProcessFixture({ catalogs: [catalog] }); + const controlDrift: DatabaseObservabilityProvisioningProcess = (request) => { + if ( + request.stdin?.includes( + "Database observability control capability is invalid" + ) === true + ) { + return Promise.resolve({ exitCode: 1, stderr: "", stdout: "" }); + } + return controlBase(request); + }; + expect( + runDatabaseObservabilityProvisioning("open-approved-collection", { + collectionLeaseTokenFactory: () => "12345678-1234-4123-8123-123456789abc", + run: controlDrift, + }) + ).rejects.toThrow("Database observability provisioning failed"); + expect( + controlBase.requests.some(({ stdin }) => + stdin?.includes("$prepare_approved_collection$") + ) + ).toBe(false); + }); + + test("a delayed enable cannot reopen after an independently completed close", async () => { + const catalog = catalogJson([provisioningControlDatabase]); + const collectionLeaseToken = "12345678-1234-4123-8123-123456789abc"; + const base = provisioningProcessFixture({ + catalogs: [catalog, catalog, catalog], + }); + let storedLeaseComment: string | null = null; + let observerCanLogin = false; + let releaseEnable = noop; + const enableRelease = new Promise((resolve) => { + releaseEnable = resolve; + }); + let announceEnable = noop; + const enableReached = new Promise((resolve) => { + announceEnable = resolve; + }); + const statefulProcess: DatabaseObservabilityProvisioningProcess = async ( + request + ) => { + if (request.stdin?.includes("$open_approved_collection$") === true) { + announceEnable(); + await enableRelease; + if ( + storedLeaseComment !== + `mira-dashboard-collection-lease:${collectionLeaseToken}` + ) { + return { exitCode: 1, stderr: "", stdout: "" }; + } + storedLeaseComment = null; + observerCanLogin = true; + return successfulProvisioningProcessResult(); + } + const result = await base(request); + if (request.stdin?.includes("$prepare_approved_collection$") === true) { + storedLeaseComment = `mira-dashboard-collection-lease:${collectionLeaseToken}`; + } + if (request.stdin?.includes("$close_approved_collection$") === true) { + storedLeaseComment = null; + observerCanLogin = false; + } + return result; + }; + + const prepared = await runDatabaseObservabilityProvisioning( + "open-approved-collection", + { + collectionLeaseTokenFactory: () => collectionLeaseToken, + run: statefulProcess, + } + ); + const delayedEnable = runDatabaseObservabilityProvisioning( + "enable-approved-collection", + { + catalogDigest: prepared.catalogDigest, + collectionLeaseToken, + run: statefulProcess, + } + ); + await enableReached; + await runDatabaseObservabilityProvisioning("close-approved-collection", { + run: statefulProcess, + }); + expect(storedLeaseComment).toBeNull(); + expect(observerCanLogin).toBe(false); + releaseEnable(); + expect(delayedEnable).rejects.toThrow( + "Database observability provisioning failed" + ); + expect(observerCanLogin).toBe(false); + }); + + test("runner rejects over-limit, duplicate, malformed, and changed inventories", () => { + const maximumNames = Array.from( + { length: provisioningDatabaseMaximum + 1 }, + (_, index) => `db-${String(index).padStart(2, "0")}` + ); + for (const catalog of [ + catalogJson(maximumNames), + JSON.stringify([ + ["10", "duplicate", "1", false, true], + ["11", "duplicate", "1", false, true], + ]), + JSON.stringify([["10", "bad\0name", "1", false, true]]), + JSON.stringify([["10", "bad\nname", "1", false, true]]), + JSON.stringify([["10", "bad\u200Bname", "1", false, true]]), + JSON.stringify([["10", "bad\u2028name", "1", false, true]]), + JSON.stringify([["10", "bad\u2029name", "1", false, true]]), + JSON.stringify([["10", " ", "1", false, true]]), + JSON.stringify([["0", provisioningControlDatabase, "1", false, true]]), + JSON.stringify([ + ["11", provisioningControlDatabase, "1", false, true], + ["10", "other", "1", false, true], + ]), + ]) { + const run = provisioningProcessFixture({ catalogs: [catalog] }); + expect( + runDatabaseObservabilityProvisioning("verify-current-catalog", { + run, + }) + ).rejects.toThrow("Database observability provisioning failed"); + } + + const firstCatalog = catalogJson([provisioningControlDatabase]); + const secondCatalog = catalogJson([provisioningControlDatabase, "new"]); + const run = provisioningProcessFixture({ + catalogs: [firstCatalog, secondCatalog], + }); + expect( + runDatabaseObservabilityProvisioning("activate-current-catalog", { + run, + }) + ).rejects.toThrow("Database observability provisioning failed"); + }); + + test("runner percent-encodes every database-name byte before fixed psql argv", async () => { + const rawNames = [ + "..", + "quote/slash", + "host=attacker dbname=x", + "postgresql://evil/x", + ]; + const catalog = catalogJson([provisioningControlDatabase, ...rawNames]); + const run = provisioningProcessFixture({ catalogs: [catalog, catalog] }); + await runDatabaseObservabilityProvisioning("verify-current-catalog", { + run, + }); + + const databaseArguments = run.requests.flatMap(({ argv }) => + argv.flatMap((argument, index) => + argument === "--dbname" ? [argv[index + 1] ?? ""] : [] + ) + ); + expect(databaseArguments).toContain("postgresql:///%2E%2E"); + expect(databaseArguments).toContain( + "postgresql:///%71%75%6F%74%65%2F%73%6C%61%73%68" + ); + expect(databaseArguments).toContain( + "postgresql:///%68%6F%73%74%3D%61%74%74%61%63%6B%65%72%20%64%62%6E%61%6D%65%3D%78" + ); + expect(databaseArguments).toContain( + "postgresql:///%70%6F%73%74%67%72%65%73%71%6C%3A%2F%2F%65%76%69%6C%2F%78" + ); + expect( + databaseArguments.every( + (argument) => !rawNames.some((name) => argument.includes(name)) + ) + ).toBe(true); + }); + + test("bounds and projects only the Docker topology fields needed for provisioning", () => { + expect(provisioningDockerContainerMaximum).toBe(256); + expect(provisioningSqlIncludeDepthMaximum).toBe(8); + expect(provisioningSqlIncludeCountMaximum).toBe(32); + expect(provisioningSqlInputMaximumBytes).toBe(512 * 1024); + expect(provisioningDockerInspectFormat).toContain( + "com.docker.compose.depends_on" + ); + expect(provisioningDockerInspectFormat).toContain( + "com.docker.compose.container-number" + ); + expect(provisioningDockerInspectFormat).toContain("com.docker.compose.oneoff"); + expect(provisioningDockerInspectFormat).not.toContain(".Config.Env"); + expect(provisioningDockerInspectFormat).not.toContain(".Mounts"); + expect(provisioningDockerInspectFormat).not.toContain("{{json .Config.Labels}}"); + }); + + test("tolerates unrelated dependencies and resolves one psql-capable healthy dependency", async () => { + const catalog = catalogJson([provisioningControlDatabase]); + const rows = [ + provisioningDockerRow(1, { + capability: "pgbouncer-v1", + dependsOn: + "cache:service_healthy:false,Postgres_Primary:service_healthy:false,web:service_started:false", + project: "Docker.Project", + service: "Pool.Service", + }), + provisioningDockerRow(2, { + project: "Docker.Project", + service: "cache", + }), + provisioningDockerRow(3, { + project: "Docker.Project", + service: "Postgres_Primary", + }), + provisioningDockerRow(4, { + project: "Docker.Project", + service: "web", + }), + ]; + const run = provisioningProcessFixture({ + catalogs: [catalog, catalog], + dockerSnapshots: [rows], + psqlServices: new Set(["Postgres_Primary"]), + }); + + const result = await runDatabaseObservabilityProvisioning( + "verify-current-catalog", + { run } + ); + expect(result).toEqual({ + databaseCount: 1, + mode: "verify-current-catalog", + status: "VERIFIED", + }); + expect( + run.requests.some( + ({ argv }) => + argv.includes("Docker.Project") && argv.includes("Postgres_Primary") + ) + ).toBe(true); + }); + + test("fails closed for ambiguous or drifting Docker execution targets", async () => { + const catalog = catalogJson([provisioningControlDatabase]); + const ambiguousRows = [ + provisioningDockerRow(1, { + capability: "pgbouncer-v1", + dependsOn: + "postgres-a:service_healthy:false,postgres-b:service_healthy:false", + service: "pool", + }), + provisioningDockerRow(2, { service: "postgres-a" }), + provisioningDockerRow(3, { service: "postgres-b" }), + ]; + const ambiguousRun = provisioningProcessFixture({ + catalogs: [catalog], + dockerSnapshots: [ambiguousRows], + psqlServices: new Set(["postgres-a", "postgres-b"]), + }); + expect( + await rejectionText( + runDatabaseObservabilityProvisioning("verify-current-catalog", { + run: ambiguousRun, + }) + ) + ).toBe("Error: Database observability provisioning failed"); + + const changedRows = validProvisioningDockerRows().map((row) => + row.service === "postgres" ? { ...row, id: provisioningContainerId(20) } : row + ); + const driftRun = provisioningProcessFixture({ + catalogs: [catalog, catalog], + dockerSnapshots: [validProvisioningDockerRows(), changedRows], + }); + expect( + await rejectionText( + runDatabaseObservabilityProvisioning("activate-current-catalog", { + run: driftRun, + }) + ) + ).toBe("Error: Database observability provisioning failed"); + expect( + driftRun.requests.some(({ stdin }) => + stdin?.includes("ALTER ROLE mira_dashboard_observer LOGIN;") + ) + ).toBe(false); + + const roleDriftRun = provisioningProcessFixture({ + catalogs: [catalog, catalog], + roleOids: ["10", "11"], + }); + expect( + await rejectionText( + runDatabaseObservabilityProvisioning("activate-current-catalog", { + run: roleDriftRun, + }) + ) + ).toBe("Error: Database observability provisioning failed"); + expect( + roleDriftRun.requests.some(({ stdin }) => + stdin?.includes("ALTER ROLE mira_dashboard_observer LOGIN;") + ) + ).toBe(false); + }); + + test("bounds Docker inventory and redacts process failures and host secrets", async () => { + const catalog = catalogJson([provisioningControlDatabase]); + const overflowRows = Array.from( + { length: provisioningDockerContainerMaximum + 1 }, + (_, index) => provisioningDockerRow(index + 1) + ); + const overflowRun = provisioningProcessFixture({ + catalogs: [catalog], + dockerSnapshots: [overflowRows], + }); + expect( + await rejectionText( + runDatabaseObservabilityProvisioning("verify-current-catalog", { + run: overflowRun, + }) + ) + ).toBe("Error: Database observability provisioning failed"); + + const secret = "must-not-cross-provisioning-boundary"; + const originalSecret = process.env.MIRA_TEST_SENTINEL_SECRET; + process.env.MIRA_TEST_SENTINEL_SECRET = secret; + const baseRun = provisioningProcessFixture({ catalogs: [catalog, catalog] }); + const failingRun: DatabaseObservabilityProvisioningProcess = async (request) => { + if (request.stdin?.includes("pg_catalog.current_database()") === true) { + return { exitCode: 1, stderr: secret, stdout: secret }; + } + return baseRun(request); + }; + let failure: unknown; + try { + await runDatabaseObservabilityProvisioning("verify-current-catalog", { + run: failingRun, + }); + } catch (error) { + failure = error; + } finally { + if (originalSecret === undefined) { + delete process.env.MIRA_TEST_SENTINEL_SECRET; + } else { + process.env.MIRA_TEST_SENTINEL_SECRET = originalSecret; + } + } + expect(String(failure)).toBe("Error: Database observability provisioning failed"); + expect(String(failure)).not.toContain(secret); + expect(JSON.stringify(baseRun.requests)).not.toContain(secret); + expect( + baseRun.requests.every( + ({ environment, stdin }) => + environment === provisioningProcessEnvironment && + (stdin === null || + Buffer.byteLength(stdin) <= provisioningSqlInputMaximumBytes) + ) + ).toBe(true); + }); + + test("pins provisioning artifacts to one bounded regular-file descriptor", async () => { + const catalog = catalogJson([provisioningControlDatabase]); + const privateParent = await mkdtemp( + "/tmp/mira-dashboard-provisioning-descriptor-test-" + ); + const privateProvisioningRoot = path.join( + privateParent, + "database-observability" + ); + await cp(provisioningRoot, privateProvisioningRoot, { + preserveTimestamps: true, + recursive: true, + }); + const artifact = path.join(privateProvisioningRoot, "verify-cluster.sql"); + const displaced = `${artifact}.descriptor-test`; + const original = await readFile(artifact); + const artifactStatus = await lstat(artifact); + const mode = artifactStatus.mode & 0o777; + + const runMutation = async ( + mutate: () => Promise, + restore: () => Promise + ) => { + let mutated = false; + try { + expect( + await rejectionText( + runDatabaseObservabilityProvisioning("verify-current-catalog", { + artifactRoot: privateProvisioningRoot, + afterSqlArtifactDescriptorStat: async (fileName) => { + if (fileName !== "verify-cluster.sql" || mutated) { + return; + } + mutated = true; + await mutate(); + }, + run: provisioningProcessFixture({ + catalogs: [catalog, catalog], + }), + }) + ) + ).toBe("Error: Database observability provisioning failed"); + } finally { + if (mutated) await restore(); + } + }; + + try { + await runMutation( + async () => { + await rename(artifact, displaced); + await symlink(displaced, artifact); + }, + async () => { + await unlink(artifact); + await rename(displaced, artifact); + } + ); + await runMutation( + async () => { + await rename(artifact, displaced); + await writeFile(artifact, original, { mode }); + }, + async () => { + await unlink(artifact); + await rename(displaced, artifact); + } + ); + await runMutation( + async () => { + await rename(artifact, displaced); + const fifo = Bun.spawn(["/usr/bin/mkfifo", artifact], { + stderr: "pipe", + stdout: "pipe", + }); + if ((await fifo.exited) !== 0) throw new Error("mkfifo failed"); + }, + async () => { + await unlink(artifact); + await rename(displaced, artifact); + } + ); + await runMutation( + async () => { + await writeFile(artifact, Buffer.alloc(64 * 1024 + 1, 0x20)); + }, + async () => { + await writeFile(artifact, original); + await chmod(artifact, mode); + } + ); + } finally { + await rm(privateParent, { force: true, recursive: true }); + } + }); + + test("keeps SQL non-interactive, bounded and free of secret or shell surfaces", async () => { + const entries = await readdir(provisioningRoot); + for (const fileName of entries.filter((name) => name.endsWith(".sql"))) { + const sql = await readProvisioningFile(fileName); + expect(sql.startsWith("\\set ON_ERROR_STOP 1\n")).toBe(true); + expect(sql).not.toContain("\0"); + expect(sql).not.toMatch( + /\\!|COPY[\s\S]*PROGRAM|ALTER SYSTEM|dblink|postgres_fdw|docker|sudo|private-password|postgresql:\/\//iu + ); + if (fileName === "apply-control-database.sql") { + expect(sql.match(/CREATE EXTENSION/gu)).toHaveLength(1); + } else { + expect(sql).not.toMatch(/CREATE EXTENSION/iu); + } + } + const runner = await readProvisioningFile("runProvisioning.ts"); + expect(runner).toContain('const dockerExecutable = "/usr/bin/docker"'); + expect(runner).toContain( + 'const composeExecutable = "/opt/docker/bin/docker-compose-doppler"' + ); + expect(runner).toContain("/usr/local/bin/psql"); + expect(runner).toContain("provisioningProcessEnvironment"); + expect(runner).not.toContain('"/usr/bin/psql"'); + expect(runner).not.toContain("env: process.env"); + expect(runner).not.toMatch(/shell:\s*true|Bun\.\$|node:child_process/u); + }); +}); diff --git a/greenfield/scripts/delivery/databaseObservabilityProvisioningPolicy.ts b/greenfield/scripts/delivery/databaseObservabilityProvisioningPolicy.ts new file mode 100644 index 000000000..fa3b79655 --- /dev/null +++ b/greenfield/scripts/delivery/databaseObservabilityProvisioningPolicy.ts @@ -0,0 +1,40 @@ +/** Exact approval-gated database-observability artifacts shipped in a release. */ +export const databaseObservabilityProvisioningReleaseArtifactPaths = Object.freeze( + [ + "README.md", + "activate-observer.sql", + "apply-cluster.sql", + "apply-control-database-capability.sql", + "apply-control-database.sql", + "apply-database-access-reconciler.sql", + "apply-database-capabilities.sql", + "apply-reconciliation-approval.sql", + "apply-torrent-view.sql", + "disable-observer.sql", + "enable-approved-collection.sql", + "manifest.json", + "prepare-approved-collection.sql", + "reconcile-database-access.sql", + "rollback-cluster.sql", + "rollback-control-database-capability.sql", + "rollback-control-database.sql", + "rollback-database-access-reconciler.sql", + "rollback-database-capabilities.sql", + "rollback-reconciliation-approval.sql", + "rollback-torrent-view.sql", + "runProvisioning.ts", + "verify-cluster.sql", + "verify-control-database-capability.sql", + "verify-control-database.sql", + "verify-database-access-reconciler.sql", + "verify-database-capabilities.sql", + "verify-reconciliation-approval.sql", + "verify-database.sql", + "verify-torrent-view.sql", + ] + .map( + (fileName) => + `scripts/delivery/provisioning/database-observability/${fileName}` + ) + .toSorted() +); diff --git a/greenfield/scripts/delivery/databaseSnapshotRetention.test.ts b/greenfield/scripts/delivery/databaseSnapshotRetention.test.ts new file mode 100644 index 000000000..b8da43223 --- /dev/null +++ b/greenfield/scripts/delivery/databaseSnapshotRetention.test.ts @@ -0,0 +1,281 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { chmod, mkdir, mkdtemp, readdir, rename, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { retainProductionDatabaseSnapshots } from "./databaseSnapshotRetention.ts"; +import { withDeploymentLease } from "./deploymentLease.ts"; +import { prepareProductionDeliveryDirectories } from "./productionDeliveryFilesystem.ts"; +import { prepareProtectedProductionStatePath } from "./productionStateFilesystem.ts"; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + for (const directory of temporaryDirectories.splice(0)) { + await restoreOwnerWrite(directory); + await rm(directory, { force: true, recursive: true }); + } +}); + +async function restoreOwnerWrite(directory: string): Promise { + const entries = await readdir(directory, { withFileTypes: true }).catch(() => []); + await chmod(directory, 0o700).catch(() => {}); + for (const entry of entries) { + const candidate = path.join(directory, entry.name); + if (entry.isDirectory()) { + await restoreOwnerWrite(candidate); + } else if (entry.isFile()) { + await chmod(candidate, 0o600).catch(() => {}); + } + } +} + +function uuidV7At(timestampMs: number, suffix: number): string { + const timestamp = timestampMs.toString(16).padStart(12, "0"); + return `${timestamp.slice(0, 8)}-${timestamp.slice(8)}-7000-8000-${suffix.toString(16).padStart(12, "0")}`; +} + +async function snapshotFixture(directory: string): Promise { + await mkdir(directory, { mode: 0o700 }); + for (const fileName of ["mira-dashboard.db", "snapshot-manifest.json"]) { + const file = path.join(directory, fileName); + await writeFile(file, "fixture", { mode: 0o600 }); + await chmod(file, 0o400); + } + await chmod(directory, 0o500); +} + +describe("production database snapshot retention", () => { + test("preserves activation and journal references while pruning stale excess", async () => { + const root = await mkdtemp(path.join(tmpdir(), "mira-snapshot-retention-")); + temporaryDirectories.push(root); + const state = await prepareProtectedProductionStatePath(root); + const nowMs = Date.now(); + const ids = Array.from({ length: 9 }, (_, index) => + uuidV7At(nowMs - (index + 1) * 24 * 60 * 60_000, index + 1) + ); + for (const id of ids) { + await snapshotFixture(path.join(state.backupsDirectory, id)); + } + + await withDeploymentLease(state.stateDirectory, async (lease) => { + const paths = await prepareProductionDeliveryDirectories(state); + await retainProductionDatabaseSnapshots(lease, paths, { + activationTransitionIds: [ids[7]!], + journalTransitionId: ids[8], + nowMs, + }); + }); + + const entries = await readdir(state.backupsDirectory); + const retained = entries.toSorted(); + expect(retained).toContain(ids[7]!); + expect(retained).toContain(ids[8]!); + expect(retained).not.toContain(ids[6]!); + expect(retained.length).toBeLessThanOrEqual(5); + }); + + test("fails closed before pruning when a future UUIDv7 snapshot is present", async () => { + const root = await mkdtemp(path.join(tmpdir(), "mira-snapshot-retention-")); + temporaryDirectories.push(root); + const state = await prepareProtectedProductionStatePath(root); + const nowMs = Date.now(); + const staleId = uuidV7At(nowMs - 3 * 24 * 60 * 60_000, 1); + const futureId = uuidV7At(nowMs + 60_000, 2); + await snapshotFixture(path.join(state.backupsDirectory, staleId)); + await snapshotFixture(path.join(state.backupsDirectory, futureId)); + + expect( + withDeploymentLease(state.stateDirectory, async (lease) => { + const paths = await prepareProductionDeliveryDirectories(state); + await retainProductionDatabaseSnapshots(lease, paths, { + activationTransitionIds: [], + nowMs, + }); + }) + ).rejects.toThrow("Database snapshot retention failed"); + + expect(await readdir(state.backupsDirectory)).toEqual( + expect.arrayContaining([futureId, staleId]) + ); + }); + + test("does not remove a replacement swapped into the selected parent entry", async () => { + const root = await mkdtemp(path.join(tmpdir(), "mira-snapshot-retention-")); + temporaryDirectories.push(root); + const state = await prepareProtectedProductionStatePath(root); + const nowMs = Date.now(); + const staleId = uuidV7At(nowMs - 3 * 24 * 60 * 60_000, 1); + const staleDirectory = path.join(state.backupsDirectory, staleId); + const movedDirectory = path.join(state.backupsDirectory, `.raced-${staleId}`); + await snapshotFixture(staleDirectory); + + expect( + withDeploymentLease(state.stateDirectory, async (lease) => { + const paths = await prepareProductionDeliveryDirectories(state); + await retainProductionDatabaseSnapshots( + lease, + paths, + { activationTransitionIds: [], nowMs }, + { + beforeSnapshotRetired: async (transitionId) => { + expect(transitionId).toBe(staleId); + await rename(staleDirectory, movedDirectory); + await snapshotFixture(staleDirectory); + }, + } + ); + }) + ).rejects.toThrow("Database snapshot retention failed"); + + const staleEntries = await readdir(staleDirectory); + expect(staleEntries.toSorted()).toEqual([ + "mira-dashboard.db", + "snapshot-manifest.json", + ]); + }); + + test("fails closed on an unknown root entry before pruning snapshots", async () => { + const root = await mkdtemp(path.join(tmpdir(), "mira-snapshot-retention-")); + temporaryDirectories.push(root); + const state = await prepareProtectedProductionStatePath(root); + const nowMs = Date.now(); + const staleId = uuidV7At(nowMs - 3 * 24 * 60 * 60_000, 1); + await snapshotFixture(path.join(state.backupsDirectory, staleId)); + await writeFile(path.join(state.backupsDirectory, "unexpected"), "fixture"); + + expect( + withDeploymentLease(state.stateDirectory, async (lease) => { + const paths = await prepareProductionDeliveryDirectories(state); + await retainProductionDatabaseSnapshots(lease, paths, { + activationTransitionIds: [], + nowMs, + }); + }) + ).rejects.toThrow("Database snapshot retention failed"); + + expect(await readdir(path.join(state.backupsDirectory, staleId))).toHaveLength(2); + }); + + test("resumes a hard crash after the atomic retire rename", async () => { + const root = await mkdtemp(path.join(tmpdir(), "mira-snapshot-retention-")); + temporaryDirectories.push(root); + const state = await prepareProtectedProductionStatePath(root); + const nowMs = Date.now(); + const staleId = uuidV7At(nowMs - 3 * 24 * 60 * 60_000, 1); + await snapshotFixture(path.join(state.backupsDirectory, staleId)); + + expect( + withDeploymentLease(state.stateDirectory, async (lease) => { + const paths = await prepareProductionDeliveryDirectories(state); + await retainProductionDatabaseSnapshots( + lease, + paths, + { activationTransitionIds: [], nowMs }, + { + afterRetiredDirectorySynced: () => { + throw new Error("simulated hard crash"); + }, + } + ); + }) + ).rejects.toThrow("Database snapshot retention failed"); + expect(await readdir(state.backupsDirectory)).toContain(`.retire-${staleId}`); + + await withDeploymentLease(state.stateDirectory, async (lease) => { + const paths = await prepareProductionDeliveryDirectories(state); + await retainProductionDatabaseSnapshots(lease, paths, { + activationTransitionIds: [], + nowMs, + }); + }); + expect(await readdir(state.backupsDirectory)).not.toContain(`.retire-${staleId}`); + }); + + test("resumes a hard crash after one retired file was removed", async () => { + const root = await mkdtemp(path.join(tmpdir(), "mira-snapshot-retention-")); + temporaryDirectories.push(root); + const state = await prepareProtectedProductionStatePath(root); + const nowMs = Date.now(); + const staleId = uuidV7At(nowMs - 3 * 24 * 60 * 60_000, 1); + await snapshotFixture(path.join(state.backupsDirectory, staleId)); + let interrupted = false; + + expect( + withDeploymentLease(state.stateDirectory, async (lease) => { + const paths = await prepareProductionDeliveryDirectories(state); + await retainProductionDatabaseSnapshots( + lease, + paths, + { activationTransitionIds: [], nowMs }, + { + afterRetiredFileRemoved: () => { + if (interrupted) return; + interrupted = true; + throw new Error("simulated hard crash"); + }, + } + ); + }) + ).rejects.toThrow("Database snapshot retention failed"); + expect( + await readdir(path.join(state.backupsDirectory, `.retire-${staleId}`)) + ).toHaveLength(1); + + await withDeploymentLease(state.stateDirectory, async (lease) => { + const paths = await prepareProductionDeliveryDirectories(state); + await retainProductionDatabaseSnapshots(lease, paths, { + activationTransitionIds: [], + nowMs, + }); + }); + expect(await readdir(state.backupsDirectory)).not.toContain(`.retire-${staleId}`); + }); + + test("reaps an unreferenced crash-left cutover stage but preserves an active one", async () => { + const root = await mkdtemp(path.join(tmpdir(), "mira-snapshot-retention-")); + temporaryDirectories.push(root); + const state = await prepareProtectedProductionStatePath(root); + const nowMs = Date.now(); + const staleId = uuidV7At(nowMs - 60_000, 1); + const activeId = uuidV7At(nowMs - 30_000, 2); + await snapshotFixture(path.join(state.backupsDirectory, `.stage-${staleId}`)); + await snapshotFixture(path.join(state.backupsDirectory, `.stage-${activeId}`)); + + await withDeploymentLease(state.stateDirectory, async (lease) => { + const paths = await prepareProductionDeliveryDirectories(state); + await retainProductionDatabaseSnapshots(lease, paths, { + activationTransitionIds: [], + journalTransitionId: activeId, + nowMs, + }); + }); + + const retained = await readdir(state.backupsDirectory); + expect(retained).not.toContain(`.stage-${staleId}`); + expect(retained).toContain(`.stage-${activeId}`); + }); + + test("resumes a crash-left cutover snapshot cleanup retire handoff", async () => { + const root = await mkdtemp(path.join(tmpdir(), "mira-snapshot-retention-")); + temporaryDirectories.push(root); + const state = await prepareProtectedProductionStatePath(root); + const nowMs = Date.now(); + const staleId = uuidV7At(nowMs - 60_000, 1); + await snapshotFixture( + path.join(state.backupsDirectory, `.retire-stage-${staleId}`) + ); + + await withDeploymentLease(state.stateDirectory, async (lease) => { + const paths = await prepareProductionDeliveryDirectories(state); + await retainProductionDatabaseSnapshots(lease, paths, { + activationTransitionIds: [], + nowMs, + }); + }); + + expect(await readdir(state.backupsDirectory)).not.toContain( + `.retire-stage-${staleId}` + ); + }); +}); diff --git a/greenfield/scripts/delivery/databaseSnapshotRetention.ts b/greenfield/scripts/delivery/databaseSnapshotRetention.ts new file mode 100644 index 000000000..c8a57df26 --- /dev/null +++ b/greenfield/scripts/delivery/databaseSnapshotRetention.ts @@ -0,0 +1,454 @@ +import { constants, type BigIntStats } from "node:fs"; +import { + lstat, + open, + readdir, + realpath, + rename, + rmdir, + unlink, + type FileHandle, +} from "node:fs/promises"; +import path from "node:path"; + +import * as v from "valibot"; + +import { lowercaseUuidV7Schema } from "../../src/shared/validation.ts"; +import type { DashboardDeploymentLease } from "./deploymentLease.ts"; +import type { PreparedProductionDeliveryPaths } from "./productionDeliveryFilesystem.ts"; + +const retentionFailureMessage = "Database snapshot retention failed"; +const databaseFileName = "mira-dashboard.db"; +const manifestFileName = "snapshot-manifest.json"; +const cutoverRetentionMaximum = 5; +const cutoverRetentionMaximumAgeMs = 2 * 24 * 60 * 60_000; +const rootEntryMaximum = 128; +const stagePrefix = ".stage-"; +const retirePrefix = ".retire-"; +const directoryFlags = + constants.O_RDONLY | + constants.O_DIRECTORY | + constants.O_NOFOLLOW | + constants.O_NONBLOCK; + +interface OpenedDirectory { + readonly device: bigint; + readonly handle: FileHandle; + readonly inode: bigint; + readonly path: string; +} + +type SnapshotEntryKind = "published" | "retired" | "stage"; + +interface CutoverSnapshotIdentity { + readonly createdAtMs: number; + readonly device: bigint; + readonly id: string; + readonly inode: bigint; + readonly kind: SnapshotEntryKind; + readonly name: string; +} + +export interface DatabaseSnapshotRetentionInput { + readonly activationTransitionIds: readonly string[]; + readonly journalTransitionId?: string; + readonly nowMs?: number; +} + +/** Deterministic crash/race boundaries exposed only to delivery tests. */ +export interface DatabaseSnapshotRetentionTestHooks { + readonly afterRetiredDirectorySynced?: (transitionId: string) => Promise | void; + readonly afterRetiredFileRemoved?: ( + transitionId: string, + fileName: string + ) => Promise | void; + readonly beforeSnapshotRetired?: (transitionId: string) => Promise | void; +} + +function failure(): Error { + return new Error(retentionFailureMessage); +} + +function validDirectory(status: BigIntStats, device?: bigint): boolean { + return ( + typeof process.getuid === "function" && + status.isDirectory() && + !status.isSymbolicLink() && + status.uid === BigInt(process.getuid()) && + (status.mode & 0o7777n) === 0o700n && + (device === undefined || status.dev === device) + ); +} + +async function closeHandle(handle: FileHandle | undefined): Promise { + if (handle === undefined) return true; + try { + await handle.close(); + return true; + } catch { + return false; + } +} + +async function openDirectory( + directory: string, + expectedDevice?: bigint +): Promise { + if (process.platform !== "linux") throw failure(); + let handle: FileHandle | undefined; + try { + handle = await open(directory, directoryFlags); + const [held, pathStatus, canonical] = await Promise.all([ + handle.stat({ bigint: true }), + lstat(directory, { bigint: true }), + realpath(`/proc/self/fd/${handle.fd}`), + ]); + if ( + canonical !== directory || + !validDirectory(held, expectedDevice) || + !validDirectory(pathStatus, expectedDevice) || + held.dev !== pathStatus.dev || + held.ino !== pathStatus.ino + ) { + throw failure(); + } + return Object.freeze({ + device: held.dev, + handle, + inode: held.ino, + path: directory, + }); + } catch { + await closeHandle(handle); + throw failure(); + } +} + +function uuidTimestamp(transitionId: string): number { + const value = Number.parseInt(transitionId.replaceAll("-", "").slice(0, 12), 16); + if (!Number.isSafeInteger(value) || value < 0) throw failure(); + return value; +} + +function isUuidV7(value: string): boolean { + return v.safeParse(lowercaseUuidV7Schema(), value, { abortEarly: true }).success; +} + +function parseSnapshotEntry(name: string): + | { + readonly id: string; + readonly kind: SnapshotEntryKind; + } + | undefined { + if (isUuidV7(name)) return { id: name, kind: "published" }; + for (const prefix of [".retire-final-", ".retire-stage-"] as const) { + if (!name.startsWith(prefix)) continue; + const id = name.slice(prefix.length); + if (isUuidV7(id)) return { id, kind: "retired" }; + } + for (const [prefix, kind] of [ + [stagePrefix, "stage"], + [retirePrefix, "retired"], + ] as const) { + if (!name.startsWith(prefix)) continue; + const id = name.slice(prefix.length); + if (isUuidV7(id)) return { id, kind }; + } + return undefined; +} + +async function inspectSnapshotEntry( + backups: OpenedDirectory, + name: string, + kind: SnapshotEntryKind, + id: string +): Promise { + const anchored = path.join(`/proc/self/fd/${backups.handle.fd}`, name); + const status = await lstat(anchored, { bigint: true }); + const allowedModes = kind === "published" ? [0o500n] : [0o500n, 0o700n]; + if ( + typeof process.getuid !== "function" || + !status.isDirectory() || + status.isSymbolicLink() || + status.nlink !== 2n || + status.uid !== BigInt(process.getuid()) || + status.dev !== backups.device || + !allowedModes.includes(status.mode & 0o7777n) + ) { + throw failure(); + } + return Object.freeze({ + createdAtMs: uuidTimestamp(id), + device: status.dev, + id, + inode: status.ino, + kind, + name, + }); +} + +async function openPinnedSnapshot( + backups: OpenedDirectory, + snapshot: CutoverSnapshotIdentity +): Promise { + const anchored = path.join(`/proc/self/fd/${backups.handle.fd}`, snapshot.name); + let handle: FileHandle | undefined; + try { + handle = await open(anchored, directoryFlags); + const [held, named] = await Promise.all([ + handle.stat({ bigint: true }), + lstat(anchored, { bigint: true }), + ]); + const allowedModes = snapshot.kind === "published" ? [0o500n] : [0o500n, 0o700n]; + if ( + typeof process.getuid !== "function" || + !held.isDirectory() || + held.isSymbolicLink() || + held.nlink !== 2n || + held.uid !== BigInt(process.getuid()) || + held.dev !== snapshot.device || + held.ino !== snapshot.inode || + !allowedModes.includes(held.mode & 0o7777n) || + !named.isDirectory() || + named.isSymbolicLink() || + named.dev !== held.dev || + named.ino !== held.ino || + named.uid !== held.uid || + named.nlink !== 2n || + !allowedModes.includes(named.mode & 0o7777n) + ) { + throw failure(); + } + return handle; + } catch { + await closeHandle(handle); + throw failure(); + } +} + +async function retireSnapshot( + backups: OpenedDirectory, + snapshot: CutoverSnapshotIdentity, + hooks?: DatabaseSnapshotRetentionTestHooks +): Promise { + if (snapshot.kind === "retired") return snapshot; + const retiredName = `${retirePrefix}${snapshot.id}`; + const parent = `/proc/self/fd/${backups.handle.fd}`; + const source = path.join(parent, snapshot.name); + const target = path.join(parent, retiredName); + const child = await openPinnedSnapshot(backups, snapshot); + let failed = false; + try { + await hooks?.beforeSnapshotRetired?.(snapshot.id); + const named = await lstat(source, { bigint: true }); + if (named.dev !== snapshot.device || named.ino !== snapshot.inode) { + throw failure(); + } + await rename(source, target); + const retired = await lstat(target, { bigint: true }); + if (retired.dev !== snapshot.device || retired.ino !== snapshot.inode) { + throw failure(); + } + await backups.handle.sync(); + await hooks?.afterRetiredDirectorySynced?.(snapshot.id); + } catch { + failed = true; + } + if (!(await closeHandle(child)) || failed) throw failure(); + return Object.freeze({ ...snapshot, kind: "retired", name: retiredName }); +} + +async function reapRetiredSnapshot( + backups: OpenedDirectory, + snapshot: CutoverSnapshotIdentity, + hooks?: DatabaseSnapshotRetentionTestHooks +): Promise { + if (snapshot.kind !== "retired") throw failure(); + if (typeof process.getuid !== "function") throw failure(); + const expectedUid = BigInt(process.getuid()); + const child = await openPinnedSnapshot(backups, snapshot); + const descriptor = `/proc/self/fd/${child.fd}`; + let failed = false; + try { + const entries = await readdir(descriptor, { withFileTypes: true }); + if ( + entries.length > 2 || + entries.some( + (entry) => + !entry.isFile() || + ![databaseFileName, manifestFileName].includes(entry.name) + ) + ) { + throw failure(); + } + await child.chmod(0o700); + for (const fileName of [databaseFileName, manifestFileName] as const) { + if (!entries.some((entry) => entry.name === fileName)) continue; + const anchored = path.join(descriptor, fileName); + let file: FileHandle | undefined; + let fileFailed = false; + try { + file = await open( + anchored, + constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK + ); + const [held, named] = await Promise.all([ + file.stat({ bigint: true }), + lstat(anchored, { bigint: true }), + ]); + if ( + typeof process.getuid !== "function" || + !held.isFile() || + held.isSymbolicLink() || + held.nlink !== 1n || + held.uid !== BigInt(process.getuid()) || + held.dev !== backups.device || + ![0o400n, 0o600n].includes(held.mode & 0o7777n) || + !named.isFile() || + named.isSymbolicLink() || + named.dev !== held.dev || + named.ino !== held.ino || + named.nlink !== 1n || + named.uid !== held.uid || + ![0o400n, 0o600n].includes(named.mode & 0o7777n) + ) { + throw failure(); + } + await file.chmod(0o600); + await unlink(anchored); + await hooks?.afterRetiredFileRemoved?.(snapshot.id, fileName); + } catch { + fileFailed = true; + } + if (!(await closeHandle(file)) || fileFailed) throw failure(); + } + const remainingEntries = await readdir(descriptor); + if (remainingEntries.length > 0) throw failure(); + const named = await lstat( + path.join(`/proc/self/fd/${backups.handle.fd}`, snapshot.name), + { bigint: true } + ); + if (named.dev !== snapshot.device || named.ino !== snapshot.inode) { + throw failure(); + } + } catch { + failed = true; + } + if (!(await closeHandle(child)) || failed) throw failure(); + const parentEntry = path.join(`/proc/self/fd/${backups.handle.fd}`, snapshot.name); + const finalNamed = await lstat(parentEntry, { bigint: true }); + if ( + !finalNamed.isDirectory() || + finalNamed.isSymbolicLink() || + finalNamed.dev !== snapshot.device || + finalNamed.ino !== snapshot.inode || + finalNamed.nlink !== 2n || + finalNamed.uid !== expectedUid || + (finalNamed.mode & 0o7777n) !== 0o700n + ) { + throw failure(); + } + await rmdir(parentEntry); + await backups.handle.sync(); +} + +/** + * Prunes only unreferenced immutable activation snapshots under the deployment lease. + * Current, previous, and in-flight journal transition identities are never removed. + */ +export async function retainProductionDatabaseSnapshots( + lease: DashboardDeploymentLease, + paths: PreparedProductionDeliveryPaths, + input: DatabaseSnapshotRetentionInput, + hooks?: DatabaseSnapshotRetentionTestHooks +): Promise { + const nowMs = input.nowMs ?? Date.now(); + if ( + lease.stateDirectory !== paths.stateDirectory || + !Number.isSafeInteger(nowMs) || + nowMs < 0 || + !input.activationTransitionIds.every((id) => v.is(lowercaseUuidV7Schema(), id)) || + (input.journalTransitionId !== undefined && + !v.is(lowercaseUuidV7Schema(), input.journalTransitionId)) + ) { + throw failure(); + } + const protectedIds = new Set(input.activationTransitionIds); + if (input.journalTransitionId !== undefined) { + protectedIds.add(input.journalTransitionId); + } + const state = await openDirectory(paths.stateDirectory); + let backups: OpenedDirectory | undefined; + let failed = false; + try { + backups = await openDirectory( + path.join(paths.stateDirectory, "backups"), + state.device + ); + const entries = await readdir(backups.path, { withFileTypes: true }); + if (entries.length > rootEntryMaximum) throw failure(); + const snapshots: CutoverSnapshotIdentity[] = []; + for (const entry of entries) { + if (entry.name === "sqlite-maintenance") { + if (!entry.isDirectory() || entry.isSymbolicLink()) throw failure(); + continue; + } + const parsed = parseSnapshotEntry(entry.name); + if (!entry.isDirectory() || parsed === undefined) throw failure(); + snapshots.push( + await inspectSnapshotEntry(backups, entry.name, parsed.kind, parsed.id) + ); + } + if (snapshots.some(({ createdAtMs }) => createdAtMs > nowMs)) { + throw failure(); + } + if ( + snapshots.some(({ id, kind }) => kind === "retired" && protectedIds.has(id)) + ) { + throw failure(); + } + for (const snapshot of snapshots.filter(({ kind }) => kind === "retired")) { + await reapRetiredSnapshot(backups, snapshot, hooks); + } + for (const snapshot of snapshots.filter(({ kind }) => kind === "stage")) { + if (protectedIds.has(snapshot.id)) continue; + const retired = await retireSnapshot(backups, snapshot, hooks); + await reapRetiredSnapshot(backups, retired, hooks); + } + const published = snapshots + .filter(({ kind }) => kind === "published") + .toSorted((left, right) => right.id.localeCompare(left.id)); + const protectedSnapshotCount = published.filter(({ id }) => + protectedIds.has(id) + ).length; + const unprotectedRetentionMaximum = Math.max( + 0, + cutoverRetentionMaximum - protectedSnapshotCount + ); + const keptUnprotected = new Set( + published + .filter(({ id }) => !protectedIds.has(id)) + .filter( + ({ createdAtMs }) => + nowMs - createdAtMs <= cutoverRetentionMaximumAgeMs + ) + .slice(0, unprotectedRetentionMaximum) + .map(({ id }) => id) + ); + for (const snapshot of published) { + if (protectedIds.has(snapshot.id) || keptUnprotected.has(snapshot.id)) { + continue; + } + const retired = await retireSnapshot(backups, snapshot, hooks); + await reapRetiredSnapshot(backups, retired, hooks); + } + await backups.handle.sync(); + } catch { + failed = true; + } + const [backupsClosed, stateClosed] = await Promise.all([ + closeHandle(backups?.handle), + closeHandle(state.handle), + ]); + if (failed || !backupsClosed || !stateClosed) throw failure(); +} diff --git a/greenfield/scripts/delivery/deploymentLease.test.ts b/greenfield/scripts/delivery/deploymentLease.test.ts index 6d620bf35..5a8f2ada0 100644 --- a/greenfield/scripts/delivery/deploymentLease.test.ts +++ b/greenfield/scripts/delivery/deploymentLease.test.ts @@ -4,6 +4,7 @@ import { mkdir, mkdtemp, open, + readFile, rename, rm, symlink, @@ -33,6 +34,23 @@ async function stateFixture(): Promise { return state; } +async function currentLockOwner() { + const processStat = await readFile(`/proc/${process.pid}/stat`, "utf8"); + const processFields = processStat + .slice(processStat.lastIndexOf(")") + 1) + .trim() + .split(/\s+/u); + const bootId = await readFile("/proc/sys/kernel/random/boot_id", "utf8"); + const processStartTicks = processFields[19]; + if (processStartTicks === undefined) throw new Error("Missing process identity"); + return Object.freeze({ + bootId: bootId.trim(), + pid: process.pid, + processStartTicks, + token: Bun.randomUUIDv7(), + }); +} + describe("Dashboard deployment lease", () => { test("runs competing production transitions one at a time", async () => { const state = await stateFixture(); @@ -64,7 +82,7 @@ describe("Dashboard deployment lease", () => { const lockPath = path.join(state, ".deployment.lock"); const initializingLock = await open(lockPath, "wx", 0o600); await initializingLock.writeFile( - JSON.stringify({ pid: process.pid, token: Bun.randomUUIDv7() }), + JSON.stringify(await currentLockOwner()), "utf8" ); let entered = false; @@ -87,6 +105,23 @@ describe("Dashboard deployment lease", () => { expect(entered).toBe(true); }); + test("recovers a crash-left empty lock publication after bounded grace", async () => { + const state = await stateFixture(); + const lockPath = path.join(state, ".deployment.lock"); + const incompleteLock = await open(lockPath, "wx", 0o600); + await incompleteLock.close(); + const startedAt = Date.now(); + + let entered = false; + await withDeploymentLease(state, () => { + entered = true; + return Promise.resolve(); + }); + + expect(entered).toBe(true); + expect(Date.now() - startedAt).toBeGreaterThanOrEqual(4900); + }, 10_000); + test("rejects permissive, replaced and linked state directories", async () => { const permissive = await stateFixture(); await chmod(permissive, 0o755); diff --git a/greenfield/scripts/delivery/deploymentLease.ts b/greenfield/scripts/delivery/deploymentLease.ts index 42b9192e3..011beb554 100644 --- a/greenfield/scripts/delivery/deploymentLease.ts +++ b/greenfield/scripts/delivery/deploymentLease.ts @@ -81,7 +81,9 @@ async function validateStateDirectory( } /** - * Serializes one complete production release/database transition below private state. + * Serializes every authorized production delivery mutation by the trusted application UID. + * This is an application authority boundary, not a sandbox against another malicious process + * running as that same UID; closing that boundary requires a different-principal host handoff. * @param stateDirectory Canonical current-user-owned `0700` production state directory. * @param operation Complete transition; callers must keep services stopped until it settles. * @returns Operation result after the lease and state identity are revalidated. diff --git a/greenfield/scripts/delivery/exclusiveProcessLock.ts b/greenfield/scripts/delivery/exclusiveProcessLock.ts index 5739f018f..a93b81dd1 100644 --- a/greenfield/scripts/delivery/exclusiveProcessLock.ts +++ b/greenfield/scripts/delivery/exclusiveProcessLock.ts @@ -1,16 +1,20 @@ import { constants, type BigIntStats } from "node:fs"; -import { lstat, open, unlink, type FileHandle } from "node:fs/promises"; +import { lstat, open, readFile, unlink, type FileHandle } from "node:fs/promises"; import path from "node:path"; import * as v from "valibot"; const maximumProcessLockBytes = 512; +const maximumProcessStatBytes = 4096; const processLockInitializationGraceMs = 5000; +const linuxBootIdPath = "/proc/sys/kernel/random/boot_id"; const lockOpenFlags = constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW | constants.O_RDWR; const lockReadFlags = constants.O_NOFOLLOW | constants.O_RDONLY; const processLockOwnerSchema = v.strictObject({ + bootId: v.pipe(v.string(), v.uuid()), pid: v.pipe(v.number(), v.integer(), v.minValue(1)), + processStartTicks: v.pipe(v.string(), v.regex(/^(?:0|[1-9]\d*)$/u), v.maxLength(32)), token: v.pipe(v.string(), v.uuid()), }); @@ -34,6 +38,11 @@ interface OwnedProcessLock { readonly snapshot: ProcessLockSnapshot; } +interface ObservedProcessLock { + readonly owner?: v.InferOutput; + readonly snapshot: ProcessLockSnapshot; +} + function processLockFailure(message: string): Error { return new Error(message); } @@ -61,7 +70,11 @@ function validateOptions(options: ExclusiveProcessLockOptions): void { } } -function snapshot(status: BigIntStats, failureMessage: string): ProcessLockSnapshot { +function snapshot( + status: BigIntStats, + failureMessage: string, + allowIncomplete = false +): ProcessLockSnapshot { if ( typeof process.getuid !== "function" || !status.isFile() || @@ -69,7 +82,7 @@ function snapshot(status: BigIntStats, failureMessage: string): ProcessLockSnaps status.nlink !== 1n || status.uid !== BigInt(process.getuid()) || (status.mode & 0o022n) !== 0n || - status.size <= 0n || + status.size < (allowIncomplete ? 0n : 1n) || status.size > BigInt(maximumProcessLockBytes) ) { throw processLockFailure(failureMessage); @@ -94,42 +107,75 @@ function sameSnapshot( ); } -async function processLockMayStillBeInitializing( - lockPath: string, - failureMessage: string, - contents: string | undefined, - observedMutation: boolean -): Promise { - // O_EXCL publishes the pathname before the owner can finish its bounded record. - // A newline terminates every complete record, so completed malformed data must - // fail immediately while only a secure incomplete publication receives grace. - if (!observedMutation && contents?.endsWith("\n")) return false; - let status: BigIntStats; +async function readLinuxBootId(failureMessage: string): Promise { try { - status = await lstat(lockPath, { bigint: true }); - } catch (error) { - if (errorCode(error) === "ENOENT") return true; + const contents = await readFile(linuxBootIdPath, "utf8"); + if (Buffer.byteLength(contents) > 64) throw processLockFailure(failureMessage); + return v.parse(v.pipe(v.string(), v.uuid()), contents.trim()); + } catch { throw processLockFailure(failureMessage); } - if ( - typeof process.getuid !== "function" || - !status.isFile() || - status.isSymbolicLink() || - status.nlink !== 1n || - status.uid !== BigInt(process.getuid()) || - (status.mode & 0o022n) !== 0n || - status.size < 0n || - status.size > BigInt(maximumProcessLockBytes) - ) { - return false; +} + +async function readProcessStartTicks( + pid: number, + failureMessage: string +): Promise { + try { + const contents = await readFile(`/proc/${pid}/stat`, "utf8"); + if ( + Buffer.byteLength(contents) === 0 || + Buffer.byteLength(contents) > maximumProcessStatBytes + ) { + throw processLockFailure(failureMessage); + } + // The command name is parenthesized and may contain spaces or `)` characters, + // so field 22 (starttime) is located relative to the final closing parenthesis. + const closingParenthesis = contents.lastIndexOf(")"); + if ( + !contents.startsWith(`${pid} (`) || + closingParenthesis <= String(pid).length + 1 + ) { + throw processLockFailure(failureMessage); + } + const fields = contents + .slice(closingParenthesis + 1) + .trim() + .split(/\s+/u); + const processStartTicks = fields[19]; + if ( + fields.length < 20 || + processStartTicks === undefined || + !/^(?:0|[1-9]\d*)$/u.test(processStartTicks) || + processStartTicks.length > 32 + ) { + throw processLockFailure(failureMessage); + } + return processStartTicks; + } catch (error) { + if (errorCode(error) === "ENOENT" || errorCode(error) === "ESRCH") { + return undefined; + } + throw processLockFailure(failureMessage); } - const ageMs = Date.now() - Number(status.ctimeMs); - return ageMs >= 0 && ageMs <= processLockInitializationGraceMs; +} + +async function currentProcessIdentity(failureMessage: string): Promise<{ + bootId: string; + processStartTicks: string; +}> { + const [bootId, processStartTicks] = await Promise.all([ + readLinuxBootId(failureMessage), + readProcessStartTicks(process.pid, failureMessage), + ] as const); + if (processStartTicks === undefined) throw processLockFailure(failureMessage); + return Object.freeze({ bootId, processStartTicks }); } async function createProcessLock( options: ExclusiveProcessLockOptions ): Promise { + const identity = await currentProcessIdentity(options.failureMessage); let handle: FileHandle; try { handle = await open(options.lockPath, lockOpenFlags, 0o600); @@ -139,7 +185,12 @@ async function createProcessLock( } try { await handle.writeFile( - `${JSON.stringify({ pid: process.pid, token: Bun.randomUUIDv7() })}\n`, + `${JSON.stringify({ + bootId: identity.bootId, + pid: process.pid, + processStartTicks: identity.processStartTicks, + token: Bun.randomUUIDv7(), + })}\n`, "utf8" ); await handle.sync(); @@ -159,16 +210,10 @@ async function createProcessLock( } } -async function readProcessLock(options: ExclusiveProcessLockOptions): Promise< - | { - owner: v.InferOutput; - snapshot: ProcessLockSnapshot; - } - | undefined -> { +async function readProcessLock( + options: ExclusiveProcessLockOptions +): Promise { let handle: FileHandle; - let contents: string | undefined; - let observedMutation = false; try { handle = await open(options.lockPath, lockReadFlags); } catch (error) { @@ -178,20 +223,18 @@ async function readProcessLock(options: ExclusiveProcessLockOptions): Promise< try { const beforeStatus = await handle.stat({ bigint: true }); if (beforeStatus.nlink === 0n) return undefined; - const before = snapshot(beforeStatus, options.failureMessage); - contents = await handle.readFile("utf8"); + const before = snapshot(beforeStatus, options.failureMessage, true); + const buffer = Buffer.alloc(maximumProcessLockBytes + 1); + const { bytesRead } = await handle.read(buffer, 0, buffer.byteLength, 0); + if (bytesRead > maximumProcessLockBytes) { + throw processLockFailure(options.failureMessage); + } + const contents = buffer.subarray(0, bytesRead).toString("utf8"); const afterStatus = await handle.stat({ bigint: true }); if (afterStatus.nlink === 0n) return undefined; - const after = snapshot(afterStatus, options.failureMessage); + const after = snapshot(afterStatus, options.failureMessage, true); if (!sameSnapshot(before, after)) { - observedMutation = true; - throw processLockFailure(options.failureMessage); - } - if ( - !contents.endsWith("\n") || - Buffer.byteLength(contents) > maximumProcessLockBytes - ) { - throw processLockFailure(options.failureMessage); + return undefined; } let pathStatus: BigIntStats; try { @@ -200,50 +243,54 @@ async function readProcessLock(options: ExclusiveProcessLockOptions): Promise< if (errorCode(error) === "ENOENT") return undefined; throw processLockFailure(options.failureMessage); } - if (!sameSnapshot(after, snapshot(pathStatus, options.failureMessage))) { + if (!sameSnapshot(after, snapshot(pathStatus, options.failureMessage, true))) { return undefined; } + if (!contents.endsWith("\n")) { + return Object.freeze({ snapshot: after }); + } const parsed: unknown = JSON.parse(contents); return Object.freeze({ owner: v.parse(processLockOwnerSchema, parsed), snapshot: after, }); } catch { - if ( - await processLockMayStillBeInitializing( - options.lockPath, - options.failureMessage, - contents, - observedMutation - ) - ) { - return undefined; - } throw processLockFailure(options.failureMessage); } finally { await handle.close(); } } -function isProcessAlive(pid: number, failureMessage: string): boolean { - try { - process.kill(pid, 0); - return true; - } catch (error) { - if (errorCode(error) === "ESRCH") return false; - if (errorCode(error) === "EPERM") return true; - throw processLockFailure(failureMessage); - } +async function ownerIdentityIsAlive( + owner: v.InferOutput, + failureMessage: string +): Promise { + const bootId = await readLinuxBootId(failureMessage); + if (bootId !== owner.bootId) return false; + const processStartTicks = await readProcessStartTicks(owner.pid, failureMessage); + return processStartTicks === owner.processStartTicks; } async function recoverStaleProcessLock( options: ExclusiveProcessLockOptions ): Promise { const observed = await readProcessLock(options); - if ( - observed === undefined || - isProcessAlive(observed.owner.pid, options.failureMessage) - ) { + if (observed === undefined) return; + if (observed.owner === undefined) { + let currentStatus: BigIntStats; + try { + currentStatus = await lstat(options.lockPath, { bigint: true }); + } catch (error) { + if (errorCode(error) === "ENOENT") return; + throw processLockFailure(options.failureMessage); + } + const current = snapshot(currentStatus, options.failureMessage, true); + if (!sameSnapshot(observed.snapshot, current)) return; + const ageMs = Date.now() - Number(currentStatus.ctimeMs); + if (!Number.isFinite(ageMs) || ageMs < processLockInitializationGraceMs) { + return; + } + } else if (await ownerIdentityIsAlive(observed.owner, options.failureMessage)) { return; } let currentStatus: BigIntStats; @@ -253,9 +300,13 @@ async function recoverStaleProcessLock( if (errorCode(error) === "ENOENT") return; throw processLockFailure(options.failureMessage); } - const current = snapshot(currentStatus, options.failureMessage); + const current = snapshot( + currentStatus, + options.failureMessage, + observed.owner === undefined + ); if (!sameSnapshot(observed.snapshot, current)) { - throw processLockFailure(options.failureMessage); + return; } try { await unlink(options.lockPath); diff --git a/greenfield/scripts/delivery/productionArtifactCapacity.ts b/greenfield/scripts/delivery/productionArtifactCapacity.ts new file mode 100644 index 000000000..3dc9b16e2 --- /dev/null +++ b/greenfield/scripts/delivery/productionArtifactCapacity.ts @@ -0,0 +1,311 @@ +import type { BigIntStats } from "node:fs"; +import { lstat, realpath, statfs } from "node:fs/promises"; +import path from "node:path"; + +import type { ReleaseManifest } from "../../src/shared/releaseManifest.ts"; +import type { DashboardDeploymentLease } from "./deploymentLease.ts"; +import type { PreparedProductionDeliveryPaths } from "./productionDeliveryFilesystem.ts"; +import { inventoryReleaseArtifactTree } from "./releaseArtifactInventory.ts"; +import { verifyReleaseArtifactIdentity } from "./releaseIdentity.ts"; + +const capacityFailureMessage = "Production artifact capacity admission failed"; +const commitShaPattern = /^[a-f\d]{40}$/u; +const maximumRuntimeBytes = 256n * 1024n * 1024n; +const maximumCapacityObjects = 8192; +const privateDirectoryMode = 0o700n; + +/** Free space left untouched when admitting a new immutable release/runtime pair. */ +export const productionArtifactCapacityReserveBytes = 64n * 1024n * 1024n; + +/** Free inodes left untouched when admitting immutable production artifacts. */ +export const productionArtifactCapacityReserveInodes = 64n; + +/** Exact filesystem capacity used to admit one immutable copy. */ +export interface ProductionArtifactFilesystemCapacity { + readonly availableBytes: bigint; + readonly availableInodes: bigint; + readonly blockSize: bigint; +} + +/** Logical files and new directories materialized by one immutable copy. */ +export interface ProductionArtifactCopyInventory { + readonly fileBytes: readonly bigint[]; + readonly newDirectoryCount: bigint; +} + +/** Read-only source-verification and capacity boundaries exposed to focused tests. */ +export interface ProductionArtifactCapacityDependencies { + readonly availableCapacity?: ( + directory: string + ) => Promise; + readonly verifySourceRelease?: typeof verifyReleaseArtifactIdentity; +} + +function failure(): Error { + return new Error(capacityFailureMessage); +} + +function errorCode(error: unknown): string | undefined { + return error instanceof Error && "code" in error + ? (error as NodeJS.ErrnoException).code + : undefined; +} + +function validPrivateDirectory(status: BigIntStats): boolean { + return ( + typeof process.getuid === "function" && + status.isDirectory() && + !status.isSymbolicLink() && + status.uid === BigInt(process.getuid()) && + (status.mode & 0o7777n) === privateDirectoryMode + ); +} + +async function defaultAvailableCapacity( + directory: string +): Promise { + const status = await statfs(directory, { bigint: true }); + if (status.bsize <= 0n || status.bavail < 0n || status.ffree < 0n) { + throw failure(); + } + return Object.freeze({ + availableBytes: status.bsize * status.bavail, + availableInodes: status.ffree, + blockSize: status.bsize, + }); +} + +function directoryCountForRelease( + records: Awaited> +): bigint { + const directories = new Set([""]); + for (const record of records) { + const segments = record.path.split("/"); + for (let index = 1; index < segments.length; index += 1) { + directories.add(segments.slice(0, index).join("/")); + } + } + return BigInt(directories.size); +} + +function roundedAllocation(bytes: bigint, blockSize: bigint): bigint { + return ((bytes + blockSize - 1n) / blockSize) * blockSize; +} + +function assertFitsCapacity( + capacity: ProductionArtifactFilesystemCapacity, + inventory: ProductionArtifactCopyInventory +): void { + const objectCount = BigInt(inventory.fileBytes.length) + inventory.newDirectoryCount; + if ( + capacity.availableBytes < 0n || + capacity.availableInodes < 0n || + capacity.blockSize <= 0n || + inventory.fileBytes.length === 0 || + inventory.fileBytes.length > maximumCapacityObjects || + inventory.newDirectoryCount < 0n || + objectCount <= 0n || + objectCount > BigInt(maximumCapacityObjects) || + inventory.fileBytes.some((bytes) => bytes <= 0n) + ) { + throw failure(); + } + + const fileAllocationBytes = inventory.fileBytes.reduce( + (total, bytes) => total + roundedAllocation(bytes, capacity.blockSize), + 0n + ); + // One additional allocation unit per inode conservatively budgets directory entries, + // indirect metadata, and small-file filesystem overhead without assuming ext4 internals. + const requiredAllocationBytes = + fileAllocationBytes + objectCount * capacity.blockSize; + if ( + capacity.availableBytes < + requiredAllocationBytes + productionArtifactCapacityReserveBytes || + capacity.availableInodes < objectCount + productionArtifactCapacityReserveInodes + ) { + throw failure(); + } +} + +/** + * Re-admits the exact bytes held by one copy operation immediately before its first write. + * The outer delivery admission budgets the full release/runtime pair; this inner boundary + * prevents later source replacement or filesystem consumption from spending the reserve. + * @param destinationDirectory Existing private directory on the filesystem receiving the copy. + * @param inventory Exact logical files and new directories the caller is about to materialize. + * @param dependencies Injectable free-space measurement used only by focused tests. + */ +export async function assertProductionArtifactCopyCapacity( + destinationDirectory: string, + inventory: ProductionArtifactCopyInventory, + dependencies: Pick = {} +): Promise { + try { + const [canonical, status] = await Promise.all([ + realpath(destinationDirectory), + lstat(destinationDirectory, { bigint: true }), + ] as const); + if (canonical !== destinationDirectory || !validPrivateDirectory(status)) { + throw failure(); + } + const capacity = await ( + dependencies.availableCapacity ?? defaultAvailableCapacity + )(destinationDirectory); + assertFitsCapacity(capacity, inventory); + } catch { + throw failure(); + } +} + +async function existingManagedDirectory( + candidate: string, + expectedDevice: bigint +): Promise { + try { + const status = await lstat(candidate, { bigint: true }); + if ( + typeof process.getuid !== "function" || + !status.isDirectory() || + status.isSymbolicLink() || + status.uid !== BigInt(process.getuid()) || + status.dev !== expectedDevice || + ![0o500n, 0o700n].includes(status.mode & 0o7777n) + ) { + throw failure(); + } + return true; + } catch (error) { + if (errorCode(error) === "ENOENT") return false; + throw failure(); + } +} + +async function runtimeSourceBytes(sourceExecutable: string): Promise { + try { + if ( + !path.isAbsolute(sourceExecutable) || + sourceExecutable.includes("\0") || + path.resolve(sourceExecutable) !== sourceExecutable || + (await realpath(sourceExecutable)) !== sourceExecutable + ) { + throw failure(); + } + const status = await lstat(sourceExecutable, { bigint: true }); + if ( + typeof process.getuid !== "function" || + !status.isFile() || + status.isSymbolicLink() || + status.nlink !== 1n || + status.uid !== BigInt(process.getuid()) || + status.size <= 0n || + status.size > maximumRuntimeBytes || + (status.mode & 0o100n) === 0n + ) { + throw failure(); + } + return status.size; + } catch { + throw failure(); + } +} + +/** + * Refuses a new immutable copy unless the post-retention production filesystem can hold its + * conservatively rounded destination allocation, directory metadata, inode demand, and fixed + * operational reserves. Existing current/rollback artifacts are not charged twice because + * install and publication verify and reuse their immutable slots. + * @param lease Active deployment lease shared by retention, copy, and activation. + * @param paths Exact prepared production delivery roots. + * @param sourceReleaseRoot Canonical verified release source. + * @param sourceManifest Verified release manifest naming the final release/runtime slots. + * @param sourceExecutable Canonical Bun source that installation will copy when absent. + * @param dependencies Injectable free-space measurement used only by focused tests. + */ +export async function assertProductionArtifactCapacity( + lease: DashboardDeploymentLease, + paths: PreparedProductionDeliveryPaths, + sourceReleaseRoot: string, + sourceManifest: ReleaseManifest, + sourceExecutable: string, + dependencies: ProductionArtifactCapacityDependencies = {} +): Promise { + try { + if ( + lease.stateDirectory !== paths.stateDirectory || + paths.stateDirectory !== path.join(paths.productionDirectory, "state") || + paths.releasesDirectory !== + path.join(paths.productionDirectory, "releases") || + paths.runtimesDirectory !== + path.join(paths.productionDirectory, "runtimes") || + !commitShaPattern.test(sourceManifest.source.commitSha) || + !commitShaPattern.test(sourceManifest.runtime.revision) + ) { + throw failure(); + } + + const [ + production, + releases, + runtimes, + releaseRecords, + verifiedManifest, + runtimeBytes, + ] = await Promise.all([ + lstat(paths.productionDirectory, { bigint: true }), + lstat(paths.releasesDirectory, { bigint: true }), + lstat(paths.runtimesDirectory, { bigint: true }), + inventoryReleaseArtifactTree(sourceReleaseRoot), + (dependencies.verifySourceRelease ?? verifyReleaseArtifactIdentity)( + sourceReleaseRoot + ), + runtimeSourceBytes(sourceExecutable), + ] as const); + if ( + !validPrivateDirectory(production) || + !validPrivateDirectory(releases) || + !validPrivateDirectory(runtimes) || + releases.dev !== production.dev || + runtimes.dev !== production.dev || + JSON.stringify(verifiedManifest) !== JSON.stringify(sourceManifest) + ) { + throw failure(); + } + + const releaseExists = await existingManagedDirectory( + path.join(paths.releasesDirectory, sourceManifest.source.commitSha), + production.dev + ); + const runtimeExists = await existingManagedDirectory( + path.join(paths.runtimesDirectory, "bun", sourceManifest.runtime.revision), + production.dev + ); + if (releaseExists && runtimeExists) return; + + const bunRootExists = runtimeExists + ? true + : await existingManagedDirectory( + path.join(paths.runtimesDirectory, "bun"), + production.dev + ); + let runtimeDirectoryCount = 0n; + if (!runtimeExists) runtimeDirectoryCount = bunRootExists ? 1n : 2n; + const inventory: ProductionArtifactCopyInventory = Object.freeze({ + fileBytes: Object.freeze([ + ...(releaseExists + ? [] + : releaseRecords.map((record) => BigInt(record.bytes))), + ...(runtimeExists ? [] : [runtimeBytes]), + ]), + newDirectoryCount: + (releaseExists ? 0n : directoryCountForRelease(releaseRecords)) + + runtimeDirectoryCount, + }); + const capacity = await ( + dependencies.availableCapacity ?? defaultAvailableCapacity + )(paths.productionDirectory); + assertFitsCapacity(capacity, inventory); + } catch { + throw failure(); + } +} diff --git a/greenfield/scripts/delivery/productionArtifactRetention.test.ts b/greenfield/scripts/delivery/productionArtifactRetention.test.ts new file mode 100644 index 000000000..b283673c1 --- /dev/null +++ b/greenfield/scripts/delivery/productionArtifactRetention.test.ts @@ -0,0 +1,561 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + chmod, + mkdir, + mkdtemp, + readdir, + readFile, + realpath, + rename, + rm, + stat, + symlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { + createLocalReleaseFixture, + createProductionTargetFixture, + publishProductionDeliveryFixtures, +} from "../testSupport/productionDeliveryFixture.ts"; +import { rejectionError } from "../testSupport/rejection.ts"; +import { withDeploymentLease } from "./deploymentLease.ts"; +import { + retainProductionArtifacts, + type ProductionArtifactReference, + type ProductionArtifactRetentionDependencies, +} from "./productionArtifactRetention.ts"; +import { prepareProductionDeliveryDirectories } from "./productionDeliveryFilesystem.ts"; +import type { PublishedProductionRelease } from "./productionReleasePublication.ts"; +import type { InstalledProductionRuntime } from "./productionRuntime.ts"; +import { prepareProtectedProductionStatePath } from "./productionStateFilesystem.ts"; + +const temporaryDirectories: string[] = []; +const sourceProjectRoot = path.resolve(import.meta.dir, "../.."); +const releaseA = "a".repeat(40); +const releaseB = "b".repeat(40); +const releaseC = "c".repeat(40); +const runtimeA = "d".repeat(40); +const runtimeB = "e".repeat(40); +const runtimeC = "f".repeat(40); +const runtimeOrphan = "1".repeat(40); +const runtimeVersion = "1.4.0"; + +afterEach(async () => { + for (const directory of temporaryDirectories.splice(0)) { + await restoreOwnerWrite(directory); + await rm(directory, { force: true, recursive: true }); + } +}); + +async function restoreOwnerWrite(directory: string): Promise { + const status = await stat(directory).catch(() => null); + if (!status?.isDirectory()) return; + await chmod(directory, 0o700).catch(() => {}); + for (const entry of await readdir(directory, { withFileTypes: true })) { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + await restoreOwnerWrite(entryPath); + } else if (entry.isFile()) { + await chmod(entryPath, 0o600).catch(() => {}); + } + } +} + +async function projectFixture() { + const projectRoot = await mkdtemp( + path.join(tmpdir(), "mira-production-artifact-retention-") + ); + temporaryDirectories.push(projectRoot); + const state = await prepareProtectedProductionStatePath(projectRoot); + return { projectRoot, state }; +} + +async function immutableTree(directory: string, fileName: string): Promise { + await mkdir(directory, { mode: 0o700 }); + const file = path.join(directory, fileName); + await writeFile(file, "fixture", { mode: 0o600 }); + await chmod(file, fileName === "bun" ? 0o500 : 0o400); + await chmod(directory, 0o500); +} + +async function releaseFixture(releasesDirectory: string, releaseId: string) { + await immutableTree(path.join(releasesDirectory, releaseId), "artifact"); +} + +async function runtimeFixture(runtimesDirectory: string, revision: string) { + const bunRoot = path.join(runtimesDirectory, "bun"); + await mkdir(bunRoot, { mode: 0o700 }).catch(() => {}); + await immutableTree(path.join(bunRoot, revision), "bun"); +} + +function retentionDependencies( + releaseRuntimes: ReadonlyMap +): ProductionArtifactRetentionDependencies { + return { + verifyRelease: (paths, releaseId) => { + const revision = releaseRuntimes.get(releaseId); + if (!revision) return Promise.reject(new Error("unknown release")); + return Promise.resolve({ + manifest: { + runtime: { revision, version: runtimeVersion }, + source: { commitSha: releaseId }, + }, + releaseRoot: path.join(paths.releasesDirectory, releaseId), + } as unknown as PublishedProductionRelease); + }, + verifyRuntime: (paths, revision) => + Promise.resolve({ + executable: path.join(paths.runtimesDirectory, "bun", revision, "bun"), + identity: { revision, version: runtimeVersion }, + } satisfies InstalledProductionRuntime), + }; +} + +const protectedReferences: readonly ProductionArtifactReference[] = Object.freeze([ + { releaseId: releaseA, runtimeRevision: runtimeA }, + { releaseId: releaseB, runtimeRevision: runtimeB }, +]); + +describe("production release and runtime retention", () => { + test("uses real manifest and runtime verification before retiring an old release", async () => { + const runtimeIdentity = Object.freeze({ + revision: runtimeA, + version: runtimeVersion, + }); + const sourceReleases = await Promise.all([ + createLocalReleaseFixture( + sourceProjectRoot, + releaseA, + runtimeIdentity, + temporaryDirectories + ), + createLocalReleaseFixture( + sourceProjectRoot, + releaseB, + runtimeIdentity, + temporaryDirectories + ), + ] as const); + const { projectRoot, runtimeSource } = + await createProductionTargetFixture(temporaryDirectories); + const state = await prepareProtectedProductionStatePath(projectRoot); + + await withDeploymentLease(state.stateDirectory, async (lease) => { + const paths = await prepareProductionDeliveryDirectories(state); + const published = await publishProductionDeliveryFixtures( + lease, + paths, + sourceReleases, + runtimeSource, + runtimeIdentity + ); + + await retainProductionArtifacts( + lease, + paths, + [ + { + releaseId: published.first.manifest.source.commitSha, + runtimeRevision: runtimeIdentity.revision, + }, + ], + { + runtimeVerification: { + probeRuntime: published.probeRuntime, + }, + } + ); + + const retainedReleases = await readdir(paths.releasesDirectory); + expect(retainedReleases.toSorted()).toEqual([releaseA]); + expect(await readdir(path.join(paths.runtimesDirectory, "bun"))).toEqual([ + runtimeA, + ]); + }); + }); + + test("retains only active, rollback, and pointer-referenced immutable artifacts", async () => { + const { state } = await projectFixture(); + await withDeploymentLease(state.stateDirectory, async (lease) => { + const paths = await prepareProductionDeliveryDirectories(state); + for (const releaseId of [releaseA, releaseB, releaseC]) { + await releaseFixture(paths.releasesDirectory, releaseId); + } + for (const revision of [runtimeA, runtimeB, runtimeC, runtimeOrphan]) { + await runtimeFixture(paths.runtimesDirectory, revision); + } + await Promise.all([ + symlink(releaseA, path.join(paths.releasesDirectory, "current")), + symlink(runtimeA, path.join(paths.runtimesDirectory, "bun", "current")), + ]); + + await retainProductionArtifacts( + lease, + paths, + protectedReferences, + retentionDependencies( + new Map([ + [releaseA, runtimeA], + [releaseB, runtimeB], + [releaseC, runtimeC], + ]) + ) + ); + + const retainedReleases = await readdir(paths.releasesDirectory); + const retainedRuntimes = await readdir( + path.join(paths.runtimesDirectory, "bun") + ); + expect(retainedReleases.toSorted()).toEqual([releaseA, releaseB, "current"]); + expect(retainedRuntimes.toSorted()).toEqual(["current", runtimeA, runtimeB]); + }); + }); + + test("reaps crash-left pointer stages after both managed roots verify", async () => { + const { state } = await projectFixture(); + await withDeploymentLease(state.stateDirectory, async (lease) => { + const paths = await prepareProductionDeliveryDirectories(state); + await releaseFixture(paths.releasesDirectory, releaseC); + await runtimeFixture(paths.runtimesDirectory, runtimeC); + const releaseStage = `.current-${Bun.randomUUIDv7()}`; + const runtimeStage = `.current-${Bun.randomUUIDv7()}`; + await Promise.all([ + symlink(releaseC, path.join(paths.releasesDirectory, releaseStage)), + symlink( + runtimeC, + path.join(paths.runtimesDirectory, "bun", runtimeStage) + ), + ]); + + await retainProductionArtifacts( + lease, + paths, + [{ releaseId: releaseC, runtimeRevision: runtimeC }], + retentionDependencies(new Map([[releaseC, runtimeC]])) + ); + + expect(await readdir(paths.releasesDirectory)).toEqual([releaseC]); + expect(await readdir(path.join(paths.runtimesDirectory, "bun"))).toEqual([ + runtimeC, + ]); + }); + }); + + test("rejects an untrusted pointer stage before removing a valid stage", async () => { + const { state } = await projectFixture(); + await withDeploymentLease(state.stateDirectory, async (lease) => { + const paths = await prepareProductionDeliveryDirectories(state); + await releaseFixture(paths.releasesDirectory, releaseC); + await runtimeFixture(paths.runtimesDirectory, runtimeC); + const releaseStage = `.current-${Bun.randomUUIDv7()}`; + const runtimeStage = `.current-${Bun.randomUUIDv7()}`; + await Promise.all([ + symlink(releaseC, path.join(paths.releasesDirectory, releaseStage)), + symlink( + "../outside", + path.join(paths.runtimesDirectory, "bun", runtimeStage) + ), + ]); + + const failure = await rejectionError( + retainProductionArtifacts( + lease, + paths, + [{ releaseId: releaseC, runtimeRevision: runtimeC }], + retentionDependencies(new Map([[releaseC, runtimeC]])) + ) + ); + + expect(failure.message).toBe("Production artifact retention failed"); + expect(await readdir(paths.releasesDirectory)).toContain(releaseStage); + expect(await readdir(path.join(paths.runtimesDirectory, "bun"))).toContain( + runtimeStage + ); + }); + }); + + test("fails before pruning when the release root contains an unknown entry", async () => { + const { state } = await projectFixture(); + await withDeploymentLease(state.stateDirectory, async (lease) => { + const paths = await prepareProductionDeliveryDirectories(state); + await releaseFixture(paths.releasesDirectory, releaseC); + await writeFile(path.join(paths.releasesDirectory, "unexpected"), "fixture"); + + const failure = await rejectionError( + retainProductionArtifacts( + lease, + paths, + [], + retentionDependencies(new Map([[releaseC, runtimeC]])) + ) + ); + + expect(failure.message).toBe("Production artifact retention failed"); + expect(await readdir(path.join(paths.releasesDirectory, releaseC))).toEqual([ + "artifact", + ]); + }); + }); + + test("verifies the runtime root before pruning a valid stale release", async () => { + const { state } = await projectFixture(); + await withDeploymentLease(state.stateDirectory, async (lease) => { + const paths = await prepareProductionDeliveryDirectories(state); + await releaseFixture(paths.releasesDirectory, releaseC); + await runtimeFixture(paths.runtimesDirectory, runtimeC); + await writeFile( + path.join(paths.runtimesDirectory, "bun", "unexpected"), + "fixture" + ); + + const failure = await rejectionError( + retainProductionArtifacts( + lease, + paths, + [], + retentionDependencies(new Map([[releaseC, runtimeC]])) + ) + ); + + expect(failure.message).toBe("Production artifact retention failed"); + expect(await readdir(path.join(paths.releasesDirectory, releaseC))).toEqual([ + "artifact", + ]); + expect( + await readdir(path.join(paths.runtimesDirectory, "bun", runtimeC)) + ).toEqual(["bun"]); + }); + }); + + test("does not remove a replacement swapped into a selected release path", async () => { + const { state } = await projectFixture(); + await withDeploymentLease(state.stateDirectory, async (lease) => { + const paths = await prepareProductionDeliveryDirectories(state); + const stale = path.join(paths.releasesDirectory, releaseC); + const moved = path.join(paths.releasesDirectory, `.raced-${releaseC}`); + await releaseFixture(paths.releasesDirectory, releaseC); + await runtimeFixture(paths.runtimesDirectory, runtimeC); + const dependencies = retentionDependencies(new Map([[releaseC, runtimeC]])); + + const failure = await rejectionError( + retainProductionArtifacts(lease, paths, [], { + ...dependencies, + beforeEntryRetired: async (kind, identity) => { + expect([kind, identity]).toEqual(["release", releaseC]); + await rename(stale, moved); + await releaseFixture(paths.releasesDirectory, releaseC); + }, + }) + ); + + expect(failure.message).toBe("Production artifact retention failed"); + expect(await readdir(stale)).toEqual(["artifact"]); + }); + }); + + test("rejects a same-device nested mount before reaping any validated tree", async () => { + const { state } = await projectFixture(); + await withDeploymentLease(state.stateDirectory, async (lease) => { + const paths = await prepareProductionDeliveryDirectories(state); + const ordinaryRetired = path.join( + paths.releasesDirectory, + `.retire-${releaseB}` + ); + const mountedRetired = path.join( + paths.releasesDirectory, + `.retire-${releaseC}` + ); + const nestedMount = path.join(mountedRetired, "nested"); + await immutableTree(ordinaryRetired, "artifact"); + await mkdir(mountedRetired, { mode: 0o700 }); + await immutableTree(nestedMount, "artifact"); + await chmod(mountedRetired, 0o500); + const canonicalNestedMount = await realpath(nestedMount); + + const failure = await rejectionError( + retainProductionArtifacts(lease, paths, [], { + ...retentionDependencies(new Map()), + readMountId: async (fileDescriptor) => + (await realpath(`/proc/self/fd/${fileDescriptor}`)) === + canonicalNestedMount + ? 2n + : 1n, + }) + ); + + expect(failure.message).toBe("Production artifact retention failed"); + expect(await readdir(ordinaryRetired)).toEqual(["artifact"]); + expect(await readdir(nestedMount)).toEqual(["artifact"]); + }); + }); + + test("does not remove a replacement swapped in before a file is retired", async () => { + const { state } = await projectFixture(); + await withDeploymentLease(state.stateDirectory, async (lease) => { + const paths = await prepareProductionDeliveryDirectories(state); + const retiredTree = path.join(paths.releasesDirectory, `.retire-${releaseC}`); + const artifact = path.join(retiredTree, "artifact"); + const selected = path.join(retiredTree, "selected"); + await immutableTree(retiredTree, "artifact"); + + const failure = await rejectionError( + retainProductionArtifacts(lease, paths, [], { + ...retentionDependencies(new Map()), + beforeFileRetired: async (fileName) => { + expect(fileName).toBe("artifact"); + await rename(artifact, selected); + await writeFile(artifact, "replacement", { mode: 0o600 }); + await chmod(artifact, 0o400); + }, + }) + ); + + expect(failure.message).toBe("Production artifact retention failed"); + expect(await readFile(selected, "utf8")).toBe("fixture"); + expect(await readFile(artifact, "utf8")).toBe("replacement"); + }); + }); + + test("does not unlink a replacement swapped into the private file tombstone", async () => { + // This hook precedes the descriptor/path revalidation and proves observed drift fails + // closed. Linux has no later inode-conditional unlink; authorized same-UID writers are + // serialized by the deployment lease as documented by the production boundary. + const { state } = await projectFixture(); + await withDeploymentLease(state.stateDirectory, async (lease) => { + const paths = await prepareProductionDeliveryDirectories(state); + const retiredTree = path.join(paths.releasesDirectory, `.retire-${releaseC}`); + const selected = path.join(retiredTree, "selected"); + let replacementPath: string | undefined; + await immutableTree(retiredTree, "artifact"); + + const failure = await rejectionError( + retainProductionArtifacts(lease, paths, [], { + ...retentionDependencies(new Map()), + afterFileRetired: async (fileName, retiredName) => { + expect(fileName).toBe("artifact"); + replacementPath = path.join(retiredTree, retiredName); + await rename(replacementPath, selected); + await writeFile(replacementPath, "replacement", { + mode: 0o600, + }); + await chmod(replacementPath, 0o400); + }, + }) + ); + + expect(failure.message).toBe("Production artifact retention failed"); + expect(await readFile(selected, "utf8")).toBe("fixture"); + expect(replacementPath).toBeDefined(); + expect(await readFile(replacementPath!, "utf8")).toBe("replacement"); + }); + }); + + test("reaps bounded crash-left release and runtime retire trees", async () => { + const { state } = await projectFixture(); + await withDeploymentLease(state.stateDirectory, async (lease) => { + const paths = await prepareProductionDeliveryDirectories(state); + await immutableTree( + path.join(paths.releasesDirectory, `.retire-${releaseC}`), + "artifact" + ); + const bunRoot = path.join(paths.runtimesDirectory, "bun"); + await mkdir(bunRoot, { mode: 0o700 }); + await immutableTree(path.join(bunRoot, `.retire-${runtimeC}`), "bun"); + + await retainProductionArtifacts( + lease, + paths, + [], + retentionDependencies(new Map()) + ); + + expect(await readdir(paths.releasesDirectory)).toEqual([]); + expect(await readdir(bunRoot)).toEqual([]); + }); + }); + + test("reaps crash-retired twins after the same identities are published again", async () => { + const { state } = await projectFixture(); + await withDeploymentLease(state.stateDirectory, async (lease) => { + const paths = await prepareProductionDeliveryDirectories(state); + await Promise.all([ + releaseFixture(paths.releasesDirectory, releaseC), + immutableTree( + path.join(paths.releasesDirectory, `.retire-${releaseC}`), + "artifact" + ), + ]); + const bunRoot = path.join(paths.runtimesDirectory, "bun"); + await mkdir(bunRoot, { mode: 0o700 }); + await Promise.all([ + immutableTree(path.join(bunRoot, runtimeC), "bun"), + immutableTree(path.join(bunRoot, `.retire-${runtimeC}`), "bun"), + ]); + + await retainProductionArtifacts( + lease, + paths, + [{ releaseId: releaseC, runtimeRevision: runtimeC }], + retentionDependencies(new Map([[releaseC, runtimeC]])) + ); + + expect(await readdir(paths.releasesDirectory)).toEqual([releaseC]); + expect(await readdir(bunRoot)).toEqual([runtimeC]); + }); + }); + + test("rejects an undocumented previous pointer before pruning", async () => { + const { state } = await projectFixture(); + await withDeploymentLease(state.stateDirectory, async (lease) => { + const paths = await prepareProductionDeliveryDirectories(state); + await releaseFixture(paths.releasesDirectory, releaseC); + await runtimeFixture(paths.runtimesDirectory, runtimeC); + await symlink(releaseC, path.join(paths.releasesDirectory, "previous")); + + const failure = await rejectionError( + retainProductionArtifacts( + lease, + paths, + [{ releaseId: releaseC, runtimeRevision: runtimeC }], + retentionDependencies(new Map([[releaseC, runtimeC]])) + ) + ); + + expect(failure.message).toBe("Production artifact retention failed"); + expect(await readdir(path.join(paths.releasesDirectory, releaseC))).toEqual([ + "artifact", + ]); + }); + }); + + test("resumes after a crash immediately following the retire rename", async () => { + const { state } = await projectFixture(); + await withDeploymentLease(state.stateDirectory, async (lease) => { + const paths = await prepareProductionDeliveryDirectories(state); + await releaseFixture(paths.releasesDirectory, releaseC); + await runtimeFixture(paths.runtimesDirectory, runtimeC); + const dependencies = retentionDependencies(new Map([[releaseC, runtimeC]])); + + const failure = await rejectionError( + retainProductionArtifacts(lease, paths, [], { + ...dependencies, + afterEntryRetired: (kind) => { + if (kind === "release") throw new Error("simulated crash"); + }, + }) + ); + expect(failure.message).toBe("Production artifact retention failed"); + expect(await readdir(paths.releasesDirectory)).toEqual([ + `.retire-${releaseC}`, + ]); + + await retainProductionArtifacts(lease, paths, [], dependencies); + + expect(await readdir(paths.releasesDirectory)).toEqual([]); + expect(await readdir(path.join(paths.runtimesDirectory, "bun"))).toEqual([]); + }); + }); +}); diff --git a/greenfield/scripts/delivery/productionArtifactRetention.ts b/greenfield/scripts/delivery/productionArtifactRetention.ts new file mode 100644 index 000000000..c9a429a3e --- /dev/null +++ b/greenfield/scripts/delivery/productionArtifactRetention.ts @@ -0,0 +1,799 @@ +import { constants, type BigIntStats } from "node:fs"; +import { + lstat, + open, + readdir, + realpath, + readlink, + rename, + rmdir, + unlink, + type FileHandle, +} from "node:fs/promises"; +import path from "node:path"; + +import type { DashboardDeploymentLease } from "./deploymentLease.ts"; +import type { PreparedProductionDeliveryPaths } from "./productionDeliveryFilesystem.ts"; +import { + loadPublishedProductionReleaseById, + type PublishedProductionRelease, +} from "./productionReleasePublication.ts"; +import { + inspectInstalledProductionRuntime, + type InstalledProductionRuntime, + type ProductionRuntimeVerificationDependencies, +} from "./productionRuntime.ts"; + +const retentionFailureMessage = "Production artifact retention failed"; +const commitShaPattern = /^[a-f\d]{40}$/u; +const stageNamePattern = + /^\.stage-([a-f\d]{40})-([\da-f]{8}-(?:[\da-f]{4}-){3}[\da-f]{12})$/u; +const retiredNamePattern = /^\.retire-([a-f\d]{40})$/u; +const pointerStageNamePattern = + /^\.current-[a-f\d]{8}-[a-f\d]{4}-7[a-f\d]{3}-[89ab][a-f\d]{3}-[a-f\d]{12}$/u; +const maximumRootEntries = 128; +const maximumTreeEntries = 4608; +const maximumTreeDepth = 20; +const maximumDescriptorInfoBytes = 4096; +const privateDirectoryMode = 0o700; +const privateFileMode = 0o600; +const directoryFlags = + constants.O_RDONLY | + constants.O_DIRECTORY | + constants.O_NOFOLLOW | + constants.O_NONBLOCK; +const fileFlags = constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK; + +/** Release/runtime pair that must survive one retention pass. */ +export interface ProductionArtifactReference { + readonly releaseId: string; + readonly runtimeRevision: string; +} + +/** Read-only verification and race boundaries exposed to focused tests. */ +export interface ProductionArtifactRetentionDependencies { + readonly afterEntryRetired?: ( + kind: "release" | "runtime", + identity: string + ) => Promise | void; + readonly beforeEntryRetired?: ( + kind: "release" | "runtime", + identity: string + ) => Promise | void; + readonly afterFileRetired?: ( + fileName: string, + retiredName: string + ) => Promise | void; + readonly beforeFileRetired?: (fileName: string) => Promise | void; + readonly readMountId?: (fileDescriptor: number) => Promise; + readonly runtimeVerification?: ProductionRuntimeVerificationDependencies; + readonly verifyRelease?: ( + paths: PreparedProductionDeliveryPaths, + releaseId: string + ) => Promise; + readonly verifyRuntime?: ( + paths: PreparedProductionDeliveryPaths, + revision: string, + dependencies?: ProductionRuntimeVerificationDependencies + ) => Promise; +} + +interface OpenedRoot { + readonly device: bigint; + readonly handle: FileHandle; + readonly inode: bigint; + readonly mountId: bigint; + readonly path: string; + readonly readMountId: (fileDescriptor: number) => Promise; +} + +interface ManagedEntry { + readonly identity: string; + readonly inode: bigint; + readonly kind: "published" | "retired" | "stage"; + readonly name: string; +} + +interface ManagedPointerStage { + readonly inode: bigint; + readonly name: string; + readonly target: string; +} + +interface VerifiedInventory { + readonly entries: readonly ManagedEntry[]; + readonly pointerStages: readonly ManagedPointerStage[]; + readonly published: ReadonlyMap; +} + +function failure(): Error { + return new Error(retentionFailureMessage); +} + +function errorCode(error: unknown): string | undefined { + return error instanceof Error && "code" in error + ? (error as NodeJS.ErrnoException).code + : undefined; +} + +function validPrivateRoot(status: BigIntStats): boolean { + return ( + typeof process.getuid === "function" && + status.isDirectory() && + !status.isSymbolicLink() && + status.uid === BigInt(process.getuid()) && + (status.mode & 0o7777n) === 0o700n + ); +} + +function validManagedDirectory(status: BigIntStats, device: bigint): boolean { + return ( + typeof process.getuid === "function" && + status.isDirectory() && + !status.isSymbolicLink() && + status.uid === BigInt(process.getuid()) && + status.dev === device && + [0o500n, 0o700n].includes(status.mode & 0o7777n) + ); +} + +function validManagedFile(status: BigIntStats, device: bigint): boolean { + return ( + typeof process.getuid === "function" && + status.isFile() && + !status.isSymbolicLink() && + status.nlink === 1n && + status.uid === BigInt(process.getuid()) && + status.dev === device && + [0o400n, 0o500n, 0o600n].includes(status.mode & 0o7777n) + ); +} + +function validManagedPointer(status: BigIntStats, device: bigint): boolean { + return ( + typeof process.getuid === "function" && + status.isSymbolicLink() && + status.nlink === 1n && + status.uid === BigInt(process.getuid()) && + status.dev === device + ); +} + +async function closeHandle(handle: FileHandle | undefined): Promise { + if (handle === undefined) return true; + try { + await handle.close(); + return true; + } catch { + return false; + } +} + +async function readDescriptorMountId(fileDescriptor: number): Promise { + try { + const text = await Bun.file(`/proc/self/fdinfo/${fileDescriptor}`).text(); + if (text.length <= 0 || text.length > maximumDescriptorInfoBytes) throw failure(); + const matches = [...text.matchAll(/^mnt_id:\s*(\d+)$/gmu)]; + if (matches.length !== 1 || !matches[0]?.[1]) throw failure(); + const mountId = BigInt(matches[0][1]); + if (mountId <= 0n) throw failure(); + return mountId; + } catch { + throw failure(); + } +} + +async function openPrivateRoot( + directory: string, + readMountId: (fileDescriptor: number) => Promise = readDescriptorMountId +): Promise { + if (process.platform !== "linux") throw failure(); + let handle: FileHandle | undefined; + try { + handle = await open(directory, directoryFlags); + const [held, named, canonical, mountId] = await Promise.all([ + handle.stat({ bigint: true }), + lstat(directory, { bigint: true }), + realpath(`/proc/self/fd/${handle.fd}`), + readMountId(handle.fd), + ]); + if ( + canonical !== directory || + !validPrivateRoot(held) || + !validPrivateRoot(named) || + named.dev !== held.dev || + named.ino !== held.ino + ) { + throw failure(); + } + return Object.freeze({ + device: held.dev, + handle, + inode: held.ino, + mountId, + path: directory, + readMountId, + }); + } catch { + await closeHandle(handle); + throw failure(); + } +} + +async function revalidateRoot(root: OpenedRoot): Promise { + const [held, named, canonical, mountId] = await Promise.all([ + root.handle.stat({ bigint: true }), + lstat(root.path, { bigint: true }), + realpath(`/proc/self/fd/${root.handle.fd}`), + root.readMountId(root.handle.fd), + ]); + if ( + canonical !== root.path || + !validPrivateRoot(held) || + !validPrivateRoot(named) || + held.dev !== root.device || + held.ino !== root.inode || + named.dev !== root.device || + named.ino !== root.inode || + mountId !== root.mountId + ) { + throw failure(); + } +} + +function parseManagedEntry(name: string): Pick | null { + if (commitShaPattern.test(name)) return { identity: name, kind: "published" }; + const retired = retiredNamePattern.exec(name); + if (retired?.[1]) return { identity: retired[1], kind: "retired" }; + const stage = stageNamePattern.exec(name); + if (stage?.[1]) return { identity: stage[1], kind: "stage" }; + return null; +} + +async function validateManagedTree(root: OpenedRoot, entry: ManagedEntry): Promise { + let entryCount = 0; + const inspect = async ( + directory: string, + expectedInode: bigint, + depth: number + ): Promise => { + if (depth > maximumTreeDepth) throw failure(); + const opened = await openManagedDirectory(root, directory, expectedInode); + let failed = false; + try { + const descriptor = `/proc/self/fd/${opened.handle.fd}`; + for (const child of await readdir(descriptor, { withFileTypes: true })) { + entryCount += 1; + if (entryCount > maximumTreeEntries) throw failure(); + const childPath = path.join(descriptor, child.name); + const childStatus = await lstat(childPath, { bigint: true }); + if ( + typeof process.getuid !== "function" || + childStatus.isSymbolicLink() || + childStatus.uid !== BigInt(process.getuid()) || + childStatus.dev !== root.device + ) { + throw failure(); + } + if (childStatus.isDirectory()) { + await inspect(childPath, childStatus.ino, depth + 1); + } else if (!validManagedFile(childStatus, root.device)) { + throw failure(); + } + } + } catch { + failed = true; + } + if (!(await closeHandle(opened.handle)) || failed) throw failure(); + }; + const entryPath = path.join(`/proc/self/fd/${root.handle.fd}`, entry.name); + await inspect(entryPath, entry.inode, 0); + const after = await lstat(entryPath, { bigint: true }); + if (after.dev !== root.device || after.ino !== entry.inode) throw failure(); +} + +async function inventoryRoot( + root: OpenedRoot, + protectedIdentities: ReadonlySet +): Promise { + const directoryEntries = await readdir(`/proc/self/fd/${root.handle.fd}`, { + withFileTypes: true, + }); + if (directoryEntries.length > maximumRootEntries) throw failure(); + const entries: ManagedEntry[] = []; + const pointerStages: ManagedPointerStage[] = []; + const published = new Map(); + for (const directoryEntry of directoryEntries) { + if (directoryEntry.name.startsWith(".current-")) { + if ( + !pointerStageNamePattern.test(directoryEntry.name) || + !directoryEntry.isSymbolicLink() + ) { + throw failure(); + } + const pointerPath = path.join( + `/proc/self/fd/${root.handle.fd}`, + directoryEntry.name + ); + const before = await lstat(pointerPath, { bigint: true }); + const target = await readlink(pointerPath); + const after = await lstat(pointerPath, { bigint: true }); + if ( + !validManagedPointer(before, root.device) || + !validManagedPointer(after, root.device) || + after.ino !== before.ino || + !commitShaPattern.test(target) + ) { + throw failure(); + } + pointerStages.push( + Object.freeze({ inode: before.ino, name: directoryEntry.name, target }) + ); + continue; + } + if (directoryEntry.name === "current") { + if (!directoryEntry.isSymbolicLink()) throw failure(); + const pointerPath = path.join( + `/proc/self/fd/${root.handle.fd}`, + directoryEntry.name + ); + const [status, target] = await Promise.all([ + lstat(pointerPath, { bigint: true }), + readlink(pointerPath), + ]); + if ( + !validManagedPointer(status, root.device) || + !commitShaPattern.test(target) || + !protectedIdentities.has(target) + ) { + throw failure(); + } + continue; + } + const parsed = parseManagedEntry(directoryEntry.name); + if (!directoryEntry.isDirectory() || parsed === null) throw failure(); + const status = await lstat( + path.join(`/proc/self/fd/${root.handle.fd}`, directoryEntry.name), + { bigint: true } + ); + if (!validManagedDirectory(status, root.device)) throw failure(); + const entry = Object.freeze({ + ...parsed, + inode: status.ino, + name: directoryEntry.name, + }); + if (entry.kind === "published") { + if (published.has(entry.identity)) throw failure(); + published.set(entry.identity, entry); + } + entries.push(entry); + } + for (const entry of entries.filter(({ kind }) => kind !== "published")) { + await validateManagedTree(root, entry); + } + return Object.freeze({ + entries: Object.freeze(entries), + pointerStages: Object.freeze(pointerStages), + published, + }); +} + +async function reapPointerStage( + root: OpenedRoot, + stage: ManagedPointerStage +): Promise { + const pointerPath = path.join(`/proc/self/fd/${root.handle.fd}`, stage.name); + const before = await lstat(pointerPath, { bigint: true }); + const target = await readlink(pointerPath); + const after = await lstat(pointerPath, { bigint: true }); + if ( + !validManagedPointer(before, root.device) || + !validManagedPointer(after, root.device) || + before.ino !== stage.inode || + after.ino !== stage.inode || + target !== stage.target + ) { + throw failure(); + } + await unlink(pointerPath); + await root.handle.sync(); + await revalidateRoot(root); +} + +async function openManagedDirectory( + root: OpenedRoot, + directory: string, + expectedInode?: bigint +): Promise<{ readonly handle: FileHandle; readonly inode: bigint }> { + let handle: FileHandle | undefined; + try { + handle = await open(directory, directoryFlags); + const [held, named, mountId] = await Promise.all([ + handle.stat({ bigint: true }), + lstat(directory, { bigint: true }), + root.readMountId(handle.fd), + ]); + if ( + !validManagedDirectory(held, root.device) || + !validManagedDirectory(named, root.device) || + held.ino !== named.ino || + mountId !== root.mountId || + (expectedInode !== undefined && held.ino !== expectedInode) + ) { + throw failure(); + } + return Object.freeze({ handle, inode: held.ino }); + } catch { + await closeHandle(handle); + throw failure(); + } +} + +async function retireAndUnlinkManagedFile( + root: OpenedRoot, + directoryHandle: FileHandle, + fileName: string, + expectedInode: bigint, + dependencies: ProductionArtifactRetentionDependencies +): Promise { + const descriptor = `/proc/self/fd/${directoryHandle.fd}`; + const source = path.join(descriptor, fileName); + const retiredName = `.reap-${Bun.randomUUIDv7()}`; + const retired = path.join(descriptor, retiredName); + let handle: FileHandle | undefined; + let failed = false; + try { + await dependencies.beforeFileRetired?.(fileName); + handle = await open(source, fileFlags); + const [held, named] = await Promise.all([ + handle.stat({ bigint: true }), + lstat(source, { bigint: true }), + ]); + if ( + !validManagedFile(held, root.device) || + !validManagedFile(named, root.device) || + held.ino !== named.ino || + held.ino !== expectedInode + ) { + throw failure(); + } + try { + await lstat(retired); + throw failure(); + } catch (error) { + if (errorCode(error) !== "ENOENT") throw failure(); + } + await rename(source, retired); + await directoryHandle.sync(); + await dependencies.afterFileRetired?.(fileName, retiredName); + const [heldAtRetiredName, namedAtRetiredName] = await Promise.all([ + handle.stat({ bigint: true }), + lstat(retired, { bigint: true }), + ]); + if ( + !validManagedFile(heldAtRetiredName, root.device) || + !validManagedFile(namedAtRetiredName, root.device) || + heldAtRetiredName.ino !== expectedInode || + namedAtRetiredName.ino !== expectedInode + ) { + throw failure(); + } + try { + await lstat(source); + throw failure(); + } catch (error) { + if (errorCode(error) !== "ENOENT") throw failure(); + } + await handle.chmod(privateFileMode); + const after = await handle.stat({ bigint: true }); + if ( + !validManagedFile(after, root.device) || + after.ino !== held.ino || + (after.mode & 0o7777n) !== 0o600n + ) { + throw failure(); + } + const current = await lstat(retired, { bigint: true }); + if (current.dev !== held.dev || current.ino !== held.ino) throw failure(); + // Linux has no inode-conditional unlink primitive: the final operation is + // necessarily pathname based. The deployment lease serializes every authorized + // mutation by this trusted application UID. The descriptor/inode checks above + // fail closed for drift observed before unlink, but a malicious concurrent + // same-UID process is outside this boundary because it can already rewrite the + // complete application-owned production namespace. Closing that boundary + // requires the planned root-owned release handoff and a different-principal GC. + await unlink(retired); + await directoryHandle.sync(); + } catch { + failed = true; + } + if (!(await closeHandle(handle)) || failed) throw failure(); +} + +async function emptyManagedDirectory( + root: OpenedRoot, + directory: string, + expectedInode: bigint, + depth: number, + observedEntries: { count: number }, + dependencies: ProductionArtifactRetentionDependencies +): Promise { + if (depth > maximumTreeDepth) throw failure(); + const opened = await openManagedDirectory(root, directory, expectedInode); + let failed = false; + try { + await opened.handle.chmod(privateDirectoryMode); + const held = await opened.handle.stat({ bigint: true }); + if ( + !validManagedDirectory(held, root.device) || + held.ino !== opened.inode || + (held.mode & 0o7777n) !== 0o700n + ) { + throw failure(); + } + const descriptor = `/proc/self/fd/${opened.handle.fd}`; + for (const child of await readdir(descriptor, { withFileTypes: true })) { + observedEntries.count += 1; + if (observedEntries.count > maximumTreeEntries) throw failure(); + const childPath = path.join(descriptor, child.name); + const childStatus = await lstat(childPath, { bigint: true }); + if (validManagedDirectory(childStatus, root.device)) { + await emptyManagedDirectory( + root, + childPath, + childStatus.ino, + depth + 1, + observedEntries, + dependencies + ); + const current = await lstat(childPath, { bigint: true }); + if ( + !validManagedDirectory(current, root.device) || + current.ino !== childStatus.ino || + (current.mode & 0o7777n) !== 0o700n + ) { + throw failure(); + } + await rmdir(childPath); + } else if (validManagedFile(childStatus, root.device)) { + await retireAndUnlinkManagedFile( + root, + opened.handle, + child.name, + childStatus.ino, + dependencies + ); + } else { + throw failure(); + } + } + const remainingEntries = await readdir(descriptor); + if (remainingEntries.length > 0) throw failure(); + await opened.handle.sync(); + } catch { + failed = true; + } + if (!(await closeHandle(opened.handle)) || failed) throw failure(); +} + +async function reapEntry( + root: OpenedRoot, + entry: ManagedEntry, + dependencies: ProductionArtifactRetentionDependencies +): Promise { + await validateManagedTree(root, entry); + const entryPath = path.join(`/proc/self/fd/${root.handle.fd}`, entry.name); + await emptyManagedDirectory( + root, + entryPath, + entry.inode, + 0, + { count: 0 }, + dependencies + ); + const named = await lstat(entryPath, { bigint: true }); + if ( + !validManagedDirectory(named, root.device) || + named.ino !== entry.inode || + (named.mode & 0o7777n) !== 0o700n + ) { + throw failure(); + } + await rmdir(entryPath); + await root.handle.sync(); + await revalidateRoot(root); +} + +async function retireEntry( + root: OpenedRoot, + entry: ManagedEntry, + artifactKind: "release" | "runtime", + dependencies: ProductionArtifactRetentionDependencies +): Promise { + if (entry.kind !== "published") throw failure(); + const source = path.join(`/proc/self/fd/${root.handle.fd}`, entry.name); + const retiredName = `.retire-${entry.identity}`; + const target = path.join(`/proc/self/fd/${root.handle.fd}`, retiredName); + await dependencies.beforeEntryRetired?.(artifactKind, entry.identity); + const before = await lstat(source, { bigint: true }); + if (before.dev !== root.device || before.ino !== entry.inode) throw failure(); + await rename(source, target); + const retired = await lstat(target, { bigint: true }); + if (retired.dev !== root.device || retired.ino !== entry.inode) throw failure(); + await root.handle.sync(); + await revalidateRoot(root); + await dependencies.afterEntryRetired?.(artifactKind, entry.identity); + return Object.freeze({ ...entry, kind: "retired", name: retiredName }); +} + +function validateReferences( + lease: DashboardDeploymentLease, + paths: PreparedProductionDeliveryPaths, + references: readonly ProductionArtifactReference[] +): void { + if ( + lease.stateDirectory !== paths.stateDirectory || + paths.releasesDirectory !== path.join(paths.productionDirectory, "releases") || + paths.runtimesDirectory !== path.join(paths.productionDirectory, "runtimes") || + references.length > 3 || + references.some( + ({ releaseId, runtimeRevision }) => + !commitShaPattern.test(releaseId) || + !commitShaPattern.test(runtimeRevision) + ) + ) { + throw failure(); + } + const releaseIds = new Set(references.map(({ releaseId }) => releaseId)); + if (releaseIds.size !== references.length) throw failure(); +} + +async function openOptionalBunRoot( + paths: PreparedProductionDeliveryPaths, + references: readonly ProductionArtifactReference[], + readMountId: (fileDescriptor: number) => Promise +): Promise { + const bunRoot = path.join(paths.runtimesDirectory, "bun"); + try { + await lstat(bunRoot); + } catch (error) { + if (errorCode(error) === "ENOENT" && references.length === 0) return undefined; + throw failure(); + } + return openPrivateRoot(bunRoot, readMountId); +} + +/** + * Prunes every unreferenced immutable release and Bun runtime under the deployment lease. + * The active, rollback, and candidate pairs are always retained; root inventories are bounded + * and completely verified before the first rename. This protects against untrusted filesystem + * shapes and accidental path/rename races. All authorized delivery mutations by the trusted + * application UID must hold the exact deployment lease; hostile concurrent mutation by that UID + * requires the planned root-owned handoff and is outside the current application-owned boundary. + */ +export async function retainProductionArtifacts( + lease: DashboardDeploymentLease, + paths: PreparedProductionDeliveryPaths, + references: readonly ProductionArtifactReference[], + dependencies: ProductionArtifactRetentionDependencies = {} +): Promise { + validateReferences(lease, paths, references); + const protectedReleaseIds = new Set(references.map(({ releaseId }) => releaseId)); + const protectedRuntimeRevisions = new Set( + references.map(({ runtimeRevision }) => runtimeRevision) + ); + const verifyRelease = + dependencies.verifyRelease ?? loadPublishedProductionReleaseById; + const verifyRuntime = dependencies.verifyRuntime ?? inspectInstalledProductionRuntime; + const readMountId = dependencies.readMountId ?? readDescriptorMountId; + const releases = await openPrivateRoot(paths.releasesDirectory, readMountId); + let runtimes: OpenedRoot | undefined; + let failed = false; + try { + runtimes = await openOptionalBunRoot(paths, references, readMountId); + const releaseInventory = await inventoryRoot(releases, protectedReleaseIds); + const runtimeInventory: VerifiedInventory = runtimes + ? await inventoryRoot(runtimes, protectedRuntimeRevisions) + : Object.freeze({ + entries: Object.freeze([]), + pointerStages: Object.freeze([]), + published: new Map(), + }); + + const verifiedReleases = new Map(); + for (const [releaseId, entry] of releaseInventory.published) { + await validateManagedTree(releases, entry); + const release = await verifyRelease(paths, releaseId); + if ( + release.releaseRoot !== path.join(paths.releasesDirectory, releaseId) || + release.manifest.source.commitSha !== releaseId + ) { + throw failure(); + } + verifiedReleases.set(releaseId, release); + } + for (const reference of references) { + const release = verifiedReleases.get(reference.releaseId); + if (release?.manifest.runtime.revision !== reference.runtimeRevision) { + throw failure(); + } + } + + const verifiedRuntimes = new Map(); + for (const [revision, entry] of runtimeInventory.published) { + if (!runtimes) throw failure(); + await validateManagedTree(runtimes, entry); + const runtime = await verifyRuntime( + paths, + revision, + dependencies.runtimeVerification + ); + if ( + runtime.identity.revision !== revision || + runtime.executable !== + path.join(paths.runtimesDirectory, "bun", revision, "bun") + ) { + throw failure(); + } + verifiedRuntimes.set(revision, runtime); + } + for (const release of verifiedReleases.values()) { + const runtime = verifiedRuntimes.get(release.manifest.runtime.revision); + if ( + !runtime || + release.manifest.runtime.version !== runtime.identity.version + ) { + throw failure(); + } + } + + for (const pointerStage of releaseInventory.pointerStages) { + await reapPointerStage(releases, pointerStage); + } + if (runtimes) { + for (const pointerStage of runtimeInventory.pointerStages) { + await reapPointerStage(runtimes, pointerStage); + } + } + + for (const entry of releaseInventory.entries.filter( + ({ kind }) => kind !== "published" + )) { + await reapEntry(releases, entry, dependencies); + } + for (const entry of releaseInventory.entries.filter( + ({ identity, kind }) => + kind === "published" && !protectedReleaseIds.has(identity) + )) { + await reapEntry( + releases, + await retireEntry(releases, entry, "release", dependencies), + dependencies + ); + } + if (runtimes) { + for (const entry of runtimeInventory.entries.filter( + ({ kind }) => kind !== "published" + )) { + await reapEntry(runtimes, entry, dependencies); + } + for (const entry of runtimeInventory.entries.filter( + ({ identity, kind }) => + kind === "published" && !protectedRuntimeRevisions.has(identity) + )) { + await reapEntry( + runtimes, + await retireEntry(runtimes, entry, "runtime", dependencies), + dependencies + ); + } + } + } catch { + failed = true; + } + const [releasesClosed, runtimesClosed] = await Promise.all([ + closeHandle(releases.handle), + closeHandle(runtimes?.handle), + ]); + if (failed || !releasesClosed || !runtimesClosed) throw failure(); +} diff --git a/greenfield/scripts/delivery/productionReleaseActivation.test.ts b/greenfield/scripts/delivery/productionReleaseActivation.test.ts index 16c24f6e9..a7607776d 100644 --- a/greenfield/scripts/delivery/productionReleaseActivation.test.ts +++ b/greenfield/scripts/delivery/productionReleaseActivation.test.ts @@ -8,11 +8,22 @@ import { setDefaultTimeout, test, } from "bun:test"; -import { lstat, readdir } from "node:fs/promises"; +import { + cp, + lstat, + mkdir, + mkdtemp, + readdir, + readlink, + unlink, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; import path from "node:path"; import { Effect } from "effect"; +import type { ProductionActivationRecord } from "../../src/shared/productionActivationRecord.ts"; import { parseProductionActivationTransition } from "../../src/shared/productionActivationTransition.ts"; import { createLocalReleaseFixture, @@ -43,6 +54,7 @@ import { commitProductionActivationState, loadProductionActivationState, } from "./productionActivationState.ts"; +import type { ProductionArtifactReference } from "./productionArtifactRetention.ts"; import { prepareProductionDeliveryDirectories } from "./productionDeliveryFilesystem.ts"; import { activatePublishedProductionRelease, @@ -50,6 +62,7 @@ import { type ProductionReleaseActivationTestHooks, } from "./productionReleaseActivation.ts"; import { type PublishedProductionRelease } from "./productionReleasePublication.ts"; +import { pointProductionProcessesAtRelease } from "./productionRuntimePointers.ts"; import { prepareProtectedProductionStatePath } from "./productionStateFilesystem.ts"; import type { ReleaseRuntimeIdentity } from "./releaseIdentity.ts"; @@ -63,6 +76,15 @@ const runtimeIdentity: ReleaseRuntimeIdentity = Object.freeze({ const releaseFixtureDirectories: string[] = []; const temporaryDirectories: string[] = []; let sharedSourceReleases: readonly [string, string] | undefined; +let sharedPublishedFixture: + | Readonly<{ + activatedProjectRoot: string; + initialActivation: ProductionActivationRecord; + firstManifest: PublishedProductionRelease["manifest"]; + projectRoot: string; + secondManifest: PublishedProductionRelease["manifest"]; + }> + | undefined; setDefaultTimeout(15_000); @@ -81,6 +103,52 @@ beforeAll(async () => { releaseFixtureDirectories ), ]); + const { projectRoot, runtimeSource } = await createProductionTargetFixture( + releaseFixtureDirectories + ); + const state = await prepareProtectedProductionStatePath(projectRoot); + sharedPublishedFixture = await withDeploymentLease( + state.stateDirectory, + async (lease) => { + const paths = await prepareProductionDeliveryDirectories(state); + const published = await publishProductionDeliveryFixtures( + lease, + paths, + sourceReleaseFixtures(), + runtimeSource, + runtimeIdentity + ); + const pristineProjectRoot = await mkdtemp( + path.join(tmpdir(), "mira-release-activation-published-") + ); + releaseFixtureDirectories.push(pristineProjectRoot); + await cp( + path.join(projectRoot, "production"), + path.join(pristineProjectRoot, "production"), + { recursive: true } + ); + await unlink( + path.join(pristineProjectRoot, "production/state/.deployment.lock") + ); + const services = new TestServiceController(); + const initialActivation = await Effect.runPromise( + activatePublishedProductionRelease( + lease, + paths, + published.first, + published.runtime, + activationDependencies(services, published.probeRuntime) + ) + ); + return Object.freeze({ + activatedProjectRoot: projectRoot, + firstManifest: published.first.manifest, + initialActivation, + projectRoot: pristineProjectRoot, + secondManifest: published.second.manifest, + }); + } + ); }); afterEach(async () => { @@ -98,13 +166,42 @@ function sourceReleaseFixtures(): readonly [string, string] { return sharedSourceReleases; } -function createProjectFixture() { - return createProductionTargetFixture(temporaryDirectories); +function publishedFixtureSource() { + if (sharedPublishedFixture === undefined) { + throw new Error("Published production fixture is not initialized"); + } + return sharedPublishedFixture; +} + +async function createProjectFixture(initiallyActivated = true): Promise { + const fixture = publishedFixtureSource(); + const projectRoot = await mkdtemp( + path.join(tmpdir(), "mira-release-activation-target-") + ); + temporaryDirectories.push(projectRoot); + await cp( + path.join( + initiallyActivated ? fixture.activatedProjectRoot : fixture.projectRoot, + "production" + ), + path.join(projectRoot, "production"), + { recursive: true } + ); + return projectRoot; +} + +function initialActivationFixture() { + return publishedFixtureSource().initialActivation; } class TestServiceController implements ProductionServiceController { readonly events: string[] = []; - onStart: ((release: PublishedProductionRelease) => Promise | void) | undefined; + onStart: + | (( + release: PublishedProductionRelease, + runtime: Parameters[1] + ) => Promise | void) + | undefined; rejectReadyReleaseId: string | undefined; rejectStartReleaseId: string | undefined; @@ -113,10 +210,13 @@ class TestServiceController implements ProductionServiceController { return Promise.resolve(); } - async start(release: PublishedProductionRelease): Promise { + async start( + release: PublishedProductionRelease, + runtime: Parameters[1] + ): Promise { const releaseId = release.manifest.source.commitSha; this.events.push(`start:${releaseId}`); - await this.onStart?.(release); + await this.onStart?.(release, runtime); if (releaseId === this.rejectStartReleaseId) { throw new Error("candidate partially started"); } @@ -139,9 +239,19 @@ class TestServiceController implements ProductionServiceController { function activationDependencies( services: TestServiceController, probeRuntime: () => Promise, - testHooks?: ProductionReleaseActivationTestHooks + testHooks?: ProductionReleaseActivationTestHooks, + observeRetention?: ( + references: readonly ProductionArtifactReference[] + ) => Promise | void ) { return Object.freeze({ + artifactRetention: async ( + _lease: unknown, + _paths: unknown, + references: readonly ProductionArtifactReference[] + ) => { + await observeRetention?.(references); + }, maintenance: { execute: executeDatabaseMaintenanceFixture, runtimeVerification: { probeRuntime }, @@ -152,19 +262,31 @@ function activationDependencies( }); } -function publishFixtures( - lease: Parameters[0], - paths: Parameters[1], - sourceReleases: readonly [string, string], - runtimeSource: string +function clonedPublishedFixtures( + paths: Parameters[1] ) { - return publishProductionDeliveryFixtures( - lease, - paths, - sourceReleases, - runtimeSource, - runtimeIdentity - ); + const fixture = publishedFixtureSource(); + const probeRuntime = () => Promise.resolve(runtimeIdentity); + return Object.freeze({ + first: Object.freeze({ + manifest: fixture.firstManifest, + releaseRoot: path.join(paths.releasesDirectory, firstReleaseId), + }), + probeRuntime, + runtime: Object.freeze({ + executable: path.join( + paths.runtimesDirectory, + "bun", + runtimeIdentity.revision, + "bun" + ), + identity: runtimeIdentity, + }), + second: Object.freeze({ + manifest: fixture.secondManifest, + releaseRoot: path.join(paths.releasesDirectory, secondReleaseId), + }), + }); } function readMigrationReleaseId(databaseFile: string): string { @@ -184,18 +306,13 @@ function readMigrationReleaseId(databaseFile: string): string { describe("production release activation", () => { test("commits initial and upgraded release/database pairs under one lease", async () => { - const sourceReleases = sourceReleaseFixtures(); - const { projectRoot, runtimeSource } = await createProjectFixture(); + const projectRoot = await createProjectFixture(false); const state = await prepareProtectedProductionStatePath(projectRoot); await withDeploymentLease(state.stateDirectory, async (lease) => { const paths = await prepareProductionDeliveryDirectories(state); - const fixtures = await publishFixtures( - lease, - paths, - sourceReleases, - runtimeSource - ); + const fixtures = clonedPublishedFixtures(paths); const services = new TestServiceController(); + const retentionReferences: ProductionArtifactReference[][] = []; const authoritativeAtStart: string[] = []; services.onStart = async (release) => { const observed = await loadProductionActivationState(lease, paths); @@ -203,7 +320,14 @@ describe("production release activation", () => { expect(observed.record?.current.releaseId).toBe(releaseId); authoritativeAtStart.push(releaseId); }; - const dependencies = activationDependencies(services, fixtures.probeRuntime); + const dependencies = activationDependencies( + services, + fixtures.probeRuntime, + undefined, + (references) => { + retentionReferences.push([...references]); + } + ); const initial = await Effect.runPromise( activatePublishedProductionRelease( lease, @@ -249,6 +373,40 @@ describe("production release activation", () => { `ready:${secondReleaseId}`, ]); expect(authoritativeAtStart).toEqual([firstReleaseId, secondReleaseId]); + expect(retentionReferences).toEqual([ + [ + { + releaseId: firstReleaseId, + runtimeRevision: runtimeIdentity.revision, + }, + ], + [ + { + releaseId: firstReleaseId, + runtimeRevision: runtimeIdentity.revision, + }, + ], + [ + { + releaseId: firstReleaseId, + runtimeRevision: runtimeIdentity.revision, + }, + { + releaseId: secondReleaseId, + runtimeRevision: runtimeIdentity.revision, + }, + ], + [ + { + releaseId: secondReleaseId, + runtimeRevision: runtimeIdentity.revision, + }, + { + releaseId: firstReleaseId, + runtimeRevision: runtimeIdentity.revision, + }, + ], + ]); const activation = await loadProductionActivationState(lease, paths); const stateEntries = await readdir(paths.stateDirectory); expect(activation.record).toEqual(upgraded); @@ -259,29 +417,88 @@ describe("production release activation", () => { }); }); + test("clears first-activation pointers after start or readiness failure", async () => { + for (const failureBoundary of ["start", "readiness"] as const) { + const projectRoot = await createProjectFixture(false); + const state = await prepareProtectedProductionStatePath(projectRoot); + await withDeploymentLease(state.stateDirectory, async (lease) => { + const paths = await prepareProductionDeliveryDirectories(state); + const fixtures = clonedPublishedFixtures(paths); + const services = new TestServiceController(); + services.onStart = (release, runtime) => + pointProductionProcessesAtRelease(lease, paths, release, runtime); + if (failureBoundary === "start") { + services.rejectStartReleaseId = firstReleaseId; + } else { + services.rejectReadyReleaseId = firstReleaseId; + } + + const failure = await rejectionError( + Effect.runPromise( + activatePublishedProductionRelease( + lease, + paths, + fixtures.first, + fixtures.runtime, + activationDependencies(services, fixtures.probeRuntime) + ) + ) + ); + + expect(failure.message).toBe("Production release activation failed"); + const rolledBack = await loadProductionActivationState(lease, paths); + expect(rolledBack.record).toBeUndefined(); + expect(await readdir(paths.releasesDirectory)).not.toContain("current"); + expect( + await readdir(path.join(paths.runtimesDirectory, "bun")) + ).not.toContain("current"); + + services.rejectStartReleaseId = undefined; + services.rejectReadyReleaseId = undefined; + const activated = await Effect.runPromise( + activatePublishedProductionRelease( + lease, + paths, + fixtures.second, + fixtures.runtime, + { + maintenance: { + execute: executeDatabaseMaintenanceFixture, + runtimeVerification: { + probeRuntime: fixtures.probeRuntime, + }, + }, + runtimeVerification: { + probeRuntime: fixtures.probeRuntime, + }, + services, + } + ) + ); + + expect(activated.current.releaseId).toBe(secondReleaseId); + expect( + await readlink(path.join(paths.releasesDirectory, "current")) + ).toBe(secondReleaseId); + expect( + await readlink(path.join(paths.runtimesDirectory, "bun", "current")) + ).toBe(runtimeIdentity.revision); + expect(await readdir(paths.releasesDirectory)).not.toContain( + firstReleaseId + ); + }); + } + }); + test("restores the previous release and database when candidate readiness fails", async () => { - const sourceReleases = sourceReleaseFixtures(); - const { projectRoot, runtimeSource } = await createProjectFixture(); + const projectRoot = await createProjectFixture(); const state = await prepareProtectedProductionStatePath(projectRoot); await withDeploymentLease(state.stateDirectory, async (lease) => { const paths = await prepareProductionDeliveryDirectories(state); - const fixtures = await publishFixtures( - lease, - paths, - sourceReleases, - runtimeSource - ); + const fixtures = clonedPublishedFixtures(paths); const services = new TestServiceController(); const dependencies = activationDependencies(services, fixtures.probeRuntime); - const initial = await Effect.runPromise( - activatePublishedProductionRelease( - lease, - paths, - fixtures.first, - fixtures.runtime, - dependencies - ) - ); + const initial = initialActivationFixture(); services.rejectReadyReleaseId = secondReleaseId; const failure = await rejectionError( Effect.runPromise( @@ -322,28 +539,14 @@ describe("production release activation", () => { }); test("restores the previous release and database after a partial candidate start", async () => { - const sourceReleases = sourceReleaseFixtures(); - const { projectRoot, runtimeSource } = await createProjectFixture(); + const projectRoot = await createProjectFixture(); const state = await prepareProtectedProductionStatePath(projectRoot); await withDeploymentLease(state.stateDirectory, async (lease) => { const paths = await prepareProductionDeliveryDirectories(state); - const fixtures = await publishFixtures( - lease, - paths, - sourceReleases, - runtimeSource - ); + const fixtures = clonedPublishedFixtures(paths); const services = new TestServiceController(); const dependencies = activationDependencies(services, fixtures.probeRuntime); - const initial = await Effect.runPromise( - activatePublishedProductionRelease( - lease, - paths, - fixtures.first, - fixtures.runtime, - dependencies - ) - ); + const initial = initialActivationFixture(); services.rejectStartReleaseId = secondReleaseId; const partialStartFailure = await rejectionError( Effect.runPromise( @@ -387,27 +590,13 @@ describe("production release activation", () => { }); test("recovers the active service after interruption immediately after stop", async () => { - const sourceReleases = sourceReleaseFixtures(); - const { projectRoot, runtimeSource } = await createProjectFixture(); + const projectRoot = await createProjectFixture(); const state = await prepareProtectedProductionStatePath(projectRoot); await withDeploymentLease(state.stateDirectory, async (lease) => { const paths = await prepareProductionDeliveryDirectories(state); - const fixtures = await publishFixtures( - lease, - paths, - sourceReleases, - runtimeSource - ); + const fixtures = clonedPublishedFixtures(paths); const services = new TestServiceController(); - const initial = await Effect.runPromise( - activatePublishedProductionRelease( - lease, - paths, - fixtures.first, - fixtures.runtime, - activationDependencies(services, fixtures.probeRuntime) - ) - ); + const initial = initialActivationFixture(); let observedPhase: string | undefined; const failure = await rejectionError( Effect.runPromise( @@ -446,31 +635,13 @@ describe("production release activation", () => { }); test("keeps a committed candidate when post-commit cleanup is interrupted", async () => { - const sourceReleases = sourceReleaseFixtures(); - const { projectRoot, runtimeSource } = await createProjectFixture(); + const projectRoot = await createProjectFixture(); const state = await prepareProtectedProductionStatePath(projectRoot); await withDeploymentLease(state.stateDirectory, async (lease) => { const paths = await prepareProductionDeliveryDirectories(state); - const fixtures = await publishFixtures( - lease, - paths, - sourceReleases, - runtimeSource - ); + const fixtures = clonedPublishedFixtures(paths); const services = new TestServiceController(); - const baseDependencies = activationDependencies( - services, - fixtures.probeRuntime - ); - await Effect.runPromise( - activatePublishedProductionRelease( - lease, - paths, - fixtures.first, - fixtures.runtime, - baseDependencies - ) - ); + const crashStages: string[] = []; for (const scenario of [ { boundary: "afterActivationCommit" as const, @@ -491,8 +662,19 @@ describe("production release activation", () => { scenario.candidate, fixtures.runtime, activationDependencies(services, fixtures.probeRuntime, { - [scenario.boundary]: () => { + [scenario.boundary]: async () => { hookCalls += 1; + const crashId = Bun.randomUUIDv7(); + const crashStage = `.stage-${crashId}`; + crashStages.push(crashStage); + await mkdir( + path.join( + paths.stateDirectory, + "backups", + crashStage + ), + { mode: 0o700 } + ); throw new Error("simulated cleanup interruption"); }, }) @@ -513,33 +695,70 @@ describe("production release activation", () => { expect(services.events.at(-1)).toBe( `ready:${scenario.expectedReleaseId}` ); + expect( + await readdir(path.join(paths.stateDirectory, "backups")) + ).not.toContain(crashStages.at(-1)); } }); }, 15_000); - test("recovers a durable rollback request after candidate activation commit", async () => { - const sourceReleases = sourceReleaseFixtures(); - const { projectRoot, runtimeSource } = await createProjectFixture(); + test("reports committed retention failure and retries it on the same candidate", async () => { + const projectRoot = await createProjectFixture(); const state = await prepareProtectedProductionStatePath(projectRoot); await withDeploymentLease(state.stateDirectory, async (lease) => { const paths = await prepareProductionDeliveryDirectories(state); - const fixtures = await publishFixtures( - lease, - paths, - sourceReleases, - runtimeSource - ); + const fixtures = clonedPublishedFixtures(paths); const services = new TestServiceController(); - const dependencies = activationDependencies(services, fixtures.probeRuntime); - const initial = await Effect.runPromise( + const unknownEntry = path.join( + paths.stateDirectory, + "backups", + "unexpected-retention-entry" + ); + const failure = await rejectionError( + Effect.runPromise( + activatePublishedProductionRelease( + lease, + paths, + fixtures.second, + fixtures.runtime, + activationDependencies(services, fixtures.probeRuntime, { + afterActivationJournalClear: async () => { + await writeFile(unknownEntry, "fixture", { mode: 0o600 }); + throw new Error( + "simulated interruption after journal clear" + ); + }, + }) + ) + ) + ); + expect(failure.message).toBe("Production release activation failed"); + const committedState = await loadProductionActivationState(lease, paths); + expect(committedState.record?.current.releaseId).toBe(secondReleaseId); + + await unlink(unknownEntry); + const retried = await Effect.runPromise( activatePublishedProductionRelease( lease, paths, - fixtures.first, + fixtures.second, fixtures.runtime, - dependencies + activationDependencies(services, fixtures.probeRuntime) ) ); + expect(retried.current.releaseId).toBe(secondReleaseId); + }); + }); + + test("recovers a durable rollback request after candidate activation commit", async () => { + const projectRoot = await createProjectFixture(); + const state = await prepareProtectedProductionStatePath(projectRoot); + await withDeploymentLease(state.stateDirectory, async (lease) => { + const paths = await prepareProductionDeliveryDirectories(state); + const fixtures = clonedPublishedFixtures(paths); + const services = new TestServiceController(); + const dependencies = activationDependencies(services, fixtures.probeRuntime); + const initial = initialActivationFixture(); const previousState = await loadProductionActivationState(lease, paths); const transitionId = Bun.randomUUIDv7(); const snapshot = await runDatabaseSnapshotMaintenance( @@ -640,28 +859,14 @@ describe("production release activation", () => { }); test("recovers a crash after database promotion but before journal advancement", async () => { - const sourceReleases = sourceReleaseFixtures(); - const { projectRoot, runtimeSource } = await createProjectFixture(); + const projectRoot = await createProjectFixture(); const state = await prepareProtectedProductionStatePath(projectRoot); await withDeploymentLease(state.stateDirectory, async (lease) => { const paths = await prepareProductionDeliveryDirectories(state); - const fixtures = await publishFixtures( - lease, - paths, - sourceReleases, - runtimeSource - ); + const fixtures = clonedPublishedFixtures(paths); const services = new TestServiceController(); const dependencies = activationDependencies(services, fixtures.probeRuntime); - const initial = await Effect.runPromise( - activatePublishedProductionRelease( - lease, - paths, - fixtures.first, - fixtures.runtime, - dependencies - ) - ); + const initial = initialActivationFixture(); const transitionId = Bun.randomUUIDv7(); const snapshot = await runDatabaseSnapshotMaintenance( lease, diff --git a/greenfield/scripts/delivery/productionReleaseActivation.ts b/greenfield/scripts/delivery/productionReleaseActivation.ts index 318ed2657..f9931aff1 100644 --- a/greenfield/scripts/delivery/productionReleaseActivation.ts +++ b/greenfield/scripts/delivery/productionReleaseActivation.ts @@ -11,6 +11,7 @@ import { runDatabaseCandidateMaintenance, runDatabaseSnapshotMaintenance, } from "./databaseMaintenanceProcess.ts"; +import { retainProductionDatabaseSnapshots } from "./databaseSnapshotRetention.ts"; import { discardDatabaseTransitionWorkspace, discardOrphanDatabaseTransitionWorkspace, @@ -38,6 +39,10 @@ import { restorePreviousProductionActivationState, type ProductionActivationState, } from "./productionActivationState.ts"; +import { + retainProductionArtifacts, + type ProductionArtifactReference, +} from "./productionArtifactRetention.ts"; import type { PreparedProductionDeliveryPaths } from "./productionDeliveryFilesystem.ts"; import { loadPublishedProductionRelease, @@ -48,6 +53,7 @@ import { loadInstalledProductionRuntime, type ProductionRuntimeVerificationDependencies, } from "./productionRuntime.ts"; +import { clearProductionProcessPointers } from "./productionRuntimePointers.ts"; const TaggedErrorClass = Schema.TaggedError; const activationFailureMessage = "Production release activation failed"; @@ -76,6 +82,7 @@ export interface ProductionServiceController { /** Activation dependencies kept explicit for disposable-host lifecycle tests. */ export interface ProductionReleaseActivationDependencies { + readonly artifactRetention?: typeof retainProductionArtifacts; readonly maintenance?: DatabaseMaintenanceProcessDependencies; readonly runtimeVerification?: ProductionRuntimeVerificationDependencies; readonly services: ProductionServiceController; @@ -132,6 +139,82 @@ function activationError(): ProductionReleaseActivationError { return new ProductionReleaseActivationError({ message: activationFailureMessage }); } +function activationSnapshotReferences( + record: ProductionActivationRecord +): readonly string[] { + return Object.freeze([ + record.transitionId, + ...(record.previous === null + ? [] + : [record.previous.databaseSnapshotTransitionId]), + ]); +} + +async function retainCommittedDatabaseSnapshots( + lease: DashboardDeploymentLease, + paths: PreparedProductionDeliveryPaths, + activation: ProductionActivationState +): Promise { + await retainProductionDatabaseSnapshots(lease, paths, { + activationTransitionIds: + activation.record === undefined + ? [] + : activationSnapshotReferences(activation.record), + }); +} + +function activationArtifactReferences( + record: ProductionActivationRecord | undefined, + candidate?: ActiveArtifacts +): readonly ProductionArtifactReference[] { + const candidates: readonly (ProductionArtifactReference | undefined)[] = [ + record === undefined + ? undefined + : { + releaseId: record.current.releaseId, + runtimeRevision: record.current.runtimeRevision, + }, + record?.previous === null || record?.previous === undefined + ? undefined + : { + releaseId: record.previous.releaseId, + runtimeRevision: record.previous.runtimeRevision, + }, + candidate === undefined + ? undefined + : { + releaseId: candidate.release.manifest.source.commitSha, + runtimeRevision: candidate.runtime.identity.revision, + }, + ]; + const references = candidates.filter( + (reference): reference is ProductionArtifactReference => reference !== undefined + ); + return Object.freeze( + references.filter( + (reference, index) => + references.findIndex( + ({ releaseId }) => releaseId === reference.releaseId + ) === index + ) + ); +} + +async function retainCommittedProductionArtifacts( + lease: DashboardDeploymentLease, + paths: PreparedProductionDeliveryPaths, + activation: ProductionActivationState, + dependencies: ProductionReleaseActivationDependencies, + candidate?: ActiveArtifacts +): Promise { + await (dependencies.artifactRetention ?? retainProductionArtifacts)( + lease, + paths, + activationArtifactReferences(activation.record, candidate), + { runtimeVerification: dependencies.runtimeVerification } + ); +} + async function loadActiveArtifacts( paths: PreparedProductionDeliveryPaths, record: ProductionActivationRecord, @@ -321,6 +404,20 @@ async function discardTransitionWorkspace( : discardOrphanDatabaseTransitionWorkspace(lease, paths, transitionId)); } +async function restorePreviousProcessState( + lease: DashboardDeploymentLease, + paths: PreparedProductionDeliveryPaths, + previous: ActiveArtifacts | undefined, + dependencies: ProductionReleaseActivationDependencies +): Promise { + if (previous) { + await prepareAndStartServices(dependencies.services, previous); + await dependencies.services.verifyReady(previous.release, previous.runtime); + return; + } + await clearProductionProcessPointers(lease, paths); +} + function activationMatchesCandidate( activation: ProductionActivationState, journal: ProductionActivationTransition @@ -372,10 +469,7 @@ async function rollbackTransition( paths, journal.transitionId ); - if (previous) { - await prepareAndStartServices(dependencies.services, previous); - await dependencies.services.verifyReady(previous.release, previous.runtime); - } + await restorePreviousProcessState(lease, paths, previous, dependencies); await clearProductionActivationJournal(lease, paths, journal); return activation; } @@ -399,10 +493,7 @@ async function rollbackTransition( ) : activation; await discardOrphanDatabaseTransitionWorkspace(lease, paths, journal.transitionId); - if (previous) { - await prepareAndStartServices(dependencies.services, previous); - await dependencies.services.verifyReady(previous.release, previous.runtime); - } + await restorePreviousProcessState(lease, paths, previous, dependencies); await clearProductionActivationJournal(lease, paths, journal); return restoredActivation; } @@ -440,6 +531,28 @@ async function recoverExistingTransition( return rollbackTransition(lease, paths, journal, activation, dependencies); } +/** + * Reconciles any durable transition before a new release/runtime copy is admitted, then + * removes every artifact not referenced by the authoritative current/rollback state. + * Recovery runs first so an in-flight journal candidate remains available until its outcome is + * known. Callers must hold the same deployment lease for the later install and publication. + * @param lease Active wider deployment lease. + * @param paths Exact project-local production paths. + * @param dependencies Process control and verification boundaries required by recovery. + */ +export async function prepareProductionArtifactAdmission( + lease: DashboardDeploymentLease, + paths: PreparedProductionDeliveryPaths, + dependencies: ProductionReleaseActivationDependencies +): Promise { + try { + const activation = await recoverExistingTransition(lease, paths, dependencies); + await retainCommittedProductionArtifacts(lease, paths, activation, dependencies); + } catch { + throw activationError(); + } +} + async function activateRelease( lease: DashboardDeploymentLease, paths: PreparedProductionDeliveryPaths, @@ -448,12 +561,20 @@ async function activateRelease( dependencies: ProductionReleaseActivationDependencies ): Promise { const activation = await recoverExistingTransition(lease, paths, dependencies); + await retainCommittedDatabaseSnapshots(lease, paths, activation); const candidate = await verifyCandidateArtifacts( paths, candidateRelease, candidateRuntime, dependencies ); + await retainCommittedProductionArtifacts( + lease, + paths, + activation, + dependencies, + candidate + ); if ( activation.record?.current.releaseId === candidate.release.manifest.source.commitSha && @@ -541,6 +662,13 @@ async function activateRelease( await clearProductionActivationJournal(lease, paths, journal); journal = undefined; await dependencies.testHooks?.afterActivationJournalClear?.(); + await retainCommittedDatabaseSnapshots(lease, paths, committedState); + await retainCommittedProductionArtifacts( + lease, + paths, + committedState, + dependencies + ); return committed; } catch { const observedJournal = await loadProductionActivationJournal(lease, paths).catch( @@ -553,12 +681,26 @@ async function activateRelease( if (observedJournal) { const recovered = await recoverExistingTransition(lease, paths, dependencies); if (sameRecord(recovered.record, expectedCommitted)) { + await retainCommittedDatabaseSnapshots(lease, paths, recovered); + await retainCommittedProductionArtifacts( + lease, + paths, + recovered, + dependencies + ); return expectedCommitted; } throw activationError(); } if (sameRecord(observedActivation.record, expectedCommitted)) { await discardTransitionWorkspace(lease, paths, transitionId, workspace); + await retainCommittedDatabaseSnapshots(lease, paths, observedActivation); + await retainCommittedProductionArtifacts( + lease, + paths, + observedActivation, + dependencies + ); return expectedCommitted; } diff --git a/greenfield/scripts/delivery/productionReleasePublication.test.ts b/greenfield/scripts/delivery/productionReleasePublication.test.ts index c497b7669..801b878b1 100644 --- a/greenfield/scripts/delivery/productionReleasePublication.test.ts +++ b/greenfield/scripts/delivery/productionReleasePublication.test.ts @@ -1,4 +1,12 @@ -import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test"; +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + setDefaultTimeout, + test, +} from "bun:test"; import { chmod, cp, @@ -17,10 +25,17 @@ import type { BuildSourceIdentity } from "../buildSourceIdentity.ts"; import { rejectionError } from "../testSupport/rejection.ts"; import { buildDashboardRelease, type ReleaseBuildCommand } from "./buildRelease.ts"; import { withDeploymentLease } from "./deploymentLease.ts"; +import { + assertProductionArtifactCapacity, + productionArtifactCapacityReserveBytes, +} from "./productionArtifactCapacity.ts"; import { prepareProductionDeliveryDirectories } from "./productionDeliveryFilesystem.ts"; import { publishProductionRelease } from "./productionReleasePublication.ts"; import { prepareProtectedProductionStatePath } from "./productionStateFilesystem.ts"; -import type { ReleaseRuntimeIdentity } from "./releaseIdentity.ts"; +import { + type ReleaseRuntimeIdentity, + verifyReleaseArtifactIdentity, +} from "./releaseIdentity.ts"; const sourceProjectRoot = path.resolve(import.meta.dir, "../.."); const temporaryDirectories: string[] = []; @@ -34,8 +49,19 @@ const runtimeIdentity: ReleaseRuntimeIdentity = Object.freeze({ version: "1.4.0", }); const releaseFixtureDirectories: string[] = []; +const documentationFixture = "# Production release publication fixture\n"; let sharedSourceReleaseRoot: string | undefined; +setDefaultTimeout(15_000); + +function filesystemCapacity(availableBytes: bigint) { + return Object.freeze({ + availableBytes, + availableInodes: 1_000_000n, + blockSize: 4096n, + }); +} + async function restoreOwnerWrite(directory: string): Promise { const status = await stat(directory).catch(() => null); if (!status?.isDirectory()) return; @@ -81,11 +107,11 @@ async function repositoryFixture(): Promise { path.join(tmpdir(), "mira-production-release-source-") ); releaseFixtureDirectories.push(repositoryRoot); + await mkdir(path.join(repositoryRoot, "docs/generated"), { recursive: true }); await Promise.all([ - cp( - path.join(sourceProjectRoot, "docs/generated"), - path.join(repositoryRoot, "docs/generated"), - { recursive: true } + writeFile( + path.join(repositoryRoot, "docs/generated/README.md"), + documentationFixture ), cp( path.join(sourceProjectRoot, "migrations"), @@ -98,13 +124,8 @@ async function repositoryFixture(): Promise { { recursive: true } ), cp( - path.join(sourceProjectRoot, "scripts/delivery/provisioning/host-operations"), - path.join(repositoryRoot, "scripts/delivery/provisioning/host-operations"), - { recursive: true } - ), - cp( - path.join(sourceProjectRoot, "scripts/delivery/provisioning/log-maintenance"), - path.join(repositoryRoot, "scripts/delivery/provisioning/log-maintenance"), + path.join(sourceProjectRoot, "scripts/delivery/provisioning"), + path.join(repositoryRoot, "scripts/delivery/provisioning"), { recursive: true } ), cp( @@ -156,7 +177,7 @@ async function localReleaseFixture(): Promise { const repositoryRoot = await repositoryFixture(); const observedCommands: ReleaseBuildCommand[] = []; const release = await buildDashboardRelease(repositoryRoot, { - resolveSourceIdentity: () => cleanSource, + resolveSourceIdentity: () => Promise.resolve(cleanSource), runCommand: async (command, root) => { observedCommands.push(command); await materializeCommandOutput(command, root); @@ -241,6 +262,103 @@ describe("production release publication", () => { expect(await readdir(result.paths.releasesDirectory)).toEqual([]); }); + test("rejects source growth after outer capacity admission before writing a stage", async () => { + const sourceReleaseRoot = await localReleaseFixture(); + const projectRoot = await productionProjectFixture(); + const runtimeRoot = await mkdtemp( + path.join(tmpdir(), "mira-publication-runtime-source-") + ); + temporaryDirectories.push(runtimeRoot); + const runtimeSource = path.join(runtimeRoot, "bun"); + await writeFile(runtimeSource, "runtime", { mode: 0o500 }); + await chmod(runtimeSource, 0o500); + const sourceManifest = await verifyReleaseArtifactIdentity(sourceReleaseRoot); + const state = await prepareProtectedProductionStatePath(projectRoot); + const result = await withDeploymentLease(state.stateDirectory, async (lease) => { + const paths = await prepareProductionDeliveryDirectories(state); + await assertProductionArtifactCapacity( + lease, + paths, + sourceReleaseRoot, + sourceManifest, + runtimeSource, + { + availableCapacity: () => + Promise.resolve( + filesystemCapacity( + productionArtifactCapacityReserveBytes + + 1024n * 1024n * 1024n + ) + ), + } + ); + let inlineCapacityChecks = 0; + const failure = await rejectionError( + publishProductionRelease( + lease, + paths, + sourceReleaseRoot, + runtimeIdentity, + { + availableCapacity: () => { + inlineCapacityChecks += 1; + return Promise.resolve( + filesystemCapacity( + productionArtifactCapacityReserveBytes + 1n + ) + ); + }, + beforeCopy: async () => { + const sourceFile = path.join( + sourceReleaseRoot, + "server/web.js" + ); + await chmod(sourceFile, 0o600); + await writeFile(sourceFile, "grown-after-admission"); + await chmod(sourceFile, 0o400); + }, + } + ) + ); + return { failure, inlineCapacityChecks, paths }; + }); + + expect(result.failure.message).toBe("Production release publication failed"); + expect(result.inlineCapacityChecks).toBe(0); + expect(await readdir(result.paths.releasesDirectory)).toEqual([]); + }); + + test("rechecks current free space immediately before copying a stable release", async () => { + const sourceReleaseRoot = sourceReleaseFixture(); + const projectRoot = await productionProjectFixture(); + const state = await prepareProtectedProductionStatePath(projectRoot); + const result = await withDeploymentLease(state.stateDirectory, async (lease) => { + const paths = await prepareProductionDeliveryDirectories(state); + let inlineCapacityChecks = 0; + const failure = await rejectionError( + publishProductionRelease( + lease, + paths, + sourceReleaseRoot, + runtimeIdentity, + { + availableCapacity: () => { + inlineCapacityChecks += 1; + return Promise.resolve( + filesystemCapacity(productionArtifactCapacityReserveBytes) + ); + }, + } + ) + ); + return { failure, inlineCapacityChecks, paths }; + }); + + expect(result.failure.message).toBe("Production release publication failed"); + expect(result.inlineCapacityChecks).toBe(1); + expect(await readdir(result.paths.releasesDirectory)).toEqual([]); + }); + test("never overwrites or removes a pre-existing commit path", async () => { const sourceReleaseRoot = sourceReleaseFixture(); const projectRoot = await productionProjectFixture(); diff --git a/greenfield/scripts/delivery/productionReleasePublication.ts b/greenfield/scripts/delivery/productionReleasePublication.ts index 862d96e12..e822a3de0 100644 --- a/greenfield/scripts/delivery/productionReleasePublication.ts +++ b/greenfield/scripts/delivery/productionReleasePublication.ts @@ -1,13 +1,14 @@ -import type { BigIntStats, Dirent } from "node:fs"; +import { constants, type BigIntStats, type Dirent } from "node:fs"; import { chmod, lstat, mkdir, + open, readdir, realpath, rename, rm, - writeFile, + type FileHandle, } from "node:fs/promises"; import path from "node:path"; @@ -15,6 +16,10 @@ import type { ReleaseManifest } from "../../src/shared/releaseManifest.ts"; import { parseReleaseManifest } from "../../src/shared/releaseManifest.ts"; import { readBoundedRegularFile } from "../files/boundedFile.ts"; import type { DashboardDeploymentLease } from "./deploymentLease.ts"; +import { + assertProductionArtifactCopyCapacity, + type ProductionArtifactCapacityDependencies, +} from "./productionArtifactCapacity.ts"; import type { PreparedProductionDeliveryPaths } from "./productionDeliveryFilesystem.ts"; import { inventoryReleaseArtifactTree, @@ -32,6 +37,13 @@ const commitShaPattern = /^[a-f\d]{40}$/u; const maximumCleanupEntries = 4608; const maximumCleanupDepth = 20; const maximumPublishedManifestBytes = 4 * 1024 * 1024; +const directoryFlags = + constants.O_RDONLY | + constants.O_DIRECTORY | + constants.O_NOFOLLOW | + constants.O_NONBLOCK; +const destinationFileFlags = + constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW | constants.O_RDWR; /** Immutable production release materialized below the project-local release root. */ export interface PublishedProductionRelease { @@ -41,10 +53,67 @@ export interface PublishedProductionRelease { /** Deterministic publication mutation boundaries exposed only to adversarial tests. */ export interface ProductionReleasePublicationTestHooks { + readonly availableCapacity?: ProductionArtifactCapacityDependencies["availableCapacity"]; + readonly beforeCopy?: (sourceRoot: string) => Promise | void; readonly afterCopy?: (stagingRoot: string) => Promise | void; readonly afterFreeze?: (stagingRoot: string) => Promise | void; } +async function closeHandle(handle: FileHandle | undefined): Promise { + if (!handle) return true; + try { + await handle.close(); + return true; + } catch { + return false; + } +} + +async function syncDirectory(directory: string): Promise { + let handle: FileHandle | undefined; + let failed = false; + try { + handle = await open(directory, directoryFlags); + await handle.sync(); + } catch { + failed = true; + } + if (!(await closeHandle(handle)) || failed) throw productionReleaseFailure(); +} + +async function writeSyncedPrivateFile( + filePath: string, + contents: Uint8Array +): Promise { + let handle: FileHandle | undefined; + let failed = false; + try { + handle = await open(filePath, destinationFileFlags, privateFileMode); + await handle.writeFile(contents); + await handle.sync(); + } catch { + failed = true; + } + if (!(await closeHandle(handle)) || failed) throw productionReleaseFailure(); +} + +function releaseDirectories( + releaseRoot: string, + records: readonly ReleaseArtifactInventoryRecord[] +): readonly string[] { + const directories = new Set([releaseRoot]); + for (const record of records) { + let directory = path.dirname(path.join(releaseRoot, record.path)); + while (directory !== releaseRoot) { + directories.add(directory); + directory = path.dirname(directory); + } + } + return Object.freeze( + [...directories].toSorted((left, right) => right.length - left.length) + ); +} + interface ExpectedTreeEntry { readonly kind: "directory" | "file"; readonly name: string; @@ -283,9 +352,24 @@ async function pathExists(candidate: string): Promise { async function copyReleaseTree( sourceRoot: string, - destinationRoot: string + destinationRoot: string, + expectedSource: readonly ReleaseArtifactInventoryRecord[], + availableCapacity?: ProductionReleasePublicationTestHooks["availableCapacity"] ): Promise { const sourceBefore = await inventoryReleaseArtifactTree(sourceRoot); + if (!sameArtifactRecords(expectedSource, sourceBefore)) { + throw productionReleaseFailure(); + } + await assertProductionArtifactCopyCapacity( + path.dirname(destinationRoot), + Object.freeze({ + fileBytes: Object.freeze(sourceBefore.map((record) => BigInt(record.bytes))), + newDirectoryCount: BigInt( + releaseDirectories(destinationRoot, sourceBefore).length + ), + }), + { availableCapacity } + ); await mkdir(destinationRoot, { mode: privateDirectoryMode }); for (const record of sourceBefore) { const contents = await readBoundedRegularFile( @@ -306,10 +390,10 @@ async function copyReleaseTree( mode: privateDirectoryMode, recursive: true, }); - await writeFile(destination, contents, { - flag: "wx", - mode: privateFileMode, - }); + await writeSyncedPrivateFile(destination, contents); + } + for (const directory of releaseDirectories(destinationRoot, sourceBefore)) { + await syncDirectory(directory); } const [sourceAfter, destination] = await Promise.all([ inventoryReleaseArtifactTree(sourceRoot), @@ -329,14 +413,33 @@ async function freezeReleaseTree( releaseRoot: string, records: readonly ReleaseArtifactInventoryRecord[] ): Promise { - const directories = [...expectedTreeEntries(records).keys()] - .map((relative) => (relative ? path.join(releaseRoot, relative) : releaseRoot)) - .toSorted((left, right) => right.length - left.length); for (const record of records) { - await chmod(path.join(releaseRoot, record.path), immutableFileMode); + const filePath = path.join(releaseRoot, record.path); + await chmod(filePath, immutableFileMode); + let handle: FileHandle | undefined; + let failed = false; + try { + handle = await open( + filePath, + constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK + ); + await handle.sync(); + } catch { + failed = true; + } + if (!(await closeHandle(handle)) || failed) throw productionReleaseFailure(); } - for (const directory of directories) { - await chmod(directory, immutableDirectoryMode); + for (const directory of releaseDirectories(releaseRoot, records)) { + let handle: FileHandle | undefined; + let failed = false; + try { + handle = await open(directory, directoryFlags); + await handle.chmod(immutableDirectoryMode); + await handle.sync(); + } catch { + failed = true; + } + if (!(await closeHandle(handle)) || failed) throw productionReleaseFailure(); } const after = await inventoryReleaseArtifactTree(releaseRoot); if (!sameArtifactRecords(records, after)) throw productionReleaseFailure(); @@ -393,6 +496,7 @@ async function restoreOwnedCandidate( }; await restore(candidateRoot, 0); await rm(candidateRoot, { force: false, recursive: true }); + await syncDirectory(releasesDirectory); } /** @@ -440,7 +544,13 @@ export async function publishProductionRelease( const stagingRoot = path.join(paths.releasesDirectory, stageName); ownedRoot = stagingRoot; ownedName = stageName; - const stagedRecords = await copyReleaseTree(sourceReleaseRoot, stagingRoot); + await testHooks.beforeCopy?.(sourceReleaseRoot); + const stagedRecords = await copyReleaseTree( + sourceReleaseRoot, + stagingRoot, + sourceRecords, + testHooks.availableCapacity + ); await testHooks.afterCopy?.(stagingRoot); const stagedManifest = await verifyReleaseIdentity(stagingRoot, runtimeIdentity); if (!sameManifest(sourceManifest, stagedManifest)) { @@ -456,6 +566,7 @@ export async function publishProductionRelease( await rename(stagingRoot, finalRoot); ownedRoot = finalRoot; ownedName = commitSha; + await syncDirectory(paths.releasesDirectory); if ((await realpath(finalRoot)) !== finalRoot) throw productionReleaseFailure(); const published = await verifyReleaseIdentity(finalRoot, runtimeIdentity); const publishedRecords = await inventoryReleaseArtifactTree(finalRoot); @@ -493,14 +604,27 @@ export async function loadPublishedProductionRelease( paths: PreparedProductionDeliveryPaths, releaseId: string, runtimeRevision: string +): Promise { + if (!commitShaPattern.test(runtimeRevision)) throw productionReleaseFailure(); + const release = await loadPublishedProductionReleaseById(paths, releaseId); + if (release.manifest.runtime.revision !== runtimeRevision) { + throw productionReleaseFailure(); + } + return release; +} + +/** + * Reloads and fully verifies one immutable production release using its own manifest identity. + * @param paths Exact project-local production delivery paths. + * @param releaseId Full commit identity naming the immutable release directory. + * @returns Verified immutable production release and manifest. + */ +export async function loadPublishedProductionReleaseById( + paths: PreparedProductionDeliveryPaths, + releaseId: string ): Promise { try { - if ( - !commitShaPattern.test(releaseId) || - !commitShaPattern.test(runtimeRevision) - ) { - throw productionReleaseFailure(); - } + if (!commitShaPattern.test(releaseId)) throw productionReleaseFailure(); await assertPrivateReleasesDirectory(paths.releasesDirectory); const releaseRoot = path.join(paths.releasesDirectory, releaseId); const manifestBytes = await readBoundedRegularFile( @@ -514,10 +638,7 @@ export async function loadPublishedProductionRelease( ); const manifestValue: unknown = JSON.parse(manifestText); const preliminary = parseReleaseManifest(manifestValue); - if ( - preliminary.source.commitSha !== releaseId || - preliminary.runtime.revision !== runtimeRevision - ) { + if (preliminary.source.commitSha !== releaseId) { throw productionReleaseFailure(); } const manifest = await verifyReleaseIdentity(releaseRoot, preliminary.runtime); diff --git a/greenfield/scripts/delivery/productionRuntime.test.ts b/greenfield/scripts/delivery/productionRuntime.test.ts index b11bcd341..5fb88f346 100644 --- a/greenfield/scripts/delivery/productionRuntime.test.ts +++ b/greenfield/scripts/delivery/productionRuntime.test.ts @@ -1,10 +1,20 @@ import { afterEach, describe, expect, test } from "bun:test"; -import { chmod, mkdir, mkdtemp, readdir, rm, stat, writeFile } from "node:fs/promises"; +import { + chmod, + mkdir, + mkdtemp, + readdir, + rename, + rm, + stat, + writeFile, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { rejectionError } from "../testSupport/rejection.ts"; import { withDeploymentLease } from "./deploymentLease.ts"; +import { productionArtifactCapacityReserveBytes } from "./productionArtifactCapacity.ts"; import { prepareProductionDeliveryDirectories } from "./productionDeliveryFilesystem.ts"; import { installProductionRuntime } from "./productionRuntime.ts"; import { prepareProtectedProductionStatePath } from "./productionStateFilesystem.ts"; @@ -16,6 +26,14 @@ const runtimeIdentity: ReleaseRuntimeIdentity = Object.freeze({ version: "1.4.0", }); +function filesystemCapacity(availableBytes: bigint) { + return Object.freeze({ + availableBytes, + availableInodes: 1_000_000n, + blockSize: 4096n, + }); +} + async function restoreOwnerWrite(directory: string): Promise { const status = await stat(directory).catch(() => null); if (!status?.isDirectory()) return; @@ -116,6 +134,47 @@ describe("production Bun runtime", () => { expect(await readdir(bunRoot)).toEqual([]); }); + test("re-admits a replacement source size after probing and before copying", async () => { + const { projectRoot, sourceExecutable } = await fixture(); + const originalBytes = BigInt("test-bun-runtime-bytes".length); + const movedSource = `${sourceExecutable}.admitted`; + const state = await prepareProtectedProductionStatePath(projectRoot); + const result = await withDeploymentLease(state.stateDirectory, async (lease) => { + const paths = await prepareProductionDeliveryDirectories(state); + let capacityChecks = 0; + const failure = await rejectionError( + installProductionRuntime(lease, paths, runtimeIdentity, { + availableCapacity: () => { + capacityChecks += 1; + return Promise.resolve( + filesystemCapacity( + productionArtifactCapacityReserveBytes + originalBytes + ) + ); + }, + beforeCopy: async () => { + await rename(sourceExecutable, movedSource); + await writeFile( + sourceExecutable, + Buffer.alloc(Number(originalBytes + 1n), 1), + { mode: 0o500 } + ); + await chmod(sourceExecutable, 0o500); + }, + probeRuntime: () => Promise.resolve(runtimeIdentity), + sourceExecutable, + }) + ); + return { capacityChecks, failure, paths }; + }); + + expect(result.failure.message).toBe("Production Bun runtime installation failed"); + expect(result.capacityChecks).toBe(1); + expect(await readdir(path.join(result.paths.runtimesDirectory, "bun"))).toEqual( + [] + ); + }); + test("never replaces a pre-existing runtime revision directory", async () => { const { projectRoot, sourceExecutable } = await fixture(); const state = await prepareProtectedProductionStatePath(projectRoot); diff --git a/greenfield/scripts/delivery/productionRuntime.ts b/greenfield/scripts/delivery/productionRuntime.ts index a65b3bbf0..73385cbd0 100644 --- a/greenfield/scripts/delivery/productionRuntime.ts +++ b/greenfield/scripts/delivery/productionRuntime.ts @@ -14,6 +14,10 @@ import path from "node:path"; import * as v from "valibot"; import type { DashboardDeploymentLease } from "./deploymentLease.ts"; +import { + assertProductionArtifactCopyCapacity, + type ProductionArtifactCapacityDependencies, +} from "./productionArtifactCapacity.ts"; import type { PreparedProductionDeliveryPaths } from "./productionDeliveryFilesystem.ts"; import type { ReleaseRuntimeIdentity } from "./releaseIdentity.ts"; @@ -28,6 +32,11 @@ const immutableFileMode = 0o500; const sourceFlags = constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK; const destinationFlags = constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW | constants.O_RDWR; +const directoryFlags = + constants.O_RDONLY | + constants.O_DIRECTORY | + constants.O_NOFOLLOW | + constants.O_NONBLOCK; const runtimeIdentitySchema = v.strictObject({ revision: v.pipe(v.string(), v.regex(/^[a-f\d]{40}$/u)), version: v.pipe(v.string(), v.regex(/^\d+\.\d+\.\d+$/u)), @@ -46,6 +55,8 @@ export interface ProductionRuntimeVerificationDependencies { /** Runtime probe and mutation boundaries exposed only to focused tests. */ export interface ProductionRuntimeDependencies { + readonly availableCapacity?: ProductionArtifactCapacityDependencies["availableCapacity"]; + readonly beforeCopy?: (sourceExecutable: string) => Promise | void; readonly afterCopy?: (destination: string) => Promise | void; readonly probeRuntime?: (executable: string) => Promise; readonly sourceExecutable?: string; @@ -120,6 +131,18 @@ async function closeHandle(handle: FileHandle | undefined): Promise { } } +async function syncDirectory(directory: string): Promise { + let handle: FileHandle | undefined; + let failed = false; + try { + handle = await open(directory, directoryFlags); + await handle.sync(); + } catch { + failed = true; + } + if (!(await closeHandle(handle)) || failed) throw productionRuntimeFailure(); +} + async function readBoundedProbeOutput( stream: ReadableStream ): Promise { @@ -206,6 +229,8 @@ async function ensurePrivateDirectory(parent: string, name: string): Promise Promise | void ): Promise { let source: FileHandle | undefined; @@ -273,6 +299,15 @@ async function copyRuntimeExecutable( throw productionRuntimeFailure(); } + await assertProductionArtifactCopyCapacity( + path.dirname(path.dirname(destination)), + Object.freeze({ + fileBytes: Object.freeze([heldBefore.size]), + newDirectoryCount: 0n, + }), + { availableCapacity } + ); + target = await open(destination, destinationFlags, privateFileMode); const buffer = Buffer.alloc(Math.min(copyBufferBytes, Number(heldBefore.size))); const sourceHasher = new Bun.CryptoHasher("sha256"); @@ -325,6 +360,7 @@ async function copyRuntimeExecutable( closeHandle(target), ]); if (failed || !sourceClosed || !targetClosed) throw productionRuntimeFailure(); + await syncDirectory(path.dirname(destination)); } async function assertInstalledRuntimeFile(executable: string): Promise { @@ -386,6 +422,7 @@ async function removeOwnedRuntimeCandidate( await chmod(executable, privateFileMode); } await rm(stageRoot, { force: false, recursive: true }); + await syncDirectory(bunRoot); } catch (error) { if (errorCode(error) !== "ENOENT") throw productionRuntimeFailure(); } @@ -443,10 +480,13 @@ export async function installProductionRuntime( ownedRoot = stageRoot; ownedName = stageName; await mkdir(stageRoot, { mode: privateDirectoryMode }); + await syncDirectory(bunRoot); const stageExecutable = path.join(stageRoot, "bun"); + await dependencies.beforeCopy?.(sourceExecutable); await copyRuntimeExecutable( sourceExecutable, stageExecutable, + dependencies.availableCapacity, dependencies.afterCopy ); const stagedIdentity = await probe(stageExecutable); @@ -454,9 +494,11 @@ export async function installProductionRuntime( throw productionRuntimeFailure(); } await chmod(stageRoot, immutableDirectoryMode); + await syncDirectory(stageRoot); await rename(stageRoot, finalRoot); ownedRoot = finalRoot; ownedName = expectedIdentity.revision; + await syncDirectory(bunRoot); await assertInstalledRuntimeFile(finalExecutable); const observed = await probe(finalExecutable); if (!sameRuntimeIdentity(expectedIdentity, observed)) { @@ -532,10 +574,46 @@ export async function loadInstalledProductionRuntime( identity: ReleaseRuntimeIdentity, dependencies: ProductionRuntimeVerificationDependencies = {} ): Promise { - const runtime = Object.freeze({ - executable: path.join(paths.runtimesDirectory, "bun", identity.revision, "bun"), - identity, - }); - await verifyInstalledProductionRuntime(paths, runtime, dependencies); - return runtime; + try { + if (!v.is(runtimeIdentitySchema, identity)) throw productionRuntimeFailure(); + const runtime = await inspectInstalledProductionRuntime( + paths, + identity.revision, + dependencies + ); + if (!sameRuntimeIdentity(identity, runtime.identity)) { + throw productionRuntimeFailure(); + } + return runtime; + } catch { + throw productionRuntimeFailure(); + } +} + +/** + * Reconstructs and verifies one installed runtime from its immutable directory identity. + * This is used by retention to validate orphaned runtimes whose release was already retired. + * @param paths Exact prepared production delivery roots. + * @param revision Full Bun revision naming the immutable runtime directory. + * @param dependencies Injectable probe boundary for focused tests. + * @returns Verified installed runtime executable and observed identity. + */ +export async function inspectInstalledProductionRuntime( + paths: PreparedProductionDeliveryPaths, + revision: string, + dependencies: ProductionRuntimeVerificationDependencies = {} +): Promise { + try { + if (!/^[a-f\d]{40}$/u.test(revision)) throw productionRuntimeFailure(); + const executable = path.join(paths.runtimesDirectory, "bun", revision, "bun"); + await assertPrivateRuntimeRoot(paths.runtimesDirectory); + await assertInstalledRuntimeFile(executable); + const identity = await (dependencies.probeRuntime ?? probeProductionRuntime)( + executable + ); + if (identity.revision !== revision) throw productionRuntimeFailure(); + return Object.freeze({ executable, identity }); + } catch { + throw productionRuntimeFailure(); + } } diff --git a/greenfield/scripts/delivery/productionRuntimePointers.ts b/greenfield/scripts/delivery/productionRuntimePointers.ts index 2bc5005f8..bbd8c3727 100644 --- a/greenfield/scripts/delivery/productionRuntimePointers.ts +++ b/greenfield/scripts/delivery/productionRuntimePointers.ts @@ -2,6 +2,7 @@ import { constants, type BigIntStats } from "node:fs"; import { lstat, open, + readdir, readlink, realpath, rename, @@ -17,6 +18,10 @@ import type { PublishedProductionRelease } from "./productionReleasePublication. import type { InstalledProductionRuntime } from "./productionRuntime.ts"; const runtimePointerFailureMessage = "Production runtime pointer update failed"; +const artifactIdentityPattern = /^[a-f\d]{40}$/u; +const pointerStageNamePattern = + /^\.current-[a-f\d]{8}-[a-f\d]{4}-7[a-f\d]{3}-[89ab][a-f\d]{3}-[a-f\d]{12}$/u; +const maximumPointerRootEntries = 128; const directoryFlags = constants.O_RDONLY | constants.O_DIRECTORY | @@ -30,6 +35,12 @@ interface OpenedDirectory { readonly path: string; } +interface ExistingPointer { + readonly device: bigint; + readonly inode: bigint; + readonly target: string; +} + function pointerFailure(): Error { return new Error(runtimePointerFailureMessage); } @@ -110,25 +121,102 @@ async function revalidateDirectory(directory: OpenedDirectory): Promise { } } -async function validateExistingPointer( - descriptorRoot: string, +function validOwnedSymlink(status: BigIntStats, device: bigint): boolean { + return ( + typeof process.getuid === "function" && + status.isSymbolicLink() && + status.nlink === 1n && + status.uid === BigInt(process.getuid()) && + status.dev === device + ); +} + +async function inspectExistingPointer( + directory: OpenedDirectory, pointerName: string -): Promise { +): Promise { + const descriptorRoot = `/proc/self/fd/${directory.handle.fd}`; const pointerPath = path.join(descriptorRoot, pointerName); try { - const status = await lstat(pointerPath, { bigint: true }); + const before = await lstat(pointerPath, { bigint: true }); + if (!validOwnedSymlink(before, directory.device)) { + throw pointerFailure(); + } + const target = await readlink(pointerPath); + const after = await lstat(pointerPath, { bigint: true }); if ( - typeof process.getuid !== "function" || - !status.isSymbolicLink() || - status.uid !== BigInt(process.getuid()) + !artifactIdentityPattern.test(target) || + !validOwnedSymlink(after, directory.device) || + after.dev !== before.dev || + after.ino !== before.ino ) { throw pointerFailure(); } + return Object.freeze({ + device: before.dev, + inode: before.ino, + target, + }); } catch (error) { if (errorCode(error) !== "ENOENT") throw pointerFailure(); + return undefined; } } +async function removeCrashLeftPointerStages(directory: OpenedDirectory): Promise { + await revalidateDirectory(directory); + const descriptorRoot = `/proc/self/fd/${directory.handle.fd}`; + const entries = await readdir(descriptorRoot, { withFileTypes: true }); + if (entries.length > maximumPointerRootEntries) throw pointerFailure(); + const stages: (ExistingPointer & { readonly name: string })[] = []; + for (const entry of entries) { + if (!entry.name.startsWith(".current-")) continue; + if (!pointerStageNamePattern.test(entry.name) || !entry.isSymbolicLink()) { + throw pointerFailure(); + } + const stage = await inspectExistingPointer(directory, entry.name); + if (!stage) throw pointerFailure(); + stages.push(Object.freeze({ ...stage, name: entry.name })); + } + for (const stage of stages) { + const observed = await inspectExistingPointer(directory, stage.name); + if ( + !observed || + observed.device !== stage.device || + observed.inode !== stage.inode || + observed.target !== stage.target + ) { + throw pointerFailure(); + } + await unlink(path.join(descriptorRoot, stage.name)); + } + if (stages.length > 0) await directory.handle.sync(); + await revalidateDirectory(directory); +} + +async function clearExistingPointer( + directory: OpenedDirectory, + pointerName: string, + expected: ExistingPointer | undefined +): Promise { + if (!expected) return; + const observed = await inspectExistingPointer(directory, pointerName); + if ( + !observed || + observed.device !== expected.device || + observed.inode !== expected.inode || + observed.target !== expected.target + ) { + throw pointerFailure(); + } + await unlink(path.join(`/proc/self/fd/${directory.handle.fd}`, pointerName)); + await directory.handle.sync(); + if ((await inspectExistingPointer(directory, pointerName)) !== undefined) { + throw pointerFailure(); + } + await revalidateDirectory(directory); +} + async function replaceRelativePointer( directory: OpenedDirectory, targetName: string @@ -140,21 +228,15 @@ async function replaceRelativePointer( const stagePath = path.join(descriptorRoot, stageName); let stageOwned = false; try { - if ( - targetName.length !== 40 || - targetName !== targetName.toLowerCase() || - /[^0-9a-f]/u.test(targetName) - ) { + if (!artifactIdentityPattern.test(targetName)) { throw pointerFailure(); } - await validateExistingPointer(descriptorRoot, pointerName); + await inspectExistingPointer(directory, pointerName); await symlink(targetName, stagePath, "dir"); stageOwned = true; const stageStatus = await lstat(stagePath, { bigint: true }); if ( - typeof process.getuid !== "function" || - !stageStatus.isSymbolicLink() || - stageStatus.uid !== BigInt(process.getuid()) || + !validOwnedSymlink(stageStatus, directory.device) || (await readlink(stagePath)) !== targetName ) { throw pointerFailure(); @@ -162,11 +244,9 @@ async function replaceRelativePointer( await rename(stagePath, pointerPath); stageOwned = false; await directory.handle.sync(); - const pointerStatus = await lstat(pointerPath, { bigint: true }); + const pointer = await inspectExistingPointer(directory, pointerName); if ( - !pointerStatus.isSymbolicLink() || - pointerStatus.uid !== BigInt(process.getuid()) || - (await readlink(pointerPath)) !== targetName || + pointer?.target !== targetName || (await realpath(pointerPath)) !== path.join(directory.path, targetName) ) { throw pointerFailure(); @@ -209,6 +289,10 @@ export async function pointProductionProcessesAtRelease( let failed = false; try { runtimes = await openPrivateDirectory(bunRoot); + await removeCrashLeftPointerStages(releases); + await removeCrashLeftPointerStages(runtimes); + await inspectExistingPointer(releases, "current"); + await inspectExistingPointer(runtimes, "current"); await replaceRelativePointer(releases, releaseId); await replaceRelativePointer(runtimes, runtimeRevision); await revalidateDirectory(releases); @@ -222,3 +306,44 @@ export async function pointProductionProcessesAtRelease( ]); if (failed || !releasesClosed || !runtimesClosed) throw pointerFailure(); } + +/** + * Clears stopped-service release/runtime pointers when no activation remains authoritative. + * A retained rollback journal makes a crash between the two durable unlinks retryable. + * @param lease Active wider deployment lease. + * @param paths Exact project-local delivery paths. + */ +export async function clearProductionProcessPointers( + lease: DashboardDeploymentLease, + paths: PreparedProductionDeliveryPaths +): Promise { + const bunRoot = path.join(paths.runtimesDirectory, "bun"); + if ( + lease.stateDirectory !== paths.stateDirectory || + paths.releasesDirectory !== path.join(paths.productionDirectory, "releases") || + paths.runtimesDirectory !== path.join(paths.productionDirectory, "runtimes") + ) { + throw pointerFailure(); + } + const releases = await openPrivateDirectory(paths.releasesDirectory); + let runtimes: OpenedDirectory | undefined; + let failed = false; + try { + runtimes = await openPrivateDirectory(bunRoot); + await removeCrashLeftPointerStages(releases); + await removeCrashLeftPointerStages(runtimes); + const releasePointer = await inspectExistingPointer(releases, "current"); + const runtimePointer = await inspectExistingPointer(runtimes, "current"); + await clearExistingPointer(releases, "current", releasePointer); + await clearExistingPointer(runtimes, "current", runtimePointer); + await revalidateDirectory(releases); + await revalidateDirectory(runtimes); + } catch { + failed = true; + } + const [releasesClosed, runtimesClosed] = await Promise.all([ + closeHandle(releases.handle), + closeHandle(runtimes?.handle), + ]); + if (failed || !releasesClosed || !runtimesClosed) throw pointerFailure(); +} diff --git a/greenfield/scripts/delivery/provisioning/database-observability/README.md b/greenfield/scripts/delivery/provisioning/database-observability/README.md new file mode 100644 index 000000000..041a1a696 --- /dev/null +++ b/greenfield/scripts/delivery/provisioning/database-observability/README.md @@ -0,0 +1,233 @@ +# Database observability provisioning + +These files define the approval-gated least-privilege boundary for PostgreSQL and +PgBouncer. Source builds, release builds, tests, and application startup inventory them but +never execute them. Production execution requires explicit approval, a reviewed clean release, +an approved database-admin session, a current backup, and a tested rollback window. + +## Dynamic authority boundary + +- PostgreSQL's live catalog is the database membership source. The runner discovers every + `datistemplate=false` and `datallowconn=true` database, sorts it by name, and fails closed + above 64 databases. There is no database-name allowlist or per-name command sequence in + source or the manifest. A newly created database is therefore discovered without a Dashboard + source/config change. PostgreSQL's default `PUBLIC` ACL is unsafe and the sanitized functions + are initially absent, so the database does not enter a fresh observation until the existing + hourly `cache.refresh.database-observability` job opens its configured privileged collection + lease and completes its mandatory full reconcile. +- `mira_dashboard_observability` is both the code-owned PgBouncer control alias and a dedicated + same-named physical PostgreSQL database created from `template0` by the approved provisioning + artifact. PgBouncer's existing wildcard database route preserves the client database name, so + the alias reaches that physical database without an explicit mapping, environment interpolation, + or second Docker label. The control database is included in the same catalog-derived inventory, + but it is a code-owned capability rather than an application-database allowlist entry. +- `mira_dashboard_observer` is the statistics login. It has zero role memberships, read-only + transactions, a five-second statement timeout, and a topology-independent PostgreSQL connection + ceiling of 64. Its PostgreSQL authority is direct `CONNECT` to the current bounded inventory, + `USAGE` on the exact private capability schema, and `EXECUTE` on the exact sanitized functions. + The optional Comet/Bitmagnet count views are the only separate `SELECT` exception. Database- + scoped role settings, inbound memberships, unsafe membership options, unrelated object grants, + effective user-schema `SECURITY DEFINER` execution, and future default-ACL authority all fail + verification. Ordinary `PUBLIC` invoker routines are not rejected. +- `mira_dashboard_observability_capability_owner` is an isolated `NOLOGIN` role. Its sole direct + role membership is `pg_read_all_stats`, and its sole additional statistics relation authority in + each provisioned database is direct `SELECT` on `pg_catalog.pg_statistic`. Those broad sources + never become observer authority. They are reachable only while the four exact no-input, + bounded, fixed-search-path `SECURITY DEFINER` functions execute: `connection_metrics()` and + identity-free `statement_metrics()` in the control database, plus `table_health()` and + `maintenance_metrics()` in every observed database. The observer receives only schema `USAGE` + and function `EXECUTE`. +- A full privileged reconcile runs through the exact immutable-release Bun runner and its pinned, + scrubbed, container-local administrative psql boundary. It quotes every live catalog identifier, + rejects more than 64 observed or 80 total catalog entries before mutation, removes every + database privilege from `PUBLIC`, and grants the observer direct `CONNECT` only to live + non-template connectable databases. Database owners retain implicit + `CONNECT`/`CREATE`/`TEMP`; every non-owner application login must have reviewed explicit + privileges before this change is applied. No dedicated reconciler login or function-executor + credential exists. +- The control apply resolves the upstream `pg_stat_statements` extension objects from PostgreSQL + catalogs, revokes the raw source views and extension routines from `PUBLIC` and the observer, and + grants the capability owner only the exact raw routine execution needed internally. Its + `statement_metrics()` function calls `pg_stat_statements(false)` and exposes at most 20 rows with + calls, execution time, rows, and block metrics. It returns no database/user identity, query text, + `queryid`, or other reversible statement identity. Exact routine shape, source body, dependency, + owner, source ACL, schema ACL, and output columns all fail closed on drift. Review compatibility + before applying because principals that relied on stock `PUBLIC` access lose that raw access. +- `bitmagnet` and `comet` are the sole named application exceptions. Their optional owner-rights + `torrent_count` views live in those application databases and remain independent of the fixed + control database. No other application, database, container, service, project, or port name is + built into this provisioning set. +- PgBouncer must list `mira_dashboard_observer` in `stats_users`, exclude it from + `admin_users`, and apply exactly `pool_size=1`, `reserve_pool_size=0`, + `max_user_connections=64`, and `max_user_client_connections=2`. Its existing wildcard route + exposes the same-named physical + `mira_dashboard_observability` database, and the container carries only + `mira.dashboard.database-observability=pgbouncer-v1` for endpoint discovery. PostgreSQL's + matching 64-backend role limit is the final cap. + +The existing hourly `cache.refresh.database-observability` job owns one bounded collection lease. +If and only if the database-observability provider is configured, that same action composes a +separate worker-only privileged collection-lease port around the observer collector. The port +spawns the exact Bun runtime and fixed `open-approved-collection`, +`enable-approved-collection`, and `close-approved-collection` modes from the immutable current +release; it accepts no +caller-selected command, argument, path, database, SQL, or credential. There is no additional job +action, schedule, systemd unit, polling loop, sidecar, PostgreSQL login, or exclusive-admission +mechanism. + +Between attempts, `mira_dashboard_observer` must be `NOLOGIN`, its `VALID UNTIL` must already be +expired, and PostgreSQL must report zero observer sessions. One configured hourly attempt has this +fixed order: + +1. the privileged boundary closes leftovers by setting `NOLOGIN`, expiring `VALID UNTIL`, + invalidating any prepared collection token, terminating observer sessions, and rechecking that + exact closed state; +2. `open-approved-collection` verifies the approval and identities, performs the full bounded, + idempotent ACL-and-capability reconcile, keeps the observer `NOLOGIN`, and prepares one random + one-use token bound to the exact catalog digest. A drifting application database is quarantined + by revoking observer `CONNECT`, so its catalog row remains visible with unavailable details; +3. `enable-approved-collection` rechecks approval, policy, Docker/PostgreSQL identity, and the + exact catalog digest, then atomically consumes that token and sets `LOGIN` plus a short + `VALID UNTIL`; +4. the least-privilege observer collector runs once; +5. a shielded mandatory close, with a cleanup signal independent of caller cancellation, restores + `NOLOGIN`, expires `VALID UNTIL`, terminates sessions, and proves the closed state again; and +6. only after that proof may the lease return the fresh payload to the generic cache executor, + which performs the cache commit afterward. + +The local runner is supervised by a Linux parent-death signal and an isolated process group; abort +uses bounded `TERM` then `KILL` and waits for the group to be reaped. The PostgreSQL one-use token +is the authoritative fence for Docker-daemon work: close invalidates it under the same advisory +lock used by enable, so a delayed old psql cannot reopen the observer after close. + +Explicit `activate-current-catalog` is the only operation allowed to create or refresh the +administrative approval binding. The marker is stored in +`mira_dashboard_observability_control.reconciliation_approval` and binds the exact +`pg_control_system().system_identifier` plus the exact current and previous immutable-release +policy digests. `sanitized-capabilities-v1` remains descriptive metadata and is never sufficient +authorization by itself. Lease operations may read but never create or update that approval. +Every open reapplies and verifies the exact database ACLs and sanitized capabilities, rechecks the +bounded catalog, and rejects approval, policy-digest, identity, or endpoint races before a token is +prepared. Cluster and control drift fail the attempt. Application-database drift revokes observer +`CONNECT` for that database and lets the remaining bounded inventory collect; the next open retries +full reconciliation. There is no persisted fingerprint, verification-age state, or reduced path. + +Administrative authority terminates at the collection-lease port and its scrubbed +container-local psql boundary; the collector and its observer credential never receive it. Any +open, collection, or close failure settles the attempt as a retryable failure with one generic +redacted reason, preserves the last-known-good cache, and prevents a fresh payload or cache commit. +Fresh cache state therefore always proves that mandatory cleanup succeeded. Logs and durable job +output never contain database names, provider output, credentials, resolved Compose configuration, +or raw administrative errors. + +The PostgreSQL close proof cannot prove that PgBouncer has no already-authenticated client waiting +for a server connection; PgBouncer admission is not transactionally coupled to the role-state +change. This design deliberately adds no exclusive-admission surface. Once close succeeds, +`NOLOGIN`, expired `VALID UNTIL`, and terminated PostgreSQL sessions prevent such a waiting client +from obtaining a new backend. If a waiting client interferes with open or close verification, the +attempt fails closed, retains last-known-good, and retries on the existing schedule. + +## Tracked PgBouncer verifier risk + +The private Docker repository currently tracks `/opt/docker/apps/pgbouncer/userlist.txt`. Its +SCRAM verifier is credential material even though it is not a cleartext password: repository access +and Git history extend its lifetime and permit offline guessing of a weak password. Before final +production cutover, replace it with a runtime-generated or equivalently secret-mounted, +non-versioned PgBouncer auth input, restrict it to `0600`, and rotate the affected PostgreSQL +credential after a rollback-capable authentication smoke test. History rewriting is a separate +destructive decision; rotation makes the retained historical verifier obsolete. Never print the +file, resolved Compose configuration, or secret values while validating the cutover. + +## Approval-gated apply and activation + +Never put a password, connection URL, SCRAM verifier, or other credential in this repository, +argv, shell history, CI output, or logs. The runner discovers the one healthy opted-in PgBouncer +Compose service and its one healthy local PostgreSQL dependency through a fixed, projected Docker +inventory. It pins the local Docker socket, root Compose file/project directory, observed project, +service index, OS user, local PostgreSQL socket, and container-local `/usr/local/bin/psql`. A fixed +`env -i` launcher carries only the existing non-secret `POSTGRES_USER`; it discards every other +container and host variable, including password and endpoint variables. SQL is expanded only from +bounded, descriptor-pinned files in the immutable artifact directory and sent over bounded stdin. +Every connection rechecks the probed superuser role OID and PostgreSQL system identifier before +SQL runs. The runner accepts no caller path, SQL text, database list, endpoint, or secret argument. + +Run cluster, activation, disable, and rollback SQL with psql `AUTOCOMMIT` on and no active +outer transaction. Do not use `psql --single-transaction` or `-1`. The committed NOLOGIN and +password-null quarantine intentionally survives any later verification failure. + +1. Inventory every non-owner application login that currently relies on `PUBLIC CONNECT` or + `PUBLIC TEMP`, grant only its reviewed direct requirement, and test it. Full reconciliation removes + all `PUBLIC` database privileges cluster-wide; do not proceed until that narrowing is safe. +2. Apply `apply-cluster.sql` through an approved administrative database. It creates or quarantines + the observer, namespace owner, and isolated capability owner; gives the observer zero + memberships; gives the `NOLOGIN` capability owner exactly `pg_read_all_stats`; terminates + reserved sessions; and establishes the role-only boundary while the observer remains disabled + and credential-free. +3. With psql autocommit enabled, apply `apply-control-database-capability.sql` through that + administrative connection with + `--set=apply_control_database_capability=approved`. `CREATE DATABASE` runs outside a + transaction, is idempotent, and creates or validates exactly the `template0`-derived physical + `mira_dashboard_observability` database before any database-local provisioning. +4. Set the observer password interactively with psql `\password`; never paste it into a SQL file + or argv. Keep the observer `NOLOGIN`. +5. Apply `apply-database-capabilities.sql` to every database in the current bounded catalog. It + grants the capability owner direct `SELECT` on `pg_catalog.pg_statistic`, installs the exact + `table_health()` and `maintenance_metrics()` functions, and grants the observer only their + `EXECUTE` boundary. In the fixed control database, then apply `apply-control-database.sql` with + `--set=apply_statement_capability=approved`. It installs or updates `pg_stat_statements`, revokes + raw extension relation/routine access from `PUBLIC` and the observer, and adds the sanitized + `connection_metrics()` and identity-free `statement_metrics()` functions. Optionally apply + `apply-torrent-view.sql` in `bitmagnet` and `comet` after reviewing their + `public.torrents` ownership and ACL. The artifact is intentionally first-install-only for + the `torrent_count` object and rejects unrelated objects in the private schema. +6. Configure PgBouncer's exact observer policy and single capability label. The existing wildcard + route needs no alias mapping or database environment variable. Compose the separate privileged + collection-lease port into the existing hourly `cache.refresh.database-observability` executor, + but do not add a second action, schedule, unit, loop, sidecar, login, or exclusive admission. + Provider absence must skip the port entirely. +7. On the first installation, run the immutable current release with the exact selected production + Bun runtime and `activate-current-catalog`: + + `/home/ubuntu/projects/mira-dashboard/production/runtimes/bun/current/bun /home/ubuntu/projects/mira-dashboard/production/releases/current/scripts/delivery/provisioning/database-observability/runProvisioning.ts activate-current-catalog --approved` + + This is the first runner operation after the manual prerequisites because + `verify-current-catalog` requires an existing matching approval. The runner first + idempotently revalidates the physical control capability, reconciles ACLs, applies and + re-verifies the four sanitized interfaces and their underlying source revocations, verifies + every discovered database and the activation approval boundary, and re-reads the full bounded + catalog fingerprint to reject add/remove/rename/template/connection-flag races. Only explicit + activation may create or refresh the approval binding to the current PostgreSQL system + identifier and the exact current and previous immutable-release policy digests. It must finish + with the observer `NOLOGIN`, expired, and with zero PostgreSQL sessions. + +8. After reviewing activation, run the same command with `verify-current-catalog`. This verifies + the approved cluster, capability-owner authority, exact source revocations, sanitized function + shapes and ACLs, and every database in one bounded catalog snapshot without enabling `LOGIN` or + installing a missing capability. On later releases, verification may run before activation only + when the retained approval already covers that release through its exact current or previous + policy digest. If neither digest is approved, explicit activation must run first. +9. Use one approved collection lease for the smoke check. Through PgBouncer's `pgbouncer` virtual + database, prove only the expected `SHOW POOLS` and `SHOW STATS` operations succeed. Confirm no + more than one retained observer backend per database and no more than 64 total, with at most two + observer clients, then require the mandatory close proof before accepting the smoke result. +10. Enable the worker provider configuration only after that smoke check passes. On its next + ordinary hourly run, confirm the existing cache job closes leftovers, opens the approved short + lease, collects through the observer, proves mandatory close, and only then lets the generic + executor commit bounded sanitized output. + +## Fail-closed rollback + +On any activation/smoke-check failure immediately run `disable-observer.sql` against +`mira_dashboard_observability`. It commits `NOLOGIN`, clears the observer password, and terminates +observer sessions. For full rollback: disable the worker provider configuration; disable the +observer; remove the reconciliation approval marker with +`rollback-reconciliation-approval.sql`; run optional +`rollback-torrent-view.sql` in `comet` and `bitmagnet` where installed; run +`rollback-control-database.sql`, then `rollback-database-capabilities.sql` in every provisioned +database; remove the PgBouncer policy; then run `rollback-cluster.sql` and finally +`rollback-control-database-capability.sql` in the retained control database. The access rollback +removes dedicated observer database grants but deliberately does not recreate unknowable broad +`PUBLIC` grants; restore reviewed application ACLs explicitly. The capability rollback verifies +that only reviewed extension objects remain and deliberately retains both the physical database +and `pg_stat_statements`; it never issues `DROP DATABASE` or guesses whether acquired data is +disposable. No rollback drops an application table or database. diff --git a/greenfield/scripts/delivery/provisioning/database-observability/activate-observer.sql b/greenfield/scripts/delivery/provisioning/database-observability/activate-observer.sql new file mode 100644 index 000000000..5371c8ab2 --- /dev/null +++ b/greenfield/scripts/delivery/provisioning/database-observability/activate-observer.sql @@ -0,0 +1,43 @@ +\set ON_ERROR_STOP 1 + +-- Explicit activation rotates the approval after the bounded runner has +-- applied and verified the current catalog. It may verify that the retained +-- credential is usable as a login boundary, but must finish closed. +\ir verify-cluster.sql +\ir verify-control-database-capability.sql +\ir verify-control-database.sql + +ALTER ROLE mira_dashboard_observer LOGIN + VALID UNTIL '1970-01-01 00:00:00+00'; + +DO $verify_activation$ +DECLARE + observer pg_catalog.pg_authid%ROWTYPE; +BEGIN + SELECT * INTO observer + FROM pg_catalog.pg_authid + WHERE rolname = 'mira_dashboard_observer'; + IF observer.oid IS NULL + OR NOT observer.rolcanlogin + OR NOT observer.rolinherit + OR observer.rolsuper + OR observer.rolcreatedb + OR observer.rolcreaterole + OR observer.rolreplication + OR observer.rolbypassrls + OR observer.rolconnlimit IS DISTINCT FROM 64 + OR observer.rolpassword IS NULL + OR observer.rolpassword NOT LIKE 'SCRAM-SHA-256$%' + OR observer.rolvaliduntil IS DISTINCT FROM + '1970-01-01 00:00:00+00'::timestamp with time zone + THEN + RAISE EXCEPTION 'Activated database observability observer role is invalid'; + END IF; +END +$verify_activation$; + +ALTER ROLE mira_dashboard_observer NOLOGIN + VALID UNTIL '1970-01-01 00:00:00+00'; +\ir verify-cluster.sql +\ir apply-reconciliation-approval.sql +\ir verify-reconciliation-approval.sql diff --git a/greenfield/scripts/delivery/provisioning/database-observability/apply-cluster.sql b/greenfield/scripts/delivery/provisioning/database-observability/apply-cluster.sql new file mode 100644 index 000000000..00be22efc --- /dev/null +++ b/greenfield/scripts/delivery/provisioning/database-observability/apply-cluster.sql @@ -0,0 +1,376 @@ +\set ON_ERROR_STOP 1 + +-- Quarantine both reserved identities in a committed transaction before any +-- privilege transition. A later failure must never restore an old login or +-- password on a same-named observer role. +DO $quarantine$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_catalog.pg_roles + WHERE rolname = 'mira_dashboard_observability_owner' + ) THEN + CREATE ROLE mira_dashboard_observability_owner + NOLOGIN NOINHERIT NOSUPERUSER NOCREATEDB NOCREATEROLE + NOREPLICATION NOBYPASSRLS; + ELSE + ALTER ROLE mira_dashboard_observability_owner + NOLOGIN NOINHERIT NOSUPERUSER NOCREATEDB NOCREATEROLE + NOREPLICATION NOBYPASSRLS; + END IF; + ALTER ROLE mira_dashboard_observability_owner PASSWORD NULL; + ALTER ROLE mira_dashboard_observability_owner RESET ALL; + + IF NOT EXISTS ( + SELECT 1 FROM pg_catalog.pg_roles + WHERE rolname = 'mira_dashboard_observability_capability_owner' + ) THEN + CREATE ROLE mira_dashboard_observability_capability_owner + NOLOGIN INHERIT NOSUPERUSER NOCREATEDB NOCREATEROLE + NOREPLICATION NOBYPASSRLS; + ELSE + ALTER ROLE mira_dashboard_observability_capability_owner + NOLOGIN INHERIT NOSUPERUSER NOCREATEDB NOCREATEROLE + NOREPLICATION NOBYPASSRLS; + END IF; + ALTER ROLE mira_dashboard_observability_capability_owner PASSWORD NULL; + ALTER ROLE mira_dashboard_observability_capability_owner RESET ALL; + + IF NOT EXISTS ( + SELECT 1 FROM pg_catalog.pg_roles + WHERE rolname = 'mira_dashboard_observer' + ) THEN + CREATE ROLE mira_dashboard_observer + NOLOGIN INHERIT NOSUPERUSER NOCREATEDB NOCREATEROLE + NOREPLICATION NOBYPASSRLS CONNECTION LIMIT 64; + ELSE + ALTER ROLE mira_dashboard_observer + NOLOGIN INHERIT NOSUPERUSER NOCREATEDB NOCREATEROLE + NOREPLICATION NOBYPASSRLS CONNECTION LIMIT 64; + END IF; + ALTER ROLE mira_dashboard_observer PASSWORD NULL; + ALTER ROLE mira_dashboard_observer + VALID UNTIL '1970-01-01 00:00:00+00'; + ALTER ROLE mira_dashboard_observer RESET ALL; + -- RESET ALL does not clear ALTER ROLE ... IN DATABASE settings. The exact + -- boundary below refuses every such override while the observer is NOLOGIN. + ALTER ROLE mira_dashboard_observer + SET default_transaction_read_only = on; + ALTER ROLE mira_dashboard_observer + SET statement_timeout = '5s'; + ALTER ROLE mira_dashboard_observer + SET idle_session_timeout = '60s'; + ALTER ROLE mira_dashboard_observer + SET idle_in_transaction_session_timeout = '60s'; +END +$quarantine$; + +-- A collection lease is a one-use, runner-owned comment. Provisioning always +-- returns the observer to the canonical closed state before qualification. +COMMENT ON ROLE mira_dashboard_observer IS NULL; + +-- NOLOGIN does not terminate an already-authenticated session. End every +-- reserved-role backend before privileges can be qualified or granted. +DO $terminate_reserved_sessions$ +DECLARE + reserved_session record; +BEGIN + FOR reserved_session IN + SELECT activity.pid + FROM pg_catalog.pg_stat_activity AS activity + WHERE activity.usename IN ( + 'mira_dashboard_observer', + 'mira_dashboard_observability_owner', + 'mira_dashboard_observability_capability_owner' + ) + AND activity.pid <> pg_catalog.pg_backend_pid() + LOOP + IF NOT pg_catalog.pg_terminate_backend(reserved_session.pid, 5000) THEN + RAISE EXCEPTION 'Database observability reserved-role session could not be terminated'; + END IF; + END LOOP; + + PERFORM pg_catalog.pg_stat_clear_snapshot(); + + IF EXISTS ( + SELECT 1 + FROM pg_catalog.pg_stat_activity AS activity + WHERE activity.usename IN ( + 'mira_dashboard_observer', + 'mira_dashboard_observability_owner', + 'mira_dashboard_observability_capability_owner' + ) + AND activity.pid <> pg_catalog.pg_backend_pid() + ) THEN + RAISE EXCEPTION 'Database observability reserved-role session remains active'; + END IF; +END +$terminate_reserved_sessions$; + +BEGIN; + +-- A same-named pre-existing role may only carry the two reviewed outbound +-- memberships. Inbound membership would bypass NOLOGIN through SET ROLE, and +-- the owner must be completely isolated in both directions. +DO $qualify_existing_roles$ +DECLARE + observer_oid oid; + owner_oid oid; + capability_owner_oid oid; + observer_direct_memberships text[]; + observer_inbound_membership_count bigint; + owner_membership_count bigint; + capability_owner_direct_memberships text[]; + capability_owner_inbound_membership_count bigint; +BEGIN + SELECT oid INTO STRICT observer_oid + FROM pg_catalog.pg_roles + WHERE rolname = 'mira_dashboard_observer'; + SELECT oid INTO STRICT owner_oid + FROM pg_catalog.pg_roles + WHERE rolname = 'mira_dashboard_observability_owner'; + SELECT oid INTO STRICT capability_owner_oid + FROM pg_catalog.pg_roles + WHERE rolname = 'mira_dashboard_observability_capability_owner'; + + SELECT COALESCE( + pg_catalog.array_agg(roles.rolname::text ORDER BY roles.rolname), + ARRAY[]::text[] + ) INTO observer_direct_memberships + FROM pg_catalog.pg_auth_members AS memberships + JOIN pg_catalog.pg_roles AS roles ON roles.oid = memberships.roleid + WHERE memberships.member = observer_oid; + + IF NOT observer_direct_memberships <@ ARRAY[ + 'pg_monitor', + 'pg_read_all_stats' + ]::text[] THEN + RAISE EXCEPTION 'Database observability observer has unexpected memberships'; + END IF; + + SELECT pg_catalog.count(*) INTO observer_inbound_membership_count + FROM pg_catalog.pg_auth_members + WHERE roleid = observer_oid; + IF observer_inbound_membership_count <> 0 THEN + RAISE EXCEPTION 'Database observability observer has inbound memberships'; + END IF; + + SELECT pg_catalog.count(*) INTO owner_membership_count + FROM pg_catalog.pg_auth_members + WHERE roleid = owner_oid OR member = owner_oid; + IF owner_membership_count <> 0 THEN + RAISE EXCEPTION 'Database observability view owner has memberships'; + END IF; + + SELECT COALESCE( + pg_catalog.array_agg(roles.rolname::text ORDER BY roles.rolname), + ARRAY[]::text[] + ) INTO capability_owner_direct_memberships + FROM pg_catalog.pg_auth_members AS memberships + JOIN pg_catalog.pg_roles AS roles ON roles.oid = memberships.roleid + WHERE memberships.member = capability_owner_oid; + IF NOT capability_owner_direct_memberships <@ ARRAY[ + 'pg_read_all_stats' + ]::text[] + OR EXISTS ( + SELECT 1 + FROM pg_catalog.pg_auth_members + WHERE member = capability_owner_oid + AND (admin_option OR NOT inherit_option OR set_option) + ) + THEN + RAISE EXCEPTION 'Database observability capability owner memberships are invalid'; + END IF; + SELECT pg_catalog.count(*) INTO capability_owner_inbound_membership_count + FROM pg_catalog.pg_auth_members + WHERE roleid = capability_owner_oid; + IF capability_owner_inbound_membership_count <> 0 THEN + RAISE EXCEPTION 'Database observability capability owner has inbound memberships'; + END IF; + IF EXISTS ( + SELECT 1 + FROM pg_catalog.pg_shdepend AS dependencies + WHERE dependencies.refclassid = 'pg_catalog.pg_authid'::pg_catalog.regclass + AND dependencies.refobjid = capability_owner_oid + AND dependencies.deptype = 'o' + AND NOT EXISTS ( + SELECT 1 FROM pg_catalog.pg_database AS databases + WHERE databases.oid = dependencies.dbid + AND NOT databases.datistemplate + AND databases.datallowconn + ) + ) THEN + RAISE EXCEPTION 'Database observability capability owner has out-of-scope ownership'; + END IF; +END +$qualify_existing_roles$; + +REVOKE pg_monitor FROM mira_dashboard_observer; +REVOKE pg_read_all_stats FROM mira_dashboard_observer; +REVOKE pg_monitor FROM mira_dashboard_observability_capability_owner; +REVOKE pg_read_all_stats FROM mira_dashboard_observability_capability_owner; +GRANT pg_read_all_stats TO mira_dashboard_observability_capability_owner + WITH ADMIN FALSE, INHERIT TRUE, SET FALSE; + +-- Commit only an exact, disabled principal with no password. Per-database and +-- view verification remains mandatory before the separate activation step. +DO $verify_disabled_boundary$ +DECLARE + observer pg_catalog.pg_authid%ROWTYPE; + observer_config text[]; + view_owner pg_catalog.pg_authid%ROWTYPE; + view_owner_config text[]; + capability_owner pg_catalog.pg_authid%ROWTYPE; + capability_owner_config text[]; + direct_memberships text[]; + observer_inbound_membership_count bigint; + owner_membership_count bigint; + capability_owner_inbound_membership_count bigint; +BEGIN + SELECT * INTO observer + FROM pg_catalog.pg_authid + WHERE rolname = 'mira_dashboard_observer'; + SELECT * INTO view_owner + FROM pg_catalog.pg_authid + WHERE rolname = 'mira_dashboard_observability_owner'; + SELECT * INTO capability_owner + FROM pg_catalog.pg_authid + WHERE rolname = 'mira_dashboard_observability_capability_owner'; + SELECT settings.setconfig INTO observer_config + FROM pg_catalog.pg_db_role_setting AS settings + WHERE settings.setrole = observer.oid AND settings.setdatabase = 0; + SELECT settings.setconfig INTO view_owner_config + FROM pg_catalog.pg_db_role_setting AS settings + WHERE settings.setrole = view_owner.oid AND settings.setdatabase = 0; + SELECT settings.setconfig INTO capability_owner_config + FROM pg_catalog.pg_db_role_setting AS settings + WHERE settings.setrole = capability_owner.oid AND settings.setdatabase = 0; + + IF observer.oid IS NULL + OR observer.rolcanlogin + OR NOT observer.rolinherit + OR observer.rolsuper + OR observer.rolcreatedb + OR observer.rolcreaterole + OR observer.rolreplication + OR observer.rolbypassrls + OR observer.rolconnlimit IS DISTINCT FROM 64 + OR observer.rolpassword IS NOT NULL + OR observer.rolvaliduntil IS DISTINCT FROM + '1970-01-01 00:00:00+00'::timestamp with time zone + OR pg_catalog.cardinality(observer_config) IS DISTINCT FROM 4 + OR NOT COALESCE( + observer_config @> ARRAY[ + 'default_transaction_read_only=on', + 'statement_timeout=5s', + 'idle_session_timeout=60s', + 'idle_in_transaction_session_timeout=60s' + ]::text[], + false + ) + THEN + RAISE EXCEPTION 'Disabled database observability observer role is invalid'; + END IF; + IF EXISTS ( + SELECT 1 + FROM pg_catalog.pg_db_role_setting AS settings + WHERE settings.setrole = observer.oid + AND settings.setdatabase <> 0 + ) THEN + RAISE EXCEPTION 'Database observability observer has database-scoped settings'; + END IF; + + IF view_owner.oid IS NULL + OR view_owner.rolcanlogin + OR view_owner.rolinherit + OR view_owner.rolsuper + OR view_owner.rolcreatedb + OR view_owner.rolcreaterole + OR view_owner.rolreplication + OR view_owner.rolbypassrls + OR view_owner.rolpassword IS NOT NULL + OR view_owner_config IS NOT NULL + THEN + RAISE EXCEPTION 'Database observability view owner is invalid'; + END IF; + IF capability_owner.oid IS NULL + OR capability_owner.rolcanlogin + OR NOT capability_owner.rolinherit + OR capability_owner.rolsuper + OR capability_owner.rolcreatedb + OR capability_owner.rolcreaterole + OR capability_owner.rolreplication + OR capability_owner.rolbypassrls + OR capability_owner.rolpassword IS NOT NULL + OR capability_owner_config IS NOT NULL + THEN + RAISE EXCEPTION 'Database observability capability owner is invalid'; + END IF; + IF EXISTS ( + SELECT 1 + FROM pg_catalog.pg_db_role_setting AS settings + WHERE settings.setrole = view_owner.oid + AND settings.setdatabase <> 0 + ) THEN + RAISE EXCEPTION 'Database observability view owner has database-scoped settings'; + END IF; + + SELECT COALESCE( + pg_catalog.array_agg(roles.rolname::text ORDER BY roles.rolname), + ARRAY[]::text[] + ) INTO direct_memberships + FROM pg_catalog.pg_auth_members AS memberships + JOIN pg_catalog.pg_roles AS roles ON roles.oid = memberships.roleid + WHERE memberships.member = observer.oid; + IF direct_memberships IS DISTINCT FROM ARRAY[]::text[] THEN + RAISE EXCEPTION 'Database observability observer memberships are invalid'; + END IF; + IF EXISTS ( + SELECT 1 + FROM pg_catalog.pg_auth_members + WHERE member = observer.oid + AND (admin_option OR NOT inherit_option OR NOT set_option) + ) THEN + RAISE EXCEPTION 'Database observability observer membership options are invalid'; + END IF; + + SELECT pg_catalog.count(*) INTO observer_inbound_membership_count + FROM pg_catalog.pg_auth_members + WHERE roleid = observer.oid; + IF observer_inbound_membership_count <> 0 THEN + RAISE EXCEPTION 'Database observability observer has inbound memberships'; + END IF; + + SELECT pg_catalog.count(*) INTO owner_membership_count + FROM pg_catalog.pg_auth_members + WHERE roleid = view_owner.oid OR member = view_owner.oid; + IF owner_membership_count <> 0 THEN + RAISE EXCEPTION 'Database observability view owner memberships are invalid'; + END IF; + + SELECT COALESCE( + pg_catalog.array_agg(roles.rolname::text ORDER BY roles.rolname), + ARRAY[]::text[] + ) INTO direct_memberships + FROM pg_catalog.pg_auth_members AS memberships + JOIN pg_catalog.pg_roles AS roles ON roles.oid = memberships.roleid + WHERE memberships.member = capability_owner.oid; + IF direct_memberships IS DISTINCT FROM ARRAY['pg_read_all_stats']::text[] + OR EXISTS ( + SELECT 1 FROM pg_catalog.pg_auth_members + WHERE member = capability_owner.oid + AND (admin_option OR NOT inherit_option OR set_option) + ) + THEN + RAISE EXCEPTION 'Database observability capability owner memberships are invalid'; + END IF; + SELECT pg_catalog.count(*) INTO capability_owner_inbound_membership_count + FROM pg_catalog.pg_auth_members + WHERE roleid = capability_owner.oid; + IF capability_owner_inbound_membership_count <> 0 THEN + RAISE EXCEPTION 'Database observability capability owner has inbound memberships'; + END IF; + +END +$verify_disabled_boundary$; + +COMMIT; diff --git a/greenfield/scripts/delivery/provisioning/database-observability/apply-control-database-capability.sql b/greenfield/scripts/delivery/provisioning/database-observability/apply-control-database-capability.sql new file mode 100644 index 000000000..f0cff5a94 --- /dev/null +++ b/greenfield/scripts/delivery/provisioning/database-observability/apply-control-database-capability.sql @@ -0,0 +1,137 @@ +\set ON_ERROR_STOP 1 + +-- CREATE DATABASE cannot run inside a transaction. This psql-only artifact +-- emits at most one fixed CREATE statement and lets \gexec autocommit it. +SET SESSION mira_dashboard.apply_control_database_capability + TO :'apply_control_database_capability'; + +DO $approval_and_preflight$ +DECLARE + capability_exists boolean; + catalog_database_count bigint; + observed_database_count bigint; + execution_role_is_superuser boolean; +BEGIN + SELECT rolsuper INTO execution_role_is_superuser + FROM pg_catalog.pg_roles + WHERE rolname = CURRENT_USER; + SELECT EXISTS ( + SELECT 1 FROM pg_catalog.pg_database + WHERE datname = 'mira_dashboard_observability' + ) INTO capability_exists; + SELECT + pg_catalog.count(*), + pg_catalog.count(*) FILTER ( + WHERE NOT datistemplate AND datallowconn + ) + INTO catalog_database_count, observed_database_count + FROM pg_catalog.pg_database; + + IF pg_catalog.current_setting( + 'mira_dashboard.apply_control_database_capability', + true + ) IS DISTINCT FROM 'approved' + OR NOT execution_role_is_superuser + OR catalog_database_count + CASE WHEN capability_exists THEN 0 ELSE 1 END > 80 + OR observed_database_count + CASE WHEN capability_exists THEN 0 ELSE 1 END > 64 + THEN + RAISE EXCEPTION 'Database observability control capability preflight failed'; + END IF; + + IF capability_exists AND NOT EXISTS ( + SELECT 1 + FROM pg_catalog.pg_database AS capability + JOIN pg_catalog.pg_database AS template + ON template.datname = 'template0' + JOIN pg_catalog.pg_roles AS owners ON owners.oid = capability.datdba + WHERE capability.datname = 'mira_dashboard_observability' + AND owners.rolname = CURRENT_USER + AND owners.rolsuper + AND NOT capability.datistemplate + AND capability.datallowconn + AND NOT capability.dathasloginevt + AND capability.datconnlimit = 4 + AND capability.encoding = template.encoding + AND capability.datlocprovider = template.datlocprovider + AND capability.datcollate = template.datcollate + AND capability.datctype = template.datctype + AND capability.datlocale IS NOT DISTINCT FROM template.datlocale + AND capability.daticurules IS NOT DISTINCT FROM template.daticurules + AND capability.dattablespace = template.dattablespace + ) THEN + RAISE EXCEPTION 'Database observability control capability is invalid'; + END IF; +END +$approval_and_preflight$; + +SELECT pg_catalog.format( + 'CREATE DATABASE mira_dashboard_observability OWNER %I TEMPLATE template0 CONNECTION LIMIT 4 STRATEGY WAL_LOG', + CURRENT_USER +) +WHERE NOT EXISTS ( + SELECT 1 FROM pg_catalog.pg_database + WHERE datname = 'mira_dashboard_observability' +) +\gexec + +REVOKE ALL PRIVILEGES ON DATABASE mira_dashboard_observability FROM PUBLIC; + +DO $verify_created_capability$ +DECLARE + capability_oid oid; + catalog_database_count bigint; + observed_database_count bigint; +BEGIN + SELECT capability.oid INTO capability_oid + FROM pg_catalog.pg_database AS capability + JOIN pg_catalog.pg_database AS template + ON template.datname = 'template0' + JOIN pg_catalog.pg_roles AS owners ON owners.oid = capability.datdba + WHERE capability.datname = 'mira_dashboard_observability' + AND owners.rolname = CURRENT_USER + AND owners.rolsuper + AND NOT capability.datistemplate + AND capability.datallowconn + AND NOT capability.dathasloginevt + AND capability.datconnlimit = 4 + AND capability.encoding = template.encoding + AND capability.datlocprovider = template.datlocprovider + AND capability.datcollate = template.datcollate + AND capability.datctype = template.datctype + AND capability.datlocale IS NOT DISTINCT FROM template.datlocale + AND capability.daticurules IS NOT DISTINCT FROM template.daticurules + AND capability.dattablespace = template.dattablespace; + + SELECT + pg_catalog.count(*), + pg_catalog.count(*) FILTER ( + WHERE NOT databases.datistemplate AND databases.datallowconn + ) + INTO catalog_database_count, observed_database_count + FROM pg_catalog.pg_database AS databases; + + IF capability_oid IS NULL + OR catalog_database_count > 80 + OR observed_database_count > 64 + OR EXISTS ( + SELECT 1 + FROM pg_catalog.pg_database AS capability + CROSS JOIN LATERAL pg_catalog.aclexplode(capability.datacl) AS grants + LEFT JOIN pg_catalog.pg_roles AS grantees ON grantees.oid = grants.grantee + WHERE capability.oid = capability_oid + AND ( + grants.grantee = 0 + OR grantees.rolname NOT IN ( + CURRENT_USER, + 'mira_dashboard_observer', + 'mira_dashboard_database_access_reconciler' + ) + ) + ) + THEN + RAISE EXCEPTION 'Database observability control capability verification failed'; + END IF; +END +$verify_created_capability$; + +RESET mira_dashboard.apply_control_database_capability; diff --git a/greenfield/scripts/delivery/provisioning/database-observability/apply-control-database.sql b/greenfield/scripts/delivery/provisioning/database-observability/apply-control-database.sql new file mode 100644 index 000000000..3cb98dfb8 --- /dev/null +++ b/greenfield/scripts/delivery/provisioning/database-observability/apply-control-database.sql @@ -0,0 +1,412 @@ +\set ON_ERROR_STOP 1 + +BEGIN; + +SET LOCAL mira_dashboard.apply_statement_capability + TO :'apply_statement_capability'; + +DO $approval_guard$ +DECLARE + database_owner_oid oid; + execution_role_oid oid; + database_owner_is_superuser boolean; +BEGIN + SELECT databases.datdba, owners.rolsuper + INTO database_owner_oid, database_owner_is_superuser + FROM pg_catalog.pg_database AS databases + JOIN pg_catalog.pg_roles AS owners ON owners.oid = databases.datdba + WHERE databases.datname = pg_catalog.current_database() + AND databases.datname = 'mira_dashboard_observability' + AND NOT databases.datistemplate + AND databases.datallowconn; + SELECT roles.oid INTO execution_role_oid + FROM pg_catalog.pg_roles AS roles + WHERE roles.rolname = CURRENT_USER; + + IF pg_catalog.current_setting( + 'mira_dashboard.apply_statement_capability', + true + ) IS DISTINCT FROM 'approved' + OR database_owner_oid IS NULL + OR execution_role_oid IS DISTINCT FROM database_owner_oid + OR NOT database_owner_is_superuser + THEN + RAISE EXCEPTION 'Database observability control apply requires approval'; + END IF; +END +$approval_guard$; + +DROP FUNCTION IF EXISTS + mira_dashboard_observability_capabilities.statement_metrics(); +DROP FUNCTION IF EXISTS + mira_dashboard_observability_capabilities.connection_metrics(); + +CREATE EXTENSION IF NOT EXISTS pg_stat_statements WITH SCHEMA public; + +DO $reviewed_extension_boundary$ +DECLARE + database_owner_oid oid; + extension_oid oid; + extension_schema_oid oid; + member_count bigint; + source_routine pg_catalog.pg_proc%ROWTYPE; + extension_member record; +BEGIN + SELECT databases.datdba INTO database_owner_oid + FROM pg_catalog.pg_database AS databases + WHERE databases.datname = pg_catalog.current_database(); + SELECT extensions.oid, extensions.extnamespace + INTO extension_oid, extension_schema_oid + FROM pg_catalog.pg_extension AS extensions + JOIN pg_catalog.pg_namespace AS namespaces + ON namespaces.oid = extensions.extnamespace + WHERE extensions.extname = 'pg_stat_statements' + AND namespaces.nspname = 'public' + AND extensions.extowner = database_owner_oid; + + SELECT pg_catalog.count(*) INTO member_count + FROM pg_catalog.pg_depend AS dependencies + WHERE dependencies.refclassid = + 'pg_catalog.pg_extension'::pg_catalog.regclass + AND dependencies.refobjid = extension_oid + AND dependencies.deptype = 'e' + AND dependencies.objsubid = 0; + + IF database_owner_oid IS NULL + OR extension_oid IS NULL + OR member_count NOT BETWEEN 1 AND 64 + OR EXISTS ( + SELECT 1 + FROM pg_catalog.pg_depend AS dependencies + WHERE dependencies.refclassid = + 'pg_catalog.pg_extension'::pg_catalog.regclass + AND dependencies.refobjid = extension_oid + AND dependencies.deptype = 'e' + AND dependencies.objsubid = 0 + AND NOT ( + dependencies.classid = 'pg_catalog.pg_class'::pg_catalog.regclass + AND EXISTS ( + SELECT 1 + FROM pg_catalog.pg_class AS classes + JOIN pg_catalog.pg_extension AS extensions + ON extensions.oid = extension_oid + AND classes.relnamespace = extensions.extnamespace + WHERE classes.oid = dependencies.objid + AND classes.relkind = 'v' + AND classes.relowner = database_owner_oid + ) + OR dependencies.classid = 'pg_catalog.pg_proc'::pg_catalog.regclass + AND EXISTS ( + SELECT 1 + FROM pg_catalog.pg_proc AS routines + JOIN pg_catalog.pg_language AS languages + ON languages.oid = routines.prolang + WHERE routines.oid = dependencies.objid + AND routines.pronamespace = extension_schema_oid + AND routines.proowner = database_owner_oid + AND routines.prokind = 'f' + AND languages.lanname = 'c' + AND routines.probin = '$libdir/pg_stat_statements' + AND NOT routines.prosecdef + AND NOT routines.proleakproof + ) + OR dependencies.classid = 'pg_catalog.pg_type'::pg_catalog.regclass + AND EXISTS ( + SELECT 1 + FROM pg_catalog.pg_type AS types + WHERE types.oid = dependencies.objid + AND types.typnamespace = extension_schema_oid + AND types.typowner = database_owner_oid + AND ( + types.typtype = 'c' + AND EXISTS ( + SELECT 1 + FROM pg_catalog.pg_class AS classes + WHERE classes.oid = types.typrelid + AND classes.relnamespace = extension_schema_oid + AND classes.relkind = 'v' + ) + OR types.typtype = 'b' + AND types.typcategory = 'A' + AND EXISTS ( + SELECT 1 + FROM pg_catalog.pg_type AS elements + JOIN pg_catalog.pg_depend AS element_dependencies + ON element_dependencies.classid = + 'pg_catalog.pg_type'::pg_catalog.regclass + AND element_dependencies.objid = elements.oid + AND element_dependencies.objsubid = 0 + AND element_dependencies.refclassid = + 'pg_catalog.pg_extension'::pg_catalog.regclass + AND element_dependencies.refobjid = extension_oid + AND element_dependencies.deptype = 'e' + WHERE elements.oid = types.typelem + AND elements.typtype = 'c' + ) + ) + ) + ) + ) + THEN + RAISE EXCEPTION 'pg_stat_statements reviewed extension boundary is invalid'; + END IF; + + SELECT routines.* INTO source_routine + FROM pg_catalog.pg_proc AS routines + JOIN pg_catalog.pg_depend AS dependencies + ON dependencies.classid = 'pg_catalog.pg_proc'::pg_catalog.regclass + AND dependencies.objid = routines.oid + AND dependencies.objsubid = 0 + AND dependencies.refclassid = + 'pg_catalog.pg_extension'::pg_catalog.regclass + AND dependencies.refobjid = extension_oid + AND dependencies.deptype = 'e' + WHERE routines.pronamespace = extension_schema_oid + AND routines.proname = 'pg_stat_statements' + AND routines.pronargs = 1 + AND routines.proargtypes = + ARRAY['pg_catalog.bool'::pg_catalog.regtype]::oidvector; + + IF source_routine.oid IS NULL + OR source_routine.proowner <> database_owner_oid + OR source_routine.prokind <> 'f' + OR source_routine.probin <> '$libdir/pg_stat_statements' + OR source_routine.prosecdef + OR source_routine.proleakproof + OR source_routine.prorettype <> 'pg_catalog.record'::pg_catalog.regtype + OR NOT source_routine.proretset + OR EXISTS ( + SELECT 1 + FROM (VALUES + ('dbid'::text, 'pg_catalog.oid'::pg_catalog.regtype), + ('userid'::text, 'pg_catalog.oid'::pg_catalog.regtype), + ('queryid'::text, 'pg_catalog.int8'::pg_catalog.regtype), + ('calls'::text, 'pg_catalog.int8'::pg_catalog.regtype), + ('total_exec_time'::text, 'pg_catalog.float8'::pg_catalog.regtype), + ('mean_exec_time'::text, 'pg_catalog.float8'::pg_catalog.regtype), + ('rows'::text, 'pg_catalog.int8'::pg_catalog.regtype), + ('shared_blks_hit'::text, 'pg_catalog.int8'::pg_catalog.regtype), + ('shared_blks_read'::text, 'pg_catalog.int8'::pg_catalog.regtype) + ) AS required(field_name, field_type) + WHERE ( + SELECT pg_catalog.count(*) + FROM pg_catalog.generate_subscripts( + source_routine.proallargtypes, + 1 + ) AS positions(position) + WHERE source_routine.proargnames[positions.position] = required.field_name + AND source_routine.proargmodes[positions.position] = 'o' + AND source_routine.proallargtypes[positions.position] = required.field_type + ) <> 1 + ) + THEN + RAISE EXCEPTION 'pg_stat_statements reviewed source shape is invalid'; + END IF; + + FOR extension_member IN + SELECT namespaces.nspname, classes.relname + FROM pg_catalog.pg_class AS classes + JOIN pg_catalog.pg_namespace AS namespaces + ON namespaces.oid = classes.relnamespace + JOIN pg_catalog.pg_depend AS dependencies + ON dependencies.classid = 'pg_catalog.pg_class'::pg_catalog.regclass + AND dependencies.objid = classes.oid + AND dependencies.objsubid = 0 + AND dependencies.refclassid = + 'pg_catalog.pg_extension'::pg_catalog.regclass + AND dependencies.refobjid = extension_oid + AND dependencies.deptype = 'e' + ORDER BY classes.oid + LOOP + EXECUTE pg_catalog.format( + 'REVOKE ALL PRIVILEGES ON TABLE %I.%I FROM PUBLIC, mira_dashboard_observer, mira_dashboard_observability_owner, mira_dashboard_observability_capability_owner, pg_read_all_stats', + extension_member.nspname, + extension_member.relname + ); + END LOOP; + + FOR extension_member IN + SELECT routines.oid + FROM pg_catalog.pg_proc AS routines + JOIN pg_catalog.pg_depend AS dependencies + ON dependencies.classid = 'pg_catalog.pg_proc'::pg_catalog.regclass + AND dependencies.objid = routines.oid + AND dependencies.objsubid = 0 + AND dependencies.refclassid = + 'pg_catalog.pg_extension'::pg_catalog.regclass + AND dependencies.refobjid = extension_oid + AND dependencies.deptype = 'e' + ORDER BY routines.oid + LOOP + EXECUTE pg_catalog.format( + 'REVOKE ALL PRIVILEGES ON FUNCTION %s FROM PUBLIC, mira_dashboard_observer, mira_dashboard_observability_owner, mira_dashboard_observability_capability_owner, pg_read_all_stats', + extension_member.oid::pg_catalog.regprocedure + ); + END LOOP; + + EXECUTE pg_catalog.format( + 'GRANT EXECUTE ON FUNCTION %s TO mira_dashboard_observability_capability_owner', + source_routine.oid::pg_catalog.regprocedure + ); +END +$reviewed_extension_boundary$; + +DO $capability_schema_boundary$ +DECLARE + capability_owner_oid oid; + capability_schema_oid oid; + view_owner_oid oid; +BEGIN + SELECT roles.oid INTO capability_owner_oid + FROM pg_catalog.pg_roles AS roles + WHERE roles.rolname = 'mira_dashboard_observability_capability_owner'; + SELECT roles.oid INTO view_owner_oid + FROM pg_catalog.pg_roles AS roles + WHERE roles.rolname = 'mira_dashboard_observability_owner'; + SELECT namespaces.oid INTO capability_schema_oid + FROM pg_catalog.pg_namespace AS namespaces + WHERE namespaces.nspname = 'mira_dashboard_observability_capabilities'; + + IF capability_owner_oid IS NULL + OR view_owner_oid IS NULL + OR capability_schema_oid IS NULL + OR (SELECT namespaces.nspowner + FROM pg_catalog.pg_namespace AS namespaces + WHERE namespaces.oid = capability_schema_oid) + IS DISTINCT FROM view_owner_oid + OR EXISTS ( + SELECT 1 FROM pg_catalog.pg_class AS classes + WHERE classes.relnamespace = capability_schema_oid + ) + OR EXISTS ( + SELECT 1 FROM pg_catalog.pg_type AS types + WHERE types.typnamespace = capability_schema_oid + ) + OR EXISTS ( + SELECT 1 FROM pg_catalog.pg_proc AS routines + WHERE routines.pronamespace = capability_schema_oid + AND NOT ( + routines.pronargs = 0 + AND routines.proowner = capability_owner_oid + AND routines.proname IN ('table_health', 'maintenance_metrics') + ) + ) + THEN + RAISE EXCEPTION 'Database observability control capability boundary is invalid'; + END IF; +END +$capability_schema_boundary$; + +CREATE FUNCTION mira_dashboard_observability_capabilities.connection_metrics() +RETURNS TABLE ( + active_connections bigint, + idle_connections bigint, + total_connections bigint +) +LANGUAGE sql +VOLATILE +PARALLEL UNSAFE +SECURITY DEFINER +ROWS 1 +SET search_path TO pg_catalog, pg_temp +SET statement_timeout TO '5s' +BEGIN ATOMIC + WITH observed_databases AS MATERIALIZED ( + SELECT databases.datname + FROM pg_catalog.pg_database AS databases + WHERE NOT databases.datistemplate + AND databases.datallowconn + ), bounded_databases AS ( + SELECT pg_catalog.array_agg( + observed_databases.datname ORDER BY observed_databases.datname + ) AS database_names + FROM observed_databases + HAVING pg_catalog.count(*) <= 64 + ) + SELECT pg_catalog.count(activity.pid) FILTER ( + WHERE activity.state = 'active' + )::bigint AS active_connections, + pg_catalog.count(activity.pid) FILTER ( + WHERE activity.state = 'idle' + )::bigint AS idle_connections, + pg_catalog.count(activity.pid)::bigint AS total_connections + FROM bounded_databases + LEFT JOIN pg_catalog.pg_stat_activity AS activity + ON activity.backend_type = 'client backend' + AND activity.datname = ANY(bounded_databases.database_names) + GROUP BY bounded_databases.database_names; +END; + +ALTER FUNCTION mira_dashboard_observability_capabilities.connection_metrics() + OWNER TO mira_dashboard_observability_capability_owner; + +CREATE FUNCTION mira_dashboard_observability_capabilities.statement_metrics() +RETURNS TABLE ( + calls bigint, + total_execution_ms double precision, + mean_execution_ms double precision, + rows bigint, + shared_blocks_hit bigint, + shared_blocks_read bigint +) +LANGUAGE sql +VOLATILE +PARALLEL UNSAFE +SECURITY DEFINER +ROWS 20 +SET search_path TO pg_catalog, pg_temp +SET statement_timeout TO '5s' +BEGIN ATOMIC + WITH observed_databases AS MATERIALIZED ( + SELECT databases.oid, databases.datname + FROM pg_catalog.pg_database AS databases + WHERE NOT databases.datistemplate + AND databases.datallowconn + ), bounded_databases AS MATERIALIZED ( + SELECT pg_catalog.array_agg( + observed_databases.oid ORDER BY observed_databases.datname + ) AS database_oids + FROM observed_databases + HAVING pg_catalog.count(*) <= 64 + ) + SELECT statements.calls::bigint, + statements.total_exec_time::double precision AS total_execution_ms, + statements.mean_exec_time::double precision AS mean_execution_ms, + statements.rows::bigint, + statements.shared_blks_hit::bigint, + statements.shared_blks_read::bigint + FROM bounded_databases + JOIN public.pg_stat_statements(false) AS statements + ON statements.dbid = ANY(bounded_databases.database_oids) + ORDER BY statements.total_exec_time DESC, + statements.calls DESC, + statements.rows DESC, + statements.dbid, + statements.userid, + statements.queryid + LIMIT 20; +END; + +ALTER FUNCTION mira_dashboard_observability_capabilities.statement_metrics() + OWNER TO mira_dashboard_observability_capability_owner; + +REVOKE ALL PRIVILEGES ON FUNCTION + mira_dashboard_observability_capabilities.connection_metrics() FROM PUBLIC; +REVOKE ALL PRIVILEGES ON FUNCTION + mira_dashboard_observability_capabilities.connection_metrics() + FROM mira_dashboard_observer; +GRANT EXECUTE ON FUNCTION + mira_dashboard_observability_capabilities.connection_metrics() + TO mira_dashboard_observer; +REVOKE ALL PRIVILEGES ON FUNCTION + mira_dashboard_observability_capabilities.statement_metrics() FROM PUBLIC; +REVOKE ALL PRIVILEGES ON FUNCTION + mira_dashboard_observability_capabilities.statement_metrics() + FROM mira_dashboard_observer; +GRANT EXECUTE ON FUNCTION + mira_dashboard_observability_capabilities.statement_metrics() + TO mira_dashboard_observer; + +COMMIT; + +\ir verify-control-database.sql diff --git a/greenfield/scripts/delivery/provisioning/database-observability/apply-database-access-reconciler.sql b/greenfield/scripts/delivery/provisioning/database-observability/apply-database-access-reconciler.sql new file mode 100644 index 000000000..8a6c3e03f --- /dev/null +++ b/greenfield/scripts/delivery/provisioning/database-observability/apply-database-access-reconciler.sql @@ -0,0 +1,150 @@ +\set ON_ERROR_STOP 1 + +BEGIN; + +DO $administrator_boundary$ +DECLARE + administrator_oid oid; +BEGIN + SELECT databases.datdba INTO administrator_oid + FROM pg_catalog.pg_database AS databases + JOIN pg_catalog.pg_roles AS owners ON owners.oid = databases.datdba + WHERE databases.datname = pg_catalog.current_database() + AND databases.datname = 'mira_dashboard_observability' + AND NOT databases.datistemplate + AND databases.datallowconn + AND owners.rolsuper + AND owners.rolname = CURRENT_USER; + IF administrator_oid IS NULL + OR EXISTS ( + SELECT 1 FROM pg_catalog.pg_roles AS roles + WHERE roles.rolname = 'mira_dashboard_database_access_reconciler' + ) + OR (SELECT pg_catalog.count(*) FROM pg_catalog.pg_database) > 80 + OR (SELECT pg_catalog.count(*) FROM pg_catalog.pg_database AS databases + WHERE NOT databases.datistemplate AND databases.datallowconn) > 64 + THEN + RAISE EXCEPTION 'Database access reconciler administrator is invalid'; + END IF; +END +$administrator_boundary$; + +CREATE SCHEMA IF NOT EXISTS mira_dashboard_database_access + AUTHORIZATION CURRENT_USER; +REVOKE ALL PRIVILEGES ON SCHEMA mira_dashboard_database_access FROM PUBLIC; +REVOKE ALL PRIVILEGES ON SCHEMA mira_dashboard_database_access + FROM mira_dashboard_observer, + mira_dashboard_observability_owner, + mira_dashboard_observability_capability_owner; + +CREATE OR REPLACE FUNCTION mira_dashboard_database_access.reconcile() +RETURNS integer +LANGUAGE plpgsql +VOLATILE +PARALLEL UNSAFE +SECURITY DEFINER +SET search_path TO pg_catalog, pg_temp +SET lock_timeout TO '2s' +SET statement_timeout TO '30s' +AS $reconcile$ +DECLARE + database_record record; + initial_oids oid[]; + initial_names text[]; + initial_template_flags boolean[]; + initial_connection_flags boolean[]; + initial_owner_oids oid[]; + final_oids oid[]; + final_names text[]; + final_template_flags boolean[]; + final_connection_flags boolean[]; + final_owner_oids oid[]; + observed_database_count bigint; + catalog_database_count bigint; +BEGIN + PERFORM pg_catalog.pg_advisory_xact_lock(1296646465, 1128351300); + SELECT pg_catalog.count(*), + pg_catalog.count(*) FILTER ( + WHERE NOT databases.datistemplate AND databases.datallowconn + ), + pg_catalog.array_agg(databases.oid ORDER BY databases.oid), + pg_catalog.array_agg(databases.datname::text ORDER BY databases.oid), + pg_catalog.array_agg(databases.datistemplate ORDER BY databases.oid), + pg_catalog.array_agg(databases.datallowconn ORDER BY databases.oid), + pg_catalog.array_agg(databases.datdba ORDER BY databases.oid) + INTO catalog_database_count, + observed_database_count, + initial_oids, + initial_names, + initial_template_flags, + initial_connection_flags, + initial_owner_oids + FROM pg_catalog.pg_database AS databases; + + IF catalog_database_count = 0 + OR catalog_database_count > 80 + OR observed_database_count = 0 + OR observed_database_count > 64 + OR pg_catalog.current_database() <> 'mira_dashboard_observability' + THEN + RAISE EXCEPTION 'Database access reconciliation catalog boundary is invalid'; + END IF; + + FOR database_record IN + SELECT databases.datname, + databases.datistemplate, + databases.datallowconn + FROM pg_catalog.pg_database AS databases + ORDER BY databases.datname + LOOP + EXECUTE pg_catalog.format( + 'REVOKE ALL PRIVILEGES ON DATABASE %I FROM mira_dashboard_observer CASCADE', + database_record.datname + ); + EXECUTE pg_catalog.format( + 'REVOKE ALL PRIVILEGES ON DATABASE %I FROM PUBLIC', + database_record.datname + ); + IF NOT database_record.datistemplate AND database_record.datallowconn THEN + EXECUTE pg_catalog.format( + 'GRANT CONNECT ON DATABASE %I TO mira_dashboard_observer', + database_record.datname + ); + END IF; + END LOOP; + + SELECT pg_catalog.array_agg(databases.oid ORDER BY databases.oid), + pg_catalog.array_agg(databases.datname::text ORDER BY databases.oid), + pg_catalog.array_agg(databases.datistemplate ORDER BY databases.oid), + pg_catalog.array_agg(databases.datallowconn ORDER BY databases.oid), + pg_catalog.array_agg(databases.datdba ORDER BY databases.oid) + INTO final_oids, + final_names, + final_template_flags, + final_connection_flags, + final_owner_oids + FROM pg_catalog.pg_database AS databases; + IF final_oids IS DISTINCT FROM initial_oids + OR final_names IS DISTINCT FROM initial_names + OR final_template_flags IS DISTINCT FROM initial_template_flags + OR final_connection_flags IS DISTINCT FROM initial_connection_flags + OR final_owner_oids IS DISTINCT FROM initial_owner_oids + THEN + RAISE EXCEPTION 'Database access reconciliation catalog changed'; + END IF; + RETURN observed_database_count::integer; +END +$reconcile$; + +ALTER FUNCTION mira_dashboard_database_access.reconcile() OWNER TO CURRENT_USER; +REVOKE ALL PRIVILEGES ON FUNCTION + mira_dashboard_database_access.reconcile() FROM PUBLIC; +REVOKE ALL PRIVILEGES ON FUNCTION + mira_dashboard_database_access.reconcile() + FROM mira_dashboard_observer, + mira_dashboard_observability_owner, + mira_dashboard_observability_capability_owner; + +SELECT mira_dashboard_database_access.reconcile(); + +COMMIT; diff --git a/greenfield/scripts/delivery/provisioning/database-observability/apply-database-capabilities.sql b/greenfield/scripts/delivery/provisioning/database-observability/apply-database-capabilities.sql new file mode 100644 index 000000000..c3a8136e4 --- /dev/null +++ b/greenfield/scripts/delivery/provisioning/database-observability/apply-database-capabilities.sql @@ -0,0 +1,433 @@ +\set ON_ERROR_STOP 1 + +BEGIN; + +DO $capability_boundary$ +DECLARE + capability_owner_oid oid; + capability_schema_oid oid; + view_owner_oid oid; +BEGIN + SELECT roles.oid INTO capability_owner_oid + FROM pg_catalog.pg_roles AS roles + WHERE roles.rolname = 'mira_dashboard_observability_capability_owner' + AND NOT roles.rolcanlogin + AND roles.rolinherit + AND NOT roles.rolsuper + AND NOT roles.rolcreatedb + AND NOT roles.rolcreaterole + AND NOT roles.rolreplication + AND NOT roles.rolbypassrls; + SELECT roles.oid INTO view_owner_oid + FROM pg_catalog.pg_roles AS roles + WHERE roles.rolname = 'mira_dashboard_observability_owner' + AND NOT roles.rolcanlogin + AND NOT roles.rolinherit + AND NOT roles.rolsuper + AND NOT roles.rolcreatedb + AND NOT roles.rolcreaterole + AND NOT roles.rolreplication + AND NOT roles.rolbypassrls; + + IF capability_owner_oid IS NULL + OR view_owner_oid IS NULL + OR NOT EXISTS ( + SELECT 1 + FROM pg_catalog.pg_roles AS execution_role + WHERE execution_role.rolname = CURRENT_USER + AND execution_role.rolsuper + ) + THEN + RAISE EXCEPTION 'Database observability capability administrator is invalid'; + END IF; + + SELECT namespaces.oid INTO capability_schema_oid + FROM pg_catalog.pg_namespace AS namespaces + WHERE namespaces.nspname = 'mira_dashboard_observability_capabilities'; + + IF capability_schema_oid IS NOT NULL AND ( + (SELECT namespaces.nspowner + FROM pg_catalog.pg_namespace AS namespaces + WHERE namespaces.oid = capability_schema_oid) + IS DISTINCT FROM view_owner_oid + OR EXISTS ( + SELECT 1 + FROM pg_catalog.pg_class AS classes + WHERE classes.relnamespace = capability_schema_oid + ) + OR EXISTS ( + SELECT 1 + FROM pg_catalog.pg_type AS types + WHERE types.typnamespace = capability_schema_oid + ) + OR EXISTS ( + SELECT 1 + FROM pg_catalog.pg_proc AS routines + WHERE routines.pronamespace = capability_schema_oid + AND NOT ( + routines.pronargs = 0 + AND routines.proowner = capability_owner_oid + AND routines.proname IN ('table_health', 'maintenance_metrics') + OR pg_catalog.current_database() = 'mira_dashboard_observability' + AND routines.pronargs = 0 + AND routines.proowner = capability_owner_oid + AND routines.proname IN ('connection_metrics', 'statement_metrics') + ) + ) + ) THEN + RAISE EXCEPTION 'Database observability capability schema boundary is invalid'; + END IF; +END +$capability_boundary$; + +CREATE SCHEMA IF NOT EXISTS mira_dashboard_observability_capabilities + AUTHORIZATION mira_dashboard_observability_owner; +REVOKE ALL PRIVILEGES ON SCHEMA + mira_dashboard_observability_capabilities FROM PUBLIC; +REVOKE ALL PRIVILEGES ON SCHEMA + mira_dashboard_observability_capabilities FROM mira_dashboard_observer; +REVOKE ALL PRIVILEGES ON SCHEMA + mira_dashboard_observability_capabilities + FROM mira_dashboard_observability_capability_owner; +GRANT USAGE ON SCHEMA mira_dashboard_observability_capabilities + TO mira_dashboard_observability_capability_owner; +GRANT USAGE ON SCHEMA mira_dashboard_observability_capabilities + TO mira_dashboard_observer; + +REVOKE ALL PRIVILEGES ON TABLE pg_catalog.pg_statistic + FROM mira_dashboard_observability_capability_owner; +GRANT SELECT ON TABLE pg_catalog.pg_statistic + TO mira_dashboard_observability_capability_owner; +ALTER DEFAULT PRIVILEGES + FOR ROLE mira_dashboard_observability_capability_owner + REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC; + +DROP FUNCTION IF EXISTS + mira_dashboard_observability_capabilities.table_health(); +DROP FUNCTION IF EXISTS + mira_dashboard_observability_capabilities.maintenance_metrics(); + +CREATE FUNCTION mira_dashboard_observability_capabilities.table_health() +RETURNS TABLE ( + schema_name name, + table_name name, + physical_bytes bigint, + live_tuples bigint, + dead_tuples bigint, + last_autovacuum_at_ms bigint, + last_autoanalyze_at_ms bigint, + dead_tuple_percent numeric, + assessed boolean, + estimated_reclaimable_bytes bigint +) +LANGUAGE sql +VOLATILE +PARALLEL UNSAFE +SECURITY DEFINER +ROWS 25 +SET search_path TO pg_catalog, pg_temp +SET statement_timeout TO '5s' +BEGIN ATOMIC + WITH average_row_widths AS ( + SELECT namespaces.nspname AS schemaname, + classes.relname AS tablename, + pg_catalog.sum(statistics.stawidth)::numeric AS row_width + FROM pg_catalog.pg_statistic AS statistics + JOIN pg_catalog.pg_class AS classes + ON classes.oid = statistics.starelid + JOIN pg_catalog.pg_namespace AS namespaces + ON namespaces.oid = classes.relnamespace + JOIN pg_catalog.pg_attribute AS attributes + ON attributes.attrelid = statistics.starelid + AND attributes.attnum = statistics.staattnum + AND NOT attributes.attisdropped + WHERE classes.relkind = 'r' + AND NOT classes.relispartition + AND NOT classes.relrowsecurity + AND NOT classes.relforcerowsecurity + AND namespaces.nspname NOT IN ('information_schema', 'pg_catalog') + AND namespaces.nspname NOT LIKE 'pg_toast%' + AND namespaces.nspname NOT LIKE 'pg_temp_%' + GROUP BY namespaces.nspname, classes.relname + ), table_estimates AS ( + SELECT tables.schemaname, + tables.relname, + tables.relid, + tables.n_live_tup, + tables.n_dead_tup, + tables.last_autovacuum, + tables.last_autoanalyze, + GREATEST( + tables.n_live_tup::numeric, + classes.reltuples::numeric + ) AS estimated_live_tuples, + CASE + WHEN classes.reltuples > 0 + AND tables.n_live_tup < classes.reltuples + AND pg_catalog.abs( + tables.n_live_tup::numeric + tables.n_dead_tup::numeric - + classes.reltuples::numeric + ) / classes.reltuples::numeric * 100 <= 10 + THEN tables.n_live_tup::numeric + ELSE GREATEST( + tables.n_live_tup::numeric, + classes.reltuples::numeric + ) + END AS dead_tuple_live_estimate, + ( + tables.n_live_tup < classes.reltuples + AND tables.n_dead_tup >= 1000 + AND ( + tables.n_dead_tup::numeric / + NULLIF(classes.reltuples::numeric, 0) * 100 >= 20 + OR pg_catalog.pg_relation_size(tables.relid)::numeric * + tables.n_dead_tup::numeric / + NULLIF(classes.reltuples::numeric, 0) >= 5368709120 + ) + ) AS catalog_estimate_may_be_stale + FROM pg_catalog.pg_stat_user_tables AS tables + JOIN pg_catalog.pg_class AS classes ON classes.oid = tables.relid + JOIN pg_catalog.pg_namespace AS namespaces + ON namespaces.oid = classes.relnamespace + WHERE classes.relkind = 'r' + AND NOT classes.relispartition + AND NOT classes.relrowsecurity + AND NOT classes.relforcerowsecurity + AND namespaces.nspname NOT IN ('information_schema', 'pg_catalog') + AND namespaces.nspname NOT LIKE 'pg_toast%' + AND namespaces.nspname NOT LIKE 'pg_temp_%' + ) + SELECT estimates.schemaname AS schema_name, + estimates.relname AS table_name, + pg_catalog.pg_relation_size(estimates.relid)::bigint AS physical_bytes, + estimates.n_live_tup::bigint AS live_tuples, + estimates.n_dead_tup::bigint AS dead_tuples, + (pg_catalog.date_part('epoch', estimates.last_autovacuum) * 1000)::bigint + AS last_autovacuum_at_ms, + (pg_catalog.date_part('epoch', estimates.last_autoanalyze) * 1000)::bigint + AS last_autoanalyze_at_ms, + LEAST( + 100, + pg_catalog.round( + CASE WHEN estimates.dead_tuple_live_estimate <= 0 THEN 0 + ELSE estimates.n_dead_tup::numeric / + NULLIF(estimates.dead_tuple_live_estimate, 0) * 100 + END, + 2 + ) + ) AS dead_tuple_percent, + ( + widths.row_width IS NOT NULL + AND estimates.estimated_live_tuples > 0 + AND NOT estimates.catalog_estimate_may_be_stale + ) AS assessed, + CASE + WHEN widths.row_width IS NULL + OR estimates.estimated_live_tuples <= 0 + OR estimates.catalog_estimate_may_be_stale + THEN NULL + ELSE GREATEST( + pg_catalog.pg_relation_size(estimates.relid) - pg_catalog.ceil( + estimates.estimated_live_tuples * (widths.row_width + 32) * 1.2 + ), + 0 + )::bigint + END AS estimated_reclaimable_bytes + FROM table_estimates AS estimates + LEFT JOIN average_row_widths AS widths + ON widths.schemaname = estimates.schemaname + AND widths.tablename = estimates.relname + WHERE pg_catalog.pg_relation_size(estimates.relid) > 0 + ORDER BY ( + pg_catalog.pg_relation_size(estimates.relid) >= 67108864 + AND LEAST( + 100, + pg_catalog.round( + CASE WHEN estimates.dead_tuple_live_estimate <= 0 THEN 0 + ELSE estimates.n_dead_tup::numeric / + NULLIF( + estimates.dead_tuple_live_estimate, + 0 + ) * 100 + END, + 2 + ) + ) >= 20 + AND estimates.n_dead_tup >= 1000 + ) DESC, + estimated_reclaimable_bytes DESC NULLS LAST, + estimates.n_dead_tup DESC, + estimates.schemaname, + estimates.relname + LIMIT 25; +END; + +ALTER FUNCTION mira_dashboard_observability_capabilities.table_health() + OWNER TO mira_dashboard_observability_capability_owner; + +CREATE FUNCTION mira_dashboard_observability_capabilities.maintenance_metrics() +RETURNS TABLE ( + assessed_physical_bytes bigint, + estimated_reclaimable_bytes bigint, + high_dead_tuple_table_count bigint, + unassessed_physical_bytes bigint, + unassessed_table_count bigint +) +LANGUAGE sql +VOLATILE +PARALLEL UNSAFE +SECURITY DEFINER +ROWS 1 +SET search_path TO pg_catalog, pg_temp +SET statement_timeout TO '5s' +BEGIN ATOMIC + WITH average_row_widths AS ( + SELECT namespaces.nspname AS schemaname, + classes.relname AS tablename, + pg_catalog.sum(statistics.stawidth)::numeric AS row_width + FROM pg_catalog.pg_statistic AS statistics + JOIN pg_catalog.pg_class AS classes + ON classes.oid = statistics.starelid + JOIN pg_catalog.pg_namespace AS namespaces + ON namespaces.oid = classes.relnamespace + JOIN pg_catalog.pg_attribute AS attributes + ON attributes.attrelid = statistics.starelid + AND attributes.attnum = statistics.staattnum + AND NOT attributes.attisdropped + WHERE classes.relkind = 'r' + AND NOT classes.relispartition + AND NOT classes.relrowsecurity + AND NOT classes.relforcerowsecurity + AND namespaces.nspname NOT IN ('information_schema', 'pg_catalog') + AND namespaces.nspname NOT LIKE 'pg_toast%' + AND namespaces.nspname NOT LIKE 'pg_temp_%' + GROUP BY namespaces.nspname, classes.relname + ), table_estimates AS ( + SELECT tables.schemaname, + tables.relname, + tables.relid, + tables.n_live_tup, + tables.n_dead_tup, + GREATEST( + tables.n_live_tup::numeric, + classes.reltuples::numeric + ) AS estimated_live_tuples, + CASE + WHEN classes.reltuples > 0 + AND tables.n_live_tup < classes.reltuples + AND pg_catalog.abs( + tables.n_live_tup::numeric + tables.n_dead_tup::numeric - + classes.reltuples::numeric + ) / classes.reltuples::numeric * 100 <= 10 + THEN tables.n_live_tup::numeric + ELSE GREATEST( + tables.n_live_tup::numeric, + classes.reltuples::numeric + ) + END AS dead_tuple_live_estimate, + ( + tables.n_live_tup < classes.reltuples + AND tables.n_dead_tup >= 1000 + AND ( + tables.n_dead_tup::numeric / + NULLIF(classes.reltuples::numeric, 0) * 100 >= 20 + OR pg_catalog.pg_relation_size(tables.relid)::numeric * + tables.n_dead_tup::numeric / + NULLIF(classes.reltuples::numeric, 0) >= 5368709120 + ) + ) AS catalog_estimate_may_be_stale + FROM pg_catalog.pg_stat_user_tables AS tables + JOIN pg_catalog.pg_class AS classes ON classes.oid = tables.relid + JOIN pg_catalog.pg_namespace AS namespaces + ON namespaces.oid = classes.relnamespace + WHERE classes.relkind = 'r' + AND NOT classes.relispartition + AND NOT classes.relrowsecurity + AND NOT classes.relforcerowsecurity + AND namespaces.nspname NOT IN ('information_schema', 'pg_catalog') + AND namespaces.nspname NOT LIKE 'pg_toast%' + AND namespaces.nspname NOT LIKE 'pg_temp_%' + ), projections AS ( + SELECT pg_catalog.pg_relation_size(estimates.relid)::bigint AS physical_bytes, + estimates.n_dead_tup::bigint AS dead_tuples, + LEAST( + 100, + pg_catalog.round( + CASE WHEN estimates.dead_tuple_live_estimate <= 0 THEN 0 + ELSE estimates.n_dead_tup::numeric / + NULLIF(estimates.dead_tuple_live_estimate, 0) * 100 + END, + 2 + ) + ) AS dead_tuple_percent, + ( + widths.row_width IS NOT NULL + AND estimates.estimated_live_tuples > 0 + AND NOT estimates.catalog_estimate_may_be_stale + ) AS assessed, + CASE + WHEN widths.row_width IS NULL + OR estimates.estimated_live_tuples <= 0 + OR estimates.catalog_estimate_may_be_stale + THEN 0 + ELSE GREATEST( + pg_catalog.pg_relation_size(estimates.relid) - pg_catalog.ceil( + estimates.estimated_live_tuples * (widths.row_width + 32) * 1.2 + ), + 0 + )::bigint + END AS estimated_reclaimable_bytes + FROM table_estimates AS estimates + LEFT JOIN average_row_widths AS widths + ON widths.schemaname = estimates.schemaname + AND widths.tablename = estimates.relname + WHERE pg_catalog.pg_relation_size(estimates.relid) > 0 + ) + SELECT COALESCE( + pg_catalog.sum(projections.physical_bytes) + FILTER (WHERE projections.assessed), + 0 + )::bigint AS assessed_physical_bytes, + COALESCE( + pg_catalog.sum(projections.estimated_reclaimable_bytes) + FILTER (WHERE projections.assessed), + 0 + )::bigint AS estimated_reclaimable_bytes, + pg_catalog.count(*) FILTER ( + WHERE projections.physical_bytes >= 67108864 + AND projections.dead_tuple_percent >= 20 + AND projections.dead_tuples >= 1000 + )::bigint AS high_dead_tuple_table_count, + COALESCE( + pg_catalog.sum(projections.physical_bytes) + FILTER (WHERE NOT projections.assessed), + 0 + )::bigint AS unassessed_physical_bytes, + pg_catalog.count(*) FILTER (WHERE NOT projections.assessed)::bigint + AS unassessed_table_count + FROM projections; +END; + +ALTER FUNCTION mira_dashboard_observability_capabilities.maintenance_metrics() + OWNER TO mira_dashboard_observability_capability_owner; + +RESET ROLE; + +REVOKE ALL PRIVILEGES ON FUNCTION + mira_dashboard_observability_capabilities.table_health() FROM PUBLIC; +REVOKE ALL PRIVILEGES ON FUNCTION + mira_dashboard_observability_capabilities.table_health() + FROM mira_dashboard_observer; +GRANT EXECUTE ON FUNCTION + mira_dashboard_observability_capabilities.table_health() + TO mira_dashboard_observer; +REVOKE ALL PRIVILEGES ON FUNCTION + mira_dashboard_observability_capabilities.maintenance_metrics() FROM PUBLIC; +REVOKE ALL PRIVILEGES ON FUNCTION + mira_dashboard_observability_capabilities.maintenance_metrics() + FROM mira_dashboard_observer; +GRANT EXECUTE ON FUNCTION + mira_dashboard_observability_capabilities.maintenance_metrics() + TO mira_dashboard_observer; + +COMMIT; diff --git a/greenfield/scripts/delivery/provisioning/database-observability/apply-reconciliation-approval.sql b/greenfield/scripts/delivery/provisioning/database-observability/apply-reconciliation-approval.sql new file mode 100644 index 000000000..3fc7a7521 --- /dev/null +++ b/greenfield/scripts/delivery/provisioning/database-observability/apply-reconciliation-approval.sql @@ -0,0 +1,316 @@ +\set ON_ERROR_STOP 1 + +BEGIN; + +SET LOCAL mira_dashboard.current_policy_digest + TO :'current_policy_digest'; + +DO $approval_structure_preflight$ +DECLARE + administrator_oid oid; + approval_oid oid; + approval_schema_oid oid; + expected_digest text := pg_catalog.current_setting( + 'mira_dashboard.current_policy_digest' + ); +BEGIN + SELECT databases.datdba INTO administrator_oid + FROM pg_catalog.pg_database AS databases + JOIN pg_catalog.pg_roles AS owners ON owners.oid = databases.datdba + WHERE databases.datname = pg_catalog.current_database() + AND databases.datname = 'mira_dashboard_observability' + AND NOT databases.datistemplate + AND databases.datallowconn + AND owners.rolname = CURRENT_USER + AND owners.rolsuper; + IF administrator_oid IS NULL + OR expected_digest !~ '^[0-9a-f]{64}$' + THEN + RAISE EXCEPTION 'Database observability reconciliation approval preflight failed'; + END IF; + + SELECT namespaces.oid INTO approval_schema_oid + FROM pg_catalog.pg_namespace AS namespaces + WHERE namespaces.nspname = 'mira_dashboard_observability_control'; + IF approval_schema_oid IS NULL THEN + EXECUTE 'CREATE SCHEMA mira_dashboard_observability_control AUTHORIZATION ' + || pg_catalog.quote_ident(CURRENT_USER); + SELECT namespaces.oid INTO STRICT approval_schema_oid + FROM pg_catalog.pg_namespace AS namespaces + WHERE namespaces.nspname = 'mira_dashboard_observability_control'; + ELSIF (SELECT namespaces.nspowner + FROM pg_catalog.pg_namespace AS namespaces + WHERE namespaces.oid = approval_schema_oid) <> administrator_oid + OR COALESCE( + (SELECT namespaces.nspacl + FROM pg_catalog.pg_namespace AS namespaces + WHERE namespaces.oid = approval_schema_oid), + pg_catalog.acldefault('n', administrator_oid) + ) <> pg_catalog.acldefault('n', administrator_oid) + THEN + RAISE EXCEPTION 'Database observability reconciliation approval schema is invalid'; + END IF; + + SELECT classes.oid INTO approval_oid + FROM pg_catalog.pg_class AS classes + WHERE classes.relnamespace = approval_schema_oid + AND classes.relname = 'reconciliation_approval' + AND classes.relkind = 'r'; + IF approval_oid IS NULL THEN + IF EXISTS ( + SELECT 1 FROM pg_catalog.pg_class AS classes + WHERE classes.relnamespace = approval_schema_oid + ) OR EXISTS ( + SELECT 1 FROM pg_catalog.pg_proc AS routines + WHERE routines.pronamespace = approval_schema_oid + ) OR EXISTS ( + SELECT 1 FROM pg_catalog.pg_type AS types + WHERE types.typnamespace = approval_schema_oid + ) + THEN + RAISE EXCEPTION 'Database observability reconciliation approval namespace is occupied'; + END IF; + EXECUTE $create_approval$ + CREATE TABLE mira_dashboard_observability_control.reconciliation_approval ( + singleton boolean NOT NULL, + policy_version text NOT NULL, + system_identifier numeric NOT NULL, + current_policy_digest text NOT NULL, + previous_policy_digest text, + CONSTRAINT reconciliation_approval_primary_key + PRIMARY KEY (singleton), + CONSTRAINT reconciliation_approval_singleton_true + CHECK (singleton), + CONSTRAINT reconciliation_approval_policy_version + CHECK (policy_version = 'sanitized-capabilities-v1'), + CONSTRAINT reconciliation_approval_system_identifier + CHECK (system_identifier > 0), + CONSTRAINT reconciliation_approval_current_digest + CHECK (current_policy_digest ~ '^[0-9a-f]{64}$'), + CONSTRAINT reconciliation_approval_previous_digest + CHECK ( + previous_policy_digest IS NULL + OR previous_policy_digest ~ '^[0-9a-f]{64}$' + ) + ) + $create_approval$; + END IF; +END +$approval_structure_preflight$; + +DO $approval_exact_boundary$ +DECLARE + administrator_oid oid; + approval_oid oid; + approval_schema_oid oid; +BEGIN + SELECT databases.datdba INTO administrator_oid + FROM pg_catalog.pg_database AS databases + WHERE databases.datname = pg_catalog.current_database(); + SELECT namespaces.oid INTO approval_schema_oid + FROM pg_catalog.pg_namespace AS namespaces + WHERE namespaces.nspname = 'mira_dashboard_observability_control'; + SELECT classes.oid INTO approval_oid + FROM pg_catalog.pg_class AS classes + WHERE classes.relnamespace = approval_schema_oid + AND classes.relname = 'reconciliation_approval' + AND classes.relkind = 'r'; + + IF administrator_oid IS NULL + OR approval_schema_oid IS NULL + OR approval_oid IS NULL + OR (SELECT classes.relowner FROM pg_catalog.pg_class AS classes + WHERE classes.oid = approval_oid) <> administrator_oid + OR (SELECT classes.relpersistence FROM pg_catalog.pg_class AS classes + WHERE classes.oid = approval_oid) <> 'p' + OR (SELECT classes.relrowsecurity OR classes.relforcerowsecurity + FROM pg_catalog.pg_class AS classes + WHERE classes.oid = approval_oid) + OR (SELECT classes.relreplident FROM pg_catalog.pg_class AS classes + WHERE classes.oid = approval_oid) <> 'd' + OR (SELECT classes.relispartition FROM pg_catalog.pg_class AS classes + WHERE classes.oid = approval_oid) + OR (SELECT classes.reloptions FROM pg_catalog.pg_class AS classes + WHERE classes.oid = approval_oid) IS NOT NULL + OR COALESCE( + (SELECT classes.relacl FROM pg_catalog.pg_class AS classes + WHERE classes.oid = approval_oid), + pg_catalog.acldefault('r', administrator_oid) + ) <> pg_catalog.acldefault('r', administrator_oid) + OR (SELECT pg_catalog.count(*) FROM pg_catalog.pg_class AS classes + WHERE classes.relnamespace = approval_schema_oid) <> 2 + OR EXISTS ( + SELECT 1 FROM pg_catalog.pg_class AS classes + WHERE classes.relnamespace = approval_schema_oid + AND classes.oid <> approval_oid + AND NOT ( + classes.relkind = 'i' + AND EXISTS ( + SELECT 1 FROM pg_catalog.pg_index AS indexes + WHERE indexes.indexrelid = classes.oid + AND indexes.indrelid = approval_oid + AND indexes.indisprimary + AND indexes.indisunique + AND indexes.indisvalid + AND indexes.indisready + AND indexes.indislive + AND indexes.indimmediate + AND NOT indexes.indisreplident + AND indexes.indnkeyatts = 1 + AND indexes.indnatts = 1 + AND indexes.indkey = '1'::pg_catalog.int2vector + AND indexes.indexprs IS NULL + AND indexes.indpred IS NULL + ) + ) + ) + OR (SELECT pg_catalog.count(*) FROM pg_catalog.pg_index AS indexes + WHERE indexes.indrelid = approval_oid) <> 1 + OR (SELECT pg_catalog.count(*) FROM pg_catalog.pg_attribute AS attributes + WHERE attributes.attrelid = approval_oid + AND attributes.attnum > 0 + AND NOT attributes.attisdropped) <> 5 + OR EXISTS ( + SELECT 1 + FROM (VALUES + (1, 'singleton'::name, 'pg_catalog.bool'::pg_catalog.regtype, true), + (2, 'policy_version'::name, 'pg_catalog.text'::pg_catalog.regtype, true), + (3, 'system_identifier'::name, 'pg_catalog.numeric'::pg_catalog.regtype, true), + (4, 'current_policy_digest'::name, 'pg_catalog.text'::pg_catalog.regtype, true), + (5, 'previous_policy_digest'::name, 'pg_catalog.text'::pg_catalog.regtype, false) + ) AS expected(attnum, attname, atttypid, attnotnull) + LEFT JOIN pg_catalog.pg_attribute AS attributes + ON attributes.attrelid = approval_oid + AND attributes.attnum = expected.attnum + AND NOT attributes.attisdropped + WHERE attributes.attname IS DISTINCT FROM expected.attname + OR attributes.atttypid IS DISTINCT FROM expected.atttypid + OR attributes.attnotnull IS DISTINCT FROM expected.attnotnull + OR attributes.atthasdef + OR attributes.attidentity <> '' + OR attributes.attgenerated <> '' + ) + OR (SELECT pg_catalog.count(*) FROM pg_catalog.pg_constraint AS constraints + WHERE constraints.conrelid = approval_oid) <> 10 + OR NOT EXISTS ( + SELECT 1 FROM pg_catalog.pg_constraint AS constraints + WHERE constraints.conrelid = approval_oid + AND constraints.conname = 'reconciliation_approval_primary_key' + AND constraints.contype = 'p' + AND constraints.conkey = ARRAY[1]::smallint[] + AND NOT constraints.condeferrable + AND NOT constraints.condeferred + AND constraints.convalidated + AND constraints.connoinherit + ) + OR EXISTS ( + SELECT 1 + FROM (VALUES + ('reconciliation_approval_singleton_not_null'::name, ARRAY[1]::smallint[]), + ('reconciliation_approval_policy_version_not_null'::name, ARRAY[2]::smallint[]), + ('reconciliation_approval_system_identifier_not_null'::name, ARRAY[3]::smallint[]), + ('reconciliation_approval_current_policy_digest_not_null'::name, ARRAY[4]::smallint[]) + ) AS expected(constraint_name, constrained_columns) + LEFT JOIN pg_catalog.pg_constraint AS constraints + ON constraints.conrelid = approval_oid + AND constraints.conname = expected.constraint_name + AND constraints.contype = 'n' + WHERE constraints.oid IS NULL + OR constraints.conkey IS DISTINCT FROM expected.constrained_columns + OR NOT constraints.convalidated + OR constraints.connoinherit + ) + OR EXISTS ( + SELECT 1 + FROM (VALUES + ('reconciliation_approval_singleton_true'::name, 'CHECK (singleton)'::text), + ('reconciliation_approval_policy_version'::name, 'CHECK ((policy_version = ''sanitized-capabilities-v1''::text))'::text), + ('reconciliation_approval_system_identifier'::name, 'CHECK ((system_identifier > (0)::numeric))'::text), + ('reconciliation_approval_current_digest'::name, 'CHECK ((current_policy_digest ~ ''^[0-9a-f]{64}$''::text))'::text), + ('reconciliation_approval_previous_digest'::name, 'CHECK (((previous_policy_digest IS NULL) OR (previous_policy_digest ~ ''^[0-9a-f]{64}$''::text)))'::text) + ) AS expected(constraint_name, definition) + LEFT JOIN pg_catalog.pg_constraint AS constraints + ON constraints.conrelid = approval_oid + AND constraints.conname = expected.constraint_name + AND constraints.contype = 'c' + WHERE constraints.oid IS NULL + OR NOT constraints.convalidated + OR constraints.connoinherit + OR pg_catalog.pg_get_constraintdef(constraints.oid, false) + IS DISTINCT FROM expected.definition + ) + OR EXISTS ( + SELECT 1 FROM pg_catalog.pg_trigger AS triggers + WHERE triggers.tgrelid = approval_oid AND NOT triggers.tgisinternal + ) + OR EXISTS ( + SELECT 1 FROM pg_catalog.pg_rewrite AS rules + WHERE rules.ev_class = approval_oid + ) + OR EXISTS ( + SELECT 1 FROM pg_catalog.pg_policy AS policies + WHERE policies.polrelid = approval_oid + ) + OR EXISTS ( + SELECT 1 FROM pg_catalog.pg_inherits AS inheritance + WHERE inheritance.inhrelid = approval_oid + OR inheritance.inhparent = approval_oid + ) + OR EXISTS ( + SELECT 1 FROM pg_catalog.pg_proc AS routines + WHERE routines.pronamespace = approval_schema_oid + ) + OR (SELECT pg_catalog.count(*) FROM pg_catalog.pg_type AS types + WHERE types.typnamespace = approval_schema_oid) <> 2 + OR EXISTS ( + SELECT 1 FROM pg_catalog.pg_type AS types + WHERE types.typnamespace = approval_schema_oid + AND ( + types.typowner <> administrator_oid + OR types.oid NOT IN ( + (SELECT classes.reltype FROM pg_catalog.pg_class AS classes + WHERE classes.oid = approval_oid), + (SELECT row_types.typarray FROM pg_catalog.pg_type AS row_types + WHERE row_types.oid = ( + SELECT classes.reltype FROM pg_catalog.pg_class AS classes + WHERE classes.oid = approval_oid + )) + ) + ) + ) + THEN + RAISE EXCEPTION 'Database observability reconciliation approval boundary is invalid'; + END IF; +END +$approval_exact_boundary$; + +INSERT INTO mira_dashboard_observability_control.reconciliation_approval ( + singleton, + policy_version, + system_identifier, + current_policy_digest, + previous_policy_digest +) +SELECT true, + 'sanitized-capabilities-v1', + controls.system_identifier, + pg_catalog.current_setting('mira_dashboard.current_policy_digest'), + NULL +FROM pg_catalog.pg_control_system() AS controls +ON CONFLICT (singleton) DO UPDATE +SET policy_version = EXCLUDED.policy_version, + system_identifier = EXCLUDED.system_identifier, + previous_policy_digest = CASE + WHEN mira_dashboard_observability_control.reconciliation_approval + .current_policy_digest = EXCLUDED.current_policy_digest + THEN mira_dashboard_observability_control.reconciliation_approval + .previous_policy_digest + ELSE mira_dashboard_observability_control.reconciliation_approval + .current_policy_digest + END, + current_policy_digest = EXCLUDED.current_policy_digest; + +COMMIT; + +\set approved_policy_digest :current_policy_digest +\ir verify-reconciliation-approval.sql diff --git a/greenfield/scripts/delivery/provisioning/database-observability/apply-torrent-view.sql b/greenfield/scripts/delivery/provisioning/database-observability/apply-torrent-view.sql new file mode 100644 index 000000000..696b28984 --- /dev/null +++ b/greenfield/scripts/delivery/provisioning/database-observability/apply-torrent-view.sql @@ -0,0 +1,166 @@ +\set ON_ERROR_STOP 1 + +BEGIN; + +DO $guard$ +BEGIN + IF pg_catalog.current_database() NOT IN ('bitmagnet', 'comet') THEN + RAISE EXCEPTION 'Database observability view target is not reviewed'; + END IF; +END +$guard$; + +DO $schema_boundary$ +DECLARE + owner_oid oid; + schema_oid oid; +BEGIN + SELECT oid INTO owner_oid FROM pg_catalog.pg_roles + WHERE rolname = 'mira_dashboard_observability_owner'; + SELECT oid INTO schema_oid FROM pg_catalog.pg_namespace + WHERE nspname = 'mira_dashboard_observability'; + IF schema_oid IS NOT NULL + AND ( + (SELECT nspowner FROM pg_catalog.pg_namespace WHERE oid = schema_oid) + IS DISTINCT FROM owner_oid + OR EXISTS ( + SELECT 1 FROM pg_catalog.pg_class + WHERE relnamespace = schema_oid + AND relname NOT IN ('statement_metrics', 'torrent_count') + ) + OR EXISTS ( + SELECT 1 FROM pg_catalog.pg_class + WHERE relnamespace = schema_oid + AND relname = 'torrent_count' + ) + ) + THEN + RAISE EXCEPTION 'Database observability schema boundary is invalid'; + END IF; +END +$schema_boundary$; + +CREATE SCHEMA IF NOT EXISTS mira_dashboard_observability + AUTHORIZATION mira_dashboard_observability_owner; +REVOKE ALL PRIVILEGES ON SCHEMA mira_dashboard_observability FROM PUBLIC; +REVOKE ALL PRIVILEGES ON SCHEMA mira_dashboard_observability + FROM mira_dashboard_observer; +GRANT USAGE ON SCHEMA mira_dashboard_observability + TO mira_dashboard_observer; + +GRANT SELECT ON TABLE public.torrents + TO mira_dashboard_observability_owner; +REVOKE ALL PRIVILEGES ON TABLE public.torrents + FROM mira_dashboard_observer; + +SET LOCAL ROLE mira_dashboard_observability_owner; +CREATE VIEW mira_dashboard_observability.torrent_count + WITH (security_barrier = true) +AS +SELECT pg_catalog.count(*)::bigint AS count +FROM public.torrents; +RESET ROLE; + +ALTER VIEW mira_dashboard_observability.torrent_count + OWNER TO mira_dashboard_observability_owner; +REVOKE ALL PRIVILEGES ON TABLE mira_dashboard_observability.torrent_count + FROM PUBLIC; +REVOKE ALL PRIVILEGES ON TABLE mira_dashboard_observability.torrent_count + FROM mira_dashboard_observer; +GRANT SELECT ON TABLE mira_dashboard_observability.torrent_count + TO mira_dashboard_observer; + +DO $exact_acl$ +DECLARE + observer_oid oid; + owner_oid oid; + schema_oid oid; + view_oid oid; +BEGIN + SELECT oid INTO observer_oid + FROM pg_catalog.pg_roles + WHERE rolname = 'mira_dashboard_observer'; + SELECT oid INTO owner_oid + FROM pg_catalog.pg_roles + WHERE rolname = 'mira_dashboard_observability_owner'; + SELECT oid INTO schema_oid + FROM pg_catalog.pg_namespace + WHERE nspname = 'mira_dashboard_observability'; + SELECT classes.oid INTO view_oid + FROM pg_catalog.pg_class AS classes + WHERE classes.relnamespace = schema_oid + AND classes.relname = 'torrent_count' + AND classes.relkind = 'v'; + + IF observer_oid IS NULL + OR owner_oid IS NULL + OR schema_oid IS NULL + OR view_oid IS NULL + OR ( + SELECT pg_catalog.count(*) + FROM pg_catalog.aclexplode( + COALESCE( + (SELECT nspacl FROM pg_catalog.pg_namespace WHERE oid = schema_oid), + pg_catalog.acldefault('n', owner_oid) + ) + ) AS grants + ) <> 3 + OR EXISTS ( + SELECT 1 + FROM pg_catalog.aclexplode( + COALESCE( + (SELECT nspacl FROM pg_catalog.pg_namespace WHERE oid = schema_oid), + pg_catalog.acldefault('n', owner_oid) + ) + ) AS grants + WHERE NOT ( + grants.grantee = owner_oid + AND grants.privilege_type IN ('CREATE', 'USAGE') + AND NOT grants.is_grantable + OR grants.grantee = observer_oid + AND grants.privilege_type = 'USAGE' + AND NOT grants.is_grantable + ) + ) + OR ( + SELECT pg_catalog.count(*) + FROM pg_catalog.aclexplode( + COALESCE( + (SELECT relacl FROM pg_catalog.pg_class WHERE oid = view_oid), + pg_catalog.acldefault('r', owner_oid) + ) + ) AS grants + ) <> 9 + OR EXISTS ( + SELECT 1 + FROM pg_catalog.aclexplode( + COALESCE( + (SELECT relacl FROM pg_catalog.pg_class WHERE oid = view_oid), + pg_catalog.acldefault('r', owner_oid) + ) + ) AS grants + WHERE NOT ( + grants.grantee = owner_oid + AND grants.privilege_type IN ( + 'SELECT', + 'INSERT', + 'UPDATE', + 'DELETE', + 'MAINTAIN', + 'TRUNCATE', + 'REFERENCES', + 'TRIGGER' + ) + AND NOT grants.is_grantable + OR grants.grantee = observer_oid + AND grants.privilege_type = 'SELECT' + AND NOT grants.is_grantable + ) + ) + THEN + RAISE EXCEPTION 'Database observability ACL is not exact'; + END IF; +END +$exact_acl$; + +COMMIT; diff --git a/greenfield/scripts/delivery/provisioning/database-observability/disable-observer.sql b/greenfield/scripts/delivery/provisioning/database-observability/disable-observer.sql new file mode 100644 index 000000000..0b972e8da --- /dev/null +++ b/greenfield/scripts/delivery/provisioning/database-observability/disable-observer.sql @@ -0,0 +1,37 @@ +\set ON_ERROR_STOP 1 + +-- Disable new authentication before clearing the password or terminating +-- already-authenticated sessions. Each statement autocommits independently so +-- a later termination failure cannot restore LOGIN. +ALTER ROLE mira_dashboard_observer NOLOGIN; +ALTER ROLE mira_dashboard_observer + VALID UNTIL '1970-01-01 00:00:00+00'; +ALTER ROLE mira_dashboard_observer PASSWORD NULL; + +DO $terminate_observability_sessions$ +DECLARE + observability_session record; +BEGIN + FOR observability_session IN + SELECT activity.pid + FROM pg_catalog.pg_stat_activity AS activity + WHERE activity.usename = 'mira_dashboard_observer' + AND activity.pid <> pg_catalog.pg_backend_pid() + LOOP + IF NOT pg_catalog.pg_terminate_backend(observability_session.pid, 5000) THEN + RAISE EXCEPTION 'Database observability session could not be terminated'; + END IF; + END LOOP; + + PERFORM pg_catalog.pg_stat_clear_snapshot(); + + IF EXISTS ( + SELECT 1 + FROM pg_catalog.pg_stat_activity AS activity + WHERE activity.usename = 'mira_dashboard_observer' + AND activity.pid <> pg_catalog.pg_backend_pid() + ) THEN + RAISE EXCEPTION 'Database observability session remains active'; + END IF; +END +$terminate_observability_sessions$; diff --git a/greenfield/scripts/delivery/provisioning/database-observability/enable-approved-collection.sql b/greenfield/scripts/delivery/provisioning/database-observability/enable-approved-collection.sql new file mode 100644 index 000000000..4b0fca2de --- /dev/null +++ b/greenfield/scripts/delivery/provisioning/database-observability/enable-approved-collection.sql @@ -0,0 +1,76 @@ +\set ON_ERROR_STOP 1 + +SELECT pg_catalog.set_config( + 'mira_dashboard.collection_lease_token', + :'collection_lease_token', + true +); +SELECT pg_catalog.pg_advisory_xact_lock(1835623521, 1668048243); + +DO $open_approved_collection$ +DECLARE + expected_comment constant text := + 'mira-dashboard-collection-lease:' || + pg_catalog.current_setting('mira_dashboard.collection_lease_token'); + lease_expires_at timestamp with time zone := + pg_catalog.clock_timestamp() + interval '2 minutes'; + observer pg_catalog.pg_authid%ROWTYPE; + observer_config text[]; +BEGIN + SELECT * INTO observer + FROM pg_catalog.pg_authid + WHERE rolname = 'mira_dashboard_observer'; + SELECT settings.setconfig INTO observer_config + FROM pg_catalog.pg_db_role_setting AS settings + WHERE settings.setrole = observer.oid + AND settings.setdatabase = 0; + IF observer.oid IS NULL + OR observer.rolcanlogin + OR NOT observer.rolinherit + OR observer.rolsuper + OR observer.rolcreatedb + OR observer.rolcreaterole + OR observer.rolreplication + OR observer.rolbypassrls + OR observer.rolconnlimit IS DISTINCT FROM 64 + OR observer.rolpassword IS NULL + OR observer.rolpassword NOT LIKE 'SCRAM-SHA-256$%' + OR observer.rolvaliduntil IS DISTINCT FROM + '1970-01-01 00:00:00+00'::timestamp with time zone + OR pg_catalog.shobj_description(observer.oid, 'pg_authid') + IS DISTINCT FROM expected_comment + OR pg_catalog.cardinality(observer_config) IS DISTINCT FROM 4 + OR NOT COALESCE(observer_config @> ARRAY[ + 'default_transaction_read_only=on', + 'statement_timeout=5s', + 'idle_session_timeout=60s', + 'idle_in_transaction_session_timeout=60s' + ]::text[], false) + OR EXISTS ( + SELECT 1 + FROM pg_catalog.pg_stat_activity AS activity + WHERE activity.usename = 'mira_dashboard_observer' + AND activity.pid <> pg_catalog.pg_backend_pid() + ) + THEN + RAISE EXCEPTION 'Database observability collection role is not closed'; + END IF; + COMMENT ON ROLE mira_dashboard_observer IS NULL; + EXECUTE pg_catalog.format( + 'ALTER ROLE mira_dashboard_observer LOGIN VALID UNTIL %L', + lease_expires_at + ); + SELECT * INTO observer + FROM pg_catalog.pg_authid + WHERE rolname = 'mira_dashboard_observer'; + IF NOT observer.rolcanlogin + OR observer.rolvaliduntil IS DISTINCT FROM lease_expires_at + OR observer.rolvaliduntil <= pg_catalog.clock_timestamp() + OR observer.rolvaliduntil > + pg_catalog.clock_timestamp() + interval '2 minutes' + OR pg_catalog.shobj_description(observer.oid, 'pg_authid') IS NOT NULL + THEN + RAISE EXCEPTION 'Database observability collection role did not open'; + END IF; +END +$open_approved_collection$; diff --git a/greenfield/scripts/delivery/provisioning/database-observability/manifest.json b/greenfield/scripts/delivery/provisioning/database-observability/manifest.json new file mode 100644 index 000000000..8e8b792fb --- /dev/null +++ b/greenfield/scripts/delivery/provisioning/database-observability/manifest.json @@ -0,0 +1,181 @@ +{ + "formatVersion": 8, + "observerRole": "mira_dashboard_observer", + "viewOwnerRole": "mira_dashboard_observability_owner", + "capabilityOwnerRole": "mira_dashboard_observability_capability_owner", + "postgresql": { + "observerRoleMemberships": [], + "capabilityOwnerAuthority": { + "directRoleMemberships": ["pg_read_all_stats"], + "directRelationPrivilegesPerDatabase": ["SELECT ON pg_catalog.pg_statistic"], + "login": false + }, + "catalogDiscovery": { + "maximumDatabases": 64, + "order": "name-ascending", + "predicate": "datistemplate=false and datallowconn=true" + }, + "controlPoint": { + "pgBouncerAlias": "mira_dashboard_observability", + "physicalDatabase": "mira_dashboard_observability", + "routing": "pgbouncer-wildcard-same-name", + "source": "code-owned-single-endpoint-capability" + }, + "privilegedCollectionLease": { + "actionKey": "cache.refresh.database-observability", + "scheduleId": "cache.database-observability", + "scheduleIntervalSeconds": 3600, + "providerGate": "configured-only", + "port": "separate worker-only privileged collection-lease port", + "commands": { + "open": "runProvisioning.ts open-approved-collection --approved", + "enable": "runProvisioning.ts enable-approved-collection --approved --collection-lease-token --catalog-digest ", + "close": "runProvisioning.ts close-approved-collection --approved" + }, + "runner": "exact immutable release Bun runtime", + "processSupervision": "Linux parent-death signal plus isolated process-group TERM/KILL reap before settlement", + "approval": { + "artifact": "mira_dashboard_observability_control.reconciliation_approval", + "createdOrUpdatedBy": "explicit activate-current-catalog only", + "binding": [ + "pg_control_system.system_identifier", + "exact current immutable-release policy digest", + "exact previous immutable-release policy digest" + ], + "policyVersion": "sanitized-capabilities-v1 is descriptive and never sufficient authorization", + "leaseMutation": "forbidden" + }, + "closedStateBetweenRuns": { + "login": false, + "validUntil": "expired", + "postgresqlSessions": 0, + "collectionLeaseComment": null + }, + "phases": [ + "privileged close leftovers: set NOLOGIN, expire VALID UNTIL, terminate observer sessions, and recheck closed state", + "open-approved-collection: verify exact approval and identity, perform the full bounded idempotent ACL-and-capability reconcile, keep NOLOGIN, and prepare one use collection token bound to the exact catalog digest", + "enable-approved-collection: recheck approval, policy, PostgreSQL/Docker identity, and exact catalog digest, then atomically consume the token and set LOGIN with a short VALID UNTIL", + "collect once through the least-privilege observer", + "shielded mandatory close: atomically invalidate every prepared token, set NOLOGIN, expire VALID UNTIL, terminate observer sessions, and recheck closed state", + "return the fresh payload to the generic cache executor only after exact close proof" + ], + "reconciliation": { + "frequency": "every approved prepared open before one-use enable", + "operations": [ + "reconcile exact database ACLs through the pinned administrative boundary", + "strictly apply and verify cluster and control capabilities; isolate application-database apply or policy drift for collector-level unavailable details", + "reject catalog, approval, policy-digest, PostgreSQL identity, or Docker identity races" + ], + "reducedPath": false, + "persistedFingerprintOrAgeState": false + }, + "failureSemantics": "cluster, control, approval, identity, catalog-race, enable, collection, or close failure prevents a fresh payload and cache commit; isolated application-database drift remains visible with unavailable details while other databases collect", + "pgBouncerWaitingClientLimitation": "closed PostgreSQL role and zero-session proof cannot prove that PgBouncer has no already-authenticated waiting client; no exclusive admission is added, and interference fails the attempt while NOLOGIN and expired VALID UNTIL prevent a new backend", + "additionalRuntimeSurfaces": { + "jobAction": false, + "schedule": false, + "systemdUnit": false, + "postgresqlLogin": false, + "exclusiveAdmission": false + } + }, + "sanitizedCapabilities": { + "schema": "mira_dashboard_observability_capabilities", + "observerPrivileges": ["USAGE ON SCHEMA", "EXECUTE ON EXACT FUNCTIONS"], + "functions": [ + "connection_metrics()@mira_dashboard_observability", + "statement_metrics()@mira_dashboard_observability", + "table_health()@each-observed-database", + "maintenance_metrics()@each-observed-database" + ], + "rawStatementAccess": "revoked from PUBLIC and observer", + "statementOutput": "bounded metrics without database, user, queryid, or query text" + }, + "provisioningExecution": { + "composeFile": "/opt/docker/compose.yaml", + "composeProjectDirectory": "/opt/docker", + "containerPsql": "/usr/local/bin/psql over /var/run/postgresql", + "dockerHost": "unix:///var/run/docker.sock", + "identityGuard": "superuser-role-oid, postgresql-system-identifier, activation approval, and exact current/previous policy digests", + "target": "single healthy PostgreSQL service_healthy dependency of the opted-in PgBouncer capability" + }, + "observerConnectionLimit": 64, + "sessionDefaults": { + "default_transaction_read_only": "on", + "statement_timeout": "5s" + }, + "statementStatistics": { + "database": "mira_dashboard_observability", + "extension": "pg_stat_statements", + "source": "catalog-resolved pg_stat_statements(false) behind a NOLOGIN capability owner", + "target": "mira_dashboard_observability_capabilities.statement_metrics()" + } + }, + "pgBouncer": { + "adminUserForbidden": "mira_dashboard_observer", + "observerUserPolicy": { + "max_user_client_connections": 2, + "max_user_connections": 64, + "pool_size": 1, + "reserve_pool_size": 0 + }, + "statsUserRequired": "mira_dashboard_observer", + "virtualDatabase": "pgbouncer" + }, + "torrentViews": [ + { + "database": "bitmagnet", + "source": "public.torrents", + "target": "mira_dashboard_observability.torrent_count" + }, + { + "database": "comet", + "source": "public.torrents", + "target": "mira_dashboard_observability.torrent_count" + } + ], + "applyOrder": [ + "apply-cluster.sql", + "apply-control-database-capability.sql@approved-administrative-database", + "interactive-observer-password", + "apply-database-capabilities.sql@each-discovered-database", + "apply-control-database.sql@mira_dashboard_observability", + "optional-apply-torrent-view.sql@bitmagnet", + "optional-apply-torrent-view.sql@comet", + "configure-pgbouncer-observer-policy-and-single-capability-label", + "compose-separate-privileged-preflight-port-into-existing-hourly-cache-job" + ], + "activationOrder": [ + "runProvisioning.ts activate-current-catalog --approved", + "first runner operation after manual prerequisites on initial installation", + "activation-creates-or-refreshes-system-and-policy-bound-approval-marker", + "manual-observer-pgbouncer-command-check", + "enable-worker-provider-configuration" + ], + "verifyOrder": [ + "runProvisioning.ts verify-current-catalog --approved after initial activation", + "verify-database-capabilities.sql@each-discovered-database", + "optional-verify-torrent-view.sql@bitmagnet", + "optional-verify-torrent-view.sql@comet", + "verify-reconciliation-approval.sql@mira_dashboard_observability-after-activation", + "manual-pgbouncer-and-preflight-port-config-check" + ], + "subsequentReleaseOrder": [ + "runProvisioning.ts verify-current-catalog --approved may precede activation only when the retained current or previous digest approves the release", + "otherwise runProvisioning.ts activate-current-catalog --approved first", + "manual-observer-pgbouncer-command-check after approval and verification", + "enable-worker-provider-configuration after the approved smoke check" + ], + "rollbackOrder": [ + "disable-worker-provider-configuration", + "disable-observer.sql", + "rollback-reconciliation-approval.sql@mira_dashboard_observability", + "optional-rollback-torrent-view.sql@comet", + "optional-rollback-torrent-view.sql@bitmagnet", + "rollback-control-database.sql@mira_dashboard_observability", + "rollback-database-capabilities.sql@each-discovered-database", + "manual-pgbouncer-stats-user-removal", + "rollback-cluster.sql", + "rollback-control-database-capability.sql@mira_dashboard_observability-retain" + ] +} diff --git a/greenfield/scripts/delivery/provisioning/database-observability/prepare-approved-collection.sql b/greenfield/scripts/delivery/provisioning/database-observability/prepare-approved-collection.sql new file mode 100644 index 000000000..66b22e9c6 --- /dev/null +++ b/greenfield/scripts/delivery/provisioning/database-observability/prepare-approved-collection.sql @@ -0,0 +1,65 @@ +\set ON_ERROR_STOP 1 + +SELECT pg_catalog.set_config( + 'mira_dashboard.collection_lease_token', + :'collection_lease_token', + true +); +SELECT pg_catalog.pg_advisory_xact_lock(1835623521, 1668048243); + +DO $prepare_approved_collection$ +DECLARE + expected_comment constant text := + 'mira-dashboard-collection-lease:' || + pg_catalog.current_setting('mira_dashboard.collection_lease_token'); + observer pg_catalog.pg_authid%ROWTYPE; + observer_config text[]; +BEGIN + SELECT * INTO observer + FROM pg_catalog.pg_authid + WHERE rolname = 'mira_dashboard_observer'; + SELECT settings.setconfig INTO observer_config + FROM pg_catalog.pg_db_role_setting AS settings + WHERE settings.setrole = observer.oid + AND settings.setdatabase = 0; + IF observer.oid IS NULL + OR observer.rolcanlogin + OR NOT observer.rolinherit + OR observer.rolsuper + OR observer.rolcreatedb + OR observer.rolcreaterole + OR observer.rolreplication + OR observer.rolbypassrls + OR observer.rolconnlimit IS DISTINCT FROM 64 + OR observer.rolpassword IS NULL + OR observer.rolpassword NOT LIKE 'SCRAM-SHA-256$%' + OR observer.rolvaliduntil IS DISTINCT FROM + '1970-01-01 00:00:00+00'::timestamp with time zone + OR pg_catalog.shobj_description(observer.oid, 'pg_authid') IS NOT NULL + OR pg_catalog.cardinality(observer_config) IS DISTINCT FROM 4 + OR NOT COALESCE(observer_config @> ARRAY[ + 'default_transaction_read_only=on', + 'statement_timeout=5s', + 'idle_session_timeout=60s', + 'idle_in_transaction_session_timeout=60s' + ]::text[], false) + OR EXISTS ( + SELECT 1 + FROM pg_catalog.pg_stat_activity AS activity + WHERE activity.usename = 'mira_dashboard_observer' + AND activity.pid <> pg_catalog.pg_backend_pid() + ) + THEN + RAISE EXCEPTION 'Database observability collection role is not closed'; + END IF; + EXECUTE pg_catalog.format( + 'COMMENT ON ROLE mira_dashboard_observer IS %L', + expected_comment + ); + IF pg_catalog.shobj_description(observer.oid, 'pg_authid') + IS DISTINCT FROM expected_comment + THEN + RAISE EXCEPTION 'Database observability collection lease was not prepared'; + END IF; +END +$prepare_approved_collection$; diff --git a/greenfield/scripts/delivery/provisioning/database-observability/reconcile-database-access.sql b/greenfield/scripts/delivery/provisioning/database-observability/reconcile-database-access.sql new file mode 100644 index 000000000..1edb25702 --- /dev/null +++ b/greenfield/scripts/delivery/provisioning/database-observability/reconcile-database-access.sql @@ -0,0 +1,6 @@ +\set ON_ERROR_STOP 1 + +-- This fixed call is shared by approval-gated activation and the periodic +-- executor. The function owns the transaction, advisory lock, bounds, +-- identifier quoting, catalog-race detection, and ACL mutation. +SELECT mira_dashboard_database_access.reconcile(); diff --git a/greenfield/scripts/delivery/provisioning/database-observability/rollback-cluster.sql b/greenfield/scripts/delivery/provisioning/database-observability/rollback-cluster.sql new file mode 100644 index 000000000..1e71c1f06 --- /dev/null +++ b/greenfield/scripts/delivery/provisioning/database-observability/rollback-cluster.sql @@ -0,0 +1,42 @@ +\set ON_ERROR_STOP 1 + +ALTER ROLE mira_dashboard_observer NOLOGIN; +ALTER ROLE mira_dashboard_observer PASSWORD NULL; +ALTER ROLE mira_dashboard_observability_capability_owner NOLOGIN; +ALTER ROLE mira_dashboard_observability_capability_owner PASSWORD NULL; + +DO $terminate_observer_sessions$ +DECLARE + observer_session record; +BEGIN + FOR observer_session IN + SELECT activity.pid + FROM pg_catalog.pg_stat_activity AS activity + WHERE activity.usename IN ( + 'mira_dashboard_observer', + 'mira_dashboard_observability_capability_owner' + ) + AND activity.pid <> pg_catalog.pg_backend_pid() + LOOP + IF NOT pg_catalog.pg_terminate_backend(observer_session.pid, 5000) THEN + RAISE EXCEPTION 'Database observability observer session could not be terminated'; + END IF; + END LOOP; + PERFORM pg_catalog.pg_stat_clear_snapshot(); +END +$terminate_observer_sessions$; + +BEGIN; + +REVOKE pg_monitor FROM mira_dashboard_observer; +REVOKE pg_read_all_stats FROM mira_dashboard_observer; +REVOKE pg_monitor FROM mira_dashboard_observability_capability_owner; +REVOKE pg_read_all_stats FROM mira_dashboard_observability_capability_owner; + +-- `rollback-control-database.sql` and the optional torrent-view rollbacks must +-- remove every owned object and source grant before either reserved role drops. +DROP ROLE mira_dashboard_observer; +DROP ROLE mira_dashboard_observability_capability_owner; +DROP ROLE mira_dashboard_observability_owner; + +COMMIT; diff --git a/greenfield/scripts/delivery/provisioning/database-observability/rollback-control-database-capability.sql b/greenfield/scripts/delivery/provisioning/database-observability/rollback-control-database-capability.sql new file mode 100644 index 000000000..aea1f0754 --- /dev/null +++ b/greenfield/scripts/delivery/provisioning/database-observability/rollback-control-database-capability.sql @@ -0,0 +1,58 @@ +\set ON_ERROR_STOP 1 + +-- The physical capability database is deliberately retained. DROP DATABASE is +-- not an acceptable generic rollback for an object that may have acquired data. +-- The preceding control/access rollbacks must leave only the reviewed extension. +REVOKE ALL PRIVILEGES ON DATABASE mira_dashboard_observability FROM PUBLIC; + +\ir verify-control-database-capability.sql + +DO $verify_safe_retention$ +DECLARE + extension_names text[]; +BEGIN + SELECT pg_catalog.array_agg(extensions.extname::text ORDER BY extensions.extname) + INTO extension_names + FROM pg_catalog.pg_extension AS extensions; + + IF pg_catalog.current_database() <> 'mira_dashboard_observability' + OR extension_names IS DISTINCT FROM ARRAY[ + 'pg_stat_statements', + 'plpgsql' + ]::text[] + OR EXISTS ( + SELECT 1 + FROM pg_catalog.pg_namespace AS namespaces + WHERE namespaces.nspname NOT IN ( + 'information_schema', + 'pg_catalog', + 'public' + ) + AND namespaces.nspname NOT LIKE 'pg_toast%' + AND namespaces.nspname NOT LIKE 'pg_temp_%' + ) + OR EXISTS ( + SELECT 1 + FROM pg_catalog.pg_class AS classes + JOIN pg_catalog.pg_namespace AS namespaces + ON namespaces.oid = classes.relnamespace + WHERE namespaces.nspname = 'public' + AND NOT EXISTS ( + SELECT 1 + FROM pg_catalog.pg_depend AS dependencies + JOIN pg_catalog.pg_extension AS extensions + ON dependencies.refclassid = + 'pg_catalog.pg_extension'::pg_catalog.regclass + AND dependencies.refobjid = extensions.oid + WHERE dependencies.classid = 'pg_catalog.pg_class'::pg_catalog.regclass + AND dependencies.objid = classes.oid + AND dependencies.objsubid = 0 + AND dependencies.deptype = 'e' + ) + ) + THEN + RAISE EXCEPTION 'Database observability control database is not safe to retain'; + END IF; + +END +$verify_safe_retention$; diff --git a/greenfield/scripts/delivery/provisioning/database-observability/rollback-control-database.sql b/greenfield/scripts/delivery/provisioning/database-observability/rollback-control-database.sql new file mode 100644 index 000000000..6b3f4ddb7 --- /dev/null +++ b/greenfield/scripts/delivery/provisioning/database-observability/rollback-control-database.sql @@ -0,0 +1,67 @@ +\set ON_ERROR_STOP 1 + +BEGIN; + +DROP FUNCTION IF EXISTS + mira_dashboard_observability_capabilities.statement_metrics(); +DROP FUNCTION IF EXISTS + mira_dashboard_observability_capabilities.connection_metrics(); + +DO $revoke_reviewed_source$ +DECLARE + extension_oid oid; + extension_member record; +BEGIN + SELECT extensions.oid INTO extension_oid + FROM pg_catalog.pg_extension AS extensions + JOIN pg_catalog.pg_namespace AS namespaces + ON namespaces.oid = extensions.extnamespace + WHERE extensions.extname = 'pg_stat_statements' + AND namespaces.nspname = 'public'; + IF extension_oid IS NULL THEN + RAISE EXCEPTION 'pg_stat_statements rollback boundary is invalid'; + END IF; + + FOR extension_member IN + SELECT namespaces.nspname, classes.relname + FROM pg_catalog.pg_class AS classes + JOIN pg_catalog.pg_namespace AS namespaces + ON namespaces.oid = classes.relnamespace + JOIN pg_catalog.pg_depend AS dependencies + ON dependencies.classid = 'pg_catalog.pg_class'::pg_catalog.regclass + AND dependencies.objid = classes.oid + AND dependencies.objsubid = 0 + AND dependencies.refclassid = + 'pg_catalog.pg_extension'::pg_catalog.regclass + AND dependencies.refobjid = extension_oid + AND dependencies.deptype = 'e' + ORDER BY classes.oid + LOOP + EXECUTE pg_catalog.format( + 'REVOKE ALL PRIVILEGES ON TABLE %I.%I FROM mira_dashboard_observer, mira_dashboard_observability_owner, mira_dashboard_observability_capability_owner', + extension_member.nspname, + extension_member.relname + ); + END LOOP; + FOR extension_member IN + SELECT routines.oid + FROM pg_catalog.pg_proc AS routines + JOIN pg_catalog.pg_depend AS dependencies + ON dependencies.classid = 'pg_catalog.pg_proc'::pg_catalog.regclass + AND dependencies.objid = routines.oid + AND dependencies.objsubid = 0 + AND dependencies.refclassid = + 'pg_catalog.pg_extension'::pg_catalog.regclass + AND dependencies.refobjid = extension_oid + AND dependencies.deptype = 'e' + ORDER BY routines.oid + LOOP + EXECUTE pg_catalog.format( + 'REVOKE ALL PRIVILEGES ON FUNCTION %s FROM mira_dashboard_observer, mira_dashboard_observability_owner, mira_dashboard_observability_capability_owner', + extension_member.oid::pg_catalog.regprocedure + ); + END LOOP; +END +$revoke_reviewed_source$; + +COMMIT; diff --git a/greenfield/scripts/delivery/provisioning/database-observability/rollback-database-access-reconciler.sql b/greenfield/scripts/delivery/provisioning/database-observability/rollback-database-access-reconciler.sql new file mode 100644 index 000000000..dfe6ebb16 --- /dev/null +++ b/greenfield/scripts/delivery/provisioning/database-observability/rollback-database-access-reconciler.sql @@ -0,0 +1,6 @@ +\set ON_ERROR_STOP 1 + +BEGIN; +DROP FUNCTION mira_dashboard_database_access.reconcile(); +DROP SCHEMA mira_dashboard_database_access RESTRICT; +COMMIT; diff --git a/greenfield/scripts/delivery/provisioning/database-observability/rollback-database-capabilities.sql b/greenfield/scripts/delivery/provisioning/database-observability/rollback-database-capabilities.sql new file mode 100644 index 000000000..1703fab35 --- /dev/null +++ b/greenfield/scripts/delivery/provisioning/database-observability/rollback-database-capabilities.sql @@ -0,0 +1,50 @@ +\set ON_ERROR_STOP 1 + +BEGIN; + +REVOKE ALL PRIVILEGES ON TABLE pg_catalog.pg_statistic + FROM mira_dashboard_observability_capability_owner; +ALTER DEFAULT PRIVILEGES + FOR ROLE mira_dashboard_observability_capability_owner + GRANT EXECUTE ON FUNCTIONS TO PUBLIC; + +DROP FUNCTION IF EXISTS + mira_dashboard_observability_capabilities.table_health(); +DROP FUNCTION IF EXISTS + mira_dashboard_observability_capabilities.maintenance_metrics(); + +DO $drop_empty_capability_schema$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM pg_catalog.pg_namespace AS namespaces + WHERE namespaces.nspname = 'mira_dashboard_observability_capabilities' + ) + AND NOT EXISTS ( + SELECT 1 + FROM pg_catalog.pg_class AS classes + JOIN pg_catalog.pg_namespace AS namespaces + ON namespaces.oid = classes.relnamespace + WHERE namespaces.nspname = 'mira_dashboard_observability_capabilities' + ) + AND NOT EXISTS ( + SELECT 1 + FROM pg_catalog.pg_proc AS routines + JOIN pg_catalog.pg_namespace AS namespaces + ON namespaces.oid = routines.pronamespace + WHERE namespaces.nspname = 'mira_dashboard_observability_capabilities' + ) + AND NOT EXISTS ( + SELECT 1 + FROM pg_catalog.pg_type AS types + JOIN pg_catalog.pg_namespace AS namespaces + ON namespaces.oid = types.typnamespace + WHERE namespaces.nspname = 'mira_dashboard_observability_capabilities' + ) + THEN + DROP SCHEMA mira_dashboard_observability_capabilities RESTRICT; + END IF; +END +$drop_empty_capability_schema$; + +COMMIT; diff --git a/greenfield/scripts/delivery/provisioning/database-observability/rollback-reconciliation-approval.sql b/greenfield/scripts/delivery/provisioning/database-observability/rollback-reconciliation-approval.sql new file mode 100644 index 000000000..f6addf638 --- /dev/null +++ b/greenfield/scripts/delivery/provisioning/database-observability/rollback-reconciliation-approval.sql @@ -0,0 +1,90 @@ +\set ON_ERROR_STOP 1 + +BEGIN; + +DO $rollback_approval_boundary$ +DECLARE + administrator_oid oid; + approval_oid oid; + approval_schema_oid oid; +BEGIN + SELECT databases.datdba INTO administrator_oid + FROM pg_catalog.pg_database AS databases + JOIN pg_catalog.pg_roles AS owners ON owners.oid = databases.datdba + WHERE databases.datname = pg_catalog.current_database() + AND databases.datname = 'mira_dashboard_observability' + AND owners.rolname = CURRENT_USER + AND owners.rolsuper; + SELECT namespaces.oid INTO approval_schema_oid + FROM pg_catalog.pg_namespace AS namespaces + WHERE namespaces.nspname = 'mira_dashboard_observability_control' + AND namespaces.nspowner = administrator_oid; + SELECT classes.oid INTO approval_oid + FROM pg_catalog.pg_class AS classes + WHERE classes.relnamespace = approval_schema_oid + AND classes.relname = 'reconciliation_approval' + AND classes.relkind = 'r' + AND classes.relowner = administrator_oid; + IF administrator_oid IS NULL + OR approval_schema_oid IS NULL + OR approval_oid IS NULL + OR (SELECT pg_catalog.count(*) FROM pg_catalog.pg_class AS classes + WHERE classes.relnamespace = approval_schema_oid) <> 2 + OR (SELECT pg_catalog.count(*) FROM pg_catalog.pg_index AS indexes + WHERE indexes.indrelid = approval_oid + AND indexes.indisprimary + AND indexes.indisunique + AND indexes.indisvalid) <> 1 + OR (SELECT pg_catalog.array_agg(attributes.attname::text + ORDER BY attributes.attnum) + FROM pg_catalog.pg_attribute AS attributes + WHERE attributes.attrelid = approval_oid + AND attributes.attnum > 0 + AND NOT attributes.attisdropped) IS DISTINCT FROM ARRAY[ + 'singleton', + 'policy_version', + 'system_identifier', + 'current_policy_digest', + 'previous_policy_digest' + ]::text[] + OR EXISTS ( + SELECT 1 FROM pg_catalog.pg_trigger AS triggers + WHERE triggers.tgrelid = approval_oid AND NOT triggers.tgisinternal + ) + OR EXISTS ( + SELECT 1 FROM pg_catalog.pg_rewrite AS rules + WHERE rules.ev_class = approval_oid + ) + OR EXISTS ( + SELECT 1 FROM pg_catalog.pg_policy AS policies + WHERE policies.polrelid = approval_oid + ) + OR EXISTS ( + SELECT 1 FROM pg_catalog.pg_proc AS routines + WHERE routines.pronamespace = approval_schema_oid + ) + OR (SELECT pg_catalog.count(*) + FROM mira_dashboard_observability_control.reconciliation_approval) <> 1 + OR NOT EXISTS ( + SELECT 1 + FROM mira_dashboard_observability_control.reconciliation_approval AS approval + CROSS JOIN pg_catalog.pg_control_system() AS controls + WHERE approval.singleton + AND approval.policy_version = 'sanitized-capabilities-v1' + AND approval.system_identifier = controls.system_identifier + AND approval.current_policy_digest ~ '^[0-9a-f]{64}$' + AND ( + approval.previous_policy_digest IS NULL + OR approval.previous_policy_digest ~ '^[0-9a-f]{64}$' + ) + ) + THEN + RAISE EXCEPTION 'Database observability reconciliation approval rollback boundary is invalid'; + END IF; +END +$rollback_approval_boundary$; + +DROP TABLE mira_dashboard_observability_control.reconciliation_approval; +DROP SCHEMA mira_dashboard_observability_control RESTRICT; + +COMMIT; diff --git a/greenfield/scripts/delivery/provisioning/database-observability/rollback-torrent-view.sql b/greenfield/scripts/delivery/provisioning/database-observability/rollback-torrent-view.sql new file mode 100644 index 000000000..9ad8daa54 --- /dev/null +++ b/greenfield/scripts/delivery/provisioning/database-observability/rollback-torrent-view.sql @@ -0,0 +1,31 @@ +\set ON_ERROR_STOP 1 + +BEGIN; + +DO $guard$ +BEGIN + IF pg_catalog.current_database() NOT IN ('bitmagnet', 'comet') THEN + RAISE EXCEPTION 'Database observability view target is not reviewed'; + END IF; +END +$guard$; + +DROP VIEW IF EXISTS mira_dashboard_observability.torrent_count; +REVOKE SELECT ON TABLE public.torrents + FROM mira_dashboard_observability_owner; + +DO $drop_empty_schema$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_catalog.pg_class AS classes + JOIN pg_catalog.pg_namespace AS namespaces + ON namespaces.oid = classes.relnamespace + WHERE namespaces.nspname = 'mira_dashboard_observability' + ) THEN + DROP SCHEMA IF EXISTS mira_dashboard_observability RESTRICT; + END IF; +END +$drop_empty_schema$; + +COMMIT; diff --git a/greenfield/scripts/delivery/provisioning/database-observability/runProvisioning.ts b/greenfield/scripts/delivery/provisioning/database-observability/runProvisioning.ts new file mode 100644 index 000000000..eae957629 --- /dev/null +++ b/greenfield/scripts/delivery/provisioning/database-observability/runProvisioning.ts @@ -0,0 +1,1822 @@ +import { createHash, randomUUID } from "node:crypto"; +import { constants, type BigIntStats } from "node:fs"; +import { lstat, open, realpath } from "node:fs/promises"; +import path from "node:path"; + +const failureMessage = "Database observability provisioning failed"; +const usage = + "Usage: bun runProvisioning.ts --approved [--collection-lease-token --catalog-digest ]"; +const dockerExecutable = "/usr/bin/docker"; +const dockerHost = "unix:///var/run/docker.sock"; +const composeExecutable = "/opt/docker/bin/docker-compose-doppler"; +const composeRoot = "/opt/docker"; +const composeRootConfig = "/opt/docker/compose.yaml"; +const containerShellExecutable = "/bin/sh"; +const containerOperatingSystemUser = "postgres"; +const containerPsqlProbeLauncher = + ': "${POSTGRES_USER:?}"; exec /usr/bin/env -i HOME=/var/lib/postgresql LANG=C LC_ALL=C PATH=/usr/local/bin:/usr/bin:/bin PGUSER="$POSTGRES_USER" /usr/bin/timeout -s TERM -k 1 3 /usr/local/bin/psql --host=/var/run/postgresql --username="$POSTGRES_USER" --no-psqlrc --set=ON_ERROR_STOP=1 "$@"'; +const containerPsqlLauncher = + ': "${POSTGRES_USER:?}"; exec /usr/bin/env -i HOME=/var/lib/postgresql LANG=C LC_ALL=C PATH=/usr/local/bin:/usr/bin:/bin PGUSER="$POSTGRES_USER" /usr/bin/timeout -s TERM -k 2 45 /usr/local/bin/psql --host=/var/run/postgresql --username="$POSTGRES_USER" --no-psqlrc --set=ON_ERROR_STOP=1 "$@"'; +const capabilityLabel = "mira.dashboard.database-observability"; +const capabilityValue = "pgbouncer-v1"; +const processDeadlineMs = 60_000; +const discoveryDeadlineMs = 5000; +const provisioningOperationDeadlineMs = 5 * 60_000; +const closeOperationDeadlineMs = 25_000; +const processStderrMaximumBytes = 64 * 1024; +const dockerPsOutputMaximumBytes = 256 * 1024; +const dockerInspectOutputMaximumBytes = 2 * 1024 * 1024; +const psqlOutputMaximumBytes = 64 * 1024; +const catalogOutputMaximumBytes = 16 * 1024; +const sqlArtifactMaximumBytes = 64 * 1024; +const composeIdentityMaximumBytes = 128; +const composeDependsOnMaximumBytes = 4096; +const containerIdPattern = /^[0-9a-f]{64}$/u; +const composeIdentityPattern = /^[A-Za-z0-9][A-Za-z0-9_.-]{0,127}$/u; +const unsafeTextPattern = /[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/u; +const collectionLeaseTokenPattern = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u; +const catalogDigestPattern = /^[0-9a-f]{64}$/u; + +export const provisioningDockerInspectFormat = [ + "{{json .Id}}", + `{{with .Config.Labels}}{{json (index . "${capabilityLabel}")}}{{else}}null{{end}}`, + '{{with .Config.Labels}}{{json (index . "com.docker.compose.project")}}{{else}}null{{end}}', + '{{with .Config.Labels}}{{json (index . "com.docker.compose.service")}}{{else}}null{{end}}', + '{{with .Config.Labels}}{{json (index . "com.docker.compose.depends_on")}}{{else}}null{{end}}', + '{{with .Config.Labels}}{{json (index . "com.docker.compose.project.working_dir")}}{{else}}null{{end}}', + '{{with .Config.Labels}}{{json (index . "com.docker.compose.project.config_files")}}{{else}}null{{end}}', + '{{with .Config.Labels}}{{json (index . "com.docker.compose.container-number")}}{{else}}null{{end}}', + '{{with .Config.Labels}}{{json (index . "com.docker.compose.oneoff")}}{{else}}null{{end}}', + "{{json .State.Status}}", + "{{if .State.Health}}{{json .State.Health.Status}}{{else}}null{{end}}", +].join("\t"); + +export const provisioningDockerContainerMaximum = 256; +export const provisioningSqlIncludeDepthMaximum = 8; +export const provisioningSqlIncludeCountMaximum = 32; +export const provisioningSqlInputMaximumBytes = 512 * 1024; +/** Must remain equal to databaseObservabilityDatabaseMaximum in shared policy. */ +export const provisioningDatabaseMaximum = 64; +/** Includes templates and disabled databases in the race-detection fingerprint. */ +export const provisioningCatalogDatabaseMaximum = 80; +/** The PgBouncer alias and physical PostgreSQL control database are identical. */ +export const provisioningControlDatabase = "mira_dashboard_observability" as const; + +export const provisioningProcessEnvironment = Object.freeze({ + HOME: "/home/ubuntu", + LANG: "C", + LC_ALL: "C", + PATH: "/usr/local/bin:/usr/bin:/bin", +}); + +const provisioningSqlArtifactNames = Object.freeze([ + "activate-observer.sql", + "apply-cluster.sql", + "apply-control-database-capability.sql", + "apply-control-database.sql", + "apply-database-access-reconciler.sql", + "apply-database-capabilities.sql", + "apply-reconciliation-approval.sql", + "apply-torrent-view.sql", + "disable-observer.sql", + "enable-approved-collection.sql", + "prepare-approved-collection.sql", + "reconcile-database-access.sql", + "rollback-cluster.sql", + "rollback-control-database-capability.sql", + "rollback-control-database.sql", + "rollback-database-access-reconciler.sql", + "rollback-database-capabilities.sql", + "rollback-reconciliation-approval.sql", + "rollback-torrent-view.sql", + "verify-cluster.sql", + "verify-control-database-capability.sql", + "verify-control-database.sql", + "verify-database-access-reconciler.sql", + "verify-database-capabilities.sql", + "verify-reconciliation-approval.sql", + "verify-database.sql", + "verify-torrent-view.sql", +] as const); +type ProvisioningSqlArtifactName = (typeof provisioningSqlArtifactNames)[number]; +const provisioningSqlArtifactNameSet = new Set(provisioningSqlArtifactNames); +const provisioningPolicyArtifactNames = Object.freeze( + [...provisioningSqlArtifactNames, "runProvisioning.ts" as const].toSorted() +); +type ProvisioningPolicyArtifactName = (typeof provisioningPolicyArtifactNames)[number]; +const provisioningPolicyArtifactNameSet = new Set( + provisioningPolicyArtifactNames +); + +interface ProvisioningDatabaseCatalogEntry { + readonly allowsConnections: boolean; + readonly isTemplate: boolean; + readonly name: string; + readonly oid: string; + readonly ownerOid: string; +} + +interface ProvisioningDockerRow { + readonly capability: string | null; + readonly configFiles: string | null; + readonly containerNumber: string | null; + readonly dependsOn: string | null; + readonly health: string | null; + readonly id: string; + readonly project: string | null; + readonly service: string | null; + readonly state: string; + readonly oneOff: string | null; + readonly workingDirectory: string | null; +} + +interface ProvisioningComposeTarget { + readonly administrativeDatabase?: string; + readonly administrativeRoleOid?: string; + readonly capabilityContainerId: string; + readonly containerId: string; + readonly project: string; + readonly service: string; + readonly systemIdentifier?: string; +} + +interface ProvisioningDeadline { + readonly expiresAt: number; +} + +interface SqlExpansionState { + readonly activeFiles: Set; + readonly afterDescriptorStat?: ( + fileName: ProvisioningSqlArtifactName + ) => Promise | void; + readonly artifactDevice: bigint; + readonly artifactMode: bigint; + readonly artifactOwner: bigint; + fileCount: number; + sourceBytes: number; +} + +const serverIdentitySql = `SELECT pg_catalog.json_build_array( + CURRENT_USER::text, + SESSION_USER::text, + (SELECT roles.rolsuper FROM pg_catalog.pg_roles AS roles WHERE roles.rolname = CURRENT_USER), + (SELECT roles.oid::text FROM pg_catalog.pg_roles AS roles WHERE roles.rolname = CURRENT_USER), + pg_catalog.current_database()::text, + (SELECT controls.system_identifier::text FROM pg_catalog.pg_control_system() AS controls), + pg_catalog.current_setting('server_version_num') +); +`; + +function containerPsqlPrefix( + service: string, + launcher: typeof containerPsqlLauncher | typeof containerPsqlProbeLauncher +): readonly string[] { + return Object.freeze([ + service, + containerShellExecutable, + "-ceu", + launcher, + "mira-dashboard-psql", + ]); +} + +export type DatabaseObservabilityProvisioningMode = + | "activate-current-catalog" + | "close-approved-collection" + | "enable-approved-collection" + | "open-approved-collection" + | "verify-current-catalog"; + +export interface DatabaseObservabilityProvisioningProcessRequest { + readonly argv: readonly string[]; + readonly cwd: typeof composeRoot; + readonly deadlineMs: number; + readonly environment: typeof provisioningProcessEnvironment; + readonly executable: typeof composeExecutable | typeof dockerExecutable; + readonly stdin: string | null; + readonly stdoutMaximumBytes: number; +} + +export interface DatabaseObservabilityProvisioningProcessResult { + readonly exitCode: number; + readonly stderr: string; + readonly stdout: string; +} + +export type DatabaseObservabilityProvisioningProcess = ( + request: DatabaseObservabilityProvisioningProcessRequest +) => Promise; + +/** Injectable process boundary for focused, non-production orchestration tests. */ +export interface DatabaseObservabilityProvisioningDependencies { + /** Private copied artifact root used only by adversarial descriptor tests. */ + readonly artifactRoot?: string; + /** Deterministic file-mutation boundary used only by adversarial tests. */ + readonly afterSqlArtifactDescriptorStat?: ( + fileName: ProvisioningSqlArtifactName + ) => Promise | void; + /** Exact catalog digest returned by the immediately preceding prepared open. */ + readonly catalogDigest?: string; + /** One-use token returned by the immediately preceding prepared open. */ + readonly collectionLeaseToken?: string; + /** Injectable entropy boundary used only by focused orchestration tests. */ + readonly collectionLeaseTokenFactory?: () => string; + readonly run?: DatabaseObservabilityProvisioningProcess; +} + +/** Redacted result for one approval-gated bounded catalog pass. */ +export interface DatabaseObservabilityProvisioningResult { + readonly catalogDigest?: string; + readonly collectionLeaseToken?: string; + readonly databaseCount: number; + readonly mode: DatabaseObservabilityProvisioningMode; + readonly status: + | "ACTIVATED" + | "CLOSED" + | "OPENED" + | "RECONCILED" + | "UNCHANGED" + | "VERIFIED"; +} + +function fail(): never { + throw new Error(failureMessage); +} + +function utf8Bytes(value: string): number { + return Buffer.byteLength(value, "utf8"); +} + +async function readBounded( + stream: ReadableStream, + maximumBytes: number +): Promise { + const reader = stream.getReader(); + const chunks: Uint8Array[] = []; + let total = 0; + try { + while (true) { + const next = await reader.read(); + if (next.done) break; + total += next.value.byteLength; + if (total > maximumBytes) fail(); + chunks.push(next.value); + } + } finally { + reader.releaseLock(); + } + const output = new Uint8Array(total); + let offset = 0; + for (const chunk of chunks) { + output.set(chunk, offset); + offset += chunk.byteLength; + } + return output; +} + +function decodeUtf8(value: Uint8Array): string { + try { + return new TextDecoder("utf-8", { fatal: true }).decode(value); + } catch { + fail(); + } +} + +const defaultProcess: DatabaseObservabilityProvisioningProcess = async (request) => { + const signal = AbortSignal.timeout(request.deadlineMs); + const child = Bun.spawn([request.executable, ...request.argv], { + cwd: request.cwd, + env: request.environment, + signal, + stderr: "pipe", + stdin: "pipe", + stdout: "pipe", + }); + try { + await child.stdin.write(request.stdin ?? ""); + await child.stdin.end(); + const [exitCode, stdout, stderr] = await Promise.all([ + child.exited, + readBounded(child.stdout, request.stdoutMaximumBytes), + readBounded(child.stderr, processStderrMaximumBytes), + ]); + return { + exitCode, + stderr: decodeUtf8(stderr), + stdout: decodeUtf8(stdout), + }; + } catch { + child.kill(); + await child.exited.catch(() => {}); + fail(); + } +}; + +async function executeProcess( + run: DatabaseObservabilityProvisioningProcess, + request: DatabaseObservabilityProvisioningProcessRequest, + deadline?: ProvisioningDeadline +): Promise { + if ( + request.stdin !== null && + utf8Bytes(request.stdin) > provisioningSqlInputMaximumBytes + ) { + fail(); + } + try { + const expiresAt = deadline?.expiresAt ?? 0; + if ( + deadline !== undefined && + (!Number.isSafeInteger(expiresAt) || + expiresAt - Date.now() < request.deadlineMs) + ) { + fail(); + } + const result = await run(request); + if ( + !Number.isSafeInteger(result.exitCode) || + result.exitCode !== 0 || + typeof result.stdout !== "string" || + typeof result.stderr !== "string" || + utf8Bytes(result.stdout) > request.stdoutMaximumBytes || + utf8Bytes(result.stderr) > processStderrMaximumBytes + ) { + fail(); + } + return result.stdout; + } catch { + fail(); + } +} + +function dockerRequest( + argv: readonly string[], + stdoutMaximumBytes: number +): DatabaseObservabilityProvisioningProcessRequest { + return Object.freeze({ + argv: Object.freeze(["--host", dockerHost, ...argv]), + cwd: composeRoot, + deadlineMs: discoveryDeadlineMs, + environment: provisioningProcessEnvironment, + executable: dockerExecutable, + stdin: null, + stdoutMaximumBytes, + }); +} + +function validDatabaseName(name: string): boolean { + return ( + name.trim().length > 0 && + !name.includes("\0") && + !unsafeTextPattern.test(name) && + utf8Bytes(name) <= 63 + ); +} + +function databaseUri(database: string): string { + const encodedName = Array.from(Buffer.from(database, "utf8"), (byte) => + byte.toString(16).toUpperCase().padStart(2, "0") + ) + .map((byte) => `%${byte}`) + .join(""); + return `postgresql:///${encodedName}`; +} + +function validComposeIdentity(value: unknown): value is string { + return ( + typeof value === "string" && + utf8Bytes(value) <= composeIdentityMaximumBytes && + composeIdentityPattern.test(value) + ); +} + +function parseContainerIds(output: string): readonly string[] { + if (utf8Bytes(output) > dockerPsOutputMaximumBytes) fail(); + const trimmed = output.trim(); + if (trimmed === "") fail(); + const lines = trimmed.split("\n"); + if (lines.length > provisioningDockerContainerMaximum) fail(); + const ids = lines.map((line) => { + let value: unknown; + try { + value = JSON.parse(line) as unknown; + } catch { + fail(); + } + if (typeof value !== "string" || !containerIdPattern.test(value)) fail(); + return value; + }); + if (new Set(ids).size !== ids.length) fail(); + return Object.freeze(ids); +} + +function nullableString(value: unknown): string | null { + if (value === null || typeof value === "string") return value; + fail(); +} + +function parseInspectRows( + output: string, + containerIds: readonly string[] +): readonly ProvisioningDockerRow[] { + if (utf8Bytes(output) > dockerInspectOutputMaximumBytes) fail(); + const trimmed = output.trim(); + if (trimmed === "") fail(); + const lines = trimmed.split("\n"); + if (lines.length !== containerIds.length) fail(); + const expectedIds = new Set(containerIds); + const observedIds = new Set(); + const rows = lines.map((line) => { + const fields = line.split("\t"); + if (fields.length !== 11) fail(); + let values: unknown[]; + try { + values = fields.map((field) => JSON.parse(field) as unknown); + } catch { + fail(); + } + const [ + id, + capability, + project, + service, + dependsOn, + workingDirectory, + configFiles, + containerNumber, + oneOff, + state, + health, + ] = values; + if ( + typeof id !== "string" || + !expectedIds.has(id) || + observedIds.has(id) || + typeof state !== "string" + ) { + fail(); + } + observedIds.add(id); + return Object.freeze({ + capability: nullableString(capability), + configFiles: nullableString(configFiles), + containerNumber: nullableString(containerNumber), + dependsOn: nullableString(dependsOn), + health: nullableString(health), + id, + project: nullableString(project), + service: nullableString(service), + state, + oneOff: nullableString(oneOff), + workingDirectory: nullableString(workingDirectory), + }); + }); + if (observedIds.size !== expectedIds.size) fail(); + return Object.freeze(rows); +} + +function healthy(row: ProvisioningDockerRow): boolean { + return row.state === "running" && row.health === "healthy"; +} + +function rootedComposeRow(row: ProvisioningDockerRow): boolean { + return ( + validComposeIdentity(row.project) && + validComposeIdentity(row.service) && + row.workingDirectory === composeRoot && + row.configFiles === composeRootConfig && + row.containerNumber === "1" && + row.oneOff === "False" + ); +} + +function serviceHealthyDependencies(value: string | null): readonly string[] { + if ( + value === null || + value === "" || + utf8Bytes(value) > composeDependsOnMaximumBytes || + unsafeTextPattern.test(value) + ) { + fail(); + } + const entries = value.split(","); + if (entries.length > 32) fail(); + const dependencies = entries.flatMap((entry) => { + const fields = entry.split(":"); + if (fields.length !== 3) fail(); + const [service, condition, restart] = fields; + if ( + !validComposeIdentity(service) || + ![ + "service_healthy", + "service_started", + "service_completed_successfully", + ].includes(condition ?? "") || + !["true", "false"].includes(restart ?? "") + ) { + fail(); + } + return condition === "service_healthy" ? [service] : []; + }); + if (dependencies.length === 0 || new Set(dependencies).size !== dependencies.length) { + fail(); + } + return Object.freeze(dependencies); +} + +function composePsqlProbeRequest( + target: ProvisioningComposeTarget +): DatabaseObservabilityProvisioningProcessRequest { + return Object.freeze({ + argv: Object.freeze([ + "--file", + composeRootConfig, + "--project-directory", + composeRoot, + "--project-name", + target.project, + "exec", + "-T", + "--index", + "1", + "--user", + containerOperatingSystemUser, + ...containerPsqlPrefix(target.service, containerPsqlProbeLauncher), + "--dbname=template1", + "--quiet", + "--tuples-only", + "--no-align", + ]), + cwd: composeRoot, + deadlineMs: discoveryDeadlineMs, + environment: provisioningProcessEnvironment, + executable: composeExecutable, + stdin: `${String.raw`\set ON_ERROR_STOP 1`} +SET statement_timeout = '5s'; +SET lock_timeout = '1s'; +${serverIdentitySql}`, + stdoutMaximumBytes: 1024, + }); +} + +function validServerIdentity( + value: unknown +): value is readonly [string, string, true, string, "template1", string, string] { + return ( + Array.isArray(value) && + value.length === 7 && + typeof value[0] === "string" && + validDatabaseName(value[0]) && + typeof value[1] === "string" && + value[1] === value[0] && + value[2] === true && + typeof value[3] === "string" && + /^[1-9][0-9]{0,9}$/u.test(value[3]) && + BigInt(value[3]) <= 4_294_967_295n && + value[4] === "template1" && + typeof value[5] === "string" && + /^[1-9][0-9]{0,19}$/u.test(value[5]) && + typeof value[6] === "string" && + /^[1-9][0-9]{4,8}$/u.test(value[6]) + ); +} + +async function discoverComposeTarget( + run: DatabaseObservabilityProvisioningProcess, + deadline: ProvisioningDeadline +): Promise { + const containerIds = parseContainerIds( + await executeProcess( + run, + dockerRequest( + ["ps", "-a", "--no-trunc", "--format", "{{json .ID}}"], + dockerPsOutputMaximumBytes + ), + deadline + ) + ); + const rows = parseInspectRows( + await executeProcess( + run, + dockerRequest( + ["inspect", "--format", provisioningDockerInspectFormat, ...containerIds], + dockerInspectOutputMaximumBytes + ), + deadline + ), + containerIds + ); + const candidates = rows.filter( + (row) => row.capability === capabilityValue && healthy(row) + ); + if (candidates.length !== 1) fail(); + const candidate = candidates[0]!; + if (!rootedComposeRow(candidate)) fail(); + const dependencyServices = serviceHealthyDependencies(candidate.dependsOn); + const potentialTargets: ProvisioningComposeTarget[] = []; + for (const dependencyService of dependencyServices) { + if (dependencyService === candidate.service) fail(); + const dependencies = rows.filter( + (row) => + row.project === candidate.project && + row.service === dependencyService && + healthy(row) + ); + if (dependencies.length === 0) continue; + if (dependencies.length !== 1 || !rootedComposeRow(dependencies[0]!)) fail(); + potentialTargets.push( + Object.freeze({ + capabilityContainerId: candidate.id, + containerId: dependencies[0]!.id, + project: candidate.project!, + service: dependencyService, + }) + ); + } + const psqlTargets: ProvisioningComposeTarget[] = []; + for (const potentialTarget of potentialTargets) { + try { + const identityOutput = await executeProcess( + run, + composePsqlProbeRequest(potentialTarget), + deadline + ); + let identity: unknown; + try { + identity = JSON.parse(identityOutput.trim()) as unknown; + } catch { + continue; + } + if (!validServerIdentity(identity)) continue; + psqlTargets.push( + Object.freeze({ + ...potentialTarget, + administrativeDatabase: identity[4], + administrativeRoleOid: identity[3], + systemIdentifier: identity[5], + }) + ); + } catch { + // A non-psql healthy dependency is not the PostgreSQL execution target. + } + } + if (psqlTargets.length !== 1) fail(); + return psqlTargets[0]!; +} + +function validCatalogTuple( + value: unknown +): value is readonly [string, string, string, boolean, boolean] { + return ( + Array.isArray(value) && + value.length === 5 && + typeof value[0] === "string" && + typeof value[1] === "string" && + typeof value[2] === "string" && + typeof value[3] === "boolean" && + typeof value[4] === "boolean" + ); +} + +function parseDatabaseCatalog( + stdout: string +): readonly ProvisioningDatabaseCatalogEntry[] { + if (stdout.includes("\0") || utf8Bytes(stdout) > catalogOutputMaximumBytes) fail(); + let parsed: unknown; + try { + parsed = JSON.parse(stdout.trim()) as unknown; + } catch { + fail(); + } + if (!Array.isArray(parsed) || !parsed.every((value) => validCatalogTuple(value))) { + fail(); + } + const catalog = parsed.map(([oid, name, ownerOid, isTemplate, allowsConnections]) => + Object.freeze({ allowsConnections, isTemplate, name, oid, ownerOid }) + ); + const observedNames = catalog + .filter(({ allowsConnections, isTemplate }) => allowsConnections && !isTemplate) + .map(({ name }) => name); + if ( + catalog.length === 0 || + catalog.length > provisioningCatalogDatabaseMaximum || + observedNames.length === 0 || + observedNames.length > provisioningDatabaseMaximum || + catalog.some( + ({ name, oid, ownerOid }) => + !validDatabaseName(name) || + !/^[1-9][0-9]{0,9}$/u.test(oid) || + BigInt(oid) > 4_294_967_295n || + !/^[1-9][0-9]{0,9}$/u.test(ownerOid) || + BigInt(ownerOid) > 4_294_967_295n + ) || + new Set(catalog.map(({ oid }) => oid)).size !== catalog.length || + new Set(catalog.map(({ name }) => name)).size !== catalog.length || + catalog.some(({ oid }, index) => { + const previous = catalog[index - 1]; + return previous !== undefined && BigInt(previous.oid) >= BigInt(oid); + }) + ) { + fail(); + } + return Object.freeze(catalog); +} + +const catalogSql = `SELECT COALESCE( + pg_catalog.json_agg(discovered.entry ORDER BY discovered.oid), + '[]'::json +) +FROM ( + SELECT + databases.oid, + pg_catalog.json_build_array( + databases.oid::text, + databases.datname::text, + databases.datdba::text, + databases.datistemplate, + databases.datallowconn + ) AS entry + FROM pg_catalog.pg_database AS databases + ORDER BY databases.oid + LIMIT ${String(provisioningCatalogDatabaseMaximum + 1)} +) AS discovered; +`; + +const closeApprovedCollectionSql = `SET statement_timeout = '8s'; +SET lock_timeout = '2s'; +BEGIN; +SELECT pg_catalog.pg_advisory_xact_lock(1835623521, 1668048243); +ALTER ROLE mira_dashboard_observer NOLOGIN + VALID UNTIL '1970-01-01 00:00:00+00'; +COMMENT ON ROLE mira_dashboard_observer IS NULL; +COMMIT; +DO $close_approved_collection$ +DECLARE + observer pg_catalog.pg_authid%ROWTYPE; + observer_session record; +BEGIN + FOR observer_session IN + SELECT activity.pid + FROM pg_catalog.pg_stat_activity AS activity + WHERE activity.usename = 'mira_dashboard_observer' + AND activity.pid <> pg_catalog.pg_backend_pid() + LOOP + IF NOT pg_catalog.pg_terminate_backend(observer_session.pid, 5000) THEN + RAISE EXCEPTION 'Database observability collection session could not be terminated'; + END IF; + END LOOP; + PERFORM pg_catalog.pg_stat_clear_snapshot(); + SELECT * INTO observer + FROM pg_catalog.pg_authid + WHERE rolname = 'mira_dashboard_observer'; + IF observer.oid IS NULL + OR observer.rolcanlogin + OR observer.rolvaliduntil IS DISTINCT FROM + '1970-01-01 00:00:00+00'::timestamp with time zone + OR pg_catalog.shobj_description(observer.oid, 'pg_authid') IS NOT NULL + OR EXISTS ( + SELECT 1 + FROM pg_catalog.pg_stat_activity AS activity + WHERE activity.usename = 'mira_dashboard_observer' + AND activity.pid <> pg_catalog.pg_backend_pid() + ) + THEN + RAISE EXCEPTION 'Database observability collection role did not close'; + END IF; +END +$close_approved_collection$; +`; + +function asSqlArtifactName(value: string): ProvisioningSqlArtifactName { + if (!provisioningSqlArtifactNameSet.has(value)) fail(); + return value as ProvisioningSqlArtifactName; +} + +async function readContainedProvisioningArtifact( + artifactRoot: string, + fileName: ProvisioningPolicyArtifactName, + state: SqlExpansionState +): Promise { + if (!provisioningPolicyArtifactNameSet.has(fileName)) fail(); + const candidate = path.join(artifactRoot, fileName); + let file: Awaited> | undefined; + let bytes: Buffer | undefined; + let pathBefore: BigIntStats | undefined; + try { + file = await open( + candidate, + constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK + ); + const descriptorPath = await realpath(`/proc/self/fd/${String(file.fd)}`); + if (descriptorPath !== candidate) fail(); + const descriptorBefore = await file.stat({ bigint: true }); + pathBefore = await lstat(candidate, { bigint: true }); + if ( + !validProvisioningArtifact(descriptorBefore, state) || + descriptorBefore.size <= 0n || + descriptorBefore.size > BigInt(sqlArtifactMaximumBytes) || + !validProvisioningArtifact(pathBefore, state) || + !sameProvisioningArtifactSnapshot(descriptorBefore, pathBefore) + ) { + fail(); + } + if (fileName !== "runProvisioning.ts") { + await state.afterDescriptorStat?.(fileName); + } + const expectedBytes = Number(descriptorBefore.size); + const buffer = Buffer.alloc(expectedBytes + 1); + let bytesRead = 0; + while (bytesRead < buffer.byteLength) { + const read = await file.read( + buffer, + bytesRead, + buffer.byteLength - bytesRead, + null + ); + if (read.bytesRead === 0) break; + bytesRead += read.bytesRead; + } + const descriptorAfter = await file.stat({ bigint: true }); + if ( + bytesRead !== expectedBytes || + !validProvisioningArtifact(descriptorAfter, state) || + !sameProvisioningArtifactSnapshot(descriptorBefore, descriptorAfter) + ) { + fail(); + } + bytes = buffer.subarray(0, bytesRead); + } catch { + fail(); + } finally { + try { + await file?.close(); + } catch { + fail(); + } + } + const pathAfter = await lstat(candidate, { bigint: true }); + if ( + !bytes || + !pathBefore || + !validProvisioningArtifact(pathAfter, state) || + !sameProvisioningArtifactSnapshot(pathBefore, pathAfter) || + bytes.byteLength !== Number(pathBefore.size) + ) { + fail(); + } + return decodeUtf8(bytes); +} + +async function readContainedSqlArtifact( + artifactRoot: string, + fileName: ProvisioningSqlArtifactName, + state: SqlExpansionState +): Promise { + return readContainedProvisioningArtifact(artifactRoot, fileName, state); +} + +function validProvisioningArtifact( + status: BigIntStats, + state: SqlExpansionState +): boolean { + return ( + status.isFile() && + !status.isSymbolicLink() && + status.nlink === 1n && + status.dev === state.artifactDevice && + status.uid === state.artifactOwner && + (status.mode & 0o7777n) === state.artifactMode + ); +} + +function sameProvisioningArtifactSnapshot( + before: BigIntStats, + after: BigIntStats +): boolean { + return ( + after.dev === before.dev && + after.ino === before.ino && + after.size === before.size && + after.ctimeNs === before.ctimeNs && + after.mtimeNs === before.mtimeNs + ); +} + +async function expandSqlArtifactFile( + artifactRoot: string, + fileName: ProvisioningSqlArtifactName, + depth: number, + state: SqlExpansionState +): Promise { + if ( + depth > provisioningSqlIncludeDepthMaximum || + state.activeFiles.has(fileName) || + ++state.fileCount > provisioningSqlIncludeCountMaximum + ) { + fail(); + } + state.activeFiles.add(fileName); + try { + const source = await readContainedSqlArtifact(artifactRoot, fileName, state); + state.sourceBytes += utf8Bytes(source); + if (state.sourceBytes > provisioningSqlInputMaximumBytes) fail(); + const lines = source.match(/[^\n]*(?:\n|$)/gu) ?? []; + const expanded: string[] = []; + for (const line of lines) { + if (line === "") continue; + const directive = line.replace(/\r?\n$/u, ""); + const include = /^[ \t]*\\ir[ \t]+([a-z0-9][a-z0-9-]*\.sql)[ \t]*$/u.exec( + directive + ); + if (include !== null) { + expanded.push( + await expandSqlArtifactFile( + artifactRoot, + asSqlArtifactName(include[1]!), + depth + 1, + state + ) + ); + continue; + } + if ( + /^[ \t]*\\ir(?:[ \t]|$)/u.test(directive) || + /^[ \t]*\\(?:i|include|include_relative)(?:[ \t]|$)/u.test(directive) + ) { + fail(); + } + expanded.push(line); + } + return expanded.join(""); + } finally { + state.activeFiles.delete(fileName); + } +} + +async function expandSqlArtifact( + fileName: ProvisioningSqlArtifactName, + afterDescriptorStat?: SqlExpansionState["afterDescriptorStat"], + sourceRoot = import.meta.dir +): Promise { + const artifactRoot = await realpath(sourceRoot); + const [rootStatus, runnerStatus] = await Promise.all([ + lstat(artifactRoot, { bigint: true }), + lstat(path.join(artifactRoot, "runProvisioning.ts"), { bigint: true }), + ]); + const runnerMode = runnerStatus.mode & 0o7777n; + if ( + typeof process.getuid !== "function" || + !rootStatus.isDirectory() || + rootStatus.isSymbolicLink() || + rootStatus.dev !== runnerStatus.dev || + rootStatus.uid !== runnerStatus.uid || + rootStatus.uid !== BigInt(process.getuid()) || + (rootStatus.mode & 0o022n) !== 0n || + !runnerStatus.isFile() || + runnerStatus.isSymbolicLink() || + runnerStatus.nlink !== 1n || + (runnerMode !== 0o400n && runnerMode !== 0o600n && runnerMode !== 0o644n) + ) { + fail(); + } + const sql = await expandSqlArtifactFile(artifactRoot, fileName, 0, { + activeFiles: new Set(), + afterDescriptorStat, + artifactDevice: runnerStatus.dev, + artifactMode: runnerMode, + artifactOwner: runnerStatus.uid, + fileCount: 0, + sourceBytes: 0, + }); + if (sql === "" || utf8Bytes(sql) > provisioningSqlInputMaximumBytes) fail(); + return sql; +} + +async function calculateProvisioningPolicyDigest( + sourceRoot = import.meta.dir +): Promise { + const artifactRoot = await realpath(sourceRoot); + const [rootStatus, runnerStatus] = await Promise.all([ + lstat(artifactRoot, { bigint: true }), + lstat(path.join(artifactRoot, "runProvisioning.ts"), { bigint: true }), + ]); + const runnerMode = runnerStatus.mode & 0o7777n; + if ( + typeof process.getuid !== "function" || + !rootStatus.isDirectory() || + rootStatus.isSymbolicLink() || + rootStatus.dev !== runnerStatus.dev || + rootStatus.uid !== runnerStatus.uid || + rootStatus.uid !== BigInt(process.getuid()) || + (rootStatus.mode & 0o022n) !== 0n || + !runnerStatus.isFile() || + runnerStatus.isSymbolicLink() || + runnerStatus.nlink !== 1n || + (runnerMode !== 0o400n && runnerMode !== 0o600n && runnerMode !== 0o644n) + ) { + fail(); + } + const state: SqlExpansionState = { + activeFiles: new Set(), + artifactDevice: runnerStatus.dev, + artifactMode: runnerMode, + artifactOwner: runnerStatus.uid, + fileCount: 0, + sourceBytes: 0, + }; + const hash = createHash("sha256"); + hash.update("mira-dashboard-database-observability-policy-v1\0", "utf8"); + for (const fileName of provisioningPolicyArtifactNames) { + const source = await readContainedProvisioningArtifact( + artifactRoot, + fileName, + state + ); + const nameBytes = utf8Bytes(fileName); + const sourceBytes = utf8Bytes(source); + state.sourceBytes += sourceBytes; + if ( + ++state.fileCount > provisioningSqlIncludeCountMaximum || + state.sourceBytes > provisioningSqlInputMaximumBytes + ) { + fail(); + } + hash.update(`${String(nameBytes)}:`, "utf8"); + hash.update(fileName, "utf8"); + hash.update(`${String(sourceBytes)}:`, "utf8"); + hash.update(source, "utf8"); + } + return hash.digest("hex"); +} + +function composePsqlRequest( + target: ProvisioningComposeTarget, + database: string | null, + sql: string, + options: { + readonly deadlineMs?: number; + readonly output?: "catalog" | "scalar"; + readonly variables?: readonly string[]; + } = {} +): DatabaseObservabilityProvisioningProcessRequest { + if ( + (database !== null && !validDatabaseName(database)) || + !target.administrativeRoleOid || + !/^[1-9][0-9]{0,9}$/u.test(target.administrativeRoleOid) || + BigInt(target.administrativeRoleOid) > 4_294_967_295n || + !target.systemIdentifier || + !/^[1-9][0-9]{0,19}$/u.test(target.systemIdentifier) + ) { + fail(); + } + const argv = [ + "--file", + composeRootConfig, + "--project-directory", + composeRoot, + "--project-name", + target.project, + "exec", + "-T", + "--index", + "1", + "--user", + containerOperatingSystemUser, + ...containerPsqlPrefix(target.service, containerPsqlLauncher), + ...(options.variables ?? []).map((variable) => `--set=${variable}`), + ...(database === null ? [] : ["--dbname", databaseUri(database)]), + "--quiet", + ...(options.output === undefined ? [] : ["--tuples-only", "--no-align"]), + ]; + return Object.freeze({ + argv: Object.freeze(argv), + cwd: composeRoot, + deadlineMs: options.deadlineMs ?? processDeadlineMs, + environment: provisioningProcessEnvironment, + executable: composeExecutable, + stdin: `${String.raw`\set ON_ERROR_STOP 1`} +SET statement_timeout = '30s'; +SET lock_timeout = '5s'; +DO $mira_dashboard_cluster_identity$ +BEGIN + IF CURRENT_USER IS DISTINCT FROM SESSION_USER + OR NOT COALESCE(( + SELECT roles.rolsuper + FROM pg_catalog.pg_roles AS roles + WHERE roles.oid = '${target.administrativeRoleOid}'::pg_catalog.oid + AND roles.rolname = CURRENT_USER + ), false) + OR ( + SELECT controls.system_identifier::text + FROM pg_catalog.pg_control_system() AS controls + ) IS DISTINCT FROM '${target.systemIdentifier}' + THEN + RAISE EXCEPTION USING MESSAGE = 'Database observability execution identity changed'; + END IF; +END +$mira_dashboard_cluster_identity$; +${sql.endsWith("\n") ? sql : `${sql}\n`}`, + stdoutMaximumBytes: + options.output === "catalog" + ? catalogOutputMaximumBytes + : psqlOutputMaximumBytes, + }); +} + +async function runSql( + run: DatabaseObservabilityProvisioningProcess, + target: ProvisioningComposeTarget, + database: string | null, + sql: string, + options: { + readonly deadlineMs?: number; + readonly output?: "catalog" | "scalar"; + readonly variables?: readonly string[]; + } = {}, + deadline?: ProvisioningDeadline +): Promise { + return executeProcess( + run, + composePsqlRequest(target, database, sql, options), + deadline + ); +} + +async function runSqlArtifact( + run: DatabaseObservabilityProvisioningProcess, + target: ProvisioningComposeTarget, + database: string, + fileName: ProvisioningSqlArtifactName, + variables: readonly string[] = [], + deadline?: ProvisioningDeadline, + afterDescriptorStat?: SqlExpansionState["afterDescriptorStat"], + artifactRoot = import.meta.dir +): Promise { + await runSql( + run, + target, + database, + await expandSqlArtifact(fileName, afterDescriptorStat, artifactRoot), + { + variables, + }, + deadline + ); +} + +function sameComposeTarget( + left: ProvisioningComposeTarget, + right: ProvisioningComposeTarget +): boolean { + return ( + left.capabilityContainerId === right.capabilityContainerId && + left.containerId === right.containerId && + left.project === right.project && + left.service === right.service && + left.systemIdentifier === right.systemIdentifier && + left.administrativeRoleOid === right.administrativeRoleOid && + left.administrativeDatabase === right.administrativeDatabase + ); +} + +function observedDatabaseNames( + catalog: readonly ProvisioningDatabaseCatalogEntry[] +): readonly string[] { + const names = catalog + .filter(({ allowsConnections, isTemplate }) => allowsConnections && !isTemplate) + .map(({ name }) => name) + .toSorted(); + if (!names.includes(provisioningControlDatabase)) fail(); + return Object.freeze(names); +} + +function databaseCatalogDigest( + catalog: readonly ProvisioningDatabaseCatalogEntry[] +): string { + return createHash("sha256") + .update("mira-dashboard-database-catalog-v1\0", "utf8") + .update(JSON.stringify(catalog), "utf8") + .digest("hex"); +} + +async function quarantineApplicationDatabase( + run: DatabaseObservabilityProvisioningProcess, + target: ProvisioningComposeTarget, + entry: ProvisioningDatabaseCatalogEntry, + deadline: ProvisioningDeadline +): Promise { + if ( + entry.name === provisioningControlDatabase || + entry.isTemplate || + !entry.allowsConnections || + !/^[1-9][0-9]{0,9}$/u.test(entry.oid) || + BigInt(entry.oid) > 4_294_967_295n + ) { + fail(); + } + await runSql( + run, + target, + provisioningControlDatabase, + `DO $quarantine_drifted_database$ +DECLARE + database_name name; + observer_oid oid; +BEGIN + SELECT databases.datname INTO database_name + FROM pg_catalog.pg_database AS databases + WHERE databases.oid = '${entry.oid}'::pg_catalog.oid + AND NOT databases.datistemplate + AND databases.datallowconn; + SELECT roles.oid INTO observer_oid + FROM pg_catalog.pg_roles AS roles + WHERE roles.rolname = 'mira_dashboard_observer'; + IF database_name IS NULL + OR database_name = '${provisioningControlDatabase}'::name + OR observer_oid IS NULL + THEN + RAISE EXCEPTION 'Database observability application quarantine target changed'; + END IF; + EXECUTE pg_catalog.format( + 'REVOKE ALL PRIVILEGES ON DATABASE %I FROM mira_dashboard_observer', + database_name + ); + IF pg_catalog.has_database_privilege(observer_oid, database_name, 'CONNECT') THEN + RAISE EXCEPTION 'Database observability application quarantine failed'; + END IF; +END +$quarantine_drifted_database$; +`, + { deadlineMs: 10_000 }, + deadline + ); +} + +async function closeApprovedCollection( + run: DatabaseObservabilityProvisioningProcess, + target: ProvisioningComposeTarget, + deadline: ProvisioningDeadline +): Promise { + if (!target.administrativeDatabase) fail(); + await runSql( + run, + target, + target.administrativeDatabase, + closeApprovedCollectionSql, + { deadlineMs: 10_000 }, + deadline + ); +} + +async function verifyReconciliationApproval( + run: DatabaseObservabilityProvisioningProcess, + target: ProvisioningComposeTarget, + policyDigest: string, + deadline: ProvisioningDeadline, + afterDescriptorStat?: SqlExpansionState["afterDescriptorStat"], + artifactRoot = import.meta.dir +): Promise { + if (!/^[0-9a-f]{64}$/u.test(policyDigest)) fail(); + await runSqlArtifact( + run, + target, + provisioningControlDatabase, + "verify-reconciliation-approval.sql", + [`approved_policy_digest=${policyDigest}`], + deadline, + afterDescriptorStat, + artifactRoot + ); +} + +async function enableApprovedCollection( + run: DatabaseObservabilityProvisioningProcess, + target: ProvisioningComposeTarget, + policyDigest: string, + collectionLeaseToken: string, + deadline: ProvisioningDeadline, + afterDescriptorStat?: SqlExpansionState["afterDescriptorStat"], + artifactRoot = import.meta.dir +): Promise { + if ( + !catalogDigestPattern.test(policyDigest) || + !collectionLeaseTokenPattern.test(collectionLeaseToken) + ) { + fail(); + } + const approvalVerification = await expandSqlArtifact( + "verify-reconciliation-approval.sql", + afterDescriptorStat, + artifactRoot + ); + const enableSql = await expandSqlArtifact( + "enable-approved-collection.sql", + afterDescriptorStat, + artifactRoot + ); + await runSql( + run, + target, + provisioningControlDatabase, + `BEGIN; +LOCK TABLE mira_dashboard_observability_control.reconciliation_approval + IN SHARE MODE; +${approvalVerification} +${enableSql} +COMMIT; +`, + { + deadlineMs: 10_000, + variables: [ + `approved_policy_digest=${policyDigest}`, + `collection_lease_token=${collectionLeaseToken}`, + ], + }, + deadline + ); +} + +async function prepareApprovedCollection( + run: DatabaseObservabilityProvisioningProcess, + target: ProvisioningComposeTarget, + policyDigest: string, + collectionLeaseToken: string, + deadline: ProvisioningDeadline, + afterDescriptorStat?: SqlExpansionState["afterDescriptorStat"], + artifactRoot = import.meta.dir +): Promise { + if ( + !catalogDigestPattern.test(policyDigest) || + !collectionLeaseTokenPattern.test(collectionLeaseToken) + ) { + fail(); + } + const approvalVerification = await expandSqlArtifact( + "verify-reconciliation-approval.sql", + afterDescriptorStat, + artifactRoot + ); + const prepareSql = await expandSqlArtifact( + "prepare-approved-collection.sql", + afterDescriptorStat, + artifactRoot + ); + await runSql( + run, + target, + provisioningControlDatabase, + `BEGIN; +LOCK TABLE mira_dashboard_observability_control.reconciliation_approval + IN SHARE MODE; +${approvalVerification} +${prepareSql} +COMMIT; +`, + { + deadlineMs: 10_000, + variables: [ + `approved_policy_digest=${policyDigest}`, + `collection_lease_token=${collectionLeaseToken}`, + ], + }, + deadline + ); +} + +/** + * Discovers the one opted-in PgBouncer capability and its one healthy PostgreSQL + * Compose dependency, then verifies the current catalog through container-local psql. + * @param mode Approval-gated verification or activation mode. + * @param dependencies Injectable fixed process boundary. + * @returns Redacted status and bounded catalog count. + */ +export async function runDatabaseObservabilityProvisioning( + mode: DatabaseObservabilityProvisioningMode, + dependencies: DatabaseObservabilityProvisioningDependencies = {} +): Promise { + try { + const run = dependencies.run ?? defaultProcess; + const artifactRoot = dependencies.artifactRoot ?? import.meta.dir; + const deadline = Object.freeze({ + expiresAt: + Date.now() + + (mode === "close-approved-collection" + ? closeOperationDeadlineMs + : provisioningOperationDeadlineMs), + }); + const target = await discoverComposeTarget(run, deadline); + if ( + !target.administrativeDatabase || + !validDatabaseName(target.administrativeDatabase) || + !target.systemIdentifier + ) { + fail(); + } + const administrativeDatabase = target.administrativeDatabase; + + if (mode === "enable-approved-collection") { + const collectionLeaseToken = dependencies.collectionLeaseToken; + const expectedCatalogDigest = dependencies.catalogDigest; + if ( + typeof collectionLeaseToken !== "string" || + !collectionLeaseTokenPattern.test(collectionLeaseToken) || + typeof expectedCatalogDigest !== "string" || + !catalogDigestPattern.test(expectedCatalogDigest) + ) { + fail(); + } + const policyDigest = await calculateProvisioningPolicyDigest(artifactRoot); + await verifyReconciliationApproval( + run, + target, + policyDigest, + deadline, + dependencies.afterSqlArtifactDescriptorStat, + artifactRoot + ); + const catalog = parseDatabaseCatalog( + await runSql( + run, + target, + provisioningControlDatabase, + catalogSql, + { output: "catalog" }, + deadline + ) + ); + const databaseNames = observedDatabaseNames(catalog); + if (databaseCatalogDigest(catalog) !== expectedCatalogDigest) fail(); + if (!sameComposeTarget(target, await discoverComposeTarget(run, deadline))) { + fail(); + } + if ((await calculateProvisioningPolicyDigest(artifactRoot)) !== policyDigest) + fail(); + await enableApprovedCollection( + run, + target, + policyDigest, + collectionLeaseToken, + deadline, + dependencies.afterSqlArtifactDescriptorStat, + artifactRoot + ); + return Object.freeze({ + databaseCount: databaseNames.length, + mode, + status: "OPENED", + }); + } + + if ( + mode === "activate-current-catalog" || + mode === "close-approved-collection" || + mode === "open-approved-collection" + ) { + await closeApprovedCollection(run, target, deadline); + } + if (mode === "close-approved-collection") { + if (!sameComposeTarget(target, await discoverComposeTarget(run, deadline))) { + fail(); + } + return Object.freeze({ + databaseCount: 0, + mode, + status: "CLOSED", + }); + } + + const policyDigest = await calculateProvisioningPolicyDigest(artifactRoot); + if (mode === "open-approved-collection") { + await verifyReconciliationApproval( + run, + target, + policyDigest, + deadline, + dependencies.afterSqlArtifactDescriptorStat, + artifactRoot + ); + } + + if (mode === "activate-current-catalog") { + await runSqlArtifact( + run, + target, + administrativeDatabase, + "apply-control-database-capability.sql", + ["apply_control_database_capability=approved"], + deadline, + dependencies.afterSqlArtifactDescriptorStat, + artifactRoot + ); + } + + const initialCatalog = parseDatabaseCatalog( + await runSql( + run, + target, + provisioningControlDatabase, + catalogSql, + { + output: "catalog", + }, + deadline + ) + ); + const databaseNames = observedDatabaseNames(initialCatalog); + const catalogByName = new Map( + initialCatalog.map((entry) => [entry.name, entry] as const) + ); + const quarantinedApplicationDatabases = new Set(); + const quarantineDriftedApplication = async (database: string) => { + const entry = catalogByName.get(database); + if ( + mode !== "open-approved-collection" || + database === provisioningControlDatabase || + entry === undefined + ) { + fail(); + } + await verifyReconciliationApproval( + run, + target, + policyDigest, + deadline, + dependencies.afterSqlArtifactDescriptorStat, + artifactRoot + ); + await quarantineApplicationDatabase(run, target, entry, deadline); + quarantinedApplicationDatabases.add(database); + }; + + if (mode === "activate-current-catalog" || mode === "open-approved-collection") { + if (mode === "open-approved-collection") { + await verifyReconciliationApproval( + run, + target, + policyDigest, + deadline, + dependencies.afterSqlArtifactDescriptorStat, + artifactRoot + ); + } + await runSqlArtifact( + run, + target, + provisioningControlDatabase, + "apply-database-access-reconciler.sql", + [], + deadline, + dependencies.afterSqlArtifactDescriptorStat, + artifactRoot + ); + } + await runSqlArtifact( + run, + target, + provisioningControlDatabase, + "verify-database-access-reconciler.sql", + [], + deadline, + dependencies.afterSqlArtifactDescriptorStat, + artifactRoot + ); + await runSqlArtifact( + run, + target, + provisioningControlDatabase, + "verify-cluster.sql", + [], + deadline, + dependencies.afterSqlArtifactDescriptorStat, + artifactRoot + ); + const databaseCapabilitiesSql = + mode === "activate-current-catalog" || mode === "open-approved-collection" + ? await expandSqlArtifact( + "apply-database-capabilities.sql", + dependencies.afterSqlArtifactDescriptorStat, + artifactRoot + ) + : null; + for (const database of databaseNames) { + if (databaseCapabilitiesSql !== null) { + if (mode === "open-approved-collection") { + await verifyReconciliationApproval( + run, + target, + policyDigest, + deadline, + dependencies.afterSqlArtifactDescriptorStat, + artifactRoot + ); + } + try { + await runSql( + run, + target, + database, + databaseCapabilitiesSql, + {}, + deadline + ); + } catch (error) { + if ( + mode !== "open-approved-collection" || + database === provisioningControlDatabase + ) { + throw error; + } + await quarantineDriftedApplication(database); + } + } + } + if (mode === "activate-current-catalog" || mode === "open-approved-collection") { + if (mode === "open-approved-collection") { + await verifyReconciliationApproval( + run, + target, + policyDigest, + deadline, + dependencies.afterSqlArtifactDescriptorStat, + artifactRoot + ); + } + await runSqlArtifact( + run, + target, + provisioningControlDatabase, + "apply-control-database.sql", + ["apply_statement_capability=approved"], + deadline, + dependencies.afterSqlArtifactDescriptorStat, + artifactRoot + ); + } + const [verifyControlDatabaseSql, verifyApplicationDatabaseSql] = + await Promise.all([ + expandSqlArtifact( + "verify-control-database.sql", + dependencies.afterSqlArtifactDescriptorStat, + artifactRoot + ), + expandSqlArtifact( + "verify-database.sql", + dependencies.afterSqlArtifactDescriptorStat, + artifactRoot + ), + ]); + for (const database of databaseNames) { + if (quarantinedApplicationDatabases.has(database)) continue; + try { + await runSql( + run, + target, + database, + database === provisioningControlDatabase + ? verifyControlDatabaseSql + : verifyApplicationDatabaseSql, + {}, + deadline + ); + } catch (error) { + if ( + mode !== "open-approved-collection" || + database === provisioningControlDatabase + ) { + throw error; + } + await quarantineDriftedApplication(database); + } + } + await runSqlArtifact( + run, + target, + provisioningControlDatabase, + "verify-control-database-capability.sql", + [], + deadline, + dependencies.afterSqlArtifactDescriptorStat, + artifactRoot + ); + + const confirmedCatalog = parseDatabaseCatalog( + await runSql( + run, + target, + provisioningControlDatabase, + catalogSql, + { + output: "catalog", + }, + deadline + ) + ); + if (JSON.stringify(confirmedCatalog) !== JSON.stringify(initialCatalog)) fail(); + + const confirmedTarget = await discoverComposeTarget(run, deadline); + if (!sameComposeTarget(target, confirmedTarget)) { + fail(); + } + + if ((await calculateProvisioningPolicyDigest(artifactRoot)) !== policyDigest) + fail(); + if (mode === "activate-current-catalog") { + await runSqlArtifact( + run, + target, + provisioningControlDatabase, + "activate-observer.sql", + [ + `current_policy_digest=${policyDigest}`, + `approved_policy_digest=${policyDigest}`, + ], + deadline, + dependencies.afterSqlArtifactDescriptorStat, + artifactRoot + ); + if (!sameComposeTarget(target, await discoverComposeTarget(run, deadline))) { + fail(); + } + } else if (mode === "open-approved-collection") { + await verifyReconciliationApproval( + run, + target, + policyDigest, + deadline, + dependencies.afterSqlArtifactDescriptorStat, + artifactRoot + ); + if (!sameComposeTarget(target, await discoverComposeTarget(run, deadline))) { + fail(); + } + const collectionLeaseToken = ( + dependencies.collectionLeaseTokenFactory ?? randomUUID + )(); + if (!collectionLeaseTokenPattern.test(collectionLeaseToken)) fail(); + await prepareApprovedCollection( + run, + target, + policyDigest, + collectionLeaseToken, + deadline, + dependencies.afterSqlArtifactDescriptorStat, + artifactRoot + ); + return Object.freeze({ + catalogDigest: databaseCatalogDigest(confirmedCatalog), + collectionLeaseToken, + databaseCount: databaseNames.length, + mode, + status: "RECONCILED", + }); + } + let status: DatabaseObservabilityProvisioningResult["status"] = "VERIFIED"; + if (mode === "activate-current-catalog") status = "ACTIVATED"; + return Object.freeze({ + databaseCount: databaseNames.length, + mode, + status, + }); + } catch { + fail(); + } +} + +if (import.meta.main) { + try { + const mode = process.argv[2]; + const standardMode = + mode === "verify-current-catalog" || + mode === "activate-current-catalog" || + mode === "open-approved-collection" || + mode === "close-approved-collection"; + const validStandardInvocation = + standardMode && process.argv.length === 4 && process.argv[3] === "--approved"; + const validEnableInvocation = + mode === "enable-approved-collection" && + process.argv.length === 8 && + process.argv[3] === "--approved" && + process.argv[4] === "--collection-lease-token" && + collectionLeaseTokenPattern.test(process.argv[5] ?? "") && + process.argv[6] === "--catalog-digest" && + catalogDigestPattern.test(process.argv[7] ?? ""); + if (!validStandardInvocation && !validEnableInvocation) { + process.stderr.write(`${usage}\n`); + process.exitCode = 2; + } else { + const dependencies: DatabaseObservabilityProvisioningDependencies = + mode === "enable-approved-collection" + ? { + catalogDigest: process.argv[7], + collectionLeaseToken: process.argv[5], + } + : {}; + const result = await runDatabaseObservabilityProvisioning(mode, dependencies); + process.stdout.write(`${JSON.stringify(result)}\n`); + } + } catch { + process.stderr.write(`${failureMessage}\n`); + process.exitCode = 1; + } +} diff --git a/greenfield/scripts/delivery/provisioning/database-observability/verify-cluster.sql b/greenfield/scripts/delivery/provisioning/database-observability/verify-cluster.sql new file mode 100644 index 000000000..d8f67df24 --- /dev/null +++ b/greenfield/scripts/delivery/provisioning/database-observability/verify-cluster.sql @@ -0,0 +1,218 @@ +\set ON_ERROR_STOP 1 + +DO $verify$ +DECLARE + observer pg_catalog.pg_authid%ROWTYPE; + observer_config text[]; + view_owner pg_catalog.pg_authid%ROWTYPE; + view_owner_config text[]; + capability_owner pg_catalog.pg_authid%ROWTYPE; + capability_owner_config text[]; + direct_memberships text[]; + observer_inbound_membership_count bigint; + owner_membership_count bigint; + capability_owner_inbound_membership_count bigint; + reserved_session_count bigint; +BEGIN + SELECT * INTO observer + FROM pg_catalog.pg_authid + WHERE rolname = 'mira_dashboard_observer'; + SELECT * INTO view_owner + FROM pg_catalog.pg_authid + WHERE rolname = 'mira_dashboard_observability_owner'; + SELECT * INTO capability_owner + FROM pg_catalog.pg_authid + WHERE rolname = 'mira_dashboard_observability_capability_owner'; + SELECT settings.setconfig INTO observer_config + FROM pg_catalog.pg_db_role_setting AS settings + WHERE settings.setrole = observer.oid AND settings.setdatabase = 0; + SELECT settings.setconfig INTO view_owner_config + FROM pg_catalog.pg_db_role_setting AS settings + WHERE settings.setrole = view_owner.oid AND settings.setdatabase = 0; + SELECT settings.setconfig INTO capability_owner_config + FROM pg_catalog.pg_db_role_setting AS settings + WHERE settings.setrole = capability_owner.oid AND settings.setdatabase = 0; + + IF observer.oid IS NULL + OR observer.rolcanlogin + OR NOT observer.rolinherit + OR observer.rolsuper + OR observer.rolcreatedb + OR observer.rolcreaterole + OR observer.rolreplication + OR observer.rolbypassrls + OR observer.rolconnlimit IS DISTINCT FROM 64 + OR observer.rolpassword IS NULL + OR observer.rolpassword NOT LIKE 'SCRAM-SHA-256$%' + OR observer.rolvaliduntil IS DISTINCT FROM + '1970-01-01 00:00:00+00'::timestamp with time zone + OR pg_catalog.shobj_description(observer.oid, 'pg_authid') IS NOT NULL + OR pg_catalog.cardinality(observer_config) IS DISTINCT FROM 4 + OR NOT COALESCE( + observer_config @> ARRAY[ + 'default_transaction_read_only=on', + 'statement_timeout=5s', + 'idle_session_timeout=60s', + 'idle_in_transaction_session_timeout=60s' + ]::text[], + false + ) + THEN + RAISE EXCEPTION 'Disabled database observability observer role is invalid'; + END IF; + IF EXISTS ( + SELECT 1 + FROM pg_catalog.pg_db_role_setting AS settings + WHERE settings.setrole = observer.oid + AND settings.setdatabase <> 0 + ) THEN + RAISE EXCEPTION 'Database observability observer has database-scoped settings'; + END IF; + + IF view_owner.oid IS NULL + OR view_owner.rolcanlogin + OR view_owner.rolinherit + OR view_owner.rolsuper + OR view_owner.rolcreatedb + OR view_owner.rolcreaterole + OR view_owner.rolreplication + OR view_owner.rolbypassrls + OR view_owner.rolpassword IS NOT NULL + OR view_owner_config IS NOT NULL + THEN + RAISE EXCEPTION 'Database observability view owner is invalid'; + END IF; + IF EXISTS ( + SELECT 1 + FROM pg_catalog.pg_db_role_setting AS settings + WHERE settings.setrole = view_owner.oid + AND settings.setdatabase <> 0 + ) THEN + RAISE EXCEPTION 'Database observability view owner has database-scoped settings'; + END IF; + + IF capability_owner.oid IS NULL + OR capability_owner.rolcanlogin + OR NOT capability_owner.rolinherit + OR capability_owner.rolsuper + OR capability_owner.rolcreatedb + OR capability_owner.rolcreaterole + OR capability_owner.rolreplication + OR capability_owner.rolbypassrls + OR capability_owner.rolpassword IS NOT NULL + OR capability_owner_config IS NOT NULL + THEN + RAISE EXCEPTION 'Database observability capability owner is invalid'; + END IF; + IF EXISTS ( + SELECT 1 + FROM pg_catalog.pg_db_role_setting AS settings + WHERE settings.setrole = capability_owner.oid + AND settings.setdatabase <> 0 + ) THEN + RAISE EXCEPTION 'Database observability capability owner has database-scoped settings'; + END IF; + + SELECT COALESCE( + pg_catalog.array_agg(roles.rolname::text ORDER BY roles.rolname), + ARRAY[]::text[] + ) INTO direct_memberships + FROM pg_catalog.pg_auth_members AS memberships + JOIN pg_catalog.pg_roles AS roles ON roles.oid = memberships.roleid + WHERE memberships.member = observer.oid; + IF direct_memberships IS DISTINCT FROM ARRAY[]::text[] THEN + RAISE EXCEPTION 'Database observability observer memberships are invalid'; + END IF; + IF EXISTS ( + SELECT 1 + FROM pg_catalog.pg_auth_members + WHERE member = observer.oid + AND (admin_option OR NOT inherit_option OR NOT set_option) + ) THEN + RAISE EXCEPTION 'Database observability observer membership options are invalid'; + END IF; + + SELECT pg_catalog.count(*) INTO observer_inbound_membership_count + FROM pg_catalog.pg_auth_members + WHERE roleid = observer.oid; + IF observer_inbound_membership_count <> 0 THEN + RAISE EXCEPTION 'Database observability observer has inbound memberships'; + END IF; + + SELECT pg_catalog.count(*) INTO owner_membership_count + FROM pg_catalog.pg_auth_members + WHERE roleid = view_owner.oid OR member = view_owner.oid; + IF owner_membership_count <> 0 THEN + RAISE EXCEPTION 'Database observability view owner memberships are invalid'; + END IF; + + SELECT COALESCE( + pg_catalog.array_agg(roles.rolname::text ORDER BY roles.rolname), + ARRAY[]::text[] + ) INTO direct_memberships + FROM pg_catalog.pg_auth_members AS memberships + JOIN pg_catalog.pg_roles AS roles ON roles.oid = memberships.roleid + WHERE memberships.member = capability_owner.oid; + IF direct_memberships IS DISTINCT FROM ARRAY['pg_read_all_stats']::text[] + OR EXISTS ( + SELECT 1 + FROM pg_catalog.pg_auth_members + WHERE member = capability_owner.oid + AND (admin_option OR NOT inherit_option OR set_option) + ) + THEN + RAISE EXCEPTION 'Database observability capability owner memberships are invalid'; + END IF; + SELECT pg_catalog.count(*) INTO capability_owner_inbound_membership_count + FROM pg_catalog.pg_auth_members + WHERE roleid = capability_owner.oid; + IF capability_owner_inbound_membership_count <> 0 THEN + RAISE EXCEPTION 'Database observability capability owner has inbound memberships'; + END IF; + IF EXISTS ( + SELECT 1 + FROM pg_catalog.pg_shdepend AS dependencies + WHERE dependencies.refclassid = 'pg_catalog.pg_authid'::pg_catalog.regclass + AND dependencies.refobjid = capability_owner.oid + AND dependencies.deptype = 'o' + AND NOT EXISTS ( + SELECT 1 + FROM pg_catalog.pg_database AS databases + WHERE databases.oid = dependencies.dbid + AND NOT databases.datistemplate + AND databases.datallowconn + ) + ) THEN + RAISE EXCEPTION 'Database observability capability owner has out-of-scope ownership'; + END IF; + + SELECT pg_catalog.count(*) INTO reserved_session_count + FROM pg_catalog.pg_stat_activity + WHERE usename IN ( + 'mira_dashboard_observer', + 'mira_dashboard_observability_owner', + 'mira_dashboard_observability_capability_owner' + ) + AND pid <> pg_catalog.pg_backend_pid(); + IF reserved_session_count <> 0 THEN + RAISE EXCEPTION 'Database observability reserved-role session remains active'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM pg_catalog.pg_database + WHERE pg_catalog.has_database_privilege( + observer.oid, + oid, + 'CREATE' + ) + OR pg_catalog.has_database_privilege( + observer.oid, + oid, + 'TEMPORARY' + ) + ) THEN + RAISE EXCEPTION 'Database observability database authority is invalid'; + END IF; +END +$verify$; diff --git a/greenfield/scripts/delivery/provisioning/database-observability/verify-control-database-capability.sql b/greenfield/scripts/delivery/provisioning/database-observability/verify-control-database-capability.sql new file mode 100644 index 000000000..0c2c3512c --- /dev/null +++ b/greenfield/scripts/delivery/provisioning/database-observability/verify-control-database-capability.sql @@ -0,0 +1,88 @@ +\set ON_ERROR_STOP 1 + +DO $verify_control_database_capability$ +DECLARE + database_owner_oid oid; + database_owner_is_superuser boolean; + extension_names text[]; + extension_shapes text[]; +BEGIN + SELECT capability.datdba, owners.rolsuper + INTO database_owner_oid, database_owner_is_superuser + FROM pg_catalog.pg_database AS capability + JOIN pg_catalog.pg_database AS template + ON template.datname = 'template0' + JOIN pg_catalog.pg_roles AS owners ON owners.oid = capability.datdba + WHERE capability.datname = pg_catalog.current_database() + AND capability.datname = 'mira_dashboard_observability' + AND NOT capability.datistemplate + AND capability.datallowconn + AND NOT capability.dathasloginevt + AND capability.datconnlimit = 4 + AND capability.encoding = template.encoding + AND capability.datlocprovider = template.datlocprovider + AND capability.datcollate = template.datcollate + AND capability.datctype = template.datctype + AND capability.datlocale IS NOT DISTINCT FROM template.datlocale + AND capability.daticurules IS NOT DISTINCT FROM template.daticurules + AND capability.dattablespace = template.dattablespace; + + SELECT pg_catalog.array_agg(extensions.extname::text ORDER BY extensions.extname) + INTO extension_names + FROM pg_catalog.pg_extension AS extensions; + + SELECT pg_catalog.array_agg( + extensions.extname::text || ':' || namespaces.nspname::text + ORDER BY extensions.extname + ) + INTO extension_shapes + FROM pg_catalog.pg_extension AS extensions + JOIN pg_catalog.pg_namespace AS namespaces + ON namespaces.oid = extensions.extnamespace; + + IF database_owner_oid IS NULL + OR NOT database_owner_is_superuser + OR extension_names IS DISTINCT FROM ARRAY[ + 'pg_stat_statements', + 'plpgsql' + ]::text[] + OR extension_shapes IS DISTINCT FROM ARRAY[ + 'pg_stat_statements:public', + 'plpgsql:pg_catalog' + ]::text[] + OR EXISTS ( + SELECT 1 + FROM pg_catalog.pg_extension AS extensions + WHERE extensions.extowner IS DISTINCT FROM database_owner_oid + ) + OR EXISTS ( + SELECT 1 + FROM pg_catalog.pg_database AS capability + CROSS JOIN LATERAL pg_catalog.aclexplode(capability.datacl) AS grants + WHERE capability.oid = ( + SELECT oid FROM pg_catalog.pg_database + WHERE datname = pg_catalog.current_database() + ) + AND grants.grantee = 0 + ) + OR EXISTS ( + SELECT 1 + FROM pg_catalog.pg_database AS capability + CROSS JOIN LATERAL pg_catalog.aclexplode(capability.datacl) AS grants + LEFT JOIN pg_catalog.pg_roles AS grantees ON grantees.oid = grants.grantee + WHERE capability.oid = ( + SELECT oid FROM pg_catalog.pg_database + WHERE datname = pg_catalog.current_database() + ) + AND grants.grantee <> 0 + AND grantees.rolname NOT IN ( + CURRENT_USER, + 'mira_dashboard_observer', + 'mira_dashboard_database_access_reconciler' + ) + ) + THEN + RAISE EXCEPTION 'Database observability control database capability is invalid'; + END IF; +END +$verify_control_database_capability$; diff --git a/greenfield/scripts/delivery/provisioning/database-observability/verify-control-database.sql b/greenfield/scripts/delivery/provisioning/database-observability/verify-control-database.sql new file mode 100644 index 000000000..17f47f13a --- /dev/null +++ b/greenfield/scripts/delivery/provisioning/database-observability/verify-control-database.sql @@ -0,0 +1,380 @@ +\set ON_ERROR_STOP 1 + +\ir verify-database-capabilities.sql + +DO $verify_control_capabilities$ +DECLARE + observer_oid oid; + capability_owner_oid oid; + view_owner_oid oid; + database_owner_oid oid; + extension_oid oid; + source_routine pg_catalog.pg_proc%ROWTYPE; + connection_metrics pg_catalog.pg_proc%ROWTYPE; + statement_metrics pg_catalog.pg_proc%ROWTYPE; + extension_schema_oid oid; + member_count bigint; +BEGIN + SELECT roles.oid INTO observer_oid + FROM pg_catalog.pg_roles AS roles + WHERE roles.rolname = 'mira_dashboard_observer'; + SELECT roles.oid INTO capability_owner_oid + FROM pg_catalog.pg_roles AS roles + WHERE roles.rolname = 'mira_dashboard_observability_capability_owner'; + SELECT roles.oid INTO view_owner_oid + FROM pg_catalog.pg_roles AS roles + WHERE roles.rolname = 'mira_dashboard_observability_owner'; + SELECT databases.datdba INTO database_owner_oid + FROM pg_catalog.pg_database AS databases + WHERE databases.datname = pg_catalog.current_database() + AND databases.datname = 'mira_dashboard_observability' + AND NOT databases.datistemplate + AND databases.datallowconn; + SELECT extensions.oid, extensions.extnamespace + INTO extension_oid, extension_schema_oid + FROM pg_catalog.pg_extension AS extensions + JOIN pg_catalog.pg_namespace AS namespaces + ON namespaces.oid = extensions.extnamespace + WHERE extensions.extname = 'pg_stat_statements' + AND namespaces.nspname = 'public' + AND extensions.extowner = database_owner_oid; + + SELECT routines.* INTO source_routine + FROM pg_catalog.pg_proc AS routines + JOIN pg_catalog.pg_depend AS dependencies + ON dependencies.classid = 'pg_catalog.pg_proc'::pg_catalog.regclass + AND dependencies.objid = routines.oid + AND dependencies.objsubid = 0 + AND dependencies.refclassid = + 'pg_catalog.pg_extension'::pg_catalog.regclass + AND dependencies.refobjid = extension_oid + AND dependencies.deptype = 'e' + WHERE routines.pronamespace = extension_schema_oid + AND routines.proname = 'pg_stat_statements' + AND routines.pronargs = 1 + AND routines.proargtypes = + ARRAY['pg_catalog.bool'::pg_catalog.regtype]::oidvector; + SELECT routines.* INTO connection_metrics + FROM pg_catalog.pg_proc AS routines + WHERE routines.oid = + 'mira_dashboard_observability_capabilities.connection_metrics()' + ::pg_catalog.regprocedure; + SELECT routines.* INTO statement_metrics + FROM pg_catalog.pg_proc AS routines + WHERE routines.oid = + 'mira_dashboard_observability_capabilities.statement_metrics()' + ::pg_catalog.regprocedure; + + SELECT pg_catalog.count(*) INTO member_count + FROM pg_catalog.pg_depend AS dependencies + WHERE dependencies.refclassid = + 'pg_catalog.pg_extension'::pg_catalog.regclass + AND dependencies.refobjid = extension_oid + AND dependencies.deptype = 'e' + AND dependencies.objsubid = 0; + + IF observer_oid IS NULL + OR capability_owner_oid IS NULL + OR view_owner_oid IS NULL + OR database_owner_oid IS NULL + OR extension_oid IS NULL + OR member_count NOT BETWEEN 1 AND 64 + OR EXISTS ( + SELECT 1 + FROM pg_catalog.pg_depend AS dependencies + WHERE dependencies.refclassid = + 'pg_catalog.pg_extension'::pg_catalog.regclass + AND dependencies.refobjid = extension_oid + AND dependencies.deptype = 'e' + AND dependencies.objsubid = 0 + AND NOT ( + dependencies.classid = 'pg_catalog.pg_class'::pg_catalog.regclass + AND EXISTS ( + SELECT 1 FROM pg_catalog.pg_class AS classes + JOIN pg_catalog.pg_extension AS extensions + ON extensions.oid = extension_oid + AND classes.relnamespace = extensions.extnamespace + WHERE classes.oid = dependencies.objid + AND classes.relkind = 'v' + AND classes.relowner = database_owner_oid + ) + OR dependencies.classid = 'pg_catalog.pg_proc'::pg_catalog.regclass + AND EXISTS ( + SELECT 1 FROM pg_catalog.pg_proc AS routines + JOIN pg_catalog.pg_language AS languages + ON languages.oid = routines.prolang + WHERE routines.oid = dependencies.objid + AND routines.pronamespace = extension_schema_oid + AND routines.proowner = database_owner_oid + AND routines.prokind = 'f' + AND languages.lanname = 'c' + AND routines.probin = '$libdir/pg_stat_statements' + AND NOT routines.prosecdef + AND NOT routines.proleakproof + ) + OR dependencies.classid = 'pg_catalog.pg_type'::pg_catalog.regclass + AND EXISTS ( + SELECT 1 FROM pg_catalog.pg_type AS types + WHERE types.oid = dependencies.objid + AND types.typnamespace = extension_schema_oid + AND types.typowner = database_owner_oid + AND ( + types.typtype = 'c' + AND EXISTS ( + SELECT 1 FROM pg_catalog.pg_class AS classes + WHERE classes.oid = types.typrelid + AND classes.relnamespace = extension_schema_oid + AND classes.relkind = 'v' + ) + OR types.typtype = 'b' + AND types.typcategory = 'A' + AND EXISTS ( + SELECT 1 FROM pg_catalog.pg_type AS elements + JOIN pg_catalog.pg_depend AS element_dependencies + ON element_dependencies.classid = + 'pg_catalog.pg_type'::pg_catalog.regclass + AND element_dependencies.objid = elements.oid + AND element_dependencies.objsubid = 0 + AND element_dependencies.refclassid = + 'pg_catalog.pg_extension'::pg_catalog.regclass + AND element_dependencies.refobjid = extension_oid + AND element_dependencies.deptype = 'e' + WHERE elements.oid = types.typelem + AND elements.typtype = 'c' + ) + ) + ) + ) + ) + OR source_routine.oid IS NULL + OR source_routine.proowner IS DISTINCT FROM database_owner_oid + OR source_routine.prolang IS DISTINCT FROM ( + SELECT languages.oid FROM pg_catalog.pg_language AS languages + WHERE languages.lanname = 'c' + ) + OR source_routine.probin <> '$libdir/pg_stat_statements' + OR source_routine.prosecdef + OR source_routine.proleakproof + OR source_routine.pronargs <> 1 + OR source_routine.proargtypes <> + ARRAY['pg_catalog.bool'::pg_catalog.regtype]::oidvector + OR source_routine.prorettype <> 'pg_catalog.record'::pg_catalog.regtype + OR NOT source_routine.proretset + OR EXISTS ( + SELECT 1 + FROM (VALUES + ('dbid'::text, 'pg_catalog.oid'::pg_catalog.regtype), + ('userid'::text, 'pg_catalog.oid'::pg_catalog.regtype), + ('queryid'::text, 'pg_catalog.int8'::pg_catalog.regtype), + ('calls'::text, 'pg_catalog.int8'::pg_catalog.regtype), + ('total_exec_time'::text, 'pg_catalog.float8'::pg_catalog.regtype), + ('mean_exec_time'::text, 'pg_catalog.float8'::pg_catalog.regtype), + ('rows'::text, 'pg_catalog.int8'::pg_catalog.regtype), + ('shared_blks_hit'::text, 'pg_catalog.int8'::pg_catalog.regtype), + ('shared_blks_read'::text, 'pg_catalog.int8'::pg_catalog.regtype) + ) AS required(field_name, field_type) + WHERE ( + SELECT pg_catalog.count(*) + FROM pg_catalog.generate_subscripts( + source_routine.proallargtypes, + 1 + ) AS positions(position) + WHERE source_routine.proargnames[positions.position] = required.field_name + AND source_routine.proargmodes[positions.position] = 'o' + AND source_routine.proallargtypes[positions.position] = required.field_type + ) <> 1 + ) + THEN + RAISE EXCEPTION 'pg_stat_statements reviewed source shape is invalid'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM pg_catalog.pg_class AS source_relations + JOIN pg_catalog.pg_depend AS dependencies + ON dependencies.classid = 'pg_catalog.pg_class'::pg_catalog.regclass + AND dependencies.objid = source_relations.oid + AND dependencies.objsubid = 0 + AND dependencies.refclassid = + 'pg_catalog.pg_extension'::pg_catalog.regclass + AND dependencies.refobjid = extension_oid + AND dependencies.deptype = 'e' + WHERE pg_catalog.has_table_privilege( + observer_oid, + source_relations.oid, + 'SELECT,INSERT,UPDATE,DELETE,TRUNCATE,REFERENCES,TRIGGER,MAINTAIN' + ) + OR pg_catalog.has_table_privilege( + capability_owner_oid, + source_relations.oid, + 'SELECT,INSERT,UPDATE,DELETE,TRUNCATE,REFERENCES,TRIGGER,MAINTAIN' + ) + OR pg_catalog.has_table_privilege( + view_owner_oid, + source_relations.oid, + 'SELECT,INSERT,UPDATE,DELETE,TRUNCATE,REFERENCES,TRIGGER,MAINTAIN' + ) + OR pg_catalog.has_table_privilege( + 'pg_read_all_stats', + source_relations.oid, + 'SELECT,INSERT,UPDATE,DELETE,TRUNCATE,REFERENCES,TRIGGER,MAINTAIN' + ) + OR EXISTS ( + SELECT 1 + FROM pg_catalog.aclexplode(COALESCE( + source_relations.relacl, + pg_catalog.acldefault( + 'r', + source_relations.relowner + ) + )) AS grants + WHERE grants.grantee = 0 + ) + ) + OR NOT pg_catalog.has_function_privilege( + capability_owner_oid, + source_routine.oid, + 'EXECUTE' + ) + OR pg_catalog.has_function_privilege(observer_oid, source_routine.oid, 'EXECUTE') + OR pg_catalog.has_function_privilege(view_owner_oid, source_routine.oid, 'EXECUTE') + OR pg_catalog.has_function_privilege( + 'pg_read_all_stats', + source_routine.oid, + 'EXECUTE' + ) + OR EXISTS ( + SELECT 1 FROM pg_catalog.aclexplode(COALESCE( + source_routine.proacl, + pg_catalog.acldefault('f', source_routine.proowner) + )) AS grants + WHERE grants.grantee = 0 AND grants.privilege_type = 'EXECUTE' + ) + OR ( + SELECT pg_catalog.count(*) + FROM pg_catalog.aclexplode(source_routine.proacl) AS grants + ) <> 2 + OR EXISTS ( + SELECT 1 + FROM pg_catalog.aclexplode(source_routine.proacl) AS grants + WHERE grants.grantor <> database_owner_oid + OR grants.is_grantable + OR grants.privilege_type <> 'EXECUTE' + OR grants.grantee NOT IN (database_owner_oid, capability_owner_oid) + ) + OR EXISTS ( + SELECT 1 + FROM pg_catalog.pg_proc AS routines + JOIN pg_catalog.pg_depend AS dependencies + ON dependencies.classid = 'pg_catalog.pg_proc'::pg_catalog.regclass + AND dependencies.objid = routines.oid + AND dependencies.objsubid = 0 + AND dependencies.refclassid = + 'pg_catalog.pg_extension'::pg_catalog.regclass + AND dependencies.refobjid = extension_oid + AND dependencies.deptype = 'e' + WHERE routines.oid <> source_routine.oid + AND ( + pg_catalog.has_function_privilege( + observer_oid, + routines.oid, + 'EXECUTE' + ) + OR pg_catalog.has_function_privilege( + capability_owner_oid, + routines.oid, + 'EXECUTE' + ) + OR pg_catalog.has_function_privilege( + view_owner_oid, + routines.oid, + 'EXECUTE' + ) + OR pg_catalog.has_function_privilege( + 'pg_read_all_stats', + routines.oid, + 'EXECUTE' + ) + OR EXISTS ( + SELECT 1 FROM pg_catalog.aclexplode(COALESCE( + routines.proacl, + pg_catalog.acldefault('f', routines.proowner) + )) AS grants + WHERE grants.grantee = 0 + AND grants.privilege_type = 'EXECUTE' + ) + ) + ) + THEN + RAISE EXCEPTION 'pg_stat_statements source ACL is invalid'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM (VALUES + ( + connection_metrics.oid, + 1::real, + 'TABLE(active_connections bigint, idle_connections bigint, total_connections bigint)', + 'e7dd5805171b451837fda6aefad1f8f71e3ada90424d296fc4aed746005ab638' + ), + ( + statement_metrics.oid, + 20::real, + 'TABLE(calls bigint, total_execution_ms double precision, mean_execution_ms double precision, rows bigint, shared_blocks_hit bigint, shared_blocks_read bigint)', + 'e96e15f965236535b5d8901c5fea3422c663f6c7858517b840dab82d66910e9e' + ) + ) AS expected(oid, rows, result_shape, body_hash) + JOIN pg_catalog.pg_proc AS routines ON routines.oid = expected.oid + WHERE routines.proowner <> capability_owner_oid + OR routines.prolang <> ( + SELECT languages.oid FROM pg_catalog.pg_language AS languages + WHERE languages.lanname = 'sql' + ) + OR routines.prokind <> 'f' + OR routines.pronargs <> 0 + OR routines.prorettype <> 'pg_catalog.record'::pg_catalog.regtype + OR NOT routines.proretset + OR routines.provolatile <> 'v' + OR routines.proparallel <> 'u' + OR NOT routines.prosecdef + OR routines.proleakproof + OR routines.proisstrict + OR routines.prosrc <> '' + OR pg_catalog.pg_get_function_sqlbody(routines.oid) IS NULL + OR routines.prorows <> expected.rows + OR routines.proconfig <> ARRAY[ + 'search_path=pg_catalog, pg_temp', + 'statement_timeout=5s' + ]::text[] + OR pg_catalog.pg_get_function_result(routines.oid) <> expected.result_shape + OR pg_catalog.encode(pg_catalog.sha256(pg_catalog.convert_to( + pg_catalog.pg_get_function_sqlbody(routines.oid), + 'UTF8' + )), 'hex') <> expected.body_hash + OR ( + SELECT pg_catalog.count(*) + FROM pg_catalog.aclexplode(routines.proacl) AS grants + ) <> 2 + OR EXISTS ( + SELECT 1 FROM pg_catalog.aclexplode(routines.proacl) AS grants + WHERE grants.grantor <> capability_owner_oid + OR grants.is_grantable + OR grants.privilege_type <> 'EXECUTE' + OR grants.grantee NOT IN (capability_owner_oid, observer_oid) + ) + ) + OR ( + SELECT pg_catalog.count(*) + FROM pg_catalog.pg_depend AS dependencies + WHERE dependencies.classid = 'pg_catalog.pg_proc'::pg_catalog.regclass + AND dependencies.objid = statement_metrics.oid + AND dependencies.refclassid = 'pg_catalog.pg_proc'::pg_catalog.regclass + AND dependencies.refobjid = source_routine.oid + AND dependencies.deptype = 'n' + ) <> 1 + THEN + RAISE EXCEPTION 'Database observability control capability is invalid'; + END IF; +END +$verify_control_capabilities$; diff --git a/greenfield/scripts/delivery/provisioning/database-observability/verify-database-access-reconciler.sql b/greenfield/scripts/delivery/provisioning/database-observability/verify-database-access-reconciler.sql new file mode 100644 index 000000000..65b980e49 --- /dev/null +++ b/greenfield/scripts/delivery/provisioning/database-observability/verify-database-access-reconciler.sql @@ -0,0 +1,122 @@ +\set ON_ERROR_STOP 1 + +DO $verify_database_access_reconciler$ +DECLARE + administrator_oid oid; + observer_oid oid; + schema_oid oid; + routine pg_catalog.pg_proc%ROWTYPE; +BEGIN + SELECT databases.datdba INTO administrator_oid + FROM pg_catalog.pg_database AS databases + JOIN pg_catalog.pg_roles AS owners ON owners.oid = databases.datdba + WHERE databases.datname = pg_catalog.current_database() + AND databases.datname = 'mira_dashboard_observability' + AND owners.rolsuper; + SELECT roles.oid INTO observer_oid + FROM pg_catalog.pg_roles AS roles + WHERE roles.rolname = 'mira_dashboard_observer'; + SELECT namespaces.oid INTO schema_oid + FROM pg_catalog.pg_namespace AS namespaces + WHERE namespaces.nspname = 'mira_dashboard_database_access'; + SELECT routines.* INTO routine + FROM pg_catalog.pg_proc AS routines + WHERE routines.pronamespace = schema_oid + AND routines.proname = 'reconcile' + AND routines.pronargs = 0; + + IF administrator_oid IS NULL + OR observer_oid IS NULL + OR EXISTS ( + SELECT 1 FROM pg_catalog.pg_roles AS roles + WHERE roles.rolname = 'mira_dashboard_database_access_reconciler' + ) + OR schema_oid IS NULL + OR (SELECT namespaces.nspowner FROM pg_catalog.pg_namespace AS namespaces + WHERE namespaces.oid = schema_oid) <> administrator_oid + OR EXISTS (SELECT 1 FROM pg_catalog.pg_class AS classes + WHERE classes.relnamespace = schema_oid) + OR EXISTS (SELECT 1 FROM pg_catalog.pg_type AS types + WHERE types.typnamespace = schema_oid) + OR (SELECT pg_catalog.count(*) FROM pg_catalog.pg_proc AS routines + WHERE routines.pronamespace = schema_oid) <> 1 + OR (SELECT pg_catalog.count(*) FROM pg_catalog.aclexplode( + (SELECT namespaces.nspacl FROM pg_catalog.pg_namespace AS namespaces + WHERE namespaces.oid = schema_oid) + ) AS grants) <> 2 + OR EXISTS ( + SELECT 1 FROM pg_catalog.aclexplode( + (SELECT namespaces.nspacl FROM pg_catalog.pg_namespace AS namespaces + WHERE namespaces.oid = schema_oid) + ) AS grants + WHERE grants.grantor <> administrator_oid + OR grants.grantee <> administrator_oid + OR grants.is_grantable + OR grants.privilege_type NOT IN ('CREATE', 'USAGE') + ) + THEN + RAISE EXCEPTION 'Database access reconciler schema is invalid'; + END IF; + + IF routine.oid IS NULL + OR routine.proowner <> administrator_oid + OR routine.prolang <> ( + SELECT languages.oid FROM pg_catalog.pg_language AS languages + WHERE languages.lanname = 'plpgsql' + ) + OR routine.prorettype <> 'pg_catalog.int4'::pg_catalog.regtype + OR routine.prokind <> 'f' + OR routine.pronargs <> 0 + OR routine.provolatile <> 'v' + OR routine.proparallel <> 'u' + OR NOT routine.prosecdef + OR routine.proleakproof + OR routine.proisstrict + OR routine.proconfig IS DISTINCT FROM ARRAY[ + 'search_path=pg_catalog, pg_temp', + 'lock_timeout=2s', + 'statement_timeout=30s' + ]::text[] + OR pg_catalog.encode(pg_catalog.sha256(pg_catalog.convert_to( + routine.prosrc, + 'UTF8' + )), 'hex') <> '137027eb01b6e0edd71d6640ab18ddb3a8534619cb6a1e0a1550dd1eff932f17' + OR (SELECT pg_catalog.count(*) FROM pg_catalog.aclexplode(routine.proacl)) <> 1 + OR EXISTS ( + SELECT 1 FROM pg_catalog.aclexplode(routine.proacl) AS grants + WHERE grants.grantor <> administrator_oid + OR grants.grantee <> administrator_oid + OR grants.privilege_type <> 'EXECUTE' + OR grants.is_grantable + ) + THEN + RAISE EXCEPTION 'Database access reconciler function is invalid'; + END IF; + + IF (SELECT pg_catalog.count(*) FROM pg_catalog.pg_database) > 80 + OR (SELECT pg_catalog.count(*) FROM pg_catalog.pg_database AS databases + WHERE NOT databases.datistemplate AND databases.datallowconn) > 64 + OR EXISTS ( + SELECT 1 + FROM pg_catalog.pg_database AS databases + WHERE (NOT databases.datistemplate AND databases.datallowconn) + IS DISTINCT FROM pg_catalog.has_database_privilege( + observer_oid, + databases.oid, + 'CONNECT' + ) + OR pg_catalog.has_database_privilege( + observer_oid, + databases.oid, + 'CREATE,TEMPORARY' + ) + OR EXISTS ( + SELECT 1 FROM pg_catalog.aclexplode(databases.datacl) AS grants + WHERE grants.grantee = 0 + ) + ) + THEN + RAISE EXCEPTION 'Database access reconciler database ACL is invalid'; + END IF; +END +$verify_database_access_reconciler$; diff --git a/greenfield/scripts/delivery/provisioning/database-observability/verify-database-capabilities.sql b/greenfield/scripts/delivery/provisioning/database-observability/verify-database-capabilities.sql new file mode 100644 index 000000000..ca15d23bd --- /dev/null +++ b/greenfield/scripts/delivery/provisioning/database-observability/verify-database-capabilities.sql @@ -0,0 +1,363 @@ +\set ON_ERROR_STOP 1 + +DO $verify_database_capabilities$ +DECLARE + observer_oid oid; + capability_owner_oid oid; + view_owner_oid oid; + capability_schema_oid oid; + table_health pg_catalog.pg_proc%ROWTYPE; + maintenance_metrics pg_catalog.pg_proc%ROWTYPE; +BEGIN + SELECT roles.oid INTO observer_oid + FROM pg_catalog.pg_roles AS roles + WHERE roles.rolname = 'mira_dashboard_observer'; + SELECT roles.oid INTO capability_owner_oid + FROM pg_catalog.pg_roles AS roles + WHERE roles.rolname = 'mira_dashboard_observability_capability_owner'; + SELECT roles.oid INTO view_owner_oid + FROM pg_catalog.pg_roles AS roles + WHERE roles.rolname = 'mira_dashboard_observability_owner'; + SELECT namespaces.oid INTO capability_schema_oid + FROM pg_catalog.pg_namespace AS namespaces + WHERE namespaces.nspname = 'mira_dashboard_observability_capabilities'; + + SELECT routines.* INTO table_health + FROM pg_catalog.pg_proc AS routines + WHERE routines.pronamespace = capability_schema_oid + AND routines.proname = 'table_health' + AND routines.pronargs = 0; + SELECT routines.* INTO maintenance_metrics + FROM pg_catalog.pg_proc AS routines + WHERE routines.pronamespace = capability_schema_oid + AND routines.proname = 'maintenance_metrics' + AND routines.pronargs = 0; + + IF observer_oid IS NULL + OR capability_owner_oid IS NULL + OR view_owner_oid IS NULL + OR capability_schema_oid IS NULL + OR (SELECT namespaces.nspowner + FROM pg_catalog.pg_namespace AS namespaces + WHERE namespaces.oid = capability_schema_oid) + IS DISTINCT FROM view_owner_oid + OR EXISTS ( + SELECT 1 + FROM pg_catalog.pg_class AS classes + WHERE classes.relnamespace = capability_schema_oid + ) + OR EXISTS ( + SELECT 1 + FROM pg_catalog.pg_type AS types + WHERE types.typnamespace = capability_schema_oid + ) + OR NOT ( + (SELECT pg_catalog.count(*) + FROM pg_catalog.pg_proc AS routines + WHERE routines.pronamespace = capability_schema_oid) = CASE + WHEN pg_catalog.current_database() = 'mira_dashboard_observability' + THEN 4 + ELSE 2 + END + ) + OR EXISTS ( + SELECT 1 + FROM pg_catalog.pg_proc AS routines + WHERE routines.pronamespace = capability_schema_oid + AND routines.proname NOT IN ('table_health', 'maintenance_metrics') + AND NOT ( + pg_catalog.current_database() = 'mira_dashboard_observability' + AND routines.proname IN ('connection_metrics', 'statement_metrics') + AND routines.pronargs = 0 + ) + ) + THEN + RAISE EXCEPTION 'Database observability capability schema is invalid'; + END IF; + + IF ( + SELECT pg_catalog.count(*) + FROM pg_catalog.aclexplode( + (SELECT namespaces.nspacl + FROM pg_catalog.pg_namespace AS namespaces + WHERE namespaces.oid = capability_schema_oid) + ) AS grants + ) IS DISTINCT FROM 4 + OR EXISTS ( + SELECT 1 + FROM pg_catalog.aclexplode( + (SELECT namespaces.nspacl + FROM pg_catalog.pg_namespace AS namespaces + WHERE namespaces.oid = capability_schema_oid) + ) AS grants + WHERE grants.grantor IS DISTINCT FROM view_owner_oid + OR grants.is_grantable + OR NOT ( + grants.grantee = view_owner_oid + AND grants.privilege_type IN ('CREATE', 'USAGE') + OR grants.grantee = capability_owner_oid + AND grants.privilege_type = 'USAGE' + OR grants.grantee = observer_oid + AND grants.privilege_type = 'USAGE' + ) + ) + THEN + RAISE EXCEPTION 'Database observability capability schema ACL is invalid'; + END IF; + + IF table_health.oid IS NULL + OR table_health.proowner IS DISTINCT FROM capability_owner_oid + OR table_health.prolang IS DISTINCT FROM ( + SELECT languages.oid + FROM pg_catalog.pg_language AS languages + WHERE languages.lanname = 'sql' + ) + OR table_health.prorettype IS DISTINCT FROM + 'pg_catalog.record'::pg_catalog.regtype + OR table_health.prokind IS DISTINCT FROM 'f' + OR table_health.provolatile IS DISTINCT FROM 'v' + OR table_health.proparallel IS DISTINCT FROM 'u' + OR NOT table_health.prosecdef + OR table_health.proleakproof + OR table_health.proisstrict + OR table_health.prosrc <> '' + OR pg_catalog.pg_get_function_sqlbody(table_health.oid) IS NULL + OR table_health.prorows IS DISTINCT FROM 25::real + OR table_health.proconfig IS DISTINCT FROM ARRAY[ + 'search_path=pg_catalog, pg_temp', + 'statement_timeout=5s' + ]::text[] + OR table_health.proargmodes IS DISTINCT FROM + ARRAY['t','t','t','t','t','t','t','t','t','t']::"char"[] + OR table_health.proargnames IS DISTINCT FROM ARRAY[ + 'schema_name', + 'table_name', + 'physical_bytes', + 'live_tuples', + 'dead_tuples', + 'last_autovacuum_at_ms', + 'last_autoanalyze_at_ms', + 'dead_tuple_percent', + 'assessed', + 'estimated_reclaimable_bytes' + ]::text[] + OR table_health.proallargtypes IS DISTINCT FROM ARRAY[ + 'pg_catalog.name'::pg_catalog.regtype, + 'pg_catalog.name'::pg_catalog.regtype, + 'pg_catalog.int8'::pg_catalog.regtype, + 'pg_catalog.int8'::pg_catalog.regtype, + 'pg_catalog.int8'::pg_catalog.regtype, + 'pg_catalog.int8'::pg_catalog.regtype, + 'pg_catalog.int8'::pg_catalog.regtype, + 'pg_catalog.numeric'::pg_catalog.regtype, + 'pg_catalog.bool'::pg_catalog.regtype, + 'pg_catalog.int8'::pg_catalog.regtype + ]::oid[] + OR pg_catalog.encode( + pg_catalog.sha256(pg_catalog.convert_to( + pg_catalog.pg_get_function_sqlbody(table_health.oid), + 'UTF8' + )), + 'hex' + ) IS DISTINCT FROM + '391b9e5325dd42c9f4a319b44dd8ddae0fb88a0cd5276540ab5d65d53ace5606' + OR maintenance_metrics.oid IS NULL + OR maintenance_metrics.proowner IS DISTINCT FROM capability_owner_oid + OR maintenance_metrics.prolang IS DISTINCT FROM table_health.prolang + OR maintenance_metrics.prorettype IS DISTINCT FROM + 'pg_catalog.record'::pg_catalog.regtype + OR maintenance_metrics.prokind IS DISTINCT FROM 'f' + OR maintenance_metrics.provolatile IS DISTINCT FROM 'v' + OR maintenance_metrics.proparallel IS DISTINCT FROM 'u' + OR NOT maintenance_metrics.prosecdef + OR maintenance_metrics.proleakproof + OR maintenance_metrics.proisstrict + OR maintenance_metrics.prosrc <> '' + OR pg_catalog.pg_get_function_sqlbody(maintenance_metrics.oid) IS NULL + OR maintenance_metrics.prorows IS DISTINCT FROM 1::real + OR maintenance_metrics.proconfig IS DISTINCT FROM ARRAY[ + 'search_path=pg_catalog, pg_temp', + 'statement_timeout=5s' + ]::text[] + OR maintenance_metrics.proargmodes IS DISTINCT FROM + ARRAY['t','t','t','t','t']::"char"[] + OR maintenance_metrics.proargnames IS DISTINCT FROM ARRAY[ + 'assessed_physical_bytes', + 'estimated_reclaimable_bytes', + 'high_dead_tuple_table_count', + 'unassessed_physical_bytes', + 'unassessed_table_count' + ]::text[] + OR maintenance_metrics.proallargtypes IS DISTINCT FROM ARRAY[ + 'pg_catalog.int8'::pg_catalog.regtype, + 'pg_catalog.int8'::pg_catalog.regtype, + 'pg_catalog.int8'::pg_catalog.regtype, + 'pg_catalog.int8'::pg_catalog.regtype, + 'pg_catalog.int8'::pg_catalog.regtype + ]::oid[] + OR pg_catalog.encode( + pg_catalog.sha256( + pg_catalog.convert_to( + pg_catalog.pg_get_function_sqlbody(maintenance_metrics.oid), + 'UTF8' + ) + ), + 'hex' + ) IS DISTINCT FROM + '617ddca7f3f255858cf01b3ec1c07cf2fa37a5d5ba4e21fc52e2ce451f473c0a' + THEN + RAISE EXCEPTION 'Database observability maintenance capabilities are invalid'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM pg_catalog.unnest( + ARRAY[table_health.oid, maintenance_metrics.oid] + ) AS admitted_routines(oid) + WHERE ( + SELECT pg_catalog.count(*) + FROM pg_catalog.aclexplode( + (SELECT routines.proacl + FROM pg_catalog.pg_proc AS routines + WHERE routines.oid = admitted_routines.oid) + ) AS grants + ) IS DISTINCT FROM 2 + OR EXISTS ( + SELECT 1 + FROM pg_catalog.aclexplode( + (SELECT routines.proacl + FROM pg_catalog.pg_proc AS routines + WHERE routines.oid = admitted_routines.oid) + ) AS grants + WHERE grants.grantor IS DISTINCT FROM capability_owner_oid + OR grants.is_grantable + OR grants.privilege_type <> 'EXECUTE' + OR grants.grantee NOT IN (capability_owner_oid, observer_oid) + ) + ) + OR NOT pg_catalog.has_function_privilege( + observer_oid, + table_health.oid, + 'EXECUTE' + ) + OR NOT pg_catalog.has_function_privilege( + observer_oid, + maintenance_metrics.oid, + 'EXECUTE' + ) + THEN + RAISE EXCEPTION 'Database observability maintenance capability ACL is invalid'; + END IF; + + IF NOT pg_catalog.has_table_privilege( + capability_owner_oid, + 'pg_catalog.pg_statistic'::pg_catalog.regclass, + 'SELECT' + ) + OR pg_catalog.has_table_privilege( + observer_oid, + 'pg_catalog.pg_statistic'::pg_catalog.regclass, + 'SELECT' + ) + OR EXISTS ( + SELECT 1 + FROM pg_catalog.aclexplode( + (SELECT classes.relacl + FROM pg_catalog.pg_class AS classes + WHERE classes.oid = 'pg_catalog.pg_statistic'::pg_catalog.regclass) + ) AS grants + WHERE grants.grantee = capability_owner_oid + AND ( + grants.privilege_type <> 'SELECT' + OR grants.is_grantable + ) + ) + OR NOT EXISTS ( + SELECT 1 + FROM pg_catalog.aclexplode( + (SELECT classes.relacl + FROM pg_catalog.pg_class AS classes + WHERE classes.oid = 'pg_catalog.pg_statistic'::pg_catalog.regclass) + ) AS grants + WHERE grants.grantee = capability_owner_oid + AND grants.privilege_type = 'SELECT' + AND NOT grants.is_grantable + ) + OR EXISTS ( + SELECT 1 + FROM pg_catalog.pg_default_acl AS defaults + CROSS JOIN LATERAL pg_catalog.aclexplode(defaults.defaclacl) AS grants + WHERE defaults.defaclrole = capability_owner_oid + AND ( + defaults.defaclnamespace <> 0 + OR defaults.defaclobjtype <> 'f' + OR grants.grantee <> capability_owner_oid + OR grants.privilege_type <> 'EXECUTE' + OR grants.is_grantable + ) + ) + OR NOT EXISTS ( + SELECT 1 + FROM pg_catalog.pg_default_acl AS defaults + CROSS JOIN LATERAL pg_catalog.aclexplode(defaults.defaclacl) AS grants + WHERE defaults.defaclrole = capability_owner_oid + AND defaults.defaclnamespace = 0 + AND defaults.defaclobjtype = 'f' + AND grants.grantee = capability_owner_oid + AND grants.privilege_type = 'EXECUTE' + AND NOT grants.is_grantable + ) + OR EXISTS ( + SELECT 1 + FROM pg_catalog.pg_shdepend AS dependencies + WHERE dependencies.refclassid = 'pg_catalog.pg_authid'::pg_catalog.regclass + AND dependencies.refobjid = capability_owner_oid + AND dependencies.deptype = 'o' + AND dependencies.dbid = ( + SELECT databases.oid + FROM pg_catalog.pg_database AS databases + WHERE databases.datname = pg_catalog.current_database() + ) + AND NOT ( + dependencies.dbid = ( + SELECT databases.oid + FROM pg_catalog.pg_database AS databases + WHERE databases.datname = pg_catalog.current_database() + ) + AND ( + dependencies.classid = 'pg_catalog.pg_proc'::pg_catalog.regclass + AND ( + dependencies.objid IN ( + table_health.oid, + maintenance_metrics.oid + ) + OR pg_catalog.current_database() = + 'mira_dashboard_observability' + AND dependencies.objid IN ( + SELECT routines.oid + FROM pg_catalog.pg_proc AS routines + WHERE routines.pronamespace = capability_schema_oid + AND routines.pronargs = 0 + AND routines.proname IN ( + 'connection_metrics', + 'statement_metrics' + ) + ) + ) + OR dependencies.classid = + 'pg_catalog.pg_default_acl'::pg_catalog.regclass + AND dependencies.objid = ( + SELECT defaults.oid + FROM pg_catalog.pg_default_acl AS defaults + WHERE defaults.defaclrole = capability_owner_oid + AND defaults.defaclnamespace = 0 + AND defaults.defaclobjtype = 'f' + ) + ) + ) + ) + THEN + RAISE EXCEPTION 'Database observability capability owner authority is invalid'; + END IF; +END +$verify_database_capabilities$; diff --git a/greenfield/scripts/delivery/provisioning/database-observability/verify-database.sql b/greenfield/scripts/delivery/provisioning/database-observability/verify-database.sql new file mode 100644 index 000000000..48c1bbaca --- /dev/null +++ b/greenfield/scripts/delivery/provisioning/database-observability/verify-database.sql @@ -0,0 +1,186 @@ +\set ON_ERROR_STOP 1 + +\ir verify-database-capabilities.sql + +-- Generic, name-independent verification for the current catalog database. +DO $verify$ +DECLARE + observer_oid oid; + allowed_relation_oids oid[] := ARRAY[]::oid[]; + allowed_routine_oids oid[] := ARRAY[]::oid[]; +BEGIN + SELECT oid INTO observer_oid + FROM pg_catalog.pg_roles + WHERE rolname = 'mira_dashboard_observer'; + + SELECT pg_catalog.array_agg(routines.oid ORDER BY routines.proname) + INTO allowed_routine_oids + FROM pg_catalog.pg_proc AS routines + JOIN pg_catalog.pg_namespace AS namespaces + ON namespaces.oid = routines.pronamespace + WHERE namespaces.nspname = 'mira_dashboard_observability_capabilities' + AND routines.pronargs = 0 + AND routines.proname IN ( + 'table_health', + 'maintenance_metrics', + 'connection_metrics', + 'statement_metrics' + ); + + IF observer_oid IS NULL + OR NOT EXISTS ( + SELECT 1 + FROM pg_catalog.pg_database + WHERE datname = pg_catalog.current_database() + AND NOT datistemplate + AND datallowconn + ) + OR NOT pg_catalog.has_database_privilege( + observer_oid, + pg_catalog.current_database(), + 'CONNECT' + ) + OR pg_catalog.has_database_privilege( + observer_oid, + pg_catalog.current_database(), + 'CREATE,TEMPORARY' + ) + THEN + RAISE EXCEPTION 'Database observability database authority is invalid'; + END IF; + + -- A role- or database-scoped setting can override the reviewed global + -- read-only/timeout defaults. Cluster verification refuses all such rows. + IF EXISTS ( + SELECT 1 + FROM pg_catalog.pg_db_role_setting AS settings + WHERE settings.setrole = observer_oid + AND settings.setdatabase <> 0 + ) THEN + RAISE EXCEPTION 'Database observability observer has database-scoped settings'; + END IF; + + -- Default ACLs are future authority. Refuse both defaults owned by the + -- observer and direct future grants to it, even when no current object leaks. + IF EXISTS ( + SELECT 1 + FROM pg_catalog.pg_default_acl AS defaults + LEFT JOIN LATERAL pg_catalog.aclexplode(defaults.defaclacl) AS grants + ON true + WHERE defaults.defaclrole = observer_oid + OR grants.grantee = observer_oid + OR ( + grants.grantee <> 0 + AND pg_catalog.pg_has_role(observer_oid, grants.grantee, 'USAGE') + ) + ) THEN + RAISE EXCEPTION 'Database observability observer default ACL is invalid'; + END IF; + + -- Explicit grants to the observer or one of its inherited roles are never + -- permitted on user-schema routines. Ordinary PUBLIC invoker routines remain + -- usable; only effective SECURITY DEFINER execution is rejected wholesale. + IF EXISTS ( + SELECT 1 + FROM pg_catalog.pg_proc AS routines + JOIN pg_catalog.pg_namespace AS namespaces + ON namespaces.oid = routines.pronamespace + WHERE namespaces.nspname NOT IN ('information_schema', 'pg_catalog') + AND namespaces.nspname NOT LIKE 'pg_toast%' + AND namespaces.nspname NOT LIKE 'pg_temp_%' + AND NOT (routines.oid = ANY(allowed_routine_oids)) + AND ( + pg_catalog.pg_has_role(observer_oid, routines.proowner, 'USAGE') + OR EXISTS ( + SELECT 1 + FROM pg_catalog.aclexplode(routines.proacl) AS grants + WHERE grants.grantee = observer_oid + OR ( + grants.grantee <> 0 + AND pg_catalog.pg_has_role( + observer_oid, + grants.grantee, + 'USAGE' + ) + ) + ) + ) + ) THEN + RAISE EXCEPTION 'Database observability routine grants are invalid'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM pg_catalog.pg_proc AS routines + JOIN pg_catalog.pg_namespace AS namespaces + ON namespaces.oid = routines.pronamespace + WHERE namespaces.nspname NOT IN ('information_schema', 'pg_catalog') + AND namespaces.nspname NOT LIKE 'pg_toast%' + AND namespaces.nspname NOT LIKE 'pg_temp_%' + AND NOT (routines.oid = ANY(allowed_routine_oids)) + AND routines.prosecdef + AND pg_catalog.has_function_privilege( + observer_oid, + routines.oid, + 'EXECUTE' + ) + ) THEN + RAISE EXCEPTION 'Database observability SECURITY DEFINER authority is invalid'; + END IF; + + IF pg_catalog.current_database() IN ('bitmagnet', 'comet') THEN + SELECT ARRAY[classes.oid] INTO allowed_relation_oids + FROM pg_catalog.pg_class AS classes + JOIN pg_catalog.pg_namespace AS namespaces + ON namespaces.oid = classes.relnamespace + WHERE namespaces.nspname = 'mira_dashboard_observability' + AND classes.relname = 'torrent_count' + AND classes.relkind = 'v'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM pg_catalog.pg_namespace AS namespaces + WHERE namespaces.nspname NOT IN ('information_schema', 'pg_catalog') + AND namespaces.nspname NOT LIKE 'pg_toast%' + AND namespaces.nspname NOT LIKE 'pg_temp_%' + AND pg_catalog.has_schema_privilege(observer_oid, namespaces.oid, 'CREATE') + ) THEN + RAISE EXCEPTION 'Database observability schema privileges are invalid'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM pg_catalog.pg_class AS classes + JOIN pg_catalog.pg_namespace AS namespaces + ON namespaces.oid = classes.relnamespace + WHERE namespaces.nspname NOT IN ('information_schema', 'pg_catalog') + AND namespaces.nspname NOT LIKE 'pg_toast%' + AND namespaces.nspname NOT LIKE 'pg_temp_%' + AND NOT (classes.oid = ANY(allowed_relation_oids)) + AND CASE + WHEN classes.relkind = 'S' THEN + pg_catalog.has_sequence_privilege( + observer_oid, + classes.oid, + 'USAGE,SELECT,UPDATE' + ) + WHEN classes.relkind IN ('r', 'p', 'v', 'm', 'f') THEN + pg_catalog.has_table_privilege( + observer_oid, + classes.oid, + 'SELECT,INSERT,UPDATE,DELETE,TRUNCATE,REFERENCES,TRIGGER,MAINTAIN' + ) + OR pg_catalog.has_any_column_privilege( + observer_oid, + classes.oid, + 'SELECT,INSERT,UPDATE,REFERENCES' + ) + ELSE false + END + ) THEN + RAISE EXCEPTION 'Database observability relation privileges are invalid'; + END IF; + +END +$verify$; diff --git a/greenfield/scripts/delivery/provisioning/database-observability/verify-reconciliation-approval.sql b/greenfield/scripts/delivery/provisioning/database-observability/verify-reconciliation-approval.sql new file mode 100644 index 000000000..5f4afbb45 --- /dev/null +++ b/greenfield/scripts/delivery/provisioning/database-observability/verify-reconciliation-approval.sql @@ -0,0 +1,228 @@ +\set ON_ERROR_STOP 1 + +SET SESSION mira_dashboard.approved_policy_digest + TO :'approved_policy_digest'; + +DO $verify_reconciliation_approval$ +DECLARE + administrator_oid oid; + approval_oid oid; + approval_schema_oid oid; + expected_digest text := pg_catalog.current_setting( + 'mira_dashboard.approved_policy_digest' + ); +BEGIN + SELECT databases.datdba INTO administrator_oid + FROM pg_catalog.pg_database AS databases + JOIN pg_catalog.pg_roles AS owners ON owners.oid = databases.datdba + WHERE databases.datname = pg_catalog.current_database() + AND databases.datname = 'mira_dashboard_observability' + AND NOT databases.datistemplate + AND databases.datallowconn + AND owners.rolname = CURRENT_USER + AND owners.rolsuper; + SELECT namespaces.oid INTO approval_schema_oid + FROM pg_catalog.pg_namespace AS namespaces + WHERE namespaces.nspname = 'mira_dashboard_observability_control'; + SELECT classes.oid INTO approval_oid + FROM pg_catalog.pg_class AS classes + WHERE classes.relnamespace = approval_schema_oid + AND classes.relname = 'reconciliation_approval' + AND classes.relkind = 'r'; + + IF administrator_oid IS NULL + OR expected_digest !~ '^[0-9a-f]{64}$' + OR approval_schema_oid IS NULL + OR approval_oid IS NULL + OR (SELECT namespaces.nspowner FROM pg_catalog.pg_namespace AS namespaces + WHERE namespaces.oid = approval_schema_oid) <> administrator_oid + OR COALESCE( + (SELECT namespaces.nspacl FROM pg_catalog.pg_namespace AS namespaces + WHERE namespaces.oid = approval_schema_oid), + pg_catalog.acldefault('n', administrator_oid) + ) <> pg_catalog.acldefault('n', administrator_oid) + OR (SELECT classes.relowner FROM pg_catalog.pg_class AS classes + WHERE classes.oid = approval_oid) <> administrator_oid + OR (SELECT classes.relpersistence FROM pg_catalog.pg_class AS classes + WHERE classes.oid = approval_oid) <> 'p' + OR (SELECT classes.relrowsecurity OR classes.relforcerowsecurity + FROM pg_catalog.pg_class AS classes + WHERE classes.oid = approval_oid) + OR (SELECT classes.relreplident FROM pg_catalog.pg_class AS classes + WHERE classes.oid = approval_oid) <> 'd' + OR (SELECT classes.relispartition FROM pg_catalog.pg_class AS classes + WHERE classes.oid = approval_oid) + OR (SELECT classes.reloptions FROM pg_catalog.pg_class AS classes + WHERE classes.oid = approval_oid) IS NOT NULL + OR COALESCE( + (SELECT classes.relacl FROM pg_catalog.pg_class AS classes + WHERE classes.oid = approval_oid), + pg_catalog.acldefault('r', administrator_oid) + ) <> pg_catalog.acldefault('r', administrator_oid) + OR (SELECT pg_catalog.count(*) FROM pg_catalog.pg_class AS classes + WHERE classes.relnamespace = approval_schema_oid) <> 2 + OR EXISTS ( + SELECT 1 FROM pg_catalog.pg_class AS classes + WHERE classes.relnamespace = approval_schema_oid + AND classes.oid <> approval_oid + AND NOT ( + classes.relkind = 'i' + AND classes.relowner = administrator_oid + AND classes.reloptions IS NULL + AND EXISTS ( + SELECT 1 FROM pg_catalog.pg_index AS indexes + JOIN pg_catalog.pg_am AS access_methods + ON access_methods.oid = classes.relam + WHERE indexes.indexrelid = classes.oid + AND indexes.indrelid = approval_oid + AND access_methods.amname = 'btree' + AND indexes.indisprimary + AND indexes.indisunique + AND indexes.indisvalid + AND indexes.indisready + AND indexes.indislive + AND indexes.indimmediate + AND NOT indexes.indisreplident + AND indexes.indnkeyatts = 1 + AND indexes.indnatts = 1 + AND indexes.indkey = '1'::pg_catalog.int2vector + AND indexes.indexprs IS NULL + AND indexes.indpred IS NULL + ) + ) + ) + OR (SELECT pg_catalog.count(*) FROM pg_catalog.pg_index AS indexes + WHERE indexes.indrelid = approval_oid) <> 1 + OR (SELECT pg_catalog.count(*) FROM pg_catalog.pg_attribute AS attributes + WHERE attributes.attrelid = approval_oid + AND attributes.attnum > 0 + AND NOT attributes.attisdropped) <> 5 + OR EXISTS ( + SELECT 1 + FROM (VALUES + (1, 'singleton'::name, 'pg_catalog.bool'::pg_catalog.regtype, true), + (2, 'policy_version'::name, 'pg_catalog.text'::pg_catalog.regtype, true), + (3, 'system_identifier'::name, 'pg_catalog.numeric'::pg_catalog.regtype, true), + (4, 'current_policy_digest'::name, 'pg_catalog.text'::pg_catalog.regtype, true), + (5, 'previous_policy_digest'::name, 'pg_catalog.text'::pg_catalog.regtype, false) + ) AS expected(attnum, attname, atttypid, attnotnull) + LEFT JOIN pg_catalog.pg_attribute AS attributes + ON attributes.attrelid = approval_oid + AND attributes.attnum = expected.attnum + AND NOT attributes.attisdropped + WHERE attributes.attname IS DISTINCT FROM expected.attname + OR attributes.atttypid IS DISTINCT FROM expected.atttypid + OR attributes.attnotnull IS DISTINCT FROM expected.attnotnull + OR attributes.atthasdef + OR attributes.attidentity <> '' + OR attributes.attgenerated <> '' + ) + OR (SELECT pg_catalog.count(*) FROM pg_catalog.pg_constraint AS constraints + WHERE constraints.conrelid = approval_oid) <> 10 + OR NOT EXISTS ( + SELECT 1 FROM pg_catalog.pg_constraint AS constraints + WHERE constraints.conrelid = approval_oid + AND constraints.conname = 'reconciliation_approval_primary_key' + AND constraints.contype = 'p' + AND constraints.conkey = ARRAY[1]::smallint[] + AND NOT constraints.condeferrable + AND NOT constraints.condeferred + AND constraints.convalidated + AND constraints.connoinherit + ) + OR EXISTS ( + SELECT 1 + FROM (VALUES + ('reconciliation_approval_singleton_not_null'::name, ARRAY[1]::smallint[]), + ('reconciliation_approval_policy_version_not_null'::name, ARRAY[2]::smallint[]), + ('reconciliation_approval_system_identifier_not_null'::name, ARRAY[3]::smallint[]), + ('reconciliation_approval_current_policy_digest_not_null'::name, ARRAY[4]::smallint[]) + ) AS expected(constraint_name, constrained_columns) + LEFT JOIN pg_catalog.pg_constraint AS constraints + ON constraints.conrelid = approval_oid + AND constraints.conname = expected.constraint_name + AND constraints.contype = 'n' + WHERE constraints.oid IS NULL + OR constraints.conkey IS DISTINCT FROM expected.constrained_columns + OR NOT constraints.convalidated + OR constraints.connoinherit + ) + OR EXISTS ( + SELECT 1 + FROM (VALUES + ('reconciliation_approval_singleton_true'::name, 'CHECK (singleton)'::text), + ('reconciliation_approval_policy_version'::name, 'CHECK ((policy_version = ''sanitized-capabilities-v1''::text))'::text), + ('reconciliation_approval_system_identifier'::name, 'CHECK ((system_identifier > (0)::numeric))'::text), + ('reconciliation_approval_current_digest'::name, 'CHECK ((current_policy_digest ~ ''^[0-9a-f]{64}$''::text))'::text), + ('reconciliation_approval_previous_digest'::name, 'CHECK (((previous_policy_digest IS NULL) OR (previous_policy_digest ~ ''^[0-9a-f]{64}$''::text)))'::text) + ) AS expected(constraint_name, definition) + LEFT JOIN pg_catalog.pg_constraint AS constraints + ON constraints.conrelid = approval_oid + AND constraints.conname = expected.constraint_name + AND constraints.contype = 'c' + WHERE constraints.oid IS NULL + OR NOT constraints.convalidated + OR constraints.connoinherit + OR pg_catalog.pg_get_constraintdef(constraints.oid, false) + IS DISTINCT FROM expected.definition + ) + OR EXISTS ( + SELECT 1 FROM pg_catalog.pg_trigger AS triggers + WHERE triggers.tgrelid = approval_oid AND NOT triggers.tgisinternal + ) + OR EXISTS ( + SELECT 1 FROM pg_catalog.pg_rewrite AS rules + WHERE rules.ev_class = approval_oid + ) + OR EXISTS ( + SELECT 1 FROM pg_catalog.pg_policy AS policies + WHERE policies.polrelid = approval_oid + ) + OR EXISTS ( + SELECT 1 FROM pg_catalog.pg_inherits AS inheritance + WHERE inheritance.inhrelid = approval_oid + OR inheritance.inhparent = approval_oid + ) + OR EXISTS ( + SELECT 1 FROM pg_catalog.pg_proc AS routines + WHERE routines.pronamespace = approval_schema_oid + ) + OR (SELECT pg_catalog.count(*) FROM pg_catalog.pg_type AS types + WHERE types.typnamespace = approval_schema_oid) <> 2 + OR EXISTS ( + SELECT 1 FROM pg_catalog.pg_type AS types + WHERE types.typnamespace = approval_schema_oid + AND ( + types.typowner <> administrator_oid + OR types.oid NOT IN ( + (SELECT classes.reltype FROM pg_catalog.pg_class AS classes + WHERE classes.oid = approval_oid), + (SELECT row_types.typarray FROM pg_catalog.pg_type AS row_types + WHERE row_types.oid = ( + SELECT classes.reltype FROM pg_catalog.pg_class AS classes + WHERE classes.oid = approval_oid + )) + ) + ) + ) + OR (SELECT pg_catalog.count(*) + FROM mira_dashboard_observability_control.reconciliation_approval) <> 1 + OR NOT EXISTS ( + SELECT 1 + FROM mira_dashboard_observability_control.reconciliation_approval AS approval + CROSS JOIN pg_catalog.pg_control_system() AS controls + WHERE approval.singleton + AND approval.policy_version = 'sanitized-capabilities-v1' + AND approval.system_identifier = controls.system_identifier + AND expected_digest IN ( + approval.current_policy_digest, + approval.previous_policy_digest + ) + ) + THEN + RAISE EXCEPTION 'Database observability reconciliation approval is invalid'; + END IF; +END +$verify_reconciliation_approval$; + +RESET mira_dashboard.approved_policy_digest; diff --git a/greenfield/scripts/delivery/provisioning/database-observability/verify-torrent-view.sql b/greenfield/scripts/delivery/provisioning/database-observability/verify-torrent-view.sql new file mode 100644 index 000000000..aecce3cc9 --- /dev/null +++ b/greenfield/scripts/delivery/provisioning/database-observability/verify-torrent-view.sql @@ -0,0 +1,289 @@ +\set ON_ERROR_STOP 1 + +DO $verify$ +DECLARE + observer_oid oid; + owner_oid oid; + schema_oid oid; + view_oid oid; + normalized_definition text; + projected_count bigint; + projected_rows bigint; + schema_relations text[]; + source_count bigint; + source_dependency_count bigint; +BEGIN + IF pg_catalog.current_database() NOT IN ('bitmagnet', 'comet') THEN + RAISE EXCEPTION 'Database observability view target is not reviewed'; + END IF; + + SELECT oid INTO observer_oid + FROM pg_catalog.pg_roles + WHERE rolname = 'mira_dashboard_observer'; + SELECT oid INTO owner_oid + FROM pg_catalog.pg_roles + WHERE rolname = 'mira_dashboard_observability_owner'; + SELECT oid INTO schema_oid + FROM pg_catalog.pg_namespace + WHERE nspname = 'mira_dashboard_observability'; + SELECT classes.oid INTO view_oid + FROM pg_catalog.pg_class AS classes + WHERE classes.relnamespace = schema_oid + AND classes.relname = 'torrent_count' + AND classes.relkind = 'v'; + + IF observer_oid IS NULL + OR owner_oid IS NULL + OR schema_oid IS NULL + OR view_oid IS NULL + THEN + RAISE EXCEPTION 'Database observability view identity is invalid'; + END IF; + + IF (SELECT nspowner FROM pg_catalog.pg_namespace WHERE oid = schema_oid) + IS DISTINCT FROM owner_oid + OR (SELECT relowner FROM pg_catalog.pg_class WHERE oid = view_oid) + IS DISTINCT FROM owner_oid + OR (SELECT reloptions FROM pg_catalog.pg_class WHERE oid = view_oid) + IS DISTINCT FROM ARRAY['security_barrier=true']::text[] + THEN + RAISE EXCEPTION 'Database observability view ownership is invalid'; + END IF; + + SELECT pg_catalog.array_agg( + classes.relname || ':' || classes.relkind::text ORDER BY classes.relname + ) INTO schema_relations + FROM pg_catalog.pg_class AS classes + WHERE classes.relnamespace = schema_oid; + + IF schema_relations IS DISTINCT FROM ARRAY['torrent_count:v']::text[] + AND schema_relations IS DISTINCT FROM ARRAY[ + 'statement_metrics:v', + 'torrent_count:v' + ]::text[] + THEN + RAISE EXCEPTION 'Database observability schema contains unexpected relations'; + END IF; + + IF EXISTS ( + SELECT 1 + FROM pg_catalog.pg_proc AS routines + WHERE routines.pronamespace = schema_oid + ) THEN + RAISE EXCEPTION 'Database observability schema contains unexpected routines'; + END IF; + + IF ( + SELECT pg_catalog.count(*) + FROM pg_catalog.pg_type AS types + WHERE types.typnamespace = schema_oid + ) <> 2 + OR EXISTS ( + SELECT 1 + FROM pg_catalog.pg_type AS types + WHERE types.typnamespace = schema_oid + AND types.oid NOT IN ( + (SELECT reltype FROM pg_catalog.pg_class WHERE oid = view_oid), + ( + SELECT typarray + FROM pg_catalog.pg_type + WHERE oid = ( + SELECT reltype FROM pg_catalog.pg_class WHERE oid = view_oid + ) + ) + ) + ) + OR NOT EXISTS ( + SELECT 1 + FROM pg_catalog.pg_type AS row_type + JOIN pg_catalog.pg_type AS array_type + ON array_type.oid = row_type.typarray + WHERE row_type.oid = ( + SELECT reltype FROM pg_catalog.pg_class WHERE oid = view_oid + ) + AND row_type.typrelid = view_oid + AND row_type.typowner = owner_oid + AND array_type.typelem = row_type.oid + AND array_type.typowner = owner_oid + AND array_type.typnamespace = schema_oid + ) + THEN + RAISE EXCEPTION 'Database observability schema contains unexpected types'; + END IF; + + IF ( + SELECT pg_catalog.count(*) + FROM pg_catalog.pg_attribute + WHERE attrelid = view_oid AND attnum > 0 AND NOT attisdropped + ) <> 1 + OR NOT EXISTS ( + SELECT 1 + FROM pg_catalog.pg_attribute + WHERE attrelid = view_oid + AND attnum = 1 + AND attname = 'count' + AND atttypid = 'pg_catalog.int8'::pg_catalog.regtype + AND NOT attisdropped + ) + THEN + RAISE EXCEPTION 'Database observability view shape is invalid'; + END IF; + + SELECT pg_catalog.count(*) INTO source_dependency_count + FROM pg_catalog.pg_depend AS dependencies + JOIN pg_catalog.pg_rewrite AS rewrites + ON dependencies.classid = 'pg_catalog.pg_rewrite'::pg_catalog.regclass + AND dependencies.objid = rewrites.oid + WHERE rewrites.ev_class = view_oid + AND dependencies.refclassid = 'pg_catalog.pg_class'::pg_catalog.regclass + AND dependencies.refobjid = 'public.torrents'::pg_catalog.regclass + AND dependencies.refobjsubid = 0 + AND dependencies.deptype = 'n'; + IF source_dependency_count IS DISTINCT FROM 1 THEN + RAISE EXCEPTION 'Database observability view source dependency is invalid'; + END IF; + + PERFORM pg_catalog.set_config('search_path', 'pg_catalog', true); + SELECT pg_catalog.btrim( + pg_catalog.regexp_replace( + pg_catalog.pg_get_viewdef(view_oid, true), + '\s+', + ' ', + 'g' + ) + ) INTO normalized_definition; + IF normalized_definition NOT IN ( + 'SELECT count(*) AS count FROM public.torrents;', + 'SELECT count(*)::bigint AS count FROM public.torrents;' + ) THEN + RAISE EXCEPTION 'Database observability view definition is invalid'; + END IF; + + IF NOT pg_catalog.has_schema_privilege( + 'mira_dashboard_observer', + schema_oid, + 'USAGE' + ) + OR pg_catalog.has_schema_privilege( + 'mira_dashboard_observer', + schema_oid, + 'CREATE' + ) + OR NOT pg_catalog.has_table_privilege( + 'mira_dashboard_observer', + view_oid, + 'SELECT' + ) + OR pg_catalog.has_table_privilege( + 'mira_dashboard_observer', + view_oid, + 'INSERT,UPDATE,DELETE,TRUNCATE,REFERENCES,TRIGGER,MAINTAIN' + ) + OR pg_catalog.has_table_privilege( + 'mira_dashboard_observer', + 'public.torrents', + 'SELECT,INSERT,UPDATE,DELETE,TRUNCATE,REFERENCES,TRIGGER,MAINTAIN' + ) + OR pg_catalog.has_any_column_privilege( + 'mira_dashboard_observer', + 'public.torrents', + 'SELECT,INSERT,UPDATE,REFERENCES' + ) + OR NOT pg_catalog.has_table_privilege( + 'mira_dashboard_observability_owner', + 'public.torrents', + 'SELECT' + ) + OR pg_catalog.has_table_privilege( + 'mira_dashboard_observability_owner', + 'public.torrents', + 'INSERT,UPDATE,DELETE,TRUNCATE,REFERENCES,TRIGGER,MAINTAIN' + ) + OR ( + SELECT relowner + FROM pg_catalog.pg_class + WHERE oid = 'public.torrents'::pg_catalog.regclass + ) = owner_oid + THEN + RAISE EXCEPTION 'Database observability view privileges are invalid'; + END IF; + + IF ( + SELECT pg_catalog.count(*) + FROM pg_catalog.aclexplode( + COALESCE( + (SELECT nspacl FROM pg_catalog.pg_namespace WHERE oid = schema_oid), + pg_catalog.acldefault('n', owner_oid) + ) + ) AS grants + ) <> 3 + OR EXISTS ( + SELECT 1 + FROM pg_catalog.aclexplode( + COALESCE( + (SELECT nspacl FROM pg_catalog.pg_namespace WHERE oid = schema_oid), + pg_catalog.acldefault('n', owner_oid) + ) + ) AS grants + WHERE NOT ( + grants.grantee = owner_oid + AND grants.privilege_type IN ('CREATE', 'USAGE') + AND NOT grants.is_grantable + OR grants.grantee = observer_oid + AND grants.privilege_type = 'USAGE' + AND NOT grants.is_grantable + ) + ) + OR ( + SELECT pg_catalog.count(*) + FROM pg_catalog.aclexplode( + COALESCE( + (SELECT relacl FROM pg_catalog.pg_class WHERE oid = view_oid), + pg_catalog.acldefault('r', owner_oid) + ) + ) AS grants + ) <> 9 + OR EXISTS ( + SELECT 1 + FROM pg_catalog.aclexplode( + COALESCE( + (SELECT relacl FROM pg_catalog.pg_class WHERE oid = view_oid), + pg_catalog.acldefault('r', owner_oid) + ) + ) AS grants + WHERE NOT ( + grants.grantee = owner_oid + AND grants.privilege_type IN ( + 'SELECT', + 'INSERT', + 'UPDATE', + 'DELETE', + 'MAINTAIN', + 'TRUNCATE', + 'REFERENCES', + 'TRIGGER' + ) + AND NOT grants.is_grantable + OR grants.grantee = observer_oid + AND grants.privilege_type = 'SELECT' + AND NOT grants.is_grantable + ) + ) + THEN + RAISE EXCEPTION 'Database observability ACL is not exact'; + END IF; + + SELECT pg_catalog.count(*), pg_catalog.min(torrent_count.count) + INTO projected_rows, projected_count + FROM mira_dashboard_observability.torrent_count AS torrent_count; + SELECT pg_catalog.count(*) INTO source_count FROM public.torrents; + IF projected_rows IS DISTINCT FROM 1 + OR projected_count IS NULL + OR projected_count < 0 + OR projected_count > 9007199254740991 + OR projected_count IS DISTINCT FROM source_count + THEN + RAISE EXCEPTION 'Database observability view result is invalid'; + END IF; +END +$verify$; diff --git a/greenfield/scripts/delivery/releaseIdentity.test.ts b/greenfield/scripts/delivery/releaseIdentity.test.ts index 444fcc767..49b44776f 100644 --- a/greenfield/scripts/delivery/releaseIdentity.test.ts +++ b/greenfield/scripts/delivery/releaseIdentity.test.ts @@ -124,6 +124,13 @@ async function releaseFixture(): Promise<{ path.join(sourceProjectRoot, "scripts/delivery/provisioning/log-maintenance"), path.join(releaseRoot, "scripts/delivery/provisioning/log-maintenance") ), + copyDirectory( + path.join( + sourceProjectRoot, + "scripts/delivery/provisioning/database-observability" + ), + path.join(releaseRoot, "scripts/delivery/provisioning/database-observability") + ), ]); return { releaseRoot, repositoryRoot }; } @@ -175,6 +182,36 @@ describe("release identity", () => { .filter(({ path: artifactPath }) => artifactPath.startsWith("scripts/")) .map(({ path: artifactPath }) => artifactPath) ).toEqual([ + "scripts/delivery/provisioning/database-observability/README.md", + "scripts/delivery/provisioning/database-observability/activate-observer.sql", + "scripts/delivery/provisioning/database-observability/apply-cluster.sql", + "scripts/delivery/provisioning/database-observability/apply-control-database-capability.sql", + "scripts/delivery/provisioning/database-observability/apply-control-database.sql", + "scripts/delivery/provisioning/database-observability/apply-database-access-reconciler.sql", + "scripts/delivery/provisioning/database-observability/apply-database-capabilities.sql", + "scripts/delivery/provisioning/database-observability/apply-reconciliation-approval.sql", + "scripts/delivery/provisioning/database-observability/apply-torrent-view.sql", + "scripts/delivery/provisioning/database-observability/disable-observer.sql", + "scripts/delivery/provisioning/database-observability/enable-approved-collection.sql", + "scripts/delivery/provisioning/database-observability/manifest.json", + "scripts/delivery/provisioning/database-observability/prepare-approved-collection.sql", + "scripts/delivery/provisioning/database-observability/reconcile-database-access.sql", + "scripts/delivery/provisioning/database-observability/rollback-cluster.sql", + "scripts/delivery/provisioning/database-observability/rollback-control-database-capability.sql", + "scripts/delivery/provisioning/database-observability/rollback-control-database.sql", + "scripts/delivery/provisioning/database-observability/rollback-database-access-reconciler.sql", + "scripts/delivery/provisioning/database-observability/rollback-database-capabilities.sql", + "scripts/delivery/provisioning/database-observability/rollback-reconciliation-approval.sql", + "scripts/delivery/provisioning/database-observability/rollback-torrent-view.sql", + "scripts/delivery/provisioning/database-observability/runProvisioning.ts", + "scripts/delivery/provisioning/database-observability/verify-cluster.sql", + "scripts/delivery/provisioning/database-observability/verify-control-database-capability.sql", + "scripts/delivery/provisioning/database-observability/verify-control-database.sql", + "scripts/delivery/provisioning/database-observability/verify-database-access-reconciler.sql", + "scripts/delivery/provisioning/database-observability/verify-database-capabilities.sql", + "scripts/delivery/provisioning/database-observability/verify-database.sql", + "scripts/delivery/provisioning/database-observability/verify-reconciliation-approval.sql", + "scripts/delivery/provisioning/database-observability/verify-torrent-view.sql", "scripts/delivery/provisioning/host-operations/60-mira-dashboard-host-operations.rules", "scripts/delivery/provisioning/host-operations/README.md", "scripts/delivery/provisioning/host-operations/hostOperationsProvisioningFilesystem.ts", diff --git a/greenfield/scripts/delivery/releaseIdentity.ts b/greenfield/scripts/delivery/releaseIdentity.ts index 09b176d44..190a80d37 100644 --- a/greenfield/scripts/delivery/releaseIdentity.ts +++ b/greenfield/scripts/delivery/releaseIdentity.ts @@ -18,6 +18,7 @@ import { } from "../buildSourceIdentity.ts"; import { readBoundedUtf8RegularFile } from "../files/boundedFile.ts"; import { resolveDirectPackageVersions } from "../packageIdentity.ts"; +import { databaseObservabilityProvisioningReleaseArtifactPaths } from "./databaseObservabilityProvisioningPolicy.ts"; import { hostOperationsProvisioningReleaseArtifactPaths } from "./hostOperationsProvisioningPolicy.ts"; import { logMaintenanceProvisioningReleaseArtifactPaths } from "./logMaintenanceProvisioningPolicy.ts"; import { productionSystemdUnits } from "./productionSystemdUnitPolicy.ts"; @@ -57,6 +58,7 @@ const exactSystemdPaths = Object.freeze( ); const exactScriptPaths = Object.freeze( [ + ...databaseObservabilityProvisioningReleaseArtifactPaths, ...hostOperationsProvisioningReleaseArtifactPaths, ...logMaintenanceProvisioningReleaseArtifactPaths, ].toSorted() @@ -318,7 +320,8 @@ export async function createReleaseIdentity( ): Promise { validateRoots(options.repositoryRoot, options.releaseRoot); const source = - options.sourceIdentity ?? resolveBuildSourceIdentity(options.repositoryRoot); + options.sourceIdentity ?? + (await resolveBuildSourceIdentity(options.repositoryRoot)); if (source.state !== "clean") throw invalidReleaseIdentity(); const runtime = options.runtimeIdentity ?? currentRuntimeIdentity(); assertRuntimeIdentity(runtime); diff --git a/greenfield/scripts/delivery/releaseStaging.ts b/greenfield/scripts/delivery/releaseStaging.ts index 088941893..c8119de68 100644 --- a/greenfield/scripts/delivery/releaseStaging.ts +++ b/greenfield/scripts/delivery/releaseStaging.ts @@ -230,18 +230,8 @@ export async function stageReleaseArtifacts( path.join(stagingRoot, "systemd") ), copyArtifactTree( - path.join( - sources.repositoryRoot, - "scripts/delivery/provisioning/log-maintenance" - ), - path.join(stagingRoot, "scripts/delivery/provisioning/log-maintenance") - ), - copyArtifactTree( - path.join( - sources.repositoryRoot, - "scripts/delivery/provisioning/host-operations" - ), - path.join(stagingRoot, "scripts/delivery/provisioning/host-operations") + path.join(sources.repositoryRoot, "scripts/delivery/provisioning"), + path.join(stagingRoot, "scripts/delivery/provisioning") ), copyMetadataFile( path.join(sources.repositoryRoot, ".bun-version"), diff --git a/greenfield/scripts/delivery/systemdProductionServices.test.ts b/greenfield/scripts/delivery/systemdProductionServices.test.ts index 3137c7881..3c64f8a5b 100644 --- a/greenfield/scripts/delivery/systemdProductionServices.test.ts +++ b/greenfield/scripts/delivery/systemdProductionServices.test.ts @@ -1,5 +1,5 @@ import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test"; -import { mkdir, readFile, readlink, unlink } from "node:fs/promises"; +import { mkdir, readdir, readFile, readlink, symlink, unlink } from "node:fs/promises"; import path from "node:path"; import { configurationEnvironmentNamesForRole } from "../../src/shared/configuration/applicationConfigurationRegistry.ts"; @@ -221,6 +221,151 @@ describe("production user-systemd service control", () => { }); }); + test("removes crash-left pointer stages before replacing current", async () => { + const sourceReleases = sourceReleaseFixtures(); + const { projectRoot, runtimeSource } = + await createProductionTargetFixture(temporaryDirectories); + const state = await prepareProtectedProductionStatePath(projectRoot); + await withDeploymentLease(state.stateDirectory, async (lease) => { + const paths = await prepareProductionDeliveryDirectories(state); + const fixtures = await publishProductionDeliveryFixtures( + lease, + paths, + sourceReleases, + runtimeSource, + runtimeIdentity + ); + await pointProductionProcessesAtRelease( + lease, + paths, + fixtures.first, + fixtures.runtime + ); + const releaseStage = `.current-${Bun.randomUUIDv7()}`; + const runtimeStage = `.current-${Bun.randomUUIDv7()}`; + await symlink( + firstReleaseId, + path.join(paths.releasesDirectory, releaseStage), + "dir" + ); + await symlink( + runtimeIdentity.revision, + path.join(paths.runtimesDirectory, "bun", runtimeStage), + "dir" + ); + + await pointProductionProcessesAtRelease( + lease, + paths, + fixtures.second, + fixtures.runtime + ); + + expect(await readlink(path.join(paths.releasesDirectory, "current"))).toBe( + secondReleaseId + ); + expect( + await readlink(path.join(paths.runtimesDirectory, "bun", "current")) + ).toBe(runtimeIdentity.revision); + expect(await readdir(paths.releasesDirectory)).not.toContain(releaseStage); + expect( + await readdir(path.join(paths.runtimesDirectory, "bun")) + ).not.toContain(runtimeStage); + }); + }); + + test("refuses to remove an untrusted pointer-stage symlink", async () => { + const sourceReleases = sourceReleaseFixtures(); + const { projectRoot, runtimeSource } = + await createProductionTargetFixture(temporaryDirectories); + const state = await prepareProtectedProductionStatePath(projectRoot); + await withDeploymentLease(state.stateDirectory, async (lease) => { + const paths = await prepareProductionDeliveryDirectories(state); + const fixtures = await publishProductionDeliveryFixtures( + lease, + paths, + sourceReleases, + runtimeSource, + runtimeIdentity + ); + await pointProductionProcessesAtRelease( + lease, + paths, + fixtures.first, + fixtures.runtime + ); + const validStageName = `.current-${Bun.randomUUIDv7()}`; + await symlink( + firstReleaseId, + path.join(paths.releasesDirectory, validStageName), + "dir" + ); + const stageName = `.current-${Bun.randomUUIDv7()}`; + const stagePath = path.join(paths.releasesDirectory, stageName); + await symlink("../outside", stagePath, "dir"); + + const failure = await rejectionError( + pointProductionProcessesAtRelease( + lease, + paths, + fixtures.second, + fixtures.runtime + ) + ); + + expect(failure.message).toBe("Production runtime pointer update failed"); + expect(await readlink(path.join(paths.releasesDirectory, "current"))).toBe( + firstReleaseId + ); + expect(await readdir(paths.releasesDirectory)).toContain(validStageName); + expect(await readlink(stagePath)).toBe("../outside"); + }); + }); + + test("bounds crash-left pointer-stage inventory before mutation", async () => { + const sourceReleases = sourceReleaseFixtures(); + const { projectRoot, runtimeSource } = + await createProductionTargetFixture(temporaryDirectories); + const state = await prepareProtectedProductionStatePath(projectRoot); + await withDeploymentLease(state.stateDirectory, async (lease) => { + const paths = await prepareProductionDeliveryDirectories(state); + const fixtures = await publishProductionDeliveryFixtures( + lease, + paths, + sourceReleases, + runtimeSource, + runtimeIdentity + ); + await pointProductionProcessesAtRelease( + lease, + paths, + fixtures.first, + fixtures.runtime + ); + for (let index = 0; index < 129; index += 1) { + await symlink( + firstReleaseId, + path.join(paths.releasesDirectory, `.current-${Bun.randomUUIDv7()}`), + "dir" + ); + } + + const failure = await rejectionError( + pointProductionProcessesAtRelease( + lease, + paths, + fixtures.second, + fixtures.runtime + ) + ); + + expect(failure.message).toBe("Production runtime pointer update failed"); + expect(await readlink(path.join(paths.releasesDirectory, "current"))).toBe( + firstReleaseId + ); + }); + }); + test("refuses to replace an untrusted current entry", async () => { const sourceReleases = sourceReleaseFixtures(); const { projectRoot, runtimeSource } = @@ -299,7 +444,9 @@ describe("production user-systemd service control", () => { `--only-secrets ${configurationEnvironmentNamesForRole("web").join(",")}` ); expect(webExecStart).not.toContain("MOLTBOOK_API_KEY"); - expect(web).toContain("UnsetEnvironment=MOLTBOOK_API_KEY MOLTBOOK_AGENT_NAME"); + expect(web).toContain( + "UnsetEnvironment=MIRA_DASHBOARD_DATABASE_OBSERVABILITY_PASSWORD MOLTBOOK_API_KEY MOLTBOOK_AGENT_NAME" + ); expect(worker).toContain("Environment=MIRA_DASHBOARD_OPENCLAW_ROOT=%h/.openclaw"); expect(worker).toContain( "--preserve-env=NODE_ENV,MIRA_DASHBOARD_PROJECT_ROOT,MIRA_DASHBOARD_OPENCLAW_ROOT,MIRA_DASHBOARD_WORKSPACE_ROOT" diff --git a/greenfield/scripts/development/developmentFrontend.test.ts b/greenfield/scripts/development/developmentFrontend.test.ts index b952da7ea..5db78a60e 100644 --- a/greenfield/scripts/development/developmentFrontend.test.ts +++ b/greenfield/scripts/development/developmentFrontend.test.ts @@ -1,128 +1,29 @@ import { expect, test } from "bun:test"; -const repositoryRoot = new URL("../../", import.meta.url).pathname; - -async function reserveLoopbackPorts(count: number): Promise { - const reservations = Array.from({ length: count }, () => - Bun.serve({ - fetch: () => new Response("reserved"), - hostname: "127.0.0.1", - port: 0, - }) - ); - try { - const ports: number[] = []; - for (const reservation of reservations) { - const port = reservation.port; - if (port === undefined) { - throw new Error("Bun did not reserve a development port"); - } - ports.push(port); - } - return ports; - } finally { - await Promise.all(reservations.map((reservation) => reservation.stop(true))); - } -} - -async function waitForFrontend( - origin: string, - child: ReturnType, - publicHost: string -): Promise { - const deadline = Date.now() + 20_000; - while (Date.now() < deadline) { - if (child.exitCode !== null) { - throw new Error(`Development frontend exited ${child.exitCode}`); - } - try { - const response = await fetch(origin, { - headers: { host: publicHost }, - signal: AbortSignal.timeout(1000), - }); - if (response.ok) return response; - } catch { - // The Bun listener or first HTML bundle is not ready yet. - } - await Bun.sleep(50); - } - throw new Error("Development frontend did not become ready"); -} - -async function stopChild(child: ReturnType): Promise { - if (child.exitCode !== null) return; - child.kill("SIGTERM"); - const settled = await Promise.race([ - child.exited.then(() => true), - Bun.sleep(2000).then(() => false), - ]); - if (!settled && child.exitCode === null) { - child.kill("SIGKILL"); - await child.exited; - } -} - -async function startFrontend(publicHost: string): Promise< - Readonly<{ - child: ReturnType; - origin: string; - response: Response; - }> -> { - for (let attempt = 1; attempt <= 3; attempt += 1) { - const [port, remoteProxyPort, backendPort] = await reserveLoopbackPorts(3); - if ( - port === undefined || - remoteProxyPort === undefined || - backendPort === undefined - ) { - throw new Error("Bun did not reserve the development ports"); - } - const origin = `http://127.0.0.1:${remoteProxyPort}`; - const child = Bun.spawn([process.execPath, "scripts/developmentFrontend.ts"], { - cwd: repositoryRoot, - env: { - DASHBOARD_API_TARGET: `http://127.0.0.1:${backendPort}`, - HOST: "127.0.0.1", - LANG: "C.UTF-8", - MIRA_DASHBOARD_DEV_HOT_RELOAD: "1", - MIRA_DASHBOARD_DEV_PUBLIC_ORIGIN: `https://${publicHost}`, - MIRA_DASHBOARD_DEV_REMOTE_PROXY_PORT: String(remoteProxyPort), - NODE_ENV: "development", - PATH: process.env.PATH ?? "/usr/bin:/bin", - PORT: String(port), - TZ: "UTC", - }, - stderr: "pipe", - stdin: "ignore", - stdout: "ignore", - }); - - try { - const response = await waitForFrontend(origin, child, publicHost); - return Object.freeze({ child, origin, response }); - } catch (error) { - await stopChild(child); - const stderr = await new Response(child.stderr).text(); - const portWasClaimed = /EADDRINUSE|address already in use/iu.test(stderr); - if (!portWasClaimed || attempt === 3) { - throw new Error( - `Development frontend startup failed${ - stderr.trim() === "" ? "" : ": listener failed" - }`, - { cause: error } - ); - } - } - } - throw new Error("Development frontend startup retries were exhausted"); -} +import { startDevelopmentFrontend } from "../developmentFrontend.ts"; test("serves remote Bun HMR, React Fast Refresh, and React Compiler output together", async () => { const publicHost = "dashboard.example.ts.net:3445"; - const { child, origin, response: frontendResponse } = await startFrontend(publicHost); + const runtime = await startDevelopmentFrontend({ + apiTarget: "http://127.0.0.1:65534", + cookieNamespace: "__Host-mira_dashboard_dev_hmr", + host: "127.0.0.1", + hotReload: true, + port: 0, + publicOrigin: `https://${publicHost}`, + remoteProxyPort: 0, + }); try { + const remoteProxyPort = runtime.remoteProxy?.port; + if (remoteProxyPort === undefined) { + throw new Error("Development remote proxy did not open a TCP listener"); + } + const origin = `http://127.0.0.1:${remoteProxyPort}`; + const frontendResponse = await fetch(origin, { + headers: { host: publicHost }, + }); + expect(frontendResponse.ok).toBeTrue(); const html = await frontendResponse.text(); const clientScript = html.match( /]+src="([^"]+)"[^>]+data-bun-dev-server-script/u @@ -144,6 +45,24 @@ test("serves remote Bun HMR, React Fast Refresh, and React Compiler output toget expect(javascript).toContain("globalThis.location.reload()"); expect(javascript).toContain("useMemoCache"); } finally { - await stopChild(child); + await runtime.stop(true); } }, 30_000); + +test("starts and stops the exported frontend runtime in process", async () => { + const runtime = await startDevelopmentFrontend({ + apiTarget: "http://127.0.0.1:65534", + cookieNamespace: "__Host-mira_dashboard_dev_in_process", + host: "127.0.0.1", + hotReload: false, + port: 0, + publicOrigin: "http://localhost", + }); + + try { + expect(runtime.frontend.port).toBeGreaterThan(0); + expect(runtime.remoteProxy).toBeUndefined(); + } finally { + await runtime.stop(true); + } +}); diff --git a/greenfield/scripts/development/developmentRemoteProxy.ts b/greenfield/scripts/development/developmentRemoteProxy.ts index 11bc03d9a..ac2ba15bd 100644 --- a/greenfield/scripts/development/developmentRemoteProxy.ts +++ b/greenfield/scripts/development/developmentRemoteProxy.ts @@ -547,6 +547,7 @@ export function startDevelopmentRemoteProxy( const resolved = resolvedConfiguration(configuration); return Bun.serve({ fetch(request, server) { + server.timeout(request, 0); if (!hasExpectedPublicHost(request, resolved.publicOrigin)) { return new Response("Invalid development host", { status: 421 }); } diff --git a/greenfield/scripts/developmentFrontend.ts b/greenfield/scripts/developmentFrontend.ts index 7c4f32afd..9c9082d34 100644 --- a/greenfield/scripts/developmentFrontend.ts +++ b/greenfield/scripts/developmentFrontend.ts @@ -39,6 +39,7 @@ export async function startDevelopmentFrontend( const frontend = Bun.serve({ development: { console: true, hmr: configuration.hotReload }, hostname: configuration.host, + idleTimeout: 0, port: configuration.port, routes: { "/api": backendRequest, @@ -52,8 +53,12 @@ export async function startDevelopmentFrontend( let remoteProxy: Bun.Server | undefined; try { if (configuration.remoteProxyPort !== undefined) { + const frontendPort = frontend.port; + if (frontendPort === undefined) { + throw new Error("Development frontend did not open a TCP listener"); + } remoteProxy = startDevelopmentRemoteProxy({ - frontendTarget: `http://127.0.0.1:${configuration.port}`, + frontendTarget: `http://127.0.0.1:${frontendPort}`, port: configuration.remoteProxyPort, publicOrigin: configuration.publicOrigin, }); diff --git a/greenfield/scripts/documentation/artifacts.test.ts b/greenfield/scripts/documentation/artifacts.test.ts index 0de9f590b..ed807bfc6 100644 --- a/greenfield/scripts/documentation/artifacts.test.ts +++ b/greenfield/scripts/documentation/artifacts.test.ts @@ -53,6 +53,9 @@ describe("generated contract documentation", () => { expect(configurationDocumentation).toContain( "| `ELEVENLABS_API_KEY` | `elevenLabsApiKey` | `opaque-secret`; values withheld | When present, a trimmed nonblank control-safe secret at most 4096 code units; never persisted, logged, or browser-exposed. | Optional; no default | `web` | Yes | None |" ); + expect(configurationDocumentation).toContain( + "| `MIRA_DASHBOARD_DATABASE_OBSERVABILITY_PASSWORD` | `databaseObservabilityPassword` | `opaque-secret`; values withheld | When present, a trimmed nonblank opaque credential at most 4096 code units; no database, container, service, project, image, host, or port value is accepted, and the credential is never persisted, logged, or browser-exposed. | Optional; no default | `worker` | Yes | None |" + ); expect(configurationDocumentation).toContain( "| `MOLTBOOK_API_KEY` | `moltbookApiKey` | `opaque-secret`; values withheld | Trimmed nonblank control-safe secret at most 4096 code units; never persisted, logged, or browser-exposed. | Required; value withheld | `worker` | Yes | None |" ); @@ -177,13 +180,16 @@ describe("generated contract documentation", () => { expect(routeDocumentation).toContain( "| `/moltbook` | Browser session | Moltbook | `moltbook` |" ); + expect(routeDocumentation).toContain( + "| `/database` | Browser session | Database | `database` |" + ); expect(routeDocumentation).toContain( "| `/settings` | Browser session | Settings | `settings` |" ); expect(routeDocumentation).toContain( "| `/terminal` | Browser session | Terminal | `terminal` |" ); - expect(routeDocumentation?.match(/^\| `\//gmu)).toHaveLength(15); + expect(routeDocumentation?.match(/^\| `\//gmu)).toHaveLength(16); expect(first.has("schemas/files.upload.accepted.schema.json")).toBe(true); expect(first.has("schemas/logs.tail.output.schema.json")).toBe(true); expect(first.has("schemas/moltbook.feed.result.v1.schema.json")).toBe(true); diff --git a/greenfield/scripts/documentation/files.test.ts b/greenfield/scripts/documentation/files.test.ts new file mode 100644 index 000000000..78dc2bda2 --- /dev/null +++ b/greenfield/scripts/documentation/files.test.ts @@ -0,0 +1,71 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { rejectionError } from "../testSupport/rejection.ts"; +import { checkDocumentationArtifacts, writeDocumentationArtifacts } from "./files.ts"; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { force: true, recursive: true })) + ); +}); + +async function outputFixture(): Promise { + const root = await mkdtemp(path.join(tmpdir(), "mira-documentation-files-")); + temporaryDirectories.push(root); + return path.join(root, "generated"); +} + +describe("generated documentation files", () => { + test("writes the exact artifact inventory and removes stale files", async () => { + const outputDirectory = await outputFixture(); + await mkdir(path.join(outputDirectory, "stale"), { recursive: true }); + await writeFile(path.join(outputDirectory, "stale", "old.md"), "old\n"); + const artifacts = new Map([ + ["README.md", "index\n"], + ["schemas/example.json", '{"type":"object"}\n'], + ]); + + await writeDocumentationArtifacts(outputDirectory, artifacts); + + expect(await readFile(path.join(outputDirectory, "README.md"), "utf8")).toBe( + "index\n" + ); + expect( + await readFile(path.join(outputDirectory, "schemas", "example.json"), "utf8") + ).toBe('{"type":"object"}\n'); + expect( + Bun.file(path.join(outputDirectory, "stale", "old.md")).exists() + ).resolves.toBe(false); + expect( + checkDocumentationArtifacts(outputDirectory, artifacts) + ).resolves.toBeUndefined(); + }); + + test("rejects a stale file inventory and stale artifact contents", async () => { + const outputDirectory = await outputFixture(); + const artifacts = new Map([["README.md", "current\n"]]); + + const missingInventory = await rejectionError( + checkDocumentationArtifacts(outputDirectory, artifacts) + ); + expect(missingInventory.message).toBe( + "Generated documentation file set is stale; run bun run docs:generate" + ); + + await mkdir(outputDirectory, { recursive: true }); + await writeFile(path.join(outputDirectory, "README.md"), "stale\n"); + const staleArtifact = await rejectionError( + checkDocumentationArtifacts(outputDirectory, artifacts) + ); + expect(staleArtifact.message).toBe( + "Generated documentation is stale at README.md; run bun run docs:generate" + ); + }); +}); diff --git a/greenfield/scripts/documentation/jsonSchema.test.ts b/greenfield/scripts/documentation/jsonSchema.test.ts index caa57409e..082d4b785 100644 --- a/greenfield/scripts/documentation/jsonSchema.test.ts +++ b/greenfield/scripts/documentation/jsonSchema.test.ts @@ -32,6 +32,7 @@ import { } from "../../src/contracts/chat.ts"; import { chatAttachmentTicketPrepareInputSchema } from "../../src/contracts/chatMedia.ts"; import { chatSpeechSynthesisInputSchema } from "../../src/contracts/chatSpeech.ts"; +import { databaseOverviewSchema } from "../../src/contracts/database.ts"; import { listIncidentsResultSchema } from "../../src/contracts/incidents.ts"; import { scheduleCronExpressionSchema } from "../../src/contracts/jobModel.ts"; import { jobRealtimeChangeSchemas } from "../../src/contracts/jobRealtime.ts"; @@ -74,6 +75,27 @@ import { convertContractSchema } from "./jsonSchema.ts"; const parseHexadecimalCodePoint = (value: string): number => Number.parseInt(value, 16); describe("contract JSON Schema conversion", () => { + test("documents database overview runtime consistency checks", () => { + const schema = convertContractSchema( + databaseOverviewSchema, + "database.overview.output", + "output" + ); + expect(JSON.stringify(schema)).toContain( + "SQLite observation and stale timestamps to remain causally ordered" + ); + expect(JSON.stringify(schema)).toContain( + "applied migrations not to exceed the bundled graph" + ); + expect(JSON.stringify(schema)).toContain("128 KiB UTF-8 payload budget"); + expect(JSON.stringify(schema)).toContain( + "exact aggregate sizes and connection bounds" + ); + expect(JSON.stringify(schema)).toContain( + `"maximum":${String(Number.MAX_SAFE_INTEGER)}` + ); + }); + test("documents the fixed canonical Service Actions inventory", () => { expect( convertContractSchema( @@ -285,6 +307,7 @@ describe("contract JSON Schema conversion", () => { "cache:write", "chat:read", "chat:write", + "database:read", "files:read", "files:write", "gateway-sessions:read", @@ -310,7 +333,7 @@ describe("contract JSON Schema conversion", () => { "terminal:write", ], }, - maxItems: 29, + maxItems: 30, type: "array", uniqueItems: true, }); diff --git a/greenfield/scripts/documentation/jsonSchema.ts b/greenfield/scripts/documentation/jsonSchema.ts index 569277dc3..485549f8b 100644 --- a/greenfield/scripts/documentation/jsonSchema.ts +++ b/greenfield/scripts/documentation/jsonSchema.ts @@ -93,6 +93,14 @@ import { chatSpeechTranscriptFitsByteBudget, normalizeChatSpeechSynthesisText, } from "../../src/contracts/chatSpeech.ts"; +import { + databaseObservabilityCachePayloadIsConsistent, + databaseObservabilityNameFitsPostgresqlByteLimit, + databaseOverviewIsConsistent, + sqlitePageSizeIsPowerOfTwo, + sqliteStorageObservationIsConsistent, + sqliteMigrationStateIsConsistent, +} from "../../src/contracts/database.ts"; import { workspaceFileContentTicketIsConsistent, workspaceFileNameIsSafe, @@ -280,6 +288,30 @@ const controlSafeTextJsonSchemaPattern = `^(?![\\s\\S]*(?:${controlSafeTextExclu const noNulJsonSchemaPattern = String.raw`^[^\u0000]*$`; const runtimeCheckComments = new Map([ + [ + databaseObservabilityNameFitsPostgresqlByteLimit, + "Live Valibot validation additionally limits PostgreSQL identifiers to 63 UTF-8 bytes.", + ], + [ + databaseObservabilityCachePayloadIsConsistent, + "Live Valibot validation additionally enforces the PostgreSQL 128 KiB UTF-8 payload budget; deterministic unique database, table-health, and statement rows; exact aggregate sizes and connection bounds; and maintenance thresholds consistent with the visible observations.", + ], + [ + databaseOverviewIsConsistent, + "Live Valibot validation additionally requires SQLite observation and stale timestamps to remain causally ordered within the response check time, and enforces the PostgreSQL 128 KiB UTF-8 payload budget, canonical unique rows, contiguous statement ranks, exact aggregate sizes and connection bounds, and coherent maintenance assessment.", + ], + [ + sqliteMigrationStateIsConsistent, + "Live Valibot validation additionally requires applied migrations not to exceed the bundled graph and current status to agree with the counts.", + ], + [ + sqlitePageSizeIsPowerOfTwo, + "Live Valibot validation additionally requires the SQLite page size to be a power of two.", + ], + [ + sqliteStorageObservationIsConsistent, + "Live Valibot validation additionally requires SQLite file totals, reusable pages and bytes, percentages, permission security, and VACUUM review status to agree exactly.", + ], [ serviceActionStatusesAreCanonical, "Live Valibot validation additionally requires the six fixed service-action rows to be complete, unique, and canonically ordered.", @@ -1047,6 +1079,9 @@ export function convertContractSchema( overrideAction({ jsonSchema, valibotAction }) { const requirement = readActionRequirement(valibotAction); const operation = readActionOperation(valibotAction); + // JSON numbers are finite by definition: NaN and infinities are + // not valid JSON values. Valibot still enforces this at runtime. + if (valibotAction.type === "finite") return jsonSchema; // JSON Schema carries a pattern but no flags. The transport uses // Unicode mode only for ASCII-bounded expressions, for which // dropping the flag does not change accepted values. diff --git a/greenfield/scripts/files/boundedFile.ts b/greenfield/scripts/files/boundedFile.ts index cab76a247..07e6e9feb 100644 --- a/greenfield/scripts/files/boundedFile.ts +++ b/greenfield/scripts/files/boundedFile.ts @@ -2,6 +2,10 @@ import { constants, type BigIntStats } from "node:fs"; import { lstat, open, realpath } from "node:fs/promises"; import path from "node:path"; +// Linux UAPI O_CLOEXEC. The pinned Node types omit this runtime constant, +// while the descriptor-rooted reader below already depends on Linux /proc. +const linuxCloseOnExecFlag = 1 << 19; + export interface BoundedFileReadTestHooks { /** Holds the read after its initial descriptor stat for deterministic mutation tests. */ readonly afterInitialStat?: () => Promise | void; @@ -23,6 +27,9 @@ function invalidFileState(message: string): Error { function matchesSnapshot(before: BigIntStats, after: BigIntStats): boolean { return ( + after.isFile() && + !after.isSymbolicLink() && + after.nlink === 1n && after.dev === before.dev && after.ino === before.ino && after.size === before.size && @@ -77,7 +84,10 @@ export async function readBoundedRegularFile( const canonicalRoot = await realpath(requestedRoot); file = await open( requestedPath, - constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK + constants.O_RDONLY | + constants.O_NOFOLLOW | + constants.O_NONBLOCK | + linuxCloseOnExecFlag ); const descriptorPath = await realpath(`/proc/self/fd/${file.fd}`); if (!isContainedPath(canonicalRoot, descriptorPath)) { @@ -85,7 +95,12 @@ export async function readBoundedRegularFile( } const before = await file.stat({ bigint: true }); - if (!before.isFile() || before.size <= 0n || before.size > BigInt(maximumBytes)) { + if ( + !before.isFile() || + before.nlink !== 1n || + before.size <= 0n || + before.size > BigInt(maximumBytes) + ) { throw invalidFileState(invalidMessage); } await testHooks.afterInitialStat?.(); @@ -110,6 +125,7 @@ export async function readBoundedRegularFile( bytesRead !== expectedBytes || !matchesSnapshot(before, after) || !pathState.isFile() || + pathState.nlink !== 1n || !matchesSnapshot(before, pathState) ) { throw invalidFileState(invalidMessage); diff --git a/greenfield/scripts/generateDocs.test.ts b/greenfield/scripts/generateDocs.test.ts new file mode 100644 index 000000000..16abe14fb --- /dev/null +++ b/greenfield/scripts/generateDocs.test.ts @@ -0,0 +1,58 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { synchronizeGeneratedDocumentation } from "./generateDocs.ts"; +import { rejectionError } from "./testSupport/rejection.ts"; + +const temporaryDirectories: string[] = []; + +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { force: true, recursive: true })) + ); +}); + +async function repositoryFixture(): Promise { + const root = await mkdtemp(path.join(tmpdir(), "mira-generate-docs-")); + temporaryDirectories.push(root); + await Promise.all([ + mkdir(path.join(root, "docs", "generated"), { recursive: true }), + writeFile( + path.join(root, "package.json"), + `${JSON.stringify({ dependencies: {}, devDependencies: {} }, null, 2)}\n` + ), + writeFile(path.join(root, "bun.lock"), '{"packages":{}}\n'), + ]); + return root; +} + +describe("generated documentation synchronization", () => { + test("writes and then verifies the deterministic artifact tree", async () => { + const projectRoot = await repositoryFixture(); + + await synchronizeGeneratedDocumentation(projectRoot, "write"); + + expect( + Bun.file(path.join(projectRoot, "docs/generated/README.md")).exists() + ).resolves.toBe(true); + expect( + synchronizeGeneratedDocumentation(projectRoot, "check") + ).resolves.toBeUndefined(); + }); + + test("rejects stale generated documentation in check mode", async () => { + const projectRoot = await repositoryFixture(); + + const failure = await rejectionError( + synchronizeGeneratedDocumentation(projectRoot, "check") + ); + + expect(failure.message).toBe( + "Generated documentation file set is stale; run bun run docs:generate" + ); + }); +}); diff --git a/greenfield/scripts/generateDocs.ts b/greenfield/scripts/generateDocs.ts index b0d1993ee..a63401e7d 100644 --- a/greenfield/scripts/generateDocs.ts +++ b/greenfield/scripts/generateDocs.ts @@ -14,22 +14,42 @@ const packageManifestSchema = v.object({ devDependencies: v.record(v.string(), v.string()), }); -const projectRoot = path.resolve(import.meta.dir, ".."); -const outputDirectory = path.join(projectRoot, "docs", "generated"); -const packageManifest = v.parse( - packageManifestSchema, - await Bun.file(path.join(projectRoot, "package.json")).json() -); -const resolvedVersions = resolveDirectPackageVersions( - [packageManifest.dependencies, packageManifest.devDependencies], - await Bun.file(path.join(projectRoot, "bun.lock")).text() -); -const artifacts = buildDocumentationArtifacts({ - ...packageManifest, - resolvedVersions, -}); +/** Documentation synchronization mode selected by the repository command. */ +export type DocumentationSynchronizationMode = "check" | "write"; + +/** + * Builds and synchronizes the generated documentation for one repository checkout. + * @param projectRoot Canonical future-root checkout. + * @param mode Whether to verify or write the generated artifact tree. + * @returns Completion after the selected documentation operation succeeds. + */ +export async function synchronizeGeneratedDocumentation( + projectRoot: string, + mode: DocumentationSynchronizationMode +): Promise { + const packageManifest = v.parse( + packageManifestSchema, + await Bun.file(path.join(projectRoot, "package.json")).json() + ); + const resolvedVersions = resolveDirectPackageVersions( + [packageManifest.dependencies, packageManifest.devDependencies], + await Bun.file(path.join(projectRoot, "bun.lock")).text() + ); + const artifacts = buildDocumentationArtifacts({ + ...packageManifest, + resolvedVersions, + }); + const synchronizeDocumentation = + mode === "check" ? checkDocumentationArtifacts : writeDocumentationArtifacts; + await synchronizeDocumentation( + path.join(projectRoot, "docs", "generated"), + artifacts + ); +} -const synchronizeDocumentation = process.argv.includes("--check") - ? checkDocumentationArtifacts - : writeDocumentationArtifacts; -await synchronizeDocumentation(outputDirectory, artifacts); +if (import.meta.main) { + await synchronizeGeneratedDocumentation( + path.resolve(import.meta.dir, ".."), + process.argv.includes("--check") ? "check" : "write" + ); +} diff --git a/greenfield/scripts/runCoverage.test.ts b/greenfield/scripts/runCoverage.test.ts index 5f6c4232d..a4bfc79a3 100644 --- a/greenfield/scripts/runCoverage.test.ts +++ b/greenfield/scripts/runCoverage.test.ts @@ -10,8 +10,10 @@ import { describe("coverage runner", () => { test("keeps Bun coverage free of browser globals", () => { expect(createCoverageTestArguments("/tmp/coverage-output", "bun")).toEqual([ + "--timings=.bun-test-timings.json", "--bail=1", "--only-failures", + "--parallel=3", "--coverage", "--coverage-reporter", "lcov", @@ -27,9 +29,12 @@ describe("coverage runner", () => { ]); }); - test("runs browser coverage in one isolated process", () => { + test("runs browser coverage with three isolated workers", () => { expect(createCoverageTestArguments("/tmp/coverage-browser", "browser")).toEqual([ + "--timings=.bun-browser-test-timings.json", + "--bail=1", "--only-failures", + "--parallel=3", "--coverage", "--coverage-reporter", "lcov", @@ -37,7 +42,6 @@ describe("coverage runner", () => { "/tmp/coverage-browser", "--preload", "./src/browser/test/setup.ts", - "--bail=1", "src/browser", ]); }); @@ -95,7 +99,12 @@ describe("coverage runner", () => { expect(mergedPatterns).toEqual(["/tmp/coverage/*/lcov.info"]); expect(writes).toEqual([["/tmp/coverage/lcov.info", "TN:\nend_of_record"]]); expect(checks).toHaveLength(1); - expect(checks[0]?.[0]).toBe("/tmp/coverage/lcov.info"); + expect(checks[0]).toEqual([ + "/tmp/coverage/lcov.info", + "85", + "scripts,src,drizzle.config.ts,tailwind.config.ts", + "/tmp/project", + ]); expect(logs).toEqual(["Coverage 90.00% meets required 85.00% (18/20 lines)"]); }); diff --git a/greenfield/scripts/runCoverage.ts b/greenfield/scripts/runCoverage.ts index fd63a7cfd..aa19d4714 100644 --- a/greenfield/scripts/runCoverage.ts +++ b/greenfield/scripts/runCoverage.ts @@ -12,7 +12,15 @@ import { runTestSuite } from "./runTestSuite.ts"; const projectRoot = path.resolve(import.meta.dir, ".."); const coverageDirectory = path.join(projectRoot, "coverage"); -const coveredSourceRoots = Object.freeze(["src"]); +const coveredSourceRoots = Object.freeze([ + "scripts", + "src", + "drizzle.config.ts", + "tailwind.config.ts", +]); +const bunTestParallelProcesses = 3; +const browserTestTimingsFile = ".bun-browser-test-timings.json"; +const bunTestTimingsFile = ".bun-test-timings.json"; const browserTestPreload = "./src/browser/test/setup.ts"; /** Independently executed test process contributing to the merged LCOV artifact. */ @@ -79,7 +87,9 @@ export function createCoverageTestArguments( partition: CoveragePartition ): readonly string[] { const coverageArguments = [ + "--bail=1", "--only-failures", + `--parallel=${bunTestParallelProcesses}`, "--coverage", "--coverage-reporter", "lcov", @@ -88,15 +98,15 @@ export function createCoverageTestArguments( ]; if (partition === "browser") { return Object.freeze([ + `--timings=${browserTestTimingsFile}`, ...coverageArguments, "--preload", browserTestPreload, - "--bail=1", "src/browser", ]); } return Object.freeze([ - "--bail=1", + `--timings=${bunTestTimingsFile}`, ...coverageArguments, "scripts", "src/app", diff --git a/greenfield/scripts/sourceBoundaries/policy.test.ts b/greenfield/scripts/sourceBoundaries/policy.test.ts index 810f9322a..11ae0b4f4 100644 --- a/greenfield/scripts/sourceBoundaries/policy.test.ts +++ b/greenfield/scripts/sourceBoundaries/policy.test.ts @@ -1,7 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { fileURLToPath } from "node:url"; -import { checkSourceBoundaries } from "../checkSourceBoundaries.ts"; import { validateDeclaredPackageImport, validateSourceAmbientRuntimeDeclaration, @@ -751,10 +749,4 @@ describe("source-boundary policy", () => { undefined ); }); - - test("accepts the complete current repository graph", async () => { - const projectRootUrl = new URL("../..", import.meta.url); - const violations = await checkSourceBoundaries(fileURLToPath(projectRootUrl)); - expect(violations).toEqual([]); - }, 60_000); }); diff --git a/greenfield/scripts/sourceBoundaries/sourceTopologyPolicy.ts b/greenfield/scripts/sourceBoundaries/sourceTopologyPolicy.ts index 64e74a696..ade5587d5 100644 --- a/greenfield/scripts/sourceBoundaries/sourceTopologyPolicy.ts +++ b/greenfield/scripts/sourceBoundaries/sourceTopologyPolicy.ts @@ -181,6 +181,7 @@ export function relativeImportTarget(importer: string, specifier: string): strin export function isTestPath(filePath: string): boolean { return ( /(?:^|\/)(?:__tests__|test(?:Support)?)\//u.test(filePath) || + /(?:^|\/)testSupport\.[cm]?[jt]sx?$/u.test(filePath) || /\.(?:spec|test)\.[cm]?[jt]sx?$/u.test(filePath) ); } diff --git a/greenfield/scripts/testSupport/productionDeliveryFixture.ts b/greenfield/scripts/testSupport/productionDeliveryFixture.ts index 20fade71c..6de4f4727 100644 --- a/greenfield/scripts/testSupport/productionDeliveryFixture.ts +++ b/greenfield/scripts/testSupport/productionDeliveryFixture.ts @@ -34,6 +34,7 @@ import { import type { ReleaseRuntimeIdentity } from "../delivery/releaseIdentity.ts"; const encoder = new TextEncoder(); +const documentationFixture = "# Production delivery fixture\n"; async function restoreOwnerWrite(directory: string): Promise { const status = await stat(directory).catch(() => null); @@ -110,11 +111,11 @@ export async function createLocalReleaseFixture( path.join(tmpdir(), "mira-release-activation-source-") ); temporaryDirectories.push(repositoryRoot); + await mkdir(path.join(repositoryRoot, "docs/generated"), { recursive: true }); await Promise.all([ - cp( - path.join(sourceProjectRoot, "docs/generated"), - path.join(repositoryRoot, "docs/generated"), - { recursive: true } + writeFile( + path.join(repositoryRoot, "docs/generated/README.md"), + documentationFixture ), cp( path.join(sourceProjectRoot, "migrations"), @@ -127,13 +128,8 @@ export async function createLocalReleaseFixture( { recursive: true } ), cp( - path.join(sourceProjectRoot, "scripts/delivery/provisioning/host-operations"), - path.join(repositoryRoot, "scripts/delivery/provisioning/host-operations"), - { recursive: true } - ), - cp( - path.join(sourceProjectRoot, "scripts/delivery/provisioning/log-maintenance"), - path.join(repositoryRoot, "scripts/delivery/provisioning/log-maintenance"), + path.join(sourceProjectRoot, "scripts/delivery/provisioning"), + path.join(repositoryRoot, "scripts/delivery/provisioning"), { recursive: true } ), cp( @@ -154,7 +150,7 @@ export async function createLocalReleaseFixture( state: "clean", }); const release = await buildDashboardRelease(repositoryRoot, { - resolveSourceIdentity: () => cleanSource, + resolveSourceIdentity: () => Promise.resolve(cleanSource), runCommand: materializeCommandOutput, runtimeIdentity, }); diff --git a/greenfield/src/app/dashboardServer.test.ts b/greenfield/src/app/dashboardServer.test.ts index c383cc3d2..470637e55 100644 --- a/greenfield/src/app/dashboardServer.test.ts +++ b/greenfield/src/app/dashboardServer.test.ts @@ -14,6 +14,11 @@ import { } from "../contracts/cache.ts"; import { chatHistoryOutputSchema, type ChatHistoryOutput } from "../contracts/chat.ts"; import { chatHistoryRetainedPageMaximum } from "../contracts/chatModel.ts"; +import { + type DatabaseObservabilityCachePayload, + databaseObservabilityCacheSchemaId, + databaseOverviewSchema, +} from "../contracts/database.ts"; import { type GatewaySession, deriveGatewaySessionStats, @@ -32,6 +37,8 @@ import { import { listSchedulesResultSchema } from "../contracts/schedules.ts"; import { authSessions } from "../server/database/schema/authSessions.ts"; import { automationPrincipalCapabilities } from "../server/database/schema/automationPrincipalCapabilities.ts"; +import { cacheEntries } from "../server/database/schema/cacheEntries.ts"; +import { jobRuns } from "../server/database/schema/jobRuns.ts"; import { users } from "../server/database/schema/users.ts"; import { automationPrincipalCapabilityInsertSchema } from "../server/database/validation/automationPrincipalCapabilities.ts"; import type { JobRunRecord } from "../server/domains/jobs/records.ts"; @@ -80,6 +87,55 @@ import { import { createDashboardTerminalComposition } from "./dashboardTerminal.ts"; const mediaRefreshObservedAtMs = 1_800_000_000_000; +const dashboardServerTestPostgresqlDatabases = ["alpha", "comet", "postgres"] as const; +const dashboardServerTestPostgresqlSnapshot = Object.freeze({ + databases: dashboardServerTestPostgresqlDatabases.map((name) => ({ + blocksHit: 99, + blocksRead: 1, + cacheHitRatio: 99, + committedTransactions: name === "comet" ? 100 : 0, + connections: name === "comet" ? 2 : 0, + detailsState: "available" as const, + name, + rolledBackTransactions: name === "comet" ? 1 : 0, + sizeBytes: name === "comet" ? 4096 : 0, + })), + pgbouncer: { + averageQueryMs: 5, + averageTransactionMs: 8, + clientConnections: 2, + maxWaitSeconds: 0, + serverConnections: 1, + waitingClients: 0, + }, + statements: [], + summary: { + activeConnections: 1, + averageCacheHitRatio: 99, + idleConnections: 1, + maintenance: { + assessedPhysicalBytes: 0, + assessmentComplete: true, + estimatedReclaimableBytes: 0, + estimatedReclaimablePercent: 0, + highDeadTupleTableCount: 0, + requiresBloatReview: false, + slowStatementCount: 0, + status: "not-assessed", + unassessedPhysicalBytes: 0, + unassessedTableCount: 0, + }, + pgStatStatementsEnabled: false, + totalConnections: 2, + totalDatabaseSizeBytes: 4096, + unavailableDatabaseCount: 0, + }, + tableHealth: [], + torrentCounts: { + bitmagnet: { state: "unavailable" }, + comet: { count: 42, state: "available" }, + }, +} as const satisfies DatabaseObservabilityCachePayload); function unavailableDashboardGatewayRequest(): Promise { return Promise.reject(new Error("unused Gateway operation")); @@ -1428,6 +1484,8 @@ describe("Dashboard security composition", () => { }, }), database: Object.freeze({ + diagnostics: () => + Promise.reject(new Error("Database must not be reached")), orm: () => Promise.reject(new Error("Database must not be reached")), run: runTestImmediateDatabaseWrite, }), @@ -1465,6 +1523,8 @@ describe("Dashboard security composition", () => { }, }), database: Object.freeze({ + diagnostics: () => + Promise.reject(new Error("Database must not be reached")), orm: () => { ormCalls += 1; return Promise.reject(new Error("Database must not be reached")); @@ -1515,15 +1575,94 @@ describe("Dashboard security composition", () => { database, authenticationTestNow ); + const databaseObservationRunId = "019f6212-0300-7000-8000-000000000001"; + const databaseObservationQueuedAt = new Date( + authenticationTestNow.getTime() - 2000 + ); + const databaseObservationStartedAt = new Date( + authenticationTestNow.getTime() - 1000 + ); + database + .insert(jobRuns) + .values({ + actionKey: "cache.refresh.database-observability", + attemptCount: 1, + attemptLimit: 3, + availableAt: databaseObservationQueuedAt, + cancellationPolicy: "cooperative", + cancelRequestedAt: null, + cancelRequestedById: null, + cancelRequestedByKind: null, + displayName: "Database observability cache", + enqueueSha256: "a".repeat(64), + eventBytes: 0, + eventCount: 0, + finishedAt: authenticationTestNow, + firstStartedAt: databaseObservationStartedAt, + heartbeatAt: null, + id: databaseObservationRunId, + idempotencyKey: "A".repeat(32), + lastAttemptStartedAt: databaseObservationStartedAt, + leaseExpiresAt: null, + leaseOwnerId: null, + leaseToken: null, + payloadEventCount: 0, + payloadJson: '{"key":"database.observability"}', + priority: 0, + queuedAt: databaseObservationQueuedAt, + requestedById: "system.database-observability", + requestedByKind: "system", + resourceClass: "light", + resourceKeysJson: '["network.database-observability"]', + resultJson: "{}", + retrySafe: true, + scheduledForAt: null, + scheduledJobId: null, + scheduledJobVersion: null, + state: "succeeded", + stateVersion: 2, + terminalCode: null, + terminalMessage: null, + timeoutMs: 65_000, + triggerType: "system", + updatedAt: authenticationTestNow, + }) + .run(); + database + .insert(cacheEntries) + .values({ + consecutiveFailures: 0, + expiresAt: new Date(authenticationTestNow.getTime() + 90 * 60_000), + failureCode: null, + failureMessage: null, + key: "database.observability", + lastAttemptAt: authenticationTestNow, + lastAttemptDurationMs: 100, + lastAttemptNumber: 1, + lastAttemptRunId: databaseObservationRunId, + lastAttemptStatus: "succeeded", + lastSuccessAt: authenticationTestNow, + metadataJson: "{}", + payloadJson: JSON.stringify(dashboardServerTestPostgresqlSnapshot), + schemaId: databaseObservabilityCacheSchemaId, + source: "postgresql.pgbouncer", + updatedAt: authenticationTestNow, + }) + .run(); database .insert(automationPrincipalCapabilities) - .values( + .values([ + v.parse(automationPrincipalCapabilityInsertSchema, { + capability: "cache:read", + grantedAt: authenticationTestNow, + principalId: authenticationTestPrincipalId, + }), v.parse(automationPrincipalCapabilityInsertSchema, { capability: "monitoring:write", grantedAt: authenticationTestNow, principalId: authenticationTestPrincipalId, - }) - ) + }), + ]) .run(); server = await createDashboardServer({ applicationRuntime, @@ -1534,6 +1673,59 @@ describe("Dashboard security composition", () => { readiness: createReadinessController(), totpSecretCipher: testTotpSecretCipher, }); + const databaseResponse = await fetch( + new URL("/trpc/database.overview", server.url), + { + headers: { + cookie: `${dashboardSessionCookieName}=${fixture.session.token}`, + }, + } + ); + expect(databaseResponse.status).toBe(200); + const databaseOverview = v.parse( + databaseOverviewSchema, + await readTrpcResult(databaseResponse) + ); + expect(databaseOverview).toMatchObject({ + checkedAtMs: authenticationTestNow.getTime(), + postgresql: { + observedAtMs: authenticationTestNow.getTime(), + state: "fresh", + torrentCounts: { + bitmagnet: { state: "unavailable" }, + comet: { count: 42, state: "available" }, + }, + }, + sqlite: { + migrations: { current: true }, + observedAtMs: authenticationTestNow.getTime(), + state: "fresh", + }, + }); + expect( + databaseOverview.postgresql.state === "unavailable" + ? undefined + : databaseOverview.postgresql.databases.find( + ({ name }) => name === "comet" + ) + ).toMatchObject({ name: "comet", sizeBytes: 4096 }); + expect(JSON.stringify(databaseOverview)).not.toContain(stateDirectory); + expect(JSON.stringify(databaseOverview)).not.toContain("initialize-empty"); + expect(JSON.stringify(databaseOverview)).not.toContain("postgresql://"); + expect(JSON.stringify(databaseOverview)).not.toContain("SELECT"); + const cacheInput = encodeURIComponent( + JSON.stringify({ json: { key: "database.observability" } }) + ); + const genericCacheResponse = await fetch( + new URL(`/trpc/cache.getEntry?input=${cacheInput}`, server.url), + { + headers: { + authorization: `Bearer ${fixture.automation.token}`, + }, + } + ); + expect(genericCacheResponse.status).toBe(404); + expect(await genericCacheResponse.text()).not.toContain("comet"); const input = encodeURIComponent(JSON.stringify({ json: {} })); const response = await fetch( new URL( @@ -1562,7 +1754,7 @@ describe("Dashboard security composition", () => { result.principals.find(({ id }) => id === authenticationTestPrincipalId) ).toMatchObject({ activeCredentialCount: 1, - capabilities: ["monitoring:write", "reports:read"], + capabilities: ["cache:read", "monitoring:write", "reports:read"], disabled: false, id: authenticationTestPrincipalId, }); @@ -1698,27 +1890,39 @@ describe("Dashboard security composition", () => { scheduleBody.result?.data?.json ); expect(schedules.schedules.map(({ id }) => id)).toEqual([ + "cache.database-observability", "cache.moltbook-dashboard", "cache.system-host", + "database.sqlite-maintenance", "maintenance.rotate-managed-logs", "system.worker-smoke", ]); expect(schedules.schedules[0]).toMatchObject({ + actionKey: "cache.refresh.database-observability", + enabled: true, + id: "cache.database-observability", + }); + expect(schedules.schedules[1]).toMatchObject({ actionKey: "cache.refresh.moltbook-dashboard", enabled: true, id: "cache.moltbook-dashboard", }); - expect(schedules.schedules[1]).toMatchObject({ + expect(schedules.schedules[2]).toMatchObject({ actionKey: "cache.refresh.system-host", enabled: true, id: "cache.system-host", }); - expect(schedules.schedules[2]).toMatchObject({ + expect(schedules.schedules[3]).toMatchObject({ + actionKey: "database.sqlite-maintenance", + enabled: true, + id: "database.sqlite-maintenance", + }); + expect(schedules.schedules[4]).toMatchObject({ actionKey: "maintenance.rotate-logs", enabled: true, id: "maintenance.rotate-managed-logs", }); - expect(schedules.schedules[3]).toMatchObject({ + expect(schedules.schedules[5]).toMatchObject({ actionKey: "system.worker-smoke", enabled: false, id: "system.worker-smoke", @@ -1779,6 +1983,11 @@ describe("Dashboard security composition", () => { ) : [] ).toEqual([ + { + defaultEnabled: true, + id: "cache.database-observability", + state: "present", + }, { defaultEnabled: true, id: "cache.moltbook-dashboard", @@ -1789,6 +1998,11 @@ describe("Dashboard security composition", () => { id: "cache.system-host", state: "present", }, + { + defaultEnabled: true, + id: "database.sqlite-maintenance", + state: "present", + }, { defaultEnabled: true, id: "maintenance.rotate-managed-logs", diff --git a/greenfield/src/app/dashboardServer.ts b/greenfield/src/app/dashboardServer.ts index 1a141ed93..d4cad3d94 100644 --- a/greenfield/src/app/dashboardServer.ts +++ b/greenfield/src/app/dashboardServer.ts @@ -21,6 +21,9 @@ import { createChatRepository } from "../server/domains/chat/repository.ts"; import { createChatService, type ChatService } from "../server/domains/chat/service.ts"; import { chatSessionSubscriptionIdleMilliseconds } from "../server/domains/chat/subscriptionManager.ts"; import { createChatTranscriptLifecycleCoordinator } from "../server/domains/chat/transcriptLifecycle.ts"; +import { createDatabaseObservabilityService } from "../server/domains/database/service.ts"; +import { createDatabaseObservabilitySnapshotRepository } from "../server/domains/database/snapshotRepository.ts"; +import { createSqliteLifecycleReader } from "../server/domains/database/sqliteLifecycle.ts"; import { createWorkspaceFileJobScheduler } from "../server/domains/files/jobScheduler.ts"; import type { WorkspaceFileRootConfiguration } from "../server/domains/files/ports.ts"; import { @@ -221,6 +224,7 @@ export interface DashboardServerOptions extends Omit< | "cacheService" | "chatRawHttpHandler" | "chatService" + | "databaseObservabilityService" | "workspaceFileRawHttpHandler" | "workspaceFilesService" | "disposeBeforeRuntime" @@ -252,6 +256,8 @@ export interface DashboardServerOptions extends Omit< readonly dashboardLogMaintenanceRoot?: string; /** Optional only for isolated composition tests; production always supplies it. */ readonly dashboardLogsRoot?: string; + /** Private production state root used only by the fixed SQLite lifecycle reader. */ + readonly databaseStateDirectory?: string; /** Optional server-only speech credential; absence keeps both voice controls hidden. */ readonly elevenLabsApiKey?: Redacted.Redacted; /** Direct-loopback endpoint shared by bootstrap verification and persistent Gateway traffic. */ @@ -662,6 +668,28 @@ export async function createDashboardServer( await options.applicationRuntime.initialize(); const databaseRuntime = options.applicationRuntime.database; const database = await databaseRuntime.orm(); + const cacheRepository = createCacheRepository(database, databaseRuntime); + const jobRepository = createJobRepository(database, databaseRuntime); + const observabilityNow = options.now; + const databaseObservabilityService = createDatabaseObservabilityService({ + ...(observabilityNow === undefined + ? {} + : { nowMs: () => observabilityNow().getTime() }), + readDiagnostics: databaseRuntime.diagnostics, + ...(options.databaseStateDirectory === undefined + ? {} + : { + lifecycleReader: createSqliteLifecycleReader({ + ...(observabilityNow === undefined + ? {} + : { nowMs: () => observabilityNow().getTime() }), + repository: jobRepository, + stateDirectory: options.databaseStateDirectory, + }), + }), + snapshotRepository: + createDatabaseObservabilitySnapshotRepository(cacheRepository), + }); const repository = createRequestAuthenticationRepository(database); const authenticator = createRequestAuthenticator({ authenticationLeaseDurationMs: options.authenticationLeaseDurationMs, @@ -752,7 +780,6 @@ export async function createDashboardServer( repository: taskRepository, wakeEventPump, }); - const jobRepository = createJobRepository(database, databaseRuntime); await reconcileJobSchedules({ ...(domainNow === undefined ? {} : { nowMs: () => domainNow().getTime() }), repository: jobRepository, @@ -1347,7 +1374,7 @@ export async function createDashboardServer( }); openClawCronHeartbeatReader = openClawCronService; const cacheService = createCacheService({ - cacheRepository: createCacheRepository(database, databaseRuntime), + cacheRepository, jobRepository, ...(domainNow === undefined ? {} : { nowMs: () => domainNow().getTime() }), readGatewayConnection: gatewayConnectionService.get, @@ -1406,6 +1433,7 @@ export async function createDashboardServer( cacheService, ...(chatRawHttpHandler === undefined ? {} : { chatRawHttpHandler }), ...(chatService === undefined ? {} : { chatService }), + databaseObservabilityService, ...(workspaceFileRawHttpHandler === undefined ? {} : { workspaceFileRawHttpHandler }), @@ -1631,6 +1659,7 @@ export async function runDashboardWebProcess( browserOrigin: configuration.publicOrigin, dashboardLogMaintenanceRoot: layout.production.state.logMaintenance, dashboardLogsRoot: layout.production.state.logs, + databaseStateDirectory: layout.production.state.root, ...(configuration.elevenLabsApiKey === undefined ? {} : { elevenLabsApiKey: configuration.elevenLabsApiKey }), diff --git a/greenfield/src/app/databaseMaintenance.test.ts b/greenfield/src/app/databaseMaintenance.test.ts index 718e78295..d1eaadde3 100644 --- a/greenfield/src/app/databaseMaintenance.test.ts +++ b/greenfield/src/app/databaseMaintenance.test.ts @@ -113,4 +113,58 @@ describe("Dashboard database maintenance process", () => { expect(result).toEqual({ state: "absent", transitionId }); }); + + test("routes only the fixed online SQLite maintenance argument shape", async () => { + const transitionId = Bun.randomUUIDv7(); + const command = parseDatabaseMaintenanceArguments([ + "--operation=sqlite-maintenance", + `--transition=${transitionId}`, + `--release=${releaseId}`, + "--state=/srv/mira/production/state", + "--migrations=/srv/mira/releases/release/migrations", + ]); + expect(command).toEqual({ + migrationsDirectory: "/srv/mira/releases/release/migrations", + operation: "sqlite-maintenance", + releaseId, + stateDirectory: "/srv/mira/production/state", + transitionId, + }); + const expected = { + backupBytes: 4096, + backupCreatedAtMs: Date.now(), + checkpoint: { busyFrames: 0, checkpointedFrames: 2, logFrames: 2 }, + completedAtMs: Date.now(), + retainedBackupBytes: 4096, + retainedBackupCount: 1, + status: "completed" as const, + }; + const result = await runDashboardDatabaseMaintenance(command, { + createRuntime() { + throw new Error("Unexpected runtime construction"); + }, + createSnapshot: unexpectedSnapshot, + createSqliteMaintenance(observed) { + expect(observed).toEqual({ + migrationsDirectory: "/srv/mira/releases/release/migrations", + releaseId, + stateDirectory: "/srv/mira/production/state", + transitionId, + }); + return Promise.resolve(expected); + }, + }); + expect(result).toEqual(expected); + + expect(() => + parseDatabaseMaintenanceArguments([ + "--operation=sqlite-maintenance", + `--transition=${transitionId}`, + `--release=${releaseId}`, + "--state=/srv/mira/production/state", + "--migrations=/srv/mira/releases/release/migrations", + "--path=/tmp/unreviewed", + ]) + ).toThrow("Usage:"); + }); }); diff --git a/greenfield/src/app/databaseMaintenance.ts b/greenfield/src/app/databaseMaintenance.ts index 352af8d42..00b764702 100644 --- a/greenfield/src/app/databaseMaintenance.ts +++ b/greenfield/src/app/databaseMaintenance.ts @@ -9,15 +9,18 @@ import { } from "../server/database/runtime/databaseCandidateMigrationOwner.ts"; import type { DatabaseCandidateMigrationLayerOptions } from "../server/database/runtime/databaseService.ts"; import { + createVerifiedSqliteMaintenanceSnapshot, createVerifiedDatabaseSnapshot, type DatabaseSnapshotOptions, type DatabaseSnapshotResult, + type SqliteMaintenanceSnapshotOptions, + type SqliteMaintenanceSnapshotResult, } from "../server/database/runtime/databaseSnapshot.ts"; import { fullCommitShaSchema, lowercaseUuidV7Schema } from "../shared/validation.ts"; const databaseMaintenanceFailureMessage = "Dashboard database maintenance failed"; const databaseMaintenanceUsage = - "Usage: bun database-maintenance.js --operation=migrate-candidate|snapshot with the exact operation arguments"; + "Usage: bun database-maintenance.js --operation=migrate-candidate|snapshot|sqlite-maintenance with the exact operation arguments"; const absolutePathSchema = v.pipe( v.string(), v.maxLength(4096), @@ -51,11 +54,19 @@ const snapshotArgumentsSchema = v.variant("expectedState", [ transitionId: lowercaseUuidV7Schema(databaseMaintenanceUsage), }), ]); +const sqliteMaintenanceArgumentsSchema = v.strictObject({ + migrationsDirectory: absolutePathSchema, + operation: v.literal("sqlite-maintenance"), + releaseId: fullCommitShaSchema(databaseMaintenanceUsage), + stateDirectory: absolutePathSchema, + transitionId: lowercaseUuidV7Schema(databaseMaintenanceUsage), +}); /** Validated command for candidate migration or live-state snapshot creation. */ export type DashboardDatabaseMaintenanceCommand = | Readonly> - | Readonly>; + | Readonly> + | Readonly>; /** Injectable retained-runtime boundary used by deterministic lifecycle tests. */ export interface DashboardDatabaseMaintenanceDependencies { @@ -65,12 +76,17 @@ export interface DashboardDatabaseMaintenanceDependencies { readonly createSnapshot: ( options: DatabaseSnapshotOptions ) => Promise; + readonly createSqliteMaintenance?: ( + options: SqliteMaintenanceSnapshotOptions + ) => Promise; } const defaultDependencies = Object.freeze({ createRuntime: createDatabaseCandidateMigrationOwner, createSnapshot: (options: DatabaseSnapshotOptions) => Effect.runPromise(createVerifiedDatabaseSnapshot(options)), + createSqliteMaintenance: (options: SqliteMaintenanceSnapshotOptions) => + Effect.runPromise(createVerifiedSqliteMaintenanceSnapshot(options)), } satisfies DashboardDatabaseMaintenanceDependencies); function databaseMaintenanceFailure(error?: unknown): Error { @@ -128,13 +144,26 @@ export function parseDatabaseMaintenanceArguments( stateDirectory: readArgument(arguments_, "state"), transitionId: readArgument(arguments_, "transition"), }; + } else if (operation === "sqlite-maintenance" && arguments_.length === 5) { + candidate = { + migrationsDirectory: readArgument(arguments_, "migrations"), + operation, + releaseId: readArgument(arguments_, "release"), + stateDirectory: readArgument(arguments_, "state"), + transitionId: readArgument(arguments_, "transition"), + }; } else { throw new TypeError(databaseMaintenanceUsage); } - const schema = - operation === "migrate-candidate" - ? candidateMigrationArgumentsSchema - : snapshotArgumentsSchema; + const schema = (() => { + if (operation === "migrate-candidate") { + return candidateMigrationArgumentsSchema; + } + if (operation === "sqlite-maintenance") { + return sqliteMaintenanceArgumentsSchema; + } + return snapshotArgumentsSchema; + })(); const parsed = v.safeParse(schema, candidate, { abortEarly: true, }); @@ -151,11 +180,18 @@ export function parseDatabaseMaintenanceArguments( export async function runDashboardDatabaseMaintenance( command: DashboardDatabaseMaintenanceCommand, dependencies: DashboardDatabaseMaintenanceDependencies = defaultDependencies -): Promise { +): Promise { if (command.operation === "snapshot") { const { operation: _operation, ...options } = command; return dependencies.createSnapshot(options); } + if (command.operation === "sqlite-maintenance") { + const { operation: _operation, ...options } = command; + return ( + dependencies.createSqliteMaintenance ?? + defaultDependencies.createSqliteMaintenance + )(options); + } const { operation: _operation, ...options } = command; const runtime = dependencies.createRuntime(options); let failure: Error | undefined; @@ -177,13 +213,15 @@ if (import.meta.main) { try { const options = parseDatabaseMaintenanceArguments(Bun.argv.slice(2)); const result = await runDashboardDatabaseMaintenance(options); - process.stdout.write( - `${JSON.stringify( - result === undefined - ? { status: "MAINTAINED" } - : { ...result, status: "SNAPSHOT" } - )}\n` - ); + let output: unknown; + if (result === undefined) { + output = { status: "MAINTAINED" }; + } else if ("checkpoint" in result) { + output = { processStatus: "SQLITE_MAINTENANCE", result }; + } else { + output = { ...result, status: "SNAPSHOT" }; + } + process.stdout.write(`${JSON.stringify(output)}\n`); } catch (error) { const message = error instanceof TypeError diff --git a/greenfield/src/app/developmentWorker.ts b/greenfield/src/app/developmentWorker.ts index 46039d4d7..f8194ad1b 100644 --- a/greenfield/src/app/developmentWorker.ts +++ b/greenfield/src/app/developmentWorker.ts @@ -10,6 +10,8 @@ import { developmentStartupFailureMessage, parseDevelopmentSourceCommit, } from "../shared/developmentProcessSupport.ts"; +import { createUnavailableDatabaseObservabilityCollector } from "../worker/database/bunSqlDatabaseObservabilityCollector.ts"; +import { createFixedSqliteLifecycleMaintenance } from "../worker/database/fixedSqliteLifecycleMaintenance.ts"; import { developmentTaskNotificationLoop } from "../worker/developmentTaskNotifications.ts"; import { createDescriptorWorkspaceFileStructuralWriter } from "../worker/files/descriptorWorkspaceFileStructuralWriter.ts"; import { createDevelopmentLogMaintenanceExecutor } from "../worker/logs/developmentLogMaintenance.ts"; @@ -51,6 +53,8 @@ export async function runDevelopmentWorkerProcess( openClawRoot, logMaintenance, moltbook, + _databaseObservability, + _databaseObservabilityReconciler, hostOperations, bootIdentity ) => { @@ -66,6 +70,7 @@ export async function runDevelopmentWorkerProcess( startupMode: "initialize-empty", stateDirectory: layout.production.state.root, }, + databaseObservability: createUnavailableDatabaseObservabilityCollector(), logMaintenance, moltbook, ...(openClawGateway === undefined ? {} : { openClawGateway }), @@ -77,6 +82,16 @@ export async function runDevelopmentWorkerProcess( pid: process.pid, releaseId: source.manifest.source.commitSha, sideEffects: createSystemJobWorkerSideEffects(), + sqliteMaintenance: createFixedSqliteLifecycleMaintenance({ + migrationsDirectory: path.join(source.releaseRoot, "migrations"), + releaseId: source.manifest.source.commitSha, + releaseRoot: source.releaseRoot, + scriptPath: path.join( + source.releaseRoot, + "src/app/databaseMaintenance.ts" + ), + stateDirectory: layout.production.state.root, + }), taskNotificationLoop: developmentTaskNotificationLoop, workerInstanceId: Bun.randomUUIDv7(), workspaceFiles: writer, diff --git a/greenfield/src/app/server.ts b/greenfield/src/app/server.ts index c0e070d3d..d7f7f6d79 100644 --- a/greenfield/src/app/server.ts +++ b/greenfield/src/app/server.ts @@ -7,6 +7,7 @@ import { healthLivenessPath, healthReadinessPath } from "../contracts/system.ts" import type { AgentService } from "../server/domains/agents/service.ts"; import type { CacheService } from "../server/domains/cache/service.ts"; import type { ChatService } from "../server/domains/chat/service.ts"; +import type { DatabaseObservabilityService } from "../server/domains/database/service.ts"; import type { WorkspaceFileRawHttpHandler } from "../server/domains/files/rawHttp.ts"; import type { WorkspaceFilesService } from "../server/domains/files/service.ts"; import type { GatewayConnectionService } from "../server/domains/gatewayConnection/service.ts"; @@ -198,6 +199,7 @@ export interface ServerOptions { /** Raw attachment/media routes mounted before browser asset fallback. */ readonly chatRawHttpHandler?: ChatRawHttpHandler; readonly chatService?: ChatService; + readonly databaseObservabilityService: DatabaseObservabilityService; readonly workspaceFilesService?: WorkspaceFilesService; /** Ticket-bound Files GET/HEAD/PUT routes mounted before browser asset fallback. */ readonly workspaceFileRawHttpHandler?: WorkspaceFileRawHttpHandler; @@ -267,6 +269,7 @@ export async function createServer(options: ServerOptions): Promise + Promise.reject( + new Error("Database discovery is not used by this fixture") + ), + }); + }, + createDatabaseObservabilityReconciler(options) { + events.push("database-observability-reconciler-create"); + expect(options).toEqual({ + bunExecutable: path.join( + layout.production.runtimes, + "bun", + revision, + "bun" + ), + releaseRoot: release.releaseRoot, + }); + return Object.freeze({ + async withApprovedCollection( + operation: (status: "unchanged", signal: AbortSignal) => Promise + ) { + const value = await operation( + "unchanged", + new AbortController().signal + ); + return { reconciliationStatus: "unchanged" as const, value }; + }, + }); + }, createGatewayTransport(options) { events.push("gateway-create"); expect(options.clientVersion).toBe(releaseId); @@ -213,6 +249,8 @@ function processFixture( observedOpenClawRoot, observedLogMaintenance, _observedMoltbook, + observedDatabaseObservability, + observedDatabaseObservabilityReconciler, observedHostOperations, observedBootIdentity ) { @@ -245,6 +283,12 @@ function processFixture( writable: true, }); expect(observedLogMaintenance).toBe(logMaintenance); + expect(observedDatabaseObservability.collect).toBeFunction(); + if (observedDatabaseObservabilityReconciler !== undefined) { + expect( + observedDatabaseObservabilityReconciler.withApprovedCollection + ).toBeFunction(); + } expect(observedHostOperations).toBeUndefined(); expect(observedBootIdentity).toBe(bootIdentity); expect(Object.keys(observedGatewayTransport).toSorted()).toEqual([ @@ -474,6 +518,25 @@ describe("Dashboard worker process", () => { ); }); + test("composes dynamic database discovery from the observer password only", async () => { + const fixture = processFixture(); + + await runDashboardWorkerProcess( + { + ...processOptions, + configurationSource: { + ...processOptions.configurationSource, + MIRA_DASHBOARD_DATABASE_OBSERVABILITY_PASSWORD: + "database-observer-password", + }, + }, + fixture.dependencies + ); + + expect(fixture.events).toContain("database-observability-discovery-create"); + expect(fixture.events).toContain("database-observability-reconciler-create"); + }); + test("disposes partial ownership and reports a redacted startup failure", () => { const failure = new Error("private worker failure"); const fixture = processFixture(failure); diff --git a/greenfield/src/app/worker.ts b/greenfield/src/app/worker.ts index ba136af96..6fac9a6db 100644 --- a/greenfield/src/app/worker.ts +++ b/greenfield/src/app/worker.ts @@ -1,6 +1,7 @@ import { realpath } from "node:fs/promises"; import path from "node:path"; +import type { DatabaseObservabilityCollector } from "../contracts/databaseObservabilityCollector.ts"; import type { FixedHostOperationsExecutionPort } from "../server/domains/jobs/actionExecutors.ts"; import { createDashboardWorkerRuntime, @@ -40,9 +41,14 @@ import { createProcessTerminationController, type ProcessTerminationController, } from "../server/platform/runtime/processSignals.ts"; +import type { DatabaseObservabilityReconciliationPort } from "../shared/databaseObservabilityReconciliation.ts"; import type { LinuxBootIdentity } from "../shared/linuxBootIdentity.ts"; import type { OpenClawGatewayLifecycleExecutionPort } from "../shared/openClawGatewayLifecycle.ts"; import type { OpenClawServiceActionsExecutionPort } from "../shared/openClawServiceActions.ts"; +import { createBunSqlDatabaseObservabilityCollector } from "../worker/database/bunSqlDatabaseObservabilityCollector.ts"; +import { createDockerDatabaseObservabilityConnectionResolver } from "../worker/database/dockerDatabaseObservabilityEndpointResolver.ts"; +import { createFixedDatabaseObservabilityReconciler } from "../worker/database/fixedDatabaseObservabilityReconciler.ts"; +import { createFixedSqliteLifecycleMaintenance } from "../worker/database/fixedSqliteLifecycleMaintenance.ts"; import { createDescriptorWorkspaceFileStructuralWriter, type WorkerWorkspaceFileRootConfiguration, @@ -92,6 +98,8 @@ export interface DashboardWorkerProcessDependencies { readonly createGatewayTransport: ( options: PersistentGatewayTransportOptions ) => PersistentGatewayTaskNotificationTransport; + readonly createDatabaseObservabilityConnectionResolver: typeof createDockerDatabaseObservabilityConnectionResolver; + readonly createDatabaseObservabilityReconciler: typeof createFixedDatabaseObservabilityReconciler; readonly createLogDestination: ( logsDirectory: string, processRole: "worker" @@ -117,6 +125,10 @@ export interface DashboardWorkerProcessDependencies { openClawRoot: WorkerWorkspaceFileRootConfiguration, logMaintenance: LogMaintenanceExecutor, moltbook: MoltbookDashboardCollector, + databaseObservability: DatabaseObservabilityCollector, + databaseObservabilityReconciler: + | DatabaseObservabilityReconciliationPort + | undefined, hostOperations: FixedHostOperationsExecutionPort | undefined, bootIdentity: LinuxBootIdentity ) => DashboardWorkerRuntime; @@ -187,6 +199,9 @@ export function createWorkerLogMaintenanceExecutor( } const defaultDependencies = Object.freeze({ + createDatabaseObservabilityConnectionResolver: + createDockerDatabaseObservabilityConnectionResolver, + createDatabaseObservabilityReconciler: createFixedDatabaseObservabilityReconciler, createGatewayTransport: createPersistentGatewayTaskNotificationTransport, createLogDestination: (logsDirectory, processRole) => createProjectFileLogDestination(logsDirectory, processRole), @@ -205,6 +220,8 @@ const defaultDependencies = Object.freeze({ openClawRoot, logMaintenance, moltbook, + databaseObservability, + databaseObservabilityReconciler, hostOperations, bootIdentity ) => { @@ -220,6 +237,10 @@ const defaultDependencies = Object.freeze({ startupMode: "validate-only", stateDirectory: layout.production.state.root, }, + databaseObservability, + ...(databaseObservabilityReconciler === undefined + ? {} + : { databaseObservabilityReconciler }), logMaintenance, moltbook, ...(openClawGateway === undefined ? {} : { openClawGateway }), @@ -229,6 +250,12 @@ const defaultDependencies = Object.freeze({ pid: process.pid, releaseId: release.manifest.source.commitSha, sideEffects: createSystemJobWorkerSideEffects(), + sqliteMaintenance: createFixedSqliteLifecycleMaintenance({ + migrationsDirectory: path.join(release.releaseRoot, "migrations"), + releaseId: release.manifest.source.commitSha, + releaseRoot: release.releaseRoot, + stateDirectory: layout.production.state.root, + }), taskNotificationLoop: taskNotificationWorkerLoop, workerInstanceId: Bun.randomUUIDv7(), workspaceFiles: writer, @@ -342,6 +369,29 @@ export async function runDashboardWorkerProcess( agentName: configuration.moltbookAgentName, apiKey: configuration.moltbookApiKey, }); + const databaseObservabilityConnectionResolver = + configuration.databaseObservabilityPassword === undefined + ? undefined + : dependencies.createDatabaseObservabilityConnectionResolver({ + credentials: { + password: configuration.databaseObservabilityPassword, + }, + }); + const databaseObservability = createBunSqlDatabaseObservabilityCollector({ + connectionResolver: databaseObservabilityConnectionResolver, + }); + const databaseObservabilityReconciler = + databaseObservabilityConnectionResolver === undefined + ? undefined + : dependencies.createDatabaseObservabilityReconciler({ + bunExecutable: path.join( + layout.production.runtimes, + "bun", + release.manifest.runtime.revision, + "bun" + ), + releaseRoot: release.releaseRoot, + }); runtime = dependencies.createRuntime( layout, release, @@ -353,6 +403,8 @@ export async function runDashboardWorkerProcess( openClawRoot, logMaintenance, moltbook, + databaseObservability, + databaseObservabilityReconciler, hostOperations, bootIdentity ); diff --git a/greenfield/src/browser/api/trpcClient.ts b/greenfield/src/browser/api/trpcClient.ts index f5a827206..1da93c36f 100644 --- a/greenfield/src/browser/api/trpcClient.ts +++ b/greenfield/src/browser/api/trpcClient.ts @@ -80,6 +80,10 @@ async function procedureContractsFor( const module = await import("../../contracts/chat.ts"); return module.chatProcedureContracts; } + case "database": { + const module = await import("../../contracts/database.ts"); + return module.databaseProcedureContracts; + } case "files": { const module = await import("../../contracts/files.ts"); return module.workspaceFileProcedureContracts; diff --git a/greenfield/src/browser/auth/LoginRoute.test.tsx b/greenfield/src/browser/auth/LoginRoute.test.tsx index ecee83e9a..cf2d1a5bd 100644 --- a/greenfield/src/browser/auth/LoginRoute.test.tsx +++ b/greenfield/src/browser/auth/LoginRoute.test.tsx @@ -399,7 +399,7 @@ describe("Dashboard login route", () => { const alert = await screen.findByRole("alert"); expect(alert.textContent).toContain("Too many attempts"); expect(alert.textContent).not.toContain(privateSentinel); - await waitFor(() => expect(document.activeElement).toBe(alert)); + await waitFor(() => expect(document.activeElement === alert).toBeTrue()); }); test("preserves the operation failure when fallback cache reset also fails", async () => { diff --git a/greenfield/src/browser/database/DatabaseRoute.test.tsx b/greenfield/src/browser/database/DatabaseRoute.test.tsx new file mode 100644 index 000000000..9af151e70 --- /dev/null +++ b/greenfield/src/browser/database/DatabaseRoute.test.tsx @@ -0,0 +1,992 @@ +import { afterEach, describe, expect, jest, test } from "bun:test"; + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act } from "react"; +import * as v from "valibot"; + +import { + databaseOverviewSchema, + type DatabaseOverview, +} from "../../contracts/database.ts"; +import type { DashboardTrpcClient } from "../api/trpcClient.ts"; +import { DashboardTrpcProvider } from "../api/trpcContext.tsx"; +import { + databaseOverviewQueryKey, + databaseOverviewQueryOptions, + databaseOverviewRefreshIntervalMs, +} from "./databaseQueries.ts"; +import { DatabaseRouteContent } from "./DatabaseRoute.tsx"; + +const { cleanup, render, screen, within } = await import("@testing-library/react"); + +const freshOverview = { + checkedAtMs: 2000, + postgresql: { state: "unavailable" }, + sqlite: { + connection: { + busyPolicy: "non-blocking", + checksEnforced: true, + foreignKeysEnabled: true, + journalMode: "wal", + synchronousMode: "full", + trustedSchemaEnabled: false, + walAutoCheckpointPages: 1000, + }, + fileName: "mira-dashboard.db", + lifecycle: { + backupInventory: { + reason: "inventory-unavailable", + state: "unavailable", + }, + maintenance: { + reason: "maintenance-unavailable", + state: "unavailable", + }, + restoreVerification: { + reason: "verification-unavailable", + state: "unavailable", + }, + }, + migrations: { applied: 12, available: 12, current: true }, + observedAtMs: 2000, + state: "fresh", + storage: { + databaseBytes: 67_108_864, + freeBytes: 4_194_304, + freePages: 1024, + freePercent: 6.25, + pageCount: 16_384, + pageSizeBytes: 4096, + permissions: { + dataDirectory: "0700", + database: "0600", + secure: true, + shm: "0600", + wal: "0600", + }, + requiresVacuumReview: false, + shmBytes: 32_768, + storageBytes: 71_335_936, + walBytes: 4_194_304, + }, + }, +} as const satisfies DatabaseOverview; + +const freshPostgresqlOverview = { + checkedAtMs: 4000, + postgresql: { + databases: [ + { + blocksHit: 984, + blocksRead: 16, + cacheHitRatio: 98.4, + committedTransactions: 1234, + connections: 7, + detailsState: "available", + name: "mira_app", + pool: { + activeClients: 5, + activeServers: 3, + averageQueryMs: 14, + averageTransactionMs: 21.5, + idleServers: 2, + totalQueries: 4500, + usedServers: 1, + waitingClients: 1, + }, + rolledBackTransactions: 12, + sizeBytes: 6 * 1024 * 1024 * 1024, + }, + { + blocksHit: 961, + blocksRead: 39, + cacheHitRatio: 96.1, + committedTransactions: 800, + connections: 3, + detailsState: "available", + name: "search_index", + rolledBackTransactions: 2, + sizeBytes: 2 * 1024 * 1024 * 1024, + }, + ], + observedAtMs: 3500, + pgbouncer: { + averageQueryMs: 14, + averageTransactionMs: 21.5, + clientConnections: 10, + maxWaitSeconds: 0.25, + serverConnections: 6, + waitingClients: 1, + }, + state: "fresh", + statements: [ + { + calls: 640, + meanExecutionMs: 508.25, + rank: 1, + rows: 1280, + sharedBlocksHit: 9100, + sharedBlocksRead: 42, + totalExecutionMs: 5280, + }, + ], + summary: { + activeConnections: 4, + averageCacheHitRatio: 97.25, + idleConnections: 6, + maintenance: { + assessedPhysicalBytes: 6 * 1024 * 1024 * 1024, + assessmentComplete: true, + estimatedReclaimableBytes: 5 * 1024 * 1024 * 1024, + estimatedReclaimablePercent: (5 / 6) * 100, + highDeadTupleTableCount: 1, + requiresBloatReview: true, + slowStatementCount: 1, + status: "review", + unassessedPhysicalBytes: 0, + unassessedTableCount: 0, + }, + pgStatStatementsEnabled: true, + totalConnections: 10, + totalDatabaseSizeBytes: 8 * 1024 * 1024 * 1024, + unavailableDatabaseCount: 0, + }, + tableHealth: [ + { + assessment: "assessed", + database: "mira_app", + deadTuplePercent: 25, + deadTuples: 2000, + estimatedReclaimableBytes: 5 * 1024 * 1024 * 1024, + lastAutoanalyzeAtMs: 3200, + lastAutovacuumAtMs: 3000, + liveTuples: 8000, + physicalBytes: 6 * 1024 * 1024 * 1024, + schema: "public", + table: "events", + }, + ], + torrentCounts: { + bitmagnet: { count: 125_000, state: "available" }, + comet: { state: "unavailable" }, + }, + }, + sqlite: freshOverview.sqlite, +} as const satisfies DatabaseOverview; + +const lifecycleOverview = { + ...freshOverview, + checkedAtMs: 172_800_000, + sqlite: { + ...freshOverview.sqlite, + lifecycle: { + backupInventory: { + backups: [ + { + bytes: 8_388_608, + createdAtMs: 172_000_000, + kind: "scheduled", + restoreVerifiedAtMs: 172_000_000, + verificationLevel: "restore-copy-verified", + }, + ], + observedAtMs: 172_800_000, + state: "available", + totalBytes: 8_388_608, + }, + maintenance: { + enabled: true, + latestSuccessfulAtMs: 172_100_000, + nextRunAtMs: 180_000_000, + observedAtMs: 172_800_000, + runs: [ + { + finishedAtMs: 172_100_000, + queuedAtMs: 172_000_000, + startedAtMs: 172_050_000, + state: "succeeded", + }, + ], + schedule: { timeOfDay: "02:40", timeZone: "Europe/Oslo" }, + state: "available", + }, + restoreVerification: { + backupBytes: 8_388_608, + backupCreatedAtMs: 172_000_000, + observedAtMs: 172_800_000, + state: "verified", + verifiedAtMs: 172_000_000, + }, + }, + observedAtMs: 172_800_000, + }, +} as const satisfies DatabaseOverview; + +afterEach(cleanup); + +function renderRoute( + response: Promise | (() => Promise), + configure?: (queryClient: QueryClient) => void, + source: "postgresql" | "sqlite" = "sqlite" +) { + const query = jest.fn(() => (typeof response === "function" ? response() : response)); + const client = { + mutation: () => Promise.reject(new Error("Unexpected mutation")), + query, + } as unknown as DashboardTrpcClient; + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + configure?.(queryClient); + const view = render( + + + {}} source={source} /> + + + ); + return { query, queryClient, view }; +} + +describe("DatabaseRoute", () => { + test("uses contract-valid source fixtures", () => { + expect(v.safeParse(databaseOverviewSchema, freshOverview).success).toBe(true); + expect(v.safeParse(databaseOverviewSchema, freshPostgresqlOverview).success).toBe( + true + ); + }); + + test("refreshes mounted observations only in the foreground", () => { + const client = { + mutation: () => Promise.reject(new Error("Unexpected mutation")), + query: () => Promise.resolve(freshOverview), + } as unknown as DashboardTrpcClient; + const options = databaseOverviewQueryOptions(client); + + expect(options.refetchInterval).toBe(databaseOverviewRefreshIntervalMs); + expect(options.refetchIntervalInBackground).toBeFalse(); + expect(options.refetchOnMount).toBe("always"); + expect(options.staleTime).toBe(databaseOverviewRefreshIntervalMs); + }); + + test("offers both reviewed sources through an accessible picker", () => { + const onSelect = jest.fn(); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const client = { + mutation: () => Promise.reject(new Error("Unexpected mutation")), + query: () => Promise.resolve(freshOverview), + } as unknown as DashboardTrpcClient; + const view = render( + + + + + + ); + try { + const sqlite = screen.getByRole("button", { name: "Dashboard SQLite" }); + const postgres = screen.getByRole("button", { + name: "PostgreSQL & PgBouncer", + }); + expect(screen.getByRole("group", { name: "Database source" })).toBeVisible(); + expect(sqlite).toHaveAttribute("aria-pressed", "true"); + expect(postgres).toHaveAttribute("aria-pressed", "false"); + postgres.click(); + expect(onSelect).toHaveBeenCalledWith("postgresql"); + } finally { + view.unmount(); + queryClient.clear(); + } + }); + + test("shows an explicit loading state before the first bounded observation", () => { + const pending = Promise.withResolvers(); + const { queryClient, view } = renderRoute(pending.promise); + try { + expect(screen.getByLabelText("Loading database overview…")).toBeVisible(); + expect(screen.queryByText("Connection policy")).toBeNull(); + } finally { + view.unmount(); + queryClient.clear(); + } + }); + + test("renders the read-only SQLite lifecycle projection", async () => { + const { query, queryClient, view } = renderRoute(Promise.resolve(freshOverview)); + try { + expect(await screen.findByText("Fresh observation")).toBeVisible(); + expect(screen.getByText("12 / 12")).toBeVisible(); + expect(screen.getByText("Current")).toBeVisible(); + expect(screen.getByText("1,000 pages")).toBeVisible(); + expect(screen.getByText("Disabled")).toBeVisible(); + expect(screen.getAllByText("64 MiB")).not.toHaveLength(0); + expect(screen.getByText("mira-dashboard.db · 64 MiB")).toBeVisible(); + expect(screen.getAllByText("4.0 MiB")).not.toHaveLength(0); + expect(screen.getByText("Permissions secure")).toBeVisible(); + expect(screen.getByText("0700 / 0600 / 0600 / 0600")).toBeVisible(); + expect(screen.getByText("Backup kinds")).toBeVisible(); + expect(screen.getByText("None")).toBeVisible(); + expect( + screen.getByText("SQLite backup inventory is unavailable.") + ).toBeVisible(); + expect( + screen.getByText("SQLite maintenance status is unavailable.") + ).toBeVisible(); + expect(query).toHaveBeenCalledWith( + "database.overview", + {}, + expect.objectContaining({ signal: expect.any(AbortSignal) }) + ); + } finally { + view.unmount(); + queryClient.clear(); + } + }); + + test("renders latest verified backup and maintenance details without paths", async () => { + const { queryClient, view } = renderRoute(Promise.resolve(lifecycleOverview)); + try { + expect(await screen.findByText("1 verified · 8.0 MiB")).toBeVisible(); + expect(screen.getByText("scheduled: 1")).toBeVisible(); + expect(screen.getAllByText(/8\.0 MiB$/u)).toHaveLength(2); + expect(screen.getByText(/succeeded/u)).toBeVisible(); + expect(screen.getByText("02:40 Europe/Oslo · 1 retained runs")).toBeVisible(); + expect(screen.queryByText(/\/state\//u)).toBeNull(); + expect(screen.queryByText(/raw error/iu)).toBeNull(); + } finally { + view.unmount(); + queryClient.clear(); + } + }); + + test("marks each retained SQLite lifecycle source with its own stale time", async () => { + const retainedOverview = { + ...lifecycleOverview, + sqlite: { + ...lifecycleOverview.sqlite, + lifecycle: { + backupInventory: { + ...lifecycleOverview.sqlite.lifecycle.backupInventory, + staleSinceMs: 172_200_000, + state: "last-known-good", + }, + maintenance: { + ...lifecycleOverview.sqlite.lifecycle.maintenance, + staleSinceMs: 172_300_000, + state: "last-known-good", + }, + restoreVerification: { + ...lifecycleOverview.sqlite.lifecycle.restoreVerification, + staleSinceMs: 172_400_000, + state: "last-known-good", + }, + }, + }, + } as const satisfies DatabaseOverview; + const { queryClient, view } = renderRoute(Promise.resolve(retainedOverview)); + try { + const states = await screen.findByRole("region", { + name: "SQLite lifecycle observation states", + }); + expect(within(states).getAllByText("Last-known-good")).toHaveLength(3); + expect(within(states).getAllByText(/Retained since/u)).toHaveLength(3); + expect( + [...states.querySelectorAll("time")].map((time) => + time.getAttribute("dateTime") + ) + ).toEqual( + [172_200_000, 172_400_000, 172_300_000].map((timestampMs) => + new Date(timestampMs).toISOString() + ) + ); + } finally { + view.unmount(); + queryClient.clear(); + } + }); + + test("explains old backups and disabled, missing, old, or failed maintenance", async () => { + const cases = [ + { + message: "Scheduled SQLite maintenance is disabled.", + overview: { + ...lifecycleOverview, + sqlite: { + ...lifecycleOverview.sqlite, + lifecycle: { + ...lifecycleOverview.sqlite.lifecycle, + maintenance: { + ...lifecycleOverview.sqlite.lifecycle.maintenance, + enabled: false, + nextRunAtMs: undefined, + }, + }, + }, + }, + }, + { + message: "Scheduled SQLite maintenance has no successful run yet.", + overview: { + ...lifecycleOverview, + sqlite: { + ...lifecycleOverview.sqlite, + lifecycle: { + ...lifecycleOverview.sqlite.lifecycle, + maintenance: { + ...lifecycleOverview.sqlite.lifecycle.maintenance, + latestSuccessfulAtMs: undefined, + runs: [ + { + queuedAtMs: 172_700_000, + state: "queued" as const, + }, + ], + }, + }, + }, + }, + }, + { + message: + "The latest verified SQLite maintenance backup is older than the 48-hour policy.", + overview: { + ...lifecycleOverview, + sqlite: { + ...lifecycleOverview.sqlite, + lifecycle: { + ...lifecycleOverview.sqlite.lifecycle, + backupInventory: { + ...lifecycleOverview.sqlite.lifecycle.backupInventory, + backups: [ + { + bytes: 4096, + createdAtMs: 172_700_000, + kind: "cutover" as const, + verificationLevel: "manifest-verified" as const, + }, + { + bytes: 8_388_608, + createdAtMs: 0, + kind: "scheduled" as const, + restoreVerifiedAtMs: 0, + verificationLevel: + "restore-copy-verified" as const, + }, + ], + totalBytes: 8_392_704, + }, + restoreVerification: { + backupBytes: 8_388_608, + backupCreatedAtMs: 0, + observedAtMs: 172_800_000, + state: "verified" as const, + verifiedAtMs: 0, + }, + }, + }, + }, + }, + { + message: "The latest terminal SQLite maintenance run failed.", + overview: { + ...lifecycleOverview, + sqlite: { + ...lifecycleOverview.sqlite, + lifecycle: { + ...lifecycleOverview.sqlite.lifecycle, + maintenance: { + ...lifecycleOverview.sqlite.lifecycle.maintenance, + runs: [ + { + queuedAtMs: 172_700_000, + startedAtMs: 172_750_000, + state: "running" as const, + }, + { + finishedAtMs: 172_100_000, + queuedAtMs: 172_000_000, + startedAtMs: 172_050_000, + state: "failed" as const, + }, + ], + }, + }, + }, + }, + }, + { + message: + "The latest successful SQLite maintenance run is older than 48 hours.", + overview: { + ...lifecycleOverview, + checkedAtMs: 200_000_000, + sqlite: { + ...lifecycleOverview.sqlite, + lifecycle: { + ...lifecycleOverview.sqlite.lifecycle, + maintenance: { + ...lifecycleOverview.sqlite.lifecycle.maintenance, + latestSuccessfulAtMs: 1, + runs: [ + { + queuedAtMs: 172_700_000, + state: "queued" as const, + }, + ], + }, + }, + observedAtMs: 200_000_000, + }, + }, + }, + ] as const; + for (const { message, overview } of cases) { + const { queryClient, view } = renderRoute( + Promise.resolve(overview as DatabaseOverview) + ); + try { + expect(await screen.findByText(message)).toBeVisible(); + } finally { + view.unmount(); + queryClient.clear(); + cleanup(); + } + } + }); + + test("surfaces material reusable space as a planned VACUUM review", async () => { + const overview = { + ...freshOverview, + sqlite: { + ...freshOverview.sqlite, + storage: { + ...freshOverview.sqlite.storage, + freeBytes: 1024 * 1024 * 1024, + freePages: 262_144, + freePercent: 100, + pageCount: 262_144, + requiresVacuumReview: true, + }, + }, + } as const satisfies DatabaseOverview; + const { queryClient, view } = renderRoute(Promise.resolve(overview)); + try { + expect( + await screen.findByText( + /review a planned vacuum to compact the database/iu + ) + ).toBeVisible(); + } finally { + view.unmount(); + queryClient.clear(); + } + }); + + test("surfaces insecure SQLite storage modes as operator attention", async () => { + const overview = { + ...freshOverview, + sqlite: { + ...freshOverview.sqlite, + storage: { + ...freshOverview.sqlite.storage, + permissions: { + ...freshOverview.sqlite.storage.permissions, + database: "0640" as const, + secure: false, + }, + }, + }, + } as const satisfies DatabaseOverview; + const { queryClient, view } = renderRoute(Promise.resolve(overview)); + try { + expect( + await screen.findByText( + /permissions are outside the private storage policy/iu + ) + ).toBeVisible(); + } finally { + view.unmount(); + queryClient.clear(); + } + }); + + test("distinguishes server-retained last-known-good data", async () => { + const retained = { + ...freshOverview, + checkedAtMs: 3000, + sqlite: { + ...freshOverview.sqlite, + staleSinceMs: 2500, + state: "last-known-good", + }, + } as const satisfies DatabaseOverview; + const { queryClient, view } = renderRoute(Promise.resolve(retained)); + try { + expect(await screen.findByText("Last-known-good")).toBeVisible(); + expect( + screen.getByText(/latest SQLite diagnostics check failed/iu) + ).toBeVisible(); + expect(screen.getByText("12 / 12")).toBeVisible(); + } finally { + view.unmount(); + queryClient.clear(); + } + }); + + test("keeps PostgreSQL unavailability independent from fresh SQLite data", async () => { + const { queryClient, view } = renderRoute( + Promise.resolve(freshOverview), + undefined, + "postgresql" + ); + try { + expect( + await screen.findByRole("heading", { + name: "PostgreSQL diagnostics unavailable", + }) + ).toBeVisible(); + expect(screen.queryByText("Connection policy")).toBeNull(); + expect( + screen.getByRole("button", { name: "PostgreSQL & PgBouncer" }) + ).toHaveAttribute("aria-pressed", "true"); + } finally { + view.unmount(); + queryClient.clear(); + } + }); + + test("renders bounded PostgreSQL and PgBouncer aggregates without query identities", async () => { + const { queryClient, view } = renderRoute( + Promise.resolve(freshPostgresqlOverview), + undefined, + "postgresql" + ); + try { + expect(await screen.findByText("Database storage")).toBeVisible(); + expect(screen.getByText("8.0 GiB")).toBeVisible(); + expect(screen.getByText("97.3%")).toBeVisible(); + expect(screen.getAllByText("Review")).toHaveLength(2); + expect(screen.getByText("5.0 GiB · 83.3%")).toBeVisible(); + expect(screen.getByText("Required")).toBeVisible(); + expect( + screen.getByText( + /estimated 5\.0 GiB \(83\.3%\) reclaimable table space/iu + ) + ).toBeVisible(); + expect( + screen.getByText(/standard VACUUM makes space reusable internally/iu) + ).toBeVisible(); + expect( + screen.getByText(/1 PostgreSQL table exceeds the dead-tuple/iu) + ).toBeVisible(); + expect( + screen.getByText( + /1 identity-free PostgreSQL statement aggregate exceeds/iu + ) + ).toBeVisible(); + expect(screen.getByText("Unassessed physical size")).toBeVisible(); + expect( + screen.getByRole("heading", { name: "Comet torrents" }).closest("section") + ).toHaveTextContent("Unavailable"); + expect( + screen + .getByRole("heading", { name: "Bitmagnet torrents" }) + .closest("section") + ).toHaveTextContent("125,000"); + expect(screen.getByText("PgBouncer aggregate")).toBeVisible(); + expect(screen.getAllByText("21.5 ms")).toHaveLength(2); + expect(screen.getByText("0.25 s")).toBeVisible(); + + const databases = screen.getByRole("region", { + name: "PostgreSQL databases", + }); + expect(databases).toHaveClass("overflow-x-auto"); + expect(within(databases).getByText("mira_app")).toBeVisible(); + expect(within(databases).getByText("1,234")).toBeVisible(); + expect(within(databases).getByText("98.4%")).toBeVisible(); + expect(within(databases).getByText("5 active · 1 waiting")).toBeVisible(); + expect(within(databases).getByText("4,500")).toBeVisible(); + + const health = screen.getByRole("region", { + name: "PostgreSQL table health", + }); + expect(within(health).getByText("public")).toBeVisible(); + expect(within(health).getByText("events")).toBeVisible(); + expect(within(health).getByText("25%")).toBeVisible(); + expect(within(health).getByText("Assessed")).toBeVisible(); + expect(within(health).getByText("5.0 GiB")).toBeVisible(); + + const statements = screen.getByRole("region", { + name: "PostgreSQL statement metrics", + }); + expect(within(statements).getByText("5,280 ms")).toBeVisible(); + expect(within(statements).getByText("508.25 ms")).toBeVisible(); + expect( + within(statements).getByRole("columnheader", { name: "Rank" }) + ).toBeVisible(); + expect( + within(statements).queryByRole("columnheader", { + name: /query|sql|user/iu, + }) + ).toBeNull(); + expect(within(statements).queryByText(/select\s/iu)).toBeNull(); + } finally { + view.unmount(); + queryClient.clear(); + } + }); + + test("renders an unavailable dynamic database and incomplete assessment explicitly", async () => { + const partiallyUnavailable = { + ...freshPostgresqlOverview, + postgresql: { + ...freshPostgresqlOverview.postgresql, + databases: freshPostgresqlOverview.postgresql.databases.map((database) => + database.name === "search_index" + ? { ...database, detailsState: "unavailable" as const } + : database + ), + summary: { + ...freshPostgresqlOverview.postgresql.summary, + maintenance: { + ...freshPostgresqlOverview.postgresql.summary.maintenance, + assessmentComplete: false, + status: "review" as const, + }, + unavailableDatabaseCount: 1, + }, + }, + } satisfies DatabaseOverview; + const { queryClient, view } = renderRoute( + Promise.resolve(partiallyUnavailable), + undefined, + "postgresql" + ); + try { + const databases = await screen.findByRole("region", { + name: "PostgreSQL databases", + }); + expect(within(databases).getByText("search_index")).toBeVisible(); + expect(within(databases).getByText("Unavailable")).toBeVisible(); + + const maintenance = screen.getByRole("region", { + name: "Maintenance assessment", + }); + expect(within(maintenance).getByText("Review")).toBeVisible(); + expect( + screen.getByText(/1 PostgreSQL database could not be fully assessed/iu) + ).toBeVisible(); + expect( + within(maintenance) + .getByText("Unavailable database details") + .closest("div") + ).toHaveTextContent("1"); + expect( + within(maintenance).getByText("Bloat assessment").closest("div") + ).toHaveTextContent("Incomplete"); + } finally { + view.unmount(); + queryClient.clear(); + } + }); + + test("distinguishes server-retained PostgreSQL data", async () => { + const retained = { + ...freshPostgresqlOverview, + checkedAtMs: 4500, + postgresql: { + ...freshPostgresqlOverview.postgresql, + staleSinceMs: 4000, + state: "last-known-good", + }, + } as const satisfies DatabaseOverview; + const { queryClient, view } = renderRoute( + Promise.resolve(retained), + undefined, + "postgresql" + ); + try { + expect(await screen.findByText("Last-known-good")).toBeVisible(); + expect( + screen.getByText(/latest PostgreSQL\/PgBouncer collection failed/iu) + ).toBeVisible(); + expect(screen.getByText("8.0 GiB")).toBeVisible(); + } finally { + view.unmount(); + queryClient.clear(); + } + }); + + test("explains incomplete maintenance and disabled statement assessment", async () => { + const notAssessed = { + ...freshPostgresqlOverview, + postgresql: { + ...freshPostgresqlOverview.postgresql, + statements: [], + summary: { + ...freshPostgresqlOverview.postgresql.summary, + maintenance: { + assessedPhysicalBytes: 0, + assessmentComplete: false, + estimatedReclaimableBytes: 0, + estimatedReclaimablePercent: 0, + highDeadTupleTableCount: 0, + requiresBloatReview: false, + slowStatementCount: 0, + status: "not-assessed", + unassessedPhysicalBytes: 536_870_912, + unassessedTableCount: 1, + }, + pgStatStatementsEnabled: false, + }, + tableHealth: [ + { + assessment: "unavailable", + database: "mira_app", + deadTuplePercent: 7.5, + deadTuples: 75, + liveTuples: 925, + physicalBytes: 536_870_912, + schema: "public", + table: "events", + }, + ], + }, + } as const satisfies DatabaseOverview; + const { queryClient, view } = renderRoute( + Promise.resolve(notAssessed), + undefined, + "postgresql" + ); + try { + expect( + await screen.findByText( + "Statement metrics are unavailable because pg_stat_statements is not enabled." + ) + ).toBeVisible(); + const maintenance = screen.getByRole("region", { + name: "Maintenance assessment", + }); + expect(within(maintenance).getByText("Incomplete")).toBeVisible(); + expect(within(maintenance).getByText("512 MiB")).toBeVisible(); + expect( + screen.getByText(/1 PostgreSQL table \(512 MiB\) could not be assessed/iu) + ).toBeVisible(); + expect( + screen.getByText(/statement maintenance assessment is unavailable/iu) + ).toBeVisible(); + const health = screen.getByRole("region", { + name: "PostgreSQL table health", + }); + expect(within(health).getByText("Not assessed")).toBeVisible(); + expect(within(health).getAllByText("—")).toHaveLength(3); + expect( + screen.queryByRole("region", { + name: "PostgreSQL statement metrics", + }) + ).toBeNull(); + } finally { + view.unmount(); + queryClient.clear(); + } + }); + + test("renders Comet and Bitmagnet count availability independently", async () => { + const reversedAvailability = { + ...freshPostgresqlOverview, + postgresql: { + ...freshPostgresqlOverview.postgresql, + torrentCounts: { + bitmagnet: { state: "unavailable" }, + comet: { count: 42, state: "available" }, + }, + }, + } as const satisfies DatabaseOverview; + const { queryClient, view } = renderRoute( + Promise.resolve(reversedAvailability), + undefined, + "postgresql" + ); + try { + const cometHeading = await screen.findByRole("heading", { + name: "Comet torrents", + }); + expect(cometHeading.closest("section")).toHaveTextContent("42"); + expect( + screen + .getByRole("heading", { name: "Bitmagnet torrents" }) + .closest("section") + ).toHaveTextContent("Unavailable"); + } finally { + view.unmount(); + queryClient.clear(); + } + }); + + test("keeps cached data visible when a background refresh fails", async () => { + const { queryClient, view } = renderRoute( + () => Promise.reject(new Error("private database path")), + (cache) => + cache.setQueryData(databaseOverviewQueryKey, freshOverview, { + updatedAt: 1, + }) + ); + try { + expect(screen.getByText("12 / 12")).toBeVisible(); + expect( + await screen.findByText( + "The latest refresh failed. Showing retained database data." + ) + ).toBeVisible(); + expect(screen.getByText("Browser cache retained")).toBeVisible(); + expect(screen.queryByText("Fresh observation")).toBeNull(); + expect(screen.queryByText(/private database path/iu)).toBeNull(); + } finally { + view.unmount(); + queryClient.clear(); + } + }); + + test("marks browser-retained PostgreSQL data separately from server LKG", async () => { + const { queryClient, view } = renderRoute( + () => Promise.reject(new Error("private PostgreSQL failure")), + (cache) => + cache.setQueryData(databaseOverviewQueryKey, freshPostgresqlOverview, { + updatedAt: 1, + }), + "postgresql" + ); + try { + expect(await screen.findByText("Browser cache retained")).toBeVisible(); + expect(screen.queryByText("Fresh observation")).toBeNull(); + expect(screen.getByText("2.0 GiB")).toBeVisible(); + expect(screen.queryByText(/private PostgreSQL failure/iu)).toBeNull(); + } finally { + view.unmount(); + queryClient.clear(); + } + }); + + test("shows a safe retryable error without retained data", async () => { + const { queryClient, view } = renderRoute(() => + Promise.reject(new Error("private database path")) + ); + try { + expect( + await screen.findByRole("heading", { + name: "Database overview unavailable", + }) + ).toBeVisible(); + expect( + screen.getByText("The request could not be completed. Try again.") + ).toBeVisible(); + expect(screen.queryByText(/private database path/iu)).toBeNull(); + } finally { + await act(async () => {}); + view.unmount(); + queryClient.clear(); + } + }); +}); diff --git a/greenfield/src/browser/database/DatabaseRoute.tsx b/greenfield/src/browser/database/DatabaseRoute.tsx new file mode 100644 index 000000000..02dda66a2 --- /dev/null +++ b/greenfield/src/browser/database/DatabaseRoute.tsx @@ -0,0 +1,600 @@ +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { useNavigate, useSearch } from "@tanstack/react-router"; +import { + ArchiveRestore, + Database, + HardDrive, + RefreshCw, + ScrollText, + ShieldCheck, +} from "lucide-react"; + +import type { DatabaseOverview } from "../../contracts/database.ts"; +import { useDashboardTrpcClient } from "../api/trpcContextValue.ts"; +import { dashboardBrowserFailureMessage } from "../api/trpcError.ts"; +import { formatDashboardDateTime } from "../lib/formatDateTime.ts"; +import { formatByteCount, formatPercent } from "../lib/formatMeasurements.ts"; +import { Alert } from "../ui/Alert.tsx"; +import { Badge } from "../ui/Badge.tsx"; +import { Button } from "../ui/Button.tsx"; +import { Card } from "../ui/Card.tsx"; +import { Heading } from "../ui/Heading.tsx"; +import { Icon } from "../ui/Icon.tsx"; +import { MetricCard } from "../ui/MetricCard.tsx"; +import { PageHeader } from "../ui/PageHeader.tsx"; +import { PageState } from "../ui/PageState.tsx"; +import { Text } from "../ui/Text.tsx"; +import { + databaseOverviewQueryKey, + databaseOverviewQueryOptions, +} from "./databaseQueries.ts"; +import { + normalizeDatabaseSearch, + type DatabaseRouteSearch, +} from "./databaseRouteSearch.ts"; +import { PostgresqlDatabaseOverview } from "./PostgresqlDatabaseOverview.tsx"; + +function ObservationTime({ timestampMs }: { readonly timestampMs: number }) { + return ( + + ); +} + +function ConnectionPolicy({ overview }: { readonly overview: DatabaseOverview }) { + if (overview.sqlite.state === "unavailable") return null; + const policy = overview.sqlite.connection; + const rows = [ + ["Journal mode", policy.journalMode.toUpperCase()], + ["Synchronous mode", policy.synchronousMode], + ["Busy policy", policy.busyPolicy], + ["WAL checkpoint", `${policy.walAutoCheckpointPages.toLocaleString()} pages`], + ["Foreign keys", policy.foreignKeysEnabled ? "Enforced" : "Not enforced"], + ["Integrity checks", policy.checksEnforced ? "Enforced" : "Not enforced"], + ["Trusted schema", policy.trustedSchemaEnabled ? "Enabled" : "Disabled"], + ] as const; + return ( + + + Connection policy + +
+ {rows.map(([label, value]) => ( +
+
{label}
+
{value}
+
+ ))} +
+
+ ); +} + +type AvailableSqliteObservation = Extract< + DatabaseOverview["sqlite"], + { readonly state: "fresh" | "last-known-good" } +>; + +type SqliteLifecycleObservation = + | AvailableSqliteObservation["lifecycle"]["backupInventory"] + | AvailableSqliteObservation["lifecycle"]["maintenance"] + | AvailableSqliteObservation["lifecycle"]["restoreVerification"]; + +function SqliteLifecycleObservationStatus({ + label, + observation, +}: { + readonly label: string; + readonly observation: SqliteLifecycleObservation; +}) { + const retained = observation.state === "last-known-good"; + let badgeLabel = "Current"; + let badgeVariant: "default" | "success" | "warning" = "success"; + if (observation.state === "unavailable") { + badgeLabel = "Unavailable"; + badgeVariant = "default"; + } else if (retained) { + badgeLabel = "Last-known-good"; + badgeVariant = "warning"; + } + return ( +
+ + {label} + +
+ {badgeLabel} + {retained ? ( + + Retained since{" "} + + + ) : null} +
+
+ ); +} + +function SqliteStorageDetails({ + sqlite, +}: { + readonly sqlite: AvailableSqliteObservation; +}) { + const { storage } = sqlite; + const { permissions } = storage; + const rows = [ + [ + "Database file", + `${sqlite.fileName} · ${formatByteCount(storage.databaseBytes)}`, + ], + ["WAL", formatByteCount(storage.walBytes)], + ["Shared memory", formatByteCount(storage.shmBytes)], + ["Total storage", formatByteCount(storage.storageBytes)], + [ + "Pages", + `${storage.pageCount.toLocaleString()} × ${formatByteCount(storage.pageSizeBytes)}`, + ], + [ + "Reusable space", + `${storage.freePages.toLocaleString()} pages · ${formatByteCount(storage.freeBytes)} · ${formatPercent(storage.freePercent)}`, + ], + [ + "Permission modes", + `${permissions.dataDirectory} / ${permissions.database} / ${permissions.wal ?? "—"} / ${permissions.shm ?? "—"}`, + ], + ] as const; + return ( + +
+ + SQLite storage + + + {permissions.secure ? "Permissions secure" : "Permission review"} + +
+ + Fixed-file sizes, reusable pages, and modes without host paths. + +
+ {rows.map(([label, value]) => ( +
+
{label}
+
+ {value} +
+
+ ))} +
+
+ ); +} + +function SqliteLifecycleBoundaries({ + lifecycle, +}: { + readonly lifecycle: AvailableSqliteObservation["lifecycle"]; +}) { + const inventory = lifecycle.backupInventory; + const verification = lifecycle.restoreVerification; + const maintenance = lifecycle.maintenance; + const latestBackup = + inventory.state === "unavailable" ? undefined : inventory.backups[0]; + const latestRun = + maintenance.state === "unavailable" ? undefined : maintenance.runs[0]; + const latestRunAtMs = latestRun?.finishedAtMs ?? latestRun?.startedAtMs; + const backupKinds = + inventory.state === "unavailable" + ? "Unavailable" + : (["scheduled", "cutover"] as const) + .map((kind) => ({ + count: inventory.backups.filter((backup) => backup.kind === kind) + .length, + kind, + })) + .filter(({ count }) => count > 0) + .map(({ count, kind }) => `${kind}: ${count.toLocaleString()}`) + .join(" · ") || "None"; + let restoreVerificationSummary: string; + if (verification.state === "unavailable") { + restoreVerificationSummary = + verification.reason === "no-verified-backup" + ? "No verified backup yet" + : "Unavailable"; + } else { + restoreVerificationSummary = `Verified ${formatDashboardDateTime(verification.verifiedAtMs)}`; + } + let recurringMaintenanceSummary = "Unavailable"; + if (maintenance.state !== "unavailable") { + recurringMaintenanceSummary = maintenance.enabled + ? `${maintenance.schedule.timeOfDay} ${maintenance.schedule.timeZone} · ${maintenance.runs.length.toLocaleString()} retained runs` + : "Disabled"; + } + const rows = [ + [ + "Backup inventory", + inventory.state === "unavailable" + ? "Unavailable" + : `${inventory.backups.length.toLocaleString()} verified · ${formatByteCount(inventory.totalBytes)}`, + ], + ["Backup kinds", backupKinds], + [ + "Latest backup", + latestBackup === undefined + ? "None" + : `${latestBackup.kind} · ${latestBackup.verificationLevel} · ${formatDashboardDateTime(latestBackup.createdAtMs)} · ${formatByteCount(latestBackup.bytes)}`, + ], + ["Restore verification", restoreVerificationSummary], + [ + "Latest maintenance", + latestRun === undefined + ? "No run recorded" + : `${latestRun.state}${latestRunAtMs === undefined ? "" : ` · ${formatDashboardDateTime(latestRunAtMs)}`}`, + ], + [ + "Latest successful maintenance", + maintenance.state === "unavailable" || + maintenance.latestSuccessfulAtMs === undefined + ? "No successful run recorded" + : formatDashboardDateTime(maintenance.latestSuccessfulAtMs), + ], + ["Recurring maintenance", recurringMaintenanceSummary], + ] as const; + return ( + + + Backup, restore & maintenance + + + Bounded scheduled and cutover snapshots with explicit verification levels + and durable maintenance history, without backup paths or raw failures. + +
+ + + +
+
+ {rows.map(([label, value]) => ( +
+
{label}
+
{value}
+
+ ))} +
+
+ ); +} + +const sqliteBackupAttentionAgeMs = 48 * 60 * 60_000; +const sqliteMaintenanceAttentionAgeMs = 48 * 60 * 60_000; +const terminalMaintenanceStates = new Set([ + "cancelled", + "failed", + "succeeded", + "timed-out", +]); +const unsuccessfulMaintenanceStates = new Set(["cancelled", "failed", "timed-out"]); + +function sqliteLifecycleAttention( + lifecycle: AvailableSqliteObservation["lifecycle"], + observedAtMs: number +): readonly string[] { + const alerts: string[] = []; + const inventory = lifecycle.backupInventory; + if (inventory.state === "unavailable") { + alerts.push("SQLite backup inventory is unavailable."); + } else { + const latestRestoreVerified = inventory.backups.find( + (backup) => backup.verificationLevel === "restore-copy-verified" + ); + if (latestRestoreVerified === undefined) { + alerts.push("No verified SQLite maintenance backup is available yet."); + } else if ( + observedAtMs - latestRestoreVerified.createdAtMs >= + sqliteBackupAttentionAgeMs + ) { + alerts.push( + "The latest verified SQLite maintenance backup is older than the 48-hour policy." + ); + } + } + + const maintenance = lifecycle.maintenance; + if (maintenance.state === "unavailable") { + alerts.push("SQLite maintenance status is unavailable."); + } else if (maintenance.enabled) { + if (maintenance.latestSuccessfulAtMs === undefined) { + alerts.push("Scheduled SQLite maintenance has no successful run yet."); + } else if ( + observedAtMs - maintenance.latestSuccessfulAtMs >= + sqliteMaintenanceAttentionAgeMs + ) { + alerts.push( + "The latest successful SQLite maintenance run is older than 48 hours." + ); + } + const latestTerminal = maintenance.runs.find((run) => + terminalMaintenanceStates.has(run.state) + ); + if ( + latestTerminal !== undefined && + unsuccessfulMaintenanceStates.has(latestTerminal.state) + ) { + alerts.push( + `The latest terminal SQLite maintenance run ${latestTerminal.state}.` + ); + } + } else { + alerts.push("Scheduled SQLite maintenance is disabled."); + } + return alerts; +} + +function DatabaseOverviewContent({ + browserCacheRetained, + overview, +}: { + readonly browserCacheRetained: boolean; + readonly overview: DatabaseOverview; +}) { + if (overview.sqlite.state === "unavailable") { + return ( + + ); + } + const { migrations } = overview.sqlite; + const { storage } = overview.sqlite; + const retained = overview.sqlite.state === "last-known-good"; + let observationBadgeLabel = "Fresh observation"; + if (retained) observationBadgeLabel = "Last-known-good"; + else if (browserCacheRetained) observationBadgeLabel = "Browser cache retained"; + const lifecycleAlerts = sqliteLifecycleAttention( + overview.sqlite.lifecycle, + overview.sqlite.observedAtMs + ); + return ( + +
+ {retained ? ( + + ) : null} + {storage.requiresVacuumReview ? ( + + ) : null} + {storage.permissions.secure ? null : ( + + )} + {lifecycleAlerts.map((message) => ( + + ))} +
+ + {observationBadgeLabel} + + {browserCacheRetained && retained ? ( + Browser cache retained + ) : null} + + Observed{" "} + + + {overview.sqlite.state === "last-known-good" ? ( + + Retained since{" "} + + + ) : null} +
+
+ + + + + + + +
+ + + +
+
+ ); +} + +interface DatabaseSourcePickerProps { + readonly onSelect: (source: DatabaseRouteSearch["source"]) => void; + readonly source: DatabaseRouteSearch["source"]; +} + +function DatabaseSourcePicker({ onSelect, source }: DatabaseSourcePickerProps) { + return ( +
+ Database source + + +
+ ); +} + +type DatabaseRouteContentProps = DatabaseSourcePickerProps; + +/** @returns Read-only database observations for the selected reviewed source. */ +export function DatabaseRouteContent({ onSelect, source }: DatabaseRouteContentProps) { + const client = useDashboardTrpcClient(); + const queryClient = useQueryClient(); + const query = useQuery(databaseOverviewQueryOptions(client)); + const refresh = () => + void queryClient.invalidateQueries({ queryKey: databaseOverviewQueryKey }); + const complete = query.data !== undefined; + + return ( +
+ + + Retry + + } + description="Inspect bounded, read-only SQLite and PostgreSQL/PgBouncer diagnostics without connection identities, SQL text, or credentials." + eyebrow="Data" + title="Database" + /> + +
+ {query.isPending && !complete ? ( + + ) : null} + {!query.isPending && query.error !== null && !complete ? ( + + ) : null} + {complete ? ( +
+ {query.error === null ? null : ( + + )} + {source === "sqlite" ? ( + + ) : ( + + )} + + Checked{" "} + + +
+ ) : null} +
+
+ ); +} + +/** @returns URL-owned source selection and read-only database observations. */ +export function DatabaseRoute() { + const navigate = useNavigate({ from: "/database" }); + const search = normalizeDatabaseSearch(useSearch({ from: "/database" }) as unknown); + return ( + void navigate({ search: { source } })} + source={search.source} + /> + ); +} diff --git a/greenfield/src/browser/database/PostgresqlDatabaseOverview.tsx b/greenfield/src/browser/database/PostgresqlDatabaseOverview.tsx new file mode 100644 index 000000000..8aeb112e7 --- /dev/null +++ b/greenfield/src/browser/database/PostgresqlDatabaseOverview.tsx @@ -0,0 +1,345 @@ +import { + Activity, + Database, + Gauge, + Magnet, + Network, + Orbit, + ScrollText, + Wrench, +} from "lucide-react"; + +import type { DatabaseOverview } from "../../contracts/database.ts"; +import { formatDashboardDateTime } from "../lib/formatDateTime.ts"; +import { formatByteCount, formatPercent } from "../lib/formatMeasurements.ts"; +import { Alert } from "../ui/Alert.tsx"; +import { Badge } from "../ui/Badge.tsx"; +import { Card } from "../ui/Card.tsx"; +import { Heading } from "../ui/Heading.tsx"; +import { MetricCard } from "../ui/MetricCard.tsx"; +import { PageState } from "../ui/PageState.tsx"; +import { Text } from "../ui/Text.tsx"; +import { PostgresqlDatabaseTables } from "./PostgresqlDatabaseTables.tsx"; + +type PostgresqlObservation = DatabaseOverview["postgresql"]; +type AvailablePostgresqlObservation = Exclude< + PostgresqlObservation, + { readonly state: "unavailable" } +>; + +const countFormatter = new Intl.NumberFormat(); +const decimalFormatter = new Intl.NumberFormat(undefined, { + maximumFractionDigits: 2, +}); + +function formatCount(value: number): string { + return countFormatter.format(value); +} + +function formatMilliseconds(value: number): string { + return `${decimalFormatter.format(value)} ms`; +} + +function formatSeconds(value: number): string { + return `${decimalFormatter.format(value)} s`; +} + +function torrentCountValue( + value: AvailablePostgresqlObservation["torrentCounts"]["bitmagnet"] +): string { + return value.state === "available" ? formatCount(value.count) : "Unavailable"; +} + +function PostgresqlObservationTime({ timestampMs }: { readonly timestampMs: number }) { + return ( + + ); +} + +function maintenanceStatusLabel( + status: AvailablePostgresqlObservation["summary"]["maintenance"]["status"] +): string { + switch (status) { + case "healthy": { + return "Healthy"; + } + case "not-assessed": { + return "Not assessed"; + } + case "review": { + return "Review"; + } + } +} + +function postgresqlMaintenanceAttention( + observation: AvailablePostgresqlObservation +): ReadonlyArray<{ readonly message: string; readonly warning: boolean }> { + const maintenance = observation.summary.maintenance; + const messages: Array<{ readonly message: string; readonly warning: boolean }> = []; + if (maintenance.requiresBloatReview) { + messages.push({ + message: `PostgreSQL has an estimated ${formatByteCount(maintenance.estimatedReclaimableBytes)} (${formatPercent(maintenance.estimatedReclaimablePercent)}) reclaimable table space. Review the affected tables; standard VACUUM makes space reusable internally, while returning disk to the host requires planned compaction or a table rebuild.`, + warning: true, + }); + } + if (maintenance.highDeadTupleTableCount > 0) { + messages.push({ + message: `${formatCount(maintenance.highDeadTupleTableCount)} PostgreSQL table${maintenance.highDeadTupleTableCount === 1 ? " exceeds" : "s exceed"} the dead-tuple maintenance threshold. Review autovacuum behavior and plan VACUUM where needed; refresh planner statistics with ANALYZE separately when stale.`, + warning: true, + }); + } + if (maintenance.slowStatementCount > 0) { + messages.push({ + message: `${formatCount(maintenance.slowStatementCount)} identity-free PostgreSQL statement aggregate${maintenance.slowStatementCount === 1 ? " exceeds" : "s exceed"} the slow-statement threshold. Review query plans and indexes through an authorized database tool.`, + warning: true, + }); + } + if (observation.summary.unavailableDatabaseCount > 0) { + messages.push({ + message: `${formatCount(observation.summary.unavailableDatabaseCount)} PostgreSQL database${observation.summary.unavailableDatabaseCount === 1 ? "" : "s"} could not be fully assessed. Restore database observability before treating maintenance as healthy.`, + warning: false, + }); + } + if (maintenance.unassessedTableCount > 0) { + messages.push({ + message: `${formatCount(maintenance.unassessedTableCount)} PostgreSQL table${maintenance.unassessedTableCount === 1 ? "" : "s"} (${formatByteCount(maintenance.unassessedPhysicalBytes)}) could not be assessed for reclaimable space.`, + warning: false, + }); + } + if (!observation.summary.pgStatStatementsEnabled) { + messages.push({ + message: + "PostgreSQL statement maintenance assessment is unavailable until the sanitized pg_stat_statements capability is restored.", + warning: false, + }); + } + return messages; +} + +function MaintenanceSummary({ + observation, +}: { + readonly observation: AvailablePostgresqlObservation; +}) { + const maintenance = observation.summary.maintenance; + const rows = [ + ["Status", maintenanceStatusLabel(maintenance.status)], + ["Bloat assessment", maintenance.assessmentComplete ? "Complete" : "Incomplete"], + [ + "Estimated reclaimable", + `${formatByteCount(maintenance.estimatedReclaimableBytes)} · ${formatPercent(maintenance.estimatedReclaimablePercent)}`, + ], + ["Bloat review", maintenance.requiresBloatReview ? "Required" : "Not required"], + ["High-dead-tuple tables", formatCount(maintenance.highDeadTupleTableCount)], + ["Slow statement aggregates", formatCount(maintenance.slowStatementCount)], + [ + "Unavailable database details", + formatCount(observation.summary.unavailableDatabaseCount), + ], + ["Unassessed tables", formatCount(maintenance.unassessedTableCount)], + [ + "Unassessed physical size", + formatByteCount(maintenance.unassessedPhysicalBytes), + ], + ] as const; + return ( + + + Maintenance assessment + + + Aggregate table-health and reclaimability signals for operator review. + +
+ {rows.map(([label, value]) => ( +
+
{label}
+
+ {value} +
+
+ ))} +
+
+ ); +} + +function PgBouncerSummary({ + observation, +}: { + readonly observation: AvailablePostgresqlObservation; +}) { + const rows = [ + ["Client connections", formatCount(observation.pgbouncer.clientConnections)], + ["Waiting clients", formatCount(observation.pgbouncer.waitingClients)], + ["Server connections", formatCount(observation.pgbouncer.serverConnections)], + ["Average query", formatMilliseconds(observation.pgbouncer.averageQueryMs)], + [ + "Average transaction", + formatMilliseconds(observation.pgbouncer.averageTransactionMs), + ], + ["Maximum wait", formatSeconds(observation.pgbouncer.maxWaitSeconds)], + ] as const; + return ( + + + PgBouncer aggregate + + + Bounded pool totals without users, hosts, ports, or credentials. + +
+ {rows.map(([label, value]) => ( +
+
{label}
+
+ {value} +
+
+ ))} +
+
+ ); +} + +interface PostgresqlDatabaseOverviewProps { + readonly browserCacheRetained: boolean; + readonly observation: PostgresqlObservation; +} + +/** @returns Independent PostgreSQL/PgBouncer availability and bounded observations. */ +export function PostgresqlDatabaseOverview({ + browserCacheRetained, + observation, +}: PostgresqlDatabaseOverviewProps) { + if (observation.state === "unavailable") { + return ( + + ); + } + + const retained = observation.state === "last-known-good"; + const maintenanceAttention = postgresqlMaintenanceAttention(observation); + let observationBadgeLabel = "Fresh observation"; + if (retained) observationBadgeLabel = "Last-known-good"; + else if (browserCacheRetained) observationBadgeLabel = "Browser cache retained"; + return ( + +
+ {retained ? ( + + ) : null} + {maintenanceAttention.map(({ message, warning }) => ( + + ))} +
+ + {observationBadgeLabel} + + {browserCacheRetained && retained ? ( + Browser cache retained + ) : null} + + Observed{" "} + + + {observation.state === "last-known-good" ? ( + + Retained since{" "} + + + ) : null} +
+
+ + + + + + + + +
+ + + +
+
+ ); +} diff --git a/greenfield/src/browser/database/PostgresqlDatabaseTables.tsx b/greenfield/src/browser/database/PostgresqlDatabaseTables.tsx new file mode 100644 index 000000000..bf1b3f05a --- /dev/null +++ b/greenfield/src/browser/database/PostgresqlDatabaseTables.tsx @@ -0,0 +1,345 @@ +import { createColumnHelper, tableFeatures, useTable } from "@tanstack/react-table"; +import type { ReactNode } from "react"; + +import type { + DatabaseObservabilityDatabase, + DatabaseObservabilityStatement, + DatabaseObservabilityTableHealth, +} from "../../contracts/database.ts"; +import { formatDashboardDateTime } from "../lib/formatDateTime.ts"; +import { formatByteCount, formatPercent } from "../lib/formatMeasurements.ts"; +import { Card } from "../ui/Card.tsx"; +import { DataTable } from "../ui/DataTable.tsx"; +import { Heading } from "../ui/Heading.tsx"; +import { Text } from "../ui/Text.tsx"; + +const countFormatter = new Intl.NumberFormat(); +const durationFormatter = new Intl.NumberFormat(undefined, { + maximumFractionDigits: 2, +}); + +function formatCount(value: number): string { + return countFormatter.format(value); +} + +function formatDuration(value: number): string { + return `${durationFormatter.format(value)} ms`; +} + +function optionalDuration(value: number | undefined): string { + return value === undefined ? "—" : formatDuration(value); +} + +function optionalTimestamp(value: number | undefined): ReactNode { + return value === undefined ? ( + "—" + ) : ( + + ); +} + +interface DatabaseTableSectionProps { + readonly children: ReactNode; + readonly description: string; + readonly empty: boolean; + readonly emptyMessage: string; + readonly headingId: string; + readonly title: string; +} + +function DatabaseTableSection({ + children, + description, + empty, + emptyMessage, + headingId, + title, +}: DatabaseTableSectionProps) { + return ( + + + {title} + + + {description} + + {empty ? ( + {emptyMessage} + ) : ( +
{children}
+ )} +
+ ); +} + +const databaseTableFeatures = tableFeatures({}); +const databaseColumnHelper = createColumnHelper< + typeof databaseTableFeatures, + DatabaseObservabilityDatabase +>(); +const databaseColumns = databaseColumnHelper.columns([ + databaseColumnHelper.accessor("name", { + cell: ({ getValue }) => ( + {getValue()} + ), + header: "Database", + }), + databaseColumnHelper.accessor("detailsState", { + cell: ({ getValue }) => + getValue() === "available" ? "Available" : "Unavailable", + header: "Details", + }), + databaseColumnHelper.accessor("sizeBytes", { + cell: ({ getValue }) => formatByteCount(getValue()), + header: "Size", + }), + databaseColumnHelper.accessor("connections", { + cell: ({ getValue }) => formatCount(getValue()), + header: "Connections", + }), + databaseColumnHelper.accessor("cacheHitRatio", { + cell: ({ getValue }) => formatPercent(getValue()), + header: "Cache hit", + }), + databaseColumnHelper.accessor("committedTransactions", { + cell: ({ getValue }) => formatCount(getValue()), + header: "Committed", + }), + databaseColumnHelper.accessor("rolledBackTransactions", { + cell: ({ getValue }) => formatCount(getValue()), + header: "Rolled back", + }), + databaseColumnHelper.accessor((row) => row.pool?.activeClients, { + cell: ({ row }) => { + const pool = row.original.pool; + return pool === undefined + ? "—" + : `${formatCount(pool.activeClients)} active · ${formatCount(pool.waitingClients)} waiting`; + }, + header: "Pool clients", + id: "poolClients", + }), + databaseColumnHelper.accessor((row) => row.pool?.activeServers, { + cell: ({ row }) => { + const pool = row.original.pool; + return pool === undefined + ? "—" + : `${formatCount(pool.activeServers)} active · ${formatCount(pool.idleServers)} idle · ${formatCount(pool.usedServers)} used`; + }, + header: "Pool servers", + id: "poolServers", + }), + databaseColumnHelper.accessor((row) => row.pool?.averageQueryMs, { + cell: ({ getValue }) => optionalDuration(getValue()), + header: "Pool avg query", + id: "poolAverageQuery", + }), + databaseColumnHelper.accessor((row) => row.pool?.averageTransactionMs, { + cell: ({ getValue }) => optionalDuration(getValue()), + header: "Pool avg transaction", + id: "poolAverageTransaction", + }), + databaseColumnHelper.accessor((row) => row.pool?.totalQueries, { + cell: ({ getValue }) => { + const value = getValue(); + return value === undefined ? "—" : formatCount(value); + }, + header: "Pool queries", + id: "poolQueries", + }), +]); + +function PostgresqlDatabasesTable({ + databases, +}: { + readonly databases: readonly DatabaseObservabilityDatabase[]; +}) { + const table = useTable({ + columns: databaseColumns, + data: databases, + features: databaseTableFeatures, + getRowId: ({ name }) => name, + }); + return ( + + ); +} + +const tableHealthFeatures = tableFeatures({}); +const tableHealthColumnHelper = createColumnHelper< + typeof tableHealthFeatures, + DatabaseObservabilityTableHealth +>(); +const tableHealthColumns = tableHealthColumnHelper.columns([ + tableHealthColumnHelper.accessor("database", { header: "Database" }), + tableHealthColumnHelper.accessor("schema", { header: "Schema" }), + tableHealthColumnHelper.accessor("table", { + cell: ({ getValue }) => {getValue()}, + header: "Table", + }), + tableHealthColumnHelper.accessor("assessment", { + cell: ({ getValue }) => (getValue() === "assessed" ? "Assessed" : "Not assessed"), + header: "Bloat assessment", + }), + tableHealthColumnHelper.accessor("physicalBytes", { + cell: ({ getValue }) => formatByteCount(getValue()), + header: "Physical size", + }), + tableHealthColumnHelper.accessor("estimatedReclaimableBytes", { + cell: ({ getValue }) => { + const value = getValue(); + return value === undefined ? "—" : formatByteCount(value); + }, + header: "Estimated reclaimable", + }), + tableHealthColumnHelper.accessor("liveTuples", { + cell: ({ getValue }) => formatCount(getValue()), + header: "Live tuples", + }), + tableHealthColumnHelper.accessor("deadTuples", { + cell: ({ getValue }) => formatCount(getValue()), + header: "Dead tuples", + }), + tableHealthColumnHelper.accessor("deadTuplePercent", { + cell: ({ getValue }) => formatPercent(getValue()), + header: "Dead tuples %", + }), + tableHealthColumnHelper.accessor("lastAutovacuumAtMs", { + cell: ({ getValue }) => optionalTimestamp(getValue()), + header: "Last autovacuum", + }), + tableHealthColumnHelper.accessor("lastAutoanalyzeAtMs", { + cell: ({ getValue }) => optionalTimestamp(getValue()), + header: "Last autoanalyze", + }), +]); + +function PostgresqlTableHealthTable({ + tableHealth, +}: { + readonly tableHealth: readonly DatabaseObservabilityTableHealth[]; +}) { + const table = useTable({ + columns: tableHealthColumns, + data: tableHealth, + features: tableHealthFeatures, + getRowId: (row) => `${row.database}\u0000${row.schema}\u0000${row.table}`, + }); + return ( + + ); +} + +const statementTableFeatures = tableFeatures({}); +const statementColumnHelper = createColumnHelper< + typeof statementTableFeatures, + DatabaseObservabilityStatement +>(); +const statementColumns = statementColumnHelper.columns([ + statementColumnHelper.accessor("rank", { header: "Rank" }), + statementColumnHelper.accessor("calls", { + cell: ({ getValue }) => formatCount(getValue()), + header: "Calls", + }), + statementColumnHelper.accessor("totalExecutionMs", { + cell: ({ getValue }) => formatDuration(getValue()), + header: "Total execution", + }), + statementColumnHelper.accessor("meanExecutionMs", { + cell: ({ getValue }) => formatDuration(getValue()), + header: "Mean execution", + }), + statementColumnHelper.accessor("rows", { + cell: ({ getValue }) => formatCount(getValue()), + header: "Rows", + }), + statementColumnHelper.accessor("sharedBlocksHit", { + cell: ({ getValue }) => formatCount(getValue()), + header: "Shared blocks hit", + }), + statementColumnHelper.accessor("sharedBlocksRead", { + cell: ({ getValue }) => formatCount(getValue()), + header: "Shared blocks read", + }), +]); + +function PostgresqlStatementsTable({ + statements, +}: { + readonly statements: readonly DatabaseObservabilityStatement[]; +}) { + const table = useTable({ + columns: statementColumns, + data: statements, + features: statementTableFeatures, + getRowId: ({ rank }) => String(rank), + }); + return ( + + ); +} + +interface PostgresqlDatabaseTablesProps { + readonly databases: readonly DatabaseObservabilityDatabase[]; + readonly statements: readonly DatabaseObservabilityStatement[]; + readonly statementsEnabled: boolean; + readonly tableHealth: readonly DatabaseObservabilityTableHealth[]; +} + +/** @returns Bounded responsive tables containing only reviewed aggregate fields. */ +export function PostgresqlDatabaseTables({ + databases, + statements, + statementsEnabled, + tableHealth, +}: PostgresqlDatabaseTablesProps) { + return ( +
+ + + + + + + + + +
+ ); +} diff --git a/greenfield/src/browser/database/databaseQueries.ts b/greenfield/src/browser/database/databaseQueries.ts new file mode 100644 index 000000000..20c0e4eca --- /dev/null +++ b/greenfield/src/browser/database/databaseQueries.ts @@ -0,0 +1,18 @@ +import { queryOptions } from "@tanstack/react-query"; + +import type { DashboardTrpcClient } from "../api/trpcClient.ts"; + +export const databaseOverviewQueryKey = ["database", "overview"] as const; +export const databaseOverviewRefreshIntervalMs = 60_000; + +/** @returns The foreground-polled bounded SQLite and PostgreSQL/PgBouncer overview query. */ +export function databaseOverviewQueryOptions(client: DashboardTrpcClient) { + return queryOptions({ + queryFn: ({ signal }) => client.query("database.overview", {}, { signal }), + queryKey: databaseOverviewQueryKey, + refetchInterval: databaseOverviewRefreshIntervalMs, + refetchIntervalInBackground: false, + refetchOnMount: "always", + staleTime: databaseOverviewRefreshIntervalMs, + }); +} diff --git a/greenfield/src/browser/database/databaseRouteSearch.test.ts b/greenfield/src/browser/database/databaseRouteSearch.test.ts new file mode 100644 index 000000000..beb595fbe --- /dev/null +++ b/greenfield/src/browser/database/databaseRouteSearch.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from "bun:test"; + +import { normalizeDatabaseSearch } from "./databaseRouteSearch.ts"; + +describe("database route search", () => { + test("keeps either exact reviewed database source", () => { + expect(normalizeDatabaseSearch({ ignored: true, source: "sqlite" })).toEqual({ + source: "sqlite", + }); + expect(normalizeDatabaseSearch({ source: "postgresql" })).toEqual({ + source: "postgresql", + }); + }); + + test("normalizes unsupported or malformed sources to SQLite", () => { + expect(normalizeDatabaseSearch({ source: "postgres" })).toEqual({ + source: "sqlite", + }); + expect(normalizeDatabaseSearch({ source: "PostgreSQL" })).toEqual({ + source: "sqlite", + }); + expect(normalizeDatabaseSearch({ source: ["sqlite"] })).toEqual({ + source: "sqlite", + }); + expect(normalizeDatabaseSearch(null)).toEqual({ source: "sqlite" }); + }); +}); diff --git a/greenfield/src/browser/database/databaseRouteSearch.ts b/greenfield/src/browser/database/databaseRouteSearch.ts new file mode 100644 index 000000000..d6b1effac --- /dev/null +++ b/greenfield/src/browser/database/databaseRouteSearch.ts @@ -0,0 +1,26 @@ +import * as v from "valibot"; + +const databaseRouteSearchSchema = v.strictObject({ + source: v.picklist(["sqlite", "postgresql"]), +}); + +/** Current reviewed database source selected by the route. */ +export type DatabaseRouteSearch = v.InferOutput; + +/** + * Normalizes untrusted route input to one reviewed database source. + * @param search Untrusted search object parsed by TanStack Router. + * @returns The canonical source selection, defaulting to Dashboard SQLite. + */ +export function normalizeDatabaseSearch(search: unknown): DatabaseRouteSearch { + const candidate = + typeof search === "object" && + search !== null && + !Array.isArray(search) && + "source" in search + ? search.source + : undefined; + const source = + candidate === "sqlite" || candidate === "postgresql" ? candidate : "sqlite"; + return v.parse(databaseRouteSearchSchema, { source }); +} diff --git a/greenfield/src/browser/jobs/JobsRoute.test.tsx b/greenfield/src/browser/jobs/JobsRoute.test.tsx index a6ab0b332..459e4ce55 100644 --- a/greenfield/src/browser/jobs/JobsRoute.test.tsx +++ b/greenfield/src/browser/jobs/JobsRoute.test.tsx @@ -582,21 +582,22 @@ async function renderJobsRoute( const collections = createDashboardBrowserCollections(queryClient, trpcClient); collectionRegistries.push(collections); const router = createDashboardRouter(createMemoryHistory({ initialEntries: [path] })); - mountedViews.push( - render( - - ) - ); - if (transport.authStatus.state === "authenticated") { - await screen.findByRole("heading", { level: 3, name: "OpenClaw cleanup" }); - } + await act(async () => { + await router.load(); + mountedViews.push( + render( + + ) + ); + await waitFor(() => expect(queryClient.isFetching()).toBe(0)); + }); return { queryClient, router }; } diff --git a/greenfield/src/browser/layout/DashboardShell.test.ts b/greenfield/src/browser/layout/DashboardShell.test.ts index 819a233c6..12c9bce97 100644 --- a/greenfield/src/browser/layout/DashboardShell.test.ts +++ b/greenfield/src/browser/layout/DashboardShell.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; +import { dashboardNavigationItems } from "./dashboardNavigation.ts"; import { dashboardContentContainerClassName, dashboardMainClassName, @@ -7,6 +8,12 @@ import { } from "./dashboardShellLayout.ts"; describe("Dashboard shell layout", () => { + test("keeps Database before Moltbook in reviewed navigation order", () => { + const paths = dashboardNavigationItems.map(({ to }) => to); + expect(paths.indexOf("/database")).toBeGreaterThan(-1); + expect(paths.indexOf("/database")).toBeLessThan(paths.indexOf("/moltbook")); + }); + test("keeps the Terminal workspace full-height with the standard responsive gutter", () => { expect(dashboardMainClassName("/terminal").split(" ")).toEqual( expect.arrayContaining([ diff --git a/greenfield/src/browser/layout/DashboardShell.tsx b/greenfield/src/browser/layout/DashboardShell.tsx index 0fc9e0cc3..17350ea90 100644 --- a/greenfield/src/browser/layout/DashboardShell.tsx +++ b/greenfield/src/browser/layout/DashboardShell.tsx @@ -1,67 +1,28 @@ import { Dialog, DialogBackdrop, DialogPanel, DialogTitle } from "@headlessui/react"; import { Outlet, useLocation } from "@tanstack/react-router"; -import { - BookOpen, - Bot, - CalendarClock, - FolderOpen, - Home, - ListTodo, - Logs, - Menu, - MessageCircle, - MessagesSquare, - Newspaper, - Settings as SettingsIcon, - SquareTerminal, - X, - type LucideIcon, -} from "lucide-react"; +import { Menu, X } from "lucide-react"; import { useState } from "react"; import { cn } from "../lib/classNames.ts"; -import type { - DashboardAuthenticatedPath, - DashboardNavigationPath, -} from "../lib/dashboardRoutes.ts"; +import type { DashboardAuthenticatedPath } from "../lib/dashboardRoutes.ts"; import { MonitoringRouteLayout } from "../monitoring/MonitoringRouteLayout.tsx"; import { Icon } from "../ui/Icon.tsx"; import { IconOnlyButton } from "../ui/IconOnlyButton.tsx"; import { NavigationLink } from "../ui/NavigationLink.tsx"; import { DashboardHeaderControls } from "./DashboardHeaderControls.tsx"; +import { dashboardNavigationItems } from "./dashboardNavigation.ts"; import { dashboardContentContainerClassName, dashboardMainClassName, } from "./dashboardShellLayout.ts"; -interface NavigationItem { - readonly icon: LucideIcon; +interface AuthenticatedRouteTitle { readonly label: string; - readonly to: DashboardNavigationPath; + readonly to: DashboardAuthenticatedPath; } -const navigationItems: readonly NavigationItem[] = Object.freeze([ - { icon: Home, label: "Dashboard", to: "/" }, - { icon: Bot, label: "Agents", to: "/agents" }, - { icon: MessagesSquare, label: "Sessions", to: "/sessions" }, - { icon: MessageCircle, label: "Chat", to: "/chat" }, - { icon: FolderOpen, label: "Files", to: "/files" }, - { icon: ListTodo, label: "Tasks", to: "/tasks" }, - { icon: CalendarClock, label: "Jobs", to: "/jobs" }, - { icon: Logs, label: "Logs", to: "/logs" }, - { icon: BookOpen, label: "Moltbook", to: "/moltbook" }, - { icon: SquareTerminal, label: "Terminal", to: "/terminal" }, - { icon: Newspaper, label: "Reports", to: "/reports" }, - { icon: SettingsIcon, label: "Settings", to: "/settings" }, -]); -const routeTitles: readonly Pick[] = Object.freeze([ - ...navigationItems, -]); -const authenticatedRouteTitles: readonly { - readonly label: string; - readonly to: DashboardAuthenticatedPath; -}[] = Object.freeze([ - ...routeTitles, +const authenticatedRouteTitles: readonly AuthenticatedRouteTitle[] = Object.freeze([ + ...dashboardNavigationItems, { label: "Account security", to: "/account-security" }, { label: "Incidents", to: "/incidents" }, ]); @@ -75,7 +36,7 @@ interface NavigationProps { function Navigation({ currentPath, onNavigate }: NavigationProps) { return (