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