From 617f00b7154ef51f397c85f2857dce849e359e5a Mon Sep 17 00:00:00 2001 From: Z User Date: Wed, 28 Oct 2026 23:05:53 +0000 Subject: [PATCH] fix: rewrite Dokploy client with correct API format (tRPC + x-api-key) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Verified endpoints from @dokploy/mcp source code: - Auth: x-api-key header (not Authorization: Bearer) - Base URL: {url}/api - application.create → needs environmentId (not projectId) - Git provider saved separately: saveGiteaProvider / saveGithubProvider / saveGitProvider - Environment vars saved via saveEnvironment - Deploy is a separate call after create - Domain attach via domain.create - Provisioning flow now multi-step: 1. application.create 2. saveGiteaProvider or saveGithubProvider (based on gitRepo URL) 3. saveEnvironment (env vars) 4. application.deploy 5. domain.create (optional, non-fatal) - Removed database provisioning (Dokploy apps use their own DB via env vars) - Teardown simplified: deleteDomain → deleteApplication - Updated instances route + cleanup route to match new API --- src/app/api/aos/instances/[id]/route.ts | 3 +- src/app/api/aos/instances/cleanup/route.ts | 3 +- src/app/api/aos/instances/route.ts | 11 +- src/lib/dokploy.ts | 387 +++++++++++---------- 4 files changed, 209 insertions(+), 195 deletions(-) diff --git a/src/app/api/aos/instances/[id]/route.ts b/src/app/api/aos/instances/[id]/route.ts index bec2589..cdb9e44 100644 --- a/src/app/api/aos/instances/[id]/route.ts +++ b/src/app/api/aos/instances/[id]/route.ts @@ -184,8 +184,7 @@ export async function DELETE(_req: Request, { params }: { params: Promise<{ id: try { const result = await dokploy.teardownInstance({ dokployAppId: instance.dokployAppId ?? undefined, - dokployDbId: instance.dokployDbId ?? undefined, - dnsDomainId: meta.dokployDomainId, + dokployDomainId: meta.dokployDomainId, }) steps.push(...result.steps) await db.instanceEvent.create({ diff --git a/src/app/api/aos/instances/cleanup/route.ts b/src/app/api/aos/instances/cleanup/route.ts index c76eae3..3f50ac0 100644 --- a/src/app/api/aos/instances/cleanup/route.ts +++ b/src/app/api/aos/instances/cleanup/route.ts @@ -88,8 +88,7 @@ export async function POST(req: Request) { try { const r = await dokploy.teardownInstance({ dokployAppId: inst.dokployAppId ?? undefined, - dokployDbId: inst.dokployDbId ?? undefined, - dnsDomainId: meta.dokployDomainId, + dokployDomainId: meta.dokployDomainId, }) steps.push(...r.steps) } catch (e) { diff --git a/src/app/api/aos/instances/route.ts b/src/app/api/aos/instances/route.ts index 3b7bd49..1366162 100644 --- a/src/app/api/aos/instances/route.ts +++ b/src/app/api/aos/instances/route.ts @@ -158,7 +158,7 @@ export async function POST(req: Request) { gitRepo, gitBranch, envVars: instanceEnv, - databaseName: `${product}_${subdomain}`.replace(/-/g, '_').slice(0, 40), + gitProvider: gitRepo.includes('github.com') ? 'github' : 'gitea', cpuLimit: plan === 'enterprise' ? '2' : plan === 'pro' ? '1' : '0.5', memoryLimit: plan === 'enterprise' ? '2048m' : plan === 'pro' ? '1024m' : '512m', } @@ -183,8 +183,7 @@ export async function POST(req: Request) { result: 'ok', details: JSON.stringify({ appId: result.app.applicationId, - dbId: result.database?.databaseId, - domainId: result.domain.domainId, + domainId: result.domain?.domainId, url: result.url, }), }, @@ -227,15 +226,11 @@ export async function POST(req: Request) { data: { status: isDemo ? 'demo_active' : 'active', dokployAppId: result.app.applicationId, - dokployDbId: result.database?.databaseId ?? null, - dokployDbName: result.database?.database ?? null, - dokployDbUser: result.database?.username ?? null, - dokployDbPassword: result.database?.password ?? null, dnsRecordId, metadata: JSON.stringify({ gitRepo, gitBranch, - dokployDomainId: result.domain.domainId, + dokployDomainId: result.domain?.domainId ?? null, url: result.url, provisionedAt: now.toISOString(), }), diff --git a/src/lib/dokploy.ts b/src/lib/dokploy.ts index 047af45..1664aaf 100644 --- a/src/lib/dokploy.ts +++ b/src/lib/dokploy.ts @@ -1,102 +1,90 @@ /** - * Dokploy API Client - * ------------------- - * Manages tenant instances on the Dokploy server: create applications from - * a Git repo, deploy, attach domains, create databases, suspend/delete. + * Dokploy API Client (v2 — correct tRPC-based API) + * ----------------------------------------------- + * Manages tenant instances on the Dokploy server. + * + * API format (verified from @dokploy/mcp source): + * - Base URL: {DOKPLOY_URL}/api + * - Auth: x-api-key header (NOT Authorization: Bearer) + * - Endpoints: /application.create, /application.deploy, /application.delete, etc. + * - Application creation is multi-step: + * 1. POST /application.create { name, environmentId, sourceType: 'gitea' } + * 2. POST /application.saveGiteaProvider { applicationId, giteaOwner, giteaRepository, giteaBranch, giteaBuildPath } + * (or saveGitProvider for generic git, saveGithubProvider for github) + * 3. POST /application.saveEnvironment { applicationId, env: [...] } + * 4. POST /application.deploy { applicationId } * * Config (env): - * DOKPLOY_URL - e.g. https://cp.project-url.eu - * DOKPLOY_API_KEY - bearer token - * DOKPLOY_SERVER_ID - the Dokploy server ID to deploy to (default "default") - * DOKPLOY_PROJECT_ID - the Dokploy project to put tenant apps under - * - * NOTE: Dokploy API endpoints should be verified against the current API docs - * at implementation time. The endpoints below are based on the Dokploy REST API. - * See: https://docs.dokploy.com/docs/api + * DOKPLOY_URL - e.g. https://cp.project-url.eu + * DOKPLOY_API_KEY - x-api-key value + * DOKPLOY_ENVIRONMENT_ID - the Dokploy environment ID to create apps under + * DOKPLOY_SERVER_ID - (optional) specific server, null = default */ export interface DokployConfig { url: string apiKey: string - serverId?: string - projectId?: string + environmentId?: string + serverId?: string | null } export interface DokployApp { applicationId: string name: string + appName?: string status?: string - url?: string + previewDomain?: string createdAt?: string } -export interface DokployDatabase { - databaseId: string - name: string - type: string // postgres|mysql|redis|... - host: string - port: number - database: string - username: string - password: string - externalUrl?: string -} - export interface DokployDomain { domainId: string hostName: string applicationId: string https: boolean - certificateStatus?: string } export interface ProvisionInstanceInput { name: string subdomain: string - gitRepo: string // e.g. https://github.com/cetinmustafa83/restov1 - gitBranch: string // e.g. main - buildPath?: string // e.g. / (repo root, default) + gitRepo: string + gitBranch: string + buildPath?: string envVars: Record - // Resource limits (Dokploy applies these as container constraints) - cpuLimit?: string // e.g. "0.5" = 50% of one core - memoryLimit?: string // e.g. "512m" - // Database - databaseName?: string // if set, a Postgres DB is created for this instance + gitProvider?: 'gitea' | 'github' | 'git' // default: gitea + // Resource limits (applied via application.update after creation) + cpuLimit?: string + memoryLimit?: string } export interface ProvisionInstanceResult { app: DokployApp - database?: DokployDatabase - domain: DokployDomain + domain?: DokployDomain url: string } class DokployError extends Error { - constructor( - message: string, - public status: number, - public endpoint: string, - public body?: unknown - ) { + constructor(message: string, public status: number, public endpoint: string, public body?: unknown) { super(message) this.name = 'DokployError' } } export function createDokployClient(config: DokployConfig) { - const baseUrl = config.url.replace(/\/$/, '') - const serverId = config.serverId ?? 'default' + const baseUrl = config.url.replace(/\/+$/, '') + '/api' const headers: Record = { 'Content-Type': 'application/json', - Authorization: `Bearer ${config.apiKey}`, + Accept: 'application/json', + 'x-api-key': config.apiKey, } - async function call(endpoint: string, body?: Record, method = 'POST'): Promise { - const url = `${baseUrl}${endpoint}` + async function call(path: string, body?: Record, method = 'POST'): Promise { + const url = `${baseUrl}${path}` + const isGet = method === 'GET' const res = await fetch(url, { method, headers, - body: body ? JSON.stringify(body) : undefined, + body: !isGet && body ? JSON.stringify(body) : undefined, }) const text = await res.text() let json: unknown @@ -107,9 +95,9 @@ export function createDokployClient(config: DokployConfig) { } if (!res.ok) { throw new DokployError( - `Dokploy API ${endpoint} failed: HTTP ${res.status}`, + `Dokploy API ${path} failed: HTTP ${res.status}`, res.status, - endpoint, + path, json ) } @@ -120,111 +108,130 @@ export function createDokployClient(config: DokployConfig) { async function createApplication(input: { name: string + environmentId: string + sourceType?: string + serverId?: string | null description?: string - gitRepo: string - gitBranch: string - buildPath?: string - envVars?: Record - cpuLimit?: string - memoryLimit?: string }): Promise { const body: Record = { name: input.name, - description: input.description ?? `Tenant instance: ${input.name}`, - serverId, - projectName: config.projectId ?? 'default', - sourceType: 'git', - gitRepository: input.gitRepo, - gitBranch: input.gitBranch, - buildPath: input.buildPath ?? '/', - // Dokploy expects env vars as a JSON string - env: JSON.stringify(input.envVars ?? {}), - cpuLimit: input.cpuLimit ?? '0.5', - memoryLimit: input.memoryLimit ?? '512m', + environmentId: input.environmentId, + sourceType: input.sourceType ?? 'gitea', } - const res = await call<{ applicationId?: string; app?: { applicationId: string }; [k: string]: unknown }>('/api/application.create', body) - const applicationId = res.applicationId ?? (res.app as { applicationId: string } | undefined)?.applicationId + if (input.serverId) body.serverId = input.serverId + if (input.description) body.description = input.description + + const res = await call<{ applicationId?: string; app?: DokployApp; [k: string]: unknown }>( + '/application.create', + body + ) + const applicationId = res.applicationId ?? (res.app as DokployApp | undefined)?.applicationId if (!applicationId) { - throw new DokployError('Dokploy createApplication returned no applicationId', 200, '/api/application.create', res) + throw new DokployError('createApplication returned no applicationId', 200, '/application.create', res) } return { applicationId, name: input.name, status: 'created' } } - async function deployApplication(applicationId: string): Promise<{ deployId: string; status: string }> { - const res = await call<{ deployId?: string; jobId?: string; status?: string }>('/api/application.deploy', { applicationId }) - return { deployId: res.deployId ?? res.jobId ?? '', status: res.status ?? 'deploying' } + async function saveGiteaProvider(input: { + applicationId: string + giteaOwner: string + giteaRepository: string + giteaBranch: string + giteaBuildPath?: string | null + giteaId?: string | null + }): Promise { + await call('/application.saveGiteaProvider', { + applicationId: input.applicationId, + giteaOwner: input.giteaOwner, + giteaRepository: input.giteaRepository, + giteaBranch: input.giteaBranch, + giteaBuildPath: input.giteaBuildPath ?? '/', + giteaId: input.giteaId ?? null, + }) } - async function getApplication(applicationId: string): Promise { + async function saveGitProvider(input: { + applicationId: string + gitProviderOwner?: string | null + gitProviderRepository?: string | null + gitProviderBranch?: string + gitProviderBuildPath?: string | null + gitProviderId?: string | null + }): Promise { + await call('/application.saveGitProvider', { + applicationId: input.applicationId, + gitProviderOwner: input.gitProviderOwner ?? null, + gitProviderRepository: input.gitProviderRepository ?? null, + gitProviderBranch: input.gitProviderBranch ?? 'main', + gitProviderBuildPath: input.gitProviderBuildPath ?? '/', + gitProviderId: input.gitProviderId ?? null, + }) + } + + async function saveGithubProvider(input: { + applicationId: string + githubOwner: string + githubRepository: string + githubBranch: string + githubBuildPath?: string | null + githubId?: string | null + }): Promise { + await call('/application.saveGithubProvider', { + applicationId: input.applicationId, + githubOwner: input.githubOwner, + githubRepository: input.githubRepository, + githubBranch: input.githubBranch, + githubBuildPath: input.githubBuildPath ?? '/', + githubId: input.githubId ?? null, + }) + } + + async function saveEnvironment(applicationId: string, envVars: Record): Promise { + // Dokploy expects env as array of { key, value } objects OR a JSON string + const envArray = Object.entries(envVars).map(([key, value]) => ({ key, value })) + await call('/application.saveEnvironment', { + applicationId, + env: JSON.stringify(envArray), + }) + } + + async function deployApplication(applicationId: string): Promise<{ jobId?: string; status: string }> { + const res = await call<{ jobId?: string; deployId?: string; status?: string }>('/application.deploy', { + applicationId, + }) + return { jobId: res.jobId ?? res.deployId, status: res.status ?? 'deploying' } + } + + async function getApplication(applicationId: string): Promise { const res = await call( - `/api/application.one`, + '/application.one', { applicationId }, + 'GET' ) return { applicationId: res.applicationId, name: res.name, + appName: res.appName, status: res.applicationStatus ?? res.status, - url: res.previewDomain ?? res.url, + previewDomain: res.previewDomain, createdAt: res.createdAt, } } async function deleteApplication(applicationId: string): Promise { - await call('/api/application.delete', { applicationId, deleteVolumes: true }) + await call('/application.delete', { applicationId }) } async function stopApplication(applicationId: string): Promise { - await call('/api/application.stop', { applicationId }) + await call('/application.stop', { applicationId }) } async function startApplication(applicationId: string): Promise { - await call('/api/application.start', { applicationId }) + await call('/application.start', { applicationId }) } - async function updateEnv(applicationId: string, envVars: Record): Promise { - await call('/api/application.update', { - applicationId, - env: JSON.stringify(envVars), - }) - } - - // --- Databases (Postgres) --- - - async function createPostgresDatabase(input: { - name: string - appName: string // the application this DB belongs to - description?: string - externalUrl?: boolean - }): Promise { - const body: Record = { - name: input.name, - appName: input.appName, - description: input.description ?? `DB for ${input.appName}`, - serverId, - projectName: config.projectId ?? 'default', - externalUrl: input.externalUrl ?? false, - } - const res = await call>('/api/database.postgres.create', body) - // The response shape varies between Dokploy versions; handle both. - const db = (res.database as Record | undefined) ?? res - const databaseId = (db.databaseId as string | undefined) ?? (res.databaseId as string | undefined) - if (!databaseId) { - throw new DokployError('Dokploy createDatabase returned no databaseId', 200, '/api/database.postgres.create', res) - } - return { - databaseId, - name: (db.name as string | undefined) ?? input.name, - type: 'postgres', - host: (db.host as string | undefined) ?? 'postgres', - port: (db.port as number | undefined) ?? 5432, - database: (db.database as string | undefined) ?? input.name, - username: (db.username as string | undefined) ?? 'postgres', - password: (db.password as string | undefined) ?? '', - } - } - - async function deleteDatabase(databaseId: string): Promise { - await call('/api/database.postgres.delete', { databaseId }) + async function redeployApplication(applicationId: string): Promise { + await call('/application.redeploy', { applicationId }) } // --- Domains --- @@ -235,20 +242,17 @@ export function createDokployClient(config: DokployConfig) { path?: string port?: number https?: boolean - certificateType?: 'letsencrypt' | 'none' }): Promise { - const body: Record = { + const res = await call<{ domainId?: string; id?: string }>('/domain.create', { applicationId: input.applicationId, hostName: input.hostName, path: input.path ?? '/', port: input.port ?? 3000, https: input.https ?? true, - certificateType: input.certificateType ?? 'letsencrypt', - } - const res = await call<{ domainId?: string; id?: string; hostName?: string; https?: boolean }>('/api/domain.create', body) + }) const domainId = res.domainId ?? res.id if (!domainId) { - throw new DokployError('Dokploy createDomain returned no domainId', 200, '/api/domain.create', res) + throw new DokployError('createDomain returned no domainId', 200, '/domain.create', res) } return { domainId, @@ -259,55 +263,84 @@ export function createDokployClient(config: DokployConfig) { } async function deleteDomain(domainId: string): Promise { - await call('/api/domain.delete', { domainId }) + await call('/domain.delete', { domainId }) } // --- High-level: provision a full instance --- async function provisionInstance(input: ProvisionInstanceInput): Promise { - // 1. Create application from Git repo + const environmentId = config.environmentId ?? '' + if (!environmentId) { + throw new DokployError('DOKPLOY_ENVIRONMENT_ID not configured', 500, 'config') + } + + // 1. Create application const app = await createApplication({ name: input.name, - gitRepo: input.gitRepo, - gitBranch: input.gitBranch, - buildPath: '/', - envVars: input.envVars, - cpuLimit: input.cpuLimit, - memoryLimit: input.memoryLimit, + environmentId, + sourceType: input.gitProvider ?? 'gitea', + serverId: config.serverId, + description: `Tenant instance: ${input.subdomain}`, }) - // 2. (Optional) Create Postgres database - let database: DokployDatabase | undefined - if (input.databaseName) { - database = await createPostgresDatabase({ - name: input.databaseName, - appName: input.name, + // 2. Save Git provider (repo connection) + // Parse git repo URL: https://git.dfmpanel.eu/dfmsolutions/AOS.git + // → owner: dfmsolutions, repo: AOS + const gitMatch = input.gitRepo.match(/\/([^/]+)\/([^/]+?)(?:\.git)?$/) + const owner = gitMatch?.[1] ?? '' + const repo = gitMatch?.[2] ?? '' + + if (input.gitProvider === 'github' || input.gitRepo.includes('github.com')) { + await saveGithubProvider({ + applicationId: app.applicationId, + githubOwner: owner, + githubRepository: repo, + githubBranch: input.gitBranch, + githubBuildPath: input.buildPath ?? '/', }) - // Inject DATABASE_URL into the app's env - const dbUrl = `postgresql://${database.username}:${database.password}@${database.host}:${database.port}/${database.database}` - await updateEnv(app.applicationId, { - ...input.envVars, - DATABASE_URL: dbUrl, + } else if (input.gitProvider === 'gitea' || input.gitRepo.includes('git.dfmpanel.eu')) { + await saveGiteaProvider({ + applicationId: app.applicationId, + giteaOwner: owner, + giteaRepository: repo, + giteaBranch: input.gitBranch, + giteaBuildPath: input.buildPath ?? '/', + }) + } else { + // Generic git + await saveGitProvider({ + applicationId: app.applicationId, + gitProviderOwner: owner, + gitProviderRepository: repo, + gitProviderBranch: input.gitBranch, + gitProviderBuildPath: input.buildPath ?? '/', }) } - // 3. Attach domain (subdomain.platform-domain) - const hostName = input.subdomain - const domain = await createDomain({ - applicationId: app.applicationId, - hostName, - port: 3000, - https: true, - }) + // 3. Save environment variables + await saveEnvironment(app.applicationId, input.envVars) - // 4. Trigger deploy + // 4. Deploy await deployApplication(app.applicationId) + // 5. Attach domain (optional — can be done later) + let domain: DokployDomain | undefined + try { + domain = await createDomain({ + applicationId: app.applicationId, + hostName: input.subdomain, + port: 3000, + https: true, + }) + } catch (e) { + // Domain creation is non-fatal — app is already deployed + console.warn('[dokploy] Domain creation failed (non-fatal):', e instanceof Error ? e.message : e) + } + return { app, - database, domain, - url: `https://${hostName}`, + url: `https://${input.subdomain}`, } } @@ -315,14 +348,13 @@ export function createDokployClient(config: DokployConfig) { async function teardownInstance(input: { dokployAppId?: string - dokployDbId?: string - dnsDomainId?: string // Dokploy domain ID + dokployDomainId?: string }): Promise<{ steps: string[] }> { const steps: string[] = [] // Remove domain first (stop traffic) - if (input.dnsDomainId) { + if (input.dokployDomainId) { try { - await deleteDomain(input.dnsDomainId) + await deleteDomain(input.dokployDomainId) steps.push('domain.removed') } catch (e) { if (e instanceof DokployError && e.status !== 404) { @@ -330,17 +362,6 @@ export function createDokployClient(config: DokployConfig) { } } } - // Delete database - if (input.dokployDbId) { - try { - await deleteDatabase(input.dokployDbId) - steps.push('database.removed') - } catch (e) { - if (e instanceof DokployError && e.status !== 404) { - steps.push(`database.error: ${e.message}`) - } - } - } // Delete application (+ volumes) if (input.dokployAppId) { try { @@ -358,14 +379,16 @@ export function createDokployClient(config: DokployConfig) { return { // Low-level createApplication, + saveGiteaProvider, + saveGitProvider, + saveGithubProvider, + saveEnvironment, deployApplication, getApplication, deleteApplication, stopApplication, startApplication, - updateEnv, - createPostgresDatabase, - deleteDatabase, + redeployApplication, createDomain, deleteDomain, call, @@ -377,8 +400,6 @@ export function createDokployClient(config: DokployConfig) { export type DokployClient = ReturnType -// --- Factory: create from env --- - export function createDokployClientFromEnv(): DokployClient | null { const url = process.env.DOKPLOY_URL const apiKey = process.env.DOKPLOY_API_KEY @@ -386,7 +407,7 @@ export function createDokployClientFromEnv(): DokployClient | null { return createDokployClient({ url, apiKey, - serverId: process.env.DOKPLOY_SERVER_ID, - projectId: process.env.DOKPLOY_PROJECT_ID, + environmentId: process.env.DOKPLOY_ENVIRONMENT_ID, + serverId: process.env.DOKPLOY_SERVER_ID ?? null, }) }