1
0
Fork 0
mirror of https://github.com/docker/build-push-action.git synced 2025-05-07 14:09:30 +02:00

*: refactor methods to support mocking

Additionally, write some tests to ensure the driver method
`startBlacksmithBuilder` handles all exceptions correctly in
both nofallback=true and nofallback=false configurations.
This commit is contained in:
Aditya Maru 2024-12-08 14:19:31 -05:00
parent 15e5beff2d
commit c71ad2dbef
15 changed files with 712 additions and 1380 deletions

View file

@ -0,0 +1,80 @@
import * as core from '@actions/core';
import * as main from '../main';
import * as reporter from '../reporter';
import {getDockerfilePath} from '../context';
import { getBuilderAddr } from '../setup_builder';
jest.mock('@actions/core', () => ({
debug: jest.fn(),
warning: jest.fn(),
info: jest.fn(),
saveState: jest.fn(),
getState: jest.fn(),
setOutput: jest.fn(),
setFailed: jest.fn(),
error: jest.fn()
}));
jest.mock('../context', () => ({
getDockerfilePath: jest.fn(),
Inputs: jest.fn()
}));
jest.mock('../reporter', () => ({
reportBuilderCreationFailed: jest.fn().mockResolvedValue(undefined)
}));
jest.mock('../setup_builder', () => ({
getBuilderAddr: jest.fn()
}));
describe('startBlacksmithBuilder', () => {
let mockInputs;
beforeEach(() => {
jest.clearAllMocks();
mockInputs = {nofallback: false};
});
test('should handle missing dockerfile path with nofallback=false', async () => {
(getDockerfilePath as jest.Mock).mockReturnValue(null);
const result = await main.startBlacksmithBuilder(mockInputs);
expect(result).toEqual({addr: null, buildId: null, exposeId: ''});
expect(core.warning).toHaveBeenCalledWith('Error during Blacksmith builder setup: Failed to resolve dockerfile path. Falling back to a local build.');
expect(reporter.reportBuilderCreationFailed).toHaveBeenCalledWith(new Error('Failed to resolve dockerfile path'));
});
test('should handle missing dockerfile path with nofallback=true', async () => {
(getDockerfilePath as jest.Mock).mockReturnValue(null);
mockInputs.nofallback = true;
await expect(main.startBlacksmithBuilder(mockInputs)).rejects.toThrow('Failed to resolve dockerfile path');
expect(core.warning).not.toHaveBeenCalled();
expect(reporter.reportBuilderCreationFailed).toHaveBeenCalledWith(new Error('Failed to resolve dockerfile path'));
});
test('should handle error in getBuilderAddr with nofallback=false', async () => {
(getDockerfilePath as jest.Mock).mockReturnValue('/path/to/Dockerfile');
(getBuilderAddr as jest.Mock).mockRejectedValue(new Error('Failed to obtain Blacksmith builder'));
mockInputs.nofallback = false;
const result = await main.startBlacksmithBuilder(mockInputs);
expect(result).toEqual({addr: null, buildId: null, exposeId: ''});
expect(core.warning).toHaveBeenCalledWith('Error during Blacksmith builder setup: Failed to obtain Blacksmith builder. Falling back to a local build.');
expect(reporter.reportBuilderCreationFailed).toHaveBeenCalledWith(new Error('Failed to obtain Blacksmith builder'));
});
test('should handle error in getBuilderAddr with nofallback=true', async () => {
(getDockerfilePath as jest.Mock).mockReturnValue('/path/to/Dockerfile');
const error = new Error('Failed to obtain Blacksmith builder');
(getBuilderAddr as jest.Mock).mockRejectedValue(error);
mockInputs.nofallback = true;
await expect(main.startBlacksmithBuilder(mockInputs)).rejects.toThrow(error);
expect(core.warning).not.toHaveBeenCalled();
expect(reporter.reportBuilderCreationFailed).toHaveBeenCalledWith(error);
});
});

View file

