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.standalone;
022
023import static java.nio.charset.StandardCharsets.UTF_8;
024
025import java.io.File;
026import java.io.IOException;
027import java.nio.file.Files;
028import java.nio.file.Path;
029import java.nio.file.attribute.BasicFileAttributes;
030import java.nio.file.attribute.FileTime;
031import java.security.SecureRandom;
032import java.util.ArrayList;
033import java.util.HashMap;
034import java.util.List;
035import java.util.Map;
036import java.util.Map.Entry;
037import java.util.Random;
038
039import org.apache.commons.io.FileUtils;
040import org.apache.commons.logging.Log;
041import org.apache.commons.logging.LogFactory;
042import org.nuxeo.common.Environment;
043import org.nuxeo.common.utils.ZipUtils;
044import org.nuxeo.connect.update.AlreadyExistsPackageException;
045import org.nuxeo.connect.update.LocalPackage;
046import org.nuxeo.connect.update.PackageException;
047import org.nuxeo.connect.update.PackageState;
048import org.nuxeo.connect.update.PackageUpdateService;
049
050/**
051 * The file {@code nxserver/data/packages/.packages} stores the state of all local features.
052 * <p>
053 * Each local package have a corresponding directory in {@code nxserver/data/features/store} which is named:
054 * {@code <package_uid>} ("id-version")
055 *
056 * @author <a href="mailto:bs@nuxeo.com">Bogdan Stefanescu</a>
057 */
058public class PackagePersistence {
059
060    private static final Log log = LogFactory.getLog(PackagePersistence.class);
061
062    protected final File root;
063
064    protected final File store;
065
066    protected final File temp;
067
068    protected static final Random RANDOM = new SecureRandom();
069
070    protected Map<String, PackageState> states;
071
072    private PackageUpdateService service;
073
074    public PackagePersistence(PackageUpdateService pus) throws IOException {
075        Environment env = Environment.getDefault();
076        root = env.getPath(Environment.NUXEO_MP_DIR, Environment.DEFAULT_MP_DIR);
077        if (!root.isAbsolute()) {
078            throw new RuntimeException();
079        }
080        root.mkdirs();
081        store = new File(root, "store");
082        store.mkdirs();
083        temp = new File(root, "tmp");
084        temp.mkdirs();
085        service = pus;
086        states = loadStates();
087    }
088
089    public File getRoot() {
090        return root;
091    }
092
093    /**
094     * @since 7.1
095     */
096    public File getStore() {
097        return store;
098    }
099
100    public synchronized Map<String, PackageState> getStates() {
101        return new HashMap<>(states);
102    }
103
104    protected Map<String, PackageState> loadStates() throws IOException {
105        Map<String, PackageState> result = new HashMap<>();
106        File file = new File(root, ".packages");
107        if (file.isFile()) {
108            List<String> lines = FileUtils.readLines(file, UTF_8);
109            for (String line : lines) {
110                line = line.trim();
111                if (line.length() == 0 || line.startsWith("#")) {
112                    continue;
113                }
114                int i = line.indexOf('=');
115                String pkgId = line.substring(0, i).trim();
116                String value = line.substring(i + 1).trim();
117                PackageState state = PackageState.getByLabel(value);
118                if (state == PackageState.UNKNOWN) {
119                    try {
120                        // Kept for backward compliance with int instead of enum
121                        state = PackageState.getByValue(value);
122                    } catch (NumberFormatException e) {
123                        // Set as REMOTE if undefined/unreadable
124                        state = PackageState.REMOTE;
125                    }
126                }
127                result.put(pkgId, state);
128            }
129        }
130        return result;
131    }
132
133    protected void writeStates() throws IOException {
134        StringBuilder buf = new StringBuilder();
135        for (Entry<String, PackageState> entry : states.entrySet()) {
136            buf.append(entry.getKey()).append('=').append(entry.getValue()).append("\n");
137        }
138        File file = new File(root, ".packages");
139        FileUtils.writeStringToFile(file, buf.toString(), UTF_8);
140    }
141
142    public LocalPackage getPackage(String id) throws PackageException {
143        File file = new File(store, id);
144        if (file.isDirectory()) {
145            return new LocalPackageImpl(file, getState(id), service);
146        }
147        return null;
148    }
149
150    public synchronized LocalPackage addPackage(File file) throws PackageException {
151        if (file.isDirectory()) {
152            return addPackageFromDir(file);
153        } else if (file.isFile()) {
154            File tmp = newTempDir(file.getName());
155            try {
156                ZipUtils.unzip(file, tmp);
157                return addPackageFromDir(tmp);
158            } catch (IOException e) {
159                throw new PackageException("Failed to unzip package: " + file.getName());
160            } finally {
161                // cleanup tmp if exists
162                org.apache.commons.io.FileUtils.deleteQuietly(tmp);
163            }
164        } else {
165            throw new PackageException("Not found: " + file);
166        }
167    }
168
169    /**
170     * Add unzipped packaged to local cache. It replaces SNAPSHOT packages if not installed
171     *
172     * @throws PackageException
173     * @throws AlreadyExistsPackageException If not replacing a SNAPSHOT or if the existing package is installed
174     */
175    protected LocalPackage addPackageFromDir(File file) throws PackageException {
176        LocalPackageImpl pkg = new LocalPackageImpl(file, PackageState.DOWNLOADED, service);
177        File dir = null;
178        try {
179            dir = new File(store, pkg.getId());
180            if (dir.exists()) {
181                LocalPackage oldpkg = getPackage(pkg.getId());
182                if (!pkg.getVersion().isSnapshot()) {
183                    throw new AlreadyExistsPackageException("Package " + pkg.getId() + " already exists");
184                }
185                if (oldpkg.getPackageState().isInstalled()) {
186                    throw new AlreadyExistsPackageException("Package " + pkg.getId() + " is already installed");
187                }
188                log.info(String.format("Replacement of %s in local cache...", oldpkg));
189                org.apache.commons.io.FileUtils.deleteQuietly(dir);
190            }
191            org.apache.commons.io.FileUtils.copyDirectory(file, dir);
192            pkg.getData().setRoot(dir);
193            updateState(pkg.getId(), pkg.state);
194            return pkg;
195        } catch (IOException e) {
196            throw new PackageException(String.format("Failed to move %s to %s", file, dir), e);
197        }
198    }
199
200    public synchronized PackageState getState(String packageId) {
201        PackageState state = states.get(packageId);
202        if (state == null) {
203            return PackageState.REMOTE;
204        }
205        return state;
206    }
207
208    /**
209     * Get the local package having the given name and which is in either one of the following states:
210     * <ul>
211     * <li>{@link PackageState#INSTALLING}
212     * <li>{@link PackageState#INSTALLED}
213     * <li>{@link PackageState#STARTED}
214     * </ul>
215     */
216    public LocalPackage getActivePackage(String name) throws PackageException {
217        String pkgId = getActivePackageId(name);
218        if (pkgId == null) {
219            return null;
220        }
221        return getPackage(pkgId);
222    }
223
224    public synchronized String getActivePackageId(String name) throws PackageException {
225        for (Entry<String, PackageState> entry : states.entrySet()) {
226            String pkgId = entry.getKey();
227            if (pkgId.startsWith(name) && entry.getValue().isInstalled() && getPackage(pkgId).getName().equals(name)) {
228                return pkgId;
229            }
230        }
231        return null;
232    }
233
234    public synchronized List<LocalPackage> getPackages() throws PackageException {
235        File[] list = store.listFiles();
236        if (list != null) {
237            List<LocalPackage> pkgs = new ArrayList<>(list.length);
238            for (File file : list) {
239                if (!file.isDirectory()) {
240                    log.warn("Ignoring file '" + file.getName() + "' in package store");
241                    continue;
242                }
243                pkgs.add(new LocalPackageImpl(file, getState(file.getName()), service));
244            }
245            return pkgs;
246        }
247        return new ArrayList<>();
248    }
249
250    public synchronized void removePackage(String id) throws PackageException {
251        states.remove(id);
252        try {
253            writeStates();
254        } catch (IOException e) {
255            throw new PackageException("Failed to write package states", e);
256        }
257        File file = new File(store, id);
258        org.apache.commons.io.FileUtils.deleteQuietly(file);
259    }
260
261    /**
262     * @deprecated Since 5.7. Use {@link #updateState(String, PackageState)} instead.
263     */
264    @Deprecated
265    public synchronized void updateState(String id, int state) throws PackageException {
266        states.put(id, PackageState.getByValue(state));
267        try {
268            writeStates();
269        } catch (IOException e) {
270            throw new PackageException("Failed to write package states", e);
271        }
272    }
273
274    /**
275     * @since 5.7
276     */
277    public synchronized void updateState(String id, PackageState state) throws PackageException {
278        states.put(id, state);
279        try {
280            writeStates();
281        } catch (IOException e) {
282            throw new PackageException("Failed to write package states", e);
283        }
284    }
285
286    public synchronized void reset() throws PackageException {
287        String[] keys = states.keySet().toArray(new String[states.size()]);
288        for (String key : keys) {
289            states.put(key, PackageState.DOWNLOADED);
290        }
291        try {
292            writeStates();
293        } catch (IOException e) {
294            throw new PackageException("Failed to write package states", e);
295        }
296    }
297
298    protected File newTempDir(String id) {
299        File tmp;
300        synchronized (temp) {
301            do {
302                tmp = new File(temp, id + "-" + RANDOM.nextInt());
303            } while (tmp.exists());
304            tmp.mkdirs();
305        }
306        return tmp;
307    }
308
309    /**
310     * @since 5.8
311     */
312    public FileTime getInstallDate(String id) {
313        File file = new File(store, id);
314        if (file.isDirectory()) {
315            Path path = file.toPath();
316            try {
317                FileTime lastModifiedTime = Files.readAttributes(path, BasicFileAttributes.class).lastModifiedTime();
318                return lastModifiedTime;
319            } catch (IOException e) {
320                log.error(e);
321            }
322        }
323        return null;
324    }
325}