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
This commit is contained in:
@@ -18,7 +18,13 @@ DOKPLOY_API_TOKEN=
|
|||||||
|
|
||||||
# Stack Configuration
|
# Stack Configuration
|
||||||
STACK_DOMAIN_SUFFIX=ai.flexinit.nl
|
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 Public IP (where DNS records should point)
|
||||||
TRAEFIK_IP=144.76.116.169
|
TRAEFIK_IP=144.76.116.169
|
||||||
|
|||||||
@@ -408,6 +408,22 @@ export class DokployProductionClient {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getApplicationsByEnvironmentId(environmentId: string): Promise<DokployApplication[]> {
|
||||||
|
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<DokployApplication | null> {
|
||||||
|
const apps = await this.getApplicationsByEnvironmentId(environmentId);
|
||||||
|
return apps.find(a => a.name === name) || null;
|
||||||
|
}
|
||||||
|
|
||||||
async createDomain(
|
async createDomain(
|
||||||
host: string,
|
host: string,
|
||||||
applicationId: string,
|
applicationId: string,
|
||||||
|
|||||||
110
src/index.ts
110
src/index.ts
@@ -80,7 +80,6 @@ async function deployStack(deploymentId: string): Promise<void> {
|
|||||||
|
|
||||||
const deployer = new ProductionDeployer(client, progressCallback);
|
const deployer = new ProductionDeployer(client, progressCallback);
|
||||||
|
|
||||||
// Execute deployment with production orchestrator
|
|
||||||
const result = await deployer.deploy({
|
const result = await deployer.deploy({
|
||||||
stackName: deployment.stackName,
|
stackName: deployment.stackName,
|
||||||
dockerImage: process.env.STACK_IMAGE || 'git.app.flexinit.nl/flexinit/agent-stack:latest',
|
dockerImage: process.env.STACK_IMAGE || 'git.app.flexinit.nl/flexinit/agent-stack:latest',
|
||||||
@@ -89,6 +88,8 @@ async function deployStack(deploymentId: string): Promise<void> {
|
|||||||
healthCheckTimeout: 180000,
|
healthCheckTimeout: 180000,
|
||||||
healthCheckInterval: 5000,
|
healthCheckInterval: 5000,
|
||||||
registryId: process.env.STACK_REGISTRY_ID,
|
registryId: process.env.STACK_REGISTRY_ID,
|
||||||
|
sharedProjectId: process.env.SHARED_PROJECT_ID,
|
||||||
|
sharedEnvironmentId: process.env.SHARED_ENVIRONMENT_ID,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Final update with logs
|
// Final update with logs
|
||||||
@@ -157,17 +158,29 @@ app.post('/api/deploy', async (c) => {
|
|||||||
|
|
||||||
const normalizedName = name.trim().toLowerCase();
|
const normalizedName = name.trim().toLowerCase();
|
||||||
|
|
||||||
// Check if name is already taken
|
|
||||||
const client = createProductionDokployClient();
|
const client = createProductionDokployClient();
|
||||||
const projectName = `ai-stack-${normalizedName}`;
|
const sharedEnvironmentId = process.env.SHARED_ENVIRONMENT_ID;
|
||||||
const existingProject = await client.findProjectByName(projectName);
|
const appName = `opencode-${normalizedName}`;
|
||||||
|
|
||||||
if (existingProject) {
|
if (sharedEnvironmentId) {
|
||||||
return c.json({
|
const existingApp = await client.findApplicationByName(sharedEnvironmentId, appName);
|
||||||
success: false,
|
if (existingApp) {
|
||||||
error: 'Name already taken',
|
return c.json({
|
||||||
code: 'NAME_EXISTS'
|
success: false,
|
||||||
}, 409);
|
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
|
// Create deployment state
|
||||||
@@ -339,7 +352,6 @@ app.get('/api/check/:name', async (c) => {
|
|||||||
try {
|
try {
|
||||||
const name = c.req.param('name');
|
const name = c.req.param('name');
|
||||||
|
|
||||||
// Validate name format
|
|
||||||
const validation = validateStackName(name);
|
const validation = validateStackName(name);
|
||||||
if (!validation.valid) {
|
if (!validation.valid) {
|
||||||
return c.json({
|
return c.json({
|
||||||
@@ -350,14 +362,22 @@ app.get('/api/check/:name', async (c) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const normalizedName = name.trim().toLowerCase();
|
const normalizedName = name.trim().toLowerCase();
|
||||||
|
|
||||||
// Check if project exists
|
|
||||||
const client = createProductionDokployClient();
|
const client = createProductionDokployClient();
|
||||||
const projectName = `ai-stack-${normalizedName}`;
|
const sharedEnvironmentId = process.env.SHARED_ENVIRONMENT_ID;
|
||||||
const existingProject = await client.findProjectByName(projectName);
|
|
||||||
|
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({
|
return c.json({
|
||||||
available: !existingProject,
|
available: !exists,
|
||||||
valid: true,
|
valid: true,
|
||||||
name: normalizedName
|
name: normalizedName
|
||||||
});
|
});
|
||||||
@@ -376,29 +396,51 @@ app.delete('/api/stack/:name', async (c) => {
|
|||||||
try {
|
try {
|
||||||
const name = c.req.param('name');
|
const name = c.req.param('name');
|
||||||
const normalizedName = name.trim().toLowerCase();
|
const normalizedName = name.trim().toLowerCase();
|
||||||
const projectName = `ai-stack-${normalizedName}`;
|
|
||||||
|
|
||||||
const client = createProductionDokployClient();
|
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({
|
return c.json({
|
||||||
success: false,
|
success: true,
|
||||||
error: 'Stack not found',
|
message: `Stack ${normalizedName} deleted successfully`,
|
||||||
code: 'NOT_FOUND'
|
deletedApplicationId: existingApp.applicationId
|
||||||
}, 404);
|
});
|
||||||
|
} 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) {
|
} catch (error) {
|
||||||
console.error('Delete endpoint error:', error);
|
console.error('Delete endpoint error:', error);
|
||||||
return c.json({
|
return c.json({
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export interface DeploymentConfig {
|
|||||||
healthCheckTimeout?: number;
|
healthCheckTimeout?: number;
|
||||||
healthCheckInterval?: number;
|
healthCheckInterval?: number;
|
||||||
registryId?: string;
|
registryId?: string;
|
||||||
|
sharedProjectId?: string;
|
||||||
|
sharedEnvironmentId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DeploymentState {
|
export interface DeploymentState {
|
||||||
@@ -179,17 +181,27 @@ export class ProductionDeployer {
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
state.phase = 'creating_project';
|
state.phase = 'creating_project';
|
||||||
state.progress = 10;
|
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}`;
|
const projectName = `ai-stack-${config.stackName}`;
|
||||||
|
|
||||||
// Idempotency: Check if project already exists
|
|
||||||
const existingProject = await this.client.findProjectByName(projectName);
|
const existingProject = await this.client.findProjectByName(projectName);
|
||||||
|
|
||||||
if (existingProject) {
|
if (existingProject) {
|
||||||
console.log(`Project ${projectName} already exists, reusing...`);
|
console.log(`Project ${projectName} already exists, reusing...`);
|
||||||
state.resources.projectId = existingProject.project.projectId;
|
state.resources.projectId = existingProject.project.projectId;
|
||||||
// Also capture environment ID if available
|
|
||||||
if (existingProject.environmentId) {
|
if (existingProject.environmentId) {
|
||||||
state.resources.environmentId = existingProject.environmentId;
|
state.resources.environmentId = existingProject.environmentId;
|
||||||
}
|
}
|
||||||
@@ -197,7 +209,6 @@ export class ProductionDeployer {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new project (returns both project and environment)
|
|
||||||
const response = await this.client.createProject(
|
const response = await this.client.createProject(
|
||||||
projectName,
|
projectName,
|
||||||
`AI Stack for ${config.stackName}`
|
`AI Stack for ${config.stackName}`
|
||||||
@@ -413,6 +424,8 @@ export class ProductionDeployer {
|
|||||||
state.phase = 'rolling_back';
|
state.phase = 'rolling_back';
|
||||||
state.message = 'Rolling back deployment';
|
state.message = 'Rolling back deployment';
|
||||||
|
|
||||||
|
const isSharedProject = !!(process.env.SHARED_PROJECT_ID || process.env.SHARED_ENVIRONMENT_ID);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (state.resources.domainId) {
|
if (state.resources.domainId) {
|
||||||
console.log(`Rolling back: deleting domain ${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}`);
|
console.log(`Rolling back: deleting project ${state.resources.projectId}`);
|
||||||
try {
|
try {
|
||||||
await this.client.deleteProject(state.resources.projectId);
|
await this.client.deleteProject(state.resources.projectId);
|
||||||
|
|||||||
Reference in New Issue
Block a user