Merge changemaker-control-panel into v2 monorepo
Absorbs the separate control-panel git repo as a subdirectory. Instances and backups directories excluded via .gitignore. Bunker Admin
This commit is contained in:
2464
changemaker-control-panel/api/package-lock.json
generated
Normal file
2464
changemaker-control-panel/api/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
45
changemaker-control-panel/api/package.json
Normal file
45
changemaker-control-panel/api/package.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "ccp-api",
|
||||
"version": "1.0.0",
|
||||
"description": "Changemaker Control Panel — API Server",
|
||||
"main": "dist/server.js",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/server.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/server.js",
|
||||
"db:migrate": "prisma migrate deploy",
|
||||
"db:migrate:dev": "prisma migrate dev",
|
||||
"db:seed": "tsx prisma/seed.ts",
|
||||
"db:studio": "prisma studio",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.3.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"compression": "^1.7.5",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.7",
|
||||
"express": "^4.21.2",
|
||||
"express-async-errors": "^3.1.1",
|
||||
"express-rate-limit": "^7.5.0",
|
||||
"handlebars": "^4.7.8",
|
||||
"helmet": "^8.0.0",
|
||||
"ioredis": "^5.4.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"rate-limit-redis": "^4.2.0",
|
||||
"winston": "^3.17.0",
|
||||
"yaml": "^2.8.2",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/compression": "^1.7.5",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.7",
|
||||
"@types/node": "^22.0.0",
|
||||
"prisma": "^6.3.0",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
-- CreateSchema
|
||||
CREATE SCHEMA IF NOT EXISTS "public";
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "CcpRole" AS ENUM ('SUPER_ADMIN', 'OPERATOR', 'VIEWER');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "InstanceStatus" AS ENUM ('PROVISIONING', 'RUNNING', 'STOPPED', 'ERROR', 'DESTROYING');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "HealthStatus" AS ENUM ('HEALTHY', 'DEGRADED', 'UNHEALTHY', 'UNKNOWN');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "BackupStatus" AS ENUM ('PENDING', 'IN_PROGRESS', 'COMPLETED', 'FAILED');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AuditAction" AS ENUM ('INSTANCE_CREATE', 'INSTANCE_UPDATE', 'INSTANCE_DELETE', 'INSTANCE_START', 'INSTANCE_STOP', 'INSTANCE_RESTART', 'INSTANCE_UPGRADE', 'BACKUP_CREATE', 'BACKUP_DELETE', 'PANGOLIN_SETUP', 'PANGOLIN_SYNC', 'USER_LOGIN', 'USER_CREATE', 'USER_UPDATE', 'USER_DELETE', 'SETTINGS_UPDATE');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ccp_users" (
|
||||
"id" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"password" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"role" "CcpRole" NOT NULL DEFAULT 'OPERATOR',
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ccp_users_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ccp_refresh_tokens" (
|
||||
"id" TEXT NOT NULL,
|
||||
"token" TEXT NOT NULL,
|
||||
"user_id" TEXT NOT NULL,
|
||||
"expires_at" TIMESTAMP(3) NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "ccp_refresh_tokens_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "instances" (
|
||||
"id" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"domain" TEXT NOT NULL,
|
||||
"status" "InstanceStatus" NOT NULL DEFAULT 'PROVISIONING',
|
||||
"status_message" TEXT,
|
||||
"base_path" TEXT NOT NULL,
|
||||
"compose_project" TEXT NOT NULL,
|
||||
"git_branch" TEXT NOT NULL DEFAULT 'v2',
|
||||
"git_commit" TEXT,
|
||||
"port_config" JSONB NOT NULL,
|
||||
"encrypted_secrets" TEXT NOT NULL,
|
||||
"enable_media" BOOLEAN NOT NULL DEFAULT false,
|
||||
"enable_chat" BOOLEAN NOT NULL DEFAULT false,
|
||||
"enable_gancio" BOOLEAN NOT NULL DEFAULT false,
|
||||
"enable_listmonk" BOOLEAN NOT NULL DEFAULT false,
|
||||
"enable_monitoring" BOOLEAN NOT NULL DEFAULT false,
|
||||
"admin_email" TEXT NOT NULL,
|
||||
"pangolin_site_id" TEXT,
|
||||
"pangolin_newt_id" TEXT,
|
||||
"pangolin_newt_secret" TEXT,
|
||||
"smtp_host" TEXT,
|
||||
"smtp_port" INTEGER,
|
||||
"smtp_user" TEXT,
|
||||
"smtp_from" TEXT,
|
||||
"email_test_mode" BOOLEAN NOT NULL DEFAULT true,
|
||||
"notes" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
"last_health_check" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "instances_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "port_allocations" (
|
||||
"id" TEXT NOT NULL,
|
||||
"port" INTEGER NOT NULL,
|
||||
"instance_id" TEXT NOT NULL,
|
||||
"service" TEXT NOT NULL,
|
||||
"notes" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "port_allocations_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "health_checks" (
|
||||
"id" TEXT NOT NULL,
|
||||
"instance_id" TEXT NOT NULL,
|
||||
"status" "HealthStatus" NOT NULL,
|
||||
"service_status" JSONB NOT NULL,
|
||||
"total_services" INTEGER NOT NULL,
|
||||
"healthy_services" INTEGER NOT NULL,
|
||||
"response_time_ms" INTEGER,
|
||||
"checked_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "health_checks_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "backups" (
|
||||
"id" TEXT NOT NULL,
|
||||
"instance_id" TEXT NOT NULL,
|
||||
"status" "BackupStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"archive_path" TEXT,
|
||||
"size_bytes" BIGINT,
|
||||
"manifest" JSONB,
|
||||
"started_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"completed_at" TIMESTAMP(3),
|
||||
"error_message" TEXT,
|
||||
"s3_uploaded" BOOLEAN NOT NULL DEFAULT false,
|
||||
"s3_key" TEXT,
|
||||
|
||||
CONSTRAINT "backups_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "audit_logs" (
|
||||
"id" TEXT NOT NULL,
|
||||
"user_id" TEXT,
|
||||
"instance_id" TEXT,
|
||||
"action" "AuditAction" NOT NULL,
|
||||
"details" JSONB,
|
||||
"ip_address" TEXT,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "audit_logs_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ccp_settings" (
|
||||
"key" TEXT NOT NULL,
|
||||
"value" JSONB NOT NULL,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "ccp_settings_pkey" PRIMARY KEY ("key")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ccp_users_email_key" ON "ccp_users"("email");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ccp_refresh_tokens_token_key" ON "ccp_refresh_tokens"("token");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ccp_refresh_tokens_user_id_idx" ON "ccp_refresh_tokens"("user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ccp_refresh_tokens_expires_at_idx" ON "ccp_refresh_tokens"("expires_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "instances_slug_key" ON "instances"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "instances_domain_key" ON "instances"("domain");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "instances_compose_project_key" ON "instances"("compose_project");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "port_allocations_port_key" ON "port_allocations"("port");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "port_allocations_instance_id_idx" ON "port_allocations"("instance_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "health_checks_instance_id_checked_at_idx" ON "health_checks"("instance_id", "checked_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "backups_instance_id_started_at_idx" ON "backups"("instance_id", "started_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "audit_logs_instance_id_created_at_idx" ON "audit_logs"("instance_id", "created_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "audit_logs_user_id_created_at_idx" ON "audit_logs"("user_id", "created_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "audit_logs_action_created_at_idx" ON "audit_logs"("action", "created_at");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "ccp_refresh_tokens" ADD CONSTRAINT "ccp_refresh_tokens_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "ccp_users"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "port_allocations" ADD CONSTRAINT "port_allocations_instance_id_fkey" FOREIGN KEY ("instance_id") REFERENCES "instances"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "health_checks" ADD CONSTRAINT "health_checks_instance_id_fkey" FOREIGN KEY ("instance_id") REFERENCES "instances"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "backups" ADD CONSTRAINT "backups_instance_id_fkey" FOREIGN KEY ("instance_id") REFERENCES "instances"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "ccp_users"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "audit_logs" ADD CONSTRAINT "audit_logs_instance_id_fkey" FOREIGN KEY ("instance_id") REFERENCES "instances"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "instances" ADD COLUMN "is_registered" BOOLEAN NOT NULL DEFAULT false,
|
||||
ALTER COLUMN "encrypted_secrets" DROP NOT NULL;
|
||||
@@ -0,0 +1,3 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "instances" ADD COLUMN "enable_dev_tools" BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN "enable_payments" BOOLEAN NOT NULL DEFAULT false;
|
||||
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "postgresql"
|
||||
232
changemaker-control-panel/api/prisma/schema.prisma
Normal file
232
changemaker-control-panel/api/prisma/schema.prisma
Normal file
@@ -0,0 +1,232 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
// ─── CCP Users (control panel operators) ───────────────────
|
||||
|
||||
enum CcpRole {
|
||||
SUPER_ADMIN
|
||||
OPERATOR
|
||||
VIEWER
|
||||
}
|
||||
|
||||
model CcpUser {
|
||||
id String @id @default(uuid())
|
||||
email String @unique
|
||||
password String
|
||||
name String
|
||||
role CcpRole @default(OPERATOR)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
refreshTokens CcpRefreshToken[]
|
||||
auditLogs AuditLog[]
|
||||
|
||||
@@map("ccp_users")
|
||||
}
|
||||
|
||||
model CcpRefreshToken {
|
||||
id String @id @default(uuid())
|
||||
token String @unique
|
||||
userId String @map("user_id")
|
||||
expiresAt DateTime @map("expires_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
user CcpUser @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
@@index([expiresAt])
|
||||
@@map("ccp_refresh_tokens")
|
||||
}
|
||||
|
||||
// ─── Managed Instances ─────────────────────────────────────
|
||||
|
||||
enum InstanceStatus {
|
||||
PROVISIONING
|
||||
RUNNING
|
||||
STOPPED
|
||||
ERROR
|
||||
DESTROYING
|
||||
}
|
||||
|
||||
model Instance {
|
||||
id String @id @default(uuid())
|
||||
slug String @unique
|
||||
name String
|
||||
domain String @unique
|
||||
status InstanceStatus @default(PROVISIONING)
|
||||
statusMessage String? @map("status_message")
|
||||
|
||||
basePath String @map("base_path")
|
||||
composeProject String @unique @map("compose_project")
|
||||
gitBranch String @default("v2") @map("git_branch")
|
||||
gitCommit String? @map("git_commit")
|
||||
|
||||
// Allocated host ports (JSON: { api: 14001, admin: 13001, postgres: 15401, nginx: 10001 })
|
||||
portConfig Json @map("port_config")
|
||||
|
||||
// AES-256-GCM encrypted JSON blob of all instance secrets (null for registered instances)
|
||||
encryptedSecrets String? @map("encrypted_secrets")
|
||||
|
||||
// True if this instance was registered externally (not provisioned by CCP)
|
||||
isRegistered Boolean @default(false) @map("is_registered")
|
||||
|
||||
// Feature flags
|
||||
enableMedia Boolean @default(false) @map("enable_media")
|
||||
enableChat Boolean @default(false) @map("enable_chat")
|
||||
enableGancio Boolean @default(false) @map("enable_gancio")
|
||||
enableListmonk Boolean @default(false) @map("enable_listmonk")
|
||||
enableMonitoring Boolean @default(false) @map("enable_monitoring")
|
||||
enableDevTools Boolean @default(false) @map("enable_dev_tools")
|
||||
enablePayments Boolean @default(false) @map("enable_payments")
|
||||
|
||||
// Admin config
|
||||
adminEmail String @map("admin_email")
|
||||
|
||||
// Pangolin tunnel
|
||||
pangolinSiteId String? @map("pangolin_site_id")
|
||||
pangolinNewtId String? @map("pangolin_newt_id")
|
||||
pangolinNewtSecret String? @map("pangolin_newt_secret")
|
||||
|
||||
// SMTP
|
||||
smtpHost String? @map("smtp_host")
|
||||
smtpPort Int? @map("smtp_port")
|
||||
smtpUser String? @map("smtp_user")
|
||||
smtpFrom String? @map("smtp_from")
|
||||
emailTestMode Boolean @default(true) @map("email_test_mode")
|
||||
|
||||
notes String?
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
lastHealthCheck DateTime? @map("last_health_check")
|
||||
|
||||
portAllocations PortAllocation[]
|
||||
healthChecks HealthCheck[]
|
||||
backups Backup[]
|
||||
auditLogs AuditLog[]
|
||||
|
||||
@@map("instances")
|
||||
}
|
||||
|
||||
// ─── Port Allocation ───────────────────────────────────────
|
||||
|
||||
model PortAllocation {
|
||||
id String @id @default(uuid())
|
||||
port Int @unique
|
||||
instanceId String @map("instance_id")
|
||||
service String
|
||||
notes String?
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
instance Instance @relation(fields: [instanceId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([instanceId])
|
||||
@@map("port_allocations")
|
||||
}
|
||||
|
||||
// ─── Health Checks ─────────────────────────────────────────
|
||||
|
||||
enum HealthStatus {
|
||||
HEALTHY
|
||||
DEGRADED
|
||||
UNHEALTHY
|
||||
UNKNOWN
|
||||
}
|
||||
|
||||
model HealthCheck {
|
||||
id String @id @default(uuid())
|
||||
instanceId String @map("instance_id")
|
||||
status HealthStatus
|
||||
serviceStatus Json @map("service_status")
|
||||
totalServices Int @map("total_services")
|
||||
healthyServices Int @map("healthy_services")
|
||||
responseTimeMs Int? @map("response_time_ms")
|
||||
checkedAt DateTime @default(now()) @map("checked_at")
|
||||
|
||||
instance Instance @relation(fields: [instanceId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([instanceId, checkedAt])
|
||||
@@map("health_checks")
|
||||
}
|
||||
|
||||
// ─── Backups ───────────────────────────────────────────────
|
||||
|
||||
enum BackupStatus {
|
||||
PENDING
|
||||
IN_PROGRESS
|
||||
COMPLETED
|
||||
FAILED
|
||||
}
|
||||
|
||||
model Backup {
|
||||
id String @id @default(uuid())
|
||||
instanceId String @map("instance_id")
|
||||
status BackupStatus @default(PENDING)
|
||||
archivePath String? @map("archive_path")
|
||||
sizeBytes BigInt? @map("size_bytes")
|
||||
manifest Json?
|
||||
startedAt DateTime @default(now()) @map("started_at")
|
||||
completedAt DateTime? @map("completed_at")
|
||||
errorMessage String? @map("error_message")
|
||||
s3Uploaded Boolean @default(false) @map("s3_uploaded")
|
||||
s3Key String? @map("s3_key")
|
||||
|
||||
instance Instance @relation(fields: [instanceId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([instanceId, startedAt])
|
||||
@@map("backups")
|
||||
}
|
||||
|
||||
// ─── Audit Log ─────────────────────────────────────────────
|
||||
|
||||
enum AuditAction {
|
||||
INSTANCE_CREATE
|
||||
INSTANCE_UPDATE
|
||||
INSTANCE_DELETE
|
||||
INSTANCE_START
|
||||
INSTANCE_STOP
|
||||
INSTANCE_RESTART
|
||||
INSTANCE_UPGRADE
|
||||
BACKUP_CREATE
|
||||
BACKUP_DELETE
|
||||
PANGOLIN_SETUP
|
||||
PANGOLIN_SYNC
|
||||
USER_LOGIN
|
||||
USER_CREATE
|
||||
USER_UPDATE
|
||||
USER_DELETE
|
||||
SETTINGS_UPDATE
|
||||
}
|
||||
|
||||
model AuditLog {
|
||||
id String @id @default(uuid())
|
||||
userId String? @map("user_id")
|
||||
instanceId String? @map("instance_id")
|
||||
action AuditAction
|
||||
details Json?
|
||||
ipAddress String? @map("ip_address")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
user CcpUser? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
||||
instance Instance? @relation(fields: [instanceId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([instanceId, createdAt])
|
||||
@@index([userId, createdAt])
|
||||
@@index([action, createdAt])
|
||||
@@map("audit_logs")
|
||||
}
|
||||
|
||||
// ─── CCP Settings ──────────────────────────────────────────
|
||||
|
||||
model CcpSetting {
|
||||
key String @id
|
||||
value Json
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@map("ccp_settings")
|
||||
}
|
||||
49
changemaker-control-panel/api/prisma/seed.ts
Normal file
49
changemaker-control-panel/api/prisma/seed.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import 'dotenv/config';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function main() {
|
||||
const email = process.env.INITIAL_ADMIN_EMAIL || 'admin@example.com';
|
||||
const password = process.env.INITIAL_ADMIN_PASSWORD || 'ChangeMe2025!!';
|
||||
|
||||
// Create initial admin user
|
||||
const existing = await prisma.ccpUser.findUnique({ where: { email } });
|
||||
if (!existing) {
|
||||
const hashedPassword = await bcrypt.hash(password, 12);
|
||||
await prisma.ccpUser.create({
|
||||
data: {
|
||||
email,
|
||||
password: hashedPassword,
|
||||
name: 'Admin',
|
||||
role: 'SUPER_ADMIN',
|
||||
},
|
||||
});
|
||||
console.log(`Created initial admin user: ${email}`);
|
||||
} else {
|
||||
console.log(`Admin user already exists: ${email}`);
|
||||
}
|
||||
|
||||
// Create default settings
|
||||
const defaults: Record<string, unknown> = {
|
||||
defaultGitBranch: 'v2',
|
||||
instancesBasePath: process.env.INSTANCES_BASE_PATH || 'instances',
|
||||
};
|
||||
|
||||
for (const [key, value] of Object.entries(defaults)) {
|
||||
await prisma.ccpSetting.upsert({
|
||||
where: { key },
|
||||
update: {},
|
||||
create: { key, value: value as string },
|
||||
});
|
||||
}
|
||||
console.log('Default settings seeded');
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((e) => {
|
||||
console.error('Seed error:', e);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => prisma.$disconnect());
|
||||
80
changemaker-control-panel/api/src/config/env.ts
Normal file
80
changemaker-control-panel/api/src/config/env.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import 'dotenv/config';
|
||||
import path from 'path';
|
||||
import { z } from 'zod';
|
||||
|
||||
const envSchema = z.object({
|
||||
// Server
|
||||
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
||||
PORT: z.coerce.number().default(5000),
|
||||
|
||||
// Database
|
||||
DATABASE_URL: z.string().url(),
|
||||
|
||||
// Redis
|
||||
REDIS_URL: z.string().default('redis://localhost:6399'),
|
||||
|
||||
// JWT
|
||||
JWT_ACCESS_SECRET: z.string().min(32),
|
||||
JWT_REFRESH_SECRET: z.string().min(32),
|
||||
JWT_ACCESS_EXPIRES_IN: z.string().default('15m'),
|
||||
JWT_REFRESH_EXPIRES_IN: z.string().default('7d'),
|
||||
|
||||
// Encryption key for secrets at rest (64 hex chars = 32 bytes for AES-256)
|
||||
ENCRYPTION_KEY: z.string().min(64).regex(/^[0-9a-f]+$/i, 'Must be hex-encoded (use: openssl rand -hex 32)'),
|
||||
|
||||
// Initial admin
|
||||
INITIAL_ADMIN_EMAIL: z.string().email().default('admin@example.com'),
|
||||
INITIAL_ADMIN_PASSWORD: z.string().min(12).default('ChangeMe2025!!'),
|
||||
|
||||
// CORS
|
||||
CORS_ORIGINS: z.string().default('http://localhost:5100'),
|
||||
|
||||
// Instance management (resolved by setup.sh; fallback for local dev)
|
||||
INSTANCES_BASE_PATH: z.string().default(
|
||||
path.resolve(process.cwd(), '..', 'instances')
|
||||
),
|
||||
CML_SOURCE_PATH: z.string().default(''),
|
||||
CML_GIT_REPO: z.string().default(''),
|
||||
CML_GIT_BRANCH: z.string().default('v2'),
|
||||
|
||||
// Port allocation ranges
|
||||
PORT_RANGE_API_START: z.coerce.number().default(14000),
|
||||
PORT_RANGE_API_END: z.coerce.number().default(14999),
|
||||
PORT_RANGE_ADMIN_START: z.coerce.number().default(13000),
|
||||
PORT_RANGE_ADMIN_END: z.coerce.number().default(13999),
|
||||
PORT_RANGE_POSTGRES_START: z.coerce.number().default(15400),
|
||||
PORT_RANGE_POSTGRES_END: z.coerce.number().default(15499),
|
||||
PORT_RANGE_NGINX_START: z.coerce.number().default(10000),
|
||||
PORT_RANGE_NGINX_END: z.coerce.number().default(10999),
|
||||
PORT_RANGE_EMBED_START: z.coerce.number().default(12000),
|
||||
PORT_RANGE_EMBED_END: z.coerce.number().default(12499),
|
||||
|
||||
// Pangolin (optional)
|
||||
PANGOLIN_API_URL: z.string().default(''),
|
||||
PANGOLIN_API_KEY: z.string().default(''),
|
||||
PANGOLIN_ORG_ID: z.string().default(''),
|
||||
|
||||
// Health checks
|
||||
HEALTH_CHECK_INTERVAL_MS: z.coerce.number().default(300_000), // 5 min (0 to disable)
|
||||
|
||||
// Backups
|
||||
BACKUP_STORAGE_PATH: z.string().default(
|
||||
path.resolve(process.cwd(), '..', 'backups')
|
||||
),
|
||||
BACKUP_RETENTION_DAYS: z.coerce.number().default(30),
|
||||
});
|
||||
|
||||
function validateEnv() {
|
||||
const result = envSchema.safeParse(process.env);
|
||||
if (!result.success) {
|
||||
console.error('❌ Invalid environment variables:');
|
||||
for (const [key, errors] of Object.entries(result.error.flatten().fieldErrors)) {
|
||||
console.error(` ${key}: ${errors?.join(', ')}`);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
export const env = validateEnv();
|
||||
export type Env = z.infer<typeof envSchema>;
|
||||
14
changemaker-control-panel/api/src/config/redis.ts
Normal file
14
changemaker-control-panel/api/src/config/redis.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import Redis from 'ioredis';
|
||||
import { env } from './env';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
export const redis = new Redis(env.REDIS_URL, {
|
||||
maxRetriesPerRequest: 3,
|
||||
retryStrategy(times) {
|
||||
if (times > 10) return null;
|
||||
return Math.min(times * 200, 5000);
|
||||
},
|
||||
});
|
||||
|
||||
redis.on('connect', () => logger.info('Redis connected'));
|
||||
redis.on('error', (err) => logger.error('Redis error:', err.message));
|
||||
3
changemaker-control-panel/api/src/lib/prisma.ts
Normal file
3
changemaker-control-panel/api/src/lib/prisma.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
export const prisma = new PrismaClient();
|
||||
51
changemaker-control-panel/api/src/middleware/auth.ts
Normal file
51
changemaker-control-panel/api/src/middleware/auth.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { CcpRole } from '@prisma/client';
|
||||
import { env } from '../config/env';
|
||||
import { AppError } from './error-handler';
|
||||
|
||||
interface TokenPayload {
|
||||
id: string;
|
||||
email: string;
|
||||
role: CcpRole;
|
||||
}
|
||||
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
user?: TokenPayload;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function authenticate(req: Request, _res: Response, next: NextFunction) {
|
||||
const header = req.headers.authorization;
|
||||
if (!header?.startsWith('Bearer ')) {
|
||||
throw new AppError(401, 'Authentication required', 'AUTH_REQUIRED');
|
||||
}
|
||||
|
||||
const token = header.slice(7);
|
||||
try {
|
||||
const payload = jwt.verify(token, env.JWT_ACCESS_SECRET) as TokenPayload;
|
||||
req.user = {
|
||||
id: payload.id,
|
||||
email: payload.email,
|
||||
role: payload.role,
|
||||
};
|
||||
next();
|
||||
} catch {
|
||||
throw new AppError(401, 'Invalid or expired token', 'INVALID_TOKEN');
|
||||
}
|
||||
}
|
||||
|
||||
export function requireRole(...roles: CcpRole[]) {
|
||||
return (req: Request, _res: Response, next: NextFunction) => {
|
||||
if (!req.user) {
|
||||
throw new AppError(401, 'Authentication required', 'AUTH_REQUIRED');
|
||||
}
|
||||
if (!roles.includes(req.user.role)) {
|
||||
throw new AppError(403, 'Insufficient permissions', 'FORBIDDEN');
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { ZodError } from 'zod';
|
||||
import { env } from '../config/env';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
export class AppError extends Error {
|
||||
constructor(
|
||||
public statusCode: number,
|
||||
message: string,
|
||||
public code?: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'AppError';
|
||||
}
|
||||
}
|
||||
|
||||
export function errorHandler(
|
||||
err: Error,
|
||||
_req: Request,
|
||||
res: Response,
|
||||
_next: NextFunction
|
||||
) {
|
||||
if (err instanceof AppError) {
|
||||
res.status(err.statusCode).json({
|
||||
error: { message: err.message, code: err.code },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (err instanceof ZodError) {
|
||||
const fieldErrors = err.flatten().fieldErrors;
|
||||
const errorCount = Object.keys(fieldErrors).length;
|
||||
res.status(400).json({
|
||||
error: {
|
||||
message: 'Validation error',
|
||||
code: 'VALIDATION_ERROR',
|
||||
...(env.NODE_ENV === 'development' && { details: fieldErrors }),
|
||||
...(env.NODE_ENV === 'production' && { fieldCount: errorCount }),
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
logger.error('Unhandled error:', err);
|
||||
res.status(500).json({
|
||||
error: {
|
||||
message: env.NODE_ENV === 'production' ? 'Internal server error' : err.message,
|
||||
code: 'INTERNAL_ERROR',
|
||||
},
|
||||
});
|
||||
}
|
||||
16
changemaker-control-panel/api/src/middleware/validate.ts
Normal file
16
changemaker-control-panel/api/src/middleware/validate.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { ZodSchema } from 'zod';
|
||||
|
||||
export function validate(schema: ZodSchema) {
|
||||
return (req: Request, _res: Response, next: NextFunction) => {
|
||||
schema.parse(req.body);
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
export function validateQuery(schema: ZodSchema) {
|
||||
return (req: Request, _res: Response, next: NextFunction) => {
|
||||
schema.parse(req.query);
|
||||
next();
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { AuditAction } from '@prisma/client';
|
||||
import { authenticate, requireRole } from '../../middleware/auth';
|
||||
import * as auditService from './audit.service';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(authenticate);
|
||||
|
||||
router.get('/', requireRole('SUPER_ADMIN', 'OPERATOR'), async (req: Request, res: Response) => {
|
||||
const { action, instanceId, userId, from, to, page, limit } = req.query;
|
||||
|
||||
const filters = {
|
||||
action: action && Object.values(AuditAction).includes(action as AuditAction)
|
||||
? (action as AuditAction)
|
||||
: undefined,
|
||||
instanceId: instanceId as string | undefined,
|
||||
userId: userId as string | undefined,
|
||||
from: from as string | undefined,
|
||||
to: to as string | undefined,
|
||||
};
|
||||
|
||||
const pageNum = Math.max(1, parseInt(page as string, 10) || 1);
|
||||
const limitNum = Math.min(100, Math.max(1, parseInt(limit as string, 10) || 50));
|
||||
|
||||
const result = await auditService.listAuditLogs(filters, pageNum, limitNum);
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,43 @@
|
||||
import { AuditAction, Prisma } from '@prisma/client';
|
||||
import { prisma } from '../../lib/prisma';
|
||||
|
||||
interface AuditFilters {
|
||||
action?: AuditAction;
|
||||
instanceId?: string;
|
||||
userId?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
}
|
||||
|
||||
export async function listAuditLogs(
|
||||
filters: AuditFilters,
|
||||
page = 1,
|
||||
limit = 50
|
||||
) {
|
||||
const where: Prisma.AuditLogWhereInput = {};
|
||||
|
||||
if (filters.action) where.action = filters.action;
|
||||
if (filters.instanceId) where.instanceId = filters.instanceId;
|
||||
if (filters.userId) where.userId = filters.userId;
|
||||
if (filters.from || filters.to) {
|
||||
where.createdAt = {};
|
||||
if (filters.from) where.createdAt.gte = new Date(filters.from);
|
||||
if (filters.to) where.createdAt.lte = new Date(filters.to);
|
||||
}
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
prisma.auditLog.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
include: {
|
||||
user: { select: { id: true, email: true, name: true } },
|
||||
instance: { select: { id: true, name: true, slug: true } },
|
||||
},
|
||||
}),
|
||||
prisma.auditLog.count({ where }),
|
||||
]);
|
||||
|
||||
return { data, total, page, limit };
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { AuditAction } from '@prisma/client';
|
||||
import { prisma } from '../../lib/prisma';
|
||||
import { authenticate } from '../../middleware/auth';
|
||||
import { validate } from '../../middleware/validate';
|
||||
import { loginSchema, refreshSchema, logoutSchema, verifyPasswordSchema } from './auth.schemas';
|
||||
import * as authService from './auth.service';
|
||||
const router = Router();
|
||||
|
||||
router.post('/login', validate(loginSchema), async (req: Request, res: Response) => {
|
||||
const { email, password } = req.body;
|
||||
const result = await authService.login(email, password);
|
||||
|
||||
// Audit log the login
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
userId: result.user.id,
|
||||
action: AuditAction.USER_LOGIN,
|
||||
details: { email: result.user.email },
|
||||
ipAddress: req.ip,
|
||||
},
|
||||
});
|
||||
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
router.post('/refresh', validate(refreshSchema), async (req: Request, res: Response) => {
|
||||
const { refreshToken } = req.body;
|
||||
const result = await authService.refresh(refreshToken);
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
router.post('/logout', validate(logoutSchema), async (req: Request, res: Response) => {
|
||||
const { refreshToken } = req.body;
|
||||
await authService.logout(refreshToken);
|
||||
res.json({ message: 'Logged out' });
|
||||
});
|
||||
|
||||
router.post(
|
||||
'/verify-password',
|
||||
authenticate,
|
||||
validate(verifyPasswordSchema),
|
||||
async (req: Request, res: Response) => {
|
||||
const { password } = req.body;
|
||||
const valid = await authService.verifyPassword(req.user!.id, password);
|
||||
if (!valid) {
|
||||
res.status(401).json({ error: { message: 'Invalid password', code: 'INVALID_PASSWORD' } });
|
||||
return;
|
||||
}
|
||||
res.json({ verified: true });
|
||||
}
|
||||
);
|
||||
|
||||
router.get('/me', authenticate, async (req: Request, res: Response) => {
|
||||
const user = await authService.getMe(req.user!.id);
|
||||
res.json({ user });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,18 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const loginSchema = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(1),
|
||||
});
|
||||
|
||||
export const refreshSchema = z.object({
|
||||
refreshToken: z.string().min(1),
|
||||
});
|
||||
|
||||
export const logoutSchema = z.object({
|
||||
refreshToken: z.string().min(1),
|
||||
});
|
||||
|
||||
export const verifyPasswordSchema = z.object({
|
||||
password: z.string().min(1),
|
||||
});
|
||||
131
changemaker-control-panel/api/src/modules/auth/auth.service.ts
Normal file
131
changemaker-control-panel/api/src/modules/auth/auth.service.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import bcrypt from 'bcryptjs';
|
||||
import jwt, { SignOptions } from 'jsonwebtoken';
|
||||
import crypto from 'crypto';
|
||||
import { CcpRole } from '@prisma/client';
|
||||
import { prisma } from '../../lib/prisma';
|
||||
import { env } from '../../config/env';
|
||||
import { AppError } from '../../middleware/error-handler';
|
||||
|
||||
interface TokenPayload {
|
||||
id: string;
|
||||
email: string;
|
||||
role: CcpRole;
|
||||
}
|
||||
|
||||
function signAccessToken(payload: TokenPayload): string {
|
||||
return jwt.sign(payload, env.JWT_ACCESS_SECRET, {
|
||||
expiresIn: env.JWT_ACCESS_EXPIRES_IN as SignOptions['expiresIn'],
|
||||
});
|
||||
}
|
||||
|
||||
function signRefreshToken(payload: TokenPayload): string {
|
||||
return jwt.sign(payload, env.JWT_REFRESH_SECRET, {
|
||||
expiresIn: env.JWT_REFRESH_EXPIRES_IN as SignOptions['expiresIn'],
|
||||
});
|
||||
}
|
||||
|
||||
function parseExpiry(expiresIn: string): Date {
|
||||
const match = expiresIn.match(/^(\d+)([smhd])$/);
|
||||
if (!match) return new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // default 7d
|
||||
const [, num, unit] = match;
|
||||
const multipliers: Record<string, number> = { s: 1000, m: 60000, h: 3600000, d: 86400000 };
|
||||
return new Date(Date.now() + parseInt(num) * multipliers[unit]);
|
||||
}
|
||||
|
||||
export async function login(email: string, password: string) {
|
||||
const user = await prisma.ccpUser.findUnique({ where: { email } });
|
||||
if (!user) {
|
||||
throw new AppError(401, 'Invalid credentials', 'INVALID_CREDENTIALS');
|
||||
}
|
||||
|
||||
const valid = await bcrypt.compare(password, user.password);
|
||||
if (!valid) {
|
||||
throw new AppError(401, 'Invalid credentials', 'INVALID_CREDENTIALS');
|
||||
}
|
||||
|
||||
const payload: TokenPayload = { id: user.id, email: user.email, role: user.role };
|
||||
const accessToken = signAccessToken(payload);
|
||||
const refreshToken = signRefreshToken(payload);
|
||||
|
||||
// Store refresh token
|
||||
await prisma.ccpRefreshToken.create({
|
||||
data: {
|
||||
token: crypto.createHash('sha256').update(refreshToken).digest('hex'),
|
||||
userId: user.id,
|
||||
expiresAt: parseExpiry(env.JWT_REFRESH_EXPIRES_IN),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
user: { id: user.id, email: user.email, name: user.name, role: user.role },
|
||||
accessToken,
|
||||
refreshToken,
|
||||
};
|
||||
}
|
||||
|
||||
export async function refresh(refreshToken: string) {
|
||||
let payload: TokenPayload;
|
||||
try {
|
||||
payload = jwt.verify(refreshToken, env.JWT_REFRESH_SECRET) as TokenPayload;
|
||||
} catch {
|
||||
throw new AppError(401, 'Invalid refresh token', 'INVALID_TOKEN');
|
||||
}
|
||||
|
||||
const tokenHash = crypto.createHash('sha256').update(refreshToken).digest('hex');
|
||||
|
||||
// Atomic rotation: delete old, create new
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
const existing = await tx.ccpRefreshToken.findUnique({ where: { token: tokenHash } });
|
||||
if (!existing || existing.expiresAt < new Date()) {
|
||||
throw new AppError(401, 'Refresh token expired or revoked', 'TOKEN_EXPIRED');
|
||||
}
|
||||
|
||||
await tx.ccpRefreshToken.delete({ where: { token: tokenHash } });
|
||||
|
||||
const user = await tx.ccpUser.findUnique({ where: { id: payload.id } });
|
||||
if (!user) {
|
||||
throw new AppError(401, 'User not found', 'USER_NOT_FOUND');
|
||||
}
|
||||
|
||||
const newPayload: TokenPayload = { id: user.id, email: user.email, role: user.role };
|
||||
const newAccessToken = signAccessToken(newPayload);
|
||||
const newRefreshToken = signRefreshToken(newPayload);
|
||||
|
||||
await tx.ccpRefreshToken.create({
|
||||
data: {
|
||||
token: crypto.createHash('sha256').update(newRefreshToken).digest('hex'),
|
||||
userId: user.id,
|
||||
expiresAt: parseExpiry(env.JWT_REFRESH_EXPIRES_IN),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
user: { id: user.id, email: user.email, name: user.name, role: user.role },
|
||||
accessToken: newAccessToken,
|
||||
refreshToken: newRefreshToken,
|
||||
};
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function logout(refreshToken: string) {
|
||||
const tokenHash = crypto.createHash('sha256').update(refreshToken).digest('hex');
|
||||
await prisma.ccpRefreshToken.deleteMany({ where: { token: tokenHash } });
|
||||
}
|
||||
|
||||
export async function verifyPassword(userId: string, password: string): Promise<boolean> {
|
||||
const user = await prisma.ccpUser.findUnique({ where: { id: userId } });
|
||||
if (!user) {
|
||||
throw new AppError(401, 'Authentication required', 'AUTH_REQUIRED');
|
||||
}
|
||||
return bcrypt.compare(password, user.password);
|
||||
}
|
||||
|
||||
export async function getMe(userId: string) {
|
||||
const user = await prisma.ccpUser.findUnique({ where: { id: userId } });
|
||||
if (!user) {
|
||||
throw new AppError(401, 'Authentication required', 'AUTH_REQUIRED');
|
||||
}
|
||||
return { id: user.id, email: user.email, name: user.name, role: user.role };
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { authenticate, requireRole } from '../../middleware/auth';
|
||||
import { env } from '../../config/env';
|
||||
import * as backupService from '../../services/backup.service';
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.use(authenticate);
|
||||
|
||||
// ─── Cross-Instance Backup Endpoints ────────────────────────────────
|
||||
|
||||
// List all backups (cross-instance)
|
||||
router.get('/', async (req: Request, res: Response) => {
|
||||
const { instanceId, page, limit } = req.query;
|
||||
const pageNum = Math.max(1, parseInt(page as string, 10) || 1);
|
||||
const limitNum = Math.min(100, Math.max(1, parseInt(limit as string, 10) || 50));
|
||||
|
||||
const result = await backupService.listBackups(
|
||||
instanceId as string | undefined,
|
||||
pageNum,
|
||||
limitNum
|
||||
);
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
// Delete a backup
|
||||
router.delete(
|
||||
'/:backupId',
|
||||
requireRole('SUPER_ADMIN'),
|
||||
async (req: Request, res: Response) => {
|
||||
await backupService.deleteBackup(req.params.backupId as string, req.user!.id, req.ip);
|
||||
res.json({ message: 'Backup deleted' });
|
||||
}
|
||||
);
|
||||
|
||||
// Download a backup archive
|
||||
router.get(
|
||||
'/:backupId/download',
|
||||
requireRole('SUPER_ADMIN'),
|
||||
async (req: Request, res: Response) => {
|
||||
const backup = await backupService.getBackup(req.params.backupId as string);
|
||||
|
||||
if (!backup.archivePath || backup.status !== 'COMPLETED') {
|
||||
res.status(400).json({ error: { message: 'Backup not available for download', code: 'NOT_AVAILABLE' } });
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate path is within backup storage (prevent traversal)
|
||||
const normalized = path.resolve(backup.archivePath);
|
||||
const normalizedStorage = path.resolve(env.BACKUP_STORAGE_PATH);
|
||||
if (!normalized.startsWith(normalizedStorage + path.sep)) {
|
||||
res.status(403).json({ error: { message: 'Access denied', code: 'FORBIDDEN' } });
|
||||
return;
|
||||
}
|
||||
if (!fs.existsSync(normalized)) {
|
||||
res.status(404).json({ error: { message: 'Backup file not found', code: 'FILE_NOT_FOUND' } });
|
||||
return;
|
||||
}
|
||||
|
||||
const filename = path.basename(normalized);
|
||||
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
|
||||
res.setHeader('Content-Type', 'application/gzip');
|
||||
|
||||
const stream = fs.createReadStream(normalized);
|
||||
stream.pipe(res);
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { prisma } from '../../lib/prisma';
|
||||
import { authenticate } from '../../middleware/auth';
|
||||
const router = Router();
|
||||
|
||||
// Public health endpoint for CCP itself
|
||||
router.get('/', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
await prisma.$queryRaw`SELECT 1`;
|
||||
res.json({ status: 'healthy', timestamp: new Date().toISOString() });
|
||||
} catch {
|
||||
res.status(503).json({ status: 'unhealthy', timestamp: new Date().toISOString() });
|
||||
}
|
||||
});
|
||||
|
||||
// Authenticated: overview of all instances' health
|
||||
router.get('/overview', authenticate, async (_req: Request, res: Response) => {
|
||||
const instances = await prisma.instance.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
domain: true,
|
||||
status: true,
|
||||
lastHealthCheck: true,
|
||||
healthChecks: {
|
||||
orderBy: { checkedAt: 'desc' },
|
||||
take: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const summary = instances.map((i) => ({
|
||||
id: i.id,
|
||||
name: i.name,
|
||||
slug: i.slug,
|
||||
domain: i.domain,
|
||||
status: i.status,
|
||||
lastHealthCheck: i.lastHealthCheck,
|
||||
health: i.healthChecks[0] || null,
|
||||
}));
|
||||
|
||||
res.json({ data: summary });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,278 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { AuditAction } from '@prisma/client';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import { prisma } from '../../lib/prisma';
|
||||
import { authenticate, requireRole } from '../../middleware/auth';
|
||||
import { validate } from '../../middleware/validate';
|
||||
import { createInstanceSchema, updateInstanceSchema, registerInstanceSchema, reconfigureInstanceSchema, importInstancesSchema } from './instances.schemas';
|
||||
import * as instancesService from './instances.service';
|
||||
import * as healthService from '../../services/health.service';
|
||||
import * as backupService from '../../services/backup.service';
|
||||
import { discoverInstances } from '../../services/discovery.service';
|
||||
|
||||
const secretsLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 10,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: { message: 'Too many secrets requests, please try again later', code: 'RATE_LIMITED' } },
|
||||
});
|
||||
|
||||
const router = Router();
|
||||
|
||||
// All instance routes require authentication
|
||||
router.use(authenticate);
|
||||
|
||||
// ─── Discovery Endpoints ────────────────────────────────────────────
|
||||
|
||||
router.post(
|
||||
'/discover',
|
||||
requireRole('SUPER_ADMIN', 'OPERATOR'),
|
||||
async (_req: Request, res: Response) => {
|
||||
const result = await discoverInstances();
|
||||
res.json({ data: result });
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/import',
|
||||
requireRole('SUPER_ADMIN', 'OPERATOR'),
|
||||
validate(importInstancesSchema),
|
||||
async (req: Request, res: Response) => {
|
||||
const { instances } = req.body as { instances: Array<Record<string, unknown>> };
|
||||
const results: Array<{ slug: string; success: boolean; instanceId?: string; error?: string }> = [];
|
||||
|
||||
for (const inst of instances) {
|
||||
try {
|
||||
const registered = await instancesService.registerInstance(
|
||||
inst as Parameters<typeof instancesService.registerInstance>[0],
|
||||
req.user!.id,
|
||||
req.ip
|
||||
);
|
||||
results.push({ slug: inst.slug as string, success: true, instanceId: registered?.id });
|
||||
} catch (err) {
|
||||
results.push({ slug: inst.slug as string, success: false, error: (err as Error).message });
|
||||
}
|
||||
}
|
||||
|
||||
const succeeded = results.filter((r) => r.success).length;
|
||||
const failed = results.filter((r) => !r.success).length;
|
||||
res.json({
|
||||
data: {
|
||||
results,
|
||||
summary: { total: results.length, succeeded, failed },
|
||||
},
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
// ─── CRUD Endpoints ──────────────────────────────────────────────────
|
||||
|
||||
router.get('/', async (_req: Request, res: Response) => {
|
||||
const instances = await instancesService.listInstances();
|
||||
res.json({ data: instances });
|
||||
});
|
||||
|
||||
// Register an existing (externally-managed) instance for monitoring
|
||||
router.post(
|
||||
'/register',
|
||||
requireRole('SUPER_ADMIN', 'OPERATOR'),
|
||||
validate(registerInstanceSchema),
|
||||
async (req: Request, res: Response) => {
|
||||
const instance = await instancesService.registerInstance(req.body, req.user!.id, req.ip);
|
||||
res.status(201).json({ data: instance });
|
||||
}
|
||||
);
|
||||
|
||||
router.get('/:id', async (req: Request, res: Response) => {
|
||||
const instance = await instancesService.getInstance(req.params.id as string);
|
||||
res.json({ data: instance });
|
||||
});
|
||||
|
||||
router.post(
|
||||
'/',
|
||||
requireRole('SUPER_ADMIN', 'OPERATOR'),
|
||||
validate(createInstanceSchema),
|
||||
async (req: Request, res: Response) => {
|
||||
const instance = await instancesService.createInstance(req.body, req.user!.id, req.ip);
|
||||
res.status(201).json({ data: instance });
|
||||
}
|
||||
);
|
||||
|
||||
router.put(
|
||||
'/:id',
|
||||
requireRole('SUPER_ADMIN', 'OPERATOR'),
|
||||
validate(updateInstanceSchema),
|
||||
async (req: Request, res: Response) => {
|
||||
const instance = await instancesService.updateInstance(
|
||||
req.params.id as string,
|
||||
req.body,
|
||||
req.user!.id,
|
||||
req.ip
|
||||
);
|
||||
res.json({ data: instance });
|
||||
}
|
||||
);
|
||||
|
||||
router.delete(
|
||||
'/:id',
|
||||
requireRole('SUPER_ADMIN'),
|
||||
async (req: Request, res: Response) => {
|
||||
const result = await instancesService.deleteInstance(req.params.id as string, req.user!.id, req.ip);
|
||||
res.json(result);
|
||||
}
|
||||
);
|
||||
|
||||
// Get decrypted secrets (SUPER_ADMIN only, rate limited)
|
||||
router.get(
|
||||
'/:id/secrets',
|
||||
secretsLimiter,
|
||||
requireRole('SUPER_ADMIN'),
|
||||
async (req: Request, res: Response) => {
|
||||
const secrets = await instancesService.getInstanceSecrets(req.params.id as string);
|
||||
|
||||
// Audit log: someone viewed secrets
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
userId: req.user!.id,
|
||||
instanceId: req.params.id as string,
|
||||
action: AuditAction.INSTANCE_UPDATE,
|
||||
details: { type: 'secrets_viewed' },
|
||||
ipAddress: req.ip,
|
||||
},
|
||||
});
|
||||
|
||||
res.json({ data: secrets });
|
||||
}
|
||||
);
|
||||
|
||||
// ─── Reconfiguration ────────────────────────────────────────────────
|
||||
|
||||
router.post(
|
||||
'/:id/reconfigure',
|
||||
requireRole('SUPER_ADMIN', 'OPERATOR'),
|
||||
validate(reconfigureInstanceSchema),
|
||||
async (req: Request, res: Response) => {
|
||||
const result = await instancesService.reconfigureInstance(
|
||||
req.params.id as string,
|
||||
req.body,
|
||||
req.user!.id,
|
||||
req.ip
|
||||
);
|
||||
res.json({ data: result });
|
||||
}
|
||||
);
|
||||
|
||||
// ─── Lifecycle Endpoints ─────────────────────────────────────────────
|
||||
|
||||
router.post(
|
||||
'/:id/provision',
|
||||
requireRole('SUPER_ADMIN', 'OPERATOR'),
|
||||
async (req: Request, res: Response) => {
|
||||
const result = await instancesService.provisionInstance(req.params.id as string, req.user!.id, req.ip);
|
||||
res.json(result);
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:id/start',
|
||||
requireRole('SUPER_ADMIN', 'OPERATOR'),
|
||||
async (req: Request, res: Response) => {
|
||||
const result = await instancesService.startInstance(req.params.id as string, req.user!.id, req.ip);
|
||||
res.json(result);
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:id/stop',
|
||||
requireRole('SUPER_ADMIN', 'OPERATOR'),
|
||||
async (req: Request, res: Response) => {
|
||||
const result = await instancesService.stopInstance(req.params.id as string, req.user!.id, req.ip);
|
||||
res.json(result);
|
||||
}
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/:id/restart',
|
||||
requireRole('SUPER_ADMIN', 'OPERATOR'),
|
||||
async (req: Request, res: Response) => {
|
||||
const service = req.query.service as string | undefined;
|
||||
const result = await instancesService.restartInstance(
|
||||
req.params.id as string,
|
||||
req.user!.id,
|
||||
req.ip,
|
||||
service
|
||||
);
|
||||
res.json(result);
|
||||
}
|
||||
);
|
||||
|
||||
// ─── Services & Logs ─────────────────────────────────────────────────
|
||||
|
||||
router.get(
|
||||
'/:id/services',
|
||||
async (req: Request, res: Response) => {
|
||||
const services = await instancesService.getInstanceServices(req.params.id as string);
|
||||
res.json({ data: services });
|
||||
}
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:id/logs',
|
||||
requireRole('SUPER_ADMIN', 'OPERATOR'),
|
||||
async (req: Request, res: Response) => {
|
||||
const { service, tail, since } = req.query;
|
||||
const tailNum = tail ? Math.min(Math.max(parseInt(tail as string, 10) || 200, 1), 2000) : 200;
|
||||
const logs = await instancesService.getInstanceLogs(
|
||||
req.params.id as string,
|
||||
service as string | undefined,
|
||||
tailNum,
|
||||
since as string | undefined
|
||||
);
|
||||
res.json({ data: logs });
|
||||
}
|
||||
);
|
||||
|
||||
// ─── Health Checks ──────────────────────────────────────────────────
|
||||
|
||||
router.post(
|
||||
'/:id/health-check',
|
||||
requireRole('SUPER_ADMIN', 'OPERATOR'),
|
||||
async (req: Request, res: Response) => {
|
||||
const check = await healthService.checkInstanceHealth(req.params.id as string);
|
||||
res.json({ data: check });
|
||||
}
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:id/health-history',
|
||||
async (req: Request, res: Response) => {
|
||||
const page = Math.max(1, parseInt(req.query.page as string, 10) || 1);
|
||||
const limit = Math.min(100, Math.max(1, parseInt(req.query.limit as string, 10) || 20));
|
||||
const result = await healthService.getHealthHistory(req.params.id as string, page, limit);
|
||||
res.json(result);
|
||||
}
|
||||
);
|
||||
|
||||
// ─── Backups ────────────────────────────────────────────────────────
|
||||
|
||||
router.post(
|
||||
'/:id/backup',
|
||||
requireRole('SUPER_ADMIN', 'OPERATOR'),
|
||||
async (req: Request, res: Response) => {
|
||||
const backup = await backupService.createBackup(req.params.id as string, req.user!.id, req.ip);
|
||||
res.status(201).json({ data: backup });
|
||||
}
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/:id/backups',
|
||||
async (req: Request, res: Response) => {
|
||||
const page = Math.max(1, parseInt(req.query.page as string, 10) || 1);
|
||||
const limit = Math.min(100, Math.max(1, parseInt(req.query.limit as string, 10) || 50));
|
||||
const result = await backupService.listBackups(req.params.id as string, page, limit);
|
||||
res.json(result);
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,82 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const createInstanceSchema = z.object({
|
||||
name: z.string().min(2).max(100),
|
||||
slug: z.string().min(2).max(50).regex(/^[a-z0-9-]+$/, 'Slug must be lowercase alphanumeric with hyphens'),
|
||||
domain: z.string().min(3).max(255),
|
||||
adminEmail: z.string().email(),
|
||||
enableMedia: z.boolean().default(false),
|
||||
enableChat: z.boolean().default(false),
|
||||
enableGancio: z.boolean().default(false),
|
||||
enableListmonk: z.boolean().default(false),
|
||||
enableMonitoring: z.boolean().default(false),
|
||||
enableDevTools: z.boolean().default(false),
|
||||
enablePayments: z.boolean().default(false),
|
||||
smtpHost: z.string().optional(),
|
||||
smtpPort: z.coerce.number().optional(),
|
||||
smtpUser: z.string().optional(),
|
||||
smtpFrom: z.string().optional(),
|
||||
emailTestMode: z.boolean().default(true),
|
||||
enablePangolin: z.boolean().default(false),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
|
||||
export const updateInstanceSchema = z.object({
|
||||
name: z.string().min(2).max(100).optional(),
|
||||
enableMedia: z.boolean().optional(),
|
||||
enableChat: z.boolean().optional(),
|
||||
enableGancio: z.boolean().optional(),
|
||||
enableListmonk: z.boolean().optional(),
|
||||
enableMonitoring: z.boolean().optional(),
|
||||
enableDevTools: z.boolean().optional(),
|
||||
enablePayments: z.boolean().optional(),
|
||||
smtpHost: z.string().optional(),
|
||||
smtpPort: z.coerce.number().optional(),
|
||||
smtpUser: z.string().optional(),
|
||||
smtpFrom: z.string().optional(),
|
||||
emailTestMode: z.boolean().optional(),
|
||||
notes: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
export const registerInstanceSchema = z.object({
|
||||
name: z.string().min(2).max(100),
|
||||
slug: z.string().min(2).max(50).regex(/^[a-z0-9-]+$/, 'Slug must be lowercase alphanumeric with hyphens'),
|
||||
domain: z.string().min(3).max(255),
|
||||
basePath: z.string().min(1),
|
||||
composeProject: z.string().min(1),
|
||||
portConfig: z.object({
|
||||
api: z.coerce.number().int().min(1).max(65535),
|
||||
admin: z.coerce.number().int().min(1).max(65535),
|
||||
postgres: z.coerce.number().int().min(1).max(65535),
|
||||
nginx: z.coerce.number().int().min(1).max(65535),
|
||||
}),
|
||||
adminEmail: z.string().email().optional().default('admin@localhost'),
|
||||
enableMedia: z.boolean().default(false),
|
||||
enableChat: z.boolean().default(false),
|
||||
enableGancio: z.boolean().default(false),
|
||||
enableListmonk: z.boolean().default(false),
|
||||
enableMonitoring: z.boolean().default(false),
|
||||
enableDevTools: z.boolean().default(false),
|
||||
enablePayments: z.boolean().default(false),
|
||||
notes: z.string().optional(),
|
||||
});
|
||||
|
||||
export const reconfigureInstanceSchema = z.object({
|
||||
enableMedia: z.boolean().optional(),
|
||||
enableChat: z.boolean().optional(),
|
||||
enableGancio: z.boolean().optional(),
|
||||
enableListmonk: z.boolean().optional(),
|
||||
enableMonitoring: z.boolean().optional(),
|
||||
enableDevTools: z.boolean().optional(),
|
||||
enablePayments: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const importInstancesSchema = z.object({
|
||||
instances: z.array(registerInstanceSchema).min(1).max(50),
|
||||
});
|
||||
|
||||
export type CreateInstanceInput = z.infer<typeof createInstanceSchema>;
|
||||
export type UpdateInstanceInput = z.infer<typeof updateInstanceSchema>;
|
||||
export type RegisterInstanceInput = z.infer<typeof registerInstanceSchema>;
|
||||
export type ReconfigureInstanceInput = z.infer<typeof reconfigureInstanceSchema>;
|
||||
export type ImportInstancesInput = z.infer<typeof importInstancesSchema>;
|
||||
@@ -0,0 +1,607 @@
|
||||
import { Prisma, InstanceStatus, AuditAction } from '@prisma/client';
|
||||
import fs from 'fs/promises';
|
||||
import { parse as parseDotenv } from 'dotenv';
|
||||
import { prisma } from '../../lib/prisma';
|
||||
import { env } from '../../config/env';
|
||||
import { AppError } from '../../middleware/error-handler';
|
||||
import { encryptJson, decryptJson } from '../../utils/encryption';
|
||||
import { generateSecrets } from '../../services/secret-generator';
|
||||
import { allocatePorts, releasePorts } from '../../services/port-allocator';
|
||||
import * as docker from '../../services/docker.service';
|
||||
import { provision } from './provisioner';
|
||||
import { CreateInstanceInput, UpdateInstanceInput, RegisterInstanceInput, ReconfigureInstanceInput } from './instances.schemas';
|
||||
import { buildTemplateContext, renderAllTemplates, clearTemplateCache } from '../../services/template-engine';
|
||||
import { logger } from '../../utils/logger';
|
||||
import path from 'path';
|
||||
|
||||
// ─── CRUD Operations ─────────────────────────────────────────────────
|
||||
|
||||
export async function listInstances() {
|
||||
return prisma.instance.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
omit: { encryptedSecrets: true },
|
||||
include: {
|
||||
portAllocations: true,
|
||||
_count: { select: { healthChecks: true, backups: true } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function getInstance(id: string) {
|
||||
const instance = await prisma.instance.findUnique({
|
||||
where: { id },
|
||||
omit: { encryptedSecrets: true },
|
||||
include: {
|
||||
portAllocations: true,
|
||||
healthChecks: { orderBy: { checkedAt: 'desc' }, take: 10 },
|
||||
backups: { orderBy: { startedAt: 'desc' }, take: 10 },
|
||||
},
|
||||
});
|
||||
if (!instance) {
|
||||
throw new AppError(404, 'Instance not found', 'NOT_FOUND');
|
||||
}
|
||||
return instance;
|
||||
}
|
||||
|
||||
export async function createInstance(input: CreateInstanceInput, userId: string, ipAddress?: string) {
|
||||
// Check uniqueness
|
||||
const existing = await prisma.instance.findFirst({
|
||||
where: { OR: [{ slug: input.slug }, { domain: input.domain }] },
|
||||
});
|
||||
if (existing) {
|
||||
const field = existing.slug === input.slug ? 'slug' : 'domain';
|
||||
throw new AppError(409, `Instance with this ${field} already exists`, 'DUPLICATE');
|
||||
}
|
||||
|
||||
// Allocate ports
|
||||
const ports = await allocatePorts();
|
||||
|
||||
// Generate secrets
|
||||
const secrets = generateSecrets(input.adminEmail);
|
||||
|
||||
// Compute paths
|
||||
const composeProject = `cml-${input.slug}`;
|
||||
const basePath = path.join(env.INSTANCES_BASE_PATH, input.slug, 'changemaker.lite');
|
||||
|
||||
// Create instance record
|
||||
const instance = await prisma.instance.create({
|
||||
data: {
|
||||
slug: input.slug,
|
||||
name: input.name,
|
||||
domain: input.domain,
|
||||
status: InstanceStatus.PROVISIONING,
|
||||
basePath,
|
||||
composeProject,
|
||||
gitBranch: env.CML_GIT_BRANCH,
|
||||
portConfig: ports.config,
|
||||
encryptedSecrets: encryptJson(secrets as unknown as Record<string, unknown>),
|
||||
enableMedia: input.enableMedia,
|
||||
enableChat: input.enableChat,
|
||||
enableGancio: input.enableGancio,
|
||||
enableListmonk: input.enableListmonk,
|
||||
enableMonitoring: input.enableMonitoring,
|
||||
enableDevTools: input.enableDevTools,
|
||||
enablePayments: input.enablePayments,
|
||||
adminEmail: input.adminEmail,
|
||||
smtpHost: input.smtpHost,
|
||||
smtpPort: input.smtpPort,
|
||||
smtpUser: input.smtpUser,
|
||||
smtpFrom: input.smtpFrom,
|
||||
emailTestMode: input.emailTestMode,
|
||||
notes: input.notes,
|
||||
portAllocations: {
|
||||
create: ports.allocations,
|
||||
},
|
||||
},
|
||||
include: { portAllocations: true },
|
||||
});
|
||||
|
||||
// Audit log
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
userId,
|
||||
instanceId: instance.id,
|
||||
action: AuditAction.INSTANCE_CREATE,
|
||||
details: { name: input.name, domain: input.domain, slug: input.slug },
|
||||
ipAddress,
|
||||
},
|
||||
});
|
||||
|
||||
// Kick off provisioning asynchronously (fire-and-forget)
|
||||
provision(instance.id).catch((err) => {
|
||||
logger.error(`[instances] Provisioning failed for ${instance.slug}: ${err}`);
|
||||
});
|
||||
|
||||
return instance;
|
||||
}
|
||||
|
||||
export async function registerInstance(input: RegisterInstanceInput, userId: string, ipAddress?: string) {
|
||||
// Check uniqueness (slug, domain, composeProject)
|
||||
const existing = await prisma.instance.findFirst({
|
||||
where: {
|
||||
OR: [
|
||||
{ slug: input.slug },
|
||||
{ domain: input.domain },
|
||||
{ composeProject: input.composeProject },
|
||||
],
|
||||
},
|
||||
});
|
||||
if (existing) {
|
||||
const field = existing.slug === input.slug ? 'slug'
|
||||
: existing.domain === input.domain ? 'domain'
|
||||
: 'composeProject';
|
||||
throw new AppError(409, `Instance with this ${field} already exists`, 'DUPLICATE');
|
||||
}
|
||||
|
||||
// Verify basePath has a docker-compose.yml
|
||||
try {
|
||||
await fs.access(path.join(input.basePath, 'docker-compose.yml'));
|
||||
} catch {
|
||||
throw new AppError(400, `No docker-compose.yml found at ${input.basePath}`, 'INVALID_PATH');
|
||||
}
|
||||
|
||||
// Detect running containers to determine initial status
|
||||
let initialStatus: InstanceStatus = InstanceStatus.STOPPED;
|
||||
try {
|
||||
const containers = await docker.composePs(input.basePath, input.composeProject);
|
||||
const runningCount = containers.filter((c) => c.state === 'running').length;
|
||||
if (runningCount > 0) {
|
||||
initialStatus = InstanceStatus.RUNNING;
|
||||
}
|
||||
} catch {
|
||||
logger.warn(`[instances] Could not detect containers for ${input.composeProject}, defaulting to STOPPED`);
|
||||
}
|
||||
|
||||
// Create instance record
|
||||
const instance = await prisma.instance.create({
|
||||
data: {
|
||||
slug: input.slug,
|
||||
name: input.name,
|
||||
domain: input.domain,
|
||||
status: initialStatus,
|
||||
statusMessage: initialStatus === InstanceStatus.RUNNING ? 'Registered — containers running' : 'Registered — containers not running',
|
||||
basePath: input.basePath,
|
||||
composeProject: input.composeProject,
|
||||
portConfig: input.portConfig,
|
||||
encryptedSecrets: null,
|
||||
isRegistered: true,
|
||||
enableMedia: input.enableMedia,
|
||||
enableChat: input.enableChat,
|
||||
enableGancio: input.enableGancio,
|
||||
enableListmonk: input.enableListmonk,
|
||||
enableMonitoring: input.enableMonitoring,
|
||||
enableDevTools: input.enableDevTools,
|
||||
enablePayments: input.enablePayments,
|
||||
adminEmail: input.adminEmail,
|
||||
notes: input.notes,
|
||||
},
|
||||
});
|
||||
|
||||
// Create PortAllocation records (try-catch each for unique constraint)
|
||||
for (const [service, port] of Object.entries(input.portConfig)) {
|
||||
try {
|
||||
await prisma.portAllocation.create({
|
||||
data: { port, service, instanceId: instance.id },
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn(`[instances] Port ${port} (${service}) already allocated, skipping`);
|
||||
}
|
||||
}
|
||||
|
||||
// Audit log
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
userId,
|
||||
instanceId: instance.id,
|
||||
action: AuditAction.INSTANCE_CREATE,
|
||||
details: { name: input.name, domain: input.domain, slug: input.slug, registered: true },
|
||||
ipAddress,
|
||||
},
|
||||
});
|
||||
|
||||
// Trigger immediate health check if running
|
||||
if (initialStatus === InstanceStatus.RUNNING) {
|
||||
import('../../services/health.service').then((healthService) => {
|
||||
healthService.checkInstanceHealth(instance.id).catch((err) => {
|
||||
logger.warn(`[instances] Initial health check failed for ${instance.slug}: ${(err as Error).message}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Re-fetch with relations
|
||||
return prisma.instance.findUnique({
|
||||
where: { id: instance.id },
|
||||
include: { portAllocations: true },
|
||||
});
|
||||
}
|
||||
|
||||
export async function updateInstance(id: string, input: UpdateInstanceInput, userId: string, ipAddress?: string) {
|
||||
const instance = await prisma.instance.findUnique({ where: { id } });
|
||||
if (!instance) {
|
||||
throw new AppError(404, 'Instance not found', 'NOT_FOUND');
|
||||
}
|
||||
|
||||
const updated = await prisma.instance.update({
|
||||
where: { id },
|
||||
data: input,
|
||||
});
|
||||
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
userId,
|
||||
instanceId: id,
|
||||
action: AuditAction.INSTANCE_UPDATE,
|
||||
details: input as unknown as Prisma.InputJsonValue,
|
||||
ipAddress,
|
||||
},
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function deleteInstance(id: string, userId: string, ipAddress?: string) {
|
||||
const instance = await prisma.instance.findUnique({ where: { id } });
|
||||
if (!instance) {
|
||||
throw new AppError(404, 'Instance not found', 'NOT_FOUND');
|
||||
}
|
||||
|
||||
// Registered instances: just remove from DB, never touch containers or files
|
||||
if (instance.isRegistered) {
|
||||
await releasePorts(id);
|
||||
await prisma.instance.delete({ where: { id } });
|
||||
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
userId,
|
||||
action: AuditAction.INSTANCE_DELETE,
|
||||
details: { name: instance.name, domain: instance.domain, slug: instance.slug, unregistered: true },
|
||||
ipAddress,
|
||||
},
|
||||
});
|
||||
|
||||
return { message: 'Instance unregistered' };
|
||||
}
|
||||
|
||||
// Mark as destroying
|
||||
await prisma.instance.update({
|
||||
where: { id },
|
||||
data: { status: InstanceStatus.DESTROYING, statusMessage: 'Shutting down containers...' },
|
||||
});
|
||||
|
||||
// Stop containers and remove volumes
|
||||
try {
|
||||
await docker.composeDown(instance.basePath, instance.composeProject, true);
|
||||
logger.info(`[instances] ${instance.slug}: Containers stopped and volumes removed`);
|
||||
} catch (err) {
|
||||
logger.warn(`[instances] ${instance.slug}: Docker cleanup warning: ${(err as Error).message}`);
|
||||
// Continue with deletion even if docker cleanup partially fails
|
||||
}
|
||||
|
||||
// Delete instance directory (with safety check)
|
||||
const instanceDir = path.resolve(path.dirname(instance.basePath));
|
||||
const expectedBase = path.resolve(env.INSTANCES_BASE_PATH);
|
||||
if (instanceDir.startsWith(expectedBase + '/') && instanceDir !== expectedBase) {
|
||||
try {
|
||||
await fs.rm(instanceDir, { recursive: true, force: true });
|
||||
logger.info(`[instances] ${instance.slug}: Directory ${instanceDir} removed`);
|
||||
} catch (err) {
|
||||
logger.warn(`[instances] ${instance.slug}: Directory cleanup warning: ${(err as Error).message}`);
|
||||
}
|
||||
} else {
|
||||
logger.error(`[instances] ${instance.slug}: Refusing to delete path outside base: ${instanceDir}`);
|
||||
}
|
||||
|
||||
// Release ports and delete instance from DB
|
||||
await releasePorts(id);
|
||||
await prisma.instance.delete({ where: { id } });
|
||||
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
userId,
|
||||
action: AuditAction.INSTANCE_DELETE,
|
||||
details: { name: instance.name, domain: instance.domain, slug: instance.slug },
|
||||
ipAddress,
|
||||
},
|
||||
});
|
||||
|
||||
return { message: 'Instance deleted' };
|
||||
}
|
||||
|
||||
export async function getInstanceSecrets(id: string) {
|
||||
const instance = await prisma.instance.findUnique({ where: { id } });
|
||||
if (!instance) {
|
||||
throw new AppError(404, 'Instance not found', 'NOT_FOUND');
|
||||
}
|
||||
|
||||
// CCP-provisioned instance: decrypt from DB
|
||||
if (!instance.isRegistered && instance.encryptedSecrets) {
|
||||
return decryptJson(instance.encryptedSecrets);
|
||||
}
|
||||
|
||||
// Registered/discovered instance: read from .env on disk
|
||||
// Path traversal protection: basePath must be within INSTANCES_BASE_PATH or CML_SOURCE_PATH
|
||||
const resolvedBase = path.resolve(instance.basePath);
|
||||
const allowedPaths = [
|
||||
path.resolve(env.INSTANCES_BASE_PATH),
|
||||
...(env.CML_SOURCE_PATH ? [path.resolve(env.CML_SOURCE_PATH)] : []),
|
||||
];
|
||||
const isAllowed = allowedPaths.some(
|
||||
(allowed) => resolvedBase === allowed || resolvedBase.startsWith(allowed + '/')
|
||||
);
|
||||
if (!isAllowed) {
|
||||
throw new AppError(400, 'Instance path is outside the allowed directory', 'INVALID_PATH');
|
||||
}
|
||||
|
||||
const envPath = path.join(resolvedBase, '.env');
|
||||
let envVars: Record<string, string> | null = null;
|
||||
try {
|
||||
const content = await fs.readFile(envPath, 'utf-8');
|
||||
envVars = parseDotenv(Buffer.from(content));
|
||||
} catch {
|
||||
envVars = null;
|
||||
}
|
||||
|
||||
if (!envVars) {
|
||||
throw new AppError(400, 'Could not read .env file for this instance', 'ENV_NOT_FOUND');
|
||||
}
|
||||
|
||||
return {
|
||||
initialAdminEmail: envVars.INITIAL_ADMIN_EMAIL || instance.adminEmail,
|
||||
initialAdminPassword: envVars.INITIAL_ADMIN_PASSWORD || null,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Lifecycle Operations ────────────────────────────────────────────
|
||||
|
||||
export async function provisionInstance(id: string, userId: string, ipAddress?: string) {
|
||||
// Registered instances cannot be provisioned
|
||||
const check = await prisma.instance.findUnique({ where: { id }, select: { isRegistered: true } });
|
||||
if (check?.isRegistered) {
|
||||
throw new AppError(400, 'Cannot provision a registered instance', 'NOT_MANAGED');
|
||||
}
|
||||
|
||||
// Atomic check-and-update to prevent concurrent provisioning
|
||||
const { count } = await prisma.instance.updateMany({
|
||||
where: { id, status: { in: [InstanceStatus.ERROR, InstanceStatus.STOPPED] } },
|
||||
data: { status: InstanceStatus.PROVISIONING, statusMessage: 'Retrying provisioning...' },
|
||||
});
|
||||
|
||||
if (count === 0) {
|
||||
const instance = await prisma.instance.findUnique({ where: { id } });
|
||||
if (!instance) throw new AppError(404, 'Instance not found', 'NOT_FOUND');
|
||||
throw new AppError(400, `Cannot provision instance in ${instance.status} state`, 'INVALID_STATE');
|
||||
}
|
||||
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
userId,
|
||||
instanceId: id,
|
||||
action: AuditAction.INSTANCE_UPDATE,
|
||||
details: { event: 'provision_retry' },
|
||||
ipAddress,
|
||||
},
|
||||
});
|
||||
|
||||
// Fire-and-forget
|
||||
provision(id).catch((err) => {
|
||||
logger.error(`[instances] Re-provisioning failed for ${id}: ${err}`);
|
||||
});
|
||||
|
||||
return { message: 'Provisioning started' };
|
||||
}
|
||||
|
||||
export async function startInstance(id: string, userId: string, ipAddress?: string) {
|
||||
const instance = await prisma.instance.findUnique({ where: { id } });
|
||||
if (!instance) {
|
||||
throw new AppError(404, 'Instance not found', 'NOT_FOUND');
|
||||
}
|
||||
|
||||
if (instance.status !== 'STOPPED' && instance.status !== 'ERROR') {
|
||||
throw new AppError(400, `Cannot start instance in ${instance.status} state`, 'INVALID_STATE');
|
||||
}
|
||||
|
||||
try {
|
||||
await docker.composeUp(instance.basePath, instance.composeProject);
|
||||
|
||||
await prisma.instance.update({
|
||||
where: { id },
|
||||
data: { status: InstanceStatus.RUNNING, statusMessage: 'All containers started' },
|
||||
});
|
||||
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
userId,
|
||||
instanceId: id,
|
||||
action: AuditAction.INSTANCE_START,
|
||||
details: { slug: instance.slug },
|
||||
ipAddress,
|
||||
},
|
||||
});
|
||||
|
||||
return { message: 'Instance started' };
|
||||
} catch (err) {
|
||||
const errorMsg = (err as Error).message;
|
||||
await prisma.instance.update({
|
||||
where: { id },
|
||||
data: { status: InstanceStatus.ERROR, statusMessage: `Start failed: ${errorMsg}` },
|
||||
});
|
||||
throw new AppError(500, `Failed to start instance: ${errorMsg}`, 'DOCKER_ERROR');
|
||||
}
|
||||
}
|
||||
|
||||
export async function stopInstance(id: string, userId: string, ipAddress?: string) {
|
||||
const instance = await prisma.instance.findUnique({ where: { id } });
|
||||
if (!instance) {
|
||||
throw new AppError(404, 'Instance not found', 'NOT_FOUND');
|
||||
}
|
||||
|
||||
if (instance.status !== 'RUNNING' && instance.status !== 'ERROR') {
|
||||
throw new AppError(400, `Cannot stop instance in ${instance.status} state`, 'INVALID_STATE');
|
||||
}
|
||||
|
||||
try {
|
||||
await docker.composeStop(instance.basePath, instance.composeProject);
|
||||
|
||||
await prisma.instance.update({
|
||||
where: { id },
|
||||
data: { status: InstanceStatus.STOPPED, statusMessage: 'All containers stopped' },
|
||||
});
|
||||
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
userId,
|
||||
instanceId: id,
|
||||
action: AuditAction.INSTANCE_STOP,
|
||||
details: { slug: instance.slug },
|
||||
ipAddress,
|
||||
},
|
||||
});
|
||||
|
||||
return { message: 'Instance stopped' };
|
||||
} catch (err) {
|
||||
const errorMsg = (err as Error).message;
|
||||
throw new AppError(500, `Failed to stop instance: ${errorMsg}`, 'DOCKER_ERROR');
|
||||
}
|
||||
}
|
||||
|
||||
export async function restartInstance(id: string, userId: string, ipAddress?: string, service?: string) {
|
||||
const instance = await prisma.instance.findUnique({ where: { id } });
|
||||
if (!instance) {
|
||||
throw new AppError(404, 'Instance not found', 'NOT_FOUND');
|
||||
}
|
||||
|
||||
try {
|
||||
await docker.composeRestart(instance.basePath, instance.composeProject, service);
|
||||
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
userId,
|
||||
instanceId: id,
|
||||
action: AuditAction.INSTANCE_RESTART,
|
||||
details: { slug: instance.slug, service: service || 'all' },
|
||||
ipAddress,
|
||||
},
|
||||
});
|
||||
|
||||
return { message: `${service || 'All services'} restarted` };
|
||||
} catch (err) {
|
||||
const errorMsg = (err as Error).message;
|
||||
throw new AppError(500, `Failed to restart: ${errorMsg}`, 'DOCKER_ERROR');
|
||||
}
|
||||
}
|
||||
|
||||
export async function getInstanceServices(id: string) {
|
||||
const instance = await prisma.instance.findUnique({ where: { id } });
|
||||
if (!instance) {
|
||||
throw new AppError(404, 'Instance not found', 'NOT_FOUND');
|
||||
}
|
||||
|
||||
try {
|
||||
return await docker.composePs(instance.basePath, instance.composeProject);
|
||||
} catch {
|
||||
// If compose ps fails (e.g. no containers), return empty array
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function getInstanceLogs(
|
||||
id: string,
|
||||
service?: string,
|
||||
tail = 200,
|
||||
since?: string
|
||||
) {
|
||||
const instance = await prisma.instance.findUnique({ where: { id } });
|
||||
if (!instance) {
|
||||
throw new AppError(404, 'Instance not found', 'NOT_FOUND');
|
||||
}
|
||||
|
||||
try {
|
||||
return await docker.composeLogs(
|
||||
instance.basePath,
|
||||
instance.composeProject,
|
||||
service,
|
||||
tail,
|
||||
since
|
||||
);
|
||||
} catch (err) {
|
||||
throw new AppError(500, `Failed to get logs: ${(err as Error).message}`, 'DOCKER_ERROR');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Reconfiguration ─────────────────────────────────────────────────
|
||||
|
||||
export async function reconfigureInstance(
|
||||
id: string,
|
||||
features: ReconfigureInstanceInput,
|
||||
userId: string,
|
||||
ipAddress?: string
|
||||
) {
|
||||
const instance = await prisma.instance.findUnique({ where: { id } });
|
||||
if (!instance) {
|
||||
throw new AppError(404, 'Instance not found', 'NOT_FOUND');
|
||||
}
|
||||
if (instance.isRegistered) {
|
||||
throw new AppError(400, 'Cannot reconfigure an external instance', 'NOT_MANAGED');
|
||||
}
|
||||
if (!instance.encryptedSecrets) {
|
||||
throw new AppError(400, 'Instance has no secrets — cannot reconfigure', 'NOT_MANAGED');
|
||||
}
|
||||
if (instance.status !== 'RUNNING' && instance.status !== 'STOPPED') {
|
||||
throw new AppError(400, `Cannot reconfigure instance in ${instance.status} state`, 'INVALID_STATE');
|
||||
}
|
||||
|
||||
// Update feature flags in DB
|
||||
const updated = await prisma.instance.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...features,
|
||||
statusMessage: 'Reconfiguring...',
|
||||
},
|
||||
});
|
||||
|
||||
// Clear template cache so updated templates are re-read
|
||||
clearTemplateCache();
|
||||
|
||||
// Re-render templates with updated flags
|
||||
const secrets = decryptJson<Record<string, string>>(instance.encryptedSecrets);
|
||||
const context = buildTemplateContext(updated, secrets);
|
||||
await renderAllTemplates(context, instance.basePath);
|
||||
|
||||
// If instance is running, apply changes via docker compose up
|
||||
if (instance.status === 'RUNNING') {
|
||||
try {
|
||||
await docker.composeUp(instance.basePath, instance.composeProject);
|
||||
// --remove-orphans (from composeUp) will clean up disabled services
|
||||
|
||||
await prisma.instance.update({
|
||||
where: { id },
|
||||
data: { statusMessage: 'Reconfiguration complete' },
|
||||
});
|
||||
} catch (err) {
|
||||
const errorMsg = (err as Error).message;
|
||||
await prisma.instance.update({
|
||||
where: { id },
|
||||
data: { statusMessage: `Reconfiguration failed: ${errorMsg}` },
|
||||
});
|
||||
throw new AppError(500, `Reconfiguration failed: ${errorMsg}`, 'DOCKER_ERROR');
|
||||
}
|
||||
} else {
|
||||
await prisma.instance.update({
|
||||
where: { id },
|
||||
data: { statusMessage: 'Reconfiguration complete — start instance to apply' },
|
||||
});
|
||||
}
|
||||
|
||||
// Audit log
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
userId,
|
||||
instanceId: id,
|
||||
action: AuditAction.INSTANCE_UPDATE,
|
||||
details: { event: 'reconfigure', features } as unknown as Prisma.InputJsonValue,
|
||||
ipAddress,
|
||||
},
|
||||
});
|
||||
|
||||
return updated;
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
import { InstanceStatus, AuditAction } from '@prisma/client';
|
||||
import { exec as execCb } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { prisma } from '../../lib/prisma';
|
||||
import { env } from '../../config/env';
|
||||
import { decryptJson } from '../../utils/encryption';
|
||||
import { renderAllTemplates, buildTemplateContext } from '../../services/template-engine';
|
||||
import * as docker from '../../services/docker.service';
|
||||
import { logger } from '../../utils/logger';
|
||||
const exec = promisify(execCb);
|
||||
|
||||
/**
|
||||
* Directories/files to exclude when copying CML source to instance directory.
|
||||
*/
|
||||
const COPY_EXCLUDES = [
|
||||
'node_modules',
|
||||
'.git',
|
||||
'.env',
|
||||
'changemaker-control-panel',
|
||||
'.claude',
|
||||
];
|
||||
|
||||
/**
|
||||
* Update instance status and statusMessage in the database.
|
||||
*/
|
||||
async function updateStatus(
|
||||
instanceId: string,
|
||||
status: InstanceStatus,
|
||||
statusMessage: string
|
||||
): Promise<void> {
|
||||
await prisma.instance.update({
|
||||
where: { id: instanceId },
|
||||
data: { status, statusMessage },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Provision a CML instance: copy source, render configs, build and start Docker stack.
|
||||
*
|
||||
* This function runs asynchronously — call it without awaiting.
|
||||
* Progress is tracked via instance.status and instance.statusMessage.
|
||||
*/
|
||||
export async function provision(instanceId: string): Promise<void> {
|
||||
const totalSteps = 13;
|
||||
let step = 0;
|
||||
|
||||
function stepMsg(description: string): string {
|
||||
step++;
|
||||
return `Step ${step}/${totalSteps}: ${description}`;
|
||||
}
|
||||
|
||||
try {
|
||||
// Load instance with all details
|
||||
const instance = await prisma.instance.findUnique({
|
||||
where: { id: instanceId },
|
||||
include: { portAllocations: true },
|
||||
});
|
||||
if (!instance) {
|
||||
throw new Error(`Instance ${instanceId} not found`);
|
||||
}
|
||||
|
||||
const { basePath, composeProject } = instance;
|
||||
const instanceDir = path.dirname(basePath); // parent of changemaker.lite
|
||||
|
||||
// ── Step 1: Create instance directory ───────────────────────────
|
||||
await updateStatus(instanceId, InstanceStatus.PROVISIONING, stepMsg('Creating instance directory'));
|
||||
logger.info(`[provisioner] ${instance.slug}: Creating directory ${basePath}`);
|
||||
await fs.mkdir(basePath, { recursive: true });
|
||||
|
||||
// ── Step 2: Copy CML source ────────────────────────────────────
|
||||
await updateStatus(instanceId, InstanceStatus.PROVISIONING, stepMsg('Copying CML source'));
|
||||
logger.info(`[provisioner] ${instance.slug}: Copying from ${env.CML_SOURCE_PATH} to ${basePath}`);
|
||||
|
||||
const excludeFlags = COPY_EXCLUDES.map((e) => `--exclude='${e}'`).join(' ');
|
||||
await exec(
|
||||
`rsync -a ${excludeFlags} ${env.CML_SOURCE_PATH}/ ${basePath}/`,
|
||||
{ timeout: 120_000 }
|
||||
);
|
||||
|
||||
// ── Step 2b: Create media directories ──────────────────────────
|
||||
// Media API volume mounts use ./media as the read-only base with
|
||||
// rw overlays for subdirectories. All must exist before Docker starts.
|
||||
const mediaDirs = [
|
||||
'media/local/inbox',
|
||||
'media/local/thumbnails',
|
||||
'media/local/photos',
|
||||
'media/public',
|
||||
];
|
||||
for (const dir of mediaDirs) {
|
||||
await fs.mkdir(path.join(basePath, dir), { recursive: true });
|
||||
}
|
||||
logger.info(`[provisioner] ${instance.slug}: Created media directories`);
|
||||
|
||||
// ── Step 3: Decrypt secrets ────────────────────────────────────
|
||||
await updateStatus(instanceId, InstanceStatus.PROVISIONING, stepMsg('Preparing configuration'));
|
||||
if (!instance.encryptedSecrets) {
|
||||
throw new Error('Instance has no encrypted secrets — cannot provision');
|
||||
}
|
||||
const secrets = decryptJson<Record<string, string>>(instance.encryptedSecrets);
|
||||
|
||||
// ── Step 4: Build template context ─────────────────────────────
|
||||
const context = buildTemplateContext(instance, secrets);
|
||||
|
||||
// ── Step 5: Render templates ───────────────────────────────────
|
||||
await updateStatus(instanceId, InstanceStatus.PROVISIONING, stepMsg('Rendering configuration files'));
|
||||
logger.info(`[provisioner] ${instance.slug}: Rendering templates to ${basePath}`);
|
||||
await renderAllTemplates(context, basePath);
|
||||
|
||||
// ── Step 6: Pull base images ───────────────────────────────────
|
||||
await updateStatus(instanceId, InstanceStatus.PROVISIONING, stepMsg('Pulling Docker images'));
|
||||
logger.info(`[provisioner] ${instance.slug}: Pulling images`);
|
||||
try {
|
||||
await docker.composePull(basePath, composeProject);
|
||||
} catch (err) {
|
||||
// Pull failures are non-fatal if images already exist locally
|
||||
logger.warn(`[provisioner] ${instance.slug}: Pull warning: ${(err as Error).message}`);
|
||||
}
|
||||
|
||||
// ── Step 7: Build custom images ────────────────────────────────
|
||||
await updateStatus(instanceId, InstanceStatus.PROVISIONING, stepMsg('Building Docker images'));
|
||||
logger.info(`[provisioner] ${instance.slug}: Building images`);
|
||||
await docker.composeBuild(basePath, composeProject);
|
||||
|
||||
// ── Step 8: Start infrastructure (Postgres + Redis) ────────────
|
||||
await updateStatus(instanceId, InstanceStatus.PROVISIONING, stepMsg('Starting database and cache'));
|
||||
logger.info(`[provisioner] ${instance.slug}: Starting infrastructure services`);
|
||||
await docker.composeUp(basePath, composeProject, ['v2-postgres', 'redis']);
|
||||
|
||||
// ── Step 9: Wait for infrastructure healthy ────────────────────
|
||||
await updateStatus(instanceId, InstanceStatus.PROVISIONING, stepMsg('Waiting for database to be ready'));
|
||||
logger.info(`[provisioner] ${instance.slug}: Waiting for Postgres + Redis healthy`);
|
||||
// Container names match template's container_name: {{containerPrefix}}-postgres / {{containerPrefix}}-redis
|
||||
const pgContainer = `${composeProject}-postgres`;
|
||||
const redisContainer = `${composeProject}-redis`;
|
||||
await Promise.all([
|
||||
docker.waitForHealthy(pgContainer, 60_000),
|
||||
docker.waitForHealthy(redisContainer, 60_000),
|
||||
]);
|
||||
|
||||
// ── Step 10: Run database schema sync ─────────────────────────
|
||||
await updateStatus(instanceId, InstanceStatus.PROVISIONING, stepMsg('Setting up database schema'));
|
||||
logger.info(`[provisioner] ${instance.slug}: Pushing Prisma schema to database`);
|
||||
// Use composeRun with --entrypoint "" to skip the API's startup entrypoint
|
||||
// (which would try migrate deploy + seed and fail on schema drift)
|
||||
await docker.composeRun(basePath, composeProject, 'api', 'npx prisma db push --accept-data-loss', 180_000);
|
||||
|
||||
// ── Step 11: Seed database ─────────────────────────────────────
|
||||
await updateStatus(instanceId, InstanceStatus.PROVISIONING, stepMsg('Seeding database'));
|
||||
logger.info(`[provisioner] ${instance.slug}: Seeding database`);
|
||||
await docker.composeRun(basePath, composeProject, 'api', 'npx prisma db seed', 120_000);
|
||||
|
||||
// ── Step 12: Start all services ────────────────────────────────
|
||||
await updateStatus(instanceId, InstanceStatus.PROVISIONING, stepMsg('Starting all services'));
|
||||
logger.info(`[provisioner] ${instance.slug}: Starting all services`);
|
||||
await docker.composeUp(basePath, composeProject);
|
||||
|
||||
// ── Step 13: Health check ──────────────────────────────────────
|
||||
await updateStatus(instanceId, InstanceStatus.PROVISIONING, stepMsg('Verifying instance health'));
|
||||
logger.info(`[provisioner] ${instance.slug}: Waiting for API health check`);
|
||||
const ports = instance.portConfig as Record<string, number>;
|
||||
// Use host.docker.internal to reach ports exposed on the Docker host
|
||||
// (localhost inside the CCP container refers to the CCP container itself)
|
||||
await docker.waitForHttp(`http://host.docker.internal:${ports.api}/api/health`, 120_000);
|
||||
|
||||
// ── Done! ──────────────────────────────────────────────────────
|
||||
await updateStatus(instanceId, InstanceStatus.RUNNING, 'Provisioning complete');
|
||||
logger.info(`[provisioner] ${instance.slug}: Provisioning complete!`);
|
||||
|
||||
// Audit log
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
instanceId,
|
||||
action: AuditAction.INSTANCE_CREATE,
|
||||
details: { event: 'provisioning_complete', slug: instance.slug },
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
logger.error(`[provisioner] ${instanceId}: Failed — ${errorMsg}`);
|
||||
await updateStatus(
|
||||
instanceId,
|
||||
InstanceStatus.ERROR,
|
||||
`Provisioning failed at ${step > 0 ? `step ${step}/${totalSteps}` : 'startup'}: ${errorMsg}`
|
||||
).catch(() => {});
|
||||
|
||||
// Audit log
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
instanceId,
|
||||
action: AuditAction.INSTANCE_CREATE,
|
||||
details: { event: 'provisioning_failed', error: errorMsg, step },
|
||||
},
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { AuditAction } from '@prisma/client';
|
||||
import { prisma } from '../../lib/prisma';
|
||||
import { authenticate, requireRole } from '../../middleware/auth';
|
||||
const router = Router();
|
||||
|
||||
router.use(authenticate);
|
||||
|
||||
router.get('/', async (_req: Request, res: Response) => {
|
||||
const settings = await prisma.ccpSetting.findMany();
|
||||
const map: Record<string, unknown> = {};
|
||||
for (const s of settings) {
|
||||
map[s.key] = s.value;
|
||||
}
|
||||
res.json({ data: map });
|
||||
});
|
||||
|
||||
router.put(
|
||||
'/:key',
|
||||
requireRole('SUPER_ADMIN'),
|
||||
async (req: Request, res: Response) => {
|
||||
const { key } = req.params as { key: string };
|
||||
const { value } = req.body;
|
||||
const setting = await prisma.ccpSetting.upsert({
|
||||
where: { key },
|
||||
update: { value },
|
||||
create: { key, value },
|
||||
});
|
||||
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
userId: req.user!.id,
|
||||
action: AuditAction.SETTINGS_UPDATE,
|
||||
details: { key, value },
|
||||
ipAddress: req.ip,
|
||||
},
|
||||
});
|
||||
|
||||
res.json({ data: setting });
|
||||
}
|
||||
);
|
||||
|
||||
export default router;
|
||||
66
changemaker-control-panel/api/src/server.ts
Normal file
66
changemaker-control-panel/api/src/server.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import 'express-async-errors';
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
import compression from 'compression';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import { env } from './config/env';
|
||||
import { logger } from './utils/logger';
|
||||
import { errorHandler } from './middleware/error-handler';
|
||||
|
||||
// Route imports
|
||||
import authRoutes from './modules/auth/auth.routes';
|
||||
import instanceRoutes from './modules/instances/instances.routes';
|
||||
import settingsRoutes from './modules/settings/settings.routes';
|
||||
import healthRoutes from './modules/health/health.routes';
|
||||
import auditRoutes from './modules/audit/audit.routes';
|
||||
import backupRoutes from './modules/backups/backup.routes';
|
||||
import { startHealthScheduler } from './services/health.service';
|
||||
import { autoDiscoverOnStartup } from './services/discovery.service';
|
||||
|
||||
const app = express();
|
||||
|
||||
// Global middleware
|
||||
app.use(helmet());
|
||||
app.use(compression());
|
||||
app.use(express.json({ limit: '10mb' }));
|
||||
app.use(
|
||||
cors({
|
||||
origin: env.CORS_ORIGINS.split(',').map((s) => s.trim()),
|
||||
credentials: true,
|
||||
})
|
||||
);
|
||||
|
||||
// Rate limiters
|
||||
const authLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
max: 15, // 15 attempts per window
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: { message: 'Too many attempts, please try again later', code: 'RATE_LIMITED' } },
|
||||
});
|
||||
|
||||
// Routes
|
||||
app.use('/api/auth', authLimiter, authRoutes);
|
||||
app.use('/api/instances', instanceRoutes);
|
||||
app.use('/api/settings', settingsRoutes);
|
||||
app.use('/api/health', healthRoutes);
|
||||
app.use('/api/audit', auditRoutes);
|
||||
app.use('/api/backups', backupRoutes);
|
||||
|
||||
// Error handler (must be last)
|
||||
app.use(errorHandler);
|
||||
|
||||
app.listen(env.PORT, () => {
|
||||
logger.info(`CCP API listening on port ${env.PORT} (${env.NODE_ENV})`);
|
||||
startHealthScheduler(env.HEALTH_CHECK_INTERVAL_MS);
|
||||
|
||||
// Auto-discover parent CML instance on first boot (5s delay for DB readiness)
|
||||
setTimeout(() => {
|
||||
autoDiscoverOnStartup().catch((err) =>
|
||||
logger.error(`[discovery] Auto-discovery failed: ${(err as Error).message}`)
|
||||
);
|
||||
}, 5_000);
|
||||
});
|
||||
|
||||
export default app;
|
||||
313
changemaker-control-panel/api/src/services/backup.service.ts
Normal file
313
changemaker-control-panel/api/src/services/backup.service.ts
Normal file
@@ -0,0 +1,313 @@
|
||||
import { Prisma, BackupStatus, AuditAction, InstanceStatus } from '@prisma/client';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import crypto from 'crypto';
|
||||
import { exec as execCb } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { prisma } from '../lib/prisma';
|
||||
import { env } from '../config/env';
|
||||
import { AppError } from '../middleware/error-handler';
|
||||
import { decryptJson } from '../utils/encryption';
|
||||
import * as docker from './docker.service';
|
||||
import { logger } from '../utils/logger';
|
||||
const exec = promisify(execCb);
|
||||
|
||||
/**
|
||||
* Compute SHA-256 hash of a file.
|
||||
*/
|
||||
async function fileHash(filePath: string): Promise<string> {
|
||||
const fileBuffer = await fs.readFile(filePath);
|
||||
return crypto.createHash('sha256').update(fileBuffer).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file size in bytes.
|
||||
*/
|
||||
async function fileSize(filePath: string): Promise<number> {
|
||||
const stat = await fs.stat(filePath);
|
||||
return stat.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a backup for a given instance.
|
||||
*/
|
||||
export async function createBackup(instanceId: string, userId?: string, ipAddress?: string) {
|
||||
const instance = await prisma.instance.findUnique({ where: { id: instanceId } });
|
||||
if (!instance) {
|
||||
throw new AppError(404, 'Instance not found', 'NOT_FOUND');
|
||||
}
|
||||
|
||||
if (instance.status !== InstanceStatus.RUNNING) {
|
||||
throw new AppError(400, `Cannot backup instance in ${instance.status} state`, 'INVALID_STATE');
|
||||
}
|
||||
|
||||
if ((instance as { isRegistered?: boolean }).isRegistered) {
|
||||
throw new AppError(400, 'Backups not managed by CCP for registered instances', 'NOT_MANAGED');
|
||||
}
|
||||
|
||||
// Create backup record
|
||||
const backup = await prisma.backup.create({
|
||||
data: {
|
||||
instanceId,
|
||||
status: BackupStatus.PENDING,
|
||||
},
|
||||
});
|
||||
|
||||
// Run backup asynchronously
|
||||
performBackup(backup.id, instance, userId, ipAddress).catch((err) => {
|
||||
logger.error(`[backup] Backup ${backup.id} failed: ${(err as Error).message}`);
|
||||
});
|
||||
|
||||
return backup;
|
||||
}
|
||||
|
||||
async function performBackup(
|
||||
backupId: string,
|
||||
instance: { id: string; slug: string; basePath: string; composeProject: string; encryptedSecrets: string | null },
|
||||
userId?: string,
|
||||
ipAddress?: string
|
||||
) {
|
||||
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||
const backupDir = path.join(env.BACKUP_STORAGE_PATH, instance.slug, timestamp);
|
||||
|
||||
try {
|
||||
// Update status to IN_PROGRESS
|
||||
await prisma.backup.update({
|
||||
where: { id: backupId },
|
||||
data: { status: BackupStatus.IN_PROGRESS },
|
||||
});
|
||||
|
||||
// Ensure backup directory exists
|
||||
await fs.mkdir(backupDir, { recursive: true });
|
||||
|
||||
const manifestFiles: Array<{ name: string; size: number; sha256: string }> = [];
|
||||
|
||||
// 1. Dump PostgreSQL
|
||||
try {
|
||||
const secrets = instance.encryptedSecrets
|
||||
? decryptJson<Record<string, string>>(instance.encryptedSecrets)
|
||||
: {} as Record<string, string>;
|
||||
const pgPassword = secrets.V2_POSTGRES_PASSWORD || secrets.postgresPassword || 'changemaker';
|
||||
|
||||
// Use docker compose exec to run pg_dump inside the container
|
||||
// Pass PGPASSWORD via -e flag so pg_dump can authenticate
|
||||
const dumpOutput = await docker.composeExec(
|
||||
instance.basePath,
|
||||
instance.composeProject,
|
||||
'v2-postgres',
|
||||
`pg_dump -U changemaker -d changemaker`,
|
||||
300_000, // 5 min timeout for large DBs
|
||||
{ PGPASSWORD: pgPassword }
|
||||
);
|
||||
|
||||
const dumpPath = path.join(backupDir, 'v2-postgres.sql');
|
||||
await fs.writeFile(dumpPath, dumpOutput);
|
||||
|
||||
// Gzip the dump
|
||||
await exec(`gzip "${dumpPath}"`, { timeout: 120_000 });
|
||||
const gzPath = dumpPath + '.gz';
|
||||
|
||||
manifestFiles.push({
|
||||
name: 'v2-postgres.sql.gz',
|
||||
size: await fileSize(gzPath),
|
||||
sha256: await fileHash(gzPath),
|
||||
});
|
||||
|
||||
logger.info(`[backup] ${instance.slug}: PostgreSQL dump complete`);
|
||||
} catch (err) {
|
||||
logger.warn(`[backup] ${instance.slug}: PostgreSQL dump failed: ${(err as Error).message}`);
|
||||
// Continue with backup — mark the dump as failed in manifest
|
||||
}
|
||||
|
||||
// 2. Archive uploads if they exist
|
||||
const uploadsDir = path.join(instance.basePath, 'uploads');
|
||||
try {
|
||||
await fs.access(uploadsDir);
|
||||
const uploadsArchive = path.join(backupDir, 'uploads.tar.gz');
|
||||
await exec(`tar -czf "${uploadsArchive}" -C "${instance.basePath}" uploads`, { timeout: 300_000 });
|
||||
|
||||
manifestFiles.push({
|
||||
name: 'uploads.tar.gz',
|
||||
size: await fileSize(uploadsArchive),
|
||||
sha256: await fileHash(uploadsArchive),
|
||||
});
|
||||
|
||||
logger.info(`[backup] ${instance.slug}: Uploads archive complete`);
|
||||
} catch {
|
||||
// No uploads directory or archive failed — skip
|
||||
logger.debug(`[backup] ${instance.slug}: No uploads directory or archive skipped`);
|
||||
}
|
||||
|
||||
// 3. Generate manifest
|
||||
const manifest = {
|
||||
instanceId: instance.id,
|
||||
instanceSlug: instance.slug,
|
||||
timestamp: new Date().toISOString(),
|
||||
files: manifestFiles,
|
||||
};
|
||||
|
||||
const manifestPath = path.join(backupDir, 'manifest.json');
|
||||
await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2));
|
||||
|
||||
// 4. Create final archive
|
||||
const archiveName = `backup-${instance.slug}-${timestamp}.tar.gz`;
|
||||
const archivePath = path.join(env.BACKUP_STORAGE_PATH, instance.slug, archiveName);
|
||||
|
||||
await exec(`tar -czf "${archivePath}" -C "${path.dirname(backupDir)}" "${path.basename(backupDir)}"`, {
|
||||
timeout: 300_000,
|
||||
});
|
||||
|
||||
const totalSize = await fileSize(archivePath);
|
||||
|
||||
// Cleanup the temp directory
|
||||
await fs.rm(backupDir, { recursive: true, force: true });
|
||||
|
||||
// Update backup record
|
||||
await prisma.backup.update({
|
||||
where: { id: backupId },
|
||||
data: {
|
||||
status: BackupStatus.COMPLETED,
|
||||
archivePath,
|
||||
sizeBytes: BigInt(totalSize),
|
||||
manifest: manifest as unknown as Prisma.InputJsonValue,
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Audit log
|
||||
if (userId) {
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
userId,
|
||||
instanceId: instance.id,
|
||||
action: AuditAction.BACKUP_CREATE,
|
||||
details: { backupId, archiveName, sizeBytes: totalSize },
|
||||
ipAddress,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
logger.info(`[backup] ${instance.slug}: Backup complete (${(totalSize / 1024 / 1024).toFixed(1)} MB)`);
|
||||
} catch (err) {
|
||||
// Update backup as failed
|
||||
await prisma.backup.update({
|
||||
where: { id: backupId },
|
||||
data: {
|
||||
status: BackupStatus.FAILED,
|
||||
errorMessage: (err as Error).message,
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Cleanup temp directory on failure
|
||||
try {
|
||||
await fs.rm(backupDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a backup (file + DB record).
|
||||
*/
|
||||
export async function deleteBackup(backupId: string, userId?: string, ipAddress?: string) {
|
||||
const backup = await prisma.backup.findUnique({
|
||||
where: { id: backupId },
|
||||
include: { instance: { select: { id: true, slug: true } } },
|
||||
});
|
||||
if (!backup) {
|
||||
throw new AppError(404, 'Backup not found', 'NOT_FOUND');
|
||||
}
|
||||
|
||||
// Delete archive file
|
||||
if (backup.archivePath) {
|
||||
try {
|
||||
await fs.unlink(backup.archivePath);
|
||||
} catch {
|
||||
logger.warn(`[backup] Could not delete file: ${backup.archivePath}`);
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.backup.delete({ where: { id: backupId } });
|
||||
|
||||
if (userId) {
|
||||
await prisma.auditLog.create({
|
||||
data: {
|
||||
userId,
|
||||
instanceId: backup.instanceId,
|
||||
action: AuditAction.BACKUP_DELETE,
|
||||
details: { backupId, instanceSlug: backup.instance?.slug },
|
||||
ipAddress,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List backups with optional instance filter and pagination.
|
||||
*/
|
||||
export async function listBackups(instanceId?: string, page = 1, limit = 50) {
|
||||
const where = instanceId ? { instanceId } : {};
|
||||
|
||||
const [data, total] = await Promise.all([
|
||||
prisma.backup.findMany({
|
||||
where,
|
||||
orderBy: { startedAt: 'desc' },
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
include: {
|
||||
instance: { select: { id: true, name: true, slug: true } },
|
||||
},
|
||||
}),
|
||||
prisma.backup.count({ where }),
|
||||
]);
|
||||
|
||||
return { data, total, page, limit };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a single backup by ID.
|
||||
*/
|
||||
export async function getBackup(backupId: string) {
|
||||
const backup = await prisma.backup.findUnique({ where: { id: backupId } });
|
||||
if (!backup) {
|
||||
throw new AppError(404, 'Backup not found', 'NOT_FOUND');
|
||||
}
|
||||
return backup;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup backups older than retention period.
|
||||
*/
|
||||
export async function cleanupOldBackups(retentionDays: number): Promise<number> {
|
||||
const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000);
|
||||
|
||||
const oldBackups = await prisma.backup.findMany({
|
||||
where: {
|
||||
startedAt: { lt: cutoff },
|
||||
status: { in: [BackupStatus.COMPLETED, BackupStatus.FAILED] },
|
||||
},
|
||||
});
|
||||
|
||||
let deleted = 0;
|
||||
for (const backup of oldBackups) {
|
||||
try {
|
||||
if (backup.archivePath) {
|
||||
await fs.unlink(backup.archivePath);
|
||||
}
|
||||
await prisma.backup.delete({ where: { id: backup.id } });
|
||||
deleted++;
|
||||
} catch (err) {
|
||||
logger.warn(`[backup] Failed to cleanup backup ${backup.id}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (deleted > 0) {
|
||||
logger.info(`[backup] Cleaned up ${deleted} old backups (>${retentionDays} days)`);
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
388
changemaker-control-panel/api/src/services/discovery.service.ts
Normal file
388
changemaker-control-panel/api/src/services/discovery.service.ts
Normal file
@@ -0,0 +1,388 @@
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { exec as execCb } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import { parse as parseDotenv } from 'dotenv';
|
||||
import { prisma } from '../lib/prisma';
|
||||
import { env } from '../config/env';
|
||||
import { logger } from '../utils/logger';
|
||||
import { registerInstance } from '../modules/instances/instances.service';
|
||||
import * as docker from './docker.service';
|
||||
|
||||
const exec = promisify(execCb);
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────────
|
||||
|
||||
export interface DiscoveredInstance {
|
||||
name: string;
|
||||
slug: string;
|
||||
domain: string;
|
||||
basePath: string;
|
||||
composeProject: string;
|
||||
portConfig: { api: number; admin: number; postgres: number; nginx: number };
|
||||
adminEmail: string;
|
||||
enableMedia: boolean;
|
||||
enableChat: boolean;
|
||||
enableGancio: boolean;
|
||||
enableListmonk: boolean;
|
||||
enableMonitoring: boolean;
|
||||
enableDevTools: boolean;
|
||||
enablePayments: boolean;
|
||||
emailTestMode: boolean;
|
||||
// Discovery metadata (UI-only, not persisted)
|
||||
source: 'parent' | 'docker';
|
||||
isRunning: boolean;
|
||||
runningContainers: number;
|
||||
totalContainers: number;
|
||||
isAlreadyRegistered: boolean;
|
||||
existingInstanceId?: string;
|
||||
isParentInstance: boolean;
|
||||
}
|
||||
|
||||
export interface DiscoverySummary {
|
||||
total: number;
|
||||
newInstances: number;
|
||||
alreadyRegistered: number;
|
||||
running: number;
|
||||
parentFound: boolean;
|
||||
}
|
||||
|
||||
export interface DiscoveryResult {
|
||||
instances: DiscoveredInstance[];
|
||||
summary: DiscoverySummary;
|
||||
}
|
||||
|
||||
interface ComposeProject {
|
||||
Name: string;
|
||||
Status: string;
|
||||
ConfigFiles: string;
|
||||
}
|
||||
|
||||
// ─── .env Parser ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parse a CML instance's .env file and extract safe configuration metadata.
|
||||
* Never reads secrets (JWT keys, passwords, encryption keys).
|
||||
*/
|
||||
async function parseCmlEnv(envPath: string): Promise<Record<string, string> | null> {
|
||||
try {
|
||||
const content = await fs.readFile(envPath, 'utf-8');
|
||||
return parseDotenv(Buffer.from(content));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function extractPortConfig(envVars: Record<string, string>): DiscoveredInstance['portConfig'] {
|
||||
return {
|
||||
api: parseInt(envVars.API_PORT || '4000', 10),
|
||||
admin: parseInt(envVars.ADMIN_PORT || '3000', 10),
|
||||
postgres: parseInt(envVars.V2_POSTGRES_PORT || '5433', 10),
|
||||
nginx: parseInt(envVars.NGINX_HTTP_PORT || '80', 10),
|
||||
};
|
||||
}
|
||||
|
||||
function extractFeatureFlags(envVars: Record<string, string>) {
|
||||
const isTrue = (val?: string) => val?.toLowerCase() === 'true';
|
||||
return {
|
||||
enableMedia: isTrue(envVars.ENABLE_MEDIA_FEATURES),
|
||||
enableChat: isTrue(envVars.ENABLE_CHAT),
|
||||
enableGancio: isTrue(envVars.GANCIO_SYNC_ENABLED),
|
||||
enableListmonk: isTrue(envVars.LISTMONK_SYNC_ENABLED),
|
||||
enablePayments: isTrue(envVars.ENABLE_PAYMENTS),
|
||||
emailTestMode: isTrue(envVars.EMAIL_TEST_MODE),
|
||||
};
|
||||
}
|
||||
|
||||
function slugify(str: string): string {
|
||||
return str
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
.substring(0, 50);
|
||||
}
|
||||
|
||||
// ─── CML Fingerprint Test ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Check if a directory is a CML instance by verifying key files exist
|
||||
* and the .env has CML-specific variables.
|
||||
*/
|
||||
async function isCmlInstance(dirPath: string): Promise<boolean> {
|
||||
try {
|
||||
// Must have docker-compose.yml and api/prisma/schema.prisma
|
||||
await fs.access(path.join(dirPath, 'docker-compose.yml'));
|
||||
await fs.access(path.join(dirPath, 'api', 'prisma', 'schema.prisma'));
|
||||
|
||||
// Must have .env with DOMAIN and JWT_ACCESS_SECRET
|
||||
const envVars = await parseCmlEnv(path.join(dirPath, '.env'));
|
||||
if (!envVars) return false;
|
||||
return !!(envVars.DOMAIN && envVars.JWT_ACCESS_SECRET);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Container Status ───────────────────────────────────────────────
|
||||
|
||||
async function getContainerCounts(
|
||||
projectDir: string,
|
||||
composeProject: string
|
||||
): Promise<{ running: number; total: number }> {
|
||||
try {
|
||||
// Use docker.composePs which validates the project name
|
||||
const containers = await docker.composePs(projectDir, composeProject);
|
||||
const running = containers.filter((c) => c.state === 'running').length;
|
||||
return { running, total: containers.length };
|
||||
} catch {
|
||||
return { running: 0, total: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Compose Project Discovery ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* List all Docker Compose projects on the host via `docker compose ls`.
|
||||
*/
|
||||
async function listComposeProjects(): Promise<ComposeProject[]> {
|
||||
try {
|
||||
const { stdout } = await exec('docker compose ls --format json', {
|
||||
timeout: 15_000,
|
||||
env: { ...process.env, COMPOSE_ANSI: 'never' },
|
||||
});
|
||||
if (!stdout.trim()) return [];
|
||||
return JSON.parse(stdout);
|
||||
} catch (err) {
|
||||
logger.warn(`[discovery] Failed to list compose projects: ${(err as Error).message}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive the compose project name from a directory path.
|
||||
* Docker Compose defaults to the directory basename (lowercased, special chars replaced).
|
||||
*/
|
||||
function deriveComposeProject(dirPath: string, projects: ComposeProject[]): string {
|
||||
// Try to find project by matching config file path
|
||||
const composePath = path.join(dirPath, 'docker-compose.yml');
|
||||
for (const p of projects) {
|
||||
// ConfigFiles can be comma-separated
|
||||
const configs = p.ConfigFiles.split(',').map((s) => s.trim());
|
||||
if (configs.some((c) => path.resolve(c) === path.resolve(composePath))) {
|
||||
return p.Name;
|
||||
}
|
||||
}
|
||||
// Fall back to directory basename convention
|
||||
return path.basename(dirPath).toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
}
|
||||
|
||||
// ─── Build Discovered Instance ──────────────────────────────────────
|
||||
|
||||
async function buildDiscoveredInstance(
|
||||
dirPath: string,
|
||||
source: 'parent' | 'docker',
|
||||
composeProject: string,
|
||||
existingInstances: Array<{ id: string; basePath: string; composeProject: string; domain: string }>
|
||||
): Promise<DiscoveredInstance | null> {
|
||||
const envVars = await parseCmlEnv(path.join(dirPath, '.env'));
|
||||
if (!envVars) return null;
|
||||
|
||||
const domain = envVars.DOMAIN || path.basename(dirPath);
|
||||
const portConfig = extractPortConfig(envVars);
|
||||
const flags = extractFeatureFlags(envVars);
|
||||
|
||||
// Check container status
|
||||
const { running, total } = await getContainerCounts(dirPath, composeProject);
|
||||
|
||||
// Infer monitoring/devtools from running containers (these aren't .env flags)
|
||||
let enableMonitoring = false;
|
||||
let enableDevTools = false;
|
||||
try {
|
||||
// Use docker.composePs which validates the project name (prevents shell injection)
|
||||
const containers = await docker.composePs(dirPath, composeProject);
|
||||
const serviceNames = containers.map((c) => c.service.toLowerCase());
|
||||
enableMonitoring = serviceNames.some((n) => n.includes('prometheus') || n.includes('grafana'));
|
||||
enableDevTools = serviceNames.some((n) => n.includes('code-server') || n.includes('gitea') || n.includes('n8n'));
|
||||
} catch {
|
||||
// Couldn't inspect services — leave as false
|
||||
}
|
||||
|
||||
// Check deduplication against existing instances
|
||||
const resolvedPath = path.resolve(dirPath);
|
||||
const match = existingInstances.find(
|
||||
(inst) =>
|
||||
path.resolve(inst.basePath) === resolvedPath ||
|
||||
inst.composeProject === composeProject ||
|
||||
inst.domain === domain
|
||||
);
|
||||
|
||||
const isParent = source === 'parent' ||
|
||||
(!!env.CML_SOURCE_PATH && path.resolve(env.CML_SOURCE_PATH) === resolvedPath);
|
||||
|
||||
return {
|
||||
name: envVars.SITE_NAME || domain.split('.')[0] || path.basename(dirPath),
|
||||
slug: slugify(envVars.SITE_NAME || domain.split('.')[0] || path.basename(dirPath)),
|
||||
domain,
|
||||
basePath: resolvedPath,
|
||||
composeProject,
|
||||
portConfig,
|
||||
adminEmail: envVars.INITIAL_ADMIN_EMAIL || 'admin@localhost',
|
||||
...flags,
|
||||
enableMonitoring,
|
||||
enableDevTools,
|
||||
source,
|
||||
isRunning: running > 0,
|
||||
runningContainers: running,
|
||||
totalContainers: total,
|
||||
isAlreadyRegistered: !!match,
|
||||
existingInstanceId: match?.id,
|
||||
isParentInstance: isParent,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Main Discovery Function ────────────────────────────────────────
|
||||
|
||||
export async function discoverInstances(): Promise<DiscoveryResult> {
|
||||
const discovered: DiscoveredInstance[] = [];
|
||||
const seenPaths = new Set<string>();
|
||||
|
||||
// Load existing instances for deduplication
|
||||
const existingInstances = await prisma.instance.findMany({
|
||||
select: { id: true, basePath: true, composeProject: true, domain: true },
|
||||
});
|
||||
|
||||
// List all compose projects upfront
|
||||
const composeProjects = await listComposeProjects();
|
||||
|
||||
// Strategy 1: Parent instance (from CML_SOURCE_PATH)
|
||||
if (env.CML_SOURCE_PATH) {
|
||||
const parentPath = path.resolve(env.CML_SOURCE_PATH);
|
||||
if (await isCmlInstance(parentPath)) {
|
||||
const project = deriveComposeProject(parentPath, composeProjects);
|
||||
const inst = await buildDiscoveredInstance(parentPath, 'parent', project, existingInstances);
|
||||
if (inst) {
|
||||
// Ensure parent gets a sensible name
|
||||
if (inst.isParentInstance && inst.name === path.basename(parentPath)) {
|
||||
inst.name = `Parent (${inst.domain})`;
|
||||
}
|
||||
discovered.push(inst);
|
||||
seenPaths.add(parentPath);
|
||||
}
|
||||
} else {
|
||||
logger.debug(`[discovery] CML_SOURCE_PATH (${env.CML_SOURCE_PATH}) is not a valid CML instance`);
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 2: Docker scan — check all running compose projects
|
||||
for (const project of composeProjects) {
|
||||
// Skip CCP's own project
|
||||
if (project.Name.startsWith('ccp-') || project.Name.startsWith('changemaker-control-panel')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract project directory from config file path
|
||||
if (!project.ConfigFiles) continue;
|
||||
const configFile = project.ConfigFiles.split(',')[0].trim();
|
||||
const projectDir = path.dirname(configFile);
|
||||
const resolvedDir = path.resolve(projectDir);
|
||||
|
||||
// Skip if already found via parent strategy
|
||||
if (seenPaths.has(resolvedDir)) continue;
|
||||
|
||||
// CML fingerprint test
|
||||
if (!(await isCmlInstance(resolvedDir))) continue;
|
||||
|
||||
const inst = await buildDiscoveredInstance(resolvedDir, 'docker', project.Name, existingInstances);
|
||||
if (inst) {
|
||||
discovered.push(inst);
|
||||
seenPaths.add(resolvedDir);
|
||||
}
|
||||
}
|
||||
|
||||
// Build summary
|
||||
const newInstances = discovered.filter((d) => !d.isAlreadyRegistered).length;
|
||||
const alreadyRegistered = discovered.filter((d) => d.isAlreadyRegistered).length;
|
||||
const running = discovered.filter((d) => d.isRunning).length;
|
||||
const parentFound = discovered.some((d) => d.isParentInstance);
|
||||
|
||||
return {
|
||||
instances: discovered,
|
||||
summary: {
|
||||
total: discovered.length,
|
||||
newInstances,
|
||||
alreadyRegistered,
|
||||
running,
|
||||
parentFound,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Auto-Import on First Boot ──────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Checks if the CCP database has zero instances. If empty, discovers
|
||||
* the parent instance and auto-registers it.
|
||||
* Called 5s after server startup via setTimeout.
|
||||
*/
|
||||
export async function autoDiscoverOnStartup(): Promise<void> {
|
||||
const count = await prisma.instance.count();
|
||||
if (count > 0) {
|
||||
logger.debug('[discovery] Instances already exist, skipping auto-discovery');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('[discovery] No instances found — running auto-discovery...');
|
||||
|
||||
const result = await discoverInstances();
|
||||
if (result.instances.length === 0) {
|
||||
logger.info('[discovery] No CML instances discovered on this host');
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto-register parent instance first, then any others
|
||||
const sorted = [...result.instances].sort((a, b) => {
|
||||
if (a.isParentInstance && !b.isParentInstance) return -1;
|
||||
if (!a.isParentInstance && b.isParentInstance) return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
// Get or create a system user ID for audit logging
|
||||
const systemUser = await prisma.ccpUser.findFirst({
|
||||
where: { role: 'SUPER_ADMIN' },
|
||||
select: { id: true },
|
||||
});
|
||||
const userId = systemUser?.id || 'system';
|
||||
|
||||
let imported = 0;
|
||||
for (const inst of sorted) {
|
||||
if (inst.isAlreadyRegistered) continue;
|
||||
try {
|
||||
await registerInstance(
|
||||
{
|
||||
name: inst.name,
|
||||
slug: inst.slug,
|
||||
domain: inst.domain,
|
||||
basePath: inst.basePath,
|
||||
composeProject: inst.composeProject,
|
||||
portConfig: inst.portConfig,
|
||||
adminEmail: inst.adminEmail,
|
||||
enableMedia: inst.enableMedia,
|
||||
enableChat: inst.enableChat,
|
||||
enableGancio: inst.enableGancio,
|
||||
enableListmonk: inst.enableListmonk,
|
||||
enableMonitoring: inst.enableMonitoring,
|
||||
enableDevTools: inst.enableDevTools,
|
||||
enablePayments: inst.enablePayments,
|
||||
},
|
||||
userId,
|
||||
'auto-discovery'
|
||||
);
|
||||
imported++;
|
||||
logger.info(`[discovery] Auto-registered instance: ${inst.name} (${inst.domain})`);
|
||||
} catch (err) {
|
||||
logger.warn(`[discovery] Failed to auto-register ${inst.name}: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`[discovery] Auto-discovery complete: ${imported}/${sorted.filter((s) => !s.isAlreadyRegistered).length} instances registered`);
|
||||
}
|
||||
351
changemaker-control-panel/api/src/services/docker.service.ts
Normal file
351
changemaker-control-panel/api/src/services/docker.service.ts
Normal file
@@ -0,0 +1,351 @@
|
||||
import { exec as execCb } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import http from 'http';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
const exec = promisify(execCb);
|
||||
|
||||
const EXEC_TIMEOUT = 120_000; // 2 minutes
|
||||
const DOCKER_SOCKET = '/var/run/docker.sock';
|
||||
|
||||
/** Validate a service/project name to prevent shell injection. */
|
||||
function validateName(name: string, label: string): string {
|
||||
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]*$/.test(name)) {
|
||||
throw new Error(`Invalid ${label}: ${name}`);
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
/** Validate a Docker duration format (e.g., "1h", "30m", "24h"). */
|
||||
function validateDuration(value: string): string {
|
||||
if (!/^\d+[smhd]$/.test(value)) {
|
||||
throw new Error(`Invalid duration: ${value}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Validate a tail count (positive integer, capped). */
|
||||
function validateTail(value: number): number {
|
||||
const n = Math.max(1, Math.min(value, 5000));
|
||||
return Math.floor(n);
|
||||
}
|
||||
|
||||
/** Parsed container status from `docker compose ps --format json` */
|
||||
export interface ContainerInfo {
|
||||
name: string;
|
||||
service: string;
|
||||
status: string;
|
||||
state: string;
|
||||
health: string;
|
||||
ports: string;
|
||||
createdAt: string;
|
||||
exitCode: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a shell command with timeout and proper error handling.
|
||||
*/
|
||||
async function execCmd(
|
||||
command: string,
|
||||
cwd: string,
|
||||
timeoutMs = EXEC_TIMEOUT
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
logger.debug(`[docker] exec: ${command} (cwd: ${cwd})`);
|
||||
try {
|
||||
const result = await exec(command, {
|
||||
cwd,
|
||||
timeout: timeoutMs,
|
||||
maxBuffer: 10 * 1024 * 1024, // 10MB
|
||||
env: { ...process.env, COMPOSE_ANSI: 'never' },
|
||||
});
|
||||
return result;
|
||||
} catch (err: unknown) {
|
||||
const error = err as { stdout?: string; stderr?: string; message?: string; killed?: boolean };
|
||||
if (error.killed) {
|
||||
throw new Error(`Command timed out after ${timeoutMs}ms: ${command}`);
|
||||
}
|
||||
// Include stderr in the error for debugging
|
||||
const msg = error.stderr || error.message || 'Unknown exec error';
|
||||
throw new Error(`Command failed: ${command}\n${msg}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the base compose command with project name.
|
||||
*/
|
||||
function composeCmd(project: string): string {
|
||||
return `docker compose -p ${validateName(project, 'project')}`;
|
||||
}
|
||||
|
||||
// ─── Docker Compose CLI Operations ───────────────────────────────────
|
||||
|
||||
export async function composeUp(
|
||||
projectDir: string,
|
||||
project: string,
|
||||
services?: string[]
|
||||
): Promise<string> {
|
||||
const svc = services?.length ? ` ${services.map((s) => validateName(s, 'service')).join(' ')}` : '';
|
||||
const orphanFlag = services?.length ? '' : ' --remove-orphans';
|
||||
const { stdout, stderr } = await execCmd(
|
||||
`${composeCmd(project)} up -d${orphanFlag}${svc}`,
|
||||
projectDir
|
||||
);
|
||||
return stdout || stderr;
|
||||
}
|
||||
|
||||
export async function composeDown(
|
||||
projectDir: string,
|
||||
project: string,
|
||||
removeVolumes = false
|
||||
): Promise<string> {
|
||||
const flags = removeVolumes ? ' -v' : '';
|
||||
const { stdout, stderr } = await execCmd(
|
||||
`${composeCmd(project)} down${flags}`,
|
||||
projectDir
|
||||
);
|
||||
return stdout || stderr;
|
||||
}
|
||||
|
||||
export async function composeStop(
|
||||
projectDir: string,
|
||||
project: string
|
||||
): Promise<string> {
|
||||
const { stdout, stderr } = await execCmd(
|
||||
`${composeCmd(project)} stop`,
|
||||
projectDir
|
||||
);
|
||||
return stdout || stderr;
|
||||
}
|
||||
|
||||
export async function composeRestart(
|
||||
projectDir: string,
|
||||
project: string,
|
||||
service?: string
|
||||
): Promise<string> {
|
||||
const svc = service ? ` ${validateName(service, 'service')}` : '';
|
||||
const { stdout, stderr } = await execCmd(
|
||||
`${composeCmd(project)} restart${svc}`,
|
||||
projectDir
|
||||
);
|
||||
return stdout || stderr;
|
||||
}
|
||||
|
||||
export async function composePull(
|
||||
projectDir: string,
|
||||
project: string
|
||||
): Promise<string> {
|
||||
const { stdout, stderr } = await execCmd(
|
||||
`${composeCmd(project)} pull`,
|
||||
projectDir,
|
||||
300_000 // 5 min for pulls
|
||||
);
|
||||
return stdout || stderr;
|
||||
}
|
||||
|
||||
export async function composeBuild(
|
||||
projectDir: string,
|
||||
project: string
|
||||
): Promise<string> {
|
||||
const { stdout, stderr } = await execCmd(
|
||||
`${composeCmd(project)} build`,
|
||||
projectDir,
|
||||
600_000 // 10 min for builds
|
||||
);
|
||||
return stdout || stderr;
|
||||
}
|
||||
|
||||
/**
|
||||
* List containers with status. Returns parsed container info.
|
||||
*/
|
||||
export async function composePs(
|
||||
projectDir: string,
|
||||
project: string
|
||||
): Promise<ContainerInfo[]> {
|
||||
const { stdout } = await execCmd(
|
||||
`${composeCmd(project)} ps --format json`,
|
||||
projectDir
|
||||
);
|
||||
|
||||
if (!stdout.trim()) return [];
|
||||
|
||||
// docker compose ps --format json outputs one JSON object per line
|
||||
const containers: ContainerInfo[] = [];
|
||||
for (const line of stdout.trim().split('\n')) {
|
||||
if (!line.trim()) continue;
|
||||
try {
|
||||
const raw = JSON.parse(line);
|
||||
containers.push({
|
||||
name: raw.Name || raw.name || '',
|
||||
service: raw.Service || raw.service || '',
|
||||
status: raw.Status || raw.status || '',
|
||||
state: raw.State || raw.state || '',
|
||||
health: raw.Health || raw.health || '',
|
||||
ports: raw.Ports || raw.ports || '',
|
||||
createdAt: raw.CreatedAt || raw.created_at || '',
|
||||
exitCode: raw.ExitCode ?? raw.exit_code ?? 0,
|
||||
});
|
||||
} catch {
|
||||
logger.warn(`[docker] Failed to parse container line: ${line}`);
|
||||
}
|
||||
}
|
||||
return containers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get logs from a specific service.
|
||||
*/
|
||||
export async function composeLogs(
|
||||
projectDir: string,
|
||||
project: string,
|
||||
service?: string,
|
||||
tail = 200,
|
||||
since?: string
|
||||
): Promise<string> {
|
||||
const parts = [composeCmd(project), 'logs', '--no-color'];
|
||||
if (tail > 0) parts.push(`--tail=${validateTail(tail)}`);
|
||||
if (since) parts.push(`--since=${validateDuration(since)}`);
|
||||
if (service) parts.push(validateName(service, 'service'));
|
||||
|
||||
const { stdout, stderr } = await execCmd(parts.join(' '), projectDir);
|
||||
return stdout || stderr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a command inside a running service container.
|
||||
* Optionally pass environment variables via -e flags.
|
||||
*/
|
||||
export async function composeExec(
|
||||
projectDir: string,
|
||||
project: string,
|
||||
service: string,
|
||||
command: string,
|
||||
timeoutMs = EXEC_TIMEOUT,
|
||||
envVars?: Record<string, string>
|
||||
): Promise<string> {
|
||||
const envFlags = envVars
|
||||
? Object.entries(envVars).map(([k, v]) => `-e ${k}=${v}`).join(' ') + ' '
|
||||
: '';
|
||||
const { stdout, stderr } = await execCmd(
|
||||
`${composeCmd(project)} exec -T ${envFlags}${validateName(service, 'service')} ${command}`,
|
||||
projectDir,
|
||||
timeoutMs
|
||||
);
|
||||
return stdout || stderr;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a one-off command in a service container (docker compose run).
|
||||
* Uses --entrypoint "" to skip the service's entrypoint script.
|
||||
* Useful for running setup commands (prisma db push, seed) without the entrypoint.
|
||||
*/
|
||||
export async function composeRun(
|
||||
projectDir: string,
|
||||
project: string,
|
||||
service: string,
|
||||
command: string,
|
||||
timeoutMs = EXEC_TIMEOUT
|
||||
): Promise<string> {
|
||||
const { stdout, stderr } = await execCmd(
|
||||
`${composeCmd(project)} run --rm --no-deps -T --entrypoint "" ${validateName(service, 'service')} ${command}`,
|
||||
projectDir,
|
||||
timeoutMs
|
||||
);
|
||||
return stdout || stderr;
|
||||
}
|
||||
|
||||
// ─── Docker Socket API ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Make a request to the Docker Engine API via Unix socket.
|
||||
*/
|
||||
function dockerSocketRequest(path: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = http.request(
|
||||
{
|
||||
socketPath: DOCKER_SOCKET,
|
||||
path,
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
},
|
||||
(res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => (data += chunk));
|
||||
res.on('end', () => {
|
||||
if (res.statusCode && res.statusCode >= 400) {
|
||||
reject(new Error(`Docker API returned ${res.statusCode}: ${data}`));
|
||||
} else {
|
||||
resolve(data);
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
req.on('error', reject);
|
||||
req.setTimeout(10_000, () => {
|
||||
req.destroy();
|
||||
reject(new Error('Docker socket request timed out'));
|
||||
});
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status of a specific container by name via Docker socket API.
|
||||
*/
|
||||
export async function getContainerStatus(
|
||||
containerName: string
|
||||
): Promise<{ state: string; health: string; running: boolean } | null> {
|
||||
try {
|
||||
const data = await dockerSocketRequest(
|
||||
`/containers/${encodeURIComponent(containerName)}/json`
|
||||
);
|
||||
const info = JSON.parse(data);
|
||||
return {
|
||||
state: info.State?.Status || 'unknown',
|
||||
health: info.State?.Health?.Status || 'none',
|
||||
running: info.State?.Running === true,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll until a container reaches healthy state or timeout expires.
|
||||
*/
|
||||
export async function waitForHealthy(
|
||||
containerName: string,
|
||||
timeoutMs = 60_000,
|
||||
pollIntervalMs = 2_000
|
||||
): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
const status = await getContainerStatus(containerName);
|
||||
if (status?.health === 'healthy') return true;
|
||||
if (status?.state === 'exited' || status?.state === 'dead') {
|
||||
throw new Error(`Container ${containerName} exited unexpectedly`);
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, pollIntervalMs));
|
||||
}
|
||||
throw new Error(`Container ${containerName} did not become healthy within ${timeoutMs}ms`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for an HTTP endpoint to respond with 200.
|
||||
*/
|
||||
export async function waitForHttp(
|
||||
url: string,
|
||||
timeoutMs = 120_000,
|
||||
pollIntervalMs = 3_000
|
||||
): Promise<boolean> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(5_000) });
|
||||
if (response.ok) return true;
|
||||
} catch {
|
||||
// Expected while service is starting
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, pollIntervalMs));
|
||||
}
|
||||
throw new Error(`HTTP endpoint ${url} did not respond within ${timeoutMs}ms`);
|
||||
}
|
||||
191
changemaker-control-panel/api/src/services/health.service.ts
Normal file
191
changemaker-control-panel/api/src/services/health.service.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { InstanceStatus, HealthStatus } from '@prisma/client';
|
||||
import { prisma } from '../lib/prisma';
|
||||
import * as docker from './docker.service';
|
||||
import { logger } from '../utils/logger';
|
||||
import type { ContainerInfo } from './docker.service';
|
||||
|
||||
/**
|
||||
* Determine overall health status from container list.
|
||||
*/
|
||||
function determineHealth(containers: ContainerInfo[]): {
|
||||
status: HealthStatus;
|
||||
serviceStatus: Record<string, { state: string; health: string }>;
|
||||
totalServices: number;
|
||||
healthyServices: number;
|
||||
} {
|
||||
if (containers.length === 0) {
|
||||
return { status: HealthStatus.UNKNOWN, serviceStatus: {}, totalServices: 0, healthyServices: 0 };
|
||||
}
|
||||
|
||||
const serviceStatus: Record<string, { state: string; health: string }> = {};
|
||||
let healthyCount = 0;
|
||||
let runningCount = 0;
|
||||
|
||||
for (const c of containers) {
|
||||
serviceStatus[c.service || c.name] = {
|
||||
state: c.state,
|
||||
health: c.health,
|
||||
};
|
||||
|
||||
const isRunning = c.state === 'running';
|
||||
if (isRunning) runningCount++;
|
||||
|
||||
// A service is "healthy" if it's running AND either has no health check or passes it
|
||||
const isHealthy = isRunning && (c.health === '' || c.health === 'healthy');
|
||||
if (isHealthy) healthyCount++;
|
||||
}
|
||||
|
||||
const total = containers.length;
|
||||
let status: HealthStatus;
|
||||
|
||||
if (healthyCount === total) {
|
||||
status = HealthStatus.HEALTHY;
|
||||
} else if (runningCount === 0) {
|
||||
status = HealthStatus.UNHEALTHY;
|
||||
} else if (healthyCount >= total / 2) {
|
||||
status = HealthStatus.DEGRADED;
|
||||
} else {
|
||||
status = HealthStatus.UNHEALTHY;
|
||||
}
|
||||
|
||||
return { status, serviceStatus, totalServices: total, healthyServices: healthyCount };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check the health of a single instance. Returns the created HealthCheck record.
|
||||
*/
|
||||
export async function checkInstanceHealth(instanceId: string) {
|
||||
const instance = await prisma.instance.findUnique({ where: { id: instanceId } });
|
||||
if (!instance) {
|
||||
throw new Error(`Instance ${instanceId} not found`);
|
||||
}
|
||||
|
||||
if (instance.status !== InstanceStatus.RUNNING) {
|
||||
throw new Error(`Instance ${instance.slug} is not running (status: ${instance.status})`);
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
let containers: ContainerInfo[];
|
||||
|
||||
try {
|
||||
containers = await docker.composePs(instance.basePath, instance.composeProject);
|
||||
} catch (err) {
|
||||
// If compose ps fails, record UNKNOWN status
|
||||
const healthCheck = await prisma.healthCheck.create({
|
||||
data: {
|
||||
instanceId,
|
||||
status: HealthStatus.UNKNOWN,
|
||||
serviceStatus: {},
|
||||
totalServices: 0,
|
||||
healthyServices: 0,
|
||||
responseTimeMs: Date.now() - startTime,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.instance.update({
|
||||
where: { id: instanceId },
|
||||
data: { lastHealthCheck: new Date() },
|
||||
});
|
||||
|
||||
logger.warn(`[health] ${instance.slug}: compose ps failed: ${(err as Error).message}`);
|
||||
return healthCheck;
|
||||
}
|
||||
|
||||
const responseTimeMs = Date.now() - startTime;
|
||||
const { status, serviceStatus, totalServices, healthyServices } = determineHealth(containers);
|
||||
|
||||
const healthCheck = await prisma.healthCheck.create({
|
||||
data: {
|
||||
instanceId,
|
||||
status,
|
||||
serviceStatus,
|
||||
totalServices,
|
||||
healthyServices,
|
||||
responseTimeMs,
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.instance.update({
|
||||
where: { id: instanceId },
|
||||
data: { lastHealthCheck: new Date() },
|
||||
});
|
||||
|
||||
return healthCheck;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check all running instances sequentially.
|
||||
*/
|
||||
export async function checkAllInstances(): Promise<void> {
|
||||
const instances = await prisma.instance.findMany({
|
||||
where: { status: InstanceStatus.RUNNING },
|
||||
select: { id: true, slug: true },
|
||||
});
|
||||
|
||||
if (instances.length === 0) {
|
||||
logger.debug('[health] No running instances to check');
|
||||
return;
|
||||
}
|
||||
|
||||
let healthy = 0;
|
||||
let degraded = 0;
|
||||
let unhealthy = 0;
|
||||
|
||||
for (const inst of instances) {
|
||||
try {
|
||||
const check = await checkInstanceHealth(inst.id);
|
||||
if (check.status === HealthStatus.HEALTHY) healthy++;
|
||||
else if (check.status === HealthStatus.DEGRADED) degraded++;
|
||||
else unhealthy++;
|
||||
} catch (err) {
|
||||
logger.warn(`[health] Failed to check ${inst.slug}: ${(err as Error).message}`);
|
||||
unhealthy++;
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[health] Checked ${instances.length} instances: ${healthy} healthy, ${degraded} degraded, ${unhealthy} unhealthy`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get paginated health history for an instance.
|
||||
*/
|
||||
export async function getHealthHistory(instanceId: string, page = 1, limit = 20) {
|
||||
const [data, total] = await Promise.all([
|
||||
prisma.healthCheck.findMany({
|
||||
where: { instanceId },
|
||||
orderBy: { checkedAt: 'desc' },
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
}),
|
||||
prisma.healthCheck.count({ where: { instanceId } }),
|
||||
]);
|
||||
|
||||
return { data, total, page, limit };
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the periodic health check scheduler.
|
||||
*/
|
||||
export function startHealthScheduler(intervalMs: number): NodeJS.Timeout | null {
|
||||
if (intervalMs <= 0) {
|
||||
logger.info('[health] Automated health checks disabled (interval=0)');
|
||||
return null;
|
||||
}
|
||||
|
||||
logger.info(`[health] Starting health scheduler (interval: ${intervalMs}ms)`);
|
||||
|
||||
// Run initial check after a short delay (let services start)
|
||||
setTimeout(() => {
|
||||
checkAllInstances().catch((err) =>
|
||||
logger.error(`[health] Initial check failed: ${(err as Error).message}`)
|
||||
);
|
||||
}, 10_000);
|
||||
|
||||
return setInterval(() => {
|
||||
checkAllInstances().catch((err) =>
|
||||
logger.error(`[health] Scheduled check failed: ${(err as Error).message}`)
|
||||
);
|
||||
}, intervalMs);
|
||||
}
|
||||
94
changemaker-control-panel/api/src/services/port-allocator.ts
Normal file
94
changemaker-control-panel/api/src/services/port-allocator.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { prisma } from '../lib/prisma';
|
||||
import { env } from '../config/env';
|
||||
import { AppError } from '../middleware/error-handler';
|
||||
|
||||
interface PortRangeConfig {
|
||||
service: string;
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
function getPortRanges(): PortRangeConfig[] {
|
||||
return [
|
||||
{ service: 'api', start: env.PORT_RANGE_API_START, end: env.PORT_RANGE_API_END },
|
||||
{ service: 'admin', start: env.PORT_RANGE_ADMIN_START, end: env.PORT_RANGE_ADMIN_END },
|
||||
{ service: 'postgres', start: env.PORT_RANGE_POSTGRES_START, end: env.PORT_RANGE_POSTGRES_END },
|
||||
{ service: 'nginx', start: env.PORT_RANGE_NGINX_START, end: env.PORT_RANGE_NGINX_END },
|
||||
{ service: 'embed', start: env.PORT_RANGE_EMBED_START, end: env.PORT_RANGE_EMBED_END },
|
||||
];
|
||||
}
|
||||
|
||||
async function findNextAvailablePort(
|
||||
start: number,
|
||||
end: number,
|
||||
tx?: Parameters<Parameters<typeof prisma.$transaction>[0]>[0]
|
||||
): Promise<number> {
|
||||
const client = tx || prisma;
|
||||
const allocated = await client.portAllocation.findMany({
|
||||
where: { port: { gte: start, lte: end } },
|
||||
select: { port: true },
|
||||
orderBy: { port: 'asc' },
|
||||
});
|
||||
|
||||
const usedPorts = new Set(allocated.map((a) => a.port));
|
||||
|
||||
for (let port = start; port <= end; port++) {
|
||||
if (!usedPorts.has(port)) {
|
||||
return port;
|
||||
}
|
||||
}
|
||||
|
||||
throw new AppError(503, `No available ports in range ${start}-${end}`, 'PORT_EXHAUSTED');
|
||||
}
|
||||
|
||||
export interface AllocatedPorts {
|
||||
config: Record<string, number>;
|
||||
allocations: Array<{ port: number; service: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allocate ports using a serializable transaction to prevent race conditions.
|
||||
* Port allocation records are created immediately to act as a DB-level lock.
|
||||
* They are linked to the instance later via instances.service.ts.
|
||||
*/
|
||||
export async function allocatePorts(): Promise<AllocatedPorts> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const ranges = getPortRanges();
|
||||
const config: Record<string, number> = {};
|
||||
const allocations: Array<{ port: number; service: string }> = [];
|
||||
|
||||
for (const range of ranges) {
|
||||
const port = await findNextAvailablePort(range.start, range.end, tx);
|
||||
config[range.service] = port;
|
||||
allocations.push({ port, service: range.service });
|
||||
}
|
||||
|
||||
return { config, allocations };
|
||||
});
|
||||
}
|
||||
|
||||
export async function releasePorts(instanceId: string): Promise<void> {
|
||||
await prisma.portAllocation.deleteMany({ where: { instanceId } });
|
||||
}
|
||||
|
||||
export async function getPortUsage(): Promise<{
|
||||
ranges: Array<{ service: string; start: number; end: number; used: number; total: number }>;
|
||||
}> {
|
||||
const ranges = getPortRanges();
|
||||
const result = [];
|
||||
|
||||
for (const range of ranges) {
|
||||
const used = await prisma.portAllocation.count({
|
||||
where: { port: { gte: range.start, lte: range.end } },
|
||||
});
|
||||
result.push({
|
||||
service: range.service,
|
||||
start: range.start,
|
||||
end: range.end,
|
||||
used,
|
||||
total: range.end - range.start + 1,
|
||||
});
|
||||
}
|
||||
|
||||
return { ranges: result };
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
function randomHex(bytes = 32): string {
|
||||
return crypto.randomBytes(bytes).toString('hex');
|
||||
}
|
||||
|
||||
function randomPassword(length = 16): string {
|
||||
// Generate password meeting CML policy: 12+ chars, uppercase, lowercase, digit
|
||||
const upper = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
const lower = 'abcdefghijklmnopqrstuvwxyz';
|
||||
const digits = '0123456789';
|
||||
// Avoid $, #, !, % — they break Docker Compose .env files
|
||||
// ($=var expansion, #=comment, !=bash history, %=printf)
|
||||
const special = '@&*_-+=';
|
||||
const all = upper + lower + digits + special;
|
||||
|
||||
// Ensure at least one of each required class
|
||||
const required = [
|
||||
upper[crypto.randomInt(upper.length)],
|
||||
lower[crypto.randomInt(lower.length)],
|
||||
digits[crypto.randomInt(digits.length)],
|
||||
special[crypto.randomInt(special.length)],
|
||||
];
|
||||
|
||||
// Fill remaining with random chars
|
||||
const remaining = Array.from({ length: length - required.length }, () =>
|
||||
all[crypto.randomInt(all.length)]
|
||||
);
|
||||
|
||||
// Shuffle
|
||||
const chars = [...required, ...remaining];
|
||||
for (let i = chars.length - 1; i > 0; i--) {
|
||||
const j = crypto.randomInt(i + 1);
|
||||
[chars[i], chars[j]] = [chars[j], chars[i]];
|
||||
}
|
||||
|
||||
return chars.join('');
|
||||
}
|
||||
|
||||
export interface InstanceSecrets {
|
||||
postgresPassword: string;
|
||||
redisPassword: string;
|
||||
jwtAccessSecret: string;
|
||||
jwtRefreshSecret: string;
|
||||
encryptionKey: string;
|
||||
initialAdminPassword: string;
|
||||
nocodbAdminPassword: string;
|
||||
grafanaAdminPassword: string;
|
||||
listmonkAdminPassword: string;
|
||||
listmonkApiToken: string;
|
||||
giteaAdminPassword: string;
|
||||
n8nEncryptionKey: string;
|
||||
gancioAdminPassword: string;
|
||||
}
|
||||
|
||||
export function generateSecrets(adminEmail: string): InstanceSecrets & { adminEmail: string } {
|
||||
return {
|
||||
adminEmail,
|
||||
postgresPassword: randomHex(16),
|
||||
redisPassword: randomHex(16),
|
||||
jwtAccessSecret: randomHex(32),
|
||||
jwtRefreshSecret: randomHex(32),
|
||||
encryptionKey: randomHex(32),
|
||||
initialAdminPassword: randomPassword(16),
|
||||
nocodbAdminPassword: randomPassword(16),
|
||||
grafanaAdminPassword: randomPassword(16),
|
||||
listmonkAdminPassword: randomPassword(16),
|
||||
listmonkApiToken: randomHex(16),
|
||||
giteaAdminPassword: randomPassword(16),
|
||||
n8nEncryptionKey: randomHex(32),
|
||||
gancioAdminPassword: randomPassword(16),
|
||||
};
|
||||
}
|
||||
227
changemaker-control-panel/api/src/services/template-engine.ts
Normal file
227
changemaker-control-panel/api/src/services/template-engine.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import Handlebars from 'handlebars';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
// Register helpers
|
||||
Handlebars.registerHelper('ifEq', function (this: unknown, a: unknown, b: unknown, options: Handlebars.HelperOptions) {
|
||||
return a === b ? options.fn(this) : options.inverse(this);
|
||||
});
|
||||
|
||||
Handlebars.registerHelper('unless', function (this: unknown, condition: unknown, options: Handlebars.HelperOptions) {
|
||||
return !condition ? options.fn(this) : options.inverse(this);
|
||||
});
|
||||
|
||||
Handlebars.registerHelper('now', function () {
|
||||
return new Date().toISOString();
|
||||
});
|
||||
|
||||
Handlebars.registerHelper('math', function (a: number, op: string, b: number) {
|
||||
switch (op) {
|
||||
case '+': return a + b;
|
||||
case '-': return a - b;
|
||||
default: return a;
|
||||
}
|
||||
});
|
||||
|
||||
export interface TemplateContext {
|
||||
// Instance identity
|
||||
slug: string;
|
||||
name: string;
|
||||
domain: string;
|
||||
containerPrefix: string; // e.g., "cml-better-edmonton"
|
||||
networkName: string; // e.g., "cml-better-edmonton"
|
||||
composeProject: string; // e.g., "cml-better-edmonton"
|
||||
|
||||
// Ports
|
||||
ports: {
|
||||
api: number;
|
||||
admin: number;
|
||||
postgres: number;
|
||||
nginx: number;
|
||||
embed: number;
|
||||
};
|
||||
|
||||
// Secrets (decrypted)
|
||||
secrets: {
|
||||
postgresPassword: string;
|
||||
redisPassword: string;
|
||||
jwtAccessSecret: string;
|
||||
jwtRefreshSecret: string;
|
||||
encryptionKey: string;
|
||||
initialAdminPassword: string;
|
||||
adminEmail: string;
|
||||
nocodbAdminPassword: string;
|
||||
grafanaAdminPassword: string;
|
||||
listmonkAdminPassword: string;
|
||||
listmonkApiToken: string;
|
||||
giteaAdminPassword: string;
|
||||
n8nEncryptionKey: string;
|
||||
gancioAdminPassword: string;
|
||||
};
|
||||
|
||||
// Feature flags
|
||||
enableMedia: boolean;
|
||||
enableChat: boolean;
|
||||
enableGancio: boolean;
|
||||
enableListmonk: boolean;
|
||||
enableMonitoring: boolean;
|
||||
enableDevTools: boolean;
|
||||
enablePayments: boolean;
|
||||
|
||||
// SMTP
|
||||
smtpHost: string;
|
||||
smtpPort: number;
|
||||
smtpUser: string;
|
||||
smtpFrom: string;
|
||||
emailTestMode: boolean;
|
||||
|
||||
// Git
|
||||
gitBranch: string;
|
||||
}
|
||||
|
||||
/** Subset of Instance fields needed to build a TemplateContext. */
|
||||
export interface InstanceForTemplate {
|
||||
slug: string;
|
||||
name: string;
|
||||
domain: string;
|
||||
composeProject: string;
|
||||
portConfig: unknown;
|
||||
enableMedia: boolean;
|
||||
enableChat: boolean;
|
||||
enableGancio: boolean;
|
||||
enableListmonk: boolean;
|
||||
enableMonitoring: boolean;
|
||||
enableDevTools: boolean;
|
||||
enablePayments: boolean;
|
||||
smtpHost: string | null;
|
||||
smtpPort: number | null;
|
||||
smtpUser: string | null;
|
||||
smtpFrom: string | null;
|
||||
emailTestMode: boolean;
|
||||
gitBranch: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the TemplateContext from an instance record and its decrypted secrets.
|
||||
*/
|
||||
export function buildTemplateContext(
|
||||
instance: InstanceForTemplate,
|
||||
secrets: Record<string, string>
|
||||
): TemplateContext {
|
||||
const ports = instance.portConfig as Record<string, number>;
|
||||
return {
|
||||
slug: instance.slug,
|
||||
name: instance.name,
|
||||
domain: instance.domain,
|
||||
containerPrefix: instance.composeProject,
|
||||
networkName: instance.composeProject,
|
||||
composeProject: instance.composeProject,
|
||||
ports: {
|
||||
api: ports.api,
|
||||
admin: ports.admin,
|
||||
postgres: ports.postgres,
|
||||
nginx: ports.nginx,
|
||||
embed: ports.embed,
|
||||
},
|
||||
secrets: {
|
||||
postgresPassword: secrets.postgresPassword,
|
||||
redisPassword: secrets.redisPassword,
|
||||
jwtAccessSecret: secrets.jwtAccessSecret,
|
||||
jwtRefreshSecret: secrets.jwtRefreshSecret,
|
||||
encryptionKey: secrets.encryptionKey,
|
||||
initialAdminPassword: secrets.initialAdminPassword,
|
||||
adminEmail: secrets.adminEmail,
|
||||
nocodbAdminPassword: secrets.nocodbAdminPassword,
|
||||
grafanaAdminPassword: secrets.grafanaAdminPassword,
|
||||
listmonkAdminPassword: secrets.listmonkAdminPassword,
|
||||
listmonkApiToken: secrets.listmonkApiToken,
|
||||
giteaAdminPassword: secrets.giteaAdminPassword,
|
||||
n8nEncryptionKey: secrets.n8nEncryptionKey,
|
||||
gancioAdminPassword: secrets.gancioAdminPassword,
|
||||
},
|
||||
enableMedia: instance.enableMedia,
|
||||
enableChat: instance.enableChat,
|
||||
enableGancio: instance.enableGancio,
|
||||
enableListmonk: instance.enableListmonk,
|
||||
enableMonitoring: instance.enableMonitoring,
|
||||
enableDevTools: instance.enableDevTools,
|
||||
enablePayments: instance.enablePayments,
|
||||
smtpHost: instance.smtpHost || '',
|
||||
smtpPort: instance.smtpPort || 587,
|
||||
smtpUser: instance.smtpUser || '',
|
||||
smtpFrom: instance.smtpFrom || '',
|
||||
emailTestMode: instance.emailTestMode,
|
||||
gitBranch: instance.gitBranch,
|
||||
};
|
||||
}
|
||||
|
||||
const templateCache = new Map<string, HandlebarsTemplateDelegate>();
|
||||
|
||||
async function loadTemplate(templatePath: string): Promise<HandlebarsTemplateDelegate> {
|
||||
if (templateCache.has(templatePath)) {
|
||||
return templateCache.get(templatePath)!;
|
||||
}
|
||||
|
||||
const source = await fs.readFile(templatePath, 'utf-8');
|
||||
const compiled = Handlebars.compile(source, { noEscape: true });
|
||||
templateCache.set(templatePath, compiled);
|
||||
return compiled;
|
||||
}
|
||||
|
||||
export async function renderTemplate(templateName: string, context: TemplateContext): Promise<string> {
|
||||
const templatesDir = path.resolve(__dirname, '../..', 'templates');
|
||||
const templatePath = path.join(templatesDir, templateName);
|
||||
const template = await loadTemplate(templatePath);
|
||||
return template(context);
|
||||
}
|
||||
|
||||
export async function renderAllTemplates(context: TemplateContext, outputDir: string): Promise<void> {
|
||||
const templatesDir = path.resolve(__dirname, '../..', 'templates');
|
||||
|
||||
const templateFiles = [
|
||||
{ template: 'docker-compose.yml.hbs', output: 'docker-compose.yml' },
|
||||
{ template: 'env.hbs', output: '.env' },
|
||||
{ template: 'nginx/conf.d/default.conf.hbs', output: 'nginx/conf.d/default.conf' },
|
||||
{ template: 'nginx/conf.d/api.conf.hbs', output: 'nginx/conf.d/api.conf' },
|
||||
{ template: 'nginx/conf.d/services.conf.hbs', output: 'nginx/conf.d/services.conf' },
|
||||
{ template: 'configs/pangolin/resources.yml.hbs', output: 'configs/pangolin/resources.yml' },
|
||||
{ template: 'configs/prometheus/prometheus.yml.hbs', output: 'configs/prometheus/prometheus.yml' },
|
||||
];
|
||||
|
||||
for (const { template, output } of templateFiles) {
|
||||
const templatePath = path.join(templatesDir, template);
|
||||
try {
|
||||
await fs.access(templatePath);
|
||||
} catch {
|
||||
logger.warn(`Template not found: ${template}, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const rendered = await renderTemplate(template, context);
|
||||
const outputPath = path.join(outputDir, output);
|
||||
|
||||
// Ensure output directory exists
|
||||
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
||||
await fs.writeFile(outputPath, rendered, 'utf-8');
|
||||
logger.debug(`Rendered ${template} → ${outputPath}`);
|
||||
}
|
||||
|
||||
// Copy static files (nginx.conf doesn't need templating)
|
||||
const staticFiles = ['nginx/nginx.conf'];
|
||||
for (const file of staticFiles) {
|
||||
const srcPath = path.join(templatesDir, file);
|
||||
try {
|
||||
await fs.access(srcPath);
|
||||
const destPath = path.join(outputDir, file);
|
||||
await fs.mkdir(path.dirname(destPath), { recursive: true });
|
||||
await fs.copyFile(srcPath, destPath);
|
||||
} catch {
|
||||
logger.warn(`Static file not found: ${file}, skipping`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function clearTemplateCache(): void {
|
||||
templateCache.clear();
|
||||
}
|
||||
37
changemaker-control-panel/api/src/utils/encryption.ts
Normal file
37
changemaker-control-panel/api/src/utils/encryption.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import crypto from 'crypto';
|
||||
import { env } from '../config/env';
|
||||
|
||||
const ALGORITHM = 'aes-256-gcm';
|
||||
const IV_LENGTH = 16;
|
||||
const TAG_LENGTH = 16;
|
||||
|
||||
function getKey(): Buffer {
|
||||
return Buffer.from(env.ENCRYPTION_KEY, 'hex').subarray(0, 32);
|
||||
}
|
||||
|
||||
export function encrypt(plaintext: string): string {
|
||||
const iv = crypto.randomBytes(IV_LENGTH);
|
||||
const cipher = crypto.createCipheriv(ALGORITHM, getKey(), iv);
|
||||
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
|
||||
const tag = cipher.getAuthTag();
|
||||
// Format: iv:tag:ciphertext (all base64)
|
||||
return [iv.toString('base64'), tag.toString('base64'), encrypted.toString('base64')].join(':');
|
||||
}
|
||||
|
||||
export function decrypt(encryptedText: string): string {
|
||||
const [ivB64, tagB64, ciphertextB64] = encryptedText.split(':');
|
||||
const iv = Buffer.from(ivB64, 'base64');
|
||||
const tag = Buffer.from(tagB64, 'base64');
|
||||
const ciphertext = Buffer.from(ciphertextB64, 'base64');
|
||||
const decipher = crypto.createDecipheriv(ALGORITHM, getKey(), iv);
|
||||
decipher.setAuthTag(tag);
|
||||
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8');
|
||||
}
|
||||
|
||||
export function encryptJson(data: Record<string, unknown>): string {
|
||||
return encrypt(JSON.stringify(data));
|
||||
}
|
||||
|
||||
export function decryptJson<T = Record<string, unknown>>(encryptedText: string): T {
|
||||
return JSON.parse(decrypt(encryptedText)) as T;
|
||||
}
|
||||
13
changemaker-control-panel/api/src/utils/logger.ts
Normal file
13
changemaker-control-panel/api/src/utils/logger.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import winston from 'winston';
|
||||
|
||||
export const logger = winston.createLogger({
|
||||
level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp(),
|
||||
winston.format.errors({ stack: true }),
|
||||
process.env.NODE_ENV === 'production'
|
||||
? winston.format.json()
|
||||
: winston.format.combine(winston.format.colorize(), winston.format.simple())
|
||||
),
|
||||
transports: [new winston.transports.Console()],
|
||||
});
|
||||
24
changemaker-control-panel/api/tsconfig.json
Normal file
24
changemaker-control-panel/api/tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"],
|
||||
"@prisma/*": ["prisma/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user