Merge pull request #99 from useblacksmith/scaffold-multi-platform

src: add scaffolding for support multi-platform builds
This commit is contained in:
Aditya Maru 2025-02-16 23:16:43 -05:00 committed by GitHub
commit 1def72df18
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 116 additions and 103 deletions

2
dist/index.js generated vendored

File diff suppressed because one or more lines are too long

2
dist/index.js.map generated vendored

File diff suppressed because one or more lines are too long

View File

@ -9,16 +9,10 @@ const config: Config.InitialOptions = {
'^.+\\.ts$': 'ts-jest',
'^.+\\.js$': 'babel-jest'
},
transformIgnorePatterns: [
'/node_modules/(?!(@buf|@connectrpc)/)'
],
transformIgnorePatterns: ['/node_modules/(?!(@buf|@connectrpc)/)'],
verbose: true,
collectCoverage: true,
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.d.ts',
'!src/**/__tests__/**'
]
collectCoverageFrom: ['src/**/*.ts', '!src/**/*.d.ts', '!src/**/__tests__/**']
};
export default config;

12
package-lock.json generated
View File

@ -8,7 +8,7 @@
"license": "Apache-2.0",
"dependencies": {
"@actions/core": "^1.10.1",
"@buf/blacksmith_vm-agent.connectrpc_es": "^1.6.1-20250209182455-7d83cfb8ddb1.2",
"@buf/blacksmith_vm-agent.connectrpc_es": "^1.6.1-20250211212423-70f4f1344c53.2",
"@connectrpc/connect": "^1.6.1",
"@connectrpc/connect-node": "^1.6.1",
"@docker/actions-toolkit": "0.37.1",
@ -2304,8 +2304,8 @@
"dev": true
},
"node_modules/@buf/blacksmith_vm-agent.bufbuild_es": {
"version": "1.10.0-20250209182455-7d83cfb8ddb1.1",
"resolved": "https://buf.build/gen/npm/v1/@buf/blacksmith_vm-agent.bufbuild_es/-/blacksmith_vm-agent.bufbuild_es-1.10.0-20250209182455-7d83cfb8ddb1.1.tgz",
"version": "1.10.0-20250211212423-70f4f1344c53.1",
"resolved": "https://buf.build/gen/npm/v1/@buf/blacksmith_vm-agent.bufbuild_es/-/blacksmith_vm-agent.bufbuild_es-1.10.0-20250211212423-70f4f1344c53.1.tgz",
"dependencies": {
"@buf/googleapis_googleapis.bufbuild_es": "1.10.0-20250203201857-83c0f6c19b2f.1"
},
@ -2314,10 +2314,10 @@
}
},
"node_modules/@buf/blacksmith_vm-agent.connectrpc_es": {
"version": "1.6.1-20250209182455-7d83cfb8ddb1.2",
"resolved": "https://buf.build/gen/npm/v1/@buf/blacksmith_vm-agent.connectrpc_es/-/blacksmith_vm-agent.connectrpc_es-1.6.1-20250209182455-7d83cfb8ddb1.2.tgz",
"version": "1.6.1-20250211212423-70f4f1344c53.2",
"resolved": "https://buf.build/gen/npm/v1/@buf/blacksmith_vm-agent.connectrpc_es/-/blacksmith_vm-agent.connectrpc_es-1.6.1-20250211212423-70f4f1344c53.2.tgz",
"dependencies": {
"@buf/blacksmith_vm-agent.bufbuild_es": "1.10.0-20250209182455-7d83cfb8ddb1.1",
"@buf/blacksmith_vm-agent.bufbuild_es": "1.10.0-20250211212423-70f4f1344c53.1",
"@buf/googleapis_googleapis.connectrpc_es": "1.6.1-20250203201857-83c0f6c19b2f.2"
},
"peerDependencies": {

View File

@ -27,7 +27,7 @@
"packageManager": "yarn@3.6.3",
"dependencies": {
"@actions/core": "^1.10.1",
"@buf/blacksmith_vm-agent.connectrpc_es": "^1.6.1-20250209182455-7d83cfb8ddb1.2",
"@buf/blacksmith_vm-agent.connectrpc_es": "^1.6.1-20250211212423-70f4f1344c53.2",
"@connectrpc/connect": "^1.6.1",
"@connectrpc/connect-node": "^1.6.1",
"@docker/actions-toolkit": "0.37.1",

View File

@ -3,7 +3,7 @@ import * as main from '../main';
import * as reporter from '../reporter';
import {getDockerfilePath} from '../context';
import * as setupBuilder from '../setup_builder';
import { Metric_MetricType } from "@buf/blacksmith_vm-agent.bufbuild_es/stickydisk/v1/stickydisk_pb";
import {Metric_MetricType} from '@buf/blacksmith_vm-agent.bufbuild_es/stickydisk/v1/stickydisk_pb';
jest.mock('@actions/core', () => ({
debug: jest.fn(),
@ -34,7 +34,9 @@ jest.mock('../setup_builder', () => ({
...jest.requireActual('../setup_builder'),
startAndConfigureBuildkitd: jest.fn(),
setupStickyDisk: jest.fn(),
getNumCPUs: jest.fn().mockResolvedValue(4)
getNumCPUs: jest.fn().mockResolvedValue(4),
leaveTailnet: jest.fn().mockResolvedValue(undefined),
getTailscaleIP: jest.fn()
}));
describe('startBlacksmithBuilder', () => {
@ -42,7 +44,7 @@ describe('startBlacksmithBuilder', () => {
beforeEach(() => {
jest.clearAllMocks();
mockInputs = {nofallback: false};
mockInputs = {nofallback: false, platforms: []};
});
test('should handle missing dockerfile path with nofallback=false', async () => {
@ -110,7 +112,7 @@ describe('startBlacksmithBuilder', () => {
buildId: mockBuildId,
exposeId: mockExposeId
});
expect(setupBuilder.startAndConfigureBuildkitd).toHaveBeenCalledWith(mockParallelism);
expect(setupBuilder.startAndConfigureBuildkitd).toHaveBeenCalledWith(mockParallelism, []);
expect(reporter.reportBuildPushActionFailure).not.toHaveBeenCalled();
});

View File

@ -20,37 +20,13 @@ import * as context from './context';
import {promisify} from 'util';
import {exec} from 'child_process';
import * as reporter from './reporter';
import {setupStickyDisk, startAndConfigureBuildkitd, getNumCPUs} from './setup_builder';
import {setupStickyDisk, startAndConfigureBuildkitd, getNumCPUs, leaveTailnet} from './setup_builder';
import {Metric_MetricType} from '@buf/blacksmith_vm-agent.bufbuild_es/stickydisk/v1/stickydisk_pb';
const buildxVersion = 'v0.17.0';
const mountPoint = '/var/lib/buildkit';
const execAsync = promisify(exec);
async function joinTailnet(): Promise<void> {
const token = process.env.BLACKSMITH_TAILSCALE_TOKEN;
if (!token || token === 'unset') {
core.debug('BLACKSMITH_TAILSCALE_TOKEN environment variable not set, skipping tailnet join');
return;
}
try {
await execAsync(`sudo tailscale up --authkey=${token} --hostname=${process.env.VM_ID}`);
core.info('Successfully joined tailnet');
} catch (error) {
throw new Error(`Failed to join tailnet: ${error.message}`);
}
}
async function leaveTailnet(): Promise<void> {
try {
await execAsync('sudo tailscale down');
} catch (error) {
core.warning(`Error leaving tailnet: ${error.message}`);
}
}
async function setupBuildx(version: string, toolkit: Toolkit): Promise<void> {
let toolPath;
const standalone = await toolkit.buildx.isStandalone();
@ -94,8 +70,6 @@ async function setupBuildx(version: string, toolkit: Toolkit): Promise<void> {
*/
export async function startBlacksmithBuilder(inputs: context.Inputs): Promise<{addr: string | null; buildId: string | null; exposeId: string}> {
try {
await joinTailnet();
const dockerfilePath = context.getDockerfilePath(inputs);
if (!dockerfilePath) {
throw new Error('Failed to resolve dockerfile path');
@ -107,7 +81,7 @@ export async function startBlacksmithBuilder(inputs: context.Inputs): Promise<{a
const parallelism = await getNumCPUs();
const buildkitdStartTime = Date.now();
const buildkitdAddr = await startAndConfigureBuildkitd(parallelism);
const buildkitdAddr = await startAndConfigureBuildkitd(parallelism, inputs.platforms);
const buildkitdDurationMs = Date.now() - buildkitdStartTime;
await reporter.reportMetric(Metric_MetricType.BPA_BUILDKITD_READY_DURATION_MS, buildkitdDurationMs);
@ -128,6 +102,8 @@ export async function startBlacksmithBuilder(inputs: context.Inputs): Promise<{a
core.warning(`${errorMessage}. Falling back to a local build.`);
return {addr: null, buildId: null, exposeId: ''};
} finally {
await leaveTailnet();
}
}

View File

@ -1,22 +1,18 @@
import * as core from '@actions/core';
import axios, {AxiosError, AxiosInstance, AxiosResponse } from 'axios';
import axios, {AxiosError, AxiosInstance, AxiosResponse} from 'axios';
import axiosRetry from 'axios-retry';
import {ExportRecordResponse} from '@docker/actions-toolkit/lib/types/buildx/history';
import FormData from 'form-data';
import { createClient } from "@connectrpc/connect";
import { createGrpcTransport } from "@connectrpc/connect-node";
import { StickyDiskService } from "@buf/blacksmith_vm-agent.connectrpc_es/stickydisk/v1/stickydisk_connect";
import { Metric, Metric_MetricType } from "@buf/blacksmith_vm-agent.bufbuild_es/stickydisk/v1/stickydisk_pb";
import {createClient} from '@connectrpc/connect';
import {createGrpcTransport} from '@connectrpc/connect-node';
import {StickyDiskService} from '@buf/blacksmith_vm-agent.connectrpc_es/stickydisk/v1/stickydisk_connect';
import {Metric, Metric_MetricType} from '@buf/blacksmith_vm-agent.bufbuild_es/stickydisk/v1/stickydisk_pb';
// Configure base axios instance for Blacksmith API.
const createBlacksmithAPIClient = () => {
const apiUrl = process.env.BLACKSMITH_BACKEND_URL || (
process.env.BLACKSMITH_ENV?.includes('staging')
? 'https://stagingapi.blacksmith.sh'
: 'https://api.blacksmith.sh'
);
const apiUrl = process.env.BLACKSMITH_BACKEND_URL || (process.env.BLACKSMITH_ENV?.includes('staging') ? 'https://stagingapi.blacksmith.sh' : 'https://api.blacksmith.sh');
core.debug(`Using Blacksmith API URL: ${apiUrl}`);
const client = axios.create({
baseURL: apiUrl,
headers: {
@ -30,8 +26,7 @@ const createBlacksmithAPIClient = () => {
retries: 5,
retryDelay: axiosRetry.exponentialDelay,
retryCondition: (error: AxiosError) => {
return axiosRetry.isNetworkOrIdempotentRequestError(error) ||
(error.response?.status ? error.response.status >= 500 : false);
return axiosRetry.isNetworkOrIdempotentRequestError(error) || (error.response?.status ? error.response.status >= 500 : false);
}
});
@ -41,7 +36,7 @@ const createBlacksmithAPIClient = () => {
export function createBlacksmithAgentClient() {
const transport = createGrpcTransport({
baseUrl: 'http://192.168.127.1:5557',
httpVersion: '2',
httpVersion: '2'
});
return createClient(StickyDiskService, transport);
@ -58,7 +53,7 @@ export async function reportBuildPushActionFailure(error?: Error, event?: string
message: event ? `${event}: ${error?.message || ''}` : error?.message || '',
warning: isWarning || false
};
const client = createBlacksmithAPIClient();
const response = await client.post('/stickydisks/report-failed', requestOptions);
return response.data;
@ -72,7 +67,7 @@ export async function reportBuildCompleted(exportRes?: ExportRecordResponse, bla
try {
const agentClient = createBlacksmithAgentClient();
await agentClient.commitStickyDisk({
exposeId: exposeId || '',
stickyDiskKey: process.env.GITHUB_REPO_NAME || '',
@ -175,22 +170,19 @@ export async function post(client: AxiosInstance, url: string, formData: FormDat
return await client.post(url, formData, {
headers: {
...client.defaults.headers.common,
...(formData && { 'Content-Type': 'multipart/form-data' }),
...(formData && {'Content-Type': 'multipart/form-data'})
},
signal: options?.signal
});
}
export async function reportMetric(
metricType: Metric_MetricType,
value: number
): Promise<void> {
export async function reportMetric(metricType: Metric_MetricType, value: number): Promise<void> {
try {
const agentClient = createBlacksmithAgentClient();
const metric = new Metric({
type: metricType,
value: { case: "intValue", value: BigInt(value) }
value: {case: 'intValue', value: BigInt(value)}
});
await agentClient.reportMetric({
@ -202,4 +194,4 @@ export async function reportMetric(
// We can enable this once all agents are updated to support metrics.
// core.warning('Error reporting metric to BlacksmithAgent:', error);
}
}
}

View File

@ -10,6 +10,16 @@ const BUILDKIT_DAEMON_ADDR = 'tcp://127.0.0.1:1234';
const mountPoint = '/var/lib/buildkit';
const execAsync = promisify(exec);
export async function getTailscaleIP(): Promise<string | null> {
try {
const {stdout} = await execAsync('tailscale ip -4');
return stdout.trim();
} catch (error) {
core.debug(`Error getting tailscale IP: ${error.message}`);
return null;
}
}
async function maybeFormatBlockDevice(device: string): Promise<string> {
try {
// Check if device is formatted with ext4
@ -52,11 +62,11 @@ export async function getNumCPUs(): Promise<number> {
}
}
async function writeBuildkitdTomlFile(parallelism: number): Promise<void> {
async function writeBuildkitdTomlFile(parallelism: number, addr: string): Promise<void> {
const jsonConfig: TOML.JsonMap = {
root: '/var/lib/buildkit',
grpc: {
address: [BUILDKIT_DAEMON_ADDR]
address: [addr]
},
registry: {
'docker.io': {
@ -95,13 +105,12 @@ async function writeBuildkitdTomlFile(parallelism: number): Promise<void> {
}
}
async function startBuildkitd(parallelism: number): Promise<string> {
export async function startBuildkitd(parallelism: number, addr: string): Promise<string> {
try {
await writeBuildkitdTomlFile(parallelism);
const addr = BUILDKIT_DAEMON_ADDR;
await writeBuildkitdTomlFile(parallelism, addr);
const logStream = fs.createWriteStream('buildkitd.log');
const buildkitd = spawn('sudo', ['buildkitd', '--debug', '--addr', addr, '--allow-insecure-entitlement', 'security.insecure', '--config=buildkitd.toml', '--allow-insecure-entitlement', 'network.host'], {
const buildkitd = spawn('sudo', ['buildkitd', '--debug', '--config=buildkitd.toml', '--allow-insecure-entitlement', 'security.insecure', '--allow-insecure-entitlement', 'network.host'], {
stdio: ['ignore', 'pipe', 'pipe']
});
@ -138,20 +147,6 @@ async function startBuildkitd(parallelism: number): Promise<string> {
}
}
async function getDiskSize(device: string): Promise<number> {
try {
const {stdout} = await execAsync(`sudo lsblk -b -n -o SIZE ${device}`);
const sizeInBytes = parseInt(stdout.trim(), 10);
if (isNaN(sizeInBytes)) {
throw new Error('Failed to parse disk size');
}
return sizeInBytes;
} catch (error) {
console.error(`Error getting disk size: ${error.message}`);
throw error;
}
}
export async function getStickyDisk(options?: {signal?: AbortSignal}): Promise<{expose_id: string; device: string}> {
const client = await reporter.createBlacksmithAgentClient();
@ -181,14 +176,65 @@ export async function getStickyDisk(options?: {signal?: AbortSignal}): Promise<{
};
}
export async function joinTailnet(): Promise<void> {
const token = process.env.BLACKSMITH_TAILSCALE_TOKEN;
if (!token || token === 'unset') {
core.debug('BLACKSMITH_TAILSCALE_TOKEN environment variable not set, skipping tailnet join');
return;
}
try {
await execAsync(`sudo tailscale up --authkey=${token} --hostname=${process.env.VM_ID}`);
core.info('Successfully joined tailnet');
} catch (error) {
throw new Error(`Failed to join tailnet: ${error.message}`);
}
}
export async function leaveTailnet(): Promise<void> {
try {
// Check if we're part of a tailnet before trying to leave
const {stdout} = await execAsync('sudo tailscale status');
if (stdout.trim() !== '') {
await execAsync('sudo tailscale down');
core.debug('Successfully left tailnet');
} else {
core.debug('Not part of a tailnet, skipping leave');
}
} catch (error) {
core.warning(`Error leaving tailnet: ${error.message}`);
}
}
// buildkitdTimeoutMs states the max amount of time this action will wait for the buildkitd
// daemon to start have its socket ready. It also additionally governs how long we will wait for
// the buildkitd workers to be ready.
const buildkitdTimeoutMs = 30000;
export async function startAndConfigureBuildkitd(parallelism: number): Promise<string> {
const buildkitdAddr = await startBuildkitd(parallelism);
core.debug(`buildkitd daemon started at addr ${buildkitdAddr}`);
export async function startAndConfigureBuildkitd(parallelism: number, platforms?: string[]): Promise<string> {
// For multi-platform builds, we need to use the tailscale IP
let buildkitdAddr = BUILDKIT_DAEMON_ADDR;
// If we are doing a multi-platform build, we need to join the tailnet and bind buildkitd to the tailscale IP.
// We do this so that the remote VM can join the same buildkitd cluster as a worker.
if (platforms && platforms.length > 1) {
await joinTailnet();
const tailscaleIP = await getTailscaleIP();
if (!tailscaleIP) {
throw new Error('Failed to get tailscale IP for multi-platform build');
}
buildkitdAddr = `tcp://${tailscaleIP}:1234`;
core.info(`Using tailscale IP for multi-platform build: ${buildkitdAddr}`);
}
const addr = await startBuildkitd(parallelism, buildkitdAddr);
core.debug(`buildkitd daemon started at addr ${addr}`);
if (platforms && platforms.length > 1) {
// TODO(adityamaru): Queue docker job for multi-platform build with a well known tailscale hostname.
// TODO(adityamaru): Wait until the VM joins the tailnet.
}
// Check that buildkit instance is ready by querying workers for up to 30s
const startTimeBuildkitReady = Date.now();
@ -196,10 +242,12 @@ export async function startAndConfigureBuildkitd(parallelism: number): Promise<s
while (Date.now() - startTimeBuildkitReady < timeoutBuildkitReady) {
try {
const {stdout} = await execAsync(`sudo buildctl --addr ${BUILDKIT_DAEMON_ADDR} debug workers`);
const {stdout} = await execAsync(`sudo buildctl --addr ${addr} debug workers`);
const lines = stdout.trim().split('\n');
if (lines.length > 1) {
// Check if we have output lines beyond the header
// For multi-platform builds, we need at least 2 workers
const requiredWorkers = platforms && platforms.length > 1 ? 2 : 1;
if (lines.length > requiredWorkers) {
core.info(`Found ${lines.length - 1} workers, required ${requiredWorkers}`);
break;
}
} catch (error) {
@ -210,10 +258,11 @@ export async function startAndConfigureBuildkitd(parallelism: number): Promise<s
// Final check after timeout.
try {
const {stdout} = await execAsync(`sudo buildctl --addr ${BUILDKIT_DAEMON_ADDR} debug workers`);
const {stdout} = await execAsync(`sudo buildctl --addr ${addr} debug workers`);
const lines = stdout.trim().split('\n');
if (lines.length <= 1) {
throw new Error('buildkit workers not ready after 15s timeout');
const requiredWorkers = platforms && platforms.length > 1 ? 2 : 1;
if (lines.length <= requiredWorkers) {
throw new Error(`buildkit workers not ready after ${buildkitdTimeoutMs}ms timeout. Found ${lines.length - 1} workers, required ${requiredWorkers}`);
}
} catch (error) {
core.warning(`Error checking buildkit workers: ${error.message}`);
@ -225,7 +274,7 @@ export async function startAndConfigureBuildkitd(parallelism: number): Promise<s
core.warning(`Background cache pruning failed: ${error.message}`);
});
return buildkitdAddr;
return addr;
}
/**