fix(node): add project name sanitization for Docker commands. (#31461)

This PR improves our Docker support by sanitizing project names for
compatibility with Docker commands and Linux systems.

closes: #31421
This commit is contained in:
Nicholas Cunningham 2025-06-04 15:18:51 -06:00 committed by GitHub
parent 66eaf2fc74
commit 33bfc51ec2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 223 additions and 8 deletions

View File

@ -18,6 +18,7 @@ RUN addgroup --system docker-express-app && \\
adduser --system -G docker-express-app docker-express-app
COPY dist/apps/docker-express-app docker-express-app/
COPY apps/docker-express-app/package.json docker-express-app/
RUN chown -R docker-express-app:docker-express-app .
# You can remove this install step if you build with \`--bundle\` option.

View File

@ -18,6 +18,7 @@ RUN addgroup --system node-nest-docker-test && \\
adduser --system -G node-nest-docker-test node-nest-docker-test
COPY dist/node-nest-docker-test node-nest-docker-test/
COPY node-nest-docker-test/package.json node-nest-docker-test/
RUN chown -R node-nest-docker-test:node-nest-docker-test .
# You can remove this install step if you build with \`--bundle\` option.

View File

@ -3,7 +3,7 @@
# Build the docker image with `npx nx docker-build <%= project %>`.
# Tip: Modify "docker-build" options in project.json to change docker build args.
#
# Run the container with `docker run -p 3000:3000 -t <%= project %>`.
# Run the container with `docker run -p 3000:3000 -t <%= sanitizedProjectName %>`.
FROM docker.io/node:lts-alpine
ENV HOST=0.0.0.0
@ -11,14 +11,15 @@ ENV PORT=3000
WORKDIR /app
RUN addgroup --system <%= project %> && \
adduser --system -G <%= project %> <%= project %>
RUN addgroup --system <%= sanitizedProjectName %> && \
adduser --system -G <%= sanitizedProjectName %> <%= sanitizedProjectName %>
COPY <%= buildLocation %> <%= project %>/
RUN chown -R <%= project %>:<%= project %> .
COPY <%= buildLocation %> <%= sanitizedProjectName %>/
COPY <%= projectPath %>/package.json <%= sanitizedProjectName %>/
RUN chown -R <%= sanitizedProjectName %>:<%= sanitizedProjectName %> .
# You can remove this install step if you build with `--bundle` option.
# The bundled output will include external dependencies.
RUN npm --prefix <%= project %> --omit=dev -f install
RUN npm --prefix <%= sanitizedProjectName %> --omit=dev -f install
CMD [ "node", "<%= project %>" ]
CMD [ "node", "<%= sanitizedProjectName %>" ]

View File

@ -1,6 +1,7 @@
import { readProjectConfiguration, Tree } from '@nx/devkit';
import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing';
import { applicationGenerator } from '../application/application';
import { setupDockerGenerator } from './setup-docker';
describe('setupDockerGenerator', () => {
let tree: Tree;
@ -65,4 +66,198 @@ describe('setupDockerGenerator', () => {
);
});
});
describe('project name sanitization', () => {
it('should sanitize project names with special characters for Docker commands', async () => {
const projectName = '@myorg/my-app';
await applicationGenerator(tree, {
name: projectName,
directory: '.',
framework: 'express',
e2eTestRunner: 'none',
addPlugin: true,
});
await setupDockerGenerator(tree, {
project: projectName,
outputPath: 'dist/myorg/my-app',
});
const project = readProjectConfiguration(tree, projectName);
expect(project.targets['docker-build']).toEqual({
dependsOn: ['build'],
command: `docker build -f Dockerfile . -t myorg-my-app`,
});
expect(tree.read('Dockerfile', 'utf8')).toMatchInlineSnapshot(`
"# This file is generated by Nx.
#
# Build the docker image with \`npx nx docker-build @myorg/my-app\`.
# Tip: Modify "docker-build" options in project.json to change docker build args.
#
# Run the container with \`docker run -p 3000:3000 -t myorg-my-app\`.
FROM docker.io/node:lts-alpine
ENV HOST=0.0.0.0
ENV PORT=3000
WORKDIR /app
RUN addgroup --system myorg-my-app && \\
adduser --system -G myorg-my-app myorg-my-app
COPY dist/myorg/my-app myorg-my-app/
COPY ./package.json myorg-my-app/
RUN chown -R myorg-my-app:myorg-my-app .
# You can remove this install step if you build with \`--bundle\` option.
# The bundled output will include external dependencies.
RUN npm --prefix myorg-my-app --omit=dev -f install
CMD [ "node", "myorg-my-app" ]
"
`);
});
it('should sanitize project names with slashes and other special characters', async () => {
const projectName = 'my/special@app';
await applicationGenerator(tree, {
name: projectName,
directory: '.',
framework: 'express',
e2eTestRunner: 'none',
addPlugin: true,
});
await setupDockerGenerator(tree, {
project: projectName,
outputPath: 'dist/basic-app',
});
const project = readProjectConfiguration(tree, projectName);
expect(project.targets['docker-build']).toEqual({
dependsOn: ['build'],
command: `docker build -f Dockerfile . -t my-special-app`,
});
expect(tree.read('Dockerfile', 'utf8')).toMatchInlineSnapshot(`
"# This file is generated by Nx.
#
# Build the docker image with \`npx nx docker-build my/special@app\`.
# Tip: Modify "docker-build" options in project.json to change docker build args.
#
# Run the container with \`docker run -p 3000:3000 -t my-special-app\`.
FROM docker.io/node:lts-alpine
ENV HOST=0.0.0.0
ENV PORT=3000
WORKDIR /app
RUN addgroup --system my-special-app && \\
adduser --system -G my-special-app my-special-app
COPY dist/basic-app my-special-app/
COPY ./package.json my-special-app/
RUN chown -R my-special-app:my-special-app .
# You can remove this install step if you build with \`--bundle\` option.
# The bundled output will include external dependencies.
RUN npm --prefix my-special-app --omit=dev -f install
CMD [ "node", "my-special-app" ]
"
`);
});
it('should handle uppercase and multiple special characters', async () => {
const projectName = 'My_App@123/Test';
await applicationGenerator(tree, {
name: projectName,
directory: '.',
framework: 'express',
e2eTestRunner: 'none',
docker: true,
addPlugin: true,
});
const project = readProjectConfiguration(tree, projectName);
expect(project.targets['docker-build']).toEqual({
dependsOn: ['build'],
command: `docker build -f Dockerfile . -t my_app-123-test`,
});
expect(tree.read('Dockerfile', 'utf8')).toMatchInlineSnapshot(`
"# This file is generated by Nx.
#
# Build the docker image with \`npx nx docker-build My_App@123/Test\`.
# Tip: Modify "docker-build" options in project.json to change docker build args.
#
# Run the container with \`docker run -p 3000:3000 -t my_app-123-test\`.
FROM docker.io/node:lts-alpine
ENV HOST=0.0.0.0
ENV PORT=3000
WORKDIR /app
RUN addgroup --system my_app-123-test && \\
adduser --system -G my_app-123-test my_app-123-test
COPY dist/My_App@123/Test my_app-123-test/
COPY ./package.json my_app-123-test/
RUN chown -R my_app-123-test:my_app-123-test .
# You can remove this install step if you build with \`--bundle\` option.
# The bundled output will include external dependencies.
RUN npm --prefix my_app-123-test --omit=dev -f install
CMD [ "node", "my_app-123-test" ]
"
`);
});
it('should ensure docker-build target works with sanitized names in Dockerfile', async () => {
const projectName = '@scope/my-app';
await applicationGenerator(tree, {
name: projectName,
directory: '.',
framework: 'express',
e2eTestRunner: 'none',
addPlugin: true,
});
await setupDockerGenerator(tree, {
project: projectName,
outputPath: 'dist/scope/my-app',
});
const dockerfileContent = tree.read('Dockerfile', 'utf8');
expect(dockerfileContent).toMatch(/^FROM\s+\S+/m);
expect(dockerfileContent).toMatch(/^WORKDIR\s+\S+/m);
expect(dockerfileContent).toMatch(/^CMD\s+\[/m);
// Verify user/group names are valid (no special chars that would break Linux)
const userGroupMatches = dockerfileContent.match(
/addgroup --system (\S+)/
);
const userGroupName = userGroupMatches?.[1];
expect(userGroupName).toMatch(/^[a-z0-9._-]+$/);
expect(userGroupName).not.toContain('@');
expect(userGroupName).not.toContain('/');
const project = readProjectConfiguration(tree, projectName);
expect(project.targets['docker-build'].command).toContain(
`-t ${userGroupName}`
);
});
});
});

