001/*
002 * (C) Copyright 2006-2016 Nuxeo SA (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 org.junit.Assert.assertEquals;
022import static org.junit.Assert.assertNotNull;
023import static org.junit.Assert.fail;
024
025import java.io.File;
026import java.io.IOException;
027import java.net.MalformedURLException;
028import java.net.URI;
029import java.net.URISyntaxException;
030import java.net.URL;
031import java.util.ArrayList;
032import java.util.Arrays;
033import java.util.Enumeration;
034import java.util.HashMap;
035import java.util.HashSet;
036import java.util.List;
037import java.util.Map;
038import java.util.Objects;
039import java.util.Properties;
040import java.util.Set;
041import java.util.jar.Attributes;
042import java.util.jar.Manifest;
043import java.util.stream.Collectors;
044import java.util.stream.Stream;
045
046import org.apache.commons.io.FileUtils;
047import org.apache.commons.logging.Log;
048import org.apache.commons.logging.LogFactory;
049import org.jmock.Mockery;
050import org.jmock.integration.junit4.JUnit4Mockery;
051import org.junit.After;
052import org.junit.Before;
053import org.junit.Ignore;
054import org.junit.runner.RunWith;
055import org.nuxeo.common.Environment;
056import org.nuxeo.osgi.BundleFile;
057import org.nuxeo.osgi.BundleImpl;
058import org.nuxeo.osgi.DirectoryBundleFile;
059import org.nuxeo.osgi.JarBundleFile;
060import org.nuxeo.osgi.OSGiAdapter;
061import org.nuxeo.osgi.SystemBundle;
062import org.nuxeo.osgi.SystemBundleFile;
063import org.nuxeo.osgi.application.StandaloneBundleLoader;
064import org.nuxeo.runtime.AbstractRuntimeService;
065import org.nuxeo.runtime.RuntimeServiceException;
066import org.nuxeo.runtime.api.Framework;
067import org.nuxeo.runtime.model.Extension;
068import org.nuxeo.runtime.model.RegistrationInfo;
069import org.nuxeo.runtime.model.RuntimeContext;
070import org.nuxeo.runtime.model.StreamRef;
071import org.nuxeo.runtime.model.URLStreamRef;
072import org.nuxeo.runtime.model.impl.DefaultRuntimeContext;
073import org.nuxeo.runtime.osgi.OSGiRuntimeContext;
074import org.nuxeo.runtime.osgi.OSGiRuntimeService;
075import org.nuxeo.runtime.test.protocols.inline.InlineURLFactory;
076import org.nuxeo.runtime.test.runner.ConditionalIgnoreRule;
077import org.nuxeo.runtime.test.runner.Features;
078import org.nuxeo.runtime.test.runner.FeaturesRunner;
079import org.nuxeo.runtime.test.runner.MDCFeature;
080import org.nuxeo.runtime.test.runner.RandomBug;
081import org.nuxeo.runtime.test.runner.RuntimeHarness;
082import org.nuxeo.runtime.test.runner.TargetExtensions;
083import org.nuxeo.runtime.transaction.TransactionHelper;
084import org.osgi.framework.Bundle;
085import org.osgi.framework.FrameworkEvent;
086
087import io.github.lukehutch.fastclasspathscanner.FastClasspathScanner;
088
089/**
090 * Abstract base class for test cases that require a test runtime service.
091 * <p>
092 * The runtime service itself is conveniently available as the <code>runtime</code> instance variable in derived
093 * classes.
094 *
095 * @author <a href="mailto:bs@nuxeo.com">Bogdan Stefanescu</a>
096 */
097// Make sure this class is kept in sync with with RuntimeHarness
098@RunWith(FeaturesRunner.class)
099@Features({ MDCFeature.class, ConditionalIgnoreRule.Feature.class, RandomBug.Feature.class })
100@Ignore
101public class NXRuntimeTestCase implements RuntimeHarness {
102
103    protected Mockery jmcontext = new JUnit4Mockery();
104
105    static {
106        // jul to jcl redirection may pose problems (infinite loops) in some
107        // environment
108        // where slf4j to jul, and jcl over slf4j is deployed
109        System.setProperty(AbstractRuntimeService.REDIRECT_JUL, "false");
110    }
111
112    private static final Log log = LogFactory.getLog(NXRuntimeTestCase.class);
113
114    protected OSGiRuntimeService runtime;
115
116    protected URL[] urls; // classpath urls, used for bundles lookup
117
118    protected File workingDir;
119
120    protected StandaloneBundleLoader bundleLoader;
121
122    private Set<URI> readUris;
123
124    protected Map<String, BundleFile> bundles;
125
126    protected boolean restart = false;
127
128    @Override
129    public boolean isRestart() {
130        return restart;
131    }
132
133    protected OSGiAdapter osgi;
134
135    protected Bundle runtimeBundle;
136
137    protected final List<WorkingDirectoryConfigurator> wdConfigs = new ArrayList<>();
138
139    protected final TargetResourceLocator targetResourceLocator;
140
141    public NXRuntimeTestCase() {
142        targetResourceLocator = new TargetResourceLocator(this.getClass());
143    }
144
145    public NXRuntimeTestCase(String name) {
146        this();
147    }
148
149    public NXRuntimeTestCase(Class<?> clazz) {
150        targetResourceLocator = new TargetResourceLocator(clazz);
151    }
152
153    @Override
154    public void addWorkingDirectoryConfigurator(WorkingDirectoryConfigurator config) {
155        wdConfigs.add(config);
156    }
157
158    @Override
159    public File getWorkingDir() {
160        return workingDir;
161    }
162
163    /**
164     * Restarts the runtime and preserve homes directory.
165     */
166    @Override
167    public void restart() throws Exception {
168        restart = true;
169        try {
170            tearDown();
171            setUp();
172        } finally {
173            restart = false;
174        }
175    }
176
177    @Override
178    public void start() throws Exception {
179        setUp();
180    }
181
182    @Before
183    public void setUp() throws Exception {
184        System.setProperty("org.nuxeo.runtime.testing", "true");
185        // super.setUp();
186        wipeRuntime();
187        initUrls();
188        if (urls == null) {
189            throw new UnsupportedOperationException("no bundles available");
190        }
191        initOsgiRuntime();
192    }
193
194    /**
195     * Fire the event {@code FrameworkEvent.STARTED}.
196     */
197    @Override
198    public void fireFrameworkStarted() throws Exception {
199        boolean txStarted = !TransactionHelper.isTransactionActiveOrMarkedRollback()
200                && TransactionHelper.startTransaction();
201        boolean txFinished = false;
202        try {
203            osgi.fireFrameworkEvent(new FrameworkEvent(FrameworkEvent.STARTED, runtimeBundle, null));
204            txFinished = true;
205        } finally {
206            if (!txFinished) {
207                TransactionHelper.setTransactionRollbackOnly();
208            }
209            if (txStarted) {
210                TransactionHelper.commitOrRollbackTransaction();
211            }
212        }
213    }
214
215    @After
216    public void tearDown() throws Exception {
217        wipeRuntime();
218        if (workingDir != null) {
219            if (!restart) {
220                if (workingDir.exists() && !FileUtils.deleteQuietly(workingDir)) {
221                    log.warn("Cannot delete " + workingDir);
222                }
223                workingDir = null;
224            }
225        }
226        readUris = null;
227        bundles = null;
228    }
229
230    @Override
231    public void stop() throws Exception {
232        tearDown();
233    }
234
235    @Override
236    public boolean isStarted() {
237        return runtime != null;
238    }
239
240    protected void initOsgiRuntime() throws Exception {
241        try {
242            if (!restart) {
243                Environment.setDefault(null);
244                if (System.getProperties().remove("nuxeo.home") != null) {
245                    log.warn("Removed System property nuxeo.home.");
246                }
247                workingDir = File.createTempFile("nxruntime-" + Thread.currentThread().getName() + "-", null,
248                        new File("target"));
249                workingDir.delete();
250            }
251        } catch (IOException e) {
252            log.error("Could not init working directory", e);
253            throw e;
254        }
255        osgi = new OSGiAdapter(workingDir);
256        BundleFile bf = new SystemBundleFile(workingDir);
257        bundleLoader = new StandaloneBundleLoader(osgi, NXRuntimeTestCase.class.getClassLoader());
258        SystemBundle systemBundle = new SystemBundle(osgi, bf, bundleLoader.getSharedClassLoader().getLoader());
259        osgi.setSystemBundle(systemBundle);
260        Thread.currentThread().setContextClassLoader(bundleLoader.getSharedClassLoader().getLoader());
261
262        for (WorkingDirectoryConfigurator cfg : wdConfigs) {
263            cfg.configure(this, workingDir);
264        }
265
266        bundleLoader.setScanForNestedJARs(false); // for now
267        bundleLoader.setExtractNestedJARs(false);
268
269        BundleFile bundleFile = lookupBundle("org.nuxeo.runtime");
270        runtimeBundle = new RootRuntimeBundle(osgi, bundleFile, bundleLoader.getClass().getClassLoader(), true);
271        runtimeBundle.start();
272
273        runtime = handleNewRuntime((OSGiRuntimeService) Framework.getRuntime());
274
275        assertNotNull(runtime);
276    }
277
278    protected OSGiRuntimeService handleNewRuntime(OSGiRuntimeService aRuntime) {
279        return aRuntime;
280    }
281
282    public static URL[] introspectClasspath(ClassLoader loader) {
283        return new FastClasspathScanner().getUniqueClasspathElements().stream().map(file -> {
284            try {
285                return file.toURI().toURL();
286            } catch (MalformedURLException cause) {
287                throw new Error("Could not get URL from " + file, cause);
288            }
289        }).toArray(URL[]::new);
290    }
291
292    protected void initUrls() throws Exception {
293        ClassLoader classLoader = NXRuntimeTestCase.class.getClassLoader();
294        urls = introspectClasspath(classLoader);
295        if (log.isDebugEnabled()) {
296            StringBuilder sb = new StringBuilder();
297            sb.append("URLs on the classpath: ");
298            for (URL url : urls) {
299                sb.append(url.toString());
300                sb.append('\n');
301            }
302            log.debug(sb.toString());
303        }
304        readUris = new HashSet<>();
305        bundles = new HashMap<>();
306    }
307
308    /**
309     * Makes sure there is no previous runtime hanging around.
310     * <p>
311     * This happens for instance if a previous test had errors in its <code>setUp()</code>, because
312     * <code>tearDown()</code> has not been called.
313     */
314    protected void wipeRuntime() throws Exception {
315        // Make sure there is no active runtime (this might happen if an
316        // exception is raised during a previous setUp -> tearDown is not called
317        // afterwards).
318        runtime = null;
319        if (Framework.getRuntime() != null) {
320            Framework.shutdown();
321        }
322    }
323
324    public static URL getResource(String name) {
325        final ClassLoader loader = Thread.currentThread().getContextClassLoader();
326        String callerName = Thread.currentThread().getStackTrace()[2].getClassName();
327        final String relativePath = callerName.replace('.', '/').concat(".class");
328        final String fullPath = loader.getResource(relativePath).getPath();
329        final String basePath = fullPath.substring(0, fullPath.indexOf(relativePath));
330        Enumeration<URL> resources;
331        try {
332            resources = loader.getResources(name);
333            while (resources.hasMoreElements()) {
334                URL resource = resources.nextElement();
335                if (resource.getPath().startsWith(basePath)) {
336                    return resource;
337                }
338            }
339        } catch (IOException e) {
340            return null;
341        }
342        return loader.getResource(name);
343    }
344
345    protected void deployContrib(URL url) {
346        assertEquals(runtime, Framework.getRuntime());
347        log.info("Deploying contribution from " + url.toString());
348        try {
349            runtime.getContext().deploy(url);
350        } catch (Exception e) {
351            fail("Failed to deploy contrib " + url.toString());
352        }
353    }
354
355    /**
356     * Deploys a contribution from a given bundle.
357     * <p>
358     * The path will be relative to the bundle root. Example: <code>
359     * deployContrib("org.nuxeo.ecm.core", "OSGI-INF/CoreExtensions.xml")
360     * </code>
361     * <p>
362     * For compatibility reasons the name of the bundle may be a jar name, but this use is discouraged and deprecated.
363     *
364     * @param name the name of the bundle to peek the contrib in
365     * @param contrib the path to contrib in the bundle.
366     */
367    @Override
368    public void deployContrib(String name, String contrib) throws Exception {
369        RuntimeContext context = runtime.getContext(name);
370        if (context == null) {
371            context = runtime.getContext();
372            BundleFile file = lookupBundle(name);
373            URL location = file.getEntry(contrib);
374            if (location == null) {
375                throw new AssertionError("Cannot locate " + contrib + " in " + name);
376            }
377            context.deploy(location);
378            return;
379        }
380        context.deploy(contrib);
381    }
382
383    /**
384     * Deploy an XML contribution from outside a bundle.
385     * <p>
386     * This should be used by tests wiling to deploy test contribution as part of a real bundle.
387     * <p>
388     * The bundle owner is important since the contribution may depend on resources deployed in that bundle.
389     * <p>
390     * Note that the owner bundle MUST be an already deployed bundle.
391     *
392     * @param bundle the bundle that becomes the contribution owner
393     * @param contrib the contribution to deploy as part of the given bundle
394     */
395    @Override
396    public RuntimeContext deployTestContrib(String bundle, String contrib) throws Exception {
397        URL url = targetResourceLocator.getTargetTestResource(contrib);
398        return deployTestContrib(bundle, url);
399    }
400
401    @Override
402    public RuntimeContext deployTestContrib(String bundle, URL contrib) throws Exception {
403        Bundle b = bundleLoader.getOSGi().getRegistry().getBundle(bundle);
404        if (b == null) {
405            b = osgi.getSystemBundle();
406        }
407        OSGiRuntimeContext ctx = new OSGiRuntimeContext(runtime, b);
408        ctx.deploy(contrib);
409        return ctx;
410    }
411
412    @Override
413    public RuntimeContext deployPartial(String name, Set<TargetExtensions> targetExtensions) throws Exception {
414        // Do not install bundle; we only need the Object to list his components
415        Bundle bundle = new BundleImpl(osgi, lookupBundle(name), null);
416
417        RuntimeContext ctx = new OSGiRuntimeContext(runtime, bundle);
418        listBundleComponents(bundle).map(URLStreamRef::new).forEach(component -> {
419            try {
420                this.deployPartialComponent(ctx, targetExtensions, component);
421            } catch (IOException e) {
422                log.error("PartialBundle: " + name + " failed to load: " + component, e);
423            }
424        });
425        return ctx;
426    }
427
428    /**
429     * Read a component from his StreamRef and create a new component (suffixed with `-partial`, and the base component
430     * name aliased) with only matching contributions of the extensionPoints parameter.
431     * 
432     * @param ctx RuntimeContext in which the new component will be deployed
433     * @param extensionPoints Set of white listed TargetExtensions
434     * @param component Reference to the original component
435     */
436    protected void deployPartialComponent(RuntimeContext ctx, Set<TargetExtensions> extensionPoints,
437            StreamRef component) throws IOException {
438        RegistrationInfo ri = ((DefaultRuntimeContext) ctx).createRegistrationInfo(component);
439        String name = ri.getName().getName() + "-partial";
440
441        // Flatten Target Extension Points
442        Set<String> targets = extensionPoints.stream()
443                                             .map(TargetExtensions::getTargetExtensions)
444                                             .flatMap(Set::stream)
445                                             .collect(Collectors.toSet());
446
447        String ext = Arrays.stream(ri.getExtensions())
448                           .filter(e -> targets.contains(TargetExtensions.newTargetExtension(
449                                   e.getTargetComponent().getName(), e.getExtensionPoint())))
450                           .map(Extension::toXML)
451                           .collect(Collectors.joining());
452
453        InlineURLFactory.install();
454        ctx.deploy(new InlineRef(name, String.format("<component name=\"%s\">%s</component>", name, ext)));
455    }
456
457    /**
458     * Listing component's urls of a bundle. Inspired from org.nuxeo.runtime.osgi.OSGiRuntimeService#loadComponents but
459     * without deploying anything.
460     * 
461     * @param bundle Bundle to be read
462     */
463    protected Stream<URL> listBundleComponents(Bundle bundle) {
464        String list = OSGiRuntimeService.getComponentsList(bundle);
465        String name = bundle.getSymbolicName();
466        log.debug("PartialBundle: " + name + " components: " + list);
467        if (list == null) {
468            return null;
469        }
470
471        return Arrays.stream(list.split("[, \t\n\r\f]")).map(s -> bundle.getEntry((String) s)).filter(Objects::nonNull);
472    }
473
474    /**
475     * Undeploys a contribution from a given bundle.
476     * <p>
477     * The path will be relative to the bundle root. Example: <code>
478     * undeployContrib("org.nuxeo.ecm.core", "OSGI-INF/CoreExtensions.xml")
479     * </code>
480     *
481     * @param name the bundle
482     * @param contrib the contribution
483     */
484    @Override
485    public void undeployContrib(String name, String contrib) throws Exception {
486        RuntimeContext context = runtime.getContext(name);
487        if (context == null) {
488            context = runtime.getContext();
489        }
490        context.undeploy(contrib);
491    }
492
493    protected static boolean isVersionSuffix(String s) {
494        if (s.length() == 0) {
495            return true;
496        }
497        return s.matches("-(\\d+\\.?)+(-SNAPSHOT)?(\\.\\w+)?");
498    }
499
500    /**
501     * Resolves an URL for bundle deployment code.
502     * <p>
503     * TODO: Implementation could be finer...
504     *
505     * @return the resolved url
506     */
507    protected URL lookupBundleUrl(String bundle) {
508        for (URL url : urls) {
509            String[] pathElts = url.getPath().split("/");
510            for (int i = 0; i < pathElts.length; i++) {
511                if (pathElts[i].startsWith(bundle) && isVersionSuffix(pathElts[i].substring(bundle.length()))) {
512                    // we want the main version of the bundle
513                    boolean isTestVersion = false;
514                    for (int j = i + 1; j < pathElts.length; j++) {
515                        // ok for Eclipse (/test) and Maven (/test-classes)
516                        if (pathElts[j].startsWith("test")) {
517                            isTestVersion = true;
518                            break;
519                        }
520                    }
521                    if (!isTestVersion) {
522                        log.info("Resolved " + bundle + " as " + url.toString());
523                        return url;
524                    }
525                }
526            }
527        }
528        throw new RuntimeException("Could not resolve bundle " + bundle);
529    }
530
531    /**
532     * Deploys a whole OSGI bundle.
533     * <p>
534     * The lookup is first done on symbolic name, as set in <code>MANIFEST.MF</code> and then falls back to the bundle
535     * url (e.g., <code>nuxeo-platform-search-api</code>) for backwards compatibility.
536     *
537     * @param name the symbolic name
538     */
539    @Override
540    public void deployBundle(String name) throws Exception {
541        // install only if not yet installed
542        Bundle bundle = bundleLoader.getOSGi().getRegistry().getBundle(name);
543        if (bundle == null) {
544            BundleFile bundleFile = lookupBundle(name);
545            bundleLoader.loadBundle(bundleFile);
546            bundleLoader.installBundle(bundleFile);
547            bundle = bundleLoader.getOSGi().getRegistry().getBundle(name);
548        }
549        if (runtime.getContext(bundle) == null) {
550            runtime.createContext(bundle);
551        }
552    }
553
554    protected String readSymbolicName(BundleFile bf) {
555        Manifest manifest = bf.getManifest();
556        if (manifest == null) {
557            return null;
558        }
559        Attributes attrs = manifest.getMainAttributes();
560        String name = attrs.getValue("Bundle-SymbolicName");
561        if (name == null) {
562            return null;
563        }
564        String[] sp = name.split(";", 2);
565        return sp[0];
566    }
567
568    public BundleFile lookupBundle(String bundleName) throws Exception {
569        BundleFile bundleFile = bundles.get(bundleName);
570        if (bundleFile != null) {
571            return bundleFile;
572        }
573        for (URL url : urls) {
574            URI uri = url.toURI();
575            if (readUris.contains(uri)) {
576                continue;
577            }
578            File file = new File(uri);
579            readUris.add(uri);
580            try {
581                if (file.isDirectory()) {
582                    bundleFile = new DirectoryBundleFile(file);
583                } else {
584                    bundleFile = new JarBundleFile(file);
585                }
586            } catch (IOException e) {
587                // no manifest => not a bundle
588                continue;
589            }
590            String symbolicName = readSymbolicName(bundleFile);
591            if (symbolicName != null) {
592                log.info(String.format("Bundle '%s' has URL %s", symbolicName, url));
593                bundles.put(symbolicName, bundleFile);
594            }
595            if (bundleName.equals(symbolicName)) {
596                return bundleFile;
597            }
598        }
599        throw new RuntimeServiceException(String.format("No bundle with symbolic name '%s';", bundleName));
600    }
601
602    @Override
603    public void deployFolder(File folder, ClassLoader loader) throws Exception {
604        DirectoryBundleFile bf = new DirectoryBundleFile(folder);
605        BundleImpl bundle = new BundleImpl(osgi, bf, loader);
606        osgi.install(bundle);
607    }
608
609    @Override
610    public Properties getProperties() {
611        return runtime.getProperties();
612    }
613
614    @Override
615    public RuntimeContext getContext() {
616        return runtime.getContext();
617    }
618
619    @Override
620    public OSGiAdapter getOSGiAdapter() {
621        return osgi;
622    }
623
624    /*
625     * (non-Javadoc)
626     * @see org.nuxeo.runtime.test.runner.RuntimeHarness#getClassLoaderFiles()
627     */
628    @Override
629    public List<String> getClassLoaderFiles() throws URISyntaxException {
630        List<String> files = new ArrayList<>(urls.length);
631        for (URL url : urls) {
632            files.add(url.toURI().getPath());
633        }
634        return files;
635    }
636
637}