Skip to content
Open
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
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 @@
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 @@
* @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 @@
* @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

Check warning on line 1043 in scripts/machine.mjs

View check run for this annotation

Claude / Claude Code Review

Snapshot typedef optional marker incorrect — tart always throws on undefined label

The Machine typedef's `snapshot` property was updated to `(label?: string) => Promise<string>`, but the `?` optional marker is incorrect for tart's implementation. Tart immediately throws if `label` is falsy (`if (\!label) throw new Error("tart snapshot() requires a label")`), unlike AWS which gracefully falls back to a timestamped name. The correct signature is `(label: string) => Promise<string>` — no `?`.
Comment thread
robobun marked this conversation as resolved.
* @property {() => Promise<void>} close
*/

Expand Down Expand Up @@ -1484,7 +1426,7 @@
}

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
53 changes: 40 additions & 13 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 @@ export const tart = {
},

/**
* @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 @@ export const tart = {
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 @@ export const tart = {
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 @@ export const tart = {
}

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 @@ export const tart = {
);
}

// 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 @@ export const tart = {
*/
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 All @@ -222,7 +236,12 @@ export const tart = {
"vnc-experimental": rdp,
});

return this.toMachine(machineId);
return {
...this.toMachine(machineId),
imageId: image,
instanceType: cpuCount || memoryGb ? `${cpuCount ?? "?"}cpu-${memoryGb ?? "?"}gb` : "default",
region: "local",
};
},

/**
Expand Down Expand Up @@ -260,9 +279,16 @@ export const tart = {
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 @@ -276,6 +302,7 @@ export const tart = {
spawnSafe: execSafe,
attach,
upload,
snapshot,
close,
[Symbol.asyncDispose]: close,
};
Comment thread
robobun marked this conversation as resolved.
Expand Down
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
Loading