Add prototype storage diagnostics and smoke test
This commit is contained in:
parent
114240c79a
commit
a27a822fc2
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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
124
scripts/prototype-self-test.sh
Executable 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
|
||||||
@ -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
|
||||||
|
|||||||
@ -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>;
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
188
services/platform-service/src/modules/status/self-test.ts
Normal file
188
services/platform-service/src/modules/status/self-test.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user