From 82bb2a99423e1ca91e732e0b3641ddfb9c143738 Mon Sep 17 00:00:00 2001 From: "Badeau, Jose" Date: Thu, 17 Oct 2024 12:19:14 -0400 Subject: [PATCH 1/2] feat(nx-container): add init generator --- plugins/nx-container/generators.json | 5 +++ .../src/generators/init/init.spec.ts | 41 +++++++++++++++++++ .../nx-container/src/generators/init/init.ts | 32 +++++++++++++++ .../src/generators/init/schema.d.ts | 1 + .../src/generators/init/schema.json | 8 ++++ 5 files changed, 87 insertions(+) create mode 100644 plugins/nx-container/src/generators/init/init.spec.ts create mode 100644 plugins/nx-container/src/generators/init/init.ts create mode 100644 plugins/nx-container/src/generators/init/schema.d.ts create mode 100644 plugins/nx-container/src/generators/init/schema.json diff --git a/plugins/nx-container/generators.json b/plugins/nx-container/generators.json index c06cd89c..ff6c2118 100644 --- a/plugins/nx-container/generators.json +++ b/plugins/nx-container/generators.json @@ -4,6 +4,11 @@ "factory": "./src/generators/configuration/generator", "schema": "./src/generators/configuration/schema.json", "description": "Configure Container builds for your application" + }, + "init": { + "factory": "./src/generators/init/init", + "schema": "./src/generators/init/schema.json", + "description": "Init Container settings for your workspace" } } } diff --git a/plugins/nx-container/src/generators/init/init.spec.ts b/plugins/nx-container/src/generators/init/init.spec.ts new file mode 100644 index 00000000..3bd1dad2 --- /dev/null +++ b/plugins/nx-container/src/generators/init/init.spec.ts @@ -0,0 +1,41 @@ +import { Tree, readJson } from '@nx/devkit'; +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; +import { initGenerator, hasContainerPlugin } from './init'; + +describe('init generator', () => { + let tree: Tree; + + beforeEach(() => { + tree = createTreeWithEmptyWorkspace(); + }); + + it('should add @nx-tools/nx-container plugin to nx.json', async () => { + // Run the generator + await initGenerator(tree, {}); + + // Read the nx.json file + const nxJson = readJson(tree, 'nx.json'); + + // Check that the plugin has been added + expect(nxJson.plugins).toBeDefined(); + expect( + nxJson.plugins.some((p) => + typeof p === 'string' ? p === '@nx-tools/nx-container' : p.plugin === '@nx-tools/nx-container' + ) + ).toBe(true); + }); + + it('should not add the plugin if it already exists', async () => { + // Simulate the plugin already being added + const nxJson = readJson(tree, 'nx.json'); + nxJson.plugins = [{ plugin: '@nx-tools/nx-container', options: {} }]; + tree.write('nx.json', JSON.stringify(nxJson)); + + // Run the generator + await initGenerator(tree, {}); + + // Ensure the plugin was not added again + const updatedNxJson = readJson(tree, 'nx.json'); + expect(updatedNxJson.plugins.length).toBe(1); + }); +}); diff --git a/plugins/nx-container/src/generators/init/init.ts b/plugins/nx-container/src/generators/init/init.ts new file mode 100644 index 00000000..e3e49f13 --- /dev/null +++ b/plugins/nx-container/src/generators/init/init.ts @@ -0,0 +1,32 @@ +import { GeneratorCallback, readNxJson, runTasksInSerial, Tree, updateNxJson } from '@nx/devkit'; +import { Schema } from './schema'; + +export async function initGenerator(tree: Tree, options: Schema) { + const tasks: GeneratorCallback[] = []; + + addPlugin(tree); + + return runTasksInSerial(...tasks); +} + +function addPlugin(tree: Tree) { + const nxJson = readNxJson(tree); + + if (!hasContainerPlugin(tree)) { + nxJson.plugins ??= []; + nxJson.plugins.push({ + plugin: '@nx-tools/nx-container', + options: {}, + }); + updateNxJson(tree, nxJson); + } +} + +export function hasContainerPlugin(tree: Tree): boolean { + const nxJson = readNxJson(tree); + return !!nxJson.plugins?.some((p) => + typeof p === 'string' ? p === '@nx-tools/nx-container' : p.plugin === '@nx-tools/nx-container' + ); +} + +export default initGenerator; diff --git a/plugins/nx-container/src/generators/init/schema.d.ts b/plugins/nx-container/src/generators/init/schema.d.ts new file mode 100644 index 00000000..e53f1202 --- /dev/null +++ b/plugins/nx-container/src/generators/init/schema.d.ts @@ -0,0 +1 @@ +export interface Schema {} diff --git a/plugins/nx-container/src/generators/init/schema.json b/plugins/nx-container/src/generators/init/schema.json new file mode 100644 index 00000000..b5152a52 --- /dev/null +++ b/plugins/nx-container/src/generators/init/schema.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json-schema.org/schema", + "$id": "Configuration", + "title": "", + "type": "object", + "properties": {}, + "required": [] +} From 7c250aa6149b1ebceb2c834aab24adcfba1dae15 Mon Sep 17 00:00:00 2001 From: "Badeau, Jose" Date: Thu, 17 Oct 2024 12:19:46 -0400 Subject: [PATCH 2/2] feat(nx-container): add inferred targets --- .../src/generators/configuration/generator.ts | 52 ++++---- plugins/nx-container/src/index.ts | 2 + .../plugins/__snapshots__/nodes.spec.ts.snap | 67 +++++++++++ plugins/nx-container/src/plugins/index.ts | 1 + .../nx-container/src/plugins/nodes.spec.ts | 110 +++++++++++++++++ plugins/nx-container/src/plugins/nodes.ts | 112 ++++++++++++++++++ 6 files changed, 323 insertions(+), 21 deletions(-) create mode 100644 plugins/nx-container/src/plugins/__snapshots__/nodes.spec.ts.snap create mode 100644 plugins/nx-container/src/plugins/index.ts create mode 100644 plugins/nx-container/src/plugins/nodes.spec.ts create mode 100644 plugins/nx-container/src/plugins/nodes.ts diff --git a/plugins/nx-container/src/generators/configuration/generator.ts b/plugins/nx-container/src/generators/configuration/generator.ts index 427564a1..6f65ad74 100644 --- a/plugins/nx-container/src/generators/configuration/generator.ts +++ b/plugins/nx-container/src/generators/configuration/generator.ts @@ -2,6 +2,7 @@ import { formatFiles, generateFiles, ProjectConfiguration, + readNxJson, readProjectConfiguration, Tree, updateProjectConfiguration, @@ -21,30 +22,32 @@ function addFiles(tree: Tree, project: ProjectConfiguration, template) { export async function configurationGenerator(tree: Tree, options: ConfigurationGeneratorSchema) { const project = readProjectConfiguration(tree, options.project); - updateProjectConfiguration(tree, options.project, { - ...project, - targets: { - ...project.targets, - container: { - executor: `@nx-tools/nx-container:build`, - dependsOn: ['build'], - options: { - engine: options.engine ?? DEFAULT_ENGINE, - metadata: { - images: [project.name], - load: true, - tags: [ - 'type=schedule', - 'type=ref,event=branch', - 'type=ref,event=tag', - 'type=ref,event=pr', - 'type=sha,prefix=sha-', - ], + if (!hasContainerPlugin(tree)) { + updateProjectConfiguration(tree, options.project, { + ...project, + targets: { + ...project.targets, + container: { + executor: `@nx-tools/nx-container:build`, + dependsOn: ['build'], + options: { + engine: options.engine ?? DEFAULT_ENGINE, + metadata: { + images: [project.name], + load: true, + tags: [ + 'type=schedule', + 'type=ref,event=branch', + 'type=ref,event=tag', + 'type=ref,event=pr', + 'type=sha,prefix=sha-', + ], + }, }, }, }, - }, - }); + }); + } addFiles(tree, project, options.template ?? DEFAULT_TEMPLATE); @@ -53,4 +56,11 @@ export async function configurationGenerator(tree: Tree, options: ConfigurationG } } +export function hasContainerPlugin(tree: Tree): boolean { + const nxJson = readNxJson(tree); + return !!nxJson.plugins?.some((p) => + typeof p === 'string' ? p === '@nx-tools/nx-container' : p.plugin === '@nx-tools/nx-container' + ); +} + export default configurationGenerator; diff --git a/plugins/nx-container/src/index.ts b/plugins/nx-container/src/index.ts index 68bf24a8..ebb1b9c9 100644 --- a/plugins/nx-container/src/index.ts +++ b/plugins/nx-container/src/index.ts @@ -1 +1,3 @@ export { default as run } from './executors/build/executor'; + +export * from './plugins'; diff --git a/plugins/nx-container/src/plugins/__snapshots__/nodes.spec.ts.snap b/plugins/nx-container/src/plugins/__snapshots__/nodes.spec.ts.snap new file mode 100644 index 00000000..02a5df23 --- /dev/null +++ b/plugins/nx-container/src/plugins/__snapshots__/nodes.spec.ts.snap @@ -0,0 +1,67 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`@nx/container/plugin non-root project with Dockerfile should create nodes for non-root project with Dockerfile 1`] = ` +{ + "projects": { + "apps/your-docker-app": { + "targets": { + "build": { + "dependsOn": [ + "build", + ], + "executor": "@nx-tools/nx-container:build", + "options": { + "engine": "docker", + "metadata": { + "images": [ + "my-docker-app", + ], + "load": true, + "tags": [ + "type=schedule", + "type=ref,event=branch", + "type=ref,event=tag", + "type=ref,event=pr", + "type=sha,prefix=sha-", + ], + }, + }, + }, + }, + }, + }, +} +`; + +exports[`@nx/container/plugin root project should create nodes with correct targets 1`] = ` +{ + "projects": { + ".": { + "targets": { + "build": { + "dependsOn": [ + "build", + ], + "executor": "@nx-tools/nx-container:build", + "options": { + "engine": "docker", + "metadata": { + "images": [ + "my-docker-app", + ], + "load": true, + "tags": [ + "type=schedule", + "type=ref,event=branch", + "type=ref,event=tag", + "type=ref,event=pr", + "type=sha,prefix=sha-", + ], + }, + }, + }, + }, + }, + }, +} +`; diff --git a/plugins/nx-container/src/plugins/index.ts b/plugins/nx-container/src/plugins/index.ts new file mode 100644 index 00000000..3bf0aad9 --- /dev/null +++ b/plugins/nx-container/src/plugins/index.ts @@ -0,0 +1 @@ +export * from './nodes'; diff --git a/plugins/nx-container/src/plugins/nodes.spec.ts b/plugins/nx-container/src/plugins/nodes.spec.ts new file mode 100644 index 00000000..5ccb2d0d --- /dev/null +++ b/plugins/nx-container/src/plugins/nodes.spec.ts @@ -0,0 +1,110 @@ +import { type CreateNodesContext, readNxJson, Tree } from '@nx/devkit'; +import { createNodes } from './nodes'; +import { calculateHashForCreateNodes } from '@nx/devkit/src/utils/calculate-hash-for-create-nodes'; +import { createTreeWithEmptyWorkspace } from '@nx/devkit/testing'; +import { readdirSync, existsSync, readFileSync } from 'fs'; + +jest.mock('fs', () => ({ + ...jest.requireActual('fs'), + readdirSync: jest.fn(), + existsSync: jest.fn(), + readFileSync: jest.fn(), +})); + +jest.mock('@nx/devkit/src/utils/calculate-hash-for-create-nodes', () => { + return { + calculateHashForCreateNodes: jest.fn().mockResolvedValue('mock-hash'), + }; +}); + +describe('@nx/container/plugin', () => { + let tree: Tree; + const createNodesFunction = createNodes[1]; + let context: CreateNodesContext; + + beforeEach(() => { + tree = createTreeWithEmptyWorkspace(); + context = { + nxJsonConfiguration: readNxJson(tree), + workspaceRoot: '/', + configFiles: [], + }; + + (existsSync as jest.Mock).mockImplementation((path: string) => { + return tree.exists(path); + }); + + (readdirSync as jest.Mock).mockImplementation((path: string) => { + return tree.children(path.replace(/\*/g, '')); + }); + + (readFileSync as jest.Mock).mockImplementation((path: string) => { + return tree.read(path); + }); + }); + + describe('root project', () => { + beforeEach(() => { + tree.write('Dockerfile', ''); + tree.write('package.json', JSON.stringify({ name: 'my-docker-app' })); + tree.write('project.json', JSON.stringify({ name: 'my-docker-app' })); + }); + + it('should create nodes with correct targets', async () => { + const nodes = await createNodesFunction( + 'Dockerfile', + { + buildTargetName: 'build', + defaultEngine: 'docker', + }, + context + ); + + expect(nodes).toMatchSnapshot(); + expect(calculateHashForCreateNodes).toHaveBeenCalled(); + }); + }); + + describe('non-root project with Dockerfile', () => { + beforeEach(() => { + tree.write('apps/your-docker-app/Dockerfile', ''); + tree.write('apps/your-docker-app/package.json', JSON.stringify({ name: 'my-docker-app' })); + tree.write('apps/your-docker-app/project.json', JSON.stringify({ name: 'my-docker-app' })); + }); + + it('should create nodes for non-root project with Dockerfile', async () => { + const nodes = await createNodesFunction( + 'apps/your-docker-app/Dockerfile', + { + buildTargetName: 'build', + defaultEngine: 'docker', + }, + context + ); + + expect(nodes).toMatchSnapshot(); + expect(calculateHashForCreateNodes).toHaveBeenCalled(); + }); + }); + + describe('non-root project without project.json', () => { + beforeEach(() => { + tree.write('apps/no-project/Dockerfile', ''); + tree.write('apps/no-project/package.json', JSON.stringify({ name: 'no-project-app' })); + }); + + it('should not create nodes if project.json is missing', async () => { + const nodes = await createNodesFunction( + 'apps/no-project/Dockerfile', + { + buildTargetName: 'build', + defaultEngine: 'docker', + }, + context + ); + + expect(nodes).toEqual({}); + expect(calculateHashForCreateNodes).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/plugins/nx-container/src/plugins/nodes.ts b/plugins/nx-container/src/plugins/nodes.ts new file mode 100644 index 00000000..52cca474 --- /dev/null +++ b/plugins/nx-container/src/plugins/nodes.ts @@ -0,0 +1,112 @@ +import { + CreateDependencies, + CreateNodes, + detectPackageManager, + parseJson, + readJsonFile, + TargetConfiguration, + writeJsonFile, +} from '@nx/devkit'; +import { dirname, join } from 'path'; +import { getLockFileName } from '@nx/js'; +import { existsSync, readdirSync, readFileSync } from 'fs'; +import { calculateHashForCreateNodes } from '@nx/devkit/src/utils/calculate-hash-for-create-nodes'; +import { workspaceDataDirectory } from 'nx/src/utils/cache-directory'; +import { DEFAULT_ENGINE } from '../generators/configuration/constants'; + +export interface ContainerPluginOptions { + buildTargetName?: string; + defaultEngine?: string; +} + +const cachePath = join(workspaceDataDirectory, 'container.hash'); +const targetsCache = readTargetsCache(); + +function readTargetsCache(): Record>> { + return existsSync(cachePath) ? readJsonFile(cachePath) : {}; +} + +function writeTargetsToCache() { + writeJsonFile(cachePath, targetsCache); +} + +export const createDependencies: CreateDependencies = () => { + writeTargetsToCache(); + return []; +}; + +export const createNodes: CreateNodes = [ + '**/Dockerfile', + async (configFilePath, options, context) => { + options = normalizeOptions(options); + const projectRoot = dirname(configFilePath); + + // Do not create a project if project.json isn't there. + const siblingFiles = readdirSync(join(context.workspaceRoot, projectRoot)); + if (!siblingFiles.includes('project.json')) { + return {}; + } + + const hash = await calculateHashForCreateNodes(projectRoot, options, context, [ + getLockFileName(detectPackageManager(context.workspaceRoot)), + ]); + + const projectName = buildProjectName(projectRoot, context.workspaceRoot); + + targetsCache[hash] ??= buildTargets(projectRoot, options, projectName); + + return { + projects: { + [projectRoot]: { + targets: targetsCache[hash], + }, + }, + }; + }, +]; + +function buildTargets(projectRoot: string, options: ContainerPluginOptions, projectName: string) { + const targets: Record = { + [options.buildTargetName]: { + executor: '@nx-tools/nx-container:build', + dependsOn: ['build'], + options: { + engine: options.defaultEngine, + metadata: { + images: [projectName], + load: true, + tags: [ + 'type=schedule', + 'type=ref,event=branch', + 'type=ref,event=tag', + 'type=ref,event=pr', + 'type=sha,prefix=sha-', + ], + }, + }, + }, + }; + + return targets; +} + +function buildProjectName(projectRoot: string, workspaceRoot: string): string | undefined { + const packageJsonPath = join(workspaceRoot, projectRoot, 'package.json'); + const projectJsonPath = join(workspaceRoot, projectRoot, 'project.json'); + let name: string; + if (existsSync(projectJsonPath)) { + const projectJson = parseJson(readFileSync(projectJsonPath, 'utf-8')); + name = projectJson.name; + } else if (existsSync(packageJsonPath)) { + const packageJson = parseJson(readFileSync(packageJsonPath, 'utf-8')); + name = packageJson.name; + } + return name; +} + +function normalizeOptions(options: ContainerPluginOptions): ContainerPluginOptions { + options ??= {}; + options.buildTargetName ??= 'container'; + options.defaultEngine ??= DEFAULT_ENGINE; + return options; +}