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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,10 @@ ROOTCELL_LIMACTL=/path/to/limactl # Lima provider
ROOTCELL_TERRAFORM=/path/to/tofu # AWS EC2 provider
```

The Lima provider requires Lima 2.0.2 or newer for `ssh.overVsock`; use
`nix shell .#hostTools --command ./rootcell` if your PATH has an older
`limactl`.

The AWS EC2 provider uses OpenTofu's `tofu` command by default. Set
`ROOTCELL_TERRAFORM=/path/to/terraform` if you want to use a Terraform binary
you installed yourself.
Expand Down
3 changes: 3 additions & 0 deletions common.nix
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,9 @@ in

environment.enableAllTerminfo = true;
services.lima.enable = !isAwsEc2;
# nixos-lima v0.0.5 boots with dbus-daemon; keep first switch from
# attempting an in-place migration to dbus-broker.
services.dbus.implementation = "dbus";
networking.nat.enable = lib.mkForce false;

# Lima's hostagent probes `/bin/bash` even when the configured user shell is
Expand Down
18 changes: 8 additions & 10 deletions flake.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 2 additions & 7 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
description = "rootcell: root-capable coding-agent workspaces with allowlisted egress";

inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11";
nixpkgs.url = "https://flakehub.com/f/DeterminateSystems/nixpkgs-weekly/0.1";

