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