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