"use strict"; // chj.disks against ONE isolated Linux test VM over real SSH (key login, sudo with password), through the real // DiskService/PrivilegedExecutor/remoteDisks.sh. Read-only checks always run. Destructive checks run only with // CHJ_DISKS_WRITE=1 and only on loop devices backed by /var/tmp/chj-disks-test/chjtest-.img created by this test // (verified before every change); the system disk is never a target. Proxmox hosts get the read-only part only. // Environment as test/linuxVmIntegration.test.js: CHJ_VM_HOST, CHJ_VM_FINGERPRINT, CHJ_VM_KEY, CHJ_VM_USER (ai), // CHJ_VM_SUDO_PASSWORD or CHJ_VM_SUDO_PASSWORD_FILE + CHJ_VM_SUDO_PASSWORD_LINE (read at runtime, never printed). const test = require("node:test"); const assert = require("node:assert/strict"); const crypto = require("node:crypto"); const fs = require("node:fs"); const path = require("node:path"); const { SessionManager } = require("../src/main/sessions/sessionManager"); const { PrivilegedExecutor } = require("../src/main/sessions/privilegedExec"); const { DiskService } = require("../src/main/disks/diskService"); const env = process.env; const enabled = Boolean(env.CHJ_VM_HOST && env.CHJ_VM_FINGERPRINT && env.CHJ_VM_KEY && (env.CHJ_VM_SUDO_PASSWORD || env.CHJ_VM_SUDO_PASSWORD_FILE)); const skip = enabled ? false : "Set CHJ_VM_* to run against an isolated Linux test VM."; const sudoPassword = !enabled ? "" : env.CHJ_VM_SUDO_PASSWORD || fs.readFileSync(env.CHJ_VM_SUDO_PASSWORD_FILE, "utf8").split("\n")[Number(env.CHJ_VM_SUDO_PASSWORD_LINE || 1) - 1].split("=").slice(1).join("=").trim(); const FIXTURE = fs.readFileSync(path.join(__dirname, "fixtures", "disks", "loopFixture.sh"), "utf8"); const MiB = 1048576; const RUN = crypto.randomBytes(4).toString("hex"); const TEST_IMAGE = /^\/var\/tmp\/chj-disks-test\/chjtest-[a-z0-9]+\.img$/; test("chj.disks on an isolated Linux test VM", { skip, timeout: 1800000 }, async (t) => { const hostKeys = new Map(); const profileService = { get: () => ({ id: "vm", label: "vm", host: env.CHJ_VM_HOST, port: 22, username: env.CHJ_VM_USER || "ai", authMethod: "privateKey", privateKeyPath: env.CHJ_VM_KEY }), getHostKey: (h, p) => hostKeys.get(`${h}:${p}`) || null, trustHostKey: (h, p, fp) => hostKeys.set(`${h}:${p}`, fp), getStoredPassword: () => "", markUsed() {} }; const manager = new SessionManager({ profileService }); const connect = async () => { try { await manager.connect({ sessionId: "vm", profileId: "vm" }); } catch (error) { if (error.code !== "HOST_KEY_UNKNOWN") throw error; assert.equal(`SHA256:${Buffer.from(error.fingerprint, "hex").toString("base64").replace(/=+$/, "")}`, env.CHJ_VM_FINGERPRINT, "documented host key"); manager.trustPending({ sessionId: "vm", profileId: "vm", fingerprint: error.fingerprint }); await manager.connect({ sessionId: "vm", profileId: "vm" }); } }; await connect(); const executor = new PrivilegedExecutor({ sessionManager: manager }); const disks = new DiskService({ sessionManager: manager, privilegedExecutor: executor }); const auth = { sudoPassword }; const fixture = async (...args) => { const run = await executor.run("vm", { script: FIXTURE, args, privileged: true, sudoPassword, purpose: "test.disks.fixture", timeoutMs: 300000, errorPrefix: "FIXTURE_" }); return Object.fromEntries(run.stdout.split("\n").filter((line) => line.includes("=")).map((line) => [line.slice(0, line.indexOf("=")), line.slice(line.indexOf("=") + 1)])); }; const fixtureText = async (...args) => (await executor.run("vm", { script: FIXTURE, args, privileged: true, sudoPassword, purpose: "test.disks.fixture", timeoutMs: 300000, errorPrefix: "FIXTURE_" })).stdout.trim(); let wrote = false; t.after(async () => { if (wrote) { try { t.diagnostic(`cleanup: ${JSON.stringify(await fixture("cleanup", RUN))}`); } catch (error) { t.diagnostic(`cleanup failed: ${error.code} ${error.message}`); } } await manager.disconnectAll("test"); }); // ---------- read-only ---------- let rootInventory; await t.test("read-only inventory without root", async () => { const inventory = await disks.inspect("vm"); assert.equal(inventory.supported, true); assert.equal(inventory.platform, "linux"); assert.equal(inventory.privileged, false); assert.ok(inventory.disks.length >= 1, "at least one disk"); const system = inventory.disks.find((disk) => disk.systemRoles.includes("mount:/")); assert.ok(system, "the disk holding / is identified without root"); t.diagnostic(`${inventory.os} | kernel ${inventory.kernel} | systemd ${inventory.systemd} | lsblk ${inventory.sources.lsblk} | disks ${inventory.disks.map((disk) => `${disk.path}:${disk.mediaType}/${disk.transport}/${disk.table?.type}`).join(", ")}`); }); await t.test("privileged inventory, tools and suggestions", async () => { rootInventory = await disks.inspect("vm", { privileged: true, ...auth }); assert.equal(rootInventory.privileged, true); const system = rootInventory.disks.find((disk) => disk.systemRoles.includes("mount:/")); assert.ok(system.table?.hash, "partition table read with sfdisk"); const root = rootInventory.nodes.find((node) => node.mountpoints.includes("/")); assert.ok(root.fstype && root.uuid, "root file system type and UUID"); assert.ok(root.usage?.size > 0 && root.usage.used > 0, "root usage from df"); for (const part of system.partitions) if (part.kernel) assert.ok(part.uuid, `PARTUUID of ${part.path}`); const tools = ["sfdisk", "partx", "growpart", "resize2fs", "xfs_growfs", "btrfs", "pvresize", "cryptsetup", "smartctl", "parted", "systemd-run", "setsid"].map((name) => `${name}:${rootInventory.tools[name] ? "yes" : "no"}`).join(" "); t.diagnostic(`tools ${tools}`); t.diagnostic(`root ${root.path} ${root.fstype} on ${system.path}; suggestions: ${rootInventory.suggestions.map((entry) => `${entry.id}=${entry.available ? "available" : entry.blocked.code}`).join(", ") || "none"}`); for (const suggestion of rootInventory.suggestions.filter((entry) => entry.target.startsWith(system.path))) assert.ok(suggestion.available || suggestion.blocked, "system disk suggestions are explicit"); }); await t.test("system disk is protected against destructive plans", async () => { const system = rootInventory.disks.find((disk) => disk.systemRoles.includes("mount:/")); const rootPart = system.partitions.find((part) => part.mountpoints.includes("/")); if (rootPart) { await assert.rejects(() => disks.plan("vm", { operations: [{ type: "delete-partition", disk: system.name, number: rootPart.number }], ...auth }), { code: "DISKS_DEVICE_IN_USE" }); await assert.rejects(() => disks.plan("vm", { operations: [{ type: "unmount", device: rootPart.path }], ...auth }), { code: "DISKS_SYSTEM_MOUNT" }); if (rootPart.fstype === "xfs") await assert.rejects(() => disks.plan("vm", { operations: [{ type: "resize-partition", disk: system.name, number: rootPart.number, size: Math.floor(rootPart.size / 2) }], ...auth }), { code: "DISKS_XFS_SHRINK_UNSUPPORTED" }); else await assert.rejects(() => disks.plan("vm", { operations: [{ type: "resize-partition", disk: system.name, number: rootPart.number, size: Math.floor(rootPart.size / 2) }], ...auth }), { code: /^DISKS_(SHRINK_REQUIRES_UNMOUNT|PROTECTED_DEVICE)$/ }); } await assert.rejects(() => disks.plan("vm", { operations: [{ type: "create-table", disk: system.name, label: "gpt" }], ...auth }), { code: /^DISKS_(DEVICE_IN_USE|PROTECTED_DEVICE)$/ }); const after = await disks.inspect("vm", { privileged: true, ...auth }); assert.equal(after.disks.find((disk) => disk.name === system.name).table.hash, system.table.hash, "the system disk is unchanged"); }); await t.test("wrong sudo password and SMART on a virtual disk", async () => { executor.forgetPassword("vm"); await assert.rejects(() => disks.inspect("vm", { privileged: true, sudoPassword: "definitely-wrong-password" }), { code: "SUDO_PASSWORD_INCORRECT" }); const system = rootInventory.disks.find((disk) => disk.systemRoles.includes("mount:/")); const smart = await disks.smart("vm", { device: system.path, ...auth }); t.diagnostic(`SMART ${system.path}: available=${smart.available} reason=${smart.reason} tool=${smart.tool}`); assert.equal(typeof smart.available, "boolean"); }); if (env.CHJ_DISKS_WRITE !== "1") { t.diagnostic("CHJ_DISKS_WRITE is not 1: destructive loop-device tests skipped"); return; } if (rootInventory.pve) { t.diagnostic("Proxmox VE host: destructive tests are never run on Proxmox hosts (read-only only)"); return; } if (!rootInventory.tools.losetup && !rootInventory.tools.sfdisk) { t.diagnostic("losetup/sfdisk missing: destructive tests BLOCKED"); return; } // ---------- destructive, dedicated loop images only ---------- const has = (tool) => Boolean(rootInventory.tools[tool]); const fresh = () => disks.inspect("vm", { privileged: true, ...auth }); const assertTestDisk = (inventory, name) => { const disk = inventory.disks.find((entry) => entry.name === name); assert.ok(disk, `${name} present`); assert.equal(disk.kind, "loop"); assert.match(disk.loopBacking || "", TEST_IMAGE, "target is a CH-J test image"); assert.equal(disk.system, false); return disk; }; const runJob = async (plan, { disconnect = false } = {}) => { for (const view of plan.before) { if (!/^\/dev\/loop\d+$/.test(view.path)) throw new Error(`refusing to apply a plan touching ${view.path}`); assertTestDisk(await fresh(), view.name); } const started = await disks.apply("vm", { planId: plan.planId, confirm: true, ...auth, rememberSudo: true }); if (disconnect) { await manager.disconnect("vm", "test: simulated SSH outage"); await new Promise((resolve) => setTimeout(resolve, 1500)); await connect(); } let status; for (let i = 0; i < 400; i += 1) { status = await disks.jobStatus("vm", { jobId: started.jobId, offset: 0, ...auth }); if (status.outcome !== "running") break; await new Promise((resolve) => setTimeout(resolve, 750)); } if (status.outcome !== "succeeded") t.diagnostic(`job ${started.jobId} ${status.outcome}: ${JSON.stringify(status.error)}\n${status.log.slice(-3000)}`); return { ...status, started }; }; const plan = (operations) => disks.plan("vm", { operations, ...auth }); const mountpoint = (name) => `/mnt/chj-disks-test-${RUN}${name}`; wrote = true; const image = await fixture("create", RUN, "1024"); const loop = image.dev.slice(5); t.diagnostic(`test image ${image.img} on ${image.dev}`); let sums; await t.test("GPT + ext4 partition + mount + data", async () => { let inventory = await fresh(); assertTestDisk(inventory, loop); const job = await runJob(await plan([{ type: "create-table", disk: loop, label: "gpt" }, { type: "create-partition", disk: loop, start: 2048, size: 512 * MiB / 512, fs: "ext4", label: "chjtest" }, { type: "mount", device: `${image.dev}p1`, mountpoint: mountpoint("a") }])); assert.equal(job.outcome, "succeeded"); assert.match(job.log, /NOT a backup of the data|has no partition table/); inventory = await fresh(); const part = assertTestDisk(inventory, loop).partitions[0]; assert.equal(part.fstype, "ext4"); assert.equal(part.label, "chjtest"); assert.deepEqual(part.mountpoints, [mountpoint("a")]); sums = await fixtureText("write", mountpoint("a")); }); await t.test("virtual disk enlarged: detected, auto-expanded online (ext4 mounted), data intact", async () => { await fixture("grow", image.dev, "2048"); const inventory = await fresh(); const disk = assertTestDisk(inventory, loop); // Newer sfdisk reports the old last LBA (CH-J adds gpt-relocate); sfdisk 2.34 already reports the new one and // moves the backup header itself when writing. Either way the whole new space must be usable. const relocate = disk.table.gptBackupMisplaced; if (!relocate) { assert.equal(disk.table.lastUsableNow, disk.table.lastUsable); t.diagnostic("sfdisk reports the enlarged GPT area already (relocation on write)"); } const suggestion = inventory.suggestions.find((entry) => entry.id === `partition:${loop}:1`); assert.ok(suggestion?.available, "auto-expand offered"); assert.ok(suggestion.freeBytes > 1400 * MiB); const expand = await disks.autoExpand("vm", { suggestionId: suggestion.id, ...auth }); assert.deepEqual(expand.steps.map((step) => step.name).filter((name) => !/^verify|^backup/.test(name)), [...(relocate ? ["gpt-relocate"] : []), "resize-part", "grow-fs"]); const job = await runJob(expand); assert.equal(job.outcome, "succeeded"); const after = assertTestDisk(await fresh(), loop); const part = after.partitions[0]; assert.ok(part.bytes > 1900 * MiB, "partition grew"); assert.ok(part.usage.size > 1800 * MiB, "mounted ext4 grew online"); assert.equal(after.table.gptBackupMisplaced, false); assert.equal(await fixtureText("sums", mountpoint("a")), sums, "files unchanged"); }); await t.test("offline ext4 shrink (unmount, e2fsck, minimum + reserve, fs before partition) with verified data", async () => { await assert.rejects(() => plan([{ type: "resize-partition", disk: loop, number: 1, size: 768 * MiB / 512 }]), { code: "DISKS_SHRINK_REQUIRES_UNMOUNT" }); await assert.rejects(() => plan([{ type: "unmount", device: `${image.dev}p1` }, { type: "resize-partition", disk: loop, number: 1, size: 64 * MiB / 512 }]), { code: "DISKS_SIZE_TOO_SMALL" }); const job = await runJob(await plan([{ type: "unmount", device: `${image.dev}p1` }, { type: "resize-partition", disk: loop, number: 1, size: 768 * MiB / 512 }, { type: "mount", device: `${image.dev}p1`, mountpoint: mountpoint("a") }])); assert.equal(job.outcome, "succeeded"); assert.match(job.log, /consistency check before shrinking/); assert.match(job.log, /verified: e2fsck -n clean/); const part = assertTestDisk(await fresh(), loop).partitions[0]; assert.equal(part.bytes, 768 * MiB); assert.equal(await fixtureText("sums", mountpoint("a")), sums, "files unchanged after shrink"); }); await t.test("stale plan: a layout change between review and apply is refused before any change", async () => { const reviewed = await plan([{ type: "create-partition", disk: loop, start: 2048 + 768 * MiB / 512, size: 64 * MiB / 512, fs: "ext4" }]); await fixture("tamper", image.dev); await assert.rejects(() => disks.apply("vm", { planId: reviewed.planId, confirm: true, ...auth }), { code: "DISKS_PLAN_STALE" }); const disk = assertTestDisk(await fresh(), loop); assert.equal(disk.partitions.length, 2, "only the out-of-band partition exists; the plan changed nothing"); const extra = disk.partitions.find((part) => part.number === 2); assert.equal((await runJob(await plan([{ type: "delete-partition", disk: loop, number: extra.number }]))).outcome, "succeeded"); }); if (has("mkfs.xfs") && has("xfs_growfs")) { await t.test("XFS: create, online grow, shrink refused", async () => { const start = 2048 + 768 * MiB / 512; assert.equal((await runJob(await plan([{ type: "create-partition", disk: loop, start, size: 320 * MiB / 512, fs: "xfs" }, { type: "mount", device: `${image.dev}p2`, mountpoint: mountpoint("x") }]))).outcome, "succeeded"); const grow = await plan([{ type: "resize-partition", disk: loop, number: 2, size: 512 * MiB / 512 }]); assert.deepEqual(grow.steps.map((step) => step.name).filter((name) => !/^verify|^backup/.test(name)), ["resize-part", "grow-fs"]); assert.equal((await runJob(grow)).outcome, "succeeded"); try { const part = assertTestDisk(await fresh(), loop).partitions.find((entry) => entry.number === 2); assert.equal(part.bytes, 512 * MiB); assert.equal(part.fsSize, 512 * MiB, "XFS data section grew online to the partition size (xfs_info)"); await assert.rejects(() => plan([{ type: "resize-partition", disk: loop, number: 2, size: 320 * MiB / 512 }]), { code: "DISKS_XFS_SHRINK_UNSUPPORTED" }); } finally { assert.equal((await runJob(await plan([{ type: "unmount", device: `${image.dev}p2` }, { type: "delete-partition", disk: loop, number: 2 }]))).outcome, "succeeded"); } }); } else t.diagnostic("XFS tools missing: XFS scenario UNSUPPORTED on this VM"); if (has("pvresize") && has("lvextend")) { await t.test("LVM on a partition: partition -> PV -> LV -> ext4 auto expansion", async () => { const start = 2048 + 768 * MiB / 512; assert.equal((await runJob(await plan([{ type: "create-partition", disk: loop, start, size: 300 * MiB / 512, fs: "none" }]))).outcome, "succeeded"); const vg = `chjtest${RUN}`; await fixture("lvm", `${image.dev}p2`, vg); const inventory = await fresh(); const suggestion = inventory.suggestions.find((entry) => entry.id === `partition:${loop}:2`); assert.ok(suggestion?.available, `auto-expand offered (${suggestion?.blocked?.code || ""})`); const expand = await disks.autoExpand("vm", { suggestionId: suggestion.id, ...auth }); assert.deepEqual(expand.steps.map((step) => step.name).filter((name) => !/^verify|^backup/.test(name)), ["resize-part", "pv-resize", "lv-extend", "grow-fs"]); assert.equal((await runJob(expand)).outcome, "succeeded"); const after = await fresh(); const lv = after.lvm.lvs.find((entry) => entry.vg === vg); assert.ok(lv.size > 1000 * MiB, `LV grew (${lv.size})`); const node = after.nodes.find((entry) => entry.displayPath === `/dev/${vg}/data`); assert.ok(node.fsSize > 1000 * MiB, "ext4 inside the LV grew"); }); } else t.diagnostic("LVM tools missing: LVM scenario UNSUPPORTED on this VM"); await t.test("SSH outage during a job: the job continues, status is recovered, nothing runs twice", async () => { const reviewed = await plan([{ type: "unmount", device: `${image.dev}p1` }, { type: "check", device: `${image.dev}p1` }, { type: "mount", device: `${image.dev}p1`, mountpoint: mountpoint("a") }]); const job = await runJob(reviewed, { disconnect: true }); assert.equal(job.outcome, "succeeded", "finished on the server despite the dropped SSH connection"); const { jobs } = await disks.jobs("vm", auth); assert.ok(jobs.some((entry) => entry.id === job.started.jobId && entry.outcome === "succeeded"), "job listed after reconnect"); await assert.rejects(() => disks.apply("vm", { planId: reviewed.planId, confirm: true, ...auth }), { code: "DISKS_PLAN_EXPIRED" }); await assert.rejects(() => executor.run("vm", { script: disks.script, args: ["job-start", job.started.jobId], privileged: true, sudoPassword, frames: [{ tag: "PLAN", data: "verify-size /dev/null 0\n" }, { tag: "HELPER", data: "#" }, { tag: "TITLE", data: "x" }], errorPrefix: "DISKS_" }), { code: "DISKS_JOB_EXISTS" }); assert.equal(await fixtureText("sums", mountpoint("a")), sums); }); await t.test("a running disk job blocks every other disk job on the server (lock contention)", async () => { const reviewed = await plan([{ type: "set-label", device: `${image.dev}p1`, label: "chjlock" }]); await fixture("hold-lock", RUN); try { await assert.rejects(() => disks.apply("vm", { planId: reviewed.planId, confirm: true, ...auth }), { code: "DISKS_BUSY" }); } finally { await fixture("release-lock", RUN); } const retry = await plan([{ type: "set-label", device: `${image.dev}p1`, label: "chjlock" }]); assert.equal((await runJob(retry)).outcome, "succeeded", "works again once the lock is free"); }); await t.test("conflicting jobs never overlap on the server", async () => { const first = await plan([{ type: "unmount", device: `${image.dev}p1` }, { type: "check", device: `${image.dev}p1` }]); const second = await plan([{ type: "set-label", device: `${image.dev}p1`, label: "chjb" }]); const a = await disks.apply("vm", { planId: first.planId, confirm: true, ...auth }); let b = null; let bError = null; try { b = await disks.apply("vm", { planId: second.planId, confirm: true, ...auth }); } catch (error) { bError = error; } const wait = async (jobId) => { for (let i = 0; i < 200; i += 1) { const status = await disks.jobStatus("vm", { jobId, ...auth }); if (status.outcome !== "running") return status; await new Promise((resolve) => setTimeout(resolve, 500)); } return null; }; const sa = await wait(a.jobId); const sb = b ? await wait(b.jobId) : null; t.diagnostic(`first ${sa.outcome}; second ${bError ? bError.code : `${sb.outcome} ${sb.error?.code || ""}`}`); if (bError) assert.ok(["DISKS_BUSY", "DISKS_PLAN_STALE"].includes(bError.code)); else if (sb.outcome === "failed") assert.match(sb.error.code, /DISKS_(BUSY|DEVICE_IN_USE|LAYOUT_CHANGED)/); else assert.ok(Date.parse(sb.started || sb.created) >= Date.parse(sa.finished), "second job started only after the first finished"); }); if (has("cryptsetup")) { await t.test("LUKS with ext4: keyring mapping is refused, dm-crypt mapping grows disk -> partition -> LUKS -> ext4", async () => { const second = await fixture("create", `${RUN}l`, "512"); const name = second.dev.slice(5); assert.equal((await runJob(await plan([{ type: "create-table", disk: name, label: "gpt" }, { type: "create-partition", disk: name, start: 2048, size: 128 * MiB / 512, fs: "none" }]))).outcome, "succeeded"); const luks = await fixture("luks", `${second.dev}p1`, `chjtest${RUN}k`, "dm-crypt"); t.diagnostic(`LUKS key location ${luks.keylocation}`); const inventory = await fresh(); const suggestion = inventory.suggestions.find((entry) => entry.id === `partition:${name}:1`); if (/keyring/.test(luks.keylocation)) { assert.equal(suggestion.blocked.code, "DISKS_LUKS_PASSPHRASE_REQUIRED"); return; } assert.ok(suggestion?.available, `auto-expand offered (${suggestion?.blocked?.code || ""})`); const expand = await disks.autoExpand("vm", { suggestionId: suggestion.id, ...auth }); assert.deepEqual(expand.steps.map((step) => step.name).filter((name) => !/^verify|^backup/.test(name)), ["resize-part", "luks-resize", "grow-fs"]); assert.equal((await runJob(expand)).outcome, "succeeded"); const map = (await fresh()).nodes.find((node) => node.path === luks.map); assert.ok(map.size > 400 * MiB && map.fsSize > 400 * MiB, "mapping and ext4 grew"); const keyring = await fixture("create", `${RUN}m`, "256"); assert.equal((await runJob(await plan([{ type: "create-table", disk: keyring.dev.slice(5), label: "gpt" }, { type: "create-partition", disk: keyring.dev.slice(5), start: 2048, size: 64 * MiB / 512, fs: "none" }]))).outcome, "succeeded"); const second2 = await fixture("luks", `${keyring.dev}p1`, `chjtest${RUN}n`, "keyring"); const blocked = (await fresh()).suggestions.find((entry) => entry.id === `partition:${keyring.dev.slice(5)}:1`); if (/keyring/.test(second2.keylocation)) assert.equal(blocked.blocked.code, "DISKS_LUKS_PASSPHRASE_REQUIRED"); else t.diagnostic(`cryptsetup used ${second2.keylocation} even without --disable-keyring`); }); } else t.diagnostic("cryptsetup missing: LUKS scenario UNSUPPORTED on this VM"); });