From a27a822fc2704913866efed077b54f6bec04435f Mon Sep 17 00:00:00 2001 From: root Date: Sat, 14 Mar 2026 06:06:35 +0000 Subject: [PATCH] Add prototype storage diagnostics and smoke test --- README.md | 1 + docker-compose.yml | 8 +- docs/PROTOTYPE_DEPLOYMENT.md | 8 +- scripts/prototype-self-test.sh | 124 +++++++++++ .../src/modules/blob/routes.ts | 102 ++++++--- .../src/modules/blob/types.ts | 6 + .../src/modules/status/health-checker.ts | 38 +++- .../src/modules/status/routes.ts | 199 ++++++++++++++++++ .../src/modules/status/self-test.ts | 188 +++++++++++++++++ 9 files changed, 631 insertions(+), 43 deletions(-) create mode 100755 scripts/prototype-self-test.sh create mode 100644 services/platform-service/src/modules/status/self-test.ts diff --git a/README.md b/README.md index 816c0ef1..69b826a2 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ cp .env.example .env See [docs/PROTOTYPE_DEPLOYMENT.md](docs/PROTOTYPE_DEPLOYMENT.md) for the required environment variables and day-to-day commands. The prototype stack now includes a local Cosmos DB Emulator container, so the default `.env.example` values are wired for single-VM Docker use. +Blob uploads are backed by local Azurite, and the platform exposes prototype diagnostics at `/api/health/dependencies`, `/api/self-test`, and `/api/self-test.json`. ## Current Capability Surface diff --git a/docker-compose.yml b/docker-compose.yml index eb9fa34d..b4eae8c5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,9 +2,11 @@ services: # ── Azurite Blob Storage (prototype only) ───────────────────── azurite: image: mcr.microsoft.com/azure-storage/azurite:3.35.0 - command: azurite-blob --blobHost 0.0.0.0 --blobPort 10000 --skipApiVersionCheck + command: azurite-blob --blobHost 0.0.0.0 --blobPort 10000 --location /data --skipApiVersionCheck ports: - '10000:10000' + volumes: + - azurite-data:/data healthcheck: test: [ @@ -115,6 +117,9 @@ services: - PORT=4003 # Local/dev convenience: ensure Cosmos DB + containers exist. - COSMOS_AUTO_INIT=true + - PLATFORM_SERVICE_URL=http://platform-service:4003 + - EXTRACTION_SERVICE_URL=http://extraction-service:4005 + - MCP_SERVER_URL=http://mcp-server:4007 depends_on: azurite: condition: service_healthy @@ -196,5 +201,6 @@ services: # ── Volumes ─────────────────────────────────────────────────────── volumes: + azurite-data: loki-data: grafana-data: diff --git a/docs/PROTOTYPE_DEPLOYMENT.md b/docs/PROTOTYPE_DEPLOYMENT.md index e5049703..23721d22 100644 --- a/docs/PROTOTYPE_DEPLOYMENT.md +++ b/docs/PROTOTYPE_DEPLOYMENT.md @@ -47,7 +47,8 @@ That script will: 1. Validate the required environment variables. 2. Start the local Cosmos DB emulator. -3. Build and start the rest of the Compose stack. +3. Start Azurite with a persistent Docker volume for blob data. +4. Build and start the rest of the Compose stack. ## Day-To-Day Commands @@ -58,15 +59,20 @@ docker compose logs -f extraction-service docker compose logs -f mcp-server docker compose logs -f cosmos-emulator docker compose logs -f azurite +./scripts/prototype-self-test.sh docker compose down ``` The Cosmos Data Explorer is exposed on `http://localhost:1234`. Azurite Blob Storage is exposed on `http://localhost:10000`. +The platform dependency health JSON is exposed on `http://localhost:4003/api/health/dependencies`. +The platform self-test page is exposed on `http://localhost:4003/api/self-test`. +The platform self-test JSON is exposed on `http://localhost:4003/api/self-test.json`. ## Notes - This is intended for early prototype use on a single machine. - Do not commit `.env`. - The Linux emulator is a preview and is only appropriate for local or prototype use. +- Azurite data is persisted in the Docker volume `azurite-data`. - When the project moves to a more secure environment later, replace the emulator with a real Azure Cosmos DB account and move secrets out of `.env` into a proper secret manager. diff --git a/scripts/prototype-self-test.sh b/scripts/prototype-self-test.sh new file mode 100755 index 00000000..0475970f --- /dev/null +++ b/scripts/prototype-self-test.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +COMPOSE_FILE="$ROOT/docker-compose.yml" + +docker compose -f "$COMPOSE_FILE" exec -T \ + -e PLATFORM_INTERNAL_BASE_URL="${PLATFORM_INTERNAL_BASE_URL:-http://platform-service:4003/api}" \ + -e PUBLIC_BLOB_BASE_URL="${PUBLIC_BLOB_BASE_URL:-http://localhost:10000/devstoreaccount1}" \ + -e INTERNAL_BLOB_BASE_URL="${INTERNAL_BLOB_BASE_URL:-http://azurite:10000/devstoreaccount1}" \ + platform-service node --input-type=module <<'NODE' +import { createAccessToken } from './dist/modules/auth/jwt.js'; + +const baseUrl = process.env.PLATFORM_INTERNAL_BASE_URL; +const publicBlobBaseUrl = process.env.PUBLIC_BLOB_BASE_URL; +const internalBlobBaseUrl = process.env.INTERNAL_BLOB_BASE_URL; + +const token = await createAccessToken({ + sub: 'prototype-self-test', + email: 'self-test@bytelyst.local', + role: 'admin', + productId: 'lysnrai', +}); + +const authHeaders = { + Authorization: `Bearer ${token}`, +}; + +async function request(path, init = {}) { + const response = await fetch(`${baseUrl}${path}`, { + ...init, + headers: { + ...authHeaders, + ...(init.headers ?? {}), + }, + }); + + const text = await response.text(); + if (!response.ok) { + throw new Error(`${path} failed with ${response.status}: ${text}`); + } + + return text ? JSON.parse(text) : null; +} + +const stamp = new Date().toISOString().replace(/[:.]/g, '-'); +const blobName = `lysnrai/prototype-self-test/${stamp}.txt`; +const payload = `prototype self-test ${stamp}`; + +console.log('Checking dependency health...'); +console.log(JSON.stringify(await request('/health/dependencies', { headers: {} }), null, 2)); + +console.log('Running platform self-test...'); +console.log(JSON.stringify(await request('/self-test.json', { headers: {} }), null, 2)); + +console.log('Fetching blob container metadata...'); +console.log(JSON.stringify(await request('/blob/containers'), null, 2)); + +console.log(`Requesting SAS URL for ${blobName} ...`); +const sasResponse = await request('/blob/sas', { + method: 'POST', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ + container: 'attachments', + blobName, + permissions: 'rw', + expiresInMinutes: 10, + }), +}); + +const uploadUrl = sasResponse.sasUrl.replace(publicBlobBaseUrl, internalBlobBaseUrl); +const uploadResponse = await fetch(uploadUrl, { + method: 'PUT', + headers: { + 'x-ms-blob-type': 'BlockBlob', + 'Content-Type': 'text/plain', + }, + body: payload, +}); + +if (!uploadResponse.ok) { + throw new Error(`SAS upload failed with ${uploadResponse.status}: ${await uploadResponse.text()}`); +} + +console.log('Listing uploaded blob...'); +console.log( + JSON.stringify( + await request(`/blob/list?container=attachments&prefix=${encodeURIComponent('lysnrai/prototype-self-test')}&limit=20`), + null, + 2 + ) +); + +console.log('Fetching blob info...'); +console.log( + JSON.stringify( + await request(`/blob/info?container=attachments&blobName=${encodeURIComponent(blobName)}`), + null, + 2 + ) +); + +console.log('Deleting uploaded blob via API...'); +console.log( + JSON.stringify( + await request('/blob/delete', { + method: 'DELETE', + headers: { + 'content-type': 'application/json', + }, + body: JSON.stringify({ + container: 'attachments', + blobName, + }), + }), + null, + 2 + ) +); + +console.log('Prototype self-test finished successfully.'); +NODE diff --git a/services/platform-service/src/modules/blob/routes.ts b/services/platform-service/src/modules/blob/routes.ts index 6420df08..2ae5b135 100644 --- a/services/platform-service/src/modules/blob/routes.ts +++ b/services/platform-service/src/modules/blob/routes.ts @@ -4,7 +4,8 @@ * POST /blob/sas — generate SAS URL for direct browser upload/download * GET /blob/list — list blobs in a container (with optional prefix) * DELETE /blob/delete — delete a blob - * GET /blob/info/:container/:blobName(*) — get blob metadata + * GET /blob/info — get blob metadata + * GET /blob/info/:container/* — legacy path-style blob metadata lookup * GET /blob/containers — list available containers and their status */ @@ -17,7 +18,13 @@ import { isBlobStorageConfigured, BLOB_CONTAINERS, } from '../../lib/blob.js'; -import { GenerateSasSchema, ListBlobsSchema, DeleteBlobSchema, type BlobInfo } from './types.js'; +import { + GenerateSasSchema, + ListBlobsSchema, + DeleteBlobSchema, + BlobInfoQuerySchema, + type BlobInfo, +} from './types.js'; const USER_ALLOWED_CONTAINERS = new Set([ BLOB_CONTAINERS.audio, @@ -57,6 +64,46 @@ function normalizePrefix(input: string | undefined): string | undefined { } export async function blobRoutes(app: FastifyInstance) { + async function getBlobInfo( + container: string, + blobName: string, + auth: Awaited> + ) { + if (!Object.values(BLOB_CONTAINERS).includes(container as never)) { + throw new BadRequestError(`Invalid container: ${container}`); + } + + const admin = isAdminRole(auth.role); + if (!admin) { + if (!USER_ALLOWED_CONTAINERS.has(container)) { + throw new UnauthorizedError(`Not allowed to access container: ${container}`); + } + const prefix = userPrefix({ productId: auth.productId, sub: auth.sub }); + if (!blobName.startsWith(prefix)) { + throw new UnauthorizedError('Not allowed to access blobs outside your user scope'); + } + } + + const bucket = await getBucket(container); + + const exists = await bucket.exists(blobName); + if (!exists) { + throw new NotFoundError(`Blob not found: ${container}/${blobName}`); + } + + const blobs = await bucket.list(blobName); + const meta = blobs.find(blob => blob.key === blobName); + return { + name: blobName, + container, + contentType: meta?.contentType, + size: meta?.size ?? 0, + lastModified: meta?.lastModified, + url: blobName, + metadata: meta?.metadata ?? {}, + }; + } + // Generate SAS URL for direct upload/download app.post('/blob/sas', async req => { const auth = await requireAuth(req); @@ -153,43 +200,28 @@ export async function blobRoutes(app: FastifyInstance) { }); // Get blob metadata/info + app.get('/blob/info', async req => { + const auth = await requireAuth(req); + const parsed = BlobInfoQuerySchema.safeParse(req.query); + if (!parsed.success) { + throw new BadRequestError(parsed.error.issues.map(i => i.message).join('; ')); + } + + return getBlobInfo(parsed.data.container, parsed.data.blobName, auth); + }); + + // Legacy path-style blob metadata/info app.get('/blob/info/:container/:blobName', async req => { const auth = await requireAuth(req); const { container, blobName } = req.params as { container: string; blobName: string }; + return getBlobInfo(container, blobName, auth); + }); - if (!Object.values(BLOB_CONTAINERS).includes(container as never)) { - throw new BadRequestError(`Invalid container: ${container}`); - } - - const admin = isAdminRole(auth.role); - if (!admin) { - if (!USER_ALLOWED_CONTAINERS.has(container)) { - throw new UnauthorizedError(`Not allowed to access container: ${container}`); - } - const prefix = userPrefix({ productId: auth.productId, sub: auth.sub }); - if (!blobName.startsWith(prefix)) { - throw new UnauthorizedError('Not allowed to access blobs outside your user scope'); - } - } - - const bucket = await getBucket(container); - - const exists = await bucket.exists(blobName); - if (!exists) { - throw new NotFoundError(`Blob not found: ${container}/${blobName}`); - } - - const blobs = await bucket.list(blobName); - const meta = blobs.find(b => b.key === blobName); - return { - name: blobName, - container, - contentType: meta?.contentType, - size: meta?.size ?? 0, - lastModified: meta?.lastModified, - url: blobName, - metadata: meta?.metadata ?? {}, - }; + app.get('/blob/info/:container/*', async req => { + const auth = await requireAuth(req); + const { container } = req.params as { container: string }; + const blobName = (req.params as Record)['*']; + return getBlobInfo(container, blobName, auth); }); // List available containers and config status diff --git a/services/platform-service/src/modules/blob/types.ts b/services/platform-service/src/modules/blob/types.ts index bf93e830..e62881cc 100644 --- a/services/platform-service/src/modules/blob/types.ts +++ b/services/platform-service/src/modules/blob/types.ts @@ -25,6 +25,11 @@ export const DeleteBlobSchema = z.object({ blobName: z.string().min(1).max(1024), }); +export const BlobInfoQuerySchema = z.object({ + container: z.enum(containerNames), + blobName: z.string().min(1).max(1024), +}); + export const UploadMetadataSchema = z.object({ container: z.enum(containerNames), blobName: z.string().min(1).max(1024), @@ -45,4 +50,5 @@ export interface BlobInfo { export type GenerateSasInput = z.infer; export type ListBlobsInput = z.infer; export type DeleteBlobInput = z.infer; +export type BlobInfoQueryInput = z.infer; export type UploadMetadataInput = z.infer; diff --git a/services/platform-service/src/modules/status/health-checker.ts b/services/platform-service/src/modules/status/health-checker.ts index a7a8d90d..5f3d2d3f 100644 --- a/services/platform-service/src/modules/status/health-checker.ts +++ b/services/platform-service/src/modules/status/health-checker.ts @@ -6,7 +6,7 @@ import type { ServiceStatus, ServiceHealth, PlatformStatus } from './types.js'; interface ServiceDef { name: string; envVar: string; - defaultUrl: string; + defaultUrl?: string; healthPath: string; } @@ -23,19 +23,44 @@ const SERVICE_DEFS: ServiceDef[] = [ defaultUrl: 'http://localhost:4005', healthPath: '/health', }, + { + name: 'MCP Server', + envVar: 'MCP_SERVER_URL', + defaultUrl: 'http://localhost:4007', + healthPath: '/health', + }, { name: 'Backend API', envVar: 'BACKEND_URL', - defaultUrl: 'http://localhost:8000', + defaultUrl: undefined, healthPath: '/health', }, ]; +function getEnabledServiceDefs(): ServiceDef[] { + return SERVICE_DEFS.filter(def => { + const configuredUrl = process.env[def.envVar]?.trim(); + return Boolean(configuredUrl || def.defaultUrl); + }); +} + /** * Check health of a single service. */ async function checkService(def: ServiceDef): Promise { - const baseUrl = process.env[def.envVar] || def.defaultUrl; + const configuredUrl = process.env[def.envVar]?.trim(); + const baseUrl = configuredUrl || def.defaultUrl; + if (!baseUrl) { + return { + name: def.name, + url: 'not_configured', + health: 'maintenance', + responseTimeMs: null, + lastCheckedAt: new Date().toISOString(), + error: `${def.envVar} is not configured`, + }; + } + const url = `${baseUrl}${def.healthPath}`; const start = Date.now(); @@ -97,13 +122,14 @@ function deriveOverallHealth(services: ServiceStatus[]): ServiceHealth { * Check all services and return platform-wide status. */ export async function checkAllServices(): Promise { - const results = await Promise.allSettled(SERVICE_DEFS.map(checkService)); + const enabledDefs = getEnabledServiceDefs(); + const results = await Promise.allSettled(enabledDefs.map(checkService)); const services: ServiceStatus[] = results.map((result, i) => { if (result.status === 'fulfilled') return result.value; return { - name: SERVICE_DEFS[i].name, - url: SERVICE_DEFS[i].defaultUrl, + name: enabledDefs[i].name, + url: enabledDefs[i].defaultUrl ?? 'not_configured', health: 'major_outage' as ServiceHealth, responseTimeMs: null, lastCheckedAt: new Date().toISOString(), diff --git a/services/platform-service/src/modules/status/routes.ts b/services/platform-service/src/modules/status/routes.ts index 180f7357..bd1d6fbc 100644 --- a/services/platform-service/src/modules/status/routes.ts +++ b/services/platform-service/src/modules/status/routes.ts @@ -5,12 +5,211 @@ import { CreateIncidentSchema, UpdateIncidentSchema } from './types.js'; import type { IncidentDoc, IncidentUpdate } from './types.js'; import * as repo from './repository.js'; import { checkAllServices } from './health-checker.js'; +import { getDependencyHealthReport, getSelfTestReport } from './self-test.js'; const DEFAULT_PRODUCT_ID = 'lysnrai'; +function renderSelfTestPage(): string { + return ` + + + + + Platform Self-Test + + + +
+

Platform Self-Test

+

Runs dependency checks plus a real blob upload, download, signed URL, and cleanup cycle.

+
+ + Dependency JSON + Self-Test JSON +
+
+ Summary: + Loading… +
+
+

Dependency Checks

+
Loading…
+
+
+

Service Status

+
Loading…
+
+
+

Raw JSON

+
Loading…
+
+
+ + +`; +} + export async function statusRoutes(app: FastifyInstance) { // ── Public endpoints (no auth) ───────────────────────────── + app.get('/health/dependencies', async (_req, reply) => { + const report = await getDependencyHealthReport(); + if (report.status !== 'ok') { + reply.code(503); + } + return report; + }); + + app.get('/self-test.json', async (_req, reply) => { + const report = await getSelfTestReport(); + if (report.status !== 'ok') { + reply.code(503); + } + return report; + }); + + app.get('/self-test', async (_req, reply) => { + reply.type('text/html; charset=utf-8'); + return renderSelfTestPage(); + }); + // Get current platform status (health checks all services) app.get('/status', async () => { return checkAllServices(); diff --git a/services/platform-service/src/modules/status/self-test.ts b/services/platform-service/src/modules/status/self-test.ts new file mode 100644 index 00000000..5ce0e0ba --- /dev/null +++ b/services/platform-service/src/modules/status/self-test.ts @@ -0,0 +1,188 @@ +import { randomUUID } from 'node:crypto'; +import { URL } from 'node:url'; +import { getStorage } from '@bytelyst/storage'; +import { BLOB_CONTAINERS, getBucket, isBlobStorageConfigured } from '../../lib/blob.js'; +import { checkAllServices } from './health-checker.js'; +import type { PlatformStatus } from './types.js'; + +export type DiagnosticStatus = 'pass' | 'fail'; + +export interface DiagnosticCheck { + name: string; + status: DiagnosticStatus; + durationMs: number; + summary: string; + details?: Record; + error?: string; +} + +export interface DiagnosticsReport { + status: 'ok' | 'degraded'; + checkedAt: string; + dependencyChecks: DiagnosticCheck[]; + serviceStatus: PlatformStatus; +} + +function checkStatus(ok: boolean): DiagnosticStatus { + return ok ? 'pass' : 'fail'; +} + +function reportStatus(checks: DiagnosticCheck[], serviceStatus: PlatformStatus): 'ok' | 'degraded' { + if (checks.some(check => check.status === 'fail')) return 'degraded'; + return serviceStatus.overall === 'operational' ? 'ok' : 'degraded'; +} + +export async function checkBlobStorageReadiness(): Promise { + const startedAt = Date.now(); + + if (!isBlobStorageConfigured()) { + return { + name: 'blob_storage', + status: 'fail', + durationMs: Date.now() - startedAt, + summary: 'Blob storage is not configured', + details: { + configured: false, + provider: process.env.STORAGE_PROVIDER || 'azure', + }, + }; + } + + try { + const storage = await getStorage(); + const healthy = await storage.isHealthy(); + + return { + name: 'blob_storage', + status: checkStatus(healthy), + durationMs: Date.now() - startedAt, + summary: healthy + ? 'Blob storage provider is reachable' + : 'Blob storage provider is not reachable', + details: { + configured: true, + provider: process.env.STORAGE_PROVIDER || 'azure', + }, + }; + } catch (error) { + return { + name: 'blob_storage', + status: 'fail', + durationMs: Date.now() - startedAt, + summary: 'Blob storage readiness check failed', + error: error instanceof Error ? error.message : String(error), + details: { + configured: true, + provider: process.env.STORAGE_PROVIDER || 'azure', + }, + }; + } +} + +export async function runBlobStorageSelfTest(): Promise { + const startedAt = Date.now(); + const bucket = await getBucket(BLOB_CONTAINERS.attachments); + const key = `self-test/platform-service/${new Date().toISOString().replace(/[:.]/g, '-')}-${randomUUID()}.txt`; + const payload = `platform-service self-test ${new Date().toISOString()}`; + + try { + const storage = await getStorage(); + const providerHealthy = await storage.isHealthy(); + + await bucket.upload(key, payload, { + contentType: 'text/plain', + metadata: { + source: 'platform-service-self-test', + }, + }); + + const existsAfterUpload = await bucket.exists(key); + const listed = await bucket.list(key); + const downloaded = (await bucket.download(key)).toString('utf8'); + const signedUrl = await bucket.getSignedUrl(key, { permissions: 'read', expiresIn: 300 }); + + await bucket.delete(key); + + const existsAfterDelete = await bucket.exists(key); + const signedUrlHost = new URL(signedUrl).host; + const listedBlob = listed.find(blob => blob.key === key); + const passed = + providerHealthy && + existsAfterUpload && + !!listedBlob && + downloaded === payload && + !existsAfterDelete; + + return { + name: 'blob_storage_self_test', + status: checkStatus(passed), + durationMs: Date.now() - startedAt, + summary: passed + ? 'Blob storage upload/list/download/sign/delete flow passed' + : 'Blob storage self-test reported a failed assertion', + details: { + bucket: BLOB_CONTAINERS.attachments, + key, + provider: process.env.STORAGE_PROVIDER || 'azure', + providerHealthy, + existsAfterUpload, + listed: !!listedBlob, + downloadedMatches: downloaded === payload, + existsAfterDelete, + signedUrlHost, + }, + }; + } catch (error) { + try { + await bucket.delete(key); + } catch { + // Best-effort cleanup for temporary self-test blobs. + } + + return { + name: 'blob_storage_self_test', + status: 'fail', + durationMs: Date.now() - startedAt, + summary: 'Blob storage self-test failed', + error: error instanceof Error ? error.message : String(error), + details: { + bucket: BLOB_CONTAINERS.attachments, + key, + provider: process.env.STORAGE_PROVIDER || 'azure', + }, + }; + } +} + +export async function getDependencyHealthReport(): Promise { + const [blobStorage, serviceStatus] = await Promise.all([ + checkBlobStorageReadiness(), + checkAllServices(), + ]); + + const dependencyChecks = [blobStorage]; + + return { + status: reportStatus(dependencyChecks, serviceStatus), + checkedAt: new Date().toISOString(), + dependencyChecks, + serviceStatus, + }; +} + +export async function getSelfTestReport(): Promise { + const [blobStorage, blobStorageSelfTest, serviceStatus] = await Promise.all([ + checkBlobStorageReadiness(), + runBlobStorageSelfTest(), + checkAllServices(), + ]); + + const dependencyChecks = [blobStorage, blobStorageSelfTest]; + + return { + status: reportStatus(dependencyChecks, serviceStatus), + checkedAt: new Date().toISOString(), + dependencyChecks, + serviceStatus, + }; +}