diff --git a/assembly/assembly-wsmaster-war/pom.xml b/assembly/assembly-wsmaster-war/pom.xml index d02883b0d..f6f3c23af 100644 --- a/assembly/assembly-wsmaster-war/pom.xml +++ b/assembly/assembly-wsmaster-war/pom.xml @@ -44,6 +44,10 @@ com.redhat.che che-plugin-analytics-wsmaster + + com.redhat.che + fabric8-cdn-support + com.redhat.che fabric8-end2end-flow diff --git a/assembly/assembly-wsmaster-war/src/main/webapp/WEB-INF/classes/che/rh-che.properties b/assembly/assembly-wsmaster-war/src/main/webapp/WEB-INF/classes/che/rh-che.properties index ca81aee0a..e119f7687 100644 --- a/assembly/assembly-wsmaster-war/src/main/webapp/WEB-INF/classes/che/rh-che.properties +++ b/assembly/assembly-wsmaster-war/src/main/webapp/WEB-INF/classes/che/rh-che.properties @@ -41,3 +41,11 @@ che.fabric8.end2end.protect.secret_key=NULL # End2End Flow bot protection: option to enable the use of the client IP # in the captcha server-side verification che.fabric8.end2end.protect.verify_with_ip=false + +# Full identifier of the default editor whose CDN resources +# should be prefetched during the dashboard client-side load +# - Can be the identifier of an editor plugin in the plugin registry, like that: +# org.eclipse.che.editor.theia:1.0.0 +# - or the full path to a custom editor plugin, like that: +# https://raw.githubusercontent.com/davidfestal/che-theia-cdn/master/plugins/org.eclipse.che.editor.theia.david:1.0.2 +che.fabric8.cdn.prefetch.editor=NULL diff --git a/assembly/fabric8-ide-dashboard-war/pom.xml b/assembly/fabric8-ide-dashboard-war/pom.xml index 1b670f964..88c8aa75a 100644 --- a/assembly/fabric8-ide-dashboard-war/pom.xml +++ b/assembly/fabric8-ide-dashboard-war/pom.xml @@ -212,7 +212,7 @@ - > + diff --git a/assembly/fabric8-ide-dashboard-war/src/components/ide-fetcher/che-ide-fetcher.service.ts b/assembly/fabric8-ide-dashboard-war/src/components/ide-fetcher/che-ide-fetcher.service.ts new file mode 100644 index 000000000..00d81694f --- /dev/null +++ b/assembly/fabric8-ide-dashboard-war/src/components/ide-fetcher/che-ide-fetcher.service.ts @@ -0,0 +1,155 @@ +/* + * Copyright (c) 2016-2018 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 + */ +'use strict'; +import {CheBranding} from '../branding/che-branding.factory'; + +const IDE_FETCHER_CALLBACK_ID = 'cheIdeFetcherCallback'; + +/** + * Provides a way to download IDE .js and then cache it before trying to load the IDE + * @author Florent Benoit + * @author Oleksii Orel + * @author David Festal + */ +export class CheIdeFetcher { + + static $inject = ['$log', '$http', '$window', 'cheBranding']; + + private $log: ng.ILogService; + private $http: ng.IHttpService; + private $window: ng.IWindowService; + private cheBranding: CheBranding; + private userAgent: string; + + /** + * Default constructor that is using resource injection + */ + constructor($log: ng.ILogService, $http: ng.IHttpService, $window: ng.IWindowService, cheBranding: CheBranding) { + this.$log = $log; + this.$http = $http; + this.$window = $window; + this.cheBranding = cheBranding; + + this.userAgent = this.getUserAgent(); + + const callback = () => { + this.findMappingFile(); + this.cheBranding.unregisterCallback(IDE_FETCHER_CALLBACK_ID); + }; + this.cheBranding.registerCallback(IDE_FETCHER_CALLBACK_ID, callback.bind(this)); + } + + /** + * Gets user agent. + * @returns {string} + */ + getUserAgent(): string { + const userAgent = this.$window.navigator.userAgent.toLowerCase(); + const docMode = (this.$window.document).documentMode; + + if (userAgent.indexOf('webkit') !== -1) { + return 'safari'; + } else if (userAgent.indexOf('msie') !== -1) { + if (docMode >= 10 && docMode < 11) { + return 'ie10'; + } else if (docMode >= 9 && docMode < 11) { + return 'ie9'; + } else if (docMode >= 8 && docMode < 11) { + return 'ie8'; + } + } else if (userAgent.indexOf('gecko') !== -1) { + return 'gecko1_8'; + } + + return 'unknown'; + } + + /** + * Finds mapping file. + */ + findMappingFile(): void { + // get the content of the compilation mapping file + const randVal = Math.floor((Math.random() * 1000000) + 1); + const resourcesPath = this.cheBranding.getIdeResourcesPath(); + if (!resourcesPath) { + this.$log.log('Unable to get IDE resources path'); + return; + } + + const fileMappingUrl = `${resourcesPath}compilation-mappings.txt?uid=${randVal}`; + + this.$http.get(fileMappingUrl).then((response: {data: any}) => { + if (!response || !response.data) { + return; + } + let urlToLoad = this.getIdeUrlMappingFile(response.data); + // load the url + if (angular.isDefined(urlToLoad)) { + this.$log.log('Preloading IDE javascript', urlToLoad); + this.$http.get(urlToLoad, { cache: true}); + } else { + this.$log.error('Unable to find the IDE javascript file to cache'); + } + }, (error: any) => { + this.$log.log('unable to find compilation mapping file', error); + }); + + this.$http.get('/api/cdn-support/paths', {cache: false}).then((response: {data: any}) => { + if (!response || !response.data) { + return; + } + const data = response.data; + data.forEach((entry) => { + let urlToLoad = entry.cdn; + // load the url + if (angular.isDefined(urlToLoad)) { + this.$log.log('Preloading Theia resource', urlToLoad); + const link = document.createElement("link"); + link.rel='prefetch'; + link.href=urlToLoad; + document.head.appendChild(link); + } else { + this.$log.error('Unable to find the Theia resource file to cache'); + } + }); + }, (error: any) => { + this.$log.log('Unable to find Theia CDN resources to cache', error); + }); + } + + /** + * Gets URL of mapping file. + * @param data {string} + * @returns {string} + */ + getIdeUrlMappingFile(data: string): string { + let mappingFileUrl: string; + let javascriptFileName: string; + const mappings = data.split(new RegExp('^\\n', 'gm')); + const isPasses = mappings.some((mapping: string) => { + const subMappings = mapping.split('\n'); + const userAgent = subMappings.find((subMapping: string) => { + return subMapping.startsWith('user.agent '); + }).split(' ')[1]; + javascriptFileName = subMappings.find((subMapping: string) => { + return subMapping.endsWith('.cache.js'); + }); + return javascriptFileName && userAgent && this.userAgent === userAgent; + }); + if (isPasses && javascriptFileName) { + mappingFileUrl = this.cheBranding.getIdeResourcesPath() + javascriptFileName; + } + + return mappingFileUrl; + } + +} diff --git a/dev-scripts/deploy_custom_rh-che.sh b/dev-scripts/deploy_custom_rh-che.sh index 2be554b6d..d57630288 100755 --- a/dev-scripts/deploy_custom_rh-che.sh +++ b/dev-scripts/deploy_custom_rh-che.sh @@ -306,7 +306,8 @@ CHE_CONFIG_YAML=$(yq ".\"data\".\"CHE_KEYCLOAK_REALM\" = \"NULL\" | .\"data\".\"che.jdbc.username\" = \"$RH_CHE_JDBC_USERNAME\" | .\"data\".\"che.jdbc.password\" = \"$RH_CHE_JDBC_PASSWORD\" | .\"data\".\"che.jdbc.url\" = \"$RH_CHE_JDBC_URL\" | - .\"data\".\"CHE_LOG_LEVEL\" = \"plaintext\" " ${RH_CHE_CONFIG}) + .\"data\".\"CHE_LOG_LEVEL\" = \"INFO\" | + .\"data\".\"CHE_LOGS_APPENDERS_IMPL\" = \"plaintext\" " ${RH_CHE_CONFIG}) CHE_CONFIG_YAML=$(echo "$CHE_CONFIG_YAML" | \ yq ".\"data\".\"CHE_HOST\" = \"rhche-$RH_CHE_PROJECT_NAMESPACE.devtools-dev.ext.devshift.net\" | diff --git a/dockerfiles/che-fabric8/Dockerfile b/dockerfiles/che-fabric8/Dockerfile index 644c972ca..2fab89014 100644 --- a/dockerfiles/che-fabric8/Dockerfile +++ b/dockerfiles/che-fabric8/Dockerfile @@ -23,6 +23,7 @@ RUN yum -y update && \ curl -sSL "https://${DOCKER_BUCKET}/builds/Linux/x86_64/docker-${DOCKER_VERSION}" -o /usr/bin/docker && \ chmod +x /usr/bin/docker && \ yum -y remove openssl && \ + yum -y install skopeo \ yum clean all && \ echo "%root ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers && \ sed -i 's/Defaults requiretty/#Defaults requiretty/g' /etc/sudoers && \ diff --git a/openshift/rh-che.config.yaml b/openshift/rh-che.config.yaml index 1176c9527..5459b6edc 100644 --- a/openshift/rh-che.config.yaml +++ b/openshift/rh-che.config.yaml @@ -70,3 +70,4 @@ data: CHE_INFRA_KUBERNETES_SERVICE__ACCOUNT__NAME: "che-workspace" CHE_WORKSPACE_STORAGE: "/home/user/che/workspaces" CHE_WORKSPACE_PLUGIN__BROKER_INIT_IMAGE: "eclipse/che-init-plugin-broker:v0.7.1" + CHE_FABRIC8_CDN_PREFETCH_EDITOR: 'NULL' diff --git a/plugins/fabric8-cdn-support/pom.xml b/plugins/fabric8-cdn-support/pom.xml new file mode 100644 index 000000000..cda0cc645 --- /dev/null +++ b/plugins/fabric8-cdn-support/pom.xml @@ -0,0 +1,114 @@ + + + + 4.0.0 + + fabric8-ide-plugins-parent + com.redhat.che + 1.0.0-SNAPSHOT + ../pom.xml + + fabric8-cdn-support + Fabric8 IDE :: Plugins :: CDN Support + + + com.fasterxml.jackson.core + jackson-core + + + com.fasterxml.jackson.core + jackson-databind + + + com.fasterxml.jackson.dataformat + jackson-dataformat-yaml + + + com.google.guava + guava + + + com.google.inject + guice + + + javax.inject + javax.inject + + + javax.ws.rs + javax.ws.rs-api + + + org.eclipse.che.core + che-core-api-core + + + org.eclipse.che.core + che-core-api-workspace + + + org.eclipse.che.core + che-core-api-workspace-shared + + + org.eclipse.che.core + che-core-commons-annotations + + + org.eclipse.che.core + che-core-commons-inject + + + org.slf4j + slf4j-api + + + javax.servlet + javax.servlet-api + provided + + + ch.qos.logback + logback-classic + test + + + org.eclipse.che.core + che-core-db + test + + + org.hamcrest + hamcrest-core + test + + + org.mockito + mockito-core + test + + + org.mockito + mockito-testng + test + + + org.testng + testng + test + + + diff --git a/plugins/fabric8-cdn-support/src/main/java/com/redhat/che/cdn/CdnSupportService.java b/plugins/fabric8-cdn-support/src/main/java/com/redhat/che/cdn/CdnSupportService.java new file mode 100644 index 000000000..0ec8c8dea --- /dev/null +++ b/plugins/fabric8-cdn-support/src/main/java/com/redhat/che/cdn/CdnSupportService.java @@ -0,0 +1,347 @@ +/* + * Copyright (c) 2016-2018 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 + */ +package com.redhat.che.cdn; + +import static java.lang.String.format; +import static java.util.Spliterators.spliteratorUnknownSize; +import static java.util.concurrent.CompletableFuture.allOf; +import static java.util.concurrent.CompletableFuture.runAsync; +import static java.util.stream.Collectors.toList; +import static java.util.stream.Stream.of; +import static java.util.stream.StreamSupport.stream; +import static javax.ws.rs.core.MediaType.APPLICATION_JSON; + +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Joiner; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.Lists; +import java.io.BufferedReader; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.net.URI; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.util.Collection; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; +import javax.inject.Inject; +import javax.inject.Named; +import javax.inject.Singleton; +import javax.ws.rs.GET; +import javax.ws.rs.Path; +import javax.ws.rs.Produces; +import org.eclipse.che.api.core.ApiException; +import org.eclipse.che.api.core.NotFoundException; +import org.eclipse.che.api.core.ServerException; +import org.eclipse.che.api.core.rest.Service; +import org.eclipse.che.api.core.util.LineConsumer; +import org.eclipse.che.api.core.util.ListLineConsumer; +import org.eclipse.che.api.core.util.ProcessUtil; +import org.eclipse.che.api.workspace.server.spi.InfrastructureException; +import org.eclipse.che.api.workspace.server.wsplugins.PluginMetaRetriever; +import org.eclipse.che.api.workspace.server.wsplugins.model.PluginMeta; +import org.eclipse.che.api.workspace.shared.Constants; +import org.eclipse.che.commons.annotation.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +@Singleton +@Path("cdn-support") +public class CdnSupportService extends Service { + private static final Logger LOG = LoggerFactory.getLogger(CdnSupportService.class); + private static final ObjectMapper YAML_PARSER = new ObjectMapper(new YAMLFactory()); + private static final ObjectMapper JSON_PARSER = new ObjectMapper(new JsonFactory()); + private static final String SKOPEO_BINARY = "skopeo"; + private static final String[] SKOPEO_HELP_ARGS = new String[] {"--help"}; + private static final long SKOPEO_HELP_TIMEOUT_SECONDS = 2; + private static final String SKOPEO_INSPECT_ARG = "inspect"; + private static final String SKOPEO_IMAGE_PREFIX = "docker://"; + private static final long SKOPEO_INSPECT_TIMEOUT_SECONDS = 10; + private static final String TAR_BINARY = "tar"; + private static final String TAR_ARG = "-xvf"; + private static final long TAR_TIMEOUT_SECONDS = 5; + private static final String CHE_PLUGIN_YAML_FILE_NAME = "che-plugin.yaml"; + private static final String CHE_PLUGIN_TGZ_FILE_NAME = "che-plugin.tar.gz"; + private static final String TEMP_DIR_PREFIX = "che-plugin-archive-dir"; + private static final String LABEL_NAME = "che-plugin.cdn.artifacts"; + + private static final CompletableFuture[] FUTURE_ARRAY = new CompletableFuture[0]; + private static final CommandRunner RUNNER = new CommandRunner(); + + private final String editorToPrefetch; + private final CommandRunner commandRunner; + private final PluginMetaRetriever pluginMetaRetriever; + @VisibleForTesting String editorDefinitionUrl = null; + @VisibleForTesting String dockerImage = null; + + @Inject + public CdnSupportService( + PluginMetaRetriever pluginMetaRetriever, + @Nullable @Named("che.fabric8.cdn.prefetch.editor") String editorToPrefetch) { + this(RUNNER, pluginMetaRetriever, editorToPrefetch); + } + + @VisibleForTesting + CdnSupportService( + CommandRunner commandRunner, + PluginMetaRetriever pluginMetaRetriever, + @Nullable @Named("che.fabric8.cdn.prefetch.editor") String editorToPrefetch) { + this.pluginMetaRetriever = pluginMetaRetriever; + this.editorToPrefetch = editorToPrefetch; + this.commandRunner = commandRunner; + + // Test that the skopeo process is available + + try { + commandRunner.runCommand( + SKOPEO_BINARY, + SKOPEO_HELP_ARGS, + null, + SKOPEO_HELP_TIMEOUT_SECONDS, + TimeUnit.SECONDS, + null, + null); + } catch (IOException e) { + throw new RuntimeException( + "The `skopeo` command is not available. Please check that is has been correctly installed in the Che server Docker image", + e); + } catch (ExecutionException | InterruptedException | TimeoutException e) { + LOG.warn("Exception during the `skopeo --help` command execution", e); + } + } + + @GET + @Path("paths") + @Produces(APPLICATION_JSON) + public String getPaths() throws Exception { + if (editorToPrefetch == null) { + throw new NotFoundException("No editor is configured for CDN resource pre-fetching"); + } + + String url = retrieveEditorPluginUrl(); + if (!url.equals(editorDefinitionUrl)) { + editorDefinitionUrl = url; + dockerImage = null; + } else { + LOG.debug("Editor full definition didn't change"); + } + + if (dockerImage == null) { + dockerImage = readDockerImageName(editorDefinitionUrl); + } + + if (dockerImage == null) { + throw new ServerException( + format( + "Plugin container image not found in the plugin descriptor of '%s'", + editorToPrefetch)); + } + + JsonNode json = inspectDockerImage(); + return json.path("Labels").path(LABEL_NAME).asText("[]"); + } + + private JsonNode inspectDockerImage() + throws IOException, TimeoutException, InterruptedException, ExecutionException, + ServerException { + LOG.debug("Running {} on image {}", SKOPEO_BINARY, dockerImage); + final ListLineConsumer out = commandRunner.newOutputConsumer(); + final ListLineConsumer err = commandRunner.newErrorConsumer(); + Process skopeoInspect = + commandRunner.runCommand( + SKOPEO_BINARY, + new String[] {SKOPEO_INSPECT_ARG, SKOPEO_IMAGE_PREFIX + dockerImage}, + null, + SKOPEO_INSPECT_TIMEOUT_SECONDS, + TimeUnit.SECONDS, + out, + err); + if (skopeoInspect.exitValue() != 0) { + String message = + format( + "%s failed when trying to retrieve the CDN label of docker image %s - exit code: %d - error output: %s", + SKOPEO_BINARY, dockerImage, skopeoInspect.exitValue(), err.getText()); + LOG.warn(message); + throw new ServerException(message); + } + String skopeoOutput = out.getText(); + LOG.debug("Result of running skopeo on image {}: {}", dockerImage, skopeoOutput); + + JsonNode json = JSON_PARSER.readTree(skopeoOutput); + return json; + } + + private String retrieveEditorPluginUrl() + throws InfrastructureException, NotFoundException, ServerException { + LOG.debug("Searching the editor plugin entry in the plugin registry"); + + Collection plugins = + pluginMetaRetriever.get( + ImmutableMap.of(Constants.WORKSPACE_TOOLING_EDITOR_ATTRIBUTE, editorToPrefetch)); + if (plugins.size() != 1) { + throw new NotFoundException("Editor '" + editorToPrefetch + "' is unknown"); + } + + PluginMeta editor = plugins.toArray(new PluginMeta[] {})[0]; + if (editor == null) { + throw new NotFoundException("Editor '" + editorToPrefetch + "' is unknown"); + } + + LOG.debug("Retrieving editor URL"); + + String url = editor.getUrl(); + if (url == null) { + throw new ServerException(format("URL of editor '%s' is null", editorToPrefetch)); + } + return url; + } + + private String readDockerImageName(String editorDefinitionUrl) + throws JsonProcessingException, IOException, TimeoutException, InterruptedException, + ExecutionException, ApiException { + LOG.debug("Creating temp folder"); + java.nio.file.Path archiveDir = + Files.createTempDirectory(FileSystems.getDefault().getPath("/tmp"), TEMP_DIR_PREFIX); + java.nio.file.Path archive = archiveDir.resolve(CHE_PLUGIN_TGZ_FILE_NAME); + + LOG.debug("Downloading Editor definition at URL {} into {}", editorDefinitionUrl, archive); + try (InputStream in = URI.create(editorDefinitionUrl).toURL().openStream()) { + Files.copy(in, archive); + } catch (IOException e) { + LOG.warn("Exception while downloading", e); + throw e; + } + + LOG.debug("Unzipping archive"); + final ListLineConsumer err = commandRunner.newErrorConsumer(); + Process tar = + commandRunner.runCommand( + TAR_BINARY, + new String[] {TAR_ARG, archive.toRealPath().toString()}, + archiveDir.toRealPath().toFile(), + TAR_TIMEOUT_SECONDS, + TimeUnit.SECONDS, + null, + err); + if (tar.exitValue() != 0) { + String message = + format( + "Tar command failed with error status: %d and error log: %s", + tar.exitValue(), err.getText()); + LOG.warn(message); + throw new ServerException(message); + } + java.nio.file.Path pluginYaml = archiveDir.resolve(CHE_PLUGIN_YAML_FILE_NAME); + + LOG.debug("Parse Plugin YAML file: {}", pluginYaml.toAbsolutePath().toString()); + JsonNode json = YAML_PARSER.readTree(pluginYaml.toFile()); + + LOG.debug("Plugin YAML file content: {}", json.toString()); + return stream(spliteratorUnknownSize(json.path("containers").elements(), 0), false) + .findFirst() + .map(node -> node.path("image").asText()) + .orElse(null); + } + + @VisibleForTesting + static class CommandRunner { + @VisibleForTesting + Process runCommand( + String command, + String[] arguments, + File directory, + long timeout, + TimeUnit timeUnit, + LineConsumer outputConsumer, + LineConsumer errorConsumer) + throws IOException, TimeoutException, InterruptedException, ExecutionException { + final String[] commandLine = new String[arguments.length + 1]; + Lists.asList(command, arguments).toArray(commandLine); + ProcessBuilder processBuilder = new ProcessBuilder(); + if (directory != null) { + processBuilder.directory(directory); + } + processBuilder.command(commandLine); + LOG.debug("Command: {}", processBuilder.command()); + LOG.debug("Directory: {}", processBuilder.directory()); + Process process = processBuilder.start(); + CompletableFuture readers = + allOf( + of(outputConsumer, errorConsumer) + .map( + (LineConsumer consumer) -> { + return runAsync( + () -> { + if (consumer != null) { + InputStream is = + consumer == outputConsumer + ? process.getInputStream() + : process.getErrorStream(); + // consume logs until process ends + try (BufferedReader inputReader = + new BufferedReader(new InputStreamReader(is))) { + String line; + while ((line = inputReader.readLine()) != null) { + consumer.writeLine(line); + } + } catch (IOException e) { + LOG.error( + format( + "Failed to complete reading of the process '%s' output or error due to occurred error", + Joiner.on(" ").join(commandLine)), + e); + } + } + }); + }) + .collect(toList()) + .toArray(FUTURE_ARRAY)); + try { + if (!process.waitFor(timeout, timeUnit)) { + try { + ProcessUtil.kill(process); + } catch (RuntimeException x) { + LOG.error( + "An error occurred while killing process '{}'", Joiner.on(" ").join(commandLine)); + } + throw new TimeoutException( + format( + "Process '%s' was terminated by timeout %s %s.", + Joiner.on(" ").join(commandLine), timeout, timeUnit.name().toLowerCase())); + } + } finally { + readers.get(2, TimeUnit.SECONDS); + } + + return process; + } + + @VisibleForTesting + ListLineConsumer newOutputConsumer() { + return new ListLineConsumer(); + } + + @VisibleForTesting + ListLineConsumer newErrorConsumer() { + return new ListLineConsumer(); + } + } +} diff --git a/plugins/fabric8-cdn-support/src/main/java/com/redhat/che/cdn/CdnSupportWsMasterModule.java b/plugins/fabric8-cdn-support/src/main/java/com/redhat/che/cdn/CdnSupportWsMasterModule.java new file mode 100644 index 000000000..e3031b84f --- /dev/null +++ b/plugins/fabric8-cdn-support/src/main/java/com/redhat/che/cdn/CdnSupportWsMasterModule.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2016-2018 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 + */ +package com.redhat.che.cdn; + +import com.google.inject.AbstractModule; +import org.eclipse.che.inject.DynaModule; + +/** + * Module that allows pushing workspace events to the Segment Analytics tracking tool + * + * @author David festal + */ +@DynaModule +public class CdnSupportWsMasterModule extends AbstractModule { + + @Override + protected void configure() { + bind(CdnSupportService.class); + } +} diff --git a/plugins/fabric8-cdn-support/src/test/java/com/redhat/che/cdn/CdnSupportServiceTest.java b/plugins/fabric8-cdn-support/src/test/java/com/redhat/che/cdn/CdnSupportServiceTest.java new file mode 100644 index 000000000..43dcc4eeb --- /dev/null +++ b/plugins/fabric8-cdn-support/src/test/java/com/redhat/che/cdn/CdnSupportServiceTest.java @@ -0,0 +1,282 @@ +/* + * Copyright (c) 2016-2018 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 + */ +package com.redhat.che.cdn; + +import static com.google.common.collect.ImmutableMap.of; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import static org.testng.Assert.*; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URL; +import java.net.URLConnection; +import java.net.URLStreamHandler; +import java.net.URLStreamHandlerFactory; +import java.util.Collections; +import java.util.concurrent.TimeUnit; +import org.eclipse.che.api.core.NotFoundException; +import org.eclipse.che.api.core.ServerException; +import org.eclipse.che.api.core.util.ListLineConsumer; +import org.eclipse.che.api.workspace.server.wsplugins.PluginMetaRetriever; +import org.eclipse.che.api.workspace.server.wsplugins.model.PluginMeta; +import org.eclipse.che.api.workspace.shared.Constants; +import org.mockito.Mock; +import org.mockito.testng.MockitoTestNGListener; +import org.testng.annotations.BeforeClass; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Listeners; +import org.testng.annotations.Test; +import org.testng.collections.Lists; + +@Listeners(MockitoTestNGListener.class) +public class CdnSupportServiceTest { + private static String EDITOR_REF = "editor"; + private static String EDITOR_URL = "http://editorURL"; + private static String IMAGE_REF = "imageRef"; + + @Mock private CdnSupportService.CommandRunner commandRunner; + @Mock private PluginMetaRetriever metaRetriever; + @Mock private URLConnection urlConnection; + @Mock private PluginMeta pluginMeta; + @Mock private Process tarProcess; + @Mock private Process skopeoHelpProcess; + @Mock private Process skopeoInspectProcess; + @Mock private ListLineConsumer skopeoOutputConsumer; + @Mock private ListLineConsumer skopeoErrorConsumer; + @Mock private ListLineConsumer tarErrorConsumer; + + private CdnSupportService service; + + @BeforeClass + public void registerURLHandler() { + URL.setURLStreamHandlerFactory( + new URLStreamHandlerFactory() { + + @Override + public URLStreamHandler createURLStreamHandler(String protocol) { + if ("http".equals(protocol)) { + return new URLStreamHandler() { + @Override + protected URLConnection openConnection(URL u) throws IOException { + return urlConnection; + } + }; + } else { + return null; + } + } + }); + } + + @BeforeMethod + public void setUp() throws Exception { + service = null; + } + + private void setupURLConnectionInputStream(String resourceName) throws IOException { + InputStream is = this.getClass().getResourceAsStream(resourceName); + doReturn(is).when(urlConnection).getInputStream(); + } + + @Test( + expectedExceptions = {RuntimeException.class}, + expectedExceptionsMessageRegExp = + "The `skopeo` command is not available. Please check that is has been correctly installed in the Che server Docker image") + public void throwAtStartIfNoSkopeoBinary() throws Exception { + doThrow(IOException.class) + .when(commandRunner) + .runCommand("skopeo", new String[] {"--help"}, null, 2, TimeUnit.SECONDS, null, null); + new CdnSupportService(commandRunner, metaRetriever, ""); + } + + @Test( + expectedExceptions = {NotFoundException.class}, + expectedExceptionsMessageRegExp = "No editor is configured for CDN resource pre-fetching") + public void throwWhenNoPreferredEditor() throws Exception { + service = new CdnSupportService(commandRunner, metaRetriever, null); + service.getPaths(); + } + + @Test( + expectedExceptions = {NotFoundException.class}, + expectedExceptionsMessageRegExp = "Editor 'unknownEditor' is unknown") + public void throwWhenEditorNotFound() throws Exception { + doReturn(Collections.emptyList()).when(metaRetriever).get(any()); + service = new CdnSupportService(commandRunner, metaRetriever, "unknownEditor"); + service.getPaths(); + } + + @Test( + expectedExceptions = {NotFoundException.class}, + expectedExceptionsMessageRegExp = "Editor 'unknownEditor' is unknown") + public void throwWhenEditorIsNull() throws Exception { + doReturn(Lists.newArrayList(new PluginMeta[] {null})) + .when(metaRetriever) + .get(any()); + service = new CdnSupportService(commandRunner, metaRetriever, "unknownEditor"); + service.getPaths(); + } + + @Test( + expectedExceptions = {ServerException.class}, + expectedExceptionsMessageRegExp = "URL of editor 'editor' is null") + public void throwWhenEditorPluginUrlIsNull() throws Exception { + doReturn(Lists.newArrayList(new PluginMeta[] {pluginMeta})) + .when(metaRetriever) + .get(any()); + doReturn(null).when(pluginMeta).getUrl(); + service = new CdnSupportService(commandRunner, metaRetriever, EDITOR_REF); + + try { + service.getPaths(); + } catch (Exception e) { + throw e; + } finally { + verify(metaRetriever).get(of(Constants.WORKSPACE_TOOLING_EDITOR_ATTRIBUTE, EDITOR_REF)); + } + } + + @Test( + expectedExceptions = {ServerException.class}, + expectedExceptionsMessageRegExp = + "skopeo failed when trying to retrieve the CDN label of docker image imageRef - exit code: 1 - error output: skopeo error output") + public void throwWhenSkopeoFailsWithNonZeroCode() throws Exception { + doReturn(Lists.newArrayList(new PluginMeta[] {pluginMeta})) + .when(metaRetriever) + .get(any()); + + doReturn(EDITOR_URL).when(pluginMeta).getUrl(); + + lenient() + .when(commandRunner.runCommand(eq("skopeo"), any(), any(), anyLong(), any(), any(), any())) + .thenReturn(skopeoHelpProcess, skopeoInspectProcess); + doReturn(0).when(skopeoHelpProcess).exitValue(); + doReturn(1).when(skopeoInspectProcess).exitValue(); + when(commandRunner.newOutputConsumer()).thenReturn(skopeoOutputConsumer); + when(commandRunner.newErrorConsumer()).thenReturn(skopeoErrorConsumer); + when(skopeoErrorConsumer.getText()).thenReturn("skopeo error output"); + + service = new CdnSupportService(commandRunner, metaRetriever, EDITOR_REF); + service.editorDefinitionUrl = EDITOR_URL; + service.dockerImage = IMAGE_REF; + + try { + service.getPaths(); + } catch (Exception e) { + throw e; + } finally { + verify(metaRetriever).get(of(Constants.WORKSPACE_TOOLING_EDITOR_ATTRIBUTE, EDITOR_REF)); + } + } + + @Test + public void reuseExistingImageRefAndReturnLabelWhenSkopeoSucceeds() throws Exception { + doReturn(Lists.newArrayList(new PluginMeta[] {pluginMeta})) + .when(metaRetriever) + .get(any()); + + doReturn(EDITOR_URL).when(pluginMeta).getUrl(); + + lenient() + .when(commandRunner.runCommand(eq("skopeo"), any(), any(), anyLong(), any(), any(), any())) + .thenReturn(skopeoHelpProcess, skopeoInspectProcess); + doReturn(0).when(skopeoInspectProcess).exitValue(); + when(commandRunner.newOutputConsumer()).thenReturn(skopeoOutputConsumer); + when(commandRunner.newErrorConsumer()).thenReturn(skopeoErrorConsumer); + when(skopeoOutputConsumer.getText()) + .thenReturn("{\"Labels\": { \"che-plugin.cdn.artifacts\": \"cdnJsonContent\" }}"); + + service = new CdnSupportService(commandRunner, metaRetriever, EDITOR_REF); + service.editorDefinitionUrl = EDITOR_URL; + service.dockerImage = IMAGE_REF; + + assertEquals(service.getPaths(), "cdnJsonContent"); + + verify(metaRetriever).get(of(Constants.WORKSPACE_TOOLING_EDITOR_ATTRIBUTE, EDITOR_REF)); + verify(commandRunner, never()) + .runCommand(eq("tar"), any(), any(), anyLong(), any(), any(), any()); + } + + @Test( + expectedExceptions = {ServerException.class}, + expectedExceptionsMessageRegExp = + "Tar command failed with error status: 1 and error log: tar error output content") + public void searchForImageRefAndThrowWhenTarFails() throws Exception { + doReturn(Lists.newArrayList(new PluginMeta[] {pluginMeta})) + .when(metaRetriever) + .get(any()); + + doReturn(EDITOR_URL).when(pluginMeta).getUrl(); + + lenient() + .when(commandRunner.runCommand(eq("skopeo"), any(), any(), anyLong(), any(), any(), any())) + .thenReturn(skopeoHelpProcess); + doReturn(0).when(skopeoHelpProcess).exitValue(); + lenient() + .when(commandRunner.runCommand(eq("tar"), any(), any(), anyLong(), any(), any(), any())) + .thenReturn(tarProcess); + doReturn(1).when(tarProcess).exitValue(); + when(commandRunner.newErrorConsumer()).thenReturn(tarErrorConsumer); + when(tarErrorConsumer.getText()).thenReturn("tar error output content"); + + setupURLConnectionInputStream("/che-editor-plugin.tar.gz"); + service = new CdnSupportService(commandRunner, metaRetriever, EDITOR_REF); + + try { + assertEquals(service.getPaths(), "{}"); + } catch (Exception e) { + throw e; + } finally { + verify(metaRetriever).get(of(Constants.WORKSPACE_TOOLING_EDITOR_ATTRIBUTE, EDITOR_REF)); + verify(commandRunner, times(1)) + .runCommand(eq("skopeo"), any(), any(), anyLong(), any(), any(), any()); + verify(commandRunner, times(1)) + .runCommand(eq("tar"), any(), any(), anyLong(), any(), any(), any()); + } + } + + @Test + public void searchForImageRefAndReturnLabelWhenSkopeoSucceeds() throws Exception { + doReturn(Lists.newArrayList(new PluginMeta[] {pluginMeta})) + .when(metaRetriever) + .get(any()); + + doReturn(EDITOR_URL).when(pluginMeta).getUrl(); + + lenient() + .when(commandRunner.runCommand(eq("skopeo"), any(), any(), anyLong(), any(), any(), any())) + .thenReturn(skopeoHelpProcess, skopeoInspectProcess); + doReturn(0).when(skopeoHelpProcess).exitValue(); + doReturn(0).when(skopeoInspectProcess).exitValue(); + lenient() + .when(commandRunner.runCommand(eq("tar"), any(), any(), anyLong(), any(), any(), any())) + .thenCallRealMethod(); + doReturn(0).when(tarProcess).exitValue(); + when(commandRunner.newOutputConsumer()).thenReturn(skopeoOutputConsumer); + when(commandRunner.newErrorConsumer()).thenReturn(tarErrorConsumer, skopeoErrorConsumer); + when(skopeoErrorConsumer.getText()).thenReturn("skopeo error output content"); + when(skopeoOutputConsumer.getText()) + .thenReturn("{\"Labels\": { \"che-plugin.cdn.artifacts\": \"cdnJsonContent\" }}"); + + setupURLConnectionInputStream("/che-editor-plugin.tar.gz"); + service = new CdnSupportService(commandRunner, metaRetriever, EDITOR_REF); + + assertEquals(service.getPaths(), "cdnJsonContent"); + + verify(metaRetriever).get(of(Constants.WORKSPACE_TOOLING_EDITOR_ATTRIBUTE, EDITOR_REF)); + verify(commandRunner, times(2)) + .runCommand(eq("skopeo"), any(), any(), anyLong(), any(), any(), any()); + verify(commandRunner, times(1)) + .runCommand(eq("tar"), any(), any(), anyLong(), any(), any(), any()); + } +} diff --git a/plugins/fabric8-cdn-support/src/test/resources/che-editor-plugin.tar.gz b/plugins/fabric8-cdn-support/src/test/resources/che-editor-plugin.tar.gz new file mode 100644 index 000000000..9aba015c0 Binary files /dev/null and b/plugins/fabric8-cdn-support/src/test/resources/che-editor-plugin.tar.gz differ diff --git a/plugins/pom.xml b/plugins/pom.xml index 5c0a07fce..d50be9e7e 100644 --- a/plugins/pom.xml +++ b/plugins/pom.xml @@ -30,5 +30,6 @@ fabric8-multi-tenant-manager che-plugin-analytics fabric8-end2end-flow + fabric8-cdn-support diff --git a/pom.xml b/pom.xml index 158b1cd97..3b8b7c127 100644 --- a/pom.xml +++ b/pom.xml @@ -59,6 +59,12 @@ ${rh.che.plugins.version} jar + + com.redhat.che + fabric8-cdn-support + ${rh.che.plugins.version} + jar + com.redhat.che fabric8-end2end-flow