@ -6,7 +6,6 @@ import * as actionsToolkit from '@docker/actions-toolkit';
import {Buildx} from '@docker/actions-toolkit/lib/buildx/buildx';
import {History as BuildxHistory} from '@docker/actions-toolkit/lib/buildx/history';
import {ExportRecordResponse} from '@docker/actions-toolkit/lib/types/buildx/history';
import {Context} from '@docker/actions-toolkit/lib/context';
import {Docker} from '@docker/actions-toolkit/lib/docker/docker';
import {Exec} from '@docker/actions-toolkit/lib/exec';
@ -16,481 +15,17 @@ import {Util} from '@docker/actions-toolkit/lib/util';
import {BuilderInfo} from '@docker/actions-toolkit/lib/types/buildx/builder';
import {ConfigFile} from '@docker/actions-toolkit/lib/types/docker/docker';
import axios, {AxiosError, AxiosInstance, AxiosResponse} from 'axios';
import * as context from './context';
import {promisify} from 'util';
import {exec} from 'child_process';
import * as TOML from '@iarna/toml';
import * as reporter from './reporter';
import {getBuilderAddr} from './setup_builder';
const buildxVersion = 'v0.17.0';
const mountPoint = '/var/lib/buildkit';
const execAsync = promisify(exec);
// Returns a client for the sticky disk manager on the agent on this host
async function getBlacksmithAgentClient(): Promise<AxiosInstance> {
const stickyDiskMgrUrl = 'http://192.168.127.1:5556';
return axios.create({
baseURL: stickyDiskMgrUrl
});
}
// Reports a successful build to the local sticky disk manager
async function reportBuildCompleted(exportRes?: ExportRecordResponse, blacksmithDockerBuildId?: string | null, buildRef?: string, dockerBuildDurationSeconds?: string, exposeId?: string) {
if (!blacksmithDockerBuildId) {
core.warning('No docker build ID found, skipping build completion report');
return;
}
try {
const client = await getBlacksmithAgentClient();
const formData = new FormData();
formData.append('shouldCommit', 'true');
formData.append('vmID', process.env.VM_ID || '');
formData.append('exposeID', exposeId || '');
formData.append('stickyDiskKey', process.env.GITHUB_REPO_NAME || '');
const retryCondition = (error: AxiosError) => {
return error.response?.status ? error.response.status > 500 : false;
};
await postWithRetry(client, '/stickydisks', formData, retryCondition);
// Report success to Blacksmith API
const requestOptions = {
docker_build_id: blacksmithDockerBuildId,
conclusion: 'successful',
runtime_seconds: dockerBuildDurationSeconds
};
if (exportRes) {
let buildRefSummary;
// Extract just the ref ID from the full buildRef path
const refId = buildRef?.split('/').pop();
core.info(`Using buildRef ID: ${refId}`);
if (refId && exportRes.summaries[refId]) {
buildRefSummary = exportRes.summaries[refId];
} else {
// Take first summary if buildRef not found
const summaryKeys = Object.keys(exportRes.summaries);
if (summaryKeys.length > 0) {
buildRefSummary = exportRes.summaries[summaryKeys[0]];
}
}
if (buildRefSummary) {
const cachedRatio = buildRefSummary.numCachedSteps / buildRefSummary.numTotalSteps;
requestOptions['cached_steps_ratio'] = cachedRatio;
}
}
await postWithRetryToBlacksmithAPI(`/stickydisks/dockerbuilds/${blacksmithDockerBuildId}`, requestOptions, retryCondition);
return;
} catch (error) {
core.warning('Error reporting build completed:', error);
// We don't want to fail the build if this fails so we swallow the error
}
}
// Reports a failed build to both the local sticky disk manager and the Blacksmith API
async function reportBuildFailed(dockerBuildId: string | null, dockerBuildDurationSeconds?: string, exposeId?: string | null) {
if (!dockerBuildId) {
core.warning('No docker build ID found, skipping build completion report');
return;
}
try {
const client = await getBlacksmithAgentClient();
const formData = new FormData();
formData.append('shouldCommit', 'false');
formData.append('vmID', process.env.VM_ID || '');
formData.append('exposeID', exposeId || '');
formData.append('stickyDiskKey', process.env.GITHUB_REPO_NAME || '');
const retryCondition = (error: AxiosError) => {
return error.response?.status ? error.response.status > 500 : false;
};
await postWithRetry(client, '/stickydisks', formData, retryCondition);
// Report failure to Blacksmith API
const requestOptions = {
docker_build_id: dockerBuildId,
conclusion: 'failed',
runtime_seconds: dockerBuildDurationSeconds
};
await postWithRetryToBlacksmithAPI(`/stickydisks/dockerbuilds/${dockerBuildId}`, requestOptions, retryCondition);
return;
} catch (error) {
core.warning('Error reporting build failed:', error);
// We don't want to fail the build if this fails so we swallow the error
}
}
async function postWithRetryToBlacksmithAPI(url: string, requestBody: unknown, retryCondition: (error: AxiosError) => boolean): Promise<AxiosResponse> {
const maxRetries = 5;
const retryDelay = 100;
const apiUrl = process.env.BLACKSMITH_ENV?.includes('staging') ? 'https://stagingapi.blacksmith.sh' : 'https://api.blacksmith.sh';
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
core.debug(`Request headers: Authorization: Bearer ${process.env.BLACKSMITH_STICKYDISK_TOKEN}, X-Github-Repo-Name: ${process.env.GITHUB_REPO_NAME || ''}`);
const fullUrl = `${apiUrl}${url}`;
core.debug(`Making request to full URL: ${fullUrl}`);
return await axios.post(fullUrl, requestBody, {
headers: {
Authorization: `Bearer ${process.env.BLACKSMITH_STICKYDISK_TOKEN}`,
'X-Github-Repo-Name': process.env.GITHUB_REPO_NAME || '',
'Content-Type': 'application/json'
}
});
} catch (error) {
if (attempt === maxRetries || !retryCondition(error as AxiosError)) {
throw error;
}
core.warning(`Request failed, retrying (${attempt}/${maxRetries})...`);
await new Promise(resolve => setTimeout(resolve, retryDelay));
}
}
throw new Error('Max retries reached');
}
async function postWithRetry(client: AxiosInstance, url: string, formData: FormData, retryCondition: (error: AxiosError) => boolean): Promise<AxiosResponse> {
const maxRetries = 5;
const retryDelay = 100;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await client.post(url, formData, {
headers: {
Authorization: `Bearer ${process.env.BLACKSMITH_STICKYDISK_TOKEN}`,
'X-Github-Repo-Name': process.env.GITHUB_REPO_NAME || '',
'Content-Type': 'multipart/form-data'
}
});
} catch (error) {
if (attempt === maxRetries || !retryCondition(error as AxiosError)) {
throw error;
}
core.warning(`Request failed, retrying (${attempt}/${maxRetries})...`);
await new Promise(resolve => setTimeout(resolve, retryDelay));
}
}
throw new Error('Max retries reached');
}
async function getWithRetry(client: AxiosInstance, url: string, formData: FormData | null, retryCondition: (error: AxiosError) => boolean, options?: {signal?: AbortSignal}): Promise<AxiosResponse> {
const maxRetries = 5;
const retryDelay = 100;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
if (formData) {
return await client.get(url, {
data: formData,
headers: {
Authorization: `Bearer ${process.env.BLACKSMITH_STICKYDISK_TOKEN}`,
'X-Github-Repo-Name': process.env.GITHUB_REPO_NAME || '',
'Content-Type': 'multipart/form-data'
},
signal: options?.signal
});
}
return await client.get(url, {signal: options?.signal});
} catch (error) {
if (attempt === maxRetries || !retryCondition(error as AxiosError)) {
throw error;
}
core.warning(`Request failed, retrying (${attempt}/${maxRetries})...`);
await new Promise(resolve => setTimeout(resolve, retryDelay));
}
}
throw new Error('Max retries reached');
}
async function getStickyDisk(retryCondition: (error: AxiosError) => boolean, options?: {signal?: AbortSignal}): Promise<{expose_id: string; device: string}> {
const client = await getBlacksmithAgentClient();
const formData = new FormData();
// TODO(adityamaru): Support a stickydisk-per-build flag that will namespace the stickydisks by Dockerfile.
// For now, we'll use the repo name as the stickydisk key.
const repoName = process.env.GITHUB_REPO_NAME || '';
if (repoName === '') {
throw new Error('GITHUB_REPO_NAME is not set');
}
formData.append('stickyDiskKey', repoName);
formData.append('region', process.env.BLACKSMITH_REGION || 'eu-central');
formData.append('installationModelID', process.env.BLACKSMITH_INSTALLATION_MODEL_ID || '');
formData.append('vmID', process.env.VM_ID || '');
core.debug(`Getting sticky disk for ${repoName}`);
core.debug('FormData contents:');
for (const pair of formData.entries()) {
core.debug(`${pair[0]}: ${pair[1]}`);
}
const response = await getWithRetry(client, '/stickydisks', formData, retryCondition, options);
// For backward compatibility, if expose_id is set, return it
if (response.data?.expose_id && response.data?.disk_identifier) {
return {expose_id: response.data.expose_id, device: response.data.disk_identifier};
}
return {expose_id: '', device: ''};
}
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;
}
}
async function writeBuildkitdTomlFile(parallelism: number, device: string): Promise<void> {
const diskSize = await getDiskSize(device);
const jsonConfig: TOML.JsonMap = {
root: '/var/lib/buildkit',
grpc: {
address: ['unix:///run/buildkit/buildkitd.sock']
},
registry: {
'docker.io': {
mirrors: ['http://192.168.127.1:5000'],
http: true,
insecure: true
},
'192.168.127.1:5000': {
http: true,
insecure: true
}
},
worker: {
oci: {
enabled: true,
gc: true,
gckeepstorage: diskSize.toString(),
'max-parallelism': parallelism,
snapshotter: 'overlayfs',
gcpolicy: [
{
all: true,
keepDuration: 1209600
},
{
all: true,
keepBytes: diskSize.toString()
}
]
},
containerd: {
enabled: false
}
}
};
const tomlString = TOML.stringify(jsonConfig);
try {
await fs.promises.writeFile('buildkitd.toml', tomlString);
core.debug(`TOML configuration is ${tomlString}`);
} catch (err) {
core.warning('error writing TOML configuration:', err);
throw err;
}
}
async function startBuildkitd(parallelism: number, device: string): Promise<string> {
try {
await writeBuildkitdTomlFile(parallelism, device);
await execAsync('sudo mkdir -p /run/buildkit');
await execAsync('sudo chmod 755 /run/buildkit');
const addr = 'unix:///run/buildkit/buildkitd.sock';
const {stdout: startStdout, stderr: startStderr} = await execAsync(
`sudo nohup buildkitd --debug --addr ${addr} --allow-insecure-entitlement security.insecure --config=buildkitd.toml --allow-insecure-entitlement network.host > buildkitd.log 2>&1 &`
);
if (startStderr) {
throw new Error(`error starting buildkitd service: ${startStderr}`);
}
core.debug(`buildkitd daemon started successfully ${startStdout}`);
const {stderr} = await execAsync(`pgrep -f buildkitd`);
if (stderr) {
throw new Error(`error finding buildkitd PID: ${stderr}`);
}
return addr;
} catch (error) {
core.error('failed to start buildkitd daemon:', error);
throw error;
}
}
// Function to gracefully shut down the buildkitd process
async function shutdownBuildkitd(): Promise<void> {
try {
await execAsync(`sudo pkill -TERM buildkitd`);
} catch (error) {
core.error('error shutting down buildkitd process:', error);
throw error;
}
}
// Function to get the number of available CPUs
async function getNumCPUs(): Promise<number> {
try {
const {stdout} = await execAsync('sudo nproc');
return parseInt(stdout.trim());
} catch (error) {
core.warning('Failed to get CPU count, defaulting to 1:', error);
return 1;
}
}
async function maybeFormatBlockDevice(device: string): Promise<string> {
try {
// Check if device is formatted with ext4
try {
const {stdout} = await execAsync(`sudo blkid -o value -s TYPE ${device}`);
if (stdout.trim() === 'ext4') {
core.debug(`Device ${device} is already formatted with ext4`);
try {
// Run resize2fs to ensure filesystem uses full block device
await execAsync(`sudo resize2fs -f ${device}`);
core.debug(`Resized ext4 filesystem on ${device}`);
} catch (error) {
core.warning(`Error resizing ext4 filesystem on ${device}: ${error}`);
}
return device;
}
} catch (error) {
// blkid returns non-zero if no filesystem found, which is fine
core.debug(`No filesystem found on ${device}, will format it`);
}
// Format device with ext4
core.debug(`Formatting device ${device} with ext4`);
await execAsync(`sudo mkfs.ext4 -m0 -Enodiscard,lazy_itable_init=1,lazy_journal_init=1 -F ${device}`);
core.debug(`Successfully formatted ${device} with ext4`);
return device;
} catch (error) {
core.error(`Failed to format device ${device}:`, error);
throw error;
}
}
// reportBuild reports the build to the Blacksmith API and returns the build ID
async function reportBuild(dockerfilePath: string) {
try {
const requestBody = {
dockerfile_path: dockerfilePath,
repo_name: process.env.GITHUB_REPO_NAME || '',
region: process.env.BLACKSMITH_REGION || 'eu-central',
arch: process.env.BLACKSMITH_ENV?.includes('arm') ? 'arm64' : 'amd64',
git_sha: process.env.GITHUB_SHA || '',
vm_id: process.env.VM_ID || '',
git_branch: process.env.GITHUB_REF_NAME || ''
};
core.debug(`Reporting build with options: ${JSON.stringify(requestBody, null, 2)}`);
const retryCondition = (error: AxiosError) => {
return error.response?.status ? error.response.status > 500 : false;
};
const response = await postWithRetryToBlacksmithAPI('/stickydisks/dockerbuilds', requestBody, retryCondition);
stateHelper.setBlacksmithDockerBuildId(response.data.docker_build_id);
return response.data;
} catch (error) {
const statusCode = (error as AxiosError)?.response?.status;
core.warning(`Error reporting build to Blacksmith API (status: ${statusCode || 'unknown'}):`);
core.warning(error);
return null;
}
}
async function reportBuilderCreationFailed() {
const requestOptions = {
stickydisk_key: process.env.GITHUB_REPO_NAME || '',
repo_name: process.env.GITHUB_REPO_NAME || '',
region: process.env.BLACKSMITH_REGION || 'eu-central',
arch: process.env.BLACKSMITH_ENV?.includes('arm') ? 'arm64' : 'amd64',
vm_id: process.env.VM_ID || '',
petname: process.env.PETNAME || ''
};
const retryCondition = (error: AxiosError) => {
return error.response?.status ? error.response.status > 500 : false;
};
const response = await postWithRetryToBlacksmithAPI('/stickydisks/report-failed', requestOptions, retryCondition);
return response.data;
}
// getBuilderAddr mounts a sticky disk for the entity, sets up buildkitd on top of it
// and returns the address to the builder.
// If it is unable to do so because of a timeout or an error it returns null.
async function getBuilderAddr(inputs: context.Inputs, dockerfilePath: string): Promise<{addr: string | null; buildId?: string | null; exposeId: string}> {
try {
const retryCondition = (error: AxiosError) => (error.response?.status ? error.response.status >= 500 : error.code === 'ECONNRESET');
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
let buildResponse: {docker_build_id: string} | null = null;
let exposeId: string = '';
let device: string = '';
try {
const stickyDiskResponse = await getStickyDisk(retryCondition, {signal: controller.signal});
exposeId = stickyDiskResponse.expose_id;
device = stickyDiskResponse.device;
if (device === '') {
// TODO(adityamaru): Remove this once all of our VM agents are returning the device in the stickydisk response.
device = '/dev/vdb';
}
clearTimeout(timeoutId);
await maybeFormatBlockDevice(device);
buildResponse = await reportBuild(dockerfilePath);
await execAsync(`sudo mkdir -p ${mountPoint}`);
await execAsync(`sudo mount ${device} ${mountPoint}`);
core.debug(`${device} has been mounted to ${mountPoint}`);
} catch (error) {
if (error.name === 'AbortError') {
return {addr: null, exposeId: ''};
}
throw error;
}
core.debug('Successfully obtained sticky disk, proceeding to start buildkitd');
// Start buildkitd.
const parallelism = await getNumCPUs();
const buildkitdAddr = await startBuildkitd(parallelism, device);
core.debug(`buildkitd daemon started at addr ${buildkitdAddr}`);
// Change permissions on the buildkitd socket to allow non-root access
const startTime = Date.now();
const timeout = 5000; // 5 seconds in milliseconds
while (Date.now() - startTime < timeout) {
if (fs.existsSync('/run/buildkit/buildkitd.sock')) {
// Change permissions on the buildkitd socket to allow non-root access
await execAsync(`sudo chmod 666 /run/buildkit/buildkitd.sock`);
break;
}
await new Promise(resolve => setTimeout(resolve, 100)); // Poll every 100ms
}
if (!fs.existsSync('/run/buildkit/buildkitd.sock')) {
throw new Error('buildkitd socket not found after 5s timeout');
}
return {addr: buildkitdAddr, buildId: buildResponse?.docker_build_id, exposeId: exposeId};
} catch (error) {
if ((error as AxiosError).response && (error as AxiosError).response!.status === 404) {
if (!inputs.nofallback) {
core.warning('No builder instances were available, falling back to a local build');
}
} else {
core.warning(`Error in getBuildkitdAddr: ${(error as Error).message}`);
}
return {addr: null, exposeId: ''};
}
}
async function setupBuildx(version: string, toolkit: Toolkit): Promise<void> {
let toolPath;
const standalone = await toolkit.buildx.isStandalone();
@ -516,6 +51,36 @@ async function setupBuildx(version: string, toolkit: Toolkit): Promise<void> {
});
}
// Core logic for starting a Blacksmith builder
export async function startBlacksmithBuilder(inputs: context.Inputs): Promise<{addr: string | null; buildId: string | null; exposeId: string}> {
try {
const dockerfilePath = context.getDockerfilePath(inputs);
if (!dockerfilePath) {
throw new Error('Failed to resolve dockerfile path');
}
if (dockerfilePath && dockerfilePath.length > 0) {
core.debug(`Using dockerfile path: ${dockerfilePath}`);
}
const {addr, buildId, exposeId} = await getBuilderAddr(inputs, dockerfilePath);
if (!addr) {
throw Error('Failed to obtain Blacksmith builder. Failing the build');
}
return {addr: addr || null, buildId: buildId || null, exposeId};
} catch (error) {
await reporter.reportBuilderCreationFailed(error);
if (inputs.nofallback) {
throw error;
} else {
console.log('coming to no fallback false');
core.warning(`Error during Blacksmith builder setup: ${error.message}. Falling back to a local build.`);
return {addr: null, buildId: null, exposeId: ''};
}
}
}
actionsToolkit.run(
// main
async () => {
@ -557,33 +122,7 @@ actionsToolkit.run(
exposeId: '' as string
};
await core.group(`Starting Blacksmith builder`, async () => {
const dockerfilePath = context.getDockerfilePath(inputs);
if (!dockerfilePath) {
if (inputs.nofallback) {
await reportBuilderCreationFailed();
throw Error('Failed to resolve dockerfile path, and fallback is disabled');
} else {
core.warning('Failed to resolve dockerfile path, and fallback is enabled. Falling back to a local build.');
}
return;
}
if (dockerfilePath && dockerfilePath.length > 0) {
core.debug(`Using dockerfile path: ${dockerfilePath}`);
}
const {addr, buildId, exposeId} = await getBuilderAddr(inputs, dockerfilePath);
builderInfo = {
addr: addr || null,
buildId: buildId || null,
exposeId: exposeId
};
if (!builderInfo.addr) {
await reportBuilderCreationFailed();
if (inputs.nofallback) {
throw Error('Failed to obtain Blacksmith builder. Failing the build');
} else {
core.warning('Failed to obtain Blacksmith builder address. Falling back to a local build.');
}
}
builderInfo = await startBlacksmithBuilder(inputs);
});
let buildError: Error | undefined;
@ -778,7 +317,7 @@ actionsToolkit.run(
}
core.info('Unmounted device');
if (!buildError) {
await reportBuildCompleted(exportRes, builderInfo.buildId, ref, buildDurationSeconds, builderInfo.exposeId);
await reporter.reportBuildCompleted(exportRes, builderInfo.buildId, ref, buildDurationSeconds, builderInfo.exposeId);
} else {
try {
const buildkitdLog = fs.readFileSync('buildkitd.log', 'utf8');
@ -787,7 +326,7 @@ actionsToolkit.run(
} catch (error) {
core.warning(`Failed to read buildkitd.log: ${error.message}`);
}
await reportBuildFailed(builderInfo.buildId, buildDurationSeconds, builderInfo.exposeId);
await reporter.reportBuildFailed(builderInfo.buildId, buildDurationSeconds, builderInfo.exposeId);
}
} catch (error) {
core.warning(`Error during Blacksmith builder shutdown: ${error.message}`);
@ -882,3 +421,12 @@ function buildSummaryEnabled(): boolean {
}
return true;
}
export async function shutdownBuildkitd(): Promise<void> {
try {
await execAsync(`sudo pkill -TERM buildkitd`);
} catch (error) {
core.error('error shutting down buildkitd process:', error);
throw error;
}
}

218
src/reporter.ts Normal file
View file

@ -0,0 +1,218 @@
import * as core from '@actions/core';
import axios, {AxiosError, AxiosInstance, AxiosResponse} from 'axios';
import {ExportRecordResponse} from '@docker/actions-toolkit/lib/types/buildx/history';
import * as utils from './utils';
export async function reportBuilderCreationFailed(error?: Error) {
const requestOptions = {
stickydisk_key: process.env.GITHUB_REPO_NAME || '',
repo_name: process.env.GITHUB_REPO_NAME || '',
region: process.env.BLACKSMITH_REGION || 'eu-central',
arch: process.env.BLACKSMITH_ENV?.includes('arm') ? 'arm64' : 'amd64',
vm_id: process.env.VM_ID || '',
petname: process.env.PETNAME || ''
};
const retryCondition = (error: AxiosError) => {
return error.response?.status ? error.response.status > 500 : false;
};
const response = await postWithRetryToBlacksmithAPI('/stickydisks/report-failed', requestOptions, retryCondition);
return response.data;
}
export async function reportBuildCompleted(exportRes?: ExportRecordResponse, blacksmithDockerBuildId?: string | null, buildRef?: string, dockerBuildDurationSeconds?: string, exposeId?: string): Promise<void> {
if (!blacksmithDockerBuildId) {
core.warning('No docker build ID found, skipping build completion report');
return;
}
try {
const client = await utils.getBlacksmithAgentClient();
const formData = new FormData();
formData.append('shouldCommit', 'true');
formData.append('vmID', process.env.VM_ID || '');
formData.append('exposeID', exposeId || '');
formData.append('stickyDiskKey', process.env.GITHUB_REPO_NAME || '');
const retryCondition = (error: AxiosError) => {
return error.response?.status ? error.response.status > 500 : false;
};
await postWithRetry(client, '/stickydisks', formData, retryCondition);
// Report success to Blacksmith API
const requestOptions = {
docker_build_id: blacksmithDockerBuildId,
conclusion: 'successful',
runtime_seconds: dockerBuildDurationSeconds
};
if (exportRes) {
let buildRefSummary;
// Extract just the ref ID from the full buildRef path
const refId = buildRef?.split('/').pop();
core.info(`Using buildRef ID: ${refId}`);
if (refId && exportRes.summaries[refId]) {
buildRefSummary = exportRes.summaries[refId];
} else {
// Take first summary if buildRef not found
const summaryKeys = Object.keys(exportRes.summaries);
if (summaryKeys.length > 0) {
buildRefSummary = exportRes.summaries[summaryKeys[0]];
}
}
if (buildRefSummary) {
const cachedRatio = buildRefSummary.numCachedSteps / buildRefSummary.numTotalSteps;
requestOptions['cached_steps_ratio'] = cachedRatio;
}
}
await postWithRetryToBlacksmithAPI(`/stickydisks/dockerbuilds/${blacksmithDockerBuildId}`, requestOptions, retryCondition);
return;
} catch (error) {
core.warning('Error reporting build completed:', error);
// We don't want to fail the build if this fails so we swallow the error
}
}
export async function reportBuildFailed(dockerBuildId: string | null, dockerBuildDurationSeconds?: string, exposeId?: string | null): Promise<void> {
if (!dockerBuildId) {
core.warning('No docker build ID found, skipping build completion report');
return;
}
try {
const client = await utils.getBlacksmithAgentClient();
const formData = new FormData();
formData.append('shouldCommit', 'false');
formData.append('vmID', process.env.VM_ID || '');
formData.append('exposeID', exposeId || '');
formData.append('stickyDiskKey', process.env.GITHUB_REPO_NAME || '');
const retryCondition = (error: AxiosError) => {
return error.response?.status ? error.response.status > 500 : false;
};
await postWithRetry(client, '/stickydisks', formData, retryCondition);
// Report failure to Blacksmith API
const requestOptions = {
docker_build_id: dockerBuildId,
conclusion: 'failed',
runtime_seconds: dockerBuildDurationSeconds
};
await postWithRetryToBlacksmithAPI(`/stickydisks/dockerbuilds/${dockerBuildId}`, requestOptions, retryCondition);
return;
} catch (error) {
core.warning('Error reporting build failed:', error);
// We don't want to fail the build if this fails so we swallow the error
}
}
// reportBuild reports the build to the Blacksmith API and returns the build ID
export async function reportBuild(dockerfilePath: string) {
try {
const requestBody = {
dockerfile_path: dockerfilePath,
repo_name: process.env.GITHUB_REPO_NAME || '',
region: process.env.BLACKSMITH_REGION || 'eu-central',
arch: process.env.BLACKSMITH_ENV?.includes('arm') ? 'arm64' : 'amd64',
git_sha: process.env.GITHUB_SHA || '',
vm_id: process.env.VM_ID || '',
git_branch: process.env.GITHUB_REF_NAME || ''
};
core.debug(`Reporting build with options: ${JSON.stringify(requestBody, null, 2)}`);
const retryCondition = (error: AxiosError) => {
return error.response?.status ? error.response.status > 500 : false;
};
const response = await postWithRetryToBlacksmithAPI('/stickydisks/dockerbuilds', requestBody, retryCondition);
return response.data;
} catch (error) {
const statusCode = (error as AxiosError)?.response?.status;
core.warning(`Error reporting build to Blacksmith API (status: ${statusCode || 'unknown'}):`);
core.warning(error);
return null;
}
}
async function postWithRetryToBlacksmithAPI(url: string, requestBody: unknown, retryCondition: (error: AxiosError) => boolean): Promise<AxiosResponse> {
const maxRetries = 5;
const retryDelay = 100;
const apiUrl = process.env.BLACKSMITH_ENV?.includes('staging') ? 'https://stagingapi.blacksmith.sh' : 'https://api.blacksmith.sh';
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
core.debug(`Request headers: Authorization: Bearer ${process.env.BLACKSMITH_STICKYDISK_TOKEN}, X-Github-Repo-Name: ${process.env.GITHUB_REPO_NAME || ''}`);
const fullUrl = `${apiUrl}${url}`;
core.debug(`Making request to full URL: ${fullUrl}`);
return await axios.post(fullUrl, requestBody, {
headers: {
Authorization: `Bearer ${process.env.BLACKSMITH_STICKYDISK_TOKEN}`,
'X-Github-Repo-Name': process.env.GITHUB_REPO_NAME || '',
'Content-Type': 'application/json'
}
});
} catch (error) {
if (attempt === maxRetries || !retryCondition(error as AxiosError)) {
throw error;
}
core.warning(`Request failed, retrying (${attempt}/${maxRetries})...`);
await new Promise(resolve => setTimeout(resolve, retryDelay));
}
}
throw new Error('Max retries reached');
}
async function postWithRetry(client: AxiosInstance, url: string, formData: FormData, retryCondition: (error: AxiosError) => boolean): Promise<AxiosResponse> {
const maxRetries = 5;
const retryDelay = 100;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await client.post(url, formData, {
headers: {
Authorization: `Bearer ${process.env.BLACKSMITH_STICKYDISK_TOKEN}`,
'X-Github-Repo-Name': process.env.GITHUB_REPO_NAME || '',
'Content-Type': 'multipart/form-data'
}
});
} catch (error) {
if (attempt === maxRetries || !retryCondition(error as AxiosError)) {
throw error;
}
core.warning(`Request failed, retrying (${attempt}/${maxRetries})...`);
await new Promise(resolve => setTimeout(resolve, retryDelay));
}
}
throw new Error('Max retries reached');
}
export async function getWithRetry(client: AxiosInstance, url: string, formData: FormData | null, retryCondition: (error: AxiosError) => boolean, options?: {signal?: AbortSignal}): Promise<AxiosResponse> {
const maxRetries = 5;
const retryDelay = 100;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
if (formData) {
return await client.get(url, {
data: formData,
headers: {
Authorization: `Bearer ${process.env.BLACKSMITH_STICKYDISK_TOKEN}`,
'X-Github-Repo-Name': process.env.GITHUB_REPO_NAME || '',
'Content-Type': 'multipart/form-data'
},
signal: options?.signal
});
}
return await client.get(url, {signal: options?.signal});
} catch (error) {
if (attempt === maxRetries || !retryCondition(error as AxiosError)) {
throw error;
}
core.warning(`Request failed, retrying (${attempt}/${maxRetries})...`);
await new Promise(resolve => setTimeout(resolve, retryDelay));
}
}
throw new Error('Max retries reached');
}

