Skip to content
Merged
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
15 changes: 11 additions & 4 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 13 additions & 6 deletions docs/Configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": "$$ " }
}
Expand Down Expand Up @@ -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 (
Expand Down
8 changes: 6 additions & 2 deletions src/config/defaults.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
55 changes: 39 additions & 16 deletions src/config/merge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }),
Expand Down Expand Up @@ -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([
Expand Down
14 changes: 13 additions & 1 deletion src/config/merge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type {
MountConfig,
NetworkConfig,
QemuConfig,
ResourcesConfig,
TuorConfig,
} from "./schema.ts";

Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -181,6 +184,15 @@ function mergeQemu(
return { qemu: { ...parentQemu, ...childQemu } };
}

function mergeResources(
parentResources: ResourcesConfig | undefined,
childResources: ResourcesConfig | undefined,
): { resources: ResourcesConfig } | Record<string, never> {
if (!parentResources && !childResources) return {} as Record<string, never>;
// Shallow merge: each field (memory/cpus) is a scalar, child wins.
return { resources: { ...parentResources, ...childResources } };
}

function mergeStringArrayField<K extends string>(
parentArr: string[] | undefined,
childArr: string[] | undefined,
Expand Down
38 changes: 28 additions & 10 deletions src/config/resolve.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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", () => {
Expand Down
22 changes: 20 additions & 2 deletions src/config/resolve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -103,14 +108,15 @@ export function createSessionSpecFromConfig(
const hasSecrets = Object.keys(secrets).length > 0;

const qemu = resolveQemu(config.qemu);
const resources = resolveResources(config.resources);

return {
user: config.user,
workdir: guestWorkdir,
network: config.network,
mounts: allMounts,
...(volumes.length > 0 ? { volumes } : {}),
...(config.rootfsSize ? { rootfsSize: config.rootfsSize } : {}),
...(resources ? { resources } : {}),
...(hasEnv ? { env: mergedEnv } : {}),
...(hasSecrets ? { secrets } : {}),
...(qemu ? { qemu } : {}),
Expand Down Expand Up @@ -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,
Expand Down
34 changes: 34 additions & 0 deletions src/config/schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }] },
Expand Down
Loading