From d070c53fc423e77927f2ca2752dc2958548716ab Mon Sep 17 00:00:00 2001 From: Jeff MAURY Date: Thu, 8 Feb 2024 08:26:35 +0100 Subject: [PATCH] feat: extend Podman Desktop API Build Image parameters (#5882) * feat: extend Podman Desktop API Build Image parameters Fixes #5851 Signed-off-by: Jeff MAURY --- packages/extension-api/src/extension-api.d.ts | 76 +++++++++ .../src/plugin/container-registry.spec.ts | 161 ++++++++++++++++-- .../main/src/plugin/container-registry.ts | 65 +++++-- packages/main/src/plugin/extension-loader.ts | 10 +- packages/main/src/plugin/index.ts | 12 +- 5 files changed, 277 insertions(+), 47 deletions(-) diff --git a/packages/extension-api/src/extension-api.d.ts b/packages/extension-api/src/extension-api.d.ts index c723b1b647596..aae32302fbfc6 100644 --- a/packages/extension-api/src/extension-api.d.ts +++ b/packages/extension-api/src/extension-api.d.ts @@ -2286,6 +2286,82 @@ declare module '@podman-desktop/api' { provider?: ProviderContainerConnectionInfo | containerDesktopAPI.ContainerProviderConnection; // The abort controller for running the build image operation abortController?: AbortController; + // Extra hosts to add to /etc/hosts + extrahosts?: string; + /* + * A Git repository URI or HTTP/HTTPS context URI. If the URI points to a single text file, the file’s contents are + * placed into a file called Dockerfile and the image is built from that file. If the URI points to a tarball, the + * file is downloaded by the daemon and the contents therein used as the context for the build. If the URI points + * to a tarball and the dockerfile parameter is also specified, there must be a file with the corresponding path + * inside the tarball. + */ + remote?: string; + /* + * Default: false + * Suppress verbose build output. + */ + q?: boolean; + // JSON array of images used for build cache resolution. + cachefrom?: string; + // Attempt to pull the image even if an older image exists locally. + pull?: string; + /* + * Default: true + * Remove intermediate containers after a successful build. + */ + rm?: boolean; + /* + * Default: false + * Always remove intermediate containers, even upon failure. + */ + forcerm?: boolean; + // Set memory limit for build. + memory?: number; + // Total memory (memory + swap). Set as -1 to disable swap. + memswap?: number; + // CPU shares (relative weight). + cpushares?: number; + // CPUs in which to allow execution (e.g., 0-3, 0,1). + cpusetcpus?: number; + // The length of a CPU period in microseconds. + cpuperiod?: number; + // Microseconds of CPU time that the container can get in a CPU period. + cpuquota?: number; + /* + * JSON map of string pairs for build-time variables. Users pass these values at build-time. Docker uses the + * buildargs as the environment context for commands run via the ```Dockerfile``` RUN instruction, or for variable + * expansion in other ```Dockerfilev instructions. This is not meant for passing secret values. + * For example, the build arg ```FOO=bar``` would become ```{"FOO":"bar"}``` in JSON. This would result in the query + * parameter ```buildargs={"FOO":"bar"}```. Note that ```{"FOO":"bar"}``` should be URI component encoded. + */ + buildargs?: { [key: string]: string }; + //Size of ```/dev/shm``` in bytes. The size must be greater than 0. If omitted the system uses 64MB. + shmsize?: number; + // Squash the resulting images layers into a single layer. + squash?: boolean; + // Arbitrary key/value labels to set on the image, as a JSON map of string pairs. + labels?: { [key: string]: string }; + /* + * Sets the networking mode for the run commands during build. Supported standard values are: ```bridge```, + * ```host```, ```none```, and ```container:```. Any other value is taken as a custom network's name or ID + * to which this container should connect to. + */ + networkmode?: string; + /* + * Default: "" + * Target build stage + */ + target?: string; + /* + * Default: "" + * BuildKit output configuration + */ + outputs?: string; + /* + * Default: false + * Do not use the cache when building the image. + */ + nocache?: boolean; } export interface NetworkCreateOptions { diff --git a/packages/main/src/plugin/container-registry.spec.ts b/packages/main/src/plugin/container-registry.spec.ts index 680895217d5db..502d43d9fa795 100644 --- a/packages/main/src/plugin/container-registry.spec.ts +++ b/packages/main/src/plugin/container-registry.spec.ts @@ -1133,9 +1133,14 @@ describe('buildImage', () => { lifecycleMethods: undefined, status: 'started', }; - await expect(containerRegistry.buildImage('context', () => {}, 'file', 'name', '', connection)).rejects.toThrow( - 'no running provider for the matching container', - ); + await expect( + containerRegistry.buildImage('context', () => {}, { + containerFile: 'file', + tag: 'name', + platform: '', + provider: connection, + }), + ).rejects.toThrow('no running provider for the matching container'); }); test('called getFirstRunningConnection when undefined provider', async () => { @@ -1172,9 +1177,14 @@ describe('buildImage', () => { }, status: () => 'started', }; - await expect(containerRegistry.buildImage('context', () => {}, 'file', 'name', '', connection)).rejects.toThrow( - 'no running provider for the matching container', - ); + await expect( + containerRegistry.buildImage('context', () => {}, { + containerFile: 'file', + tag: 'name', + platform: '', + provider: connection, + }), + ).rejects.toThrow('no running provider for the matching container'); }); test('throw if build command fail', async () => { @@ -1209,9 +1219,14 @@ describe('buildImage', () => { vi.spyOn(tar, 'pack').mockReturnValue({} as NodeJS.ReadableStream); vi.spyOn(dockerAPI, 'buildImage').mockRejectedValue('human error message'); - await expect(containerRegistry.buildImage('context', () => {}, 'file', 'name', '', connection)).rejects.toThrow( - 'human error message', - ); + await expect( + containerRegistry.buildImage('context', () => {}, { + containerFile: 'file', + tag: 'name', + platform: '', + provider: connection, + }), + ).rejects.toThrow('human error message'); }); test('throw if build command fail using a ContainerProviderConnection input', async () => { @@ -1245,9 +1260,14 @@ describe('buildImage', () => { vi.spyOn(tar, 'pack').mockReturnValue({} as NodeJS.ReadableStream); vi.spyOn(dockerAPI, 'buildImage').mockRejectedValue('human error message'); - await expect(containerRegistry.buildImage('context', () => {}, 'file', 'name', '', connection)).rejects.toThrow( - 'human error message', - ); + await expect( + containerRegistry.buildImage('context', () => {}, { + containerFile: 'file', + tag: 'name', + platform: '', + provider: connection, + }), + ).rejects.toThrow('human error message'); }); test('verify relativeFilePath gets sanitized on Windows', async () => { @@ -1285,7 +1305,12 @@ describe('buildImage', () => { return f(null, []); }); - await containerRegistry.buildImage('context', () => {}, '\\path\\file', 'name', '', connection); + await containerRegistry.buildImage('context', () => {}, { + containerFile: '\\path\\file', + tag: 'name', + platform: '', + provider: connection, + }); expect(dockerAPI.buildImage).toBeCalledWith({} as NodeJS.ReadableStream, { registryconfig: {}, @@ -1329,7 +1354,12 @@ describe('buildImage', () => { return f(null, []); }); - await containerRegistry.buildImage('context', () => {}, '\\path\\file', 'name', '', connection); + await containerRegistry.buildImage('context', () => {}, { + containerFile: '\\path\\file', + tag: 'name', + platform: '', + provider: connection, + }); expect(dockerAPI.buildImage).toBeCalledWith({} as NodeJS.ReadableStream, { registryconfig: {}, @@ -1339,7 +1369,7 @@ describe('buildImage', () => { }); }); - test('verify buildImage receives correct args on non-Windows OS', async () => { + async function verifyBuildImage(extraArgs: object): Promise { const dockerAPI = new Dockerode({ protocol: 'http', host: 'localhost' }); // set providers with docker being first @@ -1374,14 +1404,113 @@ describe('buildImage', () => { return f(null, []); }); - await containerRegistry.buildImage('context', () => {}, '/dir/dockerfile', 'name', '', connection); + await containerRegistry.buildImage('context', () => {}, { + containerFile: '/dir/dockerfile', + tag: 'name', + platform: '', + provider: connection, + ...extraArgs, + }); expect(dockerAPI.buildImage).toBeCalledWith({} as NodeJS.ReadableStream, { registryconfig: {}, platform: '', dockerfile: '/dir/dockerfile', t: 'name', + ...extraArgs, }); + } + + test('verify buildImage receives correct args on non-Windows OS', async () => { + await verifyBuildImage({}); + }); + + test('verify buildImage receives correct args on non-Windows OS with extrahosts', async () => { + await verifyBuildImage({ extrahosts: 'a string' }); + }); + + test('verify buildImage receives correct args on non-Windows OS with remote', async () => { + await verifyBuildImage({ remote: 'a string' }); + }); + + test('verify buildImage receives correct args on non-Windows OS with q', async () => { + await verifyBuildImage({ q: true }); + }); + + test('verify buildImage receives correct args on non-Windows OS with cachefrom', async () => { + await verifyBuildImage({ cachefrom: 'quay.io/ubi9/ubi' }); + }); + + test('verify buildImage receives correct args on non-Windows OS with cachefrom', async () => { + await verifyBuildImage({ cachefrom: 'quay.io/ubi9/ubi' }); + }); + + test('verify buildImage receives correct args on non-Windows OS with pull', async () => { + await verifyBuildImage({ pull: 'quay.io/ubi9/ubi' }); + }); + + test('verify buildImage receives correct args on non-Windows OS with rm', async () => { + await verifyBuildImage({ rm: true }); + }); + + test('verify buildImage receives correct args on non-Windows OS with forcerm', async () => { + await verifyBuildImage({ forcerm: true }); + }); + + test('verify buildImage receives correct args on non-Windows OS with memory', async () => { + await verifyBuildImage({ memory: 12 }); + }); + + test('verify buildImage receives correct args on non-Windows OS with memswap', async () => { + await verifyBuildImage({ memswap: 13 }); + }); + + test('verify buildImage receives correct args on non-Windows OS with cpushares', async () => { + await verifyBuildImage({ cpushares: 14 }); + }); + + test('verify buildImage receives correct args on non-Windows OS with cpusetcpus', async () => { + await verifyBuildImage({ cpusetcpus: 15 }); + }); + + test('verify buildImage receives correct args on non-Windows OS with cpuperiod', async () => { + await verifyBuildImage({ cpuperiod: 16 }); + }); + + test('verify buildImage receives correct args on non-Windows OS with cpuquota', async () => { + await verifyBuildImage({ cpuquota: 17 }); + }); + + test('verify buildImage receives correct args on non-Windows OS with buildargs', async () => { + await verifyBuildImage({ buildargs: { KEY1: 'VALUE1' } }); + }); + + test('verify buildImage receives correct args on non-Windows OS with shmsize', async () => { + await verifyBuildImage({ shmsize: 18 }); + }); + + test('verify buildImage receives correct args on non-Windows OS with squash', async () => { + await verifyBuildImage({ squash: false }); + }); + + test('verify buildImage receives correct args on non-Windows OS with labels', async () => { + await verifyBuildImage({ labels: { LABEL1: 'VALUE_LABEL1' } }); + }); + + test('verify buildImage receives correct args on non-Windows OS with networkmode', async () => { + await verifyBuildImage({ networkmode: 'bridge' }); + }); + + test('verify buildImage receives correct args on non-Windows OS with target', async () => { + await verifyBuildImage({ target: 'target' }); + }); + + test('verify buildImage receives correct args on non-Windows OS with outputs', async () => { + await verifyBuildImage({ outputs: 'outputs' }); + }); + + test('verify buildImage receives correct args on non-Windows OS with nocache', async () => { + await verifyBuildImage({ nocache: true }); }); }); diff --git a/packages/main/src/plugin/container-registry.ts b/packages/main/src/plugin/container-registry.ts index 4dee0882c4334..89e30997009e1 100644 --- a/packages/main/src/plugin/container-registry.ts +++ b/packages/main/src/plugin/container-registry.ts @@ -18,7 +18,7 @@ import type * as containerDesktopAPI from '@podman-desktop/api'; import { Disposable } from './types/disposable.js'; -import type { ContainerAttachOptions } from 'dockerode'; +import type { ContainerAttachOptions, ImageBuildOptions } from 'dockerode'; import Dockerode from 'dockerode'; import StreamValues from 'stream-json/streamers/StreamValues.js'; import type { @@ -2004,18 +2004,14 @@ export class ContainerProviderRegistry { async buildImage( containerBuildContextDirectory: string, eventCollect: (eventName: 'stream' | 'error' | 'finish', data: string) => void, - relativeContainerfilePath?: string, - imageName?: string, - platform?: string, - selectedProvider?: ProviderContainerConnectionInfo | containerDesktopAPI.ContainerProviderConnection, - abortController?: AbortController, + options?: containerDesktopAPI.BuildImageOptions, ): Promise { let telemetryOptions = {}; try { let matchingContainerProviderApi: Dockerode; - if (selectedProvider !== undefined) { + if (options?.provider !== undefined) { // grab all connections - matchingContainerProviderApi = this.getMatchingEngineFromConnection(selectedProvider); + matchingContainerProviderApi = this.getMatchingEngineFromConnection(options.provider); } else { // Get the first running connection (preference for podman) matchingContainerProviderApi = this.getFirstRunningConnection()[1]; @@ -2028,26 +2024,61 @@ export class ContainerProviderRegistry { `Uploading the build context from ${containerBuildContextDirectory}...Can take a while...\r\n`, ); const tarStream = tar.pack(containerBuildContextDirectory); - if (isWindows() && relativeContainerfilePath !== undefined) { - relativeContainerfilePath = relativeContainerfilePath.replace(/\\/g, '/'); + if (isWindows() && options?.containerFile !== undefined) { + options.containerFile = options.containerFile.replace(/\\/g, '/'); } let streamingPromise: Stream; try { - streamingPromise = (await matchingContainerProviderApi.buildImage(tarStream, { + const buildOptions: ImageBuildOptions = { registryconfig, - dockerfile: relativeContainerfilePath, - t: imageName, - platform: platform, - abortSignal: abortController?.signal, - })) as unknown as Stream; + abortSignal: options?.abortController?.signal, + dockerfile: options?.containerFile, + t: options?.tag, + platform: options?.platform, + remote: options?.remote, + q: options?.q, + rm: options?.rm, + forcerm: options?.forcerm, + memory: options?.memory, + memswap: options?.memswap, + cpushares: options?.cpushares, + cpusetcpus: options?.cpusetcpus, + cpuperiod: options?.cpuperiod, + cpuquota: options?.cpuquota, + shmsize: options?.shmsize, + squash: options?.squash, + networkmode: options?.networkmode, + target: options?.target, + outputs: options?.outputs, + nocache: options?.nocache, + }; + if (options?.extrahosts) { + buildOptions.extrahosts = options.extrahosts; + } + if (options?.cachefrom) { + buildOptions.cachefrom = options.cachefrom; + } + if (options?.buildargs) { + buildOptions.buildargs = options.buildargs; + } + if (options?.labels) { + buildOptions.labels = options.labels; + } + if (options?.pull) { + buildOptions.pull = options.pull; + } + streamingPromise = (await matchingContainerProviderApi.buildImage( + tarStream, + buildOptions, + )) as unknown as Stream; } catch (error: unknown) { console.log('error in buildImage', error); const errorMessage = error instanceof Error ? error.message : '' + error; eventCollect('error', errorMessage); throw error; } - eventCollect('stream', `Building ${imageName}...\r\n`); + eventCollect('stream', `Building ${options?.tag}...\r\n`); // eslint-disable-next-line @typescript-eslint/ban-types let resolve: (output: {}) => void; let reject: (err: Error) => void; diff --git a/packages/main/src/plugin/extension-loader.ts b/packages/main/src/plugin/extension-loader.ts index ee7a83dd06d0c..630dd912c1d08 100644 --- a/packages/main/src/plugin/extension-loader.ts +++ b/packages/main/src/plugin/extension-loader.ts @@ -932,15 +932,7 @@ export class ExtensionLoader { eventCollect: (eventName: 'stream' | 'error' | 'finish', data: string) => void, options?: containerDesktopAPI.BuildImageOptions, ) { - return containerProviderRegistry.buildImage( - context, - eventCollect, - options?.containerFile, - options?.tag, - options?.platform, - options?.provider, - options?.abortController, - ); + return containerProviderRegistry.buildImage(context, eventCollect, options); }, listImages(): Promise { return containerProviderRegistry.listImages(); diff --git a/packages/main/src/plugin/index.ts b/packages/main/src/plugin/index.ts index 0de9dafdce31c..32a05d6519ce5 100644 --- a/packages/main/src/plugin/index.ts +++ b/packages/main/src/plugin/index.ts @@ -1225,11 +1225,13 @@ export class PluginSystem { data, ); }, - relativeContainerfilePath, - imageName, - platform, - selectedProvider, - abortController, + { + containerFile: relativeContainerfilePath, + tag: imageName, + platform, + provider: selectedProvider, + abortController, + }, ); }, );