- 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
520 lines
14 KiB
TypeScript
520 lines
14 KiB
TypeScript
/**
|
|
* Production-Grade Dokploy API Client
|
|
*
|
|
* Features:
|
|
* - Correct API parameter usage (environmentId, not projectId)
|
|
* - Retry logic with exponential backoff
|
|
* - Circuit breaker pattern
|
|
* - Comprehensive error handling
|
|
* - Idempotency checks
|
|
* - Structured logging
|
|
* - Rollback mechanisms
|
|
*/
|
|
|
|
interface DokployProject {
|
|
projectId: string;
|
|
name: string;
|
|
description?: string;
|
|
createdAt: string;
|
|
organizationId: string;
|
|
env: string;
|
|
}
|
|
|
|
interface DokployEnvironment {
|
|
environmentId: string;
|
|
name: string;
|
|
projectId: string;
|
|
isDefault: boolean;
|
|
env?: string;
|
|
createdAt: string;
|
|
}
|
|
|
|
interface DokployApplication {
|
|
applicationId: string;
|
|
name: string;
|
|
environmentId: string;
|
|
applicationStatus: 'idle' | 'running' | 'done' | 'error';
|
|
dockerImage?: string;
|
|
sourceType?: 'docker' | 'git' | 'github';
|
|
createdAt: string;
|
|
}
|
|
|
|
interface DokployDomain {
|
|
domainId: string;
|
|
host: string;
|
|
applicationId: string;
|
|
https: boolean;
|
|
port: number;
|
|
}
|
|
|
|
interface RetryConfig {
|
|
maxRetries: number;
|
|
initialDelay: number;
|
|
maxDelay: number;
|
|
multiplier: number;
|
|
}
|
|
|
|
interface CircuitBreakerConfig {
|
|
threshold: number;
|
|
timeout: number;
|
|
halfOpenAttempts: number;
|
|
}
|
|
|
|
interface LogEntry {
|
|
timestamp: string;
|
|
level: 'info' | 'warn' | 'error';
|
|
phase: string;
|
|
action: string;
|
|
message: string;
|
|
duration_ms?: number;
|
|
error?: {
|
|
code: string;
|
|
message: string;
|
|
apiResponse?: unknown;
|
|
};
|
|
}
|
|
|
|
class CircuitBreaker {
|
|
private failures = 0;
|
|
private lastFailureTime: number | null = null;
|
|
private state: 'closed' | 'open' | 'half-open' = 'closed';
|
|
|
|
constructor(private config: CircuitBreakerConfig) {}
|
|
|
|
async execute<T>(fn: () => Promise<T>): Promise<T> {
|
|
if (this.state === 'open') {
|
|
const now = Date.now();
|
|
if (this.lastFailureTime && now - this.lastFailureTime < this.config.timeout) {
|
|
throw new Error('Circuit breaker is OPEN - too many failures');
|
|
}
|
|
this.state = 'half-open';
|
|
}
|
|
|
|
try {
|
|
const result = await fn();
|
|
this.onSuccess();
|
|
return result;
|
|
} catch (error) {
|
|
this.onFailure();
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
private onSuccess() {
|
|
this.failures = 0;
|
|
this.state = 'closed';
|
|
}
|
|
|
|
private onFailure() {
|
|
this.failures++;
|
|
this.lastFailureTime = Date.now();
|
|
if (this.failures >= this.config.threshold) {
|
|
this.state = 'open';
|
|
}
|
|
}
|
|
|
|
getState() {
|
|
return this.state;
|
|
}
|
|
}
|
|
|
|
export class DokployProductionClient {
|
|
private baseUrl: string;
|
|
private apiToken: string;
|
|
private retryConfig: RetryConfig;
|
|
private circuitBreaker: CircuitBreaker;
|
|
private logs: LogEntry[] = [];
|
|
|
|
constructor(
|
|
baseUrl: string,
|
|
apiToken: string,
|
|
retryConfig?: Partial<RetryConfig>,
|
|
circuitBreakerConfig?: Partial<CircuitBreakerConfig>
|
|
) {
|
|
if (!baseUrl) throw new Error('DOKPLOY_URL is required');
|
|
if (!apiToken) throw new Error('DOKPLOY_API_TOKEN is required');
|
|
|
|
this.baseUrl = baseUrl.replace(/\/$/, '');
|
|
this.apiToken = apiToken;
|
|
|
|
this.retryConfig = {
|
|
maxRetries: retryConfig?.maxRetries ?? 5,
|
|
initialDelay: retryConfig?.initialDelay ?? 1000,
|
|
maxDelay: retryConfig?.maxDelay ?? 16000,
|
|
multiplier: retryConfig?.multiplier ?? 2,
|
|
};
|
|
|
|
this.circuitBreaker = new CircuitBreaker({
|
|
threshold: circuitBreakerConfig?.threshold ?? 5,
|
|
timeout: circuitBreakerConfig?.timeout ?? 60000,
|
|
halfOpenAttempts: circuitBreakerConfig?.halfOpenAttempts ?? 3,
|
|
});
|
|
}
|
|
|
|
private log(entry: Omit<LogEntry, 'timestamp'>) {
|
|
const logEntry: LogEntry = {
|
|
...entry,
|
|
timestamp: new Date().toISOString(),
|
|
};
|
|
this.logs.push(logEntry);
|
|
|
|
// Output to console for monitoring
|
|
const level = entry.level.toUpperCase();
|
|
const msg = `[${level}] ${entry.phase}/${entry.action}: ${entry.message}`;
|
|
if (entry.level === 'error') {
|
|
console.error(msg, entry.error);
|
|
} else {
|
|
console.log(msg);
|
|
}
|
|
}
|
|
|
|
getLogs(): LogEntry[] {
|
|
return [...this.logs];
|
|
}
|
|
|
|
private async sleep(ms: number): Promise<void> {
|
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
}
|
|
|
|
private isRetryableError(error: unknown, statusCode?: number): boolean {
|
|
// Retry on network errors and 5xx server errors
|
|
if (statusCode && statusCode >= 500) return true;
|
|
if (statusCode === 429) return true; // Rate limiting
|
|
if (error instanceof TypeError && error.message.includes('fetch')) return true;
|
|
return false;
|
|
}
|
|
|
|
private async request<T>(
|
|
method: string,
|
|
endpoint: string,
|
|
body?: unknown,
|
|
phase = 'api',
|
|
action = 'request'
|
|
): Promise<T> {
|
|
const url = `${this.baseUrl}/api${endpoint}`;
|
|
const startTime = Date.now();
|
|
|
|
return this.circuitBreaker.execute(async () => {
|
|
let lastError: Error | null = null;
|
|
let delay = this.retryConfig.initialDelay;
|
|
|
|
for (let attempt = 0; attempt <= this.retryConfig.maxRetries; attempt++) {
|
|
try {
|
|
const response = await fetch(url, {
|
|
method,
|
|
headers: {
|
|
'x-api-key': this.apiToken,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
|
|
const text = await response.text();
|
|
|
|
if (!response.ok) {
|
|
let errorMessage = `API error (${response.status})`;
|
|
let apiResponse: unknown;
|
|
|
|
try {
|
|
apiResponse = JSON.parse(text);
|
|
errorMessage = (apiResponse as { message?: string }).message || errorMessage;
|
|
} catch {
|
|
errorMessage = text || errorMessage;
|
|
}
|
|
|
|
// Don't retry 4xx errors (except 429)
|
|
if (response.status >= 400 && response.status < 500 && response.status !== 429) {
|
|
const duration = Date.now() - startTime;
|
|
this.log({
|
|
level: 'error',
|
|
phase,
|
|
action,
|
|
message: `Request failed: ${errorMessage}`,
|
|
duration_ms: duration,
|
|
error: {
|
|
code: `HTTP_${response.status}`,
|
|
message: errorMessage,
|
|
apiResponse,
|
|
},
|
|
});
|
|
throw new Error(errorMessage);
|
|
}
|
|
|
|
// Retry 5xx and 429
|
|
if (attempt < this.retryConfig.maxRetries && this.isRetryableError(null, response.status)) {
|
|
this.log({
|
|
level: 'warn',
|
|
phase,
|
|
action,
|
|
message: `Retrying after ${response.status} (attempt ${attempt + 1}/${this.retryConfig.maxRetries})`,
|
|
});
|
|
await this.sleep(delay);
|
|
delay = Math.min(delay * this.retryConfig.multiplier, this.retryConfig.maxDelay);
|
|
continue;
|
|
}
|
|
|
|
throw new Error(errorMessage);
|
|
}
|
|
|
|
const duration = Date.now() - startTime;
|
|
this.log({
|
|
level: 'info',
|
|
phase,
|
|
action,
|
|
message: 'Request successful',
|
|
duration_ms: duration,
|
|
});
|
|
|
|
return text ? (JSON.parse(text) as T) : ({} as T);
|
|
|
|
} catch (error) {
|
|
lastError = error instanceof Error ? error : new Error(String(error));
|
|
|
|
if (attempt < this.retryConfig.maxRetries && this.isRetryableError(error)) {
|
|
this.log({
|
|
level: 'warn',
|
|
phase,
|
|
action,
|
|
message: `Retrying after error (attempt ${attempt + 1}/${this.retryConfig.maxRetries}): ${lastError.message}`,
|
|
});
|
|
await this.sleep(delay);
|
|
delay = Math.min(delay * this.retryConfig.multiplier, this.retryConfig.maxDelay);
|
|
continue;
|
|
}
|
|
|
|
const duration = Date.now() - startTime;
|
|
this.log({
|
|
level: 'error',
|
|
phase,
|
|
action,
|
|
message: `Request failed: ${lastError.message}`,
|
|
duration_ms: duration,
|
|
error: {
|
|
code: 'REQUEST_FAILED',
|
|
message: lastError.message,
|
|
},
|
|
});
|
|
|
|
throw lastError;
|
|
}
|
|
}
|
|
|
|
throw lastError || new Error('Request failed after all retries');
|
|
});
|
|
}
|
|
|
|
// ==================== PRODUCTION API METHODS ====================
|
|
|
|
async createProject(
|
|
name: string,
|
|
description?: string
|
|
): Promise<{ project: DokployProject; environment: DokployEnvironment }> {
|
|
const response = await this.request<{ project: DokployProject; environment: DokployEnvironment }>(
|
|
'POST',
|
|
'/project.create',
|
|
{ name, description },
|
|
'project',
|
|
'create'
|
|
);
|
|
// API returns both project and default environment in one call
|
|
return response;
|
|
}
|
|
|
|
async getProjects(): Promise<DokployProject[]> {
|
|
return this.request<DokployProject[]>('GET', '/project.all', undefined, 'project', 'list');
|
|
}
|
|
|
|
async findProjectByName(name: string): Promise<{ project: DokployProject; environmentId?: string } | null> {
|
|
const projects = await this.getProjects();
|
|
const project = projects.find(p => p.name === name);
|
|
|
|
if (!project) return null;
|
|
|
|
// Try to get environment ID for this project
|
|
try {
|
|
const env = await this.getDefaultEnvironment(project.projectId);
|
|
return { project, environmentId: env.environmentId };
|
|
} catch {
|
|
// If we can't get environment, just return project
|
|
return { project };
|
|
}
|
|
}
|
|
|
|
async getEnvironmentsByProjectId(projectId: string): Promise<DokployEnvironment[]> {
|
|
return this.request<DokployEnvironment[]>(
|
|
'GET',
|
|
`/environment.byProjectId?projectId=${projectId}`,
|
|
undefined,
|
|
'environment',
|
|
'query'
|
|
);
|
|
}
|
|
|
|
async getDefaultEnvironment(projectId: string): Promise<DokployEnvironment> {
|
|
const environments = await this.getEnvironmentsByProjectId(projectId);
|
|
const defaultEnv = environments.find(e => e.isDefault);
|
|
|
|
if (!defaultEnv) {
|
|
throw new Error(`No default environment found for project ${projectId}`);
|
|
}
|
|
|
|
return defaultEnv;
|
|
}
|
|
|
|
async createApplication(name: string, environmentId: string): Promise<DokployApplication> {
|
|
return this.request<DokployApplication>(
|
|
'POST',
|
|
'/application.create',
|
|
{ name, environmentId },
|
|
'application',
|
|
'create'
|
|
);
|
|
}
|
|
|
|
async updateApplication(
|
|
applicationId: string,
|
|
updates: {
|
|
dockerImage?: string;
|
|
sourceType?: 'docker' | 'git' | 'github';
|
|
[key: string]: unknown;
|
|
}
|
|
): Promise<DokployApplication> {
|
|
return this.request<DokployApplication>(
|
|
'POST',
|
|
'/application.update',
|
|
{ applicationId, ...updates },
|
|
'application',
|
|
'update'
|
|
);
|
|
}
|
|
|
|
async setApplicationEnv(applicationId: string, env: string): Promise<void> {
|
|
await this.request(
|
|
'POST',
|
|
'/application.update',
|
|
{ applicationId, env },
|
|
'application',
|
|
'set-env'
|
|
);
|
|
}
|
|
|
|
async getApplication(applicationId: string): Promise<DokployApplication> {
|
|
return this.request<DokployApplication>(
|
|
'GET',
|
|
`/application.one?applicationId=${applicationId}`,
|
|
undefined,
|
|
'application',
|
|
'get'
|
|
);
|
|
}
|
|
|
|
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(
|
|
host: string,
|
|
applicationId: string,
|
|
https = true,
|
|
port = 8080
|
|
): Promise<DokployDomain> {
|
|
return this.request<DokployDomain>(
|
|
'POST',
|
|
'/domain.create',
|
|
{ host, applicationId, https, port },
|
|
'domain',
|
|
'create'
|
|
);
|
|
}
|
|
|
|
async deployApplication(applicationId: string): Promise<void> {
|
|
await this.request(
|
|
'POST',
|
|
'/application.deploy',
|
|
{ applicationId },
|
|
'deploy',
|
|
'trigger'
|
|
);
|
|
}
|
|
|
|
async deleteApplication(applicationId: string): Promise<void> {
|
|
await this.request(
|
|
'POST',
|
|
'/application.delete',
|
|
{ applicationId },
|
|
'application',
|
|
'delete'
|
|
);
|
|
}
|
|
|
|
async deleteProject(projectId: string): Promise<void> {
|
|
await this.request(
|
|
'POST',
|
|
'/project.remove',
|
|
{ projectId },
|
|
'project',
|
|
'delete'
|
|
);
|
|
}
|
|
|
|
async deleteDomain(domainId: string): Promise<void> {
|
|
await this.request(
|
|
'POST',
|
|
'/domain.delete',
|
|
{ domainId },
|
|
'domain',
|
|
'delete'
|
|
);
|
|
}
|
|
|
|
async createMount(
|
|
applicationId: string,
|
|
volumeName: string,
|
|
mountPath: string
|
|
): Promise<{ mountId: string }> {
|
|
return this.request<{ mountId: string }>(
|
|
'POST',
|
|
'/mounts.create',
|
|
{
|
|
type: 'volume',
|
|
volumeName,
|
|
mountPath,
|
|
serviceId: applicationId,
|
|
serviceType: 'application',
|
|
},
|
|
'mount',
|
|
'create'
|
|
);
|
|
}
|
|
|
|
getCircuitBreakerState() {
|
|
return this.circuitBreaker.getState();
|
|
}
|
|
}
|
|
|
|
export function createProductionDokployClient(): DokployProductionClient {
|
|
const baseUrl = process.env.DOKPLOY_URL;
|
|
const apiToken = process.env.DOKPLOY_API_TOKEN;
|
|
|
|
if (!baseUrl) {
|
|
throw new Error('DOKPLOY_URL environment variable is not set');
|
|
}
|
|
if (!apiToken) {
|
|
throw new Error('DOKPLOY_API_TOKEN environment variable is not set');
|
|
}
|
|
|
|
return new DokployProductionClient(baseUrl, apiToken);
|
|
}
|