4413045f-d05e-4d7a-a70d-81d6be253c85
This commit is contained in:
1 parent
762f3d7373
commit
c6adc67479
18 files changed
+5263
-1
No files matched your search
@@ -0,0 +1,257 @@
|
||||
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 } : {}
|
||||
|
||||
// 1. Remove Dokploy domain + database + application (idempotent)
|
||||
if (dokploy) {
|
||||
try {
|
||||
const result = await dokploy.teardownInstance({
|
||||
dokployAppId: instance.dokployAppId ?? undefined,
|
||||
dokployDbId: instance.dokployDbId ?? undefined,
|
||||
dnsDomainId: 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) {
|
||||
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)
|
||||
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) {
|
||||
steps.push(`dns.error: ${e instanceof Error ? e.message : 'unknown'}`)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Mark deleted (keep the row for audit — GoBD)
|
||||
const now = new Date()
|
||||
await db.instance.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'deleted',
|
||||
deletedAt: now,
|
||||
dokployAppId: null,
|
||||
dokployDbId: null,
|
||||
dnsRecordId: null,
|
||||
dokployDbPassword: null, // clear secrets
|
||||
adminLoginToken: null,
|
||||
},
|
||||
})
|
||||
|
||||
// 4. 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 })
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user