Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -44,18 +44,18 @@
"axios": "^1.8.3",
"fs-extra": "^11.2.0",
"inversify": "^7.1.0",
"lodash": "^4.17.21",
"js-yaml": "^4.0.0",
"jsonc-parser": "^3.0.0",
"jsonschema": "^1.4.1",
"lodash": "^4.17.21",
"reflect-metadata": "^0.2.2"
},
"devDependencies": {
"@types/jest": "^29.5.14",
"eslint": "^9.5.0",
"if-env": "^1.0.4",
"jest": "^29.7.0",
"prettier": "^3.3.2",
"prettier": "^3.5.3",
"rimraf": "^6.0.1",
"rollup": "^4.18.0",
"ts-jest": "^29.2.6",
Expand Down
260 changes: 260 additions & 0 deletions src/devcontainers/dev-containers-to-devfile-adapter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
/**********************************************************************
* Copyright (c) 2022-2024
*
* This program and the accompanying materials are made
* available under the terms of the Eclipse Public License 2.0
* which is available at https://www.eclipse.org/legal/epl-2.0/
*
* SPDX-License-Identifier: EPL-2.0
***********************************************************************/

import yaml from 'js-yaml';

const DEFAULT_DEVFILE_CONTAINER_IMAGE = 'quay.io/devfile/universal-developer-image:ubi9-latest';
const DEFAULT_DEVFILE_NAME = 'default-devfile';
const DEFAULT_WORKSPACE_DIR = '/projects';

export function convertDevContainerToDevfile(devContainer: any): string {
const devfile: any = {
schemaVersion: '2.2.0',
metadata: {
name: (devContainer.name ?? DEFAULT_DEVFILE_NAME).toLowerCase().replace(/\s+/g, '-'),
description: devContainer.description ?? '',
},
components: [],
commands: [],
events: {},
};
const workspaceFolder = devContainer.workspaceFolder ?? DEFAULT_WORKSPACE_DIR;

const containerName = 'dev-container';
const containerComponent: any = {
name: containerName,
container: {
image: devContainer.image ?? DEFAULT_DEVFILE_CONTAINER_IMAGE,
},
};

if (Array.isArray(devContainer.forwardPorts)) {
containerComponent.container.endpoints = convertPortsToEndpoints(devContainer.forwardPorts);
}

const remoteEnvMap = convertToDevfileEnv(devContainer.remoteEnv);
const containerEnvMap = convertToDevfileEnv(devContainer.containerEnv);
const combinedEnvMap = new Map(remoteEnvMap);
for (const [key, value] of containerEnvMap) {
combinedEnvMap.set(key, value);
}
containerComponent.container.env = Array.from(combinedEnvMap.entries()).map(([name, value]) => ({
name,
value,
}));

if (devContainer.overrideCommand) {
containerComponent.container.command = ['/bin/bash'];
containerComponent.container.args = ['-c', 'while true; do sleep 1000; done'];
}

devfile.commands.postStart = [];
devfile.events.postStart = [];
const postStartCommands: { key: keyof typeof devContainer; id: string }[] = [
{ key: 'onCreateCommand', id: 'on-create-command' },
{ key: 'updateContentCommand', id: 'update-content-command' },
{ key: 'postCreateCommand', id: 'post-create-command' },
{ key: 'postStartCommand', id: 'post-start-command' },
{ key: 'postAttachCommand', id: 'post-attach-command' },
];

for (const { key, id } of postStartCommands) {
const commandValue = devContainer[key];
if (commandValue) {
devfile.commands.push(createDevfileCommand(id, containerComponent.name, commandValue, workspaceFolder));
devfile.events.postStart.push(id);
}
}

if (devContainer.initializeCommand) {
const commandId = 'initialize-command';
devfile.commands.push(
createDevfileCommand(commandId, containerComponent.name, devContainer.initializeCommand, workspaceFolder),
);
devfile.events.preStart = [commandId];
}

if (devContainer.hostRequirements) {
if (devContainer.hostRequirements.cpus) {
containerComponent.container.cpuRequest = devContainer.hostRequirements.cpus;
}
if (devContainer.hostRequirements.memory) {
containerComponent.container.memoryRequest = convertMemoryToDevfileFormat(devContainer.hostRequirements.memory);
}
}

if (devContainer.workspaceFolder) {
containerComponent.container.mountSources = true;
containerComponent.container.sourceMapping = devContainer.workspaceFolder;
}

const volumeComponents: any[] = [];
const volumeMounts: any[] = [];
if (devContainer.mounts) {
devContainer.mounts.forEach((mount: string) => {
const convertedDevfileVolume = createDevfileVolumeMount(mount);

if (convertedDevfileVolume) {
volumeComponents.push(convertedDevfileVolume.volumeComponent);
volumeMounts.push(convertedDevfileVolume.volumeMount);
}
});
}
if (volumeMounts.length > 0) {
containerComponent.container.volumeMounts = volumeMounts;
}

let imageComponent: any = null;
if (devContainer.build) {
imageComponent = createDevfileImageComponent(devContainer);
}

if (imageComponent) {
devfile.components.push(imageComponent);
} else {
devfile.components.push(containerComponent);
}
devfile.components.push(...volumeComponents);

return yaml.dump(devfile, { noRefs: true });
}

function convertMemoryToDevfileFormat(value: string): string {
const unitMap: Record<string, string> = { tb: 'TiB', gb: 'GiB', mb: 'MiB', kb: 'KiB' };
for (const [decUnit, binUnit] of Object.entries(unitMap)) {
if (value.toLowerCase().endsWith(decUnit)) {
value = value.toLowerCase().replace(decUnit, binUnit);
break;
}
}
return value;
}

function convertToDevfileEnv(envObject: Record<string, any> | undefined): Map<string, string> {
const result = new Map<string, string>();

if (!envObject || typeof envObject !== 'object') {
return result;
}

for (const [key, value] of Object.entries(envObject)) {
result.set(key, String(value));
}

return result;
}

function parsePortValue(port: number | string): number | null {
if (typeof port === 'number') return port;

// Example: "db:5432" => extract 5432
const match = RegExp(/.*:(\d+)$/).exec(port);
return match ? parseInt(match[1], 10) : null;
}

function convertPortsToEndpoints(ports: (number | string)[]): { name: string; targetPort: number }[] {
return ports
.map(port => {
const targetPort = parsePortValue(port);
if (targetPort === null) return null;

let portName = `port-${targetPort}`;
if (typeof port === 'string' && port.includes(':')) {
portName = port.split(':')[0];
}
return { name: portName, targetPort };
})
.filter((ep): ep is { name: string; targetPort: number } => ep !== null);
}

function createDevfileCommand(
id: string,
component: string,
commandLine: string | string[] | Record<string, any>,
workingDir: string,
) {
let resolvedCommandLine: string;
if (typeof commandLine === 'string') {
resolvedCommandLine = commandLine;
} else if (Array.isArray(commandLine)) {
resolvedCommandLine = commandLine.join(' ');
} else if (typeof commandLine === 'object') {
const values = Object.values(commandLine).map(v => {
if (typeof v === 'string') {
return v.trim();
} else if (Array.isArray(v)) {
return v.join(' ');
}
});
resolvedCommandLine = values.join(' && ');
}

return {
id: id,
exec: {
component: component,
commandLine: resolvedCommandLine,
workingDir: workingDir,
},
};
}

function createDevfileVolumeMount(mount: string) {
// Format: source=devvolume,target=/data,type=volume
const parts = Object.fromEntries(
mount.split(',').map(segment => {
const [key, val] = segment.split('=');
return [key.trim(), val.trim()];
}),
);

const { type, source, target } = parts;

if (!source || !target || !type || type === 'bind') {
return null;
}

const isEphemeral = type === 'tmpfs';

return {
volumeComponent: {
name: source,
volume: {
ephemeral: isEphemeral,
},
},
volumeMount: {
name: source,
path: target,
},
};
}

function createDevfileImageComponent(devcontainer: Record<string, any> | undefined) {
let imageComponent = {
imageName: '',
dockerfile: {
uri: '',
buildContext: '',
args: [],
},
};
imageComponent.imageName = (devcontainer.name ?? 'default-devfile-image').toLowerCase().replace(/\s+/g, '-');
if (devcontainer.build.dockerfile) {
imageComponent.dockerfile.uri = devcontainer.build.dockerfile;
}
if (devcontainer.build.context) {
imageComponent.dockerfile.buildContext = devcontainer.build.context;
}
if (devcontainer.build.args) {
imageComponent.dockerfile.args = Object.entries(devcontainer.build.args).map(([key, value]) => `${key}=${value}`);
}
return imageComponent;
}
20 changes: 15 additions & 5 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { V1alpha2DevWorkspaceSpecTemplate } from '@devfile/api';
import { DevfileContext } from './api/devfile-context';
import { GitUrlResolver } from './resolve/git-url-resolver';
import { ValidatorResult } from 'jsonschema';
import { convertDevContainerToDevfile } from './devcontainers/dev-containers-to-devfile-adapter';

export const DEVWORKSPACE_DEVFILE = 'che.eclipse.org/devfile';
export const DEVWORKSPACE_DEVFILE_SOURCE = 'che.eclipse.org/devfile-source';
Expand All @@ -37,6 +38,7 @@ export class Main {
devfilePath?: string;
devfileUrl?: string;
devfileContent?: string;
devContainerJsonContent?: string;
outputFile?: string;
editorPath?: string;
editorContent?: string;
Expand All @@ -50,8 +52,8 @@ export class Main {
if (!params.editorPath && !params.editorUrl && !params.editorContent) {
throw new Error('missing editorPath or editorUrl or editorContent');
}
if (!params.devfilePath && !params.devfileUrl && !params.devfileContent) {
throw new Error('missing devfilePath or devfileUrl or devfileContent');
if (!params.devfilePath && !params.devfileUrl && !params.devfileContent && !params.devContainerJsonContent) {
throw new Error('missing devfilePath or devfileUrl or devfileContent or devContainerJsonContent');
}

const inversifyBinbding = new InversifyBinding();
Expand Down Expand Up @@ -102,8 +104,11 @@ export class Main {
devfileContent = jsYaml.dump(devfileParsed);
} else if (params.devfilePath) {
devfileContent = await fs.readFile(params.devfilePath);
} else {
} else if (params.devfileContent) {
devfileContent = params.devfileContent;
} else if (params.devContainerJsonContent) {
const devContainer = JSON.parse(params.devContainerJsonContent);
devfileContent = convertDevContainerToDevfile(devContainer);
}

const jsYamlDevfileContent = jsYaml.load(devfileContent);
Expand Down Expand Up @@ -182,6 +187,7 @@ export class Main {
let editorUrl: string | undefined;
let injectDefaultComponent: string | undefined;
let defaultComponentImage: string | undefined;
let devContainerJsonContent: string | undefined;
const projects: { name: string; location: string }[] = [];

const args = process.argv.slice(2);
Expand All @@ -201,6 +207,9 @@ export class Main {
if (arg.startsWith('--output-file:')) {
outputFile = arg.substring('--output-file:'.length);
}
if (arg.startsWith('--devcontainer-json:')) {
devContainerJsonContent = arg.substring('--devcontainer-json:'.length);
}
if (arg.startsWith('--project.')) {
const name = arg.substring('--project.'.length, arg.indexOf('='));
let location = arg.substring(arg.indexOf('=') + 1);
Expand All @@ -220,8 +229,8 @@ export class Main {
if (!editorPath && !editorUrl) {
throw new Error('missing --editor-path: or --editor-url: parameter');
}
if (!devfilePath && !devfileUrl) {
throw new Error('missing --devfile-path: or --devfile-url: parameter');
if (!devfilePath && !devfileUrl && !devContainerJsonContent) {
throw new Error('missing --devfile-path: or --devfile-url: parameter or --devcontainer-json: parameter');
}
if (!outputFile) {
throw new Error('missing --output-file: parameter');
Expand All @@ -231,6 +240,7 @@ export class Main {
devfilePath,
devfileUrl,
editorPath,
devContainerJsonContent: devContainerJsonContent,
outputFile,
editorUrl,
projects,
Expand Down
Loading