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