mirror of
https://github.com/oven-sh/bun
synced 2026-02-14 12:51:54 +00:00
- Use D16 VMs (16 vCPU) for all Windows CI runners - Pin Node.js to 24.3.0 (ABI 137) for duckdb prebuilt + test compat - Wrap test runner in vs-shell.ps1 so node-gyp has cl.exe - Revert ccache persistent config (VMs are ephemeral) - Restore Uninstall-Windows-Defender (reboot clears pending state) - Add Windows ARM64 to install.ps1, install.sh, bun-release - Set parallelism to 2 for Windows tests - Clarify Packer vs CI runner VM sizes in comments [build images]
786 lines
25 KiB
JavaScript
786 lines
25 KiB
JavaScript
// Azure REST API client for machine.mjs
|
|
// Used by the [build images] pipeline to create Windows VM images (x64 and ARM64)
|
|
|
|
import { getSecret, isCI } from "./utils.mjs";
|
|
|
|
/**
|
|
* @typedef {Object} AzureConfig
|
|
* @property {string} tenantId
|
|
* @property {string} clientId
|
|
* @property {string} clientSecret
|
|
* @property {string} subscriptionId
|
|
* @property {string} resourceGroup
|
|
* @property {string} location
|
|
* @property {string} galleryName
|
|
*/
|
|
|
|
/** @returns {AzureConfig} */
|
|
function getConfig() {
|
|
const env = (name, fallback) => {
|
|
if (isCI) {
|
|
try {
|
|
return getSecret(name, { required: !fallback }) || fallback;
|
|
} catch {
|
|
if (fallback) return fallback;
|
|
throw new Error(`Azure secret not found: ${name}`);
|
|
}
|
|
}
|
|
return process.env[name] || fallback;
|
|
};
|
|
|
|
return {
|
|
tenantId: env("AZURE_TENANT_ID"),
|
|
clientId: env("AZURE_CLIENT_ID"),
|
|
clientSecret: env("AZURE_CLIENT_SECRET"),
|
|
subscriptionId: env("AZURE_SUBSCRIPTION_ID"),
|
|
resourceGroup: env("AZURE_RESOURCE_GROUP", "BUN-CI"),
|
|
location: env("AZURE_LOCATION", "eastus2"),
|
|
galleryName: env("AZURE_GALLERY_NAME", "bunCIGallery2"),
|
|
};
|
|
}
|
|
|
|
let _config;
|
|
function config() {
|
|
return (_config ??= getConfig());
|
|
}
|
|
|
|
// ============================================================================
|
|
// Authentication
|
|
// ============================================================================
|
|
|
|
let _accessToken = null;
|
|
let _tokenExpiry = 0;
|
|
|
|
async function getAccessToken() {
|
|
if (_accessToken && Date.now() < _tokenExpiry - 300_000) {
|
|
return _accessToken;
|
|
}
|
|
|
|
const { tenantId, clientId, clientSecret } = config();
|
|
const response = await fetch(`https://login.microsoftonline.com/${tenantId}/oauth2/v2.0/token`, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
body: new URLSearchParams({
|
|
grant_type: "client_credentials",
|
|
client_id: clientId,
|
|
client_secret: clientSecret,
|
|
scope: "https://management.azure.com/.default",
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`[azure] Auth failed: ${response.status} ${await response.text()}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
_accessToken = data.access_token;
|
|
_tokenExpiry = Date.now() + data.expires_in * 1000;
|
|
return _accessToken;
|
|
}
|
|
|
|
// ============================================================================
|
|
// REST Client
|
|
// ============================================================================
|
|
|
|
/**
|
|
* @param {"GET"|"PUT"|"POST"|"PATCH"|"DELETE"} method
|
|
* @param {string} path - Relative path under management.azure.com, or absolute URL
|
|
* @param {object} [body]
|
|
* @param {string} [apiVersion]
|
|
*/
|
|
async function azureFetch(method, path, body, apiVersion = "2024-07-01") {
|
|
const token = await getAccessToken();
|
|
|
|
const url = path.startsWith("http") ? new URL(path) : new URL(`https://management.azure.com${path}`);
|
|
|
|
if (!url.searchParams.has("api-version")) {
|
|
url.searchParams.set("api-version", apiVersion);
|
|
}
|
|
|
|
const options = {
|
|
method,
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
"Content-Type": "application/json",
|
|
},
|
|
};
|
|
|
|
if (body && method !== "GET" && method !== "DELETE") {
|
|
options.body = JSON.stringify(body);
|
|
}
|
|
|
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
const response = await fetch(url, options);
|
|
|
|
if (response.status === 429 || response.status >= 500) {
|
|
const wait = Math.pow(2, attempt) * 1000;
|
|
console.warn(`[azure] ${method} ${path} returned ${response.status}, retrying in ${wait}ms...`);
|
|
await new Promise(r => setTimeout(r, wait));
|
|
continue;
|
|
}
|
|
|
|
// 202 Accepted — async operation, poll for completion
|
|
if (response.status === 202) {
|
|
const operationUrl = response.headers.get("Azure-AsyncOperation") || response.headers.get("Location");
|
|
if (operationUrl) {
|
|
return waitForOperation(operationUrl);
|
|
}
|
|
}
|
|
|
|
if (response.status === 204) {
|
|
return null;
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const text = await response.text();
|
|
throw new Error(`[azure] ${method} ${path} failed: ${response.status} ${text}`);
|
|
}
|
|
|
|
const text = await response.text();
|
|
return text ? JSON.parse(text) : null;
|
|
}
|
|
|
|
throw new Error(`[azure] ${method} ${path} failed after 3 retries`);
|
|
}
|
|
|
|
async function waitForOperation(operationUrl, maxWaitMs = 3_600_000) {
|
|
const start = Date.now();
|
|
let fetchErrors = 0;
|
|
|
|
while (Date.now() - start < maxWaitMs) {
|
|
const token = await getAccessToken();
|
|
|
|
let response;
|
|
try {
|
|
response = await fetch(operationUrl, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
});
|
|
} catch (err) {
|
|
fetchErrors++;
|
|
if (fetchErrors > 10) {
|
|
throw new Error(`[azure] Operation poll failed after ${fetchErrors} fetch errors`, { cause: err });
|
|
}
|
|
console.warn(`[azure] Operation poll fetch error (${fetchErrors}), retrying...`);
|
|
await new Promise(r => setTimeout(r, 10_000));
|
|
continue;
|
|
}
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`[azure] Operation poll failed: ${response.status} ${await response.text()}`);
|
|
}
|
|
|
|
const data = await response.json();
|
|
|
|
if (data.status === "Succeeded") {
|
|
return data.properties?.output ?? data;
|
|
}
|
|
if (data.status === "Failed" || data.status === "Canceled") {
|
|
throw new Error(`[azure] Operation ${data.status}: ${data.error?.message ?? "unknown"}`);
|
|
}
|
|
|
|
await new Promise(r => setTimeout(r, 5000));
|
|
}
|
|
|
|
throw new Error(`[azure] Operation timed out after ${maxWaitMs}ms`);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Resource helpers
|
|
// ============================================================================
|
|
|
|
function rgPath() {
|
|
const { subscriptionId, resourceGroup } = config();
|
|
return `/subscriptions/${subscriptionId}/resourceGroups/${resourceGroup}`;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Public IP
|
|
// ============================================================================
|
|
|
|
async function createPublicIp(name) {
|
|
const { location } = config();
|
|
console.log(`[azure] Creating public IP: ${name}`);
|
|
const result = await azureFetch("PUT", `${rgPath()}/providers/Microsoft.Network/publicIPAddresses/${name}`, {
|
|
location,
|
|
sku: { name: "Standard" },
|
|
properties: {
|
|
publicIPAllocationMethod: "Static",
|
|
deleteOption: "Delete",
|
|
},
|
|
});
|
|
return result?.properties?.ipAddress;
|
|
}
|
|
|
|
async function deletePublicIp(name) {
|
|
await azureFetch("DELETE", `${rgPath()}/providers/Microsoft.Network/publicIPAddresses/${name}`).catch(() => {});
|
|
}
|
|
|
|
// ============================================================================
|
|
// Network Security Group
|
|
// ============================================================================
|
|
|
|
async function ensureNsg(name) {
|
|
const { location } = config();
|
|
const path = `${rgPath()}/providers/Microsoft.Network/networkSecurityGroups/${name}`;
|
|
|
|
try {
|
|
const existing = await azureFetch("GET", path);
|
|
if (existing) return path;
|
|
} catch {}
|
|
|
|
console.log(`[azure] Creating NSG: ${name}`);
|
|
await azureFetch("PUT", path, {
|
|
location,
|
|
properties: {
|
|
securityRules: [
|
|
{
|
|
name: "AllowSSH",
|
|
properties: {
|
|
priority: 100,
|
|
protocol: "Tcp",
|
|
access: "Allow",
|
|
direction: "Inbound",
|
|
sourceAddressPrefix: "*",
|
|
sourcePortRange: "*",
|
|
destinationAddressPrefix: "*",
|
|
destinationPortRange: "22",
|
|
},
|
|
},
|
|
{
|
|
name: "AllowRDP",
|
|
properties: {
|
|
priority: 110,
|
|
protocol: "Tcp",
|
|
access: "Allow",
|
|
direction: "Inbound",
|
|
sourceAddressPrefix: "*",
|
|
sourcePortRange: "*",
|
|
destinationAddressPrefix: "*",
|
|
destinationPortRange: "3389",
|
|
},
|
|
},
|
|
],
|
|
},
|
|
});
|
|
return path;
|
|
}
|
|
|
|
async function deleteNsg(name) {
|
|
await azureFetch("DELETE", `${rgPath()}/providers/Microsoft.Network/networkSecurityGroups/${name}`).catch(() => {});
|
|
}
|
|
|
|
// ============================================================================
|
|
// Network Interface
|
|
// ============================================================================
|
|
|
|
async function createNic(name, publicIpName, subnetId, nsgId) {
|
|
const { location } = config();
|
|
console.log(`[azure] Creating NIC: ${name}`);
|
|
const publicIpId = `${rgPath()}/providers/Microsoft.Network/publicIPAddresses/${publicIpName}`;
|
|
await azureFetch("PUT", `${rgPath()}/providers/Microsoft.Network/networkInterfaces/${name}`, {
|
|
location,
|
|
properties: {
|
|
ipConfigurations: [
|
|
{
|
|
name: "ipconfig1",
|
|
properties: {
|
|
privateIPAllocationMethod: "Dynamic",
|
|
publicIPAddress: { id: publicIpId, properties: { deleteOption: "Delete" } },
|
|
subnet: { id: subnetId },
|
|
},
|
|
},
|
|
],
|
|
...(nsgId ? { networkSecurityGroup: { id: nsgId } } : {}),
|
|
},
|
|
});
|
|
return `${rgPath()}/providers/Microsoft.Network/networkInterfaces/${name}`;
|
|
}
|
|
|
|
async function deleteNic(name) {
|
|
await azureFetch("DELETE", `${rgPath()}/providers/Microsoft.Network/networkInterfaces/${name}`).catch(() => {});
|
|
}
|
|
|
|
// ============================================================================
|
|
// Virtual Machines
|
|
// ============================================================================
|
|
|
|
/**
|
|
* @param {object} opts
|
|
* @param {string} opts.name
|
|
* @param {string} opts.vmSize
|
|
* @param {object} opts.imageReference
|
|
* @param {number} opts.osDiskSizeGB
|
|
* @param {string} opts.nicId
|
|
* @param {string} opts.adminUsername
|
|
* @param {string} opts.adminPassword
|
|
* @param {Record<string, string>} [opts.tags]
|
|
*/
|
|
async function createVm(opts) {
|
|
const { location } = config();
|
|
console.log(`[azure] Creating VM: ${opts.name} (${opts.vmSize})`);
|
|
const result = await azureFetch("PUT", `${rgPath()}/providers/Microsoft.Compute/virtualMachines/${opts.name}`, {
|
|
location,
|
|
tags: opts.tags,
|
|
properties: {
|
|
hardwareProfile: { vmSize: opts.vmSize },
|
|
storageProfile: {
|
|
imageReference: opts.imageReference,
|
|
osDisk: {
|
|
createOption: "FromImage",
|
|
diskSizeGB: opts.osDiskSizeGB,
|
|
deleteOption: "Delete",
|
|
managedDisk: { storageAccountType: "Premium_LRS" },
|
|
},
|
|
},
|
|
osProfile: {
|
|
computerName: opts.name.substring(0, 15),
|
|
adminUsername: opts.adminUsername,
|
|
adminPassword: opts.adminPassword,
|
|
},
|
|
securityProfile: {
|
|
securityType: "TrustedLaunch",
|
|
},
|
|
networkProfile: {
|
|
networkInterfaces: [{ id: opts.nicId, properties: { deleteOption: "Delete" } }],
|
|
},
|
|
},
|
|
});
|
|
return result;
|
|
}
|
|
|
|
async function getVm(name) {
|
|
try {
|
|
return await azureFetch(
|
|
"GET",
|
|
`${rgPath()}/providers/Microsoft.Compute/virtualMachines/${name}?$expand=instanceView`,
|
|
);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function getVmPowerState(name) {
|
|
const vm = await getVm(name);
|
|
const statuses = vm?.properties?.instanceView?.statuses ?? [];
|
|
const powerStatus = statuses.find(s => s.code?.startsWith("PowerState/"));
|
|
return powerStatus?.code;
|
|
}
|
|
|
|
async function stopVm(name) {
|
|
console.log(`[azure] Stopping VM: ${name}`);
|
|
await azureFetch("POST", `${rgPath()}/providers/Microsoft.Compute/virtualMachines/${name}/deallocate`);
|
|
}
|
|
|
|
async function generalizeVm(name) {
|
|
console.log(`[azure] Generalizing VM: ${name}`);
|
|
await azureFetch("POST", `${rgPath()}/providers/Microsoft.Compute/virtualMachines/${name}/generalize`);
|
|
}
|
|
|
|
async function deleteVm(name) {
|
|
console.log(`[azure] Deleting VM: ${name}`);
|
|
await azureFetch("DELETE", `${rgPath()}/providers/Microsoft.Compute/virtualMachines/${name}?forceDeletion=true`);
|
|
}
|
|
|
|
async function getPublicIpAddress(publicIpName) {
|
|
const result = await azureFetch("GET", `${rgPath()}/providers/Microsoft.Network/publicIPAddresses/${publicIpName}`);
|
|
return result?.properties?.ipAddress;
|
|
}
|
|
|
|
/**
|
|
* Run a PowerShell script on a Windows VM via Azure Run Command.
|
|
* This works even without SSH installed on the VM.
|
|
*/
|
|
async function runCommand(vmName, script) {
|
|
console.log(`[azure] Running command on VM: ${vmName}`);
|
|
return azureFetch("POST", `${rgPath()}/providers/Microsoft.Compute/virtualMachines/${vmName}/runCommand`, {
|
|
commandId: "RunPowerShellScript",
|
|
script: Array.isArray(script) ? script : [script],
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Install OpenSSH server and configure authorized keys on a Windows VM.
|
|
*/
|
|
// SSH is not used — all remote operations go through Azure Run Command API.
|
|
|
|
// ============================================================================
|
|
// Virtual Network
|
|
// ============================================================================
|
|
|
|
async function ensureVNet(vnetName, subnetName) {
|
|
const { location } = config();
|
|
const path = `${rgPath()}/providers/Microsoft.Network/virtualNetworks/${vnetName}`;
|
|
|
|
// Check if VNet exists
|
|
try {
|
|
const vnet = await azureFetch("GET", path);
|
|
if (vnet) {
|
|
const subnet = vnet.properties?.subnets?.find(s => s.name === subnetName);
|
|
if (subnet) return subnet.id;
|
|
}
|
|
} catch {}
|
|
|
|
console.log(`[azure] Creating VNet: ${vnetName} with subnet: ${subnetName}`);
|
|
await azureFetch("PUT", path, {
|
|
location,
|
|
properties: {
|
|
addressSpace: { addressPrefixes: ["10.0.0.0/16"] },
|
|
subnets: [
|
|
{
|
|
name: subnetName,
|
|
properties: { addressPrefix: "10.0.0.0/24" },
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
const vnet = await azureFetch("GET", path);
|
|
return vnet.properties.subnets.find(s => s.name === subnetName).id;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Compute Gallery
|
|
// ============================================================================
|
|
|
|
const GALLERY_API_VERSION = "2024-03-03";
|
|
|
|
async function ensureGallery() {
|
|
const { location, galleryName } = config();
|
|
const path = `${rgPath()}/providers/Microsoft.Compute/galleries/${galleryName}`;
|
|
|
|
try {
|
|
const gallery = await azureFetch("GET", path, undefined, GALLERY_API_VERSION);
|
|
if (gallery) return;
|
|
} catch {}
|
|
|
|
console.log(`[azure] Creating gallery: ${galleryName}`);
|
|
await azureFetch("PUT", path, { location }, GALLERY_API_VERSION);
|
|
}
|
|
|
|
async function ensureImageDefinition(name, os, arch) {
|
|
const { location, galleryName } = config();
|
|
const path = `${rgPath()}/providers/Microsoft.Compute/galleries/${galleryName}/images/${name}`;
|
|
|
|
try {
|
|
const def = await azureFetch("GET", path, undefined, GALLERY_API_VERSION);
|
|
if (def) return;
|
|
} catch {}
|
|
|
|
console.log(`[azure] Creating image definition: ${name}`);
|
|
await azureFetch(
|
|
"PUT",
|
|
path,
|
|
{
|
|
location,
|
|
properties: {
|
|
osType: os === "windows" ? "Windows" : "Linux",
|
|
osState: "Generalized",
|
|
hyperVGeneration: "V2",
|
|
architecture: arch === "aarch64" ? "Arm64" : "x64",
|
|
identifier: {
|
|
publisher: "bun",
|
|
offer: `${os}-${arch}-ci`,
|
|
sku: name,
|
|
},
|
|
features: [
|
|
{ name: "DiskControllerTypes", value: "SCSI, NVMe" },
|
|
{ name: "SecurityType", value: "TrustedLaunch" },
|
|
],
|
|
},
|
|
},
|
|
GALLERY_API_VERSION,
|
|
);
|
|
}
|
|
|
|
async function createImageVersion(imageDefName, version, vmId) {
|
|
const { location, galleryName } = config();
|
|
const path = `${rgPath()}/providers/Microsoft.Compute/galleries/${galleryName}/images/${imageDefName}/versions/${version}`;
|
|
|
|
console.log(`[azure] Creating image version: ${imageDefName}/${version}`);
|
|
const result = await azureFetch(
|
|
"PUT",
|
|
path,
|
|
{
|
|
location,
|
|
properties: {
|
|
storageProfile: {
|
|
source: { virtualMachineId: vmId },
|
|
},
|
|
},
|
|
},
|
|
GALLERY_API_VERSION,
|
|
);
|
|
return result;
|
|
}
|
|
|
|
async function createImageVersionWithLabel(imageDefName, version, vmId, label) {
|
|
const { location, galleryName } = config();
|
|
const path = `${rgPath()}/providers/Microsoft.Compute/galleries/${galleryName}/images/${imageDefName}/versions/${version}`;
|
|
|
|
console.log(`[azure] Creating image version: ${imageDefName}/${version} (label: ${label})`);
|
|
const result = await azureFetch(
|
|
"PUT",
|
|
path,
|
|
{
|
|
location,
|
|
tags: { "image-name": label },
|
|
properties: {
|
|
storageProfile: {
|
|
source: { virtualMachineId: vmId },
|
|
},
|
|
},
|
|
},
|
|
GALLERY_API_VERSION,
|
|
);
|
|
return result;
|
|
}
|
|
|
|
// ============================================================================
|
|
// Base Images
|
|
// ============================================================================
|
|
|
|
function getBaseImageReference(os, arch) {
|
|
if (os === "windows") {
|
|
if (arch === "aarch64") {
|
|
return {
|
|
publisher: "MicrosoftWindowsDesktop",
|
|
offer: "windows11preview-arm64",
|
|
sku: "win11-24h2-pro",
|
|
version: "latest",
|
|
};
|
|
}
|
|
// Windows Server 2019 x64 — oldest supported version
|
|
return {
|
|
publisher: "MicrosoftWindowsServer",
|
|
offer: "WindowsServer",
|
|
sku: "2019-datacenter-gensecond",
|
|
version: "latest",
|
|
};
|
|
}
|
|
throw new Error(`[azure] Unsupported OS: ${os}`);
|
|
}
|
|
|
|
function getVmSize(arch) {
|
|
return arch === "aarch64" ? "Standard_D4ps_v6" : "Standard_D4ds_v6";
|
|
}
|
|
|
|
// ============================================================================
|
|
// Exports
|
|
// ============================================================================
|
|
|
|
export const azure = {
|
|
get name() {
|
|
return "azure";
|
|
},
|
|
|
|
config,
|
|
|
|
/**
|
|
* @param {import("./machine.mjs").MachineOptions} options
|
|
* @returns {Promise<import("./machine.mjs").Machine>}
|
|
*/
|
|
async createMachine(options) {
|
|
const { os, arch, tags, sshKeys } = options;
|
|
const vmName = `bun-${os}-${arch}-${Date.now()}`;
|
|
const publicIpName = `${vmName}-ip`;
|
|
const nicName = `${vmName}-nic`;
|
|
const vmSize = options.instanceType || getVmSize(arch);
|
|
const diskSizeGB = options.diskSizeGb || (os === "windows" ? 150 : 40);
|
|
|
|
// Generate a random password for the admin account
|
|
const adminPassword = `P@${crypto.randomUUID().replace(/-/g, "").substring(0, 20)}!`;
|
|
|
|
// Ensure VNet exists
|
|
const subnetId = await ensureVNet("bun-ci-vnet", "default");
|
|
|
|
// Create public IP (needed for outbound internet during bootstrap)
|
|
await createPublicIp(publicIpName);
|
|
|
|
// Create NIC (no NSG needed — all operations go through Azure Run Command, not SSH)
|
|
const nicId = await createNic(nicName, publicIpName, subnetId, null);
|
|
|
|
// Create VM
|
|
const imageReference = options.imageId ? { id: options.imageId } : getBaseImageReference(os, arch);
|
|
|
|
await createVm({
|
|
name: vmName,
|
|
vmSize,
|
|
imageReference,
|
|
osDiskSizeGB: diskSizeGB,
|
|
nicId,
|
|
adminUsername: "bunadmin",
|
|
adminPassword,
|
|
tags: tags
|
|
? Object.fromEntries(
|
|
Object.entries(tags)
|
|
.filter(([_, v]) => v != null)
|
|
.map(([k, v]) => [k, String(v)]),
|
|
)
|
|
: undefined,
|
|
});
|
|
|
|
// Wait for public IP to be assigned
|
|
let publicIp;
|
|
for (let i = 0; i < 30; i++) {
|
|
publicIp = await getPublicIpAddress(publicIpName);
|
|
if (publicIp) break;
|
|
await new Promise(r => setTimeout(r, 5000));
|
|
}
|
|
|
|
if (!publicIp) {
|
|
throw new Error(`[azure] Failed to get public IP for ${vmName}`);
|
|
}
|
|
|
|
console.log(`[azure] VM created: ${vmName} at ${publicIp}`);
|
|
|
|
// Use Azure Run Command for all remote operations instead of SSH.
|
|
// This avoids the sshd startup issues on Azure Windows VMs.
|
|
|
|
const spawnFn = async (command, opts) => {
|
|
const script = command.join(" ");
|
|
console.log(`[azure] Run: ${script}`);
|
|
// Note: Azure Run Command output is limited to the last 4096 bytes.
|
|
// Full output is not available — only the tail is returned.
|
|
// value[0] = stdout (ComponentStatus/StdOut), value[1] = stderr (ComponentStatus/StdErr)
|
|
const result = await runCommand(vmName, [script]);
|
|
const values = result?.value ?? [];
|
|
const stdout = values[0]?.message ?? "";
|
|
const stderr = values[1]?.message ?? "";
|
|
if (opts?.stdio === "inherit") {
|
|
if (stdout) process.stdout.write(stdout);
|
|
if (stderr) process.stderr.write(stderr);
|
|
}
|
|
// Only use displayStatus to detect errors — stderr often contains non-error
|
|
// output (rustup progress, cargo warnings, PowerShell Write-Warning, etc.)
|
|
const hasError = values.some(v => v?.displayStatus === "Provisioning failed");
|
|
const exitCode = hasError ? 1 : 0;
|
|
return { exitCode, stdout, stderr };
|
|
};
|
|
|
|
const spawnSafeFn = async (command, opts) => {
|
|
const result = await spawnFn(command, opts);
|
|
if (result.exitCode !== 0) {
|
|
const msg = result.stderr || result.stdout || "Unknown error";
|
|
throw new Error(`[azure] Command failed (exit ${result.exitCode}): ${command.join(" ")}\n${msg}`);
|
|
}
|
|
return result;
|
|
};
|
|
const upload = async (source, destination) => {
|
|
// Read the file locally and write it on the VM via Run Command
|
|
const { readFileSync } = await import("node:fs");
|
|
const content = readFileSync(source, "utf-8");
|
|
// Escape for PowerShell — use base64 to avoid escaping issues
|
|
const b64 = Buffer.from(content).toString("base64");
|
|
const script = [
|
|
`$bytes = [Convert]::FromBase64String('${b64}')`,
|
|
`$dir = Split-Path '${destination}' -Parent`,
|
|
`if (-not (Test-Path $dir)) { New-Item -Path $dir -ItemType Directory -Force | Out-Null }`,
|
|
`[IO.File]::WriteAllBytes('${destination}', $bytes)`,
|
|
`Write-Host "Uploaded to ${destination} ($($bytes.Length) bytes)"`,
|
|
];
|
|
console.log(`[azure] Uploading ${source} -> ${destination}`);
|
|
await runCommand(vmName, script);
|
|
};
|
|
|
|
const attach = async () => {
|
|
console.log(`[azure] Attach not supported via Run Command (VM: ${vmName}, IP: ${publicIp})`);
|
|
};
|
|
|
|
const waitForSsh = async () => {
|
|
// No SSH needed — Run Command works immediately after VM is provisioned
|
|
// Just verify the VM is responsive
|
|
console.log(`[azure] Verifying VM is responsive...`);
|
|
await runCommand(vmName, ["Write-Host 'VM is ready'"]);
|
|
console.log(`[azure] VM is responsive`);
|
|
};
|
|
|
|
const snapshot = async label => {
|
|
const vmId = `${rgPath()}/providers/Microsoft.Compute/virtualMachines/${vmName}`;
|
|
|
|
// Run sysprep inside the VM before deallocating.
|
|
// This prepares Windows for generalization so the gallery image
|
|
// can be used to create new VMs with OS provisioning.
|
|
console.log(`[azure] Running sysprep on ${vmName}...`);
|
|
await runCommand(vmName, ["C:\\Windows\\System32\\Sysprep\\sysprep.exe /generalize /oobe /shutdown /quiet"]);
|
|
|
|
// Wait for VM to shut down after sysprep (sysprep triggers shutdown)
|
|
for (let i = 0; i < 60; i++) {
|
|
const state = await getVmPowerState(vmName);
|
|
if (state === "PowerState/stopped" || state === "PowerState/deallocated") break;
|
|
await new Promise(r => setTimeout(r, 10000));
|
|
}
|
|
|
|
// Deallocate the VM
|
|
await stopVm(vmName);
|
|
// Wait for VM to be deallocated
|
|
for (let i = 0; i < 60; i++) {
|
|
const state = await getVmPowerState(vmName);
|
|
if (state === "PowerState/deallocated") break;
|
|
await new Promise(r => setTimeout(r, 5000));
|
|
}
|
|
|
|
await generalizeVm(vmName);
|
|
|
|
// Ensure gallery and image definition exist.
|
|
// Use the label as the image definition name — this matches what ci.mjs
|
|
// emits as the image-name agent tag, so robobun can look it up directly.
|
|
await ensureGallery();
|
|
const imageDefName = label;
|
|
await ensureImageDefinition(imageDefName, os, arch);
|
|
|
|
// Create a single version "1.0.0" under this definition.
|
|
await createImageVersion(imageDefName, "1.0.0", vmId);
|
|
|
|
// Wait for image replication to complete before returning.
|
|
// Single-region replication typically takes 5-15 minutes.
|
|
const { galleryName } = config();
|
|
const versionPath = `${rgPath()}/providers/Microsoft.Compute/galleries/${galleryName}/images/${imageDefName}/versions/1.0.0`;
|
|
console.log(`[azure] Waiting for image replication...`);
|
|
for (let i = 0; i < 120; i++) {
|
|
const ver = await azureFetch("GET", versionPath, undefined, GALLERY_API_VERSION);
|
|
const state = ver?.properties?.provisioningState;
|
|
if (state === "Succeeded") {
|
|
console.log(`[azure] Image ready: ${imageDefName}/1.0.0`);
|
|
break;
|
|
}
|
|
if (state === "Failed") {
|
|
throw new Error(`[azure] Image replication failed: ${JSON.stringify(ver?.properties)}`);
|
|
}
|
|
if (i % 6 === 0) {
|
|
console.log(`[azure] Image replicating... (${i}m elapsed)`);
|
|
}
|
|
await new Promise(r => setTimeout(r, 10_000));
|
|
}
|
|
|
|
return label;
|
|
};
|
|
|
|
const terminate = async () => {
|
|
await deleteVm(vmName);
|
|
// Resources with deleteOption=Delete are cleaned up automatically
|
|
// But clean up anything that might be left
|
|
await deleteNic(nicName);
|
|
await deletePublicIp(publicIpName);
|
|
};
|
|
|
|
return {
|
|
cloud: "azure",
|
|
id: vmName,
|
|
imageId: options.imageId,
|
|
instanceType: vmSize,
|
|
region: config().location,
|
|
get publicIp() {
|
|
return publicIp;
|
|
},
|
|
spawn: spawnFn,
|
|
spawnSafe: spawnSafeFn,
|
|
upload,
|
|
attach,
|
|
snapshot,
|
|
waitForSsh,
|
|
close: terminate,
|
|
[Symbol.asyncDispose]: terminate,
|
|
};
|
|
},
|
|
};
|