From 437833d5ad2ac717afa56353b122f69dab74adad Mon Sep 17 00:00:00 2001 From: Chris Brown Date: Fri, 19 Jun 2026 22:36:20 +0100 Subject: [PATCH] fix: create parent directories for nested clonePath When a devfile project specifies a nested clonePath (e.g. "back/devfile-rest"), the intermediary directories are never created, causing os.Rename to fail when moving the cloned project to its final destination. Add os.MkdirAll calls before os.Rename in both the git and zip project setup paths to ensure parent directories exist. Add an integration test fixture exercising nested clonePath with multiple projects sharing intermediary directories. Fixes: eclipse-che/che#23877 Co-Authored-By: Claude Opus 4.6 Signed-off-by: Chris Brown --- project-clone/internal/git/setup.go | 10 +- project-clone/internal/zip/setup.go | 6 + .../nested-clone-path-test.devworkspace.yaml | 121 ++++++++++++++++++ 3 files changed, 135 insertions(+), 2 deletions(-) create mode 100644 project-clone/test/nested-clone-path-test.devworkspace.yaml diff --git a/project-clone/internal/git/setup.go b/project-clone/internal/git/setup.go index f5f1d8052..5ca3d0133 100644 --- a/project-clone/internal/git/setup.go +++ b/project-clone/internal/git/setup.go @@ -47,6 +47,9 @@ func doInitialGitClone(project *dw.Project) error { // Clone into a temp dir and then move set up project to PROJECTS_ROOT to try and make clone atomic in case // project-clone container is terminated tmpClonePath := path.Join(internal.CloneTmpDir, projectslib.GetClonePath(project)) + if err := os.MkdirAll(path.Dir(tmpClonePath), 0755); err != nil { + return fmt.Errorf("failed to create parent directories for temp clone path %s: %w", tmpClonePath, err) + } var cloneErr error for attempt := 0; attempt <= internal.CloneRetries; attempt++ { if attempt > 0 { @@ -120,6 +123,11 @@ func setupRemotesForExistingProject(project *dw.Project) error { } func copyProjectFromTmpDir(project *dw.Project, tmpClonePath string) error { + projectPath := path.Join(internal.ProjectsRoot, projectslib.GetClonePath(project)) + if err := os.MkdirAll(path.Dir(projectPath), 0755); err != nil { + return fmt.Errorf("failed to create parent directories for project path %s: %w", projectPath, err) + } + if project.Attributes.Exists(internal.ProjectSubDir) { // Only want one directory from the project var err error @@ -128,13 +136,11 @@ func copyProjectFromTmpDir(project *dw.Project, tmpClonePath string) error { return fmt.Errorf("failed to process subDir on project: %w", err) } subDirPath := path.Join(tmpClonePath, subDirSubPath) - projectPath := path.Join(internal.ProjectsRoot, projectslib.GetClonePath(project)) log.Printf("Moving subdirectory %s in project %s from temporary directory to %s", subDirSubPath, project.Name, projectPath) if err := os.Rename(subDirPath, projectPath); err != nil { return fmt.Errorf("failed to move subdirectory of cloned project to %s: %w", internal.ProjectsRoot, err) } } else { - projectPath := path.Join(internal.ProjectsRoot, projectslib.GetClonePath(project)) log.Printf("Moving cloned project %s from temporary directory %s to %s", project.Name, tmpClonePath, projectPath) if err := os.Rename(tmpClonePath, projectPath); err != nil { return fmt.Errorf("failed to move cloned project to %s: %w", internal.ProjectsRoot, err) diff --git a/project-clone/internal/zip/setup.go b/project-clone/internal/zip/setup.go index 2b0fba443..92df5bf5a 100644 --- a/project-clone/internal/zip/setup.go +++ b/project-clone/internal/zip/setup.go @@ -52,6 +52,9 @@ func SetupZipProject(project v1alpha2.Project, httpClient *http.Client) error { } tmpProjectsPath := path.Join(internal.CloneTmpDir, clonePath) + if err := os.MkdirAll(path.Dir(tmpProjectsPath), 0755); err != nil { + return fmt.Errorf("failed to create parent directories for temp path %s: %w", tmpProjectsPath, err) + } zipFilePath := path.Join(tmpDir, fmt.Sprintf("%s.zip", clonePath)) log.Printf("Downloading project archive from %s", url) @@ -67,6 +70,9 @@ func SetupZipProject(project v1alpha2.Project, httpClient *http.Client) error { } // Move unzipped project from tmp dir to final destination + if err := os.MkdirAll(path.Dir(projectPath), 0755); err != nil { + return fmt.Errorf("failed to create parent directories for project path %s: %w", projectPath, err) + } log.Printf("Moving extracted project archive to %s", projectPath) if err := os.Rename(tmpProjectsPath, projectPath); err != nil { return fmt.Errorf("failed to move unzipped project to PROJECTS_ROOT: %w", err) diff --git a/project-clone/test/nested-clone-path-test.devworkspace.yaml b/project-clone/test/nested-clone-path-test.devworkspace.yaml new file mode 100644 index 000000000..b4da35401 --- /dev/null +++ b/project-clone/test/nested-clone-path-test.devworkspace.yaml @@ -0,0 +1,121 @@ +apiVersion: workspace.devfile.io/v1alpha2 +kind: DevWorkspace +metadata: + name: nested-clone-path-test + labels: + app.kubernetes.io/name: devworkspace-project-clone-tests + app.kubernetes.io/part-of: devworkspace-operator + annotations: + controller.devfile.io/debug-start: "true" +spec: + started: true + routingClass: 'basic' + template: + attributes: + controller.devfile.io/storage-type: ephemeral + variables: + test_runner_image: quay.io/devfile/project-clone:next # Requires git, bash + main_repo: https://github.com/devfile/devworkspace-operator.git + default_branch_name: main + projects: + - name: backend-api + clonePath: back/api + git: + remotes: + origin: "{{main_repo}}" + - name: backend-worker + clonePath: back/worker + git: + remotes: + origin: "{{main_repo}}" + - name: frontend-app + clonePath: ui/app + git: + remotes: + origin: "{{main_repo}}" + - name: flat-project + git: + remotes: + origin: "{{main_repo}}" + components: + - name: test-project-clone + container: + image: "{{test_runner_image}}" + memoryLimit: 512Mi + mountSources: true + command: + - "/bin/bash" + - "-c" + - | + set -e + + fail() { + echo "[ERROR] $1" + echo "[ERROR] See project-clone logs: " + echo "[ERROR] oc logs -n $DEVWORKSPACE_NAMESPACE deploy/$DEVWORKSPACE_ID -c project-clone" + exit 1 + } + + if [ -f "${PROJECTS_ROOT}/project-clone-errors.log" ]; then + echo "==== BEGIN PROJECT CLONE LOGS ====" + sed 's/^/ /g' "${PROJECTS_ROOT}/project-clone-errors.log" + echo "==== END PROJECT CLONE LOGS ====" + echo -e "\n\n" + fi + + for project_dir in "back/api" "back/worker" "ui/app" "flat-project"; do + if [ ! -d "${PROJECTS_ROOT}/${project_dir}" ]; then + fail "Project at ${project_dir} not cloned successfully" + fi + done + + echo "Testing nested clonePath project: back/api" + cd "${PROJECTS_ROOT}/back/api" + branch_name=$(git rev-parse --abbrev-ref HEAD) + if [ "$branch_name" != "{{default_branch_name}}" ]; then + fail "Project back/api does not have default branch checked out" + fi + remote_url=$(git config remote.origin.url) + if [ "$remote_url" != "{{main_repo}}" ]; then + fail "Remote 'origin' not configured for back/api" + fi + echo "back/api: on $branch_name, remotes configured" + + echo "Testing nested clonePath project: back/worker" + cd "${PROJECTS_ROOT}/back/worker" + branch_name=$(git rev-parse --abbrev-ref HEAD) + if [ "$branch_name" != "{{default_branch_name}}" ]; then + fail "Project back/worker does not have default branch checked out" + fi + remote_url=$(git config remote.origin.url) + if [ "$remote_url" != "{{main_repo}}" ]; then + fail "Remote 'origin' not configured for back/worker" + fi + echo "back/worker: on $branch_name, remotes configured" + + echo "Testing nested clonePath project: ui/app" + cd "${PROJECTS_ROOT}/ui/app" + branch_name=$(git rev-parse --abbrev-ref HEAD) + if [ "$branch_name" != "{{default_branch_name}}" ]; then + fail "Project ui/app does not have default branch checked out" + fi + remote_url=$(git config remote.origin.url) + if [ "$remote_url" != "{{main_repo}}" ]; then + fail "Remote 'origin' not configured for ui/app" + fi + echo "ui/app: on $branch_name, remotes configured" + + echo "Testing flat clonePath project (no nesting)" + cd "${PROJECTS_ROOT}/flat-project" + branch_name=$(git rev-parse --abbrev-ref HEAD) + if [ "$branch_name" != "{{default_branch_name}}" ]; then + fail "Project flat-project does not have default branch checked out" + fi + remote_url=$(git config remote.origin.url) + if [ "$remote_url" != "{{main_repo}}" ]; then + fail "Remote 'origin' not configured for flat-project" + fi + echo "flat-project: on $branch_name, remotes configured" + + echo "[SUCCESS] Test succeeded. Sleeping indefinitely" + tail -f /dev/null