Files
AOS/src/lib/dokploy.ts
T
Z User 27669adc21 fix: set buildType to dockerfile after creating Dokploy app + add saveBuildType
- Dokploy defaults to 'nixpacks' build type which fails for repos with Dockerfile
- Now provisioning flow: create → saveGithubProvider → saveBuildType(dockerfile) → saveEnvironment → deploy → createDomain
- saveBuildType sets: buildType=dockerfile, dockerfile=Dockerfile, dockerContextPath=/
- This ensures the app builds correctly from the repo's Dockerfile
2026-10-29 13:00:54 +00:00

471 lines
14 KiB
TypeScript

/**
* 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 - 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
environmentId?: string
serverId?: string | null
}
export interface DokployApp {
applicationId: string
name: string
appName?: string
status?: string
previewDomain?: string
createdAt?: string
}
export interface DokployDomain {
domainId: string
hostName: string
applicationId: string
https: boolean
}
export interface ProvisionInstanceInput {
name: string
subdomain: string
gitRepo: string
gitBranch: string
buildPath?: string
envVars: Record<string, string>
gitProvider?: 'gitea' | 'github' | 'git' // default: gitea
// Resource limits (applied via application.update after creation)
cpuLimit?: string
memoryLimit?: string
}
export interface ProvisionInstanceResult {
app: DokployApp
domain?: DokployDomain
url: string
}
class DokployError extends Error {
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(/\/+$/, '') + '/api'
const headers: Record<string, string> = {
'Content-Type': 'application/json',
Accept: 'application/json',
'x-api-key': config.apiKey,
}
async function call<T = unknown>(path: string, body?: Record<string, unknown>, method = 'POST'): Promise<T> {
const url = `${baseUrl}${path}`
const isGet = method === 'GET'
const res = await fetch(url, {
method,
headers,
body: !isGet && body ? JSON.stringify(body) : undefined,
})
const text = await res.text()
let json: unknown
try {
json = text ? JSON.parse(text) : null
} catch {
json = text
}
if (!res.ok) {
throw new DokployError(
`Dokploy API ${path} failed: HTTP ${res.status}`,
res.status,
path,
json
)
}
return json as T
}
// --- Applications ---
async function createApplication(input: {
name: string
environmentId: string
sourceType?: string
serverId?: string | null
description?: string
}): Promise<DokployApp> {
const body: Record<string, unknown> = {
name: input.name,
environmentId: input.environmentId,
sourceType: input.sourceType ?? 'gitea',
}
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('createApplication returned no applicationId', 200, '/application.create', res)
}
return { applicationId, name: input.name, status: 'created' }
}
async function saveGiteaProvider(input: {
applicationId: string
owner: string
repository: string
branch: string
buildPath?: string | null
id?: string | null
}): Promise<void> {
await call('/application.saveGiteaProvider', {
applicationId: input.applicationId,
owner: input.owner,
repository: input.repository,
branch: input.branch,
buildPath: input.buildPath ?? '/',
id: input.id ?? null,
})
}
async function saveGitProvider(input: {
applicationId: string
owner?: string | null
repository?: string | null
branch?: string
buildPath?: string | null
id?: string | null
}): Promise<void> {
await call('/application.saveGitProvider', {
applicationId: input.applicationId,
owner: input.owner ?? null,
repository: input.repository ?? null,
branch: input.branch ?? 'main',
buildPath: input.buildPath ?? '/',
id: input.id ?? null,
})
}
async function saveGithubProvider(input: {
applicationId: string
owner: string
repository: string
branch: string
buildPath?: string | null
githubId?: string | null
}): Promise<void> {
await call('/application.saveGithubProvider', {
applicationId: input.applicationId,
owner: input.owner,
repository: input.repository,
branch: input.branch,
buildPath: input.buildPath ?? '/',
githubId: input.githubId ?? null,
})
}
async function saveBuildType(input: {
applicationId: string
buildType?: string
dockerfile?: string
dockerContextPath?: string
dockerBuildStage?: string
herokuVersion?: string
railpackVersion?: string
paketoBuilderImage?: string
paketoBuildpacks?: string[]
}): Promise<void> {
await call('/application.saveBuildType', {
applicationId: input.applicationId,
buildType: input.buildType ?? 'dockerfile',
dockerfile: input.dockerfile ?? 'Dockerfile',
dockerContextPath: input.dockerContextPath ?? '/',
dockerBuildStage: input.dockerBuildStage ?? '',
herokuVersion: input.herokuVersion ?? '24',
railpackVersion: input.railpackVersion ?? 'latest',
paketoBuilderImage: input.paketoBuilderImage ?? '',
paketoBuildpacks: input.paketoBuildpacks ?? [],
})
}
async function saveEnvironment(applicationId: string, envVars: Record<string, string>): Promise<void> {
// Dokploy expects env as a .env-style string (KEY=value\nKEY2=value2)
const envString = Object.entries(envVars)
.map(([key, value]) => `${key}=${value}`)
.join('\n')
try {
await call('/application.saveEnvironment', {
applicationId,
env: envString,
buildArgs: '[]',
buildSecrets: '[]',
createEnvFile: true,
})
} catch (e) {
// Re-throw with the actual error body for debugging
if (e instanceof DokployError) {
throw new DokployError(
`saveEnvironment failed: ${e.message} | body: ${JSON.stringify(e.body)?.substring(0, 300)}`,
e.status,
'/application.saveEnvironment',
e.body
)
}
throw e
}
}
async function deployApplication(applicationId: string): Promise<{ jobId?: string; status: string }> {
const res = await call<Record<string, unknown> | null>('/application.deploy', {
applicationId,
})
if (!res) return { jobId: undefined, status: 'deploying' }
const jobId = (res.jobId as string) ?? (res.deployId as string) ?? (res.id as string)
const status = (res.status as string) ?? 'deploying'
return { jobId, status }
}
async function getApplication(applicationId: string): Promise<DokployApp> {
const res = await call<DokployApp & { applicationStatus?: string; previewDomain?: string }>(
'/application.one',
{ applicationId },
'GET'
)
return {
applicationId: res.applicationId,
name: res.name,
appName: res.appName,
status: res.applicationStatus ?? res.status,
previewDomain: res.previewDomain,
createdAt: res.createdAt,
}
}
async function deleteApplication(applicationId: string): Promise<void> {
await call('/application.delete', { applicationId })
}
async function stopApplication(applicationId: string): Promise<void> {
await call('/application.stop', { applicationId })
}
async function startApplication(applicationId: string): Promise<void> {
await call('/application.start', { applicationId })
}
async function redeployApplication(applicationId: string): Promise<void> {
await call('/application.redeploy', { applicationId })
}
// --- Domains ---
async function createDomain(input: {
applicationId: string
host: string
path?: string
port?: number
https?: boolean
certificateType?: string
}): Promise<DokployDomain> {
const res = await call<{ domainId?: string; id?: string }>('/domain.create', {
applicationId: input.applicationId,
host: input.host,
path: input.path ?? '/',
port: input.port ?? 3000,
https: input.https ?? true,
certificateType: input.certificateType ?? 'letsencrypt',
})
const domainId = res.domainId ?? res.id
if (!domainId) {
throw new DokployError('createDomain returned no domainId', 200, '/domain.create', res)
}
return {
domainId,
hostName: input.host,
applicationId: input.applicationId,
https: input.https ?? true,
}
}
async function deleteDomain(domainId: string): Promise<void> {
await call('/domain.delete', { domainId })
}
// --- High-level: provision a full instance ---
async function provisionInstance(input: ProvisionInstanceInput): Promise<ProvisionInstanceResult> {
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,
environmentId,
sourceType: input.gitProvider ?? 'gitea',
serverId: config.serverId,
description: `Tenant instance: ${input.subdomain}`,
})
// 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,
owner,
repository: repo,
branch: input.gitBranch,
buildPath: input.buildPath ?? '/',
})
} else if (input.gitProvider === 'gitea' || input.gitRepo.includes('git.dfmpanel.eu')) {
await saveGiteaProvider({
applicationId: app.applicationId,
owner,
repository: repo,
branch: input.gitBranch,
buildPath: input.buildPath ?? '/',
})
} else {
// Generic git
await saveGitProvider({
applicationId: app.applicationId,
owner,
repository: repo,
branch: input.gitBranch,
buildPath: input.buildPath ?? '/',
})
}
// 3. Set build type to Dockerfile (default is nixpacks which fails for many repos)
await saveBuildType({
applicationId: app.applicationId,
buildType: 'dockerfile',
dockerfile: 'Dockerfile',
dockerContextPath: '/',
})
// 4. Save environment variables
await saveEnvironment(app.applicationId, input.envVars)
// 5. Deploy
await deployApplication(app.applicationId)
// 6. Attach domain (with HTTPS + Let's Encrypt)
let domain: DokployDomain | undefined
try {
domain = await createDomain({
applicationId: app.applicationId,
host: input.subdomain,
port: 3000,
https: true,
certificateType: 'letsencrypt',
})
} 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,
domain,
url: `https://${input.subdomain}`,
}
}
// --- High-level: teardown an instance (idempotent) ---
async function teardownInstance(input: {
dokployAppId?: string
dokployDomainId?: string
}): Promise<{ steps: string[] }> {
const steps: string[] = []
// Remove domain first (stop traffic)
if (input.dokployDomainId) {
try {
await deleteDomain(input.dokployDomainId)
steps.push('domain.removed')
} catch (e) {
if (e instanceof DokployError && e.status !== 404) {
steps.push(`domain.error: ${e.message}`)
}
}
}
// Delete application (+ volumes)
if (input.dokployAppId) {
try {
await deleteApplication(input.dokployAppId)
steps.push('application.removed')
} catch (e) {
if (e instanceof DokployError && e.status !== 404) {
steps.push(`application.error: ${e.message}`)
}
}
}
return { steps }
}
return {
// Low-level
createApplication,
saveGiteaProvider,
saveGitProvider,
saveGithubProvider,
saveBuildType,
saveEnvironment,
deployApplication,
getApplication,
deleteApplication,
stopApplication,
startApplication,
redeployApplication,
createDomain,
deleteDomain,
call,
// High-level
provisionInstance,
teardownInstance,
}
}
export type DokployClient = ReturnType<typeof createDokployClient>
export function createDokployClientFromEnv(): DokployClient | null {
const url = process.env.DOKPLOY_URL
const apiKey = process.env.DOKPLOY_API_KEY
if (!url || !apiKey) return null
return createDokployClient({
url,
apiKey,
environmentId: process.env.DOKPLOY_ENVIRONMENT_ID,
serverId: process.env.DOKPLOY_SERVER_ID ?? null,
})
}