diff --git a/packages/cdk-assets/lib/private/docker.ts b/packages/cdk-assets/lib/private/docker.ts index 7cca00a2d0cbd..509e15cbed22f 100644 --- a/packages/cdk-assets/lib/private/docker.ts +++ b/packages/cdk-assets/lib/private/docker.ts @@ -2,7 +2,7 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { cdkCredentialsConfig, obtainEcrCredentials } from './docker-credentials'; -import { Logger, shell, ShellOptions } from './shell'; +import { Logger, shell, ShellOptions, ProcessFailedError } from './shell'; import { createCriticalSection } from './util'; interface BuildOptions { @@ -31,6 +31,11 @@ export interface DockerDomainCredentials { readonly ecrRepository?: string; } +enum InspectImageErrorCode { + Docker = 1, + Podman = 125 +} + export class Docker { private configDir: string | undefined = undefined; @@ -46,8 +51,31 @@ export class Docker { await this.execute(['inspect', tag], { quiet: true }); return true; } catch (e) { - if (e.code !== 'PROCESS_FAILED' || e.exitCode !== 1) { throw e; } - return false; + const error: ProcessFailedError = e; + + /** + * The only error we expect to be thrown will have this property and value. + * If it doesn't, it's unrecognized so re-throw it. + */ + if (error.code !== 'PROCESS_FAILED') { + throw error; + } + + /** + * If we know the shell command above returned an error, check to see + * if the exit code is one we know to actually mean that the image doesn't + * exist. + */ + switch (error.exitCode) { + case InspectImageErrorCode.Docker: + case InspectImageErrorCode.Podman: + // Docker and Podman will return this exit code when an image doesn't exist, return false + // context: https://github.com/aws/aws-cdk/issues/16209 + return false; + default: + // This is an error but it's not an exit code we recognize, throw. + throw error; + } } } diff --git a/packages/cdk-assets/lib/private/shell.ts b/packages/cdk-assets/lib/private/shell.ts index 46eb7c6c571e1..ba7837f49810e 100644 --- a/packages/cdk-assets/lib/private/shell.ts +++ b/packages/cdk-assets/lib/private/shell.ts @@ -61,6 +61,8 @@ export async function shell(command: string[], options: ShellOptions = {}): Prom }); } +export type ProcessFailedError = ProcessFailed + class ProcessFailed extends Error { public readonly code = 'PROCESS_FAILED'; diff --git a/packages/cdk-assets/test/private/docker.test.ts b/packages/cdk-assets/test/private/docker.test.ts new file mode 100644 index 0000000000000..40c37ca35f271 --- /dev/null +++ b/packages/cdk-assets/test/private/docker.test.ts @@ -0,0 +1,94 @@ +import { Docker } from '../../lib/private/docker'; +import { ShellOptions, ProcessFailedError } from '../../lib/private/shell'; + +type ShellExecuteMock = jest.SpyInstance, Parameters>; + +describe('Docker', () => { + describe('exists', () => { + let docker: Docker; + + const makeShellExecuteMock = ( + fn: (params: string[]) => void, + ): ShellExecuteMock => + jest.spyOn<{ execute: Docker['execute'] }, 'execute'>(Docker.prototype as any, 'execute').mockImplementation( + async (params: string[], _options?: ShellOptions) => fn(params), + ); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + beforeEach(() => { + docker = new Docker(); + }); + + test('returns true when image inspect command does not throw', async () => { + const spy = makeShellExecuteMock(() => undefined); + + const imageExists = await docker.exists('foo'); + + expect(imageExists).toBe(true); + expect(spy.mock.calls[0][0]).toEqual(['inspect', 'foo']); + }); + + test('throws when an arbitrary error is caught', async () => { + makeShellExecuteMock(() => { + throw new Error(); + }); + + await expect(docker.exists('foo')).rejects.toThrow(); + }); + + test('throws when the error is a shell failure but the exit code is unrecognized', async () => { + makeShellExecuteMock(() => { + throw new (class extends Error implements ProcessFailedError { + public readonly code = 'PROCESS_FAILED' + public readonly exitCode = 47 + public readonly signal = null + + constructor() { + super('foo'); + } + }); + }); + + await expect(docker.exists('foo')).rejects.toThrow(); + }); + + test('returns false when the error is a shell failure and the exit code is 1 (Docker)', async () => { + makeShellExecuteMock(() => { + throw new (class extends Error implements ProcessFailedError { + public readonly code = 'PROCESS_FAILED' + public readonly exitCode = 1 + public readonly signal = null + + constructor() { + super('foo'); + } + }); + }); + + const imageExists = await docker.exists('foo'); + + expect(imageExists).toBe(false); + }); + + test('returns false when the error is a shell failure and the exit code is 125 (Podman)', async () => { + makeShellExecuteMock(() => { + throw new (class extends Error implements ProcessFailedError { + public readonly code = 'PROCESS_FAILED' + public readonly exitCode = 125 + public readonly signal = null + + constructor() { + super('foo'); + } + }); + }); + + const imageExists = await docker.exists('foo'); + + expect(imageExists).toBe(false); + }); + }); +});