Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Switch docker image source to Public ECR #1684

Merged
merged 23 commits into from
Sep 28, 2024
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/dull-flowers-build.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'skuba': minor
---

lint: Update Docker base images to point to AWS ECR Public and remove --platform flag

This updates references to `node:` or `python:` Docker images in your `Dockerfile` and `docker-compose.yml` files to point to AWS ECR Public to avoid Docker Hub rate limiting, along with removing the redundant --platform flag.

eg.

```Dockerfile
## Before
FROM --platform=arm64 node:20-alpine AS dev-deps

## After
FROM public.ecr.aws/docker/library/node:20-alpine AS dev-deps
```
5 changes: 5 additions & 0 deletions .changeset/twelve-bobcats-compete.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'skuba': patch
---

template/\*: Update Docker base images to point to AWS ECR Public and remove --platform flag
8 changes: 8 additions & 0 deletions src/cli/__snapshots__/format.int.test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ Patch skipped: Upgrade to ESLint flat config - no .eslintrc.js - have you alread

Patch skipped: Remove version field from docker-compose files - no docker-compose files found

Patch skipped: Update docker image references to use public.ecr.aws and remove --platform flag - no Dockerfile or docker-compose files found

skuba update complete.

Refreshed .gitignore. refresh-config-files
Expand Down Expand Up @@ -105,6 +107,8 @@ Patch skipped: Upgrade to ESLint flat config - no .eslintrc.js - have you alread

Patch skipped: Remove version field from docker-compose files - no docker-compose files found

Patch skipped: Update docker image references to use public.ecr.aws and remove --platform flag - no Dockerfile or docker-compose files found

skuba update complete.

Refreshed .gitignore. refresh-config-files
Expand Down Expand Up @@ -180,6 +184,8 @@ Patch skipped: Upgrade to ESLint flat config - no .eslintrc.js - have you alread

Patch skipped: Remove version field from docker-compose files - no docker-compose files found

Patch skipped: Update docker image references to use public.ecr.aws and remove --platform flag - no Dockerfile or docker-compose files found

skuba update complete.

Refreshed .gitignore. refresh-config-files
Expand Down Expand Up @@ -226,6 +232,8 @@ Patch skipped: Upgrade to ESLint flat config - no .eslintrc.js - have you alread

Patch skipped: Remove version field from docker-compose files - no docker-compose files found

Patch skipped: Update docker image references to use public.ecr.aws and remove --platform flag - no Dockerfile or docker-compose files found

skuba update complete.

Refreshed .gitignore. refresh-config-files
Expand Down
6 changes: 6 additions & 0 deletions src/cli/lint/internalLints/upgrade/patches/8.2.1/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type { Patches } from '../..';

import { tryCollapseDuplicateMergeKeys } from './collapseDuplicateMergeKeys';
import { tryPatchDockerComposeFiles } from './patchDockerCompose';
import { tryPatchDockerImages } from './patchDockerImages';
import { tryUpgradeESLint } from './upgradeESLint';

