001/*
002 * (C) Copyright 2006-2020 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 *     Bogdan Stefanescu
018 *     Florent Guillaume
019 *     Julien Carsique
020 *     Anahide Tchertchian
021 */
022package org.nuxeo.runtime.osgi;
023
024import static java.nio.charset.StandardCharsets.UTF_8;
025
026import java.io.BufferedInputStream;
027import java.io.File;
028import java.io.FileInputStream;
029import java.io.IOException;
030import java.io.InputStream;
031import java.net.MalformedURLException;
032import java.net.URL;
033import java.util.ArrayList;
034import java.util.Arrays;
035import java.util.Comparator;
036import java.util.Iterator;
037import java.util.List;
038import java.util.Map;
039import java.util.Set;
040import java.util.StringTokenizer;
041import java.util.concurrent.ConcurrentHashMap;
042import java.util.stream.Collectors;
043import java.util.stream.Stream;
044
045import org.apache.commons.io.FileUtils;
046import org.apache.logging.log4j.LogManager;
047import org.apache.logging.log4j.Logger;
048import org.nuxeo.common.Environment;
049import org.nuxeo.common.codec.CryptoProperties;
050import org.nuxeo.common.utils.TextTemplate;
051import org.nuxeo.runtime.AbstractRuntimeService;
052import org.nuxeo.runtime.RuntimeMessage;
053import org.nuxeo.runtime.RuntimeMessage.Level;
054import org.nuxeo.runtime.RuntimeMessage.Source;
055import org.nuxeo.runtime.Version;
056import org.nuxeo.runtime.api.Framework;
057import org.nuxeo.runtime.model.ComponentName;
058import org.nuxeo.runtime.model.RuntimeContext;
059import org.nuxeo.runtime.model.impl.ComponentPersistence;
060import org.nuxeo.runtime.model.impl.RegistrationInfoImpl;
061import org.osgi.framework.Bundle;
062import org.osgi.framework.BundleContext;
063import org.osgi.framework.Constants;
064import org.osgi.framework.FrameworkEvent;
065import org.osgi.framework.FrameworkListener;
066
067/**
068 * The default implementation of NXRuntime over an OSGi compatible environment.
069 *
070 * @author Bogdan Stefanescu
071 * @author Florent Guillaume
072 */
073public class OSGiRuntimeService extends AbstractRuntimeService implements FrameworkListener {
074
075    public static final ComponentName FRAMEWORK_STARTED_COMP = new ComponentName("org.nuxeo.runtime.started");
076
077    /** Can be used to change the runtime home directory */
078    public static final String PROP_HOME_DIR = "org.nuxeo.runtime.home";
079
080    /** The OSGi application install directory. */
081    public static final String PROP_INSTALL_DIR = "INSTALL_DIR";
082
083    /** The OSGi application config directory. */
084    public static final String PROP_CONFIG_DIR = "CONFIG_DIR";
085
086    /** The host adapter. */
087    public static final String PROP_HOST_ADAPTER = "HOST_ADAPTER";
088
089    public static final String PROP_NUXEO_BIND_ADDRESS = "nuxeo.bind.address";
090
091    public static final String NAME = "OSGi NXRuntime";
092
093    public static final Version VERSION = Version.parseString("1.4.0");
094
095    private static final Logger log = LogManager.getLogger(OSGiRuntimeService.class);
096
097    private final BundleContext bundleContext;
098
099    private final Map<String, RuntimeContext> contexts;
100
101    private boolean appStarted = false;
102
103    /**
104     * OSGi doesn't provide a method to lookup bundles by symbolic name. This table is used to map symbolic names to
105     * bundles. This map is not handling bundle versions.
106     */
107    final Map<String, Bundle> bundles;
108
109    final ComponentPersistence persistence;
110
111    public OSGiRuntimeService(BundleContext context) {
112        this(new OSGiRuntimeContext(context.getBundle()), context);
113    }
114
115    public OSGiRuntimeService(OSGiRuntimeContext runtimeContext, BundleContext context) {
116        super(runtimeContext);
117        bundleContext = context;
118        bundles = new ConcurrentHashMap<>();
119        contexts = new ConcurrentHashMap<>();
120        String bindAddress = context.getProperty(PROP_NUXEO_BIND_ADDRESS);
121        if (bindAddress != null) {
122            properties.put(PROP_NUXEO_BIND_ADDRESS, bindAddress);
123        }
124        String homeDir = getProperty(PROP_HOME_DIR);
125        log.debug("Home directory: {}", homeDir);
126        if (homeDir != null) {
127            workingDir = new File(homeDir);
128        } else {
129            workingDir = bundleContext.getDataFile("/");
130        }
131        // environment may not be set by some bootstrappers (like tests) - we create it now if not yet created
132        Environment env = Environment.getDefault();
133        if (env == null) {
134            env = new Environment(workingDir);
135            Environment.setDefault(env);
136            env.setServerHome(workingDir);
137            env.init();
138        }
139        workingDir.mkdirs();
140        persistence = new ComponentPersistence(this);
141        log.debug("Working directory: {}", workingDir);
142    }
143
144    @Override
145    public String getName() {
146        return NAME;
147    }
148
149    @Override
150    public Version getVersion() {
151        return VERSION;
152    }
153
154    public BundleContext getBundleContext() {
155        return bundleContext;
156    }
157
158    @Override
159    public Bundle getBundle(String symbolicName) {
160        return bundles.get(symbolicName);
161    }
162
163    public Map<String, Bundle> getBundlesMap() {
164        return bundles;
165    }
166
167    public ComponentPersistence getComponentPersistence() {
168        return persistence;
169    }
170
171    public synchronized RuntimeContext createContext(Bundle bundle) {
172        RuntimeContext ctx = contexts.get(bundle.getSymbolicName());
173        if (ctx == null) {
174            // workaround to handle fragment bundles
175            ctx = new OSGiRuntimeContext(bundle);
176            contexts.put(bundle.getSymbolicName(), ctx);
177            loadComponents(bundle, ctx, true);
178        }
179        return ctx;
180    }
181
182    public synchronized void destroyContext(Bundle bundle) {
183        RuntimeContext ctx = contexts.remove(bundle.getSymbolicName());
184        if (ctx != null) {
185            ctx.destroy();
186        }
187    }
188
189    public synchronized RuntimeContext getContext(Bundle bundle) {
190        return contexts.get(bundle.getSymbolicName());
191    }
192
193    public synchronized RuntimeContext getContext(String symbolicName) {
194        return contexts.get(symbolicName);
195    }
196
197    @Override
198    protected void doStart() {
199        bundleContext.addFrameworkListener(this);
200        loadComponents(bundleContext.getBundle(), context, false);
201    }
202
203    @Override
204    protected void doStop() {
205        // do not destroy context since component manager is already shutdown
206        bundleContext.removeFrameworkListener(this);
207        super.doStop();
208    }
209
210    protected void loadComponents(Bundle bundle, RuntimeContext ctx, boolean isFragment) {
211        String name = bundle.getSymbolicName();
212        if (isFragment && name.equals(context.getBundle().getSymbolicName())) {
213            // avoid deploying again runtime components as a fragment (already handled in #doStart)
214            return;
215        }
216        String list = getComponentsList(bundle);
217        if (list == null) {
218            log.debug("Bundle {} doesn't have components", name);
219            return;
220        }
221        log.trace("Load Bundle: {} / Components: {}", name, list);
222        StringTokenizer tok = new StringTokenizer(list, ", \t\n\r\f");
223        while (tok.hasMoreTokens()) {
224            String path = tok.nextToken();
225            URL url = bundle.getEntry(path);
226            if (url != null) {
227                log.trace("Load component {} [{}]", name, url);
228                try {
229                    ctx.deploy(url);
230                } catch (IOException e) {
231                    log.error(e, e);
232                    messageHandler.addMessage(new RuntimeMessage(Level.ERROR, e.getMessage(), Source.BUNDLE, name));
233                }
234            } else {
235                String message = "Unknown component '" + path + "' referenced by bundle '" + name + "'";
236                log.error(message + ". Check the MANIFEST.MF");
237                messageHandler.addMessage(new RuntimeMessage(Level.ERROR, message, Source.BUNDLE, name));
238            }
239        }
240    }
241
242    public static String getComponentsList(Bundle bundle) {
243        return (String) bundle.getHeaders().get("Nuxeo-Component");
244    }
245
246    protected boolean loadConfigurationFromProvider() throws IOException {
247        // TODO use a OSGi service for this.
248        Iterable<URL> provider = Environment.getDefault().getConfigurationProvider();
249        if (provider == null) {
250            return false;
251        }
252        Iterator<URL> it = provider.iterator();
253        List<URL> props = new ArrayList<>();
254        List<URL> xmls = new ArrayList<>();
255        while (it.hasNext()) {
256            URL url = it.next();
257            String path = url.getPath();
258            if (path.endsWith("-config.xml")) {
259                xmls.add(url);
260            } else if (path.endsWith(".properties")) {
261                props.add(url);
262            }
263        }
264        xmls.sort(Comparator.comparing(URL::getPath));
265        for (URL url : props) {
266            loadProperties(url);
267        }
268        for (URL url : xmls) {
269            context.deploy(url);
270        }
271        return true;
272    }
273
274    @Override
275    protected void loadConfig() throws IOException {
276        Environment env = Environment.getDefault();
277        if (env != null) {
278            log.debug("Configuration: host application: {}", env.getHostApplicationName());
279        } else {
280            log.warn("Configuration: no host application");
281            return;
282        }
283
284        File blacklistFile = new File(env.getConfig(), "blacklist");
285        if (blacklistFile.isFile()) {
286            Set<String> lines = FileUtils.readLines(blacklistFile, UTF_8)
287                                         .stream()
288                                         .map(String::trim)
289                                         .filter(line -> !line.isEmpty())
290                                         .collect(Collectors.toSet());
291            manager.setBlacklist(lines);
292        }
293
294        if (loadConfigurationFromProvider()) {
295            return;
296        }
297
298        String configDir = bundleContext.getProperty(PROP_CONFIG_DIR);
299        if (configDir != null && configDir.contains(":/")) { // an url of a config file
300            URL url = new URL(configDir);
301            log.debug("Configuration: loading properties from: {}", configDir);
302            loadProperties(url);
303            return;
304        }
305
306        // TODO: in JBoss there is a deployer that will deploy nuxeo
307        // configuration files ..
308        boolean isNotJBoss4 = !isJBoss4(env);
309
310        File dir = env.getConfig();
311        String[] names = dir.list();
312        if (names != null) {
313            Arrays.sort(names, String::compareToIgnoreCase);
314            log.debug("Deployment order of configuration files: {}",
315                    () -> Stream.of(names).reduce((n1, n2) -> n1 + "\n\t" + n2).map(n -> "\n\t" + n).orElse(""));
316            for (String name : names) {
317                if (name.endsWith("-config.xml") || name.endsWith("-bundle.xml")) {
318                    // TODO because of some dep bugs (regarding the deployment of demo-ds.xml), we cannot let the
319                    // runtime deploy config dir at beginning...
320                    // until fixing this we deploy config dir from NuxeoDeployer
321                    if (isNotJBoss4) {
322                        File file = new File(dir, name);
323                        log.trace("Configuration: deploy config component: {}", name);
324                        try {
325                            context.deploy(file.toURI().toURL());
326                        } catch (IOException e) {
327                            String message = String.format("Error deploying config %s (%s)", name, e.getMessage());
328                            log.error(message, e);
329                            messageHandler.addMessage(new RuntimeMessage(Level.ERROR, message, Source.CONFIG, name));
330                        }
331                    }
332                } else if (name.endsWith(".config") || name.endsWith(".ini") || name.endsWith(".properties")) {
333                    File file = new File(dir, name);
334                    log.trace("Configuration: loading properties: {}", name);
335                    loadProperties(file);
336                } else {
337                    log.trace("Configuration: ignoring: {}", name);
338                }
339            }
340        } else if (dir.isFile()) { // a file - load it
341            log.debug("Configuration: loading properties: {}", dir);
342            loadProperties(dir);
343        } else {
344            log.debug("Configuration: no configuration file found");
345        }
346
347        loadDefaultConfig();
348    }
349
350    @Override
351    public void reloadProperties() throws IOException {
352        File dir = Environment.getDefault().getConfig();
353        String[] names = dir.list();
354        if (names != null) {
355            Arrays.sort(names, String::compareToIgnoreCase);
356            CryptoProperties props = new CryptoProperties(System.getProperties());
357            for (String name : names) {
358                if (name.endsWith(".config") || name.endsWith(".ini") || name.endsWith(".properties")) {
359                    try (FileInputStream in = new FileInputStream(new File(dir, name))) {
360                        props.load(in);
361                    }
362                }
363            }
364            // replace the current runtime properties
365            properties = props;
366        }
367    }
368
369    /**
370     * Loads default properties.
371     * <p>
372     * Used for backward compatibility when adding new mandatory properties
373     * </p>
374     */
375    protected void loadDefaultConfig() {
376        String varName = "org.nuxeo.ecm.contextPath";
377        if (Framework.getProperty(varName) == null) {
378            properties.setProperty(varName, "/nuxeo");
379        }
380    }
381
382    public void loadProperties(File file) throws IOException {
383        try (InputStream in = new BufferedInputStream(new FileInputStream(file))) {
384            loadProperties(in);
385        }
386    }
387
388    public void loadProperties(URL url) throws IOException {
389        try (InputStream in = url.openStream()) {
390            loadProperties(in);
391        }
392    }
393
394    public void loadProperties(InputStream in) throws IOException {
395        properties.load(in);
396    }
397
398    /**
399     * Overrides the default method to be able to include OSGi properties.
400     */
401    @Override
402    public String getProperty(String name, String defValue) {
403        String value = properties.getProperty(name);
404        if (value == null) {
405            value = bundleContext.getProperty(name);
406            if (value == null) {
407                return defValue == null ? null : expandVars(defValue);
408            }
409        }
410        if (("${" + name + "}").equals(value)) {
411            // avoid loop, don't expand
412            return value;
413        }
414        return expandVars(value);
415    }
416
417    /**
418     * Overrides the default method to be able to include OSGi properties.
419     */
420    @Override
421    public String expandVars(String expression) {
422        return new TextTemplate(getProperties()) {
423            @Override
424            public String getVariable(String name) {
425                String value = super.getVariable(name);
426                if (value == null) {
427                    value = bundleContext.getProperty(name);
428                }
429                return value;
430            }
431
432        }.processText(expression);
433    }
434
435    protected void startComponents() {
436        synchronized (this) {
437            if (appStarted) {
438                return;
439            }
440            appStarted = true;
441        }
442        try {
443            persistence.loadPersistedComponents();
444        } catch (RuntimeException | IOException e) {
445            log.error("Failed to load persisted components", e);
446        }
447        // deploy a fake component that is marking the end of startup
448        // XML components that needs to be deployed at the end need to put a
449        // requirement
450        // on this marker component
451        deployFrameworkStartedComponent();
452        // ============ activate and start components =======
453        manager.start();
454        // create a snapshot of the started components - TODO should this be optional?
455        manager.snapshot();
456        // ==================================================
457        // print the startup message
458        printStatusMessage();
459    }
460
461    /* --------------- FrameworkListener API ------------------ */
462
463    @Override
464    public void frameworkEvent(FrameworkEvent event) {
465        if (event.getType() != FrameworkEvent.STARTED) {
466            return;
467        }
468        startComponents();
469    }
470
471    private void printStatusMessage() {
472        StringBuilder msg = new StringBuilder();
473        msg.append("Nuxeo Platform Started\n");
474        if (getStatusMessage(msg)) {
475            log.info(msg);
476        } else {
477            log.error(msg);
478            if (Boolean.getBoolean("nuxeo.start.strict")) {
479                throw new IllegalStateException("Startup aborted due to previous failures (strict mode)");
480            }
481        }
482    }
483
484    protected void deployFrameworkStartedComponent() {
485        RegistrationInfoImpl ri = new RegistrationInfoImpl(FRAMEWORK_STARTED_COMP);
486        ri.setContext(context);
487        // this will register any pending components that waits for the
488        // framework to be started
489        manager.register(ri);
490    }
491
492    public Bundle findHostBundle(Bundle bundle) {
493        String hostId = (String) bundle.getHeaders().get(Constants.FRAGMENT_HOST);
494        log.debug("Looking for host bundle: {} host id: {}", bundle.getSymbolicName(), hostId);
495        if (hostId != null) {
496            int p = hostId.indexOf(';');
497            if (p > -1) { // remove version or other extra information if any
498                hostId = hostId.substring(0, p);
499            }
500            RuntimeContext ctx = contexts.get(hostId);
501            if (ctx != null) {
502                log.debug("Context was found for host id: {}", hostId);
503                return ctx.getBundle();
504            } else {
505                log.warn("No context found for host id: {}", hostId);
506
507            }
508        }
509        return null;
510    }
511
512    @Override
513    public File getBundleFile(Bundle bundle) {
514        File file;
515        String location = bundle.getLocation();
516        String name = bundle.getSymbolicName();
517
518        if (location.startsWith("file:")) { // nuxeo osgi adapter
519            try {
520                file = org.nuxeo.common.utils.FileUtils.urlToFile(location);
521            } catch (MalformedURLException e) {
522                log.error("getBundleFile: Unable to create file for bundle name: {} as URI: {}", name, location);
523                return null;
524            }
525        } else { // may be a file path - this happens when using
526            // JarFileBundle (for ex. in nxshell)
527            file = new File(location);
528        }
529        if (file.exists()) {
530            log.debug("getBundleFile: {} bound to file: {}", name, file);
531            return file;
532        } else {
533            log.debug("getBundleFile: {} cannot bind to nonexistent file: {}", name, file);
534            return null;
535        }
536    }
537
538    public static boolean isJBoss4(Environment env) {
539        if (env == null) {
540            return false;
541        }
542        String hn = env.getHostApplicationName();
543        String hv = env.getHostApplicationVersion();
544        if (hn == null || hv == null) {
545            return false;
546        }
547        return "JBoss".equals(hn) && hv.startsWith("4");
548    }
549
550}