001package co.codewizards.cloudstore.updater;
002
003import static co.codewizards.cloudstore.core.io.StreamUtil.*;
004import static co.codewizards.cloudstore.core.oio.OioFileFactory.*;
005import static co.codewizards.cloudstore.core.util.AssertUtil.*;
006import static co.codewizards.cloudstore.core.util.Util.*;
007
008import java.io.FileFilter;
009import java.io.IOException;
010import java.io.InputStream;
011import java.io.OutputStream;
012import java.lang.reflect.Constructor;
013import java.lang.reflect.InvocationTargetException;
014import java.net.URL;
015import java.util.Collection;
016import java.util.HashSet;
017import java.util.Properties;
018import java.util.Set;
019
020import org.kohsuke.args4j.CmdLineException;
021import org.kohsuke.args4j.CmdLineParser;
022import org.kohsuke.args4j.Option;
023import org.slf4j.Logger;
024import org.slf4j.LoggerFactory;
025
026import ch.qos.logback.classic.LoggerContext;
027import ch.qos.logback.classic.joran.JoranConfigurator;
028import ch.qos.logback.core.joran.spi.JoranException;
029import ch.qos.logback.core.util.StatusPrinter;
030import co.codewizards.cloudstore.core.appid.AppId;
031import co.codewizards.cloudstore.core.appid.AppIdRegistry;
032import co.codewizards.cloudstore.core.config.ConfigDir;
033import co.codewizards.cloudstore.core.io.LockFile;
034import co.codewizards.cloudstore.core.io.LockFileFactory;
035import co.codewizards.cloudstore.core.io.TimeoutException;
036import co.codewizards.cloudstore.core.oio.File;
037import co.codewizards.cloudstore.core.updater.CloudStoreUpdaterCore;
038import co.codewizards.cloudstore.core.util.AssertUtil;
039import co.codewizards.cloudstore.core.util.IOUtil;
040
041public class CloudStoreUpdater extends CloudStoreUpdaterCore {
042        private static final Logger logger = LoggerFactory.getLogger(CloudStoreUpdater.class);
043        private static final AppId appId = AppIdRegistry.getInstance().getAppIdOrFail();
044
045        private static Class<? extends CloudStoreUpdater> cloudStoreUpdaterClass = CloudStoreUpdater.class;
046
047        private final String[] args;
048        private boolean throwException = true;
049
050        @Option(name="-installationDir", required=true, usage="Base-directory of the installation containing the 'bin' directory as well as the 'installation.properties' file - e.g. '/opt/cloudstore'. The installation in this directory will be updated.")
051        private String installationDir;
052        private File installationDirFile;
053
054        private Properties remoteUpdateProperties;
055        private File tempDownloadDir;
056
057        private File localServerRunningFile;
058        private LockFile localServerRunningLockFile;
059        private File localServerStopFile;
060
061        public static void main(final String[] args) throws Exception {
062                initLogging();
063                try {
064                        final int programExitStatus = createCloudStoreUpdater(args).throwException(false).execute();
065                        System.exit(programExitStatus);
066                } catch (final Throwable x) {
067                        logger.error(x.toString(), x);
068                        System.exit(999);
069                }
070        }
071
072        protected static Constructor<? extends CloudStoreUpdater> getCloudStoreUpdaterConstructor() throws NoSuchMethodException, SecurityException {
073                final Class<? extends CloudStoreUpdater> clazz = getCloudStoreUpdaterClass();
074                final Constructor<? extends CloudStoreUpdater> constructor = clazz.getConstructor(String[].class);
075                return constructor;
076        }
077
078        protected static CloudStoreUpdater createCloudStoreUpdater(final String[] args) throws NoSuchMethodException, SecurityException, InstantiationException, IllegalAccessException, IllegalArgumentException, InvocationTargetException {
079                final Constructor<? extends CloudStoreUpdater> constructor = getCloudStoreUpdaterConstructor();
080                final CloudStoreUpdater cloudStoreUpdater = constructor.newInstance(new Object[] { args });
081                return cloudStoreUpdater;
082        }
083
084        protected static Class<? extends CloudStoreUpdater> getCloudStoreUpdaterClass() {
085                return cloudStoreUpdaterClass;
086        }
087        protected static void setCloudStoreUpdaterClass(final Class<? extends CloudStoreUpdater> cloudStoreUpdaterClass) {
088                assertNotNull(cloudStoreUpdaterClass, "cloudStoreUpdaterClass");
089                CloudStoreUpdater.cloudStoreUpdaterClass = cloudStoreUpdaterClass;
090        }
091
092        public CloudStoreUpdater(final String[] args) {
093                this.args = args;
094        }
095
096        public boolean isThrowException() {
097                return throwException;
098        }
099        public void setThrowException(final boolean throwException) {
100                this.throwException = throwException;
101        }
102        public CloudStoreUpdater throwException(final boolean throwException) {
103                setThrowException(throwException);
104                return this;
105        }
106
107        public int execute() throws Exception {
108                int programExitStatus = 1;
109                final CmdLineParser parser = new CmdLineParser(this);
110                try {
111                        parser.parseArgument(args);
112                        this.run();
113                        programExitStatus = 0;
114                } catch (final CmdLineException e) {
115                        // handling of wrong arguments
116                        programExitStatus = 2;
117                        System.err.println("Error: " + e.getMessage());
118                        System.err.println();
119                        if (throwException)
120                                throw e;
121                } catch (final Exception x) {
122                        programExitStatus = 3;
123                        logger.error(x.toString(), x);
124                        if (throwException)
125                                throw x;
126                }
127                return programExitStatus;
128        }
129
130        private static void initLogging() throws IOException, JoranException {
131                ConfigDir.getInstance().getLogDir();
132
133                final String logbackXmlName = "logback.updater.xml";
134                final File logbackXmlFile = createFile(ConfigDir.getInstance().getFile(), logbackXmlName);
135                if (!logbackXmlFile.exists()) {
136                        AppIdRegistry.getInstance().copyResourceResolvingAppId(
137                                        CloudStoreUpdater.class, logbackXmlName, logbackXmlFile);
138                }
139
140                final LoggerContext context = (LoggerContext) LoggerFactory.getILoggerFactory();
141            try {
142              final JoranConfigurator configurator = new JoranConfigurator();
143              configurator.setContext(context);
144              // Call context.reset() to clear any previous configuration, e.g. default
145              // configuration. For multi-step configuration, omit calling context.reset().
146              context.reset();
147              configurator.doConfigure(logbackXmlFile.getIoFile());
148            } catch (final JoranException je) {
149                // StatusPrinter will handle this
150                doNothing();
151            }
152            StatusPrinter.printInCaseOfErrorsOrWarnings(context);
153        }
154
155        private void run() throws Exception {
156                System.out.println(String.format("%s updater started. Downloading meta-data.", appId.getName()));
157
158                boolean restoreRenamedFiles = false;
159                try {
160                        stopLocalServer();
161                        final long localServerStoppedTimestamp = System.currentTimeMillis();
162
163                        final File downloadFile = downloadURLViaRemoteUpdateProperties("artifact[co.codewizards.cloudstore.aggregator].downloadURL");
164                        final File signatureFile = downloadURLViaRemoteUpdateProperties("artifact[co.codewizards.cloudstore.aggregator].signatureURL");
165
166                        System.out.println("Verifying PGP signature.");
167                        new PGPVerifier().verify(downloadFile, signatureFile);
168
169                        final long durationAfterLocalServerStop = System.currentTimeMillis() - localServerStoppedTimestamp;
170                        final long additionalWaitTime = 10_000L - durationAfterLocalServerStop;
171                        if (additionalWaitTime > 0L) {
172                                // We make sure, at least 10 seconds passed after the LocalServer stopped in order to make sure
173                                // the Java process really finished (this is *after* the lock is released by the running process).
174                                // In Windows, we might otherwise run into some lingering file locks.
175                                Thread.sleep(additionalWaitTime);
176                        }
177
178                        checkAvailableDiskSpace(getInstallationDir(), downloadFile.length() * 5);
179
180                        final File backupDir = getBackupDir();
181                        backupDir.mkdirs();
182                        final File backupTarGzFile = createFile(backupDir, resolve(String.format("co.codewizards.cloudstore.aggregator-${localVersion}.backup-%s.tar.gz", Long.toString(System.currentTimeMillis(), 36))));
183                        System.out.println("Creating backup: " + backupTarGzFile);
184
185                        new TarGzFile(backupTarGzFile)
186                        .fileFilter(fileFilterIgnoringBackupAndUpdaterDir)
187                        .compress(getInstallationDir());
188
189                        // Because of f***ing Windows and its insane file-locking, we first try to move all
190                        // files out of the way by renaming them. If this fails, we restore the previous
191                        // state. This way, we increase the probability that we leave a consistent state.
192                        // If a file is locked, this should fail already now, rather than later after we extracted
193                        // half of the tarball.
194                        System.out.println("Renaming files in installation directory: " + getInstallationDir());
195                        restoreRenamedFiles = true;
196                        renameFiles(getInstallationDir(), fileFilterIgnoringBackupAndUpdaterDir);
197
198                        System.out.println("Overwriting installation directory: " + getInstallationDir());
199                        final Set<File> keepFiles = new HashSet<>();
200                        keepFiles.add(getInstallationDir());
201                        populateFilesRecursively(getBackupDir(), keepFiles);
202                        populateFilesRecursively(getUpdaterDir(), keepFiles);
203
204                        new TarGzFile(downloadFile)
205                        .tarGzEntryNameConverter(new ExtractTarGzEntryNameConverter())
206                        .fileFilter(new FileFilterTrackingExtractedFiles(keepFiles))
207                        .extract(getInstallationDir());
208
209                        restoreRenamedFiles = false;
210
211                        System.out.println("Deleting old files from installation directory: " + getInstallationDir());
212                        deleteAllExcept(getInstallationDir(), keepFiles);
213                } finally {
214                        if (restoreRenamedFiles)
215                                restoreRenamedFiles(getInstallationDir());
216
217                        if (tempDownloadDir != null) {
218                                System.out.println("Deleting temporary download-directory.");
219                                IOUtil.deleteDirectoryRecursively(tempDownloadDir);
220                        }
221
222                        if (localServerRunningLockFile != null) {
223                                localServerRunningLockFile.release();
224                                localServerRunningLockFile = null;
225                        }
226                }
227                System.out.println("Update successfully done. Exiting.");
228        }
229
230        private void stopLocalServer() {
231                try {
232                        boolean localServerRunning = ! tryAcquireLocalServerRunningLockFile();
233                        if (localServerRunning) {
234                                System.out.println("LocalServer is running. Stopping it...");
235                                final File localServerStopFile = getLocalServerStopFile();
236
237                                if (localServerStopFile.exists()) {
238                                        localServerStopFile.delete();
239                                        if (localServerStopFile.exists())
240                                                logger.warn("Failed to delete file: {}", localServerStopFile);
241                                        else
242                                                System.out.println("File successfully deleted: " + localServerStopFile);
243                                }
244                                else {
245                                        System.out.println("WARNING: File does not exist (could thus not delete it): " + localServerStopFile);
246                                        logger.warn("File does not exist: {}", localServerStopFile);
247                                }
248
249                                System.out.println("Waiting for LocalServer to stop...");
250                                final long waitStartTimestamp = System.currentTimeMillis();
251                                do {
252                                        if (System.currentTimeMillis() - waitStartTimestamp > 120_000L)
253                                                throw new TimeoutException("LocalServer did not stop within timeout!");
254
255                                        localServerRunning = ! tryAcquireLocalServerRunningLockFile();
256                                } while (localServerRunning);
257
258                                System.out.println("LocalServer stopped.");
259                        }
260                } catch (Exception x) {
261                        logger.error("stopLocalServer: " + x, x);
262                        x.printStackTrace();
263                }
264        }
265
266        private File getLocalServerRunningFile() {
267                if (localServerRunningFile == null) {
268                        localServerRunningFile = createFile(ConfigDir.getInstance().getFile(), "localServerRunning.lock");
269                        try {
270                                localServerRunningFile = localServerRunningFile.getCanonicalFile();
271                        } catch (IOException x) {
272                                logger.warn("getLocalServerRunningFile: " + x, x);
273                        }
274                }
275                return localServerRunningFile;
276        }
277
278        private File getLocalServerStopFile() {
279                if (localServerStopFile == null)
280                        localServerStopFile = createFile(ConfigDir.getInstance().getFile(), "localServerRunning.deleteToStop");
281
282                return localServerStopFile;
283        }
284
285        private boolean tryAcquireLocalServerRunningLockFile() {
286                if (localServerRunningLockFile != null) {
287                        logger.warn("tryAcquireLocalServerRunningLockFile: Already acquired before!!! Skipping!");
288                        return true;
289                }
290
291                try {
292                        localServerRunningLockFile = LockFileFactory.getInstance().acquire(getLocalServerRunningFile(), 1000);
293                        return true;
294                } catch (TimeoutException x) {
295                        return false;
296                }
297        }
298
299        private void checkAvailableDiskSpace(final File dir, final long expectedRequiredSpace) throws IOException {
300                final long usableSpace = dir.getUsableSpace();
301                logger.debug("checkAvailableDiskSpace: dir='{}' dir.usableSpace='{} MiB' expectedRequiredSpace='{} MiB'",
302                                dir, usableSpace / 1024 / 1024, expectedRequiredSpace / 1024 / 1024);
303
304                if (usableSpace < expectedRequiredSpace) {
305                        final String msg = String.format("Insufficient disk space! The file system of the directory '%s' has %s MiB (%s B) available, but %s MiB (%s B) are required!",
306                                        dir, usableSpace / 1024 / 1024, usableSpace, expectedRequiredSpace / 1024 / 1024, expectedRequiredSpace);
307                        logger.error("checkAvailableDiskSpace: " + msg);
308                        throw new IOException(msg);
309                }
310        }
311
312        private static final String RENAMED_FILE_SUFFIX = ".csupdbak";
313
314        private void renameFiles(final File dir, final FileFilter fileFilter) throws IOException {
315                final File[] children = dir.listFiles(fileFilter);
316                if (children != null) {
317                        for (final File child : children) {
318                                if (child.isDirectory())
319                                        renameFiles(child, fileFilter);
320                                else {
321                                        final File newChild = createFile(dir, child.getName() + RENAMED_FILE_SUFFIX);
322                                        logger.debug("renameFiles: file='{}', newName='{}'", child, newChild.getName());
323                                        if (!child.renameTo(newChild)) {
324                                                final String msg = String.format("Failed to rename the file '%s' to '%s' (in the same directory)!", child, newChild.getName());
325                                                logger.error("renameFiles: {}", msg);
326                                                throw new IOException(msg);
327                                        }
328                                }
329                        }
330                }
331        }
332
333        private void restoreRenamedFiles(final File dir) {
334                final File[] children = dir.listFiles();
335                if (children != null) {
336                        for (final File child : children) {
337                                if (child.isDirectory())
338                                        restoreRenamedFiles(child);
339                                else if (child.getName().endsWith(RENAMED_FILE_SUFFIX)) {
340                                        final File newChild = createFile(dir, child.getName().substring(0, child.getName().length() - RENAMED_FILE_SUFFIX.length()));
341                                        logger.debug("restoreRenamedFiles: file='{}', newName='{}'", child, newChild.getName());
342                                        newChild.delete();
343                                        if (!child.renameTo(newChild))
344                                                logger.warn("restoreRenamedFiles: Failed to rename the file '{}' back to its original name '{}' (in the same directory)!", child, newChild.getName());
345                                }
346                        }
347                }
348        }
349
350        private static class FileFilterTrackingExtractedFiles implements FileFilter {
351                private final Collection<File> files;
352
353                public FileFilterTrackingExtractedFiles(final Collection<File> files) {
354                        this.files = assertNotNull(files, "files");
355                }
356
357                @Override
358                public boolean accept(final java.io.File file) {
359                        files.add(createFile(file));
360                        files.add(createFile(file.getParentFile())); // just in case the parent didn't have its own entry and was created implicitly
361                        return true;
362                }
363        }
364
365        private static class ExtractTarGzEntryNameConverter implements TarGzEntryNameConverter {
366                @Override
367                public String getEntryName(final File rootDir, final File file) { throw new UnsupportedOperationException(); }
368
369                @Override
370                public File getFile(final File rootDir, String entryName) {
371                        final String prefix1 = appId.getSimpleId() + "/";
372                        final String prefix2 = appId.getSimpleId() + "-"; // needed by subshare! it uses "subshare-server" in its server-installation
373
374                        if (entryName.startsWith(prefix1))
375                                entryName = entryName.substring(prefix1.length());
376                        else if (entryName.startsWith(prefix2)) {
377                                final int slashIndex = entryName.indexOf('/', prefix2.length());
378                                if (slashIndex >= 0)
379                                        entryName = entryName.substring(slashIndex + 1);
380                        }
381
382                        return entryName.isEmpty() ? rootDir : createFile(rootDir, entryName);
383                }
384        }
385
386        private void populateFilesRecursively(final File fileOrDir, final Set<File> files) {
387                AssertUtil.assertNotNull(fileOrDir, "fileOrDir");
388                AssertUtil.assertNotNull(files, "files");
389                files.add(fileOrDir);
390                final File[] children = fileOrDir.listFiles();
391                if (children != null) {
392                        for (final File child : children)
393                                populateFilesRecursively(child, files);
394                }
395        }
396
397        private void deleteAllExcept(final File fileOrDir, final Set<File> keepFiles) {
398                AssertUtil.assertNotNull(fileOrDir, "fileOrDir");
399                AssertUtil.assertNotNull(keepFiles, "keepFiles");
400                if (keepFiles.contains(fileOrDir)) {
401                        logger.debug("deleteAllExcept: Keeping: {}", fileOrDir);
402                        final File[] children = fileOrDir.listFiles();
403                        if (children != null) {
404                                for (final File child : children)
405                                        deleteAllExcept(child, keepFiles);
406                        }
407                }
408                else {
409                        logger.debug("deleteAllExcept: Deleting: {}", fileOrDir);
410                        IOUtil.deleteDirectoryRecursively(fileOrDir);
411                }
412        }
413
414        private File downloadURLViaRemoteUpdateProperties(final String remoteUpdatePropertiesKey) {
415                logger.debug("downloadURLViaRemoteUpdateProperties: remoteUpdatePropertiesKey='{}'", remoteUpdatePropertiesKey);
416                final String resolvedKey = resolve(remoteUpdatePropertiesKey);
417                final String urlStr = getRemoteUpdateProperties().getProperty(resolvedKey);
418                if (urlStr == null || urlStr.trim().isEmpty())
419                        throw new IllegalStateException("No value for key in remoteUpdateProperties: " + resolvedKey);
420
421                final String resolvedURLStr = resolve(urlStr);
422                logger.debug("downloadURLViaRemoteUpdateProperties: resolvedURLStr='{}'", resolvedURLStr);
423
424                final File tempDownloadDir = getTempDownloadDir();
425
426                try {
427                        System.out.println("Downloading: " + resolvedURLStr);
428                        final URL url = new URL(resolvedURLStr);
429                        final long contentLength = url.openConnection().getContentLengthLong();
430                        if (contentLength < 0)
431                                logger.warn("downloadURLViaRemoteUpdateProperties: contentLength unknown! url='{}'", url);
432                        else {
433                                logger.debug("downloadURLViaRemoteUpdateProperties: contentLength={} url='{}'", contentLength, url);
434                                checkAvailableDiskSpace(tempDownloadDir, Math.max(1024 * 1024, contentLength * 3 / 2));
435                        }
436                        int logLastPercentage = -100; // We start with this negative value, because we want the '0%' to be printed ;-)
437                        final int logStepPercentageDiff = 5;
438                        long downloadedLength = 0;
439
440                        final String path = url.getPath();
441                        final int lastSlashIndex = path.lastIndexOf('/');
442                        if (lastSlashIndex < 0)
443                                throw new IllegalStateException("No '/' found in URL?!");
444
445                        final String fileName = path.substring(lastSlashIndex + 1);
446                        final File downloadFile = createFile(tempDownloadDir, fileName);
447
448                        boolean successful = false;
449                        final InputStream in = url.openStream();
450                        try {
451                                final OutputStream out = castStream(downloadFile.createOutputStream());
452                                try {
453
454                                        final byte[] buf = new byte[65535];
455                                        int bytesRead;
456                                        while ((bytesRead = in.read(buf)) >= 0) {
457                                                out.write(buf, 0, bytesRead);
458                                                downloadedLength += bytesRead;
459
460                                                if (contentLength > 0) {
461                                                        int percentage = (int) (downloadedLength * 100 / contentLength);
462                                                        if (logStepPercentageDiff <= percentage - logLastPercentage) {
463                                                                logLastPercentage = percentage;
464                                                                System.out.printf(" ... %d%%", percentage);
465                                                        }
466                                                }
467                                        }
468
469                                } finally {
470                                        out.close();
471                                        System.out.println();
472                                }
473                                successful = true;
474                        } finally {
475                                in.close();
476
477                                if (!successful)
478                                        downloadFile.delete();
479                        }
480
481                        return downloadFile;
482                } catch (final IOException e) {
483                        throw new RuntimeException(e);
484                }
485        }
486
487        private File getTempDownloadDir() {
488                if (tempDownloadDir == null) {
489                        try {
490                                tempDownloadDir = IOUtil.createUniqueRandomFolder(IOUtil.getTempDir(), "cloudstore-update-");
491                        } catch (final IOException e) {
492                                throw new RuntimeException(e);
493                        }
494                }
495                return tempDownloadDir;
496        }
497
498        /**
499         * Gets the installation directory that was passed as command line parameter.
500         */
501        @Override
502        protected File getInstallationDir() {
503                if (installationDirFile == null) {
504                        final String path = IOUtil.simplifyPath(createFile(AssertUtil.assertNotNull(installationDir, "installationDir")));
505                        final File f = createFile(path);
506                        if (!f.exists())
507                                throw new IllegalArgumentException(String.format("installationDir '%s' (specified as '%s') does not exist!", f, installationDir));
508
509                        if (!f.isDirectory())
510                                throw new IllegalArgumentException(String.format("installationDir '%s' (specified as '%s') is not a directory!", f, installationDir));
511
512                        installationDirFile = f;
513                }
514                return installationDirFile;
515        }
516
517        private Properties getRemoteUpdateProperties() {
518                if (remoteUpdateProperties == null) {
519                        final String resolvedRemoteUpdatePropertiesURL = resolve(remoteUpdatePropertiesURL);
520                        final Properties properties = new Properties();
521                        try {
522                                final URL url = new URL(resolvedRemoteUpdatePropertiesURL);
523                                final InputStream in = url.openStream();
524                                try {
525                                        properties.load(in);
526                                } finally {
527                                        in.close();
528                                }
529                        } catch (final IOException e) {
530                                throw new RuntimeException(e);
531                        }
532                        remoteUpdateProperties = properties;
533                }
534                return remoteUpdateProperties;
535        }
536}