feat: add extension registry health checks to doctor, expose registry on Fastify
This commit is contained in:
@@ -0,0 +1,163 @@
|
|||||||
|
/**
|
||||||
|
* S8: Doctor thread - Extension registry health checks
|
||||||
|
*
|
||||||
|
* Checks:
|
||||||
|
* - Extension registration status
|
||||||
|
* - Extension health check results
|
||||||
|
* - Production safety of registered extensions
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ExtensionRegistry, ApophisExtension } from '../../../../extension/types.js'
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export interface ExtensionCheckResult {
|
||||||
|
name: string
|
||||||
|
status: 'pass' | 'fail' | 'warn'
|
||||||
|
message: string
|
||||||
|
detail?: string
|
||||||
|
remediation?: string
|
||||||
|
mode: 'all' | 'verify' | 'observe' | 'qualify'
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExtensionCheckOptions {
|
||||||
|
extensionRegistry?: ExtensionRegistry
|
||||||
|
env?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Extension presence
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function checkExtensionPresence(options: ExtensionCheckOptions): ExtensionCheckResult {
|
||||||
|
const registry = options.extensionRegistry
|
||||||
|
|
||||||
|
if (!registry || registry.extensions.length === 0) {
|
||||||
|
return {
|
||||||
|
name: 'extensions-presence',
|
||||||
|
status: 'pass',
|
||||||
|
message: 'No extensions registered (optional)',
|
||||||
|
mode: 'all',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const names = registry.extensions.map((e: ApophisExtension) => e.name).join(', ')
|
||||||
|
return {
|
||||||
|
name: 'extensions-presence',
|
||||||
|
status: 'pass',
|
||||||
|
message: `${registry.extensions.length} extension(s) registered: ${names}`,
|
||||||
|
mode: 'all',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Extension health
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function checkExtensionHealth(options: ExtensionCheckOptions): Promise<ExtensionCheckResult> {
|
||||||
|
const registry = options.extensionRegistry
|
||||||
|
|
||||||
|
if (!registry || registry.extensions.length === 0) {
|
||||||
|
return {
|
||||||
|
name: 'extensions-health',
|
||||||
|
status: 'pass',
|
||||||
|
message: 'No extensions to health-check',
|
||||||
|
mode: 'all',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await registry.runHealthChecks()
|
||||||
|
const unhealthy = results.filter((r: { name: string; error?: string }) => r.error)
|
||||||
|
|
||||||
|
if (unhealthy.length === 0) {
|
||||||
|
return {
|
||||||
|
name: 'extensions-health',
|
||||||
|
status: 'pass',
|
||||||
|
message: 'All extensions passed health checks',
|
||||||
|
mode: 'all',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const details = unhealthy.map((r: { name: string; error?: string }) => ` ${r.name}: ${r.error}`).join('\n')
|
||||||
|
return {
|
||||||
|
name: 'extensions-health',
|
||||||
|
status: 'warn',
|
||||||
|
message: `${unhealthy.length} extension(s) failed health checks`,
|
||||||
|
detail: details,
|
||||||
|
remediation: 'Review extension configuration and dependencies',
|
||||||
|
mode: 'all',
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
name: 'extensions-health',
|
||||||
|
status: 'warn',
|
||||||
|
message: 'Could not run extension health checks',
|
||||||
|
detail: error instanceof Error ? error.message : String(error),
|
||||||
|
mode: 'all',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Production safety
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function checkExtensionProductionSafety(options: ExtensionCheckOptions): ExtensionCheckResult {
|
||||||
|
const registry = options.extensionRegistry
|
||||||
|
const env = options.env || process.env.NODE_ENV || 'local'
|
||||||
|
|
||||||
|
if (!registry || registry.extensions.length === 0) {
|
||||||
|
return {
|
||||||
|
name: 'extensions-production-safety',
|
||||||
|
status: 'pass',
|
||||||
|
message: 'No extensions to check',
|
||||||
|
mode: 'all',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isProd = env === 'production' || env === 'prod'
|
||||||
|
if (!isProd) {
|
||||||
|
return {
|
||||||
|
name: 'extensions-production-safety',
|
||||||
|
status: 'pass',
|
||||||
|
message: 'Not in production — extension safety check skipped',
|
||||||
|
mode: 'all',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsafe = registry.extensions.filter((e: ApophisExtension) => e.productionSafe === false)
|
||||||
|
|
||||||
|
if (unsafe.length === 0) {
|
||||||
|
return {
|
||||||
|
name: 'extensions-production-safety',
|
||||||
|
status: 'pass',
|
||||||
|
message: 'All extensions are production-safe',
|
||||||
|
mode: 'all',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const names = unsafe.map((e: ApophisExtension) => e.name).join(', ')
|
||||||
|
return {
|
||||||
|
name: 'extensions-production-safety',
|
||||||
|
status: 'warn',
|
||||||
|
message: `${unsafe.length} extension(s) not marked production-safe`,
|
||||||
|
detail: `Unsafe extensions: ${names}`,
|
||||||
|
remediation: 'Remove these extensions from production config or mark them productionSafe',
|
||||||
|
mode: 'all',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Aggregation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function runExtensionChecks(options: ExtensionCheckOptions): Promise<ExtensionCheckResult[]> {
|
||||||
|
return [
|
||||||
|
checkExtensionPresence(options),
|
||||||
|
await checkExtensionHealth(options),
|
||||||
|
checkExtensionProductionSafety(options),
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ import { runRouteChecks } from './checks/routes.js';
|
|||||||
import { runSafetyChecks } from './checks/safety.js';
|
import { runSafetyChecks } from './checks/safety.js';
|
||||||
import { runDocsChecks } from './checks/docs.js';
|
import { runDocsChecks } from './checks/docs.js';
|
||||||
import { runPluginContractChecks } from './checks/plugin-contracts.js';
|
import { runPluginContractChecks } from './checks/plugin-contracts.js';
|
||||||
|
import { runExtensionChecks } from './checks/extensions.js';
|
||||||
|
|
||||||
import { renderJson } from '../../renderers/json.js';
|
import { renderJson } from '../../renderers/json.js';
|
||||||
|
|
||||||
@@ -197,7 +198,7 @@ async function runPackageChecks(
|
|||||||
checks.push({ ...result, package: packageName });
|
checks.push({ ...result, package: packageName });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 6. Plugin contract checks (all modes)
|
// 6. Plugin contract and extension checks (all modes)
|
||||||
try {
|
try {
|
||||||
const { loadApp } = await import('../../core/app-loader.js')
|
const { loadApp } = await import('../../core/app-loader.js')
|
||||||
const loaded = await loadApp(cwd)
|
const loaded = await loadApp(cwd)
|
||||||
@@ -210,9 +211,16 @@ async function runPackageChecks(
|
|||||||
checks.push({ ...result, package: packageName })
|
checks.push({ ...result, package: packageName })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const extReg = (fastify as any)?._apophisExtensionRegistry
|
||||||
|
if (extReg) {
|
||||||
|
const extResults = await runExtensionChecks({ extensionRegistry: extReg, env: detectEnvironment() })
|
||||||
|
for (const result of extResults) {
|
||||||
|
checks.push({ ...result, package: packageName })
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// App can't be loaded — skip plugin contract checks
|
// App can't be loaded — skip plugin/extension checks
|
||||||
}
|
}
|
||||||
|
|
||||||
return checks;
|
return checks;
|
||||||
|
|||||||
+2
-1
@@ -147,8 +147,9 @@ export const apophisPlugin = async (fastify: FastifyInstance, opts: ApophisOptio
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
fastify.decorate('apophis', decorations)
|
fastify.decorate('apophis', decorations)
|
||||||
// Expose plugin contract registry for CLI qualify access
|
// Expose registries for CLI qualify and doctor access
|
||||||
;(fastify as unknown as Record<string, unknown>)._apophisPluginContractRegistry = pluginContractRegistry
|
;(fastify as unknown as Record<string, unknown>)._apophisPluginContractRegistry = pluginContractRegistry
|
||||||
|
;(fastify as unknown as Record<string, unknown>)._apophisExtensionRegistry = extensionRegistry
|
||||||
// Runtime validation may block requests, so it is disabled in production.
|
// Runtime validation may block requests, so it is disabled in production.
|
||||||
// Observe sinks are explicitly non-blocking and are allowed in production.
|
// Observe sinks are explicitly non-blocking and are allowed in production.
|
||||||
const isProd = process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'prod'
|
const isProd = process.env.NODE_ENV === 'production' || process.env.NODE_ENV === 'prod'
|
||||||
|
|||||||
Reference in New Issue
Block a user