import { OperationTaskType, Prisma, ProductType, ProvisionedService, ServiceLifecycleStatus, TemplateType, VmType } from "@prisma/client"; import { prisma } from "../lib/prisma"; import { HttpError } from "../lib/http-error"; import { deleteVm, provisionVmFromTemplate, startVm, stopVm, updateVmConfiguration } from "./proxmox.service"; import { createOperationTask, markOperationTaskFailed, markOperationTaskRunning, markOperationTaskSuccess } from "./operations.service"; type PlacementRequest = { productType: ProductType; applicationGroupId?: string; cpuCores: number; ramMb: number; diskGb: number; }; type ServiceCreateInput = { name: string; tenantId: string; productType: ProductType; virtualizationType: VmType; vmCount: number; targetNode?: string; autoNode: boolean; applicationGroupId?: string; templateId?: string; billingPlanId?: string; packageOptions?: Prisma.InputJsonValue; createdBy?: string; }; type ServiceListInput = { tenantId?: string; lifecycleStatus?: ServiceLifecycleStatus; limit?: number; offset?: number; }; type ServiceLifecycleInput = { serviceId: string; actorEmail: string; reason?: string; hardDelete?: boolean; }; type PackageUpdateInput = { serviceId: string; actorEmail: string; packageOptions: Prisma.InputJsonValue; }; function normalizeSlug(value: string) { return value .toLowerCase() .trim() .replace(/[^a-z0-9\s-]/g, "") .replace(/\s+/g, "-") .replace(/-+/g, "-"); } function extractNumberOption( packageOptions: Prisma.InputJsonValue | undefined, key: string, fallback: number ): number { if (!packageOptions || typeof packageOptions !== "object" || Array.isArray(packageOptions)) { return fallback; } const raw = (packageOptions as Prisma.InputJsonObject)[key]; if (typeof raw === "number" && Number.isFinite(raw) && raw > 0) { return Math.floor(raw); } if (typeof raw === "string") { const parsed = Number(raw); if (Number.isFinite(parsed) && parsed > 0) { return Math.floor(parsed); } } return fallback; } function parsePackageOptionsObject( value: Prisma.InputJsonValue | undefined ): Record | null { if (!value || typeof value !== "object" || Array.isArray(value)) { return null; } return value as Record; } async function resolvePlacementPolicy(nodeId: string, groupId: string | undefined, productType: ProductType) { const policies = await prisma.nodePlacementPolicy.findMany({ where: { is_active: true, OR: [ { node_id: nodeId, group_id: groupId, product_type: productType }, { node_id: nodeId, group_id: groupId, product_type: null }, { node_id: nodeId, group_id: null, product_type: productType }, { node_id: nodeId, group_id: null, product_type: null }, { node_id: null, group_id: groupId, product_type: productType }, { node_id: null, group_id: groupId, product_type: null }, { node_id: null, group_id: null, product_type: productType }, { node_id: null, group_id: null, product_type: null } ] } }); const scorePolicy = (policy: (typeof policies)[number]) => { let score = 0; if (policy.node_id === nodeId) score += 8; if (policy.group_id && groupId && policy.group_id === groupId) score += 4; if (!policy.group_id) score += 1; if (policy.product_type === productType) score += 2; if (!policy.product_type) score += 1; return score; }; return policies.sort((a, b) => scorePolicy(b) - scorePolicy(a))[0] ?? null; } function nodeResourcePercentages(node: { cpu_usage: number; ram_total_mb: number; ram_used_mb: number; disk_total_gb: number; disk_used_gb: number; }) { const cpuFreePct = Math.max(0, 100 - (node.cpu_usage ?? 0)); const ramFreeMb = Math.max(0, (node.ram_total_mb ?? 0) - (node.ram_used_mb ?? 0)); const diskFreeGb = Math.max(0, (node.disk_total_gb ?? 0) - (node.disk_used_gb ?? 0)); const ramFreePct = node.ram_total_mb > 0 ? (ramFreeMb / node.ram_total_mb) * 100 : 0; const diskFreePct = node.disk_total_gb > 0 ? (diskFreeGb / node.disk_total_gb) * 100 : 0; return { cpuFreePct, ramFreeMb, diskFreeGb, ramFreePct, diskFreePct }; } export async function chooseNodeForProvisioning(input: PlacementRequest) { const nodes = await prisma.proxmoxNode.findMany({ where: { status: "ONLINE", is_connected: true }, orderBy: { created_at: "asc" } }); if (nodes.length === 0) { throw new HttpError(400, "No online nodes available for provisioning", "NO_AVAILABLE_NODE"); } let bestNode: { id: string; hostname: string; score: number; } | null = null; for (const node of nodes) { const usage = nodeResourcePercentages(node); if (usage.ramFreeMb < input.ramMb || usage.diskFreeGb < input.diskGb) { continue; } const policy = await resolvePlacementPolicy(node.id, input.applicationGroupId, input.productType); if (policy?.min_free_ram_mb && usage.ramFreeMb < policy.min_free_ram_mb) { continue; } if (policy?.min_free_disk_gb && usage.diskFreeGb < policy.min_free_disk_gb) { continue; } if (policy?.max_vms && node.vm_count >= policy.max_vms) { continue; } const cpuWeight = policy?.cpu_weight ?? 40; const ramWeight = policy?.ram_weight ?? 30; const diskWeight = policy?.disk_weight ?? 20; const vmCountWeight = policy?.vm_count_weight ?? 10; const score = usage.cpuFreePct * cpuWeight + usage.ramFreePct * ramWeight + usage.diskFreePct * diskWeight - node.vm_count * vmCountWeight; if (!bestNode || score > bestNode.score) { bestNode = { id: node.id, hostname: node.hostname, score }; } } if (!bestNode) { throw new HttpError(400, "No node satisfies requested resource constraints", "NO_SUITABLE_NODE"); } return bestNode; } async function minimumVmidFromSettings() { const setting = await prisma.setting.findUnique({ where: { key: "provisioning" } }); const value = setting?.value as Prisma.JsonObject | undefined; const minVmid = value && typeof value.min_vmid === "number" ? Number(value.min_vmid) : 100; return Number.isFinite(minVmid) ? Math.max(100, Math.floor(minVmid)) : 100; } export async function allocateVmid(nodeHostname: string, applicationGroupId?: string) { const ranges = await prisma.vmIdRange.findMany({ where: { node_hostname: nodeHostname, is_active: true, OR: [{ application_group_id: applicationGroupId }, { application_group_id: null }] }, orderBy: [ { application_group_id: "desc" }, { range_start: "asc" } ] }); const usedVmids = new Set( (await prisma.virtualMachine.findMany({ where: { node: nodeHostname }, select: { vmid: true } })).map((item) => item.vmid) ); for (const range of ranges) { let candidate = Math.max(range.next_vmid, range.range_start); while (candidate <= range.range_end && usedVmids.has(candidate)) { candidate += 1; } if (candidate <= range.range_end) { await prisma.vmIdRange.update({ where: { id: range.id }, data: { next_vmid: candidate + 1 } }); return candidate; } } let fallback = await minimumVmidFromSettings(); while (usedVmids.has(fallback)) { fallback += 1; } return fallback; } function deriveServiceResources(input: ServiceCreateInput, billingPlan: { cpu_cores: number; ram_mb: number; disk_gb: number } | null) { const defaultCpu = billingPlan?.cpu_cores ?? 2; const defaultRam = billingPlan?.ram_mb ?? 2048; const defaultDisk = billingPlan?.disk_gb ?? 40; return { cpuCores: extractNumberOption(input.packageOptions, "cpu_cores", defaultCpu), ramMb: extractNumberOption(input.packageOptions, "ram_mb", defaultRam), diskGb: extractNumberOption(input.packageOptions, "disk_gb", defaultDisk) }; } export async function createProvisionedService(input: ServiceCreateInput) { const tenant = await prisma.tenant.findUnique({ where: { id: input.tenantId } }); if (!tenant) { throw new HttpError(404, "Tenant not found", "TENANT_NOT_FOUND"); } if (input.applicationGroupId) { const group = await prisma.applicationGroup.findUnique({ where: { id: input.applicationGroupId } }); if (!group) { throw new HttpError(404, "Application group not found", "APPLICATION_GROUP_NOT_FOUND"); } } const template = input.templateId ? await prisma.appTemplate.findUnique({ where: { id: input.templateId } }) : null; if (input.templateId && !template) { throw new HttpError(404, "Template not found", "TEMPLATE_NOT_FOUND"); } if (template?.virtualization_type && template.virtualization_type !== input.virtualizationType) { throw new HttpError( 400, `Template virtualization type (${template.virtualization_type}) does not match requested service type (${input.virtualizationType})`, "TEMPLATE_VM_TYPE_MISMATCH" ); } if (template?.template_type === TemplateType.KVM_TEMPLATE && input.virtualizationType !== VmType.QEMU) { throw new HttpError(400, "KVM template requires QEMU virtualization", "TEMPLATE_VM_TYPE_MISMATCH"); } if (template?.template_type === TemplateType.LXC_TEMPLATE && input.virtualizationType !== VmType.LXC) { throw new HttpError(400, "LXC template requires LXC virtualization", "TEMPLATE_VM_TYPE_MISMATCH"); } if (template?.template_type === TemplateType.ISO_IMAGE && input.virtualizationType !== VmType.QEMU) { throw new HttpError(400, "ISO template requires QEMU virtualization", "TEMPLATE_VM_TYPE_MISMATCH"); } if (template?.template_type === TemplateType.ARCHIVE && input.virtualizationType !== VmType.LXC) { throw new HttpError(400, "Archive template requires LXC virtualization", "TEMPLATE_VM_TYPE_MISMATCH"); } const billingPlan = input.billingPlanId ? await prisma.billingPlan.findUnique({ where: { id: input.billingPlanId }, select: { id: true, cpu_cores: true, ram_mb: true, disk_gb: true } }) : null; if (input.billingPlanId && !billingPlan) { throw new HttpError(404, "Billing plan not found", "BILLING_PLAN_NOT_FOUND"); } const resources = deriveServiceResources(input, billingPlan); const packageOptionsRecord = parsePackageOptionsObject(input.packageOptions); const vmCount = Math.max(1, Math.min(input.vmCount, input.productType === ProductType.CLOUD ? 20 : 1)); const serviceGroupId = vmCount > 1 ? `svcgrp_${Date.now()}_${Math.floor(Math.random() * 1000)}` : null; const created: ProvisionedService[] = []; for (let index = 0; index < vmCount; index += 1) { const selectedNode = input.autoNode || !input.targetNode ? await chooseNodeForProvisioning({ productType: input.productType, applicationGroupId: input.applicationGroupId, cpuCores: resources.cpuCores, ramMb: resources.ramMb, diskGb: resources.diskGb }) : null; const nodeHostname = selectedNode?.hostname ?? input.targetNode; if (!nodeHostname) { throw new HttpError(400, "targetNode is required when autoNode=false", "TARGET_NODE_REQUIRED"); } if (!selectedNode) { const manualNode = await prisma.proxmoxNode.findUnique({ where: { hostname: nodeHostname }, select: { hostname: true, status: true, is_connected: true } }); if (!manualNode || manualNode.status !== "ONLINE" || !manualNode.is_connected) { throw new HttpError(400, `Target node ${nodeHostname} is not online`, "TARGET_NODE_OFFLINE"); } } const vmid = await allocateVmid(nodeHostname, input.applicationGroupId); const vmName = vmCount > 1 ? `${input.name}-${index + 1}` : input.name; const vm = await prisma.virtualMachine.create({ data: { name: vmName, vmid, status: "STOPPED", type: input.virtualizationType, node: nodeHostname, tenant_id: input.tenantId, billing_plan_id: billingPlan?.id, os_template: input.templateId, cpu_cores: resources.cpuCores, ram_mb: resources.ramMb, disk_gb: resources.diskGb } }); const service = await prisma.provisionedService.create({ data: { service_group_id: serviceGroupId, vm_id: vm.id, tenant_id: input.tenantId, product_type: input.productType, lifecycle_status: ServiceLifecycleStatus.ACTIVE, application_group_id: input.applicationGroupId, template_id: input.templateId, package_options: input.packageOptions ?? {}, created_by: input.createdBy } }); const task = await createOperationTask({ taskType: OperationTaskType.VM_CREATE, vm: { id: vm.id, name: vm.name, node: vm.node }, requestedBy: input.createdBy, payload: { service_id: service.id, product_type: input.productType, template_id: input.templateId, template_type: template?.template_type, application_group_id: input.applicationGroupId } }); await markOperationTaskRunning(task.id); try { const provisionResult = await provisionVmFromTemplate({ node: vm.node, vmid: vm.vmid, name: vm.name, type: vm.type, cpuCores: resources.cpuCores, ramMb: resources.ramMb, diskGb: resources.diskGb, template: template ? { id: template.id, name: template.name, templateType: template.template_type, virtualizationType: template.virtualization_type, source: template.source, defaultCloudInit: template.default_cloud_init, metadata: template.metadata } : undefined, packageOptions: packageOptionsRecord }); const resolvedUpid = provisionResult.startUpid ?? provisionResult.mainUpid ?? provisionResult.configUpid ?? undefined; await prisma.virtualMachine.update({ where: { id: vm.id }, data: { status: provisionResult.started ? "RUNNING" : "STOPPED", proxmox_upid: resolvedUpid } }); const updatedService = await prisma.provisionedService.update({ where: { id: service.id }, data: { lifecycle_status: ServiceLifecycleStatus.ACTIVE, suspended_reason: null } }); await markOperationTaskSuccess( task.id, { vm_id: vm.id, service_id: service.id, node: vm.node, vmid, provisioning: { orchestration: provisionResult.orchestration, notes: provisionResult.notes, main_upid: provisionResult.mainUpid, config_upid: provisionResult.configUpid, ha_upid: provisionResult.haUpid, start_upid: provisionResult.startUpid } }, resolvedUpid ); created.push(updatedService); } catch (error) { const message = error instanceof Error ? error.message : "Provisioning operation failed"; await prisma.virtualMachine.update({ where: { id: vm.id }, data: { status: "ERROR" } }); await prisma.provisionedService.update({ where: { id: service.id }, data: { lifecycle_status: ServiceLifecycleStatus.SUSPENDED, suspended_reason: message } }); await markOperationTaskFailed(task.id, message); throw new HttpError(500, `Provisioning failed for ${vm.name}: ${message}`, "PROVISIONING_FAILED"); } } return created; } export async function listProvisionedServices(input: ServiceListInput) { const where: Prisma.ProvisionedServiceWhereInput = {}; if (input.tenantId) where.tenant_id = input.tenantId; if (input.lifecycleStatus) where.lifecycle_status = input.lifecycleStatus; const limit = Math.min(Math.max(input.limit ?? 50, 1), 200); const offset = Math.max(input.offset ?? 0, 0); const [data, total] = await Promise.all([ prisma.provisionedService.findMany({ where, include: { vm: true, tenant: { select: { id: true, name: true, slug: true } }, group: { select: { id: true, name: true } }, template: { select: { id: true, name: true, template_type: true } } }, orderBy: { created_at: "desc" }, take: limit, skip: offset }), prisma.provisionedService.count({ where }) ]); return { data, meta: { total, limit, offset } }; } async function fetchService(serviceId: string) { const service = await prisma.provisionedService.findUnique({ where: { id: serviceId }, include: { vm: true } }); if (!service) { throw new HttpError(404, "Provisioned service not found", "SERVICE_NOT_FOUND"); } return service; } export async function suspendProvisionedService(input: ServiceLifecycleInput) { const service = await fetchService(input.serviceId); if (service.lifecycle_status === ServiceLifecycleStatus.TERMINATED) { throw new HttpError(400, "Terminated service cannot be suspended", "SERVICE_TERMINATED"); } if (service.lifecycle_status === ServiceLifecycleStatus.SUSPENDED) { return service; } const type = service.vm.type === VmType.LXC ? "lxc" : "qemu"; const task = await createOperationTask({ taskType: OperationTaskType.VM_POWER, vm: { id: service.vm.id, name: service.vm.name, node: service.vm.node }, requestedBy: input.actorEmail, payload: { lifecycle_action: "suspend", reason: input.reason } }); await markOperationTaskRunning(task.id); try { const upid = await stopVm(service.vm.node, service.vm.vmid, type); await prisma.virtualMachine.update({ where: { id: service.vm.id }, data: { status: "STOPPED", proxmox_upid: upid ?? undefined } }); const updated = await prisma.provisionedService.update({ where: { id: service.id }, data: { lifecycle_status: ServiceLifecycleStatus.SUSPENDED, suspended_reason: input.reason } }); await markOperationTaskSuccess(task.id, { service_id: updated.id, lifecycle_status: updated.lifecycle_status, action: "suspend", ...(upid ? { upid } : {}) }); return updated; } catch (error) { const message = error instanceof Error ? error.message : "Suspend operation failed"; await markOperationTaskFailed(task.id, message); throw error; } } export async function unsuspendProvisionedService(input: ServiceLifecycleInput) { const service = await fetchService(input.serviceId); if (service.lifecycle_status === ServiceLifecycleStatus.TERMINATED) { throw new HttpError(400, "Terminated service cannot be unsuspended", "SERVICE_TERMINATED"); } if (service.lifecycle_status === ServiceLifecycleStatus.ACTIVE) { return service; } const type = service.vm.type === VmType.LXC ? "lxc" : "qemu"; const task = await createOperationTask({ taskType: OperationTaskType.VM_POWER, vm: { id: service.vm.id, name: service.vm.name, node: service.vm.node }, requestedBy: input.actorEmail, payload: { lifecycle_action: "unsuspend" } }); await markOperationTaskRunning(task.id); try { const upid = await startVm(service.vm.node, service.vm.vmid, type); await prisma.virtualMachine.update({ where: { id: service.vm.id }, data: { status: "RUNNING", proxmox_upid: upid ?? undefined } }); const updated = await prisma.provisionedService.update({ where: { id: service.id }, data: { lifecycle_status: ServiceLifecycleStatus.ACTIVE, suspended_reason: null } }); await markOperationTaskSuccess(task.id, { service_id: updated.id, lifecycle_status: updated.lifecycle_status, action: "unsuspend", ...(upid ? { upid } : {}) }); return updated; } catch (error) { const message = error instanceof Error ? error.message : "Unsuspend operation failed"; await markOperationTaskFailed(task.id, message); throw error; } } export async function terminateProvisionedService(input: ServiceLifecycleInput) { const service = await fetchService(input.serviceId); if (service.lifecycle_status === ServiceLifecycleStatus.TERMINATED) { return service; } const type = service.vm.type === VmType.LXC ? "lxc" : "qemu"; const task = await createOperationTask({ taskType: OperationTaskType.VM_DELETE, vm: { id: service.vm.id, name: service.vm.name, node: service.vm.node }, requestedBy: input.actorEmail, payload: { lifecycle_action: "terminate", hard_delete: input.hardDelete ?? false, reason: input.reason } }); await markOperationTaskRunning(task.id); try { let upid: string | undefined; if (input.hardDelete) { upid = await deleteVm(service.vm.node, service.vm.vmid, type); } else { upid = await stopVm(service.vm.node, service.vm.vmid, type); } await prisma.virtualMachine.update({ where: { id: service.vm.id }, data: { status: "STOPPED", proxmox_upid: upid ?? undefined } }); const updated = await prisma.provisionedService.update({ where: { id: service.id }, data: { lifecycle_status: ServiceLifecycleStatus.TERMINATED, terminated_at: new Date(), suspended_reason: input.reason } }); await markOperationTaskSuccess(task.id, { service_id: updated.id, lifecycle_status: updated.lifecycle_status, action: "terminate", hard_delete: Boolean(input.hardDelete), ...(upid ? { upid } : {}) }); return updated; } catch (error) { const message = error instanceof Error ? error.message : "Terminate operation failed"; await markOperationTaskFailed(task.id, message); throw error; } } export async function updateProvisionedServicePackage(input: PackageUpdateInput) { const service = await fetchService(input.serviceId); const cpuCores = extractNumberOption(input.packageOptions, "cpu_cores", service.vm.cpu_cores); const ramMb = extractNumberOption(input.packageOptions, "ram_mb", service.vm.ram_mb); const diskGb = extractNumberOption(input.packageOptions, "disk_gb", service.vm.disk_gb); const type = service.vm.type === VmType.LXC ? "lxc" : "qemu"; const task = await createOperationTask({ taskType: OperationTaskType.VM_CONFIG, vm: { id: service.vm.id, name: service.vm.name, node: service.vm.node }, requestedBy: input.actorEmail, payload: { lifecycle_action: "package_update", package_options: input.packageOptions } }); await markOperationTaskRunning(task.id); try { const proxmoxConfig: Record = { cores: cpuCores, memory: ramMb }; const upid = await updateVmConfiguration(service.vm.node, service.vm.vmid, type, proxmoxConfig); await prisma.virtualMachine.update({ where: { id: service.vm.id }, data: { cpu_cores: cpuCores, ram_mb: ramMb, disk_gb: diskGb, proxmox_upid: upid ?? undefined } }); const updated = await prisma.provisionedService.update({ where: { id: service.id }, data: { package_options: input.packageOptions } }); await markOperationTaskSuccess(task.id, { service_id: updated.id, action: "package_update", package_options: input.packageOptions, ...(upid ? { upid } : {}) }); return updated; } catch (error) { const message = error instanceof Error ? error.message : "Package update failed"; await markOperationTaskFailed(task.id, message); throw error; } } export async function createTemplate(input: { name: string; slug?: string; templateType: "APPLICATION" | "KVM_TEMPLATE" | "LXC_TEMPLATE" | "ISO_IMAGE" | "ARCHIVE"; virtualizationType?: VmType; source?: string; description?: string; defaultCloudInit?: string; metadata?: Prisma.InputJsonValue; }) { const slug = input.slug && input.slug.length > 0 ? normalizeSlug(input.slug) : normalizeSlug(input.name); return prisma.appTemplate.create({ data: { name: input.name, slug, template_type: input.templateType, virtualization_type: input.virtualizationType, source: input.source, description: input.description, default_cloud_init: input.defaultCloudInit, metadata: input.metadata ?? {} } }); } export async function updateTemplate(templateId: string, input: Partial<{ name: string; slug: string; source: string; description: string; defaultCloudInit: string; isActive: boolean; metadata: Prisma.InputJsonValue; }>) { return prisma.appTemplate.update({ where: { id: templateId }, data: { name: input.name, slug: input.slug ? normalizeSlug(input.slug) : undefined, source: input.source, description: input.description, default_cloud_init: input.defaultCloudInit, is_active: input.isActive, metadata: input.metadata } }); } export async function deleteTemplate(templateId: string) { return prisma.appTemplate.delete({ where: { id: templateId } }); } export async function listTemplates(input?: { templateType?: string; isActive?: boolean }) { return prisma.appTemplate.findMany({ where: { template_type: input?.templateType as any, is_active: input?.isActive }, orderBy: [{ is_active: "desc" }, { created_at: "desc" }] }); } export async function createApplicationGroup(input: { name: string; slug?: string; description?: string }) { const slug = input.slug && input.slug.length > 0 ? normalizeSlug(input.slug) : normalizeSlug(input.name); return prisma.applicationGroup.create({ data: { name: input.name, slug, description: input.description } }); } export async function updateApplicationGroup(groupId: string, input: Partial<{ name: string; slug: string; description: string; isActive: boolean }>) { return prisma.applicationGroup.update({ where: { id: groupId }, data: { name: input.name, slug: input.slug ? normalizeSlug(input.slug) : undefined, description: input.description, is_active: input.isActive } }); } export async function deleteApplicationGroup(groupId: string) { return prisma.applicationGroup.delete({ where: { id: groupId } }); } export async function listApplicationGroups() { return prisma.applicationGroup.findMany({ include: { templates: { include: { template: true }, orderBy: { priority: "asc" } }, placement_policies: true, vmid_ranges: true }, orderBy: [{ is_active: "desc" }, { created_at: "desc" }] }); } export async function setApplicationGroupTemplates( groupId: string, templates: Array<{ templateId: string; priority?: number }> ) { await prisma.applicationGroupTemplate.deleteMany({ where: { group_id: groupId } }); if (templates.length === 0) { return []; } await prisma.applicationGroupTemplate.createMany({ data: templates.map((item, index) => ({ group_id: groupId, template_id: item.templateId, priority: item.priority ?? (index + 1) * 10 })) }); return prisma.applicationGroupTemplate.findMany({ where: { group_id: groupId }, include: { template: true }, orderBy: { priority: "asc" } }); } export async function createPlacementPolicy(input: { groupId?: string; nodeId?: string; productType?: ProductType; cpuWeight?: number; ramWeight?: number; diskWeight?: number; vmCountWeight?: number; maxVms?: number; minFreeRamMb?: number; minFreeDiskGb?: number; }) { return prisma.nodePlacementPolicy.create({ data: { group_id: input.groupId, node_id: input.nodeId, product_type: input.productType, cpu_weight: input.cpuWeight ?? 40, ram_weight: input.ramWeight ?? 30, disk_weight: input.diskWeight ?? 20, vm_count_weight: input.vmCountWeight ?? 10, max_vms: input.maxVms, min_free_ram_mb: input.minFreeRamMb, min_free_disk_gb: input.minFreeDiskGb } }); } export async function updatePlacementPolicy( policyId: string, input: Partial<{ cpuWeight: number; ramWeight: number; diskWeight: number; vmCountWeight: number; maxVms: number | null; minFreeRamMb: number | null; minFreeDiskGb: number | null; isActive: boolean; }> ) { return prisma.nodePlacementPolicy.update({ where: { id: policyId }, data: { cpu_weight: input.cpuWeight, ram_weight: input.ramWeight, disk_weight: input.diskWeight, vm_count_weight: input.vmCountWeight, max_vms: input.maxVms, min_free_ram_mb: input.minFreeRamMb, min_free_disk_gb: input.minFreeDiskGb, is_active: input.isActive } }); } export async function deletePlacementPolicy(policyId: string) { return prisma.nodePlacementPolicy.delete({ where: { id: policyId } }); } export async function listPlacementPolicies() { return prisma.nodePlacementPolicy.findMany({ include: { group: true, node: true }, orderBy: [{ is_active: "desc" }, { created_at: "desc" }] }); } export async function createVmIdRange(input: { nodeId?: string; nodeHostname: string; applicationGroupId?: string; rangeStart: number; rangeEnd: number; nextVmid?: number; }) { if (input.rangeEnd < input.rangeStart) { throw new HttpError(400, "rangeEnd must be >= rangeStart", "INVALID_VMID_RANGE"); } const next = input.nextVmid ?? input.rangeStart; if (next < input.rangeStart || next > input.rangeEnd) { throw new HttpError(400, "nextVmid must be within the configured range", "INVALID_VMID_RANGE"); } return prisma.vmIdRange.create({ data: { node_id: input.nodeId, node_hostname: input.nodeHostname, application_group_id: input.applicationGroupId, range_start: input.rangeStart, range_end: input.rangeEnd, next_vmid: next } }); } export async function updateVmIdRange( rangeId: string, input: Partial<{ rangeStart: number; rangeEnd: number; nextVmid: number; isActive: boolean; }> ) { const existing = await prisma.vmIdRange.findUnique({ where: { id: rangeId } }); if (!existing) { throw new HttpError(404, "VMID range not found", "VMID_RANGE_NOT_FOUND"); } const rangeStart = input.rangeStart ?? existing.range_start; const rangeEnd = input.rangeEnd ?? existing.range_end; const nextVmid = input.nextVmid ?? existing.next_vmid; if (rangeEnd < rangeStart) { throw new HttpError(400, "rangeEnd must be >= rangeStart", "INVALID_VMID_RANGE"); } if (nextVmid < rangeStart || nextVmid > rangeEnd) { throw new HttpError(400, "nextVmid must be within the configured range", "INVALID_VMID_RANGE"); } return prisma.vmIdRange.update({ where: { id: rangeId }, data: { range_start: input.rangeStart, range_end: input.rangeEnd, next_vmid: input.nextVmid, is_active: input.isActive } }); } export async function deleteVmIdRange(rangeId: string) { return prisma.vmIdRange.delete({ where: { id: rangeId } }); } export async function listVmIdRanges() { return prisma.vmIdRange.findMany({ include: { group: true, node: true }, orderBy: [{ is_active: "desc" }, { node_hostname: "asc" }, { range_start: "asc" }] }); }