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:
parent
15e5beff2d
commit
c71ad2dbef
15 changed files with 712 additions and 1380 deletions
80
src/__tests__/blacksmith-builder.test.ts
Normal file
80
src/__tests__/blacksmith-builder.test.ts
Normal 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);
|
||||
});
|
||||
});
|
540
src/main.ts
540
src/main.ts
|
@ -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
218
src/reporter.ts
Normal 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
242
src/setup_builder.ts
Normal 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
59
src/utils.ts
Normal 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');
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue