001/*
002 * (C) Copyright 2006-2016 Nuxeo SA (http://nuxeo.com/) and others.
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 * Contributors:
017 *     bstefanescu
018 *     jcarsique
019 *     Yannis JULIENNE
020 */
021package org.nuxeo.connect.update.task.update;
022
023import java.io.File;
024import java.io.FileInputStream;
025import java.io.IOException;
026import java.util.HashMap;
027import java.util.Map;
028
029import org.apache.commons.io.IOUtils;
030import org.apache.commons.logging.Log;
031import org.apache.commons.logging.LogFactory;
032
033import org.nuxeo.common.utils.FileUtils;
034import org.nuxeo.common.utils.FileVersion;
035import org.nuxeo.connect.update.PackageException;
036import org.nuxeo.connect.update.task.Task;
037import org.nuxeo.connect.update.task.update.JarUtils.Match;
038
039/**
040 * Manage jar versions update.
041 * <p>
042 * To manipulate the jar version registry you need to create a new instance of this class.
043 * <p>
044 * If you want to modify the registry then you may want to synchronize the entire update process. This is how is done in
045 * the Task run method.
046 * <p>
047 * Only reading the registry is thread safe.
048 * <p>
049 * TODO backup md5 are not really used since we rely on versions - we can remove md5
050 *
051 * @author <a href="mailto:bs@nuxeo.com">Bogdan Stefanescu</a>
052 */
053public class UpdateManager {
054
055    private static final Log log = LogFactory.getLog(UpdateManager.class);
056
057    public static final String STUDIO_SNAPSHOT_VERSION = "0.0.0-SNAPSHOT";
058
059    protected Task task;
060
061    protected Map<String, Entry> registry;
062
063    protected File file;
064
065    protected File backupRoot;
066
067    protected File serverRoot;
068
069    public UpdateManager(File serverRoot, File regFile) {
070        file = regFile;
071        backupRoot = new File(file.getParentFile(), "backup");
072        backupRoot.mkdirs();
073        this.serverRoot = serverRoot;
074    }
075
076    public File getServerRoot() {
077        return serverRoot;
078    }
079
080    public File getBackupRoot() {
081        return backupRoot;
082    }
083
084    public Task getTask() {
085        return task;
086    }
087
088    public Map<String, Entry> getRegistry() {
089        return registry;
090    }
091
092    public synchronized void load() throws PackageException {
093        if (!file.isFile()) {
094            registry = new HashMap<>();
095            return;
096        }
097        try {
098            registry = RegistrySerializer.load(file);
099        } catch (PackageException e) {
100            throw e;
101        } catch (IOException e) {
102            throw new PackageException("IOException while trying to load the registry", e);
103        }
104    }
105
106    public synchronized void store() throws PackageException {
107        try {
108            RegistrySerializer.store(registry, file);
109        } catch (IOException e) {
110            throw new PackageException("IOException while trying to write the registry", e);
111        }
112    }
113
114    public String getVersionPath(UpdateOptions opt) {
115        return getServerRelativePath(opt.getTargetFile());
116    }
117
118    public String getKey(UpdateOptions opt) {
119        String key = getServerRelativePath(opt.getTargetDir());
120        if (key.endsWith(File.separator)) {
121            key = key.concat(opt.nameWithoutVersion);
122        } else {
123            key = key.concat(File.separator).concat(opt.nameWithoutVersion);
124        }
125        return key;
126    }
127
128    public RollbackOptions update(UpdateOptions opt) throws PackageException {
129        String key = getKey(opt);
130        Entry entry = registry.get(key);
131        if (entry == null) { // New Entry
132            entry = createEntry(key);
133        } else if (!entry.hasBaseVersion() && entry.getLastVersion(false) == null) {
134            // Existing Entry but all versions provided only by packages with upgradeOnly => check missing base
135            // version...
136            if (createBaseVersion(entry)) {
137                log.warn("Registry repaired: JAR introduced without corresponding entry in the registry (copy task?) : "
138                        + key);
139            }
140        }
141        Version v = entry.getVersion(opt.version);
142        boolean newVersion = v == null;
143        if (v == null) {
144            v = entry.addVersion(new Version(opt.getVersion()));
145            v.setPath(getVersionPath(opt));
146        }
147        v.addPackage(opt);
148        if (newVersion || opt.isSnapshotVersion()) {
149            // Snapshots "backup" are overwritten by new versions
150            backupFile(opt.getFile(), v.getPath());
151        }
152
153        Match<File> currentJar = findInstalledJar(key);
154        UpdateOptions optToUpdate = shouldUpdate(key, opt, currentJar);
155        if (optToUpdate != null) {
156            File currentFile = currentJar != null ? currentJar.object : null;
157            doUpdate(currentFile, optToUpdate);
158        }
159
160        return new RollbackOptions(key, opt);
161    }
162
163    /**
164     * Look if an update is required, taking into account the given UpdateOptions, the currently installed JAR and the
165     * other available JARs.
166     *
167     * @since 5.7
168     * @return null if no update required, else the right UpdateOptions
169     */
170    protected UpdateOptions shouldUpdate(String key, UpdateOptions opt, Match<File> currentJar)
171            throws PackageException {
172        log.debug("Look for updating " + opt.file.getName());
173        if (opt.upgradeOnly && currentJar == null) {
174            log.debug("=> don't update (upgradeOnly)");
175            return null;
176        }
177        if (opt.allowDowngrade) {
178            log.debug("=> update (allowDowngrade)");
179            return opt;
180        }
181
182        // !opt.allowDowngrade && (!opt.upgradeOnly || currentJar != null) ...
183        UpdateOptions optToUpdate = null;
184        Version packageVersion = registry.get(key).getVersion(opt.version);
185        Version greatestVersion = registry.get(key).getGreatestVersion();
186        if (packageVersion.equals(greatestVersion)) {
187            optToUpdate = opt;
188        } else { // we'll use the greatest available JAR instead
189            optToUpdate = UpdateOptions.newInstance(opt.pkgId, new File(backupRoot, greatestVersion.path),
190                    opt.targetDir);
191        }
192        FileVersion greatestFileVersion = greatestVersion.getFileVersion();
193        if (currentJar == null) {
194            log.debug("=> update (new) " + greatestFileVersion);
195            return optToUpdate;
196        }
197
198        // !opt.allowDowngrade && currentJar != null ...
199        FileVersion currentVersion = new FileVersion(currentJar.version);
200        log.debug("=> comparing " + greatestFileVersion + " with " + currentVersion);
201        if (greatestFileVersion.greaterThan(currentVersion)) {
202            log.debug("=> update (greater)");
203            return optToUpdate;
204        } else if (greatestFileVersion.equals(currentVersion)) {
205            if (greatestFileVersion.isSnapshot()) {
206                FileInputStream is1 = null;
207                FileInputStream is2 = null;
208                try {
209                    is1 = new FileInputStream(new File(backupRoot, greatestVersion.path));
210                    is2 = new FileInputStream(currentJar.object);
211                    if (IOUtils.contentEquals(is1, is2)) {
212                        log.debug("=> don't update (already installed)");
213                        return null;
214                    } else {
215                        log.debug("=> update (newer SNAPSHOT)");
216                        return optToUpdate;
217                    }
218                } catch (IOException e) {
219                    throw new PackageException(e);
220                } finally {
221                    IOUtils.closeQuietly(is1);
222                    IOUtils.closeQuietly(is2);
223                }
224            } else {
225                log.debug("=> don't update (already installed)");
226                return null;
227            }
228        } else {
229            log.debug("Don't update (lower)");
230            return null;
231        }
232    }
233
234    /**
235     * Ugly method to know what file is going to be deleted before it is, so that it can be undeployed for hotreload.
236     * <p>
237     * FIXME: will only handle simple cases for now (ignores version, etc...), e.g only tested with the main Studio
238     * jars. Should use version from RollbackOptions
239     *
240     * @since 5.6
241     */
242    public File getRollbackTarget(RollbackOptions opt) {
243        String entryKey = opt.getKey();
244        Match<File> m = findInstalledJar(entryKey);
245        if (m != null) {
246            return m.object;
247        } else {
248            log.trace("Could not find jar with key: " + entryKey);
249            return null;
250        }
251    }
252
253    /**
254     * Perform a rollback.
255     * <p>
256     * TODO the deleteOnExit is inherited from the current rollback command ... may be it should be read from the
257     * version that is rollbacked. (deleteOnExit should be an attribute of the entry not of the version)
258     */
259    public void rollback(RollbackOptions opt) throws PackageException {
260        Entry entry = registry.get(opt.getKey());
261        if (entry == null) {
262            log.debug("Key not found in registry for: " + opt);
263            return;
264        }
265        Version v = entry.getVersion(opt.getVersion());
266        if (v == null) {
267            // allow empty version for Studio snapshot...
268            v = entry.getVersion(STUDIO_SNAPSHOT_VERSION);
269        }
270        if (v == null) {
271            log.debug("Version not found in registry for: " + opt);
272            return;
273        }
274        // store current last version
275        Version lastVersion = entry.getLastVersion();
276        boolean removeBackup = false;
277
278        v.removePackage(opt.getPackageId());
279        if (!v.hasPackages()) {
280            // remove this version
281            entry.removeVersion(v);
282            removeBackup = true;
283        }
284
285        // Include upgradeOnly versions only if there is a base version or a non-upgradeOnly version
286        boolean includeUpgradeOnly = entry.hasBaseVersion() || entry.getLastVersion(false) != null;
287        Version versionToRollback = entry.getLastVersion(includeUpgradeOnly);
288        if (versionToRollback == null) {
289            // no more versions - remove entry and rollback base version if any
290            if (entry.isEmpty()) {
291                registry.remove(entry.getKey());
292            }
293            rollbackBaseVersion(entry, opt);
294        } else if (versionToRollback != lastVersion) {
295            // we removed the currently installed version so we need to rollback
296            rollbackVersion(entry, versionToRollback, opt);
297        } else {
298            // handle jars that were blocked using allowDowngrade or
299            // upgradeOnly
300            Match<File> m = findInstalledJar(opt.getKey());
301            if (m != null) {
302                if (entry.getVersion(m.version) == null) {
303                    // the currently installed version is no more in registry
304                    // should be the one we just removed
305                    Version greatest = entry.getGreatestVersion();
306                    if (greatest != null) {
307                        // rollback to the greatest version
308                        rollbackVersion(entry, greatest, opt);
309                    }
310                }
311            }
312        }
313
314        if (removeBackup) {
315            removeBackup(v.getPath());
316        }
317
318    }
319
320    protected void rollbackBaseVersion(Entry entry, RollbackOptions opt) throws PackageException {
321        Version base = entry.getBaseVersion();
322        if (base != null) {
323            rollbackVersion(entry, base, opt);
324            removeBackup(base.getPath());
325        } else {
326            // simply remove the installed file if exists
327            Match<File> m = JarUtils.findJar(serverRoot, entry.getKey());
328            if (m != null) {
329                if (opt.isDeleteOnExit()) {
330                    m.object.deleteOnExit();
331                } else {
332                    m.object.delete();
333                }
334            }
335        }
336    }
337
338    protected void rollbackVersion(Entry entry, Version version, RollbackOptions opt) throws PackageException {
339        File versionFile = getBackup(version.getPath());
340        if (!versionFile.isFile()) {
341            log.warn("Could not rollback version " + version.getPath() + " since the backup file was not found");
342            return;
343        }
344        Match<File> m = findInstalledJar(entry.getKey());
345        File oldFile = m != null ? m.object : null;
346        File targetFile = getTargetFile(version.getPath());
347        if (deleteOldFile(targetFile, oldFile, opt.deleteOnExit)) {
348            copy(versionFile, targetFile);
349        }
350    }
351
352    public String getServerRelativePath(File someFile) {
353        String path;
354        String serverPath;
355        try {
356            path = someFile.getCanonicalPath();
357            serverPath = serverRoot.getCanonicalPath();
358        } catch (IOException e) {
359            log.error("Failed to get a canonical path. " + "Fall back to absolute paths...", e);
360            path = someFile.getAbsolutePath();
361            serverPath = serverRoot.getAbsolutePath();
362        }
363        if (!serverPath.endsWith(File.separator)) {
364            serverPath = serverPath.concat(File.separator);
365        }
366        if (path.startsWith(serverPath)) {
367            return path.substring(serverPath.length());
368        }
369        return path;
370    }
371
372    /**
373     * Create a new entry in the registry given the entry key. A base version will be automatically created if needed.
374     */
375    public Entry createEntry(String key) throws PackageException {
376        Entry entry = new Entry(key);
377        createBaseVersion(entry);
378        registry.put(key, entry);
379        return entry;
380    }
381
382    /**
383     * Create a base version for the given entry if needed.
384     *
385     * @return true if a base version was actually created, false otherwise
386     * @since 1.4.26
387     */
388    public boolean createBaseVersion(Entry entry) throws PackageException {
389        Match<File> m = JarUtils.findJar(serverRoot, entry.getKey());
390        if (m != null) {
391            String path = getServerRelativePath(m.object);
392            Version base = new Version(m.version);
393            base.setPath(path);
394            entry.setBaseVersion(base);
395            backupFile(m.object, path);
396            return true;
397        }
398        return false;
399    }
400
401    /**
402     * Backup the given file in the registry storage. Backup is not a backup performed on removed files: it is rather
403     * like a uniformed storage of all libraries potentially installed by packages (whereas each package can have its
404     * own directory structure). So SNAPSHOT will always be overwritten. Backup of original SNAPSHOT can be found in the
405     * backup directory of the stored package.
406     */
407    protected void backupFile(File fileToBackup, String path) throws PackageException {
408        try {
409            File dst = new File(backupRoot, path);
410            copy(fileToBackup, dst);
411            // String md5 = IOUtils.createMd5(dst);
412            // FileUtils.writeFile(new
413            // File(dst.getAbsolutePath().concat(".md5")),
414            // md5);
415        } catch (PackageException e) {
416            throw new PackageException("Failed to backup file: " + path, e);
417        }
418    }
419
420    /**
421     * Remove the backup given its path. This is also removing the md5.
422     */
423    protected void removeBackup(String path) {
424        File dst = new File(backupRoot, path);
425        if (!dst.delete()) {
426            dst.deleteOnExit();
427        }
428    }
429
430    protected File getBackup(String path) {
431        return new File(backupRoot, path);
432    }
433
434    protected File getTargetFile(String path) {
435        return new File(serverRoot, path);
436    }
437
438    protected void copy(File src, File dst) throws PackageException {
439        try {
440            dst.getParentFile().mkdirs();
441            File tmp = new File(dst.getPath() + ".tmp");
442            // File tmp = new File(dst.getParentFile(), dst.getName() +
443            // ".tmp");
444            FileUtils.copy(src, tmp);
445            if (!tmp.renameTo(dst)) {
446                tmp.delete();
447                FileUtils.copy(src, dst);
448            }
449        } catch (IOException e) {
450            throw new PackageException("Failed to copy file: " + src + " to " + dst, e);
451        }
452    }
453
454    protected boolean deleteOldFile(File targetFile, File oldFile, boolean deleteOnExit) {
455        if (oldFile == null || !oldFile.exists()) {
456            return false;
457        }
458        if (deleteOnExit) {
459            if (targetFile.getName().equals(oldFile.getName())) {
460                oldFile.delete();
461            } else {
462                oldFile.deleteOnExit();
463            }
464        } else {
465            oldFile.delete();
466        }
467        return true;
468    }
469
470    public Match<File> findInstalledJar(String key) {
471        return JarUtils.findJar(serverRoot, key);
472    }
473
474    public Match<File> findBackupJar(String key) {
475        return JarUtils.findJar(backupRoot, key);
476    }
477
478    /**
479     * Update oldFile with file pointed by opt
480     */
481    public void doUpdate(File oldFile, UpdateOptions opt) throws PackageException {
482        deleteOldFile(opt.targetFile, oldFile, opt.deleteOnExit);
483        copy(opt.file, opt.targetFile);
484        log.trace("Updated " + opt.targetFile);
485    }
486
487}