242
src/setup_builder.ts Normal file
View file

@ -0,0 +1,242 @@
import * as fs from 'fs';
import * as core from '@actions/core';
import {AxiosError} from 'axios';
import {exec} from 'child_process';
import {promisify} from 'util';
import * as TOML from '@iarna/toml';
import {Inputs} from './context';
import * as reporter from './reporter';
import * as utils from './utils';
const mountPoint = '/var/lib/buildkit';
const execAsync = promisify(exec);
async function maybeFormatBlockDevice(device: string): Promise<string> {
try {
// Check if device is formatted with ext4
try {
const {stdout} = await execAsync(`sudo blkid -o value -s TYPE ${device}`);
if (stdout.trim() === 'ext4') {
core.debug(`Device ${device} is already formatted with ext4`);
try {
// Run resize2fs to ensure filesystem uses full block device
await execAsync(`sudo resize2fs -f ${device}`);
core.debug(`Resized ext4 filesystem on ${device}`);
} catch (error) {
core.warning(`Error resizing ext4 filesystem on ${device}: ${error}`);
}
return device;
}
} catch (error) {
// blkid returns non-zero if no filesystem found, which is fine
core.debug(`No filesystem found on ${device}, will format it`);
}
// Format device with ext4
core.debug(`Formatting device ${device} with ext4`);
await execAsync(`sudo mkfs.ext4 -m0 -Enodiscard,lazy_itable_init=1,lazy_journal_init=1 -F ${device}`);
core.debug(`Successfully formatted ${device} with ext4`);
return device;
} catch (error) {
core.error(`Failed to format device ${device}:`, error);
throw error;
}
}
async function getNumCPUs(): Promise<number> {
try {
const {stdout} = await execAsync('sudo nproc');
return parseInt(stdout.trim());
} catch (error) {
core.warning('Failed to get CPU count, defaulting to 1:', error);
return 1;
}
}
async function writeBuildkitdTomlFile(parallelism: number, device: string): Promise<void> {
const diskSize = await getDiskSize(device);
const jsonConfig: TOML.JsonMap = {
root: '/var/lib/buildkit',
grpc: {
address: ['unix:///run/buildkit/buildkitd.sock']
},
registry: {
'docker.io': {
mirrors: ['http://192.168.127.1:5000'],
http: true,
insecure: true
},
'192.168.127.1:5000': {
http: true,
insecure: true
}
},
worker: {
oci: {
enabled: true,
gc: true,
gckeepstorage: diskSize.toString(),
'max-parallelism': parallelism,
snapshotter: 'overlayfs',
gcpolicy: [
{
all: true,
keepDuration: 1209600
},
{
all: true,
keepBytes: diskSize.toString()
}
]
},
containerd: {
enabled: false
}
}
};
const tomlString = TOML.stringify(jsonConfig);
try {
await fs.promises.writeFile('buildkitd.toml', tomlString);
core.debug(`TOML configuration is ${tomlString}`);
} catch (err) {
core.warning('error writing TOML configuration:', err);
throw err;
}
}
async function startBuildkitd(parallelism: number, device: string): Promise<string> {
try {
await writeBuildkitdTomlFile(parallelism, device);
await execAsync('sudo mkdir -p /run/buildkit');
await execAsync('sudo chmod 755 /run/buildkit');
const addr = 'unix:///run/buildkit/buildkitd.sock';
const {stdout: startStdout, stderr: startStderr} = await execAsync(
`sudo nohup buildkitd --debug --addr ${addr} --allow-insecure-entitlement security.insecure --config=buildkitd.toml --allow-insecure-entitlement network.host > buildkitd.log 2>&1 &`
);
if (startStderr) {
throw new Error(`error starting buildkitd service: ${startStderr}`);
}
core.debug(`buildkitd daemon started successfully ${startStdout}`);
const {stderr} = await execAsync(`pgrep -f buildkitd`);
if (stderr) {
throw new Error(`error finding buildkitd PID: ${stderr}`);
}
return addr;
} catch (error) {
core.error('failed to start buildkitd daemon:', error);
throw error;
}
}
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;
}
}
async function getStickyDisk(retryCondition: (error: AxiosError) => boolean, options?: {signal?: AbortSignal}): Promise<{expose_id: string; device: string}> {
const client = await utils.getBlacksmithAgentClient();
const formData = new FormData();
// TODO(adityamaru): Support a stickydisk-per-build flag that will namespace the stickydisks by Dockerfile.
// For now, we'll use the repo name as the stickydisk key.
const repoName = process.env.GITHUB_REPO_NAME || '';
if (repoName === '') {
throw new Error('GITHUB_REPO_NAME is not set');
}
formData.append('stickyDiskKey', repoName);
formData.append('region', process.env.BLACKSMITH_REGION || 'eu-central');
formData.append('installationModelID', process.env.BLACKSMITH_INSTALLATION_MODEL_ID || '');
formData.append('vmID', process.env.VM_ID || '');
core.debug(`Getting sticky disk for ${repoName}`);
core.debug('FormData contents:');
for (const pair of formData.entries()) {
core.debug(`${pair[0]}: ${pair[1]}`);
}
const response = await reporter.getWithRetry(client, '/stickydisks', formData, retryCondition, options);
// For backward compatibility, if expose_id is set, return it
if (response.data?.expose_id && response.data?.disk_identifier) {
return {expose_id: response.data.expose_id, device: response.data.disk_identifier};
}
return {expose_id: '', device: ''};
}
// getBuilderAddr mounts a sticky disk for the entity, sets up buildkitd on top of it
// and returns the address to the builder.
// If it is unable to do so because of a timeout or an error it returns null.
export async function getBuilderAddr(inputs: Inputs, dockerfilePath: string): Promise<{addr: string | null; buildId?: string | null; exposeId: string}> {
try {
const retryCondition = (error: AxiosError) => (error.response?.status ? error.response.status >= 500 : error.code === 'ECONNRESET');
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000);
let buildResponse: {docker_build_id: string} | null = null;
let exposeId: string = '';
let device: string = '';
try {
const stickyDiskResponse = await getStickyDisk(retryCondition, {signal: controller.signal});
exposeId = stickyDiskResponse.expose_id;
device = stickyDiskResponse.device;
if (device === '') {
// TODO(adityamaru): Remove this once all of our VM agents are returning the device in the stickydisk response.
device = '/dev/vdb';
}
clearTimeout(timeoutId);
await maybeFormatBlockDevice(device);
buildResponse = await reporter.reportBuild(dockerfilePath);
await execAsync(`sudo mkdir -p ${mountPoint}`);
await execAsync(`sudo mount ${device} ${mountPoint}`);
core.debug(`${device} has been mounted to ${mountPoint}`);
} catch (error) {
if (error.name === 'AbortError') {
return {addr: null, exposeId: ''};
}
throw error;
}
core.debug('Successfully obtained sticky disk, proceeding to start buildkitd');
// Start buildkitd.
const parallelism = await getNumCPUs();
const buildkitdAddr = await startBuildkitd(parallelism, device);
core.debug(`buildkitd daemon started at addr ${buildkitdAddr}`);
// Change permissions on the buildkitd socket to allow non-root access
const startTime = Date.now();
const timeout = 5000; // 5 seconds in milliseconds
while (Date.now() - startTime < timeout) {
if (fs.existsSync('/run/buildkit/buildkitd.sock')) {
// Change permissions on the buildkitd socket to allow non-root access
await execAsync(`sudo chmod 666 /run/buildkit/buildkitd.sock`);
break;
}
await new Promise(resolve => setTimeout(resolve, 100)); // Poll every 100ms
}
if (!fs.existsSync('/run/buildkit/buildkitd.sock')) {
throw new Error('buildkitd socket not found after 5s timeout');
}
return {addr: buildkitdAddr, buildId: buildResponse?.docker_build_id, exposeId: exposeId};
} catch (error) {
if ((error as AxiosError).response && (error as AxiosError).response!.status === 404) {
if (!inputs.nofallback) {
core.warning('No builder instances were available, falling back to a local build');
}
} else {
core.warning(`Error in getBuildkitdAddr: ${(error as Error).message}`);
}
return {addr: null, exposeId: ''};
}
}

