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