001package co.codewizards.cloudstore.core.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.UrlUtil.*; 007 008import java.io.BufferedReader; 009import java.io.FileFilter; 010import java.io.IOException; 011import java.io.InputStream; 012import java.io.InputStreamReader; 013import java.io.OutputStream; 014import java.net.URL; 015import java.util.Date; 016import java.util.HashMap; 017import java.util.Map; 018import java.util.Properties; 019import java.util.concurrent.locks.Lock; 020 021import org.slf4j.Logger; 022import org.slf4j.LoggerFactory; 023 024import co.codewizards.cloudstore.core.DevMode; 025import co.codewizards.cloudstore.core.appid.AppIdRegistry; 026import co.codewizards.cloudstore.core.config.Config; 027import co.codewizards.cloudstore.core.config.ConfigDir; 028import co.codewizards.cloudstore.core.config.ConfigImpl; 029import co.codewizards.cloudstore.core.dto.DateTime; 030import co.codewizards.cloudstore.core.io.LockFile; 031import co.codewizards.cloudstore.core.io.LockFileFactory; 032import co.codewizards.cloudstore.core.oio.File; 033import co.codewizards.cloudstore.core.util.AssertUtil; 034import co.codewizards.cloudstore.core.util.IOUtil; 035import co.codewizards.cloudstore.core.util.PropertiesUtil; 036import co.codewizards.cloudstore.core.version.LocalVersionInIdeHelper; 037import co.codewizards.cloudstore.core.version.Version; 038 039public class CloudStoreUpdaterCore { 040 private static final Logger logger = LoggerFactory.getLogger(CloudStoreUpdaterCore.class); 041 042 public static final String INSTALLATION_PROPERTIES_FILE_NAME = "installation.properties"; 043 public static final String INSTALLATION_PROPERTIES_ARTIFACT_ID = "artifactId"; 044 public static final String INSTALLATION_PROPERTIES_VERSION = "version"; 045 public static final String remoteVersionURL = // "http://cloudstore.codewizards.co/update/co.codewizards.cloudstore.aggregator/version"; 046 AppIdRegistry.getInstance().getAppIdOrFail().getWebSiteBaseUrl() + "update/co.codewizards.cloudstore.aggregator/version"; 047 public static final String remoteUpdatePropertiesURL = // "http://cloudstore.codewizards.co/update/co.codewizards.cloudstore.aggregator/update.0.properties"; 048 AppIdRegistry.getInstance().getAppIdOrFail().getWebSiteBaseUrl() + "update/co.codewizards.cloudstore.aggregator/update.0.properties"; 049 050 /** 051 * Configuration property key controlling whether we do a downgrade. By default, only an upgrade is done. If this 052 * configuration property is set to <code>true</code> and the local version is newer than the version on the server 053 * a downgrade is done, too. 054 * <p> 055 * The configuration can be overridden by a system property - see {@link Config#SYSTEM_PROPERTY_PREFIX}. 056 */ 057 public static final String CONFIG_KEY_DOWNGRADE = "updater.downgrade"; 058 059 /** 060 * Configuration property key controlling whether the updater is enabled. 061 * <p> 062 * If it is enabled, it {@linkplain #CONFIG_KEY_REMOTE_VERSION_CACHE_VALIDITY_PERIOD periodically checks} whether an update is 063 * available, and if so, performs the update. Note, that {@link #CONFIG_KEY_FORCE} (or its 064 * corresponding system property "cloudstore.updater.force") have no effect, if the updater is disabled! 065 * <p> 066 * The configuration can be overridden by a system property - see {@link Config#SYSTEM_PROPERTY_PREFIX}. 067 */ 068 public static final String CONFIG_KEY_ENABLED = "updater.enabled"; 069 070 /** 071 * Configuration property key controlling whether to force the update. If this property is set, an update is 072 * done even if the versions locally and remotely are already the same. 073 * <p> 074 * This is only designed as configuration key for consistency reasons - usually, you likely don't want to write 075 * this into a configuration file! Instead, you probably want to pass this as a system property - see 076 * {@link Config#SYSTEM_PROPERTY_PREFIX} (and the example below). 077 * <p> 078 * Note, that forcing an update has no effect, if the updater is {@linkplain #CONFIG_KEY_ENABLED disabled}! 079 * Thus, if you want to force an update under all circumstances (whether the updater is enabled or not), 080 * you should pass both. As system properties, this looks as follows: 081 * <pre>-D<b>cloudstore.updater.force=true</b> -D<b>cloudstore.updater.enabled=true</b></pre> 082 */ 083 public static final String CONFIG_KEY_FORCE = "updater.force"; 084 085 /** 086 * Default value for {@link #CONFIG_KEY_REMOTE_VERSION_CACHE_VALIDITY_PERIOD} (6 hours in milliseconds). 087 */ 088 public static final long DEFAULT_REMOTE_VERSION_CACHE_VALIDITY_PERIOD = 6 * 60 * 60 * 1000; 089 090 /** 091 * Configuration property key controlling how long a queried remote version is cached (and thus how 092 * often the server is asked for it). 093 * <p> 094 * The configuration can be overridden by a system property - see {@link Config#SYSTEM_PROPERTY_PREFIX}. 095 */ 096 public static final String CONFIG_KEY_REMOTE_VERSION_CACHE_VALIDITY_PERIOD = "updater.remoteVersionCache.validityPeriod"; 097 098 private Version localVersion; 099 private Version remoteVersion; 100 private Properties installationProperties; 101 private File installationDir; 102 private File updaterDir; 103 private File backupDir; 104 105 public CloudStoreUpdaterCore() { } 106 107 public Version getRemoteVersion() { 108 Version remoteVersion = this.remoteVersion; 109 if (remoteVersion == null) { 110 final RemoteVersionCache remoteVersionCache = readRemoteVersionCacheFromProperties(); 111 final long cachePeriod = getRemoteVersionCacheValidityPeriod(); 112 if (remoteVersionCache != null && System.currentTimeMillis() - remoteVersionCache.remoteVersionTimestamp.getMillis() <= cachePeriod) { 113 logger.debug("getRemoteVersion: Cached value '{}' is from {} and still valid (it expires {}). Using this value (not asking server).", 114 remoteVersionCache.remoteVersion, 115 remoteVersionCache.remoteVersionTimestamp.toDate(), 116 new Date(remoteVersionCache.remoteVersionTimestamp.getMillis() + cachePeriod)); 117 this.remoteVersion = remoteVersion = remoteVersionCache.remoteVersion; 118 } 119 else { 120 final String artifactId = getInstallationProperties().getProperty(INSTALLATION_PROPERTIES_ARTIFACT_ID); 121 // cannot use resolve(...), because it invokes this method ;-) 122 assertNotNull(artifactId, "artifactId"); 123 final Map<String, Object> variables = new HashMap<>(1); 124 variables.put("artifactId", artifactId); 125 final String resolvedRemoteVersionURL = IOUtil.replaceTemplateVariables(remoteVersionURL, variables); 126 try { 127 final URL url = new URL(resolvedRemoteVersionURL); 128 final InputStream in = url.openStream(); 129 try { 130 final BufferedReader r = new BufferedReader(new InputStreamReader(in, "UTF-8")); 131 final String line = r.readLine(); 132 if (line == null || line.isEmpty()) 133 throw new IllegalStateException("Failed to read version from: " + resolvedRemoteVersionURL); 134 135 final String trimmed = line.trim(); 136 if (trimmed.isEmpty()) 137 throw new IllegalStateException("Failed to read version from: " + resolvedRemoteVersionURL); 138 139 this.remoteVersion = remoteVersion = new Version(trimmed); 140 r.close(); 141 } finally { 142 in.close(); 143 } 144 writeRemoteVersionCacheToProperties(new RemoteVersionCache(remoteVersion, new DateTime(new Date()))); 145 } catch (final IOException e) { 146 throw new RuntimeException(e); 147 } 148 } 149 } 150 return remoteVersion; 151 } 152 153 public Version getLocalVersion() { 154 if (localVersion == null) { 155 Properties installationProperties = null; 156 try { 157 installationProperties = getInstallationProperties(); 158 } catch (UnsupportedOperationException x) { 159 // running inside IDE => read pom.xml (or other IDE resource) instead 160 localVersion = LocalVersionInIdeHelper.getInstance().getLocalVersionInIde(); 161 } 162 if (localVersion == null) { 163 final String value = installationProperties.getProperty(INSTALLATION_PROPERTIES_VERSION); 164 if (value == null || value.isEmpty()) 165 throw new IllegalStateException("Failed to read local version from installation-properties-file!"); 166 167 final String trimmed = value.trim(); 168 if (trimmed.isEmpty()) 169 throw new IllegalStateException("Failed to read local version from installation-properties-file!"); 170 171 localVersion = new Version(trimmed); 172 } 173 } 174 return localVersion; 175 } 176 177 protected Properties getInstallationProperties() { 178 if (installationProperties == null) { 179 final File installationPropertiesFile = createFile(getInstallationDir(), INSTALLATION_PROPERTIES_FILE_NAME); 180 if (!installationPropertiesFile.exists()) 181 throw new IllegalArgumentException(String.format("installationPropertiesFile '%s' does not exist!", installationPropertiesFile.getAbsolutePath())); 182 183 if (!installationPropertiesFile.isFile()) 184 throw new IllegalArgumentException(String.format("installationPropertiesFile '%s' is not a file!", installationPropertiesFile.getAbsolutePath())); 185 186 try { 187 final Properties properties = PropertiesUtil.load(installationPropertiesFile); 188 installationProperties = properties; 189 } catch (final IOException x) { 190 throw new RuntimeException(x); 191 } 192 } 193 return installationProperties; 194 } 195 196 /** 197 * Resolves the given {@code template} by replacing all its variables with their actual values. 198 * <p> 199 * Variables are written as "${variable}" similarly to Ant and Maven. See 200 * {@link IOUtil#replaceTemplateVariables(String, Map)} for further details. 201 * <p> 202 * The variable values are obtained from the {@link #getInstallationProperties() installationProperties}. 203 * @param template the template to be resolved. Must not be <code>null</code>. 204 * @return 205 */ 206 protected String resolve(final String template) { 207 assertNotNull(template, "template"); 208 final String artifactId = getInstallationProperties().getProperty(INSTALLATION_PROPERTIES_ARTIFACT_ID); 209 assertNotNull(artifactId, "artifactId"); 210 211 final Version remoteVersion = getRemoteVersion(); 212 213 final Map<String, Object> variables = new HashMap<>(4); 214 variables.put("artifactId", artifactId); 215 variables.put("version", remoteVersion); 216 variables.put("remoteVersion", remoteVersion); 217 variables.put("localVersion", getLocalVersion()); 218 return IOUtil.replaceTemplateVariables(template, variables); 219 } 220 221 /** 222 * Gets the installation directory. 223 * <p> 224 * The implementation in {@link CloudStoreUpdaterCore} assumes that this class is located in a library 225 * (i.e. a JAR file) inside the installation directory. 226 * @return the installation directory. Never <code>null</code>. 227 * @throws IllegalStateException if the installation directory cannot be determined. 228 */ 229 protected File getInstallationDir() throws IllegalStateException { 230 if (installationDir == null) 231 installationDir = determineInstallationDirFromClass(); 232 233 return installationDir; 234 } 235 236 private File determineInstallationDirFromClass() { 237 if (DevMode.isDevModeEnabled()) 238 throw new UnsupportedOperationException("There is no installationDir in DevMode!"); 239 240 final URL resource = CloudStoreUpdaterCore.class.getResource(""); 241 logger.debug("determineInstallationDirFromClass: resource={}", resource); 242 if (resource.getProtocol().equalsIgnoreCase(PROTOCOL_JAR)) { 243 final File file = getFileFromJarUrl(resource); 244 logger.debug("determineInstallationDirFromClass: file={}", file); 245 246 File dir = file; 247 if (!dir.isDirectory()) 248 dir = dir.getParentFile(); 249 250 while (dir != null) { 251 final File installationPropertiesFile = createFile(dir, INSTALLATION_PROPERTIES_FILE_NAME); 252 if (installationPropertiesFile.exists()) { 253 logger.debug("determineInstallationDirFromClass: Found installationPropertiesFile in this directory: {}", dir); 254 return dir; 255 } 256 logger.debug("determineInstallationDirFromClass: installationPropertiesFile not found in this directory: {}", dir); 257 dir = dir.getParentFile(); 258 } 259 throw new IllegalStateException(String.format("File '%s' was not found in any expected location!", INSTALLATION_PROPERTIES_FILE_NAME)); 260 } else if (resource.getProtocol().equalsIgnoreCase(PROTOCOL_FILE)) { 261 throw new IllegalStateException("CloudStoreUpdaterCore was loaded inside the IDE! Load it from a real installation!"); 262 } else 263 throw new IllegalStateException("Class 'CloudStoreUpdaterCore' was not loaded from a local JAR or class file!"); 264 } 265 266 /** 267 * Is the configuration property {@link #CONFIG_KEY_DOWNGRADE} set to "true"? 268 * @return the value of the configuration property {@link #CONFIG_KEY_DOWNGRADE}. 269 */ 270 private boolean isDowngrade() { 271 return ConfigImpl.getInstance().getPropertyAsBoolean(CONFIG_KEY_DOWNGRADE, Boolean.FALSE); 272 } 273 274 private boolean isForce() { 275 return ConfigImpl.getInstance().getPropertyAsBoolean(CONFIG_KEY_FORCE, Boolean.FALSE); 276 } 277 278 private boolean isEnabled() { 279 return ConfigImpl.getInstance().getPropertyAsBoolean(CONFIG_KEY_ENABLED, Boolean.TRUE); 280 } 281 282 private long getRemoteVersionCacheValidityPeriod() { 283 return ConfigImpl.getInstance().getPropertyAsPositiveOrZeroLong(CONFIG_KEY_REMOTE_VERSION_CACHE_VALIDITY_PERIOD, DEFAULT_REMOTE_VERSION_CACHE_VALIDITY_PERIOD); 284 } 285 286 private File getUpdaterPropertiesFile() { 287 return createFile(ConfigDir.getInstance().getFile(), "updater.properties"); 288 } 289 290 private static final String PROPERTY_KEY_REMOTE_VERSION_TIMESTAMP = "remoteVersionTimestamp"; 291 private static final String PROPERTY_KEY_REMOTE_VERSION = "remoteVersion"; 292 293 private static class RemoteVersionCache { 294 public final Version remoteVersion; 295 public final DateTime remoteVersionTimestamp; 296 297 public RemoteVersionCache(final Version remoteVersion, final DateTime remoteVersionTimestamp) { 298 this.remoteVersion = AssertUtil.assertNotNull(remoteVersion, "remoteVersion"); 299 this.remoteVersionTimestamp = AssertUtil.assertNotNull(remoteVersionTimestamp, "remoteVersionTimestamp"); 300 } 301 } 302 303 private RemoteVersionCache readRemoteVersionCacheFromProperties() { 304 try ( final LockFile lockFile = LockFileFactory.getInstance().acquire(getUpdaterPropertiesFile(), 30000); ) { 305 final Properties properties = new Properties(); 306 try { 307 final InputStream in = castStream(lockFile.createInputStream()); 308 try { 309 properties.load(in); 310 } finally { 311 in.close(); 312 } 313 314 final String versionStr = properties.getProperty(PROPERTY_KEY_REMOTE_VERSION); 315 if (versionStr == null || versionStr.trim().isEmpty()) 316 return null; 317 318 final String timestampStr = properties.getProperty(PROPERTY_KEY_REMOTE_VERSION_TIMESTAMP); 319 if (timestampStr == null || timestampStr.trim().isEmpty()) 320 return null; 321 322 final Version remoteVersion; 323 try { 324 remoteVersion = new Version(versionStr.trim()); 325 } catch (final Exception x) { 326 logger.warn("readRemoteVersionFromProperties: Version-String '{}' could not be parsed into a Version! Returning null!", versionStr.trim()); 327 return null; 328 } 329 330 final DateTime remoteVersionTimestamp; 331 try { 332 remoteVersionTimestamp = new DateTime(timestampStr.trim()); 333 } catch (final Exception x) { 334 logger.warn("readRemoteVersionFromProperties: Timestamp-String '{}' could not be parsed into a DateTime! Returning null!", timestampStr.trim()); 335 return null; 336 } 337 338 return new RemoteVersionCache(remoteVersion, remoteVersionTimestamp); 339 } catch (final IOException e) { 340 throw new RuntimeException(e); 341 } 342 } 343 } 344 345 private void writeRemoteVersionCacheToProperties(final RemoteVersionCache remoteVersionCache) { 346 try ( final LockFile lockFile = LockFileFactory.getInstance().acquire(getUpdaterPropertiesFile(), 30000); ) { 347 final Lock lock = lockFile.getLock(); 348 lock.lock(); 349 try { 350 final Properties properties = new Properties(); 351 try { 352 final InputStream in = castStream(lockFile.createInputStream()); 353 try { 354 properties.load(in); 355 } finally { 356 in.close(); 357 } 358 359 if (remoteVersionCache == null) { 360 properties.remove(PROPERTY_KEY_REMOTE_VERSION); 361 properties.remove(PROPERTY_KEY_REMOTE_VERSION_TIMESTAMP); 362 } 363 else { 364 properties.setProperty(PROPERTY_KEY_REMOTE_VERSION, remoteVersionCache.remoteVersion.toString()); 365 properties.setProperty(PROPERTY_KEY_REMOTE_VERSION_TIMESTAMP, remoteVersionCache.remoteVersionTimestamp.toString()); 366 } 367 368 final OutputStream out = castStream(lockFile.createOutputStream()); 369 try { 370 properties.store(out, null); 371 } finally { 372 out.close(); 373 } 374 } catch (final IOException e) { 375 throw new RuntimeException(e); 376 } 377 } finally { 378 lock.unlock(); 379 } 380 } 381 } 382 383 /** 384 * Creates the {@link #getUpdaterDir() updaterDir}, if an update is necessary. 385 * <p> 386 * If an update is not necessary, this method returns silently without doing anything. 387 * <p> 388 * The check, whether an update is needed, is not done every time. The result is cached for 389 * {@linkplain #CONFIG_KEY_REMOTE_VERSION_CACHE_VALIDITY_PERIOD a certain time period} to reduce HTTP queries. 390 * <p> 391 * This method does not throw any exception. In case of an exception, it is only logged and this 392 * method returns normally. 393 * @return <code>true</code>, if an update is needed and about to be done; <code>false</code> otherwise. 394 * Note: If an update is needed, but cannot be done for any reason (e.g. because the directory is not writable), 395 * <code>false</code> is returned. 396 */ 397 public boolean createUpdaterDirIfUpdateNeeded() { 398 File updaterDir = null; 399 try { 400 if (!isEnabled()) { 401 if (isForce()) 402 logger.warn("createUpdaterDirIfUpdateNeeded: The configuration key '{}' (or its corresponding system property) is set to force an update, but the updater is *not* enabled! You must set the configuration key '{}' (or its corresponding system property) additionally! Skipping!", CONFIG_KEY_FORCE, CONFIG_KEY_ENABLED); 403 else 404 logger.info("createUpdaterDirIfUpdateNeeded: Updater is *not* enabled! Skipping! See configuration key '{}'.", CONFIG_KEY_ENABLED); 405 406 return false; 407 } 408 409 updaterDir = getUpdaterDir(); 410 IOUtil.deleteDirectoryRecursively(updaterDir); 411 412 if (isUpdateNeeded()) { 413 if (!canWriteAll(getInstallationDir())) { 414 logger.error("Installation directory '{}' is not writable or contains sub-directories/files that are not writable! Cannot perform auto-update to new version {}! Please update manually! Your local version is {}.", 415 getInstallationDir(), getRemoteVersion(), getLocalVersion()); 416 return false; 417 } 418 419 copyInstallationDirectoryForUpdater(); 420 logger.debug("createUpdaterDirIfUpdateNeeded: updaterDir='{}'", updaterDir); 421 return true; 422 } 423 } catch (final Exception x) { 424 logger.error("createUpdaterDirIfUpdateNeeded: " + x, x); 425 if (updaterDir != null) { 426 try { 427 IOUtil.deleteDirectoryRecursively(updaterDir); 428 } catch (final Exception y) { 429 logger.error("createUpdaterDirIfUpdateNeeded: " + y, y); 430 } 431 } 432 } 433 return false; 434 } 435 436 private boolean canWriteAll(final File fileOrDir) { 437 if (!fileOrDir.canWrite()) 438 return false; 439 440 final File[] children = fileOrDir.listFiles(fileFilterIgnoringBackupDir); 441 if (children != null) { 442 for (final File child : children) { 443 if (!canWriteAll(child)) 444 return false; 445 } 446 } 447 return true; 448 } 449 450 public File getUpdaterDir() { 451 if (updaterDir == null) 452 updaterDir = createFile(getInstallationDir(), "updater"); 453 454 return updaterDir; 455 } 456 457 protected File getBackupDir() { 458 if (backupDir == null) 459 backupDir = createFile(getInstallationDir(), "backup"); 460 461 return backupDir; 462 } 463 464 protected final FileFilter fileFilterIgnoringBackupDir = new FileFilter() { 465 @Override 466 public boolean accept(final java.io.File file) { 467 return !getBackupDir().getIoFile().equals(file); 468 } 469 }; 470 471 protected final FileFilter fileFilterIgnoringBackupAndUpdaterDir = new FileFilter() { 472 @Override 473 public boolean accept(final java.io.File file) { 474 return !(getBackupDir().getIoFile().equals(file) || getUpdaterDir().getIoFile().equals(file)); 475 } 476 }; 477 478 private File copyInstallationDirectoryForUpdater() { 479 try { 480 final File updaterDir = getUpdaterDir(); 481 IOUtil.deleteDirectoryRecursively(updaterDir); 482 IOUtil.copyDirectory(getInstallationDir(), updaterDir, fileFilterIgnoringBackupAndUpdaterDir); 483 return updaterDir; 484 } catch (final IOException e) { 485 throw new RuntimeException(e); 486 } 487 } 488 489 private boolean isUpdateNeeded() { 490 final Version localVersion = getLocalVersion(); 491 final Version remoteVersion = getRemoteVersion(); 492 if (isForce()) { 493 logger.warn("isUpdateNeeded: Update forced via system-property! localVersion='{}' remoteVersion='{}'", localVersion, remoteVersion); 494 return true; 495 } 496 497 if (localVersion.equals(remoteVersion)) { 498 logger.debug("isUpdateNeeded: No update, because localVersion equals remoteVersion='{}'", remoteVersion); 499 return false; 500 } 501 502 if (localVersion.compareTo(remoteVersion) > 0) { 503 if (isDowngrade()) { 504 logger.warn("isUpdateNeeded: Downgrading enabled via system-property! localVersion='{}' remoteVersion='{}'", localVersion, remoteVersion); 505 return true; 506 } 507 508 logger.info("isUpdateNeeded: No update, because localVersion='{}' is newer than remoteVersion='{}'", localVersion, remoteVersion); 509 return false; 510 } 511 512 logger.warn("isUpdateNeeded: Update needed! localVersion='{}' remoteVersion='{}'", localVersion, remoteVersion); 513 return true; 514 } 515}