Skip to content
Open
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
17 changes: 16 additions & 1 deletion create-a-container/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ erDiagram
int id PK
string hostname UK "FQDN hostname"
string username "Owner username"
string status "pending,creating,running,failed"
string template "Template name"
int creationJobId FK "References Job"
int nodeId FK "References Node"
Expand Down Expand Up @@ -231,6 +230,22 @@ Delete a container from both Proxmox and the database
- `403` - User doesn't own the container
- `500` - Proxmox API deletion failed or node not configured

#### Container status (`status` field)
Every container returned by the list, show, and create endpoints includes a
**live** `status` field, computed on demand rather than read from a stored
column. It is resolved by combining the container's run-state in Proxmox (from a
single per-node cluster snapshot) with the state of its create job. Possible
values:
- `running` — online in Proxmox
- `offline` — exists in Proxmox but stopped
- `creating` — no Proxmox VM yet, active create job
- `failed` — no Proxmox VM, create job failed
- `missing` — no Proxmox VM, create succeeded or no create job found
- `unknown` — Proxmox unreachable / node has no API credentials

The create endpoint (`POST /containers`) returns `creating` immediately, since a
create job is enqueued and there is no Proxmox VM yet.

#### `GET /status/:jobId` (Auth Required)
View container creation progress page

Expand Down
126 changes: 26 additions & 100 deletions create-a-container/bin/create-container.js
Original file line number Diff line number Diff line change
Expand Up @@ -177,9 +177,14 @@ async function main() {
console.error(`Container with ID ${containerId} not found`);
process.exit(1);
}

