From 6de9ada64e79ad75407ac8bc67a37109afbc2f24 Mon Sep 17 00:00:00 2001 From: codethief Date: Sun, 19 Jul 2026 15:07:24 +0200 Subject: [PATCH 1/3] Fix typo in CHANGELOG --- CHANGELOG.md | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 004975b..229c912 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,10 +12,9 @@ ## Bug fixes - Config: a child config layer (e.g. a project `.tuor/config.json`) that didn't set `workdir` or `user` silently reset the value inherited from an upper layer - layer (e.g. `~/.config/tuor/config.json`) to the built-in default values - (workdir: `/`, user: `root`). These fields now fall through to the parent - layer when omitted; their defaults are applied only after all layers are - merged. + (e.g. `~/.config/tuor/config.json`) to the built-in default values (workdir: + `/`, user: `root`). These fields now fall through to the parent layer when + omitted; their defaults are applied only after all layers are merged. # 0.3.0 (2026-07-08) From fcb9bdf38a12d592a1e2f2d8e3dde595cb39a988 Mon Sep 17 00:00:00 2001 From: codethief Date: Sun, 19 Jul 2026 15:10:35 +0200 Subject: [PATCH 2/3] Expose Gondolin's `cpus` & `memory` options in config schema --- CHANGELOG.md | 3 +++ README.md | 1 + docs/Configuration.md | 7 +++++++ src/config/merge.test.ts | 23 +++++++++++++++++++++++ src/config/merge.ts | 13 +++++++++++++ src/config/resolve.test.ts | 22 ++++++++++++++++++++++ src/config/resolve.ts | 21 ++++++++++++++++++++- src/config/schema.test.ts | 28 ++++++++++++++++++++++++++++ src/config/schema.ts | 30 ++++++++++++++++++++++++++++++ src/core/session.ts | 12 ++++++++++++ 10 files changed, 159 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 229c912..f51b2c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,9 @@ # Unreleased ## Features +- Config: Add `resources`, a block to size the VM's RAM (`memory`, QEMU syntax + e.g. "2G") and vCPU count (`cpus`). Unset fields fall back to Gondolin's + defaults (currently 1G memory, 2 cpus). - Config: Add `bootCommands`, a list of shell commands run once (as root, in the configured `workdir`) right after the VM boots and before the interactive shell / user command. Commands run in order and boot aborts on the first diff --git a/README.md b/README.md index 0439f0d..7377044 100644 --- a/README.md +++ b/README.md @@ -87,6 +87,7 @@ guarantees: - [Alibaba OpenSandbox](https://github.com/alibaba/OpenSandbox/) - [Docker Sandbox](https://docs.docker.com/ai/sandboxes/) - [Matchlock](https://github.com/jingkaihe/matchlock) +- [SlicerVM](https://slicervm.com) ## Acknowledgements diff --git a/docs/Configuration.md b/docs/Configuration.md index 90f5626..fe1d5fe 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -94,6 +94,13 @@ that is not set on the host is an error. "ignoreFileRefs": ["host:./tuorignore", "mount:.tuorignore"] } ], + // VM resource sizing. Any field left unset falls back to Gondolin's default + // (1G memory, 2 cpus). Note that `cpus` (the vCPU count) is distinct from + // `qemu.cpu` (the emulated CPU model). + "resources": { + "cpus": 4, // vCPU count (positive integer) + "memory": "2G" // RAM, QEMU syntax (e.g. "512M", "2G") + }, // Minimum virtual disk size (COW overlay, so actual host usage stays // sparse). Note that the virtual disk will be discarded on VM shutdown, // so it is not meant for persisting data across VM boots. (Use mounts & diff --git a/src/config/merge.test.ts b/src/config/merge.test.ts index 49fb428..ab96f37 100644 --- a/src/config/merge.test.ts +++ b/src/config/merge.test.ts @@ -423,6 +423,29 @@ describe("mergeConfigs", () => { }); }); + describe("resources: shallow field merge", () => { + test("child field overrides parent, others preserved", () => { + const result = mergeConfigs([ + layer("/a", { resources: { memory: "1G", cpus: 2 } }), + layer("/b", { resources: { cpus: 8 } }), + ]); + expect(result.resources).toEqual({ memory: "1G", cpus: 8 }); + }); + + test("parent resources used when child has none", () => { + const result = mergeConfigs([ + layer("/a", { resources: { memory: "4G" } }), + layer("/b"), + ]); + expect(result.resources).toEqual({ memory: "4G" }); + }); + + test("no resources when neither layer has it", () => { + const result = mergeConfigs([layer("/a"), layer("/b")]); + expect(result.resources).toBeUndefined(); + }); + }); + describe("three-level merge", () => { test("most specific wins for scalars, arrays concatenate across all layers", () => { const result = mergeConfigs([ diff --git a/src/config/merge.ts b/src/config/merge.ts index 1e74037..ef15587 100644 --- a/src/config/merge.ts +++ b/src/config/merge.ts @@ -5,6 +5,7 @@ import type { MountConfig, NetworkConfig, QemuConfig, + ResourcesConfig, TuorConfig, } from "./schema.ts"; @@ -100,6 +101,9 @@ function mergeTwoConfigs(parent: TuorConfig, child: TuorConfig): TuorConfig { // Qemu: deep-merge each variant, child field wins ...mergeQemu(parent.qemu, child.qemu), + // Resources: deep-merge each field, child field wins + ...mergeResources(parent.resources, child.resources), + // Arrays: concatenate ...mergeArrayField(parent.mounts, child.mounts, "mounts"), ...mergeArrayField(parent.volumes, child.volumes, "volumes"), @@ -181,6 +185,15 @@ function mergeQemu( return { qemu: { ...parentQemu, ...childQemu } }; } +function mergeResources( + parentResources: ResourcesConfig | undefined, + childResources: ResourcesConfig | undefined, +): { resources: ResourcesConfig } | Record { + if (!parentResources && !childResources) return {} as Record; + // Shallow merge: each field (memory/cpus) is a scalar, child wins. + return { resources: { ...parentResources, ...childResources } }; +} + function mergeStringArrayField( parentArr: string[] | undefined, childArr: string[] | undefined, diff --git a/src/config/resolve.test.ts b/src/config/resolve.test.ts index c783486..93553a4 100644 --- a/src/config/resolve.test.ts +++ b/src/config/resolve.test.ts @@ -447,6 +447,28 @@ describe("createSessionSpecFromConfig", () => { expect(spec.qemu).toBeUndefined(); }); }); + + describe("resources resolution", () => { + test("omits resources when nothing is configured", () => { + const spec = resolve({}); + expect(spec.resources).toBeUndefined(); + }); + + test("passes the resources config through verbatim", () => { + const spec = resolve({ resources: { memory: "2G", cpus: 4 } }); + expect(spec.resources).toEqual({ memory: "2G", cpus: 4 }); + }); + + test("passes a partial resources config through", () => { + const spec = resolve({ resources: { cpus: 8 } }); + expect(spec.resources).toEqual({ cpus: 8 }); + }); + + test("treats an empty resources config as unset", () => { + const spec = resolve({ resources: {} }); + expect(spec.resources).toBeUndefined(); + }); + }); }); describe("_resolveEnv", () => { diff --git a/src/config/resolve.ts b/src/config/resolve.ts index 7e7fbf3..7cdb771 100644 --- a/src/config/resolve.ts +++ b/src/config/resolve.ts @@ -6,7 +6,12 @@ import type { VolumeSpec, } from "../core/mounts.ts"; import { validateMounts } from "../core/mounts.ts"; -import type { QemuSpec, SecretSpec, SessionSpec } from "../core/session.ts"; +import type { + QemuSpec, + ResourcesSpec, + SecretSpec, + SessionSpec, +} from "../core/session.ts"; import type { ScopedPattern } from "../core/shadow.ts"; import type { DefaultedConfig } from "./defaults.ts"; import { expandTilde } from "./homedir.ts"; @@ -103,6 +108,7 @@ export function createSessionSpecFromConfig( const hasSecrets = Object.keys(secrets).length > 0; const qemu = resolveQemu(config.qemu); + const resources = resolveResources(config.resources); return { user: config.user, @@ -111,6 +117,7 @@ export function createSessionSpecFromConfig( mounts: allMounts, ...(volumes.length > 0 ? { volumes } : {}), ...(config.rootfsSize ? { rootfsSize: config.rootfsSize } : {}), + ...(resources ? { resources } : {}), ...(hasEnv ? { env: mergedEnv } : {}), ...(hasSecrets ? { secrets } : {}), ...(qemu ? { qemu } : {}), @@ -245,6 +252,18 @@ function resolveQemu(qemu: TuorConfig["qemu"]): QemuSpec | undefined { return { ...qemu }; } +/** + * Pass the configured VM resource knobs through verbatim. We ship no defaults: + * any field left unset falls back to Gondolin's own default (currently 1G + * memory, 2 cpus). Returns undefined when nothing is configured. + */ +function resolveResources( + resources: TuorConfig["resources"], +): ResourcesSpec | undefined { + if (!resources || Object.keys(resources).length === 0) return undefined; + return { ...resources }; +} + function resolveVolumeConfig( v: VolumeConfig, configDir: string, diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index 6bfa82a..f64304e 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -335,10 +335,38 @@ describe("parseConfig", () => { }); }); + describe("resources config", () => { + test("accepts memory and cpus", () => { + const config = parseConfig({ resources: { memory: "2G", cpus: 4 } }); + expect(config.resources).toEqual({ memory: "2G", cpus: 4 }); + }); + + test("accepts a partial config", () => { + const config = parseConfig({ resources: { cpus: 8 } }); + expect(config.resources).toEqual({ cpus: 8 }); + }); + + test("accepts memory without a unit suffix", () => { + const config = parseConfig({ resources: { memory: "1024" } }); + expect(config.resources).toEqual({ memory: "1024" }); + }); + + test("omits resources when not specified", () => { + const config = parseConfig({}); + expect(config.resources).toBeUndefined(); + }); + }); + test.each([ ["qemu unknown field", { qemu: { foo: "bar" } }], ["qemu empty accel", { qemu: { accel: "" } }], ["qemu non-string cpu", { qemu: { cpu: 42 } }], + ["resources unknown field", { resources: { foo: "bar" } }], + ["resources malformed memory", { resources: { memory: "2GB" } }], + ["resources empty memory", { resources: { memory: "" } }], + ["resources non-integer cpus", { resources: { cpus: 1.5 } }], + ["resources zero cpus", { resources: { cpus: 0 } }], + ["resources non-number cpus", { resources: { cpus: "4" } }], [ "relative guestPath", { mounts: [{ hostPath: "/foo", guestPath: "rel" }] }, diff --git a/src/config/schema.ts b/src/config/schema.ts index bb1f941..03b649e 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -52,6 +52,12 @@ const types = scope({ */ "qemu?": "QemuConfig", + /** + * VM resource sizing (RAM, vCPU count). Any field left unset falls back to + * Gondolin's own defaults (1G memory, 2 cpus) — see ResourcesConfig. + */ + "resources?": "ResourcesConfig", + /** * Minimum virtual disk size for the rootfs (e.g. "2G", "512M"). * The COW overlay will be grown to at least this size before boot. @@ -264,12 +270,36 @@ const types = scope({ /** QEMU `-machine` type, e.g. "q35", "microvm", "virt". */ "machineType?": "string > 0", }, + + // -------------------------------------------------------------------------- + // VM resources + // -------------------------------------------------------------------------- + + /** + * VM resource sizing, forwarded verbatim to Gondolin. + */ + ResourcesConfig: { + "+": "reject", + /** + * VM RAM in QEMU syntax: a positive integer with an optional K/M/G/T + * suffix, e.g. "512M", "2G". Maps to Gondolin's `memory` top-level option. + * Gondolin default: "1G". + */ + "memory?": type("string > 0").matching(/^\d+[KMGT]?$/i), + /** + * VM vCPU count (positive integer). Maps to Gondolin's `cpus` top-level + * option. Gondolin default: 2. Note that this config option is distinct + * from `QemuConfig.cpu` (the emulated CPU *model*)! + */ + "cpus?": "number.integer >= 1", + }, }).export(); export type VolumeConfig = typeof types.VolumeConfig.infer; export type MountConfig = typeof types.MountConfig.infer; export type NixConfig = typeof types.NixConfig.infer; export type QemuConfig = typeof types.QemuConfig.infer; +export type ResourcesConfig = typeof types.ResourcesConfig.infer; export type NetworkConfig = typeof types.NetworkConfig.infer; export type EnvFromHost = typeof types.EnvFromHost.infer; export type EnvSecret = typeof types.EnvSecret.infer; diff --git a/src/core/session.ts b/src/core/session.ts index 7704237..8607429 100644 --- a/src/core/session.ts +++ b/src/core/session.ts @@ -32,6 +32,15 @@ export type QemuSpec = { machineType?: string; }; +/** + * Resolved VM resource sizing. Fields map verbatim to Gondolin's top-level + * `memory`/`cpus` options; an unset field falls back to Gondolin's default. + */ +export type ResourcesSpec = { + memory?: string; + cpus?: number; +}; + /** Core's top-level input contract — everything the session needs to run. */ export type SessionSpec = { user: string; @@ -40,6 +49,7 @@ export type SessionSpec = { mounts: MountSpec[]; volumes?: VolumeSpec[]; rootfsSize?: string; + resources?: ResourcesSpec; env?: Record; secrets?: Record; qemu?: QemuSpec; @@ -78,6 +88,8 @@ export async function runSession( const vm = await VM.create({ ...networkOptions, ...(spec.rootfsSize ? { rootfs: { size: spec.rootfsSize } } : {}), + ...(spec.resources?.memory ? { memory: spec.resources.memory } : {}), + ...(spec.resources?.cpus ? { cpus: spec.resources.cpus } : {}), ...(hasEnv ? { env: mergedEnv } : {}), ...(hasVfsMounts ? { vfs: { mounts: vfsMounts } } : {}), sandbox: spec.qemu, From 1a9f6811798654f0d0cc4c2aed524af637d331d8 Mon Sep 17 00:00:00 2001 From: codethief Date: Sun, 19 Jul 2026 15:30:12 +0200 Subject: [PATCH 3/3] Breaking change: Move `rootfsSize` to `resources` config structure --- CHANGELOG.md | 9 +++++++-- docs/Configuration.md | 16 ++++++++-------- src/config/defaults.test.ts | 8 ++++++-- src/config/merge.test.ts | 32 ++++++++++++++++---------------- src/config/merge.ts | 1 - src/config/resolve.test.ts | 20 ++++++++------------ src/config/resolve.ts | 1 - src/config/schema.test.ts | 12 +++++++++--- src/config/schema.ts | 20 ++++++++++---------- src/core/session.ts | 11 +++++++---- 10 files changed, 71 insertions(+), 59 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f51b2c0..e261978 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,14 @@ # Unreleased +## Breaking changes +- Config: `rootfsSize` moved from the config top level into the new `resources` + block (i.e. `{ "resources": { "rootfsSize": "2G" } }`). Update configs that + set it at the top level. + ## Features - Config: Add `resources`, a block to size the VM's RAM (`memory`, QEMU syntax - e.g. "2G") and vCPU count (`cpus`). Unset fields fall back to Gondolin's - defaults (currently 1G memory, 2 cpus). + e.g. "2G"), vCPU count (`cpus`), and rootfs disk size (`rootfsSize`, moved + here from the top level). - Config: Add `bootCommands`, a list of shell commands run once (as root, in the configured `workdir`) right after the VM boots and before the interactive shell / user command. Commands run in order and boot aborts on the first diff --git a/docs/Configuration.md b/docs/Configuration.md index fe1d5fe..c0fa9bd 100644 --- a/docs/Configuration.md +++ b/docs/Configuration.md @@ -30,7 +30,7 @@ is validated): // $PWD lets you mount wherever you launched Tuor from: { "hostPath": "$PWD", "guestPath": "/workspace", "mode": "readwrite" } ], - "rootfsSize": "${ROOTFS_SIZE}", + "resources": { "rootfsSize": "${ROOTFS_SIZE}" }, // Use $$ for a literal dollar sign: "env": { "PROMPT": "$$ " } } @@ -98,14 +98,14 @@ that is not set on the host is an error. // (1G memory, 2 cpus). Note that `cpus` (the vCPU count) is distinct from // `qemu.cpu` (the emulated CPU model). "resources": { - "cpus": 4, // vCPU count (positive integer) - "memory": "2G" // RAM, QEMU syntax (e.g. "512M", "2G") + "cpus": 4, // vCPU count (positive integer) + "memory": "2G", // RAM, QEMU syntax (e.g. "512M", "2G") + // Minimum virtual disk size (COW overlay, so actual host usage stays + // sparse). Note that the virtual disk will be discarded on VM shutdown, + // so it is not meant for persisting data across VM boots. (Use mounts & + // volumes, instead!) + "rootfsSize": "2G" }, - // Minimum virtual disk size (COW overlay, so actual host usage stays - // sparse). Note that the virtual disk will be discarded on VM shutdown, - // so it is not meant for persisting data across VM boots. (Use mounts & - // volumes, instead!) - "rootfsSize": "2G", // Constraint: Guest user must currently be root "user": "root", // Persistent guest directories without a host backing directory ( diff --git a/src/config/defaults.test.ts b/src/config/defaults.test.ts index 887f90c..792d770 100644 --- a/src/config/defaults.test.ts +++ b/src/config/defaults.test.ts @@ -80,11 +80,15 @@ describe("applyConfigDefaults", () => { }); test("leaves other fields untouched", () => { - const input = config({ user: "dev", workdir: "/work", rootfsSize: "2G" }); + const input = config({ + user: "dev", + workdir: "/work", + resources: { rootfsSize: "2G" }, + }); const result = applyConfigDefaults(input); expect(result.user).toBe("dev"); expect(result.workdir).toBe("/work"); - expect(result.rootfsSize).toBe("2G"); + expect(result.resources).toEqual({ rootfsSize: "2G" }); }); test("does not mutate the input config", () => { diff --git a/src/config/merge.test.ts b/src/config/merge.test.ts index ab96f37..dbd3e1e 100644 --- a/src/config/merge.test.ts +++ b/src/config/merge.test.ts @@ -129,22 +129,6 @@ describe("mergeConfigs", () => { expect(result.workdir).toBe("/parent-dir"); }); - test("rootfsSize: child overrides parent", () => { - const result = mergeConfigs([ - layer("/a", { rootfsSize: "1G" }), - layer("/b", { rootfsSize: "4G" }), - ]); - expect(result.rootfsSize).toBe("4G"); - }); - - test("rootfsSize: parent used when child omits", () => { - const result = mergeConfigs([ - layer("/a", { rootfsSize: "1G" }), - layer("/b"), - ]); - expect(result.rootfsSize).toBe("1G"); - }); - test("guestHomeDir: child overrides parent", () => { const result = mergeConfigs([ layer("/a", { guestHomeDir: "/old" }), @@ -432,6 +416,22 @@ describe("mergeConfigs", () => { expect(result.resources).toEqual({ memory: "1G", cpus: 8 }); }); + test("distinct fields across layers are combined", () => { + const result = mergeConfigs([ + layer("/a", { resources: { memory: "1G" } }), + layer("/b", { resources: { rootfsSize: "4G" } }), + ]); + expect(result.resources).toEqual({ memory: "1G", rootfsSize: "4G" }); + }); + + test("child rootfsSize overrides parent", () => { + const result = mergeConfigs([ + layer("/a", { resources: { rootfsSize: "1G" } }), + layer("/b", { resources: { rootfsSize: "4G" } }), + ]); + expect(result.resources).toEqual({ rootfsSize: "4G" }); + }); + test("parent resources used when child has none", () => { const result = mergeConfigs([ layer("/a", { resources: { memory: "4G" } }), diff --git a/src/config/merge.ts b/src/config/merge.ts index ef15587..c3b7752 100644 --- a/src/config/merge.ts +++ b/src/config/merge.ts @@ -95,7 +95,6 @@ function mergeTwoConfigs(parent: TuorConfig, child: TuorConfig): TuorConfig { ...lastDefined(child.user, parent.user, "user"), ...lastDefined(child.workdir, parent.workdir, "workdir"), ...lastDefined(child.guestHomeDir, parent.guestHomeDir, "guestHomeDir"), - ...lastDefined(child.rootfsSize, parent.rootfsSize, "rootfsSize"), ...lastDefined(child.nix, parent.nix, "nix"), // Qemu: deep-merge each variant, child field wins diff --git a/src/config/resolve.test.ts b/src/config/resolve.test.ts index 93553a4..68b1ba9 100644 --- a/src/config/resolve.test.ts +++ b/src/config/resolve.test.ts @@ -392,16 +392,6 @@ describe("createSessionSpecFromConfig", () => { expect(spec.user).toBe("dev"); }); - test("passes through rootfsSize", () => { - const spec = resolve({ rootfsSize: "2G" }); - expect(spec.rootfsSize).toBe("2G"); - }); - - test("omits rootfsSize when not set", () => { - const spec = resolve({}); - expect(spec.rootfsSize).toBeUndefined(); - }); - test("infers guestHomeDir from user for tilde expansion", () => { const spec = resolve({ user: "alice", @@ -455,8 +445,14 @@ describe("createSessionSpecFromConfig", () => { }); test("passes the resources config through verbatim", () => { - const spec = resolve({ resources: { memory: "2G", cpus: 4 } }); - expect(spec.resources).toEqual({ memory: "2G", cpus: 4 }); + const spec = resolve({ + resources: { memory: "2G", cpus: 4, rootfsSize: "8G" }, + }); + expect(spec.resources).toEqual({ + memory: "2G", + cpus: 4, + rootfsSize: "8G", + }); }); test("passes a partial resources config through", () => { diff --git a/src/config/resolve.ts b/src/config/resolve.ts index 7cdb771..325bb8a 100644 --- a/src/config/resolve.ts +++ b/src/config/resolve.ts @@ -116,7 +116,6 @@ export function createSessionSpecFromConfig( network: config.network, mounts: allMounts, ...(volumes.length > 0 ? { volumes } : {}), - ...(config.rootfsSize ? { rootfsSize: config.rootfsSize } : {}), ...(resources ? { resources } : {}), ...(hasEnv ? { env: mergedEnv } : {}), ...(hasSecrets ? { secrets } : {}), diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index f64304e..b26c59d 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -336,9 +336,15 @@ describe("parseConfig", () => { }); describe("resources config", () => { - test("accepts memory and cpus", () => { - const config = parseConfig({ resources: { memory: "2G", cpus: 4 } }); - expect(config.resources).toEqual({ memory: "2G", cpus: 4 }); + test("accepts memory, cpus and rootfsSize", () => { + const config = parseConfig({ + resources: { memory: "2G", cpus: 4, rootfsSize: "8G" }, + }); + expect(config.resources).toEqual({ + memory: "2G", + cpus: 4, + rootfsSize: "8G", + }); }); test("accepts a partial config", () => { diff --git a/src/config/schema.ts b/src/config/schema.ts index 03b649e..23af674 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -53,18 +53,10 @@ const types = scope({ "qemu?": "QemuConfig", /** - * VM resource sizing (RAM, vCPU count). Any field left unset falls back to - * Gondolin's own defaults (1G memory, 2 cpus) — see ResourcesConfig. + * VM resource sizing (RAM, vCPU count, rootfs disk size). */ "resources?": "ResourcesConfig", - /** - * Minimum virtual disk size for the rootfs (e.g. "2G", "512M"). - * The COW overlay will be grown to at least this size before boot. - * Actual host disk usage remains sparse (only written pages cost space). - */ - "rootfsSize?": "string > 0", - /** * The user to open the shell under and to make mounted directories * available for. Must currently be root due to Gondolin-related @@ -286,12 +278,20 @@ const types = scope({ * Gondolin default: "1G". */ "memory?": type("string > 0").matching(/^\d+[KMGT]?$/i), - /** + /** * VM vCPU count (positive integer). Maps to Gondolin's `cpus` top-level * option. Gondolin default: 2. Note that this config option is distinct * from `QemuConfig.cpu` (the emulated CPU *model*)! */ "cpus?": "number.integer >= 1", + /** + * Minimum virtual disk size for the rootfs (e.g. "2G", "512M"). The COW + * overlay will be grown to at least this size before boot. Actual host disk + * usage remains sparse (only written pages cost space). The config option + * maps to Gondolin's `rootfs.size` and defaults to the size of the base + * image used by Gondolin (no minimum growth). + */ + "rootfsSize?": "string > 0", }, }).export(); diff --git a/src/core/session.ts b/src/core/session.ts index 8607429..e007d2c 100644 --- a/src/core/session.ts +++ b/src/core/session.ts @@ -33,12 +33,14 @@ export type QemuSpec = { }; /** - * Resolved VM resource sizing. Fields map verbatim to Gondolin's top-level - * `memory`/`cpus` options; an unset field falls back to Gondolin's default. + * Resolved VM resource sizing. `memory`/`cpus` map verbatim to Gondolin's + * top-level options; `rootfsSize` maps to `rootfs.size`. An unset field falls + * back to Gondolin's default. */ export type ResourcesSpec = { memory?: string; cpus?: number; + rootfsSize?: string; }; /** Core's top-level input contract — everything the session needs to run. */ @@ -48,7 +50,6 @@ export type SessionSpec = { network: NetworkSpec; mounts: MountSpec[]; volumes?: VolumeSpec[]; - rootfsSize?: string; resources?: ResourcesSpec; env?: Record; secrets?: Record; @@ -87,7 +88,9 @@ export async function runSession( console.log("Starting VM…"); const vm = await VM.create({ ...networkOptions, - ...(spec.rootfsSize ? { rootfs: { size: spec.rootfsSize } } : {}), + ...(spec.resources?.rootfsSize + ? { rootfs: { size: spec.resources.rootfsSize } } + : {}), ...(spec.resources?.memory ? { memory: spec.resources.memory } : {}), ...(spec.resources?.cpus ? { cpus: spec.resources.cpus } : {}), ...(hasEnv ? { env: mergedEnv } : {}),