From a6a4b1c99fe2c2c7976211d8e780a4eecf7bd0cc Mon Sep 17 00:00:00 2001 From: cmyers-mieweb Date: Tue, 9 Jun 2026 15:46:26 -0700 Subject: [PATCH 1/4] Integrate NetBox VM management Add NetBox integration to register and remove LXC records that mirror managed containers. - Add utils/netbox.js implementing NetBox API helpers: nbFetch, findClusterId, createVirtualMachine, deleteVirtualMachine, and withNetbox (loads netbox_url/netbox_token from Settings). - Use withNetbox/createVirtualMachine in bin/create-container.js to register a VM after container creation (non-fatal; errors are warned). - Use withNetbox/deleteVirtualMachine in routers/api/v1/containers.js to remove the VM when a container is deleted. - Expose new settings keys (netbox_url, netbox_token) in routers/api/v1/settings.js for GET/PUT so NetBox credentials can be configured. The NetBox deletion helper is resilient to errors (logs but does not throw) to avoid blocking core workflows. --- create-a-container/bin/create-container.js | 19 +- .../routers/api/v1/containers.js | 8 + create-a-container/routers/api/v1/settings.js | 8 + create-a-container/utils/netbox.js | 203 ++++++++++++++++++ 4 files changed, 237 insertions(+), 1 deletion(-) create mode 100644 create-a-container/utils/netbox.js diff --git a/create-a-container/bin/create-container.js b/create-a-container/bin/create-container.js index 7b89acba..ec842efe 100755 --- a/create-a-container/bin/create-container.js +++ b/create-a-container/bin/create-container.js @@ -34,6 +34,7 @@ const { Container, Node, Site, Service, HTTPService, ExternalDomain, Setting } = const { parseArgs } = require(path.join(__dirname, '..', 'utils', 'cli')); const { isDockerImage, parseDockerRef, getImageDigest } = require(path.join(__dirname, '..', 'utils', 'docker-registry')); const { manageDnsRecords } = require(path.join(__dirname, '..', 'utils', 'cloudflare-dns')); +const { createVirtualMachine, withNetbox } = require(path.join(__dirname, '..', 'utils', 'netbox')); /** * Generate a filename for a pulled Docker image @@ -480,7 +481,23 @@ async function main() { const warnings = await manageDnsRecords(httpServices, site); for (const w of warnings) console.warn(`[DNS WARNING] ${w}`); } - + + // Register the container in NetBox if the integration is configured + await withNetbox(Setting, async (baseUrl, token) => { + console.log(`Registering container in NetBox (cluster: ${site.name})...`); + try { + await createVirtualMachine(baseUrl, token, { + hostname: container.hostname, + clusterName: site.name, + ipv4Address, + createdBy: container.username, + }); + console.log(`NetBox: VM "${container.hostname}" created`); + } catch (err) { + console.warn(`NetBox: VM creation failed (non-fatal): ${err.message}`); + } + }); + process.exit(0); } catch (err) { console.error('Container creation failed:', err.message); diff --git a/create-a-container/routers/api/v1/containers.js b/create-a-container/routers/api/v1/containers.js index ca4ff762..2ca64da8 100644 --- a/create-a-container/routers/api/v1/containers.js +++ b/create-a-container/routers/api/v1/containers.js @@ -14,11 +14,13 @@ const { Site, ExternalDomain, Job, + Setting, Sequelize, sequelize, } = require('../../../models'); const { parseDockerRef, getImageConfig, extractImageMetadata } = require('../../../utils/docker-registry'); const { manageDnsRecords } = require('../../../utils/cloudflare-dns'); +const { deleteVirtualMachine, withNetbox } = require('../../../utils/netbox'); const { apiAuth, asyncHandler, ok, created, ApiError } = require('../../../middlewares/api'); const router = express.Router({ mergeParams: true }); @@ -581,6 +583,12 @@ router.delete( } } await container.destroy(); + + // Remove the VM from NetBox if the integration is configured + await withNetbox(Setting, (baseUrl, token) => + deleteVirtualMachine(baseUrl, token, container.hostname), + ); + return ok(res, { deleted: true, dnsWarnings }); }), ); diff --git a/create-a-container/routers/api/v1/settings.js b/create-a-container/routers/api/v1/settings.js index 2e6fbb70..562957d0 100644 --- a/create-a-container/routers/api/v1/settings.js +++ b/create-a-container/routers/api/v1/settings.js @@ -17,6 +17,8 @@ const KEYS = [ 'smtp_url', 'smtp_noreply_address', 'default_container_env_vars', + 'netbox_url', + 'netbox_token', ]; router.get( @@ -36,6 +38,8 @@ router.get( smtpUrl: settings.smtp_url || '', smtpNoreplyAddress: settings.smtp_noreply_address || '', defaultContainerEnvVars, + netboxUrl: settings.netbox_url || '', + netboxToken: settings.netbox_token || '', }); }), ); @@ -50,6 +54,8 @@ router.put( smtpUrl, smtpNoreplyAddress, defaultContainerEnvVars, + netboxUrl, + netboxToken, } = req.body || {}; if (pushNotificationEnabled === true && (!pushNotificationUrl || pushNotificationUrl.trim() === '')) { @@ -75,6 +81,8 @@ router.put( await Setting.set('smtp_url', smtpUrl || ''); await Setting.set('smtp_noreply_address', smtpNoreplyAddress || ''); await Setting.set('default_container_env_vars', JSON.stringify(envVars)); + await Setting.set('netbox_url', netboxUrl || ''); + await Setting.set('netbox_token', netboxToken || ''); return ok(res, { saved: true }); }), diff --git a/create-a-container/utils/netbox.js b/create-a-container/utils/netbox.js new file mode 100644 index 00000000..ff8b74ba --- /dev/null +++ b/create-a-container/utils/netbox.js @@ -0,0 +1,203 @@ +/** + * NetBox API integration utility. + * + * Manages virtual machine entries in NetBox that mirror containers managed by + * this system. Each container gets a corresponding NetBox VM record with its + * IPv4 address and the site cluster name. + * + * Settings keys (stored in the Settings table): + * netbox_url — Base URL of the NetBox instance (e.g. https://netbox.example.com) + * netbox_token — API token for a NetBox user with write access to IPAM/Virtualization + * + * NetBox objects created per container: + * virtualization.virtual-machine — one per container (name = hostname) + * virtualization.interface — "eth0" on that VM + * ipam.ip-address — the container's IPv4 address assigned to eth0 + */ + +const NETBOX_COMMENT = 'This container was built using opensource-server'; + +/** + * Build request headers for NetBox API calls. + * @param {string} token - NetBox API token + */ +function headers(token) { + return { + Authorization: `Token ${token}`, + 'Content-Type': 'application/json', + Accept: 'application/json', + }; +} + +/** + * Perform a fetch against the NetBox API. + * Throws on non-2xx responses. Returns null for 204 No Content. + * @param {string} baseUrl - NetBox base URL (no trailing slash) + * @param {string} token - API token + * @param {string} path - API path (must start with /) + * @param {object} [options] - Additional fetch options + * @returns {Promise} + */ +async function nbFetch(baseUrl, token, path, options = {}) { + const url = `${baseUrl.replace(/\/$/, '')}/api${path}`; + const res = await fetch(url, { + ...options, + headers: { ...headers(token), ...(options.headers || {}) }, + }); + if (res.status === 204) return null; + if (!res.ok) { + const body = await res.text(); + throw new Error(`NetBox API error (${path}): HTTP ${res.status} — ${body}`); + } + return res.json(); +} + +/** + * Look up a NetBox cluster by name. + * @param {string} baseUrl + * @param {string} token + * @param {string} clusterName - Should match the Site.name value + * @returns {Promise} Cluster ID + * @throws {Error} If the cluster is not found in NetBox + */ +async function findClusterId(baseUrl, token, clusterName) { + const data = await nbFetch( + baseUrl, + token, + `/virtualization/clusters/?name=${encodeURIComponent(clusterName)}&limit=1`, + ); + if (!data?.results?.length) { + throw new Error(`NetBox: cluster "${clusterName}" not found`); + } + return data.results[0].id; +} + +/** + * Create a virtual machine record in NetBox for a newly provisioned container. + * + * Steps: + * 1. Resolve cluster ID from site name + * 2. Create the VM record + * 3. Create an eth0 interface on the VM + * 4. Create an IP address assigned to that interface + * 5. Set the VM's primary_ip4 to the new IP + * + * @param {string} baseUrl + * @param {string} token + * @param {object} opts + * @param {string} opts.hostname - Container hostname (becomes VM name) + * @param {string} opts.clusterName - Site name used to resolve the NetBox cluster + * @param {string} opts.ipv4Address - Container IPv4 address (CIDR or bare IP) + * @param {string} [opts.createdBy] - Username of the person who created the container + * @returns {Promise} The created NetBox VM object + */ +async function createVirtualMachine(baseUrl, token, { hostname, clusterName, ipv4Address, createdBy }) { + const clusterId = await findClusterId(baseUrl, token, clusterName); + const comment = createdBy + ? `${NETBOX_COMMENT}\nCreated by: ${createdBy}` + : NETBOX_COMMENT; + + const vm = await nbFetch(baseUrl, token, '/virtualization/virtual-machines/', { + method: 'POST', + body: JSON.stringify({ + name: hostname, + cluster: clusterId, + status: 'active', + comments: comment, + }), + }); + + const iface = await nbFetch(baseUrl, token, '/virtualization/interfaces/', { + method: 'POST', + body: JSON.stringify({ + virtual_machine: vm.id, + name: 'eth0', + }), + }); + + // NetBox requires CIDR notation — default to /32 for a bare address + const cidr = ipv4Address.includes('/') ? ipv4Address : `${ipv4Address}/32`; + const ip = await nbFetch(baseUrl, token, '/ipam/ip-addresses/', { + method: 'POST', + body: JSON.stringify({ + address: cidr, + assigned_object_type: 'virtualization.vminterface', + assigned_object_id: iface.id, + comments: NETBOX_COMMENT, + }), + }); + + await nbFetch(baseUrl, token, `/virtualization/virtual-machines/${vm.id}/`, { + method: 'PATCH', + body: JSON.stringify({ primary_ip4: ip.id }), + }); + + return vm; +} + +/** + * Delete a virtual machine from NetBox by container hostname. + * Also removes the associated interface and IP address. + * + * Non-throwing: logs errors but never propagates them so that a NetBox + * outage does not block container deletion in the primary system. + * + * @param {string} baseUrl + * @param {string} token + * @param {string} hostname - Container hostname + * @returns {Promise} + */ +async function deleteVirtualMachine(baseUrl, token, hostname) { + try { + const data = await nbFetch( + baseUrl, + token, + `/virtualization/virtual-machines/?name=${encodeURIComponent(hostname)}&limit=1`, + ); + if (!data?.results?.length) { + console.log(`NetBox: no VM found for "${hostname}", skipping deletion`); + return; + } + const vm = data.results[0]; + + const ifaceData = await nbFetch( + baseUrl, + token, + `/virtualization/interfaces/?virtual_machine_id=${vm.id}`, + ); + for (const iface of ifaceData?.results || []) { + const ipData = await nbFetch( + baseUrl, + token, + `/ipam/ip-addresses/?assigned_object_type=virtualization.vminterface&assigned_object_id=${iface.id}`, + ); + for (const ip of ipData?.results || []) { + await nbFetch(baseUrl, token, `/ipam/ip-addresses/${ip.id}/`, { method: 'DELETE' }); + } + await nbFetch(baseUrl, token, `/virtualization/interfaces/${iface.id}/`, { method: 'DELETE' }); + } + + await nbFetch(baseUrl, token, `/virtualization/virtual-machines/${vm.id}/`, { method: 'DELETE' }); + console.log(`NetBox: VM "${hostname}" deleted`); + } catch (err) { + console.error(`NetBox: failed to delete VM "${hostname}": ${err.message}`); + } +} + +/** + * Load NetBox credentials from the Settings model and invoke a callback. + * Returns null without calling fn if NetBox is not configured. + * + * @param {object} Setting - Sequelize Setting model + * @param {function(string, string): Promise<*>} fn - Called with (baseUrl, token) + * @returns {Promise<*|null>} + */ +async function withNetbox(Setting, fn) { + const settings = await Setting.getMultiple(['netbox_url', 'netbox_token']); + const baseUrl = settings.netbox_url; + const token = settings.netbox_token; + if (!baseUrl || !token) return null; + return fn(baseUrl, token); +} + +module.exports = { createVirtualMachine, deleteVirtualMachine, withNetbox }; From 77034d8d2d23e452709390a561dd3bc24263ad3e Mon Sep 17 00:00:00 2001 From: cmyers-mieweb Date: Wed, 10 Jun 2026 13:18:39 -0700 Subject: [PATCH 2/4] Map Proxmox node and site in NetBox VM creation Add helpers to resolve NetBox site and device IDs (findSiteId, findDeviceId) and extend createVirtualMachine to accept nodeName. The code now concurrently resolves cluster, site, and device IDs and includes site/device fields in the VM payload when available. Also pass nodeName from the container script when creating the NetBox VM record so the Proxmox node can be mapped to the NetBox device. --- create-a-container/bin/create-container.js | 1 + create-a-container/utils/netbox.js | 57 +++++++++++++++++++--- 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/create-a-container/bin/create-container.js b/create-a-container/bin/create-container.js index ec842efe..dfa1b3d0 100755 --- a/create-a-container/bin/create-container.js +++ b/create-a-container/bin/create-container.js @@ -491,6 +491,7 @@ async function main() { clusterName: site.name, ipv4Address, createdBy: container.username, + nodeName: container.node?.name, }); console.log(`NetBox: VM "${container.hostname}" created`); } catch (err) { diff --git a/create-a-container/utils/netbox.js b/create-a-container/utils/netbox.js index ff8b74ba..3c5170cf 100644 --- a/create-a-container/utils/netbox.js +++ b/create-a-container/utils/netbox.js @@ -52,6 +52,38 @@ async function nbFetch(baseUrl, token, path, options = {}) { return res.json(); } +/** + * Look up a NetBox site by name. Returns null if not found. + * @param {string} baseUrl + * @param {string} token + * @param {string} siteName + * @returns {Promise} Site ID or null + */ +async function findSiteId(baseUrl, token, siteName) { + const data = await nbFetch( + baseUrl, + token, + `/dcim/sites/?name=${encodeURIComponent(siteName)}&limit=1`, + ); + return data?.results?.[0]?.id ?? null; +} + +/** + * Look up a NetBox device by name. Returns null if not found. + * @param {string} baseUrl + * @param {string} token + * @param {string} deviceName + * @returns {Promise} Device ID or null + */ +async function findDeviceId(baseUrl, token, deviceName) { + const data = await nbFetch( + baseUrl, + token, + `/dcim/devices/?name=${encodeURIComponent(deviceName)}&limit=1`, + ); + return data?.results?.[0]?.id ?? null; +} + /** * Look up a NetBox cluster by name. * @param {string} baseUrl @@ -89,22 +121,31 @@ async function findClusterId(baseUrl, token, clusterName) { * @param {string} opts.clusterName - Site name used to resolve the NetBox cluster * @param {string} opts.ipv4Address - Container IPv4 address (CIDR or bare IP) * @param {string} [opts.createdBy] - Username of the person who created the container + * @param {string} [opts.nodeName] - Proxmox node name; mapped to the NetBox device within the cluster * @returns {Promise} The created NetBox VM object */ -async function createVirtualMachine(baseUrl, token, { hostname, clusterName, ipv4Address, createdBy }) { - const clusterId = await findClusterId(baseUrl, token, clusterName); +async function createVirtualMachine(baseUrl, token, { hostname, clusterName, ipv4Address, createdBy, nodeName }) { + const [clusterId, siteId, deviceId] = await Promise.all([ + findClusterId(baseUrl, token, clusterName), + findSiteId(baseUrl, token, clusterName), + nodeName ? findDeviceId(baseUrl, token, nodeName) : Promise.resolve(null), + ]); const comment = createdBy ? `${NETBOX_COMMENT}\nCreated by: ${createdBy}` : NETBOX_COMMENT; + const vmBody = { + name: hostname, + cluster: clusterId, + status: 'active', + comments: comment, + ...(siteId !== null && { site: siteId }), + ...(deviceId !== null && { device: deviceId }), + }; + const vm = await nbFetch(baseUrl, token, '/virtualization/virtual-machines/', { method: 'POST', - body: JSON.stringify({ - name: hostname, - cluster: clusterId, - status: 'active', - comments: comment, - }), + body: JSON.stringify(vmBody), }); const iface = await nbFetch(baseUrl, token, '/virtualization/interfaces/', { From a0b2e70406a8e0121d3238fbeb938b5be6a9fa52 Mon Sep 17 00:00:00 2001 From: cmyers-mieweb Date: Fri, 12 Jun 2026 10:10:26 -0700 Subject: [PATCH 3/4] Add NetBox resource fields and settings UI Expose NetBox URL/token in settings and types, add UI inputs and defaults; include vcpus/memory/disk when creating VMs and export a new updateVirtualMachine helper to patch resources by hostname. Also tighten hostname validation on the container form and improve API error toast details. Minor: include **/node_modules in .dockerignore. --- .dockerignore | 1 + create-a-container/bin/create-container.js | 3 ++ create-a-container/client/src/lib/types.ts | 2 + .../pages/containers/ContainerFormPage.tsx | 19 ++++++- .../src/pages/settings/SettingsPage.tsx | 26 ++++++++++ create-a-container/utils/netbox.js | 52 ++++++++++++++++++- 6 files changed, 99 insertions(+), 4 deletions(-) diff --git a/.dockerignore b/.dockerignore index 1c85eea6..b044d4ac 100644 --- a/.dockerignore +++ b/.dockerignore @@ -4,3 +4,4 @@ /create-a-container/.env /mie-opensource-landing/build */node_modules +**/node_modules diff --git a/create-a-container/bin/create-container.js b/create-a-container/bin/create-container.js index dfa1b3d0..30db8cdd 100755 --- a/create-a-container/bin/create-container.js +++ b/create-a-container/bin/create-container.js @@ -492,6 +492,9 @@ async function main() { ipv4Address, createdBy: container.username, nodeName: container.node?.name, + vcpus: 4, + memoryMb: 4096, + diskGb: 50, }); console.log(`NetBox: VM "${container.hostname}" created`); } catch (err) { diff --git a/create-a-container/client/src/lib/types.ts b/create-a-container/client/src/lib/types.ts index b9f9c0df..423552f2 100644 --- a/create-a-container/client/src/lib/types.ts +++ b/create-a-container/client/src/lib/types.ts @@ -166,5 +166,7 @@ export interface AppSettings { pushNotificationApiKey: string; smtpUrl: string; smtpNoreplyAddress: string; + netboxUrl: string; + netboxToken: string; defaultContainerEnvVars: { key: string; value: string; description?: string }[]; } diff --git a/create-a-container/client/src/pages/containers/ContainerFormPage.tsx b/create-a-container/client/src/pages/containers/ContainerFormPage.tsx index 3a746782..bc260266 100644 --- a/create-a-container/client/src/pages/containers/ContainerFormPage.tsx +++ b/create-a-container/client/src/pages/containers/ContainerFormPage.tsx @@ -60,7 +60,13 @@ const serviceSchema = z const envVarSchema = z.object({ key: z.string(), value: z.string() }); const schema = z.object({ - hostname: z.string().min(1, 'Required'), + hostname: z + .string() + .min(1, 'Required') + .regex( + /^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/, + 'Only lowercase letters, digits, and hyphens; must start and end with a letter or digit (max 63 chars)', + ), template: z.string().optional(), customTemplate: z.string().optional(), entrypoint: z.string().optional(), @@ -282,7 +288,16 @@ export function ContainerFormPage() { navigate(`/sites/${siteId}/containers`, { state: { dnsWarnings } }); } }, - onError: (err: ApiError) => toast.error(err.message), + onError: (err: ApiError) => { + if (err.fields && Object.keys(err.fields).length > 0) { + const detail = Object.entries(err.fields) + .map(([f, m]) => `${f}: ${m}`) + .join('; '); + toast.error(`${err.message} — ${detail}`); + } else { + toast.error(err.message); + } + }, }); if ((isEdit && containerLoading) || bootstrapLoading) { diff --git a/create-a-container/client/src/pages/settings/SettingsPage.tsx b/create-a-container/client/src/pages/settings/SettingsPage.tsx index a99b301b..1fc5ec5c 100644 --- a/create-a-container/client/src/pages/settings/SettingsPage.tsx +++ b/create-a-container/client/src/pages/settings/SettingsPage.tsx @@ -31,6 +31,8 @@ const schema = z.object({ smtpUrl: z.string(), smtpNoreplyAddress: z.string(), defaultContainerEnvVars: z.array(envVarSchema), + netboxUrl: z.string(), + netboxToken: z.string(), }).refine( (v) => !v.pushNotificationEnabled || v.pushNotificationUrl.trim() !== '', { path: ['pushNotificationUrl'], message: 'URL is required when push notifications are enabled' }, @@ -51,6 +53,8 @@ export function SettingsPage() { smtpUrl: '', smtpNoreplyAddress: '', defaultContainerEnvVars: [], + netboxUrl: '', + netboxToken: '', }, }); const { fields, append, remove } = useFieldArray({ control, name: 'defaultContainerEnvVars' }); @@ -129,6 +133,28 @@ export function SettingsPage() { ))} +
+

NetBox

+ + +
+ + {mutation.isSuccess && ( + + Your settings have been saved successfully. + + )} {mutation.error && {(mutation.error as ApiError).message}}
diff --git a/create-a-container/utils/netbox.js b/create-a-container/utils/netbox.js index 3c5170cf..b56c1aa7 100644 --- a/create-a-container/utils/netbox.js +++ b/create-a-container/utils/netbox.js @@ -122,9 +122,12 @@ async function findClusterId(baseUrl, token, clusterName) { * @param {string} opts.ipv4Address - Container IPv4 address (CIDR or bare IP) * @param {string} [opts.createdBy] - Username of the person who created the container * @param {string} [opts.nodeName] - Proxmox node name; mapped to the NetBox device within the cluster + * @param {number} [opts.vcpus] - Number of virtual CPUs + * @param {number} [opts.memoryMb] - RAM in megabytes + * @param {number} [opts.diskGb] - Disk size in gigabytes * @returns {Promise} The created NetBox VM object */ -async function createVirtualMachine(baseUrl, token, { hostname, clusterName, ipv4Address, createdBy, nodeName }) { +async function createVirtualMachine(baseUrl, token, { hostname, clusterName, ipv4Address, createdBy, nodeName, vcpus, memoryMb, diskGb }) { const [clusterId, siteId, deviceId] = await Promise.all([ findClusterId(baseUrl, token, clusterName), findSiteId(baseUrl, token, clusterName), @@ -141,6 +144,9 @@ async function createVirtualMachine(baseUrl, token, { hostname, clusterName, ipv comments: comment, ...(siteId !== null && { site: siteId }), ...(deviceId !== null && { device: deviceId }), + ...(vcpus != null && { vcpus }), + ...(memoryMb != null && { memory: memoryMb }), + ...(diskGb != null && { disk: diskGb }), }; const vm = await nbFetch(baseUrl, token, '/virtualization/virtual-machines/', { @@ -176,6 +182,48 @@ async function createVirtualMachine(baseUrl, token, { hostname, clusterName, ipv return vm; } +/** + * Update resource fields on an existing NetBox VM record by hostname. + * + * Non-throwing: logs errors but never propagates them. + * + * @param {string} baseUrl + * @param {string} token + * @param {string} hostname - Container hostname + * @param {object} resources + * @param {number} [resources.vcpus] - Number of virtual CPUs + * @param {number} [resources.memoryMb] - RAM in megabytes + * @param {number} [resources.diskGb] - Disk size in gigabytes + * @returns {Promise} + */ +async function updateVirtualMachine(baseUrl, token, hostname, { vcpus, memoryMb, diskGb } = {}) { + try { + const data = await nbFetch( + baseUrl, + token, + `/virtualization/virtual-machines/?name=${encodeURIComponent(hostname)}&limit=1`, + ); + if (!data?.results?.length) { + console.log(`NetBox: no VM found for "${hostname}", skipping resource update`); + return; + } + const vm = data.results[0]; + const patch = { + ...(vcpus != null && { vcpus }), + ...(memoryMb != null && { memory: memoryMb }), + ...(diskGb != null && { disk: diskGb }), + }; + if (Object.keys(patch).length === 0) return; + await nbFetch(baseUrl, token, `/virtualization/virtual-machines/${vm.id}/`, { + method: 'PATCH', + body: JSON.stringify(patch), + }); + console.log(`NetBox: VM "${hostname}" resources updated`); + } catch (err) { + console.error(`NetBox: failed to update VM "${hostname}" resources: ${err.message}`); + } +} + /** * Delete a virtual machine from NetBox by container hostname. * Also removes the associated interface and IP address. @@ -241,4 +289,4 @@ async function withNetbox(Setting, fn) { return fn(baseUrl, token); } -module.exports = { createVirtualMachine, deleteVirtualMachine, withNetbox }; +module.exports = { createVirtualMachine, updateVirtualMachine, deleteVirtualMachine, withNetbox }; From 9d1ccc0fc17fd03fd6627f9c0eb9dee52c816428 Mon Sep 17 00:00:00 2001 From: cmyers-mieweb Date: Fri, 12 Jun 2026 11:54:20 -0700 Subject: [PATCH 4/4] Use actual VM resources and parse rootfs size Replace hardcoded vCPU/memory/disk values with actual provisioned values read from the LXC config. Add parseRootfsSizeGb helper that parses Proxmox LXC `rootfs` size strings (supports T/G/M/K, defaults to GB) and returns rounded gigabytes or null if unparseable. Update create-container flow to set vcpus, memoryMb and diskGb from the container config. Add a design note in netbox utils documenting why standalone functions (stateless Token auth) are used instead of a NetBoxApi class. --- create-a-container/bin/create-container.js | 30 +++++++++++++++++++--- create-a-container/utils/netbox.js | 13 ++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/create-a-container/bin/create-container.js b/create-a-container/bin/create-container.js index 30db8cdd..5b01b4e0 100755 --- a/create-a-container/bin/create-container.js +++ b/create-a-container/bin/create-container.js @@ -50,6 +50,23 @@ function generateImageFilename(parsed, digest) { return sanitized; } +/** + * Parse the disk size in gigabytes from a Proxmox LXC `rootfs` config value. + * Example input: "local-lvm:vm-123-disk-0,size=50G" → 50 + * Supports T/G/M/K suffixes; defaults to gigabytes when no suffix is present. + * @param {string} [rootfs] - The rootfs config string from lxcConfig + * @returns {number|null} Disk size rounded to whole gigabytes, or null if unparseable + */ +function parseRootfsSizeGb(rootfs) { + if (!rootfs) return null; + const match = /size=(\d+(?:\.\d+)?)([TGMK])?/i.exec(rootfs); + if (!match) return null; + const value = parseFloat(match[1]); + const unit = (match[2] || 'G').toUpperCase(); + const gb = { T: value * 1024, G: value, M: value / 1024, K: value / (1024 * 1024) }[unit]; + return Number.isFinite(gb) ? Math.round(gb) : null; +} + /** * Resolve which Proxmox storage to use for a given content type. * Returns the preferred storage if it supports the content type, @@ -423,6 +440,13 @@ async function main() { const config = await client.lxcConfig(node.name, vmid); const actualEntrypoint = config['entrypoint'] || null; const actualEnv = config['env'] || null; + + // Read back the actual provisioned resources so downstream systems + // (e.g. NetBox) mirror what the container really has rather than assuming + // the values requested at creation time. + const actualCores = config['cores'] != null ? parseInt(config['cores'], 10) : null; + const actualMemoryMb = config['memory'] != null ? parseInt(config['memory'], 10) : null; + const actualDiskGb = parseRootfsSizeGb(config['rootfs']); // Parse NUL-separated env string back to JSON object let environmentVars = {}; @@ -492,9 +516,9 @@ async function main() { ipv4Address, createdBy: container.username, nodeName: container.node?.name, - vcpus: 4, - memoryMb: 4096, - diskGb: 50, + vcpus: actualCores, + memoryMb: actualMemoryMb, + diskGb: actualDiskGb, }); console.log(`NetBox: VM "${container.hostname}" created`); } catch (err) { diff --git a/create-a-container/utils/netbox.js b/create-a-container/utils/netbox.js index b56c1aa7..70d7dcdc 100644 --- a/create-a-container/utils/netbox.js +++ b/create-a-container/utils/netbox.js @@ -277,6 +277,19 @@ async function deleteVirtualMachine(baseUrl, token, hostname) { * Load NetBox credentials from the Settings model and invoke a callback. * Returns null without calling fn if NetBox is not configured. * + * Design note: why standalone functions + a callback instead of a NetBoxApi + * class (as used for Proxmox via `node.api()`): + * 1. Stateless auth. NetBox uses a static `Authorization: Token` header, so + * there is no per-session state (cookie, CSRF token, ticket expiry) worth + * holding on an instance. ProxmoxApi is a class precisely because its + * ticket/CSRF auth IS stateful and benefits from a long-lived object. + * 2. Credentials are global, not per-entity. Proxmox creds live on each Node + * row, so `node.api()` reads naturally as an instance method. NetBox creds + * are a single system-wide pair in the Settings key/value table there is + * no domain object to anchor an `.api()` method to. + * A NetBoxApi class would also have been reasonable; this exception to the + * class-based convention is deliberate and documented here for that reason. + * * @param {object} Setting - Sequelize Setting model * @param {function(string, string): Promise<*>} fn - Called with (baseUrl, token) * @returns {Promise<*|null>}