- 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
471 lines
14 KiB
TypeScript
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,
|
|
})
|
|
}
|