View File

@ -25,6 +25,15 @@ function normalizeOptions(
};
}
function sanitizeProjectName(projectName: string): string {
return projectName
.toLowerCase()
.replace(/[@\/]/g, '-')
.replace(/[^a-z0-9._-]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
}
function addDocker(tree: Tree, options: SetUpDockerOptions) {
const projectConfig = readProjectConfiguration(tree, options.project);
@ -40,22 +49,30 @@ function addDocker(tree: Tree, options: SetUpDockerOptions) {
`The output path for the project ${options.project} is not defined. Please provide it as an option to the generator.`
);
}
const sanitizedProjectName = sanitizeProjectName(options.project);
generateFiles(tree, join(__dirname, './files'), projectConfig.root, {
tmpl: '',
buildLocation: options.outputPath ?? outputPath,
project: options.project,
projectPath: projectConfig.root,
sanitizedProjectName,
});
}
export function updateProjectConfig(tree: Tree, options: SetUpDockerOptions) {
let projectConfig = readProjectConfiguration(tree, options.project);
// Use sanitized project name for Docker image tag
const sanitizedProjectName = sanitizeProjectName(options.project);
projectConfig.targets[`${options.targetName}`] = {
dependsOn: [`${options.buildTarget}`],
command: `docker build -f ${joinPathFragments(
projectConfig.root,
'Dockerfile'
)} . -t ${options.project}`,
)} . -t ${sanitizedProjectName}`,
};
updateProjectConfiguration(tree, options.project, projectConfig);