From 21161c6554bd87079ad5386e70523a11ecbc5b5f Mon Sep 17 00:00:00 2001 From: Oussama Douhou Date: Sun, 11 Jan 2026 01:05:14 +0100 Subject: [PATCH] feat: deploy all stacks to shared ai-stack-portal project - Add SHARED_PROJECT_ID and SHARED_ENVIRONMENT_ID env vars - Add findApplicationByName to Dokploy client for app-based lookup - Update production-deployer to use shared project instead of creating new ones - Update name availability check to query apps in shared environment - Update delete endpoint to remove apps from shared project - Rollback no longer deletes shared project (only app/domain) - Backward compatible: falls back to per-project if env vars not set --- .env.example | 8 +- src/api/dokploy-production.ts | 16 ++++ src/index.ts | 110 ++++++++++++++++-------- src/orchestrator/production-deployer.ts | 25 ++++-- 4 files changed, 118 insertions(+), 41 deletions(-) diff --git a/.env.example b/.env.example index 2f676c9..4706067 100644 --- a/.env.example +++ b/.env.example @@ -18,7 +18,13 @@ DOKPLOY_API_TOKEN= # Stack Configuration STACK_DOMAIN_SUFFIX=ai.flexinit.nl -STACK_IMAGE=git.app.flexinit.nl/oussamadouhou/oh-my-opencode-free:latest +STACK_IMAGE=git.app.flexinit.nl/flexinit/agent-stack:latest +STACK_REGISTRY_ID= + +# Shared Project Deployment (all stacks deploy to one Dokploy project) +# Project: ai-stack-portal, Environment: deployments +SHARED_PROJECT_ID=2y2Glhz5Wy0dBNf6BOR_- +SHARED_ENVIRONMENT_ID=RqE9OFMdLwkzN7pif1xN8 # Traefik Public IP (where DNS records should point) TRAEFIK_IP=144.76.116.169 diff --git a/src/api/dokploy-production.ts b/src/api/dokploy-production.ts index 5587e93..4b2bedb 100644 --- a/src/api/dokploy-production.ts +++ b/src/api/dokploy-production.ts @@ -408,6 +408,22 @@ export class DokployProductionClient { ); } + async getApplicationsByEnvironmentId(environmentId: string): Promise { + const env = await this.request<{ applications: DokployApplication[] }>( + 'GET', + `/environment.one?environmentId=${environmentId}`, + undefined, + 'environment', + 'get-apps' + ); + return env.applications || []; + } + + async findApplicationByName(environmentId: string, name: string): Promise { + const apps = await this.getApplicationsByEnvironmentId(environmentId); + return apps.find(a => a.name === name) || null; + } + async createDomain( host: string, applicationId: string, diff --git a/src/index.ts b/src/index.ts index c54e0d2..97bcec6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -80,7 +80,6 @@ async function deployStack(deploymentId: string): Promise { const deployer = new ProductionDeployer(client, progressCallback); - // Execute deployment with production orchestrator const result = await deployer.deploy({ stackName: deployment.stackName, dockerImage: process.env.STACK_IMAGE || 'git.app.flexinit.nl/flexinit/agent-stack:latest', @@ -89,6 +88,8 @@ async function deployStack(deploymentId: string): Promise { healthCheckTimeout: 180000, healthCheckInterval: 5000, registryId: process.env.STACK_REGISTRY_ID, + sharedProjectId: process.env.SHARED_PROJECT_ID, + sharedEnvironmentId: process.env.SHARED_ENVIRONMENT_ID, }); // Final update with logs @@ -157,17 +158,29 @@ app.post('/api/deploy', async (c) => { 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); + const sharedEnvironmentId = process.env.SHARED_ENVIRONMENT_ID; + const appName = `opencode-${normalizedName}`; - if (existingProject) { - return c.json({ - success: false, - error: 'Name already taken', - code: 'NAME_EXISTS' - }, 409); + 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 @@ -339,7 +352,6 @@ 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({ @@ -350,14 +362,22 @@ app.get('/api/check/:name', async (c) => { } const normalizedName = name.trim().toLowerCase(); - - // Check if project exists const client = createProductionDokployClient(); - const projectName = `ai-stack-${normalizedName}`; - const existingProject = await client.findProjectByName(projectName); + 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: !existingProject, + available: !exists, valid: true, name: normalizedName }); @@ -376,29 +396,51 @@ app.delete('/api/stack/:name', async (c) => { try { const name = c.req.param('name'); const normalizedName = name.trim().toLowerCase(); - const projectName = `ai-stack-${normalizedName}`; - const client = createProductionDokployClient(); - const existingProject = await client.findProjectByName(projectName); + 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); - if (!existingProject) { return c.json({ - success: false, - error: 'Stack not found', - code: 'NOT_FOUND' - }, 404); + 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 + }); } - 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({ diff --git a/src/orchestrator/production-deployer.ts b/src/orchestrator/production-deployer.ts index c4d0ff0..50df463 100644 --- a/src/orchestrator/production-deployer.ts +++ b/src/orchestrator/production-deployer.ts @@ -20,6 +20,8 @@ export interface DeploymentConfig { healthCheckTimeout?: number; healthCheckInterval?: number; registryId?: string; + sharedProjectId?: string; + sharedEnvironmentId?: string; } export interface DeploymentState { @@ -179,17 +181,27 @@ export class ProductionDeployer { ): Promise { state.phase = 'creating_project'; state.progress = 10; - state.message = 'Creating or finding project'; + state.message = 'Using shared project for deployment'; + // Use shared project and environment IDs from config or env vars + const sharedProjectId = config.sharedProjectId || process.env.SHARED_PROJECT_ID; + const sharedEnvironmentId = config.sharedEnvironmentId || process.env.SHARED_ENVIRONMENT_ID; + + if (sharedProjectId && sharedEnvironmentId) { + console.log(`Using shared project: ${sharedProjectId}, environment: ${sharedEnvironmentId}`); + state.resources.projectId = sharedProjectId; + state.resources.environmentId = sharedEnvironmentId; + state.message = 'Using shared project'; + return; + } + + // Fallback to legacy behavior if shared IDs not configured const projectName = `ai-stack-${config.stackName}`; - - // Idempotency: Check if project already exists const existingProject = await this.client.findProjectByName(projectName); if (existingProject) { console.log(`Project ${projectName} already exists, reusing...`); state.resources.projectId = existingProject.project.projectId; - // Also capture environment ID if available if (existingProject.environmentId) { state.resources.environmentId = existingProject.environmentId; } @@ -197,7 +209,6 @@ export class ProductionDeployer { return; } - // Create new project (returns both project and environment) const response = await this.client.createProject( projectName, `AI Stack for ${config.stackName}` @@ -413,6 +424,8 @@ export class ProductionDeployer { state.phase = 'rolling_back'; state.message = 'Rolling back deployment'; + const isSharedProject = !!(process.env.SHARED_PROJECT_ID || process.env.SHARED_ENVIRONMENT_ID); + try { if (state.resources.domainId) { console.log(`Rolling back: deleting domain ${state.resources.domainId}`); @@ -432,7 +445,7 @@ export class ProductionDeployer { } } - if (state.resources.projectId) { + if (state.resources.projectId && !isSharedProject) { console.log(`Rolling back: deleting project ${state.resources.projectId}`); try { await this.client.deleteProject(state.resources.projectId);