From 91a5724116074f65e16d2f2f9d2700d48f3baee6 Mon Sep 17 00:00:00 2001 From: CrazyMax <1951866+crazy-max@users.noreply.github.com> Date: Thu, 5 Mar 2026 13:41:55 +0100 Subject: [PATCH] buildx(imagetools): implement create func with metadata parsing Signed-off-by: CrazyMax <1951866+crazy-max@users.noreply.github.com> --- __tests__/buildx/imagetools.test.ts | 117 ++++++++++++++++++++++++++++ src/buildx/imagetools.ts | 76 +++++++++++++++++- src/types/buildx/imagetools.ts | 20 +++++ 3 files changed, 212 insertions(+), 1 deletion(-) create mode 100644 __tests__/buildx/imagetools.test.ts diff --git a/__tests__/buildx/imagetools.test.ts b/__tests__/buildx/imagetools.test.ts new file mode 100644 index 00000000..0a11cfe8 --- /dev/null +++ b/__tests__/buildx/imagetools.test.ts @@ -0,0 +1,117 @@ +/** + * Copyright 2026 actions-toolkit authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {afterEach, describe, expect, it, vi} from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import * as rimraf from 'rimraf'; + +import {Buildx} from '../../src/buildx/buildx.js'; +import {ImageTools} from '../../src/buildx/imagetools.js'; +import {Context} from '../../src/context.js'; +import {Exec} from '../../src/exec.js'; + +const tmpDir = fs.mkdtempSync(path.join(process.env.TEMP || os.tmpdir(), 'buildx-imagetools-')); +const metadataFile = path.join(tmpDir, 'imagetools-metadata.json'); + +vi.spyOn(Context, 'tmpDir').mockImplementation((): string => { + fs.mkdirSync(tmpDir, {recursive: true}); + return tmpDir; +}); + +vi.spyOn(Context, 'tmpName').mockImplementation((): string => { + return metadataFile; +}); + +afterEach(() => { + vi.clearAllMocks(); + rimraf.sync(tmpDir); +}); + +describe('create', () => { + it('parses metadata and supports cwd sources', async () => { + const getCommand = vi.fn().mockResolvedValue({ + command: 'docker', + args: ['buildx', 'imagetools', 'create'] + }); + const buildx = {getCommand} as unknown as Buildx; + + fs.writeFileSync( + metadataFile, + JSON.stringify({ + 'containerimage.descriptor': { + mediaType: 'application/vnd.oci.image.index.v1+json', + digest: 'sha256:19ffeab6f8bc9293ac2c3fdf94ebe28396254c993aea0b5a542cfb02e0883fa3', + size: 4654 + }, + 'image.name': 'docker.io/user/app,docker.io/user/app2' + }) + ); + + const execSpy = vi.spyOn(Exec, 'getExecOutput').mockResolvedValue({ + exitCode: 0, + stdout: '', + stderr: '' + }); + + const result = await new ImageTools({buildx}).create({ + sources: ['cwd://descriptor.json', 'docker.io/library/alpine:latest'], + tags: ['docker.io/user/app:latest'] + }); + + expect(getCommand).toHaveBeenCalledWith(['imagetools', 'create', '--tag', 'docker.io/user/app:latest', '--metadata-file', metadataFile, '--file', 'descriptor.json', 'docker.io/library/alpine:latest']); + expect(execSpy).toHaveBeenCalledWith('docker', ['buildx', 'imagetools', 'create'], { + ignoreReturnCode: true, + silent: true + }); + expect(result).toEqual({ + digest: 'sha256:19ffeab6f8bc9293ac2c3fdf94ebe28396254c993aea0b5a542cfb02e0883fa3', + descriptor: { + mediaType: 'application/vnd.oci.image.index.v1+json', + digest: 'sha256:19ffeab6f8bc9293ac2c3fdf94ebe28396254c993aea0b5a542cfb02e0883fa3', + size: 4654 + }, + imageNames: ['docker.io/user/app', 'docker.io/user/app2'] + }); + }); + + it('does not parse metadata in dry-run mode', async () => { + const getCommand = vi.fn().mockResolvedValue({ + command: 'docker', + args: ['buildx', 'imagetools', 'create'] + }); + const buildx = {getCommand} as unknown as Buildx; + + const execSpy = vi.spyOn(Exec, 'getExecOutput').mockResolvedValue({ + exitCode: 0, + stdout: '', + stderr: '' + }); + + const result = await new ImageTools({buildx}).create({ + sources: ['docker.io/library/alpine:latest'], + dryRun: true + }); + + expect(getCommand).toHaveBeenCalledWith(['imagetools', 'create', '--dry-run', 'docker.io/library/alpine:latest']); + expect(execSpy).toHaveBeenCalledWith('docker', ['buildx', 'imagetools', 'create'], { + ignoreReturnCode: true, + silent: true + }); + expect(result).toBeUndefined(); + }); +}); diff --git a/src/buildx/imagetools.ts b/src/buildx/imagetools.ts index ca35acd1..99fdd016 100644 --- a/src/buildx/imagetools.ts +++ b/src/buildx/imagetools.ts @@ -14,10 +14,12 @@ * limitations under the License. */ +import fs from 'fs'; import {Buildx} from './buildx.js'; +import {Context} from '../context.js'; import {Exec} from '../exec.js'; -import {Manifest as ImageToolsManifest} from '../types/buildx/imagetools.js'; +import {CreateOpts, CreateResponse, CreateResult, Manifest as ImageToolsManifest} from '../types/buildx/imagetools.js'; import {Image} from '../types/oci/config.js'; import {Descriptor, Platform} from '../types/oci/descriptor.js'; import {Digest} from '../types/oci/digest.js'; @@ -41,6 +43,10 @@ export class ImageTools { return await this.getCommand(['inspect', ...args]); } + public async getCreateCommand(args: Array) { + return await this.getCommand(['create', ...args]); + } + public async inspectImage(name: string): Promise | Image> { const cmd = await this.getInspectCommand([name, '--format', '{{json .Image}}']); return await Exec.getExecOutput(cmd.command, cmd.args, { @@ -118,4 +124,72 @@ export class ImageTools { public async attestationDigests(name: string, platform?: Platform): Promise> { return (await this.attestationDescriptors(name, platform)).map(attestation => attestation.digest); } + + public async create(opts: CreateOpts): Promise { + const args: Array = []; + + const metadataFile = Context.tmpName({tmpdir: Context.tmpDir(), template: 'imagetools-metadata-XXXXXX'}); + const fileSources: Array = []; + const sources: Array = []; + for (const source of opts.sources) { + if (source.startsWith('cwd://')) { + const fileSource = source.substring('cwd://'.length); + if (fileSource.length > 0) { + fileSources.push(fileSource); + } + continue; + } + sources.push(source); + } + if (opts.tags) { + for (const tag of opts.tags) { + args.push('--tag', tag); + } + } + if (opts.platforms) { + for (const platform of opts.platforms) { + args.push('--platform', platform); + } + } + if (opts.dryRun) { + args.push('--dry-run'); + } else { + args.push('--metadata-file', metadataFile); + } + for (const fileSource of fileSources) { + args.push('--file', fileSource); + } + for (const source of sources) { + args.push(source); + } + + const cmd = await this.getCreateCommand(args); + return await Exec.getExecOutput(cmd.command, cmd.args, { + ignoreReturnCode: true, + silent: true + }).then(res => { + if (res.stderr.length > 0 && res.exitCode != 0) { + throw new Error(res.stderr.trim()); + } + if (!opts.dryRun) { + if (!fs.existsSync(metadataFile)) { + return undefined; + } + const dt = fs.readFileSync(metadataFile, {encoding: 'utf-8'}).trim(); + if (dt === '' || dt === 'null') { + return undefined; + } + const response = JSON.parse(dt); + const descriptor = response['containerimage.descriptor']; + if (!descriptor) { + return undefined; + } + return { + digest: response['containerimage.digest'] || descriptor.digest, + descriptor: descriptor, + imageNames: response['image.name'] ? response['image.name'].split(',').map(name => name.trim()) : [] + }; + } + }); + } } diff --git a/src/types/buildx/imagetools.ts b/src/types/buildx/imagetools.ts index ce2007ff..3ce5c5e3 100644 --- a/src/types/buildx/imagetools.ts +++ b/src/types/buildx/imagetools.ts @@ -26,3 +26,23 @@ export interface Manifest extends Versioned { manifests?: Descriptor[]; annotations?: Record; } + +// https://docs.docker.com/reference/cli/docker/buildx/imagetools/create/#options +export interface CreateOpts { + sources: Array; + tags?: Array; + platforms?: Array; + dryRun?: boolean; +} + +export interface CreateResponse { + 'containerimage.digest'?: Digest; + 'containerimage.descriptor'?: Descriptor; + 'image.name'?: string; +} + +export interface CreateResult { + digest: Digest; + descriptor: Descriptor; + imageNames: Array; +}