001/*
002 *  Copyright 2020 Anyware Services
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 */
016package org.ametys.core.migration;
017
018import java.util.ArrayList;
019import java.util.Collections;
020import java.util.Comparator;
021import java.util.HashMap;
022import java.util.List;
023import java.util.Map;
024import java.util.Map.Entry;
025import java.util.Set;
026import java.util.stream.Collectors;
027
028import org.apache.avalon.framework.component.Component;
029import org.apache.avalon.framework.configuration.Configuration;
030import org.apache.avalon.framework.configuration.ConfigurationException;
031import org.apache.avalon.framework.context.Context;
032import org.apache.avalon.framework.context.ContextException;
033import org.apache.avalon.framework.context.Contextualizable;
034import org.apache.avalon.framework.logger.Logger;
035import org.apache.avalon.framework.service.ServiceException;
036import org.apache.avalon.framework.service.ServiceManager;
037import org.apache.avalon.framework.service.Serviceable;
038import org.apache.avalon.framework.thread.ThreadSafe;
039import org.apache.cocoon.Constants;
040import org.apache.cocoon.util.log.SLF4JLoggerAdapter;
041import org.apache.commons.lang3.StringUtils;
042import org.apache.commons.lang3.tuple.Pair;
043
044import org.ametys.core.ObservationConstants;
045import org.ametys.core.engine.BackgroundEngineHelper;
046import org.ametys.core.migration.action.Action;
047import org.ametys.core.migration.action.ActionExtensionPoint;
048import org.ametys.core.migration.action.data.ActionData;
049import org.ametys.core.migration.configuration.VersionConfiguration;
050import org.ametys.core.migration.handler.VersionHandler;
051import org.ametys.core.migration.handler.VersionHandlerExtensionPoint;
052import org.ametys.core.migration.version.Version;
053import org.ametys.core.observation.Event;
054import org.ametys.core.observation.ObservationManager;
055import org.ametys.runtime.plugin.ExtensionPoint;
056import org.ametys.runtime.plugin.component.AbstractLogEnabled;
057import org.ametys.runtime.servlet.RuntimeServlet;
058import org.ametys.runtime.servlet.RuntimeServlet.MaintenanceStatus;
059
060/**
061 * Migration Extension Point that will list all migration needed by the current state of the application
062 */
063public class MigrationExtensionPoint extends AbstractLogEnabled implements ExtensionPoint<MigrationConfiguration>, ThreadSafe, Component, Serviceable, Contextualizable
064{
065    /** Avalon Role */
066    public static final String ROLE = MigrationExtensionPoint.class.getName();
067    
068    private Map<String, MigrationConfiguration> _configurations = new HashMap<>();
069    
070    private Map<String, String> _expectedVersionsForExtensions = new HashMap<>();
071
072    private VersionHandlerExtensionPoint _versionHandlerEP;
073
074    private ActionExtensionPoint _upgradeEP;
075    private ActionExtensionPoint _initializationEP;
076
077    private ObservationManager _observationManager;
078
079    private ServiceManager _manager;
080
081    private org.apache.cocoon.environment.Context _context;
082    
083    public void service(ServiceManager smanager) throws ServiceException
084    {
085        _manager = smanager;
086        
087        _versionHandlerEP = (VersionHandlerExtensionPoint) smanager.lookup(VersionHandlerExtensionPoint.ROLE);
088        _upgradeEP = (ActionExtensionPoint) smanager.lookup(ActionExtensionPoint.ROLE_UPGRADE);
089        _initializationEP = (ActionExtensionPoint) smanager.lookup(ActionExtensionPoint.ROLE_INITIALIZATION);
090        
091        if (smanager.hasService(ObservationManager.ROLE))
092        {
093            _observationManager = (ObservationManager) smanager.lookup(ObservationManager.ROLE);
094        }
095    }
096    
097    public void contextualize(Context context) throws ContextException
098    {
099        _context = (org.apache.cocoon.environment.Context) context.get(Constants.CONTEXT_ENVIRONMENT_CONTEXT);
100    }
101    
102    public void addExtension(String id, String pluginName, String featureName, Configuration configuration) throws ConfigurationException
103    {
104        _configurations.put(id, new MigrationConfiguration(id, pluginName, featureName, configuration));
105    }
106
107    public void initializeExtensions() throws Exception
108    {
109        // Nothing here
110    }
111    
112    /**
113     * All migrations are parsed and executed one per one in the right order
114     * @return true if the migration requires a server restart ; false otherwise
115     */
116    public boolean doMigration()
117    {
118        try
119        {
120            getLogger().info("Automatic migration start.");
121
122            // For env
123            Logger logger = new SLF4JLoggerAdapter(getLogger());
124            
125            List<ActionData> allUpgradeActions = new ArrayList<>();
126            Map<Version, List<ActionData>> allInitActions = new HashMap<>();
127            boolean versionDeterminationFailed = false;
128            Set<String> keySet = _configurations.keySet();
129            for (String id : keySet)
130            {
131                MigrationConfiguration migrationConfiguration = _configurations.get(id);
132
133                try
134                {
135                    String expectedVersion = _getHigherCurrentUpgradeVersionNumber(migrationConfiguration);
136                    _expectedVersionsForExtensions.put(id, expectedVersion);
137                }
138                catch (ConfigurationException e)
139                {
140                    throw new MigrationException("Impossible to read the upgrade configuration for component '" + id + "'", e);
141                }
142                
143                try
144                {
145                    Configuration configuration = migrationConfiguration.getConfiguration();
146                    
147                    List<Version> versions = new ArrayList<>();
148                    for (Configuration versionConfiguration : configuration.getChild("versions").getChildren("version"))
149                    {
150                        Map<String, Object> environmentInformation = null;
151                        
152                        try
153                        {
154                            // Create the environment.
155                            environmentInformation = BackgroundEngineHelper.createAndEnterEngineEnvironment(_manager, _context, logger);
156                            
157                            VersionHandler versionHandler = _getVersionHandler(versionConfiguration);
158                            VersionConfiguration versionConfigurationObject = versionHandler.getConfiguration(id, versionConfiguration);
159                            versions.addAll(versionHandler.getCurrentVersions(id, versionConfigurationObject));
160                        }
161                        catch (NotMigrableInSafeModeException e)
162                        {
163                            getLogger().warn("The migration '{}' cannot be done in safe mode", id, e);
164                        }
165                        catch (MigrationException e)
166                        {
167                            versionDeterminationFailed = true;
168                            getLogger().error("Error during version determination for component {}", id,  e);
169                        }
170                        finally
171                        {
172                            BackgroundEngineHelper.leaveEngineEnvironment(environmentInformation);
173                        }                            
174                    }
175                    
176                    Map<Version, List<ActionData>> initActions = _getInitializationActions(versions, migrationConfiguration);
177                    allInitActions.putAll(initActions);
178
179                    List<ActionData> upgradeActions = _getUpgradeActions(versions, migrationConfiguration);
180                    allUpgradeActions.addAll(upgradeActions);
181                }
182                catch (ConfigurationException | MigrationException e)
183                {
184                    throw new MigrationException("Exception occured in migration " + id, e);
185                }
186            }
187
188            if (_applyInitializationActions(allInitActions, logger)
189                || _applyUpgradeActions(allUpgradeActions, logger))
190            {
191                getLogger().info("Automatic migration is restarting the server to continue migration");
192                return true;
193            }
194            
195            if (!versionDeterminationFailed)
196            {
197                
198                _notifyEndOfMigration();
199                
200                // To free some memory space, not done on failing
201                _configurations = null;
202            }
203            else
204            {
205                RuntimeServlet.setMaintenanceStatus(MaintenanceStatus.AUTOMATIC, null);
206            }
207            
208            getLogger().info("Automatic migration finished.");
209        }
210        catch (MigrationException e)
211        {
212            RuntimeServlet.setMaintenanceStatus(MaintenanceStatus.AUTOMATIC, null);
213            
214            getLogger().error("Error during the automatic migration", e);
215        }
216        
217        return false;
218    }
219    
220    private VersionHandler _getVersionHandler(Configuration versionConfiguration) throws ConfigurationException
221    {
222        String versionHandlerType = versionConfiguration.getAttribute("type");
223        
224        VersionHandler extension = _versionHandlerEP.getExtension(versionHandlerType);
225        if (extension == null)
226        {
227            throw new ConfigurationException("A migration requests a versionHandler with id '" + versionHandlerType + "', which does not exists, this migration can not be done.", versionConfiguration);
228        }
229        return extension;
230    }
231
232    /**
233     * Notify with an {@link Event} the end of the migration.
234     */
235    protected void _notifyEndOfMigration()
236    {
237        if (_observationManager != null)
238        {
239            _observationManager.notify(new Event(ObservationConstants.EVENT_MIGRATION_ENDED, null, Map.of()));
240        }
241    }
242    
243    /**
244     * Execute all needed upgrades
245     * @param allInitializationActions list of needed upgrades
246     * @param logger The avalon wrapped logger
247     * @return true if an action requires a restart
248     * @throws MigrationException Something went wrong
249     */
250    protected boolean _applyInitializationActions(Map<Version, List<ActionData>> allInitializationActions, Logger logger) throws MigrationException
251    {
252        if (allInitializationActions.isEmpty())
253        {
254            getLogger().debug("No initialization to do");
255        }
256        else
257        {
258            for (Entry<Version, List<ActionData>> entry : allInitializationActions.entrySet())
259            {
260                Version version = entry.getKey();
261                List<ActionData> actions = entry.getValue();
262                
263                for (ActionData initializationAction : actions)
264                {
265                    Map<String, Object> environmentInformation = null;
266
267                    try
268                    {
269                        // Create the environment.
270                        environmentInformation = BackgroundEngineHelper.createAndEnterEngineEnvironment(_manager, _context, logger);
271                        
272                        Action actionExtension = _initializationEP.getExtension(initializationAction.getType());
273                        getLogger().info("Run initialization : " + initializationAction.toString());
274                        actionExtension.doAction(initializationAction);
275                        
276                        if (initializationAction.requiresRestart())
277                        {
278                            return true;
279                        }
280                    }
281                    finally
282                    {
283                        BackgroundEngineHelper.leaveEngineEnvironment(environmentInformation);
284                    }
285                }
286                
287                // The ID of the version had already be set to the most recent action id
288                _upgradeVersion(version);
289            }
290        }
291        return false;
292    }
293    /**
294     * Execute all needed upgrades
295     * @param allActions list of needed upgrades
296     * @param logger The avalon wrapped logger
297     * @return true if an action requires a restart
298     * @throws MigrationException Something went wrong
299     */
300    protected boolean _applyUpgradeActions(List<ActionData> allActions, Logger logger) throws MigrationException
301    {
302        allActions.sort(Comparator.comparing(ActionData::getVersionNumber));
303        if (allActions.isEmpty())
304        {
305            getLogger().debug("No upgrade to do");
306        }
307        else
308        {
309            _logUpgradeActionsBeforeExecution(allActions);
310            for (ActionData action : allActions)
311            {
312                Map<String, Object> environmentInformation = null;
313
314                try
315                {
316                    // Create the environment.
317                    environmentInformation = BackgroundEngineHelper.createAndEnterEngineEnvironment(_manager, _context, logger);
318                    
319                    Action upgradeExtension = _upgradeEP.getExtension(action.getType());
320                    getLogger().info("Run upgrade : " + action.toString());
321                    upgradeExtension.doAction(action);
322                    _upgradeVersion(action);
323                    
324                    if (action.requiresRestart())
325                    {
326                        return true;
327                    }
328                }
329                finally
330                {
331                    BackgroundEngineHelper.leaveEngineEnvironment(environmentInformation);
332                }
333            }
334        }
335        return false;
336    }
337
338    private void _logUpgradeActionsBeforeExecution(List<ActionData> allActions)
339    {
340        if (getLogger().isInfoEnabled())
341        {
342            int count = allActions.size();
343            StringBuilder sb = new StringBuilder().append(count).append(count > 1 ? " pending upgrade(s):" : " pending upgrade:");
344            for (ActionData action : allActions)
345            {
346                sb.append(System.lineSeparator());
347                sb.append("* ").append(action.toString());
348            }
349            getLogger().info(sb.toString());
350        }
351    }
352
353    /**
354     * When an upgrade (or an initialization) have been done, we write the new version
355     * @param action upgrade/initialization executed
356     * @throws MigrationException Something went wrong
357     */
358    protected void _upgradeVersion(ActionData action) throws MigrationException
359    {
360        Version newVersion = action.getVersion().copyFromActionData(action);
361        _upgradeVersion(newVersion);
362    }
363    
364    /**
365     * When an upgrade (or an initialization) have been done, we write the new version
366     * @param newVersion upgrade/initialization executed
367     * @throws MigrationException Something went wrong
368     */
369    protected void _upgradeVersion(Version newVersion) throws MigrationException
370    {
371        getLogger().debug("Update version info vor version : " + newVersion.toString());
372        String versionHandlerId = newVersion.getVersionHandlerId();
373        VersionHandler versionHandler = _versionHandlerEP.getExtension(versionHandlerId);
374        if (versionHandler == null)
375        {
376            throw new MigrationException("Version '" + newVersion.toString() + "' requires a versionHandler that does not exists: '" + versionHandlerId + "'.");
377        }
378        else
379        {
380            versionHandler.addVersion(newVersion);
381        }
382    }
383    
384    /**
385     * Analyze the configuration to get a list of upgrades needed based on the current Versions
386     * @param currentVersions list of current versions
387     * @param migrationConfiguration configuration for this extension
388     * @return a list of actions to work on, or null if an error occured
389     * @throws MigrationException impossible to parse the configuration
390     */
391    protected List<ActionData> _getUpgradeActions(List<Version> currentVersions, MigrationConfiguration migrationConfiguration) throws MigrationException
392    {
393        String componentId = migrationConfiguration.getId();
394        try
395        {
396            Configuration[] upgradeList = migrationConfiguration.getConfiguration().getChild("upgrades").getChildren("upgrade");
397            
398            List<ActionData> result = new ArrayList<>();
399            
400            for (Version version : currentVersions)
401            {
402                if (version == null)
403                {
404                    throw new MigrationException("The migration for '" + componentId + "' got a null Version.");
405                }
406                else if (version.getVersionNumber() != null)
407                {
408                    getLogger().debug("Check the upgrades needed for version: {}", version.toString());
409                    String versionNumber = version.getVersionNumber();
410
411                    String higherCurrentUpgradeVersionNumber = _getHigherCurrentUpgradeVersionNumber(migrationConfiguration);
412                    if (higherCurrentUpgradeVersionNumber.compareTo(versionNumber) < 0)
413                    {
414                        getLogger().warn("There is a version stored more recent that any version available in conf for version: {}", version.toString());
415                    }
416                    
417                    List<ActionData> thisVersionUpgrades = new ArrayList<>();
418                    for (Configuration upgradeConf : upgradeList)
419                    {
420                        String upgradeVersionNumber = upgradeConf.getAttribute("versionNumber");
421                        String type = upgradeConf.getAttribute("type");
422                        String from = upgradeConf.getAttribute("from", null);
423                        String comment = upgradeConf.getAttribute("comment", null);
424                        boolean restartRequired = upgradeConf.getAttributeAsBoolean("restart", false);
425
426                        if (comment == null)
427                        {
428                            comment = "Automatic Upgrade.";
429                        }
430                        else
431                        {
432                            comment = "Automatic Upgrade: " + comment;
433                        }
434                        
435                        Action actionExtension = _upgradeEP.getExtension(type);
436                        
437                        if (actionExtension == null)
438                        {
439                            throw new ConfigurationException("The type '" + type + "' does not exist.", upgradeConf);
440                        }
441                        
442                        if (versionNumber.compareTo(upgradeVersionNumber) >= 0 // Obsolete migrations (migrating to older or current version) 
443                            || from != null && versionNumber.compareTo(from) > 0) // Future migration migrating from an older version
444                        {
445                            continue;
446                        }
447                        else
448                        {
449                            ActionData upgradeData = actionExtension.generateActionData(upgradeVersionNumber, version, comment, from, type, migrationConfiguration.getPluginName(), upgradeConf, restartRequired);
450                            thisVersionUpgrades.add(upgradeData);
451                        }
452                    }
453                    
454                    result.addAll(_removeDuplicatedActions(thisVersionUpgrades));
455                }
456            }
457            
458            return result;
459        }
460        catch (ConfigurationException e)
461        {
462            throw new MigrationException("Error while parsing configuration for component '" + componentId + "'", e);
463        }
464    }
465    
466    /**
467     * Analyze the configuration to get a list of initializations needed based on the current Versions
468     * @param currentVersions list of current versions
469     * @param migrationConfiguration configuration for this extension
470     * @return a list of actions to work on, or null if an error occured
471     * @throws MigrationException impossible to parse the configuration
472     */
473    protected Map<Version, List<ActionData>> _getInitializationActions(List<Version> currentVersions, MigrationConfiguration migrationConfiguration) throws MigrationException
474    {
475        String componentId = migrationConfiguration.getId();
476        Map<Version, List<ActionData>> result = new HashMap<>();
477        try
478        {
479            Configuration[] initializations = migrationConfiguration.getConfiguration().getChild("initializations").getChildren("initialization");
480            
481            String versionComment = migrationConfiguration.getConfiguration().getChild("initializations").getAttribute("comment", null);
482            
483            if (versionComment == null)
484            {
485                versionComment = "Automatic Initialization.";
486            }
487            else
488            {
489                versionComment = "Automatic Initialization: " + versionComment;
490            }
491            
492            for (Version version : currentVersions)
493            {
494                if (version == null)
495                {
496                    throw new MigrationException("The migration for '" + componentId + "' got a null Version.");
497                }
498                else if (version.getVersionNumber() == null)
499                {
500                    List<ActionData> thisVersionActions = new ArrayList<>();
501                    getLogger().debug("No version number, this is an initialization: {}", version.toString());
502                    
503                    String higherCurrentUpgradeVersionNumber = _getHigherCurrentUpgradeVersionNumber(migrationConfiguration);
504                    version.setVersionNumber(higherCurrentUpgradeVersionNumber);
505                    version.setComment(versionComment);
506                    
507                    for (Configuration initialization : initializations)
508                    {
509                        String type = initialization.getAttribute("type");
510                        boolean restartRequired = initialization.getAttributeAsBoolean("restart", false);
511                        String actionComment = null; // No comment will be used here, only the version will have a comment in an initialization
512                        
513                        Action actionExtension = _initializationEP.getExtension(type);
514                        
515                        if (actionExtension == null)
516                        {
517                            throw new ConfigurationException("The type '" + type + "' does not exist.", initialization);
518                        }
519                        ActionData initializationData = actionExtension.generateActionData(higherCurrentUpgradeVersionNumber, version, actionComment, null, type, migrationConfiguration.getPluginName(), initialization, restartRequired);
520                        thisVersionActions.add(initializationData);
521                    }
522
523                    result.put(version, thisVersionActions);
524                }
525                
526            }
527            
528            return result;
529        }
530        catch (ConfigurationException e)
531        {
532            throw new MigrationException("Error while parsing configuration for component '" + componentId + "'", e);
533        }
534    }
535    
536    
537    /**
538     * Returns the most recent version to apply (after an initialization)
539     * @param migrationConfiguration Configuration of the extension
540     * @return the id of the most current available version, or "0" if none available
541     * @throws ConfigurationException Sompthing wrong while reading the configuration
542     */
543    protected String _getHigherCurrentUpgradeVersionNumber(MigrationConfiguration migrationConfiguration) throws ConfigurationException
544    {
545        Configuration[] upgradeList = migrationConfiguration.getConfiguration().getChild("upgrades").getChildren("upgrade");
546        List<String> upgradeIds = new ArrayList<>();
547        for (Configuration upgradeConf : upgradeList)
548        {
549            String id = upgradeConf.getAttribute("versionNumber");
550            upgradeIds.add(id);
551        }
552        
553        if (!upgradeIds.isEmpty())
554        {
555            upgradeIds.sort(String.CASE_INSENSITIVE_ORDER);
556            return upgradeIds.get(upgradeIds.size() - 1);
557        }
558        else
559        {
560            return "0";
561        }
562    }
563    
564    /**
565     * Parse the list of upgrade to remove the group-migration
566     * @param actions list of upgrades to clean
567     * @return a list with only needed upgrades.
568     * @throws MigrationException list of actions incoherent
569     */
570    protected List<ActionData> _removeDuplicatedActions(List<ActionData> actions) throws MigrationException
571    {
572        // First, sort the upgrades by if to have the right order
573        actions.sort(
574            Comparator.comparing(ActionData::getVersionNumber)
575                .thenComparing(
576                    ActionData::getFrom,
577                    Comparator.nullsFirst(Comparator.naturalOrder())
578                )
579        );
580        
581        // Create a list of pairs for each upgrade containing a "for" attribute to konw from which version to which version we should remove the lines
582        List<Pair<String, String>> fromTo = actions.stream()
583                                                    .filter(u -> StringUtils.isNotBlank(u.getFrom()))
584                                                    .map(u -> Pair.of(u.getFrom(), u.getVersionNumber()))
585                                                    .collect(Collectors.toList());
586
587        _checkFromUpgrades(actions, fromTo);
588        
589        List<ActionData> result = actions;
590        
591        // Invert the order so we will start to apply the most recent one, it may override other lines with a From inside
592        Collections.reverse(fromTo);
593        // For each pair, remove the right versions
594        for (Pair<String, String> pair : fromTo)
595        {
596            String from = pair.getLeft();
597            String to = pair.getRight();
598            
599            result = result.stream()
600                        .filter(
601                            a -> a.getVersionNumber().compareTo(from) <= 0 
602                            || to.equals(a.getVersionNumber()) && from.equals(a.getFrom())
603                            || a.getVersionNumber().compareTo(to) > 0
604                        )
605                        .collect(Collectors.toList());
606        }
607        
608        return result;
609    }
610    
611    /**
612     * Tests that in the list of fromTo, that each "fromTo" is linked to an action with the same id and without "from" 
613     * @param actions list of actions
614     * @param fromTo list of actions overriding multiple actions
615     * @throws MigrationException there is an overriding version without a "simple" version with the same id
616     */
617    protected void _checkFromUpgrades(List<ActionData> actions, List<Pair<String, String>> fromTo) throws MigrationException
618    {
619        for (Pair<String, String> pair : fromTo)
620        {
621            String from = pair.getLeft();
622            String to = pair.getRight();
623            boolean anyMatch = actions.stream().anyMatch(a -> a.getFrom() == null && to.equals(a.getVersionNumber()));
624            if (!anyMatch)
625            {
626                throw new MigrationException("The action from '" + from + "' to '" + to + "' does not contain a normal upgrade from '" + from + "'");
627            }
628        }
629    }
630    
631    private Map<String, MigrationConfiguration> _getConfigurationsForReading()
632    {
633        if (_configurations == null)
634        {
635            throw new UnsupportedOperationException("Configurations have been emptied from the extension point, this method can't be used for now.");
636        }
637        return _configurations;
638    }
639    
640    public boolean hasExtension(String id)
641    {
642        return _getConfigurationsForReading().containsKey(id);
643    }
644
645    public MigrationConfiguration getExtension(String id)
646    {
647        return _getConfigurationsForReading().get(id);
648    }
649
650    public Set<String> getExtensionsIds()
651    {
652        return _getConfigurationsForReading().keySet();
653    }
654    
655    /**
656     * Get the expected version for a component.
657     * This is the latest upgrade number declared
658     * @param id the id of the component
659     * @return the expected version number for each version in this component
660     * @throws MigrationException Component not found in the calculated map of expected versions
661     */
662    public String getExpectedVersionForComponent(String id) throws MigrationException
663    {
664        if (!_expectedVersionsForExtensions.containsKey(id))
665        {
666            throw new MigrationException("Component '" + id + "' is not found in the calculated expected versions");
667        }
668        return _expectedVersionsForExtensions.get(id);
669    }
670}