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