1403 lines
39 KiB
TypeScript
1403 lines
39 KiB
TypeScript
import { isIP } from "node:net";
|
|
import {
|
|
IpAllocationStrategy,
|
|
IpAddressStatus,
|
|
IpAssignmentType,
|
|
IpScope,
|
|
IpVersion,
|
|
Prisma,
|
|
PrivateNetworkAttachmentStatus,
|
|
PrivateNetworkType,
|
|
VmType
|
|
} from "@prisma/client";
|
|
import { prisma } from "../lib/prisma";
|
|
import { HttpError } from "../lib/http-error";
|
|
import { toPrismaJsonValue } from "../lib/prisma-json";
|
|
import { reconfigureVmNetwork, updateVmConfiguration } from "./proxmox.service";
|
|
|
|
type ListIpAddressInput = {
|
|
status?: IpAddressStatus;
|
|
version?: IpVersion;
|
|
scope?: IpScope;
|
|
nodeHostname?: string;
|
|
bridge?: string;
|
|
vlanTag?: number;
|
|
assignedVmId?: string;
|
|
limit?: number;
|
|
offset?: number;
|
|
};
|
|
|
|
type ImportIpAddressesInput = {
|
|
addresses?: string[];
|
|
cidr_blocks?: string[];
|
|
scope?: IpScope;
|
|
server?: string;
|
|
node_id?: string;
|
|
node_hostname?: string;
|
|
bridge?: string;
|
|
vlan_tag?: number;
|
|
sdn_zone?: string;
|
|
gateway?: string;
|
|
subnet?: string;
|
|
tags?: string[];
|
|
metadata?: Record<string, unknown>;
|
|
imported_by?: string;
|
|
};
|
|
|
|
type AssignIpInput = {
|
|
vm_id: string;
|
|
ip_address_id?: string;
|
|
address?: string;
|
|
scope?: IpScope;
|
|
version?: IpVersion;
|
|
assignment_type?: IpAssignmentType;
|
|
interface_name?: string;
|
|
notes?: string;
|
|
actor_email?: string;
|
|
metadata?: Record<string, unknown>;
|
|
};
|
|
|
|
type ReturnIpInput = {
|
|
assignment_id?: string;
|
|
ip_address_id?: string;
|
|
};
|
|
|
|
type CreatePrivateNetworkInput = {
|
|
name: string;
|
|
slug?: string;
|
|
network_type?: PrivateNetworkType;
|
|
cidr: string;
|
|
gateway?: string;
|
|
bridge?: string;
|
|
vlan_tag?: number;
|
|
sdn_zone?: string;
|
|
server?: string;
|
|
node_hostname?: string;
|
|
metadata?: Record<string, unknown>;
|
|
created_by?: string;
|
|
};
|
|
|
|
type AttachPrivateNetworkInput = {
|
|
network_id: string;
|
|
vm_id: string;
|
|
interface_name?: string;
|
|
requested_ip?: string;
|
|
actor_email?: string;
|
|
metadata?: Record<string, unknown>;
|
|
};
|
|
|
|
type DetachPrivateNetworkInput = {
|
|
attachment_id: string;
|
|
actor_email?: string;
|
|
};
|
|
|
|
type UpsertTenantQuotaInput = {
|
|
tenant_id: string;
|
|
ipv4_limit?: number | null;
|
|
ipv6_limit?: number | null;
|
|
reserved_ipv4?: number;
|
|
reserved_ipv6?: number;
|
|
burst_allowed?: boolean;
|
|
burst_ipv4_limit?: number | null;
|
|
burst_ipv6_limit?: number | null;
|
|
is_active?: boolean;
|
|
metadata?: Record<string, unknown>;
|
|
created_by?: string;
|
|
};
|
|
|
|
type CreateReservedRangeInput = {
|
|
name: string;
|
|
cidr: string;
|
|
scope?: IpScope;
|
|
tenant_id?: string;
|
|
reason?: string;
|
|
node_hostname?: string;
|
|
bridge?: string;
|
|
vlan_tag?: number;
|
|
sdn_zone?: string;
|
|
is_active?: boolean;
|
|
metadata?: Record<string, unknown>;
|
|
created_by?: string;
|
|
};
|
|
|
|
type UpsertIpPoolPolicyInput = {
|
|
policy_id?: string;
|
|
name: string;
|
|
tenant_id?: string;
|
|
scope?: IpScope;
|
|
version?: IpVersion;
|
|
node_hostname?: string;
|
|
bridge?: string;
|
|
vlan_tag?: number;
|
|
sdn_zone?: string;
|
|
allocation_strategy?: IpAllocationStrategy;
|
|
enforce_quota?: boolean;
|
|
disallow_reserved_use?: boolean;
|
|
is_active?: boolean;
|
|
priority?: number;
|
|
metadata?: Record<string, unknown>;
|
|
created_by?: string;
|
|
};
|
|
|
|
function normalizeSlug(value: string) {
|
|
return value
|
|
.toLowerCase()
|
|
.trim()
|
|
.replace(/[^a-z0-9\s-]/g, "")
|
|
.replace(/\s+/g, "-")
|
|
.replace(/-+/g, "-");
|
|
}
|
|
|
|
function parseIpVersion(address: string): IpVersion {
|
|
const version = isIP(address);
|
|
if (version === 4) return IpVersion.IPV4;
|
|
if (version === 6) return IpVersion.IPV6;
|
|
throw new HttpError(400, `Invalid IP address: ${address}`, "INVALID_IP_ADDRESS");
|
|
}
|
|
|
|
function parseCidr(cidr: string): { address: string; prefix: number; version: IpVersion } {
|
|
const trimmed = cidr.trim();
|
|
const [address, rawPrefix] = trimmed.split("/");
|
|
if (!address || !rawPrefix) {
|
|
throw new HttpError(400, `Invalid CIDR block: ${cidr}`, "INVALID_CIDR");
|
|
}
|
|
|
|
const version = parseIpVersion(address);
|
|
const prefix = Number(rawPrefix);
|
|
const maxPrefix = version === IpVersion.IPV4 ? 32 : 128;
|
|
if (!Number.isInteger(prefix) || prefix < 0 || prefix > maxPrefix) {
|
|
throw new HttpError(400, `CIDR prefix out of range: ${cidr}`, "INVALID_CIDR");
|
|
}
|
|
|
|
return { address, prefix, version };
|
|
}
|
|
|
|
function ipv4ToInt(address: string) {
|
|
const parts = address.split(".").map((value) => Number(value));
|
|
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
|
|
throw new HttpError(400, `Invalid IPv4 address: ${address}`, "INVALID_IPV4");
|
|
}
|
|
|
|
return (((parts[0] << 24) >>> 0) + ((parts[1] << 16) >>> 0) + ((parts[2] << 8) >>> 0) + (parts[3] >>> 0)) >>> 0;
|
|
}
|
|
|
|
function intToIpv4(value: number) {
|
|
return `${(value >>> 24) & 255}.${(value >>> 16) & 255}.${(value >>> 8) & 255}.${value & 255}`;
|
|
}
|
|
|
|
function expandIpv4Cidr(address: string, prefix: number): string[] {
|
|
const hostBits = 32 - prefix;
|
|
if (hostBits > 12) {
|
|
throw new HttpError(400, `CIDR block too large for bulk import (${address}/${prefix})`, "CIDR_TOO_LARGE");
|
|
}
|
|
|
|
const size = 2 ** hostBits;
|
|
const mask = prefix === 0 ? 0 : (0xffffffff << hostBits) >>> 0;
|
|
const base = ipv4ToInt(address) & mask;
|
|
const output: string[] = [];
|
|
|
|
for (let index = 0; index < size; index += 1) {
|
|
output.push(intToIpv4((base + index) >>> 0));
|
|
}
|
|
|
|
return output;
|
|
}
|
|
|
|
function normalizeIpv6(address: string) {
|
|
if (!address.includes(":")) {
|
|
throw new HttpError(400, `Invalid IPv6 address: ${address}`, "INVALID_IPV6");
|
|
}
|
|
|
|
const [leftPart, rightPart] = address.split("::");
|
|
if (address.split("::").length > 2) {
|
|
throw new HttpError(400, `Invalid IPv6 address: ${address}`, "INVALID_IPV6");
|
|
}
|
|
|
|
const left = leftPart ? leftPart.split(":").filter((item) => item.length > 0) : [];
|
|
const right = rightPart ? rightPart.split(":").filter((item) => item.length > 0) : [];
|
|
const missing = 8 - (left.length + right.length);
|
|
|
|
if (missing < 0) {
|
|
throw new HttpError(400, `Invalid IPv6 address: ${address}`, "INVALID_IPV6");
|
|
}
|
|
|
|
const groups = [...left, ...Array.from({ length: missing }, () => "0"), ...right];
|
|
if (groups.length !== 8) {
|
|
throw new HttpError(400, `Invalid IPv6 address: ${address}`, "INVALID_IPV6");
|
|
}
|
|
|
|
return groups.map((group) => {
|
|
const parsed = Number.parseInt(group, 16);
|
|
if (!Number.isInteger(parsed) || parsed < 0 || parsed > 0xffff) {
|
|
throw new HttpError(400, `Invalid IPv6 address: ${address}`, "INVALID_IPV6");
|
|
}
|
|
return parsed;
|
|
});
|
|
}
|
|
|
|
function ipv6ToBigInt(address: string) {
|
|
const groups = normalizeIpv6(address);
|
|
let output = 0n;
|
|
|
|
for (const group of groups) {
|
|
output = (output << 16n) + BigInt(group);
|
|
}
|
|
|
|
return output;
|
|
}
|
|
|
|
function ipToBigInt(address: string, version: IpVersion) {
|
|
if (version === IpVersion.IPV4) {
|
|
return BigInt(ipv4ToInt(address));
|
|
}
|
|
|
|
return ipv6ToBigInt(address);
|
|
}
|
|
|
|
function cidrBounds(cidr: string) {
|
|
const parsed = parseCidr(cidr);
|
|
const bits = parsed.version === IpVersion.IPV4 ? 32 : 128;
|
|
const base = ipToBigInt(parsed.address, parsed.version);
|
|
const hostBits = BigInt(bits - parsed.prefix);
|
|
const size = 1n << hostBits;
|
|
const mask = ((1n << BigInt(bits)) - 1n) ^ (size - 1n);
|
|
const network = base & mask;
|
|
const broadcast = network + size - 1n;
|
|
|
|
return {
|
|
...parsed,
|
|
network,
|
|
broadcast
|
|
};
|
|
}
|
|
|
|
function addressInCidr(address: string, cidr: string) {
|
|
const bounds = cidrBounds(cidr);
|
|
const addressVersion = parseIpVersion(address);
|
|
if (addressVersion !== bounds.version) {
|
|
return false;
|
|
}
|
|
|
|
const numeric = ipToBigInt(address, addressVersion);
|
|
return numeric >= bounds.network && numeric <= bounds.broadcast;
|
|
}
|
|
|
|
function samePlacementContext(
|
|
candidate: { node_hostname: string | null; bridge: string | null; vlan_tag: number | null; sdn_zone: string | null },
|
|
selector: { node_hostname?: string | null; bridge?: string | null; vlan_tag?: number | null; sdn_zone?: string | null }
|
|
) {
|
|
if (selector.node_hostname && candidate.node_hostname && selector.node_hostname !== candidate.node_hostname) return false;
|
|
if (selector.bridge && candidate.bridge && selector.bridge !== candidate.bridge) return false;
|
|
if (typeof selector.vlan_tag === "number" && typeof candidate.vlan_tag === "number" && selector.vlan_tag !== candidate.vlan_tag) return false;
|
|
if (selector.sdn_zone && candidate.sdn_zone && selector.sdn_zone !== candidate.sdn_zone) return false;
|
|
return true;
|
|
}
|
|
|
|
async function fetchVmForNetwork(vmId: string) {
|
|
const vm = await prisma.virtualMachine.findUnique({
|
|
where: { id: vmId },
|
|
select: {
|
|
id: true,
|
|
vmid: true,
|
|
name: true,
|
|
node: true,
|
|
type: true,
|
|
tenant_id: true,
|
|
ip_address: true
|
|
}
|
|
});
|
|
|
|
if (!vm) {
|
|
throw new HttpError(404, "VM not found", "VM_NOT_FOUND");
|
|
}
|
|
|
|
return vm;
|
|
}
|
|
|
|
function vmRuntime(vmType: VmType): "qemu" | "lxc" {
|
|
return vmType === VmType.LXC ? "lxc" : "qemu";
|
|
}
|
|
|
|
async function resolveAllocationPolicy(input: {
|
|
tenant_id: string;
|
|
scope?: IpScope;
|
|
version?: IpVersion;
|
|
node_hostname?: string | null;
|
|
bridge?: string | null;
|
|
vlan_tag?: number | null;
|
|
sdn_zone?: string | null;
|
|
}) {
|
|
const policies = await prisma.ipPoolPolicy.findMany({
|
|
where: {
|
|
is_active: true,
|
|
OR: [{ tenant_id: input.tenant_id }, { tenant_id: null }]
|
|
},
|
|
orderBy: [{ priority: "asc" }, { created_at: "asc" }]
|
|
});
|
|
|
|
const matches = policies.filter((policy) => {
|
|
if (policy.tenant_id && policy.tenant_id !== input.tenant_id) return false;
|
|
if (policy.scope && input.scope && policy.scope !== input.scope) return false;
|
|
if (policy.version && input.version && policy.version !== input.version) return false;
|
|
if (policy.node_hostname && input.node_hostname && policy.node_hostname !== input.node_hostname) return false;
|
|
if (policy.bridge && input.bridge && policy.bridge !== input.bridge) return false;
|
|
if (typeof policy.vlan_tag === "number" && typeof input.vlan_tag === "number" && policy.vlan_tag !== input.vlan_tag) return false;
|
|
if (policy.sdn_zone && input.sdn_zone && policy.sdn_zone !== input.sdn_zone) return false;
|
|
return true;
|
|
});
|
|
|
|
const scored = matches
|
|
.map((policy) => {
|
|
let score = 0;
|
|
if (policy.tenant_id) score += 100;
|
|
if (policy.scope) score += 10;
|
|
if (policy.version) score += 10;
|
|
if (policy.node_hostname) score += 4;
|
|
if (policy.bridge) score += 3;
|
|
if (typeof policy.vlan_tag === "number") score += 2;
|
|
if (policy.sdn_zone) score += 1;
|
|
score -= policy.priority / 1000;
|
|
return { policy, score };
|
|
})
|
|
.sort((a, b) => b.score - a.score);
|
|
|
|
return scored[0]?.policy ?? null;
|
|
}
|
|
|
|
async function enforceTenantQuota(input: { tenant_id: string; version: IpVersion; policyEnforced: boolean }) {
|
|
if (!input.policyEnforced) {
|
|
return;
|
|
}
|
|
|
|
const quota = await prisma.tenantIpQuota.findUnique({
|
|
where: { tenant_id: input.tenant_id }
|
|
});
|
|
|
|
if (!quota || !quota.is_active) {
|
|
return;
|
|
}
|
|
|
|
const [assignedV4, assignedV6] = await Promise.all([
|
|
prisma.ipAssignment.count({
|
|
where: {
|
|
tenant_id: input.tenant_id,
|
|
is_active: true,
|
|
ip_address: {
|
|
version: IpVersion.IPV4
|
|
}
|
|
}
|
|
}),
|
|
prisma.ipAssignment.count({
|
|
where: {
|
|
tenant_id: input.tenant_id,
|
|
is_active: true,
|
|
ip_address: {
|
|
version: IpVersion.IPV6
|
|
}
|
|
}
|
|
})
|
|
]);
|
|
|
|
const v4Limit = quota.ipv4_limit ?? null;
|
|
const v6Limit = quota.ipv6_limit ?? null;
|
|
const v4Effective = quota.burst_allowed ? quota.burst_ipv4_limit ?? v4Limit : v4Limit;
|
|
const v6Effective = quota.burst_allowed ? quota.burst_ipv6_limit ?? v6Limit : v6Limit;
|
|
|
|
if (input.version === IpVersion.IPV4 && typeof v4Effective === "number" && assignedV4 >= v4Effective) {
|
|
throw new HttpError(409, "Tenant IPv4 quota exhausted", "TENANT_IPV4_QUOTA_EXCEEDED");
|
|
}
|
|
|
|
if (input.version === IpVersion.IPV6 && typeof v6Effective === "number" && assignedV6 >= v6Effective) {
|
|
throw new HttpError(409, "Tenant IPv6 quota exhausted", "TENANT_IPV6_QUOTA_EXCEEDED");
|
|
}
|
|
}
|
|
|
|
function isCandidateReservedForOtherTenant(
|
|
candidate: {
|
|
address: string;
|
|
scope: IpScope;
|
|
version: IpVersion;
|
|
node_hostname: string | null;
|
|
bridge: string | null;
|
|
vlan_tag: number | null;
|
|
sdn_zone: string | null;
|
|
},
|
|
ranges: Array<{
|
|
tenant_id: string | null;
|
|
cidr: string;
|
|
scope: IpScope;
|
|
version: IpVersion;
|
|
node_hostname: string | null;
|
|
bridge: string | null;
|
|
vlan_tag: number | null;
|
|
sdn_zone: string | null;
|
|
}>,
|
|
tenantId: string
|
|
) {
|
|
const matches = ranges.filter((range) => {
|
|
if (range.scope !== candidate.scope) return false;
|
|
if (range.version !== candidate.version) return false;
|
|
if (!samePlacementContext(candidate, range)) return false;
|
|
return addressInCidr(candidate.address, range.cidr);
|
|
});
|
|
|
|
if (matches.length === 0) return { blocked: false, tenantReserved: false };
|
|
if (matches.some((range) => range.tenant_id === tenantId)) return { blocked: false, tenantReserved: true };
|
|
return { blocked: true, tenantReserved: false };
|
|
}
|
|
|
|
function pickBestFitCandidate(
|
|
candidates: Array<{
|
|
id: string;
|
|
address: string;
|
|
cidr: number;
|
|
version: IpVersion;
|
|
subnet: string | null;
|
|
imported_at: Date;
|
|
tenantReserved: boolean;
|
|
}>,
|
|
strategy: IpAllocationStrategy
|
|
) {
|
|
if (candidates.length === 0) return null;
|
|
|
|
if (strategy === IpAllocationStrategy.FIRST_AVAILABLE) {
|
|
return [...candidates].sort((a, b) => a.imported_at.getTime() - b.imported_at.getTime() || a.address.localeCompare(b.address))[0];
|
|
}
|
|
|
|
const groupAvailability = candidates.reduce<Record<string, number>>((acc, item) => {
|
|
const key = item.subnet ?? `${item.address}/${item.cidr}`;
|
|
acc[key] = (acc[key] ?? 0) + 1;
|
|
return acc;
|
|
}, {});
|
|
|
|
return [...candidates].sort((a, b) => {
|
|
if (a.tenantReserved !== b.tenantReserved) return a.tenantReserved ? -1 : 1;
|
|
const aGroup = groupAvailability[a.subnet ?? `${a.address}/${a.cidr}`] ?? 0;
|
|
const bGroup = groupAvailability[b.subnet ?? `${b.address}/${b.cidr}`] ?? 0;
|
|
if (aGroup !== bGroup) return aGroup - bGroup;
|
|
if (a.cidr !== b.cidr) return b.cidr - a.cidr;
|
|
return a.imported_at.getTime() - b.imported_at.getTime() || a.address.localeCompare(b.address);
|
|
})[0];
|
|
}
|
|
|
|
export async function listIpAddresses(input: ListIpAddressInput) {
|
|
const where: Prisma.IpAddressPoolWhereInput = {};
|
|
if (input.status) where.status = input.status;
|
|
if (input.version) where.version = input.version;
|
|
if (input.scope) where.scope = input.scope;
|
|
if (input.nodeHostname) where.node_hostname = input.nodeHostname;
|
|
if (input.bridge) where.bridge = input.bridge;
|
|
if (typeof input.vlanTag === "number") where.vlan_tag = input.vlanTag;
|
|
if (input.assignedVmId) where.assigned_vm_id = input.assignedVmId;
|
|
|
|
const limit = Math.min(Math.max(input.limit ?? 100, 1), 500);
|
|
const offset = Math.max(input.offset ?? 0, 0);
|
|
|
|
const [data, total] = await Promise.all([
|
|
prisma.ipAddressPool.findMany({
|
|
where,
|
|
include: {
|
|
assigned_vm: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
tenant_id: true
|
|
}
|
|
}
|
|
},
|
|
orderBy: [{ status: "asc" }, { address: "asc" }],
|
|
take: limit,
|
|
skip: offset
|
|
}),
|
|
prisma.ipAddressPool.count({ where })
|
|
]);
|
|
|
|
return {
|
|
data,
|
|
meta: {
|
|
total,
|
|
limit,
|
|
offset
|
|
}
|
|
};
|
|
}
|
|
|
|
export async function importIpAddresses(input: ImportIpAddressesInput) {
|
|
const baseTags = Array.isArray(input.tags) ? input.tags.filter((item) => item.trim().length > 0) : [];
|
|
const metadata = input.metadata ? toPrismaJsonValue(input.metadata) : {};
|
|
const directAddresses = Array.isArray(input.addresses) ? input.addresses : [];
|
|
const cidrBlocks = Array.isArray(input.cidr_blocks) ? input.cidr_blocks : [];
|
|
|
|
const prepared: Array<{
|
|
address: string;
|
|
cidr: number;
|
|
version: IpVersion;
|
|
status: IpAddressStatus;
|
|
subnet: string | null;
|
|
}> = [];
|
|
|
|
for (const raw of directAddresses) {
|
|
const address = raw.trim();
|
|
const version = parseIpVersion(address);
|
|
prepared.push({
|
|
address,
|
|
cidr: version === IpVersion.IPV4 ? 32 : 128,
|
|
version,
|
|
status: IpAddressStatus.AVAILABLE,
|
|
subnet: input.subnet ?? null
|
|
});
|
|
}
|
|
|
|
for (const block of cidrBlocks) {
|
|
const parsed = parseCidr(block);
|
|
|
|
if (parsed.version === IpVersion.IPV4) {
|
|
const expanded = expandIpv4Cidr(parsed.address, parsed.prefix);
|
|
for (const address of expanded) {
|
|
prepared.push({
|
|
address,
|
|
cidr: parsed.prefix,
|
|
version: parsed.version,
|
|
status: IpAddressStatus.AVAILABLE,
|
|
subnet: block
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (parsed.prefix === 128) {
|
|
prepared.push({
|
|
address: parsed.address,
|
|
cidr: parsed.prefix,
|
|
version: parsed.version,
|
|
status: IpAddressStatus.AVAILABLE,
|
|
subnet: block
|
|
});
|
|
} else {
|
|
prepared.push({
|
|
address: parsed.address,
|
|
cidr: parsed.prefix,
|
|
version: parsed.version,
|
|
status: IpAddressStatus.RESERVED,
|
|
subnet: block
|
|
});
|
|
}
|
|
}
|
|
|
|
if (prepared.length === 0) {
|
|
throw new HttpError(400, "No addresses or CIDR blocks supplied", "EMPTY_IMPORT");
|
|
}
|
|
|
|
const uniqueMap = new Map<string, (typeof prepared)[number]>();
|
|
for (const item of prepared) {
|
|
const key = `${item.address}/${item.cidr}`;
|
|
if (!uniqueMap.has(key)) {
|
|
uniqueMap.set(key, item);
|
|
}
|
|
}
|
|
|
|
const values = [...uniqueMap.values()];
|
|
const existing = await prisma.ipAddressPool.findMany({
|
|
where: {
|
|
OR: values.map((item) => ({
|
|
address: item.address,
|
|
cidr: item.cidr
|
|
}))
|
|
},
|
|
select: { address: true, cidr: true }
|
|
});
|
|
|
|
const existingKey = new Set(existing.map((item) => `${item.address}/${item.cidr}`));
|
|
const toInsert = values.filter((item) => !existingKey.has(`${item.address}/${item.cidr}`));
|
|
|
|
if (toInsert.length > 0) {
|
|
await prisma.ipAddressPool.createMany({
|
|
data: toInsert.map((item) => ({
|
|
address: item.address,
|
|
cidr: item.cidr,
|
|
version: item.version,
|
|
scope: input.scope ?? IpScope.PUBLIC,
|
|
status: item.status,
|
|
gateway: input.gateway,
|
|
subnet: item.subnet,
|
|
server: input.server,
|
|
node_id: input.node_id,
|
|
node_hostname: input.node_hostname,
|
|
bridge: input.bridge,
|
|
vlan_tag: input.vlan_tag,
|
|
sdn_zone: input.sdn_zone,
|
|
tags: baseTags,
|
|
metadata,
|
|
imported_by: input.imported_by
|
|
}))
|
|
});
|
|
}
|
|
|
|
return {
|
|
imported: toInsert.length,
|
|
skipped_existing: values.length - toInsert.length,
|
|
total_candidates: values.length
|
|
};
|
|
}
|
|
|
|
export async function assignIpToVm(input: AssignIpInput) {
|
|
const vm = await fetchVmForNetwork(input.vm_id);
|
|
const assignmentType = input.assignment_type ?? IpAssignmentType.ADDITIONAL;
|
|
const metadata = input.metadata ? toPrismaJsonValue(input.metadata) : {};
|
|
|
|
const resolvedPolicy = await resolveAllocationPolicy({
|
|
tenant_id: vm.tenant_id,
|
|
scope: input.scope,
|
|
version: input.version,
|
|
node_hostname: vm.node,
|
|
bridge: null,
|
|
vlan_tag: null,
|
|
sdn_zone: null
|
|
});
|
|
|
|
const where: Prisma.IpAddressPoolWhereInput = {
|
|
status: IpAddressStatus.AVAILABLE
|
|
};
|
|
|
|
if (input.ip_address_id) where.id = input.ip_address_id;
|
|
if (input.address) where.address = input.address;
|
|
if (input.scope) where.scope = input.scope;
|
|
if (input.version) where.version = input.version;
|
|
if (resolvedPolicy?.scope) where.scope = resolvedPolicy.scope;
|
|
if (resolvedPolicy?.version) where.version = resolvedPolicy.version;
|
|
if (resolvedPolicy?.node_hostname) where.node_hostname = resolvedPolicy.node_hostname;
|
|
if (resolvedPolicy?.bridge) where.bridge = resolvedPolicy.bridge;
|
|
if (typeof resolvedPolicy?.vlan_tag === "number") where.vlan_tag = resolvedPolicy.vlan_tag;
|
|
if (resolvedPolicy?.sdn_zone) where.sdn_zone = resolvedPolicy.sdn_zone;
|
|
|
|
const candidates = await prisma.ipAddressPool.findMany({
|
|
where,
|
|
select: {
|
|
id: true,
|
|
address: true,
|
|
cidr: true,
|
|
version: true,
|
|
scope: true,
|
|
subnet: true,
|
|
node_hostname: true,
|
|
bridge: true,
|
|
vlan_tag: true,
|
|
sdn_zone: true,
|
|
imported_at: true
|
|
},
|
|
orderBy: [{ imported_at: "asc" }, { address: "asc" }],
|
|
take: input.ip_address_id || input.address ? 5 : 3000
|
|
});
|
|
|
|
if (candidates.length === 0) {
|
|
throw new HttpError(404, "No available IP address found", "IP_NOT_AVAILABLE");
|
|
}
|
|
|
|
const reservedRanges = await prisma.ipReservedRange.findMany({
|
|
where: {
|
|
is_active: true,
|
|
OR: [{ tenant_id: vm.tenant_id }, { tenant_id: null }]
|
|
},
|
|
select: {
|
|
tenant_id: true,
|
|
cidr: true,
|
|
scope: true,
|
|
version: true,
|
|
node_hostname: true,
|
|
bridge: true,
|
|
vlan_tag: true,
|
|
sdn_zone: true
|
|
}
|
|
});
|
|
|
|
const candidateFlags = candidates
|
|
.map((candidate) => {
|
|
const reservation = isCandidateReservedForOtherTenant(candidate, reservedRanges, vm.tenant_id);
|
|
return {
|
|
...candidate,
|
|
tenantReserved: reservation.tenantReserved,
|
|
blocked: reservation.blocked
|
|
};
|
|
})
|
|
.filter((candidate) => !(resolvedPolicy?.disallow_reserved_use ?? true ? candidate.blocked : false));
|
|
|
|
if (candidateFlags.length === 0) {
|
|
throw new HttpError(409, "All candidate IPs are reserved by policy", "IP_RESERVED_BY_POLICY");
|
|
}
|
|
|
|
const strategy = resolvedPolicy?.allocation_strategy ?? IpAllocationStrategy.BEST_FIT;
|
|
const picked = pickBestFitCandidate(candidateFlags, strategy);
|
|
|
|
if (!picked) {
|
|
throw new HttpError(404, "No allocatable IP candidate found", "IP_NOT_AVAILABLE");
|
|
}
|
|
|
|
await enforceTenantQuota({
|
|
tenant_id: vm.tenant_id,
|
|
version: picked.version,
|
|
policyEnforced: resolvedPolicy?.enforce_quota ?? true
|
|
});
|
|
|
|
return prisma.$transaction(async (tx) => {
|
|
const updatedIp = await tx.ipAddressPool.update({
|
|
where: { id: picked.id },
|
|
data: {
|
|
status: IpAddressStatus.ASSIGNED,
|
|
assigned_vm_id: vm.id,
|
|
assigned_tenant_id: vm.tenant_id,
|
|
assigned_at: new Date(),
|
|
returned_at: null
|
|
}
|
|
});
|
|
|
|
const assignment = await tx.ipAssignment.create({
|
|
data: {
|
|
ip_address_id: updatedIp.id,
|
|
vm_id: vm.id,
|
|
tenant_id: vm.tenant_id,
|
|
assignment_type: assignmentType,
|
|
interface_name: input.interface_name,
|
|
notes: input.notes,
|
|
metadata,
|
|
assigned_by: input.actor_email
|
|
},
|
|
include: {
|
|
ip_address: true,
|
|
vm: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
tenant_id: true
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
if (assignmentType === IpAssignmentType.PRIMARY) {
|
|
await tx.virtualMachine.update({
|
|
where: { id: vm.id },
|
|
data: {
|
|
ip_address: updatedIp.address
|
|
}
|
|
});
|
|
}
|
|
|
|
return assignment;
|
|
});
|
|
}
|
|
|
|
export async function returnAssignedIp(input: ReturnIpInput) {
|
|
let assignment = input.assignment_id
|
|
? await prisma.ipAssignment.findUnique({
|
|
where: { id: input.assignment_id },
|
|
include: {
|
|
ip_address: true,
|
|
vm: {
|
|
select: {
|
|
id: true,
|
|
ip_address: true
|
|
}
|
|
}
|
|
}
|
|
})
|
|
: null;
|
|
|
|
if (!assignment && input.ip_address_id) {
|
|
assignment = await prisma.ipAssignment.findFirst({
|
|
where: {
|
|
ip_address_id: input.ip_address_id,
|
|
is_active: true
|
|
},
|
|
include: {
|
|
ip_address: true,
|
|
vm: {
|
|
select: {
|
|
id: true,
|
|
ip_address: true
|
|
}
|
|
}
|
|
},
|
|
orderBy: {
|
|
assigned_at: "desc"
|
|
}
|
|
});
|
|
}
|
|
|
|
if (!assignment) {
|
|
throw new HttpError(404, "Active IP assignment not found", "IP_ASSIGNMENT_NOT_FOUND");
|
|
}
|
|
|
|
if (!assignment.is_active) {
|
|
return assignment;
|
|
}
|
|
|
|
return prisma.$transaction(async (tx) => {
|
|
const released = await tx.ipAssignment.update({
|
|
where: { id: assignment!.id },
|
|
data: {
|
|
is_active: false,
|
|
released_at: new Date()
|
|
},
|
|
include: {
|
|
ip_address: true,
|
|
vm: {
|
|
select: {
|
|
id: true,
|
|
ip_address: true
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
await tx.ipAddressPool.update({
|
|
where: { id: assignment!.ip_address_id },
|
|
data: {
|
|
status: IpAddressStatus.AVAILABLE,
|
|
assigned_vm_id: null,
|
|
assigned_tenant_id: null,
|
|
assigned_at: null,
|
|
returned_at: new Date()
|
|
}
|
|
});
|
|
|
|
if (
|
|
assignment!.assignment_type === IpAssignmentType.PRIMARY &&
|
|
released.vm.ip_address &&
|
|
released.vm.ip_address === released.ip_address.address
|
|
) {
|
|
await tx.virtualMachine.update({
|
|
where: { id: released.vm.id },
|
|
data: {
|
|
ip_address: null
|
|
}
|
|
});
|
|
}
|
|
|
|
return released;
|
|
});
|
|
}
|
|
|
|
export async function listIpAssignments(params?: { vm_id?: string; tenant_id?: string; active_only?: boolean }) {
|
|
const where: Prisma.IpAssignmentWhereInput = {};
|
|
if (params?.vm_id) where.vm_id = params.vm_id;
|
|
if (params?.tenant_id) where.tenant_id = params.tenant_id;
|
|
if (params?.active_only) where.is_active = true;
|
|
|
|
return prisma.ipAssignment.findMany({
|
|
where,
|
|
include: {
|
|
ip_address: true,
|
|
vm: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
tenant_id: true,
|
|
node: true
|
|
}
|
|
}
|
|
},
|
|
orderBy: [{ is_active: "desc" }, { assigned_at: "desc" }]
|
|
});
|
|
}
|
|
|
|
export async function subnetUtilizationDashboard(input?: {
|
|
scope?: IpScope;
|
|
version?: IpVersion;
|
|
node_hostname?: string;
|
|
bridge?: string;
|
|
vlan_tag?: number;
|
|
tenant_id?: string;
|
|
}) {
|
|
const where: Prisma.IpAddressPoolWhereInput = {};
|
|
if (input?.scope) where.scope = input.scope;
|
|
if (input?.version) where.version = input.version;
|
|
if (input?.node_hostname) where.node_hostname = input.node_hostname;
|
|
if (input?.bridge) where.bridge = input.bridge;
|
|
if (typeof input?.vlan_tag === "number") where.vlan_tag = input.vlan_tag;
|
|
if (input?.tenant_id) {
|
|
where.OR = [{ assigned_tenant_id: input.tenant_id }, { status: IpAddressStatus.AVAILABLE }];
|
|
}
|
|
|
|
const [ips, quotas, activeAssignments, reservedRanges] = await Promise.all([
|
|
prisma.ipAddressPool.findMany({
|
|
where,
|
|
select: {
|
|
id: true,
|
|
address: true,
|
|
cidr: true,
|
|
subnet: true,
|
|
scope: true,
|
|
version: true,
|
|
status: true,
|
|
assigned_tenant_id: true,
|
|
node_hostname: true,
|
|
bridge: true,
|
|
vlan_tag: true
|
|
}
|
|
}),
|
|
prisma.tenantIpQuota.findMany({
|
|
where: input?.tenant_id ? { tenant_id: input.tenant_id } : undefined,
|
|
include: {
|
|
tenant: {
|
|
select: {
|
|
id: true,
|
|
name: true
|
|
}
|
|
}
|
|
}
|
|
}),
|
|
prisma.ipAssignment.findMany({
|
|
where: {
|
|
is_active: true,
|
|
...(input?.tenant_id ? { tenant_id: input.tenant_id } : {})
|
|
},
|
|
include: {
|
|
ip_address: {
|
|
select: {
|
|
version: true
|
|
}
|
|
}
|
|
}
|
|
}),
|
|
prisma.ipReservedRange.findMany({
|
|
where: {
|
|
is_active: true,
|
|
...(input?.tenant_id ? { OR: [{ tenant_id: input.tenant_id }, { tenant_id: null }] } : {})
|
|
}
|
|
})
|
|
]);
|
|
|
|
const subnetMap = new Map<
|
|
string,
|
|
{
|
|
subnet: string;
|
|
scope: IpScope;
|
|
version: IpVersion;
|
|
node_hostname: string | null;
|
|
bridge: string | null;
|
|
vlan_tag: number | null;
|
|
total: number;
|
|
available: number;
|
|
assigned: number;
|
|
reserved: number;
|
|
retired: number;
|
|
}
|
|
>();
|
|
|
|
for (const ip of ips) {
|
|
const subnet = ip.subnet ?? `${ip.address}/${ip.cidr}`;
|
|
const key = `${ip.scope}:${ip.version}:${ip.node_hostname ?? "-"}:${ip.bridge ?? "-"}:${ip.vlan_tag ?? "-"}:${subnet}`;
|
|
if (!subnetMap.has(key)) {
|
|
subnetMap.set(key, {
|
|
subnet,
|
|
scope: ip.scope,
|
|
version: ip.version,
|
|
node_hostname: ip.node_hostname,
|
|
bridge: ip.bridge,
|
|
vlan_tag: ip.vlan_tag,
|
|
total: 0,
|
|
available: 0,
|
|
assigned: 0,
|
|
reserved: 0,
|
|
retired: 0
|
|
});
|
|
}
|
|
|
|
const entry = subnetMap.get(key)!;
|
|
entry.total += 1;
|
|
if (ip.status === IpAddressStatus.AVAILABLE) entry.available += 1;
|
|
if (ip.status === IpAddressStatus.ASSIGNED) entry.assigned += 1;
|
|
if (ip.status === IpAddressStatus.RESERVED) entry.reserved += 1;
|
|
if (ip.status === IpAddressStatus.RETIRED) entry.retired += 1;
|
|
}
|
|
|
|
const subnets = [...subnetMap.values()]
|
|
.map((item) => {
|
|
const utilization_pct = item.total > 0 ? Number(((item.assigned / item.total) * 100).toFixed(2)) : 0;
|
|
const pressure_pct = item.total > 0 ? Number((((item.assigned + item.reserved) / item.total) * 100).toFixed(2)) : 0;
|
|
return {
|
|
...item,
|
|
utilization_pct,
|
|
pressure_pct
|
|
};
|
|
})
|
|
.sort((a, b) => b.utilization_pct - a.utilization_pct || a.subnet.localeCompare(b.subnet));
|
|
|
|
const assignmentSummary = activeAssignments.reduce(
|
|
(acc, item) => {
|
|
if (item.ip_address.version === IpVersion.IPV4) acc.ipv4 += 1;
|
|
if (item.ip_address.version === IpVersion.IPV6) acc.ipv6 += 1;
|
|
return acc;
|
|
},
|
|
{ total: activeAssignments.length, ipv4: 0, ipv6: 0 }
|
|
);
|
|
|
|
return {
|
|
subnets,
|
|
quota_summary: quotas.map((quota) => ({
|
|
tenant_id: quota.tenant_id,
|
|
tenant_name: quota.tenant.name,
|
|
ipv4_limit: quota.ipv4_limit,
|
|
ipv6_limit: quota.ipv6_limit,
|
|
burst_allowed: quota.burst_allowed
|
|
})),
|
|
assignment_summary: assignmentSummary,
|
|
reserved_range_count: reservedRanges.length
|
|
};
|
|
}
|
|
|
|
export async function upsertTenantIpQuota(input: UpsertTenantQuotaInput) {
|
|
return prisma.tenantIpQuota.upsert({
|
|
where: {
|
|
tenant_id: input.tenant_id
|
|
},
|
|
update: {
|
|
ipv4_limit: input.ipv4_limit ?? undefined,
|
|
ipv6_limit: input.ipv6_limit ?? undefined,
|
|
reserved_ipv4: input.reserved_ipv4,
|
|
reserved_ipv6: input.reserved_ipv6,
|
|
burst_allowed: input.burst_allowed,
|
|
burst_ipv4_limit: input.burst_ipv4_limit ?? undefined,
|
|
burst_ipv6_limit: input.burst_ipv6_limit ?? undefined,
|
|
is_active: input.is_active,
|
|
metadata: input.metadata ? toPrismaJsonValue(input.metadata) : undefined
|
|
},
|
|
create: {
|
|
tenant_id: input.tenant_id,
|
|
ipv4_limit: input.ipv4_limit ?? null,
|
|
ipv6_limit: input.ipv6_limit ?? null,
|
|
reserved_ipv4: input.reserved_ipv4 ?? 0,
|
|
reserved_ipv6: input.reserved_ipv6 ?? 0,
|
|
burst_allowed: input.burst_allowed ?? false,
|
|
burst_ipv4_limit: input.burst_ipv4_limit ?? null,
|
|
burst_ipv6_limit: input.burst_ipv6_limit ?? null,
|
|
is_active: input.is_active ?? true,
|
|
metadata: input.metadata ? toPrismaJsonValue(input.metadata) : {},
|
|
created_by: input.created_by
|
|
},
|
|
include: {
|
|
tenant: {
|
|
select: {
|
|
id: true,
|
|
name: true
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
export async function listTenantIpQuotas(tenantId?: string) {
|
|
return prisma.tenantIpQuota.findMany({
|
|
where: tenantId ? { tenant_id: tenantId } : undefined,
|
|
include: {
|
|
tenant: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
slug: true
|
|
}
|
|
}
|
|
},
|
|
orderBy: [{ created_at: "desc" }]
|
|
});
|
|
}
|
|
|
|
export async function createIpReservedRange(input: CreateReservedRangeInput) {
|
|
const parsed = parseCidr(input.cidr);
|
|
return prisma.ipReservedRange.create({
|
|
data: {
|
|
name: input.name,
|
|
cidr: input.cidr,
|
|
version: parsed.version,
|
|
scope: input.scope ?? IpScope.PUBLIC,
|
|
tenant_id: input.tenant_id,
|
|
reason: input.reason,
|
|
node_hostname: input.node_hostname,
|
|
bridge: input.bridge,
|
|
vlan_tag: input.vlan_tag,
|
|
sdn_zone: input.sdn_zone,
|
|
is_active: input.is_active ?? true,
|
|
metadata: input.metadata ? toPrismaJsonValue(input.metadata) : {},
|
|
created_by: input.created_by
|
|
}
|
|
});
|
|
}
|
|
|
|
export async function listIpReservedRanges() {
|
|
return prisma.ipReservedRange.findMany({
|
|
include: {
|
|
tenant: {
|
|
select: {
|
|
id: true,
|
|
name: true
|
|
}
|
|
}
|
|
},
|
|
orderBy: [{ is_active: "desc" }, { created_at: "desc" }]
|
|
});
|
|
}
|
|
|
|
export async function updateIpReservedRange(rangeId: string, input: Partial<CreateReservedRangeInput>) {
|
|
const parsed = input.cidr ? parseCidr(input.cidr) : null;
|
|
return prisma.ipReservedRange.update({
|
|
where: { id: rangeId },
|
|
data: {
|
|
name: input.name,
|
|
cidr: input.cidr,
|
|
version: parsed?.version,
|
|
scope: input.scope,
|
|
tenant_id: input.tenant_id,
|
|
reason: input.reason,
|
|
node_hostname: input.node_hostname,
|
|
bridge: input.bridge,
|
|
vlan_tag: input.vlan_tag,
|
|
sdn_zone: input.sdn_zone,
|
|
is_active: input.is_active,
|
|
metadata: input.metadata ? toPrismaJsonValue(input.metadata) : undefined
|
|
}
|
|
});
|
|
}
|
|
|
|
export async function upsertIpPoolPolicy(input: UpsertIpPoolPolicyInput) {
|
|
if (input.policy_id) {
|
|
return prisma.ipPoolPolicy.update({
|
|
where: { id: input.policy_id },
|
|
data: {
|
|
name: input.name,
|
|
tenant_id: input.tenant_id,
|
|
scope: input.scope,
|
|
version: input.version,
|
|
node_hostname: input.node_hostname,
|
|
bridge: input.bridge,
|
|
vlan_tag: input.vlan_tag,
|
|
sdn_zone: input.sdn_zone,
|
|
allocation_strategy: input.allocation_strategy,
|
|
enforce_quota: input.enforce_quota,
|
|
disallow_reserved_use: input.disallow_reserved_use,
|
|
is_active: input.is_active,
|
|
priority: input.priority,
|
|
metadata: input.metadata ? toPrismaJsonValue(input.metadata) : undefined
|
|
}
|
|
});
|
|
}
|
|
|
|
return prisma.ipPoolPolicy.create({
|
|
data: {
|
|
name: input.name,
|
|
tenant_id: input.tenant_id,
|
|
scope: input.scope,
|
|
version: input.version,
|
|
node_hostname: input.node_hostname,
|
|
bridge: input.bridge,
|
|
vlan_tag: input.vlan_tag,
|
|
sdn_zone: input.sdn_zone,
|
|
allocation_strategy: input.allocation_strategy ?? IpAllocationStrategy.BEST_FIT,
|
|
enforce_quota: input.enforce_quota ?? true,
|
|
disallow_reserved_use: input.disallow_reserved_use ?? true,
|
|
is_active: input.is_active ?? true,
|
|
priority: input.priority ?? 100,
|
|
metadata: input.metadata ? toPrismaJsonValue(input.metadata) : {},
|
|
created_by: input.created_by
|
|
}
|
|
});
|
|
}
|
|
|
|
export async function listIpPoolPolicies() {
|
|
return prisma.ipPoolPolicy.findMany({
|
|
include: {
|
|
tenant: {
|
|
select: {
|
|
id: true,
|
|
name: true
|
|
}
|
|
}
|
|
},
|
|
orderBy: [{ priority: "asc" }, { created_at: "asc" }]
|
|
});
|
|
}
|
|
|
|
export async function createPrivateNetwork(input: CreatePrivateNetworkInput) {
|
|
parseCidr(input.cidr);
|
|
|
|
const slug = input.slug && input.slug.trim().length > 0 ? normalizeSlug(input.slug) : normalizeSlug(input.name);
|
|
|
|
return prisma.privateNetwork.create({
|
|
data: {
|
|
name: input.name,
|
|
slug,
|
|
network_type: input.network_type ?? PrivateNetworkType.VLAN,
|
|
cidr: input.cidr,
|
|
gateway: input.gateway,
|
|
bridge: input.bridge,
|
|
vlan_tag: input.vlan_tag,
|
|
sdn_zone: input.sdn_zone,
|
|
server: input.server,
|
|
node_hostname: input.node_hostname,
|
|
metadata: input.metadata ? toPrismaJsonValue(input.metadata) : {},
|
|
created_by: input.created_by
|
|
}
|
|
});
|
|
}
|
|
|
|
export async function listPrivateNetworks() {
|
|
return prisma.privateNetwork.findMany({
|
|
include: {
|
|
attachments: {
|
|
where: {
|
|
status: PrivateNetworkAttachmentStatus.ATTACHED
|
|
},
|
|
select: {
|
|
id: true,
|
|
vm_id: true,
|
|
interface_name: true,
|
|
attached_at: true
|
|
}
|
|
}
|
|
},
|
|
orderBy: [{ created_at: "desc" }]
|
|
});
|
|
}
|
|
|
|
export async function attachPrivateNetwork(input: AttachPrivateNetworkInput) {
|
|
const [network, vm] = await Promise.all([
|
|
prisma.privateNetwork.findUnique({ where: { id: input.network_id } }),
|
|
fetchVmForNetwork(input.vm_id)
|
|
]);
|
|
|
|
if (!network) {
|
|
throw new HttpError(404, "Private network not found", "PRIVATE_NETWORK_NOT_FOUND");
|
|
}
|
|
|
|
const interfaceName = input.interface_name ?? "net1";
|
|
const runtime = vmRuntime(vm.type);
|
|
const bridge = network.bridge ?? (typeof network.vlan_tag === "number" ? `vmbr${network.vlan_tag}` : "vmbr0");
|
|
const upid = await reconfigureVmNetwork(vm.node, vm.vmid, runtime, {
|
|
interface_name: interfaceName,
|
|
bridge,
|
|
vlan_tag: network.vlan_tag ?? undefined,
|
|
ip_mode: input.requested_ip ? "static" : "dhcp",
|
|
ip_cidr: input.requested_ip,
|
|
gateway: input.requested_ip ? network.gateway ?? undefined : undefined
|
|
});
|
|
|
|
const existing = await prisma.privateNetworkAttachment.findFirst({
|
|
where: {
|
|
network_id: network.id,
|
|
vm_id: vm.id,
|
|
interface_name: interfaceName
|
|
}
|
|
});
|
|
|
|
if (existing) {
|
|
return prisma.privateNetworkAttachment.update({
|
|
where: { id: existing.id },
|
|
data: {
|
|
tenant_id: vm.tenant_id,
|
|
requested_ip: input.requested_ip,
|
|
status: PrivateNetworkAttachmentStatus.ATTACHED,
|
|
detached_at: null,
|
|
attached_by: input.actor_email,
|
|
attached_at: new Date(),
|
|
metadata: toPrismaJsonValue({
|
|
...(input.metadata ?? {}),
|
|
proxmox_upid: upid
|
|
})
|
|
},
|
|
include: {
|
|
network: true,
|
|
vm: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
tenant_id: true,
|
|
node: true
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
return prisma.privateNetworkAttachment.create({
|
|
data: {
|
|
network_id: network.id,
|
|
vm_id: vm.id,
|
|
tenant_id: vm.tenant_id,
|
|
interface_name: interfaceName,
|
|
requested_ip: input.requested_ip,
|
|
status: PrivateNetworkAttachmentStatus.ATTACHED,
|
|
attached_by: input.actor_email,
|
|
metadata: toPrismaJsonValue({
|
|
...(input.metadata ?? {}),
|
|
proxmox_upid: upid
|
|
})
|
|
},
|
|
include: {
|
|
network: true,
|
|
vm: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
tenant_id: true,
|
|
node: true
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
export async function detachPrivateNetwork(input: DetachPrivateNetworkInput) {
|
|
const attachment = await prisma.privateNetworkAttachment.findUnique({
|
|
where: { id: input.attachment_id },
|
|
include: {
|
|
vm: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
tenant_id: true,
|
|
vmid: true,
|
|
node: true,
|
|
type: true
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
if (!attachment) {
|
|
throw new HttpError(404, "Private network attachment not found", "PRIVATE_NETWORK_ATTACHMENT_NOT_FOUND");
|
|
}
|
|
|
|
if (attachment.status === PrivateNetworkAttachmentStatus.DETACHED) {
|
|
return attachment;
|
|
}
|
|
|
|
const runtime = vmRuntime(attachment.vm.type);
|
|
const interfaceName = attachment.interface_name ?? "net1";
|
|
const upid = await updateVmConfiguration(attachment.vm.node, attachment.vm.vmid, runtime, {
|
|
delete: interfaceName
|
|
});
|
|
|
|
return prisma.privateNetworkAttachment.update({
|
|
where: { id: attachment.id },
|
|
data: {
|
|
status: PrivateNetworkAttachmentStatus.DETACHED,
|
|
detached_at: new Date(),
|
|
metadata: toPrismaJsonValue({
|
|
...(attachment.metadata as Record<string, unknown>),
|
|
detached_by: input.actor_email,
|
|
detach_upid: upid
|
|
})
|
|
},
|
|
include: {
|
|
network: true,
|
|
vm: {
|
|
select: {
|
|
id: true,
|
|
name: true,
|
|
tenant_id: true,
|
|
node: true
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|