if (container.status !== 'pending') {
console.error(`Container is not in pending status (current: ${container.status})`);

// Guard against double-provisioning. The status column no longer exists, so
// we use the Proxmox VMID as the signal: once a VMID has been allocated and
// stored, creation has already run for this container.
if (container.containerId) {
console.error(
`Container already has a Proxmox VMID (${container.containerId}); refusing to re-create`,
);
process.exit(1);
}

Expand Down Expand Up @@ -217,10 +222,6 @@ async function main() {
console.log(`Template type: ${isDocker ? 'Docker image' : 'Proxmox template'}`);

try {
// Update status to 'creating'
await container.update({ status: 'creating' });
console.log('Status updated to: creating');

// Get the Proxmox API client
const client = await node.api();
console.log('Proxmox API client initialized');
Expand Down Expand Up @@ -340,64 +341,23 @@ async function main() {
console.log('Container configured');
}

// Apply environment variables and entrypoint
// First read defaults from the image, then merge with user-specified values
const defaultConfig = await client.lxcConfig(node.name, vmid);
const defaultEntrypoint = defaultConfig['entrypoint'] || null;
const defaultEnvStr = defaultConfig['env'] || null;

// Parse default env vars
let mergedEnvVars = {};
if (defaultEnvStr) {
const pairs = defaultEnvStr.split('\0');
for (const pair of pairs) {
const eqIndex = pair.indexOf('=');
if (eqIndex > 0) {
mergedEnvVars[pair.substring(0, eqIndex)] = pair.substring(eqIndex + 1);
}
}
}

// Merge user-specified env vars (user values override defaults)
const userEnvVars = container.environmentVars ? JSON.parse(container.environmentVars) : {};
// Snapshot the template's env/entrypoint onto the container record now, as
// if the user had supplied them (user-supplied values still win). Templates
// are mutable Docker refs we can't re-query on a later reconfigure, so we
// persist them here; otherwise a future reconfigure (which uses
// deleteMissing) would unset template-provided values that were never
// stored. System/NVIDIA defaults are intentionally left out — they stay
// configure-time-only.
const templateConfig = await client.lxcConfig(node.name, vmid);
await container.persistTemplateDefaults(templateConfig);

// Load system-wide default env vars from Settings.
// Descriptions are metadata only and are not passed into the container.
let systemDefaultEnvVars = {};
try {
const entries = await Setting.getDefaultContainerEnvVars();
for (const entry of entries) {
if (entry.key && entry.key.trim()) {
systemDefaultEnvVars[entry.key.trim()] = entry.value || '';
}
}
} catch (_) {
console.warn('Could not load default_container_env_vars from settings, skipping');
}

// Merge priority: image defaults < system defaults < per-container user values
mergedEnvVars = { ...mergedEnvVars, ...systemDefaultEnvVars, ...userEnvVars };

// Use user entrypoint if specified, otherwise keep default
const finalEntrypoint = container.entrypoint || defaultEntrypoint;

// Build config to apply
const envConfig = {};
if (finalEntrypoint) {
envConfig.entrypoint = finalEntrypoint;
}
if (Object.keys(mergedEnvVars).length > 0) {
envConfig.env = Object.entries(mergedEnvVars)
.map(([key, value]) => `${key}=${value}`)
.join('\0');
}

// Apply environment variables and entrypoint. Use the default
// (deleteMissing=false): only explicit values are pushed, nothing is unset.
// The record now already includes the template's values, and system/NVIDIA
// defaults are merged in by buildLxcEnvConfig.
const envConfig = await container.buildLxcEnvConfig();
if (Object.keys(envConfig).length > 0) {
console.log('Applying environment variables and entrypoint...');
if (defaultEntrypoint) console.log(`Default entrypoint: ${defaultEntrypoint}`);
if (defaultEnvStr) console.log(`Image default env vars: ${Object.keys(mergedEnvVars).length - Object.keys(userEnvVars).length - Object.keys(systemDefaultEnvVars).length}`);
if (Object.keys(systemDefaultEnvVars).length > 0) console.log(`System default env vars: ${Object.keys(systemDefaultEnvVars).length} from settings`);
if (Object.keys(userEnvVars).length > 0) console.log(`Per-container env vars: ${Object.keys(userEnvVars).length}`);
await client.updateLxcConfig(node.name, vmid, envConfig);
console.log('Environment/entrypoint configuration applied');
}
Expand Down Expand Up @@ -447,40 +407,17 @@ async function main() {
throw new Error('Could not extract MAC address from container configuration');
}

// Read back entrypoint and environment variables from config
// Read back configuration from Proxmox.
console.log('Querying container configuration...');
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 = {};
if (actualEnv) {
const pairs = actualEnv.split('\0');
for (const pair of pairs) {
const eqIndex = pair.indexOf('=');
if (eqIndex > 0) {
const key = pair.substring(0, eqIndex);
const value = pair.substring(eqIndex + 1);
environmentVars[key] = value;
}
}
}

if (actualEntrypoint) {
console.log(`Entrypoint: ${actualEntrypoint}`);
}
if (Object.keys(environmentVars).length > 0) {
console.log(`Environment variables: ${Object.keys(environmentVars).length} vars`);
}


// Get IP address from Proxmox interfaces API
const ipv4Address = await client.getLxcIpAddress(node.name, vmid);

Expand All @@ -492,10 +429,7 @@ async function main() {
console.log('Updating container record...');
await container.update({
macAddress,
ipv4Address,
entrypoint: actualEntrypoint,
environmentVars: JSON.stringify(environmentVars),
status: 'running'
ipv4Address
});

console.log('Container creation completed successfully!');
Expand Down Expand Up @@ -546,15 +480,7 @@ async function main() {
if (err.response?.data) {
console.error('API Error Details:', JSON.stringify(err.response.data, null, 2));
}

// Update status to failed
try {
await container.update({ status: 'failed' });
console.log('Status updated to: failed');
} catch (updateErr) {
console.error('Failed to update container status:', updateErr.message);
}


process.exit(1);
}
}
Expand Down
2 changes: 0 additions & 2 deletions create-a-container/bin/json-to-sql.js
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,6 @@ async function run() {
hostname,
ipv4Address: obj.ip,
username: obj.user || '',
status: 'running',
template: obj.template || null,
containerId: obj.ctid,
macAddress: obj.mac,
Expand All @@ -234,7 +233,6 @@ async function run() {
await container.update({
ipv4Address: obj.ip,
username: obj.user || '',
status: container.status || 'running',
template: obj.template || container.template,
containerId: obj.ctid,
macAddress: obj.mac
Expand Down
22 changes: 8 additions & 14 deletions create-a-container/bin/reconfigure-container.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,11 @@ async function main() {
const client = await node.api();
console.log('Proxmox API client initialized');

// Build config from environment variables and entrypoint
const lxcConfig = container.buildLxcEnvConfig();
// Build config from environment variables and entrypoint. Pass
// deleteMissing so that clearing env vars or removing a custom entrypoint
// actually unsets them on the existing container (vs. create, which must
// preserve template-provided values).
const lxcConfig = await container.buildLxcEnvConfig({ deleteMissing: true });

if (Object.keys(lxcConfig).length > 0) {
console.log('Applying LXC configuration...');
Expand Down Expand Up @@ -157,14 +160,13 @@ async function main() {
throw new Error('Could not get IP address from Proxmox interfaces API');
}

// Update container record with MAC/IP and running status
// Update container record with MAC/IP
await container.update({
status: 'running',
macAddress,
ipv4Address
});

console.log('Status updated to: running');
console.log('Reconfiguration applied; container running');
console.log(` MAC: ${macAddress}`);
console.log(` IP: ${ipv4Address}`);
}
Expand All @@ -178,15 +180,7 @@ async function main() {
if (err.response?.data) {
console.error('API Error Details:', JSON.stringify(err.response.data, null, 2));
}

// Update status to failed
try {
await container.update({ status: 'failed' });
console.log('Status updated to: failed');
} catch (updateErr) {
console.error('Failed to update container status:', updateErr.message);
}


process.exit(1);
}
}
Expand Down
16 changes: 14 additions & 2 deletions create-a-container/client/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ export interface Container {
hostname: string;
ipv4Address: string | null;
macAddress: string | null;
status: string;
status: ContainerStatus;
template: string | null;
creationJobId: number | null;
entrypoint: string | null;
Expand All @@ -90,11 +90,23 @@ export interface Container {
createdAt: string;
}

/**
* Live container status resolved from Proxmox run-state + create-job state.
* Embedded on each Container returned by the list/show/create endpoints.
*/
export type ContainerStatus =
| 'running'
| 'offline'
| 'creating'
| 'failed'
| 'missing'
| 'unknown';

export interface ContainerCreateResult {
containerId: number;
jobId: number;
hostname: string;
status: string;
status: ContainerStatus;
}

export interface ContainerNewBootstrap {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,28 +34,44 @@ import {
import { api, ApiError } from '@/lib/api';
import { useSession } from '@/lib/auth';
import { keys, queries } from '@/lib/queries';
import type { Container } from '@/lib/types';
import type { Container, ContainerStatus } from '@/lib/types';

type ViewMode = 'cards' | 'table';
const VIEW_STORAGE_KEY = 'containers:view';

function statusVariant(s: string): 'default' | 'success' | 'warning' | 'danger' | 'secondary' {
function statusVariant(
s: ContainerStatus,
): 'default' | 'success' | 'warning' | 'danger' | 'secondary' {
switch (s) {
case 'running':
return 'success';
case 'pending':
case 'restarting':
case 'creating':
return 'warning';
case 'failed':
case 'error':
return 'danger';
case 'stopped':
case 'offline':
case 'missing':
return 'secondary';
default:
return 'default';
}
}

// Human-readable labels for the live status values.
const STATUS_LABELS: Record<ContainerStatus, string> = {
running: 'Running',
offline: 'Offline',
creating: 'Creating',
failed: 'Failed',
missing: 'Missing',
unknown: 'Unknown',
};

/** Status badge. The status is the live value embedded in the list response. */
function StatusBadge({ status }: { status: ContainerStatus }) {
return <Badge variant={statusVariant(status)}>{STATUS_LABELS[status] ?? status}</Badge>;
}

const linkClass = 'text-(--color-primary,#1d4ed8) hover:underline';

/** Shorten a full image ref to just its name+tag, e.g. ghcr.io/mieweb/base:latest -> base:latest */
Expand Down Expand Up @@ -330,7 +346,7 @@ export function ContainersListPage() {
<CardTitle as="h2" className="truncate text-sm font-semibold">
{c.hostname}
</CardTitle>
<Badge variant={statusVariant(c.status)}>{c.status}</Badge>
<StatusBadge status={c.status} />
</div>
<div className="ml-auto flex shrink-0 items-center gap-1 lg:order-3 lg:ml-0">
<RowActions c={c} siteId={siteId} onDelete={del.mutate} deleting={del.isPending} />
Expand Down Expand Up @@ -374,7 +390,7 @@ export function ContainersListPage() {
<TableRow key={c.id}>
<TableCell className="font-medium">{c.hostname}</TableCell>
<TableCell>
<Badge variant={statusVariant(c.status)}>{c.status}</Badge>
<StatusBadge status={c.status} />
</TableCell>
<TableCell>
<NodeLink c={c} />
Expand Down
Loading
Loading