59
src/utils.ts Normal file
View file

@ -0,0 +1,59 @@
import axios, {AxiosError, AxiosInstance, AxiosResponse} from 'axios';
import * as core from '@actions/core';
export async function getBlacksmithAgentClient(): Promise<AxiosInstance> {
const stickyDiskMgrUrl = 'http://192.168.127.1:5556';
return axios.create({
baseURL: stickyDiskMgrUrl
});
}
export async function postWithRetry(client: AxiosInstance, url: string, formData: FormData, retryCondition: (error: AxiosError) => boolean): Promise<AxiosResponse> {
const maxRetries = 5;
const retryDelay = 100;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
return await client.post(url, formData, {
headers: {
Authorization: `Bearer ${process.env.BLACKSMITH_STICKYDISK_TOKEN}`,
'X-Github-Repo-Name': process.env.GITHUB_REPO_NAME || '',
'Content-Type': 'multipart/form-data'
}
});
} catch (error) {
if (attempt === maxRetries || !retryCondition(error as AxiosError)) {
throw error;
}
core.warning(`Request failed, retrying (${attempt}/${maxRetries})...`);
await new Promise(resolve => setTimeout(resolve, retryDelay));
}
}
throw new Error('Max retries reached');
}
export async function postWithRetryToBlacksmithAPI(url: string, requestBody: unknown, retryCondition: (error: AxiosError) => boolean): Promise<AxiosResponse> {
const maxRetries = 5;
const retryDelay = 100;
const apiUrl = process.env.BLACKSMITH_ENV?.includes('staging') ? 'https://stagingapi.blacksmith.sh' : 'https://api.blacksmith.sh';
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const fullUrl = `${apiUrl}${url}`;
return await axios.post(fullUrl, requestBody, {
headers: {
Authorization: `Bearer ${process.env.BLACKSMITH_STICKYDISK_TOKEN}`,
'X-Github-Repo-Name': process.env.GITHUB_REPO_NAME || '',
'Content-Type': 'application/json'
}
});
} catch (error) {
if (attempt === maxRetries || !retryCondition(error as AxiosError)) {
throw error;
}
core.warning(`Request failed, retrying (${attempt}/${maxRetries})...`);
await new Promise(resolve => setTimeout(resolve, retryDelay));
}
}
throw new Error('Max retries reached');
}