diff --git a/CHANGELOG.md b/CHANGELOG.md index 004975b..e261978 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +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"), 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 @@ -12,10 +20,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) 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..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": "$$ " } } @@ -94,11 +94,18 @@ that is not set on the host is an error. "ignoreFileRefs": ["host:./tuorignore", "mount:.tuorignore"] } ], - // 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", + // 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 & + // 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 49fb428..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" }), @@ -423,6 +407,45 @@ 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("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" } }), + 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..c3b7752 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"; @@ -94,12 +95,14 @@ 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 ...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 +184,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..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", @@ -447,6 +437,34 @@ 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, rootfsSize: "8G" }, + }); + expect(spec.resources).toEqual({ + memory: "2G", + cpus: 4, + rootfsSize: "8G", + }); + }); + + 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..325bb8a 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, @@ -110,7 +116,7 @@ export function createSessionSpecFromConfig( network: config.network, mounts: allMounts, ...(volumes.length > 0 ? { volumes } : {}), - ...(config.rootfsSize ? { rootfsSize: config.rootfsSize } : {}), + ...(resources ? { resources } : {}), ...(hasEnv ? { env: mergedEnv } : {}), ...(hasSecrets ? { secrets } : {}), ...(qemu ? { qemu } : {}), @@ -245,6 +251,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..b26c59d 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -335,10 +335,44 @@ describe("parseConfig", () => { }); }); + describe("resources config", () => { + 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", () => { + 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..23af674 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -53,11 +53,9 @@ const types = scope({ "qemu?": "QemuConfig", /** - * 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). + * VM resource sizing (RAM, vCPU count, rootfs disk size). */ - "rootfsSize?": "string > 0", + "resources?": "ResourcesConfig", /** * The user to open the shell under and to make mounted directories @@ -264,12 +262,44 @@ 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", + /** + * 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(); 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..e007d2c 100644 --- a/src/core/session.ts +++ b/src/core/session.ts @@ -32,6 +32,17 @@ export type QemuSpec = { machineType?: string; }; +/** + * 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. */ export type SessionSpec = { user: string; @@ -39,7 +50,7 @@ export type SessionSpec = { network: NetworkSpec; mounts: MountSpec[]; volumes?: VolumeSpec[]; - rootfsSize?: string; + resources?: ResourcesSpec; env?: Record; secrets?: Record; qemu?: QemuSpec; @@ -77,7 +88,11 @@ 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 } : {}), ...(hasVfsMounts ? { vfs: { mounts: vfsMounts } } : {}), sandbox: spec.qemu,