package co.codewizards.cloudstore.core.updater;
import static co.codewizards.cloudstore.core.io.StreamUtil.*;
import static co.codewizards.cloudstore.core.oio.OioFileFactory.*;
import static co.codewizards.cloudstore.core.util.AssertUtil.*;
import static co.codewizards.cloudstore.core.util.UrlUtil.*;
import java.io.BufferedReader;
import java.io.FileFilter;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.net.URL;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.Properties;
import java.util.concurrent.locks.Lock;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import co.codewizards.cloudstore.core.DevMode;
import co.codewizards.cloudstore.core.appid.AppIdRegistry;
import co.codewizards.cloudstore.core.config.Config;
import co.codewizards.cloudstore.core.config.ConfigDir;
import co.codewizards.cloudstore.core.config.ConfigImpl;
import co.codewizards.cloudstore.core.dto.DateTime;
import co.codewizards.cloudstore.core.io.LockFile;
import co.codewizards.cloudstore.core.io.LockFileFactory;
import co.codewizards.cloudstore.core.oio.File;
import co.codewizards.cloudstore.core.util.AssertUtil;
import co.codewizards.cloudstore.core.util.IOUtil;
import co.codewizards.cloudstore.core.util.PropertiesUtil;
import co.codewizards.cloudstore.core.version.LocalVersionInIdeHelper;
import co.codewizards.cloudstore.core.version.Version;
public class CloudStoreUpdaterCore {
private static final Logger logger = LoggerFactory.getLogger(CloudStoreUpdaterCore.class);
public static final String INSTALLATION_PROPERTIES_FILE_NAME = "installation.properties";
public static final String INSTALLATION_PROPERTIES_ARTIFACT_ID = "artifactId";
public static final String INSTALLATION_PROPERTIES_VERSION = "version";
public static final String remoteVersionURL = // "http://cloudstore.codewizards.co/update/${artifactId}/version";
AppIdRegistry.getInstance().getAppIdOrFail().getWebSiteBaseUrl() + "update/${artifactId}/version";
public static final String remoteUpdatePropertiesURL = // "http://cloudstore.codewizards.co/update/${artifactId}/update.0.properties";
AppIdRegistry.getInstance().getAppIdOrFail().getWebSiteBaseUrl() + "update/${artifactId}/update.0.properties";
/**
* Configuration property key controlling whether we do a downgrade. By default, only an upgrade is done. If this
* configuration property is set to <code>true</code> and the local version is newer than the version on the server
* a downgrade is done, too.
* <p>
* The configuration can be overridden by a system property - see {@link Config#SYSTEM_PROPERTY_PREFIX}.
*/
public static final String CONFIG_KEY_DOWNGRADE = "updater.downgrade";
/**
* Configuration property key controlling whether the updater is enabled.
* <p>
* If it is enabled, it {@linkplain #CONFIG_KEY_REMOTE_VERSION_CACHE_VALIDITY_PERIOD periodically checks} whether an update is
* available, and if so, performs the update. Note, that {@link #CONFIG_KEY_FORCE} (or its
* corresponding system property "cloudstore.updater.force") have no effect, if the updater is disabled!
* <p>
* The configuration can be overridden by a system property - see {@link Config#SYSTEM_PROPERTY_PREFIX}.
*/
public static final String CONFIG_KEY_ENABLED = "updater.enabled";
/**
* Configuration property key controlling whether to force the update. If this property is set, an update is
* done even if the versions locally and remotely are already the same.
* <p>
* This is only designed as configuration key for consistency reasons - usually, you likely don't want to write
* this into a configuration file! Instead, you probably want to pass this as a system property - see
* {@link Config#SYSTEM_PROPERTY_PREFIX} (and the example below).
* <p>
* Note, that forcing an update has no effect, if the updater is {@linkplain #CONFIG_KEY_ENABLED disabled}!
* Thus, if you want to force an update under all circumstances (whether the updater is enabled or not),
* you should pass both. As system properties, this looks as follows:
* <pre>-D<b>cloudstore.updater.force=true</b> -D<b>cloudstore.updater.enabled=true</b></pre>
*/
public static final String CONFIG_KEY_FORCE = "updater.force";
/**
* Default value for {@link #CONFIG_KEY_REMOTE_VERSION_CACHE_VALIDITY_PERIOD} (6 hours in milliseconds).
*/
public static final long DEFAULT_REMOTE_VERSION_CACHE_VALIDITY_PERIOD = 6 * 60 * 60 * 1000;
/**
* Configuration property key controlling how long a queried remote version is cached (and thus how
* often the server is asked for it).
* <p>
* The configuration can be overridden by a system property - see {@link Config#SYSTEM_PROPERTY_PREFIX}.
*/
public static final String CONFIG_KEY_REMOTE_VERSION_CACHE_VALIDITY_PERIOD = "updater.remoteVersionCache.validityPeriod";
private Version localVersion;
private Version remoteVersion;
private Properties installationProperties;
private File installationDir;
private File updaterDir;
private File backupDir;
public CloudStoreUpdaterCore() { }
public Version getRemoteVersion() {
Version remoteVersion = this.remoteVersion;
if (remoteVersion == null) {
final RemoteVersionCache remoteVersionCache = readRemoteVersionCacheFromProperties();
final long cachePeriod = getRemoteVersionCacheValidityPeriod();
if (remoteVersionCache != null && System.currentTimeMillis() - remoteVersionCache.remoteVersionTimestamp.getMillis() <= cachePeriod) {
logger.debug("getRemoteVersion: Cached value '{}' is from {} and still valid (it expires {}). Using this value (not asking server).",
remoteVersionCache.remoteVersion,
remoteVersionCache.remoteVersionTimestamp.toDate(),
new Date(remoteVersionCache.remoteVersionTimestamp.getMillis() + cachePeriod));
this.remoteVersion = remoteVersion = remoteVersionCache.remoteVersion;
}
else {
final String artifactId = getInstallationProperties().getProperty(INSTALLATION_PROPERTIES_ARTIFACT_ID);
// cannot use resolve(...), because it invokes this method ;-)
assertNotNull(artifactId, "artifactId");
final Map<String, Object> variables = new HashMap<>(1);
variables.put("artifactId", artifactId);
final String resolvedRemoteVersionURL = IOUtil.replaceTemplateVariables(remoteVersionURL, variables);
try {
final URL url = new URL(resolvedRemoteVersionURL);
final InputStream in = url.openStream();
try {
final BufferedReader r = new BufferedReader(new InputStreamReader(in, "UTF-8"));
final String line = r.readLine();
if (line == null || line.isEmpty())
throw new IllegalStateException("Failed to read version from: " + resolvedRemoteVersionURL);
final String trimmed = line.trim();
if (trimmed.isEmpty())
throw new IllegalStateException("Failed to read version from: " + resolvedRemoteVersionURL);
this.remoteVersion = remoteVersion = new Version(trimmed);
r.close();
} finally {
in.close();
}
writeRemoteVersionCacheToProperties(new RemoteVersionCache(remoteVersion, new DateTime(new Date())));
} catch (final IOException e) {
throw new RuntimeException(e);
}
}
}
return remoteVersion;
}
public Version getLocalVersion() {
if (localVersion == null) {
Properties installationProperties = null;
try {
installationProperties = getInstallationProperties();
} catch (UnsupportedOperationException x) {
// running inside IDE => read pom.xml (or other IDE resource) instead
localVersion = LocalVersionInIdeHelper.getInstance().getLocalVersionInIde();
}
if (localVersion == null) {
final String value = installationProperties.getProperty(INSTALLATION_PROPERTIES_VERSION);
if (value == null || value.isEmpty())
throw new IllegalStateException("Failed to read local version from installation-properties-file!");
final String trimmed = value.trim();
if (trimmed.isEmpty())
throw new IllegalStateException("Failed to read local version from installation-properties-file!");
localVersion = new Version(trimmed);
}
}
return localVersion;
}
protected Properties getInstallationProperties() {
if (installationProperties == null) {
final File installationPropertiesFile = createFile(getInstallationDir(), INSTALLATION_PROPERTIES_FILE_NAME);
if (!installationPropertiesFile.exists())
throw new IllegalArgumentException(String.format("installationPropertiesFile '%s' does not exist!", installationPropertiesFile.getAbsolutePath()));
if (!installationPropertiesFile.isFile())
throw new IllegalArgumentException(String.format("installationPropertiesFile '%s' is not a file!", installationPropertiesFile.getAbsolutePath()));
try {
final Properties properties = PropertiesUtil.load(installationPropertiesFile);
installationProperties = properties;
} catch (final IOException x) {
throw new RuntimeException(x);
}
}
return installationProperties;
}
/**
* Resolves the given {@code template} by replacing all its variables with their actual values.
* <p>
* Variables are written as "${variable}" similarly to Ant and Maven. See
* {@link IOUtil#replaceTemplateVariables(String, Map)} for further details.
* <p>
* The variable values are obtained from the {@link #getInstallationProperties() installationProperties}.
* @param template the template to be resolved. Must not be <code>null</code>.
* @return
*/
protected String resolve(final String template) {
assertNotNull(template, "template");
final String artifactId = getInstallationProperties().getProperty(INSTALLATION_PROPERTIES_ARTIFACT_ID);
assertNotNull(artifactId, "artifactId");
final Version remoteVersion = getRemoteVersion();
final Map<String, Object> variables = new HashMap<>(4);
variables.put("artifactId", artifactId);
variables.put("version", remoteVersion);
variables.put("remoteVersion", remoteVersion);
variables.put("localVersion", getLocalVersion());
return IOUtil.replaceTemplateVariables(template, variables);
}
/**
* Gets the installation directory.
* <p>
* The implementation in {@link CloudStoreUpdaterCore} assumes that this class is located in a library
* (i.e. a JAR file) inside the installation directory.
* @return the installation directory. Never <code>null</code>.
* @throws IllegalStateException if the installation directory cannot be determined.
*/
protected File getInstallationDir() throws IllegalStateException {
if (installationDir == null)
installationDir = determineInstallationDirFromClass();
return installationDir;
}
private File determineInstallationDirFromClass() {
if (DevMode.isDevModeEnabled())
throw new UnsupportedOperationException("There is no installationDir in DevMode!");
final URL resource = CloudStoreUpdaterCore.class.getResource("");
logger.debug("determineInstallationDirFromClass: resource={}", resource);
if (resource.getProtocol().equalsIgnoreCase(PROTOCOL_JAR)) {
final File file = getFileFromJarUrl(resource);
logger.debug("determineInstallationDirFromClass: file={}", file);
File dir = file;
if (!dir.isDirectory())
dir = dir.getParentFile();
while (dir != null) {
final File installationPropertiesFile = createFile(dir, INSTALLATION_PROPERTIES_FILE_NAME);
if (installationPropertiesFile.exists()) {
logger.debug("determineInstallationDirFromClass: Found installationPropertiesFile in this directory: {}", dir);
return dir;
}
logger.debug("determineInstallationDirFromClass: installationPropertiesFile not found in this directory: {}", dir);
dir = dir.getParentFile();
}
throw new IllegalStateException(String.format("File '%s' was not found in any expected location!", INSTALLATION_PROPERTIES_FILE_NAME));
} else if (resource.getProtocol().equalsIgnoreCase(PROTOCOL_FILE)) {
throw new IllegalStateException("CloudStoreUpdaterCore was loaded inside the IDE! Load it from a real installation!");
} else
throw new IllegalStateException("Class 'CloudStoreUpdaterCore' was not loaded from a local JAR or class file!");
}
/**
* Is the configuration property {@link #CONFIG_KEY_DOWNGRADE} set to "true"?
* @return the value of the configuration property {@link #CONFIG_KEY_DOWNGRADE}.
*/
private boolean isDowngrade() {
return ConfigImpl.getInstance().getPropertyAsBoolean(CONFIG_KEY_DOWNGRADE, Boolean.FALSE);
}
private boolean isForce() {
return ConfigImpl.getInstance().getPropertyAsBoolean(CONFIG_KEY_FORCE, Boolean.FALSE);
}
private boolean isEnabled() {
return ConfigImpl.getInstance().getPropertyAsBoolean(CONFIG_KEY_ENABLED, Boolean.TRUE);
}
private long getRemoteVersionCacheValidityPeriod() {
return ConfigImpl.getInstance().getPropertyAsPositiveOrZeroLong(CONFIG_KEY_REMOTE_VERSION_CACHE_VALIDITY_PERIOD, DEFAULT_REMOTE_VERSION_CACHE_VALIDITY_PERIOD);
}
private File getUpdaterPropertiesFile() {
return createFile(ConfigDir.getInstance().getFile(), "updater.properties");
}
private static final String PROPERTY_KEY_REMOTE_VERSION_TIMESTAMP = "remoteVersionTimestamp";
private static final String PROPERTY_KEY_REMOTE_VERSION = "remoteVersion";
private static class RemoteVersionCache {
public final Version remoteVersion;
public final DateTime remoteVersionTimestamp;
public RemoteVersionCache(final Version remoteVersion, final DateTime remoteVersionTimestamp) {
this.remoteVersion = AssertUtil.assertNotNull(remoteVersion, "remoteVersion");
this.remoteVersionTimestamp = AssertUtil.assertNotNull(remoteVersionTimestamp, "remoteVersionTimestamp");
}
}
private RemoteVersionCache readRemoteVersionCacheFromProperties() {
try ( final LockFile lockFile = LockFileFactory.getInstance().acquire(getUpdaterPropertiesFile(), 30000); ) {
final Properties properties = new Properties();
try {
final InputStream in = castStream(lockFile.createInputStream());
try {
properties.load(in);
} finally {
in.close();
}
final String versionStr = properties.getProperty(PROPERTY_KEY_REMOTE_VERSION);
if (versionStr == null || versionStr.trim().isEmpty())
return null;
final String timestampStr = properties.getProperty(PROPERTY_KEY_REMOTE_VERSION_TIMESTAMP);
if (timestampStr == null || timestampStr.trim().isEmpty())
return null;
final Version remoteVersion;
try {
remoteVersion = new Version(versionStr.trim());
} catch (final Exception x) {
logger.warn("readRemoteVersionFromProperties: Version-String '{}' could not be parsed into a Version! Returning null!", versionStr.trim());
return null;
}
final DateTime remoteVersionTimestamp;
try {
remoteVersionTimestamp = new DateTime(timestampStr.trim());
} catch (final Exception x) {
logger.warn("readRemoteVersionFromProperties: Timestamp-String '{}' could not be parsed into a DateTime! Returning null!", timestampStr.trim());
return null;
}
return new RemoteVersionCache(remoteVersion, remoteVersionTimestamp);
} catch (final IOException e) {
throw new RuntimeException(e);
}
}
}
private void writeRemoteVersionCacheToProperties(final RemoteVersionCache remoteVersionCache) {
try ( final LockFile lockFile = LockFileFactory.getInstance().acquire(getUpdaterPropertiesFile(), 30000); ) {
final Lock lock = lockFile.getLock();
lock.lock();
try {
final Properties properties = new Properties();
try {
final InputStream in = castStream(lockFile.createInputStream());
try {
properties.load(in);
} finally {
in.close();
}
if (remoteVersionCache == null) {
properties.remove(PROPERTY_KEY_REMOTE_VERSION);
properties.remove(PROPERTY_KEY_REMOTE_VERSION_TIMESTAMP);
}
else {
properties.setProperty(PROPERTY_KEY_REMOTE_VERSION, remoteVersionCache.remoteVersion.toString());
properties.setProperty(PROPERTY_KEY_REMOTE_VERSION_TIMESTAMP, remoteVersionCache.remoteVersionTimestamp.toString());
}
final OutputStream out = castStream(lockFile.createOutputStream());
try {
properties.store(out, null);
} finally {
out.close();
}
} catch (final IOException e) {
throw new RuntimeException(e);
}
} finally {
lock.unlock();
}
}
}
/**
* Creates the {@link #getUpdaterDir() updaterDir}, if an update is necessary.
* <p>
* If an update is not necessary, this method returns silently without doing anything.
* <p>
* The check, whether an update is needed, is not done every time. The result is cached for
* {@linkplain #CONFIG_KEY_REMOTE_VERSION_CACHE_VALIDITY_PERIOD a certain time period} to reduce HTTP queries.
* <p>
* This method does not throw any exception. In case of an exception, it is only logged and this
* method returns normally.
* @return <code>true</code>, if an update is needed and about to be done; <code>false</code> otherwise.
* Note: If an update is needed, but cannot be done for any reason (e.g. because the directory is not writable),
* <code>false</code> is returned.
*/
public boolean createUpdaterDirIfUpdateNeeded() {
File updaterDir = null;
try {
if (!isEnabled()) {
if (isForce())
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);
else
logger.info("createUpdaterDirIfUpdateNeeded: Updater is *not* enabled! Skipping! See configuration key '{}'.", CONFIG_KEY_ENABLED);
return false;
}
updaterDir = getUpdaterDir();
IOUtil.deleteDirectoryRecursively(updaterDir);
if (isUpdateNeeded()) {
if (!canWriteAll(getInstallationDir())) {
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 {}.",
getInstallationDir(), getRemoteVersion(), getLocalVersion());
return false;
}
copyInstallationDirectoryForUpdater();
logger.debug("createUpdaterDirIfUpdateNeeded: updaterDir='{}'", updaterDir);
return true;
}
} catch (final Exception x) {
logger.error("createUpdaterDirIfUpdateNeeded: " + x, x);
if (updaterDir != null) {
try {
IOUtil.deleteDirectoryRecursively(updaterDir);
} catch (final Exception y) {
logger.error("createUpdaterDirIfUpdateNeeded: " + y, y);
}
}
}
return false;
}
private boolean canWriteAll(final File fileOrDir) {
if (!fileOrDir.canWrite())
return false;
final File[] children = fileOrDir.listFiles(fileFilterIgnoringBackupDir);
if (children != null) {
for (final File child : children) {
if (!canWriteAll(child))
return false;
}
}
return true;
}
public File getUpdaterDir() {
if (updaterDir == null)
updaterDir = createFile(getInstallationDir(), "updater");
return updaterDir;
}
protected File getBackupDir() {
if (backupDir == null)
backupDir = createFile(getInstallationDir(), "backup");
return backupDir;
}
protected final FileFilter fileFilterIgnoringBackupDir = new FileFilter() {
@Override
public boolean accept(final java.io.File file) {
return !getBackupDir().getIoFile().equals(file);
}
};
protected final FileFilter fileFilterIgnoringBackupAndUpdaterDir = new FileFilter() {
@Override
public boolean accept(final java.io.File file) {
return !(getBackupDir().getIoFile().equals(file) || getUpdaterDir().getIoFile().equals(file));
}
};
private File copyInstallationDirectoryForUpdater() {
try {
final File updaterDir = getUpdaterDir();
IOUtil.deleteDirectoryRecursively(updaterDir);
IOUtil.copyDirectory(getInstallationDir(), updaterDir, fileFilterIgnoringBackupAndUpdaterDir);
return updaterDir;
} catch (final IOException e) {
throw new RuntimeException(e);
}
}
private boolean isUpdateNeeded() {
final Version localVersion = getLocalVersion();
final Version remoteVersion = getRemoteVersion();
if (isForce()) {
logger.warn("isUpdateNeeded: Update forced via system-property! localVersion='{}' remoteVersion='{}'", localVersion, remoteVersion);
return true;
}
if (localVersion.equals(remoteVersion)) {
logger.debug("isUpdateNeeded: No update, because localVersion equals remoteVersion='{}'", remoteVersion);
return false;
}
if (localVersion.compareTo(remoteVersion) > 0) {
if (isDowngrade()) {
logger.warn("isUpdateNeeded: Downgrading enabled via system-property! localVersion='{}' remoteVersion='{}'", localVersion, remoteVersion);
return true;
}
logger.info("isUpdateNeeded: No update, because localVersion='{}' is newer than remoteVersion='{}'", localVersion, remoteVersion);
return false;
}
logger.warn("isUpdateNeeded: Update needed! localVersion='{}' remoteVersion='{}'", localVersion, remoteVersion);
return true;
}
}