home-manager = {
url = "github:nix-community/home-manager/release-25.11";
Expand Down Expand Up @@ -42,12 +42,7 @@
forEachDarwin = nixpkgs.lib.genAttrs [ "aarch64-darwin" "x86_64-darwin" ];
darwinPkgs = forEachDarwin (sys:
let
p = import nixpkgs {
system = sys;
config.permittedInsecurePackages = [
"lima-1.2.2"
];
};
p = nixpkgs.legacyPackages.${sys};
in {
lima = p.lima;
hostTools = p.buildEnv {
Expand Down
12 changes: 12 additions & 0 deletions src/rootcell/integration/providers/macos-lima-user-v2/preflight.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { commandExists, runCapture } from "../../../process.ts";
import { assertLimactlSupportsSshOverVsockYaml } from "../../../providers/lima-version.ts";

export function preflightMacOsLimaUserV2Integration(): Promise<void> {
if (process.platform !== "darwin") {
Expand All @@ -19,6 +20,7 @@ export function preflightMacOsLimaUserV2Integration(): Promise<void> {
throw new Error(`macos-lima-user-v2 integration tests require '${tool.command}' on PATH or ${tool.envVars?.join(" or ") ?? "a configured override"}`);
}
}
assertLimactlSupportsSshOverVsockYaml(resolveTool("limactl", ["ROOTCELL_LIMACTL", "LIMACTL"]));
return Promise.resolve();
}

Expand All @@ -31,3 +33,13 @@ function toolAvailable(command: string, envVars: readonly string[] = []): boolea
return envVars.some((envVar) => process.env[envVar] !== undefined && process.env[envVar].length > 0)
|| commandExists(command);
}

function resolveTool(command: string, envVars: readonly string[]): string {
for (const envVar of envVars) {
const configured = process.env[envVar];
if (configured !== undefined && configured.length > 0) {
return configured;
}
}
return command;
}
75 changes: 75 additions & 0 deletions src/rootcell/providers/lima-version.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { runCapture } from "../process.ts";

export interface LimaVersion {
readonly major: number;
readonly minor: number;
readonly patch: number;
readonly raw: string;
}

export const MIN_LIMA_SSH_OVER_VSOCK_YAML_VERSION: LimaVersion = {
major: 2,
minor: 0,
patch: 2,
raw: "2.0.2",
};

export function assertLimactlSupportsSshOverVsockYaml(limactl: string): void {
const result = runCapture(limactl, ["--version"], { allowFailure: true });
const output = `${result.stdout}${result.stderr}`.trim();
if (result.status !== 0) {
throw new Error(output.length > 0 ? output : `failed to run ${limactl} --version`);
}

const version = parseLimactlVersionOutput(output);
if (version === null) {
throw new Error(`could not parse ${limactl} version output: ${output}`);
}
if (limaVersionSupportsSshOverVsockYaml(version)) {
return;
}

throw new Error([
`${limactl} ${formatLimaVersion(version)} does not support .ssh.overVsock; rootcell requires Lima >= ${formatLimaVersion(MIN_LIMA_SSH_OVER_VSOCK_YAML_VERSION)} for the macos-lima provider.`,
"Without SSH over VSOCK, Lima falls back to localhost TCP forwarding and firewall provisioning can interrupt the bootstrap transport.",
"Update Lima or run with the repo-pinned host tools:",
" nix shell .#hostTools --command ./rootcell",
].join("\n"));
}

export function parseLimactlVersionOutput(output: string): LimaVersion | null {
const match = /\bv?([0-9]+)\.([0-9]+)\.([0-9]+)(?:[-+][0-9A-Za-z.-]+)?\b/.exec(output);
if (match === null) {
return null;
}
const major = Number(match[1]);
const minor = Number(match[2]);
const patch = Number(match[3]);
if (!Number.isSafeInteger(major) || !Number.isSafeInteger(minor) || !Number.isSafeInteger(patch)) {
return null;
}
return {
major,
minor,
patch,
raw: `${String(major)}.${String(minor)}.${String(patch)}`,
};
}

export function limaVersionSupportsSshOverVsockYaml(version: LimaVersion): boolean {
return compareLimaVersions(version, MIN_LIMA_SSH_OVER_VSOCK_YAML_VERSION) >= 0;
}

export function compareLimaVersions(left: LimaVersion, right: LimaVersion): number {
for (const key of ["major", "minor", "patch"] as const) {
const diff = left[key] - right[key];
if (diff !== 0) {
return diff;
}
}
return 0;
}

export function formatLimaVersion(version: LimaVersion): string {
return version.raw;
}
3 changes: 2 additions & 1 deletion src/rootcell/providers/macos-lima-user-v2-network.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
import { resolveHostTool } from "../host-tools.ts";
import { runCapture } from "../process.ts";
import type { RootcellConfig } from "../types.ts";
import { assertLimactlSupportsSshOverVsockYaml } from "./lima-version.ts";
import type { NetworkPlan, NetworkProvider, VmNetworkAttachment, VmRole } from "./types.ts";

export interface LimaUserV2NetworkAttachment extends VmNetworkAttachment {
Expand Down Expand Up @@ -74,7 +75,7 @@ export class MacOsLimaUserV2NetworkProvider implements NetworkProvider<LimaUserV
}

preflight(): Promise<void> {
this.ensureLimactl();
assertLimactlSupportsSshOverVsockYaml(this.ensureLimactl());
return Promise.resolve();
}

Expand Down
4 changes: 4 additions & 0 deletions src/rootcell/providers/macos-lima-user-v2/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ ROOTCELL_LIMACTL=/path/to/limactl
# LIMACTL=/path/to/limactl also works
```

The Lima provider requires Lima 2.0.2 or newer because its generated YAML uses
`ssh.overVsock: true` for the VZ bootstrap SSH path. The repo's `.#hostTools`
package pins a compatible Lima release.

rootcell does not override `LIMA_HOME`; Lima instances, the Lima user key, and
user-v2 networks are managed through the normal Lima home. Set `LIMA_HOME`
yourself if you want Lima state somewhere else.
Expand Down
65 changes: 65 additions & 0 deletions src/rootcell/rootcell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ import {
limaUserV2ReservedIps,
MacOsLimaUserV2NetworkProvider,
} from "./providers/macos-lima-user-v2-network.ts";
import {
assertLimactlSupportsSshOverVsockYaml,
limaVersionSupportsSshOverVsockYaml,
parseLimactlVersionOutput,
} from "./providers/lima-version.ts";
import { directSshConfig, LimaVmProvider, limaYaml, NIXOS_LIMA_AARCH64_IMAGE, parseLimaVmState, userV2ProofScript } from "./providers/lima.ts";
import {
ImageStore,
Expand Down Expand Up @@ -465,6 +470,65 @@ describe("host tool resolution", () => {
});
});

describe("Lima version compatibility", () => {
test("parses limactl version output", () => {
expect(parseLimactlVersionOutput("limactl version 2.1.1")).toEqual({
major: 2,
minor: 1,
patch: 1,
raw: "2.1.1",
});
expect(parseLimactlVersionOutput("limactl version v2.0.2")).toEqual({
major: 2,
minor: 0,
patch: 2,
raw: "2.0.2",
});
expect(parseLimactlVersionOutput("limactl version 2.1.1-dev")).toEqual({
major: 2,
minor: 1,
patch: 1,
raw: "2.1.1",
});
expect(parseLimactlVersionOutput("limactl unknown")).toBeNull();
});

test("requires the Lima release that introduced ssh.overVsock YAML", () => {
const oldLima = parseLimactlVersionOutput("limactl version 1.2.2");
const firstYamlFieldRelease = parseLimactlVersionOutput("limactl version 2.0.2");
const newerLima = parseLimactlVersionOutput("limactl version 2.1.1");
if (oldLima === null || firstYamlFieldRelease === null || newerLima === null) {
throw new Error("test failed to parse Lima versions");
}

expect(limaVersionSupportsSshOverVsockYaml(oldLima)).toBe(false);
expect(limaVersionSupportsSshOverVsockYaml(firstYamlFieldRelease)).toBe(true);
expect(limaVersionSupportsSshOverVsockYaml(newerLima)).toBe(true);
});

test("fails preflight before provisioning with a limactl that cannot read ssh.overVsock", () => {
const dir = mkdtempSync(join(tmpdir(), "rootcell-lima-version-test-"));
try {
const limactl = join(dir, "limactl");
writeFileSync(limactl, [
"#!/bin/sh",
"printf 'limactl version 1.2.2\\n'",
"",
].join("\n"), "utf8");
chmodSync(limactl, 0o755);

expect(() => {
assertLimactlSupportsSshOverVsockYaml(limactl);
}).toThrow(".ssh.overVsock");
expect(() => {
assertLimactlSupportsSshOverVsockYaml(limactl);
}).toThrow("nix shell .#hostTools --command ./rootcell");
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});

describe("secret providers", () => {
test("registry routes provider-qualified references", async () => {
const calls: string[] = [];
Expand Down Expand Up @@ -893,6 +957,7 @@ describe("VM and network providers", () => {
expect(commonModule).toContain('home = lib.mkDefault "/home/${username}";');
expect(commonModule).toContain("ln -sfn /run/current-system/sw/bin/bash /bin/bash");
expect(commonModule).toContain("services.lima.enable = !isAwsEc2;");
expect(commonModule).toContain('services.dbus.implementation = "dbus";');
expect(commonModule).toContain("/virtualisation/amazon-image.nix");
expect(commonModule).toContain("networking.nat.enable = lib.mkForce false;");

Expand Down