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 { runDocsChecks } from './checks/docs.js';
|
||||
import { runPluginContractChecks } from './checks/plugin-contracts.js';
|
||||
import { runExtensionChecks } from './checks/extensions.js';
|
||||
|
||||
import { renderJson } from '../../renderers/json.js';
|
||||
|
||||
@@ -197,7 +198,7 @@ async function runPackageChecks(
|
||||
checks.push({ ...result, package: packageName });
|
||||
}
|
||||
|
||||
// 6. Plugin contract checks (all modes)
|
||||
// 6. Plugin contract and extension checks (all modes)
|
||||
try {
|
||||
const { loadApp } = await import('../../core/app-loader.js')
|
||||
const loaded = await loadApp(cwd)
|
||||
@@ -210,9 +211,16 @@ async function runPackageChecks(
|
||||
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 {
|
||||
// App can't be loaded — skip plugin contract checks
|
||||
// App can't be loaded — skip plugin/extension checks
|
||||
}
|
||||
|
||||
return checks;
|
||||
|
||||
+2
-1
@@ -147,8 +147,9 @@ export const apophisPlugin = async (fastify: FastifyInstance, opts: ApophisOptio
|
||||
},
|
||||
}
|
||||
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>)._apophisExtensionRegistry = extensionRegistry
|
||||
// Runtime validation may block requests, so it is disabled 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'
|
||||
|
||||
Reference in New Issue
Block a user