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