Files
ai-stack-deployer/src/index.ts
Oussama Douhou 86fe7a8bf1
Some checks failed
Build and Push Docker Image (Production) / build-and-push-main (push) Failing after 3m9s
Build and Push Docker Image (Staging) / build-and-push-staging (push) Failing after 30s
feat: Add multilingual deployment progress messages
- Created backend i18n system with EN/NL/AR translations
- Frontend now sends language preference with deployment request
- Backend deployment messages follow user's selected language
- Translated key messages: initializing, creating app, SSL waiting, etc.
- Added top margin (100px) on mobile to prevent language button overlap

Fixes real-time deployment status showing English regardless of language selection.
2026-01-13 16:40:05 +01:00

531 lines
17 KiB
TypeScript

import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { logger } from 'hono/logger';
import { serveStatic } from 'hono/bun';
import { streamSSE } from 'hono/streaming';
import { createProductionDokployClient } from './api/dokploy-production.js';
import { ProductionDeployer } from './orchestrator/production-deployer.js';
import type { DeploymentState as OrchestratorDeploymentState } from './orchestrator/production-deployer.js';
const PORT = parseInt(process.env.PORT || '3000', 10);
const HOST = process.env.HOST || '0.0.0.0';
// Extended deployment state for HTTP server (adds logs and language)
interface HttpDeploymentState extends OrchestratorDeploymentState {
logs: string[];
lang: string;
}
const deployments = new Map<string, HttpDeploymentState>();
// Generate a unique deployment ID
function generateDeploymentId(): string {
return `dep_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
}
// Validate stack name
function validateStackName(name: string): { valid: boolean; error?: string } {
if (!name || typeof name !== 'string') {
return { valid: false, error: 'Name is required' };
}
const trimmedName = name.trim().toLowerCase();
if (trimmedName.length < 3 || trimmedName.length > 20) {
return { valid: false, error: 'Name must be between 3 and 20 characters' };
}
if (!/^[a-z0-9-]+$/.test(trimmedName)) {
return { valid: false, error: 'Name can only contain lowercase letters, numbers, and hyphens' };
}
if (trimmedName.startsWith('-') || trimmedName.endsWith('-')) {
return { valid: false, error: 'Name cannot start or end with a hyphen' };
}
const reservedNames = (process.env.RESERVED_NAMES || 'admin,api,www,root,system,test,demo,portal').split(',');
if (reservedNames.includes(trimmedName)) {
return { valid: false, error: `Name "${trimmedName}" is reserved` };
}
return { valid: true };
}
// Main deployment orchestration using production components
async function deployStack(deploymentId: string): Promise<void> {
const deployment = deployments.get(deploymentId);
if (!deployment) {
throw new Error('Deployment not found');
}
try {
const client = createProductionDokployClient();
// Progress callback to update state in real-time
const progressCallback = (state: OrchestratorDeploymentState) => {
const currentDeployment = deployments.get(deploymentId);
if (currentDeployment) {
// Update all fields from orchestrator state
currentDeployment.phase = state.phase;
currentDeployment.status = state.status;
currentDeployment.progress = state.progress;
currentDeployment.message = state.message;
currentDeployment.url = state.url;
currentDeployment.error = state.error;
currentDeployment.resources = state.resources;
currentDeployment.timestamps = state.timestamps;
deployments.set(deploymentId, { ...currentDeployment });
}
};
const deployer = new ProductionDeployer(client, progressCallback);
const result = await deployer.deploy({
stackName: deployment.stackName,
dockerImage: process.env.STACK_IMAGE || 'git.app.flexinit.nl/flexinit/agent-stack:latest',
domainSuffix: process.env.STACK_DOMAIN_SUFFIX || 'ai.flexinit.nl',
port: 8080,
healthCheckTimeout: 180000,
healthCheckInterval: 5000,
registryId: process.env.STACK_REGISTRY_ID,
sharedProjectId: process.env.SHARED_PROJECT_ID,
sharedEnvironmentId: process.env.SHARED_ENVIRONMENT_ID,
lang: deployment.lang,
});
// Final update with logs
const finalDeployment = deployments.get(deploymentId);
if (finalDeployment) {
finalDeployment.logs = result.logs;
deployments.set(deploymentId, { ...finalDeployment });
}
} catch (error) {
// Deployment failed catastrophically (before orchestrator could handle it)
const currentDeployment = deployments.get(deploymentId);
if (currentDeployment) {
currentDeployment.status = 'failure';
currentDeployment.phase = 'failed';
currentDeployment.error = {
phase: currentDeployment.phase,
message: error instanceof Error ? error.message : 'Unknown error',
code: 'DEPLOYMENT_FAILED',
};
currentDeployment.message = 'Deployment failed';
currentDeployment.timestamps.completed = new Date().toISOString();
deployments.set(deploymentId, { ...currentDeployment });
}
throw error;
}
}
const app = new Hono();
app.use('*', logger());
app.use('*', cors());
app.get('/health', (c) => {
return c.json({
status: 'healthy',
timestamp: new Date().toISOString(),
version: '0.2.0', // Bumped version for production components
service: 'ai-stack-deployer',
activeDeployments: deployments.size,
features: {
productionClient: true,
retryLogic: true,
circuitBreaker: true,
autoRollback: true,
healthVerification: true,
}
});
});
// Deploy endpoint
app.post('/api/deploy', async (c) => {
try {
const body = await c.req.json();
const { name, lang = 'en' } = body;
// Validate name
const validation = validateStackName(name);
if (!validation.valid) {
return c.json({
success: false,
error: validation.error,
code: 'INVALID_NAME'
}, 400);
}
const normalizedName = name.trim().toLowerCase();
const client = createProductionDokployClient();
const sharedEnvironmentId = process.env.SHARED_ENVIRONMENT_ID;
const appName = `opencode-${normalizedName}`;
if (sharedEnvironmentId) {
const existingApp = await client.findApplicationByName(sharedEnvironmentId, appName);
if (existingApp) {
return c.json({
success: false,
error: 'Name already taken',
code: 'NAME_EXISTS'
}, 409);
}
} else {
const projectName = `ai-stack-${normalizedName}`;
const existingProject = await client.findProjectByName(projectName);
if (existingProject) {
return c.json({
success: false,
error: 'Name already taken',
code: 'NAME_EXISTS'
}, 409);
}
}
// Create deployment state
const deploymentId = generateDeploymentId();
const deployment: HttpDeploymentState = {
id: deploymentId,
stackName: normalizedName,
phase: 'initializing',
status: 'in_progress',
progress: 0,
message: 'Initializing deployment',
resources: {},
timestamps: {
started: new Date().toISOString(),
},
logs: [],
lang,
};
deployments.set(deploymentId, deployment);
// Start deployment in background
deployStack(deploymentId).catch(err => {
console.error(`Deployment ${deploymentId} failed:`, err);
});
return c.json({
success: true,
deploymentId,
url: `https://${normalizedName}.${process.env.STACK_DOMAIN_SUFFIX || 'ai.flexinit.nl'}`,
statusEndpoint: `/api/status/${deploymentId}`
});
} catch (error) {
console.error('Deploy endpoint error:', error);
return c.json({
success: false,
error: error instanceof Error ? error.message : 'Internal server error',
code: 'INTERNAL_ERROR'
}, 500);
}
});
// Status endpoint with SSE
app.get('/api/status/:deploymentId', (c) => {
const deploymentId = c.req.param('deploymentId');
const deployment = deployments.get(deploymentId);
if (!deployment) {
return c.json({
success: false,
error: 'Deployment not found',
code: 'NOT_FOUND'
}, 404);
}
return streamSSE(c, async (stream) => {
let lastStatus = '';
try {
// Stream updates until deployment completes or fails
while (true) {
const currentDeployment = deployments.get(deploymentId);
if (!currentDeployment) {
await stream.writeSSE({
event: 'error',
data: JSON.stringify({ message: 'Deployment not found' })
});
break;
}
// Send update if status changed
const currentStatus = JSON.stringify({
phase: currentDeployment.phase,
status: currentDeployment.status,
progress: currentDeployment.progress,
message: currentDeployment.message,
});
if (currentStatus !== lastStatus) {
await stream.writeSSE({
event: 'progress',
data: JSON.stringify({
phase: currentDeployment.phase,
status: currentDeployment.status,
progress: currentDeployment.progress,
message: currentDeployment.message,
currentStep: currentDeployment.message, // Backward compatibility
url: currentDeployment.url,
error: currentDeployment.error?.message,
resources: currentDeployment.resources,
})
});
lastStatus = currentStatus;
}
// Exit if terminal state
if (currentDeployment.status === 'success' || currentDeployment.phase === 'completed') {
await stream.writeSSE({
event: 'complete',
data: JSON.stringify({
url: currentDeployment.url,
status: 'ready',
resources: currentDeployment.resources,
duration: currentDeployment.timestamps.completed && currentDeployment.timestamps.started
? (new Date(currentDeployment.timestamps.completed).getTime() -
new Date(currentDeployment.timestamps.started).getTime()) / 1000
: null,
})
});
break;
}
if (currentDeployment.status === 'failure' || currentDeployment.phase === 'failed') {
await stream.writeSSE({
event: 'error',
data: JSON.stringify({
message: currentDeployment.error?.message || 'Deployment failed',
status: 'failed',
phase: currentDeployment.error?.phase,
code: currentDeployment.error?.code,
})
});
break;
}
// Wait before next check
await stream.sleep(1000);
}
} catch (error) {
console.error('SSE stream error:', error);
}
});
});
// Get deployment details (new endpoint for debugging)
app.get('/api/deployment/:deploymentId', (c) => {
const deploymentId = c.req.param('deploymentId');
const deployment = deployments.get(deploymentId);
if (!deployment) {
return c.json({
success: false,
error: 'Deployment not found',
code: 'NOT_FOUND'
}, 404);
}
return c.json({
success: true,
deployment: {
id: deployment.id,
stackName: deployment.stackName,
phase: deployment.phase,
status: deployment.status,
progress: deployment.progress,
message: deployment.message,
url: deployment.url,
error: deployment.error,
resources: deployment.resources,
timestamps: deployment.timestamps,
logs: deployment.logs.slice(-50), // Last 50 log entries
}
});
});
// Check name availability
app.get('/api/check/:name', async (c) => {
try {
const name = c.req.param('name');
const validation = validateStackName(name);
if (!validation.valid) {
return c.json({
available: false,
valid: false,
error: validation.error
});
}
const normalizedName = name.trim().toLowerCase();
const client = createProductionDokployClient();
const sharedEnvironmentId = process.env.SHARED_ENVIRONMENT_ID;
let exists = false;
if (sharedEnvironmentId) {
const appName = `opencode-${normalizedName}`;
const existingApp = await client.findApplicationByName(sharedEnvironmentId, appName);
exists = !!existingApp;
} else {
const projectName = `ai-stack-${normalizedName}`;
const existingProject = await client.findProjectByName(projectName);
exists = !!existingProject;
}
return c.json({
available: !exists,
valid: true,
name: normalizedName
});
} catch (error) {
console.error('Check endpoint error:', error);
return c.json({
available: false,
valid: false,
error: 'Failed to check availability'
}, 500);
}
});
app.delete('/api/stack/:name', async (c) => {
try {
const name = c.req.param('name');
const normalizedName = name.trim().toLowerCase();
const client = createProductionDokployClient();
const sharedEnvironmentId = process.env.SHARED_ENVIRONMENT_ID;
if (sharedEnvironmentId) {
const appName = `opencode-${normalizedName}`;
const existingApp = await client.findApplicationByName(sharedEnvironmentId, appName);
if (!existingApp) {
return c.json({
success: false,
error: 'Stack not found',
code: 'NOT_FOUND'
}, 404);
}
console.log(`Deleting stack: ${appName} (applicationId: ${existingApp.applicationId})`);
await client.deleteApplication(existingApp.applicationId);
return c.json({
success: true,
message: `Stack ${normalizedName} deleted successfully`,
deletedApplicationId: existingApp.applicationId
});
} else {
const projectName = `ai-stack-${normalizedName}`;
const existingProject = await client.findProjectByName(projectName);
if (!existingProject) {
return c.json({
success: false,
error: 'Stack not found',
code: 'NOT_FOUND'
}, 404);
}
console.log(`Deleting stack: ${projectName} (projectId: ${existingProject.project.projectId})`);
await client.deleteProject(existingProject.project.projectId);
return c.json({
success: true,
message: `Stack ${normalizedName} deleted successfully`,
deletedProjectId: existingProject.project.projectId
});
}
} catch (error) {
console.error('Delete endpoint error:', error);
return c.json({
success: false,
error: error instanceof Error ? error.message : 'Failed to delete stack',
code: 'DELETE_FAILED'
}, 500);
}
});
const REACT_BUILD_PATH = './dist/client';
const LEGACY_FRONTEND_PATH = './src/frontend';
const USE_REACT = process.env.USE_REACT_FRONTEND !== 'false';
async function serveFile(reactPath: string, legacyPath: string, contentType: string) {
const filePath = USE_REACT ? `${REACT_BUILD_PATH}${reactPath}` : `${LEGACY_FRONTEND_PATH}${legacyPath}`;
const file = Bun.file(filePath);
if (await file.exists()) {
return new Response(file, { headers: { 'Content-Type': contentType } });
}
const fallbackFile = Bun.file(`${LEGACY_FRONTEND_PATH}${legacyPath}`);
if (await fallbackFile.exists()) {
return new Response(fallbackFile, { headers: { 'Content-Type': contentType } });
}
return new Response('Not Found', { status: 404 });
}
app.get('/assets/*', async (c) => {
const path = c.req.path;
const file = Bun.file(`${REACT_BUILD_PATH}${path}`);
if (await file.exists()) {
const ext = path.split('.').pop();
const contentTypes: Record<string, string> = {
js: 'application/javascript',
css: 'text/css',
svg: 'image/svg+xml',
png: 'image/png',
jpg: 'image/jpeg',
woff: 'font/woff',
woff2: 'font/woff2',
};
return new Response(file, {
headers: { 'Content-Type': contentTypes[ext || ''] || 'application/octet-stream' }
});
}
return new Response('Not Found', { status: 404 });
});
app.get('/style.css', (c) => serveFile('/style.css', '/style.css', 'text/css'));
app.get('/app.js', (c) => serveFile('/app.js', '/app.js', 'application/javascript'));
app.get('/og-image.png', (c) => serveFile('/og-image.png', '/og-image.png', 'image/png'));
app.get('/favicon.svg', (c) => serveFile('/favicon.svg', '/favicon.svg', 'image/svg+xml'));
app.get('/favicon.ico', (c) => serveFile('/favicon.ico', '/favicon.ico', 'image/x-icon'));
app.get('/favicon.png', (c) => serveFile('/favicon.png', '/favicon.png', 'image/png'));
app.get('/apple-touch-icon.png', (c) => serveFile('/apple-touch-icon.png', '/apple-touch-icon.png', 'image/png'));
app.get('*', async (c) => {
if (c.req.path.startsWith('/api/') || c.req.path === '/health') {
return c.notFound();
}
const indexPath = USE_REACT ? `${REACT_BUILD_PATH}/index.html` : `${LEGACY_FRONTEND_PATH}/index.html`;
const file = Bun.file(indexPath);
if (await file.exists()) {
return new Response(file, { headers: { 'Content-Type': 'text/html' } });
}
const fallback = Bun.file(`${LEGACY_FRONTEND_PATH}/index.html`);
return new Response(fallback, { headers: { 'Content-Type': 'text/html' } });
});
console.log(`🚀 AI Stack Deployer (Production) starting on http://${HOST}:${PORT}`);
console.log(`✅ Production features enabled:`);
console.log(` - Retry logic with exponential backoff`);
console.log(` - Circuit breaker pattern`);
console.log(` - Automatic rollback on failure`);
console.log(` - Health verification`);
console.log(` - Structured logging`);
export default {
port: PORT,
hostname: HOST,
fetch: app.fetch,
idleTimeout: 255,
};