fix(ci): trigger workflow on main branch to enable :latest tag
Changes:
- Create Gitea workflow for ai-stack-deployer
- Trigger on main branch (default branch)
- Use oussamadouhou + REGISTRY_TOKEN for authentication
- Build from ./Dockerfile
This enables :latest tag creation via {{is_default_branch}}.
Tags created:
- git.app.flexinit.nl/oussamadouhou/ai-stack-deployer:latest
- git.app.flexinit.nl/oussamadouhou/ai-stack-deployer:<sha>
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
463
src/api/dokploy-production.ts
Normal file
463
src/api/dokploy-production.ts
Normal file
@@ -0,0 +1,463 @@
|
||||
/**
|
||||
* 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 getApplication(applicationId: string): Promise<DokployApplication> {
|
||||
return this.request<DokployApplication>(
|
||||
'GET',
|
||||
`/application.one?applicationId=${applicationId}`,
|
||||
undefined,
|
||||
'application',
|
||||
'get'
|
||||
);
|
||||
}
|
||||
|
||||
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'
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user