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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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/
Expand All @@ -15,10 +15,17 @@
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 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;
import javax.inject.Inject;
Expand Down Expand Up @@ -50,6 +57,31 @@ 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_SCRIPT_B64_PLACEHOLDER = "__START_DEVCONTAINER_B64__";

private static final String DEVCONTAINER_DEVFILE_TEMPLATE;

static {
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 =
template.replace(DEVCONTAINER_SCRIPT_B64_PLACEHOLDER, encodedScript);
} catch (IOException e) {
throw new UncheckedIOException("Failed to load devcontainer devfile template", e);
}
}

private final String defaultCheEditor;
private final String defaultChePlugins;

Expand Down Expand Up @@ -93,6 +125,10 @@ public Optional<FactoryMetaDto> 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)) {
Expand All @@ -101,21 +137,14 @@ public Optional<FactoryMetaDto> createFactoryFromDevfile(

for (DevfileLocation location : remoteFactoryUrl.devfileFileLocations()) {
String devfileLocation = location.location();
LOG.debug("Factory probing devfile: {}", devfileLocation);
try {
Optional<String> 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(
"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());
Expand All @@ -124,6 +153,9 @@ public Optional<FactoryMetaDto> 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 {
Expand All @@ -135,14 +167,82 @@ public Optional<FactoryMetaDto> 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);
}
}

// 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) {
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("Factory devcontainer fetch failed: {}", devcontainerLocation, ex);
continue;
} catch (DevfileException e) {
LOG.debug("Factory devcontainer probe failed: {}", devcontainerLocation, e);
throw e.getCause() instanceof ScmUnauthorizedException
? toApiException(e)
: new ApiException(e.getMessage());
}

if (!looksLikeJson(devcontainerContent)) {
LOG.debug(
"Factory ignoring non-JSON devcontainer content from {}; characters={}",
devcontainerLocation,
devcontainerContent == null ? 0 : devcontainerContent.length());
continue;
}

LOG.debug("Factory detected devcontainer at {}; generating devfile", devcontainerLocation);
try {
JsonNode additions = devfileParser.parseYamlRaw(DEVCONTAINER_DEVFILE_TEMPLATE);
Map<String, Object> 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)
.withDevfile(devfileMap)
.withSource(devcontainerPath));
} catch (DevfileException e) {
LOG.error("Failed to parse devcontainer devfile template", e);
break;
}
}

LOG.debug("Factory found no usable devfile or devcontainer; returning default devfile");
return Optional.empty();
}

private static String fetchContent(
RemoteFactoryUrl remoteFactoryUrl,
FileContentProvider fileContentProvider,
String location,
boolean skipAuthentication)
throws IOException, DevfileException {
Optional<String> 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.
*
Expand All @@ -155,4 +255,61 @@ 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 comments. Does not parse the file.
*/
static boolean looksLikeJson(String content) {
if (isNullOrEmpty(content)) {
return false;
}
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 (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;
}
}
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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
#
# 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: |
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,
# so it never survived. tee keeps /tmp/devcontainer.log for anyone who wants to tail it.
# 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
label: Rebuild dev container
workingDir: ${PROJECTS_ROOT}
commandLine: |
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:
component: universal-developer-image
label: Rebuild dev container (no cache)
workingDir: ${PROJECTS_ROOT}
commandLine: |
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:
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
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
Loading
Loading