Skip to content
Open
Show file tree
Hide file tree
Changes from 10 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
64 changes: 3 additions & 61 deletions scripts/machine.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,11 @@ import {
sha256,
spawn,
spawnSafe,
spawnScp,
spawnSsh,
spawnSshSafe,
spawnSyncSafe,
startGroup,
waitForPort,
which,
writeFile,
} from "./utils.mjs";
Comment thread
alii marked this conversation as resolved.
Expand Down Expand Up @@ -937,64 +937,6 @@ async function getGithubOrgSshKeys(organization) {
* @property {number} [retries]
*/

/**
* @typedef ScpOptions
* @property {string} hostname
* @property {string} source
* @property {string} destination
* @property {string[]} [identityPaths]
* @property {string} [port]
* @property {string} [username]
* @property {number} [retries]
*/

/**
* @param {ScpOptions} options
* @returns {Promise<void>}
*/
async function spawnScp(options) {
const { hostname, port, username, identityPaths, password, source, destination, retries = 3 } = options;
await waitForPort({ hostname, port: port || 22 });

const command = ["scp", "-o", "StrictHostKeyChecking=no"];
command.push("-O"); // use SCP instead of SFTP
if (!password) {
command.push("-o", "BatchMode=yes");
}
if (port) {
command.push("-P", port);
}
if (password) {
const sshPass = which("sshpass", { required: true });
command.unshift(sshPass, "-p", password);
} else if (identityPaths) {
command.push(...identityPaths.flatMap(path => ["-i", path]));
}
command.push(resolve(source));
if (username) {
command.push(`${username}@${hostname}:${destination}`);
} else {
command.push(`${hostname}:${destination}`);
}

let cause;
for (let i = 0; i < retries; i++) {
const result = await spawn(command, { stdio: "inherit" });
const { exitCode, stderr } = result;
if (exitCode === 0) {
return;
}

cause = stderr.trim() || undefined;
if (/(bad configuration option)|(no such file or directory)/i.test(stderr)) {
break;
}
await new Promise(resolve => setTimeout(resolve, Math.pow(2, i) * 1000));
}

throw new Error(`SCP failed: ${source} -> ${username}@${hostname}:${destination}`, { cause });
}

/**
* @param {string} passwordData
* @param {string} privateKeyPath
Expand Down Expand Up @@ -1098,7 +1040,7 @@ function getCloud(name) {
* @property {(source: string, destination: string) => Promise<void>} upload
* @property {() => Promise<RdpCredentials>} [rdp]
* @property {() => Promise<void>} attach
* @property {() => Promise<string>} snapshot
* @property {(label?: string) => Promise<string>} snapshot
Comment thread
robobun marked this conversation as resolved.
* @property {() => Promise<void>} close
*/

Expand Down Expand Up @@ -1484,7 +1426,7 @@ async function main() {
}

try {
if (options.rdp) {
if (options.rdp && typeof machine.rdp === "function") {
await startGroup("Connecting with RDP...", async () => {
const { hostname, username, password } = await machine.rdp();

Expand Down
46 changes: 34 additions & 12 deletions scripts/tart.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { inspect } from "node:util";
import { isPrivileged, spawnSafe, which } from "./utils.mjs";
import { isPrivileged, spawnSafe, spawnScp, spawnSsh, spawnSshSafe, which } from "./utils.mjs";

/**
* @link https://tart.run/
Expand Down Expand Up @@ -33,8 +33,8 @@
},

/**
* @typedef {"sequoia" | "sonoma" | "ventura" | "monterey"} TartDistro
* @typedef {`ghcr.io/cirruslabs/macos-${TartDistro}-xcode`} TartImage
* @typedef {"tahoe" | "sequoia" | "sonoma" | "ventura" | "monterey"} TartDistro
* @typedef {`ghcr.io/cirruslabs/macos-${TartDistro}-base`} TartImage
* @link https://github.com/orgs/cirruslabs/packages?repo_name=macos-image-templates
*/

Expand All @@ -48,16 +48,17 @@
throw new Error(`Unsupported platform: ${inspect(platform)}`);
}
const distros = {
"26": "tahoe",
"15": "sequoia",
"14": "sonoma",
"13": "ventura",
"12": "monterey",
};
const distro = distros[release];
if (!distro) {
throw new Error(`Unsupported macOS release: ${distro}`);
throw new Error(`Unsupported macOS release: ${release}`);
}
return `ghcr.io/cirruslabs/macos-${distro}-xcode`;
return `ghcr.io/cirruslabs/macos-${distro}-base`;
},

/**
Expand Down Expand Up @@ -87,6 +88,9 @@
json: true,
throwOnError: error => !/does not exist/i.test(inspect(error)),
});
if (!result) {
return undefined;
}
return {
Name: name,
...result,
Comment thread
robobun marked this conversation as resolved.
Expand Down Expand Up @@ -129,8 +133,10 @@
}

console.log(`Cloning macOS image: ${image} (this will take a long time)`);
await this.spawn(["clone", image, localName]);
await this.spawn(["clone", localName, name]);
// stdio: inherit — surface tart's layer-by-layer pull progress so
// Buildkite's 10-min no-output watchdog doesn't kill a ~25GB download.
await this.spawn(["clone", image, localName], { stdio: "inherit" });
await this.spawn(["clone", localName, name], { stdio: "inherit" });
},

/**
Expand Down Expand Up @@ -184,8 +190,10 @@
);
}

// This command is blocking, so it needs to be detached and not awaited
this.spawn(["run", name, ...args], { detached: true });
// `tart run` blocks for the VM's lifetime, so it's detached and not
// awaited. stopVm() makes it exit non-zero; without throwOnError:false
// that becomes an unhandled rejection and Node exits 1.
this.spawn(["run", name, ...args], { detached: true, throwOnError: false });
},

/**
Expand All @@ -206,6 +214,12 @@
*/
async createMachine(options) {
const { name, imageName, cpuCount, memoryGb, diskSizeGb, rdp } = options;
// cirruslabs base images use password auth (admin/admin); spawnSsh shells
// out to sshpass for that, which isn't on stock macOS. Check before
// cloneVm/runVm so a missing dep doesn't orphan a running VM.
if (!which("sshpass")) {
throw new Error("tart machine ops need sshpass: brew install hudochenkov/sshpass/sshpass");
}

const image = imageName || this.getImage(options);
const machineId = name || `i-${Math.random().toString(36).slice(2, 11)}`;
Comment thread
claude[bot] marked this conversation as resolved.
Comment thread
alii marked this conversation as resolved.
Expand Down Expand Up @@ -260,9 +274,16 @@
await spawnScp({ ...connectOptions, source, destination });
};

const rdp = async () => {
const connectOptions = await connect();
await spawnRdp({ ...connectOptions });
const snapshot = async label => {
if (!label) throw new Error("tart snapshot() requires a label");
// tart can't push a running VM — stop first, then push to ghcr. Auth via
// TART_REGISTRY_USERNAME / TART_REGISTRY_PASSWORD (set by the image-build
// pipeline; tart reads them directly, no `tart login` needed).
await this.stopVm(name);
const remote = `ghcr.io/oven-sh/${label}`;
console.log(`Pushing ${name} to ${remote} (~25GB, this takes a while)...`);
Comment thread
alii marked this conversation as resolved.
await this.spawn(["push", name, remote], { stdio: "inherit" });
return remote;
Comment thread
alii marked this conversation as resolved.
};

const close = async () => {
Comment thread
alii marked this conversation as resolved.
Expand All @@ -273,11 +294,12 @@
cloud: "tart",
id: name,
spawn: exec,
spawnSafe: execSafe,
attach,
upload,
snapshot,
close,
[Symbol.asyncDispose]: close,
};

Check warning on line 303 in scripts/tart.mjs

View check run for this annotation

Claude / Claude Code Review

tart toMachine() return object missing imageId/instanceType/region

tart.toMachine() returns an object without imageId, instanceType, or region, so the 'Created machine:' console.table printed by machine.mjs after createMachine() will show 'undefined' for all three fields on every tart create-image run. The fix is to add imageId (the OCI image string from createMachine's image variable), instanceType (a cpu/mem summary string), and region: 'local' to the returned object.
Comment thread
robobun marked this conversation as resolved.
},
};
59 changes: 59 additions & 0 deletions scripts/utils.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3146,6 +3146,65 @@ export async function spawnSsh(options, spawnOptions = {}) {
return result;
}

/**
* @typedef ScpOptions
* @property {string} hostname
* @property {string} source
* @property {string} destination
* @property {string[]} [identityPaths]
* @property {string} [port]
* @property {string} [username]
* @property {string} [password]
* @property {number} [retries]
*/

/**
* @param {ScpOptions} options
* @returns {Promise<void>}
*/
export async function spawnScp(options) {
const { hostname, port, username, identityPaths, password, source, destination, retries = 3 } = options;
await waitForPort({ hostname, port: port || 22 });

const command = ["scp", "-o", "StrictHostKeyChecking=no"];
command.push("-O"); // use SCP instead of SFTP
if (!password) {
command.push("-o", "BatchMode=yes");
}
if (port) {
command.push("-P", port);
}
if (password) {
const sshPass = which("sshpass", { required: true });
command.unshift(sshPass, "-p", password);
} else if (identityPaths) {
command.push(...identityPaths.flatMap(path => ["-i", path]));
}
command.push(resolve(source));
if (username) {
command.push(`${username}@${hostname}:${destination}`);
} else {
command.push(`${hostname}:${destination}`);
}

let cause;
for (let i = 0; i < retries; i++) {
const result = await spawn(command, { stdio: "inherit" });
const { exitCode, stderr } = result;
if (exitCode === 0) {
return;
}

cause = stderr.trim() || undefined;
if (/(bad configuration option)|(no such file or directory)/i.test(stderr)) {
break;
}
await new Promise(resolve => setTimeout(resolve, Math.pow(2, i) * 1000));
}

throw new Error(`SCP failed: ${source} -> ${username}@${hostname}:${destination}`, { cause });
Comment thread
alii marked this conversation as resolved.
}

Comment thread
alii marked this conversation as resolved.
/**
* @param {MachineOptions} options
* @returns {Promise<Machine>}
Expand Down
2 changes: 1 addition & 1 deletion test/js/node/process/process.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,7 @@ it("process.versions", () => {
const expectedVersions = {
boringssl: "0c5fce43b7ed5eb6001487ee48ac65766f5ddcd1",
libarchive: "ded82291ab41d5e355831b96b0e1ff49e24d8939",
mimalloc: "9a5e1f52cdf4662f9590b69de104a4469140796f",
mimalloc: "a29368ef60d5c90bd760ff42a36ad4ad919a9ad7",
picohttpparser: "066d2b1e9ab820703db0837a7255d92d30f0c9f5",
zlib: "886098f3f339617b4243b286f5ed364b9989e245",
tinycc: "12882eee073cfe5c7621bcfadf679e1372d4537b",
Expand Down
Loading