history: export command support
Signed-off-by: CrazyMax <1951866+crazy-max@users.noreply.github.com>
This commit is contained in:
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user