Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
62 changes: 2 additions & 60 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
40 changes: 30 additions & 10 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 @@ -184,8 +188,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 +212,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 Down Expand Up @@ -260,9 +272,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 +295,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