883bab87-6894-46db-972a-fb5bc303c0eb
This commit is contained in:
1 parent
7166c51680
commit
e2c17aea18
3 files changed
+46
-53
No files matched your search
@@ -214,7 +214,14 @@ export async function PATCH(req: Request, { params }: { params: Promise<{ id: st
|
|||||||
|
|
||||||
// DELETE /api/aos/instances/[id] — tear down instance (idempotent, resumable)
|
// DELETE /api/aos/instances/[id] — tear down instance (idempotent, resumable)
|
||||||
// Removes: Dokploy domain → database → application + volumes → DNS record.
|
// Removes: Dokploy domain → database → application + volumes → DNS record.
|
||||||
// Never deletes the Instance DB row (keeps audit record) — sets status=deleted.
|
//
|
||||||
|
// HARD DELETE: the Instance DB row is deleted entirely (cascade removes
|
||||||
|
// InstanceEvent + ProvisioningJob). Instances are infrastructure, not
|
||||||
|
// accounting records — they have NO GoBD retention requirement. Only the
|
||||||
|
// accounting module (muhasebe/buchhaltung: invoices, expenses, kassenbuch,
|
||||||
|
// belege, buchungssätze, licenses) is exempt from hard-deletion for GoBD.
|
||||||
|
// The cross-cutting AuditLog entry (separate table, string entityId) is the
|
||||||
|
// persistent record that this action happened.
|
||||||
export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
try {
|
try {
|
||||||
const { id } = await params
|
const { id } = await params
|
||||||
@@ -222,9 +229,6 @@ export async function DELETE(_req: Request, { params }: { params: Promise<{ id:
|
|||||||
if (!instance) {
|
if (!instance) {
|
||||||
return NextResponse.json({ error: 'instance not found' }, { status: 404 })
|
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.instance.update({ where: { id }, data: { status: 'deleting' } })
|
||||||
await db.instanceEvent.create({
|
await db.instanceEvent.create({
|
||||||
@@ -285,8 +289,8 @@ export async function DELETE(_req: Request, { params }: { params: Promise<{ id:
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 3. Decide final state.
|
// 3. Decide final state.
|
||||||
// - Both succeeded → 'deleted' (clear all IDs + secrets)
|
// - Both succeeded → hard-delete the instance row (no GoBD on infra)
|
||||||
// - Either failed → 'failed' (keep IDs for reconciler retry)
|
// - Either failed → keep row as 'failed' (retain IDs for reconciler retry)
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
if (dokployFailed || dnsFailed) {
|
if (dokployFailed || dnsFailed) {
|
||||||
await db.instance.update({
|
await db.instance.update({
|
||||||
@@ -319,44 +323,28 @@ export async function DELETE(_req: Request, { params }: { params: Promise<{ id:
|
|||||||
}, { status: 500 })
|
}, { status: 500 })
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Both succeeded → mark deleted (keep row for audit — GoBD)
|
// 4. Both succeeded → hard-delete the instance row.
|
||||||
await db.instance.update({
|
// Instances are infrastructure (not accounting), so there is no GoBD
|
||||||
where: { id },
|
// retention requirement on the row itself. Cascade deletes the
|
||||||
data: {
|
// InstanceEvent + ProvisioningJob rows. The AuditLog entry (separate
|
||||||
status: 'deleted',
|
// table) is the durable record of this action.
|
||||||
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({
|
await db.auditLog.create({
|
||||||
data: {
|
data: {
|
||||||
actor: 'admin:ui',
|
actor: 'admin:ui',
|
||||||
action: 'instance.delete',
|
action: 'instance.delete',
|
||||||
entity: 'Instance',
|
entity: 'Instance',
|
||||||
entityId: id,
|
entityId: id,
|
||||||
after: JSON.stringify({ subdomain: instance.subdomain, steps }),
|
before: JSON.stringify({ subdomain: instance.subdomain, status: instance.status, product: instance.product, companyId: instance.companyId }),
|
||||||
|
after: JSON.stringify({ subdomain: instance.subdomain, steps, deletedAt: now }),
|
||||||
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
return NextResponse.json({ ok: true, status: 'deleted', steps, deletedAt: now })
|
// Hard-delete: License 1:1 reverse relation is automatically cleared
|
||||||
|
// (the FK lives on the Instance row). Cascade removes events + jobs.
|
||||||
|
await db.instance.delete({ where: { id } })
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true, deleted: true, steps, deletedAt: now })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const msg = err instanceof Error ? err.message : 'internal server error'
|
const msg = err instanceof Error ? err.message : 'internal server error'
|
||||||
return NextResponse.json({ error: msg }, { status: 500 })
|
return NextResponse.json({ error: msg }, { status: 500 })
|
||||||
|
|||||||
@@ -8,9 +8,12 @@ export const maxDuration = 300
|
|||||||
|
|
||||||
// POST /api/aos/instances/cleanup
|
// POST /api/aos/instances/cleanup
|
||||||
// Cron job: run daily. Finds instances whose grace period has elapsed and
|
// Cron job: run daily. Finds instances whose grace period has elapsed and
|
||||||
// tears them down (Dokploy + DNS + volumes). Also finds expired demos and
|
// tears them down (Dokploy + DNS + volumes), then HARD-DELETES the instance
|
||||||
// moves them to grace. Idempotent — safe to call multiple times.
|
// row. Also finds expired demos and moves them to grace. Idempotent — safe
|
||||||
|
// to call multiple times.
|
||||||
//
|
//
|
||||||
|
// Instances are infrastructure, not accounting records — no GoBD retention.
|
||||||
|
// The cross-cutting AuditLog entry (separate table) records each deletion.
|
||||||
// In production, this is triggered by a cron job (e.g. via Dokploy's cron
|
// In production, this is triggered by a cron job (e.g. via Dokploy's cron
|
||||||
// or a GitHub Action hitting this endpoint with a shared secret).
|
// or a GitHub Action hitting this endpoint with a shared secret).
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
@@ -126,21 +129,23 @@ export async function POST(req: Request) {
|
|||||||
results.failed++
|
results.failed++
|
||||||
results.details.push(`${inst.subdomain}: PARTIAL (${steps.join(', ')}) — retry next run`)
|
results.details.push(`${inst.subdomain}: PARTIAL (${steps.join(', ')}) — retry next run`)
|
||||||
} else {
|
} else {
|
||||||
// Both succeeded → mark deleted (clear IDs + secrets)
|
// Both succeeded → hard-delete the instance row.
|
||||||
await db.instance.update({
|
// Instances are infrastructure (not accounting) — no GoBD retention.
|
||||||
where: { id: inst.id },
|
// Cascade removes InstanceEvent + ProvisioningJob. The AuditLog
|
||||||
|
// entry (separate table) is the durable record.
|
||||||
|
await db.auditLog.create({
|
||||||
data: {
|
data: {
|
||||||
status: 'deleted',
|
actor: 'cron:cleanup',
|
||||||
deletedAt: now,
|
action: 'instance.delete',
|
||||||
dokployAppId: null,
|
entity: 'Instance',
|
||||||
dokployDbId: null,
|
entityId: inst.id,
|
||||||
dokployDbPassword: null,
|
before: JSON.stringify({ subdomain: inst.subdomain, status: inst.status, product: inst.product }),
|
||||||
dnsRecordId: null,
|
after: JSON.stringify({ subdomain: inst.subdomain, steps, deletedAt: now }),
|
||||||
adminLoginToken: null,
|
hash: 'sha256:' + Math.random().toString(36).slice(2),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// Unlink license
|
// Unlink license first (1:1 reverse relation) then hard-delete.
|
||||||
if (inst.licenseId) {
|
if (inst.licenseId) {
|
||||||
await db.license.update({
|
await db.license.update({
|
||||||
where: { id: inst.licenseId },
|
where: { id: inst.licenseId },
|
||||||
@@ -148,9 +153,9 @@ export async function POST(req: Request) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.instanceEvent.create({
|
await db.instance.delete({ where: { id: inst.id } })
|
||||||
data: { instanceId: inst.id, type: 'cleanup.delete.ok', step: 'reconciler', result: 'ok', details: JSON.stringify({ steps }) },
|
// NOTE: no post-delete InstanceEvent — the row is gone and cascade
|
||||||
})
|
// would wipe it anyway. The AuditLog entry above is the durable record.
|
||||||
results.deleted++
|
results.deleted++
|
||||||
results.details.push(`${inst.subdomain}: deleted (${steps.join(', ')})`)
|
results.details.push(`${inst.subdomain}: deleted (${steps.join(', ')})`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -899,9 +899,9 @@ function InstanceDetailSheet({
|
|||||||
{confirmingDelete ? (
|
{confirmingDelete ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Die Instanz wird unwiderruflich gelöscht. Dokploy-Ressourcen, DNS-Einträge
|
Die Instanz wird unwiderruflich gelöscht. Dokploy-Ressourcen, DNS-Einträge,
|
||||||
und Volumes werden entfernt (die Datenbank-Zeile bleibt für das Audit-Log
|
Volumes und der Datenbank-Datensatz (inkl. Events & Jobs) werden entfernt.
|
||||||
erhalten).
|
Nur der Audit-Log-Eintrag bleibt bestehen.
|
||||||
</p>
|
</p>
|
||||||
<SlideCommit
|
<SlideCommit
|
||||||
label="Instanz wirklich löschen →"
|
label="Instanz wirklich löschen →"
|
||||||
|
|||||||
Reference in new issue
Block a user