/* * The contents of this file are subject to the terms of the Common Development and * Distribution License (the License). You may not use this file except in compliance with the * License. * * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the * specific language governing permission and limitations under the License. * * When distributing Covered Software, include this CDDL Header Notice in each file and include * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL * Header, with the fields enclosed by brackets [] replaced by your own identifying * information: "Portions copyright [year] [name of copyright owner]". * * Copyright 2015 ForgeRock AS. */ package org.forgerock.openidm.shell.impl; import static org.forgerock.openidm.shell.impl.UpdateCommand.UpdateStep.ACCEPT_LICENSE; import static org.forgerock.openidm.shell.impl.UpdateCommand.UpdateStep.ENABLE_SCHEDULER; import static org.forgerock.openidm.shell.impl.UpdateCommand.UpdateStep.ENTER_MAINTENANCE_MODE; import static org.forgerock.openidm.shell.impl.UpdateCommand.UpdateStep.EXIT_MAINTENANCE_MODE; import static org.forgerock.openidm.shell.impl.UpdateCommand.UpdateStep.FORCE_RESTART; import static org.forgerock.openidm.shell.impl.UpdateCommand.UpdateStep.INSTALL_ARCHIVE; import static org.forgerock.openidm.shell.impl.UpdateCommand.UpdateStep.PAUSING_SCHEDULER; import static org.forgerock.openidm.shell.impl.UpdateCommand.UpdateStep.PREVIEW_ARCHIVE; import static org.forgerock.openidm.shell.impl.UpdateCommand.UpdateStep.WAIT_FOR_INSTALL_DONE; import static org.forgerock.openidm.shell.impl.UpdateCommand.UpdateStep.WAIT_FOR_JOBS_TO_COMPLETE; import java.io.File; import java.io.FileWriter; import java.io.IOException; import java.io.PrintWriter; import java.text.SimpleDateFormat; import java.util.Date; import java.util.HashMap; import java.util.Map; import java.util.Scanner; import org.apache.felix.service.command.CommandSession; import org.forgerock.json.JsonValue; import org.forgerock.json.resource.ActionResponse; import org.forgerock.json.resource.Requests; import org.forgerock.json.resource.ResourceException; import org.forgerock.json.resource.ResourceResponse; import org.forgerock.services.context.Context; /** * Invoked by the command line interface, this command encapsulates all the logic and steps to perform an update to a * running OpenIDM system. */ public class UpdateCommand { static final String SCHEDULER_ROUTE = "scheduler"; static final String SCHEDULER_ACTION_RESUME_JOBS = "resumeJobs"; static final String SCHEDULER_ACTION_LIST_JOBS = "listCurrentlyExecutingJobs"; static final String SCHEDULER_ACTION_PAUSE = "pauseJobs"; static final String MAINTENANCE_ROUTE = "maintenance"; static final String MAINTENANCE_ACTION_DISABLE = "disable"; static final String MAINTENANCE_ACTION_ENABLE = "enable"; static final String UPDATE_ROUTE = "maintenance/update"; static final String UPDATE_LOG_ROUTE = UPDATE_ROUTE + "/log"; static final String UPDATE_ACTION_AVAIL = "available"; static final String UPDATE_ACTION_GET_LICENSE = "getLicense"; static final String UPDATE_PARAM_ARCHIVE = "archive"; static final String UPDATE_ACTION_UPDATE = "update"; static final String UPDATE_ACTION_RESTART = "restart"; static final String UPDATE_STATUS_FAILED = "FAILED"; static final String UPDATE_STATUS_COMPLETE = "COMPLETE"; static final String UPDATE_STATUS_IN_PROGRESS = "IN_PROGRESS"; private final CommandSession session; private final HttpRemoteJsonResource resource; private final UpdateCommandConfig config; private final Map<UpdateStep, StepExecutor> executorRegistry = new HashMap<>(); private PrintWriter logger; private UpdateStep[] executeSequence; private UpdateStep[] recoverySequence; /** * All steps associated with the update installation process. */ enum UpdateStep { PREVIEW_ARCHIVE, ACCEPT_LICENSE, PAUSING_SCHEDULER, WAIT_FOR_JOBS_TO_COMPLETE, ENTER_MAINTENANCE_MODE, INSTALL_ARCHIVE, WAIT_FOR_INSTALL_DONE, EXIT_MAINTENANCE_MODE, ENABLE_SCHEDULER, FORCE_RESTART } /** * Constructs the UpdateCommand with the default StepExecutors for the Update OpenIDM process. * <br/> * Default Steps include: * <ol> * <li>Lookup the archive data</li> * <li>Accept License</li> * <li>Pause Scheduler</li> * <li>waiting for jobs to complete</li> * <li>Enter maintenance mode.</li> * <li>Install Archive.</li> * <li>Wait for the install to complete.</li> * </ol> * Recovery Steps include: * <ol> * <li>Exit Maintenance mode if needed.</li> * <li>Restart Scheduler if needed.</li> * <li>Force Restart if needed.</li> * </ol> * * Engineering note: To add additional steps: * <ol> * <li>Add the step enum to UpdateStep</li> * <li>Implement a new StepExecutor</li> * <li>register the StepExecutor</li> * <li>insert into the execute sequence or recovery sequence.</li> * </ol> * * @param session the command line session to possibly log output to, or to get keyboard input. * @param resource the resource provider to execute REST calls to OpenIDM. * @param config the configuration provided by the command line parameters. */ public UpdateCommand(CommandSession session, HttpRemoteJsonResource resource, UpdateCommandConfig config) { this.session = session; this.resource = resource; this.config = config; // Register the update steps. registerStepExecutor(new GetArchiveDataStepExecutor()); registerStepExecutor(new AcceptLicenseStepExecutor()); registerStepExecutor(new PauseSchedulerStepExecutor()); registerStepExecutor(new WaitForJobsStepExecutor()); registerStepExecutor(new EnterMaintenanceModeStepExecutor()); registerStepExecutor(new InstallArchiveStepExecutor()); registerStepExecutor(new WaitForInstallDoneStepExecutor()); registerStepExecutor(new ExitMaintenanceModeStepExecutor()); registerStepExecutor(new EnableSchedulerStepExecutor()); registerStepExecutor(new ForceRestartStepExecutor()); // Set the sequence of execution for the steps. setExecuteSequence(PREVIEW_ARCHIVE, ACCEPT_LICENSE, PAUSING_SCHEDULER, WAIT_FOR_JOBS_TO_COMPLETE, ENTER_MAINTENANCE_MODE, INSTALL_ARCHIVE, WAIT_FOR_INSTALL_DONE); // Set the sequence of execution of the recovery steps. setRecoverySequence(EXIT_MAINTENANCE_MODE, ENABLE_SCHEDULER, FORCE_RESTART); } /** * Registers a executor for the step it satisfies. * * @param stepExecutor the executor to register. */ public void registerStepExecutor(StepExecutor stepExecutor) { executorRegistry.put(stepExecutor.getStep(), stepExecutor); } /** * Saves the sequence of steps to execute. * * @param executeSequence the array of steps to complete in the order provided. */ public void setExecuteSequence(UpdateStep... executeSequence) { this.executeSequence = executeSequence; } /** * Saves the sequence of steps to execute once the command has entered recovery mode. * * @param recoverySequence the array of steps to complete in the order provided. */ public void setRecoverySequence(UpdateStep... recoverySequence) { this.recoverySequence = recoverySequence; } /** * For each step in the executeSequence, this executes each registered executor as long as the condition of * the executor is ok to run. When all are complete, or one fails, this will then similarly loop through each * step in the recoverySequence and execute its registered executor. * * @param context The context to pass to the REST calls. * @return the value bean holding the results of the execution run. */ public UpdateExecutionState execute(Context context) { UpdateExecutionState executionResults = new UpdateExecutionState(); openLogger(); try { for (UpdateStep nextStep : executeSequence) { StepExecutor executor = executorRegistry.get(nextStep); if (null != executor && executor.onCondition(executionResults)) { executionResults.setLastAttemptedStep(nextStep); if (!executor.execute(context, executionResults)) { log("ERROR: Error during execution. The state of OpenIDM is now unknown. " + "Last Attempted step was " + executionResults.getLastAttemptedStep() + ". Now attempting recovery steps."); break; } } } } catch (Exception e) { log("ERROR: Error during execution. Last Attempted step was " + executionResults.getLastAttemptedStep() + ". Will now attempt recovery steps.", e); } finally { for (UpdateStep nextStep : recoverySequence) { try { StepExecutor executor = executorRegistry.get(nextStep); if (null != executor && executor.onCondition(executionResults)) { executionResults.setLastRecoveryStep(nextStep); if (!executor.execute(context, executionResults)) { log("WARN: Failed a recovery step " + nextStep + ", continuing on with recovery."); } } } catch (Exception e) { log("ERROR: Error in recovery step " + nextStep + ", continuing on with recovery.", e); } } } return executionResults; } /** * Opens the logger for appending, if the file has been defined in the config. */ private void openLogger() { String logFilePath = config.getLogFilePath(); if (null != logFilePath) { File logFile = new File(logFilePath); try { logFile.getParentFile().mkdirs(); logger = new PrintWriter(new FileWriter(logFile, true)); } catch (IOException e) { throw new IllegalArgumentException("Unable to write to log file at " + logFile.getAbsolutePath(), e); } } } private void log(String message) { if (!config.isQuietMode()) { session.getConsole().println(message); } if (null != logger) { logger.println(SimpleDateFormat.getDateTimeInstance().format(new Date()) + ": " + message); } } private void log(String message, Throwable throwable) { if (!config.isQuietMode()) { throwable.printStackTrace(session.getConsole()); log(message); } if (null != logger) { throwable.printStackTrace(logger); } } /** * Evaluates if the archive requires a restart. As a default, if the archive data is null or if the setting * "restartRequired" is missing from the archive data, then this will return false. The origin of the * restartRequired field is a read from a properties file, therefore the value is treated as a String. * * @param state current state of the execution. * @return true if the "restartRequired" is set to "true" */ private static boolean isRestartRequired(UpdateExecutionState state) { JsonValue archiveData = state.getArchiveData(); return (null != archiveData && "true".equals(archiveData.get("restartRequired").defaultTo("false").asString())); } /** * Defines the interface that each executor must implement. */ private interface StepExecutor { /** * Implementors should return the step that this executor handles. * * @return the step that this executor handles. */ UpdateStep getStep(); /** * Handle the step based on the current state of the command execution. * * @param context context to be utilized for REST calls. * @param state the current state of the command execution. * @return true if the execution ran to a successful outcome. */ boolean execute(Context context, UpdateExecutionState state); /** * Implementors should return true if the conditions are appropriate for the executor to execute. * * @param state the current state of the command execution. * @return true if the conditions are appropriate for the executor to execute. */ boolean onCondition(UpdateExecutionState state); } /** * Retrieves the Archive meta-data and stores it in the UpdateExecutionState bean. */ private class GetArchiveDataStepExecutor implements StepExecutor { /** * {@inheritDoc} */ @Override public UpdateStep getStep() { return PREVIEW_ARCHIVE; } /** * {@inheritDoc} */ @Override public boolean execute(Context context, UpdateExecutionState state) { try { ActionResponse response = resource.action(context, Requests.newActionRequest(UPDATE_ROUTE, UPDATE_ACTION_AVAIL)); String updateArchive = config.getUpdateArchive(); for (JsonValue archiveData : response.getJsonContent()) { if (updateArchive.equals(archiveData.get("archive").asString())) { state.setArchiveData(archiveData); return true; } } log("Archive was not found in the bin/update directory. Requested filename was = " + updateArchive); return false; } catch (ResourceException e) { log("The attempt to lookup the archive meta-data failed.", e); return false; } } /** * {@inheritDoc} */ @Override public boolean onCondition(UpdateExecutionState state) { return true; } } /** * If the licence wasn't already accepted via the command line, then the execute will retrieve the license * from the archive. If found, then the user will be prompted to accept the license terms. * * @see UpdateCommandConfig#isAcceptedLicense() */ private class AcceptLicenseStepExecutor implements StepExecutor { /** * {@inheritDoc} */ @Override public UpdateStep getStep() { return ACCEPT_LICENSE; } /** * {@inheritDoc} */ @Override public boolean execute(Context context, UpdateExecutionState state) { try { // Request the license content ActionResponse response = resource.action(context, Requests.newActionRequest(UPDATE_ROUTE, UPDATE_ACTION_GET_LICENSE) .setAdditionalParameter(UPDATE_PARAM_ARCHIVE, config.getUpdateArchive())); // If the content is found ask to accept. String license = response.getJsonContent().get("license").asString(); if (null == license) { log("No license found to accept in this update archive."); return true; } log("-------------BEGIN LICENSE----------------------------------------------------"); log(license); log("-------------END LICENSE------------------------------------------------------"); log("Do you accept these license terms? (y|n) "); Scanner input = new Scanner(session.getKeyboard()); while (input.hasNext()) { String next = input.next(); if ("y".equals(next)) { return true; } else if ("n".equals(next)) { break; } } log("License was NOT accepted."); return false; } catch (ResourceException e) { log("There was trouble retrieving the license agreement.", e); return false; } } /** * {@inheritDoc} */ @Override public boolean onCondition(UpdateExecutionState state) { if (config.isAcceptedLicense()) { log("License was accepted via command line argument."); return false; } return true; } } /** * This pauses the scheduler. */ private class PauseSchedulerStepExecutor implements StepExecutor { /** * {@inheritDoc} */ @Override public UpdateStep getStep() { return PAUSING_SCHEDULER; } /** * {@inheritDoc} */ @Override public boolean execute(Context context, UpdateExecutionState state) { try { log("Pausing the Scheduler"); ActionResponse response = resource.action(context, Requests.newActionRequest(SCHEDULER_ROUTE, SCHEDULER_ACTION_PAUSE)); // Test if the pause request was successful. if (response.getJsonContent().get("success").defaultTo(false).asBoolean()) { log("Scheduler has been paused."); return true; } else { log("Scheduler could not be paused. Exiting update process."); return false; } } catch (ResourceException e) { log("Error encountered while pausing the job scheduler.", e); return false; } } /** * {@inheritDoc} */ @Override public boolean onCondition(UpdateExecutionState state) { return true; } } /** * This repeatably calls to get the list of running jobs until all jobs have finished. * * @see UpdateCommandConfig#getMaxJobsFinishWaitTimeMs() * @see UpdateCommandConfig#getCheckJobsRunningFrequency() */ private class WaitForJobsStepExecutor implements StepExecutor { /** * {@inheritDoc} */ @Override public UpdateStep getStep() { return WAIT_FOR_JOBS_TO_COMPLETE; } /** * {@inheritDoc} */ @Override public boolean execute(Context context, UpdateExecutionState state) { long start = System.currentTimeMillis(); long maxWaitTime = config.getMaxJobsFinishWaitTimeMs(); boolean jobRunning; boolean timeout = false; log("Waiting for running jobs to finish."); do { try { jobRunning = isJobRunning(context); if (jobRunning) { if (maxWaitTime < 0) { log("Jobs are still running, exiting update process."); return false; } try { log("Waiting for jobs to finish..."); Thread.sleep(config.getCheckJobsRunningFrequency()); } catch (InterruptedException e) { log("WARNING: Got interrupted while waiting for jobs to finish, exiting update process."); return false; } timeout = (System.currentTimeMillis() - start > maxWaitTime); } } catch (ResourceException e) { log("Error encountered while waiting for jobs to finish", e); return false; } } while (jobRunning && !timeout); if (jobRunning) { log("Running jobs did not finish within the allotted wait time of " + maxWaitTime + "ms."); return false; } else { log("All running jobs have finished."); return true; } } /** * {@inheritDoc} */ @Override public boolean onCondition(UpdateExecutionState state) { return true; } /** * Gets the list of running jobs and returns true if the count of jobs is > 0. * * @param context Context of the update. * @return true if no jobs are running. * @throws ResourceException if the call to the router fails. */ private boolean isJobRunning(Context context) throws ResourceException { ActionResponse response = resource.action(context, Requests.newActionRequest(SCHEDULER_ROUTE, SCHEDULER_ACTION_LIST_JOBS)); //return true if more than 1 job is running. return response.getJsonContent().asList().size() > 0; } } /** * Puts OpenIDM into maintenance mode. */ private class EnterMaintenanceModeStepExecutor implements StepExecutor { /** * {@inheritDoc} */ @Override public UpdateStep getStep() { return ENTER_MAINTENANCE_MODE; } /** * {@inheritDoc} */ @Override public boolean execute(Context context, UpdateExecutionState state) { try { log("Entering into maintenance mode..."); // Make the call to enter maintenance mode. ActionResponse response = resource.action(context, Requests.newActionRequest(MAINTENANCE_ROUTE, MAINTENANCE_ACTION_ENABLE)); // Test that we are now in maintenance mode. if (response.getJsonContent().get("maintenanceEnabled").defaultTo(false).asBoolean()) { log("Now in maintenance mode."); return true; } else { log("Failed to enter maintenance mode. Exiting update process."); return false; } } catch (ResourceException e) { log("Error occurred while attempting to enter maintenance mode.", e); return false; } } /** * {@inheritDoc} */ @Override public boolean onCondition(UpdateExecutionState state) { return true; } } /** * Installs the update archive into OpenIDM. */ private class InstallArchiveStepExecutor implements StepExecutor { /** * {@inheritDoc} */ @Override public UpdateStep getStep() { return INSTALL_ARCHIVE; } /** * Invokes the update process with the archive that is expected to be in the update folder. * * @param context Context of the update. * @param state The current state of the execution sequence. */ @Override public boolean execute(Context context, UpdateExecutionState state) { try { log("Installing the update archive " + config.getUpdateArchive()); // Invoke the installation process. ActionResponse response = resource.action(context, Requests.newActionRequest(UPDATE_ROUTE, UPDATE_ACTION_UPDATE) .setAdditionalParameter(UPDATE_PARAM_ARCHIVE, config.getUpdateArchive())); // Read response from install call. JsonValue installResponse = response.getJsonContent(); if (installResponse.get("status").isNull()) { return false; } state.setInstallResponse(installResponse); state.setStartInstallTime(System.currentTimeMillis()); return true; } catch (ResourceException e) { log("Error encountered while installing the update archive.", e); return false; } } /** * {@inheritDoc} */ @Override public boolean onCondition(UpdateExecutionState state) { return true; } } /** * This will repeatably check the update installation status until it times out or returns COMPLETE or FAILED. * * @see UpdateCommandConfig#getMaxUpdateWaitTimeMs() * @see UpdateCommandConfig#getCheckCompleteFrequency() */ private class WaitForInstallDoneStepExecutor implements StepExecutor { /** * {@inheritDoc} */ @Override public UpdateStep getStep() { return WAIT_FOR_INSTALL_DONE; } /** * {@inheritDoc} */ @Override public boolean execute(Context context, UpdateExecutionState state) { JsonValue installResponse = state.getInstallResponse(); long startTime = state.getStartInstallTime(); if (null == installResponse || startTime <= 0 || installResponse.get("status").isNull()) { throw new IllegalStateException( "Install start time or Initial install status from install step is missing. Ensure the step " + INSTALL_ARCHIVE + " was completed"); } String status = installResponse.get("status").defaultTo(UPDATE_STATUS_IN_PROGRESS).asString().toUpperCase(); String updateId = installResponse.get(ResourceResponse.FIELD_CONTENT_ID).asString(); try { boolean timeout = false; while (!timeout && !UPDATE_STATUS_COMPLETE.equals(status) && !UPDATE_STATUS_FAILED.equals(status)) { log("Update procedure is still processing..."); // Wait for the installation process to make some progress. try { Thread.sleep(config.getCheckCompleteFrequency()); } catch (InterruptedException e) { //ignore interruption and just check status. } // Query the status of the installation process. ResourceResponse response = resource.read(context, Requests.newReadRequest(UPDATE_LOG_ROUTE, updateId)); timeout = (System.currentTimeMillis() - startTime > config.getMaxUpdateWaitTimeMs()); status = response.getContent().get("status").defaultTo(UPDATE_STATUS_IN_PROGRESS) .asString().toUpperCase(); } if (UPDATE_STATUS_COMPLETE.equals(status) || UPDATE_STATUS_FAILED.equals(status)) { state.setCompletedInstallStatus(status); log("The update process is complete with a status of " + status); return true; } else { log("The update process failed to complete within the allotted time. " + "Please verify the state of OpenIDM."); return false; } } catch (ResourceException e) { log("Error encountered while checking status of install. The update might still be in process", e); return false; } } /** * {@inheritDoc} */ @Override public boolean onCondition(UpdateExecutionState state) { return true; } } /** * Only if the archive didn't need a restart, then this executor will request OpenIDM to exit maintenance mode. */ private class ExitMaintenanceModeStepExecutor implements StepExecutor { /** * {@inheritDoc} */ @Override public UpdateStep getStep() { return EXIT_MAINTENANCE_MODE; } /** * {@inheritDoc} */ @Override public boolean execute(Context context, UpdateExecutionState state) { try { log("Exiting maintenance mode..."); // Make the call to exit maintenance mode. ActionResponse response = resource.action(context, Requests.newActionRequest(MAINTENANCE_ROUTE, MAINTENANCE_ACTION_DISABLE)); // Test that we are no longer in maintenance mode. if (response.getJsonContent().get("maintenanceEnabled").defaultTo(false).asBoolean()) { log("Failed to exit maintenance mode. Exiting update process."); return false; } else { log("No longer in maintenance mode."); return true; } } catch (ResourceException e) { log("Error encountered while exiting maintenance mode.", e); return false; } } /** * {@inheritDoc} * * @return implemented to return true if the archive data is null or doesn't need to restart and therefore we * should exit maintenance mode and if the archive data is null. */ public boolean onCondition(UpdateExecutionState state) { return !isRestartRequired(state); } } /** * Only if the archive didn't need a restart, then this will request OpenIDM to resume the scheduler. */ private class EnableSchedulerStepExecutor implements StepExecutor { /** * {@inheritDoc} */ @Override public UpdateStep getStep() { return ENABLE_SCHEDULER; } /** * {@inheritDoc} */ @Override public boolean execute(Context context, UpdateExecutionState state) { try { log("Resuming the job scheduler."); ActionResponse response = resource.action(context, Requests.newActionRequest(SCHEDULER_ROUTE, SCHEDULER_ACTION_RESUME_JOBS)); // Pull the success value from the response. if (response.getJsonContent().get("success").defaultTo(false).asBoolean()) { log("Scheduler has been resumed."); return true; } else { log("WARN: A successful request was made to resume the scheduler, " + "but it appears to not have restarted."); return false; } } catch (ResourceException e) { log("Trouble attempting to resume scheduled jobs. Please check that the scheduler is resumed.", e); return false; } } /** * {@inheritDoc} * * @return implemented to return true if the archive data is null or doesn't need to restart and therefore we * should exit maintenance mode and if the archive data is null. */ public boolean onCondition(UpdateExecutionState state) { return !isRestartRequired(state); } } /** * Only if the archive DID need a restart do we request a forced immediate restart of OpenIDM. * Without this step, OpenIDM would wait 30 seconds before restarting on its own. */ private class ForceRestartStepExecutor implements StepExecutor { /** * {@inheritDoc} */ @Override public UpdateStep getStep() { return FORCE_RESTART; } /** * {@inheritDoc} */ @Override public boolean execute(Context context, UpdateExecutionState state) { try { log("Restarting OpenIDM."); // Invoke the restart. resource.action(context, Requests.newActionRequest(UPDATE_ROUTE, UPDATE_ACTION_RESTART)); log("Restart request completed."); return true; // } catch (ResourceException e) { log("Error encountered while requesting the restart of OpenIDM.", e); return false; } } /** * {@inheritDoc} * If the archive data is null, then it means that the archive file wasn't found to install. No need to restart. * * @return implemented to return true if the archive data is null or does need a restart. */ @Override public boolean onCondition(UpdateExecutionState state) { return isRestartRequired(state); } } }