diff --git a/bun.lockb b/bun.lockb index d9ddf0a..834bf23 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/images/node-distroless/project.json b/images/node-distroless/project.json index c501927..4f04dfa 100644 --- a/images/node-distroless/project.json +++ b/images/node-distroless/project.json @@ -3,29 +3,48 @@ "$schema": "../../node_modules/nx/schemas/project-schema.json", "projectType": "library", "metadata": { - "version": "0.0.0" + "version": "0.0.0", + "registries": [ + "ghcr.io", + "docker.io" + ], + "namespace": "ebizbase/node-distroless", + "labels": { + "org.opencontainers.image.source": "https://github.com/ebizbase/dev-infras", + "org.opencontainers.image.description": "Base on distroless with dumb-init image", + "org.opencontainers.image.licenses": "MIT" + } }, "tags": [], "targets": { "build": { "executor": "@ebizbase/nx-docker:build", "options": { - "tags": ["node-distroless:edge"], - "outputs": ["type=docker"] + "load": true, + "tags": [ + "edge" + ] } }, "test": { - "dependsOn": ["build"], + "dependsOn": [ + "build" + ], "executor": "nx:run-commands", "options": { - "command": "docker run --rm node-distroless:edge -e 'console.log(process.version)'" + "command": "docker run --rm ebizbase/node-distroless:edge -e 'console.log(process.version)'" } }, "publish": { "executor": "@ebizbase/nx-docker:build", "options": { - "tags": ["ghcr.io/ebizbase/node-distroless"], - "outputs": ["type=image"] + "push": true, + "tags": [ + "latest", + "{major}", + "{major}.{minor}", + "{major}.{minor}.{patch}" + ] } } } diff --git a/package.json b/package.json index d820951..06d1094 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,6 @@ "@commitlint/cli": "^19.5.0", "@commitlint/config-conventional": "^19.5.0", "@devcontainers/cli": "^0.72.0", - "@ebizbase/nx-docker": "^2.0.0", "@eslint/js": "^9.8.0", "@jscutlery/semver": "^5.3.1", "@nx/devkit": "20.1.3", diff --git a/packages/nx-docker-e2e/project.json b/packages/nx-docker-e2e/project.json index 113537a..7602500 100644 --- a/packages/nx-docker-e2e/project.json +++ b/packages/nx-docker-e2e/project.json @@ -3,6 +3,10 @@ "$schema": "../../node_modules/nx/schemas/project-schema.json", "sourceRoot": "packages/nx-docker-e2e", "projectType": "application", + "metadata": { + "version": "1.0.0", + "namespace": "nx-docker-e2e" + }, "tags": [], "implicitDependencies": [ "nx-docker" @@ -12,7 +16,7 @@ "executor": "@ebizbase/nx-docker:build", "options": { "tags": [ - "nx-docker-e2e:test" + "test" ], "outputs": [ "type=docker" diff --git a/packages/nx-docker/package.json b/packages/nx-docker/package.json index 228b0d3..d23c5ca 100644 --- a/packages/nx-docker/package.json +++ b/packages/nx-docker/package.json @@ -25,7 +25,8 @@ "dependencies": { "@nx/devkit": "^20.0.0", "@ebizbase/nx-devkit": "1.0.0", - "tslib": "^2.3.0" + "tslib": "^2.3.0", + "semver": "^7.6.3" }, "devDependencies": {}, "publishConfig": { diff --git a/packages/nx-docker/src/executors/build/executor.spec.ts b/packages/nx-docker/src/executors/build/executor.spec.ts index fa039e4..f846c04 100644 --- a/packages/nx-docker/src/executors/build/executor.spec.ts +++ b/packages/nx-docker/src/executors/build/executor.spec.ts @@ -1,232 +1,158 @@ -// executor.spec.ts import { ExecutorContext, logger } from '@nx/devkit'; -import { existsSync } from 'fs'; -import { DockerExecutorSchema } from './schema'; -import executor from './executor'; -import { DockerUtils, ProjectUtils } from '@ebizbase/nx-devkit'; import { execFileSync } from 'child_process'; +import { existsSync, mkdirSync } from 'fs'; +import { DockerUtils } from '@ebizbase/nx-devkit'; +import semverParse from 'semver/functions/parse'; +import executor from './executor'; +import { DockerExecutorSchema } from './schema'; + +jest.mock('@nx/devkit'); +jest.mock('fs'); +jest.mock('child_process'); +jest.mock('semver/functions/parse'); +jest.mock('@ebizbase/nx-devkit', () => { + return { + ProjectUtils: jest.requireActual('@ebizbase/nx-devkit').ProjectUtils, + DockerUtils: jest.fn().mockImplementation(() => ({ + checkDockerInstalled: jest.fn(), + checkBuildxInstalled: jest.fn(), + })), + }; +}); -jest.mock('@nx/devkit', () => ({ - logger: { - error: jest.fn(), - fatal: jest.fn(), - warn: jest.fn(), - info: jest.fn(), - }, -})); - -jest.mock('fs', () => ({ - existsSync: jest.fn(), -})); - -jest.mock('@ebizbase/nx-devkit'); -jest.mock('child_process', () => ({ - execFileSync: jest.fn(), -})); - -describe('executor', () => { - const context: ExecutorContext = { +const mockDockerUtils = DockerUtils as jest.MockedClass; +describe('Docker Executor', () => { + const mockContext: ExecutorContext = { isVerbose: false, + root: '/workspace', projectName: 'test-project', + cwd: '/workspace', projectsConfigurations: { - version: 1, + version: 2, projects: { - 'test-project': { root: '/path/to/test-project' }, + 'test-project': { + root: 'apps/test-project', + sourceRoot: 'apps/test-project/src', + projectType: 'application', + metadata: { + version: '1.0.0', + }, + targets: {}, + }, }, }, - root: '/path/to/root', nxJsonConfiguration: {}, - cwd: '/path/to/root', projectGraph: { nodes: {}, - dependencies: {}, + dependencies: {} }, }; - const options: DockerExecutorSchema = { - file: undefined, - context: undefined, - tags: ['latest'], - args: ['ARG1=value1'], - ci: false, - outputs: ['image'], - flatforms: [], - }; - let dockerUtils: jest.Mocked; - let projectUtils: jest.Mocked; - - beforeEach(() => { - dockerUtils = new DockerUtils() as jest.Mocked; - dockerUtils.checkDockerInstalled.mockReturnValue(true); - dockerUtils.checkBuildxInstalled.mockReturnValue(true); - (DockerUtils as jest.Mock).mockImplementation(() => dockerUtils); + let options: DockerExecutorSchema; - projectUtils = new ProjectUtils(context) as jest.Mocked; - projectUtils.getProjectRoot.mockReturnValue('/path/to/test-project'); - (ProjectUtils as jest.Mock).mockImplementation(() => projectUtils); + beforeEach(() => { + jest.clearAllMocks(); + options = { + version: '1.0.0', + namespace: 'test-namespace', + outputs: ['dist'], + cacheFrom: ['type=local,src=/path/to/dir'], + cacheTo: ['type=local,src=/path/to/dir'], + addHost: ['host:ip'], + allow: ['network:network'], + annotation: ['key=value'], + attest: ['type=local,src=/path/to/dir'], + args: ['key=value'], + labels: { key: 'value' }, + metadataFile: 'metadata.json', + shmSize: '2gb', + ulimit: ['nofile=1024:1024'], + target: 'target', + tags: ['latest', '{major}.{minor}'], + registries: ['registry.example.com'], + file: './Dockerfile', + context: './', + flatforms: ['linux/amd64', 'linux/arm64'], + }; + (logger.info as jest.Mock).mockImplementation(() => { }); + (logger.fatal as jest.Mock).mockImplementation(() => { }); (existsSync as jest.Mock).mockReturnValue(true); - }); - - afterEach(() => { - jest.resetAllMocks(); - }); + (semverParse as jest.Mock).mockImplementation(() => ({ major: 1, minor: 0, patch: 0 })); + (mkdirSync as jest.Mock).mockImplementation(() => { }); - it('should return success when Docker build is successful', async () => { - // Arrange - // Act - const result = await executor(options, context); - // Assert - expect(result).toEqual({ success: true }); - expect(execFileSync).toHaveBeenCalled(); }); - it('should return failure if Docker is not installed', async () => { - // Arrange - dockerUtils.checkDockerInstalled.mockReturnValue(false); - - // Act - const result = await executor(options, context); - - // Assert - expect(result).toEqual({ success: false }); - expect(logger.error).toHaveBeenCalledWith( - 'Docker is not installed or docker daemon is not running' - ); - }); - - it('should warn if buildx is not installed and fallback to docker build', async () => { - // Arrange - dockerUtils.checkBuildxInstalled.mockReturnValue(false); - - // Act - await executor(options, context); - - // Assert - expect(logger.warn).toHaveBeenCalledWith( - 'Buildx is not installed falling back to docker build. Docker buildx is not installed so performance may be degraded' - ); - expect(execFileSync).toHaveBeenCalledWith('docker', expect.arrayContaining(['build']), { - stdio: 'inherit', - cwd: context.root, - }); - }); - - it('should return failure if project name is missing', async () => { - // Arrange - (ProjectUtils as jest.Mock).mockImplementation(() => { - throw new Error('No project name provided'); - }); - - // Act - const result = await executor(options, context); - - // Assert - expect(result).toEqual({ success: false }); - expect(logger.fatal).toHaveBeenCalledWith('No project name provided', expect.any(Error)); + afterAll(() => { + jest.resetAllMocks(); }); - it('should return failure if Dockerfile is missing', async () => { - // Arrange - (existsSync as jest.Mock).mockImplementation((path) => !path.includes('Dockerfile')); + it('should validate options and run docker command successfully', async () => { + mockDockerUtils.mockImplementation(() => ({ + checkDockerInstalled: jest.fn().mockReturnValue(true), + checkBuildxInstalled: jest.fn().mockReturnValue(true), + })); - // Act - const result = await executor(options, context); + const result = await executor(options, mockContext); - // Assert - expect(result).toEqual({ success: false }); - expect(logger.error).toHaveBeenCalledWith( - 'Dockerfile not found at /path/to/test-project/Dockerfile' + expect(execFileSync).toHaveBeenLastCalledWith( + 'docker', + expect.arrayContaining(['buildx', 'build']), + { stdio: 'inherit', cwd: '/workspace' } ); + expect(result.success).toBe(true); }); - it('should return failure if context path is missing', async () => { - // Arrange - (existsSync as jest.Mock).mockImplementation((path) => !path.includes('.')); - - // Act - const result = await executor(options, context); - // Assert - expect(result).toEqual({ success: false }); - expect(logger.error).toHaveBeenCalledWith('Context path not found at .'); - }); + it('should failed when docker and buildx installed', async () => { + mockDockerUtils.mockImplementation(() => ({ + checkDockerInstalled: jest.fn().mockReturnValue(false), + checkBuildxInstalled: jest.fn().mockReturnValue(false), + })); - it('should build Docker image with provided tags and build args', async () => { - // Arrange - const expectedCommand = 'docker'; - const expectedCommandArgs = [ - 'buildx', - 'build', - '--output=image', - '--build-arg', - 'ARG1=value1', - '-t', - 'latest', - '-f', - '/path/to/test-project/Dockerfile', - '.', - ]; - - // Act - await executor(options, context); - - // Assert - expect(execFileSync).toHaveBeenCalledWith(expectedCommand, expectedCommandArgs, { - stdio: 'inherit', - cwd: context.root, - }); + const result = await executor(options, mockContext); + expect(result.success).toBe(false); + expect(logger.fatal).toHaveBeenCalledWith('Docker is not installed or daemon is not running'); }); - it('should log failure if Docker build fails', async () => { - // Arrange - (execFileSync as jest.Mock).mockImplementation(() => { - throw new Error('Docker build failed'); - }); - // Act - const result = await executor(options, context); + it('should failed when not yet installed buildx', async () => { + mockDockerUtils.mockImplementation(() => ({ + checkDockerInstalled: jest.fn().mockReturnValue(true), + checkBuildxInstalled: jest.fn().mockReturnValue(false), + })); - // Assert - expect(result).toEqual({ success: false }); - expect(logger.fatal).toHaveBeenCalledWith('Failed to build Docker image', expect.any(Error)); + const result = await executor(options, mockContext); + expect(result.success).toBe(false); + expect(logger.fatal).toHaveBeenCalledWith('Buildx is not installed'); }); - it('should build Docker image with tag arguments when tags are provided', async () => { - options.tags = ['latest', 'v1.0.0']; - await executor(options, context); - - const expectedTagArgs = ['-t', 'latest', '-t', 'v1.0.0']; - expect(execFileSync).toHaveBeenCalledWith('docker', expect.arrayContaining(expectedTagArgs), { - stdio: 'inherit', - cwd: context.root, - }); + it('should failed when project metadata and executor options not containt version', async () => { + mockDockerUtils.mockImplementation(() => ({ + checkDockerInstalled: jest.fn().mockReturnValue(true), + checkBuildxInstalled: jest.fn().mockReturnValue(true), + })); + mockContext.projectsConfigurations.projects['test-project'].metadata = {}; + options.version = undefined; + const result = await executor(options, mockContext); + expect(result.success).toBe(false); + expect(logger.fatal).toHaveBeenCalledWith('No version provided. Specify in options or metadata of project.json'); }); - it('should build Docker image with build arguments when args are provided', async () => { - options.args = ['ARG1=value1', 'ARG2=value2']; - - await executor(options, context); - - const expectedBuildArgs = ['--build-arg', 'ARG1=value1', '--build-arg', 'ARG2=value2']; - expect(execFileSync).toHaveBeenCalledWith('docker', expect.arrayContaining(expectedBuildArgs), { - stdio: 'inherit', - cwd: context.root, - }); + it('should failed when project metadata and executor options not containt namespace', async () => { + mockDockerUtils.mockImplementation(() => ({ + checkDockerInstalled: jest.fn().mockReturnValue(true), + checkBuildxInstalled: jest.fn().mockReturnValue(true), + })); + options.namespace = undefined; + const result = await executor(options, mockContext); + expect(result.success).toBe(false); + expect(logger.fatal).toHaveBeenCalledWith('Namespace is required'); }); - it('should not add build arguments if args are not provided', async () => { - options.args = undefined; - await executor(options, context); - expect(execFileSync).toHaveBeenCalledWith( - 'docker', - expect.not.arrayContaining(['--build-arg']), - { stdio: 'inherit', cwd: context.root } - ); - }); }); diff --git a/packages/nx-docker/src/executors/build/executor.ts b/packages/nx-docker/src/executors/build/executor.ts index 1034813..a7f66c5 100644 --- a/packages/nx-docker/src/executors/build/executor.ts +++ b/packages/nx-docker/src/executors/build/executor.ts @@ -1,110 +1,131 @@ +import semverParse from 'semver/functions/parse'; import { logger, PromiseExecutor } from '@nx/devkit'; import { dirname, join } from 'path'; import { DockerExecutorSchema } from './schema'; import { DockerUtils, ProjectUtils } from '@ebizbase/nx-devkit'; import { existsSync, mkdirSync } from 'fs'; import { execFileSync } from 'child_process'; +import SemVer from 'semver/classes/semver'; -const executor: PromiseExecutor = async (options, context) => { - const dockerService = new DockerUtils(); - - // Check docker installed and docker daemon is running - if (!dockerService.checkDockerInstalled(context.isVerbose)) { - logger.error('Docker is not installed or docker daemon is not running'); - return { success: false }; - } - - // Determine using build or buildx for building Docker image - const isBuildxInstalled = dockerService.checkBuildxInstalled(context.isVerbose); - if (!isBuildxInstalled) { - logger.warn( - 'Buildx is not installed falling back to docker build. Docker buildx is not installed so performance may be degraded' - ); - } - const buildCommand = isBuildxInstalled ? ['docker', 'buildx', 'build'] : ['docker', 'build']; - - let projectUtils; - try { - projectUtils = new ProjectUtils(context); - } catch (error: unknown) { - logger.fatal('No project name provided', error); - return { success: false }; - } - - const dockerfilePath = options.file || join(projectUtils.getProjectRoot(), 'Dockerfile'); - const contextPath = options.context || '.'; - - // Kiểm tra Dockerfile và context - if (!existsSync(dockerfilePath)) { - logger.error(`Dockerfile not found at ${dockerfilePath}`); - return { success: false }; - } - - if (!existsSync(contextPath)) { - logger.error(`Context path not found at ${contextPath}`); - return { success: false }; +export const paserVersion = (version?: string) => { + console.log('version', version); + if (!version) { + throw new Error('No version provided. Specify in options or metadata of project.json'); + } else { + const semverVersion = semverParse(version); + if (semverVersion === null) { + throw new Error('Error occurred while parsing version'); + } else { + return semverVersion; + } } +}; - // Build tag và build arg +// Helper: Chuẩn bị các argument cho lệnh Docker +const prepareDockerArguments = ( + options: DockerExecutorSchema, + version: SemVer, + dockerfilePath: string, + contextPath: string +) => { const outputArgs = options.outputs ? [`--output=${options.outputs.join(',')}`] : []; - const cacheFromArgs = - options.cacheFrom && options.cacheFrom.length > 0 - ? [`--cache-from=${options.cacheFrom.join(',')}`] - : []; - const cacheToArgs = - options.cacheFrom && options.cacheFrom.length > 0 - ? [`--cache-to=${options.cacheFrom.join(',')}`] - : []; - const flatformsArgs = - options.flatforms && options.flatforms.length > 0 - ? [`--platform=${options.flatforms.join(',')}`] - : []; + const cacheFromArgs = options.cacheFrom?.map((cache) => `--cache-from=${cache}`) || []; + const cacheToArgs = options.cacheTo?.map((cache) => `--cache-to=${cache}`) || []; + const platformsArgs = options.flatforms ? [`--platform=${options.flatforms.join(',')}`] : []; const metadataFileArgs = options.metadataFile ? ['--metadata-file', options.metadataFile] : []; - if (options.metadataFile && !existsSync(options.metadataFile)) { - try { - mkdirSync(dirname(options.metadataFile), { recursive: true }); - } catch (error: unknown) { - logger.fatal('Failed to create metadata file', error); - return { success: false }; - } - } - const tagArgs = options.tags.flatMap((tag) => ['-t', tag]) || []; const buildArgs = options.args?.flatMap((arg) => ['--build-arg', arg]) || []; const addHostArgs = options.addHost?.flatMap((host) => ['--add-host', host]) || []; const allowArgs = options.allow?.flatMap((allow) => ['--allow', allow]) || []; - const annotationArgs = - options.annotation?.flatMap((annotation) => ['--annotation', annotation]) || []; + const annotationArgs = options.annotation?.flatMap((annotation) => ['--annotation', annotation]) || []; const attestArgs = options.attest?.flatMap((attest) => ['--attest', attest]) || []; const shmSizeArgs = options.shmSize ? ['--shm-size', options.shmSize] : []; - const uLimitArgs = options.ulimit ? [`--ulimit${options.ulimit}`] : []; + const uLimitArgs = options.ulimit ? [`--ulimit=${options.ulimit}`] : []; const targetArgs = options.target ? ['--target', options.target] : []; + const labelsArgs = options.labels + ? Object.entries(options.labels) + .map(([key, value]) => ['--label', `${key}=${value}`]) + .flat() + : []; + const tagsArgs = (options.tags || []).flatMap((tag) => + options.registries?.map((registry) => + ['-t', `${registry}/${options.namespace}:${tag.replace(/{major}/g, version.major.toString()).replace(/{minor}/g, version.minor.toString()).replace(/{patch}/g, version.patch.toString())}`] + ) + ).flat() || []; + const loadArgs = options.load ? ['--load'] : []; + const pushArgs = options.push ? ['--push'] : []; + + return [ + ...outputArgs, + ...cacheFromArgs, + ...cacheToArgs, + ...platformsArgs, + ...metadataFileArgs, + ...buildArgs, + ...addHostArgs, + ...allowArgs, + ...annotationArgs, + ...attestArgs, + ...shmSizeArgs, + ...uLimitArgs, + ...tagsArgs, + ...targetArgs, + ...labelsArgs, + ...loadArgs, + ...pushArgs, + '-f', + dockerfilePath, + contextPath, + ]; +}; + +const executor: PromiseExecutor = async (options, context) => { try { - const command = [ - ...buildCommand, - ...outputArgs, - ...cacheFromArgs, - ...cacheToArgs, - ...flatformsArgs, - ...metadataFileArgs, - ...buildArgs, - ...addHostArgs, - ...allowArgs, - ...annotationArgs, - ...attestArgs, - ...shmSizeArgs, - ...uLimitArgs, - ...tagArgs, - ...targetArgs, - '-f', - dockerfilePath, - contextPath, - ]; - logger.info(`${command.join(' ')}\n`); + const dockerService = new DockerUtils(); + const projectUtils = new ProjectUtils(context); + + // Kiểm tra Docker và buildx + if (!dockerService.checkDockerInstalled(context.isVerbose)) { + throw new Error('Docker is not installed or daemon is not running'); + } else if (!dockerService.checkBuildxInstalled(context.isVerbose)) { + throw new Error('Buildx is not installed'); + } + + // Lấy metadata và merge options + const metadata = projectUtils.getMetadata(); + options = { ...options, ...metadata }; + + const version = paserVersion(options.version); + + if(options.namespace === undefined) { + throw new Error('Namespace is required'); + } + + const dockerfilePath = options.file || join(projectUtils.getProjectRoot(), 'Dockerfile'); + const contextPath = options.context || '.'; + + // Kiểm tra Dockerfile và context + if (!existsSync(dockerfilePath)) { + throw new Error(`Dockerfile not found at ${dockerfilePath}`); + } + if (!existsSync(contextPath)) { + throw new Error(`Context path not found at ${contextPath}`); + } + + // Tạo metadata file nếu cần + if (options.metadataFile && !existsSync(options.metadataFile)) { + mkdirSync(dirname(options.metadataFile), { recursive: true }); + } + + const args = prepareDockerArguments(options, version, dockerfilePath, contextPath); + const command = ['docker', 'buildx', 'build', ...args]; + + logger.info(`Executing: ${command.join(' ')}`); execFileSync(command[0], command.slice(1), { stdio: 'inherit', cwd: context.root }); + return { success: true }; } catch (error) { - logger.fatal('Failed to build Docker image', error); + logger.fatal(error instanceof Error ? error.message : 'Unknown error occurred'); return { success: false }; } }; diff --git a/packages/nx-docker/src/executors/build/schema.d.ts b/packages/nx-docker/src/executors/build/schema.d.ts index 368591c..c5c77d8 100644 --- a/packages/nx-docker/src/executors/build/schema.d.ts +++ b/packages/nx-docker/src/executors/build/schema.d.ts @@ -15,4 +15,10 @@ export interface DockerExecutorSchema { ulimit?: string[]; metadataFile?: string; flatforms: string[]; + labels?: { [key: string]: string }; + registries: Array; + version?: string; + namespace?: string; + load: boolean; + push: boolean; } diff --git a/packages/nx-docker/src/executors/build/schema.json b/packages/nx-docker/src/executors/build/schema.json index b72f467..1e00b8f 100644 --- a/packages/nx-docker/src/executors/build/schema.json +++ b/packages/nx-docker/src/executors/build/schema.json @@ -26,6 +26,33 @@ "type": "string", "description": "The context to use for building the image" }, + "registries": { + "type": "array", + "items": { + "type": "string" + }, + "default": ["docker.io"], + "description": "The registry to push the image" + }, + "version": { + "type": "string", + "pattern": "^\\d+\\.\\d+\\.\\d+(-[0-9A-Za-z-.]+)?$", + "description": "Semantic version of the configuration" + }, + "namespace": { + "type": "string", + "description": "Namespace and name for the devcontainer image in the registry" + }, + "load": { + "type": "boolean", + "description": "Load the image into the docker daemon", + "default": false + }, + "push": { + "type": "boolean", + "description": "Push the image to the registry", + "default": false + }, "tags": { "type": "array", "items": { @@ -97,6 +124,13 @@ "flatforms": { "type": "string", "description": "Set the target platform" + }, + "labels": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "The list of labels to add to the image" } }, "required": ["tags"]