Skip to content

Commit

Permalink
feat(nx-internal): add build devcontainer image executor (with publis…
Browse files Browse the repository at this point in the history
…h support)
  • Loading branch information
johnitvn committed Nov 23, 2024
1 parent 7b4ae1c commit b5591da
Show file tree
Hide file tree
Showing 5 changed files with 448 additions and 0 deletions.
5 changes: 5 additions & 0 deletions tools/nx-internal/executors.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
{
"executors": {
"build-devcontainer-image": {
"implementation": "./src/executors/build-devcontainer-image/executor",
"schema": "./src/executors/build-devcontainer-image/schema.json",
"description": "build devcontainer image executor"
},
"test-devcontainer-feature": {
"implementation": "./src/executors/test-devcontainer-feature/executor",
"schema": "./src/executors/test-devcontainer-feature/schema.json",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,282 @@
import { execFileSync } from 'child_process';
import { DockerUtils, ProjectUtils } from '@ebizbase/nx-devkit';
import { ExecutorContext } from '@nx/devkit';
import buildExecutor from './executor';
import { BuildExecutorSchema } from './schema';

jest.mock('child_process');
jest.mock('@ebizbase/nx-devkit');
jest.mock('@nx/devkit');

describe('buildExecutor', () => {
let context: ExecutorContext;
let dlxCommand: string;

beforeEach(() => {
context = {
root: '/root',
isVerbose: false,
projectName: 'test-project',
targetName: 'build',
workspace: {
projects: {
'test-project': {
root: 'apps/test-project',
},
},
},
projectsConfigurations: {
version: 2,
projects: {
'test-project': {
root: 'apps/test-project',
},
},
},
nxJsonConfiguration: {},
cwd: '/root',
projectGraph: {
nodes: {},
dependencies: {},
},
} as ExecutorContext;

dlxCommand = 'npx';
require('@nx/devkit').getPackageManagerCommand.mockReturnValue({ dlx: dlxCommand });
});

afterEach(() => {
jest.resetAllMocks();
});

it('should return success false if Docker is not installed', async () => {
jest.spyOn(DockerUtils.prototype, 'checkDockerInstalled').mockReturnValue(false);

const options: BuildExecutorSchema = {
version: '1.0.0',
namespace: 'test-namespace',
workspaceFolder: '.',
tags: ['latest'],
push: false,
registries: [],
};

const result = await buildExecutor(options, context);
expect(result).toEqual({ success: false });
});

it('should return success false if no version is provided', async () => {
jest.spyOn(DockerUtils.prototype, 'checkDockerInstalled').mockReturnValue(true);
const options: BuildExecutorSchema = {
namespace: 'test-namespace',
workspaceFolder: '.',
tags: ['latest'],
push: false,
registries: [],
version: '',
};

const result = await buildExecutor(options, context);
expect(result).toEqual({ success: false });
});

it('should return success false if invalid version is provided', async () => {
jest.spyOn(DockerUtils.prototype, 'checkDockerInstalled').mockReturnValue(true);
const options: BuildExecutorSchema = {
version: 'invalid-version',
namespace: 'test-namespace',
workspaceFolder: '.',
tags: ['latest'],
push: false,
registries: [],
};

const result = await buildExecutor(options, context);
expect(result).toEqual({ success: false });
});

it('should return success false if no namespace is provided', async () => {
jest.spyOn(DockerUtils.prototype, 'checkDockerInstalled').mockReturnValue(true);
const options: BuildExecutorSchema = {
version: '1.0.0',
workspaceFolder: '.',
tags: ['latest'],
push: false,
registries: [],
namespace: '',
};

const result = await buildExecutor(options, context);
expect(result).toEqual({ success: false });
});

it('should return success false if devcontainer is not installed', async () => {
(execFileSync as jest.Mock).mockImplementation(() => {
throw new Error('devcontainer is not installed');
});

const options: BuildExecutorSchema = {
version: '1.0.0',
namespace: 'test-namespace',
workspaceFolder: '.',
tags: ['latest'],
push: false,
registries: [],
};

const result = await buildExecutor(options, context);
expect(result).toEqual({ success: false });
});

it('should return success false if devcontainer build fails', async () => {
(execFileSync as jest.Mock).mockImplementation(() => {
throw new Error('Failed to build devcontainer');
});

const options: BuildExecutorSchema = {
version: '1.0.0',
namespace: 'test-namespace',
workspaceFolder: '.',
tags: ['latest'],
push: false,
registries: [],
};

const result = await buildExecutor(options, context);
expect(result).toEqual({ success: false });
});

it('should return success false if mising version', async () => {
const options: BuildExecutorSchema = {
namespace: 'test-namespace',
workspaceFolder: '.',
tags: ['latest'],
push: false,
registries: [],
};

const result = await buildExecutor(options, context);
expect(result).toEqual({ success: false });
});

it('should return success false if version is wrong', async () => {
const options: BuildExecutorSchema = {
version: 'wrong-version',
namespace: 'test-namespace',
workspaceFolder: '.',
tags: ['latest'],
push: false,
registries: [],
};

const result = await buildExecutor(options, context);
expect(result).toEqual({ success: false });
});


it('should correctly handle labels', async () => {
jest.spyOn(DockerUtils.prototype, 'checkDockerInstalled').mockReturnValue(true);
jest.spyOn(ProjectUtils.prototype, 'getProjectRoot').mockReturnValue('/root-folder');
(execFileSync as jest.Mock).mockImplementation(() => { });

const options: BuildExecutorSchema = {
version: '1.0.0',
namespace: 'test-namespace',
workspaceFolder: '.',
tags: ['latest'],
push: false,
registries: [],
labels: {
'org.opencontainers.image.source': 'https://github.com/ebizbase/dev-infras',
'org.opencontainers.image.description': 'Base devcontainer image',
},
};

const result = await buildExecutor(options, context);

expect(execFileSync).toHaveBeenCalledWith(
'npx',
expect.arrayContaining([
'--label',
'org.opencontainers.image.source="https://github.com/ebizbase/dev-infras"',
'--label',
'org.opencontainers.image.description="Base devcontainer image"',
]),
expect.any(Object)
);
expect(result).toEqual({ success: true });
});

it('should handle empty tags and labels gracefully', async () => {
jest.spyOn(DockerUtils.prototype, 'checkDockerInstalled').mockReturnValue(true);
jest.spyOn(ProjectUtils.prototype, 'getProjectRoot').mockReturnValue('/root-folder');
(execFileSync as jest.Mock).mockImplementation(() => { });

const options: BuildExecutorSchema = {
version: '1.0.0',
namespace: 'test-namespace',
workspaceFolder: '.',
tags: [],
push: false,
registries: [],
labels: {},
};

const result = await buildExecutor(options, context);

expect(execFileSync).toHaveBeenCalledWith(
'npx',
expect.not.arrayContaining(['--image-name']),
expect.any(Object)
);
expect(result).toEqual({ success: true });
});

it('should include --push if push is true', async () => {
jest.spyOn(DockerUtils.prototype, 'checkDockerInstalled').mockReturnValue(true);
jest.spyOn(ProjectUtils.prototype, 'getProjectRoot').mockReturnValue('/root-folder');
(execFileSync as jest.Mock).mockImplementation(() => { });

const options: BuildExecutorSchema = {
version: '1.0.0',
namespace: 'test-namespace',
workspaceFolder: '.',
tags: ['latest'],
push: true,
registries: [],
};

const result = await buildExecutor(options, context);

expect(execFileSync).toHaveBeenCalledWith(
'npx',
expect.arrayContaining(['--push']),
expect.any(Object)
);
expect(result).toEqual({ success: true });
});

it('should correctly set custom workspace folder', async () => {
jest.spyOn(DockerUtils.prototype, 'checkDockerInstalled').mockReturnValue(true);
jest.spyOn(ProjectUtils.prototype, 'getProjectRoot').mockReturnValue('/root-folder');
(execFileSync as jest.Mock).mockImplementation(() => { });

const options: BuildExecutorSchema = {
version: '1.0.0',
namespace: 'test-namespace',
workspaceFolder: 'custom-folder',
tags: ['latest'],
push: false,
registries: [],
};

const result = await buildExecutor(options, context);

expect(execFileSync).toHaveBeenCalledWith(
'npx',
expect.arrayContaining(['--workspace-folder=/root-folder/custom-folder']),
expect.any(Object)
);
expect(result).toEqual({ success: true });
});
});
101 changes: 101 additions & 0 deletions tools/nx-internal/src/executors/build-devcontainer-image/executor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { DockerUtils, ProjectUtils } from '@ebizbase/nx-devkit';
import { getPackageManagerCommand, logger, PromiseExecutor } from '@nx/devkit';
import { BuildExecutorSchema } from './schema';
import { execFileSync } from 'child_process';
import semverParse from 'semver/functions/parse';
import semverValid from 'semver/functions/valid';
import { join } from 'path';

const buildExecutor: PromiseExecutor<BuildExecutorSchema> = async (options, context) => {
const dockerService = new DockerUtils();

if (!dockerService.checkDockerInstalled(context.isVerbose)) {
logger.error('Docker is not installed or docker daemon is not running');
return { success: false };
}

let projectUtils;
try {
projectUtils = new ProjectUtils(context);
} catch (error: unknown) {
logger.fatal('No project name provided', error);
return { success: false };
}

const metadata = projectUtils.getMetadata();
if (metadata) {
options = { ...options, ...metadata };
}

if (!options.version) {
logger.fatal(
'No version provided. You must specify a version in executor options or in metadata of project.json'
);
return { success: false };
} else if (!semverValid(options.version)) {
logger.fatal('Invalid version provided');
return { success: false };
} else if (!options.namespace) {
logger.fatal(
'No namespace provided. You must specify a namespace in executor options or in metadata of project.json'
);
return { success: false };
}

const version = semverParse(options.version);
if (version === null) {
logger.fatal('Error occurred while parsing version');
return { success: false };
}

const dlxCommand = getPackageManagerCommand().dlx;
try {
execFileSync(dlxCommand, ['devcontainer', '--version'], {
stdio: context.isVerbose ? 'inherit' : 'ignore',
});
} catch (e) {
logger.fatal('devcontainer is not installed', e);
return { success: false };
}

const workspaceFolderArgs = [
`--workspace-folder=${join(projectUtils.getProjectRoot(), options.workspaceFolder ?? '.')}`,
];
options.tags = (options.tags ?? []).map((tag) =>
tag
.replace(/{major}/g, version.major.toString())
.replace(/{minor}/g, version.minor.toString())
.replace(/{patch}/g, version.patch.toString())
);
const tagsArgs = options.tags
.map((tag) => options.registries.map((registry) => `${registry}/${options.namespace}:${tag}`))
.flat()
.map((tag) => ['--image-name', tag])
.flat();
const labelsArgs = options.labels
? Object.entries(options.labels)
.map(([key, value]) => ['--label', `${key}="${value}"`])
.flat()
: [];
const pushArgs = options.push ? ['--push'] : [];

try {
const command = [
dlxCommand,
'devcontainer',
'build',
...workspaceFolderArgs,
...tagsArgs,
...pushArgs,
...labelsArgs,
];
logger.info(`${command.join(' ')}\n`);
execFileSync(command[0], command.slice(1), { stdio: 'inherit', cwd: context.root });
return { success: true };
} catch (err) {
logger.fatal('Failed to build devcontainer', err);
return { success: false };
}
};

export default buildExecutor;
Loading

0 comments on commit b5591da

Please sign in to comment.