export const patches: Patches = [
Expand All @@ -17,4 +18,9 @@ export const patches: Patches = [
apply: tryPatchDockerComposeFiles,
description: 'Remove version field from docker-compose files',
},
{
apply: tryPatchDockerImages,
description:
'Update docker image references to use public.ecr.aws and remove --platform flag',
},
];
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,7 @@ const fetchFiles = async (files: string[]) =>
const patchDockerComposeFiles: PatchFunction = async ({
mode,
}): Promise<PatchReturnType> => {
const maybeDockerComposeFiles = await Promise.resolve(
fg(['docker-compose*.yml']),
);
const maybeDockerComposeFiles = await fg(['docker-compose*.yml']);

if (!maybeDockerComposeFiles.length) {
return {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import fg from 'fast-glob';
import { readFile, writeFile } from 'fs-extra';

import type { PatchConfig } from '../..';

import { tryPatchDockerImages } from './patchDockerImages';

jest.mock('fast-glob');
jest.mock('fs-extra');

describe('patchDockerImages', () => {
afterEach(() => jest.resetAllMocks());

it('should skip if no Dockerfile or docker-compose files are found', async () => {
jest.mocked(fg).mockResolvedValueOnce([]).mockResolvedValueOnce([]);
await expect(
tryPatchDockerImages({
mode: 'format',
} as PatchConfig),
).resolves.toEqual({
result: 'skip',
reason: 'no Dockerfile or docker-compose files found',
});
});

it('should skip if no Dockerfile or docker-compose files need to be patched', async () => {
jest
.mocked(fg)
.mockResolvedValueOnce(['Dockerfile'])
.mockResolvedValueOnce(['docker-compose.yml']);
jest
.mocked(readFile)
.mockResolvedValueOnce('beep' as never)
.mockResolvedValueOnce('boop' as never);

await expect(
tryPatchDockerImages({
mode: 'format',
} as PatchConfig),
).resolves.toEqual({
result: 'skip',
reason: 'no Dockerfile or docker-compose files to patch',
});
});

it('should patch a simple Dockerfile and docker-compose files', async () => {
jest
.mocked(fg)
.mockResolvedValueOnce(['Dockerfile'])
.mockResolvedValueOnce(['docker-compose.yml']);
jest
.mocked(readFile)
.mockResolvedValueOnce('FROM --platform=arm64 node:18\n' as never)
.mockResolvedValueOnce(' image: node:14\n' as never);

await expect(
tryPatchDockerImages({
mode: 'format',
} as PatchConfig),
).resolves.toEqual({
result: 'apply',
});

expect(writeFile).toHaveBeenNthCalledWith(
1,
'Dockerfile',
'FROM public.ecr.aws/docker/library/node:18\n',
);
expect(writeFile).toHaveBeenNthCalledWith(
2,
'docker-compose.yml',
' image: public.ecr.aws/docker/library/node:14\n',
);
});

it('should skip already patched Dockerfile and docker-compose files', async () => {
jest
.mocked(fg)
.mockResolvedValueOnce(['Dockerfile'])
.mockResolvedValueOnce(['docker-compose.yml']);
jest
.mocked(readFile)
.mockResolvedValueOnce(
'FROM public.ecr.aws/docker/library/node:18\n' as never,
)
.mockResolvedValueOnce(
' image: public.ecr.aws/docker/library/node:14\n' as never,
);

await expect(
tryPatchDockerImages({
mode: 'format',
} as PatchConfig),
).resolves.toEqual({
result: 'skip',
reason: 'no Dockerfile or docker-compose files to patch',
});
});

it('should patch multiple lines in Dockerfile and docker-compose files', async () => {
jest
.mocked(fg)
.mockResolvedValueOnce(['Dockerfile'])
.mockResolvedValueOnce(['docker-compose.yml']);
jest
.mocked(readFile)
.mockResolvedValueOnce(
('# syntax=docker/dockerfile:1.10\n' +
'\n' +
'FROM --platform=arm64 node:20-alpine AS dev-deps\n' +
'FROM --otherflag=bar --platform=arm64 node:20-alpine\n' +
'FROM --otherflag=boo --platform=${BUILDPLATFORM} --anotherflag=coo node:20-alpine\n' +
'FROM gcr.io/distroless/nodejs20-debian12 AS runtime\n' +
'FROM --newflag node:latest\n' +
'FROM node:12:@940049cabf21bf4cd20b86641c800c2b9995e4fb85fa4698b1781239fc0f6853') as never,
)
.mockResolvedValueOnce(
('services:\n' +
' app:\n' +
' image: node:20-alpine\n' +
' init: true\n' +
' volumes:\n' +
' - ./:/workdir\n' +
' # Mount agent for Buildkite annotations.\n' +
' - /usr/bin/buildkite-agent:/usr/bin/buildkite-agent\n' +
' # Mount cached dependencies.\n' +
' - /workdir/node_modules\n' +
' other:\n' +
' image: python:3.9\n') as never,
);

await expect(
tryPatchDockerImages({
mode: 'format',
} as PatchConfig),
).resolves.toEqual({
result: 'apply',
});

expect(writeFile).toHaveBeenNthCalledWith(
1,
'Dockerfile',
'# syntax=docker/dockerfile:1.10\n' +
'\n' +
'FROM public.ecr.aws/docker/library/node:20-alpine AS dev-deps\n' +
'FROM --otherflag=bar public.ecr.aws/docker/library/node:20-alpine\n' +
'FROM --otherflag=boo --anotherflag=coo public.ecr.aws/docker/library/node:20-alpine\n' +
'FROM gcr.io/distroless/nodejs20-debian12 AS runtime\n' +
'FROM --newflag public.ecr.aws/docker/library/node:latest\n' +
'FROM public.ecr.aws/docker/library/node:12:@940049cabf21bf4cd20b86641c800c2b9995e4fb85fa4698b1781239fc0f6853',
);
expect(writeFile).toHaveBeenNthCalledWith(
2,
'docker-compose.yml',
'services:\n' +
' app:\n' +
' image: public.ecr.aws/docker/library/node:20-alpine\n' +
' init: true\n' +
' volumes:\n' +
' - ./:/workdir\n' +
' # Mount agent for Buildkite annotations.\n' +
' - /usr/bin/buildkite-agent:/usr/bin/buildkite-agent\n' +
' # Mount cached dependencies.\n' +
' - /workdir/node_modules\n' +
' other:\n' +
' image: public.ecr.aws/docker/library/python:3.9\n',
);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { inspect } from 'util';

import fg from 'fast-glob';
import { readFile, writeFile } from 'fs-extra';

import type { PatchFunction, PatchReturnType } from '../..';
import { log } from '../../../../../../utils/logging';

const DOCKER_IMAGE_REGEX = /^(FROM\s?.*)(\s)(node|python)(:.*)/gm;
const DOCKER_IMAGE_PLATFORM_REGEX = /^(FROM\s?.*)(--platform=[^\s]+) /gm;
samchungy marked this conversation as resolved.
Show resolved Hide resolved
const DOCKER_COMPOSE_IMAGE_REGEX = /^(\s+image:\s)(node|python)(:.*)/gm;
const PUBLIC_ECR = 'public.ecr.aws/docker/library/';

const fetchFiles = async (files: string[]) =>
Promise.all(
files.map(async (file) => {
const contents = await readFile(file, 'utf8');

return {
file,
contents,
};
}),
);

const patchDockerImages: PatchFunction = async ({
mode,
}): Promise<PatchReturnType> => {
const [maybeDockerFilesPaths, maybeDockerComposePaths] = await Promise.all([
fg(['Dockerfile*']),
fg(['docker-compose*.yml']),
]);

if (!maybeDockerFilesPaths.length || !maybeDockerComposePaths.length) {
return {
result: 'skip',
reason: 'no Dockerfile or docker-compose files found',
};
}

const [dockerFiles, dockerComposeFiles] = await Promise.all([
fetchFiles(maybeDockerFilesPaths),
fetchFiles(maybeDockerComposePaths),
]);

const dockerFilesToPatch = dockerFiles.filter(
({ contents }) =>
DOCKER_IMAGE_REGEX.exec(contents) ??
DOCKER_IMAGE_PLATFORM_REGEX.exec(contents),
);

const dockerComposeFilesToPatch = dockerComposeFiles.filter(({ contents }) =>
DOCKER_COMPOSE_IMAGE_REGEX.exec(contents),
);

if (!dockerFilesToPatch.length || !dockerComposeFilesToPatch.length) {
return {
result: 'skip',
reason: 'no Dockerfile or docker-compose files to patch',
};
}

if (mode === 'lint') {
return {
result: 'apply',
};
}

const dockerFilePatches = dockerFilesToPatch.map(
async ({ file, contents }) => {
const patchedContents = contents
.replace(DOCKER_IMAGE_REGEX, `$1$2${PUBLIC_ECR}$3$4`)
.replace(DOCKER_IMAGE_PLATFORM_REGEX, '$1');
await writeFile(file, patchedContents);
},
);

const dockerComposeFilePatches = dockerComposeFilesToPatch.map(
async ({ file, contents }) => {
const patchedContents = contents.replace(
DOCKER_COMPOSE_IMAGE_REGEX,
`$1${PUBLIC_ECR}$2$3`,
);
await writeFile(file, patchedContents);
},
);

await Promise.all([...dockerFilePatches, ...dockerComposeFilePatches]);

return { result: 'apply' };
};

export const tryPatchDockerImages: PatchFunction = async (config) => {
try {
return await patchDockerImages(config);
} catch (err) {
log.warn('Failed to patch Docker images');
log.subtle(inspect(err));
return { result: 'skip', reason: 'due to an error' };
}
};
2 changes: 1 addition & 1 deletion template/express-rest-api/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ RUN pnpm install --offline --prod

###

FROM --platform=<%- platformName %> gcr.io/distroless/nodejs20-debian12 AS runtime
FROM gcr.io/distroless/nodejs20-debian12 AS runtime

WORKDIR /workdir

Expand Down
2 changes: 1 addition & 1 deletion template/express-rest-api/Dockerfile.dev-deps
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# syntax=docker/dockerfile:1.10

FROM --platform=<%- platformName %> node:20-alpine AS dev-deps
FROM public.ecr.aws/docker/library/node:20-alpine AS dev-deps

RUN --mount=type=bind,source=package.json,target=package.json \
corepack enable pnpm && corepack install
Expand Down
2 changes: 1 addition & 1 deletion template/greeter/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# syntax=docker/dockerfile:1.10

FROM --platform=<%- platformName %> node:20-alpine AS dev-deps
FROM public.ecr.aws/docker/library/node:20-alpine AS dev-deps

RUN --mount=type=bind,source=package.json,target=package.json \
corepack enable pnpm && corepack install
Expand Down
2 changes: 1 addition & 1 deletion template/koa-rest-api/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ RUN pnpm install --offline --prod

###

FROM --platform=<%- platformName %> gcr.io/distroless/nodejs20-debian12 AS runtime
FROM gcr.io/distroless/nodejs20-debian12 AS runtime

WORKDIR /workdir

Expand Down
Loading