From 5ea846c7f1ba6248a28e20d07d44d0ad36c5c7f7 Mon Sep 17 00:00:00 2001 From: Rohan Kumar Date: Thu, 3 Sep 2026 16:11:53 +0530 Subject: [PATCH 01/12] feat(factory): detect devcontainer.json when no devfile is present When a factory URL points to a repo with no devfile, probe for .devcontainer/devcontainer.json (then .devcontainer.json). If found, merge devcontainer commands and events with the default devfile and return a generated factory. The generated devfile adds five commands to the default devfile: - start-devcontainer: builds and runs the devcontainer via rootless podman, wires terminal profile and lifecycle hooks (postStart event) - rebuild-devcontainer: force rebuild with REBUILD=1 - rebuild-devcontainer-no-cache: rebuild without cache - show-devcontainer-log: tail the devcontainer build/run log - clean-devcontainer-images: prune all podman images Co-Authored-By: Claude Opus 4.6 Signed-off-by: Rohan Kumar --- .../server/urlfactory/URLFactoryBuilder.java | 65 ++- .../devcontainer-devfile-template.yaml | 455 ++++++++++++++++++ .../urlfactory/URLFactoryBuilderTest.java | 294 ++++++++++- 3 files changed, 812 insertions(+), 2 deletions(-) create mode 100644 wsmaster/che-core-api-factory/src/main/resources/devcontainer-devfile-template.yaml diff --git a/wsmaster/che-core-api-factory/src/main/java/org/eclipse/che/api/factory/server/urlfactory/URLFactoryBuilder.java b/wsmaster/che-core-api-factory/src/main/java/org/eclipse/che/api/factory/server/urlfactory/URLFactoryBuilder.java index 50b4975382c..226bf379c8d 100644 --- a/wsmaster/che-core-api-factory/src/main/java/org/eclipse/che/api/factory/server/urlfactory/URLFactoryBuilder.java +++ b/wsmaster/che-core-api-factory/src/main/java/org/eclipse/che/api/factory/server/urlfactory/URLFactoryBuilder.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2012-2024 Red Hat, Inc. + * Copyright (c) 2012-2026 Red Hat, Inc. * 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/ @@ -15,10 +15,15 @@ import static org.eclipse.che.api.factory.server.ApiExceptionMapper.toApiException; import static org.eclipse.che.api.factory.server.scm.exception.ExceptionMessages.getDevfileConnectionErrorMessage; import static org.eclipse.che.api.factory.shared.Constants.CURRENT_VERSION; +import static org.eclipse.che.api.factory.shared.Constants.DEFAULT_DEVFILE; import static org.eclipse.che.dto.server.DtoFactory.newDto; import com.fasterxml.jackson.databind.JsonNode; import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; import java.util.Map; import java.util.Optional; import javax.inject.Inject; @@ -50,6 +55,24 @@ public class URLFactoryBuilder { public static final String DEVFILE_FILENAME = "devfileFilename"; + private static final String[] DEVCONTAINER_LOCATIONS = { + ".devcontainer/devcontainer.json", ".devcontainer.json" + }; + + private static final String DEVCONTAINER_DEVFILE_TEMPLATE; + + static { + try (InputStream is = + URLFactoryBuilder.class.getResourceAsStream("/devcontainer-devfile-template.yaml")) { + if (is == null) { + throw new IOException("devcontainer-devfile-template.yaml not found on classpath"); + } + DEVCONTAINER_DEVFILE_TEMPLATE = new String(is.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException e) { + throw new UncheckedIOException("Failed to load devcontainer devfile template", e); + } + } + private final String defaultCheEditor; private final String defaultChePlugins; @@ -140,6 +163,46 @@ public Optional createFactoryFromDevfile( throw toApiException(e, location); } } + + // No devfile found — probe for devcontainer.json + for (String devcontainerPath : DEVCONTAINER_LOCATIONS) { + String devcontainerLocation = remoteFactoryUrl.rawFileLocation(devcontainerPath); + if (devcontainerLocation == null) { + break; + } + try { + Optional credentialsOptional = remoteFactoryUrl.getCredentials(); + if (skipAuthentication) { + fileContentProvider.fetchContentWithoutAuthentication(devcontainerLocation); + } else if (credentialsOptional.isPresent()) { + fileContentProvider.fetchContent(devcontainerLocation, credentialsOptional.get()); + } else { + fileContentProvider.fetchContent(devcontainerLocation); + } + } catch (IOException ex) { + LOG.debug("No devcontainer at: {}. Error: {}", devcontainerLocation, ex.getMessage()); + continue; + } catch (DevfileException e) { + LOG.debug("Unexpected exception probing devcontainer: {}", e.getMessage()); + continue; + } + + LOG.info("Devcontainer detected at {}; generating devfile", devcontainerLocation); + try { + JsonNode additions = devfileParser.parseYamlRaw(DEVCONTAINER_DEVFILE_TEMPLATE); + Map devfileMap = new HashMap<>(DEFAULT_DEVFILE); + devfileMap.putAll(devfileParser.convertYamlToMap(additions)); + return Optional.of( + newDto(FactoryDevfileV2Dto.class) + .withV(CURRENT_VERSION) + .withDevfile(devfileMap) + .withSource(devcontainerPath)); + } catch (DevfileException e) { + LOG.error("Failed to parse devcontainer devfile template", e); + break; + } + } + return Optional.empty(); } diff --git a/wsmaster/che-core-api-factory/src/main/resources/devcontainer-devfile-template.yaml b/wsmaster/che-core-api-factory/src/main/resources/devcontainer-devfile-template.yaml new file mode 100644 index 00000000000..fc56f0e25ac --- /dev/null +++ b/wsmaster/che-core-api-factory/src/main/resources/devcontainer-devfile-template.yaml @@ -0,0 +1,455 @@ +# +# Copyright (c) 2012-2026 Red Hat, Inc. +# 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 +# +# Contributors: +# Red Hat, Inc. - initial API and implementation +# + +commands: + - id: start-devcontainer + exec: + component: universal-developer-image + label: Start dev container + workingDir: ${PROJECTS_ROOT} + commandLine: | + cat > /tmp/start-devcontainer.sh <<'DEVCONTAINER_SCRIPT_EOF' + #!/usr/bin/env bash + # + # Outer editor + inner devcontainer. + # che-code stays in the UDI container; the repo's devcontainer.json is built and run as a + # nested rootless-podman container. Terminals, lifecycle commands and file ownership are + # wired so the inner container behaves like the project's real environment. + # + # Env overrides: + # CONTAINER_NAME nested container name (default: devcontainer) + # IMAGE_NAME built image tag (default: localhost/devcontainer:latest) + # REUSE_CONTAINER=1 reuse a running container instead of rebuilding + # REBUILD=1 force rebuild (remove existing container and image) + # NO_CACHE=1 rebuild without cache (passes --no-cache to devcontainer build) + # STRICT_LIFECYCLE=1 exit non-zero if any lifecycle command fails + # NO_KEEP_ID=1 skip --userns=keep-id (debugging) + # + set -uo pipefail + + PROJECTS_ROOT="${PROJECTS_ROOT:-/projects}" + # Project: explicit arg > DWO's PROJECT_SOURCE > the only directory under PROJECTS_ROOT. + if [ "$#" -ge 1 ] && [ -n "${1:-}" ]; then + PROJECT_DIR="${PROJECTS_ROOT}/${1}" + elif [ -n "${PROJECT_SOURCE:-}" ] && [ -d "${PROJECT_SOURCE}" ]; then + PROJECT_DIR="$PROJECT_SOURCE" + else + mapfile -t _dirs < <(find "$PROJECTS_ROOT" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | sort) + if [ "${#_dirs[@]}" -eq 1 ]; then + PROJECT_DIR="${_dirs[0]}" + else + echo "specify a project: $(basename "$0") " >&2 + [ "${#_dirs[@]}" -gt 1 ] && printf ' %s\n' "${_dirs[@]##*/}" >&2 + exit 1 + fi + fi + [ -d "$PROJECT_DIR" ] || { echo "no such project: $PROJECT_DIR" >&2; exit 1; } + PROJECT_NAME="$(basename "$PROJECT_DIR")" + CONTAINER_NAME="${CONTAINER_NAME:-devcontainer}" + IMAGE_NAME="${IMAGE_NAME:-localhost/devcontainer:latest}" + REUSE_CONTAINER="${REUSE_CONTAINER:-0}" + REBUILD="${REBUILD:-0}" + NO_CACHE="${NO_CACHE:-0}" + STRICT_LIFECYCLE="${STRICT_LIFECYCLE:-0}" + LIFECYCLE_FAILURES=0 + + # --- phase timing --------------------------------------------------------- + # Cold start is the main operational cost of this approach (CLI install + base image pull + + # build). Record where the time actually goes so it can be reported rather than guessed at. + T_START=$(date +%s); PHASE_T=$T_START; CURRENT_PHASE=""; PHASE_LOG=() + _close_phase(){ local now; now=$(date +%s) + [ -n "$CURRENT_PHASE" ] && PHASE_LOG+=("$((now-PHASE_T))|$CURRENT_PHASE"); PHASE_T=$now; } + step(){ _close_phase; CURRENT_PHASE="$2"; echo "[$1] $2"; return 0; } + + echo "=== devcontainer (outer editor): ${PROJECT_NAME} ===" + + # --------------------------------------------------------------------------- + # 1. Container engine + # --------------------------------------------------------------------------- + # UDI moves the real podman to podman.orig and puts a symlink on PATH that becomes the + # KUBEDOCK WRAPPER when KUBEDOCK_ENABLED=true. The wrapper sends run/exec to kubedock — a + # separate pod — while build stays local, so the image would be built somewhere the runtime + # cannot see it and --network=host would no longer be the pod's netns. + PODMAN="${ORIGINAL_PODMAN_PATH:-/usr/bin/podman.orig}" + [ -x "$PODMAN" ] || PODMAN="$(command -v podman 2>/dev/null)" + [ -n "$PODMAN" ] || { echo "podman not found" >&2; exit 1; } + step "1/8" "engine: $PODMAN" + + # --------------------------------------------------------------------------- + # 2. Storage + subuid + # --------------------------------------------------------------------------- + # Keep the image store OFF the PVC: subuid-owned files there break DWO's cleanup job and + # wedge the workspace in Error on teardown. Prefer overlay+fuse-overlayfs when /dev/fuse + # exists — VFS is much slower and rules out --userns=keep-id. + mkdir -p /tmp/podman/storage ~/.config/containers + if [ -c /dev/fuse ] && [ -x /usr/bin/fuse-overlayfs ]; then + printf '[storage]\ndriver = "overlay"\ngraphroot = "/tmp/podman/storage"\n[storage.options.overlay]\nmount_program = "/usr/bin/fuse-overlayfs"\n' > ~/.config/containers/storage.conf + else + printf '[storage]\ndriver = "vfs"\ngraphroot = "/tmp/podman/storage"\n' > ~/.config/containers/storage.conf + fi + + # Derive the range instead of hardcoding a uid — UDI's entrypoint computes this from the + # actual uid, and a hardcoded "user:1001:..." maps the wrong host range on a uid-1000 pod. + # NOTE: a 65536-ID userns cannot map container ID 65534 (nobody) however the ranges are + # split, because our own UID consumes one. apt's sandbox may still need disabling. + SU_USER="$(id -un 2>/dev/null || echo user)"; SU_UID="$(id -u)" + if [ "$SU_UID" -gt 0 ] && [ "$SU_UID" -lt 65536 ]; then + RANGES="$(printf '%s:1:%s\n%s:%s:%s\n' "$SU_USER" "$((SU_UID-1))" \ + "$SU_USER" "$((SU_UID+1))" "$((65535-SU_UID))")" + printf '%s\n' "$RANGES" > /etc/subuid 2>/dev/null && + printf '%s\n' "$RANGES" > /etc/subgid 2>/dev/null && + "$PODMAN" system migrate >/dev/null 2>&1 && + step "2/8" "storage=$("$PODMAN" info --format json 2>/dev/null | jq -r '.store.graphDriverName') subuid=${SU_USER}(${SU_UID})" || + step "2/8" "storage=$("$PODMAN" info --format json 2>/dev/null | jq -r '.store.graphDriverName') subuid=unchanged" + fi + + # --------------------------------------------------------------------------- + # 3. devcontainer CLI + pre-build config + # --------------------------------------------------------------------------- + DEVCONTAINER_BIN="${DEVCONTAINER_BIN:-$(command -v devcontainer || echo /home/user/.devcontainers/bin/devcontainer)}" + if ! "$DEVCONTAINER_BIN" --version >/dev/null 2>&1; then + step "3/8" "installing devcontainer CLI..." + npm install -g @devcontainers/cli >/dev/null 2>&1 || + curl -fsSL https://raw.githubusercontent.com/devcontainers/cli/main/scripts/install.sh | sh + DEVCONTAINER_BIN="$(command -v devcontainer || echo /home/user/.devcontainers/bin/devcontainer)" + else + step "3/8" "devcontainer CLI present." + fi + + # read-configuration shells out to `docker ps` before doing anything, so WITHOUT + # --docker-path it exits 1 with no output and we would silently fall back to a + # comment-stripping regex. Point it at the real engine. + CONFIG_JSON="" + raw="$("$DEVCONTAINER_BIN" read-configuration --docker-path "$PODMAN" \ + --workspace-folder "$PROJECT_DIR" 2>/dev/null || true)" + [ -n "$raw" ] && CONFIG_JSON="$(printf '%s\n' "$raw" | grep -E '^\{' | tail -1 \ + | jq -c '.configuration // empty' 2>/dev/null || true)" + if [ -z "$CONFIG_JSON" ]; then + DC="" + for c in "$PROJECT_DIR/.devcontainer/devcontainer.json" "$PROJECT_DIR/.devcontainer.json"; do + [ -f "$c" ] && { DC="$c"; break; } + done + [ -n "$DC" ] || DC="$(find "$PROJECT_DIR/.devcontainer" -mindepth 2 -maxdepth 2 \ + -name devcontainer.json 2>/dev/null | sort | head -1)" + [ -n "$DC" ] || { echo " no devcontainer.json found" >&2; exit 0; } + echo " read-configuration failed; using fallback parser on $DC" >&2 + # whole-line comments only, so a URL inside a string survives + CONFIG_JSON="$(sed -e 's@^[[:space:]]*//.*$@@' "$DC" | jq -c '.' 2>/dev/null)" + [ -n "$CONFIG_JSON" ] || { echo " could not parse $DC" >&2; exit 1; } + fi + + # --------------------------------------------------------------------------- + # 4. initializeCommand (runs OUTSIDE the container, per spec) + # --------------------------------------------------------------------------- + JQ_NORMALIZE=' + def norm($name): + if . == null then empty + elif type == "string" then {name:$name, argv:["/bin/sh","-c",.]} + elif type == "array" then {name:$name, argv:.} + elif type == "object" then to_entries[] | .key as $k | (.value | norm($k)) + else empty end; + norm("")' + + run_outer() { + local spec="$1" line label; [ -z "$spec" ] || [ "$spec" = "null" ] && return 0 + while IFS= read -r line; do + [ -z "$line" ] && continue + label="$(printf '%s' "$line" | jq -r '.name')" + local -a argv=(); mapfile -t argv < <(printf '%s' "$line" | jq -r '.argv[]') + [ "${#argv[@]}" -eq 0 ] && continue + echo " -> initializeCommand${label:+ [$label]}: ${argv[*]}" + ( cd "$PROJECT_DIR" && "${argv[@]}" ) || { + echo " !! initializeCommand failed" >&2; LIFECYCLE_FAILURES=$((LIFECYCLE_FAILURES+1)); } + done < <(printf '%s' "$spec" | jq -c "$JQ_NORMALIZE") + } + step "4/8" "initializeCommand..." + run_outer "$(printf '%s' "$CONFIG_JSON" | jq -c '.initializeCommand // null')" + + # --------------------------------------------------------------------------- + # 5. Build + # --------------------------------------------------------------------------- + REUSING=0 + if [ "$REBUILD" = "1" ]; then + "$PODMAN" rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true + "$PODMAN" rmi -f "$IMAGE_NAME" >/dev/null 2>&1 || true + fi + if [ "$REBUILD" != "1" ] && [ "$REUSE_CONTAINER" = "1" ] && "$PODMAN" container exists "$CONTAINER_NAME" 2>/dev/null; then + REUSING=1; echo "[5/8] reusing existing container."; "$PODMAN" start "$CONTAINER_NAME" >/dev/null 2>&1 || true + else + step "5/8" "building image (slow part)..." + BUILD_ARGS=(--docker-path="$PODMAN" --workspace-folder "$PROJECT_DIR" --image-name "$IMAGE_NAME") + [ "$NO_CACHE" = "1" ] && BUILD_ARGS+=(--no-cache) + "$DEVCONTAINER_BIN" build "${BUILD_ARGS[@]}" || { + echo " build failed" >&2; exit 1; } + fi + + # --------------------------------------------------------------------------- + # 6. Merged metadata from the built image + # --------------------------------------------------------------------------- + # `devcontainer build` writes a devcontainer.metadata LABEL containing the FULLY MERGED + # config — the base image's metadata, every Feature's contributions, and devcontainer.json. + # This is where remoteUser actually lives for most real repos (vscode-remote-try-node has + # its remoteUser line commented out, but the image metadata says "node"). Reading only + # devcontainer.json means lifecycle commands run as root and write root-owned node_modules + # into the bind mount. Using --include-merged-configuration instead would need a registry + # round-trip; the label is local and already merged. + JQ_MERGE=' + def last_of($k): [ .[] | .[$k] // empty ] | last // null; + def all_of($k): [ .[] | .[$k] // empty ]; + { remoteUser: last_of("remoteUser"), containerUser: last_of("containerUser"), + workspaceFolder: last_of("workspaceFolder"), + remoteEnv: ( [ .[] | .remoteEnv // {} ] | add // {} ), + containerEnv: ( [ .[] | .containerEnv // {} ] | add // {} ), + extensions: ( [ .[] | .customizations.vscode.extensions // [] ] | add // [] | unique ), + settings: ( [ .[] | .customizations.vscode.settings // {} ] | add // {} ), + onCreateCommand: all_of("onCreateCommand"), updateContentCommand: all_of("updateContentCommand"), + postCreateCommand: all_of("postCreateCommand"), postStartCommand: all_of("postStartCommand"), + postAttachCommand: all_of("postAttachCommand") }' + + META='{}' + label="$("$PODMAN" inspect --format json "$IMAGE_NAME" 2>/dev/null \ + | jq -r '.[0].Config.Labels["devcontainer.metadata"] // ""')" + if [ -n "$label" ] && [ "$label" != "" ]; then + META="$(printf '%s' "$label" | jq -c "$JQ_MERGE" 2>/dev/null || echo '{}')" + fi + # Fall back to devcontainer.json for anything the label did not provide. + META="$(jq -n --argjson m "$META" --argjson c "$CONFIG_JSON" ' + { remoteUser: ($m.remoteUser // $c.remoteUser // $c.containerUser // null), + workspaceFolder: ($m.workspaceFolder // $c.workspaceFolder // "/workspace"), + remoteEnv: (($c.remoteEnv // {}) + ($m.remoteEnv // {})), + containerEnv: (($c.containerEnv // {}) + ($m.containerEnv // {})), + extensions: ($m.extensions // ($c.customizations.vscode.extensions // [])), + settings: (($c.customizations.vscode.settings // {}) + ($m.settings // {})), + hooks: { onCreateCommand: ($m.onCreateCommand // []), updateContentCommand: ($m.updateContentCommand // []), + postCreateCommand: ($m.postCreateCommand // []), postStartCommand: ($m.postStartCommand // []), + postAttachCommand: ($m.postAttachCommand // []) } }')" + + REMOTE_USER="$(printf '%s' "$META" | jq -r '.remoteUser // empty')" + WORKSPACE_FOLDER="$(printf '%s' "$META" | jq -r '.workspaceFolder')" + echo " remoteUser=${REMOTE_USER:-} workspaceFolder=${WORKSPACE_FOLDER}" + + env_args() { # $1 = remoteEnv|containerEnv + printf '%s' "$META" | jq -r --arg k "$1" '(.[$k] // {}) | to_entries[] + | select(.value | tostring | test("\\$\\{") | not) | "-e", "\(.key)=\(.value)"' + } + CONTAINER_ENV=(); mapfile -t CONTAINER_ENV < <(env_args containerEnv) + REMOTE_ENV=(); mapfile -t REMOTE_ENV < <(env_args remoteEnv) + skipped="$(printf '%s' "$META" | jq -r '[(.remoteEnv//{}),(.containerEnv//{})] | add | to_entries[] + | select(.value|tostring|test("\\$\\{")) | .key' | paste -sd, -)" + [ -n "$skipped" ] && echo " NOTE: env with \${...} substitution skipped: $skipped" >&2 + + # --------------------------------------------------------------------------- + # 7. Run the container, with UID parity + # --------------------------------------------------------------------------- + # The whole point of outer-editor is "edit outside, run inside" — so the two sides must agree + # on file ownership. Without keep-id, anything created inside (node_modules, build output, + # .venv) is owned by a subuid and the editor cannot modify or delete it, which is what forced + # the chmod 777 workaround. keep-id:uid=,gid= maps our uid to the remoteUser inside, so files + # created either way are owned by us. Requires overlay (fuse) — VFS cannot chown. + KEEP_ID_ARGS=() + if [ "$REUSING" = "0" ] && [ "${NO_KEEP_ID:-0}" != "1" ] && [ -c /dev/fuse ]; then + IN_UID=""; IN_GID="" + if [ -n "$REMOTE_USER" ]; then + IN_UID="$("$PODMAN" run --rm "$IMAGE_NAME" id -u "$REMOTE_USER" 2>/dev/null | tr -dc '0-9')" + IN_GID="$("$PODMAN" run --rm "$IMAGE_NAME" id -g "$REMOTE_USER" 2>/dev/null | tr -dc '0-9')" + fi + if [ -n "$IN_UID" ] && [ -n "$IN_GID" ]; then + KEEP_ID_ARGS=(--userns="keep-id:uid=${IN_UID},gid=${IN_GID}") + else + KEEP_ID_ARGS=(--userns=keep-id) + fi + fi + + start_container() { # $1 = extra args array name + local -n extra="$1" + "$PODMAN" run -d --name "$CONTAINER_NAME" --network=host \ + "${extra[@]}" \ + -v "$PROJECT_DIR:${WORKSPACE_FOLDER}" \ + -v /var/run/secrets/kubernetes.io/serviceaccount:/var/run/secrets/kubernetes.io/serviceaccount:ro \ + -e KUBERNETES_SERVICE_HOST="${KUBERNETES_SERVICE_HOST:-}" \ + -e KUBERNETES_SERVICE_PORT="${KUBERNETES_SERVICE_PORT:-}" \ + "${CONTAINER_ENV[@]}" \ + "$IMAGE_NAME" sleep infinity + } + + KEEP_ID_OK=0 + if [ "$REUSING" = "0" ]; then + step "6/8" "starting container..." + "$PODMAN" rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true + if [ "${#KEEP_ID_ARGS[@]}" -gt 0 ] && start_container KEEP_ID_ARGS >/dev/null 2>&1; then + KEEP_ID_OK=1; echo " uid parity: ${KEEP_ID_ARGS[*]}" + else + [ "${#KEEP_ID_ARGS[@]}" -gt 0 ] && echo " keep-id unavailable; falling back (files created inside will be subuid-owned)" >&2 + "$PODMAN" rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true + NONE=(); start_container NONE >/dev/null || { echo " could not start container" >&2; exit 1; } + fi + else + step "6/8" "container already running." + fi + + # Only widen permissions when uid parity failed. a+rwX (not 777) so the execute bit is not + # set on every tracked file, which would make git report the whole repo as modified. + if [ "$KEEP_ID_OK" = "0" ]; then + "$PODMAN" exec --user 0 "$CONTAINER_NAME" chmod -R a+rwX "$WORKSPACE_FOLDER" 2>/dev/null || true + fi + "$PODMAN" exec "$CONTAINER_NAME" git config --global --replace-all safe.directory "$WORKSPACE_FOLDER" 2>/dev/null || true + + # --------------------------------------------------------------------------- + # 8. Lifecycle commands, then editor wiring + # --------------------------------------------------------------------------- + EXEC_USER=(); [ -n "$REMOTE_USER" ] && EXEC_USER=(-u "$REMOTE_USER") + CREATE_MARKER=/tmp/.devcontainer-create-hooks-done + + run_hook() { # $1 = hook name + local hook="$1" entries line label rc + entries="$(printf '%s' "$META" | jq -c --arg k "$hook" '.hooks[$k][]?')" + [ -n "$entries" ] || return 0 + while IFS= read -r spec; do + [ -z "$spec" ] && continue + while IFS= read -r line; do + [ -z "$line" ] && continue + label="$(printf '%s' "$line" | jq -r '.name')" + local -a argv=(); mapfile -t argv < <(printf '%s' "$line" | jq -r '.argv[]') + [ "${#argv[@]}" -eq 0 ] && continue + echo " -> ${hook}${label:+ [$label]}: ${argv[*]}" + rc=0 + "$PODMAN" exec "${EXEC_USER[@]}" "${REMOTE_ENV[@]}" -w "$WORKSPACE_FOLDER" \ + "$CONTAINER_NAME" "${argv[@]}" || rc=$? + [ "$rc" -ne 0 ] && { echo " !! ${hook} exited ${rc}" >&2 + LIFECYCLE_FAILURES=$((LIFECYCLE_FAILURES+1)); } + done < <(printf '%s' "$spec" | jq -c "$JQ_NORMALIZE") + done <<< "$entries" + } + + step "7/8" "lifecycle commands..." + if "$PODMAN" exec "$CONTAINER_NAME" test -f "$CREATE_MARKER" 2>/dev/null; then + echo " creation hooks already ran for this container." + else + run_hook onCreateCommand; run_hook updateContentCommand; run_hook postCreateCommand + "$PODMAN" exec "$CONTAINER_NAME" touch "$CREATE_MARKER" 2>/dev/null || true + fi + run_hook postStartCommand + run_hook postAttachCommand + + step "8/8" "editor wiring..." + INNER_SHELL=/bin/sh + "$PODMAN" exec "$CONTAINER_NAME" sh -c 'command -v bash' >/dev/null 2>&1 && INNER_SHELL=bash + + merge_into() { # $1 = file, $2.. = json objects + local file="$1"; shift + local cur='{}' obj + [ -f "$file" ] && cur="$(sed -e 's@^[[:space:]]*//.*$@@' "$file" | jq -c '.' 2>/dev/null || echo '{}')" + for obj in "$@"; do cur="$(jq -n --argjson a "$cur" --argjson b "$obj" '$a * $b')"; done + mkdir -p "$(dirname "$file")"; printf '%s\n' "$cur" | jq '.' > "$file" + } + + profile=(exec -it) + [ -n "$REMOTE_USER" ] && profile+=(-u "$REMOTE_USER") + profile+=("${REMOTE_ENV[@]}" -w "$WORKSPACE_FOLDER" "$CONTAINER_NAME" "$INNER_SHELL") + profile_json="$(printf '%s\n' "${profile[@]}" | jq -R . | jq -s -c .)" + terminal="$(jq -n --argjson a "$profile_json" --arg p "$PODMAN" '{ + "terminal.integrated.profiles.linux": { + "devcontainer": { "path": $p, "args": $a, "icon": "container" }, + "outer (UDI)": { "path": "/bin/bash" } }, + "terminal.integrated.defaultProfile.linux": "devcontainer" }')" + + # terminal.integrated.profiles.* and defaultProfile.* are declared `restricted: true` in + # VS Code. Restricted settings coming from WORKSPACE settings are silently discarded in an + # untrusted workspace, so they must go to MACHINE settings — which is also the file che-code's + # launcher merges the vscode-editor-configurations ConfigMap into, and it keeps the repo clean. + MACHINE_SETTINGS="${CHE_MACHINE_SETTINGS:-/checode/remote/data/Machine/settings.json}" + if mkdir -p "$(dirname "$MACHINE_SETTINGS")" 2>/dev/null; then + merge_into "$MACHINE_SETTINGS" "$terminal" + echo " terminal profile -> ${MACHINE_SETTINGS} (reload the window if it does not appear)" + else + echo " WARNING: ${MACHINE_SETTINGS} unwritable; terminal profile will not apply." >&2 + fi + + # Editor-safe settings are unrestricted, so the workspace file is fine for those. + SAFE='editor.|files.|workbench.|search.|explorer.|diffEditor.|breadcrumbs.|scm.|outline.|problems.|output.|window.|comments.' + editor_settings="$(printf '%s' "$META" | jq -c --arg p "$SAFE" '(.settings // {}) + | with_entries(select(.key as $k | ($p|split("|")|any(. as $x | $k|startswith($x)))))')" + [ "$(printf '%s' "$editor_settings" | jq 'length')" -gt 0 ] && + merge_into "${PROJECT_DIR}/.vscode/settings.json" "$editor_settings" && + echo " editor settings -> .vscode/settings.json" + + # Surface the extensions the devcontainer asks for (including Feature-contributed ones) as + # workspace recommendations. che-code cannot auto-install them, but this at least tells the + # user what the repo expects instead of silently dropping the list. + exts="$(printf '%s' "$META" | jq -c '.extensions // []')" + if [ "$(printf '%s' "$exts" | jq 'length')" -gt 0 ]; then + merge_into "${PROJECT_DIR}/.vscode/extensions.json" \ + "$(jq -n --argjson e "$exts" '{recommendations: $e}')" + echo " recommended extensions -> .vscode/extensions.json: $(printf '%s' "$exts" | jq -r 'join(", ")')" + fi + + _close_phase + echo + echo "=== ready ===" + printf ' timing: total %ss' "$(( $(date +%s) - T_START ))" + for e in "${PHASE_LOG[@]}"; do + d="${e%%|*}"; l="${e#*|}"; [ "$d" -ge 2 ] && printf ' | %s %ss' "${l%% *}" "$d" + done + echo + echo " Terminals open inside the container (profile 'devcontainer')." + echo " Files: ${PROJECT_DIR} <-> ${WORKSPACE_FOLDER}" + [ "$KEEP_ID_OK" = "1" ] && echo " UID parity ON — files created inside are owned by you." \ + || echo " UID parity OFF — files created inside are subuid-owned." + echo " Shell: ${PODMAN} exec -it ${CONTAINER_NAME} ${INNER_SHELL}" + if [ "$LIFECYCLE_FAILURES" -gt 0 ]; then + echo; echo " WARNING: ${LIFECYCLE_FAILURES} lifecycle command(s) failed." + [ "$STRICT_LIFECYCLE" = "1" ] && exit 1 + fi + exit 0 + DEVCONTAINER_SCRIPT_EOF + chmod +x /tmp/start-devcontainer.sh + # Run in the FOREGROUND so che-code keeps the task terminal alive and streams progress. + # A backgrounded (setsid/nohup &) process is reaped when the task's foreground shell exits, + # so it never survived. tee keeps /tmp/devcontainer.log for anyone who wants to tail it. + bash /tmp/start-devcontainer.sh 2>&1 | tee /tmp/devcontainer.log + - id: rebuild-devcontainer + exec: + component: universal-developer-image + label: Rebuild dev container + workingDir: ${PROJECTS_ROOT} + commandLine: | + [ -f /tmp/start-devcontainer.sh ] || { echo "run the start-devcontainer task first"; exit 1; } + REBUILD=1 bash /tmp/start-devcontainer.sh + - id: rebuild-devcontainer-no-cache + exec: + component: universal-developer-image + label: Rebuild dev container (no cache) + workingDir: ${PROJECTS_ROOT} + commandLine: | + [ -f /tmp/start-devcontainer.sh ] || { echo "run the start-devcontainer task first"; exit 1; } + REBUILD=1 NO_CACHE=1 bash /tmp/start-devcontainer.sh + - id: show-devcontainer-log + exec: + component: universal-developer-image + label: Show dev container log + workingDir: ${PROJECTS_ROOT} + commandLine: | + touch /tmp/devcontainer.log + tail -n 200 -f /tmp/devcontainer.log + - id: clean-devcontainer-images + exec: + component: universal-developer-image + label: Clean up dev container images + workingDir: ${PROJECTS_ROOT} + commandLine: | + PODMAN="${ORIGINAL_PODMAN_PATH:-/usr/bin/podman.orig}" + [ -x "$PODMAN" ] || PODMAN=podman + "$PODMAN" system prune -af + "$PODMAN" system df +events: + postStart: + - start-devcontainer diff --git a/wsmaster/che-core-api-factory/src/test/java/org/eclipse/che/api/factory/server/urlfactory/URLFactoryBuilderTest.java b/wsmaster/che-core-api-factory/src/test/java/org/eclipse/che/api/factory/server/urlfactory/URLFactoryBuilderTest.java index db353745700..1b78840e837 100644 --- a/wsmaster/che-core-api-factory/src/test/java/org/eclipse/che/api/factory/server/urlfactory/URLFactoryBuilderTest.java +++ b/wsmaster/che-core-api-factory/src/test/java/org/eclipse/che/api/factory/server/urlfactory/URLFactoryBuilderTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2012-2025 Red Hat, Inc. + * Copyright (c) 2012-2026 Red Hat, Inc. * 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/ @@ -18,8 +18,11 @@ import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertFalse; import static org.testng.Assert.assertNotNull; import static org.testng.Assert.assertNull; import static org.testng.Assert.assertTrue; @@ -410,6 +413,295 @@ public void shouldThrowErrorOnUnsupportedDevfileContent() false); } + @Test + public void testDevfileFoundSoDevcontainerProbeNeverRuns() throws Exception { + String devfileLocation = "http://repo/raw/devfile.yaml"; + String devcontainerLocation = "http://repo/raw/.devcontainer/devcontainer.json"; + + RemoteFactoryUrl remoteUrl = + new RemoteFactoryUrl() { + @Override + public String getProviderName() { + return "test"; + } + + @Override + public List devfileFileLocations() { + return singletonList( + new DevfileLocation() { + @Override + public Optional filename() { + return Optional.of("devfile.yaml"); + } + + @Override + public String location() { + return devfileLocation; + } + }); + } + + @Override + public String rawFileLocation(String filename) { + return "http://repo/raw/" + filename; + } + + @Override + public String getHostName() { + return "repo"; + } + + @Override + public String getProviderUrl() { + return "http://repo"; + } + + @Override + public String getBranch() { + return null; + } + + @Override + public Optional getCredentials() { + return Optional.empty(); + } + + @Override + public void setDevfileFilename(String devfileName) {} + }; + + when(fileContentProvider.fetchContent(eq(devfileLocation))).thenReturn("devfile content"); + when(devfileParser.parseYamlRaw(eq("devfile content"))) + .thenReturn(new ObjectNode(JsonNodeFactory.instance)); + when(devfileParser.convertYamlToMap(org.mockito.ArgumentMatchers.any())) + .thenReturn(Map.of("schemaVersion", "2.2.0")); + + Optional result = + urlFactoryBuilder.createFactoryFromDevfile( + remoteUrl, fileContentProvider, emptyMap(), false); + + assertTrue(result.isPresent()); + assertEquals(result.get().getSource(), "devfile.yaml"); + verify(fileContentProvider, never()).fetchContent(eq(devcontainerLocation)); + } + + @Test + public void testDevcontainerDetectedWhenNoDevfile() throws Exception { + String devfileLocation = "http://repo/raw/devfile.yaml"; + String devcontainerLocation = "http://repo/raw/.devcontainer/devcontainer.json"; + + RemoteFactoryUrl remoteUrl = + new RemoteFactoryUrl() { + @Override + public String getProviderName() { + return "test"; + } + + @Override + public List devfileFileLocations() { + return singletonList( + new DevfileLocation() { + @Override + public Optional filename() { + return Optional.of("devfile.yaml"); + } + + @Override + public String location() { + return devfileLocation; + } + }); + } + + @Override + public String rawFileLocation(String filename) { + return "http://repo/raw/" + filename; + } + + @Override + public String getHostName() { + return "repo"; + } + + @Override + public String getProviderUrl() { + return "http://repo"; + } + + @Override + public String getBranch() { + return null; + } + + @Override + public Optional getCredentials() { + return Optional.empty(); + } + + @Override + public void setDevfileFilename(String devfileName) {} + }; + + Map templateAdditions = + Map.of("commands", List.of(Map.of("id", "start-devcontainer"))); + + when(fileContentProvider.fetchContent(eq(devfileLocation))) + .thenThrow(new IOException("not found")); + when(fileContentProvider.fetchContent(eq(devcontainerLocation))) + .thenReturn("{\"name\": \"test\"}"); + JsonNode templateNode = new ObjectNode(JsonNodeFactory.instance); + when(devfileParser.parseYamlRaw(anyString())).thenReturn(templateNode); + when(devfileParser.convertYamlToMap(templateNode)).thenReturn(templateAdditions); + + Optional result = + urlFactoryBuilder.createFactoryFromDevfile( + remoteUrl, fileContentProvider, emptyMap(), false); + + assertTrue(result.isPresent()); + assertEquals(result.get().getSource(), ".devcontainer/devcontainer.json"); + assertTrue(result.get() instanceof FactoryDevfileV2Dto); + Map devfile = ((FactoryDevfileV2Dto) result.get()).getDevfile(); + assertEquals(devfile.get("schemaVersion"), "2.3.0"); + assertEquals(devfile.get("commands"), List.of(Map.of("id", "start-devcontainer"))); + } + + @Test + public void testNoDevfileNoDevcontainerReturnsEmpty() throws Exception { + String devfileLocation = "http://repo/raw/devfile.yaml"; + + RemoteFactoryUrl remoteUrl = + new RemoteFactoryUrl() { + @Override + public String getProviderName() { + return "test"; + } + + @Override + public List devfileFileLocations() { + return singletonList( + new DevfileLocation() { + @Override + public Optional filename() { + return Optional.of("devfile.yaml"); + } + + @Override + public String location() { + return devfileLocation; + } + }); + } + + @Override + public String rawFileLocation(String filename) { + return "http://repo/raw/" + filename; + } + + @Override + public String getHostName() { + return "repo"; + } + + @Override + public String getProviderUrl() { + return "http://repo"; + } + + @Override + public String getBranch() { + return null; + } + + @Override + public Optional getCredentials() { + return Optional.empty(); + } + + @Override + public void setDevfileFilename(String devfileName) {} + }; + + when(fileContentProvider.fetchContent(anyString())).thenThrow(new IOException("not found")); + + Optional result = + urlFactoryBuilder.createFactoryFromDevfile( + remoteUrl, fileContentProvider, emptyMap(), false); + + assertFalse(result.isPresent()); + } + + @Test + public void testDevcontainerFetchIOExceptionReturnsEmpty() throws Exception { + String devfileLocation = "http://repo/raw/devfile.yaml"; + String devcontainerLocation = "http://repo/raw/.devcontainer/devcontainer.json"; + String devcontainerLocation2 = "http://repo/raw/.devcontainer.json"; + + RemoteFactoryUrl remoteUrl = + new RemoteFactoryUrl() { + @Override + public String getProviderName() { + return "test"; + } + + @Override + public List devfileFileLocations() { + return singletonList( + new DevfileLocation() { + @Override + public Optional filename() { + return Optional.of("devfile.yaml"); + } + + @Override + public String location() { + return devfileLocation; + } + }); + } + + @Override + public String rawFileLocation(String filename) { + return "http://repo/raw/" + filename; + } + + @Override + public String getHostName() { + return "repo"; + } + + @Override + public String getProviderUrl() { + return "http://repo"; + } + + @Override + public String getBranch() { + return null; + } + + @Override + public Optional getCredentials() { + return Optional.empty(); + } + + @Override + public void setDevfileFilename(String devfileName) {} + }; + + when(fileContentProvider.fetchContent(eq(devfileLocation))) + .thenThrow(new IOException("not found")); + when(fileContentProvider.fetchContent(eq(devcontainerLocation))) + .thenThrow(new IOException("not found")); + when(fileContentProvider.fetchContent(eq(devcontainerLocation2))) + .thenThrow(new IOException("not found")); + + Optional result = + urlFactoryBuilder.createFactoryFromDevfile( + remoteUrl, fileContentProvider, emptyMap(), false); + + assertFalse(result.isPresent()); + } + @DataProvider public static Object[][] devfileExceptions() { return new Object[][] { From 03fecc1170028d6778e27b5bb5bf329d68dd832b Mon Sep 17 00:00:00 2001 From: Rohan Kumar Date: Fri, 4 Sep 2026 17:14:54 +0530 Subject: [PATCH 02/12] fix(factory): harden devcontainer probe and resync inlined script - Validate fetched content looks like JSON before treating as devcontainer detection (rejects empty responses and HTML error pages) - Use continue instead of break when rawFileLocation returns null - Escalate DevfileException logging from debug to warn - Remove Kubernetes service-account mount and env vars from nested container - Replace opt-in REUSE_CONTAINER with fingerprint-based auto-reuse via che.devcontainer.config label (sha256 of config) - Escape Go template syntax (use jq instead of --format '{{}}') to avoid DWO variable replacement errors - Mark devcontainer CLI runtime install as temporary (tracking PR #267) Co-Authored-By: Claude Opus 4.6 --- .../server/urlfactory/URLFactoryBuilder.java | 49 ++- .../devcontainer-devfile-template.yaml | 65 ++-- .../urlfactory/URLFactoryBuilderTest.java | 295 ++++++++++++++++++ 3 files changed, 373 insertions(+), 36 deletions(-) diff --git a/wsmaster/che-core-api-factory/src/main/java/org/eclipse/che/api/factory/server/urlfactory/URLFactoryBuilder.java b/wsmaster/che-core-api-factory/src/main/java/org/eclipse/che/api/factory/server/urlfactory/URLFactoryBuilder.java index 226bf379c8d..368fee5b477 100644 --- a/wsmaster/che-core-api-factory/src/main/java/org/eclipse/che/api/factory/server/urlfactory/URLFactoryBuilder.java +++ b/wsmaster/che-core-api-factory/src/main/java/org/eclipse/che/api/factory/server/urlfactory/URLFactoryBuilder.java @@ -168,22 +168,30 @@ public Optional createFactoryFromDevfile( for (String devcontainerPath : DEVCONTAINER_LOCATIONS) { String devcontainerLocation = remoteFactoryUrl.rawFileLocation(devcontainerPath); if (devcontainerLocation == null) { - break; + continue; } + String devcontainerContent; try { Optional credentialsOptional = remoteFactoryUrl.getCredentials(); if (skipAuthentication) { - fileContentProvider.fetchContentWithoutAuthentication(devcontainerLocation); + devcontainerContent = + fileContentProvider.fetchContentWithoutAuthentication(devcontainerLocation); } else if (credentialsOptional.isPresent()) { - fileContentProvider.fetchContent(devcontainerLocation, credentialsOptional.get()); + devcontainerContent = + fileContentProvider.fetchContent(devcontainerLocation, credentialsOptional.get()); } else { - fileContentProvider.fetchContent(devcontainerLocation); + devcontainerContent = fileContentProvider.fetchContent(devcontainerLocation); } } catch (IOException ex) { LOG.debug("No devcontainer at: {}. Error: {}", devcontainerLocation, ex.getMessage()); continue; } catch (DevfileException e) { - LOG.debug("Unexpected exception probing devcontainer: {}", e.getMessage()); + LOG.warn("Unexpected exception probing devcontainer: {}", e.getMessage()); + continue; + } + + if (!looksLikeJson(devcontainerContent)) { + LOG.debug("Ignoring non-JSON content from {}", devcontainerLocation); continue; } @@ -218,4 +226,35 @@ private FactoryMetaDto createFactory(JsonNode devfileJson, DevfileLocation locat .withDevfile(devfileParser.convertYamlToMap(devfileJson)) .withSource(location.filename().isPresent() ? location.filename().get() : null); } + + /** + * Cheap probe: returns true when content is non-empty and starts with '{' after skipping + * whitespace and JSONC single-line comments. Does not parse the file. + */ + static boolean looksLikeJson(String content) { + if (isNullOrEmpty(content)) { + return false; + } + boolean inBlockComment = false; + for (String line : content.split("\n")) { + String trimmed = line.trim(); + if (inBlockComment) { + if (trimmed.contains("*/")) { + inBlockComment = false; + } + continue; + } + if (trimmed.isEmpty() || trimmed.startsWith("//")) { + continue; + } + if (trimmed.startsWith("/*")) { + if (!trimmed.contains("*/")) { + inBlockComment = true; + } + continue; + } + return trimmed.startsWith("{"); + } + return false; + } } diff --git a/wsmaster/che-core-api-factory/src/main/resources/devcontainer-devfile-template.yaml b/wsmaster/che-core-api-factory/src/main/resources/devcontainer-devfile-template.yaml index fc56f0e25ac..441ebb26cae 100644 --- a/wsmaster/che-core-api-factory/src/main/resources/devcontainer-devfile-template.yaml +++ b/wsmaster/che-core-api-factory/src/main/resources/devcontainer-devfile-template.yaml @@ -28,8 +28,7 @@ commands: # Env overrides: # CONTAINER_NAME nested container name (default: devcontainer) # IMAGE_NAME built image tag (default: localhost/devcontainer:latest) - # REUSE_CONTAINER=1 reuse a running container instead of rebuilding - # REBUILD=1 force rebuild (remove existing container and image) + # REBUILD=1 force rebuild (remove existing container and image; default: auto-rebuild when config changes) # NO_CACHE=1 rebuild without cache (passes --no-cache to devcontainer build) # STRICT_LIFECYCLE=1 exit non-zero if any lifecycle command fails # NO_KEEP_ID=1 skip --userns=keep-id (debugging) @@ -56,7 +55,6 @@ commands: PROJECT_NAME="$(basename "$PROJECT_DIR")" CONTAINER_NAME="${CONTAINER_NAME:-devcontainer}" IMAGE_NAME="${IMAGE_NAME:-localhost/devcontainer:latest}" - REUSE_CONTAINER="${REUSE_CONTAINER:-0}" REBUILD="${REBUILD:-0}" NO_CACHE="${NO_CACHE:-0}" STRICT_LIFECYCLE="${STRICT_LIFECYCLE:-0}" @@ -84,19 +82,6 @@ commands: [ -n "$PODMAN" ] || { echo "podman not found" >&2; exit 1; } step "1/8" "engine: $PODMAN" - # --------------------------------------------------------------------------- - # 2. Storage + subuid - # --------------------------------------------------------------------------- - # Keep the image store OFF the PVC: subuid-owned files there break DWO's cleanup job and - # wedge the workspace in Error on teardown. Prefer overlay+fuse-overlayfs when /dev/fuse - # exists — VFS is much slower and rules out --userns=keep-id. - mkdir -p /tmp/podman/storage ~/.config/containers - if [ -c /dev/fuse ] && [ -x /usr/bin/fuse-overlayfs ]; then - printf '[storage]\ndriver = "overlay"\ngraphroot = "/tmp/podman/storage"\n[storage.options.overlay]\nmount_program = "/usr/bin/fuse-overlayfs"\n' > ~/.config/containers/storage.conf - else - printf '[storage]\ndriver = "vfs"\ngraphroot = "/tmp/podman/storage"\n' > ~/.config/containers/storage.conf - fi - # Derive the range instead of hardcoding a uid — UDI's entrypoint computes this from the # actual uid, and a hardcoded "user:1001:..." maps the wrong host range on a uid-1000 pod. # NOTE: a 65536-ID userns cannot map container ID 65534 (nobody) however the ranges are @@ -108,16 +93,20 @@ commands: printf '%s\n' "$RANGES" > /etc/subuid 2>/dev/null && printf '%s\n' "$RANGES" > /etc/subgid 2>/dev/null && "$PODMAN" system migrate >/dev/null 2>&1 && - step "2/8" "storage=$("$PODMAN" info --format json 2>/dev/null | jq -r '.store.graphDriverName') subuid=${SU_USER}(${SU_UID})" || - step "2/8" "storage=$("$PODMAN" info --format json 2>/dev/null | jq -r '.store.graphDriverName') subuid=unchanged" + step "2/8" "store=$("$PODMAN" info --format json 2>/dev/null | jq -r '.store.graphDriverName + " @ " + .store.graphRoot') subuid=${SU_USER}(${SU_UID})" || + step "2/8" "store=$("$PODMAN" info --format json 2>/dev/null | jq -r '.store.graphDriverName + " @ " + .store.graphRoot') subuid=unchanged" fi # --------------------------------------------------------------------------- - # 3. devcontainer CLI + pre-build config + # 2. devcontainer CLI + pre-build config # --------------------------------------------------------------------------- DEVCONTAINER_BIN="${DEVCONTAINER_BIN:-$(command -v devcontainer || echo /home/user/.devcontainers/bin/devcontainer)}" if ! "$DEVCONTAINER_BIN" --version >/dev/null 2>&1; then step "3/8" "installing devcontainer CLI..." + # TODO: remove once the devcontainer CLI is bundled in the Universal Developer Image. + # Tracking: https://github.com/devfile/developer-images/pull/267 + # Until then the CLI is installed at workspace start, which requires network access to + # npm/GitHub and is a blocker for airgapped clusters. npm install -g @devcontainers/cli >/dev/null 2>&1 || curl -fsSL https://raw.githubusercontent.com/devcontainers/cli/main/scripts/install.sh | sh DEVCONTAINER_BIN="$(command -v devcontainer || echo /home/user/.devcontainers/bin/devcontainer)" @@ -147,8 +136,10 @@ commands: [ -n "$CONFIG_JSON" ] || { echo " could not parse $DC" >&2; exit 1; } fi + CONFIG_FINGERPRINT="$(printf '%s' "$CONFIG_JSON" | sha256sum | cut -d' ' -f1)" + # --------------------------------------------------------------------------- - # 4. initializeCommand (runs OUTSIDE the container, per spec) + # 3. initializeCommand (runs OUTSIDE the container, per spec) # --------------------------------------------------------------------------- JQ_NORMALIZE=' def norm($name): @@ -175,16 +166,26 @@ commands: run_outer "$(printf '%s' "$CONFIG_JSON" | jq -c '.initializeCommand // null')" # --------------------------------------------------------------------------- - # 5. Build + # 4. Build # --------------------------------------------------------------------------- REUSING=0 if [ "$REBUILD" = "1" ]; then "$PODMAN" rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true - "$PODMAN" rmi -f "$IMAGE_NAME" >/dev/null 2>&1 || true + [ "$NO_CACHE" = "1" ] && { "$PODMAN" rmi -f "$IMAGE_NAME" >/dev/null 2>&1 || true; } + elif "$PODMAN" container exists "$CONTAINER_NAME" 2>/dev/null; then + STORED_FP="$("$PODMAN" inspect --format json "$CONTAINER_NAME" 2>/dev/null \ + | jq -r '.[0].Config.Labels["che.devcontainer.config"] // ""' 2>/dev/null || echo "")" + if [ "$STORED_FP" = "$CONFIG_FINGERPRINT" ]; then + REUSING=1; step "5/8" "reusing existing container (config unchanged)." + "$PODMAN" start "$CONTAINER_NAME" >/dev/null 2>&1 || true + else + REUSING=1; step "5/8" "reusing existing container (config STALE)." + echo " WARNING: devcontainer.json changed since this container was built." + echo " Run the 'Rebuild dev container' task to apply changes." + "$PODMAN" start "$CONTAINER_NAME" >/dev/null 2>&1 || true + fi fi - if [ "$REBUILD" != "1" ] && [ "$REUSE_CONTAINER" = "1" ] && "$PODMAN" container exists "$CONTAINER_NAME" 2>/dev/null; then - REUSING=1; echo "[5/8] reusing existing container."; "$PODMAN" start "$CONTAINER_NAME" >/dev/null 2>&1 || true - else + if [ "$REUSING" = "0" ]; then step "5/8" "building image (slow part)..." BUILD_ARGS=(--docker-path="$PODMAN" --workspace-folder "$PROJECT_DIR" --image-name "$IMAGE_NAME") [ "$NO_CACHE" = "1" ] && BUILD_ARGS+=(--no-cache) @@ -193,7 +194,7 @@ commands: fi # --------------------------------------------------------------------------- - # 6. Merged metadata from the built image + # 5. Merged metadata from the built image # --------------------------------------------------------------------------- # `devcontainer build` writes a devcontainer.metadata LABEL containing the FULLY MERGED # config — the base image's metadata, every Feature's contributions, and devcontainer.json. @@ -248,7 +249,7 @@ commands: [ -n "$skipped" ] && echo " NOTE: env with \${...} substitution skipped: $skipped" >&2 # --------------------------------------------------------------------------- - # 7. Run the container, with UID parity + # 6. Run the container, with UID parity # --------------------------------------------------------------------------- # The whole point of outer-editor is "edit outside, run inside" — so the two sides must agree # on file ownership. Without keep-id, anything created inside (node_modules, build output, @@ -269,14 +270,16 @@ commands: fi fi + # The dev container does NOT receive cluster credentials. It runs arbitrary repository + # code and with --network=host already reaches che-code (:3100) and machine-exec (:3333). + # VS Code dev containers do not expose Kubernetes service-account tokens; cluster tooling + # lives in UDI — use the "outer (UDI)" terminal profile for kubectl/oc. start_container() { # $1 = extra args array name local -n extra="$1" "$PODMAN" run -d --name "$CONTAINER_NAME" --network=host \ + --label "che.devcontainer.config=${CONFIG_FINGERPRINT}" \ "${extra[@]}" \ -v "$PROJECT_DIR:${WORKSPACE_FOLDER}" \ - -v /var/run/secrets/kubernetes.io/serviceaccount:/var/run/secrets/kubernetes.io/serviceaccount:ro \ - -e KUBERNETES_SERVICE_HOST="${KUBERNETES_SERVICE_HOST:-}" \ - -e KUBERNETES_SERVICE_PORT="${KUBERNETES_SERVICE_PORT:-}" \ "${CONTAINER_ENV[@]}" \ "$IMAGE_NAME" sleep infinity } @@ -304,7 +307,7 @@ commands: "$PODMAN" exec "$CONTAINER_NAME" git config --global --replace-all safe.directory "$WORKSPACE_FOLDER" 2>/dev/null || true # --------------------------------------------------------------------------- - # 8. Lifecycle commands, then editor wiring + # 7. Lifecycle commands, then editor wiring # --------------------------------------------------------------------------- EXEC_USER=(); [ -n "$REMOTE_USER" ] && EXEC_USER=(-u "$REMOTE_USER") CREATE_MARKER=/tmp/.devcontainer-create-hooks-done diff --git a/wsmaster/che-core-api-factory/src/test/java/org/eclipse/che/api/factory/server/urlfactory/URLFactoryBuilderTest.java b/wsmaster/che-core-api-factory/src/test/java/org/eclipse/che/api/factory/server/urlfactory/URLFactoryBuilderTest.java index 1b78840e837..904fd2acb7b 100644 --- a/wsmaster/che-core-api-factory/src/test/java/org/eclipse/che/api/factory/server/urlfactory/URLFactoryBuilderTest.java +++ b/wsmaster/che-core-api-factory/src/test/java/org/eclipse/che/api/factory/server/urlfactory/URLFactoryBuilderTest.java @@ -702,6 +702,301 @@ public void setDevfileFilename(String devfileName) {} assertFalse(result.isPresent()); } + @Test + public void testDevcontainerEmptyContentReturnsEmpty() throws Exception { + String devfileLocation = "http://repo/raw/devfile.yaml"; + String devcontainerLocation = "http://repo/raw/.devcontainer/devcontainer.json"; + String devcontainerLocation2 = "http://repo/raw/.devcontainer.json"; + + RemoteFactoryUrl remoteUrl = + new RemoteFactoryUrl() { + @Override + public String getProviderName() { + return "test"; + } + + @Override + public List devfileFileLocations() { + return singletonList( + new DevfileLocation() { + @Override + public Optional filename() { + return Optional.of("devfile.yaml"); + } + + @Override + public String location() { + return devfileLocation; + } + }); + } + + @Override + public String rawFileLocation(String filename) { + return "http://repo/raw/" + filename; + } + + @Override + public String getHostName() { + return "repo"; + } + + @Override + public String getProviderUrl() { + return "http://repo"; + } + + @Override + public String getBranch() { + return null; + } + + @Override + public Optional getCredentials() { + return Optional.empty(); + } + + @Override + public void setDevfileFilename(String devfileName) {} + }; + + when(fileContentProvider.fetchContent(eq(devfileLocation))) + .thenThrow(new IOException("not found")); + when(fileContentProvider.fetchContent(eq(devcontainerLocation))).thenReturn(""); + when(fileContentProvider.fetchContent(eq(devcontainerLocation2))).thenReturn(""); + + Optional result = + urlFactoryBuilder.createFactoryFromDevfile( + remoteUrl, fileContentProvider, emptyMap(), false); + + assertFalse(result.isPresent()); + } + + @Test + public void testDevcontainerHtmlContentReturnsEmpty() throws Exception { + String devfileLocation = "http://repo/raw/devfile.yaml"; + String devcontainerLocation = "http://repo/raw/.devcontainer/devcontainer.json"; + String devcontainerLocation2 = "http://repo/raw/.devcontainer.json"; + + RemoteFactoryUrl remoteUrl = + new RemoteFactoryUrl() { + @Override + public String getProviderName() { + return "test"; + } + + @Override + public List devfileFileLocations() { + return singletonList( + new DevfileLocation() { + @Override + public Optional filename() { + return Optional.of("devfile.yaml"); + } + + @Override + public String location() { + return devfileLocation; + } + }); + } + + @Override + public String rawFileLocation(String filename) { + return "http://repo/raw/" + filename; + } + + @Override + public String getHostName() { + return "repo"; + } + + @Override + public String getProviderUrl() { + return "http://repo"; + } + + @Override + public String getBranch() { + return null; + } + + @Override + public Optional getCredentials() { + return Optional.empty(); + } + + @Override + public void setDevfileFilename(String devfileName) {} + }; + + when(fileContentProvider.fetchContent(eq(devfileLocation))) + .thenThrow(new IOException("not found")); + when(fileContentProvider.fetchContent(eq(devcontainerLocation))) + .thenReturn("Access denied"); + when(fileContentProvider.fetchContent(eq(devcontainerLocation2))) + .thenReturn("Access denied"); + + Optional result = + urlFactoryBuilder.createFactoryFromDevfile( + remoteUrl, fileContentProvider, emptyMap(), false); + + assertFalse(result.isPresent()); + } + + @Test + public void testDevcontainerJsoncWithLeadingCommentDetected() throws Exception { + String devfileLocation = "http://repo/raw/devfile.yaml"; + String devcontainerLocation = "http://repo/raw/.devcontainer/devcontainer.json"; + + RemoteFactoryUrl remoteUrl = + new RemoteFactoryUrl() { + @Override + public String getProviderName() { + return "test"; + } + + @Override + public List devfileFileLocations() { + return singletonList( + new DevfileLocation() { + @Override + public Optional filename() { + return Optional.of("devfile.yaml"); + } + + @Override + public String location() { + return devfileLocation; + } + }); + } + + @Override + public String rawFileLocation(String filename) { + return "http://repo/raw/" + filename; + } + + @Override + public String getHostName() { + return "repo"; + } + + @Override + public String getProviderUrl() { + return "http://repo"; + } + + @Override + public String getBranch() { + return null; + } + + @Override + public Optional getCredentials() { + return Optional.empty(); + } + + @Override + public void setDevfileFilename(String devfileName) {} + }; + + Map templateAdditions = + Map.of("commands", List.of(Map.of("id", "start-devcontainer"))); + + when(fileContentProvider.fetchContent(eq(devfileLocation))) + .thenThrow(new IOException("not found")); + when(fileContentProvider.fetchContent(eq(devcontainerLocation))) + .thenReturn("// This is a JSONC comment\n{\"name\": \"test\"}"); + JsonNode templateNode = new ObjectNode(JsonNodeFactory.instance); + when(devfileParser.parseYamlRaw(anyString())).thenReturn(templateNode); + when(devfileParser.convertYamlToMap(templateNode)).thenReturn(templateAdditions); + + Optional result = + urlFactoryBuilder.createFactoryFromDevfile( + remoteUrl, fileContentProvider, emptyMap(), false); + + assertTrue(result.isPresent()); + assertEquals(result.get().getSource(), ".devcontainer/devcontainer.json"); + assertTrue(result.get() instanceof FactoryDevfileV2Dto); + } + + @Test + public void testDevcontainerJsoncWithBlockCommentDetected() throws Exception { + String devfileLocation = "http://repo/raw/devfile.yaml"; + String devcontainerLocation = "http://repo/raw/.devcontainer/devcontainer.json"; + + RemoteFactoryUrl remoteUrl = + new RemoteFactoryUrl() { + @Override + public String getProviderName() { + return "test"; + } + + @Override + public List devfileFileLocations() { + return singletonList( + new DevfileLocation() { + @Override + public Optional filename() { + return Optional.of("devfile.yaml"); + } + + @Override + public String location() { + return devfileLocation; + } + }); + } + + @Override + public String rawFileLocation(String filename) { + return "http://repo/raw/" + filename; + } + + @Override + public String getHostName() { + return "repo"; + } + + @Override + public String getProviderUrl() { + return "http://repo"; + } + + @Override + public String getBranch() { + return null; + } + + @Override + public Optional getCredentials() { + return Optional.empty(); + } + + @Override + public void setDevfileFilename(String devfileName) {} + }; + + Map templateAdditions = + Map.of("commands", List.of(Map.of("id", "start-devcontainer"))); + + when(fileContentProvider.fetchContent(eq(devfileLocation))) + .thenThrow(new IOException("not found")); + when(fileContentProvider.fetchContent(eq(devcontainerLocation))) + .thenReturn("/*\n * Generated config\n */\n{\"name\": \"test\"}"); + JsonNode templateNode = new ObjectNode(JsonNodeFactory.instance); + when(devfileParser.parseYamlRaw(anyString())).thenReturn(templateNode); + when(devfileParser.convertYamlToMap(templateNode)).thenReturn(templateAdditions); + + Optional result = + urlFactoryBuilder.createFactoryFromDevfile( + remoteUrl, fileContentProvider, emptyMap(), false); + + assertTrue(result.isPresent()); + assertEquals(result.get().getSource(), ".devcontainer/devcontainer.json"); + } + @DataProvider public static Object[][] devfileExceptions() { return new Object[][] { From ebac2a02958bbef806a83d4781e7f2c7da19ea60 Mon Sep 17 00:00:00 2001 From: Rohan Kumar Date: Wed, 9 Sep 2026 18:14:58 +0530 Subject: [PATCH 03/12] fix : do not update in repo vscode settings if already provided to add devcontainer vscode settings Signed-off-by: Rohan Kumar --- .../devcontainer-devfile-template.yaml | 108 +++++++++++++++--- 1 file changed, 94 insertions(+), 14 deletions(-) diff --git a/wsmaster/che-core-api-factory/src/main/resources/devcontainer-devfile-template.yaml b/wsmaster/che-core-api-factory/src/main/resources/devcontainer-devfile-template.yaml index 441ebb26cae..f778c842158 100644 --- a/wsmaster/che-core-api-factory/src/main/resources/devcontainer-devfile-template.yaml +++ b/wsmaster/che-core-api-factory/src/main/resources/devcontainer-devfile-template.yaml @@ -19,6 +19,18 @@ commands: commandLine: | cat > /tmp/start-devcontainer.sh <<'DEVCONTAINER_SCRIPT_EOF' #!/usr/bin/env bash + # + # Copyright (c) 2012-2026 Red Hat, Inc. + # 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 + # + # Contributors: + # Red Hat, Inc. - initial API and implementation + # + # # Outer editor + inner devcontainer. # che-code stays in the UDI container; the repo's devcontainer.json is built and run as a @@ -35,6 +47,28 @@ commands: # set -uo pipefail + # Only one instance may touch the container and the podman store at a time. postStart runs this + # while the user can also launch the start/rebuild task, and both would race on `podman rm -f`. + LOCK_FILE="${LOCK_FILE:-/tmp/.devcontainer-setup.lock}" + if command -v flock >/dev/null 2>&1; then + exec 9>"$LOCK_FILE" || true + if ! flock -n 9; then + HOLDER="$(cat "$LOCK_FILE" 2>/dev/null | tr -dc '0-9')" + if [ "${REBUILD:-0}" != "1" ]; then + echo "another devcontainer setup is in progress (pid ${HOLDER:-?}); exiting (not a rebuild)." + exit 0 + fi + echo "rebuild requested; killing in-progress setup (pid ${HOLDER:-?})..." + [ -n "$HOLDER" ] && kill "$HOLDER" 2>/dev/null && sleep 1 + # After killing, the lock should be free — try once more + if ! flock -n 9; then + [ -n "$HOLDER" ] && kill -9 "$HOLDER" 2>/dev/null && sleep 1 + flock -w 30 9 || { echo "could not acquire lock after killing previous run" >&2; exit 1; } + fi + fi + echo $$ > "$LOCK_FILE" + fi + PROJECTS_ROOT="${PROJECTS_ROOT:-/projects}" # Project: explicit arg > DWO's PROJECT_SOURCE > the only directory under PROJECTS_ROOT. if [ "$#" -ge 1 ] && [ -n "${1:-}" ]; then @@ -82,6 +116,21 @@ commands: [ -n "$PODMAN" ] || { echo "podman not found" >&2; exit 1; } step "1/8" "engine: $PODMAN" + # --------------------------------------------------------------------------- + # 2. Subuid ranges + # --------------------------------------------------------------------------- + # No storage.conf is written here. UDI's entrypoint (base/ubi*/entrypoint.sh) already selects + # overlay+fuse-overlayfs when /dev/fuse is present and falls back to vfs otherwise, so the + # driver choice is not ours to repeat, and the graphroot is left at podman's rootless default + # (${XDG_DATA_HOME:-$HOME/.local/share}/containers/storage). + # CAVEAT: with devEnvironments.persistUserHome enabled (the Che default) $HOME is PVC-backed, + # so the image store lands on the workspace PVC. Under DWO's common storage strategy + # (including Che's per-user strategy), the cleanup Job removes the workspace directory on + # deletion and subuid-owned files can wedge the workspace in Error: + # https://github.com/eclipse-che/che/issues/23924. On CRC (2026-09-08), the PVC-backed image + # build store was deleted cleanly with another workspace running. Full devcontainer teardown + # remains unverified there because nested-container startup failed. + # Derive the range instead of hardcoding a uid — UDI's entrypoint computes this from the # actual uid, and a hardcoded "user:1001:..." maps the wrong host range on a uid-1000 pod. # NOTE: a 65536-ID userns cannot map container ID 65534 (nobody) however the ranges are @@ -98,7 +147,7 @@ commands: fi # --------------------------------------------------------------------------- - # 2. devcontainer CLI + pre-build config + # 3. devcontainer CLI + pre-build config # --------------------------------------------------------------------------- DEVCONTAINER_BIN="${DEVCONTAINER_BIN:-$(command -v devcontainer || echo /home/user/.devcontainers/bin/devcontainer)}" if ! "$DEVCONTAINER_BIN" --version >/dev/null 2>&1; then @@ -139,7 +188,7 @@ commands: CONFIG_FINGERPRINT="$(printf '%s' "$CONFIG_JSON" | sha256sum | cut -d' ' -f1)" # --------------------------------------------------------------------------- - # 3. initializeCommand (runs OUTSIDE the container, per spec) + # 4. initializeCommand (runs OUTSIDE the container, per spec) # --------------------------------------------------------------------------- JQ_NORMALIZE=' def norm($name): @@ -166,7 +215,7 @@ commands: run_outer "$(printf '%s' "$CONFIG_JSON" | jq -c '.initializeCommand // null')" # --------------------------------------------------------------------------- - # 4. Build + # 5. Build # --------------------------------------------------------------------------- REUSING=0 if [ "$REBUILD" = "1" ]; then @@ -194,7 +243,7 @@ commands: fi # --------------------------------------------------------------------------- - # 5. Merged metadata from the built image + # 6. Merged metadata from the built image # --------------------------------------------------------------------------- # `devcontainer build` writes a devcontainer.metadata LABEL containing the FULLY MERGED # config — the base image's metadata, every Feature's contributions, and devcontainer.json. @@ -249,7 +298,7 @@ commands: [ -n "$skipped" ] && echo " NOTE: env with \${...} substitution skipped: $skipped" >&2 # --------------------------------------------------------------------------- - # 6. Run the container, with UID parity + # 7. Run the container, with UID parity # --------------------------------------------------------------------------- # The whole point of outer-editor is "edit outside, run inside" — so the two sides must agree # on file ownership. Without keep-id, anything created inside (node_modules, build output, @@ -307,7 +356,7 @@ commands: "$PODMAN" exec "$CONTAINER_NAME" git config --global --replace-all safe.directory "$WORKSPACE_FOLDER" 2>/dev/null || true # --------------------------------------------------------------------------- - # 7. Lifecycle commands, then editor wiring + # 8. Lifecycle commands, then editor wiring # --------------------------------------------------------------------------- EXEC_USER=(); [ -n "$REMOTE_USER" ] && EXEC_USER=(-u "$REMOTE_USER") CREATE_MARKER=/tmp/.devcontainer-create-hooks-done @@ -355,6 +404,20 @@ commands: mkdir -p "$(dirname "$file")"; printf '%s\n' "$cur" | jq '.' > "$file" } + # .vscode/extensions.json is the one generated file that must live in the repo — VS Code reads + # recommendations only from there. Keep it out of `git status` via .git/info/exclude, which is + # local to the clone and never committed. Ignore rules do not apply to files git already tracks, + # so a repo that commits its own .vscode/extensions.json is left completely alone. + safe_to_write() { # $1 = path relative to the project root; returns 0 when writing is OK + local rel="$1" gitdir ex + gitdir="$(git -C "$PROJECT_DIR" rev-parse --absolute-git-dir 2>/dev/null)" || return 0 + git -C "$PROJECT_DIR" ls-files --error-unmatch "$rel" >/dev/null 2>&1 && return 1 + ex="$gitdir/info/exclude" + mkdir -p "$(dirname "$ex")" 2>/dev/null || return 0 + grep -qxF "$rel" "$ex" 2>/dev/null || printf '%s\n' "$rel" >> "$ex" + return 0 + } + profile=(exec -it) [ -n "$REMOTE_USER" ] && profile+=(-u "$REMOTE_USER") profile+=("${REMOTE_ENV[@]}" -w "$WORKSPACE_FOLDER" "$CONTAINER_NAME" "$INNER_SHELL") @@ -370,29 +433,46 @@ commands: # untrusted workspace, so they must go to MACHINE settings — which is also the file che-code's # launcher merges the vscode-editor-configurations ConfigMap into, and it keeps the repo clean. MACHINE_SETTINGS="${CHE_MACHINE_SETTINGS:-/checode/remote/data/Machine/settings.json}" - if mkdir -p "$(dirname "$MACHINE_SETTINGS")" 2>/dev/null; then + MACHINE_OK=0 + mkdir -p "$(dirname "$MACHINE_SETTINGS")" 2>/dev/null && MACHINE_OK=1 + + if [ "$MACHINE_OK" = 1 ]; then merge_into "$MACHINE_SETTINGS" "$terminal" echo " terminal profile -> ${MACHINE_SETTINGS} (reload the window if it does not appear)" else echo " WARNING: ${MACHINE_SETTINGS} unwritable; terminal profile will not apply." >&2 fi - # Editor-safe settings are unrestricted, so the workspace file is fine for those. + # Editor settings go to MACHINE settings too, not the repo: the user should not find unexplained + # modifications in a tree they did not touch. /checode is recreated on each workspace start, so + # nothing written here survives into a later session with a different project. + # NOTE: machine scope is editor-wide, not per-folder. With one project — the normal case for a + # factory workspace — that is indistinguishable from workspace scope; with several projects under + # $PROJECTS_ROOT the last run wins. SAFE='editor.|files.|workbench.|search.|explorer.|diffEditor.|breadcrumbs.|scm.|outline.|problems.|output.|window.|comments.' editor_settings="$(printf '%s' "$META" | jq -c --arg p "$SAFE" '(.settings // {}) | with_entries(select(.key as $k | ($p|split("|")|any(. as $x | $k|startswith($x)))))')" - [ "$(printf '%s' "$editor_settings" | jq 'length')" -gt 0 ] && - merge_into "${PROJECT_DIR}/.vscode/settings.json" "$editor_settings" && - echo " editor settings -> .vscode/settings.json" + if [ "$(printf '%s' "$editor_settings" | jq 'length')" -gt 0 ]; then + if [ "$MACHINE_OK" = 1 ]; then + merge_into "$MACHINE_SETTINGS" "$editor_settings" + echo " editor settings -> ${MACHINE_SETTINGS}" + else + echo " WARNING: ${MACHINE_SETTINGS} unwritable; editor settings not applied." >&2 + fi + fi # Surface the extensions the devcontainer asks for (including Feature-contributed ones) as # workspace recommendations. che-code cannot auto-install them, but this at least tells the # user what the repo expects instead of silently dropping the list. exts="$(printf '%s' "$META" | jq -c '.extensions // []')" if [ "$(printf '%s' "$exts" | jq 'length')" -gt 0 ]; then - merge_into "${PROJECT_DIR}/.vscode/extensions.json" \ - "$(jq -n --argjson e "$exts" '{recommendations: $e}')" - echo " recommended extensions -> .vscode/extensions.json: $(printf '%s' "$exts" | jq -r 'join(", ")')" + if safe_to_write .vscode/extensions.json; then + merge_into "${PROJECT_DIR}/.vscode/extensions.json" \ + "$(jq -n --argjson e "$exts" '{recommendations: $e}')" + echo " recommended extensions -> .vscode/extensions.json: $(printf '%s' "$exts" | jq -r 'join(", ")')" + else + echo " recommended extensions (not written, .vscode/extensions.json is tracked): $(printf '%s' "$exts" | jq -r 'join(", ")')" + fi fi _close_phase From 15f954c43b608829ff6e3a2d99c165aefff7ed2d Mon Sep 17 00:00:00 2001 From: Rohan Kumar Date: Thu, 10 Sep 2026 22:11:01 +0530 Subject: [PATCH 04/12] fix : remove postStart command for automatic execution of devcontainer command Signed-off-by: Rohan Kumar --- .../src/main/resources/devcontainer-devfile-template.yaml | 3 --- 1 file changed, 3 deletions(-) diff --git a/wsmaster/che-core-api-factory/src/main/resources/devcontainer-devfile-template.yaml b/wsmaster/che-core-api-factory/src/main/resources/devcontainer-devfile-template.yaml index f778c842158..df1d4d60657 100644 --- a/wsmaster/che-core-api-factory/src/main/resources/devcontainer-devfile-template.yaml +++ b/wsmaster/che-core-api-factory/src/main/resources/devcontainer-devfile-template.yaml @@ -533,6 +533,3 @@ commands: [ -x "$PODMAN" ] || PODMAN=podman "$PODMAN" system prune -af "$PODMAN" system df -events: - postStart: - - start-devcontainer From 6cc7b58e299fb800ffc25682b5ecbe372710c92f Mon Sep 17 00:00:00 2001 From: Rohan Kumar Date: Fri, 11 Sep 2026 17:12:37 +0530 Subject: [PATCH 05/12] fix: validate devcontainer execution and publish terminal runtime --- .../devcontainer-devfile-template.yaml | 137 ++++++++++++------ 1 file changed, 93 insertions(+), 44 deletions(-) diff --git a/wsmaster/che-core-api-factory/src/main/resources/devcontainer-devfile-template.yaml b/wsmaster/che-core-api-factory/src/main/resources/devcontainer-devfile-template.yaml index df1d4d60657..878ed989706 100644 --- a/wsmaster/che-core-api-factory/src/main/resources/devcontainer-devfile-template.yaml +++ b/wsmaster/che-core-api-factory/src/main/resources/devcontainer-devfile-template.yaml @@ -44,31 +44,34 @@ commands: # NO_CACHE=1 rebuild without cache (passes --no-cache to devcontainer build) # STRICT_LIFECYCLE=1 exit non-zero if any lifecycle command fails # NO_KEEP_ID=1 skip --userns=keep-id (debugging) + # CHE_DEVCONTAINER_RUNTIME terminal description (default: /tmp/che-devcontainer/runtime.json) + # PREFLIGHT_IMAGE runnable probe image (default: quay.io/podman/hello:latest; override for a registry mirror) # set -uo pipefail - # Only one instance may touch the container and the podman store at a time. postStart runs this - # while the user can also launch the start/rebuild task, and both would race on `podman rm -f`. + # Serialize setup and rebuild without interrupting an active build. Opening in append mode + # preserves the current owner's PID for the extension while another invocation waits. LOCK_FILE="${LOCK_FILE:-/tmp/.devcontainer-setup.lock}" + LOCK_HELD=0 if command -v flock >/dev/null 2>&1; then - exec 9>"$LOCK_FILE" || true + exec 9>>"$LOCK_FILE" || { echo "cannot open setup lock: $LOCK_FILE" >&2; exit 1; } if ! flock -n 9; then - HOLDER="$(cat "$LOCK_FILE" 2>/dev/null | tr -dc '0-9')" - if [ "${REBUILD:-0}" != "1" ]; then - echo "another devcontainer setup is in progress (pid ${HOLDER:-?}); exiting (not a rebuild)." - exit 0 - fi - echo "rebuild requested; killing in-progress setup (pid ${HOLDER:-?})..." - [ -n "$HOLDER" ] && kill "$HOLDER" 2>/dev/null && sleep 1 - # After killing, the lock should be free — try once more - if ! flock -n 9; then - [ -n "$HOLDER" ] && kill -9 "$HOLDER" 2>/dev/null && sleep 1 - flock -w 30 9 || { echo "could not acquire lock after killing previous run" >&2; exit 1; } - fi + echo "another devcontainer setup is in progress; waiting..." + flock -w 900 9 || { echo "timed out waiting for the in-progress setup" >&2; exit 1; } fi - echo $$ > "$LOCK_FILE" + LOCK_HELD=1 + printf '%s\n' "$$" > "$LOCK_FILE" fi + # The parent owns the lock and stays alive for the extension's PID-based build detection. + # Close the descriptor in the setup child so conmon, fuse-overlayfs, and lifecycle processes + # cannot retain it after setup finishes. + ( + exec 9>&- + RUNTIME_FILE="${CHE_DEVCONTAINER_RUNTIME:-/tmp/che-devcontainer/runtime.json}" + # Invalidate the previous terminal configuration while holding the setup lock. A failed + # setup must never leave a description that the extension could mistake for a ready result. + rm -f -- "$RUNTIME_FILE" || exit 1 PROJECTS_ROOT="${PROJECTS_ROOT:-/projects}" # Project: explicit arg > DWO's PROJECT_SOURCE > the only directory under PROJECTS_ROOT. if [ "$#" -ge 1 ] && [ -n "${1:-}" ]; then @@ -116,6 +119,33 @@ commands: [ -n "$PODMAN" ] || { echo "podman not found" >&2; exit 1; } step "1/8" "engine: $PODMAN" + # --------------------------------------------------------------------------- + # 1b. Preflight: can this workspace run nested containers at all? + # --------------------------------------------------------------------------- + # Exercise the same engine and host networking used by start_container. Podman info's + # capability and UID-map fields do not prove that a container can actually start. + # This checks basic execution only, not project builds, bind mounts, or keep-id mappings. + PREFLIGHT_IMAGE="${PREFLIGHT_IMAGE:-quay.io/podman/hello:latest}" + PREFLIGHT_CONTAINER="che-devcontainer-preflight-$$" + step "1b" "checking nested container execution (timeout: 120s)..." + PREFLIGHT_RC=0 + timeout --kill-after=5s 120s "$PODMAN" run --rm --network=host \ + --name "$PREFLIGHT_CONTAINER" "$PREFLIGHT_IMAGE" 9>&- || PREFLIGHT_RC=$? + if [ "$PREFLIGHT_RC" -ne 0 ]; then + # A timeout or runtime failure may leave a partially created container. Only remove + # this probe's container, and bound cleanup as well. Preserve the original failure. + timeout --kill-after=5s 10s "$PODMAN" rm -f "$PREFLIGHT_CONTAINER" \ + >/dev/null 2>&1 9>&- || true + if [ "$PREFLIGHT_RC" -eq 124 ] || [ "$PREFLIGHT_RC" -eq 137 ]; then + echo "Nested-container preflight timed out; devcontainer setup stopped." >&2 + else + echo "Nested-container preflight failed (exit ${PREFLIGHT_RC}); devcontainer setup stopped." >&2 + fi + echo "See the Podman error above for the cause (for example, image pull or container runtime failure)." >&2 + exit "$PREFLIGHT_RC" + fi + step "1b" "nested container execution succeeded" + # --------------------------------------------------------------------------- # 2. Subuid ranges # --------------------------------------------------------------------------- @@ -322,7 +352,7 @@ commands: # The dev container does NOT receive cluster credentials. It runs arbitrary repository # code and with --network=host already reaches che-code (:3100) and machine-exec (:3333). # VS Code dev containers do not expose Kubernetes service-account tokens; cluster tooling - # lives in UDI — use the "outer (UDI)" terminal profile for kubectl/oc. + # lives in UDI — use a regular editor terminal for kubectl/oc. start_container() { # $1 = extra args array name local -n extra="$1" "$PODMAN" run -d --name "$CONTAINER_NAME" --network=host \ @@ -418,31 +448,11 @@ commands: return 0 } - profile=(exec -it) - [ -n "$REMOTE_USER" ] && profile+=(-u "$REMOTE_USER") - profile+=("${REMOTE_ENV[@]}" -w "$WORKSPACE_FOLDER" "$CONTAINER_NAME" "$INNER_SHELL") - profile_json="$(printf '%s\n' "${profile[@]}" | jq -R . | jq -s -c .)" - terminal="$(jq -n --argjson a "$profile_json" --arg p "$PODMAN" '{ - "terminal.integrated.profiles.linux": { - "devcontainer": { "path": $p, "args": $a, "icon": "container" }, - "outer (UDI)": { "path": "/bin/bash" } }, - "terminal.integrated.defaultProfile.linux": "devcontainer" }')" - - # terminal.integrated.profiles.* and defaultProfile.* are declared `restricted: true` in - # VS Code. Restricted settings coming from WORKSPACE settings are silently discarded in an - # untrusted workspace, so they must go to MACHINE settings — which is also the file che-code's - # launcher merges the vscode-editor-configurations ConfigMap into, and it keeps the repo clean. + # Terminal configuration is owned by the extension; only editor settings belong here. MACHINE_SETTINGS="${CHE_MACHINE_SETTINGS:-/checode/remote/data/Machine/settings.json}" MACHINE_OK=0 mkdir -p "$(dirname "$MACHINE_SETTINGS")" 2>/dev/null && MACHINE_OK=1 - if [ "$MACHINE_OK" = 1 ]; then - merge_into "$MACHINE_SETTINGS" "$terminal" - echo " terminal profile -> ${MACHINE_SETTINGS} (reload the window if it does not appear)" - else - echo " WARNING: ${MACHINE_SETTINGS} unwritable; terminal profile will not apply." >&2 - fi - # Editor settings go to MACHINE settings too, not the repo: the user should not find unexplained # modifications in a tree they did not touch. /checode is recreated on each workspace start, so # nothing written here survives into a later session with a different project. @@ -475,6 +485,41 @@ commands: fi fi + if [ "$LIFECYCLE_FAILURES" -gt 0 ]; then + echo; echo " WARNING: ${LIFECYCLE_FAILURES} lifecycle command(s) failed." + [ "$STRICT_LIFECYCLE" = "1" ] && exit 1 + fi + + # Publish only a successful, running container. The ID binds these resolved values to + # this instance, so a replacement container cannot reuse an obsolete terminal description. + publish_runtime() ( + umask 077 + local runtime_dir runtime_tmp container_id remote_env_json + runtime_dir="$(dirname "$RUNTIME_FILE")" + mkdir -p "$runtime_dir" || return 1 + container_id="$("$PODMAN" inspect --format json "$CONTAINER_NAME" | jq -er \ + '.[0] | select(.State.Running == true) | .Id | select(type == "string" and length > 0)')" || return 1 + # Use exactly the already-resolved environment arguments passed to lifecycle commands. + remote_env_json='{}' + local i entry key value + for ((i=1; i<${#REMOTE_ENV[@]}; i+=2)); do + entry="${REMOTE_ENV[i]}"; key="${entry%%=*}"; value="${entry#*=}" + remote_env_json="$(jq -cn --argjson env "$remote_env_json" --arg k "$key" --arg v "$value" '$env + {($k): $v}')" || return 1 + done + runtime_tmp="$(mktemp "$runtime_dir/.runtime.XXXXXX")" || return 1 + trap 'rm -f -- "$runtime_tmp"' EXIT + jq -n --arg containerName "$CONTAINER_NAME" --arg containerId "$container_id" \ + --arg podmanPath "$PODMAN" --arg image "$IMAGE_NAME" --arg remoteUser "$REMOTE_USER" \ + --arg workspaceFolder "$WORKSPACE_FOLDER" --arg shell "$INNER_SHELL" \ + --arg fingerprint "$CONFIG_FINGERPRINT" --argjson remoteEnv "$remote_env_json" \ + '{version: 1, containerName: $containerName, containerId: $containerId, + podmanPath: $podmanPath, image: $image, remoteUser: $remoteUser, + workspaceFolder: $workspaceFolder, shell: $shell, remoteEnv: $remoteEnv, + fingerprint: $fingerprint}' > "$runtime_tmp" || return 1 + mv -f -- "$runtime_tmp" "$RUNTIME_FILE" + ) + publish_runtime || { echo "could not publish devcontainer terminal configuration" >&2; exit 1; } + _close_phase echo echo "=== ready ===" @@ -483,22 +528,26 @@ commands: d="${e%%|*}"; l="${e#*|}"; [ "$d" -ge 2 ] && printf ' | %s %ss' "${l%% *}" "$d" done echo - echo " Terminals open inside the container (profile 'devcontainer')." + echo " Use the extension action: Open Terminal in Dev Container." echo " Files: ${PROJECT_DIR} <-> ${WORKSPACE_FOLDER}" [ "$KEEP_ID_OK" = "1" ] && echo " UID parity ON — files created inside are owned by you." \ || echo " UID parity OFF — files created inside are subuid-owned." echo " Shell: ${PODMAN} exec -it ${CONTAINER_NAME} ${INNER_SHELL}" - if [ "$LIFECYCLE_FAILURES" -gt 0 ]; then - echo; echo " WARNING: ${LIFECYCLE_FAILURES} lifecycle command(s) failed." - [ "$STRICT_LIFECYCLE" = "1" ] && exit 1 - fi exit 0 + ) + SETUP_RC=$? + # Clear the published PID while we still own the lock, before another run can acquire it. + if [ "$LOCK_HELD" = 1 ]; then + : > "$LOCK_FILE" + fi + exit "$SETUP_RC" DEVCONTAINER_SCRIPT_EOF chmod +x /tmp/start-devcontainer.sh # Run in the FOREGROUND so che-code keeps the task terminal alive and streams progress. # A backgrounded (setsid/nohup &) process is reaped when the task's foreground shell exits, # so it never survived. tee keeps /tmp/devcontainer.log for anyone who wants to tail it. - bash /tmp/start-devcontainer.sh 2>&1 | tee /tmp/devcontainer.log + # Preserve setup failures through tee so the extension receives the task's exit code. + bash -o pipefail -c 'bash /tmp/start-devcontainer.sh 2>&1 | tee /tmp/devcontainer.log' - id: rebuild-devcontainer exec: component: universal-developer-image From 9013eab87e4df09d25fc021cfe6e864c14f1781b Mon Sep 17 00:00:00 2001 From: Rohan Kumar Date: Thu, 17 Sep 2026 20:01:16 +0530 Subject: [PATCH 06/12] fix(factory): protect nested-container script from DWO substitution Base64-encode start-devcontainer.sh so flattening cannot rewrite bash ${VAR:-default} syntax. Rethrow SCM auth during the probe, pin the devcontainer CLI, and drop leftover editor wiring now owned by the extension. Signed-off-by: Rohan Kumar Co-authored-by: Cursor --- .../server/urlfactory/URLFactoryBuilder.java | 130 ++-- .../devcontainer-devfile-template.yaml | 557 +-------------- .../src/main/resources/start-devcontainer.sh | 450 ++++++++++++ .../urlfactory/URLFactoryBuilderTest.java | 659 +++++------------- 4 files changed, 754 insertions(+), 1042 deletions(-) create mode 100644 wsmaster/che-core-api-factory/src/main/resources/start-devcontainer.sh diff --git a/wsmaster/che-core-api-factory/src/main/java/org/eclipse/che/api/factory/server/urlfactory/URLFactoryBuilder.java b/wsmaster/che-core-api-factory/src/main/java/org/eclipse/che/api/factory/server/urlfactory/URLFactoryBuilder.java index 368fee5b477..49009e6ab27 100644 --- a/wsmaster/che-core-api-factory/src/main/java/org/eclipse/che/api/factory/server/urlfactory/URLFactoryBuilder.java +++ b/wsmaster/che-core-api-factory/src/main/java/org/eclipse/che/api/factory/server/urlfactory/URLFactoryBuilder.java @@ -19,10 +19,12 @@ import static org.eclipse.che.dto.server.DtoFactory.newDto; import com.fasterxml.jackson.databind.JsonNode; +import com.google.common.annotations.VisibleForTesting; import java.io.IOException; import java.io.InputStream; import java.io.UncheckedIOException; import java.nio.charset.StandardCharsets; +import java.util.Base64; import java.util.HashMap; import java.util.Map; import java.util.Optional; @@ -59,15 +61,22 @@ public class URLFactoryBuilder { ".devcontainer/devcontainer.json", ".devcontainer.json" }; + private static final String DEVCONTAINER_SCRIPT_B64_PLACEHOLDER = "__START_DEVCONTAINER_B64__"; + private static final String DEVCONTAINER_DEVFILE_TEMPLATE; static { - try (InputStream is = - URLFactoryBuilder.class.getResourceAsStream("/devcontainer-devfile-template.yaml")) { - if (is == null) { - throw new IOException("devcontainer-devfile-template.yaml not found on classpath"); + try { + String script = loadClasspathResource("/start-devcontainer.sh"); + String encodedScript = + Base64.getEncoder().encodeToString(script.getBytes(StandardCharsets.UTF_8)); + String template = loadClasspathResource("/devcontainer-devfile-template.yaml"); + if (!template.contains(DEVCONTAINER_SCRIPT_B64_PLACEHOLDER)) { + throw new IOException( + "devcontainer-devfile-template.yaml is missing " + DEVCONTAINER_SCRIPT_B64_PLACEHOLDER); } - DEVCONTAINER_DEVFILE_TEMPLATE = new String(is.readAllBytes(), StandardCharsets.UTF_8); + DEVCONTAINER_DEVFILE_TEMPLATE = + template.replace(DEVCONTAINER_SCRIPT_B64_PLACEHOLDER, encodedScript); } catch (IOException e) { throw new UncheckedIOException("Failed to load devcontainer devfile template", e); } @@ -125,16 +134,9 @@ public Optional createFactoryFromDevfile( for (DevfileLocation location : remoteFactoryUrl.devfileFileLocations()) { String devfileLocation = location.location(); try { - Optional credentialsOptional = remoteFactoryUrl.getCredentials(); - if (skipAuthentication) { - devfileYamlContent = - fileContentProvider.fetchContentWithoutAuthentication(devfileLocation); - } else if (credentialsOptional.isPresent()) { - devfileYamlContent = - fileContentProvider.fetchContent(devfileLocation, credentialsOptional.get()); - } else { - devfileYamlContent = fileContentProvider.fetchContent(devfileLocation); - } + devfileYamlContent = + fetchContent( + remoteFactoryUrl, fileContentProvider, devfileLocation, skipAuthentication); } catch (IOException ex) { // try next location LOG.debug( @@ -164,7 +166,9 @@ public Optional createFactoryFromDevfile( } } - // No devfile found — probe for devcontainer.json + // No devfile found — probe for devcontainer.json. Providers that cannot fetch raw files + // (git-ssh returns a bare filename from rawFileLocation) skip this and fall through to the + // default factory. for (String devcontainerPath : DEVCONTAINER_LOCATIONS) { String devcontainerLocation = remoteFactoryUrl.rawFileLocation(devcontainerPath); if (devcontainerLocation == null) { @@ -172,22 +176,17 @@ public Optional createFactoryFromDevfile( } String devcontainerContent; try { - Optional credentialsOptional = remoteFactoryUrl.getCredentials(); - if (skipAuthentication) { - devcontainerContent = - fileContentProvider.fetchContentWithoutAuthentication(devcontainerLocation); - } else if (credentialsOptional.isPresent()) { - devcontainerContent = - fileContentProvider.fetchContent(devcontainerLocation, credentialsOptional.get()); - } else { - devcontainerContent = fileContentProvider.fetchContent(devcontainerLocation); - } + devcontainerContent = + fetchContent( + remoteFactoryUrl, fileContentProvider, devcontainerLocation, skipAuthentication); } catch (IOException ex) { LOG.debug("No devcontainer at: {}. Error: {}", devcontainerLocation, ex.getMessage()); continue; } catch (DevfileException e) { - LOG.warn("Unexpected exception probing devcontainer: {}", e.getMessage()); - continue; + LOG.debug("Unexpected exception probing devcontainer: {}", e.getMessage()); + throw e.getCause() instanceof ScmUnauthorizedException + ? toApiException(e) + : new ApiException(e.getMessage()); } if (!looksLikeJson(devcontainerContent)) { @@ -195,7 +194,7 @@ public Optional createFactoryFromDevfile( continue; } - LOG.info("Devcontainer detected at {}; generating devfile", devcontainerLocation); + LOG.debug("Devcontainer detected at {}; generating devfile", devcontainerLocation); try { JsonNode additions = devfileParser.parseYamlRaw(DEVCONTAINER_DEVFILE_TEMPLATE); Map devfileMap = new HashMap<>(DEFAULT_DEVFILE); @@ -214,6 +213,21 @@ public Optional createFactoryFromDevfile( return Optional.empty(); } + private static String fetchContent( + RemoteFactoryUrl remoteFactoryUrl, + FileContentProvider fileContentProvider, + String location, + boolean skipAuthentication) + throws IOException, DevfileException { + Optional credentialsOptional = remoteFactoryUrl.getCredentials(); + if (skipAuthentication) { + return fileContentProvider.fetchContentWithoutAuthentication(location); + } else if (credentialsOptional.isPresent()) { + return fileContentProvider.fetchContent(location, credentialsOptional.get()); + } + return fileContentProvider.fetchContent(location); + } + /** * Converts given devfile json into factory. * @@ -229,32 +243,58 @@ private FactoryMetaDto createFactory(JsonNode devfileJson, DevfileLocation locat /** * Cheap probe: returns true when content is non-empty and starts with '{' after skipping - * whitespace and JSONC single-line comments. Does not parse the file. + * whitespace and JSONC comments. Does not parse the file. */ static boolean looksLikeJson(String content) { if (isNullOrEmpty(content)) { return false; } - boolean inBlockComment = false; - for (String line : content.split("\n")) { - String trimmed = line.trim(); - if (inBlockComment) { - if (trimmed.contains("*/")) { - inBlockComment = false; - } - continue; - } - if (trimmed.isEmpty() || trimmed.startsWith("//")) { + int i = 0; + int n = content.length(); + if (content.charAt(0) == '\uFEFF') { + i = 1; + } + while (i < n) { + char c = content.charAt(i); + if (Character.isWhitespace(c)) { + i++; continue; } - if (trimmed.startsWith("/*")) { - if (!trimmed.contains("*/")) { - inBlockComment = true; + if (c == '/' && i + 1 < n) { + char next = content.charAt(i + 1); + if (next == '/') { + int newline = content.indexOf('\n', i); + if (newline < 0) { + return false; + } + i = newline + 1; + continue; + } + if (next == '*') { + int end = content.indexOf("*/", i + 2); + if (end < 0) { + return false; + } + i = end + 2; + continue; } - continue; } - return trimmed.startsWith("{"); + return c == '{'; } return false; } + + private static String loadClasspathResource(String name) throws IOException { + try (InputStream is = URLFactoryBuilder.class.getResourceAsStream(name)) { + if (is == null) { + throw new IOException(name + " not found on classpath"); + } + return new String(is.readAllBytes(), StandardCharsets.UTF_8); + } + } + + @VisibleForTesting + static String getDevcontainerDevfileTemplate() { + return DEVCONTAINER_DEVFILE_TEMPLATE; + } } diff --git a/wsmaster/che-core-api-factory/src/main/resources/devcontainer-devfile-template.yaml b/wsmaster/che-core-api-factory/src/main/resources/devcontainer-devfile-template.yaml index 878ed989706..9334688a2c4 100644 --- a/wsmaster/che-core-api-factory/src/main/resources/devcontainer-devfile-template.yaml +++ b/wsmaster/che-core-api-factory/src/main/resources/devcontainer-devfile-template.yaml @@ -9,6 +9,14 @@ # Contributors: # Red Hat, Inc. - initial API and implementation # +# Commands target Che's default UDI component name, which the dashboard injects +# when a factory has no components. Custom CheCluster defaultComponents names +# will not match these exec commands. +# +# The start script is stored as start-devcontainer.sh and substituted here as +# base64 so DevWorkspace flattening cannot rewrite bash variable-default syntax +# for the projects root / project source inside the script body. +# commands: - id: start-devcontainer @@ -17,531 +25,9 @@ commands: label: Start dev container workingDir: ${PROJECTS_ROOT} commandLine: | - cat > /tmp/start-devcontainer.sh <<'DEVCONTAINER_SCRIPT_EOF' - #!/usr/bin/env bash - # - # Copyright (c) 2012-2026 Red Hat, Inc. - # 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 - # - # Contributors: - # Red Hat, Inc. - initial API and implementation - # - - # - # Outer editor + inner devcontainer. - # che-code stays in the UDI container; the repo's devcontainer.json is built and run as a - # nested rootless-podman container. Terminals, lifecycle commands and file ownership are - # wired so the inner container behaves like the project's real environment. - # - # Env overrides: - # CONTAINER_NAME nested container name (default: devcontainer) - # IMAGE_NAME built image tag (default: localhost/devcontainer:latest) - # REBUILD=1 force rebuild (remove existing container and image; default: auto-rebuild when config changes) - # NO_CACHE=1 rebuild without cache (passes --no-cache to devcontainer build) - # STRICT_LIFECYCLE=1 exit non-zero if any lifecycle command fails - # NO_KEEP_ID=1 skip --userns=keep-id (debugging) - # CHE_DEVCONTAINER_RUNTIME terminal description (default: /tmp/che-devcontainer/runtime.json) - # PREFLIGHT_IMAGE runnable probe image (default: quay.io/podman/hello:latest; override for a registry mirror) - # - set -uo pipefail - - # Serialize setup and rebuild without interrupting an active build. Opening in append mode - # preserves the current owner's PID for the extension while another invocation waits. - LOCK_FILE="${LOCK_FILE:-/tmp/.devcontainer-setup.lock}" - LOCK_HELD=0 - if command -v flock >/dev/null 2>&1; then - exec 9>>"$LOCK_FILE" || { echo "cannot open setup lock: $LOCK_FILE" >&2; exit 1; } - if ! flock -n 9; then - echo "another devcontainer setup is in progress; waiting..." - flock -w 900 9 || { echo "timed out waiting for the in-progress setup" >&2; exit 1; } - fi - LOCK_HELD=1 - printf '%s\n' "$$" > "$LOCK_FILE" - fi - - # The parent owns the lock and stays alive for the extension's PID-based build detection. - # Close the descriptor in the setup child so conmon, fuse-overlayfs, and lifecycle processes - # cannot retain it after setup finishes. - ( - exec 9>&- - RUNTIME_FILE="${CHE_DEVCONTAINER_RUNTIME:-/tmp/che-devcontainer/runtime.json}" - # Invalidate the previous terminal configuration while holding the setup lock. A failed - # setup must never leave a description that the extension could mistake for a ready result. - rm -f -- "$RUNTIME_FILE" || exit 1 - PROJECTS_ROOT="${PROJECTS_ROOT:-/projects}" - # Project: explicit arg > DWO's PROJECT_SOURCE > the only directory under PROJECTS_ROOT. - if [ "$#" -ge 1 ] && [ -n "${1:-}" ]; then - PROJECT_DIR="${PROJECTS_ROOT}/${1}" - elif [ -n "${PROJECT_SOURCE:-}" ] && [ -d "${PROJECT_SOURCE}" ]; then - PROJECT_DIR="$PROJECT_SOURCE" - else - mapfile -t _dirs < <(find "$PROJECTS_ROOT" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | sort) - if [ "${#_dirs[@]}" -eq 1 ]; then - PROJECT_DIR="${_dirs[0]}" - else - echo "specify a project: $(basename "$0") " >&2 - [ "${#_dirs[@]}" -gt 1 ] && printf ' %s\n' "${_dirs[@]##*/}" >&2 - exit 1 - fi - fi - [ -d "$PROJECT_DIR" ] || { echo "no such project: $PROJECT_DIR" >&2; exit 1; } - PROJECT_NAME="$(basename "$PROJECT_DIR")" - CONTAINER_NAME="${CONTAINER_NAME:-devcontainer}" - IMAGE_NAME="${IMAGE_NAME:-localhost/devcontainer:latest}" - REBUILD="${REBUILD:-0}" - NO_CACHE="${NO_CACHE:-0}" - STRICT_LIFECYCLE="${STRICT_LIFECYCLE:-0}" - LIFECYCLE_FAILURES=0 - - # --- phase timing --------------------------------------------------------- - # Cold start is the main operational cost of this approach (CLI install + base image pull + - # build). Record where the time actually goes so it can be reported rather than guessed at. - T_START=$(date +%s); PHASE_T=$T_START; CURRENT_PHASE=""; PHASE_LOG=() - _close_phase(){ local now; now=$(date +%s) - [ -n "$CURRENT_PHASE" ] && PHASE_LOG+=("$((now-PHASE_T))|$CURRENT_PHASE"); PHASE_T=$now; } - step(){ _close_phase; CURRENT_PHASE="$2"; echo "[$1] $2"; return 0; } - - echo "=== devcontainer (outer editor): ${PROJECT_NAME} ===" - - # --------------------------------------------------------------------------- - # 1. Container engine - # --------------------------------------------------------------------------- - # UDI moves the real podman to podman.orig and puts a symlink on PATH that becomes the - # KUBEDOCK WRAPPER when KUBEDOCK_ENABLED=true. The wrapper sends run/exec to kubedock — a - # separate pod — while build stays local, so the image would be built somewhere the runtime - # cannot see it and --network=host would no longer be the pod's netns. - PODMAN="${ORIGINAL_PODMAN_PATH:-/usr/bin/podman.orig}" - [ -x "$PODMAN" ] || PODMAN="$(command -v podman 2>/dev/null)" - [ -n "$PODMAN" ] || { echo "podman not found" >&2; exit 1; } - step "1/8" "engine: $PODMAN" - - # --------------------------------------------------------------------------- - # 1b. Preflight: can this workspace run nested containers at all? - # --------------------------------------------------------------------------- - # Exercise the same engine and host networking used by start_container. Podman info's - # capability and UID-map fields do not prove that a container can actually start. - # This checks basic execution only, not project builds, bind mounts, or keep-id mappings. - PREFLIGHT_IMAGE="${PREFLIGHT_IMAGE:-quay.io/podman/hello:latest}" - PREFLIGHT_CONTAINER="che-devcontainer-preflight-$$" - step "1b" "checking nested container execution (timeout: 120s)..." - PREFLIGHT_RC=0 - timeout --kill-after=5s 120s "$PODMAN" run --rm --network=host \ - --name "$PREFLIGHT_CONTAINER" "$PREFLIGHT_IMAGE" 9>&- || PREFLIGHT_RC=$? - if [ "$PREFLIGHT_RC" -ne 0 ]; then - # A timeout or runtime failure may leave a partially created container. Only remove - # this probe's container, and bound cleanup as well. Preserve the original failure. - timeout --kill-after=5s 10s "$PODMAN" rm -f "$PREFLIGHT_CONTAINER" \ - >/dev/null 2>&1 9>&- || true - if [ "$PREFLIGHT_RC" -eq 124 ] || [ "$PREFLIGHT_RC" -eq 137 ]; then - echo "Nested-container preflight timed out; devcontainer setup stopped." >&2 - else - echo "Nested-container preflight failed (exit ${PREFLIGHT_RC}); devcontainer setup stopped." >&2 - fi - echo "See the Podman error above for the cause (for example, image pull or container runtime failure)." >&2 - exit "$PREFLIGHT_RC" - fi - step "1b" "nested container execution succeeded" - - # --------------------------------------------------------------------------- - # 2. Subuid ranges - # --------------------------------------------------------------------------- - # No storage.conf is written here. UDI's entrypoint (base/ubi*/entrypoint.sh) already selects - # overlay+fuse-overlayfs when /dev/fuse is present and falls back to vfs otherwise, so the - # driver choice is not ours to repeat, and the graphroot is left at podman's rootless default - # (${XDG_DATA_HOME:-$HOME/.local/share}/containers/storage). - # CAVEAT: with devEnvironments.persistUserHome enabled (the Che default) $HOME is PVC-backed, - # so the image store lands on the workspace PVC. Under DWO's common storage strategy - # (including Che's per-user strategy), the cleanup Job removes the workspace directory on - # deletion and subuid-owned files can wedge the workspace in Error: - # https://github.com/eclipse-che/che/issues/23924. On CRC (2026-09-08), the PVC-backed image - # build store was deleted cleanly with another workspace running. Full devcontainer teardown - # remains unverified there because nested-container startup failed. - - # Derive the range instead of hardcoding a uid — UDI's entrypoint computes this from the - # actual uid, and a hardcoded "user:1001:..." maps the wrong host range on a uid-1000 pod. - # NOTE: a 65536-ID userns cannot map container ID 65534 (nobody) however the ranges are - # split, because our own UID consumes one. apt's sandbox may still need disabling. - SU_USER="$(id -un 2>/dev/null || echo user)"; SU_UID="$(id -u)" - if [ "$SU_UID" -gt 0 ] && [ "$SU_UID" -lt 65536 ]; then - RANGES="$(printf '%s:1:%s\n%s:%s:%s\n' "$SU_USER" "$((SU_UID-1))" \ - "$SU_USER" "$((SU_UID+1))" "$((65535-SU_UID))")" - printf '%s\n' "$RANGES" > /etc/subuid 2>/dev/null && - printf '%s\n' "$RANGES" > /etc/subgid 2>/dev/null && - "$PODMAN" system migrate >/dev/null 2>&1 && - step "2/8" "store=$("$PODMAN" info --format json 2>/dev/null | jq -r '.store.graphDriverName + " @ " + .store.graphRoot') subuid=${SU_USER}(${SU_UID})" || - step "2/8" "store=$("$PODMAN" info --format json 2>/dev/null | jq -r '.store.graphDriverName + " @ " + .store.graphRoot') subuid=unchanged" - fi - - # --------------------------------------------------------------------------- - # 3. devcontainer CLI + pre-build config - # --------------------------------------------------------------------------- - DEVCONTAINER_BIN="${DEVCONTAINER_BIN:-$(command -v devcontainer || echo /home/user/.devcontainers/bin/devcontainer)}" - if ! "$DEVCONTAINER_BIN" --version >/dev/null 2>&1; then - step "3/8" "installing devcontainer CLI..." - # TODO: remove once the devcontainer CLI is bundled in the Universal Developer Image. - # Tracking: https://github.com/devfile/developer-images/pull/267 - # Until then the CLI is installed at workspace start, which requires network access to - # npm/GitHub and is a blocker for airgapped clusters. - npm install -g @devcontainers/cli >/dev/null 2>&1 || - curl -fsSL https://raw.githubusercontent.com/devcontainers/cli/main/scripts/install.sh | sh - DEVCONTAINER_BIN="$(command -v devcontainer || echo /home/user/.devcontainers/bin/devcontainer)" - else - step "3/8" "devcontainer CLI present." - fi - - # read-configuration shells out to `docker ps` before doing anything, so WITHOUT - # --docker-path it exits 1 with no output and we would silently fall back to a - # comment-stripping regex. Point it at the real engine. - CONFIG_JSON="" - raw="$("$DEVCONTAINER_BIN" read-configuration --docker-path "$PODMAN" \ - --workspace-folder "$PROJECT_DIR" 2>/dev/null || true)" - [ -n "$raw" ] && CONFIG_JSON="$(printf '%s\n' "$raw" | grep -E '^\{' | tail -1 \ - | jq -c '.configuration // empty' 2>/dev/null || true)" - if [ -z "$CONFIG_JSON" ]; then - DC="" - for c in "$PROJECT_DIR/.devcontainer/devcontainer.json" "$PROJECT_DIR/.devcontainer.json"; do - [ -f "$c" ] && { DC="$c"; break; } - done - [ -n "$DC" ] || DC="$(find "$PROJECT_DIR/.devcontainer" -mindepth 2 -maxdepth 2 \ - -name devcontainer.json 2>/dev/null | sort | head -1)" - [ -n "$DC" ] || { echo " no devcontainer.json found" >&2; exit 0; } - echo " read-configuration failed; using fallback parser on $DC" >&2 - # whole-line comments only, so a URL inside a string survives - CONFIG_JSON="$(sed -e 's@^[[:space:]]*//.*$@@' "$DC" | jq -c '.' 2>/dev/null)" - [ -n "$CONFIG_JSON" ] || { echo " could not parse $DC" >&2; exit 1; } - fi - - CONFIG_FINGERPRINT="$(printf '%s' "$CONFIG_JSON" | sha256sum | cut -d' ' -f1)" - - # --------------------------------------------------------------------------- - # 4. initializeCommand (runs OUTSIDE the container, per spec) - # --------------------------------------------------------------------------- - JQ_NORMALIZE=' - def norm($name): - if . == null then empty - elif type == "string" then {name:$name, argv:["/bin/sh","-c",.]} - elif type == "array" then {name:$name, argv:.} - elif type == "object" then to_entries[] | .key as $k | (.value | norm($k)) - else empty end; - norm("")' - - run_outer() { - local spec="$1" line label; [ -z "$spec" ] || [ "$spec" = "null" ] && return 0 - while IFS= read -r line; do - [ -z "$line" ] && continue - label="$(printf '%s' "$line" | jq -r '.name')" - local -a argv=(); mapfile -t argv < <(printf '%s' "$line" | jq -r '.argv[]') - [ "${#argv[@]}" -eq 0 ] && continue - echo " -> initializeCommand${label:+ [$label]}: ${argv[*]}" - ( cd "$PROJECT_DIR" && "${argv[@]}" ) || { - echo " !! initializeCommand failed" >&2; LIFECYCLE_FAILURES=$((LIFECYCLE_FAILURES+1)); } - done < <(printf '%s' "$spec" | jq -c "$JQ_NORMALIZE") - } - step "4/8" "initializeCommand..." - run_outer "$(printf '%s' "$CONFIG_JSON" | jq -c '.initializeCommand // null')" - - # --------------------------------------------------------------------------- - # 5. Build - # --------------------------------------------------------------------------- - REUSING=0 - if [ "$REBUILD" = "1" ]; then - "$PODMAN" rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true - [ "$NO_CACHE" = "1" ] && { "$PODMAN" rmi -f "$IMAGE_NAME" >/dev/null 2>&1 || true; } - elif "$PODMAN" container exists "$CONTAINER_NAME" 2>/dev/null; then - STORED_FP="$("$PODMAN" inspect --format json "$CONTAINER_NAME" 2>/dev/null \ - | jq -r '.[0].Config.Labels["che.devcontainer.config"] // ""' 2>/dev/null || echo "")" - if [ "$STORED_FP" = "$CONFIG_FINGERPRINT" ]; then - REUSING=1; step "5/8" "reusing existing container (config unchanged)." - "$PODMAN" start "$CONTAINER_NAME" >/dev/null 2>&1 || true - else - REUSING=1; step "5/8" "reusing existing container (config STALE)." - echo " WARNING: devcontainer.json changed since this container was built." - echo " Run the 'Rebuild dev container' task to apply changes." - "$PODMAN" start "$CONTAINER_NAME" >/dev/null 2>&1 || true - fi - fi - if [ "$REUSING" = "0" ]; then - step "5/8" "building image (slow part)..." - BUILD_ARGS=(--docker-path="$PODMAN" --workspace-folder "$PROJECT_DIR" --image-name "$IMAGE_NAME") - [ "$NO_CACHE" = "1" ] && BUILD_ARGS+=(--no-cache) - "$DEVCONTAINER_BIN" build "${BUILD_ARGS[@]}" || { - echo " build failed" >&2; exit 1; } - fi - - # --------------------------------------------------------------------------- - # 6. Merged metadata from the built image - # --------------------------------------------------------------------------- - # `devcontainer build` writes a devcontainer.metadata LABEL containing the FULLY MERGED - # config — the base image's metadata, every Feature's contributions, and devcontainer.json. - # This is where remoteUser actually lives for most real repos (vscode-remote-try-node has - # its remoteUser line commented out, but the image metadata says "node"). Reading only - # devcontainer.json means lifecycle commands run as root and write root-owned node_modules - # into the bind mount. Using --include-merged-configuration instead would need a registry - # round-trip; the label is local and already merged. - JQ_MERGE=' - def last_of($k): [ .[] | .[$k] // empty ] | last // null; - def all_of($k): [ .[] | .[$k] // empty ]; - { remoteUser: last_of("remoteUser"), containerUser: last_of("containerUser"), - workspaceFolder: last_of("workspaceFolder"), - remoteEnv: ( [ .[] | .remoteEnv // {} ] | add // {} ), - containerEnv: ( [ .[] | .containerEnv // {} ] | add // {} ), - extensions: ( [ .[] | .customizations.vscode.extensions // [] ] | add // [] | unique ), - settings: ( [ .[] | .customizations.vscode.settings // {} ] | add // {} ), - onCreateCommand: all_of("onCreateCommand"), updateContentCommand: all_of("updateContentCommand"), - postCreateCommand: all_of("postCreateCommand"), postStartCommand: all_of("postStartCommand"), - postAttachCommand: all_of("postAttachCommand") }' - - META='{}' - label="$("$PODMAN" inspect --format json "$IMAGE_NAME" 2>/dev/null \ - | jq -r '.[0].Config.Labels["devcontainer.metadata"] // ""')" - if [ -n "$label" ] && [ "$label" != "" ]; then - META="$(printf '%s' "$label" | jq -c "$JQ_MERGE" 2>/dev/null || echo '{}')" - fi - # Fall back to devcontainer.json for anything the label did not provide. - META="$(jq -n --argjson m "$META" --argjson c "$CONFIG_JSON" ' - { remoteUser: ($m.remoteUser // $c.remoteUser // $c.containerUser // null), - workspaceFolder: ($m.workspaceFolder // $c.workspaceFolder // "/workspace"), - remoteEnv: (($c.remoteEnv // {}) + ($m.remoteEnv // {})), - containerEnv: (($c.containerEnv // {}) + ($m.containerEnv // {})), - extensions: ($m.extensions // ($c.customizations.vscode.extensions // [])), - settings: (($c.customizations.vscode.settings // {}) + ($m.settings // {})), - hooks: { onCreateCommand: ($m.onCreateCommand // []), updateContentCommand: ($m.updateContentCommand // []), - postCreateCommand: ($m.postCreateCommand // []), postStartCommand: ($m.postStartCommand // []), - postAttachCommand: ($m.postAttachCommand // []) } }')" - - REMOTE_USER="$(printf '%s' "$META" | jq -r '.remoteUser // empty')" - WORKSPACE_FOLDER="$(printf '%s' "$META" | jq -r '.workspaceFolder')" - echo " remoteUser=${REMOTE_USER:-} workspaceFolder=${WORKSPACE_FOLDER}" - - env_args() { # $1 = remoteEnv|containerEnv - printf '%s' "$META" | jq -r --arg k "$1" '(.[$k] // {}) | to_entries[] - | select(.value | tostring | test("\\$\\{") | not) | "-e", "\(.key)=\(.value)"' - } - CONTAINER_ENV=(); mapfile -t CONTAINER_ENV < <(env_args containerEnv) - REMOTE_ENV=(); mapfile -t REMOTE_ENV < <(env_args remoteEnv) - skipped="$(printf '%s' "$META" | jq -r '[(.remoteEnv//{}),(.containerEnv//{})] | add | to_entries[] - | select(.value|tostring|test("\\$\\{")) | .key' | paste -sd, -)" - [ -n "$skipped" ] && echo " NOTE: env with \${...} substitution skipped: $skipped" >&2 - - # --------------------------------------------------------------------------- - # 7. Run the container, with UID parity - # --------------------------------------------------------------------------- - # The whole point of outer-editor is "edit outside, run inside" — so the two sides must agree - # on file ownership. Without keep-id, anything created inside (node_modules, build output, - # .venv) is owned by a subuid and the editor cannot modify or delete it, which is what forced - # the chmod 777 workaround. keep-id:uid=,gid= maps our uid to the remoteUser inside, so files - # created either way are owned by us. Requires overlay (fuse) — VFS cannot chown. - KEEP_ID_ARGS=() - if [ "$REUSING" = "0" ] && [ "${NO_KEEP_ID:-0}" != "1" ] && [ -c /dev/fuse ]; then - IN_UID=""; IN_GID="" - if [ -n "$REMOTE_USER" ]; then - IN_UID="$("$PODMAN" run --rm "$IMAGE_NAME" id -u "$REMOTE_USER" 2>/dev/null | tr -dc '0-9')" - IN_GID="$("$PODMAN" run --rm "$IMAGE_NAME" id -g "$REMOTE_USER" 2>/dev/null | tr -dc '0-9')" - fi - if [ -n "$IN_UID" ] && [ -n "$IN_GID" ]; then - KEEP_ID_ARGS=(--userns="keep-id:uid=${IN_UID},gid=${IN_GID}") - else - KEEP_ID_ARGS=(--userns=keep-id) - fi - fi - - # The dev container does NOT receive cluster credentials. It runs arbitrary repository - # code and with --network=host already reaches che-code (:3100) and machine-exec (:3333). - # VS Code dev containers do not expose Kubernetes service-account tokens; cluster tooling - # lives in UDI — use a regular editor terminal for kubectl/oc. - start_container() { # $1 = extra args array name - local -n extra="$1" - "$PODMAN" run -d --name "$CONTAINER_NAME" --network=host \ - --label "che.devcontainer.config=${CONFIG_FINGERPRINT}" \ - "${extra[@]}" \ - -v "$PROJECT_DIR:${WORKSPACE_FOLDER}" \ - "${CONTAINER_ENV[@]}" \ - "$IMAGE_NAME" sleep infinity - } - - KEEP_ID_OK=0 - if [ "$REUSING" = "0" ]; then - step "6/8" "starting container..." - "$PODMAN" rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true - if [ "${#KEEP_ID_ARGS[@]}" -gt 0 ] && start_container KEEP_ID_ARGS >/dev/null 2>&1; then - KEEP_ID_OK=1; echo " uid parity: ${KEEP_ID_ARGS[*]}" - else - [ "${#KEEP_ID_ARGS[@]}" -gt 0 ] && echo " keep-id unavailable; falling back (files created inside will be subuid-owned)" >&2 - "$PODMAN" rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true - NONE=(); start_container NONE >/dev/null || { echo " could not start container" >&2; exit 1; } - fi - else - step "6/8" "container already running." - fi - - # Only widen permissions when uid parity failed. a+rwX (not 777) so the execute bit is not - # set on every tracked file, which would make git report the whole repo as modified. - if [ "$KEEP_ID_OK" = "0" ]; then - "$PODMAN" exec --user 0 "$CONTAINER_NAME" chmod -R a+rwX "$WORKSPACE_FOLDER" 2>/dev/null || true - fi - "$PODMAN" exec "$CONTAINER_NAME" git config --global --replace-all safe.directory "$WORKSPACE_FOLDER" 2>/dev/null || true - - # --------------------------------------------------------------------------- - # 8. Lifecycle commands, then editor wiring - # --------------------------------------------------------------------------- - EXEC_USER=(); [ -n "$REMOTE_USER" ] && EXEC_USER=(-u "$REMOTE_USER") - CREATE_MARKER=/tmp/.devcontainer-create-hooks-done - - run_hook() { # $1 = hook name - local hook="$1" entries line label rc - entries="$(printf '%s' "$META" | jq -c --arg k "$hook" '.hooks[$k][]?')" - [ -n "$entries" ] || return 0 - while IFS= read -r spec; do - [ -z "$spec" ] && continue - while IFS= read -r line; do - [ -z "$line" ] && continue - label="$(printf '%s' "$line" | jq -r '.name')" - local -a argv=(); mapfile -t argv < <(printf '%s' "$line" | jq -r '.argv[]') - [ "${#argv[@]}" -eq 0 ] && continue - echo " -> ${hook}${label:+ [$label]}: ${argv[*]}" - rc=0 - "$PODMAN" exec "${EXEC_USER[@]}" "${REMOTE_ENV[@]}" -w "$WORKSPACE_FOLDER" \ - "$CONTAINER_NAME" "${argv[@]}" || rc=$? - [ "$rc" -ne 0 ] && { echo " !! ${hook} exited ${rc}" >&2 - LIFECYCLE_FAILURES=$((LIFECYCLE_FAILURES+1)); } - done < <(printf '%s' "$spec" | jq -c "$JQ_NORMALIZE") - done <<< "$entries" - } - - step "7/8" "lifecycle commands..." - if "$PODMAN" exec "$CONTAINER_NAME" test -f "$CREATE_MARKER" 2>/dev/null; then - echo " creation hooks already ran for this container." - else - run_hook onCreateCommand; run_hook updateContentCommand; run_hook postCreateCommand - "$PODMAN" exec "$CONTAINER_NAME" touch "$CREATE_MARKER" 2>/dev/null || true - fi - run_hook postStartCommand - run_hook postAttachCommand - - step "8/8" "editor wiring..." - INNER_SHELL=/bin/sh - "$PODMAN" exec "$CONTAINER_NAME" sh -c 'command -v bash' >/dev/null 2>&1 && INNER_SHELL=bash - - merge_into() { # $1 = file, $2.. = json objects - local file="$1"; shift - local cur='{}' obj - [ -f "$file" ] && cur="$(sed -e 's@^[[:space:]]*//.*$@@' "$file" | jq -c '.' 2>/dev/null || echo '{}')" - for obj in "$@"; do cur="$(jq -n --argjson a "$cur" --argjson b "$obj" '$a * $b')"; done - mkdir -p "$(dirname "$file")"; printf '%s\n' "$cur" | jq '.' > "$file" - } - - # .vscode/extensions.json is the one generated file that must live in the repo — VS Code reads - # recommendations only from there. Keep it out of `git status` via .git/info/exclude, which is - # local to the clone and never committed. Ignore rules do not apply to files git already tracks, - # so a repo that commits its own .vscode/extensions.json is left completely alone. - safe_to_write() { # $1 = path relative to the project root; returns 0 when writing is OK - local rel="$1" gitdir ex - gitdir="$(git -C "$PROJECT_DIR" rev-parse --absolute-git-dir 2>/dev/null)" || return 0 - git -C "$PROJECT_DIR" ls-files --error-unmatch "$rel" >/dev/null 2>&1 && return 1 - ex="$gitdir/info/exclude" - mkdir -p "$(dirname "$ex")" 2>/dev/null || return 0 - grep -qxF "$rel" "$ex" 2>/dev/null || printf '%s\n' "$rel" >> "$ex" - return 0 - } - - # Terminal configuration is owned by the extension; only editor settings belong here. - MACHINE_SETTINGS="${CHE_MACHINE_SETTINGS:-/checode/remote/data/Machine/settings.json}" - MACHINE_OK=0 - mkdir -p "$(dirname "$MACHINE_SETTINGS")" 2>/dev/null && MACHINE_OK=1 - - # Editor settings go to MACHINE settings too, not the repo: the user should not find unexplained - # modifications in a tree they did not touch. /checode is recreated on each workspace start, so - # nothing written here survives into a later session with a different project. - # NOTE: machine scope is editor-wide, not per-folder. With one project — the normal case for a - # factory workspace — that is indistinguishable from workspace scope; with several projects under - # $PROJECTS_ROOT the last run wins. - SAFE='editor.|files.|workbench.|search.|explorer.|diffEditor.|breadcrumbs.|scm.|outline.|problems.|output.|window.|comments.' - editor_settings="$(printf '%s' "$META" | jq -c --arg p "$SAFE" '(.settings // {}) - | with_entries(select(.key as $k | ($p|split("|")|any(. as $x | $k|startswith($x)))))')" - if [ "$(printf '%s' "$editor_settings" | jq 'length')" -gt 0 ]; then - if [ "$MACHINE_OK" = 1 ]; then - merge_into "$MACHINE_SETTINGS" "$editor_settings" - echo " editor settings -> ${MACHINE_SETTINGS}" - else - echo " WARNING: ${MACHINE_SETTINGS} unwritable; editor settings not applied." >&2 - fi - fi - - # Surface the extensions the devcontainer asks for (including Feature-contributed ones) as - # workspace recommendations. che-code cannot auto-install them, but this at least tells the - # user what the repo expects instead of silently dropping the list. - exts="$(printf '%s' "$META" | jq -c '.extensions // []')" - if [ "$(printf '%s' "$exts" | jq 'length')" -gt 0 ]; then - if safe_to_write .vscode/extensions.json; then - merge_into "${PROJECT_DIR}/.vscode/extensions.json" \ - "$(jq -n --argjson e "$exts" '{recommendations: $e}')" - echo " recommended extensions -> .vscode/extensions.json: $(printf '%s' "$exts" | jq -r 'join(", ")')" - else - echo " recommended extensions (not written, .vscode/extensions.json is tracked): $(printf '%s' "$exts" | jq -r 'join(", ")')" - fi - fi - - if [ "$LIFECYCLE_FAILURES" -gt 0 ]; then - echo; echo " WARNING: ${LIFECYCLE_FAILURES} lifecycle command(s) failed." - [ "$STRICT_LIFECYCLE" = "1" ] && exit 1 - fi - - # Publish only a successful, running container. The ID binds these resolved values to - # this instance, so a replacement container cannot reuse an obsolete terminal description. - publish_runtime() ( - umask 077 - local runtime_dir runtime_tmp container_id remote_env_json - runtime_dir="$(dirname "$RUNTIME_FILE")" - mkdir -p "$runtime_dir" || return 1 - container_id="$("$PODMAN" inspect --format json "$CONTAINER_NAME" | jq -er \ - '.[0] | select(.State.Running == true) | .Id | select(type == "string" and length > 0)')" || return 1 - # Use exactly the already-resolved environment arguments passed to lifecycle commands. - remote_env_json='{}' - local i entry key value - for ((i=1; i<${#REMOTE_ENV[@]}; i+=2)); do - entry="${REMOTE_ENV[i]}"; key="${entry%%=*}"; value="${entry#*=}" - remote_env_json="$(jq -cn --argjson env "$remote_env_json" --arg k "$key" --arg v "$value" '$env + {($k): $v}')" || return 1 - done - runtime_tmp="$(mktemp "$runtime_dir/.runtime.XXXXXX")" || return 1 - trap 'rm -f -- "$runtime_tmp"' EXIT - jq -n --arg containerName "$CONTAINER_NAME" --arg containerId "$container_id" \ - --arg podmanPath "$PODMAN" --arg image "$IMAGE_NAME" --arg remoteUser "$REMOTE_USER" \ - --arg workspaceFolder "$WORKSPACE_FOLDER" --arg shell "$INNER_SHELL" \ - --arg fingerprint "$CONFIG_FINGERPRINT" --argjson remoteEnv "$remote_env_json" \ - '{version: 1, containerName: $containerName, containerId: $containerId, - podmanPath: $podmanPath, image: $image, remoteUser: $remoteUser, - workspaceFolder: $workspaceFolder, shell: $shell, remoteEnv: $remoteEnv, - fingerprint: $fingerprint}' > "$runtime_tmp" || return 1 - mv -f -- "$runtime_tmp" "$RUNTIME_FILE" - ) - publish_runtime || { echo "could not publish devcontainer terminal configuration" >&2; exit 1; } - - _close_phase - echo - echo "=== ready ===" - printf ' timing: total %ss' "$(( $(date +%s) - T_START ))" - for e in "${PHASE_LOG[@]}"; do - d="${e%%|*}"; l="${e#*|}"; [ "$d" -ge 2 ] && printf ' | %s %ss' "${l%% *}" "$d" - done - echo - echo " Use the extension action: Open Terminal in Dev Container." - echo " Files: ${PROJECT_DIR} <-> ${WORKSPACE_FOLDER}" - [ "$KEEP_ID_OK" = "1" ] && echo " UID parity ON — files created inside are owned by you." \ - || echo " UID parity OFF — files created inside are subuid-owned." - echo " Shell: ${PODMAN} exec -it ${CONTAINER_NAME} ${INNER_SHELL}" - exit 0 - ) - SETUP_RC=$? - # Clear the published PID while we still own the lock, before another run can acquire it. - if [ "$LOCK_HELD" = 1 ]; then - : > "$LOCK_FILE" - fi - exit "$SETUP_RC" - DEVCONTAINER_SCRIPT_EOF + base64 -d >/tmp/start-devcontainer.sh <<'DEVCONTAINER_SCRIPT_B64' + __START_DEVCONTAINER_B64__ + DEVCONTAINER_SCRIPT_B64 chmod +x /tmp/start-devcontainer.sh # Run in the FOREGROUND so che-code keeps the task terminal alive and streams progress. # A backgrounded (setsid/nohup &) process is reaped when the task's foreground shell exits, @@ -554,7 +40,12 @@ commands: label: Rebuild dev container workingDir: ${PROJECTS_ROOT} commandLine: | - [ -f /tmp/start-devcontainer.sh ] || { echo "run the start-devcontainer task first"; exit 1; } + if [ ! -f /tmp/start-devcontainer.sh ]; then + base64 -d >/tmp/start-devcontainer.sh <<'DEVCONTAINER_SCRIPT_B64' + __START_DEVCONTAINER_B64__ + DEVCONTAINER_SCRIPT_B64 + chmod +x /tmp/start-devcontainer.sh + fi REBUILD=1 bash /tmp/start-devcontainer.sh - id: rebuild-devcontainer-no-cache exec: @@ -562,7 +53,12 @@ commands: label: Rebuild dev container (no cache) workingDir: ${PROJECTS_ROOT} commandLine: | - [ -f /tmp/start-devcontainer.sh ] || { echo "run the start-devcontainer task first"; exit 1; } + if [ ! -f /tmp/start-devcontainer.sh ]; then + base64 -d >/tmp/start-devcontainer.sh <<'DEVCONTAINER_SCRIPT_B64' + __START_DEVCONTAINER_B64__ + DEVCONTAINER_SCRIPT_B64 + chmod +x /tmp/start-devcontainer.sh + fi REBUILD=1 NO_CACHE=1 bash /tmp/start-devcontainer.sh - id: show-devcontainer-log exec: @@ -580,5 +76,8 @@ commands: commandLine: | PODMAN="${ORIGINAL_PODMAN_PATH:-/usr/bin/podman.orig}" [ -x "$PODMAN" ] || PODMAN=podman - "$PODMAN" system prune -af - "$PODMAN" system df + CONTAINER_NAME="${CONTAINER_NAME:-devcontainer}" + IMAGE_NAME="${IMAGE_NAME:-localhost/devcontainer:latest}" + "$PODMAN" rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true + "$PODMAN" rmi -f "$IMAGE_NAME" >/dev/null 2>&1 || true + "$PODMAN" images diff --git a/wsmaster/che-core-api-factory/src/main/resources/start-devcontainer.sh b/wsmaster/che-core-api-factory/src/main/resources/start-devcontainer.sh new file mode 100644 index 00000000000..c1dbe2c30b5 --- /dev/null +++ b/wsmaster/che-core-api-factory/src/main/resources/start-devcontainer.sh @@ -0,0 +1,450 @@ +#!/usr/bin/env bash +# +# Copyright (c) 2012-2026 Red Hat, Inc. +# 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 +# +# Contributors: +# Red Hat, Inc. - initial API and implementation +# + +# +# Outer editor + inner devcontainer. +# che-code stays in the UDI container; the repo's devcontainer.json is built and run as a +# nested rootless-podman container. Terminals, lifecycle commands and file ownership are +# wired so the inner container behaves like the project's real environment. +# +# Env overrides: +# CONTAINER_NAME nested container name (default: devcontainer) +# IMAGE_NAME built image tag (default: localhost/devcontainer:latest) +# REBUILD=1 force rebuild (remove existing container and image; default: auto-rebuild when config changes) +# NO_CACHE=1 rebuild without cache (passes --no-cache to devcontainer build) +# STRICT_LIFECYCLE=1 exit non-zero if any lifecycle command fails +# NO_KEEP_ID=1 skip --userns=keep-id (debugging) +# CHE_DEVCONTAINER_RUNTIME terminal description (default: /tmp/che-devcontainer/runtime.json) +# PREFLIGHT_IMAGE runnable probe image (default: quay.io/podman/hello:latest; override for a registry mirror) +# +set -uo pipefail + +# Serialize setup and rebuild without interrupting an active build. Opening in append mode +# preserves the current owner's PID for the extension while another invocation waits. +LOCK_FILE="${LOCK_FILE:-/tmp/.devcontainer-setup.lock}" +LOCK_HELD=0 +if command -v flock >/dev/null 2>&1; then + exec 9>>"$LOCK_FILE" || { echo "cannot open setup lock: $LOCK_FILE" >&2; exit 1; } + if ! flock -n 9; then + echo "another devcontainer setup is in progress; waiting..." + flock -w 900 9 || { echo "timed out waiting for the in-progress setup" >&2; exit 1; } + fi + LOCK_HELD=1 + printf '%s\n' "$$" > "$LOCK_FILE" +fi + +# The parent owns the lock and stays alive for the extension's PID-based build detection. +# Close the descriptor in the setup child so conmon, fuse-overlayfs, and lifecycle processes +# cannot retain it after setup finishes. +( +exec 9>&- +RUNTIME_FILE="${CHE_DEVCONTAINER_RUNTIME:-/tmp/che-devcontainer/runtime.json}" +# Invalidate the previous terminal configuration while holding the setup lock. A failed +# setup must never leave a description that the extension could mistake for a ready result. +rm -f -- "$RUNTIME_FILE" || exit 1 +PROJECTS_ROOT="${PROJECTS_ROOT:-/projects}" +# Project: explicit arg > DWO's PROJECT_SOURCE > the only directory under PROJECTS_ROOT. +if [ "$#" -ge 1 ] && [ -n "${1:-}" ]; then + PROJECT_DIR="${PROJECTS_ROOT}/${1}" +elif [ -n "${PROJECT_SOURCE:-}" ] && [ -d "${PROJECT_SOURCE}" ]; then + PROJECT_DIR="$PROJECT_SOURCE" +else + mapfile -t _dirs < <(find "$PROJECTS_ROOT" -mindepth 1 -maxdepth 1 -type d 2>/dev/null | sort) + if [ "${#_dirs[@]}" -eq 1 ]; then + PROJECT_DIR="${_dirs[0]}" + else + echo "specify a project: $(basename "$0") " >&2 + [ "${#_dirs[@]}" -gt 1 ] && printf ' %s\n' "${_dirs[@]##*/}" >&2 + exit 1 + fi +fi +[ -d "$PROJECT_DIR" ] || { echo "no such project: $PROJECT_DIR" >&2; exit 1; } +PROJECT_NAME="$(basename "$PROJECT_DIR")" +CONTAINER_NAME="${CONTAINER_NAME:-devcontainer}" +IMAGE_NAME="${IMAGE_NAME:-localhost/devcontainer:latest}" +REBUILD="${REBUILD:-0}" +NO_CACHE="${NO_CACHE:-0}" +STRICT_LIFECYCLE="${STRICT_LIFECYCLE:-0}" +LIFECYCLE_FAILURES=0 + +# --- phase timing --------------------------------------------------------- +# Cold start is the main operational cost of this approach (CLI install + base image pull + +# build). Record where the time actually goes so it can be reported rather than guessed at. +T_START=$(date +%s); PHASE_T=$T_START; CURRENT_PHASE=""; PHASE_LOG=() +_close_phase(){ local now; now=$(date +%s) + [ -n "$CURRENT_PHASE" ] && PHASE_LOG+=("$((now-PHASE_T))|$CURRENT_PHASE"); PHASE_T=$now; } +step(){ _close_phase; CURRENT_PHASE="$2"; echo "[$1] $2"; return 0; } + +echo "=== devcontainer (outer editor): ${PROJECT_NAME} ===" + +# --------------------------------------------------------------------------- +# 1. Container engine +# --------------------------------------------------------------------------- +# UDI moves the real podman to podman.orig and puts a symlink on PATH that becomes the +# KUBEDOCK WRAPPER when KUBEDOCK_ENABLED=true. The wrapper sends run/exec to kubedock — a +# separate pod — while build stays local, so the image would be built somewhere the runtime +# cannot see it and --network=host would no longer be the pod's netns. +PODMAN="${ORIGINAL_PODMAN_PATH:-/usr/bin/podman.orig}" +[ -x "$PODMAN" ] || PODMAN="$(command -v podman 2>/dev/null)" +[ -n "$PODMAN" ] || { echo "podman not found" >&2; exit 1; } +step "1/8" "engine: $PODMAN" + +# Exercise the same engine and host networking used by start_container. Podman info's +# capability and UID-map fields do not prove that a container can actually start. +# Cached image pull makes this cheap after the first workspace start; 120s is a timeout +# ceiling, not the expected duration. Called only when we are about to build/run, not +# on a no-op reuse of an already-running container. +run_preflight() { + PREFLIGHT_IMAGE="${PREFLIGHT_IMAGE:-quay.io/podman/hello:latest}" + PREFLIGHT_CONTAINER="che-devcontainer-preflight-$$" + step "1b" "checking nested container execution (timeout: 120s)..." + PREFLIGHT_RC=0 + timeout --kill-after=5s 120s "$PODMAN" run --rm --network=host \ + --name "$PREFLIGHT_CONTAINER" "$PREFLIGHT_IMAGE" 9>&- || PREFLIGHT_RC=$? + if [ "$PREFLIGHT_RC" -ne 0 ]; then + timeout --kill-after=5s 10s "$PODMAN" rm -f "$PREFLIGHT_CONTAINER" \ + >/dev/null 2>&1 9>&- || true + if [ "$PREFLIGHT_RC" -eq 124 ] || [ "$PREFLIGHT_RC" -eq 137 ]; then + echo "Nested-container preflight timed out; devcontainer setup stopped." >&2 + else + echo "Nested-container preflight failed (exit ${PREFLIGHT_RC}); devcontainer setup stopped." >&2 + fi + echo "See the Podman error above for the cause (for example, image pull or container runtime failure)." >&2 + exit "$PREFLIGHT_RC" + fi + step "1b" "nested container execution succeeded" +} + +# Derive the range instead of hardcoding a uid — UDI's entrypoint computes this from the +# actual uid, and a hardcoded "user:1001:..." maps the wrong host range on a uid-1000 pod. +# NOTE: a 65536-ID userns cannot map container ID 65534 (nobody) however the ranges are +# split, because our own UID consumes one. apt's sandbox may still need disabling. +SU_USER="$(id -un 2>/dev/null || echo user)"; SU_UID="$(id -u)" +if [ "$SU_UID" -gt 0 ] && [ "$SU_UID" -lt 65536 ]; then + RANGES="$(printf '%s:1:%s\n%s:%s:%s\n' "$SU_USER" "$((SU_UID-1))" \ + "$SU_USER" "$((SU_UID+1))" "$((65535-SU_UID))")" + printf '%s\n' "$RANGES" > /etc/subuid 2>/dev/null && + printf '%s\n' "$RANGES" > /etc/subgid 2>/dev/null && + "$PODMAN" system migrate >/dev/null 2>&1 && + step "2/8" "store=$("$PODMAN" info --format json 2>/dev/null | jq -r '.store.graphDriverName + " @ " + .store.graphRoot') subuid=${SU_USER}(${SU_UID})" || + step "2/8" "store=$("$PODMAN" info --format json 2>/dev/null | jq -r '.store.graphDriverName + " @ " + .store.graphRoot') subuid=unchanged" +fi + +# --------------------------------------------------------------------------- +# 3. devcontainer CLI + pre-build config +# --------------------------------------------------------------------------- +DEVCONTAINER_BIN="${DEVCONTAINER_BIN:-$(command -v devcontainer || echo /home/user/.devcontainers/bin/devcontainer)}" +if ! "$DEVCONTAINER_BIN" --version >/dev/null 2>&1; then + step "3/8" "installing devcontainer CLI..." + # TODO: remove once the devcontainer CLI is bundled in the Universal Developer Image. + # Tracking: https://github.com/devfile/developer-images/pull/267 + # Until then the CLI is installed at workspace start, which requires network access to + # npm/GitHub and is a blocker for airgapped clusters. + # Pin a release. Do not install from GitHub main — that is an unpinned supply chain. + # Override with DEVCONTAINER_CLI_VERSION if a workspace needs a newer CLI. + DEVCONTAINER_CLI_VERSION="${DEVCONTAINER_CLI_VERSION:-0.89.0}" + npm install -g "@devcontainers/cli@${DEVCONTAINER_CLI_VERSION}" >/dev/null 2>&1 || { + echo " npm install failed; installing CLI ${DEVCONTAINER_CLI_VERSION} from tagged release..." >&2 + curl -fsSL "https://raw.githubusercontent.com/devcontainers/cli/v${DEVCONTAINER_CLI_VERSION}/scripts/install.sh" \ + | sh -s -- --version "${DEVCONTAINER_CLI_VERSION}" + } + DEVCONTAINER_BIN="$(command -v devcontainer || echo /home/user/.devcontainers/bin/devcontainer)" +else + step "3/8" "devcontainer CLI present." +fi + +# read-configuration shells out to `docker ps` before doing anything, so WITHOUT +# --docker-path it exits 1 with no output and we would silently fall back to a +# comment-stripping regex. Point it at the real engine. +CONFIG_JSON="" +raw="$("$DEVCONTAINER_BIN" read-configuration --docker-path "$PODMAN" \ + --workspace-folder "$PROJECT_DIR" 2>/dev/null || true)" +[ -n "$raw" ] && CONFIG_JSON="$(printf '%s\n' "$raw" | grep -E '^\{' | tail -1 \ + | jq -c '.configuration // empty' 2>/dev/null || true)" +if [ -z "$CONFIG_JSON" ]; then + DC="" + for c in "$PROJECT_DIR/.devcontainer/devcontainer.json" "$PROJECT_DIR/.devcontainer.json"; do + [ -f "$c" ] && { DC="$c"; break; } + done + [ -n "$DC" ] || DC="$(find "$PROJECT_DIR/.devcontainer" -mindepth 2 -maxdepth 2 \ + -name devcontainer.json 2>/dev/null | sort | head -1)" + [ -n "$DC" ] || { echo " no devcontainer.json found" >&2; exit 1; } + echo " read-configuration failed; using fallback parser on $DC" >&2 + # whole-line comments only, so a URL inside a string survives + CONFIG_JSON="$(sed -e 's@^[[:space:]]*//.*$@@' "$DC" | jq -c '.' 2>/dev/null)" + [ -n "$CONFIG_JSON" ] || { echo " could not parse $DC" >&2; exit 1; } +fi + +CONFIG_FINGERPRINT="$(printf '%s' "$CONFIG_JSON" | sha256sum | cut -d' ' -f1)" + +# --------------------------------------------------------------------------- +# 4. initializeCommand (runs OUTSIDE the container, per spec) +# --------------------------------------------------------------------------- +JQ_NORMALIZE=' +def norm($name): + if . == null then empty + elif type == "string" then {name:$name, argv:["/bin/sh","-c",.]} + elif type == "array" then {name:$name, argv:.} + elif type == "object" then to_entries[] | .key as $k | (.value | norm($k)) + else empty end; +norm("")' + +run_outer() { + local spec="$1" line label; [ -z "$spec" ] || [ "$spec" = "null" ] && return 0 + while IFS= read -r line; do + [ -z "$line" ] && continue + label="$(printf '%s' "$line" | jq -r '.name')" + local -a argv=(); mapfile -t argv < <(printf '%s' "$line" | jq -r '.argv[]') + [ "${#argv[@]}" -eq 0 ] && continue + echo " -> initializeCommand${label:+ [$label]}: ${argv[*]}" + ( cd "$PROJECT_DIR" && "${argv[@]}" ) || { + echo " !! initializeCommand failed" >&2; LIFECYCLE_FAILURES=$((LIFECYCLE_FAILURES+1)); } + done < <(printf '%s' "$spec" | jq -c "$JQ_NORMALIZE") +} +step "4/8" "initializeCommand..." +run_outer "$(printf '%s' "$CONFIG_JSON" | jq -c '.initializeCommand // null')" + +# --------------------------------------------------------------------------- +# 5. Build +# --------------------------------------------------------------------------- +REUSING=0 +if [ "$REBUILD" = "1" ]; then + "$PODMAN" rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true + [ "$NO_CACHE" = "1" ] && { "$PODMAN" rmi -f "$IMAGE_NAME" >/dev/null 2>&1 || true; } +elif "$PODMAN" container exists "$CONTAINER_NAME" 2>/dev/null; then + STORED_FP="$("$PODMAN" inspect --format json "$CONTAINER_NAME" 2>/dev/null \ + | jq -r '.[0].Config.Labels["che.devcontainer.config"] // ""' 2>/dev/null || echo "")" + if [ "$STORED_FP" = "$CONFIG_FINGERPRINT" ]; then + REUSING=1; step "5/8" "reusing existing container (config unchanged)." + "$PODMAN" start "$CONTAINER_NAME" >/dev/null 2>&1 || true + else + step "5/8" "devcontainer.json changed; rebuilding." + "$PODMAN" rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true + fi +fi +if [ "$REUSING" = "0" ]; then + run_preflight + step "5/8" "building image (slow part)..." + BUILD_ARGS=(--docker-path="$PODMAN" --workspace-folder "$PROJECT_DIR" --image-name "$IMAGE_NAME") + [ "$NO_CACHE" = "1" ] && BUILD_ARGS+=(--no-cache) + "$DEVCONTAINER_BIN" build "${BUILD_ARGS[@]}" || { + echo " build failed" >&2; exit 1; } +fi + +# --------------------------------------------------------------------------- +# 6. Merged metadata from the built image +# --------------------------------------------------------------------------- +# `devcontainer build` writes a devcontainer.metadata LABEL containing the FULLY MERGED +# config — the base image's metadata, every Feature's contributions, and devcontainer.json. +# This is where remoteUser actually lives for most real repos (vscode-remote-try-node has +# its remoteUser line commented out, but the image metadata says "node"). Reading only +# devcontainer.json means lifecycle commands run as root and write root-owned node_modules +# into the bind mount. Using --include-merged-configuration instead would need a registry +# round-trip; the label is local and already merged. +JQ_MERGE=' +def last_of($k): [ .[] | .[$k] // empty ] | last // null; +def all_of($k): [ .[] | .[$k] // empty ]; +{ remoteUser: last_of("remoteUser"), containerUser: last_of("containerUser"), + workspaceFolder: last_of("workspaceFolder"), + remoteEnv: ( [ .[] | .remoteEnv // {} ] | add // {} ), + containerEnv: ( [ .[] | .containerEnv // {} ] | add // {} ), + onCreateCommand: all_of("onCreateCommand"), updateContentCommand: all_of("updateContentCommand"), + postCreateCommand: all_of("postCreateCommand"), postStartCommand: all_of("postStartCommand"), + postAttachCommand: all_of("postAttachCommand") }' + +META='{}' +label="$("$PODMAN" inspect --format json "$IMAGE_NAME" 2>/dev/null \ + | jq -r '.[0].Config.Labels["devcontainer.metadata"] // ""')" +if [ -n "$label" ]; then + META="$(printf '%s' "$label" | jq -c "$JQ_MERGE" 2>/dev/null || echo '{}')" +fi +# Fall back to devcontainer.json for anything the label did not provide. +META="$(jq -n --argjson m "$META" --argjson c "$CONFIG_JSON" ' + { remoteUser: ($m.remoteUser // $c.remoteUser // $c.containerUser // null), + workspaceFolder: ($m.workspaceFolder // $c.workspaceFolder // "/workspace"), + remoteEnv: (($c.remoteEnv // {}) + ($m.remoteEnv // {})), + containerEnv: (($c.containerEnv // {}) + ($m.containerEnv // {})), + hooks: { onCreateCommand: ($m.onCreateCommand // []), updateContentCommand: ($m.updateContentCommand // []), + postCreateCommand: ($m.postCreateCommand // []), postStartCommand: ($m.postStartCommand // []), + postAttachCommand: ($m.postAttachCommand // []) } }')" + +REMOTE_USER="$(printf '%s' "$META" | jq -r '.remoteUser // empty')" +WORKSPACE_FOLDER="$(printf '%s' "$META" | jq -r '.workspaceFolder')" +echo " remoteUser=${REMOTE_USER:-} workspaceFolder=${WORKSPACE_FOLDER}" + +env_args() { # $1 = remoteEnv|containerEnv + printf '%s' "$META" | jq -r --arg k "$1" '(.[$k] // {}) | to_entries[] + | select(.value | tostring | test("\\$\\{") | not) | "-e", "\(.key)=\(.value)"' +} +CONTAINER_ENV=(); mapfile -t CONTAINER_ENV < <(env_args containerEnv) +REMOTE_ENV=(); mapfile -t REMOTE_ENV < <(env_args remoteEnv) +skipped="$(printf '%s' "$META" | jq -r '[(.remoteEnv//{}),(.containerEnv//{})] | add | to_entries[] + | select(.value|tostring|test("\\$\\{")) | .key' | paste -sd, -)" +[ -n "$skipped" ] && echo " NOTE: env with \${...} substitution skipped: $skipped" >&2 + +# --------------------------------------------------------------------------- +# 7. Run the container, with UID parity +# --------------------------------------------------------------------------- +# The whole point of outer-editor is "edit outside, run inside" — so the two sides must agree +# on file ownership. Without keep-id, anything created inside (node_modules, build output, +# .venv) is owned by a subuid and the editor cannot modify or delete it, which is what forced +# the chmod 777 workaround. keep-id:uid=,gid= maps our uid to the remoteUser inside, so files +# created either way are owned by us. Requires overlay (fuse) — VFS cannot chown. +KEEP_ID_ARGS=() +if [ "$REUSING" = "0" ] && [ "${NO_KEEP_ID:-0}" != "1" ] && [ -c /dev/fuse ]; then + IN_UID=""; IN_GID="" + if [ -n "$REMOTE_USER" ]; then + IN_UID="$("$PODMAN" run --rm "$IMAGE_NAME" id -u "$REMOTE_USER" 2>/dev/null | tr -dc '0-9')" + IN_GID="$("$PODMAN" run --rm "$IMAGE_NAME" id -g "$REMOTE_USER" 2>/dev/null | tr -dc '0-9')" + fi + if [ -n "$IN_UID" ] && [ -n "$IN_GID" ]; then + KEEP_ID_ARGS=(--userns="keep-id:uid=${IN_UID},gid=${IN_GID}") + else + KEEP_ID_ARGS=(--userns=keep-id) + fi +fi + +# The dev container does NOT receive cluster credentials. It runs arbitrary repository +# code and with --network=host already reaches che-code (:3100) and machine-exec (:3333). +# VS Code dev containers do not expose Kubernetes service-account tokens; cluster tooling +# lives in UDI — use a regular editor terminal for kubectl/oc. +start_container() { # $1 = extra args array name + local -n extra="$1" + "$PODMAN" run -d --name "$CONTAINER_NAME" --network=host \ + --label "che.devcontainer.config=${CONFIG_FINGERPRINT}" \ + "${extra[@]}" \ + -v "$PROJECT_DIR:${WORKSPACE_FOLDER}" \ + "${CONTAINER_ENV[@]}" \ + "$IMAGE_NAME" sleep infinity +} + +KEEP_ID_OK=0 +if [ "$REUSING" = "0" ]; then + step "6/8" "starting container..." + "$PODMAN" rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true + if [ "${#KEEP_ID_ARGS[@]}" -gt 0 ] && start_container KEEP_ID_ARGS >/dev/null 2>&1; then + KEEP_ID_OK=1; echo " uid parity: ${KEEP_ID_ARGS[*]}" + else + [ "${#KEEP_ID_ARGS[@]}" -gt 0 ] && echo " keep-id unavailable; falling back (files created inside will be subuid-owned)" >&2 + "$PODMAN" rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true + NONE=(); start_container NONE >/dev/null || { echo " could not start container" >&2; exit 1; } + fi +else + step "6/8" "container already running." +fi + +# Only widen permissions when uid parity failed. a+rwX (not 777) so the execute bit is not +# set on every tracked file, which would make git report the whole repo as modified. +if [ "$KEEP_ID_OK" = "0" ]; then + "$PODMAN" exec --user 0 "$CONTAINER_NAME" chmod -R a+rwX "$WORKSPACE_FOLDER" 2>/dev/null || true +fi +"$PODMAN" exec "$CONTAINER_NAME" git config --global --replace-all safe.directory "$WORKSPACE_FOLDER" 2>/dev/null || true + +# --------------------------------------------------------------------------- +# 8. Lifecycle commands, then runtime publish +# --------------------------------------------------------------------------- +EXEC_USER=(); [ -n "$REMOTE_USER" ] && EXEC_USER=(-u "$REMOTE_USER") +CREATE_MARKER=/tmp/.devcontainer-create-hooks-done + +run_hook() { # $1 = hook name + local hook="$1" entries line label rc + entries="$(printf '%s' "$META" | jq -c --arg k "$hook" '.hooks[$k][]?')" + [ -n "$entries" ] || return 0 + while IFS= read -r spec; do + [ -z "$spec" ] && continue + while IFS= read -r line; do + [ -z "$line" ] && continue + label="$(printf '%s' "$line" | jq -r '.name')" + local -a argv=(); mapfile -t argv < <(printf '%s' "$line" | jq -r '.argv[]') + [ "${#argv[@]}" -eq 0 ] && continue + echo " -> ${hook}${label:+ [$label]}: ${argv[*]}" + rc=0 + "$PODMAN" exec "${EXEC_USER[@]}" "${REMOTE_ENV[@]}" -w "$WORKSPACE_FOLDER" \ + "$CONTAINER_NAME" "${argv[@]}" || rc=$? + [ "$rc" -ne 0 ] && { echo " !! ${hook} exited ${rc}" >&2 + LIFECYCLE_FAILURES=$((LIFECYCLE_FAILURES+1)); } + done < <(printf '%s' "$spec" | jq -c "$JQ_NORMALIZE") + done <<< "$entries" +} + +step "7/8" "lifecycle commands..." +if "$PODMAN" exec "$CONTAINER_NAME" test -f "$CREATE_MARKER" 2>/dev/null; then + echo " creation hooks already ran for this container." +else + run_hook onCreateCommand; run_hook updateContentCommand; run_hook postCreateCommand + "$PODMAN" exec "$CONTAINER_NAME" touch "$CREATE_MARKER" 2>/dev/null || true +fi +run_hook postStartCommand +run_hook postAttachCommand + +INNER_SHELL=/bin/sh +"$PODMAN" exec "$CONTAINER_NAME" sh -c 'command -v bash' >/dev/null 2>&1 && INNER_SHELL=bash + +if [ "$LIFECYCLE_FAILURES" -gt 0 ]; then + echo; echo " WARNING: ${LIFECYCLE_FAILURES} lifecycle command(s) failed." + [ "$STRICT_LIFECYCLE" = "1" ] && exit 1 +fi + +# Publish only a successful, running container. The ID binds these resolved values to +# this instance, so a replacement container cannot reuse an obsolete terminal description. +# The flock file PID is what the extension uses for in-flight build detection; runtime.json +# is published only on success and cannot replace that. +publish_runtime() ( + umask 077 + local runtime_dir runtime_tmp container_id remote_env_json + runtime_dir="$(dirname "$RUNTIME_FILE")" + mkdir -p "$runtime_dir" || return 1 + container_id="$("$PODMAN" inspect --format json "$CONTAINER_NAME" | jq -er \ + '.[0] | select(.State.Running == true) | .Id | select(type == "string" and length > 0)')" || return 1 + # Use exactly the already-resolved environment arguments passed to lifecycle commands. + remote_env_json='{}' + local i entry key value + for ((i=1; i<${#REMOTE_ENV[@]}; i+=2)); do + entry="${REMOTE_ENV[i]}"; key="${entry%%=*}"; value="${entry#*=}" + remote_env_json="$(jq -cn --argjson env "$remote_env_json" --arg k "$key" --arg v "$value" '$env + {($k): $v}')" || return 1 + done + runtime_tmp="$(mktemp "$runtime_dir/.runtime.XXXXXX")" || return 1 + trap 'rm -f -- "$runtime_tmp"' EXIT + jq -n --arg containerName "$CONTAINER_NAME" --arg containerId "$container_id" \ + --arg podmanPath "$PODMAN" --arg image "$IMAGE_NAME" --arg remoteUser "$REMOTE_USER" \ + --arg workspaceFolder "$WORKSPACE_FOLDER" --arg shell "$INNER_SHELL" \ + --arg fingerprint "$CONFIG_FINGERPRINT" --argjson remoteEnv "$remote_env_json" \ + '{version: 1, containerName: $containerName, containerId: $containerId, + podmanPath: $podmanPath, image: $image, remoteUser: $remoteUser, + workspaceFolder: $workspaceFolder, shell: $shell, remoteEnv: $remoteEnv, + fingerprint: $fingerprint}' > "$runtime_tmp" || return 1 + mv -f -- "$runtime_tmp" "$RUNTIME_FILE" +) +publish_runtime || { echo "could not publish devcontainer terminal configuration" >&2; exit 1; } + +_close_phase +echo +echo "=== ready ===" +printf ' timing: total %ss' "$(( $(date +%s) - T_START ))" +for e in "${PHASE_LOG[@]}"; do + d="${e%%|*}"; l="${e#*|}"; [ "$d" -ge 2 ] && printf ' | %s %ss' "${l%% *}" "$d" +done +echo +echo " Use the extension action: Open Terminal in Dev Container." +echo " Files: ${PROJECT_DIR} <-> ${WORKSPACE_FOLDER}" +[ "$KEEP_ID_OK" = "1" ] && echo " UID parity ON — files created inside are owned by you." \ + || echo " UID parity OFF — files created inside are subuid-owned." +echo " Shell: ${PODMAN} exec -it ${CONTAINER_NAME} ${INNER_SHELL}" +exit 0 +) +SETUP_RC=$? +# Clear the published PID while we still own the lock, before another run can acquire it. +if [ "$LOCK_HELD" = 1 ]; then + : > "$LOCK_FILE" +fi +exit "$SETUP_RC" diff --git a/wsmaster/che-core-api-factory/src/test/java/org/eclipse/che/api/factory/server/urlfactory/URLFactoryBuilderTest.java b/wsmaster/che-core-api-factory/src/test/java/org/eclipse/che/api/factory/server/urlfactory/URLFactoryBuilderTest.java index 904fd2acb7b..f39af233045 100644 --- a/wsmaster/che-core-api-factory/src/test/java/org/eclipse/che/api/factory/server/urlfactory/URLFactoryBuilderTest.java +++ b/wsmaster/che-core-api-factory/src/test/java/org/eclipse/che/api/factory/server/urlfactory/URLFactoryBuilderTest.java @@ -31,6 +31,8 @@ import com.fasterxml.jackson.databind.node.JsonNodeFactory; import com.fasterxml.jackson.databind.node.ObjectNode; import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Base64; import java.util.Collections; import java.util.List; import java.util.Map; @@ -413,64 +415,80 @@ public void shouldThrowErrorOnUnsupportedDevfileContent() false); } - @Test - public void testDevfileFoundSoDevcontainerProbeNeverRuns() throws Exception { - String devfileLocation = "http://repo/raw/devfile.yaml"; - String devcontainerLocation = "http://repo/raw/.devcontainer/devcontainer.json"; + private static final String TEST_DEVFILE_LOCATION = "http://repo/raw/devfile.yaml"; + private static final String TEST_DEVCONTAINER_LOCATION = + "http://repo/raw/.devcontainer/devcontainer.json"; + private static final String TEST_DEVCONTAINER_LOCATION_ROOT = + "http://repo/raw/.devcontainer.json"; - RemoteFactoryUrl remoteUrl = - new RemoteFactoryUrl() { - @Override - public String getProviderName() { - return "test"; - } + private static RemoteFactoryUrl testRemoteUrl() { + return testRemoteUrl(Optional.empty()); + } - @Override - public List devfileFileLocations() { - return singletonList( - new DevfileLocation() { - @Override - public Optional filename() { - return Optional.of("devfile.yaml"); - } + private static RemoteFactoryUrl testRemoteUrl(Optional credentials) { + return new RemoteFactoryUrl() { + @Override + public String getProviderName() { + return "test"; + } - @Override - public String location() { - return devfileLocation; - } - }); - } + @Override + public List devfileFileLocations() { + return singletonList( + new DevfileLocation() { + @Override + public Optional filename() { + return Optional.of("devfile.yaml"); + } + + @Override + public String location() { + return TEST_DEVFILE_LOCATION; + } + }); + } - @Override - public String rawFileLocation(String filename) { - return "http://repo/raw/" + filename; - } + @Override + public String rawFileLocation(String filename) { + return "http://repo/raw/" + filename; + } - @Override - public String getHostName() { - return "repo"; - } + @Override + public String getHostName() { + return "repo"; + } - @Override - public String getProviderUrl() { - return "http://repo"; - } + @Override + public String getProviderUrl() { + return "http://repo"; + } - @Override - public String getBranch() { - return null; - } + @Override + public String getBranch() { + return null; + } - @Override - public Optional getCredentials() { - return Optional.empty(); - } + @Override + public Optional getCredentials() { + return credentials; + } - @Override - public void setDevfileFilename(String devfileName) {} - }; + @Override + public void setDevfileFilename(String devfileName) {} + }; + } + + private void stubDevcontainerTemplateParse() throws DevfileException { + Map templateAdditions = + Map.of("commands", List.of(Map.of("id", "start-devcontainer"))); + JsonNode templateNode = new ObjectNode(JsonNodeFactory.instance); + when(devfileParser.parseYamlRaw(anyString())).thenReturn(templateNode); + when(devfileParser.convertYamlToMap(templateNode)).thenReturn(templateAdditions); + } - when(fileContentProvider.fetchContent(eq(devfileLocation))).thenReturn("devfile content"); + @Test + public void testDevfileFoundSoDevcontainerProbeNeverRuns() throws Exception { + when(fileContentProvider.fetchContent(eq(TEST_DEVFILE_LOCATION))).thenReturn("devfile content"); when(devfileParser.parseYamlRaw(eq("devfile content"))) .thenReturn(new ObjectNode(JsonNodeFactory.instance)); when(devfileParser.convertYamlToMap(org.mockito.ArgumentMatchers.any())) @@ -478,84 +496,24 @@ public void setDevfileFilename(String devfileName) {} Optional result = urlFactoryBuilder.createFactoryFromDevfile( - remoteUrl, fileContentProvider, emptyMap(), false); + testRemoteUrl(), fileContentProvider, emptyMap(), false); assertTrue(result.isPresent()); assertEquals(result.get().getSource(), "devfile.yaml"); - verify(fileContentProvider, never()).fetchContent(eq(devcontainerLocation)); + verify(fileContentProvider, never()).fetchContent(eq(TEST_DEVCONTAINER_LOCATION)); } @Test public void testDevcontainerDetectedWhenNoDevfile() throws Exception { - String devfileLocation = "http://repo/raw/devfile.yaml"; - String devcontainerLocation = "http://repo/raw/.devcontainer/devcontainer.json"; - - RemoteFactoryUrl remoteUrl = - new RemoteFactoryUrl() { - @Override - public String getProviderName() { - return "test"; - } - - @Override - public List devfileFileLocations() { - return singletonList( - new DevfileLocation() { - @Override - public Optional filename() { - return Optional.of("devfile.yaml"); - } - - @Override - public String location() { - return devfileLocation; - } - }); - } - - @Override - public String rawFileLocation(String filename) { - return "http://repo/raw/" + filename; - } - - @Override - public String getHostName() { - return "repo"; - } - - @Override - public String getProviderUrl() { - return "http://repo"; - } - - @Override - public String getBranch() { - return null; - } - - @Override - public Optional getCredentials() { - return Optional.empty(); - } - - @Override - public void setDevfileFilename(String devfileName) {} - }; - - Map templateAdditions = - Map.of("commands", List.of(Map.of("id", "start-devcontainer"))); - - when(fileContentProvider.fetchContent(eq(devfileLocation))) + stubDevcontainerTemplateParse(); + when(fileContentProvider.fetchContent(eq(TEST_DEVFILE_LOCATION))) .thenThrow(new IOException("not found")); - when(fileContentProvider.fetchContent(eq(devcontainerLocation))) + when(fileContentProvider.fetchContent(eq(TEST_DEVCONTAINER_LOCATION))) .thenReturn("{\"name\": \"test\"}"); - JsonNode templateNode = new ObjectNode(JsonNodeFactory.instance); - when(devfileParser.parseYamlRaw(anyString())).thenReturn(templateNode); - when(devfileParser.convertYamlToMap(templateNode)).thenReturn(templateAdditions); Optional result = urlFactoryBuilder.createFactoryFromDevfile( - remoteUrl, fileContentProvider, emptyMap(), false); + testRemoteUrl(), fileContentProvider, emptyMap(), false); assertTrue(result.isPresent()); assertEquals(result.get().getSource(), ".devcontainer/devcontainer.json"); @@ -566,355 +524,140 @@ public void setDevfileFilename(String devfileName) {} } @Test - public void testNoDevfileNoDevcontainerReturnsEmpty() throws Exception { - String devfileLocation = "http://repo/raw/devfile.yaml"; + public void testDevcontainerDetectedAtRootWhenNestedMissing() throws Exception { + stubDevcontainerTemplateParse(); + when(fileContentProvider.fetchContent(eq(TEST_DEVFILE_LOCATION))) + .thenThrow(new IOException("not found")); + when(fileContentProvider.fetchContent(eq(TEST_DEVCONTAINER_LOCATION))) + .thenThrow(new IOException("not found")); + when(fileContentProvider.fetchContent(eq(TEST_DEVCONTAINER_LOCATION_ROOT))) + .thenReturn("{\"name\": \"test\"}"); - RemoteFactoryUrl remoteUrl = - new RemoteFactoryUrl() { - @Override - public String getProviderName() { - return "test"; - } + Optional result = + urlFactoryBuilder.createFactoryFromDevfile( + testRemoteUrl(), fileContentProvider, emptyMap(), false); - @Override - public List devfileFileLocations() { - return singletonList( - new DevfileLocation() { - @Override - public Optional filename() { - return Optional.of("devfile.yaml"); - } + assertTrue(result.isPresent()); + assertEquals(result.get().getSource(), ".devcontainer.json"); + } - @Override - public String location() { - return devfileLocation; - } - }); - } + @Test + public void testDevcontainerDetectedWithoutAuthentication() throws Exception { + stubDevcontainerTemplateParse(); + when(fileContentProvider.fetchContentWithoutAuthentication(eq(TEST_DEVFILE_LOCATION))) + .thenThrow(new IOException("not found")); + when(fileContentProvider.fetchContentWithoutAuthentication(eq(TEST_DEVCONTAINER_LOCATION))) + .thenReturn("{\"name\": \"test\"}"); - @Override - public String rawFileLocation(String filename) { - return "http://repo/raw/" + filename; - } + Optional result = + urlFactoryBuilder.createFactoryFromDevfile( + testRemoteUrl(), fileContentProvider, emptyMap(), true); - @Override - public String getHostName() { - return "repo"; - } + assertTrue(result.isPresent()); + assertEquals(result.get().getSource(), ".devcontainer/devcontainer.json"); + verify(fileContentProvider, never()).fetchContent(anyString()); + } - @Override - public String getProviderUrl() { - return "http://repo"; - } + @Test + public void testDevcontainerDetectedWithCredentials() throws Exception { + stubDevcontainerTemplateParse(); + when(fileContentProvider.fetchContent(eq(TEST_DEVFILE_LOCATION), eq("user:token"))) + .thenThrow(new IOException("not found")); + when(fileContentProvider.fetchContent(eq(TEST_DEVCONTAINER_LOCATION), eq("user:token"))) + .thenReturn("{\"name\": \"test\"}"); - @Override - public String getBranch() { - return null; - } + Optional result = + urlFactoryBuilder.createFactoryFromDevfile( + testRemoteUrl(Optional.of("user:token")), fileContentProvider, emptyMap(), false); - @Override - public Optional getCredentials() { - return Optional.empty(); - } + assertTrue(result.isPresent()); + assertEquals(result.get().getSource(), ".devcontainer/devcontainer.json"); + } - @Override - public void setDevfileFilename(String devfileName) {} - }; + @Test( + expectedExceptions = UnauthorizedException.class, + expectedExceptionsMessageRegExp = "SCM Authentication required") + public void testDevcontainerProbeRethrowsUnauthorized() throws Exception { + when(fileContentProvider.fetchContent(eq(TEST_DEVFILE_LOCATION))) + .thenThrow(new IOException("not found")); + when(fileContentProvider.fetchContent(eq(TEST_DEVCONTAINER_LOCATION))) + .thenThrow( + new DevfileException( + "auth required", + new ScmUnauthorizedException("foo", "github", "2.0", "http://oauth.example"))); + urlFactoryBuilder.createFactoryFromDevfile( + testRemoteUrl(), fileContentProvider, emptyMap(), false); + } + + @Test + public void testNoDevfileNoDevcontainerReturnsEmpty() throws Exception { when(fileContentProvider.fetchContent(anyString())).thenThrow(new IOException("not found")); Optional result = urlFactoryBuilder.createFactoryFromDevfile( - remoteUrl, fileContentProvider, emptyMap(), false); + testRemoteUrl(), fileContentProvider, emptyMap(), false); assertFalse(result.isPresent()); } @Test public void testDevcontainerFetchIOExceptionReturnsEmpty() throws Exception { - String devfileLocation = "http://repo/raw/devfile.yaml"; - String devcontainerLocation = "http://repo/raw/.devcontainer/devcontainer.json"; - String devcontainerLocation2 = "http://repo/raw/.devcontainer.json"; - - RemoteFactoryUrl remoteUrl = - new RemoteFactoryUrl() { - @Override - public String getProviderName() { - return "test"; - } - - @Override - public List devfileFileLocations() { - return singletonList( - new DevfileLocation() { - @Override - public Optional filename() { - return Optional.of("devfile.yaml"); - } - - @Override - public String location() { - return devfileLocation; - } - }); - } - - @Override - public String rawFileLocation(String filename) { - return "http://repo/raw/" + filename; - } - - @Override - public String getHostName() { - return "repo"; - } - - @Override - public String getProviderUrl() { - return "http://repo"; - } - - @Override - public String getBranch() { - return null; - } - - @Override - public Optional getCredentials() { - return Optional.empty(); - } - - @Override - public void setDevfileFilename(String devfileName) {} - }; - - when(fileContentProvider.fetchContent(eq(devfileLocation))) + when(fileContentProvider.fetchContent(eq(TEST_DEVFILE_LOCATION))) .thenThrow(new IOException("not found")); - when(fileContentProvider.fetchContent(eq(devcontainerLocation))) + when(fileContentProvider.fetchContent(eq(TEST_DEVCONTAINER_LOCATION))) .thenThrow(new IOException("not found")); - when(fileContentProvider.fetchContent(eq(devcontainerLocation2))) + when(fileContentProvider.fetchContent(eq(TEST_DEVCONTAINER_LOCATION_ROOT))) .thenThrow(new IOException("not found")); Optional result = urlFactoryBuilder.createFactoryFromDevfile( - remoteUrl, fileContentProvider, emptyMap(), false); + testRemoteUrl(), fileContentProvider, emptyMap(), false); assertFalse(result.isPresent()); } @Test public void testDevcontainerEmptyContentReturnsEmpty() throws Exception { - String devfileLocation = "http://repo/raw/devfile.yaml"; - String devcontainerLocation = "http://repo/raw/.devcontainer/devcontainer.json"; - String devcontainerLocation2 = "http://repo/raw/.devcontainer.json"; - - RemoteFactoryUrl remoteUrl = - new RemoteFactoryUrl() { - @Override - public String getProviderName() { - return "test"; - } - - @Override - public List devfileFileLocations() { - return singletonList( - new DevfileLocation() { - @Override - public Optional filename() { - return Optional.of("devfile.yaml"); - } - - @Override - public String location() { - return devfileLocation; - } - }); - } - - @Override - public String rawFileLocation(String filename) { - return "http://repo/raw/" + filename; - } - - @Override - public String getHostName() { - return "repo"; - } - - @Override - public String getProviderUrl() { - return "http://repo"; - } - - @Override - public String getBranch() { - return null; - } - - @Override - public Optional getCredentials() { - return Optional.empty(); - } - - @Override - public void setDevfileFilename(String devfileName) {} - }; - - when(fileContentProvider.fetchContent(eq(devfileLocation))) + when(fileContentProvider.fetchContent(eq(TEST_DEVFILE_LOCATION))) .thenThrow(new IOException("not found")); - when(fileContentProvider.fetchContent(eq(devcontainerLocation))).thenReturn(""); - when(fileContentProvider.fetchContent(eq(devcontainerLocation2))).thenReturn(""); + when(fileContentProvider.fetchContent(eq(TEST_DEVCONTAINER_LOCATION))).thenReturn(""); + when(fileContentProvider.fetchContent(eq(TEST_DEVCONTAINER_LOCATION_ROOT))).thenReturn(""); Optional result = urlFactoryBuilder.createFactoryFromDevfile( - remoteUrl, fileContentProvider, emptyMap(), false); + testRemoteUrl(), fileContentProvider, emptyMap(), false); assertFalse(result.isPresent()); } @Test public void testDevcontainerHtmlContentReturnsEmpty() throws Exception { - String devfileLocation = "http://repo/raw/devfile.yaml"; - String devcontainerLocation = "http://repo/raw/.devcontainer/devcontainer.json"; - String devcontainerLocation2 = "http://repo/raw/.devcontainer.json"; - - RemoteFactoryUrl remoteUrl = - new RemoteFactoryUrl() { - @Override - public String getProviderName() { - return "test"; - } - - @Override - public List devfileFileLocations() { - return singletonList( - new DevfileLocation() { - @Override - public Optional filename() { - return Optional.of("devfile.yaml"); - } - - @Override - public String location() { - return devfileLocation; - } - }); - } - - @Override - public String rawFileLocation(String filename) { - return "http://repo/raw/" + filename; - } - - @Override - public String getHostName() { - return "repo"; - } - - @Override - public String getProviderUrl() { - return "http://repo"; - } - - @Override - public String getBranch() { - return null; - } - - @Override - public Optional getCredentials() { - return Optional.empty(); - } - - @Override - public void setDevfileFilename(String devfileName) {} - }; - - when(fileContentProvider.fetchContent(eq(devfileLocation))) + when(fileContentProvider.fetchContent(eq(TEST_DEVFILE_LOCATION))) .thenThrow(new IOException("not found")); - when(fileContentProvider.fetchContent(eq(devcontainerLocation))) + when(fileContentProvider.fetchContent(eq(TEST_DEVCONTAINER_LOCATION))) .thenReturn("Access denied"); - when(fileContentProvider.fetchContent(eq(devcontainerLocation2))) + when(fileContentProvider.fetchContent(eq(TEST_DEVCONTAINER_LOCATION_ROOT))) .thenReturn("Access denied"); Optional result = urlFactoryBuilder.createFactoryFromDevfile( - remoteUrl, fileContentProvider, emptyMap(), false); + testRemoteUrl(), fileContentProvider, emptyMap(), false); assertFalse(result.isPresent()); } @Test public void testDevcontainerJsoncWithLeadingCommentDetected() throws Exception { - String devfileLocation = "http://repo/raw/devfile.yaml"; - String devcontainerLocation = "http://repo/raw/.devcontainer/devcontainer.json"; - - RemoteFactoryUrl remoteUrl = - new RemoteFactoryUrl() { - @Override - public String getProviderName() { - return "test"; - } - - @Override - public List devfileFileLocations() { - return singletonList( - new DevfileLocation() { - @Override - public Optional filename() { - return Optional.of("devfile.yaml"); - } - - @Override - public String location() { - return devfileLocation; - } - }); - } - - @Override - public String rawFileLocation(String filename) { - return "http://repo/raw/" + filename; - } - - @Override - public String getHostName() { - return "repo"; - } - - @Override - public String getProviderUrl() { - return "http://repo"; - } - - @Override - public String getBranch() { - return null; - } - - @Override - public Optional getCredentials() { - return Optional.empty(); - } - - @Override - public void setDevfileFilename(String devfileName) {} - }; - - Map templateAdditions = - Map.of("commands", List.of(Map.of("id", "start-devcontainer"))); - - when(fileContentProvider.fetchContent(eq(devfileLocation))) + stubDevcontainerTemplateParse(); + when(fileContentProvider.fetchContent(eq(TEST_DEVFILE_LOCATION))) .thenThrow(new IOException("not found")); - when(fileContentProvider.fetchContent(eq(devcontainerLocation))) + when(fileContentProvider.fetchContent(eq(TEST_DEVCONTAINER_LOCATION))) .thenReturn("// This is a JSONC comment\n{\"name\": \"test\"}"); - JsonNode templateNode = new ObjectNode(JsonNodeFactory.instance); - when(devfileParser.parseYamlRaw(anyString())).thenReturn(templateNode); - when(devfileParser.convertYamlToMap(templateNode)).thenReturn(templateAdditions); Optional result = urlFactoryBuilder.createFactoryFromDevfile( - remoteUrl, fileContentProvider, emptyMap(), false); + testRemoteUrl(), fileContentProvider, emptyMap(), false); assertTrue(result.isPresent()); assertEquals(result.get().getSource(), ".devcontainer/devcontainer.json"); @@ -923,80 +666,60 @@ public void setDevfileFilename(String devfileName) {} @Test public void testDevcontainerJsoncWithBlockCommentDetected() throws Exception { - String devfileLocation = "http://repo/raw/devfile.yaml"; - String devcontainerLocation = "http://repo/raw/.devcontainer/devcontainer.json"; - - RemoteFactoryUrl remoteUrl = - new RemoteFactoryUrl() { - @Override - public String getProviderName() { - return "test"; - } - - @Override - public List devfileFileLocations() { - return singletonList( - new DevfileLocation() { - @Override - public Optional filename() { - return Optional.of("devfile.yaml"); - } - - @Override - public String location() { - return devfileLocation; - } - }); - } - - @Override - public String rawFileLocation(String filename) { - return "http://repo/raw/" + filename; - } - - @Override - public String getHostName() { - return "repo"; - } - - @Override - public String getProviderUrl() { - return "http://repo"; - } - - @Override - public String getBranch() { - return null; - } - - @Override - public Optional getCredentials() { - return Optional.empty(); - } - - @Override - public void setDevfileFilename(String devfileName) {} - }; - - Map templateAdditions = - Map.of("commands", List.of(Map.of("id", "start-devcontainer"))); - - when(fileContentProvider.fetchContent(eq(devfileLocation))) + stubDevcontainerTemplateParse(); + when(fileContentProvider.fetchContent(eq(TEST_DEVFILE_LOCATION))) .thenThrow(new IOException("not found")); - when(fileContentProvider.fetchContent(eq(devcontainerLocation))) + when(fileContentProvider.fetchContent(eq(TEST_DEVCONTAINER_LOCATION))) .thenReturn("/*\n * Generated config\n */\n{\"name\": \"test\"}"); - JsonNode templateNode = new ObjectNode(JsonNodeFactory.instance); - when(devfileParser.parseYamlRaw(anyString())).thenReturn(templateNode); - when(devfileParser.convertYamlToMap(templateNode)).thenReturn(templateAdditions); Optional result = urlFactoryBuilder.createFactoryFromDevfile( - remoteUrl, fileContentProvider, emptyMap(), false); + testRemoteUrl(), fileContentProvider, emptyMap(), false); assertTrue(result.isPresent()); assertEquals(result.get().getSource(), ".devcontainer/devcontainer.json"); } + @Test + public void testLooksLikeJson() { + assertFalse(URLFactoryBuilder.looksLikeJson(null)); + assertFalse(URLFactoryBuilder.looksLikeJson("")); + assertFalse(URLFactoryBuilder.looksLikeJson(" ")); + assertFalse(URLFactoryBuilder.looksLikeJson("")); + assertTrue(URLFactoryBuilder.looksLikeJson("{\"name\": \"test\"}")); + assertTrue(URLFactoryBuilder.looksLikeJson("\uFEFF{\"name\": \"test\"}")); + assertTrue(URLFactoryBuilder.looksLikeJson("// comment\n{\"name\": \"test\"}")); + assertTrue(URLFactoryBuilder.looksLikeJson("/* comment */ {\"name\": \"test\"}")); + assertTrue(URLFactoryBuilder.looksLikeJson("/*\n * generated\n */\n{\"name\": \"test\"}")); + assertFalse(URLFactoryBuilder.looksLikeJson("/* unterminated")); + } + + @Test + public void testDevcontainerTemplateEncodesScriptWithoutDwoBashDefaults() { + String template = URLFactoryBuilder.getDevcontainerDevfileTemplate(); + assertFalse(template.contains("__START_DEVCONTAINER_B64__")); + assertFalse(template.contains("${PROJECTS_ROOT:-")); + assertFalse(template.contains("${PROJECT_SOURCE")); + assertTrue(template.contains("id: start-devcontainer")); + assertTrue(template.contains("id: rebuild-devcontainer")); + assertTrue(template.contains("id: rebuild-devcontainer-no-cache")); + assertTrue(template.contains("id: show-devcontainer-log")); + assertTrue(template.contains("id: clean-devcontainer-images")); + assertTrue(template.contains("workingDir: ${PROJECTS_ROOT}")); + + String marker = "base64 -d >/tmp/start-devcontainer.sh <<'DEVCONTAINER_SCRIPT_B64'\n"; + int encodedAt = template.indexOf(marker); + assertTrue(encodedAt >= 0); + int encodedStart = encodedAt + marker.length(); + int encodedEnd = template.indexOf("DEVCONTAINER_SCRIPT_B64", encodedStart); + assertTrue(encodedEnd > encodedStart); + String encoded = template.substring(encodedStart, encodedEnd).trim(); + String decoded = new String(Base64.getDecoder().decode(encoded), StandardCharsets.UTF_8); + assertTrue(decoded.startsWith("#!/usr/bin/env bash")); + assertTrue(decoded.contains("${PROJECTS_ROOT:-/projects}")); + assertTrue(decoded.contains("${DEVCONTAINER_CLI_VERSION:-0.89.0}")); + } + @DataProvider public static Object[][] devfileExceptions() { return new Object[][] { From fe1fe3c0829e8ef27c852efc072a37fc0b5eade9 Mon Sep 17 00:00:00 2001 From: Rohan Kumar Date: Fri, 18 Sep 2026 15:49:24 +0530 Subject: [PATCH 07/12] fix(factory): publish devcontainer config file fingerprint in runtime Expose resolved config path and on-disk file hash so the editor can detect devcontainer.json changes separately from resolved-config reuse. Co-authored-by: Cursor --- .../devcontainer-devfile-template.yaml | 8 -------- .../src/main/resources/start-devcontainer.sh | 17 ++++++++++++++++- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/wsmaster/che-core-api-factory/src/main/resources/devcontainer-devfile-template.yaml b/wsmaster/che-core-api-factory/src/main/resources/devcontainer-devfile-template.yaml index 9334688a2c4..d21cca34721 100644 --- a/wsmaster/che-core-api-factory/src/main/resources/devcontainer-devfile-template.yaml +++ b/wsmaster/che-core-api-factory/src/main/resources/devcontainer-devfile-template.yaml @@ -9,14 +9,6 @@ # Contributors: # Red Hat, Inc. - initial API and implementation # -# Commands target Che's default UDI component name, which the dashboard injects -# when a factory has no components. Custom CheCluster defaultComponents names -# will not match these exec commands. -# -# The start script is stored as start-devcontainer.sh and substituted here as -# base64 so DevWorkspace flattening cannot rewrite bash variable-default syntax -# for the projects root / project source inside the script body. -# commands: - id: start-devcontainer diff --git a/wsmaster/che-core-api-factory/src/main/resources/start-devcontainer.sh b/wsmaster/che-core-api-factory/src/main/resources/start-devcontainer.sh index c1dbe2c30b5..dcc556be9f8 100644 --- a/wsmaster/che-core-api-factory/src/main/resources/start-devcontainer.sh +++ b/wsmaster/che-core-api-factory/src/main/resources/start-devcontainer.sh @@ -182,11 +182,22 @@ if [ -z "$CONFIG_JSON" ]; then echo " read-configuration failed; using fallback parser on $DC" >&2 # whole-line comments only, so a URL inside a string survives CONFIG_JSON="$(sed -e 's@^[[:space:]]*//.*$@@' "$DC" | jq -c '.' 2>/dev/null)" + CONFIG_PATH="$DC" [ -n "$CONFIG_JSON" ] || { echo " could not parse $DC" >&2; exit 1; } +else + # Use the file the CLI actually resolved, rather than repeating config discovery. + CONFIG_PATH="$(printf '%s' "$CONFIG_JSON" | jq -r '.configFilePath.fsPath // empty' 2>/dev/null || true)" fi CONFIG_FINGERPRINT="$(printf '%s' "$CONFIG_JSON" | sha256sum | cut -d' ' -f1)" +# Raw config bytes for the editor's "Config changed" check. CONFIG_FINGERPRINT above +# remains the resolved-config hash used for container reuse and the container label. +CONFIG_FILE_FINGERPRINT="" +if [ -n "$CONFIG_PATH" ] && [ -f "$CONFIG_PATH" ]; then + CONFIG_FILE_FINGERPRINT="$(sha256sum "$CONFIG_PATH" | cut -d' ' -f1)" || CONFIG_FILE_FINGERPRINT="" +fi + # --------------------------------------------------------------------------- # 4. initializeCommand (runs OUTSIDE the container, per spec) # --------------------------------------------------------------------------- @@ -419,10 +430,14 @@ publish_runtime() ( --arg podmanPath "$PODMAN" --arg image "$IMAGE_NAME" --arg remoteUser "$REMOTE_USER" \ --arg workspaceFolder "$WORKSPACE_FOLDER" --arg shell "$INNER_SHELL" \ --arg fingerprint "$CONFIG_FINGERPRINT" --argjson remoteEnv "$remote_env_json" \ + --arg configPath "$CONFIG_PATH" --arg configFileFingerprint "$CONFIG_FILE_FINGERPRINT" \ '{version: 1, containerName: $containerName, containerId: $containerId, podmanPath: $podmanPath, image: $image, remoteUser: $remoteUser, workspaceFolder: $workspaceFolder, shell: $shell, remoteEnv: $remoteEnv, - fingerprint: $fingerprint}' > "$runtime_tmp" || return 1 + fingerprint: $fingerprint} + + (if $configPath != "" and $configFileFingerprint != "" then + {configPath: $configPath, configFileFingerprint: $configFileFingerprint} + else {} end)' > "$runtime_tmp" || return 1 mv -f -- "$runtime_tmp" "$RUNTIME_FILE" ) publish_runtime || { echo "could not publish devcontainer terminal configuration" >&2; exit 1; } From 4092c8bc0ecde88828d90084d3cc5cdda4341f9e Mon Sep 17 00:00:00 2001 From: Rohan Kumar Date: Thu, 24 Sep 2026 14:41:31 +0000 Subject: [PATCH 08/12] fix: do not re-chmod the project when reusing a container with uid parity KEEP_ID_OK is set only on the path that creates the container, so every reuse left it at 0 and ran the fallback `chmod -R a+rwX` over the whole project -- on a container that already has uid parity and does not need it. On a large repository that is a full tree walk on every workspace start, and it widens permissions on files that were correctly owned to begin with. A reused container keeps the user-namespace mapping it was created with, so ask the container instead of trusting a variable the create path never set. The mapping is read as JSON through jq rather than with --format: UidMap is the JSON key but not the Go field name, and the template form fails with "can't evaluate field UidMap in type *define.InspectIDMappings" on podman 5.8. Signed-off-by: Rohan Kumar --- .../src/main/resources/start-devcontainer.sh | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/wsmaster/che-core-api-factory/src/main/resources/start-devcontainer.sh b/wsmaster/che-core-api-factory/src/main/resources/start-devcontainer.sh index dcc556be9f8..b2b6a5ae61d 100644 --- a/wsmaster/che-core-api-factory/src/main/resources/start-devcontainer.sh +++ b/wsmaster/che-core-api-factory/src/main/resources/start-devcontainer.sh @@ -352,6 +352,19 @@ if [ "$REUSING" = "0" ]; then fi else step "6/8" "container already running." + # A reused container keeps the user-namespace mapping it was created with, so ask the + # container rather than trusting a variable the create path never set. Without this, + # KEEP_ID_OK stays 0 on every reuse and the fallback chmod below walks the whole project + # and widens permissions on a container that already has uid parity. + # + # JSON + jq, not --format: `.HostConfig.IDMappings.UidMap` is the JSON key but not the Go + # field name, and the template form fails with "can't evaluate field UidMap in type + # *define.InspectIDMappings" on podman 5.8. + if "$PODMAN" inspect "$CONTAINER_NAME" --format json 2>/dev/null \ + | jq -e '(.[0].HostConfig.IDMappings.UidMap // []) | length > 0' >/dev/null 2>&1; then + KEEP_ID_OK=1 + echo " uid parity: already active on the reused container" + fi fi # Only widen permissions when uid parity failed. a+rwX (not 777) so the execute bit is not From f20b27fd45d80456b982ba89a03cbe76f035a81c Mon Sep 17 00:00:00 2001 From: Rohan Kumar Date: Thu, 24 Sep 2026 14:41:52 +0000 Subject: [PATCH 09/12] refactor: drop the editor-facing runtime description The first iteration ships the devfile commands only, with no editor extension, so nothing reads the runtime description the script published. Publishing state that no consumer reads is dead weight in review and in the script. Removed: runtime.json and everything that fed it -- publish_runtime and the raw-file config fingerprint. The resolved-config fingerprint stays: it decides container reuse and labels the container. The setup lock stays as well. It serialises concurrent runs, which has nothing to do with the editor. The closing summary pointed at an editor action that does not exist yet; it now points at the podman exec line it already prints. This is a change of scope, not of design. The runtime description and the extension that consumes it are on a separate branch for the next iteration, where both sides can be reviewed together. Signed-off-by: Rohan Kumar --- .../src/main/resources/start-devcontainer.sh | 59 ++----------------- 1 file changed, 5 insertions(+), 54 deletions(-) diff --git a/wsmaster/che-core-api-factory/src/main/resources/start-devcontainer.sh b/wsmaster/che-core-api-factory/src/main/resources/start-devcontainer.sh index b2b6a5ae61d..3b0ddbe5038 100644 --- a/wsmaster/che-core-api-factory/src/main/resources/start-devcontainer.sh +++ b/wsmaster/che-core-api-factory/src/main/resources/start-devcontainer.sh @@ -24,13 +24,12 @@ # NO_CACHE=1 rebuild without cache (passes --no-cache to devcontainer build) # STRICT_LIFECYCLE=1 exit non-zero if any lifecycle command fails # NO_KEEP_ID=1 skip --userns=keep-id (debugging) -# CHE_DEVCONTAINER_RUNTIME terminal description (default: /tmp/che-devcontainer/runtime.json) # PREFLIGHT_IMAGE runnable probe image (default: quay.io/podman/hello:latest; override for a registry mirror) # set -uo pipefail # Serialize setup and rebuild without interrupting an active build. Opening in append mode -# preserves the current owner's PID for the extension while another invocation waits. +# preserves the current owner's PID while another invocation waits. LOCK_FILE="${LOCK_FILE:-/tmp/.devcontainer-setup.lock}" LOCK_HELD=0 if command -v flock >/dev/null 2>&1; then @@ -43,15 +42,10 @@ if command -v flock >/dev/null 2>&1; then printf '%s\n' "$$" > "$LOCK_FILE" fi -# The parent owns the lock and stays alive for the extension's PID-based build detection. -# Close the descriptor in the setup child so conmon, fuse-overlayfs, and lifecycle processes -# cannot retain it after setup finishes. +# The parent owns the lock and stays alive for the whole run. Close the descriptor in the setup +# child so conmon, fuse-overlayfs, and lifecycle processes cannot retain it after setup finishes. ( exec 9>&- -RUNTIME_FILE="${CHE_DEVCONTAINER_RUNTIME:-/tmp/che-devcontainer/runtime.json}" -# Invalidate the previous terminal configuration while holding the setup lock. A failed -# setup must never leave a description that the extension could mistake for a ready result. -rm -f -- "$RUNTIME_FILE" || exit 1 PROJECTS_ROOT="${PROJECTS_ROOT:-/projects}" # Project: explicit arg > DWO's PROJECT_SOURCE > the only directory under PROJECTS_ROOT. if [ "$#" -ge 1 ] && [ -n "${1:-}" ]; then @@ -191,13 +185,6 @@ fi CONFIG_FINGERPRINT="$(printf '%s' "$CONFIG_JSON" | sha256sum | cut -d' ' -f1)" -# Raw config bytes for the editor's "Config changed" check. CONFIG_FINGERPRINT above -# remains the resolved-config hash used for container reuse and the container label. -CONFIG_FILE_FINGERPRINT="" -if [ -n "$CONFIG_PATH" ] && [ -f "$CONFIG_PATH" ]; then - CONFIG_FILE_FINGERPRINT="$(sha256sum "$CONFIG_PATH" | cut -d' ' -f1)" || CONFIG_FILE_FINGERPRINT="" -fi - # --------------------------------------------------------------------------- # 4. initializeCommand (runs OUTSIDE the container, per spec) # --------------------------------------------------------------------------- @@ -375,7 +362,7 @@ fi "$PODMAN" exec "$CONTAINER_NAME" git config --global --replace-all safe.directory "$WORKSPACE_FOLDER" 2>/dev/null || true # --------------------------------------------------------------------------- -# 8. Lifecycle commands, then runtime publish +# 8. Lifecycle commands # --------------------------------------------------------------------------- EXEC_USER=(); [ -n "$REMOTE_USER" ] && EXEC_USER=(-u "$REMOTE_USER") CREATE_MARKER=/tmp/.devcontainer-create-hooks-done @@ -419,42 +406,6 @@ if [ "$LIFECYCLE_FAILURES" -gt 0 ]; then [ "$STRICT_LIFECYCLE" = "1" ] && exit 1 fi -# Publish only a successful, running container. The ID binds these resolved values to -# this instance, so a replacement container cannot reuse an obsolete terminal description. -# The flock file PID is what the extension uses for in-flight build detection; runtime.json -# is published only on success and cannot replace that. -publish_runtime() ( - umask 077 - local runtime_dir runtime_tmp container_id remote_env_json - runtime_dir="$(dirname "$RUNTIME_FILE")" - mkdir -p "$runtime_dir" || return 1 - container_id="$("$PODMAN" inspect --format json "$CONTAINER_NAME" | jq -er \ - '.[0] | select(.State.Running == true) | .Id | select(type == "string" and length > 0)')" || return 1 - # Use exactly the already-resolved environment arguments passed to lifecycle commands. - remote_env_json='{}' - local i entry key value - for ((i=1; i<${#REMOTE_ENV[@]}; i+=2)); do - entry="${REMOTE_ENV[i]}"; key="${entry%%=*}"; value="${entry#*=}" - remote_env_json="$(jq -cn --argjson env "$remote_env_json" --arg k "$key" --arg v "$value" '$env + {($k): $v}')" || return 1 - done - runtime_tmp="$(mktemp "$runtime_dir/.runtime.XXXXXX")" || return 1 - trap 'rm -f -- "$runtime_tmp"' EXIT - jq -n --arg containerName "$CONTAINER_NAME" --arg containerId "$container_id" \ - --arg podmanPath "$PODMAN" --arg image "$IMAGE_NAME" --arg remoteUser "$REMOTE_USER" \ - --arg workspaceFolder "$WORKSPACE_FOLDER" --arg shell "$INNER_SHELL" \ - --arg fingerprint "$CONFIG_FINGERPRINT" --argjson remoteEnv "$remote_env_json" \ - --arg configPath "$CONFIG_PATH" --arg configFileFingerprint "$CONFIG_FILE_FINGERPRINT" \ - '{version: 1, containerName: $containerName, containerId: $containerId, - podmanPath: $podmanPath, image: $image, remoteUser: $remoteUser, - workspaceFolder: $workspaceFolder, shell: $shell, remoteEnv: $remoteEnv, - fingerprint: $fingerprint} - + (if $configPath != "" and $configFileFingerprint != "" then - {configPath: $configPath, configFileFingerprint: $configFileFingerprint} - else {} end)' > "$runtime_tmp" || return 1 - mv -f -- "$runtime_tmp" "$RUNTIME_FILE" -) -publish_runtime || { echo "could not publish devcontainer terminal configuration" >&2; exit 1; } - _close_phase echo echo "=== ready ===" @@ -463,7 +414,7 @@ for e in "${PHASE_LOG[@]}"; do d="${e%%|*}"; l="${e#*|}"; [ "$d" -ge 2 ] && printf ' | %s %ss' "${l%% *}" "$d" done echo -echo " Use the extension action: Open Terminal in Dev Container." +echo " Open a shell inside it with the command shown below." echo " Files: ${PROJECT_DIR} <-> ${WORKSPACE_FOLDER}" [ "$KEEP_ID_OK" = "1" ] && echo " UID parity ON — files created inside are owned by you." \ || echo " UID parity OFF — files created inside are subuid-owned." From a36f3d74ed8aac8886f58f31f9aa699af2df6dc8 Mon Sep 17 00:00:00 2001 From: Rohan Kumar Date: Thu, 24 Sep 2026 15:34:44 +0000 Subject: [PATCH 10/12] refactor: drop the setup lock PID publication The PID written into the lock file was only read by the editor extension, which is not part of this iteration. flock still serializes concurrent runs; only the published PID and its teardown are removed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CidWf1g9fie6vS1rQDRcNF --- .../src/main/resources/start-devcontainer.sh | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/wsmaster/che-core-api-factory/src/main/resources/start-devcontainer.sh b/wsmaster/che-core-api-factory/src/main/resources/start-devcontainer.sh index 3b0ddbe5038..11a94d47f28 100644 --- a/wsmaster/che-core-api-factory/src/main/resources/start-devcontainer.sh +++ b/wsmaster/che-core-api-factory/src/main/resources/start-devcontainer.sh @@ -28,18 +28,15 @@ # set -uo pipefail -# Serialize setup and rebuild without interrupting an active build. Opening in append mode -# preserves the current owner's PID while another invocation waits. +# Serialize setup and rebuild so a second invocation waits rather than interrupting an +# active build. LOCK_FILE="${LOCK_FILE:-/tmp/.devcontainer-setup.lock}" -LOCK_HELD=0 if command -v flock >/dev/null 2>&1; then exec 9>>"$LOCK_FILE" || { echo "cannot open setup lock: $LOCK_FILE" >&2; exit 1; } if ! flock -n 9; then echo "another devcontainer setup is in progress; waiting..." flock -w 900 9 || { echo "timed out waiting for the in-progress setup" >&2; exit 1; } fi - LOCK_HELD=1 - printf '%s\n' "$$" > "$LOCK_FILE" fi # The parent owns the lock and stays alive for the whole run. Close the descriptor in the setup @@ -421,9 +418,3 @@ echo " Files: ${PROJECT_DIR} <-> ${WORKSPACE_FOLDER}" echo " Shell: ${PODMAN} exec -it ${CONTAINER_NAME} ${INNER_SHELL}" exit 0 ) -SETUP_RC=$? -# Clear the published PID while we still own the lock, before another run can acquire it. -if [ "$LOCK_HELD" = 1 ]; then - : > "$LOCK_FILE" -fi -exit "$SETUP_RC" From 82a9e45dec69de28a1d8d6751ab102068ba54fa3 Mon Sep 17 00:00:00 2001 From: Rohan Kumar Date: Thu, 24 Sep 2026 16:18:46 +0000 Subject: [PATCH 11/12] feat: register a devcontainer terminal profile Writes terminal.integrated.profiles.linux into che-code's machine settings so a shell can be opened inside the dev container from the terminal dropdown. Machine rather than workspace settings because the setting is restricted and would be discarded in an untrusted workspace. defaultProfile is left alone: the default terminal stays in UDI, where the cluster credentials are. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CidWf1g9fie6vS1rQDRcNF --- .../src/main/resources/start-devcontainer.sh | 41 ++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/wsmaster/che-core-api-factory/src/main/resources/start-devcontainer.sh b/wsmaster/che-core-api-factory/src/main/resources/start-devcontainer.sh index 11a94d47f28..d61ad801a2c 100644 --- a/wsmaster/che-core-api-factory/src/main/resources/start-devcontainer.sh +++ b/wsmaster/che-core-api-factory/src/main/resources/start-devcontainer.sh @@ -398,6 +398,45 @@ run_hook postAttachCommand INNER_SHELL=/bin/sh "$PODMAN" exec "$CONTAINER_NAME" sh -c 'command -v bash' >/dev/null 2>&1 && INNER_SHELL=bash +# --------------------------------------------------------------------------- +# 9. Terminal profile +# --------------------------------------------------------------------------- +# A "devcontainer" terminal profile, so a shell can be opened inside the container from the +# terminal dropdown rather than by retyping a podman exec line. +# +# terminal.integrated.profiles.* is declared `restricted: true` in VS Code, so a value coming +# from WORKSPACE settings is silently discarded in an untrusted workspace. It has to go to +# MACHINE settings - which is also the file che-code's launcher merges the +# vscode-editor-configurations ConfigMap into, and it keeps the project tree clean. +# +# defaultProfile is deliberately NOT set: the default terminal stays in UDI, where kubectl/oc +# and the cluster credentials are. Opening the dev container is an explicit choice from the +# dropdown. +merge_into() { # $1 = file, $2 = json object to merge in + local file="$1" obj="$2" cur='{}' tmp + [ -f "$file" ] && cur="$(sed -e 's@^[[:space:]]*//.*$@@' "$file" | jq -c '.' 2>/dev/null || echo '{}')" + cur="$(jq -n --argjson a "$cur" --argjson b "$obj" '$a * $b')" || return 1 + mkdir -p "$(dirname "$file")" || return 1 + tmp="$(mktemp "$(dirname "$file")/.settings.XXXXXX")" || return 1 + printf '%s\n' "$cur" | jq '.' > "$tmp" && mv -f -- "$tmp" "$file" || { rm -f -- "$tmp"; return 1; } +} + +profile_argv=(exec -it) +[ -n "$REMOTE_USER" ] && profile_argv+=(-u "$REMOTE_USER") +profile_argv+=("${REMOTE_ENV[@]}" -w "$WORKSPACE_FOLDER" "$CONTAINER_NAME" "$INNER_SHELL") +TERMINAL_PROFILE="$(jq -n \ + --argjson args "$(printf '%s\n' "${profile_argv[@]}" | jq -R . | jq -s -c .)" \ + --arg path "$PODMAN" '{ + "terminal.integrated.profiles.linux": { + "devcontainer": { "path": $path, "args": $args, "icon": "container" } } }')" + +MACHINE_SETTINGS="${CHE_MACHINE_SETTINGS:-/checode/remote/data/Machine/settings.json}" +if merge_into "$MACHINE_SETTINGS" "$TERMINAL_PROFILE"; then + echo " terminal profile 'devcontainer' -> ${MACHINE_SETTINGS}" +else + echo " NOTE: ${MACHINE_SETTINGS} not writable; use the podman exec line below instead." >&2 +fi + if [ "$LIFECYCLE_FAILURES" -gt 0 ]; then echo; echo " WARNING: ${LIFECYCLE_FAILURES} lifecycle command(s) failed." [ "$STRICT_LIFECYCLE" = "1" ] && exit 1 @@ -411,7 +450,7 @@ for e in "${PHASE_LOG[@]}"; do d="${e%%|*}"; l="${e#*|}"; [ "$d" -ge 2 ] && printf ' | %s %ss' "${l%% *}" "$d" done echo -echo " Open a shell inside it with the command shown below." +echo " Terminal: pick the 'devcontainer' profile, or use the command below." echo " Files: ${PROJECT_DIR} <-> ${WORKSPACE_FOLDER}" [ "$KEEP_ID_OK" = "1" ] && echo " UID parity ON — files created inside are owned by you." \ || echo " UID parity OFF — files created inside are subuid-owned." From 85809ecadce8a3596876f2b4f5205e01f123a67f Mon Sep 17 00:00:00 2001 From: Rohan Kumar Date: Thu, 24 Sep 2026 23:00:59 +0530 Subject: [PATCH 12/12] chore: add debug diagnostics for factory devfile resolution --- .../server/urlfactory/URLFactoryBuilder.java | 27 ++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/wsmaster/che-core-api-factory/src/main/java/org/eclipse/che/api/factory/server/urlfactory/URLFactoryBuilder.java b/wsmaster/che-core-api-factory/src/main/java/org/eclipse/che/api/factory/server/urlfactory/URLFactoryBuilder.java index 49009e6ab27..03d17f07cb1 100644 --- a/wsmaster/che-core-api-factory/src/main/java/org/eclipse/che/api/factory/server/urlfactory/URLFactoryBuilder.java +++ b/wsmaster/che-core-api-factory/src/main/java/org/eclipse/che/api/factory/server/urlfactory/URLFactoryBuilder.java @@ -125,6 +125,10 @@ public Optional createFactoryFromDevfile( boolean skipAuthentication) throws ApiException { String devfileYamlContent; + LOG.debug( + "Factory resolution started: provider={}, skipAuthentication={}", + remoteFactoryUrl.getClass().getSimpleName(), + skipAuthentication); // Apply the new devfile name to look for if (overrideProperties.containsKey(DEVFILE_FILENAME)) { @@ -133,14 +137,14 @@ public Optional createFactoryFromDevfile( for (DevfileLocation location : remoteFactoryUrl.devfileFileLocations()) { String devfileLocation = location.location(); + LOG.debug("Factory probing devfile: {}", devfileLocation); try { devfileYamlContent = fetchContent( remoteFactoryUrl, fileContentProvider, devfileLocation, skipAuthentication); } catch (IOException ex) { // try next location - LOG.debug( - "Unreachable devfile location met: {}. Error is: {}", devfileLocation, ex.getMessage()); + LOG.debug("Factory devfile fetch failed: {}", devfileLocation, ex); continue; } catch (DevfileException e) { LOG.debug("Unexpected devfile exception: {}", e.getMessage()); @@ -149,6 +153,9 @@ public Optional createFactoryFromDevfile( : new ApiException(e.getMessage()); } if (isNullOrEmpty(devfileYamlContent)) { + LOG.debug( + "Factory received empty devfile content at {}; returning default without devcontainer probing", + devfileLocation); return Optional.empty(); } try { @@ -160,6 +167,7 @@ public Optional createFactoryFromDevfile( } catch (DevfileException e) { throw new ApiException(getDevfileConnectionErrorMessage(devfileLocation)); } + LOG.debug("Factory selected repository devfile: {}", devfileLocation); return Optional.of(createFactory(parsedDevfile, location)); } catch (DevfileException e) { throw toApiException(e, location); @@ -172,33 +180,39 @@ public Optional createFactoryFromDevfile( for (String devcontainerPath : DEVCONTAINER_LOCATIONS) { String devcontainerLocation = remoteFactoryUrl.rawFileLocation(devcontainerPath); if (devcontainerLocation == null) { + LOG.debug("Factory cannot resolve raw devcontainer location: {}", devcontainerPath); continue; } String devcontainerContent; + LOG.debug("Factory probing devcontainer: {}", devcontainerLocation); try { devcontainerContent = fetchContent( remoteFactoryUrl, fileContentProvider, devcontainerLocation, skipAuthentication); } catch (IOException ex) { - LOG.debug("No devcontainer at: {}. Error: {}", devcontainerLocation, ex.getMessage()); + LOG.debug("Factory devcontainer fetch failed: {}", devcontainerLocation, ex); continue; } catch (DevfileException e) { - LOG.debug("Unexpected exception probing devcontainer: {}", e.getMessage()); + LOG.debug("Factory devcontainer probe failed: {}", devcontainerLocation, e); throw e.getCause() instanceof ScmUnauthorizedException ? toApiException(e) : new ApiException(e.getMessage()); } if (!looksLikeJson(devcontainerContent)) { - LOG.debug("Ignoring non-JSON content from {}", devcontainerLocation); + LOG.debug( + "Factory ignoring non-JSON devcontainer content from {}; characters={}", + devcontainerLocation, + devcontainerContent == null ? 0 : devcontainerContent.length()); continue; } - LOG.debug("Devcontainer detected at {}; generating devfile", devcontainerLocation); + LOG.debug("Factory detected devcontainer at {}; generating devfile", devcontainerLocation); try { JsonNode additions = devfileParser.parseYamlRaw(DEVCONTAINER_DEVFILE_TEMPLATE); Map devfileMap = new HashMap<>(DEFAULT_DEVFILE); devfileMap.putAll(devfileParser.convertYamlToMap(additions)); + LOG.debug("Factory selected generated devcontainer devfile: {}", devcontainerPath); return Optional.of( newDto(FactoryDevfileV2Dto.class) .withV(CURRENT_VERSION) @@ -210,6 +224,7 @@ public Optional createFactoryFromDevfile( } } + LOG.debug("Factory found no usable devfile or devcontainer; returning default devfile"); return Optional.empty(); }