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:
Oussama Douhou
2026-01-09 23:33:39 +01:00
commit 19845880e3
46 changed files with 9875 additions and 0 deletions

View 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);
}