Skip to content

Commit 2e69ba2

Browse files
committed
feat : Initial support for devcontainer.json
Signed-off-by: Rohan Kumar <[email protected]>
1 parent c685e0b commit 2e69ba2

20 files changed

+586
-15
lines changed

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,18 +44,18 @@
4444
"axios": "^1.8.3",
4545
"fs-extra": "^11.2.0",
4646
"inversify": "^7.1.0",
47-
"lodash": "^4.17.21",
4847
"js-yaml": "^4.0.0",
4948
"jsonc-parser": "^3.0.0",
5049
"jsonschema": "^1.4.1",
50+
"lodash": "^4.17.21",
5151
"reflect-metadata": "^0.2.2"
5252
},
5353
"devDependencies": {
5454
"@types/jest": "^29.5.14",
5555
"eslint": "^9.5.0",
5656
"if-env": "^1.0.4",
5757
"jest": "^29.7.0",
58-
"prettier": "^3.3.2",
58+
"prettier": "^3.5.3",
5959
"rimraf": "^6.0.1",
6060
"rollup": "^4.18.0",
6161
"ts-jest": "^29.2.6",
Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
import yaml from 'js-yaml';
2+
3+
const DEFAULT_DEVFILE_CONTAINER_IMAGE = 'quay.io/devfile/universal-developer-image:ubi9-latest';
4+
const DEFAULT_DEVFILE_NAME = 'default-devfile';
5+
const DEFAULT_WORKSPACE_DIR = '/projects';
6+
7+
export function convertDevContainerToDevfile(devContainer: any): string {
8+
const devfile: any = {
9+
schemaVersion: '2.2.0',
10+
metadata: {
11+
name: (devContainer.name ?? DEFAULT_DEVFILE_NAME).toLowerCase().replace(/\s+/g, '-'),
12+
description: devContainer.description ?? '',
13+
},
14+
components: [],
15+
commands: [],
16+
events: {},
17+
};
18+
const workspaceFolder = devContainer.workspaceFolder ?? DEFAULT_WORKSPACE_DIR;
19+
20+
const containerName = 'dev-container';
21+
const containerComponent: any = {
22+
name: containerName,
23+
container: {
24+
image: devContainer.image ?? DEFAULT_DEVFILE_CONTAINER_IMAGE,
25+
},
26+
};
27+
28+
if (Array.isArray(devContainer.forwardPorts)) {
29+
containerComponent.container.endpoints = convertPortsToEndpoints(devContainer.forwardPorts);
30+
}
31+
32+
const remoteEnvMap = convertToDevfileEnv(devContainer.remoteEnv);
33+
const containerEnvMap = convertToDevfileEnv(devContainer.containerEnv);
34+
const combinedEnvMap = new Map(remoteEnvMap);
35+
for (const [key, value] of containerEnvMap) {
36+
combinedEnvMap.set(key, value);
37+
}
38+
containerComponent.container.env = Array.from(combinedEnvMap.entries()).map(([name, value]) => ({
39+
name,
40+
value,
41+
}));
42+
43+
if (devContainer.overrideCommand) {
44+
containerComponent.container.command = ['/bin/bash'];
45+
containerComponent.container.args = ['-c', 'while true; do sleep 1000; done'];
46+
}
47+
48+
devfile.commands.postStart = [];
49+
devfile.events.postStart = [];
50+
const postStartCommands: { key: keyof typeof devContainer; id: string }[] = [
51+
{ key: 'onCreateCommand', id: 'on-create-command' },
52+
{ key: 'updateContentCommand', id: 'update-content-command' },
53+
{ key: 'postCreateCommand', id: 'post-create-command' },
54+
{ key: 'postStartCommand', id: 'post-start-command' },
55+
{ key: 'postAttachCommand', id: 'post-attach-command' },
56+
];
57+
58+
for (const { key, id } of postStartCommands) {
59+
const commandValue = devContainer[key];
60+
if (commandValue) {
61+
devfile.commands.push(createDevfileCommand(id, containerComponent.name, commandValue, workspaceFolder));
62+
devfile.events.postStart.push(id);
63+
}
64+
}
65+
66+
if (devContainer.initializeCommand) {
67+
const commandId = 'initialize-command';
68+
devfile.commands.push(
69+
createDevfileCommand(commandId, containerComponent.name, devContainer.initializeCommand, workspaceFolder),
70+
);
71+
devfile.events.preStart = [commandId];
72+
}
73+
74+
if (devContainer.hostRequirements) {
75+
if (devContainer.hostRequirements.cpus) {
76+
containerComponent.container.cpuRequest = devContainer.hostRequirements.cpus;
77+
}
78+
if (devContainer.hostRequirements.memory) {
79+
containerComponent.container.memoryRequest = convertMemoryToDevfileFormat(devContainer.hostRequirements.memory);
80+
}
81+
}
82+
83+
if (devContainer.workspaceFolder) {
84+
containerComponent.container.mountSources = true;
85+
containerComponent.container.sourceMapping = devContainer.workspaceFolder;
86+
}
87+
88+
const volumeComponents: any[] = [];
89+
const volumeMounts: any[] = [];
90+
if (devContainer.mounts) {
91+
devContainer.mounts.forEach((mount: string) => {
92+
const convertedDevfileVolume = createDevfileVolumeMount(mount);
93+
94+
if (convertedDevfileVolume) {
95+
volumeComponents.push(convertedDevfileVolume.volumeComponent);
96+
volumeMounts.push(convertedDevfileVolume.volumeMount);
97+
}
98+
});
99+
}
100+
if (volumeMounts.length > 0) {
101+
containerComponent.container.volumeMounts = volumeMounts;
102+
}
103+
104+
let imageComponent: any = null;
105+
if (devContainer.build) {
106+
imageComponent = createDevfileImageComponent(devContainer);
107+
}
108+
109+
if (imageComponent) {
110+
devfile.components.push(imageComponent);
111+
} else {
112+
devfile.components.push(containerComponent);
113+
}
114+
devfile.components.push(...volumeComponents);
115+
116+
return yaml.dump(devfile, { noRefs: true });
117+
}
118+
119+
function convertMemoryToDevfileFormat(value: string): string {
120+
const unitMap: Record<string, string> = { tb: 'TiB', gb: 'GiB', mb: 'MiB', kb: 'KiB' };
121+
for (const [decUnit, binUnit] of Object.entries(unitMap)) {
122+
if (value.toLowerCase().endsWith(decUnit)) {
123+
value = value.toLowerCase().replace(decUnit, binUnit);
124+
break;
125+
}
126+
}
127+
return value;
128+
}
129+
130+
function convertToDevfileEnv(envObject: Record<string, any> | undefined): Map<string, string> {
131+
const result = new Map<string, string>();
132+
133+
if (!envObject || typeof envObject !== 'object') {
134+
return result;
135+
}
136+
137+
for (const [key, value] of Object.entries(envObject)) {
138+
result.set(key, String(value));
139+
}
140+
141+
return result;
142+
}
143+
144+
function parsePortValue(port: number | string): number | null {
145+
if (typeof port === 'number') return port;
146+
147+
// Example: "db:5432" => extract 5432
148+
const match = RegExp(/.*:(\d+)$/).exec(port);
149+
return match ? parseInt(match[1], 10) : null;
150+
}
151+
152+
function convertPortsToEndpoints(ports: (number | string)[]): { name: string; targetPort: number }[] {
153+
return ports
154+
.map(port => {
155+
const targetPort = parsePortValue(port);
156+
if (targetPort === null) return null;
157+
158+
let portName = `port-${targetPort}`;
159+
if (typeof port === 'string' && port.includes(':')) {
160+
portName = port.split(':')[0];
161+
}
162+
return { name: portName, targetPort };
163+
})
164+
.filter((ep): ep is { name: string; targetPort: number } => ep !== null);
165+
}
166+
167+
function createDevfileCommand(
168+
id: string,
169+
component: string,
170+
commandLine: string | string[] | Record<string, any>,
171+
workingDir: string,
172+
) {
173+
let resolvedCommandLine: string;
174+
if (typeof commandLine === 'string') {
175+
resolvedCommandLine = commandLine;
176+
} else if (Array.isArray(commandLine)) {
177+
resolvedCommandLine = commandLine.join(' ');
178+
} else if (typeof commandLine === 'object') {
179+
const values = Object.values(commandLine).map(v => {
180+
if (typeof v === 'string') {
181+
return v.trim();
182+
} else if (Array.isArray(v)) {
183+
return v.join(' ');
184+
}
185+
});
186+
resolvedCommandLine = values.join(' && ');
187+
}
188+
189+
return {
190+
id: id,
191+
exec: {
192+
component: component,
193+
commandLine: resolvedCommandLine,
194+
workingDir: workingDir,
195+
},
196+
};
197+
}
198+
199+
function createDevfileVolumeMount(mount: string) {
200+
// Format: source=devvolume,target=/data,type=volume
201+
const parts = Object.fromEntries(
202+
mount.split(',').map(segment => {
203+
const [key, val] = segment.split('=');
204+
return [key.trim(), val.trim()];
205+
}),
206+
);
207+
208+
const { type, source, target } = parts;
209+
210+
if (!source || !target || !type || type === 'bind') {
211+
return null;
212+
}
213+
214+
const isEphemeral = type === 'tmpfs';
215+
216+
return {
217+
volumeComponent: {
218+
name: source,
219+
volume: {
220+
ephemeral: isEphemeral,
221+
},
222+
},
223+
volumeMount: {
224+
name: source,
225+
path: target,
226+
},
227+
};
228+
}
229+
230+
function createDevfileImageComponent(devcontainer: Record<string, any> | undefined) {
231+
let imageComponent = {
232+
imageName: '',
233+
dockerfile: {
234+
uri: '',
235+
buildContext: '',
236+
args: [],
237+
},
238+
};
239+
imageComponent.imageName = (devcontainer.name ?? 'default-devfile-image').toLowerCase().replace(/\s+/g, '-');
240+
if (devcontainer.build.dockerfile) {
241+
imageComponent.dockerfile.uri = devcontainer.build.dockerfile;
242+
}
243+
if (devcontainer.build.context) {
244+
imageComponent.dockerfile.buildContext = devcontainer.build.context;
245+
}
246+
if (devcontainer.build.args) {
247+
imageComponent.dockerfile.args = Object.entries(devcontainer.build.args).map(([key, value]) => `${key}=${value}`);
248+
}
249+
return imageComponent;
250+
}

src/main.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { V1alpha2DevWorkspaceSpecTemplate } from '@devfile/api';
2020
import { DevfileContext } from './api/devfile-context';
2121
import { GitUrlResolver } from './resolve/git-url-resolver';
2222
import { ValidatorResult } from 'jsonschema';
23+
import { convertDevContainerToDevfile } from './devcontainers/dev-containers-to-devfile-adapter';
2324

2425
export const DEVWORKSPACE_DEVFILE = 'che.eclipse.org/devfile';
2526
export const DEVWORKSPACE_DEVFILE_SOURCE = 'che.eclipse.org/devfile-source';
@@ -37,6 +38,7 @@ export class Main {
3738
devfilePath?: string;
3839
devfileUrl?: string;
3940
devfileContent?: string;
41+
devContainerJsonContent?: string;
4042
outputFile?: string;
4143
editorPath?: string;
4244
editorContent?: string;
@@ -50,8 +52,8 @@ export class Main {
5052
if (!params.editorPath && !params.editorUrl && !params.editorContent) {
5153
throw new Error('missing editorPath or editorUrl or editorContent');
5254
}
53-
if (!params.devfilePath && !params.devfileUrl && !params.devfileContent) {
54-
throw new Error('missing devfilePath or devfileUrl or devfileContent');
55+
if (!params.devfilePath && !params.devfileUrl && !params.devfileContent && !params.devContainerJsonContent) {
56+
throw new Error('missing devfilePath or devfileUrl or devfileContent or devContainerJsonContent');
5557
}
5658

5759
const inversifyBinbding = new InversifyBinding();
@@ -102,8 +104,11 @@ export class Main {
102104
devfileContent = jsYaml.dump(devfileParsed);
103105
} else if (params.devfilePath) {
104106
devfileContent = await fs.readFile(params.devfilePath);
105-
} else {
107+
} else if (params.devfileContent) {
106108
devfileContent = params.devfileContent;
109+
} else if (params.devContainerJsonContent) {
110+
const devContainer = JSON.parse(params.devContainerJsonContent);
111+
devfileContent = convertDevContainerToDevfile(devContainer);
107112
}
108113

109114
const jsYamlDevfileContent = jsYaml.load(devfileContent);
@@ -182,6 +187,7 @@ export class Main {
182187
let editorUrl: string | undefined;
183188
let injectDefaultComponent: string | undefined;
184189
let defaultComponentImage: string | undefined;
190+
let devContainerJsonContent: string | undefined;
185191
const projects: { name: string; location: string }[] = [];
186192

187193
const args = process.argv.slice(2);
@@ -201,6 +207,9 @@ export class Main {
201207
if (arg.startsWith('--output-file:')) {
202208
outputFile = arg.substring('--output-file:'.length);
203209
}
210+
if (arg.startsWith('--devcontainer-json:')) {
211+
devContainerJsonContent = arg.substring('--devcontainer-json:'.length);
212+
}
204213
if (arg.startsWith('--project.')) {
205214
const name = arg.substring('--project.'.length, arg.indexOf('='));
206215
let location = arg.substring(arg.indexOf('=') + 1);
@@ -220,8 +229,8 @@ export class Main {
220229
if (!editorPath && !editorUrl) {
221230
throw new Error('missing --editor-path: or --editor-url: parameter');
222231
}
223-
if (!devfilePath && !devfileUrl) {
224-
throw new Error('missing --devfile-path: or --devfile-url: parameter');
232+
if (!devfilePath && !devfileUrl && !devContainerJsonContent) {
233+
throw new Error('missing --devfile-path: or --devfile-url: parameter or --devcontainer-json: parameter');
225234
}
226235
if (!outputFile) {
227236
throw new Error('missing --output-file: parameter');
@@ -231,6 +240,7 @@ export class Main {
231240
devfilePath,
232241
devfileUrl,
233242
editorPath,
243+
devContainerJsonContent: devContainerJsonContent,
234244
outputFile,
235245
editorUrl,
236246
projects,
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { convertDevContainerToDevfile } from '../../src/devcontainers/dev-containers-to-devfile-adapter';
2+
import * as fs from 'fs/promises';
3+
import * as path from 'path';
4+
import * as yaml from 'js-yaml';
5+
6+
describe('convertDevContainerToDevfile - integration test from files', () => {
7+
const testCases = [
8+
'basic-node',
9+
'minimal',
10+
'host-requirements',
11+
'override-command',
12+
'dockerfile',
13+
'lifecycle-scripts',
14+
'unsupported-fields',
15+
];
16+
17+
test.each(testCases)('test case: %s', async testCaseName => {
18+
const baseDir = path.join(__dirname, 'testdata', testCaseName);
19+
20+
const devContainerPath = path.join(baseDir, 'input-devcontainer.json');
21+
const expectedYamlPath = path.join(baseDir, 'expected.yaml');
22+
23+
const devContainerContent = await fs.readFile(devContainerPath, 'utf-8');
24+
const expectedYamlContent = await fs.readFile(expectedYamlPath, 'utf-8');
25+
26+
const devContainer = JSON.parse(devContainerContent);
27+
const expectedDevfile = yaml.load(expectedYamlContent);
28+
const actualDevfileYaml = convertDevContainerToDevfile(devContainer);
29+
const actualDevfile = yaml.load(actualDevfileYaml);
30+
31+
expect(actualDevfile).toMatchObject(expectedDevfile);
32+
});
33+
});

0 commit comments

Comments
 (0)