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