Skip to content

Commit 121f70d

Browse files
authored
Merge pull request #652 from crazy-max/buildx-history-cmd
history: export build using history command support
2 parents b6da7a2 + 4731c96 commit 121f70d

File tree

7 files changed

+336
-83
lines changed

7 files changed

+336
-83
lines changed

__tests__/buildx/history.test.itg.ts

Lines changed: 49 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
* limitations under the License.
1515
*/
1616

17-
import {afterEach, beforeEach, describe, expect, it, jest, test} from '@jest/globals';
17+
import {describe, expect, it, test} from '@jest/globals';
1818
import fs from 'fs';
1919
import os from 'os';
2020
import path from 'path';
@@ -30,7 +30,49 @@ const tmpDir = fs.mkdtempSync(path.join(process.env.TEMP || os.tmpdir(), 'buildx
3030

3131
const maybe = !process.env.GITHUB_ACTIONS || (process.env.GITHUB_ACTIONS === 'true' && process.env.ImageOS && process.env.ImageOS.startsWith('ubuntu')) ? describe : describe.skip;
3232

33-
maybe('exportBuild', () => {
33+
maybe('inspect', () => {
34+
it('build', async () => {
35+
const buildx = new Buildx();
36+
const build = new Build({buildx: buildx});
37+
38+
fs.mkdirSync(tmpDir, {recursive: true});
39+
await expect(
40+
(async () => {
41+
// prettier-ignore
42+
const buildCmd = await buildx.getCommand([
43+
'--builder', process.env.CTN_BUILDER_NAME ?? 'default',
44+
'build', '-f', path.join(fixturesDir, 'hello.Dockerfile'),
45+
'--metadata-file', build.getMetadataFilePath(),
46+
fixturesDir
47+
]);
48+
await Exec.exec(buildCmd.command, buildCmd.args);
49+
})()
50+
).resolves.not.toThrow();
51+
52+
const metadata = build.resolveMetadata();
53+
expect(metadata).toBeDefined();
54+
const buildRef = build.resolveRef(metadata);
55+
if (!buildRef) {
56+
throw new Error('buildRef is undefined');
57+
}
58+
const [builderName, nodeName, ref] = buildRef.split('/');
59+
expect(builderName).toBeDefined();
60+
expect(nodeName).toBeDefined();
61+
expect(ref).toBeDefined();
62+
63+
const history = new History({buildx: buildx});
64+
const res = await history.inspect({
65+
ref: ref,
66+
builder: builderName
67+
});
68+
69+
expect(res).toBeDefined();
70+
expect(res?.Name).toBeDefined();
71+
expect(res?.Ref).toBeDefined();
72+
});
73+
});
74+
75+
maybe('export', () => {
3476
// prettier-ignore
3577
test.each([
3678
[
@@ -50,7 +92,7 @@ maybe('exportBuild', () => {
5092
fixturesDir
5193
],
5294
]
53-
])('export build %p', async (_, bargs) => {
95+
])('export with build %p', async (_, bargs) => {
5496
const buildx = new Buildx();
5597
const build = new Build({buildx: buildx});
5698

@@ -110,7 +152,7 @@ maybe('exportBuild', () => {
110152
'hello-matrix'
111153
],
112154
]
113-
])('export bake build %p', async (_, bargs) => {
155+
])('export with bake %p', async (_, bargs) => {
114156
const buildx = new Buildx();
115157
const bake = new Bake({buildx: buildx});
116158

@@ -145,22 +187,8 @@ maybe('exportBuild', () => {
145187
expect(fs.existsSync(exportRes?.dockerbuildFilename)).toBe(true);
146188
expect(exportRes?.summaries).toBeDefined();
147189
});
148-
});
149-
150-
maybe('exportBuild custom image', () => {
151-
const originalEnv = process.env;
152-
beforeEach(() => {
153-
jest.resetModules();
154-
process.env = {
155-
...originalEnv,
156-
DOCKER_BUILD_EXPORT_BUILD_IMAGE: 'docker.io/dockereng/export-build:0.2.2'
157-
};
158-
});
159-
afterEach(() => {
160-
process.env = originalEnv;
161-
});
162190

163-
it('with custom image', async () => {
191+
it('export using container', async () => {
164192
const buildx = new Buildx();
165193
const build = new Build({buildx: buildx});
166194

@@ -185,7 +213,8 @@ maybe('exportBuild custom image', () => {
185213

186214
const history = new History({buildx: buildx});
187215
const exportRes = await history.export({
188-
refs: [buildRef ?? '']
216+
refs: [buildRef ?? ''],
217+
useContainer: true
189218
});
190219

191220
expect(exportRes).toBeDefined();

__tests__/util.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -469,6 +469,36 @@ describe('isPathRelativeTo', () => {
469469
});
470470
});
471471

472+
describe('formatDuration', () => {
473+
it('formats 0 nanoseconds as "0s"', () => {
474+
expect(Util.formatDuration(0)).toBe('0s');
475+
});
476+
it('formats only seconds', () => {
477+
expect(Util.formatDuration(5e9)).toBe('5s');
478+
expect(Util.formatDuration(59e9)).toBe('59s');
479+
});
480+
it('formats minutes and seconds', () => {
481+
expect(Util.formatDuration(65e9)).toBe('1m5s');
482+
expect(Util.formatDuration(600e9)).toBe('10m');
483+
});
484+
it('formats hours, minutes, and seconds', () => {
485+
expect(Util.formatDuration(3661e9)).toBe('1h1m1s');
486+
expect(Util.formatDuration(7322e9)).toBe('2h2m2s');
487+
});
488+
it('formats hours only', () => {
489+
expect(Util.formatDuration(3 * 3600e9)).toBe('3h');
490+
});
491+
it('formats hours and minutes', () => {
492+
expect(Util.formatDuration(3900e9)).toBe('1h5m');
493+
});
494+
it('formats minutes only', () => {
495+
expect(Util.formatDuration(120e9)).toBe('2m');
496+
});
497+
it('rounds down partial seconds', () => {
498+
expect(Util.formatDuration(1799999999)).toBe('1s');
499+
});
500+
});
501+
472502
// See: https://github.com/actions/toolkit/blob/a1b068ec31a042ff1e10a522d8fdf0b8869d53ca/packages/core/src/core.ts#L89
473503
function getInputName(name: string): string {
474504
return `INPUT_${name.replace(/ /g, '_').toUpperCase()}`;

src/buildx/history.ts

Lines changed: 107 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import {Exec} from '../exec';
2828
import {GitHub} from '../github';
2929
import {Util} from '../util';
3030

31-
import {ExportRecordOpts, ExportRecordResponse, Summaries} from '../types/buildx/history';
31+
import {ExportOpts, ExportResponse, InspectOpts, InspectResponse, Summaries} from '../types/buildx/history';
3232

3333
export interface HistoryOpts {
3434
buildx?: Buildx;
@@ -37,27 +37,43 @@ export interface HistoryOpts {
3737
export class History {
3838
private readonly buildx: Buildx;
3939

40-
private static readonly EXPORT_BUILD_IMAGE_DEFAULT: string = 'docker.io/dockereng/export-build:latest';
41-
private static readonly EXPORT_BUILD_IMAGE_ENV: string = 'DOCKER_BUILD_EXPORT_BUILD_IMAGE';
42-
4340
constructor(opts?: HistoryOpts) {
4441
this.buildx = opts?.buildx || new Buildx();
4542
}
4643

47-
public async export(opts: ExportRecordOpts): Promise<ExportRecordResponse> {
48-
if (os.platform() === 'win32') {
49-
throw new Error('Exporting a build record is currently not supported on Windows');
50-
}
51-
if (!(await Docker.isAvailable())) {
52-
throw new Error('Docker is required to export a build record');
44+
public async getCommand(args: Array<string>) {
45+
return await this.buildx.getCommand(['history', ...args]);
46+
}
47+
48+
public async getInspectCommand(args: Array<string>) {
49+
return await this.getCommand(['inspect', ...args]);
50+
}
51+
52+
public async getExportCommand(args: Array<string>) {
53+
return await this.getCommand(['export', ...args]);
54+
}
55+
56+
public async inspect(opts: InspectOpts): Promise<InspectResponse> {
57+
const args: Array<string> = ['--format', 'json'];
58+
if (opts.builder) {
59+
args.push('--builder', opts.builder);
5360
}
54-
if (!(await Docker.isDaemonRunning())) {
55-
throw new Error('Docker daemon needs to be running to export a build record');
56-
}
57-
if (!(await this.buildx.versionSatisfies('>=0.13.0'))) {
58-
throw new Error('Buildx >= 0.13.0 is required to export a build record');
61+
if (opts.ref) {
62+
args.push(opts.ref);
5963
}
64+
const cmd = await this.getInspectCommand(args);
65+
return await Exec.getExecOutput(cmd.command, cmd.args, {
66+
ignoreReturnCode: true,
67+
silent: true
68+
}).then(res => {
69+
if (res.stderr.length > 0 && res.exitCode != 0) {
70+
throw new Error(res.stderr.trim());
71+
}
72+
return <InspectResponse>JSON.parse(res.stdout);
73+
});
74+
}
6075

76+
public async export(opts: ExportOpts): Promise<ExportResponse> {
6177
let builderName: string = '';
6278
let nodeName: string = '';
6379
const refs: Array<string> = [];
@@ -85,6 +101,72 @@ export class History {
85101
core.info(`exporting build record to ${outDir}`);
86102
fs.mkdirSync(outDir, {recursive: true});
87103

104+
if (opts.useContainer || (await this.buildx.versionSatisfies('<0.23.0'))) {
105+
return await this.exportLegacy(builderName, nodeName, refs, outDir, opts.image);
106+
}
107+
108+
// wait 3 seconds to ensure build records are finalized: https://github.com/moby/buildkit/pull/5109
109+
await Util.sleep(3);
110+
111+
const summaries: Summaries = {};
112+
if (!opts.noSummaries) {
113+
for (const ref of refs) {
114+
await this.inspect({
115+
ref: ref,
116+
builder: builderName
117+
}).then(res => {
118+
let errorLogs = '';
119+
if (res.Error && res.Status !== 'canceled') {
120+
if (res.Error.Message) {
121+
errorLogs = res.Error.Message;
122+
} else if (res.Error.Name && res.Error.Logs) {
123+
errorLogs = `=> ${res.Error.Name}\n${res.Error.Logs}`;
124+
}
125+
}
126+
summaries[ref] = {
127+
name: res.Name,
128+
status: res.Status,
129+
duration: Util.formatDuration(res.Duration),
130+
numCachedSteps: res.NumCachedSteps,
131+
numTotalSteps: res.NumTotalSteps,
132+
numCompletedSteps: res.NumCompletedSteps,
133+
error: errorLogs
134+
};
135+
});
136+
}
137+
}
138+
139+
const dockerbuildPath = path.join(outDir, `${History.exportFilename(refs)}.dockerbuild`);
140+
141+
const cmd = await this.getExportCommand(['--builder', builderName, '--output', dockerbuildPath, ...refs]);
142+
await Exec.getExecOutput(cmd.command, cmd.args);
143+
144+
const dockerbuildStats = fs.statSync(dockerbuildPath);
145+
146+
return {
147+
dockerbuildFilename: dockerbuildPath,
148+
dockerbuildSize: dockerbuildStats.size,
149+
builderName: builderName,
150+
nodeName: nodeName,
151+
refs: refs,
152+
summaries: summaries
153+
};
154+
}
155+
156+
private async exportLegacy(builderName: string, nodeName: string, refs: Array<string>, outDir: string, image?: string): Promise<ExportResponse> {
157+
if (os.platform() === 'win32') {
158+
throw new Error('Exporting a build record is currently not supported on Windows');
159+
}
160+
if (!(await Docker.isAvailable())) {
161+
throw new Error('Docker is required to export a build record');
162+
}
163+
if (!(await Docker.isDaemonRunning())) {
164+
throw new Error('Docker daemon needs to be running to export a build record');
165+
}
166+
if (!(await this.buildx.versionSatisfies('>=0.13.0'))) {
167+
throw new Error('Buildx >= 0.13.0 is required to export a build record');
168+
}
169+
88170
// wait 3 seconds to ensure build records are finalized: https://github.com/moby/buildkit/pull/5109
89171
await Util.sleep(3);
90172

@@ -139,7 +221,7 @@ export class History {
139221
'run', '--rm', '-i',
140222
'-v', `${Buildx.refsDir}:/buildx-refs`,
141223
'-v', `${outDir}:/out`,
142-
opts.image || process.env[History.EXPORT_BUILD_IMAGE_ENV] || History.EXPORT_BUILD_IMAGE_DEFAULT,
224+
image || process.env['DOCKER_BUILD_EXPORT_BUILD_IMAGE'] || 'docker.io/dockereng/export-build:latest',
143225
...ebargs
144226
]
145227
core.info(`[command]docker ${dockerRunArgs.join(' ')}`);
@@ -190,12 +272,7 @@ export class History {
190272
}
191273
});
192274

193-
let dockerbuildFilename = `${GitHub.context.repo.owner}~${GitHub.context.repo.repo}~${refs[0].substring(0, 6).toUpperCase()}`;
194-
if (refs.length > 1) {
195-
dockerbuildFilename += `+${refs.length - 1}`;
196-
}
197-
198-
const dockerbuildPath = path.join(outDir, `${dockerbuildFilename}.dockerbuild`);
275+
const dockerbuildPath = path.join(outDir, `${History.exportFilename(refs)}.dockerbuild`);
199276
fs.renameSync(tmpDockerbuildFilename, dockerbuildPath);
200277
const dockerbuildStats = fs.statSync(dockerbuildPath);
201278

@@ -212,4 +289,12 @@ export class History {
212289
refs: refs
213290
};
214291
}
292+
293+
private static exportFilename(refs: Array<string>): string {
294+
let name = `${GitHub.context.repo.owner}~${GitHub.context.repo.repo}~${refs[0].substring(0, 6).toUpperCase()}`;
295+
if (refs.length > 1) {
296+
name += `+${refs.length - 1}`;
297+
}
298+
return name;
299+
}
215300
}

0 commit comments

Comments
 (0)