diff --git a/src/main/java/org/qora/ApplyUpdate.java b/src/main/java/org/qora/ApplyUpdate.java new file mode 100644 index 00000000..b43426a5 --- /dev/null +++ b/src/main/java/org/qora/ApplyUpdate.java @@ -0,0 +1,116 @@ +package org.qora; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.security.Security; +import java.util.Arrays; +import java.util.List; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.jsse.provider.BouncyCastleJsseProvider; +import org.qora.api.ApiRequest; +import org.qora.controller.AutoUpdate; +import org.qora.settings.Settings; + +public class ApplyUpdate { + + static { + // This static block will be called before others if using ApplyUpdate.main() + + // Log into different files for auto-update - this has to be before LogManger.getLogger() calls + System.setProperty("log4j2.filenameTemplate", "log-apply-update.txt"); + + // This must go before any calls to LogManager/Logger + System.setProperty("java.util.logging.manager", "org.apache.logging.log4j.jul.LogManager"); + } + + private static final Logger LOGGER = LogManager.getLogger(ApplyUpdate.class); + private static final String JAR_FILENAME = AutoUpdate.JAR_FILENAME; + private static final String NEW_JAR_FILENAME = AutoUpdate.NEW_JAR_FILENAME; + + private static final long CHECK_INTERVAL = 5 * 1000; // ms + private static final int MAX_ATTEMPTS = 5; + + public static void main(String args[]) { + Security.insertProviderAt(new BouncyCastleProvider(), 0); + Security.insertProviderAt(new BouncyCastleJsseProvider(), 1); + + // Load/check settings, which potentially sets up blockchain config, etc. + Settings.getInstance(); + + LOGGER.info("Applying update..."); + + // Shutdown node using API + if (!shutdownNode()) + return; + + // Replace JAR + replaceJar(); + + // Restart node + restartNode(); + } + + private static boolean shutdownNode() { + String BASE_URI = "http://localhost:" + Settings.getInstance().getApiPort() + "/"; + LOGGER.info(String.format("Shutting down node using API via %s", BASE_URI)); + + int attempt; + for (attempt = 0; attempt < MAX_ATTEMPTS; ++attempt) { + LOGGER.debug(String.format("Attempt #%d out of %d to shutdown node", attempt + 1, MAX_ATTEMPTS)); + String response = ApiRequest.perform(BASE_URI + "admin/stop", null); + if (response != null) + break; + + try { + Thread.sleep(CHECK_INTERVAL); + } catch (InterruptedException e) { + // We still need to check... + break; + } + } + + if (attempt == MAX_ATTEMPTS) { + LOGGER.error("Failed to shutdown node - giving up"); + return false; + } + + return true; + } + + private static void replaceJar() { + // Assuming current working directory contains the JAR files + Path realJar = Paths.get(JAR_FILENAME); + Path newJar = Paths.get(NEW_JAR_FILENAME); + + try { + Files.copy(newJar, realJar, StandardCopyOption.REPLACE_EXISTING); + } catch (IOException e) { + LOGGER.warn(String.format("Failed to copy %s to %s", newJar.toString(), realJar.toString()), e.getMessage()); + // Fall-through to restarting node... + } + } + + private static void restartNode() { + String javaHome = System.getProperty("java.home"); + LOGGER.debug(String.format("Java home: %s", javaHome)); + + Path javaBinary = Paths.get(javaHome, "bin", "java"); + LOGGER.debug(String.format("Java binary: %s", javaBinary)); + + try { + List javaCmd = Arrays.asList(javaBinary.toString(), "-jar", JAR_FILENAME); + LOGGER.info(String.format("Restarting node with: %s", String.join(" ", javaCmd))); + + new ProcessBuilder(javaCmd).start(); + } catch (IOException e) { + LOGGER.error(String.format("Failed to restart node (BAD): %s", e.getMessage())); + } + } + +} diff --git a/src/main/java/org/qora/controller/AutoUpdate.java b/src/main/java/org/qora/controller/AutoUpdate.java new file mode 100644 index 00000000..6e67107a --- /dev/null +++ b/src/main/java/org/qora/controller/AutoUpdate.java @@ -0,0 +1,171 @@ +package org.qora.controller; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.util.Arrays; +import java.util.List; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.qora.ApplyUpdate; +import org.qora.api.ApiRequest; +import org.qora.api.resource.TransactionsResource.ConfirmationStatus; +import org.qora.data.transaction.ArbitraryTransactionData; +import org.qora.data.transaction.TransactionData; +import org.qora.repository.DataException; +import org.qora.repository.Repository; +import org.qora.repository.RepositoryManager; +import org.qora.settings.Settings; +import org.qora.transaction.ArbitraryTransaction; +import org.qora.transaction.Transaction.TransactionType; +import org.qora.utils.NTP; + +import com.google.common.hash.HashCode; + +public class AutoUpdate extends Thread { + + public static final String JAR_FILENAME = "qora-core.jar"; + public static final String NEW_JAR_FILENAME = "new-" + JAR_FILENAME; + + private static final Logger LOGGER = LogManager.getLogger(AutoUpdate.class); + private static final long CHECK_INTERVAL = 5 * 1000; // ms + + private static final int DEV_GROUP_ID = 1; + private static final int UPDATE_SERVICE = 1; + private static final List ARBITRARY_TX_TYPE = Arrays.asList(TransactionType.ARBITRARY); + + private static AutoUpdate instance; + + private boolean isStopping = false; + + private AutoUpdate() { + } + + public static AutoUpdate getInstance() { + if (instance == null) + instance = new AutoUpdate(); + + return instance; + } + + public void run() { + long buildTimestamp = Controller.getInstance().getBuildTimestamp() * 1000L; + boolean attemptedUpdate = false; + + while (!isStopping) { + try { + Thread.sleep(CHECK_INTERVAL); + } catch (InterruptedException e) { + return; + } + + // Try to clean up any leftover downloads (but if we are/have attempted update then don't delete new JAR) + if (!attemptedUpdate) + try { + Path newJar = Paths.get(NEW_JAR_FILENAME); + Files.deleteIfExists(newJar); + } catch (IOException de) { + // Whatever + } + + // Look for "update" tx which is arbitrary tx in dev-group with service 1 and timestamp later than buildTimestamp + try (final Repository repository = RepositoryManager.getRepository()) { + List signatures = repository.getTransactionRepository().getSignaturesMatchingCriteria(null, null, DEV_GROUP_ID, ARBITRARY_TX_TYPE, UPDATE_SERVICE, null, ConfirmationStatus.CONFIRMED, 1, null, true); + if (signatures == null || signatures.isEmpty()) + continue; + + TransactionData transactionData = repository.getTransactionRepository().fromSignature(signatures.get(0)); + if (transactionData == null || !(transactionData instanceof ArbitraryTransactionData)) + continue; + + // Transaction needs to be newer than this build + if (transactionData.getTimestamp() <= buildTimestamp) + continue; + + ArbitraryTransaction arbitraryTransaction = new ArbitraryTransaction(repository, transactionData); + if (!arbitraryTransaction.isDataLocal()) + continue; // We can't access data + + // TODO: check arbitrary data length (pre-fetch) matches git commit length (20) + sha256 hash length (32) = 52 bytes + + byte[] commitHash = arbitraryTransaction.fetchData(); + LOGGER.info(String.format("Update's git commit hash: %s", HashCode.fromBytes(commitHash).toString())); + + String[] autoUpdateRepos = Settings.getInstance().getAutoUpdateRepos(); + for (String repo : autoUpdateRepos) + if (attemptUpdate(commitHash, repo)) { + // Consider ourselves updated so don't re-re-re-download + buildTimestamp = NTP.getTime(); + attemptedUpdate = true; + break; + } + } catch (DataException e) { + LOGGER.warn("Repository issue to find updates", e); + // Keep going I guess... + } + } + + LOGGER.info("Stopping auto-update service"); + } + + public void shutdown() { + isStopping = true; + } + + private static boolean attemptUpdate(byte[] commitHash, String repoBaseUri) { + LOGGER.info(String.format("Fetching update from %s", repoBaseUri)); + InputStream in = ApiRequest.fetchStream(repoBaseUri + "/raw/" + HashCode.fromBytes(commitHash).toString() + "/" + JAR_FILENAME); + if (in == null) { + LOGGER.warn(String.format("Failed to fetch update from %s", repoBaseUri)); + return false; // failed - try another repo + } + + Path newJar = Paths.get(NEW_JAR_FILENAME); + try { + // Save input stream into new JAR + LOGGER.debug(String.format("Saving update from %s into %s", repoBaseUri, newJar.toString())); + Files.copy(in, newJar, StandardCopyOption.REPLACE_EXISTING); + } catch (IOException e) { + LOGGER.warn(String.format("Failed to save update from %s into %s", repoBaseUri, newJar.toString())); + + try { + Files.deleteIfExists(newJar); + } catch (IOException de) { + LOGGER.warn(String.format("Failed to delete partial download: %s", de.getMessage())); + } + + return false; // failed - try another repo + } + + // Call ApplyUpdate to end this process (unlocking current JAR so it can be replaced) + String javaHome = System.getProperty("java.home"); + LOGGER.debug(String.format("Java home: %s", javaHome)); + + Path javaBinary = Paths.get(javaHome, "bin", "java"); + LOGGER.debug(String.format("Java binary: %s", javaBinary)); + + try { + List javaCmd = Arrays.asList(javaBinary.toString(), "-cp", NEW_JAR_FILENAME, ApplyUpdate.class.getCanonicalName()); + LOGGER.info(String.format("Applying update with: %s", String.join(" ", javaCmd))); + + new ProcessBuilder(javaCmd).start(); + + return true; // applying update OK + } catch (IOException e) { + LOGGER.error(String.format("Failed to apply update: %s", e.getMessage())); + + try { + Files.deleteIfExists(newJar); + } catch (IOException de) { + LOGGER.warn(String.format("Failed to delete update download: %s", de.getMessage())); + } + + return true; // repo was okay, even if applying update failed + } + } + +}