From edb7a4861ae691f841ef4e43dec261b752114ecc Mon Sep 17 00:00:00 2001 From: Max Batleforc Date: Wed, 8 Jul 2026 18:13:37 +0000 Subject: [PATCH] feat: add clone path and related test Signed-off-by: Max Batleforc Assisted-by: CodeRabbit review --- launcher/src/code-workspace.ts | 22 +- launcher/src/flattened-devfile.ts | 1 + ...lattened.devworkspace.with-clone-path.yaml | 172 ++++++++++++++++ launcher/tests/code-workspace.spec.ts | 191 +++++++++++++++++- 4 files changed, 382 insertions(+), 4 deletions(-) create mode 100644 launcher/tests/_data/flattened.devworkspace.with-clone-path.yaml diff --git a/launcher/src/code-workspace.ts b/launcher/src/code-workspace.ts index bd97c3bc634..7024f5fc3e9 100644 --- a/launcher/src/code-workspace.ts +++ b/launcher/src/code-workspace.ts @@ -11,6 +11,7 @@ import * as fs from './fs-extra.js'; import { env } from 'process'; import { FlattenedDevfile, Project } from './flattened-devfile.js'; +import path from 'path'; export interface Workspace { folders: Folder[]; @@ -68,7 +69,8 @@ export class CodeWorkspace { // if there is only one project, try to find the workspace file if (!path && devfile.projects && devfile.projects.length === 1) { const project = devfile.projects[0]; - const toFind = `${env.PROJECTS_ROOT}/${project.name}/.code-workspace`; + const pathProject = project.clonePath || project.name; + const toFind = `${env.PROJECTS_ROOT}/${pathProject}/.code-workspace`; try { if (await this.fileExists(toFind)) { @@ -152,12 +154,26 @@ export class CodeWorkspace { let synchronized = false; + if (!env.PROJECTS_ROOT) { + console.log(' > env.PROJECTS_ROOT is not set, skip project assertion'); + return false; + } + + const basePath = path.resolve(env.PROJECTS_ROOT); + for (const project of projects) { - if (await fs.pathExists(`${env.PROJECTS_ROOT}/${project.name}`)) { + const pathProject = project.clonePath || project.name; + const fullPath = path.resolve(basePath, pathProject); + + if (!fullPath.startsWith(basePath) || !path.isAbsolute(fullPath) || fullPath.startsWith('..')) { + console.log(`> Skipping project ${project.name}: clonePath escapes projects root`); + continue; + } + if (await fs.pathExists(fullPath)) { if (!workspace.folders.some((folder) => folder.name === project.name)) { workspace.folders.push({ name: project.name, - path: `${env.PROJECTS_ROOT}/${project.name}`, + path: fullPath, }); synchronized = true; diff --git a/launcher/src/flattened-devfile.ts b/launcher/src/flattened-devfile.ts index a0c477ca6ac..360b5d6b5e7 100644 --- a/launcher/src/flattened-devfile.ts +++ b/launcher/src/flattened-devfile.ts @@ -21,6 +21,7 @@ export interface Devfile { export interface Project { name: string; + clonePath?: string; git: Git; } diff --git a/launcher/tests/_data/flattened.devworkspace.with-clone-path.yaml b/launcher/tests/_data/flattened.devworkspace.with-clone-path.yaml new file mode 100644 index 00000000000..b5df00cb94d --- /dev/null +++ b/launcher/tests/_data/flattened.devworkspace.with-clone-path.yaml @@ -0,0 +1,172 @@ +attributes: + controller.devfile.io/devworkspace-config: + name: devworkspace-config + namespace: dogfooding + controller.devfile.io/scc: container-build + controller.devfile.io/storage-type: per-workspace + dw.metadata.annotations: + che.eclipse.org/devfile-source: | + scm: + repo: https://github.com/che-incubator/che-code.git + revision: che-code-launcher-test + fileName: devfile.yaml + factory: + params: url=https://github.com/che-incubator/che-code/tree/che-code-launcher-test +commands: +- apply: + component: che-code-injector + attributes: + controller.devfile.io/imported-by: editor + id: init-container-command +- attributes: + controller.devfile.io/imported-by: editor + exec: + commandLine: nohup /checode/entrypoint-volume.sh > /checode/entrypoint-logs.txt + 2>&1 & + component: dev + id: init-che-code-command +- exec: + commandLine: | + yarn prepare + component: dev + group: + kind: build + workingDir: ${PROJECTS_ROOT}/che-code + id: prepare +- exec: + commandLine: | + yarn watch + component: dev + group: + isDefault: true + kind: build + workingDir: ${PROJECTS_ROOT}/che-code + id: build +- exec: + commandLine: | + yarn server + component: dev + group: + isDefault: true + kind: run + workingDir: ${PROJECTS_ROOT}/che-code + id: run +components: +- attributes: + controller.devfile.io/imported-by: editor + name: checode + volume: {} +- attributes: + controller.devfile.io/imported-by: editor + container: + command: + - /entrypoint-init-container.sh + cpuLimit: 500m + cpuRequest: 30m + env: + - name: CHE_DASHBOARD_URL + value: https://che-dogfooding.apps.che-dev.x6e0.p1.openshiftapps.com + - name: CHE_PLUGIN_REGISTRY_URL + value: https://che-dogfooding.apps.che-dev.x6e0.p1.openshiftapps.com/plugin-registry/v3 + - name: CHE_PLUGIN_REGISTRY_INTERNAL_URL + value: http://plugin-registry.dogfooding.svc:8080/v3 + - name: CLUSTER_CONSOLE_URL + value: https://console-openshift-console.apps.che-dev.x6e0.p1.openshiftapps.com + - name: CLUSTER_CONSOLE_TITLE + value: OpenShift console + - name: OPENVSX_REGISTRY_URL + value: https://open-vsx.org + image: quay.io/vgulyy/che-code:test-che-code-launcher-2 + memoryLimit: 256Mi + memoryRequest: 32Mi + sourceMapping: /projects + volumeMounts: + - name: checode + path: /checode + name: che-code-injector +- attributes: + app.kubernetes.io/component: che-code-runtime + app.kubernetes.io/part-of: che-code.eclipse.org + controller.devfile.io/merged-contributions: editor + container: + cpuLimit: "4" + endpoints: + - attributes: + controller.devfile.io/endpoint-url: https://che-dogfooding.apps.che-dev.x6e0.p1.openshiftapps.com/vgulyy/che-code-multiroot/3100/ + cookiesAuthEnabled: true + discoverable: false + type: main + urlRewriteSupported: true + exposure: public + name: che-code + protocol: https + secure: false + targetPort: 3100 + - attributes: + controller.devfile.io/endpoint-url: http://vgulyy-che-code-multiroot-code-redirect-1.apps.che-dev.x6e0.p1.openshiftapps.com/ + discoverable: false + urlRewriteSupported: false + exposure: public + name: code-redirect-1 + protocol: http + targetPort: 13131 + - attributes: + controller.devfile.io/endpoint-url: http://vgulyy-che-code-multiroot-code-redirect-2.apps.che-dev.x6e0.p1.openshiftapps.com/ + discoverable: false + urlRewriteSupported: false + exposure: public + name: code-redirect-2 + protocol: http + targetPort: 13132 + - attributes: + controller.devfile.io/endpoint-url: http://vgulyy-che-code-multiroot-code-redirect-3.apps.che-dev.x6e0.p1.openshiftapps.com/ + discoverable: false + urlRewriteSupported: false + exposure: public + name: code-redirect-3 + protocol: http + targetPort: 13133 + - attributes: + controller.devfile.io/endpoint-url: https://vgulyy-che-code-multiroot-dev.apps.che-dev.x6e0.p1.openshiftapps.com/ + exposure: public + name: dev + protocol: http + secure: true + targetPort: 8000 + env: + - name: WEBVIEW_LOCAL_RESOURCES + value: "true" + - name: CHE_DASHBOARD_URL + value: https://che-dogfooding.apps.che-dev.x6e0.p1.openshiftapps.com + - name: CHE_PLUGIN_REGISTRY_URL + value: https://che-dogfooding.apps.che-dev.x6e0.p1.openshiftapps.com/plugin-registry/v3 + - name: CHE_PLUGIN_REGISTRY_INTERNAL_URL + value: http://plugin-registry.dogfooding.svc:8080/v3 + - name: CLUSTER_CONSOLE_URL + value: https://console-openshift-console.apps.che-dev.x6e0.p1.openshiftapps.com + - name: CLUSTER_CONSOLE_TITLE + value: OpenShift console + - name: OPENVSX_REGISTRY_URL + value: https://open-vsx.org + image: quay.io/che-incubator/che-code-dev:insiders + memoryLimit: 13Gi + memoryRequest: 320Mi + sourceMapping: /projects + volumeMounts: + - name: checode + path: /checode + name: dev +- name: projects + volume: + size: 3Gi +events: + postStart: + - init-che-code-command + preStart: + - init-container-command +projects: +- clonePath: custom/clone/path/web-nodejs-sample + git: + remotes: + url: https://github.com/che-samples/web-nodejs-sample + name: web-nodejs-sample diff --git a/launcher/tests/code-workspace.spec.ts b/launcher/tests/code-workspace.spec.ts index d7a54ef1cf2..537f487cd02 100644 --- a/launcher/tests/code-workspace.spec.ts +++ b/launcher/tests/code-workspace.spec.ts @@ -12,7 +12,8 @@ import * as fs from '../src/fs-extra'; import * as path from 'path'; import { env } from 'process'; -import { CodeWorkspace } from '../src/code-workspace'; +import { CodeWorkspace, Workspace } from '../src/code-workspace'; +import { Project } from '../src/flattened-devfile'; const WORKSPACE_JSON = `{ \t"folders": [ @@ -40,6 +41,15 @@ const WORKSPACE_WITH_ONE_PROJECT = `{ \t] }`; +const WORKSPACE_WITH_CLONE_PATH = `{ +\t"folders": [ +\t\t{ +\t\t\t"name": "web-nodejs-sample", +\t\t\t"path": "/tmp/projects/custom/clone/path/web-nodejs-sample" +\t\t} +\t] +}`; + const WORKSPACE_WITH_TWO_PROJECTS = `{ \t"folders": [ \t\t{ @@ -512,6 +522,82 @@ describe('Test generating VS Code Workspace file:', () => { expect(writeFileMock).toBeCalledWith(env.VSCODE_DEFAULT_WORKSPACE, WORKSPACE_JSON); }); + test('should use clonePath instead of project name when generating the folder path', async () => { + env.PROJECTS_ROOT = '/tmp/projects'; + + env.DEVWORKSPACE_FLATTENED_DEVFILE = path.join(__dirname, '_data', 'flattened.devworkspace.with-clone-path.yaml'); + + const pathExistsMock = jest.fn(); + const writeFileMock = jest.fn(); + const readFileMock = jest.fn(); + + Object.assign(fs, { + pathExists: pathExistsMock, + writeFile: writeFileMock, + readFile: readFileMock, + }); + + readFileMock.mockImplementation(async (path) => { + if (path === env.DEVWORKSPACE_FLATTENED_DEVFILE) { + return originalReadFile(path); + } + + return undefined; + }); + + pathExistsMock.mockImplementation((path) => { + return '/tmp/projects/custom/clone/path/web-nodejs-sample' === path; + }); + + const codeWorkspace = new CodeWorkspace(); + await codeWorkspace.generate(); + + expect(pathExistsMock).toBeCalledWith('/tmp/projects/custom/clone/path/web-nodejs-sample'); + expect(writeFileMock).toBeCalledWith('/tmp/projects/.code-workspace', WORKSPACE_WITH_CLONE_PATH); + }); + + test('should find existing workspace file in clonePath directory', async () => { + env.PROJECTS_ROOT = '/tmp/projects'; + env.DEVWORKSPACE_FLATTENED_DEVFILE = path.join(__dirname, '_data', 'flattened.devworkspace.with-clone-path.yaml'); + + const pathExistsMock = jest.fn(); + const isFileMock = jest.fn(); + const writeFileMock = jest.fn(); + const readFileMock = jest.fn(); + + Object.assign(fs, { + pathExists: pathExistsMock, + isFile: isFileMock, + writeFile: writeFileMock, + readFile: readFileMock, + }); + + readFileMock.mockImplementation(async (p) => { + if (p === env.DEVWORKSPACE_FLATTENED_DEVFILE) { + return originalReadFile(p); + } + return WORKSPACE_WITH_CLONE_PATH; + }); + + // Return true for the clonePath-based workspace file AND the clonePath directory + pathExistsMock.mockImplementation((p) => { + return ( + '/tmp/projects/custom/clone/path/web-nodejs-sample/.code-workspace' === p || + '/tmp/projects/custom/clone/path/web-nodejs-sample' === p + ); + }); + + isFileMock.mockImplementation(async (p) => { + return '/tmp/projects/custom/clone/path/web-nodejs-sample/.code-workspace' === p; + }); + + const codeWorkspace = new CodeWorkspace(); + await codeWorkspace.generate(); + + expect(pathExistsMock).toBeCalledWith('/tmp/projects/custom/clone/path/web-nodejs-sample/.code-workspace'); + expect(writeFileMock).not.toBeCalled(); // existing workspace found, no need to write + }); + test('should not add project to the .code-workspace file if project directory does not exist', async () => { env.PROJECTS_ROOT = '/tmp/projects'; @@ -565,3 +651,106 @@ describe('Test generating VS Code Workspace file:', () => { expect(writeFileMock).toBeCalledWith(env.VSCODE_DEFAULT_WORKSPACE, WORKSPACE_WITH_TWO_PROJECTS); }); }); + +describe('Test CodeWorkspace#synchronizeProjects with clonePath:', () => { + beforeEach(() => { + delete env.PROJECTS_ROOT; + + Object.assign(fs, { + pathExists: jest.fn(), + }); + }); + + test('should use clonePath instead of project name to build the folder path', async () => { + env.PROJECTS_ROOT = '/tmp/projects'; + + const pathExistsMock = jest.fn().mockResolvedValue(true); + Object.assign(fs, { pathExists: pathExistsMock }); + + const workspace: Workspace = { folders: [] }; + const projects: Project[] = [ + { + name: 'web-nodejs-sample', + clonePath: 'custom/clone/path/web-nodejs-sample', + git: { remotes: { url: 'https://github.com/che-samples/web-nodejs-sample' } }, + }, + ]; + + const codeWorkspace = new CodeWorkspace(); + const synchronized = await codeWorkspace.synchronizeProjects(workspace, projects); + + expect(synchronized).toBe(true); + expect(pathExistsMock).toBeCalledWith('/tmp/projects/custom/clone/path/web-nodejs-sample'); + expect(workspace.folders).toEqual([ + { name: 'web-nodejs-sample', path: '/tmp/projects/custom/clone/path/web-nodejs-sample' }, + ]); + }); + + test('should fall back to project name when clonePath is not set', async () => { + env.PROJECTS_ROOT = '/tmp/projects'; + + const pathExistsMock = jest.fn().mockResolvedValue(true); + Object.assign(fs, { pathExists: pathExistsMock }); + + const workspace: Workspace = { folders: [] }; + const projects: Project[] = [ + { + name: 'web-nodejs-sample', + git: { remotes: { url: 'https://github.com/che-samples/web-nodejs-sample' } }, + }, + ]; + + const codeWorkspace = new CodeWorkspace(); + const synchronized = await codeWorkspace.synchronizeProjects(workspace, projects); + + expect(synchronized).toBe(true); + expect(pathExistsMock).toBeCalledWith('/tmp/projects/web-nodejs-sample'); + expect(workspace.folders).toEqual([{ name: 'web-nodejs-sample', path: '/tmp/projects/web-nodejs-sample' }]); + }); + + test('should not add a folder if the clonePath directory does not exist', async () => { + env.PROJECTS_ROOT = '/tmp/projects'; + + const pathExistsMock = jest.fn().mockResolvedValue(false); + Object.assign(fs, { pathExists: pathExistsMock }); + + const workspace: Workspace = { folders: [] }; + const projects: Project[] = [ + { + name: 'web-nodejs-sample', + clonePath: 'custom/clone/path/web-nodejs-sample', + git: { remotes: { url: 'https://github.com/che-samples/web-nodejs-sample' } }, + }, + ]; + + const codeWorkspace = new CodeWorkspace(); + const synchronized = await codeWorkspace.synchronizeProjects(workspace, projects); + + expect(synchronized).toBe(false); + expect(workspace.folders).toEqual([]); + }); + + test('should not duplicate a folder when a project with the same name already exists, regardless of clonePath', async () => { + env.PROJECTS_ROOT = '/tmp/projects'; + + const pathExistsMock = jest.fn().mockResolvedValue(true); + Object.assign(fs, { pathExists: pathExistsMock }); + + const workspace: Workspace = { + folders: [{ name: 'web-nodejs-sample', path: '/tmp/projects/web-nodejs-sample' }], + }; + const projects: Project[] = [ + { + name: 'web-nodejs-sample', + clonePath: 'custom/clone/path/web-nodejs-sample', + git: { remotes: { url: 'https://github.com/che-samples/web-nodejs-sample' } }, + }, + ]; + + const codeWorkspace = new CodeWorkspace(); + const synchronized = await codeWorkspace.synchronizeProjects(workspace, projects); + + expect(synchronized).toBe(false); + expect(workspace.folders).toEqual([{ name: 'web-nodejs-sample', path: '/tmp/projects/web-nodejs-sample' }]); + }); +});