4413045f-d05e-4d7a-a70d-81d6be253c85

This commit is contained in:
Z User committed 2026-10-28 16:37:26 +00:00
1 parent 762f3d7373
commit c6adc67479
18 files changed
+5263 -1

No files matched your search

+257
View File
@@ -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 })
}
}