Files
proxpanel/backend/src/services/provisioning.service.ts

1124 lines
31 KiB
TypeScript

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<string, unknown> | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
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<string, string | number | boolean> = {
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" }]
});
}