001/*
002 * (C) Copyright 2006-2018 Nuxeo (http://nuxeo.com/) and others.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 *     http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 *
016 * Contributors:
017 *     Nuxeo - initial API and implementation
018 */
019package org.nuxeo.runtime.test;
020
021import static java.util.stream.Collectors.joining;
022
023import java.io.File;
024import java.io.IOException;
025import java.net.MalformedURLException;
026import java.net.URI;
027import java.net.URISyntaxException;
028import java.net.URL;
029import java.nio.file.Files;
030import java.util.ArrayList;
031import java.util.Arrays;
032import java.util.HashMap;
033import java.util.HashSet;
034import java.util.List;
035import java.util.Map;
036import java.util.Objects;
037import java.util.Properties;
038import java.util.Set;
039import java.util.jar.Attributes;
040import java.util.jar.Manifest;
041import java.util.stream.Collectors;
042import java.util.stream.Stream;
043
044import org.apache.commons.io.FileUtils;
045import org.apache.logging.log4j.LogManager;
046import org.apache.logging.log4j.Logger;
047import org.nuxeo.common.Environment;
048import org.nuxeo.osgi.BundleFile;
049import org.nuxeo.osgi.BundleImpl;
050import org.nuxeo.osgi.DirectoryBundleFile;
051import org.nuxeo.osgi.JarBundleFile;
052import org.nuxeo.osgi.OSGiAdapter;
053import org.nuxeo.osgi.SystemBundle;
054import org.nuxeo.osgi.SystemBundleFile;
055import org.nuxeo.osgi.application.StandaloneBundleLoader;
056import org.nuxeo.runtime.RuntimeServiceException;
057import org.nuxeo.runtime.api.Framework;
058import org.nuxeo.runtime.model.Extension;
059import org.nuxeo.runtime.model.RegistrationInfo;
060import org.nuxeo.runtime.model.RuntimeContext;
061import org.nuxeo.runtime.model.StreamRef;
062import org.nuxeo.runtime.model.URLStreamRef;
063import org.nuxeo.runtime.model.impl.DefaultRuntimeContext;
064import org.nuxeo.runtime.osgi.OSGiRuntimeContext;
065import org.nuxeo.runtime.osgi.OSGiRuntimeService;
066import org.nuxeo.runtime.test.runner.RuntimeHarness;
067import org.nuxeo.runtime.test.runner.TargetExtensions;
068import org.nuxeo.runtime.transaction.TransactionHelper;
069import org.osgi.framework.Bundle;
070import org.osgi.framework.FrameworkEvent;
071
072import io.github.lukehutch.fastclasspathscanner.FastClasspathScanner;
073
074/**
075 * Default RuntimeHarness implementation.
076 *
077 * @since 10.2
078 */
079public class RuntimeHarnessImpl implements RuntimeHarness {
080
081    protected static final Logger log = LogManager.getLogger(RuntimeHarnessImpl.class);
082
083    protected static URL[] introspectClasspath() {
084        return new FastClasspathScanner().getUniqueClasspathElements().stream().map(file -> {
085            try {
086                return file.toURI().toURL();
087            } catch (MalformedURLException cause) {
088                throw new RuntimeServiceException("Could not get URL from " + file, cause);
089            }
090        }).toArray(URL[]::new);
091    }
092
093    protected StandaloneBundleLoader bundleLoader;
094
095    protected Map<String, BundleFile> bundles;
096
097    protected boolean frameworkStarted;
098
099    protected OSGiAdapter osgi;
100
101    protected Set<URI> readUris;
102
103    protected OSGiRuntimeService runtime;
104
105    protected Bundle runtimeBundle;
106
107    protected TargetResourceLocator targetResourceLocator;
108
109    protected URL[] urls; // classpath urls, used for bundles lookup
110
111    protected List<WorkingDirectoryConfigurator> wdConfigs;
112
113    protected File workingDir;
114
115    protected RuntimeHarnessImpl() {
116        wdConfigs = new ArrayList<>();
117    }
118
119    public RuntimeHarnessImpl(Class<?> clazz) {
120        this();
121        targetResourceLocator = new TargetResourceLocator(clazz);
122    }
123
124    @Override
125    public void addWorkingDirectoryConfigurator(WorkingDirectoryConfigurator config) {
126        wdConfigs.add(config);
127    }
128
129    @Override
130    public void deployBundle(String name) throws Exception {
131        // install only if not yet installed
132        Bundle bundle = bundleLoader.getOSGi().getRegistry().getBundle(name);
133        if (bundle == null) {
134            BundleFile bundleFile = lookupBundle(name);
135            bundleLoader.loadBundle(bundleFile);
136            bundleLoader.installBundle(bundleFile);
137            bundle = bundleLoader.getOSGi().getRegistry().getBundle(name);
138        } else {
139            log.info("A bundle with name {} has been found. Deploy is ignored.", name);
140        }
141        if (runtime.getContext(bundle) == null) {
142            runtime.createContext(bundle);
143        }
144    }
145
146    @Override
147    public void deployContrib(String name, String contrib) throws Exception {
148        RuntimeContext context = runtime.getContext(name);
149        if (context == null) {
150            context = runtime.getContext();
151            BundleFile file = lookupBundle(name);
152            URL location = file.getEntry(contrib);
153            if (location == null) {
154                throw new AssertionError("Cannot locate " + contrib + " in " + name);
155            }
156            context.deploy(location);
157        } else {
158            context.deploy(contrib);
159        }
160    }
161
162    @Override
163    @Deprecated
164    public void deployFolder(File folder, ClassLoader loader) throws Exception {
165        DirectoryBundleFile bf = new DirectoryBundleFile(folder);
166        BundleImpl bundle = new BundleImpl(osgi, bf, loader);
167        osgi.install(bundle);
168    }
169
170    @Override
171    public RuntimeContext deployPartial(String name, Set<TargetExtensions> targetExtensions) throws Exception {
172        // Do not install bundle; we only need the Object to list his components
173        Bundle bundle = new BundleImpl(osgi, lookupBundle(name), getClass().getClassLoader());
174        RuntimeContext ctx = new OSGiRuntimeContext(runtime, bundle);
175        listBundleComponents(bundle).map(URLStreamRef::new).forEach(component -> {
176            try {
177                deployPartialComponent(ctx, targetExtensions, component);
178            } catch (IOException e) {
179                log.error("PartialBundle: {} failed to load: {}", name, component, e);
180            }
181        });
182        return ctx;
183    }
184
185    @Override
186    @Deprecated
187    public RuntimeContext deployTestContrib(String bundle, String contrib) throws Exception {
188        URL url = targetResourceLocator.getTargetTestResource(contrib);
189        return deployTestContrib(bundle, url);
190    }
191
192    @Override
193    @Deprecated
194    public RuntimeContext deployTestContrib(String bundle, URL contrib) throws Exception {
195        Bundle b = bundleLoader.getOSGi().getRegistry().getBundle(bundle);
196        if (b == null) {
197            b = osgi.getSystemBundle();
198        }
199        OSGiRuntimeContext ctx = new OSGiRuntimeContext(runtime, b);
200        ctx.deploy(contrib);
201        return ctx;
202    }
203
204    @Override
205    public void fireFrameworkStarted() {
206        if (frameworkStarted) {
207            throw new IllegalStateException("fireFrameworkStarted must not be called more than once");
208        }
209        frameworkStarted = true;
210        boolean txStarted = !TransactionHelper.isTransactionActiveOrMarkedRollback()
211                && TransactionHelper.startTransaction();
212        boolean txFinished = false;
213        try {
214            osgi.fireFrameworkEvent(new FrameworkEvent(FrameworkEvent.STARTED, runtimeBundle, null));
215            txFinished = true;
216        } finally {
217            if (!txFinished) {
218                TransactionHelper.setTransactionRollbackOnly();
219            }
220            if (txStarted) {
221                TransactionHelper.commitOrRollbackTransaction();
222            }
223        }
224    }
225
226    @Override
227    @Deprecated
228    public List<String> getClassLoaderFiles() throws URISyntaxException {
229        List<String> files = new ArrayList<>(urls.length);
230        for (URL url : urls) {
231            files.add(url.toURI().getPath());
232        }
233        return files;
234    }
235
236    @Override
237    public RuntimeContext getContext() {
238        return runtime.getContext();
239    }
240
241    @Override
242    public OSGiAdapter getOSGiAdapter() {
243        return osgi;
244    }
245
246    @Override
247    @Deprecated
248    public Properties getProperties() {
249        return runtime.getProperties();
250    }
251
252    @Override
253    public File getWorkingDir() {
254        return workingDir;
255    }
256
257    @Override
258    public boolean isRestart() {
259        return false;
260    }
261
262    @Override
263    public boolean isStarted() {
264        return runtime != null;
265    }
266
267    @Override
268    public void restart() throws Exception {
269        // do nothing
270    }
271
272    @Override
273    public void start() throws Exception {
274        System.setProperty("org.nuxeo.runtime.testing", "true");
275        wipeEmptyTestSystemProperties();
276        wipeRuntime();
277        initUrls();
278        if (urls == null) {
279            throw new UnsupportedOperationException("no bundles available");
280        }
281        initOsgiRuntime();
282    }
283
284    @Override
285    public void stop() throws Exception {
286        wipeRuntime();
287        if (workingDir != null) {
288            if (workingDir.exists() && !FileUtils.deleteQuietly(workingDir)) {
289                log.warn("Cannot delete {}", workingDir);
290            }
291            workingDir = null;
292        }
293        readUris = null;
294        bundles = null;
295    }
296
297    @Override
298    public void undeployContrib(String name, String contrib) {
299        RuntimeContext context = runtime.getContext(name);
300        if (context == null) {
301            context = runtime.getContext();
302        }
303        context.undeploy(contrib);
304    }
305
306    /**
307     * Read a component from his StreamRef and create a new component (suffixed with `-partial`, and the base component
308     * name aliased) with only matching contributions of the extensionPoints parameter.
309     *
310     * @param ctx RuntimeContext in which the new component will be deployed
311     * @param extensionPoints Set of white listed TargetExtensions
312     * @param component Reference to the original component
313     * @throws IOException Signals that an I/O exception has occurred.
314     */
315    protected void deployPartialComponent(RuntimeContext ctx, Set<TargetExtensions> extensionPoints,
316            StreamRef component) throws IOException {
317        RegistrationInfo ri = ((DefaultRuntimeContext) ctx).createRegistrationInfo(component);
318        String name = ri.getName().getName() + "-partial";
319
320        // Flatten Target Extension Points
321        Set<String> targets = extensionPoints.stream()
322                                             .map(TargetExtensions::getTargetExtensions)
323                                             .flatMap(Set::stream)
324                                             .collect(Collectors.toSet());
325
326        String ext = Arrays.stream(ri.getExtensions())
327                           .filter(e -> targets.contains(TargetExtensions.newTargetExtension(
328                                   e.getTargetComponent().getName(), e.getExtensionPoint())))
329                           .map(Extension::toXML)
330                           .collect(joining());
331
332        ctx.deploy(new InlineRef(name, String.format("<component name=\"%s\">%s</component>", name, ext)));
333    }
334
335    /**
336     * Inits the osgi runtime.
337     *
338     * @throws Exception the exception
339     */
340    protected void initOsgiRuntime() throws Exception {
341        try {
342            Environment.setDefault(null);
343            if (System.getProperties().remove("nuxeo.home") != null) {
344                log.warn("Removed System property nuxeo.home.");
345            }
346            workingDir = File.createTempFile("nxruntime-" + Thread.currentThread().getName() + "-", null,
347                    new File("target"));
348            Files.delete(workingDir.toPath());
349        } catch (IOException e) {
350            log.error("Could not init working directory", e);
351            throw e;
352        }
353        osgi = new OSGiAdapter(workingDir);
354        BundleFile bf = new SystemBundleFile(workingDir);
355        bundleLoader = new StandaloneBundleLoader(osgi, RuntimeHarnessImpl.class.getClassLoader());
356        SystemBundle systemBundle = new SystemBundle(osgi, bf, bundleLoader.getSharedClassLoader().getLoader());
357        osgi.setSystemBundle(systemBundle);
358        Thread.currentThread().setContextClassLoader(bundleLoader.getSharedClassLoader().getLoader());
359
360        for (WorkingDirectoryConfigurator cfg : wdConfigs) {
361            cfg.configure(this, workingDir);
362        }
363
364        bundleLoader.setScanForNestedJARs(false); // for now
365        bundleLoader.setExtractNestedJARs(false);
366
367        BundleFile bundleFile = lookupBundle("org.nuxeo.runtime");
368        runtimeBundle = new RootRuntimeBundle(osgi, bundleFile, bundleLoader.getClass().getClassLoader(), true);
369        runtimeBundle.start();
370
371        runtime = (OSGiRuntimeService) Framework.getRuntime();
372
373    }
374
375    /**
376     * Inits the urls.
377     */
378    protected void initUrls() {
379        urls = introspectClasspath();
380        log.debug("URLs on the classpath:\n{}", () -> Stream.of(urls).map(URL::toString).collect(joining("\n")));
381        readUris = new HashSet<>();
382        bundles = new HashMap<>();
383    }
384
385    /**
386     * Listing component's urls of a bundle. Inspired from org.nuxeo.runtime.osgi.OSGiRuntimeService#loadComponents but
387     * without deploying anything.
388     *
389     * @param bundle Bundle to be read
390     * @return the stream
391     */
392    protected Stream<URL> listBundleComponents(Bundle bundle) {
393        String list = OSGiRuntimeService.getComponentsList(bundle);
394        String name = bundle.getSymbolicName();
395        log.debug("PartialBundle: {} components: {}", name, list);
396        if (list == null) {
397            return Stream.empty();
398        } else {
399            return Arrays.stream(list.split("[, \t\n\r\f]")).map(bundle::getEntry).filter(Objects::nonNull);
400        }
401    }
402
403    /**
404     * Lookup bundle.
405     *
406     * @param bundleName the bundle name
407     * @return the bundle file
408     * @throws Exception the exception
409     */
410    protected BundleFile lookupBundle(String bundleName) throws Exception {
411        BundleFile bundleFile = bundles.get(bundleName);
412        if (bundleFile != null) {
413            return bundleFile;
414        }
415        for (URL url : urls) {
416            URI uri = url.toURI();
417            if (readUris.contains(uri)) {
418                continue;
419            }
420            File file = new File(uri);
421            readUris.add(uri);
422            try {
423                if (file.isDirectory()) {
424                    bundleFile = new DirectoryBundleFile(file);
425                } else {
426                    bundleFile = new JarBundleFile(file);
427                }
428            } catch (IOException e) {
429                // no manifest => not a bundle
430                continue;
431            }
432            String symbolicName = readSymbolicName(bundleFile);
433            if (symbolicName != null) {
434                log.debug("Bundle '{}' has URL {}", symbolicName, url);
435                bundles.put(symbolicName, bundleFile);
436            }
437            if (bundleName.equals(symbolicName)) {
438                return bundleFile;
439            }
440        }
441        throw new RuntimeServiceException(String.format("No bundle with symbolic name '%s';", bundleName));
442    }
443
444    /**
445     * Read symbolic name.
446     *
447     * @param bf the bf
448     * @return the string
449     */
450    protected String readSymbolicName(BundleFile bf) {
451        Manifest manifest = bf.getManifest();
452        if (manifest == null) {
453            return null;
454        }
455        Attributes attrs = manifest.getMainAttributes();
456        String name = attrs.getValue("Bundle-SymbolicName");
457        if (name == null) {
458            return null;
459        }
460        String[] sp = name.split(";", 2);
461        return sp[0];
462    }
463
464    /**
465     * Makes sure there is no previous runtime hanging around.
466     * <p>
467     * This happens for instance if a previous test had errors in its <code>setUp()</code>, because
468     * <code>tearDown()</code> has not been called.
469     */
470    protected void wipeRuntime() {
471        // Make sure there is no active runtime (this might happen if an
472        // exception is raised during a previous setUp -> tearDown is not called afterwards).
473        runtime = null;
474        frameworkStarted = false;
475        if (Framework.getRuntime() != null) {
476            try {
477                Framework.shutdown();
478            } catch (InterruptedException cause) {
479                Thread.currentThread().interrupt();
480                throw new RuntimeServiceException("Interrupted during shutdown", cause);
481            }
482        }
483    }
484
485    /**
486     * Removes Nuxeo test system properties that are empty.
487     * <p>
488     * This is needed when using maven surefire > 2.17 because since SUREFIRE-649 surefire propagates empty system
489     * properties.
490     */
491    protected void wipeEmptyTestSystemProperties() {
492        List<String> emptyProps = System.getProperties()
493                                        .entrySet()
494                                        .stream()
495                                        .filter(this::isAnEmptyTestProperty)
496                                        .map(entry -> entry.getKey().toString())
497                                        .collect(Collectors.toList());
498        emptyProps.forEach(System::clearProperty);
499        if (log.isDebugEnabled()) {
500            emptyProps.forEach(property -> log.debug("Removed empty test system property: {}", property));
501        }
502    }
503
504    protected boolean isAnEmptyTestProperty(Map.Entry<Object, Object> entry) {
505        if (!entry.getKey().toString().startsWith("nuxeo.test.")) {
506            return false;
507        }
508        return entry.getValue() == null || entry.getValue().toString().isEmpty();
509    }
510
511}