Skip to content

Commit 499285e

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

20 files changed

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

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,

0 commit comments

Comments
 (0)