history: export command support

Signed-off-by: CrazyMax <1951866+crazy-max@users.noreply.github.com>
This commit is contained in:
CrazyMax
2025-04-11 01:48:40 +02:00
parent e1c74199da
commit 4731c96418
5 changed files with 134 additions and 86 deletions

View File

@@ -14,7 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
import {afterEach, beforeEach, describe, expect, it, jest, test} from '@jest/globals'; import {describe, expect, it, test} from '@jest/globals';
import fs from 'fs'; import fs from 'fs';
import os from 'os'; import os from 'os';
import path from 'path'; import path from 'path';
@@ -72,7 +72,7 @@ maybe('inspect', () => {
}); });
}); });
maybe('exportBuild', () => { maybe('export', () => {
// prettier-ignore // prettier-ignore
test.each([ test.each([
[ [
@@ -92,7 +92,7 @@ maybe('exportBuild', () => {
fixturesDir fixturesDir
], ],
] ]
])('export build %p', async (_, bargs) => { ])('export with build %p', async (_, bargs) => {
const buildx = new Buildx(); const buildx = new Buildx();
const build = new Build({buildx: buildx}); const build = new Build({buildx: buildx});
@@ -152,7 +152,7 @@ maybe('exportBuild', () => {
'hello-matrix' 'hello-matrix'
], ],
] ]
])('export bake build %p', async (_, bargs) => { ])('export with bake %p', async (_, bargs) => {
const buildx = new Buildx(); const buildx = new Buildx();
const bake = new Bake({buildx: buildx}); const bake = new Bake({buildx: buildx});
@@ -187,22 +187,8 @@ maybe('exportBuild', () => {
expect(fs.existsSync(exportRes?.dockerbuildFilename)).toBe(true); expect(fs.existsSync(exportRes?.dockerbuildFilename)).toBe(true);
expect(exportRes?.summaries).toBeDefined(); expect(exportRes?.summaries).toBeDefined();
}); });
});
maybe('exportBuild custom image', () => { it('export using container', async () => {
const originalEnv = process.env;
beforeEach(() => {
jest.resetModules();
process.env = {
...originalEnv,
DOCKER_BUILD_EXPORT_BUILD_IMAGE: 'docker.io/dockereng/export-build:0.2.2'
};
});
afterEach(() => {
process.env = originalEnv;
});
it('with custom image', async () => {
const buildx = new Buildx(); const buildx = new Buildx();
const build = new Build({buildx: buildx}); const build = new Build({buildx: buildx});
@@ -227,7 +213,8 @@ maybe('exportBuild custom image', () => {
const history = new History({buildx: buildx}); const history = new History({buildx: buildx});
const exportRes = await history.export({ const exportRes = await history.export({
refs: [buildRef ?? ''] refs: [buildRef ?? ''],
useContainer: true
}); });
expect(exportRes).toBeDefined(); expect(exportRes).toBeDefined();

View File

@@ -28,7 +28,7 @@ import {Exec} from '../exec';
import {GitHub} from '../github'; import {GitHub} from '../github';
import {Util} from '../util'; import {Util} from '../util';
import {ExportBuildOpts, ExportBuildResponse, InspectOpts, InspectResponse, Summaries} from '../types/buildx/history'; import {ExportOpts, ExportResponse, InspectOpts, InspectResponse, Summaries} from '../types/buildx/history';
export interface HistoryOpts { export interface HistoryOpts {
buildx?: Buildx; buildx?: Buildx;
@@ -37,9 +37,6 @@ export interface HistoryOpts {
export class History { export class History {
private readonly buildx: Buildx; private readonly buildx: Buildx;
private static readonly EXPORT_BUILD_IMAGE_DEFAULT: string = 'docker.io/dockereng/export-build:latest';
private static readonly EXPORT_BUILD_IMAGE_ENV: string = 'DOCKER_BUILD_EXPORT_BUILD_IMAGE';
constructor(opts?: HistoryOpts) { constructor(opts?: HistoryOpts) {
this.buildx = opts?.buildx || new Buildx(); this.buildx = opts?.buildx || new Buildx();
} }
@@ -52,6 +49,10 @@ export class History {
return await this.getCommand(['inspect', ...args]); return await this.getCommand(['inspect', ...args]);
} }
public async getExportCommand(args: Array<string>) {
return await this.getCommand(['export', ...args]);
}
public async inspect(opts: InspectOpts): Promise<InspectResponse> { public async inspect(opts: InspectOpts): Promise<InspectResponse> {
const args: Array<string> = ['--format', 'json']; const args: Array<string> = ['--format', 'json'];
if (opts.builder) { if (opts.builder) {
@@ -72,20 +73,7 @@ export class History {
}); });
} }
public async export(opts: ExportBuildOpts): Promise<ExportBuildResponse> { public async export(opts: ExportOpts): Promise<ExportResponse> {
if (os.platform() === 'win32') {
throw new Error('Exporting a build record is currently not supported on Windows');
}
if (!(await Docker.isAvailable())) {
throw new Error('Docker is required to export a build record');
}
if (!(await Docker.isDaemonRunning())) {
throw new Error('Docker daemon needs to be running to export a build record');
}
if (!(await this.buildx.versionSatisfies('>=0.13.0'))) {
throw new Error('Buildx >= 0.13.0 is required to export a build record');
}
let builderName: string = ''; let builderName: string = '';
let nodeName: string = ''; let nodeName: string = '';
const refs: Array<string> = []; const refs: Array<string> = [];
@@ -113,6 +101,72 @@ export class History {
core.info(`exporting build record to ${outDir}`); core.info(`exporting build record to ${outDir}`);
fs.mkdirSync(outDir, {recursive: true}); fs.mkdirSync(outDir, {recursive: true});
if (opts.useContainer || (await this.buildx.versionSatisfies('<0.23.0'))) {
return await this.exportLegacy(builderName, nodeName, refs, outDir, opts.image);
}
// wait 3 seconds to ensure build records are finalized: https://github.com/moby/buildkit/pull/5109
await Util.sleep(3);
const summaries: Summaries = {};
if (!opts.noSummaries) {
for (const ref of refs) {
await this.inspect({
ref: ref,
builder: builderName
}).then(res => {
let errorLogs = '';
if (res.Error && res.Status !== 'canceled') {
if (res.Error.Message) {
errorLogs = res.Error.Message;
} else if (res.Error.Name && res.Error.Logs) {
errorLogs = `=> ${res.Error.Name}\n${res.Error.Logs}`;
}
}
summaries[ref] = {
name: res.Name,
status: res.Status,
duration: Util.formatDuration(res.Duration),
numCachedSteps: res.NumCachedSteps,
numTotalSteps: res.NumTotalSteps,
numCompletedSteps: res.NumCompletedSteps,
error: errorLogs
};
});
}
}
const dockerbuildPath = path.join(outDir, `${History.exportFilename(refs)}.dockerbuild`);
const cmd = await this.getExportCommand(['--builder', builderName, '--output', dockerbuildPath, ...refs]);
await Exec.getExecOutput(cmd.command, cmd.args);
const dockerbuildStats = fs.statSync(dockerbuildPath);
return {
dockerbuildFilename: dockerbuildPath,
dockerbuildSize: dockerbuildStats.size,
builderName: builderName,
nodeName: nodeName,
refs: refs,
summaries: summaries
};
}
private async exportLegacy(builderName: string, nodeName: string, refs: Array<string>, outDir: string, image?: string): Promise<ExportResponse> {
if (os.platform() === 'win32') {
throw new Error('Exporting a build record is currently not supported on Windows');
}
if (!(await Docker.isAvailable())) {
throw new Error('Docker is required to export a build record');
}
if (!(await Docker.isDaemonRunning())) {
throw new Error('Docker daemon needs to be running to export a build record');
}
if (!(await this.buildx.versionSatisfies('>=0.13.0'))) {
throw new Error('Buildx >= 0.13.0 is required to export a build record');
}
// wait 3 seconds to ensure build records are finalized: https://github.com/moby/buildkit/pull/5109 // wait 3 seconds to ensure build records are finalized: https://github.com/moby/buildkit/pull/5109
await Util.sleep(3); await Util.sleep(3);
@@ -167,7 +221,7 @@ export class History {
'run', '--rm', '-i', 'run', '--rm', '-i',
'-v', `${Buildx.refsDir}:/buildx-refs`, '-v', `${Buildx.refsDir}:/buildx-refs`,
'-v', `${outDir}:/out`, '-v', `${outDir}:/out`,
opts.image || process.env[History.EXPORT_BUILD_IMAGE_ENV] || History.EXPORT_BUILD_IMAGE_DEFAULT, image || process.env['DOCKER_BUILD_EXPORT_BUILD_IMAGE'] || 'docker.io/dockereng/export-build:latest',
...ebargs ...ebargs
] ]
core.info(`[command]docker ${dockerRunArgs.join(' ')}`); core.info(`[command]docker ${dockerRunArgs.join(' ')}`);
@@ -218,12 +272,7 @@ export class History {
} }
}); });
let dockerbuildFilename = `${GitHub.context.repo.owner}~${GitHub.context.repo.repo}~${refs[0].substring(0, 6).toUpperCase()}`; const dockerbuildPath = path.join(outDir, `${History.exportFilename(refs)}.dockerbuild`);
if (refs.length > 1) {
dockerbuildFilename += `+${refs.length - 1}`;
}
const dockerbuildPath = path.join(outDir, `${dockerbuildFilename}.dockerbuild`);
fs.renameSync(tmpDockerbuildFilename, dockerbuildPath); fs.renameSync(tmpDockerbuildFilename, dockerbuildPath);
const dockerbuildStats = fs.statSync(dockerbuildPath); const dockerbuildStats = fs.statSync(dockerbuildPath);
@@ -240,4 +289,12 @@ export class History {
refs: refs refs: refs
}; };
} }
private static exportFilename(refs: Array<string>): string {
let name = `${GitHub.context.repo.owner}~${GitHub.context.repo.repo}~${refs[0].substring(0, 6).toUpperCase()}`;
if (refs.length > 1) {
name += `+${refs.length - 1}`;
}
return name;
}
} }

View File

@@ -269,56 +269,58 @@ export class GitHub {
// Feedback survey // Feedback survey
sum.addRaw(`<p>`).addRaw(`Find this useful? `).addRaw(addLink('Let us know', 'https://docs.docker.com/feedback/gha-build-summary')).addRaw('</p>'); sum.addRaw(`<p>`).addRaw(`Find this useful? `).addRaw(addLink('Let us know', 'https://docs.docker.com/feedback/gha-build-summary')).addRaw('</p>');
// Preview if (opts.exportRes.summaries) {
sum.addRaw('<p>'); // Preview
const summaryTableData: Array<Array<SummaryTableCell>> = [ sum.addRaw('<p>');
[ const summaryTableData: Array<Array<SummaryTableCell>> = [
{header: true, data: 'ID'}, [
{header: true, data: 'Name'}, {header: true, data: 'ID'},
{header: true, data: 'Status'}, {header: true, data: 'Name'},
{header: true, data: 'Cached'}, {header: true, data: 'Status'},
{header: true, data: 'Duration'} {header: true, data: 'Cached'},
] {header: true, data: 'Duration'}
]; ]
let buildError: string | undefined; ];
for (const ref in opts.exportRes.summaries) { let buildError: string | undefined;
if (Object.prototype.hasOwnProperty.call(opts.exportRes.summaries, ref)) { for (const ref in opts.exportRes.summaries) {
const summary = opts.exportRes.summaries[ref]; if (Object.prototype.hasOwnProperty.call(opts.exportRes.summaries, ref)) {
// prettier-ignore const summary = opts.exportRes.summaries[ref];
summaryTableData.push([ // prettier-ignore
summaryTableData.push([
{data: `<code>${ref.substring(0, 6).toUpperCase()}</code>`}, {data: `<code>${ref.substring(0, 6).toUpperCase()}</code>`},
{data: `<strong>${Util.stringToUnicodeEntities(summary.name)}</strong>`}, {data: `<strong>${Util.stringToUnicodeEntities(summary.name)}</strong>`},
{data: `${summary.status === 'completed' ? ':white_check_mark:' : summary.status === 'canceled' ? ':no_entry_sign:' : ':x:'} ${summary.status}`}, {data: `${summary.status === 'completed' ? ':white_check_mark:' : summary.status === 'canceled' ? ':no_entry_sign:' : ':x:'} ${summary.status}`},
{data: `${summary.numCachedSteps > 0 ? Math.round((summary.numCachedSteps / summary.numTotalSteps) * 100) : 0}%`}, {data: `${summary.numCachedSteps > 0 ? Math.round((summary.numCachedSteps / summary.numTotalSteps) * 100) : 0}%`},
{data: summary.duration} {data: summary.duration}
]); ]);
if (summary.error) { if (summary.error) {
buildError = summary.error; buildError = summary.error;
}
} }
} }
} sum.addTable([...summaryTableData]);
sum.addTable([...summaryTableData]); sum.addRaw(`</p>`);
sum.addRaw(`</p>`);
// Build error // Build error
if (buildError) { if (buildError) {
sum.addRaw(`<blockquote>`); sum.addRaw(`<blockquote>`);
if (Util.countLines(buildError) > 10) { if (Util.countLines(buildError) > 10) {
// prettier-ignore // prettier-ignore
sum sum
.addRaw(`<details><summary><strong>Error</strong></summary>`) .addRaw(`<details><summary><strong>Error</strong></summary>`)
.addCodeBlock(he.encode(buildError), 'text') .addCodeBlock(he.encode(buildError), 'text')
.addRaw(`</details>`); .addRaw(`</details>`);
} else { } else {
// prettier-ignore // prettier-ignore
sum sum
.addRaw(`<strong>Error</strong>`) .addRaw(`<strong>Error</strong>`)
.addBreak() .addBreak()
.addRaw(`<p>`) .addRaw(`<p>`)
.addCodeBlock(he.encode(buildError), 'text') .addCodeBlock(he.encode(buildError), 'text')
.addRaw(`</p>`); .addRaw(`</p>`);
}
sum.addRaw(`</blockquote>`);
} }
sum.addRaw(`</blockquote>`);
} }
// Build inputs // Build inputs

View File

@@ -22,7 +22,7 @@ export interface InspectOpts {
export type BuildStatus = 'completed' | 'running' | 'failed' | 'canceled'; export type BuildStatus = 'completed' | 'running' | 'failed' | 'canceled';
export interface InspectResponse { export interface InspectResponse {
Name?: string; Name: string;
Ref: string; Ref: string;
Context?: string; Context?: string;
@@ -37,8 +37,8 @@ export interface InspectResponse {
StartedAt?: Date; StartedAt?: Date;
CompletedAt?: Date; CompletedAt?: Date;
Duration?: number; Duration: number;
Status?: BuildStatus; Status: BuildStatus;
Error?: InspectErrorOutput; Error?: InspectErrorOutput;
NumCompletedSteps: number; NumCompletedSteps: number;
@@ -103,18 +103,20 @@ export interface InspectKeyValueOutput {
Value?: string; Value?: string;
} }
export interface ExportBuildOpts { export interface ExportOpts {
refs: Array<string>; refs: Array<string>;
noSummaries?: boolean;
image?: string; image?: string;
useContainer?: boolean;
} }
export interface ExportBuildResponse { export interface ExportResponse {
dockerbuildFilename: string; dockerbuildFilename: string;
dockerbuildSize: number; dockerbuildSize: number;
summaries: Summaries;
builderName: string; builderName: string;
nodeName: string; nodeName: string;
refs: Array<string>; refs: Array<string>;
summaries?: Summaries;
} }
export interface Summaries { export interface Summaries {
@@ -128,6 +130,6 @@ export interface Summary {
numCachedSteps: number; numCachedSteps: number;
numTotalSteps: number; numTotalSteps: number;
numCompletedSteps: number; numCompletedSteps: number;
frontendAttrs: Record<string, string>; frontendAttrs?: Record<string, string>;
error?: string; error?: string;
} }

View File

@@ -19,7 +19,7 @@ import {components as OctoOpenApiTypes} from '@octokit/openapi-types';
import {JwtPayload} from 'jwt-decode'; import {JwtPayload} from 'jwt-decode';
import {BakeDefinition} from './buildx/bake'; import {BakeDefinition} from './buildx/bake';
import {ExportRecordResponse} from './buildx/history'; import {ExportResponse} from './buildx/history';
export interface GitHubRelease { export interface GitHubRelease {
id: number; id: number;
@@ -57,7 +57,7 @@ export interface UploadArtifactResponse {
} }
export interface BuildSummaryOpts { export interface BuildSummaryOpts {
exportRes: ExportRecordResponse; exportRes: ExportResponse;
uploadRes?: UploadArtifactResponse; uploadRes?: UploadArtifactResponse;
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
inputs?: any; inputs?: any;