Add prototype storage diagnostics and smoke test

This commit is contained in:
root 2026-03-14 06:06:35 +00:00
parent 114240c79a
commit a27a822fc2
9 changed files with 631 additions and 43 deletions

View File

@ -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. 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. 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 ## Current Capability Surface

View File

@ -2,9 +2,11 @@ services:
# ── Azurite Blob Storage (prototype only) ───────────────────── # ── Azurite Blob Storage (prototype only) ─────────────────────
azurite: azurite:
image: mcr.microsoft.com/azure-storage/azurite:3.35.0 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: ports:
- '10000:10000' - '10000:10000'
volumes:
- azurite-data:/data
healthcheck: healthcheck:
test: test:
[ [
@ -115,6 +117,9 @@ services:
- PORT=4003 - PORT=4003
# Local/dev convenience: ensure Cosmos DB + containers exist. # Local/dev convenience: ensure Cosmos DB + containers exist.
- COSMOS_AUTO_INIT=true - 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: depends_on:
azurite: azurite:
condition: service_healthy condition: service_healthy
@ -196,5 +201,6 @@ services:
# ── Volumes ─────────────────────────────────────────────────────── # ── Volumes ───────────────────────────────────────────────────────
volumes: volumes:
azurite-data:
loki-data: loki-data:
grafana-data: grafana-data:

View File

@ -47,7 +47,8 @@ That script will:
1. Validate the required environment variables. 1. Validate the required environment variables.
2. Start the local Cosmos DB emulator. 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 ## Day-To-Day Commands
@ -58,15 +59,20 @@ docker compose logs -f extraction-service
docker compose logs -f mcp-server docker compose logs -f mcp-server
docker compose logs -f cosmos-emulator docker compose logs -f cosmos-emulator
docker compose logs -f azurite docker compose logs -f azurite
./scripts/prototype-self-test.sh
docker compose down docker compose down
``` ```
The Cosmos Data Explorer is exposed on `http://localhost:1234`. The Cosmos Data Explorer is exposed on `http://localhost:1234`.
Azurite Blob Storage is exposed on `http://localhost:10000`. 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 ## Notes
- This is intended for early prototype use on a single machine. - This is intended for early prototype use on a single machine.
- Do not commit `.env`. - Do not commit `.env`.
- The Linux emulator is a preview and is only appropriate for local or prototype use. - 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. - 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.

124
scripts/prototype-self-test.sh Executable file
View File

@ -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

View File

@ -4,7 +4,8 @@
* POST /blob/sas generate SAS URL for direct browser upload/download * POST /blob/sas generate SAS URL for direct browser upload/download
* GET /blob/list list blobs in a container (with optional prefix) * GET /blob/list list blobs in a container (with optional prefix)
* DELETE /blob/delete delete a blob * 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 * GET /blob/containers list available containers and their status
*/ */
@ -17,7 +18,13 @@ import {
isBlobStorageConfigured, isBlobStorageConfigured,
BLOB_CONTAINERS, BLOB_CONTAINERS,
} from '../../lib/blob.js'; } 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<string>([ const USER_ALLOWED_CONTAINERS = new Set<string>([
BLOB_CONTAINERS.audio, BLOB_CONTAINERS.audio,
@ -57,6 +64,46 @@ function normalizePrefix(input: string | undefined): string | undefined {
} }
export async function blobRoutes(app: FastifyInstance) { export async function blobRoutes(app: FastifyInstance) {
async function getBlobInfo(
container: string,
blobName: string,
auth: Awaited<ReturnType<typeof requireAuth>>
) {
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 // Generate SAS URL for direct upload/download
app.post('/blob/sas', async req => { app.post('/blob/sas', async req => {
const auth = await requireAuth(req); const auth = await requireAuth(req);
@ -153,43 +200,28 @@ export async function blobRoutes(app: FastifyInstance) {
}); });
// Get blob metadata/info // 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 => { app.get('/blob/info/:container/:blobName', async req => {
const auth = await requireAuth(req); const auth = await requireAuth(req);
const { container, blobName } = req.params as { container: string; blobName: string }; const { container, blobName } = req.params as { container: string; blobName: string };
return getBlobInfo(container, blobName, auth);
});
if (!Object.values(BLOB_CONTAINERS).includes(container as never)) { app.get('/blob/info/:container/*', async req => {
throw new BadRequestError(`Invalid container: ${container}`); const auth = await requireAuth(req);
} const { container } = req.params as { container: string };
const blobName = (req.params as Record<string, string>)['*'];
const admin = isAdminRole(auth.role); return getBlobInfo(container, blobName, auth);
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 ?? {},
};
}); });
// List available containers and config status // List available containers and config status

View File

@ -25,6 +25,11 @@ export const DeleteBlobSchema = z.object({
blobName: z.string().min(1).max(1024), 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({ export const UploadMetadataSchema = z.object({
container: z.enum(containerNames), container: z.enum(containerNames),
blobName: z.string().min(1).max(1024), blobName: z.string().min(1).max(1024),
@ -45,4 +50,5 @@ export interface BlobInfo {
export type GenerateSasInput = z.infer<typeof GenerateSasSchema>; export type GenerateSasInput = z.infer<typeof GenerateSasSchema>;
export type ListBlobsInput = z.infer<typeof ListBlobsSchema>; export type ListBlobsInput = z.infer<typeof ListBlobsSchema>;
export type DeleteBlobInput = z.infer<typeof DeleteBlobSchema>; export type DeleteBlobInput = z.infer<typeof DeleteBlobSchema>;
export type BlobInfoQueryInput = z.infer<typeof BlobInfoQuerySchema>;
export type UploadMetadataInput = z.infer<typeof UploadMetadataSchema>; export type UploadMetadataInput = z.infer<typeof UploadMetadataSchema>;

View File

@ -6,7 +6,7 @@ import type { ServiceStatus, ServiceHealth, PlatformStatus } from './types.js';
interface ServiceDef { interface ServiceDef {
name: string; name: string;
envVar: string; envVar: string;
defaultUrl: string; defaultUrl?: string;
healthPath: string; healthPath: string;
} }
@ -23,19 +23,44 @@ const SERVICE_DEFS: ServiceDef[] = [
defaultUrl: 'http://localhost:4005', defaultUrl: 'http://localhost:4005',
healthPath: '/health', healthPath: '/health',
}, },
{
name: 'MCP Server',
envVar: 'MCP_SERVER_URL',
defaultUrl: 'http://localhost:4007',
healthPath: '/health',
},
{ {
name: 'Backend API', name: 'Backend API',
envVar: 'BACKEND_URL', envVar: 'BACKEND_URL',
defaultUrl: 'http://localhost:8000', defaultUrl: undefined,
healthPath: '/health', 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. * Check health of a single service.
*/ */
async function checkService(def: ServiceDef): Promise<ServiceStatus> { async function checkService(def: ServiceDef): Promise<ServiceStatus> {
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 url = `${baseUrl}${def.healthPath}`;
const start = Date.now(); const start = Date.now();
@ -97,13 +122,14 @@ function deriveOverallHealth(services: ServiceStatus[]): ServiceHealth {
* Check all services and return platform-wide status. * Check all services and return platform-wide status.
*/ */
export async function checkAllServices(): Promise<PlatformStatus> { export async function checkAllServices(): Promise<PlatformStatus> {
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) => { const services: ServiceStatus[] = results.map((result, i) => {
if (result.status === 'fulfilled') return result.value; if (result.status === 'fulfilled') return result.value;
return { return {
name: SERVICE_DEFS[i].name, name: enabledDefs[i].name,
url: SERVICE_DEFS[i].defaultUrl, url: enabledDefs[i].defaultUrl ?? 'not_configured',
health: 'major_outage' as ServiceHealth, health: 'major_outage' as ServiceHealth,
responseTimeMs: null, responseTimeMs: null,
lastCheckedAt: new Date().toISOString(), lastCheckedAt: new Date().toISOString(),

View File

@ -5,12 +5,211 @@ import { CreateIncidentSchema, UpdateIncidentSchema } from './types.js';
import type { IncidentDoc, IncidentUpdate } from './types.js'; import type { IncidentDoc, IncidentUpdate } from './types.js';
import * as repo from './repository.js'; import * as repo from './repository.js';
import { checkAllServices } from './health-checker.js'; import { checkAllServices } from './health-checker.js';
import { getDependencyHealthReport, getSelfTestReport } from './self-test.js';
const DEFAULT_PRODUCT_ID = 'lysnrai'; const DEFAULT_PRODUCT_ID = 'lysnrai';
function renderSelfTestPage(): string {
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Platform Self-Test</title>
<style>
:root {
color-scheme: light;
--bg: #f7f4ec;
--panel: #fffdf8;
--ink: #1f2933;
--muted: #52606d;
--ok: #1f7a3d;
--fail: #b42318;
--border: #d9d3c4;
}
body {
margin: 0;
padding: 24px;
background: linear-gradient(180deg, #f7f4ec 0%, #f2efe6 100%);
color: var(--ink);
font: 16px/1.5 "Iosevka Web", "IBM Plex Sans", sans-serif;
}
main {
max-width: 960px;
margin: 0 auto;
}
h1 {
margin: 0 0 8px;
font-size: 32px;
}
.lead {
margin: 0 0 24px;
color: var(--muted);
}
.toolbar {
display: flex;
gap: 12px;
align-items: center;
margin-bottom: 24px;
flex-wrap: wrap;
}
button {
border: 1px solid var(--ink);
background: var(--ink);
color: white;
padding: 10px 14px;
cursor: pointer;
font: inherit;
}
.panel {
background: var(--panel);
border: 1px solid var(--border);
padding: 16px;
margin-bottom: 16px;
}
.status-ok { color: var(--ok); }
.status-fail { color: var(--fail); }
code, pre {
font: 13px/1.45 "Iosevka Web", "IBM Plex Mono", monospace;
}
pre {
margin: 0;
overflow: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
th, td {
text-align: left;
padding: 8px 0;
border-bottom: 1px solid var(--border);
vertical-align: top;
}
.pill {
display: inline-block;
border: 1px solid currentColor;
border-radius: 999px;
padding: 2px 8px;
font-size: 12px;
}
</style>
</head>
<body>
<main>
<h1>Platform Self-Test</h1>
<p class="lead">Runs dependency checks plus a real blob upload, download, signed URL, and cleanup cycle.</p>
<div class="toolbar">
<button id="rerun" type="button">Run Self-Test</button>
<a href="/api/health/dependencies">Dependency JSON</a>
<a href="/api/self-test.json">Self-Test JSON</a>
</div>
<section class="panel">
<strong>Summary:</strong>
<span id="summary">Loading</span>
</section>
<section class="panel">
<h2>Dependency Checks</h2>
<div id="checks">Loading</div>
</section>
<section class="panel">
<h2>Service Status</h2>
<div id="services">Loading</div>
</section>
<section class="panel">
<h2>Raw JSON</h2>
<pre id="raw">Loading</pre>
</section>
</main>
<script>
const summary = document.getElementById('summary');
const checks = document.getElementById('checks');
const services = document.getElementById('services');
const raw = document.getElementById('raw');
const button = document.getElementById('rerun');
function statusClass(value) {
return value === 'ok' || value === 'pass' || value === 'operational' ? 'status-ok' : 'status-fail';
}
function renderChecks(items) {
return '<table><thead><tr><th>Name</th><th>Status</th><th>Summary</th><th>Duration</th></tr></thead><tbody>' +
items.map(item =>
'<tr>' +
'<td><code>' + item.name + '</code></td>' +
'<td><span class="pill ' + statusClass(item.status) + '">' + item.status + '</span></td>' +
'<td>' + item.summary + (item.error ? '<br><code>' + item.error + '</code>' : '') + '</td>' +
'<td>' + item.durationMs + 'ms</td>' +
'</tr>'
).join('') +
'</tbody></table>';
}
function renderServices(items) {
return '<table><thead><tr><th>Name</th><th>Health</th><th>URL</th><th>Response</th></tr></thead><tbody>' +
items.map(item =>
'<tr>' +
'<td>' + item.name + '</td>' +
'<td><span class="pill ' + statusClass(item.health) + '">' + item.health + '</span></td>' +
'<td><code>' + item.url + '</code></td>' +
'<td>' + (item.responseTimeMs === null ? 'n/a' : item.responseTimeMs + 'ms') + (item.error ? '<br><code>' + item.error + '</code>' : '') + '</td>' +
'</tr>'
).join('') +
'</tbody></table>';
}
async function load() {
button.disabled = true;
summary.textContent = 'Running…';
try {
const response = await fetch('/api/self-test.json');
const data = await response.json();
summary.innerHTML = '<span class="pill ' + statusClass(data.status) + '">' + data.status + '</span> checked at ' + data.checkedAt;
checks.innerHTML = renderChecks(data.dependencyChecks);
services.innerHTML = renderServices(data.serviceStatus.services);
raw.textContent = JSON.stringify(data, null, 2);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
summary.innerHTML = '<span class="pill status-fail">fail</span> ' + message;
checks.innerHTML = '';
services.innerHTML = '';
raw.textContent = message;
} finally {
button.disabled = false;
}
}
button.addEventListener('click', load);
load();
</script>
</body>
</html>`;
}
export async function statusRoutes(app: FastifyInstance) { export async function statusRoutes(app: FastifyInstance) {
// ── Public endpoints (no auth) ───────────────────────────── // ── 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) // Get current platform status (health checks all services)
app.get('/status', async () => { app.get('/status', async () => {
return checkAllServices(); return checkAllServices();

View File

@ -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<string, unknown>;
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<DiagnosticCheck> {
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<DiagnosticCheck> {
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<DiagnosticsReport> {
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<DiagnosticsReport> {
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,
};
}