- 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
303 lines
12 KiB
TypeScript
303 lines
12 KiB
TypeScript
import { NextResponse } from 'next/server'
|
||
import { db } from '@/lib/db'
|
||
import { z } from 'zod'
|
||
import { createDokployClientFromEnv } from '@/lib/dokploy'
|
||
import { createHetznerDnsClientFromEnv } from '@/lib/hetzner-dns'
|
||
|
||
export const dynamic = 'force-dynamic'
|
||
export const maxDuration = 300
|
||
|
||
const patchSchema = z.object({
|
||
action: z.enum(['suspend', 'reactivate', 'extend', 'retry']),
|
||
extendDays: z.number().int().min(1).max(90).optional(),
|
||
})
|
||
|
||
// GET /api/aos/instances/[id] — instance detail with events + provisioning log
|
||
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||
try {
|
||
const { id } = await params
|
||
const instance = await db.instance.findUnique({
|
||
where: { id },
|
||
include: {
|
||
company: { select: { id: true, name: true, email: true } },
|
||
license: { select: { id: true, key: true, product: true, plan: true, billingCycle: true, netPriceCents: true, status: true, cancelledAt: true } },
|
||
events: { orderBy: { createdAt: 'desc' }, take: 50 },
|
||
jobs: { orderBy: { createdAt: 'desc' }, take: 10 },
|
||
},
|
||
})
|
||
if (!instance) {
|
||
return NextResponse.json({ error: 'instance not found' }, { status: 404 })
|
||
}
|
||
return NextResponse.json({ instance })
|
||
} catch (err) {
|
||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||
return NextResponse.json({ error: msg }, { status: 500 })
|
||
}
|
||
}
|
||
|
||
// PATCH /api/aos/instances/[id] — suspend / reactivate / extend demo / retry provisioning
|
||
export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||
try {
|
||
const { id } = await params
|
||
const parsed = patchSchema.safeParse(await req.json())
|
||
if (!parsed.success) {
|
||
return NextResponse.json({ error: 'validation failed', details: parsed.error.flatten() }, { status: 400 })
|
||
}
|
||
const { action, extendDays } = parsed.data
|
||
|
||
const instance = await db.instance.findUnique({ where: { id } })
|
||
if (!instance) {
|
||
return NextResponse.json({ error: 'instance not found' }, { status: 404 })
|
||
}
|
||
|
||
const dokploy = createDokployClientFromEnv()
|
||
|
||
if (action === 'suspend') {
|
||
if (!['demo_active', 'active'].includes(instance.status)) {
|
||
return NextResponse.json({ error: `cannot suspend instance in status ${instance.status}` }, { status: 400 })
|
||
}
|
||
const prevStatus = instance.status
|
||
// Stop the Dokploy application
|
||
if (dokploy && instance.dokployAppId) {
|
||
try {
|
||
await dokploy.stopApplication(instance.dokployAppId)
|
||
} catch (e) {
|
||
// Log but don't fail — the instance record is the source of truth
|
||
await db.instanceEvent.create({
|
||
data: {
|
||
instanceId: id,
|
||
type: 'suspend.dokploy.error',
|
||
step: 'dokploy',
|
||
result: 'error',
|
||
details: JSON.stringify({ error: e instanceof Error ? e.message : 'unknown' }),
|
||
},
|
||
})
|
||
}
|
||
}
|
||
await db.instance.update({
|
||
where: { id },
|
||
data: {
|
||
status: 'grace',
|
||
graceEndsAt: new Date(Date.now() + 7 * 86400000), // 7-day grace
|
||
},
|
||
})
|
||
await db.instanceEvent.create({
|
||
data: { instanceId: id, type: 'suspend', step: 'done', result: 'ok', details: JSON.stringify({ prevStatus }) },
|
||
})
|
||
return NextResponse.json({ ok: true, status: 'grace' })
|
||
}
|
||
|
||
if (action === 'reactivate') {
|
||
if (instance.status !== 'grace') {
|
||
return NextResponse.json({ error: 'can only reactivate from grace' }, { status: 400 })
|
||
}
|
||
if (dokploy && instance.dokployAppId) {
|
||
try {
|
||
await dokploy.startApplication(instance.dokployAppId)
|
||
} catch (e) {
|
||
await db.instanceEvent.create({
|
||
data: { instanceId: id, type: 'reactivate.dokploy.error', step: 'dokploy', result: 'error', details: JSON.stringify({ error: e instanceof Error ? e.message : 'unknown' }) },
|
||
})
|
||
}
|
||
}
|
||
// If the license is still in trial, keep demo_active; otherwise active
|
||
const newStatus = instance.demoExpiresAt && new Date(instance.demoExpiresAt) > new Date() ? 'demo_active' : 'active'
|
||
await db.instance.update({
|
||
where: { id },
|
||
data: { status: newStatus, graceEndsAt: null },
|
||
})
|
||
await db.instanceEvent.create({
|
||
data: { instanceId: id, type: 'reactivate', step: 'done', result: 'ok', details: JSON.stringify({ status: newStatus }) },
|
||
})
|
||
return NextResponse.json({ ok: true, status: newStatus })
|
||
}
|
||
|
||
if (action === 'extend') {
|
||
if (instance.status !== 'demo_active') {
|
||
return NextResponse.json({ error: 'can only extend demo_active instances' }, { status: 400 })
|
||
}
|
||
const days = extendDays ?? 7
|
||
const newExpiry = new Date(Date.now() + days * 86400000)
|
||
await db.instance.update({
|
||
where: { id },
|
||
data: { demoExpiresAt: newExpiry },
|
||
})
|
||
await db.instanceEvent.create({
|
||
data: { instanceId: id, type: 'demo.extend', step: 'done', result: 'ok', details: JSON.stringify({ extendedBy: days, newExpiry }) },
|
||
})
|
||
return NextResponse.json({ ok: true, demoExpiresAt: newExpiry })
|
||
}
|
||
|
||
if (action === 'retry') {
|
||
// Reset to provisioning — the reconciler or manual call will re-provision
|
||
if (instance.status !== 'failed') {
|
||
return NextResponse.json({ error: 'can only retry failed instances' }, { status: 400 })
|
||
}
|
||
await db.instance.update({
|
||
where: { id },
|
||
data: { status: 'requested', lastError: null, lastErrorAt: null },
|
||
})
|
||
await db.instanceEvent.create({
|
||
data: { instanceId: id, type: 'provision.retry', step: 'init', result: 'pending' },
|
||
})
|
||
return NextResponse.json({ ok: true, status: 'requested', message: 'Instance queued for re-provisioning' })
|
||
}
|
||
|
||
return NextResponse.json({ error: 'unknown action' }, { status: 400 })
|
||
} catch (err) {
|
||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||
return NextResponse.json({ error: msg }, { status: 500 })
|
||
}
|
||
}
|
||
|
||
// DELETE /api/aos/instances/[id] — tear down instance (idempotent, resumable)
|
||
// Removes: Dokploy domain → database → application + volumes → DNS record.
|
||
// Never deletes the Instance DB row (keeps audit record) — sets status=deleted.
|
||
export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||
try {
|
||
const { id } = await params
|
||
const instance = await db.instance.findUnique({ where: { id } })
|
||
if (!instance) {
|
||
return NextResponse.json({ error: 'instance not found' }, { status: 404 })
|
||
}
|
||
if (instance.status === 'deleted') {
|
||
return NextResponse.json({ ok: true, message: 'already deleted', steps: [] })
|
||
}
|
||
|
||
await db.instance.update({ where: { id }, data: { status: 'deleting' } })
|
||
await db.instanceEvent.create({
|
||
data: { instanceId: id, type: 'delete.start', step: 'init', result: 'pending' },
|
||
})
|
||
|
||
const steps: string[] = []
|
||
const dokploy = createDokployClientFromEnv()
|
||
const dns = createHetznerDnsClientFromEnv()
|
||
|
||
// Parse metadata for Dokploy domain ID
|
||
const meta = instance.metadata ? JSON.parse(instance.metadata) as { dokployDomainId?: string; url?: string } : {}
|
||
|
||
let dokployFailed = false
|
||
let dnsFailed = false
|
||
|
||
// 1. Remove Dokploy domain + database + application (idempotent)
|
||
if (dokploy) {
|
||
try {
|
||
const result = await dokploy.teardownInstance({
|
||
dokployAppId: instance.dokployAppId ?? undefined,
|
||
dokployDomainId: meta.dokployDomainId,
|
||
})
|
||
steps.push(...result.steps)
|
||
await db.instanceEvent.create({
|
||
data: { instanceId: id, type: 'delete.dokploy.done', step: 'dokploy', result: 'ok', details: JSON.stringify({ steps: result.steps }) },
|
||
})
|
||
} catch (e) {
|
||
dokployFailed = true
|
||
steps.push(`dokploy.error: ${e instanceof Error ? e.message : 'unknown'}`)
|
||
await db.instanceEvent.create({
|
||
data: { instanceId: id, type: 'delete.dokploy.error', step: 'dokploy', result: 'error', details: JSON.stringify({ error: e instanceof Error ? e.message : 'unknown' }) },
|
||
})
|
||
}
|
||
} else {
|
||
steps.push('dokploy.skipped (not configured)')
|
||
}
|
||
|
||
// 2. Remove DNS A record (if per-instance, not wildcard).
|
||
// IMPORTANT: Dokploy silme başarısız olsa bile DNS'i silmeye çalış —
|
||
// orphaned DNS record subdomain takeover riski taşır. Ama DNS silme
|
||
// hata verirse dnsRecordId'yi KORU, instance'ı failed yap ki reconciler
|
||
// sonra tekrar deneyebilsin.
|
||
if (dns && instance.dnsRecordId && instance.dnsRecordId !== 'wildcard') {
|
||
try {
|
||
await dns.deleteRecord(instance.dnsRecordId)
|
||
steps.push('dns.removed')
|
||
await db.instanceEvent.create({
|
||
data: { instanceId: id, type: 'delete.dns.removed', step: 'dns', result: 'ok', details: JSON.stringify({ recordId: instance.dnsRecordId }) },
|
||
})
|
||
} catch (e) {
|
||
dnsFailed = true
|
||
steps.push(`dns.error: ${e instanceof Error ? e.message : 'unknown'}`)
|
||
await db.instanceEvent.create({
|
||
data: { instanceId: id, type: 'delete.dns.error', step: 'dns', result: 'error', details: JSON.stringify({ recordId: instance.dnsRecordId, error: e instanceof Error ? e.message : 'unknown' }) },
|
||
})
|
||
}
|
||
}
|
||
|
||
// 3. Decide final state.
|
||
// - Both succeeded → 'deleted' (clear all IDs + secrets)
|
||
// - Either failed → 'failed' (keep IDs for reconciler retry)
|
||
const now = new Date()
|
||
if (dokployFailed || dnsFailed) {
|
||
await db.instance.update({
|
||
where: { id },
|
||
data: {
|
||
status: 'failed',
|
||
lastError: `deletion partial: ${steps.join(', ')}`,
|
||
lastErrorAt: now,
|
||
// Keep dokployAppId + dnsRecordId so reconciler can retry.
|
||
},
|
||
})
|
||
await db.instanceEvent.create({
|
||
data: { instanceId: id, type: 'delete.partial', step: 'done', result: 'error', details: JSON.stringify({ steps, dokployFailed, dnsFailed }) },
|
||
})
|
||
await db.auditLog.create({
|
||
data: {
|
||
actor: 'admin:ui',
|
||
action: 'instance.delete.partial',
|
||
entity: 'Instance',
|
||
entityId: id,
|
||
after: JSON.stringify({ subdomain: instance.subdomain, steps, dokployFailed, dnsFailed, message: 'reconciler will retry' }),
|
||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||
},
|
||
})
|
||
return NextResponse.json({
|
||
ok: false,
|
||
status: 'failed',
|
||
steps,
|
||
message: 'Löschung teilweise fehlgeschlagen — Reconciler wird erneut versuchen. Dokploy + Hetzner A record birlikte silinmedi; orphaned resource riski için instance failed durumda.',
|
||
}, { status: 500 })
|
||
}
|
||
|
||
// 4. Both succeeded → mark deleted (keep row for audit — GoBD)
|
||
await db.instance.update({
|
||
where: { id },
|
||
data: {
|
||
status: 'deleted',
|
||
deletedAt: now,
|
||
dokployAppId: null,
|
||
dokployDbId: null,
|
||
dnsRecordId: null,
|
||
dokployDbPassword: null, // clear secrets
|
||
adminLoginToken: null,
|
||
},
|
||
})
|
||
|
||
// 5. Unlink from license (keep license, just remove instance ref)
|
||
if (instance.licenseId) {
|
||
await db.license.update({
|
||
where: { id: instance.licenseId },
|
||
data: { instance: { disconnect: true } },
|
||
})
|
||
}
|
||
|
||
await db.instanceEvent.create({
|
||
data: { instanceId: id, type: 'delete.ok', step: 'done', result: 'ok', details: JSON.stringify({ steps, deletedAt: now }) },
|
||
})
|
||
|
||
await db.auditLog.create({
|
||
data: {
|
||
actor: 'admin:ui',
|
||
action: 'instance.delete',
|
||
entity: 'Instance',
|
||
entityId: id,
|
||
after: JSON.stringify({ subdomain: instance.subdomain, steps }),
|
||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||
},
|
||
})
|
||
|
||
return NextResponse.json({ ok: true, status: 'deleted', steps, deletedAt: now })
|
||
} catch (err) {
|
||
const msg = err instanceof Error ? err.message : 'internal server error'
|
||
return NextResponse.json({ error: msg }, { status: 500 })
|
||
}
|
||
}
|