@codelook/codebox-registry
Version:
Service registry and monitoring for Codebox deployed applications
1,509 lines (1,498 loc) • 57.4 kB
JavaScript
// src/index.ts
import { pino as pino4 } from "pino";
import path3 from "path";
// src/database/index.ts
import Database from "better-sqlite3";
import path from "path";
import os from "os";
import fs from "fs";
var RegistryDatabase = class {
db;
constructor(dbPath) {
const defaultPath = path.join(os.homedir(), ".codebox", "registry.db");
const finalPath = dbPath || defaultPath;
const dir = path.dirname(finalPath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
this.db = new Database(finalPath);
this.db.pragma("journal_mode = WAL");
this.initSchema();
}
initSchema() {
this.db.exec(`
CREATE TABLE IF NOT EXISTS services (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
server_id TEXT NOT NULL,
deployment_id TEXT NOT NULL,
name TEXT NOT NULL,
type TEXT NOT NULL,
host TEXT NOT NULL,
port INTEGER,
url TEXT NOT NULL,
health_check_config TEXT,
status TEXT NOT NULL,
version TEXT NOT NULL,
deployed_at TEXT NOT NULL,
last_health_check TEXT,
last_status_change TEXT,
metrics TEXT,
metadata TEXT,
tags TEXT,
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_services_status ON services(status);
CREATE INDEX IF NOT EXISTS idx_services_project ON services(project_id);
CREATE INDEX IF NOT EXISTS idx_services_server ON services(server_id);
`);
this.db.exec(`
CREATE TABLE IF NOT EXISTS service_events (
id TEXT PRIMARY KEY,
service_id TEXT NOT NULL,
event TEXT NOT NULL,
timestamp TEXT NOT NULL,
details TEXT,
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_events_service ON service_events(service_id);
CREATE INDEX IF NOT EXISTS idx_events_timestamp ON service_events(timestamp);
`);
this.db.exec(`
CREATE TABLE IF NOT EXISTS operation_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
service_id TEXT NOT NULL,
operation TEXT NOT NULL,
success BOOLEAN NOT NULL,
message TEXT,
error TEXT,
timestamp TEXT NOT NULL,
duration INTEGER,
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_operations_service ON operation_history(service_id);
CREATE INDEX IF NOT EXISTS idx_operations_timestamp ON operation_history(timestamp);
`);
}
// 服务管理
registerService(service) {
const stmt = this.db.prepare(`
INSERT OR REPLACE INTO services (
id, project_id, server_id, deployment_id, name, type, host, port, url,
health_check_config, status, version, deployed_at, last_health_check,
last_status_change, metrics, metadata, tags, updated_at
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?,
?, ?, ?, ?, ?,
?, ?, ?, ?, CURRENT_TIMESTAMP
)
`);
stmt.run(
service.id,
service.projectId,
service.serverId,
service.deploymentId,
service.name,
service.type,
service.host,
service.port || null,
service.url,
JSON.stringify(service.healthCheck || null),
service.status,
service.version,
service.deployedAt,
service.lastHealthCheck || null,
service.lastStatusChange || null,
JSON.stringify(service.metrics || null),
JSON.stringify(service.metadata || null),
JSON.stringify(service.tags || null)
);
this.recordEvent({
id: `evt-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
serviceId: service.id,
event: ServiceEvent.Registered,
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
details: { version: service.version }
});
}
getService(id) {
const row = this.db.prepare("SELECT * FROM services WHERE id = ?").get(id);
return row ? this.rowToService(row) : null;
}
listServices(filter) {
let query = "SELECT * FROM services WHERE 1=1";
const params = [];
if (filter) {
if (filter.status) {
query += " AND status = ?";
params.push(filter.status);
}
if (filter.type) {
query += " AND type = ?";
params.push(filter.type);
}
if (filter.projectId) {
query += " AND project_id = ?";
params.push(filter.projectId);
}
if (filter.serverId) {
query += " AND server_id = ?";
params.push(filter.serverId);
}
}
query += " ORDER BY deployed_at DESC";
const rows = this.db.prepare(query).all(...params);
return rows.map((row) => this.rowToService(row));
}
updateServiceStatus(id, status) {
const stmt = this.db.prepare(`
UPDATE services
SET status = ?, last_status_change = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`);
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
stmt.run(status, timestamp, id);
this.recordEvent({
id: `evt-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
serviceId: id,
event: ServiceEvent.StatusChanged,
timestamp,
details: { newStatus: status }
});
}
updateServiceMetrics(id, metrics) {
const stmt = this.db.prepare(`
UPDATE services
SET metrics = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`);
stmt.run(JSON.stringify(metrics), id);
}
updateLastHealthCheck(id, timestamp) {
const stmt = this.db.prepare(`
UPDATE services
SET last_health_check = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`);
stmt.run(timestamp, id);
}
deleteService(id) {
this.db.prepare("DELETE FROM services WHERE id = ?").run(id);
}
// 事件管理
recordEvent(event) {
const stmt = this.db.prepare(`
INSERT INTO service_events (id, service_id, event, timestamp, details)
VALUES (?, ?, ?, ?, ?)
`);
stmt.run(
event.id,
event.serviceId,
event.event,
event.timestamp,
JSON.stringify(event.details || null)
);
}
getServiceEvents(serviceId, limit = 100) {
const rows = this.db.prepare(`
SELECT * FROM service_events
WHERE service_id = ?
ORDER BY timestamp DESC
LIMIT ?
`).all(serviceId, limit);
return rows.map((row) => ({
id: row.id,
serviceId: row.service_id,
event: row.event,
timestamp: row.timestamp,
details: row.details ? JSON.parse(row.details) : void 0
}));
}
// 操作历史
recordOperation(result) {
const stmt = this.db.prepare(`
INSERT INTO operation_history (
service_id, operation, success, message, error, timestamp, duration
) VALUES (?, ?, ?, ?, ?, ?, ?)
`);
stmt.run(
result.serviceId,
result.operation,
result.success ? 1 : 0,
result.message || null,
result.error || null,
result.timestamp,
result.duration || null
);
}
getOperationHistory(serviceId, limit = 50) {
const rows = this.db.prepare(`
SELECT * FROM operation_history
WHERE service_id = ?
ORDER BY timestamp DESC
LIMIT ?
`).all(serviceId, limit);
return rows.map((row) => ({
serviceId: row.service_id,
operation: row.operation,
success: row.success === 1,
message: row.message || void 0,
error: row.error || void 0,
timestamp: row.timestamp,
duration: row.duration || void 0
}));
}
// 统计信息
getStatistics() {
const stats = this.db.prepare(`
SELECT
COUNT(*) as total,
SUM(CASE WHEN status = 'running' THEN 1 ELSE 0 END) as running,
SUM(CASE WHEN status = 'stopped' THEN 1 ELSE 0 END) as stopped,
SUM(CASE WHEN status = 'unhealthy' THEN 1 ELSE 0 END) as unhealthy,
SUM(CASE WHEN status = 'unknown' THEN 1 ELSE 0 END) as unknown
FROM services
`).get();
return {
total: stats.total || 0,
running: stats.running || 0,
stopped: stats.stopped || 0,
unhealthy: stats.unhealthy || 0,
unknown: stats.unknown || 0
};
}
// 清理旧数据
cleanupOldData(retentionDays) {
const cutoffDate = /* @__PURE__ */ new Date();
cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
const cutoffTimestamp = cutoffDate.toISOString();
this.db.prepare(`
DELETE FROM service_events
WHERE timestamp < ?
`).run(cutoffTimestamp);
this.db.prepare(`
DELETE FROM operation_history
WHERE timestamp < ?
`).run(cutoffTimestamp);
}
// 工具方法
rowToService(row) {
return {
id: row.id,
projectId: row.project_id,
serverId: row.server_id,
deploymentId: row.deployment_id,
name: row.name,
type: row.type,
host: row.host,
port: row.port || void 0,
url: row.url,
healthCheck: row.health_check_config ? JSON.parse(row.health_check_config) : void 0,
status: row.status,
version: row.version,
deployedAt: row.deployed_at,
lastHealthCheck: row.last_health_check || void 0,
lastStatusChange: row.last_status_change || void 0,
metrics: row.metrics ? JSON.parse(row.metrics) : void 0,
metadata: row.metadata ? JSON.parse(row.metadata) : void 0,
tags: row.tags ? JSON.parse(row.tags) : void 0
};
}
close() {
this.db.close();
}
};
// src/health-checker.ts
import { Cron } from "croner";
import fetch from "node-fetch";
import { pino } from "pino";
// src/types.ts
import { z } from "zod";
var ServiceStatus = /* @__PURE__ */ ((ServiceStatus2) => {
ServiceStatus2["Running"] = "running";
ServiceStatus2["Stopped"] = "stopped";
ServiceStatus2["Unhealthy"] = "unhealthy";
ServiceStatus2["Unknown"] = "unknown";
ServiceStatus2["Starting"] = "starting";
ServiceStatus2["Stopping"] = "stopping";
return ServiceStatus2;
})(ServiceStatus || {});
var ServiceType = /* @__PURE__ */ ((ServiceType2) => {
ServiceType2["Docker"] = "docker";
ServiceType2["DockerCompose"] = "docker-compose";
ServiceType2["Node"] = "node";
ServiceType2["Static"] = "static";
return ServiceType2;
})(ServiceType || {});
var HealthCheckConfigSchema = z.object({
enabled: z.boolean().default(true),
endpoint: z.string().url().optional(),
interval: z.number().min(1e3).default(3e4),
// 默认30秒
timeout: z.number().min(1e3).default(5e3),
// 默认5秒
retries: z.number().min(0).default(3)
});
var ServiceMetricsSchema = z.object({
uptime: z.number().default(0),
// 运行时间(秒)
requestCount: z.number().default(0),
errorCount: z.number().default(0),
errorRate: z.number().default(0),
lastResponseTime: z.number().optional(),
// 最后响应时间(毫秒)
avgResponseTime: z.number().default(0),
healthChecksPassed: z.number().default(0),
healthChecksFailed: z.number().default(0)
});
var DeployedServiceSchema = z.object({
id: z.string(),
projectId: z.string(),
serverId: z.string(),
deploymentId: z.string(),
name: z.string(),
type: z.nativeEnum(ServiceType),
host: z.string(),
port: z.number().optional(),
url: z.string().url(),
healthCheck: HealthCheckConfigSchema.optional(),
status: z.nativeEnum(ServiceStatus),
version: z.string(),
deployedAt: z.string().datetime(),
lastHealthCheck: z.string().datetime().optional(),
lastStatusChange: z.string().datetime().optional(),
metrics: ServiceMetricsSchema.optional(),
metadata: z.record(z.string(), z.any()).optional(),
tags: z.array(z.string()).optional()
});
var ServiceOperation = /* @__PURE__ */ ((ServiceOperation2) => {
ServiceOperation2["Start"] = "start";
ServiceOperation2["Stop"] = "stop";
ServiceOperation2["Restart"] = "restart";
ServiceOperation2["Destroy"] = "destroy";
ServiceOperation2["HealthCheck"] = "health-check";
return ServiceOperation2;
})(ServiceOperation || {});
var OperationResultSchema = z.object({
serviceId: z.string(),
operation: z.nativeEnum(ServiceOperation),
success: z.boolean(),
message: z.string().optional(),
error: z.string().optional(),
timestamp: z.string().datetime(),
duration: z.number().optional()
// 操作耗时(毫秒)
});
var ServiceEvent2 = /* @__PURE__ */ ((ServiceEvent3) => {
ServiceEvent3["Registered"] = "registered";
ServiceEvent3["Started"] = "started";
ServiceEvent3["Stopped"] = "stopped";
ServiceEvent3["Destroyed"] = "destroyed";
ServiceEvent3["HealthCheckPassed"] = "health-check-passed";
ServiceEvent3["HealthCheckFailed"] = "health-check-failed";
ServiceEvent3["StatusChanged"] = "status-changed";
ServiceEvent3["MetricsUpdated"] = "metrics-updated";
return ServiceEvent3;
})(ServiceEvent2 || {});
var ServiceEventRecordSchema = z.object({
id: z.string(),
serviceId: z.string(),
event: z.nativeEnum(ServiceEvent2),
timestamp: z.string().datetime(),
details: z.record(z.string(), z.any()).optional()
});
var RegistryConfigSchema = z.object({
database: z.object({
type: z.enum(["sqlite", "postgres"]).default("sqlite"),
path: z.string().optional(),
connectionString: z.string().optional()
}),
healthCheck: z.object({
enabled: z.boolean().default(true),
defaultInterval: z.number().default(3e4),
defaultTimeout: z.number().default(5e3),
defaultRetries: z.number().default(3)
}),
api: z.object({
enabled: z.boolean().default(true),
port: z.number().default(9091),
host: z.string().default("localhost"),
auth: z.object({
enabled: z.boolean().default(false),
token: z.string().optional()
}).optional()
}),
monitoring: z.object({
retentionDays: z.number().default(30),
metricsInterval: z.number().default(6e4)
// 1分钟
})
});
var ApiResponseSchema = z.object({
success: z.boolean(),
data: z.any().optional(),
error: z.string().optional(),
timestamp: z.string().datetime()
});
var ServiceFilterSchema = z.object({
status: z.nativeEnum(ServiceStatus).optional(),
type: z.nativeEnum(ServiceType).optional(),
projectId: z.string().optional(),
serverId: z.string().optional(),
tags: z.array(z.string()).optional()
});
// src/health-checker.ts
var HealthChecker = class {
logger = pino({ name: "health-checker" });
jobs = /* @__PURE__ */ new Map();
db;
constructor(db) {
this.db = db;
}
// 启动服务的健康检查
async startHealthCheck(service) {
this.stopHealthCheck(service.id);
const config = service.healthCheck;
if (!config?.enabled || !config.endpoint) {
this.logger.info(`Health check disabled for service ${service.name}`);
return;
}
const job = new Cron(
`*/${Math.floor(config.interval / 1e3)} * * * * *`,
// 转换为秒
async () => {
await this.performHealthCheck(service);
},
{
name: `health-check-${service.id}`,
catch: (error) => {
this.logger.error({ error, serviceId: service.id }, "Health check job error");
}
}
);
this.jobs.set(service.id, job);
this.logger.info(
{ serviceId: service.id, interval: config.interval },
`Started health check for service ${service.name}`
);
await this.performHealthCheck(service);
}
// 停止健康检查
stopHealthCheck(serviceId) {
const job = this.jobs.get(serviceId);
if (job) {
job.stop();
this.jobs.delete(serviceId);
this.logger.info({ serviceId }, "Stopped health check");
}
}
// 执行单次健康检查
async performHealthCheck(service) {
const config = service.healthCheck || this.getDefaultConfig();
const startTime = Date.now();
const result = {
serviceId: service.id,
success: false,
status: "unknown" /* Unknown */,
timestamp: (/* @__PURE__ */ new Date()).toISOString()
};
try {
const healthUrl = config.endpoint || `${service.url}/health`;
this.logger.debug({ serviceId: service.id, url: healthUrl }, "Performing health check");
const response = await this.fetchWithRetry(healthUrl, {
timeout: config.timeout,
retries: config.retries
});
const responseTime = Date.now() - startTime;
result.responseTime = responseTime;
if (response.ok) {
result.success = true;
result.status = "running" /* Running */;
this.db.updateServiceStatus(service.id, "running" /* Running */);
this.db.updateLastHealthCheck(service.id, result.timestamp);
const currentMetrics = service.metrics || {
uptime: 0,
requestCount: 0,
errorCount: 0,
errorRate: 0,
avgResponseTime: 0,
healthChecksPassed: 0,
healthChecksFailed: 0
};
currentMetrics.healthChecksPassed++;
currentMetrics.lastResponseTime = responseTime;
const totalChecks = currentMetrics.healthChecksPassed + currentMetrics.healthChecksFailed;
currentMetrics.avgResponseTime = (currentMetrics.avgResponseTime * (totalChecks - 1) + responseTime) / totalChecks;
this.db.updateServiceMetrics(service.id, currentMetrics);
this.db.recordEvent({
id: `evt-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
serviceId: service.id,
event: "health-check-passed" /* HealthCheckPassed */,
timestamp: result.timestamp,
details: { responseTime }
});
this.logger.debug(
{ serviceId: service.id, responseTime },
"Health check passed"
);
} else {
throw new Error(`Health check failed with status ${response.status}`);
}
} catch (error) {
result.success = false;
result.status = "unhealthy" /* Unhealthy */;
result.error = error.message;
this.db.updateServiceStatus(service.id, "unhealthy" /* Unhealthy */);
this.db.updateLastHealthCheck(service.id, result.timestamp);
const currentMetrics = service.metrics || {
uptime: 0,
requestCount: 0,
errorCount: 0,
errorRate: 0,
avgResponseTime: 0,
healthChecksPassed: 0,
healthChecksFailed: 0
};
currentMetrics.healthChecksFailed++;
currentMetrics.errorCount++;
const totalChecks = currentMetrics.healthChecksPassed + currentMetrics.healthChecksFailed;
currentMetrics.errorRate = currentMetrics.healthChecksFailed / totalChecks;
this.db.updateServiceMetrics(service.id, currentMetrics);
this.db.recordEvent({
id: `evt-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
serviceId: service.id,
event: "health-check-failed" /* HealthCheckFailed */,
timestamp: result.timestamp,
details: { error: error.message }
});
this.db.recordOperation({
serviceId: service.id,
operation: "health-check",
success: false,
error: error.message,
timestamp: result.timestamp,
duration: Date.now() - startTime
});
this.logger.error(
{ serviceId: service.id, error: error.message },
"Health check failed"
);
}
return result;
}
// 批量健康检查
async checkAllServices() {
const services = this.db.listServices({ status: "running" /* Running */ });
const results = [];
for (const service of services) {
if (service.healthCheck?.enabled) {
const result = await this.performHealthCheck(service);
results.push(result);
}
}
return results;
}
// 启动所有服务的健康检查
async startAll() {
const services = this.db.listServices();
for (const service of services) {
if (service.status === "running" /* Running */ && service.healthCheck?.enabled) {
await this.startHealthCheck(service);
}
}
}
// 停止所有健康检查
stopAll() {
for (const [serviceId, job] of this.jobs) {
job.stop();
}
this.jobs.clear();
this.logger.info("Stopped all health checks");
}
// 工具方法
async fetchWithRetry(url, options) {
let lastError = null;
for (let i = 0; i <= options.retries; i++) {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), options.timeout);
const response = await fetch(url, {
signal: controller.signal,
headers: {
"User-Agent": "ServiceRegistry/1.0"
}
});
clearTimeout(timeoutId);
return response;
} catch (error) {
lastError = error;
if (i < options.retries) {
await new Promise((resolve) => setTimeout(resolve, Math.pow(2, i) * 1e3));
}
}
}
throw lastError || new Error("Health check failed");
}
getDefaultConfig() {
return {
enabled: true,
interval: 3e4,
timeout: 5e3,
retries: 3
};
}
// 获取健康检查状态
getStatus() {
const activeServices = Array.from(this.jobs.keys());
return {
total: this.jobs.size,
active: activeServices.length,
services: activeServices
};
}
};
// src/service-manager.ts
import { pino as pino2 } from "pino";
import { execa } from "execa";
var ServiceManager = class {
logger = pino2({ name: "service-manager" });
db;
healthChecker;
sshConfig;
constructor(options) {
this.db = options.db;
this.healthChecker = options.healthChecker;
this.sshConfig = options.sshConfig;
}
// 启动服务
async startService(serviceId) {
const service = this.db.getService(serviceId);
if (!service) {
throw new Error(`Service ${serviceId} not found`);
}
const startTime = Date.now();
const result = {
serviceId,
operation: "start" /* Start */,
success: false,
timestamp: (/* @__PURE__ */ new Date()).toISOString()
};
try {
this.logger.info({ serviceId, serviceName: service.name }, "Starting service");
this.db.updateServiceStatus(serviceId, "starting" /* Starting */);
switch (service.type) {
case "docker":
await this.startDockerService(service);
break;
case "docker-compose":
await this.startDockerComposeService(service);
break;
case "node":
await this.startNodeService(service);
break;
default:
throw new Error(`Unsupported service type: ${service.type}`);
}
this.db.updateServiceStatus(serviceId, "running" /* Running */);
await this.healthChecker.startHealthCheck(service);
result.success = true;
result.message = `Service ${service.name} started successfully`;
result.duration = Date.now() - startTime;
this.db.recordEvent({
id: `evt-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
serviceId,
event: "started" /* Started */,
timestamp: result.timestamp
});
this.logger.info({ serviceId, duration: result.duration }, "Service started");
} catch (error) {
result.error = error.message;
result.duration = Date.now() - startTime;
this.db.updateServiceStatus(serviceId, "stopped" /* Stopped */);
this.logger.error({ serviceId, error: error.message }, "Failed to start service");
}
this.db.recordOperation(result);
return result;
}
// 停止服务
async stopService(serviceId) {
const service = this.db.getService(serviceId);
if (!service) {
throw new Error(`Service ${serviceId} not found`);
}
const startTime = Date.now();
const result = {
serviceId,
operation: "stop" /* Stop */,
success: false,
timestamp: (/* @__PURE__ */ new Date()).toISOString()
};
try {
this.logger.info({ serviceId, serviceName: service.name }, "Stopping service");
this.healthChecker.stopHealthCheck(serviceId);
this.db.updateServiceStatus(serviceId, "stopping" /* Stopping */);
switch (service.type) {
case "docker":
await this.stopDockerService(service);
break;
case "docker-compose":
await this.stopDockerComposeService(service);
break;
case "node":
await this.stopNodeService(service);
break;
default:
throw new Error(`Unsupported service type: ${service.type}`);
}
this.db.updateServiceStatus(serviceId, "stopped" /* Stopped */);
result.success = true;
result.message = `Service ${service.name} stopped successfully`;
result.duration = Date.now() - startTime;
this.db.recordEvent({
id: `evt-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
serviceId,
event: "stopped" /* Stopped */,
timestamp: result.timestamp
});
this.logger.info({ serviceId, duration: result.duration }, "Service stopped");
} catch (error) {
result.error = error.message;
result.duration = Date.now() - startTime;
this.logger.error({ serviceId, error: error.message }, "Failed to stop service");
}
this.db.recordOperation(result);
return result;
}
// 重启服务
async restartService(serviceId) {
const stopResult = await this.stopService(serviceId);
if (!stopResult.success) {
return stopResult;
}
await new Promise((resolve) => setTimeout(resolve, 2e3));
return await this.startService(serviceId);
}
// 销毁服务
async destroyService(serviceId, confirmDestroy = false) {
if (!confirmDestroy) {
throw new Error("Destroy operation requires confirmation");
}
const service = this.db.getService(serviceId);
if (!service) {
throw new Error(`Service ${serviceId} not found`);
}
const startTime = Date.now();
const result = {
serviceId,
operation: "destroy" /* Destroy */,
success: false,
timestamp: (/* @__PURE__ */ new Date()).toISOString()
};
try {
this.logger.info({ serviceId, serviceName: service.name }, "Destroying service");
if (service.status === "running" /* Running */) {
await this.stopService(serviceId);
}
switch (service.type) {
case "docker":
await this.destroyDockerService(service);
break;
case "docker-compose":
await this.destroyDockerComposeService(service);
break;
case "node":
await this.destroyNodeService(service);
break;
default:
throw new Error(`Unsupported service type: ${service.type}`);
}
this.db.deleteService(serviceId);
result.success = true;
result.message = `Service ${service.name} destroyed successfully`;
result.duration = Date.now() - startTime;
this.db.recordEvent({
id: `evt-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
serviceId,
event: "destroyed" /* Destroyed */,
timestamp: result.timestamp
});
this.logger.info({ serviceId, duration: result.duration }, "Service destroyed");
} catch (error) {
result.error = error.message;
result.duration = Date.now() - startTime;
this.logger.error({ serviceId, error: error.message }, "Failed to destroy service");
}
this.db.recordOperation(result);
return result;
}
// Docker 服务操作
async startDockerService(service) {
const containerName = service.metadata?.containerName || service.name;
if (service.host === "localhost" || service.host === "127.0.0.1") {
await execa("docker", ["start", containerName]);
} else {
await this.executeRemoteCommand(
service.host,
`sudo docker start ${containerName}`
);
}
}
async stopDockerService(service) {
const containerName = service.metadata?.containerName || service.name;
if (service.host === "localhost" || service.host === "127.0.0.1") {
await execa("docker", ["stop", containerName]);
} else {
await this.executeRemoteCommand(
service.host,
`sudo docker stop ${containerName}`
);
}
}
async destroyDockerService(service) {
const containerName = service.metadata?.containerName || service.name;
if (service.host === "localhost" || service.host === "127.0.0.1") {
await execa("docker", ["rm", "-f", containerName]);
} else {
await this.executeRemoteCommand(
service.host,
`sudo docker rm -f ${containerName}`
);
}
}
// Docker Compose 服务操作
async startDockerComposeService(service) {
const projectPath = service.metadata?.projectPath;
if (!projectPath) {
throw new Error("Project path not found in service metadata");
}
const command = `cd ${projectPath} && sudo docker compose up -d`;
if (service.host === "localhost" || service.host === "127.0.0.1") {
await execa("sh", ["-c", command]);
} else {
await this.executeRemoteCommand(service.host, command);
}
}
async stopDockerComposeService(service) {
const projectPath = service.metadata?.projectPath;
if (!projectPath) {
throw new Error("Project path not found in service metadata");
}
const command = `cd ${projectPath} && sudo docker compose stop`;
if (service.host === "localhost" || service.host === "127.0.0.1") {
await execa("sh", ["-c", command]);
} else {
await this.executeRemoteCommand(service.host, command);
}
}
async destroyDockerComposeService(service) {
const projectPath = service.metadata?.projectPath;
if (!projectPath) {
throw new Error("Project path not found in service metadata");
}
const command = `cd ${projectPath} && sudo docker compose down -v`;
if (service.host === "localhost" || service.host === "127.0.0.1") {
await execa("sh", ["-c", command]);
} else {
await this.executeRemoteCommand(service.host, command);
}
}
// Node.js 服务操作
async startNodeService(service) {
const appName = service.metadata?.pm2Name || service.name;
const command = `pm2 start ${appName}`;
if (service.host === "localhost" || service.host === "127.0.0.1") {
await execa("sh", ["-c", command]);
} else {
await this.executeRemoteCommand(service.host, command);
}
}
async stopNodeService(service) {
const appName = service.metadata?.pm2Name || service.name;
const command = `pm2 stop ${appName}`;
if (service.host === "localhost" || service.host === "127.0.0.1") {
await execa("sh", ["-c", command]);
} else {
await this.executeRemoteCommand(service.host, command);
}
}
async destroyNodeService(service) {
const appName = service.metadata?.pm2Name || service.name;
const projectPath = service.metadata?.projectPath;
const commands = [
`pm2 delete ${appName}`,
projectPath ? `rm -rf ${projectPath}` : null
].filter(Boolean);
for (const command of commands) {
if (service.host === "localhost" || service.host === "127.0.0.1") {
await execa("sh", ["-c", command]);
} else {
await this.executeRemoteCommand(service.host, command);
}
}
}
// 执行远程命令
async executeRemoteCommand(host, command) {
const sshArgs = [host];
if (this.sshConfig?.user) {
sshArgs.unshift(`${this.sshConfig.user}@${host}`);
sshArgs.shift();
}
if (this.sshConfig?.privateKeyPath) {
sshArgs.unshift("-i", this.sshConfig.privateKeyPath);
}
sshArgs.push(command);
await execa("ssh", sshArgs);
}
};
// src/api-server.ts
import express from "express";
import { pino as pino3 } from "pino";
var ApiServer = class {
app;
logger = pino3({ name: "api-server" });
db;
serviceManager;
healthChecker;
config;
constructor(db, serviceManager, healthChecker, config) {
this.db = db;
this.serviceManager = serviceManager;
this.healthChecker = healthChecker;
this.config = config;
this.app = express();
this.setupMiddleware();
this.setupRoutes();
}
setupMiddleware() {
this.app.use(express.json());
this.app.use((req, res, next) => {
res.header("Access-Control-Allow-Origin", "*");
res.header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
res.header("Access-Control-Allow-Headers", "Content-Type, Authorization");
if (req.method === "OPTIONS") {
return res.sendStatus(204);
}
next();
});
if (this.config.auth?.enabled) {
this.app.use((req, res, next) => {
if (req.path === "/health") {
return next();
}
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return this.sendError(res, 401, "Unauthorized");
}
const token = authHeader.substring(7);
if (token !== this.config.auth?.token) {
return this.sendError(res, 401, "Invalid token");
}
next();
});
}
this.app.use((req, res, next) => {
this.logger.info({ method: req.method, path: req.path }, "API request");
next();
});
this.app.use((err, req, res, next) => {
this.logger.error({ error: err.message }, "API error");
this.sendError(res, 500, err.message);
});
}
setupRoutes() {
this.app.get("/health", (req, res) => {
this.sendSuccess(res, { status: "ok", uptime: process.uptime() });
});
this.app.get("/api/stats", (req, res) => {
const stats = this.db.getStatistics();
const healthStatus = this.healthChecker.getStatus();
this.sendSuccess(res, {
services: stats,
healthChecker: healthStatus
});
});
this.app.get("/api/services", (req, res) => {
const filter = {};
if (req.query.status) filter.status = req.query.status;
if (req.query.type) filter.type = req.query.type;
if (req.query.projectId) filter.projectId = req.query.projectId;
if (req.query.serverId) filter.serverId = req.query.serverId;
const services = this.db.listServices(filter);
this.sendSuccess(res, services);
});
this.app.get("/api/services/:id", (req, res) => {
const service = this.db.getService(req.params.id);
if (!service) {
return this.sendError(res, 404, "Service not found");
}
this.sendSuccess(res, service);
});
this.app.post("/api/services", (req, res) => {
try {
const service = req.body;
this.db.registerService(service);
if (service.status === "running" && service.healthCheck?.enabled) {
this.healthChecker.startHealthCheck(service).catch((err) => {
this.logger.error({ error: err.message }, "Failed to start health check");
});
}
this.sendSuccess(res, service, 201);
} catch (error) {
this.sendError(res, 400, error.message);
}
});
this.app.put("/api/services/:id", (req, res) => {
const service = this.db.getService(req.params.id);
if (!service) {
return this.sendError(res, 404, "Service not found");
}
try {
const updatedService = { ...service, ...req.body, id: service.id };
this.db.registerService(updatedService);
this.sendSuccess(res, updatedService);
} catch (error) {
this.sendError(res, 400, error.message);
}
});
this.app.delete("/api/services/:id", (req, res) => {
const service = this.db.getService(req.params.id);
if (!service) {
return this.sendError(res, 404, "Service not found");
}
this.healthChecker.stopHealthCheck(req.params.id);
this.db.deleteService(req.params.id);
this.sendSuccess(res, { message: "Service deleted" });
});
this.app.post("/api/services/:id/start", async (req, res) => {
try {
const result = await this.serviceManager.startService(req.params.id);
this.sendSuccess(res, result);
} catch (error) {
this.sendError(res, 400, error.message);
}
});
this.app.post("/api/services/:id/stop", async (req, res) => {
try {
const result = await this.serviceManager.stopService(req.params.id);
this.sendSuccess(res, result);
} catch (error) {
this.sendError(res, 400, error.message);
}
});
this.app.post("/api/services/:id/restart", async (req, res) => {
try {
const result = await this.serviceManager.restartService(req.params.id);
this.sendSuccess(res, result);
} catch (error) {
this.sendError(res, 400, error.message);
}
});
this.app.post("/api/services/:id/destroy", async (req, res) => {
const confirmDestroy = req.body.confirm === true;
if (!confirmDestroy) {
return this.sendError(res, 400, "Destroy operation requires confirmation");
}
try {
const result = await this.serviceManager.destroyService(req.params.id, true);
this.sendSuccess(res, result);
} catch (error) {
this.sendError(res, 400, error.message);
}
});
this.app.post("/api/services/:id/health-check", async (req, res) => {
const service = this.db.getService(req.params.id);
if (!service) {
return this.sendError(res, 404, "Service not found");
}
try {
const result = await this.healthChecker.performHealthCheck(service);
this.sendSuccess(res, result);
} catch (error) {
this.sendError(res, 400, error.message);
}
});
this.app.get("/api/services/:id/events", (req, res) => {
const limit = parseInt(req.query.limit) || 100;
const events = this.db.getServiceEvents(req.params.id, limit);
this.sendSuccess(res, events);
});
this.app.get("/api/services/:id/operations", (req, res) => {
const limit = parseInt(req.query.limit) || 50;
const operations = this.db.getOperationHistory(req.params.id, limit);
this.sendSuccess(res, operations);
});
this.app.post("/api/health-check/all", async (req, res) => {
try {
const results = await this.healthChecker.checkAllServices();
this.sendSuccess(res, results);
} catch (error) {
this.sendError(res, 400, error.message);
}
});
}
// 启动服务器
async start() {
return new Promise((resolve) => {
const server = this.app.listen(this.config.port, this.config.host, () => {
this.logger.info(
{ host: this.config.host, port: this.config.port },
"API server started"
);
resolve();
});
process.on("SIGTERM", () => {
server.close(() => {
this.logger.info("API server stopped");
});
});
});
}
// 响应辅助方法
sendSuccess(res, data, status = 200) {
const response = {
success: true,
data,
timestamp: (/* @__PURE__ */ new Date()).toISOString()
};
res.status(status).json(response);
}
sendError(res, status, error) {
const response = {
success: false,
error,
timestamp: (/* @__PURE__ */ new Date()).toISOString()
};
res.status(status).json(response);
}
// 获取 Express 应用实例(用于测试)
getApp() {
return this.app;
}
};
// src/registry/registry-manager.ts
import fs2 from "fs-extra";
import path2 from "path";
import { z as z2 } from "zod";
var registryItemSchema = z2.object({
name: z2.string().regex(/^[a-z0-9-]+$/, "Name must be lowercase alphanumeric with hyphens"),
type: z2.enum(["deployment", "service", "config", "template"]),
version: z2.string().regex(/^\d+\.\d+\.\d+$/, "Version must follow semver format"),
description: z2.string().min(10).max(200),
category: z2.string(),
tags: z2.array(z2.string()),
files: z2.array(z2.object({
path: z2.string(),
content: z2.string(),
type: z2.enum(["config", "script", "template", "documentation"]),
encoding: z2.enum(["utf8", "base64"]).optional(),
permissions: z2.string().optional()
})).min(1),
dependencies: z2.record(z2.string()).optional(),
environment: z2.record(z2.object({
required: z2.boolean(),
description: z2.string(),
default: z2.string().optional(),
type: z2.enum(["string", "number", "boolean", "url", "path"]).optional(),
pattern: z2.string().optional(),
options: z2.array(z2.string()).optional()
})).optional(),
scripts: z2.record(z2.string()).optional(),
metadata: z2.object({
homepage: z2.string().url().optional(),
repository: z2.string().url().optional(),
documentation: z2.string().url().optional(),
examples: z2.array(z2.string()).optional()
}).optional(),
targets: z2.array(z2.object({
platform: z2.string(),
requirements: z2.array(z2.string()).optional(),
recommended: z2.boolean().optional()
})).optional()
});
var RegistryManager = class {
db;
storagePath;
constructor(db, storagePath) {
this.db = db;
this.storagePath = storagePath;
this.initializeDatabase();
this.ensureStorageDirectory();
}
initializeDatabase() {
this.db.exec(`
CREATE TABLE IF NOT EXISTS registry_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
type TEXT NOT NULL,
version TEXT NOT NULL,
description TEXT NOT NULL,
author TEXT,
license TEXT,
category TEXT NOT NULL,
tags TEXT NOT NULL, -- JSON array
dependencies TEXT, -- JSON object
environment TEXT, -- JSON object
scripts TEXT, -- JSON object
metadata TEXT, -- JSON object
targets TEXT, -- JSON array
downloads INTEGER DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_registry_name ON registry_items(name);
CREATE INDEX IF NOT EXISTS idx_registry_type ON registry_items(type);
CREATE INDEX IF NOT EXISTS idx_registry_category ON registry_items(category);
`);
this.db.exec(`
CREATE TABLE IF NOT EXISTS registry_usage (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item_name TEXT NOT NULL,
project_id TEXT,
applied_at TEXT NOT NULL,
variables TEXT, -- JSON object
FOREIGN KEY (item_name) REFERENCES registry_items(name)
);
`);
}
ensureStorageDirectory() {
fs2.ensureDirSync(this.storagePath);
fs2.ensureDirSync(path2.join(this.storagePath, "items"));
}
/**
* 发布新的 Registry 项目
*/
async publish(item) {
const validation = registryItemSchema.safeParse(item);
if (!validation.success) {
throw new Error(`\u9A8C\u8BC1\u5931\u8D25: ${validation.error.message}`);
}
const now = (/* @__PURE__ */ new Date()).toISOString();
const registryItem = {
...item,
createdAt: now,
updatedAt: now
};
const existing = this.db.prepare("SELECT id FROM registry_items WHERE name = ?").get(item.name);
if (existing) {
throw new Error(`Registry item '${item.name}' already exists`);
}
const stmt = this.db.prepare(`
INSERT INTO registry_items (
name, type, version, description, author, license,
category, tags, dependencies, environment, scripts,
metadata, targets, created_at, updated_at
) VALUES (
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
)
`);
stmt.run(
registryItem.name,
registryItem.type,
registryItem.version,
registryItem.description,
registryItem.author || null,
registryItem.license || null,
registryItem.category,
JSON.stringify(registryItem.tags),
JSON.stringify(registryItem.dependencies || {}),
JSON.stringify(registryItem.environment || {}),
JSON.stringify(registryItem.scripts || {}),
JSON.stringify(registryItem.metadata || {}),
JSON.stringify(registryItem.targets || []),
registryItem.createdAt,
registryItem.updatedAt
);
const itemPath = path2.join(this.storagePath, "items", registryItem.name);
fs2.ensureDirSync(itemPath);
fs2.writeJsonSync(path2.join(itemPath, "registry-item.json"), registryItem, { spaces: 2 });
for (const file of registryItem.files) {
const filePath = path2.join(itemPath, "files", file.path);
fs2.ensureDirSync(path2.dirname(filePath));
if (file.encoding === "base64") {
fs2.writeFileSync(filePath, Buffer.from(file.content, "base64"));
} else {
fs2.writeFileSync(filePath, file.content, "utf8");
}
if (file.permissions) {
fs2.chmodSync(filePath, file.permissions);
}
}
return registryItem;
}
/**
* 获取 Registry 项目
*/
async get(name) {
const row = this.db.prepare(`
SELECT * FROM registry_items WHERE name = ?
`).get(name);
if (!row) {
return null;
}
const itemPath = path2.join(this.storagePath, "items", name, "registry-item.json");
if (fs2.existsSync(itemPath)) {
return fs2.readJsonSync(itemPath);
}
return this.rowToRegistryItem(row);
}
/**
* 搜索 Registry 项目
*/
async search(query) {
let sql = "SELECT * FROM registry_items WHERE 1=1";
const params = [];
if (query.query) {
sql += " AND (name LIKE ? OR description LIKE ?)";
params.push(`%${query.query}%`, `%${query.query}%`);
}
if (query.type) {
sql += " AND type = ?";
params.push(query.type);
}
if (query.category) {
sql += " AND category = ?";
params.push(query.category);
}
if (query.author) {
sql += " AND author = ?";
params.push(query.author);
}
if (query.tags && query.tags.length > 0) {
sql += " AND (";
sql += query.tags.map(() => "tags LIKE ?").join(" OR ");
sql += ")";
query.tags.forEach((tag) => params.push(`%"${tag}"%`));
}
sql += " ORDER BY downloads DESC, created_at DESC";
if (query.limit) {
sql += " LIMIT ?";
params.push(query.limit);
if (query.offset) {
sql += " OFFSET ?";
params.push(query.offset);
}
}
const rows = this.db.prepare(sql).all(...params);
const items = [];
for (const row of rows) {
const item = await this.get(row.name);
if (item) {
items.push(item);
}
}
return items;
}
/**
* 应用 Registry 项目到本地
*/
async apply(name, options) {
const item = await this.get(name);
if (!item) {
throw new Error(`Registry item '${name}' not found`);
}
if (item.environment) {
for (const [key, envVar] of Object.entries(item.environment)) {
const value = options.variables?.[key] || envVar.default;
if (envVar.required && !value) {
throw new Error(`Required environment variable '${key}' not provided`);
}
if (value && envVar.pattern) {
const regex = new RegExp(envVar.pattern);
if (!regex.test(value)) {
throw new Error(`Environment variable '${key}' does not match pattern: ${envVar.pattern}`);
}
}
}
}
const targetDir = path2.resolve(options.targetPath);
fs2.ensureDirSync(targetDir);
for (const file of item.files) {
const targetPath = path2.join(targetDir, file.path);
if (fs2.existsSync(targetPath) && !options.overwrite) {
if (options.interactive) {
console.warn(`File ${file.path} already exists, skipping...`);
continue;
} else {
throw new Error(`File ${file.path} already exists. Use --overwrite to replace.`);
}
}
fs2.ensureDirSync(path2.dirname(targetPath));
let content = file.content;
if (file.type === "template" && options.variables) {
for (const [key, value] of Object.entries(options.variables)) {
content = content.replace(new RegExp(`\\$\\{${key}\\}`, "g"), value);
content = content.replace(new RegExp(`\\$${key}`, "g"), value);
}
}
if (file.encoding === "base64") {
fs2.writeFileSync(targetPath, Buffer.from(content, "base64"));
} else {
fs2.writeFileSync(targetPath, content, "utf8");
}
if (file.permissions) {
fs2.chmodSync(targetPath, file.permissions);
}
}
this.recordUsage(name, options);
this.db.prepare("UPDATE registry_items SET downloads = downloads + 1 WHERE name = ?").run(name);
}
/**
* 获取统计信息
*/
async getStats() {
const totalItems = this.db.prepare("SELECT COUNT(*) as count FROM registry_items").get();
const byType = this.db.prepare(`
SELECT type, COUNT(*) as count
FROM registry_items
GROUP BY type
`).all();
const byCategory = this.db.prepare(`
SELECT category, COUNT(*) as count
FROM registry_items
GROUP BY category
`).all();
const recentlyAdded = this.db.prepare(`
SELECT * FROM registry_items
ORDER BY created_at DESC
LIMIT 10
`).all();
const mostUsed = this.db.prepare(`
SELECT name, downloads
FROM registry_items
WHERE downloads > 0
ORDER BY downloads DESC
LIMIT 10
`).all();
const allItems = this.db.prepare("SELECT tags FROM registry_items").all();
const tagCounts = {};
for (const item of allItems) {
const tags = JSON.parse(item.tags);
for (const tag of tags) {
tagCounts[tag] = (tagCounts[tag] || 0) + 1;
}
}
const popularTags = Object.entries(tagCounts).map(([tag, count]) => ({ tag, count })).sort((a, b) => b.count - a.count).slice(0, 20);
return {
totalItems: totalItems.count,
byType: Object.fromEntries(byType.map((r) => [r.type, r.count])),
byCategory: Object.fromEntries(byCategory.map((r) => [r.category, r.count])),
popularTags,
recentlyAdded: await Promise.all(
recentlyAdded.map((row) => this.get(row.name))
).then((items) => items.filter(Boolean)),
mostUsed: await Promise.all(
mostUsed.map(async (row) => ({
item: await this.get(row.name),
uses: row.downloads
}))
)
};
}
rowToRegistryItem(row) {
return {
name: row.name,
type: row.type,
version: row.version,
description: row.description,
author