diff --git a/create-a-container/bin/create-container.js b/create-a-container/bin/create-container.js index 8ed215c3..766ed815 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, Re 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 @@ -49,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, @@ -434,6 +452,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,7 +517,27 @@ 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, + nodeName: container.node?.name, + vcpus: actualCores, + memoryMb: actualMemoryMb, + diskGb: actualDiskGb, + }); + 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/client/src/lib/types.ts b/create-a-container/client/src/lib/types.ts index 48b187da..85701ab3 100644 --- a/create-a-container/client/src/lib/types.ts +++ b/create-a-container/client/src/lib/types.ts @@ -162,6 +162,8 @@ export interface ApiKeyCreated extends ApiKey { export interface AppSettings { 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 fdb3f5f9..87ec7ebe 100644 --- a/create-a-container/client/src/pages/containers/ContainerFormPage.tsx +++ b/create-a-container/client/src/pages/containers/ContainerFormPage.tsx @@ -71,7 +71,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(), @@ -294,7 +300,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 43092089..e36f23cc 100644 --- a/create-a-container/client/src/pages/settings/SettingsPage.tsx +++ b/create-a-container/client/src/pages/settings/SettingsPage.tsx @@ -33,6 +33,8 @@ const schema = z.object({ smtpUrl: z.string(), smtpNoreplyAddress: z.string(), defaultContainerEnvVars: z.array(envVarSchema), + netboxUrl: z.string(), + netboxToken: z.string(), }); type FormData = z.infer; @@ -47,6 +49,8 @@ export function SettingsPage() { smtpUrl: '', smtpNoreplyAddress: '', defaultContainerEnvVars: [], + netboxUrl: '', + netboxToken: '', }, }); const { fields, append, remove } = useFieldArray({ control, name: 'defaultContainerEnvVars' }); @@ -126,6 +130,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/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 7e76b7c8..b4cd6f70 100644 --- a/create-a-container/routers/api/v1/settings.js +++ b/create-a-container/routers/api/v1/settings.js @@ -14,6 +14,8 @@ const KEYS = [ 'smtp_url', 'smtp_noreply_address', 'default_container_env_vars', + 'netbox_url', + 'netbox_token', ]; router.get( @@ -30,6 +32,8 @@ router.get( smtpUrl: settings.smtp_url || '', smtpNoreplyAddress: settings.smtp_noreply_address || '', defaultContainerEnvVars, + netboxUrl: settings.netbox_url || '', + netboxToken: settings.netbox_token || '', }); }), ); @@ -41,6 +45,8 @@ router.put( smtpUrl, smtpNoreplyAddress, defaultContainerEnvVars, + netboxUrl, + netboxToken, } = req.body || {}; const envVars = []; @@ -59,6 +65,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..70d7dcdc --- /dev/null +++ b/create-a-container/utils/netbox.js @@ -0,0 +1,305 @@ +/** + * 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 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 + * @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 + * @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, vcpus, memoryMb, diskGb }) { + 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 }), + ...(vcpus != null && { vcpus }), + ...(memoryMb != null && { memory: memoryMb }), + ...(diskGb != null && { disk: diskGb }), + }; + + const vm = await nbFetch(baseUrl, token, '/virtualization/virtual-machines/', { + method: 'POST', + body: JSON.stringify(vmBody), + }); + + 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; +} + +/** + * 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. + * + * 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. + * + * 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>} + */ +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, updateVirtualMachine, deleteVirtualMachine, withNetbox };