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,
|
||||
};
|
||||
@@ -1,68 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Test script to verify Dokploy API endpoint formats
|
||||
* Tests the three core operations needed for deployment
|
||||
*/
|
||||
|
||||
import { createDokployClient } from './api/dokploy.js';
|
||||
|
||||
const TEST_PROJECT_NAME = `test-api-${Date.now()}`;
|
||||
const TEST_APP_NAME = `test-app-${Date.now()}`;
|
||||
|
||||
console.log('═══════════════════════════════════════');
|
||||
console.log(' Dokploy API Format Tests');
|
||||
console.log('═══════════════════════════════════════\n');
|
||||
|
||||
try {
|
||||
const client = createDokployClient();
|
||||
|
||||
// Test 1: project.create
|
||||
console.log('📋 Test 1: project.create');
|
||||
console.log('Request: { name, description }');
|
||||
const project = await client.createProject(
|
||||
TEST_PROJECT_NAME,
|
||||
'API format test project'
|
||||
);
|
||||
console.log('Response format:');
|
||||
console.log(JSON.stringify(project, null, 2));
|
||||
console.log(`✅ Project created: ${project.projectId}\n`);
|
||||
|
||||
// Test 2: application.create
|
||||
console.log('📋 Test 2: application.create');
|
||||
console.log('Request: { name, appName, projectId, dockerImage, sourceType }');
|
||||
const application = await client.createApplication(
|
||||
TEST_APP_NAME,
|
||||
project.projectId,
|
||||
'nginx:alpine'
|
||||
);
|
||||
console.log('Response format:');
|
||||
console.log(JSON.stringify(application, null, 2));
|
||||
console.log(`✅ Application created: ${application.applicationId}\n`);
|
||||
|
||||
// Test 3: application.deploy
|
||||
console.log('📋 Test 3: application.deploy');
|
||||
console.log('Request: { applicationId }');
|
||||
await client.deployApplication(application.applicationId);
|
||||
console.log('Response: void (no return value)');
|
||||
console.log(`✅ Deployment triggered\n`);
|
||||
|
||||
// Cleanup
|
||||
console.log('🧹 Cleaning up test resources...');
|
||||
await client.deleteApplication(application.applicationId);
|
||||
console.log(`✅ Deleted application: ${application.applicationId}`);
|
||||
await client.deleteProject(project.projectId);
|
||||
console.log(`✅ Deleted project: ${project.projectId}`);
|
||||
|
||||
console.log('\n═══════════════════════════════════════');
|
||||
console.log(' All API Format Tests Passed!');
|
||||
console.log('═══════════════════════════════════════');
|
||||
|
||||
} catch (error) {
|
||||
console.error('\n❌ Test failed:');
|
||||
console.error(error);
|
||||
if (error instanceof Error) {
|
||||
console.error('Error message:', error.message);
|
||||
console.error('Error stack:', error.stack);
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
import { createHetznerClient } from './api/hetzner';
|
||||
import { createDokployClient } from './api/dokploy';
|
||||
|
||||
async function testHetznerClient() {
|
||||
console.log('\n🔍 Testing Hetzner DNS Client...');
|
||||
|
||||
try {
|
||||
const client = createHetznerClient();
|
||||
const result = await client.testConnection();
|
||||
|
||||
if (result.success) {
|
||||
console.log(`✅ Hetzner: ${result.message}`);
|
||||
return true;
|
||||
} else {
|
||||
console.log(`❌ Hetzner: ${result.message}`);
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`❌ Hetzner: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function testDokployClient() {
|
||||
console.log('\n🔍 Testing Dokploy Client...');
|
||||
|
||||
try {
|
||||
const client = createDokployClient();
|
||||
const result = await client.testConnection();
|
||||
|
||||
if (result.success) {
|
||||
console.log(`✅ Dokploy: ${result.message}`);
|
||||
return true;
|
||||
} else {
|
||||
console.log(`❌ Dokploy: ${result.message}`);
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`❌ Dokploy: ${error instanceof Error ? error.message : 'Unknown error'}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log('═══════════════════════════════════════');
|
||||
console.log(' AI Stack Deployer - API Client Tests');
|
||||
console.log('═══════════════════════════════════════');
|
||||
|
||||
const hetznerOk = await testHetznerClient();
|
||||
const dokployOk = await testDokployClient();
|
||||
|
||||
console.log('\n═══════════════════════════════════════');
|
||||
console.log(' Test Summary');
|
||||
console.log('═══════════════════════════════════════');
|
||||
console.log(`Hetzner DNS: ${hetznerOk ? '✅ PASS' : '❌ FAIL'}`);
|
||||
console.log(`Dokploy API: ${dokployOk ? '✅ PASS' : '❌ FAIL'}`);
|
||||
console.log('═══════════════════════════════════════\n');
|
||||
|
||||
if (!hetznerOk || !dokployOk) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1,116 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Deploy persistent resources for verification
|
||||
* Skips health check and rollback - leaves resources in Dokploy
|
||||
*/
|
||||
|
||||
import { createProductionDokployClient } from './api/dokploy-production.js';
|
||||
|
||||
const STACK_NAME = `verify-${Date.now()}`;
|
||||
const DOCKER_IMAGE = 'nginx:alpine';
|
||||
const DOMAIN_SUFFIX = 'ai.flexinit.nl';
|
||||
|
||||
console.log('═══════════════════════════════════════');
|
||||
console.log(' PERSISTENT DEPLOYMENT TEST');
|
||||
console.log('═══════════════════════════════════════\n');
|
||||
|
||||
async function main() {
|
||||
const client = createProductionDokployClient();
|
||||
const projectName = `ai-stack-${STACK_NAME}`;
|
||||
|
||||
console.log(`Deploying: ${STACK_NAME}`);
|
||||
console.log(`Project: ${projectName}`);
|
||||
console.log(`URL: https://${STACK_NAME}.${DOMAIN_SUFFIX}\n`);
|
||||
|
||||
// Phase 1: Create Project
|
||||
console.log('[1/6] Creating project...');
|
||||
const { project, environment } = await client.createProject(
|
||||
projectName,
|
||||
`Verification deployment for ${STACK_NAME}`
|
||||
);
|
||||
console.log(`✅ Project: ${project.projectId}`);
|
||||
console.log(`✅ Environment: ${environment.environmentId}\n`);
|
||||
|
||||
// Phase 2: Create Application
|
||||
console.log('[2/6] Creating application...');
|
||||
const application = await client.createApplication(
|
||||
`opencode-${STACK_NAME}`,
|
||||
environment.environmentId
|
||||
);
|
||||
console.log(`✅ Application: ${application.applicationId}\n`);
|
||||
|
||||
// Phase 3: Configure Docker Image
|
||||
console.log('[3/6] Configuring Docker image...');
|
||||
await client.updateApplication(application.applicationId, {
|
||||
dockerImage: DOCKER_IMAGE,
|
||||
sourceType: 'docker',
|
||||
});
|
||||
console.log(`✅ Configured: ${DOCKER_IMAGE}\n`);
|
||||
|
||||
// Phase 4: Create Domain
|
||||
console.log('[4/6] Creating domain...');
|
||||
const domain = await client.createDomain(
|
||||
`${STACK_NAME}.${DOMAIN_SUFFIX}`,
|
||||
application.applicationId,
|
||||
true,
|
||||
80
|
||||
);
|
||||
console.log(`✅ Domain: ${domain.domainId}`);
|
||||
console.log(`✅ Host: ${domain.host}\n`);
|
||||
|
||||
// Phase 5: Trigger Deployment
|
||||
console.log('[5/6] Triggering deployment...');
|
||||
await client.deployApplication(application.applicationId);
|
||||
console.log(`✅ Deployment triggered\n`);
|
||||
|
||||
// Phase 6: Summary
|
||||
console.log('[6/6] Deployment complete!\n');
|
||||
|
||||
console.log('═══════════════════════════════════════');
|
||||
console.log(' DEPLOYMENT SUCCESSFUL');
|
||||
console.log('═══════════════════════════════════════\n');
|
||||
|
||||
console.log(`📦 Resources:`);
|
||||
console.log(` Project ID: ${project.projectId}`);
|
||||
console.log(` Environment ID: ${environment.environmentId}`);
|
||||
console.log(` Application ID: ${application.applicationId}`);
|
||||
console.log(` Domain ID: ${domain.domainId}`);
|
||||
|
||||
console.log(`\n🌐 URLs:`);
|
||||
console.log(` Application: https://${STACK_NAME}.${DOMAIN_SUFFIX}`);
|
||||
console.log(` Dokploy UI: https://app.flexinit.nl/project/${project.projectId}`);
|
||||
|
||||
console.log(`\n⏱️ Note: Application will be accessible in 1-2 minutes (SSL provisioning)`);
|
||||
|
||||
console.log(`\n🧹 Cleanup command:`);
|
||||
console.log(` source .env && curl -X POST -H "x-api-key: \${DOKPLOY_API_TOKEN}" \\`);
|
||||
console.log(` https://app.flexinit.nl/api/application.delete \\`);
|
||||
console.log(` -H "Content-Type: application/json" \\`);
|
||||
console.log(` -d '{"applicationId":"${application.applicationId}"}'`);
|
||||
|
||||
console.log('\n');
|
||||
|
||||
// Output machine-readable format for verification
|
||||
const output = {
|
||||
success: true,
|
||||
stackName: STACK_NAME,
|
||||
resources: {
|
||||
projectId: project.projectId,
|
||||
environmentId: environment.environmentId,
|
||||
applicationId: application.applicationId,
|
||||
domainId: domain.domainId,
|
||||
},
|
||||
url: `https://${STACK_NAME}.${DOMAIN_SUFFIX}`,
|
||||
dokployUrl: `https://app.flexinit.nl/project/${project.projectId}`,
|
||||
};
|
||||
|
||||
console.log('JSON OUTPUT:');
|
||||
console.log(JSON.stringify(output, null, 2));
|
||||
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error('\n❌ Deployment failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,177 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Deployment Proof Test
|
||||
*
|
||||
* Executes a complete deployment and captures evidence at each phase.
|
||||
* Does not fail on health check timeout (SSL provisioning can take time).
|
||||
* Does not cleanup - leaves resources for manual verification.
|
||||
*/
|
||||
|
||||
import { createProductionDokployClient } from './api/dokploy-production.js';
|
||||
import { ProductionDeployer } from './orchestrator/production-deployer.js';
|
||||
|
||||
const TEST_STACK_NAME = `proof-${Date.now()}`;
|
||||
const DOCKER_IMAGE = 'nginx:alpine'; // Fast to deploy
|
||||
const DOMAIN_SUFFIX = process.env.STACK_DOMAIN_SUFFIX || 'ai.flexinit.nl';
|
||||
|
||||
console.log('═══════════════════════════════════════');
|
||||
console.log(' DEPLOYMENT PROOF TEST');
|
||||
console.log('═══════════════════════════════════════\n');
|
||||
|
||||
console.log(`Configuration:`);
|
||||
console.log(` Stack Name: ${TEST_STACK_NAME}`);
|
||||
console.log(` Docker Image: ${DOCKER_IMAGE}`);
|
||||
console.log(` Domain: ${TEST_STACK_NAME}.${DOMAIN_SUFFIX}`);
|
||||
console.log(` Expected URL: https://${TEST_STACK_NAME}.${DOMAIN_SUFFIX}`);
|
||||
console.log();
|
||||
|
||||
async function main() {
|
||||
const client = createProductionDokployClient();
|
||||
const deployer = new ProductionDeployer(client);
|
||||
|
||||
console.log('🚀 Starting deployment...\n');
|
||||
|
||||
const result = await deployer.deploy({
|
||||
stackName: TEST_STACK_NAME,
|
||||
dockerImage: DOCKER_IMAGE,
|
||||
domainSuffix: DOMAIN_SUFFIX,
|
||||
port: 80,
|
||||
healthCheckTimeout: 30000, // 30 seconds only (don't wait forever for SSL)
|
||||
healthCheckInterval: 5000,
|
||||
});
|
||||
|
||||
console.log('\n═══════════════════════════════════════');
|
||||
console.log(' DEPLOYMENT RESULT');
|
||||
console.log('═══════════════════════════════════════\n');
|
||||
|
||||
console.log(`Final Phase: ${result.state.phase}`);
|
||||
console.log(`Status: ${result.state.status}`);
|
||||
console.log(`Progress: ${result.state.progress}%`);
|
||||
console.log(`Message: ${result.state.message}`);
|
||||
|
||||
if (result.state.url) {
|
||||
console.log(`\n🌐 Application URL: ${result.state.url}`);
|
||||
}
|
||||
|
||||
console.log(`\n📦 Resources Created:`);
|
||||
console.log(` ✓ Project ID: ${result.state.resources.projectId || 'NONE'}`);
|
||||
console.log(` ✓ Environment ID: ${result.state.resources.environmentId || 'NONE'}`);
|
||||
console.log(` ✓ Application ID: ${result.state.resources.applicationId || 'NONE'}`);
|
||||
console.log(` ✓ Domain ID: ${result.state.resources.domainId || 'NONE'}`);
|
||||
|
||||
console.log(`\n⏱️ Timestamps:`);
|
||||
console.log(` Started: ${result.state.timestamps.started}`);
|
||||
console.log(` Completed: ${result.state.timestamps.completed || 'IN PROGRESS'}`);
|
||||
|
||||
if (result.state.timestamps.completed && result.state.timestamps.started) {
|
||||
const start = new Date(result.state.timestamps.started).getTime();
|
||||
const end = new Date(result.state.timestamps.completed).getTime();
|
||||
const duration = ((end - start) / 1000).toFixed(2);
|
||||
console.log(` Duration: ${duration}s`);
|
||||
}
|
||||
|
||||
if (result.state.error) {
|
||||
console.log(`\n⚠️ Error Details:`);
|
||||
console.log(` Phase: ${result.state.error.phase}`);
|
||||
console.log(` Message: ${result.state.error.message}`);
|
||||
}
|
||||
|
||||
console.log(`\n🔄 Circuit Breaker: ${client.getCircuitBreakerState()}`);
|
||||
|
||||
console.log(`\n═══════════════════════════════════════`);
|
||||
console.log(' PHASE EXECUTION LOG');
|
||||
console.log('═══════════════════════════════════════\n');
|
||||
|
||||
const logs = client.getLogs();
|
||||
let phaseNum = 1;
|
||||
let lastPhase = '';
|
||||
|
||||
logs.forEach(log => {
|
||||
if (log.phase !== lastPhase) {
|
||||
console.log(`\n[PHASE ${phaseNum}] ${log.phase.toUpperCase()}`);
|
||||
phaseNum++;
|
||||
lastPhase = log.phase;
|
||||
}
|
||||
|
||||
const level = log.level === 'error' ? '❌' : log.level === 'warn' ? '⚠️ ' : '✅';
|
||||
const duration = log.duration_ms ? ` (${log.duration_ms}ms)` : '';
|
||||
console.log(` ${level} ${log.action}: ${log.message}${duration}`);
|
||||
|
||||
if (log.error) {
|
||||
console.log(` ↳ Error: ${log.error.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Check deployment success criteria
|
||||
console.log(`\n═══════════════════════════════════════`);
|
||||
console.log(' SUCCESS CRITERIA');
|
||||
console.log('═══════════════════════════════════════\n');
|
||||
|
||||
const checks = {
|
||||
'Project Created': !!result.state.resources.projectId,
|
||||
'Environment Retrieved': !!result.state.resources.environmentId,
|
||||
'Application Created': !!result.state.resources.applicationId,
|
||||
'Domain Configured': !!result.state.resources.domainId,
|
||||
'Deployment Triggered': result.state.phase !== 'creating_domain',
|
||||
'URL Generated': !!result.state.url,
|
||||
};
|
||||
|
||||
let passCount = 0;
|
||||
Object.entries(checks).forEach(([name, passed]) => {
|
||||
console.log(` ${passed ? '✅' : '❌'} ${name}`);
|
||||
if (passed) passCount++;
|
||||
});
|
||||
|
||||
const healthCheckNote = result.state.error?.phase === 'verifying_health'
|
||||
? '\n ℹ️ Note: Health check timeout is expected for new SSL certificates'
|
||||
: '';
|
||||
|
||||
console.log(`\n Score: ${passCount}/${Object.keys(checks).length} checks passed${healthCheckNote}`);
|
||||
|
||||
console.log(`\n═══════════════════════════════════════`);
|
||||
console.log(' VERIFICATION INSTRUCTIONS');
|
||||
console.log('═══════════════════════════════════════\n');
|
||||
|
||||
if (result.state.resources.projectId) {
|
||||
console.log(`1. Verify in Dokploy UI:`);
|
||||
console.log(` https://app.flexinit.nl/project/${result.state.resources.projectId}`);
|
||||
console.log();
|
||||
}
|
||||
|
||||
if (result.state.resources.applicationId) {
|
||||
console.log(`2. Check application status:`);
|
||||
console.log(` https://app.flexinit.nl/project/${result.state.resources.projectId}/services/application/${result.state.resources.applicationId}`);
|
||||
console.log();
|
||||
}
|
||||
|
||||
if (result.state.url) {
|
||||
console.log(`3. Test application (may take 1-2 min for SSL):`);
|
||||
console.log(` ${result.state.url}`);
|
||||
console.log();
|
||||
}
|
||||
|
||||
console.log(`4. Cleanup command:`);
|
||||
console.log(` curl -X POST -H "x-api-key: \${DOKPLOY_API_TOKEN}" \\`);
|
||||
console.log(` https://app.flexinit.nl/api/application.delete \\`);
|
||||
console.log(` -d '{"applicationId":"${result.state.resources.applicationId}"}'`);
|
||||
console.log();
|
||||
|
||||
// Determine overall success
|
||||
const corePhases = passCount >= 5; // At least 5/6 core checks
|
||||
const noBlockingErrors = !result.state.error || result.state.error.phase === 'verifying_health';
|
||||
|
||||
console.log(`\n═══════════════════════════════════════`);
|
||||
if (corePhases && noBlockingErrors) {
|
||||
console.log(' ✅ DEPLOYMENT WORKING - ALL CORE PHASES PASSED');
|
||||
} else {
|
||||
console.log(' ❌ DEPLOYMENT BLOCKED');
|
||||
}
|
||||
console.log('═══════════════════════════════════════\n');
|
||||
|
||||
process.exit(corePhases && noBlockingErrors ? 0 : 1);
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error('\n❌ Fatal error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,125 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Production Deployment Integration Test
|
||||
*
|
||||
* Tests the complete deployment flow with real Dokploy API:
|
||||
* - Project creation
|
||||
* - Environment retrieval
|
||||
* - Application creation
|
||||
* - Configuration
|
||||
* - Domain setup
|
||||
* - Deployment
|
||||
* - Health verification
|
||||
* - Cleanup/Rollback
|
||||
*/
|
||||
|
||||
import { createProductionDokployClient } from './api/dokploy-production.js';
|
||||
import { ProductionDeployer } from './orchestrator/production-deployer.js';
|
||||
|
||||
console.log('═══════════════════════════════════════');
|
||||
console.log(' Production Deployment Integration Test');
|
||||
console.log('═══════════════════════════════════════\n');
|
||||
|
||||
const TEST_STACK_NAME = `test-prod-${Date.now()}`;
|
||||
const DOCKER_IMAGE = process.env.STACK_IMAGE || 'nginx:alpine';
|
||||
const DOMAIN_SUFFIX = process.env.STACK_DOMAIN_SUFFIX || 'ai.flexinit.nl';
|
||||
|
||||
console.log(`Test Configuration:`);
|
||||
console.log(` Stack Name: ${TEST_STACK_NAME}`);
|
||||
console.log(` Docker Image: ${DOCKER_IMAGE}`);
|
||||
console.log(` Domain: ${TEST_STACK_NAME}.${DOMAIN_SUFFIX}`);
|
||||
console.log();
|
||||
|
||||
async function main() {
|
||||
const client = createProductionDokployClient();
|
||||
const deployer = new ProductionDeployer(client);
|
||||
|
||||
console.log('🚀 Starting deployment...\n');
|
||||
|
||||
const result = await deployer.deploy({
|
||||
stackName: TEST_STACK_NAME,
|
||||
dockerImage: DOCKER_IMAGE,
|
||||
domainSuffix: DOMAIN_SUFFIX,
|
||||
port: 80, // nginx default port
|
||||
healthCheckTimeout: 120000, // 2 minutes
|
||||
healthCheckInterval: 5000, // 5 seconds
|
||||
});
|
||||
|
||||
console.log('\n═══════════════════════════════════════');
|
||||
console.log(' Deployment Result');
|
||||
console.log('═══════════════════════════════════════\n');
|
||||
|
||||
console.log(`Status: ${result.success ? '✅ SUCCESS' : '❌ FAILURE'}`);
|
||||
console.log(`Deployment ID: ${result.state.id}`);
|
||||
console.log(`Final Phase: ${result.state.phase}`);
|
||||
console.log(`Progress: ${result.state.progress}%`);
|
||||
console.log(`Message: ${result.state.message}`);
|
||||
|
||||
if (result.state.url) {
|
||||
console.log(`URL: ${result.state.url}`);
|
||||
}
|
||||
|
||||
console.log(`\nResources Created:`);
|
||||
console.log(` Project ID: ${result.state.resources.projectId || 'N/A'}`);
|
||||
console.log(` Environment ID: ${result.state.resources.environmentId || 'N/A'}`);
|
||||
console.log(` Application ID: ${result.state.resources.applicationId || 'N/A'}`);
|
||||
console.log(` Domain ID: ${result.state.resources.domainId || 'N/A'}`);
|
||||
|
||||
console.log(`\nTimestamps:`);
|
||||
console.log(` Started: ${result.state.timestamps.started}`);
|
||||
console.log(` Completed: ${result.state.timestamps.completed || 'N/A'}`);
|
||||
|
||||
if (result.state.error) {
|
||||
console.log(`\nError Details:`);
|
||||
console.log(` Phase: ${result.state.error.phase}`);
|
||||
console.log(` Message: ${result.state.error.message}`);
|
||||
if (result.state.error.code) {
|
||||
console.log(` Code: ${result.state.error.code}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nCircuit Breaker State: ${client.getCircuitBreakerState()}`);
|
||||
|
||||
console.log(`\n═══════════════════════════════════════`);
|
||||
console.log(' Deployment Logs');
|
||||
console.log('═══════════════════════════════════════\n');
|
||||
|
||||
const logs = client.getLogs();
|
||||
logs.forEach(log => {
|
||||
const level = log.level.toUpperCase().padEnd(5);
|
||||
const phase = log.phase.padEnd(15);
|
||||
const action = log.action.padEnd(10);
|
||||
const duration = log.duration_ms ? ` (${log.duration_ms}ms)` : '';
|
||||
console.log(`[${level}] ${phase} ${action} ${log.message}${duration}`);
|
||||
if (log.error) {
|
||||
console.log(` Error: ${log.error.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Cleanup test resources
|
||||
if (result.success && result.state.resources.applicationId) {
|
||||
console.log('\n🧹 Cleaning up test resources...');
|
||||
try {
|
||||
await client.deleteApplication(result.state.resources.applicationId);
|
||||
console.log('✅ Test application deleted');
|
||||
|
||||
if (result.state.resources.projectId) {
|
||||
await client.deleteProject(result.state.resources.projectId);
|
||||
console.log('✅ Test project deleted');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Cleanup failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\n═══════════════════════════════════════');
|
||||
console.log(result.success ? ' ✅ Test PASSED' : ' ❌ Test FAILED');
|
||||
console.log('═══════════════════════════════════════\n');
|
||||
|
||||
process.exit(result.success ? 0 : 1);
|
||||
}
|
||||
|
||||
main().catch(error => {
|
||||
console.error('\n❌ Test execution failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,103 +0,0 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Unit tests for validation logic
|
||||
* Tests name validation without external dependencies
|
||||
*/
|
||||
|
||||
import { expect, test, describe } from 'bun:test';
|
||||
|
||||
// Extracted validation function for testing
|
||||
function validateStackName(name: string, reservedNames: 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' };
|
||||
}
|
||||
|
||||
if (reservedNames.includes(trimmedName)) {
|
||||
return { valid: false, error: `Name "${trimmedName}" is reserved` };
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
describe('validateStackName', () => {
|
||||
const reservedNames = ['admin', 'api', 'www', 'root', 'system', 'test', 'demo', 'portal'];
|
||||
|
||||
test('accepts valid names', () => {
|
||||
expect(validateStackName('john', reservedNames).valid).toBe(true);
|
||||
expect(validateStackName('alice-123', reservedNames).valid).toBe(true);
|
||||
expect(validateStackName('my-stack', reservedNames).valid).toBe(true);
|
||||
expect(validateStackName('abc', reservedNames).valid).toBe(true);
|
||||
expect(validateStackName('12345678901234567890', reservedNames).valid).toBe(true); // exactly 20 chars
|
||||
});
|
||||
|
||||
test('rejects empty or null names', () => {
|
||||
expect(validateStackName('', reservedNames).valid).toBe(false);
|
||||
expect(validateStackName(' ', reservedNames).valid).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects names too short or too long', () => {
|
||||
expect(validateStackName('ab', reservedNames).error).toContain('between 3 and 20');
|
||||
expect(validateStackName('a'.repeat(21), reservedNames).error).toContain('between 3 and 20');
|
||||
});
|
||||
|
||||
test('rejects invalid characters', () => {
|
||||
const result1 = validateStackName('john_doe', reservedNames);
|
||||
expect(result1.valid).toBe(false);
|
||||
expect(result1.error).toBe('Name can only contain lowercase letters, numbers, and hyphens');
|
||||
|
||||
const result2 = validateStackName('john.doe', reservedNames);
|
||||
expect(result2.valid).toBe(false);
|
||||
expect(result2.error).toBe('Name can only contain lowercase letters, numbers, and hyphens');
|
||||
|
||||
const result3 = validateStackName('john doe', reservedNames);
|
||||
expect(result3.valid).toBe(false);
|
||||
expect(result3.error).toBe('Name can only contain lowercase letters, numbers, and hyphens');
|
||||
|
||||
const result4 = validateStackName('john@example', reservedNames);
|
||||
expect(result4.valid).toBe(false);
|
||||
expect(result4.error).toBe('Name can only contain lowercase letters, numbers, and hyphens');
|
||||
});
|
||||
|
||||
test('rejects names with leading or trailing hyphens', () => {
|
||||
expect(validateStackName('-john', reservedNames).error).toContain('cannot start or end');
|
||||
expect(validateStackName('john-', reservedNames).error).toContain('cannot start or end');
|
||||
expect(validateStackName('-john-', reservedNames).error).toContain('cannot start or end');
|
||||
});
|
||||
|
||||
test('rejects reserved names', () => {
|
||||
expect(validateStackName('admin', reservedNames).error).toContain('reserved');
|
||||
expect(validateStackName('api', reservedNames).error).toContain('reserved');
|
||||
expect(validateStackName('www', reservedNames).error).toContain('reserved');
|
||||
expect(validateStackName('root', reservedNames).error).toContain('reserved');
|
||||
});
|
||||
|
||||
test('normalizes names (lowercase, trim)', () => {
|
||||
// Uppercase input gets normalized to lowercase
|
||||
const result1 = validateStackName(' John ', reservedNames);
|
||||
expect(result1.valid).toBe(true); // passes after trim and lowercase
|
||||
|
||||
// Already lowercase with spaces - should pass
|
||||
const result2 = validateStackName(' john ', reservedNames);
|
||||
expect(result2.valid).toBe(true);
|
||||
|
||||
// All uppercase - should pass after normalization
|
||||
const result3 = validateStackName('MYSTACK', reservedNames);
|
||||
expect(result3.valid).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
console.log('\n✅ Running unit tests...\n');
|
||||
Reference in New Issue
Block a user