Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 46 additions & 1 deletion create-a-container/bin/create-container.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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 = {};
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions create-a-container/client/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }[];
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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) {
Expand Down
26 changes: 26 additions & 0 deletions create-a-container/client/src/pages/settings/SettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof schema>;

Expand All @@ -47,6 +49,8 @@ export function SettingsPage() {
smtpUrl: '',
smtpNoreplyAddress: '',
defaultContainerEnvVars: [],
netboxUrl: '',
netboxToken: '',
},
});
const { fields, append, remove } = useFieldArray({ control, name: 'defaultContainerEnvVars' });
Expand Down Expand Up @@ -126,6 +130,28 @@ export function SettingsPage() {
)}
</section>

<section className="grid gap-4">
<h2 className="text-lg font-semibold">NetBox</h2>
<Input
label="NetBox URL"
placeholder="https://netbox.example.com"
helperText="Base URL of your NetBox instance"
{...register('netboxUrl')}
/>
<Input
label="NetBox API token"
type="password"
autoComplete="off"
helperText="API token with write access to IPAM and Virtualization"
{...register('netboxToken')}
/>
</section>

{mutation.isSuccess && (
<Alert variant="success" role="status" aria-live="polite">
<AlertDescription>Your settings have been saved successfully.</AlertDescription>
</Alert>
)}
{mutation.error && <Alert variant="danger"><AlertDescription>{(mutation.error as ApiError).message}</AlertDescription></Alert>}

<div className="flex flex-wrap justify-end gap-2">
Expand Down
8 changes: 8 additions & 0 deletions create-a-container/routers/api/v1/containers.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand Down Expand Up @@ -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 });
}),
);
Expand Down
8 changes: 8 additions & 0 deletions create-a-container/routers/api/v1/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ const KEYS = [
'smtp_url',
'smtp_noreply_address',
'default_container_env_vars',
'netbox_url',
'netbox_token',
];

router.get(
Expand All @@ -30,6 +32,8 @@ router.get(
smtpUrl: settings.smtp_url || '',
smtpNoreplyAddress: settings.smtp_noreply_address || '',
defaultContainerEnvVars,
netboxUrl: settings.netbox_url || '',
netboxToken: settings.netbox_token || '',
});
}),
);
Expand All @@ -41,6 +45,8 @@ router.put(
smtpUrl,
smtpNoreplyAddress,
defaultContainerEnvVars,
netboxUrl,
netboxToken,
} = req.body || {};

const envVars = [];
Expand All @@ -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 });
}),
Expand Down
Loading
Loading