refactor: enterprise-grade project structure
- Move test files to tests/ - Archive session notes to docs/archive/ - Remove temp/diagnostic files - Clean src/ to only contain production code
This commit is contained in:
@@ -1,49 +0,0 @@
|
|||||||
#!/usr/bin/env bun
|
|
||||||
/**
|
|
||||||
* Diagnostic script to test application.create API call
|
|
||||||
* Captures exact error message and request/response
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { createDokployClient } from './api/dokploy.js';
|
|
||||||
|
|
||||||
console.log('═══════════════════════════════════════');
|
|
||||||
console.log(' Diagnosing application.create API');
|
|
||||||
console.log('═══════════════════════════════════════\n');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const client = createDokployClient();
|
|
||||||
|
|
||||||
// Use existing project ID from earlier test
|
|
||||||
const projectId = 'MV2b-c1hIW4-Dww8Xoinj';
|
|
||||||
const appName = `test-diagnostic-${Date.now()}`;
|
|
||||||
|
|
||||||
console.log(`Project ID: ${projectId}`);
|
|
||||||
console.log(`App Name: ${appName}`);
|
|
||||||
console.log(`Docker Image: nginx:alpine`);
|
|
||||||
console.log();
|
|
||||||
|
|
||||||
console.log('Making API call...\n');
|
|
||||||
|
|
||||||
const application = await client.createApplication(
|
|
||||||
appName,
|
|
||||||
projectId,
|
|
||||||
'nginx:alpine'
|
|
||||||
);
|
|
||||||
|
|
||||||
console.log('✅ Success! Application created:');
|
|
||||||
console.log(JSON.stringify(application, null, 2));
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
console.error('❌ Failed to create application\n');
|
|
||||||
console.error('Error details:');
|
|
||||||
|
|
||||||
if (error instanceof Error) {
|
|
||||||
console.error(`Message: ${error.message}`);
|
|
||||||
console.error(`\nStack trace:`);
|
|
||||||
console.error(error.stack);
|
|
||||||
} else {
|
|
||||||
console.error(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
@@ -1,351 +0,0 @@
|
|||||||
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 { createDokployClient } from './api/dokploy.js';
|
|
||||||
|
|
||||||
const PORT = parseInt(process.env.PORT || '3000', 10);
|
|
||||||
const HOST = process.env.HOST || '0.0.0.0';
|
|
||||||
|
|
||||||
// Deployment state tracking
|
|
||||||
interface DeploymentState {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
status: 'initializing' | 'creating_project' | 'creating_application' | 'deploying' | 'completed' | 'failed';
|
|
||||||
url?: string;
|
|
||||||
error?: string;
|
|
||||||
createdAt: Date;
|
|
||||||
projectId?: string;
|
|
||||||
applicationId?: string;
|
|
||||||
progress: number;
|
|
||||||
currentStep: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const deployments = new Map<string, DeploymentState>();
|
|
||||||
|
|
||||||
// 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
|
|
||||||
async function deployStack(deploymentId: string): Promise<void> {
|
|
||||||
const deployment = deployments.get(deploymentId);
|
|
||||||
if (!deployment) {
|
|
||||||
throw new Error('Deployment not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const dokployClient = createDokployClient();
|
|
||||||
const domain = `${deployment.name}.${process.env.STACK_DOMAIN_SUFFIX || 'ai.flexinit.nl'}`;
|
|
||||||
|
|
||||||
// Step 1: Create Dokploy project
|
|
||||||
deployment.status = 'creating_project';
|
|
||||||
deployment.progress = 25;
|
|
||||||
deployment.currentStep = 'Creating Dokploy project';
|
|
||||||
deployments.set(deploymentId, { ...deployment });
|
|
||||||
|
|
||||||
const projectName = `ai-stack-${deployment.name}`;
|
|
||||||
let project = await dokployClient.findProjectByName(projectName);
|
|
||||||
|
|
||||||
if (!project) {
|
|
||||||
project = await dokployClient.createProject(
|
|
||||||
projectName,
|
|
||||||
`AI Stack for ${deployment.name}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
deployment.projectId = project.projectId;
|
|
||||||
deployments.set(deploymentId, { ...deployment });
|
|
||||||
|
|
||||||
// Step 2: Create application
|
|
||||||
deployment.status = 'creating_application';
|
|
||||||
deployment.progress = 50;
|
|
||||||
deployment.currentStep = 'Creating application container';
|
|
||||||
deployments.set(deploymentId, { ...deployment });
|
|
||||||
|
|
||||||
const dockerImage = process.env.STACK_IMAGE || 'git.app.flexinit.nl/oussamadouhou/oh-my-opencode-free:latest';
|
|
||||||
const application = await dokployClient.createApplication(
|
|
||||||
`opencode-${deployment.name}`,
|
|
||||||
project.projectId,
|
|
||||||
dockerImage
|
|
||||||
);
|
|
||||||
|
|
||||||
deployment.applicationId = application.applicationId;
|
|
||||||
deployments.set(deploymentId, { ...deployment });
|
|
||||||
|
|
||||||
// Step 3: Configure domain
|
|
||||||
deployment.progress = 70;
|
|
||||||
deployment.currentStep = 'Configuring domain';
|
|
||||||
deployments.set(deploymentId, { ...deployment });
|
|
||||||
|
|
||||||
await dokployClient.createDomain(
|
|
||||||
domain,
|
|
||||||
application.applicationId,
|
|
||||||
true,
|
|
||||||
8080
|
|
||||||
);
|
|
||||||
|
|
||||||
// Step 4: Deploy application
|
|
||||||
deployment.status = 'deploying';
|
|
||||||
deployment.progress = 85;
|
|
||||||
deployment.currentStep = 'Deploying application';
|
|
||||||
deployments.set(deploymentId, { ...deployment });
|
|
||||||
|
|
||||||
await dokployClient.deployApplication(application.applicationId);
|
|
||||||
|
|
||||||
// Mark as completed
|
|
||||||
deployment.status = 'completed';
|
|
||||||
deployment.progress = 100;
|
|
||||||
deployment.currentStep = 'Deployment complete';
|
|
||||||
deployment.url = `https://${domain}`;
|
|
||||||
deployments.set(deploymentId, { ...deployment });
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
deployment.status = 'failed';
|
|
||||||
deployment.error = error instanceof Error ? error.message : 'Unknown error';
|
|
||||||
deployment.currentStep = 'Deployment failed';
|
|
||||||
deployments.set(deploymentId, { ...deployment });
|
|
||||||
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.1.0',
|
|
||||||
service: 'ai-stack-deployer',
|
|
||||||
activeDeployments: deployments.size
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Root path now served by static frontend (removed JSON response)
|
|
||||||
// app.get('/', ...) - see bottom of file for static file serving
|
|
||||||
|
|
||||||
// Deploy endpoint
|
|
||||||
app.post('/api/deploy', async (c) => {
|
|
||||||
try {
|
|
||||||
const body = await c.req.json();
|
|
||||||
const { name } = 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();
|
|
||||||
|
|
||||||
// Check if name is already taken
|
|
||||||
const dokployClient = createDokployClient();
|
|
||||||
const projectName = `ai-stack-${normalizedName}`;
|
|
||||||
const existingProject = await dokployClient.findProjectByName(projectName);
|
|
||||||
|
|
||||||
if (existingProject) {
|
|
||||||
return c.json({
|
|
||||||
success: false,
|
|
||||||
error: 'Name already taken',
|
|
||||||
code: 'NAME_EXISTS'
|
|
||||||
}, 409);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create deployment
|
|
||||||
const deploymentId = generateDeploymentId();
|
|
||||||
const deployment: DeploymentState = {
|
|
||||||
id: deploymentId,
|
|
||||||
name: normalizedName,
|
|
||||||
status: 'initializing',
|
|
||||||
createdAt: new Date(),
|
|
||||||
progress: 0,
|
|
||||||
currentStep: 'Initializing deployment'
|
|
||||||
};
|
|
||||||
|
|
||||||
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(currentDeployment);
|
|
||||||
if (currentStatus !== lastStatus) {
|
|
||||||
await stream.writeSSE({
|
|
||||||
event: 'progress',
|
|
||||||
data: JSON.stringify({
|
|
||||||
status: currentDeployment.status,
|
|
||||||
progress: currentDeployment.progress,
|
|
||||||
currentStep: currentDeployment.currentStep,
|
|
||||||
url: currentDeployment.url,
|
|
||||||
error: currentDeployment.error
|
|
||||||
})
|
|
||||||
});
|
|
||||||
lastStatus = currentStatus;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Exit if terminal state
|
|
||||||
if (currentDeployment.status === 'completed') {
|
|
||||||
await stream.writeSSE({
|
|
||||||
event: 'complete',
|
|
||||||
data: JSON.stringify({
|
|
||||||
url: currentDeployment.url,
|
|
||||||
status: 'ready'
|
|
||||||
})
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentDeployment.status === 'failed') {
|
|
||||||
await stream.writeSSE({
|
|
||||||
event: 'error',
|
|
||||||
data: JSON.stringify({
|
|
||||||
message: currentDeployment.error || 'Deployment failed',
|
|
||||||
status: 'failed'
|
|
||||||
})
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wait before next check
|
|
||||||
await stream.sleep(1000);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('SSE stream error:', error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check name availability
|
|
||||||
app.get('/api/check/:name', async (c) => {
|
|
||||||
try {
|
|
||||||
const name = c.req.param('name');
|
|
||||||
|
|
||||||
// Validate name format
|
|
||||||
const validation = validateStackName(name);
|
|
||||||
if (!validation.valid) {
|
|
||||||
return c.json({
|
|
||||||
available: false,
|
|
||||||
valid: false,
|
|
||||||
error: validation.error
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedName = name.trim().toLowerCase();
|
|
||||||
|
|
||||||
// Check if project exists
|
|
||||||
const dokployClient = createDokployClient();
|
|
||||||
const projectName = `ai-stack-${normalizedName}`;
|
|
||||||
const existingProject = await dokployClient.findProjectByName(projectName);
|
|
||||||
|
|
||||||
return c.json({
|
|
||||||
available: !existingProject,
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Serve static files (frontend)
|
|
||||||
app.use('/static/*', serveStatic({ root: './src/frontend' }));
|
|
||||||
app.use('/*', serveStatic({ root: './src/frontend', path: '/index.html' }));
|
|
||||||
|
|
||||||
console.log(`🚀 AI Stack Deployer starting on http://${HOST}:${PORT}`);
|
|
||||||
|
|
||||||
export default {
|
|
||||||
port: PORT,
|
|
||||||
hostname: HOST,
|
|
||||||
fetch: app.fetch,
|
|
||||||
};
|
|
||||||
@@ -1,374 +0,0 @@
|
|||||||
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)
|
|
||||||
interface HttpDeploymentState extends OrchestratorDeploymentState {
|
|
||||||
logs: 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();
|
|
||||||
const deployer = new ProductionDeployer(client);
|
|
||||||
|
|
||||||
// Execute deployment with production orchestrator
|
|
||||||
const result = await deployer.deploy({
|
|
||||||
stackName: deployment.stackName,
|
|
||||||
dockerImage: process.env.STACK_IMAGE || 'git.app.flexinit.nl/oussamadouhou/oh-my-opencode-free:latest',
|
|
||||||
domainSuffix: process.env.STACK_DOMAIN_SUFFIX || 'ai.flexinit.nl',
|
|
||||||
port: 8080,
|
|
||||||
healthCheckTimeout: 60000, // 60 seconds
|
|
||||||
healthCheckInterval: 5000, // 5 seconds
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update deployment state with orchestrator result
|
|
||||||
deployment.phase = result.state.phase;
|
|
||||||
deployment.status = result.state.status;
|
|
||||||
deployment.progress = result.state.progress;
|
|
||||||
deployment.message = result.state.message;
|
|
||||||
deployment.url = result.state.url;
|
|
||||||
deployment.error = result.state.error;
|
|
||||||
deployment.resources = result.state.resources;
|
|
||||||
deployment.timestamps = result.state.timestamps;
|
|
||||||
deployment.logs = result.logs;
|
|
||||||
|
|
||||||
deployments.set(deploymentId, { ...deployment });
|
|
||||||
|
|
||||||
} catch (error) {
|
|
||||||
// Deployment failed catastrophically (before orchestrator could handle it)
|
|
||||||
deployment.status = 'failure';
|
|
||||||
deployment.phase = 'failed';
|
|
||||||
deployment.error = {
|
|
||||||
phase: deployment.phase,
|
|
||||||
message: error instanceof Error ? error.message : 'Unknown error',
|
|
||||||
code: 'DEPLOYMENT_FAILED',
|
|
||||||
};
|
|
||||||
deployment.message = 'Deployment failed';
|
|
||||||
deployment.timestamps.completed = new Date().toISOString();
|
|
||||||
deployments.set(deploymentId, { ...deployment });
|
|
||||||
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 } = 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();
|
|
||||||
|
|
||||||
// Check if name is already taken
|
|
||||||
const client = createProductionDokployClient();
|
|
||||||
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: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
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');
|
|
||||||
|
|
||||||
// Validate name format
|
|
||||||
const validation = validateStackName(name);
|
|
||||||
if (!validation.valid) {
|
|
||||||
return c.json({
|
|
||||||
available: false,
|
|
||||||
valid: false,
|
|
||||||
error: validation.error
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedName = name.trim().toLowerCase();
|
|
||||||
|
|
||||||
// Check if project exists
|
|
||||||
const client = createProductionDokployClient();
|
|
||||||
const projectName = `ai-stack-${normalizedName}`;
|
|
||||||
const existingProject = await client.findProjectByName(projectName);
|
|
||||||
|
|
||||||
return c.json({
|
|
||||||
available: !existingProject,
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Serve static files (frontend)
|
|
||||||
app.use('/static/*', serveStatic({ root: './src/frontend' }));
|
|
||||||
app.use('/*', serveStatic({ root: './src/frontend', path: '/index.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,
|
|
||||||
};
|
|
||||||
Reference in New Issue
Block a user