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
7 changes: 6 additions & 1 deletion apps/dashboard/src/lib/remote/vms.remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -812,7 +812,12 @@ export const resizeVm = command(resizeParams, async (params) => {
try {
await backend.resizeVm(
row.id,
{ cores: target.cores, memoryMb: target.ramCapacity, diskGb: target.storageAmount },
{
cores: target.cores,
memoryMb: target.ramCapacity,
diskGb: target.storageAmount,
tierName: target.name
},
row.proxmoxId ?? undefined
);
} catch (err) {
Expand Down
86 changes: 86 additions & 0 deletions apps/dashboard/src/lib/server/backends/proxmox/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,87 @@ interface CloudInitVendorConfigParams {
enableSshPasswordAuth?: boolean;
}

// SMBIOS type 1 (System Information) shown to the guest via dmidecode,
// /sys/class/dmi/id/*, and `hostnamectl` (Hardware Vendor / Hardware Model).
// https://pve.proxmox.com/pve-docs/qm.1.html (smbios1)
// Product is the VM's tier (vm_types.name), so it's rewritten on resize.
const SMBIOS_BRAND = 'Fyra Stack';

interface Smbios1 {
fields: Record<string, string>;
uuid?: string;
}

function smbios1Branding(stableId: string, tierName: string): Record<string, string> {
return {
manufacturer: SMBIOS_BRAND,
product: tierName,
family: SMBIOS_BRAND,
serial: stableId.toLowerCase()
};
}

function encodeBase64Utf8(value: string) {
return btoa(String.fromCharCode(...new TextEncoder().encode(value)));
}

function decodeBase64Utf8(value: string) {
return new TextDecoder().decode(Uint8Array.from(atob(value), (char) => char.charCodeAt(0)));
}

function parseSmbios1(raw: string | undefined): Smbios1 {
const entries = (raw ?? '')
.split(',')
.filter(Boolean)
.map((part) => {
const eq = part.indexOf('=');
return eq === -1 ? [part, ''] : [part.slice(0, eq), part.slice(eq + 1)];
});
const base64 = entries.some(([key, value]) => key === 'base64' && value === '1');
const result: Smbios1 = { fields: {} };
for (const [key, value] of entries) {
if (key === 'base64') {
continue;
}
if (key === 'uuid') {
result.uuid = value;
} else {
result.fields[key] = base64 ? decodeBase64Utf8(value) : value;
}
}
return result;
}

function formatSmbios1({ fields, uuid }: Smbios1) {
const encoded = Object.entries(fields).map(([key, value]) => `${key}=${encodeBase64Utf8(value)}`);
return ['base64=1', ...encoded, ...(uuid ? [`uuid=${uuid}`] : [])].join(',');
}

function smbios1Config(stableId: string, tierName: string) {
// PVE only auto-generates the SMBIOS UUID when smbios1 is omitted, so supply one ourselves.
return formatSmbios1({
fields: smbios1Branding(stableId, tierName),
uuid: crypto.randomUUID()
});
}

/** Returns the updated smbios1 string, or null if the branding already matches. */
function rebrandSmbios1(raw: string | undefined, stableId: string, tierName: string) {
const current = parseSmbios1(raw);
const branding = smbios1Branding(stableId, tierName);
if (
current.uuid &&
Object.entries(branding).every(([key, value]) => current.fields[key] === value)
) {
return null;
}
return formatSmbios1({
fields: { ...current.fields, ...branding },
// Keep the existing UUID: guests (and licensing/activation) key off it.
uuid: current.uuid ?? crypto.randomUUID()
});
}

function generateMacAddress() {
const bytes = crypto.getRandomValues(new Uint8Array(6));
bytes[0] = (bytes[0] & 0xfe) | 0x02;
Expand Down Expand Up @@ -773,6 +854,7 @@ export class ProxmoxBackend implements VmBackend {
machine: 'q35',
efidisk0: `${pvePool}:0,efitype=4m,pre-enrolled-keys=${(params.secureBoot ?? true) ? 1 : 0}`,
tpmstate0: `${pvePool}:0,version=v2.0`,
smbios1: smbios1Config(params.id, params.tierName),
scsihw: 'virtio-scsi-single',
...(params.imageSource
? {}
Expand Down Expand Up @@ -1081,6 +1163,10 @@ export class ProxmoxBackend implements VmBackend {
if (params.memoryMb !== qemuConfig.memory) {
updates.memory = params.memoryMb;
}
const smbios1 = rebrandSmbios1(qemuConfig.smbios1, id, params.tierName);
if (smbios1) {
updates.smbios1 = smbios1;
}
if (Object.keys(updates).length > 0) {
await this.client.updateQemuConfig(node, vmid, updates);
}
Expand Down
2 changes: 2 additions & 0 deletions apps/dashboard/src/lib/server/backends/proxmox/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export interface PveQemuConfig {
ostype?: string;
scsi0?: string;
scsihw?: string;
smbios1?: string;
sockets?: number;
sshkeys?: string;
virtio0?: string;
Expand Down Expand Up @@ -136,6 +137,7 @@ export interface PveCreateQemuParams {
scsi0?: string;
scsihw?: string;
serial0?: string;
smbios1?: string;
sockets?: number;
sshkeys?: string;
start?: 0 | 1;
Expand Down
2 changes: 2 additions & 0 deletions apps/dashboard/src/lib/server/backends/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ export interface VmCreateParams {
registerBackground?: (work: Promise<unknown>, label?: string) => void;
secureBoot?: boolean;
sshKeys?: string[];
tierName: string;
userId: string;
}

Expand All @@ -106,6 +107,7 @@ export interface VmResizeParams {
cores: number;
diskGb: number;
memoryMb: number;
tierName: string;
}

export class VmNotFoundError extends Error {
Expand Down
1 change: 1 addition & 0 deletions apps/dashboard/src/lib/server/vm-provisioning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,7 @@ export async function provisionVm(db: Database, input: ProvisionVmInput) {
cores: vmType.cores,
memoryMb: vmType.ramCapacity,
diskGb: vmType.storageAmount,
tierName: vmType.name,
imageId: input.imageId,
imageSource: baseImage?.filePath,
secureBoot: baseImage?.secureBoot ?? true,
Expand Down
155 changes: 155 additions & 0 deletions scripts/backfill-smbios.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
#!/usr/bin/env python3
"""Backfill Fyra Stack SMBIOS type 1 branding onto existing VMs.

Run as root on any PVE cluster node. Dry-run by default; pass --apply to write.

CSV columns (header optional): stable_id,proxmox_id,tier
01JABCDEFGHJKMNPQRSTVWXYZ0,1234,s1.small

Mirrors smbios1Branding()/rebrandSmbios1() in
apps/dashboard/src/lib/server/backends/proxmox/index.ts: sets manufacturer/family
to "Fyra Stack", product to the tier, serial to the stable id, and keeps the
existing SMBIOS UUID (plus any other fields). Takes effect on next cold boot.
"""

import argparse
import base64
import csv
import json
import subprocess
import sys
import uuid

BRAND = "Fyra Stack"
STABLE_ID_TAG_PREFIX = "vmid-"


def pvesh(*args: str):
out = subprocess.run(
["pvesh", *args, "--output-format", "json"],
check=True,
capture_output=True,
text=True,
).stdout
return json.loads(out) if out.strip() else None


def parse_smbios1(raw: str | None) -> tuple[dict[str, str], str | None]:
entries = []
for part in filter(None, (raw or "").split(",")):
key, _, value = part.partition("=")
entries.append((key, value))
is_b64 = ("base64", "1") in entries
fields: dict[str, str] = {}
vm_uuid = None
for key, value in entries:
if key == "base64":
continue
if key == "uuid":
vm_uuid = value
else:
fields[key] = base64.b64decode(value).decode() if is_b64 else value
return fields, vm_uuid


def format_smbios1(fields: dict[str, str], vm_uuid: str) -> str:
encoded = [
f"{k}={base64.b64encode(v.encode()).decode()}" for k, v in fields.items()
]
return ",".join(["base64=1", *encoded, f"uuid={vm_uuid}"])


def read_rows(path: str):
with open(path, newline="") as f:
for lineno, row in enumerate(csv.reader(f), 1):
if not row or row[0].startswith("#"):
continue
if len(row) != 3:
sys.exit(f"{path}:{lineno}: expected 3 columns, got {len(row)}")
stable_id, proxmox_id, tier = (c.strip() for c in row)
if lineno == 1 and not proxmox_id.isdigit():
continue # header
if not proxmox_id.isdigit():
sys.exit(f"{path}:{lineno}: proxmox_id {proxmox_id!r} is not a number")
yield lineno, stable_id.lower(), int(proxmox_id), tier


def main() -> int:
ap = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
ap.add_argument("csv", help="path to CSV (stable_id,proxmox_id,tier)")
ap.add_argument(
"--apply", action="store_true", help="actually write changes (default: dry run)"
)
ap.add_argument(
"--skip-tag-check",
action="store_true",
help=f"don't require the VM's {STABLE_ID_TAG_PREFIX}<stable_id> tag to match the CSV",
)
args = ap.parse_args()

resources = {
r["vmid"]: r
for r in pvesh("get", "/cluster/resources", "--type", "vm")
if r.get("type") == "qemu"
}

failures = changed = 0
for lineno, stable_id, vmid, tier in read_rows(args.csv):
label = f"[{lineno}] {vmid} ({stable_id})"
res = resources.get(vmid)
if not res:
print(f"{label}: SKIP, no such qemu VM in cluster", file=sys.stderr)
failures += 1
continue

tags = set(filter(None, (res.get("tags") or "").replace(",", ";").split(";")))
if not args.skip_tag_check and f"{STABLE_ID_TAG_PREFIX}{stable_id}" not in tags:
print(
f"{label}: SKIP, tag {STABLE_ID_TAG_PREFIX}{stable_id} not on VM (tags: {sorted(tags)})",
file=sys.stderr,
)
failures += 1
continue

node = res["node"]
config = pvesh("get", f"/nodes/{node}/qemu/{vmid}/config")
current = config.get("smbios1")
fields, vm_uuid = parse_smbios1(current)
branding = {
"manufacturer": BRAND,
"product": tier,
"family": BRAND,
"serial": stable_id,
}

if vm_uuid and all(fields.get(k) == v for k, v in branding.items()):
print(f"{label}: ok, already branded as {tier!r}")
continue

if not vm_uuid:
vm_uuid = str(uuid.uuid4())
print(f"{label}: note, no existing SMBIOS UUID, generated {vm_uuid}")
new = format_smbios1({**fields, **branding}, vm_uuid)
print(f"{label} on {node}: {fields.get('product')!r} -> {tier!r}")
print(f" old: {current}")
print(f" new: {new}")
if args.apply:
try:
pvesh("set", f"/nodes/{node}/qemu/{vmid}/config", "--smbios1", new)
except subprocess.CalledProcessError as e:
print(f"{label}: FAILED: {e.stderr.strip()}", file=sys.stderr)
failures += 1
continue
changed += 1

verb = "updated" if args.apply else "would update"
print(
f"\n{verb} {changed} VM(s), {failures} skipped/failed. Changes apply on next stop/start."
)
return 1 if failures else 0


if __name__ == "__main__":
sys.exit(main())
Loading