-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(nx-internal): add build devcontainer image executor (with publis…
…h support)
- Loading branch information
Showing
5 changed files
with
448 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
282 changes: 282 additions & 0 deletions
282
tools/nx-internal/src/executors/build-devcontainer-image/executor.spec.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
101
tools/nx-internal/src/executors/build-devcontainer-image/executor.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; |
Oops, something went wrong.