chore: initialize repository with deployment baseline

This commit is contained in:
Austin A
2026-04-17 23:03:00 +01:00
parent f02ddf42aa
commit 5def26e0df
166 changed files with 43065 additions and 0 deletions

View File

@@ -0,0 +1,637 @@
import { OperationTaskType, Prisma } from "@prisma/client";
import { Router } from "express";
import { z } from "zod";
import { HttpError } from "../lib/http-error";
import { prisma } from "../lib/prisma";
import { authorize, requireAuth } from "../middleware/auth";
import {
addVmDisk,
clusterUsageGraphs,
deleteVm,
migrateVm,
nodeUsageGraphs,
vmUsageGraphs,
reinstallVm,
reconfigureVmNetwork,
restartVm,
resumeVm,
shutdownVm,
startVm,
stopVm,
suspendVm,
syncNodesAndVirtualMachines,
updateVmConfiguration,
vmConsoleTicket
} from "../services/proxmox.service";
import { logAudit } from "../services/audit.service";
import {
createOperationTask,
markOperationTaskFailed,
markOperationTaskRunning,
markOperationTaskSuccess
} from "../services/operations.service";
const router = Router();
const consoleTypeSchema = z.enum(["novnc", "spice", "xterm"]);
const graphTimeframeSchema = z.enum(["hour", "day", "week", "month", "year"]);
function vmRuntimeType(vm: { type: "QEMU" | "LXC" }) {
return vm.type === "LXC" ? "lxc" : "qemu";
}
function withUpid(payload: Prisma.InputJsonObject, upid?: string): Prisma.InputJsonObject {
if (!upid) {
return payload;
}
return {
...payload,
upid
};
}
async function fetchVm(vmId: string) {
const vm = await prisma.virtualMachine.findUnique({ where: { id: vmId } });
if (!vm) {
throw new HttpError(404, "VM not found", "VM_NOT_FOUND");
}
return vm;
}
async function resolveConsoleProxyTarget(node: string, consoleType: "novnc" | "spice" | "xterm") {
const setting = await prisma.setting.findUnique({
where: {
key: "console_proxy"
}
});
const raw = setting?.value as
| {
mode?: "cluster" | "per_node";
cluster?: Record<string, unknown>;
nodes?: Record<string, Record<string, unknown>>;
}
| undefined;
if (!raw) {
return undefined;
}
const mode = raw.mode ?? "cluster";
if (mode === "per_node") {
const nodeConfig = raw.nodes?.[node];
if (nodeConfig && typeof nodeConfig[consoleType] === "string") {
return String(nodeConfig[consoleType]);
}
}
if (raw.cluster && typeof raw.cluster[consoleType] === "string") {
return String(raw.cluster[consoleType]);
}
return undefined;
}
router.post("/sync", requireAuth, authorize("node:manage"), async (req, res, next) => {
try {
const task = await createOperationTask({
taskType: OperationTaskType.SYSTEM_SYNC,
requestedBy: req.user?.email,
payload: { source: "manual_sync" }
});
await markOperationTaskRunning(task.id);
try {
const result = await syncNodesAndVirtualMachines();
await markOperationTaskSuccess(task.id, {
node_count: result.node_count
});
await logAudit({
action: "proxmox_sync",
resource_type: "NODE",
actor_email: req.user!.email,
actor_role: req.user!.role,
details: {
node_count: result.node_count,
task_id: task.id
},
ip_address: req.ip
});
res.json({
...result,
task_id: task.id
});
} catch (error) {
const message = error instanceof Error ? error.message : "Proxmox sync failed";
await markOperationTaskFailed(task.id, message);
throw error;
}
} catch (error) {
next(error);
}
});
const actionSchema = z.object({
action: z.enum(["start", "stop", "restart", "shutdown", "suspend", "resume", "delete"])
});
router.post("/vms/:id/actions/:action", requireAuth, authorize("vm:update"), async (req, res, next) => {
try {
const { action } = actionSchema.parse(req.params);
const vm = await fetchVm(req.params.id);
const type = vmRuntimeType(vm);
const taskType = action === "delete" ? OperationTaskType.VM_DELETE : OperationTaskType.VM_POWER;
const task = await createOperationTask({
taskType,
vm: {
id: vm.id,
name: vm.name,
node: vm.node
},
requestedBy: req.user?.email,
payload: { action }
});
await markOperationTaskRunning(task.id);
let upid: string | undefined;
try {
if (action === "start") {
upid = await startVm(vm.node, vm.vmid, type);
await prisma.virtualMachine.update({ where: { id: vm.id }, data: { status: "RUNNING", proxmox_upid: upid } });
} else if (action === "stop") {
upid = await stopVm(vm.node, vm.vmid, type);
await prisma.virtualMachine.update({ where: { id: vm.id }, data: { status: "STOPPED", proxmox_upid: upid } });
} else if (action === "restart") {
upid = await restartVm(vm.node, vm.vmid, type);
await prisma.virtualMachine.update({ where: { id: vm.id }, data: { status: "RUNNING", proxmox_upid: upid } });
} else if (action === "shutdown") {
upid = await shutdownVm(vm.node, vm.vmid, type);
await prisma.virtualMachine.update({ where: { id: vm.id }, data: { status: "STOPPED", proxmox_upid: upid } });
} else if (action === "suspend") {
upid = await suspendVm(vm.node, vm.vmid, type);
await prisma.virtualMachine.update({ where: { id: vm.id }, data: { status: "PAUSED", proxmox_upid: upid } });
} else if (action === "resume") {
upid = await resumeVm(vm.node, vm.vmid, type);
await prisma.virtualMachine.update({ where: { id: vm.id }, data: { status: "RUNNING", proxmox_upid: upid } });
} else {
upid = await deleteVm(vm.node, vm.vmid, type);
await prisma.virtualMachine.delete({ where: { id: vm.id } });
}
const taskResult = withUpid(
{
vm_id: vm.id,
action
},
upid
);
await markOperationTaskSuccess(task.id, taskResult, upid);
await logAudit({
action: `vm_${action}`,
resource_type: "VM",
resource_id: vm.id,
resource_name: vm.name,
actor_email: req.user!.email,
actor_role: req.user!.role,
details: {
...taskResult,
task_id: task.id
},
ip_address: req.ip
});
res.json({ success: true, action, upid, task_id: task.id });
} catch (error) {
const message = error instanceof Error ? error.message : "VM action failed";
await markOperationTaskFailed(task.id, message);
throw error;
}
} catch (error) {
next(error);
}
});
const migrateSchema = z.object({
target_node: z.string().min(1)
});
router.post("/vms/:id/migrate", requireAuth, authorize("vm:update"), async (req, res, next) => {
try {
const payload = migrateSchema.parse(req.body);
const vm = await fetchVm(req.params.id);
const type = vmRuntimeType(vm);
const task = await createOperationTask({
taskType: OperationTaskType.VM_MIGRATION,
vm: {
id: vm.id,
name: vm.name,
node: vm.node
},
requestedBy: req.user?.email,
payload
});
await markOperationTaskRunning(task.id);
try {
const upid = await migrateVm(vm.node, vm.vmid, payload.target_node, type);
await prisma.virtualMachine.update({
where: { id: vm.id },
data: { node: payload.target_node, status: "MIGRATING", proxmox_upid: upid }
});
const migrationResult = withUpid(
{
vm_id: vm.id,
from_node: vm.node,
target_node: payload.target_node
},
upid
);
await markOperationTaskSuccess(task.id, migrationResult, upid);
res.json({ success: true, upid, target_node: payload.target_node, task_id: task.id });
} catch (error) {
const message = error instanceof Error ? error.message : "VM migrate failed";
await markOperationTaskFailed(task.id, message);
throw error;
}
} catch (error) {
next(error);
}
});
const configSchema = z
.object({
hostname: z.string().min(1).optional(),
iso_image: z.string().min(1).optional(),
boot_order: z.string().min(1).optional(),
ssh_public_key: z.string().min(10).optional(),
qemu_guest_agent: z.boolean().optional()
})
.refine((value) => Object.keys(value).length > 0, {
message: "At least one configuration field is required"
});
router.patch("/vms/:id/config", requireAuth, authorize("vm:update"), async (req, res, next) => {
try {
const payload = configSchema.parse(req.body ?? {});
const vm = await fetchVm(req.params.id);
const type = vmRuntimeType(vm);
const config: Record<string, string | number | boolean> = {};
if (payload.hostname) config.name = payload.hostname;
if (payload.boot_order) config.boot = payload.boot_order;
if (payload.ssh_public_key) config.sshkeys = payload.ssh_public_key;
if (payload.iso_image && vm.type === "QEMU") config.ide2 = `${payload.iso_image},media=cdrom`;
if (typeof payload.qemu_guest_agent === "boolean" && vm.type === "QEMU") {
config.agent = payload.qemu_guest_agent ? 1 : 0;
}
const task = await createOperationTask({
taskType: OperationTaskType.VM_CONFIG,
vm: { id: vm.id, name: vm.name, node: vm.node },
requestedBy: req.user?.email,
payload
});
await markOperationTaskRunning(task.id);
try {
const upid = await updateVmConfiguration(vm.node, vm.vmid, type, config);
const configResult = withUpid(
{
vm_id: vm.id,
config: config as unknown as Prisma.InputJsonValue
},
upid
);
await markOperationTaskSuccess(task.id, configResult, upid);
await logAudit({
action: "vm_config_update",
resource_type: "VM",
resource_id: vm.id,
resource_name: vm.name,
actor_email: req.user!.email,
actor_role: req.user!.role,
details: {
config: config as unknown as Prisma.InputJsonValue,
task_id: task.id,
...(upid ? { upid } : {})
},
ip_address: req.ip
});
res.json({ success: true, upid, task_id: task.id, config_applied: config });
} catch (error) {
const message = error instanceof Error ? error.message : "VM config update failed";
await markOperationTaskFailed(task.id, message);
throw error;
}
} catch (error) {
next(error);
}
});
const networkSchema = z.object({
interface_name: z.string().optional(),
bridge: z.string().min(1),
vlan_tag: z.number().int().min(0).max(4094).optional(),
rate_mbps: z.number().int().positive().optional(),
firewall: z.boolean().optional(),
ip_mode: z.enum(["dhcp", "static"]).default("dhcp"),
ip_cidr: z.string().optional(),
gateway: z.string().optional()
});
router.patch("/vms/:id/network", requireAuth, authorize("vm:update"), async (req, res, next) => {
try {
const payload = networkSchema.parse(req.body ?? {});
if (payload.ip_mode === "static" && !payload.ip_cidr) {
throw new HttpError(400, "ip_cidr is required when ip_mode=static", "INVALID_NETWORK_PAYLOAD");
}
const vm = await fetchVm(req.params.id);
const type = vmRuntimeType(vm);
const task = await createOperationTask({
taskType: OperationTaskType.VM_NETWORK,
vm: { id: vm.id, name: vm.name, node: vm.node },
requestedBy: req.user?.email,
payload
});
await markOperationTaskRunning(task.id);
try {
const networkInput: Parameters<typeof reconfigureVmNetwork>[3] = {
interface_name: payload.interface_name,
bridge: payload.bridge,
vlan_tag: payload.vlan_tag,
rate_mbps: payload.rate_mbps,
firewall: payload.firewall,
ip_mode: payload.ip_mode,
ip_cidr: payload.ip_cidr,
gateway: payload.gateway
};
const upid = await reconfigureVmNetwork(vm.node, vm.vmid, type, networkInput);
const networkResult = withUpid(
{
vm_id: vm.id,
network: payload as unknown as Prisma.InputJsonValue
},
upid
);
await markOperationTaskSuccess(task.id, networkResult, upid);
res.json({ success: true, upid, task_id: task.id });
} catch (error) {
const message = error instanceof Error ? error.message : "VM network update failed";
await markOperationTaskFailed(task.id, message);
throw error;
}
} catch (error) {
next(error);
}
});
const diskSchema = z.object({
storage: z.string().min(1),
size_gb: z.number().int().positive(),
bus: z.enum(["scsi", "sata", "virtio", "ide"]).default("scsi"),
mount_point: z.string().optional()
});
router.post("/vms/:id/disks", requireAuth, authorize("vm:update"), async (req, res, next) => {
try {
const payload = diskSchema.parse(req.body ?? {});
const vm = await fetchVm(req.params.id);
const type = vmRuntimeType(vm);
const task = await createOperationTask({
taskType: OperationTaskType.VM_CONFIG,
vm: { id: vm.id, name: vm.name, node: vm.node },
requestedBy: req.user?.email,
payload
});
await markOperationTaskRunning(task.id);
try {
const diskInput: Parameters<typeof addVmDisk>[3] = {
storage: payload.storage,
size_gb: payload.size_gb,
bus: payload.bus,
mount_point: payload.mount_point
};
const upid = await addVmDisk(vm.node, vm.vmid, type, diskInput);
const diskResult = withUpid(
{
vm_id: vm.id,
disk: payload as unknown as Prisma.InputJsonValue
},
upid
);
await markOperationTaskSuccess(task.id, diskResult, upid);
res.status(201).json({ success: true, upid, task_id: task.id });
} catch (error) {
const message = error instanceof Error ? error.message : "VM disk attach failed";
await markOperationTaskFailed(task.id, message);
throw error;
}
} catch (error) {
next(error);
}
});
const reinstallSchema = z.object({
backup_before_reinstall: z.boolean().default(false),
iso_image: z.string().optional(),
ssh_public_key: z.string().optional()
});
router.post("/vms/:id/reinstall", requireAuth, authorize("vm:update"), async (req, res, next) => {
try {
const payload = reinstallSchema.parse(req.body ?? {});
const vm = await fetchVm(req.params.id);
const type = vmRuntimeType(vm);
if (payload.backup_before_reinstall) {
await prisma.backup.create({
data: {
vm_id: vm.id,
vm_name: vm.name,
node: vm.node,
status: "PENDING",
type: "FULL",
schedule: "MANUAL",
notes: "Auto-created before VM reinstall"
}
});
}
const task = await createOperationTask({
taskType: OperationTaskType.VM_REINSTALL,
vm: { id: vm.id, name: vm.name, node: vm.node },
requestedBy: req.user?.email,
payload
});
await markOperationTaskRunning(task.id);
try {
const upid = await reinstallVm(vm.node, vm.vmid, type, {
iso_image: payload.iso_image,
ssh_public_key: payload.ssh_public_key
});
await prisma.virtualMachine.update({
where: { id: vm.id },
data: {
status: "RUNNING",
proxmox_upid: upid ?? undefined
}
});
const reinstallResult = withUpid(
{
vm_id: vm.id,
reinstall: payload as unknown as Prisma.InputJsonValue
},
upid
);
await markOperationTaskSuccess(task.id, reinstallResult, upid);
res.json({ success: true, upid, task_id: task.id });
} catch (error) {
const message = error instanceof Error ? error.message : "VM reinstall failed";
await markOperationTaskFailed(task.id, message);
throw error;
}
} catch (error) {
next(error);
}
});
router.get("/vms/:id/console", requireAuth, authorize("vm:read"), async (req, res, next) => {
try {
const vm = await fetchVm(req.params.id);
const type = vmRuntimeType(vm);
const consoleType = consoleTypeSchema.parse(
typeof req.query.console_type === "string"
? req.query.console_type.toLowerCase()
: "novnc"
);
const ticket = await vmConsoleTicket(vm.node, vm.vmid, type, consoleType);
const proxyTarget = await resolveConsoleProxyTarget(vm.node, consoleType);
res.json({
...ticket,
console_type: consoleType,
proxy_target: proxyTarget ?? null
});
} catch (error) {
next(error);
}
});
router.get("/vms/:id/usage-graphs", requireAuth, authorize("vm:read"), async (req, res, next) => {
try {
const vm = await fetchVm(req.params.id);
const type = vmRuntimeType(vm);
const timeframe = graphTimeframeSchema.parse(
typeof req.query.timeframe === "string" ? req.query.timeframe.toLowerCase() : "day"
);
const graph = await vmUsageGraphs(vm.node, vm.vmid, type, timeframe, {
cpu_usage: vm.cpu_usage,
ram_usage: vm.ram_usage,
disk_usage: vm.disk_usage,
network_in: vm.network_in,
network_out: vm.network_out
});
return res.json({
vm_id: vm.id,
vm_name: vm.name,
vm_type: vm.type,
node: vm.node,
timeframe: graph.timeframe,
source: graph.source,
summary: graph.summary,
points: graph.points
});
} catch (error) {
return next(error);
}
});
router.get("/nodes/:id/usage-graphs", requireAuth, authorize("node:read"), async (req, res, next) => {
try {
const node = await prisma.proxmoxNode.findFirst({
where: {
OR: [{ id: req.params.id }, { hostname: req.params.id }, { name: req.params.id }]
}
});
if (!node) {
throw new HttpError(404, "Node not found", "NODE_NOT_FOUND");
}
const timeframe = graphTimeframeSchema.parse(
typeof req.query.timeframe === "string" ? req.query.timeframe.toLowerCase() : "day"
);
const graph = await nodeUsageGraphs(node.hostname, timeframe, {
cpu_usage: node.cpu_usage,
ram_used_mb: node.ram_used_mb,
ram_total_mb: node.ram_total_mb,
disk_used_gb: node.disk_used_gb,
disk_total_gb: node.disk_total_gb
});
return res.json({
node_id: node.id,
node_name: node.name,
node_hostname: node.hostname,
timeframe: graph.timeframe,
source: graph.source,
summary: graph.summary,
points: graph.points
});
} catch (error) {
return next(error);
}
});
router.get("/cluster/usage-graphs", requireAuth, authorize("node:read"), async (req, res, next) => {
try {
const timeframe = graphTimeframeSchema.parse(
typeof req.query.timeframe === "string" ? req.query.timeframe.toLowerCase() : "day"
);
const graph = await clusterUsageGraphs(timeframe);
return res.json({
timeframe: graph.timeframe,
source: graph.source,
node_count: graph.node_count,
nodes: graph.nodes,
summary: graph.summary,
points: graph.points
});
} catch (error) {
return next(error);
}
});
export default router;