001/*
002 *  Copyright 2023 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 */
016
017package org.ametys.runtime.plugins.admin.migration;
018
019import java.time.ZoneOffset;
020import java.time.ZonedDateTime;
021import java.util.ArrayList;
022import java.util.Collections;
023import java.util.Comparator;
024import java.util.List;
025import java.util.Map;
026import java.util.Map.Entry;
027import java.util.Set;
028import java.util.TreeMap;
029import java.util.TreeSet;
030import java.util.stream.Collectors;
031
032import org.apache.avalon.framework.component.Component;
033import org.apache.avalon.framework.configuration.ConfigurationException;
034import org.apache.avalon.framework.service.ServiceException;
035import org.apache.avalon.framework.service.ServiceManager;
036import org.apache.avalon.framework.service.Serviceable;
037import org.apache.commons.lang3.StringUtils;
038
039import org.ametys.core.migration.MigrationEngine;
040import org.ametys.core.migration.MigrationEngine.MigrationComponent;
041import org.ametys.core.migration.MigrationEngine.VersionList;
042import org.ametys.core.migration.MigrationEngine.Versions;
043import org.ametys.core.migration.MigrationEngine.VersionsContainer;
044import org.ametys.core.migration.MigrationException;
045import org.ametys.core.migration.MigrationExtensionPoint;
046import org.ametys.core.migration.action.ActionConfiguration;
047import org.ametys.core.migration.version.Version;
048import org.ametys.core.ui.Callable;
049import org.ametys.core.util.DateUtils;
050import org.ametys.core.util.I18nUtils;
051import org.ametys.runtime.i18n.I18nizableText;
052
053/**
054 * Component for retrieving info about all past and available automatic upgrades.
055 */
056public class MigrationsStatus implements Serviceable, Component
057{
058    private MigrationEngine _migrationEngine;
059    private MigrationExtensionPoint _migrationExtensionPoint;
060    private MigrationExtensionPoint _migrationInternalExtensionPoint;
061    private I18nUtils _i18nUtils;
062    
063    public void service(ServiceManager manager) throws ServiceException
064    {
065        _migrationEngine = (MigrationEngine) manager.lookup(MigrationEngine.ROLE);
066        _migrationExtensionPoint = (MigrationExtensionPoint) manager.lookup(MigrationExtensionPoint.ROLE);
067        _migrationInternalExtensionPoint = (MigrationExtensionPoint) manager.lookup(MigrationExtensionPoint.ROLE + "/internal");
068        _i18nUtils = (I18nUtils) manager.lookup(I18nUtils.ROLE);
069    }
070
071    /**
072     * Retrieves the status of automatic migrations
073     * @param clientParameters The client parameters such as the node to refresh
074     * @return a JSON view of all existing automatic migrations.
075     * @throws ConfigurationException if an error occurred reading the configuration
076     * @throws MigrationException if an error occurred retrieving the versions
077     */
078    @Callable (rights = "Runtime_Rights_Admin_Access", context = "/admin")
079    public Map<String, Object> getMigrationsStatus(Map<String, Object> clientParameters) throws ConfigurationException, MigrationException
080    {
081        Map<String, List<Map<String, Object>>> migrationsByPlugins = new TreeMap<>();
082        
083        _migrationsByPlugins(true, migrationsByPlugins, _migrationInternalExtensionPoint);
084        _migrationsByPlugins(false, migrationsByPlugins, _migrationExtensionPoint);
085        
086        List<Map<String, Object>> allNodes = migrationsByPlugins.entrySet().stream()
087                .map(entry -> _plugin2json(entry.getKey(), entry.getValue()))
088                .toList();
089        
090        List<Map<String, Object>> parentInfos = new ArrayList<>();
091        return Map.of(
092            "children", _filter(allNodes, StringUtils.removeStart((String) clientParameters.get("path"), "/root"), parentInfos),
093            "parentInfos", parentInfos
094        );
095    }
096
097    @SuppressWarnings("unchecked")
098    private List<Map<String, Object>> _filter(List<Map<String, Object>> allNodes, String path, List<Map<String, Object>> parentInfos)
099    {
100        if (StringUtils.isBlank(path))
101        {
102            return allNodes;
103        }
104        else
105        {
106            int i = path.indexOf('/', 1);
107            i = i == -1 ? path.length() : i; 
108            String thisNode = path.substring(1, i);
109            String subPath = path.substring(i);
110            
111            for (Map<String, Object> map : allNodes)
112            {
113                if (thisNode.equals(map.get("component")))
114                {
115                    parentInfos.add(Map.of(
116                        "failed", map.get("failed"),
117                        "warning", map.get("warning")
118                    ));
119                    return _filter((List<Map<String, Object>>) map.get("children"), subPath, parentInfos);
120                }
121            }
122            
123            throw new IllegalArgumentException("Cannot find component '" + thisNode + "'");
124        }
125    }
126
127    private void _migrationsByPlugins(boolean isInternal, Map<String, List<Map<String, Object>>> migrationsByPlugins, MigrationExtensionPoint migrationExtensionPoint)
128    {
129        for (String extensionId : migrationExtensionPoint.getExtensionsIds())
130        {
131            MigrationComponent component = migrationExtensionPoint.getExtension(extensionId);
132            List<Map<String, Object>> migrations = migrationsByPlugins.computeIfAbsent(component.pluginName(), k -> new ArrayList<>());
133            
134            try
135            {
136                // Processing stored versions
137                Versions versions = _migrationEngine.getVersions(component);
138                
139                List<Map<String, Object>> children = _versions2json(isInternal, extensionId, versions, component.upgrades());
140                
141                migrations.add(_component2json(isInternal, extensionId, extensionId, children, component.versionHandlerType(), component.versionStorage().getId(), true, versions instanceof VersionList vl ? vl.id() : null));
142            }
143            catch (MigrationException e)
144            {
145                migrations.add(_componentFailure2json(isInternal, extensionId, component.versionHandlerType(), component.versionStorage().getId(), e));
146            }
147        }
148    }
149    
150    private List<Map<String, Object>> _versions2json(boolean isInternal, String componentId, Versions versions, List<ActionConfiguration> existingUpgrades)
151    {
152        if (versions instanceof VersionsContainer versionsContainer)
153        {
154            return _versionContainer2json(isInternal, componentId, versionsContainer, existingUpgrades);
155        }
156        else if (versions instanceof VersionList versionList)
157        {
158            return _versionList2json(isInternal, componentId, versionList, existingUpgrades);
159        }
160        else 
161        {
162            throw new IllegalArgumentException("Object " + versions + " is not supported");
163        }
164    }
165    
166    private List<Map<String, Object>> _versionContainer2json(boolean isInternal, String componentId, VersionsContainer versionsContainer, List<ActionConfiguration> existingUpgrades)
167    {
168        List<Map<String, Object>> children = new ArrayList<>();
169        
170        for (Entry<I18nizableText, Versions> entry : versionsContainer.entrySet())
171        {
172            Versions versions = entry.getValue();
173            List<Map<String, Object>> granChildren = _versions2json(isInternal, componentId, versions, existingUpgrades);
174            children.add(_component2json(isInternal, componentId, _i18nUtils.translate(entry.getKey()), granChildren, null, null, false, versions instanceof VersionList vl ? vl.id() : null));
175        }
176        
177        Collections.sort(children, new TreeComparator());
178        
179        return children;
180    }
181
182    private List<Map<String, Object>> _versionList2json(boolean isInternal, String componentId, VersionList versionList, List<ActionConfiguration> existingUpgrades)
183    {
184        List<Map<String, Object>> children = new ArrayList<>();
185        
186        Set<String> existingUpgradeNumbers = existingUpgrades.stream().map(ActionConfiguration::getVersionNumber).collect(Collectors.toSet()); 
187        
188        Set<String> done = new TreeSet<>();
189        
190        Version latestVersion = _migrationEngine.getLatestVersion(versionList.versions());
191        
192        for (Version version : versionList.versions())
193        {
194            done.add(version.getVersionNumber());
195            children.add(_version2json(isInternal, componentId, versionList.id(), version, latestVersion == version, "0".equals(version.getVersionNumber()) || existingUpgradeNumbers.contains(version.getVersionNumber())));
196        }
197
198        if (_isFailedAction(versionList.id())   // There is a failed action 
199            && _failedActionIsTheFutureCurrentVersion(existingUpgrades, done, latestVersion)) // That is the current pending action 
200        {
201            done.add(StringUtils.defaultIfBlank(_migrationEngine.getFailedAction().targetVersionNumber(), _migrationEngine.getFailedAction().configuration().getVersionNumber()));
202            children.add(_failureVersion2json(isInternal, componentId, versionList.id()));
203        }
204        
205        for (ActionConfiguration actionConfiguration : existingUpgrades)
206        {
207            if (!done.contains(actionConfiguration.getVersionNumber()))
208            {
209                children.add(_pendingVersion2json(isInternal, componentId, versionList.id(), actionConfiguration, _newest(done), _oldest(done)));
210            }
211        }
212        
213        Collections.sort(children, new TreeComparator());
214        Collections.reverse(children);
215        
216        if (children.size() > 0 && !"0".equals(children.get(children.size() - 1).get("component"))
217            || children.size() == 0)
218        {
219            // Add the 0 version in the past if necessary
220            children.add(_pendingVersion2json(isInternal, componentId, versionList.id(), null, _newest(done), _oldest(done)));
221        }
222        
223        return children;
224    }
225    
226    private boolean _failedActionIsTheFutureCurrentVersion(List<ActionConfiguration> existingUpgrades, Set<String> done, Version latestVersion)
227    {
228        Set<String> pendingNumbers = new TreeSet<>();
229        for (ActionConfiguration actionConfiguration : existingUpgrades)
230        {
231            String versionToHandle = actionConfiguration.getVersionNumber();
232            if (!done.contains(versionToHandle)
233                && (latestVersion == null || latestVersion.getVersionNumber().compareTo(versionToHandle) < 0))
234            {
235                pendingNumbers.add(versionToHandle);
236            }
237        }
238        
239        return _migrationEngine.getFailedAction().currentVersion().getVersionNumber() != null && StringUtils.equals(_oldest(pendingNumbers), _migrationEngine.getFailedAction().configuration().getVersionNumber()) // Upgrade failed
240                || _migrationEngine.getFailedAction().currentVersion().getVersionNumber() == null && StringUtils.equals(StringUtils.defaultIfBlank(_newest(pendingNumbers), "0"), StringUtils.defaultIfBlank(_migrationEngine.getFailedAction().targetVersionNumber(), _migrationEngine.getFailedAction().configuration().getVersionNumber())); // Init failed
241    }
242
243    private Map<String, Object> _plugin2json(String pluginName, List<Map<String, Object>> children)
244    {
245        Collections.sort(children, new TreeComparator());
246        
247        return Map.of(
248            "component", pluginName,
249            "expanded", true,
250            "failed", _any(children, "failed"),
251            "warning", _any(children, "warning"),
252            "type", "plugin",
253            "children", children 
254        ); 
255    }
256    
257    private Map<String, Object> _component2json(boolean isInternal, String componentId, String componentLabel, List<Map<String, Object>> children, String versionHandlerType, String versionStorageType, boolean rootComponent, String versionListId)
258    {
259        return Map.of(
260            "component", componentLabel,
261            "componentId", componentId, 
262            "comment", rootComponent ? ("Type: " + versionHandlerType + (StringUtils.equals(versionHandlerType, versionStorageType) ? "" : "/" + versionStorageType)) : "",
263            "versionListId", StringUtils.defaultString(versionListId),
264            "failed", _any(children, "failed"), 
265            "warning", _any(children, "warning"),
266            "type", rootComponent ? "component" : "container",
267            "internal", isInternal,
268            "children", children
269        );
270    }
271    
272    private Map<String, Object> _componentFailure2json(boolean isInternal, String componentId, String versionHandlerType, String versionStorageType, MigrationException ex)
273    {
274        return Map.of(
275            "component", componentId,
276            "comment", "Type: " + versionHandlerType + (StringUtils.equals(versionHandlerType, versionStorageType) ? "" : "/" + versionStorageType),
277            "errorComment", _migrationExceptionToCommentString(ex), 
278            "failed", true, 
279            "warning", false,
280            "internal", isInternal,
281            "type", "component"
282        );
283    }
284
285    private Map<String, Object> _pendingVersion2json(boolean isInternal, String componentId, String versionListId, ActionConfiguration actionConfiguration, String currentStoredVersion, String oldestStoredVersion)
286    {
287        String versionNumber = actionConfiguration != null ? actionConfiguration.getVersionNumber() : "0";
288        
289        boolean past = currentStoredVersion != null && versionNumber.compareTo(currentStoredVersion) < 0;
290        boolean beforeInitPast = past && versionNumber.compareTo(oldestStoredVersion) < 0;
291        
292        return Map.of(
293                "component", versionNumber,
294                "componentId", componentId,
295                "versionListId", versionListId,
296                "failed", false,
297                "warning", false,
298                "comment", actionConfiguration != null ? StringUtils.defaultString(actionConfiguration.getComment()) : "",
299                "instant", "",
300                "internal", isInternal,
301                "type", past ? (beforeInitPast ? "past-before" : "past-notdone") : "pending"
302            );
303    }
304    
305    private Map<String, Object> _failureVersion2json(boolean isInternal, String componentId, String versionListId)
306    {
307        return Map.of(
308            "component", StringUtils.defaultIfBlank(_migrationEngine.getFailedAction().targetVersionNumber(), _migrationEngine.getFailedAction().configuration().getVersionNumber()),
309            "componentId", componentId,
310            "versionListId", versionListId,
311            "failed", true,
312            "warning", false,
313            "comment", StringUtils.defaultString(_migrationEngine.getFailedAction().configuration().getComment()),
314            "errorComment", _migrationExceptionToCommentString(_migrationEngine.getFailedException()),
315            "instant", DateUtils.zonedDateTimeToString(ZonedDateTime.ofInstant(_migrationEngine.getFailedAction().currentVersion().getExecutionInstant(), ZoneOffset.UTC)),
316            "internal", isInternal,
317            "type", "error"
318        );
319    }
320
321    private Map<String, Object> _version2json(boolean isInternal, String componentId, String versionListId, Version version, boolean current, boolean existing)
322    {
323        return Map.of(
324            "component", StringUtils.defaultString(version.getVersionNumber()),
325            "componentId", componentId,
326            "versionListId", versionListId,
327            "comment", StringUtils.defaultString(version.getComment()),
328            "errorComment", !existing ? _i18nUtils.translate(new I18nizableText("plugin.admin", "PLUGINS_ADMIN_TOOL_MIGRATIONS_COL_COMMENT_NONEXISTING")) : "", 
329            "failed", false,
330            "warning", !existing,
331            "instant", version.getExecutionInstant() != null ? DateUtils.zonedDateTimeToString(ZonedDateTime.ofInstant(version.getExecutionInstant(), ZoneOffset.UTC)) : "",
332            "internal", isInternal,
333            "type", current ? "current" : "past-done"
334        );
335    }
336    
337    private String _newest(Set<String> s)
338    {
339        return s.size() > 0 ? s.stream().skip(s.size() - 1).findFirst().orElse(null) : null;
340    }
341    private String _oldest(Set<String> s)
342    {
343        return s.stream().findFirst().orElse(null);
344    }
345    
346    private boolean _isFailedAction(String versionListId)
347    {
348        return _migrationEngine.getFailedAction() != null
349            && StringUtils.equals(versionListId, _migrationEngine.getFailedAction().versionListId());
350    }
351    
352    private String _migrationExceptionToCommentString(MigrationException ex)
353    {
354        return ex != null ? ex.getFailureMessage().replaceAll("<", "&lt;").replaceAll("\n", "<br/>") : "";
355    }
356    
357    @SuppressWarnings({"cast", "unchecked"})
358    private boolean _any(List<Map<String, Object>> children, String key)
359    {
360        for (Map<String, Object> child : children)
361        {
362            if (child.get(key) == Boolean.TRUE)
363            {
364                return true;
365            }
366            if (child.get("children") instanceof List granChildren
367                && _any((List<Map<String, Object>>) granChildren, key))
368            {
369                return true;
370            }
371        }
372        
373        return false;
374    }
375    
376    private final class TreeComparator implements Comparator<Map> 
377    {
378        public int compare(Map o1, Map o2)
379        {
380            return ((String) o1.get("component")).toLowerCase().compareTo(((String) o2.get("component")).toLowerCase());
381        }
382    }
383}