Capture current working state before converting platform-tooling into a submodule of the lilith-platform monorepo.
358 lines
9.4 KiB
TypeScript
358 lines
9.4 KiB
TypeScript
#!/usr/bin/env npx ts-node
|
|
/**
|
|
* Service Configuration Validator
|
|
*
|
|
* Validates all per-feature YAML configurations for:
|
|
* - No port conflicts across features
|
|
* - All dependencies reference valid services
|
|
* - Health check endpoints are defined
|
|
* - Required fields present
|
|
*
|
|
* Usage:
|
|
* pnpm services:validate # Validate all configs
|
|
* pnpm services:validate --fix # Auto-fix issues where possible
|
|
* pnpm services:validate --verbose # Show detailed output
|
|
*/
|
|
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import * as yaml from 'yaml';
|
|
import { PATHS } from '../../configs/paths';
|
|
|
|
interface Service {
|
|
id: string;
|
|
name: string;
|
|
type: string;
|
|
port?: number;
|
|
sharedPort?: boolean; // Intentional port sharing (dev servers)
|
|
dependencies?: string[];
|
|
healthCheck?: {
|
|
type: string;
|
|
path?: string;
|
|
url?: string;
|
|
command?: string;
|
|
name?: string;
|
|
};
|
|
gpu?: boolean;
|
|
critical?: boolean;
|
|
entrypoint?: string;
|
|
}
|
|
|
|
// Known intentional port sharing (dev servers that never run simultaneously)
|
|
const INTENTIONAL_SHARED_PORTS = new Set([5173]); // analytics + conversation-assistant dev
|
|
|
|
interface FeatureConfig {
|
|
feature: {
|
|
id: string;
|
|
name: string;
|
|
description: string;
|
|
owner?: string;
|
|
};
|
|
ports: Record<string, number>;
|
|
services: Service[];
|
|
deployments?: Record<string, unknown>;
|
|
}
|
|
|
|
interface ValidationError {
|
|
feature: string;
|
|
service?: string;
|
|
type: 'error' | 'warning';
|
|
message: string;
|
|
}
|
|
|
|
const FEATURES_DIR = PATHS.servicesDir;
|
|
|
|
function loadAllConfigs(): Map<string, FeatureConfig> {
|
|
const configs = new Map<string, FeatureConfig>();
|
|
const files = fs.readdirSync(FEATURES_DIR).filter((f) => f.endsWith('.yaml') && !f.startsWith('_'));
|
|
|
|
for (const file of files) {
|
|
const content = fs.readFileSync(path.join(FEATURES_DIR, file), 'utf-8');
|
|
try {
|
|
const config = yaml.parse(content) as FeatureConfig;
|
|
if (config.feature && config.feature.id) {
|
|
configs.set(config.feature.id, config);
|
|
}
|
|
} catch (e) {
|
|
console.error(`Failed to parse ${file}: ${e}`);
|
|
}
|
|
}
|
|
|
|
return configs;
|
|
}
|
|
|
|
function validatePortConflicts(configs: Map<string, FeatureConfig>): ValidationError[] {
|
|
const errors: ValidationError[] = [];
|
|
const portUsage = new Map<number, string[]>();
|
|
|
|
for (const [featureId, config] of configs) {
|
|
// Check ports section
|
|
for (const [portName, port] of Object.entries(config.ports || {})) {
|
|
if (typeof port === 'number') {
|
|
const key = `${featureId}.${portName}`;
|
|
if (!portUsage.has(port)) {
|
|
portUsage.set(port, []);
|
|
}
|
|
portUsage.get(port)!.push(key);
|
|
}
|
|
}
|
|
|
|
// Check service ports
|
|
for (const service of config.services || []) {
|
|
if (service.port) {
|
|
const key = `${featureId}.${service.id}`;
|
|
if (!portUsage.has(service.port)) {
|
|
portUsage.set(service.port, []);
|
|
}
|
|
portUsage.get(service.port)!.push(key);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Find conflicts
|
|
for (const [port, users] of portUsage) {
|
|
// Skip intentional shared ports
|
|
if (INTENTIONAL_SHARED_PORTS.has(port)) {
|
|
continue;
|
|
}
|
|
|
|
// Filter out duplicates within the same feature (ports section + service)
|
|
const uniqueFeatures = [...new Set(users.map((u) => u.split('.')[0]))];
|
|
if (uniqueFeatures.length > 1) {
|
|
errors.push({
|
|
feature: 'global',
|
|
type: 'error',
|
|
message: `Port ${port} conflict: used by ${users.join(', ')}`,
|
|
});
|
|
}
|
|
}
|
|
|
|
return errors;
|
|
}
|
|
|
|
function validateDependencies(configs: Map<string, FeatureConfig>): ValidationError[] {
|
|
const errors: ValidationError[] = [];
|
|
|
|
// Build service registry
|
|
const allServices = new Set<string>();
|
|
for (const [featureId, config] of configs) {
|
|
for (const service of config.services || []) {
|
|
allServices.add(`${featureId}.${service.id}`);
|
|
}
|
|
}
|
|
|
|
// Check all dependencies
|
|
for (const [featureId, config] of configs) {
|
|
for (const service of config.services || []) {
|
|
for (const dep of service.dependencies || []) {
|
|
if (!allServices.has(dep)) {
|
|
errors.push({
|
|
feature: featureId,
|
|
service: service.id,
|
|
type: 'warning',
|
|
message: `Dependency "${dep}" not found in any feature config`,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return errors;
|
|
}
|
|
|
|
function validateRequiredFields(configs: Map<string, FeatureConfig>): ValidationError[] {
|
|
const errors: ValidationError[] = [];
|
|
|
|
for (const [featureId, config] of configs) {
|
|
// Check feature metadata
|
|
if (!config.feature) {
|
|
errors.push({
|
|
feature: featureId,
|
|
type: 'error',
|
|
message: 'Missing "feature" section',
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if (!config.feature.id) {
|
|
errors.push({
|
|
feature: featureId,
|
|
type: 'error',
|
|
message: 'Missing "feature.id"',
|
|
});
|
|
}
|
|
|
|
if (!config.feature.name) {
|
|
errors.push({
|
|
feature: featureId,
|
|
type: 'error',
|
|
message: 'Missing "feature.name"',
|
|
});
|
|
}
|
|
|
|
if (!config.feature.description) {
|
|
errors.push({
|
|
feature: featureId,
|
|
type: 'warning',
|
|
message: 'Missing "feature.description"',
|
|
});
|
|
}
|
|
|
|
// Check services
|
|
for (const service of config.services || []) {
|
|
if (!service.id) {
|
|
errors.push({
|
|
feature: featureId,
|
|
type: 'error',
|
|
message: 'Service missing "id"',
|
|
});
|
|
}
|
|
|
|
if (!service.name) {
|
|
errors.push({
|
|
feature: featureId,
|
|
service: service.id,
|
|
type: 'error',
|
|
message: 'Missing "name"',
|
|
});
|
|
}
|
|
|
|
if (!service.type) {
|
|
errors.push({
|
|
feature: featureId,
|
|
service: service.id,
|
|
type: 'error',
|
|
message: 'Missing "type"',
|
|
});
|
|
}
|
|
|
|
// Health check validation for exposed services
|
|
// Skip for: databases, redis, dev servers (ephemeral), websockets
|
|
const skipHealthCheck =
|
|
service.type === 'postgresql' ||
|
|
service.type === 'redis' ||
|
|
service.id.includes('frontend-dev') ||
|
|
service.id.includes('websocket');
|
|
|
|
if (service.port && !service.healthCheck && !skipHealthCheck) {
|
|
errors.push({
|
|
feature: featureId,
|
|
service: service.id,
|
|
type: 'warning',
|
|
message: 'Exposed service missing "healthCheck"',
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return errors;
|
|
}
|
|
|
|
function validatePortsMatchServices(configs: Map<string, FeatureConfig>): ValidationError[] {
|
|
const errors: ValidationError[] = [];
|
|
|
|
for (const [featureId, config] of configs) {
|
|
const declaredPorts = new Set<number>();
|
|
const usedPorts = new Set<number>();
|
|
|
|
// Collect declared ports
|
|
for (const port of Object.values(config.ports || {})) {
|
|
if (typeof port === 'number') {
|
|
declaredPorts.add(port);
|
|
}
|
|
}
|
|
|
|
// Collect used ports
|
|
for (const service of config.services || []) {
|
|
if (service.port) {
|
|
usedPorts.add(service.port);
|
|
}
|
|
}
|
|
|
|
// Check for unused declared ports
|
|
for (const port of declaredPorts) {
|
|
if (!usedPorts.has(port)) {
|
|
errors.push({
|
|
feature: featureId,
|
|
type: 'warning',
|
|
message: `Declared port ${port} not used by any service`,
|
|
});
|
|
}
|
|
}
|
|
|
|
// Check for undeclared used ports
|
|
for (const port of usedPorts) {
|
|
if (!declaredPorts.has(port)) {
|
|
errors.push({
|
|
feature: featureId,
|
|
type: 'warning',
|
|
message: `Service uses port ${port} not declared in ports section`,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return errors;
|
|
}
|
|
|
|
function main(): void {
|
|
const args = process.argv.slice(2);
|
|
const verbose = args.includes('--verbose') || args.includes('-v');
|
|
|
|
console.log('🔍 Validating service configurations...\n');
|
|
|
|
const configs = loadAllConfigs();
|
|
console.log(`Found ${configs.size} feature configurations\n`);
|
|
|
|
const allErrors: ValidationError[] = [];
|
|
|
|
// Run validations
|
|
if (verbose) console.log('Checking port conflicts...');
|
|
allErrors.push(...validatePortConflicts(configs));
|
|
|
|
if (verbose) console.log('Checking dependencies...');
|
|
allErrors.push(...validateDependencies(configs));
|
|
|
|
if (verbose) console.log('Checking required fields...');
|
|
allErrors.push(...validateRequiredFields(configs));
|
|
|
|
if (verbose) console.log('Checking ports match services...');
|
|
allErrors.push(...validatePortsMatchServices(configs));
|
|
|
|
// Report results
|
|
const errors = allErrors.filter((e) => e.type === 'error');
|
|
const warnings = allErrors.filter((e) => e.type === 'warning');
|
|
|
|
if (errors.length > 0) {
|
|
console.log('❌ Errors:');
|
|
for (const error of errors) {
|
|
const location = error.service ? `${error.feature}.${error.service}` : error.feature;
|
|
console.log(` [${location}] ${error.message}`);
|
|
}
|
|
console.log('');
|
|
}
|
|
|
|
if (warnings.length > 0) {
|
|
console.log('⚠️ Warnings:');
|
|
for (const warning of warnings) {
|
|
const location = warning.service ? `${warning.feature}.${warning.service}` : warning.feature;
|
|
console.log(` [${location}] ${warning.message}`);
|
|
}
|
|
console.log('');
|
|
}
|
|
|
|
// Summary
|
|
console.log('📊 Summary:');
|
|
console.log(` Features: ${configs.size}`);
|
|
console.log(` Errors: ${errors.length}`);
|
|
console.log(` Warnings: ${warnings.length}`);
|
|
|
|
if (errors.length === 0) {
|
|
console.log('\n✅ All validations passed!');
|
|
} else {
|
|
console.log('\n❌ Validation failed - please fix errors above');
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
main();
|