1124 lines
31 KiB
TypeScript
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" }]
|
|
});
|
|
}
|