Files
ai-stack-deployer/src/orchestrator/production-deployer.ts
Oussama Douhou 2f306f7d68 feat: production-ready deployment with multi-language UI
- Add multi-language support (NL, AR, EN) with RTL
- Improve health checks (SSL-tolerant, multi-endpoint)
- Add DELETE /api/stack/:name for cleanup
- Add persistent storage (portal-ai-workspace-{name})
- Improve rollback (delete domain, app, project)
- Increase SSE timeout to 255s
- Add deployment strategy documentation
2026-01-10 09:56:33 +01:00

444 lines
13 KiB
TypeScript

/**
* Production-Grade Deployment Orchestrator
*
* Features:
* - Complete deployment lifecycle management
* - Idempotency checks at every stage
* - Automatic rollback on failure
* - Health verification
* - Comprehensive logging
* - State tracking
*/
import { DokployProductionClient } from '../api/dokploy-production.js';
export interface DeploymentConfig {
stackName: string;
dockerImage: string;
domainSuffix: string;
port?: number;
healthCheckTimeout?: number;
healthCheckInterval?: number;
}
export interface DeploymentState {
id: string;
stackName: string;
phase:
| 'initializing'
| 'creating_project'
| 'getting_environment'
| 'creating_application'
| 'configuring_application'
| 'creating_domain'
| 'deploying'
| 'verifying_health'
| 'completed'
| 'failed'
| 'rolling_back';
status: 'success' | 'failure' | 'in_progress';
progress: number;
message: string;
url?: string;
error?: {
phase: string;
message: string;
code?: string;
};
resources: {
projectId?: string;
environmentId?: string;
applicationId?: string;
domainId?: string;
};
timestamps: {
started: string;
completed?: string;
};
}
export interface DeploymentResult {
success: boolean;
state: DeploymentState;
logs: string[];
}
export type ProgressCallback = (state: DeploymentState) => void;
export class ProductionDeployer {
private client: DokployProductionClient;
private progressCallback?: ProgressCallback;
constructor(client: DokployProductionClient, progressCallback?: ProgressCallback) {
this.client = client;
this.progressCallback = progressCallback;
}
private notifyProgress(state: DeploymentState): void {
if (this.progressCallback) {
this.progressCallback({ ...state });
}
}
/**
* Deploy a complete AI stack with full production safeguards
*/
async deploy(config: DeploymentConfig): Promise<DeploymentResult> {
const state: DeploymentState = {
id: `dep_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`,
stackName: config.stackName,
phase: 'initializing',
status: 'in_progress',
progress: 0,
message: 'Initializing deployment',
resources: {},
timestamps: {
started: new Date().toISOString(),
},
};
this.notifyProgress(state);
try {
// Phase 1: Project Creation (Idempotent)
await this.createOrFindProject(state, config);
this.notifyProgress(state);
// Phase 2: Get Environment ID
await this.getEnvironment(state);
this.notifyProgress(state);
// Phase 3: Application Creation (Idempotent)
await this.createOrFindApplication(state, config);
this.notifyProgress(state);
// Phase 4: Configure Application
await this.configureApplication(state, config);
this.notifyProgress(state);
// Phase 5: Domain Creation (Idempotent)
await this.createOrFindDomain(state, config);
this.notifyProgress(state);
// Phase 6: Deploy Application
await this.deployApplication(state);
this.notifyProgress(state);
// Phase 7: Health Verification
await this.verifyHealth(state, config);
this.notifyProgress(state);
// Success
state.phase = 'completed';
state.status = 'success';
state.progress = 100;
state.message = 'Deployment completed successfully';
state.timestamps.completed = new Date().toISOString();
this.notifyProgress(state);
return {
success: true,
state,
logs: this.client.getLogs().map(l => JSON.stringify(l)),
};
} catch (error) {
// Failure - Initiate Rollback
state.status = 'failure';
state.error = {
phase: state.phase,
message: error instanceof Error ? error.message : String(error),
code: 'DEPLOYMENT_FAILED',
};
this.notifyProgress(state);
console.error(`Deployment failed at phase ${state.phase}:`, error);
// Attempt rollback
await this.rollback(state);
this.notifyProgress(state);
state.timestamps.completed = new Date().toISOString();
this.notifyProgress(state);
return {
success: false,
state,
logs: this.client.getLogs().map(l => JSON.stringify(l)),
};
}
}
private async createOrFindProject(
state: DeploymentState,
config: DeploymentConfig
): Promise<void> {
state.phase = 'creating_project';
state.progress = 10;
state.message = 'Creating or finding project';
const projectName = `ai-stack-${config.stackName}`;
// Idempotency: Check if project already exists
const existingProject = await this.client.findProjectByName(projectName);
if (existingProject) {
console.log(`Project ${projectName} already exists, reusing...`);
state.resources.projectId = existingProject.project.projectId;
// Also capture environment ID if available
if (existingProject.environmentId) {
state.resources.environmentId = existingProject.environmentId;
}
state.message = 'Found existing project';
return;
}
// Create new project (returns both project and environment)
const response = await this.client.createProject(
projectName,
`AI Stack for ${config.stackName}`
);
console.log('Project and environment created:', JSON.stringify(response, null, 2));
if (!response.project.projectId) {
throw new Error(`Project creation succeeded but projectId is missing. Response: ${JSON.stringify(response)}`);
}
state.resources.projectId = response.project.projectId;
state.resources.environmentId = response.environment.environmentId;
state.message = 'Project and environment created';
}
private async getEnvironment(state: DeploymentState): Promise<void> {
state.phase = 'getting_environment';
state.progress = 25;
state.message = 'Getting environment ID';
// Skip if we already have environment ID from project creation
if (state.resources.environmentId) {
console.log('Environment ID already available from project creation');
state.message = 'Environment ID already available';
return;
}
if (!state.resources.projectId) {
throw new Error('Project ID not available');
}
const environment = await this.client.getDefaultEnvironment(state.resources.projectId);
state.resources.environmentId = environment.environmentId;
state.message = 'Environment ID retrieved';
}
private async createOrFindApplication(
state: DeploymentState,
config: DeploymentConfig
): Promise<void> {
state.phase = 'creating_application';
state.progress = 40;
state.message = 'Creating application';
if (!state.resources.environmentId) {
throw new Error('Environment ID not available');
}
const appName = `opencode-${config.stackName}`;
// Note: Idempotency for applications requires listing all applications
// in the environment and checking by name. For now, we rely on API
// to handle duplicates or we can add this later.
const application = await this.client.createApplication(
appName,
state.resources.environmentId
);
state.resources.applicationId = application.applicationId;
state.message = 'Application created';
}
private async configureApplication(
state: DeploymentState,
config: DeploymentConfig
): Promise<void> {
state.phase = 'configuring_application';
state.progress = 50;
state.message = 'Configuring application with Docker image';
if (!state.resources.applicationId) {
throw new Error('Application ID not available');
}
await this.client.updateApplication(state.resources.applicationId, {
dockerImage: config.dockerImage,
sourceType: 'docker',
});
state.progress = 55;
state.message = 'Creating persistent storage';
const volumeName = `portal-ai-workspace-${config.stackName}`;
try {
await this.client.createMount(
state.resources.applicationId,
volumeName,
'/workspace'
);
console.log(`Created persistent volume: ${volumeName}`);
} catch (error) {
console.warn(`Volume creation failed (may already exist): ${error}`);
}
state.message = 'Application configured with storage';
}
private async createOrFindDomain(
state: DeploymentState,
config: DeploymentConfig
): Promise<void> {
state.phase = 'creating_domain';
state.progress = 70;
state.message = 'Creating domain';
if (!state.resources.applicationId) {
throw new Error('Application ID not available');
}
const host = `${config.stackName}.${config.domainSuffix}`;
const port = config.port || 8080;
// Note: Idempotency for domains would require listing existing domains
// for the application. For now, API should handle duplicates.
const domain = await this.client.createDomain(
host,
state.resources.applicationId,
true,
port
);
state.resources.domainId = domain.domainId;
state.url = `https://${host}`;
state.message = 'Domain created';
}
private async deployApplication(state: DeploymentState): Promise<void> {
state.phase = 'deploying';
state.progress = 85;
state.message = 'Triggering deployment';
if (!state.resources.applicationId) {
throw new Error('Application ID not available');
}
await this.client.deployApplication(state.resources.applicationId);
state.message = 'Deployment triggered';
}
private async verifyHealth(
state: DeploymentState,
config: DeploymentConfig
): Promise<void> {
state.phase = 'verifying_health';
state.progress = 95;
state.message = 'Verifying application health';
if (!state.url) {
throw new Error('Application URL not available');
}
const timeout = config.healthCheckTimeout || 120000; // 2 minutes
const interval = config.healthCheckInterval || 5000; // 5 seconds
const startTime = Date.now();
// Try multiple endpoints - the container may not have /health
const endpoints = ['/', '/health', '/api'];
while (Date.now() - startTime < timeout) {
for (const endpoint of endpoints) {
try {
const checkUrl = `${state.url}${endpoint}`;
const response = await fetch(checkUrl, {
method: 'GET',
signal: AbortSignal.timeout(5000),
tls: { rejectUnauthorized: false },
});
// Accept ANY HTTP response (even 404) as "server is alive"
// Only connection errors mean the container isn't ready
console.log(`Health check ${checkUrl} returned ${response.status}`);
state.message = 'Application is responding';
return;
} catch (error) {
const errorMsg = error instanceof Error ? error.message : String(error);
// SSL cert errors mean server IS responding, just cert issue during provisioning
if (errorMsg.includes('certificate') || errorMsg.includes('SSL') || errorMsg.includes('TLS')) {
console.log(`Health check SSL error (treating as alive): ${errorMsg}`);
state.message = 'Application is responding (SSL provisioning)';
return;
}
console.log(`Health check failed: ${errorMsg}`);
}
}
const elapsed = Math.round((Date.now() - startTime) / 1000);
state.message = `Waiting for application to start (${elapsed}s)...`;
this.notifyProgress(state);
await this.sleep(interval);
}
throw new Error('Health check timeout - application did not become healthy');
}
private async rollback(state: DeploymentState): Promise<void> {
console.log('Initiating rollback...');
state.phase = 'rolling_back';
state.message = 'Rolling back deployment';
try {
if (state.resources.domainId) {
console.log(`Rolling back: deleting domain ${state.resources.domainId}`);
try {
await this.client.deleteDomain(state.resources.domainId);
} catch (error) {
console.error('Failed to delete domain during rollback:', error);
}
}
if (state.resources.applicationId) {
console.log(`Rolling back: deleting application ${state.resources.applicationId}`);
try {
await this.client.deleteApplication(state.resources.applicationId);
} catch (error) {
console.error('Failed to delete application during rollback:', error);
}
}
if (state.resources.projectId) {
console.log(`Rolling back: deleting project ${state.resources.projectId}`);
try {
await this.client.deleteProject(state.resources.projectId);
} catch (error) {
console.error('Failed to delete project during rollback:', error);
}
}
state.message = 'Rollback completed';
} catch (error) {
console.error('Rollback failed:', error);
state.message = 'Rollback failed - manual cleanup required';
}
}
private sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
}