diff --git a/__tests__/buildx/history.test.itg.ts b/__tests__/buildx/history.test.itg.ts index 7416ef6..5b8af75 100644 --- a/__tests__/buildx/history.test.itg.ts +++ b/__tests__/buildx/history.test.itg.ts @@ -14,7 +14,7 @@ * 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 os from 'os'; import path from 'path'; @@ -72,7 +72,7 @@ maybe('inspect', () => { }); }); -maybe('exportBuild', () => { +maybe('export', () => { // prettier-ignore test.each([ [ @@ -92,7 +92,7 @@ maybe('exportBuild', () => { fixturesDir ], ] - ])('export build %p', async (_, bargs) => { + ])('export with build %p', async (_, bargs) => { const buildx = new Buildx(); const build = new Build({buildx: buildx}); @@ -152,7 +152,7 @@ maybe('exportBuild', () => { 'hello-matrix' ], ] - ])('export bake build %p', async (_, bargs) => { + ])('export with bake %p', async (_, bargs) => { const buildx = new Buildx(); const bake = new Bake({buildx: buildx}); @@ -187,22 +187,8 @@ maybe('exportBuild', () => { expect(fs.existsSync(exportRes?.dockerbuildFilename)).toBe(true); expect(exportRes?.summaries).toBeDefined(); }); -}); -maybe('exportBuild custom image', () => { - 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 () => { + it('export using container', async () => { const buildx = new Buildx(); const build = new Build({buildx: buildx}); @@ -227,7 +213,8 @@ maybe('exportBuild custom image', () => { const history = new History({buildx: buildx}); const exportRes = await history.export({ - refs: [buildRef ?? ''] + refs: [buildRef ?? ''], + useContainer: true }); expect(exportRes).toBeDefined(); diff --git a/src/buildx/history.ts b/src/buildx/history.ts index eb5a21b..3bae017 100644 --- a/src/buildx/history.ts +++ b/src/buildx/history.ts @@ -28,7 +28,7 @@ import {Exec} from '../exec'; import {GitHub} from '../github'; 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 { buildx?: Buildx; @@ -37,9 +37,6 @@ export interface HistoryOpts { export class History { 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) { this.buildx = opts?.buildx || new Buildx(); } @@ -52,6 +49,10 @@ export class History { return await this.getCommand(['inspect', ...args]); } + public async getExportCommand(args: Array) { + return await this.getCommand(['export', ...args]); + } + public async inspect(opts: InspectOpts): Promise { const args: Array = ['--format', 'json']; if (opts.builder) { @@ -72,20 +73,7 @@ export class History { }); } - public async export(opts: ExportBuildOpts): Promise { - 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'); - } - + public async export(opts: ExportOpts): Promise { let builderName: string = ''; let nodeName: string = ''; const refs: Array = []; @@ -113,6 +101,72 @@ export class History { core.info(`exporting build record to ${outDir}`); 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, outDir: string, image?: string): Promise { + 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 await Util.sleep(3); @@ -167,7 +221,7 @@ export class History { 'run', '--rm', '-i', '-v', `${Buildx.refsDir}:/buildx-refs`, '-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 ] 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()}`; - if (refs.length > 1) { - dockerbuildFilename += `+${refs.length - 1}`; - } - - const dockerbuildPath = path.join(outDir, `${dockerbuildFilename}.dockerbuild`); + const dockerbuildPath = path.join(outDir, `${History.exportFilename(refs)}.dockerbuild`); fs.renameSync(tmpDockerbuildFilename, dockerbuildPath); const dockerbuildStats = fs.statSync(dockerbuildPath); @@ -240,4 +289,12 @@ export class History { refs: refs }; } + + private static exportFilename(refs: Array): 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; + } } diff --git a/src/github.ts b/src/github.ts index ca3b049..da2298d 100644 --- a/src/github.ts +++ b/src/github.ts @@ -269,56 +269,58 @@ export class GitHub { // Feedback survey sum.addRaw(`

`).addRaw(`Find this useful? `).addRaw(addLink('Let us know', 'https://docs.docker.com/feedback/gha-build-summary')).addRaw('

'); - // Preview - sum.addRaw('

'); - const summaryTableData: Array> = [ - [ - {header: true, data: 'ID'}, - {header: true, data: 'Name'}, - {header: true, data: 'Status'}, - {header: true, data: 'Cached'}, - {header: true, data: 'Duration'} - ] - ]; - let buildError: string | undefined; - for (const ref in opts.exportRes.summaries) { - if (Object.prototype.hasOwnProperty.call(opts.exportRes.summaries, ref)) { - const summary = opts.exportRes.summaries[ref]; - // prettier-ignore - summaryTableData.push([ + if (opts.exportRes.summaries) { + // Preview + sum.addRaw('

'); + const summaryTableData: Array> = [ + [ + {header: true, data: 'ID'}, + {header: true, data: 'Name'}, + {header: true, data: 'Status'}, + {header: true, data: 'Cached'}, + {header: true, data: 'Duration'} + ] + ]; + let buildError: string | undefined; + for (const ref in opts.exportRes.summaries) { + if (Object.prototype.hasOwnProperty.call(opts.exportRes.summaries, ref)) { + const summary = opts.exportRes.summaries[ref]; + // prettier-ignore + summaryTableData.push([ {data: `${ref.substring(0, 6).toUpperCase()}`}, {data: `${Util.stringToUnicodeEntities(summary.name)}`}, {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.duration} ]); - if (summary.error) { - buildError = summary.error; + if (summary.error) { + buildError = summary.error; + } } } - } - sum.addTable([...summaryTableData]); - sum.addRaw(`

`); + sum.addTable([...summaryTableData]); + sum.addRaw(`

`); - // Build error - if (buildError) { - sum.addRaw(`
`); - if (Util.countLines(buildError) > 10) { - // prettier-ignore - sum + // Build error + if (buildError) { + sum.addRaw(`
`); + if (Util.countLines(buildError) > 10) { + // prettier-ignore + sum .addRaw(`
Error`) .addCodeBlock(he.encode(buildError), 'text') .addRaw(`
`); - } else { - // prettier-ignore - sum + } else { + // prettier-ignore + sum .addRaw(`Error`) .addBreak() .addRaw(`

`) .addCodeBlock(he.encode(buildError), 'text') .addRaw(`

`); + } + sum.addRaw(`
`); } - sum.addRaw(`
`); } // Build inputs diff --git a/src/types/buildx/history.ts b/src/types/buildx/history.ts index 42ab199..c81faf4 100644 --- a/src/types/buildx/history.ts +++ b/src/types/buildx/history.ts @@ -22,7 +22,7 @@ export interface InspectOpts { export type BuildStatus = 'completed' | 'running' | 'failed' | 'canceled'; export interface InspectResponse { - Name?: string; + Name: string; Ref: string; Context?: string; @@ -37,8 +37,8 @@ export interface InspectResponse { StartedAt?: Date; CompletedAt?: Date; - Duration?: number; - Status?: BuildStatus; + Duration: number; + Status: BuildStatus; Error?: InspectErrorOutput; NumCompletedSteps: number; @@ -103,18 +103,20 @@ export interface InspectKeyValueOutput { Value?: string; } -export interface ExportBuildOpts { +export interface ExportOpts { refs: Array; + noSummaries?: boolean; image?: string; + useContainer?: boolean; } -export interface ExportBuildResponse { +export interface ExportResponse { dockerbuildFilename: string; dockerbuildSize: number; - summaries: Summaries; builderName: string; nodeName: string; refs: Array; + summaries?: Summaries; } export interface Summaries { @@ -128,6 +130,6 @@ export interface Summary { numCachedSteps: number; numTotalSteps: number; numCompletedSteps: number; - frontendAttrs: Record; + frontendAttrs?: Record; error?: string; } diff --git a/src/types/github.ts b/src/types/github.ts index 2970e97..8613854 100644 --- a/src/types/github.ts +++ b/src/types/github.ts @@ -19,7 +19,7 @@ import {components as OctoOpenApiTypes} from '@octokit/openapi-types'; import {JwtPayload} from 'jwt-decode'; import {BakeDefinition} from './buildx/bake'; -import {ExportRecordResponse} from './buildx/history'; +import {ExportResponse} from './buildx/history'; export interface GitHubRelease { id: number; @@ -57,7 +57,7 @@ export interface UploadArtifactResponse { } export interface BuildSummaryOpts { - exportRes: ExportRecordResponse; + exportRes: ExportResponse; uploadRes?: UploadArtifactResponse; // eslint-disable-next-line @typescript-eslint/no-explicit-any inputs?: any;