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<String, Entry>();
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     * @param key
169     * @param opt
170     * @param currentJar
171     * @return null if no update required, else the right UpdateOptions
172     * @throws PackageException
173     */
174    protected UpdateOptions shouldUpdate(String key, UpdateOptions opt, Match<File> currentJar)
175            throws PackageException {
176        log.debug("Look for updating " + opt.file.getName());
177        if (opt.upgradeOnly && currentJar == null) {
178            log.debug("=> don't update (upgradeOnly)");
179            return null;
180        }
181        if (opt.allowDowngrade) {
182            log.debug("=> update (allowDowngrade)");
183            return opt;
184        }
185
186        // !opt.allowDowngrade && (!opt.upgradeOnly || currentJar != null) ...
187        UpdateOptions optToUpdate = null;
188        Version packageVersion = registry.get(key).getVersion(opt.version);
189        Version greatestVersion = registry.get(key).getGreatestVersion();
190        if (packageVersion.equals(greatestVersion)) {
191            optToUpdate = opt;
192        } else { // we'll use the greatest available JAR instead
193            optToUpdate = UpdateOptions.newInstance(opt.pkgId, new File(backupRoot, greatestVersion.path),
194                    opt.targetDir);
195        }
196        FileVersion greatestFileVersion = greatestVersion.getFileVersion();
197        if (currentJar == null) {
198            log.debug("=> update (new) " + greatestFileVersion);
199            return optToUpdate;
200        }
201
202        // !opt.allowDowngrade && currentJar != null ...
203        FileVersion currentVersion = new FileVersion(currentJar.version);
204        log.debug("=> comparing " + greatestFileVersion + " with " + currentVersion);
205        if (greatestFileVersion.greaterThan(currentVersion)) {
206            log.debug("=> update (greater)");
207            return optToUpdate;
208        } else if (greatestFileVersion.equals(currentVersion)) {
209            if (greatestFileVersion.isSnapshot()) {
210                FileInputStream is1 = null;
211                FileInputStream is2 = null;
212                try {
213                    is1 = new FileInputStream(new File(backupRoot, greatestVersion.path));
214                    is2 = new FileInputStream(currentJar.object);
215                    if (IOUtils.contentEquals(is1, is2)) {
216                        log.debug("=> don't update (already installed)");
217                        return null;
218                    } else {
219                        log.debug("=> update (newer SNAPSHOT)");
220                        return optToUpdate;
221                    }
222                } catch (IOException e) {
223                    throw new PackageException(e);
224                } finally {
225                    IOUtils.closeQuietly(is1);
226                    IOUtils.closeQuietly(is2);
227                }
228            } else {
229                log.debug("=> don't update (already installed)");
230                return null;
231            }
232        } else {
233            log.debug("Don't update (lower)");
234            return null;
235        }
236    }
237
238    /**
239     * Ugly method to know what file is going to be deleted before it is, so that it can be undeployed for hotreload.
240     * <p>
241     * FIXME: will only handle simple cases for now (ignores version, etc...), e.g only tested with the main Studio
242     * jars. Should use version from RollbackOptions
243     *
244     * @since 5.6
245     */
246    public File getRollbackTarget(RollbackOptions opt) {
247        String entryKey = opt.getKey();
248        Match<File> m = findInstalledJar(entryKey);
249        if (m != null) {
250            return m.object;
251        } else {
252            log.trace("Could not find jar with key: " + entryKey);
253            return null;
254        }
255    }
256
257    /**
258     * Perform a rollback.
259     * <p>
260     * TODO the deleteOnExit is inherited from the current rollback command ... may be it should be read from the
261     * version that is rollbacked. (deleteOnExit should be an attribute of the entry not of the version)
262     *
263     * @param opt
264     * @throws PackageException
265     */
266    public void rollback(RollbackOptions opt) throws PackageException {
267        Entry entry = registry.get(opt.getKey());
268        if (entry == null) {
269            log.debug("Key not found in registry for: " + opt);
270            return;
271        }
272        Version v = entry.getVersion(opt.getVersion());
273        if (v == null) {
274            // allow empty version for Studio snapshot...
275            v = entry.getVersion(STUDIO_SNAPSHOT_VERSION);
276        }
277        if (v == null) {
278            log.debug("Version not found in registry for: " + opt);
279            return;
280        }
281        // store current last version
282        Version lastVersion = entry.getLastVersion();
283        boolean removeBackup = false;
284
285        v.removePackage(opt.getPackageId());
286        if (!v.hasPackages()) {
287            // remove this version
288            entry.removeVersion(v);
289            removeBackup = true;
290        }
291
292        // Include upgradeOnly versions only if there is a base version or a non-upgradeOnly version
293        boolean includeUpgradeOnly = entry.hasBaseVersion() || entry.getLastVersion(false) != null;
294        Version versionToRollback = entry.getLastVersion(includeUpgradeOnly);
295        if (versionToRollback == null) {
296            // no more versions - remove entry and rollback base version if any
297            if (entry.isEmpty()) {
298                registry.remove(entry.getKey());
299            }
300            rollbackBaseVersion(entry, opt);
301        } else if (versionToRollback != lastVersion) {
302            // we removed the currently installed version so we need to rollback
303            rollbackVersion(entry, versionToRollback, opt);
304        } else {
305            // handle jars that were blocked using allowDowngrade or
306            // upgradeOnly
307            Match<File> m = findInstalledJar(opt.getKey());
308            if (m != null) {
309                if (entry.getVersion(m.version) == null) {
310                    // the currently installed version is no more in registry
311                    // should be the one we just removed
312                    Version greatest = entry.getGreatestVersion();
313                    if (greatest != null) {
314                        // rollback to the greatest version
315                        rollbackVersion(entry, greatest, opt);
316                    }
317                }
318            }
319        }
320
321        if (removeBackup) {
322            removeBackup(v.getPath());
323        }
324
325    }
326
327    protected void rollbackBaseVersion(Entry entry, RollbackOptions opt) throws PackageException {
328        Version base = entry.getBaseVersion();
329        if (base != null) {
330            rollbackVersion(entry, base, opt);
331            removeBackup(base.getPath());
332        } else {
333            // simply remove the installed file if exists
334            Match<File> m = JarUtils.findJar(serverRoot, entry.getKey());
335            if (m != null) {
336                if (opt.isDeleteOnExit()) {
337                    m.object.deleteOnExit();
338                } else {
339                    m.object.delete();
340                }
341            }
342        }
343    }
344
345    protected void rollbackVersion(Entry entry, Version version, RollbackOptions opt) throws PackageException {
346        File versionFile = getBackup(version.getPath());
347        if (!versionFile.isFile()) {
348            log.warn("Could not rollback version " + version.getPath() + " since the backup file was not found");
349            return;
350        }
351        Match<File> m = findInstalledJar(entry.getKey());
352        File oldFile = m != null ? m.object : null;
353        File targetFile = getTargetFile(version.getPath());
354        if (deleteOldFile(targetFile, oldFile, opt.deleteOnExit)) {
355            copy(versionFile, targetFile);
356        }
357    }
358
359    public String getServerRelativePath(File someFile) {
360        String path;
361        String serverPath;
362        try {
363            path = someFile.getCanonicalPath();
364            serverPath = serverRoot.getCanonicalPath();
365        } catch (IOException e) {
366            log.error("Failed to get a canonical path. " + "Fall back to absolute paths...", e);
367            path = someFile.getAbsolutePath();
368            serverPath = serverRoot.getAbsolutePath();
369        }
370        if (!serverPath.endsWith(File.separator)) {
371            serverPath = serverPath.concat(File.separator);
372        }
373        if (path.startsWith(serverPath)) {
374            return path.substring(serverPath.length());
375        }
376        return path;
377    }
378
379    /**
380     * Create a new entry in the registry given the entry key. A base version will be automatically created if needed.
381     *
382     * @param key
383     * @throws PackageException
384     */
385    public Entry createEntry(String key) throws PackageException {
386        Entry entry = new Entry(key);
387        createBaseVersion(entry);
388        registry.put(key, entry);
389        return entry;
390    }
391
392    /**
393     * Create a base version for the given entry if needed.
394     *
395     * @param entry
396     * @return true if a base version was actually created, false otherwise
397     * @throws PackageException
398     * @since 1.4.26
399     */
400    public boolean createBaseVersion(Entry entry) throws PackageException {
401        Match<File> m = JarUtils.findJar(serverRoot, entry.getKey());
402        if (m != null) {
403            String path = getServerRelativePath(m.object);
404            Version base = new Version(m.version);
405            base.setPath(path);
406            entry.setBaseVersion(base);
407            backupFile(m.object, path);
408            return true;
409        }
410        return false;
411    }
412
413    /**
414     * Backup the given file in the registry storage. Backup is not a backup performed on removed files: it is rather
415     * like a uniformed storage of all libraries potentially installed by packages (whereas each package can have its
416     * own directory structure). So SNAPSHOT will always be overwritten. Backup of original SNAPSHOT can be found in the
417     * backup directory of the stored package.
418     *
419     * @param fileToBackup
420     * @param path
421     */
422    protected void backupFile(File fileToBackup, String path) throws PackageException {
423        try {
424            File dst = new File(backupRoot, path);
425            copy(fileToBackup, dst);
426            // String md5 = IOUtils.createMd5(dst);
427            // FileUtils.writeFile(new
428            // File(dst.getAbsolutePath().concat(".md5")),
429            // md5);
430        } catch (PackageException e) {
431            throw new PackageException("Failed to backup file: " + path, e);
432        }
433    }
434
435    /**
436     * Remove the backup given its path. This is also removing the md5.
437     *
438     * @param path
439     */
440    protected void removeBackup(String path) {
441        File dst = new File(backupRoot, path);
442        if (!dst.delete()) {
443            dst.deleteOnExit();
444        }
445    }
446
447    protected File getBackup(String path) {
448        return new File(backupRoot, path);
449    }
450
451    protected File getTargetFile(String path) {
452        return new File(serverRoot, path);
453    }
454
455    protected void copy(File src, File dst) throws PackageException {
456        try {
457            dst.getParentFile().mkdirs();
458            File tmp = new File(dst.getPath() + ".tmp");
459            // File tmp = new File(dst.getParentFile(), dst.getName() +
460            // ".tmp");
461            FileUtils.copy(src, tmp);
462            if (!tmp.renameTo(dst)) {
463                tmp.delete();
464                FileUtils.copy(src, dst);
465            }
466        } catch (IOException e) {
467            throw new PackageException("Failed to copy file: " + src + " to " + dst, e);
468        }
469    }
470
471    protected boolean deleteOldFile(File targetFile, File oldFile, boolean deleteOnExit) {
472        if (oldFile == null || !oldFile.exists()) {
473            return false;
474        }
475        if (deleteOnExit) {
476            if (targetFile.getName().equals(oldFile.getName())) {
477                oldFile.delete();
478            } else {
479                oldFile.deleteOnExit();
480            }
481        } else {
482            oldFile.delete();
483        }
484        return true;
485    }
486
487    public Match<File> findInstalledJar(String key) {
488        return JarUtils.findJar(serverRoot, key);
489    }
490
491    public Match<File> findBackupJar(String key) {
492        return JarUtils.findJar(backupRoot, key);
493    }
494
495    /**
496     * Update oldFile with file pointed by opt
497     *
498     * @throws PackageException
499     */
500    public void doUpdate(File oldFile, UpdateOptions opt) throws PackageException {
501        deleteOldFile(opt.targetFile, oldFile, opt.deleteOnExit);
502        copy(opt.file, opt.targetFile);
503        log.trace("Updated " + opt.targetFile);
504    }
505
506}