001/*
002 * (C) Copyright 2006-2017 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 *     bstefanescu
018 *     Kevin Leturc <kleturc@nuxeo.com>
019 */
020package org.nuxeo.runtime.reload;
021
022import java.io.File;
023import java.io.IOException;
024import java.net.MalformedURLException;
025import java.net.URL;
026import java.nio.file.Files;
027import java.nio.file.Path;
028import java.nio.file.StandardCopyOption;
029import java.util.ArrayList;
030import java.util.LinkedHashMap;
031import java.util.List;
032import java.util.Optional;
033import java.util.concurrent.TimeUnit;
034import java.util.jar.Manifest;
035import java.util.stream.Collectors;
036import java.util.stream.Stream;
037
038import javax.transaction.Transaction;
039
040import org.apache.commons.logging.Log;
041import org.apache.commons.logging.LogFactory;
042import org.nuxeo.common.Environment;
043import org.nuxeo.common.utils.FileUtils;
044import org.nuxeo.common.utils.JarUtils;
045import org.nuxeo.common.utils.ZipUtils;
046import org.nuxeo.osgi.application.DevMutableClassLoader;
047import org.nuxeo.runtime.RuntimeServiceException;
048import org.nuxeo.runtime.api.Framework;
049import org.nuxeo.runtime.deployment.preprocessor.DeploymentPreprocessor;
050import org.nuxeo.runtime.model.ComponentContext;
051import org.nuxeo.runtime.model.ComponentManager;
052import org.nuxeo.runtime.model.DefaultComponent;
053import org.nuxeo.runtime.services.event.Event;
054import org.nuxeo.runtime.services.event.EventService;
055import org.nuxeo.runtime.transaction.TransactionHelper;
056import org.nuxeo.runtime.util.Watch;
057import org.osgi.framework.Bundle;
058import org.osgi.framework.BundleContext;
059import org.osgi.framework.BundleException;
060import org.osgi.framework.ServiceReference;
061import org.osgi.service.packageadmin.PackageAdmin;
062
063/**
064 * @author <a href="mailto:bs@nuxeo.com">Bogdan Stefanescu</a>
065 */
066public class ReloadComponent extends DefaultComponent implements ReloadService {
067
068    /**
069     * The reload strategy to adopt for hot reload. Default value is {@link #RELOAD_STRATEGY_VALUE_DEFAULT}.
070     *
071     * @since 9.3
072     */
073    public static final String RELOAD_STRATEGY_PARAMETER = "org.nuxeo.runtime.reload_strategy";
074
075    public static final String RELOAD_STRATEGY_VALUE_UNSTASH = "unstash";
076
077    public static final String RELOAD_STRATEGY_VALUE_STANDBY = "standby";
078
079    public static final String RELOAD_STRATEGY_VALUE_RESTART = "restart";
080
081    public static final String RELOAD_STRATEGY_VALUE_DEFAULT = RELOAD_STRATEGY_VALUE_STANDBY;
082
083    private static final Log log = LogFactory.getLog(ReloadComponent.class);
084
085    protected static Bundle bundle;
086
087    protected Long lastFlushed;
088
089    public static BundleContext getBundleContext() {
090        return bundle.getBundleContext();
091    }
092
093    public static Bundle getBundle() {
094        return bundle;
095    }
096
097    @Override
098    public void activate(ComponentContext context) {
099        super.activate(context);
100        bundle = context.getRuntimeContext().getBundle();
101    }
102
103    @Override
104    public void deactivate(ComponentContext context) {
105        super.deactivate(context);
106        bundle = null;
107    }
108
109    /**
110     * @deprecated since 9.3, this method is only used in deployBundles and undeployBundles which are deprecated. Keep
111     *             it for backward compatibility.
112     */
113    @Deprecated
114    protected void refreshComponents() {
115        String reloadStrategy = Framework.getProperty(RELOAD_STRATEGY_PARAMETER, RELOAD_STRATEGY_VALUE_DEFAULT);
116        if (log.isInfoEnabled()) {
117            log.info("Refresh components. Strategy: " + reloadStrategy);
118        }
119        // reload components / contributions
120        ComponentManager mgr = Framework.getRuntime().getComponentManager();
121        switch (reloadStrategy) {
122        case RELOAD_STRATEGY_VALUE_UNSTASH:
123            // compat mode
124            mgr.unstash();
125            break;
126        case RELOAD_STRATEGY_VALUE_STANDBY:
127            // standby / resume
128            mgr.standby();
129            mgr.unstash();
130            mgr.resume();
131            break;
132        case RELOAD_STRATEGY_VALUE_RESTART:
133        default:
134            // restart mode
135            mgr.refresh(false);
136            break;
137        }
138    }
139
140    @Override
141    public void reload() throws InterruptedException {
142        log.debug("Starting reload");
143
144        try {
145            reloadProperties();
146        } catch (IOException e) {
147            throw new RuntimeServiceException(e);
148        }
149
150        triggerReloadWithNewTransaction(RELOAD_EVENT_ID);
151    }
152
153    @Override
154    public void reloadProperties() throws IOException {
155        log.info("Before reload runtime properties");
156        Framework.getRuntime().reloadProperties();
157        log.info("After reload runtime properties");
158    }
159
160    @Override
161    public void reloadSeamComponents() {
162        log.info("Reload Seam components");
163        Framework.getService(EventService.class)
164                 .sendEvent(new Event(RELOAD_TOPIC, RELOAD_SEAM_EVENT_ID, this, null));
165    }
166
167    @Override
168    public void flush() {
169        log.info("Before flush caches");
170        Framework.getService(EventService.class).sendEvent(new Event(RELOAD_TOPIC, FLUSH_EVENT_ID, this, null));
171        flushJaasCache();
172        setFlushedNow();
173        log.info("After flush caches");
174    }
175
176    @Override
177    public void flushJaasCache() {
178        log.info("Before flush the JAAS cache");
179        Framework.getService(EventService.class)
180                 .sendEvent(new Event("usermanager", "user_changed", this, "Deployer"));
181        setFlushedNow();
182        log.info("After flush the JAAS cache");
183    }
184
185    @Override
186    public void flushSeamComponents() {
187        log.info("Flush Seam components");
188        Framework.getService(EventService.class)
189                 .sendEvent(new Event(RELOAD_TOPIC, FLUSH_SEAM_EVENT_ID, this, null));
190        setFlushedNow();
191    }
192
193    /**
194     * @deprecated since 9.3 use {@link #reloadBundles(ReloadContext)} instead.
195     */
196    @Override
197    @Deprecated
198    public void deployBundles(List<File> files, boolean reloadResources) throws BundleException {
199        long begin = System.currentTimeMillis();
200        List<String> missingNames = files.stream()
201                                         .filter(file -> getOSGIBundleName(file) == null)
202                                         .map(File::getAbsolutePath)
203                                         .collect(Collectors.toList());
204        if (!missingNames.isEmpty()) {
205            missingNames.forEach(
206                    name -> log.error(String.format("No Bundle-SymbolicName found in MANIFEST for jar at '%s'", name)));
207            // TODO investigate why we need to exit here, getBundleContext().installBundle(path) will throw an exception
208            // unless, maybe tests ?
209            return;
210        }
211        if (log.isInfoEnabled()) {
212            StringBuilder builder = new StringBuilder("Before deploy bundles\n");
213            Framework.getRuntime().getStatusMessage(builder);
214            log.info(builder.toString());
215        }
216
217        // Reload resources
218        if (reloadResources) {
219            List<URL> urls = files.stream().map(this::toURL).collect(Collectors.toList());
220            Framework.reloadResourceLoader(urls, null);
221        }
222
223        // Deploy bundles
224        Transaction tx = TransactionHelper.suspendTransaction();
225        try {
226            _deployBundles(files);
227            refreshComponents();
228        } finally {
229            TransactionHelper.resumeTransaction(tx);
230        }
231
232        if (log.isInfoEnabled()) {
233            StringBuilder builder = new StringBuilder("After deploy bundles.\n");
234            Framework.getRuntime().getStatusMessage(builder);
235            log.info(builder.toString());
236            log.info(String.format("Hot deploy was done in %s ms.", System.currentTimeMillis() - begin));
237        }
238    }
239
240    /**
241     * @deprecated since 9.3 use {@link #reloadBundles(ReloadContext)} instead.
242     */
243    @Override
244    @Deprecated
245    public void undeployBundles(List<String> bundleNames, boolean reloadResources) throws BundleException {
246        long begin = System.currentTimeMillis();
247        if (log.isInfoEnabled()) {
248            StringBuilder builder = new StringBuilder("Before undeploy bundles\n");
249            Framework.getRuntime().getStatusMessage(builder);
250            log.info(builder.toString());
251        }
252
253        // Undeploy bundles
254        Transaction tx = TransactionHelper.suspendTransaction();
255        ReloadResult result = new ReloadResult();
256        try {
257            result.merge(_undeployBundles(bundleNames));
258            refreshComponents();
259        } finally {
260            TransactionHelper.resumeTransaction(tx);
261        }
262
263        // Reload resources
264        if (reloadResources) {
265            List<URL> undeployedBundleURLs = result.undeployedBundles.stream()
266                                                                     .map(this::toURL)
267                                                                     .collect(Collectors.toList());
268            Framework.reloadResourceLoader(null, undeployedBundleURLs);
269        }
270
271        if (log.isInfoEnabled()) {
272            StringBuilder builder = new StringBuilder("After undeploy bundles.\n");
273            Framework.getRuntime().getStatusMessage(builder);
274            log.info(builder.toString());
275            log.info(String.format("Hot undeploy was done in %s ms.", System.currentTimeMillis() - begin));
276        }
277    }
278
279    @Override
280    public ReloadResult reloadBundles(ReloadContext context) throws BundleException {
281        ReloadResult result = new ReloadResult();
282        List<String> bundlesNamesToUndeploy = context.bundlesNamesToUndeploy;
283
284        Watch watch = new Watch(new LinkedHashMap<>()).start();
285        if (log.isInfoEnabled()) {
286            StringBuilder builder = new StringBuilder("Before updating Nuxeo server\n");
287            Framework.getRuntime().getStatusMessage(builder);
288            log.info(builder.toString());
289        }
290        // get class loader
291        Optional<DevMutableClassLoader> classLoader = Optional.of(getClass().getClassLoader())
292                                                              .filter(DevMutableClassLoader.class::isInstance)
293                                                              .map(DevMutableClassLoader.class::cast);
294
295        watch.start("flush");
296        flush();
297        watch.stop("flush");
298
299        // Suspend current transaction
300        Transaction tx = TransactionHelper.suspendTransaction();
301
302        try {
303            // Stop or Standby the component manager
304            ComponentManager componentManager = Framework.getRuntime().getComponentManager();
305            String reloadStrategy = Framework.getProperty(RELOAD_STRATEGY_PARAMETER, RELOAD_STRATEGY_VALUE_DEFAULT);
306            if (log.isInfoEnabled()) {
307                log.info("Component reload strategy=" + reloadStrategy);
308            }
309
310            watch.start("stop/standby");
311            log.info("Before stop/standby component manager");
312            if (RELOAD_STRATEGY_VALUE_RESTART.equals(reloadStrategy)) {
313                componentManager.stop();
314            } else {
315                // standby strategy by default
316                componentManager.standby();
317            }
318            log.info("After stop/standby component manager");
319            watch.stop("stop/standby");
320
321            // Undeploy bundles
322            if (!bundlesNamesToUndeploy.isEmpty()) {
323                watch.start("undeploy-bundles");
324                log.info("Before undeploy bundles");
325                logComponentManagerStatus();
326
327                result.merge(_undeployBundles(bundlesNamesToUndeploy));
328                componentManager.unstash();
329
330                // Clear the class loader
331                classLoader.ifPresent(DevMutableClassLoader::clearPreviousClassLoader);
332                // TODO shall we do a GC here ? see DevFrameworkBootstrap#clearClassLoader
333
334                log.info("After undeploy bundles");
335                logComponentManagerStatus();
336                watch.stop("undeploy-bundles");
337            }
338
339            watch.start("delete-copy");
340            // Delete old bundles
341            log.info("Before delete-copy");
342            List<URL> urlsToRemove = result.undeployedBundles.stream()
343                                                             .map(Bundle::getLocation)
344                                                             .map(File::new)
345                                                             .peek(File::delete)
346                                                             .map(this::toURL)
347                                                             .collect(Collectors.toList());
348            // Then copy new ones
349            List<File> bundlesToDeploy = copyBundlesToDeploy(context);
350            List<URL> urlsToAdd = bundlesToDeploy.stream().map(this::toURL).collect(Collectors.toList());
351            log.info("After delete-copy");
352            watch.stop("delete-copy");
353
354            // Reload resources
355            watch.start("reload-resources");
356            Framework.reloadResourceLoader(urlsToAdd, urlsToRemove);
357            watch.stop("reload-resources");
358
359            // Deploy bundles
360            if (!bundlesToDeploy.isEmpty()) {
361                watch.start("deploy-bundles");
362                log.info("Before deploy bundles");
363                logComponentManagerStatus();
364
365                // Fill the class loader
366                classLoader.ifPresent(cl -> cl.addClassLoader(urlsToAdd.toArray(new URL[0])));
367
368                result.merge(_deployBundles(bundlesToDeploy));
369                componentManager.unstash();
370
371                log.info("After deploy bundles");
372                logComponentManagerStatus();
373                watch.stop("deploy-bundles");
374            }
375
376            // Start or Resume the component manager
377            watch.start("start/resume");
378            log.info("Before start/resume component manager");
379            if (RELOAD_STRATEGY_VALUE_RESTART.equals(reloadStrategy)) {
380                componentManager.start();
381            } else {
382                // standby strategy by default
383                componentManager.resume();
384            }
385            log.info("After start/resume component manager");
386            watch.stop("start/resume");
387
388            try {
389                // run deployment preprocessor
390                watch.start("deployment-preprocessor");
391                runDeploymentPreprocessor();
392                watch.stop("deployment-preprocessor");
393            } catch (IOException e) {
394                throw new BundleException("Unable to run deployment preprocessor", e);
395            }
396
397            try {
398                // reload
399                watch.start("reload-properties");
400                reloadProperties();
401                watch.stop("reload-properties");
402            } catch (IOException e) {
403                throw new BundleException("Unable to reload properties", e);
404            }
405        } finally {
406            TransactionHelper.resumeTransaction(tx);
407        }
408        if (log.isInfoEnabled()) {
409            StringBuilder builder = new StringBuilder("After updating Nuxeo server\n");
410            Framework.getRuntime().getStatusMessage(builder);
411            log.info(builder.toString());
412        }
413
414        watch.stop();
415        if (log.isInfoEnabled()) {
416            StringBuilder message = new StringBuilder();
417            message.append("Hot reload was done in ")
418                   .append(watch.getTotal().elapsed(TimeUnit.MILLISECONDS))
419                   .append(" ms, detailed steps:");
420            Stream.of(watch.getIntervals())
421                  .forEach(i -> message.append("\n- ")
422                                       .append(i.getName())
423                                       .append(": ")
424                                       .append(i.elapsed(TimeUnit.MILLISECONDS))
425                                       .append(" ms"));
426            log.info(message.toString());
427        }
428        return result;
429    }
430
431    protected List<File> copyBundlesToDeploy(ReloadContext context) throws BundleException {
432        List<File> bundlesToDeploy = new ArrayList<>();
433        Path homePath = Framework.getRuntime().getHome().toPath();
434        Path destinationPath = homePath.resolve(context.bundlesDestination);
435        try {
436            Files.createDirectories(destinationPath);
437            for (File bundle : context.bundlesToDeploy) {
438                Path bundlePath = bundle.toPath();
439                // check if the bundle is located under the desired destination
440                // if not copy it to the desired destination
441                if (!bundlePath.startsWith(destinationPath)) {
442                    if (Files.isDirectory(bundlePath)) {
443                        // If it's a directory, assume that it's an exploded jar
444                        bundlePath = JarUtils.zipDirectory(bundlePath,
445                                destinationPath.resolve("hotreload-bundle-" + System.currentTimeMillis() + ".jar"),
446                                StandardCopyOption.REPLACE_EXISTING);
447                    } else {
448                        bundlePath = Files.copy(bundlePath, destinationPath.resolve(bundle.getName()),
449                                StandardCopyOption.REPLACE_EXISTING);
450                    }
451                }
452                bundlesToDeploy.add(bundlePath.toFile());
453            }
454            return bundlesToDeploy;
455        } catch (IOException e) {
456            throw new BundleException("Unable to copy bundles to " + destinationPath, e);
457        }
458    }
459
460    /*
461     * TODO Change this method name when deployBundles will be removed.
462     */
463    protected ReloadResult _deployBundles(List<File> bundlesToDeploy) throws BundleException {
464        ReloadResult result = new ReloadResult();
465        BundleContext bundleContext = getBundleContext();
466        for (File file : bundlesToDeploy) {
467            String path = file.getAbsolutePath();
468            if (log.isInfoEnabled()) {
469                log.info(String.format("Before deploy bundle for file at '%s'", path));
470            }
471            Bundle bundle = bundleContext.installBundle(path);
472            if (bundle == null) {
473                // TODO check why this is necessary, our implementation always return sth
474                throw new IllegalArgumentException("Could not find a valid bundle at path: " + path);
475            }
476            bundle.start();
477            result.deployedBundles.add(bundle);
478            if (log.isInfoEnabled()) {
479                log.info(String.format("Deploy done for bundle with name '%s'", bundle.getSymbolicName()));
480            }
481        }
482        return result;
483    }
484
485    /*
486     * TODO Change this method name when undeployBundles will be removed.
487     */
488    protected ReloadResult _undeployBundles(List<String> bundleNames) throws BundleException {
489        ReloadResult result = new ReloadResult();
490        BundleContext ctx = getBundleContext();
491        ServiceReference ref = ctx.getServiceReference(PackageAdmin.class.getName());
492        PackageAdmin srv = (PackageAdmin) ctx.getService(ref);
493        try {
494            for (String bundleName : bundleNames) {
495                for (Bundle bundle : srv.getBundles(bundleName, null)) {
496                    if (bundle != null && bundle.getState() == Bundle.ACTIVE) {
497                        if (log.isInfoEnabled()) {
498                            log.info(String.format("Before undeploy bundle with name '%s'.", bundleName));
499                        }
500                        bundle.stop();
501                        bundle.uninstall();
502                        result.undeployedBundles.add(bundle);
503                        if (log.isInfoEnabled()) {
504                            log.info(String.format("After undeploy bundle with name '%s'.", bundleName));
505                        }
506                    }
507                }
508            }
509        } finally {
510            ctx.ungetService(ref);
511        }
512        return result;
513    }
514
515    /**
516     * This method needs to be called before bundle uninstallation, otherwise {@link Bundle#getLocation()} throw a NPE.
517     */
518    protected URL toURL(Bundle bundle) {
519        String location = bundle.getLocation();
520        File file = new File(location);
521        return toURL(file);
522    }
523
524    protected URL toURL(File file) {
525        try {
526            return file.toURI().toURL();
527        } catch (MalformedURLException e) {
528            throw new RuntimeServiceException(e);
529        }
530    }
531
532    /**
533     * Logs the {@link ComponentManager} status.
534     */
535    protected void logComponentManagerStatus() {
536        if (log.isDebugEnabled()) {
537            StringBuilder builder = new StringBuilder("ComponentManager status:\n");
538            Framework.getRuntime().getStatusMessage(builder);
539            log.debug(builder.toString());
540        }
541    }
542
543    @Override
544    public Long lastFlushed() {
545        return lastFlushed;
546    }
547
548    /**
549     * Sets the last date date to current date timestamp
550     *
551     * @since 5.6
552     */
553    protected void setFlushedNow() {
554        lastFlushed = Long.valueOf(System.currentTimeMillis());
555    }
556
557    /**
558     * @deprecated since 5.6, use {@link #runDeploymentPreprocessor()} instead. Keep it as compatibility code until
559     *             NXP-9642 is done.
560     */
561    @Override
562    @Deprecated
563    public void installWebResources(File file) throws IOException {
564        log.info("Install web resources");
565        if (file.isDirectory()) {
566            File war = new File(file, "web");
567            war = new File(war, "nuxeo.war");
568            if (war.isDirectory()) {
569                FileUtils.copyTree(war, getAppDir());
570            } else {
571                // compatibility mode with studio 1.5 - see NXP-6186
572                war = new File(file, "nuxeo.war");
573                if (war.isDirectory()) {
574                    FileUtils.copyTree(war, getAppDir());
575                }
576            }
577        } else if (file.isFile()) { // a jar
578            File war = getWarDir();
579            ZipUtils.unzip("web/nuxeo.war", file, war);
580            // compatibility mode with studio 1.5 - see NXP-6186
581            ZipUtils.unzip("nuxeo.war", file, war);
582        }
583    }
584
585    @Override
586    public void runDeploymentPreprocessor() throws IOException {
587        log.info("Start running deployment preprocessor");
588        String rootPath = Environment.getDefault().getRuntimeHome().getAbsolutePath();
589        File root = new File(rootPath);
590        DeploymentPreprocessor processor = new DeploymentPreprocessor(root);
591        // initialize
592        processor.init();
593        // and predeploy
594        processor.predeploy();
595        log.info("Deployment preprocessing done");
596    }
597
598    protected static File getAppDir() {
599        return Environment.getDefault().getConfig().getParentFile();
600    }
601
602    protected static File getWarDir() {
603        return new File(getAppDir(), "nuxeo.war");
604    }
605
606    @Override
607    public String getOSGIBundleName(File file) {
608        Manifest mf = JarUtils.getManifest(file);
609        if (mf == null) {
610            return null;
611        }
612        String bundleName = mf.getMainAttributes().getValue("Bundle-SymbolicName");
613        if (bundleName == null) {
614            return null;
615        }
616        int index = bundleName.indexOf(';');
617        if (index > -1) {
618            bundleName = bundleName.substring(0, index);
619        }
620        return bundleName;
621    }
622
623    /**
624     * @deprecated since 9.3 should not be needed anymore
625     */
626    @Deprecated
627    protected void triggerReloadWithNewTransaction(String eventId) throws InterruptedException {
628        if (TransactionHelper.isTransactionMarkedRollback()) {
629            throw new AssertionError("The calling transaction is marked rollback");
630        }
631        // we need to commit or rollback transaction because suspending it leads to a lock/errors when acquiring a new
632        // connection during the datasource reload
633        boolean hasTransaction = TransactionHelper.isTransactionActiveOrMarkedRollback();
634        if (hasTransaction) {
635            TransactionHelper.commitOrRollbackTransaction();
636        }
637        try {
638            TransactionHelper.runInTransaction(() -> triggerReload(eventId));
639        } finally {
640            // start a new transaction only if one already existed
641            // this is because there's no user transaction when coming from SDK
642            if (hasTransaction) {
643                TransactionHelper.startTransaction();
644            }
645        }
646    }
647
648    /**
649     * @deprecated since 9.3 should not be needed anymore
650     */
651    @Deprecated
652    protected void triggerReload(String eventId) {
653        log.info("About to send reload event for id: " + eventId);
654        Framework.getService(EventService.class)
655                 .sendEvent(new Event(RELOAD_TOPIC, BEFORE_RELOAD_EVENT_ID, this, null));
656        try {
657            Framework.getService(EventService.class).sendEvent(new Event(RELOAD_TOPIC, eventId, this, null));
658        } finally {
659            Framework.getService(EventService.class)
660                     .sendEvent(new Event(RELOAD_TOPIC, AFTER_RELOAD_EVENT_ID, this, null));
661            log.info("Returning from reload for event id: " + eventId);
662        }
663    }
664}